feat: 更新前端UI,增强审计和日志视图功能

This commit is contained in:
caoxiaozhu
2026-05-18 02:51:25 +00:00
parent 35a3783481
commit 9d90bf5299
10 changed files with 3544 additions and 954 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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

View 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}%`
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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`
}
}