diff --git a/docs/superpowers/plans/2026-11-01-pl-quota-list-consistency.md b/docs/superpowers/plans/2026-11-01-pl-quota-list-consistency.md index db99a085e..56337a474 100644 --- a/docs/superpowers/plans/2026-11-01-pl-quota-list-consistency.md +++ b/docs/superpowers/plans/2026-11-01-pl-quota-list-consistency.md @@ -35,3 +35,18 @@ Files: `web-admin/src/views/pl/{plan-quota,quota-cert,legal-use-document}/index. - 最新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供用户测试的方式,不推送远端、不关闭工单。保留任务分支与工作区用于反馈修复。 + +## 用户转测补充:新增证件弹窗(2026-11-01) + +用户明确要求加宽、每行三个字段、单附件解析、弹窗内单选购买证/运输证并与建档方式同行、区块标题增加绿色竖条。沿用本任务已确认的Web范围及直接修复方式。 + +视觉依据:Fresh Mint §9.3.1、全局 `form-grid-3` 与运输计划的区块标题;保留 `AttachmentUploadField`。宽度1200px且限制在视口内,窄屏按全局栅格降列。建档方式与证件类型同行,证面信息每行三项,附件及备注整行。绿色竖条使用现有 section-bar Token。 + +- [x] 在 `web-admin/scripts/quota-cert-form.test.ts` 验证真实SFC setup:弹窗类型独立于列表、类型切换安全、单附件自动选择、解析/保存使用所选类型、失效响应不回填;先运行观察失败。 +- [x] 修改 `web-admin/src/views/pl/quota-cert/index.vue`:新增 `formKind` 驱动表单字段/校验/解析/保存;新增上传限制1个,已有档案保留多附件;切换有内容的类型前提示,保留建档方式/附件/备注,避免错证回填。 +- [x] 附件组件已支持 `limit` 并覆盖上传/拖拽/粘贴路径,只将文件选择器的 multiple 与 limit 关联,不改全局默认15个。 +- [x] 执行 Node 专项、Web构建、合成API的实际弹窗交互与截图;审查后延续本地V3.0合入,不推送/关闭工单。无需数据库修改。 + +补充验证:新增7项真实setup回归(先失败后通过),连同列表、移动状态、附件预览/分类及证件model共56项通过。真实组件Playwright验证1200px宽度、建档方式/证件类型同行、3px绿色标题竖条,1440/1100/780/500宽度分别3/3/2/1列;单选文件、重复上传拦截(仅1次上传请求)、transport解析及保存请求、取消类型切换恢复选中项均通过,页面错误0。全程合成API,未连接真实上传或模型服务;截图位于忽略目录 `web-admin/.local/cert-dialog/`。 + +浏览器检查修正了弹窗根节点scoped样式失效、异步取消类型切换时原生radio选中态未恢复、规则变化提前显示必填错误;日期框已撑满列宽,弹窗高度遵守现有viewport inset变量。最终构建52.97秒通过;vue-tsc仍有历史499项错误,归一化行号比较新增0项。日志 `/tmp/shanxia-cert-dialog-{tests,build,typecheck,browser}.log`。 diff --git a/web-admin/scripts/quota-cert-form.test.ts b/web-admin/scripts/quota-cert-form.test.ts new file mode 100644 index 000000000..b9e8f75b2 --- /dev/null +++ b/web-admin/scripts/quota-cert-form.test.ts @@ -0,0 +1,266 @@ +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 * as model from '../src/views/pl/quota-cert/model' +import { useClientColumnFilters } from '../src/composables/useClientColumnFilters' +import { usePersistedListColumns } from '../src/composables/usePersistedListColumns' + +function deferred() { + let resolve!: (value: any) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} +function harness() { + const reads: string[] = [], + writes: any[] = [], + parses: any[] = [], + messages: string[] = [] + let confirmSwitch = true + let pendingOptions: ReturnType | undefined + const env = { + ...Object.fromEntries( + Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') + ), + ...model, + useClientColumnFilters, + usePersistedListColumns, + useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), + useRoute: () => ({ query: {} }), + useUserStore: () => ({ accessToken: 'synthetic', getUserInfo: {} }), + onMounted: () => {}, + onBeforeUnmount: () => {}, + fetchGetMenuCodes: async () => ({ all: true }), + ElMessage: { + warning: (s: string) => messages.push(s), + success: (s: string) => messages.push(s) + }, + ElMessageBox: { + confirm: async () => { + if (!confirmSwitch) throw new Error('cancel') + } + }, + quotaCertApi: { + list: async (kind: string) => { + reads.push(kind) + return pendingOptions ? pendingOptions.promise : [] + }, + get: async (kind: string, id: string) => ({ + ...model.newCert(kind as any), + id, + certNo: 'EXISTING', + attachmentUrls: 'a.png,b.png' + }), + save: async (kind: string, body: any, id?: string) => { + writes.push({ kind, body, id }) + }, + recognize: (kind: string, url: string) => { + const pending = deferred() + parses.push({ kind, url, ...pending }) + return pending.promise + } + } + } + const script = parse( + readFileSync(new URL('../src/views/pl/quota-cert/index.vue', import.meta.url), 'utf8') + ).descriptor.scriptSetup!.content + const source = ts.createSourceFile('page.ts', script, ts.ScriptTarget.Latest, true) + const statements = source.statements.filter((s) => !ts.isImportDeclaration(s)) + const names = statements.flatMap((s) => + ts.isVariableStatement(s) + ? s.declarationList.declarations + .filter((d) => ts.isIdentifier(d.name)) + .map((d) => d.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)) + ) + state.formRef.value = { validate: async () => true, clearValidate: () => {} } + return { + state, + reads, + writes, + parses, + messages, + deferOptions: () => { + pendingOptions = deferred() + return pendingOptions + }, + cancelSwitch: () => { + confirmSwitch = false + }, + stop: () => scope.stop() + } +} + +test('type changes immediately while options load and stale options cannot enter a later form', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + const options = h.deferOptions() + const switching = s.changeFormKind('transport') + assert.equal(s.formKind.value, 'transport') + await s.changeFormKind('purchase') + options.resolve([{ id: 'old-option' }]) + await switching + assert.equal(s.formKind.value, 'purchase') + assert.deepEqual(s.purchases.value, []) + } finally { + h.stop() + } +}) + +test('new form type changes without closing dialog or changing the list, and drives save', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + assert.equal(s.formKind?.value, 'purchase') + await s.changeFormKind('transport') + assert.equal(s.formKind.value, 'transport') + assert.equal(s.kind.value, 'purchase') + assert.equal(s.formVisible.value, true) + assert.ok(s.formFields.value.some(([key]: string[]) => key === 'shipperName')) + assert.ok(!s.formFields.value.some(([key]: string[]) => key === 'buyerName')) + s.form.value.shipperName = '合成托运单位' + await s.save() + assert.equal(h.writes[0].kind, 'transport') + assert.equal(h.writes[0].body.shipperName, '合成托运单位') + assert.equal(h.writes[0].body.buyerName, undefined) + assert.equal(s.kind.value, 'transport') + } finally { + h.stop() + } +}) + +test('switch clears old certificate data after confirmation, preserving attachments and mode', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + s.form.value.certNo = 'PURCHASE-1' + s.form.value.entryMode = 'image_parse' + s.form.value.attachmentUrls = 'one.png' + s.form.value.remark = '保留备注' + await s.changeFormKind('transport') + assert.equal(s.form.value.certNo, '') + assert.equal(s.form.value.attachmentUrls, 'one.png') + assert.equal(s.form.value.entryMode, 'image_parse') + assert.equal(s.form.value.remark, '保留备注') + assert.equal(s.form.value.buyerName, undefined) + } finally { + h.stop() + } +}) + +test('cancel type switch and existing certificate edit preserve data and original type', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + s.form.value.certNo = 'KEEP' + h.cancelSwitch() + s.selectedFormKind.value = 'transport' + await s.changeFormKind('transport') + assert.equal(s.formKind.value, 'purchase') + assert.equal(s.selectedFormKind.value, 'purchase') + assert.equal(s.form.value.certNo, 'KEEP') + 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') + } finally { + h.stop() + } +}) + +test('single attachment is automatically selected, parsing uses form type and preserves manual edits', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + await s.changeFormKind('transport') + s.form.value.attachmentUrls = 'one.png' + await vue.nextTick() + assert.equal(s.recognitionImage.value, 'one.png') + s.form.value.certNo = 'MANUAL' + s.edited.add('certNo') + const parsing = s.recognize() + assert.equal(h.parses.length, 1) + assert.equal(h.parses[0].kind, 'transport') + assert.equal(h.parses[0].url, 'one.png') + h.parses[0].resolve({ + candidates: { certNo: 'OCR', shipperName: '合成托运单位' }, + invalidFields: {} + }) + await parsing + assert.equal(s.form.value.certNo, 'MANUAL') + assert.equal(s.form.value.shipperName, '合成托运单位') + } finally { + h.stop() + } +}) + +test('missing, removed, multiple new attachments and uploads in progress never start parsing', async () => { + const h = harness(), + s = h.state + try { + await s.edit() + const empty = s.recognize() + assert.equal(h.parses.length, 0) + await empty + s.form.value.attachmentUrls = 'one.png' + await vue.nextTick() + s.form.value.attachmentUrls = '' + await vue.nextTick() + assert.equal(s.recognitionImage.value, '') + await s.recognize() + s.form.value.attachmentUrls = 'one.png,two.png' + s.recognitionImage.value = 'one.png' + await s.recognize() + s.form.value.attachmentUrls = 'one.png' + s.certAttachments.value = { hasUploading: () => true } + await s.recognize() + assert.equal(h.parses.length, 0) + await s.save() + assert.equal(h.writes.length, 0) + } finally { + h.stop() + } +}) + +test('closed or switched forms reject delayed recognition results', async () => { + for (const action of ['close', 'switch']) { + const h = harness(), + s = h.state + try { + await s.edit() + s.form.value.attachmentUrls = 'one.png' + await vue.nextTick() + const parsing = s.recognize() + assert.equal(h.parses.length, 1) + if (action === 'close') { + s.formVisible.value = false + await s.edit() + } else await s.changeFormKind('transport') + h.parses[0].resolve({ candidates: { certNo: 'OLD' }, invalidFields: {} }) + await parsing + assert.equal(s.form.value.certNo, '') + assert.equal(s.recognizing.value, false) + } finally { + h.stop() + } + } +}) diff --git a/web-admin/src/components/business/attachment-upload-field/index.vue b/web-admin/src/components/business/attachment-upload-field/index.vue index 156cc382e..fade9a0bf 100644 --- a/web-admin/src/components/business/attachment-upload-field/index.vue +++ b/web-admin/src/components/business/attachment-upload-field/index.vue @@ -25,7 +25,7 @@ class="att-upload" :auto-upload="false" :show-file-list="false" - :multiple="true" + :multiple="limit > 1" :accept="accept" @change="handleAttachmentChange" > @@ -85,7 +85,7 @@ ref="uploadRef" :auto-upload="false" :show-file-list="false" - :multiple="true" + :multiple="limit > 1" :accept="accept" @change="handleAttachmentChange" > diff --git a/web-admin/src/views/pl/quota-cert/index.vue b/web-admin/src/views/pl/quota-cert/index.vue index 6108f51b4..b401d48f1 100644 --- a/web-admin/src/views/pl/quota-cert/index.vue +++ b/web-admin/src/views/pl/quota-cert/index.vue @@ -104,10 +104,10 @@ -
- + + + 手工录入(可后补图片)图片解析后核对 - + + + 购买证 + 运输证 + + +
+
+ + +

证面信息

+ >

证面信息

-
+
-

证面引用

+

证面引用