Files
X-Financial/web/src/utils/employeeProfileViewModel.js
caoxiaozhu 7989f3a159 feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
2026-05-30 15:46:51 +08:00

607 lines
18 KiB
JavaScript
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.
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 FINANCIAL_RADAR_CODES = [
'expense_intensity',
'application_rhythm',
'travel_entertainment',
'material_completeness',
'process_pressure'
]
const GOVERNANCE_RADAR_CODES = [
'ai_collaboration',
'approval_efficiency',
'approval_control'
]
export const USER_PROFILE_RADAR_VIEW_OPTIONS = [
{
value: 'financial_risk',
label: '财务风险视角',
shortLabel: '财务风险',
description: '费用、材料和流程相关维度'
},
{
value: 'collaboration_governance',
label: '协作治理视角',
shortLabel: '协作治理',
description: 'AI 协作和审批治理维度'
},
{
value: 'all_behavior',
label: '全部行为视角',
shortLabel: '全部行为',
description: '展示全部可用画像维度'
}
]
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 windowedUserRuns = filterRunsByProfileWindow(userRuns, profile)
const durationMs = resolveUsageDurationMs(aiMetrics, windowedUserRuns)
const durationDisplay = formatDurationMetric(durationMs)
const commonAgent = resolveCommonAgent(windowedUserRuns)
const tokenCount = resolveNumber(aiMetrics.exact_token_count) || resolveNumber(aiMetrics.estimated_token_count)
const tokenDisplay = formatTokenCount(tokenCount)
const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || windowedUserRuns.length
return [
{
key: 'usage-duration',
label: '使用时长',
value: durationDisplay.value,
unit: durationDisplay.unit,
hint: resolveUsageDurationHint(aiMetrics, 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),
category: normalizeCode(tag.category),
tone: resolveTagTone(tag),
score: clampScore(tag.score),
reason: normalizeText(tag.reason) || '画像算法已识别该行为特征。',
confidence: resolveNumber(tag.confidence),
radarDimensions: normalizeRadarDimensions(tag)
}))
.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 filterUserProfileRadarDimensions(dimensions, viewKey) {
const items = Array.isArray(dimensions) ? dimensions : []
const codes = resolveRadarViewCodes(viewKey)
if (!codes.length) {
return items
}
const filtered = items.filter((item) => codes.includes(normalizeCode(item?.code)))
return filtered.length ? filtered : items
}
export function filterUserProfileTagsByRadarView(tags, viewKey) {
const items = Array.isArray(tags) ? tags : []
const codes = resolveRadarViewCodes(viewKey)
if (!codes.length) {
return items
}
return items.filter((tag) => {
const dimensions = Array.isArray(tag?.radarDimensions) ? tag.radarDimensions : []
if (dimensions.some((code) => codes.includes(normalizeCode(code)))) {
return true
}
return resolveFallbackTagRadarCodes(tag).some((code) => codes.includes(code))
})
}
export function resolveUserProfileDefaultRadarView(profile) {
const profileTypes = new Set(
(Array.isArray(profile?.profiles) ? profile.profiles : [])
.map((item) => normalizeCode(item?.profile_type))
.filter(Boolean)
)
if (profileTypes.has('expense') || profileTypes.has('process_quality')) {
return 'financial_risk'
}
if (profileTypes.has('ai_usage') || profileTypes.has('approval')) {
return 'collaboration_governance'
}
const dimensions = normalizeUserProfileRadarDimensions(profile)
const financialScore = sumRadarScores(dimensions, FINANCIAL_RADAR_CODES)
const governanceScore = sumRadarScores(dimensions, GOVERNANCE_RADAR_CODES)
if (financialScore > 0 || governanceScore > 0) {
return financialScore >= governanceScore ? 'financial_risk' : 'collaboration_governance'
}
return 'all_behavior'
}
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 resolveRadarViewCodes(viewKey) {
if (viewKey === 'financial_risk') {
return FINANCIAL_RADAR_CODES
}
if (viewKey === 'collaboration_governance') {
return GOVERNANCE_RADAR_CODES
}
return []
}
function resolveFallbackTagRadarCodes(tag) {
const category = normalizeCode(tag?.category)
if (['expense', 'travel', 'entertainment', 'process'].includes(category)) {
return FINANCIAL_RADAR_CODES
}
if (['ai', 'approval'].includes(category)) {
return GOVERNANCE_RADAR_CODES
}
return []
}
function normalizeRadarDimensions(tag) {
const dimensions = Array.isArray(tag?.radar_dimensions)
? tag.radar_dimensions
: Array.isArray(tag?.radarDimensions)
? tag.radarDimensions
: []
return dimensions.map((item) => normalizeCode(item)).filter(Boolean)
}
function sumRadarScores(dimensions, codes) {
const codeSet = new Set(codes)
return (Array.isArray(dimensions) ? dimensions : [])
.filter((item) => codeSet.has(normalizeCode(item?.code)))
.reduce((total, item) => total + clampScore(item?.score), 0)
}
function hasProfileDurationMetric(metrics) {
return (
Object.prototype.hasOwnProperty.call(metrics || {}, 'usage_duration_ms')
|| Object.prototype.hasOwnProperty.call(metrics || {}, 'online_duration_ms')
|| Object.prototype.hasOwnProperty.call(metrics || {}, 'ai_run_duration_ms')
)
}
function resolveUsageDurationMs(metrics, fallbackRuns) {
if (!hasProfileDurationMetric(metrics)) {
return sumRunDurationMs(fallbackRuns)
}
return resolveNumber(metrics.usage_duration_ms)
|| resolveNumber(metrics.online_duration_ms)
|| resolveNumber(metrics.ai_run_duration_ms)
}
function resolveUsageDurationHint(metrics, profile) {
const days = resolveWindowDays(profile)
if (normalizeCode(metrics?.usage_duration_mode) === 'online_session') {
return `${days}天在线会话累计`
}
if (normalizeCode(metrics?.usage_duration_mode) === 'agent_run_fallback') {
return `${days}天智能体运行累计`
}
return `${days}天使用行为累计`
}
function filterRunsByCurrentUser(runs, currentUser) {
const identities = resolveCurrentUserIdentities(currentUser)
return (Array.isArray(runs) ? runs : []).filter((run) => belongsToCurrentUser(run, identities))
}
function filterRunsByProfileWindow(runs, profile) {
const cutoff = Date.now() - resolveWindowDays(profile) * 24 * 60 * 60 * 1000
return (Array.isArray(runs) ? runs : []).filter((run) => {
const startedAt = Date.parse(run?.started_at || '')
return Number.isFinite(startedAt) && startedAt >= cutoff
})
}
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')
}