284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
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>/
|
|
)
|
|
})
|