fix(pl): 调整证件建档弹窗布局并按所选类型解析单个附件。

This commit is contained in:
wuzilong committed 2026-11-01 20:05:06 +08:00
1 parent c16681a16f
commit 4dbd48d827
4 files changed
+478 -32

No files matched your search

@@ -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`。
+266
View File
@@ -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<any>((r) => {
resolve = r
})
return { promise, resolve }
}
function harness() {
const reads: string[] = [],
writes: any[] = [],
parses: any[] = [],
messages: string[] = []
let confirmSwitch = true
let pendingOptions: ReturnType<typeof deferred> | 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()
}
}
})
@@ -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"
>
+195 -30
View File
@@ -104,10 +104,10 @@
</section>
<el-dialog
v-model="formVisible"
:title="`${editingId ? '编辑' : '新增'}${kind === 'purchase' ? '购买证' : '运输证'}`"
width="960px"
:title="editingId ? `编辑${formKind === 'purchase' ? '购买证' : '运输证'}` : '新增证件'"
width="1200px"
align-center
class="dialog-form-layout"
class="dialog-form-layout quota-cert-form-dialog"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
@@ -117,47 +117,77 @@
ref="formRef"
:model="form"
:rules="rules"
:validate-on-rule-change="false"
label-position="top"
scroll-to-error
>
<section class="view-section">
<el-form-item label="建档方式"
><el-radio-group v-model="form.entryMode" :disabled="!!editingId"
<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 label="证件附件"
><AttachmentUploadField
<el-form-item>
<template #label><span class="dialog-section-title">证件类型</span></template>
<el-radio-group
v-model="selectedFormKind"
:disabled="!!editingId"
@change="changeFormKind"
>
<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"
: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">
<el-select v-model="recognitionImage" placeholder="选择一张已上传的证件图片"
><el-option
v-for="url in (form.attachmentUrls || '').split(',').filter(Boolean)"
:key="url"
:value="url"
:label="url.split('/').pop()"
/></el-select>
<el-button :loading="recognizing" :disabled="!recognitionImage" @click="recognize"
><el-icon><Document /></el-icon>解析为候选字段</el-button
>
<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
>
</div>
<p class="form-tip"
>请核对证号、数量、单位、有效次数和引用关系。解析只补充未编辑的空字段。</p
>
</template>
</section>
<section class="view-section"
><h3>证面信息</h3
><div class="form-grid-2">
><h3 class="dialog-section-title">证面信息</h3
><div class="form-grid-3">
<el-form-item
v-for="field in fields"
v-for="field in formFields"
:key="field[0]"
:label="field[1]"
:prop="field[0]"
@@ -179,7 +209,7 @@
/>
</el-form-item> </div
></section>
<section v-if="kind === 'transport'" class="view-section">
<section v-if="formKind === 'transport'" class="view-section">
<el-form-item label="车牌号(多车共用额度)"
><el-select
v-model="form.vehicleNumbersJson"
@@ -189,7 +219,7 @@
default-first-option
@change="edited.add('vehicleNumbersJson')"
/></el-form-item>
<h3>证面引用</h3>
<h3 class="dialog-section-title">证面引用</h3>
<el-table :data="form.purchaseRefs" row-key="id" stripe>
<el-table-column label="引用类型" min-width="160"
><template #default="{ row }"
@@ -397,6 +427,8 @@
} from './model'
const route = useRoute()
const kind = ref<CertType>('purchase')
const formKind = ref<CertType>('purchase')
const selectedFormKind = ref<CertType>('purchase')
const keyword = ref(''),
page = ref(1)
const rows = ref<QuotaCert[]>([]),
@@ -430,6 +462,24 @@
...commonFields,
...(kind.value === 'purchase' ? purchaseFields : transportFields)
])
const formFields = computed(() => [
...commonFields,
...(formKind.value === 'purchase' ? purchaseFields : transportFields)
])
const attachmentOptions = computed<string[]>(() =>
(form.value.attachmentUrls || '')
.split(',')
.map((url: string) => url.trim())
.filter(Boolean)
)
watch(
attachmentOptions,
(urls) => {
if (urls.length === 1) recognitionImage.value = urls[0]
else if (!urls.includes(recognitionImage.value)) recognitionImage.value = ''
},
{ flush: 'sync' }
)
const statusLabels = { draft: '待核对', active: '已启用', suspended: '已停用', void: '已作废' }
const refLabels = {
@@ -455,7 +505,7 @@
'totalQty',
'quantityUnit',
'validUseText',
...(kind.value === 'purchase' ? ['buyerName', 'sellerName'] : ['shipperName'])
...(formKind.value === 'purchase' ? ['buyerName', 'sellerName'] : ['shipperName'])
].map((k) => [k, [{ required: true, message: '请填写此项', trigger: 'blur' }]])
)
)
@@ -507,6 +557,8 @@
const options = targetKind === 'transport' ? await quotaCertApi.list('purchase') : []
if (session !== editSession || kind.value !== targetKind) return
editingId.value = row?.id || ''
formKind.value = targetKind
selectedFormKind.value = targetKind
edited.clear()
recognitionImage.value = ''
form.value = value
@@ -514,6 +566,54 @@
recognizing.value = false
formVisible.value = true
}
async function changeFormKind(value: string | number | boolean | undefined) {
try {
if (
(value !== 'purchase' && value !== 'transport') ||
value === formKind.value ||
editingId.value ||
saving.value
)
return
if (certAttachments.value?.hasUploading()) {
ElMessage.warning('请等待附件上传完成')
return
}
const session = editSession
const hasContent =
formFields.value.some(([key]) => form.value[key] != null && form.value[key] !== '') ||
form.value.purchaseRefs?.length ||
form.value.vehicleNumbersJson?.length
if (hasContent) {
try {
await ElMessageBox.confirm(
'切换证件类型将清空证面信息,保留建档方式、附件和系统备注。是否继续?',
'切换证件类型',
{ type: 'warning' }
)
} catch {
return
}
}
if (session !== editSession || !formVisible.value || saving.value) return
const nextSession = ++editSession
recognizing.value = false
const { entryMode, attachmentUrls, remark, orderNum } = form.value
formKind.value = value
selectedFormKind.value = value
form.value = { ...newCert(value), entryMode, attachmentUrls, remark, orderNum }
purchases.value = []
edited.clear()
formRef.value?.clearValidate()
if (value === 'transport') {
const options = await quotaCertApi.list('purchase')
if (nextSession === editSession && formVisible.value && formKind.value === value)
purchases.value = options
}
} finally {
selectedFormKind.value = formKind.value
}
}
async function save() {
if (saving.value) return
if (certAttachments.value?.hasUploading()) {
@@ -521,6 +621,7 @@
return
}
const session = editSession
const targetKind = formKind.value
saving.value = true
if (!(await formRef.value.validate().catch(() => false)) || session !== editSession) {
saving.value = false
@@ -528,21 +629,34 @@
}
try {
await quotaCertApi.save(
kind.value,
certPayload(kind.value, form.value),
targetKind,
certPayload(targetKind, form.value),
editingId.value || undefined
)
formVisible.value = false
ElMessage.success('证件已保存')
await load()
if (kind.value !== targetKind) kind.value = targetKind
else await load()
} finally {
saving.value = false
}
}
async function recognize() {
if (recognizing.value || saving.value) return
if (certAttachments.value?.hasUploading()) {
ElMessage.warning('请等待附件上传完成')
return
}
if (
!recognitionImage.value ||
!attachmentOptions.value.includes(recognitionImage.value) ||
(!editingId.value && attachmentOptions.value.length !== 1)
) {
ElMessage.warning('请选择一个已上传的证件附件')
return
}
const session = editSession,
targetKind = kind.value,
targetKind = formKind.value,
targetImage = recognitionImage.value
recognizing.value = true
try {
@@ -550,12 +664,15 @@
if (
session !== editSession ||
!formVisible.value ||
kind.value !== targetKind ||
formKind.value !== targetKind ||
recognitionImage.value !== targetImage ||
!(form.value.attachmentUrls || '').split(',').includes(targetImage)
)
return
mergeCandidates(form.value as any, result.candidates, edited)
await nextTick()
if (session !== editSession || !formVisible.value) return
formRef.value?.clearValidate()
ElMessage.success(
Object.keys(result.invalidFields).length
? '部分字段未识别,请手工核对补全'
@@ -727,3 +844,51 @@
tableRef
})
</script>
<style lang="scss">
.quota-cert-form-dialog {
max-width: calc(100vw - 40px);
--dialog-viewport-inset: 50px;
--dialog-max-height: calc(100vh - 100px);
.view-section {
padding: 0 0 20px;
}
.dialog-section-title {
margin: 0 0 14px;
padding-left: 8px;
border-left: 3px solid var(--section-bar-border-color);
color: var(--section-bar-title-color);
font-size: 14px;
font-weight: 600;
}
span.dialog-section-title {
margin-bottom: 0;
}
.form-grid-2,
.form-grid-3 {
.el-form-item {
min-width: 0;
margin-bottom: 0;
}
}
.el-date-editor.el-input {
width: 100%;
}
.recognition-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
.el-select {
width: 320px;
max-width: 100%;
}
}
}
</style>