feat(pl): 按运输段产品维护合法使用证明并隔离异步会话。

This commit is contained in:
wuzilong committed 2026-11-01 23:15:17 +08:00
1 parent 722076fb3f
commit 4cb2d88dab
8 files changed
+1103 -404

No files matched your search

+2 -1
View File
@@ -262,10 +262,11 @@ def list_documents(keyword: Optional[str] = None, status: Optional[str] = None,
@legal_router.get('/stops')
def list_stop_documents(keyword: Optional[str] = None, status: Optional[str] = None,
plan_no: Optional[str] = Query(None, alias='planNo'),
contract_id: Optional[str] = Query(None, alias='contractId'),
page: int = Query(1, ge=1), page_size: int = Query(20, alias='pageSize', ge=1, le=100),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
return _wire(stop_documents.list_documents(db, user, keyword, status, contract_id, page, page_size))
return _wire(stop_documents.list_documents(db, user, keyword, status, contract_id, page, page_size, plan_no=plan_no))
@legal_router.get('/stops/{plan_id}')
@@ -70,7 +70,9 @@ def _details(db, user, plans, contract_id=None, lock=False):
query = _query(db, user, side, model, parent, field, contract_id).filter(model.plan_id.in_(result))
if lock:
query = query.with_for_update().populate_existing()
for row, stop, product, contract, document in query.order_by(parent.order_num, parent.id, model.order_num, model.id).all():
stop_order = (func.coalesce(func.nullif(parent.seq, 0), parent.order_num)
if side == 'delivery' else parent.order_num)
for row, stop, product, contract, document in query.order_by(stop_order, parent.id, model.order_num, model.id).all():
url = document.legal_use_cert_url if document else None
result[row.plan_id]['items'].append({
'stopType': side, 'stopId': stop.id, 'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
@@ -102,7 +104,7 @@ def get_documents(db, user, plan_id, contract_id=None):
return _details(db, user, [plan], contract_id)[plan_id]
def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1, page_size=20):
def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1, page_size=20, plan_no=None):
if status not in (None, 'pending', 'uploaded') or page < 1 or not 1 <= page_size <= 100:
raise HTTPException(400, '无效资料状态或分页参数')
if contract_id:
@@ -124,6 +126,8 @@ def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1
query = apply_pl_scope(query, PlPlan, user, db, user.tenant_no)
if keyword:
query = query.filter(PlPlan.plan_no.contains(keyword))
if plan_no:
query = query.filter(PlPlan.plan_no.contains(plan_no))
if status == 'pending':
query = query.filter(counts.c.uploaded < counts.c.required)
elif status == 'uploaded':
+19 -1
View File
@@ -180,7 +180,7 @@ def test_multiple_plans_filter_then_page_and_route_shape(case):
c.db.add(c.m.PlPlanDeliveryProduct(id=pid+'dp', delivery_id=pid+'d', plan_id=pid,
plan_product_id=pid+'pp', product_id='sku', product_no='S', product_name='产品', tenant_no='t'))
c.db.flush()
first = list_stop_documents(keyword=None, status='pending', contract_id=None, page=1, page_size=2, db=c.db, user=c.user)
first = list_stop_documents(keyword=None, plan_no=None, status='pending', contract_id=None, page=1, page_size=2, db=c.db, user=c.user)
second = c.svc.list_documents(c.db, c.user, status='pending', page=2, page_size=2)
assert first['total'] == second['total'] == 4
assert len(first['list']) == len(second['list']) == 2
@@ -269,3 +269,21 @@ def test_save_response_stop_details_keep_current_read(case):
# 装送两侧均有校验读与最终响应读,均须保留当前读。
assert len(statements) == 4
assert all('FOR UPDATE' in sql for sql in statements)
def test_keyword_and_plan_number_filter_are_combined(case):
c = case
assert c.svc.list_documents(c.db, c.user, keyword='P', plan_no='P')['total'] == 1
assert c.svc.list_documents(c.db, c.user, keyword='P', plan_no='OTHER')['total'] == 0
def test_delivery_details_follow_sequence_before_legacy_order(case):
c = case
c.db.get(c.m.PlPlanDelivery, 'd1').seq = 2
c.db.get(c.m.PlPlanDelivery, 'd1').order_num = 1
c.db.get(c.m.PlPlanDelivery, 'd2').seq = 1
c.db.get(c.m.PlPlanDelivery, 'd2').order_num = 2
c.db.flush()
rows = c.svc.get_documents(c.db, c.user, 'p')['items']
assert [row['stopProductId'] for row in rows] == ['lp', 'd2p', 'd1p']
assert [row['stopOrder'] for row in rows] == [1, 1, 2]
+3 -48
View File
@@ -20,6 +20,7 @@ function deferred() {
})
return { promise, resolve, reject }
}
// 段产品证明已改为服务端单选筛选,其回归见 stop-documents.test.ts。
// 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[] = [],
@@ -116,7 +117,7 @@ function pageHarness(kind: 'quota' | 'legal' | 'cert', props: any = { mobile: tr
}
}
for (const kind of ['cert', 'legal'] as const) {
for (const kind of ['cert'] as const) {
test(`${kind}: late list cannot overwrite latest refresh`, async () => {
const h = pageHarness(kind),
s = h.state
@@ -130,7 +131,7 @@ for (const kind of ['cert', 'legal'] as const) {
h.stop()
})
}
for (const kind of ['cert', 'legal'] as const) {
for (const kind of ['cert'] as const) {
const fixture = (index: number) =>
kind === 'cert'
? {
@@ -280,49 +281,3 @@ test('plan: actual header filters send server conditions, reset page, preserve r
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()
})
+511
View File
@@ -0,0 +1,511 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve()
}
function deferred() {
let resolve!: (value: any) => void, reject!: (error: any) => void
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
function harness(fixed?: string) {
const viewportWidth = vue.ref(1120)
const calls: any[] = [],
permissions: any[] = [],
unmount: (() => void)[] = []
const props = vue.reactive({
contractId: fixed,
active: true,
embedded: !!fixed
})
const user = vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } })
const queue = (method: string, args: any[]) => {
const pending = deferred()
calls.push({ method, args, ...pending })
return pending.promise
}
const api = Object.fromEntries(
['listStops', 'getStops', 'saveStops'].map((method) => [
method,
(...args: any[]) => queue(method, args)
])
)
const env = {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
defineProps: () => props,
withDefaults: (v: any) => v,
useUserStore: () => user,
fetchGetMenuCodes: () => {
const p = deferred()
permissions.push(p)
return p.promise
},
useElementSize: () => ({ width: viewportWidth }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
usePersistedListColumns,
useServerColumnFilters,
legalDocumentApi: api,
ElMessage: { success() {}, warning() {} },
onBeforeUnmount: (fn: () => void) => unmount.push(fn)
}
const src = ts.createSourceFile(
'panel.ts',
parse(
readFileSync(
new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url),
'utf8'
)
).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
src.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(src))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const scope = vue.effectScope()
const state = scope.run(() =>
new Function(
...Object.keys(env),
`${code}; return { rows, details, load, loadDetail, allowed, canUpload, keyword, setFilterState, page, total, expansionStyle, open, save, visible, editing, upload, saving, editorLoading, status, search, statusLabel }`
)(...Object.values(env))
)
return {
state,
viewportWidth,
calls,
permissions,
props,
user,
stop: () => {
unmount.forEach((fn) => fn())
scope.stop()
}
}
}
test('global and fixed contract preserve server counts and scope every request', async () => {
for (const fixed of [undefined, 'contract-one']) {
const h = harness(fixed),
s = h.state
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list', 'pl:plan-cert:upload'] })
await flush()
assert.equal(h.calls[0].method, 'listStops')
assert.equal(h.calls[0].args[0].contractId, fixed)
h.calls[0].resolve({
list: [{ id: 'plan', uploadedCount: 1, requiredCount: fixed ? 1 : 3 }],
total: 45
})
await flush()
assert.equal(s.rows.value[0].requiredCount, fixed ? 1 : 3)
const pending = s.open({ id: 'plan' }, { stopType: 'delivery', stopProductId: 'd2' })
assert.equal(h.calls[1].args[1], fixed)
h.calls[1].resolve({
id: 'plan',
items: [
{ stopType: 'delivery', stopProductId: 'd2', productId: 'same', legalUseCertUrl: 'a,b' }
]
})
await pending
const saving = s.save()
assert.deepEqual(h.calls[2].args, [
'plan',
[{ stopType: 'delivery', stopProductId: 'd2', legalUseCertUrl: 'a,b' }],
fixed
])
h.calls[2].resolve({})
await saving
h.stop()
}
})
test('busy uploads refuse saves and failures preserve draft', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [], total: 0 })
await flush()
const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
h.calls[1].resolve({
id: 'p',
items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'original' }]
})
await opened
s.editing.value.legalUseCertUrl = 'draft'
s.upload.value = { hasUploading: () => true }
await s.save()
assert.equal(h.calls.length, 2)
s.upload.value = { hasUploading: () => false }
const saving = s.save()
h.calls[2].reject(Error('failed'))
await saving
assert.equal(s.editing.value.legalUseCertUrl, 'draft')
assert.equal(s.visible.value, true)
h.stop()
})
for (const change of ['contract', 'tenant', 'close'])
test(`old editor response is discarded after ${change}`, async () => {
const h = harness('one'),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const old = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
if (change === 'contract') h.props.contractId = 'two'
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
if (change === 'close') s.visible.value = false
h.calls[1].resolve({ id: 'p', items: [{ stopType: 'load', stopProductId: 'l' }] })
await old
assert.equal(s.editing.value, undefined)
h.stop()
})
test('search during permission initialization does not cancel it; filters are independent', async () => {
const h = harness(),
s = h.state
s.keyword.value = 'word'
s.setFilterState('planNo', { text: 'PL' })
await s.load()
h.permissions[0].resolve({ all: true })
await flush()
assert.equal(h.calls.length, 1)
assert.equal(h.calls[0].args[0].keyword, 'word')
assert.equal(h.calls[0].args[0].planNo, 'PL')
h.stop()
})
test('refresh preserves page and total; not_required is not uploaded', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [], total: 45 })
await flush()
s.page.value = 2
const pending = s.load()
assert.equal(s.total.value, 45)
assert.equal(h.calls[1].args[0].page, 2)
h.calls[1].reject(Error('failed'))
await pending
assert.equal(s.page.value, 2)
assert.equal(s.total.value, 45)
assert.equal(s.statusLabel('not_required'), '无需上传')
h.stop()
})
// Mount the real Element Plus pager. Its page-count watcher must remain active.
import ElPagination from 'element-plus/es/components/pagination/src/pagination.mjs'
test('real pagination keeps page two while loading and preserves count after request failure', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [{ id: 'first' }], total: 45 })
await flush()
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText: () => {},
setElementText: () => {},
parentNode: () => null,
nextSibling: () => null,
insert: () => {},
remove: () => {},
patchProp: () => {}
})
const app = renderer.createApp({
render: () =>
vue.h(ElPagination, {
currentPage: s.page.value,
total: s.total.value,
pageSize: 20,
layout: '',
'onUpdate:current-page': (value: number) => {
s.page.value = value
},
onCurrentChange: () => {
void s.load()
}
})
})
app.mount({})
s.page.value = 2
const pending = s.load()
await vue.nextTick()
await flush()
assert.equal(s.page.value, 2)
assert.equal(h.calls.length, 2)
assert.equal(h.calls[1].args[0].page, 2)
h.calls[1].resolve({ list: [{ id: 'second' }], total: 45 })
await pending
await vue.nextTick()
assert.equal(s.page.value, 2)
assert.equal(s.rows.value[0].id, 'second')
const failed = s.load()
h.calls[2].reject(Error('failed'))
await failed
await vue.nextTick()
assert.equal(s.page.value, 2)
assert.equal(s.total.value, 45)
assert.deepEqual(s.rows.value, [])
h.user.getUserInfo.tenantNo = 'next'
await vue.nextTick()
assert.equal(s.page.value, 1)
assert.equal(s.total.value, 0)
app.unmount()
h.stop()
})
import { compileScript } from '@vue/compiler-sfc'
function compiledSfc(path: string, dependencies: Record<string, any>, inlineTemplate = true) {
const descriptor = parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor
const compiled = compileScript(descriptor, { id: path, inlineTemplate })
const source = ts.createSourceFile('compiled.ts', compiled.content, ts.ScriptTarget.Latest, true)
const env: Record<string, any> = Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
)
Object.assign(env, dependencies)
for (const statement of source.statements) {
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue
const clause = statement.importClause
if (clause?.name) env[clause.name.text] = dependencies[clause.name.text] || {}
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const item of clause.namedBindings.elements) {
if (item.isTypeOnly) continue
const name = (item.propertyName || item.name).text
env[item.name.text] =
statement.moduleSpecifier.getText(source) === '"vue"' ||
statement.moduleSpecifier.getText(source) === "'vue'"
? (vue as any)[name]
: dependencies[name]
}
}
}
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source))
.join('\n')
.replace('export default', 'const component ='),
{ target: ts.ScriptTarget.ESNext }
)
return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env))
}
for (const active of [undefined, false])
test(`compiled proof component active=${active}`, async () => {
const calls: string[] = []
const component = compiledSfc(
'../src/views/pl/legal-use-document/StopDocuments.vue',
{
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
fetchGetMenuCodes: async () => ({ menuCodes: ['pl:legal-use-document:list'] }),
legalDocumentApi: {
listStops: async () => {
calls.push('proof')
return { list: [], total: 0 }
}
},
usePersistedListColumns,
useServerColumnFilters,
useElementSize: () => ({ width: vue.ref(1000) }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) })
},
false
)
component.render = () => null
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText() {},
setElementText() {},
parentNode: () => null,
nextSibling: () => null,
insert() {},
remove() {},
patchProp() {}
})
const app = renderer.createApp(component, active === undefined ? {} : { active })
app.mount({})
await flush()
assert.deepEqual(calls, active === false ? [] : ['proof'])
app.unmount()
})
test('closing and reopening rejects old response and finally without clearing new loading', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const first = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
s.visible.value = false
const second = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' })
h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l' }] })
await first
assert.equal(s.editorLoading.value, true)
assert.equal(s.editing.value, undefined)
h.calls[2].resolve({ items: [{ stopType: 'delivery', stopProductId: 'd' }] })
await second
assert.equal(s.editorLoading.value, false)
assert.equal(s.editing.value.stopProductId, 'd')
h.stop()
})
test('historical attachments have a separate readonly field and viewport aligned expansion', () => {
const descriptor = parse(
readFileSync(
new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url),
'utf8'
)
).descriptor
assert.match(descriptor.template!.content, /历史计划级资料/)
assert.match(
descriptor.template!.content,
/:model-value="product.legalUseCertUrl \|\| ''"\s+disabled/
)
assert.match(descriptor.styles[0].content, /text-align: left/)
const h = harness()
assert.equal(h.state.expansionStyle.value.width, '1120px')
h.viewportWidth.value = 760
assert.equal(h.state.expansionStyle.value.width, '760px')
h.stop()
})
test('stop API preserves original legacy methods and scopes all three new endpoints', async () => {
const calls: any[] = []
const source = ts.createSourceFile(
'api.ts',
readFileSync(new URL('../src/api/pl/quota-cert.ts', import.meta.url), 'utf8'),
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source).replace(/^export /, ''))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const api = new Function('request', 'pickBxData', `${code};return legalDocumentApi`)(
{
get: (...args: any[]) => {
calls.push(args)
return Promise.resolve({})
},
patch: (...args: any[]) => {
calls.push(args)
return Promise.resolve({})
}
},
(v: any) => v
)
await api.listStops({ contractId: 'c', page: 2, status: 'pending', planNo: 'PL' })
await api.getStops('p', 'c')
await api.saveStops(
'p',
[{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'a,b' }],
'c'
)
assert.deepEqual(calls, [
[
'/transport/legal-use-documents/stops',
{ params: { contractId: 'c', page: 2, status: 'pending', planNo: 'PL' } }
],
['/transport/legal-use-documents/stops/p', { params: { contractId: 'c' } }],
[
'/transport/legal-use-documents/stops/p',
{ items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'a,b' }] },
{ params: { contractId: 'c' } }
]
])
for (const method of ['list', 'get', 'save']) assert.equal(typeof api[method], 'function')
})
test('view permission loads proof only and prevents upload requests', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list'] })
await flush()
assert.equal(s.allowed.value, true)
assert.equal(s.canUpload.value, false)
await s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
await s.save()
assert.deepEqual(
h.calls.map((c) => c.method),
['listStops']
)
h.stop()
})
test('old save completion cannot close or clear a reopened editor', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'a' }] })
await opened
const oldSave = s.save()
s.visible.value = false
const reopened = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' })
h.calls[3].resolve({
items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'b' }]
})
await reopened
const newSave = s.save()
h.calls[2].resolve({})
await oldSave
assert.equal(s.visible.value, true)
assert.equal(s.saving.value, true)
assert.equal(s.editing.value.stopProductId, 'd')
assert.equal(h.calls.length, 5)
h.calls[4].reject(Error('retry'))
await newSave
assert.equal(s.editing.value.legalUseCertUrl, 'b')
assert.equal(s.saving.value, false)
h.stop()
})
test('old list and permission responses cannot reauthorize another tenant', async () => {
const h = harness(),
s = h.state
h.user.getUserInfo.tenantNo = 'b'
h.permissions[0].resolve({ all: true })
await flush()
assert.equal(h.calls.length, 0)
h.permissions[1].resolve({ all: true })
await flush()
h.user.getUserInfo.tenantNo = 'c'
h.calls[0].resolve({ list: [{ id: 'stale' }], total: 1 })
await flush()
assert.deepEqual(s.rows.value, [])
assert.equal(s.allowed.value, false)
h.permissions[2].resolve({ menuCodes: [] })
await flush()
assert.equal(h.calls.length, 1)
h.stop()
})
test('status is a server single filter and combines with the number filter', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
s.status.value = 'uploaded'
s.search()
assert.equal(h.calls.at(-1).args[0].status, 'uploaded')
s.setFilterState('planNo', { text: 'PL' })
assert.equal(h.calls.at(-1).args[0].planNo, 'PL')
assert.equal(h.calls.at(-1).args[0].status, 'uploaded')
h.calls.forEach((c) => c.resolve({ list: [], total: 0 }))
await flush()
h.stop()
})
+63
View File
@@ -159,7 +159,70 @@ export const quotaCertApi = {
request.get(`${base}/${kind}/${id}/contracts`).then(pickBxData<ContractOption[]>)
}
const legalBase = '/transport/legal-use-documents'
export interface StopLegalDocumentItem {
stopType: 'load' | 'delivery'
stopId: string
stopOrder: number
stopProductId: string
productId: string
productName: string
model?: string
unit?: string
contractId?: string | null
contractNo?: string | null
contractProductId?: string | null
partyName?: string | null
address?: string | null
legalUseCertUrl: string
documentStatus: 'pending' | 'uploaded'
uploadedBy?: string | null
uploadedTime?: string | null
}
export interface StopLegalDocument {
id: string
planNo: string
planDate?: string
licensePlate?: string
transportStatus: string
archiveStatus: string
requiredCount: number
uploadedCount: number
documentStatus: 'pending' | 'uploaded' | 'not_required'
items: StopLegalDocumentItem[]
legacyProducts: {
id: string
productId: string
productName: string
model?: string
legalUseCertUrl?: string | null
}[]
}
export const legalDocumentApi = {
listStops: (params: {
keyword?: string
planNo?: string
status?: 'pending' | 'uploaded'
contractId?: string
page?: number
pageSize?: number
}) =>
request
.get(`${legalBase}/stops`, { params })
.then(
pickBxData<{ list: StopLegalDocument[]; total: number; page: number; pageSize: number }>
),
getStops: (id: string, contractId?: string) =>
request
.get(`${legalBase}/stops/${id}`, { params: { contractId } })
.then(pickBxData<StopLegalDocument>),
saveStops: (
id: string,
items: Pick<StopLegalDocumentItem, 'stopType' | 'stopProductId' | 'legalUseCertUrl'>[],
contractId?: string
) =>
request
.patch(`${legalBase}/stops/${id}`, { items }, { params: { contractId } })
.then(pickBxData<StopLegalDocument>),
list: (params: { keyword?: string; status?: string }) =>
request.get(legalBase, { params }).then(pickBxData<LegalDocument[]>),
get: (id: string) => request.get(`${legalBase}/${id}`).then(pickBxData<LegalDocument>),
@@ -0,0 +1,497 @@
<template>
<section :class="embedded ? 'dialog-list-panel' : 'panel'">
<div v-if="!embedded" class="panel-top"><h2 class="title-bar-heading">合法使用证明</h2></div>
<div class="list-toolbar"
><div class="list-toolbar-row">
<ListQuickSearchInput
v-model="keyword"
placeholder="计划编号"
@search="search"
@clear="search"
/>
<el-select
v-model="status"
clearable
placeholder="上传情况"
aria-label="上传情况"
style="width: 150px"
@change="search"
>
<el-option label="待上传" value="pending" /><el-option label="已上传" value="uploaded" />
</el-select>
<el-button v-if="keyword || status || hasActiveFilters" @click="reset"
><el-icon><RefreshLeft /></el-icon>清除筛选</el-button
>
<ListToolbarEndActions
:loading="loading"
:column-defs="columns"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="load"
@reset-columns="resetToDefault"
/> </div
></div>
<el-alert v-if="error" :title="error" type="error" :closable="false"
><el-button @click="allowed ? load() : init()"
><el-icon><Refresh /></el-icon>重试</el-button
></el-alert
>
<el-empty v-if="!allowed && !loading && !error" description="无合法使用证明查看权限" />
<div v-else class="list-body list-wrap"
><div ref="tableWrapRef" class="table-scroll">
<ListTable
ref="tableRef"
table-key="pl-stop-documents"
:height="tableHeight"
:data="rows"
row-key="id"
stripe
class="list-table"
v-loading="loading"
@expand-change="expand"
>
<el-table-column type="expand"
><template #default="{ row }">
<div
class="view-section documents-expansion"
:style="expansionStyle"
v-loading="details[row.id]?.loading"
>
<el-alert
v-if="details[row.id]?.error"
title="资料加载失败"
type="error"
:closable="false"
><el-button @click="loadDetail(row)"
><el-icon><Refresh /></el-icon>重试</el-button
></el-alert
>
<template v-else-if="details[row.id]?.data">
<el-table
:data="details[row.id].data!.items"
:row-key="itemKey"
stripe
class="list-table"
>
<el-table-column label="运输段" min-width="100"
><template #default="{ row: item }"
><el-tag size="small"
>{{ item.stopType === 'load' ? '装货' : '送货' }}
{{ item.stopOrder }}</el-tag
></template
></el-table-column
>
<el-table-column prop="productName" label="产品" min-width="140" />
<el-table-column prop="model" label="规格" min-width="100" />
<el-table-column prop="unit" label="单位" min-width="80" />
<el-table-column prop="contractNo" label="合同编号" min-width="170" />
<el-table-column prop="partyName" label="本段主体" min-width="180" />
<el-table-column prop="address" label="本段位置" min-width="220" />
<el-table-column label="上传情况" min-width="110"
><template #default="{ row: item }"
><el-tag size="small" :type="statusType(item.documentStatus)">{{
statusLabel(item.documentStatus)
}}</el-tag></template
></el-table-column
>
<el-table-column label="附件" min-width="220"
><template #default="{ row: item }"
><AttachmentUploadField
:model-value="item.legalUseCertUrl || ''"
disabled
compact /></template
></el-table-column>
<el-table-column prop="uploadedTime" label="上传时间" min-width="170" />
<el-table-column v-if="canUpload" label="操作" min-width="260" fixed="right"
><template #default="{ row: item }"
><el-button @click="open(row, item)"
><el-icon><Document /></el-icon>维护本段资料</el-button
></template
></el-table-column
>
</el-table>
<section v-if="details[row.id].data!.legacyProducts.length" class="view-section">
<h3>历史计划级资料</h3
><p class="form-tip">历史附件仅供查看,不计入运输段上传情况。</p>
<div v-for="product in details[row.id].data!.legacyProducts" :key="product.id">
<p>{{ product.productName }} {{ product.model || '' }}</p>
<AttachmentUploadField
:model-value="product.legalUseCertUrl || ''"
disabled
/>
</div>
</section>
</template>
</div> </template
></el-table-column>
<el-table-column
v-for="column in visibleDefs"
:key="column.prop"
:prop="column.prop"
:label="column.label"
min-width="150"
>
<template #header
><div class="col-header-with-filter"
><span class="col-header-with-filter__label">{{ column.label }}</span
><ColumnHeaderFilter
v-if="column.prop === 'planNo'"
prop="planNo"
label="计划编号"
type="text"
:enable-sort="false"
:model-value="filters.planNo || null"
:is-active="isFilterActive('planNo')"
@update:model-value="(value) => setFilterState('planNo', value)" /></div
></template>
<template #default="{ row }">
<el-tag
v-if="column.prop === 'documentStatus'"
size="small"
:type="statusType(row.documentStatus)"
>{{ statusLabel(row.documentStatus) }}</el-tag
>
<span v-else-if="column.prop === 'progress'"
>{{ row.uploadedCount }} / {{ row.requiredCount }}</span
>
<span v-else>{{ row[column.prop] || '—' }}</span>
</template>
</el-table-column>
</ListTable> </div
><div ref="pagerRef" class="pagination list-pager-bar"
><el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="total, prev, pager, next"
@current-change="load" /></div
></div>
<el-dialog
v-model="visible"
title="本段产品合法使用证明"
width="720px"
align-center
append-to-body
class="dialog-form-layout"
:close-on-click-modal="false"
>
<div v-loading="editorLoading">
<el-alert v-if="editorError" :title="editorError" type="error" :closable="false" />
<template v-if="editing"
><p
>{{ editing.stopType === 'load' ? '装货' : '送货' }} {{ editing.stopOrder }} ·
{{ editing.productName }} {{ editing.model }}</p
><p
>{{ editing.contractNo || '未关联合同' }} · {{ editing.partyName }} ·
{{ editing.address }}</p
>
<el-form label-position="top"
><el-form-item label="合法使用证明"
><AttachmentUploadField
:key="editorEpoch"
ref="upload"
v-model="editing.legalUseCertUrl"
folder="pl/legal-use-document"
:disabled="!canUpload || saving" /></el-form-item
></el-form>
</template>
</div>
<template #footer
><el-button @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
><el-button
v-if="canUpload"
type="primary"
:loading="saving"
:disabled="editorLoading || !editing || !!upload?.hasUploading()"
@click="save"
><el-icon><Check /></el-icon>保存本段资料</el-button
></template
>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { Check, Close, Document, Refresh, RefreshLeft } from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import {
legalDocumentApi,
type StopLegalDocument,
type StopLegalDocumentItem
} from '@/api/pl/quota-cert'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
const props = withDefaults(
defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>(),
{ active: true }
)
const user = useUserStore()
const allowed = ref(false),
canUpload = ref(false),
loading = ref(false),
error = ref(''),
keyword = ref(''),
status = ref<'' | 'pending' | 'uploaded'>(''),
page = ref(1),
total = ref(0)
const rows = ref<StopLegalDocument[]>([])
const details = reactive<
Record<string, { loading: boolean; error: boolean; data?: StopLegalDocument }>
>({})
const visible = ref(false),
saving = ref(false),
editorLoading = ref(false),
editorError = ref(''),
editorEpoch = ref(0)
const editing = ref<StopLegalDocumentItem>(),
editingPlanId = ref('')
const upload = ref<InstanceType<typeof AttachmentUploadField>>()
let authEpoch = 0,
listEpoch = 0
const columns: ColumnFilterDef[] = [
['planNo', '计划编号'],
['planDate', '计划日期'],
['licensePlate', '车牌号'],
['progress', '已上传 / 应上传'],
['documentStatus', '上传情况']
].map(([prop, label]) => ({ prop, label, type: 'text', queryKey: prop }))
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
'pl-stop-documents-v1',
columns
)
const visibleDefs = computed(() => columns.filter((c) => visibleColumns.value.includes(c.prop)))
const {
filters,
hasActiveFilters,
setFilterState,
clearAllFilters,
isFilterActive,
toQueryParams,
watchVisibleColumns
} = useServerColumnFilters(
columns.filter((c) => c.prop === 'planNo'),
{ onChange: search }
)
watchVisibleColumns(visibleColumns)
const statusLabel = (value: string) =>
value === 'uploaded' ? '已上传' : value === 'not_required' ? '无需上传' : '待上传'
const statusType = (value: string) =>
value === 'uploaded' ? 'success' : value === 'not_required' ? 'info' : 'warning'
const itemKey = (item: StopLegalDocumentItem) => `${item.stopType}:${item.stopProductId}`
function clearDetails() {
Object.keys(details).forEach((key) => delete details[key])
}
function closeEditor() {
editorEpoch.value++
editing.value = undefined
editingPlanId.value = ''
upload.value = undefined
saving.value = false
editorLoading.value = false
editorError.value = ''
}
watch(
visible,
(value) => {
if (!value) closeEditor()
},
{ flush: 'sync' }
)
async function load() {
if (!allowed.value || !props.active) return
const current = ++listEpoch
rows.value = []
clearDetails()
loading.value = true
error.value = ''
try {
const data = await legalDocumentApi.listStops({
page: page.value,
pageSize: 20,
keyword: keyword.value,
...toQueryParams(),
status: status.value || undefined,
contractId: props.contractId
})
if (current !== listEpoch) return
rows.value = data.list
total.value = data.total
} catch {
if (current === listEpoch) error.value = '资料加载失败,请重试'
} finally {
if (current === listEpoch) loading.value = false
}
}
async function loadDetail(row: Pick<StopLegalDocument, 'id'>) {
if (!allowed.value || !props.active) return
const current = listEpoch,
state = reactive({
loading: true,
error: false,
data: undefined as StopLegalDocument | undefined
})
details[row.id] = state
try {
const result = await legalDocumentApi.getStops(row.id, props.contractId)
if (current === listEpoch && details[row.id] === state) state.data = result
} catch {
if (current === listEpoch && details[row.id] === state) state.error = true
} finally {
if (current === listEpoch && details[row.id] === state) state.loading = false
}
}
function expand(row: StopLegalDocument, expanded: StopLegalDocument[]) {
if (expanded.some((r) => r.id === row.id) && !details[row.id]) void loadDetail(row)
}
async function open(
row: Pick<StopLegalDocument, 'id'>,
item: Pick<StopLegalDocumentItem, 'stopType' | 'stopProductId'>
) {
if (!allowed.value || !canUpload.value || !props.active) return
closeEditor()
visible.value = true
editorLoading.value = true
const current = editorEpoch.value
try {
const result = await legalDocumentApi.getStops(row.id, props.contractId)
if (current !== editorEpoch.value) return
const match = result.items.find(
(candidate) => itemKey(candidate) === itemKey(item as StopLegalDocumentItem)
)
if (!match) {
editorError.value = '本段产品已变更,请刷新列表'
return
}
editing.value = { ...match, legalUseCertUrl: match.legalUseCertUrl || '' }
editingPlanId.value = row.id
} catch {
if (current === editorEpoch.value) editorError.value = '资料加载失败,请关闭后重试'
} finally {
if (current === editorEpoch.value) editorLoading.value = false
}
}
async function save() {
if (
!visible.value ||
!editing.value ||
!allowed.value ||
!canUpload.value ||
saving.value ||
editorLoading.value
)
return
if (upload.value?.hasUploading()) {
ElMessage.warning('请等待附件上传完成')
return
}
const current = editorEpoch.value,
item = editing.value
saving.value = true
editorError.value = ''
try {
await legalDocumentApi.saveStops(
editingPlanId.value,
[
{
stopType: item.stopType,
stopProductId: item.stopProductId,
legalUseCertUrl: item.legalUseCertUrl
}
],
props.contractId
)
if (current !== editorEpoch.value) return
ElMessage.success('本段资料已保存')
visible.value = false
void load()
} catch {
if (current === editorEpoch.value) editorError.value = '保存失败,已保留填写内容,请重试'
} finally {
if (current === editorEpoch.value) saving.value = false
}
}
function search() {
page.value = 1
void load()
}
function reset() {
keyword.value = ''
status.value = ''
clearAllFilters()
search()
}
async function init() {
const current = ++authEpoch
listEpoch++
rows.value = []
total.value = 0
page.value = 1
clearDetails()
visible.value = false
closeEditor()
allowed.value = false
canUpload.value = false
loading.value = false
error.value = ''
if (!props.active || !user.accessToken) return
loading.value = true
try {
const permissions = await fetchGetMenuCodes()
if (current !== authEpoch) return
const codes = permissions.menuCodes || [],
all = !!permissions.all || !!permissions.isSuperAdmin
allowed.value = all || codes.includes('pl:legal-use-document:list')
canUpload.value = allowed.value && (all || codes.includes('pl:plan-cert:upload'))
loading.value = false
await load()
} catch {
if (current === authEpoch) {
loading.value = false
error.value = '权限加载失败,请重试'
}
}
}
watch(
() => [props.contractId, props.active, JSON.stringify([user.accessToken, user.getUserInfo])],
() => void init(),
{ immediate: true, flush: 'sync' }
)
onBeforeUnmount(() => {
authEpoch++
listEpoch++
closeEditor()
allowed.value = false
canUpload.value = false
rows.value = []
clearDetails()
})
const tableWrapRef = ref<HTMLElement | null>(null),
pagerRef = ref<HTMLElement | null>(null),
tableRef = ref()
const { width: tableViewportWidth } = useElementSize(tableWrapRef)
const expansionStyle = computed(() => ({
width: tableViewportWidth.value > 0 ? `${tableViewportWidth.value}px` : undefined
}))
const { tableHeight } = useListTableViewportHeight({
tableEl: tableWrapRef,
pagerEl: pagerRef,
tableRef,
enabled: computed(() => !props.embedded)
})
</script>
<style scoped>
.documents-expansion {
box-sizing: border-box;
max-width: 100%;
padding: 16px;
text-align: left;
}
</style>
@@ -1,356 +1,6 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top"><h2 class="title-bar-heading">合法使用证明</h2></div>
<div class="list-toolbar"
><div class="list-toolbar-row"
><ListQuickSearchInput
v-model="keyword"
placeholder="计划编号"
@search="search"
@clear="search" /><el-button v-if="hasActiveFilters || keyword" @click="reset"
><el-icon><RefreshLeft /></el-icon>清除筛选</el-button
><ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="load"
@reset-columns="resetToDefault" /></div
></div>
<div class="list-body list-wrap"
><div ref="tableWrapRef" class="table-scroll"
><ListTable
ref="tableRef"
:height="tableHeight"
table-key="pl-legal-use-document"
v-loading="loading"
:data="pageRows"
row-key="id"
stripe
class="list-table"
@row-dblclick="open"
>
<el-table-column
v-for="column in visibleDefs"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.type === 'text' ? 190 : 140"
><template #header
><div class="col-header-with-filter"
><span class="col-header-with-filter__label">{{ column.label }}</span>
<ColumnHeaderFilter
:prop="column.prop"
:label="column.label"
:type="column.type"
:enable-sort="false"
:options="collectUniqueOptions(column.prop)"
:is-active="isFilterActive(column.prop)"
:model-value="filters[column.prop] || null"
@update:model-value="(v) => setFilterState(column.prop, v)"
/> </div></template
><template #default="{ row }"
><el-link v-if="column.prop === 'planNo'" type="primary" @click="open(row)">{{
row.planNo
}}</el-link
><span v-else-if="column.prop === 'productNames'">{{ productNames(row) }}</span
><el-tag v-else size="small" :type="pendingCount(row) ? 'warning' : 'success'">{{
column.prop === 'documentStatus'
? pendingCount(row)
? '待上传'
: '已上传'
: `${pendingCount(row)} / ${row.products.length}`
}}</el-tag></template
></el-table-column
><el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ row }"
><el-button @click="open(row)"
><el-icon><Document /></el-icon
>{{ canUpload ? '维护资料' : '查看资料' }}</el-button
></template
></el-table-column
>
</ListTable></div
><div ref="pagerRef" class="pagination list-pager-bar"
><el-pagination
v-model:current-page="page"
:page-size="20"
:total="filteredRows.length"
layout="total, prev, pager, next" /></div
></div>
</section>
<el-dialog
v-model="visible"
:title="`合法使用证明 · ${detail?.planNo || ''}`"
width="900px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<section v-for="p in detail?.products || []" :key="p.id" class="view-section">
<h3>{{ p.productName }} {{ p.model || '' }}</h3>
<p v-for="context in p.contexts || []" :key="context.stopProductId"
>{{ context.stopType === 'load' ? '装货' : '送货' }}:{{
context.partyName || '未填写单位'
}}
· {{ context.address || '未填写地址' }} · 合同
{{ context.contractNo || '未关联或无查看权限' }}</p
>
<el-tag size="small" :type="p.legalUseCertUrl ? 'success' : 'warning'">{{
p.legalUseCertUrl ? '已上传' : '待上传'
}}</el-tag>
<AttachmentUploadField
:ref="(el) => setUpload(p.id, el)"
v-model="p.legalUseCertUrl"
folder="pl/legal-use-document"
:disabled="!canUpload || saving"
/>
</section>
<template #footer
><el-button :disabled="saving" @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
><el-button v-if="canUpload" type="primary" :loading="saving" @click="save"
><el-icon><Check /></el-icon>保存资料</el-button
></template
>
</el-dialog>
</div>
<div class="page-container"><StopDocuments /></div>
</template>
<script setup lang="ts">
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
useClientColumnFilters,
type ColumnFilterDef,
type ColumnFilterState,
type TextColumnFilterState
} from '@/composables/useClientColumnFilters'
import { Check, Close, Document, RefreshLeft } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'
import { fetchGetMenuCodes } from '@/api/auth'
import { legalDocumentApi, type LegalDocument } from '@/api/pl/quota-cert'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
const page = ref(1)
const keyword = ref('')
const loading = ref(false),
saving = ref(false),
visible = ref(false),
canUpload = ref(false)
const rows = ref<LegalDocument[]>([]),
detail = ref<LegalDocument>()
let listEpoch = 0
let disposed = false
async function load() {
if (disposed) return
const current = ++listEpoch
loading.value = true
try {
const result = await legalDocumentApi.list({})
if (current !== listEpoch || disposed) return
rows.value = result
} finally {
if (current === listEpoch && !disposed) loading.value = false
}
}
const uploads = new Map<string, InstanceType<typeof AttachmentUploadField>>()
function setUpload(id: string, value: any) {
if (value) uploads.set(id, value)
else uploads.delete(id)
}
let session = 0
watch(
visible,
(value) => {
if (!value) session++
},
{ flush: 'sync' }
)
async function open(row: LegalDocument) {
if (saving.value) return
const current = ++session
const result = await legalDocumentApi.get(row.id)
if (current !== session) return
detail.value = result
visible.value = true
}
async function save() {
if (!detail.value || !canUpload.value || saving.value) return
if ([...uploads.values()].some((upload) => upload.hasUploading())) {
ElMessage.warning('请等待附件上传完成')
return
}
const current = session,
targetId = detail.value.id
saving.value = true
try {
const result = await legalDocumentApi.save(
targetId,
detail.value.products.map((p) => ({ id: p.id, legalUseCertUrl: p.legalUseCertUrl }))
)
if (current !== session || detail.value?.id !== targetId) return
detail.value = result
ElMessage.success('合法使用证明已保存')
visible.value = false
await load()
} finally {
saving.value = false
}
}
const user = useUserStore()
let authEpoch = 0
async function init() {
const current = ++authEpoch
const p = await fetchGetMenuCodes()
if (current !== authEpoch || disposed) return
canUpload.value =
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:plan-cert:upload')
await load()
}
watch(
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => {
authEpoch++
listEpoch++
rows.value = []
page.value = 1
loading.value = false
canUpload.value = false
session++
visible.value = false
detail.value = undefined
if (user.accessToken) void init()
},
{ flush: 'sync' }
)
onMounted(init)
onBeforeUnmount(() => {
disposed = true
listEpoch++
authEpoch++
})
const productNames = (row: LegalDocument) => row.products.map((p) => p.productName).join('、')
const pendingCount = (row: LegalDocument) =>
row.products.filter((p) => p.documentStatus === 'pending').length
const listColumnDefs: ColumnFilterDef[] = [
{ prop: 'planNo', label: '计划编号', type: 'text' },
{ prop: 'productNames', label: '易制爆产品', type: 'text', getValue: productNames },
{ prop: 'pendingCount', label: '待补明细', type: 'number', getValue: pendingCount },
{
prop: 'documentStatus',
label: '上传情况',
type: 'enum',
enumOptions: [
{ value: 'pending', label: '待上传' },
{ value: 'uploaded', label: '已上传' }
]
}
]
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
'pl-legal-use-document-columns-v1',
listColumnDefs
)
const visibleDefs = computed(() =>
listColumnDefs.filter((c) => visibleColumns.value.includes(c.prop))
)
const appliedKeyword = ref('')
const searchedRows = computed(() =>
rows.value.filter((row) => String(row.planNo).includes(appliedKeyword.value))
)
const {
filters,
filteredRows: columnFilteredRows,
collectUniqueOptions: collectColumnOptions,
hasActiveFilters: hasColumnFilters,
setFilterState: setColumnFilter,
clearAllFilters,
clearFilter,
isFilterActive: isColumnFilterActive
} = useClientColumnFilters(
searchedRows,
listColumnDefs.filter((column) => column.prop !== 'documentStatus')
)
const selectedStatuses = computed(
() => (filters.documentStatus as TextColumnFilterState | undefined)?.selected || []
)
const filteredRows = computed(() =>
columnFilteredRows.value.filter(
(row: LegalDocument) =>
!selectedStatuses.value.length ||
row.products.some((product) => selectedStatuses.value.includes(product.documentStatus))
)
)
const hasActiveFilters = computed(
() => hasColumnFilters.value || selectedStatuses.value.length > 0
)
function setFilterState(prop: string, state: ColumnFilterState | null) {
if (prop !== 'documentStatus') return setColumnFilter(prop, state)
const selected =
(state as TextColumnFilterState | null)?.selected?.filter((value) =>
['pending', 'uploaded'].includes(value)
) || []
if (selected.length) filters.documentStatus = { text: '', selected: [...selected] }
else clearFilter(prop)
}
function isFilterActive(prop: string) {
return prop === 'documentStatus'
? selectedStatuses.value.length > 0
: isColumnFilterActive(prop)
}
function collectUniqueOptions(prop: string) {
if (prop !== 'documentStatus') return collectColumnOptions(prop)
return [
{ value: 'pending', label: '待上传' },
{ value: 'uploaded', label: '已上传' }
].map((option) => ({
...option,
count: searchedRows.value.filter((row) =>
row.products.some((product) => product.documentStatus === option.value)
).length
}))
}
const pageRows = computed(() => filteredRows.value.slice((page.value - 1) * 20, page.value * 20))
function search() {
appliedKeyword.value = keyword.value.trim()
page.value = 1
}
function reset() {
keyword.value = ''
appliedKeyword.value = ''
clearAllFilters()
page.value = 1
}
watch(
filters,
() => {
page.value = 1
},
{ deep: true, flush: 'sync' }
)
watch(visibleColumns, (next, prev) => {
prev.filter((p) => !next.includes(p)).forEach(clearFilter)
})
watch(
() => filteredRows.value.length,
(total) => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(total / 20)))
}
)
const tableWrapRef = ref<HTMLElement | null>(null)
const pagerRef = ref<HTMLElement | null>(null)
const tableRef = ref()
const { tableHeight } = useListTableViewportHeight({
tableEl: tableWrapRef,
pagerEl: pagerRef,
tableRef
})
import StopDocuments from './StopDocuments.vue'
</script>