From eb12c755d34b3d5a319fdb1c3adf0b8bcabbca93 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sun, 1 Nov 2026 23:21:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(upload):=20=E5=93=8D=E5=BA=94=E5=BC=8F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=8A=E4=BC=A0=E9=A1=B9=E4=BB=A5=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=A4=96=E9=83=A8=E4=BF=9D=E5=AD=98=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment-upload-reactivity.test.ts | 118 ++++++++++++++++++ .../attachment-upload-field/index.vue | 4 +- 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 web-admin/scripts/attachment-upload-reactivity.test.ts diff --git a/web-admin/scripts/attachment-upload-reactivity.test.ts b/web-admin/scripts/attachment-upload-reactivity.test.ts new file mode 100644 index 000000000..082120f12 --- /dev/null +++ b/web-admin/scripts/attachment-upload-reactivity.test.ts @@ -0,0 +1,118 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { parse, compileScript } from '@vue/compiler-sfc' +import ts from 'typescript' +import * as vue from 'vue' +function compiledSfc(path: string, dependencies: Record, inlineTemplate = true) { + const descriptor = parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor + const compiled = compileScript(descriptor, { id: path, inlineTemplate }) + const source = ts.createSourceFile('compiled.ts', compiled.content, ts.ScriptTarget.Latest, true) + const env: Record = Object.fromEntries( + Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') + ) + Object.assign(env, dependencies) + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue + const clause = statement.importClause + if (clause?.name) env[clause.name.text] = dependencies[clause.name.text] || {} + if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const item of clause.namedBindings.elements) { + if (item.isTypeOnly) continue + const name = (item.propertyName || item.name).text + env[item.name.text] = + statement.moduleSpecifier.getText(source) === '"vue"' || + statement.moduleSpecifier.getText(source) === "'vue'" + ? (vue as any)[name] + : dependencies[name] + } + } + } + const code = ts.transpile( + source.statements + .filter((s) => !ts.isImportDeclaration(s)) + .map((s) => s.getText(source)) + .join('\n') + .replace('export default', 'const component ='), + { target: ts.ScriptTarget.ESNext } + ) + return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env)) +} + +for (const outcome of ['success', 'failure'] as const) + test(`real attachment ${outcome} releases reactive external save guard`, async () => { + let resolve!: (value: any) => void, reject!: (reason: any) => void + const pending = new Promise((a, b) => { + resolve = a + reject = b + }) + const events: string[] = [] + const browser = { addEventListener() {}, removeEventListener() {} } + const Attachment = compiledSfc( + '../src/components/business/attachment-upload-field/index.vue', + { + useRoute: () => ({ path: '/pl/legal-use-document' }), + isMpWebviewPath: () => false, + fetchUploadAttachment: () => pending, + ElMessage: { warning: (v: string) => events.push(v), error: (v: string) => events.push(v) }, + document: browser, + window: browser + }, + false + ) + Attachment.render = () => null + const upload = vue.ref(), + model = vue.ref('') + // An external footer/computed consumes the exposed guard independently of v-model. + const savingDisabled = vue.computed(() => !!upload.value?.hasUploading()) + const buttons: any[] = [] + const renderer = vue.createRenderer({ + createElement: (tag: string) => { + const el: any = { tag } + if (tag === 'button') buttons.push(el) + return el + }, + createText: () => ({}), + createComment: () => ({}), + setText() {}, + setElementText() {}, + parentNode: () => null, + nextSibling: () => null, + insert() {}, + remove() {}, + patchProp: (el: any, key: string, _prev: any, value: any) => { + el[key] = value + } + }) + const app = renderer.createApp({ + render: () => + vue.h('section', [ + vue.h(Attachment, { + ref: upload, + modelValue: model.value, + 'onUpdate:modelValue': (value: string) => { + model.value = value + } + }), + vue.h('button', { disabled: savingDisabled.value }, '保存本段资料') + ]) + }) + app.mount({}) + await vue.nextTick() + assert.equal(buttons[0].disabled, false) + const operation = upload.value.$.setupState.handleAttachmentChange({ + raw: new File(['synthetic'], 'proof.pdf', { type: 'application/pdf' }) + }) + await vue.nextTick() + assert.equal(buttons[0].disabled, true) + assert.equal(upload.value.hasUploading(), true) + if (outcome === 'success') + resolve({ fileUrl: 'legal/proof.pdf', fileName: 'proof.pdf', fileType: 'application/pdf' }) + else reject(Error('上传失败')) + await operation + await vue.nextTick() + assert.equal(upload.value.hasUploading(), false) + assert.equal(buttons[0].disabled, false, '上传结束必须通知外部保存按钮更新') + assert.equal(model.value, outcome === 'success' ? 'legal/proof.pdf' : '') + app.unmount() + }) 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 fade9a0bf..11331b8e8 100644 --- a/web-admin/src/components/business/attachment-upload-field/index.vue +++ b/web-admin/src/components/business/attachment-upload-field/index.vue @@ -535,13 +535,13 @@ async function uploadRawFile(rawFile: File) { return false } - const item: AttachmentItem = { + const item = reactive({ id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, fileName: rawFile.name, fileUrl: '', fileType: rawFile.type, _uploading: true - } + }) attachments.value.push(item) const formData = props.bizType