From 9d90bf5299a0a7f85465cb0c4270603929516164 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 18 May 2026 02:51:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=89=8D=E7=AB=AFUI?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=AE=A1=E8=AE=A1=E5=92=8C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=A7=86=E5=9B=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/assets/styles/views/audit-view.css | 920 ++++++++- web/src/assets/styles/views/logs-view.css | 22 + web/src/utils/accessControl.js | 12 +- web/src/utils/agentRunMonitor.js | 271 +++ web/src/views/AuditView.vue | 1816 ++++++++++------- web/src/views/LogDetailView.vue | 349 ++-- web/src/views/LogsView.vue | 12 +- web/src/views/scripts/AuditView.js | 931 ++++++++- web/src/views/scripts/LogsView.js | 140 +- .../views/scripts/onlyOfficePreviewConfig.js | 25 + 10 files changed, 3544 insertions(+), 954 deletions(-) create mode 100644 web/src/utils/agentRunMonitor.js diff --git a/web/src/assets/styles/views/audit-view.css b/web/src/assets/styles/views/audit-view.css index 0948a59..353f46e 100644 --- a/web/src/assets/styles/views/audit-view.css +++ b/web/src/assets/styles/views/audit-view.css @@ -26,6 +26,10 @@ gap: 12px; } +.skill-detail.spreadsheet-skill-detail { + gap: 10px; +} + .skill-list { display: flex; flex-direction: column; @@ -474,6 +478,7 @@ tbody tr.spotlight { } .skill-avatar.emerald { background: linear-gradient(135deg, #10b981, #059669); } +.skill-avatar.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); } .skill-avatar.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); } .skill-avatar.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); } .skill-avatar.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); } @@ -557,6 +562,13 @@ tbody tr.spotlight { gap: 16px; } +.spreadsheet-skill-detail .detail-scroll { + min-height: 0; + overflow: hidden; + align-content: stretch; + grid-template-rows: minmax(0, 1fr); +} + .detail-hero { display: grid; grid-template-columns: minmax(0, 1fr) 320px; @@ -581,6 +593,7 @@ tbody tr.spotlight { } .skill-badge.emerald { background: linear-gradient(135deg, #10b981, #059669); } +.skill-badge.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); } .skill-badge.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); } .skill-badge.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); } .skill-badge.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); } @@ -813,6 +826,832 @@ tbody tr.spotlight { gap: 12px; } +.spreadsheet-rule-card { + display: grid; + gap: 14px; +} + +.spreadsheet-editor-shell { + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; +} + +.spreadsheet-editor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.spreadsheet-editor-title { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 12px; +} + +.spreadsheet-editor-title h2 { + color: #0f172a; + font-size: 18px; + font-weight: 850; +} + +.spreadsheet-editor-title p { + margin-top: 2px; + max-width: 760px; + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.spreadsheet-editor-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.spreadsheet-editor-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.spreadsheet-editor-meta span { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border-radius: 999px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 750; +} + +.spreadsheet-editor-meta strong { + color: #0f172a; + font-weight: 850; +} + +.spreadsheet-editor-body { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 286px; + gap: 14px; + align-items: start; +} + +.spreadsheet-main-stage { + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} + +.spreadsheet-workbench { + flex: 1 1 auto; + position: relative; + min-height: 0; + overflow: visible; + border: 1px solid #dbe4ee; + border-radius: 12px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +.spreadsheet-workbench .rule-spreadsheet-host { + min-height: 0; + height: 100%; + overflow: visible; +} + +.spreadsheet-editor-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.spreadsheet-version-center { + min-height: 0; + height: 100%; + align-self: stretch; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 12px; + padding: 14px; + border: 1px solid #dbe4ee; + border-radius: 14px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + overflow: hidden; +} + +.version-center-head h3, +.version-center-head p, +.version-center-section header, +.version-center-section p { + margin: 0; +} + +.version-center-head h3 { + color: #0f172a; + font-size: 15px; + font-weight: 900; +} + +.version-center-head p { + margin-top: 3px; + color: #64748b; + font-size: 12px; +} + +.version-pair-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.version-pair-card { + display: grid; + gap: 4px; + min-height: 84px; + padding: 10px; + border-radius: 12px; +} + +.version-pair-card span { + color: #64748b; + font-size: 11px; + font-weight: 800; +} + +.version-pair-card strong { + color: #0f172a; + font-size: 16px; + font-weight: 900; +} + +.version-pair-card b { + width: fit-content; + min-height: 20px; + display: inline-flex; + align-items: center; + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 850; +} + +.version-pair-card.published { + background: rgba(16, 185, 129, 0.1); +} + +.version-pair-card.published b { + background: #dcfce7; + color: #059669; +} + +.version-pair-card.working { + background: rgba(37, 99, 235, 0.08); +} + +.version-pair-card.working b { + background: #dbeafe; + color: #2563eb; +} + +.version-center-section { + display: grid; + gap: 8px; +} + +.version-history-section { + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); +} + +.version-center-section > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.version-center-section > header strong { + color: #0f172a; + font-size: 13px; + font-weight: 900; +} + +.version-center-section > header small, +.version-center-section > header button { + color: #64748b; + font-size: 11px; + font-weight: 800; +} + +.version-center-section > header button { + padding: 0; + border: 0; + background: transparent; + color: #2563eb; + cursor: pointer; +} + +.version-center-list { + display: grid; + align-content: start; + gap: 8px; + min-height: 0; + overflow-y: auto; + padding-right: 2px; +} + +.version-center-item { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; +} + +.version-center-item.active { + border-color: rgba(16, 185, 129, 0.35); + background: rgba(16, 185, 129, 0.05); +} + +.version-center-item > button { + display: grid; + gap: 5px; + padding: 0; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + +.version-center-item > button div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.version-center-item > button strong { + color: #0f172a; + font-size: 13px; + font-weight: 900; +} + +.version-center-item > button span, +.version-center-item > button p { + color: #64748b; + font-size: 11px; +} + +.version-center-item > button p { + margin: 0; + line-height: 1.45; +} + +.version-center-item footer { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.version-center-item footer button { + min-height: 24px; + padding: 0 9px; + border: 0; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 850; + cursor: pointer; +} + +.version-center-item footer button:nth-child(2) { + background: #eef2ff; + color: #4f46e5; +} + +.version-center-item footer button:nth-child(3) { + background: #fff7ed; + color: #ea580c; +} + +.version-flow-preview { + display: grid; + gap: 8px; +} + +.version-flow-preview article { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 8px; + align-items: start; +} + +.version-flow-preview i { + color: #2563eb; + font-size: 16px; +} + +.version-flow-preview div { + display: grid; + gap: 2px; +} + +.version-flow-preview strong { + color: #0f172a; + font-size: 12px; +} + +.version-flow-preview span, +.version-flow-preview small, +.version-flow-empty { + color: #64748b; + font-size: 11px; +} + +.version-flow-preview small { + grid-column: 2; +} + +.rule-drawer-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + justify-content: flex-end; + background: rgba(15, 23, 42, 0.34); + backdrop-filter: blur(3px); +} + +.rule-drawer { + width: min(860px, 78vw); + height: 100%; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 18px; + padding: 22px; + overflow: auto; + background: #fff; + box-shadow: -18px 0 42px rgba(15, 23, 42, 0.18); +} + +.timeline-drawer { + width: min(640px, 62vw); +} + +.rule-drawer-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} + +.rule-drawer-head span { + color: #2563eb; + font-size: 12px; + font-weight: 850; +} + +.rule-drawer-head h3 { + margin: 4px 0 0; + color: #0f172a; + font-size: 20px; + font-weight: 950; +} + +.rule-drawer-head button { + width: 34px; + height: 34px; + border: 0; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + cursor: pointer; +} + +.rule-drawer-state { + min-height: 160px; + display: grid; + place-items: center; + gap: 10px; + color: #64748b; +} + +.rule-drawer-state.error { + color: #dc2626; +} + +.rule-timeline-list { + display: grid; + align-content: start; +} + +.rule-timeline-item { + position: relative; + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + gap: 12px; + padding-bottom: 20px; +} + +.rule-timeline-item:not(:last-child)::after { + content: ''; + position: absolute; + left: 14px; + top: 28px; + bottom: 0; + width: 2px; + background: #e2e8f0; +} + +.rule-timeline-item > i { + position: relative; + z-index: 1; + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 999px; + background: #f1f5f9; + color: #475569; +} + +.rule-timeline-item > i.success { + background: #dcfce7; + color: #059669; +} + +.rule-timeline-item > i.warning { + background: #fef3c7; + color: #d97706; +} + +.rule-timeline-item > i.danger { + background: #fee2e2; + color: #dc2626; +} + +.rule-timeline-item > i.info { + background: #dbeafe; + color: #2563eb; +} + +.rule-timeline-item header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.rule-timeline-item header strong { + color: #0f172a; + font-size: 14px; +} + +.rule-timeline-item header b { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 11px; +} + +.rule-timeline-item header span, +.rule-timeline-item p, +.rule-timeline-item small { + color: #64748b; +} + +.rule-timeline-item p { + margin: 8px 0 4px; + line-height: 1.6; +} + +.compare-toolbar { + display: flex; + align-items: end; + gap: 12px; +} + +.compare-toolbar label { + min-width: 0; + display: grid; + gap: 6px; + flex: 1; +} + +.compare-toolbar span { + color: #64748b; + font-size: 12px; + font-weight: 850; +} + +.compare-toolbar select { + width: 100%; + min-height: 40px; + padding: 0 12px; + border: 1px solid #cbd5e1; + border-radius: 12px; + background: #fff; +} + +.compare-toolbar i { + margin-bottom: 10px; + color: #94a3b8; +} + +.compare-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.compare-summary-grid article { + display: grid; + gap: 4px; + min-height: 76px; + padding: 12px; + border-radius: 14px; + background: #f8fafc; +} + +.compare-summary-grid span { + color: #64748b; + font-size: 12px; +} + +.compare-summary-grid strong { + color: #0f172a; + font-size: 24px; + font-weight: 950; +} + +.compare-panel { + display: grid; + gap: 10px; +} + +.compare-panel header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.compare-panel header strong { + color: #0f172a; + font-size: 14px; +} + +.compare-panel p, +.compare-panel small { + color: #64748b; +} + +.compare-sheet-list { + display: grid; + gap: 8px; +} + +.compare-sheet-list article { + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 12px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; +} + +.compare-sheet-list strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 850; + overflow-wrap: anywhere; +} + +.compare-sheet-list b { + min-height: 24px; + display: inline-flex; + align-items: center; + flex: 0 0 auto; + padding: 0 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 850; +} + +.compare-sheet-list b.success { + background: #dcfce7; + color: #059669; +} + +.compare-sheet-list b.danger { + background: #fee2e2; + color: #dc2626; +} + +.compare-table-wrap { + overflow: auto; + border: 1px solid #e2e8f0; + border-radius: 14px; +} + +.compare-table-wrap table { + width: 100%; + border-collapse: collapse; +} + +.compare-table-wrap th, +.compare-table-wrap td { + padding: 10px 12px; + border-bottom: 1px solid #eef2f7; + text-align: left; + vertical-align: top; +} + +.compare-table-wrap th { + color: #475569; + font-size: 12px; + background: #f8fafc; +} + +.compare-table-wrap td { + color: #0f172a; + font-size: 13px; +} + +.compare-table-wrap td b { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; +} + +.compare-table-wrap td b.success { + background: #dcfce7; + color: #059669; +} + +.compare-table-wrap td b.warning { + background: #fef3c7; + color: #d97706; +} + +.compare-table-wrap td b.danger { + background: #fee2e2; + color: #dc2626; +} + +@media (max-width: 1280px) { + .spreadsheet-editor-body { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .rule-drawer, + .timeline-drawer { + width: 100%; + } + + .compare-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.rule-spreadsheet-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.spreadsheet-mode-pill { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + background: #eff6ff; + color: #1d4ed8; + font-size: 12px; + font-weight: 800; +} + +.spreadsheet-upload-input { + display: none; +} + +.spreadsheet-meta-strip { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.spreadsheet-meta-strip span { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border-radius: 999px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 750; +} + +.spreadsheet-meta-strip strong { + color: #0f172a; + font-weight: 850; +} + +.rule-spreadsheet-stage { + position: relative; + min-height: 720px; + overflow: hidden; + border: 1px solid #dbe4ee; + border-radius: 14px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +.rule-spreadsheet-host { + width: 100%; + height: 100%; + min-height: 720px; +} + +.rule-spreadsheet-host.hidden { + visibility: hidden; +} + +.rule-spreadsheet-state { + position: absolute; + inset: 0; + display: grid; + place-items: center; + gap: 8px; + padding: 24px; + background: rgba(248, 250, 252, 0.94); + color: #475569; + font-size: 13px; + font-weight: 800; + text-align: center; +} + +.rule-spreadsheet-state i { + font-size: 22px; +} + +.rule-spreadsheet-state.error { + color: #dc2626; +} + +.preview-mode-note { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 14px; + padding: 10px 12px; + border: 1px solid rgba(14, 165, 233, 0.22); + border-radius: 12px; + background: linear-gradient(180deg, rgba(240, 249, 255, 0.96), rgba(224, 242, 254, 0.9)); + color: #075985; + font-size: 12px; + font-weight: 760; + line-height: 1.5; +} + +.preview-mode-note i { + font-size: 16px; +} + .markdown-card .field { min-height: 0; grid-template-rows: auto minmax(0, 1fr); @@ -958,7 +1797,6 @@ tbody tr.spotlight { border-left: 0; background: transparent; text-align: left; - cursor: pointer; transition: background 180ms ease; } @@ -976,6 +1814,17 @@ tbody tr.spotlight { background: rgba(16, 185, 129, 0.08); } +.version-main { + display: grid; + gap: 6px; + width: 100%; + padding: 0; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + .version-row-head { display: grid; grid-template-columns: minmax(52px, 1fr) 46px 82px; @@ -1017,12 +1866,76 @@ tbody tr.spotlight { font-weight: 850; } +.version-current-slot .current-version.working { + background: #dbeafe; + color: #2563eb; +} + .version-row p { color: #64748b; font-size: 12px; line-height: 1.5; } +.version-row-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.version-state { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 850; +} + +.version-state.success { + background: #dcfce7; + color: #059669; +} + +.version-state.warning { + background: #fef3c7; + color: #d97706; +} + +.version-state.danger { + background: #fee2e2; + color: #dc2626; +} + +.version-state.draft { + background: #e2e8f0; + color: #475569; +} + +.version-state.disabled { + background: #f1f5f9; + color: #64748b; +} + +.version-restore-btn { + min-height: 24px; + padding: 0 9px; + border: 0; + border-radius: 999px; + background: #eef2ff; + color: #4f46e5; + font-size: 11px; + font-weight: 850; + cursor: pointer; +} + +.version-restore-btn:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .empty-side-note { min-height: 120px; display: grid; @@ -1392,6 +2305,11 @@ tbody tr.spotlight { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .rule-spreadsheet-stage, + .rule-spreadsheet-host { + min-height: 620px; + } + .detail-grid { grid-template-columns: 1fr; } diff --git a/web/src/assets/styles/views/logs-view.css b/web/src/assets/styles/views/logs-view.css index 3853eeb..a77fe74 100644 --- a/web/src/assets/styles/views/logs-view.css +++ b/web/src/assets/styles/views/logs-view.css @@ -282,12 +282,34 @@ line-height: 1.5; } +.summary-meta { + display: block; + margin-top: 6px; + color: #94a3b8; + font-size: 12px; + font-style: normal; + line-height: 1.5; +} + .trace-cell { max-width: 180px; color: #2563eb; word-break: break-all; } +.status-stack { + display: grid; + gap: 5px; + align-content: start; +} + +.status-note { + color: #64748b; + font-size: 12px; + line-height: 1.5; + word-break: break-word; +} + .system-table .summary-cell { min-width: 260px; } diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 5f81095..b6054e6 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -14,7 +14,7 @@ const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies']) const VIEW_ROLE_RULES = { overview: ['finance', 'executive'], approval: ['approver'], - audit: ['auditor'], + audit: ['auditor', 'finance'], logs: ['manager'], employees: ['manager'], settings: ['manager'] @@ -28,9 +28,13 @@ function normalizedRoleCodes(user) { return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : [] } -export function isManagerUser(user) { - return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') -} +export function isManagerUser(user) { + return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') +} + +export function isFinanceUser(user) { + return normalizedRoleCodes(user).includes('finance') +} export function canAccessAppView(user, viewId) { if (!viewId || !user) { diff --git a/web/src/utils/agentRunMonitor.js b/web/src/utils/agentRunMonitor.js new file mode 100644 index 0000000..e327da4 --- /dev/null +++ b/web/src/utils/agentRunMonitor.js @@ -0,0 +1,271 @@ +const KNOWLEDGE_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync']) + +const STATUS_LABELS = { + running: '运行中', + succeeded: '已完成', + failed: '失败', + blocked: '待确认' +} + +const STATUS_TONES = { + running: 'warning', + succeeded: 'success', + failed: 'danger', + blocked: 'muted' +} + +const PHASE_LABELS = { + queued: '排队中', + indexing: '归纳中', + completed: '已完成', + failed: '失败', + stale_failed: '超时失败' +} + +export const AGENT_RUN_POLL_INTERVAL_MS = 5000 +export const AGENT_RUN_HEARTBEAT_DELAY_MS = 60 * 1000 +export const AGENT_RUN_HEARTBEAT_STUCK_MS = 5 * 60 * 1000 + +function toDate(value) { + if (!value) { + return null + } + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function resolveNowMs(now = Date.now()) { + if (now instanceof Date) { + return now.getTime() + } + + const numeric = Number(now) + return Number.isFinite(numeric) ? numeric : Date.now() +} + +export function isKnowledgeIndexRun(run) { + const jobType = String(run?.route_json?.job_type || '').trim() + return KNOWLEDGE_JOB_TYPES.has(jobType) +} + +export function getAgentRunPhase(run) { + return String(run?.route_json?.phase || '').trim() +} + +export function formatDurationShort(valueMs) { + const numeric = Number(valueMs) + if (!Number.isFinite(numeric) || numeric < 0) { + return '—' + } + + const totalSeconds = Math.max(0, Math.round(numeric / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + if (hours > 0) { + return `${hours}小时${minutes}分` + } + if (minutes > 0) { + return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分` + } + return `${seconds}秒` +} + +export function formatAgentRunElapsed(run, now = Date.now()) { + const startedAt = toDate(run?.started_at) + if (startedAt === null) { + return '—' + } + + const finishedAt = toDate(run?.finished_at) + const endMs = finishedAt ? finishedAt.getTime() : resolveNowMs(now) + return formatDurationShort(endMs - startedAt.getTime()) +} + +export function resolveAgentRunPhaseLabel(run) { + const phase = getAgentRunPhase(run) + return PHASE_LABELS[phase] || STATUS_LABELS[String(run?.status || '').trim()] || '未知' +} + +export function resolveAgentRunHeartbeat(run, now = Date.now()) { + const heartbeatAt = toDate(run?.route_json?.heartbeat_at) + const startedAt = toDate(run?.started_at) + const nowMs = resolveNowMs(now) + const phase = getAgentRunPhase(run) + const isRunning = String(run?.status || '').trim() === 'running' + const heartbeatAgeMs = heartbeatAt ? Math.max(0, nowMs - heartbeatAt.getTime()) : null + const startedAgeMs = startedAt ? Math.max(0, nowMs - startedAt.getTime()) : null + + if (heartbeatAt) { + if (heartbeatAgeMs >= AGENT_RUN_HEARTBEAT_STUCK_MS) { + return { + at: heartbeatAt, + ageMs: heartbeatAgeMs, + text: `${formatDurationShort(heartbeatAgeMs)}前`, + label: '疑似中断', + tone: 'danger' + } + } + if (heartbeatAgeMs >= AGENT_RUN_HEARTBEAT_DELAY_MS) { + return { + at: heartbeatAt, + ageMs: heartbeatAgeMs, + text: `${formatDurationShort(heartbeatAgeMs)}前`, + label: '心跳延迟', + tone: 'warning' + } + } + return { + at: heartbeatAt, + ageMs: heartbeatAgeMs, + text: `${formatDurationShort(heartbeatAgeMs)}前`, + label: '心跳正常', + tone: 'success' + } + } + + if (!isKnowledgeIndexRun(run) || !isRunning) { + return { + at: null, + ageMs: null, + text: '—', + label: '无心跳', + tone: 'muted' + } + } + + if (phase === 'queued') { + return { + at: null, + ageMs: startedAgeMs, + text: '尚未开始', + label: '等待执行', + tone: 'muted' + } + } + + if ((startedAgeMs || 0) >= AGENT_RUN_HEARTBEAT_DELAY_MS) { + return { + at: null, + ageMs: startedAgeMs, + text: '尚未收到', + label: '无心跳', + tone: 'warning' + } + } + + return { + at: null, + ageMs: startedAgeMs, + text: '等待首个心跳', + label: '等待心跳', + tone: 'muted' + } +} + +export function resolveAgentRunStatus(run, now = Date.now()) { + const status = String(run?.status || '').trim() + const phase = getAgentRunPhase(run) + const heartbeat = resolveAgentRunHeartbeat(run, now) + let label = STATUS_LABELS[status] || status || '未知' + let tone = STATUS_TONES[status] || 'muted' + let note = '' + let isSuspicious = false + + if (status === 'failed' && phase === 'stale_failed') { + return { + label: '已超时', + tone: 'danger', + note: '系统已按长时间无心跳自动判定失败', + phase, + phaseLabel: resolveAgentRunPhaseLabel(run), + heartbeat, + isSuspicious: true + } + } + + if (status === 'running' && isKnowledgeIndexRun(run)) { + if (phase === 'queued') { + label = '排队中' + tone = 'muted' + note = '等待后台线程接管' + } else if (phase === 'indexing') { + if (heartbeat.at === null && heartbeat.label === '无心跳') { + label = '无心跳' + tone = 'warning' + note = '已进入归纳流程,但还没有收到心跳' + isSuspicious = true + } else if (heartbeat.tone === 'danger') { + label = '疑似卡住' + tone = 'danger' + note = `最后心跳在 ${heartbeat.text}` + isSuspicious = true + } else if (heartbeat.tone === 'warning') { + label = '心跳延迟' + tone = 'warning' + note = `最后心跳在 ${heartbeat.text}` + isSuspicious = true + } else if (heartbeat.at === null) { + label = '归纳启动中' + tone = 'warning' + note = '任务已启动,等待首个心跳' + } else { + label = '归纳中' + tone = 'warning' + note = `最后心跳在 ${heartbeat.text}` + } + } + } + + if (!note && status === 'failed' && run?.error_message) { + note = String(run.error_message).trim() + } + + return { + label, + tone, + note, + phase, + phaseLabel: resolveAgentRunPhaseLabel(run), + heartbeat, + isSuspicious + } +} + +export function formatAgentRunProgress(run) { + const progress = run?.route_json?.progress || {} + const percent = Number(progress.percent || 0) + const completed = Number(progress.completed_documents || 0) + const total = Number(progress.total_documents || 0) + const failed = Number(progress.failed_documents || 0) + const stage = String(progress.current_stage || '').trim() + const stageLabelMap = { + document_started: '文档启动', + text_extracted: '文本已提取', + candidate_chunks_selected: '已筛正文', + extracting_candidates: '候选提炼中', + candidate_extraction_completed: '候选提炼完成', + document_completed: '文档完成', + skipped: '跳过' + } + const stageLabel = stageLabelMap[stage] || stage + + if (total > 0) { + const parts = [`${percent}%`, `${completed}/${total} 文档`] + if (failed > 0) { + parts.push(`失败 ${failed}`) + } + if (stageLabel) { + parts.push(stageLabel) + } + return parts.join(' · ') + } + + if (stageLabel) { + return `${percent}% · ${stageLabel}` + } + + return `${percent}%` +} diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index 2cd7216..05eb3ce 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -1,707 +1,1125 @@ - + + + + diff --git a/web/src/views/LogDetailView.vue b/web/src/views/LogDetailView.vue index 9811454..1988a9e 100644 --- a/web/src/views/LogDetailView.vue +++ b/web/src/views/LogDetailView.vue @@ -15,43 +15,56 @@ - +watch( + () => [route.params.logKind, route.params.logId], + () => { + void loadDetail() + } +) + +onMounted(() => { + void loadDetail() +}) + +onBeforeUnmount(() => { + stopPolling() +}) + diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue index 75d3812..ba48e58 100644 --- a/web/src/views/LogsView.vue +++ b/web/src/views/LogsView.vue @@ -113,12 +113,16 @@ {{ resolveRunTitle(run) }} {{ formatSummary(run.result_summary) }} + {{ resolveRunSummaryMeta(run) }} {{ run.run_id }} - - {{ resolveStatusLabel(run.status) }} - +
+ + {{ resolveStatusLabel(run) }} + + {{ resolveRunStatusNote(run) }} +
@@ -225,7 +229,7 @@

日志趋势

-

近 8 个小时的 Hermes 运行量与失败量

+

近 8 个小时的 Hermes 启动量与失败量,不代表实时心跳

({ + id: `${PREVIEW_RULE_ID}-version-${index + 1}`, + asset_id: PREVIEW_RULE_ID, + version: spec.version, + content: buildPreviewSpreadsheetVersionMarkdown(spec), + content_type: 'markdown', + change_note: spec.note, + created_by: spec.updatedBy, + created_at: spec.updatedAt, + is_current: spec.isCurrent + })) + const currentSpec = PREVIEW_RULE_VERSION_SPECS[0] + const currentMeta = buildPreviewSpreadsheetMeta(currentSpec) + + return { + id: PREVIEW_RULE_ID, + asset_type: 'rule', + code: PREVIEW_RULE_CODE, + name: '公司差旅费报销规则', + description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。', + domain: 'expense', + scenario_json: ['expense', 'travel_policy', 'travel_standard'], + owner: '财务制度管理组', + reviewer: '顾承宇', + status: 'active', + current_version: currentSpec.version, + published_version: currentSpec.version, + working_version: currentSpec.version, + config_json: { + severity: 'medium', + enabled: true, + tag: '财务规则', + detail_mode: 'spreadsheet', + runtime_kind: 'travel_policy', + rule_template_label: '差旅报销 Excel 模板', + rule_document: { + ...currentMeta, + asset_version: currentSpec.version + } + }, + created_at: '2026-05-10T11:10:00Z', + updated_at: currentSpec.updatedAt, + current_version_content: recentVersions[0].content, + current_version_content_type: 'markdown', + current_version_change_note: currentSpec.note, + recent_versions: recentVersions, + latest_review: { + id: `${PREVIEW_RULE_ID}-review-1`, + asset_id: PREVIEW_RULE_ID, + version: currentSpec.version, + reviewer: '顾承宇', + review_status: 'approved', + review_note: '当前为页面预览态,先确认布局与交互位置。', + reviewed_at: '2026-05-17T10:00:00Z', + created_at: '2026-05-17T10:00:00Z' + } + } +} + +function buildPreviewRuleListItem() { + const payload = createPreviewRuleDetailPayload() + return { + ...buildListItem(payload), + isPreviewMock: true + } +} + +function buildPreviewRuleDetail() { + const detail = buildDetailViewModel(createPreviewRuleDetailPayload(), []) + return { + ...detail, + isPreviewMock: true + } +} + function normalizeText(value) { return String(value || '').trim() } @@ -246,6 +447,65 @@ function readConfigJson(value) { return {} } +function readRuleDocumentMeta(value) { + const configJson = readConfigJson(value) + return isPlainObject(configJson.rule_document) ? configJson.rule_document : null +} + +function isSpreadsheetRuleSource(value) { + const configJson = readConfigJson(value) + return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE +} + +function normalizeRuleTagValue(value) { + return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '') +} + +function collectRuleTagValues(source) { + const configJson = readConfigJson(source) + const rawValues = [ + configJson.tag, + configJson.rule_tag, + ...(Array.isArray(configJson.tags) ? configJson.tags : []), + ...(Array.isArray(configJson.rule_tags) ? configJson.rule_tags : []) + ] + + return rawValues.map((item) => normalizeText(item)).filter(Boolean) +} + +function resolveRuleTabId(source) { + const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item)) + + if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) { + return 'riskRules' + } + if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.financialRules.has(item))) { + return 'financialRules' + } + return '' +} + +function resolveTabId(source, typeKey) { + if (typeKey === 'rules') { + return resolveRuleTabId(source) + } + return typeKey +} + +function resolveTabMeta(tabId, typeKey) { + if (TAB_META[tabId]) { + return TAB_META[tabId] + } + if (typeKey === 'rules') { + return { + ...TYPE_META.rules, + typeKey: 'rules', + badgeTone: 'emerald' + } + } + return TAB_META[typeKey] +} + function cloneJsonObject(value) { if (!isPlainObject(value)) { return null @@ -276,6 +536,20 @@ function extractRuntimeRuleFromMarkdown(markdown) { } } +function extractSpreadsheetMetaFromMarkdown(markdown) { + const match = String(markdown || '').match(RULE_SPREADSHEET_BLOCK_PATTERN) + if (!match) { + return null + } + + try { + const payload = JSON.parse(match[1]) + return isPlainObject(payload) ? payload : null + } catch { + return null + } +} + function stripRuntimeRuleBlock(markdown) { const text = String(markdown || '') const stripped = text.replace(EXPENSE_RULE_BLOCK_PATTERN, '').replace(/\n{3,}/g, '\n\n').trim() @@ -381,6 +655,25 @@ function resolveReviewMeta(value) { return REVIEW_META[value] || { label: '暂无审核', tone: 'draft' } } +function resolveTimelineEventMeta(value) { + return { + created: { label: '创建工作稿', icon: 'mdi mdi-file-document-edit-outline', tone: 'draft' }, + submitted: { label: '提交审核', icon: 'mdi mdi-send-outline', tone: 'warning' }, + approved: { label: '审核通过', icon: 'mdi mdi-check-decagram-outline', tone: 'success' }, + rejected: { label: '审核驳回', icon: 'mdi mdi-close-octagon-outline', tone: 'danger' }, + published: { label: '正式上线', icon: 'mdi mdi-rocket-launch-outline', tone: 'success' }, + restored: { label: '恢复生成工作稿', icon: 'mdi mdi-history', tone: 'info' } + }[normalizeText(value)] || { label: normalizeText(value) || '版本事件', icon: 'mdi mdi-circle-medium', tone: 'draft' } +} + +function resolveDiffChangeMeta(value) { + return { + added: { label: '新增', tone: 'success' }, + removed: { label: '删除', tone: 'danger' }, + modified: { label: '修改', tone: 'warning' } + }[normalizeText(value)] || { label: normalizeText(value) || '变化', tone: 'draft' } +} + function formatScenarioList(items) { if (!Array.isArray(items) || !items.length) { return '未配置场景' @@ -408,9 +701,14 @@ function buildHistory(recentVersions = [], source) { rawContent, item.is_current ? currentRuntimeRule : null ), + spreadsheetMeta: extractSpreadsheetMetaFromMarkdown(rawContent), contentType: item.content_type, createdBy: item.created_by, - isCurrent: Boolean(item.is_current) + isCurrent: Boolean(item.is_current), + isPublished: Boolean(item.is_published), + isWorking: Boolean(item.is_working), + lifecycleState: item.lifecycle_state || 'history', + lifecycleMeta: VERSION_STATE_META[item.lifecycle_state] || VERSION_STATE_META.history } }) } @@ -523,12 +821,20 @@ function buildRowMetric(asset, typeKey) { function buildListItem(asset) { const typeKey = resolveTypeKey(asset.asset_type) + const tabId = resolveTabId(asset, typeKey) + if (!tabId) { + return null + } + + const tabMeta = resolveTabMeta(tabId, typeKey) const statusMeta = resolveStatusMeta(asset.status) return { id: asset.id, + tabId, type: typeKey, - typeLabel: TYPE_META[typeKey].typeLabel, + isPreviewMock: Boolean(asset.isPreviewMock), + typeLabel: tabMeta.typeLabel, short: makeShort(asset.name), name: asset.name, code: asset.code, @@ -538,21 +844,36 @@ function buildListItem(asset) { reviewer: asset.reviewer || '待分配', scope: formatScenarioList(asset.scenario_json), model: buildRowRuntime(asset, typeKey), - version: asset.current_version || '-', + version: asset.working_version || asset.current_version || '-', + publishedVersion: asset.published_version || '-', + workingVersion: asset.working_version || asset.current_version || '-', status: statusMeta.label, statusValue: asset.status, statusTone: statusMeta.tone, hitRate: buildRowMetric(asset, typeKey), updatedAt: formatDateTime(asset.updated_at), - badgeTone: BADGE_TONES[typeKey], + badgeTone: tabMeta.badgeTone, spotlight: asset.status === 'active', domainValue: asset.domain } } function buildRuleFields(detail) { + const ruleDocument = readRuleDocumentMeta(detail) return [ { label: '规则编码', value: detail.code }, + { + label: '明细载体', + value: isSpreadsheetRuleSource(detail) ? 'Excel 表格' : 'Markdown / JSON' + }, + ...(ruleDocument + ? [ + { + label: '关联文件', + value: normalizeText(ruleDocument.file_name) || '未上传' + } + ] + : []), { label: '模板键', value: normalizeText(detail.config_json?.rule_template_key) || '未指定' @@ -563,7 +884,8 @@ function buildRuleFields(detail) { value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft' }, { label: '适用场景', value: formatScenarioList(detail.scenario_json) }, - { label: '当前版本', value: detail.current_version || '-' } + { label: '线上版本', value: detail.published_version || '-' }, + { label: '工作版本', value: detail.working_version || detail.current_version || '-' } ] } @@ -692,6 +1014,14 @@ function buildOutputRules(detail, typeKey, latestRun, latestCall) { const content = detail.current_version_content || {} if (typeKey === 'rules') { + if (isSpreadsheetRuleSource(detail)) { + return [ + '规则详情页以内联 Excel 表格作为主载体,管理员可直接编辑当前版本。', + '上传新的 Excel 文件后,会自动生成新的规则版本快照。', + '切换到历史版本时仅支持预览,不允许直接覆盖历史版本。', + '规则表发生变更后,仍需完成审核才能再次正式上线。' + ] + } return [ '规则使用固定模板落 Markdown,并配套维护 runtime_rule JSON。', '保存 Markdown 或 JSON 都会生成新版本快照。', @@ -856,8 +1186,11 @@ function buildTools(detail, typeKey, latestRun, latestCall) { function buildPublishDescription(detail, typeKey) { if (typeKey === 'rules') { + if (detail.published_version && detail.working_version && detail.published_version !== detail.working_version) { + return '当前存在尚未上线的工作版本,系统运行仍以线上版本为准。' + } if (detail.status === 'active') { - return '当前规则版本已经上线,仍可继续保存新版本并重新走审核。' + return '当前规则线上版本已生效,仍可继续保存新的工作版本并重新走审核。' } return '当前规则需要先完成审核,再调用上线接口正式激活。' } @@ -867,13 +1200,17 @@ function buildPublishDescription(detail, typeKey) { function buildDetailViewModel(detail, runs) { const typeKey = resolveTypeKey(detail.asset_type) + const tabId = resolveTabId(detail, typeKey) || typeKey + const tabMeta = resolveTabMeta(tabId, typeKey) const latestRun = typeKey === 'tasks' ? findLatestTaskRun(runs, detail.id) : null const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null const configJson = readConfigJson(detail) const statusMeta = resolveStatusMeta(detail.status) const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status) const history = buildHistory(detail.recent_versions || [], detail) - const previewVersion = history.find((item) => item.isCurrent) || history[0] || null + const previewVersion = history.find((item) => item.isWorking) || history[0] || null + const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(detail) + const ruleDocument = readRuleDocumentMeta(detail) const previewRawMarkdown = detail.current_version_content_type === 'markdown' ? String(previewVersion?.content ?? detail.current_version_content ?? '') @@ -892,8 +1229,9 @@ function buildDetailViewModel(detail, runs) { return { id: detail.id, + tabId, type: typeKey, - typeLabel: TYPE_META[typeKey].typeLabel, + typeLabel: tabMeta.typeLabel, short: makeShort(detail.name), name: detail.name, code: detail.code, @@ -902,16 +1240,20 @@ function buildDetailViewModel(detail, runs) { reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配', category: resolveDomainLabel(detail.domain), scope: formatScenarioList(detail.scenario_json), - version: detail.current_version || '-', + version: detail.working_version || detail.current_version || '-', currentVersion: detail.current_version || '-', - displayVersion: previewVersion?.version || detail.current_version || '-', + publishedVersion: detail.published_version || '-', + workingVersion: detail.working_version || detail.current_version || '-', + displayVersion: previewVersion?.version || detail.working_version || detail.current_version || '-', status: statusMeta.label, statusValue: detail.status, statusTone: statusMeta.tone, hitRate: buildRowMetric(detail, typeKey), updatedAt: formatDateTime(detail.updated_at), - badgeTone: BADGE_TONES[typeKey], + badgeTone: tabMeta.badgeTone, configJson, + usesSpreadsheetRule, + ruleDocument, scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [], markdownContent: previewMarkdown, runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule), @@ -944,8 +1286,14 @@ function buildDetailViewModel(detail, runs) { tone: reviewMeta.tone }, { - name: detail.current_version || '暂无版本', - scope: '当前版本', + name: detail.published_version || '暂无版本', + scope: '线上版本', + mode: '正式生效', + tone: 'safe' + }, + { + name: detail.working_version || detail.current_version || '暂无版本', + scope: '工作版本', mode: detail.current_version_change_note || '无版本说明', tone: 'safe' } @@ -1015,12 +1363,12 @@ export default { const { toast } = useToast() const { currentUser } = useSystemState() - const tabs = Object.entries(TYPE_META).map(([id, meta]) => ({ + const tabs = Object.entries(TAB_META).map(([id, meta]) => ({ id, label: meta.label })) - const activeType = ref('rules') + const activeType = ref('financialRules') const selectedSkill = ref(null) const versionSwitchTarget = ref(null) const keyword = ref('') @@ -1035,15 +1383,33 @@ export default { const actionState = ref('') const runLoading = ref(false) const runs = ref([]) + const spreadsheetUploadInput = ref(null) + const spreadsheetOnlyOfficeLoading = ref(false) + const spreadsheetOnlyOfficeError = ref('') + const spreadsheetOnlyOfficeEditor = ref(null) + const spreadsheetOnlyOfficeReady = ref(false) + const spreadsheetOnlyOfficeHostId = ref('audit-rule-onlyoffice') + const versionTimelineOpen = ref(false) + const versionTimelineLoading = ref(false) + const versionTimelineError = ref('') + const versionTimelineItems = ref([]) + const versionCompareOpen = ref(false) + const versionCompareLoading = ref(false) + const versionCompareError = ref('') + const versionComparePayload = ref(null) + const compareBaseVersion = ref('') + const compareTargetVersion = ref('') const assetBuckets = ref({ - rules: [], + financialRules: [], + riskRules: [], skills: [], mcp: [], tasks: [] }) const isAdmin = computed(() => isManagerUser(currentUser.value)) - const activeMeta = computed(() => TYPE_META[activeType.value]) + const isFinance = computed(() => isFinanceUser(currentUser.value)) + const activeMeta = computed(() => TAB_META[activeType.value]) const activeTabLabel = computed(() => activeMeta.value.label) const currentAssets = computed(() => assetBuckets.value[activeType.value] || []) const searchPlaceholder = computed(() => activeMeta.value.searchPlaceholder) @@ -1052,8 +1418,91 @@ export default { const tableColumns = computed(() => activeMeta.value.tableColumns) const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false) const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules') - const canManageSelected = computed(() => isAdmin.value && Boolean(selectedSkill.value)) - const canEditMarkdown = computed(() => canManageSelected.value && selectedSkillIsRule.value) + const selectedSkillUsesSpreadsheet = computed( + () => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule) + ) + const canManageSelected = computed( + () => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock + ) + const canEditSelected = computed( + () => + Boolean(selectedSkill.value) && + !selectedSkill.value?.isPreviewMock && + (isAdmin.value || isFinance.value) + ) + const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value) + const isDisplayingWorkingVersion = computed( + () => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion + ) + const canSubmitReview = computed( + () => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value + ) + const canReviewSelected = computed( + () => canManageSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value + ) + const canUploadSpreadsheet = computed( + () => + canEditSelected.value && + selectedSkillUsesSpreadsheet.value && + !detailBusy.value && + selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion + ) + const canDownloadSpreadsheet = computed( + () => + selectedSkillUsesSpreadsheet.value && + Boolean(selectedSkill.value?.id) && + !detailBusy.value + ) + const canEditSpreadsheetInline = computed( + () => + selectedSkillUsesSpreadsheet.value && + selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion && + (selectedSkill.value?.isPreviewMock || canEditSelected.value) + ) + const selectedDisplayHistory = computed( + () => + selectedSkill.value?.history?.find((item) => item.version === selectedSkill.value?.displayVersion) || null + ) + const selectedSpreadsheetFileName = computed( + () => + normalizeText( + selectedDisplayHistory.value?.spreadsheetMeta?.file_name || selectedSkill.value?.ruleDocument?.file_name + ) || '未上传规则表' + ) + const selectedSpreadsheetVersionModeLabel = computed(() => { + if (selectedSkill.value?.isPreviewMock) { + return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑预览' : 'ONLYOFFICE 历史预览' + } + return selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion + ? '当前工作版本' + : '历史预览版本' + }) + const selectedVersionTimelineItems = computed(() => + versionTimelineItems.value.map((item) => ({ + ...item, + meta: resolveTimelineEventMeta(item.event_type), + timeLabel: formatDateTime(item.event_time) + })) + ) + const recentVersionTimelineItems = computed(() => + [...selectedVersionTimelineItems.value].slice(-3).reverse() + ) + const versionCompareCellRows = computed(() => + Array.isArray(versionComparePayload.value?.cell_changes) + ? versionComparePayload.value.cell_changes.map((item) => ({ + ...item, + meta: resolveDiffChangeMeta(item.change_type) + })) + : [] + ) + const versionCompareSheetRows = computed(() => + Array.isArray(versionComparePayload.value?.sheet_changes) + ? versionComparePayload.value.sheet_changes.map((item) => ({ + ...item, + meta: resolveDiffChangeMeta(item.change_type) + })) + : [] + ) const detailBusy = computed(() => Boolean(actionState.value)) const showReviewNote = computed( () => selectedSkillIsRule.value && (selectedSkill.value?.reviewNote || selectedSkill.value?.reviewTimeLabel) @@ -1114,8 +1563,8 @@ export default { title: `${activeTabLabel.value}列表暂时还是空的`, desc: `当前环境里还没有可展示的${activeTabLabel.value}资产。完成接入或同步后,会统一展示在这里。`, icon: 'mdi mdi-database-search-outline', - actionLabel: '重新加载', - actionIcon: 'mdi mdi-refresh', + actionLabel: '', + actionIcon: '', tone: 'amber', artLabel: 'ASSET', tips: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤'] @@ -1129,8 +1578,8 @@ export default { ? '试试清空业务域、负责人、状态或关键词筛选,再重新查看。' : `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`, icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline', - actionLabel: hasFilters ? '清空筛选' : '重新加载', - actionIcon: hasFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-refresh', + actionLabel: hasFilters ? '清空筛选' : '', + actionIcon: hasFilters ? 'mdi mdi-filter-remove-outline' : '', tone: hasFilters ? 'emerald' : 'slate', artLabel: hasFilters ? 'FILTER' : 'QUEUE', tips: hasFilters @@ -1143,17 +1592,27 @@ export default { return false } - return selectedSkill.value?.reviewStatusValue === 'approved' && selectedSkill.value?.statusValue !== 'active' + return ( + isDisplayingWorkingVersion.value && + selectedSkill.value?.reviewStatusValue === 'approved' && + selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion + ) }) const activateBlockedReason = computed(() => { if (!selectedSkillIsRule.value) { return '' } - if (!canManageSelected.value) { - return '仅管理员可执行审核和上线。' + if (selectedSkill.value?.isPreviewMock) { + return '当前为页面预览态,暂不执行真实审核和上线。' } - if (selectedSkill.value?.statusValue === 'active') { - return '当前规则版本已经上线。' + if (!canManageSelected.value) { + return '仅高级管理人员可执行审核和上线。' + } + if (!isDisplayingWorkingVersion.value) { + return '请先切回当前工作版本,再执行审核或上线。' + } + if (selectedSkill.value?.workingVersion === selectedSkill.value?.publishedVersion) { + return '当前工作版本已经是线上版本。' } if (selectedSkill.value?.reviewStatusValue !== 'approved') { return '当前规则版本未审核通过,不能上线。' @@ -1185,7 +1644,26 @@ export default { { immediate: true } ) + watch( + () => [ + selectedSkill.value?.id || '', + selectedSkill.value?.displayVersion || '', + selectedSkill.value?.loading ? '1' : '0', + selectedSkill.value?.usesSpreadsheetRule ? '1' : '0' + ], + async () => { + if (!selectedSkillUsesSpreadsheet.value || selectedSkill.value?.loading) { + destroySpreadsheetOnlyOfficeEditor() + spreadsheetOnlyOfficeError.value = '' + spreadsheetOnlyOfficeLoading.value = false + return + } + await mountSpreadsheetOnlyOfficeEditor() + } + ) + watch(activeType, () => { + destroySpreadsheetOnlyOfficeEditor() selectedSkill.value = null versionSwitchTarget.value = null resetFilters() @@ -1271,6 +1749,132 @@ export default { ) } + function destroySpreadsheetOnlyOfficeEditor() { + if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) { + spreadsheetOnlyOfficeEditor.value.destroyEditor() + } + spreadsheetOnlyOfficeEditor.value = null + spreadsheetOnlyOfficeReady.value = false + } + + async function mountSpreadsheetOnlyOfficeEditor() { + if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id || selectedSkill.value?.loading) { + destroySpreadsheetOnlyOfficeEditor() + return + } + + spreadsheetOnlyOfficeLoading.value = true + spreadsheetOnlyOfficeError.value = '' + spreadsheetOnlyOfficeReady.value = false + destroySpreadsheetOnlyOfficeEditor() + + try { + const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig( + selectedSkill.value.id, + selectedSkill.value.displayVersion + ) + await loadOnlyOfficeApi(payload.documentServerUrl) + if (!window.DocsAPI?.DocEditor) { + throw new Error('ONLYOFFICE 编辑器未正确加载。') + } + + spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${selectedSkill.value.id}-${selectedSkill.value.displayVersion}` + await nextTick() + const config = buildOnlyOfficeEditorConfig(payload.config, { + viewportHeight: window.innerHeight, + editable: canEditSpreadsheetInline.value, + fillContainer: true + }) + const upstreamEvents = config.events || {} + config.events = { + ...upstreamEvents, + onAppReady(event) { + spreadsheetOnlyOfficeReady.value = true + spreadsheetOnlyOfficeLoading.value = false + upstreamEvents.onAppReady?.(event) + }, + onError(event) { + const errorCode = event?.data?.errorCode + const errorDescription = event?.data?.errorDescription + spreadsheetOnlyOfficeError.value = errorDescription + ? `ONLYOFFICE 加载失败:${errorDescription}` + : `ONLYOFFICE 加载失败${errorCode ? `(错误码 ${errorCode})` : '。'}` + spreadsheetOnlyOfficeLoading.value = false + upstreamEvents.onError?.(event) + } + } + spreadsheetOnlyOfficeEditor.value = new window.DocsAPI.DocEditor( + spreadsheetOnlyOfficeHostId.value, + config + ) + } catch (error) { + spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。' + spreadsheetOnlyOfficeLoading.value = false + toast(spreadsheetOnlyOfficeError.value) + } + } + + function triggerSpreadsheetUpload() { + if (!canUploadSpreadsheet.value) { + return + } + spreadsheetUploadInput.value?.click() + } + + async function downloadSpreadsheetFile() { + if (!canDownloadSpreadsheet.value || !selectedSkill.value?.id) { + return + } + + actionState.value = 'download-spreadsheet' + try { + const blob = await fetchAgentAssetSpreadsheetBlob( + selectedSkill.value.id, + selectedSkill.value.displayVersion, + 'attachment' + ) + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = objectUrl + anchor.download = selectedSpreadsheetFileName.value || '规则表.xlsx' + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(objectUrl) + } catch (error) { + toast(error?.message || '规则表下载失败,请稍后重试。') + } finally { + actionState.value = '' + } + } + + async function uploadSpreadsheetFile(file) { + if (!file || !selectedSkill.value?.id || !canUploadSpreadsheet.value) { + return + } + + actionState.value = 'upload-spreadsheet' + try { + await importAgentAssetSpreadsheetContent(selectedSkill.value.id, file, { + actor: resolveActor() + }) + await refreshCurrentAssets() + await loadSelectedAssetDetail(selectedSkill.value.id) + toast(`已导入 ${file.name} 的表格内容,并生成新版本。`) + } catch (error) { + toast(error?.message || '规则表内容导入失败,请稍后重试。') + } finally { + actionState.value = '' + if (spreadsheetUploadInput.value) { + spreadsheetUploadInput.value.value = '' + } + } + } + + async function handleSpreadsheetFileInput(event) { + await uploadSpreadsheetFile(event?.target?.files?.[0] || null) + } + async function loadRuns(options = {}) { if (runLoading.value && !options.force) { return @@ -1291,16 +1895,46 @@ export default { try { const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType }) - assetBuckets.value = { - ...assetBuckets.value, - [activeType.value]: Array.isArray(payload) ? payload.map(buildListItem) : [] + const items = Array.isArray(payload) ? payload.map(buildListItem).filter(Boolean) : [] + + if (activeMeta.value.assetType === 'rule') { + const nextBuckets = { + financialRules: [], + riskRules: [] + } + + items.forEach((item) => { + if (item?.tabId === 'financialRules' || item?.tabId === 'riskRules') { + nextBuckets[item.tabId].push(item) + } + }) + + assetBuckets.value = { + ...assetBuckets.value, + ...nextBuckets + } + } else { + assetBuckets.value = { + ...assetBuckets.value, + [activeType.value]: items + } } } catch (error) { - assetBuckets.value = { - ...assetBuckets.value, - [activeType.value]: [] + if (activeMeta.value.assetType === 'rule') { + assetBuckets.value = { + ...assetBuckets.value, + financialRules: + activeType.value === 'financialRules' ? [] : assetBuckets.value.financialRules, + riskRules: [] + } + errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。' + } else { + assetBuckets.value = { + ...assetBuckets.value, + [activeType.value]: [] + } + errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。' } - errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。' if (!options.silent) { toast(errorMessage.value) } @@ -1323,6 +1957,9 @@ export default { } const detail = await fetchAgentAssetDetail(assetId) selectedSkill.value = buildDetailViewModel(detail, runs.value) + if (selectedSkill.value?.type === 'rules') { + loadVersionTimeline(assetId, { silent: true }).catch(() => {}) + } } catch (error) { detailError.value = error?.message || '资产详情加载失败,请稍后重试。' toast(detailError.value) @@ -1332,9 +1969,22 @@ export default { } function openAssetDetail(asset) { + destroySpreadsheetOnlyOfficeEditor() + spreadsheetOnlyOfficeError.value = '' + spreadsheetOnlyOfficeLoading.value = false + if (asset?.isPreviewMock) { + selectedSkill.value = buildPreviewRuleDetail() + detailError.value = '' + detailLoading.value = false + versionSwitchTarget.value = null + return + } selectedSkill.value = { ...asset, configJson: {}, + isPreviewMock: false, + usesSpreadsheetRule: false, + ruleDocument: null, scenarioList: [], fields: [], promptSections: [], @@ -1359,10 +2009,17 @@ export default { } function closeDetail() { + destroySpreadsheetOnlyOfficeEditor() + spreadsheetOnlyOfficeError.value = '' + spreadsheetOnlyOfficeLoading.value = false selectedSkill.value = null detailError.value = '' detailLoading.value = false versionSwitchTarget.value = null + versionTimelineOpen.value = false + versionCompareOpen.value = false + versionTimelineItems.value = [] + versionComparePayload.value = null } function openVersionSwitch(version) { @@ -1383,6 +2040,10 @@ export default { selectedSkill.value.displayVersion = versionSwitchTarget.value.version selectedSkill.value.displayVersionChangeNote = versionSwitchTarget.value.note || '无版本说明' + if (selectedSkill.value.usesSpreadsheetRule) { + versionSwitchTarget.value = null + return + } if (typeof versionSwitchTarget.value.markdownContent === 'string') { selectedSkill.value.markdownContent = versionSwitchTarget.value.markdownContent } @@ -1397,7 +2058,13 @@ export default { } async function saveRuleMarkdown() { - if (!selectedSkill.value || !selectedSkillIsRule.value || !canEditMarkdown.value || detailBusy.value) { + if ( + !selectedSkill.value || + !selectedSkillIsRule.value || + selectedSkill.value.usesSpreadsheetRule || + !canEditMarkdown.value || + detailBusy.value + ) { return } @@ -1439,7 +2106,13 @@ export default { } async function saveRuleRuntimeJson() { - if (!selectedSkill.value || !selectedSkillIsRule.value || !canEditMarkdown.value || detailBusy.value) { + if ( + !selectedSkill.value || + !selectedSkillIsRule.value || + selectedSkill.value.usesSpreadsheetRule || + !canEditMarkdown.value || + detailBusy.value + ) { return } @@ -1481,7 +2154,13 @@ export default { } async function reviewSelectedRule(reviewStatus) { - if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) { + if (!selectedSkill.value || !selectedSkillIsRule.value || detailBusy.value) { + return + } + if (reviewStatus === 'pending' && !canSubmitReview.value) { + return + } + if (reviewStatus !== 'pending' && !canReviewSelected.value) { return } @@ -1491,7 +2170,7 @@ export default { await createAgentAssetReview( selectedSkill.value.id, { - version: selectedSkill.value.displayVersion || selectedSkill.value.currentVersion, + version: selectedSkill.value.workingVersion, reviewer: resolveActor(), review_status: reviewStatus, review_note: buildReviewNote(reviewStatus) @@ -1527,6 +2206,105 @@ export default { } } + async function restoreSelectedVersion(version) { + if ( + !selectedSkill.value || + !selectedSkillIsRule.value || + !canManageSelected.value || + detailBusy.value || + !version + ) { + return + } + + actionState.value = `restore-${version}` + try { + await restoreAgentAssetVersion(selectedSkill.value.id, version, { actor: resolveActor() }) + await refreshCurrentAssets() + await loadSelectedAssetDetail(selectedSkill.value.id) + toast(`已基于 ${version} 生成新的工作版本。`) + } catch (error) { + toast(error?.message || '历史版本恢复失败,请稍后重试。') + } finally { + actionState.value = '' + } + } + + async function loadVersionTimeline(assetId = selectedSkill.value?.id, options = {}) { + if (!assetId) { + return + } + + versionTimelineLoading.value = true + versionTimelineError.value = '' + try { + const payload = await fetchAgentAssetVersionTimeline(assetId) + versionTimelineItems.value = Array.isArray(payload) ? payload : [] + } catch (error) { + versionTimelineError.value = error?.message || '版本流转加载失败,请稍后重试。' + if (!options.silent) { + toast(versionTimelineError.value) + } + } finally { + versionTimelineLoading.value = false + } + } + + async function openVersionTimeline() { + if (!selectedSkill.value?.id) { + return + } + versionTimelineOpen.value = true + await loadVersionTimeline(selectedSkill.value.id) + } + + function closeVersionTimeline() { + versionTimelineOpen.value = false + } + + async function openVersionCompare(options = {}) { + if (!selectedSkill.value?.id) { + return + } + const defaultBase = + options.baseVersion || selectedSkill.value.publishedVersion || selectedSkill.value.workingVersion || '' + let defaultTarget = + options.targetVersion || selectedSkill.value.workingVersion || selectedSkill.value.publishedVersion || '' + if (!options.targetVersion && defaultBase === defaultTarget) { + defaultTarget = + selectedSkill.value.history.find((item) => item.version !== defaultBase)?.version || defaultTarget + } + compareBaseVersion.value = defaultBase + compareTargetVersion.value = defaultTarget + versionCompareOpen.value = true + await loadVersionCompare() + } + + function closeVersionCompare() { + versionCompareOpen.value = false + } + + async function loadVersionCompare() { + if (!selectedSkill.value?.id || !compareBaseVersion.value || !compareTargetVersion.value) { + return + } + + versionCompareLoading.value = true + versionCompareError.value = '' + try { + versionComparePayload.value = await compareAgentAssetSpreadsheetVersions( + selectedSkill.value.id, + compareBaseVersion.value, + compareTargetVersion.value + ) + } catch (error) { + versionComparePayload.value = null + versionCompareError.value = error?.message || '版本差异对比失败,请稍后重试。' + } finally { + versionCompareLoading.value = false + } + } + onMounted(() => { document.addEventListener('click', handleDocumentClick) loadAssets({ force: true }).catch(() => {}) @@ -1534,6 +2312,7 @@ export default { }) onBeforeUnmount(() => { + destroySpreadsheetOnlyOfficeEditor() document.removeEventListener('click', handleDocumentClick) }) @@ -1567,13 +2346,40 @@ export default { activeFilterPopover, activeFilterTokens, canManageSelected, + canEditSelected, + canSubmitReview, + canReviewSelected, canEditMarkdown, + canUploadSpreadsheet, + canDownloadSpreadsheet, + canEditSpreadsheetInline, canActivateSelected, activateBlockedReason, selectedSkillIsRule, + selectedSkillUsesSpreadsheet, + selectedSpreadsheetFileName, + selectedSpreadsheetVersionModeLabel, + selectedVersionTimelineItems, + recentVersionTimelineItems, detailBusy, actionState, showReviewNote, + spreadsheetUploadInput, + spreadsheetOnlyOfficeLoading, + spreadsheetOnlyOfficeError, + spreadsheetOnlyOfficeReady, + spreadsheetOnlyOfficeHostId, + versionTimelineOpen, + versionTimelineLoading, + versionTimelineError, + versionCompareOpen, + versionCompareLoading, + versionCompareError, + versionComparePayload, + versionCompareCellRows, + versionCompareSheetRows, + compareBaseVersion, + compareTargetVersion, openAssetDetail, closeDetail, resetFilters, @@ -1586,8 +2392,17 @@ export default { confirmVersionSwitch, saveRuleMarkdown, saveRuleRuntimeJson, + triggerSpreadsheetUpload, + downloadSpreadsheetFile, + handleSpreadsheetFileInput, reviewSelectedRule, activateSelectedRule, + restoreSelectedVersion, + openVersionTimeline, + closeVersionTimeline, + openVersionCompare, + closeVersionCompare, + loadVersionCompare, loadAssets } } diff --git a/web/src/views/scripts/LogsView.js b/web/src/views/scripts/LogsView.js index 60d6312..dea4ea2 100644 --- a/web/src/views/scripts/LogsView.js +++ b/web/src/views/scripts/LogsView.js @@ -1,15 +1,20 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRouter } from 'vue-router' -import LogTrendChart from '../../components/charts/LogTrendChart.vue' -import DonutChart from '../../components/charts/DonutChart.vue' -import { fetchAgentRuns } from '../../services/agentAssets.js' -import { fetchSystemLogEntries } from '../../services/systemLogs.js' -import { useSystemState } from '../../composables/useSystemState.js' -import { useToast } from '../../composables/useToast.js' -import { isManagerUser } from '../../utils/accessControl.js' - -const POLL_INTERVAL_MS = 5000 +import LogTrendChart from '../../components/charts/LogTrendChart.vue' +import DonutChart from '../../components/charts/DonutChart.vue' +import { fetchAgentRuns } from '../../services/agentAssets.js' +import { fetchSystemLogEntries } from '../../services/systemLogs.js' +import { useSystemState } from '../../composables/useSystemState.js' +import { useToast } from '../../composables/useToast.js' +import { + AGENT_RUN_POLL_INTERVAL_MS, + formatAgentRunElapsed, + formatAgentRunProgress, + resolveAgentRunHeartbeat, + resolveAgentRunStatus +} from '../../utils/agentRunMonitor.js' +import { isManagerUser } from '../../utils/accessControl.js' const SOURCE_LABELS = { schedule: '定时任务', @@ -30,37 +35,13 @@ function formatDateTime(value) { return date.toLocaleString('zh-CN', { hour12: false }) } -function resolveStatusLabel(status) { - if (status === 'running') { - return '运行中' - } - if (status === 'succeeded') { - return '已完成' - } - if (status === 'failed') { - return '失败' - } - if (status === 'blocked') { - return '待确认' - } - return status || '未知' -} - -function resolveStatusTone(status) { - if (status === 'running') { - return 'warning' - } - if (status === 'succeeded') { - return 'success' - } - if (status === 'failed') { - return 'danger' - } - if (status === 'blocked') { - return 'muted' - } - return 'muted' -} +function resolveStatusLabel(run) { + return resolveAgentRunStatus(run).label +} + +function resolveStatusTone(run) { + return resolveAgentRunStatus(run).tone +} function resolveRunSourceLabel(source) { return SOURCE_LABELS[source] || source || '未标记' @@ -88,16 +69,20 @@ function resolveRunTitle(run) { return `Hermes 调用 · ${resolveRunModuleLabel(run)}` } -function resolveRunLevel(run) { - const progress = run?.route_json?.progress || {} - if (run?.status === 'failed' || run?.error_message) { - return 'ERROR' - } - if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) { - return 'WARN' - } - if (run?.status === 'running') { - return 'INFO' +function resolveRunLevel(run) { + const progress = run?.route_json?.progress || {} + const statusInfo = resolveAgentRunStatus(run) + if (run?.status === 'failed' || run?.error_message) { + return 'ERROR' + } + if (statusInfo.isSuspicious) { + return 'WARN' + } + if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) { + return 'WARN' + } + if (run?.status === 'running') { + return 'INFO' } return 'INFO' } @@ -115,7 +100,7 @@ function resolveLevelTone(level) { return 'muted' } -function formatSummary(summary) { +function formatSummary(summary) { const text = String(summary || '').trim() if (!text) { return '暂无摘要。' @@ -123,8 +108,39 @@ function formatSummary(summary) { if (text.length <= 64) { return text } - return `${text.slice(0, 64)}...` -} + return `${text.slice(0, 64)}...` +} + +function resolveRunSummaryMeta(run) { + const statusInfo = resolveAgentRunStatus(run) + const progressText = formatAgentRunProgress(run) + const elapsedLabel = run?.status === 'running' ? '已运行' : '耗时' + const elapsedText = formatAgentRunElapsed(run) + const parts = [`阶段 ${statusInfo.phaseLabel}`] + + if (progressText) { + parts.push(progressText) + } + if (elapsedText !== '—') { + parts.push(`${elapsedLabel} ${elapsedText}`) + } + + return parts.join(' · ') +} + +function resolveRunStatusNote(run) { + const statusInfo = resolveAgentRunStatus(run) + if (statusInfo.note) { + return statusInfo.note + } + + const heartbeat = resolveAgentRunHeartbeat(run) + if (heartbeat.at !== null) { + return `最后心跳 ${formatDateTime(heartbeat.at)}` + } + + return '暂无额外状态' +} function resolveSystemLevelTone(level) { if (level === 'ERROR' || level === 'CRITICAL') { @@ -368,9 +384,9 @@ export default { stopPolling() pollTimer = window.setInterval(() => { loadHermesRuns() - loadSystemLogs() - }, POLL_INTERVAL_MS) - } + loadSystemLogs() + }, AGENT_RUN_POLL_INTERVAL_MS) + } function stopPolling() { if (pollTimer) { @@ -442,11 +458,13 @@ export default { pageSizes, resolveLevelTone, resolveRunLevel, - resolveRunModuleLabel, - resolveRunSourceLabel, - resolveRunTitle, - resolveStatusLabel, - resolveStatusTone, + resolveRunModuleLabel, + resolveRunSourceLabel, + resolveRunStatusNote, + resolveRunSummaryMeta, + resolveRunTitle, + resolveStatusLabel, + resolveStatusTone, resolveSystemLevelTone, resolveSystemOutcomeTone, runningRunCount, diff --git a/web/src/views/scripts/onlyOfficePreviewConfig.js b/web/src/views/scripts/onlyOfficePreviewConfig.js index 8e79a3d..7273059 100644 --- a/web/src/views/scripts/onlyOfficePreviewConfig.js +++ b/web/src/views/scripts/onlyOfficePreviewConfig.js @@ -28,3 +28,28 @@ export function buildOnlyOfficePreviewConfig(config, options = {}) { height: `${clampHeight(viewportHeight)}px` } } + +export function buildOnlyOfficeEditorConfig(config, options = {}) { + const viewportHeight = options.viewportHeight + const editable = Boolean(options.editable) + const fillContainer = Boolean(options.fillContainer) + + return { + ...config, + type: editable ? 'desktop' : 'embedded', + editorConfig: { + ...(config.editorConfig || {}), + embedded: editable + ? undefined + : { + embedUrl: '', + fullscreenUrl: '', + saveUrl: '', + shareUrl: '', + toolbarDocked: 'top' + } + }, + width: '100%', + height: fillContainer ? '100%' : `${clampHeight(viewportHeight)}px` + } +}