feat: 更新前端UI,增强审计和日志视图功能
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
@@ -32,6 +32,10 @@ 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) {
|
||||
return false
|
||||
|
||||
271
web/src/utils/agentRunMonitor.js
Normal file
271
web/src/utils/agentRunMonitor.js
Normal file
@@ -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}%`
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<section class="skill-center">
|
||||
<Transition name="skill-view" mode="out-in">
|
||||
<article v-if="selectedSkill" key="detail" class="skill-detail">
|
||||
<article
|
||||
v-if="selectedSkill"
|
||||
key="detail"
|
||||
class="skill-detail"
|
||||
:class="{ 'spreadsheet-skill-detail': selectedSkill.usesSpreadsheetRule }"
|
||||
>
|
||||
<div class="detail-scroll">
|
||||
<section class="detail-hero panel">
|
||||
<section v-if="!selectedSkill.usesSpreadsheetRule" class="detail-hero panel">
|
||||
<div class="hero-title">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
@@ -67,7 +72,6 @@
|
||||
<strong>资产详情加载失败</strong>
|
||||
<p>{{ detailError }}</p>
|
||||
</div>
|
||||
<button class="state-action" type="button" @click="openAssetDetail(selectedSkill)">重新加载</button>
|
||||
</section>
|
||||
|
||||
<section v-else-if="detailLoading && selectedSkill.loading" class="detail-inline-state panel">
|
||||
@@ -78,13 +82,190 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="selectedSkill.usesSpreadsheetRule"
|
||||
class="spreadsheet-editor-shell panel"
|
||||
>
|
||||
<header class="spreadsheet-editor-head">
|
||||
<div class="spreadsheet-editor-title">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<div>
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spreadsheet-editor-actions">
|
||||
<span class="spreadsheet-mode-pill">
|
||||
{{ selectedSpreadsheetVersionModeLabel }}
|
||||
</span>
|
||||
<button
|
||||
class="mini-btn"
|
||||
type="button"
|
||||
:disabled="selectedSkill.isPreviewMock"
|
||||
@click="openVersionCompare()"
|
||||
>
|
||||
<i class="mdi mdi-compare-horizontal"></i>
|
||||
<span>版本对比</span>
|
||||
</button>
|
||||
<button
|
||||
class="mini-btn"
|
||||
type="button"
|
||||
:disabled="selectedSkill.isPreviewMock"
|
||||
@click="openVersionTimeline"
|
||||
>
|
||||
<i class="mdi mdi-timeline-clock-outline"></i>
|
||||
<span>查看流转</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<input
|
||||
ref="spreadsheetUploadInput"
|
||||
class="spreadsheet-upload-input"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
@change="handleSpreadsheetFileInput"
|
||||
/>
|
||||
|
||||
<div class="spreadsheet-editor-body">
|
||||
<section class="spreadsheet-main-stage">
|
||||
<div class="spreadsheet-editor-meta">
|
||||
<span><strong>文件</strong>{{ selectedSpreadsheetFileName }}</span>
|
||||
<span><strong>线上版本</strong>{{ selectedSkill.publishedVersion }}</span>
|
||||
<span><strong>工作版本</strong>{{ selectedSkill.workingVersion }}</span>
|
||||
<span><strong>当前预览</strong>{{ selectedSkill.displayVersion }}</span>
|
||||
<span><strong>审核状态</strong>{{ selectedSkill.reviewStatusLabel }}</span>
|
||||
<span><strong>负责人</strong>{{ selectedSkill.owner }}</span>
|
||||
<span><strong>最近更新</strong>{{ selectedSkill.updatedAt }}</span>
|
||||
</div>
|
||||
|
||||
<div class="spreadsheet-workbench">
|
||||
<div
|
||||
:id="spreadsheetOnlyOfficeHostId"
|
||||
class="rule-spreadsheet-host"
|
||||
:class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }"
|
||||
></div>
|
||||
<div v-if="spreadsheetOnlyOfficeLoading" class="rule-spreadsheet-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在加载 Excel 规则表...</span>
|
||||
</div>
|
||||
<div v-else-if="spreadsheetOnlyOfficeError" class="rule-spreadsheet-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ spreadsheetOnlyOfficeError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="spreadsheet-editor-foot">
|
||||
<span>
|
||||
{{
|
||||
canEditSpreadsheetInline
|
||||
? '当前版本可直接编辑;关闭编辑器并保存后,会自动生成新的规则版本快照。'
|
||||
: '当前为历史版本预览或只读模式。'
|
||||
}}
|
||||
</span>
|
||||
<span>最近版本说明:{{ selectedSkill.displayVersionChangeNote }}</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="spreadsheet-version-center">
|
||||
<header class="version-center-head">
|
||||
<div>
|
||||
<h3>版本中心</h3>
|
||||
<p>一眼看清线上、工作与最近流转。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="version-pair-grid">
|
||||
<article class="version-pair-card published">
|
||||
<span>线上版本</span>
|
||||
<strong>{{ selectedSkill.publishedVersion }}</strong>
|
||||
<b>正式生效</b>
|
||||
</article>
|
||||
<article class="version-pair-card working">
|
||||
<span>工作版本</span>
|
||||
<strong>{{ selectedSkill.workingVersion }}</strong>
|
||||
<b>{{ selectedSkill.reviewStatusLabel }}</b>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="version-center-section version-history-section">
|
||||
<header>
|
||||
<strong>最近版本</strong>
|
||||
<small>最近 5 个</small>
|
||||
</header>
|
||||
<div class="version-center-list">
|
||||
<article
|
||||
v-for="item in selectedSkill.history.slice(0, 5)"
|
||||
:key="`spreadsheet-version-${item.version}-${item.time}`"
|
||||
class="version-center-item"
|
||||
:class="{ active: item.version === selectedSkill.displayVersion }"
|
||||
>
|
||||
<button type="button" @click="openVersionSwitch(item)">
|
||||
<div>
|
||||
<strong>{{ item.version }}</strong>
|
||||
<b :class="['version-state', item.lifecycleMeta.tone]">
|
||||
{{ item.lifecycleMeta.label }}
|
||||
</b>
|
||||
</div>
|
||||
<span>{{ item.time }}</span>
|
||||
<p>{{ item.note }}</p>
|
||||
</button>
|
||||
<footer>
|
||||
<button type="button" @click="openVersionSwitch(item)">查看</button>
|
||||
<button
|
||||
v-if="selectedSkill.publishedVersion && item.version !== selectedSkill.publishedVersion"
|
||||
type="button"
|
||||
@click="openVersionCompare({ baseVersion: selectedSkill.publishedVersion, targetVersion: item.version })"
|
||||
>
|
||||
与线上比
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageSelected && !item.isWorking"
|
||||
type="button"
|
||||
@click="restoreSelectedVersion(item.version)"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="version-center-section compact">
|
||||
<header>
|
||||
<strong>最近流转</strong>
|
||||
<button type="button" @click="openVersionTimeline">查看完整流转</button>
|
||||
</header>
|
||||
<div v-if="recentVersionTimelineItems.length" class="version-flow-preview">
|
||||
<article v-for="item in recentVersionTimelineItems" :key="`${item.event_type}-${item.version}-${item.event_time}`">
|
||||
<i :class="item.meta.icon"></i>
|
||||
<div>
|
||||
<strong>{{ item.meta.label }}</strong>
|
||||
<span>{{ item.version }} · {{ item.actor }}</span>
|
||||
</div>
|
||||
<small>{{ item.timeLabel }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="version-flow-empty">暂无版本流转记录</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="detail-grid"
|
||||
:class="{ 'skill-md-detail-grid': selectedSkill.type === 'rules' }"
|
||||
:class="{
|
||||
'skill-md-detail-grid': selectedSkill.type === 'rules' && !selectedSkill.usesSpreadsheetRule,
|
||||
'spreadsheet-detail-grid': selectedSkill.usesSpreadsheetRule
|
||||
}"
|
||||
>
|
||||
<section class="detail-main">
|
||||
<article v-if="selectedSkill.type === 'rules'" class="detail-card panel markdown-card">
|
||||
<article
|
||||
v-if="selectedSkill.type === 'rules' && !selectedSkill.usesSpreadsheetRule"
|
||||
class="detail-card panel markdown-card"
|
||||
>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>Markdown 规则内容</h3>
|
||||
@@ -128,7 +309,10 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="selectedSkill.type === 'rules'" class="detail-card panel json-editor-card">
|
||||
<article
|
||||
v-if="selectedSkill.type === 'rules' && !selectedSkill.usesSpreadsheetRule"
|
||||
class="detail-card panel json-editor-card"
|
||||
>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>运行时 JSON</h3>
|
||||
@@ -262,7 +446,7 @@
|
||||
<button
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="!canManageSelected || detailBusy"
|
||||
:disabled="!canSubmitReview || detailBusy"
|
||||
@click="reviewSelectedRule('pending')"
|
||||
>
|
||||
<i class="mdi mdi-send-outline"></i>
|
||||
@@ -271,7 +455,7 @@
|
||||
<button
|
||||
class="minor-action success-action"
|
||||
type="button"
|
||||
:disabled="!canManageSelected || detailBusy"
|
||||
:disabled="!canReviewSelected || detailBusy"
|
||||
@click="reviewSelectedRule('approved')"
|
||||
>
|
||||
<i class="mdi mdi-check-decagram-outline"></i>
|
||||
@@ -280,7 +464,7 @@
|
||||
<button
|
||||
class="minor-action danger-action"
|
||||
type="button"
|
||||
:disabled="!canManageSelected || detailBusy"
|
||||
:disabled="!canReviewSelected || detailBusy"
|
||||
@click="reviewSelectedRule('rejected')"
|
||||
>
|
||||
<i class="mdi mdi-close-octagon-outline"></i>
|
||||
@@ -304,23 +488,38 @@
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSkill.history.length" class="version-list">
|
||||
<button
|
||||
<div
|
||||
v-for="item in selectedSkill.history.slice(0, 5)"
|
||||
:key="item.version + item.time"
|
||||
class="version-row"
|
||||
:class="{ active: item.version === selectedSkill.displayVersion }"
|
||||
type="button"
|
||||
@click="openVersionSwitch(item)"
|
||||
>
|
||||
<div class="version-row-head">
|
||||
<strong>{{ item.version }}</strong>
|
||||
<span class="version-current-slot">
|
||||
<b v-if="item.version === selectedSkill.currentVersion" class="current-version">当前</b>
|
||||
</span>
|
||||
<span>{{ item.time }}</span>
|
||||
<button class="version-main" type="button" @click="openVersionSwitch(item)">
|
||||
<div class="version-row-head">
|
||||
<strong>{{ item.version }}</strong>
|
||||
<span class="version-current-slot">
|
||||
<b v-if="item.isPublished" class="current-version">线上</b>
|
||||
<b v-else-if="item.isWorking" class="current-version working">工作</b>
|
||||
</span>
|
||||
<span>{{ item.time }}</span>
|
||||
</div>
|
||||
<p>{{ item.note }}</p>
|
||||
</button>
|
||||
<div class="version-row-foot">
|
||||
<b :class="['version-state', item.lifecycleMeta.tone]">
|
||||
{{ item.lifecycleMeta.label }}
|
||||
</b>
|
||||
<button
|
||||
v-if="canManageSelected && !item.isWorking"
|
||||
class="version-restore-btn"
|
||||
type="button"
|
||||
:disabled="detailBusy"
|
||||
@click.stop="restoreSelectedVersion(item.version)"
|
||||
>
|
||||
{{ actionState === `restore-${item.version}` ? '恢复中...' : '恢复为工作稿' }}
|
||||
</button>
|
||||
</div>
|
||||
<p>{{ item.note }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-side-note">
|
||||
@@ -407,6 +606,27 @@
|
||||
|
||||
<div v-if="selectedSkillIsRule" class="detail-action-group">
|
||||
<button
|
||||
v-if="selectedSkill.usesSpreadsheetRule"
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="!canDownloadSpreadsheet"
|
||||
@click="downloadSpreadsheetFile"
|
||||
>
|
||||
<i class="mdi mdi-file-download-outline"></i>
|
||||
<span>{{ actionState === 'download-spreadsheet' ? '下载中...' : '下载 Excel' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSkill.usesSpreadsheetRule"
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="!canUploadSpreadsheet"
|
||||
@click="triggerSpreadsheetUpload"
|
||||
>
|
||||
<i class="mdi mdi-file-upload-outline"></i>
|
||||
<span>{{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="!canEditMarkdown || detailBusy"
|
||||
@@ -415,6 +635,33 @@
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ actionState === 'save-markdown' ? '保存中...' : '保存 Markdown' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="!canSubmitReview || detailBusy"
|
||||
@click="reviewSelectedRule('pending')"
|
||||
>
|
||||
<i class="mdi mdi-send-outline"></i>
|
||||
<span>{{ actionState === 'review-pending' ? '提交中...' : '提交审核' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="minor-action success-action"
|
||||
type="button"
|
||||
:disabled="!canReviewSelected || detailBusy"
|
||||
@click="reviewSelectedRule('approved')"
|
||||
>
|
||||
<i class="mdi mdi-check-decagram-outline"></i>
|
||||
<span>{{ actionState === 'review-approved' ? '处理中...' : '审核通过' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="minor-action danger-action"
|
||||
type="button"
|
||||
:disabled="!canReviewSelected || detailBusy"
|
||||
@click="reviewSelectedRule('rejected')"
|
||||
>
|
||||
<i class="mdi mdi-close-octagon-outline"></i>
|
||||
<span>{{ actionState === 'review-rejected' ? '处理中...' : '驳回版本' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="major-action"
|
||||
type="button"
|
||||
@@ -602,7 +849,6 @@
|
||||
<div v-else-if="errorMessage" class="table-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<button type="button" class="state-action" @click="loadAssets">重新加载</button>
|
||||
</div>
|
||||
|
||||
<TableEmptyState
|
||||
@@ -699,6 +945,178 @@
|
||||
<span>{{ versionSwitchTarget.time }}</span>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<Transition name="drawer-fade">
|
||||
<div v-if="versionTimelineOpen" class="rule-drawer-backdrop" @click.self="closeVersionTimeline">
|
||||
<aside class="rule-drawer timeline-drawer">
|
||||
<header class="rule-drawer-head">
|
||||
<div>
|
||||
<span>版本治理</span>
|
||||
<h3>版本流转时间线</h3>
|
||||
</div>
|
||||
<button type="button" @click="closeVersionTimeline">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="versionTimelineLoading" class="rule-drawer-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在加载版本流转...</span>
|
||||
</div>
|
||||
<div v-else-if="versionTimelineError" class="rule-drawer-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ versionTimelineError }}</span>
|
||||
</div>
|
||||
<div v-else-if="selectedVersionTimelineItems.length" class="rule-timeline-list">
|
||||
<article
|
||||
v-for="item in selectedVersionTimelineItems"
|
||||
:key="`${item.event_type}-${item.version}-${item.event_time}`"
|
||||
class="rule-timeline-item"
|
||||
>
|
||||
<i :class="[item.meta.icon, item.meta.tone]"></i>
|
||||
<div>
|
||||
<header>
|
||||
<strong>{{ item.meta.label }}</strong>
|
||||
<b>{{ item.version }}</b>
|
||||
<span>{{ item.timeLabel }}</span>
|
||||
</header>
|
||||
<p>{{ item.description || item.note || '暂无补充说明' }}</p>
|
||||
<small>
|
||||
操作人:{{ item.actor }}
|
||||
<template v-if="item.source_version"> · 来源版本:{{ item.source_version }}</template>
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="rule-drawer-state">
|
||||
<i class="mdi mdi-history"></i>
|
||||
<span>暂无版本流转记录</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="drawer-fade">
|
||||
<div v-if="versionCompareOpen" class="rule-drawer-backdrop" @click.self="closeVersionCompare">
|
||||
<aside class="rule-drawer compare-drawer">
|
||||
<header class="rule-drawer-head">
|
||||
<div>
|
||||
<span>版本治理</span>
|
||||
<h3>版本差异对比</h3>
|
||||
</div>
|
||||
<button type="button" @click="closeVersionCompare">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="compare-toolbar">
|
||||
<label>
|
||||
<span>基准版本</span>
|
||||
<select v-model="compareBaseVersion" @change="loadVersionCompare">
|
||||
<option
|
||||
v-for="item in selectedSkill?.history || []"
|
||||
:key="`base-${item.version}`"
|
||||
:value="item.version"
|
||||
>
|
||||
{{ item.version }} · {{ item.lifecycleMeta.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<i class="mdi mdi-arrow-right"></i>
|
||||
<label>
|
||||
<span>对比版本</span>
|
||||
<select v-model="compareTargetVersion" @change="loadVersionCompare">
|
||||
<option
|
||||
v-for="item in selectedSkill?.history || []"
|
||||
:key="`target-${item.version}`"
|
||||
:value="item.version"
|
||||
>
|
||||
{{ item.version }} · {{ item.lifecycleMeta.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="versionCompareLoading" class="rule-drawer-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在生成版本差异...</span>
|
||||
</div>
|
||||
<div v-else-if="versionCompareError" class="rule-drawer-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ versionCompareError }}</span>
|
||||
</div>
|
||||
<template v-else-if="versionComparePayload">
|
||||
<section class="compare-summary-grid">
|
||||
<article>
|
||||
<span>新增工作表</span>
|
||||
<strong>{{ versionComparePayload.added_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>删除工作表</span>
|
||||
<strong>{{ versionComparePayload.removed_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<strong>{{ versionComparePayload.changed_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>变更单元格</span>
|
||||
<strong>{{ versionComparePayload.changed_cell_count }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="compare-panel">
|
||||
<header>
|
||||
<strong>工作表变化</strong>
|
||||
</header>
|
||||
<div v-if="versionCompareSheetRows.length" class="compare-sheet-list">
|
||||
<span
|
||||
v-for="item in versionCompareSheetRows"
|
||||
:key="`${item.sheet_name}-${item.change_type}`"
|
||||
:class="item.meta.tone"
|
||||
>
|
||||
{{ item.sheet_name }} · {{ item.meta.label }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else>没有新增或删除工作表。</p>
|
||||
</section>
|
||||
|
||||
<section class="compare-panel">
|
||||
<header>
|
||||
<strong>单元格差异</strong>
|
||||
<small>最多展示前 500 条</small>
|
||||
</header>
|
||||
<div v-if="versionCompareCellRows.length" class="compare-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工作表</th>
|
||||
<th>位置</th>
|
||||
<th>类型</th>
|
||||
<th>旧值</th>
|
||||
<th>新值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in versionCompareCellRows"
|
||||
:key="`${item.sheet_name}-${item.cell}`"
|
||||
>
|
||||
<td>{{ item.sheet_name }}</td>
|
||||
<td>{{ item.cell }}</td>
|
||||
<td><b :class="item.meta.tone">{{ item.meta.label }}</b></td>
|
||||
<td>{{ item.before_value ?? '-' }}</td>
|
||||
<td>{{ item.after_value ?? '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else>两个版本内容一致,没有发现单元格级差异。</p>
|
||||
</section>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -21,12 +21,13 @@
|
||||
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
|
||||
{{ resolveRunLevel(hermesRun) }}
|
||||
</span>
|
||||
<span class="status-pill" :class="resolveStatusTone(hermesRun.status)">
|
||||
{{ resolveStatusLabel(hermesRun.status) }}
|
||||
<span class="status-pill" :class="resolveStatusTone(hermesRun)">
|
||||
{{ resolveStatusLabel(hermesRun) }}
|
||||
</span>
|
||||
</div>
|
||||
<h2>{{ resolveRunTitle(hermesRun) }}</h2>
|
||||
<p>{{ hermesRun.result_summary || '暂无运行摘要。' }}</p>
|
||||
<p v-if="hermesRun.status === 'running'" class="hero-hint">运行中每 5 秒自动刷新一次详情。</p>
|
||||
</div>
|
||||
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
@@ -34,6 +35,14 @@
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-if="hermesRunAlert"
|
||||
class="panel detail-alert"
|
||||
:class="hermesRunAlert.tone"
|
||||
>
|
||||
{{ hermesRunAlert.message }}
|
||||
</article>
|
||||
|
||||
<div class="detail-grid">
|
||||
<article class="panel detail-card wide">
|
||||
<div class="card-head">
|
||||
@@ -46,7 +55,11 @@
|
||||
<div><span>结束时间</span><strong>{{ formatDateTime(hermesRun.finished_at) }}</strong></div>
|
||||
<div><span>来源</span><strong>{{ resolveRunSourceLabel(hermesRun.source) }}</strong></div>
|
||||
<div><span>模块</span><strong>{{ resolveRunModuleLabel(hermesRun) }}</strong></div>
|
||||
<div><span>当前阶段</span><strong>{{ hermesRunStatus.phaseLabel }}</strong></div>
|
||||
<div><span>当前进度</span><strong>{{ resolveRunProgress(hermesRun) }}</strong></div>
|
||||
<div><span>执行耗时</span><strong>{{ resolveRunElapsedLabel(hermesRun) }}</strong></div>
|
||||
<div><span>最后心跳</span><strong>{{ resolveHeartbeatAtText(hermesRunHeartbeat) }}</strong></div>
|
||||
<div><span>心跳状态</span><strong>{{ hermesRunHeartbeat.label }}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -67,14 +80,16 @@
|
||||
<span class="step-index">{{ index + 1 }}</span>
|
||||
<div class="step-copy">
|
||||
<strong>{{ toolCall.tool_name }}</strong>
|
||||
<span>{{ toolCall.tool_type }} · {{ toolCall.duration_ms }}ms</span>
|
||||
<span>{{ resolveToolCallMeta(toolCall) }}</span>
|
||||
</div>
|
||||
<span class="status-pill" :class="resolveToolStatusTone(toolCall.status)">
|
||||
{{ toolCall.status }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="inline-empty">当前运行暂无 ToolCall 明细。</div>
|
||||
<div v-else class="inline-empty">
|
||||
当前暂无 ToolCall 明细。若长时间停在运行中且没有心跳,通常表示任务尚未真正进入 LightRAG 索引调用,或执行它的是旧版后端进程。
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="selectedToolCall" class="panel detail-card">
|
||||
@@ -176,11 +191,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { fetchAgentRunDetail } from '../services/agentAssets.js'
|
||||
import { fetchSystemLogEntry } from '../services/systemLogs.js'
|
||||
import {
|
||||
AGENT_RUN_POLL_INTERVAL_MS,
|
||||
formatAgentRunElapsed,
|
||||
formatAgentRunProgress,
|
||||
formatDurationShort,
|
||||
resolveAgentRunHeartbeat,
|
||||
resolveAgentRunStatus
|
||||
} from '../utils/agentRunMonitor.js'
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
schedule: '定时任务',
|
||||
@@ -195,12 +218,34 @@ const error = ref('')
|
||||
const hermesRun = ref(null)
|
||||
const systemEntry = ref(null)
|
||||
const selectedToolCallId = ref('')
|
||||
const nowTick = ref(Date.now())
|
||||
let pollTimer = 0
|
||||
|
||||
const isHermes = computed(() => route.params.logKind === 'hermes')
|
||||
const isSystem = computed(() => route.params.logKind === 'system')
|
||||
const selectedToolCall = computed(() =>
|
||||
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
|
||||
)
|
||||
const hermesRunStatus = computed(() => resolveAgentRunStatus(hermesRun.value, nowTick.value))
|
||||
const hermesRunHeartbeat = computed(() => resolveAgentRunHeartbeat(hermesRun.value, nowTick.value))
|
||||
const hermesRunAlert = computed(() => {
|
||||
if (!hermesRun.value) {
|
||||
return null
|
||||
}
|
||||
if (hermesRun.value.error_message) {
|
||||
return {
|
||||
tone: 'danger',
|
||||
message: hermesRun.value.error_message
|
||||
}
|
||||
}
|
||||
if (hermesRunStatus.value.isSuspicious) {
|
||||
return {
|
||||
tone: hermesRunStatus.value.tone === 'danger' ? 'danger' : 'warning',
|
||||
message: hermesRunStatus.value.note || '当前任务长时间没有有效进展,建议检查后台执行器。'
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '未结束'
|
||||
@@ -216,19 +261,12 @@ function formatJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusLabel(status) {
|
||||
if (status === 'running') return '运行中'
|
||||
if (status === 'succeeded') return '已完成'
|
||||
if (status === 'failed') return '失败'
|
||||
if (status === 'blocked') return '待确认'
|
||||
return status || '未知'
|
||||
function resolveStatusLabel(run) {
|
||||
return resolveAgentRunStatus(run, nowTick.value).label
|
||||
}
|
||||
|
||||
function resolveStatusTone(status) {
|
||||
if (status === 'running') return 'warning'
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'failed') return 'danger'
|
||||
return 'muted'
|
||||
function resolveStatusTone(run) {
|
||||
return resolveAgentRunStatus(run, nowTick.value).tone
|
||||
}
|
||||
|
||||
function resolveToolStatusTone(status) {
|
||||
@@ -257,7 +295,9 @@ function resolveRunTitle(run) {
|
||||
|
||||
function resolveRunLevel(run) {
|
||||
const progress = run?.route_json?.progress || {}
|
||||
const statusInfo = resolveAgentRunStatus(run, nowTick.value)
|
||||
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'
|
||||
return 'INFO'
|
||||
}
|
||||
@@ -270,25 +310,7 @@ function resolveLevelTone(level) {
|
||||
}
|
||||
|
||||
function resolveRunProgress(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 || '等待中')
|
||||
return total > 0
|
||||
? `${percent}% · ${completed}/${total} 文档 · ${stageLabel}${failed > 0 ? ` · 失败 ${failed}` : ''}`
|
||||
: `${percent}% · ${stageLabel}`
|
||||
return formatAgentRunProgress(run)
|
||||
}
|
||||
|
||||
function resolveSystemLevelTone(level) {
|
||||
@@ -317,6 +339,38 @@ function resolveSystemRecommendation(entry) {
|
||||
return '无需额外处理'
|
||||
}
|
||||
|
||||
function resolveRunElapsedLabel(run) {
|
||||
const elapsed = formatAgentRunElapsed(run, nowTick.value)
|
||||
if (elapsed === '—') {
|
||||
return elapsed
|
||||
}
|
||||
return run?.status === 'running' ? `已运行 ${elapsed}` : elapsed
|
||||
}
|
||||
|
||||
function resolveHeartbeatAtText(heartbeat) {
|
||||
if (heartbeat?.at) {
|
||||
return `${formatDateTime(heartbeat.at)} · ${heartbeat.text}`
|
||||
}
|
||||
return heartbeat?.text || '—'
|
||||
}
|
||||
|
||||
function resolveToolCallMeta(toolCall) {
|
||||
const toolType = String(toolCall?.tool_type || 'tool').trim()
|
||||
if (String(toolCall?.status || '').trim() === 'running') {
|
||||
const createdAt = new Date(toolCall?.created_at)
|
||||
if (!Number.isNaN(createdAt.getTime())) {
|
||||
return `${toolType} · 已运行 ${formatDurationShort(nowTick.value - createdAt.getTime())}`
|
||||
}
|
||||
return `${toolType} · 执行中`
|
||||
}
|
||||
|
||||
const durationMs = Number(toolCall?.duration_ms || 0)
|
||||
if (durationMs > 0) {
|
||||
return `${toolType} · ${durationMs}ms`
|
||||
}
|
||||
return `${toolType} · 已结束`
|
||||
}
|
||||
|
||||
function syncSelectedToolCall() {
|
||||
const calls = hermesRun.value?.tool_calls || []
|
||||
if (!calls.length) {
|
||||
@@ -328,8 +382,33 @@ function syncSelectedToolCall() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
function syncPolling() {
|
||||
stopPolling()
|
||||
|
||||
if (!isHermes.value || hermesRun.value?.status !== 'running') {
|
||||
return
|
||||
}
|
||||
|
||||
pollTimer = window.setInterval(() => {
|
||||
nowTick.value = Date.now()
|
||||
if (!loading.value) {
|
||||
void loadDetail({ silent: true })
|
||||
}
|
||||
}, AGENT_RUN_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function loadDetail(options = {}) {
|
||||
const silent = options.silent === true
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
}
|
||||
error.value = ''
|
||||
try {
|
||||
const id = String(route.params.logId || '')
|
||||
@@ -348,7 +427,11 @@ async function loadDetail() {
|
||||
} catch (nextError) {
|
||||
error.value = nextError?.message || '日志详情加载失败。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
nowTick.value = Date.now()
|
||||
syncPolling()
|
||||
if (!silent) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,8 +439,20 @@ function backToLogs() {
|
||||
router.push({ name: 'app-logs' })
|
||||
}
|
||||
|
||||
watch(() => [route.params.logKind, route.params.logId], loadDetail)
|
||||
onMounted(loadDetail)
|
||||
watch(
|
||||
() => [route.params.logKind, route.params.logId],
|
||||
() => {
|
||||
void loadDetail()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void loadDetail()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/log-detail-view.css"></style>
|
||||
|
||||
@@ -113,12 +113,16 @@
|
||||
<td class="summary-cell">
|
||||
<strong>{{ resolveRunTitle(run) }}</strong>
|
||||
<span>{{ formatSummary(run.result_summary) }}</span>
|
||||
<em class="summary-meta">{{ resolveRunSummaryMeta(run) }}</em>
|
||||
</td>
|
||||
<td class="trace-cell">{{ run.run_id }}</td>
|
||||
<td>
|
||||
<span class="status-pill" :class="resolveStatusTone(run.status)">
|
||||
{{ resolveStatusLabel(run.status) }}
|
||||
</span>
|
||||
<div class="status-stack">
|
||||
<span class="status-pill" :class="resolveStatusTone(run)">
|
||||
{{ resolveStatusLabel(run) }}
|
||||
</span>
|
||||
<span class="status-note">{{ resolveRunStatusNote(run) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -225,7 +229,7 @@
|
||||
<div class="analytics-head">
|
||||
<div>
|
||||
<h3>日志趋势</h3>
|
||||
<p>近 8 个小时的 Hermes 运行量与失败量</p>
|
||||
<p>近 8 个小时的 Hermes 启动量与失败量,不代表实时心跳</p>
|
||||
</div>
|
||||
</div>
|
||||
<LogTrendChart
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,15 @@ 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 POLL_INTERVAL_MS = 5000
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
schedule: '定时任务',
|
||||
system_event: '系统事件',
|
||||
@@ -30,36 +35,12 @@ 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 resolveStatusLabel(run) {
|
||||
return resolveAgentRunStatus(run).label
|
||||
}
|
||||
|
||||
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 resolveStatusTone(run) {
|
||||
return resolveAgentRunStatus(run).tone
|
||||
}
|
||||
|
||||
function resolveRunSourceLabel(source) {
|
||||
@@ -90,9 +71,13 @@ function resolveRunTitle(run) {
|
||||
|
||||
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'
|
||||
}
|
||||
@@ -126,6 +111,37 @@ function formatSummary(summary) {
|
||||
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') {
|
||||
return 'danger'
|
||||
@@ -369,7 +385,7 @@ export default {
|
||||
pollTimer = window.setInterval(() => {
|
||||
loadHermesRuns()
|
||||
loadSystemLogs()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}, AGENT_RUN_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
@@ -444,6 +460,8 @@ export default {
|
||||
resolveRunLevel,
|
||||
resolveRunModuleLabel,
|
||||
resolveRunSourceLabel,
|
||||
resolveRunStatusNote,
|
||||
resolveRunSummaryMeta,
|
||||
resolveRunTitle,
|
||||
resolveStatusLabel,
|
||||
resolveStatusTone,
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user