feat: 引入 ECharts 统一图表并完善员工画像标签分页
后端优化员工行为画像服务和辅助函数,完善系统设置模型和 配置持久化,前端引入 ECharts 替换所有图表组件实现统一 渲染,新增员工画像标签分页器和数字员工工作记录组件,优 化工作台响应式布局和登录页过渡动画,完善预算中心和数字 员工页面样式细节。
This commit is contained in:
445
web/src/utils/employeeProfileViewModel.js
Normal file
445
web/src/utils/employeeProfileViewModel.js
Normal 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')
|
||||
}
|
||||
Reference in New Issue
Block a user