-
+
{{ item.name }}
{{ item.display }}
@@ -18,18 +18,17 @@
+
+
diff --git a/web/src/components/charts/TrendChart.vue b/web/src/components/charts/TrendChart.vue
index da09b71..c7ce9ed 100644
--- a/web/src/components/charts/TrendChart.vue
+++ b/web/src/components/charts/TrendChart.vue
@@ -5,30 +5,22 @@
审批完成量(单)
平均审批时长(小时)
-
-
-
+
diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue
index 675abbe..2623f24 100644
--- a/web/src/components/layout/SidebarRail.vue
+++ b/web/src/components/layout/SidebarRail.vue
@@ -159,7 +159,7 @@ const sidebarMeta = {
policies: { label: '知识管理' },
audit: { label: '规则中心' },
digitalEmployees: { label: '数字员工' },
- logs: { label: '日志管理' },
+ logs: { label: '系统日志' },
employees: { label: '员工管理' },
settings: { label: '系统设置' }
}
diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue
index fb3c2d0..390016d 100644
--- a/web/src/components/layout/TopBar.vue
+++ b/web/src/components/layout/TopBar.vue
@@ -290,20 +290,20 @@ const documentKpis = computed(() => {
]
})
-const logsKpis = computed(() => {
- const summary = props.logsSummary ?? {}
- const total = Number(summary.total ?? 0)
- const running = Number(summary.running ?? 0)
- const completed = Number(summary.completed ?? 0)
- const failed = Number(summary.failed ?? 0)
-
- return [
- { label: 'Hermes 总任务', value: total, unit: '条', meta: '当前', trend: 'up', color: 'var(--theme-primary)' },
- { label: '运行中', value: running, unit: '条', meta: running > 0 ? '实时执行' : '暂无执行', trend: running > 0 ? 'up' : 'down', color: '#3b82f6' },
- { label: '已完成', value: completed, unit: '条', meta: total ? `占比 ${Math.round((completed / total) * 100)}%` : '等待数据', trend: 'up', color: 'var(--success)' },
- { label: '失败数', value: failed, unit: '条', meta: failed > 0 ? '需要关注' : '运行正常', trend: failed > 0 ? 'down' : 'up', color: '#ef4444' }
- ]
-})
+const logsKpis = computed(() => {
+ const summary = props.logsSummary ?? {}
+ const total = Number(summary.total ?? 0)
+ const errors = Number(summary.errors ?? 0)
+ const warnings = Number(summary.warnings ?? 0)
+ const info = Number(summary.info ?? 0)
+
+ return [
+ { label: '系统日志', value: total, unit: '条', meta: '当前', trend: 'up', color: 'var(--theme-primary)' },
+ { label: '错误数量', value: errors, unit: '条', meta: errors > 0 ? '需要关注' : '运行正常', trend: errors > 0 ? 'down' : 'up', color: '#ef4444' },
+ { label: '告警数量', value: warnings, unit: '条', meta: warnings > 0 ? '建议排查' : '暂无告警', trend: warnings > 0 ? 'down' : 'up', color: '#f59e0b' },
+ { label: '正常数量', value: info, unit: '条', meta: total ? `占比 ${Math.round((info / total) * 100)}%` : '等待数据', trend: 'up', color: 'var(--success)' }
+ ]
+})
const chatKpis = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
diff --git a/web/src/components/travel/BudgetAssistantReport.vue b/web/src/components/travel/BudgetAssistantReport.vue
index e2c67ea..d611616 100644
--- a/web/src/components/travel/BudgetAssistantReport.vue
+++ b/web/src/components/travel/BudgetAssistantReport.vue
@@ -113,19 +113,19 @@ const summaryCards = computed(() => [
label: '上季度开销',
value: props.report.summary?.totalSpend || '—',
hint: '按四类预算口径汇总',
- color: 'var(--chart-blue)'
+ color: 'var(--theme-secondary)'
},
{
label: '预算使用率',
value: props.report.summary?.usageRate || '—',
hint: '未触达风险线',
- color: 'var(--chart-amber)'
+ color: 'var(--warning)'
},
{
label: '建议编制额',
value: props.report.summary?.recommendedTotal || '—',
hint: '含业务增长预留',
- color: 'var(--chart-purple)'
+ color: 'var(--info)'
}
])
@@ -145,9 +145,9 @@ const summaryCards = computed(() => [
.budget-report-action-panel,
.budget-report-summary-card {
border: 1px solid #dbe4ee;
- border-radius: 8px;
+ border-radius: 4px;
background: #fff;
- box-shadow: 0 8px 20px rgba(15, 23, 42, .05);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.budget-report-head {
@@ -187,7 +187,7 @@ const summaryCards = computed(() => [
align-items: center;
gap: 5px;
padding: 0 10px;
- border-radius: 999px;
+ border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
@@ -291,7 +291,7 @@ const summaryCards = computed(() => [
padding: 12px;
border: 1px solid #e2e8f0;
border-left: 3px solid var(--accent);
- border-radius: 8px;
+ border-radius: 4px;
background: #fbfdff;
animation: budgetReportItemIn 460ms var(--ease, ease) both;
animation-delay: var(--delay, 0ms);
@@ -326,7 +326,7 @@ const summaryCards = computed(() => [
.budget-report-expense-card header em {
margin-left: auto;
padding: 1px 7px;
- border-radius: 999px;
+ border-radius: 4px;
font-size: 11px;
font-style: normal;
font-weight: 850;
@@ -357,7 +357,7 @@ const summaryCards = computed(() => [
display: inline-flex;
align-items: center;
padding: 0 7px;
- border-radius: 6px;
+ border-radius: 4px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
@@ -389,7 +389,7 @@ const summaryCards = computed(() => [
.budget-report-expense-card li {
padding: 2px 7px;
- border-radius: 999px;
+ border-radius: 4px;
background: #fff;
border: 1px solid #e2e8f0;
}
diff --git a/web/src/composables/useEcharts.js b/web/src/composables/useEcharts.js
new file mode 100644
index 0000000..05f9524
--- /dev/null
+++ b/web/src/composables/useEcharts.js
@@ -0,0 +1,79 @@
+import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
+import { init } from 'echarts/core'
+
+export function useEcharts(chartElement, chartOptions) {
+ let chartInstance = null
+ let resizeObserver = null
+ let renderFrame = 0
+
+ function renderChart() {
+ if (!chartElement.value) {
+ return
+ }
+ if (!chartInstance) {
+ chartInstance = init(chartElement.value, null, { renderer: 'canvas' })
+ chartInstance.resize()
+ }
+ chartInstance.setOption(chartOptions.value, true)
+ }
+
+ function handleResize() {
+ chartInstance?.resize()
+ }
+
+ function scheduleRender() {
+ if (typeof window === 'undefined') {
+ renderChart()
+ return
+ }
+ if (renderFrame) {
+ window.cancelAnimationFrame(renderFrame)
+ }
+ renderFrame = window.requestAnimationFrame(() => {
+ renderFrame = 0
+ renderChart()
+ })
+ }
+
+ function bindResize() {
+ if (!chartElement.value) {
+ return
+ }
+ if (typeof ResizeObserver !== 'undefined') {
+ resizeObserver = new ResizeObserver(handleResize)
+ resizeObserver.observe(chartElement.value)
+ }
+ window.addEventListener('resize', handleResize)
+ }
+
+ function unbindResize() {
+ resizeObserver?.disconnect()
+ resizeObserver = null
+ window.removeEventListener('resize', handleResize)
+ }
+
+ onMounted(() => {
+ renderChart()
+ bindResize()
+ })
+
+ onBeforeUnmount(() => {
+ unbindResize()
+ if (renderFrame && typeof window !== 'undefined') {
+ window.cancelAnimationFrame(renderFrame)
+ renderFrame = 0
+ }
+ if (chartInstance) {
+ chartInstance.dispose()
+ chartInstance = null
+ }
+ })
+
+ watch(chartOptions, () => {
+ nextTick(scheduleRender)
+ }, { deep: true })
+
+ return {
+ renderChart
+ }
+}
diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js
index 4f2604f..9834864 100644
--- a/web/src/composables/useNavigation.js
+++ b/web/src/composables/useNavigation.js
@@ -83,11 +83,11 @@ export const navItems = [
},
{
id: 'logs',
- label: '日志管理',
- navHint: '查看 Hermes 调用与系统运行日志',
+ label: '系统日志',
+ navHint: '查看系统运行日志',
icon: icons.logs,
- title: '日志管理',
- desc: '集中查看 Hermes 归纳任务进度、调用明细与系统运行日志。'
+ title: '系统日志',
+ desc: '集中查看系统运行日志、结构化事件和请求追踪信息。'
},
{
id: 'settings',
diff --git a/web/src/composables/useSettings.js b/web/src/composables/useSettings.js
index c57da99..ade1050 100644
--- a/web/src/composables/useSettings.js
+++ b/web/src/composables/useSettings.js
@@ -107,6 +107,10 @@ export function useSettings() {
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value)
+
+ if (nextState.appearanceForm?.themeSkin) {
+ setThemeSkin(nextState.appearanceForm.themeSkin)
+ }
}
async function loadSettingsSnapshot() {
@@ -123,6 +127,7 @@ export function useSettings() {
function buildSettingsPayload() {
return {
companyForm: { ...pageState.value.companyForm },
+ appearanceForm: { ...pageState.value.appearanceForm },
adminForm: { ...pageState.value.adminForm },
sessionForm: { ...pageState.value.sessionForm },
llmForm: buildLlmPayload(pageState.value.llmForm),
@@ -307,10 +312,16 @@ export function useSettings() {
function selectThemeSkin(skinId) {
setThemeSkin(skinId)
+ pageState.value.appearanceForm.themeSkin = skinId
}
- function saveAppearanceSection() {
- toast('界面皮肤已应用到当前浏览器。')
+ async function saveAppearanceSection() {
+ await persistRemoteSettings('界面皮肤已保存并应用到企业配置。', {
+ preserveModelApiKeys: true,
+ preserveAdminPasswords: true,
+ preserveRenderSecret: true,
+ preserveMailPassword: true
+ })
}
async function saveLlmSection() {
diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js
index aa30453..52da9a3 100644
--- a/web/src/composables/useSystemState.js
+++ b/web/src/composables/useSystemState.js
@@ -13,6 +13,8 @@ import { setRuntimeApiBaseUrl } from '../services/api.js'
import { checkBackendHealth } from './useBackendHealth.js'
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
import { useToast } from './useToast.js'
+import { fetchSettings } from '../services/settings.js'
+import { setThemeSkin } from './useThemeSkin.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
@@ -86,68 +88,68 @@ function readStoredUsername() {
}
function buildAnonymousUser() {
- return {
- username: '',
- name: '',
- role: '',
- department: '',
- departmentName: '',
- position: '',
- grade: '',
- employeeNo: '',
- managerName: '',
- location: '',
- costCenter: '',
- financeOwnerName: '',
- riskProfile: {},
- roleCodes: [],
- email: '',
- avatar: '',
- isAdmin: false
+ return {
+ username: '',
+ name: '',
+ role: '',
+ department: '',
+ departmentName: '',
+ position: '',
+ grade: '',
+ employeeNo: '',
+ managerName: '',
+ location: '',
+ costCenter: '',
+ financeOwnerName: '',
+ riskProfile: {},
+ roleCodes: [],
+ email: '',
+ avatar: '',
+ isAdmin: false
}
}
-function buildLegacyAdminUser(username = '') {
+function buildLegacyAdminUser(username = '') {
const normalized = String(username || '').trim()
const name = normalized || DEFAULT_USER_NAME
- return {
- username: normalized,
- name,
- role: DEFAULT_USER_ROLE,
- department: '',
- departmentName: '',
- position: DEFAULT_USER_ROLE,
- grade: '',
- employeeNo: '',
- managerName: '',
- location: '',
- costCenter: '',
- financeOwnerName: '',
- riskProfile: {},
- roleCodes: ['manager'],
- email: '',
- avatar: name.slice(0, 1).toUpperCase(),
- isAdmin: true
- }
-}
-
-function resolvePlatformAdminFlag(payload, roleCodes = []) {
- const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
- const role = String(payload?.role || '').trim().toLowerCase()
- const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
-
- return (
- Boolean(payload?.isAdmin)
- || username === 'admin'
- || role === 'admin'
- || role === '管理员'
- || role === '系统管理员'
- || normalizedRoleCodes.includes('admin')
- )
-}
-
-function readStoredUser() {
+ return {
+ username: normalized,
+ name,
+ role: DEFAULT_USER_ROLE,
+ department: '',
+ departmentName: '',
+ position: DEFAULT_USER_ROLE,
+ grade: '',
+ employeeNo: '',
+ managerName: '',
+ location: '',
+ costCenter: '',
+ financeOwnerName: '',
+ riskProfile: {},
+ roleCodes: ['manager'],
+ email: '',
+ avatar: name.slice(0, 1).toUpperCase(),
+ isAdmin: true
+ }
+}
+
+function resolvePlatformAdminFlag(payload, roleCodes = []) {
+ const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
+ const role = String(payload?.role || '').trim().toLowerCase()
+ const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
+
+ return (
+ Boolean(payload?.isAdmin)
+ || username === 'admin'
+ || role === 'admin'
+ || role === '管理员'
+ || role === '系统管理员'
+ || normalizedRoleCodes.includes('admin')
+ )
+}
+
+function readStoredUser() {
if (typeof window === 'undefined') {
return buildAnonymousUser()
}
@@ -162,26 +164,26 @@ function readStoredUser() {
const name = String(payload.name || username || DEFAULT_USER_NAME).trim()
const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : []
- return {
- username,
- name,
- role: String(payload.role || DEFAULT_USER_ROLE),
- department: String(payload.department || payload.departmentName || ''),
- departmentName: String(payload.departmentName || payload.department || ''),
- position: String(payload.position || ''),
- grade: String(payload.grade || ''),
- employeeNo: String(payload.employeeNo || payload.employee_no || ''),
- managerName: String(payload.managerName || payload.manager_name || ''),
- location: String(payload.location || ''),
- costCenter: String(payload.costCenter || payload.cost_center || ''),
- financeOwnerName: String(payload.financeOwnerName || payload.finance_owner_name || ''),
- riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
- roleCodes,
- email: String(payload.email || ''),
- avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
- isAdmin: resolvePlatformAdminFlag(payload, roleCodes)
- }
- }
+ return {
+ username,
+ name,
+ role: String(payload.role || DEFAULT_USER_ROLE),
+ department: String(payload.department || payload.departmentName || ''),
+ departmentName: String(payload.departmentName || payload.department || ''),
+ position: String(payload.position || ''),
+ grade: String(payload.grade || ''),
+ employeeNo: String(payload.employeeNo || payload.employee_no || ''),
+ managerName: String(payload.managerName || payload.manager_name || ''),
+ location: String(payload.location || ''),
+ costCenter: String(payload.costCenter || payload.cost_center || ''),
+ financeOwnerName: String(payload.financeOwnerName || payload.finance_owner_name || ''),
+ riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
+ roleCodes,
+ email: String(payload.email || ''),
+ avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
+ isAdmin: resolvePlatformAdminFlag(payload, roleCodes)
+ }
+ }
} catch {
return buildLegacyAdminUser(readStoredUsername())
}
@@ -359,6 +361,15 @@ export function installSessionNavigation(router) {
.then((state) => {
applyBootstrapState(state)
setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state))
+ fetchSettings()
+ .then((snapshot) => {
+ if (snapshot?.appearanceForm?.themeSkin) {
+ setThemeSkin(snapshot.appearanceForm.themeSkin)
+ }
+ })
+ .catch((error) => {
+ console.warn('Failed to load remote theme settings:', error)
+ })
router.isReady().then(() => reconcileEntryRoute(router))
})
.catch(() => {
@@ -624,14 +635,14 @@ async function handleLogin(credentials) {
password: credentials.password
})
- const responseUser = response?.user || buildAnonymousUser()
- const responseRoleCodes = Array.isArray(responseUser.roleCodes) ? responseUser.roleCodes.filter(Boolean) : []
- const user = {
- ...responseUser,
- roleCodes: responseRoleCodes,
- isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
- }
- loggedIn.value = true
+ const responseUser = response?.user || buildAnonymousUser()
+ const responseRoleCodes = Array.isArray(responseUser.roleCodes) ? responseUser.roleCodes.filter(Boolean) : []
+ const user = {
+ ...responseUser,
+ roleCodes: responseRoleCodes,
+ isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
+ }
+ loggedIn.value = true
persistAuthState(true, user)
currentUser.value = user
touchAuthActivity(true)
diff --git a/web/src/data/personalWorkbench.js b/web/src/data/personalWorkbench.js
index 60616fe..895ec6f 100644
--- a/web/src/data/personalWorkbench.js
+++ b/web/src/data/personalWorkbench.js
@@ -207,7 +207,7 @@ export function buildExpenseStatItems(summary = {}) {
]
}
-/** 费用画像:待后端接入后替换为真实用户行为统计 */
+/** 用户画像历史示例数据:首页已切换为后端真实画像,仅保留给旧演示入口兜底。 */
export const usageProfileMetrics = [
{
key: 'stay-duration',
diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js
index 81b86a6..a4e9efd 100644
--- a/web/src/services/reimbursements.js
+++ b/web/src/services/reimbursements.js
@@ -32,6 +32,18 @@ export function fetchEmployeeLatestProfile(employeeId, params = {}) {
return apiRequest(`/employee-profiles/${encodeURIComponent(String(employeeId || '').trim())}/latest${suffix}`)
}
+export function fetchCurrentEmployeeLatestProfile(params = {}) {
+ const query = new URLSearchParams()
+ Object.entries(params || {}).forEach(([key, value]) => {
+ const normalized = String(value ?? '').trim()
+ if (normalized) {
+ query.set(key, normalized)
+ }
+ })
+ const suffix = query.toString() ? `?${query.toString()}` : ''
+ return apiRequest(`/employee-profiles/me/latest${suffix}`)
+}
+
export function updateExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, {
method: 'PATCH',
diff --git a/web/src/utils/employeeProfileViewModel.js b/web/src/utils/employeeProfileViewModel.js
new file mode 100644
index 0000000..8202ba4
--- /dev/null
+++ b/web/src/utils/employeeProfileViewModel.js
@@ -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')
+}
diff --git a/web/src/utils/loginEntryTransition.js b/web/src/utils/loginEntryTransition.js
new file mode 100644
index 0000000..086bab0
--- /dev/null
+++ b/web/src/utils/loginEntryTransition.js
@@ -0,0 +1,35 @@
+const LOGIN_ENTRY_TRANSITION_KEY = 'x-financial-login-entry-transition'
+const LOGIN_ENTRY_TRANSITION_MAX_AGE_MS = 5000
+
+function canUseSessionStorage() {
+ return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined'
+}
+
+export function markLoginEntryTransition() {
+ if (!canUseSessionStorage()) {
+ return
+ }
+
+ window.sessionStorage.setItem(LOGIN_ENTRY_TRANSITION_KEY, String(Date.now()))
+}
+
+export function consumeLoginEntryTransition() {
+ if (!canUseSessionStorage()) {
+ return false
+ }
+
+ const rawTimestamp = window.sessionStorage.getItem(LOGIN_ENTRY_TRANSITION_KEY)
+ window.sessionStorage.removeItem(LOGIN_ENTRY_TRANSITION_KEY)
+
+ if (!rawTimestamp) {
+ return false
+ }
+
+ const timestamp = Number(rawTimestamp)
+
+ if (!Number.isFinite(timestamp)) {
+ return true
+ }
+
+ return Date.now() - timestamp <= LOGIN_ENTRY_TRANSITION_MAX_AGE_MS
+}
diff --git a/web/src/utils/settingsModelHelper.js b/web/src/utils/settingsModelHelper.js
index e25059c..3033b48 100644
--- a/web/src/utils/settingsModelHelper.js
+++ b/web/src/utils/settingsModelHelper.js
@@ -206,6 +206,9 @@ export function buildDefaultState(companyProfile, currentUser) {
recordNumber: '',
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
},
+ appearanceForm: {
+ themeSkin: 'sky'
+ },
adminForm: {
adminAccount,
adminEmail,
@@ -310,6 +313,7 @@ export function mergeState(baseState, overrideState) {
return {
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
+ appearanceForm: { ...baseState.appearanceForm, ...(overrideState?.appearanceForm || {}) },
adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) },
sessionForm: { ...baseState.sessionForm, ...(overrideState?.sessionForm || {}) },
hermesForm: mergeHermesEmployeeForm({
@@ -326,6 +330,7 @@ export function mergeState(baseState, overrideState) {
export function sanitizeForStorage(state) {
return {
companyForm: { ...state.companyForm },
+ appearanceForm: { ...state.appearanceForm },
adminForm: {
...state.adminForm,
newPassword: '',
diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue
index 1cfc2fe..0b18098 100644
--- a/web/src/views/AppShellRouteView.vue
+++ b/web/src/views/AppShellRouteView.vue
@@ -1,6 +1,27 @@
-
+
+
+
+
+
+
+
+
+ 登录成功
+ 正在进入工作台
+
+
+
+
+
-
-
+
@@ -67,112 +67,97 @@
-
-
-
-
+
+
+
+
+
-
-
-
- | 编制时间 |
- 编制人 |
- 审核人 |
- 费用类型 |
- 预算金额(元) |
- 已发生(元) |
- 已占用(元) |
- 剩余可用(元) |
- 使用率 |
- 提醒阈值 |
- 告警阈值 |
- 风险阈值 |
-
-
-
-
- | {{ row.compiledAt }} |
- {{ row.compiler }} |
- {{ row.reviewer }} |
- {{ row.expenseType }} |
- {{ row.total }} |
- {{ row.used }} |
- {{ row.occupied }} |
- {{ row.left }} |
-
-
- |
-
- {{ row.reminderLine }}
- |
-
- {{ row.alertLine }}
- |
-
- {{ row.riskLine }}
- |
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.reminderLine }}
+
+
+
+
+ {{ row.alertLine }}
+
+
+
+
+ {{ row.riskLine }}
+
+
+