| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677 |
- import assert from 'node:assert/strict'
- import test from 'node:test'
- import { readFileSync, existsSync } from 'node:fs'
- import { parse } from '@vue/compiler-sfc'
- import ts from 'typescript'
- import * as vue from 'vue'
- import { useClientColumnFilters } from '../src/composables/useClientColumnFilters'
- import * as defs from '../src/views/crm/customer/dialogColumnFilterDefs'
- const flush = async () => {
- for (let i = 0; i < 20; i++) await Promise.resolve()
- }
- // Execute actual script-setup functions inside Vue's renderer; API responses alone are deferred.
- const helperPath = new URL('../src/utils/mp-customer-panel-identity.ts', import.meta.url)
- function evaluate(page: string, props: any, overrides: Record<string, any>, events: any[]) {
- const source = parse(
- readFileSync(new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8')
- ).descriptor.scriptSetup!.content
- const ast = ts.createSourceFile('panel.ts', source, ts.ScriptTarget.Latest, true)
- const vueEntries = Object.entries(vue).filter(
- ([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default'
- )
- const bindings: Record<string, any> = Object.fromEntries(vueEntries)
- delete bindings.default
- for (const statement of ast.statements) {
- if (!ts.isImportDeclaration(statement)) continue
- const clause = statement.importClause
- if (clause?.name) bindings[clause.name.text] = () => undefined
- if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
- for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
- }
- if (existsSync(helperPath)) {
- const helper = ts.transpile(
- readFileSync(helperPath, 'utf8').replace('export function', 'function'),
- { target: ts.ScriptTarget.ESNext }
- )
- bindings.useCustomerPanelIdentity = new Function(
- ...vueEntries.map(([k]) => k),
- `${helper};return useCustomerPanelIdentity`
- )(...vueEntries.map(([, v]) => v))
- }
- Object.assign(bindings, {
- useClientColumnFilters,
- ...defs,
- OPPORTUNITY_SOURCE_FALLBACK: {},
- OPPORTUNITY_TYPE_FALLBACK: {},
- defineProps: () => props,
- defineEmits:
- () =>
- (...args: any[]) =>
- events.push(args),
- defineExpose() {},
- useRouter: () => ({ push: async () => {} }),
- useDictStore: () => ({ getOptions: () => [], getLabel: () => '', getTagType: () => '' }),
- unwrapRows: (v: any) => (Array.isArray(v) ? v : v?.data?.rows || v?.data || v?.rows || []),
- unwrapData: (v: any) => v?.data || v,
- canDeleteByCreator: () => true,
- contactFields: [{ key: 'contactName', required: true }],
- invoiceFields: [{ key: 'companyName' }],
- requiredFieldsError: () => '',
- contactDetailsError: () => '',
- percentageError: () => '',
- apiTime: (v: any) => v,
- opportunityProgress: () => ({}),
- showCrmConfirmDialog: async () => {},
- fetchContactByCustomerId: async () => [],
- fetchFollowByCustomerId: async () => [],
- getInvoiceInfoByCustomerId: async () => [],
- fetchContactDetail: async (v: any) => v,
- fetchUserOptions: async () => [],
- fetchCustomerOptions: async () => [],
- listOpportunity: async () => [],
- listContract: async () => [],
- listOperLog: async () => [],
- getOpportunity: async (id: string) => ({ id }),
- listFollowByOpportunity: async () => [],
- getStageLogList: async () => [],
- ...overrides
- })
- const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
- const names = statements.flatMap((s) =>
- ts.isFunctionDeclaration(s)
- ? [s.name!.text]
- : ts.isVariableStatement(s)
- ? s.declarationList.declarations.flatMap((d) =>
- ts.isObjectBindingPattern(d.name)
- ? d.name.elements.map((e) => e.name.getText(ast))
- : [d.name.getText(ast)]
- )
- : []
- )
- const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
- target: ts.ScriptTarget.ESNext
- })
- return new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
- ...Object.values(bindings)
- )
- }
- function mount(page: string, kind = 'contract', extraProps: any = {}, overrides: any = {}) {
- let api: any
- const props = vue.shallowReactive({ customerId: 'A', kind, ...extraProps })
- const renderer = vue.createRenderer<any, any>({
- patchProp() {},
- insert() {},
- remove() {},
- createElement: () => ({}),
- createText: () => ({}),
- createComment: () => ({}),
- setText() {},
- setElementText() {},
- parentNode: () => null,
- nextSibling: () => null
- })
- const app = renderer.createApp({
- setup() {
- api = evaluate(page, props, overrides, [])
- return () => vue.h('div')
- }
- })
- app.mount({})
- return { api, props, stop: () => app.unmount() }
- }
- test('联系人姓名手机号组合、枚举并集和快捷筛选共享状态', async () => {
- const { api, stop } = mount('ContactPanel')
- await flush()
- assert.ok(api.filterApi, '面板应使用 Web 客户端筛选 API')
- api.rows.value = [
- { id: '1', contactName: '张甲', mobile: '138', gender: 'male', role: 'a' },
- { id: '2', contactName: '张乙', mobile: '139', gender: 'female', role: 'b' },
- { id: '3', contactName: '李甲', mobile: '138', gender: 'male', role: 'c' }
- ]
- api.filterApi.getFilterState('contactName').text = '张'
- api.filterApi.getFilterState('mobile').text = '138'
- assert.deepEqual(
- api.filtered.value.map((r: any) => r.id),
- ['1']
- )
- api.resetMore()
- api.filterApi.getFilterState('role').selected = ['a', 'b']
- assert.equal(api.filtered.value.length, 2)
- api.roleFilter.value = 'c'
- assert.deepEqual(
- api.filtered.value.map((r: any) => r.id),
- ['3']
- )
- api.resetMore()
- assert.equal(api.roleFilter.value, '')
- assert.equal(api.filtered.value.length, 3)
- stop()
- })
- test('联系人快速搜索同时匹配角色显示名称和原始编码', async () => {
- const { api, stop } = mount('ContactPanel', 'contract', {}, {
- useDictStore: () => ({
- getOptions: () => [],
- getLabel: (_code: string, value: string) =>
- ({ decision: '决策人', manager: '管理者' })[value] || value || ''
- })
- })
- try {
- await flush()
- api.rows.value = [
- { id: '1', contactName: '张甲', role: 'decision' },
- { id: '2', contactName: '李乙', role: 'manager' },
- { id: '3', contactName: '王丙', role: 'legacy' }
- ]
- for (const [keyword, expected] of [
- ['决策人', ['1']],
- [' 策人 ', ['1']],
- ['DECISION', ['1']],
- ['管理', ['2']],
- ['legacy', ['3']],
- ['不存在角色', []],
- ['', ['1', '2', '3']]
- ] as Array<[string, string[]]>) {
- api.keyword.value = keyword
- assert.deepEqual(api.filtered.value.map((row: any) => row.id), expected, keyword)
- }
- } finally {
- stop()
- }
- })
- test('联系人角色名称搜索与列筛选取交集,清空不会丢失原筛选', async () => {
- const { api, stop } = mount('ContactPanel', 'contract', {}, {
- useDictStore: () => ({
- getOptions: () => [],
- getLabel: (_code: string, value: string) => value === 'decision' ? '决策人' : value
- })
- })
- try {
- await flush()
- api.rows.value = [
- { id: '1', role: 'decision', gender: 'male' },
- { id: '2', role: 'decision', gender: 'female' },
- { id: '3', role: 'manager', gender: 'female' }
- ]
- api.genderFilter.value = 'female'
- api.keyword.value = '决策人'
- assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['2'])
- api.roleFilter.value = 'manager'
- assert.deepEqual(api.filtered.value, [])
- api.keyword.value = ''
- assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['3'])
- } finally {
- stop()
- }
- })
- test('联系人角色字典后加载时重新计算名称搜索结果', async () => {
- const labels = vue.reactive<Record<string, string>>({})
- const { api, stop } = mount('ContactPanel', 'contract', {}, {
- useDictStore: () => ({
- getOptions: () => [],
- getLabel: (_code: string, value: string) => labels[value] || value || ''
- })
- })
- try {
- await flush()
- api.rows.value = [{ id: '1', role: 'decision' }]
- api.keyword.value = '决策人'
- assert.deepEqual(api.filtered.value, [])
- labels.decision = '决策人'
- assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['1'])
- labels.decision = '决策负责人'
- assert.deepEqual(api.filtered.value, [])
- api.keyword.value = '决策负责人'
- assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['1'])
- } finally {
- stop()
- }
- })
- test('开票默认多选与单位税号组合即时筛选', async () => {
- const { api, stop } = mount('InvoicePanel')
- await flush()
- assert.ok(api.filterApi)
- api.rows.value = [
- { id: '1', companyName: '甲', taxNo: '123', isDefault: 1 },
- { id: '2', companyName: '甲', taxNo: '234', isDefault: 0 },
- { id: '3', companyName: '乙', taxNo: '', isDefault: 0 }
- ]
- api.filterApi.getFilterState('isDefault').selected = ['1', '0']
- assert.equal(api.filtered.value.length, 3)
- api.filterApi.getFilterState('companyName').text = '甲'
- api.filterApi.getFilterState('taxNo').text = '123'
- assert.deepEqual(
- api.filtered.value.map((r: any) => r.id),
- ['1']
- )
- api.resetMore()
- assert.equal(api.filtered.value.length, 3)
- stop()
- })
- for (const [kind, date, number] of [
- ['contract', 'signDate', 'amount'],
- ['log', 'operTime', 'costTime'],
- ['opportunity', 'expectDealDate', '']
- ])
- test(`${kind} 日期范围及数值比较与 Web 一致`, async () => {
- const { api, props, stop } = mount('RelatedPanel', kind)
- await flush()
- assert.ok(api.filterApi)
- api.rows.value = [10, 20, 30].map((value, i) => ({
- id: String(i),
- [date]: `2026-10-${21 + i} 23:59:59`,
- [number]: value,
- stage: String(i),
- status: String(i),
- contractType: String(i),
- businessType: String(i)
- }))
- Object.assign(api.filterApi.getFilterState(date), { from: '2026-10-22', to: '2026-10-22' })
- assert.deepEqual(
- api.filtered.value.map((r: any) => r.id),
- ['1']
- )
- api.resetMore()
- assert.equal(api.filtered.value.length, 3)
- if (number) {
- for (const [op, ids] of [
- ['eq', ['1']],
- ['gt', ['2']],
- ['gte', ['1', '2']],
- ['lt', ['0']],
- ['lte', ['0', '1']],
- ['between', ['1']]
- ]) {
- Object.assign(api.filterApi.getFilterState(number), {
- op,
- value: '20',
- min: '15',
- max: '25'
- })
- assert.deepEqual(
- api.filtered.value.map((r: any) => r.id),
- ids
- )
- }
- }
- api.resetMore()
- const prop = kind === 'contract' ? 'contractType' : kind === 'log' ? 'businessType' : 'stage'
- api.filterApi.getFilterState(prop).selected = ['0', '2']
- assert.deepEqual(
- api.filtered.value.map((r: any) => r.id),
- ['0', '2']
- )
- api.quick1.value = '1'
- assert.deepEqual(
- api.filtered.value.map((r: any) => r.id),
- ['1']
- )
- api.resetMore()
- api.rows.value = []
- assert.equal(api.filtered.value.length, 0, '刷新集合为空时不保留子组件缓存行')
- props.customerId = 'B'
- await vue.nextTick()
- assert.equal(api.filtered.value.length, 0)
- if (kind === 'opportunity') {
- assert.equal(api.listFields.value.filter((f: any) => f.key === 'createTime').length, 1)
- assert.equal(api.detailFields.value.filter((f: any) => f.key === 'createTime').length, 1)
- }
- stop()
- })
- test('移动筛选控件使用父级状态、枚举搜索/清空和日期/比较器事件即时生效', () => {
- const rows = vue.ref([
- { gender: 'male', amount: 10, signDate: '2026-10-22' },
- { gender: 'female', amount: 30, signDate: '2026-10-23' }
- ])
- const definitions = [
- ...defs.buildDetailContactColumnFilterDefs(() => []),
- ...defs.buildDetailContractColumnFilterDefs(() => [])
- ]
- const filterApi = useClientColumnFilters(rows, definitions)
- const { api, stop } = mount('LocalFilters', '', { filterApi, definitions, rows: rows.value })
- api.toggleEnum('gender', 'male')
- assert.equal(filterApi.filteredRows.value.length, 1)
- api.enumKeywords.gender = '女'
- assert.deepEqual(
- api.enumOptions(definitions.find((d: any) => d.prop === 'gender')).map((o: any) => o.value),
- ['female']
- )
- api.toggleEnum('gender', 'female')
- assert.equal(filterApi.filteredRows.value.length, 2)
- api.clearField('gender')
- assert.equal(filterApi.hasActiveFilters.value, false)
- api.openDate('signDate', 'from')
- api.dateParts.value = ['2026', '10', '23']
- api.onDateConfirm()
- assert.equal(filterApi.filteredRows.value.length, 1)
- api.clearField('signDate')
- assert.equal(filterApi.filteredRows.value.length, 2)
- api.openOp('amount')
- api.onOpConfirm({ selectedValues: ['gte'] })
- api.setRangeValue('amount', 'value', '30')
- assert.equal(filterApi.filteredRows.value.length, 1)
- api.clearAllFilters()
- assert.equal(filterApi.filteredRows.value.length, 2)
- stop()
- })
- for (const [page, kind, builder] of [
- ['ContactPanel', '', defs.buildDetailContactColumnFilterDefs],
- ['InvoicePanel', '', defs.buildInvoiceInfoColumnFilterDefs],
- ['RelatedPanel', 'opportunity', defs.buildDetailOpportunityColumnFilterDefs],
- ['RelatedPanel', 'contract', defs.buildDetailContractColumnFilterDefs],
- ['RelatedPanel', 'log', defs.buildDetailOperLogColumnFilterDefs]
- ] as const) {
- test(`${page}/${kind}: 每个 Web 字段均绑定真实筛选状态,刷新立即移除旧行`, async () => {
- const { api, stop } = mount(page, kind)
- await flush()
- for (const def of builder(() => [])) {
- api.resetMore()
- assert.equal(api.filterApi.getColumnDef(def.prop)?.type, def.type)
- api.rows.value = [
- {
- id: 'a',
- [def.prop]:
- def.type === 'datetime' ? '2026-10-22 00:00:00' : def.type === 'number' ? 10 : 'alpha'
- },
- {
- id: 'b',
- [def.prop]:
- def.type === 'datetime' ? '2026-10-23 23:59:59' : def.type === 'number' ? 20 : 'beta'
- }
- ]
- const state = api.filterApi.getFilterState(def.prop)
- if (def.type === 'text') state.text = 'alpha'
- else if (def.type === 'enum') state.selected = ['alpha']
- else if (def.type === 'datetime')
- Object.assign(state, { from: '2026-10-22', to: '2026-10-22' })
- else Object.assign(state, { op: 'eq', value: '10' })
- assert.deepEqual(
- api.filtered.value.map((r: any) => r.id),
- ['a'],
- def.prop
- )
- api.filterApi.clearFilter(def.prop)
- assert.equal(api.filtered.value.length, 2, `${def.prop} 清空`)
- }
- const pending = api.load()
- assert.equal(api.filtered.value.length, 0, '刷新请求未完成时已移除旧行')
- await pending
- assert.equal(api.filtered.value.length, 0)
- stop()
- })
- }
- // Execute the installed Vant option handler, including its same-value no-emit guard.
- function clickVantOption(modelValue: unknown, value: string, update: (value: string) => void) {
- const source = readFileSync(
- new URL('../node_modules/vant/es/dropdown-item/DropdownItem.mjs', import.meta.url),
- 'utf8'
- )
- const ast = ts.createSourceFile('DropdownItem.mjs', source, ts.ScriptTarget.Latest, true)
- let handler = ''
- function visit(node: ts.Node) {
- if (ts.isVariableDeclaration(node) && node.name.getText(ast) === 'onClick' && node.initializer)
- handler = node.initializer.getText(ast)
- ts.forEachChild(node, visit)
- }
- visit(ast)
- assert.ok(handler, '读取真实 Vant option onClick')
- const click = new Function('disabled', 'state', 'option', 'props', 'emit', `return (${handler})`)(
- false,
- { showPopup: true },
- { value },
- { modelValue },
- (event: string, next: string) => {
- if (event === 'update:modelValue') update(next)
- }
- )
- click()
- }
- for (const [page, kind, prop, quick] of [
- ['ContactPanel', '', 'role', 'roleFilter'],
- ['ContactPanel', '', 'gender', 'genderFilter'],
- ['InvoicePanel', '', 'isDefault', 'defaultFilter'],
- ['RelatedPanel', 'opportunity', 'stage', 'quick1'],
- ['RelatedPanel', 'opportunity', 'status', 'quick2'],
- ['RelatedPanel', 'contract', 'contractType', 'quick1'],
- ['RelatedPanel', 'contract', 'status', 'quick2'],
- ['RelatedPanel', 'log', 'businessType', 'quick1'],
- ['RelatedPanel', 'log', 'status', 'quick2']
- ])
- test(`${page}/${kind}/${prop}: 多选后点击 Vant 全部确实清空`, async () => {
- const { api, stop } = mount(page, kind)
- await flush()
- api.rows.value = ['a', 'b', 'c'].map((value) => ({ id: value, [prop]: value }))
- api.filterApi.getFilterState(prop).selected = ['a', 'b']
- assert.equal(api.filtered.value.length, 2)
- clickVantOption(api[quick].value, '', (value) => {
- api[quick].value = value
- })
- assert.equal(api.filtered.value.length, 3)
- assert.equal(api.filterApi.isFilterActive(prop), false)
- stop()
- })
- test('日期筛选年份可输入、窗口有界、旧年份可达且确认直接生效', async () => {
- const definitions = defs.buildDetailContractColumnFilterDefs(() => [])
- const rows = vue.ref([{ signDate: '1800-02-28' }, { signDate: '2400-02-29' }])
- const filterApi = useClientColumnFilters(rows, definitions)
- const { api, stop } = mount('LocalFilters', '', { filterApi, definitions })
- api.openDate('signDate', 'from')
- assert.ok(api.yearInput, '日期筛选应支持输入年份')
- api.dateParts.value = ['2024', '02', '29']
- await vue.nextTick()
- api.yearInput.value = '1800'
- api.onDateConfirm()
- assert.equal((filterApi.getFilterState('signDate') as any).from, '1800-02-28')
- assert.equal(filterApi.filteredRows.value.length, 2)
- api.openDate('signDate', 'from')
- assert.equal(api.pickerYear.value, 1800)
- assert.equal(api.maxDate.value.getFullYear() - api.minDate.value.getFullYear(), 20)
- api.yearInput.value = '2400'
- api.onDateConfirm()
- assert.equal(filterApi.filteredRows.value.length, 1)
- api.openDate('signDate', 'to')
- api.yearInput.value = '9999'
- api.applyYear()
- assert.equal(api.maxDate.value.getFullYear(), 9999)
- api.yearInput.value = '1000'
- api.applyYear()
- assert.equal(api.minDate.value.getFullYear(), 1000)
- stop()
- })
- test('关联商机直接按ID打开,目标切换和卸载丢弃迟到响应', async () => {
- const pending: Record<string, (value: any) => void> = {}
- const { api, props, stop } = mount('RelatedPanel', 'opportunity', { targetOpportunityId: 'O1', readonly: true }, {
- getOpportunity: (id: string) => new Promise((resolve) => { pending[id] = resolve })
- })
- await flush()
- assert.equal(api.detailOpen.value, true)
- assert.ok(pending.O1, '无需列表命中即按ID读取')
- props.targetOpportunityId = 'O2'
- await flush()
- pending.O1({ id: 'O1' })
- await flush()
- assert.notEqual(api.detail.value?.id, 'O1')
- pending.O2({ id: 'O2' })
- await flush()
- assert.equal(api.detail.value.id, 'O2')
- props.targetOpportunityId = 'O3'
- await flush()
- stop()
- pending.O3({ id: 'O3' })
- await flush()
- assert.equal(api.detailOpen.value, false)
- assert.equal(api.detail.value, undefined)
- })
- test('关闭关联商机取消读取,重新打开同ID可以重试', async () => {
- let resolve!: (value: any) => void
- const { api, props, stop } = mount('RelatedPanel', 'opportunity', { targetOpportunityId: 'O1', readonly: true }, {
- getOpportunity: () => new Promise((r) => { resolve = r })
- })
- await flush()
- assert.equal(api.detailOpen.value, true)
- api.closeDetail()
- resolve({ id: 'O1' })
- await flush()
- assert.equal(api.detail.value, undefined)
- props.targetOpportunityId = ''
- await flush()
- props.targetOpportunityId = 'O1'
- await flush()
- resolve({ id: 'O1' })
- await flush()
- assert.equal(api.detail.value.id, 'O1')
- stop()
- })
- function rangeHarness(kind = 'contract') {
- const parent = mount('RelatedPanel', kind)
- const messages: string[] = []
- const child = mount('LocalFilters', '', {
- definitions: parent.api.filterDefinitions.value,
- filterApi: parent.api.filterApi
- }, { showFailToast: (message: string) => messages.push(message) })
- if (parent.api.localFilters) parent.api.localFilters.value = child.api
- const edit = (prop: string, field: string, value: string) => {
- if (child.api.setRangeValue) child.api.setRangeValue(prop, field, value)
- else child.api.state(prop)[field] = value
- }
- return { parent, child, edit, messages, stop: () => { child.stop(); parent.stop() } }
- }
- test('关联金额倒置草稿不污染有效筛选,确定保留面板并提示,修正后可提交', async () => {
- const h = rangeHarness()
- try {
- await flush()
- const number = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number')
- const prop = number.prop
- h.parent.api.rows.value = [{ id: 'a', [prop]: 100 }, { id: 'b', [prop]: 1000 }]
- h.edit(prop, 'min', '200')
- assert.deepEqual(h.parent.api.filtered.value.map((r: any) => r.id), ['b'])
- h.edit(prop, 'max', '100')
- assert.deepEqual(h.parent.api.filtered.value.map((r: any) => r.id), ['b'])
- assert.equal(h.child.api.state(prop).max, '100')
- h.parent.api.moreOpen.value = true
- h.parent.api.applyMore()
- assert.equal(h.parent.api.moreOpen.value, true)
- assert.deepEqual(h.messages, [`${number.label}:最小值不能大于最大值`])
- h.edit(prop, 'max', '1000')
- assert.equal(h.child.api.rangeError.value, '')
- h.parent.api.applyMore()
- assert.equal(h.parent.api.moreOpen.value, false)
- } finally { h.stop() }
- })
- test('所有关联日期字段拒绝倒置草稿,保留已生效日期且可修正', async () => {
- for (const kind of ['contract', 'opportunity', 'log']) {
- const h = rangeHarness(kind)
- try {
- await flush()
- for (const def of h.parent.api.filterDefinitions.value.filter((d: any) => d.type === 'datetime')) {
- h.child.api.clearAllFilters()
- h.edit(def.prop, 'from', '2025-01-01')
- h.edit(def.prop, 'to', '2024-01-01')
- assert.equal(h.parent.api.filterApi.getFilterState(def.prop).to, '', `${kind}/${def.prop}`)
- h.parent.api.moreOpen.value = true
- h.parent.api.applyMore()
- assert.equal(h.parent.api.moreOpen.value, true)
- assert.equal(h.messages.at(-1), `${def.label}:开始日期不能晚于结束日期`)
- h.edit(def.prop, 'to', '2025-01-01')
- assert.equal(h.child.api.rangeError.value, '')
- h.parent.api.applyMore()
- assert.equal(h.parent.api.moreOpen.value, false)
- }
- } finally { h.stop() }
- }
- })
- test('区间清空本项、全部清空及父面板重置同时清理错误草稿与生效条件', async () => {
- const h = rangeHarness()
- try {
- await flush()
- const prop = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number').prop
- for (const reset of [() => h.child.api.clearField(prop), () => h.child.api.clearAllFilters(), () => h.parent.api.resetMore()]) {
- h.edit(prop, 'min', '200')
- h.edit(prop, 'max', '100')
- reset()
- assert.equal(h.child.api.state(prop).min, '')
- assert.equal(h.child.api.state(prop).max, '')
- assert.equal(h.child.api.rangeError?.value || '', '')
- assert.equal(h.parent.api.filterApi.hasActiveFilters.value, false)
- }
- } finally { h.stop() }
- })
- test('区间合法零值、负数、小数、单边、相等及切换比较方式继续即时生效', async () => {
- const h = rangeHarness()
- try {
- await flush()
- const prop = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number').prop
- for (const [min, max] of [['', ''], ['0', ''], ['', '0'], ['0', '0'], ['-2.5', '-1.5'], ['44.99', '44.99']]) {
- h.child.api.clearAllFilters()
- h.edit(prop, 'min', min)
- h.edit(prop, 'max', max)
- assert.equal(h.parent.api.filterApi.getFilterState(prop).min, min)
- assert.equal(h.parent.api.filterApi.getFilterState(prop).max, max)
- }
- for (const op of ['eq', 'gt', 'gte', 'lt', 'lte']) {
- h.child.api.clearAllFilters()
- h.edit(prop, 'min', '200')
- h.edit(prop, 'max', '100')
- h.child.api.openOp(prop)
- h.child.api.onOpConfirm({ selectedValues: [op] })
- h.edit(prop, 'value', '44.99')
- assert.equal(h.child.api.validate(), true)
- assert.equal(h.parent.api.filterApi.getFilterState(prop).op, op)
- assert.equal(h.parent.api.filterApi.getFilterState(prop).value, '44.99')
- h.child.api.onOpConfirm({ selectedValues: ['between'] })
- assert.equal(h.child.api.validate(), false)
- assert.equal(h.parent.api.filterApi.getFilterState(prop).op, op)
- }
- } finally { h.stop() }
- })
- for (const page of ['ContactPanel', 'InvoicePanel', 'RelatedPanel']) {
- test(`${page}: 重置清除所有枚举搜索词并恢复选项,未挂载筛选器时仍能清除条件`, async () => {
- const parent = mount(page)
- await flush()
- const definitions = vue.unref(parent.api.filterDefinitions)
- const child = mount('LocalFilters', '', { definitions, filterApi: parent.api.filterApi })
- try {
- if (parent.api.localFilters) parent.api.localFilters.value = child.api
- const enums = definitions.filter((d: any) => d.type === 'enum')
- assert.ok(enums.length)
- for (const def of enums) {
- child.api.enumKeywords[def.prop] = '无此选项'
- child.api.state(def.prop).selected = ['sample']
- }
- parent.api.resetMore()
- for (const def of enums) {
- assert.equal(child.api.enumKeywords[def.prop] || '', '', `${def.prop}搜索词应清空`)
- assert.deepEqual(child.api.state(def.prop).selected, [])
- assert.deepEqual(child.api.enumOptions(def), def.enumOptions || parent.api.filterApi.collectUniqueOptions(def.prop))
- }
- // 没有选中条件时再次搜索,组件内“清空筛选”也必须恢复完整选项。
- child.api.enumKeywords[enums[0].prop] = '不存在'
- child.api.clearAllFilters()
- assert.equal(child.api.enumKeywords[enums[0].prop] || '', '')
- parent.api.localFilters.value = undefined
- parent.api.filterApi.getFilterState(enums[0].prop).selected = ['sample']
- parent.api.resetMore()
- assert.equal(parent.api.filterApi.hasActiveFilters.value, false)
- } finally { child.stop(); parent.stop() }
- })
- }
|