feat(pl): 调整计划用证布局并在单张证件保存后重置表单。

This commit is contained in:
wuzilong committed 2026-11-02 15:01:07 +08:00
1 parent 26c6dc3a93
commit 0e83e22847
4 files changed
+516 -281

No files matched your search

@@ -0,0 +1,44 @@
# 计划用证弹窗布局 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
**Goal:** 完成工单 #11 的 Web 弹窗加宽、类型与附件同行、下方左右分区。
**Architecture:** 仅移动现有 Vue 模板分区并调整局部布局,布局最小调整;用户追加单张处理及保存成功重置需修改本页script setup与既有行为测试,不改共享组件或API。复用全局 form-grid-2;下方以2:3分区,≤1100px堆叠。
**Tech Stack:** Vue 3、Element Plus、SCSS、pnpm。
## Task 1: 调整布局并验证
Files: 修改 `web-admin/src/views/pl/quota-cert/index.vue`;设计依据 `docs/superpowers/specs/2026-11-02-quota-cert-dialog-layout-design.md`。
- [x] 将新增/编辑弹窗的 `width="1200px"` 改为 `width="1440px"`,保留 max-width 和正文滚动。
- [x] 第一行采用局部cert-header-grid,左侧包含建档方式、证件类型,右侧包含原附件及解析模板;保留全部事件和禁用绑定。
- [x] 下方cert-content-grid分两列:cert-info-panel保留证面信息form-grid-2及车牌、状态、备注;cert-relations-panel先放原合同产品section,再放原证面引用section。
- [x] 局部样式:`.cert-content-grid { display:grid; grid-template-columns:minmax(0,2fr) minmax(0,3fr); gap:24px; align-items:start; }`;各直接子项min-width:0;1100px以下改1fr。首行左侧minmax(0,1fr)、右侧minmax(0,3fr),900px以下1:2,560px以下单列。复用全局form-grid-2的560px降列。
- [x] 表格保留最小列宽、固定操作列及本身横向滚动,不改变共享组件;script setup仅按Task 3调整。
- [x] 运行 `pnpm --config.verify-deps-before-run=false exec tsx --test scripts/quota-cert-form.test.ts`(本页21项通过)及 `pnpm --config.verify-deps-before-run=false exec eslint src/views/pl/quota-cert/index.vue`(预期无错误)。这是低影响布局调整,不新增仅镜像模板实现的单测。
- [x] 运行 `pnpm --config.verify-deps-before-run=false build`;临时 Playwright fixture 直接编译真实弹窗与附件模板,加载真实样式,检查1600/1024/800/375视口、单附件、保存/解析禁用、错误提示。记录合成验证限制,不冒称真实业务E2E。
- [x] 需求审查后再代码质量审查,修复阻塞项;`git diff --check` 后按 Conventional Commits 提交。
## Task 2: 交付
- [x] 更新设计与验证记录,核对工单最新评论。
- [ ] 按现有 push_task_branch 授权推送任务分支。
- [ ] 准备包含工单、原型、改动及验证结果的PR正文。create_pr/merge/close_issue 尚未授权,完成可评审代码后仅询问对应具体动作;不继承 #10 的单工单授权。
## Task 3: 用户追加的单张处理
- [x] 在既有script harness先写失败测试:零/多附件解析与保存被拒,保存失败保留输入和备注,新增/编辑成功进入同类型新建空表单,下一张无旧ID,旧异步响应失效。
- [x] 本页附件limit固定1,删除多附件选择器,移除“可后补图片”矛盾文案。recognize与save统一校验附件数量。
- [x] save在API成功且会话有效后重置表单、附件、备注、关系ID与编辑缓存,推进会话版本;保留类型/录入方式且弹窗仍打开。当前备注必须在API调用前已纳入payload。
- [x] 修正原测试的默认多附件数据,保留历史异常多附件专用用例;跑完整本页行为回归、eslint/build及更新后的单附件视觉验证。
- [x] 重新进行需求与质量审查,先前布局审查不冒充新增业务行为通过。
## 验证记录
- 本页真实 script setup 回归21项、共享附件组件回归2项,共23项通过。新增覆盖单附件门槛、编辑后下一张无旧ID、成功重置/失败保留备注,以及运输证候选加载与保存的竞态。
- 改动Vue和测试文件ESLint通过;生产构建通过,仍有既有大chunk告警。ESLint读取构建生成的.auto-import.json,必须在构建后顺序执行,避免瞬态缺少自动导入声明。
- 实际Vue弹窗/附件模板及真实SCSS的合成浏览器检查:购买证新增和运输证编辑,1600/1440/1024/800/375视口,共10场景通过;系统备注进入真实certPayload,保存/解析禁用、必填提示及固定底栏检查通过。没有真实登录、上传存储或后端写入E2E,不宣称完整环境验收。
- 本轮未运行全仓类型检查(已有其他模块错误),未变更数据库、共享附件组件、后端或服务运行环境。
- 需求独立复审通过;最终质量审查通过,无P0–P2阻塞项。
@@ -1,29 +1,37 @@
# 计划用证弹窗布局(工单 #11)
# 计划用证弹窗布局(工单 #11,修订 v2)
工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/11
## 范围与设计
用户已确认仅修改 Web 新增、编辑购买证及运输证弹窗。目标分支 V3.0,基线 4d10142bfb6c211feee8d82e52bc10f844989cfe。采用 1440px 上限的弹窗,沿用现有视口最大宽度与固定头部、底栏规则。相较全屏弹窗,保留上下文;相较仅改为四列,增加字段可用宽度。
用户确认仅修改 Web 新增、编辑购买证及运输证弹窗。用户最新纠正需求:附件放在第一行,下方左侧为证面信息,右侧上方为绑定合同产品、下方为证面引用。此修订替代 v1 的通栏四列布局,不继续执行旧方案。
建档方式单独一行。下一行左侧为证件类型,右侧为证件附件及其解析操作,宽屏比例约 1:3,窄屏适当缩小类型区,手机窄视口垂直排列。附件沿用 AttachmentUploadField 的 140×96 卡片和自适应换行,不改变上传限制、已上传列表、解析与禁用条件。
弹窗最大宽度从1200px增至1440px,保留视口最大宽度、固定头部底栏及正文滚动。第一行为建档方式/证件类型设置和附件区域,附件区域从首行开始展示,解析操作紧随附件。复用 AttachmentUploadField,上传与解析遵循下述新增单张规则,预览、下载和纠正附件能力保留。
证面信息复用全局 form-grid-4:宽屏四列,视口不超过 900px 时两列,不超过 560px 时一列。字段顺序、数据绑定、必填校验和错误定位不变;日期输入撑满所在列。弹窗底栏固定、正文独立滚动。
下方采用约2:3左右布局:左侧证面信息使用现有form-grid-2两列,随证件类型显示相应字段,车牌号、状态和备注随证面信息放置;右侧合同产品分区在上、证面引用分区在下。购买证保持不显示证面引用;原字段、子数据ID和引用保护不变。两表保留水平滚动及固定操作列。
视口≤1100px时两区按证面信息、合同产品、证面引用顺序上下排列;≤560px时左侧字段及首行布局降为单列。用minmax(0,...)和min-width:0防止表格撑破父容器。无需后端、API或数据库改动。
## 视觉与复用
依据 Fresh Mint 规范 §9–10,模式 P12(多分区表单),参考现有 web-admin/src/views/pl/quota-cert/index.vue 与全局 src/assets/styles/core/app.scss。复用 el-dialog、el-form、AttachmentUploadField;无 API、后端或数据库改动。
依据Fresh Mint规范§9–10,模式P12,参考现有web-admin/src/views/pl/quota-cert/index.vue及全局src/assets/styles/core/app.scss。复用el-dialog、el-form、AttachmentUploadField及现有表格。
可编辑原型 v1:https://www.figma.com/design/ImDcikQnCX5GiJQLwUHwDC/shanxia2.0?node-id=36-314
可编辑原型v2:https://www.figma.com/design/ImDcikQnCX5GiJQLwUHwDC/shanxia2.0?node-id=39-431
原型的宽屏、窄屏均已查看;248 个可编辑后代节点,含 46 个组件实例,无完整界面图片。Inter 字体与规范一致。原型重点表达本次布局,已有证面引用、合同绑定及保存规则仍以已验收 #10 实现为准。
v2已查看1440px原型截图:附件与首行建档设置顶部对齐;下方证面信息在左、合同产品和证面引用在右上下排列。162个可编辑后代节点、26个组件实例,无界面图片,字体Inter,附件提示使用Noto Sans SC以完整显示中文。原型表达本次分区和单张处理提示;保留已验收#10引用保护。
## 验收与风险
Agent 执行针对性验证:真实 Vue 模板与全局样式在 1600、1024、800、375 像素视口渲染;购买证、运输证新增/编辑;多附件、长文件名、解析中、保存中及必填错误;检查字段列数、同行关系、无页面级横向溢出和底栏可达。
Agent执行实际Vue弹窗和附件模板的合成渲染,覆盖1600、1440、1024、800、375像素视口,购买证/运输证、新增/编辑、单附件、长文件名、解析中、保存中及必填错误。历史多附件阻止解析/保存由实际script harness验证。核对首行附件、左右/上下顺序、字段列数、水平滚动局限于表格、底栏可达。
运行既有前端 17 项行为回归、改动文件 ESLint 和生产构建。已知全仓类型检查有既存错误,不以局部检查声称全仓通过。业务 API/上传不连接真实数据,合成组件视觉验证与真实业务联调明确区分。
运行21项本页行为回归及2项共享附件组件回归、改动文件ESLint和生产构建。已知全仓类型检查存在其他文件错误;不将组件合成验证称为真实业务E2E。重点检查表格不撑宽分区、窄屏降列和原有保存载荷/引用保护不受影响。
主要风险是附件区挤压和窄屏字段溢出;通过复用全局降列规则、子项 min-width:0 及真实渲染核查。不得改变 #10 已关联行保护、子数据 ID、表单载荷、图片解析逻辑。
状态:用户在v2确认题中补充“备注字段不要丢了”;已补入原型,按v2保留系统备注继续实施。
状态:范围已确认;原型及本设计等待用户确认。
## 用户追加:逐张解析与保存后重置
用户明确“附件有且只能一张……保存后表单重置,然后才可以继续传下一张进行解析”。新增和编辑上传上限均为1;解析和保存入口均校验恰好一个已上传附件。历史多附件不自动删减,保持内容并提示先整理为单附件。上传或解析进行中禁止保存。
保存成功后保持弹窗,清空当前业务字段、附件、系统备注、关系和各层旧ID,进入同类型、同建档方式的新建表单。编辑只更新当前证件一次,下一张使用新增API。备注完整提交到当前证件,下一张不继承。保存/校验失败保留原表单;异步识别或选项请求不能污染重置后的新表单。用户可删除错误附件纠正当前单,但不可累积或批量解析多张。
范围仍为Web;不自动修改历史记录或其他客户端/API。新增真实业务回归覆盖单附件门槛、成功重置、失败保留、旧ID隔离、旧异步响应隔离。
+114 -3
View File
@@ -78,7 +78,7 @@ function harness() {
...model.newCert(kind as any),
id,
certNo: 'EXISTING',
attachmentUrls: 'a.png,b.png',
attachmentUrls: 'a.png',
...certData
},
save: async (kind: string, body: any, id?: string) => {
@@ -148,6 +148,7 @@ test('type changes immediately while options load and stale options cannot enter
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
const options = h.deferOptions()
const switching = s.changeFormKind('transport')
assert.equal(s.formKind.value, 'transport')
@@ -166,6 +167,7 @@ test('new form type changes without closing dialog or changing the list, and dri
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
assert.equal(s.formKind?.value, 'purchase')
await s.changeFormKind('transport')
assert.equal(s.formKind.value, 'transport')
@@ -189,6 +191,7 @@ test('switch clears old certificate data after confirmation, preserving attachme
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.form.value.certNo = 'PURCHASE-1'
s.form.value.entryMode = 'image_parse'
s.form.value.attachmentUrls = 'one.png'
@@ -209,6 +212,7 @@ test('cancel type switch and existing certificate edit preserve data and origina
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.form.value.certNo = 'KEEP'
h.cancelSwitch()
s.selectedFormKind.value = 'transport'
@@ -219,7 +223,7 @@ test('cancel type switch and existing certificate edit preserve data and origina
await s.edit({ id: 'existing' })
await s.changeFormKind('transport')
assert.equal(s.formKind.value, 'purchase')
assert.equal(s.form.value.attachmentUrls, 'a.png,b.png')
assert.equal(s.form.value.attachmentUrls, 'a.png')
} finally {
h.stop()
}
@@ -230,6 +234,7 @@ test('single attachment is automatically selected, parsing uses form type and pr
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
await s.changeFormKind('transport')
s.form.value.attachmentUrls = 'one.png'
await vue.nextTick()
@@ -257,6 +262,7 @@ test('missing, removed, multiple new attachments and uploads in progress never s
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = ''
const empty = s.recognize()
assert.equal(h.parses.length, 0)
await empty
@@ -293,6 +299,7 @@ test('closed or switched forms reject delayed recognition results', async () =>
if (action === 'close') {
s.formVisible.value = false
await s.edit()
s.form.value.attachmentUrls = 'one.png'
} else await s.changeFormKind('transport')
h.parses[0].resolve({ candidates: { certNo: 'OLD' }, invalidFields: {} })
await parsing
@@ -355,6 +362,7 @@ test('产品勾选往返保留原范围ID;关闭重开后拒绝旧合同和产
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding('c')
h.productRequests[0].resolve([{ id: 'p' }, { id: 'p2' }])
await vue.nextTick()
@@ -368,6 +376,7 @@ test('产品勾选往返保留原范围ID;关闭重开后拒绝旧合同和产
const products = s.loadBindingProducts('c')
s.formVisible.value = false
await s.edit()
s.form.value.attachmentUrls = 'one.png'
h.contractRequests.at(-1).resolve([{ id: 'stale', contractNo: '旧合同' }])
h.productRequests.forEach((r) => r.resolve([{ id: 'stale', productName: '旧产品' }]))
await Promise.all([search, products])
@@ -407,6 +416,7 @@ test('后发合同搜索覆盖先发,租户切换使保存和读取旧结果
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
const first = s.searchBindingContracts('first'),
second = s.searchBindingContracts('second')
h.contractRequests[1].resolve([{ id: 'second', contractNo: 'SECOND', contractName: '' }])
@@ -438,6 +448,7 @@ test('新增多合同连同证面成功提交一次,移除全部提交明确
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding('one')
s.addContractBinding('two')
h.productRequests[0].resolve([{ id: 'p1' }])
@@ -453,7 +464,7 @@ test('新增多合同连同证面成功提交一次,移除全部提交明确
h.writes[0].body.contractBindings.map((b: any) => b.contractId),
['one', 'two']
)
assert.equal(s.formVisible.value, false)
assert.equal(s.formVisible.value, true)
h.setCert({ contractBindings: [{ id: 'b', contractId: 'one', products: [] }] })
await s.edit({ id: 'existing' })
s.removeContractBinding(0)
@@ -469,6 +480,7 @@ test('同合同产品后发请求优先,移除重加使移除前请求失效',
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding('c')
const second = s.loadBindingProducts('c')
h.productRequests[1].resolve([{ id: 'new', productName: '新产品' }])
@@ -496,6 +508,7 @@ test('产品加载失败可再次展开重试,加载中不能改选择', async
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding('c')
h.productRequests[0].reject(new Error('network'))
await vue.nextTick()
@@ -518,6 +531,7 @@ test('inline binding adds an empty row, requires contract, and resets products o
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding()
assert.equal(s.form.value.contractBindings.length, 1)
assert.equal(s.form.value.contractBindings[0].contractId, '')
@@ -589,3 +603,100 @@ test('recognition cannot populate locked empty references while other empty fiel
h.stop()
}
})
test('successful edit clears attachment and remark and becomes a fresh open form', async () => {
const h = harness(),
s = h.state
try {
await s.edit({ id: 'existing' })
s.form.value.attachmentUrls = 'one.png'
s.form.value.remark = '本张备注'
await s.save()
assert.equal(h.writes[0].body.remark, '本张备注')
assert.equal(s.formVisible.value, true)
assert.equal(s.editingId.value, '')
assert.equal(s.form.value.attachmentUrls, '')
assert.equal(s.form.value.remark, '')
s.form.value.attachmentUrls = 'next.png'
await s.save()
assert.equal(h.writes[1].id, undefined)
} finally {
h.stop()
}
})
test('save and recognition reject missing or historical multiple attachments', async () => {
const h = harness(),
s = h.state
try {
await s.edit({ id: 'existing' })
for (const urls of ['', 'a.png,b.png']) {
s.form.value.attachmentUrls = urls
await vue.nextTick()
s.recognitionImage.value = urls.split(',')[0]
await s.save()
const parsing = s.recognize()
for (const p of h.parses) p.resolve({ candidates: {}, invalidFields: {} })
await parsing
}
assert.equal(h.writes.length, 0)
assert.equal(h.parses.length, 0)
} finally {
h.stop()
}
})
test('new transport save keeps its type open while failures and recognition preserve the current remark', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
await s.changeFormKind('transport')
s.form.value.attachmentUrls = 'one.png'
s.form.value.remark = '不得丢失'
await s.save()
assert.equal(s.formVisible.value, true)
assert.equal(s.kind.value, 'transport')
assert.equal(s.formKind.value, 'transport')
assert.equal(s.form.value.remark, '')
assert.deepEqual(s.form.value.purchaseRefs, [])
assert.deepEqual(s.form.value.contractBindings, [])
s.form.value.attachmentUrls = 'two.png'
s.form.value.remark = '失败保留'
s.recognizing.value = true
await s.save()
assert.equal(h.writes.length, 1)
assert.equal(s.form.value.remark, '失败保留')
s.recognizing.value = false
h.failSave()
await assert.rejects(s.save(), /保存失败/)
assert.equal(s.form.value.remark, '失败保留')
assert.equal(s.form.value.attachmentUrls, 'two.png')
assert.equal(s.formVisible.value, true)
} finally {
h.stop()
}
})
test('saving while transport purchase candidates load refreshes candidates for the next session', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
const old = h.deferOptions()
const switching = s.changeFormKind('transport')
const fresh = h.deferOptions()
s.form.value.attachmentUrls = 'one.png'
await s.save()
old.resolve([{ id: 'stale' }])
await switching
assert.deepEqual(s.purchases.value, [])
fresh.resolve([{ id: 'fresh' }])
await Promise.resolve()
await vue.nextTick()
assert.deepEqual(s.purchases.value, [{ id: 'fresh' }])
assert.equal(s.formVisible.value, true)
} finally {
h.stop()
}
})
+339 -267
View File
@@ -105,7 +105,7 @@
<el-dialog
v-model="formVisible"
:title="editingId ? `编辑${formKind === 'purchase' ? '购买证' : '运输证'}` : '新增证件'"
width="1200px"
width="1440px"
align-center
class="dialog-form-layout quota-cert-form-dialog"
:close-on-click-modal="false"
@@ -121,270 +121,270 @@
label-position="top"
scroll-to-error
>
<section class="view-section form-grid-2">
<el-form-item>
<template #label><span class="dialog-section-title">建档方式</span></template>
<el-radio-group v-model="form.entryMode" :disabled="!!editingId || recognizing"
><el-radio value="manual">手工录入(可后补图片)</el-radio
><el-radio value="image_parse">图片解析后核对</el-radio></el-radio-group
></el-form-item
>
<el-form-item>
<template #label><span class="dialog-section-title">证件类型</span></template>
<el-radio-group
v-model="selectedFormKind"
:disabled="!!editingId"
@change="changeFormKind"
<section class="view-section cert-header-grid">
<div class="cert-entry-settings">
<el-form-item>
<template #label><span class="dialog-section-title">建档方式</span></template>
<el-radio-group v-model="form.entryMode" :disabled="!!editingId || recognizing"
><el-radio value="manual">手工录入</el-radio
><el-radio value="image_parse">图片解析后核对</el-radio></el-radio-group
></el-form-item
>
<el-radio value="purchase">购买证</el-radio>
<el-radio value="transport">运输证</el-radio>
</el-radio-group>
</el-form-item>
</section>
<section class="view-section">
<el-form-item>
<template #label><span class="dialog-section-title">证件附件</span></template>
<AttachmentUploadField
ref="certAttachments"
:key="editSession"
:disabled="saving || recognizing"
v-model="form.attachmentUrls"
folder="pl/quota-cert"
:limit="editingId ? 15 : 1"
accept="image/*,.pdf"
:hint="
editingId
? '支持图片、PDF;每次仅解析一个附件'
: '支持图片、PDF;每次仅上传并解析一个附件,更换请先删除原附件'
"
@update:model-value="edited.add('attachmentUrls')"
/></el-form-item>
<template v-if="form.entryMode === 'image_parse' || !!form.attachmentUrls">
<div class="recognition-actions">
<el-select
v-if="editingId && attachmentOptions.length > 1"
v-model="recognitionImage"
:disabled="recognizing"
placeholder="选择一个附件进行解析"
><el-option
v-for="url in attachmentOptions"
:key="url"
:value="url"
:label="url.split('/').pop()"
/></el-select>
<el-button :loading="recognizing" :disabled="!recognitionImage" @click="recognize"
><el-icon><Document /></el-icon>解析{{
formKind === 'purchase' ? '购买证' : '运输证'
}}</el-button
<el-form-item>
<template #label><span class="dialog-section-title">证件类型</span></template>
<el-radio-group
v-model="selectedFormKind"
:disabled="!!editingId"
@change="changeFormKind"
>
</div>
<p class="form-tip"
>请核对证号、数量、单位、有效次数和引用关系。解析只补充未编辑的空字段。</p
>
</template>
</section>
<section class="view-section"
><h3 class="dialog-section-title">证面信息</h3
><div class="form-grid-3">
<el-form-item
v-for="field in formFields"
:key="field[0]"
:label="field[1]"
:prop="field[0]"
>
<el-date-picker
v-if="isDate(field[0])"
v-model="form[field[0]]"
type="date"
value-format="YYYY-MM-DD"
@change="edited.add(field[0])"
/>
<el-input
v-else
v-model="form[field[0]]"
:placeholder="
field[0] === 'validUseText' ? '例如:一次有效、多次有效' : `请输入${field[1]}`
"
@input="edited.add(field[0])"
/>
<el-radio value="purchase">购买证</el-radio>
<el-radio value="transport">运输证</el-radio>
</el-radio-group>
</el-form-item> </div
></section>
<section v-if="formKind === 'transport'" class="view-section">
<el-form-item label="车牌号(多车共用额度)"
><el-select
v-model="form.vehicleNumbersJson"
multiple
filterable
allow-create
default-first-option
@change="edited.add('vehicleNumbersJson')"
/></el-form-item>
<h3 class="dialog-section-title">证面引用</h3>
<el-button
class="mb-3"
type="primary"
:disabled="saving || form.referencesEditable === false"
@click="addReference"
><el-icon><Plus /></el-icon>添加引用</el-button
>
<p v-if="form.referencesEditable === false" class="form-tip"
>已有用证关联,不能编辑或移除证面引用,请联系管理员。</p
>
<el-table :data="form.purchaseRefs" row-key="id" stripe>
<el-table-column label="引用类型" min-width="160"
><template #default="{ row }"
><el-select
v-model="row.referenceType"
:disabled="saving || form.referencesEditable === false"
@change="edited.add('purchaseRefs')"
><el-option
v-for="(label, value) in refLabels"
:key="value"
:value="value"
:label="label" /></el-select></template
></el-table-column>
<el-table-column label="引用证号" min-width="160"
><template #default="{ row }"
><el-input
v-model="row.referenceNo"
:disabled="saving || form.referencesEditable === false"
@input="edited.add('purchaseRefs')" /></template
></el-table-column>
<el-table-column label="对应购买证档案" min-width="200"
><template #default="{ row }"
><el-select
v-model="row.purchaseCertId"
:disabled="saving || form.referencesEditable === false"
filterable
clearable
@change="edited.add('purchaseRefs')"
><el-option
v-for="c in purchases"
:key="c.id"
:value="c.id"
:label="c.certNo" /></el-select></template
></el-table-column>
<el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ $index }"
><el-button
:disabled="saving || form.referencesEditable === false"
@click="removeReference($index)"
><el-icon><Delete /></el-icon>移除引用</el-button
></template
></el-table-column
>
</el-table>
</section>
<section class="view-section">
<h3 class="dialog-section-title">绑定合同产品</h3>
<p v-if="form.bindingsEditable === false" class="form-tip">
当前仅展示可见合同,绑定范围不可编辑;证面信息仍可保存。
</p>
<el-button
v-if="form.bindingsEditable !== false"
class="mb-3"
type="primary"
:disabled="saving"
@click="addContractBinding()"
><el-icon><Plus /></el-icon>添加合同</el-button
>
<el-table :data="form.contractBindings || []" stripe>
<el-table-column label="合同" min-width="220">
<template #default="{ row, $index }">
<el-select
:model-value="row.contractId"
filterable
remote
placeholder="请选择合同"
:remote-method="searchBindingContracts"
:loading="bindingSearching"
:disabled="saving || form.bindingsEditable === false || row.editLocked"
@visible-change="(open: boolean) => open && searchBindingContracts('')"
@change="(id: string) => changeBindingContract($index, id)"
><div class="cert-attachment-section">
<el-form-item>
<template #label><span class="dialog-section-title">证件附件</span></template>
<AttachmentUploadField
ref="certAttachments"
:key="editSession"
:disabled="saving || recognizing"
v-model="form.attachmentUrls"
folder="pl/quota-cert"
:limit="1"
accept="image/*,.pdf"
hint="一次仅上传并解析一张证件;保存成功后自动清空,继续处理下一张"
@update:model-value="edited.add('attachmentUrls')"
/></el-form-item>
<template v-if="form.entryMode === 'image_parse' || !!form.attachmentUrls">
<div class="recognition-actions">
<el-button :loading="recognizing" :disabled="!recognitionImage" @click="recognize"
><el-icon><Document /></el-icon>解析{{
formKind === 'purchase' ? '购买证' : '运输证'
}}</el-button
>
<el-option
v-for="contract in bindingContracts"
:key="contract.id"
:value="contract.id"
:label="contract.contractNo + ' · ' + contract.contractName"
:disabled="
form.contractBindings.some(
(b: CertificateContractBinding, i: number) =>
i !== $index && b.contractId === contract.id
)
"
/>
<el-option
v-if="row.contractId && !bindingContracts.some((c) => c.id === row.contractId)"
:value="row.contractId"
label="合同信息不可用"
/>
</el-select>
<p v-if="row.editLocked" class="form-tip" style="white-space: normal"
>已有用证关联,不能编辑或移除,请联系管理员。</p
>
</template>
</el-table-column>
<el-table-column label="适用合同产品" min-width="360">
<template #default="{ row, $index }">
<el-select
multiple
:model-value="row.products.map((p: any) => p.contractProductId)"
:disabled="
saving || form.bindingsEditable === false || row.editLocked || !row.contractId
"
:loading="bindingProductLoading[row.contractId]"
:placeholder="row.contractId ? '勾选适用产品' : '请先选择合同'"
style="width: 100%"
@change="(ids: string[]) => setBindingProducts($index, ids)"
@visible-change="(open: boolean) => open && loadBindingProducts(row.contractId)"
>
<el-option
v-for="product in bindingProductOptions(row)"
:key="product.id"
:value="product.id"
:disabled="bindingProductLoading[row.contractId]"
:label="
product.productName +
(product.model ? ' / ' + product.model : '') +
(product.unit ? '(' + product.unit + ')' : '')
"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="状态" min-width="100">
<template #default="{ row }"
><el-tag :type="row.linkStatus === 'inactive' ? 'info' : 'success'" size="small">{{
row.linkStatus === 'inactive' ? '已停用' : '启用'
}}</el-tag></template
</div>
<p class="form-tip"
>请核对证号、数量、单位、有效次数和引用关系。解析只补充未编辑的空字段。</p
>
</el-table-column>
<el-table-column
v-if="form.bindingsEditable !== false"
label="操作"
min-width="260"
fixed="right"
>
<template #default="{ row, $index }"
><el-button
:disabled="saving || row.editLocked"
@click="removeContractBinding($index)"
><el-icon><Delete /></el-icon>移除合同</el-button
></template
</template>
</div></section
>
<div class="cert-content-grid"
><div class="cert-info-panel">
<section class="view-section"
><h3 class="dialog-section-title">证面信息</h3
><div class="form-grid-2">
<el-form-item
v-for="field in formFields"
:key="field[0]"
:label="field[1]"
:prop="field[0]"
>
<el-date-picker
v-if="isDate(field[0])"
v-model="form[field[0]]"
type="date"
value-format="YYYY-MM-DD"
@change="edited.add(field[0])"
/>
<el-input
v-else
v-model="form[field[0]]"
:placeholder="
field[0] === 'validUseText' ? '例如:一次有效、多次有效' : `请输入${field[1]}`
"
@input="edited.add(field[0])"
/>
</el-form-item> </div
></section>
<el-form-item v-if="formKind === 'transport'" label="车牌号(多车共用额度)"
><el-select
v-model="form.vehicleNumbersJson"
multiple
filterable
allow-create
default-first-option
@change="edited.add('vehicleNumbersJson')"
/></el-form-item>
<el-form-item v-if="editingId" label="停止新增使用">
<el-select v-model="form.status" clearable placeholder="保持当前状态">
<el-option label="停用(可复核恢复)" value="suspended" />
<el-option label="作废(不可恢复)" value="void" />
</el-select>
</el-form-item>
<el-form-item label="系统备注"
><el-input v-model="form.remark" type="textarea"
/></el-form-item> </div
><div class="cert-relations-panel">
<section class="view-section">
<h3 class="dialog-section-title">绑定合同产品</h3>
<p v-if="form.bindingsEditable === false" class="form-tip">
当前仅展示可见合同,绑定范围不可编辑;证面信息仍可保存。
</p>
<el-button
v-if="form.bindingsEditable !== false"
class="mb-3"
type="primary"
:disabled="saving"
@click="addContractBinding()"
><el-icon><Plus /></el-icon>添加合同</el-button
>
</el-table-column>
</el-table>
</section>
<el-form-item v-if="editingId" label="停止新增使用">
<el-select v-model="form.status" clearable placeholder="保持当前状态">
<el-option label="停用(可复核恢复)" value="suspended" />
<el-option label="作废(不可恢复)" value="void" />
</el-select>
</el-form-item>
<el-form-item label="系统备注"
><el-input v-model="form.remark" type="textarea"
/></el-form-item>
<el-table :data="form.contractBindings || []" stripe>
<el-table-column label="合同" min-width="220">
<template #default="{ row, $index }">
<el-select
:model-value="row.contractId"
filterable
remote
placeholder="请选择合同"
:remote-method="searchBindingContracts"
:loading="bindingSearching"
:disabled="saving || form.bindingsEditable === false || row.editLocked"
@visible-change="(open: boolean) => open && searchBindingContracts('')"
@change="(id: string) => changeBindingContract($index, id)"
>
<el-option
v-for="contract in bindingContracts"
:key="contract.id"
:value="contract.id"
:label="contract.contractNo + ' · ' + contract.contractName"
:disabled="
form.contractBindings.some(
(b: CertificateContractBinding, i: number) =>
i !== $index && b.contractId === contract.id
)
"
/>
<el-option
v-if="
row.contractId && !bindingContracts.some((c) => c.id === row.contractId)
"
:value="row.contractId"
label="合同信息不可用"
/>
</el-select>
<p v-if="row.editLocked" class="form-tip" style="white-space: normal"
>已有用证关联,不能编辑或移除,请联系管理员。</p
>
</template>
</el-table-column>
<el-table-column label="适用合同产品" min-width="360">
<template #default="{ row, $index }">
<el-select
multiple
:model-value="row.products.map((p: any) => p.contractProductId)"
:disabled="
saving ||
form.bindingsEditable === false ||
row.editLocked ||
!row.contractId
"
:loading="bindingProductLoading[row.contractId]"
:placeholder="row.contractId ? '勾选适用产品' : '请先选择合同'"
style="width: 100%"
@change="(ids: string[]) => setBindingProducts($index, ids)"
@visible-change="
(open: boolean) => open && loadBindingProducts(row.contractId)
"
>
<el-option
v-for="product in bindingProductOptions(row)"
:key="product.id"
:value="product.id"
:disabled="bindingProductLoading[row.contractId]"
:label="
product.productName +
(product.model ? ' / ' + product.model : '') +
(product.unit ? '(' + product.unit + ')' : '')
"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="状态" min-width="100">
<template #default="{ row }"
><el-tag
:type="row.linkStatus === 'inactive' ? 'info' : 'success'"
size="small"
>{{ row.linkStatus === 'inactive' ? '已停用' : '启用' }}</el-tag
></template
>
</el-table-column>
<el-table-column
v-if="form.bindingsEditable !== false"
label="操作"
min-width="260"
fixed="right"
>
<template #default="{ row, $index }"
><el-button
:disabled="saving || row.editLocked"
@click="removeContractBinding($index)"
><el-icon><Delete /></el-icon>移除合同</el-button
></template
>
</el-table-column>
</el-table>
</section>
<section v-if="formKind === 'transport'" class="view-section">
<h3 class="dialog-section-title">证面引用</h3>
<el-button
class="mb-3"
type="primary"
:disabled="saving || form.referencesEditable === false"
@click="addReference"
><el-icon><Plus /></el-icon>添加引用</el-button
>
<p v-if="form.referencesEditable === false" class="form-tip"
>已有用证关联,不能编辑或移除证面引用,请联系管理员。</p
>
<el-table :data="form.purchaseRefs" row-key="id" stripe>
<el-table-column label="引用类型" min-width="160"
><template #default="{ row }"
><el-select
v-model="row.referenceType"
:disabled="saving || form.referencesEditable === false"
@change="edited.add('purchaseRefs')"
><el-option
v-for="(label, value) in refLabels"
:key="value"
:value="value"
:label="label" /></el-select></template
></el-table-column>
<el-table-column label="引用证号" min-width="160"
><template #default="{ row }"
><el-input
v-model="row.referenceNo"
:disabled="saving || form.referencesEditable === false"
@input="edited.add('purchaseRefs')" /></template
></el-table-column>
<el-table-column label="对应购买证档案" min-width="200"
><template #default="{ row }"
><el-select
v-model="row.purchaseCertId"
:disabled="saving || form.referencesEditable === false"
filterable
clearable
@change="edited.add('purchaseRefs')"
><el-option
v-for="c in purchases"
:key="c.id"
:value="c.id"
:label="c.certNo" /></el-select></template
></el-table-column>
<el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ $index }"
><el-button
:disabled="saving || form.referencesEditable === false"
@click="removeReference($index)"
><el-icon><Delete /></el-icon>移除引用</el-button
></template
></el-table-column
>
</el-table>
</section>
</div></div
>
</el-form>
<template #footer
><el-button :disabled="saving" @click="formVisible = false"
@@ -920,7 +920,11 @@
}
}
async function save() {
if (saving.value) return
if (saving.value || recognizing.value) return
if (attachmentOptions.value.length !== 1) {
ElMessage.warning('请保留且仅上传一张证件附件')
return
}
if (
form.value.bindingsEditable !== false &&
form.value.contractBindings?.some((b: CertificateContractBinding) => !b.contractId)
@@ -946,10 +950,46 @@
editingId.value || undefined
)
if (session !== editSession || disposed) return
formVisible.value = false
ElMessage.success('证件已保存')
if (kind.value !== targetKind) kind.value = targetKind
else await load()
const entryMode = form.value.entryMode
const switchedKind = kind.value !== targetKind
if (switchedKind) kind.value = targetKind
editSession++
editingId.value = ''
formKind.value = targetKind
selectedFormKind.value = targetKind
edited.clear()
recognitionImage.value = ''
bindingSearchEpoch++
bindingContracts.value = []
bindingProducts.value = {}
bindingProductLoading.value = {}
bindingProductEpochs.clear()
bindingSearching.value = false
originalScopes.clear()
removedBindings.clear()
form.value = {
...newCert(targetKind),
entryMode,
contractBindings: [],
bindingsEditable: true
}
recognizing.value = false
formVisible.value = true
purchases.value = []
if (targetKind === 'transport') {
const nextSession = editSession
void quotaCertApi
.list('purchase')
.then((options) => {
if (nextSession === editSession && formVisible.value && formKind.value === targetKind)
purchases.value = options
})
.catch(() => {})
}
await nextTick()
formRef.value?.clearValidate()
ElMessage.success('证件已保存,请上传下一张证件')
if (!switchedKind) await load()
} finally {
saving.value = false
}
@@ -963,7 +1003,7 @@
if (
!recognitionImage.value ||
!attachmentOptions.value.includes(recognitionImage.value) ||
(!editingId.value && attachmentOptions.value.length !== 1)
attachmentOptions.value.length !== 1
) {
ElMessage.warning('请选择一个已上传的证件附件')
return
@@ -1191,8 +1231,7 @@
margin-bottom: 0;
}
.form-grid-2,
.form-grid-3 {
.form-grid-2 {
.el-form-item {
min-width: 0;
margin-bottom: 0;
@@ -1203,7 +1242,40 @@
width: 100%;
}
.cert-header-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
gap: 24px;
}
.cert-content-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
gap: 24px;
align-items: start;
}
.cert-entry-settings,
.cert-attachment-section,
.cert-info-panel,
.cert-relations-panel {
min-width: 0;
}
@media (max-width: 1100px) {
.cert-content-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.cert-header-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
}
}
@media (max-width: 560px) {
.cert-header-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.recognition-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 12px;