Initial commit

This commit is contained in:
汤学会
2026-06-12 16:00:56 +08:00
commit c927886888
337 changed files with 160396 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8000/api
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" />
<link rel="alternate icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/logo.png" />
<title>嘉恒智能五金 ERP</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1229
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "forgeflow-erp-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"vite": "^5.4.0"
}
}
+54
View File
@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="238 65 828 828" role="img" aria-label="嘉恒智能">
<defs>
<linearGradient id="logoBlue" x1="258" y1="166" x2="1047" y2="748" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#12a6df"/>
<stop offset="0.48" stop-color="#087fc3"/>
<stop offset="1" stop-color="#005ba8"/>
</linearGradient>
<linearGradient id="logoBlueDark" x1="313" y1="279" x2="936" y2="688" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0d8ed0"/>
<stop offset="1" stop-color="#004f9c"/>
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd">
<path
fill="url(#logoBlueDark)"
d="M651 53l22 67 70 1-57 41 21 68-56-42-57 42 22-68-57-41 70-1z"
/>
<path stroke="#087fc3" stroke-width="18" stroke-linecap="round" d="M651 188v88"/>
<path
fill="url(#logoBlue)"
d="M650 244 516 167 238 328v314l278 160 134-77V613L516 691 332 585V385l184-106 134 77z"
/>
<path
fill="url(#logoBlue)"
d="M654 244 788 167l278 161v314L788 802l-134-77V613l134 78 184-106V385L788 279l-134 77z"
/>
<path
fill="url(#logoBlueDark)"
d="M574 179c0-26 28-43 51-30l108 62c16 9 26 26 26 44v142l122 70c23 13 23 47 0 60l-122 70v142c0 18-10 35-26 44l-108 62c-23 13-51-4-51-30V597L452 527c-23-13-23-47 0-60l122-70z"
/>
<path
stroke="#fff"
stroke-width="50"
stroke-linecap="round"
stroke-linejoin="round"
d="M651 275v204M651 479 512 398M651 479l139-81M651 479v205M651 684l-139-81M651 684l139-81"
/>
<path
stroke="#fff"
stroke-width="42"
stroke-linecap="round"
stroke-linejoin="round"
d="M390 433v119l126 73M914 433v119l-126 73"
/>
<path
fill="#087fc3"
d="M229 883c181-76 665-76 846 0-220-38-626-38-846 0z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

+54
View File
@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1304" height="1028" viewBox="0 0 1304 1028" role="img" aria-label="宁波嘉恒智能科技有限公司 logo">
<defs>
<linearGradient id="logoBlue" x1="258" y1="166" x2="1047" y2="748" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#12a6df"/>
<stop offset="0.48" stop-color="#087fc3"/>
<stop offset="1" stop-color="#005ba8"/>
</linearGradient>
<linearGradient id="logoBlueDark" x1="313" y1="279" x2="936" y2="688" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0d8ed0"/>
<stop offset="1" stop-color="#004f9c"/>
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd">
<path
fill="url(#logoBlueDark)"
d="M651 53l22 67 70 1-57 41 21 68-56-42-57 42 22-68-57-41 70-1z"
/>
<path stroke="#087fc3" stroke-width="18" stroke-linecap="round" d="M651 188v88"/>
<path
fill="url(#logoBlue)"
d="M650 244 516 167 238 328v314l278 160 134-77V613L516 691 332 585V385l184-106 134 77z"
/>
<path
fill="url(#logoBlue)"
d="M654 244 788 167l278 161v314L788 802l-134-77V613l134 78 184-106V385L788 279l-134 77z"
/>
<path
fill="url(#logoBlueDark)"
d="M574 179c0-26 28-43 51-30l108 62c16 9 26 26 26 44v142l122 70c23 13 23 47 0 60l-122 70v142c0 18-10 35-26 44l-108 62c-23 13-51-4-51-30V597L452 527c-23-13-23-47 0-60l122-70z"
/>
<path
stroke="#fff"
stroke-width="50"
stroke-linecap="round"
stroke-linejoin="round"
d="M651 275v204M651 479 512 398M651 479l139-81M651 479v205M651 684l-139-81M651 684l139-81"
/>
<path
stroke="#fff"
stroke-width="42"
stroke-linecap="round"
stroke-linejoin="round"
d="M390 433v119l126 73M914 433v119l-126 73"
/>
<path
fill="#087fc3"
d="M229 883c181-76 665-76 846 0-220-38-626-38-846 0z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const inventoryLedger = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const purchaseOrder = readFileSync(new URL("../src/views/PurchaseOrderView.vue", import.meta.url), "utf8");
const purchaseReceipt = readFileSync(new URL("../src/views/PurchaseReceiptView.vue", import.meta.url), "utf8");
assert.match(inventoryLedger, /const activeWeightOnly = computed\(\(\) => \["raw", "scrap"\]\.includes\(activeInventoryTab\.value\)\)/);
assert.match(inventoryLedger, /const selectedWeightOnly = computed\(\s*\(\) => \["RAW", "SCRAP"\]\.includes\(selectedWarehouseType\.value\)\s*\)/);
assert.match(inventoryLedger, /const activeQuantityOnly = computed\(\(\) => activeInventoryTab\.value === "auxiliary"\)/);
assert.match(inventoryLedger, /const selectedQuantityOnly = computed\(\(\) => selectedWarehouseType\.value === "AUX"\)/);
assert.match(inventoryLedger, /normalizeWarehouseType\(lot\) === "AUX"[\s\S]*return lotAvailableQty\(lot\) > 0/);
assert.match(inventoryLedger, /<th v-if="!activeQuantityOnly">现存重量\(kg\)<\/th>/);
assert.match(inventoryLedger, /<td v-if="!activeQuantityOnly">[\s\S]*formatWeight\(balance\.weight_on_hand_kg\)/);
assert.match(inventoryLedger, /<th v-if="!activeQuantityOnly">变动重量\(kg\)<\/th>/);
assert.match(inventoryLedger, /selectedQuantityOnly\.value[\s\S]*return "可用数量"/);
assert.match(inventoryLedger, /isSelectedFinishedWarehouse\.value \|\| selectedQuantityOnly\.value[\s\S]*key: "inbound_qty"/);
assert.match(inventoryLedger, /!\s*selectedQuantityOnly\.value \? \[\s*\{ key: "inbound_weight_kg"/);
assert.match(inventoryLedger, /activeWarehouseType\.value === "AUX"\s*\?\s*Number\(genericForm\.qty \|\| 0\)/);
assert.match(inventoryLedger, /辅料生产出库必须填写用途/);
assert.match(inventoryLedger, /activeWarehouseType === "AUX" \? "单价\(元\/件\)" : "单价\(元\/kg\)"/);
assert.match(inventoryLedger, /<th v-if="activeQuantityOnly">单位<\/th>/);
assert.match(inventoryLedger, /<td v-if="activeQuantityOnly">\{\{ formatInventoryUnitLabel\(balance\) \}\}<\/td>/);
assert.match(inventoryLedger, /searchFields: \["item_code", "item_name", "specification", "stock_unit_name", "purchase_unit_name", "sales_unit_name", "warehouse_name", "location_name", "operation_label", "source_material_summary"\]/);
assert.match(inventoryLedger, /const specificationLabel = balance\.specification \? ` · \$\{balance\.specification\}` : ""/);
assert.match(inventoryLedger, /const baseLabel = `\$\{balance\.item_code\} · \$\{balance\.item_name\}\$\{specificationLabel\}`/);
assert.match(inventoryLedger, /function formatInventoryUnitLabel\(item\)/);
assert.match(inventoryLedger, /计量单位:\(\[\^;;\]\+\)/);
assert.match(purchaseOrder, /v-model\.number="item\.order_qty"/);
assert.match(purchaseOrder, /order_qty: form\.target_warehouse_type === "AUX" \? Number\(item\.order_qty \|\| 0\) : 0/);
assert.match(purchaseOrder, /order_weight_kg: form\.target_warehouse_type === "AUX" \? 0 : Number\(item\.order_weight_kg \|\| 0\)/);
assert.match(purchaseOrder, /formatOrderTotal\(order\)/);
assert.match(purchaseOrder, /formatPurchaseUnitPriceDisplay\(row\)/);
assert.match(purchaseReceipt, /const selectedPurchaseTargetType = computed/);
assert.match(purchaseReceipt, /v-model\.number="item\.received_qty"/);
assert.match(purchaseReceipt, /received_qty: selectedPurchaseTargetType\.value === "AUX" \? Number\(item\.received_qty \|\| 0\) : 0/);
assert.match(purchaseReceipt, /received_weight_kg: selectedPurchaseTargetType\.value === "AUX" \? 0 : Number\(item\.received_weight_kg \|\| 0\)/);
assert.match(purchaseReceipt, /formatReceiptItemReceived\(row\)/);
console.log("辅料库数量口径前端静态检查通过");
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const dashboardSource = readFileSync(new URL("../src/views/DashboardView.vue", import.meta.url), "utf8");
assert.match(dashboardSource, /原材料生命周期 DAG/, "经营总览应保留原材料生命周期 DAG");
assert.match(dashboardSource, /拖动画布 · 点击节点看详情/, "DAG 操作提示不应再提滚轮缩放");
assert.match(dashboardSource, /title="放大"[\s\S]*@click="zoomCanvasBy\(0\.08\)"/, "DAG 右上角应提供放大按钮");
assert.match(dashboardSource, /title="缩小"[\s\S]*@click="zoomCanvasBy\(-0\.08\)"/, "DAG 右上角应提供缩小按钮");
assert.match(dashboardSource, /function zoomCanvasBy\(delta\)/, "DAG 应通过按钮函数调整缩放比例");
assert.doesNotMatch(dashboardSource, /@wheel/, "DAG 视图不应绑定鼠标滚轮缩放");
assert.doesNotMatch(dashboardSource, /function zoomCanvas\(event\)/, "DAG 不应保留鼠标滚轮缩放函数");
assert.doesNotMatch(dashboardSource, /deltaY/, "DAG 缩放逻辑不应依赖鼠标滚轮 deltaY");
console.log("dashboard lifecycle DAG zoom checks passed");
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
function readSource(relativePath) {
return readFileSync(path.join(root, relativePath), "utf8");
}
const inventory = readSource("src/views/InventoryLedgerView.vue");
const delivery = readSource("src/views/DeliveryManagementView.vue");
const app = readSource("src/App.vue");
const router = readSource("src/router/index.js");
const deliveryWorkspace = readSource("src/views/DeliveryWorkspaceView.vue");
assert.match(inventory, /delivery_mode:\s*"SALES_ORDER"/, "sales-out form should default to sales-order delivery mode");
assert.match(inventory, /直接客户发货/, "warehouse sales-out drawer should support direct customer delivery");
assert.match(inventory, /销售订单发货/, "warehouse sales-out drawer should keep sales-order delivery mode");
assert.match(inventory, /isSalesOrderDelivery/, "sales-out drawer should centralize mode-dependent behavior");
assert.match(inventory, /setDeliveryMode\("DIRECT_CUSTOMER"\)/, "direct customer mode should be selectable");
assert.match(inventory, /deliveryForm\.delivery_mode === "SALES_ORDER"/, "sales order fields should be conditional");
assert.match(inventory, /source_material_summary/, "pre-submit allocation rows should expose source material summary");
assert.match(inventory, />来源材料摘要</, "pre-submit allocation table should show source material summary column");
assert.match(inventory, />成品库存批次号</, "pre-submit allocation table should show finished stock lot column");
assert.match(inventory, />来源原材料库存批次号</, "pre-submit allocation table should show source raw-material lot column");
assert.match(inventory, /sales_order_id:\s*isSalesOrderDelivery\.value \? Number\(deliveryForm\.sales_order_id\) : null/, "direct customer delivery should send null sales_order_id");
assert.match(inventory, /sales_order_item_id:\s*isSalesOrderDelivery\.value \? Number\(deliveryForm\.sales_order_item_id\) : null/, "direct customer delivery lines should send null sales_order_item_id");
assert.match(delivery, /发货台账/, "delivery management should be renamed to delivery ledger");
assert.match(delivery, /交付查询/, "delivery ledger should describe query/trace use");
assert.match(delivery, />销售订单号</, "delivery ledger should show sales order number");
assert.match(delivery, /source_material_summary/, "delivery ledger detail should keep material trace summary");
assert.doesNotMatch(delivery, /新增发货/, "delivery ledger must not expose create-delivery button");
assert.doesNotMatch(delivery, /openCreateDrawer/, "delivery ledger must not keep create drawer action");
assert.doesNotMatch(delivery, /submitDelivery/, "delivery ledger must not keep create submit handler");
assert.doesNotMatch(delivery, /postResource/, "delivery ledger must not import write API helpers");
assert.doesNotMatch(delivery, /uploadResource/, "delivery ledger must not upload logistics photos");
assert.doesNotMatch(delivery, /title="新增发货"/, "delivery ledger must not contain create drawer");
assert.match(app, /label: "发货台账"/, "main navigation should expose delivery ledger naming");
assert.match(router, /title: "发货台账"/, "route tab title should expose delivery ledger naming");
assert.match(deliveryWorkspace, /label: "发货台账"/, "delivery workspace section should expose delivery ledger naming");
console.log("delivery entry unification checks passed");
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const source = fs.readFileSync(path.resolve("src/views/DeliveryManagementView.vue"), "utf8");
assert.match(
source,
/const deliveryLedgerRows = computed\(\(\) =>/,
"delivery ledger should build sales-order-level aggregate rows"
);
assert.match(
source,
/v-for="group in paginatedDeliveryGroups"/,
"main delivery ledger table should render aggregated sales-order groups"
);
assert.doesNotMatch(
source,
/v-for="delivery in paginatedDeliveries"/,
"main delivery ledger table should not render one row per delivery order"
);
assert.match(
source,
/function buildDeliveryLedgerRows\(/,
"delivery ledger should have a dedicated aggregation helper"
);
assert.match(
source,
/发货单流水/,
"sales-order detail drawer should show delivery order flow"
);
assert.match(
source,
/发货明细流水/,
"sales-order detail drawer should show delivery item flow"
);
assert.match(
source,
/const activeDeliveryGroup = computed\(\(\) =>/,
"detail drawer should be driven by the selected sales-order group"
);
console.log("delivery ledger order aggregation checks passed");
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const appSource = fs.readFileSync(path.resolve("src/App.vue"), "utf8");
const routerSource = fs.readFileSync(path.resolve("src/router/index.js"), "utf8");
const deliveryWorkspaceSource = fs.readFileSync(path.resolve("src/views/DeliveryWorkspaceView.vue"), "utf8");
const scrapReworkSource = fs.readFileSync(path.resolve("src/views/ScrapReworkView.vue"), "utf8");
assert.match(appSource, /label:\s*"发货概览"/, "left menu module label should be renamed to 发货概览");
assert.match(routerSource, /title:\s*"发货概览"/, "route title should be renamed to 发货概览");
assert.match(deliveryWorkspaceSource, /eyebrow="发货概览"/, "delivery workspace eyebrow should use 发货概览");
assert.match(deliveryWorkspaceSource, /发货概览模块/, "delivery workspace description should use 发货概览");
assert.match(scrapReworkSource, /发货概览模块/, "scrap/rework guidance should use the renamed module");
for (const [file, source] of [
["src/App.vue", appSource],
["src/router/index.js", routerSource],
["src/views/DeliveryWorkspaceView.vue", deliveryWorkspaceSource],
["src/views/ScrapReworkView.vue", scrapReworkSource]
]) {
assert.doesNotMatch(source, /发货与售后/, `${file} should not keep the old delivery module name`);
}
console.log("delivery overview rename checks passed");
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import * as dictionaries from "../src/utils/dictionaries.js";
const root = process.cwd();
function readSource(relativePath) {
return readFileSync(path.join(root, relativePath), "utf8");
}
function assertFormatter(name, samples) {
assert.equal(typeof dictionaries[name], "function", `${name} should be exported`);
samples.forEach(([value, expected]) => {
assert.equal(dictionaries[name](value), expected, `${name} should translate ${value}`);
});
}
function assertNoRawEnumFallback(name) {
const rawCode = "UNTRANSLATED_SAMPLE_ENUM";
assert.notEqual(dictionaries[name](rawCode), rawCode, `${name} must not render raw English enum fallbacks`);
assert.match(dictionaries[name](rawCode), /未识别/, `${name} fallback should be Chinese`);
}
assertFormatter("formatInventoryTxnTypeLabel", [
["OPENING_IN", "期初入库"],
["PRODUCTION_SURPLUS_IN", "生产余料入库"],
["OUTSOURCING_SURPLUS_IN", "委外余料入库"],
["WIP_IN", "产中入库"],
["WIP_OUT", "产中出库"],
["PRODUCTION_FINISHED_IN", "生产入库"],
["STOCKTAKE_GAIN", "盘库盘盈"],
["STOCKTAKE_LOSS", "盘库盘亏"],
["STOCKTAKE_NEW_LOT", "盘库新增批次"]
]);
assertNoRawEnumFallback("formatInventoryTxnTypeLabel");
assertFormatter("formatSourceDocTypeLabel", [
["OPENING_STOCK", "期初入库"],
["STOCKTAKE", "盘库"],
["PRODUCTION_OUT", "生产出库"],
["OUTSOURCING_OUT", "委外出库"],
["RETURN_OUT", "返工出库"]
]);
assertNoRawEnumFallback("formatSourceDocTypeLabel");
assertFormatter("formatWarehouseTypeLabel", [
["RAW", "原材料库"],
["SEMI", "半成品库"],
["FINISHED", "成品库"],
["AUX", "辅料库"],
["SCRAP", "废料库"],
["RETURN", "退货库"]
]);
assertNoRawEnumFallback("formatWarehouseTypeLabel");
assertFormatter("formatItemTypeLabel", [
["RAW_MATERIAL", "原材料"],
["FINISHED_GOOD", "成品"],
["PRODUCT", "产品"]
]);
assertNoRawEnumFallback("formatItemTypeLabel");
assertFormatter("formatPurchaseSourceTypeLabel", [
["SALES_ORDER", "销售订单采购"],
["STOCKING", "备料采购"]
]);
assertNoRawEnumFallback("formatPurchaseSourceTypeLabel");
assertFormatter("formatMiniappRoleLabel", [
["worker", "员工"],
["manager", "经理"],
["admin", "管理员"]
]);
assertNoRawEnumFallback("formatMiniappRoleLabel");
assertFormatter("formatMaintenanceTypeLabel", [
["PERIODIC", "周期保养"],
["REPAIR", "维修保养"],
["INSPECTION", "点检保养"]
]);
assertNoRawEnumFallback("formatMaintenanceTypeLabel");
assertFormatter("formatStocktakeRowStatusLabel", [
["READY", "可确认"],
["ERROR", "异常"],
["IGNORED", "已忽略"]
]);
assertNoRawEnumFallback("formatStocktakeRowStatusLabel");
const dictionarySource = readSource("src/utils/dictionaries.js");
assert.match(dictionarySource, /function normalizeDictionaryKey/, "dictionary lookup should normalize keys");
assert.match(dictionarySource, /function isEnumLikeValue/, "dictionary lookup should identify enum-like fallbacks");
assert.match(dictionarySource, /未识别/, "dictionary fallback should be Chinese");
const tableControlsSource = readSource("src/utils/tableControls.js");
[
"WAREHOUSE_TYPE_LABELS",
"ITEM_TYPE_LABELS",
"PURCHASE_SOURCE_TYPE_LABELS",
"MAINTENANCE_TYPE_LABELS",
"MINIAPP_ROLE_LABELS",
"STOCKTAKE_ROW_STATUS_LABELS"
].forEach((mapName) => {
assert.match(tableControlsSource, new RegExp(mapName), `TableControls search should include ${mapName}`);
});
const inventorySource = readSource("src/views/InventoryLedgerView.vue");
assert.match(inventorySource, /formatWarehouseTypeLabel/, "inventory view should use warehouse type formatter");
assert.match(inventorySource, /formatItemTypeLabel/, "inventory view should use item type formatter");
assert.doesNotMatch(inventorySource, /placeholder="如 SALES_OUT、RETURN_IN"/, "inventory ledger filter should not show English examples");
const equipmentSource = readSource("src/views/EquipmentManagementView.vue");
assert.match(equipmentSource, /formatMaintenanceTypeLabel/, "equipment view should translate maintenance types");
assert.doesNotMatch(equipmentSource, /\{\{\s*order\.maintenance_type\s*\}\}/, "equipment view must not render raw maintenance_type");
const employeeSource = readSource("src/views/EmployeeManagementView.vue");
assert.match(employeeSource, /formatPersonRoles/, "employee view should centralize role display formatting");
assert.match(employeeSource, /formatMiniappRoleLabel/, "employee view should translate miniapp role codes");
assert.doesNotMatch(employeeSource, /personnel \/ person_roles/, "employee view should not show raw table names");
const stocktakeSource = readSource("src/components/StocktakeDialog.vue");
assert.match(stocktakeSource, /formatStocktakeRowStatusLabel/, "stocktake dialog should have row status formatter available");
const purchaseSource = readSource("src/views/PurchaseOrderView.vue");
assert.match(purchaseSource, /formatPurchaseSourceTypeLabel/, "purchase order view should translate source_type");
assert.doesNotMatch(purchaseSource, /"source_type",\s*$/m, "purchase order search should not rely on raw source_type only");
console.log("dictionary localization checks passed");
@@ -0,0 +1,72 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const files = [
"src/components/documentForms/DocumentPaper.vue",
"src/components/documentForms/DocumentGrid.vue",
"src/components/documentForms/DocumentLineTable.vue",
"src/components/documentForms/DocumentArchiveActions.vue",
"src/styles/main.css"
];
const sources = new Map();
for (const file of files) {
const path = resolve(process.cwd(), file);
if (!existsSync(path)) {
throw new Error(`Missing required file: ${file}`);
}
const source = readFileSync(path, "utf8");
if (source.trim().length < 200) {
throw new Error(`Required file has unexpectedly short content: ${file}`);
}
sources.set(file, source);
}
const documentPaper = sources.get("src/components/documentForms/DocumentPaper.vue");
for (const snippet of ["ERP留存联", "document-paper-clip", "document-paper-binder-hole"]) {
if (!documentPaper.includes(snippet)) {
throw new Error(`DocumentPaper missing required snippet: ${snippet}`);
}
}
const archiveActions = sources.get("src/components/documentForms/DocumentArchiveActions.vue");
for (const snippet of ["预览PDF", "下载PDF", "重新生成"]) {
if (!archiveActions.includes(snippet)) {
throw new Error(`DocumentArchiveActions missing required label: ${snippet}`);
}
}
for (const forbiddenSnippet of ["ghost-button", "action-button"]) {
if (archiveActions.includes(forbiddenSnippet)) {
throw new Error(`DocumentArchiveActions must not use global button class: ${forbiddenSnippet}`);
}
}
for (const snippet of ["document-archive-button", "normalizedStatusType", ".trim()"]) {
if (!archiveActions.includes(snippet)) {
throw new Error(`DocumentArchiveActions missing archive action hardening snippet: ${snippet}`);
}
}
const css = sources.get("src/styles/main.css");
for (const selector of [".document-paper-shell", ".document-paper-sheet", ".document-line-table"]) {
if (!css.includes(selector)) {
throw new Error(`main.css missing required selector: ${selector}`);
}
}
for (const selector of [
".document-archive-button",
".document-archive-button-preview",
".document-archive-button-download",
".document-archive-button-regenerate"
]) {
if (!css.includes(selector)) {
throw new Error(`main.css missing document archive button selector: ${selector}`);
}
}
console.log("document form components static tests passed");
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const componentPath = resolve(process.cwd(), "src/components/DrawerDataTable.vue");
const source = readFileSync(componentPath, "utf8");
const requiredSnippets = [
"defineProps",
"columns",
"rows",
"rowKey",
"expandedRows",
"cellClass",
"slot :name=\"`cell-${column.key}`\"",
"drawer-data-table",
"drawer-row-detail"
];
for (const snippet of requiredSnippets) {
if (!source.includes(snippet)) {
throw new Error(`DrawerDataTable missing required snippet: ${snippet}`);
}
}
console.log("drawer data table static tests passed");
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const pages = [
{
file: "src/views/DeliveryManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedDeliveryItemRows",
"const deliveryItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedDeliveryItemRows\""
]
},
{
file: "src/views/CompletionReceiptView.vue",
snippets: [
"import DrawerDataTable",
"const expandedCompletionItemRows",
"const completionItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedCompletionItemRows\""
]
},
{
file: "src/views/WorkOrderManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedWorkOrderMaterialRows",
"const expandedWorkOrderOperationRows",
"const workOrderMaterialDrawerColumns",
"const workOrderOperationDrawerColumns",
"v-model:expanded-rows=\"expandedWorkOrderMaterialRows\"",
"v-model:expanded-rows=\"expandedWorkOrderOperationRows\""
]
},
{
file: "src/views/PurchaseReceiptView.vue",
snippets: [
"import DrawerDataTable",
"const expandedReceiptItemRows",
"const receiptItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedReceiptItemRows\""
]
},
{
file: "src/views/ReturnManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedDispositionRows",
"const dispositionDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedDispositionRows\""
]
},
{
file: "src/views/BomManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedBomItemRows",
"const bomItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedBomItemRows\""
]
},
{
file: "src/views/ProcessRouteManagementView.vue",
snippets: [
"import DrawerDataTable",
"const expandedRouteOperationRows",
"const routeOperationDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedRouteOperationRows\""
]
},
{
file: "src/views/PurchaseOrderView.vue",
snippets: [
"import DrawerDataTable",
"const expandedPurchaseOrderItemRows",
"const purchaseOrderItemDrawerColumns",
"<DrawerDataTable",
"v-model:expanded-rows=\"expandedPurchaseOrderItemRows\""
]
}
];
for (const page of pages) {
const source = readFileSync(resolve(process.cwd(), page.file), "utf8");
for (const snippet of page.snippets) {
assert.ok(source.includes(snippet), `${page.file} missing drawer table migration snippet: ${snippet}`);
}
}
console.log("drawer table migration static tests passed");
@@ -0,0 +1,560 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { buildGenericItemOptions, buildProductOperationOptions } from "../src/utils/inventoryOperationOptions.js";
const stockedBalances = [
{
item_id: 101,
item_code: "原材料00001",
item_name: "304不锈钢",
warehouse_id: 1,
warehouse_type: "RAW",
weight_available_kg: 100,
qty_available: 0,
avg_unit_cost: 4
},
{
item_id: 101,
item_code: "原材料00001",
item_name: "304不锈钢",
warehouse_id: 1,
warehouse_type: "RAW",
weight_available_kg: 40,
qty_available: 0,
avg_unit_cost: 5
},
{
item_id: 102,
item_code: "原材料00002",
item_name: "冷轧板",
warehouse_id: 1,
warehouse_type: "RAW",
weight_available_kg: 0,
qty_available: 0
},
{
item_id: 103,
item_code: "原材料00003",
item_name: "铝合金",
warehouse_id: 2,
warehouse_type: "RAW",
weight_available_kg: 80,
qty_available: 0
}
];
const rawOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "raw",
activeWarehouseType: "RAW",
warehouseId: 1,
balances: stockedBalances,
materials: [],
products: []
});
assert.deepEqual(
rawOutboundOptions.map((item) => ({
item_id: item.item_id,
item_code: item.item_code,
item_name: item.item_name,
weight_available_kg: item.weight_available_kg
})),
[
{
item_id: 101,
item_code: "原材料00001",
item_name: "304不锈钢",
weight_available_kg: 140
}
]
);
const rawInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "raw",
activeWarehouseType: "RAW",
warehouseId: 1,
balances: stockedBalances,
materials: [{ item_id: 201, item_code: "原材料00004", item_name: "铜板" }],
products: [{ item_id: 301, item_code: "产品00001", item_name: "支架" }]
});
assert.deepEqual(rawInboundOptions.map((item) => item.item_id), [201]);
const finishedOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "finished",
activeWarehouseType: "FINISHED",
warehouseId: 8,
balances: [
{
item_id: 401,
item_code: "产品需规00001",
item_name: "电脑支架",
warehouse_id: 8,
warehouse_type: "FINISHED",
qty_available: 12,
weight_available_kg: 30
}
],
materials: [],
products: []
});
assert.equal(finishedOutboundOptions.length, 1);
assert.equal(finishedOutboundOptions[0].qty_available, 12);
const semiOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "semi",
activeWarehouseType: "SEMI",
warehouseId: 9,
balances: [
{
item_id: 501,
item_code: "产品需规00002",
item_name: "半成品支架",
source_line_id: 9101,
option_key: "501::9101",
operation_label: "产品:半成品支架;已完成工序:第1道 冲压",
warehouse_id: 9,
warehouse_type: "SEMI",
qty_available: 6,
weight_available_kg: 12
},
{
item_id: 501,
item_code: "产品需规00002",
item_name: "半成品支架",
source_line_id: 9102,
option_key: "501::9102",
operation_label: "产品:半成品支架;已完成工序:第2道 折弯",
warehouse_id: 9,
warehouse_type: "SEMI",
qty_available: 8,
weight_available_kg: 20
}
],
materials: [],
products: []
});
assert.equal(semiOutboundOptions.length, 2);
assert.deepEqual(semiOutboundOptions.map((item) => item.option_key), ["501::9101", "501::9102"]);
const productMasterRows = [{ item_id: 601, item_code: "产品需规00003", item_name: "委外成品" }];
const productOperationRows = [
{
option_key: "601::7001",
item_id: 601,
item_code: "产品需规00003",
item_name: "委外成品",
route_id: 8001,
route_operation_id: 7001,
seq_no: 1,
operation_name: "冲压"
},
{
option_key: "601::7002",
item_id: 601,
item_code: "产品需规00003",
item_name: "委外成品",
route_id: 8001,
route_operation_id: 7002,
seq_no: 2,
operation_name: "折弯"
}
];
const routeOperationOptions = buildProductOperationOptions([
{
route_id: 8001,
product_item_id: 601,
product_code: "产品需规00003",
product_name: "委外成品",
version_no: "V1.0",
status: "ACTIVE",
operations: [
{
route_operation_id: 7001,
seq_no: 1,
operation_name: "1",
process_name: "1",
stamping_method: "切边冲孔",
status: "ACTIVE"
}
]
}
]);
assert.equal(routeOperationOptions[0].operation_label, "第1道 切边冲孔");
assert.equal(routeOperationOptions[0].source_material_summary, "产品:委外成品;已完成工序:第1道 切边冲孔");
const operationWithoutStampingMethod = buildProductOperationOptions([
{
route_id: 8002,
product_item_id: 602,
product_code: "产品需规00004",
product_name: "无冲压方式产品",
version_no: "V1.0",
status: "ACTIVE",
operations: [
{
route_operation_id: 7003,
seq_no: 1,
operation_name: "1",
process_name: "开平",
stamping_method: "",
status: "ACTIVE"
}
]
}
]);
assert.equal(operationWithoutStampingMethod[0].operation_label, "第1道");
const semiInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "semi",
activeWarehouseType: "SEMI",
warehouseId: 9,
balances: [],
materials: [],
products: productMasterRows,
productOperations: productOperationRows
});
const finishedInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "finished",
activeWarehouseType: "FINISHED",
warehouseId: 8,
balances: [],
materials: [],
products: productMasterRows
});
assert.deepEqual(semiInboundOptions.map((item) => item.option_key), ["601::7001", "601::7002"]);
assert.deepEqual(semiInboundOptions.map((item) => item.route_operation_id), [7001, 7002]);
assert.deepEqual(finishedInboundOptions.map((item) => item.item_id), [601]);
const auxiliaryOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "auxiliary",
activeWarehouseType: "AUX",
warehouseId: 10,
balances: [
{
item_id: 701,
item_code: "辅料00001",
item_name: "包装膜",
warehouse_id: 10,
warehouse_type: "AUX",
qty_available: 0,
weight_available_kg: 25
}
],
materials: [],
products: []
});
const auxiliaryInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "auxiliary",
activeWarehouseType: "AUX",
warehouseId: 10,
balances: [],
materials: [{ item_id: 702, item_code: "辅料00002", item_name: "手套" }],
products: []
});
assert.equal(auxiliaryOutboundOptions.length, 1);
assert.equal(auxiliaryOutboundOptions[0].item_id, 701);
assert.deepEqual(auxiliaryInboundOptions.map((item) => item.item_id), [702]);
const scrapInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "scrap",
activeWarehouseType: "SCRAP",
warehouseId: 11,
balances: [],
materials: [{ item_id: 801, item_code: "原材料00005", item_name: "冲压边角料" }],
products: [{ item_id: 901, item_code: "产品需规00004", item_name: "报废支架" }]
});
const scrapOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "scrap",
activeWarehouseType: "SCRAP",
warehouseId: 11,
balances: [
{
item_id: 801,
item_code: "原材料00005",
item_name: "冲压边角料",
warehouse_id: 11,
warehouse_type: "SCRAP",
qty_available: 10,
weight_available_kg: 0
},
{
item_id: 901,
item_code: "产品需规00004",
item_name: "报废支架",
warehouse_id: 11,
warehouse_type: "SCRAP",
qty_available: 0,
weight_available_kg: 42
},
{
item_id: 902,
item_code: "产品需规00005",
item_name: "异仓废料",
warehouse_id: 12,
warehouse_type: "SCRAP",
qty_available: 0,
weight_available_kg: 18
}
],
materials: [],
products: []
});
const scrapWarehouseTypeInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "raw",
activeWarehouseType: "SCRAP",
warehouseId: 11,
balances: [],
materials: [{ item_id: 803, item_code: "原材料00006", item_name: "废铜" }],
products: [{ item_id: 903, item_code: "产品需规00006", item_name: "报废夹具" }]
});
assert.deepEqual(scrapInboundOptions.map((item) => item.item_id), [801, 901]);
assert.deepEqual(scrapWarehouseTypeInboundOptions.map((item) => item.item_id), [803, 903]);
assert.deepEqual(
scrapOutboundOptions.map((item) => ({
item_id: item.item_id,
qty_available: item.qty_available,
weight_available_kg: item.weight_available_kg
})),
[
{
item_id: 901,
qty_available: 0,
weight_available_kg: 42
}
]
);
const inventoryLedgerSource = readFileSync(
fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)),
"utf8"
);
const deliveryManagementSource = readFileSync(
fileURLToPath(new URL("../src/views/DeliveryManagementView.vue", import.meta.url)),
"utf8"
);
const tableControlsSource = readFileSync(
fileURLToPath(new URL("../src/components/TableControls.vue", import.meta.url)),
"utf8"
);
const stockLotTagSelectSource = readFileSync(
fileURLToPath(new URL("../src/components/StockLotTagSelect.vue", import.meta.url)),
"utf8"
);
assert.match(inventoryLedgerSource, /key: "scrap"/);
assert.match(inventoryLedgerSource, /label: "废料库"/);
assert.match(inventoryLedgerSource, /PRODUCTION_SCRAP_IN/);
assert.match(
inventoryLedgerSource,
/const genericUnitCostLabel = computed\(\(\) =>[\s\S]*isProductionScrapInbound\.value[\s\S]*原材料成本单价\(元\/kg\)/,
"production scrap inbound should label unit cost as raw material cost unit price"
);
assert.match(
inventoryLedgerSource,
/const productionScrapToleranceRemainingWeight = computed\(\(\) => \{[\s\S]*productionScrapExpectedWeight\.value[\s\S]*1\.15[\s\S]*productionScrapInboundBeforeWeight/,
"production scrap inbound should calculate the non-settle upper limit from cumulative scrap inbound weight"
);
assert.match(
inventoryLedgerSource,
/genericForm\.settle_work_order && productionScrapSettleNeedsRemark\.value/,
"production scrap inbound should require a settle explanation when final scrap weight is outside tolerance"
);
assert.match(
inventoryLedgerSource,
/const scrapToleranceMax = Number\(productionScrapToleranceRemainingWeight\.value \|\| 0\);[\s\S]*inboundWeight > scrapToleranceMax/,
"production scrap inbound validation should compare inbound weight against cumulative upper-limit remaining amount"
);
assert.match(
inventoryLedgerSource,
/activeOperation\?\.direction !== 'out' && !isCustomerSuppliedInbound && !isSourceLotReturnInbound && !isSemiSourceLotTraceInbound && !isFinishedOutsourcingInbound && !isProductionScrapInbound && !isReworkScrapInbound && !isOutsourcingScrapInbound/,
"production scrap inbound should hide the manual batch number field"
);
assert.match(
inventoryLedgerSource,
/lot_no: isCustomerSuppliedInbound\.value \? null : isProductionScrapInbound\.value \? null[\s\S]*isOutsourcingScrapInbound\.value \? null/,
"production scrap inbound should never submit a manually-entered batch number"
);
assert.match(inventoryLedgerSource, /OUTSOURCING_SCRAP_IN/);
assert.match(inventoryLedgerSource, /SCRAP_SALE_OUT/);
assert.match(inventoryLedgerSource, /LOGISTICS_REQUIRED_BIZ_TYPES/);
assert.match(inventoryLedgerSource, /"CUSTOMER_SUPPLIED"/);
assert.match(inventoryLedgerSource, /const isCustomerSuppliedInbound = computed/);
assert.match(inventoryLedgerSource, /customerSuppliedLotNoPreview/);
assert.match(inventoryLedgerSource, /提交后自动生成/);
assert.match(inventoryLedgerSource, /activeOperation\?\.direction !== 'out' && !isCustomerSuppliedInbound/);
assert.match(inventoryLedgerSource, /lot_no: isCustomerSuppliedInbound\.value \? null/);
assert.match(inventoryLedgerSource, /source_material_sub_batch_no: isCustomerSuppliedInbound\.value\s+\?\s+null/);
assert.match(inventoryLedgerSource, /const workOrderLedgerRows = ref\(\[\]\);/);
assert.match(inventoryLedgerSource, /fetchResource\("\/production\/work-order-ledger\?limit=500", \[\]\)/);
assert.match(inventoryLedgerSource, /workOrderLedgerRows\.value = workOrderLedgerRowData;/);
assert.match(
stockLotTagSelectSource,
/issued_weight_kg:\s*normalizeSelectedIssuedWeight\(lot\)/,
"StockLotTagSelect should preserve an option's issued_weight_kg instead of overwriting it with remaining weight"
);
assert.match(
stockLotTagSelectSource,
/function displayWeight\(lot\)/,
"StockLotTagSelect should support scenario-specific selected-tag weight display"
);
assert.match(
inventoryLedgerSource,
/:display-weight-field="isProductionSurplusInbound \? 'issued_weight_kg' : 'remaining_weight_kg'"/,
"production surplus inbound should display selected source lots by work-order issued weight"
);
assert.doesNotMatch(
inventoryLedgerSource,
/<div v-if="usesGenericSourceLotSelector && !isProductTraceOnlySourceLotInbound" class="feedback-banner">/,
"source-lot return/outbound live total should not use feedback-banner because it triggers toast notifications while typing"
);
assert.doesNotMatch(
inventoryLedgerSource,
/<div v-else-if="isProductTraceOnlySourceLotInbound && selectedGenericSourceLots\.length" class="feedback-banner">/,
"source-lot trace-only live summary should not use feedback-banner because it is not an operation result"
);
assert.match(
inventoryLedgerSource,
/function computeWorkOrderSourceLotReturnLimit\(workOrder,\s*material\)/,
"production surplus inbound should calculate source-lot return limit from the work-order surplus limit"
);
assert.match(
inventoryLedgerSource,
/max_return_weight_kg:\s*returnLimit\.normal/,
"source-lot normal return weight should be capped by the work-order normal surplus amount"
);
assert.match(
inventoryLedgerSource,
/tolerance_return_weight_kg:\s*returnLimit\.tolerance/,
"source-lot tolerance return weight should be capped by the work-order 15 percent tolerance amount"
);
assert.match(
inventoryLedgerSource,
/const productionSurplusToleranceRemainingWeight = computed\(\(\) => \{[\s\S]*productionSurplusExpectedReturnWeight\.value[\s\S]*1\.15[\s\S]*productionSurplusReturnedBeforeWeight/,
"production surplus inbound should calculate the non-settle upper limit from cumulative returned weight"
);
assert.match(
inventoryLedgerSource,
/:max="productionSurplusReturnInputMax\(lot\)"/,
"production surplus inbound input should use a dynamic return max so settle mode can allow explained deviations"
);
assert.match(
inventoryLedgerSource,
/function productionSurplusReturnInputMax\(lot\)[\s\S]*genericForm\.settle_work_order[\s\S]*source_remaining_weight_kg/,
"production surplus inbound should relax the input max to source-lot remaining return weight when settling"
);
assert.match(
inventoryLedgerSource,
/const toleranceMax = Number\(productionSurplusToleranceRemainingWeight\.value \|\| 0\);[\s\S]*genericSourceLotReturnWeight\.value > toleranceMax/,
"production surplus inbound validation should compare the selected return total against the cumulative upper-limit remaining amount"
);
assert.match(
inventoryLedgerSource,
/const receivableWorkOrders = computed\(\(\) =>\s*workOrderLedgerRows\.value\.filter\([\s\S]*!isWorkOrderSettled\(item\)[\s\S]*tolerance_finished_inbound_qty[\s\S]*\)\s*\);/
);
assert.match(
inventoryLedgerSource,
/completionForm\.tolerance_receivable_qty = Number\(workOrder\.limits\?\.tolerance_finished_inbound_qty \|\| completionForm\.max_receivable_qty \|\| 0\);/
);
assert.match(
inventoryLedgerSource,
/const genericWorkOrderOptions = computed\(\(\) => \{[\s\S]*!isWorkOrderSettled\(item,\s*"PRODUCTION_SCRAP_IN"\)[\s\S]*tolerance_scrap_inbound_weight_kg[\s\S]*!isWorkOrderSettled\(item,\s*"PRODUCTION_SURPLUS"\)[\s\S]*tolerance_surplus_inbound_weight_kg[\s\S]*return workOrderLedgerRows\.value\.filter\(\(item\) => !isWorkOrderSettled\(item\)\);[\s\S]*\}\);/
);
assert.match(
inventoryLedgerSource,
/source_doc_type: isProductionSurplusInbound\.value \? "WORK_ORDER" : null[\s\S]*source_doc_id: isProductionSurplusInbound\.value \? Number\(selectedGenericWorkOrder\.value\.work_order_id\) : null/
);
assert.match(
inventoryLedgerSource,
/source_doc_type: isProductionScrapInbound\.value \|\| isReworkScrapInbound\.value \? "WORK_ORDER" : activeWarehouseType\.value === "SEMI" \? "PRODUCT_ROUTE_OPERATION" : null[\s\S]*source_doc_id: isProductionScrapInbound\.value \|\| isReworkScrapInbound\.value \? Number\(selectedGenericWorkOrder\.value\.work_order_id\) : activeWarehouseType\.value === "SEMI" \? Number\(selectedSourceDocId \|\| 0\) : null/
);
assert.match(inventoryLedgerSource, /:key="`inventory-main-table-\$\{activeInventoryTab\}`"/);
assert.match(inventoryLedgerSource, /:key="`inventory-controls-\$\{activeInventoryTab\}`"/);
assert.match(inventoryLedgerSource, /balanceControls\.sortKey\.value === "operation_label"/);
assert.match(tableControlsSource, /hasEnhancedButton/);
assert.match(tableControlsSource, /currentHeaderLabel/);
assert.match(inventoryLedgerSource, /const isSelectedFinishedWarehouse = computed/);
assert.match(inventoryLedgerSource, /const selectedPrimaryAvailableLabel = computed/);
assert.match(inventoryLedgerSource, /const selectedPrimaryOnHandLabel = computed/);
assert.match(inventoryLedgerSource, /const selectedAuxiliaryMetricLabel = computed/);
assert.match(inventoryLedgerSource, /function formatSelectedUnitCost/);
assert.match(inventoryLedgerSource, /selectedWarehouseType\.value === "FINISHED"[\s\S]*return "\/件"/);
assert.match(inventoryLedgerSource, /import DrawerDataTable from "\.\.\/components\/DrawerDataTable\.vue"/);
assert.match(inventoryLedgerSource, /const overviewLotDrawerColumns = computed/);
assert.match(inventoryLedgerSource, /const lotDrawerColumns = computed/);
assert.match(inventoryLedgerSource, /const txnDrawerColumns = computed/);
assert.match(inventoryLedgerSource, /key: "inbound_qty"[\s\S]*label: "入库数量"/);
assert.match(inventoryLedgerSource, /key: "auxiliary_weight"[\s\S]*label: "辅助重量\(kg\)"/);
assert.match(inventoryLedgerSource, /v-model:expanded-rows="expandedLotRows"/);
assert.match(inventoryLedgerSource, /v-model:expanded-rows="expandedTxnRows"/);
assert.match(inventoryLedgerSource, /"OUTSOURCING_SURPLUS"/);
assert.match(inventoryLedgerSource, /"OUTSOURCING_IN"/);
assert.match(inventoryLedgerSource, /"OUTSOURCING_SCRAP_IN"/);
assert.match(inventoryLedgerSource, /"OUTSOURCING_OUT"/);
assert.match(inventoryLedgerSource, /"SCRAP_SALE_OUT"/);
assert.match(inventoryLedgerSource, /v-model\.trim="genericForm\.waybill_no"/);
assert.match(inventoryLedgerSource, /v-model\.number="genericForm\.freight_amount"/);
assert.match(inventoryLedgerSource, /handleLogisticsPhotoUpload\(genericForm/);
assert.match(inventoryLedgerSource, /order_photo_url: genericRequiresLogistics\.value \? genericForm\.order_photo_url/);
assert.match(inventoryLedgerSource, /v-model\.trim="deliveryForm\.waybill_no"/);
assert.match(inventoryLedgerSource, /v-model\.number="deliveryForm\.freight_amount"/);
assert.match(inventoryLedgerSource, /handleLogisticsPhotoUpload\(deliveryForm/);
assert.match(inventoryLedgerSource, /order_photo_url: deliveryForm\.order_photo_url/);
assert.match(deliveryManagementSource, /发货台账/);
assert.match(deliveryManagementSource, /交付查询/);
assert.doesNotMatch(deliveryManagementSource, /v-model\.trim="form\.waybill_no"/);
assert.doesNotMatch(deliveryManagementSource, /v-model\.number="form\.freight_amount"/);
assert.doesNotMatch(deliveryManagementSource, /handleLogisticsPhotoUpload/);
assert.doesNotMatch(deliveryManagementSource, /waybill_no: form\.waybill_no/);
assert.doesNotMatch(deliveryManagementSource, /order_photo_url: form\.order_photo_url/);
assert.doesNotMatch(deliveryManagementSource, /freight_amount: Number\(form\.freight_amount\)/);
assert.match(inventoryLedgerSource, /fetchResource\("\/master-data\/materials\?limit=200", \[\]\)/);
assert.match(inventoryLedgerSource, /fetchResource\("\/master-data\/products\?limit=200", \[\]\)/);
assert.match(inventoryLedgerSource, /fetchResource\("\/sales\/customers\?limit=200", \[\]\)/);
assert.doesNotMatch(inventoryLedgerSource, /fetchResource\("\/master-data\/materials\?limit=500", \[\]\)/);
assert.doesNotMatch(inventoryLedgerSource, /fetchResource\("\/master-data\/products\?limit=500", \[\]\)/);
assert.doesNotMatch(inventoryLedgerSource, /fetchResource\("\/sales\/customers\?limit=500", \[\]\)/);
assert.match(inventoryLedgerSource, /const isSemiSourceLotTraceInbound = computed/);
assert.match(inventoryLedgerSource, /\["WIP_IN", "OUTSOURCING_IN"\]\.includes/);
assert.match(inventoryLedgerSource, /const isSemiAutoSourceOutbound = computed/);
assert.match(inventoryLedgerSource, /\["WIP_OUT", "OUTSOURCING_OUT"\]\.includes/);
assert.match(inventoryLedgerSource, /const semiAutoSourceOutboundSourceLots = computed/);
assert.match(inventoryLedgerSource, /入库数量/);
assert.match(inventoryLedgerSource, /出库数量/);
assert.match(inventoryLedgerSource, /入库类型/);
assert.match(inventoryLedgerSource, /按件入库/);
assert.match(inventoryLedgerSource, /按重入库/);
assert.match(inventoryLedgerSource, /const selectedSemiMeasureBasis = computed/);
assert.match(inventoryLedgerSource, /function formatSemiAvailability/);
assert.match(inventoryLedgerSource, /function formatSemiMeasureValue/);
assert.match(inventoryLedgerSource, /委外方/);
assert.match(inventoryLedgerSource, /outsourcing_party_name/);
assert.match(inventoryLedgerSource, /getProductBomMaterialItemIds\(genericForm\.item_id\)/);
assert.match(inventoryLedgerSource, /:allow-unavailable="isSourceLotReturnInbound \|\| isSemiSourceLotTraceInbound \|\| isOutsourcingScrapInbound"/);
assert.match(inventoryLedgerSource, /const inboundQty = activeWarehouseType\.value === "AUX" \|\| isFinishedOutsourcingInbound\.value[\s\S]*\? Number\(genericForm\.qty \|\| 0\)[\s\S]*: isSemiSourceLotTraceInbound\.value && selectedSemiMeasureBasis\.value === "PIECE"/);
assert.match(inventoryLedgerSource, /outbound_qty: activeWarehouseType\.value === "AUX"[\s\S]*\? Number\(genericForm\.qty \|\| 0\)[\s\S]*: isSemiAutoSourceOutbound\.value && selectedSemiMeasureBasis\.value === "PIECE"/);
assert.match(inventoryLedgerSource, /const selectedTraceSourceLotIds = isProductTraceOnlySourceLotInbound\.value[\s\S]*selectedGenericSourceLots\.value\.map/);
assert.match(inventoryLedgerSource, /source_lot_ids: selectedTraceSourceLotIds/);
assert.match(inventoryLedgerSource, /semiAutoSourceOutboundSourceLotSummary/);
assert.match(inventoryLedgerSource, /仅用于来源追溯,不扣减原材料库存/);
console.log("inventory ledger option tests passed");
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const viewPath = path.join(root, "src/views/InventoryLedgerView.vue");
const dictionariesPath = path.join(root, "src/utils/dictionaries.js");
const view = fs.readFileSync(viewPath, "utf8");
const dictionaries = fs.readFileSync(dictionariesPath, "utf8");
const checks = [
["raw return operation button", view.includes("raw-return-out") && view.includes("退货出库")],
["dedicated raw return drawer", view.includes("rawReturnDrawerOpen") && view.includes("原材料退货出库")],
["purchase links endpoint", view.includes("/inventory/stock-lots/") && view.includes("/purchase-links")],
["return outbound endpoint", view.includes("/inventory/raw-material-return-outbound")],
["available weight subtracts locked weight", view.includes("rawReturnLotAvailableWeight") && view.includes("locked_weight_kg")],
["purchase link stale request guard", view.includes("rawReturnLinksRequestSeq") && view.includes("requestSeq !== rawReturnLinksRequestSeq")],
["lot change clears logistics evidence", view.includes("rawReturnForm.waybill_no = \"\"") && view.includes("rawReturnForm.order_photo_url = \"\"")],
["purchase return label", dictionaries.includes("PURCHASE_RETURN_OUT") && dictionaries.includes("采购退货出库")],
];
const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
console.error("Raw return UI checks failed:");
failed.forEach(([name]) => console.error(`- ${name}`));
process.exit(1);
}
console.log("Raw return UI checks passed");
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const inventoryPath = path.join(root, "src/views/InventoryLedgerView.vue");
const appPath = path.join(root, "src/App.vue");
const routerPath = path.join(root, "src/router/index.js");
const deliveryWorkspacePath = path.join(root, "src/views/DeliveryWorkspaceView.vue");
const dictionariesPath = path.join(root, "src/utils/dictionaries.js");
const inventory = fs.readFileSync(inventoryPath, "utf8");
const app = fs.readFileSync(appPath, "utf8");
const router = fs.readFileSync(routerPath, "utf8");
const deliveryWorkspace = fs.readFileSync(deliveryWorkspacePath, "utf8");
const dictionaries = fs.readFileSync(dictionariesPath, "utf8");
const checks = [
["return warehouse tab", inventory.includes('key: "return"') && inventory.includes('label: "退货库"')],
["return warehouse type mapping", inventory.includes('return: "RETURN"') && inventory.includes('return: "退货库"')],
["return inbound operation", inventory.includes('RETURN_IN') && inventory.includes('returnInboundDrawerOpen') && inventory.includes('/returns/orders')],
["return scrap inbound operation", inventory.includes('RETURN_SCRAP_IN') && inventory.includes('returnScrapInboundDrawerOpen') && inventory.includes('/returns/scrap-inbound')],
["return rework outbound operation", inventory.includes('RETURN_OUT') && inventory.includes('returnReworkDrawerOpen') && inventory.includes('/returns/rework-outbound')],
["delivery-line trace loaded", inventory.includes('/sales/delivery-items?limit=500') && inventory.includes('formatReturnTraceSummary')],
["return items loaded", inventory.includes('/returns/items?limit=500') && inventory.includes('pendingReturnItems')],
["old delivery return child hidden", !app.includes('label: "退货处理"') && !deliveryWorkspace.includes('ReturnManagementView')],
["old route redirects to warehouse", router.includes('path: "/return-management"') && router.includes('redirect: { name: "inventory-ledger" }')],
["dictionary labels", dictionaries.includes('RETURN_IN: "退货入库"') && dictionaries.includes('RETURN_SCRAP_IN: "退货废料入库"') && dictionaries.includes('RETURN_OUT: "返工出库"')]
];
const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
console.error("Return warehouse UI checks failed:");
failed.forEach(([name]) => console.error(`- ${name}`));
process.exit(1);
}
console.log("Return warehouse UI checks passed");
@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const viewPath = path.join(root, "src/views/InventoryLedgerView.vue");
const view = fs.readFileSync(viewPath, "utf8");
const checks = [
["ledger button exists", view.includes("openWarehouseLedgerDrawer") && view.includes("查看当前仓库出入库流水")],
["ledger drawer exists", view.includes("warehouseLedgerDrawerOpen") && view.includes("出入库流水")],
["ledger endpoint used", view.includes("/inventory/transaction-ledger")],
["warehouse tab drives ledger", view.includes("activeWarehouseType") && view.includes("activeInventoryLabel")],
["server pagination state exists", view.includes("warehouseLedgerPage") && view.includes("warehouseLedgerPageSize") && view.includes("warehouseLedgerTotal")],
["existing material stock detail remains", view.includes("openStockDetail(balance)") && view.includes("selectedTransactions")],
["six warehouse tabs still present", ["raw", "semi", "finished", "auxiliary", "scrap", "return"].every((key) => view.includes(`key: "${key}"`))]
];
const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
console.error("Warehouse transaction ledger UI checks failed:");
failed.forEach(([name]) => console.error(`- ${name}`));
process.exit(1);
}
console.log("Warehouse transaction ledger UI checks passed");
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const sourceFiles = [
"src/views/InventoryLedgerView.vue",
"src/views/PurchaseReceiptView.vue",
"src/components/StocktakeDialog.vue",
"src/views/MasterDataImportView.vue",
"src/views/SupplierManagementView.vue",
"src/views/SupplierMaterialLiteView.vue",
"src/views/SalesPlanningView.vue"
];
for (const relativeFile of sourceFiles) {
const source = fs.readFileSync(path.join(root, relativeFile), "utf8");
const fileInputs = source.match(/<input\b[\s\S]*?>/g)?.filter((input) => /type=["']file["']/.test(input)) || [];
for (const input of fileInputs) {
assert.match(
input,
/class=["'][^"']*(hidden-file-input|modern-upload-input)[^"']*["']/,
`${relativeFile} 的文件上传不能暴露浏览器原生“选择文件”控件:${input}`
);
}
}
const inventorySource = fs.readFileSync(path.join(root, "src/views/InventoryLedgerView.vue"), "utf8");
assert.match(
inventorySource,
/class="workflow-mode-switch delivery-mode-switch"/,
"成品库销售出库方式必须使用统一分段切换控件"
);
assert.doesNotMatch(
inventorySource,
/purchase-choice-card|purchase-choice-grid|choice-check|choice-main/,
"仓库抽屉不能复用采购订单 scoped 样式类,否则会退化成老式裸按钮"
);
const cssSource = fs.readFileSync(path.join(root, "src/styles/main.css"), "utf8");
assert.match(cssSource, /\.modern-upload-pill/, "需要保留现代上传胶囊样式");
assert.match(cssSource, /\.workflow-mode-switch/, "需要保留统一分段切换样式");
assert.match(cssSource, /\.import-dropzone input\[type="file"\]/, "导入 dropzone 需要兜底隐藏原生 file input");
console.log("legacy native UI cleanup checks passed");
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const source = readFileSync(path.join(root, "src/views/OperationReportView.vue"), "utf8");
assert.match(source, /RECENT_REPORT_DAYS\s*=\s*15/, "operation reports should default to recent 15 days");
assert.match(source, /buildDefaultReportDateRange/, "operation reports should centralize default date range");
assert.match(source, /reportDateFilters/, "operation reports should keep user date filter state");
assert.match(source, /v-model="reportDateFilters\.start_date"/, "operation report page should expose start date picker");
assert.match(source, /v-model="reportDateFilters\.end_date"/, "operation report page should expose end date picker");
assert.match(source, /params\.set\("start_date", reportDateFilters\.start_date\)/, "operation report request should send start_date");
assert.match(source, /params\.set\("end_date", reportDateFilters\.end_date\)/, "operation report request should send end_date");
assert.match(source, /\/production\/miniapp-operation-reports\?\$\{params\.toString\(\)\}/, "operation report request should use query params");
assert.match(source, /最近15天/, "operation report UI should explain the default date range");
assert.match(source, /开始日期不能晚于结束日期/, "operation report page should reject invalid date ranges before requesting");
assert.match(source, /sync_to_erp:\s*"false"/, "operation report refresh should be read-only by default");
assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "operation report refresh must not sync ERP implicitly");
console.log("operation report date filter checks passed");
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const source = readFileSync(path.join(root, "src/views/OperationReportView.vue"), "utf8");
assert.match(source, /syncReportsToErp/, "operation report page should define an explicit ERP sync action");
assert.match(source, /loadSyncState/, "operation report page should load sync state on entry");
assert.match(source, /fetchResource\("\/production\/miniapp-operation-reports\/sync-state"/, "page should read the last successful sync time");
assert.match(source, /postResource\("\/production\/miniapp-operation-reports\/sync"/, "sync action should call the dedicated sync endpoint");
assert.match(source, /syncingReports\.value\s*=\s*true/, "sync action should enable loading state");
assert.match(source, /:disabled="syncingReports"/, "sync button should be disabled while syncing");
assert.match(source, /同步中/, "sync button should show a syncing label");
assert.match(source, /lastSyncedAt/, "page should display the last successful sync time");
assert.match(source, /await loadReports\(\)/, "page should refresh the list after sync succeeds");
assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "list refresh should not sync ERP implicitly");
assert.match(source, /sync_to_erp:\s*"false"/, "list refresh should explicitly request read-only data");
console.log("operation report sync checks passed");
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const source = fs.readFileSync(path.resolve("src/views/InventoryLedgerView.vue"), "utf8");
const backendInventorySource = fs.readFileSync(path.resolve("../backend/app/api/routes/inventory.py"), "utf8");
assert.match(
source,
/const isOutsourcingScrapInbound = computed\(\(\) =>[\s\S]*activeWarehouseType\.value === "SCRAP"[\s\S]*activeOperation\.value\?\.bizType === "OUTSOURCING_SCRAP_IN"/,
"outsourcing scrap inbound should have an explicit state flag"
);
assert.match(
source,
/isProductTraceOnlySourceLotInbound\.value \|\| isOutsourcingScrapInbound\.value/,
"outsourcing scrap inbound should use the product-to-raw-material source lot selector"
);
assert.match(
source,
/!isFinishedOutsourcingInbound && !isProductionScrapInbound && !isOutsourcingScrapInbound/,
"outsourcing scrap inbound should hide the manual lot number field"
);
assert.match(
source,
/:allow-unavailable="isSourceLotReturnInbound \|\| isSemiSourceLotTraceInbound \|\| isOutsourcingScrapInbound"/,
"outsourcing scrap inbound should show both available and unavailable raw-material source lots"
);
assert.match(
source,
/isProductTraceOnlySourceLotInbound\.value \|\| isOutsourcingScrapInbound\.value[\s\S]*\? selectedTraceSourceLotNos/,
"outsourcing scrap inbound should submit source lot numbers through source material trace fields"
);
assert.match(
backendInventorySource,
/is_outsourcing_scrap_source_lot_trace_inbound\s*=\s*warehouse_type == "SCRAP" and biz_type == "OUTSOURCING_SCRAP_IN"/,
"backend should classify outsourcing scrap inbound as product raw-material source lot trace inbound"
);
assert.match(
backendInventorySource,
/is_product_raw_source_lot_trace_inbound = \([\s\S]*is_outsourcing_scrap_source_lot_trace_inbound[\s\S]*\)/,
"backend should validate selected raw-material source lots for outsourcing scrap inbound"
);
console.log("outsourcing scrap inbound source lot checks passed");
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
function readSource(relativePath) {
return fs.readFileSync(path.resolve(relativePath), "utf8");
}
function assertContains(source, expected, message) {
assert.ok(source.includes(expected), message);
}
function assertNotContains(source, unexpected, message) {
assert.ok(!source.includes(unexpected), message);
}
const serviceSource = readSource("src/services/employeeOptions.js");
assertContains(
serviceSource,
"PERSONNEL_PERMISSION_CODES",
"employee option service should define PERSONNEL_PERMISSION_CODES"
);
assertContains(
serviceSource,
"/master-data/employee-options?",
"employee option service should call the permission-filtered employee options endpoint"
);
const salesSource = readSource("src/views/SalesPlanningView.vue");
assertContains(salesSource, "销售人员", "sales order form should include a sales employee label");
assertContains(salesSource, "sales_employee_id", "sales order form should submit sales_employee_id");
const permissionSelectorViews = [
{
path: "src/views/PurchaseOrderView.vue",
constant: "PURCHASE_ORDER"
},
{
path: "src/views/PurchaseReceiptView.vue",
constant: "PURCHASE_RECEIPT"
},
{
path: "src/views/InventoryLedgerView.vue",
constant: "INVENTORY_LEDGER"
},
{
path: "src/views/CompletionReceiptView.vue",
constant: "INVENTORY_LEDGER"
},
{
path: "src/views/ReturnManagementView.vue",
constant: "INVENTORY_LEDGER"
},
{
path: "src/views/EquipmentManagementView.vue",
constant: "EQUIPMENT_MANAGEMENT"
}
];
for (const view of permissionSelectorViews) {
const source = readSource(view.path);
assertContains(
source,
"fetchPermissionEmployeeOptions",
`${view.path} should load personnel options through fetchPermissionEmployeeOptions`
);
assertContains(
source,
`PERSONNEL_PERMISSION_CODES.${view.constant}`,
`${view.path} should use PERSONNEL_PERMISSION_CODES.${view.constant}`
);
assertNotContains(
source,
"/master-data/employees",
`${view.path} should not call the unrestricted employee endpoint`
);
}
console.log("permission employee selector checks passed");
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const productSpecSource = readFileSync(new URL("../src/views/ProductSpecLiteView.vue", import.meta.url), "utf8");
const masterDataImportSource = readFileSync(new URL("../src/views/MasterDataImportView.vue", import.meta.url), "utf8");
const masterDataRouteSource = readFileSync(new URL("../../backend/app/api/routes/master_data.py", import.meta.url), "utf8");
assert.doesNotMatch(productSpecSource, />\s*标准工作量\s*</, "产品需规表单不应再展示标准工作量列");
assert.doesNotMatch(
productSpecSource,
/v-model\.number="processRow\.standard_workload"/,
"产品需规表单不应再让用户手填标准工作量"
);
assert.doesNotMatch(
productSpecSource,
/processRow\.standard_workload\s*\|\|\s*\(standardBeat > 0 \? 28800 \/ standardBeat : 0\)/,
"产品需规保存时不应再用手填标准工作量兜底"
);
assert.match(
productSpecSource,
/standard_workload:\s*0/,
"产品需规同步小程序时应保留兼容字段并统一写入 0"
);
assert.doesNotMatch(masterDataImportSource, /标准工作量/, "基础资料导入导出说明不应再提示用户维护标准工作量");
assert.doesNotMatch(
masterDataRouteSource,
/"标准工作量":\s*"standard_workload"/,
"后端导入字段映射不应再接收标准工作量列"
);
assert.doesNotMatch(
masterDataRouteSource,
/_import_number\(data\.get\("standard_workload"\)\)/,
"后端导入不应再读取 Excel 中的标准工作量"
);
console.log("product spec standard workload hidden checks passed");
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const read = (relativePath) => readFileSync(path.join(root, relativePath), "utf8");
const router = read("src/router/index.js");
const app = read("src/App.vue");
const productionLedger = read("src/views/ProductionLedgerView.vue");
const inventory = read("src/views/InventoryLedgerView.vue");
assert.match(router, /ProductionLedgerView/, "router should import ProductionLedgerView");
assert.match(router, /name:\s*"production-ledger"/, "router should expose production-ledger route");
assert.match(router, /path:\s*"\/work-order-ledger"[\s\S]*redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/, "old work-order-ledger route should redirect to production-ledger");
assert.match(app, /生产台账/, "sidebar should show 生产台账");
assert.doesNotMatch(app, /工单台账/, "sidebar should no longer show 工单台账");
assert.match(productionLedger, /材料库存批次号/, "production ledger page should use material stock lot number");
assert.match(productionLedger, /产品/, "production ledger page should show product");
assert.match(productionLedger, /库外未闭环重量/, "production ledger page should show outside weight");
assert.match(productionLedger, /该批材料结单/, "production ledger page should expose material batch lock action");
assert.match(inventory, /生产台账入库/, "inventory unified inbound should be renamed to production ledger inbound");
assert.match(inventory, /该批材料结单/, "inventory close action should use material batch close wording");
assert.match(inventory, /production-ledger-inbounds\/preview/, "inventory preview should use production ledger inbound endpoint");
assert.match(inventory, /production-ledger-inbounds/, "inventory submit should use production ledger inbound endpoint");
assert.match(inventory, /source_doc_type:\s*isProductionSurplusInbound\.value\s*\?\s*"PRODUCTION_LEDGER"/, "production surplus should link production ledger source");
assert.match(inventory, /source_doc_type:\s*isProductionScrapInbound\.value\s*\?\s*"PRODUCTION_LEDGER"/, "production scrap should link production ledger source");
console.log("production batch ledger UI static checks passed");
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
function readSource(relativePath) {
return readFileSync(path.join(root, relativePath), "utf8");
}
const app = readSource("src/App.vue");
const router = readSource("src/router/index.js");
const workspace = readSource("src/views/ProductionWorkspaceView.vue");
const operationReports = readSource("src/views/OperationReportView.vue");
const inventoryLedger = readSource("src/views/InventoryLedgerView.vue");
assert.doesNotMatch(
app,
/key:\s*"scrap-rework"[\s\S]*?label:\s*"生产报废"/,
"production side menu should not expose the production scrap entry"
);
assert.doesNotMatch(
app,
/routeNames:\s*\[[\s\S]*?"scrap-rework"[\s\S]*?\]/,
"production module route activation should not include scrap-rework"
);
assert.doesNotMatch(
app,
/permissionsAny:\s*\[[\s\S]*?"MENU_SCRAP_REWORK"[\s\S]*?\]/,
"production module permissions should not depend on the hidden scrap entry"
);
assert.doesNotMatch(
router,
/const productionPermissionOrder = \[[\s\S]*?routeName:\s*"scrap-rework"/,
"production permission order should not choose the production scrap route"
);
assert.doesNotMatch(workspace, /ScrapReworkView/, "production workspace should not import the scrap page");
assert.doesNotMatch(workspace, /label:\s*"生产报废"/, "production workspace should not show a production scrap card");
assert.match(workspace, /title="生产台账与小程序报工"/, "production workspace title should describe the remaining production entries");
assert.match(workspace, /生产台账/, "production workspace should keep the production ledger wording");
assert.match(operationReports, /报废数量/, "operation report page should still keep reported scrap quantity");
assert.match(inventoryLedger, /生产废料入库/, "scrap warehouse production scrap inbound should remain available");
console.log("production scrap entry hidden checks passed");
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
const source = readFileSync(
fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)),
"utf8"
);
const functionStart = source.indexOf("function computeWorkOrderSourceLotReturnLimit");
const nextFunctionStart = source.indexOf("function productionSurplusReturnInputMax", functionStart);
assert.ok(functionStart >= 0 && nextFunctionStart > functionStart, "should find source lot return limit function");
const inputMaxStart = nextFunctionStart;
const nextApplyFunctionStart = source.indexOf("function applyGenericWorkOrder", inputMaxStart);
assert.ok(nextApplyFunctionStart > inputMaxStart, "should find production surplus input max function");
const functionSource = source.slice(functionStart, nextFunctionStart);
const sandbox = {};
vm.createContext(sandbox);
vm.runInContext(`${functionSource}; globalThis.computeWorkOrderSourceLotReturnLimit = computeWorkOrderSourceLotReturnLimit;`, sandbox);
const workOrder = {
limits: {
issued_weight_kg: 200,
used_material_weight_kg: 61.8,
returned_material_weight_kg: 0,
max_surplus_inbound_weight_kg: 138.2,
tolerance_surplus_inbound_weight_kg: 158.93
}
};
const material = {
issued_weight_kg: 100,
returned_weight_kg: 0
};
const limit = sandbox.computeWorkOrderSourceLotReturnLimit(workOrder, material);
assert.equal(limit.normal, 69.1, "single lot normal return limit should subtract its proportional used material weight");
assert.equal(limit.tolerance, 79.465, "single lot tolerance limit should also use its proportional remaining weight");
const inputMaxSource = source.slice(inputMaxStart, nextApplyFunctionStart);
assert.doesNotMatch(
inputMaxSource,
/Math\.min\([\s\S]*tolerance_return_weight_kg/,
"single lot input max should not block uneven returns with the proportional suggested return weight"
);
assert.match(
inputMaxSource,
/source_remaining_weight_kg/,
"single lot input max should only cap by the source lot's unreturned issued weight"
);
assert.match(
source,
/:disabled="productionSurplusReturnInputDisabled\(lot\)"/,
"fully returned production surplus source lots should disable the return input instead of showing a min/max browser warning"
);
assert.match(
source,
/function productionSurplusReturnInputDisabled\(lot\)/,
"production surplus inbound should have an explicit disabled helper for fully returned source lots"
);
assert.match(
source,
/const sourceLotsToSubmit = selectedGenericSourceLots\.value\.filter/,
"production surplus inbound should submit only source lots with an actual positive return weight"
);
console.log("production surplus source lot return limit checks passed");
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const inventoryView = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const stockLotSelect = readFileSync(new URL("../src/components/StockLotTagSelect.vue", import.meta.url), "utf8");
const systemExtension = readFileSync(new URL("../src/views/SystemExtensionView.vue", import.meta.url), "utf8");
assert.match(stockLotSelect, /maxSelections/);
assert.match(inventoryView, /生产台账入库/);
assert.match(inventoryView, /一次生产出库只能选择一个材料库存批次号/);
assert.match(inventoryView, /production-ledger-inbounds\/preview/);
assert.match(inventoryView, /production-ledger-inbounds/);
assert.match(inventoryView, /本次入库后该批材料结单/);
assert.match(
inventoryView,
/<input[\s\S]*?v-model\.number="productionWorkOrderInboundForm\.finished_qty"[\s\S]*?@input="handleProductionWorkOrderInboundFinishedQtyInput"/,
"生产台账入库的成品数量必须在输入时即时刷新理论余料和理论废料"
);
assert.doesNotMatch(
inventoryView,
/<input[\s\S]*?v-model\.number="productionWorkOrderInboundForm\.finished_qty"[\s\S]*?@change="loadProductionWorkOrderInboundPreview\(true\)"/,
"生产台账入库的成品数量不能依赖 change/失焦后才刷新理论指标"
);
assert.match(inventoryView, /参考成品数量/);
assert.match(inventoryView, /reference_finished_qty/);
assert.match(inventoryView, /以下指标超过15%导致异常/);
assert.match(inventoryView, /成品入库数量超过本生产台账参考生产数量上限15%/);
assert.match(inventoryView, /余料入库重量与理论值偏差超过上下15%/);
assert.match(inventoryView, /废料重量与理论值偏差超过上下15%/);
assert.doesNotMatch(
inventoryView,
/<label class="form-field">\s*<span>偏差说明<\/span>\s*<textarea[\s\S]*?productionWorkOrderInboundForm\.deviation_remark/,
"生产台账入库偏差说明不能常驻显示,必须在异常确认后弹出填写"
);
assert.match(
inventoryView,
/const confirmed = window\.confirm\(buildProductionWorkOrderInboundConfirmMessage\([\s\S]*?if \(!ensureProductionWorkOrderInboundDeviationRemark\(\)\)/,
"生产台账入库必须先弹入库明细确认,再在异常时弹出偏差说明"
);
assert.match(systemExtension, /原材料库存批次号前缀/);
assert.match(systemExtension, /raw-material-lot-prefix/);
console.log("production work-order inbound UI checks passed");
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
function readSource(relativePath) {
return readFileSync(path.join(root, relativePath), "utf8");
}
const router = readSource("src/router/index.js");
const app = readSource("src/App.vue");
const productionLedgerView = readSource("src/views/ProductionLedgerView.vue");
const inventoryView = readSource("src/views/InventoryLedgerView.vue");
assert.match(
router,
/import\s+ProductionLedgerView\s+from\s+"..\/views\/ProductionLedgerView\.vue";/,
"router should import ProductionLedgerView"
);
assert.match(
router,
/path:\s*"\/production-ledger"[\s\S]*?name:\s*"production-ledger"[\s\S]*?component:\s*ProductionLedgerView[\s\S]*?permission:\s*"MENU_WORK_ORDER"/,
"router should define production-ledger route with MENU_WORK_ORDER permission"
);
assert.match(
router,
/path:\s*"\/work-order-ledger"[\s\S]*?redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/,
"legacy work-order-ledger route should redirect to production-ledger"
);
assert.match(
app,
/label:\s*"生产台账"[\s\S]*?to:\s*\{\s*name:\s*"production-ledger"\s*\}[\s\S]*?permission:\s*"MENU_WORK_ORDER"/,
"App.vue production menu should expose 生产台账 linked to production-ledger"
);
assert.match(
productionLedgerView,
/fetchResource\("\/production\/production-ledger\?limit=500"/,
"ProductionLedgerView should load the production ledger endpoint with limit=500"
);
assert.match(productionLedgerView, /材料库存批次号/, "ProductionLedgerView should show material stock lot number");
assert.match(productionLedgerView, /库外未闭环重量/, "ProductionLedgerView should show outside unclosed weight");
assert.match(productionLedgerView, /该批材料结单/, "ProductionLedgerView should provide material batch lock action");
assert.doesNotMatch(productionLedgerView, /容差上限/, "ProductionLedgerView should not expose tolerance limit wording to users");
assert.doesNotMatch(inventoryView, /容差上限/, "InventoryLedgerView should not expose tolerance limit wording to users");
assert.match(inventoryView, /该批材料余料结单/, "InventoryLedgerView should provide a production-surplus material batch settle option");
assert.match(inventoryView, /该批材料废料结单/, "InventoryLedgerView should provide a production-scrap material batch settle option");
assert.match(
inventoryView,
/source_doc_type:\s*isProductionSurplusInbound\.value\s*\?\s*"PRODUCTION_LEDGER"/,
"InventoryLedgerView production surplus inbound payload should link production ledger source document"
);
assert.match(
inventoryView,
/source_doc_type:\s*isProductionScrapInbound\.value\s*\?\s*"PRODUCTION_LEDGER"/,
"InventoryLedgerView production scrap inbound payload should link production ledger source document"
);
console.log("production ledger static checks passed");
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/PurchaseOrderView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/function\s+formatMaterialOptionLabel\s*\(\s*material\s*,\s*item\s*\)/,
"purchase order view should format material dropdown option labels through a helper"
);
assert.match(
source,
/formatMaterialOptionLabel\(material,\s*item\)/,
"purchase order material dropdown should use the formatted label"
);
assert.match(
source,
/form\.target_warehouse_type\s*!==\s*"AUX"[\s\S]*性能要求:\$\{performance\}/,
"raw-material purchase option labels should append the performance requirement at the end"
);
assert.match(
source,
/materialPerformanceRequirement\(material\)/,
"material option label should read performance requirement from the material master data"
);
console.log("purchase order material option performance checks passed");
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/PurchaseOrderView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/function\s+scheduleSalesOrderPurchaseAutoFill\s*\(/,
"purchase order view should schedule automatic sales-order purchase detail generation"
);
assert.match(
source,
/watch\(\s*\(\)\s*=>\s*normalizedSalesOrderIds\(form\.sales_order_ids\)\.join\(","\)[\s\S]*scheduleSalesOrderPurchaseAutoFill\(\)/,
"sales order selection changes should trigger automatic purchase detail generation"
);
assert.match(
source,
/function\s+applySalesOrderPurchase\s*\(\s*\{\s*automatic\s*=\s*false\s*}\s*=\s*\{\}\s*\)/,
"sales order purchase generation should support automatic and manual refresh modes"
);
assert.match(
source,
/重新带出采购明细/,
"manual button should be kept as a refresh action instead of the required first step"
);
assert.match(
source,
/ref="salesOrderPickerRef"\s+class="order-tag-picker"\s+@pointerdown\.stop\s+@mousedown\.stop\s+@click\.stop/,
"sales order picker should keep internal clicks from bubbling into unrelated close handlers"
);
assert.match(
source,
/@click\.stop="toggleSalesOrder\(order\.order_id\)"/,
"sales order option clicks should be handled by the picker itself"
);
assert.match(
source,
/function\s+toggleSalesOrder\s*\(orderId\)[\s\S]*salesOrderPickerOpen\.value\s*=\s*false/,
"selecting or unselecting one sales order should close the picker; selecting another requires focusing the search box again"
);
assert.doesNotMatch(
source,
/function\s+toggleSalesOrder\s*\(orderId\)[\s\S]*salesOrderPickerOpen\.value\s*=\s*true/,
"sales order selection should not force the picker to stay open after choosing one row"
);
assert.doesNotMatch(
source,
/请重新按销售订单带出采购建议/,
"selection changes should no longer ask the user to manually bring out purchase suggestions"
);
console.log("purchase order sales auto-fill checks passed");
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/PurchaseOrderView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/function\s+formatUnitPriceTonHint\s*\(/,
"purchase order view should provide a unit-price ton hint formatter"
);
assert.match(
source,
/formatUnitPriceTonHint\(item\.unit_price\)/,
"purchase order item editor should show 元/kg converted to 元/t"
);
assert.match(
source,
/formatUnitPriceDisplay\(item\.unit_price\)/,
"purchase order detail rows should show both 元/kg and 元/t"
);
assert.match(
source,
/materialChipSubtitle[\s\S]*formatUnitPriceDisplay\(item\.unit_price\)/,
"purchase order material chips should show both 元/kg and 元/t"
);
console.log("purchase order ton price hint checks passed");
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/PurchaseReceiptView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.doesNotMatch(source, /合并到已有批次|入库批次处理|merge_to_lot_id|merge_mode/, "purchase receipt UI must not expose legacy lot merge controls");
assert.doesNotMatch(source, /YL-NB|buildDefaultLotNo/, "purchase receipt UI must not generate legacy YL-NB lot numbers");
assert.doesNotMatch(source, /送货单号|supplier_delivery_no/, "purchase receipt UI should not keep the duplicate supplier delivery number field");
assert.match(source, /后端自动生成库存批次号|系统自动生成库存批次号/, "purchase receipt UI should explain that inventory lot numbers are generated by the backend");
assert.match(source, /uploadResource/, "purchase receipt UI should upload logistics auxiliary photos");
assert.match(source, /selectedPurchaseTargetType\s*===\s*["']RAW["'][\s\S]*运单号[\s\S]*辅助照片/, "raw purchase receipt UI should show waybill and auxiliary photo fields");
assert.match(source, /validateLogisticsForm\s*\(\s*\)/, "raw purchase receipt submit should validate logistics information before posting");
assert.match(source, /waybill_no:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.waybill_no/, "purchase receipt payload should include raw waybill number");
assert.match(source, /order_photo_url:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.order_photo_url/, "purchase receipt payload should include raw auxiliary photo URL");
assert.match(source, /freight_amount:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*Number\(form\.freight_amount\)/, "purchase receipt payload should include raw freight amount");
assert.match(source, /function\s+formatReceiptOrderOption\s*\(order\)/, "purchase receipt order dropdown should use a formatter");
assert.match(source, /formatReceiptOrderMaterialNames\s*\(order\.purchase_order_id\)/, "purchase receipt order option should append material names from purchase order items");
assert.match(source, /<option v-for="order in receivableOrders"[\s\S]*{{ formatReceiptOrderOption\(order\) }}/, "purchase receipt order dropdown should render formatted options with material names");
console.log("purchase receipt inventory lot UI checks passed");
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import {
formatReturnDeliveryLineLabel,
formatReturnReworkItemLabel,
formatReturnTraceSummary
} from "../src/utils/returnTraceFormatters.js";
const deliveryLine = {
delivery_no: "DLV-NB-20260603-02600003-001",
customer_name: "比得金属有限公司",
product_code: "产品需规00091",
product_name: "钢制碗",
lot_no: "FGL-NB-20260602-产品需规00091-需规00091001-L01",
source_lot_no: "FGL-NB-20260602-产品需规00091-需规00091001-L01",
source_material_code: "原材料00002",
source_material_name: "5系钢板",
source_material_lot_no: "JH_5系钢板00002_0001",
source_material_sub_batch_no: "JH_5系钢板00002_0001",
returnable_qty: 150,
returnable_weight_kg: 75
};
const label = formatReturnDeliveryLineLabel(deliveryLine);
assert.equal(
label,
"比得金属有限公司 · 钢制碗 · 原材料00002 · 5系钢板 · JH_5系钢板00002_0001 · 可退 150 / 75 kg"
);
assert.doesNotMatch(label, /DLV/i, "delivery line option should not expose delivery document numbers");
assert.doesNotMatch(label, /FGL/i, "delivery line option should not expose finished stock lot numbers");
const summary = formatReturnTraceSummary(deliveryLine);
assert.equal(
summary,
"客户:比得金属有限公司;产品:钢制碗;原材料库存批次:原材料00002 · 5系钢板 · JH_5系钢板00002_0001;可退:150 / 75 kg"
);
assert.doesNotMatch(summary, /DLV/i, "return trace summary should not expose delivery document numbers");
assert.doesNotMatch(summary, /FGL/i, "return trace summary should not expose finished stock lot numbers");
console.log("return delivery line label checks passed");
const reworkItem = {
return_no: "RTN-NB-20260603-比得-001",
delivery_no: "DLV-NB-20260603-02600003-002",
product_code: "产品需规00091",
product_name: "钢制碗",
source_lot_no: "RTL-NB-20260603-0260603比得001-001",
source_material_code: "原材料00002",
source_material_name: "5系钢板",
source_material_lot_no: "JH_5系钢板00002_0001",
source_material_sub_batch_no: "JH_5系钢板00002_0001",
return_qty: 20,
return_weight_kg: 24
};
const reworkLabel = formatReturnReworkItemLabel(reworkItem);
assert.equal(
reworkLabel,
"钢制碗 · 原材料00002 · 5系钢板 · JH_5系钢板00002_0001 · 可返工 20 / 24 kg"
);
assert.doesNotMatch(reworkLabel, /RTN/i, "rework item option should not expose return document numbers");
assert.doesNotMatch(reworkLabel, /DLV/i, "rework item option should not expose delivery document numbers");
assert.doesNotMatch(reworkLabel, /RTL/i, "rework item option should not expose return warehouse internal lot numbers");
assert.match(reworkLabel, /JH_5系钢板00002_0001/, "rework item option should show raw-material stock lot number");
@@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
const inventory = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const dictionaries = readFileSync(new URL("../src/utils/dictionaries.js", import.meta.url), "utf8");
const requiredInventorySnippets = [
"REWORK_FINISHED_IN",
"返工入库",
"reworkCompletion",
"REWORK_SCRAP_IN",
"返工废料入库",
"reworkScrapInbound",
"isReworkScrapInbound",
"source_doc_type: isProductionScrapInbound.value || isReworkScrapInbound.value ? \"WORK_ORDER\"",
"inbound_qty: inboundQty"
];
const requiredDictionarySnippets = [
"REWORK_FINISHED_IN: \"返工入库\"",
"REWORK_SCRAP_IN: \"返工废料入库\"",
"REWORK_RECEIPT: \"返工入库\""
];
for (const snippet of requiredInventorySnippets) {
if (!inventory.includes(snippet)) {
throw new Error(`InventoryLedgerView.vue missing ${snippet}`);
}
}
for (const snippet of requiredDictionarySnippets) {
if (!dictionaries.includes(snippet)) {
throw new Error(`dictionaries.js missing ${snippet}`);
}
}
console.log("return rework closed-loop UI guard passed");
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const inventorySource = readFileSync(
fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)),
"utf8"
);
assert.match(
inventorySource,
/function shouldDisplaySourceMaterialLotNo\(warehouseType\) \{[\s\S]*\["SEMI", "FINISHED", "SCRAP", "RETURN"\]\.includes/,
"return warehouse should display raw-material source stock lot numbers in lot and transaction tables"
);
assert.match(
inventorySource,
/formatSelectedDetailLotNo\(row\)[\s\S]*formatLotSourceLots\(row\)/,
"return warehouse detail drawer should use the shared source-lot formatter once RETURN is enabled"
);
assert.match(
inventorySource,
/formatWarehouseLedgerLotNo\(row\)[\s\S]*formatLotSourceLots\(row\)/,
"return warehouse ledger should use the shared source-lot formatter once RETURN is enabled"
);
console.log("return warehouse source lot display checks passed");
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/SalesPlanningView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/promised_date_touched:\s*false/,
"new sales order lines should track whether line promised date was manually edited"
);
assert.match(
source,
/@input="markSalesOrderItemPromisedDateTouched\(item\)"/,
"line promised date input should mark the row as user-edited"
);
assert.match(
source,
/syncSalesOrderItemPromisedDatesFromHeader/,
"header promised date changes should synchronize eligible line dates"
);
assert.match(
source,
/!item\.promised_date_touched/,
"sync should only update untouched line promised dates"
);
assert.match(
source,
/promised_date:\s*item\.promised_date\s*\|\|\s*salesOrderForm\.promised_date\s*\|\|\s*null/,
"submit should fall back empty line dates to the sales order promised date"
);
console.log("sales order line promised date default checks passed");
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/InventoryLedgerView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(
source,
/function\s+orderPendingProductSummary\s*\(/,
"sales order dropdown should build a pending product summary"
);
assert.match(
source,
/orderPendingProductSummary\(order\.order_id\)/,
"sales order dropdown should show pending product names next to the pending quantity"
);
assert.doesNotMatch(
source,
/:disabled="[^"]*totalAllocatedQty\s*<\s*Number\(deliveryForm\.delivery_qty\s*\|\|\s*0\)[^"]*"/,
"sales out submit button should not be disabled by insufficient stock, otherwise the error message cannot show"
);
assert.match(
source,
/本次要发[\s\S]*可发[\s\S]*缺/,
"sales out validation should explain requested quantity, available quantity, and shortage"
);
console.log("sales out drawer feedback checks passed");
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/InventoryLedgerView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(source, /<span>包装备注<\/span>/, "sales out drawer should show the packaging remark field");
assert.match(
source,
/placeholder="有无提供篮子等"/,
"packaging remark field should guide the user with the requested placeholder"
);
assert.match(
source,
/remark:\s*""/,
"delivery form state should include a remark field so the packaging remark is reset with the drawer"
);
assert.match(
source,
/remark:\s*deliveryForm\.remark\s*\|\|\s*null,[\s\S]*items:\s*allocationRows\.value\.map/,
"sales out payload should persist the packaging remark on the delivery header"
);
assert.match(
source,
/unit_price:\s*Number\(selectedSalesOrderItem\.value\?\.unit_price\s*\|\|\s*0\),\s*remark:\s*deliveryForm\.remark\s*\|\|\s*null/,
"sales out payload should copy the packaging remark onto generated delivery detail rows"
);
console.log("sales out packaging remark checks passed");
@@ -0,0 +1,103 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
function readRequiredFile(file) {
const path = resolve(process.cwd(), file);
if (!existsSync(path)) {
throw new Error(`Missing required file: ${file}`);
}
return readFileSync(path, "utf8");
}
function assertIncludes(source, snippet, label) {
if (!source.includes(snippet)) {
throw new Error(`${label} missing required snippet: ${snippet}`);
}
}
const api = readRequiredFile("src/services/api.js");
const sales = readRequiredFile("src/views/SalesPlanningView.vue");
const purchase = readRequiredFile("src/views/PurchaseOrderView.vue");
assertIncludes(api, "export async function postDownloadResource", "api.js");
assertIncludes(api, "method: \"POST\"", "postDownloadResource");
assertIncludes(api, "JSON.stringify(payload)", "postDownloadResource");
for (const snippet of [
"DocumentPaper",
"DocumentGrid",
"DocumentLineTable",
"DocumentArchiveActions",
"salesDocumentHeaderFields",
"salesDocumentLineColumns",
"title=\"销售订单\"",
"销售确认",
"客户确认",
"财务复核",
"制单人",
"保存并生成PDF归档",
"保存并归档中...",
"previewDocumentArchive",
"downloadDocumentArchive",
"regenerateDocumentArchive",
"batchDownloadSalesArchives",
"postDownloadResource",
"archive_status",
"sales-archive-cell",
"sales-archive-actions-compact",
"sales-order",
"归档"
]) {
assertIncludes(sales, snippet, "SalesPlanningView.vue");
}
for (const snippet of [
"openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`)",
"downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`",
"postResource(`/document-archives/${typeKey}/${businessId}/generate`, {})",
"postDownloadResource(\"/document-archives/batch-download\"",
"document_type: \"sales-order\""
]) {
assertIncludes(sales, snippet, "Sales archive actions");
}
for (const snippet of [
"DocumentPaper",
"DocumentGrid",
"DocumentLineTable",
"DocumentArchiveActions",
"purchaseDocumentHeaderFields",
"purchaseDocumentLineColumns",
"title=\"采购订单\"",
"采购确认",
"供应商确认",
"仓库接收",
"制单人",
"保存并生成PDF归档",
"更新并生成PDF归档",
"保存并归档中...",
"previewDocumentArchive",
"downloadDocumentArchive",
"regenerateDocumentArchive",
"batchDownloadPurchaseArchives",
"postDownloadResource",
"archive_status",
"purchase-archive-cell",
"purchase-archive-actions-compact",
"purchase-order",
"归档"
]) {
assertIncludes(purchase, snippet, "PurchaseOrderView.vue Task 8 assertions");
}
for (const snippet of [
"openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`)",
"downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`",
"postResource(`/document-archives/${typeKey}/${businessId}/generate`, {})",
"postDownloadResource(\"/document-archives/batch-download\"",
"document_type: \"purchase-order\""
]) {
assertIncludes(purchase, snippet, "Purchase archive actions");
}
console.log("sales/purchase document archive UI static tests passed");
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const inventoryViewSource = readFileSync(
fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)),
"utf8"
);
const backendInventorySource = readFileSync(
fileURLToPath(new URL("../../backend/app/api/routes/inventory.py", import.meta.url)),
"utf8"
);
assert.match(
inventoryViewSource,
/const isScrapSaleOutbound = computed\(\(\) =>[\s\S]*activeWarehouseType\.value === "SCRAP"[\s\S]*activeOperation\.value\?\.bizType === "SCRAP_SALE_OUT"/,
"scrap sale outbound should have an explicit state flag"
);
assert.match(
inventoryViewSource,
/if \(isScrapSaleOutbound\.value\) \{[\s\S]*return "废料";[\s\S]*\}/,
"scrap sale outbound should label the generic item field as 废料"
);
assert.match(
inventoryViewSource,
/usesGenericSourceLotSelector[\s\S]*isScrapSaleOutbound\.value/,
"scrap sale outbound should use the stock-lot selector instead of a manual source-lot input"
);
assert.match(
inventoryViewSource,
/function buildScrapSaleSourceLotOptions\([\s\S]*source_material_sub_batch_no[\s\S]*lot_no: sourceLotNo[\s\S]*source_stock_lot_no/,
"scrap sale outbound should build selectable options from scrap lots while displaying raw-material source lot numbers"
);
assert.match(
inventoryViewSource,
/isScrapSaleOutbound\.value[\s\S]*buildScrapSaleSourceLotOptions/,
"scrap sale outbound source lot options should come from selected scrap stock"
);
assert.match(
backendInventorySource,
/_SELECTED_SOURCE_LOT_OUTBOUND_BIZ_TYPES = \{[\s\S]*"SCRAP_SALE_OUT"[\s\S]*\}/,
"backend should allow scrap sale outbound to specify selected stock lots"
);
console.log("scrap sale outbound source lot checks passed");
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const appVuePath = resolve("frontend/src/App.vue");
const source = readFileSync(appVuePath, "utf8");
const navStart = source.indexOf("const navStages = [");
const navEnd = source.indexOf("\n];", navStart);
assert.notEqual(navStart, -1, "未找到 navStages 菜单配置");
assert.notEqual(navEnd, -1, "未找到 navStages 结束位置");
const navSource = source.slice(navStart, navEnd);
const iconEntries = [];
const entryPattern = /label:\s*"([^"]+)"[\s\S]*?iconPaths:\s*\[([\s\S]*?)\]/g;
let match;
while ((match = entryPattern.exec(navSource))) {
const [, label, iconSource] = match;
const paths = [...iconSource.matchAll(/"([^"]+)"/g)].map((pathMatch) => pathMatch[1].trim());
if (paths.length) {
iconEntries.push({
label,
signature: paths.join("|")
});
}
}
assert.ok(iconEntries.length > 0, "未解析到任何侧边栏图标配置");
const labelsBySignature = new Map();
for (const entry of iconEntries) {
const labels = labelsBySignature.get(entry.signature) || [];
labels.push(entry.label);
labelsBySignature.set(entry.signature, labels);
}
const duplicates = [...labelsBySignature.values()].filter((labels) => labels.length > 1);
assert.deepEqual(
duplicates,
[],
`侧边栏存在重复图标:${duplicates.map((labels) => labels.join(" / ")).join(";")}`
);
console.log(`侧边栏图标唯一性检查通过,共检查 ${iconEntries.length} 个菜单图标。`);
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const read = (relativePath) => readFileSync(path.join(root, relativePath), "utf8");
const featureService = read("src/services/systemFeatures.js");
const router = read("src/router/index.js");
const app = read("src/App.vue");
const systemExtension = read("src/views/SystemExtensionView.vue");
const productionLedger = read("src/views/ProductionLedgerView.vue");
assert.match(featureService, /smart-operation-report/, "feature service should load switch config");
assert.match(router, /operation-reports/, "router should still define operation report route");
assert.match(router, /loadSmartOperationReportConfig/, "router should guard operation-reports by switch");
assert.match(app, /smartOperationReportEnabled/, "App should track switch state");
assert.match(app, /child\.key\s*===\s*"operation-reports"/, "App should hide operation reports child when disabled");
assert.match(systemExtension, /对接智能报工小程序/, "system extension should show switch");
assert.match(productionLedger, /工序明细/, "production ledger page should show operation detail wording when enabled");
assert.match(productionLedger, /当前未对接智能报工小程序/, "production ledger page should explain disabled mode");
console.log("smart operation report toggle static checks passed");
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
specialAdjustmentDirectionLabel,
specialAdjustmentMeasureMode,
specialAdjustmentOperationKeys
} from "../src/utils/specialAdjustmentRules.js";
const viewPath = fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url));
const operationKeys = specialAdjustmentOperationKeys();
assert.equal(specialAdjustmentDirectionLabel("OUT"), "特殊出库");
assert.equal(specialAdjustmentDirectionLabel("out"), "特殊出库");
assert.equal(specialAdjustmentDirectionLabel("IN"), "特殊入库");
assert.equal(specialAdjustmentDirectionLabel(""), "特殊入库");
assert.equal(specialAdjustmentMeasureMode("RAW"), "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SCRAP"), "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("AUX"), "QTY");
assert.equal(specialAdjustmentMeasureMode("FINISHED"), "QTY_WITH_WEIGHT");
assert.equal(specialAdjustmentMeasureMode("RETURN"), "QTY_WITH_WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 8, remaining_weight_kg: 0 }), "QTY");
assert.equal(specialAdjustmentMeasureMode("SEMI", { qty_available: 3, weight_available_kg: 0 }), "QTY");
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 0, remaining_weight_kg: 12 }), "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SEMI", { qty_available: 0, weight_available_kg: 4 }), "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 2, remaining_weight_kg: 7 }), "SEMI_SELECT");
assert.equal(specialAdjustmentMeasureMode("SEMI", {}), "SEMI_SELECT");
assert.deepEqual(operationKeys, {
raw: {
in: "raw-special-in",
out: "raw-special-out"
},
semi: {
in: "semi-special-in",
out: "semi-special-out"
},
finished: {
in: "finished-special-in",
out: "finished-special-out"
},
auxiliary: {
in: "aux-special-in",
out: "aux-special-out"
},
scrap: {
in: "scrap-special-in",
out: "scrap-special-out"
},
return: {
in: "return-special-in",
out: "return-special-out"
}
});
const viewSource = readFileSync(viewPath, "utf8");
const flattenedOperationKeys = Object.values(operationKeys).flatMap((entry) => [entry.in, entry.out]);
for (const operationKey of flattenedOperationKeys) {
assert.ok(viewSource.includes(operationKey), `InventoryLedgerView.vue should include ${operationKey}`);
}
assert.ok(viewSource.includes("特殊入库"), "InventoryLedgerView.vue should include the special inbound label");
assert.ok(viewSource.includes("特殊出库"), "InventoryLedgerView.vue should include the special outbound label");
assert.ok(viewSource.includes("入库方式"), "InventoryLedgerView.vue should label the special inbound line mode in Chinese");
assert.ok(viewSource.includes("补增已有明细"), "InventoryLedgerView.vue should allow adding to an existing special inbound stock detail");
assert.ok(viewSource.includes("新增特殊明细"), "InventoryLedgerView.vue should allow creating a new special inbound stock detail");
assert.ok(viewSource.includes("特殊出库必须选择库存明细"), "InventoryLedgerView.vue should keep lot selection required for special outbound");
assert.ok(viewSource.includes("specialAdjustmentLineLotId(line)"), "InventoryLedgerView.vue should route special adjustment lot_id through conditional logic");
assert.ok(viewSource.includes("lot_id: null"), "InventoryLedgerView.vue should submit lot_id null when special inbound creates a new detail");
assert.ok(viewSource.includes("/inventory/special-adjustments"), "InventoryLedgerView.vue should call the special adjustments API");
console.log("special adjustment UI static assertions passed");
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const source = readFileSync(
fileURLToPath(new URL("../src/components/StockLotTagSelect.vue", import.meta.url)),
"utf8"
);
const selectLotStart = source.indexOf("function selectLot(lot)");
const removeLotStart = source.indexOf("function removeLot(lot)");
assert.ok(selectLotStart >= 0 && removeLotStart > selectLotStart, "StockLotTagSelect should define selectLot before removeLot");
const selectLotSource = source.slice(selectLotStart, removeLotStart);
assert.match(
selectLotSource,
/dropdownOpen\.value\s*=\s*false/,
"choosing one stock lot should close the dropdown"
);
assert.doesNotMatch(
selectLotSource,
/dropdownOpen\.value\s*=\s*true/,
"choosing one stock lot should not keep the dropdown open"
);
assert.match(
source,
/@focus="dropdownOpen = true"/,
"clicking back into the search input should open the dropdown again"
);
console.log("stock lot tag select dropdown behavior checks passed");
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
canContinueStocktake,
diffTone,
filterStocktakesForWarehouse,
findActiveStocktakeForWarehouse,
findOnlyActiveStocktake,
hasBlockingDiffErrors,
isEmptyStocktakeRead,
nextStocktakePanel,
splitDiffLines,
stocktakeBelongsToWarehouse,
stocktakeSummaryLineCount,
warehouseStocktakeStatus
} from "../src/utils/stocktakeWorkflow.js";
const emptyExported = {
id: 10,
status: "EXPORTED",
summary: {
match_count: 0,
gain_count: 0,
loss_count: 0,
new_lot_count: 0,
missing_count: 0,
error_count: 0
},
warehouses: [{ warehouse_id: 4, warehouse_name: "辅料库" }]
};
const importedWithDiff = {
id: 11,
status: "IMPORTED",
summary: {
match_count: 2,
gain_count: 1,
loss_count: 1,
new_lot_count: 0,
missing_count: 0,
error_count: 0
},
warehouses: [{ warehouse_id: 1, warehouse_name: "原材料库" }]
};
const lockedRaw = {
id: 12,
status: "LOCKED",
stocktake_no: "PK-20260530-012",
warehouses: [{ warehouse_id: 1, warehouse_name: "原材料库" }]
};
assert.equal(stocktakeSummaryLineCount(emptyExported.summary), 0);
assert.equal(isEmptyStocktakeRead(emptyExported), true);
assert.equal(isEmptyStocktakeRead(importedWithDiff), false);
assert.equal(isEmptyStocktakeRead({ status: "EXPORTED" }), false);
assert.equal(nextStocktakePanel(null), "overview");
assert.equal(nextStocktakePanel({ status: "LOCKED" }), "export");
assert.equal(nextStocktakePanel({ status: "EXPORTED" }), "import");
assert.equal(nextStocktakePanel(emptyExported), "diff");
assert.equal(nextStocktakePanel({ ...emptyExported, summary: { match_count: 1 } }), "import");
assert.equal(nextStocktakePanel(importedWithDiff), "diff");
assert.equal(nextStocktakePanel({ status: "CONFIRMED" }), "history");
assert.equal(canContinueStocktake({ status: "LOCKED" }), true);
assert.equal(canContinueStocktake({ status: "EXPORTED" }), true);
assert.equal(canContinueStocktake({ status: "IMPORTED" }), true);
assert.equal(canContinueStocktake({ status: " imported " }), true);
assert.equal(canContinueStocktake({ status: "CONFIRMED" }), false);
assert.equal(findActiveStocktakeForWarehouse([importedWithDiff, lockedRaw], 1).id, 11);
assert.equal(findActiveStocktakeForWarehouse([emptyExported], 4).id, 10);
assert.equal(findActiveStocktakeForWarehouse([emptyExported], 999), null);
assert.equal(findActiveStocktakeForWarehouse([{ status: "LOCKED", warehouses: null }], 1), null);
assert.equal(stocktakeBelongsToWarehouse(lockedRaw, 1), true);
assert.equal(stocktakeBelongsToWarehouse(lockedRaw, 4), false);
assert.deepEqual(filterStocktakesForWarehouse([lockedRaw, emptyExported], 1).map((row) => row.id), [12]);
assert.equal(findOnlyActiveStocktake([lockedRaw]).id, 12);
assert.equal(findOnlyActiveStocktake([lockedRaw, importedWithDiff]), null);
assert.equal(findOnlyActiveStocktake([{ status: "CONFIRMED" }]), null);
const lines = [
{ id: 1, diff_type: "MATCH", row_status: "READY" },
{ id: 2, diff_type: "LOSS", row_status: "READY" },
{ id: 3, diff_type: "GAIN", row_status: "READY" },
{ id: 4, diff_type: "MATCH", row_status: "ERROR" }
];
assert.deepEqual(splitDiffLines(lines, "ALL").map((line) => line.id), [4, 2, 3, 1]);
assert.deepEqual(splitDiffLines(lines, "DIFF").map((line) => line.id), [4, 2, 3]);
assert.deepEqual(splitDiffLines(lines, "ERROR").map((line) => line.id), [4]);
assert.deepEqual(lines.map((line) => line.id), [1, 2, 3, 4]);
assert.equal(hasBlockingDiffErrors(lines), true);
assert.equal(diffTone({ diff_type: "GAIN", row_status: "READY" }), "gain");
assert.equal(diffTone({ diff_type: "LOSS", row_status: "READY" }), "loss");
assert.equal(diffTone({ diff_type: "NEW_LOT", row_status: "READY" }), "new");
assert.equal(diffTone({ diff_type: "MISSING", row_status: "READY" }), "missing");
assert.equal(diffTone({ diff_type: "MATCH", row_status: "ERROR" }), "error");
assert.equal(diffTone({ diff_type: "MATCH", row_status: "READY" }), "match");
const warehouseStatus = warehouseStocktakeStatus(
{ id: 1, warehouse_name: "原材料库", warehouse_type: "RAW" },
[lockedRaw]
);
assert.equal(warehouseStatus.locked, true);
assert.equal(warehouseStatus.activeStocktake.stocktake_no, "PK-20260530-012");
assert.equal(warehouseStatus.label, "盘库中");
const componentSource = readFileSync(
fileURLToPath(new URL("../src/components/StocktakeDialog.vue", import.meta.url)),
"utf8"
);
assert.match(componentSource, /async function applyLoadedStocktake\(stocktake, options = \{\}\)/);
assert.match(componentSource, /await applyLoadedStocktake\(stocktake, options\);/);
assert.match(componentSource, /await applyLoadedStocktake\(exportedStocktake, \{ silent: true \}\);/);
assert.doesNotMatch(componentSource, /findOnlyActiveStocktake\(historyRows\.value\)/);
assert.match(componentSource, /findActiveStocktakeForWarehouse\(historyRows\.value, currentWarehouseId\.value\)/);
assert.match(componentSource, /\/inventory\/stocktakes\?warehouse_id=\$\{currentWarehouseId\.value\}/);
assert.match(componentSource, /import PaginationBar from "\.\/PaginationBar\.vue";/);
assert.match(componentSource, /import \{ usePagination \} from "\.\.\/utils\/pagination";/);
assert.match(componentSource, /usePagination\(currentWarehouseHistoryRows, 6\)/);
assert.match(componentSource, /class="stocktake-main"/);
assert.match(componentSource, /class="stocktake-flow-column"/);
assert.match(componentSource, /:page-size-options="\[6\]"/);
assert.match(componentSource, /v-for="row in paginatedHistoryRows"/);
assert.doesNotMatch(componentSource, /v-for="row in currentWarehouseHistoryRows"/);
assert.doesNotMatch(componentSource, /<th>操作<\/th>/);
assert.doesNotMatch(componentSource, /继续处理\s*<\/button>/);
assert.doesNotMatch(componentSource, /v-if="canContinueStocktake\(row\)"/);
assert.match(componentSource, /hasBlockingDiffErrors\(previewLines\.value\)/);
assert.match(componentSource, /盘点清单已导入:发现/);
const styleSource = readFileSync(
fileURLToPath(new URL("../src/styles/main.css", import.meta.url)),
"utf8"
);
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-diff-table-v2[\s\S]*margin: 14px 18px 0;/);
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*min-height: 72px;/);
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*box-sizing: border-box;/);
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*z-index: 3;/);
assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*height: min\(820px, 100%\);/);
assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*max-height: 100%;/);
assert.doesNotMatch(styleSource, /\.stocktake-diff-modal[\s\S]*calc\(100vh - 64px\)/);
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*justify-content: flex-end;/);
assert.match(styleSource, /\.stocktake-confirm-mask[\s\S]*z-index: 4;/);
assert.match(styleSource, /\.stocktake-diff-cell,[\s\S]*overflow-wrap: anywhere;/);
assert.match(styleSource, /\.stocktake-diff-cell span,[\s\S]*line-height: 1\.55;/);
const applyLoadedStart = componentSource.indexOf("async function applyLoadedStocktake(stocktake, options = {})");
const activeStocktakeAssignment = componentSource.indexOf("activeStocktake.value = stocktake;", applyLoadedStart);
const clearConfirmDialogAssignment = componentSource.indexOf("confirmDialogOpen.value = false;", applyLoadedStart);
const clearPreviewLinesAssignment = componentSource.indexOf("previewLines.value = [];", applyLoadedStart);
const clearActiveStocktakeAssignment = componentSource.indexOf("activeStocktake.value = null;", applyLoadedStart);
const clearPreviewSummaryAssignment = componentSource.indexOf("previewSummary.value = {};", applyLoadedStart);
const lineFetchAssignment = componentSource.indexOf(
"fetchResource(`/inventory/stocktakes/${stocktake.id}/lines?warehouse_id=${currentWarehouseId.value}`, null)",
applyLoadedStart
);
const lineFetchGuard = componentSource.indexOf('throw new Error("读取盘库明细失败,请重新打开盘库单");', applyLoadedStart);
const activePanelAssignment = componentSource.indexOf(
'activePanel.value = nextPanel === "diff" || nextPanel === "history" ? "import" : nextPanel;',
applyLoadedStart
);
assert.notEqual(applyLoadedStart, -1);
assert.notEqual(activeStocktakeAssignment, -1);
assert.notEqual(clearConfirmDialogAssignment, -1);
assert.notEqual(clearPreviewLinesAssignment, -1);
assert.notEqual(clearActiveStocktakeAssignment, -1);
assert.notEqual(clearPreviewSummaryAssignment, -1);
assert.notEqual(lineFetchAssignment, -1);
assert.notEqual(lineFetchGuard, -1);
assert.notEqual(activePanelAssignment, -1);
assert.ok(clearConfirmDialogAssignment < lineFetchAssignment);
assert.ok(clearPreviewLinesAssignment < lineFetchAssignment);
assert.ok(clearActiveStocktakeAssignment < lineFetchAssignment);
assert.ok(clearPreviewSummaryAssignment < lineFetchAssignment);
assert.ok(lineFetchAssignment < activeStocktakeAssignment);
assert.ok(lineFetchAssignment < activePanelAssignment);
console.log("stocktake workflow tests passed");
@@ -0,0 +1,354 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const appSource = readFileSync(new URL("../src/App.vue", import.meta.url), "utf8");
const routerSource = readFileSync(new URL("../src/router/index.js", import.meta.url), "utf8");
const systemPermissionSource = readFileSync(new URL("../src/views/SystemPermissionView.vue", import.meta.url), "utf8");
const orgMindMapSource = readFileSync(new URL("../src/components/OrgMindMap.vue", import.meta.url), "utf8");
const orgPermissionTreeSource = readFileSync(new URL("../src/components/OrgPermissionTree.vue", import.meta.url), "utf8");
assert.match(appSource, /系统管理/, "App.vue should show the system-management parent menu");
assert.match(appSource, /系统权限管理/, "App.vue should show the system permission child menu");
assert.match(appSource, /系统拓展/, "App.vue should keep the system extension child menu");
assert.match(
appSource,
/permissionsAny:\s*\[[\s\S]*"MENU_SYSTEM_PERMISSION"[\s\S]*"MENU_SYSTEM_EXTENSION"[\s\S]*\]/,
"App.vue system menu should allow either system permission"
);
assert.match(
routerSource,
/import SystemPermissionView from "\.\.\/views\/SystemPermissionView\.vue";/,
"router should import SystemPermissionView"
);
assert.match(routerSource, /path:\s*"\/system-management"[\s\S]*redirect:/, "router should add /system-management redirect");
assert.match(routerSource, /path:\s*"\/system-permissions"/, "router should add /system-permissions route");
assert.match(
routerSource,
/name:\s*"system-permissions"[\s\S]*permission:\s*"MENU_SYSTEM_PERMISSION"/,
"system-permissions route should require MENU_SYSTEM_PERMISSION"
);
assert.match(systemPermissionSource, /系统权限管理/, "SystemPermissionView should show page title");
assert.match(systemPermissionSource, /组织架构/, "SystemPermissionView should expose organization structure tab");
assert.match(systemPermissionSource, /角色维护/, "SystemPermissionView should expose role-only maintenance tab");
assert.doesNotMatch(systemPermissionSource, /人员与角色维护/, "personnel and role maintenance should be renamed");
assert.doesNotMatch(systemPermissionSource, /maintenanceSubTab/, "role maintenance should not keep nested personnel tabs");
assert.doesNotMatch(systemPermissionSource, /system-permission-sub-tabs/, "role maintenance should not render nested sub tabs");
assert.doesNotMatch(
systemPermissionSource,
/v-if="maintenanceSubTab === 'users'"/,
"standalone system users table should be removed from maintenance page"
);
assert.doesNotMatch(
systemPermissionSource,
/v-if="maintenanceSubTab === 'roles'"/,
"roles should render directly in the role maintenance tab"
);
assert.match(
systemPermissionSource,
/import PaginationBar from "\.\.\/components\/PaginationBar\.vue";/,
"page should use global PaginationBar"
);
assert.match(
systemPermissionSource,
/import \{ usePagination \} from "\.\.\/utils\/pagination";/,
"page should use global pagination helper"
);
assert.match(systemPermissionSource, /paginatedRoles/, "roles table should use paginated rows");
assert.match(systemPermissionSource, /<table class="data-table compact-table">/, "role maintenance should use global table styling");
assert.doesNotMatch(
systemPermissionSource,
/class="maintenance-grid"/,
"maintenance page should not use the old side-by-side card grid"
);
assert.doesNotMatch(
systemPermissionSource,
/<ul class="permission-list">[\s\S]*v-for="role in roles"/,
"roles should not render as old card list"
);
assert.doesNotMatch(
systemPermissionSource,
/<ul class="permission-list">[\s\S]*v-for="user in users"/,
"users should not render as old card list"
);
assert.match(systemPermissionSource, /permissionMainTab/, "SystemPermissionView should keep top-level tabs separate");
assert.match(
systemPermissionSource,
/v-if="permissionMainTab === 'org'"/,
"organization mind map should live in its own top-level tab"
);
assert.match(
systemPermissionSource,
/v-if="permissionMainTab === 'maintenance'"/,
"role maintenance should live in a separate top-level tab"
);
assert.match(systemPermissionSource, /orgViewMode/, "organization tab should keep organization view mode state");
assert.match(systemPermissionSource, /脑图模式/, "organization tab should expose mind-map mode switch");
assert.match(systemPermissionSource, /组织树模式/, "organization tab should expose organization-tree mode switch");
assert.match(
systemPermissionSource,
/<OrgPermissionTree/,
"SystemPermissionView should render organization tree mode through OrgPermissionTree"
);
assert.match(
systemPermissionSource,
/showAuthorizeDrawer/,
"SystemPermissionView should expose a personnel authorization drawer"
);
assert.match(systemPermissionSource, /openAuthorizeDrawer/, "personnel authorization should open from org personnel nodes");
assert.match(systemPermissionSource, /submitAuthorizeUser/, "authorization drawer should save account and roles");
assert.match(
systemPermissionSource,
/putResource\(`\/system-permissions\/users\/\$\{[^`]+\.user_id\}\/roles`/,
"existing users should update roles"
);
assert.match(systemPermissionSource, /postResource\("\/system-permissions\/users"/, "missing users should be created from authorization drawer");
assert.doesNotMatch(systemPermissionSource, /function openUserDrawer/, "standalone add-user drawer should be removed");
assert.doesNotMatch(systemPermissionSource, /function submitCreateUser/, "standalone add-user submit should be removed");
assert.doesNotMatch(systemPermissionSource, /paginatedUsers/, "standalone users table pagination should be removed");
assert.doesNotMatch(systemPermissionSource, /reset-password/, "standalone password reset action should not be exposed on this page");
assert.match(systemPermissionSource, /角色维护/, "SystemPermissionView should expose role maintenance");
assert.match(systemPermissionSource, /人员[\s\S]*电话号码[\s\S]*账号/, "SystemPermissionView should explain employee phone username mapping");
assert.match(systemPermissionSource, /密码/, "SystemPermissionView should mention password");
assert.match(systemPermissionSource, /最少6位/, "SystemPermissionView should mention the password length rule");
assert.match(systemPermissionSource, /employee-options/, "page must load employee options from backend");
assert.match(systemPermissionSource, /role_ids/, "page must submit role ids");
assert.match(systemPermissionSource, /\/system-permissions\/users/, "page must create system users through backend");
assert.match(systemPermissionSource, /menu-permission-tree/, "page must load menu permission tree");
assert.match(systemPermissionSource, /permission_codes/, "role form must submit permission codes");
assert.match(systemPermissionSource, /function collectPermissionCodes/, "role form must collect parent and child permission codes");
assert.match(systemPermissionSource, /function collectPersistablePermissionCodes/, "role form must collect only persistable menu permission codes");
assert.match(systemPermissionSource, /function togglePermissionNode/, "role form must toggle permission tree nodes");
assert.match(systemPermissionSource, /function isPermissionIndeterminate/, "role form must show partial permission tree selection");
assert.match(systemPermissionSource, /openOrgDrawer/, "page must support adding or editing org nodes");
assert.match(systemPermissionSource, /\/system-permissions\/org-nodes/, "page must persist org nodes through backend");
assert.match(systemPermissionSource, /node_type/, "org form must capture node type");
assert.match(systemPermissionSource, /主管/, "org form must support manager binding");
assert.match(systemPermissionSource, /manager_employee_ids/, "org form must submit multiple manager ids");
assert.match(systemPermissionSource, /manager-option-grid/, "manager selector should use clickable cards instead of a native multi select");
assert.match(systemPermissionSource, /selected-manager-tags/, "manager selector should show selected managers as tags");
assert.match(systemPermissionSource, /function toggleOrgManager/, "manager selector should toggle managers without keyboard modifiers");
assert.match(systemPermissionSource, /managerSearchKeyword/, "manager selector should keep a search keyword");
assert.match(systemPermissionSource, /filteredManagerOptions/, "manager selector should filter employees before rendering options");
assert.match(systemPermissionSource, /搜索主管姓名或手机号/, "manager selector should expose a Chinese search placeholder");
assert.match(
systemPermissionSource,
/v-for="employee in filteredManagerOptions"/,
"manager selector cards should render filtered manager options"
);
assert.doesNotMatch(
systemPermissionSource,
/<select v-model="orgForm\.manager_employee_ids" multiple>/,
"manager selector should not rely on native multi-select keyboard behavior"
);
assert.match(systemPermissionSource, /多位主管/, "manager drawer should explain multiple manager binding");
assert.match(systemPermissionSource, /contextMenu/, "page must expose a right-click context menu for mind-map nodes");
assert.match(systemPermissionSource, /@node-contextmenu/, "page must listen for node context menu events");
assert.match(systemPermissionSource, /重命名/, "context menu should support renaming organization nodes");
assert.match(systemPermissionSource, /新增分公司节点/, "company context menu should support adding branch nodes");
assert.match(systemPermissionSource, /新增部门节点/, "branch context menu should support adding department nodes");
assert.match(systemPermissionSource, /新增小组节点/, "department context menu should support adding group nodes");
assert.match(systemPermissionSource, /新增人员/, "department and group context menu should support adding employee leaves");
assert.match(systemPermissionSource, /添加该节点的主管/, "context menu should support manager binding");
assert.match(systemPermissionSource, /deleteResource/, "page must call backend delete endpoints for org actions");
assert.match(systemPermissionSource, /\/employees/, "page must call backend employee binding endpoints");
assert.match(systemPermissionSource, /员工节点默认收拢/, "page should document collapsed employee leaves");
assert.doesNotMatch(systemPermissionSource, /class="org-node-detail"/, "mind-map node operations must not stay in a persistent bottom bar");
assert.match(systemPermissionSource, /mindmap-toolbar/, "mind-map should expose a top-right toolbar");
assert.match(systemPermissionSource, /放大/, "mind-map toolbar should support zooming in");
assert.match(systemPermissionSource, /缩小/, "mind-map toolbar should support zooming out");
assert.match(systemPermissionSource, /刷新/, "mind-map toolbar should support refreshing data");
assert.match(systemPermissionSource, /一屏展示/, "mind-map toolbar should expose a separate one-screen fit button");
assert.match(systemPermissionSource, /mindMapScale/, "page should keep mind-map zoom state");
assert.match(systemPermissionSource, /refreshMindMap/, "page should refresh mind-map data from toolbar");
assert.match(systemPermissionSource, /fitMindMapToScreen/, "page should separate one-screen fitting from refreshing");
assert.match(systemPermissionSource, /mindMapFitRequestKey/, "page should request one-screen mind-map fitting");
assert.match(
systemPermissionSource,
/:fit-request-key="mindMapFitRequestKey"/,
"SystemPermissionView should pass fit request key to OrgMindMap"
);
assert.doesNotMatch(
systemPermissionSource,
/@zoom-scale=/,
"SystemPermissionView should not accept mouse-wheel zoom from OrgMindMap"
);
assert.match(
systemPermissionSource,
/@fit-scale="applyMindMapFitScale"/,
"SystemPermissionView should accept fit scale from OrgMindMap"
);
assert.match(
systemPermissionSource,
/function applyMindMapFitScale/,
"SystemPermissionView should apply one-screen fit scale"
);
assert.doesNotMatch(
systemPermissionSource,
/:key="mindMapRenderKey"/,
"mind-map refresh must not recreate OrgMindMap because that collapses expanded employees"
);
const refreshMindMapMatch = systemPermissionSource.match(/async function refreshMindMap\(\)\s*\{([\s\S]*?)\n\}/);
assert.ok(refreshMindMapMatch, "refreshMindMap function block must be parseable");
assert.doesNotMatch(
refreshMindMapMatch[1],
/mindMapFitRequestKey\.value \+= 1/,
"refresh must not request one-screen fit; one-screen fit is a separate user action"
);
assert.doesNotMatch(
refreshMindMapMatch[1],
/mindMapRenderKey\.value \+= 1/,
"refresh must not recreate mind-map component"
);
assert.match(
systemPermissionSource,
/function fitMindMapToScreen\(\)[\s\S]*mindMapFitRequestKey\.value \+= 1/,
"one-screen fit action should request a viewport fit explicitly"
);
assert.match(systemPermissionSource, /const updatePayload\s*=/, "org edit must build an update-only payload");
assert.match(systemPermissionSource, /const createPayload\s*=/, "org create must build a create payload");
const updatePayloadMatch = systemPermissionSource.match(/const updatePayload\s*=\s*\{([\s\S]*?)\n\s*\};/);
assert.ok(updatePayloadMatch, "org edit updatePayload block must be parseable");
assert.doesNotMatch(
updatePayloadMatch[1],
/parent_id|node_type|dept_code/,
"org edit updatePayload must exclude create-only fields"
);
assert.match(
systemPermissionSource,
/putResource\(`\/system-permissions\/org-nodes\/\$\{editingOrgNode\.value\.node_id\}`,\s*updatePayload\)/,
"org edit PUT must only send the update payload"
);
assert.match(
systemPermissionSource,
/postResource\("\/system-permissions\/org-nodes",\s*createPayload\)/,
"org create POST must send the create payload"
);
assert.match(
systemPermissionSource,
/@change="togglePermissionNode\(group, \$event\.target\.checked\)"/,
"parent permission checkbox must toggle descendants"
);
const persistablePermissionCodesMatch = systemPermissionSource.match(/function collectPersistablePermissionCodes\(node\)\s*\{([\s\S]*?)\n\}/);
assert.ok(persistablePermissionCodesMatch, "collectPersistablePermissionCodes function block must be parseable");
assert.match(
persistablePermissionCodesMatch[1],
/if \(children\.length\)[\s\S]*return children\.flatMap\(\(child\) => collectPersistablePermissionCodes\(child\)\)/,
"group-only parent permission codes must be skipped in favor of child menu permissions"
);
assert.match(
systemPermissionSource,
/for \(const code of collectPersistablePermissionCodes\(node\)\)/,
"permission toggling must persist only real menu permission codes"
);
assert.match(
systemPermissionSource,
/if \(!authorizingEmployee\.value\?\.employee_id\)[\s\S]*notifyError\("请选择有效人员"\)/,
"authorization submit must reject missing employee context"
);
assert.match(
systemPermissionSource,
/if \(!authorizingEmployeePhone\.value\)[\s\S]*notifyError\("该人员没有电话号码,不能作为账号"\)/,
"authorization submit must reject employees without a phone account"
);
assert.doesNotMatch(
systemPermissionSource,
/window\.alert\("新增系统使用人员抽屉将在实现任务中接入/,
"temporary user alert must be removed"
);
assert.doesNotMatch(
systemPermissionSource,
/window\.alert\("新增角色抽屉将在实现任务中接入/,
"temporary role alert must be removed"
);
assert.match(orgMindMapSource, /defineProps/, "OrgMindMap should use defineProps");
assert.match(orgMindMapSource, /orthogonal/i, "OrgMindMap should name orthogonal connector logic");
assert.match(orgMindMapSource, /collapsedEmployeeNodeIds/, "OrgMindMap should default employee leaves to collapsed");
assert.match(orgMindMapSource, /toggle-employee-leaves/, "OrgMindMap should emit employee leaf expand or collapse events");
assert.match(orgMindMapSource, /employee_count/, "OrgMindMap should display employee count on parent org cards");
assert.match(orgMindMapSource, /node-contextmenu/, "OrgMindMap should emit right-click context menu events");
assert.match(orgMindMapSource, /manager_names/, "OrgMindMap should display multiple manager names");
assert.match(orgMindMapSource, /zoomScale/, "OrgMindMap should accept a zoom scale prop");
assert.match(orgMindMapSource, /fitRequestKey/, "OrgMindMap should accept fit request key");
assert.match(orgMindMapSource, /fit-scale/, "OrgMindMap should emit fit scale");
assert.match(orgMindMapSource, /mindMapViewport/, "OrgMindMap should keep a viewport ref for canvas panning");
assert.match(orgMindMapSource, /isCanvasPanning/, "OrgMindMap should track canvas panning state");
assert.match(orgMindMapSource, /startCanvasPan/, "OrgMindMap should start canvas panning from empty canvas space");
assert.match(orgMindMapSource, /moveCanvasPan/, "OrgMindMap should move the viewport while panning");
assert.match(orgMindMapSource, /endCanvasPan/, "OrgMindMap should stop canvas panning");
assert.doesNotMatch(orgMindMapSource, /@wheel/, "OrgMindMap should not bind mouse-wheel zooming");
assert.doesNotMatch(orgMindMapSource, /function handleCanvasWheel/, "OrgMindMap should remove mouse-wheel zoom handling");
assert.doesNotMatch(orgMindMapSource, /WHEEL_ZOOM_STEP/, "OrgMindMap should not keep mouse-wheel zoom step configuration");
assert.doesNotMatch(orgMindMapSource, /emit\("zoom-scale"/, "OrgMindMap should not emit wheel zoom scale to parent");
assert.match(orgMindMapSource, /fitCanvasToViewport/, "OrgMindMap should fit canvas to visible viewport");
assert.match(
orgMindMapSource,
/window\.addEventListener\("mousemove", moveCanvasPan\)/,
"OrgMindMap should pan canvas through window mousemove listener"
);
assert.match(
orgMindMapSource,
/viewport\.scrollTop = canvasPan\.value\.scrollTop - deltaY/,
"OrgMindMap should support vertical canvas panning in addition to horizontal panning"
);
assert.match(
orgMindMapSource,
/window\.removeEventListener\("mousemove", moveCanvasPan\)/,
"OrgMindMap should clean up canvas pan listener"
);
assert.match(
orgMindMapSource,
/closest\?\.\("\.org-mind-map-node"\)/,
"canvas panning should not start from node cards"
);
assert.match(
orgMindMapSource,
/defineExpose/,
"OrgMindMap should expose fit helper for explicit refresh handling if needed"
);
assert.match(orgMindMapSource, /scaledCanvasStyle/, "OrgMindMap should size the canvas according to zoom scale");
assert.match(orgMindMapSource, /overscroll-behavior: contain;/, "OrgMindMap should keep drag interaction inside the canvas viewport");
assert.match(orgMindMapSource, /NODE_MIN_WIDTH/, "OrgMindMap should have a minimum node width");
assert.match(orgMindMapSource, /NODE_MAX_WIDTH/, "OrgMindMap should have a maximum node width");
assert.match(orgMindMapSource, /function estimateTextWidth/, "OrgMindMap should estimate text width before laying out cards");
assert.match(orgMindMapSource, /function computeNodeWidth/, "OrgMindMap should compute node width from text length");
assert.match(orgMindMapSource, /function fitTextToWidth/, "OrgMindMap should safely fit very long text into cards");
assert.match(orgMindMapSource, /:width="node\.width"/, "OrgMindMap node card width should use computed per-node width");
assert.match(
orgMindMapSource,
/parent\.x \+ parent\.width/,
"connector layout should use each node's computed width"
);
assert.match(orgMindMapSource, /function measureSubtree/, "mind map must measure subtree leaf spans before layout");
assert.match(orgMindMapSource, /function layoutTree/, "mind map must use deterministic tree layout");
assert.match(orgMindMapSource, /connectorSegments/, "mind map must deduplicate connector segments");
assert.match(orgMindMapSource, /COLUMN_GAP/, "mind map must reserve a column gutter for connector lines");
assert.match(orgMindMapSource, /NODE_MAX_WIDTH/, "mind map must know maximum node width for line avoidance");
assert.match(
orgMindMapSource,
/H \$\{connector\.midX\} V \$\{connector\.toY\} H \$\{connector\.toX\}/,
"OrgMindMap should render H/V/H orthogonal connector paths"
);
assert.doesNotMatch(orgMindMapSource, /draggable="true"/, "OrgMindMap should not mark cards draggable");
assert.doesNotMatch(
orgMindMapSource,
/@(drag|dragstart|dragend|pointermove|mousemove)\b/,
"OrgMindMap should not bind free-drag handlers"
);
assert.doesNotMatch(
orgMindMapSource,
/<path[^>]*\sd="[^"]*[CQ]/i,
"OrgMindMap should not render curved SVG path commands"
);
assert.match(orgPermissionTreeSource, /组织树/, "OrgPermissionTree should render organization tree UI");
assert.match(orgPermissionTreeSource, /onContextmenu/, "OrgPermissionTree should support right-click org node actions");
assert.match(orgPermissionTreeSource, /授权/, "OrgPermissionTree should expose personnel authorization action");
assert.match(orgPermissionTreeSource, /移除/, "OrgPermissionTree should expose personnel removal action");
assert.match(orgPermissionTreeSource, /新增人员/, "OrgPermissionTree should expose add-person action for selected org node");
assert.match(orgPermissionTreeSource, /PaginationBar/, "OrgPermissionTree should use global pagination bar");
assert.match(orgPermissionTreeSource, /搜索人员姓名或手机号/, "OrgPermissionTree should support personnel search");
console.log("system permission management UI static assertions passed");
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const checks = [
{
label: "仓库页面",
file: path.join(root, "src/views/InventoryLedgerView.vue")
},
{
label: "发货台账页面",
file: path.join(root, "src/views/DeliveryManagementView.vue")
},
{
label: "物流校验服务",
file: path.join(root, "../backend/app/services/logistics.py")
},
{
label: "仓库照片上传接口",
file: path.join(root, "../backend/app/api/routes/inventory.py")
}
];
for (const check of checks) {
const source = fs.readFileSync(check.file, "utf8");
assert.doesNotMatch(source, /订单照片/, `${check.label}不应再显示“订单照片”`);
assert.match(source, /辅助照片/, `${check.label}应显示“辅助照片”`);
}
for (const relativePath of [
"src",
"../backend/app"
]) {
const findCommand = `rg -n "订单照片" ${JSON.stringify(path.join(root, relativePath))} --glob '!dist/**' --glob '!**/node_modules/**'`;
const { execSync } = await import("node:child_process");
let output = "";
try {
output = execSync(findCommand, { encoding: "utf8" });
} catch (error) {
output = error.status === 1 ? "" : String(error.stdout || error.message || "");
}
assert.equal(output.trim(), "", `系统源码不应再残留“订单照片”:${output}`);
}
console.log("auxiliary photo label checks passed");
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const styles = fs.readFileSync(path.join(root, "src/styles/main.css"), "utf8");
const inventory = fs.readFileSync(path.join(root, "src/views/InventoryLedgerView.vue"), "utf8");
function blockFor(selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = styles.match(new RegExp(`${escaped}\\s*\\{([\\s\\S]*?)\\}`));
assert.ok(match, `missing style block for ${selector}`);
return match[1];
}
const workflowTableWrap = blockFor(".workflow-drawer .table-wrap");
assert.match(
workflowTableWrap,
/overflow-x:\s*auto/,
"drawer table wrappers must allow horizontal scrolling instead of clipping overflow"
);
assert.doesNotMatch(
workflowTableWrap,
/overflow-x:\s*hidden/,
"drawer table wrappers must not hide overflowing columns"
);
const drawerDataTableWrap = blockFor(".drawer-data-table-wrap");
assert.match(
drawerDataTableWrap,
/overflow-x:\s*auto/,
"DrawerDataTable wrapper must allow horizontal scrolling"
);
assert.doesNotMatch(
drawerDataTableWrap,
/overflow:\s*hidden|overflow-x:\s*hidden/,
"DrawerDataTable wrapper must not clip columns"
);
const workflowTable = blockFor(".workflow-drawer .data-table");
assert.match(
workflowTable,
/width:\s*max-content/,
"drawer tables should size to their columns when wider than the drawer"
);
assert.match(
workflowTable,
/min-width:\s*100%/,
"drawer tables should still fill the drawer when columns are narrow"
);
assert.match(
workflowTable,
/table-layout:\s*auto/,
"drawer tables should not use fixed layout that squeezes headers and cells"
);
const drawerDataTable = blockFor(".drawer-data-table");
assert.match(
drawerDataTable,
/width:\s*max-content/,
"DrawerDataTable should size to column widths when needed"
);
assert.match(
drawerDataTable,
/min-width:\s*100%/,
"DrawerDataTable should still fill the available drawer width"
);
assert.match(
drawerDataTable,
/table-layout:\s*auto/,
"DrawerDataTable should not use fixed layout that cuts off trailing columns"
);
assert.match(
inventory,
/<DrawerDataTable[\s\S]*:columns="lotDrawerColumns"/,
"warehouse lot details should use DrawerDataTable and benefit from the overflow fix"
);
assert.match(
inventory,
/<DrawerDataTable[\s\S]*:columns="txnDrawerColumns"/,
"warehouse transaction details should use DrawerDataTable and benefit from the overflow fix"
);
console.log("warehouse drawer table overflow checks passed");
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "App.vue"), "utf8");
describe("App reset password account picker", () => {
it("uses a searchable system-user select instead of free text input", () => {
assert.match(source, /<select[\s\S]*?v-model="resetPasswordForm\.username"[\s\S]*?required[\s\S]*?>/);
assert.doesNotMatch(source, /placeholder="请输入需要重置密码的账号"/);
});
it("loads reset password account options from system permission users", () => {
assert.match(source, /fetchResource\("\/system-permissions\/users", \[\]\)/);
assert.match(source, /resetPasswordUserOptions/);
assert.match(source, /formatResetPasswordUserLabel\(user\)/);
});
});
+1242
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
<template>
<button
class="action-button"
:class="[
`action-button-${normalizedVariant}`,
`action-button-${normalizedSize}`,
{ 'action-button-icon-only': iconOnly }
]"
:type="type"
:disabled="disabled || loading"
:data-button-kind="normalizedVariant"
:data-button-tone="buttonTone"
>
<AppIcon v-if="icon" :name="icon" :tone="iconTone" />
<span v-if="!iconOnly" class="action-button-label"><slot /></span>
<span v-if="loading" class="action-button-spinner" aria-hidden="true"></span>
</button>
</template>
<script setup>
import { computed } from "vue";
import AppIcon from "./AppIcon.vue";
const props = defineProps({
icon: { type: String, default: "" },
variant: { type: String, default: "secondary" },
size: { type: String, default: "md" },
type: { type: String, default: "button" },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
iconOnly: { type: Boolean, default: false }
});
const normalizedVariant = computed(() => {
return ["primary", "secondary", "success", "warning", "danger", "ghost"].includes(props.variant)
? props.variant
: "secondary";
});
const normalizedSize = computed(() => (["sm", "md", "lg"].includes(props.size) ? props.size : "md"));
const buttonTone = computed(() => {
if (normalizedVariant.value === "danger") return "danger";
if (normalizedVariant.value === "primary") return "primary";
if (normalizedVariant.value === "success") return "success";
if (normalizedVariant.value === "warning") return "warning";
return "neutral";
});
const iconTone = computed(() => {
if (normalizedVariant.value === "danger") return "danger";
if (normalizedVariant.value === "primary") return "primary";
if (normalizedVariant.value === "success") return "success";
if (normalizedVariant.value === "warning") return "warning";
return "muted";
});
</script>
+39
View File
@@ -0,0 +1,39 @@
<template>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">异常中心</p>
<h3>需要管理员快速处理的事项</h3>
</div>
<span class="panel-tag">实时优先级</span>
</div>
<div class="alert-list">
<article
v-for="alert in alerts"
:key="`${alert.level}-${alert.title}`"
class="alert-item"
:class="`alert-${alert.level}`"
:title="alert.description"
>
<div class="alert-top">
<strong>{{ alert.title }}</strong>
<span>{{ alert.owner }}</span>
</div>
<div class="alert-footer">
<span>建议动作</span>
<strong>{{ alert.action }}</strong>
</div>
</article>
</div>
</section>
</template>
<script setup>
defineProps({
alerts: {
type: Array,
required: true
}
});
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<svg class="app-icon" :class="[`app-icon-${tone}`]" viewBox="0 0 24 24" aria-hidden="true">
<path
v-for="path in paths"
:key="path"
:d="path"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="strokeWidth"
/>
</svg>
</template>
<script setup>
import { computed } from "vue";
import { ICON_PATHS } from "../utils/iconMap";
const props = defineProps({
name: { type: String, required: true },
tone: { type: String, default: "default" },
strokeWidth: { type: [Number, String], default: 1.8 }
});
const paths = computed(() => ICON_PATHS[props.name] || ICON_PATHS.dashboard);
</script>
+129
View File
@@ -0,0 +1,129 @@
<template>
<div class="drawer-data-table-wrap">
<table class="data-table compact-table drawer-data-table">
<colgroup>
<col v-for="column in visibleColumns" :key="column.key" :style="{ width: column.width || 'auto' }" />
<col v-if="expandable" style="width: 72px" />
</colgroup>
<thead>
<tr>
<th v-for="column in visibleColumns" :key="column.key" :title="headerTitle(column)">
<span class="drawer-table-head-label">{{ headerLabel(column) }}</span>
</th>
<th v-if="expandable" title="详情">详情</th>
</tr>
</thead>
<tbody>
<template v-for="row in rows" :key="getRowKey(row)">
<tr>
<td v-for="column in visibleColumns" :key="column.key" :class="cellClass(column)">
<slot :name="`cell-${column.key}`" :row="row" :column="column" :value="row[column.key]">
<span :class="fallbackClass(column)" :title="formatFallback(row, column)">
{{ formatFallback(row, column) }}
</span>
</slot>
</td>
<td v-if="expandable" class="drawer-cell-actions">
<button class="mini-icon-button" type="button" @click="toggleRow(row)">
{{ isExpanded(row) ? "收起" : "展开" }}
</button>
</td>
</tr>
<tr v-if="expandable && isExpanded(row)" class="drawer-row-detail">
<td :colspan="visibleColumns.length + 1">
<slot name="detail" :row="row">
<div class="drawer-row-detail-grid">
<article v-for="column in detailColumns" :key="column.key">
<span>{{ column.label }}</span>
<strong>{{ formatFallback(row, column) }}</strong>
</article>
</div>
</slot>
</td>
</tr>
</template>
<tr v-if="!rows.length">
<td :colspan="visibleColumns.length + (expandable ? 1 : 0)" class="empty-row">{{ emptyText }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
rows: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => []
},
rowKey: {
type: [String, Function],
default: "id"
},
expandedRows: {
type: Array,
default: () => []
},
emptyText: {
type: String,
default: "暂无数据"
}
});
const emit = defineEmits(["update:expandedRows"]);
const visibleColumns = computed(() => props.columns.filter((column) => !column.hidden && !column.detailOnly));
const detailColumns = computed(() => props.columns.filter((column) => column.detailOnly));
const expandable = computed(() => detailColumns.value.length > 0);
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
function isExpanded(row) {
return props.expandedRows.includes(getRowKey(row));
}
function toggleRow(row) {
const key = getRowKey(row);
const nextRows = isExpanded(row) ? props.expandedRows.filter((item) => item !== key) : [...props.expandedRows, key];
emit("update:expandedRows", nextRows);
}
function cellClass(column) {
return {
"drawer-cell-number": column.type === "number" || column.align === "right",
"drawer-cell-status": column.type === "status",
"drawer-cell-long": column.type === "code" || column.type === "longText"
};
}
function fallbackClass(column) {
return {
"drawer-cell-code": column.type === "code",
"drawer-cell-ellipsis": column.type === "longText" || column.type === "code"
};
}
function headerLabel(column) {
return column.compactLabel || column.label || column.title || column.key;
}
function headerTitle(column) {
return column.title || column.label || column.compactLabel || column.key;
}
function formatFallback(row, column) {
if (typeof column.format === "function") {
return column.format(row[column.key], row);
}
const value = row?.[column.key];
return value === null || value === undefined || value === "" ? "-" : String(value);
}
</script>
+96
View File
@@ -0,0 +1,96 @@
<template>
<span
ref="hintRoot"
class="field-hint"
tabindex="0"
:aria-describedby="popoverId"
@mouseenter="showPopover"
@mouseleave="hidePopover"
@focus="showPopover"
@blur="hidePopover"
@keydown.escape="hidePopover"
>
<AppIcon name="warning" tone="warning" stroke-width="1.6" />
<Teleport to="body">
<span
v-if="popoverVisible"
:id="popoverId"
ref="hintPopover"
class="field-hint-popover"
:class="{
'field-hint-popover-open': popoverPositioned,
'field-hint-popover-bottom': popoverPlacement === 'bottom'
}"
:style="popoverStyle"
role="tooltip"
>
{{ text }}
</span>
</Teleport>
</span>
</template>
<script setup>
import { nextTick, ref, useId } from "vue";
import AppIcon from "./AppIcon.vue";
defineProps({
text: {
type: String,
required: true
}
});
const popoverId = `field-hint-${useId()}`;
const hintRoot = ref(null);
const hintPopover = ref(null);
const popoverVisible = ref(false);
const popoverPositioned = ref(false);
const popoverPlacement = ref("top");
const popoverStyle = ref({});
async function showPopover() {
popoverVisible.value = true;
popoverPositioned.value = false;
await nextTick();
updatePopoverPosition();
popoverPositioned.value = true;
}
function hidePopover() {
popoverVisible.value = false;
popoverPositioned.value = false;
}
function updatePopoverPosition() {
if (!hintRoot.value || !hintPopover.value) {
return;
}
const viewportPadding = 12;
const gap = 10;
const rootRect = hintRoot.value.getBoundingClientRect();
const popoverRect = hintPopover.value.getBoundingClientRect();
const maxWidth = Math.max(160, Math.min(280, window.innerWidth - viewportPadding * 2));
const width = Math.min(popoverRect.width || maxWidth, maxWidth);
const height = popoverRect.height || 40;
const rootCenter = rootRect.left + rootRect.width / 2;
const left = Math.min(Math.max(rootCenter - width / 2, viewportPadding), window.innerWidth - width - viewportPadding);
let top = rootRect.top - height - gap;
let placement = "top";
if (top < viewportPadding) {
top = rootRect.bottom + gap;
placement = "bottom";
}
popoverPlacement.value = placement;
popoverStyle.value = {
left: `${Math.round(left)}px`,
top: `${Math.round(top)}px`,
maxWidth: `${Math.round(maxWidth)}px`,
"--field-hint-arrow-left": `${Math.round(rootCenter - left)}px`
};
}
</script>
+40
View File
@@ -0,0 +1,40 @@
<template>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">流程闭环</p>
<h3>订单、材料、工单、完工的联动看板</h3>
</div>
<span class="panel-tag">流程化管理</span>
</div>
<div class="flow-grid">
<section v-for="group in groups" :key="group.title" class="flow-group">
<div class="flow-group-head">
<h4>{{ group.title }}</h4>
</div>
<article
v-for="step in group.items"
:key="step.step_code"
class="flow-step"
:class="`flow-${step.status}`"
:title="step.summary"
>
<div class="flow-step-top">
<strong>{{ step.title }}</strong>
<span>{{ step.quantity }}</span>
</div>
</article>
</section>
</div>
</section>
</template>
<script setup>
defineProps({
groups: {
type: Array,
required: true
}
});
</script>
+79
View File
@@ -0,0 +1,79 @@
<template>
<Teleport to="body">
<div v-if="open" class="workflow-drawer-mask" @click.self="emit('close')">
<aside class="workflow-drawer form-drawer" :class="drawerClass" :aria-describedby="description ? descriptionId : undefined">
<header class="workflow-drawer-head">
<div>
<p class="eyebrow">{{ eyebrow }}</p>
<div class="drawer-title-row" :title="description || undefined">
<h3>{{ title }}</h3>
</div>
<p v-if="description" :id="descriptionId" class="form-drawer-description">{{ description }}</p>
</div>
<div class="workflow-drawer-head-actions">
<span v-if="tag" class="panel-tag">{{ tag }}</span>
<button class="ghost-button icon-button" type="button" aria-label="关闭" title="关闭" @click="emit('close')">
<AppIcon name="close" />
</button>
</div>
</header>
<section class="workflow-section-card form-drawer-body">
<slot />
</section>
</aside>
</div>
</Teleport>
</template>
<script setup>
import { computed, toRef, useId } from "vue";
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
import AppIcon from "./AppIcon.vue";
const props = defineProps({
open: {
type: Boolean,
default: false
},
eyebrow: {
type: String,
default: "资料维护"
},
title: {
type: String,
required: true
},
description: {
type: String,
default: ""
},
tag: {
type: String,
default: ""
},
wide: {
type: Boolean,
default: false
},
size: {
type: String,
default: "normal"
}
});
const emit = defineEmits(["close"]);
const descriptionId = `form-drawer-description-${useId()}`;
const drawerClass = computed(() => {
const normalizedSize = ["normal", "wide", "xl", "fullscreen"].includes(props.size) ? props.size : "normal";
return {
"form-drawer-wide": props.wide || normalizedSize === "wide",
"form-drawer-xl": normalizedSize === "xl",
"form-drawer-fullscreen": normalizedSize === "fullscreen"
};
});
useBodyScrollLock(toRef(props, "open"));
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<section class="form-section" :class="sectionClass">
<header v-if="hasHeader" class="form-section-header">
<div class="form-section-heading">
<p v-if="eyebrow" class="form-section-eyebrow">{{ eyebrow }}</p>
<h3 v-if="title" class="form-section-title">{{ title }}</h3>
<p v-if="description" class="form-section-description">{{ description }}</p>
</div>
<div v-if="$slots.actions" class="form-section-actions">
<slot name="actions" />
</div>
</header>
<div class="form-section-grid">
<slot />
</div>
</section>
</template>
<script setup>
import { computed, useSlots } from "vue";
const props = defineProps({
eyebrow: {
type: String,
default: ""
},
title: {
type: String,
default: ""
},
description: {
type: String,
default: ""
},
columns: {
type: [Number, String],
default: 2,
validator: (value) => [1, 2, 3, "1", "2", "3"].includes(value)
}
});
const slots = useSlots();
const normalizedColumns = computed(() => Number(props.columns) || 2);
const hasHeader = computed(() => Boolean(props.eyebrow || props.title || props.description || slots.actions));
const sectionClass = computed(() => ({
[`form-section-columns-${normalizedColumns.value}`]: true
}));
</script>
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const componentSource = readFileSync(join(__dirname, "GlobalSearchableSelect.vue"), "utf8");
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
describe("GlobalSearchableSelect option panel", () => {
it("keeps disabled empty placeholder options out of the dropdown list", () => {
assert.match(componentSource, /function isPanelPlaceholderOption\(option\)/);
assert.match(componentSource, /\.filter\(\(\{ option \}\) => !isPanelPlaceholderOption\(option\)\)/);
});
it("lays option text out as a compact two-column business row", () => {
assert.match(mainCss, /\.global-select-layer \.global-select-option \{[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/);
assert.match(mainCss, /\.global-select-layer \.global-select-option-meta \{[\s\S]*grid-column:\s*2;/);
assert.match(mainCss, /\.global-select-layer \.global-select-option-sub \{[\s\S]*grid-column:\s*1 \/ -1;/);
});
});
@@ -0,0 +1,440 @@
<template>
<Teleport to="body">
<div
v-if="open"
ref="panelRef"
class="global-select-layer"
@pointerdown.stop
@mousedown.stop
@click.stop
>
<div class="global-select-input-box" :style="inputStyle">
<input
ref="searchInputRef"
v-model.trim="searchText"
class="global-select-search-input"
type="text"
:placeholder="selectedLabel || '请输入关键字搜索'"
@keydown.down.prevent="moveActive(1)"
@keydown.up.prevent="moveActive(-1)"
@keydown.enter.prevent="chooseActive"
@keydown.esc.prevent="closePanel"
/>
<span class="global-select-arrow" aria-hidden="true"></span>
</div>
<div class="global-select-panel" :style="panelStyle">
<div class="global-select-option-list">
<button
v-for="(option, index) in filteredOptions"
:key="`${option.index}-${option.value}`"
class="global-select-option"
:class="{
'global-select-option-active': index === activeIndex,
'global-select-option-selected': option.selected,
'global-select-option-disabled': option.disabled
}"
type="button"
:disabled="option.disabled"
@mouseenter="activeIndex = index"
@click="selectOption(option)"
>
<span class="global-select-option-main">{{ option.display.title }}</span>
<span v-if="option.display.subtitle" class="global-select-option-sub">{{ option.display.subtitle }}</span>
<span v-if="option.display.meta" class="global-select-option-meta">{{ option.display.meta }}</span>
</button>
<div v-if="!filteredOptions.length" class="global-select-empty">没有匹配选项</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
const open = ref(false);
const activeSelect = ref(null);
const options = ref([]);
const searchText = ref("");
const activeIndex = ref(0);
const panelRef = ref(null);
const searchInputRef = ref(null);
const panelPosition = ref({
left: 0,
top: 0,
width: 280,
height: 48,
maxHeight: 360
});
const selectedLabel = computed(() => {
const selectedOptionLabel = cleanOptionLabel(activeSelect.value?.selectedOptions?.[0]?.textContent);
if (selectedOptionLabel) {
return selectedOptionLabel;
}
const selected = options.value.find((option) => option.selected);
return selected?.label || selected?.value || "";
});
const inputStyle = computed(() => ({
left: `${panelPosition.value.left}px`,
top: `${panelPosition.value.top}px`,
width: `${panelPosition.value.width}px`,
height: `${panelPosition.value.height}px`
}));
const panelStyle = computed(() => ({
left: `${panelPosition.value.left}px`,
top: `${panelPosition.value.top + panelPosition.value.height + 6}px`,
width: `${panelPosition.value.width}px`,
maxHeight: `${panelPosition.value.maxHeight}px`
}));
const filteredOptions = computed(() => {
const keywords = normalizeText(searchText.value).split(/\s+/).filter(Boolean);
if (!keywords.length) {
return options.value;
}
return options.value.filter((option) => {
const searchBody = normalizeText(`${option.label} ${option.value}`);
return keywords.every((keyword) => searchBody.includes(keyword));
});
});
watch(filteredOptions, (rows) => {
if (!rows.length) {
activeIndex.value = 0;
return;
}
if (activeIndex.value >= rows.length) {
activeIndex.value = rows.length - 1;
}
if (activeIndex.value < 0) {
activeIndex.value = 0;
}
});
function normalizeText(value) {
return String(value || "").trim().toLowerCase();
}
function cleanOptionLabel(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function splitOptionLabel(label) {
const parts = cleanOptionLabel(label).split(" · ").map((part) => part.trim()).filter(Boolean);
return {
title: parts[0] || cleanOptionLabel(label) || "空选项",
subtitle: parts[1] || "",
meta: parts.slice(2).join(" · ")
};
}
function isSearchableSelect(target) {
return target instanceof HTMLSelectElement && !target.disabled && target.size <= 1 && !target.multiple;
}
function isPanelPlaceholderOption(option) {
return Boolean(option?.disabled && option?.value === "");
}
function collectOptions(selectEl) {
return Array.from(selectEl.options || [])
.map((option, index) => ({ option, index }))
.filter(({ option }) => !isPanelPlaceholderOption(option))
.map(({ option, index }) => {
const label = cleanOptionLabel(option.textContent);
return {
index,
value: option.value,
label,
display: splitOptionLabel(label),
disabled: Boolean(option.disabled || option.parentElement?.disabled),
selected: index === selectEl.selectedIndex
};
});
}
function calculatePanelPosition(selectEl) {
const rect = selectEl.getBoundingClientRect();
const margin = 8;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const width = Math.max(rect.width, 180);
const left = Math.min(Math.max(rect.left, margin), Math.max(viewportWidth - width - margin, margin));
const top = Math.min(Math.max(rect.top, margin), Math.max(viewportHeight - rect.height - margin, margin));
const belowSpace = viewportHeight - top - rect.height - margin - 6;
const maxHeight = Math.max(Math.min(belowSpace, 320), 160);
panelPosition.value = {
left,
top,
width,
height: Math.max(rect.height, 40),
maxHeight
};
}
function openForSelect(selectEl) {
activeSelect.value = selectEl;
options.value = collectOptions(selectEl);
searchText.value = "";
const selectedIndex = options.value.findIndex((option) => option.selected && !option.disabled);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : Math.max(options.value.findIndex((option) => !option.disabled), 0);
calculatePanelPosition(selectEl);
open.value = true;
nextTick(() => {
searchInputRef.value?.focus();
searchInputRef.value?.select();
});
}
function closePanel() {
open.value = false;
activeSelect.value = null;
options.value = [];
searchText.value = "";
activeIndex.value = 0;
}
function moveActive(delta) {
const rows = filteredOptions.value;
if (!rows.length) {
return;
}
let nextIndex = activeIndex.value;
for (let step = 0; step < rows.length; step += 1) {
nextIndex = (nextIndex + delta + rows.length) % rows.length;
if (!rows[nextIndex]?.disabled) {
activeIndex.value = nextIndex;
return;
}
}
}
function chooseActive() {
const option = filteredOptions.value[activeIndex.value];
if (option) {
selectOption(option);
}
}
function selectOption(option) {
const selectEl = activeSelect.value;
if (!selectEl || option.disabled) {
return;
}
selectEl.selectedIndex = option.index;
selectEl.dispatchEvent(new Event("input", { bubbles: true }));
selectEl.dispatchEvent(new Event("change", { bubbles: true }));
selectEl.focus({ preventScroll: true });
closePanel();
}
function handlePointerDown(event) {
const target = event.target;
if (panelRef.value?.contains(target)) {
return;
}
if (!isSearchableSelect(target)) {
if (open.value) {
closePanel();
}
return;
}
event.preventDefault();
event.stopPropagation();
openForSelect(target);
}
function handleMouseDown(event) {
if ("PointerEvent" in window) {
return;
}
handlePointerDown(event);
}
function handleKeyDown(event) {
if (open.value) {
if (event.key === "Escape") {
event.preventDefault();
closePanel();
}
return;
}
if (
isSearchableSelect(event.target) &&
["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)
) {
event.preventDefault();
openForSelect(event.target);
}
}
function handleViewportChange() {
if (open.value && activeSelect.value) {
calculatePanelPosition(activeSelect.value);
}
}
onMounted(() => {
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("mousedown", handleMouseDown, true);
document.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
});
onUnmounted(() => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("mousedown", handleMouseDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
});
</script>
<style scoped>
.global-select-layer {
position: fixed;
inset: 0;
z-index: var(--ui-z-global-select, 3100);
pointer-events: none;
}
.global-select-input-box {
position: fixed;
pointer-events: auto;
}
.global-select-search-input {
width: 100%;
height: 100%;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #ffffff;
color: #606266;
font: inherit;
font-weight: 400;
outline: none;
padding: 0 34px 0 12px;
box-shadow:
0 0 0 2px rgba(64, 158, 255, 0.1),
0 6px 16px rgba(31, 45, 61, 0.12);
}
.global-select-search-input:focus {
border-color: #409eff;
box-shadow:
0 0 0 2px rgba(64, 158, 255, 0.12),
0 6px 16px rgba(31, 45, 61, 0.12);
}
.global-select-search-input::placeholder {
color: #c0c4cc;
}
.global-select-arrow {
position: absolute;
right: 13px;
top: 50%;
width: 8px;
height: 8px;
border-bottom: 1.5px solid #c0c4cc;
border-right: 1.5px solid #c0c4cc;
pointer-events: none;
transform: translateY(-65%) rotate(45deg);
}
.global-select-panel {
position: fixed;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #e4e7ed;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.14);
backdrop-filter: none;
pointer-events: auto;
}
.global-select-option-list {
overflow: auto;
padding: 6px 0;
}
.global-select-option {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 3px 10px;
border: 0;
border-radius: 0;
background: transparent;
color: #606266;
cursor: pointer;
font: inherit;
min-height: 34px;
padding: 8px 14px;
text-align: left;
}
.global-select-option-main {
grid-column: 1;
grid-row: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.global-select-option-sub {
grid-column: 1 / -1;
grid-row: 2;
min-width: 0;
}
.global-select-option-meta {
grid-column: 2;
grid-row: 1;
justify-self: end;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.global-select-option small {
color: #909399;
font-size: 12px;
}
.global-select-option:hover,
.global-select-option-active {
color: #409eff;
background: #f5f7fa;
}
.global-select-option-selected {
color: #409eff;
font-weight: 600;
background: #ecf5ff;
}
.global-select-option-disabled {
cursor: not-allowed;
opacity: 0.45;
}
.global-select-empty {
color: #909399;
padding: 18px 12px;
text-align: center;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
<template>
<article class="kpi-card" :class="`kpi-${metric.highlight || 'normal'}`" :title="metric.note || '关键指标实时刷新'">
<p class="kpi-label">{{ metric.label }}</p>
<div class="kpi-value-row">
<strong class="kpi-value">{{ metric.value }}</strong>
<span v-if="metric.unit" class="kpi-unit">{{ metric.unit }}</span>
</div>
</article>
</template>
<script setup>
defineProps({
metric: {
type: Object,
required: true
}
});
</script>
+31
View File
@@ -0,0 +1,31 @@
<template>
<section class="metric-strip" aria-label="指标概览">
<article v-for="metric in normalizedMetrics" :key="metric._key" class="metric-strip-item">
<span class="metric-strip-label">{{ metric.label }}</span>
<strong class="metric-strip-value">{{ metric.value }}</strong>
<span v-if="metric.hint" class="metric-strip-hint">{{ metric.hint }}</span>
</article>
</section>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
metrics: {
type: Array,
default: () => []
}
});
const normalizedMetrics = computed(() =>
props.metrics
.filter((metric) => metric && typeof metric === "object")
.map((metric, index) => ({
...metric,
label: metric.label || "未命名指标",
value: metric.value ?? "-",
_key: metric.key || `${metric.label || "metric"}-${index}`
}))
);
</script>
@@ -0,0 +1,37 @@
<template>
<Teleport to="body">
<div class="notification-stack" aria-live="polite" aria-atomic="false">
<TransitionGroup name="notification-toast" tag="div" class="notification-stack-list">
<article
v-for="message in messages"
:key="message.id"
class="notification-toast-card"
:class="`notification-toast-${message.type}`"
@mouseenter="pauseToast(message.id)"
@mouseleave="resumeToast(message.id)"
>
<div class="notification-toast-indicator"></div>
<div class="notification-toast-body">
<strong>{{ titleMap[message.type] || "系统提示" }}</strong>
<p>{{ message.text }}</p>
</div>
<button class="notification-toast-close" type="button" aria-label="关闭提示" @click="removeToast(message.id)">
×
</button>
</article>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup>
import { useNotifications } from "../utils/notifications";
const titleMap = {
success: "操作成功",
error: "操作失败",
info: "系统提示"
};
const { messages, removeToast, pauseToast, resumeToast } = useNotifications();
</script>
+553
View File
@@ -0,0 +1,553 @@
<template>
<div
ref="mindMapViewport"
class="org-mind-map"
:class="{ 'org-mind-map--panning': isCanvasPanning }"
@mousedown="startCanvasPan"
>
<svg
class="org-mind-map-canvas"
:viewBox="`0 0 ${canvasWidth} ${canvasHeight}`"
:style="scaledCanvasStyle"
role="img"
aria-label="组织权限脑图"
>
<g class="org-mind-map-connectors" fill="none">
<path
v-for="connector in layout.connectors"
:key="connector.key"
class="org-mind-map-connector"
:d="`M ${connector.fromX} ${connector.fromY} H ${connector.midX} V ${connector.toY} H ${connector.toX}`"
/>
</g>
<g
v-for="node in layout.nodes"
:key="node.key"
:class="['org-mind-map-node', { 'org-mind-map-node--employee': node.isEmployee }]"
:transform="`translate(${node.x} ${node.y})`"
tabindex="0"
role="button"
@click="emit('select-node', node.raw)"
@contextmenu.prevent.stop="emit('node-contextmenu', { node: node.raw, x: $event.clientX, y: $event.clientY })"
@keyup.enter="emit('select-node', node.raw)"
>
<title>{{ node.title }} · {{ node.subtitle }}</title>
<rect class="org-mind-map-card" :width="node.width" :height="NODE_HEIGHT" rx="16" />
<text class="org-mind-map-title" x="18" y="31">{{ node.displayTitle }}</text>
<text class="org-mind-map-subtitle" x="18" y="57">{{ node.displaySubtitle }}</text>
<text
v-if="!node.isEmployee && node.employee_count"
class="org-mind-map-employee-count"
x="18"
y="76"
>
{{ node.employee_count }} 人
</text>
<g
v-if="!node.isEmployee && node.employee_count"
class="org-mind-map-employee-toggle"
:transform="`translate(${node.width - 38} 14)`"
role="button"
tabindex="0"
@click.stop="toggleEmployeeLeaves(node.raw)"
@contextmenu.prevent.stop
@keyup.enter.stop="toggleEmployeeLeaves(node.raw)"
>
<circle r="15" cx="15" cy="15" />
<text x="15" y="20" text-anchor="middle">{{ isEmployeeExpanded(node.raw) ? "−" : "+" }}</text>
</g>
</g>
</svg>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const props = defineProps({
nodes: {
type: Array,
default: () => []
},
zoomScale: {
type: Number,
default: 1
},
fitRequestKey: {
type: Number,
default: 0
}
});
const emit = defineEmits(["select-node", "node-contextmenu", "toggle-employee-leaves", "fit-scale"]);
const NODE_MIN_WIDTH = 190;
const NODE_MAX_WIDTH = 420;
const NODE_HEIGHT = 92;
const TEXT_PADDING_X = 40;
const TOGGLE_RESERVE_WIDTH = 52;
const COLUMN_GAP = 150;
const ROW_GAP = 34;
const CANVAS_PADDING = 24;
const COLUMN_WIDTH = NODE_MAX_WIDTH + COLUMN_GAP;
const ROW_HEIGHT = NODE_HEIGHT + ROW_GAP;
const ROOT_GROUP_GAP = 1;
const FIT_SCALE_MIN = 0.12;
const FIT_SCALE_MAX = 1;
const mindMapViewport = ref(null);
const isCanvasPanning = ref(false);
const canvasPan = ref({
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0
});
const expandedEmployeeNodeIds = ref(new Set());
const collapsedEmployeeNodeIds = computed(() => {
const collapsed = new Set();
for (const node of flattenOrgNodes(props.nodes)) {
if ((node.employees || []).length && !expandedEmployeeNodeIds.value.has(node.node_id)) {
collapsed.add(node.node_id);
}
}
return collapsed;
});
function flattenOrgNodes(nodes) {
const result = [];
for (const node of nodes || []) {
result.push(node);
result.push(...flattenOrgNodes(node.children || []));
}
return result;
}
function isEmployeeExpanded(node) {
return expandedEmployeeNodeIds.value.has(node?.node_id);
}
function toggleEmployeeLeaves(node) {
if (!node?.node_id) {
return;
}
const next = new Set(expandedEmployeeNodeIds.value);
if (next.has(node.node_id)) {
next.delete(node.node_id);
} else {
next.add(node.node_id);
}
expandedEmployeeNodeIds.value = next;
emit("toggle-employee-leaves", node);
}
function shouldIgnoreCanvasPan(event) {
const target = event.target;
return Boolean(
target?.closest?.(".org-mind-map-node") ||
target?.closest?.(".org-mind-map-employee-toggle")
);
}
function startCanvasPan(event) {
if (event.button !== 0 || shouldIgnoreCanvasPan(event)) {
return;
}
const viewport = mindMapViewport.value;
if (!viewport) {
return;
}
event.preventDefault();
isCanvasPanning.value = true;
canvasPan.value = {
startX: event.clientX,
startY: event.clientY,
scrollLeft: viewport.scrollLeft,
scrollTop: viewport.scrollTop
};
window.addEventListener("mousemove", moveCanvasPan);
window.addEventListener("mouseup", endCanvasPan);
}
function moveCanvasPan(event) {
if (!isCanvasPanning.value) {
return;
}
const viewport = mindMapViewport.value;
if (!viewport) {
return;
}
const deltaX = event.clientX - canvasPan.value.startX;
const deltaY = event.clientY - canvasPan.value.startY;
event.preventDefault();
viewport.scrollLeft = canvasPan.value.scrollLeft - deltaX;
viewport.scrollTop = canvasPan.value.scrollTop - deltaY;
}
function endCanvasPan() {
isCanvasPanning.value = false;
window.removeEventListener("mousemove", moveCanvasPan);
window.removeEventListener("mouseup", endCanvasPan);
}
function estimateTextWidth(value, fontSize = 16) {
return Array.from(String(value || "")).reduce((width, char) => {
if (/[\u4e00-\u9fff\uff00-\uffef]/.test(char)) {
return width + fontSize;
}
if (/\s/.test(char)) {
return width + fontSize * 0.35;
}
if (/[A-Z0-9]/.test(char)) {
return width + fontSize * 0.62;
}
return width + fontSize * 0.55;
}, 0);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function fitTextToWidth(value, maxWidth, fontSize = 16) {
const text = String(value || "");
if (estimateTextWidth(text, fontSize) <= maxWidth) {
return text;
}
let result = "";
for (const char of Array.from(text)) {
const next = `${result}${char}`;
if (estimateTextWidth(`${next}...`, fontSize) > maxWidth) {
return `${result}...`;
}
result = next;
}
return result;
}
function computeNodeWidth({ title, subtitle, hasEmployeeToggle }) {
const toggleReserve = hasEmployeeToggle ? TOGGLE_RESERVE_WIDTH : 0;
const titleWidth = estimateTextWidth(title, 16) + TEXT_PADDING_X + toggleReserve;
const subtitleWidth = estimateTextWidth(subtitle, 13) + TEXT_PADDING_X;
return clamp(Math.ceil(Math.max(titleWidth, subtitleWidth)), NODE_MIN_WIDTH, NODE_MAX_WIDTH);
}
function toVisualChildren(node) {
const orgChildren = Array.isArray(node.children) ? node.children : [];
const shouldShowEmployees = Boolean(node.node_id) && !collapsedEmployeeNodeIds.value.has(node.node_id);
const employeeLeaves = shouldShowEmployees && Array.isArray(node.employees)
? node.employees.map((employee) => ({
...employee,
parent_node_id: node.node_id,
parent_node_label: node.node_label,
node_type: "EMPLOYEE",
node_label: employee.employee_name,
children: [],
employees: []
}))
: [];
return [...orgChildren, ...employeeLeaves];
}
function normalizeNode(node, index, parentKey = "root") {
const isEmployee = node.node_type === "EMPLOYEE" || Boolean(node.employee_id && !node.node_id);
const id = node.node_id ?? node.employee_id ?? `${parentKey}-${index}`;
const key = `${isEmployee ? "employee" : "org"}-${id}`;
const managerSubtitle = Array.isArray(node.manager_names) && node.manager_names.length
? node.manager_names.join("、")
: node.manager_name || node.subtitle || "未设置主管";
const title = node.node_label || node.employee_name || node.title || "未命名节点";
const subtitle = isEmployee ? node.mobile || "未设置电话" : managerSubtitle;
const employeeCount = Array.isArray(node.employees) ? node.employees.length : 0;
const width = computeNodeWidth({
title,
subtitle,
hasEmployeeToggle: !isEmployee && employeeCount > 0
});
return {
raw: node,
key,
isEmployee,
width,
employee_count: employeeCount,
title,
subtitle,
displayTitle: fitTextToWidth(title, width - TEXT_PADDING_X - (!isEmployee && employeeCount ? TOGGLE_RESERVE_WIDTH : 0), 16),
displaySubtitle: fitTextToWidth(subtitle, width - TEXT_PADDING_X, 13),
children: toVisualChildren(node).map((child, childIndex) => normalizeNode(child, childIndex, key))
};
}
function normalizeNodes(nodes) {
return nodes.map((node, index) => normalizeNode(node, index));
}
function measureSubtree(node) {
if (!node.children.length) {
node.subtreeSpan = 1;
return node.subtreeSpan;
}
node.subtreeSpan = node.children.reduce((total, child) => total + measureSubtree(child), 0);
return node.subtreeSpan;
}
function segmentKey(segment) {
const x1 = Math.round(segment.x1 * 100) / 100;
const y1 = Math.round(segment.y1 * 100) / 100;
const x2 = Math.round(segment.x2 * 100) / 100;
const y2 = Math.round(segment.y2 * 100) / 100;
const start = `${Math.min(x1, x2)},${Math.min(y1, y2)}`;
const end = `${Math.max(x1, x2)},${Math.max(y1, y2)}`;
return `${start}-${end}`;
}
function connectorSegments(parent, child, laneX, trunkFromY, trunkToY) {
const parentEdgeX = parent.x + parent.width;
const parentCenterY = parent.y + NODE_HEIGHT / 2;
const childEdgeX = child.x;
const childCenterY = child.y + NODE_HEIGHT / 2;
// Orthogonal connector logic: edge stems stay outside cards, trunk stays in the column gutter.
return [
{ x1: parentEdgeX, y1: parentCenterY, x2: laneX, y2: parentCenterY },
{ x1: laneX, y1: trunkFromY, x2: laneX, y2: trunkToY },
{ x1: laneX, y1: childCenterY, x2: childEdgeX, y2: childCenterY }
];
}
function toConnector(segment) {
return {
key: segmentKey(segment),
fromX: segment.x1,
fromY: segment.y1,
midX: segment.x2,
toY: segment.y2,
toX: segment.x2
};
}
function layoutTree(rootNodes) {
const nodes = [];
const connectors = [];
const connectorMap = new Map();
let nextLeafRow = 0;
let maxDepth = 0;
function placeNode(node, depth) {
maxDepth = Math.max(maxDepth, depth);
if (!node.children.length) {
const row = nextLeafRow;
nextLeafRow += 1;
const layoutNode = {
...node,
depth,
row,
x: CANVAS_PADDING + depth * COLUMN_WIDTH,
y: CANVAS_PADDING + row * ROW_HEIGHT
};
nodes.push(layoutNode);
return layoutNode;
}
const childNodes = node.children.map((child) => placeNode(child, depth + 1));
const firstChild = childNodes[0];
const lastChild = childNodes[childNodes.length - 1];
const row = (firstChild.row + lastChild.row) / 2;
const layoutNode = {
...node,
depth,
row,
x: CANVAS_PADDING + depth * COLUMN_WIDTH,
y: CANVAS_PADDING + row * ROW_HEIGHT
};
nodes.push(layoutNode);
const laneX = layoutNode.x + layoutNode.width + COLUMN_GAP / 2;
const childCenterYs = childNodes.map((child) => child.y + NODE_HEIGHT / 2);
const trunkFromY = Math.min(...childCenterYs);
const trunkToY = Math.max(...childCenterYs);
childNodes.forEach((child) => {
connectorSegments(layoutNode, child, laneX, trunkFromY, trunkToY).forEach((segment) => {
const key = segmentKey(segment);
if (!connectorMap.has(key)) {
connectorMap.set(key, true);
connectors.push(toConnector(segment));
}
});
});
return layoutNode;
}
rootNodes.forEach((rootNode, index) => {
measureSubtree(rootNode);
placeNode(rootNode, 0);
if (index < rootNodes.length - 1) {
nextLeafRow += ROOT_GROUP_GAP;
}
});
const maxRow = Math.max(0, ...nodes.map((node) => node.row));
return {
nodes,
connectors,
width: CANVAS_PADDING * 2 + NODE_MAX_WIDTH + maxDepth * COLUMN_WIDTH,
height: CANVAS_PADDING * 2 + NODE_HEIGHT + maxRow * ROW_HEIGHT
};
}
const layout = computed(() => layoutTree(normalizeNodes(props.nodes)));
const canvasWidth = computed(() => Math.max(760, layout.value.width));
const canvasHeight = computed(() => Math.max(460, layout.value.height));
const scaledCanvasStyle = computed(() => ({
width: `${canvasWidth.value * props.zoomScale}px`,
height: `${canvasHeight.value * props.zoomScale}px`
}));
function centerCanvasInViewport() {
const viewport = mindMapViewport.value;
if (!viewport) {
return;
}
viewport.scrollLeft = Math.max(0, (viewport.scrollWidth - viewport.clientWidth) / 2);
viewport.scrollTop = Math.max(0, (viewport.scrollHeight - viewport.clientHeight) / 2);
}
async function fitCanvasToViewport() {
await nextTick();
const viewport = mindMapViewport.value;
if (!viewport) {
return;
}
const availableWidth = Math.max(1, viewport.clientWidth - 24);
const availableHeight = Math.max(1, viewport.clientHeight - 24);
const widthScale = availableWidth / canvasWidth.value;
const heightScale = availableHeight / canvasHeight.value;
const nextScale = clamp(Math.min(widthScale, heightScale), FIT_SCALE_MIN, FIT_SCALE_MAX);
emit("fit-scale", Number(nextScale.toFixed(2)));
requestAnimationFrame(centerCanvasInViewport);
}
watch(
() => props.fitRequestKey,
() => {
if (props.fitRequestKey > 0) {
fitCanvasToViewport();
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
endCanvasPan();
});
defineExpose({
fitCanvasToViewport
});
</script>
<style scoped>
.org-mind-map {
width: 100%;
height: clamp(520px, 68vh, 760px);
min-height: 460px;
overflow: auto;
overscroll-behavior: contain;
border: 1px solid rgba(58, 82, 105, 0.18);
border-radius: 24px;
background:
linear-gradient(90deg, rgba(58, 82, 105, 0.06) 1px, transparent 1px) 0 0 / 28px 28px,
linear-gradient(0deg, rgba(58, 82, 105, 0.05) 1px, transparent 1px) 0 0 / 28px 28px,
radial-gradient(circle at 12% 12%, rgba(14, 165, 233, 0.12), transparent 30%),
linear-gradient(135deg, rgba(248, 250, 252, 0.98), rgba(232, 239, 247, 0.72));
background-size:
28px 28px,
28px 28px,
auto,
auto;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
cursor: grab;
user-select: none;
}
.org-mind-map--panning {
cursor: grabbing;
}
.org-mind-map-canvas {
display: block;
transition: width 0.18s ease, height 0.18s ease;
}
.org-mind-map-connector {
stroke: rgba(79, 115, 148, 0.7);
stroke-width: 2.25;
stroke-linecap: square;
stroke-linejoin: miter;
filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.76));
}
.org-mind-map-node {
cursor: pointer;
outline: none;
}
.org-mind-map-node:focus .org-mind-map-card,
.org-mind-map-node:hover .org-mind-map-card {
stroke: #0ea5e9;
stroke-width: 2;
filter: drop-shadow(0 18px 26px rgba(37, 99, 235, 0.18));
}
.org-mind-map-card {
fill: rgba(255, 255, 255, 0.96);
stroke: rgba(58, 82, 105, 0.2);
filter: drop-shadow(0 14px 22px rgba(15, 23, 42, 0.11));
}
.org-mind-map-node--employee .org-mind-map-card {
fill: rgba(240, 253, 250, 0.96);
stroke: rgba(20, 184, 166, 0.34);
}
.org-mind-map-title {
fill: #142033;
font-size: 16px;
font-weight: 800;
letter-spacing: 0.02em;
}
.org-mind-map-subtitle,
.org-mind-map-employee-count {
fill: #647587;
font-size: 13px;
}
.org-mind-map-node--employee .org-mind-map-title {
fill: #0f766e;
}
.org-mind-map-employee-toggle {
cursor: pointer;
}
.org-mind-map-employee-toggle circle {
fill: #e0f2fe;
stroke: rgba(14, 165, 233, 0.38);
}
.org-mind-map-employee-toggle text {
fill: #0369a1;
font-size: 18px;
font-weight: 900;
pointer-events: none;
}
</style>
@@ -0,0 +1,865 @@
<template>
<section class="org-permission-tree-card">
<aside class="org-tree-panel" aria-label="组织树">
<div class="panel-head">
<div>
<p class="eyebrow">组织架构</p>
<h3>组织树</h3>
</div>
<span class="status-pill">{{ organizationCount }} 个组织节点</span>
</div>
<label class="org-tree-search">
<span aria-hidden="true">⌕</span>
<input v-model.trim="treeSearchKeyword" type="search" placeholder="请输入组织名称" />
</label>
<div v-if="displayedOrganizationNodes.length" class="org-tree-list">
<OrgTreeBranch
v-for="node in displayedOrganizationNodes"
:key="nodeKey(node)"
:node="node"
:level="0"
:selected-id="effectiveSelectedNodeId"
:expanded-ids="expandedNodeIds"
@toggle="toggleNode"
@select="selectNode"
@open-menu="openNodeMenu"
/>
</div>
<p v-else class="empty-state">{{ treeSearchKeyword ? "没有匹配的组织节点" : "暂无组织节点" }}</p>
</aside>
<section class="employee-panel" aria-label="当前组织人员">
<div class="panel-head employee-head">
<div>
<p class="eyebrow">组织人员</p>
<h3>{{ selectedNodeLabel }}</h3>
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
</div>
<button
class="primary-button"
type="button"
:disabled="!canAddEmployee"
@click="addEmployee"
>
新增人员
</button>
</div>
<label class="employee-search-field">
<span>搜索</span>
<input v-model="searchKeyword" type="search" placeholder="搜索人员姓名或手机号" />
</label>
<div class="table-wrap">
<table class="data-table compact-table org-employee-table">
<thead>
<tr>
<th>姓名</th>
<th>电话号码 / 账号</th>
<th>岗位</th>
<th>角色</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="employee in paginatedEmployees" :key="employeeKey(employee)">
<td>{{ employeeName(employee) }}</td>
<td>{{ employeePhone(employee) }}</td>
<td>{{ employeePosition(employee) }}</td>
<td>{{ employeeRoles(employee) }}</td>
<td>
<StatusBadge :value="employee?.status" :text="employeeStatus(employee)" />
</td>
<td class="action-row">
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">授权</button>
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除</button>
</td>
</tr>
<tr v-if="!filteredEmployees.length">
<td colspan="6" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar v-model:page="employeePage" v-model:page-size="employeePageSize" :total="filteredEmployees.length" />
</section>
</section>
</template>
<script setup>
import { computed, defineComponent, h, ref, watch } from "vue";
import PaginationBar from "./PaginationBar.vue";
import StatusBadge from "./StatusBadge.vue";
import { usePagination } from "../utils/pagination";
const props = defineProps({
nodes: {
type: Array,
default: () => []
},
selectedNodeId: {
type: [Number, String],
default: ""
}
});
const emit = defineEmits([
"select-node",
"node-contextmenu",
"add-employee",
"authorize-employee",
"remove-employee"
]);
const OrgTreeBranch = defineComponent({
name: "OrgTreeBranch",
props: {
node: {
type: Object,
required: true
},
level: {
type: Number,
default: 0
},
selectedId: {
type: [Number, String],
default: ""
},
expandedIds: {
type: Object,
required: true
}
},
emits: ["toggle", "select", "open-menu"],
setup(branchProps, { emit: branchEmit }) {
const childOrganizations = computed(() => organizationChildren(branchProps.node));
const currentNodeId = computed(() => getNodeId(branchProps.node));
const hasChildren = computed(() => childOrganizations.value.length > 0);
const isExpanded = computed(() => branchProps.expandedIds.has(String(currentNodeId.value)));
const isSelected = computed(() => sameId(currentNodeId.value, branchProps.selectedId));
function selectCurrent() {
branchEmit("select", branchProps.node);
}
function toggleCurrent(event) {
event.stopPropagation();
branchEmit("toggle", branchProps.node);
}
function openMenu(event) {
event.preventDefault();
event.stopPropagation();
branchEmit("open-menu", {
node: branchProps.node,
x: event.clientX,
y: event.clientY
});
}
return () =>
h("div", { class: "org-tree-branch" }, [
h(
"button",
{
type: "button",
class: ["org-tree-node", { active: isSelected.value }],
style: { "--tree-level": branchProps.level },
onClick: selectCurrent,
onContextmenu: openMenu
},
[
h(
"span",
{
class: ["tree-toggle", { hidden: !hasChildren.value }],
onClick: toggleCurrent
},
hasChildren.value ? (isExpanded.value ? "⌄" : "›") : ""
),
h("span", { class: ["tree-node-icon", `tree-node-icon-${String(branchProps.node?.node_type || "ORG").toLowerCase()}`] }, nodeTypeShortLabel(branchProps.node?.node_type)),
h("span", { class: "tree-node-main" }, [
h("strong", null, nodeLabel(branchProps.node)),
h("small", null, `${nodeTypeLabel(branchProps.node?.node_type)} · ${employeeCount(branchProps.node)} 人`)
])
]
),
hasChildren.value && isExpanded.value
? h(
"div",
{ class: "org-tree-children" },
childOrganizations.value.map((child) =>
h(OrgTreeBranch, {
key: nodeKey(child),
node: child,
level: branchProps.level + 1,
selectedId: branchProps.selectedId,
expandedIds: branchProps.expandedIds,
onToggle: (node) => branchEmit("toggle", node),
onSelect: (node) => branchEmit("select", node),
onOpenMenu: (payload) => branchEmit("open-menu", payload)
})
)
)
: null
]);
}
});
const searchKeyword = ref("");
const treeSearchKeyword = ref("");
const expandedNodeIds = ref(new Set());
const organizationNodes = computed(() => (Array.isArray(props.nodes) ? props.nodes : []).filter(isOrganizationNode));
const displayedOrganizationNodes = computed(() => filterOrgTreeByKeyword(organizationNodes.value, treeSearchKeyword.value));
const flattenedOrganizations = computed(() => flattenOrganizations(organizationNodes.value));
const organizationCount = computed(() => flattenedOrganizations.value.length);
const firstRootNode = computed(() => organizationNodes.value[0] || null);
const requestedNode = computed(() => findNodeById(organizationNodes.value, props.selectedNodeId));
const selectedNode = computed(() => requestedNode.value || firstRootNode.value);
const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? "");
const selectedNodeLabel = computed(() => selectedNode.value ? nodeLabel(selectedNode.value) : "未选择组织");
const selectedEmployees = computed(() => Array.isArray(selectedNode.value?.employees) ? selectedNode.value.employees : []);
const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type));
const filteredEmployees = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) {
return selectedEmployees.value;
}
return selectedEmployees.value.filter((employee) =>
[employeeName(employee), employeePhone(employee), employee.employee_code, employee.username]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(keyword)
);
});
const {
page: employeePage,
pageSize: employeePageSize,
rows: paginatedEmployees
} = usePagination(filteredEmployees);
watch(
organizationNodes,
(nodes) => {
expandedNodeIds.value = new Set(flattenOrganizations(nodes).map((node) => String(getNodeId(node))));
},
{ immediate: true }
);
watch(effectiveSelectedNodeId, () => {
searchKeyword.value = "";
});
function organizationChildren(node) {
return (Array.isArray(node?.children) ? node.children : []).filter(isOrganizationNode);
}
function filterOrgTreeByKeyword(nodes, keyword) {
const normalizedKeyword = String(keyword || "").trim().toLowerCase();
if (!normalizedKeyword) {
return nodes;
}
return (nodes || [])
.map((node) => {
const children = filterOrgTreeByKeyword(organizationChildren(node), normalizedKeyword);
const text = [nodeLabel(node), nodeTypeLabel(node?.node_type), node?.dept_code, node?.manager_name, ...(node?.manager_names || [])]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (text.includes(normalizedKeyword) || children.length) {
return {
...node,
children
};
}
return null;
})
.filter(Boolean);
}
function isOrganizationNode(node) {
return Boolean(node) && node.node_type !== "EMPLOYEE" && !node.employee_id;
}
function flattenOrganizations(nodes) {
return (nodes || []).flatMap((node) => [node, ...flattenOrganizations(organizationChildren(node))]);
}
function findNodeById(nodes, id) {
if (id === "" || id === null || id === undefined) {
return null;
}
for (const node of nodes || []) {
if (sameId(getNodeId(node), id)) {
return node;
}
const matchedChild = findNodeById(organizationChildren(node), id);
if (matchedChild) {
return matchedChild;
}
}
return null;
}
function sameId(left, right) {
return String(left ?? "") === String(right ?? "");
}
function getNodeId(node) {
return node?.node_id ?? node?.id ?? node?.value ?? "";
}
function nodeKey(node) {
return getNodeId(node) || nodeLabel(node);
}
function nodeLabel(node) {
return node?.node_label || node?.label || node?.name || "未命名组织";
}
function nodeTypeLabel(type) {
return {
COMPANY: "总公司",
BRANCH: "分公司",
DEPARTMENT: "部门",
GROUP: "小组"
}[type] || "组织";
}
function nodeTypeShortLabel(type) {
return {
COMPANY: "总",
BRANCH: "分",
DEPARTMENT: "部",
GROUP: "组"
}[type] || "组";
}
function employeeCount(node) {
const employeeKeys = new Set();
collectEmployeeKeys(node, employeeKeys);
return employeeKeys.size;
}
function collectEmployeeKeys(node, employeeKeys) {
if (!node) {
return;
}
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
employeeKeys.add(String(employeeKey(employee)));
}
for (const child of Array.isArray(node.children) ? node.children : []) {
if (isOrganizationNode(child)) {
collectEmployeeKeys(child, employeeKeys);
} else if (child?.employee_id) {
employeeKeys.add(String(employeeKey(child)));
}
}
}
function toggleNode(node) {
const targetId = String(getNodeId(node));
const next = new Set(expandedNodeIds.value);
if (next.has(targetId)) {
next.delete(targetId);
} else {
next.add(targetId);
}
expandedNodeIds.value = next;
}
function selectNode(node) {
emit("select-node", node);
}
function openNodeMenu(payload) {
emit("node-contextmenu", payload);
}
function employeeName(employee) {
return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员";
}
function employeePhone(employee) {
return employee?.mobile || employee?.phone || employee?.username || "未设置电话号码";
}
function employeePosition(employee) {
return employee?.job_title || employee?.position || employee?.post_name || employee?.title || "未设置岗位";
}
function employeeRoles(employee) {
if (Array.isArray(employee?.role_names) && employee.role_names.length) {
return employee.role_names.join("、");
}
if (Array.isArray(employee?.roles) && employee.roles.length) {
return employee.roles.map((role) => role.role_name || role.name || role.role_code || role).join("、");
}
return employee?.role_name || employee?.role || "未设置角色";
}
function employeeStatus(employee) {
if (employee?.status === "ACTIVE") {
return "启用";
}
if (employee?.status === "INACTIVE") {
return "停用";
}
return "未设置状态";
}
function employeeKey(employee) {
return employee?.employee_id || employee?.user_id || employee?.username || employeeName(employee);
}
function employeeWithParent(employee) {
return {
...employee,
parent_node_id: getNodeId(selectedNode.value),
parent_node_label: nodeLabel(selectedNode.value)
};
}
function addEmployee() {
if (canAddEmployee.value) {
emit("add-employee", selectedNode.value);
}
}
function authorizeEmployee(employee) {
emit("authorize-employee", employeeWithParent(employee));
}
function removeEmployee(employee) {
emit("remove-employee", employeeWithParent(employee));
}
</script>
<style scoped>
.org-permission-tree-card {
display: grid;
grid-template-columns: minmax(300px, 0.32fr) minmax(680px, 1fr);
gap: 16px;
width: 100%;
min-width: 1040px;
min-height: min(620px, calc(100vh - 270px));
}
.org-tree-panel,
.employee-panel {
border: 1px solid #dbe3ef;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
.org-tree-panel {
display: flex;
flex-direction: column;
min-height: 0;
padding: 14px 10px 14px 12px;
}
.employee-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 18px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.employee-head {
align-items: center;
}
.eyebrow {
margin: 0 0 6px;
color: #2563eb;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.panel-head h3 {
margin: 0;
color: #0f172a;
font-size: 18px;
}
.permission-note,
.empty-state {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
.status-pill {
display: inline-flex;
align-items: center;
flex-shrink: 0;
border-radius: 999px;
padding: 6px 10px;
background: #dbeafe;
color: #1d4ed8;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.org-tree-search {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 6px;
min-height: 36px;
margin-bottom: 12px;
padding: 0 10px;
border: 1px solid #d8e0eb;
border-radius: 6px;
background: #ffffff;
color: #8b97a8;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.org-tree-search:focus-within {
border-color: #409eff;
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.12);
}
.org-tree-search span {
display: inline-grid;
place-items: center;
color: #9aa7b8;
font-size: 17px;
line-height: 1;
}
.org-tree-search input {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: #303847;
font-size: 14px;
outline: none;
}
.org-tree-search input::placeholder {
color: #a8b1bf;
}
.org-tree-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-height: 0;
overflow-y: auto;
padding: 2px 2px 8px 0;
border-radius: 8px;
background: #ffffff;
}
.org-tree-branch {
display: flex;
flex-direction: column;
gap: 2px;
}
.org-tree-node {
display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr);
align-items: center;
gap: 6px;
width: 100%;
border: 1px solid transparent;
border-radius: 4px;
padding: 7px 8px 7px calc(6px + var(--tree-level, 0) * 22px);
background: transparent;
color: #4b5565;
text-align: left;
cursor: pointer;
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
}
.org-tree-node:hover,
.org-tree-node.active {
border-color: transparent;
background: #eaf4ff;
color: #1f6fbd;
box-shadow: inset 3px 0 0 #409eff;
}
.tree-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
padding: 0;
background: transparent;
color: #9098a8;
font-size: 18px;
font-weight: 800;
line-height: 1;
}
.tree-toggle.hidden {
visibility: hidden;
}
.tree-node-icon {
display: inline-grid;
place-items: center;
width: 23px;
height: 23px;
border-radius: 5px;
background: #f1f5f9;
color: #607086;
font-size: 12px;
font-weight: 850;
}
.org-tree-node.active .tree-node-icon,
.org-tree-node:hover .tree-node-icon {
background: #409eff;
color: #ffffff;
}
.tree-node-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.tree-node-main strong,
.tree-node-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node-main strong {
font-size: 14px;
font-weight: 700;
}
.tree-node-main small {
color: #8b97a8;
font-size: 11px;
}
.org-tree-children {
display: flex;
flex-direction: column;
gap: 2px;
}
.employee-search-field {
display: grid;
gap: 6px;
margin-bottom: 12px;
}
.employee-search-field span {
color: #475569;
font-size: 13px;
font-weight: 800;
}
.employee-search-field input {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 16px;
padding: 11px 14px;
background: #ffffff;
color: #0f172a;
font-size: 14px;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.employee-search-field input:focus {
border-color: rgba(37, 99, 235, 0.62);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
}
.primary-button,
.ghost-button {
border: 0;
border-radius: 999px;
padding: 10px 16px;
font-weight: 700;
cursor: pointer;
}
.primary-button {
background: #2563eb;
color: #ffffff;
}
.primary-button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.ghost-button {
background: #eff6ff;
color: #1d4ed8;
}
.ghost-button.danger {
background: #fef2f2;
color: #b91c1c;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.empty-row {
color: #64748b;
text-align: center;
}
.org-employee-table {
min-width: 0;
table-layout: fixed;
}
:deep(.org-tree-branch) {
display: flex;
flex-direction: column;
gap: 2px;
}
:deep(.org-tree-node) {
display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr);
align-items: center;
gap: 6px;
width: 100%;
border: 1px solid transparent;
border-radius: 4px;
padding: 7px 8px 7px calc(6px + var(--tree-level, 0) * 22px);
background: transparent;
color: #4b5565;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
}
:deep(.org-tree-node:hover),
:deep(.org-tree-node.active) {
border-color: transparent;
background: #eaf4ff;
color: #1f6fbd;
box-shadow: inset 3px 0 0 #409eff;
}
:deep(.tree-toggle) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
padding: 0;
background: transparent;
color: #9098a8;
font-size: 18px;
font-weight: 800;
line-height: 1;
}
:deep(.tree-toggle.hidden) {
visibility: hidden;
}
:deep(.tree-node-icon) {
display: inline-grid;
place-items: center;
width: 23px;
height: 23px;
border-radius: 5px;
background: #f1f5f9;
color: #607086;
font-size: 12px;
font-weight: 850;
}
:deep(.org-tree-node.active .tree-node-icon),
:deep(.org-tree-node:hover .tree-node-icon) {
background: #409eff;
color: #ffffff;
}
:deep(.tree-node-main) {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
:deep(.tree-node-main strong),
:deep(.tree-node-main small) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.tree-node-main strong) {
font-size: 14px;
font-weight: 700;
}
:deep(.tree-node-main small) {
color: #8b97a8;
font-size: 11px;
}
:deep(.org-tree-children) {
display: flex;
flex-direction: column;
gap: 2px;
}
@media (max-width: 980px) {
.org-permission-tree-card {
grid-template-columns: minmax(300px, 0.32fr) minmax(680px, 1fr);
}
.org-tree-panel {
min-height: 320px;
}
.panel-head,
.employee-head {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div class="pagination-bar">
<div class="pagination-summary">显示第 {{ startItem }} 条 - 第 {{ endItem }} 条,共 {{ total }} 条</div>
<div class="pagination-actions">
<span class="pagination-total-pages">总页数:{{ pageCount }}</span>
<button class="pagination-arrow" type="button" :disabled="currentPage <= 1" aria-label="上一页" @click="goPage(currentPage - 1)">
‹
</button>
<template v-for="item in visiblePages" :key="`${item.type}-${item.value}`">
<span v-if="item.type === 'ellipsis'" class="pagination-ellipsis">...</span>
<button
v-else
class="pagination-page"
:class="{ 'pagination-page-active': item.value === currentPage }"
type="button"
@click="goPage(item.value)"
>
{{ item.value }}
</button>
</template>
<button
class="pagination-arrow"
type="button"
:disabled="currentPage >= pageCount"
aria-label="下一页"
@click="goPage(currentPage + 1)"
>
›
</button>
<label class="pagination-size-field">
<span>每页条数:</span>
<select :value="pageSize" @change="changePageSize">
<option v-for="option in pageSizeOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { PAGE_SIZE_OPTIONS } from "../utils/pagination";
const props = defineProps({
page: {
type: Number,
required: true
},
pageSize: {
type: Number,
default: 10
},
total: {
type: Number,
required: true
},
pageSizeOptions: {
type: Array,
default: () => PAGE_SIZE_OPTIONS
}
});
const emit = defineEmits(["update:page", "update:pageSize"]);
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
const currentPage = computed(() => Math.min(Math.max(1, props.page), pageCount.value));
const startItem = computed(() => (props.total > 0 ? (currentPage.value - 1) * props.pageSize + 1 : 0));
const endItem = computed(() => Math.min(props.total, currentPage.value * props.pageSize));
const visiblePages = computed(() => {
const count = pageCount.value;
const page = currentPage.value;
const pages = new Set([1, count, page, page - 1, page + 1]);
if (page <= 3) {
pages.add(2);
pages.add(3);
pages.add(4);
}
if (page >= count - 2) {
pages.add(count - 1);
pages.add(count - 2);
pages.add(count - 3);
}
const normalizedPages = Array.from(pages)
.filter((item) => item >= 1 && item <= count)
.sort((left, right) => left - right);
return normalizedPages.reduce((items, value, index) => {
const previous = normalizedPages[index - 1];
if (index > 0 && value - previous > 1) {
items.push({ type: "ellipsis", value: `${previous}-${value}` });
}
items.push({ type: "page", value });
return items;
}, []);
});
function changePageSize(event) {
emit("update:pageSize", Number(event.target.value || props.pageSize));
emit("update:page", 1);
}
function goPage(nextPage) {
const normalizedPage = Math.min(Math.max(1, nextPage), pageCount.value);
if (normalizedPage !== props.page) {
emit("update:page", normalizedPage);
}
}
</script>
@@ -0,0 +1,122 @@
<template>
<section class="page-grid">
<section class="panel workspace-panel">
<div class="panel-header">
<div>
<p class="eyebrow">流程工作台</p>
<h3>{{ title }}</h3>
</div>
<span class="panel-tag">{{ panelTag }}</span>
</div>
<div v-if="visibleSections.length" class="workspace-tabbar">
<button
v-for="section in visibleSections"
:key="section.key"
class="workspace-tab"
:class="{ 'workspace-tab-active': section.key === activeSection?.key }"
type="button"
:title="section.desc"
@click="selectSection(section.key)"
>
<span class="workspace-tab-step">{{ section.step }}</span>
<div>
<strong>{{ section.label }}</strong>
</div>
</button>
</div>
<div v-if="activeSection" class="workspace-section-head">
<div>
<p class="eyebrow">当前环节</p>
<span class="title-with-help" :title="activeSection.desc">
<h3>{{ activeSection.label }}</h3>
</span>
</div>
<span class="panel-tag">{{ activeSection.step }}</span>
</div>
<KeepAlive v-if="activeSection">
<component :is="activeSection.component" :key="activeSection.key" />
</KeepAlive>
<div v-else class="feedback-banner error-banner">当前账号没有此流程工作台的访问权限。</div>
</section>
</section>
</template>
<script setup>
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { readSession } from "../services/api";
const props = defineProps({
eyebrow: {
type: String,
required: true
},
title: {
type: String,
required: true
},
description: {
type: String,
required: true
},
panelTag: {
type: String,
default: "流程融合"
},
stageCode: {
type: String,
required: true
},
sections: {
type: Array,
required: true
}
});
const route = useRoute();
const router = useRouter();
const permissionCodes = computed(() => readSession()?.permission_codes || []);
const visibleSections = computed(() =>
props.sections.filter((section) => {
if (section.permissionsAny?.length) {
return section.permissionsAny.some((permission) => permissionCodes.value.includes(permission));
}
return permissionCodes.value.includes(section.permission);
})
);
const activeSection = computed(() => {
const sectionKey = String(route.query.tab || "");
return visibleSections.value.find((section) => section.key === sectionKey) || visibleSections.value[0] || null;
});
function selectSection(sectionKey) {
router.replace({
name: route.name,
query: {
...route.query,
tab: sectionKey
}
});
}
watch(
() => [route.query.tab, visibleSections.value.map((section) => section.key).join("|")],
() => {
if (!visibleSections.value.length) {
return;
}
const currentTab = String(route.query.tab || "");
if (!visibleSections.value.some((section) => section.key === currentTab)) {
selectSection(visibleSections.value[0].key);
}
},
{ immediate: true }
);
</script>
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
<template>
<Teleport to="body">
<div v-if="open" class="smart-drawer-mask" @click.self="handleClose">
<aside
class="smart-drawer-shell"
:class="drawerClass"
role="dialog"
aria-modal="true"
:aria-labelledby="labelledById"
:aria-label="dialogLabel"
:aria-describedby="description ? descriptionId : undefined"
>
<header class="smart-drawer-header">
<slot name="header">
<div class="smart-drawer-heading">
<p v-if="eyebrow" class="smart-drawer-eyebrow">{{ eyebrow }}</p>
<h2 v-if="title" :id="titleId" class="smart-drawer-title">{{ title }}</h2>
<p v-if="description" :id="descriptionId" class="smart-drawer-description">{{ description }}</p>
</div>
</slot>
<div class="smart-drawer-actions">
<slot name="head-actions" />
<button class="smart-drawer-close" type="button" aria-label="关闭" title="关闭" @click="handleClose">
<AppIcon name="close" />
</button>
</div>
</header>
<section class="smart-drawer-body">
<slot v-if="$slots.default" />
<slot v-else name="body" />
</section>
<footer v-if="$slots.footer" class="smart-drawer-footer">
<slot name="footer" />
</footer>
</aside>
</div>
</Teleport>
</template>
<script setup>
import { computed, toRef, useId, useSlots } from "vue";
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
import AppIcon from "./AppIcon.vue";
const props = defineProps({
open: {
type: Boolean,
default: false
},
eyebrow: {
type: String,
default: ""
},
title: {
type: String,
default: ""
},
description: {
type: String,
default: ""
},
size: {
type: String,
default: "lg",
validator: (value) => ["lg", "xl", "fullscreen"].includes(value)
}
});
const emit = defineEmits(["close"]);
const slots = useSlots();
const titleId = `smart-drawer-title-${useId()}`;
const descriptionId = `smart-drawer-description-${useId()}`;
const drawerClass = computed(() => ({
[`smart-drawer-${props.size}`]: true
}));
const labelledById = computed(() => (props.title && !slots.header ? titleId : undefined));
const dialogLabel = computed(() => (labelledById.value ? undefined : props.title || props.eyebrow || "业务抽屉"));
function handleClose() {
emit("close");
}
useBodyScrollLock(toRef(props, "open"));
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<span :class="['status-badge', `status-badge-${tone}`]">
<span class="status-badge-dot" aria-hidden="true"></span>
<span>{{ label }}</span>
</span>
</template>
<script setup>
import { computed } from "vue";
import { getStatusLabel, getStatusTone } from "../utils/statusToneMap";
const props = defineProps({
value: {
type: [String, Number, Boolean, null],
default: ""
},
text: {
type: String,
default: ""
}
});
const label = computed(() => props.text || getStatusLabel(props.value));
const tone = computed(() => getStatusTone(props.value || props.text) || "neutral");
</script>
@@ -0,0 +1,436 @@
<template>
<div ref="rootRef" class="stock-lot-select">
<div class="stock-lot-control" :class="{ 'stock-lot-control-open': dropdownOpen }" @click="focusSearch">
<span
v-for="lot in selectedLots"
:key="lot.lot_id"
class="stock-lot-pill"
:title="lotLabel(lot)"
>
<strong>{{ lot.lot_no }}</strong>
<small>{{ formatWeight(displayWeight(lot)) }}</small>
<button type="button" aria-label="移除库存批次号" @click.stop="removeLot(lot)">×</button>
</span>
<input
ref="searchInputRef"
v-model.trim="searchText"
class="stock-lot-search"
type="text"
:placeholder="selectedLots.length ? '' : placeholder"
@focus="dropdownOpen = true"
@keydown.down.prevent="moveActive(1)"
@keydown.up.prevent="moveActive(-1)"
@keydown.enter.prevent="chooseActive"
@keydown.esc.prevent="dropdownOpen = false"
@keydown.backspace="removeLastWhenEmpty"
/>
</div>
<div v-if="dropdownOpen" class="stock-lot-dropdown">
<button
v-for="(lot, index) in filteredOptions"
:key="lot.lot_id"
class="stock-lot-option"
:class="{
'stock-lot-option-active': index === activeIndex,
'stock-lot-option-disabled': !canSelect(lot),
'stock-lot-option-selected': isSelected(lot)
}"
type="button"
:disabled="!canSelect(lot)"
@mouseenter="activeIndex = index"
@click="selectLot(lot)"
>
<span class="stock-lot-option-main">
<strong>{{ lot.lot_no }}</strong>
<small>{{ lot.item_code }} · {{ lot.item_name }}</small>
<small v-if="lot.source_stock_lot_no">废料库存批次号:{{ lot.source_stock_lot_no }}</small>
</span>
<span class="stock-lot-option-meta">
<span>{{ optionWeightLabel }} {{ formatWeight(displayWeight(lot)) }}</span>
<span>¥{{ formatAmount(lot.unit_cost) }}/kg</span>
<em v-if="lot.disabled_reason">{{ lot.disabled_reason }}</em>
<em v-else-if="isSelected(lot)">已选择</em>
</span>
</button>
<div v-if="!filteredOptions.length" class="stock-lot-empty">没有匹配的库存批次号</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { formatAmount, formatWeight } from "../utils/formatters";
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
options: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: "搜索并选择库存批次号"
},
allowUnavailable: {
type: Boolean,
default: false
},
maxSelections: {
type: Number,
default: 0
},
displayWeightField: {
type: String,
default: "remaining_weight_kg"
},
optionWeightLabel: {
type: String,
default: "剩余"
}
});
const emit = defineEmits(["update:modelValue"]);
const rootRef = ref(null);
const searchInputRef = ref(null);
const searchText = ref("");
const dropdownOpen = ref(false);
const activeIndex = ref(0);
const selectedLots = computed(() => props.modelValue || []);
const selectedLotIds = computed(() => new Set(selectedLots.value.map((lot) => Number(lot.lot_id))));
const sortedOptions = computed(() =>
[...(props.options || [])].sort((left, right) => {
const leftUsable = left.is_available_for_issue ? 1 : 0;
const rightUsable = right.is_available_for_issue ? 1 : 0;
if (leftUsable !== rightUsable) {
return rightUsable - leftUsable;
}
return String(left.lot_no || "").localeCompare(String(right.lot_no || ""), "zh-CN", { numeric: true });
})
);
const filteredOptions = computed(() => {
const keywords = normalizeText(searchText.value).split(/\s+/).filter(Boolean);
if (!keywords.length) {
return sortedOptions.value;
}
return sortedOptions.value.filter((lot) => {
const body = normalizeText(`${lot.lot_no} ${lot.source_stock_lot_no} ${lot.source_material_sub_batch_no} ${lot.item_code} ${lot.item_name} ${lot.disabled_reason}`);
return keywords.every((keyword) => body.includes(keyword));
});
});
watch([filteredOptions, selectedLotIds], ([rows]) => {
resetActiveIndexToFirstSelectable(rows);
});
watch(dropdownOpen, (isOpen) => {
if (isOpen) {
resetActiveIndexToFirstSelectable();
}
});
function resetActiveIndexToFirstSelectable(rows = filteredOptions.value) {
if (!rows.length) {
activeIndex.value = 0;
return;
}
const firstSelectableIndex = rows.findIndex((lot) => canSelect(lot));
activeIndex.value = firstSelectableIndex >= 0 ? firstSelectableIndex : 0;
}
function syncActiveIndexToSelectable(rows = filteredOptions.value) {
if (!rows.length) {
activeIndex.value = 0;
return;
}
const clampedIndex = Math.min(Math.max(activeIndex.value, 0), rows.length - 1);
if (canSelect(rows[clampedIndex])) {
activeIndex.value = clampedIndex;
return;
}
const firstSelectableIndex = rows.findIndex((lot) => canSelect(lot));
activeIndex.value = firstSelectableIndex >= 0 ? firstSelectableIndex : clampedIndex;
}
function normalizeText(value) {
return String(value || "").trim().toLowerCase();
}
function lotLabel(lot) {
const stockLotSuffix = lot.source_stock_lot_no ? ` · 废料库存批次号:${lot.source_stock_lot_no}` : "";
return `${lot.lot_no} · ${lot.item_code} · ${lot.item_name}${stockLotSuffix}`;
}
function toFiniteNumber(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : 0;
}
function displayWeight(lot) {
const configuredValue = lot?.[props.displayWeightField];
if (configuredValue !== undefined && configuredValue !== null && configuredValue !== "") {
return toFiniteNumber(configuredValue);
}
return toFiniteNumber(lot?.remaining_weight_kg);
}
function normalizeSelectedIssuedWeight(lot) {
return toFiniteNumber(lot?.issued_weight_kg ?? lot?.remaining_weight_kg ?? 0);
}
function canSelect(lot) {
if (!lot || (!props.allowUnavailable && !lot.is_available_for_issue) || isSelected(lot)) {
return false;
}
return !props.maxSelections || selectedLots.value.length < props.maxSelections;
}
function isSelected(lot) {
return selectedLotIds.value.has(Number(lot?.lot_id));
}
function focusSearch() {
dropdownOpen.value = true;
searchInputRef.value?.focus();
}
function selectLot(lot) {
if (!canSelect(lot)) {
return;
}
const nextSelectedLots = props.maxSelections === 1 ? [] : selectedLots.value;
emit("update:modelValue", [
...nextSelectedLots,
{
...lot,
issued_weight_kg: normalizeSelectedIssuedWeight(lot)
}
]);
searchText.value = "";
dropdownOpen.value = false;
activeIndex.value = 0;
}
function removeLot(lot) {
emit("update:modelValue", selectedLots.value.filter((item) => Number(item.lot_id) !== Number(lot.lot_id)));
}
function removeLastWhenEmpty() {
if (searchText.value || !selectedLots.value.length) {
return;
}
emit("update:modelValue", selectedLots.value.slice(0, -1));
}
function moveActive(delta) {
const rows = filteredOptions.value;
if (!rows.length) {
return;
}
let nextIndex = activeIndex.value;
for (let step = 0; step < rows.length; step += 1) {
nextIndex = (nextIndex + delta + rows.length) % rows.length;
if (canSelect(rows[nextIndex])) {
activeIndex.value = nextIndex;
return;
}
}
}
function chooseActive() {
const row = filteredOptions.value[activeIndex.value];
if (row) {
selectLot(row);
}
}
function handlePointerDown(event) {
if (!rootRef.value?.contains(event.target)) {
dropdownOpen.value = false;
}
}
onMounted(() => {
document.addEventListener("pointerdown", handlePointerDown, true);
});
onUnmounted(() => {
document.removeEventListener("pointerdown", handlePointerDown, true);
});
</script>
<style scoped>
.stock-lot-select {
position: relative;
}
.stock-lot-control {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-height: 46px;
padding: 6px 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(31, 45, 61, 0.04);
cursor: text;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.stock-lot-control-open {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.12);
}
.stock-lot-pill {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 4px 8px;
border: 1px solid #d9ecff;
border-radius: 4px;
background: #ecf5ff;
color: #409eff;
font-size: 12px;
}
.stock-lot-pill strong {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stock-lot-pill small {
color: #67a8e8;
}
.stock-lot-pill button {
border: 0;
background: transparent;
color: #409eff;
cursor: pointer;
font: inherit;
font-size: 16px;
line-height: 1;
padding: 0;
}
.stock-lot-search {
flex: 1 1 220px;
min-width: 160px;
border: 0;
outline: none;
background: transparent;
color: #606266;
font: inherit;
padding: 6px 2px;
}
.stock-lot-search::placeholder {
color: #c0c4cc;
}
.stock-lot-dropdown {
position: absolute;
z-index: 20;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 320px;
overflow: auto;
border: 1px solid #e4e7ed;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.16);
}
.stock-lot-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
width: 100%;
min-height: 54px;
padding: 10px 12px;
border: 0;
border-bottom: 1px solid #f0f2f5;
background: #ffffff;
color: #606266;
cursor: pointer;
font: inherit;
text-align: left;
}
.stock-lot-option:hover,
.stock-lot-option-active {
background: #f5f7fa;
color: #409eff;
}
.stock-lot-option-selected {
background: #ecf5ff;
}
.stock-lot-option-disabled {
cursor: not-allowed;
opacity: 0.48;
}
.stock-lot-option-main,
.stock-lot-option-meta {
display: grid;
gap: 4px;
}
.stock-lot-option-main {
min-width: 0;
}
.stock-lot-option-main strong,
.stock-lot-option-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stock-lot-option-main small,
.stock-lot-option-meta {
color: #909399;
font-size: 12px;
}
.stock-lot-option-meta {
justify-items: end;
white-space: nowrap;
}
.stock-lot-option-meta em {
color: #f56c6c;
font-style: normal;
}
.stock-lot-empty {
color: #909399;
padding: 18px 12px;
text-align: center;
}
@media (max-width: 640px) {
.stock-lot-option {
grid-template-columns: 1fr;
}
.stock-lot-option-meta {
justify-items: start;
}
}
</style>
+550
View File
@@ -0,0 +1,550 @@
<template>
<Teleport to="body">
<div v-if="open" class="workflow-drawer-mask stocktake-mask" @click.self="$emit('close')">
<section class="stocktake-shell">
<header class="stocktake-head">
<div>
<p class="eyebrow">嘉恒仓库 · 盘库</p>
<h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3>
</div>
<button class="ghost-button" type="button" @click="$emit('close')">关闭</button>
</header>
<nav class="stocktake-steps stocktake-progress-rail" aria-label="盘库流程">
<span
v-for="(step, index) in flowSteps"
:key="step.key"
class="stocktake-step"
:class="{
active: activePanel === step.key,
done: index < currentFlowIndex
}"
>
<span>{{ step.no }}</span>
<strong>{{ step.label }}</strong>
</span>
</nav>
<div class="stocktake-notice-row" :class="{ empty: !dialogMessage && !dialogError }" aria-live="polite">
<div v-if="dialogMessage" class="feedback-banner">{{ dialogMessage }}</div>
<div v-if="dialogError" class="feedback-banner error-banner">{{ dialogError }}</div>
</div>
<div class="stocktake-main">
<div class="stocktake-flow-column">
<section v-if="activePanel === 'export'" class="stocktake-panel stocktake-single-panel">
<div class="stocktake-action-card stocktake-action-card-wide">
<span class="stocktake-stage-tag">当前仓库:{{ currentWarehouseName }}</span>
<strong>01 确认盘库并导出仓库清单</strong>
<p>点击后系统会锁定当前仓库,并导出本次盘库 Excel 清单。盘库结束前该仓库不能出入库。</p>
<div class="stocktake-inline-actions">
<button class="primary-button" type="button" :disabled="busy || !currentWarehouseId" @click="startAndExportStocktake">
{{ busy ? "处理中..." : "确认盘库并导出仓库清单" }}
</button>
<button v-if="activeStocktake?.status === 'LOCKED'" class="ghost-button" type="button" :disabled="busy" @click="exportStocktake">
重新导出清单
</button>
</div>
</div>
</section>
<section v-if="activePanel === 'import'" class="stocktake-panel stocktake-panel-import">
<div class="stocktake-action-card stocktake-action-card-wide">
<span class="stocktake-stage-tag">当前盘库单:{{ activeStocktake?.stocktake_no || "-" }}</span>
<strong>02 导入盘点后的 Excel</strong>
<p>只允许导入当前仓库、本次盘库单导出的清单。导入成功后会立刻弹出差异对比。</p>
<label class="import-dropzone">
<input ref="fileInputRef" class="hidden-file-input" type="file" accept=".xlsx,.xlsm" :disabled="busy" @change="importStocktake" />
<strong>{{ busy ? "导入中..." : "选择盘点后的 Excel" }}</strong>
<span>如果差异确认时取消,可以修改 Excel 后重新导入覆盖旧差异。</span>
</label>
</div>
</section>
</div>
<section class="stocktake-panel stocktake-panel-history">
<div class="table-wrap">
<table class="data-table compact-table">
<thead>
<tr>
<th>盘库单号</th>
<th>仓库</th>
<th>状态</th>
<th>开始时间</th>
<th>确认时间</th>
<th>盘盈</th>
<th>盘亏</th>
<th>新增批次</th>
<th>缺失批次</th>
<th>影响金额</th>
</tr>
</thead>
<tbody>
<tr v-for="row in paginatedHistoryRows" :key="row.id">
<td>{{ row.stocktake_no }}</td>
<td>{{ stocktakeWarehouseNames(row) }}</td>
<td>{{ formatStocktakeStatusLabel(row.status) }}</td>
<td>{{ formatDateTime(row.started_at) }}</td>
<td>{{ formatDateTime(row.confirmed_at) }}</td>
<td>{{ stocktakeSummaryValue(row, "gain_count") }}</td>
<td>{{ stocktakeSummaryValue(row, "loss_count") }}</td>
<td>{{ stocktakeSummaryValue(row, "new_lot_count") }}</td>
<td>{{ stocktakeSummaryValue(row, "missing_count") }}</td>
<td>¥{{ formatAmount(stocktakeSummaryValue(row, "diff_amount")) }}</td>
</tr>
<tr v-if="!currentWarehouseHistoryRows.length">
<td colspan="10" class="empty-row">暂无盘库记录</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
v-model:page="historyPage"
v-model:page-size="historyPageSize"
:total="currentWarehouseHistoryRows.length"
:page-size-options="[6]"
/>
</section>
</div>
<div v-if="diffDialogOpen" class="stocktake-diff-modal-mask" @click.self="closeDiffDialog">
<section class="stocktake-diff-modal">
<header class="stocktake-diff-modal-head">
<div>
<p class="eyebrow">盘库差异对比</p>
<h3>{{ activeStocktake?.stocktake_no }} · {{ currentWarehouseName }}</h3>
</div>
<button class="ghost-button" type="button" :disabled="busy" @click="closeDiffDialog">取消,重新导入</button>
</header>
<div class="stocktake-summary-grid">
<article v-for="card in summaryCards" :key="card.key" class="stocktake-summary-card" :class="card.tone">
<span>{{ card.label }}</span>
<strong>{{ card.value }}</strong>
</article>
</div>
<div v-if="isCleanEmptyStocktake" class="stocktake-empty-state">
<strong>空库盘库清单已确认到差异页</strong>
<span>系统库存快照和盘点清单都没有明细。确认盘库后只会解锁仓库,不会生成库存调整流水。</span>
</div>
<div class="stocktake-diff-toolbar">
<button class="ghost-button" type="button" :class="{ active: diffFilter === 'ALL' }" @click="diffFilter = 'ALL'">全部</button>
<button class="ghost-button" type="button" :class="{ active: diffFilter === 'DIFF' }" @click="diffFilter = 'DIFF'">只看差异</button>
<button class="ghost-button" type="button" :class="{ active: diffFilter === 'ERROR' }" @click="diffFilter = 'ERROR'">只看异常</button>
</div>
<div class="stocktake-diff-table stocktake-diff-table-v2">
<div class="stocktake-diff-header">系统快照</div>
<div class="stocktake-diff-change-header">变化</div>
<div class="stocktake-diff-header">实盘结果</div>
<template v-for="line in filteredLines" :key="line.id">
<div class="stocktake-diff-cell stocktake-diff-cell-left" :class="diffClass(line)">
<strong>{{ line.item_code }} · {{ line.item_name }}</strong>
<span>批次:{{ line.lot_no || "-" }}</span>
<span>来源库存批次号:{{ line.source_material_sub_batch_no || "-" }}</span>
<span>重量:{{ formatWeight(line.snapshot_weight_kg) }}</span>
<span v-if="!isWeightOnlyStocktakeLine(line)">数量:{{ formatQty(line.snapshot_qty) }}</span>
<span>单价:¥{{ formatAmount(line.snapshot_unit_cost) }}</span>
</div>
<div class="stocktake-diff-change" :class="diffClass(line)">
<strong>{{ formatStocktakeDiffTypeLabel(line.diff_type) }}</strong>
<span>行状态:{{ formatStocktakeRowStatusLabel(line.row_status) }}</span>
<span>重量差:{{ formatWeight(line.diff_weight_kg) }}</span>
<span v-if="!isWeightOnlyStocktakeLine(line)">数量差:{{ formatQty(line.diff_qty) }}</span>
<span>金额影响:¥{{ formatAmount(line.diff_amount) }}</span>
<em v-if="line.error_message">{{ line.error_message }}</em>
</div>
<div class="stocktake-diff-cell stocktake-diff-cell-right" :class="diffClass(line)">
<strong>{{ line.item_code }} · {{ line.item_name }}</strong>
<span>批次:{{ line.lot_no || "-" }}</span>
<span>盘点重量:{{ formatWeight(line.counted_weight_kg) }}</span>
<span v-if="!isWeightOnlyStocktakeLine(line)">盘点数量:{{ formatQty(line.counted_qty) }}</span>
<span>备注:{{ line.remark || "-" }}</span>
</div>
</template>
<div v-if="!filteredLines.length" class="stocktake-diff-empty-v2">
<strong>没有需要展示的差异行</strong>
<span v-if="isCleanEmptyStocktake">这是空库盘库,确认后只解锁仓库,不生成库存调整流水。</span>
<span v-else>当前筛选条件下没有明细。</span>
</div>
</div>
<div class="stocktake-footer">
<button class="primary-button" type="button" :disabled="busy || !canConfirmCurrentStocktake" @click="openConfirmDialog">
{{ busy ? "确认中..." : "确认更新库存并解锁" }}
</button>
</div>
</section>
</div>
</section>
<div v-if="confirmDialogOpen" class="stocktake-confirm-mask" @click.self="closeConfirmDialog">
<section class="stocktake-confirm-dialog">
<strong>是否确认盘库?</strong>
<span>确认后系统会按当前差异重置库存,并解锁本次盘库仓库。该操作会写入盘库记录。</span>
<div class="stocktake-confirm-actions">
<button class="ghost-button" type="button" :disabled="busy" @click="closeConfirmDialog">取消</button>
<button class="primary-button" type="button" :disabled="busy" @click="confirmStocktake">
{{ busy ? "确认中..." : "确认盘库" }}
</button>
</div>
</section>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import PaginationBar from "./PaginationBar.vue";
import { downloadResource, fetchResource, postResource, uploadResource } from "../services/api";
import { formatStocktakeDiffTypeLabel, formatStocktakeRowStatusLabel, formatStocktakeStatusLabel } from "../utils/dictionaries";
import { formatAmount, formatDateTime, formatQty, formatWeight } from "../utils/formatters";
import { usePagination } from "../utils/pagination";
import {
diffTone,
filterStocktakesForWarehouse,
findActiveStocktakeForWarehouse,
hasBlockingDiffErrors,
isEmptyStocktakeRead,
nextStocktakePanel,
splitDiffLines
} from "../utils/stocktakeWorkflow";
const props = defineProps({
open: { type: Boolean, default: false },
warehouses: { type: Array, default: () => [] },
defaultWarehouseId: { type: Number, default: 0 }
});
const emit = defineEmits(["close", "changed", "message", "error"]);
const flowSteps = [
{ key: "export", no: "01", label: "确认盘库并导出" },
{ key: "import", no: "02", label: "导入清单" }
];
const activePanel = ref("export");
const activeStocktake = ref(null);
const previewLines = ref([]);
const previewSummary = ref({});
const busy = ref(false);
const fileInputRef = ref(null);
const diffFilter = ref("ALL");
const confirmDialogOpen = ref(false);
const diffDialogOpen = ref(false);
const historyRows = ref([]);
const currentFlowIndex = computed(() => Math.max(0, flowSteps.findIndex((step) => step.key === activePanel.value)));
const currentWarehouseId = computed(() => Number(props.defaultWarehouseId || 0));
const currentWarehouse = computed(
() => props.warehouses.find((warehouse) => Number(warehouse.id) === currentWarehouseId.value) || null
);
const currentWarehouseName = computed(() => currentWarehouse.value?.warehouse_name || "未选择仓库");
const currentWarehouseHistoryRows = computed(() => filterStocktakesForWarehouse(historyRows.value, currentWarehouseId.value));
const {
page: historyPage,
pageSize: historyPageSize,
rows: paginatedHistoryRows
} = usePagination(currentWarehouseHistoryRows, 6);
const canConfirmCurrentStocktake = computed(
() => Boolean(activeStocktake.value?.id) && !hasBlockingDiffErrors(previewLines.value)
);
const dialogMessage = ref("");
const dialogError = ref("");
watch(
() => props.open,
async (open) => {
if (!open) return;
activePanel.value = "export";
activeStocktake.value = null;
previewLines.value = [];
previewSummary.value = {};
diffFilter.value = "ALL";
confirmDialogOpen.value = false;
diffDialogOpen.value = false;
dialogMessage.value = "";
dialogError.value = "";
if (!currentWarehouseId.value) {
setDialogError("请先在仓库栏选中一个仓库,再进行盘库");
return;
}
await loadHistory();
const activeRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value);
if (activeRow) {
await continueStocktake(activeRow, { silent: true });
}
}
);
const summaryCards = computed(() => [
{ key: "match", label: "一致", value: previewSummary.value.match_count || 0, tone: "neutral" },
{ key: "gain", label: "盘盈", value: previewSummary.value.gain_count || 0, tone: "gain" },
{ key: "loss", label: "盘亏", value: previewSummary.value.loss_count || 0, tone: "loss" },
{ key: "new", label: "新增批次", value: previewSummary.value.new_lot_count || 0, tone: "new" },
{ key: "missing", label: "缺失批次", value: previewSummary.value.missing_count || 0, tone: "missing" },
{ key: "error", label: "异常", value: previewSummary.value.error_count || 0, tone: "error" },
{ key: "amount", label: "影响金额", value: `¥${formatAmount(previewSummary.value.diff_amount || 0)}`, tone: "amount" }
]);
const filteredLines = computed(() => splitDiffLines(previewLines.value, diffFilter.value));
const isCleanEmptyStocktake = computed(
() =>
diffDialogOpen.value &&
["EXPORTED", "IMPORTED"].includes(String(activeStocktake.value?.status || "").toUpperCase()) &&
previewLines.value.length === 0
);
function isWeightOnlyStocktakeLine(line) {
const warehouseType = String(line?.warehouse_type || currentWarehouse.value?.warehouse_type || "").toUpperCase();
return ["RAW", "AUX", "SCRAP"].includes(warehouseType) || String(line?.item_type || "").toUpperCase() === "RAW_MATERIAL";
}
function diffClass(line) {
return `diff-${diffTone(line)}`;
}
function stocktakeWarehouseNames(row) {
return Array.isArray(row?.warehouses) ? row.warehouses.map((item) => item.warehouse_name).filter(Boolean).join("、") : "-";
}
function stocktakeSummaryValue(row, key) {
return Number(row?.summary?.[key] || 0);
}
async function startAndExportStocktake() {
clearDialogFeedback();
if (!currentWarehouseId.value) {
setDialogError("请先在仓库栏选中一个仓库");
return;
}
busy.value = true;
try {
const stocktake = await postResource("/inventory/stocktakes", {
warehouse_ids: [currentWarehouseId.value],
remark: `${currentWarehouseName.value}盘库`
});
activeStocktake.value = stocktake;
await downloadResource(`/inventory/stocktakes/${stocktake.id}/export`, `${stocktake.stocktake_no}_${currentWarehouseName.value}_盘库清单.xlsx`);
const exportedStocktake = await fetchResource(`/inventory/stocktakes/${stocktake.id}`, null);
if (!exportedStocktake) {
throw new Error("导出后读取盘库单失败,请重新打开盘库");
}
await loadHistory();
await applyLoadedStocktake(exportedStocktake, { silent: true });
setDialogMessage(`盘库单 ${exportedStocktake.stocktake_no} 已导出,${currentWarehouseName.value} 已锁库`);
emit("changed");
} catch (error) {
setDialogError(error.message || "开始盘库并导出失败");
} finally {
busy.value = false;
}
}
async function loadHistory() {
try {
if (!currentWarehouseId.value) {
historyRows.value = [];
return;
}
historyRows.value = await fetchResource(`/inventory/stocktakes?warehouse_id=${currentWarehouseId.value}&limit=100`, []);
} catch (error) {
setDialogError(error.message || "读取盘库记录失败");
}
}
function clearDialogFeedback() {
dialogMessage.value = "";
dialogError.value = "";
}
function setDialogMessage(message) {
dialogMessage.value = message;
dialogError.value = "";
emit("message", message);
}
function setDialogError(message) {
dialogError.value = message;
dialogMessage.value = "";
emit("error", message);
}
async function applyLoadedStocktake(stocktake, options = {}) {
const nextPanel = nextStocktakePanel(stocktake);
let loadedLines = [];
confirmDialogOpen.value = false;
diffDialogOpen.value = false;
previewLines.value = [];
activeStocktake.value = null;
previewSummary.value = {};
if (nextPanel === "diff" && String(stocktake?.status || "").toUpperCase() === "IMPORTED") {
const lineRows = await fetchResource(`/inventory/stocktakes/${stocktake.id}/lines?warehouse_id=${currentWarehouseId.value}`, null);
if (!Array.isArray(lineRows)) {
throw new Error("读取盘库明细失败,请重新打开盘库单");
}
loadedLines = lineRows;
}
activeStocktake.value = stocktake;
previewSummary.value = stocktake?.summary || {};
previewLines.value = loadedLines;
activePanel.value = nextPanel === "diff" || nextPanel === "history" ? "import" : nextPanel;
diffDialogOpen.value = nextPanel === "diff";
if (options.silent) return;
if (nextPanel === "export") {
setDialogMessage(`${stocktake.stocktake_no} 已锁库,请导出盘库清单`);
} else if (nextPanel === "import") {
setDialogMessage(`${stocktake.stocktake_no} 已导出,请导入盘点清单`);
} else if (nextPanel === "diff" && isEmptyStocktakeRead(stocktake)) {
setDialogMessage(`${stocktake.stocktake_no} 是空库盘库单,可确认后解锁`);
} else if (nextPanel === "diff") {
setDialogMessage(`${stocktake.stocktake_no} 已导入,请核对差异后确认`);
}
}
async function continueStocktake(row, options = {}) {
if (!row?.id) return;
clearDialogFeedback();
busy.value = true;
try {
const stocktake = await fetchResource(`/inventory/stocktakes/${row.id}`, row);
await applyLoadedStocktake(stocktake, options);
} catch (error) {
setDialogError(error.message || "继续处理盘库单失败");
} finally {
busy.value = false;
}
}
async function exportStocktake() {
if (!activeStocktake.value?.id) return;
clearDialogFeedback();
busy.value = true;
try {
await downloadResource(`/inventory/stocktakes/${activeStocktake.value.id}/export`, `${activeStocktake.value.stocktake_no}_${currentWarehouseName.value}_盘库清单.xlsx`);
const exportedStocktake = await fetchResource(`/inventory/stocktakes/${activeStocktake.value.id}`, null);
if (!exportedStocktake) {
throw new Error("导出后读取盘库单失败,请刷新后继续处理");
}
await applyLoadedStocktake(exportedStocktake, { silent: true });
if (isEmptyStocktakeRead(exportedStocktake)) {
setDialogMessage(`空盘库单 ${exportedStocktake.stocktake_no} 已导出,可直接确认盘库后解锁仓库`);
} else {
setDialogMessage(`盘库清单 ${exportedStocktake.stocktake_no} 已导出,请清点后导入`);
}
} catch (error) {
setDialogError(error.message || "导出盘库清单失败");
} finally {
busy.value = false;
}
}
async function importStocktake(event) {
const file = event.target.files?.[0];
if (!file) {
setDialogError("请选择盘点后的 Excel 清单");
return;
}
if (!activeStocktake.value?.id) {
const fallbackRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value);
if (fallbackRow) {
await continueStocktake(fallbackRow, { silent: true });
}
if (!activeStocktake.value?.id) {
setDialogError("没有可导入的盘库单,请先创建盘库单或从盘库记录继续处理");
if (fileInputRef.value) fileInputRef.value.value = "";
return;
}
}
clearDialogFeedback();
busy.value = true;
try {
const formData = new FormData();
formData.append("file", file);
const result = await uploadResource(`/inventory/stocktakes/${activeStocktake.value.id}/import`, formData);
if (!result?.stocktake) {
throw new Error("导入接口返回异常,请重新导入盘点清单");
}
activeStocktake.value = result.stocktake;
previewLines.value = Array.isArray(result.lines) ? result.lines : [];
previewSummary.value = result.summary || {};
confirmDialogOpen.value = false;
diffDialogOpen.value = true;
activePanel.value = "import";
await loadHistory();
if (!previewLines.value.length) {
setDialogMessage(`空盘库单 ${result.stocktake.stocktake_no} 已导入:无库存差异,确认盘库后解锁仓库`);
} else if (hasBlockingDiffErrors(previewLines.value)) {
setDialogError(`盘点清单已导入,但存在 ${previewSummary.value.error_count || 0} 行异常,请处理后再确认`);
} else {
setDialogMessage(`盘点清单已导入:发现 ${previewSummary.value.gain_count || 0} 行盘盈、${previewSummary.value.loss_count || 0} 行盘亏、${previewSummary.value.new_lot_count || 0} 行新增批次、${previewSummary.value.missing_count || 0} 行缺失批次`);
}
} catch (error) {
setDialogError(error.message || "导入盘点清单失败");
} finally {
busy.value = false;
if (fileInputRef.value) fileInputRef.value.value = "";
}
}
function openConfirmDialog() {
clearDialogFeedback();
if (!activeStocktake.value?.id) {
setDialogError("没有可确认的盘库单,请先创建或继续处理盘库单");
return;
}
if (!canConfirmCurrentStocktake.value) {
setDialogError("存在异常行或没有可确认的盘库单,不能确认盘库");
return;
}
confirmDialogOpen.value = true;
}
function closeConfirmDialog() {
if (busy.value) return;
confirmDialogOpen.value = false;
}
function closeDiffDialog() {
if (busy.value) return;
diffDialogOpen.value = false;
}
async function confirmStocktake() {
clearDialogFeedback();
if (!activeStocktake.value?.id) {
setDialogError("没有可确认的盘库单,请先创建或继续处理盘库单");
return;
}
if (!canConfirmCurrentStocktake.value) {
setDialogError("存在异常行或没有可确认的盘库单,不能确认盘库");
return;
}
busy.value = true;
try {
const confirmedStocktake = await postResource(`/inventory/stocktakes/${activeStocktake.value.id}/confirm`, {
remark: "盘库确认"
});
activeStocktake.value = confirmedStocktake;
await loadHistory();
confirmDialogOpen.value = false;
diffDialogOpen.value = false;
activePanel.value = "export";
activeStocktake.value = null;
previewLines.value = [];
previewSummary.value = {};
setDialogMessage(`盘库单 ${confirmedStocktake.stocktake_no} 已确认并解锁`);
emit("changed");
} catch (error) {
setDialogError(error.message || "确认盘库失败");
} finally {
busy.value = false;
}
}
</script>
+580
View File
@@ -0,0 +1,580 @@
<template>
<div ref="controlRef" class="table-control-bar">
<label class="table-search-field">
<span>{{ searchLabel }}</span>
<input :value="search" type="search" :placeholder="placeholder" @input="emit('update:search', $event.target.value)" />
</label>
<div class="table-view-mode-switch" role="group" aria-label="表格展示模式">
<button
type="button"
class="table-view-mode-button"
:class="{ 'table-view-mode-active': tableMode === 'overview' }"
:aria-pressed="tableMode === 'overview'"
@click="setTableMode('overview')"
>
概览模式
</button>
<button
type="button"
class="table-view-mode-button"
:class="{ 'table-view-mode-active': tableMode === 'wide' }"
:aria-pressed="tableMode === 'wide'"
@click="setTableMode('wide')"
>
宽表模式
</button>
</div>
</div>
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { normalizeTableViewMode, readStoredTableMode, TABLE_VIEW_MODE_STORAGE_KEY } from "../utils/tableViewMode";
const props = defineProps({
search: {
type: String,
default: ""
},
searchLabel: {
type: String,
default: "搜索"
},
placeholder: {
type: String,
default: "输入编码、名称、客户、单号等关键词"
},
sortKey: {
type: String,
default: ""
},
sortDirection: {
type: String,
default: "desc"
},
sortOptions: {
type: Array,
default: () => []
}
});
const emit = defineEmits(["update:search", "update:sortKey", "update:sortDirection"]);
function normalizeTableMode(value) {
return normalizeTableViewMode(value);
}
const controlRef = ref(null);
const tableMode = ref(readStoredTableMode());
let enhancedTable = null;
let modeTable = null;
let modeTableObserver = null;
let tooltipTable = null;
let tooltipElement = null;
let tooltipCleanupHandlers = [];
let cleanupHandlers = [];
let headerObserver = null;
let enhanceFrame = 0;
let modeFrame = 0;
let isEnhancing = false;
function normalizeLabel(value) {
return String(value || "")
.replace(/\s+/g, "")
.toLowerCase();
}
function findTableAfterControl() {
const control = controlRef.value;
if (!control) {
return null;
}
let sibling = control.nextElementSibling;
while (sibling) {
if (sibling.matches?.("table")) {
return sibling;
}
const nestedTable = sibling.querySelector?.("table");
if (nestedTable) {
return nestedTable;
}
sibling = sibling.nextElementSibling;
}
const scope =
control.closest(".panel, .workflow-drawer, .workflow-section-card, .workflow-summary-card, .compact-side-drawer") ||
control.parentElement;
const tables = Array.from(scope?.querySelectorAll("table") || []);
return tables.find((table) => control.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING) || null;
}
function getTableViewWrap(table) {
return (
table?.closest?.(".table-wrap, .drawer-data-table-wrap, .special-adjustment-table-wrap") ||
table?.parentElement ||
null
);
}
function clearTableViewClasses(table) {
if (!table) {
return;
}
const wrap = getTableViewWrap(table);
table.classList.remove("data-table-overview", "data-table-wide");
wrap?.classList.remove("table-view-overview", "table-view-wide");
delete table.dataset.tableViewMode;
}
function disconnectModeObserver() {
if (modeTableObserver) {
modeTableObserver.disconnect();
modeTableObserver = null;
}
}
function hideCellTooltip() {
tooltipElement?.classList.remove("table-cell-hover-tooltip-visible");
}
function ensureTooltipElement() {
if (typeof document === "undefined") {
return null;
}
tooltipElement = document.querySelector(".table-cell-hover-tooltip");
if (!tooltipElement) {
tooltipElement = document.createElement("div");
tooltipElement.className = "table-cell-hover-tooltip";
tooltipElement.setAttribute("role", "tooltip");
document.body.appendChild(tooltipElement);
}
return tooltipElement;
}
function positionCellTooltip(event) {
if (!tooltipElement) {
return;
}
const padding = 14;
const offset = 16;
const rect = tooltipElement.getBoundingClientRect();
const nextLeft = Math.min(event.clientX + offset, window.innerWidth - rect.width - padding);
const nextTop = Math.min(event.clientY + offset, window.innerHeight - rect.height - padding);
tooltipElement.style.left = `${Math.max(padding, nextLeft)}px`;
tooltipElement.style.top = `${Math.max(padding, nextTop)}px`;
}
function getTooltipCell(event) {
const cell = event.target?.closest?.('td[data-table-tooltip="auto"]');
if (!cell || !tooltipTable?.contains(cell)) {
return null;
}
return cell;
}
function showCellTooltip(cell, event) {
const value = cell.dataset.tableFullValue || "";
if (!value || !["overview", "wide"].includes(tableMode.value)) {
hideCellTooltip();
return;
}
const tooltip = ensureTooltipElement();
if (!tooltip) {
return;
}
tooltip.textContent = value;
tooltip.classList.add("table-cell-hover-tooltip-visible");
positionCellTooltip(event);
}
function clearTooltipListeners() {
tooltipCleanupHandlers.forEach((cleanup) => cleanup());
tooltipCleanupHandlers = [];
tooltipTable = null;
hideCellTooltip();
}
function bindCellTooltip(table) {
if (!table || tooltipTable === table) {
return;
}
clearTooltipListeners();
tooltipTable = table;
const handleMouseOver = (event) => {
const cell = getTooltipCell(event);
if (cell) {
showCellTooltip(cell, event);
}
};
const handleMouseMove = (event) => {
const cell = getTooltipCell(event);
if (cell) {
showCellTooltip(cell, event);
}
};
const handleMouseOut = (event) => {
const cell = getTooltipCell(event);
if (cell && !cell.contains(event.relatedTarget)) {
hideCellTooltip();
}
};
const handleDocumentMouseMove = (event) => {
if (!getTooltipCell(event)) {
hideCellTooltip();
}
};
table.addEventListener("mouseover", handleMouseOver);
table.addEventListener("mousemove", handleMouseMove);
table.addEventListener("mouseout", handleMouseOut);
document.addEventListener("mousemove", handleDocumentMouseMove, true);
window.addEventListener("scroll", hideCellTooltip, true);
window.addEventListener("blur", hideCellTooltip);
tooltipCleanupHandlers.push(() => table.removeEventListener("mouseover", handleMouseOver));
tooltipCleanupHandlers.push(() => table.removeEventListener("mousemove", handleMouseMove));
tooltipCleanupHandlers.push(() => table.removeEventListener("mouseout", handleMouseOut));
tooltipCleanupHandlers.push(() => document.removeEventListener("mousemove", handleDocumentMouseMove, true));
tooltipCleanupHandlers.push(() => window.removeEventListener("scroll", hideCellTooltip, true));
tooltipCleanupHandlers.push(() => window.removeEventListener("blur", hideCellTooltip));
}
function refreshCellTooltips(table) {
const shouldShowCellTooltip = ["overview", "wide"].includes(tableMode.value);
const cells = Array.from(table?.tBodies || []).flatMap((tbody) => Array.from(tbody.querySelectorAll("td")));
cells.forEach((cell) => {
const value = cell.innerText?.trim() || cell.textContent?.trim() || "";
const hasBlockingInteractiveContent = Boolean(
cell.querySelector("input, select, textarea, a, button:not(.link-button), button.semantic-action-button")
);
if (shouldShowCellTooltip && value && !hasBlockingInteractiveContent) {
cell.dataset.tableTooltip = "auto";
cell.dataset.tableFullValue = value;
if (!cell.getAttribute("aria-label") || cell.dataset.tableAria === "auto") {
cell.setAttribute("aria-label", value);
cell.dataset.tableAria = "auto";
}
cell.removeAttribute("title");
return;
}
if (cell.dataset.tableTooltip === "auto") {
cell.removeAttribute("title");
delete cell.dataset.tableTooltip;
delete cell.dataset.tableFullValue;
if (cell.dataset.tableAria === "auto") {
cell.removeAttribute("aria-label");
delete cell.dataset.tableAria;
}
}
});
}
function observeTableForViewMode(table) {
if (modeTableObserver || !table) {
return;
}
modeTableObserver = new MutationObserver(() => {
scheduleApplyTableViewMode();
});
modeTableObserver.observe(table, { childList: true, characterData: true, subtree: true });
}
function applyTableViewMode(table = findTableAfterControl()) {
if (!table) {
return;
}
if (modeTable && modeTable !== table) {
clearTableViewClasses(modeTable);
disconnectModeObserver();
}
modeTable = table;
const wrap = getTableViewWrap(table);
const isWide = tableMode.value === "wide";
table.classList.toggle("data-table-overview", !isWide);
table.classList.toggle("data-table-wide", isWide);
table.dataset.tableViewMode = isWide ? "wide" : "overview";
wrap?.classList.toggle("table-view-overview", !isWide);
wrap?.classList.toggle("table-view-wide", isWide);
refreshCellTooltips(table);
bindCellTooltip(table);
observeTableForViewMode(table);
}
function scheduleApplyTableViewMode() {
if (modeFrame) {
return;
}
modeFrame = window.requestAnimationFrame(() => {
modeFrame = 0;
applyTableViewMode();
});
}
function setTableMode(mode) {
const nextMode = normalizeTableMode(mode);
if (tableMode.value === nextMode) {
return;
}
tableMode.value = nextMode;
}
function getOriginalHeaderLabel(th) {
return th.dataset.originalHeaderLabel || th.textContent.trim();
}
function findSortOption(label) {
const normalizedHeader = normalizeLabel(label);
if (!normalizedHeader) {
return null;
}
const scoredOptions = props.sortOptions
.map((option) => {
const normalizedOptionLabel = normalizeLabel(option.label);
const normalizedOptionKey = normalizeLabel(option.key);
let score = 0;
if (normalizedOptionLabel === normalizedHeader) {
score = 100;
} else if (normalizedOptionKey === normalizedHeader) {
score = 96;
} else if (normalizedHeader === "编码" && normalizedOptionLabel.endsWith("编码")) {
score = 90;
} else if (normalizedHeader === "名称" && normalizedOptionLabel.endsWith("名称")) {
score = 90;
} else if (normalizedOptionLabel === `${normalizedHeader}名称`) {
score = 86;
} else if (normalizedOptionLabel === `${normalizedHeader}编码`) {
score = 82;
} else if (normalizedOptionLabel.startsWith(normalizedHeader) && normalizedOptionLabel.endsWith("名称")) {
score = 78;
} else if (normalizedOptionLabel.startsWith(normalizedHeader) && normalizedOptionLabel.endsWith("编码")) {
score = 68;
} else if (normalizedOptionLabel.includes(normalizedHeader)) {
score = 58;
} else if (normalizedHeader.includes(normalizedOptionLabel)) {
score = 52;
}
return { option, score };
})
.filter((item) => item.score > 0)
.sort((left, right) => right.score - left.score);
return scoredOptions[0]?.option || null;
}
function disconnectHeaderObserver() {
if (headerObserver) {
headerObserver.disconnect();
headerObserver = null;
}
}
function clearEnhancedHeaders({ disconnectObserver = true } = {}) {
if (disconnectObserver) {
disconnectHeaderObserver();
}
cleanupHandlers.forEach((cleanup) => cleanup());
cleanupHandlers = [];
if (!enhancedTable) {
return;
}
enhancedTable.querySelectorAll("th.sortable-th").forEach((th) => {
const hasEnhancedButton = Boolean(th.querySelector(".sortable-th-button"));
const currentHeaderLabel = th.textContent.trim();
const originalLabel = hasEnhancedButton ? th.dataset.originalHeaderLabel : currentHeaderLabel || th.dataset.originalHeaderLabel;
th.classList.remove("sortable-th", "sortable-th-active", "sortable-th-asc", "sortable-th-desc");
delete th.dataset.sortKey;
delete th.dataset.originalHeaderLabel;
if (originalLabel) {
th.textContent = originalLabel;
}
});
enhancedTable = null;
}
function scheduleEnhanceHeaders() {
if (isEnhancing || enhanceFrame) {
return;
}
enhanceFrame = window.requestAnimationFrame(() => {
enhanceFrame = 0;
enhanceTableHeaders();
});
}
function observeTableHead(table) {
disconnectHeaderObserver();
if (!table?.tHead) {
return;
}
headerObserver = new MutationObserver(() => {
scheduleEnhanceHeaders();
});
headerObserver.observe(table.tHead, { childList: true, characterData: true, subtree: true });
}
function toggleSort(sortKey) {
if (props.sortKey === sortKey) {
emit("update:sortDirection", props.sortDirection === "asc" ? "desc" : "asc");
return;
}
emit("update:sortKey", sortKey);
emit("update:sortDirection", "asc");
}
function refreshHeaderStates() {
const table = enhancedTable || findTableAfterControl();
if (!table) {
return;
}
table.querySelectorAll("th.sortable-th").forEach((th) => {
const isActive = th.dataset.sortKey === props.sortKey;
const arrow = th.querySelector(".sort-arrow");
th.classList.toggle("sortable-th-active", isActive);
th.classList.toggle("sortable-th-asc", isActive && props.sortDirection === "asc");
th.classList.toggle("sortable-th-desc", isActive && props.sortDirection === "desc");
if (arrow) {
arrow.textContent = isActive ? (props.sortDirection === "asc" ? "↑" : "↓") : "";
arrow.classList.toggle("sort-arrow-active", isActive);
}
});
}
function enhanceTableHeaders() {
isEnhancing = true;
clearEnhancedHeaders();
const table = findTableAfterControl();
applyTableViewMode(table);
if (!props.sortOptions.length) {
isEnhancing = false;
return;
}
const headerRows = Array.from(table?.tHead?.rows || []);
if (!table || !headerRows.length) {
isEnhancing = false;
return;
}
enhancedTable = table;
headerRows
.flatMap((row) => Array.from(row.cells))
.filter((th) => th.tagName === "TH" && Number(th.colSpan || 1) === 1)
.forEach((th) => {
const originalLabel = getOriginalHeaderLabel(th);
const option = findSortOption(originalLabel);
if (!option) {
return;
}
th.dataset.originalHeaderLabel = originalLabel;
th.dataset.sortKey = option.key;
th.classList.add("sortable-th");
th.textContent = "";
const button = document.createElement("button");
button.type = "button";
button.className = "sortable-th-button";
button.setAttribute("aria-label", `按${option.label}排序`);
const label = document.createElement("span");
label.className = "sortable-th-label";
label.textContent = originalLabel;
const arrow = document.createElement("span");
arrow.className = "sort-arrow";
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = "";
button.append(label, arrow);
const listener = () => toggleSort(option.key);
button.addEventListener("click", listener);
cleanupHandlers.push(() => button.removeEventListener("click", listener));
th.appendChild(button);
});
refreshHeaderStates();
observeTableHead(table);
isEnhancing = false;
}
onMounted(() => {
nextTick(enhanceTableHeaders);
});
watch(
() => props.sortOptions,
() => nextTick(enhanceTableHeaders),
{ deep: true }
);
watch(
() => [props.sortKey, props.sortDirection],
() => nextTick(refreshHeaderStates)
);
watch(
tableMode,
(mode) => {
if (typeof window !== "undefined") {
window.localStorage.setItem(TABLE_VIEW_MODE_STORAGE_KEY, mode);
}
nextTick(applyTableViewMode);
}
);
onBeforeUnmount(() => {
if (enhanceFrame) {
window.cancelAnimationFrame(enhanceFrame);
enhanceFrame = 0;
}
if (modeFrame) {
window.cancelAnimationFrame(modeFrame);
modeFrame = 0;
}
clearTableViewClasses(modeTable);
disconnectModeObserver();
clearTooltipListeners();
clearEnhancedHeaders();
});
</script>
@@ -0,0 +1,75 @@
<template>
<div class="document-archive-actions" :class="statusClass">
<span class="document-archive-status" aria-live="polite">
<span class="document-archive-status-dot" aria-hidden="true"></span>
{{ statusText }}
</span>
<div class="document-archive-buttons">
<button
class="document-archive-button document-archive-button-preview"
type="button"
:disabled="!isReady"
:title="isReady ? '预览PDF' : 'PDF未生成,暂不能预览'"
@click="emit('preview')"
>
<span class="document-archive-icon" aria-hidden="true">PDF</span>
预览PDF
</button>
<button
class="document-archive-button document-archive-button-download"
type="button"
:disabled="!isReady"
:title="isReady ? '下载PDF' : 'PDF未生成,暂不能下载'"
@click="emit('download')"
>
<span class="document-archive-icon" aria-hidden="true">↓</span>
下载PDF
</button>
<button
class="document-archive-button document-archive-button-regenerate"
type="button"
title="重新生成PDF归档"
@click="emit('regenerate')"
>
<span class="document-archive-icon" aria-hidden="true">↻</span>
重新生成
</button>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
status: {
type: String,
default: "未生成"
}
});
const emit = defineEmits(["preview", "download", "regenerate"]);
const normalizedStatusType = computed(() => {
const value = String(props.status ?? "").trim();
if (!value || value === "未生成" || value === "missing") return "missing";
if (value === "已归档" || value === "ready" || value === "archived") return "ready";
if (value === "归档失败" || value === "failed" || value === "error") return "failed";
return "missing";
});
const statusText = computed(() => {
if (normalizedStatusType.value === "ready") return "已归档";
if (normalizedStatusType.value === "failed") return "归档失败";
return "未生成";
});
const isReady = computed(() => normalizedStatusType.value === "ready");
const statusClass = computed(() => ({
"document-archive-actions-archived": normalizedStatusType.value === "ready",
"document-archive-actions-failed": normalizedStatusType.value === "failed",
"document-archive-actions-pending": normalizedStatusType.value === "missing"
}));
</script>
@@ -0,0 +1,42 @@
<template>
<dl class="document-grid" :style="gridStyle">
<div
v-for="field in fields"
:key="field.key"
class="document-grid-cell"
:class="field.class"
>
<dt class="document-grid-label">{{ field.label }}</dt>
<dd class="document-grid-value">
<slot :name="field.key" :field="field">
{{ field.value ?? "-" }}
</slot>
</dd>
</div>
</dl>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
fields: {
type: Array,
required: true
},
columns: {
type: [Number, String],
default: 4
}
});
const normalizedColumns = computed(() => {
const columns = Number(props.columns);
if (!Number.isFinite(columns)) return 4;
return Math.min(Math.max(Math.trunc(columns), 1), 6);
});
const gridStyle = computed(() => ({
"--document-grid-columns": normalizedColumns.value
}));
</script>
@@ -0,0 +1,44 @@
<template>
<section class="document-line-table">
<header class="document-line-table-toolbar">
<h2 class="document-line-table-title">{{ title }}</h2>
<div v-if="$slots.actions" class="document-line-table-actions">
<slot name="actions" />
</div>
</header>
<div class="document-line-table-scroll">
<table>
<thead>
<tr>
<th
v-for="column in columns"
:key="column.key"
:class="column.class"
:style="{ width: column.width }"
scope="col"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<slot />
</tbody>
</table>
</div>
</section>
</template>
<script setup>
defineProps({
title: {
type: String,
default: "明细联单"
},
columns: {
type: Array,
required: true
}
});
</script>
@@ -0,0 +1,83 @@
<template>
<section class="document-paper-shell" :class="toneClass">
<div class="document-paper-stack document-paper-stack-back" aria-hidden="true"></div>
<div class="document-paper-stack document-paper-stack-mid" aria-hidden="true"></div>
<article class="document-paper-sheet">
<div class="document-paper-clip" aria-hidden="true">
<span class="document-paper-clip-bar"></span>
<span class="document-paper-clip-jaw"></span>
</div>
<div class="document-paper-binder" aria-hidden="true">
<span
v-for="hole in binderHoles"
:key="hole"
class="document-paper-binder-hole"
></span>
</div>
<div class="document-retained-mark" aria-label="ERP留存联">ERP留存联</div>
<header class="document-paper-header">
<p class="document-company-name">{{ companyName }}</p>
<h1 class="document-paper-title">{{ title }}</h1>
<div class="document-number-stamp" :aria-label="documentNo ? `单据编号 ${documentNo}` : '单据编号未填写'">
<span class="document-number-label">NO.</span>
<strong>{{ documentNo || "待生成" }}</strong>
</div>
</header>
<div class="document-paper-body">
<slot />
</div>
<footer class="document-signature-footer" aria-label="签核栏">
<div
v-for="label in signatureLabels"
:key="label"
class="document-signature-cell"
>
<span class="document-signature-label">{{ label }}</span>
<span class="document-signature-line"></span>
</div>
</footer>
<div class="document-paper-action-host" data-warehouse-document-action-host></div>
</article>
</section>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
title: {
type: String,
required: true
},
documentNo: {
type: String,
default: ""
},
companyName: {
type: String,
default: "嘉恒仓库 ERP"
},
tone: {
type: String,
default: "red"
},
signatureLabels: {
type: Array,
default: () => ["制单", "审核", "仓库", "财务"]
}
});
const binderHoles = [1, 2, 3, 4, 5];
const toneClass = computed(() => ({
"document-paper-tone-red": props.tone !== "green",
"document-paper-tone-green": props.tone === "green"
}));
</script>
@@ -0,0 +1,47 @@
<template>
<span ref="anchorRef" class="warehouse-document-action-anchor" aria-hidden="true"></span>
<Teleport :to="actionHost" :disabled="!actionHost">
<div
class="warehouse-document-action-bar warehouse-document-action-bar--shared"
:class="{ 'warehouse-document-action-bar--with-state': $slots.state }"
@click.capture="handleActionClick"
>
<div v-if="$slots.state" class="warehouse-document-action-state">
<slot name="state" />
</div>
<div class="warehouse-document-action-buttons">
<slot />
</div>
</div>
</Teleport>
</template>
<script setup>
import { nextTick, onMounted, ref } from "vue";
const anchorRef = ref(null);
const actionHost = ref(null);
onMounted(async () => {
await nextTick();
actionHost.value = anchorRef.value
?.closest?.("form")
?.querySelector?.("[data-warehouse-document-action-host]") || null;
});
function handleActionClick(event) {
const target = event.target;
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
if (!submitter || submitter.disabled) {
return;
}
const form = anchorRef.value?.closest?.("form");
if (!form) {
return;
}
event.preventDefault();
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
}
</script>
@@ -0,0 +1,50 @@
<template>
<form class="warehouse-document-form" @submit.prevent="emit('submit')">
<DocumentPaper
:title="title"
:document-no="documentNo"
:company-name="companyName"
:tone="tone"
>
<div class="warehouse-document-form-meta" aria-label="仓库单据基础信息">
<span>仓库联单</span>
<strong>{{ companyName }}</strong>
<span>业务日期</span>
<strong>{{ businessDate || "-" }}</strong>
</div>
<main class="warehouse-document-body">
<slot />
</main>
</DocumentPaper>
</form>
</template>
<script setup>
import DocumentPaper from "./DocumentPaper.vue";
defineProps({
title: {
type: String,
required: true
},
documentNo: {
type: String,
default: ""
},
businessDate: {
type: String,
default: ""
},
tone: {
type: String,
default: "green"
},
companyName: {
type: String,
default: "嘉恒仓库"
}
});
const emit = defineEmits(["submit"]);
</script>
@@ -0,0 +1,25 @@
<template>
<section class="warehouse-document-section">
<header class="warehouse-document-section-head">
<span class="warehouse-document-section-index">{{ index }}</span>
<strong>{{ title }}</strong>
</header>
<div class="warehouse-document-section-grid">
<slot />
</div>
</section>
</template>
<script setup>
defineProps({
index: {
type: [Number, String],
required: true
},
title: {
type: String,
required: true
}
});
</script>
@@ -0,0 +1,63 @@
import { onUnmounted, watch } from "vue";
let lockCount = 0;
let savedOverflow = "";
let savedPaddingRight = "";
function lockBody() {
if (typeof window === "undefined") {
return;
}
if (lockCount === 0) {
savedOverflow = document.body.style.overflow;
savedPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
}
lockCount += 1;
}
function unlockBody() {
if (typeof window === "undefined" || lockCount <= 0) {
return;
}
lockCount -= 1;
if (lockCount === 0) {
document.body.style.overflow = savedOverflow;
document.body.style.paddingRight = savedPaddingRight;
}
}
export function useBodyScrollLock(source) {
let active = false;
const stop = watch(
source,
(locked) => {
if (locked && !active) {
lockBody();
active = true;
}
if (!locked && active) {
unlockBody();
active = false;
}
},
{ immediate: true }
);
onUnmounted(() => {
stop();
if (active) {
unlockBody();
active = false;
}
});
}
+22
View File
@@ -0,0 +1,22 @@
export const moduleMatrix = [
{
name: "原材料驱动",
description: "原材料入库成本驱动生产出库、成品库存、发货和财务对账。",
tables: ["po_receipt_item", "wh_stock_lot", "pp_work_order_material_issue", "pp_completion_receipt_item", "so_delivery"]
},
{
name: "物料与库存一体化",
description: "统一物料编码贯穿采购、库存、生产和退货。",
tables: ["md_item", "md_material", "po_purchase_order_item", "wh_stock_lot", "wh_inventory_txn"]
},
{
name: "工艺与报工联动",
description: "产品工艺路线决定工单工序,报工实时反哺异常预警与成本。",
tables: ["md_process_route", "md_process_route_operation", "pp_work_order_operation", "pp_operation_report", "pp_scrap_record"]
},
{
name: "财务成本还原",
description: "材料、人工、设备、制造费用、返工和废料收益统一归集。",
tables: ["fi_accounting_period", "fi_overhead_entry", "fi_cost_allocation", "fi_statement_snapshot"]
}
];
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./styles/main.css";
import { setupStatusBadgeEnhancer } from "./utils/statusBadges";
createApp(App).use(router).mount("#app");
setupStatusBadgeEnhancer();
+506
View File
@@ -0,0 +1,506 @@
import { createRouter, createWebHistory } from "vue-router";
import DashboardView from "../views/DashboardView.vue";
import SalesPlanningView from "../views/SalesPlanningView.vue";
import EmployeeManagementView from "../views/EmployeeManagementView.vue";
import ProductManagementView from "../views/ProductManagementView.vue";
import BomManagementView from "../views/BomManagementView.vue";
import ProcessRouteManagementView from "../views/ProcessRouteManagementView.vue";
import LoginView from "../views/LoginView.vue";
import SupplierManagementView from "../views/SupplierManagementView.vue";
import PurchaseOrderView from "../views/PurchaseOrderView.vue";
import PurchaseReceiptView from "../views/PurchaseReceiptView.vue";
import InventoryLedgerView from "../views/InventoryLedgerView.vue";
import ProductionLedgerView from "../views/ProductionLedgerView.vue";
import OperationReportView from "../views/OperationReportView.vue";
import DeliveryManagementView from "../views/DeliveryManagementView.vue";
import EquipmentManagementView from "../views/EquipmentManagementView.vue";
import QualityInspectionView from "../views/QualityInspectionView.vue";
import FinanceReportView from "../views/FinanceReportView.vue";
import ProductSpecLiteView from "../views/ProductSpecLiteView.vue";
import SupplierMaterialLiteView from "../views/SupplierMaterialLiteView.vue";
import SystemPermissionView from "../views/SystemPermissionView.vue";
import SystemExtensionView from "../views/SystemExtensionView.vue";
import MasterDataImportView from "../views/MasterDataImportView.vue";
import { isAuthenticated, readSession } from "../services/api";
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
const masterDataPermissionOrder = [
{
routeName: "supplier-management",
permission: "MENU_SUPPLIER_MANAGEMENT"
},
{
routeName: "supplier-material-lite",
permissionsAny: ["MENU_SUPPLIER_MANAGEMENT", "MENU_MATERIAL_MANAGEMENT"]
},
{
routeName: "customer-management",
permission: "MENU_SALES_PLANNING"
},
{
routeName: "product-spec-lite",
permissionsAny: ["MENU_PRODUCT_MANAGEMENT", "MENU_BOM_MANAGEMENT", "MENU_ROUTE_MANAGEMENT"]
},
{
routeName: "employee-management",
permission: "MENU_EMPLOYEE_MANAGEMENT"
}
];
const procurementPermissionOrder = [
{
routeName: "purchase-orders",
permission: "MENU_PURCHASE_ORDER"
},
{
routeName: "purchase-receipts",
permission: "MENU_PURCHASE_RECEIPT"
},
{
routeName: "quality-inspection",
permission: "MENU_QUALITY_INSPECTION"
},
{
routeName: "inventory-ledger",
permission: "MENU_INVENTORY_LEDGER"
}
];
const productionPermissionOrder = [
{
routeName: "production-ledger",
permission: "MENU_WORK_ORDER"
},
{
routeName: "operation-reports",
permission: "MENU_OPERATION_REPORT"
}
];
const deliveryPermissionOrder = [
{
routeName: "delivery-management",
permission: "MENU_DELIVERY_MANAGEMENT"
}
];
const systemPermissionOrder = [
{
routeName: "system-permissions",
permission: "MENU_SYSTEM_PERMISSION"
},
{
routeName: "system-extension",
permission: "MENU_SYSTEM_EXTENSION"
}
];
const navPermissionOrder = [
{ routeName: "dashboard", permission: "MENU_DASHBOARD" },
{
routeName: "master-data-workspace",
permissionsAny: [
"MENU_PRODUCT_MANAGEMENT",
"MENU_SUPPLIER_MANAGEMENT",
"MENU_MATERIAL_MANAGEMENT",
"MENU_BOM_MANAGEMENT",
"MENU_ROUTE_MANAGEMENT",
"MENU_EMPLOYEE_MANAGEMENT",
"MENU_SALES_PLANNING"
]
},
{ routeName: "order-list", permission: "MENU_SALES_PLANNING" },
{
routeName: "procurement-workspace",
permissionsAny: [
"MENU_PURCHASE_ORDER",
"MENU_PURCHASE_RECEIPT",
"MENU_QUALITY_INSPECTION",
"MENU_INVENTORY_LEDGER"
]
},
{
routeName: "production-workspace",
permissionsAny: [
"MENU_WORK_ORDER",
"MENU_OPERATION_REPORT"
]
},
{
routeName: "delivery-workspace",
permissionsAny: ["MENU_DELIVERY_MANAGEMENT", "MENU_RETURN_MANAGEMENT"]
},
{ routeName: "finance-reports", permission: "MENU_FINANCE_REPORT" },
{
routeName: "system-management",
permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"]
}
];
function hasSystemAdminFallback(permission, roleCodes = []) {
return String(permission || "").startsWith("MENU_SYSTEM_") && roleCodes.includes("ADMIN");
}
function getFirstAllowedMasterDataRoute(permissionCodes = readSession()?.permission_codes || []) {
const matched = masterDataPermissionOrder.find((item) => {
if (item.permission) {
return permissionCodes.includes(item.permission);
}
return item.permissionsAny?.some((permission) => permissionCodes.includes(permission));
});
return matched?.routeName || "dashboard";
}
function getFirstAllowedProcurementRoute(permissionCodes = readSession()?.permission_codes || []) {
const matched = procurementPermissionOrder.find((item) => permissionCodes.includes(item.permission));
return matched?.routeName || "dashboard";
}
function getFirstAllowedProductionRoute(permissionCodes = readSession()?.permission_codes || []) {
const matched = productionPermissionOrder.find((item) => permissionCodes.includes(item.permission));
return matched?.routeName || "dashboard";
}
function getFirstAllowedDeliveryRoute(permissionCodes = readSession()?.permission_codes || []) {
const matched = deliveryPermissionOrder.find((item) => permissionCodes.includes(item.permission));
return matched?.routeName || "dashboard";
}
function getFirstAllowedSystemRoute(
permissionCodes = readSession()?.permission_codes || [],
roleCodes = readSession()?.role_codes || []
) {
const matched = systemPermissionOrder.find(
(item) => permissionCodes.includes(item.permission) || hasSystemAdminFallback(item.permission, roleCodes)
);
return matched?.routeName || "dashboard";
}
function getFirstAllowedRoute() {
const session = readSession();
const permissionCodes = session?.permission_codes || [];
const roleCodes = session?.role_codes || [];
const matched = navPermissionOrder.find((item) => {
if (item.permission) {
return permissionCodes.includes(item.permission) || hasSystemAdminFallback(item.permission, roleCodes);
}
if (item.permissionsAny) {
return item.permissionsAny.some((permission) => permissionCodes.includes(permission) || hasSystemAdminFallback(permission, roleCodes));
}
return false;
});
if (matched?.routeName === "master-data-workspace") {
return getFirstAllowedMasterDataRoute(permissionCodes);
}
if (matched?.routeName === "procurement-workspace") {
return getFirstAllowedProcurementRoute(permissionCodes);
}
if (matched?.routeName === "production-workspace") {
return getFirstAllowedProductionRoute(permissionCodes);
}
if (matched?.routeName === "delivery-workspace") {
return getFirstAllowedDeliveryRoute(permissionCodes);
}
if (matched?.routeName === "system-management") {
return getFirstAllowedSystemRoute(permissionCodes, roleCodes);
}
return matched?.routeName || "dashboard";
}
function hasRoutePermission(to, permissionCodes, roleCodes = []) {
if (to.meta.permission) {
return permissionCodes.includes(to.meta.permission) || hasSystemAdminFallback(to.meta.permission, roleCodes);
}
if (Array.isArray(to.meta.permissionsAny)) {
return to.meta.permissionsAny.some((permission) => permissionCodes.includes(permission) || hasSystemAdminFallback(permission, roleCodes));
}
return true;
}
const routes = [
{
path: "/login",
name: "login",
component: LoginView,
meta: { title: "工业风登录", layout: "auth" }
},
{
path: "/",
name: "dashboard",
component: DashboardView,
meta: { title: "原材料批次生命周期追踪", requiresAuth: true, permission: "MENU_DASHBOARD" }
},
{
path: "/order-planning",
alias: "/sales-planning",
name: "sales-planning",
redirect: { name: "order-list" },
meta: { title: "订单与计划中心", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
},
{
path: "/customer-management",
name: "customer-management",
component: SalesPlanningView,
meta: { title: "客户名录", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
},
{
path: "/order-list",
name: "order-list",
component: SalesPlanningView,
meta: { title: "销售订单列表", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
},
{
path: "/procurement-workspace",
name: "procurement-workspace",
redirect: () => ({ name: getFirstAllowedProcurementRoute() }),
meta: {
title: "采购与库存",
requiresAuth: true,
permissionsAny: [
"MENU_PURCHASE_ORDER",
"MENU_PURCHASE_RECEIPT",
"MENU_QUALITY_INSPECTION",
"MENU_INVENTORY_LEDGER",
"MENU_COMPLETION_RECEIPT"
]
}
},
{
path: "/production-workspace",
name: "production-workspace",
redirect: () => ({ name: getFirstAllowedProductionRoute() }),
meta: {
title: "生产执行",
requiresAuth: true,
permissionsAny: [
"MENU_WORK_ORDER",
"MENU_OPERATION_REPORT"
]
}
},
{
path: "/delivery-workspace",
name: "delivery-workspace",
redirect: () => ({ name: getFirstAllowedDeliveryRoute() }),
meta: {
title: "发货概览",
requiresAuth: true,
permissionsAny: ["MENU_DELIVERY_MANAGEMENT", "MENU_RETURN_MANAGEMENT"]
}
},
{
path: "/master-data-workspace",
name: "master-data-workspace",
redirect: () => ({ name: getFirstAllowedMasterDataRoute() }),
meta: {
title: "基础资料",
requiresAuth: true,
permissionsAny: [
"MENU_PRODUCT_MANAGEMENT",
"MENU_SUPPLIER_MANAGEMENT",
"MENU_MATERIAL_MANAGEMENT",
"MENU_BOM_MANAGEMENT",
"MENU_ROUTE_MANAGEMENT",
"MENU_EMPLOYEE_MANAGEMENT",
"MENU_SALES_PLANNING"
]
}
},
{
path: "/supplier-material-lite",
name: "supplier-material-lite",
component: SupplierMaterialLiteView,
meta: {
title: "原材料名录",
requiresAuth: true,
permissionsAny: ["MENU_SUPPLIER_MANAGEMENT", "MENU_MATERIAL_MANAGEMENT"]
}
},
{
path: "/product-spec-lite",
name: "product-spec-lite",
component: ProductSpecLiteView,
meta: {
title: "产品需规清单",
requiresAuth: true,
permissionsAny: ["MENU_PRODUCT_MANAGEMENT", "MENU_BOM_MANAGEMENT", "MENU_ROUTE_MANAGEMENT"]
}
},
{
path: "/supplier-management",
name: "supplier-management",
component: SupplierManagementView,
meta: { title: "供应商名录", requiresAuth: true, permission: "MENU_SUPPLIER_MANAGEMENT" }
},
{
path: "/purchase-orders",
name: "purchase-orders",
component: PurchaseOrderView,
meta: { title: "采购订单", requiresAuth: true, permission: "MENU_PURCHASE_ORDER" }
},
{
path: "/purchase-receipts",
name: "purchase-receipts",
component: PurchaseReceiptView,
meta: { title: "到货入库", requiresAuth: true, permission: "MENU_PURCHASE_RECEIPT" }
},
{
path: "/inventory-ledger",
name: "inventory-ledger",
component: InventoryLedgerView,
meta: { title: "原材料库存台账与批次", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" }
},
{
path: "/production-ledger",
name: "production-ledger",
component: ProductionLedgerView,
meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" }
},
{
path: "/work-order-ledger",
name: "work-order-ledger",
redirect: { name: "production-ledger" },
meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" }
},
{
path: "/operation-reports",
name: "operation-reports",
component: OperationReportView,
meta: { title: "工序报工", requiresAuth: true, permission: "MENU_OPERATION_REPORT" }
},
{
path: "/scrap-rework",
name: "scrap-rework",
redirect: { name: "operation-reports" },
meta: { title: "工序报工", requiresAuth: true, permission: "MENU_OPERATION_REPORT" }
},
{
path: "/delivery-management",
name: "delivery-management",
component: DeliveryManagementView,
meta: { title: "发货台账", requiresAuth: true, permission: "MENU_DELIVERY_MANAGEMENT" }
},
{
path: "/equipment-management",
name: "equipment-management",
component: EquipmentManagementView,
meta: { title: "设备管理", requiresAuth: true, permission: "MENU_EQUIPMENT_MANAGEMENT" }
},
{
path: "/quality-inspection",
name: "quality-inspection",
component: QualityInspectionView,
meta: { title: "质量检验", requiresAuth: true, permission: "MENU_QUALITY_INSPECTION" }
},
{
path: "/finance-reports",
name: "finance-reports",
component: FinanceReportView,
meta: { title: "冲压产品材料成本核算", requiresAuth: true, permission: "MENU_FINANCE_REPORT" }
},
{
path: "/return-management",
name: "return-management",
redirect: { name: "inventory-ledger" },
meta: { title: "退货处理", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" }
},
{
path: "/material-management",
name: "material-management",
redirect: { name: "supplier-material-lite" },
meta: { title: "原材料名录", requiresAuth: true, permission: "MENU_MATERIAL_MANAGEMENT" }
},
{
path: "/bom-management",
name: "bom-management",
component: BomManagementView,
meta: { title: "BOM管理", requiresAuth: true, permission: "MENU_BOM_MANAGEMENT" }
},
{
path: "/process-route-management",
name: "process-route-management",
component: ProcessRouteManagementView,
meta: { title: "工艺路线管理", requiresAuth: true, permission: "MENU_ROUTE_MANAGEMENT" }
},
{
path: "/employee-management",
name: "employee-management",
component: EmployeeManagementView,
meta: { title: "员工管理", requiresAuth: true, permission: "MENU_EMPLOYEE_MANAGEMENT" }
},
{
path: "/master-data-import",
name: "master-data-import",
component: MasterDataImportView,
meta: {
title: "基础资料导入导出",
requiresAuth: true,
permissionsAny: ["MENU_PRODUCT_MANAGEMENT", "MENU_SUPPLIER_MANAGEMENT", "MENU_MATERIAL_MANAGEMENT", "MENU_BOM_MANAGEMENT", "MENU_ROUTE_MANAGEMENT"]
}
},
{
path: "/product-management",
name: "product-management",
component: ProductManagementView,
meta: { title: "产品管理", requiresAuth: true, permission: "MENU_PRODUCT_MANAGEMENT" }
},
{
path: "/system-management",
name: "system-management",
redirect: () => ({ name: getFirstAllowedSystemRoute() }),
meta: {
title: "系统管理",
requiresAuth: true,
permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"]
}
},
{
path: "/system-permissions",
name: "system-permissions",
component: SystemPermissionView,
meta: { title: "系统权限管理", requiresAuth: true, permission: "MENU_SYSTEM_PERMISSION" }
},
{
path: "/system-extension",
name: "system-extension",
component: SystemExtensionView,
meta: { title: "系统拓展", requiresAuth: true, permission: "MENU_SYSTEM_EXTENSION" }
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach(async (to) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { name: "login", query: { redirect: to.fullPath } };
}
if (to.name === "login" && isAuthenticated()) {
return { name: getFirstAllowedRoute() };
}
if (to.meta.requiresAuth) {
const session = readSession();
const permissionCodes = session?.permission_codes || [];
const roleCodes = session?.role_codes || [];
if (!hasRoutePermission(to, permissionCodes, roleCodes)) {
return { name: getFirstAllowedRoute() };
}
if (to.name === "operation-reports") {
const config = await loadSmartOperationReportConfig();
if (config.enabled === false) {
return permissionCodes.includes("MENU_WORK_ORDER") ? { name: "production-ledger" } : { name: "dashboard" };
}
}
}
return true;
});
export default router;
+258
View File
@@ -0,0 +1,258 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api";
const SESSION_KEY = "forgeflow.erp.session";
async function parseResponse(response) {
const contentType = response.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const data = isJson ? await response.json() : await response.text();
if (!response.ok) {
const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`;
throw new Error(detail);
}
return data;
}
function isSessionExpired(session) {
if (!session?.access_token || !session?.expires_at) {
return true;
}
const expiresAt = Date.parse(session.expires_at);
if (Number.isNaN(expiresAt)) {
return true;
}
return expiresAt <= Date.now();
}
export async function requestJson(path, options = {}) {
const { skipAuthRedirect = false, headers = {}, ...fetchOptions } = options;
const session = readSession();
const authHeaders =
session?.access_token && !isSessionExpired(session)
? { Authorization: `Bearer ${session.access_token}` }
: {};
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
"Content-Type": "application/json",
...authHeaders,
...headers
},
...fetchOptions
});
if ((response.status === 401 || response.status === 403) && !skipAuthRedirect) {
clearSession();
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
return parseResponse(response);
}
export async function fetchResource(path, fallbackData) {
try {
return await requestJson(path, { method: "GET" });
} catch (error) {
return fallbackData;
}
}
export async function postResource(path, payload) {
return requestJson(path, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function uploadResource(path, formData) {
const session = readSession();
const authHeaders =
session?.access_token && !isSessionExpired(session)
? { Authorization: `Bearer ${session.access_token}` }
: {};
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "POST",
headers: authHeaders,
body: formData
});
if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") {
clearSession();
window.location.href = "/login";
}
return parseResponse(response);
}
function getDownloadFilename(response, fallbackFilename) {
const disposition = response.headers.get("content-disposition") || "";
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
try {
return decodeURIComponent(encodedMatch[1]);
} catch (error) {
return fallbackFilename;
}
}
const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
return plainMatch?.[1] || fallbackFilename;
}
export async function downloadResource(path, fallbackFilename = "download.xlsx") {
const session = readSession();
const authHeaders =
session?.access_token && !isSessionExpired(session)
? { Authorization: `Bearer ${session.access_token}` }
: {};
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "GET",
headers: authHeaders
});
if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") {
clearSession();
window.location.href = "/login";
}
if (!response.ok) {
return parseResponse(response);
}
const blob = await response.blob();
const filename = getDownloadFilename(response, fallbackFilename);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return { filename };
}
export async function postDownloadResource(path, payload, fallbackFilename = "download.zip") {
const session = readSession();
const authHeaders =
session?.access_token && !isSessionExpired(session)
? { Authorization: `Bearer ${session.access_token}` }
: {};
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeaders
},
body: JSON.stringify(payload)
});
if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") {
clearSession();
window.location.href = "/login";
}
if (!response.ok) {
return parseResponse(response);
}
const blob = await response.blob();
const filename = getDownloadFilename(response, fallbackFilename);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return { filename };
}
export async function openResource(path) {
const session = readSession();
const authHeaders =
session?.access_token && !isSessionExpired(session)
? { Authorization: `Bearer ${session.access_token}` }
: {};
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "GET",
headers: authHeaders
});
if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") {
clearSession();
window.location.href = "/login";
}
if (!response.ok) {
return parseResponse(response);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
return { opened: true };
}
export async function putResource(path, payload) {
return requestJson(path, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function deleteResource(path) {
return requestJson(path, {
method: "DELETE"
});
}
export function saveSession(user) {
localStorage.setItem(SESSION_KEY, JSON.stringify(user));
}
export function readSession() {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) {
return null;
}
try {
const session = JSON.parse(raw);
if (isSessionExpired(session)) {
localStorage.removeItem(SESSION_KEY);
return null;
}
return session;
} catch (error) {
console.warn("Failed to parse session", error);
localStorage.removeItem(SESSION_KEY);
return null;
}
}
export function clearSession() {
localStorage.removeItem(SESSION_KEY);
}
export function isAuthenticated() {
return Boolean(readSession());
}
export async function login(username, password) {
return requestJson("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
skipAuthRedirect: true
});
}
export function buildApiUrl(path) {
return `${API_BASE_URL}${path}`;
}
+48
View File
@@ -0,0 +1,48 @@
import { fetchResource, readSession } from "./api";
export const PERSONNEL_PERMISSION_CODES = {
SALES_ORDER: "MENU_SALES_PLANNING",
PURCHASE_ORDER: "MENU_PURCHASE_ORDER",
PURCHASE_RECEIPT: "MENU_PURCHASE_RECEIPT",
INVENTORY_LEDGER: "MENU_INVENTORY_LEDGER",
DELIVERY_MANAGEMENT: "MENU_DELIVERY_MANAGEMENT",
EQUIPMENT_MANAGEMENT: "MENU_EQUIPMENT_MANAGEMENT"
};
export function normalizeEmployeeOption(row) {
return {
id: Number(row.employee_id ?? row.id),
employee_id: Number(row.employee_id ?? row.id),
employee_name: row.employee_name || row.name || "",
mobile: row.mobile || "",
dept_name: row.dept_name || "",
job_title: row.job_title || "",
username: row.username || "",
role_names: Array.isArray(row.role_names) ? row.role_names : [],
permission_codes: Array.isArray(row.permission_codes) ? row.permission_codes : [],
has_system_user: Boolean(row.has_system_user)
};
}
export async function fetchPermissionEmployeeOptions(permissionCode, limit = 500) {
const params = new URLSearchParams({
permission_code: permissionCode,
limit: String(limit)
});
const rows = await fetchResource(`/master-data/employee-options?${params.toString()}`, []);
return Array.isArray(rows) ? rows.map(normalizeEmployeeOption) : [];
}
export function defaultEmployeeIdFromSession(options) {
const currentEmployeeId = readSession()?.employee_id;
if (!currentEmployeeId) {
return "";
}
const matched = options.find((employee) => String(employee.employee_id) === String(currentEmployeeId));
return matched ? matched.employee_id : "";
}
export function employeeOptionLabel(employee) {
const extra = [employee.dept_name, employee.role_names?.join("、")].filter(Boolean).join(" · ");
return extra ? `${employee.employee_name} · ${extra}` : employee.employee_name;
}
+34
View File
@@ -0,0 +1,34 @@
import { fetchResource } from "./api";
let smartOperationReportConfigCache = null;
let smartOperationReportConfigPromise = null;
export async function loadSmartOperationReportConfig(force = false) {
if (!force && smartOperationReportConfigCache) {
return smartOperationReportConfigCache;
}
if (!force && smartOperationReportConfigPromise) {
return smartOperationReportConfigPromise;
}
smartOperationReportConfigPromise = fetchResource("/system-extension/smart-operation-report", {
config_code: "SMART_OPERATION_REPORT_ENABLED",
config_name: "对接智能报工小程序",
enabled: true,
config_value: "开启",
remark: ""
}).then((config) => {
smartOperationReportConfigCache = {
...config,
enabled: config?.enabled !== false
};
smartOperationReportConfigPromise = null;
return smartOperationReportConfigCache;
});
return smartOperationReportConfigPromise;
}
export function resetSmartOperationReportConfigCache(config = null) {
smartOperationReportConfigCache = config;
smartOperationReportConfigPromise = null;
}
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(join(__dirname, "main.css"), "utf8");
const viewSourceByName = new Map(
[
"InventoryLedgerView.vue",
"PurchaseOrderView.vue",
"SalesPlanningView.vue",
"SupplierMaterialLiteView.vue",
"SystemExtensionView.vue"
].map((fileName) => [fileName, readFileSync(join(__dirname, "../views", fileName), "utf8")])
);
function cssRuleBlock(selectorStart) {
const selectorIndex = styles.indexOf(selectorStart);
assert.notEqual(selectorIndex, -1, `Missing CSS rule for ${selectorStart}`);
const bodyStart = styles.indexOf("{", selectorIndex);
assert.notEqual(bodyStart, -1, `Missing CSS body for ${selectorStart}`);
const bodyEnd = styles.indexOf("}", bodyStart);
assert.notEqual(bodyEnd, -1, `Missing CSS close brace for ${selectorStart}`);
return styles.slice(bodyStart + 1, bodyEnd);
}
describe("table action button layout and tones", () => {
it("keeps wide-table action cells as real table cells with enough width", () => {
const wideActionHeaderRule = cssRuleBlock("body .table-view-wide .data-table:has(td.action-row) thead th:last-child");
assert.match(styles, /--table-action-column-width:\s*300px;/);
assert.match(styles, /body \.table-view-wide \.data-table td\.action-row,[\s\S]*?display:\s*table-cell !important;/);
assert.match(wideActionHeaderRule, /width:\s*var\(--table-action-column-width\) !important;/);
assert.match(wideActionHeaderRule, /text-align:\s*center !important;/);
});
it("centers compact overview action headers over icon-only action buttons", () => {
const overviewActionHeaderRule = cssRuleBlock("body .table-view-overview .data-table:has(td.action-row) thead th:last-child");
assert.match(overviewActionHeaderRule, /text-align:\s*center !important;/);
});
it("adds stronger table-specific color treatments for edit and delete actions", () => {
assert.match(styles, /body \.data-table button\.semantic-action-button\.semantic-action-button\[data-button-tone="info"\]/);
assert.match(styles, /body \.data-table button\.semantic-action-button\.semantic-action-button\[data-button-tone="danger"\]/);
assert.match(styles, /--button-bg:\s*linear-gradient\(180deg,\s*#eff8ff/);
assert.match(styles, /--button-bg:\s*linear-gradient\(180deg,\s*#fff1f2/);
});
it("marks every table operation column with the standard action-row cell class", () => {
viewSourceByName.forEach((source, fileName) => {
const operationTables = source
.split(/<table\b/)
.slice(1)
.filter((chunk) => /<th[^>]*>\s*操作\s*<\/th>/.test(chunk));
assert.ok(operationTables.length > 0, `${fileName} should include operation tables for this coverage`);
operationTables.forEach((chunk, index) => {
assert.match(chunk, /<td\s+class="action-row"/, `${fileName} operation table ${index + 1} should use action-row`);
});
});
});
it("gives custom import and export toolbar buttons distinct semantic classes", () => {
const transferToolbarSources = [
readFileSync(join(__dirname, "../views/SupplierManagementView.vue"), "utf8"),
readFileSync(join(__dirname, "../views/SupplierMaterialLiteView.vue"), "utf8"),
readFileSync(join(__dirname, "../views/SalesPlanningView.vue"), "utf8")
];
transferToolbarSources.forEach((source, index) => {
assert.match(source, /supplier-action-import/, `toolbar source ${index + 1} should mark import buttons`);
assert.match(source, /supplier-action-export/, `toolbar source ${index + 1} should mark export buttons`);
});
assert.match(styles, /\.supplier-action-import/);
assert.match(styles, /\.supplier-action-export/);
});
});
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
import { resolveButtonSemantics } from "./buttonSemantics";
import { ICON_PATHS } from "./iconMap";
const LEGACY_ICON_CLASSES = [
"icon-add",
"icon-edit",
"icon-delete",
"icon-close",
"icon-refresh",
"icon-reset",
"icon-save",
"icon-pass",
"icon-reject",
"icon-records",
"icon-dispose",
"icon-cost",
"icon-upload",
"icon-download",
"icon-prev",
"icon-next",
"icon-open",
"icon-generate"
];
const ACTION_BUTTON_SELECTOR = [
"button.primary-button",
"button.ghost-button",
"button.link-button",
"button.workflow-close-button",
"button.small-button",
"button.contract-header-button",
"button.purchase-contract-button"
].join(",");
const SKIP_CONTAINER_SELECTOR = [
".pagination-bar",
".tags-view-container",
".tags-context-menu",
".user-menu-dropdown",
".global-select-layer",
".table-view-mode-switch",
".nav-list"
].join(",");
function buttonText(button) {
const label = button.querySelector(".semantic-action-label");
return String(label?.textContent || button.textContent || "").replace(/\s+/g, " ").trim();
}
function isTransientLabel(text) {
return /中(\.{1,3}|…)?$/.test(text) || text.includes("提交中") || text.includes("上传中") || text.includes("导入中");
}
function shouldSkipButton(button) {
return (
button.classList.contains("no-auto-icon") ||
button.classList.contains("icon-button") ||
button.classList.contains("table-view-mode-button") ||
button.classList.contains("sortable-th-button") ||
button.classList.contains("hamburger-container") ||
button.classList.contains("assistant-bot-button") ||
button.classList.contains("user-avatar-button") ||
button.classList.contains("tags-view-open") ||
button.classList.contains("tags-view-close") ||
button.classList.contains("pagination-arrow") ||
button.classList.contains("pagination-page") ||
button.classList.contains("tag-remove") ||
button.closest(SKIP_CONTAINER_SELECTOR)
);
}
function createSemanticIcon(iconName) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("semantic-action-svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
(ICON_PATHS[iconName] || ICON_PATHS.cursorClick || ICON_PATHS.dashboard).forEach((pathValue) => {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pathValue);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-width", "1.85");
svg.appendChild(path);
});
const icon = document.createElement("span");
icon.className = "semantic-action-icon";
icon.appendChild(svg);
return icon;
}
function setButtonContent(button, semantics) {
let icon = button.querySelector(".semantic-action-icon");
let label = button.querySelector(".semantic-action-label");
if (button.dataset.semanticActionIcon !== semantics.icon || !icon || !label) {
button.textContent = "";
icon = createSemanticIcon(semantics.icon);
label = document.createElement("span");
label.className = "semantic-action-label";
button.append(icon, label);
}
label.textContent = semantics.label;
}
function clearSemanticButton(button) {
if (button.dataset.autoSemanticButton !== "true") {
return;
}
const label = button.querySelector(".semantic-action-label")?.textContent || button.textContent;
button.querySelector(".semantic-action-icon")?.remove();
button.querySelector(".semantic-action-label")?.replaceWith(document.createTextNode(label));
button.classList.remove("semantic-action-button");
delete button.dataset.autoSemanticButton;
delete button.dataset.buttonKind;
delete button.dataset.buttonTone;
delete button.dataset.semanticActionIcon;
}
function enhanceButton(button) {
LEGACY_ICON_CLASSES.forEach((className) => button.classList.remove(className));
if (shouldSkipButton(button)) {
clearSemanticButton(button);
return;
}
const text = buttonText(button);
if (!text || isTransientLabel(text)) {
return;
}
const semantics = resolveButtonSemantics(text, button.className);
if (!semantics) {
clearSemanticButton(button);
return;
}
button.classList.add("semantic-action-button");
button.dataset.autoSemanticButton = "true";
button.dataset.buttonKind = semantics.kind;
button.dataset.buttonTone = semantics.tone;
button.dataset.semanticActionIcon = semantics.icon;
setButtonContent(button, semantics);
button.setAttribute("aria-label", semantics.label);
if (!button.title || button.dataset.autoIconTitle === "true") {
button.title = semantics.label;
button.dataset.autoIconTitle = "true";
}
}
function scanActionButtons(root = document) {
if (root instanceof HTMLButtonElement && root.matches(ACTION_BUTTON_SELECTOR)) {
enhanceButton(root);
}
root.querySelectorAll?.(ACTION_BUTTON_SELECTOR).forEach(enhanceButton);
}
export function setupActionIconButtons() {
scanActionButtons();
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "characterData") {
const parent = mutation.target.parentElement;
if (parent) {
scanActionButtons(parent);
}
return;
}
if (mutation.target instanceof Element) {
scanActionButtons(mutation.target);
}
mutation.addedNodes.forEach((node) => {
if (node instanceof Element) {
scanActionButtons(node);
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
return () => observer.disconnect();
}
+113
View File
@@ -0,0 +1,113 @@
const EXACT_ACTIONS = new Map([
["刷新", { label: "刷新数据", icon: "refresh", tone: "neutral", kind: "tool" }],
["查看", { label: "查看详情", icon: "view", tone: "info", kind: "table" }],
["详情", { label: "查看详情", icon: "view", tone: "info", kind: "table" }],
["编辑", { label: "编辑资料", icon: "edit", tone: "info", kind: "table" }],
["删除", { label: "删除记录", icon: "delete", tone: "danger", kind: "danger" }],
["移除", { label: "移除记录", icon: "delete", tone: "danger", kind: "danger" }],
["增加一行", { label: "添加明细行", icon: "rowAdd", tone: "info", kind: "secondary" }],
["新增工序", { label: "添加工序", icon: "rowAdd", tone: "info", kind: "secondary" }],
["添加联系人", { label: "添加联系人", icon: "plus", tone: "info", kind: "secondary" }],
["保存", { label: "保存信息", icon: "save", tone: "primary", kind: "primary" }],
["取消", { label: "取消操作", icon: "close", tone: "neutral", kind: "secondary" }],
["关闭", { label: "关闭窗口", icon: "close", tone: "neutral", kind: "secondary" }],
["放大", { label: "放大视图", icon: "zoomIn", tone: "neutral", kind: "tool" }],
["缩小", { label: "缩小视图", icon: "zoomOut", tone: "neutral", kind: "tool" }],
["一屏展示", { label: "一屏展示", icon: "fitScreen", tone: "neutral", kind: "tool" }],
["授权", { label: "人员授权", icon: "authorize", tone: "primary", kind: "primary" }],
["放行", { label: "质检放行", icon: "check", tone: "success", kind: "success" }],
["处置", { label: "处理退货", icon: "edit", tone: "warning", kind: "warning" }],
["处置记录", { label: "查看处置记录", icon: "ledger", tone: "info", kind: "table" }]
]);
const PARTIAL_ACTIONS = [
{ match: "保存授权", icon: "authorize", tone: "primary", kind: "primary" },
{ match: "保存单据", icon: "save", tone: "primary", kind: "primary" },
{ match: "保存", icon: "save", tone: "primary", kind: "primary" },
{ match: "确认", icon: "check", tone: "primary", kind: "primary" },
{ match: "登记", icon: "save", tone: "primary", kind: "primary" },
{ match: "提交", icon: "save", tone: "primary", kind: "primary" },
{ match: "新建", icon: "plus", tone: "primary", kind: "primary" },
{ match: "新增", icon: "plus", tone: "primary", kind: "primary", labelPrefix: "新建" },
{ match: "导入", icon: "import", tone: "primary", kind: "primary" },
{ match: "导出", icon: "export", tone: "success", kind: "tool" },
{ match: "下载", icon: "download", tone: "success", kind: "tool" },
{ match: "上传", icon: "upload", tone: "primary", kind: "primary" },
{ match: "打印", icon: "print", tone: "neutral", kind: "tool" },
{ match: "刷新", icon: "refresh", tone: "neutral", kind: "tool", explicitLabel: "刷新数据" },
{ match: "重置", icon: "refresh", tone: "neutral", kind: "tool" },
{ match: "搜索", icon: "search", tone: "info", kind: "tool" },
{ match: "查看", icon: "view", tone: "info", kind: "table" },
{ match: "详情", icon: "view", tone: "info", kind: "table" },
{ match: "编辑", icon: "edit", tone: "info", kind: "table" },
{ match: "质检放行", icon: "check", tone: "success", kind: "success" },
{ match: "合格", icon: "check", tone: "success", kind: "success" },
{ match: "放行", icon: "check", tone: "success", kind: "success" },
{ match: "驳回", icon: "reject", tone: "danger", kind: "danger" },
{ match: "不合格", icon: "reject", tone: "danger", kind: "danger" },
{ match: "拒绝", icon: "reject", tone: "danger", kind: "danger" },
{ match: "删除", icon: "delete", tone: "danger", kind: "danger" },
{ match: "移除", icon: "delete", tone: "danger", kind: "danger" },
{ match: "结单", icon: "check", tone: "warning", kind: "warning" },
{ match: "添加", icon: "rowAdd", tone: "info", kind: "secondary" },
{ match: "增加", icon: "rowAdd", tone: "info", kind: "secondary" },
{ match: "下一步", icon: "arrowRight", tone: "primary", kind: "primary" },
{ match: "上一步", icon: "arrowLeft", tone: "neutral", kind: "secondary" }
];
function normalizeLabel(label) {
return String(label || "").replace(/\s+/g, " ").trim();
}
function hasDangerClass(className) {
return /\b(danger|danger-ghost|error|reject)\b/i.test(String(className || ""));
}
function normalizeExplicitLabel(label, rule) {
if (rule.explicitLabel) {
return rule.explicitLabel;
}
if (rule.labelPrefix && label.startsWith(rule.match)) {
return `${rule.labelPrefix}${label.slice(rule.match.length)}`;
}
return label;
}
export function resolveButtonSemantics(label, className = "") {
const normalizedLabel = normalizeLabel(label);
if (!normalizedLabel) {
return null;
}
if (hasDangerClass(className) && ["删除", "移除"].includes(normalizedLabel)) {
return EXACT_ACTIONS.get(normalizedLabel);
}
const exact = EXACT_ACTIONS.get(normalizedLabel);
if (exact) {
return exact;
}
const partial = PARTIAL_ACTIONS.find((rule) => normalizedLabel.includes(rule.match));
if (partial) {
return {
label: normalizeExplicitLabel(normalizedLabel, partial),
icon: partial.icon,
tone: hasDangerClass(className) ? "danger" : partial.tone,
kind: hasDangerClass(className) ? "danger" : partial.kind
};
}
if (hasDangerClass(className)) {
return {
label: normalizedLabel,
icon: "warning",
tone: "danger",
kind: "danger"
};
}
return null;
}
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { resolveButtonSemantics } from "./buttonSemantics.js";
describe("resolveButtonSemantics", () => {
it("expands terse common action labels into clear ERP labels", () => {
assert.deepEqual(resolveButtonSemantics("刷新"), {
label: "刷新数据",
icon: "refresh",
tone: "neutral",
kind: "tool"
});
assert.deepEqual(resolveButtonSemantics("查看"), {
label: "查看详情",
icon: "view",
tone: "info",
kind: "table"
});
assert.deepEqual(resolveButtonSemantics("编辑"), {
label: "编辑资料",
icon: "edit",
tone: "info",
kind: "table"
});
assert.deepEqual(resolveButtonSemantics("删除"), {
label: "删除记录",
icon: "delete",
tone: "danger",
kind: "danger"
});
assert.deepEqual(resolveButtonSemantics("增加一行"), {
label: "添加明细行",
icon: "rowAdd",
tone: "info",
kind: "secondary"
});
assert.deepEqual(resolveButtonSemantics("保存"), {
label: "保存信息",
icon: "save",
tone: "primary",
kind: "primary"
});
});
it("keeps already explicit business labels while assigning icon and tone", () => {
assert.deepEqual(resolveButtonSemantics("新增采购订单"), {
label: "新建采购订单",
icon: "plus",
tone: "primary",
kind: "primary"
});
assert.deepEqual(resolveButtonSemantics("保存授权"), {
label: "保存授权",
icon: "authorize",
tone: "primary",
kind: "primary"
});
assert.deepEqual(resolveButtonSemantics("质检放行"), {
label: "质检放行",
icon: "check",
tone: "success",
kind: "success"
});
assert.deepEqual(resolveButtonSemantics("保存产品需规"), {
label: "保存产品需规",
icon: "save",
tone: "primary",
kind: "primary"
});
});
it("uses distinct tones for transfer actions instead of rendering every utility button alike", () => {
assert.deepEqual(resolveButtonSemantics("导入"), {
label: "导入",
icon: "import",
tone: "primary",
kind: "primary"
});
assert.deepEqual(resolveButtonSemantics("导出"), {
label: "导出",
icon: "export",
tone: "success",
kind: "tool"
});
assert.deepEqual(resolveButtonSemantics("下载模板"), {
label: "下载模板",
icon: "download",
tone: "success",
kind: "tool"
});
});
it("treats danger classes as risk actions even when the label is short", () => {
assert.deepEqual(resolveButtonSemantics("移除", "ghost-button danger-ghost"), {
label: "移除记录",
icon: "delete",
tone: "danger",
kind: "danger"
});
});
it("does not decorate business identifier links that are not actions", () => {
assert.equal(resolveButtonSemantics("产品需规00001", "link-button"), null);
});
});
+169
View File
@@ -0,0 +1,169 @@
const CHINESE_CODE_INITIALS = {
产: "C",
品: "P",
原: "Y",
料: "L",
本: "B",
体: "T",
垃: "L",
圾: "J",
桶: "T",
垫: "D",
电: "D",
脑: "N",
支: "Z",
架: "J",
钢: "G",
板: "B",
卷: "J",
铝: "L",
合: "H",
金: "J",
铁: "T",
铜: "T",
不: "B",
锈: "X",
冷: "L",
热: "R",
轧: "Z",
镀: "D",
锌: "X",
冲: "C",
压: "Y",
件: "J",
片: "P",
管: "G",
棒: "B",
圆: "Y",
方: "F",
型: "X",
材: "C",
外: "W",
壳: "K",
底: "D",
座: "Z",
盖: "G",
盒: "H",
箱: "X",
门: "M",
锁: "S",
扣: "K",
连: "L",
接: "J"
};
function normalizeText(value) {
return String(value || "").trim();
}
export function normalizeCodeToken(value, maxLength = 24) {
const text = normalizeText(value).toUpperCase();
if (!text) {
return "";
}
const chunks = [];
let current = "";
let currentKind = "";
const flushCurrent = () => {
if (current) {
chunks.push(current);
}
current = "";
currentKind = "";
};
for (const char of text.replaceAll("×", "X").replaceAll("*", "X")) {
let kind = "";
let tokenChar = "";
if (/^[A-Z0-9.]$/.test(char)) {
kind = "LATIN";
tokenChar = char;
} else if (/^[\u4e00-\u9fff]$/.test(char)) {
const mapped = CHINESE_CODE_INITIALS[char];
if (mapped) {
kind = "CJK";
tokenChar = mapped;
} else {
kind = "CJK_RAW";
tokenChar = char;
}
} else {
flushCurrent();
continue;
}
if (currentKind && currentKind !== kind) {
flushCurrent();
}
currentKind = kind;
current += tokenChar;
}
flushCurrent();
return chunks.join("-").slice(0, maxLength).replace(/^[.-]+|[.-]+$/g, "");
}
function buildCodePrefix(parts, maxLength = 42) {
const tokens = parts.map((part) => normalizeCodeToken(part)).filter(Boolean);
if (!tokens.length) {
return "";
}
return tokens.join("-").slice(0, maxLength).replace(/^[.-]+|[.-]+$/g, "");
}
export function buildNextProductSpecCode(products = []) {
let maxNo = 0;
for (const product of products || []) {
const code = normalizeText(product?.item_code);
const match = code.match(/^产品需规(\d{5})$/);
if (match) {
maxNo = Math.max(maxNo, Number(match[1]));
}
}
return `产品需规${String(maxNo + 1).padStart(5, "0")}`;
}
export function buildProductCodePreview(form, savedCode = "", nextCode = "") {
if (normalizeText(savedCode)) {
return savedCode;
}
return normalizeText(nextCode) || "产品需规00001";
}
export function buildNextMaterialCode(materials = []) {
let maxNo = 0;
for (const material of materials || []) {
const code = normalizeText(material?.item_code);
const match = code.match(/^原材料(\d{5})$/);
if (match) {
maxNo = Math.max(maxNo, Number(match[1]));
}
}
return `原材料${String(maxNo + 1).padStart(5, "0")}`;
}
export function buildMaterialCodePreview(form, savedCode = "", nextCode = "") {
if (normalizeText(savedCode)) {
return savedCode;
}
return normalizeText(nextCode) || "原材料00001";
}
export function buildBomCodePreview(productCode, savedCode = "") {
if (normalizeText(savedCode)) {
return savedCode;
}
const prefix = buildCodePrefix(["BOM", productCode || "产品编码"], 50) || "BOM-产品编码";
return prefix;
}
export function buildRouteCodePreview(productCode, savedCode = "") {
if (normalizeText(savedCode)) {
return savedCode;
}
const prefix = buildCodePrefix(["ROUTE", productCode || "产品编码"], 50) || "ROUTE-产品编码";
return prefix;
}
+347
View File
@@ -0,0 +1,347 @@
export const STATUS_LABELS = {
ACTIVE: "启用",
ENABLED: "启用",
INACTIVE: "停用",
DISABLED: "停用",
OPEN: "未开始",
NEW: "新建",
DRAFT: "草稿",
SUBMITTED: "已提交",
APPROVED: "已审批",
PARTIAL: "部分完成",
PARTIAL_RECEIVED: "部分收货",
CLOSED: "已关闭",
CANCELED: "已取消",
CANCELLED: "已取消",
ORDERED: "已下单",
RECEIVED: "已收货",
RELEASED: "已下达",
IN_PROGRESS: "进行中",
READY_TO_RECEIPT: "待成品入库",
COMPLETED: "已完工",
SETTLED: "已结单",
DONE: "已完成",
PENDING: "待处理",
PENDING_QC: "待质检",
PASSED: "已放行",
REJECTED: "已拒收",
POSTED: "已过账",
AVAILABLE: "可用",
LOCKED: "已锁单",
DEPLETED: "已耗尽",
CONFIRMED: "已确认",
OVERRIDDEN: "已覆盖",
CREATED: "已创建",
IDLE: "空闲",
READY: "就绪",
ERROR: "异常",
FAILED: "失败",
EXPORTED: "已导出",
IMPORTED: "已导入",
SUPERSEDED: "已替代",
MINIAPP_ONLY: "小程序清单",
HAS_RETURN: "存在退货"
};
export const BOM_USAGE_LABELS = {
WEIGHT: "重量"
};
export const PURCHASE_CALC_MODE_LABELS = {
BY_WEIGHT: "按重量采购"
};
export const QUALITY_STATUS_LABELS = {
PENDING_QC: "待质检",
PASS: "已放行",
PASSED: "已放行",
REJECT: "已拒收",
REJECTED: "已拒收",
REWORK: "返工中",
SCRAP: "已报废",
PENDING: "待处理"
};
export const DISPOSAL_TYPE_LABELS = {
SCRAP: "报废",
REWORK: "返工"
};
export const INVENTORY_TXN_TYPE_LABELS = {
OPENING_IN: "期初入库",
PURCHASE_IN: "采购入库",
CUSTOMER_SUPPLIED_IN: "客供料入库",
MATERIAL_ISSUE: "生产领料",
PRODUCTION_OUT: "生产出库",
PRODUCTION_SURPLUS_IN: "生产余料入库",
OUTSOURCING_SURPLUS_IN: "委外余料入库",
WIP_IN: "产中入库",
WIP_OUT: "产中出库",
OUTSOURCING_IN: "委外入库",
OUTSOURCING_OUT: "委外出库",
PRODUCTION_FINISHED_IN: "生产入库",
REWORK_FINISHED_IN: "返工入库",
FG_IN: "成品入库",
SALES_OUT: "发货出库",
SCRAP_OUT: "报废出库",
PRODUCTION_SCRAP_IN: "生产废料入库",
REWORK_SCRAP_IN: "返工废料入库",
OUTSOURCING_SCRAP_IN: "委外废料入库",
SCRAP_SALE_OUT: "废料售卖出库",
PURCHASE_RETURN_OUT: "采购退货出库",
RETURN_IN: "退货入库",
RETURN_SCRAP_IN: "退货废料入库",
RETURN_OUT: "返工出库",
STOCKTAKE_GAIN: "盘库盘盈",
STOCKTAKE_LOSS: "盘库盘亏",
STOCKTAKE_NEW_LOT: "盘库新增批次"
};
export const SOURCE_DOC_TYPE_LABELS = {
OPENING_STOCK: "期初入库",
PURCHASE_RECEIPT: "采购到货",
PURCHASE_ORDER: "采购订单",
CUSTOMER_SUPPLIED_IN: "客供料入库",
WORK_ORDER: "生产工单",
FG_RECEIPT: "成品入库",
DELIVERY: "发货单",
SALES_ORDER: "销售订单",
PRODUCTION_OUT: "生产出库",
PRODUCTION_SURPLUS_IN: "生产余料入库",
OUTSOURCING_SURPLUS_IN: "委外余料入库",
WIP_IN: "产中入库",
WIP_OUT: "产中出库",
OUTSOURCING_IN: "委外入库",
OUTSOURCING_OUT: "委外出库",
PRODUCTION_FINISHED_IN: "生产入库",
REWORK_RECEIPT: "返工入库",
REWORK_SCRAP_IN: "返工废料入库",
SCRAP_OUT: "报废出库",
PRODUCTION_SCRAP_IN: "生产废料入库",
OUTSOURCING_SCRAP_IN: "委外废料入库",
SCRAP_SALE_OUT: "废料售卖出库",
PURCHASE_RETURN: "采购退货",
RETURN_ORDER: "客户退货",
RETURN_DISPOSITION: "退货处置",
RETURN_OUT: "返工出库",
STOCKTAKE: "盘库",
STOCKTAKE_ADJUSTMENT: "盘库调整"
};
export const WAREHOUSE_TYPE_LABELS = {
RAW: "原材料库",
SEMI: "半成品库",
FINISHED: "成品库",
AUX: "辅料库",
SCRAP: "废料库",
RETURN: "退货库"
};
export const ITEM_TYPE_LABELS = {
RAW_MATERIAL: "原材料",
FINISHED_GOOD: "成品",
PRODUCT: "产品",
SEMI_FINISHED: "半成品",
AUXILIARY: "辅料",
CONSUMABLE: "辅料",
SCRAP: "废料",
SPARE_PART: "备件"
};
export const PURCHASE_SOURCE_TYPE_LABELS = {
SALES_ORDER: "销售订单采购",
STOCKING: "备料采购"
};
export const MAINTENANCE_TYPE_LABELS = {
PERIODIC: "周期保养",
PREVENTIVE: "周期保养",
ROUTINE: "例行保养",
REPAIR: "维修保养",
INSPECTION: "点检保养",
EMERGENCY: "紧急维修",
MOULD: "模具保养",
MOLD: "模具保养"
};
export const MINIAPP_ROLE_LABELS = {
WORKER: "员工",
MANAGER: "经理",
ADMIN: "管理员"
};
export const STOCKTAKE_ROW_STATUS_LABELS = {
READY: "可确认",
ERROR: "异常",
IGNORED: "已忽略"
};
export const DASHBOARD_NODE_TYPE_LABELS = {
RAW_MATERIAL_LOT: "原材料库存批次",
MATERIAL_ISSUE_BATCH: "生产出库批次",
WORK_ORDER: "生产工单",
WORK_ORDER_OPERATION: "工单工序",
OPERATION_REPORT: "工序报工",
COMPLETION_RECEIPT: "成品入库",
DELIVERY: "销售出库",
SALES_ORDER: "销售订单",
SALES_ORDER_ITEM: "销售订单明细",
MRP_DEMAND: "物料需求",
PURCHASE_ORDER_ITEM: "采购订单明细",
PURCHASE_RECEIPT_ITEM: "到货入库明细",
SCRAP_REWORK: "报废返工",
RETURN: "客户退货",
RETURN_DISPOSITION: "退货处置"
};
export function normalizeDictionaryKey(value) {
return String(value ?? "").trim().toUpperCase();
}
export function isEnumLikeValue(value) {
const text = String(value ?? "").trim();
return /^[A-Z][A-Z0-9_]*$/.test(text) || /^[a-z][a-z0-9_]*$/.test(text);
}
export function dictionaryFallback(value, fallback = "-", unknownLabel = "未识别类型") {
if (value === null || value === undefined || value === "") {
return fallback;
}
return isEnumLikeValue(value) ? unknownLabel : String(value);
}
export function getLabel(map, value, fallback = "-", unknownLabel = "未识别类型") {
if (value === null || value === undefined || value === "") {
return fallback;
}
const key = normalizeDictionaryKey(value);
return map[key] || map[value] || dictionaryFallback(value, fallback, unknownLabel);
}
export function formatStatusLabel(value) {
return getLabel(STATUS_LABELS, value, "-", "未识别状态");
}
export function formatUsageTypeLabel(value) {
return getLabel(BOM_USAGE_LABELS, value, "-", "未识别用量类型");
}
export function formatPurchaseCalcModeLabel(value) {
return getLabel(PURCHASE_CALC_MODE_LABELS, value, "-", "未识别采购方式");
}
export function formatQualityStatusLabel(value) {
return getLabel(QUALITY_STATUS_LABELS, value, "-", "未识别质检状态");
}
export function formatDisposalTypeLabel(value) {
return getLabel(DISPOSAL_TYPE_LABELS, value, "-", "未识别处置方式");
}
export function formatInventoryTxnTypeLabel(value) {
return getLabel(INVENTORY_TXN_TYPE_LABELS, value, "-", "未识别流水类型");
}
export function formatSourceDocTypeLabel(value) {
return getLabel(SOURCE_DOC_TYPE_LABELS, value, "-", "未识别来源");
}
export function formatWarehouseTypeLabel(value) {
return getLabel(WAREHOUSE_TYPE_LABELS, value, "-", "未识别仓库");
}
export function formatItemTypeLabel(value) {
return getLabel(ITEM_TYPE_LABELS, value, "-", "未识别物料类型");
}
export function formatPurchaseSourceTypeLabel(value) {
return getLabel(PURCHASE_SOURCE_TYPE_LABELS, value, "-", "未识别采购来源");
}
export function formatMaintenanceTypeLabel(value) {
return getLabel(MAINTENANCE_TYPE_LABELS, value, "-", "未识别保养类型");
}
export function formatMiniappRoleLabel(value) {
return getLabel(MINIAPP_ROLE_LABELS, value, "-", "未识别角色");
}
export function formatStocktakeRowStatusLabel(value) {
return getLabel(STOCKTAKE_ROW_STATUS_LABELS, value, "-", "未识别行状态");
}
export function formatDashboardNodeTypeLabel(value) {
return getLabel(DASHBOARD_NODE_TYPE_LABELS, value, "-", "未识别节点类型");
}
export function formatStocktakeStatusLabel(status) {
return {
LOCKED: "盘库中",
EXPORTED: "已导出",
IMPORTED: "已导入",
CONFIRMED: "已确认",
CANCELED: "已取消"
}[normalizeDictionaryKey(status)] || dictionaryFallback(status, "-", "未识别盘库状态");
}
export function formatStocktakeDiffTypeLabel(type) {
return {
MATCH: "一致",
GAIN: "盘盈",
LOSS: "盘亏",
NEW_LOT: "新增批次",
MISSING: "缺失批次"
}[normalizeDictionaryKey(type)] || dictionaryFallback(type, "-", "未识别差异类型");
}
export function formatProductLabel(product) {
if (!product) {
return "-";
}
const code = product.item_code || product.product_code;
const name = product.item_name || product.product_name;
const version = product.version_no || product.product_version_no;
const parts = [code, name];
if (version) {
parts.push(version);
}
return parts.filter(Boolean).join(" · ");
}
export function formatBomLabel(bom) {
if (!bom) {
return "-";
}
return [bom.bom_code, bom.version_no].filter(Boolean).join(" · ");
}
export function formatRouteLabel(route) {
if (!route) {
return "-";
}
return [route.route_code, route.route_name, route.version_no].filter(Boolean).join(" · ");
}
export function buildVersionedRows(rows, { codeKey, idKey }) {
const groups = new Map();
rows.forEach((row) => {
const code = row[codeKey];
if (!groups.has(code)) {
groups.set(code, []);
}
groups.get(code).push(row);
});
return Array.from(groups.entries())
.map(([code, versionRows]) => {
const sortedRows = [...versionRows].sort((a, b) => Number(b[idKey] || 0) - Number(a[idKey] || 0));
const activeRow = sortedRows.find((row) => row.is_default) || sortedRows[0];
return {
code,
active_id: activeRow?.[idKey],
rows: sortedRows
};
})
.sort((a, b) => String(a.code || "").localeCompare(String(b.code || ""), "zh-CN", { numeric: true }));
}
+35
View File
@@ -0,0 +1,35 @@
export function formatDate(value) {
if (!value) {
return "-";
}
return String(value).slice(0, 10);
}
export function formatDateTime(value) {
if (!value) {
return "-";
}
return String(value).replace("T", " ").slice(0, 16);
}
export function formatAmount(value) {
return Number(value || 0).toLocaleString("zh-CN", {
minimumFractionDigits: 0,
maximumFractionDigits: 2
});
}
export function formatQty(value) {
return Number(value || 0).toLocaleString("zh-CN", {
minimumFractionDigits: 0,
maximumFractionDigits: 3
});
}
export function formatWeight(value) {
return `${formatQty(value)} kg`;
}
export function emptyToNull(value) {
return value === "" || value === undefined ? null : value;
}
+55
View File
@@ -0,0 +1,55 @@
export const ICON_PATHS = {
dashboard: ["M4 13h6v7H4z", "M14 4h6v16h-6z", "M4 4h6v5H4z"],
masterData: ["M5 5h14v4H5z", "M5 15h14v4H5z", "M8 9v6", "M16 9v6"],
order: ["M7 3h8l4 4v14H7z", "M15 3v5h5", "M9 12h8", "M9 16h6"],
warehouse: ["M4 9l8-5 8 5-8 5z", "M4 9v7l8 5 8-5V9", "M12 14v7"],
warehouseRaw: ["M4 8.5 12 4l8 4.5-8 4.5-8-4.5Z", "M4 12l8 4.5 8-4.5", "M4 15.5l8 4.5 8-4.5", "M8 6.2l8 4.6"],
warehouseSemi: ["M5 7h8.5a3.5 3.5 0 0 1 0 7H10", "M5 7v10h6", "M16 15.5l3 2.5-3 2.5", "M19 18h-8"],
warehouseFinished: ["M4 8.5 12 4l8 4.5v8.8L12 21l-8-3.7V8.5Z", "M4 8.5 12 13l8-4.5", "M12 13v8", "M9 16l1.7 1.7L15.5 13"],
warehouseAuxiliary: ["M5 18.5 14.5 9", "M13 7l4-4 4 4-4 4-4-4Z", "M4 20h7", "M15.5 14.5h4", "M17.5 12.5v4"],
warehouseScrap: ["M6 7h12l-1 13H7L6 7Z", "M9 7V4h6v3", "M4 7h16", "M9.5 11v5", "M14.5 11v5"],
warehouseReturn: ["M7 7h10l2 4H5l2-4Z", "M5 11v7h14v-7", "M9 15h6", "M10 13l-2 2 2 2", "M8 15h8"],
production: ["M12 3v3", "M12 18v3", "M4.8 7.2l2.1 2.1", "M17.1 16.7l2.1 2.1", "M3 12h3", "M18 12h3", "M7.2 19.2l2.1-2.1", "M16.7 6.9l2.1-2.1", "M8 12a4 4 0 1 0 8 0 4 4 0 0 0-8 0z"],
delivery: ["M3 7h11v10H3z", "M14 10h4l3 3v4h-7z", "M6.5 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z", "M17.5 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"],
finance: ["M4 19V5", "M4 19h16", "M8 15l3-4 3 2 5-7"],
system: ["M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M19.4 15a8 8 0 0 0 .1-5.8l-2.2.4a6.4 6.4 0 0 0-1.1-1.9l1.2-1.9a8 8 0 0 0-5.1-2.9l-.8 2.1a6.8 6.8 0 0 0-2.2 0l-.8-2.1a8 8 0 0 0-5.1 2.9l1.2 1.9a6.4 6.4 0 0 0-1.1 1.9l-2.2-.4a8 8 0 0 0 .1 5.8l2.1-.6a6.6 6.6 0 0 0 1.2 1.7l-1 2a8 8 0 0 0 5.1 2.8l.7-2.1a6.7 6.7 0 0 0 2.4 0l.7 2.1a8 8 0 0 0 5.1-2.8l-1-2a6.6 6.6 0 0 0 1.2-1.7z"],
plus: ["M12 5v14", "M5 12h14"],
minus: ["M5 12h14"],
save: ["M5 4h12l2 2v14H5z", "M8 4v6h8V4", "M8 16h8", "M10 19v-3"],
edit: ["M5 19h4l10-10a2.8 2.8 0 0 0-4-4L5 15z", "M13.5 6.5l4 4", "M4 21h16"],
delete: ["M4 7h16", "M9 7V4h6v3", "M7 7l1 13h8l1-13", "M10.5 11v5", "M13.5 11v5"],
view: ["M3 12s3.5-6 9-6 9 6 9 6-3.5 6-9 6-9-6-9-6z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
import: ["M12 3v10", "M8 9l4 4 4-4", "M5 15v3a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3v-3"],
export: ["M12 21V11", "M8 15l4-4 4 4", "M5 9V6a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v3"],
upload: ["M12 21V9", "M8 13l4-4 4 4", "M5 5h14"],
download: ["M12 3v12", "M8 11l4 4 4-4", "M5 19h14"],
authorize: ["M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "M3 21c0-3.4 2.7-6 6-6 1.4 0 2.7.4 3.7 1.1", "M17 14v7", "M13.5 17.5h7"],
check: ["M5 12.5l4.2 4.2L19 7"],
reject: ["M6 6l12 12", "M18 6L6 18"],
rowAdd: ["M4 6h10", "M4 12h8", "M4 18h10", "M17 10v8", "M13 14h8"],
zoomIn: ["M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z", "M16 16l5 5", "M10.5 7.5v6", "M7.5 10.5h6"],
zoomOut: ["M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z", "M16 16l5 5", "M7.5 10.5h6"],
fitScreen: ["M4 9V4h5", "M15 4h5v5", "M20 15v5h-5", "M9 20H4v-5", "M9 4 4 9", "M15 4l5 5", "M20 15l-5 5", "M4 15l5 5"],
arrowRight: ["M5 12h14", "M13 6l6 6-6 6"],
arrowLeft: ["M19 12H5", "M11 6l-6 6 6 6"],
print: ["M7 8V4h10v4", "M7 17H5a2 2 0 0 1-2-2v-3a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v3a2 2 0 0 1-2 2h-2", "M7 14h10v7H7z"],
cursorClick: ["M5 4l9 16 1.6-6.2L22 12z", "M13.5 13.5 19 19"],
refresh: ["M20 6v5h-5", "M4 18v-5h5", "M18.5 9A7 7 0 0 0 6.8 6.8L4 11", "M5.5 15a7 7 0 0 0 11.7 2.2L20 13"],
ledger: ["M6 4h12v16H6z", "M9 8h6", "M9 12h6", "M9 16h4"],
stocktake: ["M5 5h14v14H5z", "M8 9h8", "M8 13h5", "M8 17h3"],
close: ["M6 6l12 12", "M18 6L6 18"],
search: ["M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z", "M16 16l5 5"],
warning: ["M12 4l9 16H3z", "M12 9v5", "M12 17h.01"],
supplier: ["M5 4h14v16H5z", "M8 8h8", "M8 12h8", "M8 16h5"],
material: ["M6 8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z", "M13 6h8v6h-8z", "M13 16h8", "M3 20c0-2.2 1.4-4 3-4s3 1.8 3 4"],
customer: ["M12 5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M5 21c0-3.3 3-6 7-6s7 2.7 7 6", "M17 8h4", "M17 12h4"],
productSpec: ["M5 4h14v16H5z", "M8 8h8", "M8 12h8", "M8 16h4"],
employee: ["M12 5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M5 21c0-3.3 3-6 7-6s7 2.7 7 6"],
purchaseOrder: ["M6 4h10l3 3v13H6z", "M16 4v4h3", "M8 11h5", "M8 15h4", "M14 16l2 2 4-5"],
receipt: ["M4 9l8-4 8 4-8 4z", "M4 13v4l8 4 8-4v-4", "M12 3v8", "M9 8l3 3 3-3"],
quality: ["M5 12l5 5 10-10", "M4 4h16v16H4z"],
workOrder: ["M7 4h10v16H7z", "M9 4V2h6v2", "M10 9h4", "M10 13h3", "M15.5 15.5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"],
operationReport: ["M5 5h14v14H5z", "M8 9h4", "M8 13h4", "M15 9l3 3-3 3", "M14 17h4"],
permissions: ["M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M5 21c0-3.3 3-6 7-6s7 2.7 7 6", "M17 5h4v4"],
extension: ["M7 4h6v4h4v6h-4v6H7v-6H3V8h4z", "M9 6V2", "M11 6V2", "M9 22v-4", "M11 22v-4"]
};

Some files were not shown because too many files have changed in this diff Show More