diff --git a/docs/superpowers/plans/2026-11-02-quota-cert-dialog-layout.md b/docs/superpowers/plans/2026-11-02-quota-cert-dialog-layout.md new file mode 100644 index 000000000..d218876a8 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-quota-cert-dialog-layout.md @@ -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阻塞项。 diff --git a/docs/superpowers/specs/2026-11-02-quota-cert-dialog-layout-design.md b/docs/superpowers/specs/2026-11-02-quota-cert-dialog-layout-design.md index 031d4f87d..a88343803 100644 --- a/docs/superpowers/specs/2026-11-02-quota-cert-dialog-layout-design.md +++ b/docs/superpowers/specs/2026-11-02-quota-cert-dialog-layout-design.md @@ -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隔离、旧异步响应隔离。 diff --git a/web-admin/scripts/quota-cert-form.test.ts b/web-admin/scripts/quota-cert-form.test.ts index 37ac91567..6c6c174dc 100644 --- a/web-admin/scripts/quota-cert-form.test.ts +++ b/web-admin/scripts/quota-cert-form.test.ts @@ -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() + } +}) diff --git a/web-admin/src/views/pl/quota-cert/index.vue b/web-admin/src/views/pl/quota-cert/index.vue index 0b7493ef4..30e86d44d 100644 --- a/web-admin/src/views/pl/quota-cert/index.vue +++ b/web-admin/src/views/pl/quota-cert/index.vue @@ -105,7 +105,7 @@ -
- - - 手工录入(可后补图片)图片解析后核对 - - - +
+ + + 手工录入图片解析后核对 - 购买证 - 运输证 - - -
-
- - - - -
-

证面信息

- - - + 购买证 + 运输证 +
-
- -

证面引用

- 添加引用 -

已有用证关联,不能编辑或移除证面引用,请联系管理员。

- - - - - - -
-
-

绑定合同产品

-

- 当前仅展示可见合同,绑定范围不可编辑;证面信息仍可保存。 -

- 添加合同 - - -