From 20cb60e2476ce21b57249ba337ab9d489c9d6547 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 3 Jun 2026 14:59:55 +0800 Subject: [PATCH] feat(workbench): add expense stats detail modal --- .../工作台费用统计详情弹窗/CONCEPT.md | 106 +++ .../工作台费用统计详情弹窗/TODO.md | 26 + .../styles/components/personal-workbench.css | 14 +- .../business/ExpenseStatsDetailModal.vue | 605 ++++++++++++++++++ .../components/business/PersonalWorkbench.vue | 24 +- web/src/utils/workbenchSummary.js | 232 +++++++ web/tests/expense-stats-detail-modal.test.mjs | 23 + .../personal-workbench-assistant.test.mjs | 16 + web/tests/workbench-summary.test.mjs | 8 + 9 files changed, 1047 insertions(+), 7 deletions(-) create mode 100644 document/development/工作台费用统计详情弹窗/CONCEPT.md create mode 100644 document/development/工作台费用统计详情弹窗/TODO.md create mode 100644 web/src/components/business/ExpenseStatsDetailModal.vue create mode 100644 web/tests/expense-stats-detail-modal.test.mjs diff --git a/document/development/工作台费用统计详情弹窗/CONCEPT.md b/document/development/工作台费用统计详情弹窗/CONCEPT.md new file mode 100644 index 0000000..5a9b240 --- /dev/null +++ b/document/development/工作台费用统计详情弹窗/CONCEPT.md @@ -0,0 +1,106 @@ +# 工作台费用统计详情弹窗概念文档 + +## 功能一句话 + +在个人工作台的“费用统计”卡片中提供本地弹窗详情,让用户直接查看历史费用分布、单据处理时间和系统操作明细。 + +## 背景与问题 + +当前“费用统计”右上角的“查看详情”会进入助手问答,不符合用户期望的“像用户画像一样直接看详情”的操作方式。费用进度区域也存在两个可见性问题:右上角“全部进度”按钮没有实际承载完整列表能力,10 日以上分割标识靠左且不醒目。 + +本次调整需要让工作台成为个人费用操作的直接入口:用户不离开首页即可理解自己的费用结构、单据流转时间和近期系统动作。 + +## 目标与非目标 + +目标: + +- 移除“费用进度”右上角的“全部进度”按钮,减少无效操作。 +- 将“10日以上”分割标识放在分割线中间,并使用更醒目的主题强调色。 +- 将“费用统计”的“查看详情”改为打开详情弹窗。 +- 弹窗展示历史报销费用分布、单据处理时间和系统操作详情。 +- 数据优先来自 `buildWorkbenchSummary` 已有的当前用户单据汇总,不新增后端接口。 + +非目标: + +- 不新增后端 API。 +- 不改变报销单据审批状态计算规则。 +- 不替代用户画像详情弹窗。 +- 不做复杂图表库接入,避免为了一个工作台弹窗扩大依赖和维护面。 + +## 用户与场景 + +主要用户是个人员工和经常处理报销的业务人员。典型场景: + +- 在首页查看本月报销情况后,想进一步确认自己的历史费用主要花在哪些类别。 +- 想知道近期单据从创建到当前状态大概处理了多久。 +- 想复盘系统里最近需要处理或已提醒的费用相关动作。 + +## 功能能力 + +### 费用分布 + +按单据标题、场景或备注归类费用类型,统计每类金额、单据数量和金额占比。若数据不足,展示空状态。 + +### 处理时间 + +按单据创建、提交、更新或进度步骤时间推断处理耗时,输出可读的耗时文案,并展示当前状态和节点数量。 + +### 操作详情 + +基于待办、通知和进度项生成系统操作明细,帮助用户理解最近有哪些费用动作需要关注。 + +## 方案设计 + +前端实现: + +- 在 `workbenchSummary.js` 中新增 `expenseStatsDetail` 汇总结构。 +- 新增 `ExpenseStatsDetailModal.vue`,复用 Element Plus `ElDialog`、`ElButton`、`ElTag` 的企业后台弹窗体验。 +- 在 `PersonalWorkbench.vue` 中接入弹窗状态,费用统计“查看详情”只打开弹窗。 +- 调整 `personal-workbench.css` 中长时间分割标识的居中与强调样式。 + +数据结构: + +```js +expenseStatsDetail: { + distributionRows: [], + processingRows: [], + operationRows: [] +} +``` + +## 算法与公式 + +费用类型金额占比: + +$$ +percent_i = \frac{amount_i}{\sum_{k=1}^{n} amount_k} \times 100 +$$ + +单据处理耗时: + +$$ +duration = latestTime - firstTime +$$ + +其中 `firstTime` 优先取单据创建时间、提交时间或最早进度步骤时间,`latestTime` 优先取更新时间或最新进度步骤时间。 + +## 测试方案 + +- 源码测试确认费用进度不再渲染“全部进度”按钮。 +- 源码测试确认“费用统计”的“查看详情”打开弹窗而不是进入助手。 +- 单元测试确认 `buildWorkbenchSummary` 能生成费用分布、处理时间和操作明细。 +- 源码测试确认弹窗包含费用分布、处理时间和系统操作详情区块。 +- 运行前端构建验证组件编译通过。 + +## 指标与验收 + +- “10日以上”标识位于分割线中间,且使用主题强调色。 +- “费用进度”卡片右上角不再出现“全部进度”。 +- 点击“费用统计”的“查看详情”打开详情弹窗。 +- 弹窗至少包含费用分布、处理时间、系统操作详情三个信息区。 +- 相关测试与前端构建通过。 + +## 风险与开放问题 + +- 当前数据来自工作台前端汇总,历史维度受首页已加载单据范围影响;若后续需要跨年或分页全量统计,应补后端专用接口。 +- 单据类型归类依赖标题、场景和备注,属于前端轻量归类;后续可与 ontology 费用类别字段打通。 diff --git a/document/development/工作台费用统计详情弹窗/TODO.md b/document/development/工作台费用统计详情弹窗/TODO.md new file mode 100644 index 0000000..0edf1b7 --- /dev/null +++ b/document/development/工作台费用统计详情弹窗/TODO.md @@ -0,0 +1,26 @@ +# 工作台费用统计详情弹窗 TODO + +## 调研与契约 + +- [x] 核对 `PersonalWorkbench.vue`、工作台样式和现有用户画像弹窗结构。[CONCEPT: 方案设计] 证据:已确认工作台入口、`ExpenseProfileDetailModal.vue` 弹窗模式和 `personal-workbench.css` 分割样式。 +- [x] 明确费用详情弹窗的数据结构,并限制为前端工作台汇总数据。[CONCEPT: 功能能力] 证据:采用 `expenseStatsDetail`,由 `buildWorkbenchSummary` 基于当前用户单据生成。 + +## 前端实现 + +- [x] 移除费用进度卡片右上角“全部进度”按钮。[CONCEPT: 目标与非目标] 证据:`PersonalWorkbench.vue` 的费用进度标题区已移除该按钮。 +- [x] 调整“10日以上”分割标识为居中、醒目主题色样式。[CONCEPT: 指标与验收] 证据:`personal-workbench.css` 使用 `left: 50%`、`transform: translateX(-50%)` 和主题强调色。 +- [x] 在 `workbenchSummary.js` 生成费用分布、处理时间、系统操作详情数据。[CONCEPT: 算法与公式] 证据:新增 `expenseStatsDetail` 汇总结构。 +- [x] 新增费用统计详情弹窗组件,展示三个详情区块和空状态。[CONCEPT: 功能能力] 证据:新增 `ExpenseStatsDetailModal.vue`。 +- [x] 在 `PersonalWorkbench.vue` 接入弹窗状态与费用统计“查看详情”按钮。[CONCEPT: 方案设计] 证据:新增 `expenseStatsModalOpen` 与 `openExpenseStatsModal`。 + +## 测试与验证 + +- [x] 补充工作台源码测试,覆盖按钮移除、弹窗接入和分割标识样式。[CONCEPT: 测试方案] 证据:`node web/tests/personal-workbench-assistant.test.mjs` 通过。 +- [x] 补充工作台汇总单元测试,覆盖详情数据生成。[CONCEPT: 测试方案] 证据:`node web/tests/workbench-summary.test.mjs` 通过。 +- [x] 补充弹窗源码测试,覆盖费用分布、处理时间、系统操作详情区块。[CONCEPT: 测试方案] 证据:`node web/tests/expense-stats-detail-modal.test.mjs` 通过。 +- [x] 运行前端定向测试和构建验证。[CONCEPT: 指标与验收] 证据:以上定向测试和 `npm.cmd --prefix web run build` 均通过。 + +## 交付 + +- [x] 复查本次暂存范围,避免纳入无关工作区改动。[CONCEPT: 风险与开放问题] 证据:`git diff --cached --name-only` 仅包含本次工作台弹窗、样式、汇总测试和开发文档。 +- [ ] 提交并 push 本次功能分支。[CONCEPT: 指标与验收] diff --git a/web/src/assets/styles/components/personal-workbench.css b/web/src/assets/styles/components/personal-workbench.css index 4874722..fa4ae32 100644 --- a/web/src/assets/styles/components/personal-workbench.css +++ b/web/src/assets/styles/components/personal-workbench.css @@ -574,14 +574,18 @@ content: "10日以上"; position: absolute; top: -9px; - left: 0; + left: 50%; z-index: 1; display: inline-flex; align-items: center; height: 18px; - padding-right: 8px; - background: var(--workbench-surface); - color: var(--workbench-muted); + padding: 0 10px; + transform: translateX(-50%); + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28); + border-radius: 4px; + background: color-mix(in srgb, var(--theme-primary) 11%, #ffffff); + box-shadow: 0 4px 10px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12); + color: var(--theme-primary-active); font-size: 11px; font-weight: 850; line-height: 1; @@ -595,7 +599,7 @@ left: 0; right: 0; height: 1px; - background: var(--workbench-line-soft); + background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.26); pointer-events: none; } diff --git a/web/src/components/business/ExpenseStatsDetailModal.vue b/web/src/components/business/ExpenseStatsDetailModal.vue new file mode 100644 index 0000000..e226328 --- /dev/null +++ b/web/src/components/business/ExpenseStatsDetailModal.vue @@ -0,0 +1,605 @@ + + + + + diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index 3d208cc..ac1c12e 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -194,7 +194,6 @@

费用进度

-
@@ -248,7 +247,9 @@
+ + import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import PanelHead from '../shared/PanelHead.vue' +import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue' import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue' import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp' import { useSystemState } from '../../composables/useSystemState.js' @@ -392,6 +402,7 @@ const { }) const latestExpenseConversation = ref(null) const hasLocalExpenseSnapshot = ref(false) +const expenseStatsModalOpen = ref(false) const expenseProfileModalOpen = ref(false) const employeeProfile = ref(null) const employeeProfileRuns = ref([]) @@ -451,6 +462,7 @@ const expenseProfileOperations = computed(() => buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value) ) const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.empty_reason || '').trim()) +const expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {}) const currentUserProfileKey = computed(() => { const user = currentUser.value || {} return [ @@ -700,6 +712,14 @@ async function loadCurrentEmployeeProfile() { employeeProfileLoading.value = false } +function openExpenseStatsModal() { + expenseStatsModalOpen.value = true +} + +function closeExpenseStatsModal() { + expenseStatsModalOpen.value = false +} + function openExpenseProfileModal() { expenseProfileModalOpen.value = true if (!employeeProfile.value && !employeeProfileLoading.value) { diff --git a/web/src/utils/workbenchSummary.js b/web/src/utils/workbenchSummary.js index 74c3837..4777731 100644 --- a/web/src/utils/workbenchSummary.js +++ b/web/src/utils/workbenchSummary.js @@ -64,6 +64,61 @@ function formatCurrency(value) { }).format(parseNumber(value)) } +function formatPercent(value) { + const percent = Number(value) + if (!Number.isFinite(percent)) { + return '0%' + } + + return `${percent.toFixed(percent >= 10 || percent === 0 ? 0 : 1)}%` +} + +function padDatePart(value) { + return String(value).padStart(2, '0') +} + +function formatDateTimeLabel(value) { + if (value instanceof Date) { + return [ + value.getFullYear(), + padDatePart(value.getMonth() + 1), + padDatePart(value.getDate()) + ].join('-') + ` ${padDatePart(value.getHours())}:${padDatePart(value.getMinutes())}` + } + + const text = normalizeText(value) + if (!text) { + return '暂无时间' + } + + const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text) + if (match) { + return match[4] ? `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[1]}-${match[2]}-${match[3]}` + } + + return text +} + +function formatDurationLabel(milliseconds) { + const duration = Number(milliseconds) + if (!Number.isFinite(duration) || duration <= 0) { + return '暂无耗时' + } + + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + + if (duration < hour) { + return `${Math.max(1, Math.round(duration / minute))}分钟` + } + if (duration < day) { + return `${Math.max(1, Math.round(duration / hour))}小时` + } + + return `${Math.ceil(duration / day)}天` +} + function resolveRequestIdentity(request) { return normalizeText(request?.claimNo || request?.claim_no || request?.id || request?.claimId) } @@ -232,6 +287,177 @@ function buildNotifications(todoItems, progressItems) { return [...todoNotifications, ...progressNotifications] } +function resolveExpenseCategory(request) { + const explicitCategory = normalizeText( + request?.expenseCategory + || request?.expense_category + || request?.category + || request?.expenseType + || request?.expense_type + || request?.type + ) + if (explicitCategory) { + return explicitCategory + } + + const text = normalizeText([ + request?.sceneLabel, + request?.title, + request?.note, + request?.description + ].filter(Boolean).join(' ')) + + if (/差旅|出差|交通|机票|火车|高铁|出租|网约车|住宿|酒店/i.test(text)) { + return '差旅交通' + } + if (/招待|客户|餐饮|宴请|接待/i.test(text)) { + return '业务招待' + } + if (/办公|采购|用品|设备|耗材/i.test(text)) { + return '办公采购' + } + if (/培训|学习|课程|会议/i.test(text)) { + return '培训会议' + } + if (/市场|活动|推广|宣传/i.test(text)) { + return '市场活动' + } + + return '其他费用' +} + +function buildExpenseDistributionRows(ownedRequests) { + const groups = new Map() + + for (const request of ownedRequests) { + const category = resolveExpenseCategory(request) + const amount = parseNumber(request?.amount) + const group = groups.get(category) || { + key: category, + label: category, + count: 0, + amount: 0 + } + + group.count += 1 + group.amount += amount + groups.set(category, group) + } + + const totalAmount = Array.from(groups.values()).reduce((sum, item) => sum + item.amount, 0) + const rows = Array.from(groups.values()) + .sort((left, right) => right.amount - left.amount || right.count - left.count) + .slice(0, 6) + + return rows.map((item) => { + const percent = totalAmount > 0 ? (item.amount / totalAmount) * 100 : 0 + return { + ...item, + amountLabel: formatCurrency(item.amount), + percent, + percentLabel: formatPercent(percent) + } + }) +} + +function collectRequestDates(request) { + const candidates = [ + request?.createdAt, + request?.submittedAt, + request?.updatedAt, + request?.applyTime, + request?.occurredAt + ] + + const steps = Array.isArray(request?.progressSteps) ? request.progressSteps : [] + for (const step of steps) { + candidates.push(step?.time, step?.createdAt, step?.updatedAt, step?.timestamp) + } + + return candidates + .map((item) => toDate(item)) + .filter(Boolean) + .sort((left, right) => left.getTime() - right.getTime()) +} + +function buildExpenseProcessingRows(ownedRequests) { + return ownedRequests + .map((request) => { + const dates = collectRequestDates(request) + const requestId = resolveRequestIdentity(request) + const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据' + const startedAt = dates[0] || toDate(request?.createdAt || request?.submittedAt) + const latestAt = dates[dates.length - 1] || toDate(request?.updatedAt || request?.submittedAt || request?.createdAt) + const stepCount = Array.isArray(request?.progressSteps) ? request.progressSteps.length : 0 + + return { + id: requestId || title, + requestId, + title, + status: normalizeText(request?.approvalStatus || request?.status) || '处理中', + statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)), + startedAt: startedAt ? formatDateTimeLabel(startedAt) : '暂无开始时间', + updatedAt: latestAt ? formatDateTimeLabel(latestAt) : '暂无更新时间', + durationLabel: startedAt && latestAt ? formatDurationLabel(latestAt.getTime() - startedAt.getTime()) : '暂无耗时', + stepCount, + sortTime: latestAt ? latestAt.getTime() : 0 + } + }) + .sort((left, right) => right.sortTime - left.sortTime) + .slice(0, 6) + .map(({ sortTime, ...item }) => item) +} + +function buildExpenseOperationRows(todoItems, notifications, progressItems) { + const rows = [] + + for (const item of todoItems) { + rows.push({ + id: `todo:${item.requestId || item.description}`, + action: item.status || item.title, + detail: item.description, + time: item.due, + tone: item.statusTone || 'info', + source: '待办' + }) + } + + for (const item of notifications) { + rows.push({ + id: `notice:${item.id}`, + action: item.title, + detail: item.description, + time: item.time, + tone: item.tone || 'info', + source: item.unread ? '未读提醒' : '系统提醒' + }) + } + + for (const item of progressItems.slice(0, 6)) { + rows.push({ + id: `progress:${item.requestId || item.id}`, + action: item.status, + detail: `${item.requestId || item.id || '单据'} · ${item.title}`, + time: item.updatedAt || '最近更新', + tone: item.statusTone || 'info', + source: '进度' + }) + } + + const seen = new Set() + return rows + .filter((item) => { + const key = [item.action, item.detail, item.time].join('|') + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) + .sort((left, right) => normalizeText(right.time).localeCompare(normalizeText(left.time))) + .slice(0, 8) +} + export function buildWorkbenchSummary(requests, currentUser) { const ownedRequests = Array.isArray(requests) ? requests.filter((item) => belongsToCurrentUser(item, currentUser)) @@ -254,6 +480,11 @@ export function buildWorkbenchSummary(requests, currentUser) { const todoItems = buildTodoItems(ownedRequests) const progressItems = buildProgressItems(ownedRequests) const notifications = buildNotifications(todoItems, progressItems) + const expenseStatsDetail = { + distributionRows: buildExpenseDistributionRows(ownedRequests), + processingRows: buildExpenseProcessingRows(ownedRequests), + operationRows: buildExpenseOperationRows(todoItems, notifications, progressItems) + } return { monthlyCount, @@ -270,6 +501,7 @@ export function buildWorkbenchSummary(requests, currentUser) { todoItems, progressItems, notifications, + expenseStatsDetail, unreadNotificationCount: notifications.filter((item) => item.unread).length } } diff --git a/web/tests/expense-stats-detail-modal.test.mjs b/web/tests/expense-stats-detail-modal.test.mjs new file mode 100644 index 0000000..f06978e --- /dev/null +++ b/web/tests/expense-stats-detail-modal.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const modal = readFileSync( + fileURLToPath(new URL('../src/components/business/ExpenseStatsDetailModal.vue', import.meta.url)), + 'utf8' +) + +test('expense stats detail modal exposes distribution, processing time and operation detail sections', () => { + assert.match(modal, /Expense Operation Details/) + assert.match(modal, /历史报销费用分布/) + assert.match(modal, /单据处理时间/) + assert.match(modal, /系统操作详情/) + assert.match(modal, /ElDialog/) + assert.match(modal, /ElTag/) + assert.match(modal, /distributionRows/) + assert.match(modal, /processingRows/) + assert.match(modal, /operationRows/) + assert.match(modal, /--expense-detail-percent/) + assert.match(modal, /统计口径来自当前工作台已加载的个人单据/) +}) diff --git a/web/tests/personal-workbench-assistant.test.mjs b/web/tests/personal-workbench-assistant.test.mjs index fb19243..6a8240c 100644 --- a/web/tests/personal-workbench-assistant.test.mjs +++ b/web/tests/personal-workbench-assistant.test.mjs @@ -152,8 +152,24 @@ test('workbench progress rows show update time first', () => { assert.match(workbench, /