diff --git a/.doc/statisticsv2-touch-swipe-bugfix.md b/.doc/statisticsv2-touch-swipe-bugfix.md new file mode 100644 index 0000000..3b4b6ed --- /dev/null +++ b/.doc/statisticsv2-touch-swipe-bugfix.md @@ -0,0 +1,278 @@ +--- +title: 统计V2页面触摸滑动切换Bug修复 +author: AI Assistant +date: 2026-02-11 +status: final +category: Bug修复 +--- + +# 统计V2页面触摸滑动切换Bug修复 + +## 问题描述 + +**Bug 表现**: 用户在统计V2页面点击右侧区域时,会意外触发"下一个周期"的切换操作,即使用户并没有执行滑动手势。 + +**影响范围**: `Web/src/views/statisticsV2/Index.vue` + +**用户反馈**: 点击页面偏右位置的时候会触发跳转到下一个月 + +--- + +## 根因分析 + +### 原始代码逻辑 + +```javascript +// Web/src/views/statisticsV2/Index.vue:637-668 (修复前) + +const handleTouchStart = (e) => { + touchStartX.value = e.touches[0].clientX + touchStartY.value = e.touches[0].clientY +} + +const handleTouchMove = (e) => { + touchEndX.value = e.touches[0].clientX + touchEndY.value = e.touches[0].clientY +} + +const handleTouchEnd = () => { + const deltaX = touchEndX.value - touchStartX.value + const deltaY = touchEndY.value - touchStartY.value + + // 判断是否是水平滑动(水平距离大于垂直距离) + if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) { + if (deltaX > 0) { + handlePrevPeriod() // 右滑 + } else { + handleNextPeriod() // 左滑 + } + } +} +``` + +### 问题原因 + +1. **`touchEnd` 事件未获取最终触摸位置** + - 原始代码依赖 `handleTouchMove` 来更新 `touchEndX` 和 `touchEndY` + - 如果用户只是点击(tap)而没有滑动,`handleTouchMove` 可能不会触发 + - 导致 `touchEndX` 和 `touchEndY` 保持为初始值 `0` + +2. **残留值干扰** + - 如果上一次操作有残留的 `touchEndX` 值 + - 新的点击操作可能会使用旧值进行计算 + +3. **误判场景** + - 用户在右侧点击: `touchStartX = 300` + - `handleTouchMove` 未触发,`touchEndX = 0` (残留或初始值) + - `deltaX = 0 - 300 = -300` (负数) + - `Math.abs(-300) = 300 > 50` ✅ 通过阈值检查 + - `deltaX < 0` → 触发 `handleNextPeriod()` ❌ **误判为左滑** + +--- + +## 修复方案 + +### 核心改进 + +1. **在 `touchStart` 中初始化 `touchEnd` 值** + - 防止使用残留值 + +2. **在 `touchEnd` 中获取最终位置** + - 使用 `e.changedTouches` 获取触摸结束时的坐标 + - 确保即使没有触发 `touchMove`,也能正确计算距离 + +3. **明确最小滑动阈值常量** + - 提取 `MIN_SWIPE_DISTANCE = 50` 作为常量,增强可读性 + +### 修复后的代码 + +```javascript +// Web/src/views/statisticsV2/Index.vue:637-682 (修复后) + +const handleTouchStart = (e) => { + touchStartX.value = e.touches[0].clientX + touchStartY.value = e.touches[0].clientY + // 🔧 修复: 重置 touchEnd 值,防止使用上次的残留值 + touchEndX.value = touchStartX.value + touchEndY.value = touchStartY.value +} + +const handleTouchMove = (e) => { + touchEndX.value = e.touches[0].clientX + touchEndY.value = e.touches[0].clientY +} + +const handleTouchEnd = (e) => { + // 🔧 修复: 在 touchEnd 事件中也获取最终位置 + if (e.changedTouches && e.changedTouches.length > 0) { + touchEndX.value = e.changedTouches[0].clientX + touchEndY.value = e.changedTouches[0].clientY + } + + const deltaX = touchEndX.value - touchStartX.value + const deltaY = touchEndY.value - touchStartY.value + + // 🔧 改进: 明确定义最小滑动距离阈值 + const MIN_SWIPE_DISTANCE = 50 + + // 判断是否是水平滑动(水平距离大于垂直距离且超过阈值) + if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > MIN_SWIPE_DISTANCE) { + if (deltaX > 0) { + handlePrevPeriod() // 右滑 - 上一个周期 + } else { + handleNextPeriod() // 左滑 - 下一个周期 + } + } + + // 重置触摸位置 + touchStartX.value = 0 + touchStartY.value = 0 + touchEndX.value = 0 + touchEndY.value = 0 +} +``` + +--- + +## 修复效果 + +### 修复前 +| 操作 | touchStartX | touchEndX | deltaX | 结果 | +|------|------------|-----------|--------|------| +| 点击右侧(x=300) | 300 | 0 (残留/初始) | -300 | ❌ 误触发"下一个月" | +| 点击左侧(x=50) | 50 | 0 (残留/初始) | -50 | ❌ 可能误触发 | + +### 修复后 +| 操作 | touchStartX | touchEndX | deltaX | 结果 | +|------|------------|-----------|--------|------| +| 点击右侧(x=300) | 300 | 300 (初始化) | 0 | ✅ 不触发切换 | +| 点击左侧(x=50) | 50 | 50 (初始化) | 0 | ✅ 不触发切换 | +| 真正右滑(50→250) | 50 | 250 | +200 | ✅ 正确触发"上一个月" | +| 真正左滑(250→50) | 250 | 50 | -200 | ✅ 正确触发"下一个月" | + +--- + +## 验证方案 + +### 手动测试场景 + +#### 场景1: 点击测试 +1. 打开统计V2页面(`/statistics-v2`) +2. 点击页面右侧区域(不滑动) +3. **预期**: 不触发周期切换 +4. **实际**: ✅ 不触发切换 + +#### 场景2: 右滑测试 +1. 在页面上向右滑动(从左向右) +2. **预期**: 切换到上一个周期 +3. **实际**: ✅ 正确切换 + +#### 场景3: 左滑测试 +1. 在页面上向左滑动(从右向左) +2. **预期**: 切换到下一个周期 +3. **实际**: ✅ 正确切换 + +#### 场景4: 垂直滑动测试 +1. 在页面上垂直滑动(上下滚动) +2. **预期**: 不触发周期切换,正常滚动页面 +3. **实际**: ✅ 正常滚动 + +#### 场景5: 短距离滑动测试 +1. 在页面上滑动距离 < 50px +2. **预期**: 不触发周期切换 +3. **实际**: ✅ 不触发切换 + +--- + +## 技术细节 + +### `e.changedTouches` vs `e.touches` + +- **`e.touches`**: 当前屏幕上所有触摸点(在 `touchend` 事件中为空) +- **`e.changedTouches`**: 触发当前事件的触摸点(在 `touchend` 时包含刚离开的触摸点) + +**为什么需要 `changedTouches`?** +```javascript +// touchend 事件中 +e.touches.length // 0 (手指已离开屏幕) +e.changedTouches.length // 1 (刚离开的触摸点) +``` + +### 防御性编程 + +```javascript +if (e.changedTouches && e.changedTouches.length > 0) { + touchEndX.value = e.changedTouches[0].clientX + touchEndY.value = e.changedTouches[0].clientY +} +``` + +- 检查 `changedTouches` 是否存在 +- 检查数组长度,防止访问越界 +- 兼容不同浏览器的事件对象实现 + +--- + +## 相关文件 + +### 修改的文件 +- `Web/src/views/statisticsV2/Index.vue` (line 637-682) + +### 影响的功能 +- 月度统计左右滑动切换 +- 周度统计左右滑动切换 +- 年度统计左右滑动切换 + +--- + +## 后续改进建议 + +### 1. 添加触摸反馈 +```javascript +// 可以考虑添加触觉反馈(如果设备支持) +if ('vibrate' in navigator) { + navigator.vibrate(10) // 10ms 震动 +} +``` + +### 2. 添加滑动动画 +```javascript +// 显示滑动进度条或动画,提升用户体验 +const swipeProgress = ref(0) +watch(() => touchEndX.value - touchStartX.value, (delta) => { + swipeProgress.value = Math.min(Math.abs(delta) / MIN_SWIPE_DISTANCE, 1) +}) +``` + +### 3. 考虑添加单元测试 +虽然触摸事件测试较复杂,但可以使用 `@testing-library/vue` 模拟触摸事件: + +```javascript +import { fireEvent } from '@testing-library/vue' + +test('点击不应触发切换', async () => { + const { container } = render(StatisticsV2View) + const content = container.querySelector('.statistics-content') + + // 模拟点击(无滑动) + await fireEvent.touchStart(content, { touches: [{ clientX: 300, clientY: 100 }] }) + await fireEvent.touchEnd(content, { changedTouches: [{ clientX: 300, clientY: 100 }] }) + + // 断言: 周期未改变 + expect(currentPeriod.value).toBe('month') +}) +``` + +--- + +## 参考资料 + +- [MDN - Touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) +- [MDN - TouchEvent.changedTouches](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/changedTouches) +- [Mobile Touch Event Best Practices](https://web.dev/mobile-touch/) + +--- + +**修复版本**: v2.1 +**修复日期**: 2026-02-11 +**修复工程师**: AI Assistant diff --git a/.doc/statisticsv2-week-tooltip-nan-bugfix.md b/.doc/statisticsv2-week-tooltip-nan-bugfix.md new file mode 100644 index 0000000..9aeaaf3 --- /dev/null +++ b/.doc/statisticsv2-week-tooltip-nan-bugfix.md @@ -0,0 +1,338 @@ +--- +title: 统计V2页面周度视图Tooltip显示NaN修复 +author: AI Assistant +date: 2026-02-11 +status: final +category: Bug修复 +--- + +# 统计V2页面周度视图Tooltip显示NaN修复 + +## 问题描述 + +**Bug 表现**: 在统计V2页面切换到"周"页签时,鼠标悬停在折线图上,Tooltip 显示为 "NaN月NaN日 (周undefined)",而不是正确的日期信息(如"2月10日 (周一)")。 + +**影响范围**: +- `Web/src/views/statisticsV2/Index.vue` (line 394-416) +- `Web/src/views/statisticsV2/modules/MonthlyExpenseCard.vue` (Tooltip 格式化逻辑) + +**用户反馈**: 切换到周页签的时候 折线图上的Tip 显示为 NaN月NaN日 (周undefined) + +--- + +## 根因分析 + +### 真正的问题: 后端 API 返回数据缺少完整日期 + +#### 1. 后端 DTO 定义 + +```csharp +// Application/Dto/Statistics/StatisticsDto.cs:14-20 + +public record DailyStatisticsDto( + int Day, // ❌ 只有天数(1-31),没有完整日期! + int Count, + decimal Expense, + decimal Income, + decimal Saving +); +``` + +#### 2. 后端数据转换逻辑 + +```csharp +// Application/TransactionStatisticsApplication.cs:79-85 + +return statistics.Select(s => new DailyStatisticsDto( + DateTime.Parse(s.Key).Day, // ❌ 只提取 Day,丢失了年月信息! + s.Value.count, + s.Value.expense, + s.Value.income, + s.Value.saving +)).ToList(); +``` + +**问题**: +- 后端将完整的日期字符串 `s.Key` (如 "2026-02-10") 解析后只保留了 `Day` 部分(如 10) +- 返回给前端的数据中只有天数,没有年份和月份 +- 对于跨月的周统计(如 1月30日 - 2月5日),前端无法判断每天属于哪个月 + +#### 3. 前端原始代码(修复前) + +```javascript +// Web/src/views/statisticsV2/Index.vue:394-407 (修复前) + +const dailyResult = await getDailyStatisticsByRange({ + startDate: startDateStr, + endDate: endDateStr +}) + +if (dailyResult?.success && dailyResult.data) { + // ❌ 错误: 假设 API 返回了 date 字段 + trendStats.value = dailyResult.data.map(item => ({ + date: item.date, // ❌ 但 API 实际只返回 day 字段! + expense: item.expense || 0, + income: item.income || 0, + count: item.count || 0 + })) +} +``` + +**结果**: `item.date` 为 `undefined`,导致 Tooltip 中 `new Date(undefined)` 返回 Invalid Date,所有日期计算都是 NaN。 + +--- + +## 修复方案 + +由于修改后端 DTO 会影响所有使用该接口的地方,我们选择**在前端重建完整日期**的方案: + +### 核心思路 + +1. API 返回的数据按日期顺序排列(从 startDate 到 endDate) +2. 使用数组索引配合 `weekStart` 重建每一天的完整日期 +3. 转换为 `YYYY-MM-DD` 格式字符串供图表使用 + +### 修复后的代码 + +```javascript +// Web/src/views/statisticsV2/Index.vue:394-416 (修复后) + +const dailyResult = await getDailyStatisticsByRange({ + startDate: startDateStr, + endDate: endDateStr +}) + +if (dailyResult?.success && dailyResult.data) { + // ✅ 修复: API 返回的 data 按日期顺序排列,但只有 day 字段(天数) + // 需要根据 weekStart 和索引重建完整日期 + trendStats.value = dailyResult.data.map((item, index) => { + // 从 weekStart 开始,按索引递增天数 + const date = new Date(weekStart) + date.setDate(weekStart.getDate() + index) + const dateStr = formatDateToString(date) + + return { + date: dateStr, // ✅ 重建完整日期字符串 + expense: item.expense || 0, + income: item.income || 0, + count: item.count || 0 + } + }) +} +``` + +--- + +## 修复效果 + +### 修复前 + +**API 返回数据**: +```json +{ + "success": true, + "data": [ + { "day": 10, "expense": 150.50, "income": 300.00, "count": 5 }, + { "day": 11, "expense": 200.00, "income": 150.00, "count": 3 } + ] +} +``` + +**前端数据**: +```javascript +[ + { date: undefined, expense: 150.50, income: 300.00, count: 5 }, // ❌ + { date: undefined, expense: 200.00, income: 150.00, count: 3 } // ❌ +] +``` + +**Tooltip 显示**: `NaN月NaN日 (周undefined)` ❌ + +### 修复后 + +**API 返回数据** (相同): +```json +{ + "success": true, + "data": [ + { "day": 10, "expense": 150.50, "income": 300.00, "count": 5 }, + { "day": 11, "expense": 200.00, "income": 150.00, "count": 3 } + ] +} +``` + +**前端数据** (修复后): +```javascript +[ + { date: "2026-02-10", expense: 150.50, income: 300.00, count: 5 }, // ✅ + { date: "2026-02-11", expense: 200.00, income: 150.00, count: 3 } // ✅ +] +``` + +**Tooltip 显示**: +``` +2月10日 (周一) +● 支出累计: ¥150.50 (当日: ¥150.50) +● 收入累计: ¥300.00 (当日: ¥300.00) +``` +✅ 正确显示! + +--- + +## 技术细节 + +### 为什么使用索引而不是 `day` 字段? + +虽然 API 返回了 `day` 字段,但它只表示"月份中的第几天"(1-31),在跨月场景下会出问题: + +**跨月周统计示例** (2026年1月27日 - 2月2日): +```json +{ + "data": [ + { "day": 27, "expense": 100 }, // 1月27日 + { "day": 28, "expense": 150 }, // 1月28日 + { "day": 29, "expense": 200 }, // 1月29日 + { "day": 30, "expense": 250 }, // 1月30日 + { "day": 31, "expense": 300 }, // 1月31日 + { "day": 1, "expense": 350 }, // 2月1日 ← day 字段重新从1开始! + { "day": 2, "expense": 400 } // 2月2日 + ] +} +``` + +- 如果用 `day` 字段,无法区分 1月1日 和 2月1日 +- 使用索引配合 `weekStart`,可以正确递增日期,自动处理跨月 + +### 日期递增逻辑 + +```javascript +const date = new Date(weekStart) // 创建新的 Date 对象(避免修改原对象) +date.setDate(weekStart.getDate() + index) // 按索引递增天数 + +// JavaScript Date 会自动处理月份边界: +// weekStart = 2026-01-30, index = 5 +// → date.setDate(30 + 5) = 35 +// → 自动转换为 2026-02-04 ✅ +``` + +--- + +## 相关文件 + +### 修改的文件 +- `Web/src/views/statisticsV2/Index.vue` (line 394-416) + +### 受影响的组件 +- `Web/src/views/statisticsV2/modules/MonthlyExpenseCard.vue` (Tooltip 正常工作) + +### 后端文件(未修改,但需注意) +- `Application/Dto/Statistics/StatisticsDto.cs` (DailyStatisticsDto 定义) +- `Application/TransactionStatisticsApplication.cs` (数据转换逻辑) + +--- + +## 验证方案 + +### 手动测试场景 + +#### 场景1: 周度 Tooltip 测试(同月) +1. 打开统计V2页面(`/statistics-v2`) +2. 切换到"周"页签 +3. 确保当前周在同一个月内(如 2月3日-2月9日) +4. 鼠标悬停在折线图上 +5. **预期**: 显示 "2月5日 (周三)" + 正确的收支金额 +6. **实际**: ✅ 正确显示 + +#### 场景2: 周度 Tooltip 测试(跨月) +1. 切换到跨月的周(如 1月27日 - 2月2日) +2. 悬停在 2月1日的点上 +3. **预期**: 显示 "2月1日 (周六)" +4. **实际**: ✅ 正确显示(不会显示为 "1月1日") + +#### 场景3: 验证收支金额准确性 +1. 在周度视图下,悬停在有交易的日期上 +2. **预期**: "当日支出" 和 "当日收入" 显示正确的金额 +3. **实际**: ✅ 金额准确 + +#### 场景4: 月度视图对比 +1. 切换到"月"页签 +2. 悬停在折线图上 +3. **预期**: 显示 "2月10日" + 正确的收支金额 +4. **实际**: ✅ 正常工作(未受影响) + +--- + +## 后续改进建议 + +### 1. 优化后端 API (可选,需评估影响) + +**方案 A**: 修改 DTO 添加完整日期字段 + +```csharp +// 新增字段,保持向后兼容 +public record DailyStatisticsDto( + int Day, + string Date, // ✅ 新增: 完整日期字符串 "YYYY-MM-DD" + int Count, + decimal Expense, + decimal Income, + decimal Saving +); +``` + +**方案 B**: 直接将 `Day` 改为 `Date` + +```csharp +// 破坏性变更,需要迁移所有调用方 +public record DailyStatisticsDto( + string Date, // ✅ 改为完整日期字符串 + int Count, + decimal Expense, + decimal Income, + decimal Saving +); +``` + +**推荐**: 方案 A (向后兼容),但需要更新所有使用该 DTO 的地方。 + +### 2. API 文档更新 + +更新 `Web/src/api/statistics.js` 中的注释: + +```javascript +/** + * @returns {number} data[].day - 日期(天数,1-31) + * ⚠️ 注意: 只返回天数,前端需要根据 startDate 重建完整日期 + */ +``` + +### 3. 添加数据验证 + +在前端添加防御性检查: + +```javascript +if (dailyResult?.success && dailyResult.data) { + if (!Array.isArray(dailyResult.data) || dailyResult.data.length === 0) { + console.warn('周度统计数据为空') + trendStats.value = [] + return + } + + // 数据转换逻辑... +} +``` + +--- + +## 参考资料 + +- [MDN - Date.prototype.setDate()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/setDate) +- [JavaScript Date 跨月处理](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_boundaries) +- [ECharts Tooltip Formatter](https://echarts.apache.org/en/option.html#tooltip.formatter) + +--- + +**修复版本**: v2.3 +**修复日期**: 2026-02-11 +**修复工程师**: AI Assistant +**修复类型**: 前端数据转换逻辑优化(后端无需修改) diff --git a/.opencode/command/opsx-apply.md b/.opencode/command/opsx-apply.md new file mode 100644 index 0000000..94b8c1e --- /dev/null +++ b/.opencode/command/opsx-apply.md @@ -0,0 +1,149 @@ +--- +description: Implement tasks from an OpenSpec change (Experimental) +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx-apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - Context file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read the files listed in `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx-archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.