diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index a828cbe..26d93c4 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -126,6 +126,51 @@ const expectedReportExports = { 'src/api/report/palletTrace.js': ['listPalletTrace'] } +const masterPageContracts = { + strain: { + relativePath: 'src/views/master/strain/index.vue', + apiNames: ['addStrain', 'delStrain', 'getStrain', 'listStrain', 'updateStrain'], + permissions: ['add', 'edit', 'list', 'query', 'remove'], + addPayloadKeys: ['remark', 'status', 'strainCode', 'strainName'], + editPayloadKeys: ['remark', 'status', 'strainCode', 'strainId', 'strainName'] + }, + customer: { + relativePath: 'src/views/master/customer/index.vue', + apiNames: ['addCustomer', 'delCustomer', 'getCustomer', 'listCustomer', 'updateCustomer'], + permissions: ['add', 'edit', 'list', 'query', 'remove'], + addPayloadKeys: ['customerCode', 'customerName', 'remark', 'status'], + editPayloadKeys: ['customerCode', 'customerId', 'customerName', 'remark', 'status'] + }, + site: { + relativePath: 'src/views/master/site/index.vue', + apiNames: ['addSite', 'delSite', 'getSite', 'listSite', 'updateSite'], + permissions: ['add', 'assign', 'edit', 'list', 'query', 'remove'], + addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'], + editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status'] + }, + line: { + relativePath: 'src/views/master/line/index.vue', + apiNames: ['addLine', 'delLine', 'getLine', 'listLine', 'updateLine'], + permissions: ['add', 'edit', 'list', 'query', 'remove'], + addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'], + editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status'] + }, + warehouse: { + relativePath: 'src/views/master/warehouse/index.vue', + apiNames: ['addWarehouse', 'delWarehouse', 'getWarehouse', 'listWarehouse', 'updateWarehouse'], + permissions: ['add', 'assign', 'edit', 'list', 'query', 'remove'], + addPayloadKeys: ['address', 'remark', 'status', 'warehouseCode', 'warehouseName'], + editPayloadKeys: ['address', 'remark', 'status', 'warehouseCode', 'warehouseId', 'warehouseName'] + }, + pallet: { + relativePath: 'src/views/master/pallet/index.vue', + apiNames: ['addPallet', 'delPallet', 'getPallet', 'getPalletQrcode', 'listPallet', 'updatePallet'], + permissions: ['add', 'edit', 'list', 'print', 'query', 'remove'], + addPayloadKeys: ['capacityPackages', 'palletCode', 'remark'], + editPayloadKeys: ['assetStatus', 'capacityPackages', 'palletId', 'remark'] + } +} + const delimiterPairs = { '(': ')', '[': ']', '{': '}' } function sanitizeSource(source) { @@ -301,6 +346,44 @@ function extractRequests(source) { return requests } +function extractImportedNames(source, modulePath) { + const escapedPath = modulePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const importMatch = new RegExp(`import\\s*{([^}]*)}\\s*from\\s*['"]${escapedPath}['"]`).exec(source) + assert.ok(importMatch, `缺少 ${modulePath} 的具名导入`) + return importMatch[1] + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + .sort() +} + +function extractCallObjectKeys(source, callName) { + const escapedCallName = callName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const callMatch = new RegExp(`(? property.trim()).filter(Boolean) + return properties.map((property) => { + const keyMatch = /^([A-Za-z_$][\w$]*)\s*:/.exec(property) + assert.ok(keyMatch, `${callName} payload 禁止展开、简写或动态属性`) + return keyMatch[1] + }).sort() +} + +function extractButtonBlocks(source, clickHandler) { + return [...source.matchAll(//g)] + .map((match) => match[0]) + .filter((button) => button.includes(`@click="${clickHandler}`)) +} + +function assertButtonsUsePermission(source, clickHandler, permission) { + const buttons = extractButtonBlocks(source, clickHandler) + assert.ok(buttons.length > 0, `缺少 ${clickHandler} 按钮`) + for (const button of buttons) { + assert.match(button, new RegExp(`v-hasPermi="\\['${permission}'\\]"`), `${clickHandler} 权限不精确`) + } +} + function hasExpectedRequest(expectedRequest) { return Object.values(expectedRequests).some((requests) => requests.some((request) => @@ -475,3 +558,150 @@ test('扫码组件声明交互契约并保持触控尺寸', async () => { assert.match(source, /focus-within/) assert.doesNotMatch(source, /BarcodeDetector|camera|getUserMedia/i) }) + +test('六个基础资料页面逐页满足 API、权限、分页和安全删除契约', async (t) => { + for (const [resource, contract] of Object.entries(masterPageContracts)) { + await t.test(contract.relativePath, async () => { + const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8') + const permissionPrefix = `master:${resource}` + const importedModules = [...source.matchAll(/from\s*['"]@\/api\/master\/([a-z]+)['"]/g)] + .map((match) => match[1]) + + assert.deepEqual(importedModules, [resource], `${resource} 页面不得跨调其他主数据 API`) + assert.deepEqual(extractImportedNames(source, `@/api/master/${resource}`), contract.apiNames) + + const actualPermissions = [...new Set( + [...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))] + .map((match) => match[1].slice(permissionPrefix.length + 1)) + )].sort() + assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`) + + assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`) + assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`) + assertButtonsUsePermission(source, 'handleAdd', `${permissionPrefix}:add`) + assertButtonsUsePermission(source, 'submitCreate', `${permissionPrefix}:add`) + assertButtonsUsePermission(source, 'handleUpdate', `${permissionPrefix}:edit`) + assertButtonsUsePermission(source, 'submitUpdate', `${permissionPrefix}:edit`) + assertButtonsUsePermission(source, 'handleDelete', `${permissionPrefix}:remove`) + + for (const button of extractButtonBlocks(source, 'handleUpdate')) { + assert.match( + button, + new RegExp(`v-if="proxy\\.\\$auth\\.hasPermiAnd\\(\\['${permissionPrefix}:query', '${permissionPrefix}:edit'\\]\\)"`), + `${resource} 修改按钮必须同时校验 query 与 edit` + ) + } + + assert.match(source, /proxy\.useDict\(['"]sys_normal_disable['"]\)/) + assert.match(source, /]*v-loading="loading"[^>]*empty-text="暂无数据"/) + assert.match(source, /]*v-if="loadError"/) + assert.match(source, /]*v-if="formError"/) + assert.match(source, /const formError = ref\(['"]['"]\)/) + assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`) + 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\)/) + 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 suffix = resource[0].toUpperCase() + resource.slice(1) + assert.deepEqual(extractCallObjectKeys(source, `add${suffix}`), contract.addPayloadKeys) + assert.deepEqual(extractCallObjectKeys(source, `update${suffix}`), contract.editPayloadKeys) + + assert.match(source, /validator:\s*validateRequiredText/) + assert.match(source, /maxlength="500"/) + if (resource !== 'pallet') { + assert.match(source, /maxlength="64"/) + assert.match(source, /maxlength="100"/) + } + if (resource === 'site' || resource === 'line' || resource === 'pallet') { + assert.match(source, /validator:\s*validatePositiveInteger/) + } + + if (resource === 'site' || resource === 'warehouse') { + assertButtonsUsePermission(source, 'handleAssign', `${permissionPrefix}:assign`) + assert.match(source, /import BusinessUserAssignDialog from ['"]@\/components\/BusinessUserAssignDialog['"]/) + assert.match(source, //) + assert.match( + source, + new RegExp(`assignDialogRef\\.value\\.show\\(\\{[\\s\\S]*?resourceType: ['"]${resource}['"][\\s\\S]*?resourceId: row\\.${resource}Id[\\s\\S]*?resourceName: row\\.${resource}Name[\\s\\S]*?}\\)`) + ) + } + + if (resource === 'pallet') { + assertButtonsUsePermission(source, 'handlePrint', 'master:pallet:print') + const printHandler = source.slice(source.indexOf('function handlePrint')) + assert.match(printHandler, /const printWindow = window\.open\('', '_blank'\)/) + assert.ok( + printHandler.indexOf("window.open('', '_blank')") < printHandler.indexOf('getPalletQrcode('), + '打印窗口必须在异步二维码请求前同步打开' + ) + assert.match(printHandler, /getPalletQrcode\(row\.palletId\)/) + assert.match(printHandler, /instanceof Blob/) + assert.match(printHandler, /URL\.createObjectURL\(/) + assert.match(printHandler, /URL\.revokeObjectURL\(/) + assert.match(printHandler, /document\.createElement\(['"](?:h1|img|style)['"]\)/) + assert.match(printHandler, /\.textContent\s*=/) + assert.match(printHandler, /['"]load['"][\s\S]*?printWindow\.print\(\)/) + assert.match(printHandler, /['"]afterprint['"][\s\S]*?URL\.revokeObjectURL/) + assert.doesNotMatch(printHandler, /document\.write|innerHTML|BarcodeDetector|getUserMedia|camera/i) + assert.match(source, /v-if="isEdit"[\s\S]*?v-model="form\.palletCode"[\s\S]*?readonly/) + assert.match(source, /v-if="isEdit"[\s\S]*?v-model="form\.qrToken"[\s\S]*?readonly/) + assert.doesNotMatch(contract.editPayloadKeys.join(','), /palletCode|qrToken/) + } + }) + } +}) + +test('业务用户分配组件使用固定映射并允许空数组完整替换', async () => { + const relativePath = 'src/components/BusinessUserAssignDialog/index.vue' + const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') + + assert.deepEqual( + extractImportedNames(source, '@/api/master/site'), + ['assignSiteUsers', 'getSiteUsers'] + ) + assert.deepEqual( + extractImportedNames(source, '@/api/master/warehouse'), + ['assignWarehouseUsers', 'getWarehouseUsers'] + ) + assert.match(source, /const resourceApiMap = Object\.freeze\(\{[\s\S]*?site:\s*Object\.freeze\(\{\s*getUsers:\s*getSiteUsers,\s*assignUsers:\s*assignSiteUsers\s*}\)[\s\S]*?warehouse:\s*Object\.freeze\(\{\s*getUsers:\s*getWarehouseUsers,\s*assignUsers:\s*assignWarehouseUsers\s*}\)[\s\S]*?}\)/) + assert.doesNotMatch(source, /defineProps\(/) + assert.match(source, /function show\(\{ resourceType, resourceId, resourceName }\)/) + assert.match(source, /response\.data/) + assert.match(source, /userId:\s*row\.userId,\s*userName:\s*row\.userName,\s*nickName:\s*row\.nickName,\s*assigned:\s*row\.assigned === true/) + assert.doesNotMatch(source, /\.\.\.row|email:\s*row\.|phonenumber:\s*row\.|status:\s*row\./) + assert.match(source, /filteredUsers\s*=\s*computed\([\s\S]*?user\.userName[\s\S]*?user\.nickName/) + 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.doesNotMatch(source, /请选择要分配的用户|userIds\.length\s*===?\s*0/) + assert.match(source, /:loading="submitLoading"/) + assert.match(source, /const loadFailed = ref\(false\)/) + assert.match(source, /:disabled="loading \|\| submitLoading \|\| loadFailed"/) + assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, '分配组件禁止空 catch') + const submitButtons = extractButtonBlocks(source, 'submitAssignment') + const submitPermissions = submitButtons.flatMap((button) => + [...button.matchAll(/v-hasPermi="\['(master:(?:site|warehouse):assign)'\]"/g)].map((match) => match[1]) + ).sort() + 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['"]\)/) + 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 new file mode 100644 index 0000000..8c4bc46 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/BusinessUserAssignDialog/index.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/views/master/customer/index.vue b/shierfeng-fastapi-frontend/src/views/master/customer/index.vue new file mode 100644 index 0000000..bc9d62f --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/master/customer/index.vue @@ -0,0 +1,230 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/master/line/index.vue b/shierfeng-fastapi-frontend/src/views/master/line/index.vue new file mode 100644 index 0000000..2a3b0fe --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/master/line/index.vue @@ -0,0 +1,246 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/master/pallet/index.vue b/shierfeng-fastapi-frontend/src/views/master/pallet/index.vue new file mode 100644 index 0000000..247507b --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/master/pallet/index.vue @@ -0,0 +1,309 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/master/site/index.vue b/shierfeng-fastapi-frontend/src/views/master/site/index.vue new file mode 100644 index 0000000..dc23998 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/master/site/index.vue @@ -0,0 +1,270 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/master/strain/index.vue b/shierfeng-fastapi-frontend/src/views/master/strain/index.vue new file mode 100644 index 0000000..d7c9131 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/master/strain/index.vue @@ -0,0 +1,292 @@ + + + diff --git a/shierfeng-fastapi-frontend/src/views/master/warehouse/index.vue b/shierfeng-fastapi-frontend/src/views/master/warehouse/index.vue new file mode 100644 index 0000000..f813a0a --- /dev/null +++ b/shierfeng-fastapi-frontend/src/views/master/warehouse/index.vue @@ -0,0 +1,252 @@ + + +