完善系统拓展与原材料生命周期视图

This commit is contained in:
汤学会 2026-06-14 20:38:53 +08:00
parent 453b19399b
commit df9a4b485d
14 changed files with 5960 additions and 384 deletions

162
.impeccable/design.json Normal file
View File

@ -0,0 +1,162 @@
{
"schemaVersion": 2,
"generatedAt": "2026-06-12T00:00:00+08:00",
"title": "Design System: ForgeFlow ERP",
"extensions": {
"colorMeta": {
"canvas": {
"role": "neutral",
"displayName": "ERP Canvas",
"canonical": "#f0f2f5",
"tonalRamp": ["#ffffff", "#f5f7fb", "#f0f2f5", "#e9edf3"]
},
"surface": {
"role": "neutral",
"displayName": "Work Surface",
"canonical": "#ffffff",
"tonalRamp": ["#ffffff", "#fbfdff", "#f8fafc"]
},
"primary": {
"role": "primary",
"displayName": "Operational Blue",
"canonical": "#409eff",
"tonalRamp": ["#ecf5ff", "#d9ecff", "#409eff", "#2563eb"]
},
"success": {
"role": "semantic",
"displayName": "Pass Green",
"canonical": "#67c23a",
"tonalRamp": ["#f0f9eb", "#e1f3d8", "#67c23a"]
},
"warning": {
"role": "semantic",
"displayName": "Review Amber",
"canonical": "#e6a23c",
"tonalRamp": ["#fdf6ec", "#faecd8", "#e6a23c"]
},
"danger": {
"role": "semantic",
"displayName": "Stop Red",
"canonical": "#f56c6c",
"tonalRamp": ["#fef0f0", "#fde2e2", "#f56c6c"]
}
},
"typographyMeta": {
"title": {
"displayName": "Title",
"purpose": "Panel and drawer titles inside authenticated product screens."
},
"body": {
"displayName": "Body",
"purpose": "Chinese-first operational UI text, descriptions, and table cells."
},
"label": {
"displayName": "Label",
"purpose": "Field labels, chips, table headers, and compact metadata."
}
},
"shadows": [
{
"name": "low-surface",
"value": "0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06)",
"purpose": "Tables, compact repeated cards, and low emphasis containers."
},
{
"name": "panel-surface",
"value": "0 12px 28px rgba(31, 45, 61, 0.12), 0 3px 8px rgba(31, 45, 61, 0.08)",
"purpose": "Main panels and large workflow containers."
},
{
"name": "floating-surface",
"value": "0 20px 46px rgba(31, 45, 61, 0.16), 0 8px 18px rgba(31, 45, 61, 0.1)",
"purpose": "Drawers, dropdowns, popovers, and overlays."
}
],
"motion": [
{
"name": "fast-state",
"value": "140ms ease",
"purpose": "Small button, link, and row hover feedback."
},
{
"name": "normal-state",
"value": "220ms cubic-bezier(0.2, 0.8, 0.2, 1)",
"purpose": "Drawer and panel state transitions."
}
],
"breakpoints": [
{
"name": "tablet",
"value": "860px"
},
{
"name": "desktop-collapse",
"value": "1180px"
}
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "Main action button for saving, creating, and confirming high-value ERP actions.",
"html": "<button class=\"ds-btn-primary\">Save document</button>",
"css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 0 14px; border: 1px solid #409eff; border-radius: 12px; background: #409eff; color: #ffffff; font: 700 13px/1 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; cursor: pointer; transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } .ds-btn-primary:hover { background: #2563eb; border-color: #2563eb; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); }"
},
{
"name": "Status Badge",
"kind": "chip",
"refersTo": "status-primary",
"description": "Compact semantic status label used across tables and detail drawers.",
"html": "<span class=\"ds-status\"><span></span>Approved</span>",
"css": ".ds-status { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; padding: 2px 8px; border: 1px solid #d9ecff; border-radius: 4px; background: #ecf5ff; color: #409eff; font: 600 12px/1.45 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; white-space: nowrap; } .ds-status span { width: 6px; height: 6px; border-radius: 999px; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); }"
},
{
"name": "Data Table Container",
"kind": "custom",
"refersTo": "table-container",
"description": "Scrollable table frame with sticky headers for dense ERP records.",
"html": "<div class=\"ds-table\"><table><thead><tr><th>Document</th><th>Status</th></tr></thead><tbody><tr><td>PO-001</td><td>Pending</td></tr></tbody></table></div>",
"css": ".ds-table { overflow: auto; border: 1px solid rgba(209, 218, 232, 0.96); border-radius: 16px; background: #ffffff; box-shadow: 0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06); } .ds-table table { width: 100%; border-collapse: separate; border-spacing: 0; font: 14px/1.45 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; } .ds-table th { position: sticky; top: 0; background: #f8fbff; color: #334155; font-size: 12px; font-weight: 750; text-align: left; } .ds-table th, .ds-table td { padding: 12px; border-bottom: 1px solid #ebeef5; } .ds-table td { color: #42526b; }"
}
],
"narrative": {
"northStar": "The Clear Production Desk",
"overview": "ForgeFlow ERP is a clear, light, high-density operational interface for a hardware manufacturing company. It should help users trace orders, materials, batches, operations, documents, and costs without visual noise.",
"keyCharacteristics": [
"Light routine ERP surfaces",
"Restrained blue action and selection language",
"Chinese-first practical product typography",
"Tables, drawers, forms, and status badges as core vocabulary",
"Formal document visuals only for business documents"
],
"rules": [
{
"name": "The Blue Rarity Rule",
"body": "Blue identifies current position and next action; it is not broad decoration.",
"section": "colors"
},
{
"name": "The Fixed Scale Rule",
"body": "Product screens use fixed UI type sizes, not fluid hero headings.",
"section": "typography"
},
{
"name": "The One Depth Signal Rule",
"body": "Do not stack thick borders, large radius, glossy gradients, and heavy shadow on one element.",
"section": "elevation"
}
],
"dos": [
"Do keep routine ERP screens light, restrained, and table-first.",
"Do standardize table, drawer, form, status badge, pagination, and action button behavior before styling individual pages.",
"Do provide loading, empty, error, no-permission, disabled, saving, and archive-failed states for critical flows."
],
"donts": [
"Don't make screens look obviously AI-generated through generic card grids, decorative gradients, glass effects, or ornamental animation.",
"Don't use side accent borders greater than 1px on cards or callouts.",
"Don't let old dark industrial styling bleed into everyday ERP work screens."
]
}
}

View File

@ -0,0 +1,6 @@
{
"files": ["frontend/index.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -43,6 +43,7 @@ from app.schemas.dashboard import (
SalesOrderLifecycleRead,
)
from app.services.auth import require_authenticated_user
from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph
from app.services.mock_data import get_dashboard_payload
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
@ -358,7 +359,7 @@ def _material_lot_rows(limit: int = 100):
.outerjoin(issue_subquery, issue_subquery.c.source_lot_id == StockLot.id)
.outerjoin(finished_subquery, finished_subquery.c.source_material_lot_id == StockLot.id)
.outerjoin(delivery_subquery, delivery_subquery.c.source_material_lot_id == StockLot.id)
.where(Item.item_type == "RAW_MATERIAL")
.where(Item.item_type == "RAW_MATERIAL", Warehouse.warehouse_type == "RAW")
.order_by(StockLot.created_at.desc(), StockLot.id.desc())
.limit(limit)
)
@ -387,13 +388,10 @@ def list_dashboard_material_lots(
return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows]
@router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead)
def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead:
lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first()
if not lot_row:
raise HTTPException(status_code=404, detail="原材料批次不存在")
lot = DashboardMaterialLotRead.model_validate(dict(lot_row))
def _build_legacy_material_lot_lifecycle_graph(
db: Session,
lot: DashboardMaterialLotRead,
) -> tuple[list[LifecycleNode], list[LifecycleEdge]]:
nodes: list[LifecycleNode] = []
edges: list[LifecycleEdge] = []
node_ids: set[str] = set()
@ -465,7 +463,7 @@ def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> Ma
.join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
.join(Item, Item.id == WorkOrder.product_item_id)
.outerjoin(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
.where(WorkOrderMaterialIssue.source_lot_id == lot_id)
.where(WorkOrderMaterialIssue.source_lot_id == lot.lot_id)
.order_by(WorkOrderMaterialIssue.issue_time, WorkOrderMaterialIssue.id)
).mappings().all()
work_order_ids = sorted({row["work_order_id"] for row in issues})
@ -741,6 +739,19 @@ def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> Ma
)
add_edge(_edge(f"fg-{row['completion_item_id']}", node_id, "库存发货"))
return nodes, edges
@router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead)
def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead:
lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first()
if not lot_row:
raise HTTPException(status_code=404, detail="原材料批次不存在")
lot = DashboardMaterialLotRead.model_validate(dict(lot_row))
nodes, edges = build_material_lot_lifecycle_graph(db, lot)
if not any(node.type == "production_ledger" for node in nodes):
nodes, edges = _build_legacy_material_lot_lifecycle_graph(db, lot)
return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges)

View File

@ -0,0 +1,456 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from sqlalchemy import false, select
from sqlalchemy.orm import Session, aliased
from app.models.master_data import Item
from app.models.operations import Delivery, DeliveryItem, OperationReport, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot
from app.models.org import Employee
from app.models.sales import Customer, SalesOrder
from app.schemas.dashboard import DashboardMaterialLotRead, LifecycleDetailItem, LifecycleEdge, LifecycleNode
from app.services.document_archives import production_document_batch_no_from_text
from app.services.system_config import get_smart_operation_report_enabled
def _num(value: Any) -> float:
if value is None:
return 0.0
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _fmt_qty(value: Any, digits: int = 2) -> str:
numeric = _num(value)
if abs(numeric - round(numeric)) < 0.000001:
return f"{int(round(numeric))}"
return f"{numeric:.{digits}f}".rstrip("0").rstrip(".")
def _fmt_weight(value: Any) -> str:
return f"{_fmt_qty(value, 3)} kg"
def _fmt_datetime(value: Any) -> str:
if not value:
return "-"
return str(value).replace("T", " ")[:19]
def _detail(label: str, value: Any) -> LifecycleDetailItem:
text = "-" if value is None or value == "" else str(value)
return LifecycleDetailItem(label=label, value=text)
def _node(
*,
node_id: str,
node_type: str,
title: str,
stage: str,
level: int,
subtitle: str | None = None,
status: str | None = None,
tone: str = "normal",
summary: str | None = None,
metrics: list[tuple[str, Any]] | None = None,
details: list[tuple[str, Any]] | None = None,
raw: dict[str, Any] | None = None,
) -> LifecycleNode:
return LifecycleNode(
id=node_id,
type=node_type,
title=title,
subtitle=subtitle,
status=status,
stage=stage,
level=level,
tone=tone,
summary=summary,
metrics=[_detail(label, value) for label, value in (metrics or [])],
details=[_detail(label, value) for label, value in (details or [])],
raw=raw or {},
)
def _edge(source: str, target: str, label: str | None = None) -> LifecycleEdge:
return LifecycleEdge(id=f"{source}->{target}", source=source, target=target, label=label)
def _append_node(nodes: list[LifecycleNode], node_ids: set[str], node: LifecycleNode) -> None:
if node.id not in node_ids:
node_ids.add(node.id)
nodes.append(node)
def _append_edge(edges: list[LifecycleEdge], edge_ids: set[str], node_ids: set[str], edge: LifecycleEdge) -> None:
if edge.source in node_ids and edge.target in node_ids and edge.id not in edge_ids:
edge_ids.add(edge.id)
edges.append(edge)
def _settlement_group_key(txn: ProductionBatchLedgerTxn) -> tuple[str, str]:
batch_no = production_document_batch_no_from_text(txn.remark)
if batch_no:
return f"batch:{batch_no}", batch_no
return f"txn:{txn.id}", f"{txn.txn_type}-{txn.id}"
def _txn_node_type(txn_type: str) -> str:
return {
"成品入库": "finished_inbound",
"生产余料入库": "surplus_inbound",
"生产废料入库": "scrap_inbound",
"重新开工": "ledger_reopen",
}.get(txn_type, "ledger_writeoff")
def build_material_lot_lifecycle_graph(
db: Session,
lot: DashboardMaterialLotRead,
) -> tuple[list[LifecycleNode], list[LifecycleEdge]]:
nodes: list[LifecycleNode] = []
edges: list[LifecycleEdge] = []
node_ids: set[str] = set()
edge_ids: set[str] = set()
raw_node_id = f"raw-lot-{lot.lot_id}"
_append_node(
nodes,
node_ids,
_node(
node_id=raw_node_id,
node_type="raw_material_lot",
title=lot.lot_no,
subtitle=f"{lot.material_code} · {lot.material_name}",
status=lot.status,
stage="原料入库",
level=0,
summary="原材料批次入库后,按生产台账追踪生产出库、库外余额、入库结算和销售去向。",
metrics=[
("入库重量", _fmt_weight(lot.inbound_weight_kg)),
("剩余重量", _fmt_weight(lot.remaining_weight_kg)),
],
details=[
("原材料编码", lot.material_code),
("原材料名称", lot.material_name),
("仓库", lot.warehouse_name),
],
raw=lot.model_dump(),
),
)
ledger_rows = db.execute(
select(
ProductionBatchLedger,
Item.item_code.label("product_code"),
Item.item_name.label("product_name"),
)
.join(Item, Item.id == ProductionBatchLedger.product_item_id)
.where(ProductionBatchLedger.material_lot_id == lot.lot_id)
.order_by(ProductionBatchLedger.id)
).all()
ledgers = [row.ProductionBatchLedger for row in ledger_rows]
ledger_ids = [int(ledger.id) for ledger in ledgers]
txns_by_ledger_id: dict[int, list[ProductionBatchLedgerTxn]] = {ledger_id: [] for ledger_id in ledger_ids}
if ledger_ids:
txns = db.scalars(
select(ProductionBatchLedgerTxn)
.where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids))
.order_by(ProductionBatchLedgerTxn.biz_time, ProductionBatchLedgerTxn.id)
).all()
for txn in txns:
txns_by_ledger_id.setdefault(int(txn.production_ledger_id), []).append(txn)
finished_node_id_by_lot_id: dict[int, str] = {}
settlement_txn_types = {"成品入库", "生产余料入库", "生产废料入库", "结单核销", "该批材料结单", "重新开工"}
for row in ledger_rows:
ledger = row.ProductionBatchLedger
ledger_id = int(ledger.id)
ledger_node_id = f"ledger-{ledger_id}"
_append_node(
nodes,
node_ids,
_node(
node_id=ledger_node_id,
node_type="production_ledger",
title=f"{ledger.material_lot_no} · {row.product_name}",
subtitle=row.product_code,
status=ledger.status,
stage="生产台账",
level=1,
summary="一行生产台账代表同一材料库存批次号与产品组合,是材料批次闭环的主链路。",
metrics=[
("累计领料", _fmt_weight(ledger.total_issued_weight_kg)),
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
("成品入库", _fmt_qty(ledger.finished_inbound_qty)),
("余料入库", _fmt_weight(ledger.surplus_inbound_weight_kg)),
("废料入库", _fmt_weight(ledger.scrap_inbound_weight_kg)),
],
details=[
("产品编码", row.product_code),
("产品名称", row.product_name),
("首次领料", _fmt_datetime(ledger.first_issue_time)),
("最近领料", _fmt_datetime(ledger.last_issue_time)),
("结单次数", ledger.lock_count),
("备注", ledger.remark),
],
raw={
"production_ledger_id": ledger.id,
"material_lot_id": ledger.material_lot_id,
"product_item_id": ledger.product_item_id,
},
),
)
_append_edge(edges, edge_ids, node_ids, _edge(raw_node_id, ledger_node_id, "进入生产台账"))
issue_txns = [txn for txn in txns_by_ledger_id.get(ledger_id, []) if txn.txn_type == "生产出库"]
issue_node_ids: list[str] = []
for txn in issue_txns:
issue_node_id = f"issue-txn-{txn.id}"
issue_node_ids.append(issue_node_id)
_append_node(
nodes,
node_ids,
_node(
node_id=issue_node_id,
node_type="production_issue_txn",
title=f"生产出库 {txn.id}",
subtitle=ledger.material_lot_no,
status="POSTED",
tone="success",
stage="生产出库",
level=2,
summary="生产出库事务累加到生产台账,并增加该批材料的库外余额。",
metrics=[
("出库重量", _fmt_weight(txn.weight_delta_kg)),
("出库后库外余额", _fmt_weight(txn.outside_weight_after_kg)),
],
details=[
("业务时间", _fmt_datetime(txn.biz_time)),
("来源单据", txn.source_doc_type),
("来源ID", txn.source_doc_id),
("备注", txn.remark),
],
raw={"txn_id": txn.id, "production_ledger_id": txn.production_ledger_id},
),
)
_append_edge(edges, edge_ids, node_ids, _edge(ledger_node_id, issue_node_id, "生产出库"))
outside_node_id = f"outside-balance-{ledger_id}"
_append_node(
nodes,
node_ids,
_node(
node_id=outside_node_id,
node_type="ledger_outside_balance",
title="库外余额",
subtitle=ledger.material_lot_no,
status=ledger.status,
stage="库外闭环",
level=3,
summary="库外余额用于衡量该材料批次尚未通过成品、余料、废料入库或结单核销闭环的重量。",
metrics=[
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
("累计领料", _fmt_weight(ledger.total_issued_weight_kg)),
("结单核销", _fmt_weight(ledger.writeoff_weight_kg)),
],
raw={"production_ledger_id": ledger.id},
),
)
if issue_node_ids:
for issue_node_id in issue_node_ids:
_append_edge(edges, edge_ids, node_ids, _edge(issue_node_id, outside_node_id, "形成库外余额"))
else:
_append_edge(edges, edge_ids, node_ids, _edge(ledger_node_id, outside_node_id, "库外余额"))
settlement_groups: dict[tuple[str, str], list[ProductionBatchLedgerTxn]] = {}
for txn in txns_by_ledger_id.get(ledger_id, []):
if txn.txn_type in settlement_txn_types:
settlement_groups.setdefault(_settlement_group_key(txn), []).append(txn)
for group_index, ((group_key, group_title), group_txns) in enumerate(settlement_groups.items(), start=1):
settlement_node_id = f"settlement-{ledger_id}-{group_key}"
total_weight_delta = sum(_num(txn.weight_delta_kg) for txn in group_txns)
_append_node(
nodes,
node_ids,
_node(
node_id=settlement_node_id,
node_type="production_ledger_settlement",
title=group_title,
subtitle=f"{len(group_txns)} 笔台账事务",
status="POSTED",
tone="success",
stage="入库结算",
level=4,
summary="按生产单据批次号聚合成品、余料、废料入库及结单核销事务;没有批次号的事务独立成组。",
metrics=[
("事务数", len(group_txns)),
("重量变化", _fmt_weight(total_weight_delta)),
],
details=[
("分组", group_title),
("序号", group_index),
],
raw={"production_ledger_id": ledger.id, "group_key": group_key},
),
)
_append_edge(edges, edge_ids, node_ids, _edge(outside_node_id, settlement_node_id, "入库结算"))
txns_by_type: dict[str, list[ProductionBatchLedgerTxn]] = {}
for txn in group_txns:
txns_by_type.setdefault(str(txn.txn_type), []).append(txn)
for txn_type, type_txns in txns_by_type.items():
child_node_id = f"{_txn_node_type(txn_type)}-{ledger_id}-{group_key}"
child_weight_delta = sum(_num(txn.weight_delta_kg) for txn in type_txns)
child_qty_delta = sum(_num(txn.qty_delta) for txn in type_txns)
_append_node(
nodes,
node_ids,
_node(
node_id=child_node_id,
node_type=_txn_node_type(txn_type),
title=txn_type,
subtitle=group_title,
status="POSTED",
tone="success",
stage=txn_type,
level=5,
summary="该节点来自生产台账事务,并参与材料批次库外闭环。",
metrics=[
("数量变化", _fmt_qty(child_qty_delta)),
("重量变化", _fmt_weight(child_weight_delta)),
("事务数", len(type_txns)),
],
details=[
("最近库外余额", _fmt_weight(type_txns[-1].outside_weight_after_kg)),
("业务时间", _fmt_datetime(type_txns[-1].biz_time)),
],
raw={
"production_ledger_id": ledger.id,
"txn_ids": [txn.id for txn in type_txns],
"group_key": group_key,
},
),
)
_append_edge(edges, edge_ids, node_ids, _edge(settlement_node_id, child_node_id, txn_type))
if _txn_node_type(txn_type) == "finished_inbound":
for txn in type_txns:
if txn.source_line_id:
finished_node_id_by_lot_id[int(txn.source_line_id)] = child_node_id
if ledger_ids and get_smart_operation_report_enabled(db):
report_rows = db.execute(
select(
OperationReport,
Employee.employee_name.label("employee_name"),
)
.join(Employee, Employee.id == OperationReport.employee_id)
.where(OperationReport.production_ledger_id.in_(ledger_ids))
.order_by(OperationReport.start_time, OperationReport.id)
).all()
for row in report_rows:
report = row.OperationReport
node_id = f"report-{report.id}"
_append_node(
nodes,
node_ids,
_node(
node_id=node_id,
node_type="operation_report",
title=report.report_no,
subtitle=row.employee_name,
status="ABNORMAL" if report.is_abnormal else "NORMAL",
tone="danger" if report.is_abnormal else "success",
stage="现场报工证据",
level=2,
summary="只作为现场报工证据展示,不参与材料批次闭环判定。",
metrics=[
("报工数", _fmt_qty(report.report_qty)),
("合格数", _fmt_qty(report.good_qty)),
("报废数", _fmt_qty(report.scrap_qty)),
],
details=[
("员工", row.employee_name),
("开始", _fmt_datetime(report.start_time)),
("结束", _fmt_datetime(report.end_time)),
("来源", report.report_source),
],
raw={"report_id": report.id, "production_ledger_id": report.production_ledger_id},
),
)
_append_edge(edges, edge_ids, node_ids, _edge(f"ledger-{report.production_ledger_id}", node_id, "报工证据"))
finished_lot = aliased(StockLot)
order_customer = aliased(Customer)
direct_customer = aliased(Customer)
delivery_rows = db.execute(
select(
DeliveryItem.id.label("delivery_item_id"),
DeliveryItem.lot_id.label("delivery_lot_id"),
finished_lot.id.label("finished_lot_id"),
finished_lot.source_doc_id.label("production_ledger_id"),
finished_lot.lot_no.label("lot_no"),
Delivery.delivery_no.label("delivery_no"),
SalesOrder.order_no.label("order_no"),
direct_customer.customer_name.label("direct_customer_name"),
order_customer.customer_name.label("order_customer_name"),
Delivery.delivery_date.label("delivery_date"),
DeliveryItem.delivery_qty.label("delivery_qty"),
DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"),
DeliveryItem.line_amount.label("line_amount"),
DeliveryItem.status.label("status"),
)
.join(finished_lot, finished_lot.id == DeliveryItem.lot_id)
.join(Item, Item.id == finished_lot.item_id)
.join(Delivery, Delivery.id == DeliveryItem.delivery_id)
.outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id)
.outerjoin(order_customer, order_customer.id == SalesOrder.customer_id)
.outerjoin(direct_customer, direct_customer.id == Delivery.customer_id)
.where(
finished_lot.source_doc_id.in_(ledger_ids) if ledger_ids else false(),
finished_lot.source_doc_type.in_(["PRODUCTION_LEDGER_IN", "生产台账"]),
Item.item_type == "FINISHED_GOOD",
)
.order_by(Delivery.delivery_date, DeliveryItem.id)
).mappings().all()
for row in delivery_rows:
delivery_node_id = f"delivery-{row['delivery_item_id']}"
_append_node(
nodes,
node_ids,
_node(
node_id=delivery_node_id,
node_type="delivery",
title=row["delivery_no"],
subtitle=row["order_no"],
status=row["status"],
stage="销售去向",
level=6,
summary="销售出库从生产台账生成的成品库存批次追溯到材料批次。",
metrics=[
("发货数量", _fmt_qty(row["delivery_qty"])),
("发货重量", _fmt_weight(row["delivery_weight_kg"])),
],
details=[
("客户", row["direct_customer_name"] or row["order_customer_name"]),
("库存批次号", row["lot_no"]),
("发货时间", _fmt_datetime(row["delivery_date"])),
],
raw=dict(row),
),
)
source_node_id = finished_node_id_by_lot_id.get(int(row["delivery_lot_id"] or 0))
if not source_node_id:
source_node_id = f"ledger-{row['production_ledger_id']}"
_append_edge(edges, edge_ids, node_ids, _edge(source_node_id, delivery_node_id, "销售出库"))
return nodes, edges

View File

@ -0,0 +1,510 @@
from __future__ import annotations
import unittest
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.dashboard import get_material_lot_lifecycle, list_dashboard_material_lots # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Item, Warehouse # noqa: E402
from app.models.operations import Delivery, DeliveryItem, OperationReport, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402
from app.models.org import Department, Employee, SystemConfig # noqa: E402
from app.schemas.dashboard import DashboardMaterialLotRead # noqa: E402
from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph # noqa: E402
class DashboardMaterialLotLifecycleTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self.now = datetime(2026, 6, 14, 9, 0)
self._seed_lot_and_ledger()
def tearDown(self) -> None:
self.db.close()
def _seed_lot_and_ledger(self) -> None:
raw_item = Item(
id=1,
item_code="RM-001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
product = Item(
id=2,
item_code="FG-001",
item_name="钢制碗",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("0.5"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
lot = StockLot(
id=1,
lot_no="YL0001",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_id=1,
source_doc_type="期初入库",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("500"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("300"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
ledger = ProductionBatchLedger(
id=1,
material_lot_id=1,
material_lot_no="YL0001",
material_item_id=1,
product_item_id=2,
status="在生产",
miniapp_selectable=1,
total_issued_weight_kg=Decimal("120"),
outside_weight_kg=Decimal("42"),
finished_inbound_qty=Decimal("100"),
surplus_inbound_weight_kg=Decimal("18"),
scrap_inbound_weight_kg=Decimal("6"),
writeoff_weight_kg=Decimal("54"),
first_issue_time=self.now,
last_issue_time=self.now,
created_at=self.now,
updated_at=self.now,
)
dept = Department(
id=1,
dept_code="D-001",
dept_name="生产部",
dept_type="PRODUCTION",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
employee = Employee(
id=1,
employee_code="EMP-001",
employee_name="张三",
dept_id=1,
is_operator=1,
is_workshop_staff=1,
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
txns = [
ProductionBatchLedgerTxn(
id=1,
production_ledger_id=1,
txn_type="生产出库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("120"),
outside_weight_after_kg=Decimal("120"),
source_doc_type="库存流水",
source_doc_id=101,
biz_time=self.now,
operator_user_id=1,
remark="生产出库累计到生产台账",
created_at=self.now,
updated_at=self.now,
),
ProductionBatchLedgerTxn(
id=2,
production_ledger_id=1,
txn_type="成品入库",
qty_delta=Decimal("100"),
weight_delta_kg=Decimal("-50"),
outside_weight_after_kg=Decimal("70"),
source_doc_type="库存流水",
source_doc_id=102,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号PB20260614001",
created_at=self.now,
updated_at=self.now,
),
ProductionBatchLedgerTxn(
id=3,
production_ledger_id=1,
txn_type="生产余料入库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("-18"),
outside_weight_after_kg=Decimal("52"),
source_doc_type="库存流水",
source_doc_id=103,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号PB20260614001",
created_at=self.now,
updated_at=self.now,
),
ProductionBatchLedgerTxn(
id=4,
production_ledger_id=1,
txn_type="生产废料入库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("-6"),
outside_weight_after_kg=Decimal("46"),
source_doc_type="库存流水",
source_doc_id=104,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号PB20260614001",
created_at=self.now,
updated_at=self.now,
),
ProductionBatchLedgerTxn(
id=5,
production_ledger_id=1,
txn_type="结单核销",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("-4"),
outside_weight_after_kg=Decimal("42"),
source_doc_type="结单",
source_doc_id=105,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号PB20260614001",
created_at=self.now,
updated_at=self.now,
),
]
report = OperationReport(
id=1,
report_no="BG20260614001",
production_ledger_id=1,
employee_id=1,
report_source="MINIAPP",
start_time=self.now,
end_time=self.now,
report_qty=Decimal("100"),
good_qty=Decimal("96"),
scrap_qty=Decimal("4"),
rework_qty=Decimal("0"),
downtime_minutes=Decimal("0"),
extra_cost_amount=Decimal("0"),
is_abnormal=0,
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([raw_item, product, warehouse, lot, ledger, dept, employee, *txns, report])
self.db.commit()
def _lot_read(self) -> DashboardMaterialLotRead:
return DashboardMaterialLotRead(
lot_id=1,
lot_no="YL0001",
material_item_id=1,
material_code="RM-001",
material_name="冷轧钢板",
warehouse_name="原材料库",
inbound_weight_kg=500,
remaining_weight_kg=300,
unit_cost=4,
inbound_amount=2000,
issued_batch_count=0,
issued_weight_kg=0,
finished_batch_count=0,
finished_qty=0,
delivery_count=0,
status="AVAILABLE",
quality_status="PASS",
created_at=self.now,
)
def test_production_ledger_graph_replaces_work_order_main_chain(self) -> None:
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
node_types = {node.type for node in nodes}
self.assertIn("production_ledger", node_types)
self.assertIn("production_issue_txn", node_types)
self.assertIn("ledger_outside_balance", node_types)
self.assertIn("production_ledger_settlement", node_types)
self.assertIn("finished_inbound", node_types)
self.assertIn("surplus_inbound", node_types)
self.assertIn("scrap_inbound", node_types)
self.assertNotIn("work_order", node_types)
def test_production_ledger_title_stage_and_outside_balance_metric(self) -> None:
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
ledger_node = next(node for node in nodes if node.type == "production_ledger")
metrics = {item.label: item.value for item in ledger_node.metrics}
self.assertEqual(ledger_node.title, "YL0001 · 钢制碗")
self.assertEqual(ledger_node.stage, "生产台账")
self.assertEqual(metrics["库外余额"], "42 kg")
def test_material_lot_dashboard_list_excludes_scrap_warehouse_lots(self) -> None:
scrap_warehouse = Warehouse(
id=3,
warehouse_code="WH-SCRAP",
warehouse_name="废料库",
warehouse_type="SCRAP",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
scrap_lot = StockLot(
id=30,
lot_no="SCRAPI-YL0001-001",
lot_role="PRODUCTION_SCRAP",
item_id=1,
warehouse_id=3,
source_doc_type="PRODUCTION_SCRAP_IN",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("5"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("5"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="SCRAP",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([scrap_warehouse, scrap_lot])
self.db.commit()
rows = list_dashboard_material_lots(limit=100, db=self.db)
lot_nos = {row.lot_no for row in rows}
self.assertIn("YL0001", lot_nos)
self.assertNotIn("SCRAPI-YL0001-001", lot_nos)
self.assertTrue(all(row.warehouse_name == "原材料库" for row in rows))
def test_operation_report_respects_smart_operation_report_switch(self) -> None:
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
self.assertIn("operation_report", {node.type for node in nodes})
self.db.add(
SystemConfig(
config_code="SMART_OPERATION_REPORT_ENABLED",
config_name="对接智能报工小程序",
config_value="关闭",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
)
self.db.commit()
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
self.assertNotIn("operation_report", {node.type for node in nodes})
def test_route_returns_new_graph_when_production_ledger_exists(self) -> None:
response = get_material_lot_lifecycle(1, self.db)
node_types = {node.type for node in response.nodes}
self.assertIn("production_ledger", node_types)
self.assertNotIn("work_order", node_types)
def test_route_legacy_fallback_does_not_crash_without_production_ledger(self) -> None:
legacy_lot = StockLot(
id=2,
lot_no="YL9999",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_id=1,
source_doc_type="期初入库",
source_doc_id=2,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("80"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("80"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.db.add(legacy_lot)
self.db.commit()
response = get_material_lot_lifecycle(2, self.db)
self.assertEqual(response.lot.lot_no, "YL9999")
node_types = {node.type for node in response.nodes}
self.assertIn("raw_material_lot", node_types)
self.assertNotIn("production_ledger", node_types)
def test_delivery_edges_target_finished_inbound_for_exact_finished_lot(self) -> None:
finished_lot_a = StockLot(
id=10,
lot_no="FGL-A",
lot_role="INVENTORY",
item_id=2,
warehouse_id=1,
source_doc_type="PRODUCTION_LEDGER_IN",
source_doc_id=1,
inbound_qty=Decimal("40"),
inbound_weight_kg=Decimal("20"),
remaining_qty=Decimal("40"),
remaining_weight_kg=Decimal("20"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("8"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
finished_lot_b = StockLot(
id=11,
lot_no="FGL-B",
lot_role="INVENTORY",
item_id=2,
warehouse_id=1,
source_doc_type="PRODUCTION_LEDGER_IN",
source_doc_id=1,
inbound_qty=Decimal("60"),
inbound_weight_kg=Decimal("30"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("0"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("8"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
finished_txn_a = ProductionBatchLedgerTxn(
id=20,
production_ledger_id=1,
txn_type="成品入库",
qty_delta=Decimal("40"),
weight_delta_kg=Decimal("-20"),
outside_weight_after_kg=Decimal("70"),
source_doc_type="库存流水",
source_doc_id=201,
source_line_id=10,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号PB-FGL-A",
created_at=self.now,
updated_at=self.now,
)
finished_txn_b = ProductionBatchLedgerTxn(
id=21,
production_ledger_id=1,
txn_type="成品入库",
qty_delta=Decimal("60"),
weight_delta_kg=Decimal("-30"),
outside_weight_after_kg=Decimal("42"),
source_doc_type="库存流水",
source_doc_id=202,
source_line_id=11,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号PB-FGL-B",
created_at=self.now,
updated_at=self.now,
)
delivery = Delivery(
id=1,
delivery_no="FH20260614001",
warehouse_id=1,
delivery_date=self.now,
status="POSTED",
created_at=self.now,
updated_at=self.now,
)
delivery_item = DeliveryItem(
id=1,
delivery_id=1,
line_no=1,
product_item_id=2,
lot_id=11,
delivery_qty=Decimal("10"),
delivery_weight_kg=Decimal("5"),
unit_price=Decimal("12"),
line_amount=Decimal("120"),
status="POSTED",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([finished_lot_a, finished_lot_b, finished_txn_a, finished_txn_b, delivery, delivery_item])
self.db.commit()
nodes, edges = build_material_lot_lifecycle_graph(self.db, self._lot_read())
delivery_node = next(node for node in nodes if node.type == "delivery")
finished_a = next(node for node in nodes if node.type == "finished_inbound" and node.subtitle == "PB-FGL-A")
finished_b = next(node for node in nodes if node.type == "finished_inbound" and node.subtitle == "PB-FGL-B")
delivery_source_ids = {edge.source for edge in edges if edge.target == delivery_node.id}
self.assertIn(finished_b.id, delivery_source_ids)
self.assertNotIn(finished_a.id, delivery_source_ids)
self.assertNotIn("ledger-1", delivery_source_ids)
def test_reopen_txn_uses_reopen_node_type_not_writeoff(self) -> None:
self.db.add(
ProductionBatchLedgerTxn(
id=30,
production_ledger_id=1,
txn_type="重新开工",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("0"),
outside_weight_after_kg=Decimal("42"),
source_doc_type="生产台账",
source_doc_id=1,
biz_time=self.now,
operator_user_id=1,
remark="重新开工",
created_at=self.now,
updated_at=self.now,
)
)
self.db.commit()
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
reopen_node = next(node for node in nodes if node.title == "重新开工")
self.assertEqual(reopen_node.type, "ledger_reopen")
self.assertEqual(reopen_node.stage, "重新开工")
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1842,37 +1842,6 @@ a {
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.extension-hero {
align-items: center;
}
.extension-status-card {
display: grid;
gap: 8px;
align-content: center;
padding: 20px;
border-radius: 24px;
border: 1px solid rgba(126, 198, 255, 0.24);
background: rgba(255, 255, 255, 0.06);
}
.extension-status-card strong {
color: #f5fbff;
font-size: 18px;
}
.compact-extension-form {
max-width: 1180px;
}
.extension-preview-field {
justify-content: end;
}
.extension-preview-banner {
max-width: 100%;
}
.panel::after {
content: "";
position: absolute;
@ -2502,6 +2471,66 @@ a {
cursor: grabbing;
}
.dag-legend {
position: absolute;
z-index: 4;
top: 18px;
right: 18px;
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: min(560px, calc(100% - 36px));
padding: 10px;
border-radius: 18px;
border: 1px solid rgba(144, 215, 255, 0.22);
background:
linear-gradient(135deg, rgba(11, 29, 45, 0.9), rgba(6, 16, 27, 0.78)),
radial-gradient(circle at 18% 18%, rgba(219, 239, 255, 0.16), transparent 34%);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(16px);
}
.dag-legend span {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 9px;
border-radius: 999px;
border: 1px solid rgba(144, 215, 255, 0.14);
background: rgba(79, 180, 255, 0.07);
color: #d9edff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
}
.dag-legend i {
width: 10px;
height: 10px;
border-radius: 999px;
box-shadow: 0 0 14px currentColor;
}
.dag-legend-ledger {
color: #8fd8ff;
background: linear-gradient(135deg, #d7f2ff, #4fb4ff);
}
.dag-legend-balance {
color: #ffd07d;
background: linear-gradient(135deg, #fff0bd, #f29d52);
}
.dag-legend-inbound {
color: #80f2bc;
background: linear-gradient(135deg, #d9ffe9, #31c983);
}
.dag-legend-evidence {
color: #bac8d8;
background: linear-gradient(135deg, #f3f7fb, #7d91a8);
}
.dag-transform {
transform-origin: 0 0;
}
@ -2735,6 +2764,84 @@ a {
background: linear-gradient(145deg, rgba(42, 20, 24, 0.98), rgba(42, 31, 23, 0.94));
}
.dag-node-raw {
border-color: rgba(144, 215, 255, 0.3);
background:
linear-gradient(145deg, rgba(12, 34, 54, 0.98), rgba(10, 24, 38, 0.95)),
radial-gradient(circle at 12% 10%, rgba(185, 219, 247, 0.2), transparent 36%);
}
.dag-node-ledger {
border-color: rgba(144, 215, 255, 0.52);
background:
linear-gradient(145deg, rgba(9, 30, 52, 0.98), rgba(14, 48, 72, 0.95)),
radial-gradient(circle at 16% 12%, rgba(79, 180, 255, 0.28), transparent 38%);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.3), 0 0 24px rgba(79, 180, 255, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
.dag-node-issue {
border-color: rgba(117, 197, 255, 0.4);
background:
linear-gradient(145deg, rgba(10, 26, 43, 0.98), rgba(16, 54, 78, 0.94)),
repeating-linear-gradient(135deg, rgba(144, 215, 255, 0.07) 0 1px, transparent 1px 8px);
}
.dag-node-balance {
border-color: rgba(255, 188, 91, 0.58);
background:
linear-gradient(145deg, rgba(48, 34, 18, 0.98), rgba(46, 49, 34, 0.93)),
radial-gradient(circle at 18% 12%, rgba(242, 157, 82, 0.3), transparent 38%);
}
.dag-node-settlement {
border-color: rgba(178, 221, 255, 0.54);
background:
linear-gradient(145deg, rgba(16, 33, 52, 0.98), rgba(28, 47, 64, 0.94)),
linear-gradient(90deg, rgba(219, 239, 255, 0.12), transparent 44%);
}
.dag-node-inbound {
border-color: rgba(98, 230, 162, 0.48);
background:
linear-gradient(145deg, rgba(11, 42, 36, 0.98), rgba(12, 55, 46, 0.94)),
radial-gradient(circle at 18% 10%, rgba(98, 230, 162, 0.22), transparent 38%);
}
.dag-node-writeoff {
border-color: rgba(255, 130, 111, 0.56);
background:
linear-gradient(145deg, rgba(47, 21, 22, 0.98), rgba(52, 36, 28, 0.94)),
repeating-linear-gradient(135deg, rgba(255, 111, 97, 0.1) 0 2px, transparent 2px 10px);
}
.dag-node-reopen {
border-color: rgba(170, 210, 255, 0.56);
background:
linear-gradient(145deg, rgba(18, 30, 58, 0.98), rgba(27, 43, 70, 0.94)),
radial-gradient(circle at 82% 12%, rgba(144, 215, 255, 0.22), transparent 34%);
}
.dag-node-evidence {
border-style: dashed;
border-color: rgba(185, 199, 214, 0.5);
background:
linear-gradient(145deg, rgba(22, 29, 39, 0.92), rgba(29, 39, 51, 0.86)),
linear-gradient(135deg, rgba(185, 199, 214, 0.08), transparent 48%);
color: #e2ebf3;
}
.dag-node-evidence .dag-node-stage,
.dag-node-evidence small {
color: #bac8d8;
}
.dag-node-delivery {
border-color: rgba(118, 231, 255, 0.5);
background:
linear-gradient(145deg, rgba(9, 35, 48, 0.98), rgba(14, 48, 62, 0.94)),
radial-gradient(circle at 82% 14%, rgba(118, 231, 255, 0.2), transparent 34%);
}
.dag-detail-panel {
min-width: 0;
padding: 22px;
@ -5382,8 +5489,7 @@ body::before,
.detail-item,
.detail-block,
.calculator-selected-material,
.calculator-result-grid article,
.extension-status-card {
.calculator-result-grid article {
border: 1px solid #ebeef5;
border-radius: 4px;
background: #ffffff;
@ -6845,8 +6951,7 @@ select option {
.detail-item,
.detail-block,
.calculator-selected-material,
.calculator-result-grid article,
.extension-status-card {
.calculator-result-grid article {
border-color: rgba(222, 230, 241, 0.98);
border-radius: 12px;
background:
@ -18272,6 +18377,664 @@ body .warehouse-ledger-table .document-archive-icon {
}
}
.system-extension-workbench {
display: grid;
gap: 12px;
padding-bottom: 92px;
}
.extension-workbench-head {
display: grid;
grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr);
gap: 12px;
align-items: center;
}
.extension-workbench-head h2 {
margin: 2px 0 0;
color: var(--text-strong);
font-size: clamp(24px, 2.2vw, 34px);
letter-spacing: -0.04em;
}
.extension-status-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.extension-status-pill {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
padding: 7px 10px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 999px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
}
.extension-status-pill span {
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
}
.extension-status-pill strong {
display: inline-block;
max-width: 128px;
overflow: hidden;
color: var(--text-strong);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.extension-status-success {
border-color: rgba(34, 197, 94, 0.28);
}
.extension-status-warning {
border-color: rgba(245, 158, 11, 0.34);
}
.extension-status-info {
border-color: rgba(14, 165, 233, 0.28);
}
.extension-status-muted {
border-color: rgba(148, 163, 184, 0.26);
}
.extension-config-shell {
display: grid;
grid-template-columns: 188px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.extension-nav-rail {
position: sticky;
top: 88px;
display: grid;
gap: 6px;
padding: 8px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 18px;
background: rgba(248, 250, 252, 0.92);
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.05);
}
.extension-nav-button {
position: relative;
display: flex;
gap: 10px;
align-items: center;
width: 100%;
min-height: 44px;
padding: 10px 12px;
overflow: hidden;
border: 1px solid transparent;
border-radius: 13px;
background: transparent;
color: var(--text-muted);
text-align: left;
cursor: pointer;
transition:
transform 0.18s ease,
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
}
.extension-nav-button::before {
content: none;
}
.extension-nav-mark {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.5);
box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.08);
}
.extension-nav-button strong {
color: var(--text-strong);
font-size: 14px;
white-space: nowrap;
}
.extension-nav-button:hover,
.extension-nav-button.active {
border-color: rgba(31, 78, 121, 0.2);
background: #ffffff;
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.07);
}
.extension-nav-blue.active .extension-nav-mark {
background: #0ea5e9;
}
.extension-nav-steel.active .extension-nav-mark {
background: #475569;
}
.extension-nav-green.active .extension-nav-mark {
background: #16a34a;
}
.extension-nav-orange.active .extension-nav-mark {
background: #f97316;
}
.extension-module-panel {
min-width: 0;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 22px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.07);
}
.extension-module-head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.extension-module-head h3 {
margin: 0;
color: var(--text-strong);
font-size: 19px;
}
.system-extension-workbench .warning-tag {
border-color: rgba(245, 158, 11, 0.28);
background: rgba(255, 247, 237, 0.92);
color: #b45309;
}
.extension-broadcast-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 540px), 1fr));
gap: 14px;
align-items: start;
}
.extension-broadcast-ledger {
display: block;
}
.extension-form-card,
.extension-list-card {
min-width: 0;
padding: 14px;
border: 1px solid rgba(203, 213, 225, 0.74);
border-radius: 17px;
background: #ffffff;
box-shadow: none;
}
.extension-list-card-full {
padding: 16px;
}
.system-extension-workbench .form-section-title {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.system-extension-workbench .form-section-title strong {
color: var(--text-strong);
font-size: 16px;
}
.extension-list-title {
margin-bottom: 14px;
}
.extension-title-stack {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.extension-add-button {
flex: 0 0 auto;
min-width: 104px;
}
.system-extension-workbench .form-field,
.extension-modal-card .form-field {
min-width: 0;
}
.system-extension-workbench .form-field span,
.extension-modal-card .form-field span {
color: #475569;
font-size: 12px;
font-weight: 700;
}
.system-extension-workbench .form-field input,
.system-extension-workbench .form-field select,
.system-extension-workbench .form-field textarea,
.extension-modal-card .form-field input,
.extension-modal-card .form-field select,
.extension-modal-card .form-field textarea {
min-width: 0;
min-height: 40px;
border-color: rgba(148, 163, 184, 0.42);
border-radius: 12px;
color: #1e293b;
}
.extension-field-grid {
display: grid;
gap: 12px;
}
.extension-field-grid-2 {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
}
.extension-field-grid-3 {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr));
}
.extension-broadcast-status-row {
grid-template-columns: minmax(min(100%, 220px), 0.4fr) minmax(min(100%, 320px), 1fr);
align-items: end;
}
.extension-checkbox-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: none;
overflow: visible;
padding-right: 0;
}
.extension-checkbox-grid .checkbox-field {
gap: 7px;
min-height: 32px;
padding: 7px 10px;
border-color: rgba(148, 163, 184, 0.28);
border-radius: 999px;
background: #f8fafc;
color: #334155;
backdrop-filter: none;
}
.extension-checkbox-grid .checkbox-field input {
flex: 0 0 auto;
width: 14px;
height: 14px;
}
.extension-preview-disclosure,
.extension-detail-note {
min-width: 0;
border: 1px dashed rgba(148, 163, 184, 0.42);
border-radius: 14px;
background: #f8fafc;
}
.extension-preview-disclosure summary,
.extension-detail-note summary {
padding: 10px 12px;
color: #334155;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.extension-detail-note p {
margin: 0;
padding: 0 12px 12px;
color: #64748b;
line-height: 1.55;
}
.extension-banner-preview {
display: grid;
gap: 8px;
align-content: end;
padding: 0 10px 10px;
}
.extension-banner-preview > span {
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
}
.extension-table-wrap {
max-height: 430px;
}
.extension-setting-block {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border: 1px solid rgba(71, 85, 105, 0.14);
border-radius: 16px;
background: linear-gradient(135deg, #f8fafc, #eef4fa);
color: #334155;
}
.extension-setting-block .setting-kicker {
color: #64748b;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.extension-setting-block strong {
margin-right: auto;
color: #0f172a;
font-size: 22px;
letter-spacing: 0.04em;
}
.extension-setting-block em {
padding: 4px 9px;
border-radius: 999px;
background: rgba(14, 165, 233, 0.1);
color: #0369a1;
font-size: 12px;
font-style: normal;
font-weight: 800;
}
.extension-mode-switch {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 16px;
background: #ffffff;
cursor: pointer;
}
.extension-mode-switch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.extension-mode-switch .switch-track {
position: relative;
width: 54px;
height: 30px;
border-radius: 999px;
background: #cbd5e1;
box-shadow: inset 0 2px 8px rgba(15, 23, 42, 0.16);
transition: background 0.18s ease;
}
.extension-mode-switch .switch-track::after {
content: "";
position: absolute;
top: 4px;
left: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.24);
transition: transform 0.18s ease;
}
.extension-mode-switch.active .switch-track {
background: #0f766e;
}
.extension-mode-switch.active .switch-track::after {
transform: translateX(24px);
}
.extension-mode-switch strong {
display: block;
color: var(--text-strong);
}
.extension-mode-switch small {
display: inline-flex;
width: max-content;
max-width: 100%;
margin-top: 6px;
padding: 3px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
font-weight: 800;
line-height: 1.4;
}
.extension-modal-backdrop {
position: fixed;
inset: 0;
z-index: 6500;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.34);
backdrop-filter: blur(10px);
}
.extension-modal-card {
width: min(860px, calc(100vw - 48px));
max-height: min(760px, calc(100dvh - 48px));
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
radial-gradient(circle at 100% 0%, rgba(14, 165, 233, 0.12), transparent 30%);
box-shadow: 0 34px 88px rgba(15, 23, 42, 0.26);
}
.extension-modal-head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 18px 20px 14px;
border-bottom: 1px solid rgba(203, 213, 225, 0.72);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
}
.extension-modal-head h3 {
margin: 2px 0 0;
color: var(--text-strong);
font-size: 20px;
}
.extension-modal-close {
flex: 0 0 auto;
}
.extension-dialog-form {
padding: 18px 20px 20px;
}
.extension-dialog-actions {
position: sticky;
bottom: 0;
display: flex;
gap: 10px;
justify-content: flex-end;
margin: 4px -20px -20px;
padding: 14px 20px;
border-top: 1px solid rgba(203, 213, 225, 0.72);
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(14px);
}
.extension-save-dock {
position: fixed;
right: 28px;
bottom: 28px;
z-index: 30;
display: flex;
gap: 10px;
align-items: center;
max-width: min(680px, calc(100vw - 56px));
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 22px 54px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(18px);
}
.extension-save-dock > span {
padding: 0 8px;
color: var(--text-muted);
font-weight: 700;
white-space: nowrap;
}
.extension-save-dock .ghost-button,
.extension-save-dock .primary-button {
flex: 0 0 auto;
min-width: 96px;
padding-inline: 16px;
}
.extension-save-dock .semantic-action-label {
display: inline;
}
.system-extension-workbench .status-chip.status-info {
background: rgba(14, 165, 233, 0.12);
color: #0369a1;
}
.system-extension-workbench .status-chip.status-success {
background: rgba(34, 197, 94, 0.14);
color: #15803d;
}
.system-extension-workbench .status-chip.status-warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.system-extension-workbench .status-chip.status-danger {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.system-extension-workbench .status-chip.status-active {
background: rgba(34, 197, 94, 0.14);
color: #15803d;
}
.system-extension-workbench .status-chip.status-paused {
background: rgba(148, 163, 184, 0.16);
color: #475569;
}
@media (max-width: 1440px) {
.extension-workbench-head {
grid-template-columns: 1fr;
}
.extension-config-shell {
grid-template-columns: 1fr;
}
.extension-nav-rail {
position: static;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
overflow-x: auto;
}
.extension-broadcast-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.extension-status-strip {
justify-content: flex-start;
}
.extension-list-title {
align-items: stretch;
}
.extension-list-title,
.extension-title-stack {
flex-direction: column;
}
.extension-add-button {
width: 100%;
}
.extension-broadcast-status-row {
grid-template-columns: 1fr;
}
.extension-modal-backdrop {
padding: 12px;
}
.extension-modal-card {
width: calc(100vw - 24px);
max-height: calc(100dvh - 24px);
border-radius: 18px;
}
.extension-modal-head,
.extension-dialog-form {
padding-inline: 14px;
}
.extension-dialog-actions {
margin-inline: -14px;
padding-inline: 14px;
}
.extension-save-dock {
right: 14px;
bottom: 14px;
left: 14px;
justify-content: flex-end;
border-radius: 24px;
}
.extension-save-dock > span {
display: none;
}
}
/* Final warehouse document action contract: submit buttons live at the paper document bottom. */
body .workflow-drawer.form-drawer:has(.warehouse-document-operation-form) {
width: min(1280px, calc(100vw - 24px)) !important;

View File

@ -96,6 +96,7 @@ function createSemanticIcon(iconName) {
function setButtonContent(button, semantics) {
let icon = button.querySelector(".semantic-action-icon");
let label = button.querySelector(".semantic-action-label");
const resolvedLabel = button.dataset.semanticLabel || semantics.label;
if (button.dataset.semanticActionIcon !== semantics.icon || !icon || !label) {
button.textContent = "";
@ -105,7 +106,7 @@ function setButtonContent(button, semantics) {
button.append(icon, label);
}
label.textContent = semantics.label;
label.textContent = resolvedLabel;
}
function clearSemanticButton(button) {
@ -148,9 +149,10 @@ function enhanceButton(button) {
button.dataset.buttonTone = semantics.tone;
button.dataset.semanticActionIcon = semantics.icon;
setButtonContent(button, semantics);
button.setAttribute("aria-label", semantics.label);
const resolvedLabel = button.dataset.semanticLabel || semantics.label;
button.setAttribute("aria-label", resolvedLabel);
if (!button.title || button.dataset.autoIconTitle === "true") {
button.title = semantics.label;
button.title = resolvedLabel;
button.dataset.autoIconTitle = "true";
}
}

View File

@ -0,0 +1,63 @@
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, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "DashboardView.vue"), "utf8");
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
describe("DashboardView material lot lifecycle DAG", () => {
it("uses closed-loop material batch copy and stage labels", () => {
assert.match(source, /材料库存批次闭环图/);
assert.match(source, /生产台账/);
assert.match(source, /库外闭环/);
assert.match(source, /入库结果/);
assert.doesNotMatch(source, /原材料生命周期 DAG/);
});
it("knows all production ledger lifecycle node types", () => {
[
"production_ledger",
"production_issue_txn",
"ledger_outside_balance",
"production_ledger_settlement",
"finished_inbound",
"surplus_inbound",
"scrap_inbound",
"ledger_writeoff",
"ledger_reopen",
"operation_report",
"delivery"
].forEach((nodeType) => {
assert.match(source, new RegExp(nodeType));
});
});
it("frames operation reports as evidence instead of lifecycle control nodes", () => {
assert.match(source, /报工证据/);
assert.match(source, /报工仅作证据/);
assert.doesNotMatch(source, /stage:\s*standardStageLabel\(node\)/);
assert.match(source, /stageBandLabel/);
});
it("uses parent-aware row placement before business priority", () => {
assert.match(source, /buildParentByTarget/);
assert.match(source, /buildParentRank/);
assert.match(source, /parentRank/);
assert.match(source, /compareDagNode\(left,\s*right,\s*parentRank/);
});
it("styles the new lifecycle legend and node type treatments", () => {
[
".dag-node-ledger",
".dag-node-balance",
".dag-node-settlement",
".dag-node-evidence",
".dag-legend"
].forEach((selector) => {
assert.match(mainCss, new RegExp(selector.replace(".", "\\.")));
});
});
});

View File

@ -87,7 +87,7 @@
<section class="lifecycle-modal">
<header class="lifecycle-modal-head">
<div>
<p class="eyebrow">原材料生命周期 DAG</p>
<p class="eyebrow">材料库存批次闭环图</p>
<h3>{{ lifecycle?.lot?.lot_no || activeMaterialLot?.lot_no || "库存批次号流转" }}</h3>
<p>{{ lifecycle?.lot ? `${lifecycle.lot.material_code} · ${lifecycle.lot.material_name}` : activeMaterialLot?.material_name || "-" }}</p>
</div>
@ -96,7 +96,7 @@
<input v-model="clickHighlightEnabled" type="checkbox" @change="handleClickHighlightModeChange" />
<span>点击常亮</span>
</label>
<span class="table-chip">拖动画布 · 点击节点看详情</span>
<span class="table-chip">拖动画布 · 点击节点看详情 · 报工仅作证据</span>
<button class="ghost-button" type="button" title="放大" @click="zoomCanvasBy(0.08)">放大</button>
<button class="ghost-button" type="button" title="缩小" @click="zoomCanvasBy(-0.08)">缩小</button>
<button class="ghost-button" type="button" @click="resetCanvas">重置视图</button>
@ -104,7 +104,7 @@
</div>
</header>
<div v-if="loadingLifecycle" class="dag-loading-card">正在加载库存批次号全链路数据...</div>
<div v-if="loadingLifecycle" class="dag-loading-card">正在加载材料库存批次闭环图...</div>
<div v-else class="lifecycle-content">
<div
@ -115,6 +115,12 @@
@mouseup="stopPan"
@mouseleave="handleDagViewportMouseLeave"
>
<div class="dag-legend" @mousedown.stop>
<span><i class="dag-legend-ledger"></i>生产台账</span>
<span><i class="dag-legend-balance"></i>库外余额</span>
<span><i class="dag-legend-inbound"></i>入库结果</span>
<span><i class="dag-legend-evidence"></i>报工证据</span>
</div>
<div class="dag-transform" :style="canvasTransformStyle">
<div class="dag-canvas" :style="{ width: `${canvasSize.width}px`, height: `${canvasSize.height}px` }">
<div
@ -228,6 +234,7 @@
class="dag-node-card"
:class="[
`dag-node-${node.tone}`,
dagNodeTypeClass(node),
{
'dag-node-active': selectedNode?.id === node.id,
'dag-node-locked': persistentDagNodeIds.has(node.id)
@ -242,7 +249,7 @@
>
<span class="dag-node-stage">{{ node.stage }}</span>
<strong>{{ node.title }}</strong>
<small>{{ node.subtitle || formatDashboardNodeTypeLabel(node.type) }}</small>
<small>{{ dagNodeSubtitle(node) }}</small>
<div class="dag-node-footer">
<span>{{ displayStatus(node.status) }}</span>
<span>{{ node.metrics?.[0]?.value || "" }}</span>
@ -305,8 +312,29 @@ const EDGE_GAP_PER_LINE = 68;
const ROW_GAP = 168;
const LEFT_OFFSET = 78;
const TOP_OFFSET = 104;
const REPORT_ROW_GAP = 150;
const BRANCH_GAP = 46;
const STAGE_LABELS = {
0: "原料入库",
1: "生产台账",
2: "生产出库",
3: "库外闭环",
4: "入库结算",
5: "入库结果",
6: "销售去向"
};
const NODE_TYPE_CLASS = {
raw_material_lot: "dag-node-raw",
production_ledger: "dag-node-ledger",
production_issue_txn: "dag-node-issue",
ledger_outside_balance: "dag-node-balance",
production_ledger_settlement: "dag-node-settlement",
finished_inbound: "dag-node-inbound",
surplus_inbound: "dag-node-inbound",
scrap_inbound: "dag-node-inbound",
ledger_writeoff: "dag-node-writeoff",
ledger_reopen: "dag-node-reopen",
operation_report: "dag-node-evidence",
delivery: "dag-node-delivery"
};
const materialLots = ref([]);
const loadingMaterialLots = ref(false);
@ -360,57 +388,10 @@ const layoutNodes = computed(() => {
}
const edges = lifecycle.value?.edges || [];
const nodeById = new Map(nodes.map((node, index) => [node.id, { ...node, __index: index }]));
const childrenBySource = new Map();
const parentByTarget = new Map();
edges.forEach((edge) => {
if (!childrenBySource.has(edge.source)) {
childrenBySource.set(edge.source, []);
}
childrenBySource.get(edge.source).push(edge.target);
if (!parentByTarget.has(edge.target)) {
parentByTarget.set(edge.target, edge.source);
}
});
const parentByTarget = buildParentByTarget(nodes, edges);
const parentRank = buildParentRank(nodes, edges, parentByTarget);
const levelX = buildAdaptiveLevelX(nodes, edges);
const yById = new Map();
const operationNodes = nodes
.filter((node) => node.type === "work_order_operation")
.sort(compareDagNode);
const reportNodes = nodes
.filter((node) => node.type === "operation_report")
.sort(compareDagNode);
const reportGroups = new Map();
reportNodes.forEach((node) => {
const parentId = parentByTarget.get(node.id) || "__unlinked_reports";
if (!reportGroups.has(parentId)) {
reportGroups.set(parentId, []);
}
reportGroups.get(parentId).push(node);
});
let cursorY = TOP_OFFSET;
operationNodes.forEach((operation) => {
const reports = reportGroups.get(operation.id) || [];
const rowCount = Math.max(1, reports.length);
const groupHeight = Math.max(NODE_HEIGHT, rowCount * NODE_HEIGHT + (rowCount - 1) * (REPORT_ROW_GAP - NODE_HEIGHT));
yById.set(operation.id, cursorY + Math.max(0, (groupHeight - NODE_HEIGHT) / 2));
reports.forEach((report, index) => {
yById.set(report.id, cursorY + index * REPORT_ROW_GAP);
});
cursorY += groupHeight + BRANCH_GAP;
});
const unlinkedReports = reportGroups.get("__unlinked_reports") || [];
unlinkedReports.forEach((report) => {
if (!yById.has(report.id)) {
yById.set(report.id, cursorY);
cursorY += REPORT_ROW_GAP;
}
});
const branchHeight = Math.max(NODE_HEIGHT, cursorY - TOP_OFFSET - BRANCH_GAP);
const levels = new Map();
nodes.forEach((node) => {
if (!levels.has(node.level)) {
@ -420,20 +401,9 @@ const layoutNodes = computed(() => {
});
Array.from(levels.entries()).forEach(([level, rows]) => {
const sortedRows = rows.slice().sort(compareDagNode);
if (level <= 2) {
const distributed = distributeLevelRows(sortedRows, branchHeight);
distributed.forEach(({ node, y }) => yById.set(node.id, y));
return;
}
const sortedRows = rows.slice().sort((left, right) => compareDagNode(left, right, parentRank));
sortedRows.forEach((node, index) => {
if (yById.has(node.id)) {
return;
}
const parent = nodeById.get(parentByTarget.get(node.id));
const parentY = parent ? yById.get(parent.id) : null;
yById.set(node.id, Number.isFinite(parentY) ? parentY : TOP_OFFSET + index * ROW_GAP);
yById.set(node.id, TOP_OFFSET + index * ROW_GAP);
});
});
@ -567,7 +537,7 @@ const stageBands = computed(() => {
if (!stages.has(node.level)) {
stages.set(node.level, {
level: node.level,
label: node.stage,
label: stageBandLabel(node.level),
left: Math.max(0, node.x - 18),
width: NODE_WIDTH + 36
});
@ -841,10 +811,18 @@ function segmentKey(leftLevel, rightLevel) {
return `${leftLevel}->${rightLevel}`;
}
function compareDagNode(left, right) {
function compareDagNode(left, right, parentRank = new Map()) {
if (left.level !== right.level) {
return left.level - right.level;
}
const parentDelta = String(parentRank.get(left.id) || "").localeCompare(String(parentRank.get(right.id) || ""), "zh-Hans-CN");
if (parentDelta) {
return parentDelta;
}
const priorityDelta = dagRowPriority(left) - dagRowPriority(right);
if (priorityDelta) {
return priorityDelta;
}
const leftSeq = Number(String(left.title || "").match(/OP(\d+)/i)?.[1] || 0);
const rightSeq = Number(String(right.title || "").match(/OP(\d+)/i)?.[1] || 0);
if (leftSeq || rightSeq) {
@ -853,19 +831,106 @@ function compareDagNode(left, right) {
return String(left.title || left.id).localeCompare(String(right.title || right.id), "zh-Hans-CN");
}
function distributeLevelRows(rows, branchHeight) {
if (rows.length <= 1) {
return rows.map((node) => ({
node,
y: TOP_OFFSET + Math.max(0, (branchHeight - NODE_HEIGHT) / 2)
}));
function buildParentByTarget(nodes, edges) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const parentByTarget = new Map();
edges.forEach((edge) => {
const source = nodeById.get(edge.source);
const target = nodeById.get(edge.target);
if (!source || !target || parentByTarget.has(edge.target)) {
return;
}
const usableHeight = Math.max(branchHeight - NODE_HEIGHT, (rows.length - 1) * ROW_GAP);
const gap = usableHeight / Math.max(1, rows.length - 1);
return rows.map((node, index) => ({
node,
y: TOP_OFFSET + index * gap
}));
parentByTarget.set(edge.target, edge.source);
});
return parentByTarget;
}
function buildParentRank(nodes, edges, parentByTarget) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const childrenByParent = new Map();
edges.forEach((edge) => {
if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) {
return;
}
if (!childrenByParent.has(edge.source)) {
childrenByParent.set(edge.source, []);
}
childrenByParent.get(edge.source).push(edge.target);
});
const rootOrder = nodes
.filter((node) => !parentByTarget.has(node.id))
.slice()
.sort(compareDagNode)
.map((node) => node.id);
const rootIndexById = new Map(rootOrder.map((id, index) => [id, index]));
const siblingIndexById = new Map();
childrenByParent.forEach((childIds) => {
childIds
.slice()
.sort((leftId, rightId) => compareDagNode(nodeById.get(leftId), nodeById.get(rightId)))
.forEach((childId, index) => siblingIndexById.set(childId, index));
});
const rankById = new Map();
const visiting = new Set();
const rankFor = (nodeId) => {
if (rankById.has(nodeId)) {
return rankById.get(nodeId);
}
if (visiting.has(nodeId)) {
return padRank(rootIndexById.get(nodeId) ?? 9999);
}
visiting.add(nodeId);
const parentId = parentByTarget.get(nodeId);
const rank = parentId && nodeById.has(parentId)
? `${rankFor(parentId)}.${padRank(siblingIndexById.get(nodeId) ?? 9999)}`
: padRank(rootIndexById.get(nodeId) ?? nodes.findIndex((node) => node.id === nodeId));
visiting.delete(nodeId);
rankById.set(nodeId, rank);
return rank;
};
nodes.forEach((node) => rankFor(node.id));
return rankById;
}
function padRank(value) {
return String(Math.max(0, Number(value) || 0)).padStart(4, "0");
}
function dagRowPriority(node) {
if (["raw_material_lot", "production_ledger", "production_issue_txn", "production_ledger_settlement", "finished_inbound", "delivery"].includes(node.type)) {
return 0;
}
if (["ledger_outside_balance", "surplus_inbound"].includes(node.type)) {
return 1;
}
if (["operation_report", "scrap_inbound"].includes(node.type)) {
return 2;
}
if (node.type === "ledger_writeoff") {
return 3;
}
if (node.type === "ledger_reopen") {
return 4;
}
return 9;
}
function stageBandLabel(level) {
return STAGE_LABELS[Number(level)] || `阶段 ${level}`;
}
function dagNodeTypeClass(node) {
return NODE_TYPE_CLASS[node.type] || "";
}
function dagNodeSubtitle(node) {
if (node.type === "operation_report") {
return node.subtitle ? `报工证据 · ${node.subtitle}` : "报工证据";
}
return node.subtitle || formatDashboardNodeTypeLabel(node.type);
}
function resolveLevelCollisions(nodes, yById) {

View File

@ -0,0 +1,79 @@
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, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "SystemExtensionView.vue"), "utf8");
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
describe("SystemExtensionView workbench redesign", () => {
it("removes the old hero explanation card and admin-only copy", () => {
assert.doesNotMatch(source, /extension-hero/);
assert.doesNotMatch(source, /extension-status-card/);
assert.doesNotMatch(source, /广播通知与 AI 助手配置中心/);
assert.doesNotMatch(source, /ADMIN ONLY/);
});
it("renders a compact system extension workbench shell", () => {
[
"system-extension-workbench",
"extension-status-strip",
"extension-config-shell",
"extension-nav-rail",
"extension-module-panel",
"extension-save-dock"
].forEach((className) => {
assert.match(source, new RegExp(className));
assert.match(mainCss, new RegExp(`\\.${className}`));
});
});
it("keeps auxiliary explanations out of the permanent workspace", () => {
assert.doesNotMatch(source, /<small>{{ section\.desc }}<\/small>/);
assert.doesNotMatch(source, /activeSectionMeta\.eyebrow/);
assert.doesNotMatch(source, /extension-impact-list/);
assert.match(source, /extension-detail-note/);
assert.match(source, /extension-preview-disclosure/);
assert.match(source, /extension-field-grid/);
});
it("opens broadcast editing from a dialog instead of a permanent form block", () => {
assert.match(source, /showBroadcastDialog/);
assert.match(source, /openNewBroadcastDialog/);
assert.match(source, /extension-modal-backdrop/);
assert.match(source, /extension-broadcast-ledger/);
assert.match(source, /data-semantic-label="新增广播"/);
assert.match(source, /新增广播/);
assert.doesNotMatch(source, /<section v-if="activeSection === 'broadcast'" class="extension-broadcast-grid">/);
});
it("exposes the four configuration modules as a navigation directory", () => {
["广播通知", "库存批次规则", "生产对接模式", "AI 助手配置"].forEach((label) => {
assert.match(source, new RegExp(label));
});
assert.match(source, /activeSection/);
assert.match(source, /extensionSections/);
});
it("uses a fixed save dock with explicit save and reset actions", () => {
assert.match(source, /放弃修改/);
assert.match(source, /保存配置/);
assert.match(source, /saveActiveSection/);
assert.match(source, /resetActiveSection/);
assert.match(source, /activeSectionDirty/);
});
it("keeps existing system extension API endpoints unchanged", () => {
[
"/system-extension/broadcasts",
"/system-extension/assistant-config",
"/system-extension/raw-material-lot-prefix",
"/system-extension/smart-operation-report"
].forEach((endpoint) => {
assert.ok(source.includes(endpoint), `${endpoint} endpoint should remain in source`);
});
});
});

View File

@ -1,39 +1,233 @@
<template>
<section class="page-grid">
<section class="panel panel-hero extension-hero">
<div class="hero-copy">
<p class="eyebrow">系统拓展</p>
<span
class="title-with-help"
title="该模块仅系统管理员可见,用于维护全局右上角喇叭广播,以及后续 AI 聊天机器人接入免费 LLM API 的配置。"
>
<h3>广播通知与 AI 助手配置中心</h3>
</span>
</div>
<div class="extension-status-card">
<span class="panel-tag">ADMIN ONLY</span>
<strong>{{ assistantConfig.enabled ? "真实 LLM 已启用" : "当前为占位助手模式" }}</strong>
<p>{{ assistantConfig.model_name || "尚未配置模型名称" }}</p>
</div>
</section>
<section class="panel">
<div class="panel-header">
<section class="system-extension-workbench">
<header class="extension-workbench-head">
<div>
<p class="eyebrow">广播内容管理</p>
<h3>右上角小喇叭横幅</h3>
</div>
<div class="workflow-inline-actions">
<span class="panel-tag">{{ editingBroadcastId ? "编辑广播" : "新增广播" }}</span>
<button class="ghost-button" type="button" @click="resetBroadcastForm">新建</button>
<p class="eyebrow">系统管理</p>
<h2>系统拓展</h2>
</div>
<div class="extension-status-strip" aria-label="系统拓展状态概览">
<article
v-for="card in statusCards"
:key="card.label"
class="extension-status-pill"
:class="`extension-status-${card.tone}`"
>
<span>{{ card.label }}</span>
<strong>{{ card.value }}</strong>
</article>
</div>
</header>
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
<form class="stack-form compact-extension-form" @submit.prevent="saveBroadcast">
<div class="double-field">
<section class="extension-config-shell">
<nav class="extension-nav-rail" aria-label="系统拓展配置目录">
<button
v-for="section in extensionSections"
:key="section.key"
class="extension-nav-button"
:class="[{ active: activeSection === section.key }, `extension-nav-${section.accent}`]"
type="button"
:aria-pressed="activeSection === section.key"
@click="activeSection = section.key"
>
<span class="extension-nav-mark" aria-hidden="true"></span>
<strong>{{ section.title }}</strong>
</button>
</nav>
<main class="extension-module-panel">
<div class="extension-module-head">
<div>
<h3>{{ activeSectionMeta.title }}</h3>
</div>
<span v-if="activeSectionDirty" class="panel-tag warning-tag">有未保存修改</span>
<span v-else class="panel-tag">已同步</span>
</div>
<section v-if="activeSection === 'broadcast'" class="extension-broadcast-ledger">
<section class="extension-list-card extension-list-card-full">
<div class="form-section-title extension-list-title">
<div class="extension-title-stack">
<strong>广播记录</strong>
<span class="panel-tag">{{ filteredBroadcasts.length }} </span>
</div>
<button
class="primary-button extension-add-button"
type="button"
data-semantic-label="新增广播"
@click="openNewBroadcastDialog"
>
新增广播
</button>
</div>
<TableControls
v-model:search="broadcastControls.search.value"
v-model:sort-key="broadcastControls.sortKey.value"
v-model:sort-direction="broadcastControls.sortDirection.value"
:sort-options="broadcastControls.sortOptions"
placeholder="搜索标题、内容、状态、类型"
/>
<div class="table-wrap extension-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>
</tr>
</thead>
<tbody>
<tr v-for="item in paginatedBroadcasts" :key="item.broadcast_id">
<td>{{ item.title }}</td>
<td>{{ formatAttendancePoints(item.attendance_point_names) }}</td>
<td>{{ item.content }}</td>
<td><span class="status-chip" :class="`status-${String(item.tone || 'INFO').toLowerCase()}`">{{ formatToneLabel(item.tone) }}</span></td>
<td><span class="status-chip" :class="item.status === 'ACTIVE' ? 'status-active' : 'status-paused'">{{ item.status === "ACTIVE" ? "启用" : "暂停" }}</span></td>
<td>{{ formatDateTime(item.starts_at) }}</td>
<td>{{ formatDateTime(item.ends_at) }}</td>
<td>{{ item.sort_no }}</td>
<td class="action-row">
<button class="ghost-button" type="button" @click="editBroadcast(item)">编辑</button>
</td>
</tr>
<tr v-if="!filteredBroadcasts.length">
<td colspan="9" class="empty-row">暂无广播内容</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar v-model:page="broadcastPage" v-model:page-size="broadcastPageSize" :total="filteredBroadcasts.length" />
</section>
</section>
<form
v-else-if="activeSection === 'rawLotPrefix'"
id="extension-form-rawLotPrefix"
class="stack-form extension-form-card"
@submit.prevent="saveActiveSection"
>
<div class="extension-setting-block" title="序号达到 9999 后自然扩展为下一位数,不截断、不补新规则。">
<span class="setting-kicker">当前规则</span>
<strong>{{ rawLotPrefixForm.config_value || "YL" }}0001</strong>
<em>自动递增</em>
</div>
<div class="extension-field-grid extension-field-grid-2">
<label class="form-field">
<span>前缀</span>
<input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
</label>
<label class="form-field">
<span>说明</span>
<input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如:原材料库存批次号默认前缀" />
</label>
</div>
</form>
<form
v-else-if="activeSection === 'productionMode'"
id="extension-form-productionMode"
class="stack-form extension-form-card"
@submit.prevent="saveActiveSection"
>
<label class="extension-mode-switch" :class="{ active: smartOperationReportForm.enabled }">
<input v-model="smartOperationReportForm.enabled" type="checkbox" />
<span class="switch-track" aria-hidden="true"></span>
<span>
<strong>对接智能报工小程序</strong>
<small class="extension-inline-state">{{ smartOperationReportForm.enabled ? "当前:已对接" : "当前:未对接" }}</small>
</span>
</label>
<details class="extension-detail-note">
<summary>查看影响范围</summary>
<p>开启时显示工序报工和小程序证据关闭时隐藏工序报工生产按 ERP 入库闭环推进</p>
</details>
<label class="form-field">
<span>说明</span>
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如客户未购买小程序模块暂按ERP入库反推" />
</label>
</form>
<form
v-else-if="activeSection === 'assistant'"
id="extension-form-assistant"
class="stack-form extension-form-card"
@submit.prevent="saveActiveSection"
>
<div class="extension-field-grid extension-field-grid-3">
<label class="form-field">
<span>助手名称</span>
<input v-model.trim="assistantForm.assistant_name" type="text" required />
</label>
<label class="form-field">
<span>供应商</span>
<input v-model.trim="assistantForm.provider_name" type="text" placeholder="例如OpenRouter / SiliconFlow" />
</label>
<label class="form-field">
<span>模型名称</span>
<input v-model.trim="assistantForm.model_name" type="text" placeholder="例如deepseek-chat" />
</label>
</div>
<div class="extension-field-grid extension-field-grid-2">
<label class="form-field">
<span>API Base URL</span>
<input v-model.trim="assistantForm.api_base_url" type="text" placeholder="https://api.example.com/v1" />
</label>
<label class="form-field">
<span>API Key</span>
<input v-model.trim="assistantForm.api_key" type="password" :placeholder="assistantConfig.api_key_configured ? '留空表示不修改现有密钥' : '请输入 API Key'" />
</label>
</div>
<div class="extension-field-grid extension-field-grid-2">
<label class="form-field">
<span>温度</span>
<input v-model.number="assistantForm.temperature" type="number" min="0" max="2" step="0.1" />
</label>
<label class="extension-mode-switch" :class="{ active: assistantForm.enabled }">
<input v-model="assistantForm.enabled" type="checkbox" />
<span class="switch-track" aria-hidden="true"></span>
<span>
<strong>启用真实 LLM 调用</strong>
<small class="extension-inline-state">{{ assistantConfig.api_key_configured ? "密钥已配置" : "未配置密钥" }}</small>
</span>
</label>
</div>
<label class="form-field">
<span>每次新建对话的系统提示词</span>
<textarea v-model.trim="assistantForm.system_prompt" rows="6"></textarea>
</label>
</form>
</main>
</section>
<Teleport to="body">
<div v-if="showBroadcastDialog" class="extension-modal-backdrop" @click.self="closeBroadcastDialog">
<section class="extension-modal-card" role="dialog" aria-modal="true" aria-labelledby="broadcast-dialog-title">
<header class="extension-modal-head">
<div>
<p class="eyebrow">广播通知</p>
<h3 id="broadcast-dialog-title">{{ editingBroadcastId ? "编辑广播" : "新增广播" }}</h3>
</div>
<button class="ghost-button extension-modal-close" type="button" @click="closeBroadcastDialog">关闭</button>
</header>
<form id="extension-form-broadcast" class="stack-form extension-dialog-form" @submit.prevent="saveBroadcast">
<div class="extension-field-grid extension-field-grid-2">
<label class="form-field">
<span>广播标题 <i class="required-mark">*</i></span>
<input v-model.trim="broadcastForm.title" type="text" required />
@ -56,7 +250,7 @@
<div class="form-field">
<span>通知考勤点 <i class="required-mark">*</i></span>
<div class="checkbox-grid">
<div class="checkbox-grid extension-checkbox-grid">
<label v-for="point in attendancePoints" :key="point.name" class="checkbox-field">
<input v-model="broadcastForm.attendance_point_names" type="checkbox" :value="point.name" />
<span>{{ point.name }}</span>
@ -64,7 +258,7 @@
</div>
</div>
<div class="triple-field">
<div class="extension-field-grid extension-field-grid-3">
<label class="form-field">
<span>开始时间</span>
<input v-model="broadcastForm.starts_at" type="datetime-local" />
@ -79,7 +273,7 @@
</label>
</div>
<div class="double-field">
<div class="extension-field-grid extension-broadcast-status-row">
<label class="form-field">
<span>状态</span>
<select v-model="broadcastForm.status">
@ -87,9 +281,10 @@
<option value="PAUSED">暂停</option>
</select>
</label>
<div class="form-field extension-preview-field">
<span>横幅预览</span>
<div class="broadcast-banner extension-preview-banner" :class="`broadcast-${broadcastForm.tone.toLowerCase()}`">
<details class="extension-preview-disclosure">
<summary>查看横幅预览</summary>
<div class="extension-banner-preview">
<div class="broadcast-banner" :class="`broadcast-${broadcastForm.tone.toLowerCase()}`">
<span class="broadcast-speaker" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img">
<path d="M4 10.5H7.5L16 6.5V17.5L7.5 13.5H4V10.5Z" />
@ -100,179 +295,31 @@
</svg>
</span>
<strong>{{ broadcastForm.title || "广播标题" }}</strong>
<span>{{ broadcastForm.content || "广播内容会显示在系统右上角" }}</span>
<span>{{ broadcastForm.content || "广播内容" }}</span>
</div>
</div>
</details>
</div>
<button class="primary-button" type="submit">{{ editingBroadcastId ? "更新广播" : "保存广播" }}</button>
<footer class="extension-dialog-actions">
<button class="ghost-button" type="button" @click="closeBroadcastDialog">取消</button>
<button class="primary-button" type="submit">{{ editingBroadcastId ? "保存修改" : "保存广播" }}</button>
</footer>
</form>
</section>
</div>
</Teleport>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">仓库编号配置</p>
<h3>原材料库存批次号前缀</h3>
<div v-if="activeSectionDirty" class="extension-save-dock">
<span>{{ activeSectionMeta.title }}有未保存修改</span>
<button class="ghost-button" type="button" @click="resetActiveSection">放弃修改</button>
<button class="primary-button" type="submit" :form="activeSectionFormId">保存配置</button>
</div>
<span class="panel-tag">{{ rawLotPrefixConfig.config_value || "YL" }}</span>
</div>
<form class="stack-form compact-extension-form" @submit.prevent="saveRawLotPrefixConfig">
<div class="double-field">
<label class="form-field">
<span>前缀</span>
<input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
</label>
<label class="form-field">
<span>说明</span>
<input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如:原材料库存批次号默认前缀" />
</label>
</div>
<div class="workflow-tip">
生成效果示例{{ rawLotPrefixForm.config_value || "YL" }}0001序号达到 9999 后会自然扩展为 {{ rawLotPrefixForm.config_value || "YL" }}10000
</div>
<button class="primary-button" type="submit">保存前缀</button>
</form>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">生产模式配置</p>
<h3>对接智能报工小程序</h3>
</div>
<span class="panel-tag">{{ smartOperationReportForm.enabled ? "已对接" : "未对接" }}</span>
</div>
<form class="stack-form compact-extension-form" @submit.prevent="saveSmartOperationReportConfig">
<label class="checkbox-field extension-switch-field">
<input v-model="smartOperationReportForm.enabled" type="checkbox" />
<span>对接智能报工小程序</span>
</label>
<div class="workflow-tip">
{{ smartOperationReportForm.enabled ? "开启后显示工序报工,并按小程序报工数据辅助计算生产进度。" : "关闭后隐藏工序报工生产进度按ERP入库数据和BOM反推。" }}
</div>
<label class="form-field">
<span>说明</span>
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如客户未购买小程序模块暂按ERP入库反推" />
</label>
<button class="primary-button" type="submit">保存生产模式</button>
</form>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">广播列表</p>
<h3>当前系统广播记录</h3>
</div>
</div>
<TableControls
v-model:search="broadcastControls.search.value"
v-model:sort-key="broadcastControls.sortKey.value"
v-model:sort-direction="broadcastControls.sortDirection.value"
:sort-options="broadcastControls.sortOptions"
placeholder="搜索标题、内容、状态、类型"
/>
<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>
</tr>
</thead>
<tbody>
<tr v-for="item in paginatedBroadcasts" :key="item.broadcast_id">
<td>{{ item.title }}</td>
<td>{{ formatAttendancePoints(item.attendance_point_names) }}</td>
<td>{{ item.content }}</td>
<td>{{ formatToneLabel(item.tone) }}</td>
<td>{{ item.status === "ACTIVE" ? "启用" : "暂停" }}</td>
<td>{{ formatDateTime(item.starts_at) }}</td>
<td>{{ formatDateTime(item.ends_at) }}</td>
<td>{{ item.sort_no }}</td>
<td class="action-row"><button class="ghost-button" type="button" @click="editBroadcast(item)">编辑</button></td>
</tr>
<tr v-if="!filteredBroadcasts.length">
<td colspan="9" class="empty-row">暂无广播内容</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar v-model:page="broadcastPage" v-model:page-size="broadcastPageSize" :total="filteredBroadcasts.length" />
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">AI 机器人配置</p>
<h3>免费 LLM API 与新对话系统提示词</h3>
</div>
<span class="panel-tag">{{ assistantConfig.api_key_configured ? "密钥已配置" : "未配置密钥" }}</span>
</div>
<form class="stack-form" @submit.prevent="saveAssistantConfig">
<div class="triple-field">
<label class="form-field">
<span>助手名称</span>
<input v-model.trim="assistantForm.assistant_name" type="text" required />
</label>
<label class="form-field">
<span>供应商</span>
<input v-model.trim="assistantForm.provider_name" type="text" placeholder="例如OpenRouter / SiliconFlow" />
</label>
<label class="form-field">
<span>模型名称</span>
<input v-model.trim="assistantForm.model_name" type="text" placeholder="例如deepseek-chat" />
</label>
</div>
<div class="double-field">
<label class="form-field">
<span>API Base URL</span>
<input v-model.trim="assistantForm.api_base_url" type="text" placeholder="https://api.example.com/v1" />
</label>
<label class="form-field">
<span>API Key</span>
<input v-model.trim="assistantForm.api_key" type="password" :placeholder="assistantConfig.api_key_configured ? '留空表示不修改现有密钥' : '请输入 API Key'" />
</label>
</div>
<div class="double-field">
<label class="form-field">
<span>温度</span>
<input v-model.number="assistantForm.temperature" type="number" min="0" max="2" step="0.1" />
</label>
<label class="form-field checkbox-field">
<input v-model="assistantForm.enabled" type="checkbox" />
<span>启用真实 LLM 调用</span>
</label>
</div>
<label class="form-field">
<span>每次新建对话的系统提示词</span>
<textarea v-model.trim="assistantForm.system_prompt" rows="5"></textarea>
</label>
<button class="primary-button" type="submit">保存 AI 配置</button>
</form>
</section>
</section>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import PaginationBar from "../components/PaginationBar.vue";
import TableControls from "../components/TableControls.vue";
@ -291,6 +338,46 @@ const smartOperationReportConfig = ref({});
const feedbackMessage = ref("");
const errorMessage = ref("");
const editingBroadcastId = ref(null);
const showBroadcastDialog = ref(false);
const activeSection = ref("broadcast");
const extensionLoaded = ref(false);
const sectionSnapshots = reactive({
broadcast: "",
rawLotPrefix: "",
productionMode: "",
assistant: ""
});
const extensionSections = [
{
key: "broadcast",
eyebrow: "Broadcast",
title: "广播通知",
desc: "维护右上角小喇叭通知",
accent: "blue"
},
{
key: "rawLotPrefix",
eyebrow: "Lot Rule",
title: "库存批次规则",
desc: "维护原材料库存批次号前缀",
accent: "steel"
},
{
key: "productionMode",
eyebrow: "Production",
title: "生产对接模式",
desc: "控制是否对接智能报工小程序",
accent: "green"
},
{
key: "assistant",
eyebrow: "AI Assistant",
title: "AI 助手配置",
desc: "维护 LLM 供应商、模型与提示词",
accent: "orange"
}
];
const broadcastForm = reactive(buildEmptyBroadcastForm());
const assistantForm = reactive(buildEmptyAssistantForm());
@ -364,6 +451,17 @@ function resetBroadcastForm() {
editingBroadcastId.value = null;
Object.assign(broadcastForm, buildEmptyBroadcastForm());
broadcastForm.attendance_point_names = attendancePoints.value.map((point) => point.name);
captureSectionSnapshot("broadcast");
}
function openNewBroadcastDialog() {
resetBroadcastForm();
showBroadcastDialog.value = true;
}
function closeBroadcastDialog() {
showBroadcastDialog.value = false;
resetBroadcastForm();
}
function editBroadcast(item) {
@ -380,12 +478,110 @@ function editBroadcast(item) {
ends_at: toDateTimeInput(item.ends_at),
sort_no: Number(item.sort_no || 0)
});
captureSectionSnapshot("broadcast");
showBroadcastDialog.value = true;
}
function formatAttendancePoints(pointNames) {
return pointNames?.length ? pointNames.join(" / ") : "-";
}
function clonePlain(value) {
return JSON.parse(JSON.stringify(value));
}
function serializeValue(value) {
return JSON.stringify(value);
}
function getSectionState(sectionKey) {
if (sectionKey === "broadcast") {
return {
editingBroadcastId: editingBroadcastId.value,
...clonePlain(broadcastForm)
};
}
if (sectionKey === "rawLotPrefix") {
return clonePlain(rawLotPrefixForm);
}
if (sectionKey === "productionMode") {
return clonePlain(smartOperationReportForm);
}
if (sectionKey === "assistant") {
return {
...clonePlain(assistantForm),
api_key: ""
};
}
return {};
}
function captureSectionSnapshot(sectionKey) {
sectionSnapshots[sectionKey] = serializeValue(getSectionState(sectionKey));
}
function captureAllSectionSnapshots() {
extensionSections.forEach((section) => {
captureSectionSnapshot(section.key);
});
}
function restoreSectionSnapshot(sectionKey) {
const snapshot = sectionSnapshots[sectionKey] ? JSON.parse(sectionSnapshots[sectionKey]) : null;
if (!snapshot) {
return false;
}
if (sectionKey === "broadcast") {
editingBroadcastId.value = snapshot.editingBroadcastId ?? null;
Object.assign(broadcastForm, {
title: snapshot.title || "",
content: snapshot.content || "",
attendance_point_names: Array.isArray(snapshot.attendance_point_names)
? [...snapshot.attendance_point_names]
: attendancePoints.value.map((point) => point.name),
tone: snapshot.tone || "INFO",
status: snapshot.status || "ACTIVE",
starts_at: snapshot.starts_at || "",
ends_at: snapshot.ends_at || "",
sort_no: Number(snapshot.sort_no ?? 10)
});
return true;
}
if (sectionKey === "rawLotPrefix") {
rawLotPrefixForm.config_value = snapshot.config_value || "YL";
rawLotPrefixForm.remark = snapshot.remark || "";
return true;
}
if (sectionKey === "productionMode") {
smartOperationReportForm.enabled = snapshot.enabled !== false;
smartOperationReportForm.remark = snapshot.remark || "";
return true;
}
if (sectionKey === "assistant") {
Object.assign(assistantForm, {
assistant_name: snapshot.assistant_name || "嘉恒工艺助手",
provider_name: snapshot.provider_name || "",
api_base_url: snapshot.api_base_url || "",
api_key: "",
model_name: snapshot.model_name || "",
temperature: Number(snapshot.temperature ?? 0.3),
system_prompt: snapshot.system_prompt || buildEmptyAssistantForm().system_prompt,
enabled: Boolean(snapshot.enabled)
});
return true;
}
return false;
}
function applyAssistantConfig(config) {
assistantConfig.value = config || {};
Object.assign(assistantForm, {
@ -412,6 +608,62 @@ function applySmartOperationReportConfig(config) {
smartOperationReportForm.remark = config?.remark || "";
}
const activeSectionMeta = computed(() => {
return extensionSections.find((section) => section.key === activeSection.value) || extensionSections[0];
});
const activeSectionFormId = computed(() => `extension-form-${activeSection.value}`);
const activeBroadcastCount = computed(() => {
return broadcasts.value.filter((item) => item.status === "ACTIVE").length;
});
const assistantStateLabel = computed(() => {
if (assistantConfig.value.enabled && assistantConfig.value.api_key_configured) {
return "已启用";
}
if (assistantConfig.value.api_key_configured) {
return "密钥已配置";
}
return "占位模式";
});
const statusCards = computed(() => [
{
label: "广播启用",
value: `${activeBroadcastCount.value}`,
tone: activeBroadcastCount.value ? "success" : "muted"
},
{
label: "库存批次前缀",
value: rawLotPrefixConfig.value.config_value || rawLotPrefixForm.config_value || "YL",
tone: "info"
},
{
label: "报工小程序",
value: smartOperationReportForm.enabled ? "已对接" : "未对接",
tone: smartOperationReportForm.enabled ? "success" : "warning"
},
{
label: "AI 助手",
value: assistantStateLabel.value,
tone: assistantConfig.value.enabled ? "success" : "muted"
}
]);
const activeSectionDirty = computed(() => {
if (!extensionLoaded.value) {
return false;
}
if (activeSection.value === "broadcast") {
return false;
}
if (activeSection.value === "assistant" && assistantForm.api_key.trim()) {
return true;
}
return sectionSnapshots[activeSection.value] !== serializeValue(getSectionState(activeSection.value));
});
async function loadAll() {
const [pointRows, broadcastRows, config, rawLotPrefix, smartOperationReport] = await Promise.all([
fetchResource("/master-data/miniapp-attendance-points", []),
@ -451,10 +703,18 @@ async function saveBroadcast() {
? await putResource(`/system-extension/broadcasts/${editingBroadcastId.value}`, payload)
: await postResource("/system-extension/broadcasts", payload);
feedbackMessage.value = `广播「${result.title}」已保存`;
showBroadcastDialog.value = false;
resetBroadcastForm();
try {
broadcasts.value = await fetchResource("/system-extension/broadcasts", []);
} catch (refreshError) {
errorMessage.value = `广播已保存,但列表刷新失败:${refreshError.message || "请稍后刷新页面"}`;
}
captureSectionSnapshot("broadcast");
return true;
} catch (error) {
errorMessage.value = error.message || "广播保存失败";
return false;
}
}
@ -475,8 +735,11 @@ async function saveAssistantConfig() {
const result = await putResource("/system-extension/assistant-config", payload);
applyAssistantConfig(result);
feedbackMessage.value = "AI 机器人配置已保存";
captureSectionSnapshot("assistant");
return true;
} catch (error) {
errorMessage.value = error.message || "AI 配置保存失败";
return false;
}
}
@ -490,8 +753,11 @@ async function saveRawLotPrefixConfig() {
});
applyRawLotPrefixConfig(result);
feedbackMessage.value = "原材料库存批次号前缀已保存";
captureSectionSnapshot("rawLotPrefix");
return true;
} catch (error) {
errorMessage.value = error.message || "原材料库存批次号前缀保存失败";
return false;
}
}
@ -506,12 +772,46 @@ async function saveSmartOperationReportConfig() {
applySmartOperationReportConfig(result);
resetSmartOperationReportConfigCache(result);
feedbackMessage.value = "生产模式配置已保存";
captureSectionSnapshot("productionMode");
return true;
} catch (error) {
errorMessage.value = error.message || "生产模式配置保存失败";
return false;
}
}
async function saveActiveSection() {
if (activeSection.value === "broadcast") {
return saveBroadcast();
}
if (activeSection.value === "rawLotPrefix") {
return saveRawLotPrefixConfig();
}
if (activeSection.value === "productionMode") {
return saveSmartOperationReportConfig();
}
if (activeSection.value === "assistant") {
return saveAssistantConfig();
}
return false;
}
function resetActiveSection() {
restoreSectionSnapshot(activeSection.value);
}
onMounted(async () => {
try {
await loadAll();
} catch (error) {
errorMessage.value = error.message || "系统拓展配置加载失败";
} finally {
captureAllSectionSnapshots();
extensionLoaded.value = true;
}
});
</script>