6 Commits

Author SHA1 Message Date
SunCheng
63aaaf39c5 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 19s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-02-04 19:23:07 +08:00
SunCheng
15f0ba0993 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 16:23:12 +08:00
SunCheng
f328c72ca0 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 16:07:08 +08:00
SunCheng
453007ab69 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 15:56:57 +08:00
SunCheng
fe7cb98410 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 35s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 15:36:42 +08:00
SunCheng
1a3d0658bb fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 15:31:22 +08:00
16 changed files with 831 additions and 1627 deletions

View 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. **功能不变** - 始终确保行为一致性

View File

@@ -32,120 +32,60 @@ metadata:
### 1. 现代移动端优先设计
**布局标准:**
**核心规范:**
- 移动视口: 375px 宽度 (iPhone SE 基准)
- 安全区域: 尊重 iOS/Android 安全区域边距
- 触摸目标: 交互元素最小 44x44px
- 间距比例: 4px, 8px, 12px, 16px, 24px, 32px (8px 基础网格)
- 圆角半径: 12px (卡片), 16px (对话框), 24px (药丸/标签), 8px (按钮)
- 卡片阴影: `0 2px 12px rgba(0,0,0,0.08)` 用于突出表面
- 交互元素最小触摸目标: 44x44px
- 间距基于 8px 网格: 4px, 8px, 12px, 16px, 24px, 32px
- 卡片阴影: `0 2px 12px rgba(0,0,0,0.08)` (亮色模式)
**避免 AI 设计痕迹:**
- 不要使用通用的 "Dashboard" 占位符文本
- 不要使用图库照片或 Lorem Ipsum
- 不要使用过饱和的主色 (#007AFF, 不是 #0088FF)
- 不要使用生硬的阴影或渐变
- 不要使用 Comic Sans, Papyrus 或装饰性字体
- 使用代码库中的真实中文业务术语
** AI 设计痕迹检查清单:**
- ❌ 使用 "Dashboard", "Lorem Ipsum" 等通用占位符
- ❌ 使用过饱和的颜色或生硬的渐变
- ❌ 使用装饰性字体 (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`
- **主文本**: 亮色 `#1A1A1A`, 暗色 `#F4F4F5`
- **次要文本**: 亮色 `#6B7280`, 暗色 `#A1A1AA`
- **支出/负数**: `#FF6B6B` (两种模式一致)
- **主操作按钮**: `#3B82F6` (两种模式一致)
- **圆角**: 12px (小按钮), 16px (卡片), 20px (统计卡片), 22px (图标按钮)
- **阴影**: 亮色使用柔和阴影, 暗色使用更深的阴影或无阴影
- **避免**: 纯黑 (#000000) 或纯白 (#FFFFFF) 文本
- 始终使用语义颜色变量,避免硬编码十六进制值
- 支出/负数统一使用红色 `#FF6B6B`,收入/正数使用绿色系
- 主操作按钮统一使用蓝色 `#3B82F6`
- 避免纯黑 (#000000) 或纯白 (#FFFFFF) 文本,使用柔和的色调
- 暗色模式下减少阴影强度或完全移除
- 详细色值参见文末"快速参考"表格
### 3. 排版系统 (基于实际 v2.pen 设计)
### 3. 排版系统
**字体栈:**
```css
/* 标题与大标题 - 基于 v2.pen */
font-family: 'Bricolage Grotesque', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
- **标题**: `'Bricolage Grotesque'` - 用于大数值、章节标题
- **正文**: `'DM Sans'` - 用于界面文本、说明
- **数字**: `'DIN Alternate'` - 用于金额、数据显示
- **备选**: `-apple-system, 'PingFang SC'` - 系统默认字体
/* 正文与界面 - 基于 v2.pen */
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
/* 备选: 系统默认 */
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)
**排版原则:**
- 使用真实中文业务术语,避免 Lorem Ipsum
- 行高: 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 (暗色)
@@ -207,23 +147,16 @@ font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB
**布局模式 (基于 Calendar 结构):**
```
页面容器:
- 宽度: 402px (设计视口)
- 布局: 垂直
- 内边距: 24px (容器边距)
- 间距: 16px (章节之间)
头部区域:
- 内边距: 8px 24px
- 布局: 水平, 两端对齐
- 对齐项: 居中
内容区域:
- 内边距: 24px
- 间距: 12-16px
- 布局: 垂直
页面容器: 402px (设计视口), 垂直布局, 24px 内边距
头部区域: 水平布局, 两端对齐, 8px 24px 内边距
内容区域: 垂直布局, 24px 内边距, 12-16px 间距
```
**关键布局原则:**
- 遵循 Flex 容器模式 (见下方"5. 布局模式")
- 导航栏背景必须透明 (`:deep(.van-nav-bar) { background: transparent !important; }`)
- 尊重安全区域 (`env(safe-area-inset-bottom)`)
### 5. 布局模式
**页面结构 (Flex 容器):**
@@ -241,17 +174,17 @@ font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB
4. bottom-button 或 van-tabbar (固定)
```
**导航栏背景透明化 (一致性模式):**
**导航栏背景透明化 (项目标准模式):**
```css
/* 设置页面容器背景色 */
/* 所有页面统一设置 */
:deep(.van-nav-bar) {
background: transparent !important;
}
```
**重要:** 项目中所有视图都采用此模式,使导航栏背景透明,与页面背景融合。实现此效果时,请确保:
- 页面容器有明确的背景色 (亮色: #FFFFFF, 暗色: #09090B)
- 导航栏始终使用 `:deep(.van-nav-bar)` 选择器
- 必须添加 `!important` 覆盖 Vant 默认样式
**关键要求:**
- 页面容器必须有明确的背景色
- 必须使用 `:deep()` 选择器覆盖 Vant 样式
- 必须添加 `!important` 标记
-`<style scoped>` 块中添加此规则
**安全区域处理:**
@@ -407,42 +340,23 @@ document.documentElement.setAttribute('data-theme', theme.value)
- 示例: "Budget/ListView", "Budget/EditForm"
```
**图层命名:**
```
内部元素可用中文:
✅ 预算卡片背景
✅ 金额文本
✅ 操作按钮组
组件用英文:
✅ CardBackground
✅ AmountLabel
✅ ActionButtons
```
### 10. 质量检查清单
**完成设计前:**
- [ ] 同时创建亮色和暗色主题
- [ ] 所有文本使用真实中文业务术语 (无 Lorem Ipsum)
**设计完成前必检项:**
- [ ] 同时创建亮色和暗色主题
- [ ] 使用真实中文业务术语 (无占位文本)
- [ ] 交互元素 ≥ 44x44px
- [ ] 间距遵循 8px 网格
- [ ] 颜色使用语义变量 (非硬编码十六进制)
- [ ] 圆角: 12px/16px/24px 保持一致
- [ ] 尊重安全区域边距 (底部: 50px + 安全区域)
- [ ] 帧正确命名 模块/屏幕/变体
- [ ] 可复用组件标记为 `reusable: true`
- [ ] 字号匹配字号比例 (12/14/16/18/24)
- [ ] 数字数据使用 DIN Alternate
- [ ] 无 AI 生成的占位内容
- [ ] 已截图并视觉验证
- [ ] 导航栏背景设置为透明 (`:deep(.van-nav-bar) { background: transparent !important; }`)
- [ ] 使用语义颜色变量 (非硬编码)
- [ ] 导航栏背景透明 (`:deep(.van-nav-bar)`)
- [ ] 帧命名: 模块-屏幕-变体 格式
- [ ] 可复用组件标记 `reusable: true`
- [ ] 两种主题截图验证
**无障碍:**
**无障碍标准:**
- [ ] 正文对比度 ≥ 4.5:1
- [ ] 大文本对比度 ≥ 3:1 (18px+)
- [ ] 触摸目标清晰分离 (最小 8px 间距)
- [ ] 颜色不是信息的唯一指示器
- [ ] 触摸目标间距 ≥ 8px
## PANCLI 工作流程
@@ -804,9 +718,10 @@ delegate_task(
)
```
## 快速参考 (基于实际 v2.pen 设计)
## 快速参考
**颜色面板 (基于实际 v2.pen 设计):**
**颜色面板:**
| 名称 | 亮色 | 暗色 | 用途 |
|------|------|------|------|
| 页面背景 | #FFFFFF | #09090B | 页面背景 |
@@ -821,7 +736,8 @@ delegate_task(
| 绿色 | #F0FDF4 | #064E3B | 收入标签 |
| 蓝色 | #E0E7FF | #312E81 | 信息标签 |
**排版 (实际字体):**
**排版比例:**
| 用途 | 字体 | 大小 | 粗细 |
|------|------|------|------|
| 大数值 | Bricolage Grotesque | 32px | 800 |
@@ -831,26 +747,19 @@ delegate_task(
| 说明 | DM Sans | 13px | 500 |
| 微型标签 | DM Sans | 12px | 600 |
**间距与布局 (实际数值):**
- 容器内边距: 24px (主区域), 20px (卡片), 16px (小卡片)
- 间距比例: 2px, 4px, 8px, 12px, 14px, 16px
- 图标大小: 20px
- 图标按钮: 44x44px
- FAB 按钮: 56x56px
**组件规格:**
**圆角 (实际数值):**
- 小按钮/标签: 12px
- 卡片: 16px, 20px
- 图标按钮: 22px (圆形)
- FAB: 28px (圆形)
**触摸目标:** 最小 44x44px
**视口:** 402px (设计宽度), 移动端优先
- **容器内边距**: 24px (主区域), 20px (卡片), 16px (小卡片)
- **间距比例**: 2px, 4px, 8px, 12px, 14px, 16px
- **圆角**: 12px (标签), 16px/20px (卡片), 22px/28px (圆形按钮)
- **图标**: 20px
- **图标按钮**: 44x44px
- **FAB 按钮**: 56x56px
- **触摸目标**: 最小 44x44px
- **设计视口**: 402px 宽度
---
**版本:** 2.0.0 (使用 v2.pen 实际设计值更新)
**最后更新:** 2026-02-03
**来源:** .pans/v2.pen 日历 (亮色/暗色)
**版本:** 2.0.0
**最后更新:** 2026-02-04
**维护者:** EmailBill 设计团队

View 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 当前月份数据加载
- **用户可见变化:** 当前月份的日期单元格将正确显示消费金额
- **副作用:** 无

View 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

View File

@@ -231,6 +231,7 @@ const handleAddTransactionSuccess = () => {
/* 使用准确的视口高度 CSS 变量 */
height: var(--vh, 100vh);
width: 100%;
background-color: var(--van-background);
}
.app-root {
@@ -240,6 +241,7 @@ const handleAddTransactionSuccess = () => {
padding-top: max(0px, calc(env(safe-area-inset-top, 0px) * 0.75));
box-sizing: border-box;
overflow: hidden;
background-color: var(--van-background);
}
/* TabBar 固定在底部 */

View File

@@ -33,7 +33,10 @@
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
</div>
<!-- 编辑模式 -->
<div v-else class="amount-input-wrapper">
<div
v-else
class="amount-input-wrapper"
>
<span class="currency-symbol">¥</span>
<input
ref="amountInputRef"

View File

@@ -71,12 +71,6 @@ const router = createRouter({
component: () => import('../views/StatisticsView.vue'),
meta: { requiresAuth: true }
},
{
path: '/statistics-v2',
name: 'statistics-v2',
component: () => import('../views/statisticsV2/Index.vue'),
meta: { requiresAuth: true }
},
{
path: '/bill-analysis',
name: 'bill-analysis',

View File

@@ -334,7 +334,6 @@ onBeforeUnmount(() => {
/* ========== 页面容器 ========== */
.calendar-v2-wrapper {
background-color: var(--bg-primary);
font-family: var(--font-primary);
color: var(--text-primary);
}
@@ -344,6 +343,7 @@ onBeforeUnmount(() => {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background-color: var(--bg-primary);
}
/* ========== 头部 ========== */
@@ -354,6 +354,8 @@ onBeforeUnmount(() => {
padding: 8px 24px;
gap: 8px;
background: transparent !important;
position: relative;
z-index: 1;
}
.header-content {

View File

@@ -141,7 +141,8 @@ const fetchAllRelevantMonthsData = async (year, month) => {
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) {
const prevMonth = month === 0 ? 11 : month - 1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -217,16 +217,73 @@ public class TransactionCategoryController(
return "分类不存在".Fail<string>();
}
// 使用AI生成简洁的SVG图标
var systemPrompt = @"你是一个SVG图标生成专家。请生成简洁、现代的单色SVG图标。
要求:
1. 只返回<svg>标签及其内容,不要其他说明文字
2. viewBox=""0 0 24 24""
3. 尺寸为24x24
4. 使用单色fill=""currentColor""
5. 简洁的设计,适合作为应用图标";
// 使用AI生成简洁、风格鲜明的SVG图标
var systemPrompt = @"你是一个专业的SVG图标设计师。你的任务是为预算分类生成极简风格、视觉识别度高的SVG图标。
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);
if (string.IsNullOrWhiteSpace(svgContent))