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

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
+798 -35
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;
+5 -3
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";
}
}
+63
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(".", "\\.")));
});
});
});
+151 -86
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);
});
});
@@ -566,11 +536,11 @@ const stageBands = computed(() => {
layoutNodes.value.forEach((node) => {
if (!stages.has(node.level)) {
stages.set(node.level, {
level: node.level,
label: node.stage,
left: Math.max(0, node.x - 18),
width: NODE_WIDTH + 36
});
level: node.level,
label: stageBandLabel(node.level),
left: Math.max(0, node.x - 18),
width: NODE_WIDTH + 36
});
}
});
return Array.from(stages.values()).sort((left, right) => left.level - right.level);
@@ -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;
}
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;
}
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
}));
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) {
@@ -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`);
});
});
});
+551 -251
View File
@@ -1,278 +1,325 @@
<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 的配置。"
<section class="system-extension-workbench">
<header class="extension-workbench-head">
<div>
<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}`"
>
<h3>广播通知与 AI 助手配置中心</h3>
</span>
<span>{{ card.label }}</span>
<strong>{{ card.value }}</strong>
</article>
</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>
</header>
<section class="panel">
<div class="panel-header">
<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>
</div>
</div>
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
<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>
<form class="stack-form compact-extension-form" @submit.prevent="saveBroadcast">
<div class="double-field">
<label class="form-field">
<span>广播标题 <i class="required-mark">*</i></span>
<input v-model.trim="broadcastForm.title" type="text" required />
</label>
<label class="form-field">
<span>提示类型</span>
<select v-model="broadcastForm.tone">
<option value="INFO">信息</option>
<option value="SUCCESS">成功</option>
<option value="WARNING">预警</option>
<option value="DANGER">重要</option>
</select>
</label>
<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>
<label class="form-field">
<span>广播内容 <i class="required-mark">*</i></span>
<input v-model.trim="broadcastForm.content" type="text" required />
</label>
<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>
<div class="form-field">
<span>通知考勤点 <i class="required-mark">*</i></span>
<div class="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>
<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>
</div>
</form>
<div class="triple-field">
<label class="form-field">
<span>开始时间</span>
<input v-model="broadcastForm.starts_at" type="datetime-local" />
<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>
<label class="form-field">
<span>结束时间</span>
<input v-model="broadcastForm.ends_at" type="datetime-local" />
</label>
<label class="form-field">
<span>排序号</span>
<input v-model.number="broadcastForm.sort_no" type="number" step="1" />
</label>
</div>
<div class="double-field">
<label class="form-field">
<span>状态</span>
<select v-model="broadcastForm.status">
<option value="ACTIVE">启用</option>
<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()}`">
<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" />
<path d="M7.5 13.5L9.2 19H12.2L10.4 14.8" />
<path d="M18.3 9.2L20.5 8" />
<path d="M18.7 12H21.4" />
<path d="M18.3 14.8L20.5 16" />
</svg>
</span>
<strong>{{ broadcastForm.title || "广播标题" }}</strong>
<span>{{ broadcastForm.content || "广播内容会显示在系统右上角" }}</span>
</div>
</div>
</div>
<details class="extension-detail-note">
<summary>查看影响范围</summary>
<p>开启时显示工序报工和小程序证据;关闭时隐藏工序报工,生产按 ERP 入库闭环推进。</p>
</details>
<button class="primary-button" type="submit">{{ editingBroadcastId ? "更新广播" : "保存广播" }}</button>
</form>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">仓库编号配置</p>
<h3>原材料库存批次号前缀</h3>
</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="例如:原材料库存批次号默认前缀" />
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如:客户未购买小程序模块,暂按ERP入库反推" />
</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>
</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>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">生产模式配置</p>
<h3>对接智能报工小程序</h3>
</div>
<span class="panel-tag">{{ smartOperationReportForm.enabled ? "已对接" : "未对接" }}</span>
<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 />
</label>
<label class="form-field">
<span>提示类型</span>
<select v-model="broadcastForm.tone">
<option value="INFO">信息</option>
<option value="SUCCESS">成功</option>
<option value="WARNING">预警</option>
<option value="DANGER">重要</option>
</select>
</label>
</div>
<label class="form-field">
<span>广播内容 <i class="required-mark">*</i></span>
<input v-model.trim="broadcastForm.content" type="text" required />
</label>
<div class="form-field">
<span>通知考勤点 <i class="required-mark">*</i></span>
<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>
</label>
</div>
</div>
<div class="extension-field-grid extension-field-grid-3">
<label class="form-field">
<span>开始时间</span>
<input v-model="broadcastForm.starts_at" type="datetime-local" />
</label>
<label class="form-field">
<span>结束时间</span>
<input v-model="broadcastForm.ends_at" type="datetime-local" />
</label>
<label class="form-field">
<span>排序号</span>
<input v-model.number="broadcastForm.sort_no" type="number" step="1" />
</label>
</div>
<div class="extension-field-grid extension-broadcast-status-row">
<label class="form-field">
<span>状态</span>
<select v-model="broadcastForm.status">
<option value="ACTIVE">启用</option>
<option value="PAUSED">暂停</option>
</select>
</label>
<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" />
<path d="M7.5 13.5L9.2 19H12.2L10.4 14.8" />
<path d="M18.3 9.2L20.5 8" />
<path d="M18.7 12H21.4" />
<path d="M18.3 14.8L20.5 16" />
</svg>
</span>
<strong>{{ broadcastForm.title || "广播标题" }}</strong>
<span>{{ broadcastForm.content || "广播内容" }}</span>
</div>
</div>
</details>
</div>
<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>
<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>
<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>
</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();
broadcasts.value = await fetchResource("/system-extension/broadcasts", []);
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 () => {
await loadAll();
try {
await loadAll();
} catch (error) {
errorMessage.value = error.message || "系统拓展配置加载失败";
} finally {
captureAllSectionSnapshots();
extensionLoaded.value = true;
}
});
</script>