diff --git a/web/src/assets/styles/views/audit-view.css b/web/src/assets/styles/views/audit-view.css
index 0948a59..353f46e 100644
--- a/web/src/assets/styles/views/audit-view.css
+++ b/web/src/assets/styles/views/audit-view.css
@@ -26,6 +26,10 @@
gap: 12px;
}
+.skill-detail.spreadsheet-skill-detail {
+ gap: 10px;
+}
+
.skill-list {
display: flex;
flex-direction: column;
@@ -474,6 +478,7 @@ tbody tr.spotlight {
}
.skill-avatar.emerald { background: linear-gradient(135deg, #10b981, #059669); }
+.skill-avatar.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.skill-avatar.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-avatar.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-avatar.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
@@ -557,6 +562,13 @@ tbody tr.spotlight {
gap: 16px;
}
+.spreadsheet-skill-detail .detail-scroll {
+ min-height: 0;
+ overflow: hidden;
+ align-content: stretch;
+ grid-template-rows: minmax(0, 1fr);
+}
+
.detail-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
@@ -581,6 +593,7 @@ tbody tr.spotlight {
}
.skill-badge.emerald { background: linear-gradient(135deg, #10b981, #059669); }
+.skill-badge.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.skill-badge.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-badge.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-badge.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
@@ -813,6 +826,832 @@ tbody tr.spotlight {
gap: 12px;
}
+.spreadsheet-rule-card {
+ display: grid;
+ gap: 14px;
+}
+
+.spreadsheet-editor-shell {
+ min-height: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px;
+}
+
+.spreadsheet-editor-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.spreadsheet-editor-title {
+ min-width: 0;
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.spreadsheet-editor-title h2 {
+ color: #0f172a;
+ font-size: 18px;
+ font-weight: 850;
+}
+
+.spreadsheet-editor-title p {
+ margin-top: 2px;
+ max-width: 760px;
+ color: #64748b;
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.spreadsheet-editor-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.spreadsheet-editor-meta {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.spreadsheet-editor-meta span {
+ min-height: 28px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 10px;
+ border-radius: 999px;
+ background: #f8fafc;
+ color: #475569;
+ font-size: 12px;
+ font-weight: 750;
+}
+
+.spreadsheet-editor-meta strong {
+ color: #0f172a;
+ font-weight: 850;
+}
+
+.spreadsheet-editor-body {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 286px;
+ gap: 14px;
+ align-items: start;
+}
+
+.spreadsheet-main-stage {
+ min-height: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.spreadsheet-workbench {
+ flex: 1 1 auto;
+ position: relative;
+ min-height: 0;
+ overflow: visible;
+ border: 1px solid #dbe4ee;
+ border-radius: 12px;
+ background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
+}
+
+.spreadsheet-workbench .rule-spreadsheet-host {
+ min-height: 0;
+ height: 100%;
+ overflow: visible;
+}
+
+.spreadsheet-editor-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ color: #64748b;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.spreadsheet-version-center {
+ min-height: 0;
+ height: 100%;
+ align-self: stretch;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid #dbe4ee;
+ border-radius: 14px;
+ background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
+ overflow: hidden;
+}
+
+.version-center-head h3,
+.version-center-head p,
+.version-center-section header,
+.version-center-section p {
+ margin: 0;
+}
+
+.version-center-head h3 {
+ color: #0f172a;
+ font-size: 15px;
+ font-weight: 900;
+}
+
+.version-center-head p {
+ margin-top: 3px;
+ color: #64748b;
+ font-size: 12px;
+}
+
+.version-pair-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.version-pair-card {
+ display: grid;
+ gap: 4px;
+ min-height: 84px;
+ padding: 10px;
+ border-radius: 12px;
+}
+
+.version-pair-card span {
+ color: #64748b;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.version-pair-card strong {
+ color: #0f172a;
+ font-size: 16px;
+ font-weight: 900;
+}
+
+.version-pair-card b {
+ width: fit-content;
+ min-height: 20px;
+ display: inline-flex;
+ align-items: center;
+ padding: 0 7px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.version-pair-card.published {
+ background: rgba(16, 185, 129, 0.1);
+}
+
+.version-pair-card.published b {
+ background: #dcfce7;
+ color: #059669;
+}
+
+.version-pair-card.working {
+ background: rgba(37, 99, 235, 0.08);
+}
+
+.version-pair-card.working b {
+ background: #dbeafe;
+ color: #2563eb;
+}
+
+.version-center-section {
+ display: grid;
+ gap: 8px;
+}
+
+.version-history-section {
+ min-height: 0;
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.version-center-section > header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.version-center-section > header strong {
+ color: #0f172a;
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.version-center-section > header small,
+.version-center-section > header button {
+ color: #64748b;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.version-center-section > header button {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: #2563eb;
+ cursor: pointer;
+}
+
+.version-center-list {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 2px;
+}
+
+.version-center-item {
+ display: grid;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ background: #fff;
+}
+
+.version-center-item.active {
+ border-color: rgba(16, 185, 129, 0.35);
+ background: rgba(16, 185, 129, 0.05);
+}
+
+.version-center-item > button {
+ display: grid;
+ gap: 5px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+}
+
+.version-center-item > button div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.version-center-item > button strong {
+ color: #0f172a;
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.version-center-item > button span,
+.version-center-item > button p {
+ color: #64748b;
+ font-size: 11px;
+}
+
+.version-center-item > button p {
+ margin: 0;
+ line-height: 1.45;
+}
+
+.version-center-item footer {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.version-center-item footer button {
+ min-height: 24px;
+ padding: 0 9px;
+ border: 0;
+ border-radius: 999px;
+ background: #f1f5f9;
+ color: #475569;
+ font-size: 11px;
+ font-weight: 850;
+ cursor: pointer;
+}
+
+.version-center-item footer button:nth-child(2) {
+ background: #eef2ff;
+ color: #4f46e5;
+}
+
+.version-center-item footer button:nth-child(3) {
+ background: #fff7ed;
+ color: #ea580c;
+}
+
+.version-flow-preview {
+ display: grid;
+ gap: 8px;
+}
+
+.version-flow-preview article {
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr);
+ gap: 8px;
+ align-items: start;
+}
+
+.version-flow-preview i {
+ color: #2563eb;
+ font-size: 16px;
+}
+
+.version-flow-preview div {
+ display: grid;
+ gap: 2px;
+}
+
+.version-flow-preview strong {
+ color: #0f172a;
+ font-size: 12px;
+}
+
+.version-flow-preview span,
+.version-flow-preview small,
+.version-flow-empty {
+ color: #64748b;
+ font-size: 11px;
+}
+
+.version-flow-preview small {
+ grid-column: 2;
+}
+
+.rule-drawer-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+ display: flex;
+ justify-content: flex-end;
+ background: rgba(15, 23, 42, 0.34);
+ backdrop-filter: blur(3px);
+}
+
+.rule-drawer {
+ width: min(860px, 78vw);
+ height: 100%;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 18px;
+ padding: 22px;
+ overflow: auto;
+ background: #fff;
+ box-shadow: -18px 0 42px rgba(15, 23, 42, 0.18);
+}
+
+.timeline-drawer {
+ width: min(640px, 62vw);
+}
+
+.rule-drawer-head {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.rule-drawer-head span {
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.rule-drawer-head h3 {
+ margin: 4px 0 0;
+ color: #0f172a;
+ font-size: 20px;
+ font-weight: 950;
+}
+
+.rule-drawer-head button {
+ width: 34px;
+ height: 34px;
+ border: 0;
+ border-radius: 999px;
+ background: #f1f5f9;
+ color: #475569;
+ cursor: pointer;
+}
+
+.rule-drawer-state {
+ min-height: 160px;
+ display: grid;
+ place-items: center;
+ gap: 10px;
+ color: #64748b;
+}
+
+.rule-drawer-state.error {
+ color: #dc2626;
+}
+
+.rule-timeline-list {
+ display: grid;
+ align-content: start;
+}
+
+.rule-timeline-item {
+ position: relative;
+ display: grid;
+ grid-template-columns: 30px minmax(0, 1fr);
+ gap: 12px;
+ padding-bottom: 20px;
+}
+
+.rule-timeline-item:not(:last-child)::after {
+ content: '';
+ position: absolute;
+ left: 14px;
+ top: 28px;
+ bottom: 0;
+ width: 2px;
+ background: #e2e8f0;
+}
+
+.rule-timeline-item > i {
+ position: relative;
+ z-index: 1;
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: 999px;
+ background: #f1f5f9;
+ color: #475569;
+}
+
+.rule-timeline-item > i.success {
+ background: #dcfce7;
+ color: #059669;
+}
+
+.rule-timeline-item > i.warning {
+ background: #fef3c7;
+ color: #d97706;
+}
+
+.rule-timeline-item > i.danger {
+ background: #fee2e2;
+ color: #dc2626;
+}
+
+.rule-timeline-item > i.info {
+ background: #dbeafe;
+ color: #2563eb;
+}
+
+.rule-timeline-item header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.rule-timeline-item header strong {
+ color: #0f172a;
+ font-size: 14px;
+}
+
+.rule-timeline-item header b {
+ min-height: 22px;
+ display: inline-flex;
+ align-items: center;
+ padding: 0 8px;
+ border-radius: 999px;
+ background: #f1f5f9;
+ color: #475569;
+ font-size: 11px;
+}
+
+.rule-timeline-item header span,
+.rule-timeline-item p,
+.rule-timeline-item small {
+ color: #64748b;
+}
+
+.rule-timeline-item p {
+ margin: 8px 0 4px;
+ line-height: 1.6;
+}
+
+.compare-toolbar {
+ display: flex;
+ align-items: end;
+ gap: 12px;
+}
+
+.compare-toolbar label {
+ min-width: 0;
+ display: grid;
+ gap: 6px;
+ flex: 1;
+}
+
+.compare-toolbar span {
+ color: #64748b;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.compare-toolbar select {
+ width: 100%;
+ min-height: 40px;
+ padding: 0 12px;
+ border: 1px solid #cbd5e1;
+ border-radius: 12px;
+ background: #fff;
+}
+
+.compare-toolbar i {
+ margin-bottom: 10px;
+ color: #94a3b8;
+}
+
+.compare-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.compare-summary-grid article {
+ display: grid;
+ gap: 4px;
+ min-height: 76px;
+ padding: 12px;
+ border-radius: 14px;
+ background: #f8fafc;
+}
+
+.compare-summary-grid span {
+ color: #64748b;
+ font-size: 12px;
+}
+
+.compare-summary-grid strong {
+ color: #0f172a;
+ font-size: 24px;
+ font-weight: 950;
+}
+
+.compare-panel {
+ display: grid;
+ gap: 10px;
+}
+
+.compare-panel header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.compare-panel header strong {
+ color: #0f172a;
+ font-size: 14px;
+}
+
+.compare-panel p,
+.compare-panel small {
+ color: #64748b;
+}
+
+.compare-sheet-list {
+ display: grid;
+ gap: 8px;
+}
+
+.compare-sheet-list article {
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ background: #fff;
+}
+
+.compare-sheet-list strong {
+ min-width: 0;
+ color: #0f172a;
+ font-size: 13px;
+ font-weight: 850;
+ overflow-wrap: anywhere;
+}
+
+.compare-sheet-list b {
+ min-height: 24px;
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+ padding: 0 9px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.compare-sheet-list b.success {
+ background: #dcfce7;
+ color: #059669;
+}
+
+.compare-sheet-list b.danger {
+ background: #fee2e2;
+ color: #dc2626;
+}
+
+.compare-table-wrap {
+ overflow: auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+}
+
+.compare-table-wrap table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.compare-table-wrap th,
+.compare-table-wrap td {
+ padding: 10px 12px;
+ border-bottom: 1px solid #eef2f7;
+ text-align: left;
+ vertical-align: top;
+}
+
+.compare-table-wrap th {
+ color: #475569;
+ font-size: 12px;
+ background: #f8fafc;
+}
+
+.compare-table-wrap td {
+ color: #0f172a;
+ font-size: 13px;
+}
+
+.compare-table-wrap td b {
+ min-height: 22px;
+ display: inline-flex;
+ align-items: center;
+ padding: 0 8px;
+ border-radius: 999px;
+ font-size: 11px;
+}
+
+.compare-table-wrap td b.success {
+ background: #dcfce7;
+ color: #059669;
+}
+
+.compare-table-wrap td b.warning {
+ background: #fef3c7;
+ color: #d97706;
+}
+
+.compare-table-wrap td b.danger {
+ background: #fee2e2;
+ color: #dc2626;
+}
+
+@media (max-width: 1280px) {
+ .spreadsheet-editor-body {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 900px) {
+ .rule-drawer,
+ .timeline-drawer {
+ width: 100%;
+ }
+
+ .compare-summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.rule-spreadsheet-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.spreadsheet-mode-pill {
+ min-height: 28px;
+ display: inline-flex;
+ align-items: center;
+ padding: 0 10px;
+ border-radius: 999px;
+ background: #eff6ff;
+ color: #1d4ed8;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.spreadsheet-upload-input {
+ display: none;
+}
+
+.spreadsheet-meta-strip {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.spreadsheet-meta-strip span {
+ min-height: 28px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 10px;
+ border-radius: 999px;
+ background: #f8fafc;
+ color: #475569;
+ font-size: 12px;
+ font-weight: 750;
+}
+
+.spreadsheet-meta-strip strong {
+ color: #0f172a;
+ font-weight: 850;
+}
+
+.rule-spreadsheet-stage {
+ position: relative;
+ min-height: 720px;
+ overflow: hidden;
+ border: 1px solid #dbe4ee;
+ border-radius: 14px;
+ background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
+}
+
+.rule-spreadsheet-host {
+ width: 100%;
+ height: 100%;
+ min-height: 720px;
+}
+
+.rule-spreadsheet-host.hidden {
+ visibility: hidden;
+}
+
+.rule-spreadsheet-state {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ gap: 8px;
+ padding: 24px;
+ background: rgba(248, 250, 252, 0.94);
+ color: #475569;
+ font-size: 13px;
+ font-weight: 800;
+ text-align: center;
+}
+
+.rule-spreadsheet-state i {
+ font-size: 22px;
+}
+
+.rule-spreadsheet-state.error {
+ color: #dc2626;
+}
+
+.preview-mode-note {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 14px;
+ padding: 10px 12px;
+ border: 1px solid rgba(14, 165, 233, 0.22);
+ border-radius: 12px;
+ background: linear-gradient(180deg, rgba(240, 249, 255, 0.96), rgba(224, 242, 254, 0.9));
+ color: #075985;
+ font-size: 12px;
+ font-weight: 760;
+ line-height: 1.5;
+}
+
+.preview-mode-note i {
+ font-size: 16px;
+}
+
.markdown-card .field {
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
@@ -958,7 +1797,6 @@ tbody tr.spotlight {
border-left: 0;
background: transparent;
text-align: left;
- cursor: pointer;
transition: background 180ms ease;
}
@@ -976,6 +1814,17 @@ tbody tr.spotlight {
background: rgba(16, 185, 129, 0.08);
}
+.version-main {
+ display: grid;
+ gap: 6px;
+ width: 100%;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+}
+
.version-row-head {
display: grid;
grid-template-columns: minmax(52px, 1fr) 46px 82px;
@@ -1017,12 +1866,76 @@ tbody tr.spotlight {
font-weight: 850;
}
+.version-current-slot .current-version.working {
+ background: #dbeafe;
+ color: #2563eb;
+}
+
.version-row p {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
+.version-row-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.version-state {
+ min-height: 22px;
+ display: inline-flex;
+ align-items: center;
+ padding: 0 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.version-state.success {
+ background: #dcfce7;
+ color: #059669;
+}
+
+.version-state.warning {
+ background: #fef3c7;
+ color: #d97706;
+}
+
+.version-state.danger {
+ background: #fee2e2;
+ color: #dc2626;
+}
+
+.version-state.draft {
+ background: #e2e8f0;
+ color: #475569;
+}
+
+.version-state.disabled {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.version-restore-btn {
+ min-height: 24px;
+ padding: 0 9px;
+ border: 0;
+ border-radius: 999px;
+ background: #eef2ff;
+ color: #4f46e5;
+ font-size: 11px;
+ font-weight: 850;
+ cursor: pointer;
+}
+
+.version-restore-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
.empty-side-note {
min-height: 120px;
display: grid;
@@ -1392,6 +2305,11 @@ tbody tr.spotlight {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+ .rule-spreadsheet-stage,
+ .rule-spreadsheet-host {
+ min-height: 620px;
+ }
+
.detail-grid {
grid-template-columns: 1fr;
}
diff --git a/web/src/assets/styles/views/logs-view.css b/web/src/assets/styles/views/logs-view.css
index 3853eeb..a77fe74 100644
--- a/web/src/assets/styles/views/logs-view.css
+++ b/web/src/assets/styles/views/logs-view.css
@@ -282,12 +282,34 @@
line-height: 1.5;
}
+.summary-meta {
+ display: block;
+ margin-top: 6px;
+ color: #94a3b8;
+ font-size: 12px;
+ font-style: normal;
+ line-height: 1.5;
+}
+
.trace-cell {
max-width: 180px;
color: #2563eb;
word-break: break-all;
}
+.status-stack {
+ display: grid;
+ gap: 5px;
+ align-content: start;
+}
+
+.status-note {
+ color: #64748b;
+ font-size: 12px;
+ line-height: 1.5;
+ word-break: break-word;
+}
+
.system-table .summary-cell {
min-width: 260px;
}
diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js
index 5f81095..b6054e6 100644
--- a/web/src/utils/accessControl.js
+++ b/web/src/utils/accessControl.js
@@ -14,7 +14,7 @@ const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver'],
- audit: ['auditor'],
+ audit: ['auditor', 'finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
@@ -28,9 +28,13 @@ function normalizedRoleCodes(user) {
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
}
-export function isManagerUser(user) {
- return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
-}
+export function isManagerUser(user) {
+ return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
+}
+
+export function isFinanceUser(user) {
+ return normalizedRoleCodes(user).includes('finance')
+}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
diff --git a/web/src/utils/agentRunMonitor.js b/web/src/utils/agentRunMonitor.js
new file mode 100644
index 0000000..e327da4
--- /dev/null
+++ b/web/src/utils/agentRunMonitor.js
@@ -0,0 +1,271 @@
+const KNOWLEDGE_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
+
+const STATUS_LABELS = {
+ running: '运行中',
+ succeeded: '已完成',
+ failed: '失败',
+ blocked: '待确认'
+}
+
+const STATUS_TONES = {
+ running: 'warning',
+ succeeded: 'success',
+ failed: 'danger',
+ blocked: 'muted'
+}
+
+const PHASE_LABELS = {
+ queued: '排队中',
+ indexing: '归纳中',
+ completed: '已完成',
+ failed: '失败',
+ stale_failed: '超时失败'
+}
+
+export const AGENT_RUN_POLL_INTERVAL_MS = 5000
+export const AGENT_RUN_HEARTBEAT_DELAY_MS = 60 * 1000
+export const AGENT_RUN_HEARTBEAT_STUCK_MS = 5 * 60 * 1000
+
+function toDate(value) {
+ if (!value) {
+ return null
+ }
+
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+function resolveNowMs(now = Date.now()) {
+ if (now instanceof Date) {
+ return now.getTime()
+ }
+
+ const numeric = Number(now)
+ return Number.isFinite(numeric) ? numeric : Date.now()
+}
+
+export function isKnowledgeIndexRun(run) {
+ const jobType = String(run?.route_json?.job_type || '').trim()
+ return KNOWLEDGE_JOB_TYPES.has(jobType)
+}
+
+export function getAgentRunPhase(run) {
+ return String(run?.route_json?.phase || '').trim()
+}
+
+export function formatDurationShort(valueMs) {
+ const numeric = Number(valueMs)
+ if (!Number.isFinite(numeric) || numeric < 0) {
+ return '—'
+ }
+
+ const totalSeconds = Math.max(0, Math.round(numeric / 1000))
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = totalSeconds % 60
+
+ if (hours > 0) {
+ return `${hours}小时${minutes}分`
+ }
+ if (minutes > 0) {
+ return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分`
+ }
+ return `${seconds}秒`
+}
+
+export function formatAgentRunElapsed(run, now = Date.now()) {
+ const startedAt = toDate(run?.started_at)
+ if (startedAt === null) {
+ return '—'
+ }
+
+ const finishedAt = toDate(run?.finished_at)
+ const endMs = finishedAt ? finishedAt.getTime() : resolveNowMs(now)
+ return formatDurationShort(endMs - startedAt.getTime())
+}
+
+export function resolveAgentRunPhaseLabel(run) {
+ const phase = getAgentRunPhase(run)
+ return PHASE_LABELS[phase] || STATUS_LABELS[String(run?.status || '').trim()] || '未知'
+}
+
+export function resolveAgentRunHeartbeat(run, now = Date.now()) {
+ const heartbeatAt = toDate(run?.route_json?.heartbeat_at)
+ const startedAt = toDate(run?.started_at)
+ const nowMs = resolveNowMs(now)
+ const phase = getAgentRunPhase(run)
+ const isRunning = String(run?.status || '').trim() === 'running'
+ const heartbeatAgeMs = heartbeatAt ? Math.max(0, nowMs - heartbeatAt.getTime()) : null
+ const startedAgeMs = startedAt ? Math.max(0, nowMs - startedAt.getTime()) : null
+
+ if (heartbeatAt) {
+ if (heartbeatAgeMs >= AGENT_RUN_HEARTBEAT_STUCK_MS) {
+ return {
+ at: heartbeatAt,
+ ageMs: heartbeatAgeMs,
+ text: `${formatDurationShort(heartbeatAgeMs)}前`,
+ label: '疑似中断',
+ tone: 'danger'
+ }
+ }
+ if (heartbeatAgeMs >= AGENT_RUN_HEARTBEAT_DELAY_MS) {
+ return {
+ at: heartbeatAt,
+ ageMs: heartbeatAgeMs,
+ text: `${formatDurationShort(heartbeatAgeMs)}前`,
+ label: '心跳延迟',
+ tone: 'warning'
+ }
+ }
+ return {
+ at: heartbeatAt,
+ ageMs: heartbeatAgeMs,
+ text: `${formatDurationShort(heartbeatAgeMs)}前`,
+ label: '心跳正常',
+ tone: 'success'
+ }
+ }
+
+ if (!isKnowledgeIndexRun(run) || !isRunning) {
+ return {
+ at: null,
+ ageMs: null,
+ text: '—',
+ label: '无心跳',
+ tone: 'muted'
+ }
+ }
+
+ if (phase === 'queued') {
+ return {
+ at: null,
+ ageMs: startedAgeMs,
+ text: '尚未开始',
+ label: '等待执行',
+ tone: 'muted'
+ }
+ }
+
+ if ((startedAgeMs || 0) >= AGENT_RUN_HEARTBEAT_DELAY_MS) {
+ return {
+ at: null,
+ ageMs: startedAgeMs,
+ text: '尚未收到',
+ label: '无心跳',
+ tone: 'warning'
+ }
+ }
+
+ return {
+ at: null,
+ ageMs: startedAgeMs,
+ text: '等待首个心跳',
+ label: '等待心跳',
+ tone: 'muted'
+ }
+}
+
+export function resolveAgentRunStatus(run, now = Date.now()) {
+ const status = String(run?.status || '').trim()
+ const phase = getAgentRunPhase(run)
+ const heartbeat = resolveAgentRunHeartbeat(run, now)
+ let label = STATUS_LABELS[status] || status || '未知'
+ let tone = STATUS_TONES[status] || 'muted'
+ let note = ''
+ let isSuspicious = false
+
+ if (status === 'failed' && phase === 'stale_failed') {
+ return {
+ label: '已超时',
+ tone: 'danger',
+ note: '系统已按长时间无心跳自动判定失败',
+ phase,
+ phaseLabel: resolveAgentRunPhaseLabel(run),
+ heartbeat,
+ isSuspicious: true
+ }
+ }
+
+ if (status === 'running' && isKnowledgeIndexRun(run)) {
+ if (phase === 'queued') {
+ label = '排队中'
+ tone = 'muted'
+ note = '等待后台线程接管'
+ } else if (phase === 'indexing') {
+ if (heartbeat.at === null && heartbeat.label === '无心跳') {
+ label = '无心跳'
+ tone = 'warning'
+ note = '已进入归纳流程,但还没有收到心跳'
+ isSuspicious = true
+ } else if (heartbeat.tone === 'danger') {
+ label = '疑似卡住'
+ tone = 'danger'
+ note = `最后心跳在 ${heartbeat.text}`
+ isSuspicious = true
+ } else if (heartbeat.tone === 'warning') {
+ label = '心跳延迟'
+ tone = 'warning'
+ note = `最后心跳在 ${heartbeat.text}`
+ isSuspicious = true
+ } else if (heartbeat.at === null) {
+ label = '归纳启动中'
+ tone = 'warning'
+ note = '任务已启动,等待首个心跳'
+ } else {
+ label = '归纳中'
+ tone = 'warning'
+ note = `最后心跳在 ${heartbeat.text}`
+ }
+ }
+ }
+
+ if (!note && status === 'failed' && run?.error_message) {
+ note = String(run.error_message).trim()
+ }
+
+ return {
+ label,
+ tone,
+ note,
+ phase,
+ phaseLabel: resolveAgentRunPhaseLabel(run),
+ heartbeat,
+ isSuspicious
+ }
+}
+
+export function formatAgentRunProgress(run) {
+ const progress = run?.route_json?.progress || {}
+ const percent = Number(progress.percent || 0)
+ const completed = Number(progress.completed_documents || 0)
+ const total = Number(progress.total_documents || 0)
+ const failed = Number(progress.failed_documents || 0)
+ const stage = String(progress.current_stage || '').trim()
+ const stageLabelMap = {
+ document_started: '文档启动',
+ text_extracted: '文本已提取',
+ candidate_chunks_selected: '已筛正文',
+ extracting_candidates: '候选提炼中',
+ candidate_extraction_completed: '候选提炼完成',
+ document_completed: '文档完成',
+ skipped: '跳过'
+ }
+ const stageLabel = stageLabelMap[stage] || stage
+
+ if (total > 0) {
+ const parts = [`${percent}%`, `${completed}/${total} 文档`]
+ if (failed > 0) {
+ parts.push(`失败 ${failed}`)
+ }
+ if (stageLabel) {
+ parts.push(stageLabel)
+ }
+ return parts.join(' · ')
+ }
+
+ if (stageLabel) {
+ return `${percent}% · ${stageLabel}`
+ }
+
+ return `${percent}%`
+}
diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue
index 2cd7216..05eb3ce 100644
--- a/web/src/views/AuditView.vue
+++ b/web/src/views/AuditView.vue
@@ -1,707 +1,1125 @@
-
-
-
-
-
+
+
-
-
-
-
-
-
-
- {{ hintText }}
-
-
-
- {{ token }}
-
-
-
-
-
-
-
正在加载{{ activeTabLabel }}资产...
-
-
-
-
-
{{ errorMessage }}
-
-
-
-
-
-
-
-
- | {{ tableColumns.name }} |
- {{ tableColumns.category }} |
- {{ tableColumns.owner }} |
- {{ tableColumns.scope }} |
- {{ tableColumns.runtime }} |
- {{ tableColumns.version }} |
- 状态 |
- {{ tableColumns.metric }} |
- 最近更新 |
-
-
-
-
-
-
- {{ skill.short }}
-
- {{ skill.name }}
- {{ skill.summary }}
-
-
- |
- {{ skill.category }} |
- {{ skill.owner }} |
- {{ skill.scope }} |
- {{ skill.model }} |
- {{ skill.version }} |
- {{ skill.status }} |
- {{ skill.hitRate }} |
- {{ skill.updatedAt }} |
-
-
-
-
-
-
-
-
-
-
-
-
- 当前展示版本
- {{ selectedSkill?.displayVersion }}
-
-
-
- 目标版本
- {{ versionSwitchTarget?.version }}
-
-
-
-
- {{ versionSwitchTarget.note }}
- {{ versionSwitchTarget.time }}
-
-
-
-
-
-
-
-
+ v-if="selectedSkill.usesSpreadsheetRule"
+ class="minor-action"
+ type="button"
+ :disabled="!canUploadSpreadsheet"
+ @click="triggerSpreadsheetUpload"
+ >
+
+ {{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ hintText }}
+
+
+
+ {{ token }}
+
+
+
+
+
+
+
正在加载{{ activeTabLabel }}资产...
+
+
+
+
+
+
+
+
+
+ | {{ tableColumns.name }} |
+ {{ tableColumns.category }} |
+ {{ tableColumns.owner }} |
+ {{ tableColumns.scope }} |
+ {{ tableColumns.runtime }} |
+ {{ tableColumns.version }} |
+ 状态 |
+ {{ tableColumns.metric }} |
+ 最近更新 |
+
+
+
+
+
+
+ {{ skill.short }}
+
+ {{ skill.name }}
+ {{ skill.summary }}
+
+
+ |
+ {{ skill.category }} |
+ {{ skill.owner }} |
+ {{ skill.scope }} |
+ {{ skill.model }} |
+ {{ skill.version }} |
+ {{ skill.status }} |
+ {{ skill.hitRate }} |
+ {{ skill.updatedAt }} |
+
+
+
+
+
+
+
+
+
+
+
+
+ 当前展示版本
+ {{ selectedSkill?.displayVersion }}
+
+
+
+ 目标版本
+ {{ versionSwitchTarget?.version }}
+
+
+
+
+ {{ versionSwitchTarget.note }}
+ {{ versionSwitchTarget.time }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/LogDetailView.vue b/web/src/views/LogDetailView.vue
index 9811454..1988a9e 100644
--- a/web/src/views/LogDetailView.vue
+++ b/web/src/views/LogDetailView.vue
@@ -15,43 +15,56 @@
-
-
-
-
- {{ resolveRunLevel(hermesRun) }}
-
-
- {{ resolveStatusLabel(hermesRun.status) }}
-
-
-
{{ resolveRunTitle(hermesRun) }}
-
{{ hermesRun.result_summary || '暂无运行摘要。' }}
-
-
-
-
-
-
-
-
基本信息
+
+
+
+
+ {{ resolveRunLevel(hermesRun) }}
+
+
+ {{ resolveStatusLabel(hermesRun) }}
+
+
+
{{ resolveRunTitle(hermesRun) }}
+
{{ hermesRun.result_summary || '暂无运行摘要。' }}
+
运行中每 5 秒自动刷新一次详情。
+
+
+
+
+
+ {{ hermesRunAlert.message }}
+
+
+
+
+
+
基本信息
围绕当前 Hermes 任务查看关键字段。
-
Trace ID{{ hermesRun.run_id }}
-
开始时间{{ formatDateTime(hermesRun.started_at) }}
-
结束时间{{ formatDateTime(hermesRun.finished_at) }}
-
来源{{ resolveRunSourceLabel(hermesRun.source) }}
-
模块{{ resolveRunModuleLabel(hermesRun) }}
-
当前进度{{ resolveRunProgress(hermesRun) }}
-
-
-
-
-
+
Trace ID{{ hermesRun.run_id }}
+
开始时间{{ formatDateTime(hermesRun.started_at) }}
+
结束时间{{ formatDateTime(hermesRun.finished_at) }}
+
来源{{ resolveRunSourceLabel(hermesRun.source) }}
+
模块{{ resolveRunModuleLabel(hermesRun) }}
+
当前阶段{{ hermesRunStatus.phaseLabel }}
+
当前进度{{ resolveRunProgress(hermesRun) }}
+
执行耗时{{ resolveRunElapsedLabel(hermesRun) }}
+
最后心跳{{ resolveHeartbeatAtText(hermesRunHeartbeat) }}
+
心跳状态{{ hermesRunHeartbeat.label }}
+
+
+
+
+
@@ -62,20 +75,22 @@
type="button"
class="trace-step"
:class="{ active: selectedToolCall?.id === toolCall.id }"
- @click="selectedToolCallId = toolCall.id"
- >
- {{ index + 1 }}
-
- {{ toolCall.tool_name }}
- {{ toolCall.tool_type }} · {{ toolCall.duration_ms }}ms
-
-
- {{ toolCall.status }}
-
-
-
-
当前运行暂无 ToolCall 明细。
-
+ @click="selectedToolCallId = toolCall.id"
+ >
+
{{ index + 1 }}
+
+ {{ toolCall.tool_name }}
+ {{ resolveToolCallMeta(toolCall) }}
+
+
+ {{ toolCall.status }}
+
+
+
+
+ 当前暂无 ToolCall 明细。若长时间停在运行中且没有心跳,通常表示任务尚未真正进入 LightRAG 索引调用,或执行它的是旧版后端进程。
+
+
@@ -175,12 +190,20 @@
-
+watch(
+ () => [route.params.logKind, route.params.logId],
+ () => {
+ void loadDetail()
+ }
+)
+
+onMounted(() => {
+ void loadDetail()
+})
+
+onBeforeUnmount(() => {
+ stopPolling()
+})
+
diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue
index 75d3812..ba48e58 100644
--- a/web/src/views/LogsView.vue
+++ b/web/src/views/LogsView.vue
@@ -113,12 +113,16 @@
{{ resolveRunTitle(run) }}
{{ formatSummary(run.result_summary) }}
+ {{ resolveRunSummaryMeta(run) }}
|
{{ run.run_id }} |
-
- {{ resolveStatusLabel(run.status) }}
-
+
+
+ {{ resolveStatusLabel(run) }}
+
+ {{ resolveRunStatusNote(run) }}
+
|
@@ -225,7 +229,7 @@
日志趋势
-
近 8 个小时的 Hermes 运行量与失败量
+
近 8 个小时的 Hermes 启动量与失败量,不代表实时心跳
({
+ id: `${PREVIEW_RULE_ID}-version-${index + 1}`,
+ asset_id: PREVIEW_RULE_ID,
+ version: spec.version,
+ content: buildPreviewSpreadsheetVersionMarkdown(spec),
+ content_type: 'markdown',
+ change_note: spec.note,
+ created_by: spec.updatedBy,
+ created_at: spec.updatedAt,
+ is_current: spec.isCurrent
+ }))
+ const currentSpec = PREVIEW_RULE_VERSION_SPECS[0]
+ const currentMeta = buildPreviewSpreadsheetMeta(currentSpec)
+
+ return {
+ id: PREVIEW_RULE_ID,
+ asset_type: 'rule',
+ code: PREVIEW_RULE_CODE,
+ name: '公司差旅费报销规则',
+ description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。',
+ domain: 'expense',
+ scenario_json: ['expense', 'travel_policy', 'travel_standard'],
+ owner: '财务制度管理组',
+ reviewer: '顾承宇',
+ status: 'active',
+ current_version: currentSpec.version,
+ published_version: currentSpec.version,
+ working_version: currentSpec.version,
+ config_json: {
+ severity: 'medium',
+ enabled: true,
+ tag: '财务规则',
+ detail_mode: 'spreadsheet',
+ runtime_kind: 'travel_policy',
+ rule_template_label: '差旅报销 Excel 模板',
+ rule_document: {
+ ...currentMeta,
+ asset_version: currentSpec.version
+ }
+ },
+ created_at: '2026-05-10T11:10:00Z',
+ updated_at: currentSpec.updatedAt,
+ current_version_content: recentVersions[0].content,
+ current_version_content_type: 'markdown',
+ current_version_change_note: currentSpec.note,
+ recent_versions: recentVersions,
+ latest_review: {
+ id: `${PREVIEW_RULE_ID}-review-1`,
+ asset_id: PREVIEW_RULE_ID,
+ version: currentSpec.version,
+ reviewer: '顾承宇',
+ review_status: 'approved',
+ review_note: '当前为页面预览态,先确认布局与交互位置。',
+ reviewed_at: '2026-05-17T10:00:00Z',
+ created_at: '2026-05-17T10:00:00Z'
+ }
+ }
+}
+
+function buildPreviewRuleListItem() {
+ const payload = createPreviewRuleDetailPayload()
+ return {
+ ...buildListItem(payload),
+ isPreviewMock: true
+ }
+}
+
+function buildPreviewRuleDetail() {
+ const detail = buildDetailViewModel(createPreviewRuleDetailPayload(), [])
+ return {
+ ...detail,
+ isPreviewMock: true
+ }
+}
+
function normalizeText(value) {
return String(value || '').trim()
}
@@ -246,6 +447,65 @@ function readConfigJson(value) {
return {}
}
+function readRuleDocumentMeta(value) {
+ const configJson = readConfigJson(value)
+ return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
+}
+
+function isSpreadsheetRuleSource(value) {
+ const configJson = readConfigJson(value)
+ return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE
+}
+
+function normalizeRuleTagValue(value) {
+ return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '')
+}
+
+function collectRuleTagValues(source) {
+ const configJson = readConfigJson(source)
+ const rawValues = [
+ configJson.tag,
+ configJson.rule_tag,
+ ...(Array.isArray(configJson.tags) ? configJson.tags : []),
+ ...(Array.isArray(configJson.rule_tags) ? configJson.rule_tags : [])
+ ]
+
+ return rawValues.map((item) => normalizeText(item)).filter(Boolean)
+}
+
+function resolveRuleTabId(source) {
+ const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item))
+
+ if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) {
+ return 'riskRules'
+ }
+ if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.financialRules.has(item))) {
+ return 'financialRules'
+ }
+ return ''
+}
+
+function resolveTabId(source, typeKey) {
+ if (typeKey === 'rules') {
+ return resolveRuleTabId(source)
+ }
+ return typeKey
+}
+
+function resolveTabMeta(tabId, typeKey) {
+ if (TAB_META[tabId]) {
+ return TAB_META[tabId]
+ }
+ if (typeKey === 'rules') {
+ return {
+ ...TYPE_META.rules,
+ typeKey: 'rules',
+ badgeTone: 'emerald'
+ }
+ }
+ return TAB_META[typeKey]
+}
+
function cloneJsonObject(value) {
if (!isPlainObject(value)) {
return null
@@ -276,6 +536,20 @@ function extractRuntimeRuleFromMarkdown(markdown) {
}
}
+function extractSpreadsheetMetaFromMarkdown(markdown) {
+ const match = String(markdown || '').match(RULE_SPREADSHEET_BLOCK_PATTERN)
+ if (!match) {
+ return null
+ }
+
+ try {
+ const payload = JSON.parse(match[1])
+ return isPlainObject(payload) ? payload : null
+ } catch {
+ return null
+ }
+}
+
function stripRuntimeRuleBlock(markdown) {
const text = String(markdown || '')
const stripped = text.replace(EXPENSE_RULE_BLOCK_PATTERN, '').replace(/\n{3,}/g, '\n\n').trim()
@@ -381,6 +655,25 @@ function resolveReviewMeta(value) {
return REVIEW_META[value] || { label: '暂无审核', tone: 'draft' }
}
+function resolveTimelineEventMeta(value) {
+ return {
+ created: { label: '创建工作稿', icon: 'mdi mdi-file-document-edit-outline', tone: 'draft' },
+ submitted: { label: '提交审核', icon: 'mdi mdi-send-outline', tone: 'warning' },
+ approved: { label: '审核通过', icon: 'mdi mdi-check-decagram-outline', tone: 'success' },
+ rejected: { label: '审核驳回', icon: 'mdi mdi-close-octagon-outline', tone: 'danger' },
+ published: { label: '正式上线', icon: 'mdi mdi-rocket-launch-outline', tone: 'success' },
+ restored: { label: '恢复生成工作稿', icon: 'mdi mdi-history', tone: 'info' }
+ }[normalizeText(value)] || { label: normalizeText(value) || '版本事件', icon: 'mdi mdi-circle-medium', tone: 'draft' }
+}
+
+function resolveDiffChangeMeta(value) {
+ return {
+ added: { label: '新增', tone: 'success' },
+ removed: { label: '删除', tone: 'danger' },
+ modified: { label: '修改', tone: 'warning' }
+ }[normalizeText(value)] || { label: normalizeText(value) || '变化', tone: 'draft' }
+}
+
function formatScenarioList(items) {
if (!Array.isArray(items) || !items.length) {
return '未配置场景'
@@ -408,9 +701,14 @@ function buildHistory(recentVersions = [], source) {
rawContent,
item.is_current ? currentRuntimeRule : null
),
+ spreadsheetMeta: extractSpreadsheetMetaFromMarkdown(rawContent),
contentType: item.content_type,
createdBy: item.created_by,
- isCurrent: Boolean(item.is_current)
+ isCurrent: Boolean(item.is_current),
+ isPublished: Boolean(item.is_published),
+ isWorking: Boolean(item.is_working),
+ lifecycleState: item.lifecycle_state || 'history',
+ lifecycleMeta: VERSION_STATE_META[item.lifecycle_state] || VERSION_STATE_META.history
}
})
}
@@ -523,12 +821,20 @@ function buildRowMetric(asset, typeKey) {
function buildListItem(asset) {
const typeKey = resolveTypeKey(asset.asset_type)
+ const tabId = resolveTabId(asset, typeKey)
+ if (!tabId) {
+ return null
+ }
+
+ const tabMeta = resolveTabMeta(tabId, typeKey)
const statusMeta = resolveStatusMeta(asset.status)
return {
id: asset.id,
+ tabId,
type: typeKey,
- typeLabel: TYPE_META[typeKey].typeLabel,
+ isPreviewMock: Boolean(asset.isPreviewMock),
+ typeLabel: tabMeta.typeLabel,
short: makeShort(asset.name),
name: asset.name,
code: asset.code,
@@ -538,21 +844,36 @@ function buildListItem(asset) {
reviewer: asset.reviewer || '待分配',
scope: formatScenarioList(asset.scenario_json),
model: buildRowRuntime(asset, typeKey),
- version: asset.current_version || '-',
+ version: asset.working_version || asset.current_version || '-',
+ publishedVersion: asset.published_version || '-',
+ workingVersion: asset.working_version || asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
hitRate: buildRowMetric(asset, typeKey),
updatedAt: formatDateTime(asset.updated_at),
- badgeTone: BADGE_TONES[typeKey],
+ badgeTone: tabMeta.badgeTone,
spotlight: asset.status === 'active',
domainValue: asset.domain
}
}
function buildRuleFields(detail) {
+ const ruleDocument = readRuleDocumentMeta(detail)
return [
{ label: '规则编码', value: detail.code },
+ {
+ label: '明细载体',
+ value: isSpreadsheetRuleSource(detail) ? 'Excel 表格' : 'Markdown / JSON'
+ },
+ ...(ruleDocument
+ ? [
+ {
+ label: '关联文件',
+ value: normalizeText(ruleDocument.file_name) || '未上传'
+ }
+ ]
+ : []),
{
label: '模板键',
value: normalizeText(detail.config_json?.rule_template_key) || '未指定'
@@ -563,7 +884,8 @@ function buildRuleFields(detail) {
value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft'
},
{ label: '适用场景', value: formatScenarioList(detail.scenario_json) },
- { label: '当前版本', value: detail.current_version || '-' }
+ { label: '线上版本', value: detail.published_version || '-' },
+ { label: '工作版本', value: detail.working_version || detail.current_version || '-' }
]
}
@@ -692,6 +1014,14 @@ function buildOutputRules(detail, typeKey, latestRun, latestCall) {
const content = detail.current_version_content || {}
if (typeKey === 'rules') {
+ if (isSpreadsheetRuleSource(detail)) {
+ return [
+ '规则详情页以内联 Excel 表格作为主载体,管理员可直接编辑当前版本。',
+ '上传新的 Excel 文件后,会自动生成新的规则版本快照。',
+ '切换到历史版本时仅支持预览,不允许直接覆盖历史版本。',
+ '规则表发生变更后,仍需完成审核才能再次正式上线。'
+ ]
+ }
return [
'规则使用固定模板落 Markdown,并配套维护 runtime_rule JSON。',
'保存 Markdown 或 JSON 都会生成新版本快照。',
@@ -856,8 +1186,11 @@ function buildTools(detail, typeKey, latestRun, latestCall) {
function buildPublishDescription(detail, typeKey) {
if (typeKey === 'rules') {
+ if (detail.published_version && detail.working_version && detail.published_version !== detail.working_version) {
+ return '当前存在尚未上线的工作版本,系统运行仍以线上版本为准。'
+ }
if (detail.status === 'active') {
- return '当前规则版本已经上线,仍可继续保存新版本并重新走审核。'
+ return '当前规则线上版本已生效,仍可继续保存新的工作版本并重新走审核。'
}
return '当前规则需要先完成审核,再调用上线接口正式激活。'
}
@@ -867,13 +1200,17 @@ function buildPublishDescription(detail, typeKey) {
function buildDetailViewModel(detail, runs) {
const typeKey = resolveTypeKey(detail.asset_type)
+ const tabId = resolveTabId(detail, typeKey) || typeKey
+ const tabMeta = resolveTabMeta(tabId, typeKey)
const latestRun = typeKey === 'tasks' ? findLatestTaskRun(runs, detail.id) : null
const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null
const configJson = readConfigJson(detail)
const statusMeta = resolveStatusMeta(detail.status)
const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status)
const history = buildHistory(detail.recent_versions || [], detail)
- const previewVersion = history.find((item) => item.isCurrent) || history[0] || null
+ const previewVersion = history.find((item) => item.isWorking) || history[0] || null
+ const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(detail)
+ const ruleDocument = readRuleDocumentMeta(detail)
const previewRawMarkdown =
detail.current_version_content_type === 'markdown'
? String(previewVersion?.content ?? detail.current_version_content ?? '')
@@ -892,8 +1229,9 @@ function buildDetailViewModel(detail, runs) {
return {
id: detail.id,
+ tabId,
type: typeKey,
- typeLabel: TYPE_META[typeKey].typeLabel,
+ typeLabel: tabMeta.typeLabel,
short: makeShort(detail.name),
name: detail.name,
code: detail.code,
@@ -902,16 +1240,20 @@ function buildDetailViewModel(detail, runs) {
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
category: resolveDomainLabel(detail.domain),
scope: formatScenarioList(detail.scenario_json),
- version: detail.current_version || '-',
+ version: detail.working_version || detail.current_version || '-',
currentVersion: detail.current_version || '-',
- displayVersion: previewVersion?.version || detail.current_version || '-',
+ publishedVersion: detail.published_version || '-',
+ workingVersion: detail.working_version || detail.current_version || '-',
+ displayVersion: previewVersion?.version || detail.working_version || detail.current_version || '-',
status: statusMeta.label,
statusValue: detail.status,
statusTone: statusMeta.tone,
hitRate: buildRowMetric(detail, typeKey),
updatedAt: formatDateTime(detail.updated_at),
- badgeTone: BADGE_TONES[typeKey],
+ badgeTone: tabMeta.badgeTone,
configJson,
+ usesSpreadsheetRule,
+ ruleDocument,
scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [],
markdownContent: previewMarkdown,
runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule),
@@ -944,8 +1286,14 @@ function buildDetailViewModel(detail, runs) {
tone: reviewMeta.tone
},
{
- name: detail.current_version || '暂无版本',
- scope: '当前版本',
+ name: detail.published_version || '暂无版本',
+ scope: '线上版本',
+ mode: '正式生效',
+ tone: 'safe'
+ },
+ {
+ name: detail.working_version || detail.current_version || '暂无版本',
+ scope: '工作版本',
mode: detail.current_version_change_note || '无版本说明',
tone: 'safe'
}
@@ -1015,12 +1363,12 @@ export default {
const { toast } = useToast()
const { currentUser } = useSystemState()
- const tabs = Object.entries(TYPE_META).map(([id, meta]) => ({
+ const tabs = Object.entries(TAB_META).map(([id, meta]) => ({
id,
label: meta.label
}))
- const activeType = ref('rules')
+ const activeType = ref('financialRules')
const selectedSkill = ref(null)
const versionSwitchTarget = ref(null)
const keyword = ref('')
@@ -1035,15 +1383,33 @@ export default {
const actionState = ref('')
const runLoading = ref(false)
const runs = ref([])
+ const spreadsheetUploadInput = ref(null)
+ const spreadsheetOnlyOfficeLoading = ref(false)
+ const spreadsheetOnlyOfficeError = ref('')
+ const spreadsheetOnlyOfficeEditor = ref(null)
+ const spreadsheetOnlyOfficeReady = ref(false)
+ const spreadsheetOnlyOfficeHostId = ref('audit-rule-onlyoffice')
+ const versionTimelineOpen = ref(false)
+ const versionTimelineLoading = ref(false)
+ const versionTimelineError = ref('')
+ const versionTimelineItems = ref([])
+ const versionCompareOpen = ref(false)
+ const versionCompareLoading = ref(false)
+ const versionCompareError = ref('')
+ const versionComparePayload = ref(null)
+ const compareBaseVersion = ref('')
+ const compareTargetVersion = ref('')
const assetBuckets = ref({
- rules: [],
+ financialRules: [],
+ riskRules: [],
skills: [],
mcp: [],
tasks: []
})
const isAdmin = computed(() => isManagerUser(currentUser.value))
- const activeMeta = computed(() => TYPE_META[activeType.value])
+ const isFinance = computed(() => isFinanceUser(currentUser.value))
+ const activeMeta = computed(() => TAB_META[activeType.value])
const activeTabLabel = computed(() => activeMeta.value.label)
const currentAssets = computed(() => assetBuckets.value[activeType.value] || [])
const searchPlaceholder = computed(() => activeMeta.value.searchPlaceholder)
@@ -1052,8 +1418,91 @@ export default {
const tableColumns = computed(() => activeMeta.value.tableColumns)
const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false)
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
- const canManageSelected = computed(() => isAdmin.value && Boolean(selectedSkill.value))
- const canEditMarkdown = computed(() => canManageSelected.value && selectedSkillIsRule.value)
+ const selectedSkillUsesSpreadsheet = computed(
+ () => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
+ )
+ const canManageSelected = computed(
+ () => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
+ )
+ const canEditSelected = computed(
+ () =>
+ Boolean(selectedSkill.value) &&
+ !selectedSkill.value?.isPreviewMock &&
+ (isAdmin.value || isFinance.value)
+ )
+ const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
+ const isDisplayingWorkingVersion = computed(
+ () => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
+ )
+ const canSubmitReview = computed(
+ () => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
+ )
+ const canReviewSelected = computed(
+ () => canManageSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
+ )
+ const canUploadSpreadsheet = computed(
+ () =>
+ canEditSelected.value &&
+ selectedSkillUsesSpreadsheet.value &&
+ !detailBusy.value &&
+ selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
+ )
+ const canDownloadSpreadsheet = computed(
+ () =>
+ selectedSkillUsesSpreadsheet.value &&
+ Boolean(selectedSkill.value?.id) &&
+ !detailBusy.value
+ )
+ const canEditSpreadsheetInline = computed(
+ () =>
+ selectedSkillUsesSpreadsheet.value &&
+ selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion &&
+ (selectedSkill.value?.isPreviewMock || canEditSelected.value)
+ )
+ const selectedDisplayHistory = computed(
+ () =>
+ selectedSkill.value?.history?.find((item) => item.version === selectedSkill.value?.displayVersion) || null
+ )
+ const selectedSpreadsheetFileName = computed(
+ () =>
+ normalizeText(
+ selectedDisplayHistory.value?.spreadsheetMeta?.file_name || selectedSkill.value?.ruleDocument?.file_name
+ ) || '未上传规则表'
+ )
+ const selectedSpreadsheetVersionModeLabel = computed(() => {
+ if (selectedSkill.value?.isPreviewMock) {
+ return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑预览' : 'ONLYOFFICE 历史预览'
+ }
+ return selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
+ ? '当前工作版本'
+ : '历史预览版本'
+ })
+ const selectedVersionTimelineItems = computed(() =>
+ versionTimelineItems.value.map((item) => ({
+ ...item,
+ meta: resolveTimelineEventMeta(item.event_type),
+ timeLabel: formatDateTime(item.event_time)
+ }))
+ )
+ const recentVersionTimelineItems = computed(() =>
+ [...selectedVersionTimelineItems.value].slice(-3).reverse()
+ )
+ const versionCompareCellRows = computed(() =>
+ Array.isArray(versionComparePayload.value?.cell_changes)
+ ? versionComparePayload.value.cell_changes.map((item) => ({
+ ...item,
+ meta: resolveDiffChangeMeta(item.change_type)
+ }))
+ : []
+ )
+ const versionCompareSheetRows = computed(() =>
+ Array.isArray(versionComparePayload.value?.sheet_changes)
+ ? versionComparePayload.value.sheet_changes.map((item) => ({
+ ...item,
+ meta: resolveDiffChangeMeta(item.change_type)
+ }))
+ : []
+ )
const detailBusy = computed(() => Boolean(actionState.value))
const showReviewNote = computed(
() => selectedSkillIsRule.value && (selectedSkill.value?.reviewNote || selectedSkill.value?.reviewTimeLabel)
@@ -1114,8 +1563,8 @@ export default {
title: `${activeTabLabel.value}列表暂时还是空的`,
desc: `当前环境里还没有可展示的${activeTabLabel.value}资产。完成接入或同步后,会统一展示在这里。`,
icon: 'mdi mdi-database-search-outline',
- actionLabel: '重新加载',
- actionIcon: 'mdi mdi-refresh',
+ actionLabel: '',
+ actionIcon: '',
tone: 'amber',
artLabel: 'ASSET',
tips: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤']
@@ -1129,8 +1578,8 @@ export default {
? '试试清空业务域、负责人、状态或关键词筛选,再重新查看。'
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
- actionLabel: hasFilters ? '清空筛选' : '重新加载',
- actionIcon: hasFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-refresh',
+ actionLabel: hasFilters ? '清空筛选' : '',
+ actionIcon: hasFilters ? 'mdi mdi-filter-remove-outline' : '',
tone: hasFilters ? 'emerald' : 'slate',
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
tips: hasFilters
@@ -1143,17 +1592,27 @@ export default {
return false
}
- return selectedSkill.value?.reviewStatusValue === 'approved' && selectedSkill.value?.statusValue !== 'active'
+ return (
+ isDisplayingWorkingVersion.value &&
+ selectedSkill.value?.reviewStatusValue === 'approved' &&
+ selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion
+ )
})
const activateBlockedReason = computed(() => {
if (!selectedSkillIsRule.value) {
return ''
}
- if (!canManageSelected.value) {
- return '仅管理员可执行审核和上线。'
+ if (selectedSkill.value?.isPreviewMock) {
+ return '当前为页面预览态,暂不执行真实审核和上线。'
}
- if (selectedSkill.value?.statusValue === 'active') {
- return '当前规则版本已经上线。'
+ if (!canManageSelected.value) {
+ return '仅高级管理人员可执行审核和上线。'
+ }
+ if (!isDisplayingWorkingVersion.value) {
+ return '请先切回当前工作版本,再执行审核或上线。'
+ }
+ if (selectedSkill.value?.workingVersion === selectedSkill.value?.publishedVersion) {
+ return '当前工作版本已经是线上版本。'
}
if (selectedSkill.value?.reviewStatusValue !== 'approved') {
return '当前规则版本未审核通过,不能上线。'
@@ -1185,7 +1644,26 @@ export default {
{ immediate: true }
)
+ watch(
+ () => [
+ selectedSkill.value?.id || '',
+ selectedSkill.value?.displayVersion || '',
+ selectedSkill.value?.loading ? '1' : '0',
+ selectedSkill.value?.usesSpreadsheetRule ? '1' : '0'
+ ],
+ async () => {
+ if (!selectedSkillUsesSpreadsheet.value || selectedSkill.value?.loading) {
+ destroySpreadsheetOnlyOfficeEditor()
+ spreadsheetOnlyOfficeError.value = ''
+ spreadsheetOnlyOfficeLoading.value = false
+ return
+ }
+ await mountSpreadsheetOnlyOfficeEditor()
+ }
+ )
+
watch(activeType, () => {
+ destroySpreadsheetOnlyOfficeEditor()
selectedSkill.value = null
versionSwitchTarget.value = null
resetFilters()
@@ -1271,6 +1749,132 @@ export default {
)
}
+ function destroySpreadsheetOnlyOfficeEditor() {
+ if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) {
+ spreadsheetOnlyOfficeEditor.value.destroyEditor()
+ }
+ spreadsheetOnlyOfficeEditor.value = null
+ spreadsheetOnlyOfficeReady.value = false
+ }
+
+ async function mountSpreadsheetOnlyOfficeEditor() {
+ if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id || selectedSkill.value?.loading) {
+ destroySpreadsheetOnlyOfficeEditor()
+ return
+ }
+
+ spreadsheetOnlyOfficeLoading.value = true
+ spreadsheetOnlyOfficeError.value = ''
+ spreadsheetOnlyOfficeReady.value = false
+ destroySpreadsheetOnlyOfficeEditor()
+
+ try {
+ const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(
+ selectedSkill.value.id,
+ selectedSkill.value.displayVersion
+ )
+ await loadOnlyOfficeApi(payload.documentServerUrl)
+ if (!window.DocsAPI?.DocEditor) {
+ throw new Error('ONLYOFFICE 编辑器未正确加载。')
+ }
+
+ spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${selectedSkill.value.id}-${selectedSkill.value.displayVersion}`
+ await nextTick()
+ const config = buildOnlyOfficeEditorConfig(payload.config, {
+ viewportHeight: window.innerHeight,
+ editable: canEditSpreadsheetInline.value,
+ fillContainer: true
+ })
+ const upstreamEvents = config.events || {}
+ config.events = {
+ ...upstreamEvents,
+ onAppReady(event) {
+ spreadsheetOnlyOfficeReady.value = true
+ spreadsheetOnlyOfficeLoading.value = false
+ upstreamEvents.onAppReady?.(event)
+ },
+ onError(event) {
+ const errorCode = event?.data?.errorCode
+ const errorDescription = event?.data?.errorDescription
+ spreadsheetOnlyOfficeError.value = errorDescription
+ ? `ONLYOFFICE 加载失败:${errorDescription}`
+ : `ONLYOFFICE 加载失败${errorCode ? `(错误码 ${errorCode})` : '。'}`
+ spreadsheetOnlyOfficeLoading.value = false
+ upstreamEvents.onError?.(event)
+ }
+ }
+ spreadsheetOnlyOfficeEditor.value = new window.DocsAPI.DocEditor(
+ spreadsheetOnlyOfficeHostId.value,
+ config
+ )
+ } catch (error) {
+ spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。'
+ spreadsheetOnlyOfficeLoading.value = false
+ toast(spreadsheetOnlyOfficeError.value)
+ }
+ }
+
+ function triggerSpreadsheetUpload() {
+ if (!canUploadSpreadsheet.value) {
+ return
+ }
+ spreadsheetUploadInput.value?.click()
+ }
+
+ async function downloadSpreadsheetFile() {
+ if (!canDownloadSpreadsheet.value || !selectedSkill.value?.id) {
+ return
+ }
+
+ actionState.value = 'download-spreadsheet'
+ try {
+ const blob = await fetchAgentAssetSpreadsheetBlob(
+ selectedSkill.value.id,
+ selectedSkill.value.displayVersion,
+ 'attachment'
+ )
+ const objectUrl = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = objectUrl
+ anchor.download = selectedSpreadsheetFileName.value || '规则表.xlsx'
+ document.body.appendChild(anchor)
+ anchor.click()
+ anchor.remove()
+ URL.revokeObjectURL(objectUrl)
+ } catch (error) {
+ toast(error?.message || '规则表下载失败,请稍后重试。')
+ } finally {
+ actionState.value = ''
+ }
+ }
+
+ async function uploadSpreadsheetFile(file) {
+ if (!file || !selectedSkill.value?.id || !canUploadSpreadsheet.value) {
+ return
+ }
+
+ actionState.value = 'upload-spreadsheet'
+ try {
+ await importAgentAssetSpreadsheetContent(selectedSkill.value.id, file, {
+ actor: resolveActor()
+ })
+ await refreshCurrentAssets()
+ await loadSelectedAssetDetail(selectedSkill.value.id)
+ toast(`已导入 ${file.name} 的表格内容,并生成新版本。`)
+ } catch (error) {
+ toast(error?.message || '规则表内容导入失败,请稍后重试。')
+ } finally {
+ actionState.value = ''
+ if (spreadsheetUploadInput.value) {
+ spreadsheetUploadInput.value.value = ''
+ }
+ }
+ }
+
+ async function handleSpreadsheetFileInput(event) {
+ await uploadSpreadsheetFile(event?.target?.files?.[0] || null)
+ }
+
async function loadRuns(options = {}) {
if (runLoading.value && !options.force) {
return
@@ -1291,16 +1895,46 @@ export default {
try {
const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType })
- assetBuckets.value = {
- ...assetBuckets.value,
- [activeType.value]: Array.isArray(payload) ? payload.map(buildListItem) : []
+ const items = Array.isArray(payload) ? payload.map(buildListItem).filter(Boolean) : []
+
+ if (activeMeta.value.assetType === 'rule') {
+ const nextBuckets = {
+ financialRules: [],
+ riskRules: []
+ }
+
+ items.forEach((item) => {
+ if (item?.tabId === 'financialRules' || item?.tabId === 'riskRules') {
+ nextBuckets[item.tabId].push(item)
+ }
+ })
+
+ assetBuckets.value = {
+ ...assetBuckets.value,
+ ...nextBuckets
+ }
+ } else {
+ assetBuckets.value = {
+ ...assetBuckets.value,
+ [activeType.value]: items
+ }
}
} catch (error) {
- assetBuckets.value = {
- ...assetBuckets.value,
- [activeType.value]: []
+ if (activeMeta.value.assetType === 'rule') {
+ assetBuckets.value = {
+ ...assetBuckets.value,
+ financialRules:
+ activeType.value === 'financialRules' ? [] : assetBuckets.value.financialRules,
+ riskRules: []
+ }
+ errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。'
+ } else {
+ assetBuckets.value = {
+ ...assetBuckets.value,
+ [activeType.value]: []
+ }
+ errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。'
}
- errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。'
if (!options.silent) {
toast(errorMessage.value)
}
@@ -1323,6 +1957,9 @@ export default {
}
const detail = await fetchAgentAssetDetail(assetId)
selectedSkill.value = buildDetailViewModel(detail, runs.value)
+ if (selectedSkill.value?.type === 'rules') {
+ loadVersionTimeline(assetId, { silent: true }).catch(() => {})
+ }
} catch (error) {
detailError.value = error?.message || '资产详情加载失败,请稍后重试。'
toast(detailError.value)
@@ -1332,9 +1969,22 @@ export default {
}
function openAssetDetail(asset) {
+ destroySpreadsheetOnlyOfficeEditor()
+ spreadsheetOnlyOfficeError.value = ''
+ spreadsheetOnlyOfficeLoading.value = false
+ if (asset?.isPreviewMock) {
+ selectedSkill.value = buildPreviewRuleDetail()
+ detailError.value = ''
+ detailLoading.value = false
+ versionSwitchTarget.value = null
+ return
+ }
selectedSkill.value = {
...asset,
configJson: {},
+ isPreviewMock: false,
+ usesSpreadsheetRule: false,
+ ruleDocument: null,
scenarioList: [],
fields: [],
promptSections: [],
@@ -1359,10 +2009,17 @@ export default {
}
function closeDetail() {
+ destroySpreadsheetOnlyOfficeEditor()
+ spreadsheetOnlyOfficeError.value = ''
+ spreadsheetOnlyOfficeLoading.value = false
selectedSkill.value = null
detailError.value = ''
detailLoading.value = false
versionSwitchTarget.value = null
+ versionTimelineOpen.value = false
+ versionCompareOpen.value = false
+ versionTimelineItems.value = []
+ versionComparePayload.value = null
}
function openVersionSwitch(version) {
@@ -1383,6 +2040,10 @@ export default {
selectedSkill.value.displayVersion = versionSwitchTarget.value.version
selectedSkill.value.displayVersionChangeNote = versionSwitchTarget.value.note || '无版本说明'
+ if (selectedSkill.value.usesSpreadsheetRule) {
+ versionSwitchTarget.value = null
+ return
+ }
if (typeof versionSwitchTarget.value.markdownContent === 'string') {
selectedSkill.value.markdownContent = versionSwitchTarget.value.markdownContent
}
@@ -1397,7 +2058,13 @@ export default {
}
async function saveRuleMarkdown() {
- if (!selectedSkill.value || !selectedSkillIsRule.value || !canEditMarkdown.value || detailBusy.value) {
+ if (
+ !selectedSkill.value ||
+ !selectedSkillIsRule.value ||
+ selectedSkill.value.usesSpreadsheetRule ||
+ !canEditMarkdown.value ||
+ detailBusy.value
+ ) {
return
}
@@ -1439,7 +2106,13 @@ export default {
}
async function saveRuleRuntimeJson() {
- if (!selectedSkill.value || !selectedSkillIsRule.value || !canEditMarkdown.value || detailBusy.value) {
+ if (
+ !selectedSkill.value ||
+ !selectedSkillIsRule.value ||
+ selectedSkill.value.usesSpreadsheetRule ||
+ !canEditMarkdown.value ||
+ detailBusy.value
+ ) {
return
}
@@ -1481,7 +2154,13 @@ export default {
}
async function reviewSelectedRule(reviewStatus) {
- if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
+ if (!selectedSkill.value || !selectedSkillIsRule.value || detailBusy.value) {
+ return
+ }
+ if (reviewStatus === 'pending' && !canSubmitReview.value) {
+ return
+ }
+ if (reviewStatus !== 'pending' && !canReviewSelected.value) {
return
}
@@ -1491,7 +2170,7 @@ export default {
await createAgentAssetReview(
selectedSkill.value.id,
{
- version: selectedSkill.value.displayVersion || selectedSkill.value.currentVersion,
+ version: selectedSkill.value.workingVersion,
reviewer: resolveActor(),
review_status: reviewStatus,
review_note: buildReviewNote(reviewStatus)
@@ -1527,6 +2206,105 @@ export default {
}
}
+ async function restoreSelectedVersion(version) {
+ if (
+ !selectedSkill.value ||
+ !selectedSkillIsRule.value ||
+ !canManageSelected.value ||
+ detailBusy.value ||
+ !version
+ ) {
+ return
+ }
+
+ actionState.value = `restore-${version}`
+ try {
+ await restoreAgentAssetVersion(selectedSkill.value.id, version, { actor: resolveActor() })
+ await refreshCurrentAssets()
+ await loadSelectedAssetDetail(selectedSkill.value.id)
+ toast(`已基于 ${version} 生成新的工作版本。`)
+ } catch (error) {
+ toast(error?.message || '历史版本恢复失败,请稍后重试。')
+ } finally {
+ actionState.value = ''
+ }
+ }
+
+ async function loadVersionTimeline(assetId = selectedSkill.value?.id, options = {}) {
+ if (!assetId) {
+ return
+ }
+
+ versionTimelineLoading.value = true
+ versionTimelineError.value = ''
+ try {
+ const payload = await fetchAgentAssetVersionTimeline(assetId)
+ versionTimelineItems.value = Array.isArray(payload) ? payload : []
+ } catch (error) {
+ versionTimelineError.value = error?.message || '版本流转加载失败,请稍后重试。'
+ if (!options.silent) {
+ toast(versionTimelineError.value)
+ }
+ } finally {
+ versionTimelineLoading.value = false
+ }
+ }
+
+ async function openVersionTimeline() {
+ if (!selectedSkill.value?.id) {
+ return
+ }
+ versionTimelineOpen.value = true
+ await loadVersionTimeline(selectedSkill.value.id)
+ }
+
+ function closeVersionTimeline() {
+ versionTimelineOpen.value = false
+ }
+
+ async function openVersionCompare(options = {}) {
+ if (!selectedSkill.value?.id) {
+ return
+ }
+ const defaultBase =
+ options.baseVersion || selectedSkill.value.publishedVersion || selectedSkill.value.workingVersion || ''
+ let defaultTarget =
+ options.targetVersion || selectedSkill.value.workingVersion || selectedSkill.value.publishedVersion || ''
+ if (!options.targetVersion && defaultBase === defaultTarget) {
+ defaultTarget =
+ selectedSkill.value.history.find((item) => item.version !== defaultBase)?.version || defaultTarget
+ }
+ compareBaseVersion.value = defaultBase
+ compareTargetVersion.value = defaultTarget
+ versionCompareOpen.value = true
+ await loadVersionCompare()
+ }
+
+ function closeVersionCompare() {
+ versionCompareOpen.value = false
+ }
+
+ async function loadVersionCompare() {
+ if (!selectedSkill.value?.id || !compareBaseVersion.value || !compareTargetVersion.value) {
+ return
+ }
+
+ versionCompareLoading.value = true
+ versionCompareError.value = ''
+ try {
+ versionComparePayload.value = await compareAgentAssetSpreadsheetVersions(
+ selectedSkill.value.id,
+ compareBaseVersion.value,
+ compareTargetVersion.value
+ )
+ } catch (error) {
+ versionComparePayload.value = null
+ versionCompareError.value = error?.message || '版本差异对比失败,请稍后重试。'
+ } finally {
+ versionCompareLoading.value = false
+ }
+ }
+
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
loadAssets({ force: true }).catch(() => {})
@@ -1534,6 +2312,7 @@ export default {
})
onBeforeUnmount(() => {
+ destroySpreadsheetOnlyOfficeEditor()
document.removeEventListener('click', handleDocumentClick)
})
@@ -1567,13 +2346,40 @@ export default {
activeFilterPopover,
activeFilterTokens,
canManageSelected,
+ canEditSelected,
+ canSubmitReview,
+ canReviewSelected,
canEditMarkdown,
+ canUploadSpreadsheet,
+ canDownloadSpreadsheet,
+ canEditSpreadsheetInline,
canActivateSelected,
activateBlockedReason,
selectedSkillIsRule,
+ selectedSkillUsesSpreadsheet,
+ selectedSpreadsheetFileName,
+ selectedSpreadsheetVersionModeLabel,
+ selectedVersionTimelineItems,
+ recentVersionTimelineItems,
detailBusy,
actionState,
showReviewNote,
+ spreadsheetUploadInput,
+ spreadsheetOnlyOfficeLoading,
+ spreadsheetOnlyOfficeError,
+ spreadsheetOnlyOfficeReady,
+ spreadsheetOnlyOfficeHostId,
+ versionTimelineOpen,
+ versionTimelineLoading,
+ versionTimelineError,
+ versionCompareOpen,
+ versionCompareLoading,
+ versionCompareError,
+ versionComparePayload,
+ versionCompareCellRows,
+ versionCompareSheetRows,
+ compareBaseVersion,
+ compareTargetVersion,
openAssetDetail,
closeDetail,
resetFilters,
@@ -1586,8 +2392,17 @@ export default {
confirmVersionSwitch,
saveRuleMarkdown,
saveRuleRuntimeJson,
+ triggerSpreadsheetUpload,
+ downloadSpreadsheetFile,
+ handleSpreadsheetFileInput,
reviewSelectedRule,
activateSelectedRule,
+ restoreSelectedVersion,
+ openVersionTimeline,
+ closeVersionTimeline,
+ openVersionCompare,
+ closeVersionCompare,
+ loadVersionCompare,
loadAssets
}
}
diff --git a/web/src/views/scripts/LogsView.js b/web/src/views/scripts/LogsView.js
index 60d6312..dea4ea2 100644
--- a/web/src/views/scripts/LogsView.js
+++ b/web/src/views/scripts/LogsView.js
@@ -1,15 +1,20 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
-import LogTrendChart from '../../components/charts/LogTrendChart.vue'
-import DonutChart from '../../components/charts/DonutChart.vue'
-import { fetchAgentRuns } from '../../services/agentAssets.js'
-import { fetchSystemLogEntries } from '../../services/systemLogs.js'
-import { useSystemState } from '../../composables/useSystemState.js'
-import { useToast } from '../../composables/useToast.js'
-import { isManagerUser } from '../../utils/accessControl.js'
-
-const POLL_INTERVAL_MS = 5000
+import LogTrendChart from '../../components/charts/LogTrendChart.vue'
+import DonutChart from '../../components/charts/DonutChart.vue'
+import { fetchAgentRuns } from '../../services/agentAssets.js'
+import { fetchSystemLogEntries } from '../../services/systemLogs.js'
+import { useSystemState } from '../../composables/useSystemState.js'
+import { useToast } from '../../composables/useToast.js'
+import {
+ AGENT_RUN_POLL_INTERVAL_MS,
+ formatAgentRunElapsed,
+ formatAgentRunProgress,
+ resolveAgentRunHeartbeat,
+ resolveAgentRunStatus
+} from '../../utils/agentRunMonitor.js'
+import { isManagerUser } from '../../utils/accessControl.js'
const SOURCE_LABELS = {
schedule: '定时任务',
@@ -30,37 +35,13 @@ function formatDateTime(value) {
return date.toLocaleString('zh-CN', { hour12: false })
}
-function resolveStatusLabel(status) {
- if (status === 'running') {
- return '运行中'
- }
- if (status === 'succeeded') {
- return '已完成'
- }
- if (status === 'failed') {
- return '失败'
- }
- if (status === 'blocked') {
- return '待确认'
- }
- return status || '未知'
-}
-
-function resolveStatusTone(status) {
- if (status === 'running') {
- return 'warning'
- }
- if (status === 'succeeded') {
- return 'success'
- }
- if (status === 'failed') {
- return 'danger'
- }
- if (status === 'blocked') {
- return 'muted'
- }
- return 'muted'
-}
+function resolveStatusLabel(run) {
+ return resolveAgentRunStatus(run).label
+}
+
+function resolveStatusTone(run) {
+ return resolveAgentRunStatus(run).tone
+}
function resolveRunSourceLabel(source) {
return SOURCE_LABELS[source] || source || '未标记'
@@ -88,16 +69,20 @@ function resolveRunTitle(run) {
return `Hermes 调用 · ${resolveRunModuleLabel(run)}`
}
-function resolveRunLevel(run) {
- const progress = run?.route_json?.progress || {}
- if (run?.status === 'failed' || run?.error_message) {
- return 'ERROR'
- }
- if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) {
- return 'WARN'
- }
- if (run?.status === 'running') {
- return 'INFO'
+function resolveRunLevel(run) {
+ const progress = run?.route_json?.progress || {}
+ const statusInfo = resolveAgentRunStatus(run)
+ if (run?.status === 'failed' || run?.error_message) {
+ return 'ERROR'
+ }
+ if (statusInfo.isSuspicious) {
+ return 'WARN'
+ }
+ if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) {
+ return 'WARN'
+ }
+ if (run?.status === 'running') {
+ return 'INFO'
}
return 'INFO'
}
@@ -115,7 +100,7 @@ function resolveLevelTone(level) {
return 'muted'
}
-function formatSummary(summary) {
+function formatSummary(summary) {
const text = String(summary || '').trim()
if (!text) {
return '暂无摘要。'
@@ -123,8 +108,39 @@ function formatSummary(summary) {
if (text.length <= 64) {
return text
}
- return `${text.slice(0, 64)}...`
-}
+ return `${text.slice(0, 64)}...`
+}
+
+function resolveRunSummaryMeta(run) {
+ const statusInfo = resolveAgentRunStatus(run)
+ const progressText = formatAgentRunProgress(run)
+ const elapsedLabel = run?.status === 'running' ? '已运行' : '耗时'
+ const elapsedText = formatAgentRunElapsed(run)
+ const parts = [`阶段 ${statusInfo.phaseLabel}`]
+
+ if (progressText) {
+ parts.push(progressText)
+ }
+ if (elapsedText !== '—') {
+ parts.push(`${elapsedLabel} ${elapsedText}`)
+ }
+
+ return parts.join(' · ')
+}
+
+function resolveRunStatusNote(run) {
+ const statusInfo = resolveAgentRunStatus(run)
+ if (statusInfo.note) {
+ return statusInfo.note
+ }
+
+ const heartbeat = resolveAgentRunHeartbeat(run)
+ if (heartbeat.at !== null) {
+ return `最后心跳 ${formatDateTime(heartbeat.at)}`
+ }
+
+ return '暂无额外状态'
+}
function resolveSystemLevelTone(level) {
if (level === 'ERROR' || level === 'CRITICAL') {
@@ -368,9 +384,9 @@ export default {
stopPolling()
pollTimer = window.setInterval(() => {
loadHermesRuns()
- loadSystemLogs()
- }, POLL_INTERVAL_MS)
- }
+ loadSystemLogs()
+ }, AGENT_RUN_POLL_INTERVAL_MS)
+ }
function stopPolling() {
if (pollTimer) {
@@ -442,11 +458,13 @@ export default {
pageSizes,
resolveLevelTone,
resolveRunLevel,
- resolveRunModuleLabel,
- resolveRunSourceLabel,
- resolveRunTitle,
- resolveStatusLabel,
- resolveStatusTone,
+ resolveRunModuleLabel,
+ resolveRunSourceLabel,
+ resolveRunStatusNote,
+ resolveRunSummaryMeta,
+ resolveRunTitle,
+ resolveStatusLabel,
+ resolveStatusTone,
resolveSystemLevelTone,
resolveSystemOutcomeTone,
runningRunCount,
diff --git a/web/src/views/scripts/onlyOfficePreviewConfig.js b/web/src/views/scripts/onlyOfficePreviewConfig.js
index 8e79a3d..7273059 100644
--- a/web/src/views/scripts/onlyOfficePreviewConfig.js
+++ b/web/src/views/scripts/onlyOfficePreviewConfig.js
@@ -28,3 +28,28 @@ export function buildOnlyOfficePreviewConfig(config, options = {}) {
height: `${clampHeight(viewportHeight)}px`
}
}
+
+export function buildOnlyOfficeEditorConfig(config, options = {}) {
+ const viewportHeight = options.viewportHeight
+ const editable = Boolean(options.editable)
+ const fillContainer = Boolean(options.fillContainer)
+
+ return {
+ ...config,
+ type: editable ? 'desktop' : 'embedded',
+ editorConfig: {
+ ...(config.editorConfig || {}),
+ embedded: editable
+ ? undefined
+ : {
+ embedUrl: '',
+ fullscreenUrl: '',
+ saveUrl: '',
+ shareUrl: '',
+ toolbarDocked: 'top'
+ }
+ },
+ width: '100%',
+ height: fillContainer ? '100%' : `${clampHeight(viewportHeight)}px`
+ }
+}