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,445 @@
const PROFILE_TYPE_LABELS = {
expense: '费用申请',
process_quality: '流程质量',
ai_usage: 'AI 协作',
approval: '审核行为'
}
const STATUS_LABELS = {
succeeded: '已完成',
success: '已完成',
running: '进行中',
blocked: '待确认',
failed: '失败'
}
const STATUS_TONES = {
succeeded: 'success',
success: 'success',
running: 'warning',
blocked: 'warning',
failed: 'danger'
}
const AGENT_LABELS = {
hermes: 'Hermes 数字员工',
user_agent: '智能问答助手',
orchestrator: '智能编排服务',
system: '系统服务'
}
const AGENT_SHORT_LABELS = {
hermes: 'Hermes',
user_agent: '问答助手',
orchestrator: '编排服务',
system: '系统服务'
}
const RADAR_COLORS = [
'#3a7ca5',
'#0f9f8f',
'#f59e0b',
'#7c3aed',
'#dc2626',
'#2563eb',
'#16a34a',
'#db2777'
]
const TAG_ACCENT_COUNT = 8
const SOURCE_LABELS = {
user_message: '用户对话',
schedule: '定时任务',
system_event: '系统事件',
workbench: '个人工作台',
detail: '单据详情',
documents_application: '单据中心'
}
const SCENARIO_LABELS = {
knowledge: '知识库问答',
expense: '费用报销',
reimbursement: '费用报销',
expense_application: '费用申请',
application: '费用申请',
budget: '预算查询',
audit: '风险审核',
approval: '审批处理',
policy: '制度问答',
travel: '差旅费用',
entertainment: '业务招待',
accounts_receivable: '应收查询',
accounts_payable: '应付查询'
}
const INTENT_LABELS = {
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '草稿生成',
operate: '操作办理',
review: '审核',
submit: '提交'
}
const JOB_TYPE_LABELS = {
knowledge_index_sync: '知识库索引同步',
llm_wiki_sync: '知识库归纳同步',
employee_behavior_profile_scan: '用户画像测算',
workbench_on_demand: '工作台画像测算',
global_risk_scan: '全局风险巡检',
weekly_expense_report: '周费用报告'
}
export function buildUserProfileMetricCards(profile, runs = [], currentUser = {}) {
const index = indexProfiles(profile)
const aiMetrics = metricsOf(index.ai_usage)
const userRuns = filterRunsByCurrentUser(runs, currentUser)
const durationDisplay = formatDurationMetric(sumRunDurationMs(userRuns))
const commonAgent = resolveCommonAgent(userRuns)
const tokenCount = resolveNumber(aiMetrics.exact_token_count) || resolveNumber(aiMetrics.estimated_token_count)
const tokenDisplay = formatTokenCount(tokenCount)
const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || userRuns.length
return [
{
key: 'usage-duration',
label: '使用时长',
value: durationDisplay.value,
unit: durationDisplay.unit,
hint: `${resolveWindowDays(profile)}天智能体运行累计`,
icon: 'mdi mdi-timer-sand',
tone: 'primary'
},
{
key: 'common-agent',
label: '常用智能体',
value: commonAgent.label,
unit: '',
hint: commonAgent.count ? `${commonAgent.count} 次调用,占比 ${commonAgent.share}` : '暂无智能体调用记录',
icon: 'mdi mdi-account-tie-voice-outline',
tone: 'cyan'
},
{
key: 'ai-usage',
label: 'AI 使用次数',
value: formatNumber(aiRunCount),
unit: '次',
hint: `${resolveWindowDays(profile)}天智能协作记录`,
icon: 'mdi mdi-robot-outline',
tone: 'violet'
},
{
key: 'token-usage',
label: 'Token 消耗',
value: tokenDisplay.value,
unit: tokenDisplay.unit,
hint: resolveTokenHint(aiMetrics),
icon: 'mdi mdi-lightning-bolt-outline',
tone: 'amber'
}
]
}
export function buildUserProfileSummaryMetrics(profile, runs = [], currentUser = {}) {
return buildUserProfileMetricCards(profile, runs, currentUser).slice(0, 4)
}
export function normalizeUserProfileTags(profile, limit = 8) {
return (Array.isArray(profile?.profile_tags) ? profile.profile_tags : [])
.map((tag) => ({
code: normalizeText(tag.code || tag.label),
label: normalizeText(tag.label),
displayLabel: normalizeText(tag.display_label || tag.displayLabel || tag.label),
tone: resolveTagTone(tag),
score: clampScore(tag.score),
reason: normalizeText(tag.reason) || '画像算法已识别该行为特征。',
confidence: resolveNumber(tag.confidence)
}))
.filter((tag) => tag.code && tag.displayLabel)
.sort((left, right) => right.score - left.score)
.slice(0, limit)
.map((tag, index) => ({
...tag,
colorIndex: index % TAG_ACCENT_COUNT
}))
}
export function normalizeUserProfileRadarDimensions(profile) {
const dimensions = Array.isArray(profile?.radar?.dimensions) ? profile.radar.dimensions : []
if (dimensions.length) {
return withRadarColors(
dimensions.map((item) => ({
code: normalizeText(item.code || item.label),
label: normalizeText(item.label || item.code),
score: clampScore(item.score)
}))
)
}
return withRadarColors(
(Array.isArray(profile?.profiles) ? profile.profiles : [])
.map((item) => ({
code: normalizeText(item.profile_type),
label: PROFILE_TYPE_LABELS[item.profile_type] || normalizeText(item.profile_label || item.profile_type),
score: clampScore(item.score)
}))
.filter((item) => item.code && item.label)
)
}
export function buildProfileOperationsFromAgentRuns(runs, currentUser, limit = 5) {
const identities = resolveCurrentUserIdentities(currentUser)
return (Array.isArray(runs) ? runs : [])
.filter((run) => belongsToCurrentUser(run, identities))
.sort((left, right) => Date.parse(right.started_at || 0) - Date.parse(left.started_at || 0))
.slice(0, limit)
.map((run, index) => ({
id: normalizeText(run.run_id || run.id) || `operation-${index + 1}`,
time: formatOperationTime(run.started_at),
action: resolveOperationAction(run),
target: resolveOperationTarget(run),
channel: resolveOperationChannel(run),
status: STATUS_LABELS[normalizeCode(run.status)] || normalizeText(run.status) || '未知',
tone: STATUS_TONES[normalizeCode(run.status)] || 'info'
}))
}
export function resolveCurrentUserProfileError(error) {
return normalizeText(error?.message) || '用户画像读取失败,请稍后重试。'
}
function indexProfiles(profile) {
return Object.fromEntries(
(Array.isArray(profile?.profiles) ? profile.profiles : [])
.map((item) => [normalizeText(item.profile_type), item])
.filter(([key]) => key)
)
}
function metricsOf(profile) {
return profile?.metrics && typeof profile.metrics === 'object' ? profile.metrics : {}
}
function filterRunsByCurrentUser(runs, currentUser) {
const identities = resolveCurrentUserIdentities(currentUser)
return (Array.isArray(runs) ? runs : []).filter((run) => belongsToCurrentUser(run, identities))
}
function belongsToCurrentUser(run, identities) {
if (!identities.size) {
return false
}
const userId = normalizeText(run?.user_id).toLowerCase()
return Boolean(userId && identities.has(userId))
}
function resolveCurrentUserIdentities(user = {}) {
return new Set(
[
user.username,
user.email,
user.name,
user.employeeNo,
user.employee_no
]
.map((item) => normalizeText(item).toLowerCase())
.filter(Boolean)
)
}
function resolveCommonAgent(runs) {
const counts = new Map()
for (const run of runs) {
const code = normalizeCode(run?.agent || run?.route_json?.selected_agent || 'system') || 'system'
counts.set(code, (counts.get(code) || 0) + 1)
}
const [code = '', count = 0] = Array.from(counts.entries())
.sort((left, right) => right[1] - left[1])[0] || []
if (!code || !count) {
return { label: '暂无', count: 0, share: '0%' }
}
return {
label: AGENT_SHORT_LABELS[code] || translateKnownValue(code, AGENT_LABELS, '智能体') || '智能体',
count,
share: formatPercent(count / Math.max(1, runs.length))
}
}
function sumRunDurationMs(runs) {
return runs.reduce((total, run) => total + resolveRunDurationMs(run), 0)
}
function resolveRunDurationMs(run) {
const startedAt = Date.parse(run?.started_at || '')
const finishedAt = Date.parse(run?.finished_at || '')
if (Number.isFinite(startedAt) && Number.isFinite(finishedAt) && finishedAt > startedAt) {
return Math.min(finishedAt - startedAt, 24 * 60 * 60 * 1000)
}
return (Array.isArray(run?.tool_calls) ? run.tool_calls : []).reduce(
(total, tool) => total + Math.max(0, resolveNumber(tool?.duration_ms)),
0
)
}
function resolveOperationAction(run) {
const semanticText = normalizeText(run?.semantic_parse?.raw_query)
if (semanticText) {
return `${resolveOperationBusinessLabel(run)}${semanticText}`
}
return translateKnownValue(run?.result_summary, JOB_TYPE_LABELS, '')
|| translateKnownValue(run?.route_json?.job_type, JOB_TYPE_LABELS, '执行系统任务')
|| '执行智能财务任务'
}
function resolveOperationTarget(run) {
return translateKnownValue(run?.route_json?.task_title, JOB_TYPE_LABELS, '')
|| translateKnownValue(run?.route_json?.asset_name, JOB_TYPE_LABELS, '')
|| translateKnownValue(run?.semantic_parse?.scenario, SCENARIO_LABELS, '业务操作')
|| translateKnownValue(run?.task_id, JOB_TYPE_LABELS, '系统任务')
|| '个人工作台'
}
function resolveOperationChannel(run) {
const agent = translateKnownValue(run?.agent, AGENT_LABELS, '智能服务') || 'Hermes 数字员工'
const source = translateKnownValue(run?.source, SOURCE_LABELS, '系统入口')
return source ? `${agent} · ${source}` : agent
}
function resolveOperationBusinessLabel(run) {
const scenario = translateKnownValue(run?.semantic_parse?.scenario, SCENARIO_LABELS, '业务操作')
const intent = translateKnownValue(run?.semantic_parse?.intent, INTENT_LABELS, '')
if (scenario && intent) {
return `${scenario}${intent}`
}
return scenario || intent || '发起'
}
function resolveTagTone(tag) {
const polarity = normalizeText(tag?.polarity || tag?.tone).toLowerCase()
if (['risk', 'danger', 'negative'].includes(polarity)) {
return 'risk'
}
if (['positive', 'success'].includes(polarity)) {
return 'positive'
}
return 'behavior'
}
function resolveWindowDays(profile) {
const days = Number(profile?.window_days || 90)
return Number.isFinite(days) && days > 0 ? Math.round(days) : 90
}
function resolveTokenHint(metrics) {
const mode = normalizeText(metrics.token_count_mode)
return mode === 'estimated_token_count' ? '按运行载荷估算' : '模型调用累计'
}
function formatTokenCount(value) {
const count = resolveNumber(value)
if (count >= 10000) {
return { value: trimNumber(count / 10000, 2), unit: '万' }
}
return { value: formatNumber(count), unit: 'tokens' }
}
function formatDurationMetric(totalMs) {
const seconds = Math.round(Math.max(0, resolveNumber(totalMs)) / 1000)
if (seconds < 60) {
return { value: formatNumber(seconds), unit: '秒' }
}
const minutes = seconds / 60
if (minutes < 60) {
return { value: trimNumber(minutes, minutes >= 10 ? 0 : 1), unit: '分钟' }
}
const hours = minutes / 60
return { value: trimNumber(hours, hours >= 10 ? 0 : 1), unit: '小时' }
}
function withRadarColors(items) {
return items.map((item, index) => ({
...item,
color: item.color || RADAR_COLORS[index % RADAR_COLORS.length]
}))
}
function formatOperationTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '时间未知'
}
const now = new Date()
const sameDay = date.toDateString() === now.toDateString()
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
if (sameDay) {
return `今天 ${time}`
}
if (date.toDateString() === yesterday.toDateString()) {
return `昨天 ${time}`
}
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
}
function formatNumber(value) {
return String(Math.round(resolveNumber(value)))
}
function formatPercent(value) {
return `${Math.round(resolveNumber(value) * 100)}%`
}
function formatMoney(value) {
return `¥${trimNumber(resolveNumber(value), 2)}`
}
function trimNumber(value, digits = 0) {
const number = Number(value || 0)
return number.toFixed(digits).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
function clampScore(value) {
const score = Math.round(resolveNumber(value))
return Math.max(0, Math.min(100, score))
}
function resolveNumber(value) {
const number = Number(value || 0)
return Number.isFinite(number) ? number : 0
}
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeCode(value) {
return normalizeText(value).toLowerCase()
}
function translateKnownValue(value, labels, internalFallback = '') {
const raw = normalizeText(value)
if (!raw) {
return ''
}
const mapped = labels[normalizeCode(raw)]
if (mapped) {
return mapped
}
return isInternalCode(raw) ? internalFallback : raw
}
function isInternalCode(value) {
return /^[a-z][a-z0-9_:-]*$/i.test(normalizeText(value))
}
function pad(value) {
return String(value).padStart(2, '0')
}