Files
X-Financial/web/src/views/LogDetailView.vue
caoxiaozhu 8417a9f542 feat(web): 设置中心缓存管理与文件预览资产工具
- 新增 documentPreviewAssets 工具,统一从 URL/Blob/File 推断预览类型(image/pdf/file/unsupported)
- SettingsView/SettingsView.js/settingsModelHelper 新增系统缓存管理区块,调用 /settings/cache/clear 并展示清理结果;useSettings/services 适配
- WorkbenchAiFilePreviewDialog/useWorkbenchAiFilePreview 接入预览资产工具,workbenchAiComposerModel 调整文件处理
- ReceiptFolder/LogDetailView/DigitalEmployeeWorkRecords/travelReimbursementAttachmentModel 配套适配
- 新增 settings-cache-management-section 测试,更新 settings-llm/rendering/receipt-folder-view/composer-components/attachment-association 测试
2026-06-24 12:35:59 +08:00

461 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="log-detail-page">
<div class="detail-scroll">
<article v-if="loading" class="panel detail-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在加载日志详情</strong>
<p>系统正在读取当前记录的结构化信息</p>
</article>
<article v-else-if="error" class="panel detail-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>日志详情加载失败</strong>
<p>{{ error }}</p>
<button type="button" @click="loadDetail">重新加载</button>
</article>
<template v-else-if="isHermes && hermesRun">
<article class="detail-hero panel">
<div class="hero-copy">
<div class="hero-tags">
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
{{ resolveRunLevel(hermesRun) }}
</span>
<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>
<div class="hero-actions">
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
<i class="mdi mdi-refresh"></i>
<span>刷新详情</span>
</button>
</div>
</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">
<h3>基本信息</h3>
<p>围绕当前 Hermes 任务查看关键字段</p>
</div>
<div class="info-grid">
<div><span>Trace ID</span><strong>{{ hermesRun.run_id }}</strong></div>
<div><span>开始时间</span><strong>{{ formatDateTime(hermesRun.started_at) }}</strong></div>
<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>
<article class="panel detail-card">
<div class="card-head">
<h3>处理链路</h3>
<p>按工具调用顺序查看执行链</p>
</div>
<div v-if="(hermesRun.tool_calls || []).length" class="trace-steps">
<button
v-for="(toolCall, index) in hermesRun.tool_calls || []"
:key="toolCall.id"
type="button"
class="trace-step"
:class="{ active: selectedToolCall?.id === toolCall.id }"
@click="selectedToolCallId = toolCall.id"
>
<span class="step-index">{{ index + 1 }}</span>
<div class="step-copy">
<strong>{{ toolCall.tool_name }}</strong>
<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 明细若长时间停在运行中且没有心跳通常表示任务尚未真正进入 LightRAG 索引调用或执行它的是旧版后端进程
</div>
</article>
<article v-if="selectedToolCall" class="panel detail-card">
<div class="card-head">
<h3>当前 ToolCall</h3>
<p>查看当前工具调用的请求与返回</p>
</div>
<div class="payload-grid">
<div>
<h4>请求参数</h4>
<pre class="code-block">{{ formatJson(selectedToolCall.request_json) }}</pre>
</div>
<div>
<h4>返回结果</h4>
<pre class="code-block">{{ formatJson(selectedToolCall.response_json) }}</pre>
</div>
</div>
</article>
<article class="panel detail-card wide">
<div class="card-head">
<h3>路由上下文</h3>
<p>保留 Hermes 路由与进度原文便于管理员核查</p>
</div>
<pre class="code-block large">{{ formatJson(hermesRun.route_json) }}</pre>
</article>
</div>
</template>
<template v-else-if="isSystem && systemEntry">
<article class="detail-hero panel">
<div class="hero-copy">
<div class="hero-tags">
<span class="level-pill" :class="resolveSystemLevelTone(systemEntry.level)">
{{ systemEntry.level }}
</span>
<span class="status-pill" :class="resolveSystemOutcomeTone(systemEntry.outcome)">
{{ systemEntry.outcome }}
</span>
</div>
<h2>{{ systemEntry.summary || systemEntry.message }}</h2>
<p>{{ systemEntry.event_type }} · {{ systemEntry.logger || '未标记模块' }}</p>
</div>
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
<i class="mdi mdi-refresh"></i>
<span>刷新详情</span>
</button>
</article>
<div class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
<h3>解析结果</h3>
<p>按单条系统日志提取出的结构化字段</p>
</div>
<div class="info-grid">
<div><span>发生时间</span><strong>{{ formatDateTime(systemEntry.timestamp) }}</strong></div>
<div><span>日志来源</span><strong>{{ systemEntry.source_file }} #{{ systemEntry.line_number }}</strong></div>
<div><span>模块</span><strong>{{ systemEntry.logger || '未标记' }}</strong></div>
<div><span>Request ID</span><strong>{{ systemEntry.request_id || '—' }}</strong></div>
<div><span>请求方法</span><strong>{{ systemEntry.method || '—' }}</strong></div>
<div><span>响应状态</span><strong>{{ systemEntry.status_code ?? '—' }}</strong></div>
<div><span>请求路径</span><strong>{{ systemEntry.path || '—' }}</strong></div>
<div><span>处理耗时</span><strong>{{ systemEntry.duration_ms == null ? '—' : `${systemEntry.duration_ms}ms` }}</strong></div>
</div>
</article>
<article class="panel detail-card">
<div class="card-head">
<h3>解析反馈</h3>
<p>系统对当前日志的归类与处理建议</p>
</div>
<div class="feedback-grid">
<div><span>事件类型</span><strong>{{ systemEntry.event_type }}</strong></div>
<div><span>解析状态</span><strong>{{ resolveSystemParseLabel(systemEntry.parse_status) }}</strong></div>
<div><span>处理结果</span><strong>{{ systemEntry.outcome }}</strong></div>
<div><span>建议动作</span><strong>{{ resolveSystemRecommendation(systemEntry) }}</strong></div>
</div>
</article>
<article class="panel detail-card">
<div class="card-head">
<h3>原始日志</h3>
<p>保留该条记录的完整原文便于排障核对</p>
</div>
<pre class="code-block large">{{ systemEntry.raw }}</pre>
</article>
</div>
</template>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="backToLogs">
<i class="mdi mdi-arrow-left"></i>
<span>返回系统日志</span>
</button>
</footer>
</section>
</template>
<script setup>
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: '定时任务',
system_event: '系统事件',
user_message: '用户触发'
}
const route = useRoute()
const router = useRouter()
const loading = ref(false)
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 '未结束'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false })
}
function formatJson(value) {
try {
return JSON.stringify(value || {}, null, 2)
} catch {
return String(value || '')
}
}
function resolveStatusLabel(run) {
return resolveAgentRunStatus(run, nowTick.value).label
}
function resolveStatusTone(run) {
return resolveAgentRunStatus(run, nowTick.value).tone
}
function resolveToolStatusTone(status) {
return status === 'succeeded' ? 'success' : status === 'failed' ? 'danger' : 'warning'
}
function resolveRunSourceLabel(source) {
return SOURCE_LABELS[source] || source || '未标记'
}
function resolveRunModuleLabel(run) {
const routeJson = run?.route_json || {}
if (routeJson.job_type === 'knowledge_index_sync' || routeJson.job_type === 'llm_wiki_sync') return '\u77e5\u8bc6\u5f52\u7eb3'
if (routeJson.selected_agent) return String(routeJson.selected_agent)
if (routeJson.folder) return String(routeJson.folder)
return resolveRunSourceLabel(run?.source)
}
function resolveRunTitle(run) {
const routeJson = run?.route_json || {}
if (routeJson.job_type === 'knowledge_index_sync' || routeJson.job_type === 'llm_wiki_sync') {
return `\u77e5\u8bc6\u5f52\u7eb3 \u00b7 ${routeJson.folder || '\u672a\u6307\u5b9a\u76ee\u5f55'}`
}
return `Hermes 调用 · ${resolveRunModuleLabel(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'
}
function resolveLevelTone(level) {
if (level === 'ERROR') return 'danger'
if (level === 'WARN') return 'warning'
if (level === 'INFO') return 'info'
return 'muted'
}
function resolveRunProgress(run) {
return formatAgentRunProgress(run)
}
function resolveSystemLevelTone(level) {
if (level === 'ERROR' || level === 'CRITICAL') return 'danger'
if (level === 'WARNING' || level === 'WARN') return 'warning'
if (level === 'INFO') return 'info'
return 'muted'
}
function resolveSystemOutcomeTone(outcome) {
if (outcome === '失败') return 'danger'
if (outcome === '异常' || outcome === '告警') return 'warning'
if (outcome === '成功') return 'success'
return 'muted'
}
function resolveSystemParseLabel(status) {
return status === 'parsed' ? '已结构化' : '待人工核查'
}
function resolveSystemRecommendation(entry) {
if (!entry) return '暂无'
if (entry.outcome === '失败') return '优先排查并关联上下游日志'
if (entry.outcome === '异常' || entry.outcome === '告警') return '建议继续关注同模块后续记录'
if (entry.parse_status !== 'parsed') return '建议人工核对原始日志'
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) {
selectedToolCallId.value = ''
return
}
if (!calls.some((item) => item.id === selectedToolCallId.value)) {
selectedToolCallId.value = calls[0].id
}
}
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 || '')
if (isHermes.value) {
hermesRun.value = await fetchAgentRunDetail(id)
systemEntry.value = null
syncSelectedToolCall()
return
}
if (isSystem.value) {
systemEntry.value = await fetchSystemLogEntry(id)
hermesRun.value = null
return
}
throw new Error('不支持的日志类型。')
} catch (nextError) {
error.value = nextError?.message || '日志详情加载失败。'
} finally {
nowTick.value = Date.now()
syncPolling()
if (!silent) {
loading.value = false
}
}
}
function backToLogs() {
router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
}
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>