fix(pl): 统一计划用证三页的检索工具栏与漏斗以保持筛选和分页一致。
This commit is contained in:
1 parent
c4f173bc3a
commit
c16681a16f
8 files changed
+982
-235
No files matched your search
@@ -9,19 +9,29 @@
|
||||
### 1. 计划列表服务端筛选
|
||||
|
||||
Files: `backend/app/services/pl_quota_overview.py`, `backend/app/api/pl/quota_cert.py`, `backend/tests/test_pl_quota_overview_filters.py`。
|
||||
- [ ] 先写跨20条分页、租户/删除/取消、组合编号车号日期及多状态测试,并运行确认失败。
|
||||
- [ ] `list_plans(..., plan_no=None, license_plate=None, plan_date_from=None, plan_date_to=None, quota_status_in=None)` 可选参数在count/offset之前应用。接口别名planNo/licensePlate/planDateFrom/planDateTo/quotaStatusIn。拒绝非法状态/日期范围。
|
||||
- [ ] 使用独立SQLite测试,不连接测试或生产库:backend内 `ENV_FILE=/dev/null <主目录>/backend/venv/bin/python -m pytest tests/test_pl_quota_overview_filters.py tests/test_pl_quota_api.py -q`。
|
||||
- [x] 先写跨20条分页、租户/删除/取消、组合编号车号日期及多状态测试,并运行确认失败。
|
||||
- [x] `list_plans(..., plan_no=None, license_plate=None, plan_date_from=None, plan_date_to=None, quota_status_in=None)` 可选参数在count/offset之前应用。接口别名planNo/licensePlate/planDateFrom/planDateTo/quotaStatusIn。拒绝非法状态/日期范围。
|
||||
- [x] 使用独立SQLite测试,不连接测试或生产库:backend内 `ENV_FILE=/dev/null <主目录>/backend/venv/bin/python -m pytest tests/test_pl_quota_overview_filters.py tests/test_pl_quota_api.py -q`。
|
||||
|
||||
### 2. 三页列表组件
|
||||
|
||||
Files: `web-admin/src/views/pl/{plan-quota,quota-cert,legal-use-document}/index.vue`;所需列表筛选定义及专项测试。
|
||||
- [ ] 复用运输计划公共工具栏、ListTable、漏斗、列设置;保留已有详情/维护逻辑和权限。
|
||||
- [ ] 计划列表新漏斗参数传后端;另外两页在完整集合先筛再分页;隐藏列清漏斗,条件变化回首页,刷新保留条件。
|
||||
- [ ] 新增真实筛选/组件handler专项测试:组合、清空、跨页、旧响应失效;H5保持原能力。
|
||||
- [x] 复用运输计划公共工具栏、ListTable、漏斗、列设置;保留已有详情/维护逻辑和权限。
|
||||
- [x] 计划列表新漏斗参数传后端;另外两页在完整集合先筛再分页;隐藏列清漏斗,条件变化回首页,刷新保留条件。
|
||||
- [x] 新增真实筛选/组件handler专项测试:组合、清空、跨页、旧响应失效;H5保持原能力。
|
||||
|
||||
### 3. 审查与交付
|
||||
|
||||
- [ ] 按改动运行专项pytest/Node测试及Web构建;合成API验证三页检索和漏斗交互、截图对比参考组件。
|
||||
- [ ] 独立代码审查,修复实际问题后重验;重新读取工单评论。
|
||||
- [ ] 保存实际证据/版本和剩余限制;保留待用户测试,未获授权不推送合并/关闭工单。
|
||||
- [x] 按改动运行专项pytest/Node测试及Web构建;合成API验证三页检索和漏斗交互、截图对比参考组件。
|
||||
- [x] 独立代码审查,修复实际问题后重验;重新读取工单评论。
|
||||
- [x] 保存实际证据/版本和剩余限制;保留待用户测试,未获授权不推送合并/关闭工单。
|
||||
|
||||
## 验证及转测记录(2026-11-01)
|
||||
|
||||
- 后端专项35/35通过:新增9项服务筛选回归及API别名验证,联跑原来源/额度接口测试。先观察新增参数缺失导致的8项失败,再实现。独立后端规格与质量审查PASS。
|
||||
- 前端专项50/50通过:真实SFC setup的过滤/分页/乱序/认证失效回归,联跑既有quota移动、配置、归档、补量;漏斗计数渲染覆盖省略计数、0及正数。日志 `/tmp/shanxia-issue8-ui-tests.log`。
|
||||
- 规格审查发现部分上传计划被“已上传”筛选遗漏;修正为任一产品命中,两个状态多选为OR。混合行回归先失败后通过,规格复审PASS;独立质量复审自行运行42项相关测试,全部通过,无未解决P1/P2。
|
||||
- 合成Playwright验证实际三页组件:筛出原第二页031记录、刷新保留条件、检索030、重置、计划状态条件传服务端、隐藏列清条件、证面品名与余额组合、混合资料上传状态;均0页面错误。1440/1100宽度截图位于忽略目录 `web-admin/.local/issue8/`,已与实际运输计划参考组件截图比较,固定表头、表体滚动、分页可见。全部API由合成响应拦截,外网请求拒绝。
|
||||
- 最新Web生产构建通过46.61秒(既有chunk体积警告),日志 `/tmp/shanxia-issue8-build.log`。全库vue-tsc仍失败:主目录和本任务均499条错误,归一化行号与目录后新增错误0;不声称全库类型通过。日志 `/tmp/shanxia-issue8-typecheck{,-baseline}.log`。
|
||||
- 本工单未读取运行密钥、连接真实业务数据库或执行SQL,无需新增迁移。H5保留原移动检索、卡片、分页分支。完整应用及真实数据验收仍待用户。
|
||||
- 复用本会话既有隔离目录,任务分支 `codex/gitea-issue-8`,#4分支保留原交付提交。用户已确认三个Web页面范围并免除新Figma;延续本会话本地合入V3.0供用户测试的方式,不推送远端、不关闭工单。保留任务分支与工作区用于反馈修复。
|
||||
@@ -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),
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
>
|
||||
<span class="value-label">{{ opt.label }}</span>
|
||||
</el-checkbox>
|
||||
<span class="value-count">{{ opt.count }}</span>
|
||||
<span v-if="opt.count !== undefined" class="value-count">{{ opt.count }}</span>
|
||||
</label>
|
||||
<div v-if="!optionsLoading && !visibleOptions.length" class="empty-tip">暂无可选值</div>
|
||||
</div>
|
||||
@@ -267,7 +267,7 @@
|
||||
type: ColumnFilterType
|
||||
isActive: boolean
|
||||
modelValue?: ColumnFilterState | null
|
||||
options?: Array<{ value: string; label: string; count: number }>
|
||||
options?: Array<{ value: string; label: string; count?: number }>
|
||||
/** 枚举选项加载中(动态 facet) */
|
||||
optionsLoading?: boolean
|
||||
/** CRM 外壳 CrmColFilterHeader 已自绘双三角时传 false,避免重复 */
|
||||
|
||||
@@ -2,71 +2,84 @@
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top"><h2 class="title-bar-heading">合法使用证明</h2></div>
|
||||
<div class="filter-block"
|
||||
><el-form label-position="top" @submit.prevent="load">
|
||||
<div class="filter-grid"
|
||||
><el-form-item label="计划编号"
|
||||
><el-input v-model="keyword" placeholder="输入计划编号" clearable /></el-form-item
|
||||
><el-form-item label="上传情况"
|
||||
><el-select v-model="status" clearable
|
||||
><el-option value="pending" label="待上传" /><el-option
|
||||
value="uploaded"
|
||||
label="已上传" /></el-select></el-form-item
|
||||
></div>
|
||||
<div class="filter-actions-row"
|
||||
><span class="filter-hint">合法使用证明可在运输安排或归档后补传。</span
|
||||
><div class="filter-actions-end"
|
||||
><el-button @click="reset"
|
||||
><el-icon><RefreshLeft /></el-icon>重置</el-button
|
||||
><el-button type="primary" :loading="loading" @click="load"
|
||||
><el-icon><Search /></el-icon>搜索</el-button
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</el-form></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"
|
||||
><el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="list-table"
|
||||
@row-dblclick="open"
|
||||
>
|
||||
<el-table-column label="计划编号" min-width="180"
|
||||
><template #default="{ row }"
|
||||
><el-link type="primary" @click="open(row)">{{ row.planNo }}</el-link></template
|
||||
></el-table-column
|
||||
><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 label="易制爆产品" min-width="220"
|
||||
><template #default="{ row }">{{
|
||||
row.products.map((p: any) => p.productName).join('、')
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="待补明细" min-width="120"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
size="small"
|
||||
:type="
|
||||
row.products.some((p: any) => p.documentStatus === 'pending')
|
||||
? 'warning'
|
||||
: 'success'
|
||||
"
|
||||
>{{ row.products.filter((p: any) => p.documentStatus === 'pending').length }} /
|
||||
{{ 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
|
||||
>
|
||||
</el-table></div
|
||||
>
|
||||
<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"
|
||||
@@ -109,34 +122,45 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, Document, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
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 keyword = ref(''),
|
||||
status = ref('')
|
||||
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 {
|
||||
rows.value = await legalDocumentApi.list({
|
||||
keyword: keyword.value,
|
||||
status: status.value || undefined
|
||||
})
|
||||
const result = await legalDocumentApi.list({})
|
||||
if (current !== listEpoch || disposed) return
|
||||
rows.value = result
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (current === listEpoch && !disposed) loading.value = false
|
||||
}
|
||||
}
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
status.value = ''
|
||||
load()
|
||||
}
|
||||
const uploads = new Map<string, InstanceType<typeof AttachmentUploadField>>()
|
||||
function setUpload(id: string, value: any) {
|
||||
if (value) uploads.set(id, value)
|
||||
@@ -181,10 +205,152 @@
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
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
|
||||
})
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div :class="mobile ? 'mp-h5-list-content' : 'page-container'">
|
||||
<section class="panel">
|
||||
<div v-if="!mobile" class="panel-top"><h2 class="title-bar-heading">计划用证</h2></div>
|
||||
<div class="filter-block"
|
||||
<div v-if="mobile" class="filter-block"
|
||||
><el-form label-position="top" @submit.prevent="load"
|
||||
><el-form-item label="计划编号"
|
||||
><el-input v-model="keyword" clearable placeholder="输入计划编号" /></el-form-item
|
||||
@@ -18,7 +18,26 @@
|
||||
></el-form
|
||||
></div
|
||||
>
|
||||
<p class="form-tip">按当前来源、合同产品范围和证件余额校验;实际安排时原子预占。</p>
|
||||
<div v-else 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>
|
||||
<p v-if="mobile" class="form-tip"
|
||||
>按当前来源、合同产品范围和证件余额校验;实际安排时原子预占。</p
|
||||
>
|
||||
<div v-if="mobile" v-loading="loading"
|
||||
><section v-for="row in rows" :key="row.id" class="mp-wv-section"
|
||||
><h3>{{ row.planNo }}</h3
|
||||
@@ -38,38 +57,69 @@
|
||||
></div
|
||||
>
|
||||
<div v-else class="list-body list-wrap"
|
||||
><el-table v-loading="loading" :data="rows" stripe class="list-table"
|
||||
><el-table-column label="计划编号" min-width="190"
|
||||
><template #default="{ row }"
|
||||
><el-link type="primary" @click="open(row.id)">{{ row.planNo }}</el-link></template
|
||||
></el-table-column
|
||||
><el-table-column prop="planDate" label="计划日期" min-width="140" /><el-table-column
|
||||
prop="licensePlate"
|
||||
label="车号"
|
||||
min-width="160"
|
||||
/><el-table-column label="用证校验" min-width="160"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
:type="
|
||||
row.quotaStatus === 'exception'
|
||||
? 'danger'
|
||||
: row.quotaStatus === 'reserved' || row.quotaStatus === 'settled'
|
||||
? 'success'
|
||||
: 'info'
|
||||
"
|
||||
>{{ stateLabels[row.quotaStatus] }}</el-tag
|
||||
></template
|
||||
></el-table-column
|
||||
><el-table-column label="操作" min-width="260" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button @click="open(row.id)"
|
||||
><el-icon><Document /></el-icon>用证配置与占用</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
></div
|
||||
>
|
||||
<div class="pagination list-pager-bar"
|
||||
><div ref="tableWrapRef" class="table-scroll"
|
||||
><ListTable
|
||||
ref="tableRef"
|
||||
:height="tableHeight"
|
||||
table-key="pl-plan-quota"
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
stripe
|
||||
class="list-table"
|
||||
><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="column.enumOptions || []"
|
||||
: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.id)">{{
|
||||
row.planNo
|
||||
}}</el-link
|
||||
><el-tag
|
||||
v-else-if="column.prop === 'quotaStatus'"
|
||||
size="small"
|
||||
:type="
|
||||
row.quotaStatus === 'exception'
|
||||
? 'danger'
|
||||
: ['reserved', 'settled'].includes(row.quotaStatus)
|
||||
? 'success'
|
||||
: 'info'
|
||||
"
|
||||
>{{ stateLabels[row.quotaStatus] }}</el-tag
|
||||
><span v-else>{{ row[column.prop] || '—' }}</span></template
|
||||
></el-table-column
|
||||
><el-table-column label="操作" min-width="260" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button @click="open(row.id)"
|
||||
><el-icon><Document /></el-icon>用证配置与占用</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="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="load" /></div
|
||||
></div>
|
||||
<div v-if="mobile" class="pagination list-pager-bar"
|
||||
><el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="20"
|
||||
@@ -82,8 +132,16 @@
|
||||
</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 { useServerColumnFilters } from '@/composables/useServerColumnFilters'
|
||||
import { type ColumnFilterDef } from '@/composables/useClientColumnFilters'
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { Document, Search } from '@element-plus/icons-vue'
|
||||
import { Document, Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { type Plan } from '@/api/pl'
|
||||
import request from '@/utils/http/bx'
|
||||
const stateLabels: Record<string, string> = {
|
||||
@@ -112,6 +170,7 @@
|
||||
const value = pickBxData<{ list: (Plan & { quotaStatus: string })[]; total: number }>(
|
||||
await request.get('/transport/quota-certs/plans', {
|
||||
params: {
|
||||
...toQueryParams(),
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value,
|
||||
@@ -155,4 +214,51 @@
|
||||
onBeforeUnmount(() => {
|
||||
epoch++
|
||||
})
|
||||
const listColumnDefs: ColumnFilterDef[] = [
|
||||
{ prop: 'planNo', label: '计划编号', type: 'text' },
|
||||
{ prop: 'planDate', label: '计划日期', type: 'datetime' },
|
||||
{ prop: 'licensePlate', label: '车号', type: 'text' },
|
||||
{
|
||||
prop: 'quotaStatus',
|
||||
label: '用证校验',
|
||||
type: 'enum',
|
||||
enumOptions: Object.entries(stateLabels).map(([value, label]) => ({ value, label }))
|
||||
}
|
||||
]
|
||||
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
|
||||
'pl-plan-quota-columns-v1',
|
||||
listColumnDefs
|
||||
)
|
||||
const visibleDefs = computed(() =>
|
||||
listColumnDefs.filter((c) => visibleColumns.value.includes(c.prop))
|
||||
)
|
||||
const {
|
||||
filters,
|
||||
hasActiveFilters,
|
||||
setFilterState,
|
||||
clearAllFilters,
|
||||
isFilterActive,
|
||||
toQueryParams,
|
||||
watchVisibleColumns
|
||||
} = useServerColumnFilters(listColumnDefs, { onChange: search })
|
||||
watchVisibleColumns(visibleColumns)
|
||||
function search() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
status.value = ''
|
||||
clearAllFilters()
|
||||
search()
|
||||
}
|
||||
const tableWrapRef = ref<HTMLElement | null>(null)
|
||||
const pagerRef = ref<HTMLElement | null>(null)
|
||||
const tableRef = ref()
|
||||
const { tableHeight } = useListTableViewportHeight({
|
||||
tableEl: tableWrapRef,
|
||||
pagerEl: pagerRef,
|
||||
tableRef,
|
||||
enabled: computed(() => !props.mobile)
|
||||
})
|
||||
</script>
|
||||
@@ -3,105 +3,101 @@
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top"><h2 class="title-bar-heading">购买证与运输证</h2></div>
|
||||
<div class="filter-block">
|
||||
<el-form label-position="top" @submit.prevent="load">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="证件种类"
|
||||
><el-select v-model="kind" @change="load"
|
||||
><el-option label="购买证" value="purchase" /><el-option
|
||||
label="运输证"
|
||||
value="transport" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="证件编号"
|
||||
><el-input v-model="keyword" clearable placeholder="输入证件编号"
|
||||
/></el-form-item>
|
||||
<el-form-item label="状态"
|
||||
><el-select v-model="status" clearable
|
||||
><el-option
|
||||
v-for="(label, value) in statusLabels"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value" /></el-select
|
||||
></el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row"
|
||||
><span class="filter-hint">先建档核对,再关联合同及产品;同一证件共用一个额度池。</span
|
||||
><div class="filter-actions-end"
|
||||
><el-button @click="reset"
|
||||
><el-icon><RefreshLeft /></el-icon>重置</el-button
|
||||
><el-button type="primary" :loading="loading" @click="load"
|
||||
><el-icon><Search /></el-icon>搜索</el-button
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="list-toolbar"
|
||||
><div class="list-toolbar-row">
|
||||
<span class="list-toolbar-selected">共 {{ rows.length }} 张</span>
|
||||
<el-button v-if="canEdit" type="primary" @click="edit()"
|
||||
><div class="list-toolbar-row"
|
||||
><el-select v-model="kind" aria-label="证件种类" style="width: 130px"
|
||||
><el-option label="购买证" value="purchase" /><el-option
|
||||
label="运输证"
|
||||
value="transport" /></el-select
|
||||
><ListQuickSearchInput
|
||||
v-model="keyword"
|
||||
placeholder="证件编号"
|
||||
@search="search"
|
||||
@clear="search" /><el-button v-if="hasActiveFilters || keyword" @click="reset"
|
||||
><el-icon><RefreshLeft /></el-icon>清除筛选</el-button
|
||||
><span class="list-toolbar-selected">共 {{ filteredRows.length }} 张</span
|
||||
><el-button v-if="canEdit" type="primary" @click="edit()"
|
||||
><el-icon><Plus /></el-icon>新增证件</el-button
|
||||
>
|
||||
<el-button @click="scopeDialog?.open()"
|
||||
><el-button @click="scopeDialog?.open()"
|
||||
><el-icon><Connection /></el-icon>关联合同产品</el-button
|
||||
>
|
||||
</div></div
|
||||
>
|
||||
><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">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="pageRows"
|
||||
stripe
|
||||
row-key="id"
|
||||
class="list-table"
|
||||
@row-dblclick="view"
|
||||
<div ref="tableWrapRef" class="table-scroll"
|
||||
><ListTable
|
||||
ref="tableRef"
|
||||
:height="tableHeight"
|
||||
table-key="pl-quota-cert"
|
||||
v-loading="loading"
|
||||
:data="pageRows"
|
||||
stripe
|
||||
row-key="id"
|
||||
class="list-table"
|
||||
@row-dblclick="view"
|
||||
>
|
||||
<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 === 'certNo'" type="primary" @click="view(row)">{{
|
||||
row.certNo
|
||||
}}</el-link
|
||||
><el-tag
|
||||
v-else-if="column.prop === 'status'"
|
||||
size="small"
|
||||
:type="tagType(row.status)"
|
||||
>{{ statusLabels[row.status as keyof typeof statusLabels] }}</el-tag
|
||||
><span v-else-if="column.type === 'number'"
|
||||
>{{ row[column.prop] }} {{ row.quantityUnit }}</span
|
||||
><span v-else>{{ row[column.prop] || '—' }}</span></template
|
||||
></el-table-column
|
||||
><el-table-column label="操作" min-width="320" fixed="right"
|
||||
><template #default="{ row }">
|
||||
<el-button v-if="canEdit" size="small" @click="edit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canEdit && row.status !== 'void'"
|
||||
size="small"
|
||||
@click="openConfirm(row)"
|
||||
><el-icon><Check /></el-icon
|
||||
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
|
||||
>
|
||||
<el-button size="small" @click="showContracts(row)"
|
||||
><el-icon><Connection /></el-icon>关联合同</el-button
|
||||
>
|
||||
</template></el-table-column
|
||||
>
|
||||
</ListTable></div
|
||||
>
|
||||
<el-table-column label="证件编号" min-width="190"
|
||||
><template #default="{ row }"
|
||||
><el-link type="primary" @click="view(row)">{{ row.certNo }}</el-link></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="productNameOnCert" label="证面品名" min-width="150" />
|
||||
<el-table-column label="状态" min-width="100"
|
||||
><template #default="{ row }"
|
||||
><el-tag size="small" :type="tagType(row.status)">{{
|
||||
statusLabels[row.status as keyof typeof statusLabels]
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in qtyColumns"
|
||||
:key="column.key"
|
||||
:label="column.label"
|
||||
min-width="130"
|
||||
><template #default="{ row }"
|
||||
>{{ row[column.key] }} {{ row.quantityUnit }}</template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="validTo" label="有效期至" min-width="130" />
|
||||
<el-table-column label="操作" min-width="320" fixed="right"
|
||||
><template #default="{ row }">
|
||||
<el-button v-if="canEdit" size="small" @click="edit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canEdit && row.status !== 'void'"
|
||||
size="small"
|
||||
@click="openConfirm(row)"
|
||||
><el-icon><Check /></el-icon
|
||||
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
|
||||
>
|
||||
<el-button size="small" @click="showContracts(row)"
|
||||
><el-icon><Connection /></el-icon>关联合同</el-button
|
||||
>
|
||||
</template></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar"
|
||||
<div ref="pagerRef" class="pagination list-pager-bar"
|
||||
><el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="20"
|
||||
:total="rows.length"
|
||||
:total="filteredRows.length"
|
||||
layout="total, prev, pager, next"
|
||||
/></div>
|
||||
</div>
|
||||
@@ -359,6 +355,16 @@
|
||||
</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
|
||||
} from '@/composables/useClientColumnFilters'
|
||||
|
||||
import QuotaHistoryDialog from './QuotaHistoryDialog.vue'
|
||||
const historyDialog = ref<InstanceType<typeof QuotaHistoryDialog>>()
|
||||
import {
|
||||
@@ -369,9 +375,9 @@
|
||||
Document,
|
||||
Edit,
|
||||
Plus,
|
||||
RefreshLeft,
|
||||
Search
|
||||
RefreshLeft
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import {
|
||||
quotaCertApi,
|
||||
@@ -392,7 +398,6 @@
|
||||
const route = useRoute()
|
||||
const kind = ref<CertType>('purchase')
|
||||
const keyword = ref(''),
|
||||
status = ref(''),
|
||||
page = ref(1)
|
||||
const rows = ref<QuotaCert[]>([]),
|
||||
purchases = ref<QuotaCert[]>([])
|
||||
@@ -425,7 +430,7 @@
|
||||
...commonFields,
|
||||
...(kind.value === 'purchase' ? purchaseFields : transportFields)
|
||||
])
|
||||
const pageRows = computed(() => rows.value.slice((page.value - 1) * 20, page.value * 20))
|
||||
|
||||
const statusLabels = { draft: '待核对', active: '已启用', suspended: '已停用', void: '已作废' }
|
||||
const refLabels = {
|
||||
purchase_permit: '购买许可证',
|
||||
@@ -454,23 +459,20 @@
|
||||
].map((k) => [k, [{ required: true, message: '请填写此项', trigger: 'blur' }]])
|
||||
)
|
||||
)
|
||||
let listEpoch = 0
|
||||
let disposed = false
|
||||
async function load() {
|
||||
if (disposed) return
|
||||
const current = ++listEpoch
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await quotaCertApi.list(kind.value, {
|
||||
keyword: keyword.value,
|
||||
status: status.value || undefined
|
||||
})
|
||||
page.value = 1
|
||||
const result = await quotaCertApi.list(kind.value)
|
||||
if (current !== listEpoch || disposed) return
|
||||
rows.value = result
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (current === listEpoch && !disposed) loading.value = false
|
||||
}
|
||||
}
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
status.value = ''
|
||||
load()
|
||||
}
|
||||
let editSession = 0
|
||||
watch(
|
||||
formVisible,
|
||||
@@ -482,10 +484,19 @@
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
watch(kind, () => {
|
||||
editSession++
|
||||
formVisible.value = false
|
||||
})
|
||||
watch(
|
||||
kind,
|
||||
() => {
|
||||
editSession++
|
||||
formVisible.value = false
|
||||
detailVisible.value = false
|
||||
confirmVisible.value = false
|
||||
rows.value = []
|
||||
page.value = 1
|
||||
void load()
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
async function edit(row?: QuotaCert) {
|
||||
if (saving.value) return
|
||||
const session = ++editSession
|
||||
@@ -611,11 +622,108 @@
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
const user = useUserStore()
|
||||
let authEpoch = 0
|
||||
async function init() {
|
||||
const current = ++authEpoch
|
||||
const p = await fetchGetMenuCodes()
|
||||
if (current !== authEpoch || disposed) return
|
||||
canEdit.value =
|
||||
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:edit')
|
||||
await load()
|
||||
if (typeof route.query.contractId === 'string') scopeDialog.value?.open(route.query.contractId)
|
||||
if (current === authEpoch && !disposed && typeof route.query.contractId === 'string')
|
||||
scopeDialog.value?.open(route.query.contractId)
|
||||
}
|
||||
watch(
|
||||
() => JSON.stringify([user.accessToken, user.getUserInfo]),
|
||||
() => {
|
||||
authEpoch++
|
||||
listEpoch++
|
||||
rows.value = []
|
||||
page.value = 1
|
||||
loading.value = false
|
||||
canEdit.value = false
|
||||
editSession++
|
||||
formVisible.value = false
|
||||
detailVisible.value = false
|
||||
confirmVisible.value = false
|
||||
if (user.accessToken) void init()
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
onMounted(init)
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
listEpoch++
|
||||
authEpoch++
|
||||
})
|
||||
|
||||
const listColumnDefs: ColumnFilterDef[] = [
|
||||
{ prop: 'certNo', label: '证件编号', type: 'text' },
|
||||
{ prop: 'productNameOnCert', label: '证面品名', type: 'text' },
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'enum',
|
||||
enumOptions: Object.entries(statusLabels).map(([value, label]) => ({ value, label }))
|
||||
},
|
||||
...qtyColumns.map((c) => ({ prop: c.key, label: c.label, type: 'number' as const })),
|
||||
{ prop: 'validTo', label: '有效期至', type: 'datetime' }
|
||||
]
|
||||
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
|
||||
'pl-quota-cert-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.certNo).includes(appliedKeyword.value))
|
||||
)
|
||||
const {
|
||||
filters,
|
||||
filteredRows,
|
||||
collectUniqueOptions,
|
||||
hasActiveFilters,
|
||||
setFilterState,
|
||||
clearAllFilters,
|
||||
clearFilter,
|
||||
isFilterActive
|
||||
} = useClientColumnFilters(searchedRows, listColumnDefs)
|
||||
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
|
||||
})
|
||||
</script>
|
||||
Reference in new issue
Block a user