补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
300 行
11 KiB
TypeScript
300 行
11 KiB
TypeScript
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';
|
|
import defaultI18n from '../src/locale/index.ts';
|
|
|
|
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const profilePagePath = path.join(
|
|
adminRoot,
|
|
'src/pages/crm/lead-profile/index.tsx'
|
|
);
|
|
const profileLocalePath = path.join(
|
|
adminRoot,
|
|
'src/pages/crm/lead-profile/locale/index.ts'
|
|
);
|
|
|
|
const emojiPattern = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
|
|
|
test('1. crm/lead-profile 必须 GET lead/info 并 POST radar/page', () => {
|
|
assert.ok(fs.existsSync(profilePagePath), '线索画像页面必须存在');
|
|
const content = fs.readFileSync(profilePagePath, 'utf-8');
|
|
|
|
assert.ok(
|
|
content.includes('admin/crm/lead/info'),
|
|
'必须请求 GET /api/admin/crm/lead/info'
|
|
);
|
|
assert.ok(
|
|
content.includes('axios.get'),
|
|
'lead/info 必须使用 GET,而不是 POST'
|
|
);
|
|
assert.ok(
|
|
content.includes('admin/crm/radar/page'),
|
|
'必须请求 POST /api/admin/crm/radar/page'
|
|
);
|
|
assert.ok(content.includes('axios.post'), '足迹必须使用 POST radar/page');
|
|
assert.ok(
|
|
content.includes('keyWord') && content.includes('visitorId'),
|
|
'足迹请求必须用 visitorId 作为 keyWord'
|
|
);
|
|
assert.ok(content.includes('cardId'), '足迹请求必须支持 cardId 字段筛选');
|
|
assert.ok(
|
|
content.includes('code === 1000') || content.includes('code ===1000'),
|
|
'必须按 Cool-Admin code === 1000 判断成功'
|
|
);
|
|
assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
|
|
assert.ok(
|
|
!content.includes('Mock.js') &&
|
|
!content.includes("from 'mockjs'") &&
|
|
!content.includes('mockjs'),
|
|
'禁止用 Mock.js 假数据冒充成功'
|
|
);
|
|
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
|
|
assert.ok(!emojiPattern.test(content), '禁止使用 emoji');
|
|
});
|
|
|
|
test('2. crm/lead-profile 必须用 Descriptions 或 Card + Timeline 展示画像与足迹时间轴', () => {
|
|
const content = fs.readFileSync(profilePagePath, 'utf-8');
|
|
|
|
assert.ok(
|
|
content.includes("from '@arco-design/web-react'"),
|
|
'必须从 @arco-design/web-react 引入组件'
|
|
);
|
|
assert.ok(content.includes('Descriptions') || content.includes('Card'), '必须使用 Descriptions 或 Card');
|
|
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');
|
|
assert.ok(content.includes("from '@/style/tokens'"), '颜色必须引用设计 token');
|
|
|
|
for (const field of [
|
|
'name',
|
|
'company',
|
|
'email',
|
|
'phone',
|
|
'status',
|
|
'intentScore',
|
|
'source',
|
|
'notes',
|
|
]) {
|
|
assert.ok(content.includes(field), `画像必须展示字段: ${field}`);
|
|
}
|
|
for (const field of [
|
|
'touchType',
|
|
'stayDurationMs',
|
|
'scrollDepth',
|
|
'viewedBlocks',
|
|
'country',
|
|
'city',
|
|
]) {
|
|
assert.ok(content.includes(field), `时间轴必须展示字段: ${field}`);
|
|
}
|
|
});
|
|
|
|
test('3. crm/lead-profile 必须从 URL search 读取 id,无 id 给空状态主 CTA', () => {
|
|
const content = fs.readFileSync(profilePagePath, 'utf-8');
|
|
|
|
assert.ok(content.includes('useLocation'), '必须用 react-router useLocation 读路由');
|
|
assert.ok(content.includes('location.search'), '必须从 location.search 读取查询串');
|
|
assert.ok(
|
|
content.includes('query-string') ||
|
|
content.includes('queryString') ||
|
|
content.includes('URLSearchParams'),
|
|
'必须用 querystring 或 URLSearchParams 解析 id'
|
|
);
|
|
assert.ok(
|
|
content.includes("get('id')") ||
|
|
content.includes('.id') ||
|
|
content.includes("['id']"),
|
|
'必须读取 query 中的 id'
|
|
);
|
|
assert.ok(
|
|
content.includes('empty') || content.includes('Empty'),
|
|
'无 id 必须有空状态'
|
|
);
|
|
assert.ok(content.includes('type="primary"') || content.includes("type='primary'"), '空状态必须有一个主 CTA');
|
|
assert.ok(content.includes('/crm/leads'), '空状态主 CTA 应回到线索说明页');
|
|
});
|
|
|
|
test('4. locale 必须包含 menu.crm.leadProfile 中英文案,且禁止 any / mockjs / emoji', () => {
|
|
assert.ok(fs.existsSync(profileLocalePath), '线索画像语言包必须存在');
|
|
const content = fs.readFileSync(profileLocalePath, 'utf-8');
|
|
|
|
assert.ok(
|
|
content.includes("'menu.crm.leadProfile'"),
|
|
'页面语言包必须包含 menu.crm.leadProfile'
|
|
);
|
|
assert.equal(defaultI18n['en-US']['menu.crm.leadProfile'], 'Lead Profile');
|
|
assert.equal(defaultI18n['zh-CN']['menu.crm.leadProfile'], '线索画像');
|
|
assert.ok(content.includes('crmLeadProfile.request.fail'), '必须包含失败提示文案');
|
|
assert.ok(content.includes('crmLeadProfile.empty'), '必须包含空状态文案');
|
|
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
|
|
assert.ok(
|
|
!content.includes('Mock.js') && !content.includes('mockjs'),
|
|
'locale 禁止 Mock.js'
|
|
);
|
|
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 状态渲染,不得写死事件节点'
|
|
);
|
|
});
|