完善系统拓展与原材料生命周期视图
This commit is contained in:
+798
-35
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(".", "\\.")));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user