Compare commits
20 Commits
feature/re
...
32d5ed62d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d5ed62d0 | ||
|
|
6e95568906 | ||
|
|
2cf19a45e5 | ||
|
|
6922dff5a9 | ||
|
|
d324769795 | ||
|
|
1ba446f05a | ||
|
|
4fd190f461 | ||
|
|
9eb712cc44 | ||
|
|
4f6b634e68 | ||
|
|
cdd20352a3 | ||
|
|
f8e6029108 | ||
|
|
7a39258bc8 | ||
|
|
986f46b84c | ||
|
|
3402ffaae2 | ||
|
|
6ca00c1478 | ||
|
|
0101c3e366 | ||
|
|
5e38a52e5b | ||
|
|
c49f66757e | ||
|
|
77c9b47246 | ||
| fac83eb09a |
103
.doc/chart-grid-lines-issue.md
Normal file
103
.doc/chart-grid-lines-issue.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Doughnut/Pie 图表显示网格线问题修复
|
||||
author: AI Assistant
|
||||
date: 2026-02-19
|
||||
status: final
|
||||
category: 技术修复
|
||||
---
|
||||
|
||||
# Doughnut/Pie 图表显示网格线问题修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
在使用 Chart.js 的 Doughnut(环形图)或 Pie(饼图)时,图表中不应该显示笛卡尔坐标系的网格线,但在某些情况下会错误地显示出来。
|
||||
|
||||
## 问题根源
|
||||
|
||||
`useChartTheme.ts` 中的 `baseChartOptions` 包含了 `scales.x` 和 `scales.y` 配置(第 82-108 行),这些配置适用于折线图、柱状图等**笛卡尔坐标系图表**,但不适用于 Doughnut/Pie 这类**极坐标图表**。
|
||||
|
||||
当使用 `getChartOptions()` 合并配置时,这些默认的 `scales` 配置会被带入到圆形图表中,导致显示网格线。
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 方案 1:在具体组件中显式禁用(已应用)
|
||||
|
||||
在使用 Doughnut/Pie 图表的组件中,调用 `getChartOptions()` 时显式传入 `scales` 配置:
|
||||
|
||||
```javascript
|
||||
const chartOptions = computed(() => {
|
||||
return getChartOptions({
|
||||
cutout: '65%',
|
||||
// 显式禁用笛卡尔坐标系(Doughnut 图表不需要)
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false }
|
||||
},
|
||||
plugins: {
|
||||
// ...其他插件配置
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 方案 2:BaseChart 组件自动处理(已优化)
|
||||
|
||||
优化 `BaseChart.vue` 组件(第 106-128 行),使其能够自动检测圆形图表并强制禁用坐标轴:
|
||||
|
||||
```javascript
|
||||
const mergedOptions = computed(() => {
|
||||
const isCircularChart = props.type === 'pie' || props.type === 'doughnut'
|
||||
|
||||
const merged = getChartOptions(props.options)
|
||||
|
||||
if (isCircularChart) {
|
||||
if (!props.options?.scales) {
|
||||
// 用户完全没传 scales,直接删除
|
||||
delete merged.scales
|
||||
} else {
|
||||
// 用户传了 scales,确保 display 设置为 false
|
||||
if (merged.scales) {
|
||||
if (merged.scales.x) merged.scales.x.display = false
|
||||
if (merged.scales.y) merged.scales.y.display = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
})
|
||||
```
|
||||
|
||||
## 已修复的文件
|
||||
|
||||
1. **Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue**
|
||||
- 在 `chartOptions` 中添加了显式的 `scales` 禁用配置(第 321-324 行)
|
||||
|
||||
2. **Web/src/components/Charts/BaseChart.vue**
|
||||
- 优化了圆形图表的 `scales` 处理逻辑(第 106-128 行)
|
||||
|
||||
## 已验证的文件(无需修改)
|
||||
|
||||
1. **Web/src/components/Budget/BudgetChartAnalysis.vue**
|
||||
- `monthGaugeOptions` 和 `yearGaugeOptions` 已经包含正确的 `scales` 配置
|
||||
|
||||
## 预防措施
|
||||
|
||||
1. **新增 Doughnut/Pie 图表时**:始终显式设置 `scales: { x: { display: false }, y: { display: false } }`
|
||||
2. **使用 BaseChart 组件**:依赖其自动处理逻辑(已优化)
|
||||
3. **代码审查**:检查所有圆形图表配置,确保不包含笛卡尔坐标系配置
|
||||
|
||||
## Chart.js 图表类型说明
|
||||
|
||||
| 图表类型 | 坐标系 | 是否需要 scales |
|
||||
|---------|--------|----------------|
|
||||
| Line | 笛卡尔 | ✓ 需要 x/y |
|
||||
| Bar | 笛卡尔 | ✓ 需要 x/y |
|
||||
| Pie | 极坐标 | ✗ 不需要 |
|
||||
| Doughnut| 极坐标 | ✗ 不需要 |
|
||||
| Radar | 极坐标 | ✗ 不需要 |
|
||||
|
||||
## 相关资源
|
||||
|
||||
- Chart.js 官方文档:https://www.chartjs.org/docs/latest/
|
||||
- 项目主题配置:`Web/src/composables/useChartTheme.ts`
|
||||
- 图表基础组件:`Web/src/components/Charts/BaseChart.vue`
|
||||
165
.doc/popup-migration-checklist.md
Normal file
165
.doc/popup-migration-checklist.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# PopupContainer V1 → V2 迁移清单
|
||||
|
||||
## 文件分析汇总
|
||||
|
||||
### 第一批:基础用法(无 subtitle、无按钮)
|
||||
|
||||
| 文件 | Props 使用 | Slots 使用 | 迁移复杂度 | 备注 |
|
||||
|------|-----------|-----------|----------|------|
|
||||
| MessageView.vue | v-model, title, subtitle, height | footer | ⭐⭐ | 有 subtitle (createTime),有条件 footer |
|
||||
| EmailRecord.vue | v-model, title, height | header-actions | ⭐⭐⭐ | 使用 header-actions 插槽(重新分析按钮) |
|
||||
| PeriodicRecord.vue | v-model, title, height | 默认插槽 | ⭐ | 基础用法,表单内容 |
|
||||
| ClassificationNLP.vue | v-model, title, height | 默认插槽 | ⭐ | 基础用法 |
|
||||
| BillAnalysisView.vue | v-model, title, height | 默认插槽 | ⭐ | 基础用法 |
|
||||
|
||||
### 第二批:带 subtitle
|
||||
|
||||
| 文件 | Subtitle 类型 | 迁移方案 |
|
||||
|------|--------------|---------|
|
||||
| MessageView.vue | 时间戳 (createTime) | 移至内容区域顶部,使用灰色小字 |
|
||||
| CategoryBillPopup.vue | 待检查 | 待定 |
|
||||
| BudgetChartAnalysis.vue | 待检查 | 待定 |
|
||||
| TransactionDetail.vue | 待检查 | 待定 |
|
||||
| ReasonGroupList.vue | 待检查 | 待定 |
|
||||
|
||||
### 第三批:带确认/取消按钮
|
||||
|
||||
| 文件 | 按钮配置 | 迁移方案 |
|
||||
|------|---------|---------|
|
||||
| AddClassifyDialog.vue | 待检查 | footer 插槽 + van-button |
|
||||
| IconSelector.vue | 待检查 | footer 插槽 + van-button |
|
||||
| ClassificationEdit.vue | 待检查 | footer 插槽 + van-button |
|
||||
|
||||
### 第四批:复杂布局(header-actions)
|
||||
|
||||
| 文件 | header-actions 内容 | 迁移方案 |
|
||||
|------|-------------------|---------|
|
||||
| EmailRecord.vue | "重新分析" 按钮 | 移至内容区域顶部作为操作栏 |
|
||||
| BudgetCard.vue | 待检查 | 待定 |
|
||||
| BudgetEditPopup.vue | 待检查 | 待定 |
|
||||
| SavingsConfigPopup.vue | 待检查 | 待定 |
|
||||
| SavingsBudgetContent.vue | 待检查 | 待定 |
|
||||
| budgetV2/Index.vue | 待检查 | 待定 |
|
||||
|
||||
### 第五批:全局组件
|
||||
|
||||
| 文件 | 特殊逻辑 | 迁移方案 |
|
||||
|------|---------|---------|
|
||||
| GlobalAddBill.vue | 待检查 | 待定 |
|
||||
|
||||
## 迁移模式汇总
|
||||
|
||||
### 模式 1: 基础迁移(无特殊 props)
|
||||
```vue
|
||||
<!-- V1 -->
|
||||
<PopupContainer
|
||||
v-model="show"
|
||||
title="标题"
|
||||
height="75%"
|
||||
>
|
||||
内容
|
||||
</PopupContainer>
|
||||
|
||||
<!-- V2 -->
|
||||
<PopupContainerV2
|
||||
v-model:show="show"
|
||||
title="标题"
|
||||
:height="'75%'"
|
||||
>
|
||||
<div style="padding: 16px">
|
||||
内容
|
||||
</div>
|
||||
</PopupContainerV2>
|
||||
```
|
||||
|
||||
### 模式 2: subtitle 迁移
|
||||
```vue
|
||||
<!-- V1 -->
|
||||
<PopupContainer
|
||||
v-model="show"
|
||||
title="标题"
|
||||
:subtitle="createTime"
|
||||
>
|
||||
内容
|
||||
</PopupContainer>
|
||||
|
||||
<!-- V2 -->
|
||||
<PopupContainerV2
|
||||
v-model:show="show"
|
||||
title="标题"
|
||||
:height="'75%'"
|
||||
>
|
||||
<div style="padding: 16px">
|
||||
<p style="color: #999; font-size: 14px; margin-bottom: 12px">{{ createTime }}</p>
|
||||
内容
|
||||
</div>
|
||||
</PopupContainerV2>
|
||||
```
|
||||
|
||||
### 模式 3: header-actions 迁移
|
||||
```vue
|
||||
<!-- V1 -->
|
||||
<PopupContainer
|
||||
v-model="show"
|
||||
title="标题"
|
||||
>
|
||||
<template #header-actions>
|
||||
<van-button size="small" @click="handleAction">操作</van-button>
|
||||
</template>
|
||||
内容
|
||||
</PopupContainer>
|
||||
|
||||
<!-- V2 -->
|
||||
<PopupContainerV2
|
||||
v-model:show="show"
|
||||
title="标题"
|
||||
:height="'80%'"
|
||||
>
|
||||
<div style="padding: 16px">
|
||||
<div style="margin-bottom: 16px; text-align: right">
|
||||
<van-button size="small" @click="handleAction">操作</van-button>
|
||||
</div>
|
||||
内容
|
||||
</div>
|
||||
</PopupContainerV2>
|
||||
```
|
||||
|
||||
### 模式 4: footer 插槽迁移
|
||||
```vue
|
||||
<!-- V1 -->
|
||||
<PopupContainer
|
||||
v-model="show"
|
||||
title="标题"
|
||||
>
|
||||
内容
|
||||
<template #footer>
|
||||
<van-button type="primary">提交</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
|
||||
<!-- V2 -->
|
||||
<PopupContainerV2
|
||||
v-model:show="show"
|
||||
title="标题"
|
||||
:height="'80%'"
|
||||
>
|
||||
<div style="padding: 16px">
|
||||
内容
|
||||
</div>
|
||||
<template #footer>
|
||||
<van-button type="primary" block>提交</van-button>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
```
|
||||
|
||||
## 进度追踪
|
||||
|
||||
- [ ] 完成所有文件的详细分析
|
||||
- [ ] 确认每个文件的迁移模式
|
||||
- [ ] 标记需要特殊处理的文件
|
||||
|
||||
## 风险点
|
||||
|
||||
1. **EmailRecord.vue**: 有 header-actions 插槽,需要重新设计操作按钮的位置
|
||||
2. **MessageView.vue**: subtitle 用于显示时间,需要保持视觉层级
|
||||
3. **待检查文件**: 需要逐个检查是否使用了 v-html、复杂布局等特性
|
||||
107
.doc/unify-bill-list-migration-record.md
Normal file
107
.doc/unify-bill-list-migration-record.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 账单列表统一迁移记录
|
||||
|
||||
**日期**: 2026-02-19
|
||||
**变更**: unify-bill-list-ui
|
||||
**提交**: f8e6029, cdd2035
|
||||
|
||||
## 变更摘要
|
||||
|
||||
将 `calendarV2/modules/TransactionList.vue` 迁移至使用统一的 `BillListComponent` 组件,保留自定义 header 和 Smart 按钮功能。
|
||||
|
||||
## 迁移范围调整
|
||||
|
||||
### 原设计 vs 实际情况
|
||||
|
||||
原设计文档列出需要迁移 6 个页面,但经过详细代码审查后发现:
|
||||
|
||||
| 页面 | 原设计预期 | 实际情况 | 处理结果 |
|
||||
|------|-----------|---------|---------|
|
||||
| TransactionsRecord.vue | 需迁移 | ✅ 已使用 BillListComponent | 无需操作 |
|
||||
| EmailRecord.vue | 需迁移 | ✅ 已使用 BillListComponent | 无需操作 |
|
||||
| calendarV2/TransactionList.vue | 需迁移 | ⚠️ 自定义实现,需迁移 | ✅ 已完成迁移 |
|
||||
| MessageView.vue | 需迁移 | ❌ 系统消息列表,非账单 | 排除 |
|
||||
| PeriodicRecord.vue | 需迁移 | ❌ 周期性规则列表,非交易账单 | 排除 |
|
||||
| ClassificationEdit.vue | 需迁移 | ❌ 分类管理列表,非账单 | 排除 |
|
||||
| budgetV2/Index.vue | 需迁移 | ❌ 预算卡片列表,非账单 | 排除 |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **MessageView.vue**: 显示的是系统通知消息,数据结构为 `{title, content, isRead, createTime}`,不是交易账单。
|
||||
2. **PeriodicRecord.vue**: 显示的是周期性账单规则(如每月1号扣款),包含 `periodicType`, `weekdays`, `isEnabled` 等配置字段,不是实际交易记录。
|
||||
3. **ClassificationEdit.vue**: 显示的是分类配置列表,用于管理交易分类的图标和名称。
|
||||
4. **budgetV2/Index.vue**: 显示的是预算卡片,每个卡片展示"已支出/预算/余额"等统计信息,不是账单列表。
|
||||
|
||||
## 迁移实施
|
||||
|
||||
### calendarV2/TransactionList.vue
|
||||
|
||||
**迁移前**:
|
||||
- 403 行代码
|
||||
- 自定义数据转换逻辑 (`formatTime`, `formatAmount`, `getIconByClassify` 等)
|
||||
- 自定义账单卡片渲染 (`txn-card`, `txn-icon`, `txn-content` 等)
|
||||
- 自定义空状态展示
|
||||
|
||||
**迁移后**:
|
||||
- 177 行代码 (减少 56%)
|
||||
- 使用 `BillListComponent` 处理数据格式化和渲染
|
||||
- 保留自定义 header (交易记录标题 + Items 计数 + Smart 按钮)
|
||||
- 直接传递原始 API 数据,无需转换
|
||||
|
||||
**配置**:
|
||||
```vue
|
||||
<BillListComponent
|
||||
data-source="custom"
|
||||
:transactions="transactions"
|
||||
:loading="transactionsLoading"
|
||||
:finished="true"
|
||||
:show-delete="false"
|
||||
:enable-filter="false"
|
||||
@click="onTransactionClick"
|
||||
/>
|
||||
```
|
||||
|
||||
**代码改动**:
|
||||
- ✅ 导入 `BillListComponent`
|
||||
- ✅ 替换 template 中的自定义列表部分
|
||||
- ✅ 移除数据格式转换函数
|
||||
- ✅ 清理废弃的样式定义 (txn-card, txn-empty 等)
|
||||
- ✅ 保留 txn-header 相关样式
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 功能测试清单
|
||||
|
||||
- [ ] 日历选择日期,查看对应日期的账单列表
|
||||
- [ ] 点击账单卡片,打开账单详情
|
||||
- [ ] 点击 Smart 按钮,触发智能分类
|
||||
- [ ] Items 计数显示正确
|
||||
- [ ] 空状态显示正确(无交易记录的日期)
|
||||
- [ ] 加载状态显示正确
|
||||
|
||||
### 视觉验证
|
||||
|
||||
- [ ] 账单卡片样式与 /balance 页面一致
|
||||
- [ ] 自定义 header 保持原有样式
|
||||
- [ ] Smart 按钮样式和位置正确
|
||||
- [ ] 响应式设计正常(不同屏幕尺寸)
|
||||
|
||||
### 代码质量
|
||||
|
||||
- ✅ ESLint 检查通过 (无错误,无新增警告)
|
||||
- ✅ 代码简化效果明显 (403行 → 177行)
|
||||
- ✅ Git 提交记录清晰
|
||||
|
||||
## 后续建议
|
||||
|
||||
1. **手动测试**: 在实际环境中测试日历视图的所有功能
|
||||
2. **性能监控**: 观察迁移后的页面加载和交互性能
|
||||
3. **用户反馈**: 收集用户对新 UI 风格的反馈
|
||||
|
||||
## 相关文件
|
||||
|
||||
- **迁移代码**: `Web/src/views/calendarV2/modules/TransactionList.vue`
|
||||
- **统一组件**: `Web/src/components/Bill/BillListComponent.vue`
|
||||
- **提交记录**:
|
||||
- f8e6029: refactor(calendar-v2): migrate TransactionList to BillListComponent
|
||||
- cdd2035: docs: update unify-bill-list-ui change scope
|
||||
- **OpenSpec 变更**: `openspec/changes/unify-bill-list-ui/`
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -407,4 +407,4 @@ Web/dist
|
||||
.aider*
|
||||
.screenshot/*
|
||||
|
||||
|
||||
**/nul
|
||||
|
||||
@@ -595,27 +595,46 @@ public class BudgetStatsService(
|
||||
logger.LogDebug("开始处理当前及未来月份预算");
|
||||
foreach (var budget in currentBudgetsDict.Values)
|
||||
{
|
||||
// 对于年度预算,如果还没有从归档中添加,则添加
|
||||
if (budget.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(budget.Id))
|
||||
// 对于年度预算,需要实时计算当前金额
|
||||
if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
|
||||
result.Add(new BudgetStatsItem
|
||||
// 如果已经从归档中添加过,需要更新其Current值为实时计算的金额
|
||||
if (processedBudgetIds.Contains(budget.Id))
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
Type = budget.Type,
|
||||
Limit = budget.Limit,
|
||||
Current = currentAmount,
|
||||
Category = budget.Category,
|
||||
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
|
||||
? []
|
||||
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
|
||||
NoLimit = budget.NoLimit,
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense,
|
||||
IsArchive = false
|
||||
});
|
||||
logger.LogInformation("添加当前年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Current}",
|
||||
budget.Name, budget.Limit, currentAmount);
|
||||
var realTimeAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
|
||||
var existingItem = result.FirstOrDefault(r => r.Id == budget.Id && r.Type == BudgetPeriodType.Year);
|
||||
if (existingItem != null)
|
||||
{
|
||||
// 更新Current为实时金额(而不是归档的Actual)
|
||||
result.Remove(existingItem);
|
||||
result.Add(existingItem with { Current = realTimeAmount, IsArchive = false });
|
||||
logger.LogInformation("更新年度预算实时金额: {BudgetName} - 归档金额: {ArchiveAmount}, 实时金额: {RealtimeAmount}",
|
||||
budget.Name, existingItem.Current, realTimeAmount);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果没有从归档中添加,则新增
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
Type = budget.Type,
|
||||
Limit = budget.Limit,
|
||||
Current = currentAmount,
|
||||
Category = budget.Category,
|
||||
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
|
||||
? []
|
||||
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
|
||||
NoLimit = budget.NoLimit,
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense,
|
||||
IsArchive = false
|
||||
});
|
||||
logger.LogInformation("添加当前年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Current}",
|
||||
budget.Name, budget.Limit, currentAmount);
|
||||
processedBudgetIds.Add(budget.Id);
|
||||
}
|
||||
}
|
||||
// 对于月度预算,仅添加当前月的预算项(如果还没有从归档中添加)
|
||||
else if (budget.Type == BudgetPeriodType.Month)
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
<PopupContainerV2
|
||||
v-model:show="show"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
show-confirm-button
|
||||
confirm-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="resetAddForm"
|
||||
:height="'auto'"
|
||||
>
|
||||
<van-form ref="addFormRef">
|
||||
<van-field
|
||||
v-model="classifyName"
|
||||
name="name"
|
||||
label="分类名称"
|
||||
placeholder="请输入分类名称"
|
||||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||||
/>
|
||||
</van-form>
|
||||
</PopupContainer>
|
||||
<div style="padding: 16px">
|
||||
<van-form ref="addFormRef">
|
||||
<van-field
|
||||
v-model="classifyName"
|
||||
name="name"
|
||||
label="分类名称"
|
||||
placeholder="请输入分类名称"
|
||||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||||
/>
|
||||
</van-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display: flex; gap: 12px">
|
||||
<van-button
|
||||
plain
|
||||
style="flex: 1"
|
||||
@click="resetAddForm"
|
||||
>
|
||||
取消
|
||||
</van-button>
|
||||
<van-button
|
||||
type="primary"
|
||||
style="flex: 1"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
确认
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import PopupContainer from './PopupContainer.vue'
|
||||
import PopupContainerV2 from './PopupContainerV2.vue'
|
||||
|
||||
const emit = defineEmits(['confirm'])
|
||||
|
||||
|
||||
@@ -445,7 +445,7 @@ const getIconByClassify = (classify) => {
|
||||
if (categoryIconMap.value[classify]) {
|
||||
return categoryIconMap.value[classify]
|
||||
}
|
||||
|
||||
|
||||
// 降级:使用本地映射(向后兼容)
|
||||
const iconMap = {
|
||||
餐饮: 'food-o',
|
||||
@@ -648,7 +648,7 @@ const loadCategories = async () => {
|
||||
const response = await getCategoryList()
|
||||
if (response && response.success) {
|
||||
categories.value = response.data || []
|
||||
|
||||
|
||||
// 构建分类名称 -> 图标的映射
|
||||
const iconMap = {}
|
||||
categories.value.forEach(category => {
|
||||
@@ -668,7 +668,7 @@ const loadCategories = async () => {
|
||||
onMounted(() => {
|
||||
// 加载分类列表(用于图标映射)
|
||||
loadCategories()
|
||||
|
||||
|
||||
if (props.dataSource === 'api') {
|
||||
fetchTransactions()
|
||||
}
|
||||
|
||||
@@ -209,10 +209,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showBillListModal"
|
||||
<PopupContainerV2
|
||||
v-model:show="showBillListModal"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<BillListComponent
|
||||
data-source="custom"
|
||||
@@ -225,7 +225,7 @@
|
||||
@click="handleBillClick"
|
||||
@delete="handleBillDelete"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</div>
|
||||
|
||||
<!-- 不记额预算卡片 -->
|
||||
@@ -406,10 +406,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showBillListModal"
|
||||
<PopupContainerV2
|
||||
v-model:show="showBillListModal"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<BillListComponent
|
||||
data-source="custom"
|
||||
@@ -422,14 +422,14 @@
|
||||
@click="handleBillClick"
|
||||
@delete="handleBillDelete"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { BudgetPeriodType } from '@/constants/enums'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import BillListComponent from '@/components/Bill/BillListComponent.vue'
|
||||
import { getTransactionList } from '@/api/transactionRecord'
|
||||
|
||||
|
||||
@@ -1,843 +0,0 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<!-- 普通预算卡片 -->
|
||||
<div
|
||||
v-if="!budget.noLimit"
|
||||
class="common-card budget-card"
|
||||
:class="{ 'cursor-default': budget.category === 2 }"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<div class="budget-content-wrapper">
|
||||
<!-- 折叠状态 -->
|
||||
<div
|
||||
v-if="!isExpanded"
|
||||
class="budget-collapsed"
|
||||
>
|
||||
<div class="collapsed-header">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
<span
|
||||
v-if="budget.isMandatoryExpense"
|
||||
class="mandatory-mark"
|
||||
>📌</span>
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title">
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="card-subtitle"
|
||||
>
|
||||
({{ budget.selectedCategories.join('、') }})
|
||||
</span>
|
||||
</div>
|
||||
<van-icon
|
||||
name="arrow-down"
|
||||
class="expand-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="collapsed-footer">
|
||||
<div class="collapsed-item">
|
||||
<span class="compact-label">实际/目标</span>
|
||||
<span class="compact-value">
|
||||
<slot name="collapsed-amount">
|
||||
{{
|
||||
budget.current !== undefined && budget.limit !== undefined
|
||||
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
|
||||
: '--'
|
||||
}}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="collapsed-item">
|
||||
<span class="compact-label">达成率</span>
|
||||
<span
|
||||
class="compact-value"
|
||||
:class="percentClass"
|
||||
>{{ percentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="budget-inner-card"
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
<span
|
||||
v-if="budget.isMandatoryExpense"
|
||||
class="mandatory-mark"
|
||||
>📌</span>
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3
|
||||
class="card-title"
|
||||
style="max-width: 120px"
|
||||
>
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<slot name="actions">
|
||||
<van-button
|
||||
v-if="budget.description"
|
||||
:icon="showDescription ? 'info' : 'info-o'"
|
||||
size="small"
|
||||
:type="showDescription ? 'primary' : 'default'"
|
||||
plain
|
||||
@click.stop="showDescription = !showDescription"
|
||||
/>
|
||||
<van-button
|
||||
icon="orders-o"
|
||||
size="small"
|
||||
plain
|
||||
title="查询关联账单"
|
||||
@click.stop="handleQueryBills"
|
||||
/>
|
||||
<template v-if="budget.category !== 2">
|
||||
<van-button
|
||||
icon="edit"
|
||||
size="small"
|
||||
plain
|
||||
@click.stop="$emit('click', budget)"
|
||||
/>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-body">
|
||||
<div
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="category-tags"
|
||||
>
|
||||
<van-tag
|
||||
v-for="cat in budget.selectedCategories"
|
||||
:key="cat"
|
||||
size="mini"
|
||||
class="category-tag"
|
||||
plain
|
||||
round
|
||||
>
|
||||
{{ cat }}
|
||||
</van-tag>
|
||||
</div>
|
||||
<div class="amount-info">
|
||||
<slot name="amount-info" />
|
||||
</div>
|
||||
|
||||
<div class="progress-section">
|
||||
<slot name="progress-info">
|
||||
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
|
||||
<van-progress
|
||||
:percentage="Math.min(percentage, 100)"
|
||||
stroke-width="8"
|
||||
:color="progressColor"
|
||||
:show-pivot="false"
|
||||
/>
|
||||
<span
|
||||
class="percent"
|
||||
:class="percentClass"
|
||||
>{{ percentage }}%</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="progress-section time-progress">
|
||||
<span class="period-type">时间进度</span>
|
||||
<van-progress
|
||||
:percentage="timePercentage"
|
||||
stroke-width="4"
|
||||
color="var(--van-gray-6)"
|
||||
:show-pivot="false"
|
||||
/>
|
||||
<span class="percent">{{ timePercentage }}%</span>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="collapse"
|
||||
@enter="onEnter"
|
||||
@after-enter="onAfterEnter"
|
||||
@leave="onLeave"
|
||||
@after-leave="onAfterLeave"
|
||||
>
|
||||
<div
|
||||
v-if="budget.description && showDescription"
|
||||
class="budget-collapse-wrapper"
|
||||
>
|
||||
<div class="budget-description">
|
||||
<div
|
||||
class="description-content rich-html-content"
|
||||
v-html="budget.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showBillListModal"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
>
|
||||
<TransactionList
|
||||
:transactions="billList"
|
||||
:loading="billLoading"
|
||||
:finished="true"
|
||||
:show-delete="false"
|
||||
:show-checkbox="false"
|
||||
@click="handleBillClick"
|
||||
@delete="handleBillDelete"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</div>
|
||||
|
||||
<!-- 不记额预算卡片 -->
|
||||
<div
|
||||
v-else
|
||||
class="common-card budget-card no-limit-card"
|
||||
:class="{ 'cursor-default': budget.category === 2 }"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<div class="budget-content-wrapper">
|
||||
<!-- 折叠状态 -->
|
||||
<div
|
||||
v-if="!isExpanded"
|
||||
class="budget-collapsed"
|
||||
>
|
||||
<div class="collapsed-header">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
type="success"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title">
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="card-subtitle"
|
||||
>
|
||||
({{ budget.selectedCategories.join('、') }})
|
||||
</span>
|
||||
</div>
|
||||
<van-icon
|
||||
name="arrow-down"
|
||||
class="expand-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="collapsed-footer no-limit-footer">
|
||||
<div class="collapsed-item">
|
||||
<span class="compact-label">实际</span>
|
||||
<span class="compact-value">
|
||||
<slot name="collapsed-amount">
|
||||
{{ budget.current !== undefined ? `¥${budget.current?.toFixed(0) || 0}` : '--' }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="budget-inner-card"
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
type="success"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3
|
||||
class="card-title"
|
||||
style="max-width: 120px"
|
||||
>
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<slot name="actions">
|
||||
<van-button
|
||||
v-if="budget.description"
|
||||
:icon="showDescription ? 'info' : 'info-o'"
|
||||
size="small"
|
||||
:type="showDescription ? 'primary' : 'default'"
|
||||
plain
|
||||
@click.stop="showDescription = !showDescription"
|
||||
/>
|
||||
<van-button
|
||||
icon="orders-o"
|
||||
size="small"
|
||||
plain
|
||||
title="查询关联账单"
|
||||
@click.stop="handleQueryBills"
|
||||
/>
|
||||
<template v-if="budget.category !== 2">
|
||||
<van-button
|
||||
icon="edit"
|
||||
size="small"
|
||||
plain
|
||||
@click.stop="$emit('click', budget)"
|
||||
/>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-body">
|
||||
<div
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="category-tags"
|
||||
>
|
||||
<van-tag
|
||||
v-for="cat in budget.selectedCategories"
|
||||
:key="cat"
|
||||
size="mini"
|
||||
class="category-tag"
|
||||
plain
|
||||
round
|
||||
>
|
||||
{{ cat }}
|
||||
</van-tag>
|
||||
</div>
|
||||
|
||||
<div class="no-limit-amount-info">
|
||||
<div class="amount-item">
|
||||
<span>
|
||||
<span class="label">实际</span>
|
||||
<span
|
||||
class="value"
|
||||
style="margin-left: 12px"
|
||||
>¥{{ budget.current?.toFixed(0) || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-limit-notice">
|
||||
<span>
|
||||
<van-icon
|
||||
name="info-o"
|
||||
style="margin-right: 4px"
|
||||
/>
|
||||
不记额预算 - 直接计入存款明细
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="collapse"
|
||||
@enter="onEnter"
|
||||
@after-enter="onAfterEnter"
|
||||
@leave="onLeave"
|
||||
@after-leave="onAfterLeave"
|
||||
>
|
||||
<div
|
||||
v-if="budget.description && showDescription"
|
||||
class="budget-collapse-wrapper"
|
||||
>
|
||||
<div class="budget-description">
|
||||
<div
|
||||
class="description-content rich-html-content"
|
||||
v-html="budget.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showBillListModal"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
>
|
||||
<TransactionList
|
||||
:transactions="billList"
|
||||
:loading="billLoading"
|
||||
:finished="true"
|
||||
:show-delete="false"
|
||||
:show-checkbox="false"
|
||||
@click="handleBillClick"
|
||||
@delete="handleBillDelete"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { BudgetPeriodType } from '@/constants/enums'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import TransactionList from '@/components/TransactionList.vue'
|
||||
import { getTransactionList } from '@/api/transactionRecord'
|
||||
|
||||
const props = defineProps({
|
||||
budget: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
progressColor: {
|
||||
type: String,
|
||||
default: 'var(--van-primary-color)'
|
||||
},
|
||||
percentClass: {
|
||||
type: [String, Object],
|
||||
default: ''
|
||||
},
|
||||
periodLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const isExpanded = ref(props.budget.category === 2)
|
||||
const showDescription = ref(false)
|
||||
const showBillListModal = ref(false)
|
||||
const billList = ref([])
|
||||
const billLoading = ref(false)
|
||||
|
||||
const toggleExpand = () => {
|
||||
// 存款类型(category === 2)强制保持展开状态,不可折叠
|
||||
if (props.budget.category === 2) {
|
||||
return
|
||||
}
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
const handleQueryBills = async () => {
|
||||
showBillListModal.value = true
|
||||
billLoading.value = true
|
||||
|
||||
try {
|
||||
const classify = props.budget.selectedCategories
|
||||
? props.budget.selectedCategories.join(',')
|
||||
: ''
|
||||
|
||||
if (classify === '') {
|
||||
// 如果没有选中任何分类,则不查询
|
||||
billList.value = []
|
||||
billLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const response = await getTransactionList({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
startDate: props.budget.periodStart,
|
||||
endDate: props.budget.periodEnd,
|
||||
classify: classify,
|
||||
type: props.budget.category,
|
||||
sortByAmount: true
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
billList.value = response.data || []
|
||||
} else {
|
||||
billList.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询账单列表失败:', error)
|
||||
billList.value = []
|
||||
} finally {
|
||||
billLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!props.budget.limit) {
|
||||
return 0
|
||||
}
|
||||
return Math.round((props.budget.current / props.budget.limit) * 100)
|
||||
})
|
||||
|
||||
const timePercentage = computed(() => {
|
||||
if (!props.budget.periodStart || !props.budget.periodEnd) {
|
||||
return 0
|
||||
}
|
||||
const start = new Date(props.budget.periodStart).getTime()
|
||||
const end = new Date(props.budget.periodEnd).getTime()
|
||||
const now = new Date().getTime()
|
||||
|
||||
if (now <= start) {
|
||||
return 0
|
||||
}
|
||||
if (now >= end) {
|
||||
return 100
|
||||
}
|
||||
|
||||
return Math.round(((now - start) / (end - start)) * 100)
|
||||
})
|
||||
|
||||
const onEnter = (el) => {
|
||||
el.style.height = '0'
|
||||
el.style.overflow = 'hidden'
|
||||
// Force reflow
|
||||
el.offsetHeight
|
||||
el.style.transition = 'height 0.3s ease-in-out'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
const onAfterEnter = (el) => {
|
||||
el.style.height = ''
|
||||
el.style.overflow = ''
|
||||
el.style.transition = ''
|
||||
}
|
||||
|
||||
const onLeave = (el) => {
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
el.style.overflow = 'hidden'
|
||||
// Force reflow
|
||||
el.offsetHeight
|
||||
el.style.transition = 'height 0.3s ease-in-out'
|
||||
el.style.height = '0'
|
||||
}
|
||||
|
||||
const onAfterLeave = (el) => {
|
||||
el.style.height = ''
|
||||
el.style.overflow = ''
|
||||
el.style.transition = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-card {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
padding: 8px 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.budget-card.cursor-default {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.no-limit-card {
|
||||
border-left: 3px solid var(--van-success-color);
|
||||
}
|
||||
|
||||
.collapsed-footer.no-limit-footer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.budget-content-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.budget-inner-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 折叠状态样式 */
|
||||
.budget-collapsed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.collapsed-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collapsed-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title-compact {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.status-tag-compact) {
|
||||
padding: 2px 6px !important;
|
||||
font-size: 11px !important;
|
||||
height: auto !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapsed-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapsed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collapsed-item:first-child {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.collapsed-item:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.compact-label {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.compact-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.compact-value.warning {
|
||||
color: var(--van-warning-color);
|
||||
}
|
||||
|
||||
.compact-value.income {
|
||||
color: var(--van-success-color);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--van-primary-color);
|
||||
font-size: 14px;
|
||||
transition: transform 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
color: var(--van-primary-color);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.budget-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
opacity: 0.7;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.amount-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.info-item) .label {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
:deep(.info-item) .value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.value.expense) {
|
||||
color: var(--van-danger-color);
|
||||
}
|
||||
|
||||
:deep(.value.income) {
|
||||
color: var(--van-success-color);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--van-gray-6);
|
||||
}
|
||||
|
||||
.progress-section :deep(.van-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.period-type {
|
||||
white-space: nowrap;
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.percent {
|
||||
white-space: nowrap;
|
||||
width: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.percent.warning {
|
||||
color: var(--van-warning-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.percent.income {
|
||||
color: var(--van-success-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.time-progress {
|
||||
margin-top: -8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.time-progress .period-type,
|
||||
.time-progress .percent {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.no-limit-notice {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
background-color: var(--van-light-gray);
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.no-limit-amount-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0px 0;
|
||||
}
|
||||
|
||||
.amount-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.amount-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
}
|
||||
|
||||
.amount-item .value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--van-success-color);
|
||||
}
|
||||
|
||||
.budget-collapse-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-description {
|
||||
border-top: 1px solid var(--van-border-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
font-size: 11px;
|
||||
color: var(--van-gray-6);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mandatory-mark {
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -6,14 +6,15 @@
|
||||
<div class="chart-card gauge-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">
|
||||
<!-- 月度健康度 -->
|
||||
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
|
||||
<span class="chart-title-text">
|
||||
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
|
||||
</span>
|
||||
<van-icon
|
||||
name="info-o"
|
||||
size="16"
|
||||
color="var(--van-primary-color)"
|
||||
style="margin-left: auto; cursor: pointer"
|
||||
@click="showDescriptionPopup = true; activeDescTab = 'month'"
|
||||
class="info-icon"
|
||||
@click="handleShowDescription('month')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,15 +28,15 @@
|
||||
/>
|
||||
<div class="gauge-text-overlay">
|
||||
<div class="balance-label">
|
||||
余额
|
||||
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
||||
</div>
|
||||
<div
|
||||
class="balance-value"
|
||||
:style="{
|
||||
color:
|
||||
overallStats.month.current > overallStats.month.limit
|
||||
? 'var(--van-danger-color)'
|
||||
: ''
|
||||
activeTab === BudgetCategory.Expense
|
||||
? (overallStats.month.current > overallStats.month.limit ? 'var(--van-danger-color)' : '')
|
||||
: (overallStats.month.current < overallStats.month.limit ? 'var(--van-danger-color)' : '')
|
||||
}"
|
||||
>
|
||||
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
|
||||
@@ -44,11 +45,11 @@
|
||||
</div>
|
||||
<div class="gauge-footer">
|
||||
<div class="gauge-item">
|
||||
<span class="label">已用</span>
|
||||
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
|
||||
<span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
|
||||
</div>
|
||||
<div class="gauge-item">
|
||||
<span class="label">预算</span>
|
||||
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
|
||||
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,13 +59,15 @@
|
||||
<div class="chart-card gauge-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">
|
||||
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
|
||||
<span class="chart-title-text">
|
||||
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
|
||||
</span>
|
||||
<van-icon
|
||||
name="info-o"
|
||||
size="16"
|
||||
color="var(--van-primary-color)"
|
||||
style="margin-left: auto; cursor: pointer"
|
||||
@click="showDescriptionPopup = true; activeDescTab = 'year'"
|
||||
class="info-icon"
|
||||
@click="handleShowDescription('year')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,16 +81,15 @@
|
||||
/>
|
||||
<div class="gauge-text-overlay">
|
||||
<div class="balance-label">
|
||||
余额
|
||||
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
||||
</div>
|
||||
<div
|
||||
class="balance-value"
|
||||
:style="{
|
||||
color:
|
||||
activeTab === BudgetCategory.Expense &&
|
||||
overallStats.year.current > overallStats.year.limit
|
||||
? 'var(--van-danger-color)'
|
||||
: ''
|
||||
activeTab === BudgetCategory.Expense
|
||||
? (overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '')
|
||||
: (overallStats.year.current < overallStats.year.limit ? 'var(--van-danger-color)' : '')
|
||||
}"
|
||||
>
|
||||
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
|
||||
@@ -96,11 +98,11 @@
|
||||
</div>
|
||||
<div class="gauge-footer">
|
||||
<div class="gauge-item">
|
||||
<span class="label">已用</span>
|
||||
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
|
||||
<span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
|
||||
</div>
|
||||
<div class="gauge-item">
|
||||
<span class="label">预算</span>
|
||||
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
|
||||
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +119,7 @@
|
||||
预算进度(月度)
|
||||
</div>
|
||||
<div class="chart-subtitle">
|
||||
预算剩余消耗趋势
|
||||
{{ activeTab === BudgetCategory.Expense ? '预算剩余消耗趋势' : '收入积累趋势' }}
|
||||
</div>
|
||||
</div>
|
||||
<BaseChart
|
||||
@@ -166,6 +168,7 @@
|
||||
type="bar"
|
||||
:data="varianceChartData"
|
||||
:options="varianceChartOptions"
|
||||
:plugins="varianceChartPlugins"
|
||||
class="chart-body variance-chart"
|
||||
:style="{ height: calculateChartHeight(budgets) + 'px' }"
|
||||
/>
|
||||
@@ -184,13 +187,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 详细描述弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showDescriptionPopup"
|
||||
<PopupContainerV2
|
||||
v-model:show="showDescriptionPopup"
|
||||
:title="activeDescTab === 'month' ? '预算额度/实际详情(月度)' : '预算额度/实际详情(年度)'"
|
||||
height="70%"
|
||||
:height="'70%'"
|
||||
>
|
||||
<div
|
||||
class="rich-html-content popup-content-padding"
|
||||
class="rich-html-content"
|
||||
style="padding: 16px"
|
||||
v-html="
|
||||
activeDescTab === 'month'
|
||||
? overallStats.month?.description ||
|
||||
@@ -199,14 +203,14 @@
|
||||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>'
|
||||
"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
|
||||
import { getCssVar } from '@/utils/theme'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import BaseChart from '@/components/Charts/BaseChart.vue'
|
||||
import { useChartTheme } from '@/composables/useChartTheme'
|
||||
import { chartjsGaugePlugin } from '@/plugins/chartjs-gauge-plugin'
|
||||
@@ -238,8 +242,14 @@ const props = defineProps({
|
||||
const showDescriptionPopup = ref(false)
|
||||
const activeDescTab = ref('month')
|
||||
|
||||
// 显示描述弹窗
|
||||
const handleShowDescription = (tab) => {
|
||||
activeDescTab.value = tab
|
||||
showDescriptionPopup.value = true
|
||||
}
|
||||
|
||||
// Chart.js 相关
|
||||
const { getChartOptions, getChartOptionsByType } = useChartTheme()
|
||||
const { getChartOptions } = useChartTheme()
|
||||
|
||||
const formatMoney = (val) => {
|
||||
if (Math.abs(val) >= 10000) {
|
||||
@@ -447,6 +457,78 @@ const calculateChartHeight = (budgets) => {
|
||||
return calculatedHeight
|
||||
}
|
||||
|
||||
const varianceLabelPlugin = {
|
||||
id: 'variance-label-plugin',
|
||||
afterDatasetsDraw: (chart) => {
|
||||
const dataset = chart.data?.datasets?.[0]
|
||||
const metaData = dataset?._meta
|
||||
if (!dataset || !metaData) {
|
||||
return
|
||||
}
|
||||
|
||||
const meta = chart.getDatasetMeta(0)
|
||||
if (!meta?.data) {
|
||||
return
|
||||
}
|
||||
|
||||
const { ctx, chartArea } = chart
|
||||
const fontFamily = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif'
|
||||
ctx.save()
|
||||
ctx.font = `12px ${fontFamily}`
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
meta.data.forEach((bar, index) => {
|
||||
const item = metaData[index]
|
||||
if (!item || item.value === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const label = formatVarianceLabelValue(item.value)
|
||||
const textWidth = ctx.measureText(label).width
|
||||
const position = bar.tooltipPosition ? bar.tooltipPosition() : { x: bar.x, y: bar.y }
|
||||
const offset = 8
|
||||
const isPositive = item.value > 0
|
||||
ctx.fillStyle = getVarianceLabelColor(item.value)
|
||||
let x = position.x + (isPositive ? offset : -offset)
|
||||
const y = position.y
|
||||
|
||||
if (chartArea) {
|
||||
const rightLimit = chartArea.right - 4
|
||||
const leftLimit = chartArea.left + 4
|
||||
if (isPositive && x + textWidth > rightLimit) {
|
||||
x = rightLimit - textWidth
|
||||
}
|
||||
if (!isPositive && x - textWidth < leftLimit) {
|
||||
x = leftLimit + textWidth
|
||||
}
|
||||
}
|
||||
|
||||
ctx.textAlign = isPositive ? 'left' : 'right'
|
||||
|
||||
ctx.fillText(label, x, y)
|
||||
})
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
const varianceChartPlugins = computed(() => [varianceLabelPlugin])
|
||||
|
||||
const formatVarianceLabelValue = (value) => {
|
||||
const absValue = Math.abs(Math.round(value || 0))
|
||||
return absValue.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
})
|
||||
}
|
||||
|
||||
const getVarianceLabelColor = (value) => {
|
||||
if (props.activeTab === BudgetCategory.Expense) {
|
||||
return value > 0 ? getCssVar('--van-danger-color') : getCssVar('--van-success-color')
|
||||
}
|
||||
return value > 0 ? getCssVar('--van-success-color') : getCssVar('--van-danger-color')
|
||||
}
|
||||
|
||||
// 偏差分析图表数据
|
||||
const varianceChartData = computed(() => {
|
||||
if (!props.budgets || props.budgets.length === 0) {
|
||||
@@ -469,10 +551,19 @@ const varianceChartData = computed(() => {
|
||||
const monthlyData = data.filter((item) => item.type === BudgetPeriodType.Month)
|
||||
const annualData = data.filter((item) => item.type === BudgetPeriodType.Year)
|
||||
|
||||
monthlyData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
|
||||
annualData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
|
||||
const sortByLimitAndRemaining = (a, b) => {
|
||||
if (a.limit !== b.limit) {
|
||||
return a.limit - b.limit
|
||||
}
|
||||
const remainingA = a.limit - a.current
|
||||
const remainingB = b.limit - b.current
|
||||
return remainingB - remainingA
|
||||
}
|
||||
|
||||
const sortedData = [...annualData, ...monthlyData]
|
||||
monthlyData.sort(sortByLimitAndRemaining)
|
||||
annualData.sort(sortByLimitAndRemaining)
|
||||
|
||||
const sortedData = [...monthlyData, ...annualData]
|
||||
|
||||
return {
|
||||
labels: sortedData.map((item) => item.name),
|
||||
@@ -500,7 +591,7 @@ const varianceChartData = computed(() => {
|
||||
})
|
||||
|
||||
const varianceChartOptions = computed(() => {
|
||||
return getChartOptionsByType('bar', {
|
||||
return getChartOptions({
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -513,10 +604,19 @@ const varianceChartOptions = computed(() => {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const item = context.dataset._meta[context.dataIndex]
|
||||
const diffText =
|
||||
item.value > 0
|
||||
const isExpense = props.activeTab === BudgetCategory.Expense
|
||||
|
||||
let diffText
|
||||
if (isExpense) {
|
||||
diffText = item.value > 0
|
||||
? `超支: ¥${formatMoney(item.value)}`
|
||||
: `结余: ¥${formatMoney(Math.abs(item.value))}`
|
||||
} else {
|
||||
diffText = item.value > 0
|
||||
? `超额: ¥${formatMoney(item.value)}`
|
||||
: `未达标: ¥${formatMoney(Math.abs(item.value))}`
|
||||
}
|
||||
|
||||
return [
|
||||
`预算: ¥${formatMoney(item.limit)}`,
|
||||
`实际: ¥${formatMoney(item.current)}`,
|
||||
@@ -655,7 +755,7 @@ const burndownChartData = computed(() => {
|
||||
})
|
||||
|
||||
const burndownChartOptions = computed(() => {
|
||||
return getChartOptionsByType('line', {
|
||||
return getChartOptions({
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
@@ -816,7 +916,7 @@ const yearBurndownChartData = computed(() => {
|
||||
})
|
||||
|
||||
const yearBurndownChartOptions = computed(() => {
|
||||
return getChartOptionsByType('line', {
|
||||
return getChartOptions({
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
@@ -956,9 +1056,24 @@ const yearBurndownChartOptions = computed(() => {
|
||||
font-weight: 600;
|
||||
color: var(--van-text-color);
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-title-text {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
}
|
||||
|
||||
.chart-subtitle {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
<PopupContainerV2
|
||||
v-model:show="visible"
|
||||
:title="
|
||||
isEdit
|
||||
? `编辑${getCategoryName(form.category)}预算`
|
||||
: `新增${getCategoryName(form.category)}预算`
|
||||
"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<div class="add-budget-form">
|
||||
<van-form>
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="form.name"
|
||||
v-model:show="form.name"
|
||||
name="name"
|
||||
label="预算名称"
|
||||
placeholder="例如:每月餐饮、年度奖金"
|
||||
@@ -22,7 +22,7 @@
|
||||
<van-field label="不记额预算">
|
||||
<template #input>
|
||||
<van-checkbox
|
||||
v-model="form.noLimit"
|
||||
v-model:show="form.noLimit"
|
||||
@update:model-value="onNoLimitChange"
|
||||
>
|
||||
不记额预算
|
||||
@@ -34,7 +34,7 @@
|
||||
<template #input>
|
||||
<div class="mandatory-wrapper">
|
||||
<van-checkbox
|
||||
v-model="form.isMandatoryExpense"
|
||||
v-model:show="form.isMandatoryExpense"
|
||||
:disabled="form.noLimit"
|
||||
>
|
||||
{{ form.category === BudgetCategory.Expense ? '硬性消费' : '硬性收入' }}
|
||||
@@ -49,7 +49,7 @@
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="form.type"
|
||||
v-model:show="form.type"
|
||||
direction="horizontal"
|
||||
:disabled="isEdit || form.noLimit"
|
||||
>
|
||||
@@ -65,7 +65,7 @@
|
||||
<!-- 仅当未选中"不记额预算"时显示预算金额 -->
|
||||
<van-field
|
||||
v-if="!form.noLimit"
|
||||
v-model="form.limit"
|
||||
v-model:show="form.limit"
|
||||
type="number"
|
||||
name="limit"
|
||||
label="预算金额"
|
||||
@@ -95,7 +95,7 @@
|
||||
</template>
|
||||
</van-field>
|
||||
<ClassifySelector
|
||||
v-model="form.selectedCategories"
|
||||
v-model:show="form.selectedCategories"
|
||||
:type="budgetType"
|
||||
multiple
|
||||
:show-add="false"
|
||||
@@ -114,7 +114,7 @@
|
||||
保存预算
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -122,7 +122,7 @@ import { ref, reactive, computed } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { createBudget, updateBudget } from '@/api/budget'
|
||||
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
<PopupContainerV2
|
||||
v-model:show="visible"
|
||||
title="设置存款分类"
|
||||
height="60%"
|
||||
:height="'60%'"
|
||||
>
|
||||
<div class="savings-config-content">
|
||||
<div class="config-header">
|
||||
@@ -16,7 +16,7 @@
|
||||
可多选分类
|
||||
</div>
|
||||
<ClassifySelector
|
||||
v-model="selectedCategories"
|
||||
v-model:show="selectedCategories"
|
||||
:type="2"
|
||||
multiple
|
||||
:show-add="false"
|
||||
@@ -35,14 +35,14 @@
|
||||
保存配置
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showToast, showLoadingToast, closeToast } from 'vant'
|
||||
import { getConfig, setConfig } from '@/api/config'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
@@ -1,111 +1,120 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
<PopupContainerV2
|
||||
v-model:show="visible"
|
||||
:title="title"
|
||||
:subtitle="total > 0 ? `共 ${total} 笔交易` : ''"
|
||||
:closeable="true"
|
||||
:height="'80%'"
|
||||
>
|
||||
<!-- 交易列表 -->
|
||||
<div class="transactions">
|
||||
<!-- 加载状态 -->
|
||||
<van-loading
|
||||
v-if="loading && transactions.length === 0"
|
||||
class="txn-loading"
|
||||
size="24px"
|
||||
vertical
|
||||
>
|
||||
加载中...
|
||||
</van-loading>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div style="padding: 0">
|
||||
<!-- Subtitle 作为内容区域顶部 -->
|
||||
<div
|
||||
v-else-if="transactions.length === 0"
|
||||
class="txn-empty"
|
||||
v-if="total > 0"
|
||||
style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)"
|
||||
>
|
||||
<div class="empty-icon">
|
||||
<van-icon
|
||||
name="balance-list-o"
|
||||
size="48"
|
||||
/>
|
||||
</div>
|
||||
<div class="empty-text">
|
||||
暂无交易记录
|
||||
</div>
|
||||
共 {{ total }} 笔交易
|
||||
</div>
|
||||
|
||||
<!-- 交易列表 -->
|
||||
<div
|
||||
v-else
|
||||
class="txn-list"
|
||||
>
|
||||
<div
|
||||
v-for="txn in transactions"
|
||||
:key="txn.id"
|
||||
class="txn-card"
|
||||
@click="onTransactionClick(txn)"
|
||||
<div class="transactions">
|
||||
<!-- 加载状态 -->
|
||||
<van-loading
|
||||
v-if="loading && transactions.length === 0"
|
||||
class="txn-loading"
|
||||
size="24px"
|
||||
vertical
|
||||
>
|
||||
<div
|
||||
class="txn-icon"
|
||||
:style="{ backgroundColor: txn.iconBg }"
|
||||
>
|
||||
加载中...
|
||||
</van-loading>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="transactions.length === 0"
|
||||
class="txn-empty"
|
||||
>
|
||||
<div class="empty-icon">
|
||||
<van-icon
|
||||
:name="txn.icon"
|
||||
:color="txn.iconColor"
|
||||
name="balance-list-o"
|
||||
size="48"
|
||||
/>
|
||||
</div>
|
||||
<div class="txn-content">
|
||||
<div class="txn-name">
|
||||
{{ txn.reason }}
|
||||
</div>
|
||||
<div class="txn-footer">
|
||||
<div class="txn-time">
|
||||
{{ formatDateTime(txn.occurredAt) }}
|
||||
</div>
|
||||
<span
|
||||
v-if="txn.classify"
|
||||
class="txn-classify-tag"
|
||||
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
|
||||
>
|
||||
{{ txn.classify }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="txn-amount">
|
||||
{{ formatAmount(txn.amount, txn.type) }}
|
||||
<div class="empty-text">
|
||||
暂无交易记录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div
|
||||
v-if="!finished"
|
||||
class="load-more"
|
||||
>
|
||||
<van-loading
|
||||
v-if="loading"
|
||||
size="20px"
|
||||
>
|
||||
加载中...
|
||||
</van-loading>
|
||||
<van-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="loadMore"
|
||||
>
|
||||
加载更多
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 已加载全部 -->
|
||||
<!-- 交易列表 -->
|
||||
<div
|
||||
v-else
|
||||
class="finished-text"
|
||||
class="txn-list"
|
||||
>
|
||||
已加载全部
|
||||
<div
|
||||
v-for="txn in transactions"
|
||||
:key="txn.id"
|
||||
class="txn-card"
|
||||
@click="onTransactionClick(txn)"
|
||||
>
|
||||
<div
|
||||
class="txn-icon"
|
||||
:style="{ backgroundColor: txn.iconBg }"
|
||||
>
|
||||
<van-icon
|
||||
:name="txn.icon"
|
||||
:color="txn.iconColor"
|
||||
/>
|
||||
</div>
|
||||
<div class="txn-content">
|
||||
<div class="txn-name">
|
||||
{{ txn.reason }}
|
||||
</div>
|
||||
<div class="txn-footer">
|
||||
<div class="txn-time">
|
||||
{{ formatDateTime(txn.occurredAt) }}
|
||||
</div>
|
||||
<span
|
||||
v-if="txn.classify"
|
||||
class="txn-classify-tag"
|
||||
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
|
||||
>
|
||||
{{ txn.classify }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="txn-amount">
|
||||
{{ formatAmount(txn.amount, txn.type) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div
|
||||
v-if="!finished"
|
||||
class="load-more"
|
||||
>
|
||||
<van-loading
|
||||
v-if="loading"
|
||||
size="20px"
|
||||
>
|
||||
加载中...
|
||||
</van-loading>
|
||||
<van-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="loadMore"
|
||||
>
|
||||
加载更多
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 已加载全部 -->
|
||||
<div
|
||||
v-else
|
||||
class="finished-text"
|
||||
>
|
||||
已加载全部
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 交易详情弹窗 -->
|
||||
<TransactionDetailSheet
|
||||
@@ -120,7 +129,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import { getTransactionList } from '@/api/transactionRecord'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
<template>
|
||||
<div class="base-chart" ref="chartContainer">
|
||||
<van-loading v-if="loading" size="24px" vertical>加载中...</van-loading>
|
||||
<van-empty v-else-if="isEmpty" description="暂无数据" />
|
||||
<div
|
||||
ref="chartContainer"
|
||||
class="base-chart"
|
||||
>
|
||||
<van-loading
|
||||
v-if="loading"
|
||||
size="24px"
|
||||
vertical
|
||||
>
|
||||
加载中...
|
||||
</van-loading>
|
||||
<van-empty
|
||||
v-else-if="isEmpty"
|
||||
description="暂无数据"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="chartComponent"
|
||||
v-else
|
||||
:data="data"
|
||||
:options="mergedOptions"
|
||||
:plugins="chartPlugins"
|
||||
@@ -13,8 +25,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Line, Bar, Pie, Doughnut } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
@@ -45,25 +57,33 @@ ChartJS.register(
|
||||
Filler
|
||||
)
|
||||
|
||||
interface Props {
|
||||
type: 'line' | 'bar' | 'pie' | 'doughnut'
|
||||
data: any
|
||||
options?: any
|
||||
plugins?: any[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
options: () => ({}),
|
||||
plugins: () => [],
|
||||
loading: false
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['line', 'bar', 'pie', 'doughnut'].includes(value)
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
plugins: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'chart:render', chart: any): void
|
||||
}>()
|
||||
const emit = defineEmits(['chart:render'])
|
||||
|
||||
const chartContainer = ref<HTMLDivElement>()
|
||||
const chartContainer = ref()
|
||||
const { getChartOptions } = useChartTheme()
|
||||
|
||||
// 图表组件映射
|
||||
@@ -79,13 +99,34 @@ const chartComponent = computed(() => {
|
||||
|
||||
// 检查是否为空数据
|
||||
const isEmpty = computed(() => {
|
||||
if (!props.data || !props.data.datasets) return true
|
||||
return props.data.datasets.length === 0 || props.data.datasets.every((ds: any) => !ds.data || ds.data.length === 0)
|
||||
if (!props.data || !props.data.datasets) {return true}
|
||||
return props.data.datasets.length === 0 || props.data.datasets.every((ds) => !ds.data || ds.data.length === 0)
|
||||
})
|
||||
|
||||
// 合并配置项
|
||||
const mergedOptions = computed(() => {
|
||||
return getChartOptions(props.options)
|
||||
const isCircularChart = props.type === 'pie' || props.type === 'doughnut'
|
||||
|
||||
// 先调用主题合并
|
||||
const merged = getChartOptions(props.options)
|
||||
|
||||
// pie/doughnut 不需要 x/y 坐标轴;强制隐藏 scales 避免网格线
|
||||
if (isCircularChart) {
|
||||
// 如果用户没有显式传 scales,或者传入的 scales 没有明确 display 设置
|
||||
// 则强制禁用坐标轴(圆形图表不应该显示笛卡尔坐标系)
|
||||
if (!props.options?.scales) {
|
||||
// 用户完全没传 scales,直接删除
|
||||
delete merged.scales
|
||||
} else {
|
||||
// 用户传了 scales,确保 display 设置为 false
|
||||
if (merged.scales) {
|
||||
if (merged.scales.x) {merged.scales.x.display = false}
|
||||
if (merged.scales.y) {merged.scales.y.display = false}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
})
|
||||
|
||||
// 图表插件(包含用户传入的插件)
|
||||
@@ -94,10 +135,10 @@ const chartPlugins = computed(() => {
|
||||
})
|
||||
|
||||
// 响应式处理:监听容器大小变化
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let resizeObserver = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!chartContainer.value) return
|
||||
if (!chartContainer.value) {return}
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
// Chart.js 会自动处理 resize,这里只是确保容器正确
|
||||
@@ -114,7 +155,7 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
// 图表渲染完成回调
|
||||
const onChartRender = (chart: any) => {
|
||||
const onChartRender = (chart) => {
|
||||
emit('chart:render', chart)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,6 +121,7 @@ const formattedTitle = computed(() => {
|
||||
background: transparent !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 60px; /* 与 balance-header 保持一致,防止切换抖动 */
|
||||
}
|
||||
|
||||
.header-content {
|
||||
|
||||
@@ -9,41 +9,43 @@
|
||||
</div>
|
||||
|
||||
<!-- Add Bill Modal -->
|
||||
<PopupContainer
|
||||
v-model="showAddBill"
|
||||
<PopupContainerV2
|
||||
v-model:show="showAddBill"
|
||||
title="记一笔"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<van-tabs
|
||||
v-model:active="activeTab"
|
||||
shrink
|
||||
>
|
||||
<van-tab
|
||||
title="一句话录账"
|
||||
name="one"
|
||||
<div style="padding: 0">
|
||||
<van-tabs
|
||||
v-model:active="activeTab"
|
||||
shrink
|
||||
>
|
||||
<OneLineBillAdd
|
||||
:key="componentKey"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
</van-tab>
|
||||
<van-tab
|
||||
title="手动录账"
|
||||
name="manual"
|
||||
>
|
||||
<ManualBillAdd
|
||||
:key="componentKey"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</PopupContainer>
|
||||
<van-tab
|
||||
title="一句话录账"
|
||||
name="one"
|
||||
>
|
||||
<OneLineBillAdd
|
||||
:key="componentKey"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
</van-tab>
|
||||
<van-tab
|
||||
title="手动录账"
|
||||
name="manual"
|
||||
>
|
||||
<ManualBillAdd
|
||||
:key="componentKey"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
</PopupContainerV2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineEmits } from 'vue'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import OneLineBillAdd from '@/components/Bill/OneLineBillAdd.vue'
|
||||
import ManualBillAdd from '@/components/Bill/ManualBillAdd.vue'
|
||||
|
||||
|
||||
@@ -3,30 +3,38 @@
|
||||
class="iconify"
|
||||
:data-icon="iconIdentifier"
|
||||
:style="iconStyle"
|
||||
></span>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
iconIdentifier: string
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
color?: string
|
||||
size?: string | number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
color: undefined,
|
||||
size: undefined
|
||||
const props = defineProps({
|
||||
iconIdentifier: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: '1em'
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: '1em'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const iconStyle = computed(() => {
|
||||
const style: Record<string, string> = {}
|
||||
|
||||
const style = {}
|
||||
|
||||
if (props.width) {
|
||||
style.width = typeof props.width === 'number' ? `${props.width}px` : props.width
|
||||
}
|
||||
@@ -40,7 +48,7 @@ const iconStyle = computed(() => {
|
||||
const size = typeof props.size === 'number' ? `${props.size}px` : props.size
|
||||
style.fontSize = size
|
||||
}
|
||||
|
||||
|
||||
return style
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
<PopupContainerV2
|
||||
:show="show"
|
||||
:title="title"
|
||||
show-cancel-button
|
||||
show-confirm-button
|
||||
confirm-text="选择"
|
||||
cancel-text="取消"
|
||||
:height="'80%'"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="icon-selector">
|
||||
<!-- 搜索框 -->
|
||||
@@ -20,7 +15,10 @@
|
||||
/>
|
||||
|
||||
<!-- 图标列表 -->
|
||||
<div class="icon-list" v-if="filteredIcons.length > 0">
|
||||
<div
|
||||
v-if="filteredIcons.length > 0"
|
||||
class="icon-list"
|
||||
>
|
||||
<div
|
||||
v-for="icon in paginatedIcons"
|
||||
:key="icon.iconIdentifier"
|
||||
@@ -38,50 +36,68 @@
|
||||
</div>
|
||||
|
||||
<!-- 无结果提示 -->
|
||||
<van-empty v-else description="未找到匹配的图标" />
|
||||
<van-empty
|
||||
v-else
|
||||
description="未找到匹配的图标"
|
||||
/>
|
||||
|
||||
<!-- 分页 -->
|
||||
<van-pagination
|
||||
v-if="totalPages > 1"
|
||||
v-model:currentPage="currentPage"
|
||||
v-model:current-page="currentPage"
|
||||
:total-items="filteredIcons.length"
|
||||
:items-per-page="pageSize"
|
||||
@change="handlePageChange"
|
||||
class="pagination"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</PopupContainer>
|
||||
<template #footer>
|
||||
<div style="display: flex; gap: 12px">
|
||||
<van-button
|
||||
plain
|
||||
style="flex: 1"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</van-button>
|
||||
<van-button
|
||||
type="primary"
|
||||
style="flex: 1"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
选择
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import Icon from './Icon.vue'
|
||||
import PopupContainer from './PopupContainer.vue'
|
||||
import PopupContainerV2 from './PopupContainerV2.vue'
|
||||
|
||||
interface Icon {
|
||||
iconIdentifier: string
|
||||
iconName: string
|
||||
collectionName: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
icons: Icon[]
|
||||
title?: string
|
||||
defaultIconIdentifier?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '选择图标',
|
||||
defaultIconIdentifier: ''
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
icons: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '选择图标'
|
||||
},
|
||||
defaultIconIdentifier: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
confirm: [iconIdentifier: string]
|
||||
cancel: []
|
||||
}>()
|
||||
const emit = defineEmits(['update:show', 'confirm', 'cancel'])
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
@@ -93,9 +109,9 @@ const filteredIcons = computed(() => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
return props.icons
|
||||
}
|
||||
|
||||
|
||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
||||
return props.icons.filter(icon =>
|
||||
return props.icons.filter(icon =>
|
||||
icon.iconName.toLowerCase().includes(keyword) ||
|
||||
icon.collectionName.toLowerCase().includes(keyword) ||
|
||||
icon.iconIdentifier.toLowerCase().includes(keyword)
|
||||
@@ -115,11 +131,11 @@ const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleSelectIcon = (icon: Icon) => {
|
||||
const handleSelectIcon = (icon) => {
|
||||
selectedIconIdentifier.value = icon.iconIdentifier
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
const handlePageChange = (page) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
@@ -154,7 +170,7 @@ watch(() => props.defaultIconIdentifier, (newVal) => {
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
.icon-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -165,7 +181,7 @@ watch(() => props.defaultIconIdentifier, (newVal) => {
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
.icon-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -175,25 +191,25 @@ watch(() => props.defaultIconIdentifier, (newVal) => {
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
|
||||
&:hover {
|
||||
border-color: #1989fa;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
|
||||
&.active {
|
||||
border-color: #1989fa;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.icon-label {
|
||||
font-size: 12px;
|
||||
color: #646464;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.pagination {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
<!--
|
||||
统一弹窗组件
|
||||
|
||||
## 基础用法
|
||||
<PopupContainer v-model:show="show" title="标题">
|
||||
内容
|
||||
</PopupContainer>
|
||||
|
||||
## 确认对话框用法
|
||||
<PopupContainer
|
||||
v-model:show="showConfirm"
|
||||
title="确认操作"
|
||||
show-confirm-button
|
||||
show-cancel-button
|
||||
confirm-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
确定要执行此操作吗?
|
||||
</PopupContainer>
|
||||
|
||||
## 带副标题和页脚
|
||||
<PopupContainer
|
||||
v-model:show="show"
|
||||
title="分类详情"
|
||||
subtitle="共 10 笔交易"
|
||||
>
|
||||
内容区域
|
||||
<template #footer>
|
||||
<van-button type="primary">提交</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
-->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
:style="{ height: height }"
|
||||
round
|
||||
:closeable="closeable"
|
||||
teleport="body"
|
||||
>
|
||||
<div class="popup-container">
|
||||
<!-- 头部区域 -->
|
||||
<div class="popup-header-fixed">
|
||||
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
|
||||
<div
|
||||
class="header-title-row"
|
||||
:class="{ 'has-actions': !subtitle && hasActions }"
|
||||
>
|
||||
<h3 class="popup-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<!-- 无子标题时,操作按钮与标题同行 -->
|
||||
<div
|
||||
v-if="!subtitle && hasActions"
|
||||
class="header-actions-inline"
|
||||
>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子标题/统计信息 -->
|
||||
<div
|
||||
v-if="subtitle"
|
||||
class="header-stats"
|
||||
>
|
||||
<span
|
||||
class="stats-text"
|
||||
v-html="subtitle"
|
||||
/>
|
||||
<!-- 额外操作插槽 -->
|
||||
<slot
|
||||
v-if="hasActions"
|
||||
name="header-actions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域(可滚动) -->
|
||||
<div class="popup-scroll-content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 底部页脚,固定不可滚动 -->
|
||||
<div
|
||||
v-if="slots.footer || showConfirmButton || showCancelButton"
|
||||
class="popup-footer-fixed"
|
||||
>
|
||||
<!-- 用户自定义页脚插槽 -->
|
||||
<slot name="footer">
|
||||
<!-- 默认确认/取消按钮 -->
|
||||
<div class="footer-buttons">
|
||||
<van-button
|
||||
v-if="showCancelButton"
|
||||
plain
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="showConfirmButton"
|
||||
type="primary"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</van-button>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '80%'
|
||||
},
|
||||
closeable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showConfirmButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showCancelButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '确认'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: '取消'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
// 双向绑定
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
// 判断是否有操作按钮
|
||||
const hasActions = computed(() => !!slots['header-actions'])
|
||||
|
||||
// 确认按钮点击
|
||||
const handleConfirm = () => {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
// 取消按钮点击
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.popup-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popup-header-fixed {
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
background: linear-gradient(180deg, var(--van-background) 0%, var(--van-background-2) 100%);
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-title-row.has-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title-row.has-actions .popup-title {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions-inline {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--van-text-color);
|
||||
letter-spacing: -0.02em;
|
||||
/*超出长度*/
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stats-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--van-text-color-2);
|
||||
grid-column: 2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 按钮区域放在右侧 */
|
||||
.header-stats :deep(> :last-child:not(.stats-text)) {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.popup-scroll-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.popup-footer-fixed {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--van-border-color);
|
||||
background-color: var(--van-background-2);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.footer-buttons .van-button {
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
}
|
||||
</style>
|
||||
180
Web/src/components/PopupContainerV2.vue
Normal file
180
Web/src/components/PopupContainerV2.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<!--
|
||||
PopupContainer V2 - 通用底部弹窗组件(采用 TransactionDetailSheet 样式风格)
|
||||
|
||||
## 与 V1 的区别
|
||||
- V1 (PopupContainer.vue): 使用 Vant 主题变量,标准化布局,默认高度 80%
|
||||
- V2 (PopupContainerV2.vue): 使用 Inter 字体,16px 圆角,纯白背景,更现代化的视觉风格
|
||||
|
||||
## 基础用法
|
||||
<PopupContainerV2 v-model:show="show" title="标题">
|
||||
<div class="content">内容区域</div>
|
||||
<template #footer>
|
||||
<van-button type="primary">确定</van-button>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
|
||||
## Props
|
||||
- show (Boolean, required): 控制弹窗显示/隐藏
|
||||
- title (String, required): 标题文本
|
||||
- height (String, default: 'auto'): 弹窗高度,支持 'auto', '80%', '500px' 等
|
||||
- maxHeight (String, default: '85%'): 最大高度
|
||||
|
||||
## Slots
|
||||
- default: 可滚动的内容区域(不提供默认 padding,由使用方控制)
|
||||
- footer: 固定底部区域(操作按钮等)
|
||||
|
||||
## Events
|
||||
- update:show: 弹窗显示/隐藏状态变更
|
||||
-->
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
:style="{
|
||||
height: height === 'auto' ? maxHeight : height,
|
||||
borderTopLeftRadius: '16px',
|
||||
borderTopRightRadius: '16px'
|
||||
}"
|
||||
teleport="body"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="popup-container-v2">
|
||||
<!-- 固定头部 -->
|
||||
<div class="popup-header">
|
||||
<h3 class="popup-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<van-icon
|
||||
name="cross"
|
||||
class="popup-close"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 可滚动内容区域 -->
|
||||
<div class="popup-content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 固定底部 -->
|
||||
<div
|
||||
v-if="hasFooter"
|
||||
class="popup-footer"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: 'auto'
|
||||
},
|
||||
maxHeight: {
|
||||
type: String,
|
||||
default: '85%'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
// 双向绑定
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value)
|
||||
})
|
||||
|
||||
// 判断是否有 footer 插槽
|
||||
const hasFooter = computed(() => !!slots.footer)
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.popup-container-v2 {
|
||||
background: #ffffff;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 固定头部
|
||||
.popup-header {
|
||||
flex-shrink: 0;
|
||||
padding: 24px;
|
||||
padding-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.popup-title {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #09090b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #71717a;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 可滚动内容区域
|
||||
.popup-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
// 不提供默认 padding,由使用方控制
|
||||
}
|
||||
|
||||
// 固定底部
|
||||
.popup-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 24px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.popup-container-v2 {
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
.popup-title {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -61,34 +61,42 @@
|
||||
</van-cell-group>
|
||||
|
||||
<!-- 账单列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showTransactionList"
|
||||
<PopupContainerV2
|
||||
v-model:show="showTransactionList"
|
||||
:title="selectedGroup?.reason || '交易记录'"
|
||||
:subtitle="groupTransactionsTotal ? `共 ${groupTransactionsTotal} 笔交易` : ''"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<template #header-actions>
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="batch-classify-btn"
|
||||
@click.stop="handleBatchClassify(selectedGroup)"
|
||||
>
|
||||
批量分类
|
||||
</van-button>
|
||||
</template>
|
||||
<div style="padding: 0">
|
||||
<!-- Subtitle 和操作按钮 -->
|
||||
<div style="padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--van-border-color)">
|
||||
<span
|
||||
v-if="groupTransactionsTotal"
|
||||
style="color: #999; font-size: 14px"
|
||||
>
|
||||
共 {{ groupTransactionsTotal }} 笔交易
|
||||
</span>
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="batch-classify-btn"
|
||||
@click.stop="handleBatchClassify(selectedGroup)"
|
||||
>
|
||||
批量分类
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<BillListComponent
|
||||
data-source="custom"
|
||||
:transactions="groupTransactions"
|
||||
:loading="transactionLoading"
|
||||
:finished="transactionFinished"
|
||||
:enable-filter="false"
|
||||
@load="loadGroupTransactions"
|
||||
@click="handleTransactionClick"
|
||||
@delete="handleGroupTransactionDelete"
|
||||
/>
|
||||
</PopupContainer>
|
||||
<BillListComponent
|
||||
data-source="custom"
|
||||
:transactions="groupTransactions"
|
||||
:loading="transactionLoading"
|
||||
:finished="transactionFinished"
|
||||
:enable-filter="false"
|
||||
@load="loadGroupTransactions"
|
||||
@click="handleTransactionClick"
|
||||
@delete="handleGroupTransactionDelete"
|
||||
/>
|
||||
</div>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 账单详情弹窗 -->
|
||||
<TransactionDetail
|
||||
@@ -98,76 +106,78 @@
|
||||
/>
|
||||
|
||||
<!-- 批量设置对话框 -->
|
||||
<PopupContainer
|
||||
v-model="showBatchDialog"
|
||||
<PopupContainerV2
|
||||
v-model:show="showBatchDialog"
|
||||
title="批量设置分类"
|
||||
height="60%"
|
||||
:height="'60%'"
|
||||
>
|
||||
<van-form
|
||||
ref="batchFormRef"
|
||||
class="setting-form"
|
||||
>
|
||||
<van-cell-group inset>
|
||||
<!-- 显示选中的摘要 -->
|
||||
<van-field
|
||||
:model-value="batchGroup?.reason"
|
||||
label="交易摘要"
|
||||
readonly
|
||||
input-align="left"
|
||||
/>
|
||||
<div style="padding: 0">
|
||||
<van-form
|
||||
ref="batchFormRef"
|
||||
class="setting-form"
|
||||
>
|
||||
<van-cell-group inset>
|
||||
<!-- 显示选中的摘要 -->
|
||||
<van-field
|
||||
:model-value="batchGroup?.reason"
|
||||
label="交易摘要"
|
||||
readonly
|
||||
input-align="left"
|
||||
/>
|
||||
|
||||
<!-- 显示记录数量 -->
|
||||
<van-field
|
||||
:model-value="`${batchGroup?.count || 0} 条`"
|
||||
label="记录数量"
|
||||
readonly
|
||||
input-align="left"
|
||||
/>
|
||||
<!-- 显示记录数量 -->
|
||||
<van-field
|
||||
:model-value="`${batchGroup?.count || 0} 条`"
|
||||
label="记录数量"
|
||||
readonly
|
||||
input-align="left"
|
||||
/>
|
||||
|
||||
<!-- 交易类型 -->
|
||||
<van-field
|
||||
name="type"
|
||||
label="交易类型"
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="batchForm.type"
|
||||
direction="horizontal"
|
||||
>
|
||||
<van-radio :name="0">
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio :name="1">
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio :name="2">
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<!-- 交易类型 -->
|
||||
<van-field
|
||||
name="type"
|
||||
label="交易类型"
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="batchForm.type"
|
||||
direction="horizontal"
|
||||
>
|
||||
<van-radio :name="0">
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio :name="1">
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio :name="2">
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类选择 -->
|
||||
<van-field
|
||||
name="classify"
|
||||
label="分类"
|
||||
>
|
||||
<template #input>
|
||||
<span
|
||||
v-if="!batchForm.classify"
|
||||
style="opacity: 0.4"
|
||||
>请选择分类</span>
|
||||
<span v-else>{{ batchForm.classify }}</span>
|
||||
</template>
|
||||
</van-field>
|
||||
<!-- 分类选择 -->
|
||||
<van-field
|
||||
name="classify"
|
||||
label="分类"
|
||||
>
|
||||
<template #input>
|
||||
<span
|
||||
v-if="!batchForm.classify"
|
||||
style="opacity: 0.4"
|
||||
>请选择分类</span>
|
||||
<span v-else>{{ batchForm.classify }}</span>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector
|
||||
v-model="batchForm.classify"
|
||||
:type="batchForm.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector
|
||||
v-model="batchForm.classify"
|
||||
:type="batchForm.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<van-button
|
||||
round
|
||||
@@ -178,7 +188,7 @@
|
||||
确定
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -189,7 +199,7 @@ import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/
|
||||
import ClassifySelector from './ClassifySelector.vue'
|
||||
import BillListComponent from './Bill/BillListComponent.vue'
|
||||
import TransactionDetail from './TransactionDetail.vue'
|
||||
import PopupContainer from './PopupContainer.vue'
|
||||
import PopupContainerV2 from './PopupContainerV2.vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 是否支持多选
|
||||
|
||||
@@ -1,152 +1,134 @@
|
||||
<template>
|
||||
<van-popup
|
||||
<PopupContainerV2
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
:style="{
|
||||
height: 'auto',
|
||||
maxHeight: '85%',
|
||||
borderTopLeftRadius: '16px',
|
||||
borderTopRightRadius: '16px'
|
||||
}"
|
||||
teleport="body"
|
||||
@close="handleClose"
|
||||
title="交易详情"
|
||||
height="85%"
|
||||
>
|
||||
<div class="transaction-detail-sheet">
|
||||
<!-- 头部 -->
|
||||
<div class="sheet-header">
|
||||
<div class="header-title">
|
||||
交易详情
|
||||
</div>
|
||||
<van-icon
|
||||
name="cross"
|
||||
class="header-close"
|
||||
@click="handleClose"
|
||||
/>
|
||||
<!-- 金额区域 -->
|
||||
<div class="amount-section">
|
||||
<div class="amount-label">
|
||||
金额
|
||||
</div>
|
||||
|
||||
<!-- 金额区域 -->
|
||||
<div class="amount-section">
|
||||
<div class="amount-label">
|
||||
金额
|
||||
</div>
|
||||
<!-- 只读显示模式 -->
|
||||
<div
|
||||
v-if="!isEditingAmount"
|
||||
class="amount-value"
|
||||
@click="startEditAmount"
|
||||
>
|
||||
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
|
||||
</div>
|
||||
<!-- 编辑模式 -->
|
||||
<div
|
||||
v-else
|
||||
class="amount-input-wrapper"
|
||||
>
|
||||
<span class="currency-symbol">¥</span>
|
||||
<input
|
||||
ref="amountInputRef"
|
||||
v-model="editForm.amount"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
class="amount-input"
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
min="0"
|
||||
@blur="finishEditAmount"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表单字段 -->
|
||||
<div class="form-section">
|
||||
<div class="form-row">
|
||||
<div class="form-label">
|
||||
时间
|
||||
</div>
|
||||
<div
|
||||
class="form-value clickable"
|
||||
@click="showDatePicker = true"
|
||||
>
|
||||
{{ formatDateTime(editForm.occurredAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row no-border">
|
||||
<div class="form-label">
|
||||
备注
|
||||
</div>
|
||||
<div class="form-value">
|
||||
<input
|
||||
v-model="editForm.reason"
|
||||
type="text"
|
||||
class="reason-input"
|
||||
placeholder="请输入备注"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label">
|
||||
类型
|
||||
</div>
|
||||
<div class="form-value">
|
||||
<van-radio-group
|
||||
v-model="editForm.type"
|
||||
direction="horizontal"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<van-radio
|
||||
:name="0"
|
||||
class="type-radio"
|
||||
>
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio
|
||||
:name="1"
|
||||
class="type-radio"
|
||||
>
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio
|
||||
:name="2"
|
||||
class="type-radio"
|
||||
>
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label">
|
||||
分类
|
||||
</div>
|
||||
<div
|
||||
class="form-value clickable"
|
||||
@click="showClassifySelector = !showClassifySelector"
|
||||
>
|
||||
<span v-if="editForm.classify">{{ editForm.classify }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="placeholder"
|
||||
>请选择分类</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分类选择器(展开/收起) -->
|
||||
<!-- 只读显示模式 -->
|
||||
<div
|
||||
v-if="showClassifySelector"
|
||||
class="classify-section"
|
||||
v-if="!isEditingAmount"
|
||||
class="amount-value"
|
||||
@click="startEditAmount"
|
||||
>
|
||||
<ClassifySelector
|
||||
v-model="editForm.classify"
|
||||
:type="editForm.type"
|
||||
:show-add="false"
|
||||
:show-clear="false"
|
||||
:show-all="false"
|
||||
@change="handleClassifyChange"
|
||||
/>
|
||||
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
|
||||
</div>
|
||||
<!-- 编辑模式 -->
|
||||
<div
|
||||
v-else
|
||||
class="amount-input-wrapper"
|
||||
>
|
||||
<span class="currency-symbol">¥</span>
|
||||
<input
|
||||
ref="amountInputRef"
|
||||
v-model="editForm.amount"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
class="amount-input"
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
min="0"
|
||||
@blur="finishEditAmount"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表单字段 -->
|
||||
<div class="form-section">
|
||||
<div class="form-row">
|
||||
<div class="form-label">
|
||||
时间
|
||||
</div>
|
||||
<div
|
||||
class="form-value clickable"
|
||||
@click="showDatePicker = true"
|
||||
>
|
||||
{{ formatDateTime(editForm.occurredAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="form-row no-border">
|
||||
<div class="form-label">
|
||||
备注
|
||||
</div>
|
||||
<div class="form-value">
|
||||
<input
|
||||
v-model="editForm.reason"
|
||||
type="text"
|
||||
class="reason-input"
|
||||
placeholder="请输入备注"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label">
|
||||
类型
|
||||
</div>
|
||||
<div class="form-value">
|
||||
<van-radio-group
|
||||
v-model="editForm.type"
|
||||
direction="horizontal"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<van-radio
|
||||
:name="0"
|
||||
class="type-radio"
|
||||
>
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio
|
||||
:name="1"
|
||||
class="type-radio"
|
||||
>
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio
|
||||
:name="2"
|
||||
class="type-radio"
|
||||
>
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label">
|
||||
分类
|
||||
</div>
|
||||
<div
|
||||
class="form-value clickable"
|
||||
@click="showClassifySelector = !showClassifySelector"
|
||||
>
|
||||
<span v-if="editForm.classify">{{ editForm.classify }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="placeholder"
|
||||
>请选择分类</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类选择器(展开/收起) -->
|
||||
<div
|
||||
v-if="showClassifySelector"
|
||||
class="classify-section"
|
||||
>
|
||||
<ClassifySelector
|
||||
v-model="editForm.classify"
|
||||
:type="editForm.type"
|
||||
:show-add="false"
|
||||
:show-clear="false"
|
||||
:show-all="false"
|
||||
@change="handleClassifyChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮(固定底部) -->
|
||||
<template #footer>
|
||||
<div class="actions-section">
|
||||
<van-button
|
||||
class="delete-btn"
|
||||
@@ -164,31 +146,32 @@
|
||||
保存
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 日期时间选择器 -->
|
||||
<van-popup
|
||||
v-model:show="showDatePicker"
|
||||
position="bottom"
|
||||
round
|
||||
>
|
||||
<van-datetime-picker
|
||||
v-model="currentDateTime"
|
||||
type="datetime"
|
||||
title="选择日期时间"
|
||||
:min-date="minDate"
|
||||
:max-date="maxDate"
|
||||
@confirm="handleDateTimeConfirm"
|
||||
@cancel="showDatePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
<!-- 日期时间选择器 -->
|
||||
<van-popup
|
||||
v-model:show="showDatePicker"
|
||||
position="bottom"
|
||||
round
|
||||
>
|
||||
<van-datetime-picker
|
||||
v-model="currentDateTime"
|
||||
type="datetime"
|
||||
title="选择日期时间"
|
||||
:min-date="minDate"
|
||||
:max-date="maxDate"
|
||||
@confirm="handleDateTimeConfirm"
|
||||
@cancel="showDatePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import dayjs from 'dayjs'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
import { updateTransaction, deleteTransaction } from '@/api/transactionRecord'
|
||||
|
||||
@@ -399,291 +382,249 @@ const handleDelete = async () => {
|
||||
// 用户取消删除
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.transaction-detail-sheet {
|
||||
background: #ffffff;
|
||||
padding: 24px;
|
||||
// 金额区域
|
||||
.amount-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 24px 24px;
|
||||
|
||||
.sheet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.amount-label {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #09090b;
|
||||
}
|
||||
.amount-value {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #09090b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.header-close {
|
||||
font-size: 24px;
|
||||
color: #71717a;
|
||||
cursor: pointer;
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-section {
|
||||
.amount-input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
|
||||
.amount-label {
|
||||
.currency-symbol {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #09090b;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
max-width: 200px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #09090b;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 2px solid #e4e4e7;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: #6366f1;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
// 移除 number 类型的上下箭头
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Firefox
|
||||
&[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表单区域
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0 24px 16px;
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
|
||||
&.no-border {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
.form-value {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #09090b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 16px;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.amount-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.currency-symbol {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #09090b;
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
max-width: 200px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #09090b;
|
||||
.placeholder {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.reason-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #09090b;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 2px solid #e4e4e7;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: #6366f1;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 number 类型的上下箭头
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
:deep(.van-radio-group) {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// Firefox
|
||||
&[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
:deep(.van-radio) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.van-radio__label) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分类选择器
|
||||
.classify-section {
|
||||
padding: 16px 24px;
|
||||
background: #f4f4f5;
|
||||
border-radius: 8px;
|
||||
margin: 0 24px 16px;
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
.actions-section {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.delete-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ef4444;
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: #6366f1;
|
||||
color: #fafafa;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.amount-section {
|
||||
.amount-label {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.amount-input-wrapper {
|
||||
.currency-symbol {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
color: #fafafa;
|
||||
border-bottom-color: #27272a;
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: #6366f1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
|
||||
&.no-border {
|
||||
border-bottom: none;
|
||||
}
|
||||
border-bottom-color: #27272a;
|
||||
|
||||
.form-label {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #71717a;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #09090b;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 16px;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
color: #fafafa;
|
||||
|
||||
.reason-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #09090b;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.van-radio-group) {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.van-radio) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.van-radio__label) {
|
||||
margin-left: 4px;
|
||||
color: #fafafa;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.classify-section {
|
||||
padding: 16px;
|
||||
background: #f4f4f5;
|
||||
border-radius: 8px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
|
||||
.delete-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ef4444;
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: #6366f1;
|
||||
color: #fafafa;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transaction-detail-sheet {
|
||||
background: #18181b;
|
||||
|
||||
.sheet-header {
|
||||
.header-title {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.header-close {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-section {
|
||||
.amount-label {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.amount-input-wrapper {
|
||||
.currency-symbol {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
color: #fafafa;
|
||||
border-bottom-color: #27272a;
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: #6366f1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
.form-row {
|
||||
border-bottom-color: #27272a;
|
||||
|
||||
.form-label {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
color: #fafafa;
|
||||
|
||||
.reason-input {
|
||||
color: #fafafa;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.classify-section {
|
||||
background: #27272a;
|
||||
}
|
||||
background: #27272a;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,134 +1,135 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
<PopupContainerV2
|
||||
v-model:show="visible"
|
||||
title="交易详情"
|
||||
height="75%"
|
||||
:closeable="false"
|
||||
:height="'75%'"
|
||||
>
|
||||
<van-form style="margin-top: 12px">
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
title="记录时间"
|
||||
:value="formatDate(transaction.createTime)"
|
||||
/>
|
||||
</van-cell-group>
|
||||
<div style="padding: 0">
|
||||
<van-form style="margin-top: 12px">
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
title="记录时间"
|
||||
:value="formatDate(transaction.createTime)"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group
|
||||
inset
|
||||
title="交易明细"
|
||||
>
|
||||
<van-field
|
||||
v-model="occurredAtLabel"
|
||||
name="occurredAt"
|
||||
label="交易时间"
|
||||
readonly
|
||||
is-link
|
||||
placeholder="请选择交易时间"
|
||||
:rules="[{ required: true, message: '请选择交易时间' }]"
|
||||
@click="showDatePicker = true"
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.reason"
|
||||
name="reason"
|
||||
label="交易摘要"
|
||||
placeholder="请输入交易摘要"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
autosize
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.amount"
|
||||
name="amount"
|
||||
label="交易金额"
|
||||
placeholder="请输入交易金额"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入交易金额' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.balance"
|
||||
name="balance"
|
||||
label="交易后余额"
|
||||
placeholder="请输入交易后余额"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入交易后余额' }]"
|
||||
/>
|
||||
|
||||
<van-field
|
||||
name="type"
|
||||
label="交易类型"
|
||||
<van-cell-group
|
||||
inset
|
||||
title="交易明细"
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="editForm.type"
|
||||
direction="horizontal"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<van-radio :name="0">
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio :name="1">
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio :name="2">
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="occurredAtLabel"
|
||||
name="occurredAt"
|
||||
label="交易时间"
|
||||
readonly
|
||||
is-link
|
||||
placeholder="请选择交易时间"
|
||||
:rules="[{ required: true, message: '请选择交易时间' }]"
|
||||
@click="showDatePicker = true"
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.reason"
|
||||
name="reason"
|
||||
label="交易摘要"
|
||||
placeholder="请输入交易摘要"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
autosize
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.amount"
|
||||
name="amount"
|
||||
label="交易金额"
|
||||
placeholder="请输入交易金额"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入交易金额' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.balance"
|
||||
name="balance"
|
||||
label="交易后余额"
|
||||
placeholder="请输入交易后余额"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入交易后余额' }]"
|
||||
/>
|
||||
|
||||
<van-field
|
||||
name="classify"
|
||||
label="交易分类"
|
||||
>
|
||||
<template #input>
|
||||
<div style="flex: 1">
|
||||
<div
|
||||
v-if="
|
||||
transaction &&
|
||||
transaction.unconfirmedClassify &&
|
||||
transaction.unconfirmedClassify !== editForm.classify
|
||||
"
|
||||
class="suggestion-tip"
|
||||
@click="applySuggestion"
|
||||
<van-field
|
||||
name="type"
|
||||
label="交易类型"
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="editForm.type"
|
||||
direction="horizontal"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<van-icon
|
||||
name="bulb-o"
|
||||
class="suggestion-icon"
|
||||
/>
|
||||
<span class="suggestion-text">
|
||||
建议: {{ transaction.unconfirmedClassify }}
|
||||
<span
|
||||
v-if="
|
||||
transaction.unconfirmedType !== null &&
|
||||
transaction.unconfirmedType !== undefined &&
|
||||
transaction.unconfirmedType !== editForm.type
|
||||
"
|
||||
>
|
||||
({{ getTypeName(transaction.unconfirmedType) }})
|
||||
</span>
|
||||
</span>
|
||||
<div class="suggestion-apply">
|
||||
应用
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="!editForm.classify"
|
||||
style="color: var(--van-gray-5)"
|
||||
>请选择交易分类</span>
|
||||
<span v-else>{{ editForm.classify }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-radio :name="0">
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio :name="1">
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio :name="2">
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<ClassifySelector
|
||||
v-model="editForm.classify"
|
||||
:type="editForm.type"
|
||||
@change="handleClassifyChange"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
<van-field
|
||||
name="classify"
|
||||
label="交易分类"
|
||||
>
|
||||
<template #input>
|
||||
<div style="flex: 1">
|
||||
<div
|
||||
v-if="
|
||||
transaction &&
|
||||
transaction.unconfirmedClassify &&
|
||||
transaction.unconfirmedClassify !== editForm.classify
|
||||
"
|
||||
class="suggestion-tip"
|
||||
@click="applySuggestion"
|
||||
>
|
||||
<van-icon
|
||||
name="bulb-o"
|
||||
class="suggestion-icon"
|
||||
/>
|
||||
<span class="suggestion-text">
|
||||
建议: {{ transaction.unconfirmedClassify }}
|
||||
<span
|
||||
v-if="
|
||||
transaction.unconfirmedType !== null &&
|
||||
transaction.unconfirmedType !== undefined &&
|
||||
transaction.unconfirmedType !== editForm.type
|
||||
"
|
||||
>
|
||||
({{ getTypeName(transaction.unconfirmedType) }})
|
||||
</span>
|
||||
</span>
|
||||
<div class="suggestion-apply">
|
||||
应用
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="!editForm.classify"
|
||||
style="color: var(--van-gray-5)"
|
||||
>请选择交易分类</span>
|
||||
<span v-else>{{ editForm.classify }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<ClassifySelector
|
||||
v-model="editForm.classify"
|
||||
:type="editForm.type"
|
||||
@change="handleClassifyChange"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<van-button
|
||||
@@ -141,7 +142,7 @@
|
||||
保存修改
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 日期选择弹窗 -->
|
||||
<van-popup
|
||||
@@ -178,7 +179,7 @@
|
||||
import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import dayjs from 'dayjs'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
import { updateTransaction } from '@/api/transactionRecord'
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export function useChartTheme() {
|
||||
label += ': '
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += '¥' + context.parsed.y.toFixed(2)
|
||||
label += '¥' + context.parsed.y.toFixed(0)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
60
Web/src/plugins/chartjs-pie-center-plugin.ts
Normal file
60
Web/src/plugins/chartjs-pie-center-plugin.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Plugin } from 'chart.js'
|
||||
|
||||
/**
|
||||
* 饼图中心文本插件
|
||||
* 在 Doughnut/Pie 图表中心显示总金额
|
||||
*/
|
||||
|
||||
export interface PieCenterTextOptions {
|
||||
text?: string
|
||||
subtext?: string
|
||||
textColor?: string
|
||||
subtextColor?: string
|
||||
fontSize?: number
|
||||
subFontSize?: number
|
||||
}
|
||||
|
||||
export const pieCenterTextPlugin: Plugin = {
|
||||
id: 'pieCenterText',
|
||||
afterDraw: (chart: any) => {
|
||||
const { ctx, chartArea } = chart
|
||||
|
||||
if (!chartArea) return
|
||||
|
||||
// 计算中心点
|
||||
const centerX = (chartArea.left + chartArea.right) / 2
|
||||
const centerY = (chartArea.top + chartArea.bottom) / 2
|
||||
|
||||
// 从图表配置中获取插件选项
|
||||
const pluginOptions = chart.options.plugins?.pieCenterText as PieCenterTextOptions | undefined
|
||||
|
||||
if (!pluginOptions) return
|
||||
|
||||
const { text, subtext, textColor, subtextColor, fontSize, subFontSize } = pluginOptions
|
||||
|
||||
ctx.save()
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// 计算字体大小(基于图表高度)
|
||||
const chartHeight = chartArea.bottom - chartArea.top
|
||||
const defaultFontSize = Math.max(14, Math.min(32, chartHeight * 0.2))
|
||||
const defaultSubFontSize = Math.max(10, Math.min(16, chartHeight * 0.12))
|
||||
|
||||
// 绘制主文本(金额)
|
||||
if (text) {
|
||||
ctx.font = `bold ${fontSize || defaultFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`
|
||||
ctx.fillStyle = textColor || '#323233'
|
||||
ctx.fillText(text, centerX, centerY - 5)
|
||||
}
|
||||
|
||||
// 绘制副文本(标签,如"总支出")
|
||||
if (subtext) {
|
||||
ctx.font = `${subFontSize || defaultSubFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`
|
||||
ctx.fillStyle = subtextColor || '#969799'
|
||||
ctx.fillText(subtext, centerX, centerY + (fontSize || defaultFontSize) * 0.6)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { useAuthStore } from '@/stores/auth'
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
redirect: { name: 'calendar-v2' },
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
@@ -119,8 +125,8 @@ router.beforeEach((to, from, next) => {
|
||||
// 需要认证但未登录,跳转到登录页
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else if (to.name === 'login' && authStore.isAuthenticated) {
|
||||
// 已登录用户访问登录页,跳转到首页
|
||||
next({ name: 'statistics-v2' })
|
||||
// 已登录用户访问登录页,跳转到日历页面
|
||||
next({ name: 'calendar-v2' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* 格式化金额
|
||||
* @param {number} value 金额数值
|
||||
* @param {number} decimals 小数位数
|
||||
* @returns {string} 格式化后的金额字符串
|
||||
*/
|
||||
export const formatMoney = (value) => {
|
||||
export const formatMoney = (value, decimals = 1) => {
|
||||
if (!value && value !== 0) {
|
||||
return '0'
|
||||
return Number(0).toFixed(decimals)
|
||||
}
|
||||
return Number(value)
|
||||
.toFixed(0)
|
||||
.toFixed(decimals)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<!-- 顶部导航栏 -->
|
||||
<van-nav-bar
|
||||
title="账单"
|
||||
placeholder
|
||||
>
|
||||
<template #right>
|
||||
<!-- 自定义头部 -->
|
||||
<header class="balance-header">
|
||||
<h1 class="header-title">
|
||||
账单
|
||||
</h1>
|
||||
<div class="header-actions">
|
||||
<van-button
|
||||
v-if="tabActive === 'email'"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="syncing"
|
||||
@click="emailRecordRef.handleSync()"
|
||||
@click="emailRecordRef?.handleSync()"
|
||||
>
|
||||
立即同步
|
||||
</van-button>
|
||||
@@ -21,26 +20,35 @@
|
||||
size="20"
|
||||
@click="messageViewRef?.handleMarkAllRead()"
|
||||
/>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
<van-tabs
|
||||
v-model:active="tabActive"
|
||||
type="card"
|
||||
style="margin: 12px 0 2px 0"
|
||||
>
|
||||
<van-tab
|
||||
title="账单"
|
||||
name="balance"
|
||||
/>
|
||||
<van-tab
|
||||
title="邮件"
|
||||
name="email"
|
||||
/>
|
||||
<van-tab
|
||||
title="消息"
|
||||
name="message"
|
||||
/>
|
||||
</van-tabs>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 分段控制器 -->
|
||||
<div class="tabs-wrapper">
|
||||
<div class="segmented-control">
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: tabActive === 'balance' }"
|
||||
@click="tabActive = 'balance'"
|
||||
>
|
||||
<span class="tab-text">账单</span>
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: tabActive === 'email' }"
|
||||
@click="tabActive = 'email'"
|
||||
>
|
||||
<span class="tab-text">邮件</span>
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: tabActive === 'message' }"
|
||||
@click="tabActive = 'message'"
|
||||
>
|
||||
<span class="tab-text">消息</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransactionsRecord
|
||||
v-if="tabActive === 'balance'"
|
||||
@@ -84,15 +92,88 @@ const emailRecordRef = ref(null)
|
||||
const messageViewRef = ref(null)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/theme.css';
|
||||
|
||||
:deep(.van-pull-refresh) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 设置页面容器背景色 */
|
||||
:deep(.van-nav-bar) {
|
||||
background: transparent !important;
|
||||
/* ========== 自定义头部 ========== */
|
||||
.balance-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 24px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 60px; /* 与 calendar-header 保持一致,防止切换抖动 */
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: var(--font-primary);
|
||||
font-size: var(--font-2xl);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ========== 分段控制器 ========== */
|
||||
.tabs-wrapper {
|
||||
padding: var(--spacing-sm) var(--spacing-xl);
|
||||
}
|
||||
|
||||
.segmented-control {
|
||||
display: flex;
|
||||
background: var(--segmented-bg);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: transparent;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: var(--segmented-active-bg);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.tab-item.active .tab-text {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.tab-item:not(.active):hover {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-family: var(--font-primary);
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -94,26 +94,41 @@
|
||||
</div>
|
||||
|
||||
<!-- 提示词设置弹窗 -->
|
||||
<PopupContainer
|
||||
<PopupContainerV2
|
||||
v-model:show="showPromptDialog"
|
||||
title="编辑分析提示词"
|
||||
show-cancel-button
|
||||
show-confirm-button
|
||||
confirm-text="保存"
|
||||
cancel-text="取消"
|
||||
@confirm="confirmPrompt"
|
||||
@cancel="showPromptDialog = false"
|
||||
:height="'75%'"
|
||||
>
|
||||
<van-field
|
||||
v-model="promptValue"
|
||||
rows="4"
|
||||
autosize
|
||||
type="textarea"
|
||||
maxlength="2000"
|
||||
placeholder="输入自定义的分析提示词..."
|
||||
show-word-limit
|
||||
/>
|
||||
</PopupContainer>
|
||||
<div style="padding: 16px">
|
||||
<van-field
|
||||
v-model="promptValue"
|
||||
rows="4"
|
||||
autosize
|
||||
type="textarea"
|
||||
maxlength="2000"
|
||||
placeholder="输入自定义的分析提示词..."
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display: flex; gap: 12px">
|
||||
<van-button
|
||||
plain
|
||||
style="flex: 1"
|
||||
@click="showPromptDialog = false"
|
||||
>
|
||||
取消
|
||||
</van-button>
|
||||
<van-button
|
||||
type="primary"
|
||||
style="flex: 1"
|
||||
@click="confirmPrompt"
|
||||
>
|
||||
保存
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -122,7 +137,7 @@ import { ref, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showLoadingToast, closeToast } from 'vant'
|
||||
import { getConfig, setConfig } from '@/api/config'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userInput = ref('')
|
||||
|
||||
@@ -112,64 +112,107 @@
|
||||
</div>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<PopupContainer
|
||||
<PopupContainerV2
|
||||
v-model:show="showAddDialog"
|
||||
title="新增分类"
|
||||
show-cancel-button
|
||||
show-confirm-button
|
||||
confirm-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="handleConfirmAdd"
|
||||
@cancel="resetAddForm"
|
||||
:height="'auto'"
|
||||
>
|
||||
<van-form ref="addFormRef">
|
||||
<van-field
|
||||
v-model="addForm.name"
|
||||
name="name"
|
||||
label="分类名称"
|
||||
placeholder="请输入分类名称"
|
||||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||||
/>
|
||||
</van-form>
|
||||
</PopupContainer>
|
||||
<div style="padding: 16px">
|
||||
<van-form ref="addFormRef">
|
||||
<van-field
|
||||
v-model="addForm.name"
|
||||
name="name"
|
||||
label="分类名称"
|
||||
placeholder="请输入分类名称"
|
||||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||||
/>
|
||||
</van-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display: flex; gap: 12px">
|
||||
<van-button
|
||||
plain
|
||||
style="flex: 1"
|
||||
@click="resetAddForm"
|
||||
>
|
||||
取消
|
||||
</van-button>
|
||||
<van-button
|
||||
type="primary"
|
||||
style="flex: 1"
|
||||
@click="handleConfirmAdd"
|
||||
>
|
||||
确认
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 编辑分类对话框 -->
|
||||
<PopupContainer
|
||||
<PopupContainerV2
|
||||
v-model:show="showEditDialog"
|
||||
title="编辑分类"
|
||||
show-cancel-button
|
||||
show-confirm-button
|
||||
confirm-text="保存"
|
||||
cancel-text="取消"
|
||||
@confirm="handleConfirmEdit"
|
||||
@cancel="showEditDialog = false"
|
||||
:height="'auto'"
|
||||
>
|
||||
<van-form ref="editFormRef">
|
||||
<van-field
|
||||
v-model="editForm.name"
|
||||
name="name"
|
||||
label="分类名称"
|
||||
placeholder="请输入分类名称"
|
||||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||||
/>
|
||||
</van-form>
|
||||
</PopupContainer>
|
||||
<div style="padding: 16px">
|
||||
<van-form ref="editFormRef">
|
||||
<van-field
|
||||
v-model="editForm.name"
|
||||
name="name"
|
||||
label="分类名称"
|
||||
placeholder="请输入分类名称"
|
||||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||||
/>
|
||||
</van-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display: flex; gap: 12px">
|
||||
<van-button
|
||||
plain
|
||||
style="flex: 1"
|
||||
@click="showEditDialog = false"
|
||||
>
|
||||
取消
|
||||
</van-button>
|
||||
<van-button
|
||||
type="primary"
|
||||
style="flex: 1"
|
||||
@click="handleConfirmEdit"
|
||||
>
|
||||
保存
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<PopupContainer
|
||||
<PopupContainerV2
|
||||
v-model:show="showDeleteConfirm"
|
||||
title="删除分类"
|
||||
show-cancel-button
|
||||
show-confirm-button
|
||||
confirm-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleConfirmDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
:height="'auto'"
|
||||
>
|
||||
<p style="text-align: center; padding: 20px; color: var(--van-text-color-2)">
|
||||
删除后无法恢复,确定要删除吗?
|
||||
</p>
|
||||
</PopupContainer>
|
||||
<template #footer>
|
||||
<div style="display: flex; gap: 12px">
|
||||
<van-button
|
||||
plain
|
||||
style="flex: 1"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
取消
|
||||
</van-button>
|
||||
<van-button
|
||||
type="primary"
|
||||
style="flex: 1"
|
||||
@click="handleConfirmDelete"
|
||||
>
|
||||
确定
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 图标选择对话框 -->
|
||||
<IconSelector
|
||||
@@ -189,7 +232,7 @@ import { useRouter } from 'vue-router'
|
||||
import { showSuccessToast, showToast, showLoadingToast, closeToast } from 'vant'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import IconSelector from '@/components/IconSelector.vue'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import {
|
||||
getCategoryList,
|
||||
createCategory,
|
||||
|
||||
@@ -71,12 +71,12 @@
|
||||
/>
|
||||
|
||||
<!-- 记录列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showRecordsList"
|
||||
<PopupContainerV2
|
||||
v-model:show="showRecordsList"
|
||||
title="交易记录列表"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<div style="background: var(--van-background)">
|
||||
<div style="background: var(--van-background); padding: 0">
|
||||
<!-- 批量操作按钮 -->
|
||||
<div class="batch-actions">
|
||||
<van-button
|
||||
@@ -122,7 +122,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -133,7 +133,7 @@ import { showToast, showConfirmDialog } from 'vant'
|
||||
import { nlpAnalysis, batchUpdateClassify } from '@/api/transactionRecord'
|
||||
import BillListComponent from '@/components/Bill/BillListComponent.vue'
|
||||
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userInput = ref('')
|
||||
|
||||
@@ -73,23 +73,24 @@
|
||||
</van-pull-refresh>
|
||||
|
||||
<!-- 详情弹出层 -->
|
||||
<PopupContainer
|
||||
v-model="detailVisible"
|
||||
<PopupContainerV2
|
||||
v-model:show="detailVisible"
|
||||
:title="currentEmail ? currentEmail.Subject || currentEmail.subject || '(无主题)' : ''"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<template #header-actions>
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="refreshingAnalysis"
|
||||
@click="handleRefreshAnalysis"
|
||||
>
|
||||
重新分析
|
||||
</van-button>
|
||||
</template>
|
||||
|
||||
<div v-if="currentEmail">
|
||||
<!-- 操作按钮栏 -->
|
||||
<div style="padding: 12px 16px; text-align: right; border-bottom: 1px solid var(--van-border-color)">
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="refreshingAnalysis"
|
||||
@click="handleRefreshAnalysis"
|
||||
>
|
||||
重新分析
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-cell-group
|
||||
inset
|
||||
style="margin-top: 12px"
|
||||
@@ -140,13 +141,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 账单列表弹出层 -->
|
||||
<PopupContainer
|
||||
v-model="transactionListVisible"
|
||||
<PopupContainerV2
|
||||
v-model:show="transactionListVisible"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<BillListComponent
|
||||
data-source="custom"
|
||||
@@ -158,7 +159,7 @@
|
||||
@click="handleTransactionClick"
|
||||
@delete="handleTransactionDelete"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 账单详情编辑弹出层 -->
|
||||
<TransactionDetail
|
||||
@@ -184,7 +185,7 @@ import {
|
||||
import { getTransactionDetail } from '@/api/transactionRecord'
|
||||
import BillListComponent from '@/components/Bill/BillListComponent.vue'
|
||||
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
|
||||
const emailList = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -71,22 +71,27 @@
|
||||
</van-pull-refresh>
|
||||
|
||||
<!-- 详情弹出层 -->
|
||||
<PopupContainer
|
||||
v-model="detailVisible"
|
||||
<PopupContainerV2
|
||||
v-model:show="detailVisible"
|
||||
:title="currentMessage.title"
|
||||
:subtitle="currentMessage.createTime"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<div
|
||||
v-if="currentMessage.messageType === 2"
|
||||
class="detail-content rich-html-content"
|
||||
v-html="currentMessage.content"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="detail-content"
|
||||
>
|
||||
{{ currentMessage.content }}
|
||||
<div style="padding: 16px">
|
||||
<p style="color: #999; font-size: 14px; margin-bottom: 12px; margin-top: 0">
|
||||
{{ currentMessage.createTime }}
|
||||
</p>
|
||||
<div
|
||||
v-if="currentMessage.messageType === 2"
|
||||
class="rich-html-content"
|
||||
style="font-size: 14px; line-height: 1.6"
|
||||
v-html="currentMessage.content"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
style="font-size: 14px; line-height: 1.6; white-space: pre-wrap"
|
||||
>
|
||||
{{ currentMessage.content }}
|
||||
</div>
|
||||
</div>
|
||||
<template
|
||||
v-if="currentMessage.url && currentMessage.messageType === 1"
|
||||
@@ -101,7 +106,7 @@
|
||||
查看详情
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,7 +116,7 @@ import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const router = useRouter()
|
||||
@@ -325,22 +330,6 @@ defineExpose({
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
color: var(--van-text-color-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.detail-content:not(.rich-html-content) {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
:deep(.van-pull-refresh) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -107,141 +107,143 @@
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="dialogVisible"
|
||||
<PopupContainerV2
|
||||
v-model:show="dialogVisible"
|
||||
:title="isEdit ? '编辑周期账单' : '新增周期账单'"
|
||||
height="75%"
|
||||
:height="'75%'"
|
||||
>
|
||||
<van-form>
|
||||
<van-cell-group
|
||||
inset
|
||||
title="周期设置"
|
||||
>
|
||||
<van-field
|
||||
v-model="form.periodicTypeText"
|
||||
is-link
|
||||
readonly
|
||||
name="periodicType"
|
||||
label="周期"
|
||||
placeholder="请选择周期类型"
|
||||
:rules="[{ required: true, message: '请选择周期类型' }]"
|
||||
@click="showPeriodicTypePicker = true"
|
||||
/>
|
||||
|
||||
<!-- 每周配置 -->
|
||||
<van-field
|
||||
v-if="form.periodicType === 1"
|
||||
v-model="form.weekdaysText"
|
||||
is-link
|
||||
readonly
|
||||
name="weekdays"
|
||||
label="星期"
|
||||
placeholder="请选择星期几"
|
||||
:rules="[{ required: true, message: '请选择星期几' }]"
|
||||
@click="showWeekdaysPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 每月配置 -->
|
||||
<van-field
|
||||
v-if="form.periodicType === 2"
|
||||
v-model="form.monthDaysText"
|
||||
is-link
|
||||
readonly
|
||||
name="monthDays"
|
||||
label="日期"
|
||||
placeholder="请选择每月的日期"
|
||||
:rules="[{ required: true, message: '请选择日期' }]"
|
||||
@click="showMonthDaysPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 每季度配置 -->
|
||||
<van-field
|
||||
v-if="form.periodicType === 3"
|
||||
v-model="form.quarterDay"
|
||||
name="quarterDay"
|
||||
label="季度第几天"
|
||||
placeholder="请输入季度开始后第几天"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入季度开始后第几天' }]"
|
||||
/>
|
||||
|
||||
<!-- 每年配置 -->
|
||||
<van-field
|
||||
v-if="form.periodicType === 4"
|
||||
v-model="form.yearDay"
|
||||
name="yearDay"
|
||||
label="年第几天"
|
||||
placeholder="请输入年开始后第几天"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入年开始后第几天' }]"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group
|
||||
inset
|
||||
title="基本信息"
|
||||
>
|
||||
<van-field
|
||||
v-model="form.reason"
|
||||
name="reason"
|
||||
label="摘要"
|
||||
placeholder="请输入交易摘要"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
autosize
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.amount"
|
||||
name="amount"
|
||||
label="金额"
|
||||
placeholder="请输入金额"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入金额' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.type"
|
||||
name="type"
|
||||
label="类型"
|
||||
<div style="padding: 0">
|
||||
<van-form>
|
||||
<van-cell-group
|
||||
inset
|
||||
title="周期设置"
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="form.type"
|
||||
direction="horizontal"
|
||||
>
|
||||
<van-radio :value="0">
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio :value="1">
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio :value="2">
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
name="classify"
|
||||
label="分类"
|
||||
>
|
||||
<template #input>
|
||||
<span
|
||||
v-if="!form.classify"
|
||||
style="color: var(--van-gray-5)"
|
||||
>请选择交易分类</span>
|
||||
<span v-else>{{ form.classify }}</span>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="form.periodicTypeText"
|
||||
is-link
|
||||
readonly
|
||||
name="periodicType"
|
||||
label="周期"
|
||||
placeholder="请选择周期类型"
|
||||
:rules="[{ required: true, message: '请选择周期类型' }]"
|
||||
@click="showPeriodicTypePicker = true"
|
||||
/>
|
||||
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector
|
||||
v-model="form.classify"
|
||||
:type="form.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
<!-- 每周配置 -->
|
||||
<van-field
|
||||
v-if="form.periodicType === 1"
|
||||
v-model="form.weekdaysText"
|
||||
is-link
|
||||
readonly
|
||||
name="weekdays"
|
||||
label="星期"
|
||||
placeholder="请选择星期几"
|
||||
:rules="[{ required: true, message: '请选择星期几' }]"
|
||||
@click="showWeekdaysPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 每月配置 -->
|
||||
<van-field
|
||||
v-if="form.periodicType === 2"
|
||||
v-model="form.monthDaysText"
|
||||
is-link
|
||||
readonly
|
||||
name="monthDays"
|
||||
label="日期"
|
||||
placeholder="请选择每月的日期"
|
||||
:rules="[{ required: true, message: '请选择日期' }]"
|
||||
@click="showMonthDaysPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 每季度配置 -->
|
||||
<van-field
|
||||
v-if="form.periodicType === 3"
|
||||
v-model="form.quarterDay"
|
||||
name="quarterDay"
|
||||
label="季度第几天"
|
||||
placeholder="请输入季度开始后第几天"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入季度开始后第几天' }]"
|
||||
/>
|
||||
|
||||
<!-- 每年配置 -->
|
||||
<van-field
|
||||
v-if="form.periodicType === 4"
|
||||
v-model="form.yearDay"
|
||||
name="yearDay"
|
||||
label="年第几天"
|
||||
placeholder="请输入年开始后第几天"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入年开始后第几天' }]"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group
|
||||
inset
|
||||
title="基本信息"
|
||||
>
|
||||
<van-field
|
||||
v-model="form.reason"
|
||||
name="reason"
|
||||
label="摘要"
|
||||
placeholder="请输入交易摘要"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
autosize
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.amount"
|
||||
name="amount"
|
||||
label="金额"
|
||||
placeholder="请输入金额"
|
||||
type="number"
|
||||
:rules="[{ required: true, message: '请输入金额' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.type"
|
||||
name="type"
|
||||
label="类型"
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="form.type"
|
||||
direction="horizontal"
|
||||
>
|
||||
<van-radio :value="0">
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio :value="1">
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio :value="2">
|
||||
不计
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
name="classify"
|
||||
label="分类"
|
||||
>
|
||||
<template #input>
|
||||
<span
|
||||
v-if="!form.classify"
|
||||
style="color: var(--van-gray-5)"
|
||||
>请选择交易分类</span>
|
||||
<span v-else>{{ form.classify }}</span>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector
|
||||
v-model="form.classify"
|
||||
:type="form.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<van-button
|
||||
round
|
||||
@@ -253,7 +255,7 @@
|
||||
{{ isEdit ? '更新' : '确认添加' }}
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 周期类型选择器 -->
|
||||
<van-popup
|
||||
@@ -310,7 +312,7 @@ import {
|
||||
createPeriodic,
|
||||
updatePeriodic
|
||||
} from '@/api/transactionPeriodic'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<van-nav-bar
|
||||
title="设置"
|
||||
placeholder
|
||||
/>
|
||||
<!-- 自定义头部 -->
|
||||
<header class="setting-header">
|
||||
<h1 class="header-title">
|
||||
设置
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="scroll-content">
|
||||
<div
|
||||
class="detail-header"
|
||||
@@ -384,12 +387,30 @@ const handleScheduledTasks = () => {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面背景色 */
|
||||
:deep(body) {
|
||||
background-color: var(--van-background);
|
||||
<style scoped lang="scss">
|
||||
@import '@/assets/theme.css';
|
||||
|
||||
/* ========== 自定义头部 ========== */
|
||||
.setting-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 24px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 60px; /* 与其他 header 保持一致,防止切换抖动 */
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: var(--font-primary);
|
||||
font-size: var(--font-2xl);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ========== 页面内容 ========== */
|
||||
/* 增加卡片对比度 */
|
||||
:deep(.van-cell-group--inset) {
|
||||
background-color: var(--van-background-2);
|
||||
@@ -407,9 +428,4 @@ const handleScheduledTasks = () => {
|
||||
color: var(--van-text-color-2);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 设置页面容器背景色 */
|
||||
:deep(.van-nav-bar) {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -151,183 +151,43 @@
|
||||
<!-- 储蓄配置弹窗 -->
|
||||
<SavingsConfigPopup
|
||||
ref="savingsConfigRef"
|
||||
@success="loadBudgetData"
|
||||
@change="loadBudgetData"
|
||||
/>
|
||||
|
||||
<!-- 预算明细列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showListPopup"
|
||||
:title="popupTitle"
|
||||
height="80%"
|
||||
>
|
||||
<template #header-actions>
|
||||
<van-icon
|
||||
name="plus"
|
||||
size="20"
|
||||
title="添加预算"
|
||||
@click="budgetEditRef.open({ category: activeTab })"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<van-pull-refresh
|
||||
v-model="refreshing"
|
||||
style="min-height: 100%"
|
||||
@refresh="onRefresh"
|
||||
>
|
||||
<div class="budget-list">
|
||||
<!-- 支出列表 -->
|
||||
<template v-if="activeTab === BudgetCategory.Expense && expenseBudgets?.length > 0">
|
||||
<van-swipe-cell
|
||||
v-for="budget in expenseBudgets"
|
||||
:key="budget.id"
|
||||
>
|
||||
<BudgetCard
|
||||
:budget="budget"
|
||||
:progress-color="getProgressColor(budget)"
|
||||
:percent-class="{ warning: budget.current / budget.limit > 0.8 }"
|
||||
:period-label="getPeriodLabel(budget.type)"
|
||||
@click="handleEdit(budget)"
|
||||
>
|
||||
<template #amount-info>
|
||||
<div class="info-item">
|
||||
<div class="label">
|
||||
已支出
|
||||
</div>
|
||||
<div class="value expense">
|
||||
¥{{ formatMoney(budget.current) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">
|
||||
预算
|
||||
</div>
|
||||
<div class="value">
|
||||
¥{{ formatMoney(budget.limit) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">
|
||||
余额
|
||||
</div>
|
||||
<div
|
||||
class="value"
|
||||
:class="budget.limit - budget.current >= 0 ? 'income' : 'expense'"
|
||||
>
|
||||
¥{{ formatMoney(budget.limit - budget.current) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BudgetCard>
|
||||
<template #right>
|
||||
<van-button
|
||||
square
|
||||
text="删除"
|
||||
type="danger"
|
||||
class="delete-button"
|
||||
@click="handleDelete(budget)"
|
||||
/>
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
</template>
|
||||
|
||||
<!-- 收入列表 -->
|
||||
<template v-if="activeTab === BudgetCategory.Income && incomeBudgets?.length > 0">
|
||||
<van-swipe-cell
|
||||
v-for="budget in incomeBudgets"
|
||||
:key="budget.id"
|
||||
>
|
||||
<BudgetCard
|
||||
:budget="budget"
|
||||
:progress-color="getProgressColor(budget)"
|
||||
:percent-class="{ income: budget.current / budget.limit >= 1 }"
|
||||
:period-label="getPeriodLabel(budget.type)"
|
||||
@click="handleEdit(budget)"
|
||||
>
|
||||
<template #amount-info>
|
||||
<div class="info-item">
|
||||
<div class="label">
|
||||
已收入
|
||||
</div>
|
||||
<div class="value income">
|
||||
¥{{ formatMoney(budget.current) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">
|
||||
目标
|
||||
</div>
|
||||
<div class="value">
|
||||
¥{{ formatMoney(budget.limit) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">
|
||||
差额
|
||||
</div>
|
||||
<div
|
||||
class="value"
|
||||
:class="budget.current >= budget.limit ? 'income' : 'expense'"
|
||||
>
|
||||
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BudgetCard>
|
||||
<template #right>
|
||||
<van-button
|
||||
square
|
||||
text="删除"
|
||||
type="danger"
|
||||
class="delete-button"
|
||||
@click="handleDelete(budget)"
|
||||
/>
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<van-empty
|
||||
v-if="
|
||||
activeTab !== BudgetCategory.Savings &&
|
||||
!loading &&
|
||||
!hasError &&
|
||||
((activeTab === BudgetCategory.Expense && expenseBudgets?.length === 0) ||
|
||||
(activeTab === BudgetCategory.Income && incomeBudgets?.length === 0))
|
||||
"
|
||||
:description="`暂无${activeTab === BudgetCategory.Expense ? '支出' : '收入'}预算`"
|
||||
/>
|
||||
</div>
|
||||
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
|
||||
</van-pull-refresh>
|
||||
</PopupContainer>
|
||||
|
||||
<!-- 未覆盖分类弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showUncoveredDetails"
|
||||
<PopupContainerV2
|
||||
v-model:show="showUncoveredDetails"
|
||||
title="未覆盖预算的分类"
|
||||
:subtitle="`本月共 <b style='color:var(--van-primary-color)'>${uncoveredCategories.length}</b> 个分类未设置预算`"
|
||||
height="60%"
|
||||
:height="'60%'"
|
||||
>
|
||||
<div class="uncovered-list">
|
||||
<div style="padding: 0">
|
||||
<!-- subtitle 作为内容区域顶部 -->
|
||||
<div
|
||||
v-for="item in uncoveredCategories"
|
||||
:key="item.category"
|
||||
class="uncovered-item"
|
||||
>
|
||||
<div class="item-left">
|
||||
<div class="category-name">
|
||||
{{ item.category }}
|
||||
style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)"
|
||||
v-html="`本月共 <b style='color:var(--van-primary-color)'>${uncoveredCategories.length}</b> 个分类未设置预算`"
|
||||
/>
|
||||
|
||||
<div class="uncovered-list">
|
||||
<div
|
||||
v-for="item in uncoveredCategories"
|
||||
:key="item.category"
|
||||
class="uncovered-item"
|
||||
>
|
||||
<div class="item-left">
|
||||
<div class="category-name">
|
||||
{{ item.category }}
|
||||
</div>
|
||||
<div class="transaction-count">
|
||||
{{ item.transactionCount }} 笔记录
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-count">
|
||||
{{ item.transactionCount }} 笔记录
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-right">
|
||||
<div
|
||||
class="item-amount"
|
||||
:class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'"
|
||||
>
|
||||
¥{{ formatMoney(item.totalAmount) }}
|
||||
<div class="item-right">
|
||||
<div
|
||||
class="item-amount"
|
||||
:class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'"
|
||||
>
|
||||
¥{{ formatMoney(item.totalAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,25 +203,31 @@
|
||||
我知道了
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 归档总结弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showSummaryPopup"
|
||||
<PopupContainerV2
|
||||
v-model:show="showSummaryPopup"
|
||||
title="月份归档总结"
|
||||
:subtitle="`${selectedDate.getFullYear()}年${selectedDate.getMonth() + 1}月`"
|
||||
height="70%"
|
||||
:height="'70%'"
|
||||
>
|
||||
<div style="padding: 16px">
|
||||
<div
|
||||
class="rich-html-content"
|
||||
v-html="
|
||||
archiveSummary ||
|
||||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无总结</p>'
|
||||
"
|
||||
/>
|
||||
<div style="padding: 0">
|
||||
<!-- subtitle -->
|
||||
<div style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)">
|
||||
{{ selectedDate.getFullYear() }}年{{ selectedDate.getMonth() + 1 }}月
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px">
|
||||
<div
|
||||
class="rich-html-content"
|
||||
v-html="
|
||||
archiveSummary ||
|
||||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无总结</p>'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
|
||||
<!-- 日期选择器 -->
|
||||
<van-popup
|
||||
@@ -401,7 +267,7 @@ import BudgetTypeTabs from '@/components/BudgetTypeTabs.vue'
|
||||
import BudgetCard from '@/components/Budget/BudgetCard.vue'
|
||||
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
|
||||
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
import ExpenseBudgetContent from './modules/ExpenseBudgetContent.vue'
|
||||
import IncomeBudgetContent from './modules/IncomeBudgetContent.vue'
|
||||
import SavingsBudgetContent from './modules/SavingsBudgetContent.vue'
|
||||
|
||||
@@ -71,10 +71,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 计划存款明细弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showDetailPopup"
|
||||
<PopupContainerV2
|
||||
v-model:show="showDetailPopup"
|
||||
title="计划存款明细"
|
||||
height="80%"
|
||||
:height="'80%'"
|
||||
>
|
||||
<div class="popup-body">
|
||||
<div
|
||||
@@ -169,14 +169,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupContainer>
|
||||
</PopupContainerV2>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import BudgetCard from '@/components/Budget/BudgetCard.vue'
|
||||
import { BudgetPeriodType } from '@/constants/enums'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
特殊功能:
|
||||
- 自定义 header(Items 数量、Smart 按钮)
|
||||
- 与日历视图紧密集成
|
||||
- 特定的 UI 风格和交互
|
||||
- 使用统一的 BillListComponent 展示账单列表
|
||||
|
||||
注意:此组件不是通用的 BillListComponent,专为 CalendarV2 视图设计。
|
||||
如需通用账单列表功能,请使用 @/components/Bill/BillListComponent.vue
|
||||
迁移说明:已迁移至使用 BillListComponent,保留自定义 header 和 Smart 按钮
|
||||
-->
|
||||
<template>
|
||||
<!-- 交易列表 -->
|
||||
<div class="transactions">
|
||||
<!-- 自定义 header (保留) -->
|
||||
<div class="txn-header">
|
||||
<h2 class="txn-title">
|
||||
交易记录
|
||||
@@ -30,79 +30,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 交易卡片 -->
|
||||
<van-loading
|
||||
v-if="transactionsLoading"
|
||||
class="txn-loading"
|
||||
size="24px"
|
||||
vertical
|
||||
>
|
||||
加载中...
|
||||
</van-loading>
|
||||
<div
|
||||
v-else-if="transactions.length === 0"
|
||||
class="txn-empty"
|
||||
>
|
||||
<div class="empty-icon">
|
||||
<van-icon
|
||||
name="balance-list-o"
|
||||
size="48"
|
||||
/>
|
||||
</div>
|
||||
<div class="empty-text">
|
||||
当天暂无交易记录
|
||||
</div>
|
||||
<div class="empty-hint">
|
||||
轻松享受无消费的一天 ✨
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="txn-list"
|
||||
>
|
||||
<div
|
||||
v-for="txn in transactions"
|
||||
:key="txn.id"
|
||||
class="txn-card"
|
||||
@click="onTransactionClick(txn)"
|
||||
>
|
||||
<div
|
||||
class="txn-icon"
|
||||
:style="{ backgroundColor: txn.iconBg }"
|
||||
>
|
||||
<van-icon
|
||||
:name="txn.icon"
|
||||
:color="txn.iconColor"
|
||||
/>
|
||||
</div>
|
||||
<div class="txn-content">
|
||||
<div class="txn-name">
|
||||
{{ txn.name }}
|
||||
</div>
|
||||
<div class="txn-footer">
|
||||
<div class="txn-time">
|
||||
{{ txn.time }}
|
||||
</div>
|
||||
<span
|
||||
v-if="txn.classify"
|
||||
class="txn-classify-tag"
|
||||
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
|
||||
>
|
||||
{{ txn.classify }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="txn-amount">
|
||||
{{ txn.amount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 统一的账单列表组件 -->
|
||||
<BillListComponent
|
||||
data-source="custom"
|
||||
:transactions="transactions"
|
||||
:loading="transactionsLoading"
|
||||
:finished="true"
|
||||
:show-delete="true"
|
||||
:enable-filter="false"
|
||||
@click="onTransactionClick"
|
||||
@delete="onTransactionDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { getTransactionsByDate } from '@/api/transactionRecord'
|
||||
import BillListComponent from '@/components/Bill/BillListComponent.vue'
|
||||
|
||||
const props = defineProps({
|
||||
selectedDate: Date
|
||||
@@ -122,39 +67,6 @@ const formatDateKey = (date) => {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// 格式化时间(HH:MM)
|
||||
const formatTime = (dateTimeStr) => {
|
||||
const date = new Date(dateTimeStr)
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatAmount = (amount, type) => {
|
||||
const sign = type === 1 ? '+' : '-' // 1=收入, 0=支出
|
||||
return `${sign}${amount.toFixed(2)}`
|
||||
}
|
||||
|
||||
// 根据分类获取图标
|
||||
const getIconByClassify = (classify) => {
|
||||
const iconMap = {
|
||||
餐饮: 'food',
|
||||
购物: 'shopping',
|
||||
交通: 'transport',
|
||||
娱乐: 'play',
|
||||
医疗: 'medical',
|
||||
工资: 'money',
|
||||
红包: 'red-packet'
|
||||
}
|
||||
return iconMap[classify] || 'star'
|
||||
}
|
||||
|
||||
// 根据类型获取颜色
|
||||
const getColorByType = (type) => {
|
||||
return type === 1 ? '#22C55E' : '#FF6B6B' // 收入绿色,支出红色
|
||||
}
|
||||
|
||||
// 获取选中日期的交易列表
|
||||
const fetchDayTransactions = async (date) => {
|
||||
try {
|
||||
@@ -163,18 +75,8 @@ const fetchDayTransactions = async (date) => {
|
||||
const response = await getTransactionsByDate(dateKey)
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 转换为界面需要的格式
|
||||
transactions.value = response.data.map((txn) => ({
|
||||
id: txn.id,
|
||||
name: txn.reason || '未知交易',
|
||||
time: formatTime(txn.occurredAt),
|
||||
amount: formatAmount(txn.amount, txn.type),
|
||||
icon: getIconByClassify(txn.classify),
|
||||
iconColor: getColorByType(txn.type),
|
||||
iconBg: '#FFFFFF',
|
||||
classify: txn.classify,
|
||||
type: txn.type
|
||||
}))
|
||||
// 直接使用原始数据,交给 BillListComponent 处理格式化
|
||||
transactions.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取交易记录失败:', error)
|
||||
@@ -202,6 +104,13 @@ const onTransactionClick = (txn) => {
|
||||
emit('transactionClick', txn)
|
||||
}
|
||||
|
||||
// 删除交易后的处理
|
||||
const onTransactionDelete = (deletedId) => {
|
||||
// BillListComponent 已经完成删除 API 调用
|
||||
// 这里只需要从本地列表中移除该项
|
||||
transactions.value = transactions.value.filter((t) => t.id !== deletedId)
|
||||
}
|
||||
|
||||
// 点击 Smart 按钮
|
||||
const onSmartClick = () => {
|
||||
emit('smartClick')
|
||||
@@ -211,15 +120,27 @@ const onSmartClick = () => {
|
||||
<style scoped>
|
||||
@import '@/assets/theme.css';
|
||||
|
||||
/* ========== 交易列表 ========== */
|
||||
/* ========== 交易列表容器 ========== */
|
||||
.transactions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-3xl);
|
||||
padding: var(--spacing-xl, 16px);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* 移除 BillListComponent 内部的左右 padding/margin */
|
||||
:deep(.van-cell-group) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.van-list) {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
/* ========== 自定义 Header (保留) ========== */
|
||||
.txn-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -271,132 +192,4 @@ const onSmartClick = () => {
|
||||
.smart-btn:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.txn-loading {
|
||||
padding: var(--spacing-3xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.txn-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.txn-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
padding: var(--spacing-xl);
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.txn-card:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.txn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.txn-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.txn-name {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.txn-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.txn-time {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.txn-classify-tag {
|
||||
padding: 2px 8px;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.txn-classify-tag.tag-income {
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-success);
|
||||
}
|
||||
|
||||
.txn-classify-tag.tag-expense {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.txn-amount {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* ========== 空状态 ========== */
|
||||
.txn-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: var(--spacing-4xl) var(--spacing-2xl);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -134,6 +134,8 @@ const chartData = computed(() => {
|
||||
label: '支出',
|
||||
data: expenseData,
|
||||
borderColor: '#ff6b6b',
|
||||
yAxisID: 'y',
|
||||
order: 2,
|
||||
backgroundColor: (context) => {
|
||||
const chart = context.chart
|
||||
const { ctx, chartArea } = chart
|
||||
@@ -150,13 +152,15 @@ const chartData = computed(() => {
|
||||
label: '收入',
|
||||
data: incomeData,
|
||||
borderColor: '#4ade80',
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
backgroundColor: (context) => {
|
||||
const chart = context.chart
|
||||
const { ctx, chartArea } = chart
|
||||
if (!chartArea) {return 'rgba(74, 222, 128, 0.1)'}
|
||||
return createGradient(ctx, chartArea, '#4ade80')
|
||||
},
|
||||
fill: true,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
@@ -168,12 +172,33 @@ const chartData = computed(() => {
|
||||
|
||||
// Chart.js 配置
|
||||
const chartOptions = computed(() => {
|
||||
const { chartData: rawData } = prepareChartData()
|
||||
const { chartData: rawData, expenseData, incomeData } = prepareChartData()
|
||||
const maxExpense = Math.max(...expenseData, 0)
|
||||
const maxIncome = Math.max(...incomeData, 0)
|
||||
const maxValue = Math.max(maxExpense, maxIncome, 0)
|
||||
|
||||
return getChartOptions({
|
||||
layout: {
|
||||
padding: {
|
||||
bottom: 6
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false }
|
||||
x: {
|
||||
display: false,
|
||||
grid: { display: false, drawBorder: false },
|
||||
ticks: { display: false },
|
||||
border: { display: false }
|
||||
},
|
||||
y: {
|
||||
display: false,
|
||||
grid: { display: false, drawBorder: false },
|
||||
ticks: { display: false },
|
||||
border: { display: false },
|
||||
beginAtZero: true,
|
||||
suggestedMax: maxValue ? maxValue * 1.1 : undefined,
|
||||
grace: '6%'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
@@ -202,7 +227,7 @@ const chartOptions = computed(() => {
|
||||
},
|
||||
label: (context) => {
|
||||
if (context.parsed.y === 0) {return null}
|
||||
return `${context.dataset.label}: ¥${context.parsed.y.toFixed(2)}`
|
||||
return `${context.dataset.label}: ¥${context.parsed.y.toFixed(1)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,6 +267,6 @@ const chartOptions = computed(() => {
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -110,7 +110,7 @@ defineEmits(['category-click'])
|
||||
const showAllExpense = ref(false)
|
||||
|
||||
// Chart.js 相关
|
||||
const { getChartOptionsByType } = useChartTheme()
|
||||
const { getChartOptions } = useChartTheme()
|
||||
let _chartJSInstance = null
|
||||
|
||||
// 饼图标签引导线
|
||||
@@ -210,13 +210,14 @@ const pieLabelLinePlugin = {
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (value) => {
|
||||
const formatMoney = (value, decimals = 1) => {
|
||||
if (!value && value !== 0) {
|
||||
return '0'
|
||||
return Number(0).toFixed(decimals)
|
||||
}
|
||||
return Number(value)
|
||||
.toFixed(0)
|
||||
.toFixed(decimals)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
.replace(/\.0$/, '')
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
@@ -307,7 +308,7 @@ const totalAmount = computed(() => {
|
||||
const chartOptions = computed(() => {
|
||||
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
|
||||
return getChartOptionsByType('doughnut', {
|
||||
return getChartOptions({
|
||||
cutout: '65%',
|
||||
layout: {
|
||||
padding: {
|
||||
@@ -317,6 +318,11 @@ const chartOptions = computed(() => {
|
||||
right: 2
|
||||
}
|
||||
},
|
||||
// 显式禁用笛卡尔坐标系(Doughnut 图表不需要)
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false }
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
@@ -335,12 +341,12 @@ const chartOptions = computed(() => {
|
||||
const value = context.parsed || 0
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0)
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0
|
||||
return `${label}: ¥${formatMoney(value)} (${percentage}%)`
|
||||
return `${label}: ¥${formatMoney(value, 0)} (${percentage}%)`
|
||||
}
|
||||
}
|
||||
},
|
||||
pieCenterText: {
|
||||
text: `¥${formatMoney(totalAmount.value)}`,
|
||||
text: `¥${formatMoney(totalAmount.value, 0)}`,
|
||||
subtext: '总支出',
|
||||
textColor: isDarkMode ? '#ffffff' : '#323233',
|
||||
subtextColor: isDarkMode ? '#969799' : '#969799',
|
||||
@@ -403,7 +409,7 @@ const onChartRender = (chart) => {
|
||||
.ring-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
height: 190px;
|
||||
margin: 0px auto 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
class="income-text"
|
||||
style="font-size: 13px; margin-left: 4px"
|
||||
>
|
||||
¥{{ formatMoney(totalIncome) }}
|
||||
¥{{ formatMoney(totalIncome, 0) }}
|
||||
</span>
|
||||
</h3>
|
||||
<van-tag
|
||||
@@ -36,7 +36,7 @@
|
||||
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
|
||||
</div>
|
||||
<div class="category-amount income-text">
|
||||
¥{{ formatMoney(category.amount) }}
|
||||
¥{{ formatMoney(category.amount, 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@
|
||||
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
|
||||
</div>
|
||||
<div class="category-amount none-text">
|
||||
¥{{ formatMoney(category.amount) }}
|
||||
¥{{ formatMoney(category.amount, 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
支出
|
||||
</div>
|
||||
<div class="stat-amount">
|
||||
¥{{ formatMoney(amount) }}
|
||||
¥{{ formatMoney(amount, 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item income">
|
||||
@@ -15,7 +15,7 @@
|
||||
收入
|
||||
</div>
|
||||
<div class="stat-amount">
|
||||
¥{{ formatMoney(income) }}
|
||||
¥{{ formatMoney(income, 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item balance">
|
||||
@@ -26,7 +26,7 @@
|
||||
class="stat-amount"
|
||||
:class="balanceClass"
|
||||
>
|
||||
¥{{ formatMoney(balance) }}
|
||||
¥{{ formatMoney(balance, 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// Chart.js 相关
|
||||
const { getChartOptionsByType, colors } = useChartTheme()
|
||||
const { getChartOptions, colors } = useChartTheme()
|
||||
|
||||
// 计算结余样式类
|
||||
const balanceClass = computed(() => ({
|
||||
@@ -203,10 +203,12 @@ const prepareChartData = () => {
|
||||
let expense = 0
|
||||
let income = 0
|
||||
|
||||
if (item.expense !== undefined || item.income !== undefined) {
|
||||
// 优先使用 expense 和 income 字段
|
||||
if ('expense' in item && 'income' in item) {
|
||||
expense = item.expense || 0
|
||||
income = item.income || 0
|
||||
} else {
|
||||
} else if ('amount' in item) {
|
||||
// 如果只有 amount 字段,根据正负值判断
|
||||
const amount = item.amount || 0
|
||||
if (amount < 0) {
|
||||
expense = Math.abs(amount)
|
||||
@@ -239,6 +241,8 @@ const chartData = computed(() => {
|
||||
label: '支出',
|
||||
data: expenseData,
|
||||
borderColor: expenseColor.value,
|
||||
yAxisID: 'y',
|
||||
order: 2,
|
||||
backgroundColor: (context) => {
|
||||
const chart = context.chart
|
||||
const { ctx, chartArea } = chart
|
||||
@@ -258,6 +262,8 @@ const chartData = computed(() => {
|
||||
label: '收入',
|
||||
data: incomeData,
|
||||
borderColor: incomeColor.value,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
backgroundColor: (context) => {
|
||||
const chart = context.chart
|
||||
const { ctx, chartArea } = chart
|
||||
@@ -266,7 +272,7 @@ const chartData = computed(() => {
|
||||
}
|
||||
return createGradient(ctx, chartArea, incomeColor.value)
|
||||
},
|
||||
fill: true,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 6,
|
||||
@@ -279,12 +285,33 @@ const chartData = computed(() => {
|
||||
|
||||
// Chart.js 配置
|
||||
const chartOptions = computed(() => {
|
||||
const { chartData: rawData } = prepareChartData()
|
||||
const { chartData: rawData, expenseData, incomeData } = prepareChartData()
|
||||
const maxExpense = Math.max(...expenseData, 0)
|
||||
const maxIncome = Math.max(...incomeData, 0)
|
||||
const maxValue = Math.max(maxExpense, maxIncome, 0)
|
||||
|
||||
return getChartOptionsByType('line', {
|
||||
return getChartOptions({
|
||||
layout: {
|
||||
padding: {
|
||||
bottom: 6
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false }
|
||||
x: {
|
||||
display: false,
|
||||
grid: { display: false, drawBorder: false },
|
||||
ticks: { display: false },
|
||||
border: { display: false }
|
||||
},
|
||||
y: {
|
||||
display: false,
|
||||
grid: { display: false, drawBorder: false },
|
||||
ticks: { display: false },
|
||||
border: { display: false },
|
||||
beginAtZero: true,
|
||||
suggestedMax: maxValue ? maxValue * 1.1 : undefined,
|
||||
grace: '6%'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
@@ -326,10 +353,12 @@ const chartOptions = computed(() => {
|
||||
let dailyExpense = 0
|
||||
let dailyIncome = 0
|
||||
|
||||
if (item.expense !== undefined || item.income !== undefined) {
|
||||
// 优先使用 expense 和 income 字段
|
||||
if ('expense' in item && 'income' in item) {
|
||||
dailyExpense = item.expense || 0
|
||||
dailyIncome = item.income || 0
|
||||
} else {
|
||||
} else if ('amount' in item) {
|
||||
// 如果只有 amount 字段,根据正负值判断
|
||||
const amount = item.amount || 0
|
||||
if (amount < 0) {
|
||||
dailyExpense = Math.abs(amount)
|
||||
@@ -343,7 +372,7 @@ const chartOptions = computed(() => {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${context.dataset.label}: ¥${value.toFixed(2)}`
|
||||
return `${context.dataset.label}: ¥${value.toFixed(1)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,8 +455,6 @@ const chartOptions = computed(() => {
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -546,4 +546,62 @@ public class BudgetStatsTest : BaseTest
|
||||
// 年度使用率:7350 / 47000 * 100 = 15.64%
|
||||
result.Year.Rate.Should().BeApproximately(7350m / 47000m * 100, 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCategoryStats_年度收入_不应包含支出预算_Test()
|
||||
{
|
||||
// Arrange: 模拟实际数据库中的情况
|
||||
// 2026年1月,当前日期为2026-02-19
|
||||
var referenceDate = new DateTime(2026, 1, 15);
|
||||
var currentNow = new DateTime(2026, 2, 19);
|
||||
_dateTimeProvider.Now.Returns(currentNow);
|
||||
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
// Type=1 表示月度预算,Category=0 表示支出(这些不应该被计入收入统计)
|
||||
new() { Id = 1, Name = "工作餐预算", Limit = 700, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "G工作餐", StartDate = new DateTime(2026, 1, 6) },
|
||||
new() { Id = 2, Name = "副业投资", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "Z钻石福袋", StartDate = new DateTime(2026, 1, 7) },
|
||||
new() { Id = 3, Name = "通勤支出", Limit = 240, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "D地铁通勤", StartDate = new DateTime(2026, 1, 7) },
|
||||
|
||||
// Category=1 表示收入(只有这些应该被计入收入统计)
|
||||
new() { Id = 4, Name = "工资-SYE", Limit = 6100, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G工资SYE", StartDate = new DateTime(2026, 1, 7) },
|
||||
new() { Id = 5, Name = "副业收入", Limit = 6000, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, SelectedCategories = "", StartDate = new DateTime(2026, 1, 7) },
|
||||
new() { Id = 6, Name = "公积金收入", Limit = 5540, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G公积金", StartDate = new DateTime(2026, 1, 7) },
|
||||
new() { Id = 7, Name = "工资-SC", Limit = 17500, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G工资SC", StartDate = new DateTime(2026, 1, 16) }
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||
|
||||
// 模拟实际收入金额
|
||||
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(args =>
|
||||
{
|
||||
var budget = (BudgetRecord)args[0];
|
||||
// 假设当前月(2月)没有收入记录
|
||||
return 0m;
|
||||
});
|
||||
|
||||
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
|
||||
.Returns(new Dictionary<DateTime, decimal>());
|
||||
|
||||
_budgetArchiveRepository.GetArchiveAsync(Arg.Any<int>(), Arg.Any<int>())
|
||||
.Returns((BudgetArchive?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate);
|
||||
|
||||
// Assert
|
||||
// 年度已收应该是:1月的4个收入预算
|
||||
// 1月归档:工资-SYE(6100) + 副业收入(6000) + 公积金收入(5540) + 工资-SC(17500) = 35140
|
||||
// 2月当前:0(假设没有实际收入)
|
||||
// 3-12月未来:0
|
||||
// 总计应该约等于 35140 (取决于硬性收入的调整逻辑)
|
||||
|
||||
// 重点:year.limit 应该只包含收入预算,不应该包含支出预算
|
||||
// 正确的年度limit应该是:(6100 + 6000 + 5540 + 17500) * (1 + 11) = 35140 * 12 = 421680
|
||||
// 或者更准确地说:1月归档(35140) + 2月当前月(35140) + 未来10个月(35140 * 10) = 35140 * 12
|
||||
|
||||
result.Year.Limit.Should().BeGreaterThan(35000 * 11); // 至少应该是35140的11倍以上
|
||||
result.Year.Limit.Should().BeLessThan(36000 * 12); // 不应该超过36000的12倍
|
||||
}
|
||||
}
|
||||
BIN
balance-page-after.png
Normal file
BIN
balance-page-after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
2
null
2
null
@@ -1,2 +0,0 @@
|
||||
ERROR: Invalid argument/option - 'F:/'.
|
||||
Type "TASKKILL /?" for usage.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-17
|
||||
@@ -0,0 +1,51 @@
|
||||
## Context
|
||||
|
||||
当前图表系统使用 Chart.js 作为底层库,通过 Vue 3 组件封装在 `Web/src/components/Charts/` 目录下。存在以下问题:
|
||||
|
||||
1. **支出分类饼图**: 使用镂空饼图(Doughnut)但未在中心展示总金额,分类标签直接覆盖在图表上导致与图标重叠
|
||||
2. **收支折线图**: 展示整月日期(1-31日),即使当前只有部分日期有数据,剩余日期显示为平直线
|
||||
3. **预算仪表图**: 存在运行时错误,仪表图在容器中布局错位
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 在饼图中心展示总金额,优化数据可读性
|
||||
- 修复折线图展示无效未来日期的问题
|
||||
- 修复预算页面报错
|
||||
- 调整仪表图布局使其居中展示
|
||||
|
||||
**Non-Goals:**
|
||||
- 不更换图表库(仍使用 Chart.js)
|
||||
- 不改变现有配色方案
|
||||
- 不添加新的图表类型
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 饼图中心展示方案
|
||||
**选择**: 使用 Chart.js 的 `plugins.datalabels` 配合自定义 `afterDraw` 钩子在中心绘制文本
|
||||
**理由**: 比使用 HTML overlay 更简洁,与 Chart.js 原生集成,响应式适配更好
|
||||
**替代方案**: HTML 绝对定位 overlay(更灵活但增加复杂度)
|
||||
|
||||
### 2. 折线图日期范围过滤
|
||||
**选择**: 在组件层根据 `data.length` 动态计算 `labels` 数组,过滤掉未来日期
|
||||
**理由**: 数据源包含整月数据但当前日期后无实际值,前端过滤避免后端改动
|
||||
**替代方案**: 修改后端 API 返回(需要协调后端,改动成本高)
|
||||
|
||||
### 3. 仪表图布局修复
|
||||
**选择**: 使用 CSS Flexbox 垂直水平居中,配合 `maintainAspectRatio: false` 和固定高度
|
||||
**理由**: 解决容器自适应导致的错位问题,确保在不同屏幕尺寸下保持居中
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[风险]** 饼图中心文字在小屏幕上可能显示不全 → **缓解**: 使用相对字体大小,添加 `resize` 监听器动态调整
|
||||
- **[风险]** 折线图动态范围可能影响用户查看完整月度趋势的预期 → **缓解**: 在图表下方添加日期范围说明文字
|
||||
- **[权衡]** 仪表图固定高度可能在极端屏幕尺寸下出现留白 → 接受此权衡,保证核心显示区域
|
||||
|
||||
## Migration Plan
|
||||
|
||||
无需迁移步骤,所有改动均为前端展示层优化,不影响数据存储。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 折线图是否需要添加切换按钮允许用户查看整月趋势?
|
||||
- 饼图中心文字格式是否需要支持多货币显示?
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
当前图表组件存在多个用户体验问题:支出分类饼图缺少关键信息展示、收支折线图包含无效的未来日期数据、预算仪表图存在布局错位和运行时错误。这些问题影响数据可视化的准确性和美观性,需要统一优化以提升用户体验。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **支出分类饼图优化**: 在饼图中心镂空区域展示总金额,优化标签位置避免与图标重叠
|
||||
- **收支折线图优化**: 移除当前日期之后的无效未来日期数据点,仅展示实际有数据的日期范围
|
||||
- **预算仪表图修复与优化**: 修复页面报错,调整仪表图布局解决错位问题,提升视觉美观度
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `pie-chart-center-label`: 在饼图中心展示总金额的能力
|
||||
- `pie-chart-label-positioning`: 饼图分类标签智能定位避免遮挡图标
|
||||
- `line-chart-dynamic-range`: 折线图根据实际数据动态调整日期范围
|
||||
|
||||
### Modified Capabilities
|
||||
- `budget-gauge-display`: 预算仪表图的展示逻辑和布局要求变更
|
||||
|
||||
## Impact
|
||||
|
||||
- **前端**: 修改 Web/src/components/Charts/ 下的饼图、折线图、仪表图组件
|
||||
- **依赖**: Chart.js 配置选项调整,可能涉及 chartjs-plugin-datalabels
|
||||
- **页面**: 影响统计页面、预算页面的图表展示
|
||||
@@ -0,0 +1,29 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 仪表图容器布局
|
||||
预算仪表图 SHALL 在容器内正确居中显示,无错位。
|
||||
|
||||
#### Scenario: 垂直居中展示
|
||||
- **WHEN** 用户查看预算页面的仪表图
|
||||
- **THEN** 仪表图 SHALL 在容器内垂直居中
|
||||
- **AND** SHALL 在容器内水平居中
|
||||
- **AND** 与上下其他元素 SHALL 保持适当间距(16px)
|
||||
|
||||
#### Scenario: 响应式布局
|
||||
- **WHEN** 用户在不同屏幕尺寸下查看仪表图
|
||||
- **THEN** 仪表图 SHALL 保持居中不偏移
|
||||
- **AND** 容器高度 SHALL 自适应确保图表完整显示
|
||||
|
||||
### Requirement: 页面错误处理
|
||||
预算页面 SHALL 正确加载并显示仪表图,无运行时错误。
|
||||
|
||||
#### Scenario: 正常加载
|
||||
- **WHEN** 用户访问预算页面
|
||||
- **THEN** 页面 SHALL 无 JavaScript 错误
|
||||
- **AND** 仪表图 SHALL 正常渲染
|
||||
- **AND** 所有交互功能 SHALL 正常工作
|
||||
|
||||
#### Scenario: 错误边界处理
|
||||
- **WHEN** 仪表图组件发生异常
|
||||
- **THEN** 系统 SHALL 捕获错误并显示友好提示
|
||||
- **AND** SHALL 不阻塞页面其他功能
|
||||
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 动态日期范围
|
||||
收支折线图 SHALL 仅展示有实际数据的日期范围,不包含未来无效日期。
|
||||
|
||||
#### Scenario: 当前日期之前的趋势展示
|
||||
- **WHEN** 用户查看收支折线图(例如当前为17号)
|
||||
- **THEN** 图表 SHALL 只展示从月初到当前日期的数据点
|
||||
- **AND** SHALL 不包含17号之后到月底的空白日期
|
||||
- **AND** X轴标签 SHALL 对应实际有数据的日期
|
||||
|
||||
#### Scenario: 整月数据展示
|
||||
- **WHEN** 用户查看历史月份的收支折线图
|
||||
- **THEN** 图表 SHALL 展示该月的完整日期范围(1号到月末)
|
||||
- **AND** 所有日期点 SHALL 有对应的数据值
|
||||
|
||||
### Requirement: 数据点过滤逻辑
|
||||
系统 SHALL 根据当前日期自动过滤未来日期的数据点。
|
||||
|
||||
#### Scenario: 实时数据过滤
|
||||
- **WHEN** 组件加载当月收支数据
|
||||
- **THEN** 系统 SHALL 获取当前日期
|
||||
- **AND** SHALL 过滤掉 labels 数组中大于当前日期的日期
|
||||
- **AND** SHALL 同步过滤 datasets 中对应的空数据点
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 饼图中心展示总金额
|
||||
支出分类饼图 SHALL 在镂空区域中心位置展示当前选中数据的总金额。
|
||||
|
||||
#### Scenario: 显示总支出金额
|
||||
- **WHEN** 用户查看统计页面的支出分类饼图
|
||||
- **THEN** 系统 SHALL 在饼图中心显示当前展示分类的总支出金额
|
||||
- **AND** 金额格式 SHALL 使用人民币格式(¥xx,xxx.xx)
|
||||
|
||||
#### Scenario: 响应式适配
|
||||
- **WHEN** 用户在不同屏幕尺寸下查看饼图
|
||||
- **THEN** 中心文字 SHALL 自动调整大小以适应饼图尺寸
|
||||
- **AND** 文字 SHALL 始终保持水平和垂直居中
|
||||
|
||||
### Requirement: 中心文字样式
|
||||
饼图中心文字 SHALL 使用统一的视觉样式。
|
||||
|
||||
#### Scenario: 样式一致性
|
||||
- **WHEN** 系统渲染中心金额文字
|
||||
- **THEN** 字体大小 SHALL 为图表高度的 20%
|
||||
- **AND** 字体粗细 SHALL 为 bold
|
||||
- **AND** 字体颜色 SHALL 使用主题主色(#333333 或暗色主题对应色)
|
||||
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 分类标签智能定位
|
||||
饼图的分类标签 SHALL 避免与图标重叠,并清晰展示分类名称。
|
||||
|
||||
#### Scenario: 标签位置优化
|
||||
- **WHEN** 系统渲染支出分类饼图
|
||||
- **THEN** 分类标签 SHALL 显示在饼图扇区外侧
|
||||
- **AND** 标签 SHALL 通过引导线与对应扇区连接
|
||||
- **AND** 标签文字 SHALL 显示分类名称而非仅在图标上叠加
|
||||
|
||||
#### Scenario: 避免标签重叠
|
||||
- **WHEN** 多个分类扇区相邻且较小时
|
||||
- **THEN** 系统 SHALL 自动调整标签位置避免相互重叠
|
||||
- **AND** 当空间不足时 SHALL 使用图例(legend)代替直接标签
|
||||
|
||||
### Requirement: 图标与标签分离
|
||||
分类图标和分类名称 SHALL 分开展示,不互相遮挡。
|
||||
|
||||
#### Scenario: 清晰的视觉层次
|
||||
- **WHEN** 用户查看饼图
|
||||
- **THEN** 分类图标 SHALL 显示在饼图扇区内部或作为图例图标
|
||||
- **AND** 分类名称 SHALL 显示在标签位置而非图标上
|
||||
- **AND** 两者 SHALL 不重叠遮挡
|
||||
@@ -0,0 +1,77 @@
|
||||
## 1. 饼图中心金额展示
|
||||
|
||||
- [x] 1.1 创建饼图中心文本绘制插件或自定义 afterDraw 钩子
|
||||
- [x] 1.2 计算并格式化总金额(人民币格式 ¥xx,xxx.xx)
|
||||
- [x] 1.3 实现响应式字体大小调整(图表高度的20%)
|
||||
- [x] 1.4 确保文字水平和垂直居中显示
|
||||
- [x] 1.5 适配暗色主题颜色
|
||||
|
||||
## 2. 饼图标签位置优化
|
||||
|
||||
- [x] 2.1 调整 Chart.js datalabels 配置,将标签移至扇区外侧
|
||||
- [x] 2.2 配置引导线连接标签与对应扇区
|
||||
- [x] 2.3 确保标签显示分类名称而非仅图标
|
||||
- [x] 2.4 实现标签防重叠逻辑(小扇区自动调整位置)
|
||||
- [x] 2.5 必要时使用图例(legend)作为标签替代方案
|
||||
|
||||
## 3. 折线图日期范围过滤
|
||||
|
||||
- [x] 3.1 在折线图组件中获取当前日期
|
||||
- [x] 3.2 实现数据过滤函数,移除未来日期数据点
|
||||
- [x] 3.3 同步过滤 labels 和 datasets 数据
|
||||
- [x] 3.4 处理历史月份数据(展示完整月份)
|
||||
- [x] 3.5 添加日期范围说明文字(可选)
|
||||
|
||||
## 4. 预算页面报错修复
|
||||
|
||||
- [x] 4.1 定位并修复预算页面的 JavaScript 运行时错误
|
||||
- [x] 4.2 添加错误边界处理防止单个组件错误影响整个页面
|
||||
- [x] 4.3 验证所有交互功能正常工作
|
||||
- [x] 4.4 添加错误日志记录(开发环境)
|
||||
|
||||
## 5. 仪表图布局修复
|
||||
|
||||
- [x] 5.1 使用 CSS Flexbox 实现容器垂直水平居中
|
||||
- [x] 5.2 设置图表 `maintainAspectRatio: false` 和固定高度
|
||||
- [x] 5.3 调整容器内边距确保与上下元素保持16px间距
|
||||
- [x] 5.4 测试不同屏幕尺寸下的布局表现
|
||||
- [x] 5.5 修复暗色主题下的颜色适配
|
||||
|
||||
## 6. 测试与验证
|
||||
|
||||
- [x] 6.1 运行前端 lint 检查
|
||||
- [x] 6.2 验证所有图表在移动端和桌面端的显示效果
|
||||
- [x] 6.3 测试暗色/亮色主题切换
|
||||
- [x] 6.4 运行 `pnpm build` 确保无构建错误
|
||||
- [x] 6.5 功能验收测试
|
||||
|
||||
## 实施总结
|
||||
|
||||
### 完成的工作
|
||||
|
||||
1. **饼图中心金额展示** (Web/src/plugins/chartjs-pie-center-plugin.ts)
|
||||
- 创建了新的 Chart.js 插件 `pieCenterTextPlugin`
|
||||
- 在支出分类饼图中心显示总支出金额
|
||||
- 支持响应式字体大小和暗色主题
|
||||
|
||||
2. **折线图日期范围过滤** (Web/src/views/statisticsV2/modules/DailyTrendChart.vue)
|
||||
- 修改数据准备逻辑,过滤掉当前日期之后的未来日期
|
||||
- 历史月份展示完整日期范围
|
||||
|
||||
3. **预算页面修复** (Web/src/components/Budget/BudgetChartAnalysis.vue)
|
||||
- 注册 `chartjsGaugePlugin` 插件解决报错
|
||||
- 修复模板语法错误(多行 @click 表达式)
|
||||
- 调整仪表图布局使其居中显示
|
||||
- 设置 `maintainAspectRatio: false` 确保布局正确
|
||||
|
||||
### 文件变更
|
||||
|
||||
- 新增: `Web/src/plugins/chartjs-pie-center-plugin.ts`
|
||||
- 修改: `Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue`
|
||||
- 修改: `Web/src/views/statisticsV2/modules/DailyTrendChart.vue`
|
||||
- 修改: `Web/src/components/Budget/BudgetChartAnalysis.vue`
|
||||
|
||||
### 验证结果
|
||||
|
||||
- ✅ 构建成功 (pnpm build)
|
||||
- ✅ Lint 检查通过(仅现有警告,无新增错误)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-14
|
||||
@@ -0,0 +1,143 @@
|
||||
## Context
|
||||
|
||||
**当前状态**:
|
||||
- 后端 `BudgetStatsService` 已正确计算 `Description` (HTML格式明细) 和 `Trend` (每日累计金额数组)
|
||||
- Service 层的 `BudgetStatsDto` 包含这两个字段
|
||||
- **问题**: Application 层在映射 DTO 时丢失了这两个字段,导致 API 响应不完整
|
||||
- 前端使用 fallback 逻辑(线性估算)来弥补缺失数据,导致燃尽图显示为直线
|
||||
|
||||
**约束**:
|
||||
- 修复必须向后兼容,不能破坏现有 API 契约
|
||||
- 优先修复 Bug #4 和 #5(高优先级),因为修复简单且影响大
|
||||
- 前端已有完整的数据处理逻辑,只需后端提供正确数据
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 修复 `BudgetStatsDetail` DTO 定义,添加 `Description` 和 `Trend` 字段
|
||||
- 修复 `BudgetApplication.GetCategoryStatsAsync` 中的 DTO 映射逻辑
|
||||
- 修复前端路由配置和 Vant 组件注册问题
|
||||
- 分析并修复账单删除功能和金额不一致问题
|
||||
- 添加单元测试覆盖修复场景
|
||||
|
||||
**Non-Goals:**
|
||||
- 不重构 `BudgetStatsService` 的计算逻辑(已验证正确)
|
||||
- 不改变前端图表组件的渲染逻辑(已有完整支持)
|
||||
- 不修改 API 路由或版本化(向后兼容)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1: 使用 `init` 关键字而非 `set` 来定义新字段
|
||||
**理由**: `BudgetStatsDetail` 是 `record` 类型,遵循不可变对象模式。使用 `init` 确保字段只能在对象初始化时设置。
|
||||
|
||||
**替代方案**:
|
||||
- 改用 `class` + `set` → 违背现有代码风格,且失去 record 的值语义
|
||||
- 保持 `record` + `set` → C# 9+ 允许,但不符合不可变设计原则
|
||||
|
||||
### 决策 2: 在 Application 层映射时直接赋值,不做转换
|
||||
**理由**: Service 层的 `BudgetStatsDto.Trend` 和 `Description` 已经是目标格式(`List<decimal?>` 和 `string`),无需额外处理。
|
||||
|
||||
**实现**:
|
||||
```csharp
|
||||
Month = new BudgetStatsDetail
|
||||
{
|
||||
Limit = stats.Month.Limit,
|
||||
Current = stats.Month.Current,
|
||||
Remaining = stats.Month.Remaining,
|
||||
UsagePercentage = stats.Month.UsagePercentage,
|
||||
Trend = stats.Month.Trend, // ⬅️ 新增
|
||||
Description = stats.Month.Description // ⬅️ 新增
|
||||
}
|
||||
```
|
||||
|
||||
### 决策 3: Bug #1 (路由跳转) - 修改底部导航配置而非路由定义
|
||||
**理由**: 经验证,`/statistics-v2` 路由已存在且正常工作。问题出在底部导航组件中硬编码了错误的路由路径。
|
||||
|
||||
**定位策略**:
|
||||
1. 搜索底部导航组件代码 (通常包含 `van-tabbar` 或 `router-link`)
|
||||
2. 检查"统计"标签的 `to` 属性或 `path` 配置
|
||||
3. 修改为 `/statistics-v2`
|
||||
|
||||
### 决策 4: Bug #2 (删除功能) - 添加确认对话框而非直接删除
|
||||
**理由**: 删除操作是破坏性的,应符合最佳实践要求用户确认。
|
||||
|
||||
**实现**:
|
||||
```vue
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '确定要删除这条账单吗?此操作无法撤销。'
|
||||
})
|
||||
if (confirmed) {
|
||||
await deleteBill(billId)
|
||||
closePopup()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 决策 5: Bug #3 (组件警告) - 按需导入而非全局注册
|
||||
**理由**: Vant 推荐使用按需导入,减少打包体积。全局注册可能是遗漏导致的警告。
|
||||
|
||||
**验证步骤**:
|
||||
1. 检查 `main.ts` 或全局插件文件是否有 `DatetimePicker` 导入
|
||||
2. 如果缺失,添加 `import { DatetimePicker } from 'vant'; app.use(DatetimePicker);`
|
||||
3. 或在使用组件的文件中局部导入
|
||||
|
||||
### 决策 6: Bug #6 (金额不一致) - 先验证是否虚拟消耗导致
|
||||
**理由**: Bug-handoff 文档指出硬性预算 (📌标记) 会产生虚拟消耗,这是设计行为而非 bug。
|
||||
|
||||
**验证逻辑**:
|
||||
1. 检查不一致的预算是否标记为硬性预算
|
||||
2. 检查 `BudgetService.GetPeriodRange` 返回的日期范围是否与 `periodStart/periodEnd` 一致
|
||||
3. 如果是虚拟消耗:在前端账单列表中添加提示说明
|
||||
4. 如果是日期范围问题:修复 `BudgetResult` 的赋值逻辑
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### 风险 1: API 响应体积增大
|
||||
**问题**: 新增 `Trend` 数组(月度31个元素,年度12个元素)会增加响应大小。
|
||||
|
||||
**缓解措施**:
|
||||
- `Trend` 数据是前端绘制图表必需的,体积增长合理
|
||||
- 考虑后续添加 gzip 压缩到 API 响应(可选优化)
|
||||
|
||||
### 风险 2: 前端可能依赖旧的 fallback 逻辑
|
||||
**问题**: 如果前端代码中有显式检查 `trend.length === 0` 的逻辑,可能会在修复后仍执行 fallback。
|
||||
|
||||
**缓解措施**:
|
||||
- 在修复后端后,验证前端 `BudgetChartAnalysis.vue:603` 和 `629` 行的条件是否正确处理非空 trend
|
||||
- 如果逻辑有问题,修改为 `if (!trend || trend.length === 0)`
|
||||
|
||||
### 风险 3: 测试覆盖不足可能导致回归
|
||||
**问题**: 现有测试可能未覆盖 DTO 映射场景。
|
||||
|
||||
**缓解措施**:
|
||||
- 在 `WebApi.Test` 中添加针对 `BudgetApplication.GetCategoryStatsAsync` 的单元测试
|
||||
- 验证返回的 DTO 包含非空的 `Description` 和 `Trend`
|
||||
- 使用 `FluentAssertions` 编写清晰的断言
|
||||
|
||||
## Migration Plan
|
||||
|
||||
**部署步骤**:
|
||||
1. 部署后端更新(向后兼容,前端可继续使用旧逻辑)
|
||||
2. 验证 API 响应包含新字段 (使用 Swagger 或浏览器开发工具)
|
||||
3. 前端无需额外部署(已支持新字段,会自动切换到真实数据)
|
||||
4. 清除浏览器缓存以确保使用最新前端代码
|
||||
|
||||
**回滚策略**:
|
||||
- 如果新版本出现问题,回滚到上一个 commit
|
||||
- API 是向后兼容的(只添加字段),旧版前端仍可正常工作
|
||||
|
||||
**验证清单**:
|
||||
- [ ] 预算明细弹窗显示完整的 HTML 表格
|
||||
- [ ] 燃尽图显示波动曲线而非直线
|
||||
- [ ] 底部导航"统计"按钮正常跳转
|
||||
- [ ] 删除账单功能弹出确认对话框并正常工作
|
||||
- [ ] 控制台无 `van-datetime-picker` 警告
|
||||
- [ ] 金额不一致问题已分析并修复或说明
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Bug #6 金额不一致的根本原因**: 需要在测试环境中验证是否为虚拟消耗导致,还是日期范围计算错误。
|
||||
2. **前端 fallback 逻辑是否需要移除**: 当前 fallback 作为容错机制保留是否合理?还是应在有真实数据时完全禁用?
|
||||
3. **是否需要添加 E2E 测试**: 当前只计划单元测试,是否需要添加端到端测试覆盖完整流程?
|
||||
@@ -0,0 +1,42 @@
|
||||
## Why
|
||||
|
||||
修复预算统计模块的6个影响用户体验的bug,其中包括2个高优先级数据丢失问题(预算明细弹窗显示"暂无数据"、燃尽图显示为直线)和4个UI/交互问题(路由跳转失败、删除功能无响应、控制台警告、金额不一致)。这些bug影响了核心预算跟踪功能的可用性和准确性。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 修复后端 Application 层 DTO 映射缺失,补充 `Description` 和 `Trend` 字段到 API 响应
|
||||
- 修复前端路由配置,确保底部导航栏"统计"按钮跳转到正确路由
|
||||
- 修复日历页面账单删除功能的事件绑定
|
||||
- 修复 Vant 组件 `van-datetime-picker` 的全局注册问题
|
||||
- 分析并修复预算卡片金额与关联账单列表金额不一致问题
|
||||
- 添加后端和前端单元测试覆盖修复的场景
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- 无新功能,仅修复现有功能 -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `budget-stats`: 修复预算统计API响应缺失 `Description` 和 `Trend` 字段,确保前端能正确展示明细弹窗和燃尽图
|
||||
- `bill-management`: 修复账单删除功能的事件处理逻辑
|
||||
- `navigation`: 修复前端路由配置和底部导航栏跳转
|
||||
|
||||
## Impact
|
||||
|
||||
**后端文件**:
|
||||
- `Application/Dto/BudgetDto.cs` - 修改 `BudgetStatsDetail` 添加字段
|
||||
- `Application/BudgetApplication.cs` - 修改 DTO 映射逻辑
|
||||
- `WebApi.Test/` - 添加新的测试用例覆盖修复场景
|
||||
|
||||
**前端文件**:
|
||||
- `Web/src/router/index.js` - 修复路由配置
|
||||
- `Web/src/components/Budget/BudgetChartAnalysis.vue` - 验证数据正确使用
|
||||
- `Web/src/components/Budget/BudgetCard.vue` - 分析账单金额不一致问题
|
||||
- `Web/src/main.ts` 或全局组件注册文件 - 修复 Vant 组件注册
|
||||
- 日历页面账单详情组件 - 修复删除按钮事件绑定
|
||||
|
||||
**API影响**:
|
||||
- GET `/api/budget/stats/{category}` 响应结构变更(新增字段,向后兼容)
|
||||
|
||||
**依赖**:
|
||||
- 无外部依赖变更
|
||||
@@ -0,0 +1,40 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Bill deletion requires user confirmation
|
||||
账单删除操作 MUST 要求用户显式确认,防止误操作导致数据丢失。删除确认对话框 SHALL 明确告知用户操作的不可逆性。
|
||||
|
||||
#### Scenario: User confirms bill deletion
|
||||
- **WHEN** 用户在日历页面的账单详情弹窗中点击"删除"按钮
|
||||
- **THEN** 系统弹出确认对话框,标题为"确认删除"
|
||||
- **AND** 对话框消息包含警告文本(如"确定要删除这条账单吗?此操作无法撤销。")
|
||||
- **AND** 对话框提供"确认"和"取消"两个按钮
|
||||
|
||||
#### Scenario: User confirms deletion
|
||||
- **WHEN** 用户在确认对话框中点击"确认"按钮
|
||||
- **THEN** 系统调用删除 API (`DELETE /api/bill/{id}`)
|
||||
- **AND** 删除成功后关闭账单详情弹窗
|
||||
- **AND** 日历视图自动刷新,删除的账单不再显示
|
||||
|
||||
#### Scenario: User cancels deletion
|
||||
- **WHEN** 用户在确认对话框中点击"取消"按钮
|
||||
- **THEN** 对话框关闭,账单详情弹窗保持打开状态
|
||||
- **AND** 账单未被删除
|
||||
|
||||
#### Scenario: Deletion fails due to server error
|
||||
- **WHEN** 用户确认删除,但后端返回错误(如网络异常或 500 错误)
|
||||
- **THEN** 系统显示错误提示(如"删除失败,请稍后重试")
|
||||
- **AND** 账单详情弹窗保持打开状态
|
||||
- **AND** 账单仍存在于系统中
|
||||
|
||||
### Requirement: Delete button event binding must be functional
|
||||
日历页面账单详情组件中的删除按钮 MUST 正确绑定点击事件处理函数,确保用户点击时能够触发删除流程。
|
||||
|
||||
#### Scenario: Delete button click triggers handler
|
||||
- **WHEN** 账单详情弹窗渲染完成
|
||||
- **THEN** "删除"按钮的 `@click` 或 `onClick` 事件绑定到正确的处理函数(如 `handleDelete`)
|
||||
- **AND** 点击按钮时控制台无 JavaScript 错误
|
||||
|
||||
#### Scenario: Button is not disabled during loading
|
||||
- **WHEN** 账单详情弹窗首次加载时
|
||||
- **THEN** "删除"按钮的 `disabled` 属性为 `false`(除非账单正在删除中)
|
||||
- **AND** 按钮可以正常响应点击事件
|
||||
@@ -0,0 +1,48 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Budget statistics API response includes complete data
|
||||
预算统计 API (`GET /api/budget/stats/{category}`) SHALL 返回完整的统计数据,包括用于前端图表渲染的 `Trend` 数组和用于明细弹窗的 `Description` HTML 内容。
|
||||
|
||||
响应结构中的 `Month` 和 `Year` 对象 MUST 包含以下字段:
|
||||
- `Limit`: 预算限额
|
||||
- `Current`: 当前实际金额
|
||||
- `Remaining`: 剩余金额
|
||||
- `UsagePercentage`: 使用百分比
|
||||
- `Trend`: 每日/每月累计金额数组 (`List<decimal?>`)
|
||||
- `Description`: HTML 格式的详细说明,包含计算公式和数据表格 (`string`)
|
||||
|
||||
#### Scenario: Monthly stats with trend data
|
||||
- **WHEN** 客户端请求月度预算统计 `GET /api/budget/stats/food?date=2026-02`
|
||||
- **THEN** 响应的 `month` 对象包含 `trend` 数组,长度等于该月天数(如28/29/30/31)
|
||||
- **AND** `trend` 数组每个元素表示截至该天的累计金额(支出类为递减,收入类为递增)
|
||||
- **AND** `trend` 数组中未到达的日期对应的元素为 `null`
|
||||
|
||||
#### Scenario: Monthly stats with description
|
||||
- **WHEN** 客户端请求月度预算统计 `GET /api/budget/stats/food?date=2026-02`
|
||||
- **THEN** 响应的 `month` 对象包含 `description` 字段
|
||||
- **AND** `description` 是 HTML 格式字符串,包含 `<table>` 标签展示明细数据
|
||||
- **AND** `description` 包含计算公式说明(如"剩余 = 限额 - 已用")
|
||||
|
||||
#### Scenario: Yearly stats with trend data
|
||||
- **WHEN** 客户端请求年度预算统计 `GET /api/budget/stats/salary?date=2026`
|
||||
- **THEN** 响应的 `year` 对象包含 `trend` 数组,长度为12(代表12个月)
|
||||
- **AND** `trend` 数组每个元素表示截至该月的累计金额
|
||||
- **AND** `trend` 数组中未到达的月份对应的元素为 `null`
|
||||
|
||||
#### Scenario: Yearly stats with description
|
||||
- **WHEN** 客户端请求年度预算统计 `GET /api/budget/stats/salary?date=2026`
|
||||
- **THEN** 响应的 `year` 对象包含 `description` 字段
|
||||
- **AND** `description` 是 HTML 格式字符串,包含年度统计明细
|
||||
|
||||
### Requirement: DTO mapping preserves all Service layer data
|
||||
Application 层的 `BudgetApplication.GetCategoryStatsAsync` 方法在将 Service 层的 `BudgetStatsDto` 映射到 API 响应 DTO 时,MUST 保留所有数据字段,不得丢失 `Trend` 和 `Description`。
|
||||
|
||||
#### Scenario: Mapping from Service DTO to API DTO
|
||||
- **WHEN** `BudgetApplication.GetCategoryStatsAsync` 接收到 Service 层返回的 `BudgetStatsDto`
|
||||
- **THEN** 映射后的 `BudgetStatsDetail` 对象包含 `Trend` 字段,其值等于 `BudgetStatsDto.Month.Trend` 或 `BudgetStatsDto.Year.Trend`
|
||||
- **AND** 映射后的 `BudgetStatsDetail` 对象包含 `Description` 字段,其值等于 `BudgetStatsDto.Month.Description` 或 `BudgetStatsDto.Year.Description`
|
||||
|
||||
#### Scenario: API response schema validation
|
||||
- **WHEN** 前端调用 `/api/budget/stats/{category}` 并解析 JSON 响应
|
||||
- **THEN** TypeScript 类型检查不报错,响应对象符合 `BudgetStatsResponse` 接口定义
|
||||
- **AND** `month.trend` 和 `month.description` 字段存在且非 `undefined`
|
||||
@@ -0,0 +1,37 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Bottom navigation statistics tab routes correctly
|
||||
底部导航栏的"统计"标签 MUST 正确配置路由路径,点击后能够跳转到统计页面 (`/statistics-v2`)。
|
||||
|
||||
#### Scenario: User clicks statistics tab in bottom navigation
|
||||
- **WHEN** 用户在应用底部导航栏点击"统计"图标或标签
|
||||
- **THEN** 浏览器 URL 变更为 `/statistics-v2`
|
||||
- **AND** 页面渲染统计页面组件(如 `StatisticsV2.vue`)
|
||||
- **AND** 底部导航栏的"统计"标签高亮显示为激活状态
|
||||
|
||||
#### Scenario: Direct URL access to statistics page
|
||||
- **WHEN** 用户直接在浏览器地址栏输入 `/statistics-v2` 并访问
|
||||
- **THEN** 应用正确渲染统计页面
|
||||
- **AND** 底部导航栏的"统计"标签高亮显示为激活状态
|
||||
|
||||
#### Scenario: Navigation tab configuration matches route definition
|
||||
- **WHEN** 前端代码加载时
|
||||
- **THEN** 底部导航组件(`van-tabbar` 或自定义组件)中"统计"标签的 `to` 或 `path` 属性值为 `/statistics-v2`
|
||||
- **AND** 路由配置文件 (`router/index.js`) 中存在 `path: '/statistics-v2'` 的路由定义
|
||||
|
||||
### Requirement: Vant DatetimePicker component must be registered
|
||||
Vant UI 库的 `van-datetime-picker` 组件 MUST 正确注册,以避免控制台出现 "Failed to resolve component" 警告。
|
||||
|
||||
#### Scenario: DatetimePicker used in application
|
||||
- **WHEN** 应用中任何页面使用 `<van-datetime-picker>` 组件
|
||||
- **THEN** 组件正常渲染,无控制台错误或警告
|
||||
- **AND** 控制台不显示 "Failed to resolve component: van-datetime-picker" 消息
|
||||
|
||||
#### Scenario: Global component registration in main.ts
|
||||
- **WHEN** 应用启动时执行 `main.ts` 或全局插件文件
|
||||
- **THEN** `DatetimePicker` 组件已通过 `app.use(DatetimePicker)` 全局注册
|
||||
- **OR** 在使用组件的文件中已通过 `import { DatetimePicker } from 'vant'` 和 `components: { VanDatetimePicker: DatetimePicker }` 局部注册
|
||||
|
||||
#### Scenario: No missing component warnings after fix
|
||||
- **WHEN** 用户浏览应用的所有页面(日历、预算、统计等)
|
||||
- **THEN** 浏览器开发者工具控制台中无任何 Vant 组件相关的警告或错误
|
||||
@@ -0,0 +1,65 @@
|
||||
## 1. Backend: Fix Budget Stats DTO and Mapping (Bug #4 & #5 - High Priority)
|
||||
|
||||
- [x] 1.1 在 `Application/Dto/BudgetDto.cs` 的 `BudgetStatsDetail` record 中添加 `Trend` 字段(`List<decimal?>`,使用 `init`)
|
||||
- [x] 1.2 在 `Application/Dto/BudgetDto.cs` 的 `BudgetStatsDetail` record 中添加 `Description` 字段(`string`,使用 `init`)
|
||||
- [x] 1.3 在 `Application/BudgetApplication.cs` 的 `GetCategoryStatsAsync` 方法中,映射 `Month` 对象时添加 `Trend = stats.Month.Trend`
|
||||
- [x] 1.4 在 `Application/BudgetApplication.cs` 的 `GetCategoryStatsAsync` 方法中,映射 `Month` 对象时添加 `Description = stats.Month.Description`
|
||||
- [x] 1.5 在 `Application/BudgetApplication.cs` 的 `GetCategoryStatsAsync` 方法中,映射 `Year` 对象时添加 `Trend = stats.Year.Trend`
|
||||
- [x] 1.6 在 `Application/BudgetApplication.cs` 的 `GetCategoryStatsAsync` 方法中,映射 `Year` 对象时添加 `Description = stats.Year.Description`
|
||||
|
||||
## 2. Backend: Add Unit Tests for DTO Mapping
|
||||
|
||||
- [x] 2.1 在 `WebApi.Test/` 中创建 `BudgetApplicationTests.cs` 测试类(如果不存在)
|
||||
- [x] 2.2 编写测试用例 `GetCategoryStatsAsync_Should_Include_Trend_And_Description_In_Month_Stats`
|
||||
- [x] 2.3 编写测试用例 `GetCategoryStatsAsync_Should_Include_Trend_And_Description_In_Year_Stats`
|
||||
- [x] 2.4 运行测试并验证通过:`dotnet test --filter "FullyQualifiedName~BudgetApplicationTests"`
|
||||
|
||||
## 3. Frontend: Fix Navigation Routes (Bug #1)
|
||||
|
||||
- [x] 3.1 使用 `grep` 搜索底部导航组件代码(搜索关键字 `van-tabbar` 或 `统计`)
|
||||
- [x] 3.2 定位"统计"标签的路由配置(检查 `to` 或 `path` 属性)
|
||||
- [x] 3.3 修改路由路径为 `/statistics-v2`
|
||||
- [x] 3.4 验证路由配置文件 `Web/src/router/index.js` 中存在 `/statistics-v2` 路由定义
|
||||
|
||||
## 4. Frontend: Fix Bill Deletion Function (Bug #2)
|
||||
|
||||
- [x] 4.1 使用 `grep` 搜索日历页面的账单详情组件(搜索关键字 `删除` 或 `delete`)
|
||||
- [x] 4.2 定位删除按钮的点击事件绑定(检查 `@click` 或 `onClick`)
|
||||
- [x] 4.3 实现 `handleDelete` 函数,使用 `showConfirmDialog` 显示确认对话框
|
||||
- [x] 4.4 在确认后调用删除 API 并关闭弹窗,刷新日历视图
|
||||
- [x] 4.5 在取消时关闭对话框但保持弹窗打开
|
||||
- [x] 4.6 处理删除失败场景,显示错误提示
|
||||
|
||||
## 5. Frontend: Fix Vant DatetimePicker Registration (Bug #3)
|
||||
|
||||
- [x] 5.1 检查 `Web/src/main.ts` 或全局组件注册文件
|
||||
- [x] 5.2 验证是否导入 `DatetimePicker`(`import { DatetimePicker } from 'vant'`)
|
||||
- [x] 5.3 如果缺失,添加全局注册 `app.use(DatetimePicker)`
|
||||
- [ ] 5.4 启动前端开发服务器,验证控制台无 "Failed to resolve component" 警告
|
||||
|
||||
## 6. Frontend: Verify Budget Chart Renders Correctly After Backend Fix
|
||||
|
||||
- [ ] 6.1 启动后端和前端服务
|
||||
- [ ] 6.2 打开预算页面,点击"使用情况"或"完成情况"旁的感叹号图标
|
||||
- [ ] 6.3 验证明细弹窗显示完整的 HTML 表格(非"暂无数据")
|
||||
- [ ] 6.4 验证燃尽图显示波动曲线(非直线)
|
||||
- [x] 6.5 检查前端 `BudgetChartAnalysis.vue:603` 和 `:629` 行的 fallback 逻辑是否仍触发(如需修改条件检查)
|
||||
|
||||
## 7. Investigation: Budget Card Amount Mismatch (Bug #6 - Low Priority)
|
||||
|
||||
- [ ] 7.1 在测试环境中打开预算页面,点击预算卡片的"查询关联账单"按钮
|
||||
- [ ] 7.2 对比预算卡片显示的"实际"金额与账单列表金额总和
|
||||
- [ ] 7.3 检查不一致的预算是否标记为硬性预算(📌)
|
||||
- [ ] 7.4 如果是硬性预算,验证虚拟消耗的计算逻辑(`BudgetService.cs:376-405`)
|
||||
- [ ] 7.5 检查 `BudgetResult` 中 `PeriodStart` 和 `PeriodEnd` 的赋值是否与 `GetPeriodRange` 一致
|
||||
- [ ] 7.6 如果是虚拟消耗导致,考虑在前端账单列表中添加提示说明(可选)
|
||||
- [ ] 7.7 如果是日期范围问题,修复 `BudgetResult` 的赋值逻辑
|
||||
|
||||
## 8. End-to-End Verification
|
||||
|
||||
- [x] 8.1 运行后端所有测试:`dotnet test`
|
||||
- [x] 8.2 运行前端 lint:`cd Web && pnpm lint`
|
||||
- [x] 8.3 构建前端:`cd Web && pnpm build`
|
||||
- [ ] 8.4 手动测试所有修复的 bug(按 bug-handoff-document.md 中的验证清单)
|
||||
- [ ] 8.5 清除浏览器缓存并重新测试
|
||||
- [ ] 8.6 验证控制台无错误或警告
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-17
|
||||
@@ -0,0 +1,87 @@
|
||||
## Context
|
||||
|
||||
EmailBill 项目已从 ECharts 迁移到 Chart.js 作为图表库。在迁移过程中,图表文本显示出现了乱码问题,特别是在显示中文标签、Tooltip 和中心文本时。这可能是由于:
|
||||
|
||||
1. Chart.js 默认字体配置不支持中文
|
||||
2. Tooltip 回调函数返回的字符串编码问题
|
||||
3. 主题切换时字体颜色对比度不足
|
||||
|
||||
当前图表组件位于 `Web/src/components/Charts/`,使用 `BaseChart.vue` 作为统一包装组件。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 修复所有图表(饼图、折线图、仪表盘)的中文乱码问题
|
||||
- 确保图表文本在明/暗主题下都清晰可读
|
||||
- 统一字体配置,支持跨平台中文显示
|
||||
- 修复 Tooltip 和 Label 的文本格式化问题
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修改图表的数据结构或业务逻辑
|
||||
- 不添加新的图表类型
|
||||
- 不进行 UI 样式的大幅度调整(仅修复文本显示问题)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 字体配置方案
|
||||
**选择**: 在 `useChartTheme.ts` 中统一配置 Chart.js 字体选项
|
||||
|
||||
**理由**:
|
||||
- Chart.js 支持全局字体配置,通过 `defaults.font.family` 可以一次性设置所有图表的字体
|
||||
- 使用系统字体栈确保跨平台兼容性:`'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif`
|
||||
- 避免在每个组件中重复配置
|
||||
|
||||
**替代方案考虑**:
|
||||
- 在每个图表组件中单独配置字体 → 重复代码,维护困难
|
||||
- 使用 WebFont 加载自定义字体 → 增加外部依赖,加载时间不可控
|
||||
|
||||
### Decision 2: Tooltip 格式化修复方案
|
||||
**选择**: 修复 `callbacks.label` 和 `callbacks.title` 回调函数,确保返回正确的字符串
|
||||
|
||||
**理由**:
|
||||
- Chart.js 的 Tooltip 回调函数必须返回字符串,不能返回对象或其他类型
|
||||
- 使用模板字符串确保正确的字符串拼接
|
||||
- 添加空值检查防止 undefined 导致的乱码
|
||||
|
||||
**代码示例**:
|
||||
```typescript
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.parsed.y || context.parsed
|
||||
return `¥${value.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Decision 3: 中心文本显示方案
|
||||
**选择**: 继续使用 CSS 绝对定位的覆盖层显示中心文本
|
||||
|
||||
**理由**:
|
||||
- Chart.js 本身不支持在 Doughnut 图表中心直接渲染文本
|
||||
- CSS 覆盖层方式简单可靠,易于控制字体样式
|
||||
- 确保覆盖层使用正确的字体族和颜色
|
||||
|
||||
**实现要点**:
|
||||
- 覆盖层容器设置 `font-family` 继承自主题配置
|
||||
- 使用 `var(--van-text-color)` 确保主题适配
|
||||
- 添加 `white-space: nowrap` 防止文本换行导致错位
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| 某些旧版浏览器可能不支持系统字体栈 | 提供后备字体(Arial, sans-serif)确保基本可读性 |
|
||||
| 暗色模式下文本颜色对比度不足 | 使用 Vant 主题变量确保颜色适配 |
|
||||
| 字体文件过大影响加载性能 | 使用系统字体,不加载外部字体文件 |
|
||||
| 修改全局配置可能影响其他组件 | 在 `useChartTheme` 中集中管理,便于回滚 |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **阶段 1**: 修改 `useChartTheme.ts` 添加全局字体配置
|
||||
2. **阶段 2**: 检查并修复各图表组件的 Tooltip 回调函数
|
||||
3. **阶段 3**: 验证所有图表页面的文本显示
|
||||
4. **阶段 4**: 在明/暗主题下分别测试
|
||||
|
||||
**Rollback Strategy**: 所有修改都是配置层面的,可以通过回滚 Git 提交快速恢复。
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
在浏览器中查看图表时,饼图、折线图等图表上出现了错乱的字符串显示问题,影响数据可读性和用户体验。这可能是由于 Chart.js 配置中的字体设置、编码问题或 tooltip/label 格式化错误导致的。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 修复饼图、折线图、仪表盘等图表上的错乱字符串显示
|
||||
- 检查并修正图表字体配置,确保使用中文字体或兼容字体
|
||||
- 修复 tooltip 和 label 的格式化回调函数
|
||||
- 确保图表文本在各种主题(明/暗色模式)下正确显示
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `chart-text-encoding-fix`: 修复图表文本编码和字体配置,确保中文和特殊字符正确显示
|
||||
|
||||
### Modified Capabilities
|
||||
- `chart-migration-patterns`: 更新图表迁移模式中的文本渲染配置,确保 Chart.js 图表文本正确显示
|
||||
|
||||
## Impact
|
||||
|
||||
- **前端组件**: `Web/src/components/Charts/` 下的所有图表组件
|
||||
- **配置文件**: 图表主题配置文件 `useChartTheme.ts`
|
||||
- **工具函数**: 图表辅助函数 `chartHelpers.ts`
|
||||
- **页面**: 统计页面、预算页面等使用图表的页面
|
||||
@@ -0,0 +1,129 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 仪表盘图表迁移模式
|
||||
组件 SHALL 使用 Chart.js Doughnut 图表实现仪表盘(Gauge)效果,替代 ECharts Gauge 图表。
|
||||
|
||||
#### Scenario: 半圆仪表盘渲染
|
||||
- **WHEN** 组件接收预算统计数据(current, limit)
|
||||
- **THEN** 系统使用 Doughnut 图表渲染半圆进度条,配置 `rotation: -90` 和 `circumference: 180`
|
||||
- **AND** 图表字体配置 SHALL 包含中文字体支持
|
||||
|
||||
#### Scenario: 中心文本叠加显示
|
||||
- **WHEN** 仪表盘图表渲染完成
|
||||
- **THEN** 系统在图表中心显示余额/超支文本,使用 CSS 绝对定位的覆盖层
|
||||
- **AND** 中心文本 SHALL 正确显示中文,无乱码
|
||||
|
||||
#### Scenario: 动态颜色切换
|
||||
- **WHEN** 实际值超过预算限额
|
||||
- **THEN** 进度条颜色切换为危险色(`var(--van-danger-color)`),中心文本显示"超支"
|
||||
- **AND** "超支"文字 SHALL 清晰可读
|
||||
|
||||
#### Scenario: 暗色模式适配
|
||||
- **WHEN** 用户切换到暗色主题
|
||||
- **THEN** 图表颜色自动适配,使用 `useChartTheme` composable 获取主题色
|
||||
- **AND** 文本颜色 SHALL 与背景有足够对比度
|
||||
|
||||
### Requirement: 折线图迁移模式
|
||||
组件 SHALL 使用 Chart.js Line 图表实现趋势折线图,替代 ECharts Line 图表。
|
||||
|
||||
#### Scenario: 单系列折线图渲染
|
||||
- **WHEN** 组件接收月度支出数据(日期 + 金额数组)
|
||||
- **THEN** 系统渲染折线图,X 轴为日期标签,Y 轴为金额,使用渐变填充
|
||||
- **AND** X 轴日期标签 SHALL 正确显示,无乱码
|
||||
|
||||
#### Scenario: 双系列折线图渲染
|
||||
- **WHEN** 组件接收收支数据(包含收入和支出两个系列)
|
||||
- **THEN** 系统渲染两条折线,支出为红色,收入为绿色,支持独立的 hover 交互
|
||||
- **AND** 图例 SHALL 正确显示"收入"和"支出"中文标签
|
||||
|
||||
#### Scenario: 空数据处理
|
||||
- **WHEN** 图表数据为空或所有数据点为 0
|
||||
- **THEN** 系统显示 `<van-empty>` 空状态组件,而非空白图表
|
||||
|
||||
#### Scenario: Tooltip 格式化
|
||||
- **WHEN** 用户 hover 到数据点
|
||||
- **THEN** Tooltip 显示"¥XXX.XX"格式的金额,使用 `callbacks.label` 自定义
|
||||
- **AND** Tooltip 内容 SHALL 正确编码,无乱码
|
||||
|
||||
### Requirement: 饼图/环形图迁移模式
|
||||
组件 SHALL 使用 Chart.js Doughnut 图表实现分类统计环形图,替代 ECharts Pie 图表。
|
||||
|
||||
#### Scenario: 环形图渲染
|
||||
- **WHEN** 组件接收分类统计数据(分类名称 + 金额数组)
|
||||
- **THEN** 系统渲染环形图,每个分类使用不同颜色,配置 `cutout: '50%'`
|
||||
- **AND** 分类标签 SHALL 正确显示中文名称
|
||||
|
||||
#### Scenario: 分类颜色映射
|
||||
- **WHEN** 分类数据包含预定义颜色
|
||||
- **THEN** 图表使用 props 传入的颜色数组,确保与列表中的分类色块一致
|
||||
|
||||
#### Scenario: 小分类合并
|
||||
- **WHEN** 分类数量超过 10 个
|
||||
- **THEN** 系统使用 `mergeSmallCategories()` 工具函数,将占比小于 5% 的分类合并为"其他"
|
||||
- **AND** "其他"标签 SHALL 正确显示
|
||||
|
||||
#### Scenario: 点击分类跳转
|
||||
- **WHEN** 用户点击环形图扇区
|
||||
- **THEN** 系统触发 `@category-click` 事件,传递分类名称和类型
|
||||
|
||||
### Requirement: BaseChart 组件统一使用
|
||||
所有图表组件 SHALL 使用 `BaseChart.vue` 包装组件,而非直接使用 vue-chartjs 组件。
|
||||
|
||||
#### Scenario: BaseChart 组件使用
|
||||
- **WHEN** 组件需要渲染图表
|
||||
- **THEN** 使用 `<BaseChart type="line|bar|doughnut" :data="chartData" :options="chartOptions" />`
|
||||
- **AND** 通过 options 传入正确的字体配置
|
||||
|
||||
#### Scenario: Loading 状态处理
|
||||
- **WHEN** 图表数据加载中
|
||||
- **THEN** BaseChart 显示 `<van-loading>` 组件
|
||||
|
||||
#### Scenario: 图表渲染回调
|
||||
- **WHEN** 图表渲染完成
|
||||
- **THEN** BaseChart 触发 `@chart:render` 事件,传递 Chart.js 实例引用
|
||||
|
||||
### Requirement: ECharts 代码完全移除
|
||||
组件 SHALL 移除所有 ECharts 相关代码,包括导入语句、实例变量、环境变量判断。
|
||||
|
||||
#### Scenario: 移除 ECharts 导入
|
||||
- **WHEN** 迁移组件
|
||||
- **THEN** 删除 `import * as echarts from 'echarts'` 语句
|
||||
|
||||
#### Scenario: 移除环境变量开关
|
||||
- **WHEN** 迁移组件
|
||||
- **THEN** 删除 `const useChartJS = import.meta.env.VITE_USE_CHARTJS === 'true'` 和相关的 `v-if`/`v-else` 条件渲染
|
||||
|
||||
#### Scenario: 移除 ECharts 实例管理
|
||||
- **WHEN** 迁移组件
|
||||
- **THEN** 删除 `let chartInstance = null`、`echarts.init()`、`chartInstance.setOption()` 等代码
|
||||
|
||||
#### Scenario: 移除生命周期清理
|
||||
- **WHEN** 迁移组件
|
||||
- **THEN** 删除 `onBeforeUnmount()` 中的 `chartInstance.dispose()` 调用
|
||||
|
||||
### Requirement: 测试覆盖
|
||||
迁移后的组件 SHALL 通过白盒和黑盒测试验证功能正确性。
|
||||
|
||||
#### Scenario: 单元测试 - 组件挂载
|
||||
- **WHEN** 运行 Jest 单元测试
|
||||
- **THEN** 组件能够成功挂载,不抛出错误
|
||||
|
||||
#### Scenario: 单元测试 - Props 传递
|
||||
- **WHEN** 传入测试数据 props
|
||||
- **THEN** 计算属性 `chartData` 和 `chartOptions` 返回正确的 Chart.js 配置对象
|
||||
- **AND** options 中 SHALL 包含正确的字体配置
|
||||
|
||||
#### Scenario: E2E 测试 - 图表渲染
|
||||
- **WHEN** 运行 Playwright E2E 测试
|
||||
- **THEN** 浏览器中能看到图表元素(Canvas),且无控制台错误
|
||||
- **AND** 图表文本 SHALL 正确显示,无乱码
|
||||
|
||||
#### Scenario: E2E 测试 - 用户交互
|
||||
- **WHEN** 用户 hover 到图表数据点
|
||||
- **THEN** Tooltip 正确显示,格式化后的金额信息可见
|
||||
- **AND** Tooltip 内容 SHALL 无乱码
|
||||
|
||||
#### Scenario: 视觉回归测试
|
||||
- **WHEN** 截图对比迁移前后的图表
|
||||
- **THEN** 颜色、布局、字体大小差异在可接受范围内(像素差异 < 5%)
|
||||
- **AND** 中文文本 SHALL 清晰可读
|
||||
@@ -0,0 +1,44 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 图表文本编码修复
|
||||
Chart.js 图表 SHALL 正确显示中文文本,不出现乱码或异常字符。
|
||||
|
||||
#### Scenario: 饼图标签中文显示
|
||||
- **WHEN** 系统渲染支出分类饼图,分类名称为中文
|
||||
- **THEN** 分类标签 SHALL 正确显示中文字符
|
||||
- **AND** 标签文字 SHALL 清晰可读,无乱码
|
||||
|
||||
#### Scenario: Tooltip 中文显示
|
||||
- **WHEN** 用户 hover 到图表数据点
|
||||
- **THEN** Tooltip SHALL 正确显示中文内容
|
||||
- **AND** 金额和分类名称 SHALL 无乱码
|
||||
|
||||
#### Scenario: 中心文本中文显示
|
||||
- **WHEN** 仪表盘图表渲染中心文本
|
||||
- **THEN** 中心显示的余额/超支文本 SHALL 正确显示中文
|
||||
- **AND** 文字 SHALL 清晰无乱码
|
||||
|
||||
### Requirement: 图表字体配置
|
||||
Chart.js 配置 SHALL 使用兼容的字体设置,确保跨平台文本正确渲染。
|
||||
|
||||
#### Scenario: 字体族配置
|
||||
- **WHEN** 图表初始化时
|
||||
- **THEN** 系统 SHALL 配置 `font.family` 为兼容中文字体的字体栈(如 `'PingFang SC', 'Microsoft YaHei', sans-serif`)
|
||||
|
||||
#### Scenario: 响应式字体大小
|
||||
- **WHEN** 图表在不同尺寸屏幕上渲染
|
||||
- **THEN** 字体大小 SHALL 根据屏幕尺寸自动调整
|
||||
- **AND** 文字 SHALL 始终保持清晰可读
|
||||
|
||||
### Requirement: Tooltip 格式化修复
|
||||
Tooltip 回调函数 SHALL 正确处理文本编码和格式化。
|
||||
|
||||
#### Scenario: Tooltip Label 格式化
|
||||
- **WHEN** Tooltip 显示数据标签
|
||||
- **THEN** 回调函数 SHALL 返回正确编码的字符串
|
||||
- **AND** 特殊字符 SHALL 正确转义
|
||||
|
||||
#### Scenario: 金额格式化显示
|
||||
- **WHEN** Tooltip 显示金额
|
||||
- **THEN** 金额格式 SHALL 为 "¥XXX.XX"
|
||||
- **AND** 货币符号 SHALL 正确显示
|
||||
@@ -0,0 +1,43 @@
|
||||
## 1. 全局字体配置
|
||||
|
||||
- [x] 1.1 修改 `useChartTheme.ts`,添加 Chart.js 全局字体配置
|
||||
- [x] 1.2 配置字体栈支持中文显示:`'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif`
|
||||
- [x] 1.3 设置默认字体大小和颜色变量
|
||||
|
||||
## 2. Tooltip 格式化修复
|
||||
|
||||
- [x] 2.1 检查饼图组件的 Tooltip 回调函数,修复编码问题
|
||||
- [x] 2.2 检查折线图组件的 Tooltip 回调函数,确保金额格式正确
|
||||
- [x] 2.3 检查仪表盘组件的 Tooltip 配置
|
||||
- [x] 2.4 确保所有 Tooltip 回调返回正确的字符串类型
|
||||
|
||||
## 3. 图表组件文本修复
|
||||
|
||||
- [x] 3.1 修复饼图分类标签的中文显示
|
||||
- [x] 3.2 修复折线图 X 轴日期标签显示
|
||||
- [x] 3.3 修复仪表盘中心文本(余额/超支)的中文显示
|
||||
- [x] 3.4 确保图例(Legend)中文标签正确显示
|
||||
|
||||
## 4. 主题适配
|
||||
|
||||
- [x] 4.1 验证明色模式下图表文本清晰可读
|
||||
- [x] 4.2 验证暗色模式下图表文本颜色和对比度
|
||||
- [x] 4.3 修复主题切换时可能出现的文本渲染问题
|
||||
|
||||
## 7. 修复密集数字显示
|
||||
|
||||
- [x] 7.1 禁用折线图的数据标签(datalabels)
|
||||
- [x] 7.2 验证明暗模式下图表显示正常
|
||||
|
||||
## 5. 测试验证
|
||||
|
||||
- [x] 5.1 在 Chrome 浏览器中验证所有图表文本显示
|
||||
- [x] 5.2 在移动端浏览器中验证图表文本显示
|
||||
- [x] 5.3 验证 Tooltip hover 时文本无乱码
|
||||
- [x] 5.4 检查控制台是否有相关错误日志
|
||||
|
||||
## 6. 代码审查
|
||||
|
||||
- [x] 6.1 运行 `pnpm lint` 检查代码格式
|
||||
- [x] 6.2 运行 `pnpm build` 确保构建成功
|
||||
- [x] 6.3 检查是否有未使用的导入或变量
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-14
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
用户在 2026 年 2 月份查看 2026 年 1 月份的月度预算统计时,页面显示"超支170元",但用户认为应该远远大于这个值。经检查发现,页面显示的"超支170"仅计算了月度支出预算(Type=1)的超支,未包含年度支出预算(Type=2)在该月的实际支出。1 月份实际总支出为 36,130.40 元,但页面只显示了 23,284.40 元(月度支出预算),缺少了约 12,846 元的年度支出预算部分。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **修改月度预算统计逻辑**:在 `CalculateMonthlyCategoryStatsAsync` 方法中,统计支出时需要包含年度支出预算(Type=2)在该月的实际支出金额
|
||||
- **修改数据源逻辑**:`GetAllBudgetsWithArchiveAsync` 方法在获取月度预算数据时,需要同时获取年度支出预算在该月的实际支出
|
||||
- **确保归档数据正确使用**:使用归档数据中的 `Actual` 值,而非重新计算
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
无新能力引入,仅修复现有逻辑。
|
||||
|
||||
### Modified Capabilities
|
||||
- `budget-stats`: 修改月度预算统计的需求,要求月度支出统计包含所有类型的实际支出(月度+年度支出预算在该月的支出)
|
||||
|
||||
## Impact
|
||||
|
||||
- **受影响代码**:`Service/Budget/BudgetStatsService.cs` 中的 `CalculateMonthlyCategoryStatsAsync` 和 `GetAllBudgetsWithArchiveAsync` 方法
|
||||
- **受影响 API**:预算统计相关的 API 接口(前端调用的获取预算统计信息的接口)
|
||||
- **数据来源**:`BudgetArchive` 表中的归档数据,需要正确使用归档的 `Actual` 值
|
||||
- **用户体验**:修复后,用户查看月度预算统计时,将看到包含所有实际支出的准确数据
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-16
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: 图表样式优化设计文档
|
||||
author: AI Assistant
|
||||
date: 2026-02-16
|
||||
status: draft
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### 当前状态
|
||||
应用中的图表存在以下样式问题:
|
||||
|
||||
1. **支出分类饼图 (ExpenseCategoryCard.vue)**
|
||||
- 问题:显示了不应该有的坐标轴(X轴 0-7,Y轴 ¥0-¥1)
|
||||
- 原因:`useChartTheme.ts` 中的基础配置默认包含坐标轴,饼图配置未覆盖
|
||||
|
||||
2. **使用情况仪表盘 (BudgetChartAnalysis.vue)**
|
||||
- 问题:仪表盘周围有坐标轴和网格线干扰
|
||||
- 影响:视觉上显得混乱,不够简洁
|
||||
|
||||
3. **预算进度燃尽图**
|
||||
- 现状:样式基本可用,但缺乏现代感
|
||||
- 可优化:颜色对比度、网格线样式、动画效果
|
||||
|
||||
### 技术背景
|
||||
- 使用 Chart.js 4.5+ 和 vue-chartjs 5.3+
|
||||
- 主题配置通过 `useChartTheme.ts` 统一管理
|
||||
- 支持 Vant UI 的暗色模式
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
1. 移除所有饼图/环形图/仪表盘的不必要坐标轴
|
||||
2. 统一图表的视觉风格,符合 Vant 设计系统
|
||||
3. 优化配色方案,提高可读性和美观度
|
||||
4. 增强动画效果,提升交互体验
|
||||
5. 确保暗色模式下的显示效果
|
||||
|
||||
**Non-Goals:**
|
||||
- 不添加新的图表类型
|
||||
- 不修改业务逻辑或数据结构
|
||||
- 不改变现有的 API 接口
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 图表类型感知配置
|
||||
|
||||
**选择**: 修改 `useChartTheme.ts`,使其根据图表类型自动调整默认配置
|
||||
|
||||
**理由**:
|
||||
- 饼图/环形图/仪表盘不需要坐标轴
|
||||
- 折线图/柱状图需要坐标轴但可简化
|
||||
- 减少每个组件手动覆盖配置的重复工作
|
||||
|
||||
**实现**:
|
||||
```typescript
|
||||
// 新增根据图表类型获取配置的方法
|
||||
const getChartOptionsByType = (type: 'line' | 'bar' | 'pie' | 'doughnut', customOptions = {}) => {
|
||||
const baseOptions = baseChartOptions.value
|
||||
|
||||
// 无坐标轴图表类型
|
||||
if (['pie', 'doughnut'].includes(type)) {
|
||||
return mergeDeep(baseOptions, {
|
||||
scales: { x: { display: false }, y: { display: false } }
|
||||
}, customOptions)
|
||||
}
|
||||
|
||||
return mergeDeep(baseOptions, customOptions)
|
||||
}
|
||||
```
|
||||
|
||||
**替代方案**: 在每个使用饼图的组件中手动添加 `scales: { x: { display: false }, y: { display: false } }`
|
||||
- **排除原因**: 重复代码多,容易遗漏
|
||||
|
||||
### Decision 2: 简化坐标轴样式
|
||||
|
||||
**选择**: 对于需要坐标轴的图表,采用极简风格
|
||||
|
||||
**具体措施**:
|
||||
- 网格线:使用极淡的颜色 (`--van-border-color` 30% 透明度)
|
||||
- 刻度标签:减小字体大小至 10px
|
||||
- 移除坐标轴边框 (`drawBorder: false`)
|
||||
|
||||
**理由**:
|
||||
- 减少视觉噪音,突出数据本身
|
||||
- 移动设备上更清晰的阅读体验
|
||||
|
||||
### Decision 3: 优化配色方案
|
||||
|
||||
**选择**: 使用更现代、和谐的颜色方案
|
||||
|
||||
**具体措施**:
|
||||
1. **主色调扩展**:
|
||||
- 保留 Vant 主题色作为基础
|
||||
- 添加柔和的辅助色(降低饱和度)
|
||||
|
||||
2. **饼图/环形图**:
|
||||
- 使用 8 色渐进色板
|
||||
- 颜色从 Vant 主题派生但降低饱和度 20%
|
||||
|
||||
3. **折线图/柱状图**:
|
||||
- 支出:暖色调(橙红系)
|
||||
- 收入:冷色调(青绿系)
|
||||
- 对比度符合 WCAG AA 标准
|
||||
|
||||
**理由**:
|
||||
- 更符合现代移动端 UI 审美
|
||||
- 色盲友好
|
||||
|
||||
### Decision 4: 增强交互体验
|
||||
|
||||
**选择**: 添加微妙的悬停和点击效果
|
||||
|
||||
**具体措施**:
|
||||
1. **悬停效果**:
|
||||
- 饼图扇区:`hoverOffset: 8`(从 4 增加)
|
||||
- 折线点:`pointHoverRadius: 6`(从 4 增加)
|
||||
|
||||
2. **动画优化**:
|
||||
- 持续时间:750ms → 600ms(更快响应)
|
||||
- 缓动函数:`easeInOutQuart` → `easeOutQuart`(更自然的结束)
|
||||
|
||||
3. **触控优化**:
|
||||
- 增加触控目标大小
|
||||
- 支持捏合缩放(对于趋势图)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**风险 1**: 颜色变更可能影响用户习惯
|
||||
- **影响**: 低 - 纯视觉变化
|
||||
- **缓解**: 保持色相大致不变,只调整饱和度和明度
|
||||
|
||||
**风险 2**: 移除坐标轴可能降低某些图表的可读性
|
||||
- **影响**: 中 - 对于复杂数据集
|
||||
- **缓解**: 保留关键刻度,仅淡化网格线
|
||||
|
||||
**风险 3**: 动画增强可能影响低性能设备
|
||||
- **影响**: 低 - 已考虑 `prefers-reduced-motion`
|
||||
- **缓解**: 动画持续时间控制在 600ms 以内
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 实施顺序
|
||||
1. **Phase 1**: 修复坐标轴问题(最高优先级)
|
||||
- 修改 `useChartTheme.ts`
|
||||
- 更新 `ExpenseCategoryCard.vue`
|
||||
- 更新 `BudgetChartAnalysis.vue`
|
||||
|
||||
2. **Phase 2**: 配色优化
|
||||
- 更新图表色板
|
||||
- 调整渐变效果
|
||||
|
||||
3. **Phase 3**: 动画和交互增强
|
||||
- 优化悬停效果
|
||||
- 添加触控支持
|
||||
|
||||
### 回滚策略
|
||||
- 所有变更都是样式层面的
|
||||
- 可通过 git revert 回滚
|
||||
- 建议分步提交,便于部分回滚
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. 是否需要提供图表主题切换开关(明亮/暗黑/高对比度)?
|
||||
2. 预算页面的仪表盘是否需要添加中心数值显示?
|
||||
3. 是否需要支持图表数据的导出功能?
|
||||
|
||||
## 附录
|
||||
|
||||
### 参考资源
|
||||
- [Vant Design 色彩系统](https://vant-ui.github.io/vant/#/zh-CN/design-color)
|
||||
- [Chart.js 配置文档](https://www.chartjs.org/docs/latest/configuration/)
|
||||
- [WCAG 颜色对比度指南](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 图表样式全面优化
|
||||
author: AI Assistant
|
||||
date: 2026-02-16
|
||||
status: draft
|
||||
category: UI/UX
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
当前应用中统计页面和预算页面的图表样式存在明显问题:
|
||||
1. **支出分类饼图**出现了不应该有的坐标轴(X轴0-7,Y轴¥0-¥1),严重干扰视觉
|
||||
2. **使用情况仪表盘**周围也有坐标轴干扰,影响美观
|
||||
3. 所有图表整体样式缺乏现代感,显得凌乱
|
||||
|
||||
为提升用户体验和视觉品质,需要对应用内所有图表进行全面样式优化。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 统计页面 (Statistics)
|
||||
- **修复** 支出分类饼图的坐标轴问题,移除所有不必要的坐标轴和网格线
|
||||
- **优化** 收入/支出趋势图的样式,使其更简洁清晰
|
||||
- **统一** 图表配色方案,与 Vant 设计系统保持一致
|
||||
- **改进** 图表响应式布局和触控交互体验
|
||||
|
||||
### 预算页面 (Budget)
|
||||
- **修复** 使用情况仪表盘(月度/年度)的坐标轴干扰
|
||||
- **优化** 预算进度燃尽图的视觉层次和颜色对比度
|
||||
- **美化** 偏差分析图表的数据展示形式
|
||||
- **统一** 图表组件的圆角、阴影等视觉元素
|
||||
|
||||
### 通用改进
|
||||
- 更新 Chart.js 全局配置,移除默认坐标轴样式
|
||||
- 为暗色模式优化图表颜色
|
||||
- 添加平滑的动画过渡效果
|
||||
- 确保所有图表在移动设备上的可读性
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `chart-theme-system`: 统一的图表主题系统,支持明暗模式切换和主题色自动适配
|
||||
- `responsive-chart-layout`: 响应式图表布局组件,自动适配不同屏幕尺寸
|
||||
|
||||
### Modified Capabilities
|
||||
- 无现有 spec 需要修改(本次主要是样式优化,不涉及功能需求变更)
|
||||
|
||||
## Impact
|
||||
|
||||
**受影响文件**:
|
||||
- `Web/src/components/Charts/BaseChart.vue`
|
||||
- `Web/src/composables/useChartTheme.ts`
|
||||
- `Web/src/views/StatisticsView.vue`
|
||||
- `Web/src/views/BudgetView.vue`
|
||||
- `Web/src/utils/chartHelpers.ts`
|
||||
|
||||
**依赖**:
|
||||
- Chart.js 4.5+
|
||||
- vue-chartjs 5.3+
|
||||
- Vant UI 主题系统
|
||||
|
||||
**风险**:
|
||||
- 低 - 纯样式变更,不影响业务逻辑
|
||||
- 需验证所有图表在暗色模式下的可读性
|
||||
|
||||
## 更新日志
|
||||
|
||||
- 2026-02-16: 创建提案,定义图表优化范围
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: 图表主题系统规格
|
||||
author: AI Assistant
|
||||
date: 2026-02-16
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 图表类型感知配置
|
||||
`useChartTheme` 组合式函数 SHALL 根据图表类型自动提供合适的默认配置。
|
||||
|
||||
#### Scenario: 饼图/环形图自动隐藏坐标轴
|
||||
- **WHEN** 调用 `getChartOptionsByType('doughnut', customOptions)`
|
||||
- **THEN** 返回的配置中 `scales.x.display` 和 `scales.y.display` 均为 `false`
|
||||
- **AND** 返回的配置 SHALL 与 customOptions 深度合并
|
||||
|
||||
#### Scenario: 折线图/柱状图保留简化坐标轴
|
||||
- **WHEN** 调用 `getChartOptionsByType('line', customOptions)`
|
||||
- **THEN** 返回的配置包含简化的坐标轴样式
|
||||
- **AND** 网格线使用 `--van-border-color` 30% 透明度
|
||||
- **AND** 刻度标签字体大小为 10px
|
||||
|
||||
### Requirement: 现代化配色方案
|
||||
图表主题系统 SHALL 提供符合现代审美的配色方案。
|
||||
|
||||
#### Scenario: 主色板包含 8 个颜色
|
||||
- **WHEN** 访问 `chartPalette`
|
||||
- **THEN** 返回包含 8 个颜色的数组
|
||||
- **AND** 颜色 SHALL 从 Vant 主题色派生并降低 20% 饱和度
|
||||
|
||||
#### Scenario: 支出/收入颜色区分
|
||||
- **WHEN** 配置支出相关图表
|
||||
- **THEN** 默认使用暖色调(橙红系)
|
||||
- **WHEN** 配置收入相关图表
|
||||
- **THEN** 默认使用冷色调(青绿系)
|
||||
|
||||
### Requirement: 暗色模式适配
|
||||
图表 SHALL 自动适配 Vant UI 的暗色模式。
|
||||
|
||||
#### Scenario: 暗色模式颜色切换
|
||||
- **WHEN** Vant 主题切换为暗色模式
|
||||
- **THEN** 图表文本颜色 SHALL 自动变为浅色
|
||||
- **AND** 图表背景色 SHALL 与卡片背景一致
|
||||
- **AND** 网格线颜色 SHALL 变为深色系的边框色
|
||||
|
||||
#### Scenario: 手动颜色获取
|
||||
- **WHEN** 调用 `colors.text`
|
||||
- **THEN** 返回当前主题的文本颜色 CSS 变量值
|
||||
- **AND** SHALL 实时响应主题切换
|
||||
|
||||
### Requirement: 动画配置
|
||||
图表 SHALL 支持可配置的动画效果。
|
||||
|
||||
#### Scenario: 默认动画配置
|
||||
- **WHEN** 获取图表配置
|
||||
- **THEN** 默认动画持续时间为 600ms
|
||||
- **AND** 缓动函数为 `easeOutQuart`
|
||||
|
||||
#### Scenario: 减少动画偏好
|
||||
- **WHEN** 用户系统偏好 `prefers-reduced-motion: reduce`
|
||||
- **THEN** 动画持续时间 SHALL 自动设为 0
|
||||
- **AND** 图表 SHALL 立即渲染完成
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
无修改的现有需求。
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
无删除的需求。
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: 响应式图表布局规格
|
||||
author: AI Assistant
|
||||
date: 2026-02-16
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 容器自适应
|
||||
BaseChart 组件 SHALL 自动适应父容器大小。
|
||||
|
||||
#### Scenario: 容器大小变化
|
||||
- **WHEN** 父容器大小发生变化
|
||||
- **THEN** 图表 SHALL 自动调整尺寸
|
||||
- **AND** 使用 ResizeObserver 进行监听
|
||||
- **AND** 图表 SHALL 保持比例不失真
|
||||
|
||||
#### Scenario: 横竖屏切换
|
||||
- **WHEN** 移动设备从竖屏切换到横屏
|
||||
- **THEN** 图表 SHALL 在 300ms 内完成重绘
|
||||
- **AND** 所有元素 SHALL 保持可读性
|
||||
|
||||
### Requirement: 触控交互优化
|
||||
图表 SHALL 针对移动设备触控操作进行优化。
|
||||
|
||||
#### Scenario: 悬停效果增强
|
||||
- **WHEN** 用户悬停/触摸饼图扇区
|
||||
- **THEN** `hoverOffset` SHALL 为 8px(比默认值大)
|
||||
- **AND** 过渡动画 SHALL 流畅自然
|
||||
|
||||
#### Scenario: 折线图点触控
|
||||
- **WHEN** 用户触摸折线图数据点
|
||||
- **THEN** 点的 `pointHoverRadius` SHALL 为 6px
|
||||
- **AND** 触控目标 SHALL 足够大(最小 44px)
|
||||
|
||||
### Requirement: 空状态处理
|
||||
图表组件 SHALL 优雅处理空数据情况。
|
||||
|
||||
#### Scenario: 无数据时显示空状态
|
||||
- **WHEN` 传入的数据为空数组或 datasets 为空
|
||||
- **THEN** 显示 VanEmpty 组件
|
||||
- **AND** 显示文案 "暂无数据"
|
||||
|
||||
#### Scenario: 加载状态
|
||||
- **WHEN** `loading` prop 为 true
|
||||
- **THEN** 显示 VanLoading 组件
|
||||
- **AND** 显示文案 "加载中..."
|
||||
|
||||
### Requirement: 最小高度限制
|
||||
图表容器 SHALL 有最小高度限制以确保可读性。
|
||||
|
||||
#### Scenario: 小容器适配
|
||||
- **WHEN** 父容器高度小于 200px
|
||||
- **THEN** 图表 SHALL 使用 200px 作为最小高度
|
||||
- **AND** SHALL 显示滚动条或缩放提示
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
无修改的现有需求。
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
无删除的需求。
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: 图表样式优化任务清单
|
||||
author: AI Assistant
|
||||
date: 2026-02-16
|
||||
---
|
||||
|
||||
## 1. 核心配置更新
|
||||
|
||||
- [x] 1.1 修改 `useChartTheme.ts`,添加 `getChartOptionsByType` 方法
|
||||
- 根据图表类型自动隐藏/显示坐标轴
|
||||
- 为饼图/环形图设置默认 `scales: { x: { display: false }, y: { display: false } }`
|
||||
|
||||
- [x] 1.2 优化基础配色方案
|
||||
- 更新 `chartPalette` 为 8 色低饱和度色板
|
||||
- 调整 `colors` 对象以更好适配暗色模式
|
||||
|
||||
- [x] 1.3 简化坐标轴样式
|
||||
- 网格线透明度降至 30%
|
||||
- 刻度字体大小调整为 10px
|
||||
- 确保 `drawBorder: false`
|
||||
|
||||
## 2. 统计页面图表修复
|
||||
|
||||
- [x] 2.1 修复支出分类饼图 (ExpenseCategoryCard.vue)
|
||||
- 更新图表配置调用方式,使用新的类型感知配置
|
||||
- 验证坐标轴已完全隐藏
|
||||
|
||||
- [x] 2.2 优化每日趋势图 (DailyTrendChart.vue)
|
||||
- 更新渐变色使用新的配色方案
|
||||
- 调整动画参数(600ms, easeOutQuart)
|
||||
- 增大触控目标大小
|
||||
|
||||
- [x] 2.3 验证其他统计图表
|
||||
- 检查收入分类图表
|
||||
- 检查支出排行图表
|
||||
|
||||
## 3. 预算页面图表修复
|
||||
|
||||
- [x] 3.1 修复使用情况仪表盘 (BudgetChartAnalysis.vue)
|
||||
- 移除仪表盘周围的坐标轴和网格线
|
||||
- 优化中心文字显示
|
||||
|
||||
- [x] 3.2 优化预算进度燃尽图
|
||||
- 调整线条颜色和粗细
|
||||
- 优化网格线样式
|
||||
- 更新图例位置和样式
|
||||
|
||||
- [x] 3.3 检查偏差分析图表
|
||||
- 确保无坐标轴干扰
|
||||
- 优化数据标签显示
|
||||
|
||||
## 4. 通用组件优化
|
||||
|
||||
- [x] 4.1 更新 BaseChart.vue
|
||||
- 集成新的类型感知配置
|
||||
- 优化加载和空状态显示
|
||||
|
||||
- [x] 4.2 增强响应式处理
|
||||
- 确保 ResizeObserver 正常工作
|
||||
- 优化横竖屏切换体验
|
||||
|
||||
- [x] 4.3 更新 chartHelpers.ts
|
||||
- 优化渐变创建函数
|
||||
- 添加颜色格式化工具
|
||||
|
||||
## 5. 测试与验证
|
||||
|
||||
- [x] 5.1 运行前端构建
|
||||
- 执行 `pnpm build`
|
||||
- 确保无 TypeScript 错误
|
||||
|
||||
- [x] 5.2 验证明亮模式
|
||||
- 统计页面所有图表显示正常
|
||||
- 预算页面所有图表显示正常
|
||||
- 坐标轴问题已修复
|
||||
|
||||
- [x] 5.3 验证暗色模式
|
||||
- 主题系统自动适配暗色模式
|
||||
- 所有图表颜色适配正常
|
||||
|
||||
- [x] 5.4 移动端测试
|
||||
- 触控目标已增大(pointHoverRadius: 6, hitRadius: 20)
|
||||
- 动画参数已优化(600ms, easeOutQuart)
|
||||
|
||||
## 6. 代码整理
|
||||
|
||||
- [x] 6.1 运行代码格式化
|
||||
- 执行 `pnpm lint`
|
||||
- 执行 `pnpm format`
|
||||
|
||||
- [x] 6.2 清理无用代码
|
||||
- 删除重复函数定义
|
||||
- 移除未使用的导入
|
||||
|
||||
- [x] 6.3 更新文档注释
|
||||
- 为新增函数添加 JSDoc
|
||||
- 使用中文注释解释业务逻辑
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-17
|
||||
@@ -0,0 +1,56 @@
|
||||
## Context
|
||||
|
||||
当前统计页面使用 Chart.js 渲染折线图和饼图,但存在以下技术问题:
|
||||
|
||||
1. **折线图溢出**: 图表 canvas 尺寸计算未考虑容器边界,导致图表绘制超出卡片范围
|
||||
2. **时间范围显示**: 折线图 X 轴显示整个自然周期(如整月),但数据仅到当前日期,导致后半段为平直线
|
||||
3. **饼图标签**: 当前饼图使用图例(legend)展示分类,用户需要在图例和图表间来回查看
|
||||
|
||||
项目使用 Vue 3 + Chart.js + vue-chartjs 技术栈,图表配置通过 `useChartTheme` composable 统一管理。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 实现图表在容器内的自适应布局,无溢出
|
||||
- 折线图动态计算数据截止时间,仅显示有效数据范围
|
||||
- 饼图扇区直接渲染分类名称标签
|
||||
- 保持现有主题配置和响应式行为
|
||||
|
||||
**Non-Goals:**
|
||||
- 不更换图表库(保持 Chart.js)
|
||||
- 不修改数据 API 或数据结构
|
||||
- 不添加新的图表类型
|
||||
- 不影响其他页面的图表显示
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 布局约束方案: CSS 容器 + Chart.js responsive 配置
|
||||
- **选择**: 结合 CSS `overflow: hidden` 和 Chart.js `maintainAspectRatio: false` + `responsive: true`
|
||||
- **理由**: 利用 Chart.js 内置的响应式机制,同时通过 CSS 确保容器边界约束
|
||||
- **替代方案**: 手动计算 canvas 尺寸(复杂,需监听 resize)
|
||||
|
||||
### 2. 折线图数据过滤: 前端日期截断
|
||||
- **选择**: 在组件内根据当前日期过滤数据数组,仅传递有效数据给 Chart.js
|
||||
- **理由**: 最小化改动,不修改 API;保持数据完整性以备他用
|
||||
- **替代方案**: 后端 API 支持日期参数(需后端改动,过度设计)
|
||||
|
||||
### 3. 饼图标签方案: Chart.js datalabels 插件
|
||||
- **选择**: 使用 `chartjs-plugin-datalabels` 插件在扇区上渲染标签
|
||||
- **理由**: 官方推荐方案,支持自动位置计算和碰撞检测
|
||||
- **替代方案**: 自定义绘制(复杂,需处理重叠)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| 饼图标签在扇区过小时显示不全 | 设置最小扇区角度阈值,小分类合并为"其他" |
|
||||
| 暗色模式切换时标签颜色适配 | 通过 `useChartTheme` 动态计算对比色 |
|
||||
| 性能影响(datalabels 插件) | 仅在饼图启用,监控渲染耗时 |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
无需迁移,纯视觉修复,向后兼容。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 饼图标签在移动端小屏幕上的显示策略(待实现时验证)
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
统计页面的图表存在两个影响用户体验的显示问题:折线图超出卡片边界且显示未来日期的平直线段,饼图缺少直接的分类标签展示。这些问题降低了数据可视化的直观性和美观度,需要修复以提升用户查看统计数据的体验。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 修复折线图(Line Chart)在统计卡片中的溢出布局问题,确保图表完全包含在卡片边界内
|
||||
- 修改折线图数据截止时间逻辑,从显示完整自然周期(如整月)改为仅显示至当前日期,避免未来日期形成无意义的平直线段
|
||||
- 优化支出分类饼图(Pie Chart),在饼图扇区上直接显示分类名称标签,提升可读性
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `chart-layout-constraint`: 图表在容器内的自适应布局和边界约束控制
|
||||
- `chart-data-filtering`: 基于当前日期的动态数据过滤和范围控制
|
||||
- `chart-label-overlay`: 饼图扇区上的直接标签渲染和位置计算
|
||||
|
||||
### Modified Capabilities
|
||||
- (无现有能力需要修改需求)
|
||||
|
||||
## Impact
|
||||
|
||||
- **受影响组件**:
|
||||
- `Web/src/components/Charts/BaseChart.vue` - 基础图表组件
|
||||
- `Web/src/views/Statistics/` 下的统计页面组件
|
||||
- `Web/src/composables/useChartTheme.ts` - 图表主题配置
|
||||
- **图表库**: Chart.js 配置选项调整
|
||||
- **无API变更**: 纯前端显示层修复
|
||||
- **向后兼容**: 无破坏性变更
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Line chart date range truncation
|
||||
The line chart SHALL display data only up to the current date, not the full natural period.
|
||||
|
||||
#### Scenario: Monthly view shows data to current day
|
||||
- **GIVEN** today is the 15th of the month
|
||||
- **WHEN** the user views the monthly statistics chart
|
||||
- **THEN** the chart SHALL display data from the 1st to the 15th only
|
||||
- **AND** the X-axis SHALL NOT show dates beyond the current day
|
||||
|
||||
#### Scenario: Weekly view shows data to current day
|
||||
- **GIVEN** today is Wednesday
|
||||
- **WHEN** the user views the weekly statistics chart
|
||||
- **THEN** the chart SHALL display data from Monday to Wednesday only
|
||||
- **AND** no flat line segments SHALL appear for future dates
|
||||
|
||||
#### Scenario: Yearly view shows data to current month
|
||||
- **GIVEN** today is in June
|
||||
- **WHEN** the user views the yearly statistics chart
|
||||
- **THEN** the chart SHALL display data from January to June only
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Pie chart direct category labeling
|
||||
The pie chart SHALL display category names directly on or adjacent to their corresponding sectors.
|
||||
|
||||
#### Scenario: Category labels rendered on pie sectors
|
||||
- **WHEN** the expense category pie chart is displayed
|
||||
- **THEN** each sector SHALL display its category name as a label
|
||||
- **AND** the label SHALL be positioned to not obscure the sector
|
||||
|
||||
#### Scenario: Labels adapt to sector size
|
||||
- **GIVEN** a category represents less than 5% of total expenses
|
||||
- **WHEN** the pie chart renders
|
||||
- **THEN** the label for that small sector MAY be hidden to avoid clutter
|
||||
- **AND** the category SHALL still be identifiable via tooltip on hover
|
||||
|
||||
#### Scenario: Label visibility in dark mode
|
||||
- **GIVEN** the application is in dark mode
|
||||
- **WHEN** the pie chart displays labels on sectors
|
||||
- **THEN** the label text color SHALL provide sufficient contrast against the sector color
|
||||
- **AND** labels SHALL remain readable against both light and dark sector colors
|
||||
@@ -0,0 +1,14 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Chart container boundary enforcement
|
||||
The chart SHALL be fully contained within its parent card container without overflow.
|
||||
|
||||
#### Scenario: Chart renders within card boundaries
|
||||
- **WHEN** the statistics page displays a line chart in a card component
|
||||
- **THEN** the chart canvas SHALL NOT extend beyond the card's padding boundaries
|
||||
- **AND** the chart SHALL adapt to container resize events
|
||||
|
||||
#### Scenario: Chart adapts to mobile viewport
|
||||
- **WHEN** the viewport width is less than 375px
|
||||
- **THEN** the chart SHALL scale down proportionally
|
||||
- **AND** no horizontal scrolling SHALL be required to view the full chart
|
||||
@@ -0,0 +1,27 @@
|
||||
## 1. 图表布局修复
|
||||
|
||||
- [x] 1.1 检查 BaseChart.vue 响应式配置,确保 maintainAspectRatio: false 和 responsive: true
|
||||
- [x] 1.2 为统计卡片添加 CSS 约束,设置 overflow: hidden 和固定高度
|
||||
- [x] 1.3 验证图表在移动端(<375px)下正常缩放无溢出
|
||||
|
||||
## 2. 折线图数据过滤
|
||||
|
||||
- [x] 2.1 在统计页面组件中添加当前日期获取逻辑
|
||||
- [x] 2.2 实现数据过滤函数,根据周期类型(日/周/月/年)截断未来日期数据
|
||||
- [x] 2.3 更新折线图数据传递,仅传递过滤后的数据
|
||||
- [x] 2.4 验证 X 轴不再显示未来日期
|
||||
|
||||
## 3. 饼图标签渲染
|
||||
|
||||
- [x] 3.1 安装 chartjs-plugin-datalabels 插件
|
||||
- [x] 3.2 在 useChartTheme.ts 中添加饼图标签配置
|
||||
- [x] 3.3 实现标签位置计算和扇区大小阈值控制(<5% 隐藏标签)
|
||||
- [x] 3.4 添加暗色模式下的标签颜色适配逻辑
|
||||
- [x] 3.5 验证标签在各类别扇区上正确显示
|
||||
|
||||
## 4. 测试与验证
|
||||
|
||||
- [x] 4.1 运行前端 lint 检查
|
||||
- [x] 4.2 在桌面端验证所有图表显示正常
|
||||
- [x] 4.3 在移动端验证响应式布局
|
||||
- [x] 4.4 验证暗色模式下图表标签可读性
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-11
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-11
|
||||
@@ -0,0 +1,219 @@
|
||||
# Design: 修复主题色差与导航栏间距
|
||||
|
||||
## Overview
|
||||
|
||||
本设计文档详细说明如何修复 EmailBill 应用的两大 UI 问题:
|
||||
1. 亮色/暗色主题下的颜色不一致(色差)
|
||||
2. 底部导航栏与屏幕底部间距过大
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### 1. 主题色彩统一方案
|
||||
|
||||
#### Current Issues
|
||||
- 导航栏、卡片背景色与页面背景色存在色差
|
||||
- 亮色和暗色模式下颜色变量定义不一致
|
||||
- Vant UI 组件主题定制配置不完整
|
||||
|
||||
#### Solution Approach
|
||||
使用 CSS 变量(CSS Custom Properties)统一管理主题色彩,确保所有组件引用相同的变量。
|
||||
|
||||
```css
|
||||
/* Web/src/styles/theme.css */
|
||||
:root {
|
||||
/* 亮色主题 - 背景色系统 */
|
||||
--bg-primary: #FFFFFF;
|
||||
--bg-secondary: #F6F7F8;
|
||||
--bg-tertiary: #F5F5F5;
|
||||
|
||||
/* 亮色主题 - 文本色系统 */
|
||||
--text-primary: #1A1A1A;
|
||||
--text-secondary: #6B7280;
|
||||
--text-tertiary: #9CA3AF;
|
||||
|
||||
/* 语义色 */
|
||||
--color-primary: #3B82F6;
|
||||
--color-danger: #FF6B6B;
|
||||
--color-success: #07C160;
|
||||
--color-warning: #FAAD14;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* 暗色主题 - 背景色系统 */
|
||||
--bg-primary: #09090B;
|
||||
--bg-secondary: #18181B;
|
||||
--bg-tertiary: #27272A;
|
||||
|
||||
/* 暗色主题 - 文本色系统 */
|
||||
--text-primary: #F4F4F5;
|
||||
--text-secondary: #A1A1AA;
|
||||
--text-tertiary: #71717A;
|
||||
|
||||
/* 语义色在暗色模式下保持不变或微调 */
|
||||
--color-primary: #3B82F6;
|
||||
--color-danger: #FF6B6B;
|
||||
--color-success: #07C160;
|
||||
--color-warning: #FAAD14;
|
||||
}
|
||||
```
|
||||
|
||||
#### Vant UI 主题映射
|
||||
|
||||
```javascript
|
||||
// Web/src/main.ts 或主题配置文件
|
||||
import { ConfigProvider } from 'vant'
|
||||
|
||||
const themeVars = {
|
||||
// 亮色主题
|
||||
light: {
|
||||
'--van-nav-bar-background': 'var(--bg-primary)',
|
||||
'--van-nav-bar-text-color': 'var(--text-primary)',
|
||||
'--van-card-background': 'var(--bg-secondary)',
|
||||
'--van-cell-background': 'var(--bg-secondary)',
|
||||
'--van-background': 'var(--bg-primary)',
|
||||
'--van-background-2': 'var(--bg-secondary)',
|
||||
'--van-text-color': 'var(--text-primary)',
|
||||
'--van-text-color-2': 'var(--text-secondary)',
|
||||
'--van-border-color': 'var(--bg-tertiary)',
|
||||
},
|
||||
// 暗色主题
|
||||
dark: {
|
||||
'--van-nav-bar-background': 'var(--bg-primary)',
|
||||
'--van-nav-bar-text-color': 'var(--text-primary)',
|
||||
'--van-card-background': 'var(--bg-secondary)',
|
||||
'--van-cell-background': 'var(--bg-secondary)',
|
||||
'--van-background': 'var(--bg-primary)',
|
||||
'--van-background-2': 'var(--bg-secondary)',
|
||||
'--van-text-color': 'var(--text-primary)',
|
||||
'--van-text-color-2': 'var(--text-secondary)',
|
||||
'--van-border-color': 'var(--bg-tertiary)',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 底部导航栏间距优化
|
||||
|
||||
#### Current Issues
|
||||
- 底部导航栏距离屏幕底部过远
|
||||
- 未正确处理 iPhone 安全区域(Safe Area)
|
||||
- padding/margin 设置不合理
|
||||
|
||||
#### Solution Approach
|
||||
|
||||
```css
|
||||
/* Web/src/styles/layout.css */
|
||||
|
||||
/* 底部导航栏容器 */
|
||||
.tabbar-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* 导航栏主体 - 减少不必要的 padding */
|
||||
.van-tabbar {
|
||||
height: 56px; /* 标准高度 */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px); /* 仅保留安全区域内边距 */
|
||||
background: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
/* 导航项样式 */
|
||||
.van-tabbar-item {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.van-tabbar-item--active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 页面内容区域需要为底部导航栏留出空间 */
|
||||
.page-content {
|
||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
```
|
||||
|
||||
#### Vue 组件调整
|
||||
|
||||
```vue
|
||||
<!-- Layout.vue 或 App.vue -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<router-view class="page-content" />
|
||||
<van-tabbar v-model="activeTab" class="tabbar-container">
|
||||
<van-tabbar-item icon="home-o">首页</van-tabbar-item>
|
||||
<van-tabbar-item icon="chart-o">统计</van-tabbar-item>
|
||||
<van-tabbar-item icon="setting-o">设置</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.tabbar-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:deep(.van-tabbar) {
|
||||
height: 56px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
background: var(--bg-primary) !important;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Implementation Files
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. **Web/src/styles/theme.css** (新建)
|
||||
- 定义 CSS 变量系统
|
||||
- 包含亮色/暗色两套变量
|
||||
|
||||
2. **Web/src/styles/layout.css** (新建或修改)
|
||||
- 底部导航栏布局样式
|
||||
- 安全区域处理
|
||||
|
||||
3. **Web/src/main.ts**
|
||||
- 引入主题样式文件
|
||||
- 配置 Vant UI 主题变量
|
||||
|
||||
4. **Web/src/App.vue** (或布局组件)
|
||||
- 调整底部导航栏结构
|
||||
- 应用新的布局样式
|
||||
|
||||
5. **Web/src/components/Navigation/** (如存在)
|
||||
- 更新导航组件样式
|
||||
|
||||
### Key Considerations
|
||||
|
||||
1. **兼容性**: CSS 变量在现代浏览器中支持良好,项目使用 Vite 构建,无需额外 polyfill
|
||||
|
||||
2. **性能**: CSS 变量在运行时计算,对性能影响极小
|
||||
|
||||
3. **可维护性**: 集中管理颜色变量,后续主题调整只需修改一处
|
||||
|
||||
4. **测试**: 需要在 iOS Safari、Android Chrome、微信内置浏览器中测试显示效果
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. **Phase 1**: 创建 theme.css 和 layout.css,定义新的变量系统
|
||||
2. **Phase 2**: 逐步替换硬编码颜色值为 CSS 变量
|
||||
3. **Phase 3**: 更新 Vant UI 主题配置
|
||||
4. **Phase 4**: 调整底部导航栏布局
|
||||
5. **Phase 5**: 跨设备测试验证
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
根据用户提供的实机截图,发现 EmailBill 应用在两种主题色(亮色/暗色)下存在明显的色差问题:导航栏、卡片背景等组件颜色与页面背景色不一致,造成视觉割裂感。同时,底部导航栏距离屏幕底部过远,留下大量空白区域,影响移动端使用体验。这些问题需要统一修复以提升应用的整体视觉一致性和用户体验。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **统一主题色彩系统**: 修复亮色和暗色模式下导航栏、卡片、按钮等组件的颜色变量,确保同一主题内颜色一致性
|
||||
- **优化底部导航栏布局**: 调整底部导航栏的间距,减少不必要的空白区域,使其更贴近屏幕底部
|
||||
- **更新 CSS 样式文件**: 修改 `Web/src/styles/` 下的主题变量定义
|
||||
- **调整组件样式**: 更新 Vant UI 组件的主题定制配置
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- 此变更主要是修复现有问题,不引入新能力 -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `theme-system`: 统一亮色/暗色主题的色彩变量,消除色差问题
|
||||
- `navbar-layout`: 优化底部导航栏的间距和定位
|
||||
|
||||
## Impact
|
||||
|
||||
- **前端代码**: `Web/src/styles/` 目录下的主题配置文件
|
||||
- **组件库**: Vant UI 的主题定制配置 (`Web/src/main.ts` 或主题配置文件)
|
||||
- **布局文件**: 涉及底部导航栏的页面布局样式
|
||||
- **测试**: 需要在亮色和暗色两种模式下进行视觉回归测试
|
||||
@@ -0,0 +1,120 @@
|
||||
# Tasks: 修复主题色差与导航栏间距
|
||||
|
||||
## Phase 1: 创建主题系统基础文件
|
||||
|
||||
### 1.1 创建 CSS 变量定义文件
|
||||
- [x] 新建 `Web/src/styles/theme.css`
|
||||
- [x] 定义亮色主题变量(背景、文本、语义色)
|
||||
- [x] 定义暗色主题变量(背景、文本、语义色)
|
||||
- [x] 确保两组变量名称一致,仅值不同
|
||||
|
||||
### 1.2 在 main.ts 中引入主题文件
|
||||
- [x] 导入 `theme.css` 到 `Web/src/main.ts`
|
||||
- [x] 确保 CSS 变量在应用启动时生效
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 配置 Vant UI 主题映射
|
||||
|
||||
### 2.1 更新 Vant 主题配置
|
||||
- [x] 修改 `Web/src/main.ts` 中的 Vant ConfigProvider 配置
|
||||
- [x] 将 `--van-nav-bar-background` 映射到 `--bg-primary`
|
||||
- [x] 将 `--van-card-background` 映射到 `--bg-secondary`
|
||||
- [x] 将 `--van-text-color` 映射到 `--text-primary`
|
||||
- [x] 将 `--van-border-color` 映射到 `--bg-tertiary`
|
||||
- [x] 确保亮/暗两套配置使用相同的变量名
|
||||
|
||||
### 2.2 验证组件颜色一致性
|
||||
- [ ] 检查 NavBar 组件背景色是否与页面背景一致
|
||||
- [ ] 检查 Card 组件背景色是否正确应用
|
||||
- [ ] 检查 Cell 组件背景色是否正确应用
|
||||
- [ ] 在亮色模式下截图对比
|
||||
- [ ] 在暗色模式下截图对比
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 修复底部导航栏间距
|
||||
|
||||
### 3.1 创建布局样式文件
|
||||
- [x] 新建或修改 `Web/src/styles/layout.css`
|
||||
- [x] 定义 `.tabbar-container` 固定定位样式
|
||||
- [x] 设置导航栏高度为 56px
|
||||
- [x] 添加 `env(safe-area-inset-bottom)` 安全区域内边距
|
||||
- [x] 移除多余的 margin/padding
|
||||
|
||||
### 3.2 调整主布局组件
|
||||
- [x] 修改 `Web/src/App.vue` (或布局组件)
|
||||
- [x] 为页面内容区域添加 `.page-content` 类
|
||||
- [x] 设置 `padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px))`
|
||||
- [x] 确保内容不被导航栏遮挡
|
||||
|
||||
### 3.3 优化 TabBar 组件样式
|
||||
- [x] 使用 `:deep(.van-tabbar)` 覆盖 Vant 默认样式
|
||||
- [x] 设置背景色为 `var(--bg-primary)`
|
||||
- [x] 移除不必要的 padding/margin
|
||||
- [x] 确保图标和文字垂直居中
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 清理硬编码颜色值
|
||||
|
||||
### 4.1 扫描并替换硬编码颜色
|
||||
- [x] 搜索项目中所有 `#FFFFFF`、`#000000` 等硬编码颜色
|
||||
- [x] 替换为对应的 CSS 变量
|
||||
- [x] 页面背景 → `--bg-primary`
|
||||
- [x] 卡片背景 → `--bg-secondary`
|
||||
- [x] 主要文本 → `--text-primary`
|
||||
- [x] 次要文本 → `--text-secondary`
|
||||
|
||||
### 4.2 检查自定义组件
|
||||
- [x] 审查所有 Vue 组件的 `<style>` 块
|
||||
- [x] 将硬编码颜色替换为 CSS 变量
|
||||
- [x] 确保 scoped 样式正确使用 `:deep()` 覆盖 Vant 样式
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 跨设备测试验证
|
||||
|
||||
### 5.1 iOS 设备测试
|
||||
- [ ] iPhone Safari (亮色模式)
|
||||
- [ ] iPhone Safari (暗色模式)
|
||||
- [ ] 微信内置浏览器 (亮色模式)
|
||||
- [ ] 微信内置浏览器 (暗色模式)
|
||||
- [ ] 验证安全区域适配是否正常
|
||||
|
||||
### 5.2 Android 设备测试
|
||||
- [ ] Chrome (亮色模式)
|
||||
- [ ] Chrome (暗色模式)
|
||||
- [ ] 微信内置浏览器 (亮色模式)
|
||||
- [ ] 微信内置浏览器 (暗色模式)
|
||||
|
||||
### 5.3 视觉回归测试
|
||||
- [ ] 对比修复前后的截图
|
||||
- [ ] 确认色差问题已解决
|
||||
- [ ] 确认导航栏底部无过多空白
|
||||
- [ ] 检查所有页面布局是否正常
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: 文档更新
|
||||
|
||||
### 6.1 更新项目文档
|
||||
- [ ] 在 AGENTS.md 中添加主题系统说明
|
||||
- [ ] 记录 CSS 变量命名规范
|
||||
- [ ] 添加 Vant UI 主题定制指南
|
||||
|
||||
### 6.2 代码审查
|
||||
- [x] 自我审查:检查代码风格和一致性
|
||||
- [x] 运行 `pnpm lint` 确保无 ESLint 错误
|
||||
- [x] 运行 `pnpm build` 确保构建成功
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 亮色主题下所有组件颜色一致,无色差
|
||||
- [ ] 暗色主题下所有组件颜色一致,无色差
|
||||
- [ ] 底部导航栏紧贴屏幕底部(考虑安全区域)
|
||||
- [ ] 页面内容可正常滚动,不被导航栏遮挡
|
||||
- [ ] 在 iOS 和 Android 设备上显示正常
|
||||
- [ ] 两种主题切换时无闪烁或延迟
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-15
|
||||
@@ -0,0 +1,212 @@
|
||||
## Context
|
||||
|
||||
当前系统使用AI生成SVG图标来表示分类,但生成的图标不够直观,与分类名称匹配度低,用户体验不佳。Iconify是一个包含200+图标库(如Material Design Icons、Font Awesome、Tailwind Icons等)的图标搜索服务,提供统一的API接口,可以直接在Web前端使用,无需安装额外的npm包。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 集成Iconify API,实现图标搜索和检索功能
|
||||
- 使用AI生成英文搜索关键字,提高搜索相关性
|
||||
- 将检索到的图标持久化到数据库,避免重复搜索
|
||||
- 提供RESTful API接口,支持图标管理操作
|
||||
- 替换现有的AI生成SVG图标逻辑,提升图标可视化质量
|
||||
|
||||
**Non-Goals:**
|
||||
- 不实现图标上传功能(仅使用Iconify API检索)
|
||||
- 不实现图标的在线编辑功能
|
||||
- 不支持自定义图标(仅使用Iconify现有图标库)
|
||||
- 不实现图标的热门推荐或相似图标推荐功能
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 使用Iconify API而非其他图标库
|
||||
**决策**: 选择Iconify API作为图标检索服务
|
||||
|
||||
**理由**:
|
||||
- Iconify集成了200+图标库,覆盖范围广,包括Material Design Icons、Font Awesome、Bootstrap Icons等主流库
|
||||
- 提供统一的搜索API,无需逐个调用不同图标库
|
||||
- 前端可以直接使用Iconify CDN,无需安装npm包
|
||||
- 搜索API响应快,返回数据结构清晰
|
||||
|
||||
**替代方案考虑**:
|
||||
- 方案A:使用单个图标库(如Material Design Icons)→ 覆盖范围有限,图标数量不足
|
||||
- 方案B:自建图标数据库 → 维护成本高,图标更新不及时
|
||||
- 方案C:使用多个图标库API → 需要分别集成不同API,开发复杂度高
|
||||
|
||||
### 2. AI生成搜索关键字而非直接使用分类名称翻译
|
||||
**决策**: 使用AI生成多个英文搜索关键字,而非直接翻译分类名称
|
||||
|
||||
**理由**:
|
||||
- 直接翻译可能不准确(如"餐饮"翻译为"catering",但更常用"food"或"restaurant")
|
||||
- 一个分类可能有多个相关的图标概念(如"交通"可以是"car"、"bus"、"transport")
|
||||
- AI能够理解语义,生成更准确的英文搜索词
|
||||
|
||||
**替代方案考虑**:
|
||||
- 方案A:直接翻译分类名称 → 关键字可能不准确,搜索结果相关性低
|
||||
- 方案B:硬编码关键字映射表 → 维护成本高,不灵活
|
||||
- 方案C:用户手动输入关键字 → 增加用户操作负担
|
||||
|
||||
### 3. 图标持久化到数据库而非实时搜索
|
||||
**决策**: 将检索到的图标保存到数据库,避免重复搜索
|
||||
|
||||
**理由**:
|
||||
- 减少对Iconify API的调用次数,降低依赖风险
|
||||
- 提高图标获取速度(从数据库读取比API调用快)
|
||||
- 可以记录每个图标使用的搜索关键字,便于后续分析和优化
|
||||
- 避免重复存储相同图标,节省存储空间
|
||||
|
||||
**替代方案考虑**:
|
||||
- 方案A:每次都实时调用Iconify API → 依赖性强,API可能限流或中断
|
||||
- 方案B:使用缓存(如Redis) → 缓存可能过期,需要处理缓存失效逻辑
|
||||
- 方案C:前端缓存图标 → 无法跨设备同步,数据不一致
|
||||
|
||||
### 4. 修改TransactionCategory实体
|
||||
**决策**: 修改TransactionCategory.Icon字段,从存储SVG格式改为存储Iconify图标标识符;新增IconKeywords字段
|
||||
|
||||
**理由**:
|
||||
- 现有的TransactionCategory表已有Icon字段,无需创建新表
|
||||
- 存储Iconify标识符(如"mdi:home")比存储SVG字符串更简洁
|
||||
- 新增IconKeywords字段记录AI生成的搜索关键字,便于后续分析和重新搜索
|
||||
|
||||
**字段修改**:
|
||||
```csharp
|
||||
public class TransactionCategory : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 图标(Iconify标识符格式:{collection}:{name},如"mdi:home")
|
||||
/// </summary>
|
||||
[Column(StringLength = 50)]
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 搜索关键字(JSON数组,如["food", "restaurant", "dining"])
|
||||
/// </summary>
|
||||
[Column(StringLength = 200)]
|
||||
public string? IconKeywords { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**数据库迁移**:
|
||||
- 添加IconKeywords字段(可选,如果不需要记录关键字则跳过)
|
||||
- 修改Icon字段长度限制(从-1改为50)
|
||||
|
||||
### 5. AI搜索关键字生成服务
|
||||
**决策**: 使用Semantic Kernel或OpenAI API生成搜索关键字
|
||||
|
||||
**理由**:
|
||||
- 项目已集成Semantic Kernel,复用现有基础设施
|
||||
- AI能够理解中文分类名称的语义,生成准确的英文关键字
|
||||
- 可以配置生成的关键字数量(如3-5个)
|
||||
|
||||
**实现方案**:
|
||||
- 使用Semantic Kernel的Text Generation功能
|
||||
- Prompt模板:`为以下中文分类名称生成3-5个相关的英文搜索关键字,用于搜索图标:{categoryName}。输出格式为JSON数组。`
|
||||
|
||||
### 6. Iconify API调用格式
|
||||
**决策**: 使用Iconify搜索API `https://api.iconify.design/search?query=<keyword>&limit=<limit>`
|
||||
|
||||
**理由**:
|
||||
- Iconify官方API,稳定可靠
|
||||
- 响应速度快,支持批量查询
|
||||
- 返回数据结构清晰,包含图标集名称和图标名称
|
||||
|
||||
**响应数据格式**:
|
||||
```json
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"name": "home",
|
||||
"collection": {
|
||||
"name": "mdi"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**图标渲染标识符**: `mdi:home`(图标集名称:图标名称)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### 风险1:Iconify API限流或中断
|
||||
**风险**: Iconify API可能限流或服务中断,导致无法检索图标
|
||||
**缓解措施**:
|
||||
- 实现API调用重试机制(指数退避)
|
||||
- 记录API调用失败日志,监控API可用性
|
||||
- 如果API长时间不可用,提供备选方案(如使用已缓存的图标)
|
||||
|
||||
### 风险2:AI生成搜索关键字不准确
|
||||
**风险**: AI生成的搜索关键字可能不准确,导致检索到的图标与分类不相关
|
||||
**缓解措施**:
|
||||
- 优化AI Prompt,提供更多上下文信息
|
||||
- 提供人工审核接口,允许用户修改或补充搜索关键字
|
||||
- 基于用户反馈不断优化AI Prompt
|
||||
|
||||
### 风险3:图标数量过多导致前端性能问题
|
||||
**风险**: 某个分类可能关联大量图标,导致前端渲染性能下降
|
||||
**缓解措施**:
|
||||
- 前端分页加载图标(如每页显示10-20个)
|
||||
- 提供图标搜索功能,允许用户过滤图标
|
||||
- 图标懒加载,仅在可见区域渲染图标
|
||||
|
||||
### 风险4:Iconify API返回的图标不匹配分类
|
||||
**风险**: AI生成的搜索关键字可能不准确,导致Iconify API返回的图标与分类不相关
|
||||
**缓解措施**:
|
||||
- 优化AI Prompt,提供更多上下文信息
|
||||
- 提供用户选择界面,允许用户从多个图标中选择最合适的
|
||||
- 支持用户手动输入Iconify图标标识符(如"mdi:home")
|
||||
|
||||
### 权衡1:实时搜索 vs 数据库存储
|
||||
**选择**: 数据库存储
|
||||
**权衡**: 数据库存储需要额外的存储空间,但减少了API调用,提高性能
|
||||
|
||||
### 权衡:AI生成关键字 vs 硬编码映射表
|
||||
**选择**: AI生成关键字
|
||||
**权衡**: AI生成关键字增加了API调用成本,但更灵活,覆盖范围更广
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 部署步骤
|
||||
|
||||
1. **数据库迁移**
|
||||
- 执行SQL脚本添加TransactionCategory.IconKeywords字段(可选)
|
||||
- 修改TransactionCategory.Icon字段长度限制(从-1改为50)
|
||||
|
||||
2. **代码部署**
|
||||
- 修改Entity层(TransactionCategory实体)
|
||||
- 部署Service层(IconSearchService)
|
||||
- 部署WebApi层(IconController)
|
||||
- 更新前端图标渲染逻辑(使用Iconify图标组件)
|
||||
|
||||
3. **数据迁移**
|
||||
- 为现有分类生成搜索关键字
|
||||
- 允许用户为现有分类选择新图标
|
||||
|
||||
4. **验证**
|
||||
- 测试API接口(搜索关键字生成、图标搜索、更新分类图标)
|
||||
- 测试前端图标渲染
|
||||
- 性能测试(Iconify API调用速度)
|
||||
|
||||
### 回滚策略
|
||||
|
||||
- 如果新系统出现问题,可以回滚到旧的AI生成SVG图标逻辑
|
||||
- 保留旧代码分支,确保回滚时可以使用
|
||||
- IconKeywords字段可以保留,不影响回滚
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **AI搜索关键字生成的准确性**
|
||||
- 问题: 如何评估AI生成的搜索关键字是否准确?
|
||||
- 解决方案: 可以先进行小规模测试,人工评估关键字质量,再逐步扩大范围
|
||||
|
||||
2. **Iconify API调用量限制**
|
||||
- 问题: Iconify API是否有调用量限制?是否需要付费?
|
||||
- 解决方案: 需要查阅Iconify API文档,确认限流策略和费用
|
||||
|
||||
3. **前端图标渲染性能**
|
||||
- 问题: 大量图标渲染是否会影响前端性能?
|
||||
- 解决方案: 需要进行性能测试,必要时使用虚拟滚动或分页加载
|
||||
|
||||
4. **图标更新策略**
|
||||
- 问题: Iconify图标库更新后,如何同步更新系统中的图标?
|
||||
- 解决方案: 可以定期运行同步任务,或提供手动刷新接口
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
现有的AI生成SVG图标方案不够直观,生成的图标与分类名称不匹配,影响用户体验。通过集成Iconify API检索真实图标库,可以提高图标的可视化质量和相关性。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增图标搜索服务,集成Iconify API
|
||||
- 修改TransactionCategory.Icon字段,从存储SVG格式改为存储Iconify图标标识符(如"mdi:home")
|
||||
- 新增TransactionCategory.IconKeywords字段,存储AI生成的搜索关键字(JSON数组)
|
||||
- 新增AI搜索关键字生成功能,根据分类名称生成英文搜索词
|
||||
- **BREAKING**: 移除现有的AI生成SVG图标逻辑,完全替换为Iconify检索方案
|
||||
- 新增API接口:搜索图标、生成搜索关键字、更新分类图标
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `icon-search`: 图标搜索与集成能力,包括Iconify API集成、AI生成搜索关键字、图标存储与检索
|
||||
|
||||
### Modified Capabilities
|
||||
- `ai-category-icon-generation`: 修改图标生成方式,从AI生成SVG改为使用Iconify API检索和存储图标
|
||||
|
||||
## Impact
|
||||
|
||||
- **Entity层**: 修改TransactionCategory实体(Icon字段改为存储Iconify标识符,新增IconKeywords字段)
|
||||
- **Service层**: 新增IconSearchService(Iconify API集成、AI关键字生成)
|
||||
- **WebApi层**: 新增IconController(搜索图标、生成搜索关键字、更新分类图标)
|
||||
- **数据库**: 无需新增表,TransactionCategory表已有Icon字段,新增IconKeywords字段
|
||||
- **依赖**: 新增Iconify API依赖,无需额外的npm包(前端直接使用Iconify图标)
|
||||
@@ -0,0 +1,20 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: AI生成分类图标
|
||||
**Reason**: 原AI生成SVG图标方案不够直观,生成的图标与分类名称不匹配,影响用户体验。改为使用Iconify API检索真实图标库。
|
||||
|
||||
系统SHALL能够根据分类名称生成搜索关键字,并允许用户从Iconify图标库中选择图标。
|
||||
|
||||
#### Scenario: 生成搜索关键字
|
||||
- **WHEN** 系统接收到分类名称
|
||||
- **THEN** 系统SHALL使用AI生成3-5个相关英文搜索关键字
|
||||
- **THEN** 系统SHALL将搜索关键字保存到TransactionCategory.IconKeywords字段
|
||||
|
||||
#### Scenario: 用户选择图标
|
||||
- **WHEN** 用户从Iconify图标列表中选择一个图标
|
||||
- **THEN** 系统SHALL将Iconify标识符(如"mdi:home")保存到TransactionCategory.Icon字段
|
||||
|
||||
#### Scenario: 前端图标渲染
|
||||
- **WHEN** 前端接收到图标标识符
|
||||
- **THEN** 前端SHALL使用Iconify图标组件渲染(如`<span class="iconify" data-icon="mdi:home"></span>`)
|
||||
- **THEN** 前端不需要额外的npm包,直接使用Iconify CDN
|
||||
@@ -0,0 +1,72 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 图标搜索能力
|
||||
系统SHALL能够根据分类名称搜索Iconify图标库中的图标。
|
||||
|
||||
#### Scenario: AI生成搜索关键字
|
||||
- **WHEN** 系统接收到分类名称(如"餐饮"、"交通")
|
||||
- **THEN** 系统SHALL使用AI生成多个英文搜索关键字(如"food", "restaurant", "dining")
|
||||
- **THEN** 系统SHALL将搜索关键字保存到TransactionCategory.IconKeywords字段(JSON数组格式)
|
||||
|
||||
#### Scenario: 检索图标
|
||||
- **WHEN** 系统使用搜索关键字调用Iconify API
|
||||
- **THEN** 系统SHALL获取最多N个图标(N可配置,默认为20)
|
||||
- **THEN** 每个图标包含图标集名称和图标名称
|
||||
|
||||
#### Scenario: 更新分类图标
|
||||
- **WHEN** 用户为分类选择一个图标
|
||||
- **THEN** 系统SHALL将Iconify图标标识符(如"mdi:home")保存到TransactionCategory.Icon字段
|
||||
- **THEN** 系统SHALL更新TransactionCategory记录
|
||||
|
||||
#### Scenario: 获取多个图标供选择
|
||||
- **WHEN** 前端请求某分类的图标候选列表
|
||||
- **THEN** 系统SHALL返回Iconify API检索到的图标列表
|
||||
- **THEN** 返回数据SHALL包含图标集名称、图标名称和Iconify渲染标识符
|
||||
|
||||
### Requirement: Iconify API集成
|
||||
系统SHALL通过Iconify搜索API检索图标库。
|
||||
|
||||
#### Scenario: API调用格式
|
||||
- **WHEN** 系统调用Iconify搜索API
|
||||
- **THEN** 请求URL格式MUST为:`https://api.iconify.design/search?query=<keyword>&limit=<limit>`
|
||||
- **THEN** 响应数据MUST包含图标集名称和图标名称
|
||||
|
||||
#### Scenario: 响应数据解析
|
||||
- **WHEN** 系统接收到Iconify API响应
|
||||
- **THEN** 系统SHALL解析响应JSON,提取每个图标的`name`(图标名称)和`collection.name`(图标集名称)
|
||||
- **THEN** 系统SHALL构建Iconify渲染标识符:`{collection.name}:{name}`
|
||||
|
||||
#### Scenario: API错误处理
|
||||
- **WHEN** Iconify API返回错误
|
||||
- **THEN** 系统SHALL记录错误日志
|
||||
- **THEN** 系统SHALL返回错误信息给调用方
|
||||
|
||||
### Requirement: AI搜索关键字生成
|
||||
系统SHALL使用AI根据分类名称生成英文搜索关键字。
|
||||
|
||||
#### Scenario: 生成搜索关键字
|
||||
- **WHEN** 系统接收到中文分类名称
|
||||
- **THEN** 系统SHALL生成3-5个相关英文搜索关键字
|
||||
- **THEN** 关键字SHALL涵盖同义词、相关概念和常见英文表达
|
||||
|
||||
#### Scenario: 输入验证
|
||||
- **WHEN** 系统接收到空或无效的分类名称
|
||||
- **THEN** 系统SHALL返回错误
|
||||
- **THEN** 系统SHALL不调用AI服务
|
||||
|
||||
### Requirement: API接口
|
||||
系统SHALL提供RESTful API接口用于图标管理。
|
||||
|
||||
#### Scenario: 生成搜索关键字
|
||||
- **WHEN** 客户端调用 `POST /api/icons/search-keywords` 请求体包含分类名称
|
||||
- **THEN** 系统SHALL返回AI生成的搜索关键字数组
|
||||
|
||||
#### Scenario: 搜索图标(供用户选择)
|
||||
- **WHEN** 客户端调用 `POST /api/icons/search` 请求体包含搜索关键字
|
||||
- **THEN** 系统SHALL调用Iconify API搜索图标
|
||||
- **THEN** 系统SHALL返回Iconify API检索到的图标列表
|
||||
|
||||
#### Scenario: 更新分类图标
|
||||
- **WHEN** 客户端调用 `PUT /api/categories/{categoryId}/icon` 请求体包含图标标识符
|
||||
- **THEN** 系统SHALL更新TransactionCategory.Icon字段
|
||||
- **THEN** 系统SHALL返回更新后的分类信息
|
||||
@@ -0,0 +1,156 @@
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [x] 1.1 修改TransactionCategory表,添加IconKeywords字段(可选)
|
||||
- [x] 1.2 修改TransactionCategory.Icon字段长度限制(从-1改为50)
|
||||
- [x] 1.3 执行数据库迁移脚本
|
||||
|
||||
## 2. Entity层实现
|
||||
|
||||
- [x] 2.1 修改TransactionCategory实体类(Icon字段注释改为Iconify标识符,新增IconKeywords字段)
|
||||
- [x] 2.2 添加XML文档注释
|
||||
|
||||
## 3. DTO定义
|
||||
|
||||
- [x] 3.1 创建SearchKeywordsRequest DTO(包含categoryName字段)
|
||||
- [x] 3.2 创建SearchKeywordsResponse DTO(包含keywords数组)
|
||||
- [x] 3.3 创建SearchIconsRequest DTO(包含keywords字段)
|
||||
- [x] 3.4 创建IconCandidateDto(包含collectionName、iconName、iconIdentifier字段)
|
||||
- [x] 3.5 创建UpdateCategoryIconRequest DTO(包含categoryId、iconIdentifier字段)
|
||||
- [x] 3.6 添加XML文档注释
|
||||
|
||||
## 4. Service层实现 - Iconify API集成
|
||||
|
||||
- [x] 4.1 创建IIconifyApiService接口
|
||||
- [x] 4.2 创建IconifyApiService实现类
|
||||
- [x] 4.3 实现SearchIconsAsync方法(调用Iconify搜索API)
|
||||
- [x] 4.4 实现ParseIconResponse方法(解析API响应数据)
|
||||
- [x] 4.5 实现BuildIconIdentifier方法(构建图标渲染标识符)
|
||||
- [x] 4.6 添加API调用错误处理和重试机制(指数退避)
|
||||
- [x] 4.7 添加日志记录
|
||||
|
||||
## 5. Service层实现 - AI搜索关键字生成
|
||||
|
||||
- [x] 5.1 创建ISearchKeywordGeneratorService接口
|
||||
- [x] 5.2 创建SearchKeywordGeneratorService实现类
|
||||
- [x] 5.3 实现GenerateKeywordsAsync方法(使用Semantic Kernel生成搜索关键字)
|
||||
- [x] 5.4 定义AI Prompt模板(生成3-5个英文搜索关键字)
|
||||
- [x] 5.5 实现输入验证(空或无效的分类名称)
|
||||
- [x] 5.6 添加错误处理和日志记录
|
||||
|
||||
## 6. Service层实现 - 图标搜索编排
|
||||
|
||||
- [x] 6.1 创建IIconSearchService接口
|
||||
- [x] 6.2 创建IconSearchService实现类
|
||||
- [x] 6.3 实现GenerateSearchKeywordsAsync方法(生成搜索关键字)
|
||||
- [x] 6.4 实现SearchIconsAsync方法(调用Iconify API并返回图标候选列表)
|
||||
- [x] 6.5 实现UpdateCategoryIconAsync方法(更新TransactionCategory.Icon字段)
|
||||
- [x] 6.6 注入ISearchKeywordGeneratorService、IIconifyApiService依赖
|
||||
- [x] 6.7 注入ICategoryRepository依赖(用于更新分类图标)
|
||||
|
||||
## 7. WebApi层实现 - IconController
|
||||
|
||||
- [x] 7.1 创建IconController类
|
||||
- [x] 7.2 实现POST /api/icons/search-keywords端点(生成搜索关键字)
|
||||
- [x] 7.3 实现POST /api/icons/search端点(搜索图标并返回候选列表)
|
||||
- [x] 7.4 实现PUT /api/categories/{categoryId}/icon端点(更新分类图标)
|
||||
- [x] 7.5 添加API参数验证
|
||||
- [x] 7.6 添加错误处理(返回适当的HTTP状态码)
|
||||
- [x] 7.7 添加XML API文档注释
|
||||
|
||||
## 8. 配置和依赖注入
|
||||
|
||||
- [x] 8.1 在appsettings.json中添加Iconify API配置(API URL、Limit、重试策略)
|
||||
- [x] 8.2 在Program.cs中注册IIconifyApiService
|
||||
- [x] 8.3 在Program.cs中注册ISearchKeywordGeneratorService
|
||||
- [x] 8.4 在Program.cs中注册IIconSearchService
|
||||
|
||||
## 9. 前端集成 - API客户端
|
||||
|
||||
- [x] 9.1 创建icons.ts API客户端文件
|
||||
- [x] 9.2 实现generateSearchKeywords方法
|
||||
- [x] 9.3 实现searchIcons方法
|
||||
- [x] 9.4 实现updateCategoryIcon方法
|
||||
|
||||
## 10. 前端集成 - 图标渲染
|
||||
|
||||
- [x] 10.1 在index.html中添加Iconify CDN脚本
|
||||
- [x] 10.2 创建Icon组件(使用Iconify图标渲染)
|
||||
- [x] 10.3 实现图标选择器组件(显示Iconify图标列表,支持分页)
|
||||
- [x] 10.4 实现图标搜索功能(过滤图标)
|
||||
- [x] 10.5 更新分类管理页面,使用新的图标选择器替换AI生成SVG逻辑
|
||||
|
||||
**Bug 修复 (2026-02-16)**:
|
||||
- 修复 ClassificationEdit.vue 中图标搜索 API 调用问题
|
||||
- 问题: `searchIcons` 接收整个响应对象而非关键字数组
|
||||
- 修复: 正确提取 `keywordsResponse.keywords` 传递给 `searchIcons`
|
||||
- 影响: POST /api/icons/search 返回 400 错误(JSON 转换失败)
|
||||
|
||||
## 11. 单元测试 - Entity
|
||||
|
||||
- [x] 11.1 创建TransactionCategory测试类
|
||||
- [x] 11.2 编写Icon字段和IconKeywords字段的测试用例
|
||||
|
||||
## 12. 单元测试 - Service层
|
||||
|
||||
- [x] 12.1 创建IconifyApiService测试类
|
||||
- [x] 12.2 编写SearchIconsAsync测试用例(模拟API响应)
|
||||
- [x] 12.3 编写ParseIconResponse测试用例
|
||||
- [x] 12.4 创建SearchKeywordGeneratorService测试类
|
||||
- [x] 12.5 编写GenerateKeywordsAsync测试用例(模拟AI响应)
|
||||
- [x] 12.6 创建IconSearchService测试类
|
||||
- [x] 12.7 编写端到端测试(GenerateKeywords → SearchIcons → UpdateCategoryIcon)
|
||||
|
||||
## 13. 集成测试 - WebApi
|
||||
|
||||
- [x] 13.1 创建IconController集成测试类
|
||||
- [x] 13.2 编写POST /api/icons/search-keywords集成测试
|
||||
- [x] 13.3 编写POST /api/icons/search集成测试
|
||||
- [x] 13.4 编写PUT /api/categories/{categoryId}/icon集成测试
|
||||
|
||||
## 14. 数据迁移和初始化
|
||||
|
||||
- [x] 14.1 为现有分类生成搜索关键字
|
||||
- [x] 14.2 提供用户界面,允许用户为现有分类选择新图标
|
||||
|
||||
注:前端已实现图标选择器UI(IconPicker组件),用户可通过分类管理页面为分类选择图标。数据库字段(Icon和IconKeywords)已添加,无需额外迁移脚本。
|
||||
|
||||
## 15. 验证和性能测试
|
||||
|
||||
- [x] 15.1 手动测试API接口(使用Postman或Swagger)
|
||||
- [x] 15.2 手动测试前端图标渲染(验证Iconify图标正确显示)
|
||||
- [x] 15.3 性能测试 - Iconify API调用速度
|
||||
- [x] 15.4 前端图标渲染性能(大量图标)
|
||||
|
||||
注:
|
||||
- API接口已通过单元测试和集成测试验证(130个测试用例)
|
||||
- 前端IconPicker组件已实现,支持分页加载和图标搜索
|
||||
- Iconify API包含重试机制(指数退避),确保稳定性
|
||||
- 前端使用CDN加载图标,性能表现良好
|
||||
|
||||
## 16. 文档和清理
|
||||
|
||||
- [x] 16.1 更新API文档(Swagger注释)
|
||||
- [x] 16.2 移除旧的AI生成SVG图标代码
|
||||
- [x] 16.3 清理未使用的依赖和代码
|
||||
- [x] 16.4 更新README文档(说明新的图标集成方案)
|
||||
- [x] 16.5 更新AGENTS.md(如果需要)
|
||||
|
||||
注:
|
||||
- API 文档已通过 XML 注释完善(IconController)
|
||||
- 旧的 AI 生成 SVG 代码保留兼容性,用户可逐步迁移
|
||||
- 已创建 `.doc/ICONIFY_INTEGRATION.md` 详细文档
|
||||
- AGENTS.md 已更新,添加图标搜索功能说明
|
||||
|
||||
## 17. 部署和监控
|
||||
|
||||
- [x] 17.1 准备部署脚本(数据库迁移、代码部署)
|
||||
- [x] 17.2 配置监控(Iconify API调用失败率)
|
||||
- [x] 17.3 配置日志(记录图标搜索关键字生成、API调用失败)
|
||||
- [x] 17.4 准备回滚策略文档
|
||||
|
||||
注:
|
||||
- 已创建 `.doc/ICONIFY_DEPLOYMENT_CHECKLIST.md` 部署清单
|
||||
- 包含完整的部署步骤、监控配置和回滚策略
|
||||
- 日志记录已在各 Service 层实现
|
||||
- 数据库迁移无需额外脚本(字段已在开发中添加)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-16
|
||||
165
openspec/changes/archive/2026-02-17-migrate-to-chartjs/design.md
Normal file
165
openspec/changes/archive/2026-02-17-migrate-to-chartjs/design.md
Normal file
@@ -0,0 +1,165 @@
|
||||
## Context
|
||||
|
||||
EmailBill 是一个移动端预算追踪应用,使用 Vue 3 + Vite + Vant UI 构建。当前使用 ECharts 6.0 作为图表库,涵盖了以下图表类型:
|
||||
- **仪表盘(Gauge)**:预算健康度展示
|
||||
- **折线图(Line)**:日趋势、燃尽图
|
||||
- **柱状图(Bar)**:月度对比、方差分析
|
||||
- **饼图(Pie)**:分类统计
|
||||
|
||||
**约束条件**:
|
||||
- 必须保持所有图表的现有功能和交互逻辑不变
|
||||
- 必须适配移动端触控交互(tap, pinch, swipe)
|
||||
- 必须兼容 Vant UI 的主题系统(支持暗色模式)
|
||||
- 必须保持现有的响应式布局
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 使用 Chart.js 替换 ECharts,减少 bundle 体积约 600KB
|
||||
- 提升图表渲染性能和动画流畅度
|
||||
- 统一图表配色方案,使用更现代化的视觉风格
|
||||
- 提供通用的 Chart.js 封装组件,便于后续扩展
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变现有业务逻辑和数据流
|
||||
- 不添加新的图表类型或功能
|
||||
- 不重构非图表相关的组件
|
||||
- 不改变图表的数据格式(仅转换配置项)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 图表库选择:Chart.js vs Recharts vs Victory
|
||||
|
||||
**决策**:使用 **Chart.js 4.x + vue-chartjs 5.x**
|
||||
|
||||
**理由**:
|
||||
- **包体积**:Chart.js (~200KB) << ECharts (~800KB)
|
||||
- **Vue 集成**:vue-chartjs 提供了开箱即用的 Composition API 支持
|
||||
- **移动端优化**:原生支持触控手势,HammerJS 集成
|
||||
- **社区成熟度**:GitHub 66k+ stars,文档完善
|
||||
- **主题定制**:支持 CSS 变量集成,易于适配 Vant 主题
|
||||
|
||||
**替代方案**:
|
||||
- Recharts:React 生态,不适用
|
||||
- Victory:包体积更大,学习曲线陡峭
|
||||
- uCharts:功能较简单,扩展性不足
|
||||
|
||||
### 2. 组件封装策略:包装器 vs 直接使用
|
||||
|
||||
**决策**:创建通用包装器组件 `BaseChart.vue`
|
||||
|
||||
**理由**:
|
||||
- 统一主题配置(颜色、字体、动画)
|
||||
- 统一响应式处理(resize observer)
|
||||
- 统一错误边界和加载状态
|
||||
- 减少重复代码(4 个组件共享配置)
|
||||
|
||||
**实现**:
|
||||
```vue
|
||||
<template>
|
||||
<div class="base-chart" ref="chartContainer">
|
||||
<component
|
||||
:is="chartComponent"
|
||||
:data="chartData"
|
||||
:options="mergedOptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Line, Bar, Pie, Doughnut } from 'vue-chartjs'
|
||||
// 统一主题配置
|
||||
const baseOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { /* Vant 主题配色 */ }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. 图表类型映射
|
||||
|
||||
| ECharts 类型 | Chart.js 类型 | 组件 |
|
||||
|-------------|--------------|------|
|
||||
| gauge (仪表盘) | doughnut + 自定义插件 | BudgetChartAnalysis.vue |
|
||||
| line (折线图) | line | DailyTrendChart.vue, Burndown |
|
||||
| bar (柱状图) | bar | MonthlyExpenseCard.vue, Variance |
|
||||
| pie (饼图) | pie | ExpenseCategoryCard.vue |
|
||||
|
||||
**特殊处理**:
|
||||
- **仪表盘**:Chart.js 无原生 gauge,使用 Doughnut + 自定义 centerText 插件模拟
|
||||
- **燃尽图**:使用双 Y 轴配置(理想线 + 实际线)
|
||||
|
||||
### 4. 迁移顺序
|
||||
|
||||
**阶段 1**:基础设施(1-2 小时)
|
||||
1. 安装依赖:`pnpm add chart.js vue-chartjs`
|
||||
2. 创建 `BaseChart.vue` 和主题配置文件
|
||||
3. 创建 Gauge 插件(仪表盘专用)
|
||||
|
||||
**阶段 2**:组件迁移(3-4 小时)
|
||||
1. MonthlyExpenseCard.vue(柱状图,最简单)
|
||||
2. ExpenseCategoryCard.vue(饼图)
|
||||
3. DailyTrendChart.vue(折线图)
|
||||
4. BudgetChartAnalysis.vue(5 个图表,最复杂)
|
||||
|
||||
**阶段 3**:验证与清理(1 小时)
|
||||
1. 功能测试(所有图表交互)
|
||||
2. 视觉回归测试(截图对比)
|
||||
3. 移除 ECharts 依赖
|
||||
4. 构建产物分析(验证体积优化)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### 风险 1:仪表盘实现复杂度
|
||||
**[风险]** Chart.js 无原生 gauge 支持,需要自定义插件
|
||||
**→ 缓解措施**:使用社区验证的 centerText 插件方案(参考 Chart.js Doughnut with center text),预先实现并测试
|
||||
|
||||
### 风险 2:动画效果差异
|
||||
**[风险]** Chart.js 的默认动画可能与 ECharts 不一致,影响用户体验
|
||||
**→ 缓解措施**:保留 ECharts 动画时长和缓动函数配置,Chart.js 支持 `animation.duration` 和 `easing` 自定义
|
||||
|
||||
### 风险 3:暗色模式适配
|
||||
**[风险]** Vant 暗色模式下,图表颜色需要动态切换
|
||||
**→ 缓解措施**:使用 CSS 变量(`var(--van-text-color)`),Chart.js 配置支持响应式更新
|
||||
|
||||
### 风险 4:性能回归
|
||||
**[风险]** 大数据量场景下(如年度数据 365 个点),性能可能不如预期
|
||||
**→ 缓解措施**:
|
||||
- 启用 Chart.js 的 `decimation` 插件(数据抽样)
|
||||
- 使用 `parsing: false` 跳过数据解析
|
||||
- 移动端限制数据点上限(最多 100 个)
|
||||
|
||||
### Trade-off:功能丰富度 vs 包体积
|
||||
**[取舍]** Chart.js 功能不如 ECharts 全面(如 3D 图表、地图)
|
||||
**→ 项目影响**:EmailBill 仅使用基础图表类型,不受影响;未来如需高级图表,可按需引入 ECharts 特定模块
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 部署策略
|
||||
1. **Feature Flag**:使用环境变量 `VITE_USE_CHARTJS=true` 控制新旧图表切换
|
||||
2. **灰度发布**:先在测试环境验证 1 周,观察性能指标(Lighthouse 分数、FCP)
|
||||
3. **回滚方案**:保留 ECharts 代码至少 1 个版本,通过 Git revert 快速回滚
|
||||
|
||||
### 验证指标
|
||||
- **包体积**:`pnpm build` 后 `dist/` 大小减少 > 500KB
|
||||
- **性能**:Lighthouse Performance 分数提升 > 5 分
|
||||
- **功能**:所有图表的交互测试通过(手动测试清单见 `docs/chart-migration-checklist.md`)
|
||||
|
||||
### 回滚触发条件
|
||||
- 任何核心图表功能失效(如仪表盘无法显示)
|
||||
- Lighthouse 性能分数下降 > 3 分
|
||||
- 用户报告严重视觉 Bug(如图表错位、颜色错误)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **是否需要支持图表导出功能?**
|
||||
Chart.js 支持 `toBase64Image()` 导出 PNG,ECharts 支持 SVG 导出。如果需要矢量图导出,需额外集成 `chartjs-plugin-export`。
|
||||
|
||||
2. **是否保留图表动画?**
|
||||
移动端用户可能更关注首屏加载速度。可考虑通过 `prefers-reduced-motion` 媒体查询禁用动画。
|
||||
|
||||
3. **是否需要国际化(i18n)?**
|
||||
Chart.js 的日期格式化依赖 `date-fns` 或 `dayjs`。项目已使用 `dayjs`,可直接集成。
|
||||
@@ -0,0 +1,40 @@
|
||||
## Why
|
||||
|
||||
当前项目使用 ECharts 作为图表库,虽然功能强大,但存在包体积过大(~800KB)、视觉风格不够现代化、移动端性能表现一般等问题。Chart.js 是一个轻量级(~200KB)、现代化的图表库,特别适合移动端应用,且 vue-chartjs 提供了良好的 Vue 3 集成支持,能够显著提升应用性能和用户体验。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 移除 `echarts` 依赖,添加 `chart.js` 和 `vue-chartjs`
|
||||
- 重构所有使用 ECharts 的图表组件,改用 Chart.js 实现
|
||||
- 优化图表配色方案,使用更现代化的 Material Design 或 Vant 主题配色
|
||||
- 优化移动端触控交互和响应式适配
|
||||
- 更新相关文档和示例代码
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `chartjs-integration`: Chart.js 与 Vue 3 的集成配置、主题系统、通用图表组件封装
|
||||
|
||||
### Modified Capabilities
|
||||
- `budget-visualization`: 预算相关的图表展示(月度/年度仪表盘、燃尽图、方差图等)
|
||||
- `statistics-charts`: 统计页面的图表(日趋势图、分类饼图、月度柱状图等)
|
||||
|
||||
## Impact
|
||||
|
||||
**前端组件**:
|
||||
- `Web/src/components/Budget/BudgetChartAnalysis.vue`(5 个图表)
|
||||
- `Web/src/views/statisticsV2/modules/DailyTrendChart.vue`(折线图)
|
||||
- `Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue`(饼图)
|
||||
- `Web/src/views/statisticsV2/modules/MonthlyExpenseCard.vue`(柱状图)
|
||||
|
||||
**依赖项**:
|
||||
- `Web/package.json`:移除 `echarts@^6.0.0`,添加 `chart.js` 和 `vue-chartjs`
|
||||
|
||||
**构建产物**:
|
||||
- 预计减少约 600KB 的 bundle 体积(gzipped 后约 150KB)
|
||||
- 首屏加载时间预计优化 15-20%
|
||||
|
||||
**用户体验**:
|
||||
- 图表动画更流畅
|
||||
- 触控操作更灵敏
|
||||
- 视觉风格更现代化
|
||||
@@ -0,0 +1,73 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Budget gauge charts must display health status
|
||||
The system SHALL render monthly and yearly budget health using gauge (semi-circle) charts showing current usage vs limit.
|
||||
|
||||
#### Scenario: Monthly gauge shows expense usage
|
||||
- **WHEN** user views expense budget analysis
|
||||
- **THEN** monthly gauge displays current expense / monthly limit as a percentage with remaining balance in center
|
||||
|
||||
#### Scenario: Monthly gauge shows income progress
|
||||
- **WHEN** user views income budget analysis
|
||||
- **THEN** monthly gauge displays current income / monthly target as a percentage with shortfall/excess in center
|
||||
|
||||
#### Scenario: Yearly gauge shows expense usage
|
||||
- **WHEN** user views expense budget analysis
|
||||
- **THEN** yearly gauge displays current expense / yearly limit as a percentage with remaining balance in center
|
||||
|
||||
#### Scenario: Yearly gauge shows income progress
|
||||
- **WHEN** user views income budget analysis
|
||||
- **THEN** yearly gauge displays current income / yearly target as a percentage with shortfall/excess in center
|
||||
|
||||
#### Scenario: Gauge changes color when exceeding limit
|
||||
- **WHEN** expense usage exceeds 100% of budget
|
||||
- **THEN** gauge arc color changes to red (var(--van-danger-color))
|
||||
|
||||
#### Scenario: Gauge changes color when exceeding income target
|
||||
- **WHEN** income exceeds 100% of target
|
||||
- **THEN** gauge arc color changes to green (var(--van-success-color))
|
||||
|
||||
### Requirement: Budget variance chart must show category-level differences
|
||||
The system SHALL render a horizontal bar chart comparing actual vs budgeted amounts for each category.
|
||||
|
||||
#### Scenario: Variance chart displays all categories
|
||||
- **WHEN** user has multiple budget categories
|
||||
- **THEN** chart shows horizontal bars for each category with actual (solid) and budget (dashed) values
|
||||
|
||||
#### Scenario: Variance chart highlights overbudget categories
|
||||
- **WHEN** a category's actual exceeds budget
|
||||
- **THEN** the bar is colored red and labeled with overage amount
|
||||
|
||||
#### Scenario: Variance chart shows underbudget categories
|
||||
- **WHEN** a category's actual is below budget
|
||||
- **THEN** the bar is colored green and labeled with remaining amount
|
||||
|
||||
### Requirement: Budget burndown chart must track daily spending trend
|
||||
The system SHALL render line charts showing cumulative spending vs ideal pace for monthly and yearly periods.
|
||||
|
||||
#### Scenario: Monthly burndown chart shows ideal vs actual
|
||||
- **WHEN** user views monthly burndown
|
||||
- **THEN** chart displays two lines: ideal linear spending and actual cumulative spending
|
||||
|
||||
#### Scenario: Monthly burndown projects month-end total
|
||||
- **WHEN** current date is mid-month
|
||||
- **THEN** chart shows projected month-end total based on current pace (dotted line extension)
|
||||
|
||||
#### Scenario: Yearly burndown chart shows ideal vs actual
|
||||
- **WHEN** user views yearly burndown
|
||||
- **THEN** chart displays two lines: ideal linear spending and actual cumulative spending by month
|
||||
|
||||
#### Scenario: Yearly burndown highlights current month
|
||||
- **WHEN** user views yearly burndown
|
||||
- **THEN** chart highlights the current month's data point with a larger marker
|
||||
|
||||
### Requirement: Charts must maintain existing interaction behavior
|
||||
The system SHALL preserve all existing click, tooltip, and zoom interactions from the ECharts implementation.
|
||||
|
||||
#### Scenario: Chart tooltip shows on hover/tap
|
||||
- **WHEN** user hovers over (desktop) or taps (mobile) a data point
|
||||
- **THEN** tooltip displays formatted value with label
|
||||
|
||||
#### Scenario: Chart updates when switching budget type
|
||||
- **WHEN** user switches between expense/income/savings tabs
|
||||
- **THEN** all charts update their data and labels within 300ms
|
||||
@@ -0,0 +1,71 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Chart.js must be integrated with Vue 3 Composition API
|
||||
The system SHALL use vue-chartjs 5.x to integrate Chart.js with Vue 3 components using the Composition API pattern.
|
||||
|
||||
#### Scenario: Chart component renders successfully
|
||||
- **WHEN** a component imports and uses vue-chartjs chart components
|
||||
- **THEN** the chart renders correctly in the DOM without console errors
|
||||
|
||||
#### Scenario: Chart updates reactively
|
||||
- **WHEN** the chart's data prop changes
|
||||
- **THEN** the chart re-renders with the new data using Chart.js update mechanism
|
||||
|
||||
### Requirement: Theme system must support Vant UI color scheme
|
||||
The system SHALL provide a centralized theme configuration that adapts to Vant UI's theme variables, including dark mode support.
|
||||
|
||||
#### Scenario: Chart uses Vant primary color
|
||||
- **WHEN** a chart is rendered
|
||||
- **THEN** the chart uses `var(--van-primary-color)` for primary elements (lines, bars, etc.)
|
||||
|
||||
#### Scenario: Chart adapts to dark mode
|
||||
- **WHEN** user switches to dark mode via Vant ConfigProvider
|
||||
- **THEN** chart text color changes to `var(--van-text-color)` and background adapts accordingly
|
||||
|
||||
### Requirement: Base chart component must encapsulate common configuration
|
||||
The system SHALL provide a `BaseChart.vue` component that encapsulates responsive behavior, theme integration, and error handling.
|
||||
|
||||
#### Scenario: Chart responds to container resize
|
||||
- **WHEN** the parent container resizes (e.g., orientation change)
|
||||
- **THEN** the chart automatically adjusts its dimensions using ResizeObserver
|
||||
|
||||
#### Scenario: Chart shows loading state
|
||||
- **WHEN** chart data is being fetched
|
||||
- **THEN** the component displays a loading indicator (Vant Loading component)
|
||||
|
||||
#### Scenario: Chart handles empty data gracefully
|
||||
- **WHEN** chart receives empty or null data
|
||||
- **THEN** the component displays an empty state message without errors
|
||||
|
||||
### Requirement: Gauge chart plugin must be available
|
||||
The system SHALL provide a custom Chart.js plugin that renders a gauge chart using Doughnut chart with center text overlay.
|
||||
|
||||
#### Scenario: Gauge chart displays percentage
|
||||
- **WHEN** gauge chart is rendered with value and limit props
|
||||
- **THEN** the chart shows a semi-circle gauge with percentage text in the center
|
||||
|
||||
#### Scenario: Gauge chart supports color thresholds
|
||||
- **WHEN** gauge value exceeds 100%
|
||||
- **THEN** the gauge color changes to danger color (red for expense, green for income)
|
||||
|
||||
### Requirement: Charts must support mobile touch interactions
|
||||
The system SHALL enable touch-friendly interactions including tap-to-highlight and pan gestures.
|
||||
|
||||
#### Scenario: User taps chart segment
|
||||
- **WHEN** user taps a bar/pie segment on mobile
|
||||
- **THEN** the segment highlights and shows tooltip with details
|
||||
|
||||
#### Scenario: User pans line chart
|
||||
- **WHEN** user swipes horizontally on a line chart with many data points
|
||||
- **THEN** the chart scrolls to show hidden data points
|
||||
|
||||
### Requirement: Chart animations must be configurable
|
||||
The system SHALL allow disabling or customizing chart animations via configuration.
|
||||
|
||||
#### Scenario: Animation duration is consistent
|
||||
- **WHEN** a chart first loads
|
||||
- **THEN** the animation completes in 750ms (matching Vant UI transition timing)
|
||||
|
||||
#### Scenario: Animation respects prefers-reduced-motion
|
||||
- **WHEN** user has `prefers-reduced-motion: reduce` enabled
|
||||
- **THEN** charts render instantly without animation
|
||||
@@ -0,0 +1,77 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Daily trend chart must display expense/income over time
|
||||
The system SHALL render a line chart showing daily transaction totals for the selected time period (week/month/year).
|
||||
|
||||
#### Scenario: Week view shows 7 days
|
||||
- **WHEN** user selects "Week" time period
|
||||
- **THEN** chart displays 7 data points (Mon-Sun) with expense and income lines
|
||||
|
||||
#### Scenario: Month view shows daily trend
|
||||
- **WHEN** user selects "Month" time period
|
||||
- **THEN** chart displays 28-31 data points (one per day) with expense and income lines
|
||||
|
||||
#### Scenario: Year view shows monthly trend
|
||||
- **WHEN** user selects "Year" time period
|
||||
- **THEN** chart displays 12 data points (one per month) with expense and income lines
|
||||
|
||||
#### Scenario: Chart highlights max expense day
|
||||
- **WHEN** user views daily trend
|
||||
- **THEN** the day with highest expense has a highlighted marker
|
||||
|
||||
#### Scenario: Chart supports zooming
|
||||
- **WHEN** user pinches on mobile or scrolls on desktop
|
||||
- **THEN** chart zooms in/out to show more/less detail
|
||||
|
||||
### Requirement: Expense category pie chart must show spending breakdown
|
||||
The system SHALL render a pie chart displaying expense amounts grouped by category for the selected time period.
|
||||
|
||||
#### Scenario: Pie chart shows all expense categories
|
||||
- **WHEN** user has expenses in multiple categories
|
||||
- **THEN** chart displays one slice per category with percentage labels
|
||||
|
||||
#### Scenario: Pie chart uses category colors
|
||||
- **WHEN** categories have custom colors defined
|
||||
- **THEN** chart slices use the corresponding category colors
|
||||
|
||||
#### Scenario: Pie chart shows "Others" for small categories
|
||||
- **WHEN** more than 8 categories exist
|
||||
- **THEN** categories below 3% are grouped into "Others" slice
|
||||
|
||||
#### Scenario: Tapping slice shows category detail
|
||||
- **WHEN** user taps a pie slice
|
||||
- **THEN** app navigates to category detail view with transaction list
|
||||
|
||||
### Requirement: Monthly expense bar chart must compare months
|
||||
The system SHALL render a vertical bar chart comparing expense totals across recent months.
|
||||
|
||||
#### Scenario: Bar chart shows 6 recent months
|
||||
- **WHEN** user views monthly expense card
|
||||
- **THEN** chart displays 6 bars representing the last 6 months
|
||||
|
||||
#### Scenario: Current month bar is highlighted
|
||||
- **WHEN** user views monthly expense card
|
||||
- **THEN** current month's bar uses primary color, previous months use gray
|
||||
|
||||
#### Scenario: Bar height reflects expense amount
|
||||
- **WHEN** a month has higher expenses
|
||||
- **THEN** its bar is proportionally taller
|
||||
|
||||
#### Scenario: Bar shows tooltip with formatted amount
|
||||
- **WHEN** user hovers/taps a bar
|
||||
- **THEN** tooltip displays month name and expense amount formatted as "¥X,XXX.XX"
|
||||
|
||||
### Requirement: Charts must maintain existing responsive behavior
|
||||
The system SHALL ensure all statistics charts adapt to different screen sizes and orientations.
|
||||
|
||||
#### Scenario: Chart scales on narrow screens
|
||||
- **WHEN** screen width is less than 375px
|
||||
- **THEN** chart font sizes scale down proportionally while maintaining readability
|
||||
|
||||
#### Scenario: Chart reflows on orientation change
|
||||
- **WHEN** device orientation changes from portrait to landscape
|
||||
- **THEN** chart re-renders to fill available width within 300ms
|
||||
|
||||
#### Scenario: Chart labels truncate on small screens
|
||||
- **WHEN** category names are longer than 12 characters
|
||||
- **THEN** labels show ellipsis (e.g., "Entertainment..." ) and full text in tooltip
|
||||
@@ -0,0 +1,62 @@
|
||||
## 1. 基础设施搭建
|
||||
|
||||
- [x] 1.1 安装依赖:`pnpm add chart.js vue-chartjs`
|
||||
- [x] 1.2 创建 `Web/src/composables/useChartTheme.ts`(主题配置 composable)
|
||||
- [x] 1.3 创建 `Web/src/components/Charts/BaseChart.vue`(通用图表包装器)
|
||||
- [x] 1.4 创建 `Web/src/plugins/chartjs-gauge-plugin.ts`(仪表盘插件)
|
||||
- [x] 1.5 创建 `Web/src/utils/chartHelpers.ts`(图表工具函数:格式化、颜色等)
|
||||
|
||||
## 2. 迁移简单图表(验证基础设施)
|
||||
|
||||
- [x] 2.1 迁移 `MonthlyExpenseCard.vue`(柱状图,最简单)
|
||||
- 保留原有 ECharts 代码,新增 Chart.js 实现
|
||||
- 使用环境变量 `VITE_USE_CHARTJS` 控制切换
|
||||
- [x] 2.2 验证 MonthlyExpenseCard 功能:tooltip、响应式、暗色模式
|
||||
- [x] 2.3 迁移 `ExpenseCategoryCard.vue`(饼图)
|
||||
- 实现点击跳转到分类详情功能
|
||||
- 实现 "Others" 分组逻辑(<3% 的分类)
|
||||
- [x] 2.4 验证 ExpenseCategoryCard 功能:点击事件、颜色映射
|
||||
|
||||
## 3. 迁移折线图
|
||||
|
||||
- [x] 3.1 迁移 `DailyTrendChart.vue`(基础折线图)
|
||||
- 实现双线(expense + income)配置
|
||||
- 实现缩放功能(使用 chartjs-plugin-zoom)
|
||||
- [x] 3.2 验证 DailyTrendChart 功能:周/月/年切换、缩放、高亮最大值点
|
||||
|
||||
## 4. 迁移复杂图表(BudgetChartAnalysis)
|
||||
|
||||
- [x] 4.1 迁移月度仪表盘(使用 Doughnut + centerText 插件)
|
||||
- 实现居中文本显示(余额/差额)
|
||||
- 实现超支时颜色变化(红色/绿色)
|
||||
- 实现 scaleX(-1) 镜像效果(支出类型)
|
||||
- [x] 4.2 迁移年度仪表盘(复用月度逻辑)
|
||||
- [x] 4.3 迁移方差图(Variance Chart)
|
||||
- 实现横向柱状图
|
||||
- 实现实际 vs 预算的双柱对比
|
||||
- 实现超支/节省的颜色标识
|
||||
- [x] 4.4 迁移月度燃尽图(Burndown Chart)
|
||||
- 实现双线(理想线 + 实际线)
|
||||
- 实现投影线(dotted line extension)
|
||||
- [x] 4.5 迁移年度燃尽图(复用月度逻辑)
|
||||
- 实现当前月高亮标记
|
||||
- [x] 4.6 验证 BudgetChartAnalysis 所有交互:tab 切换、tooltip、响应式
|
||||
|
||||
## 5. 优化与测试
|
||||
|
||||
- [x] 5.1 实现 `prefers-reduced-motion` 支持(禁用动画)
|
||||
- [x] 5.2 实现数据抽样(decimation plugin)用于大数据量场景
|
||||
- [x] 5.3 测试所有图表的暗色模式适配
|
||||
- [x] 5.4 测试所有图表的移动端触控交互(tap, pinch, swipe)
|
||||
- [x] 5.5 测试边界情况:空数据、单条数据、超长分类名
|
||||
- [x] 5.6 性能测试:Lighthouse Performance 分数对比
|
||||
|
||||
## 6. 清理与上线
|
||||
|
||||
- [x] 6.1 移除所有组件中的 ECharts 代码(删除旧实现)
|
||||
- [x] 6.2 移除环境变量 `VITE_USE_CHARTJS`(默认使用 Chart.js)
|
||||
- [x] 6.3 从 `package.json` 移除 `echarts` 依赖
|
||||
- [x] 6.4 运行 `pnpm build` 并分析 bundle 大小(验证优化效果)
|
||||
- [x] 6.5 更新 `AGENTS.md`:记录 Chart.js 使用规范
|
||||
- [x] 6.6 创建 `.doc/chart-migration-checklist.md`(手动测试清单)
|
||||
- [x] 6.7 提交代码并部署到测试环境
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-13
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user