fix(workbench): show single expense distribution chart

This commit is contained in:
caoxiaozhu
2026-06-03 15:46:51 +08:00
parent 18d716bc6b
commit e12b140508
6 changed files with 139 additions and 15 deletions

View File

@@ -105,3 +105,7 @@ $$
- 当前数据来自工作台前端汇总,历史维度受首页已加载单据范围影响;若后续需要跨年或分页全量统计,应补后端专用接口。
- 单据类型归类依赖标题、场景和备注,属于前端轻量归类;后续可与 ontology 费用类别字段打通。
## 2026-06-03 饼图呈现修正
费用分布仍复用项目已有 `DonutChart`,但在费用统计详情弹窗内关闭组件自带图例,只保留一个环形饼图入口。费用类型、金额、笔数和占比改为右侧文字明细列表,避免环图主体和双列图例在同一卡片内被误认为出现两个饼图。

View File

@@ -13,6 +13,8 @@
- [x] 新增费用统计详情弹窗组件,展示三个详情区块和空状态。[CONCEPT: 功能能力] 证据:新增 `ExpenseStatsDetailModal.vue`
- [x]`PersonalWorkbench.vue` 接入弹窗状态与费用统计“查看详情”按钮。[CONCEPT: 方案设计] 证据:新增 `expenseStatsModalOpen``openExpenseStatsModal`
- [x] 将费用分布区从条形列表改为 `DonutChart` 饼图展示。[CONCEPT: 功能能力] 证据:`ExpenseStatsDetailModal.vue` 已接入 `DonutChart``distributionChartItems`
- [x] 关闭费用详情内 `DonutChart` 自带图例,改为单饼图加右侧文字明细。[CONCEPT: 2026-06-03 饼图呈现修正] 证据:`ExpenseStatsDetailModal.vue` 传入 `:show-legend="false"` 并新增 `expense-distribution-summary-list`
- [x] 为通用 `DonutChart` 增加可隐藏内置图例的开关,默认保持其它页面不变。[CONCEPT: 2026-06-03 饼图呈现修正] 证据:`DonutChart.vue` 新增 `showLegend` 默认值和 `donut-chart--legendless` 状态。
## 测试与验证
@@ -21,8 +23,9 @@
- [x] 补充弹窗源码测试,覆盖费用分布、处理时间、系统操作详情区块。[CONCEPT: 测试方案] 证据:`node web/tests/expense-stats-detail-modal.test.mjs` 通过。
- [x] 运行前端定向测试和构建验证。[CONCEPT: 指标与验收] 证据:以上定向测试和 `npm.cmd --prefix web run build` 均通过。
- [x] 更新弹窗源码测试,确认费用分布使用饼图组件。[CONCEPT: 测试方案] 证据:`node web/tests/expense-stats-detail-modal.test.mjs` 通过,`npm.cmd --prefix web run build` 通过。
- [x] 更新弹窗与环图源码测试,确认详情弹窗只使用一个饼图入口且关闭内置图例。[CONCEPT: 2026-06-03 饼图呈现修正] 证据:`node web/tests/expense-stats-detail-modal.test.mjs``node web/tests/donut-chart.test.mjs` 通过。
## 交付
- [x] 复查本次暂存范围,避免纳入无关工作区改动。[CONCEPT: 风险与开放问题] 证据:`git diff --cached --name-only` 仅包含本次工作台弹窗、样式、汇总测试和开发文档。
- [ ] 提交并 push 本次功能分支。[CONCEPT: 指标与验收]
- [x] 提交并 push 本次功能分支。[CONCEPT: 指标与验收] 证据:本次单饼图修复完成后提交并推送当前分支。

View File

@@ -51,12 +51,29 @@
</div>
<div v-if="distributionChartItems.length" class="expense-distribution-chart">
<div class="expense-distribution-chart-layout">
<DonutChart
class="expense-distribution-donut"
:items="distributionChartItems"
:center-value="distributionCenterValue"
center-label="费用总额"
:show-legend="false"
/>
<div class="expense-distribution-summary-list" aria-label="费用分布明细">
<article
v-for="(row, index) in distributionRows"
:key="`${row.label}-${index}`"
class="expense-distribution-summary-row"
>
<i :style="{ background: resolveDistributionColor(index) }"></i>
<div>
<strong>{{ row.label }}</strong>
<span>{{ row.count || 0 }} · {{ row.amountLabel || '¥0' }}</span>
</div>
<em>{{ row.percentLabel || '0%' }}</em>
</article>
</div>
</div>
</div>
<p v-else class="expense-stats-empty">暂无历史报销费用分布</p>
</section>
@@ -188,6 +205,10 @@ const distributionChartItems = computed(() => distributionRows.value.map((row, i
color: distributionChartColors[index % distributionChartColors.length]
})))
function resolveDistributionColor(index) {
return distributionChartColors[index % distributionChartColors.length]
}
function emitClose() {
emit('close')
}
@@ -454,26 +475,84 @@ function resolveTagType(tone) {
align-items: stretch;
}
.expense-distribution-donut {
.expense-distribution-chart-layout {
display: grid;
grid-template-columns: minmax(170px, 0.86fr) minmax(0, 1.14fr);
align-items: center;
gap: 12px;
min-height: 286px;
}
.expense-distribution-donut {
min-height: 0;
}
.expense-distribution-donut :deep(.donut-body) {
height: 194px;
height: 220px;
margin-top: 0;
}
.expense-distribution-donut :deep(.donut-legend) {
gap: 7px 14px;
.expense-distribution-summary-list {
min-width: 0;
display: grid;
align-content: center;
gap: 8px;
}
.expense-distribution-donut :deep(.legend-name) {
.expense-distribution-summary-row {
min-width: 0;
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px 0;
border-top: 1px solid #e8eef5;
}
.expense-distribution-summary-row:first-child {
border-top: 0;
}
.expense-distribution-summary-row i {
width: 10px;
height: 10px;
border-radius: 2px;
}
.expense-distribution-summary-row div {
min-width: 0;
display: grid;
gap: 2px;
}
.expense-distribution-summary-row strong,
.expense-distribution-summary-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-distribution-summary-row strong {
color: #334155;
font-size: 12.5px;
font-weight: 850;
}
.expense-distribution-summary-row span {
color: #94a3b8;
font-size: 11px;
font-weight: 650;
}
.expense-distribution-summary-row em {
color: #0f172a;
font-size: 12px;
font-style: normal;
font-weight: 850;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.expense-processing-row {
display: grid;
grid-template-columns: minmax(124px, 0.8fr) minmax(164px, 1fr) auto 58px;
@@ -580,6 +659,14 @@ function resolveTagType(tone) {
}
@media (max-width: 620px) {
.expense-distribution-chart-layout {
grid-template-columns: 1fr;
}
.expense-distribution-donut :deep(.donut-body) {
height: 190px;
}
.expense-processing-row,
.expense-operation-row {
grid-template-columns: 1fr;

View File

@@ -1,5 +1,5 @@
<template>
<div class="donut-chart">
<div class="donut-chart" :class="{ 'donut-chart--legendless': !showLegend }">
<div class="donut-body">
<div ref="chartElement" class="donut-canvas" role="img" :aria-label="ariaLabel"></div>
<div class="donut-center">
@@ -7,7 +7,7 @@
<span>{{ centerLabel }}</span>
</div>
</div>
<div class="donut-legend">
<div v-if="showLegend" class="donut-legend">
<div v-for="item in resolvedItems" :key="item.name" class="legend-row">
<i :style="{ background: item.resolvedColor }"></i>
<span class="legend-name">{{ item.name }}</span>
@@ -32,9 +32,12 @@ use([TooltipComponent, PieChart, CanvasRenderer])
const props = defineProps({
items: { type: Array, required: true },
centerValue: { type: String, required: true },
centerLabel: { type: String, required: true }
centerLabel: { type: String, required: true },
showLegend: { type: Boolean, default: true }
})
const showLegend = computed(() => props.showLegend)
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const resolvedItems = computed(() => {
@@ -117,6 +120,15 @@ useEcharts(chartElement, chartOptions)
gap: 10px;
}
.donut-chart--legendless {
min-height: 0;
justify-content: center;
}
.donut-chart--legendless .donut-body {
margin-top: 0;
}
.donut-body {
position: relative;
width: 100%;

View File

@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const donutChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/DonutChart.vue', import.meta.url)),
'utf8'
)
test('donut chart supports hiding its built-in legend without changing the default', () => {
assert.match(donutChart, /showLegend: \{ type: Boolean, default: true \}/)
assert.match(donutChart, /v-if="showLegend" class="donut-legend"/)
assert.match(donutChart, /donut-chart--legendless/)
})

View File

@@ -17,9 +17,12 @@ test('expense stats detail modal exposes distribution, processing time and opera
assert.match(modal, /ElTag/)
assert.match(modal, /import DonutChart from '\.\.\/charts\/DonutChart\.vue'/)
assert.match(modal, /<DonutChart/)
assert.match(modal, /:show-legend="false"/)
assert.match(modal, /distributionChartItems/)
assert.match(modal, /distributionCenterValue/)
assert.match(modal, /distributionRows/)
assert.match(modal, /expense-distribution-summary-list/)
assert.match(modal, /resolveDistributionColor/)
assert.match(modal, /processingRows/)
assert.match(modal, /operationRows/)
assert.doesNotMatch(modal, /--expense-detail-percent/)