diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index 53decb7..b52b755 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -564,6 +564,8 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除 await t.test(contract.relativePath, async () => { const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8') const permissionPrefix = `master:${resource}` + const suffix = resource[0].toUpperCase() + resource.slice(1) + const idKey = `${resource}Id` const importedModules = [...source.matchAll(/from\s*['"]@\/api\/master\/([a-z]+)['"]/g)] .map((match) => match[1]) @@ -608,17 +610,62 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除 assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/) assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/) - assert.match(source, /]*type="selection"[^>]*:selectable="isRowSelectable"/) - assert.match(source, /function isRowSelectable\(row\)\s*{\s*return row\.canRemove === true;?\s*}/) - assert.match(source, /ids\.value\s*=\s*selection\s*\.filter\(\(item\) => item\.canRemove === true\)/) + assert.match(source, /let listGeneration = 0;/) + assert.match(source, /function isCurrentListRequest\(listContext\)\s*{\s*return listContext\.generation === listGeneration;?\s*}/) + const listSource = source.slice(source.indexOf('function getList()'), source.indexOf('function reset()')) + const staleListGuard = 'if \\(!isCurrentListRequest\\(listContext\\)\\) \\{\\s*return;\\s*\\}' + assert.match(listSource, /const listContext = \{\s*generation: \+\+listGeneration,\s*query: \{ \.\.\.queryParams\.value \}\s*};/) + assert.match(listSource, new RegExp(`list${suffix}\\(listContext\\.query\\)`)) + assert.match(listSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleListGuard}`)) + assert.match(listSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleListGuard}`)) + assert.match(listSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleListGuard}`)) + + assert.match(source, /let detailGeneration = 0;/) + assert.match(source, /function isCurrentDetailRequest\(detailContext\)\s*{\s*return detailContext\.generation === detailGeneration;?\s*}/) + assert.match(source, /function invalidateDetailRequest\(\)\s*{\s*detailGeneration \+= 1;\s*detailLoading\.value = false;\s*}/) + const detailSource = source.slice(source.indexOf('function handleUpdate('), source.indexOf('function afterSubmit(')) + const staleDetailGuard = 'if \\(!isCurrentDetailRequest\\(detailContext\\)\\) \\{\\s*return;\\s*\\}' + assert.match(detailSource, new RegExp(`const detailContext = \\{\\s*generation: \\+\\+detailGeneration,\\s*resourceId: ${idKey}\\s*};`)) + assert.match(detailSource, new RegExp(`get${suffix}\\(detailContext\\.resourceId\\)`)) + assert.match(detailSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleDetailGuard}`)) + assert.match(detailSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleDetailGuard}`)) + assert.match(detailSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleDetailGuard}`)) + assert.match(source, /function handleAdd\(\)\s*{\s*invalidateDetailRequest\(\)/) + assert.match(source, /function cancel\(\)\s*{\s*if \(submitLoading\.value\) return;\s*invalidateDetailRequest\(\)/) + + const dialogTag = source.match(/]*>/)?.[0] || '' + assert.match(dialogTag, /:close-on-click-modal="!submitLoading"/) + assert.match(dialogTag, /:close-on-press-escape="!submitLoading"/) + assert.match(dialogTag, /:show-close="!submitLoading"/) + assert.match(dialogTag, /:before-close="handleDialogBeforeClose"/) + assert.match(source, /function handleDialogBeforeClose\(done\)\s*{\s*if \(submitLoading\.value\) return;[\s\S]*?invalidateDetailRequest\(\);[\s\S]*?done\(\);\s*}/) + + const selectionColumn = source.match(/]*type="selection"[^>]*\/>/)?.[0] || '' + assert.ok(selectionColumn, `${resource} 缺少选择列`) + assert.doesNotMatch(selectionColumn, /:selectable=/) + assert.doesNotMatch(source, /function isRowSelectable\(/) + assert.match(source, /const selectedIds = ref\(\[\]\)/) + assert.match(source, /const removableIds = ref\(\[\]\)/) + const selectionSource = source.slice(source.indexOf('function handleSelectionChange('), source.indexOf('function handleAdd(')) + assert.match(selectionSource, new RegExp(`selectedIds\\.value = selection\\.map\\(\\(item\\) => item\\.${idKey}\\)`)) + assert.match(selectionSource, new RegExp(`removableIds\\.value = selection\\s*\\.filter\\(\\(item\\) => item\\.canRemove === true\\)\\s*\\.map\\(\\(item\\) => item\\.${idKey}\\)`)) + assert.match(selectionSource, /single\.value = selectedIds\.value\.length !== 1/) + assert.match(selectionSource, /multiple\.value = removableIds\.value\.length === 0/) + assert.match(detailSource, new RegExp(`const ${idKey} = row\\.${idKey} \\|\\| selectedIds\\.value\\[0\\]`)) + assert.match(source, new RegExp(`const targetIds = row\\?\\.${idKey} \\? \\(row\\.canRemove === true \\? \\[row\\.${idKey}\\] : \\[\\]\\) : removableIds\\.value`)) const rowDeleteButtons = extractButtonBlocks(source, 'handleDelete') .filter((button) => button.includes('handleDelete(scope.row)')) assert.ok(rowDeleteButtons.length > 0, `${resource} 缺少行删除按钮`) for (const button of rowDeleteButtons) { assert.match(button, /v-if="scope\.row\.canRemove === true"/) } + const rowUpdateButtons = extractButtonBlocks(source, 'handleUpdate') + .filter((button) => button.includes('handleUpdate(scope.row)')) + assert.ok(rowUpdateButtons.length > 0, `${resource} 缺少行修改按钮`) + for (const button of rowUpdateButtons) { + assert.match(button, /:disabled="detailLoading"/) + } - const suffix = resource[0].toUpperCase() + resource.slice(1) assert.deepEqual(extractCallObjectKeys(source, `add${suffix}`), contract.addPayloadKeys) assert.deepEqual(extractCallObjectKeys(source, `update${suffix}`), contract.editPayloadKeys) @@ -659,10 +706,12 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除 assert.match(printHandler, /['"]load['"][\s\S]*?printWindow\.print\(\)/) assert.match(printHandler, /let closeMonitorId;/) assert.match(printHandler, /let cleanupFinished = false;/) - assert.match(printHandler, /const cleanupPrint = \(\) => \{\s*if \(cleanupFinished\) return;\s*cleanupFinished = true;/) + assert.doesNotMatch(printHandler, /if \(cleanupFinished\) return;/) + assert.match(printHandler, /const cleanupPrint = \(\) => \{[\s\S]*?window\.clearInterval\(closeMonitorId\)[\s\S]*?URL\.revokeObjectURL\(objectUrl\)[\s\S]*?if \(!cleanupFinished\) \{\s*cleanupFinished = true;\s*setPrinting\(row\.palletId, false\);\s*}/) assert.match(printHandler, /window\.clearInterval\(closeMonitorId\)/) assert.equal((printHandler.match(/URL\.revokeObjectURL\(objectUrl\)/g) || []).length, 1) assert.equal((printHandler.match(/setPrinting\(row\.palletId, false\)/g) || []).length, 1) + assert.match(printHandler, /objectUrl = URL\.createObjectURL\([^;]+\);\s*if \(cleanupFinished \|\| printWindow\.closed\) \{\s*cleanupPrint\(\);\s*return;\s*}/) assert.match(printHandler, /closeMonitorId = window\.setInterval\(\(\) => \{\s*if \(printWindow\.closed\) cleanupPrint\(\);\s*}, \d+\)/) assert.ok( printHandler.indexOf('window.setInterval(') < printHandler.indexOf('getPalletQrcode('), @@ -713,7 +762,16 @@ test('业务用户分配组件使用固定映射并允许空数组完整替换', assert.match(source, /nextTick\([\s\S]*?toggleRowSelection\(row, selectedUserIds\.value\.includes\(row\.userId\)\)/) assert.match(source, /@row-click="handleRowClick"/) assert.match(source, /const userIds = \[\.\.\.new Set\(selectedUserIds\.value\.filter\(\(userId\) => Number\.isInteger\(userId\) && userId > 0\)\)\]/) - assert.match(source, /assignUsers\(resourceId\.value, \{ userIds }\)/) + assert.match(source, /let submitGeneration = 0;/) + assert.match(source, /function isCurrentSubmit\(submitContext\)\s*{[\s\S]*?submitContext\.generation === submitGeneration[\s\S]*?submitContext\.resourceType === resourceType\.value[\s\S]*?submitContext\.resourceId === resourceId\.value[\s\S]*?}/) + const submitSource = source.slice(source.indexOf('function submitAssignment()'), source.indexOf('defineExpose(')) + const staleSubmitGuard = 'if \\(!isCurrentSubmit\\(submitContext\\)\\) \\{\\s*return;\\s*\\}' + assert.match(submitSource, /const submitContext = \{\s*generation: \+\+submitGeneration,\s*resourceType: resourceType\.value,\s*resourceId: resourceId\.value\s*};/) + assert.match(submitSource, /resourceApiMap\[submitContext\.resourceType\]\.assignUsers\(submitContext\.resourceId, \{ userIds }\)/) + assert.match(submitSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleSubmitGuard}`)) + assert.match(submitSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleSubmitGuard}`)) + assert.match(submitSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleSubmitGuard}`)) + assert.match(resetDialogSource, /submitGeneration \+= 1;/) assert.doesNotMatch(source, /请选择要分配的用户|userIds\.length\s*===?\s*0/) assert.match(source, /:loading="submitLoading"/) assert.match(source, /const loadFailed = ref\(false\)/) @@ -726,6 +784,13 @@ test('业务用户分配组件使用固定映射并允许空数组完整替换', assert.deepEqual(submitPermissions, ['master:site:assign', 'master:warehouse:assign']) assert.match(source, /proxy\.\$modal\.msgSuccess\(response\.msg \|\| ['"]分配成功['"]\)/) assert.match(source, /visible\.value = false[\s\S]*?emit\(['"]ok['"]\)/) + const dialogTag = source.match(/]*>/)?.[0] || '' + assert.match(dialogTag, /:close-on-click-modal="!submitLoading"/) + assert.match(dialogTag, /:close-on-press-escape="!submitLoading"/) + assert.match(dialogTag, /:show-close="!submitLoading"/) + assert.match(dialogTag, /:before-close="handleDialogBeforeClose"/) + assert.match(source, /function handleDialogBeforeClose\(done\)\s*{\s*if \(submitLoading\.value\) return;[\s\S]*?resetDialogState\(\);[\s\S]*?done\(\);\s*}/) + assert.match(source, /function closeDialog\(\)\s*{\s*if \(submitLoading\.value\) return;/) assert.match(source, /]*v-if="loadError"/) assert.match(source, /width="min\(720px, 92vw\)"/) }) diff --git a/shierfeng-fastapi-frontend/src/components/BusinessUserAssignDialog/index.vue b/shierfeng-fastapi-frontend/src/components/BusinessUserAssignDialog/index.vue index 265182f..7514f57 100644 --- a/shierfeng-fastapi-frontend/src/components/BusinessUserAssignDialog/index.vue +++ b/shierfeng-fastapi-frontend/src/components/BusinessUserAssignDialog/index.vue @@ -6,6 +6,10 @@ top="6vh" append-to-body destroy-on-close + :close-on-click-modal="!submitLoading" + :close-on-press-escape="!submitLoading" + :show-close="!submitLoading" + :before-close="handleDialogBeforeClose" @closed="resetDialogState" > @@ -57,7 +61,7 @@