feat: 统计页面添加分类筛选与搜索优化
- 统计页面添加分类多选筛选功能 - 点击分类可跳转到搜索页面查看详情 - 搜索页面添加 URL 同步与自动搜索 - 提取 useCategories hook 消除重复代码 - 修复自定义分类图标显示问题 - 添加 useMemo 优化计算性能 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b5f9a238bb
commit
a89d4f2402
@@ -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. 页面响应流畅,无明显卡顿
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { isLoggedIn, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } from '../lib/api';
|
import { isLoggedIn, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } from '../lib/api';
|
||||||
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
|
||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
import { Icon } from '../components/icons';
|
import { Icon } from '../components/icons';
|
||||||
import { useToast } from '../components/ToastProvider';
|
import { useToast } from '../components/ToastProvider';
|
||||||
import { useConfirm } from '../components/ConfirmProvider';
|
import { useConfirm } from '../components/ConfirmProvider';
|
||||||
|
import { useCategories } from '../hooks/useCategories';
|
||||||
|
|
||||||
export default function Recurring() {
|
export default function Recurring() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
const { categories } = useCategories();
|
||||||
const [bills, setBills] = useState([]);
|
const [bills, setBills] = useState([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingBill, setEditingBill] = useState(null);
|
const [editingBill, setEditingBill] = useState(null);
|
||||||
@@ -197,8 +198,8 @@ export default function Recurring() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bills-list">
|
<div className="bills-list">
|
||||||
{bills.map((bill) => {
|
{bills.map((bill) => {
|
||||||
const categories = DEFAULT_CATEGORIES[bill.type];
|
const categoryList = categories[bill.type];
|
||||||
const cat = categories.find((item) => item.name === bill.category) || { icon: '📝' };
|
const cat = categoryList.find((item) => item.name === bill.category) || { icon: '📝' };
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={bill.id}
|
key={bill.id}
|
||||||
@@ -304,7 +305,7 @@ export default function Recurring() {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>分类</label>
|
<label>分类</label>
|
||||||
<div className="category-grid">
|
<div className="category-grid">
|
||||||
{DEFAULT_CATEGORIES[formData.type].map((cat) => (
|
{categories[formData.type].map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={cat.name}
|
key={cat.name}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { isLoggedIn, searchRecords, updateRecord, deleteRecord } from '../lib/api';
|
import { isLoggedIn, searchRecords, updateRecord, deleteRecord } from '../lib/api';
|
||||||
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
|
||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
import { Icon } from '../components/icons';
|
import { Icon } from '../components/icons';
|
||||||
import RecordModal from '../components/RecordModal';
|
import RecordModal from '../components/RecordModal';
|
||||||
import { useToast } from '../components/ToastProvider';
|
import { useToast } from '../components/ToastProvider';
|
||||||
import { useConfirm } from '../components/ConfirmProvider';
|
import { useConfirm } from '../components/ConfirmProvider';
|
||||||
|
import { useCategories } from '../hooks/useCategories';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const { categories } = useCategories();
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -35,6 +36,28 @@ export default function Search() {
|
|||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// 检查 URL 参数,自动搜索
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.isReady && router.query.q) {
|
||||||
|
const q = decodeURIComponent(router.query.q);
|
||||||
|
setKeyword(q);
|
||||||
|
// 自动搜索
|
||||||
|
const doSearch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await searchRecords(q);
|
||||||
|
setResults(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast(err.message, { tone: 'error' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
doSearch();
|
||||||
|
}
|
||||||
|
}, [router.isReady, router.query.q]);
|
||||||
|
|
||||||
const handleSearch = async (e) => {
|
const handleSearch = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!keyword.trim()) {
|
if (!keyword.trim()) {
|
||||||
@@ -42,6 +65,9 @@ export default function Search() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新 URL
|
||||||
|
router.push(`/search?q=${encodeURIComponent(keyword)}`, undefined, { shallow: true });
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await searchRecords(keyword);
|
const data = await searchRecords(keyword);
|
||||||
@@ -142,6 +168,7 @@ export default function Search() {
|
|||||||
const formatMoney = (num) => (num || 0).toFixed(2);
|
const formatMoney = (num) => (num || 0).toFixed(2);
|
||||||
|
|
||||||
const groupedResults = useMemo(() => {
|
const groupedResults = useMemo(() => {
|
||||||
|
if (!Array.isArray(results)) return {};
|
||||||
return results.reduce((groups, record) => {
|
return results.reduce((groups, record) => {
|
||||||
const date = record.date.split(' ')[0];
|
const date = record.date.split(' ')[0];
|
||||||
if (!groups[date]) groups[date] = [];
|
if (!groups[date]) groups[date] = [];
|
||||||
@@ -156,6 +183,7 @@ export default function Search() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { totalIncome, totalExpense, totalBalance } = useMemo(() => {
|
const { totalIncome, totalExpense, totalBalance } = useMemo(() => {
|
||||||
|
if (!Array.isArray(results)) return { totalIncome: 0, totalExpense: 0, totalBalance: 0 };
|
||||||
let income = 0;
|
let income = 0;
|
||||||
let expense = 0;
|
let expense = 0;
|
||||||
for (const record of results) {
|
for (const record of results) {
|
||||||
@@ -165,16 +193,16 @@ export default function Search() {
|
|||||||
return { totalIncome: income, totalExpense: expense, totalBalance: income - expense };
|
return { totalIncome: income, totalExpense: expense, totalBalance: income - expense };
|
||||||
}, [results]);
|
}, [results]);
|
||||||
|
|
||||||
// O(1) category icon lookup map
|
// O(1) category icon lookup map (includes custom categories)
|
||||||
const categoryIconMap = useMemo(() => {
|
const categoryIconMap = useMemo(() => {
|
||||||
const icons = {};
|
const icons = {};
|
||||||
for (const type of ['expense', 'income']) {
|
for (const type of ['expense', 'income']) {
|
||||||
for (const cat of DEFAULT_CATEGORIES[type]) {
|
for (const cat of categories[type]) {
|
||||||
icons[`${type}:${cat.name}`] = cat.icon;
|
icons[`${type}:${cat.name}`] = cat.icon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return icons;
|
return icons;
|
||||||
}, []);
|
}, [categories]);
|
||||||
|
|
||||||
const actions = (
|
const actions = (
|
||||||
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
||||||
@@ -209,16 +237,16 @@ export default function Search() {
|
|||||||
|
|
||||||
{results.length > 0 ? (
|
{results.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="search-summary">
|
<div className="stats-grid stats-grid--hero">
|
||||||
<div className="search-stat">
|
<div className="stat-card">
|
||||||
<span className="label">收入</span>
|
<span className="label">收入</span>
|
||||||
<span className="value income">+{formatMoney(totalIncome)}</span>
|
<span className="value income">+{formatMoney(totalIncome)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="search-stat">
|
<div className="stat-card">
|
||||||
<span className="label">支出</span>
|
<span className="label">支出</span>
|
||||||
<span className="value expense">-{formatMoney(totalExpense)}</span>
|
<span className="value expense">-{formatMoney(totalExpense)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="search-stat">
|
<div className="stat-card stat-card--balance">
|
||||||
<span className="label">结余</span>
|
<span className="label">结余</span>
|
||||||
<span className="value">{formatMoney(totalBalance)}</span>
|
<span className="value">{formatMoney(totalBalance)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +311,7 @@ export default function Search() {
|
|||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
deleting={deleting}
|
deleting={deleting}
|
||||||
editingRecord={editingRecord}
|
editingRecord={editingRecord}
|
||||||
categories={DEFAULT_CATEGORIES}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
+57
-39
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
|
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
|
||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js';
|
||||||
@@ -16,12 +16,11 @@ export default function Stats() {
|
|||||||
const [trendData, setTrendData] = useState([]);
|
const [trendData, setTrendData] = useState([]);
|
||||||
const [categoryData, setCategoryData] = useState([]);
|
const [categoryData, setCategoryData] = useState([]);
|
||||||
const [categoryType, setCategoryType] = useState('expense');
|
const [categoryType, setCategoryType] = useState('expense');
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState([]);
|
||||||
const [trendLoading, setTrendLoading] = useState(true);
|
const [trendLoading, setTrendLoading] = useState(true);
|
||||||
const [categoryLoading, setCategoryLoading] = useState(true);
|
const [categoryLoading, setCategoryLoading] = useState(true);
|
||||||
const trendChartRef = useRef(null);
|
const trendChartRef = useRef(null);
|
||||||
const categoryChartRef = useRef(null);
|
|
||||||
const trendChartInstance = useRef(null);
|
const trendChartInstance = useRef(null);
|
||||||
const categoryChartInstance = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
@@ -112,35 +111,6 @@ export default function Stats() {
|
|||||||
}
|
}
|
||||||
}, [trendData]);
|
}, [trendData]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (categoryChartInstance.current) {
|
|
||||||
categoryChartInstance.current.destroy();
|
|
||||||
}
|
|
||||||
if (categoryChartRef.current && categoryData.length > 0) {
|
|
||||||
const ctx = categoryChartRef.current.getContext('2d');
|
|
||||||
const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1'];
|
|
||||||
categoryChartInstance.current = new Chart(ctx, {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: categoryData.map((item) => item.category),
|
|
||||||
datasets: [{
|
|
||||||
data: categoryData.map((item) => item.total),
|
|
||||||
backgroundColor: colors.slice(0, categoryData.length),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'bottom',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [categoryData]);
|
|
||||||
|
|
||||||
const prevMonth = () => {
|
const prevMonth = () => {
|
||||||
const newDate = new Date(currentMonth);
|
const newDate = new Date(currentMonth);
|
||||||
newDate.setMonth(newDate.getMonth() - 1);
|
newDate.setMonth(newDate.getMonth() - 1);
|
||||||
@@ -153,7 +123,29 @@ export default function Stats() {
|
|||||||
setCurrentMonth(newDate);
|
setCurrentMonth(newDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalCategoryAmount = categoryData.reduce((sum, item) => sum + item.total, 0);
|
const handleCategoryTypeChange = (type) => {
|
||||||
|
setCategoryType(type);
|
||||||
|
setSelectedCategories([]); // 切换类型时清空选中
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategory = (categoryName) => {
|
||||||
|
if (selectedCategories.includes(categoryName)) {
|
||||||
|
setSelectedCategories(selectedCategories.filter(c => c !== categoryName));
|
||||||
|
} else {
|
||||||
|
setSelectedCategories([...selectedCategories, categoryName]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据选中分类过滤数据
|
||||||
|
const filteredCategoryData = useMemo(() => {
|
||||||
|
return selectedCategories.length > 0
|
||||||
|
? categoryData.filter(cat => selectedCategories.includes(cat.category))
|
||||||
|
: categoryData;
|
||||||
|
}, [selectedCategories, categoryData]);
|
||||||
|
|
||||||
|
const filteredTotalAmount = useMemo(() => {
|
||||||
|
return filteredCategoryData.reduce((sum, item) => sum + item.total, 0);
|
||||||
|
}, [filteredCategoryData]);
|
||||||
|
|
||||||
const headerContent = (
|
const headerContent = (
|
||||||
<div className="header-center-container">
|
<div className="header-center-container">
|
||||||
@@ -201,14 +193,40 @@ export default function Stats() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="category-type-tabs">
|
<div className="category-type-tabs">
|
||||||
<button type="button" className={categoryType === 'expense' ? 'active' : ''} onClick={() => setCategoryType('expense')}>支出</button>
|
<button type="button" className={categoryType === 'expense' ? 'active' : ''} onClick={() => handleCategoryTypeChange('expense')}>支出</button>
|
||||||
<button type="button" className={categoryType === 'income' ? 'active' : ''} onClick={() => setCategoryType('income')}>收入</button>
|
<button type="button" className={categoryType === 'income' ? 'active' : ''} onClick={() => handleCategoryTypeChange('income')}>收入</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 总额显示 */}
|
||||||
|
<div className="stats-total">
|
||||||
|
<span className="stats-total-label">{categoryType === 'expense' ? '支出' : '收入'}</span>
|
||||||
|
<span className="stats-total-amount">¥{filteredTotalAmount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类选择chips */}
|
||||||
|
<div className="category-chips">
|
||||||
|
{categoryData.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.category}
|
||||||
|
type="button"
|
||||||
|
className={`category-chip ${selectedCategories.includes(cat.category) ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleCategory(cat.category)}
|
||||||
|
>
|
||||||
|
{cat.category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="category-breakdown category-breakdown--stats">
|
<div className="category-breakdown category-breakdown--stats">
|
||||||
{categoryData.length > 0 ? categoryData.map((cat, index) => {
|
{filteredCategoryData.length > 0 ? filteredCategoryData.map((cat, index) => {
|
||||||
const percent = totalCategoryAmount ? ((cat.total / totalCategoryAmount) * 100).toFixed(1) : '0.0';
|
const percent = filteredTotalAmount ? ((cat.total / filteredTotalAmount) * 100).toFixed(1) : '0.0';
|
||||||
return (
|
return (
|
||||||
<div key={index} className="stats-category-item">
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
className="stats-category-item stats-category-item--clickable"
|
||||||
|
onClick={() => router.push(`/search?q=${encodeURIComponent(cat.category)}`)}
|
||||||
|
>
|
||||||
<div className="stats-category-header">
|
<div className="stats-category-header">
|
||||||
<span className="stats-category-name">{cat.category}</span>
|
<span className="stats-category-name">{cat.category}</span>
|
||||||
<span className="stats-category-amount">{cat.total.toFixed(2)}</span>
|
<span className="stats-category-amount">{cat.total.toFixed(2)}</span>
|
||||||
@@ -216,7 +234,7 @@ export default function Stats() {
|
|||||||
<div className="stats-category-bar-wrap">
|
<div className="stats-category-bar-wrap">
|
||||||
<div className="stats-category-bar" style={{ width: `${percent}%` }}></div>
|
<div className="stats-category-bar" style={{ width: `${percent}%` }}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}) : <div className="empty-state empty-state--inline">暂无数据</div>}
|
}) : <div className="empty-state empty-state--inline">暂无数据</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+101
-8
@@ -1207,14 +1207,6 @@ textarea:focus-visible {
|
|||||||
gap: 10px;
|
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 {
|
.section-card--search .results-count {
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1591,6 +1583,18 @@ textarea:focus-visible {
|
|||||||
background: rgba(246, 248, 252, 0.8);
|
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 {
|
.stats-category-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1622,6 +1626,95 @@ textarea:focus-visible {
|
|||||||
background: linear-gradient(90deg, var(--primary), #60a5fa);
|
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 {
|
.category-item .category-name {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user