From a89d4f240245857c30070b41045a38f440076fe0 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 17 Mar 2026 13:35:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E8=AE=A1=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=88=86=E7=B1=BB=E7=AD=9B=E9=80=89=E4=B8=8E?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统计页面添加分类多选筛选功能 - 点击分类可跳转到搜索页面查看详情 - 搜索页面添加 URL 同步与自动搜索 - 提取 useCategories hook 消除重复代码 - 修复自定义分类图标显示问题 - 添加 useMemo 优化计算性能 Co-Authored-By: Claude Opus 4.6 --- ...2026-03-17-stats-category-filter-design.md | 100 ++++++++++++++++ frontend/hooks/useCategories.js | 45 ++++++++ frontend/pages/recurring.js | 9 +- frontend/pages/search.js | 46 ++++++-- frontend/pages/stats.js | 96 ++++++++------- frontend/styles/globals.css | 109 ++++++++++++++++-- 6 files changed, 345 insertions(+), 60 deletions(-) create mode 100644 docs/superpowers/specs/2026-03-17-stats-category-filter-design.md create mode 100644 frontend/hooks/useCategories.js diff --git a/docs/superpowers/specs/2026-03-17-stats-category-filter-design.md b/docs/superpowers/specs/2026-03-17-stats-category-filter-design.md new file mode 100644 index 0000000..ccfe2f5 --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-stats-category-filter-design.md @@ -0,0 +1,100 @@ +# 统计界面分类筛选功能设计 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在统计界面添加分类多选功能,支持按分类筛选查看支出/收入明细 + +**Architecture:** 在现有统计页面基础上,增加分类多选状态管理,在分类明细区域显示选中分类的标签chips,并过滤显示对应分类的数据 + +**Tech Stack:** React (Next.js), Chart.js + +--- + +## 需求概述 + +用户在统计界面可以多选分类,查看特定分类的支出/收入明细。 + +### 界面布局 + +``` +┌─────────────────────────────────┐ +│ ← 2026年3月 → │ +├─────────────────────────────────┤ +│ [周] [月] [年] │ +│ ┌─────────────────────────┐ │ +│ │ 收支趋势折线图 │ │ +│ └─────────────────────────┘ │ +├─────────────────────────────────┤ +│ [支出] [收入] │ +│ ┌─────────────────────────┐ │ +│ │ 支出 ¥3200 │ │ ← 切换到支出时显示支出总额 +│ ├─────────────────────────┤ │ +│ │ 餐饮 ████████ 500 │ │ +│ │ 交通 ████ 300 │ │ ← 分类明细 +│ │ 购物 ██ 150 │ │ +│ └─────────────────────────┘ │ +└─────────────────────────────────┘ +``` + +### 交互逻辑 + +1. **分类类型切换**:点击「支出」/「收入」tab,显示对应类型的总额 + 分类明细 +2. **分类多选**: + - 分类明细区域显示所有分类(可点击) + - 点击某个分类 → 添加到已选列表(显示为chip标签) + - 点击已选分类的 ✕ 按钮 → 从已选列表移除 +3. **数据过滤**: + - 没有选中任何分类 → 显示全部分类 + - 选中部分分类 → 只显示选中的分类 +4. **总额变化**:根据选中的分类,计算并显示对应分类的总金额 + +--- + +## 实现方案 + +### 需要修改的文件 + +- `frontend/pages/stats.js` - 主页面逻辑 + +### 状态管理 + +```javascript +// 新增状态 +const [selectedCategories, setSelectedCategories] = useState([]); // 已选中的分类数组 +``` + +### 逻辑实现 + +1. **分类点击处理**: + - 如果分类已在 selectedCategories 中,忽略 + - 否则添加到 selectedCategories + +2. **移除分类**: + - 从 selectedCategories 中过滤掉对应分类 + +3. **数据过滤**: + - 如果 selectedCategories 为空,显示全部 categoryData + - 否则过滤只显示 selectedCategories 中的分类 + +4. **总额计算**: + - 根据当前过滤后的数据计算总额 + +### UI 修改 + +1. **分类明细区域**: + - 在分类明细列表上方添加「总额显示」(如:支出 ¥3200) + - 将分类名称改为可点击的 chip 样式 + - 已选中的分类显示 ✕ 按钮 + +2. **移除饼图**:按照需求去掉饼图显示 + +--- + +## 验收标准 + +1. 切换「支出」/「收入」tab 时,正确显示对应类型的总额 +2. 点击分类可选中/取消选中,选中的分类显示为 chip 标签 +3. 分类明细只显示选中的分类(如果有选中) +4. 没有选中分类时,显示全部分类 +5. 总额根据选中的分类动态变化 +6. 页面响应流畅,无明显卡顿 diff --git a/frontend/hooks/useCategories.js b/frontend/hooks/useCategories.js new file mode 100644 index 0000000..21630f7 --- /dev/null +++ b/frontend/hooks/useCategories.js @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react'; +import { getCategories, isLoggedIn } from '../lib/api'; +import { DEFAULT_CATEGORIES } from '../lib/categories'; + +export function useCategories() { + const [categories, setCategories] = useState(DEFAULT_CATEGORIES); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!isLoggedIn()) { + setLoading(false); + return; + } + + const loadCategories = async () => { + try { + const categoriesData = await getCategories(); + if (categoriesData) { + setCategories({ + expense: [ + ...DEFAULT_CATEGORIES.expense, + ...(categoriesData.expense || []).filter( + c => !DEFAULT_CATEGORIES.expense.some(d => d.name === c.name) + ) + ], + income: [ + ...DEFAULT_CATEGORIES.income, + ...(categoriesData.income || []).filter( + c => !DEFAULT_CATEGORIES.income.some(d => d.name === c.name) + ) + ], + }); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + loadCategories(); + }, []); + + return { categories, loading }; +} diff --git a/frontend/pages/recurring.js b/frontend/pages/recurring.js index dd4c8a1..b337c26 100644 --- a/frontend/pages/recurring.js +++ b/frontend/pages/recurring.js @@ -1,16 +1,17 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/router'; import { isLoggedIn, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } from '../lib/api'; -import { DEFAULT_CATEGORIES } from '../lib/categories'; import AppShell from '../components/AppShell'; import { Icon } from '../components/icons'; import { useToast } from '../components/ToastProvider'; import { useConfirm } from '../components/ConfirmProvider'; +import { useCategories } from '../hooks/useCategories'; export default function Recurring() { const router = useRouter(); const { showToast } = useToast(); const { confirm } = useConfirm(); + const { categories } = useCategories(); const [bills, setBills] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [editingBill, setEditingBill] = useState(null); @@ -197,8 +198,8 @@ export default function Recurring() {
{bills.map((bill) => { - const categories = DEFAULT_CATEGORIES[bill.type]; - const cat = categories.find((item) => item.name === bill.category) || { icon: '📝' }; + const categoryList = categories[bill.type]; + const cat = categoryList.find((item) => item.name === bill.category) || { icon: '📝' }; return ( - + +
+ + {/* 总额显示 */} +
+ {categoryType === 'expense' ? '支出' : '收入'} + ¥{filteredTotalAmount.toFixed(2)} +
+ + {/* 分类选择chips */} +
+ {categoryData.map((cat) => ( + + ))} +
+
- {categoryData.length > 0 ? categoryData.map((cat, index) => { - const percent = totalCategoryAmount ? ((cat.total / totalCategoryAmount) * 100).toFixed(1) : '0.0'; + {filteredCategoryData.length > 0 ? filteredCategoryData.map((cat, index) => { + const percent = filteredTotalAmount ? ((cat.total / filteredTotalAmount) * 100).toFixed(1) : '0.0'; return ( -
+ ); }) :
暂无数据
}
diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index 683a75a..ffa133d 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -1207,14 +1207,6 @@ textarea:focus-visible { gap: 10px; } -.search-stat { - padding: 13px; - background: rgba(246, 248, 252, 0.82); - border: 1px solid rgba(148, 163, 184, 0.14); - display: grid; - gap: 6px; -} - .section-card--search .results-count { padding: 0 2px; font-weight: 600; @@ -1591,6 +1583,18 @@ textarea:focus-visible { background: rgba(246, 248, 252, 0.8); } +.stats-category-item--clickable { + cursor: pointer; + border: none; + width: 100%; + text-align: left; + transition: background 0.2s ease; +} + +.stats-category-item--clickable:hover { + background: rgba(37, 99, 235, 0.1); +} + .stats-category-header { display: flex; justify-content: space-between; @@ -1622,6 +1626,95 @@ textarea:focus-visible { background: linear-gradient(90deg, var(--primary), #60a5fa); } +/* 统计页面总额显示 */ +.stats-total { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--card-bg); + border-radius: 8px; + margin-bottom: 12px; +} + +.stats-total-label { + font-size: 14px; + color: var(--text-secondary); +} + +.stats-total-amount { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} + +/* 分类选择chips */ +.category-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.category-chip { + padding: 6px 12px; + border-radius: 16px; + border: 1px solid var(--border-color); + background: var(--card-bg); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} + +.category-chip:hover { + border-color: var(--primary); + color: var(--primary); +} + +.category-chip.selected { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +/* 已选分类标签 */ +.selected-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.selected-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px 4px 12px; + border-radius: 16px; + background: var(--primary); + color: white; + font-size: 13px; +} + +.selected-chip-remove { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.3); + color: white; + cursor: pointer; + padding: 0; +} + +.selected-chip-remove:hover { + background: rgba(255, 255, 255, 0.5); +} + .category-item .category-name { white-space: nowrap; overflow: hidden;