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

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

No files matched your search

@@ -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>
+141 -35
View File
@@ -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>
+220 -112
View File
@@ -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>