feat(workbench): add expense stats detail modal
This commit is contained in:
106
document/development/工作台费用统计详情弹窗/CONCEPT.md
Normal file
106
document/development/工作台费用统计详情弹窗/CONCEPT.md
Normal 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 费用类别字段打通。
|
||||||
26
document/development/工作台费用统计详情弹窗/TODO.md
Normal file
26
document/development/工作台费用统计详情弹窗/TODO.md
Normal 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: 指标与验收]
|
||||||
@@ -574,14 +574,18 @@
|
|||||||
content: "10日以上";
|
content: "10日以上";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -9px;
|
top: -9px;
|
||||||
left: 0;
|
left: 50%;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding-right: 8px;
|
padding: 0 10px;
|
||||||
background: var(--workbench-surface);
|
transform: translateX(-50%);
|
||||||
color: var(--workbench-muted);
|
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-size: 11px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -595,7 +599,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--workbench-line-soft);
|
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.26);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
605
web/src/components/business/ExpenseStatsDetailModal.vue
Normal file
605
web/src/components/business/ExpenseStatsDetailModal.vue
Normal 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>
|
||||||
@@ -194,7 +194,6 @@
|
|||||||
<article class="panel workbench-card progress-panel">
|
<article class="panel workbench-card progress-panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>费用进度</h2>
|
<h2>费用进度</h2>
|
||||||
<button type="button" class="link-action">全部进度 <i class="mdi mdi-chevron-right"></i></button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-list">
|
<div class="progress-list">
|
||||||
@@ -248,7 +247,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="detail-action"
|
class="detail-action"
|
||||||
@click="openPromptAssistant('查看我的费用统计详情,并说明本月报销金额、审批中和待付款的主要变化。')"
|
aria-haspopup="dialog"
|
||||||
|
:aria-expanded="expenseStatsModalOpen"
|
||||||
|
@click="openExpenseStatsModal"
|
||||||
>
|
>
|
||||||
<span>查看详情</span>
|
<span>查看详情</span>
|
||||||
<i class="mdi mdi-chevron-right"></i>
|
<i class="mdi mdi-chevron-right"></i>
|
||||||
@@ -308,6 +309,14 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ExpenseStatsDetailModal
|
||||||
|
:visible="expenseStatsModalOpen"
|
||||||
|
:user-name="displayUserName"
|
||||||
|
:summary="workbenchSummary"
|
||||||
|
:detail="expenseStatsDetail"
|
||||||
|
@close="closeExpenseStatsModal"
|
||||||
|
/>
|
||||||
|
|
||||||
<ExpenseProfileDetailModal
|
<ExpenseProfileDetailModal
|
||||||
:visible="expenseProfileModalOpen"
|
:visible="expenseProfileModalOpen"
|
||||||
:user-name="displayUserName"
|
:user-name="displayUserName"
|
||||||
@@ -327,6 +336,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import PanelHead from '../shared/PanelHead.vue'
|
import PanelHead from '../shared/PanelHead.vue'
|
||||||
|
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
|
||||||
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||||||
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
|
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
@@ -392,6 +402,7 @@ const {
|
|||||||
})
|
})
|
||||||
const latestExpenseConversation = ref(null)
|
const latestExpenseConversation = ref(null)
|
||||||
const hasLocalExpenseSnapshot = ref(false)
|
const hasLocalExpenseSnapshot = ref(false)
|
||||||
|
const expenseStatsModalOpen = ref(false)
|
||||||
const expenseProfileModalOpen = ref(false)
|
const expenseProfileModalOpen = ref(false)
|
||||||
const employeeProfile = ref(null)
|
const employeeProfile = ref(null)
|
||||||
const employeeProfileRuns = ref([])
|
const employeeProfileRuns = ref([])
|
||||||
@@ -451,6 +462,7 @@ const expenseProfileOperations = computed(() =>
|
|||||||
buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value)
|
buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value)
|
||||||
)
|
)
|
||||||
const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.empty_reason || '').trim())
|
const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.empty_reason || '').trim())
|
||||||
|
const expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {})
|
||||||
const currentUserProfileKey = computed(() => {
|
const currentUserProfileKey = computed(() => {
|
||||||
const user = currentUser.value || {}
|
const user = currentUser.value || {}
|
||||||
return [
|
return [
|
||||||
@@ -700,6 +712,14 @@ async function loadCurrentEmployeeProfile() {
|
|||||||
employeeProfileLoading.value = false
|
employeeProfileLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openExpenseStatsModal() {
|
||||||
|
expenseStatsModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeExpenseStatsModal() {
|
||||||
|
expenseStatsModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function openExpenseProfileModal() {
|
function openExpenseProfileModal() {
|
||||||
expenseProfileModalOpen.value = true
|
expenseProfileModalOpen.value = true
|
||||||
if (!employeeProfile.value && !employeeProfileLoading.value) {
|
if (!employeeProfile.value && !employeeProfileLoading.value) {
|
||||||
|
|||||||
@@ -64,6 +64,61 @@ function formatCurrency(value) {
|
|||||||
}).format(parseNumber(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) {
|
function resolveRequestIdentity(request) {
|
||||||
return normalizeText(request?.claimNo || request?.claim_no || request?.id || request?.claimId)
|
return normalizeText(request?.claimNo || request?.claim_no || request?.id || request?.claimId)
|
||||||
}
|
}
|
||||||
@@ -232,6 +287,177 @@ function buildNotifications(todoItems, progressItems) {
|
|||||||
return [...todoNotifications, ...progressNotifications]
|
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) {
|
export function buildWorkbenchSummary(requests, currentUser) {
|
||||||
const ownedRequests = Array.isArray(requests)
|
const ownedRequests = Array.isArray(requests)
|
||||||
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
|
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
|
||||||
@@ -254,6 +480,11 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
|||||||
const todoItems = buildTodoItems(ownedRequests)
|
const todoItems = buildTodoItems(ownedRequests)
|
||||||
const progressItems = buildProgressItems(ownedRequests)
|
const progressItems = buildProgressItems(ownedRequests)
|
||||||
const notifications = buildNotifications(todoItems, progressItems)
|
const notifications = buildNotifications(todoItems, progressItems)
|
||||||
|
const expenseStatsDetail = {
|
||||||
|
distributionRows: buildExpenseDistributionRows(ownedRequests),
|
||||||
|
processingRows: buildExpenseProcessingRows(ownedRequests),
|
||||||
|
operationRows: buildExpenseOperationRows(todoItems, notifications, progressItems)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
monthlyCount,
|
monthlyCount,
|
||||||
@@ -270,6 +501,7 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
|||||||
todoItems,
|
todoItems,
|
||||||
progressItems,
|
progressItems,
|
||||||
notifications,
|
notifications,
|
||||||
|
expenseStatsDetail,
|
||||||
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
web/tests/expense-stats-detail-modal.test.mjs
Normal file
23
web/tests/expense-stats-detail-modal.test.mjs
Normal 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, /统计口径来自当前工作台已加载的个人单据/)
|
||||||
|
})
|
||||||
@@ -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, /<time :datetime="item\.updatedAt \|\| ''">\{\{ item\.displayTime \}\}<\/time>/)
|
||||||
assert.match(workbench, /displayTime: formatProgressTime\(item\?\.updatedAt\)/)
|
assert.match(workbench, /displayTime: formatProgressTime\(item\?\.updatedAt\)/)
|
||||||
assert.match(workbench, /function formatProgressTime\(value\)/)
|
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\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]*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(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"/)
|
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, /查看我的费用统计详情/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -77,4 +77,12 @@ test('workbench summary builds real user notifications and progress from request
|
|||||||
)
|
)
|
||||||
assert.equal(summary.notifications.length, 1)
|
assert.equal(summary.notifications.length, 1)
|
||||||
assert.equal(summary.unreadNotificationCount, 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 === '进度'))
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user