调整部分前端布局和后端增删改逻辑

This commit is contained in:
焦龙言
2026-09-26 23:11:05 +08:00
parent 977e7f996e
commit 17d991a74a
117 changed files with 311994 additions and 3192 deletions
@@ -11,6 +11,25 @@ import * as testBatchContext from '../src/utils/testBatchContext.js'
const frontendRoot = fileURLToPath(new URL('..', import.meta.url))
const execFileAsync = promisify(execFile)
test('Web 端统一使用 Sylvan 横版标识、折叠图标和浏览器图标', async () => {
const [logoComponent, toolBuilder, indexHtml, fullLogo, markLogo] = await Promise.all([
readFile(path.join(frontendRoot, 'src/layout/components/Sidebar/Logo.vue'), 'utf8'),
readFile(path.join(frontendRoot, 'src/views/tool/build/index.vue'), 'utf8'),
readFile(path.join(frontendRoot, 'index.html'), 'utf8'),
readFile(path.join(frontendRoot, 'src/assets/logo/sylvan-logo.svg'), 'utf8'),
readFile(path.join(frontendRoot, 'src/assets/logo/sylvan-mark.svg'), 'utf8')
])
assert.match(logoComponent, /@\/assets\/logo\/sylvan-logo\.svg/)
assert.match(logoComponent, /@\/assets\/logo\/sylvan-mark\.svg/)
assert.match(toolBuilder, /@\/assets\/logo\/sylvan-mark\.svg/)
assert.match(indexHtml, /href="\/sylvan-mark\.svg"/)
assert.match(fullLogo, /viewBox="0 0 516\.1 194\.52"/)
assert.match(markLogo, /viewBox="0 0 140 140"/)
assert.doesNotMatch(`${logoComponent}\n${toolBuilder}`, /assets\/logo\/logo\.png/)
assert.doesNotMatch(`${fullLogo}\n${markLogo}`, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i)
})
const expectedRequests = {
'src/api/master/strain.js': [
{ url: "'/master/strain/list'", method: 'get' },
@@ -885,6 +904,23 @@ function extractButtonBlocks(source, clickHandler) {
.filter((button) => button.includes(`@click="${clickHandler}`))
}
function assertButtonInSingleToolbar(source, relativePath, toolbarClass, clickHandler) {
const buttonIndex = source.indexOf(`@click="${clickHandler}`)
assert.ok(buttonIndex >= 0, `${relativePath} 缺少 ${clickHandler} 按钮`)
const beforeButton = source.slice(0, buttonIndex)
const rowStart = beforeButton.lastIndexOf('<el-row')
const rowEnd = beforeButton.lastIndexOf('</el-row>')
assert.ok(rowStart > rowEnd, `${relativePath} 的 ${clickHandler} 按钮必须位于 el-row 工具栏内`)
const rowTagEnd = source.indexOf('>', rowStart)
const rowTag = source.slice(rowStart, rowTagEnd + 1)
assert.match(rowTag, new RegExp(`class="[^"]*\\b${toolbarClass}\\b`), `${relativePath} 必须使用 ${toolbarClass}`)
const toolbarRows = [...source.matchAll(new RegExp(`<el-row\\b[^>]*class="[^"]*\\b${toolbarClass}\\b[^"]*"`, 'g'))]
assert.equal(toolbarRows.length, 1, `${relativePath} 的操作按钮必须合并到同一个 ${toolbarClass}`)
}
function assertButtonsUsePermission(source, clickHandler, permission) {
const buttons = extractButtonBlocks(source, clickHandler)
assert.ok(buttons.length > 0, `缺少 ${clickHandler} 按钮`)
@@ -1176,7 +1212,7 @@ test('真实权限门禁解析拒绝注释常量、hasPermiOr 和动态数组假
const missing = expectedPermissions.filter((permission) => permission !== 'master:strain:query')
assert.throws(
() => assertExactPermissionLayers({ mysql: missing, postgresql: missing, controller: missing, vue: missing }),
() => assertExactPermissionLayers({ mysql: missing, controller: missing, vue: missing }),
/权限集合不精确/
)
})
@@ -1487,6 +1523,38 @@ test('六个报表 API 只提供 GET /list,不提供导出接口', async () =>
}
})
test('托盘追溯报表将来源和流转状态显示为中文', async () => {
const source = await readFile(path.join(frontendRoot, 'src/views/report/palletTrace/index.vue'), 'utf8')
const sourceLabels = [
['INBOUND', '入库单'],
['FILLING', '装填记录'],
['SHIPMENT', '发货单'],
['USAGE', '使用记录'],
['RETURN', '退回单']
]
const flowStateLabels = [
['UNSTOCKED', '未入库'],
['EMPTY_IN_WAREHOUSE', '仓库空托盘'],
['FILLED_IN_WAREHOUSE', '仓库有料'],
['AT_SITE_UNUSED', '现场未使用'],
['AT_SITE_PARTIAL', '现场部分使用'],
['AT_SITE_EMPTY', '现场空托盘'],
['RETURNING', '退回中']
]
for (const [value, label] of sourceLabels) {
assert.match(source, new RegExp(`value: '${value}', label: '${label}'`))
}
for (const [value, label] of flowStateLabels) {
assert.match(source, new RegExp(`value: '${value}', label: '${label}'`))
}
assert.match(source, /sourceLabel\(scope\.row\.sourceType,\s*scope\.row\.sourceId\)/)
assert.match(source, /flowStateLabel\(scope\.row\.beforeState\)/)
assert.match(source, /flowStateLabel\(scope\.row\.afterState\)/)
assert.match(source, /function sourceLabel\([\s\S]*?sourceTypeOptions\.find\(/)
assert.match(source, /function flowStateLabel\([\s\S]*?flowStateOptions\.find\(/)
})
test('六个只读报表页面逐页满足 API、权限、筛选、列和异步分页契约', async (t) => {
for (const [resource, contract] of Object.entries(reportPageContracts)) {
await t.test(contract.relativePath, async () => {
@@ -1506,6 +1574,53 @@ test('六个只读报表页面逐页满足 API、权限、筛选、列和异步
assert.doesNotMatch(source, /report:[a-z]+:(?:add|edit|remove|export|print)/)
assert.doesNotMatch(source, /\bhandle(?:Add|Edit|Delete|Remove|Export|Print|Download)\b|proxy\.download\s*\(/)
const queryFormTag = source.match(/<el-form\b[^>]*ref="queryRef"[^>]*>/)?.[0] || ''
assert.match(queryFormTag, /v-show="showSearch"/, `${resource} 搜索区必须支持显隐`)
assert.match(queryFormTag, /label-width="88px"/, `${resource} 筛选标签宽度必须统一`)
assert.match(source, /const showSearch = ref\(true\);?/, `${resource} 搜索区必须默认展开`)
const reportFilterFooter = source.match(/<div\s+class="report-filter__footer">[\s\S]*?<\/div>/)?.[0] || ''
assert.ok(reportFilterFooter, `${resource} 日期和搜索操作必须位于统一底部操作栏`)
assert.match(reportFilterFooter, /<el-form-item\b[^>]*class="report-filter__date"/, `${resource} 底部操作栏必须包含日期范围`)
assert.match(reportFilterFooter, /<el-form-item\b[^>]*class="report-filter__actions"/, `${resource} 底部操作栏必须包含搜索操作`)
assert.ok(
reportFilterFooter.indexOf('report-filter__date') < reportFilterFooter.indexOf('report-filter__actions'),
`${resource} 日期范围必须位于搜索操作之前`
)
const reportToolbar = source.match(/<el-row\b[^>]*class="[^"]*\breport-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || ''
assert.ok(reportToolbar, `${resource} 缺少报表操作栏`)
assert.match(reportToolbar, /<right-toolbar\s+v-model:showSearch="showSearch"\s+@queryTable="getList"\s*\/>/, `${resource} 必须提供搜索显隐和刷新入口`)
assert.match(source, /\.report-filter\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fit,\s*minmax\(260px,\s*1fr\)\)/, `${resource} 筛选区必须使用响应式等宽网格`)
assert.match(
source,
/\.report-filter__footer\s*\{[^}]*grid-column:\s*1\s*\/\s*-1;[^}]*display:\s*flex;[^}]*align-items:\s*flex-end;[^}]*gap:\s*16px;/,
`${resource} 日期和操作项必须位于独占整行的底部操作栏`
)
assert.doesNotMatch(source, /\.report-filter__footer\s*\{[^}]*display:\s*contents;/, `${resource} 底部操作栏不得使用 contents 分散到筛选网格`)
assert.doesNotMatch(source, /\.report-filter__date\s*\{[^}]*grid-column:\s*-3\s*\/\s*-1;/, `${resource} 日期范围不得单独跨最后两列`)
assert.match(
source,
/\.report-filter\s+\.report-filter__date\s*\{[^}]*flex:\s*0\s+1\s+420px;[^}]*width:\s*420px;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;/,
`${resource} 桌面日期范围必须固定基础宽度并覆盖通用表单项宽度`
)
assert.match(source, /\.report-filter\s+\.report-filter__actions\s*\{[^}]*flex:\s*0\s+0\s+auto;[^}]*width:\s*auto;/, `${resource} 操作按钮必须按自身宽度紧邻日期范围`)
assert.match(source, /\.report-filter :deep\(\.el-form-item__content\)\s*\{[^}]*min-width:\s*0;/, `${resource} 筛选内容必须允许收缩`)
assert.match(
source,
/\.report-filter :deep\(\.el-input\),\s*\.report-filter :deep\(\.el-input-number\),\s*\.report-filter :deep\(\.el-select\),\s*\.report-filter :deep\(\.el-date-editor\)\s*\{[^}]*width:\s*100%\s*!important;[^}]*min-width:\s*0;/,
`${resource} 筛选控件必须等宽且不得越界`
)
assert.match(
source,
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.report-filter__footer\s*\{[^}]*grid-column:\s*1;[^}]*flex-direction:\s*column;[^}]*align-items:\s*stretch;/,
`${resource} 窄屏底部操作栏必须恢复纵向布局`
)
assert.match(
source,
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.report-filter\s+\.report-filter__date\s*\{[^}]*flex:\s*0\s+0\s+auto;[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*max-width:\s*none;/,
`${resource} 窄屏日期范围必须恢复全宽`
)
for (const queryKey of contract.queryKeys) {
assert.match(source, new RegExp(`\\b${queryKey}\\b`), `${resource} 缺少筛选键 ${queryKey}`)
}
@@ -1646,6 +1761,20 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除
assert.match(source, /const detail\s*=\s*response\.data/)
assert.match(source, /function handleQuery\(\)\s*{[\s\S]*?queryParams\.value\.pageNum\s*=\s*1[\s\S]*?getList\(\)/)
assert.match(source, /function resetQuery\(\)\s*{[\s\S]*?resetForm\(['"]queryRef['"]\)[\s\S]*?handleQuery\(\)/)
const queryFormTag = source.match(/<el-form\b[^>]*ref="queryRef"[^>]*>/)?.[0] || ''
assert.match(queryFormTag, /v-show="showSearch"/, `${resource} 搜索区必须支持显隐`)
assert.match(source, /const showSearch = ref\(true\);/, `${resource} 搜索区必须默认展开`)
const masterToolbar = source.match(/<el-row\b[^>]*class="[^"]*\bmaster-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || ''
assert.ok(masterToolbar, `${resource} 缺少基础资料操作栏`)
assert.doesNotMatch(masterToolbar.match(/^<el-row\b[^>]*>/)?.[0] || '', /v-if="isMasterWriteAllowed"/, `${resource} 搜索与刷新入口不得受写权限限制`)
assert.match(masterToolbar, /<template v-if="isMasterWriteAllowed">/, `${resource} 写操作仍需受测试模式限制`)
assert.match(masterToolbar, /<right-toolbar\s+v-model:showSearch="showSearch"\s+@queryTable="getList"\s*\/>/, `${resource} 必须复用右侧搜索与刷新工具栏`)
assert.match(
source,
/\.master-query :deep\(\.el-input\),\s*\.master-query :deep\(\.el-input-number\),\s*\.master-query :deep\(\.el-select\)\s*\{[^}]*min-width:\s*0;/,
`${resource} 查询控件必须允许收缩到网格内容宽度`
)
assert.match(source, /<pagination\b[\s\S]*?v-model:page="queryParams\.pageNum"[\s\S]*?v-model:limit="queryParams\.pageSize"[\s\S]*?@pagination="getList"/)
assert.match(source, /<el-table\b[^>]*v-loading="loading"[^>]*empty-text="暂无数据"/)
assert.match(source, /<el-alert\b[^>]*v-if="loadError"/)
@@ -2495,6 +2624,12 @@ test('测试批次管理与旧数据纳入遵守 root、权限和预览提交契
assert.match(source, /v-hasPermi="\['system:testbatch:adopt'\]"/, `${rootType} 纳入入口必须校验批次纳入权限`)
assert.match(source, new RegExp(`<test-batch-adoption-dialog\\b[\\s\\S]*?:root-type="['\"]${rootType}['\"][\\s\\S]*?:resource-ids="selectedIds"`))
assert.match(source, new RegExp(`selectedIds\\.value\\s*=\\s*selection\\.map\\(\\(item\\)\\s*=>\\s*item\\.${contract.id}\\)`), `${rootType} 必须传递正整数根 ID`)
assertButtonInSingleToolbar(
source,
contract.path,
contract.path.includes('/master/') ? 'master-toolbar' : 'page-toolbar',
'openAdoptionDialog'
)
}
for (const rootType of ['strain', 'customer', 'site', 'line', 'warehouse', 'pallet']) {
@@ -2720,3 +2855,21 @@ test('测试批次管理页真实 hydration 拒绝旧版本回写并保留当前
assert.equal(failedPage.state.batchList.value[0].participantCount, undefined)
assert.equal(failedPage.state.loadError.value, '', '当前详情失败必须是单项状态,不得升级为列表失败')
})
test('三个业务菜单公共样式统一使用 Element Plus 主题变量', async () => {
const styles = await source('src/assets/styles/data-pages.scss')
assert.match(styles, /background:\s*var\(--el-bg-color-page,\s*#f5f7fb\)/)
assert.match(styles, /background:\s*var\(--el-bg-color,\s*#fff\)/)
assert.match(styles, /border:\s*1px solid var\(--el-border-color-lighter,\s*#e4e7ed\)/)
assert.match(styles, /background:\s*var\(--el-fill-color-light,\s*#f5f7fa\)/)
assert.match(styles, /color:\s*var\(--el-text-color-regular,\s*#606266\)/)
assert.match(styles, /background:\s*var\(--el-fill-color-lighter,\s*#fafafa\)/)
assert.match(styles, /box-shadow:\s*var\(--el-box-shadow-lighter,/)
assert.doesNotMatch(styles, /background:\s*#f5f7fb\s*;/)
assert.doesNotMatch(styles, /background:\s*#fff\s*;/)
assert.doesNotMatch(styles, /border:\s*1px solid #e7ebf2\s*;/)
assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/)
assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/)
})
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildTrendData, getTrendRangeQuery, TREND_RANGE_OPTIONS } from '../src/utils/dashboardTrend.js'
const referenceDate = new Date(2026, 8, 26, 12)
test('趋势下拉提供 7 天、14 天和 1 个月三个选项', () => {
assert.deepEqual(TREND_RANGE_OPTIONS, [
{ value: 7, label: '近 7 天' },
{ value: 14, label: '近 14 天' },
{ value: 30, label: '近 1 个月' }
])
})
test('趋势范围为近 14 天时,查询从 13 天前开始并包含今天', () => {
assert.deepEqual(getTrendRangeQuery(14, referenceDate), {
beginTime: '2026-09-13',
endTime: '2026-09-26'
})
})
test('趋势范围为近 1 个月时最多查询 30 天', () => {
assert.deepEqual(getTrendRangeQuery(30, referenceDate), {
beginTime: '2026-08-28',
endTime: '2026-09-26'
})
})
test('趋势数据按选择的天数生成日期轴并聚合入库单与出库包数', () => {
const result = buildTrendData(
[
{ createTime: '2026-09-20T08:00:00+08:00' },
{ createTime: '2026-09-24T09:00:00+08:00' },
{ createTime: '2026-09-24T10:00:00+08:00' }
],
[
{ confirmedAt: '2026-09-20T11:00:00+08:00', packageQty: 4 },
{ confirmedAt: '2026-09-24T13:00:00+08:00', packageQty: 7 }
],
7,
referenceDate
)
assert.deepEqual(result.labels, ['9/20', '9/21', '9/22', '9/23', '9/24', '9/25', '9/26'])
assert.deepEqual(result.inbound, [1, 0, 0, 0, 2, 0, 0])
assert.deepEqual(result.outbound, [4, 0, 0, 0, 7, 0, 0])
})