fix(pl): 统一计划用证三页的检索工具栏与漏斗以保持筛选和分页一致。

This commit is contained in:
wuzilong committed 2026-11-01 19:24:59 +08:00
1 parent c4f173bc3a
commit c16681a16f
8 files changed
+982 -235

No files matched your search

@@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import * as Vue from 'vue'
import { renderToString } from 'vue/server-renderer'
const source = readFileSync(
new URL('../src/components/business/column-header-filter/index.vue', import.meta.url),
'utf8'
)
const countTemplate = source.match(/<span\b[^>]*class="value-count"[^>]*>[\s\S]*?<\/span>/)![0]
const render = Vue.compile(countTemplate)
for (const [count, visible] of [
[undefined, false],
[0, true],
[7, true]
] as const) {
test(`count ${count} renders only when supplied, including zero`, async () => {
const html = await renderToString(
Vue.createSSRApp({ data: () => ({ opt: { count } }), render })
)
assert.equal(html.includes('class="value-count"'), visible)
if (visible) assert.ok(html.includes(`>${count}</span>`))
})
}
@@ -0,0 +1,327 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
import { useClientColumnFilters } from '../src/composables/useClientColumnFilters'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve()
}
function deferred() {
let resolve!: (value?: any) => void
let reject!: (error: any) => void
const promise = new Promise<any>((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
// Run production setup, including its real synchronous session watchers. Only I/O is substituted.
function pageHarness(kind: 'quota' | 'legal' | 'cert', props: any = { mobile: true }) {
const lists: any[] = [],
gets: any[] = [],
writes: any[] = [],
permissions: any[] = []
const events: string[] = [],
mounted: Function[] = [],
unmount: Function[] = []
const user = vue.reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a', roles: ['sales'] } })
const queue = (items: any[], params?: any) => {
const pending = deferred()
items.push({ ...pending, params })
return pending.promise
}
const env = {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
useClientColumnFilters,
useServerColumnFilters,
usePersistedListColumns,
useRoute: () => ({ query: {} }),
newCert: () => ({}),
commonFields: [],
purchaseFields: [],
transportFields: [],
quotaCertApi: { list: (kind: string) => queue(lists, { kind }) },
useUserStore: () => user,
defineProps: () => props,
onMounted: (fn: Function) => mounted.push(fn),
onBeforeUnmount: (fn: Function) => unmount.push(fn),
request: { get: (url: string, options: any) => queue(lists, { url, ...options.params }) },
pickBxData: (value: any) => value,
legalDocumentApi: {
list: (params: any) => queue(lists, params),
get: (id: string) => queue(gets, id),
save: (id: string, products: any) => queue(writes, { id, products })
},
fetchGetMenuCodes: () => queue(permissions),
ElMessage: {
warning: (msg: string) => events.push(msg),
success: (msg: string) => events.push(msg)
}
}
const path =
kind === 'quota'
? '../src/views/pl/plan-quota/index.vue'
: kind === 'cert'
? '../src/views/pl/quota-cert/index.vue'
: '../src/views/pl/legal-use-document/index.vue'
const source = ts.createSourceFile(
'page.ts',
parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const statements = source.statements.filter((s) => !ts.isImportDeclaration(s))
const names = statements.flatMap((s) =>
ts.isVariableStatement(s)
? s.declarationList.declarations.flatMap((d) =>
ts.isIdentifier(d.name)
? [d.name.getText(source)]
: ts.isObjectBindingPattern(d.name)
? d.name.elements.map((e) => e.name.getText(source))
: []
)
: ts.isFunctionDeclaration(s) && s.name
? [s.name.text]
: []
)
const code = ts.transpile(statements.map((s) => s.getText(source)).join('\n'), {
target: ts.ScriptTarget.ESNext
})
const scope = vue.effectScope()
const state = scope.run(() =>
new Function(...Object.keys(env), `${code}; return {${names.join(',')}}`)(...Object.values(env))
)
return {
state,
user,
lists,
gets,
writes,
permissions,
events,
mounted,
stop() {
unmount.forEach((fn) => fn())
scope.stop()
}
}
}
for (const kind of ['cert', 'legal'] as const) {
test(`${kind}: late list cannot overwrite latest refresh`, async () => {
const h = pageHarness(kind),
s = h.state
const first = s.load(),
second = s.load()
h.lists[1].resolve([{ id: 'new' }])
await second
h.lists[0].resolve([{ id: 'old' }])
await first
assert.equal(s.rows.value[0].id, 'new')
h.stop()
})
}
for (const kind of ['cert', 'legal'] as const) {
const fixture = (index: number) =>
kind === 'cert'
? {
id: String(index),
certNo: `CERT-${index}`,
productNameOnCert: index === 25 ? '目标品名' : '普通',
status: index === 25 ? 'active' : 'draft',
totalQty: String(index),
reservedQty: '0',
usedQty: '0',
availableQty: String(index),
validTo: index === 25 ? '2026-11-01' : '2025-01-01'
}
: {
id: String(index),
planNo: `PLAN-${index}`,
products: [
{
productName: index === 25 ? '目标品名' : '普通',
documentStatus: index === 25 ? 'pending' : 'uploaded'
}
]
}
test(`${kind}: filters operate on full rows before pagination, combine, reset and clear hidden columns`, async () => {
const h = pageHarness(kind),
s = h.state
s.rows.value = Array.from({ length: 45 }, (_, i) => fixture(i))
s.page.value = 3
s.setFilterState(kind === 'cert' ? 'productNameOnCert' : 'productNames', { text: '目标' })
assert.equal(s.page.value, 1)
assert.deepEqual(
s.pageRows.value.map((r: any) => r.id),
['25']
)
s.setFilterState(kind === 'cert' ? 'status' : 'documentStatus', {
selected: [kind === 'cert' ? 'draft' : 'uploaded']
})
assert.equal(s.filteredRows.value.length, 0)
s.visibleColumns.value = s.visibleColumns.value.filter(
(p: string) => p !== (kind === 'cert' ? 'status' : 'documentStatus')
)
await vue.nextTick()
assert.deepEqual(
s.pageRows.value.map((r: any) => r.id),
['25']
)
s.reset()
assert.equal(s.filteredRows.value.length, 45)
s.keyword.value = kind === 'cert' ? 'CERT-25' : 'PLAN-25'
s.page.value = 2
s.search()
assert.equal(s.page.value, 1)
assert.deepEqual(
s.pageRows.value.map((r: any) => r.id),
['25']
)
const refresh = s.load()
h.lists[0].resolve(Array.from({ length: 45 }, (_, i) => fixture(i)))
await refresh
assert.deepEqual(
s.pageRows.value.map((r: any) => r.id),
['25']
)
h.stop()
})
test(`${kind}: numeric and enum filters compare business values`, () => {
const h = pageHarness(kind),
s = h.state
s.rows.value = Array.from({ length: 45 }, (_, i) => fixture(i))
s.setFilterState(kind === 'cert' ? 'availableQty' : 'pendingCount', {
op: 'eq',
value: kind === 'cert' ? '25' : '1',
min: '',
max: ''
})
assert.deepEqual(
s.pageRows.value.map((r: any) => r.id),
['25']
)
if (kind === 'cert') {
s.setFilterState('validTo', { from: '2026-11-01', to: '2026-11-01' })
assert.equal(s.filteredRows.value.length, 1)
s.setFilterState('validTo', { from: '2026-11-02', to: '' })
assert.equal(s.filteredRows.value.length, 0)
}
h.stop()
})
for (const change of ['token', 'tenant', 'roles', 'unmount']) {
test(`${kind}: ${change} rejects stale list and permission responses`, async () => {
const h = pageHarness(kind),
s = h.state
const init = s.init()
const pending = s.load()
s.rows.value = [fixture(25)]
if (change === 'token') h.user.accessToken = 'b'
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
if (change === 'roles') h.user.getUserInfo.roles = ['viewer']
if (change === 'unmount') h.stop()
h.permissions[0].resolve({ all: true })
await init
h.lists[0].resolve([fixture(1)])
await pending
assert.notEqual(s.rows.value[0]?.id, '1')
assert.equal((kind === 'cert' ? s.canEdit : s.canUpload).value, false)
h.stop()
})
}
}
test('cert: changing kind immediately invalidates an old purchase response', async () => {
const h = pageHarness('cert'),
s = h.state
const pending = s.load()
s.kind.value = 'transport'
assert.equal(h.lists[1].params.kind, 'transport')
h.lists[1].resolve([{ id: 'transport' }])
await flush()
h.lists[0].resolve([{ id: 'purchase' }])
await pending
assert.equal(s.rows.value[0].id, 'transport')
h.stop()
})
test('plan: actual header filters send server conditions, reset page, preserve refresh and clear hidden conditions', async () => {
const h = pageHarness('quota', { mobile: false }),
s = h.state
s.page.value = 3
s.setFilterState('planNo', { text: 'PLAN' })
assert.equal(s.page.value, 1)
assert.equal(h.lists.at(-1).params.planNo, 'PLAN')
s.setFilterState('quotaStatus', { selected: ['reserved', 'settled'] })
s.setFilterState('planDate', { from: '2026-11-01', to: '2026-11-30' })
s.setFilterState('licensePlate', { text: '鄂A' })
assert.deepEqual(s.toQueryParams(), {
planNo: 'PLAN',
quotaStatusIn: 'reserved,settled',
planDateFrom: '2026-11-01',
planDateTo: '2026-11-30',
licensePlate: '鄂A'
})
void s.load()
assert.equal(h.lists.at(-1).params.licensePlate, '鄂A')
s.visibleColumns.value = s.visibleColumns.value.filter((p: string) => p !== 'licensePlate')
await vue.nextTick()
assert.equal(h.lists.at(-1).params.licensePlate, undefined)
s.reset()
assert.deepEqual(s.toQueryParams(), {})
h.lists.forEach((r) => r.resolve({ list: [], total: 0 }))
await flush()
h.stop()
})
test('legal: mixed-product upload filters preserve any-match and OR semantics', async () => {
const h = pageHarness('legal'),
s = h.state
const row = (id: string, statuses: string[]) => ({
id,
planNo: id,
products: statuses.map((documentStatus) => ({ productName: '产品', documentStatus }))
})
s.rows.value = [
row('mixed', ['pending', 'uploaded']),
row('pending', ['pending']),
row('uploaded', ['uploaded'])
]
s.setFilterState('documentStatus', { selected: ['uploaded'] })
assert.deepEqual(
s.filteredRows.value.map((r: any) => r.id),
['mixed', 'uploaded']
)
s.setFilterState('documentStatus', { selected: ['pending'] })
assert.deepEqual(
s.filteredRows.value.map((r: any) => r.id),
['mixed', 'pending']
)
s.setFilterState('documentStatus', { selected: ['pending', 'uploaded'] })
assert.deepEqual(
s.filteredRows.value.map((r: any) => r.id),
['mixed', 'pending', 'uploaded']
)
assert.deepEqual(
s.collectUniqueOptions('documentStatus').map((o: any) => o.count),
[2, 2]
)
s.setFilterState('documentStatus', { selected: ['uploaded'] })
s.setFilterState('planNo', { text: 'mixed' })
assert.deepEqual(
s.pageRows.value.map((r: any) => r.id),
['mixed']
)
s.clearFilter('planNo')
s.visibleColumns.value = s.visibleColumns.value.filter((p: string) => p !== 'documentStatus')
await vue.nextTick()
assert.equal(s.filteredRows.value.length, 3)
assert.equal(s.hasActiveFilters.value, false)
h.stop()
})
@@ -4,6 +4,8 @@ import test from 'node:test'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve()
@@ -36,6 +38,9 @@ function pageHarness(kind: 'quota' | 'legal', props: any = { mobile: true }) {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
useServerColumnFilters,
usePersistedListColumns,
useUserStore: () => user,
defineProps: () => props,
onMounted: (fn: Function) => mounted.push(fn),