feat(workbench): add expense stats detail modal

This commit is contained in:
caoxiaozhu
2026-06-03 14:59:55 +08:00
parent 3130c42d76
commit 20cb60e247
9 changed files with 1047 additions and 7 deletions

View File

@@ -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 费用类别字段打通。

View File

@@ -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: 指标与验收]

View File

@@ -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;
}

View File

@@ -0,0 +1,605 @@
<template>
<ElDialog
:model-value="visible"
append-to-body
align-center
width="min(980px, calc(100vw - 48px))"
:show-close="false"
:lock-scroll="true"
class="expense-stats-detail-dialog"
modal-class="expense-stats-detail-overlay"
body-class="expense-stats-detail-body"
transition="expense-stats-detail-zoom"
aria-labelledby="expense-stats-detail-title"
@update:model-value="handleVisibleChange"
>
<template #header>
<header class="expense-stats-detail-header">
<div class="expense-stats-detail-title-block">
<span class="expense-stats-detail-eyebrow">Expense Operation Details</span>
<h2 id="expense-stats-detail-title">{{ userName }}的费用统计详情</h2>
<p>汇总历史报销分布单据处理时间和近期系统操作</p>
</div>
<ElButton
class="expense-stats-detail-close"
text
aria-label="关闭费用统计详情"
@click="emitClose"
>
<i class="mdi mdi-close"></i>
</ElButton>
</header>
</template>
<section class="expense-stats-detail-content" aria-label="费用统计详情">
<section class="expense-stats-summary-grid" aria-label="费用统计摘要">
<article v-for="metric in summaryMetrics" :key="metric.key" class="expense-stats-summary-item">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}<small>{{ metric.unit }}</small></strong>
<em>{{ metric.hint }}</em>
</article>
</section>
<div class="expense-stats-analysis-grid">
<section class="expense-stats-panel expense-stats-distribution-panel" aria-label="历史报销费用分布">
<div class="expense-stats-section-title">
<div>
<span>历史报销费用分布</span>
<small>按费用类型聚合金额和笔数</small>
</div>
</div>
<div v-if="distributionRows.length" class="expense-distribution-list">
<article
v-for="row in distributionRows"
:key="row.key"
class="expense-distribution-row"
:style="{ '--expense-detail-percent': `${Math.max(4, row.percent || 0)}%` }"
>
<div class="expense-distribution-copy">
<strong>{{ row.label }}</strong>
<span>{{ row.count }} · {{ row.amountLabel }}</span>
</div>
<div class="expense-distribution-track" aria-hidden="true">
<span></span>
</div>
<em>{{ row.percentLabel }}</em>
</article>
</div>
<p v-else class="expense-stats-empty">暂无历史报销费用分布</p>
</section>
<section class="expense-stats-panel expense-stats-processing-panel" aria-label="单据处理时间">
<div class="expense-stats-section-title">
<div>
<span>单据处理时间</span>
<small>按最近更新的单据排序</small>
</div>
</div>
<div v-if="processingRows.length" class="expense-processing-list">
<article v-for="row in processingRows" :key="row.id" class="expense-processing-row">
<div class="expense-processing-main">
<strong>{{ row.requestId || row.id }}</strong>
<span>{{ row.title }}</span>
</div>
<div class="expense-processing-meta">
<span>{{ row.startedAt }} {{ row.updatedAt }}</span>
<em>{{ row.stepCount }} 个节点</em>
</div>
<ElTag class="expense-processing-status" :type="resolveTagType(row.statusTone)" effect="light">
{{ row.status }}
</ElTag>
<b>{{ row.durationLabel }}</b>
</article>
</div>
<p v-else class="expense-stats-empty">暂无可计算处理时间的单据</p>
</section>
</div>
<section class="expense-stats-panel expense-stats-operation-panel" aria-label="系统操作详情">
<div class="expense-stats-section-title">
<div>
<span>系统操作详情</span>
<small>待办提醒和费用进度的近期操作记录</small>
</div>
</div>
<div v-if="operationRows.length" class="expense-operation-list">
<article v-for="row in operationRows" :key="row.id" class="expense-operation-row">
<time>{{ row.time }}</time>
<div class="expense-operation-copy">
<strong>{{ row.action }}</strong>
<span>{{ row.detail }}</span>
</div>
<ElTag class="expense-operation-source" :type="resolveTagType(row.tone)" effect="light">
{{ row.source }}
</ElTag>
</article>
</div>
<p v-else class="expense-stats-empty">暂无费用系统操作记录</p>
</section>
</section>
<template #footer>
<footer class="expense-stats-detail-footer">
<span>统计口径来自当前工作台已加载的个人单据用于操作参考</span>
</footer>
</template>
</ElDialog>
</template>
<script setup>
import { computed } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
const props = defineProps({
visible: { type: Boolean, default: false },
userName: { type: String, default: '同事' },
summary: { type: Object, default: () => ({}) },
detail: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close'])
const summaryMetrics = computed(() => [
{
key: 'total-amount',
label: '累计报销金额',
value: props.summary.totalAmountLabel || '¥0',
unit: '',
hint: `${props.summary.totalCount ?? 0} 笔历史单据`
},
{
key: 'monthly-amount',
label: '本月报销金额',
value: props.summary.monthlyAmountLabel || '¥0',
unit: '',
hint: `${props.summary.monthlyCount ?? 0} 笔本月单据`
},
{
key: 'in-review',
label: '审批中单据',
value: props.summary.inReviewCount ?? 0,
unit: '笔',
hint: '等待流程节点处理'
},
{
key: 'pending-payment',
label: '待付款单据',
value: props.summary.pendingPaymentCount ?? 0,
unit: '笔',
hint: '财务付款前状态'
}
])
const distributionRows = computed(() => Array.isArray(props.detail.distributionRows) ? props.detail.distributionRows : [])
const processingRows = computed(() => Array.isArray(props.detail.processingRows) ? props.detail.processingRows : [])
const operationRows = computed(() => Array.isArray(props.detail.operationRows) ? props.detail.operationRows : [])
function emitClose() {
emit('close')
}
function handleVisibleChange(value) {
if (!value) {
emitClose()
}
}
function resolveTagType(tone) {
const normalized = String(tone || '').trim()
if (['success', 'positive', 'emerald'].includes(normalized)) {
return 'success'
}
if (['warning', 'risk', 'amber'].includes(normalized)) {
return 'warning'
}
if (['danger', 'high'].includes(normalized)) {
return 'danger'
}
return 'info'
}
</script>
<style scoped>
:global(.expense-stats-detail-overlay) {
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.34), rgba(15, 23, 42, 0.4)),
rgba(15, 23, 42, 0.36);
}
:global(.expense-stats-detail-dialog.el-dialog) {
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 4px;
background: #ffffff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.2);
}
:global(.expense-stats-detail-dialog .el-dialog__header),
:global(.expense-stats-detail-dialog .expense-stats-detail-body),
:global(.expense-stats-detail-dialog .el-dialog__footer) {
padding: 0;
margin: 0;
}
:global(.expense-stats-detail-zoom-enter-active),
:global(.expense-stats-detail-zoom-leave-active) {
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1);
}
:global(.expense-stats-detail-zoom-enter-active .expense-stats-detail-dialog) {
animation: expenseStatsDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both;
}
:global(.expense-stats-detail-zoom-leave-active .expense-stats-detail-dialog) {
animation: expenseStatsDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
:global(.expense-stats-detail-zoom-enter-from),
:global(.expense-stats-detail-zoom-leave-to) {
opacity: 0;
}
.expense-stats-detail-header,
.expense-stats-detail-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
background: #ffffff;
}
.expense-stats-detail-header {
border-bottom: 1px solid #e2e8f0;
}
.expense-stats-detail-footer {
justify-content: flex-start;
border-top: 1px solid #e2e8f0;
}
.expense-stats-detail-title-block {
min-width: 0;
}
.expense-stats-detail-eyebrow,
.expense-stats-section-title small {
color: #64748b;
font-size: 10px;
font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
.expense-stats-detail-header h2 {
margin: 3px 0 4px;
color: #0f172a;
font-size: 19px;
line-height: 1.25;
font-weight: 850;
}
.expense-stats-detail-header p,
.expense-stats-detail-footer span {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
font-weight: 650;
}
.expense-stats-detail-close {
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
border-radius: 4px;
color: #334155;
font-size: 18px;
}
.expense-stats-detail-close:hover {
background: #eef4fb;
color: var(--theme-primary-active);
}
.expense-stats-detail-content {
max-height: min(660px, calc(100vh - 190px));
display: grid;
gap: 12px;
padding: 14px;
overflow: auto;
background: #f8fafc;
}
.expense-stats-summary-grid,
.expense-stats-analysis-grid {
display: grid;
gap: 10px;
}
.expense-stats-summary-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.expense-stats-analysis-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(360px, 1.1fr);
}
.expense-stats-summary-item,
.expense-stats-panel {
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #ffffff;
}
.expense-stats-summary-item {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 12px;
}
.expense-stats-summary-item span,
.expense-distribution-copy span,
.expense-processing-meta,
.expense-operation-row time,
.expense-operation-copy span {
color: #64748b;
font-size: 11.5px;
font-weight: 650;
}
.expense-stats-summary-item strong {
color: #0f172a;
font-size: 18px;
line-height: 1.15;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
.expense-stats-summary-item small {
margin-left: 2px;
color: #64748b;
font-size: 11px;
font-weight: 650;
}
.expense-stats-summary-item em {
overflow: hidden;
color: #94a3b8;
font-size: 11px;
font-style: normal;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-stats-panel {
min-width: 0;
display: grid;
gap: 10px;
padding: 12px;
}
.expense-stats-distribution-panel,
.expense-stats-processing-panel {
min-height: 336px;
}
.expense-stats-section-title {
display: flex;
justify-content: space-between;
gap: 10px;
}
.expense-stats-section-title > div {
min-width: 0;
display: grid;
gap: 2px;
}
.expense-stats-section-title span {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.expense-distribution-list,
.expense-processing-list,
.expense-operation-list {
display: grid;
gap: 8px;
}
.expense-distribution-row {
display: grid;
grid-template-columns: minmax(136px, 0.78fr) minmax(120px, 1fr) 44px;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid #e8eef5;
}
.expense-distribution-row:first-child,
.expense-processing-row:first-child,
.expense-operation-row:first-child {
border-top: 0;
padding-top: 0;
}
.expense-distribution-copy,
.expense-processing-main,
.expense-operation-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.expense-distribution-copy strong,
.expense-processing-main strong,
.expense-operation-copy strong {
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-distribution-track {
overflow: hidden;
height: 7px;
border-radius: 4px;
background: #e8eef5;
}
.expense-distribution-track span {
display: block;
width: var(--expense-detail-percent);
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--theme-primary), var(--theme-primary-active));
}
.expense-distribution-row em {
color: var(--theme-primary-active);
font-size: 12px;
font-style: normal;
font-weight: 850;
text-align: right;
}
.expense-processing-row {
display: grid;
grid-template-columns: minmax(124px, 0.8fr) minmax(164px, 1fr) auto 58px;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid #e8eef5;
}
.expense-processing-main span,
.expense-processing-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-processing-meta {
min-width: 0;
display: grid;
gap: 3px;
}
.expense-processing-meta em {
color: #94a3b8;
font-size: 11px;
font-style: normal;
}
.expense-processing-status,
.expense-operation-source {
border-radius: 4px;
font-weight: 800;
}
.expense-processing-row b {
color: #0f172a;
font-size: 13px;
font-weight: 850;
text-align: right;
white-space: nowrap;
}
.expense-operation-row {
display: grid;
grid-template-columns: 126px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid #e8eef5;
}
.expense-stats-empty {
margin: 0;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 700;
text-align: center;
}
@keyframes expenseStatsDialogIn {
0% {
opacity: 0;
transform: scale3d(0.94, 0.94, 1);
}
100% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
@keyframes expenseStatsDialogOut {
0% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
100% {
opacity: 0;
transform: scale3d(0.96, 0.96, 1);
}
}
@media (max-width: 860px) {
:global(.expense-stats-detail-dialog.el-dialog) {
width: calc(100vw - 24px) !important;
}
.expense-stats-summary-grid,
.expense-stats-analysis-grid {
grid-template-columns: 1fr;
}
.expense-stats-detail-content {
max-height: calc(100vh - 170px);
}
}
@media (max-width: 620px) {
.expense-processing-row,
.expense-operation-row,
.expense-distribution-row {
grid-template-columns: 1fr;
align-items: start;
}
.expense-processing-row b,
.expense-distribution-row em {
text-align: left;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.expense-stats-detail-zoom-enter-active .expense-stats-detail-dialog),
:global(.expense-stats-detail-zoom-leave-active .expense-stats-detail-dialog) {
animation-duration: 1ms !important;
}
}
</style>

View File

@@ -194,7 +194,6 @@
<article class="panel workbench-card progress-panel">
<div class="section-head">
<h2>费用进度</h2>
<button type="button" class="link-action">全部进度 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="progress-list">
@@ -248,7 +247,9 @@
<button
type="button"
class="detail-action"
@click="openPromptAssistant('查看我的费用统计详情,并说明本月报销金额、审批中和待付款的主要变化。')"
aria-haspopup="dialog"
:aria-expanded="expenseStatsModalOpen"
@click="openExpenseStatsModal"
>
<span>查看详情</span>
<i class="mdi mdi-chevron-right"></i>
@@ -308,6 +309,14 @@
</aside>
</div>
<ExpenseStatsDetailModal
:visible="expenseStatsModalOpen"
:user-name="displayUserName"
:summary="workbenchSummary"
:detail="expenseStatsDetail"
@close="closeExpenseStatsModal"
/>
<ExpenseProfileDetailModal
:visible="expenseProfileModalOpen"
:user-name="displayUserName"
@@ -327,6 +336,7 @@
<script setup>
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) {

View File

@@ -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
}
}

View File

@@ -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, /统计口径来自当前工作台已加载的个人单据/)
})

View File

@@ -152,8 +152,24 @@ test('workbench progress rows show update time first', () => {
assert.match(workbench, /<time :datetime="item\.updatedAt \|\| ''">\{\{ item\.displayTime \}\}<\/time>/)
assert.match(workbench, /displayTime: formatProgressTime\(item\?\.updatedAt\)/)
assert.match(workbench, /function formatProgressTime\(value\)/)
assert.doesNotMatch(workbench, />全部进度/)
assert.match(workbenchStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*minmax\(78px,\s*0\.44fr\)/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*content:\s*"10日以上"/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*left:\s*50%;[\s\S]*transform:\s*translateX\(-50%\);/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*color:\s*var\(--theme-primary-active\);/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::after\s*\{[\s\S]*rgba\(var\(--theme-primary-rgb/)
assert.match(workbenchStyles, /\.progress-time\s*\{[\s\S]*color:\s*var\(--workbench-muted\);/)
assert.match(workbenchResponsiveStyles, /grid-template-areas:[\s\S]*"time identity result"[\s\S]*"steps steps steps"/)
})
test('workbench expense stats detail opens a local modal instead of the assistant', () => {
assert.match(workbench, /import ExpenseStatsDetailModal from '\.\/ExpenseStatsDetailModal\.vue'/)
assert.match(workbench, /<ExpenseStatsDetailModal/)
assert.match(workbench, /const expenseStatsModalOpen = ref\(false\)/)
assert.match(workbench, /const expenseStatsDetail = computed\(\(\) => props\.workbenchSummary\.expenseStatsDetail \|\| \{\}\)/)
assert.match(workbench, /@click="openExpenseStatsModal"/)
assert.match(workbench, /:aria-expanded="expenseStatsModalOpen"/)
assert.match(workbench, /function openExpenseStatsModal\(\)/)
assert.match(workbench, /function closeExpenseStatsModal\(\)/)
assert.doesNotMatch(workbench, /查看我的费用统计详情/)
})

View File

@@ -77,4 +77,12 @@ test('workbench summary builds real user notifications and progress from request
)
assert.equal(summary.notifications.length, 1)
assert.equal(summary.unreadNotificationCount, 1)
assert.equal(summary.expenseStatsDetail.distributionRows[0].label, '差旅交通')
assert.equal(summary.expenseStatsDetail.distributionRows[0].count, 1)
assert.equal(summary.expenseStatsDetail.distributionRows[0].percentLabel, '100%')
assert.equal(summary.expenseStatsDetail.processingRows[0].requestId, 'BX-001')
assert.equal(summary.expenseStatsDetail.processingRows[0].durationLabel, '10分钟')
assert.equal(summary.expenseStatsDetail.processingRows[0].stepCount, 5)
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '待办'))
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '进度'))
})