feat: 引入 ECharts 统一图表并完善员工画像标签分页

后端优化员工行为画像服务和辅助函数,完善系统设置模型和
配置持久化,前端引入 ECharts 替换所有图表组件实现统一
渲染,新增员工画像标签分页器和数字员工工作记录组件,优
化工作台响应式布局和登录页过渡动画,完善预算中心和数字
员工页面样式细节。
This commit is contained in:
caoxiaozhu
2026-05-28 16:24:59 +08:00
parent 8a4a777be7
commit e384318046
53 changed files with 4698 additions and 2468 deletions

View File

@@ -0,0 +1,332 @@
<template>
<section class="digital-work-records">
<header class="work-records-head">
<div>
<h3>工作记录</h3>
<p>查看数字员工近期执行记录状态和结果摘要</p>
</div>
<div class="work-records-kpis" aria-label="工作记录统计">
<article class="work-record-kpi">
<span>日志总数</span>
<strong>{{ totalCount }}</strong>
</article>
<article class="work-record-kpi success">
<span>成功数量</span>
<strong>{{ successCount }}</strong>
</article>
<article class="work-record-kpi danger">
<span>失败数量</span>
<strong>{{ failedCount }}</strong>
</article>
</div>
</header>
<div class="work-records-toolbar">
<span>{{ loading ? '正在同步工作记录' : `当前展示 ${visibleRuns.length} 条记录` }}</span>
<button type="button" :disabled="loading" @click="loadWorkRecords(true)">
<i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button>
</div>
<div class="table-wrap work-records-table-wrap" :class="{ 'is-empty': !loading && !runs.length }">
<div v-if="loading && !runs.length" class="table-state">
<TableLoadingState
variant="panel"
title="工作记录同步中"
message="正在读取数字员工近期执行记录"
icon="mdi mdi-clipboard-text-clock-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>工作记录加载失败</strong>
<p>{{ errorMessage }}</p>
</div>
<table v-else-if="runs.length" class="digital-work-records-table">
<colgroup>
<col class="col-time">
<col class="col-module">
<col class="col-source">
<col class="col-status">
<col class="col-summary">
<col class="col-trace">
</colgroup>
<thead>
<tr>
<th>执行时间</th>
<th>工作模块</th>
<th>触发来源</th>
<th>状态</th>
<th>摘要</th>
<th>Run ID</th>
</tr>
</thead>
<tbody>
<tr
v-for="run in visibleRuns"
:key="run.run_id"
class="work-record-row"
tabindex="0"
role="button"
@click="openWorkRecordDetail(run)"
@keydown.enter.prevent="openWorkRecordDetail(run)"
>
<td>{{ formatWorkRecordDateTime(run.started_at) }}</td>
<td>{{ resolveWorkRecordModuleLabel(run) }}</td>
<td>{{ resolveWorkRecordSourceLabel(run.source) }}</td>
<td>
<div class="work-record-status-stack">
<span class="status-pill" :class="resolveWorkRecordStatusTone(run)">
{{ resolveWorkRecordStatusLabel(run) }}
</span>
<span>{{ resolveWorkRecordStatusNote(run) }}</span>
</div>
</td>
<td class="work-record-summary-cell">
<strong>{{ resolveWorkRecordTitle(run) }}</strong>
<span>{{ formatWorkRecordSummary(run.result_summary) }}</span>
<em>{{ resolveWorkRecordSummaryMeta(run) }}</em>
</td>
<td class="work-record-trace-cell">{{ run.run_id }}</td>
</tr>
</tbody>
</table>
<div v-else class="work-records-empty">
当前还没有数字员工工作记录
</div>
</div>
<Teleport to="body">
<Transition name="work-record-detail">
<div
v-if="detailOpen"
class="work-record-detail-mask"
role="dialog"
aria-modal="true"
aria-label="工作记录详情"
@click.self="closeWorkRecordDetail"
>
<aside class="work-record-detail-panel">
<header class="work-record-detail-head">
<div>
<span>工作记录详情</span>
<h3>{{ selectedRunDetail ? resolveWorkRecordTitle(selectedRunDetail) : '工作记录' }}</h3>
</div>
<button type="button" aria-label="关闭工作记录详情" @click="closeWorkRecordDetail">
<i class="mdi mdi-close"></i>
</button>
</header>
<div v-if="detailLoading" class="work-record-detail-state">
<TableLoadingState
variant="panel"
title="详情加载中"
message="正在读取该次工作记录的完整执行信息"
icon="mdi mdi-clipboard-text-search-outline"
/>
</div>
<div v-else-if="detailError" class="work-record-detail-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>工作记录详情加载失败</strong>
<p>{{ detailError }}</p>
<button type="button" @click="reloadSelectedDetail">重新加载</button>
</div>
<div v-else-if="selectedRunDetail" class="work-record-detail-body">
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>基本信息</h4>
<span class="status-pill" :class="resolveWorkRecordStatusTone(selectedRunDetail)">
{{ resolveWorkRecordStatusLabel(selectedRunDetail) }}
</span>
</div>
<div class="work-record-info-grid">
<div><span>Run ID</span><strong>{{ selectedRunDetail.run_id }}</strong></div>
<div><span>工作模块</span><strong>{{ resolveWorkRecordModuleLabel(selectedRunDetail) }}</strong></div>
<div><span>触发来源</span><strong>{{ resolveWorkRecordSourceLabel(selectedRunDetail.source) }}</strong></div>
<div><span>开始时间</span><strong>{{ formatWorkRecordDateTime(selectedRunDetail.started_at) }}</strong></div>
<div><span>结束时间</span><strong>{{ formatWorkRecordDateTime(selectedRunDetail.finished_at) }}</strong></div>
<div><span>状态说明</span><strong>{{ resolveWorkRecordStatusNote(selectedRunDetail) }}</strong></div>
</div>
</section>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>执行摘要</h4>
<span>{{ resolveWorkRecordSummaryMeta(selectedRunDetail) }}</span>
</div>
<p class="work-record-result-text">
{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}
</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text">
{{ selectedRunDetail.error_message }}
</p>
</section>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>工具调用</h4>
<span>{{ (selectedRunDetail.tool_calls || []).length }} </span>
</div>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id">
<strong>{{ toolCall.tool_name }}</strong>
<span>{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
</article>
</div>
<div v-else class="work-record-inline-empty">当前暂无工具调用明细</div>
</section>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>执行上下文</h4>
<span>JSON</span>
</div>
<pre class="work-record-code-block">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</section>
</div>
</aside>
</div>
</Transition>
</Teleport>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
import { useToast } from '../../composables/useToast.js'
import { AGENT_RUN_POLL_INTERVAL_MS } from '../../utils/agentRunMonitor.js'
import {
formatWorkRecordDateTime,
formatWorkRecordSummary,
resolveWorkRecordModuleLabel,
resolveWorkRecordSourceLabel,
resolveWorkRecordStatusLabel,
resolveWorkRecordStatusNote,
resolveWorkRecordStatusTone,
resolveWorkRecordSummaryMeta,
resolveWorkRecordTitle
} from '../../views/scripts/digitalEmployeeWorkRecordsModel.js'
defineOptions({
name: 'DigitalEmployeeWorkRecords'
})
const emit = defineEmits(['summary-change'])
const { toast } = useToast()
const runs = ref([])
const loading = ref(false)
const errorMessage = ref('')
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref('')
const selectedRunId = ref('')
const selectedRunDetail = ref(null)
let pollTimer = 0
const totalCount = computed(() => runs.value.length)
const successCount = computed(() => runs.value.filter((run) => run.status === 'succeeded').length)
const failedCount = computed(() => runs.value.filter((run) => run.status === 'failed').length)
const visibleRuns = computed(() => runs.value.slice(0, 100))
async function loadWorkRecords(showToast = false) {
loading.value = true
errorMessage.value = ''
try {
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
runs.value = Array.isArray(payload) ? payload : []
emit('summary-change', {
total: totalCount.value,
succeeded: successCount.value,
failed: failedCount.value
})
} catch (error) {
errorMessage.value = error?.message || '工作记录加载失败,请稍后重试。'
if (showToast) {
toast(errorMessage.value)
}
} finally {
loading.value = false
}
}
function formatJson(value) {
try {
return JSON.stringify(value || {}, null, 2)
} catch {
return String(value || '')
}
}
async function loadWorkRecordDetail(runId) {
detailLoading.value = true
detailError.value = ''
try {
selectedRunDetail.value = await fetchAgentRunDetail(runId)
} catch (error) {
detailError.value = error?.message || '工作记录详情加载失败,请稍后重试。'
} finally {
detailLoading.value = false
}
}
function openWorkRecordDetail(run) {
const runId = String(run?.run_id || '').trim()
if (!runId) {
return
}
selectedRunId.value = runId
selectedRunDetail.value = run
detailOpen.value = true
void loadWorkRecordDetail(runId)
}
function reloadSelectedDetail() {
if (!selectedRunId.value) {
return
}
void loadWorkRecordDetail(selectedRunId.value)
}
function closeWorkRecordDetail() {
detailOpen.value = false
detailError.value = ''
}
function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => {
loadWorkRecords(false)
}, AGENT_RUN_POLL_INTERVAL_MS)
}
function stopPolling() {
if (pollTimer) {
window.clearInterval(pollTimer)
pollTimer = 0
}
}
onMounted(() => {
loadWorkRecords(false).catch(() => {})
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<style scoped src="../../assets/styles/components/digital-employee-work-records.css"></style>