Compare commits
6 Commits
statistics
...
63aaaf39c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63aaaf39c5 | ||
|
|
15f0ba0993 | ||
|
|
f328c72ca0 | ||
|
|
453007ab69 | ||
|
|
fe7cb98410 | ||
|
|
1a3d0658bb |
465
.opencode/skills/code-refactoring/SKILL.md
Normal file
465
.opencode/skills/code-refactoring/SKILL.md
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
---
|
||||||
|
name: code-refactoring
|
||||||
|
description: 代码重构技能 - 强调保持功能不变的前提下优化代码结构,充分理解需求和交互式确认
|
||||||
|
tags:
|
||||||
|
- refactoring
|
||||||
|
- code-quality
|
||||||
|
- clean-code
|
||||||
|
- interactive
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# 代码重构技能
|
||||||
|
|
||||||
|
## 技能概述
|
||||||
|
|
||||||
|
专门用于在**不改变现有功能逻辑**的前提下优化代码结构,包括:
|
||||||
|
- 抽取公共方法、组件和工具类
|
||||||
|
- 消除重复代码(DRY原则)
|
||||||
|
- 移除无用代码(死代码、注释代码、未使用的依赖)
|
||||||
|
- 改善代码可读性和可维护性
|
||||||
|
- 优化代码结构和命名规范
|
||||||
|
|
||||||
|
## ⚠️ 核心原则(MUST FOLLOW)
|
||||||
|
|
||||||
|
### 1. 功能不变保证
|
||||||
|
**禁止在重构过程中改变功能行为!**
|
||||||
|
- ✅ 重构前后的输入输出必须完全一致
|
||||||
|
- ✅ 重构前后的副作用必须一致(数据库操作、文件IO、日志等)
|
||||||
|
- ✅ 重构不应改变性能特征(除非明确以性能优化为目标)
|
||||||
|
- ❌ 严禁"顺便"添加新功能或修复bug
|
||||||
|
|
||||||
|
### 2. 充分理解需求
|
||||||
|
**禁止根据模糊的需求开始重构!**
|
||||||
|
- ✅ 彻底理解重构的目标和范围
|
||||||
|
- ✅ 识别需求中的模糊点和二义性
|
||||||
|
- ✅ 使用 `question` 工具获取明确的用户意图
|
||||||
|
- ❌ 不要基于假设进行重构
|
||||||
|
|
||||||
|
### 3. 先确认再动手
|
||||||
|
**禁止未经用户确认就直接修改代码!**
|
||||||
|
- ✅ 先列出所有修改点和影响范围
|
||||||
|
- ✅ 使用 `question` 工具让用户确认重构方案
|
||||||
|
- ✅ 获得明确的同意后再执行修改
|
||||||
|
- ❌ 不要边分析边修改
|
||||||
|
|
||||||
|
## ⚠️ 强制交互规则(MUST FOLLOW)
|
||||||
|
|
||||||
|
**遇到需要用户确认的情况时,必须立即调用 `question` 工具:**
|
||||||
|
|
||||||
|
❌ **禁止**:"我需要向用户确认..."、"建议向用户询问..."、"在执行前应该确认..."
|
||||||
|
✅ **必须**:直接调用 `question` 工具,不要描述或延迟
|
||||||
|
|
||||||
|
**调用格式**:
|
||||||
|
```javascript
|
||||||
|
question({
|
||||||
|
header: "重构确认",
|
||||||
|
questions: [{
|
||||||
|
question: "是否要将重复的验证逻辑抽取到公共方法中?",
|
||||||
|
options: ["是,抽取到工具类", "是,抽取到基类", "否,保持现状", "其他"]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**规则**:
|
||||||
|
- 每次最多 **3个问题**
|
||||||
|
- 每个问题 **3-6个选项**(穷举常见情况 + "其他"兜底)
|
||||||
|
- 用户通过**上下键导航**选择
|
||||||
|
- 适用于**所有阶段**(需求理解、方案确认、风险评估)
|
||||||
|
|
||||||
|
## 重构流程
|
||||||
|
|
||||||
|
### 阶段1: 需求理解(必须交互确认)
|
||||||
|
|
||||||
|
#### 1.1 理解重构目标
|
||||||
|
**获取用户意图**:
|
||||||
|
- 用户想重构什么?(文件、模块、类、方法)
|
||||||
|
- 重构的原因是什么?(代码重复、难以维护、命名不清晰、结构混乱)
|
||||||
|
- 期望达到什么效果?(提高复用性、提升可读性、简化逻辑、统一规范)
|
||||||
|
|
||||||
|
**触发 `question` 工具的场景**:
|
||||||
|
- 用户只说"重构这个文件"但未说明具体问题
|
||||||
|
- 用户提到"优化"但没有明确优化方向
|
||||||
|
- 用户的需求包含多个可能的重构方向
|
||||||
|
- 重构范围不明确(单个文件 vs 整个模块)
|
||||||
|
|
||||||
|
**示例问题**:
|
||||||
|
```javascript
|
||||||
|
question({
|
||||||
|
header: "明确重构目标",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "您主要关注哪方面的重构?",
|
||||||
|
options: [
|
||||||
|
"抽取重复代码",
|
||||||
|
"改善命名和结构",
|
||||||
|
"移除无用代码",
|
||||||
|
"提取公共组件/方法",
|
||||||
|
"全面优化"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "重构范围是?",
|
||||||
|
options: [
|
||||||
|
"仅当前文件",
|
||||||
|
"当前模块(相关的几个文件)",
|
||||||
|
"整个项目",
|
||||||
|
"让我分析后建议"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 识别约束条件
|
||||||
|
**必须明确的约束**:
|
||||||
|
- 是否有不能改动的接口或API(对外暴露的)
|
||||||
|
- 是否有特殊的性能要求
|
||||||
|
- 是否需要保持特定的代码风格
|
||||||
|
- 是否有测试覆盖(如有,重构后测试必须通过)
|
||||||
|
|
||||||
|
**触发 `question` 工具的场景**:
|
||||||
|
- 发现公开API可能需要调整
|
||||||
|
- 代码涉及性能敏感的操作
|
||||||
|
- 存在多种重构方式,各有权衡
|
||||||
|
- 不确定某些代码是否仍在使用
|
||||||
|
|
||||||
|
**示例问题**:
|
||||||
|
```javascript
|
||||||
|
question({
|
||||||
|
header: "重构约束确认",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "发现 `ProcessData` 方法被多个外部模块调用,重构时:",
|
||||||
|
options: [
|
||||||
|
"保持方法签名不变,仅优化内部实现",
|
||||||
|
"可以修改方法签名,我会同步更新调用方",
|
||||||
|
"先告诉我影响范围,我再决定",
|
||||||
|
"其他"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 理解代码上下文
|
||||||
|
**分析现有代码**:
|
||||||
|
- 使用 `semantic_search` 查找相关代码
|
||||||
|
- 使用 `grep_search` 查找重复模式
|
||||||
|
- 使用 `list_code_usages` 分析调用关系
|
||||||
|
- 阅读相关文件理解业务逻辑
|
||||||
|
|
||||||
|
**注意事项**:
|
||||||
|
- 不要在分析阶段进行任何修改
|
||||||
|
- 记录发现的问题点和重构机会
|
||||||
|
- 识别可能的风险和边界情况
|
||||||
|
|
||||||
|
### 阶段2: 方案设计(必须交互确认)
|
||||||
|
|
||||||
|
#### 2.1 列出重构点
|
||||||
|
**详细列出每个修改点**:
|
||||||
|
- 修改的文件和位置
|
||||||
|
- 修改的具体内容(前后对比)
|
||||||
|
- 修改的原因和收益
|
||||||
|
- 可能的影响范围
|
||||||
|
|
||||||
|
**示例格式**:
|
||||||
|
```
|
||||||
|
## 重构点清单
|
||||||
|
|
||||||
|
### 1. 抽取重复的数据验证逻辑
|
||||||
|
**位置**: TransactionController.cs (L45-L60, L120-L135)
|
||||||
|
**操作**: 将重复的金额验证逻辑抽取到 ValidationHelper.ValidateAmount()
|
||||||
|
**原因**: 两处代码完全相同,违反DRY原则
|
||||||
|
**影响**: 无,纯内部优化
|
||||||
|
|
||||||
|
### 2. 移除未使用的导入和变量
|
||||||
|
**位置**: BudgetService.cs (L5, L23)
|
||||||
|
**操作**: 删除 `using System.Text.RegularExpressions;` 和未使用的 `_tempValue` 字段
|
||||||
|
**原因**: 死代码,增加维护负担
|
||||||
|
**影响**: 无
|
||||||
|
|
||||||
|
### 3. 重命名方法提高可读性
|
||||||
|
**位置**: DataProcessor.cs (L89)
|
||||||
|
**操作**: `DoWork()` → `ProcessTransactionData()`
|
||||||
|
**原因**: 原名称不够清晰,无法表达具体功能
|
||||||
|
**影响**: 4个调用点需要同步更新
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 评估风险和影响
|
||||||
|
**必须分析的风险**:
|
||||||
|
- 是否影响公开API
|
||||||
|
- 是否影响性能
|
||||||
|
- 是否影响测试
|
||||||
|
- 是否涉及数据迁移
|
||||||
|
- 是否存在隐藏的依赖关系
|
||||||
|
|
||||||
|
**触发 `question` 工具的场景**:
|
||||||
|
- 发现重构会影响多个模块
|
||||||
|
- 存在潜在的兼容性问题
|
||||||
|
- 有多种实现方式可选
|
||||||
|
- 需要在代码质量和改动风险间权衡
|
||||||
|
|
||||||
|
**示例问题**:
|
||||||
|
```javascript
|
||||||
|
question({
|
||||||
|
header: "重构方案确认",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "发现3处重复的日期格式化代码,建议:",
|
||||||
|
options: [
|
||||||
|
"抽取到工具类(Common项目)",
|
||||||
|
"抽取到当前服务的私有方法",
|
||||||
|
"保留重复(代码简单,抽取收益小)",
|
||||||
|
"让我看看代码再决定"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "重构会影响4个controller和2个service,是否继续?",
|
||||||
|
options: [
|
||||||
|
"是,一次性全部重构",
|
||||||
|
"否,先重构影响小的部分",
|
||||||
|
"告诉我每个的影响详情",
|
||||||
|
"其他"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 提交方案供确认
|
||||||
|
**必须向用户展示**:
|
||||||
|
1. 完整的重构点清单(如2.1格式)
|
||||||
|
2. 风险评估和影响分析
|
||||||
|
3. 建议的执行顺序
|
||||||
|
4. 预计改动的文件数量
|
||||||
|
|
||||||
|
**必须调用 `question` 工具获得最终确认**:
|
||||||
|
```javascript
|
||||||
|
question({
|
||||||
|
header: "最终确认",
|
||||||
|
questions: [{
|
||||||
|
question: "我已列出所有重构点和影响分析,是否开始执行?",
|
||||||
|
options: [
|
||||||
|
"是,按计划执行",
|
||||||
|
"需要调整部分重构点",
|
||||||
|
"取消重构",
|
||||||
|
"其他问题"
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**重要**:
|
||||||
|
- ❌ 不要在得到明确的"是,按计划执行"之前修改任何代码
|
||||||
|
- ❌ 不要假设用户会同意
|
||||||
|
- ✅ 如用户选择"需要调整",返回阶段1重新理解需求
|
||||||
|
|
||||||
|
### 阶段3: 执行重构
|
||||||
|
|
||||||
|
#### 3.1 执行原则
|
||||||
|
- **小步快跑**: 一次完成一个重构点,不要多个同时进行
|
||||||
|
- **频繁验证**: 每完成一个点就运行测试或构建验证
|
||||||
|
- **保持可逆**: 确保随时可以回滚
|
||||||
|
- **记录进度**: 使用 `manage_todo_list` 跟踪进度
|
||||||
|
|
||||||
|
#### 3.2 执行步骤
|
||||||
|
1. **创建TODO清单**:
|
||||||
|
```javascript
|
||||||
|
manage_todo_list({
|
||||||
|
todoList: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "抽取重复验证逻辑到ValidationHelper",
|
||||||
|
description: "TransactionController.cs L45-L60, L120-L135",
|
||||||
|
status: "not-started"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "移除BudgetService.cs中的未使用导入",
|
||||||
|
description: "删除using System.Text.RegularExpressions",
|
||||||
|
status: "not-started"
|
||||||
|
},
|
||||||
|
// ... 更多任务
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **逐个执行**:
|
||||||
|
- 标记任务为 `in-progress`
|
||||||
|
- 使用 `multi_replace_string_in_file` 或 `replace_string_in_file` 修改代码
|
||||||
|
- 运行测试验证: `dotnet test` 或 `pnpm test`
|
||||||
|
- 标记任务为 `completed`
|
||||||
|
- 继续下一个
|
||||||
|
|
||||||
|
3. **验证每个步骤**:
|
||||||
|
- 后端重构后运行: `dotnet build && dotnet test`
|
||||||
|
- 前端重构后运行: `pnpm lint && pnpm build`
|
||||||
|
- 确保没有引入编译错误或测试失败
|
||||||
|
|
||||||
|
#### 3.3 异常处理
|
||||||
|
**如果遇到预期外的问题**:
|
||||||
|
- ✅ 立即停止后续重构
|
||||||
|
- ✅ 报告问题详情
|
||||||
|
- ✅ 调用 `question` 工具询问如何处理
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
question({
|
||||||
|
header: "重构遇到问题",
|
||||||
|
questions: [{
|
||||||
|
question: "抽取方法后发现测试 `TestValidation` 失败了,如何处理?",
|
||||||
|
options: [
|
||||||
|
"回滚这个改动",
|
||||||
|
"修复测试用例",
|
||||||
|
"暂停,我来看看",
|
||||||
|
"继续其他重构点"
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阶段4: 验证和总结
|
||||||
|
|
||||||
|
#### 4.1 全面验证
|
||||||
|
**必须执行的验证**:
|
||||||
|
- 所有单元测试通过
|
||||||
|
- 项目成功构建
|
||||||
|
- Lint检查通过
|
||||||
|
- 关键功能手动验证(如适用)
|
||||||
|
|
||||||
|
**验证命令**:
|
||||||
|
```bash
|
||||||
|
# 后端
|
||||||
|
dotnet clean
|
||||||
|
dotnet build EmailBill.sln
|
||||||
|
dotnet test WebApi.Test/WebApi.Test.csproj
|
||||||
|
|
||||||
|
# 前端
|
||||||
|
cd Web
|
||||||
|
pnpm lint
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 总结报告
|
||||||
|
**提供清晰的总结**:
|
||||||
|
```
|
||||||
|
## 重构完成总结
|
||||||
|
|
||||||
|
### ✅ 已完成的重构
|
||||||
|
1. 抽取重复验证逻辑 (ValidationHelper.cs)
|
||||||
|
- 消除了 3 处重复代码
|
||||||
|
- 减少代码行数 45 行
|
||||||
|
|
||||||
|
2. 移除未使用的导入和变量
|
||||||
|
- BudgetService.cs: 移除 2 个未使用的 using
|
||||||
|
- TransactionController.cs: 移除 1 个未使用字段
|
||||||
|
|
||||||
|
3. 改善方法命名
|
||||||
|
- DoWork → ProcessTransactionData (4 处调用点已更新)
|
||||||
|
- Calculate → CalculateMonthlyBudget (2 处调用点已更新)
|
||||||
|
|
||||||
|
### 📊 重构影响
|
||||||
|
- 修改文件数: 6
|
||||||
|
- 新增文件数: 1 (ValidationHelper.cs)
|
||||||
|
- 删除代码行数: 78
|
||||||
|
- 新增代码行数: 42
|
||||||
|
- 净减少代码: 36 行
|
||||||
|
|
||||||
|
### ✅ 验证结果
|
||||||
|
- ✓ 所有测试通过 (23/23)
|
||||||
|
- ✓ 项目构建成功
|
||||||
|
- ✓ Lint检查通过
|
||||||
|
- ✓ 功能验证正常
|
||||||
|
|
||||||
|
### 📝 建议的后续工作
|
||||||
|
- 考虑为 ValidationHelper 添加单元测试
|
||||||
|
- 可以进一步重构 DataProcessor 类的其他方法
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见重构模式
|
||||||
|
|
||||||
|
### 1. 抽取公共方法
|
||||||
|
**识别标准**: 代码块在多处重复出现(≥2次)
|
||||||
|
**操作**:
|
||||||
|
- 创建独立方法或工具类
|
||||||
|
- 保持方法签名简洁明确
|
||||||
|
- 添加必要的注释和文档
|
||||||
|
|
||||||
|
### 2. 抽取公共组件
|
||||||
|
**识别标准**: UI组件或业务逻辑在多个视图/页面重复
|
||||||
|
**操作**:
|
||||||
|
- 创建可复用组件(Vue组件、Service类等)
|
||||||
|
- 使用Props/参数传递可变部分
|
||||||
|
- 确保组件职责单一
|
||||||
|
|
||||||
|
### 3. 移除死代码
|
||||||
|
**识别标准**:
|
||||||
|
- 未被调用的方法
|
||||||
|
- 未被使用的变量、导入、依赖
|
||||||
|
- 注释掉的代码
|
||||||
|
**操作**:
|
||||||
|
- 使用 `list_code_usages` 确认真正未使用
|
||||||
|
- 谨慎删除(可能有隐式调用)
|
||||||
|
- 使用Git历史作为备份
|
||||||
|
|
||||||
|
### 4. 改善命名
|
||||||
|
**识别标准**:
|
||||||
|
- 名称不能表达意图(如 `DoWork`, `Process`, `temp`)
|
||||||
|
- 名称与实际功能不符
|
||||||
|
- 违反命名规范
|
||||||
|
**操作**:
|
||||||
|
- 使用 `list_code_usages` 找到所有使用点
|
||||||
|
- 使用 `multi_replace_string_in_file` 批量更新
|
||||||
|
- 确保命名符合项目规范(见AGENTS.md)
|
||||||
|
|
||||||
|
### 5. 简化复杂逻辑
|
||||||
|
**识别标准**:
|
||||||
|
- 深层嵌套(>3层)
|
||||||
|
- 过长方法(>50行)
|
||||||
|
- 复杂条件判断
|
||||||
|
**操作**:
|
||||||
|
- 早返回模式(guard clauses)
|
||||||
|
- 拆分子方法
|
||||||
|
- 使用策略模式或查表法
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
### ❌ 不要做
|
||||||
|
- 在重构中添加新功能
|
||||||
|
- 在重构中修复bug(除非bug是重构导致的)
|
||||||
|
- 未经确认就大范围修改
|
||||||
|
- 改变公开API而不考虑兼容性
|
||||||
|
- 跳过测试验证
|
||||||
|
|
||||||
|
### ✅ 要做
|
||||||
|
- 保持每次重构的范围可控
|
||||||
|
- 频繁提交代码(每完成一个重构点提交一次)
|
||||||
|
- 确保测试覆盖率不降低
|
||||||
|
- 保持代码风格一致
|
||||||
|
- 记录重构的原因和收益
|
||||||
|
|
||||||
|
## 项目特定规范
|
||||||
|
|
||||||
|
### C# 代码重构
|
||||||
|
- 遵循 `AGENTS.md` 中的 C# 代码风格
|
||||||
|
- 使用 file-scoped namespace
|
||||||
|
- 公共方法使用 XML 注释
|
||||||
|
- 业务逻辑使用中文注释
|
||||||
|
- 工具方法考虑放入 `Common` 项目
|
||||||
|
|
||||||
|
### Vue/TypeScript 代码重构
|
||||||
|
- 使用 Composition API
|
||||||
|
- 组件放入 `src/components`
|
||||||
|
- 遵循 ESLint 和 Prettier 规则
|
||||||
|
- 使用 `@/` 别名避免相对路径
|
||||||
|
- 提取的组件使用 Vant UI 风格
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
代码重构是一个**谨慎的、迭代的、需要充分确认的**过程。核心要点:
|
||||||
|
|
||||||
|
1. **理解先于行动** - 彻底理解需求和约束
|
||||||
|
2. **交互式确认** - 使用 `question` 工具消除歧义
|
||||||
|
3. **计划后执行** - 列出修改点并获得确认
|
||||||
|
4. **小步快跑** - 逐个完成重构点,频繁验证
|
||||||
|
5. **功能不变** - 始终确保行为一致性
|
||||||
@@ -32,120 +32,60 @@ metadata:
|
|||||||
|
|
||||||
### 1. 现代移动端优先设计
|
### 1. 现代移动端优先设计
|
||||||
|
|
||||||
**布局标准:**
|
**核心规范:**
|
||||||
- 移动视口: 375px 宽度 (iPhone SE 基准)
|
- 移动视口: 375px 宽度 (iPhone SE 基准)
|
||||||
- 安全区域: 尊重 iOS/Android 安全区域边距
|
- 安全区域: 尊重 iOS/Android 安全区域边距
|
||||||
- 触摸目标: 交互元素最小 44x44px
|
- 交互元素最小触摸目标: 44x44px
|
||||||
- 间距比例: 4px, 8px, 12px, 16px, 24px, 32px (8px 基础网格)
|
- 间距基于 8px 网格: 4px, 8px, 12px, 16px, 24px, 32px
|
||||||
- 圆角半径: 12px (卡片), 16px (对话框), 24px (药丸/标签), 8px (按钮)
|
- 卡片阴影: `0 2px 12px rgba(0,0,0,0.08)` (亮色模式)
|
||||||
- 卡片阴影: `0 2px 12px rgba(0,0,0,0.08)` 用于突出表面
|
|
||||||
|
|
||||||
**避免 AI 设计痕迹:**
|
**反 AI 设计痕迹检查清单:**
|
||||||
- 不要使用通用的 "Dashboard" 占位符文本
|
- ❌ 使用 "Dashboard", "Lorem Ipsum" 等通用占位符
|
||||||
- 不要使用图库照片或 Lorem Ipsum
|
- ❌ 使用过饱和的颜色或生硬的渐变
|
||||||
- 不要使用过饱和的主色 (#007AFF, 不是 #0088FF)
|
- ❌ 使用装饰性字体 (Comic Sans, Papyrus)
|
||||||
- 不要使用生硬的阴影或渐变
|
- ✅ 使用代码库中的真实中文业务术语
|
||||||
- 不要使用 Comic Sans, Papyrus 或装饰性字体
|
- ✅ 使用克制的配色和柔和的阴影
|
||||||
- 使用代码库中的真实中文业务术语
|
- ✅ 使用专业的系统字体
|
||||||
|
|
||||||
### 2. 统一色彩系统 (基于实际 v2.pen 日历设计)
|
### 2. 统一色彩系统
|
||||||
|
|
||||||
**亮色主题:**
|
**色彩分层:**
|
||||||
```css
|
- **背景层**: 页面背景 → 卡片背景 → 强调背景 (三层递进)
|
||||||
/* 背景色 - 基于实际设计 */
|
- **文本层**: 主文本 → 次要文本 → 三级文本 (三级层次)
|
||||||
--background-page: #FFFFFF /* 页面背景 (Calendar frame fill) */
|
- **语义色**: 红色(支出/危险) → 黄色(警告) → 绿色(收入/成功) → 蓝色(主操作/信息)
|
||||||
--background-card: #F6F7F8 /* 卡片背景 (statsCard, tCard fills) */
|
|
||||||
--background-accent: #F5F5F5 /* 强调背景 (notif button) */
|
|
||||||
|
|
||||||
/* 文本色 - 基于实际设计 */
|
|
||||||
--text-primary: #1A1A1A /* 主文本 (titles, labels) */
|
|
||||||
--text-secondary: #6B7280 /* 次要文本 (dates, subtitles) */
|
|
||||||
--text-tertiary: #9CA3AF /* 三级文本 (weekday labels) */
|
|
||||||
|
|
||||||
/* 语义色 - 基于实际设计 */
|
|
||||||
--accent-red: #FF6B6B /* 支出/负数 (expense icon) */
|
|
||||||
--accent-yellow: #FCD34D /* 警告/中性 (coffee icon) */
|
|
||||||
--accent-green: #F0FDF4 /* 收入/正数 (badge background) */
|
|
||||||
--accent-blue: #E0E7FF /* 智能标签 (smart button) */
|
|
||||||
--accent-warm: #FFFBEB /* 温暖色调 (badge background) */
|
|
||||||
|
|
||||||
/* 主操作色 */
|
|
||||||
--primary: #3B82F6 /* 主色调 (FAB button from Budget Stats) */
|
|
||||||
|
|
||||||
/* 边框与分割线 */
|
|
||||||
--border: #E5E7EB /* 边框颜色 (从设计推断) */
|
|
||||||
```
|
|
||||||
|
|
||||||
**暗色主题:**
|
|
||||||
```css
|
|
||||||
/* 背景色 - 基于实际暗色设计 */
|
|
||||||
--background-page: #09090B /* 页面背景 (Calendar Dark frame) */
|
|
||||||
--background-card: #18181B /* 卡片背景 (dark statsCard, tCard) */
|
|
||||||
--background-accent: #27272A /* 强调背景 (dark notif, tCat) */
|
|
||||||
|
|
||||||
/* 文本色 - 基于实际暗色设计 */
|
|
||||||
--text-primary: #F4F4F5 /* 主文本 (dark titles) */
|
|
||||||
--text-secondary: #A1A1AA /* 次要文本 (dark dates, subtitles) */
|
|
||||||
--text-tertiary: #71717A /* 三级文本 (dark weekday labels) */
|
|
||||||
|
|
||||||
/* 语义色 - 暗色模式适配 */
|
|
||||||
--accent-red: #FF6B6B /* 保持一致 */
|
|
||||||
--accent-yellow: #FCD34D /* 保持一致 */
|
|
||||||
--accent-green: #064E3B /* 深绿 (dark badge) */
|
|
||||||
--accent-blue: #312E81 /* 深蓝 (dark smart button) */
|
|
||||||
--accent-warm: #451A03 /* 深暖色 (dark badge) */
|
|
||||||
|
|
||||||
/* 主操作色 */
|
|
||||||
--primary: #3B82F6 /* 保持一致 */
|
|
||||||
|
|
||||||
/* 边框与分割线 */
|
|
||||||
--border: #3F3F46 /* 深色边框 */
|
|
||||||
```
|
|
||||||
|
|
||||||
**颜色使用规则:**
|
**颜色使用规则:**
|
||||||
- **页面背景**: 亮色 `#FFFFFF`, 暗色 `#09090B`
|
- 始终使用语义颜色变量,避免硬编码十六进制值
|
||||||
- **卡片背景**: 亮色 `#F6F7F8`, 暗色 `#18181B`
|
- 支出/负数统一使用红色 `#FF6B6B`,收入/正数使用绿色系
|
||||||
- **主文本**: 亮色 `#1A1A1A`, 暗色 `#F4F4F5`
|
- 主操作按钮统一使用蓝色 `#3B82F6`
|
||||||
- **次要文本**: 亮色 `#6B7280`, 暗色 `#A1A1AA`
|
- 避免纯黑 (#000000) 或纯白 (#FFFFFF) 文本,使用柔和的色调
|
||||||
- **支出/负数**: `#FF6B6B` (两种模式一致)
|
- 暗色模式下减少阴影强度或完全移除
|
||||||
- **主操作按钮**: `#3B82F6` (两种模式一致)
|
- 详细色值参见文末"快速参考"表格
|
||||||
- **圆角**: 12px (小按钮), 16px (卡片), 20px (统计卡片), 22px (图标按钮)
|
|
||||||
- **阴影**: 亮色使用柔和阴影, 暗色使用更深的阴影或无阴影
|
|
||||||
- **避免**: 纯黑 (#000000) 或纯白 (#FFFFFF) 文本
|
|
||||||
|
|
||||||
### 3. 排版系统 (基于实际 v2.pen 设计)
|
### 3. 排版系统
|
||||||
|
|
||||||
**字体栈:**
|
**字体栈:**
|
||||||
```css
|
- **标题**: `'Bricolage Grotesque'` - 用于大数值、章节标题
|
||||||
/* 标题与大标题 - 基于 v2.pen */
|
- **正文**: `'DM Sans'` - 用于界面文本、说明
|
||||||
font-family: 'Bricolage Grotesque', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
- **数字**: `'DIN Alternate'` - 用于金额、数据显示
|
||||||
|
- **备选**: `-apple-system, 'PingFang SC'` - 系统默认字体
|
||||||
|
|
||||||
/* 正文与界面 - 基于 v2.pen */
|
**排版原则:**
|
||||||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
- 使用真实中文业务术语,避免 Lorem Ipsum
|
||||||
|
|
||||||
/* 备选: 系统默认 */
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
|
|
||||||
```
|
|
||||||
|
|
||||||
**字号比例 (从实际设计提取):**
|
|
||||||
| 用途 | 字体 | 大小 | 粗细 | 示例 |
|
|
||||||
|------|------|------|------|------|
|
|
||||||
| 大标题数值 | Bricolage Grotesque | 32px | 800 | ¥ 1,248.50 (statsVal) |
|
|
||||||
| 页面标题 | DM Sans | 24px | 500 | 2026年1月 (subtitle) |
|
|
||||||
| 章节标题 | Bricolage Grotesque | 18px | 700 | 每日统计, 交易记录 (titles) |
|
|
||||||
| 正文文本 | DM Sans | 15px | 600 | Lunch, Coffee (交易名称) |
|
|
||||||
| 说明文字 | DM Sans | 13px | 500 | 12:30 PM, Total Spent (标签) |
|
|
||||||
| 微型文字 | DM Sans | 12px | 600 | 一二三四五六日 (星期) |
|
|
||||||
|
|
||||||
**中文文本规则:**
|
|
||||||
- 使用简体中文
|
|
||||||
- 真实业务术语: "每日统计" (daily stats), "交易记录" (transactions)
|
|
||||||
- 行高: 1.4-1.6 保证可读性
|
- 行高: 1.4-1.6 保证可读性
|
||||||
- 使用真实业务术语,避免 Lorem Ipsum
|
- 数字数据使用等宽字体 (tabular-nums)
|
||||||
|
- 字号遵循比例系统,避免任意数值
|
||||||
|
- 详细字号比例参见文末"快速参考"表格
|
||||||
|
|
||||||
### 4. 组件库 (基于实际 v2.pen 设计)
|
### 4. 组件库
|
||||||
|
|
||||||
**卡片 (基于 statsCard, tCard):**
|
**设计原则:**
|
||||||
|
- 所有尺寸和间距基于 8px 网格系统
|
||||||
|
- 圆角: 12px (小按钮), 16px/20px (卡片), 22px/28px (圆形按钮)
|
||||||
|
- 交互元素最小触摸目标: 44x44px
|
||||||
|
- 详细组件规格参见文末"快速参考"表格
|
||||||
|
|
||||||
|
**卡片设计 (基于 statsCard, tCard):**
|
||||||
```
|
```
|
||||||
统计卡片 (大卡片):
|
统计卡片 (大卡片):
|
||||||
- 背景: #F6F7F8 (亮色), #18181B (暗色)
|
- 背景: #F6F7F8 (亮色), #18181B (暗色)
|
||||||
@@ -207,23 +147,16 @@ font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB
|
|||||||
|
|
||||||
**布局模式 (基于 Calendar 结构):**
|
**布局模式 (基于 Calendar 结构):**
|
||||||
```
|
```
|
||||||
页面容器:
|
页面容器: 402px (设计视口), 垂直布局, 24px 内边距
|
||||||
- 宽度: 402px (设计视口)
|
头部区域: 水平布局, 两端对齐, 8px 24px 内边距
|
||||||
- 布局: 垂直
|
内容区域: 垂直布局, 24px 内边距, 12-16px 间距
|
||||||
- 内边距: 24px (容器边距)
|
|
||||||
- 间距: 16px (章节之间)
|
|
||||||
|
|
||||||
头部区域:
|
|
||||||
- 内边距: 8px 24px
|
|
||||||
- 布局: 水平, 两端对齐
|
|
||||||
- 对齐项: 居中
|
|
||||||
|
|
||||||
内容区域:
|
|
||||||
- 内边距: 24px
|
|
||||||
- 间距: 12-16px
|
|
||||||
- 布局: 垂直
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**关键布局原则:**
|
||||||
|
- 遵循 Flex 容器模式 (见下方"5. 布局模式")
|
||||||
|
- 导航栏背景必须透明 (`:deep(.van-nav-bar) { background: transparent !important; }`)
|
||||||
|
- 尊重安全区域 (`env(safe-area-inset-bottom)`)
|
||||||
|
|
||||||
### 5. 布局模式
|
### 5. 布局模式
|
||||||
|
|
||||||
**页面结构 (Flex 容器):**
|
**页面结构 (Flex 容器):**
|
||||||
@@ -241,17 +174,17 @@ font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB
|
|||||||
4. bottom-button 或 van-tabbar (固定)
|
4. bottom-button 或 van-tabbar (固定)
|
||||||
```
|
```
|
||||||
|
|
||||||
**导航栏背景透明化 (一致性模式):**
|
**导航栏背景透明化 (项目标准模式):**
|
||||||
```css
|
```css
|
||||||
/* 设置页面容器背景色 */
|
/* 所有页面统一设置 */
|
||||||
:deep(.van-nav-bar) {
|
:deep(.van-nav-bar) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
**重要:** 项目中所有视图都采用此模式,使导航栏背景透明,与页面背景融合。实现此效果时,请确保:
|
**关键要求:**
|
||||||
- 页面容器有明确的背景色 (亮色: #FFFFFF, 暗色: #09090B)
|
- 页面容器必须有明确的背景色
|
||||||
- 导航栏始终使用 `:deep(.van-nav-bar)` 选择器
|
- 必须使用 `:deep()` 选择器覆盖 Vant 样式
|
||||||
- 必须添加 `!important` 覆盖 Vant 默认样式
|
- 必须添加 `!important` 标记
|
||||||
- 在 `<style scoped>` 块中添加此规则
|
- 在 `<style scoped>` 块中添加此规则
|
||||||
|
|
||||||
**安全区域处理:**
|
**安全区域处理:**
|
||||||
@@ -407,42 +340,23 @@ document.documentElement.setAttribute('data-theme', theme.value)
|
|||||||
- 示例: "Budget/ListView", "Budget/EditForm"
|
- 示例: "Budget/ListView", "Budget/EditForm"
|
||||||
```
|
```
|
||||||
|
|
||||||
**图层命名:**
|
|
||||||
```
|
|
||||||
内部元素可用中文:
|
|
||||||
✅ 预算卡片背景
|
|
||||||
✅ 金额文本
|
|
||||||
✅ 操作按钮组
|
|
||||||
|
|
||||||
组件用英文:
|
|
||||||
✅ CardBackground
|
|
||||||
✅ AmountLabel
|
|
||||||
✅ ActionButtons
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. 质量检查清单
|
### 10. 质量检查清单
|
||||||
|
|
||||||
**完成设计前:**
|
**设计完成前必检项:**
|
||||||
- [ ] 同时创建了亮色和暗色主题
|
- [ ] 同时创建亮色和暗色主题
|
||||||
- [ ] 所有文本使用真实中文业务术语 (无 Lorem Ipsum)
|
- [ ] 使用真实中文业务术语 (无占位文本)
|
||||||
- [ ] 交互元素 ≥ 44x44px
|
- [ ] 交互元素 ≥ 44x44px
|
||||||
- [ ] 间距遵循 8px 网格
|
- [ ] 间距遵循 8px 网格
|
||||||
- [ ] 颜色使用语义变量 (非硬编码十六进制)
|
- [ ] 使用语义颜色变量 (非硬编码)
|
||||||
- [ ] 圆角: 12px/16px/24px 保持一致
|
- [ ] 导航栏背景透明 (`:deep(.van-nav-bar)`)
|
||||||
- [ ] 尊重安全区域边距 (底部: 50px + 安全区域)
|
- [ ] 帧命名: 模块-屏幕-变体 格式
|
||||||
- [ ] 帧正确命名 模块/屏幕/变体
|
- [ ] 可复用组件标记 `reusable: true`
|
||||||
- [ ] 可复用组件标记为 `reusable: true`
|
- [ ] 两种主题截图验证
|
||||||
- [ ] 字号匹配字号比例 (12/14/16/18/24)
|
|
||||||
- [ ] 数字数据使用 DIN Alternate
|
|
||||||
- [ ] 无 AI 生成的占位内容
|
|
||||||
- [ ] 已截图并视觉验证
|
|
||||||
- [ ] 导航栏背景设置为透明 (`:deep(.van-nav-bar) { background: transparent !important; }`)
|
|
||||||
|
|
||||||
**无障碍:**
|
**无障碍标准:**
|
||||||
- [ ] 正文对比度 ≥ 4.5:1
|
- [ ] 正文对比度 ≥ 4.5:1
|
||||||
- [ ] 大文本对比度 ≥ 3:1 (18px+)
|
- [ ] 大文本对比度 ≥ 3:1 (18px+)
|
||||||
- [ ] 触摸目标清晰分离 (最小 8px 间距)
|
- [ ] 触摸目标间距 ≥ 8px
|
||||||
- [ ] 颜色不是信息的唯一指示器
|
|
||||||
|
|
||||||
## PANCLI 工作流程
|
## PANCLI 工作流程
|
||||||
|
|
||||||
@@ -804,9 +718,10 @@ delegate_task(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速参考 (基于实际 v2.pen 设计)
|
## 快速参考
|
||||||
|
|
||||||
|
**颜色面板 (基于实际 v2.pen 设计):**
|
||||||
|
|
||||||
**颜色面板:**
|
|
||||||
| 名称 | 亮色 | 暗色 | 用途 |
|
| 名称 | 亮色 | 暗色 | 用途 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| 页面背景 | #FFFFFF | #09090B | 页面背景 |
|
| 页面背景 | #FFFFFF | #09090B | 页面背景 |
|
||||||
@@ -821,7 +736,8 @@ delegate_task(
|
|||||||
| 绿色 | #F0FDF4 | #064E3B | 收入标签 |
|
| 绿色 | #F0FDF4 | #064E3B | 收入标签 |
|
||||||
| 蓝色 | #E0E7FF | #312E81 | 信息标签 |
|
| 蓝色 | #E0E7FF | #312E81 | 信息标签 |
|
||||||
|
|
||||||
**排版 (实际字体):**
|
**排版比例:**
|
||||||
|
|
||||||
| 用途 | 字体 | 大小 | 粗细 |
|
| 用途 | 字体 | 大小 | 粗细 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| 大数值 | Bricolage Grotesque | 32px | 800 |
|
| 大数值 | Bricolage Grotesque | 32px | 800 |
|
||||||
@@ -831,26 +747,19 @@ delegate_task(
|
|||||||
| 说明 | DM Sans | 13px | 500 |
|
| 说明 | DM Sans | 13px | 500 |
|
||||||
| 微型标签 | DM Sans | 12px | 600 |
|
| 微型标签 | DM Sans | 12px | 600 |
|
||||||
|
|
||||||
**间距与布局 (实际数值):**
|
**组件规格:**
|
||||||
- 容器内边距: 24px (主区域), 20px (卡片), 16px (小卡片)
|
|
||||||
- 间距比例: 2px, 4px, 8px, 12px, 14px, 16px
|
|
||||||
- 图标大小: 20px
|
|
||||||
- 图标按钮: 44x44px
|
|
||||||
- FAB 按钮: 56x56px
|
|
||||||
|
|
||||||
**圆角 (实际数值):**
|
- **容器内边距**: 24px (主区域), 20px (卡片), 16px (小卡片)
|
||||||
- 小按钮/标签: 12px
|
- **间距比例**: 2px, 4px, 8px, 12px, 14px, 16px
|
||||||
- 卡片: 16px, 20px
|
- **圆角**: 12px (标签), 16px/20px (卡片), 22px/28px (圆形按钮)
|
||||||
- 图标按钮: 22px (圆形)
|
- **图标**: 20px
|
||||||
- FAB: 28px (圆形)
|
- **图标按钮**: 44x44px
|
||||||
|
- **FAB 按钮**: 56x56px
|
||||||
**触摸目标:** 最小 44x44px
|
- **触摸目标**: 最小 44x44px
|
||||||
|
- **设计视口**: 402px 宽度
|
||||||
**视口:** 402px (设计宽度), 移动端优先
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**版本:** 2.0.0 (使用 v2.pen 实际设计值更新)
|
**版本:** 2.0.0
|
||||||
**最后更新:** 2026-02-03
|
**最后更新:** 2026-02-04
|
||||||
**来源:** .pans/v2.pen 日历 (亮色/暗色)
|
|
||||||
**维护者:** EmailBill 设计团队
|
**维护者:** EmailBill 设计团队
|
||||||
|
|||||||
44
.sisyphus/notepads/calendar-v2-data-loading-fix/decisions.md
Normal file
44
.sisyphus/notepads/calendar-v2-data-loading-fix/decisions.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Calendar V2 数据加载修复 - 决策记录
|
||||||
|
|
||||||
|
## 2026-02-04 修复决策
|
||||||
|
|
||||||
|
### 问题确认
|
||||||
|
用户报告:"日历v2中当前月份的日历矩阵中并没有加载当前月份的消费数据"
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
1. `Web/src/views/calendarV2/modules/Calendar.vue` 的 `fetchAllRelevantMonthsData` 函数
|
||||||
|
2. 第 144 行在调用 API 时传递的 `month` 参数格式错误
|
||||||
|
3. JavaScript Date 的 month 是 0-11,但后端 API 期望 1-12
|
||||||
|
4. 上月和下月数据正常,因为代码中已有 `+1` 转换
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
**选择:** 在第 144 行添加 `+1` 转换,与上月/下月处理保持一致
|
||||||
|
|
||||||
|
**理由:**
|
||||||
|
- 最小化修改范围(仅1行代码 + 1行注释)
|
||||||
|
- 保持代码一致性(三个月份处理逻辑统一)
|
||||||
|
- 不影响其他功能模块
|
||||||
|
- 符合现有的API约定
|
||||||
|
|
||||||
|
**拒绝的方案:**
|
||||||
|
- ❌ 修改后端 API 接受 0-11 格式 - 会破坏现有其他调用方
|
||||||
|
- ❌ 修改 `fetchDailyStats` 函数内部转换 - 会影响所有调用处
|
||||||
|
- ❌ 使用 watch 监听并重新加载 - 增加不必要的复杂度
|
||||||
|
|
||||||
|
### 代码变更
|
||||||
|
```diff
|
||||||
|
- const promises = [fetchDailyStats(year, month)]
|
||||||
|
+ // JavaScript Date.month 是 0-11,但后端 API 期望 1-12
|
||||||
|
+ const promises = [fetchDailyStats(year, month + 1)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
✅ 代码审查通过
|
||||||
|
✅ ESLint 检查通过(0 errors)
|
||||||
|
✅ 逻辑一致性确认(当前月、上月、下月都使用 +1)
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
- **修改文件:** 仅 `Web/src/views/calendarV2/modules/Calendar.vue`
|
||||||
|
- **影响功能:** 日历v2 当前月份数据加载
|
||||||
|
- **用户可见变化:** 当前月份的日期单元格将正确显示消费金额
|
||||||
|
- **副作用:** 无
|
||||||
165
.sisyphus/notepads/calendar-v2-data-loading-fix/learnings.md
Normal file
165
.sisyphus/notepads/calendar-v2-data-loading-fix/learnings.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Calendar V2 数据加载修复 - 学习笔记
|
||||||
|
|
||||||
|
## 2026-02-04 初始分析
|
||||||
|
|
||||||
|
### 问题根因
|
||||||
|
- `Web/src/views/calendarV2/modules/Calendar.vue` 第 144 行
|
||||||
|
- `fetchAllRelevantMonthsData` 函数在加载当前月数据时,传递的 `month` 参数是 JavaScript Date 的月份(0-11)
|
||||||
|
- 但后端 API `GetDailyStatistics` 期望的是 1-12 格式
|
||||||
|
- 上月和下月数据加载正确,因为代码中有 `prevMonth + 1` 和 `nextMonth + 1`
|
||||||
|
- **当前月数据加载失败,导致日历矩阵中没有当前月的消费数据**
|
||||||
|
|
||||||
|
### API 约定
|
||||||
|
- 后端 API: `WebApi/Controllers/TransactionRecordController.cs` 的 `GetDailyStatisticsAsync`
|
||||||
|
- 参数格式: `year` (完整年份), `month` (1-12)
|
||||||
|
- 前端 API: `Web/src/api/statistics.js` 的 `getDailyStatistics`
|
||||||
|
|
||||||
|
### 相关代码位置
|
||||||
|
- 问题文件: `Web/src/views/calendarV2/modules/Calendar.vue`
|
||||||
|
- 问题函数: `fetchAllRelevantMonthsData` (第 120-164 行)
|
||||||
|
- 问题行: 第 144 行 `const promises = [fetchDailyStats(year, month)]`
|
||||||
|
- 正确格式应该是: `const promises = [fetchDailyStats(year, month + 1)]`
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-02-04 修复完成
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
- 文件: `Web/src/views/calendarV2/modules/Calendar.vue`
|
||||||
|
- 行号: 第 144 行
|
||||||
|
- 修改前: `const promises = [fetchDailyStats(year, month)]`
|
||||||
|
- 修改后: `const promises = [fetchDailyStats(year, month + 1)]`
|
||||||
|
- 新增注释: "// JavaScript Date.month 是 0-11,但后端 API 期望 1-12"
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
- ✅ 代码风格检查通过 (`pnpm lint`)
|
||||||
|
- ✅ 仅有项目已存在的 40 个警告,无新增错误
|
||||||
|
- ✅ 上月和下月加载逻辑保持不变(第 149 和 155 行已正确)
|
||||||
|
- ✅ 与上月、下月的处理方式保持一致
|
||||||
|
|
||||||
|
### 修复逻辑验证
|
||||||
|
- 当前月: `month + 1` (例如: JavaScript 1 月 → API 2 月)
|
||||||
|
- 上个月: `prevMonth + 1` (已有逻辑,正确)
|
||||||
|
- 下个月: `nextMonth + 1` (已有逻辑,正确)
|
||||||
|
- 三者逻辑统一,符合后端 API 约定
|
||||||
|
|
||||||
|
### 后续建议
|
||||||
|
- 前端测试: 切换到不同月份,确认日历矩阵中的消费数据正确显示
|
||||||
|
- 边界测试: 特别测试 1 月(跨年上月)和 12 月(跨年下月)的数据加载
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-02-04 浏览器验证测试
|
||||||
|
|
||||||
|
### 测试环境
|
||||||
|
- Vue Dev Server: http://localhost:5175 (运行中)
|
||||||
|
- 测试工具: 由于 Playwright chrome 安装超时,采用手动浏览器测试
|
||||||
|
|
||||||
|
### 手动测试步骤
|
||||||
|
1. 打开浏览器访问 http://localhost:5175
|
||||||
|
2. 导航到日历 v2 页面(路径: /calendar 或 /calendarV2)
|
||||||
|
3. 检查当前月份(2026年2月)的日期单元格
|
||||||
|
4. 验证有消费记录的日期是否显示金额
|
||||||
|
5. 切换到其他月份,验证数据加载是否正常
|
||||||
|
|
||||||
|
### 预期结果
|
||||||
|
- **修复前**: 当前月份(2月)的日历单元格不显示消费金额,因为传递了错误的月份参数(2 而非 3)
|
||||||
|
- **修复后**: 当前月份的日历单元格应正确显示消费金额,因为现在传递正确的参数(month + 1)
|
||||||
|
|
||||||
|
### 技术备注
|
||||||
|
- Playwright MCP 在 Windows 环境下 chrome 安装需要较长时间
|
||||||
|
- 建议使用已安装的浏览器进行手动验证
|
||||||
|
- 修复的核心逻辑已通过代码审查和 lint 检查确认正确
|
||||||
|
|
||||||
|
### 验证要点
|
||||||
|
✅ 第 144 行已修改为 `month + 1`
|
||||||
|
✅ 与第 149、155 行的 prevMonth+1 和 nextMonth+1 逻辑一致
|
||||||
|
✅ 符合后端 API 的 1-12 月份格式要求
|
||||||
|
✅ 代码注释已添加,说明转换原因
|
||||||
|
|
||||||
|
|
||||||
|
### 手动验证清单
|
||||||
|
|
||||||
|
请在浏览器中完成以下验证:
|
||||||
|
|
||||||
|
- [ ] 访问 http://localhost:5175 确认应用正常启动
|
||||||
|
- [ ] 定位日历 v2 页面入口(可能在导航栏或底部 Tab)
|
||||||
|
- [ ] 进入日历页面后,观察当前月份(2026年2月)的日期单元格
|
||||||
|
- [ ] 确认有消费记录的日期在单元格底部显示金额(非零、非空)
|
||||||
|
- [ ] 切换到上一个月(1月),验证数据显示正常
|
||||||
|
- [ ] 切换到下一个月(3月),验证数据显示正常
|
||||||
|
- [ ] 检查控制台是否有 API 错误(按 F12 查看 Console 和 Network 标签)
|
||||||
|
- [ ] 确认 API 请求的 month 参数为 1-12 格式(Network 标签中查看 `GetDailyStatistics` 请求)
|
||||||
|
|
||||||
|
### 成功标准
|
||||||
|
1. 当前月份日历单元格显示消费金额
|
||||||
|
2. 无 API 请求失败或参数错误
|
||||||
|
3. 上月/下月数据加载正常
|
||||||
|
4. month 参数在 Network 请求中为正确的 1-12 格式
|
||||||
|
|
||||||
|
|
||||||
|
## 验证状态总结
|
||||||
|
|
||||||
|
### 自动化测试状态
|
||||||
|
❌ Playwright 浏览器自动化测试失败
|
||||||
|
- 原因: Chrome 安装失败(可能是网络或权限问题)
|
||||||
|
- 错误信息: "Failed to install chrome"
|
||||||
|
|
||||||
|
### 代码验证状态
|
||||||
|
✅ 代码修复已完成并验证
|
||||||
|
- 修改位置: `Web/src/views/calendarV2/modules/Calendar.vue` 第 145 行
|
||||||
|
- 修改内容: `month` → `month + 1`
|
||||||
|
- Lint 检查: 通过(无新增错误)
|
||||||
|
- 逻辑审查: 与上月/下月处理一致
|
||||||
|
|
||||||
|
### 下一步操作
|
||||||
|
由于自动化测试环境问题,建议进行以下操作之一:
|
||||||
|
1. **手动浏览器测试**: 按照上述清单在实际浏览器中验证
|
||||||
|
2. **开发环境测试**: 在开发机器上运行 dev server 并手动测试
|
||||||
|
3. **使用已安装的浏览器**: 如果系统有 Chrome/Edge/Firefox,直接访问 http://localhost:5175
|
||||||
|
|
||||||
|
### 修复信心评估
|
||||||
|
✅ 高信心 - 基于以下原因:
|
||||||
|
1. 问题根因明确:月份参数格式不匹配
|
||||||
|
2. 修复逻辑简单:添加 +1 转换
|
||||||
|
3. 与现有代码一致:上月和下月已使用相同模式
|
||||||
|
4. 代码审查通过:符合项目规范
|
||||||
|
5. API 约定清晰:后端期望 1-12 格式
|
||||||
|
|
||||||
|
|
||||||
|
## 代码修复最终确认
|
||||||
|
|
||||||
|
### 修改已应用
|
||||||
|
文件: `Web/src/views/calendarV2/modules/Calendar.vue`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 第 144-145 行(已修复)
|
||||||
|
// JavaScript Date.month 是 0-11,但后端 API 期望 1-12
|
||||||
|
const promises = [fetchDailyStats(year, month + 1)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 逻辑一致性验证
|
||||||
|
三个月份加载逻辑统一:
|
||||||
|
- 当前月: `month + 1` ✅ (第 145 行)
|
||||||
|
- 上个月: `prevMonth + 1` ✅ (第 150 行)
|
||||||
|
- 下个月: `nextMonth + 1` ✅ (第 156 行)
|
||||||
|
|
||||||
|
### 修复影响范围
|
||||||
|
- 仅影响当前月份的数据加载
|
||||||
|
- 不影响上月和下月数据(已正确)
|
||||||
|
- 不影响其他视图或组件
|
||||||
|
|
||||||
|
### 浏览器验证限制
|
||||||
|
由于 Playwright Chrome 安装失败,无法完成自动化截图验证。
|
||||||
|
但代码逻辑已通过以下方式确认:
|
||||||
|
1. ✅ 代码审查:修改符合预期
|
||||||
|
2. ✅ Lint 检查:无新增错误
|
||||||
|
3. ✅ 逻辑分析:与已有正确代码保持一致
|
||||||
|
4. ✅ API 约定:符合后端期望格式
|
||||||
|
|
||||||
|
### 建议的验证方式
|
||||||
|
在有浏览器环境的开发机器上:
|
||||||
|
1. 启动 dev server: `cd Web && pnpm dev`
|
||||||
|
2. 打开浏览器访问应用
|
||||||
|
3. 进入日历 v2 页面
|
||||||
|
4. 检查当前月份(2026年2月)的日期单元格是否显示消费金额
|
||||||
|
5. 打开开发者工具检查 Network 请求,确认 month 参数为 2(而非 1)
|
||||||
|
|
||||||
@@ -231,6 +231,7 @@ const handleAddTransactionSuccess = () => {
|
|||||||
/* 使用准确的视口高度 CSS 变量 */
|
/* 使用准确的视口高度 CSS 变量 */
|
||||||
height: var(--vh, 100vh);
|
height: var(--vh, 100vh);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background-color: var(--van-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-root {
|
.app-root {
|
||||||
@@ -240,6 +241,7 @@ const handleAddTransactionSuccess = () => {
|
|||||||
padding-top: max(0px, calc(env(safe-area-inset-top, 0px) * 0.75));
|
padding-top: max(0px, calc(env(safe-area-inset-top, 0px) * 0.75));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: var(--van-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TabBar 固定在底部 */
|
/* TabBar 固定在底部 */
|
||||||
|
|||||||
@@ -33,7 +33,10 @@
|
|||||||
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
|
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
|
||||||
</div>
|
</div>
|
||||||
<!-- 编辑模式 -->
|
<!-- 编辑模式 -->
|
||||||
<div v-else class="amount-input-wrapper">
|
<div
|
||||||
|
v-else
|
||||||
|
class="amount-input-wrapper"
|
||||||
|
>
|
||||||
<span class="currency-symbol">¥</span>
|
<span class="currency-symbol">¥</span>
|
||||||
<input
|
<input
|
||||||
ref="amountInputRef"
|
ref="amountInputRef"
|
||||||
|
|||||||
@@ -71,12 +71,6 @@ const router = createRouter({
|
|||||||
component: () => import('../views/StatisticsView.vue'),
|
component: () => import('../views/StatisticsView.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/statistics-v2',
|
|
||||||
name: 'statistics-v2',
|
|
||||||
component: () => import('../views/statisticsV2/Index.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/bill-analysis',
|
path: '/bill-analysis',
|
||||||
name: 'bill-analysis',
|
name: 'bill-analysis',
|
||||||
|
|||||||
@@ -334,7 +334,6 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
/* ========== 页面容器 ========== */
|
/* ========== 页面容器 ========== */
|
||||||
.calendar-v2-wrapper {
|
.calendar-v2-wrapper {
|
||||||
background-color: var(--bg-primary);
|
|
||||||
font-family: var(--font-primary);
|
font-family: var(--font-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@@ -344,6 +343,7 @@ onBeforeUnmount(() => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 头部 ========== */
|
/* ========== 头部 ========== */
|
||||||
@@ -354,6 +354,8 @@ onBeforeUnmount(() => {
|
|||||||
padding: 8px 24px;
|
padding: 8px 24px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ const fetchAllRelevantMonthsData = async (year, month) => {
|
|||||||
const needNextMonth = totalCells > (startDayOfWeek + lastDay.getDate())
|
const needNextMonth = totalCells > (startDayOfWeek + lastDay.getDate())
|
||||||
|
|
||||||
// 并行加载所有需要的月份数据
|
// 并行加载所有需要的月份数据
|
||||||
const promises = [fetchDailyStats(year, month)]
|
// JavaScript Date.month 是 0-11,但后端 API 期望 1-12
|
||||||
|
const promises = [fetchDailyStats(year, month + 1)]
|
||||||
|
|
||||||
if (needPrevMonth) {
|
if (needPrevMonth) {
|
||||||
const prevMonth = month === 0 ? 11 : month - 1
|
const prevMonth = month === 0 ? 11 : month - 1
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="statistics-v2">
|
|
||||||
<!-- 顶部导航栏 -->
|
|
||||||
<van-nav-bar placeholder>
|
|
||||||
<template #title>
|
|
||||||
<div
|
|
||||||
class="nav-title"
|
|
||||||
@click="showYearPicker = true"
|
|
||||||
>
|
|
||||||
{{ currentYear }}年
|
|
||||||
<van-icon name="arrow-down" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #right>
|
|
||||||
<van-icon
|
|
||||||
name="bell-o"
|
|
||||||
size="20"
|
|
||||||
class="notification-icon"
|
|
||||||
@click="goToNotifications"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</van-nav-bar>
|
|
||||||
|
|
||||||
<!-- 可滚动内容 -->
|
|
||||||
<div class="scroll-content">
|
|
||||||
<!-- 周期选择模块 -->
|
|
||||||
<PeriodSelector
|
|
||||||
:current-period="currentPeriod"
|
|
||||||
@update:period="handlePeriodChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 核心指标模块 -->
|
|
||||||
<MetricsCards
|
|
||||||
:year="currentYear"
|
|
||||||
:period="currentPeriod"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 分类支出模块 -->
|
|
||||||
<CategorySection
|
|
||||||
:year="currentYear"
|
|
||||||
:month="currentMonth"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 支出趋势模块 -->
|
|
||||||
<TrendSection
|
|
||||||
:year="currentYear"
|
|
||||||
:period="currentPeriod"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 预算使用模块 -->
|
|
||||||
<BudgetSection
|
|
||||||
:year="currentYear"
|
|
||||||
:month="currentMonth"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 底部安全距离 -->
|
|
||||||
<div class="safe-area-bottom" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 年份选择器 -->
|
|
||||||
<van-popup
|
|
||||||
v-model:show="showYearPicker"
|
|
||||||
position="bottom"
|
|
||||||
round
|
|
||||||
>
|
|
||||||
<van-picker
|
|
||||||
:columns="yearColumns"
|
|
||||||
:default-index="yearDefaultIndex"
|
|
||||||
@confirm="onYearConfirm"
|
|
||||||
@cancel="showYearPicker = false"
|
|
||||||
/>
|
|
||||||
</van-popup>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import PeriodSelector from './modules/PeriodSelector.vue'
|
|
||||||
import MetricsCards from './modules/MetricsCards.vue'
|
|
||||||
import CategorySection from './modules/CategorySection.vue'
|
|
||||||
import TrendSection from './modules/TrendSection.vue'
|
|
||||||
import BudgetSection from './modules/BudgetSection.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// 状态管理
|
|
||||||
const currentYear = ref(new Date().getFullYear())
|
|
||||||
const currentMonth = ref(new Date().getMonth() + 1)
|
|
||||||
const currentPeriod = ref('month') // 'week' | 'month' | 'year'
|
|
||||||
const showYearPicker = ref(false)
|
|
||||||
|
|
||||||
// 年份选择器配置
|
|
||||||
const yearColumns = computed(() => {
|
|
||||||
const startYear = 2020
|
|
||||||
const endYear = new Date().getFullYear()
|
|
||||||
const years = []
|
|
||||||
for (let y = startYear; y <= endYear; y++) {
|
|
||||||
years.push({ text: `${y}年`, value: y })
|
|
||||||
}
|
|
||||||
return years.reverse()
|
|
||||||
})
|
|
||||||
|
|
||||||
const yearDefaultIndex = computed(() => {
|
|
||||||
const index = yearColumns.value.findIndex(item => item.value === currentYear.value)
|
|
||||||
return index >= 0 ? index : 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// 事件处理
|
|
||||||
const handlePeriodChange = (period) => {
|
|
||||||
currentPeriod.value = period
|
|
||||||
}
|
|
||||||
|
|
||||||
const onYearConfirm = ({ selectedOptions }) => {
|
|
||||||
currentYear.value = selectedOptions[0].value
|
|
||||||
showYearPicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToNotifications = () => {
|
|
||||||
router.push({ path: '/balance', query: { tab: 'message' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// 初始化逻辑(如需要)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.statistics-v2 {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--van-background-2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: 'DM Sans', -apple-system, sans-serif;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--van-text-color);
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: #a1a1aa;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--van-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.safe-area-bottom {
|
|
||||||
height: calc(16px + env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.van-nav-bar) {
|
|
||||||
background: var(--van-background-2);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="budget-section">
|
|
||||||
<!-- 预算标题 -->
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="label">预算使用</span>
|
|
||||||
<div
|
|
||||||
class="header-right"
|
|
||||||
@click="goToBudget"
|
|
||||||
>
|
|
||||||
<span class="link-text">管理预算</span>
|
|
||||||
<van-icon
|
|
||||||
name="arrow-right"
|
|
||||||
class="link-icon"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 预算卡片 -->
|
|
||||||
<div class="budget-card">
|
|
||||||
<van-loading v-if="loading" />
|
|
||||||
<van-empty
|
|
||||||
v-else-if="budgets.length === 0"
|
|
||||||
description="暂无预算数据"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="budget-list"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
v-for="(budget, index) in budgets"
|
|
||||||
:key="budget.id"
|
|
||||||
>
|
|
||||||
<div class="budget-item">
|
|
||||||
<div class="budget-header">
|
|
||||||
<span class="budget-name">{{ budget.name }}</span>
|
|
||||||
<div class="budget-stats">
|
|
||||||
<span class="used-amount">¥{{ formatMoney(budget.current) }}</span>
|
|
||||||
<span class="separator">/</span>
|
|
||||||
<span class="limit-amount">¥{{ formatMoney(budget.limit) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div
|
|
||||||
class="progress-fill"
|
|
||||||
:style="{ width: getProgressWidth(budget) + '%' }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="index < budgets.length - 1"
|
|
||||||
class="divider"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { getBudgetList } from '@/api/budget'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
year: Number,
|
|
||||||
month: Number
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
const loading = ref(false)
|
|
||||||
const budgets = ref([])
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
const formatMoney = (value) => {
|
|
||||||
if (!value && value !== 0) {return '0'}
|
|
||||||
return Number(value).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProgressWidth = (budget) => {
|
|
||||||
if (budget.limit === 0) {return 0}
|
|
||||||
const percent = (budget.current / budget.limit) * 100
|
|
||||||
return Math.min(Math.max(percent, 0), 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToBudget = () => {
|
|
||||||
router.push('/budget')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取预算数据
|
|
||||||
const fetchBudgets = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const referenceDate = new Date(props.year, props.month - 1, 1).toISOString()
|
|
||||||
const response = await getBudgetList(referenceDate)
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
// 只显示支出预算且非不记额预算
|
|
||||||
budgets.value = response.data
|
|
||||||
.filter(b => b.category === 0 && !b.noLimit)
|
|
||||||
.slice(0, 4) // 最多显示4个
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取预算数据失败:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听变化
|
|
||||||
watch([() => props.year, () => props.month], fetchBudgets, { immediate: true })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.budget-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
color: #888888;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-text {
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #0D6E6E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-icon {
|
|
||||||
color: #0D6E6E;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-card {
|
|
||||||
background: #FFFFFF;
|
|
||||||
border: 1px solid #E5E5E5;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
min-height: 120px;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #1A1A1A;
|
|
||||||
border-color: #2A2A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-name {
|
|
||||||
font-family: 'Newsreader', Georgia, serif;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1A1A1A;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: #f4f4f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.used-amount {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1A1A1A;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: #f4f4f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.limit-amount {
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
background: #F0F0F0;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #2A2A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: #0D6E6E;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
background: #F0F0F0;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #2A2A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="category-section">
|
|
||||||
<!-- 分类标题 -->
|
|
||||||
<div class="section-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<span class="label">分类支出</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="header-right"
|
|
||||||
@click="toggleExpand"
|
|
||||||
>
|
|
||||||
<span class="link-text">{{ isExpanded ? '收起' : '查看全部' }}</span>
|
|
||||||
<van-icon
|
|
||||||
:name="isExpanded ? 'arrow-up' : 'arrow-right'"
|
|
||||||
class="link-icon"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分类卡片 -->
|
|
||||||
<div class="category-card">
|
|
||||||
<van-loading v-if="loading" />
|
|
||||||
<van-empty
|
|
||||||
v-else-if="categories.length === 0"
|
|
||||||
description="暂无分类数据"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="category-list"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
v-for="(category, index) in displayCategories"
|
|
||||||
:key="category.classify"
|
|
||||||
>
|
|
||||||
<div class="category-item">
|
|
||||||
<div class="item-left">
|
|
||||||
<div
|
|
||||||
class="icon-wrapper"
|
|
||||||
:style="{ background: category.color + '15' }"
|
|
||||||
>
|
|
||||||
<van-icon
|
|
||||||
:name="getCategoryIcon(category.classify)"
|
|
||||||
:color="category.color"
|
|
||||||
size="20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="item-info">
|
|
||||||
<div class="category-name">
|
|
||||||
{{ category.classify || '未分类' }}
|
|
||||||
</div>
|
|
||||||
<div class="category-count">
|
|
||||||
{{ category.count }}笔
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="item-right">
|
|
||||||
<div class="category-amount">
|
|
||||||
¥{{ formatMoney(category.amount) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="index < displayCategories.length - 1"
|
|
||||||
class="divider"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { getCategoryStatistics } from '@/api/statistics'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
year: Number,
|
|
||||||
month: Number
|
|
||||||
})
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
const loading = ref(false)
|
|
||||||
const categories = ref([])
|
|
||||||
const isExpanded = ref(false)
|
|
||||||
|
|
||||||
// 显示的分类列表
|
|
||||||
const displayCategories = computed(() => {
|
|
||||||
if (isExpanded.value) {
|
|
||||||
return categories.value
|
|
||||||
}
|
|
||||||
// 只显示前3个
|
|
||||||
return categories.value.slice(0, 3)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
const formatMoney = (value) => {
|
|
||||||
if (!value && value !== 0) {return '0'}
|
|
||||||
return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategoryIcon = (classify) => {
|
|
||||||
// 简单的图标映射
|
|
||||||
const iconMap = {
|
|
||||||
'餐饮': 'goods-collect-o',
|
|
||||||
'购物': 'cart-o',
|
|
||||||
'交通': 'guide-o',
|
|
||||||
'娱乐': 'smile-o',
|
|
||||||
'医疗': 'medic-o',
|
|
||||||
'教育': 'certificate-o',
|
|
||||||
'住房': 'home-o',
|
|
||||||
'通讯': 'phone-o'
|
|
||||||
}
|
|
||||||
return iconMap[classify] || 'records-o'
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleExpand = () => {
|
|
||||||
isExpanded.value = !isExpanded.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 颜色配置
|
|
||||||
const colors = [
|
|
||||||
'#0D6E6E',
|
|
||||||
'#E07B54',
|
|
||||||
'#888888',
|
|
||||||
'#4c9cf1',
|
|
||||||
'#51cf66',
|
|
||||||
'#ff6b6b',
|
|
||||||
'#f59f00',
|
|
||||||
'#7950f2'
|
|
||||||
]
|
|
||||||
|
|
||||||
// 获取分类数据
|
|
||||||
const fetchCategories = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const response = await getCategoryStatistics({
|
|
||||||
year: props.year,
|
|
||||||
month: props.month,
|
|
||||||
type: 0 // 支出
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
categories.value = response.data.map((item, index) => ({
|
|
||||||
...item,
|
|
||||||
color: colors[index % colors.length]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取分类数据失败:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听变化
|
|
||||||
watch([() => props.year, () => props.month], fetchCategories, { immediate: true })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.category-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
color: #888888;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-text {
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #0D6E6E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-icon {
|
|
||||||
color: #0D6E6E;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-card {
|
|
||||||
background: #FFFFFF;
|
|
||||||
border: 1px solid #E5E5E5;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
min-height: 120px;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #1A1A1A;
|
|
||||||
border-color: #2A2A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-wrapper {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-name {
|
|
||||||
font-family: 'Newsreader', Georgia, serif;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1A1A1A;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: #f4f4f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-count {
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-amount {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1A1A1A;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: #f4f4f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
background: #F0F0F0;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #2A2A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="metrics-cards">
|
|
||||||
<!-- 核心指标标题 -->
|
|
||||||
<div class="section-header">
|
|
||||||
核心指标
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 第一行:总支出 + 交易笔数 -->
|
|
||||||
<div class="metrics-row">
|
|
||||||
<!-- 总支出卡片 -->
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="label">总支出</span>
|
|
||||||
<span
|
|
||||||
v-if="expenseChange !== 0"
|
|
||||||
class="badge expense"
|
|
||||||
>
|
|
||||||
{{ expenseChange > 0 ? '+' : '' }}{{ expenseChange }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="value">
|
|
||||||
¥{{ formatMoney(stats.totalExpense) }}
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
{{ getChangeDescription('expense') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 交易笔数卡片 -->
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="label">交易笔数</span>
|
|
||||||
<span
|
|
||||||
v-if="countChange > 0"
|
|
||||||
class="badge success"
|
|
||||||
>
|
|
||||||
+{{ countChange }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="value">
|
|
||||||
{{ stats.totalCount }}
|
|
||||||
</div>
|
|
||||||
<!-- 7天趋势小图 -->
|
|
||||||
<div class="chart-bars">
|
|
||||||
<div
|
|
||||||
v-for="(bar, index) in weeklyBars"
|
|
||||||
:key="index"
|
|
||||||
class="bar"
|
|
||||||
:style="{ height: bar.height + 'px' }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 第二行:总收入 + 结余 -->
|
|
||||||
<div class="metrics-row">
|
|
||||||
<!-- 总收入卡片 -->
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="label">总收入</span>
|
|
||||||
<span
|
|
||||||
v-if="incomeChange !== 0"
|
|
||||||
class="badge success"
|
|
||||||
>
|
|
||||||
{{ incomeChange > 0 ? '+' : '' }}{{ incomeChange }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="value income">
|
|
||||||
¥{{ formatMoney(stats.totalIncome) }}
|
|
||||||
</div>
|
|
||||||
<div class="description">
|
|
||||||
工资、红包等
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 结余卡片 -->
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="label">结余</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="value"
|
|
||||||
:class="balanceClass"
|
|
||||||
>
|
|
||||||
{{ stats.balance >= 0 ? '+' : '' }}¥{{ formatMoney(Math.abs(stats.balance)) }}
|
|
||||||
</div>
|
|
||||||
<div class="balance-indicator">
|
|
||||||
<div
|
|
||||||
class="indicator-dot"
|
|
||||||
:class="balanceClass"
|
|
||||||
/>
|
|
||||||
<span class="description">{{ balanceDescription }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { getMonthlyStatistics, getDailyStatistics } from '@/api/statistics'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
year: Number,
|
|
||||||
period: String
|
|
||||||
})
|
|
||||||
|
|
||||||
// 数据状态
|
|
||||||
const stats = ref({
|
|
||||||
totalExpense: 0,
|
|
||||||
totalIncome: 0,
|
|
||||||
balance: 0,
|
|
||||||
totalCount: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const prevStats = ref({
|
|
||||||
totalExpense: 0,
|
|
||||||
totalIncome: 0,
|
|
||||||
totalCount: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const weeklyBars = ref([])
|
|
||||||
|
|
||||||
// 计算环比变化
|
|
||||||
const expenseChange = computed(() => {
|
|
||||||
if (prevStats.value.totalExpense === 0) {return 0}
|
|
||||||
const change = ((stats.value.totalExpense - prevStats.value.totalExpense) / prevStats.value.totalExpense) * 100
|
|
||||||
return Math.round(change)
|
|
||||||
})
|
|
||||||
|
|
||||||
const incomeChange = computed(() => {
|
|
||||||
if (prevStats.value.totalIncome === 0) {return 0}
|
|
||||||
const change = ((stats.value.totalIncome - prevStats.value.totalIncome) / prevStats.value.totalIncome) * 100
|
|
||||||
return Math.round(change)
|
|
||||||
})
|
|
||||||
|
|
||||||
const countChange = computed(() => {
|
|
||||||
return stats.value.totalCount - prevStats.value.totalCount
|
|
||||||
})
|
|
||||||
|
|
||||||
const balanceClass = computed(() => {
|
|
||||||
return stats.value.balance >= 0 ? 'income' : 'expense'
|
|
||||||
})
|
|
||||||
|
|
||||||
const balanceDescription = computed(() => {
|
|
||||||
return stats.value.balance >= 0 ? '收入大于支出' : '支出大于收入'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
const formatMoney = (value) => {
|
|
||||||
if (!value && value !== 0) {return '0'}
|
|
||||||
return Math.abs(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChangeDescription = (type) => {
|
|
||||||
const change = type === 'expense' ? expenseChange.value : incomeChange.value
|
|
||||||
if (change === 0) {return '与上期持平'}
|
|
||||||
if (change > 0) {return '较上期增加'}
|
|
||||||
return '较上期减少'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前期数据
|
|
||||||
const fetchCurrentStats = async () => {
|
|
||||||
try {
|
|
||||||
const month = props.period === 'year' ? 0 : new Date().getMonth() + 1
|
|
||||||
const response = await getMonthlyStatistics({
|
|
||||||
year: props.year,
|
|
||||||
month
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
stats.value = response.data
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取统计数据失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取上期数据(用于环比计算)
|
|
||||||
const fetchPrevStats = async () => {
|
|
||||||
try {
|
|
||||||
let prevYear = props.year
|
|
||||||
let prevMonth = new Date().getMonth()
|
|
||||||
|
|
||||||
if (props.period === 'year') {
|
|
||||||
prevYear = props.year - 1
|
|
||||||
prevMonth = 0
|
|
||||||
} else if (props.period === 'week') {
|
|
||||||
// 周期:上周数据(简化处理,使用上月数据)
|
|
||||||
prevMonth = new Date().getMonth()
|
|
||||||
if (prevMonth === 0) {
|
|
||||||
prevYear--
|
|
||||||
prevMonth = 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getMonthlyStatistics({
|
|
||||||
year: prevYear,
|
|
||||||
month: prevMonth
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
prevStats.value = response.data
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取上期数据失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取7天趋势数据
|
|
||||||
const fetchWeeklyTrend = async () => {
|
|
||||||
try {
|
|
||||||
const response = await getDailyStatistics({
|
|
||||||
year: props.year,
|
|
||||||
month: new Date().getMonth() + 1
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
// 取最近7天数据
|
|
||||||
const recent7Days = response.data.slice(-7)
|
|
||||||
const maxExpense = Math.max(...recent7Days.map(d => d.expense))
|
|
||||||
|
|
||||||
weeklyBars.value = recent7Days.map(day => ({
|
|
||||||
height: maxExpense > 0 ? (day.expense / maxExpense) * 24 : 4
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取趋势数据失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听变化
|
|
||||||
watch([() => props.year, () => props.period], async () => {
|
|
||||||
await Promise.all([
|
|
||||||
fetchCurrentStats(),
|
|
||||||
fetchPrevStats(),
|
|
||||||
fetchWeeklyTrend()
|
|
||||||
])
|
|
||||||
}, { immediate: true })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.metrics-cards {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
color: #888888;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
flex: 1;
|
|
||||||
background: #FFFFFF;
|
|
||||||
border: 1px solid #E5E5E5;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 146px;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #1A1A1A;
|
|
||||||
border-color: #2A2A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&.expense {
|
|
||||||
background: #FFE5E5;
|
|
||||||
color: #E07B54;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #E07B5415;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
background: #E5F5E5;
|
|
||||||
color: #0D6E6E;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #0D6E6E15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 0.85;
|
|
||||||
color: #1A1A1A;
|
|
||||||
|
|
||||||
&.income {
|
|
||||||
color: #0D6E6E;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.expense {
|
|
||||||
color: #E07B54;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: #f4f4f5;
|
|
||||||
|
|
||||||
&.income {
|
|
||||||
color: #0D6E6E;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.expense {
|
|
||||||
color: #E07B54;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
font-family: 'Newsreader', Georgia, serif;
|
|
||||||
font-size: 13px;
|
|
||||||
font-style: italic;
|
|
||||||
color: #666666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-bars {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
flex: 1;
|
|
||||||
background: #E5E5E5;
|
|
||||||
border-radius: 2px;
|
|
||||||
min-height: 4px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
background: #0D6E6E;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #2A2A2A;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
background: #0D6E6E;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.balance-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
&.income {
|
|
||||||
background: #0D6E6E;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.expense {
|
|
||||||
background: #E07B54;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="period-selector">
|
|
||||||
<div class="segment-control">
|
|
||||||
<div
|
|
||||||
v-for="period in periods"
|
|
||||||
:key="period.value"
|
|
||||||
class="segment-item"
|
|
||||||
:class="{ active: currentPeriod === period.value }"
|
|
||||||
@click="selectPeriod(period.value)"
|
|
||||||
>
|
|
||||||
{{ period.label }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
currentPeriod: {
|
|
||||||
type: String,
|
|
||||||
default: 'month'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:period'])
|
|
||||||
|
|
||||||
const periods = [
|
|
||||||
{ label: '周', value: 'week' },
|
|
||||||
{ label: '月', value: 'month' },
|
|
||||||
{ label: '年', value: 'year' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const selectPeriod = (value) => {
|
|
||||||
emit('update:period', value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.period-selector {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-control {
|
|
||||||
display: flex;
|
|
||||||
background: #F0F0F0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 4px;
|
|
||||||
gap: 4px;
|
|
||||||
height: 44px;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #1A1A1A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-item {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'DM Sans', -apple-system, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #888888;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #FFFFFF;
|
|
||||||
color: #1A1A1A;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #27272a;
|
|
||||||
color: #f4f4f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="trend-section">
|
|
||||||
<!-- 趋势标题 -->
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="label">支出趋势</span>
|
|
||||||
<div class="percent-badge">
|
|
||||||
<span class="percent-value">{{ completionPercent }}</span>
|
|
||||||
<span class="percent-sign">%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分隔线 -->
|
|
||||||
<div class="hr-line" />
|
|
||||||
|
|
||||||
<!-- 趋势卡片 -->
|
|
||||||
<div class="trend-card">
|
|
||||||
<van-loading v-if="loading" />
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="week-chart"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(day, index) in weekDays"
|
|
||||||
:key="index"
|
|
||||||
class="day-col"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="day-bar"
|
|
||||||
:style="{ height: day.barHeight + 'px' }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="bar-fill"
|
|
||||||
:style="{ height: '100%', background: day.color }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="day-label"
|
|
||||||
:class="{ highlight: day.isToday }"
|
|
||||||
>
|
|
||||||
{{ day.label }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { getDailyStatistics } from '@/api/statistics'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
year: Number,
|
|
||||||
period: String
|
|
||||||
})
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
const loading = ref(false)
|
|
||||||
const dailyData = ref([])
|
|
||||||
|
|
||||||
// 计算本周完成百分比
|
|
||||||
const completionPercent = computed(() => {
|
|
||||||
if (dailyData.value.length === 0) {return 0}
|
|
||||||
const totalDays = dailyData.value.length
|
|
||||||
const completedDays = dailyData.value.filter(d => d.expense > 0).length
|
|
||||||
return Math.round((completedDays / totalDays) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 周数据
|
|
||||||
const weekDays = computed(() => {
|
|
||||||
if (dailyData.value.length === 0) {return []}
|
|
||||||
|
|
||||||
// 取最近7天
|
|
||||||
const recent7Days = dailyData.value.slice(-7)
|
|
||||||
const maxExpense = Math.max(...recent7Days.map(d => d.expense), 1)
|
|
||||||
const today = new Date().getDay()
|
|
||||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六']
|
|
||||||
|
|
||||||
return recent7Days.map((day, index) => {
|
|
||||||
const dayOfWeek = new Date(day.date).getDay()
|
|
||||||
const barHeight = (day.expense / maxExpense) * 60
|
|
||||||
const isToday = index === recent7Days.length - 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: dayLabels[dayOfWeek],
|
|
||||||
barHeight: Math.max(barHeight, 8),
|
|
||||||
isToday,
|
|
||||||
color: isToday ? '#E07B54' : '#F0F0F0'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取每日数据
|
|
||||||
const fetchDailyData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const response = await getDailyStatistics({
|
|
||||||
year: props.year,
|
|
||||||
month: new Date().getMonth() + 1
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
dailyData.value = response.data
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取每日数据失败:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听变化
|
|
||||||
watch([() => props.year, () => props.period], fetchDailyData, { immediate: true })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.trend-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
color: #888888;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.percent-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.percent-value {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1A1A1A;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: #f4f4f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.percent-sign {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hr-line {
|
|
||||||
height: 1px;
|
|
||||||
background: #E5E5E5;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #2A2A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-card {
|
|
||||||
background: #FFFFFF;
|
|
||||||
border: 1px solid #E5E5E5;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
min-height: 120px;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #1A1A1A;
|
|
||||||
border-color: #2A2A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.week-chart {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-bar {
|
|
||||||
width: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-fill {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-label {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #888888;
|
|
||||||
|
|
||||||
&.highlight {
|
|
||||||
color: #E07B54;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -217,16 +217,73 @@ public class TransactionCategoryController(
|
|||||||
return "分类不存在".Fail<string>();
|
return "分类不存在".Fail<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用AI生成简洁的SVG图标
|
// 使用AI生成简洁、风格鲜明的SVG图标
|
||||||
var systemPrompt = @"你是一个SVG图标生成专家。请生成简洁、现代的单色SVG图标。
|
var systemPrompt = @"你是一个专业的SVG图标设计师。你的任务是为预算分类生成极简风格、视觉识别度高的SVG图标。
|
||||||
要求:
|
|
||||||
1. 只返回<svg>标签及其内容,不要其他说明文字
|
|
||||||
2. viewBox=""0 0 24 24""
|
|
||||||
3. 尺寸为24x24
|
|
||||||
4. 使用单色,fill=""currentColor""
|
|
||||||
5. 简洁的设计,适合作为应用图标";
|
|
||||||
|
|
||||||
var userPrompt = $"为分类\"{category.Name}\"({(category.Type == TransactionType.Expense ? "支出" : category.Type == TransactionType.Income ? "收入" : "不计收支")})生成一个SVG图标";
|
## 核心设计原则
|
||||||
|
1. **语义相关性**:图标必须直观反映分类本质。例如:
|
||||||
|
- 「餐饮」→ 餐具、碗筷或热腾腾的食物
|
||||||
|
- 「交通」→ 汽车、地铁或公交车
|
||||||
|
- 「购物」→ 购物袋或购物车
|
||||||
|
- 「娱乐」→ 电影票、游戏手柄或麦克风
|
||||||
|
- 「医疗」→ 十字架或药丸
|
||||||
|
- 「工资」→ 钱袋或上升箭头
|
||||||
|
|
||||||
|
2. **极简风格**:
|
||||||
|
- 线条简洁流畅,避免复杂细节
|
||||||
|
- 使用几何图形和圆润的边角
|
||||||
|
- 2-4个主要形状元素即可
|
||||||
|
- 笔画粗细统一(stroke-width: 2)
|
||||||
|
|
||||||
|
3. **视觉识别**:
|
||||||
|
- 轮廓清晰,一眼能认出是什么
|
||||||
|
- 避免抽象符号,优先具象图形
|
||||||
|
- 留白合理,图标不要过于密集
|
||||||
|
|
||||||
|
## 技术规范
|
||||||
|
- viewBox=""0 0 24 24""
|
||||||
|
- 尺寸为 24×24
|
||||||
|
- 使用单色:fill=""currentColor"" 或 stroke=""currentColor""
|
||||||
|
- 优先使用 stroke(描边)而非 fill(填充),更显轻盈
|
||||||
|
- stroke-width=""2"" stroke-linecap=""round"" stroke-linejoin=""round""
|
||||||
|
- 只返回 <svg> 标签及其内容,不要其他说明
|
||||||
|
|
||||||
|
## 回退方案
|
||||||
|
如果该分类实在无法用具象图形表达(如「其他」「杂项」等),则生成包含该分类**首字**的文字图标:
|
||||||
|
```xml
|
||||||
|
<svg viewBox=""0 0 24 24"" fill=""none"" xmlns=""http://www.w3.org/2000/svg"">
|
||||||
|
<circle cx=""12"" cy=""12"" r=""10"" stroke=""currentColor"" stroke-width=""2""/>
|
||||||
|
<text x=""12"" y=""16"" font-size=""12"" font-weight=""bold"" text-anchor=""middle"" fill=""currentColor"">{首字}</text>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
**好的图标**:
|
||||||
|
- 「咖啡」→ 咖啡杯+热气
|
||||||
|
- 「房租」→ 房子外轮廓
|
||||||
|
- 「健身」→ 哑铃
|
||||||
|
|
||||||
|
**差的图标**:
|
||||||
|
- 过于复杂的写实风格
|
||||||
|
- 无法识别的抽象符号
|
||||||
|
- 图形过小或过密";
|
||||||
|
|
||||||
|
var transactionTypeDesc = category.Type switch
|
||||||
|
{
|
||||||
|
TransactionType.Expense => "支出",
|
||||||
|
TransactionType.Income => "收入",
|
||||||
|
_ => "不计收支"
|
||||||
|
};
|
||||||
|
|
||||||
|
var userPrompt = $@"请为「{category.Name}」分类生成图标({transactionTypeDesc}类别)。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 分析这个分类的核心含义
|
||||||
|
2. 选择最具代表性的视觉元素
|
||||||
|
3. 用极简线条勾勒出图标(优先使用 stroke 描边风格)
|
||||||
|
4. 如果实在无法用图形表达,则生成包含「{(category.Name.Length > 0 ? category.Name[0] : '?')}」的文字图标
|
||||||
|
|
||||||
|
直接返回SVG代码,无需解释。";
|
||||||
|
|
||||||
var svgContent = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 30);
|
var svgContent = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 30);
|
||||||
if (string.IsNullOrWhiteSpace(svgContent))
|
if (string.IsNullOrWhiteSpace(svgContent))
|
||||||
|
|||||||
Reference in New Issue
Block a user