From 167df4adb849a3117c3e0f4a0e3195f46565e534 Mon Sep 17 00:00:00 2001 From: Rickie Date: Sun, 18 Oct 2026 05:03:22 -0700 Subject: [PATCH] =?UTF-8?q?Feat:=20=E5=89=8D=E7=AB=AF=E6=94=B9=E7=89=88=20?= =?UTF-8?q?Web=20=E7=AB=AF=E7=95=8C=E9=9D=A2=E4=B8=8E=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E4=BD=9C=E4=B8=9A=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。 - 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、 测试模式提示条、深色模式配色、窄屏适配 - 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、 缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择 MasterRefSelect,以及编号段解析等工具 - 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案 - 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览; 分配用户对话框标注增减;7 个新增/编辑对话框统一布局 - 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为 列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变 - 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查 - 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试, 同步更新业务与工作台契约脚本 Co-Authored-By: Claude Opus 5.5 --- shierfeng-fastapi-frontend/package.json | 2 +- .../scripts/filling-behavior.test.mjs | 350 +++ .../scripts/filling-dates.test.mjs | 3 + .../scripts/pallet-ranges.test.mjs | 62 + .../scripts/return-behavior.test.mjs | 361 +++ .../scripts/usage-behavior.test.mjs | 131 +- .../scripts/verify-business-contract.mjs | 63 +- .../scripts/verify-dashboard-contract.mjs | 38 +- .../src/api/business/filling.js | 26 + .../src/api/business/inbound.js | 8 + .../src/api/business/return.js | 16 + .../src/api/business/usage.js | 26 + .../src/api/master/line.js | 8 + .../src/api/master/site.js | 8 + .../src/api/master/tunnel.js | 8 + .../src/assets/styles/index.scss | 2 + .../src/assets/styles/sf-master.scss | 1097 +++++++++ .../src/assets/styles/sf-shell.scss | 1068 +++++++++ .../src/assets/styles/sidebar.scss | 346 +-- .../src/assets/styles/variables.module.scss | 5 +- .../src/components/Breadcrumb/index.vue | 21 +- .../BusinessUserAssignDialog/index.vue | 192 +- .../src/components/DocNo/index.vue | 19 + .../src/components/FlowStepper/index.vue | 92 + .../components/MasterExcelActions/index.vue | 952 +++++++- .../src/components/MasterRefSelect/index.vue | 132 ++ .../src/components/PageHeader/descriptions.js | 24 + .../src/components/PageHeader/index.vue | 101 + .../src/components/PalletCodeLink/index.vue | 33 + .../src/components/ScanFeedback/index.vue | 68 + .../src/components/SfIcon/icons.js | 116 + .../src/components/SfIcon/index.vue | 37 + .../src/layout/components/AppMain.vue | 62 +- .../components/CommandPalette/index.vue | 376 +++ .../src/layout/components/Navbar.vue | 164 +- .../src/layout/components/Sidebar/Logo.vue | 67 +- .../layout/components/Sidebar/SidebarItem.vue | 12 +- .../layout/components/Sidebar/UserCard.vue | 186 ++ .../src/layout/components/Sidebar/index.vue | 93 +- .../src/layout/components/TagsView/index.vue | 27 +- .../components/TestModeBanner/index.vue | 92 + .../src/layout/index.vue | 105 +- shierfeng-fastapi-frontend/src/main.js | 7 + .../src/router/index.js | 19 +- shierfeng-fastapi-frontend/src/settings.js | 4 +- .../src/store/modules/user.js | 4 + shierfeng-fastapi-frontend/src/utils/docNo.js | 8 + .../src/utils/masterDisplay.js | 51 + .../src/utils/masterRouteIntent.js | 25 + .../src/utils/palletPassport.js | 33 + .../src/utils/palletRanges.js | 148 ++ .../src/views/business/filling/index.vue | 1588 +++++++++++-- .../src/views/business/inbound/index.vue | 1367 +++++++++-- .../src/views/business/return/index.vue | 1925 +++++++++++++--- .../src/views/business/shipment/index.vue | 1175 ++++++++-- .../src/views/business/usage/index.vue | 1682 ++++++++++++-- .../src/views/dashboard/index.vue | 2020 +++++++++++++---- .../src/views/master/customer/index.vue | 481 +++- .../src/views/master/line/index.vue | 265 ++- .../src/views/master/pallet/index.vue | 651 +++++- .../src/views/master/site/index.vue | 316 ++- .../src/views/master/strain/index.vue | 225 +- .../src/views/master/tunnel/index.vue | 433 +++- .../src/views/master/warehouse/index.vue | 299 ++- .../src/views/pallet/passport/index.vue | 1338 +++++++++++ .../src/views/report/consumption/index.vue | 5 +- .../src/views/report/filling/index.vue | 5 +- .../views/report/palletInventory/index.vue | 5 +- .../src/views/report/palletTrace/index.vue | 136 +- .../src/views/report/return/index.vue | 5 +- .../src/views/report/shipment/index.vue | 5 +- 71 files changed, 18396 insertions(+), 2428 deletions(-) create mode 100644 shierfeng-fastapi-frontend/scripts/filling-behavior.test.mjs create mode 100644 shierfeng-fastapi-frontend/scripts/pallet-ranges.test.mjs create mode 100644 shierfeng-fastapi-frontend/scripts/return-behavior.test.mjs create mode 100644 shierfeng-fastapi-frontend/src/assets/styles/sf-master.scss create mode 100644 shierfeng-fastapi-frontend/src/assets/styles/sf-shell.scss create mode 100644 shierfeng-fastapi-frontend/src/components/DocNo/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/FlowStepper/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/MasterRefSelect/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/PageHeader/descriptions.js create mode 100644 shierfeng-fastapi-frontend/src/components/PageHeader/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/PalletCodeLink/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/ScanFeedback/index.vue create mode 100644 shierfeng-fastapi-frontend/src/components/SfIcon/icons.js create mode 100644 shierfeng-fastapi-frontend/src/components/SfIcon/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/CommandPalette/index.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/Sidebar/UserCard.vue create mode 100644 shierfeng-fastapi-frontend/src/layout/components/TestModeBanner/index.vue create mode 100644 shierfeng-fastapi-frontend/src/utils/docNo.js create mode 100644 shierfeng-fastapi-frontend/src/utils/masterDisplay.js create mode 100644 shierfeng-fastapi-frontend/src/utils/masterRouteIntent.js create mode 100644 shierfeng-fastapi-frontend/src/utils/palletPassport.js create mode 100644 shierfeng-fastapi-frontend/src/utils/palletRanges.js create mode 100644 shierfeng-fastapi-frontend/src/views/pallet/passport/index.vue diff --git a/shierfeng-fastapi-frontend/package.json b/shierfeng-fastapi-frontend/package.json index 79343d3..ee01c27 100644 --- a/shierfeng-fastapi-frontend/package.json +++ b/shierfeng-fastapi-frontend/package.json @@ -11,7 +11,7 @@ "build:docker": "vite build --mode docker", "build:stage": "vite build --mode staging", "preview": "vite preview", - "test:business": "node --test scripts/verify-business-contract.mjs scripts/verify-report-*.mjs scripts/*-behavior.test.mjs scripts/filling-dates.test.mjs scripts/pallet-identity.test.mjs" + "test:business": "node --test scripts/verify-business-contract.mjs scripts/verify-report-*.mjs scripts/*-behavior.test.mjs scripts/filling-dates.test.mjs scripts/pallet-identity.test.mjs scripts/pallet-ranges.test.mjs" }, "repository": { "type": "git", diff --git a/shierfeng-fastapi-frontend/scripts/filling-behavior.test.mjs b/shierfeng-fastapi-frontend/scripts/filling-behavior.test.mjs new file mode 100644 index 0000000..54303ef --- /dev/null +++ b/shierfeng-fastapi-frontend/scripts/filling-behavior.test.mjs @@ -0,0 +1,350 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { webcrypto } from 'node:crypto' +import { test } from 'node:test' +import * as Vue from 'vue' +import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc' + +// 执行真实 setup、模板和 API,仅替换网络与组件宿主边界。 +const read = path => readFile(new URL(`../src/${path}`, import.meta.url), 'utf8') +const { descriptor } = parse(await read('views/business/filling/index.vue')) +const script = compileScript(descriptor, { id: 'pc-filling-test', genDefaultAs: '__component' }) +const template = compileTemplate({ source: descriptor.template.content, filename: 'filling/index.vue', id: 'pc-filling-test', compilerOptions: { bindingMetadata: script.bindings } }) +assert.deepEqual(template.errors, []) +const apiSource = await read('api/business/filling.js') +const importText = async path => import(`data:text/javascript,${encodeURIComponent(await read(path))}`) +const scanUtils = await importText('utils/palletScan.js') +const passport = await importText('utils/palletPassport.js') +const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) => + `const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`) +function loadModule(source, modules) { + const names = [...source.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1]) + return new Function('__modules', `${bindImports(source).replace(/export (function|const) /g, '$1 ')}\nreturn { ${names.join(',')} };`)(modules) +} +const masterDisplay = loadModule(await read('utils/masterDisplay.js'), { + '@/router': { default: { getRoutes: () => [{ path: '/businessOperation/shipment' }] } }, + '@/utils/palletPassport': passport +}) +const flush = async () => { for (let index = 0; index < 4; index += 1) await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() } + +const isoDate = offset => { + const now = new Date() + const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset) + return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}` +} +const STRAINS = [{ id: 3, code: 'A15', name: '平菇 A15' }] +const SUMMARY = { + todayPalletCount: 6, todayPackageSum: 600, todayBatchCount: 1, monthStart: '2026-10-01', monthPalletCount: 99, monthBatchCount: 8, + monthTopStrains: [{ strainName: '平菇 A15', palletCount: 31 }, { strainName: '白灵菇 B22', palletCount: 27 }], + emptyPalletCount: 13, emptyByWarehouse: [{ warehouseId: 4, warehouseName: '成品一仓', palletCount: 13 }], + nearExpiryDays: 14, nearExpiryPalletCount: 4, nearExpiryBatchCount: 1, nearestExpiryBatchNo: 'B260923-01', nearestExpiryDate: isoDate(7) +} +const BATCH = { + strainId: 3, strainCode: 'A15', strainName: '平菇 A15', batchNo: 'B261017-01', productionDate: isoDate(-1), expiryDate: isoDate(44), + packageSpec: '1kg/包', palletCount: 6, packageSum: 600, lastOperatedAt: '2026-10-17T09:10:00', lastOperatorName: '年糕' +} +const NEAR_BATCH = { ...BATCH, batchNo: 'B260923-01', expiryDate: isoDate(7) } +const DETAIL = { + ...BATCH, + distribution: [{ state: 'FILLED_IN_WAREHOUSE', palletCount: 4 }, { state: 'IN_TRANSIT', palletCount: 1 }, { state: 'CONSUMED', palletCount: 1 }], + recent: [{ fillingId: 90, fillingNo: 'FL-90', palletId: 501, palletCode: 'TP-10501', warehouseName: '成品一仓', operatorName: '年糕', operatedAt: '2026-10-17T09:10:00' }] +} +const EMPTY_PALLETS = { + 'TP-10501': { palletId: 501, palletCode: 'TP-10501', qrToken: 'QR-501', flowState: 'EMPTY_IN_WAREHOUSE', warehouseId: 4, capacityPackages: 100 }, + 'TP-10502': { palletId: 502, palletCode: 'TP-10502', qrToken: 'QR-502', flowState: 'EMPTY_IN_WAREHOUSE', warehouseId: 4, capacityPackages: 100 } +} + +function defaultScan(token) { + const pallet = EMPTY_PALLETS[token] + return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('托盘不符合仓库空托盘装填条件')) +} + +const renderer = Vue.createRenderer({ createElement: () => ({}), createText: () => ({}), createComment: () => ({}), insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {}, parentNode: () => null, nextSibling: () => null }) +function flatten(vnode) { + if (Array.isArray(vnode)) return vnode.flatMap(flatten) + if (!vnode || typeof vnode !== 'object') return [] + const children = vnode.children + const slots = children && !Array.isArray(children) && typeof children === 'object' + ? Object.entries(children).filter(([key, value]) => !key.startsWith('_') && typeof value === 'function').flatMap(([, slot]) => flatten(slot({ row: {} }))) : [] + return [vnode, ...flatten(children), ...slots] +} + +function createPage({ request: override, permissions = () => true, batches = [BATCH] } = {}) { + const requests = [] + const user = Vue.reactive({ admin: false }), batch = Vue.reactive({ isTestMode: false }) + const request = config => { + requests.push(structuredClone(config)) + const custom = override?.(config) + if (custom !== undefined) return custom + if (config.url === '/business/filling/summary') return Promise.resolve({ data: SUMMARY }) + if (config.url === '/business/filling/batches') return Promise.resolve({ rows: structuredClone(batches), total: batches.length }) + if (config.url === '/business/filling/batches/detail') return Promise.resolve({ data: structuredClone({ ...DETAIL, ...batches.find(item => item.batchNo === config.params.batchNo) }) }) + if (config.url === '/business/filling/list') return Promise.resolve({ rows: [], total: 0 }) + if (config.url === '/business/filling/strain-options') return Promise.resolve({ data: STRAINS }) + if (config.url.startsWith('/business/filling/scan/')) return defaultScan(decodeURIComponent(config.url.split('/scan/')[1])) + if (config.url === '/business/filling' && config.method === 'post') return Promise.resolve({ msg: '装填成功' }) + throw new Error(`未处理请求 ${config.url}`) + } + const names = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1]) + const api = new Function('__modules', `${bindImports(apiSource).replace(/export function /g, 'function ')}\nreturn { ${names.join(',')} };`)({ '@/utils/request': { default: request } }) + const proxy = { + $auth: { hasPermiAnd: perms => permissions(perms) }, + $modal: { msgSuccess() {}, msgWarning() {} }, + $refs: { fillingRef: { validate: callback => callback(true), clearValidate() {} } }, + resetForm() {} + } + const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), resolveComponent: name => ({ name }), resolveDirective: () => ({}) } + const stub = name => ({ default: { name } }) + const modules = { + vue, + '@/components/PalletScanInput': stub('PalletScanInput'), + '@/components/ScanFeedback': stub('ScanFeedback'), + '@/components/SfIcon': stub('SfIcon'), + '@/components/TestBatchAdoptionDialog': stub('TestBatchAdoptionDialog'), + '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) }, + '@/utils/masterDisplay': masterDisplay, + '@/utils/palletPassport': passport, + '@/store/modules/user': { default: () => user }, + '@/store/modules/testBatch': { default: () => batch }, + '@/api/business/filling': api + } + // PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。 + const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance'] + const component = new Function('__modules', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, ...globals.map(name => vue[name])) + const scope = Vue.effectScope(), state = scope.run(() => component.setup({}, { expose() {} })) + const render = new Function('__modules', `${bindImports(template.code).replace('export function render', 'function render')}\nreturn render;`)(modules) + return { + state, requests, + posts: () => requests.filter(item => item.method === 'post'), + async scan(value) { state.handleFillingScan(value); await flush() }, + nodes() { + let nodes + const app = renderer.createApp({ render() { nodes = flatten(render({}, [], {}, Vue.proxyRefs(state), {}, {})); return null } }) + app.mount({}); app.unmount() + return nodes + }, + close() { scope.stop() } + } +} +const label = node => flatten(node).filter(child => typeof child.children === 'string').map(child => child.children).join('') +const button = (page, text) => page.nodes().find(node => node.type?.name === 'el-button' && label(node).includes(text)) +const scanInput = page => page.nodes().find(node => node.type?.name === 'PalletScanInput') + +async function openStation(page, batch) { + await flush() + page.state.handleOpenFilling(batch) + await flush() +} + +async function lockParams(page, overrides = {}) { + Object.assign(page.state.form.value, { + strainId: 3, batchNo: ' B261018-01 ', productionDate: isoDate(0), expiryDate: isoDate(45), packageSpec: ' 1kg/包 ', ...overrides + }) + await flush() + page.state.lockFillingParams() + await flush() +} + +test('装填工位:未锁定参数不能扫码,锁定前校验必填与日期顺序', async () => { + const page = createPage() + await openStation(page) + assert.equal(page.state.open.value, true) + assert.equal(scanInput(page).props.disabled, true) + await page.scan('TP-10501') + assert.equal(page.requests.some(item => item.url.includes('/scan/')), false) + assert.equal(page.state.scanNotice.value.type, 'warn') + + page.state.lockFillingParams() + assert.equal(page.state.paramError.value, '请选择菌种') + await lockParams(page, { expiryDate: isoDate(-1) }) + assert.equal(page.state.paramError.value, '有效期不能早于生产日期') + assert.equal(page.state.paramsLocked.value, false) + + await lockParams(page) + assert.equal(page.state.paramsLocked.value, true) + assert.deepEqual( + { ...page.state.lockedParams.value }, + { strainId: 3, strainLabel: '平菇 A15', batchNo: 'B261018-01', productionDate: isoDate(0), expiryDate: isoDate(45), packageSpec: '1kg/包', initialPackages: 100 } + ) + assert.equal(scanInput(page).props.disabled, false) + page.close() +}) + +test('扫码后确认:载荷由扫码结果与锁定参数组成,成功后进入本班记录且参数保持锁定', async () => { + const page = createPage() + await openStation(page) + await lockParams(page) + await page.scan('TP-10501') + assert.equal(page.posts().length, 0, '默认模式扫码后等待确认') + assert.equal(page.state.scannedPallet.value.palletCode, 'TP-10501') + assert.equal(page.state.form.value.qrToken, 'QR-501') + assert.equal(page.state.form.value.warehouseId, 4) + assert.equal(button(page, '确认装填').props.disabled, false) + + page.state.submitFilling() + await flush() + const [post] = page.posts() + assert.deepEqual(post.data, { + qrToken: 'QR-501', warehouseId: 4, strainId: 3, batchNo: 'B261018-01', productionDate: isoDate(0), expiryDate: isoDate(45), + packageSpec: '1kg/包', initialPackages: 100, idempotencyKey: post.data.idempotencyKey + }) + assert.ok(post.data.idempotencyKey) + assert.equal(page.state.shiftRecords.value.length, 1) + assert.deepEqual( + [page.state.shiftRecords.value[0].palletCode, page.state.shiftRecords.value[0].warehouseName, page.state.shiftRecords.value[0].batchNo], + ['TP-10501', '成品一仓', 'B261018-01'] + ) + assert.equal(page.state.scannedPallet.value, null) + assert.equal(page.state.paramsLocked.value, true) + assert.equal(page.state.scanNotice.value.type, 'ok') + + await page.scan('TP-10502') + page.state.submitFilling() + await flush() + const keys = page.posts().map(item => item.data.idempotencyKey) + assert.equal(keys.length, 2) + assert.notEqual(keys[0], keys[1], '不同托盘使用不同幂等键') + page.close() +}) + +test('扫码即装填:校验通过立即提交,失败后重试复用同一幂等键', async () => { + let attempts = 0 + const page = createPage({ + request: config => { + if (config.method !== 'post') return undefined + attempts += 1 + return attempts === 1 ? Promise.reject(new Error('历史非 100 包托盘不允许新增装填')) : Promise.resolve({ msg: '装填成功' }) + } + }) + await openStation(page) + await lockParams(page) + page.state.fillMode.value = 'auto' + await page.scan('TP-10501') + assert.equal(page.posts().length, 1) + assert.match(page.state.formError.value, /直接重试将复用原键/) + assert.equal(page.state.scanNotice.value.message, '历史非 100 包托盘不允许新增装填') + assert.ok(page.state.scannedPallet.value, '失败后保留待装填托盘以便重试') + assert.ok(button(page, '重试装填')) + + page.state.submitFilling() + await flush() + const keys = page.posts().map(item => item.data.idempotencyKey) + assert.equal(keys[0], keys[1]) + assert.equal(page.state.shiftRecords.value.length, 1) + page.close() +}) + +test('后端拒绝与非空托盘进入未装填记录,本班已装填的托盘提示无需重复', async () => { + const filled = new Set() + const page = createPage({ + request: config => { + if (config.method === 'post') { + filled.add(config.data.qrToken) + return Promise.resolve({ msg: '装填成功' }) + } + if (config.url.endsWith('/scan/TP-10288')) { + return Promise.resolve({ data: { palletId: 288, palletCode: 'TP-10288', flowState: 'FILLED_IN_WAREHOUSE', warehouseId: 4 } }) + } + // 已装填的托盘不再是空托盘,服务端扫码会拒绝 + const token = decodeURIComponent(config.url.split('/scan/')[1] || '') + if (filled.has(EMPTY_PALLETS[token]?.qrToken)) return Promise.reject(new Error('托盘不符合仓库空托盘装填条件')) + return undefined + } + }) + await openStation(page) + await lockParams(page) + await page.scan('TP-10999') + assert.deepEqual([page.state.scanRejects.value[0].code, page.state.scanRejects.value[0].message], ['TP-10999', '托盘不符合仓库空托盘装填条件']) + await page.scan('TP-10288') + assert.equal(page.state.scanRejects.value[0].message, '该托盘为「仓库有料」,只有仓库空托盘可以装填') + assert.equal(page.state.scannedPallet.value, null) + + await page.scan('TP-10501') + page.state.submitFilling() + await flush() + await page.scan('TP-10501') + assert.equal(page.state.scanNotice.value.type, 'info') + assert.match(page.state.scanNotice.value.message, /^本班 \d{2}:\d{2} 已装填,无需重复$/) + assert.equal(page.state.scanRejects.value.length, 2, '重复扫本班托盘不计入未装填记录') + page.close() +}) + +test('修改参数会放弃待装填托盘,沿用批次只带入参数仍需锁定', async () => { + const page = createPage() + await openStation(page, BATCH) + assert.equal(page.state.paramsLocked.value, false) + assert.deepEqual( + [page.state.form.value.strainId, page.state.form.value.batchNo, page.state.form.value.packageSpec], + [3, 'B261017-01', '1kg/包'] + ) + page.state.lockFillingParams() + await flush() + await page.scan('TP-10501') + assert.ok(page.state.scannedPallet.value) + + page.state.unlockFillingParams() + await flush() + assert.equal(page.state.paramsLocked.value, false) + assert.equal(page.state.scannedPallet.value, null) + assert.equal(page.state.form.value.batchNo, 'B261017-01', '解锁后参数回到表单供修改') + assert.equal(page.state.form.value.qrToken, '') + page.state.applyShelfLife(30) + assert.equal(page.state.form.value.expiryDate, isoDate(29)) + page.close() +}) + +test('结束本班后显示装填结果并刷新列表与概况,再次打开工位从空白开始', async () => { + const page = createPage() + await openStation(page, BATCH) + page.state.lockFillingParams() + await flush() + await page.scan('TP-10502') + page.state.submitFilling() + await flush() + const before = page.requests.length + page.state.cancelFilling() + await flush() + assert.equal(page.state.open.value, false) + assert.match(page.state.shiftResultText.value, /^本班已装填 1 托 · 100 包(B261017-01)/) + const refreshed = page.requests.slice(before).map(item => item.url) + assert.ok(refreshed.includes('/business/filling/batches')) + assert.ok(refreshed.includes('/business/filling/summary')) + + await openStation(page) + assert.equal(page.state.shiftRecords.value.length, 0) + assert.equal(page.state.form.value.batchNo, '') + assert.equal(page.state.lockedParams.value, null) + page.close() +}) + +test('列表:默认按生产批次,切到装填明细使用同一筛选;临期批次不能沿用', async () => { + const page = createPage({ batches: [BATCH, NEAR_BATCH] }) + await flush() + const urls = page.requests.map(item => item.url) + assert.deepEqual(urls.slice(0, 2), ['/business/filling/summary', '/business/filling/batches']) + assert.equal(urls.includes('/business/filling/strain-options'), false, '启动阶段不请求 query 权限接口') + assert.equal(page.state.todayHint.value, '600 包 · 1 个批次') + assert.equal(page.state.monthHint.value, '8 个批次 · 平菇 A15 / 白灵菇 B22') + assert.equal(page.state.nearExpiryHint.value, 'B260923-01 · 7 天后到期,请优先发货') + assert.deepEqual(page.state.specPresets.value, ['1kg/包']) + + page.state.openBatchPanel(page.state.batchList.value[0]) + await flush() + assert.deepEqual(page.state.distributionRows.value.map(item => [item.label, item.palletCount]), [['仓库有料', 4], ['在途待收货', 1], ['已用完', 1]]) + assert.equal(page.state.reuseBlocked.value, false) + assert.equal(button(page, '沿用此批参数').props.disabled, false) + page.state.openBatchPanel(page.state.batchList.value[1]) + await flush() + assert.equal(page.state.reuseBlocked.value, true) + assert.equal(button(page, '沿用此批参数').props.disabled, true) + + page.state.queryParams.value.keyword = 'TP-105' + page.state.viewMode.value = 'record' + page.state.handleViewChange() + await flush() + const last = page.requests.filter(item => item.url === '/business/filling/list').at(-1) + assert.equal(last.params.keyword, 'TP-105') + assert.equal(last.params.pageNum, 1) + assert.equal(page.state.panelBatch.value, null) + page.close() +}) diff --git a/shierfeng-fastapi-frontend/scripts/filling-dates.test.mjs b/shierfeng-fastapi-frontend/scripts/filling-dates.test.mjs index 0138bb8..309b080 100644 --- a/shierfeng-fastapi-frontend/scripts/filling-dates.test.mjs +++ b/shierfeng-fastapi-frontend/scripts/filling-dates.test.mjs @@ -25,6 +25,9 @@ async function fillingPage(platform) { useUserStore: () => ({ admin: true }), useTestBatchStore: () => ({ isTestMode: false }), onShow() {}, createIdempotencyKey: () => `date-command-${++key}`, listFilling: async () => ({ rows: [], total: 0 }), + listFillingBatches: async () => ({ rows: [], total: 0 }), + getFillingSummary: async () => ({ data: null }), + getFillingBatchDetail: async () => ({ data: null }), addFilling: async payload => { submissions.push(payload); throw new Error('网络暂不可用') }, }) vm.runInContext(`${script}\nglobalThis.page = {form, scannedPallet, submitFilling, formError};`, context) diff --git a/shierfeng-fastapi-frontend/scripts/pallet-ranges.test.mjs b/shierfeng-fastapi-frontend/scripts/pallet-ranges.test.mjs new file mode 100644 index 0000000..5f2c4cd --- /dev/null +++ b/shierfeng-fastapi-frontend/scripts/pallet-ranges.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { groupCodeRanges, parsePasteTokens, splitPalletCode } from '../src/utils/palletRanges.js' + +test('托盘号拆分为前缀与末尾数字,无数字结尾时返回空', () => { + assert.deepEqual(splitPalletCode(' TP-10501 '), { prefix: 'TP-', digits: '10501', number: 10501 }) + assert.deepEqual(splitPalletCode('PLT-007'), { prefix: 'PLT-', digits: '007', number: 7 }) + assert.equal(splitPalletCode('TP-A'), null) + assert.equal(splitPalletCode(''), null) +}) + +test('连续编号合并成段,乱序与重复不影响结果', () => { + const ranges = groupCodeRanges(['TP-10503', 'TP-10501', 'TP-10502', 'TP-10502', 'TP-10520', 'TP-10521']) + assert.deepEqual( + ranges.map(({ label, count }) => [label, count]), + [['TP-10501 – 10503', 3], ['TP-10520 – 10521', 2]] + ) +}) + +test('补零位数保持连续,不同前缀与无数字托盘号单独成段', () => { + const ranges = groupCodeRanges(['PLT-0999', 'PLT-1000', 'A-9', 'A-10', 'B-001', 'TP-X']) + assert.deepEqual( + ranges.map(({ label, count }) => [label, count]), + [['A-9 – 10', 2], ['B-001', 1], ['PLT-0999 – 1000', 2], ['TP-X', 1]] + ) + // 位数不同且带补零的编号不是同一段 + assert.equal(groupCodeRanges(['C-09', 'C-010']).length, 2) +}) + +test('粘贴内容按换行、空格与中英文标点切分并去重', () => { + const result = parsePasteTokens('TP-10501\nTP-10502,TP-10503、TP-10501;TP-10504 TP-10505') + assert.deepEqual(result.codes, ['TP-10501', 'TP-10502', 'TP-10503', 'TP-10504', 'TP-10505']) + assert.equal(result.duplicates, 1) + assert.deepEqual(result.errors, []) +}) + +test('区间按起始托盘号的前缀与位数展开', () => { + assert.deepEqual(parsePasteTokens('TP-10512-10514').codes, ['TP-10512', 'TP-10513', 'TP-10514']) + assert.deepEqual(parsePasteTokens('10512-10513').codes, ['10512', '10513']) + assert.deepEqual(parsePasteTokens('PLT-008 ~ PLT-010').codes, ['PLT-008', 'PLT-009', 'PLT-010']) + assert.deepEqual(parsePasteTokens('PLT-008–010').codes, ['PLT-008', 'PLT-009', 'PLT-010']) +}) + +test('普通带连字符的托盘号不会被误当作区间', () => { + assert.deepEqual(parsePasteTokens('TP-10501').codes, ['TP-10501']) + // 右侧不大于左侧时不构成区间 + assert.deepEqual(parsePasteTokens('A1-0001').codes, ['A1-0001']) +}) + +test('超长区间与无法识别的波浪号区间给出错误且不展开', () => { + const result = parsePasteTokens('TP-1-300 TP-5~TP-3 TP-9', { rangeLimit: 200 }) + assert.deepEqual(result.codes, ['TP-9']) + assert.equal(result.errors.length, 2) + assert.match(result.errors[0].message, /最多 200 个/) + assert.match(result.errors[1].message, /无法识别/) +}) + +test('总数超过上限时截断并标记', () => { + const result = parsePasteTokens('T-1-10', { totalLimit: 4 }) + assert.deepEqual(result.codes, ['T-1', 'T-2', 'T-3', 'T-4']) + assert.equal(result.truncated, true) +}) diff --git a/shierfeng-fastapi-frontend/scripts/return-behavior.test.mjs b/shierfeng-fastapi-frontend/scripts/return-behavior.test.mjs new file mode 100644 index 0000000..fa8c5b0 --- /dev/null +++ b/shierfeng-fastapi-frontend/scripts/return-behavior.test.mjs @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { webcrypto } from 'node:crypto' +import { test } from 'node:test' +import * as Vue from 'vue' +import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc' + +// 执行真实 setup、模板和 API,仅替换网络与组件宿主边界。 +const read = path => readFile(new URL(`../src/${path}`, import.meta.url), 'utf8') +const { descriptor } = parse(await read('views/business/return/index.vue')) +const script = compileScript(descriptor, { id: 'pc-return-test', genDefaultAs: '__component' }) +const template = compileTemplate({ source: descriptor.template.content, filename: 'return/index.vue', id: 'pc-return-test', compilerOptions: { bindingMetadata: script.bindings } }) +assert.deepEqual(template.errors, []) +const apiSource = await read('api/business/return.js') +const importText = async path => import(`data:text/javascript,${encodeURIComponent(await read(path))}`) +const scanUtils = await importText('utils/palletScan.js') +const passport = await importText('utils/palletPassport.js') +const docNo = await importText('utils/docNo.js') +const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) => + `const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`) +function loadModule(source, modules) { + const names = [...source.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1]) + return new Function('__modules', `${bindImports(source).replace(/export (function|const) /g, '$1 ')}\nreturn { ${names.join(',')} };`)(modules) +} +const masterDisplay = loadModule(await read('utils/masterDisplay.js'), { + '@/router': { default: { getRoutes: () => [{ path: '/businessOperation/filling' }] } }, + '@/utils/palletPassport': passport +}) +const flush = async () => { for (let index = 0; index < 4; index += 1) await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() } + +const ROW = { + returnId: 13, + returnNo: 'RE-3f8a6d1c0b9e42f7a5d3c8e1b06fc21d', + status: 'REQUESTED', + sourceSiteId: 9, + targetWarehouseId: 7, + sourceSiteName: '南厂', + customerName: '金禾菌业', + targetWarehouseName: '成品一仓', + palletCount: 3, + packageSum: 60, + filledCount: 1, + requestedAt: '2026-10-17T10:20:00', + requesterName: '赵强' +} +const DETAIL = { + ...ROW, + items: [ + { returnItemId: 1, palletId: 101, palletCode: 'TP-10466', remainingPackagesSnapshot: 60, tunnelIdSnapshot: 31, tunnelName: '2 号隧道' }, + { returnItemId: 2, palletId: 102, palletCode: 'TP-10468', remainingPackagesSnapshot: 0, tunnelIdSnapshot: null, tunnelName: null }, + { returnItemId: 3, palletId: 103, palletCode: 'TP-10471', remainingPackagesSnapshot: 0, tunnelIdSnapshot: null, tunnelName: null } + ] +} +const SITES = [ + { id: 9, code: 'JH-S', name: '南厂', customerName: '金禾菌业' }, + { id: 10, code: 'LY-1', name: '一号基地', customerName: '绿源农业' } +] +const WAREHOUSES = [{ id: 7, code: 'WH-NB01', name: '成品一仓' }, { id: 8, code: 'WH-NB02', name: '成品二仓' }] +const SUMMARY = { + pendingCount: 1, pendingPalletCount: 3, pendingFilledCount: 1, pendingPackageSum: 60, oldestPendingAt: '2026-10-17T10:20:00', + monthStart: '2026-10-01', monthConfirmedCount: 2, monthConfirmedPalletCount: 26, monthFilledPalletCount: 4, monthPackageSum: 210, monthAvgConfirmHours: 20 +} +const CONFIRM_PALLETS = Object.fromEntries(DETAIL.items.map(item => [item.palletCode, { palletId: item.palletId, palletCode: item.palletCode, flowState: 'RETURNING', siteId: 9, remainingPackages: item.remainingPackagesSnapshot }])) +const REQUEST_PALLETS = { + 'TP-10475': { palletId: 201, palletCode: 'TP-10475', flowState: 'AT_SITE_PARTIAL', siteId: 9, remainingPackages: 30, tunnelId: 31, traceNo: 'TR-0042' }, + 'TP-10474': { palletId: 202, palletCode: 'TP-10474', flowState: 'AT_SITE_EMPTY', siteId: 9, remainingPackages: 0, tunnelId: null, traceNo: null }, + 'TP-10520': { palletId: 203, palletCode: 'TP-10520', flowState: 'AT_SITE_EMPTY', siteId: 10, remainingPackages: 0, tunnelId: null, traceNo: null } +} + +function defaultScan(token, params) { + if (params.mode === 'confirm') { + const pallet = CONFIRM_PALLETS[token] + return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('扫码托盘不属于该退回单')) + } + if (token === 'TP-10288') return Promise.reject(new Error('托盘当前状态不允许发起退回')) + const pallet = REQUEST_PALLETS[token] + return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('无权访问该托盘')) +} + +const renderer = Vue.createRenderer({ createElement: () => ({}), createText: () => ({}), createComment: () => ({}), insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {}, parentNode: () => null, nextSibling: () => null }) +function flatten(vnode) { + if (Array.isArray(vnode)) return vnode.flatMap(flatten) + if (!vnode || typeof vnode !== 'object') return [] + const children = vnode.children + const slots = children && !Array.isArray(children) && typeof children === 'object' + ? Object.entries(children).filter(([key, value]) => !key.startsWith('_') && typeof value === 'function').flatMap(([, slot]) => flatten(slot({ row: {} }))) : [] + return [vnode, ...flatten(children), ...slots] +} + +function createPage({ request: override, permissions = () => true } = {}) { + const requests = [], messages = [] + const user = Vue.reactive({ admin: false }), batch = Vue.reactive({ isTestMode: false }) + const request = config => { + requests.push(structuredClone(config)) + const custom = override?.(config) + if (custom !== undefined) return custom + if (config.url === '/business/return/list') return Promise.resolve({ rows: [structuredClone(ROW)], total: 1 }) + if (config.url === '/business/return/summary') return Promise.resolve({ data: SUMMARY }) + if (config.url === '/business/return/site-options') return Promise.resolve({ data: SITES }) + if (config.url === '/business/return/warehouse-options') return Promise.resolve({ data: WAREHOUSES }) + if (config.url === '/business/return/13') return Promise.resolve({ data: structuredClone(DETAIL) }) + if (config.url.startsWith('/business/return/scan/')) return defaultScan(decodeURIComponent(config.url.split('/scan/')[1]), config.params) + if (config.url === '/business/return/13/confirm') return Promise.resolve({ msg: '退回确认成功' }) + if (config.url === '/business/return/request') return Promise.resolve({ msg: '退回申请提交成功' }) + throw new Error(`未处理请求 ${config.url}`) + } + const names = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1]) + const api = new Function('__modules', `${bindImports(apiSource).replace(/export function /g, 'function ')}\nreturn { ${names.join(',')} };`)({ '@/utils/request': { default: request } }) + const proxy = { + $auth: { hasPermiAnd: perms => permissions(perms) }, + $modal: { msgSuccess: msg => messages.push(msg), msgWarning: msg => messages.push(msg) }, + resetForm() {} + } + const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), resolveComponent: name => ({ name }), resolveDirective: () => ({}) } + const stub = name => ({ default: { name } }) + const modules = { + vue, + '@/components/PalletScanInput': stub('PalletScanInput'), + '@/components/ScanFeedback': stub('ScanFeedback'), + '@/components/SfIcon': stub('SfIcon'), + '@/components/TestBatchAdoptionDialog': stub('TestBatchAdoptionDialog'), + '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) }, + '@/utils/docNo': docNo, + '@/utils/masterDisplay': masterDisplay, + '@/utils/palletPassport': passport, + '@/store/modules/user': { default: () => user }, + '@/store/modules/testBatch': { default: () => batch }, + '@/api/business/return': api + } + // PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。 + const globals = ['ref', 'reactive', 'computed', 'watch', 'getCurrentInstance'] + const component = new Function('__modules', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, ...globals.map(name => vue[name])) + const scope = Vue.effectScope(), state = scope.run(() => component.setup({}, { expose() {} })) + const render = new Function('__modules', `${bindImports(template.code).replace('export function render', 'function render')}\nreturn render;`)(modules) + return { + state, requests, messages, + listCalls: () => requests.filter(item => item.url === '/business/return/list' && item.params.pageSize !== 1), + async confirmScan(value) { state.handleConfirmScan(value); await flush() }, + async requestScan(value) { state.handleRequestScan(value); await flush() }, + nodes() { + let nodes + const app = renderer.createApp({ render() { nodes = flatten(render({}, [], {}, Vue.proxyRefs(state), {}, {})); return null } }) + app.mount({}); app.unmount() + return nodes + }, + close() { scope.stop() } + } +} +const label = node => flatten(node).filter(child => typeof child.children === 'string').map(child => child.children).join('') +const button = (page, text) => page.nodes().find(node => node.type?.name === 'el-button' && label(node).includes(text)) +const scanInput = page => page.nodes().find(node => node.type?.name === 'PalletScanInput') + +async function openConfirm(page) { + await flush() + page.state.startConfirmFromRow(page.state.returnList.value[0]) + await flush() +} + +async function scanAll(page) { + for (const code of ['TP-10466', 'TP-10468', 'TP-10471']) await page.confirmScan(code) +} + +test('到仓核对:全部对上前禁止确认,非本单托盘不计入,提交前重新读取详情', async () => { + const page = createPage() + await openConfirm(page) + assert.equal(page.state.jobOpen.value, true) + assert.equal(page.state.jobKind.value, 'confirm') + assert.deepEqual(page.state.confirmExpectedPalletIds.value, [101, 102, 103]) + assert.deepEqual(page.state.confirmOutcome.value, { filled: 1, packages: 60, empty: 2, inTunnel: 1 }) + const confirmButton = () => button(page, '确认退回 ·') + assert.equal(confirmButton().props.disabled, true) + + await page.confirmScan('TP-10466') + await page.confirmScan('TP-10468') + assert.equal(confirmButton().props.disabled, true) + assert.equal(page.state.confirmTodoCount.value, 1) + assert.equal(page.state.confirmFeedback.value.message, '核对通过 · 还差 1 托') + + await page.confirmScan('TP-10999') + assert.equal(page.state.confirmScannedPallets.value.length, 2, '非本单托盘不能计入') + assert.deepEqual(page.state.confirmStrays.value.map(item => [item.code, item.message]), [['TP-10999', '扫码托盘不属于该退回单']]) + await page.confirmScan('TP-10466') + assert.equal(page.state.confirmFeedback.value.type, 'info') + assert.equal(page.state.confirmScannedPallets.value.length, 2, '重复扫码不追加') + + await page.confirmScan('TP-10471') + assert.equal(page.state.confirmComplete.value, true) + assert.equal(confirmButton().props.disabled, false) + + const before = page.requests.length + page.state.submitReturnConfirm() + await flush() + const writes = page.requests.slice(before, before + 2) + assert.deepEqual(writes.map(item => `${item.method} ${item.url}`), ['get /business/return/13', 'put /business/return/13/confirm']) + assert.deepEqual(Object.keys(writes[1].data).sort(), ['idempotencyKey', 'scannedPalletIds']) + assert.deepEqual([...writes[1].data.scannedPalletIds].sort(), [101, 102, 103]) + assert.equal(page.state.jobOpen.value, false) + assert.match(page.state.jobResultText.value, /RE-3F8A…C21D 已确认 · 1 托带料(60 包)已回到成品一仓有料库存,2 托空托盘回到成品一仓,可再次装填。/) + page.close() +}) + +test('到仓核对:确认失败后重试复用幂等键,撤销一托后重新锁定确认', async () => { + let attempts = 0 + const page = createPage({ + request: config => { + if (config.url !== '/business/return/13/confirm') return undefined + attempts += 1 + return attempts === 1 ? Promise.reject(new Error('网络异常')) : Promise.resolve({ msg: '退回确认成功' }) + } + }) + await openConfirm(page) + await scanAll(page) + + page.state.removeConfirmPallet(102) + await flush() + assert.equal(page.state.confirmComplete.value, false) + assert.equal(button(page, '确认退回 ·').props.disabled, true) + assert.equal(page.state.confirmFeedback.value.message, '已撤销核对,需要重新扫码') + await page.confirmScan('TP-10468') + + page.state.submitReturnConfirm() + await flush() + assert.match(page.state.confirmError.value, /复用本次幂等键/) + assert.equal(page.state.jobOpen.value, true, '失败时保留作业区与已核对集合') + assert.equal(page.state.confirmScannedPallets.value.length, 3) + page.state.submitReturnConfirm() + await flush() + const keys = page.requests.filter(item => item.method === 'put').map(item => item.data.idempotencyKey) + assert.equal(keys.length, 2) + assert.equal(keys[0], keys[1]) + assert.equal(page.state.jobOpen.value, false) + page.close() +}) + +test('到仓核对:详情已变化时不提交确认', async () => { + let reads = 0 + const page = createPage({ + request: config => { + if (config.url !== '/business/return/13') return undefined + reads += 1 + const detail = structuredClone(DETAIL) + if (reads > 1) detail.items = detail.items.slice(0, 2) + return Promise.resolve({ data: detail }) + } + }) + await openConfirm(page) + await scanAll(page) + page.state.submitReturnConfirm() + await flush() + assert.equal(page.requests.filter(item => item.method === 'put').length, 0) + assert.match(page.state.confirmError.value, /明细已变化/) + page.close() +}) + +test('发起申请:先选仓库再扫码,首托锁定来源现场,跨现场与后端拒绝原因进入被拒记录', async () => { + const page = createPage() + await flush() + page.state.startRequestJob() + await flush() + assert.equal(page.state.jobKind.value, 'request') + assert.deepEqual(page.state.warehouseOptions.value, WAREHOUSES) + assert.equal(scanInput(page).props.disabled, true, '未选目标仓库时扫码框禁用') + + await page.requestScan('TP-10475') + assert.equal(page.requests.filter(item => item.url.includes('/scan/')).length, 0) + assert.equal(page.state.requestFeedback.value.type, 'warn') + + page.state.requestForm.value.targetWarehouseId = 7 + await flush() + assert.equal(scanInput(page).props.disabled, false) + await page.requestScan('TP-10475') + assert.deepEqual(page.requests.filter(item => item.url.includes('/scan/')).at(-1).params, { mode: 'request' }) + assert.equal(page.state.requestForm.value.sourceSiteId, 9) + assert.equal(page.state.requestSiteText.value, '金禾菌业 · 南厂') + assert.equal(page.state.requestFeedback.value.message, '已添加 · 现场部分使用 · 余 30 包') + + await page.requestScan('TP-10520') + assert.equal(page.state.requestRejects.value[0].message, '同一退回申请只能包含同一客户现场的托盘:该托盘在绿源农业 · 一号基地') + await page.requestScan('TP-10288') + assert.deepEqual([page.state.requestRejects.value[0].code, page.state.requestRejects.value[0].message], ['TP-10288', '托盘当前状态不允许发起退回']) + assert.equal(page.state.requestRejectCount.value, 2) + await page.requestScan('TP-10474') + await page.requestScan('TP-10474') + assert.equal(page.state.requestFeedback.value.message, '已在本单中,未重复添加') + assert.deepEqual(page.state.requestScannedPallets.value.map(item => item.palletCode), ['TP-10474', 'TP-10475']) + assert.deepEqual(page.state.requestSummary.value, { count: 2, packages: 30, filled: 1, empty: 1 }) + + page.state.submitReturnRequest() + await flush() + const post = page.requests.find(item => item.method === 'post') + assert.deepEqual(Object.keys(post.data).sort(), ['idempotencyKey', 'palletIds', 'sourceSiteId', 'targetWarehouseId']) + assert.deepEqual([...post.data.palletIds].sort(), [201, 202]) + assert.equal(post.data.sourceSiteId, 9) + assert.equal(post.data.targetWarehouseId, 7) + assert.equal(page.state.jobOpen.value, false) + assert.equal(page.state.jobResultText.value, '退回申请已提交 · 2 托已变为「退回中」,现场不能再使用。成品一仓收到实物后逐托核对确认。') + assert.equal(page.state.activeStatusTab.value, 'requested') + assert.equal(page.listCalls().at(-1).params.status, 'REQUESTED') + page.close() +}) + +test('移除最后一托后释放来源现场,可改扫其他现场', async () => { + const page = createPage() + await flush() + page.state.startRequestJob() + await flush() + page.state.requestForm.value.targetWarehouseId = 7 + await flush() + await page.requestScan('TP-10475') + page.state.removeRequestPallet(201) + await flush() + assert.equal(page.state.requestForm.value.sourceSiteId, undefined) + await page.requestScan('TP-10520') + assert.equal(page.state.requestForm.value.sourceSiteId, 10) + page.close() +}) + +test('列表:关键字、页签与概况来自真实接口,名称与余量按行展示', async () => { + const page = createPage() + await flush() + assert.deepEqual(page.requests.slice(0, 3).map(item => item.url), ['/business/return/summary', '/business/return/site-options', '/business/return/list']) + assert.equal(page.requests.some(item => item.url === '/business/return/warehouse-options'), false, '启动阶段不请求 query 权限接口') + assert.equal(page.state.sourceLabel(ROW), '金禾菌业 · 南厂') + assert.equal(page.state.targetLabel(ROW), '成品一仓') + assert.equal(page.state.packagesText(ROW), '余 60 包') + assert.equal(page.state.packagesText({ packageSum: 0 }), '均已用完') + assert.equal(page.state.returningHint.value, '其中带料 1 托 · 共 60 包') + assert.equal(page.state.monthEmptyCount.value, 22) + assert.equal(page.state.monthHint.value, '平均申请到确认 20 小时') + assert.equal(page.state.statusCounts.all, 1) + + page.state.queryParams.value.keyword = 'TP-104' + page.state.selectStatusTab('confirmed') + await flush() + const last = page.listCalls().at(-1) + assert.equal(last.params.keyword, 'TP-104') + assert.equal(last.params.status, 'CONFIRMED') + assert.equal(last.params.pageNum, 1) + page.close() +}) + +test('无确认权限时详情面板与行内都不提供到仓核对入口', async () => { + const page = createPage({ permissions: perms => !perms.includes('business:return:confirm') }) + await flush() + page.state.openReturnPanel(page.state.returnList.value[0]) + await flush() + assert.equal(page.state.panelItems.value.length, 3) + assert.equal(button(page, '开始到仓核对'), undefined) + page.state.startConfirmFromRow(page.state.returnList.value[0]) + await flush() + assert.equal(page.state.jobOpen.value, false) + assert.equal(page.requests.some(item => item.url.includes('/scan/')), false) + page.close() + + const allowed = createPage() + await flush() + allowed.state.openReturnPanel(allowed.state.returnList.value[0]) + await flush() + assert.ok(button(allowed, '开始到仓核对')) + allowed.close() +}) diff --git a/shierfeng-fastapi-frontend/scripts/usage-behavior.test.mjs b/shierfeng-fastapi-frontend/scripts/usage-behavior.test.mjs index 5c48778..de8a07a 100644 --- a/shierfeng-fastapi-frontend/scripts/usage-behavior.test.mjs +++ b/shierfeng-fastapi-frontend/scripts/usage-behavior.test.mjs @@ -13,8 +13,13 @@ const template = compileTemplate({ source: descriptor.template.content, filename assert.deepEqual(template.errors, []) const apiSource = await read('api/business/usage.js') const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletScan.js'))}`) +const passport = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletPassport.js'))}`) const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) => `const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`) +const masterDisplaySource = await read('utils/masterDisplay.js') +const masterDisplay = new Function('__modules', `${bindImports(masterDisplaySource).replace(/export (function|const) /g, '$1 ')}\nreturn { ${[...masterDisplaySource.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1]).join(',')} };`)({ '@/router': { default: { getRoutes: () => [] } }, '@/utils/palletPassport': passport }) +// 看板默认不含隧道和产线,避免看板名称掩盖托盘摘要中的隧道断言 +const siteBoard = { siteId: 21, siteCode: 'XC-21', siteName: '一号现场', customerName: '客户A', palletCount: 0, packageSum: 0, unusedPalletCount: 0, partialPalletCount: 0, emptyPalletCount: 0, todayUsageCount: 0, todayUsedPackages: 0, todayByLine: [], tunnelCount: 0, activeTunnelCount: 0, todayFifoSkipCount: 0, weekFifoSkipCount: 0, tunnels: [], lines: [] } const flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() } function deferred() { let resolve, reject @@ -46,6 +51,9 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma if (config.url.endsWith('/line-options')) return Promise.resolve({ data: lines }) if (config.url.endsWith('/recommendation')) return Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: '请按实际接收时间先到先用' } }) if (config.url.endsWith('/list')) return Promise.resolve({ rows: [], total: 0 }) + if (config.url === '/business/usage/site-options') return Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }] }) + if (config.url === '/business/usage/site-board') return Promise.resolve({ data: siteBoard }) + if (config.url === '/business/usage-fifo/queue') return Promise.resolve({ data: { siteId: 21, strains: [] } }) if (config.url.endsWith('/enter-tunnel')) return Promise.resolve({ data: { palletId: 11, tunnelId: config.data.tunnelId, traceNo: 'INTERNAL-TRACE' } }) if (config.url === '/business/usage') return Promise.resolve({ data: { usageId: 71, remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' } }) throw new Error(`未处理请求 ${config.url}`) @@ -63,7 +71,7 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma confirm(message) { confirmations.push(message); return confirm(message) } }, resetForm() {}, $refs: { usageRef: { validate: callback => callback(true) } } } const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), ...Object.fromEntries(Object.keys(hooks).map(name => [name, fn => hooks[name].push(fn)])), resolveComponent: name => ({ name }), resolveDirective: () => ({}) } - const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } } + const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/ScanFeedback': { default: { name: 'ScanFeedback' } }, '@/components/SfIcon': { default: { name: 'SfIcon' } }, '@/utils/masterDisplay': masterDisplay, '@/utils/palletPassport': passport, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } } // PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。 const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance', 'onActivated', 'onDeactivated', 'onBeforeUnmount'] const component = new Function('__modules', 'localStorage', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, localStorage, ...globals.map(name => vue[name])) @@ -95,7 +103,8 @@ async function retry(page) { test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => { const page = createPage() await page.api.scanUsagePallet('托盘/A?#') - assert.equal(page.requests.at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23') + // 页面启动后还会读取现场看板,按路径取最近一次扫码请求再核对编码 + assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23') await page.scan('印刷托盘号') assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } }) page.close() @@ -753,3 +762,121 @@ test('F3 真正更换隧道可清除原目标必填,旧推荐不能恢复它', assert.equal(page.state.tunnelLocked.value, true) page.close() }) + +const twoSites = config => config.url === '/business/usage/site-options' + ? Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }, { id: 22, code: 'XC-22', name: '二号现场', customerName: '客户A', tunnelCount: 0, lineCount: 0 }] }) + : undefined + +test('现场看板默认选第一个可访问现场,只有主动切换才记住,记住的现场用于首次查询', async () => { + const storage = new Map() + const page = createPage({ storage, request: twoSites }) + await flush() + assert.deepEqual(page.requests.find(item => item.url === '/business/usage/site-board').params, { siteId: 21 }) + assert.deepEqual(page.requests.find(item => item.url === '/business/usage-fifo/queue').params, { siteId: 21 }) + assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21) + assert.equal(storage.size, 0, '自动选择不写入本机偏好') + page.state.handleSiteChange(22); await flush() + assert.equal(storage.get('business:usage:site:v1:7'), '22') + assert.deepEqual(page.requests.filter(item => item.url === '/business/usage/site-board').at(-1).params, { siteId: 22 }) + page.close() + + const again = createPage({ storage, request: twoSites }) + assert.equal(again.requests.find(item => item.url === '/business/usage/list').params.siteId, 22, '首次查询直接使用记住的现场') + await flush() + assert.equal(again.state.selectedSiteId.value, 22) + again.close() + const otherUser = createPage({ storage, userId: 8, request: twoSites }) + await flush() + assert.equal(otherUser.state.selectedSiteId.value, 21, '偏好按账号隔离') + otherUser.close() +}) + +test('偏好写入失败不影响切换,读取到已无权访问的现场时回到第一个现场', async () => { + const storage = new Map([['business:usage:site:v1:7', '99']]) + const page = createPage({ storage, storageWriteError: true }) + assert.equal(page.requests.find(item => item.url === '/business/usage/list').params.siteId, 99) + await flush() + assert.equal(page.state.selectedSiteId.value, 21) + assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21) + page.state.handleSiteChange(21); await flush() + assert.equal(storage.get('business:usage:site:v1:7'), '99') + assert.equal(page.state.storageError.value, '') + page.close() +}) + +test('隧道卡片切换记录筛选,看板只展示隧道在用情况不展示溯源号', async () => { + const board = { ...siteBoard, tunnelCount: 1, activeTunnelCount: 1, tunnels: [{ tunnelId: 51, tunnelCode: 'KB-01', tunnelName: '看板隧道', inUse: true, palletCount: 2, remainingPackages: 150, startedAt: '2026-10-17T08:00:00', lastUsedAt: '2026-10-17T10:00:00', lastLineName: '看板产线' }] } + const page = createPage({ request: config => config.url === '/business/usage/site-board' ? Promise.resolve({ data: board }) : undefined }) + await flush() + assert.match(text(page), /看板隧道/) + assert.doesNotMatch(text(page), /INTERNAL-TRACE|溯源号/) + assert.equal(page.state.tunnelFill(board.tunnels[0]), '75%') + page.state.toggleTunnelFilter(51); await flush() + assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, 51) + page.state.toggleTunnelFilter(51); await flush() + assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, undefined) + page.close() +}) + +test('无查询权限不读取看板,无列表权限不读取现场选项', async () => { + const page = createPage({ canOperate: false }) + await flush() + assert.equal(page.requests.some(item => ['/business/usage/site-options', '/business/usage/site-board', '/business/usage-fifo/queue'].includes(item.url)), false) + page.state.openWork() + assert.equal(page.state.workOpen.value, false) + page.close() +}) + +test('作业步骤由扫码、入隧和登记结果推导,快捷包数与预览按当前余量计算', async () => { + const page = createPage() + await flush() + page.state.openWork() + assert.equal(page.state.workOpen.value, true) + assert.equal(page.state.workStep.value, 1) + await page.scan() + assert.equal(page.state.workStep.value, 2) + page.state.selectTunnel(31) + assert.equal(page.state.form.value.tunnelId, 31) + assert.equal(page.state.selectedTunnelName.value, '一号隧道') + await page.act('enterTunnel') + assert.equal(page.state.workStep.value, 3) + page.state.selectTunnel(32) + assert.equal(page.state.form.value.tunnelId, 31, '入隧后不能再切换隧道') + assert.deepEqual(page.state.quickAmounts.value.map(item => item.label), ['10', '20', '50', '全部 100']) + page.state.form.value.usedPackages = 30 + assert.deepEqual({ ...page.state.usagePreview.value }, { text: '提交后余 70 包', bad: false }) + page.state.form.value.usedPackages = 120 + assert.equal(page.state.usagePreview.value.bad, true) + page.state.form.value.usedPackages = 17 + const boards = page.requests.filter(item => item.url === '/business/usage/site-board').length + await page.act('submitUsage') + assert.equal(page.state.workStep.value, 4) + assert.equal(page.requests.filter(item => item.url === '/business/usage/site-board').length, boards + 1, '登记成功后刷新看板') + page.state.continueSamePallet(); await flush() + assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/QR-001') + page.state.closeWork(); await flush() + assert.equal(page.state.workOpen.value, false) + assert.equal(page.state.scannedPallet.value, null) + page.close() +}) + +test('先到先用:可一键改用建议托盘,快捷原因写入同一原因字段', async () => { + const page = createPage({ request: config => config.url.endsWith('/recommendation') + ? Promise.resolve({ data: { recommended: { palletId: 9, palletCode: '较早托盘', batchNo: '较早批次', remainingPackages: 25, siteArrivedAt: '2026-10-01 09:00:00' }, unknownArrivalCount: 0, requiresSkipReason: true, message: '请填写跳过原因' } }) + : undefined }) + await page.scan() + assert.equal(page.state.recommendedOther.value, true) + assert.ok(button(page, '改用 较早托盘')) + page.state.pickFifoReason('建议托盘取不出') + assert.equal(page.state.form.value.fifoSkipReason, '建议托盘取不出') + assert.equal(page.state.isReasonActive('建议托盘取不出'), true) + page.state.pickFifoReason('其他') + assert.equal(page.state.form.value.fifoSkipReason, '') + page.state.form.value.fifoSkipReason = '叉车暂时取不出' + assert.equal(page.state.isReasonActive('其他'), true) + page.state.pickFifoReason('其他') + assert.equal(page.state.form.value.fifoSkipReason, '叉车暂时取不出', '自填原因不被「其他」清空') + page.state.useRecommendedPallet(); await flush() + assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, `/business/usage/scan/${encodeURIComponent('较早托盘')}`) + page.close() +}) diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index 395e50f..ae1f21e 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -91,13 +91,15 @@ const expectedRequests = { { url: "'/master/site'", method: 'post' }, { url: "'/master/site'", method: 'put' }, { url: "'/master/site/' + siteId + '/users'", method: 'get' }, - { url: "'/master/site/' + siteId + '/users'", method: 'put' } + { url: "'/master/site/' + siteId + '/users'", method: 'put' }, + { url: "'/master/site/customer-options'", method: 'get' } ], 'src/api/master/line.js': [ { url: "'/master/line/list'", method: 'get' }, { url: "'/master/line/' + lineId", method: 'get' }, { url: "'/master/line'", method: 'post' }, { url: "'/master/line'", method: 'put' }, + { url: "'/master/line/site-options'", method: 'get' } ], 'src/api/master/warehouse.js': [ { url: "'/master/warehouse/list'", method: 'get' }, @@ -119,9 +121,11 @@ const expectedRequests = { { url: "'/master/tunnel/' + tunnelId", method: 'get' }, { url: "'/master/tunnel'", method: 'post' }, { url: "'/master/tunnel'", method: 'put' }, + { url: "'/master/tunnel/site-options'", method: 'get' } ], 'src/api/business/inbound.js': [ { url: "'/business/inbound/list'", method: 'get' }, + { url: "'/business/inbound/summary'", method: 'get' }, { url: "'/business/inbound/warehouse-options'", method: 'get' }, { url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' }, { url: "'/business/inbound/' + inboundId", method: 'get' }, @@ -131,6 +135,9 @@ const expectedRequests = { ], 'src/api/business/filling.js': [ { url: "'/business/filling/list'", method: 'get' }, + { url: "'/business/filling/summary'", method: 'get' }, + { url: "'/business/filling/batches'", method: 'get' }, + { url: "'/business/filling/batches/detail'", method: 'get' }, { url: "'/business/filling/strain-options'", method: 'get' }, { url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' }, { url: "'/business/filling/' + fillingId", method: 'get' }, @@ -151,6 +158,9 @@ const expectedRequests = { { url: "'/business/usage/list'", method: 'get' }, { url: "'/business/usage/line-options'", method: 'get' }, { url: "'/business/usage/tunnel-options'", method: 'get' }, + { url: "'/business/usage/site-options'", method: 'get' }, + { url: "'/business/usage/site-board'", method: 'get' }, + { url: "'/business/usage-fifo/queue'", method: 'get' }, { url: "'/business/usage/enter-tunnel'", method: 'post' }, { url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' }, { url: "'/business/usage-fifo/recommendation'", method: 'get' }, @@ -159,6 +169,8 @@ const expectedRequests = { ], 'src/api/business/return.js': [ { url: "'/business/return/list'", method: 'get' }, + { url: "'/business/return/summary'", method: 'get' }, + { url: "'/business/return/site-options'", method: 'get' }, { url: "'/business/return/warehouse-options'", method: 'get' }, { url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' }, { url: "'/business/return/request'", method: 'post' }, @@ -211,14 +223,14 @@ const masterPageContracts = { }, site: { relativePath: 'src/views/master/site/index.vue', - apiNames: ['addSite', 'getSite', 'listSite', 'updateSite'], + apiNames: ['addSite', 'getSite', 'getSiteCustomerOptions', 'listSite', 'updateSite'], permissions: ['add', 'assign', 'edit', 'list', 'query'], 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', 'getLine', 'listLine', 'updateLine'], + apiNames: ['addLine', 'getLine', 'getLineSiteOptions', 'listLine', 'updateLine'], permissions: ['add', 'edit', 'list', 'query'], addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'], editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status'] @@ -246,6 +258,7 @@ const businessPageContracts = { 'addInbound', 'confirmInbound', 'getInbound', + 'getInboundSummary', 'getInboundWarehouseOptions', 'listInbound', 'scanInboundPallet', @@ -260,7 +273,15 @@ const businessPageContracts = { }, filling: { relativePath: 'src/views/business/filling/index.vue', - apiNames: ['addFilling', 'getFillingStrainOptions', 'listFilling', 'scanFillingPallet'], + apiNames: [ + 'addFilling', + 'getFillingBatchDetail', + 'getFillingStrainOptions', + 'getFillingSummary', + 'listFilling', + 'listFillingBatches', + 'scanFillingPallet' + ], permissions: ['add', 'list', 'query'], payloads: { addFilling: [ @@ -298,7 +319,18 @@ const businessPageContracts = { }, usage: { relativePath: 'src/views/business/usage/index.vue', - apiNames: ['addUsage', 'enterUsageTunnel', 'getUsageFifoRecommendation', 'getUsageLineOptions', 'getUsageTunnelOptions', 'listUsage', 'scanUsagePallet'], + apiNames: [ + 'addUsage', + 'enterUsageTunnel', + 'getUsageFifoQueue', + 'getUsageFifoRecommendation', + 'getUsageLineOptions', + 'getUsageSiteBoard', + 'getUsageSiteOptions', + 'getUsageTunnelOptions', + 'listUsage', + 'scanUsagePallet' + ], permissions: ['add', 'list', 'query'], // 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。 payloads: {} @@ -308,6 +340,8 @@ const businessPageContracts = { apiNames: [ 'confirmReturn', 'getReturn', + 'getReturnSiteOptions', + 'getReturnSummary', 'getReturnWarehouseOptions', 'listReturn', 'requestReturn', @@ -1538,7 +1572,7 @@ test('首次入库确认失败后取消重试不丢弃原幂等键', async () => test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => { assert.equal(Object.keys(expectedRequests).length, 18) - assert.equal(Object.values(expectedRequests).flat().length, 74) + assert.equal(Object.values(expectedRequests).flat().length, 86) for (const [relativePath, expected] of Object.entries(expectedRequests)) { await t.test(relativePath, async () => { @@ -2923,3 +2957,20 @@ test('三个业务菜单公共样式统一使用 Element Plus 主题变量', asy assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/) assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/) }) + +test('业务页面导入的组件不被同名响应式状态遮蔽', async (t) => { + // 非 .vue 路径导入的组件按 maybe-ref 绑定,模板标签会优先解析为同名 camelCase 状态(如 scanFeedback 遮蔽 ScanFeedback) + const { compileScript, parse } = await import('@vue/compiler-sfc') + for (const [resource, contract] of Object.entries(businessPageContracts)) { + await t.test(resource, async () => { + const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8') + const { descriptor } = parse(source) + const compiled = compileScript(descriptor, { id: `contract-${resource}`, inlineTemplate: true }).content + const components = [...source.matchAll(/^import\s+([A-Z]\w*)\s+from\s+['"]@\/components\/[^'"]+['"]/gm)].map((match) => match[1]) + for (const component of components) { + const shadow = component[0].toLowerCase() + component.slice(1) + assert.doesNotMatch(compiled, new RegExp(`_create(?:VNode|Block)\\(_unref\\(${shadow}\\)`), `${component} 被同名状态 ${shadow} 遮蔽`) + } + }) + } +}) diff --git a/shierfeng-fastapi-frontend/scripts/verify-dashboard-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-dashboard-contract.mjs index 003719d..3287232 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-dashboard-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-dashboard-contract.mjs @@ -8,27 +8,27 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)) const dashboardPath = path.resolve(scriptDir, '../src/views/dashboard/index.vue') const dashboardSource = fs.readFileSync(dashboardPath, 'utf8') -test('首页包含仓储运营总览的核心模块', () => { +test('首页工作台包含扫码入口、流转看板与核心运营模块', () => { for (const label of [ - '仓储运营总览', - '当前库存托盘', - '剩余包数', - '今日入库单', - '今日出库单', - '待确认单据', - '新增入库', - '托盘填充', + '扫描托盘码', + '快捷作业', + '新建作业', + '在库托盘', + '客户现场在用', + '今日发货', + '待处理', + '托盘流转', + '装填与发货', + '临期预警', + '仓库库存', + '最近动态', + '仓库入库', + '工厂装填', '发货出库', - '托盘使用', - '托盘退回', - '托盘追踪', - '近 7 天入库 / 出库趋势', - '托盘流转状态分布', - '仓库库存排行', - '最近入库单', - '最近出库单', - '扫码异常', - '待确认业务' + '现场使用', + '退回仓库', + '托盘追溯', + '扫码异常' ]) { assert.match(dashboardSource, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) } diff --git a/shierfeng-fastapi-frontend/src/api/business/filling.js b/shierfeng-fastapi-frontend/src/api/business/filling.js index 5b383cc..419f967 100644 --- a/shierfeng-fastapi-frontend/src/api/business/filling.js +++ b/shierfeng-fastapi-frontend/src/api/business/filling.js @@ -9,6 +9,32 @@ export function listFilling(query) { }) } +// 查询工厂装填概况 +export function getFillingSummary() { + return request({ + url: '/business/filling/summary', + method: 'get' + }) +} + +// 查询按生产批次汇总的装填列表 +export function listFillingBatches(query) { + return request({ + url: '/business/filling/batches', + method: 'get', + params: query + }) +} + +// 查询生产批次详情 +export function getFillingBatchDetail(query) { + return request({ + url: '/business/filling/batches/detail', + method: 'get', + params: query + }) +} + // 查询装填菌种选项 export function getFillingStrainOptions() { return request({ diff --git a/shierfeng-fastapi-frontend/src/api/business/inbound.js b/shierfeng-fastapi-frontend/src/api/business/inbound.js index 9720557..c92aa0f 100644 --- a/shierfeng-fastapi-frontend/src/api/business/inbound.js +++ b/shierfeng-fastapi-frontend/src/api/business/inbound.js @@ -9,6 +9,14 @@ export function listInbound(query) { }) } +// 查询首次入库概况 +export function getInboundSummary() { + return request({ + url: '/business/inbound/summary', + method: 'get' + }) +} + // 查询首次入库仓库选项 export function getInboundWarehouseOptions() { return request({ diff --git a/shierfeng-fastapi-frontend/src/api/business/return.js b/shierfeng-fastapi-frontend/src/api/business/return.js index 779bbac..1e6ce5e 100644 --- a/shierfeng-fastapi-frontend/src/api/business/return.js +++ b/shierfeng-fastapi-frontend/src/api/business/return.js @@ -9,6 +9,22 @@ export function listReturn(query) { }) } +// 查询托盘退回概况 +export function getReturnSummary() { + return request({ + url: '/business/return/summary', + method: 'get' + }) +} + +// 查询退回来源现场选项 +export function getReturnSiteOptions() { + return request({ + url: '/business/return/site-options', + method: 'get' + }) +} + // 查询退回目标仓库选项 export function getReturnWarehouseOptions() { return request({ diff --git a/shierfeng-fastapi-frontend/src/api/business/usage.js b/shierfeng-fastapi-frontend/src/api/business/usage.js index 4357c99..47c7f42 100644 --- a/shierfeng-fastapi-frontend/src/api/business/usage.js +++ b/shierfeng-fastapi-frontend/src/api/business/usage.js @@ -27,6 +27,32 @@ export function getUsageTunnelOptions(siteId) { }) } +// 查询当前用户可访问的启用现场 +export function getUsageSiteOptions() { + return request({ + url: '/business/usage/site-options', + method: 'get' + }) +} + +// 查询现场看板(库存、今日用量、隧道) +export function getUsageSiteBoard(siteId) { + return request({ + url: '/business/usage/site-board', + method: 'get', + params: { siteId } + }) +} + +// 查询现场待使用托盘队列,与先到先用建议同序 +export function getUsageFifoQueue(siteId) { + return request({ + url: '/business/usage-fifo/queue', + method: 'get', + params: { siteId } + }) +} + // 扫码入隧 export function enterUsageTunnel(data) { return request({ diff --git a/shierfeng-fastapi-frontend/src/api/master/line.js b/shierfeng-fastapi-frontend/src/api/master/line.js index 73bd06e..a5147c8 100644 --- a/shierfeng-fastapi-frontend/src/api/master/line.js +++ b/shierfeng-fastapi-frontend/src/api/master/line.js @@ -34,3 +34,11 @@ export function updateLine(data) { data: data }) } + +// 查询新增或修改产线时可选的现场 +export function getLineSiteOptions() { + return request({ + url: '/master/line/site-options', + method: 'get' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/master/site.js b/shierfeng-fastapi-frontend/src/api/master/site.js index bce1254..d950195 100644 --- a/shierfeng-fastapi-frontend/src/api/master/site.js +++ b/shierfeng-fastapi-frontend/src/api/master/site.js @@ -51,3 +51,11 @@ export function assignSiteUsers(siteId, data) { data: data }) } + +// 查询新增或修改现场时可选的客户 +export function getSiteCustomerOptions() { + return request({ + url: '/master/site/customer-options', + method: 'get' + }) +} diff --git a/shierfeng-fastapi-frontend/src/api/master/tunnel.js b/shierfeng-fastapi-frontend/src/api/master/tunnel.js index e0413cf..0d73470 100644 --- a/shierfeng-fastapi-frontend/src/api/master/tunnel.js +++ b/shierfeng-fastapi-frontend/src/api/master/tunnel.js @@ -34,3 +34,11 @@ export function updateTunnel(data) { data: data }) } + +// 查询新增或修改隧道时可选的现场 +export function getTunnelSiteOptions() { + return request({ + url: '/master/tunnel/site-options', + method: 'get' + }) +} diff --git a/shierfeng-fastapi-frontend/src/assets/styles/index.scss b/shierfeng-fastapi-frontend/src/assets/styles/index.scss index f0af73a..a8a821b 100644 --- a/shierfeng-fastapi-frontend/src/assets/styles/index.scss +++ b/shierfeng-fastapi-frontend/src/assets/styles/index.scss @@ -6,6 +6,8 @@ @use './shierfeng.scss'; @use './data-pages.scss'; +@use './sf-shell.scss'; +@use './sf-master.scss'; body { height: 100%; diff --git a/shierfeng-fastapi-frontend/src/assets/styles/sf-master.scss b/shierfeng-fastapi-frontend/src/assets/styles/sf-master.scss new file mode 100644 index 0000000..c8c5831 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/styles/sf-master.scss @@ -0,0 +1,1097 @@ +// ========================================== +// 基础资料维护与业务作业页面共用的列表样式 +// 与 sf-shell 一样只在登录后的布局内生效。 +// ========================================== + +html.sf-shell .master-page, +html.sf-shell .business-page { + // ---------- 页头操作栏(master-toolbar 放在页头右侧) ---------- + .sf-page-header__actions .master-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0 !important; + + > .el-col { + flex: none; + max-width: none; + width: auto; + padding: 0 !important; + } + + .el-button + .el-button { + margin-left: 0; + } + + .mp-primary-col { + order: 10; + } + + .top-right-btn { + order: -1; + float: none; + margin: 0 4px 0 0; + + .el-row { + flex-wrap: nowrap; + gap: 4px; + } + + .el-button { + margin: 0; + border-color: transparent; + background: transparent; + color: var(--sf-text-3); + + &:hover { + background: var(--sf-surface-3); + color: var(--sf-text-1); + } + } + } + } + + // ---------- 指标条 ---------- + .mp-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1px; + margin: 0 0 20px; + overflow: hidden; + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-lg); + background: var(--sf-border); + } + + .mp-kpi { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + padding: 16px 20px; + background: var(--sf-surface); + } + + .mp-kpi__label { + color: var(--sf-text-3); + font-size: 13px; + } + + .mp-kpi__value { + display: flex; + align-items: baseline; + gap: 6px; + color: var(--sf-text-1); + font-size: 26px; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + + small { + color: var(--sf-text-3); + font-size: 13px; + font-weight: 400; + letter-spacing: 0; + } + + &.is-warn { + color: var(--sf-warn-fg); + } + } + + .mp-kpi__hint { + overflow: hidden; + color: var(--sf-text-4); + font-size: 12.5px; + white-space: nowrap; + text-overflow: ellipsis; + } + + // ---------- 状态页签 ---------- + .mp-tabs { + display: flex; + gap: 22px; + margin: 0 0 16px; + overflow-x: auto; + border-bottom: 1px solid var(--sf-border-soft); + scrollbar-width: none; + } + + .mp-tab { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + height: 40px; + margin-bottom: -1px; + padding: 0 2px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--sf-text-3); + font: inherit; + font-size: 13.5px; + cursor: pointer; + + &:hover { + color: var(--sf-text-1); + } + + &.is-active { + border-bottom-color: var(--sf-text-1); + color: var(--sf-text-1); + font-weight: 600; + } + } + + .mp-tab__count { + padding: 0 6px; + border-radius: 9px; + background: var(--sf-surface-3); + color: var(--sf-text-3); + font-size: 12px; + font-weight: 500; + font-variant-numeric: tabular-nums; + } + + // ---------- 筛选条(el-form.master-query) ---------- + .master-query.mp-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 10px; + margin: 0 0 14px; + padding: 0; + border: 0; + background: transparent; + + .el-form-item { + width: auto; + margin: 0 0 10px; + } + + .el-form-item__label { + padding-right: 8px; + color: var(--sf-text-4); + font-size: 13px; + } + + .mp-filter__search { + flex: 1 1 260px; + max-width: 380px; + + .el-form-item__content, + .el-input { + width: 100%; + } + } + + .mp-filter__select .el-select { + width: 150px !important; + } + + .mp-filter__summary { + margin: 0 2px 10px auto; + color: var(--sf-text-4); + font-size: 12.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + } + + // ---------- 列表 + 详情 ---------- + .mp-workspace { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 20px; + } + + .mp-workspace__list { + flex: 999 1 560px; + min-width: 0; + + .el-table__row { + cursor: pointer; + } + + .el-table__row.is-panel-row > td.el-table__cell { + background: var(--sf-accent-soft) !important; + } + + .el-table__row.is-muted-row > td.el-table__cell .cell { + opacity: 0.62; + } + + .nowrap-cell .cell { + white-space: nowrap; + } + } + + .mp-workspace.is-wide-panel .mp-workspace__list { + flex-basis: 380px; + } + + .mp-panel { + position: sticky; + top: 72px; + display: flex; + flex: 1 1 360px; + flex-direction: column; + max-width: 100%; + max-height: calc(100vh - 96px); + min-width: 0; + overflow: hidden; + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-lg); + background: var(--sf-surface); + } + + .mp-workspace.is-wide-panel .mp-panel { + flex: 999 1 560px; + } + + .mp-panel__head { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 14px 14px 18px; + border-bottom: 1px solid var(--sf-border-soft); + } + + .mp-panel__title { + display: flex; + flex: 1; + flex-direction: column; + gap: 6px; + min-width: 0; + } + + .mp-panel__name { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + color: var(--sf-text-1); + font-size: 17px; + font-weight: 600; + line-height: 1.35; + } + + .mp-panel__sub { + color: var(--sf-text-3); + font-size: 12.5px; + line-height: 1.5; + } + + .mp-panel__close { + flex: none; + width: 30px; + height: 30px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--sf-text-3); + font-size: 20px; + line-height: 1; + cursor: pointer; + + &:hover { + background: var(--sf-surface-3); + color: var(--sf-text-1); + } + } + + .mp-panel__body { + display: flex; + flex: 1; + flex-direction: column; + gap: 18px; + min-height: 0; + overflow-y: auto; + padding: 16px 18px; + + // 内容超出面板高度时整体滚动,子块保持原有高度 + > * { + flex-shrink: 0; + } + } + + .mp-panel__foot { + display: flex; + flex-wrap: wrap; + row-gap: 8px; + align-items: center; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid var(--sf-border-soft); + + .el-button + .el-button { + margin-left: 0; + } + + .mp-panel__foot-end { + margin-left: auto; + } + } + + .mp-section { + display: flex; + flex-direction: column; + gap: 10px; + } + + .mp-section__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + + strong { + color: var(--sf-text-1); + font-size: 13.5px; + font-weight: 600; + } + + span, + a { + color: var(--sf-text-4); + font-size: 12px; + } + + a { + color: var(--sf-accent); + font-size: 12.5px; + font-weight: 500; + } + } + + // 三格统计 + .mp-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--sf-border-soft); + border-radius: var(--sf-radius-md); + background: var(--sf-border-soft); + + > div { + display: flex; + flex-direction: column; + min-width: 0; + padding: 10px 12px; + background: var(--sf-surface); + } + + span { + color: var(--sf-text-4); + font-size: 12px; + } + + strong { + color: var(--sf-text-1); + font-size: 16px; + font-weight: 600; + font-variant-numeric: tabular-nums; + + &.is-warn { + color: var(--sf-warn-fg); + } + } + } + + // 定义列表 + .mp-dl { + display: grid; + grid-template-columns: auto 1fr; + margin: 0; + font-size: 13px; + + dt, + dd { + margin: 0; + padding: 7px 0; + border-bottom: 1px solid var(--sf-border-soft); + line-height: 1.5; + } + + dt { + padding-right: 16px; + color: var(--sf-text-3); + } + + dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + color: var(--sf-text-1); + text-align: right; + } + + dt:last-of-type, + dd:last-of-type { + border-bottom: 0; + } + } + + // 时间线 + .mp-timeline { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; + } + + .mp-timeline__item { + display: flex; + gap: 10px; + + &:last-child .mp-timeline__rail::after { + display: none; + } + } + + .mp-timeline__rail { + position: relative; + display: flex; + flex: none; + justify-content: center; + width: 10px; + + &::before { + content: ''; + z-index: 1; + width: 8px; + height: 8px; + margin-top: 6px; + border: 2px solid var(--mp-dot, var(--sf-neutral-dot)); + border-radius: 50%; + background: var(--sf-surface); + } + + &::after { + content: ''; + position: absolute; + top: 16px; + bottom: 0; + width: 1px; + background: var(--sf-border-soft); + } + } + + .mp-timeline__text { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + padding-bottom: 10px; + line-height: 1.45; + + > span:first-child { + display: flex; + align-items: baseline; + gap: 8px; + } + + strong { + color: var(--sf-text-1); + font-size: 13px; + font-weight: 500; + } + + small { + color: var(--sf-text-4); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + p { + margin: 0; + color: var(--sf-text-3); + font-size: 12.5px; + } + } + + // ---------- 单元格 ---------- + .mp-code { + padding: 0; + border: 0; + background: transparent; + color: var(--sf-text-1); + font-family: var(--sf-font-mono); + font-size: 13px; + font-weight: 500; + text-align: left; + cursor: pointer; + + &:hover { + color: var(--sf-accent); + } + } + + .mp-name-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + .mp-name-cell__text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.4; + + strong { + overflow: hidden; + color: var(--sf-text-1); + font-size: 13.5px; + font-weight: 500; + white-space: nowrap; + text-overflow: ellipsis; + } + + small { + overflow: hidden; + color: var(--sf-text-4); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .mp-initial { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 9px; + background: var(--sf-accent-soft); + color: var(--sf-accent); + font-size: 13px; + font-weight: 600; + + &.is-off { + background: var(--sf-surface-3); + color: var(--sf-text-4); + } + + &.is-lg { + width: 44px; + height: 44px; + border-radius: 12px; + font-size: 17px; + } + } + + .mp-muted { + color: var(--sf-text-4); + } + + .mp-mono { + font-family: var(--sf-font-mono); + font-size: 12.5px; + } + + .mp-num { + font-variant-numeric: tabular-nums; + } + + .mp-link { + padding: 0; + border: 0; + background: transparent; + color: var(--sf-accent); + font: inherit; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + + &:hover { + color: var(--sf-accent-strong); + text-decoration: underline; + } + } + + .mp-count-cell { + display: inline-flex; + flex-wrap: wrap; + gap: 4px 12px; + color: var(--sf-text-2); + font-size: 12.5px; + font-variant-numeric: tabular-nums; + + strong { + color: var(--sf-text-1); + font-weight: 600; + } + } + + // 状态胶囊:圆点 + 文本 + .mp-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 8px; + border-radius: 6px; + background: var(--sf-surface-3); + color: var(--sf-text-2); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + + &::before { + content: ''; + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--sf-neutral-dot); + } + + &.is-ok, + &.is-wh { + background: var(--sf-accent-soft); + color: var(--sf-accent-strong); + + &::before { + background: var(--sf-accent); + } + } + + &.is-transit { + background: var(--sf-transit-bg); + color: var(--sf-transit-fg); + + &::before { + background: var(--sf-transit); + } + } + + &.is-site { + background: var(--sf-site-bg); + color: var(--sf-site-fg); + + &::before { + background: var(--sf-site); + } + } + + &.is-return, + &.is-warn { + background: var(--sf-warn-bg); + color: var(--sf-warn-fg); + + &::before { + background: var(--sf-warn-dot); + } + } + + &.is-danger { + background: var(--sf-danger-bg); + color: var(--sf-danger-fg); + + &::before { + background: var(--sf-danger-dot); + } + } + + &.is-outline { + border: 1px solid var(--sf-border); + background: transparent; + color: var(--sf-text-3); + + &::before { + display: none; + } + } + } + + // 余量条 + .mp-meter { + display: inline-flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + font-size: 12.5px; + font-variant-numeric: tabular-nums; + } + + .mp-meter__bar { + width: 56px; + height: 4px; + overflow: hidden; + border-radius: 2px; + background: var(--sf-border-soft); + + > span { + display: block; + height: 100%; + border-radius: 2px; + background: var(--sf-accent); + } + } + + // 头像组 + .mp-avatars { + display: inline-flex; + align-items: center; + padding-left: 6px; + } + + .mp-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-left: -6px; + border: 2px solid var(--sf-surface); + border-radius: 50%; + background: var(--mp-avatar, #5b616e); + color: #ffffff; + font-size: 11px; + font-weight: 600; + + &.is-more { + background: var(--sf-surface-3); + color: var(--sf-text-3); + } + } + + // 堆叠占比条 + .mp-stack { + display: flex; + gap: 2px; + height: 8px; + overflow: hidden; + border-radius: 4px; + background: var(--sf-surface-3); + + > span { + min-width: 0; + height: 100%; + } + } + + .mp-legend { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + color: var(--sf-text-3); + font-size: 12.5px; + font-variant-numeric: tabular-nums; + + > span { + display: inline-flex; + align-items: center; + gap: 6px; + } + + i { + width: 8px; + height: 8px; + border-radius: 2px; + } + + b { + color: var(--sf-text-1); + font-weight: 600; + } + } + + // ---------- 多选浮动操作条 ---------- + .mp-bulk { + position: sticky; + bottom: 20px; + z-index: 10; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + width: fit-content; + max-width: 100%; + margin: 14px auto 0; + padding: 6px 6px 6px 16px; + border-radius: var(--sf-radius-lg); + background: #15171c; + box-shadow: 0 12px 32px rgba(16, 24, 40, 0.24); + color: #ffffff; + font-size: 13px; + + .mp-bulk__count { + margin-right: 8px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .el-button { + --el-button-bg-color: transparent; + --el-button-border-color: transparent; + --el-button-text-color: #ffffff; + --el-button-hover-bg-color: rgba(255, 255, 255, 0.12); + --el-button-hover-border-color: transparent; + --el-button-hover-text-color: #ffffff; + --el-button-active-bg-color: rgba(255, 255, 255, 0.18); + --el-button-disabled-bg-color: transparent; + --el-button-disabled-border-color: transparent; + --el-button-disabled-text-color: rgba(255, 255, 255, 0.4); + margin: 0; + } + + .mp-bulk__sep { + width: 1px; + height: 18px; + margin: 0 4px; + background: rgba(255, 255, 255, 0.18); + } + } + + .mp-empty { + padding: 40px 16px; + color: var(--sf-text-3); + font-size: 13.5px; + text-align: center; + } +} + +@media (max-width: 767px) { + html.sf-shell .master-page, + html.sf-shell .business-page { + .sf-page-header__actions .master-toolbar { + width: 100%; + } + + .mp-kpi { + padding: 14px 16px; + } + + .mp-kpi__value { + font-size: 22px; + } + + .master-query.mp-filter .mp-filter__search { + max-width: none; + } + + .master-query.mp-filter .mp-filter__summary { + width: 100%; + margin-left: 0; + } + + .mp-panel { + position: static; + max-height: none; + } + } +} + +// ========================================== +// 基础资料新增 / 修改弹窗(el-dialog.sf-form-dialog) +// 弹窗挂载在 body 上,因此不放在 .master-page 之下。 +// ========================================== +html.sf-shell .el-dialog.sf-form-dialog { + padding: 0; + + .el-dialog__header { + padding: 20px 24px 2px; + + .el-dialog__title { + font-size: 17px; + } + } + + .el-dialog__body { + padding: 0 24px 6px; + } + + .el-dialog__footer { + padding: 14px 24px; + border-radius: 0 0 var(--sf-radius-xl) var(--sf-radius-xl); + background: var(--sf-surface-2); + } +} + +html.sf-shell .sf-form-intro { + margin: 0 0 18px; + color: var(--sf-text-3); + font-size: 13px; + line-height: 1.6; +} + +html.sf-shell .el-form.sf-form { + .el-form-item { + margin-bottom: 18px; + } + + .el-form-item__label { + height: auto; + margin-bottom: 0; + padding-bottom: 6px; + color: var(--sf-text-2); + font-size: 13px; + line-height: 1.4; + } + + .el-form-item__content { + line-height: 1.5; + } + + .el-input, + .el-select, + .el-input-number { + width: 100%; + } + + .el-input-number .el-input__inner { + text-align: left; + } +} + +html.sf-shell .sf-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 16px; +} + +html.sf-shell .sf-form-section { + margin: 4px 0 14px; + padding-top: 14px; + border-top: 1px solid var(--sf-border-soft); + color: var(--sf-text-3); + font-size: 12.5px; + font-weight: 600; +} + +html.sf-shell .sf-form-hint { + width: 100%; + margin-top: 6px; + color: var(--sf-text-4); + font-size: 12.5px; + line-height: 1.5; +} + +html.sf-shell .sf-form-mono .el-input__inner, +html.sf-shell .sf-form-mono.el-input .el-input__inner { + font-family: var(--sf-font-mono); +} + +// 固定或系统生成的值:只读信息条 +html.sf-shell .sf-form-fixed { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 36px; + padding: 8px 12px; + border-radius: var(--sf-radius-md); + background: var(--sf-surface-2); + color: var(--sf-text-1); + font-size: 13.5px; + line-height: 1.45; + + strong { + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + small { + color: var(--sf-text-4); + font-size: 12.5px; + } + + .sf-form-fixed__value { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; + font-family: var(--sf-font-mono); + font-size: 12.5px; + } +} + +// 带说明的单选卡片:状态等需要解释后果的选项 +html.sf-shell .el-radio-group.sf-choice { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + gap: 10px; + width: 100%; + + .el-radio { + display: flex; + align-items: flex-start; + height: auto; + margin: 0; + padding: 10px 12px; + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-md); + background: var(--sf-surface); + white-space: normal; + + &.is-checked { + border-color: var(--sf-accent); + background: var(--sf-accent-soft); + box-shadow: 0 0 0 1px var(--sf-accent); + } + + &.is-disabled { + opacity: 0.6; + } + } + + .el-radio__input { + margin-top: 3px; + } + + .el-radio__label { + display: flex; + flex-direction: column; + padding-left: 10px; + color: var(--sf-text-1); + line-height: 1.5; + + strong { + font-size: 13.5px; + font-weight: 500; + } + + small { + color: var(--sf-text-3); + font-size: 12.5px; + font-weight: 400; + } + } +} + +html.sf-shell .sf-form-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + + .el-button + .el-button { + margin-left: 0; + } +} + +html.sf-shell .sf-form-footer__note { + margin-right: auto; + color: var(--sf-text-4); + font-size: 12.5px; + text-align: left; +} + +@media (max-width: 600px) { + html.sf-shell .sf-form-grid, + html.sf-shell .el-radio-group.sf-choice { + grid-template-columns: 1fr; + } +} diff --git a/shierfeng-fastapi-frontend/src/assets/styles/sf-shell.scss b/shierfeng-fastapi-frontend/src/assets/styles/sf-shell.scss new file mode 100644 index 0000000..33d7fbd --- /dev/null +++ b/shierfeng-fastapi-frontend/src/assets/styles/sf-shell.scss @@ -0,0 +1,1068 @@ +// ========================================== +// 智链云枢工作区设计令牌与组件覆盖 +// 仅在登录后的布局挂载时生效(layout 为 添加 sf-shell 类), +// 登录页、开源协议页等独立页面保持原有视觉。 +// ========================================== + +html.sf-shell { + // 字体:Geist 可选自托管,未安装时回落到系统字体 + --sf-font-sans: Geist, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans SC', system-ui, sans-serif; + --sf-font-mono: 'Geist Mono', 'SF Mono', 'JetBrains Mono', 'Cascadia Mono', Consolas, Menlo, monospace; + + // 底色与表面 + --sf-ground: #f5f6f7; + --sf-surface: #ffffff; + --sf-surface-2: #f7f8f9; + --sf-surface-3: #f1f2f4; + --sf-hover: rgba(15, 17, 21, 0.045); + + // 边框 + --sf-border-soft: #eef0f2; + --sf-border: #e7e9ec; + --sf-border-strong: #dde0e4; + + // 文本(均满足白底 4.5:1) + --sf-text-1: #0f1115; + --sf-text-2: #3d4350; + --sf-text-3: #5b616e; + --sf-text-4: #6b717d; + + // 强调色跟随主题色,文本场景取加深一级以保证对比度 + --sf-accent: var(--el-color-primary-dark-1, #007787); + --sf-accent-strong: var(--el-color-primary-dark-2, #006a78); + --sf-accent-soft: var(--el-color-primary-light-9, #e6f3f5); + --sf-accent-line: var(--el-color-primary-light-5, #80c2cb); + + // 状态与流转阶段 + --sf-warn-bg: #fff3e8; + --sf-warn-fg: #b54708; + --sf-warn-dot: #f79009; + --sf-warn-border: #f9ddba; + --sf-danger-bg: #fef3f2; + --sf-danger-fg: #b42318; + --sf-danger-dot: #f04438; + --sf-neutral-dot: #9aa1ac; + --sf-transit: #6a7fe8; + --sf-transit-bg: #eef1fd; + --sf-transit-fg: #3549a8; + --sf-site: #8e6bd9; + --sf-site-bg: #f3effc; + --sf-site-fg: #5b3bae; + --sf-return: #f79009; + + // 图表(经色觉校验的两系列配色) + --sf-chart-1: #00909f; + --sf-chart-2: #6a7fe8; + --sf-chart-grid: #f0f1f3; + --sf-chart-axis: #d9dce1; + + // 形状与阴影 + --sf-radius-sm: 6px; + --sf-radius: 8px; + --sf-radius-md: 10px; + --sf-radius-lg: 12px; + --sf-radius-xl: 14px; + --sf-shadow-xs: 0 1px 2px rgba(16, 24, 40, 0.05); + --sf-shadow-pop: 0 12px 32px rgba(16, 24, 40, 0.12); + --sf-ring: 0 0 0 1px var(--sf-border), 0 1px 2px rgba(16, 24, 40, 0.05); + + // Element Plus 变量映射 + --el-font-family: var(--sf-font-sans); + --el-bg-color-page: var(--sf-ground); + --el-bg-color: var(--sf-surface); + --el-bg-color-overlay: var(--sf-surface); + --el-border-color: var(--sf-border-strong); + --el-border-color-light: var(--sf-border); + --el-border-color-lighter: var(--sf-border-soft); + --el-border-color-extra-light: #f3f4f6; + --el-text-color-primary: var(--sf-text-1); + --el-text-color-regular: var(--sf-text-2); + --el-text-color-secondary: var(--sf-text-3); + --el-text-color-placeholder: #8a909b; + --el-fill-color: var(--sf-surface-3); + --el-fill-color-light: var(--sf-surface-2); + --el-fill-color-lighter: #fafafb; + --el-fill-color-blank: var(--sf-surface); + --el-border-radius-base: 8px; + --el-border-radius-small: 6px; + --el-box-shadow-lighter: none; + --el-box-shadow-light: 0 8px 24px rgba(16, 24, 40, 0.1); + --el-box-shadow: var(--sf-shadow-pop); + + // 旧布局变量 + --navbar-bg: var(--sf-surface); + --navbar-text: var(--sf-text-1); + --tags-bg: var(--sf-surface); + --tags-item-bg: var(--sf-surface); + --tags-item-border: var(--sf-border); + --tags-item-text: var(--sf-text-2); +} + +html.sf-shell.dark { + --sf-ground: #0d0e10; + --sf-surface: #15171a; + --sf-surface-2: #1b1e22; + --sf-surface-3: #22252a; + --sf-hover: rgba(255, 255, 255, 0.05); + --sf-border-soft: #22252a; + --sf-border: #2a2e34; + --sf-border-strong: #353a42; + --sf-text-1: #f2f3f5; + --sf-text-2: #c9cdd4; + --sf-text-3: #a3a9b3; + --sf-text-4: #8d939d; + --sf-accent: var(--el-color-primary-light-2, #339dab); + --sf-accent-strong: var(--el-color-primary-light-4, #66b6c0); + --sf-accent-soft: rgba(0, 133, 150, 0.18); + --sf-accent-line: rgba(0, 133, 150, 0.45); + --sf-warn-bg: rgba(247, 144, 9, 0.14); + --sf-warn-fg: #fdb022; + --sf-warn-border: rgba(247, 144, 9, 0.32); + --sf-danger-bg: rgba(240, 68, 56, 0.14); + --sf-danger-fg: #fd9a8f; + --sf-transit-bg: rgba(106, 127, 232, 0.16); + --sf-transit-fg: #a9b6f7; + --sf-site-bg: rgba(142, 107, 217, 0.18); + --sf-site-fg: #c4b1f2; + --sf-chart-1: #0f9aa8; + --sf-chart-2: #6f7fe6; + --sf-chart-grid: #23262b; + --sf-chart-axis: #353a42; + --sf-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --sf-shadow-pop: 0 12px 32px rgba(0, 0, 0, 0.45); + --el-bg-color-overlay: var(--sf-surface-2); + --el-text-color-placeholder: #747a85; + --el-fill-color-lighter: #181a1e; + --el-border-color-extra-light: #1f2226; + --el-box-shadow-light: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +// ---------- 基础排版 ---------- +html.sf-shell { + body { + font-family: var(--sf-font-sans); + color: var(--sf-text-1); + background: var(--sf-ground); + font-size: 14px; + } + + label, + .el-form .el-form-item__label { + font-weight: 500; + } + + h1, h2, h3, h4, h5, h6 { + font-weight: 600; + letter-spacing: 0; + } + + ::selection { + background: var(--sf-accent-soft); + } + + :focus-visible { + outline: 2px solid var(--sf-accent); + outline-offset: 2px; + } + + // 输入框由外层 wrapper 绘制聚焦态,避免重复描边 + .el-input__inner:focus-visible, + .el-textarea__inner:focus-visible, + .el-select__input:focus-visible, + .el-radio-button__original-radio:focus-visible + .el-radio-button__inner { + outline: none; + } +} + +.sf-mono { + font-family: var(--sf-font-mono); + font-variant-numeric: tabular-nums; +} + +.sf-num { + font-variant-numeric: tabular-nums; +} + +.sf-visually-hidden { + position: absolute !important; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +// ---------- 按钮 ---------- +html.sf-shell .el-button { + --el-button-font-weight: 500; + border-radius: var(--sf-radius); + + &:not(.is-link):not(.is-text):not(.el-button--primary):not(.el-button--success):not(.el-button--warning):not(.el-button--danger):not(.el-button--info) { + --el-button-bg-color: var(--sf-surface); + --el-button-border-color: var(--sf-border-strong); + --el-button-text-color: var(--sf-text-1); + --el-button-hover-bg-color: var(--sf-surface-2); + --el-button-hover-border-color: #cdd1d7; + --el-button-hover-text-color: var(--sf-text-1); + --el-button-active-bg-color: var(--sf-surface-3); + --el-button-active-border-color: #c3c8cf; + } + + &.el-button--primary:not(.is-plain):not(.is-link):not(.is-text) { + --el-button-bg-color: var(--sf-accent); + --el-button-border-color: var(--sf-accent); + --el-button-hover-bg-color: var(--el-color-primary); + --el-button-hover-border-color: var(--el-color-primary); + --el-button-active-bg-color: var(--sf-accent-strong); + --el-button-active-border-color: var(--sf-accent-strong); + } + + // 朴素主按钮统一为次级按钮,图标保留强调色 + &.el-button--primary.is-plain { + --el-button-bg-color: var(--sf-surface); + --el-button-border-color: var(--sf-border-strong); + --el-button-text-color: var(--sf-text-1); + --el-button-hover-bg-color: var(--sf-surface-2); + --el-button-hover-border-color: #cdd1d7; + --el-button-hover-text-color: var(--sf-text-1); + --el-button-active-bg-color: var(--sf-surface-3); + + .el-icon { + color: var(--sf-accent); + } + } + + // 工具栏的彩色朴素按钮统一为中性次级按钮,类型颜色只保留在图标上 + &.is-plain.el-button--success, + &.is-plain.el-button--warning, + &.is-plain.el-button--info, + &.is-plain.el-button--danger { + --el-button-bg-color: var(--sf-surface); + --el-button-border-color: var(--sf-border-strong); + --el-button-text-color: var(--sf-text-1); + --el-button-hover-bg-color: var(--sf-surface-2); + --el-button-hover-border-color: #cdd1d7; + --el-button-hover-text-color: var(--sf-text-1); + --el-button-active-bg-color: var(--sf-surface-3); + --el-button-active-border-color: #c3c8cf; + --el-button-disabled-bg-color: var(--sf-surface); + --el-button-disabled-border-color: var(--sf-border); + --el-button-disabled-text-color: var(--el-text-color-placeholder); + } + + &.is-plain.el-button--success:not(.is-disabled) .el-icon { color: var(--sf-accent); } + &.is-plain.el-button--warning:not(.is-disabled) .el-icon { color: var(--sf-warn-fg); } + &.is-plain.el-button--info:not(.is-disabled) .el-icon { color: var(--sf-text-3); } + + &.is-plain.el-button--danger { + --el-button-text-color: var(--sf-danger-fg); + --el-button-hover-text-color: var(--sf-danger-fg); + --el-button-hover-border-color: var(--sf-danger-dot); + } + + &.is-link.el-button--primary, + &.is-text.el-button--primary { + --el-button-text-color: var(--sf-accent); + --el-button-hover-link-text-color: var(--sf-accent-strong); + } +} + +html.sf-shell.dark .el-button.el-button--primary.is-plain { + background-color: var(--sf-surface); + border-color: var(--sf-border-strong); + color: var(--sf-text-1); +} + +// ---------- 输入 ---------- +html.sf-shell { + .el-input__wrapper, + .el-select__wrapper, + .el-textarea__inner { + box-shadow: 0 0 0 1px var(--sf-border-strong) inset; + border-radius: var(--sf-radius) !important; + } + + .el-input__wrapper:hover, + .el-select__wrapper:hover, + .el-textarea__inner:hover { + box-shadow: 0 0 0 1px #c3c8cf inset; + } + + .el-input__wrapper.is-focus, + .el-select__wrapper.is-focused, + .el-textarea__inner:focus { + box-shadow: 0 0 0 1px var(--el-color-primary) inset, 0 0 0 3px var(--sf-accent-soft); + } + + .el-input.is-disabled .el-input__wrapper, + .el-select__wrapper.is-disabled { + background-color: var(--sf-surface-2); + box-shadow: 0 0 0 1px var(--sf-border) inset; + } + + .el-input-number.is-without-controls .el-input__wrapper { + padding: 1px 11px; + } +} + +// ---------- 表格 ---------- +html.sf-shell .el-table { + --el-table-border-color: var(--sf-border-soft); + --el-table-header-bg-color: var(--sf-surface-2); + --el-table-header-text-color: var(--sf-text-3); + --el-table-row-hover-bg-color: var(--sf-surface-2); + --el-table-text-color: var(--sf-text-1); + font-size: 13.5px; + + .el-table__header-wrapper th, + .el-table__fixed-header-wrapper th, + .el-table__header th { + background-color: var(--sf-surface-2) !important; + color: var(--sf-text-3) !important; + font-size: 12.5px; + font-weight: 500 !important; + height: 42px !important; + } + + .el-table__body tr:hover > td.el-table__cell { + background-color: var(--sf-surface-2) !important; + } + + .el-tag { + border: 0; + } +} + +// ---------- 标签 ---------- +html.sf-shell .el-tag { + --el-tag-border-radius: 6px; + border-radius: 6px; + font-weight: 500; + + &.el-tag--light { + border-color: transparent; + + &.el-tag--success, + &.el-tag--primary { + --el-tag-bg-color: var(--sf-accent-soft); + --el-tag-text-color: var(--sf-accent-strong); + } + + &.el-tag--warning { + --el-tag-bg-color: var(--sf-warn-bg); + --el-tag-text-color: var(--sf-warn-fg); + } + + &.el-tag--danger { + --el-tag-bg-color: var(--sf-danger-bg); + --el-tag-text-color: var(--sf-danger-fg); + } + + &.el-tag--info { + --el-tag-bg-color: var(--sf-surface-3); + --el-tag-text-color: var(--sf-text-2); + } + } + + &.el-tag--plain { + --el-tag-border-color: var(--sf-border); + --el-tag-bg-color: var(--sf-surface); + + &.el-tag--info { + --el-tag-text-color: var(--sf-text-2); + } + } +} + +// 表格中的状态标签附带圆点,颜色之外再提供形状线索 +html.sf-shell .el-table .el-tag.el-tag--light::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 50%; + background: currentColor; + vertical-align: 1px; +} + +// ---------- 弹窗、抽屉、浮层 ---------- +html.sf-shell { + .el-dialog { + border: 1px solid var(--sf-border) !important; + border-radius: var(--sf-radius-xl) !important; + box-shadow: 0 24px 64px rgba(16, 24, 40, 0.18) !important; + + .el-dialog__header { + padding: 18px 22px 12px; + border-bottom: 0; + + .el-dialog__title { + font-size: 16px; + font-weight: 600; + } + } + + .el-dialog__body { + padding: 8px 22px 20px; + } + + .el-dialog__footer { + padding: 14px 22px; + border-top: 1px solid var(--sf-border-soft); + } + } + + .el-message-box { + border-radius: var(--sf-radius-xl); + border: 1px solid var(--sf-border); + padding: 20px 22px 18px; + } + + .el-drawer { + background: var(--sf-surface); + + .el-drawer__header { + margin-bottom: 0; + padding: 18px 22px 14px; + color: var(--sf-text-1); + font-weight: 600; + border-bottom: 1px solid var(--sf-border-soft); + } + } + + .el-popper.is-light, + .el-select__popper.el-popper, + .el-dropdown__popper.el-popper { + border-color: var(--sf-border) !important; + border-radius: var(--sf-radius-md); + box-shadow: var(--sf-shadow-pop) !important; + } + + .el-select-dropdown__item { + border-radius: var(--sf-radius-sm); + margin: 0 6px; + } + + .el-select-dropdown__item.is-selected { + color: var(--sf-accent); + } + + .el-dropdown-menu { + padding: 6px; + + .el-dropdown-menu__item { + border-radius: var(--sf-radius-sm); + padding: 7px 12px; + font-size: 13.5px; + } + + .el-dropdown-menu__item:not(.is-disabled):hover, + .el-dropdown-menu__item:not(.is-disabled):focus { + background-color: var(--sf-surface-2); + color: var(--sf-text-1); + } + } +} + +// ---------- 卡片、提示、分页、页签 ---------- +html.sf-shell { + .el-card { + border: 1px solid var(--sf-border) !important; + border-radius: var(--sf-radius-lg) !important; + box-shadow: none !important; + background: var(--sf-surface); + + .el-card__header { + font-weight: 600; + border-bottom-color: var(--sf-border-soft) !important; + } + } + + .el-alert { + border-radius: var(--sf-radius-md); + padding: 10px 14px; + + &.el-alert--info.is-light { + background: var(--sf-surface-2); + color: var(--sf-text-2); + border: 1px solid var(--sf-border-soft); + + .el-alert__description { + color: var(--sf-text-3); + } + } + + &.el-alert--warning.is-light { + background: var(--sf-warn-bg); + color: var(--sf-warn-fg); + border: 1px solid var(--sf-warn-border); + } + + &.el-alert--error.is-light { + background: var(--sf-danger-bg); + color: var(--sf-danger-fg); + } + + &.el-alert--success.is-light { + background: var(--sf-accent-soft); + color: var(--sf-accent-strong); + } + } + + .el-pagination { + --el-pagination-button-bg-color: transparent; + --el-pagination-hover-color: var(--sf-text-1); + font-variant-numeric: tabular-nums; + + .el-pager li, + .btn-prev, + .btn-next { + border-radius: var(--sf-radius-sm); + min-width: 30px; + } + + .el-pager li.is-active { + background: var(--sf-surface-3); + color: var(--sf-text-1); + font-weight: 600; + } + } + + .el-tabs:not(.el-tabs--card):not(.el-tabs--border-card) { + --el-tabs-header-height: 42px; + + .el-tabs__item { + font-size: 13.5px; + color: var(--sf-text-3); + padding: 0 16px; + + &:hover, + &.is-active { + color: var(--sf-text-1); + } + + &.is-active { + font-weight: 600; + } + } + + .el-tabs__active-bar { + background-color: var(--sf-text-1); + } + + .el-tabs__nav-wrap::after { + height: 1px; + background-color: var(--sf-border-soft); + } + } + + .el-descriptions__label.el-descriptions__cell.is-bordered-label { + background: var(--sf-surface-2); + color: var(--sf-text-3); + font-weight: 500; + } + + .el-empty__description p { + color: var(--sf-text-4); + } + + .el-checkbox__inner { + border-radius: 4px; + } +} + +// ---------- 分段控件(el-radio-group.sf-segmented) ---------- +html.sf-shell .el-radio-group.sf-segmented { + display: inline-flex; + flex-wrap: nowrap; + padding: 2px; + border-radius: var(--sf-radius); + background: var(--sf-surface-3); + + .el-radio-button__inner { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 12px; + border: 0 !important; + outline: 0 !important; + border-radius: 6px !important; + background: transparent; + box-shadow: none !important; + color: var(--sf-text-3); + font-size: 13px; + font-weight: 500; + } + + .el-radio-button.is-active .el-radio-button__inner, + .el-radio-button__original-radio:checked + .el-radio-button__inner { + background: var(--sf-surface); + color: var(--sf-text-1); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08) !important; + } + + .el-radio-button:first-child .el-radio-button__inner, + .el-radio-button:last-child .el-radio-button__inner { + border-radius: 6px !important; + } +} + +// ---------- 页面工作区 ---------- +html.sf-shell { + .app-container { + padding: 24px 32px 40px; + } + + .app-container.business-page, + .app-container.master-page, + .app-container.report-page { + padding: 24px 32px 40px; + } + + // 筛选托盘:浅灰底、无投影,与白色内容面板区分 + .app-container > .el-form--inline, + .business-page .page-filter, + .master-page .page-filter, + .report-page .page-filter { + background: var(--sf-surface-2); + border-color: var(--sf-border-soft); + border-radius: var(--sf-radius-lg); + box-shadow: none; + } + + .app-container > .el-table, + .business-page .data-table, + .master-page .data-table, + .report-page .data-table { + border-color: var(--sf-border); + border-radius: var(--sf-radius-lg); + box-shadow: none; + } + + .business-page .data-table .el-table__body tr:nth-child(even) > td, + .master-page .data-table .el-table__body tr:nth-child(even) > td, + .app-container > .el-table .el-table__body tr:nth-child(even) > td { + background: var(--sf-surface); + } + + .page-filter .el-input-number .el-input__inner { + text-align: left; + } + + .form-tip { + margin-top: 6px; + color: var(--sf-text-4); + font-size: 12.5px; + line-height: 1.5; + } +} + +@media (max-width: 767px) { + html.sf-shell { + .app-container, + .app-container.business-page, + .app-container.master-page, + .app-container.report-page { + padding: 16px 16px 32px; + } + } +} + +// ---------- 顶部数据模式切换 ---------- +html.sf-shell .navbar .test-batch-mode-switch { + .mode-tag:not(.test-mode-tag) { + display: none; + } + + .test-mode-tag { + --el-tag-bg-color: var(--sf-warn-bg); + --el-tag-border-color: var(--sf-warn-border); + --el-tag-text-color: var(--sf-warn-fg); + } + + .mode-selector { + padding: 2px; + border-radius: var(--sf-radius); + background: var(--sf-surface-3); + + .el-radio-button__inner { + height: 26px; + padding: 0 10px; + border: 0 !important; + outline: 0 !important; + border-radius: 6px !important; + background: transparent; + box-shadow: none !important; + color: var(--sf-text-3); + font-size: 12.5px; + line-height: 26px; + } + + .el-radio-button.is-active .el-radio-button__inner { + background: var(--sf-surface); + color: var(--sf-text-1); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08) !important; + } + } +} + +// ---------- 全屏扫码作业区(el-dialog.sf-workspace-dialog) ---------- +html.sf-shell .el-dialog.sf-workspace-dialog { + --el-dialog-padding-primary: 0; + display: flex; + flex-direction: column; + padding: 0; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + background: var(--sf-ground); + + .el-dialog__header { + flex: none; + margin: 0; + padding: 16px 24px; + border-bottom: 1px solid var(--sf-border); + background: var(--sf-surface); + + .el-dialog__title { + font-size: 16px; + font-weight: 600; + } + } + + .el-dialog__headerbtn { + top: 8px; + right: 12px; + width: 44px; + height: 44px; + } + + .el-dialog__body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 24px; + + > .el-alert { + max-width: 1320px; + margin: 0 auto 12px; + } + } + + .el-dialog__footer { + flex: none; + padding: 12px 24px; + border-top: 1px solid var(--sf-border); + background: var(--sf-surface); + box-shadow: 0 -8px 24px rgba(16, 24, 40, 0.05); + } +} + +html.sf-shell .sf-workspace { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 20px; + max-width: 1320px; + margin: 0 auto; + + .el-form-item { + margin-bottom: 14px; + } + + .el-form-item:last-child { + margin-bottom: 0; + } + + .el-form-item__label { + color: var(--sf-text-3); + font-size: 12.5px; + line-height: 1.4; + margin-bottom: 6px; + } +} + +html.sf-shell .sf-workspace__side { + display: flex; + flex: 1 1 320px; + flex-direction: column; + gap: 16px; + max-width: 100%; +} + +html.sf-shell .sf-workspace__main { + display: flex; + flex: 999 1 560px; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +html.sf-shell .sf-workspace__panel { + padding: 16px; + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-lg); + background: var(--sf-surface); + + &.is-flush { + padding: 0; + overflow: hidden; + } + + .el-table { + --el-table-border-color: var(--sf-border-soft); + } +} + +html.sf-shell .sf-workspace__panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--sf-border-soft); + + .sf-workspace__title { + margin: 0; + } + + > span { + color: var(--sf-text-4); + font-size: 13px; + } +} + +html.sf-shell .sf-workspace__title { + margin: 0 0 12px; + color: var(--sf-text-1); + font-size: 14px; + font-weight: 600; +} + +html.sf-shell .sf-workspace__totals { + display: flex; + gap: 28px; + + div { + display: flex; + flex-direction: column; + } + + span { + color: var(--sf-text-4); + font-size: 12px; + } + + strong { + font-size: 26px; + font-weight: 600; + line-height: 1.15; + letter-spacing: -0.02em; + } +} + +html.sf-shell .sf-workspace__label { + display: block; + margin-bottom: 8px; + color: var(--sf-text-4); + font-size: 12px; +} + +html.sf-shell .sf-workspace__mix { + margin-top: 16px; +} + +html.sf-shell .sf-workspace__mix-row { + display: grid; + grid-template-columns: 64px 1fr 48px; + gap: 10px; + align-items: center; + margin-top: 6px; + font-size: 13px; + + > span:first-child { + overflow: hidden; + font-weight: 500; + white-space: nowrap; + text-overflow: ellipsis; + } + + > span:last-child { + color: var(--sf-text-2); + text-align: right; + } +} + +html.sf-shell .sf-workspace__mix-bar { + height: 6px; + overflow: hidden; + border-radius: 3px; + background: var(--sf-border-soft); + + span { + display: block; + height: 100%; + border-radius: 3px; + background: var(--sf-accent); + } +} + +html.sf-shell .sf-workspace__facts { + display: flex; + justify-content: space-between; + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid var(--sf-border-soft); + font-size: 13px; + + dt { + color: var(--sf-text-3); + } + + dd { + margin: 0; + } +} + +html.sf-shell .sf-workspace__tip { + padding: 12px 14px; + border-radius: var(--sf-radius-md); + background: var(--sf-surface-3); + color: var(--sf-text-2); + font-size: 12.5px; + line-height: 1.6; +} + +// 作业区扫码框放大,适合扫码枪与触屏 +html.sf-shell .sf-workspace__scan { + .el-form-item__label { + color: var(--sf-text-1); + font-size: 14px; + font-weight: 600; + } + + .pallet-scan-input .el-input__wrapper { + min-height: 60px; + padding: 0 16px; + border-radius: var(--sf-radius-lg) !important; + } + + .pallet-scan-input .el-input__inner { + font-family: var(--sf-font-mono); + font-size: 18px; + font-weight: 500; + letter-spacing: 0.02em; + + &::placeholder { + font-family: var(--sf-font-sans); + font-size: 15px; + font-weight: 400; + letter-spacing: 0; + } + } + + .pallet-scan-input:not(.is-disabled):not(.is-error):focus-within .el-input__wrapper { + box-shadow: 0 0 0 2px var(--el-color-primary) inset, 0 0 0 4px var(--sf-accent-soft); + } + + .el-alert { + margin-top: 12px; + } +} + +html.sf-shell .sf-workspace__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + max-width: 1320px; + margin: 0 auto; + + .dialog-footer { + display: flex; + gap: 8px; + + .el-button { + min-width: 104px; + height: 40px; + margin: 0; + } + } +} + +html.sf-shell .sf-workspace__footer-summary { + color: var(--sf-text-2); + font-size: 14px; + + strong { + color: var(--sf-text-1); + font-weight: 600; + } +} + +@media (max-width: 767px) { + html.sf-shell .el-dialog.sf-workspace-dialog .el-dialog__body { + padding: 16px; + } + + html.sf-shell .sf-workspace__footer .dialog-footer { + width: 100%; + + .el-button { + flex: 1; + } + } +} + +// ---------- 列表多选浮动操作条(el-row.sf-bulk-bar) ---------- +html.sf-shell .app-container > .el-row.sf-bulk-bar, +html.sf-shell .el-row.sf-bulk-bar { + position: sticky; + bottom: 20px; + z-index: 10; + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 8px; + width: fit-content; + max-width: 100%; + min-height: 0; + margin: 16px auto 0 !important; + padding: 6px 6px 6px 16px; + border-radius: var(--sf-radius-lg); + background: #15171c; + box-shadow: 0 12px 32px rgba(16, 24, 40, 0.24); + color: #ffffff; + + .el-col { + flex: none; + max-width: none; + padding: 0 !important; + } + + .sf-bulk-bar__count { + margin-right: 6px; + font-size: 13px; + white-space: nowrap; + } + + .el-button, + .el-button.is-plain { + --el-button-bg-color: transparent; + --el-button-border-color: rgba(255, 255, 255, 0.2); + --el-button-text-color: #ffffff; + --el-button-hover-bg-color: rgba(255, 255, 255, 0.12); + --el-button-hover-border-color: rgba(255, 255, 255, 0.32); + --el-button-hover-text-color: #ffffff; + --el-button-disabled-bg-color: transparent; + --el-button-disabled-border-color: rgba(255, 255, 255, 0.12); + --el-button-disabled-text-color: rgba(255, 255, 255, 0.4); + + .el-icon { + color: inherit; + } + } +} diff --git a/shierfeng-fastapi-frontend/src/assets/styles/sidebar.scss b/shierfeng-fastapi-frontend/src/assets/styles/sidebar.scss index 132d8d0..7cd103b 100755 --- a/shierfeng-fastapi-frontend/src/assets/styles/sidebar.scss +++ b/shierfeng-fastapi-frontend/src/assets/styles/sidebar.scss @@ -1,5 +1,8 @@ @use './variables.module.scss' as vars; +// ========================================== +// 侧边导航:位于底色之上,内容区为右侧内嵌面板 +// ========================================== #app { .main-container { @@ -10,24 +13,54 @@ } .sidebarHide { - margin-left: 0!important; + margin-left: 0 !important; } .sidebar-container { + --sf-menu-text: var(--sf-text-2, #3d4350); + --sf-menu-icon: var(--sf-text-4, #6b717d); + --sf-menu-group: var(--sf-text-4, #6b717d); + --sf-menu-hover: var(--sf-hover, rgba(15, 17, 21, 0.045)); + --sf-menu-active-bg: var(--sf-surface, #fff); + --sf-menu-active-text: var(--sf-text-1, #0f1115); + --sf-menu-active-icon: var(--sf-accent, #007787); + --sf-menu-active-ring: var(--sf-ring, 0 0 0 1px #e7e9ec); + --el-menu-bg-color: transparent; + --el-menu-hover-bg-color: var(--sf-menu-hover); + --el-menu-text-color: var(--sf-menu-text); + --el-menu-active-color: var(--sf-menu-active-text); + --el-menu-item-height: 36px; + --el-menu-sub-item-height: 36px; + --el-menu-base-level-padding: 12px; + --el-menu-level-padding: 14px; + --el-menu-item-font-size: 13.5px; + transition: width 0.28s; width: vars.$base-sidebar-width !important; height: 100%; position: fixed; - font-size: 0px; + font-size: 0; top: 0; bottom: 0; left: 0; z-index: 1001; overflow: hidden; - border-right: 1px solid var(--el-border-color-lighter, #e4e7ed); - box-shadow: 2px 0 10px 0 rgba(15, 34, 58, 0.05); + background: var(--sf-ground, #f5f6f7); + display: flex; + flex-direction: column; + + &.theme-dark { + --sf-menu-text: #c9cdd4; + --sf-menu-icon: #8d939d; + --sf-menu-group: #8d939d; + --sf-menu-hover: rgba(255, 255, 255, 0.06); + --sf-menu-active-bg: rgba(255, 255, 255, 0.1); + --sf-menu-active-text: #ffffff; + --sf-menu-active-icon: var(--el-color-primary-light-3, #4daab6); + --sf-menu-active-ring: none; + background: #15171c; + } - // reset element-ui css .horizontal-collapse-transition { transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out; } @@ -37,176 +70,169 @@ } .el-scrollbar__bar.is-vertical { - right: 0px; + right: 0; } .el-scrollbar { - height: 100%; - } - - &.has-logo { - .el-scrollbar { - height: calc(100% - 50px); - } + flex: 1; + min-height: 0; } .is-horizontal { display: none; } - a { - display: inline-block; + .el-menu a { + display: block; width: 100%; overflow: hidden; } - .svg-icon { - margin-right: 10px !important; - } - .el-menu { border: none; height: 100%; width: 100% !important; + padding: 4px 0 16px; + background: transparent; + } - &:not(.el-menu--collapse) { - .el-menu-item, - .el-sub-menu__title { - height: 46px; - line-height: 46px; - margin: 0; - border-radius: 0; - transition: background-color 0.2s ease, color 0.2s ease; - } + // 菜单项:圆角胶囊 + .el-menu-item, + .el-sub-menu__title { + height: 36px; + line-height: 36px; + margin: 1px 10px; + padding-right: 10px; + border-radius: 8px; + color: var(--sf-menu-text); + font-size: 13.5px; + transition: background-color 0.15s ease, color 0.15s ease; - .el-menu-item.is-active { - font-weight: 600; - background-color: rgba(0, 133, 150, 0.12) !important; - color: var(--current-color, #008596) !important; - border-radius: 0; - } + &:hover { + background-color: var(--sf-menu-hover) !important; + color: var(--sf-menu-active-text); } } - .el-menu-item, .menu-title { + .sf-menu-icon, + .svg-icon { + flex: none; + width: 16px !important; + height: 16px !important; + margin-right: 10px !important; + color: var(--sf-menu-icon); + font-size: 16px; + } + + .el-menu-item.is-active { + background-color: var(--sf-menu-active-bg) !important; + box-shadow: var(--sf-menu-active-ring); + color: var(--sf-menu-active-text) !important; + font-weight: 500; + + .sf-menu-icon, + .svg-icon { + color: var(--sf-menu-active-icon); + } + } + + .menu-title, + .el-menu-item { overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } .el-menu-item .el-menu-tooltip__trigger { - display: inline-block !important; + display: inline-flex !important; + align-items: center; } - .el-sub-menu { - .el-sub-menu__icon-arrow { - position: absolute; - top: 50%; - right: var(--el-menu-base-level-padding, 20px); - margin-top: -6px; - width: auto !important; + .el-sub-menu__icon-arrow { + right: 10px; + width: 12px !important; + font-size: 12px; + color: var(--sf-menu-group); + } + + // 一级目录展开时作为分组标题:小字号、无图标 + .el-menu:not(.el-menu--collapse) { + & > div > .el-sub-menu { + margin-top: 10px; + + & > .el-sub-menu__title { + height: 28px; + line-height: 28px; + font-size: 12px; + font-weight: 500; + color: var(--sf-menu-group); + letter-spacing: 0.02em; + + .sf-menu-icon, + .svg-icon { + display: none; + } + + &:hover { + background-color: transparent !important; + color: var(--sf-menu-active-text); + } + } + + & > .el-menu { + padding: 2px 0 0; + } + + // 分组内一级子项与顶层菜单项左对齐 + & > .el-menu > div > a > .el-menu-item, + & > .el-menu > div > .el-sub-menu > .el-sub-menu__title { + padding-left: 12px !important; + } + } + + .nest-menu .el-sub-menu .el-menu-item { + padding-left: 38px !important; } } - // menu hover - .sub-menu-title-noDropdown, - .el-sub-menu__title { - &:hover { - background-color: rgba(0, 0, 0, 0.06) !important; - } - } - - & .theme-dark .is-active > .el-sub-menu__title { - color: vars.$base-menu-color-active !important; - } - - & .theme-dark .el-menu:not(.el-menu--collapse) .el-menu-item.is-active { - background-color: rgba(0, 133, 150, 0.26) !important; - color: #38bdf8 !important; - border-radius: 0; - } - - & .nest-menu .el-sub-menu>.el-sub-menu__title, - & .el-sub-menu .el-menu-item { - min-width: vars.$base-sidebar-width !important; - width: 100% !important; - margin: 0 !important; - border-radius: 0 !important; - - &:hover { - background-color: rgba(0, 0, 0, 0.06) !important; - } - - .el-sub-menu__icon-arrow { - position: absolute; - top: 50%; - right: var(--el-menu-base-level-padding, 20px); - margin-top: -6px; - width: auto !important; - } - } - - & .theme-dark .nest-menu .el-sub-menu>.el-sub-menu__title, - & .theme-dark .el-sub-menu .el-menu-item { - background-color: vars.$base-sub-menu-background; - - &:hover { - background-color: vars.$base-sub-menu-hover !important; - } - - &.is-active { - background-color: rgba(0, 133, 150, 0.28) !important; - color: #38bdf8 !important; - } + .el-sub-menu.is-active > .el-sub-menu__title { + color: var(--sf-menu-active-text); } } .hideSidebar { .sidebar-container { - width: 54px !important; + width: vars.$collapsed-sidebar-width !important; } .main-container { - margin-left: 54px; - } - - .sub-menu-title-noDropdown { - padding: 0 !important; - position: relative; - - .el-tooltip { - padding: 0 !important; - - .svg-icon { - margin-left: 20px; - } - } - } - - .el-sub-menu { - overflow: hidden; - - &>.el-sub-menu__title { - padding: 0 !important; - - .svg-icon { - margin-left: 20px; - } - - } + margin-left: vars.$collapsed-sidebar-width; } .el-menu--collapse { + width: 100% !important; + + .el-menu-item, + .el-sub-menu__title { + justify-content: center; + margin: 1px 10px; + padding: 0 !important; + } + + .el-tooltip__trigger { + justify-content: center; + padding: 0 !important; + } + + .sf-menu-icon, + .svg-icon { + margin-right: 0 !important; + } + .el-sub-menu { - &>.el-sub-menu__title { - &>span { - height: 0; - width: 0; - overflow: hidden; - visibility: hidden; - display: inline-block; - } - &>i { + & > .el-sub-menu__title { + & > span, + & > i { height: 0; width: 0; overflow: hidden; @@ -222,15 +248,16 @@ min-width: vars.$base-sidebar-width !important; } - // mobile responsive + // 移动端:抽屉式导航 .mobile { .main-container { - margin-left: 0px; + margin-left: 0; } .sidebar-container { transition: transform .28s; width: vars.$base-sidebar-width !important; + box-shadow: 8px 0 32px rgba(16, 24, 40, 0.12); } &.hideSidebar { @@ -251,47 +278,60 @@ } } -// when menu collapsed +// 折叠态弹出的子菜单 .el-menu--vertical { - .el-sub-menu { - .el-sub-menu__icon-arrow { - position: absolute; - top: 50%; - right: var(--el-menu-base-level-padding, 20px); - margin-top: -6px; - width: auto !important; - } + --el-menu-item-height: 36px; + --el-menu-sub-item-height: 36px; + + .el-menu--popup { + padding: 6px; + border-radius: 10px; + min-width: 200px; } - &>.el-menu { + .el-menu-item, + .el-sub-menu__title { + border-radius: 6px; + font-size: 13.5px; + + .sf-menu-icon, .svg-icon { - margin-right: 16px; + width: 16px; + height: 16px; + margin-right: 10px; } } - .nest-menu .el-sub-menu>.el-sub-menu__title, + .el-menu-item.is-active { + color: var(--sf-accent, #007787); + font-weight: 500; + } + + .el-sub-menu .el-sub-menu__icon-arrow { + position: absolute; + top: 50%; + right: 10px; + margin-top: -6px; + width: auto !important; + } + + .nest-menu .el-sub-menu > .el-sub-menu__title, .el-menu-item { &:hover { - // you can use $sub-menuHover - background-color: rgba(0, 0, 0, 0.06) !important; + background-color: var(--sf-hover, rgba(0, 0, 0, 0.05)) !important; } } - // the scroll bar appears when the sub-menu is too long - >.el-menu--popup { + > .el-menu--popup { max-height: 100vh; overflow-y: auto; - &::-webkit-scrollbar-track-piece { - background: #d3dce6; - } - &::-webkit-scrollbar { width: 6px; } &::-webkit-scrollbar-thumb { - background: #99a9bf; + background: var(--sf-border-strong, #c0c4cc); border-radius: 20px; } } diff --git a/shierfeng-fastapi-frontend/src/assets/styles/variables.module.scss b/shierfeng-fastapi-frontend/src/assets/styles/variables.module.scss index b038f96..38f7c64 100755 --- a/shierfeng-fastapi-frontend/src/assets/styles/variables.module.scss +++ b/shierfeng-fastapi-frontend/src/assets/styles/variables.module.scss @@ -21,8 +21,9 @@ $menuLightText: #303133; $menuLightActiveText: #409EFF; // 基础变量 -$base-sidebar-width: 200px; -$sideBarWidth: 200px; +$base-sidebar-width: 232px; +$collapsed-sidebar-width: 64px; +$sideBarWidth: 232px; // 菜单暗色变量 $base-menu-color: #bfcbd9; diff --git a/shierfeng-fastapi-frontend/src/components/Breadcrumb/index.vue b/shierfeng-fastapi-frontend/src/components/Breadcrumb/index.vue index 1af758b..7e46af5 100644 --- a/shierfeng-fastapi-frontend/src/components/Breadcrumb/index.vue +++ b/shierfeng-fastapi-frontend/src/components/Breadcrumb/index.vue @@ -34,7 +34,7 @@ function getBreadcrumb() { } // 判断是否为首页 if (!isDashboard(matched[0])) { - matched = [{ path: "/index", meta: { title: "首页" } }].concat(matched) + matched = [{ path: "/index", meta: { title: "工作台" } }].concat(matched) } levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false) } @@ -86,22 +86,27 @@ getBreadcrumb() diff --git a/shierfeng-fastapi-frontend/src/components/FlowStepper/index.vue b/shierfeng-fastapi-frontend/src/components/FlowStepper/index.vue new file mode 100644 index 0000000..ca09353 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/FlowStepper/index.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/MasterExcelActions/index.vue b/shierfeng-fastapi-frontend/src/components/MasterExcelActions/index.vue index eacbd6f..bee3fb8 100644 --- a/shierfeng-fastapi-frontend/src/components/MasterExcelActions/index.vue +++ b/shierfeng-fastapi-frontend/src/components/MasterExcelActions/index.vue @@ -1,87 +1,200 @@ + + diff --git a/shierfeng-fastapi-frontend/src/components/MasterRefSelect/index.vue b/shierfeng-fastapi-frontend/src/components/MasterRefSelect/index.vue new file mode 100644 index 0000000..c5bc8f5 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/MasterRefSelect/index.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/PageHeader/descriptions.js b/shierfeng-fastapi-frontend/src/components/PageHeader/descriptions.js new file mode 100644 index 0000000..863f849 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/PageHeader/descriptions.js @@ -0,0 +1,24 @@ +// 页面说明:按菜单路由末段匹配,用于页面标题下方的一句话说明 +export const PAGE_DESCRIPTIONS = { + // 基础资料 + strain: '菌种是装填时选择的物料品类。这里能看到每个菌种此刻在仓库与在外的量、最近有效期和装填情况。', + customer: '客户 → 现场 → 产线与隧道的完整层级。现场是发货的目的地,产线和隧道用于现场使用登记和同批溯源。', + site: '现场是发货的目的地,也是使用登记与退回的发起地。授权用户只能在自己负责的现场收货、使用和退回。', + line: '产线隶属于客户现场。现场使用登记时选择产线,用于按产线统计用量。', + warehouse: '仓库是入库、装填、发货与退回的归属地。授权用户只能在自己负责的仓库中作业,也只能看到这些仓库的数据。', + pallet: '托盘是循环使用的资产。在这里登记编号、二维码与容量,并随时看到每个托盘此刻在哪、处于哪个环节。', + tunnel: '隧道隶属于客户现场。托盘入隧后生成内部溯源号,同一隧道同一批次的托盘共享溯源号,用于同批追溯。', + // 业务作业 + inbound: '空托盘首次入库登记。草稿可继续扫码修改,确认后托盘成为仓库空托。', + filling: '扫描仓库空托盘,登记菌种、生产批次与包数,完成后托盘变为仓库有料。', + shipment: '从仓库发往客户现场的托盘单据。确认后托盘进入在途,由现场扫码完成收货核对。', + usage: '客户现场扫码入隧并登记使用包数,系统按到货先后提示优先使用的托盘。', + return: '客户现场发起空托或余料退回申请,仓库扫码核对后确认退回。', + // 报表查询 + palletInventory: '每个托盘当前所处的流转状态、位置与剩余包数。', + reportFilling: '按时间、仓库、菌种与批次查询工厂装填记录。', + reportShipment: '已确认发货的托盘明细,包含发货时的包数快照与客户现场。', + consumption: '客户现场按产线、隧道统计的实际使用包数。', + reportReturn: '退回申请与确认的托盘明细,可按溯源号与批次追溯。', + palletTrace: '托盘的每一次状态、位置与数量变化。点击托盘编号查看完整的托盘档案。' +} diff --git a/shierfeng-fastapi-frontend/src/components/PageHeader/index.vue b/shierfeng-fastapi-frontend/src/components/PageHeader/index.vue new file mode 100644 index 0000000..76811c3 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/PageHeader/index.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/PalletCodeLink/index.vue b/shierfeng-fastapi-frontend/src/components/PalletCodeLink/index.vue new file mode 100644 index 0000000..c036d82 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/PalletCodeLink/index.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/ScanFeedback/index.vue b/shierfeng-fastapi-frontend/src/components/ScanFeedback/index.vue new file mode 100644 index 0000000..4b21817 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/ScanFeedback/index.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/components/SfIcon/icons.js b/shierfeng-fastapi-frontend/src/components/SfIcon/icons.js new file mode 100644 index 0000000..ece355d --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/SfIcon/icons.js @@ -0,0 +1,116 @@ +// 线性图标路径(24×24 视窗,描边绘制),供菜单与工作区页面统一使用 +export const SF_ICONS = { + layout: '', + search: '', + scan: '', + route: '', + inbound: '', + layers: '', + truck: '', + clipboard: '', + sprout: '', + undo: '', + warehouse: '', + chart: '', + gauge: '', + database: '', + flask: '', + building: '', + pin: '', + line: '', + package: '', + tunnel: '', + settings: '', + user: '', + users: '', + shield: '', + menu: '', + tree: '', + badge: '', + book: '', + sliders: '', + megaphone: '', + file: '', + login: '', + activity: '', + clock: '', + server: '', + zap: '', + lock: '', + wrench: '', + grid: '', + code: '', + link: '', + plus: '', + check: '', + x: '', + 'chevron-right': '', + 'chevron-down': '', + 'arrow-right': '', + 'arrow-left': '', + alert: '', + info: '', + 'check-circle': '', + refresh: '', + printer: '', + copy: '', + calendar: '', + enter: '' +} + +// 菜单路由名(路径末段)到图标的映射;未命中时回退到菜单配置的 svg 图标 +export const MENU_ICON_MAP = { + index: 'layout', + businessMaster: 'database', + strain: 'flask', + customer: 'building', + site: 'pin', + line: 'line', + warehouse: 'warehouse', + pallet: 'package', + tunnel: 'tunnel', + businessOperation: 'layers', + inbound: 'inbound', + filling: 'layers', + shipment: 'truck', + usage: 'sprout', + return: 'undo', + businessReport: 'chart', + palletInventory: 'warehouse', + reportFilling: 'layers', + reportShipment: 'truck', + consumption: 'gauge', + reportReturn: 'undo', + palletTrace: 'route', + system: 'settings', + user: 'user', + role: 'shield', + menu: 'menu', + dept: 'tree', + post: 'badge', + dict: 'book', + config: 'sliders', + notice: 'megaphone', + log: 'file', + operlog: 'file', + logininfor: 'login', + testBatch: 'flask', + monitor: 'activity', + online: 'users', + job: 'clock', + druid: 'database', + server: 'server', + cache: 'zap', + cacheList: 'zap', + transportCrypto: 'lock', + tool: 'wrench', + build: 'grid', + gen: 'code', + swagger: 'book' +} + +export function resolveMenuIcon(path) { + if (!path || typeof path !== 'string') return '' + const segment = path.split('/').filter(Boolean).pop() || 'index' + return MENU_ICON_MAP[segment] || '' +} diff --git a/shierfeng-fastapi-frontend/src/components/SfIcon/index.vue b/shierfeng-fastapi-frontend/src/components/SfIcon/index.vue new file mode 100644 index 0000000..587c245 --- /dev/null +++ b/shierfeng-fastapi-frontend/src/components/SfIcon/index.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/layout/components/AppMain.vue b/shierfeng-fastapi-frontend/src/layout/components/AppMain.vue index 343b129..8ab3cb4 100644 --- a/shierfeng-fastapi-frontend/src/layout/components/AppMain.vue +++ b/shierfeng-fastapi-frontend/src/layout/components/AppMain.vue @@ -46,73 +46,25 @@ async function syncTestBatchScope() { diff --git a/shierfeng-fastapi-frontend/src/layout/components/CommandPalette/index.vue b/shierfeng-fastapi-frontend/src/layout/components/CommandPalette/index.vue new file mode 100644 index 0000000..5807fec --- /dev/null +++ b/shierfeng-fastapi-frontend/src/layout/components/CommandPalette/index.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue b/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue index ca4b971..d0ccd8b 100644 --- a/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue +++ b/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue @@ -9,13 +9,18 @@
+ - +
- + {{ userStore.nickName }}