import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import { parse, compileTemplate } from '@vue/compiler-sfc' import * as vue from 'vue' import ts from 'typescript' import * as labels from '../src/utils/crm-opportunity-labels' import { formatViewValue } from '../src/components/business/system-view-dialog/types' const mobile = parse( readFileSync(new URL('../src/views/m/crm/customer/RelatedPanel.vue', import.meta.url), 'utf8') ).descriptor const web = parse( readFileSync( new URL('../src/views/crm/opportunity/components/OpportunityDetailDrawer.vue', import.meta.url), 'utf8' ) ).descriptor function declarations(source: string, names: string[]) { const ast = ts.createSourceFile('component.ts', source, ts.ScriptTarget.Latest, true) return ast.statements .filter((node) => ts.isFunctionDeclaration(node) ? names.includes(node.name!.text) : ts.isVariableStatement(node) && node.declarationList.declarations.some((d) => names.includes(d.name.getText(ast))) ) .map((node) => node.getText(ast)) .join('\n') } function evaluate(code: string, bindings: Record, result: string) { const entries = Object.entries(bindings).filter( ([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default' ) return new Function( ...entries.map(([key]) => key), ts.transpile(code, { target: ts.ScriptTarget.ESNext }) + ';return ' + result )(...entries.map(([, value]) => value)) } function harness(row: Record = {}, kind = 'opportunity') { const detail = vue.ref(row) const dictMap = vue.reactive>({ crm_opportunity_type: [{ value: 'new', label: '租户自定义新业务', tagType: 'danger' }], crm_clue_source: [{ value: 'phone', label: '热线获客', tagType: 'warning' }], crm_opportunity_stage: [{ value: 'initial', label: '接洽', tagType: 'success' }], crm_opportunity_status: [{ value: 'following', label: '跟进中', tagType: 'primary' }], crm_follow_type: [{ value: 'phone', label: '电话回访', tagType: 'danger' }] }) const dict = { dictMap, getOptions: (code: string) => dictMap[code] || [], getTagType: (code: string, value: string) => dictMap[code]?.find((v) => v.value === value)?.tagType || 'info' } const names = [ 'fieldsFrom', 'opportunityFields', 'contractFields', 'logFields', 'detailFields', 'formatOpportunityMoney', 'formatOpportunityDateTime' ] const api = evaluate( declarations(mobile.scriptSetup!.content, names), { ...labels, ...vue, formatViewValue, props: { kind }, detail, dict }, '{detailFields, date: typeof formatOpportunityDateTime === "function" ? formatOpportunityDateTime : undefined, money: typeof formatOpportunityMoney === "function" ? formatOpportunityMoney : undefined}' ) return { ...api, detail, dict } } test('业务字段按Web整组条件出现,非单项过滤;lost和备注严格遵守条件', () => { const h = harness({ id: '1', status: 'following' }) const keys = () => h.detailFields.value.map((f: any) => f.key) const business = ['competitors', 'keyFactors', 'description'] for (const key of [...business, 'lostReason', 'remark']) assert.ok(!keys().includes(key), key) for (const key of business) { h.detail.value = { id: '1', [key]: '内容' } for (const included of business) assert.ok(keys().includes(included), included) } for (const value of ['', null, undefined, 0, false]) { h.detail.value = { id: '1', competitors: value, keyFactors: value, description: value, status: 'lost', lostReason: value, remark: value } for (const key of [...business, 'lostReason', 'remark']) assert.ok(!keys().includes(key), key) } h.detail.value = { id: '1', description: ' ', status: 'following', lostReason: '旧原因', remark: ' ' } assert.ok(keys().includes('description'), 'Web使用truthy而非trim') assert.ok(keys().includes('remark')) assert.ok(!keys().includes('lostReason')) h.detail.value.status = 'lost' assert.ok(keys().includes('lostReason')) }) test('只读金额、计数、百分比、时间、字典和Tag与Web语义对应', () => { const h = harness({ id: '1', amount: 1234.5, stagePercent: 0, winRate: 25.2, type: 'new', source: 'phone', stage: 'initial', status: 'following', nextTime: '2000-01-02T03:04:05.000Z' }) const field = (key: string) => h.detailFields.value.find((f: any) => f.key === key) assert.equal(field('amount').value, '¥1,234.50') assert.equal(field('followCount').value, '0 次') assert.equal(field('productCount').value, '0 个') assert.equal(field('stagePercent').value, '0%') assert.equal(field('winRate').value, '25.2%') assert.equal(field('type').value, '租户自定义新业务', '字典优先历史fallback') assert.equal(field('type').tagType, 'primary', 'Web类型固定primary') assert.equal(field('source').value, '热线获客') assert.equal(field('source').tagType, 'warning') assert.equal(field('stage').tagType, 'success') assert.equal(field('status').tagType, 'primary') assert.equal(field('nextTime').value, '2000-01-02 03:04:05') assert.equal(field('nextTime').tagType, undefined, '商机不沿用客户逾期色') h.detail.value = { id: '1', type: 'upgrade', source: 'referral', amount: 0, followCount: 3, productCount: 4 } assert.equal(field('type').value, '升级') assert.equal(field('source').value, '客户推荐') assert.equal(field('amount').value, '¥0.00') assert.equal(field('followCount').value, '3 次') assert.equal(field('productCount').value, '4 个') assert.equal(field('stagePercent').value, '—') h.dict.dictMap.crm_opportunity_type = [ { value: 'upgrade', label: '更新后的字典', tagType: 'warning' } ] assert.equal(field('type').value, '更新后的字典', '字典晚加载保持响应式') }) test('金额日期格式直接对照Web实际函数,覆盖零值、空值、非法值与ISO输入', () => { const baseline = evaluate( declarations(web.scriptSetup!.content, ['formatMoney', 'formatDateTime']), {}, '{formatMoney,formatDateTime}' ) const h = harness() assert.equal(typeof h.money, 'function') assert.equal(typeof h.date, 'function') for (const input of [null, undefined, '', 0, -1234.567, '1234.50', 'bad', '12.3元']) assert.equal(h.money(input), baseline.formatMoney(input)) for (const input of [ null, undefined, '', '2026-10-23', '2026-10-23 12:13:14', '2026-10-23T12:13:14.000Z', 'bad', '2026-10-23T12:13:14+08:00' ]) assert.equal(h.date(input), baseline.formatDateTime(input)) }) test('商机只读模板独立消费格式值及Tag,不套用客户nextTime规则', () => { const start = mobile.template!.content.indexOf('
') const end = mobile.template!.content.indexOf('
', start) + ''.length const section = mobile.template!.content.slice(start, end) const { code, errors } = compileTemplate({ source: section, filename: 'section.vue', id: 'section' }) assert.deepEqual(errors, []) const exports: any = {} new Function( 'require', 'exports', ts.transpile(code, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext }) )(() => ({ ...vue, resolveComponent: (name: string) => name }), exports) const h = harness({ id: '1', type: 'new', nextTime: '2000-01-01' }) const node = exports.render( { kind: 'opportunity', detailFields: h.detailFields.value, detail: h.detail.value, detailHeading: '商机' }, [] ) const nodes: any[] = [] function walk(n: any) { if (!n || typeof n !== 'object') return if (Array.isArray(n)) return n.forEach(walk) nodes.push(n) if (Array.isArray(n.children)) walk(n.children) else if (n.children && typeof n.children === 'object') for (const v of Object.values(n.children)) if (typeof v === 'function') walk(v()) } walk(node) assert.ok(!nodes.some((n) => n.type === 'CustomerFields')) assert.ok(nodes.some((n) => n.type === 'span' && n.props?.class?.includes('status-tag--primary'))) assert.ok(nodes.some((n) => n.type === 'van-cell' && n.props?.value === '2000-01-01 00:00:00')) }) test('合同和日志详情字段列表不受商机显示修复影响', () => { assert.deepEqual( harness({}, 'contract').detailFields.value.map((f: any) => f.key), ['contractName', 'contractNo', 'amount', 'signDate', 'ownerUserName', 'contractType', 'status'] ) assert.deepEqual( harness({}, 'log').detailFields.value.map((f: any) => f.key), [ 'title', 'operName', 'operTime', 'operIp', 'costTime', 'businessType', 'status', 'operUrl', 'errorMsg' ] ) }) test('商机跟进和阶段模板的时间、标签及说明条件对齐Web', () => { const source = mobile.template!.content const h = harness() const expressions = [...source.matchAll(/{{([\s\S]*?)}}/g)].map((m) => m[1].trim()) const row = { followTime: '2026-10-23T12:13:14.000Z', createTime: '2026-10-23', nextTime: '2026-10-24', followType: 'phone' } const valueOf = (needle: string) => { const expression = expressions.find((v) => v.includes(needle)) assert.ok(expression, needle) return evaluate('', { row, formatOpportunityDateTime: h.date }, expression) } assert.ok(String(valueOf('row.followTime')).includes('2026-10-23 12:13:14')) assert.equal(valueOf('row.nextTime'), '2026-10-24 00:00:00') const follow = source.slice( source.indexOf('v-for="row in follows"'), source.indexOf('v-for="row in stages"') ) const tagExpression = follow.match(/:class="([^"]*crm_follow_type[^"]*)"/)?.[1] assert.ok(tagExpression, 'follow label carries the Web semantic tag type') assert.equal(evaluate('', { row, dict: h.dict, ...labels }, tagExpression), 'status-tag--danger') const changeRow = source.match(/]*)>\s*变更原因<\/span>/)?.[1] assert.ok(changeRow?.includes('v-if="row.changeReason"')) assert.match( source, /]*class="[^"]*status-tag--danger[^"]*"[^>]*>{{\s*row.lostDetail\s*}}<\/span>/ ) assert.match( source, /]*class="[^"]*status-tag--success[^"]*"[^>]*>{{\s*row.wonRemark\s*}}<\/span>/ ) })