feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

@@ -10,9 +10,17 @@ const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..
const conciergePagePath = path.join(adminRoot, 'src/pages/ai/concierge/index.tsx');
const conciergeLocalePath = path.join(adminRoot, 'src/pages/ai/concierge/locale/index.ts');
function readPage(): string {
return fs.readFileSync(conciergePagePath, 'utf-8');
}
function readLocale(): string {
return fs.readFileSync(conciergeLocalePath, 'utf-8');
}
test('1. ai/concierge 页面必须使用 Arco Form/Select/Slider/Switch/Card/Radio 并接入 API', () => {
assert.ok(fs.existsSync(conciergePagePath), 'AI 顾问配置页面必须存在');
const content = fs.readFileSync(conciergePagePath, 'utf-8');
const content = readPage();
assert.ok(
content.includes('admin/ai/concierge'),
@@ -38,7 +46,7 @@ test('1. ai/concierge 页面必须使用 Arco Form/Select/Slider/Switch/Card/Rad
});
test('2. ai/concierge 必须遵循设计 Token,禁止 emoji 与硬编码颜色,支持预设人设与阈值', () => {
const content = fs.readFileSync(conciergePagePath, 'utf-8');
const content = readPage();
assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
@@ -75,7 +83,7 @@ test('3. ai/concierge 路由与国际化配置必须齐备', () => {
// 检查语言包
assert.ok(fs.existsSync(conciergeLocalePath), '页面语言包必须存在');
const localeContent = fs.readFileSync(conciergeLocalePath, 'utf-8');
const localeContent = readLocale();
assert.ok(localeContent.includes("'en-US'"), '必须包含英文案');
assert.ok(localeContent.includes("'zh-CN'"), '必须包含中文案');
assert.ok(localeContent.includes("'menu.ai.concierge'"), '页面语言包必须包含 menu.ai.concierge');
@@ -91,3 +99,116 @@ test('3. ai/concierge 路由与国际化配置必须齐备', () => {
'src/locale/index.ts 必须包含 zh-CN menu.ai.concierge'
);
});
test('4. 保存 / 人设 / DeepSeek 等操作仅在 code===1000 且有 data 时 Message.success', () => {
const content = readPage();
const localeContent = readLocale();
assert.ok(content.includes('Message.success'), '成功时仍应使用 Message.success');
assert.ok(content.includes('Message.error'), '失败必须使用 Message.error');
// 每个 Message.success 必须出现在 code === 1000 成功分支内,且附近要求 data
const successMatches = [...content.matchAll(/Message\.success\s*\(/g)];
assert.ok(successMatches.length > 0, '至少应有一处成功提示');
for (const match of successMatches) {
const idx = match.index ?? 0;
const windowStart = Math.max(0, idx - 360);
const preceding = content.slice(windowStart, idx);
assert.ok(
/code\s*===\s*1000/.test(preceding),
`Message.success 必须位于 code === 1000 分支内,附近片段: ${preceding.slice(-100)}`
);
assert.ok(
/\.data\b|data\s*&&|&&\s*.*data/.test(preceding),
`Message.success 前须校验 data 存在,附近片段: ${preceding.slice(-100)}`
);
}
// 保存接口必须同时校验 code 与 data
assert.ok(
/saveByCardId[\s\S]{0,800}code\s*===\s*1000[\s\S]{0,120}data/.test(content) ||
/SAVE_CONFIG_API[\s\S]{0,800}code\s*===\s*1000[\s\S]{0,120}data/.test(
content
),
'保存顾问配置须 code === 1000 且有 data'
);
// 人设切换仅填充表单预览,不得 Message.success 假装已落库
assert.ok(
content.includes('handlePersonaChange'),
'必须存在人设切换处理'
);
assert.ok(
!/handlePersonaChange[\s\S]{0,500}Message\.success/.test(content),
'人设切换不得 Message.success 假装已保存,须经保存接口'
);
// FAQ:无独立保存接口时禁止假成功,须提示暂不可用
const hasFaqApi =
/admin\/ai\/concierge\/(faq|saveFaq|upsertFaq)/i.test(content) ||
/FAQ_SAVE_API|SAVE_FAQ_API/i.test(content);
assert.ok(
localeContent.includes("'aiConcierge.faq.unavailable'"),
'locale 必须包含 aiConcierge.faq.unavailable'
);
assert.ok(
content.includes("t['aiConcierge.faq.unavailable']") ||
content.includes('aiConcierge.faq.unavailable'),
'页面必须引用 FAQ 暂不可用文案'
);
if (!hasFaqApi) {
assert.ok(
!/Message\.success[\s\S]{0,80}faq|Message\.success[\s\S]{0,80}FAQ/i.test(
content
),
'无 FAQ 接口时禁止 Message.success 假装 FAQ 已保存'
);
assert.ok(
/disabled[\s\S]{0,160}faq|faq[\s\S]{0,200}disabled|aiConcierge\.faq[\s\S]{0,200}disabled/i.test(
content
) ||
content.includes("t['aiConcierge.faq.unavailable']"),
'无 FAQ 接口时须禁用相关操作或提示暂不可用'
);
}
assert.ok(
localeContent.includes("'aiConcierge.request.success'"),
'locale 须含保存成功文案'
);
assert.ok(
localeContent.includes("'aiConcierge.request.saveFail'"),
'locale 须含保存失败文案'
);
});
test('5. 失败分支不得 Message.success,且不得本地假装已保存', () => {
const content = readPage();
// catch / else 失败分支附近不得出现 Message.success
const failBlocks = [
...content.matchAll(
/catch\s*(?:\([^)]*\))?\s*\{[\s\S]{0,280}\}|else\s*\{[\s\S]{0,280}\}/g
),
];
for (const block of failBlocks) {
const text = block[0];
if (/Message\.error/.test(text) || /setLoading|setSaving|setDeepSeek/.test(text)) {
assert.ok(
!/Message\.success/.test(text),
`失败分支内不得 Message.success: ${text.slice(0, 120)}`
);
}
}
// 保存失败后不得用本地假数据冒充已保存成功提示
assert.ok(
!/catch[\s\S]{0,200}Message\.success/.test(content),
'catch 失败后禁止 Message.success'
);
assert.ok(
!/code\s*!==\s*1000[\s\S]{0,120}Message\.success/.test(content),
'code 非 1000 时禁止 Message.success'
);
});

查看文件

@@ -5,58 +5,159 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const conciergePagePath = path.join(adminRoot, 'src/pages/ai/concierge/index.tsx');
const conciergeLocalePath = path.join(adminRoot, 'src/pages/ai/concierge/locale/index.ts');
const omniTypesPath = path.join(adminRoot, 'src/types/omni.ts');
const deepseekPagePath = path.join(adminRoot, 'src/pages/ai/deepseek/index.tsx');
const deepseekLocalePath = path.join(adminRoot, 'src/pages/ai/deepseek/locale/index.ts');
test('1. DeepSeek 大模型后台界面必须接入标准 API 与连通性测试端点', () => {
assert.ok(fs.existsSync(conciergePagePath), 'AI 页面文件必须存在');
const content = fs.readFileSync(conciergePagePath, 'utf-8');
/** 账单式误导词(标题/指标/描述里禁止正向使用;disclaimer 可否定提及) */
const BILLING_MISLEAD_RE =
/账本|账单|已出账|对账|划扣|本月账单|invoice|billed|billing|pay-as-you-go|ledger|reconcil/i;
/** 前端不得填入的示例消耗数字 */
const SAMPLE_USAGE_RE =
/totalTokens:\s*[1-9]\d*|promptTokens:\s*[1-9]\d*|completionTokens:\s*[1-9]\d*|callCount:\s*[1-9]\d*/;
function readPage(): string {
assert.ok(fs.existsSync(deepseekPagePath), 'DeepSeek 配置页必须存在');
return fs.readFileSync(deepseekPagePath, 'utf-8');
}
function readLocale(): string {
assert.ok(fs.existsSync(deepseekLocalePath), 'DeepSeek 语言包必须存在');
return fs.readFileSync(deepseekLocalePath, 'utf-8');
}
/** 去掉 usage.notice 词条后再扫误导词,避免「不是账单」说明被误伤 */
function localeWithoutUsageNotice(locale: string): string {
return locale.replace(
/'ai\.deepseek\.usage\.notice'\s*:\s*(?:'(?:\\'|[^'])*'|"(?:\\"|[^"])*"|`(?:\\`|[^`])*`)\s*,?/g,
''
);
}
test('1. DeepSeek 页必须请求 usage,且无用量时回落 0 / 空,禁止示例消耗', () => {
const content = readPage();
assert.ok(
content.includes('/api/admin/ai/deepseek/usage'),
'必须请求 POST /api/admin/ai/deepseek/usage'
);
assert.ok(
content.includes('/api/admin/ai/deepseek/config'),
'必须包含 /api/admin/ai/deepseek/config 配置端点'
'必须请求配置端点'
);
assert.ok(
content.includes('/api/admin/ai/deepseek/test-connection'),
'必须包含 /api/admin/ai/deepseek/test-connection 连通性测试端点'
content.includes("from '@arco-design/web-react'"),
'必须从 @arco-design/web-react 引入组件'
);
assert.ok(content.includes('deepseek-chat'), '必须支持 deepseek-chat (V3)');
assert.ok(content.includes('deepseek-reasoner'), '必须支持 deepseek-reasoner (R1)');
assert.ok(content.includes('handleTestDeepSeek'), '必须提供连通性测试处理函数');
assert.ok(content.includes('handleSaveDeepSeek'), '必须提供保存配置处理函数');
});
assert.ok(content.includes('Statistic'), '必须用 Statistic 展示用量');
assert.ok(content.includes('Alert'), '必须用 Alert 标明进程内性质');
test('2. DeepSeek 大模型跨端类型契约必须在 admin/src/types/omni.ts 中严密定义', () => {
assert.ok(fs.existsSync(omniTypesPath), 'omni.ts 类型契约必须存在');
const content = fs.readFileSync(omniTypesPath, 'utf-8');
assert.ok(content.includes('DeepSeekModel'), '必须导出 DeepSeekModel 类型');
assert.ok(content.includes('DeepSeekConfig'), '必须导出 DeepSeekConfig 接口');
assert.ok(content.includes('DeepSeekChatMessage'), '必须导出 DeepSeekChatMessage 接口');
assert.ok(
content.includes('DeepSeekTestConnectionResult'),
'必须导出 DeepSeekTestConnectionResult 接口'
/totalTokens:\s*0/.test(content) &&
/promptTokens:\s*0/.test(content) &&
/completionTokens:\s*0/.test(content) &&
/callCount:\s*0/.test(content),
'初始用量必须为 0,不得填示例消耗'
);
assert.ok(!SAMPLE_USAGE_RE.test(content), '禁止硬编码非零示例 Token 消耗');
assert.ok(
content.includes('usageRes.data?.data') || content.includes('usageRes.data.data'),
'仅当接口返回 data 时才写入用量'
);
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
assert.ok(
!content.includes('Mock.js') && !content.includes("from 'mockjs'"),
'禁止用 Mock.js 假用量'
);
});
test('3. DeepSeek 多语言国际化必须覆盖中英阿且无 emoji 无 any', () => {
assert.ok(fs.existsSync(conciergeLocalePath), '语言包必须存在');
const localeContent = fs.readFileSync(conciergeLocalePath, 'utf-8');
test('2. Token 用量文案必须标明进程内统计,禁止账单式误导', () => {
const page = readPage();
const locale = readLocale();
assert.ok(localeContent.includes("'aiDeepSeek.card.title'"), '必须包含卡片标题词条');
assert.ok(localeContent.includes("'aiDeepSeek.form.apiKey'"), '必须包含 API Key 表单项');
assert.ok(localeContent.includes("'aiDeepSeek.form.model'"), '必须包含模型选择项');
assert.ok(localeContent.includes("'aiDeepSeek.action.test'"), '必须包含测试连通性按钮词条');
assert.ok(localeContent.includes("'aiDeepSeek.test.success'"), '必须包含测试成功提示');
assert.ok(
locale.includes("'ai.deepseek.usage.cardTitle'") ||
locale.includes('"ai.deepseek.usage.cardTitle"'),
'必须有用量卡片标题词条'
);
assert.ok(
locale.includes("'ai.deepseek.usage.notice'") ||
locale.includes('"ai.deepseek.usage.notice"'),
'必须有进程内用量说明词条'
);
assert.ok(
page.includes("t['ai.deepseek.usage.cardTitle']") ||
page.includes('t["ai.deepseek.usage.cardTitle"]'),
'卡片标题必须走 locale,禁止硬编码「账本」'
);
assert.ok(
page.includes("t['ai.deepseek.usage.notice']") ||
page.includes('t["ai.deepseek.usage.notice"]'),
'进程内说明必须走 locale'
);
// 三语检查
assert.ok(localeContent.includes("'en-US'"), '必须支持 en-US');
assert.ok(localeContent.includes("'zh-CN'"), '必须支持 zh-CN');
assert.ok(localeContent.includes("'ar-SA'"), '必须支持 ar-SA');
assert.ok(
/进程内|本次进程/.test(locale),
'中文词条必须标明「进程内 / 本次进程」'
);
assert.ok(
/process|in-process|current process|this process/i.test(locale),
'英文词条必须标明 process / in-process'
);
// disclaimer 必须明确否定账单语义
assert.ok(
/不是已出账|不是.*账单|非.*账单/.test(locale) ||
/not billed|not .*invoice|not .*reconcil/i.test(locale),
'usage.notice 必须明确否定已出账/账单/可对账语义'
);
const localeBody = localeWithoutUsageNotice(locale);
assert.ok(
!BILLING_MISLEAD_RE.test(localeBody),
'locale 标题/指标/描述禁止账单式误导词(notice 除外)'
);
assert.ok(!BILLING_MISLEAD_RE.test(page), '页面禁止账单式硬编码文案');
for (const key of [
'ai.deepseek.stats.totalTokens',
'ai.deepseek.stats.quotaProgress',
'ai.deepseek.stats.unlimited',
'ai.deepseek.desc',
]) {
assert.ok(locale.includes(`'${key}'`) || locale.includes(`"${key}"`), `必须包含 ${key}`);
}
});
test('3. DeepSeek locale 覆盖中英,无 emoji,用量词条可被页面引用', () => {
const locale = readLocale();
const page = readPage();
assert.ok(locale.includes("'en-US'"), '必须支持 en-US');
assert.ok(locale.includes("'zh-CN'"), '必须支持 zh-CN');
// 禁止 emoji
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
assert.ok(!emojiRegex.test(localeContent), '语言包严禁使用 emoji');
assert.ok(!/\bany\b/.test(localeContent), '语言包严禁使用 any');
assert.ok(!emojiRegex.test(locale), '语言包严禁使用 emoji');
assert.ok(!/\bany\b/.test(locale), '语言包严禁使用 any');
assert.ok(
page.includes("t['ai.deepseek.stats.totalTokens']") ||
page.includes('t["ai.deepseek.stats.totalTokens"]'),
'总量标题必须走 locale'
);
assert.ok(
page.includes("t['ai.deepseek.stats.promptTokens']") ||
page.includes('t["ai.deepseek.stats.promptTokens"]'),
'Prompt 标题必须走 locale'
);
assert.ok(
page.includes("t['ai.deepseek.stats.completionTokens']") ||
page.includes('t["ai.deepseek.stats.completionTokens"]'),
'Completion 标题必须走 locale'
);
assert.ok(
page.includes("t['ai.deepseek.stats.callCount']") ||
page.includes('t["ai.deepseek.stats.callCount"]'),
'调用次数标题必须走 locale'
);
});

查看文件

@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import defaultI18n from '../src/locale/index.ts';
import knowledgeI18n from '../src/pages/ai/knowledge/locale/index.ts';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const knowledgePagePath = path.join(adminRoot, 'src/pages/ai/knowledge/index.tsx');
@@ -99,6 +99,147 @@ test('3. ai/knowledge locale 必须包含 menu.ai.knowledge 与中英文案', ()
assert.ok(content.includes('aiKnowledge.filter.keyWord'), '必须包含 keyWord 筛选文案');
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
assert.equal(defaultI18n['en-US']['menu.ai.knowledge'], 'Knowledge');
assert.equal(defaultI18n['zh-CN']['menu.ai.knowledge'], '知识库');
assert.equal(knowledgeI18n['en-US']['menu.ai.knowledge'], 'Knowledge');
assert.equal(knowledgeI18n['zh-CN']['menu.ai.knowledge'], '知识库');
assert.ok(
/keyword overlap/i.test(knowledgeI18n['en-US']['aiKnowledge.sandbox.desc']),
'英文沙盒说明须含 keyword overlap'
);
assert.ok(
knowledgeI18n['zh-CN']['aiKnowledge.sandbox.desc'].includes('关键词重叠召回'),
'中文沙盒说明须含关键词重叠召回'
);
assert.ok(
/تداخل الكلمات المفتاحية/.test(
knowledgeI18n['ar-SA']['aiKnowledge.sandbox.desc']
),
'阿语沙盒说明须含 تداخل الكلمات المفتاحية'
);
});
test('4. 沙盒文案必须写关键词重叠召回,禁止伪装向量库/命中率', () => {
const page = fs.readFileSync(knowledgePagePath, 'utf-8');
const i18n = fs.readFileSync(knowledgeLocalePath, 'utf-8');
assert.ok(i18n.includes("'ar-SA'"), '必须包含阿语文案');
assert.ok(
i18n.includes('aiKnowledge.sandbox.desc'),
'必须包含沙盒说明文案'
);
assert.ok(
i18n.includes('aiKnowledge.sandbox.recallCount'),
'必须包含召回条数文案'
);
// 中英文必须明确「关键词重叠」召回,禁止向量库表述
assert.match(
i18n,
/关键词重叠召回/,
'中文沙盒说明必须写关键词重叠召回'
);
assert.match(
i18n,
/keyword overlap/i,
'英文沙盒说明必须写 keyword overlap'
);
assert.match(
i18n,
/تداخل الكلمات|تداخل المفردات|تداخل الكلمات المفتاحية/,
'阿语沙盒说明须用自然的关键词重叠产品文案'
);
assert.ok(!/vector|embedding|向量库|向量召回|向量命中|向量相似度/i.test(i18n), 'locale 禁止向量库/向量召回表述');
assert.ok(!/simulated Top-K|模拟召回|向量命中/i.test(i18n), 'locale 禁止伪装模拟向量 Top-K');
assert.ok(!/vector|embedding|向量库|向量召回|向量命中/i.test(page), '页面禁止向量库/向量召回表述');
// 禁止伪造命中率百分比 Progress;改为展示召回条数
assert.ok(!page.includes('<Progress'), '禁止用 Progress 展示伪造命中率百分比');
assert.ok(!page.includes('calculatePercent'), '禁止把 score 换算成假命中率百分比');
assert.ok(
page.includes('aiKnowledge.sandbox.recallCount') &&
page.includes('recallResults.length'),
'有召回结果时必须展示召回条数'
);
assert.ok(
page.includes('sandbox-test') && page.includes('chunkSize') && page.includes('overlap'),
'不得改动沙盒请求字段(chunkSize/overlap 等)'
);
});
test('5. 知识库须提供 URL 录入;无真实抓取接口时按钮禁用且禁止假切片', () => {
const page = fs.readFileSync(knowledgePagePath, 'utf-8');
const i18n = fs.readFileSync(knowledgeLocalePath, 'utf-8');
assert.ok(
i18n.includes("'aiKnowledge.form.url'") || i18n.includes('aiKnowledge.form.url'),
'locale 必须包含 URL 录入文案'
);
assert.ok(
i18n.includes("'aiKnowledge.crawl.unavailable'") ||
i18n.includes('aiKnowledge.crawl.unavailable'),
'locale 必须说明暂不可抓取'
);
assert.ok(
knowledgeI18n['zh-CN']['aiKnowledge.crawl.unavailable'].includes('暂不可'),
'中文须说明暂不可抓取'
);
assert.ok(
/unavailable|not available|not supported/i.test(
knowledgeI18n['en-US']['aiKnowledge.crawl.unavailable']
),
'英文须说明抓取暂不可用'
);
assert.ok(
page.includes("t['aiKnowledge.form.url']") || page.includes('aiKnowledge.form.url'),
'页面必须出现 URL 录入'
);
assert.ok(
page.includes("t['aiKnowledge.crawl.unavailable']") ||
page.includes('aiKnowledge.crawl.unavailable'),
'页面必须展示暂不可抓取说明'
);
const hasCrawlApi =
/admin\/ai\/knowledge\/(crawl|fetch|scrape|ingest-url|fetch-url|import-url)/i.test(
page
) || /CRAWL_API|FETCH_URL_API|SCRAPE_API|INGEST_URL_API/i.test(page);
if (hasCrawlApi) {
assert.ok(
page.includes('code === 1000') || page.includes('code ===1000'),
'有抓取接口时必须按 code === 1000 判断成功'
);
assert.ok(
!/Message\.success[\s\S]{0,80}crawl|Message\.success[\s\S]{0,80}fetch/i.test(
page
) || page.includes('code === 1000'),
'失败时禁止 Message.success'
);
} else {
assert.ok(
/crawl[\s\S]{0,200}disabled|disabled[\s\S]{0,200}crawl|aiKnowledge\.crawl[\s\S]{0,120}disabled/i.test(
page
) ||
(page.includes('disabled') &&
(page.includes('aiKnowledge.crawl') || page.includes('crawl'))),
'无真实抓取接口时抓取按钮必须禁用'
);
assert.ok(
!/fakeChunk|mockChunk|假切片|假命中|simulatedChunk|dummyChunk/i.test(page),
'禁止生成假切片或假命中'
);
assert.ok(
!page.includes('Math.random') && !page.includes('mockjs'),
'禁止用随机数或 Mock 伪造抓取切片'
);
assert.ok(
!/handleCrawl|fetchWebsite|scrapeUrl|ingestUrl/i.test(page),
'无真实接口时禁止手写抓取处理函数写假正文'
);
assert.ok(
!/setFieldValue\(\s*['"]content['"][\s\S]{0,80}url/i.test(page) &&
!/setSandboxContent\([\s\S]{0,80}url/i.test(page),
'禁止把 URL 录入伪装成抓取正文或沙盒切片'
);
}
});

查看文件

@@ -118,3 +118,142 @@ test('4. 必须遵守瑞士国际主义设计规范与 Token 约束 (零 emoji,
'瑞士国际主义风格约束:整屏只允许 1 个主 CTA (type="primary")'
);
});
function readTranslationPage(): string {
return fs.readFileSync(translationPagePath, 'utf-8');
}
function readTranslationLocale(): string {
return fs.readFileSync(translationLocalePath, 'utf-8');
}
test('5. R17: 术语导入 / 润色 / 保存仅在 code===1000 时 Message.success,文案走 locale', () => {
const content = readTranslationPage();
const localeContent = readTranslationLocale();
assert.ok(content.includes('Message.success'), '真实成功仍应 Message.success');
assert.ok(content.includes('Message.error'), '失败必须 Message.error');
// 每个 Message.success 必须出现在 code === 1000 成功分支内
const successMatches = [...content.matchAll(/Message\.success\s*\(/g)];
assert.ok(successMatches.length > 0, '至少应有一处成功提示');
for (const match of successMatches) {
const idx = match.index ?? 0;
const preceding = content.slice(Math.max(0, idx - 400), idx);
assert.ok(
/code\s*===\s*1000/.test(preceding),
`Message.success 必须位于 code === 1000 分支内,附近片段: ${preceding.slice(-100)}`
);
}
// 润色:code===1000 且有 data 才成功
assert.ok(
/LOCALIZE_API|localize\/localize[\s\S]{0,900}code\s*===\s*1000[\s\S]{0,160}Message\.success/.test(
content
) ||
/handlePolishSubmit[\s\S]{0,900}code\s*===\s*1000[\s\S]{0,200}Message\.success/.test(
content
),
'润色提交须在 code === 1000 后 Message.success'
);
assert.ok(
/code\s*===\s*1000\s*&&\s*.*data|res\.code\s*===\s*1000\s*&&\s*res\.data/.test(content),
'润色成功须同时校验 code === 1000 与 data'
);
// 术语保存:add/update 仅在 code===1000 成功
assert.ok(
/handleSaveTerm[\s\S]{0,900}code\s*===\s*1000[\s\S]{0,160}Message\.success/.test(content),
'术语保存须在 code === 1000 后 Message.success'
);
// 术语导入:batchImport 仅在 code===1000 成功
assert.ok(
/batchImport|GLOSSARY_IMPORT_API|handleBatchImportFile[\s\S]{0,900}code\s*===\s*1000[\s\S]{0,160}Message\.success/.test(
content
),
'术语导入须在 code === 1000 后 Message.success'
);
// 成功/失败文案走 locale
for (const key of [
'aiTranslation.playground.success',
'aiTranslation.request.saveSuccess',
'aiTranslation.request.importSuccess',
'aiTranslation.request.importFail',
'aiTranslation.request.fail',
]) {
assert.ok(localeContent.includes(`'${key}'`), `locale 必须包含 ${key}`);
assert.ok(
content.includes(`t['${key}']`) || content.includes(key),
`页面必须引用 ${key}`
);
}
});
test('6. R17: 失败 / 空文件 / code 非 1000 不得 Message.success,禁止本地插入假术语', () => {
const content = readTranslationPage();
// catch / else 失败分支附近不得出现 Message.success
const failBlocks = [
...content.matchAll(
/catch\s*(?:\([^)]*\))?\s*\{[\s\S]{0,320}\}|else\s*\{[\s\S]{0,320}\}/g
),
];
for (const block of failBlocks) {
const text = block[0];
if (/Message\.error/.test(text)) {
assert.ok(
!/Message\.success/.test(text),
`失败分支内不得 Message.success: ${text.slice(0, 120)}`
);
}
}
assert.ok(
!/catch[\s\S]{0,220}Message\.success/.test(content),
'catch 失败后禁止 Message.success'
);
assert.ok(
!/code\s*!==\s*1000[\s\S]{0,140}Message\.success/.test(content),
'code 非 1000 时禁止 Message.success'
);
// 空文件:须显式拦截并用 Message.error,不得 success
assert.ok(
/file\.size\s*===\s*0|!file\.size|Number\(file\.size\)\s*===\s*0/.test(content),
'空文件须显式校验 file.size === 0'
);
assert.ok(
/file\.size[\s\S]{0,220}Message\.error/.test(content) ||
/size\s*===\s*0[\s\S]{0,220}Message\.error/.test(content),
'空文件须 Message.error,不得 Message.success'
);
assert.ok(
/!Array\.isArray|Array\.isArray\(parsed\)/.test(content),
'导入须校验 JSON 为数组,非法结构不得假装成功'
);
assert.ok(
/Array\.isArray\(parsed\)[\s\S]{0,180}Message\.error|!Array\.isArray\(parsed\)[\s\S]{0,120}Message\.error/.test(
content
),
'非数组导入须 Message.error'
);
// 禁止本地乐观插入假术语(未过接口就 setGlossaryData 追加)
assert.ok(
!/setGlossaryData\(\s*\[[\s\S]{0,80}\.\.\.[\s\S]{0,80}glossaryData/.test(content) &&
!/setGlossaryData\(\s*\[[\s\S]{0,120}sourceTerm/.test(content),
'禁止本地插入假术语,导入/保存成功后应 refreshGlossary 拉真实数据'
);
assert.ok(
/refreshGlossary|setReloadTick/.test(content),
'导入/保存成功后须刷新术语列表'
);
// code 非 1000 分支须 Message.error
assert.ok(
/Message\.error\(\s*res\.message\s*\|\|/.test(content),
'code 非 1000 须 Message.error(res.message || fallback)'
);
});

查看文件

@@ -105,12 +105,54 @@ test('4. card/articles 语言包必须包含中英文案与 OpenGraph 预览词
assert.ok(content.includes("'en-US'"), '必须包含 en-US');
assert.ok(content.includes("'zh-CN'"), '必须包含 zh-CN');
assert.ok(content.includes("'ar-SA'"), '必须包含 ar-SA');
assert.ok(content.includes('cardArticles.title'), '必须包含页面标题文案');
assert.ok(content.includes('cardArticles.action.add'), '必须包含新增文章主 CTA 文案');
assert.ok(content.includes('cardArticles.ogPreview.title'), '必须包含 OG 社交预览卡文案');
assert.ok(
content.includes('cardArticles.form.cardId.placeholder'),
'必须包含名片编号占位文案'
);
assert.ok(
content.includes('cardArticles.form.title.placeholder'),
'必须包含标题占位文案'
);
assert.ok(
content.includes('cardArticles.form.summary.placeholder'),
'必须包含摘要占位文案'
);
assert.ok(
content.includes('cardArticles.form.content.placeholder'),
'必须包含正文占位文案'
);
assert.ok(!/\bany\b/.test(content), '语言包禁止使用 any');
});
test('4b. card/articles 表单占位必须走 locale,禁止硬编码中文 placeholder', () => {
const content = fs.readFileSync(articlesPagePath, 'utf-8');
assert.ok(
content.includes("t['cardArticles.form.cardId.placeholder']"),
'cardId 占位必须使用 locale'
);
assert.ok(
content.includes("t['cardArticles.form.title.placeholder']"),
'标题占位必须使用 locale'
);
assert.ok(
content.includes("t['cardArticles.form.summary.placeholder']"),
'摘要占位必须使用 locale'
);
assert.ok(
content.includes("t['cardArticles.form.content.placeholder']"),
'正文占位必须使用 locale'
);
assert.ok(
!/placeholder=["'][^"']*[\u4e00-\u9fff]/.test(content),
'禁止硬编码含中文的 placeholder'
);
assert.ok(!content.includes('placeholder="101"'), '禁止硬编码 cardId 示例占位');
});
test('5. card/articles 必须支持 OpenGraph 社交卡片多比例切换 (1.91:1 / 1:1)', () => {
const content = fs.readFileSync(articlesPagePath, 'utf-8');
assert.ok(content.includes('Radio'), '必须使用 Arco Radio 组件进行卡片比例切换');
@@ -120,3 +162,50 @@ test('5. card/articles 必须支持 OpenGraph 社交卡片多比例切换 (1.91:
assert.ok(localeContent.includes('cardArticles.ogPreview.landscape'), '语言包必须包含横版比例文案');
assert.ok(localeContent.includes('cardArticles.ogPreview.square'), '语言包必须包含方版比例文案');
});
test('6. 无封面时 OG 预览必须显示 locale 空图占位,禁止默认假图/假 CDN/示例封面', () => {
const content = fs.readFileSync(articlesPagePath, 'utf-8');
const localeContent = fs.readFileSync(articlesLocalePath, 'utf-8');
assert.ok(
content.includes("t['cardArticles.ogPreview.emptyImage']"),
'无封面时必须使用 locale 空图占位文案'
);
assert.ok(!content.includes('>OG Image<'), '禁止硬编码 OG Image 占位文案');
assert.ok(
/ogPreview\.image\s*\?/.test(content) || /\{ogPreview\.image\s*\?/.test(content),
'必须仅在有真实封面 URL 时渲染 img'
);
// 禁止默认假图、假 CDN、示例封面地址
const fakeImagePatterns = [
/unsplash\.com/i,
/picsum\.photos/i,
/placeholder\.com/i,
/placehold\.co/i,
/via\.placeholder/i,
/dummyimage\.com/i,
/loremflickr\.com/i,
/example\.com\/.*\.(png|jpe?g|webp|gif)/i,
/cdn\.example/i,
/default[-_]?cover/i,
/sample[-_]?cover/i,
/fake[-_]?cover/i,
/src=["']https?:\/\/[^"']*cover[^"']*["']/i,
/src=["']\/assets\/[^"']*cover/i,
];
for (const pattern of fakeImagePatterns) {
assert.ok(!pattern.test(content), `页面禁止假图/示例封面地址: ${pattern}`);
}
assert.ok(
localeContent.includes('cardArticles.ogPreview.emptyImage'),
'语言包必须包含空图占位文案 key'
);
// 三语均需有 emptyImage 词条
const emptyImageMatches = localeContent.match(/cardArticles\.ogPreview\.emptyImage/g) || [];
assert.ok(
emptyImageMatches.length >= 3,
'en-US / zh-CN / ar-SA 均须包含 emptyImage 词条'
);
});

查看文件

@@ -122,3 +122,104 @@ test('7. builder 支持无 ID 自动拉取名片列表、名片切换器与多
'必须支持 RTL 排版镜像切换'
);
});
test('8. 手机预览区 RTL 使用 Arco Switch,且不写入 batchSaveLayout body', () => {
const content = fs.readFileSync(builderPagePath, 'utf-8');
const localeContent = fs.readFileSync(builderLocalePath, 'utf-8');
assert.ok(
/Switch/.test(content) &&
content.includes("from '@arco-design/web-react'"),
'必须从 @arco-design/web-react 使用 Switch'
);
assert.ok(content.includes('<Switch'), '手机预览区必须渲染 Switch');
assert.ok(
/dir=\{isRtl \? ['"]rtl['"] : ['"]ltr['"]\}/.test(content),
'打开/关闭 Switch 时预览容器必须切换 dir=rtl / dir=ltr'
);
assert.ok(
content.includes('{ cardId: Number(id), blocks: payloadBlocks }'),
'batchSaveLayout body 必须仍为 { cardId, blocks },不得扩展参数'
);
const saveStart = content.indexOf('const handleSave');
const saveEnd = content.indexOf('const handleOpenPreview');
assert.ok(saveStart >= 0 && saveEnd > saveStart, '必须能定位 handleSave 实现');
const saveSection = content.slice(saveStart, saveEnd);
assert.ok(!saveSection.includes('isRtl'), 'handleSave 不得引用 isRtl');
assert.ok(!/\brtl\b/i.test(saveSection), 'batchSaveLayout 请求体不得包含 rtl');
assert.ok(
localeContent.includes('cardBuilder.preview.rtl'),
'locale 必须有预览 RTL 开关文案 key'
);
assert.ok(localeContent.includes("'en-US'"), 'locale 必须含英文');
assert.ok(localeContent.includes("'zh-CN'"), 'locale 必须含中文');
assert.ok(localeContent.includes("'ar-SA'"), 'locale 必须含阿语');
});
test('9. 深色预览只作用于手机容器,切换后保存不得写入 theme=dark', () => {
const content = fs.readFileSync(builderPagePath, 'utf-8');
const localeContent = fs.readFileSync(builderLocalePath, 'utf-8');
assert.ok(
/useState<'light' \| 'dark'>\(['"]light['"]\)/.test(content) ||
content.includes("useState<'light' | 'dark'>('light')"),
'themeMode 必须为本地预览状态,默认 light'
);
assert.ok(
/themeMode === ['"]dark['"] \? styles\.phoneDark/.test(content) ||
content.includes("themeMode === 'dark' ? styles.phoneDark"),
'深色必须只挂在 phone 预览 class(phoneDark),不得改整页 tokens'
);
assert.ok(
content.includes("getCssVariables('light')"),
'页面根 tokens 必须固定 light,深色不得写进整页主题'
);
const saveStart = content.indexOf('const handleSave');
const saveEnd = content.indexOf('const handleOpenPreview');
assert.ok(saveStart >= 0 && saveEnd > saveStart, '必须能定位 handleSave 实现');
const saveSection = content.slice(saveStart, saveEnd);
assert.ok(
content.includes('{ cardId: Number(id), blocks: payloadBlocks }'),
'batchSaveLayout body 必须仍为 { cardId, blocks }'
);
assert.ok(!saveSection.includes('themeMode'), 'handleSave 不得引用 themeMode');
assert.ok(!saveSection.includes('phoneDark'), 'handleSave 不得引用 phoneDark');
assert.ok(
!/theme\s*[:=]\s*['"]dark['"]/.test(saveSection),
'切换深色后再保存,请求体不得出现 theme=dark'
);
assert.ok(!/\btheme\b/.test(saveSection), 'batchSaveLayout 请求体不得包含 theme 字段');
assert.ok(!saveSection.includes('isRtl'), '深色切换后的保存体仍不得带入 RTL 预览态');
const savePostMatch = saveSection.match(
/axios\.post[\s\S]*?SAVE_LAYOUT_API[\s\S]*?\{([\s\S]*?)\}/
);
assert.ok(savePostMatch, '必须能解析 batchSaveLayout 的 axios.post 请求体');
const saveBody = savePostMatch[1];
assert.ok(saveBody.includes('cardId'), '保存体必须含 cardId');
assert.ok(saveBody.includes('blocks'), '保存体必须含 blocks');
assert.ok(!/theme/i.test(saveBody), '保存体字符串不得含 theme');
assert.ok(!/dark/i.test(saveBody), '保存体字符串不得因预览切换而含 dark');
assert.ok(!/rtl/i.test(saveBody), '保存体字符串不得因预览切换而含 rtl');
assert.ok(
/}, \[hasId, id, orderedBlocks, t\]\);/.test(saveSection) ||
saveSection.includes('[hasId, id, orderedBlocks, t]'),
'handleSave 依赖不得包含 themeMode / isRtl'
);
assert.ok(!saveSection.includes('themeMode'), '依赖自审:不得出现 themeMode');
assert.ok(!saveSection.includes('isRtl'), '依赖自审:不得出现 isRtl');
assert.ok(
localeContent.includes('cardBuilder.theme.dark'),
'locale 必须有深色预览文案'
);
assert.ok(
localeContent.includes('cardBuilder.theme.light'),
'locale 必须有浅色预览文案'
);
});

查看文件

@@ -115,3 +115,92 @@ test('7. cases 国际化文件完备且无 emoji', () => {
);
assert.ok(!/\bany\b/.test(localeContent), 'locale 严禁使用 any');
});
test('8. R14: 成果 KPI 标签不得预填假指标/假百分比/示例客户', () => {
const content = fs.readFileSync(casesPagePath, 'utf-8');
const localeContent = fs.readFileSync(casesLocalePath, 'utf-8');
assert.ok(!content.includes('PRESET_METRIC_TAGS'), '禁止预设 KPI 标签常量');
assert.ok(!content.includes('handleAddMetricTag'), '禁止点击注入预设 KPI');
// 禁止硬编码演示 KPI / 假百分比
const fakeKpiPatterns = [
/\+320%\s*ROI/i,
/\+150%\s*留资/,
/45k\+?\s*国际线索/i,
/2M\+?\s*全球曝光/i,
/Top\s*1\s*细分类目/i,
/\+320%/,
/45k\s*Leads/i,
];
for (const pattern of fakeKpiPatterns) {
assert.ok(!pattern.test(content), `页面禁止假 KPI: ${pattern}`);
assert.ok(!pattern.test(localeContent), `locale 禁止假 KPI: ${pattern}`);
}
// 禁止示例客户品牌预填或写进口径
assert.ok(!/VoltRide/i.test(content), '页面禁止示例客户 VoltRide');
assert.ok(!/VoltRide/i.test(localeContent), 'locale 禁止示例客户 VoltRide');
// 新建空表单 metrics 必须为空,不得写入演示值
assert.ok(
/metrics:\s*['"]{2}/.test(content) || /metrics:\s*''/.test(content),
'新建时 metrics 必须置为空字符串'
);
assert.ok(
!/setFieldsValue\([\s\S]*metrics:\s*['"][^'"]+['"]/.test(content),
'setFieldsValue 不得给 metrics 写入非空演示值'
);
});
test('9. R14: 保存失败不得 Message.success;成功仅在 code===1000', () => {
const content = fs.readFileSync(casesPagePath, 'utf-8');
const successMatches = [...content.matchAll(/Message\.success\s*\(/g)];
assert.ok(successMatches.length > 0, '真实保存成功仍应 Message.success');
for (const match of successMatches) {
const index = match.index ?? 0;
const preceding = content.slice(Math.max(0, index - 220), index);
assert.ok(
/code\s*===\s*1000/.test(preceding),
`Message.success 必须位于 code === 1000 分支内,附近片段: ${preceding.slice(-80)}`
);
}
// catch / else 失败分支附近不得出现 Message.success
const failBlocks = content.match(
/else\s*\{[\s\S]{0,180}\}|catch\s*\([^)]*\)\s*\{[\s\S]{0,180}\}/g
);
if (failBlocks) {
for (const block of failBlocks) {
assert.ok(
!block.includes('Message.success'),
`失败分支内不得 Message.success: ${block.slice(0, 120)}`
);
}
}
});
test('10. R14: 文案与 placeholder 必须走 locale,禁止硬编码校验/占位', () => {
const content = fs.readFileSync(casesPagePath, 'utf-8');
const localeContent = fs.readFileSync(casesLocalePath, 'utf-8');
assert.ok(
content.includes("t['cases.metricsPlaceholder']") ||
content.includes('t["cases.metricsPlaceholder"]'),
'metrics placeholder 必须走 locale'
);
assert.ok(
content.includes("t['cases.clientPlaceholder']") ||
content.includes('t["cases.clientPlaceholder"]'),
'client placeholder 必须走 locale'
);
assert.ok(localeContent.includes('cases.metricsPlaceholder'), 'locale 须有 metrics 占位词条');
assert.ok(localeContent.includes('cases.clientPlaceholder'), 'locale 须有 client 占位词条');
assert.ok(localeContent.includes('cases.requiredTitle') || localeContent.includes('cases.titleRequired'), 'locale 须有标题必填校验词条');
assert.ok(localeContent.includes('cases.requiredCardId') || localeContent.includes('cases.cardIdRequired'), 'locale 须有 cardId 必填校验词条');
assert.ok(!content.includes('Please enter cardId'), '禁止硬编码英文 cardId 校验');
assert.ok(!content.includes('Please enter case title'), '禁止硬编码英文 title 校验');
assert.ok(!content.includes('placeholder="e.g.'), '禁止硬编码 e.g. placeholder');
});

查看文件

@@ -54,7 +54,7 @@ test('1. crm/lead-profile 必须 GET lead/info 并 POST radar/page', () => {
assert.ok(!emojiPattern.test(content), '禁止使用 emoji');
});
test('2. crm/lead-profile 必须用 Descriptions 或 Card + Table 展示画像与足迹', () => {
test('2. crm/lead-profile 必须用 Descriptions 或 Card + Timeline 展示画像与足迹时间轴', () => {
const content = fs.readFileSync(profilePagePath, 'utf-8');
assert.ok(
@@ -62,7 +62,8 @@ test('2. crm/lead-profile 必须用 Descriptions 或 Card + Table 展示画像
'必须从 @arco-design/web-react 引入组件'
);
assert.ok(content.includes('Descriptions') || content.includes('Card'), '必须使用 Descriptions 或 Card');
assert.ok(content.includes('Table'), '必须使用 Arco Table');
assert.ok(content.includes('Timeline'), '必须使用 Arco Timeline 展示访问时间轴');
assert.ok(content.includes('Empty'), '无足迹事件必须使用 Arco Empty');
assert.ok(content.includes('LeadEntity'), '必须使用 LeadEntity 类型');
assert.ok(content.includes('RadarTrack'), '必须使用 RadarTrack 类型');
assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
@@ -88,7 +89,7 @@ test('2. crm/lead-profile 必须用 Descriptions 或 Card + Table 展示画像
'country',
'city',
]) {
assert.ok(content.includes(field), `足迹表必须展示字段: ${field}`);
assert.ok(content.includes(field), `时间轴必须展示字段: ${field}`);
}
});
@@ -136,3 +137,163 @@ test('4. locale 必须包含 menu.crm.leadProfile 中英文案,且禁止 any /
);
assert.ok(!emojiPattern.test(content), 'locale 禁止使用 emoji');
});
test('5. crm/lead-profile 次按钮生成跟进话术须调已有 copilot/generate,成功才填框', () => {
const content = fs.readFileSync(profilePagePath, 'utf-8');
const localeContent = fs.readFileSync(profileLocalePath, 'utf-8');
assert.ok(
content.includes('admin/ai/copilot/generate'),
'必须请求已有接口 POST /api/admin/ai/copilot/generate'
);
assert.ok(
content.includes('axios.post') && content.includes('COPILOT'),
'跟进话术必须用 axios.post 调用 copilot'
);
assert.ok(
content.includes('Input.TextArea') || content.includes('TextArea'),
'必须有文本框承接返回话术'
);
assert.ok(
content.includes("type=\"outline\"") ||
content.includes("type='outline'") ||
content.includes('type="text"') ||
content.includes("type='text'"),
'生成按钮必须是描边或文字次按钮,不得再加实心主按钮'
);
assert.ok(
content.includes('crmLeadProfile.copilot.generate'),
'生成按钮文案必须走 locale key'
);
assert.ok(
content.includes('crmLeadProfile.copilot.fail') &&
content.includes('Message.'),
'失败必须用 Message 说明暂不可生成'
);
assert.ok(
content.includes('payload.data') || content.includes('data?.script') || content.includes('.script'),
'成功后必须用接口返回的 script 填入文本框'
);
const hardcodedEmail =
/Dear\s+\w+|Looking forward to|Best regards|Hi\s+there,|I hope this (email|message) finds you/i;
assert.ok(
!hardcodedEmail.test(content),
'页面禁止写死英文跟进邮件话术'
);
assert.ok(
!hardcodedEmail.test(localeContent),
'locale 禁止写死英文跟进邮件话术'
);
for (const lang of ['en-US', 'zh-CN', 'ar-SA'] as const) {
assert.ok(
localeContent.includes(`'${lang}'`),
`locale 必须包含 ${lang}`
);
}
assert.ok(
localeContent.includes('crmLeadProfile.copilot.generate'),
'locale 必须包含生成按钮文案'
);
assert.ok(
localeContent.includes('crmLeadProfile.copilot.fail'),
'locale 必须包含暂不可生成提示'
);
assert.ok(
localeContent.includes('crmLeadProfile.copilot.script') ||
localeContent.includes('crmLeadProfile.copilot.title'),
'locale 必须包含话术文本框标签'
);
assert.ok(
content.includes('setFollowUpScript(script)'),
'成功时只能用接口返回的 script 变量填框'
);
assert.ok(
!/setFollowUpScript\(\s*[`'"][^`'"]+[`'"]/.test(content),
'禁止用非空字符串字面量给话术框填假内容'
);
const primaryMatches = content.match(/type=["']primary["']/g) ?? [];
assert.ok(
primaryMatches.length <= 1,
'画像有 id 时不得再加第二个实心主按钮;primary 仅保留空状态 CTA'
);
const generateHandler = content.slice(
content.indexOf('handleGenerateFollowUp'),
content.indexOf('const pagination')
);
assert.ok(generateHandler.includes('catch'), '必须有失败 catch');
const catchBlock = generateHandler.slice(generateHandler.indexOf('catch'));
assert.ok(
!catchBlock.includes('setFollowUpScript'),
'接口失败时不得调用 setFollowUpScript 填假话术'
);
});
test('6. R49: 画像时间轴只渲染接口事件;无事件 Empty;失败清空,禁止示例动态', () => {
const content = fs.readFileSync(profilePagePath, 'utf-8');
const localeContent = fs.readFileSync(profileLocalePath, 'utf-8');
assert.ok(
content.includes('Timeline') && content.includes('Timeline.Item'),
'必须用 Timeline / Timeline.Item 渲染访问事件'
);
assert.ok(
content.includes('Empty') &&
(content.includes('crmLeadProfile.radar.empty') ||
content.includes("t['crmLeadProfile.radar.empty']")),
'无事件必须渲染 Arco Empty,文案走 locale'
);
assert.ok(
localeContent.includes('crmLeadProfile.radar.empty'),
'locale 必须包含时间轴空状态文案'
);
assert.ok(
/useState\s*<\s*RadarLogRow\s*\[\s*\]\s*>\s*\(\s*\[\s*\]\s*\)/.test(content) ||
/useState[<\s(][^)]*RadarLogRow[^)]*\)?\s*\(\s*\[\s*\]\s*\)/.test(content),
'tracks 初始必须为空数组'
);
assert.ok(
content.includes('setTracks(payload.data?.list ?? [])') ||
content.includes('setTracks(payload.data?.list') ||
/setTracks\(\s*payload\.data\?\.list/.test(content),
'成功时只能写入接口返回的 list'
);
assert.ok(content.includes('setTracks([])'), '失败或无数据必须 setTracks([])');
assert.ok(content.includes('setTotal(0)'), '失败必须把 total 清零');
const radarStart = content.indexOf('RADAR_PAGE_API');
const radarEnd = content.indexOf('handleGenerateFollowUp');
assert.ok(radarStart >= 0 && radarEnd > radarStart, '必须存在足迹请求与生成话术逻辑');
const radarEffect = content.slice(radarStart, radarEnd);
assert.ok(radarEffect.includes('catch'), '足迹请求必须有 catch');
assert.ok(
radarEffect.includes('setTracks([])') &&
/code\s*===\s*1000/.test(radarEffect) &&
radarEffect.includes('catch'),
'code 非 1000 与 catch 都必须清空时间轴'
);
assert.ok(
!content.includes('示例动态') &&
!content.includes('示例时间') &&
!content.includes('假跟进') &&
!content.includes('Just Now') &&
!content.includes('sample-track') &&
!content.includes('demo-event') &&
!/const\s+(MOCK_|SAMPLE_|DEMO_|FAKE_)/.test(content),
'禁止示例动态、示例时间或假跟进常量'
);
assert.ok(
!/useState\s*<\s*RadarLogRow\s*\[\s*\]\s*>\s*\(\s*\[[^\]]+id/.test(content),
'禁止用示例事件初始化时间轴'
);
assert.ok(
content.includes('tracks.map') || content.includes('tracks.length'),
'时间轴必须基于 tracks 状态渲染,不得写死事件节点'
);
});

查看文件

@@ -59,3 +59,59 @@ test('3. crm/leads locale 必须包含中英文案', () => {
assert.ok(content.includes('crmLeads.status.new'), '必须包含状态文案');
assert.ok(content.includes('crmLeads.source.ai_chat'), '必须包含来源文案');
});
test('4. crm/leads 导出必须调用 batchExport,空数据不落盘', () => {
const content = fs.readFileSync(leadsPagePath, 'utf-8');
const localeContent = fs.readFileSync(leadsLocalePath, 'utf-8');
assert.ok(
content.includes('admin/crm/lead/batchExport'),
'必须请求 POST /api/admin/crm/lead/batchExport'
);
assert.ok(
content.includes('crmLeads.export') || content.includes("t['crmLeads.export"),
'必须有导出按钮文案'
);
assert.ok(
content.includes('crmLeads.export.empty'),
'空数据必须提示 crmLeads.export.empty'
);
assert.ok(
/exportCount\s*<=\s*0|list\.length\s*===\s*0/.test(content),
'必须判断空导出,禁止空表头落盘冒充成功'
);
assert.ok(
/crmLeads\.export\.empty[\s\S]{0,120}return|Message\.info\([\s\S]{0,80}export\.empty[\s\S]{0,40}return/.test(
content
),
'空数据提示后必须 return,禁止继续下载'
);
assert.ok(
content.includes('createObjectURL') || content.includes('Blob'),
'有数据时必须下载 csv/xlsx'
);
assert.ok(
content.includes('.csv') || content.includes('.xlsx'),
'下载文件必须是 csv 或 xlsx'
);
assert.ok(
!content.includes('Alex') &&
!content.includes('John Doe') &&
!content.includes('示例客户') &&
!content.includes('张三') &&
!content.includes('假客户'),
'禁止前端写死示例客户冒充导出成功'
);
for (const key of [
'crmLeads.export',
'crmLeads.export.empty',
'crmLeads.export.success',
'crmLeads.export.fail',
]) {
assert.ok(localeContent.includes(key), `locale 必须包含 ${key}`);
}
assert.ok(localeContent.includes("'en-US'"), 'locale 必须含 en-US');
assert.ok(localeContent.includes("'zh-CN'"), 'locale 必须含 zh-CN');
assert.ok(localeContent.includes("'ar-SA'"), 'locale 必须含 ar-SA');
});

查看文件

@@ -96,3 +96,112 @@ test('4. crm/pipeline 必须支持泳道快速录入商机 Modal 与对应语言
assert.ok(localeContent.includes('crmPipeline.action.quickAdd'), '语言包必须包含 crmPipeline.action.quickAdd');
assert.ok(localeContent.includes('crmPipeline.modal.quickAddTitle'), '语言包必须包含 crmPipeline.modal.quickAddTitle');
});
test('5. crm/pipeline 阶段变更必须写回 updateStatus,失败时回滚原列并 Message.error', () => {
const content = fs.readFileSync(pipelinePagePath, 'utf-8');
assert.ok(
content.includes('/api/admin/crm/lead/updateStatus') ||
content.includes('admin/crm/lead/updateStatus'),
'改阶段必须写回 POST /api/admin/crm/lead/updateStatus'
);
assert.ok(
content.includes('handleStatusChange') || content.includes('onChange'),
'必须通过 Select/点击改阶段触发写回'
);
assert.ok(
/setColumns\s*\(/.test(content),
'阶段变更必须更新看板列(乐观移列或回滚)'
);
assert.ok(
content.includes('rollback') ||
content.includes('prevColumns') ||
content.includes('snapshot') ||
/setColumns\(\s*(rollback|prev|snapshot|previous)/.test(content) ||
content.includes('setColumns(rollbackColumns)') ||
content.includes('setColumns(snapshot)') ||
content.includes('setColumns(prevColumns)'),
'请求失败必须把卡片放回原列,禁止只留在新列假装已保存'
);
assert.ok(content.includes('Message.error'), '失败必须 Message.error 提示');
assert.ok(content.includes('crmPipeline.update.fail'), '失败提示必须走 update.fail 文案');
assert.ok(
content.includes('axios.post') && content.includes('UPDATE_STATUS_API'),
'必须 axios.post 调用 UPDATE_STATUS_API,禁止只改本地状态'
);
const localeContent = fs.readFileSync(pipelineLocalePath, 'utf-8');
assert.ok(
localeContent.includes('crmPipeline.update.fail'),
'locale 必须包含阶段写回失败文案'
);
});
test('6. R22: 金额只读线索 amount/dealValue,无有效数字不得假总额', () => {
const content = fs.readFileSync(pipelinePagePath, 'utf-8');
const localeContent = fs.readFileSync(pipelineLocalePath, 'utf-8');
assert.ok(
content.includes('amount') && content.includes('dealValue'),
'必须从线索 amount / dealValue 读取金额'
);
assert.ok(
content.includes('readLeadAmount') && content.includes('sumStageAmount'),
'必须有读单条与汇总阶段金额的函数'
);
assert.ok(
content.includes('Number.isFinite'),
'金额必须经 Number.isFinite 校验,禁止非数字冒充'
);
assert.ok(
content.includes('counted > 0') ||
content.includes('counted>0') ||
/return counted\s*>\s*0\s*\?\s*total\s*:\s*undefined/.test(content),
'无一笔有效金额时必须返回 undefined,禁止汇总成 0'
);
assert.ok(
content.includes('crmPipeline.column.amount.empty') &&
content.includes('crmPipeline.card.amount.empty'),
'无有效金额时必须走 locale 空态文案'
);
assert.ok(
localeContent.includes('crmPipeline.column.amount.empty'),
'locale 必须包含列金额空态'
);
assert.ok(
localeContent.includes('crmPipeline.card.amount') &&
localeContent.includes('crmPipeline.card.amount.empty'),
'locale 必须包含卡片金额与空态文案'
);
// 禁止用 0×条数 / 条数×0 冒充商机金额
assert.ok(
!/0\s*\*\s*(leads\.length|\.length)/.test(content) &&
!/(leads\.length|\.length)\s*\*\s*0/.test(content),
'禁止用 0 乘条数冒充商机金额'
);
// 禁止把缺失金额默认成 0 再累加成假总额
assert.ok(
!/(amount|dealValue)[^\n]{0,120}\?\?\s*0/.test(content) &&
!/\?\?\s*0[^\n]{0,120}(amount|dealValue)/.test(content) &&
!/readLeadAmount\([^)]*\)\s*\?\?\s*0/.test(content) &&
!/parseFiniteAmount\([^)]*\)\s*\?\?\s*0/.test(content),
'禁止 amount/dealValue 缺失时 ?? 0 累加'
);
assert.ok(
!content.includes('99999') &&
!content.includes('100000') &&
!content.includes('$12,000') &&
!content.includes('¥0') &&
!content.includes('$0.00'),
'禁止写死演示商机总额'
);
// 阶段失败回滚不得被本改动破坏
assert.ok(
content.includes('setColumns(snapshot)') ||
content.includes('setColumns(prevColumns)') ||
content.includes('setColumns(rollbackColumns)'),
'阶段流转失败仍须回滚原列'
);
});

查看文件

@@ -103,3 +103,97 @@ test('5. 页面 locale 必须覆盖筛选与列文案,且禁止 any', () => {
assert.ok(content.includes('radarLog.columns.viewedBlocks'), '必须有 viewedBlocks 列文案');
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
});
test('6. 深度访问高亮:停留 ≥180000ms 或滚动深度 ≥0.6;缺字段不高亮', () => {
const content = fs.readFileSync(radarLogPagePath, 'utf-8');
assert.ok(
content.includes('180000'),
'必须包含停留时长阈值 180000 毫秒'
);
assert.ok(
content.includes('0.6'),
'必须包含滚动深度阈值 0.6'
);
assert.ok(
/isDeepVisit|deepVisit/.test(content),
'必须具备 isDeepVisit / deepVisit 判定'
);
assert.ok(
content.includes('rowClassName') || content.includes('onRow'),
'必须通过 rowClassName 或 onRow 对深度访问行高亮'
);
// 缺字段:须对 stayDurationMs / scrollDepth 做有限数值校验后再比阈值
assert.ok(
/Number\.isFinite|typeof[\s\S]{0,40}number/.test(content),
'字段缺失或非有限数时不得误判为深度访问'
);
assert.ok(
content.includes("t['radarLog.deepVisit']") ||
content.includes('radarLog.deepVisit'),
'深度访问文案必须走 locale'
);
});
test('7. locale 必须含深度访问文案;页面禁止写死国家或示例访客', () => {
const pageContent = fs.readFileSync(radarLogPagePath, 'utf-8');
const localeContent = fs.readFileSync(radarLogLocalePath, 'utf-8');
assert.ok(
localeContent.includes("'radarLog.deepVisit'"),
'locale 必须包含 radarLog.deepVisit'
);
assert.ok(localeContent.includes("'en-US'"), 'locale 必须含 en-US');
assert.ok(localeContent.includes("'zh-CN'"), 'locale 必须含 zh-CN');
assert.ok(localeContent.includes("'ar-SA'"), 'locale 必须含 ar-SA');
const bannedCountries = [
'United States',
'United Kingdom',
'Singapore',
'Germany',
'Japan',
'China',
'Dubai',
'Saudi Arabia',
];
for (const name of bannedCountries) {
assert.ok(!pageContent.includes(name), `禁止硬编码国家/地区名: ${name}`);
}
assert.ok(
!pageContent.includes('示例访客') &&
!pageContent.includes('sample-visitor') &&
!pageContent.includes('visitor_demo') &&
!pageContent.includes('Alex') &&
!/visitorId\s*:\s*['"][^'"]+['"]/.test(pageContent),
'禁止写死示例访客行'
);
assert.ok(
!/useState\s*<\s*RadarLogRow\s*\[\s*\]\s*>\s*\(\s*\[[^\]]+visitorId/.test(
pageContent
) &&
!/const\s+MOCK_|const\s+SAMPLE_|const\s+DEMO_/.test(pageContent),
'禁止用示例访客初始化表格数据'
);
});
test('8. 请求失败必须空表,不得残留示例访客', () => {
const content = fs.readFileSync(radarLogPagePath, 'utf-8');
assert.ok(content.includes('setData([])'), '失败必须 setData([])');
assert.ok(
/useState[<\s(][^)]*RadarLogRow[^)]*\)?\s*\(\s*\[\s*\]\s*\)/.test(content) ||
/useState\s*<\s*RadarLogRow\s*\[\s*\]\s*>\s*\(\s*\[\s*\]\s*\)/.test(content),
'初始 data 必须为空数组'
);
// 非 1000 与 catch 均应清空
assert.ok(
/code\s*===\s*1000[\s\S]*?setData\(\[\]\)/.test(content) ||
(content.includes('setData([])') && content.includes('catch')),
'code 非 1000 或 catch 必须清空表格'
);
assert.ok(
!content.includes('Mock.js') && !content.includes('mockList'),
'失败路径禁止留下 mock/示例列表'
);
});

查看文件

@@ -42,6 +42,12 @@ test('3. dashboard/financial 页面必须存在,且具备 COO/CFO 视角切换
'必须调用 /admin/team/billing/analytics 统计接口'
);
// 必须按 Cool-Admin code === 1000 判断成功
assert.ok(
content.includes('code === 1000') || content.includes('code ===1000'),
'必须按 Cool-Admin code === 1000 判断成功'
);
// 必须具备运营官 (coo) 与财务官 (cfo) 双视角
assert.ok(
content.includes("'coo'") && content.includes("'cfo'"),
@@ -50,6 +56,17 @@ test('3. dashboard/financial 页面必须存在,且具备 COO/CFO 视角切换
assert.ok(!content.includes('14500'), '禁止硬编码演示 MRR');
assert.ok(!content.includes('Vanguard Global'), '禁止硬编码演示账单');
assert.ok(!content.includes('Acme Corp'), '禁止硬编码演示客户');
assert.ok(!content.includes('Demo Tenant'), '禁止硬编码演示租户');
assert.ok(!/mrr\s*:\s*[1-9]/.test(content), '禁止写死非零 mrr 演示值');
assert.ok(
!/totalInvoiced\s*:\s*[1-9]/.test(content),
'禁止写死非零 totalInvoiced 演示值'
);
assert.ok(
!/recentLedger\s*:\s*\[[\s\S]*tenantName\s*:/.test(content),
'禁止在页面内联写死 recentLedger 示例客户'
);
// 必须使用 Arco 核心组件
assert.ok(content.includes('@arco-design/web-react'), '必须引入 Arco Design');
@@ -58,6 +75,71 @@ test('3. dashboard/financial 页面必须存在,且具备 COO/CFO 视角切换
assert.ok(content.includes('Card'), '必须使用 Card 卡片容器');
});
test('3b. 数字仅来自 analytics 成功 data;失败/非 1000/无 data 必须清空为空态', () => {
const content = fs.readFileSync(financialPagePath, 'utf-8');
assert.ok(
content.includes('EMPTY_ANALYTICS') || content.includes('emptyAnalytics'),
'必须具备 EMPTY_ANALYTICS / emptyAnalytics 空态结构'
);
assert.ok(
/mrr\s*:\s*0/.test(content) || /totalInvoiced\s*:\s*0/.test(content),
'空态汇总指标必须为 0'
);
assert.ok(
/recentLedger\s*:\s*\[\s*\]/.test(content) ||
/planDistribution\s*:\s*\[\s*\]/.test(content),
'空态列表必须为空数组'
);
assert.ok(
/setAnalyticsData\(\s*(EMPTY_ANALYTICS|emptyAnalytics)/.test(content),
'接口失败或非 1000 必须 setAnalyticsData 清空为空态'
);
// 禁止非 1000 仍写入 data 的兜底分支(避免残留假数据)
assert.ok(
!/else if\s*\([^)]*response\.data\.data[^)]*\)\s*\{[\s\S]*?setAnalyticsData/.test(
content
),
'禁止在 code 非 1000 时仍 setAnalyticsData(response.data.data)'
);
});
test('3c. 无真实导出接口时不得假 Message.success;须禁用或提示暂不可导出', () => {
const content = fs.readFileSync(financialPagePath, 'utf-8');
const localeContent = fs.readFileSync(financialLocalePath, 'utf-8');
assert.ok(
localeContent.includes("'dashboardFinancial.export.unavailable'"),
'locale 必须包含 dashboardFinancial.export.unavailable'
);
assert.ok(
content.includes("t['dashboardFinancial.export.unavailable']") ||
content.includes('dashboardFinancial.export.unavailable'),
'页面必须引用 export.unavailable 文案'
);
// 不得在无真实文件时假装导出成功
const hasFakeSuccess =
/Message\.success\s*\(\s*t\[['"]dashboardFinancial\.export\.success['"]\]/.test(
content
) ||
(/handleExport[\s\S]{0,200}Message\.success/.test(content) &&
!content.includes('/export'));
assert.ok(!hasFakeSuccess, '无导出接口时禁止 Message.success 假装导出成功');
const exportDisabled =
/disabled[\s\S]{0,80}export|export[\s\S]{0,120}disabled/i.test(content) ||
content.includes('disabled={true}') ||
/Button[\s\S]{0,300}disabled/.test(content);
const exportUnavailableHint =
content.includes("t['dashboardFinancial.export.unavailable']") ||
content.includes('dashboardFinancial.export.unavailable');
assert.ok(
exportDisabled || exportUnavailableHint,
'无导出接口时必须禁用导出按钮或提示暂不可导出'
);
});
test('4. dashboard/financial 必须遵循瑞士国际主义设计规范、Token 与零 any、零 emoji 约束', () => {
const pageContent = fs.readFileSync(financialPagePath, 'utf-8');
const localeContent = fs.readFileSync(financialLocalePath, 'utf-8');
@@ -88,4 +170,8 @@ test('5. 专属语言包 financial/locale/index.ts 必须完备覆盖 COO 与 CF
assert.ok(localeContent.includes('mrr'), '必须包含 MRR 词条');
assert.ok(localeContent.includes('totalInvoiced'), '必须包含开票总额词条');
assert.ok(localeContent.includes('agingAnalysis'), '必须包含账龄分析词条');
assert.ok(
localeContent.includes('export.unavailable'),
'必须包含导出暂不可用词条'
);
});

查看文件

@@ -57,13 +57,18 @@ test('3. 大屏页面必须存在且使用 Arco Design 官方核心组件', () =
assert.ok(content.includes('Progress'), '必须使用 Arco Progress 展示占比进度条');
});
test('4. 大屏页面必须对接雷达聚合分析 API 且只设置 1 个主 CTA', () => {
test('4. 大屏页面必须对接雷达热力聚合 API 且只设置 1 个主 CTA', () => {
const content = fs.readFileSync(radarMapPagePath, 'utf-8');
// 接口校验
// 国家/地区分布必须来自已有热力聚合接口(analytics 内含 countryDistribution,或独立 distribution)
assert.ok(
content.includes('/api/admin/crm/radar/analytics') || content.includes('/api/admin/crm/radar/distribution'),
'必须对接雷达分析或国家分布 API'
content.includes('/api/admin/crm/radar/analytics') ||
content.includes('/api/admin/crm/radar/distribution'),
'必须对接雷达热力聚合 analytics 或 distribution API'
);
assert.ok(
content.includes('countryDistribution') || content.includes('/radar/distribution'),
'国家/地区分布必须绑定接口返回的 countryDistribution(或 distribution)'
);
// 瑞士国际主义与一屏单主 CTA 原则: 统计 type="primary" 按钮数量
@@ -75,6 +80,63 @@ test('4. 大屏页面必须对接雷达聚合分析 API 且只设置 1 个主 CT
);
});
test('4b. 国家分布禁止写死占比;请求失败或无数据必须空态', () => {
const content = fs.readFileSync(radarMapPagePath, 'utf-8');
const localeContent = fs.readFileSync(radarMapLocalePath, 'utf-8');
// 禁止演示用硬编码国家名与写死占比
const bannedCountries = [
'United States',
'United Kingdom',
'Singapore',
'Germany',
'Japan',
'China',
'Dubai',
'Saudi Arabia',
];
for (const name of bannedCountries) {
assert.ok(!content.includes(name), `禁止硬编码国家/地区名: ${name}`);
}
assert.ok(
!/percentage\s*:\s*\d+/.test(content),
'禁止在页面中写死 percentage 国家占比'
);
assert.ok(
!/visitorCount\s*:\s*[1-9]/.test(content),
'禁止在页面中写死非零 visitorCount 演示数据'
);
// 失败或非 1000 必须清空分布,不得残留假占比
assert.ok(
content.includes('countryDistribution: []') ||
content.includes('EMPTY_ANALYTICS') ||
content.includes('emptyAnalytics'),
'必须具备空的 countryDistribution 初始/空态结构'
);
assert.ok(
/setAnalyticsData\(\s*(EMPTY_ANALYTICS|emptyAnalytics)/.test(content) ||
/setAnalyticsData\(\s*\{\s*[\s\S]*countryDistribution\s*:\s*\[\s*\]/.test(content),
'接口失败必须 setAnalyticsData 清空为国家空分布'
);
// 空态文案走 locale
assert.ok(
content.includes('radarMap.status.empty') || content.includes("t['radarMap.status.empty']"),
'空态必须引用 radarMap.status.empty'
);
assert.ok(
content.includes('Empty') || content.includes('noDataElement'),
'无数据必须使用 Empty 或 Table noDataElement'
);
assert.ok(localeContent.includes('radarMap.status.empty'), 'locale 必须包含空态文案');
assert.ok(localeContent.includes('radarMap.status.loadFailed'), 'locale 必须包含加载失败文案');
assert.ok(
localeContent.includes('radarMap.country.unknown'),
'locale 必须包含未知国家/地区文案'
);
});
test('5. 必须使用设计 Token 系统且保持严谨纯粹 (零 any、零 emoji)', () => {
const content = fs.readFileSync(radarMapPagePath, 'utf-8');

查看文件

@@ -184,5 +184,63 @@ test('8. 实时雷达数据专家重构:相对时间、本地环境优雅降
assert.ok(!/\bany\b/.test(content), '严禁使用 any');
});
test('9. P32: KPI 必须来自 workplace 统计接口,禁止演示假数,失败回退 0/空态', () => {
const content = fs.readFileSync(pagePath, 'utf-8');
const localeContent = fs.readFileSync(localePath, 'utf-8');
assert.ok(
content.includes('/api/admin/crm/radar/workplace'),
'KPI 必须请求 /api/admin/crm/radar/workplace'
);
assert.ok(content.includes('kpis.tapCount'), '触碰量必须绑定接口 kpis.tapCount');
assert.ok(content.includes('kpis.viewCount'), '浏览量必须绑定接口 kpis.viewCount');
assert.ok(content.includes('kpis.newLeadCount'), '新线索必须绑定接口 kpis.newLeadCount');
assert.ok(
content.includes('kpis.conversionRate'),
'转化率必须绑定接口 kpis.conversionRate'
);
// 禁止渠道/KPI 演示假数(空 feed 时不得回退 50/30/20)
assert.ok(
!/nfc:\s*50/.test(content) && !/qr:\s*30/.test(content) && !/link:\s*20/.test(content),
'渠道分布禁止硬编码 50/30/20 假数'
);
assert.ok(!content.includes('14500'), '禁止硬编码演示 KPI 数字');
assert.ok(!content.includes('Vanguard'), '禁止硬编码演示客户名');
assert.ok(!/\b210\b/.test(content), '页面禁止硬编码演示停留秒数');
assert.ok(
!localeContent.includes('210'),
'语言包禁止硬编码演示停留秒数假数'
);
assert.ok(
content.includes('code !== 1000') || content.includes('code === 1000'),
'必须按 Cool-Admin 约定校验 code === 1000'
);
// 失败或无数据时必须回退 EMPTY_KPI / 全 0,不得保留假数
assert.ok(content.includes('EMPTY_KPI'), '必须定义 EMPTY_KPI 作为空态/失败回退');
assert.ok(
content.includes('kpis: EMPTY_KPI, radarFeed: []'),
'接口失败或无效响应时必须 setOverview 回退 EMPTY_KPI'
);
// 漏斗 meta 与渠道/预警空态必须走 locale
const localeKeys = [
'workplace.funnel.metaBase',
'workplace.funnel.metaTapView',
'workplace.funnel.metaViewInteract',
'workplace.funnel.metaInteractLead',
'workplace.channel.empty',
'workplace.alerts.empty',
];
for (const key of localeKeys) {
assert.ok(localeContent.includes(key), `语言包必须包含: ${key}`);
assert.ok(content.includes(key), `页面必须引用 locale key: ${key}`);
}
assert.ok(!EMOJI_RE.test(content), '页面严禁 emoji');
assert.ok(!EMOJI_RE.test(localeContent), '语言包严禁 emoji');
assert.ok(!/\bany\b/.test(content), '严禁 any');
});

查看文件

@@ -80,3 +80,76 @@ test('4. nfc/batch locale 必须包含 menu.nfc.batch', () => {
'locale 禁止 Mock.js'
);
});
test('5. 无可用 zip 打包下载时不得假成功 / 假 zip;须禁用打包按钮并提示暂不可打包', () => {
const pageContent = fs.readFileSync(batchPagePath, 'utf-8');
const localeContent = fs.readFileSync(batchLocalePath, 'utf-8');
assert.ok(
localeContent.includes("'nfcBatch.pack.unavailable'"),
'locale 必须包含 nfcBatch.pack.unavailable'
);
assert.ok(
localeContent.includes("'nfcBatch.form.packDownload'"),
'locale 必须包含打包下载按钮文案'
);
assert.ok(
/暂不可打包/.test(localeContent),
'中文须说明暂不可打包'
);
assert.ok(
pageContent.includes("t['nfcBatch.pack.unavailable']") ||
pageContent.includes('nfcBatch.pack.unavailable'),
'页面必须引用 pack.unavailable 文案'
);
assert.ok(
pageContent.includes("t['nfcBatch.form.packDownload']") ||
pageContent.includes('nfcBatch.form.packDownload'),
'页面必须展示打包下载按钮文案'
);
// 禁止客户端造假 zip / 把复制短链当成打包成功
assert.ok(
!/JSZip|new Blob\s*\(|application\/zip|createObjectURL/.test(pageContent),
'禁止前端生成假 zip'
);
assert.ok(
!/Message\.success\s*\(\s*t\[['"]nfcBatch\.pack\.(success|download)/.test(
pageContent
),
'禁止 Message.success 假装打包成功'
);
const copySuccessAsPack =
/handlePack[\s\S]{0,240}Message\.success\s*\(\s*t\[['"]nfcBatch\.copy\.success['"]\]/.test(
pageContent
) ||
/packDownload[\s\S]{0,240}Message\.success\s*\(\s*t\[['"]nfcBatch\.copy\.success['"]\]/.test(
pageContent
);
assert.ok(!copySuccessAsPack, '禁止把复制短链成功当成打包成功');
const packDisabled =
/packDownload[\s\S]{0,200}disabled|disabled[\s\S]{0,200}packDownload|handlePackUnavailable[\s\S]{0,200}disabled|disabled[\s\S]{0,120}handlePackUnavailable/i.test(
pageContent
) ||
/Button[\s\S]{0,320}nfcBatch\.form\.packDownload[\s\S]{0,120}disabled|disabled[\s\S]{0,200}nfcBatch\.form\.packDownload/i.test(
pageContent
);
const packUnavailableHint =
pageContent.includes("t['nfcBatch.pack.unavailable']") ||
pageContent.includes('nfcBatch.pack.unavailable');
assert.ok(
packDisabled && packUnavailableHint,
'无可用打包接口时必须禁用打包下载按钮且点击/悬停提示暂不可打包'
);
// 真实入库成功仍须 code===1000 才 Message.success
assert.ok(
/code\s*===\s*1000[\s\S]{0,400}Message\.success\s*\(\s*t\[['"]nfcBatch\.request\.success['"]\]/.test(
pageContent
) ||
(pageContent.includes('code === 1000') &&
pageContent.includes("t['nfcBatch.request.success']")),
'真实制卡写入仍只在 code===1000 时提示入库成功'
);
});

查看文件

@@ -85,7 +85,54 @@ test('3. nfc/devices locale 必须包含 menu.nfc.devices 与中英文案', () =
assert.ok(content.includes('nfcDevices.request.fail'), '必须包含失败提示文案');
assert.ok(content.includes('nfcDevices.status.active'), '必须包含状态文案');
assert.ok(content.includes('nfcDevices.material.wood'), '必须包含材质文案');
assert.ok(content.includes('nfcDevices.material.empty'), '必须包含材质空态文案');
assert.ok(content.includes('nfcDevices.action.freeze'), '必须包含挂失文案');
assert.ok(content.includes('nfcDevices.action.reset'), '必须包含重置文案');
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
});
test('4. 材质列未知/空值显示空态,禁止默认 pvc;筛选与表单不预选 PVC', () => {
const content = fs.readFileSync(devicesPagePath, 'utf-8');
const localeContent = fs.readFileSync(devicesLocalePath, 'utf-8');
assert.ok(
content.includes("t['nfcDevices.material.empty']") ||
content.includes('t[`nfcDevices.material.empty`]'),
'未知/缺失材质必须走 nfcDevices.material.empty'
);
assert.ok(
!content.includes("isNFCMaterial(value) ? value : 'pvc'"),
'材质列禁止把无法识别的值默认成 pvc'
);
assert.ok(
!content.includes("? value : 'pvc'"),
'禁止三元回退未知材质为 pvc'
);
assert.ok(
!/field=["']material["'][\s\S]{0,400}initialValue=["']pvc["']/.test(content),
'筛选 Form.Item material 禁止 initialValue=pvc'
);
assert.ok(
!/field=["']material["'][\s\S]{0,400}defaultValue=["']pvc["']/.test(content),
'筛选 Form.Item material 禁止 defaultValue=pvc'
);
assert.ok(
!/material:\s*['"]pvc['"]/.test(content),
'禁止在 filters/表单状态里预填 material: pvc'
);
assert.ok(
!/defaultValue=["']pvc["']/.test(content) &&
!/initialValue=["']pvc["']/.test(content),
'页面禁止任何 pvc 预选初始值'
);
assert.ok(
localeContent.includes("'nfcDevices.material.empty'"),
'locale 必须定义材质空态 key'
);
for (const lang of ['en-US', 'zh-CN', 'ar-SA']) {
assert.ok(
localeContent.includes(`'${lang}'`),
`locale 必须包含语言包: ${lang}`
);
}
});

查看文件

@@ -127,3 +127,116 @@ test('3. nfc/rules 路由与国际化配置必须完整', () => {
assert.ok(rulesLocaleContent.includes('nfcRules.action.resolve'), '必须包含路由解析文案');
assert.ok(!/\bany\b/.test(rulesLocaleContent), 'locale 禁止使用 any');
});
test('4. placeholder 必须走 i18n,禁止硬编码英文 e.g. 占位', () => {
const content = fs.readFileSync(rulesPagePath, 'utf-8');
const rulesLocaleContent = fs.readFileSync(rulesLocalePath, 'utf-8');
const placeholderKeys = [
'nfcRules.filter.countryCode.placeholder',
'nfcRules.form.name.placeholder',
'nfcRules.form.deviceId.placeholder',
'nfcRules.form.targetCardId.placeholder',
'nfcRules.form.countryCode.placeholder',
'nfcRules.resolve.deviceId.placeholder',
'nfcRules.resolve.countryCode.placeholder',
];
for (const key of placeholderKeys) {
assert.ok(
content.includes(`t['${key}']`),
`页面 placeholder 必须使用 t['${key}']`
);
assert.ok(
rulesLocaleContent.includes(`'${key}'`),
`locale 必须包含 ${key}`
);
}
assert.ok(rulesLocaleContent.includes("'en-US'"), 'placeholder 必须有英文');
assert.ok(rulesLocaleContent.includes("'zh-CN'"), 'placeholder 必须有中文');
assert.ok(rulesLocaleContent.includes("'ar-SA'"), 'placeholder 必须有阿语');
assert.ok(!content.includes('e.g.'), '页面禁止硬编码 e.g. placeholder');
assert.ok(
!content.includes('US, AE, CN'),
'页面禁止硬编码国家筛选 placeholder'
);
assert.ok(
!content.includes('Gitex Dubai'),
'页面禁止硬编码规则名称 placeholder'
);
});
test('5. 新建规则地理字段必须为空,禁止预选迪拜/阿联酋/示例城市', () => {
const content = fs.readFileSync(rulesPagePath, 'utf-8');
// 新建时必须显式清空 countryCode,不得依赖隐式残留
assert.ok(
/handleOpenCreate[\s\S]*?setFieldsValue\(\{[\s\S]*?countryCode:\s*(undefined|null|['"]{2})/.test(
content
),
'handleOpenCreate 必须显式设置 countryCode 为空 (undefined/null/"")'
);
// 禁止把示例国家/城市写入表单初始值
const bannedGeoAssignments = [
/countryCode:\s*['"]AE['"]/i,
/countryCode:\s*['"]UAE['"]/i,
/countryCode:\s*['"]Dubai['"]/i,
/countryCode:\s*['"]CN['"]/,
/countryCode:\s*['"]US['"]/,
/countryCode:\s*['"]SG['"]/,
/city:\s*['"]Dubai['"]/i,
/city:\s*['"]迪拜['"]/,
];
for (const pattern of bannedGeoAssignments) {
assert.ok(
!pattern.test(content),
`禁止预填地理示例值: ${pattern}`
);
}
// Form.Item countryCode 禁止 defaultValue / initialValue 预选(仅检查该字段自身)
const countryCodeItems =
content.match(
/<Form\.Item[^>]*field=["']countryCode["'][\s\S]*?<\/Form\.Item>/g
) || [];
assert.ok(countryCodeItems.length > 0, '必须存在 countryCode Form.Item');
for (const item of countryCodeItems) {
assert.ok(
!/(defaultValue|initialValue)=["'][^"']+["']/.test(item),
'countryCode Form.Item 禁止 defaultValue/initialValue 预选'
);
}
// 页面源码不得把迪拜/阿联酋当表单默认展示(locale 占位文案不在本文件)
assert.ok(!content.includes('Dubai'), '页面禁止硬编码 Dubai');
assert.ok(!content.includes('迪拜'), '页面禁止硬编码迪拜');
assert.ok(!content.includes('阿联酋'), '页面禁止硬编码阿联酋');
assert.ok(!content.includes('United Arab Emirates'), '页面禁止硬编码 UAE 全称');
// 编辑仅回填接口 countryCode;无值时空串,不得塞示例
assert.ok(
/countryCode:\s*rule\.countryCode\s*\|\|\s*['"]{2}/.test(content),
'编辑必须用 rule.countryCode || "",无接口值则空'
);
// 模拟解析弹窗打开时必须重置地理字段,不得预填
assert.ok(
content.includes('resolveForm.resetFields()'),
'打开解析弹窗必须 resolveForm.resetFields() 清空地理等字段'
);
assert.ok(
/resolveForm\.setFieldsValue\(\{[\s\S]*?countryCode:\s*(undefined|null|['"]{2})/.test(
content
),
'打开解析弹窗必须显式清空 countryCode'
);
// 禁止任何 defaultValue/initialValue 指向示例国家码
assert.ok(
!/(defaultValue|initialValue)=["'](AE|UAE|Dubai|CN|US|SG)["']/i.test(content),
'禁止 defaultValue/initialValue 预选示例国家'
);
});

查看文件

@@ -11,8 +11,14 @@ const localePath = path.join(adminRoot, 'src/locale/index.ts');
const navBarPath = path.join(adminRoot, 'src/components/NavBar/index.tsx');
const subscribersPagePath = path.join(adminRoot, 'src/pages/platform/subscribers/index.tsx');
const renewalsPagePath = path.join(adminRoot, 'src/pages/platform/renewals/index.tsx');
const renewalsLocalePath = path.join(
adminRoot,
'src/pages/platform/renewals/locale/index.ts'
);
const subscriptionsPagePath = path.join(adminRoot, 'src/pages/platform/subscriptions/index.tsx');
const EMOJI_RE = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
test('1. routes.ts 必须配置 platform 平台总控与会员/续费/订阅三大路由', () => {
const content = fs.readFileSync(routesPath, 'utf-8');
assert.ok(content.includes("key: 'platform'"), 'routes.ts 必须包含 platform 模块');
@@ -37,7 +43,9 @@ test('3. locale/index.ts 必须包含平台总控相关中英文词条', () => {
test('4. NavBar 组件必须展示超级管理员 [最高权限] 身份标签', () => {
const content = fs.readFileSync(navBarPath, 'utf-8');
assert.ok(
content.includes('超级管理员') || content.includes('Platform Super Admin') || content.includes('最高权限'),
content.includes('超级管理员') ||
content.includes('Platform Super Admin') ||
content.includes('最高权限'),
'NavBar 必须展示超级管理员或最高权限身份标识'
);
});
@@ -59,3 +67,74 @@ test('5. 平台总控三大页面文件必须存在且包含核心 Arco 组件
assert.ok(planContent.includes('/api/admin/team/platform/plans'), '订阅管理必须调用对应后端接口');
assert.ok(planContent.includes('Card'), '订阅管理必须使用 Arco Card');
});
test('6. P39: 续费列表状态与到期日必须来自订阅接口,禁止演示假数据', () => {
assert.ok(fs.existsSync(renewalsLocalePath), '必须存在 platform/renewals/locale/index.ts');
const content = fs.readFileSync(renewalsPagePath, 'utf-8');
const localeContent = fs.readFileSync(renewalsLocalePath, 'utf-8');
// 状态 / 到期日来自订阅会员接口
assert.ok(
content.includes('/api/admin/team/platform/subscribers/page'),
'状态与到期日必须请求 /api/admin/team/platform/subscribers/page'
);
assert.ok(content.includes('expireDate'), '列表必须绑定接口 expireDate');
assert.ok(
content.includes('dataIndex: \'status\'') || content.includes('dataIndex: "status"'),
'列表必须绑定接口 status'
);
assert.ok(content.includes('SubscriberItem'), '必须使用 SubscriberItem 类型');
assert.ok(content.includes('code === 1000') || content.includes('code !== 1000'), '必须按 Cool-Admin code 校验');
// 代续费仍走续费接口
assert.ok(
content.includes('/api/admin/team/platform/renewals/manual-renew'),
'代续费必须调用 renewals/manual-renew'
);
// 禁止写死日期 / 演示行 / 假租户
assert.ok(!content.includes('即将到期'), '禁止硬编码「即将到期」演示行');
assert.ok(!content.includes('Vanguard'), '禁止硬编码演示租户名');
assert.ok(!/\b2026-\d{2}-\d{2}\b/.test(content), '禁止硬编码到期日 YYYY-MM-DD');
assert.ok(!/\b2099-\d{2}-\d{2}\b/.test(content), '禁止硬编码演示到期日');
assert.ok(!content.includes('ORD-2026'), '禁止硬编码演示订单号');
// 失败必须空态,不得保留假列表
assert.ok(
content.includes('setData([])') || content.includes('setData([] as'),
'接口失败必须 setData([]) 清空列表'
);
assert.ok(
content.includes('setTotal(0)') || content.includes('setTotal(0 as'),
'接口失败必须 setTotal(0)'
);
// 文案走 locale
assert.ok(content.includes("from './locale'") || content.includes('from "./locale"'), '必须引入页面 locale');
assert.ok(content.includes('useLocale(locale)'), '必须 useLocale(locale)');
const localeKeys = [
'menu.platform.renewals',
'platformRenewals.request.fail',
'platformRenewals.col.status',
'platformRenewals.col.expireDate',
'platformRenewals.empty',
'platformRenewals.status.active',
'platformRenewals.status.past_due',
];
for (const key of localeKeys) {
assert.ok(localeContent.includes(key), `语言包必须包含: ${key}`);
assert.ok(content.includes(key), `页面必须引用 locale key: ${key}`);
}
assert.ok(localeContent.includes("'en-US'"), 'locale 必须含 en-US');
assert.ok(localeContent.includes("'zh-CN'"), 'locale 必须含 zh-CN');
assert.ok(localeContent.includes("'ar-SA'"), 'locale 必须含 ar-SA');
assert.ok(!/\bany\b/.test(content), '严禁 any');
assert.ok(!EMOJI_RE.test(content), '页面严禁 emoji');
assert.ok(!EMOJI_RE.test(localeContent), '语言包严禁 emoji');
assert.ok(
!content.includes('Mock.js') && !content.includes("from 'mockjs'"),
'禁止用 Mock.js 假数据'
);
});

查看文件

@@ -0,0 +1,145 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const pagePath = path.join(adminRoot, 'src/pages/platform/subscribers/index.tsx');
const localePath = path.join(adminRoot, 'src/pages/platform/subscribers/locale/index.ts');
const EMOJI_RE = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
const HARDCODED_SUCCESS = [
'会员配置已更新',
'延长有效期',
'会员状态已切换',
];
function readPage(): string {
assert.ok(fs.existsSync(pagePath), '必须存在 platform/subscribers/index.tsx');
return fs.readFileSync(pagePath, 'utf-8');
}
function readLocale(): string {
assert.ok(fs.existsSync(localePath), '必须存在 platform/subscribers/locale/index.ts');
return fs.readFileSync(localePath, 'utf-8');
}
test('1. R20: 必须有页面 locale,且含成功/失败词条与三语', () => {
const localeContent = readLocale();
const keys = [
'platformSubscribers.update.success',
'platformSubscribers.update.fail',
'platformSubscribers.extend.success',
'platformSubscribers.extend.fail',
'platformSubscribers.status.success',
'platformSubscribers.status.fail',
];
for (const key of keys) {
assert.ok(localeContent.includes(key), `locale 必须包含: ${key}`);
}
assert.ok(localeContent.includes("'en-US'"), 'locale 必须含 en-US');
assert.ok(localeContent.includes("'zh-CN'"), 'locale 必须含 zh-CN');
assert.ok(localeContent.includes("'ar-SA'"), 'locale 必须含 ar-SA');
assert.ok(!EMOJI_RE.test(localeContent), '语言包严禁 emoji');
assert.ok(!/\bany\b/.test(localeContent), 'locale 禁止 any');
});
test('2. R20: 页面必须引入 locale,成功文案走 t[...]', () => {
const content = readPage();
assert.ok(
content.includes("from './locale'") || content.includes('from "./locale"'),
'必须引入页面 locale'
);
assert.ok(content.includes('useLocale(locale)'), '必须 useLocale(locale)');
const successKeys = [
'platformSubscribers.update.success',
'platformSubscribers.extend.success',
'platformSubscribers.status.success',
];
for (const key of successKeys) {
assert.ok(content.includes(key), `页面必须引用成功 locale key: ${key}`);
}
const failKeys = [
'platformSubscribers.update.fail',
'platformSubscribers.extend.fail',
'platformSubscribers.status.fail',
];
for (const key of failKeys) {
assert.ok(content.includes(key), `页面必须引用失败 locale key: ${key}`);
}
});
test('3. R20: 延期/改配置/切换状态仅在 code===1000 时 Message.success', () => {
const content = readPage();
assert.ok(
content.includes('/api/admin/team/platform/subscribers/update'),
'操作必须走 subscribers/update'
);
const successMatches = [...content.matchAll(/Message\.success\s*\(/g)];
assert.ok(successMatches.length >= 3, '改配置/延期/切状态成功时仍应 Message.success');
for (const match of successMatches) {
const index = match.index ?? 0;
const preceding = content.slice(Math.max(0, index - 280), index);
assert.ok(
/code\s*===\s*1000/.test(preceding),
`Message.success 必须位于 code === 1000 分支内,附近片段: ${preceding.slice(-100)}`
);
}
});
test('4. R20: 失败不得报延期成功或状态已切换;catch/非1000 不得 Message.success', () => {
const content = readPage();
assert.ok(
content.includes('code !== 1000') || content.includes('code === 1000'),
'必须按 Cool-Admin code 校验'
);
// 硬编码中文成功句不得再出现在页面源码(尤其 Message.success)
for (const phrase of HARDCODED_SUCCESS) {
assert.ok(
!content.includes(phrase),
`页面禁止硬编码成功句: ${phrase}`
);
}
const failBlocks = content.match(
/else\s*\{[\s\S]{0,220}\}|catch\s*(?:\([^)]*\))?\s*\{[\s\S]{0,220}\}/g
);
if (failBlocks) {
for (const block of failBlocks) {
assert.ok(
!block.includes('Message.success'),
`失败分支内不得 Message.success: ${block.slice(0, 120)}`
);
}
}
// 非 1000 分支须 Message.error(正分支 return 后、或 !== 1000 早退)
assert.ok(
/code\s*!==\s*1000[\s\S]{0,160}Message\.error/.test(content) ||
/if\s*\(\s*res\.data\?\.code\s*!==\s*1000[\s\S]{0,200}Message\.error/.test(
content
) ||
(/code\s*===\s*1000[\s\S]{0,400}Message\.error/.test(content) &&
content.includes('Message.error')),
'code 非 1000 时必须 Message.error'
);
assert.ok(content.includes('Message.error'), '失败须 Message.error');
});
test('5. R20: 禁止造假租户;操作不写演示企业名', () => {
const content = readPage();
assert.ok(!content.includes('Vanguard'), '禁止硬编码演示租户名 Vanguard');
assert.ok(!content.includes('Acme Corp'), '禁止硬编码演示租户名');
assert.ok(!content.includes('演示企业'), '禁止硬编码演示企业');
assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止 Mock.js');
assert.ok(!/\bany\b/.test(content), '严禁 any');
assert.ok(!EMOJI_RE.test(content), '页面严禁 emoji');
});

查看文件

@@ -0,0 +1,205 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const pagePath = path.join(
adminRoot,
'src/pages/platform/subscriptions/index.tsx'
);
const localePath = path.join(
adminRoot,
'src/pages/platform/subscriptions/locale/index.ts'
);
const EMOJI_RE = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
const HARDCODED_SUCCESS_RE = /规格配置已更新|配置已更新/;
const HARDCODED_PRICE_RE =
/monthlyPrice\s*[:=]\s*\d+|yearlyPrice\s*[:=]\s*\d+|\$\s*(9|19|29|49|99|199|299)\b/;
function readPage(): string {
assert.ok(fs.existsSync(pagePath), '必须存在 platform/subscriptions/index.tsx');
return fs.readFileSync(pagePath, 'utf-8');
}
function readLocale(): string {
assert.ok(
fs.existsSync(localePath),
'必须存在 platform/subscriptions/locale/index.ts'
);
return fs.readFileSync(localePath, 'utf-8');
}
test('1. R19: 订阅规格页必须请求 plans list/update,并按 code===1000 才成功提示', () => {
const content = readPage();
assert.ok(
content.includes('/api/admin/team/platform/plans/list'),
'必须请求 plans/list'
);
assert.ok(
content.includes('/api/admin/team/platform/plans/update'),
'必须请求 plans/update'
);
assert.ok(content.includes('axios'), '必须使用 axios');
assert.ok(
content.includes('code === 1000') || content.includes('code ===1000'),
'必须按 Cool-Admin code === 1000 判断保存成功'
);
assert.ok(content.includes('Message.success'), '真实保存成功仍应 Message.success');
assert.ok(content.includes('Message.error'), '失败必须 Message.error');
assert.ok(content.includes('PlanConfigItem'), '必须使用 PlanConfigItem 类型');
assert.ok(content.includes("from '@/types/omni'"), '必须复用 omni 类型');
assert.ok(!/\bany\b/.test(content), '严禁 any');
assert.ok(
!content.includes('Mock.js') && !content.includes("from 'mockjs'"),
'禁止 Mock.js'
);
});
test('2. R19: Message.success 仅允许出现在 code===1000 分支,失败不得报已更新', () => {
const content = readPage();
const successMatches = [...content.matchAll(/Message\.success\s*\(/g)];
assert.ok(successMatches.length > 0, '必须保留保存成功 Message.success');
for (const match of successMatches) {
const index = match.index ?? 0;
const preceding = content.slice(Math.max(0, index - 280), index);
assert.ok(
/code\s*===\s*1000/.test(preceding),
`Message.success 必须位于 code === 1000 分支内,附近: ${preceding.slice(-80)}`
);
}
const failBlocks = content.match(
/else\s*\{[\s\S]{0,200}\}|catch\s*(?:\([^)]*\))?\s*\{[\s\S]{0,200}\}/g
);
if (failBlocks) {
for (const block of failBlocks) {
assert.ok(
!block.includes('Message.success'),
`失败分支内不得 Message.success: ${block.slice(0, 120)}`
);
assert.ok(
!HARDCODED_SUCCESS_RE.test(block),
'失败分支不得硬编码已更新文案'
);
}
}
assert.ok(
content.includes("t['platformSubscriptions.request.updateSuccess']") ||
content.includes('t["platformSubscriptions.request.updateSuccess"]'),
'成功提示必须走 locale updateSuccess'
);
assert.ok(
content.includes("t['platformSubscriptions.request.saveFail']") ||
content.includes('t["platformSubscriptions.request.saveFail"]') ||
content.includes("t['platformSubscriptions.request.updateFail']") ||
content.includes('t["platformSubscriptions.request.updateFail"]'),
'保存失败必须走 locale 失败文案'
);
assert.ok(
content.includes("t['platformSubscriptions.request.fail']") ||
content.includes('t["platformSubscriptions.request.fail"]'),
'加载失败必须走 locale request.fail'
);
});
test('3. R19: 成功/失败文案走 locale,禁止硬编码成功句与套餐价格', () => {
const content = readPage();
const localeContent = readLocale();
assert.ok(
content.includes("from './locale'") || content.includes('from "./locale"'),
'必须引入页面 locale'
);
assert.ok(content.includes('useLocale(locale)'), '必须 useLocale(locale)');
assert.ok(!HARDCODED_SUCCESS_RE.test(content), '页面禁止硬编码「规格配置已更新」');
assert.ok(
!HARDCODED_SUCCESS_RE.test(localeContent) ||
localeContent.includes('platformSubscriptions.request.updateSuccess'),
'成功句只能作为 locale 词条值,不得散落在脚本模板'
);
// 页面脚本不得把成功句写死在模板字符串里
assert.ok(
!/`[^`]*规格配置已更新[^`]*`/.test(content) &&
!/'[^']*规格配置已更新[^']*'/.test(content) &&
!/"[^"]*规格配置已更新[^"]*"/.test(content),
'页面不得硬编码规格配置已更新'
);
assert.ok(!content.includes('获取套餐配置失败'), '加载失败不得硬编码中文');
const localeKeys = [
'menu.platform.subscriptions',
'platformSubscriptions.request.fail',
'platformSubscriptions.request.updateSuccess',
'platformSubscriptions.request.saveFail',
];
for (const key of localeKeys) {
assert.ok(localeContent.includes(key), `语言包必须包含: ${key}`);
}
assert.ok(
content.includes('platformSubscriptions.request.updateSuccess'),
'页面必须引用 updateSuccess'
);
assert.ok(
content.includes('platformSubscriptions.request.saveFail') ||
content.includes('platformSubscriptions.request.updateFail'),
'页面必须引用保存失败 key'
);
assert.ok(
content.includes('platformSubscriptions.request.fail'),
'页面必须引用 request.fail'
);
assert.ok(localeContent.includes("'en-US'"), 'locale 必须含 en-US');
assert.ok(localeContent.includes("'zh-CN'"), 'locale 必须含 zh-CN');
assert.ok(localeContent.includes("'ar-SA'"), 'locale 必须含 ar-SA');
// 价格来自接口 plan.monthlyPrice / yearlyPrice,禁止写死套餐价
assert.ok(
content.includes('plan.monthlyPrice') || content.includes('monthlyPrice'),
'月价必须绑定接口字段'
);
assert.ok(
content.includes('plan.yearlyPrice') || content.includes('yearlyPrice'),
'年价必须绑定接口字段'
);
assert.ok(!HARDCODED_PRICE_RE.test(content), '页面禁止写死套餐价格');
assert.ok(!HARDCODED_PRICE_RE.test(localeContent), 'locale 禁止写死套餐价格');
assert.ok(!EMOJI_RE.test(content), '页面严禁 emoji');
assert.ok(!EMOJI_RE.test(localeContent), '语言包严禁 emoji');
assert.ok(!/\bany\b/.test(localeContent), 'locale 严禁 any');
// 自审:模板/脚本不得把成功句写死;Message.success 只能引用 locale key
const successCalls = [
...content.matchAll(/Message\.success\s*\(\s*([^)]+)\s*\)/g),
];
assert.ok(successCalls.length > 0, '必须有 Message.success 调用');
for (const match of successCalls) {
const arg = match[1] ?? '';
assert.ok(
/platformSubscriptions\.request\.updateSuccess/.test(arg),
`Message.success 参数必须引用 updateSuccess locale key,实际: ${arg}`
);
assert.ok(
!HARDCODED_SUCCESS_RE.test(arg),
'Message.success 参数不得内联硬编码成功句'
);
}
assert.ok(
!content.includes('规格配置已更新'),
'页面脚本/模板自审:不得出现硬编码成功句「规格配置已更新」'
);
assert.ok(
/'platformSubscriptions\.request\.updateSuccess'\s*:\s*'/.test(
localeContent
),
'成功句只能存在于 locale 词条值'
);
});

查看文件

@@ -114,3 +114,89 @@ test('4. settings/audit-logs 语言包必须包含中英文案且零 emoji、零
'语言包严禁包含 emoji'
);
});
test('5. R18: 必须支持按 action 筛选,并将所选动作作为 page 请求参数发出', () => {
const content = readPage();
const localeContent = fs.readFileSync(auditLocalePath, 'utf-8');
assert.ok(
content.includes('selectedAction'),
'必须具备 selectedAction 筛选状态'
);
assert.ok(
content.includes('ACTION_OPTIONS') ||
(content.includes('nfc_freeze') && content.includes('lead_batch_export')),
'必须提供可选动作列表'
);
assert.ok(
/if\s*\(\s*filters\.action\s*\)\s*\{[\s\S]*?payload\.action\s*=\s*filters\.action/.test(
content
),
'仅当已选动作时写入 payload.action;清空筛选时不带空动作'
);
assert.ok(
!/payload\.action\s*=\s*['"]['"]/.test(content),
'禁止把空字符串硬编码进 payload.action'
);
assert.ok(
content.includes('setSelectedAction'),
'重置/变更筛选必须能更新 selectedAction'
);
assert.ok(
localeContent.includes("'auditLogs.filter.action'"),
'locale 必须包含 auditLogs.filter.action'
);
assert.ok(
localeContent.includes('auditLogs.actionType.nfc_freeze') &&
localeContent.includes('auditLogs.actionType.lead_batch_export'),
'locale 必须覆盖动作选项文案'
);
assert.ok(
content.includes("t['auditLogs.filter.action']"),
'action 筛选 placeholder 必须走 locale'
);
});
test('6. R18: 请求失败或 code 非 1000 必须清空表格,禁止残留示例日志', () => {
const content = readPage();
assert.ok(
/useState\s*<\s*BaseAuditLog\s*\[\s*\]\s*>\s*\(\s*\[\s*\]\s*\)/.test(
content
),
'初始 dataList 必须为空数组'
);
// else(非 1000)与 catch 两处都必须清空
const clearCalls = content.match(/setDataList\(\[\]\)/g) || [];
assert.ok(
clearCalls.length >= 2,
'code 非 1000 与 catch 均须 setDataList([]) 清空表格'
);
assert.ok(
content.includes('setTotal(0)'),
'失败时 total 也须归零'
);
assert.ok(
content.includes("t['auditLogs.request.fail']"),
'失败提示必须走 locale,禁止硬编码'
);
assert.ok(
!content.includes('Mock.js') &&
!content.includes('mockList') &&
!content.includes('示例日志') &&
!content.includes('sampleLogs') &&
!content.includes('fakeLogs'),
'禁止写死示例日志行或 mock 列表'
);
assert.ok(
!/operatorName:\s*['"][^'"]+['"]/.test(content) &&
!/detail:\s*['"][^'"]+['"]/.test(content) &&
!/ip:\s*['"]\d+\.\d+\.\d+\.\d+['"]/.test(content),
'禁止内联写死审计日志行字段'
);
// 成功分支才写入 list;失败不得回退到本地示例数组
assert.ok(
/res\.data\.list/.test(content) || /data\.list/.test(content),
'表格数据必须来自接口 list'
);
});

查看文件

@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import defaultI18n from '../src/locale/index.ts';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const billingPagePath = path.join(
@@ -14,6 +13,7 @@ const billingLocalePath = path.join(
adminRoot,
'src/pages/settings/billing/locale/index.ts'
);
const mainLocalePath = path.join(adminRoot, 'src/locale/index.ts');
function readPage(): string {
assert.ok(fs.existsSync(billingPagePath), '订阅账单页面必须存在');
@@ -25,6 +25,31 @@ function readLocale(): string {
return fs.readFileSync(billingLocalePath, 'utf-8');
}
function assertNoFabricatedCheckoutUrl(content: string, label: string) {
assert.ok(!content.includes('cs_test_'), `${label}禁止前端自造 cs_test_`);
assert.ok(
!content.includes('https://checkout.stripe.com'),
`${label}禁止前端自造完整 checkout URL`
);
assert.ok(!content.includes('sk_live'), `${label}禁止出现 sk_live`);
}
function assertNoFakePayPalCheckout(content: string, label: string) {
assert.ok(
!/paypal\.com/i.test(content),
`${label}禁止出现 paypal.com 假结账地址`
);
assert.ok(
!/www\.paypal\.com/i.test(content),
`${label}禁止出现 www.paypal.com`
);
assert.ok(
!content.includes('paypal-checkout') &&
!content.includes('billing/paypal'),
`${label}禁止伪造 PayPal 结账接口`
);
}
test('1. settings/billing 必须请求 billing/currentPlan', () => {
const content = readPage();
@@ -58,10 +83,8 @@ test('1. settings/billing 必须请求 billing/currentPlan', () => {
'禁止用 Mock.js 假数据冒充成功'
);
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
assert.ok(
!content.includes('stripe.com') && !content.includes('sk_live'),
'源码不含 stripe.com 或 sk_live'
);
assertNoFabricatedCheckoutUrl(content, '页面');
assertNoFakePayPalCheckout(content, '页面');
});
test('2. settings/billing 必须使用 Card / Progress / Tag', () => {
@@ -94,10 +117,8 @@ test('2. settings/billing 必须使用 Card / Progress / Tag', () => {
'禁止用 Mock.js'
);
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
assert.ok(
!content.includes('stripe.com') && !content.includes('sk_live'),
'源码不含 stripe.com 或 sk_live'
);
assertNoFabricatedCheckoutUrl(content, '页面');
assertNoFakePayPalCheckout(content, '页面');
});
test('3. settings/billing locale 必须包含 menu.settings.billing', () => {
@@ -109,6 +130,7 @@ test('3. settings/billing locale 必须包含 menu.settings.billing', () => {
);
assert.ok(content.includes("'en-US'"), '必须包含英文案');
assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
assert.ok(content.includes("'ar-SA'"), '必须包含阿语文案');
assert.ok(
content.includes('settingsBilling.request.fail'),
'必须包含失败提示文案'
@@ -121,16 +143,146 @@ test('3. settings/billing locale 必须包含 menu.settings.billing', () => {
content.includes('settingsBilling.alert'),
'必须包含国际卡与发票后续接入提示'
);
assert.ok(
content.includes('settingsBilling.checkout.stripeMissing'),
'必须包含未配置 Stripe 无法打开支付的 i18n'
);
assert.ok(
content.includes('settingsBilling.action.upgrade'),
'必须包含升级/结账文案'
);
assert.ok(
content.includes('settingsBilling.checkout.paypalUnavailable'),
'必须包含 PayPal 暂不可用文案 key'
);
assert.ok(
content.includes('settingsBilling.action.paypal'),
'必须包含 PayPal 按钮文案 key'
);
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
assert.ok(
!/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
'locale 禁止使用 emoji'
);
assert.ok(
!content.includes('stripe.com') && !content.includes('sk_live'),
'源码不含 stripe.com 或 sk_live'
);
assertNoFabricatedCheckoutUrl(content, 'locale');
assertNoFakePayPalCheckout(content, 'locale');
assert.equal(defaultI18n['en-US']['menu.settings.billing'], 'Billing');
assert.equal(defaultI18n['zh-CN']['menu.settings.billing'], '订阅账单');
const mainLocale = fs.readFileSync(mainLocalePath, 'utf-8');
assert.ok(
/'en-US'[\s\S]*'menu\.settings\.billing':\s*'Billing'/.test(mainLocale),
'主词典 en-US menu.settings.billing 应为 Billing'
);
assert.ok(
/'zh-CN'[\s\S]*'menu\.settings\.billing':\s*'订阅账单'/.test(mainLocale),
'主词典 zh-CN menu.settings.billing 应为 订阅账单'
);
});
test('4. settings/billing 升级结账仅信任 Stripe 结账域', () => {
const content = readPage();
const locale = readLocale();
assert.ok(
content.includes('stripe-checkout') ||
content.includes('billing/stripe-checkout'),
'必须请求 POST /api/admin/team/billing/stripe-checkout'
);
assert.ok(content.includes('window.open'), '成功时必须 window.open 打开结账页');
assert.ok(content.includes('Message.error'), '失败必须 Message.error');
assert.ok(
content.includes('checkout.stripe.com'),
'必须校验返回 url 主机为 checkout.stripe.com'
);
assert.ok(
content.includes("protocol === 'https:'") ||
content.includes('protocol === "https:"') ||
content.includes("protocol==='https:'"),
'必须校验返回 url 协议为 https'
);
assert.ok(
content.includes('settingsBilling.checkout.stripeMissing'),
'缺服务端文案时必须回退到 Stripe 未配置 i18n'
);
assert.ok(
locale.includes("'settingsBilling.checkout.stripeMissing'"),
'locale 必须声明 Stripe 未配置文案 key'
);
assert.ok(
/'zh-CN'[\s\S]*settingsBilling\.checkout\.stripeMissing'[\s\S]*未配置 Stripe/.test(
locale
),
'中文案必须含「未配置 Stripe」'
);
assert.ok(
/'en-US'[\s\S]*settingsBilling\.checkout\.stripeMissing'/.test(locale),
'英文案必须含 Stripe missing 提示'
);
assert.ok(
/'ar-SA'[\s\S]*settingsBilling\.checkout\.stripeMissing'/.test(locale),
'阿语文案必须含 Stripe missing 提示'
);
assertNoFabricatedCheckoutUrl(content, '结账逻辑');
assertNoFakePayPalCheckout(content, '结账逻辑');
assert.ok(!/\bany\b/.test(content), '结账逻辑禁止 any');
});
test('5. settings/billing PayPal 按钮必须禁用且不可假结账', () => {
const content = readPage();
const locale = readLocale();
assert.ok(
content.includes('settingsBilling.action.paypal'),
'页面必须渲染 PayPal 按钮文案'
);
assert.ok(
content.includes('settingsBilling.checkout.paypalUnavailable'),
'页面必须展示 PayPal 暂不可用说明'
);
assert.ok(
/disabled(\s*=\s*\{?\s*true\s*\}?)?/.test(content) ||
content.includes('disabled={true}') ||
content.includes('disabled'),
'PayPal 按钮必须 disabled'
);
assert.ok(
!content.includes('Message.success'),
'PayPal 点击不得 Message.success'
);
assert.ok(
!content.includes('window.open') ||
content.includes('isTrustedStripeCheckoutUrl'),
'window.open 仅允许用于受信 Stripe URL'
);
assert.ok(
!content.includes('paypal-checkout') &&
!content.includes('billing/paypal'),
'禁止请求不存在的 PayPal 结账接口'
);
assert.ok(
locale.includes("'settingsBilling.action.paypal'"),
'locale 必须声明 PayPal 按钮文案'
);
assert.ok(
locale.includes("'settingsBilling.checkout.paypalUnavailable'"),
'locale 必须声明 PayPal 暂不可用文案'
);
assert.ok(
/'zh-CN'[\s\S]*settingsBilling\.checkout\.paypalUnavailable'[\s\S]*暂不可用/.test(
locale
),
'中文案必须说明 PayPal 暂不可用'
);
assert.ok(
/'en-US'[\s\S]*settingsBilling\.checkout\.paypalUnavailable'[\s\S]*(unavailable|not available|temporarily)/i.test(
locale
),
'英文案必须说明 PayPal 暂不可用'
);
assert.ok(
/'ar-SA'[\s\S]*settingsBilling\.checkout\.paypalUnavailable'/.test(locale),
'阿语文案必须含 PayPal 暂不可用提示'
);
assertNoFakePayPalCheckout(content, 'PayPal 按钮');
assertNoFakePayPalCheckout(locale, 'PayPal locale');
assert.ok(!/\bany\b/.test(content), 'PayPal 逻辑禁止 any');
});

查看文件

@@ -96,7 +96,7 @@ test('2. settings/tenant 必须拉取 size=1、按 id 分流保存,并展示 T
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
});
test('3. settings/tenant locale 必须包含 menu.settings.tenant 与中英文案', () => {
test('3. settings/tenant locale 必须包含 menu.settings.tenant 与中英阿文案', () => {
assert.ok(fs.existsSync(tenantLocalePath), '租户域名语言包必须存在');
const content = fs.readFileSync(tenantLocalePath, 'utf-8');
@@ -106,12 +106,28 @@ test('3. settings/tenant locale 必须包含 menu.settings.tenant 与中英文
);
assert.ok(content.includes("'en-US'"), '必须包含英文案');
assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
assert.ok(content.includes("'ar-SA'"), '必须包含阿语文案');
assert.ok(content.includes('settingsTenant.request.fail'), '必须包含失败提示文案');
assert.ok(content.includes('settingsTenant.form.save'), '必须包含保存主 CTA 文案');
assert.ok(content.includes('customDomain'), '必须包含 customDomain 文案');
assert.ok(content.includes('sslStatus') || content.includes('settingsTenant.ssl'), '必须包含 ssl 状态文案');
assert.ok(content.includes('settingsTenant.ssl.alert'), '必须包含证书检测 Alert 文案');
assert.ok(content.includes('settingsTenant.ssl.unverified'), '必须包含 SSL 未核验文案');
assert.ok(content.includes('settingsTenant.ssl.issued'), '必须包含证书已签发文案');
assert.ok(content.includes('settingsTenant.verify.true'), '必须包含已核验文案');
assert.ok(content.includes('settingsTenant.verify.false'), '必须包含未核验文案');
assert.ok(content.includes('settingsTenant.cdn.notConfigured'), '必须包含 CDN 未配置文案');
assert.ok(
content.includes('settingsTenant.form.cdnStatus') ||
content.includes('settingsTenant.cdn.status'),
'必须包含 CDN 状态标签文案'
);
assert.ok(content.includes('pending'), '必须包含 pending 状态文案');
assert.ok(!content.includes("'已启用'"), 'locale 禁止写死 SSL 已启用');
assert.ok(
!content.includes("'已加速'") && !content.includes("'已开通全球 CDN'"),
'locale 禁止写死 CDN 已加速'
);
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
assert.ok(
!/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
@@ -121,3 +137,112 @@ test('3. settings/tenant locale 必须包含 menu.settings.tenant 与中英文
assert.equal(defaultI18n['en-US']['menu.settings.tenant'], 'Domain & SSL');
assert.equal(defaultI18n['zh-CN']['menu.settings.tenant'], '域名与证书');
});
test('4. settings/tenant 必须按 verify-dns 的 verified/ssl 展示,未核验不得显示已签发', () => {
const content = readPage();
assert.ok(
content.includes('admin/team/domain/verify-dns'),
'必须请求 POST /api/admin/team/domain/verify-dns'
);
assert.ok(content.includes('verified'), '必须读取核验接口 verified 字段');
assert.ok(
content.includes("settingsTenant.ssl.issued") ||
content.includes('settingsTenant.ssl.issued'),
'证书已签发必须走 locale key'
);
assert.ok(
content.includes('settingsTenant.ssl.unverified'),
'无 ssl 或未核验时必须走 未核验 locale'
);
assert.ok(
content.includes('settingsTenant.verify.true') &&
content.includes('settingsTenant.verify.false'),
'核验状态必须走 locale'
);
assert.ok(
content.includes('verified === true') ||
content.includes('verified===true') ||
content.includes('verified !== true') ||
content.includes('verified!==true'),
'必须用 verified === true 门控,禁止未核验时显示已签发'
);
assert.ok(
content.includes("'ssl'") ||
content.includes('"ssl"') ||
content.includes('.ssl'),
'必须检查接口是否返回 ssl 字段'
);
assert.ok(
!content.includes('已启用') &&
!/\bsslStatus:\s*['"]active['"]/.test(content) &&
!/\bsslDisplay\s*=\s*['"]issued['"]/.test(content),
'禁止写死 SSL 开启/已签发态'
);
assert.ok(
!/证书已签发/.test(content),
'页面源码禁止硬编码「证书已签发」,须走 locale'
);
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
});
test('5. settings/tenant CDN:无接口或未返回已启用时必须显示未配置,禁止假加速', () => {
const content = readPage();
const localeContent = fs.readFileSync(tenantLocalePath, 'utf-8');
assert.ok(
content.includes('settingsTenant.cdn.status') ||
content.includes('settingsTenant.form.cdnStatus'),
'必须展示 CDN 状态标签文案 key'
);
assert.ok(
content.includes('settingsTenant.cdn.notConfigured'),
'无 CDN 接口或未启用时必须走 未配置 locale'
);
assert.ok(
content.includes('resolveCdnDisplay') ||
content.includes('cdnDisplay'),
'必须有 CDN 展示态解析,禁止写死已加速'
);
assert.ok(
content.includes("enabled === true") ||
content.includes('enabled===true') ||
content.includes("enabled !== true") ||
content.includes('enabled!==true'),
'CDN 仅在 enabled === true 时才可显示已启用'
);
assert.ok(
!content.includes('已加速') &&
!content.includes('已开通全球 CDN') &&
!content.includes('全球 CDN 已开通'),
'页面禁止提示已加速/已开通全球 CDN'
);
assert.ok(
!/cdn\.(omnicard|cloudflare|akamai|fastly)\./i.test(content) &&
!/cname\.cdn\./i.test(content) &&
!content.includes('cdn.omnicard.ai'),
'禁止造假 CDN 域名'
);
assert.ok(
localeContent.includes('settingsTenant.cdn.notConfigured'),
'locale 必须包含 CDN 未配置文案'
);
assert.ok(
localeContent.includes('settingsTenant.cdn.status') ||
localeContent.includes('settingsTenant.form.cdnStatus'),
'locale 必须包含 CDN 状态标签'
);
assert.ok(
!localeContent.includes("'已加速'") &&
!localeContent.includes('"已加速"') &&
!localeContent.includes("'已开通全球 CDN'") &&
!localeContent.includes('"已开通全球 CDN"'),
'locale 禁止写死已加速/已开通全球 CDN'
);
assert.ok(
/未配置/.test(localeContent) ||
/Not configured/i.test(localeContent),
'locale 未配置文案须存在'
);
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
});

查看文件

@@ -112,3 +112,131 @@ test('3. team/brand-kit locale 必须包含 menu.team.brandKit 与中英文案',
assert.equal(defaultI18n['en-US']['menu.team.brandKit'], 'Brand Kit');
assert.equal(defaultI18n['zh-CN']['menu.team.brandKit'], '品牌规范');
});
test('4. 版权页脚:无真实保存字段时不得提交/假装已生效,须禁用或提示暂不可保存', () => {
const content = readPage();
const localeContent = fs.readFileSync(brandKitLocalePath, 'utf-8');
// 当前 brand 保存契约仅有 companyName / logoUrl / primaryColor,无 footer|copyright|legalNotice
const savePayloadMatch = content.match(
/interface BrandSavePayload\s*\{[\s\S]*?\}/
);
const savePayloadBlock = savePayloadMatch?.[0] ?? '';
const hasFooterInSavePayload = /\b(footer|copyright|legalNotice)\b/.test(
savePayloadBlock
);
const hasFooterAssignedToBody =
/body\.(footer|copyright|legalNotice)\s*=/.test(content) ||
/(?:const|let)\s+body\s*[:=][^{]*\{[\s\S]*?\b(footer|copyright|legalNotice)\s*:/.test(
content
);
assert.ok(
localeContent.includes("'teamBrandKit.footer.unavailable'") ||
localeContent.includes("'teamBrandKit.copyright.unavailable'") ||
localeContent.includes("'teamBrandKit.legalNotice.unavailable'"),
'locale 必须包含页脚/版权暂不可保存词条'
);
assert.ok(
localeContent.includes("'teamBrandKit.form.footer'") ||
localeContent.includes("'teamBrandKit.form.copyright'") ||
localeContent.includes("'teamBrandKit.form.legalNotice'"),
'locale 必须包含页脚输入标签文案'
);
assert.ok(
content.includes("t['teamBrandKit.footer.unavailable']") ||
content.includes("t['teamBrandKit.copyright.unavailable']") ||
content.includes("t['teamBrandKit.legalNotice.unavailable']") ||
content.includes('teamBrandKit.footer.unavailable') ||
content.includes('teamBrandKit.copyright.unavailable') ||
content.includes('teamBrandKit.legalNotice.unavailable'),
'页面必须引用页脚暂不可保存文案'
);
// 禁止把写死版权句当成已保存生效内容
assert.ok(
!/©\s*20\d{2}|All rights reserved|版权所有|保留所有权利/i.test(content),
'禁止页面写死版权声明并当成已生效'
);
assert.ok(
!hasFooterInSavePayload && !hasFooterAssignedToBody,
'无后端页脚字段时禁止把 footer/copyright/legalNotice 写入保存 payload'
);
// 空值不得提交假声明(保存体不得硬编码版权句子)
assert.ok(
!/BrandSavePayload[\s\S]{0,400}(©|All rights reserved|版权所有)/i.test(
content
),
'空值不得向保存接口提交假版权声明'
);
const hasFooterSaveField = hasFooterInSavePayload || hasFooterAssignedToBody;
if (!hasFooterSaveField) {
const footerInputShown =
/field=["'](footer|copyright|legalNotice)["']/.test(content) ||
content.includes('teamBrandKit.form.footer') ||
content.includes('teamBrandKit.form.copyright') ||
content.includes('teamBrandKit.form.legalNotice');
assert.ok(footerInputShown, '可展示页脚输入,但不得当作已保存字段');
const footerDisabled =
/disabled[\s\S]{0,160}(footer|copyright|legalNotice)|(footer|copyright|legalNotice)[\s\S]{0,200}disabled/i.test(
content
) ||
content.includes('disabled={!FOOTER_SAVE_SUPPORTED}') ||
content.includes('disabled={true}');
const footerUnavailableHint =
content.includes("t['teamBrandKit.footer.unavailable']") ||
content.includes("t['teamBrandKit.copyright.unavailable']") ||
content.includes("t['teamBrandKit.legalNotice.unavailable']") ||
content.includes('teamBrandKit.footer.unavailable') ||
content.includes('teamBrandKit.copyright.unavailable') ||
content.includes('teamBrandKit.legalNotice.unavailable');
assert.ok(
footerDisabled || footerUnavailableHint,
'无页脚保存字段时必须禁用输入/保存或提示暂不可保存'
);
}
// Message.success 仅允许出现在 code === 1000 成功分支
const successMatches = [
...content.matchAll(/Message\.success\s*\([^)]*\)/g),
];
for (const match of successMatches) {
const idx = match.index ?? 0;
const windowBefore = content.slice(Math.max(0, idx - 280), idx);
assert.ok(
/code\s*===\s*1000/.test(windowBefore),
'保存失败不得 Message.success;成功提示必须在 code === 1000 分支内'
);
}
});
test('5. locale 页脚词条须说明暂不可保存,且中英文齐全', () => {
const localeContent = fs.readFileSync(brandKitLocalePath, 'utf-8');
const unavailableKey =
localeContent.includes("'teamBrandKit.footer.unavailable'")
? 'teamBrandKit.footer.unavailable'
: localeContent.includes("'teamBrandKit.copyright.unavailable'")
? 'teamBrandKit.copyright.unavailable'
: 'teamBrandKit.legalNotice.unavailable';
assert.ok(
localeContent.includes(`'${unavailableKey}'`),
'必须包含暂不可保存词条'
);
assert.ok(
/暂不可保存|暂不可用|not available|cannot be saved|unavailable/i.test(
localeContent
),
'暂不可保存文案须明确不可落库'
);
assert.ok(!/\bany\b/.test(localeContent), 'locale 禁止使用 any');
assert.ok(
!/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(
localeContent
),
'locale 禁止使用 emoji'
);
});

查看文件

@@ -83,6 +83,7 @@ test('3. team/handover locale 必须包含 menu.team.handover 与中英文案',
);
assert.ok(content.includes("'en-US'"), '必须包含英文案');
assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
assert.ok(content.includes("'ar-SA'"), '必须包含阿语文案');
assert.ok(content.includes('teamHandover.form.cardId'), '必须包含 cardId 文案');
assert.ok(content.includes('teamHandover.form.fromUserId'), '必须包含 fromUserId 文案');
assert.ok(content.includes('teamHandover.form.toUserId'), '必须包含 toUserId 文案');
@@ -99,3 +100,49 @@ test('3. team/handover locale 必须包含 menu.team.handover 与中英文案',
assert.equal(defaultI18n['en-US']['menu.team.handover'], 'Handover');
assert.equal(defaultI18n['zh-CN']['menu.team.handover'], '离职交接');
});
test('4. 离职人/接替人必须用 Select(成员列表),提交字段仍是用户 ID', () => {
const content = readPage();
assert.ok(content.includes('Select'), '必须使用 Arco Select');
assert.ok(
content.includes('admin/team/members/getMemberPage'),
'离职人/接替人选项必须来自现有 getMemberPage 接口'
);
assert.ok(
content.includes('field="fromUserId"'),
'表单字段名仍必须是 fromUserId'
);
assert.ok(
content.includes('field="toUserId"'),
'表单字段名仍必须是 toUserId'
);
assert.ok(
/fromUserId[\s\S]*toUserId/.test(content) &&
content.includes('HandoverRequest'),
'提交体必须仍按 HandoverRequest 携带 fromUserId / toUserId'
);
assert.ok(
content.includes('body') || content.includes('HandoverRequest'),
'必须构造交接请求体'
);
// 离职人、接替人表单项不得再用裸 Input 手填用户 ID
const fromBlock = content.slice(
content.indexOf('field="fromUserId"'),
content.indexOf('field="toUserId"')
);
const toStart = content.indexOf('field="toUserId"');
const toBlock = content.slice(
toStart,
content.indexOf('Form.Item', content.indexOf('</Form.Item>', toStart) + 1)
);
assert.ok(fromBlock.includes('<Select'), 'fromUserId 必须使用 Select');
assert.ok(!/<Input[\s>]/.test(fromBlock), 'fromUserId 禁止裸 Input 手填用户 ID');
assert.ok(toBlock.includes('<Select'), 'toUserId 必须使用 Select');
assert.ok(!/<Input[\s>]/.test(toBlock), 'toUserId 禁止裸 Input 手填用户 ID');
assert.ok(
!content.includes('modules/team/service/handover') &&
!content.includes('backend/src/modules'),
'禁止改动或引用后端 handover service'
);
});

查看文件

@@ -124,6 +124,15 @@ test('4. team/members locale 必须包含中英多语言且零 emoji、零 any',
assert.ok(content.includes('teamMembers.memberTable.title'), '必须包含成员列表标题 key');
assert.ok(content.includes('teamMembers.modal.assignTitle'), '必须包含名片分配弹窗 key');
assert.ok(content.includes('teamMembers.modal.moveTitle'), '必须包含批量调部弹窗 key');
assert.ok(
content.includes("'teamMembers.invite.unavailable'"),
'locale 必须包含邀请暂不可用词条'
);
assert.ok(
content.includes("'teamMembers.memberTable.invite'") ||
content.includes("'teamMembers.invite.action'"),
'locale 必须包含邀请成员按钮文案'
);
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
assert.ok(
@@ -131,3 +140,95 @@ test('4. team/members locale 必须包含中英多语言且零 emoji、零 any',
'locale 禁止使用 emoji'
);
});
test('5. 无真实邀请接口时不得假 Message.success;须禁用或提示暂不可邀请', () => {
const content = readPage();
const localeContent = fs.readFileSync(membersLocalePath, 'utf-8');
const hasInviteApi =
/admin\/team\/members\/invite/i.test(content) ||
/admin\/team\/members\/createInvite/i.test(content) ||
/admin\/team\/members\/generateInvite/i.test(content);
assert.ok(
localeContent.includes("'teamMembers.invite.unavailable'"),
'locale 必须包含 teamMembers.invite.unavailable'
);
assert.ok(
content.includes("t['teamMembers.invite.unavailable']") ||
content.includes('teamMembers.invite.unavailable'),
'页面必须引用 invite.unavailable 文案'
);
// 不得在无真实邀请接口时假装邀请成功
const hasFakeInviteSuccess =
/Message\.success\s*\(\s*t\[['"]teamMembers\.invite\.success['"]\]/.test(
content
) ||
(/handleInvite[\s\S]{0,240}Message\.success/.test(content) && !hasInviteApi);
assert.ok(!hasFakeInviteSuccess, '无邀请接口时禁止 Message.success 假装邀请成功');
// 禁止前端伪造邀请链接 / 邀请码资源
assert.ok(
!/inviteUrl|inviteLink|invitationUrl|invitationLink/i.test(content) ||
hasInviteApi,
'无真实接口时禁止生成假邀请链接字段'
);
assert.ok(
!/\bQRCode\b|from ['"][^'"]*qrcode|qrcode\.react|qr-code/i.test(content) ||
hasInviteApi,
'无真实接口时禁止生成假邀请二维码'
);
assert.ok(
!content.includes('createObjectURL') || hasInviteApi,
'无真实接口时禁止 createObjectURL 伪造邀请资源'
);
if (!hasInviteApi) {
const inviteDisabled =
/disabled[\s\S]{0,120}invite|invite[\s\S]{0,160}disabled/i.test(content) ||
content.includes('disabled={true}') ||
/Button[\s\S]{0,400}invite[\s\S]{0,200}disabled/i.test(content) ||
/disabled[\s\S]{0,200}teamMembers\.(memberTable\.invite|invite\.action)/i.test(
content
);
const inviteUnavailableHint =
content.includes("t['teamMembers.invite.unavailable']") ||
content.includes('teamMembers.invite.unavailable');
assert.ok(
inviteDisabled || inviteUnavailableHint,
'无邀请接口时必须禁用邀请按钮或提示暂不可邀请'
);
assert.ok(
content.includes('Message.warning') ||
content.includes('Message.info') ||
content.includes('Message.error'),
'无邀请接口时必须通过 Message 提示不可邀请'
);
}
});
test('6. 已接真实接口的成员操作失败时不得 Message.success', () => {
const content = readPage();
// 每个 Message.success 必须出现在 code === 1000 成功分支内
const successMatches = [
...content.matchAll(/Message\.success\s*\([^)]*\)/g),
];
assert.ok(successMatches.length > 0, '真实操作成功时仍应使用 Message.success');
for (const match of successMatches) {
const idx = match.index ?? 0;
const windowStart = Math.max(0, idx - 280);
const preceding = content.slice(windowStart, idx);
assert.ok(
/code\s*===\s*1000/.test(preceding),
`Message.success 必须位于 code === 1000 分支内,附近片段: ${preceding.slice(-80)}`
);
}
assert.ok(
content.includes('Message.error'),
'失败必须使用 Message.error,不得报成功'
);
});