| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- 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<string, unknown>, 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<string, any> = {}, kind = 'opportunity') {
- const detail = vue.ref(row)
- const dictMap = vue.reactive<Record<string, any[]>>({
- 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('<section class="mp-wv-section">')
- const end = mobile.template!.content.indexOf('</section>', start) + '</section>'.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(/<div([^>]*)>\s*<span>变更原因<\/span>/)?.[1]
- assert.ok(changeRow?.includes('v-if="row.changeReason"'))
- assert.match(
- source,
- /<span[^>]*class="[^"]*status-tag--danger[^"]*"[^>]*>{{\s*row.lostDetail\s*}}<\/span>/
- )
- assert.match(
- source,
- /<span[^>]*class="[^"]*status-tag--success[^"]*"[^>]*>{{\s*row.wonRemark\s*}}<\/span>/
- )
- })
|