更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
@@ -30,6 +30,48 @@ test('Web 端统一使用 Sylvan 横版标识、折叠图标和浏览器图标',
assert.doesNotMatch(`${fullLogo}\n${markLogo}`, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i)
})
test('PC 登录页在版权信息旁提供开源协议外链', async () => {
const [loginPage, router, permission] = await Promise.all([
readFile(path.join(frontendRoot, 'src/views/login.vue'), 'utf8'),
readFile(path.join(frontendRoot, 'src/router/index.js'), 'utf8'),
readFile(path.join(frontendRoot, 'src/permission.js'), 'utf8')
])
assert.match(loginPage, /开源协议/)
assert.match(loginPage, /to="\/open-source-notice"/)
assert.doesNotMatch(loginPage, /git\.yujiangrubber\.cn\/niuniu\/ShiErFeng/)
assert.match(router, /path: '\/open-source-notice'/)
assert.match(router, /@\/views\/openSourceNotice\/index/)
assert.match(permission, /whiteList = \['\/login', '\/register', '\/open-source-notice'\]/)
})
test('站内开源协议页展示 RuoYi 来源与 MIT 许可全文', async () => {
const noticePage = await readFile(path.join(frontendRoot, 'src/views/openSourceNotice/index.vue'), 'utf8')
assert.match(noticePage, /RuoYi-Vue3-FastAPI/)
assert.match(noticePage, /Copyright \(c\) 2018 RuoYi/)
assert.match(noticePage, /The MIT License \(MIT\)/)
assert.match(noticePage, /自有业务代码、界面设计和文档不因本声明而开放授权/)
})
test('版权归属与 RuoYi 第三方 MIT 许可范围明确', async () => {
const [settings, rootLicense, thirdPartyNotices, packageJson] = await Promise.all([
readFile(path.join(frontendRoot, 'src/settings.js'), 'utf8'),
readFile(path.join(frontendRoot, '..', 'LICENSE'), 'utf8'),
readFile(path.join(frontendRoot, '..', 'THIRD-PARTY-NOTICES.md'), 'utf8'),
readFile(path.join(frontendRoot, 'package.json'), 'utf8')
])
assert.match(settings, /Copyright © 2024-2026 Shierfeng International Biotechnology Co\., Ltd\. All Rights Reserved\./)
assert.match(rootLicense, /Shierfeng International Biotechnology Co\., Ltd\./)
assert.match(rootLicense, /All rights reserved\./i)
assert.doesNotMatch(rootLicense, /^MIT License$/m)
assert.match(thirdPartyNotices, /RuoYi-Vue3-FastAPI/)
assert.match(thirdPartyNotices, /Copyright \(c\) 2018 RuoYi/)
assert.match(thirdPartyNotices, /The MIT License \(MIT\)/)
assert.equal(JSON.parse(packageJson).license, 'UNLICENSED')
})
const expectedRequests = {
'src/api/master/strain.js': [
{ url: "'/master/strain/list'", method: 'get' },
@@ -72,10 +114,16 @@ const expectedRequests = {
{ url: "'/master/pallet'", method: 'post' },
{ url: "'/master/pallet'", method: 'put' },
],
'src/api/master/tunnel.js': [
{ url: "'/master/tunnel/list'", method: 'get' },
{ url: "'/master/tunnel/' + tunnelId", method: 'get' },
{ url: "'/master/tunnel'", method: 'post' },
{ url: "'/master/tunnel'", method: 'put' },
],
'src/api/business/inbound.js': [
{ url: "'/business/inbound/list'", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/inbound/' + inboundId", method: 'get' },
{ url: "'/business/inbound'", method: 'post' },
{ url: "'/business/inbound'", method: 'put' },
@@ -84,7 +132,7 @@ const expectedRequests = {
'src/api/business/filling.js': [
{ url: "'/business/filling/list'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/filling/' + fillingId", method: 'get' },
{ url: "'/business/filling'", method: 'post' }
],
@@ -92,8 +140,9 @@ const expectedRequests = {
{ url: "'/business/shipment/list'", method: 'get' },
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
{ url: "'/business/shipment/site-options'", method: 'get' },
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
{ url: "'/business/shipment/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/shipment/' + shipmentId", method: 'get' },
{ url: "'/business/shipment/' + shipmentId + '/labels'", method: 'get' },
{ url: "'/business/shipment'", method: 'post' },
{ url: "'/business/shipment'", method: 'put' },
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' }
@@ -101,14 +150,17 @@ const expectedRequests = {
'src/api/business/usage.js': [
{ url: "'/business/usage/list'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
{ url: "'/business/usage/tunnel-options'", method: 'get' },
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/usage-fifo/recommendation'", method: 'get' },
{ url: "'/business/usage/' + usageId", method: 'get' },
{ url: "'/business/usage'", method: 'post' }
],
'src/api/business/return.js': [
{ url: "'/business/return/list'", method: 'get' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/return/request'", method: 'post' },
{ url: "'/business/return/' + returnId", method: 'get' },
{ url: "'/business/return/' + returnId + '/confirm'", method: 'put' }
@@ -213,9 +265,11 @@ const businessPageContracts = {
payloads: {
addFilling: [
'batchNo',
'expiryDate',
'idempotencyKey',
'initialPackages',
'packageSpec',
'productionDate',
'qrToken',
'strainId',
'warehouseId'
@@ -228,13 +282,14 @@ const businessPageContracts = {
'addShipment',
'confirmShipment',
'getShipment',
'getShipmentLabels',
'getShipmentSiteOptions',
'getShipmentWarehouseOptions',
'listShipment',
'scanShipmentPallet',
'updateShipment'
],
permissions: ['add', 'confirm', 'edit', 'list', 'query'],
permissions: ['add', 'confirm', 'edit', 'list', 'query', 'receive'],
payloads: {
addShipment: ['palletIds', 'sourceWarehouseId', 'targetSiteId'],
updateShipment: ['palletIds', 'shipmentId', 'sourceWarehouseId', 'targetSiteId'],
@@ -243,11 +298,10 @@ const businessPageContracts = {
},
usage: {
relativePath: 'src/views/business/usage/index.vue',
apiNames: ['addUsage', 'getUsageLineOptions', 'listUsage', 'scanUsagePallet'],
apiNames: ['addUsage', 'enterUsageTunnel', 'getUsageFifoRecommendation', 'getUsageLineOptions', 'getUsageTunnelOptions', 'listUsage', 'scanUsagePallet'],
permissions: ['add', 'list', 'query'],
payloads: {
addUsage: ['idempotencyKey', 'lineId', 'qrToken', 'usedPackages']
}
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
payloads: {}
},
return: {
relativePath: 'src/views/business/return/index.vue',
@@ -398,6 +452,7 @@ const expectedButtonPermissions = [
'business:shipment:add',
'business:shipment:edit',
'business:shipment:confirm',
'business:shipment:receive',
'business:usage:query',
'business:usage:add',
'business:return:query',
@@ -1166,8 +1221,8 @@ test('窄屏模式控件仍保留正式退出与测试批次切换入口', async
test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => {
assert.equal(expectedPagePermissions.length, 17)
assert.equal(expectedButtonPermissions.length, 37)
assert.equal(new Set(expectedPermissions).size, 54)
assert.equal(expectedButtonPermissions.length, 38)
assert.equal(new Set(expectedPermissions).size, 55)
const relativePaths = [
...Object.values(masterPageContracts),
...Object.values(businessPageContracts),
@@ -1467,9 +1522,9 @@ test('首次入库确认失败后取消重试不丢弃原幂等键', async () =>
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)|handleConfirmCancellation\(/)
})
test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
assert.equal(Object.keys(expectedRequests).length, 17)
assert.equal(Object.values(expectedRequests).flat().length, 66)
test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
assert.equal(Object.keys(expectedRequests).length, 18)
assert.equal(Object.values(expectedRequests).flat().length, 74)
for (const [relativePath, expected] of Object.entries(expectedRequests)) {
await t.test(relativePath, async () => {
@@ -1482,16 +1537,18 @@ test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
test('关键扫码、选项、确认与二维码契约被显式覆盖', () => {
const requiredRequests = [
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/shipment/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
{ url: "'/business/shipment/site-options'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/usage/tunnel-options'", method: 'get' },
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' },
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' },
@@ -1960,7 +2017,7 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础
.map((match) => match[0])
assert.ok(businessControls.length > 0, `${resource} 页面缺少业务输入控件`)
for (const control of businessControls) {
assert.match(control, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务输入`)
assert.match(control, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*operationPending[^"]*")/, `${resource} 提交期间必须禁用业务输入`)
}
const businessActions = [...source.matchAll(/<el-button\b[^>]*class="business-action"[^>]*>/g)]
.map((match) => match[0])
@@ -2018,7 +2075,7 @@ test('五个业务页面的读取请求逐分支执行 stale guard,并在上
},
usage: {
flows: [
['loadLineOptions', ['getUsageLineOptions'], 'isCurrentOptionRequest'],
['loadUsageOptions', ['getUsageLineOptions', 'getUsageTunnelOptions'], 'isCurrentOptionRequest'],
['handleUsageScan', ['scanUsagePallet'], 'isCurrentScanRequest']
],
resets: [['resetUsageOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]],
@@ -2172,7 +2229,7 @@ test('装填扫码 pending 期间禁用全部业务输入', async () => {
for (const field of ['strainId', 'batchNo', 'packageSpec', 'initialPackages']) {
const businessControl = source.match(new RegExp(`<[^>]+v-model="form\\.${field}"[^>]*>`))?.[0]
assert.ok(businessControl, `缺少装填 ${field} 业务控件`)
assert.match(businessControl, /:disabled="[^"]*scanLoading[^"]*"/, `装填扫码 pending 时必须禁用 ${field}`)
assert.match(businessControl, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*scanLoading[^"]*")/, `装填扫码 pending 时必须禁用 ${field}`)
}
})
@@ -2196,7 +2253,7 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时
['strainId', 'undefined'],
['batchNo', "''"],
['packageSpec', "''"],
['initialPackages', 'undefined']
['initialPackages', '100']
]) {
const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`)
assert.match(clearPayloadSource, fieldPattern, `装填载荷清理必须重置 ${field}`)
@@ -2366,56 +2423,13 @@ test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
})
test('使用页面依扫码结果锁定产线并只采用后端返回的余量和状态', async () => {
test('使用页面依扫码结果完成入隧、锁定产线并只采用后端返回的余量和状态', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.usage.relativePath), 'utf8')
assertButtonsUsePermission(source, 'submitUsage', 'business:usage:add')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:usage:query', 'business:usage:add'\]\)/)
assert.match(source, /pallet\.currentLineId == null/)
assert.match(source, /getUsageLineOptions\(pallet\.siteId\)/)
assert.match(source, /:disabled="operationPending \|\| lineLocked/)
assert.match(source, /validator:\s*validatePositiveInteger/)
const scanSource = extractNamedFunction(source, 'handleUsageScan')
const scanCallIndex = scanSource.indexOf('scanUsagePallet(')
assert.ok(scanCallIndex >= 0, '使用扫码必须调用 scanUsagePallet')
for (const [snippet, message] of [
['clearUsageIdempotencyKey()', '新使用扫码前必须清除旧幂等键'],
['scannedPallet.value = null', '新使用扫码前必须清除旧托盘'],
['usageResult.value = null', '新使用扫码前必须清除旧成功结果'],
['lineOptions.value = []', '新使用扫码前必须清除旧产线选项'],
['lineLocked.value = false', '新使用扫码前必须解除旧产线锁定'],
["form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }", '新使用扫码前必须清除旧提交 payload']
]) {
const index = scanSource.indexOf(snippet)
assert.ok(index >= 0 && index < scanCallIndex, message)
}
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
assert.match(invalidSource, /clearUsageIdempotencyKey\(\)/)
assert.match(invalidSource, /scannedPallet\.value = null/)
assert.match(invalidSource, /usageResult\.value = null/)
assert.match(invalidSource, /lineOptions\.value = \[\]/)
assert.match(invalidSource, /lineLocked\.value = false/)
assert.match(invalidSource, /form\.value = \{ qrToken: '', lineId: undefined, usedPackages: undefined \}/)
const submitSource = extractNamedFunction(source, 'submitUsage')
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'使用提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
assert.match(submitSource, /const idempotencyKey = ensureUsageIdempotencyKey\(\)/)
const successSource = extractPromiseCallback(submitSource, 'then')
assert.match(successSource, /const result = response\.data;/)
assert.match(successSource, /remainingPackages:\s*result\.remainingPackages/)
assert.match(successSource, /flowState:\s*result\.flowState/)
assert.doesNotMatch(source, /remainingPackages\s*[-+]=|remainingPackages\s*[-+]\s*form|Math\.(?:max|min)\([^)]*remainingPackages/)
assert.match(successSource, /clearUsageIdempotencyKey\(\)/)
const catchSource = extractPromiseCallback(submitSource, 'catch')
assert.doesNotMatch(catchSource, /clearUsageIdempotencyKey/)
assert.match(extractNamedFunction(source, 'resetUsageOperation'), /clearUsageIdempotencyKey\(\)/)
assert.match(source, /watch\([\s\S]*?clearUsageIdempotencyKey\(\)/)
// 执行真实页面/API行为,避免把“必须每次重扫清空幂等键”的旧实现误当成安全要求。
await execFileAsync(process.execPath, ['--test', 'scripts/usage-behavior.test.mjs'], { cwd: frontendRoot })
assert.match(source, /@media \(max-width: 767px\)[\s\S]*?min-height:\s*(?:48|52|56)px/)
})