2026-05-28 16:24:59 +08:00
|
|
|
|
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)
|
2026-05-29 14:51:18 +08:00
|
|
|
|
const windowedUserRuns = filterRunsByProfileWindow(userRuns, profile)
|
|
|
|
|
|
const durationMs = hasProfileDurationMetric(aiMetrics)
|
|
|
|
|
|
? resolveNumber(aiMetrics.ai_run_duration_ms)
|
|
|
|
|
|
: sumRunDurationMs(windowedUserRuns)
|
|
|
|
|
|
const durationDisplay = formatDurationMetric(durationMs)
|
|
|
|
|
|
const commonAgent = resolveCommonAgent(windowedUserRuns)
|
2026-05-28 16:24:59 +08:00
|
|
|
|
const tokenCount = resolveNumber(aiMetrics.exact_token_count) || resolveNumber(aiMetrics.estimated_token_count)
|
|
|
|
|
|
const tokenDisplay = formatTokenCount(tokenCount)
|
2026-05-29 14:51:18 +08:00
|
|
|
|
const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || windowedUserRuns.length
|
2026-05-28 16:24:59 +08:00
|
|
|
|
|
|
|
|
|
|
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 : {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 14:51:18 +08:00
|
|
|
|
function hasProfileDurationMetric(metrics) {
|
|
|
|
|
|
return Object.prototype.hasOwnProperty.call(metrics || {}, 'ai_run_duration_ms')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 16:24:59 +08:00
|
|
|
|
function filterRunsByCurrentUser(runs, currentUser) {
|
|
|
|
|
|
const identities = resolveCurrentUserIdentities(currentUser)
|
|
|
|
|
|
return (Array.isArray(runs) ? runs : []).filter((run) => belongsToCurrentUser(run, identities))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 14:51:18 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 16:24:59 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|