# UI 重设计 P4:四个 Tab 页 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。 **Goal:** 按 spec §7.1–7.4 重做四个 Tab 页:首页(问候 + 净资产白卡 + 待办条 + 最近交易)、交易页(搜索优先 + 日期分组时间线 + FilterSheet + 左滑操作)、报表页(单一 anchor 日期统一导航 + ⋯ 菜单 + 年报去重)、我的页(4 分组)。 **Architecture:** 纯逻辑(periodNav 周期导航 / transactionGroups 日期分组 / useSearch 金额区间)先 TDD 落地;共享组件(FilterSheet / 重刷 TransactionCard / SwipeableTransactionCard / TodoStrip)随后;四个页面逐页替换,每页替换后全量测试。 **Spec:** `docs/ui-redesign-design.md` §7。**与 spec 的偏差**:①「数据」组的备份恢复/导出入口并入现有「同步与备份」页(/settings/sync 已含备份 UI),不新建页面;②左滑用 gesture-handler 传统 `Swipeable`(不依赖 reanimated worklet 配置);③报表周期 label 维持现有中文格式(P5 统一 i18n)。 **前置状态:** P1–P3 已完成(570 测试全绿)。BottomSheet/DatePickerField/CategoryIcon/StatCard/ScreenHeader 可用;gesture-handler 2.28 已安装;`_layout.tsx` **无** GestureHandlerRootView(T3 加);`settings/` 现有 ai/preferences/sync 三个子页,Stack.Screen 需显式注册。 --- ### Task 1: 纯逻辑失败测试 **Files:** - Create: `tests/periodNav.test.ts` - Create: `tests/transactionGroups.test.ts` - Modify: 现有 search 测试文件(先 `ls tests/ | grep -i search` 找到它,追加金额区间用例) - [ ] **Step 1: tests/periodNav.test.ts** ```typescript import { describe, expect, it } from 'vitest'; import { shiftAnchor, periodRange, isoWeekNumber } from '../src/domain/periodNav'; describe('shiftAnchor 周期导航', () => { it('周:±7 天', () => { expect(shiftAnchor('2026-07-21', 'weekly', 1)).toBe('2026-07-28'); expect(shiftAnchor('2026-07-21', 'weekly', -1)).toBe('2026-07-14'); }); it('月:月末回退钳制(1月31日 → 2月28日)', () => { expect(shiftAnchor('2026-01-31', 'monthly', 1)).toBe('2026-02-28'); expect(shiftAnchor('2026-03-31', 'monthly', -1)).toBe('2026-02-28'); }); it('月:跨年', () => { expect(shiftAnchor('2026-12-15', 'monthly', 1)).toBe('2027-01-15'); expect(shiftAnchor('2026-01-15', 'monthly', -1)).toBe('2025-12-15'); }); it('年:±1 年', () => { expect(shiftAnchor('2026-07-21', 'annual', 1)).toBe('2027-07-21'); expect(shiftAnchor('2026-07-21', 'annual', -1)).toBe('2025-07-21'); }); it('多步 delta', () => { expect(shiftAnchor('2026-07-21', 'monthly', 3)).toBe('2026-10-21'); expect(shiftAnchor('2026-07-21', 'weekly', -2)).toBe('2026-07-07'); }); }); describe('periodRange 周期范围', () => { it('周:周一起周日止(周二 anchor)', () => { expect(periodRange('2026-07-21', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' }); }); it('周:周日 anchor 属于本周(周一开头)', () => { expect(periodRange('2026-07-26', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' }); }); it('月:整月', () => { expect(periodRange('2026-02-10', 'monthly')).toEqual({ start: '2026-02-01', end: '2026-02-28' }); }); it('年:整年', () => { expect(periodRange('2026-07-21', 'annual')).toEqual({ start: '2026-01-01', end: '2026-12-31' }); }); }); describe('isoWeekNumber ISO 周数', () => { it('2026-01-01 是第 1 周', () => { expect(isoWeekNumber('2026-01-01')).toBe(1); }); it('2026-07-20 是第 30 周', () => { expect(isoWeekNumber('2026-07-20')).toBe(30); }); }); ``` - [ ] **Step 2: tests/transactionGroups.test.ts** ```typescript import { describe, expect, it } from 'vitest'; import { groupTransactionsByDate } from '../src/components/transactionGroups'; import type { Transaction } from '../src/domain/types'; function tx(id: string, date: string, postings: { account: string; amount?: string }[]): Transaction { return { id, date, flag: '*', narration: id, postings: postings.map(p => ({ account: p.account, amount: p.amount, currency: 'CNY' })), tags: [], links: [], raw: '', } as Transaction; } describe('groupTransactionsByDate 日期分组', () => { it('同日合并并计算收支小计', () => { const groups = groupTransactionsByDate([ tx('a', '2026-07-21', [{ account: 'Assets:招行', amount: '-35' }, { account: 'Expenses:餐饮', amount: '35' }]), tx('b', '2026-07-21', [{ account: 'Assets:招行', amount: '100' }, { account: 'Income:工资', amount: '-100' }]), ]); expect(groups).toHaveLength(1); expect(groups[0].date).toBe('2026-07-21'); expect(groups[0].expense).toBe('35'); expect(groups[0].income).toBe('100'); expect(groups[0].items.map(t => t.id)).toEqual(['a', 'b']); }); it('保持输入顺序(调用方已按日期倒序)', () => { const groups = groupTransactionsByDate([ tx('a', '2026-07-21', [{ account: 'Expenses:餐饮', amount: '35' }]), tx('b', '2026-07-20', [{ account: 'Expenses:餐饮', amount: '20' }]), ]); expect(groups.map(g => g.date)).toEqual(['2026-07-21', '2026-07-20']); }); it('转账日收支小计为零', () => { const groups = groupTransactionsByDate([ tx('a', '2026-07-21', [{ account: 'Assets:A', amount: '-50' }, { account: 'Assets:B', amount: '50' }]), ]); expect(groups[0].income).toBe('0'); expect(groups[0].expense).toBe('0'); }); it('空输入返回空数组', () => { expect(groupTransactionsByDate([])).toEqual([]); }); }); ``` - [ ] **Step 3: search 测试追加金额区间用例** 找到现有 search 测试文件,在末尾追加(构造交易的辅助函数沿用该文件现有风格): ```typescript describe('金额区间筛选', () => { it('amountMin/amountMax 按主金额绝对值过滤', () => { // 三笔:支出 35、支出 200、收入 5000(收入主金额为负 → 绝对值 5000) // filters { amountMin: '30', amountMax: '300' } 应只剩支出 35 与支出 200 // filters { amountMin: '1000' } 应只剩收入 5000 }); it('无金额 posting 的交易在设了下限时被排除', () => { // filters { amountMin: '1' } 排除 postings 全无 amount 的交易 }); }); ``` (实现者按现有测试文件的构造风格补全具体数据与断言。) - [ ] **Step 4: 运行确认失败** Run: `npx vitest run tests/periodNav.test.ts tests/transactionGroups.test.ts` → Expected: FAIL(模块不存在)。金额区间用例也应 FAIL。**若意外通过,报告 BLOCKED。** --- ### Task 2: periodNav + transactionGroups + useSearch 金额区间 **Files:** - Create: `src/domain/periodNav.ts` - Create: `src/components/transactionGroups.ts` - Modify: `src/hooks/useSearch.ts`、`src/domain/index.ts` - [ ] **Step 1: src/domain/periodNav.ts** ```typescript /** * 报表周期导航(P4):单一 anchor 日期 + 周期类型,替代周/月/年三套独立状态。 * 全部本地时区字符串运算,避免 toISOString 的 UTC 偏移。 */ import { toDateString } from './decimal'; export type ReportPeriod = 'weekly' | 'monthly' | 'annual'; function parse(dateStr: string): Date { const [y, m, d] = dateStr.split('-').map(Number); return new Date(y, m - 1, d); } /** 平移 anchor:周 ±7 天;月 ±N(月末日钳制);年 ±N。 */ export function shiftAnchor(anchor: string, period: ReportPeriod, delta: number): string { const d = parse(anchor); if (period === 'weekly') { d.setDate(d.getDate() + 7 * delta); } else if (period === 'monthly') { const day = d.getDate(); d.setDate(1); d.setMonth(d.getMonth() + delta); // 月末钳制:目标月天数不足时退到月末 const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); d.setDate(Math.min(day, lastDay)); } else { d.setFullYear(d.getFullYear() + delta); } return toDateString(d); } /** anchor 所在周期的起止日期(含两端)。周 = 周一~周日。 */ export function periodRange(anchor: string, period: ReportPeriod): { start: string; end: string } { const d = parse(anchor); if (period === 'weekly') { const day = d.getDay(); // 0=周日 const diffToMonday = day === 0 ? -6 : 1 - day; const monday = new Date(d); monday.setDate(d.getDate() + diffToMonday); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); return { start: toDateString(monday), end: toDateString(sunday) }; } if (period === 'monthly') { const start = new Date(d.getFullYear(), d.getMonth(), 1); const end = new Date(d.getFullYear(), d.getMonth() + 1, 0); return { start: toDateString(start), end: toDateString(end) }; } return { start: `${d.getFullYear()}-01-01`, end: `${d.getFullYear()}-12-31` }; } /** ISO-8601 周数(周一开头)。 */ export function isoWeekNumber(dateStr: string): number { const date = parse(dateStr); date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7)); const week1 = new Date(date.getFullYear(), 0, 4); return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7); } ``` - [ ] **Step 2: src/components/transactionGroups.ts** ```typescript /** * 交易按日期分组(P4 时间线):组内保持输入顺序,组头带当日收支小计。 * 输入需已由调用方按日期倒序排列。 */ import type { Transaction } from '../domain/types'; import { addDecimals, negateDecimal } from '../domain/decimal'; export interface DayGroup { date: string; // YYYY-MM-DD income: string; expense: string; items: Transaction[]; } export function groupTransactionsByDate(txs: Transaction[]): DayGroup[] { const groups: DayGroup[] = []; const byDate = new Map(); for (const t of txs) { const date = t.date.slice(0, 10); let group = byDate.get(date); if (!group) { group = { date, income: '0', expense: '0', items: [] }; byDate.set(date, group); groups.push(group); } group.items.push(t); const incomeAmounts: string[] = []; const expenseAmounts: string[] = []; for (const p of t.postings) { if (!p.amount) continue; if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount)); if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount); } if (incomeAmounts.length) group.income = addDecimals([group.income, ...incomeAmounts]); if (expenseAmounts.length) group.expense = addDecimals([group.expense, ...expenseAmounts]); } return groups; } ``` - [ ] **Step 3: useSearch.ts 金额区间** - `SearchFilters` 加 `amountMin?: string; amountMax?: string;` - filter 链中(direction 判断之后)加: ```typescript if (debouncedFilters.amountMin || debouncedFilters.amountMax) { const raw = t.postings.find(p => p.amount)?.amount; const abs = raw?.replace(/^-/, ''); if (debouncedFilters.amountMin && (!abs || compareDecimals(abs, debouncedFilters.amountMin) < 0)) return false; if (debouncedFilters.amountMax && abs && compareDecimals(abs, debouncedFilters.amountMax) > 0) return false; } ``` - 顶部 import `compareDecimals` from '../domain/decimal'。 - [ ] **Step 4: domain/index.ts 加 barrel** ```typescript export * from './periodNav'; ``` - [ ] **Step 5: 验证** Run: `npx vitest run tests/periodNav.test.ts tests/transactionGroups.test.ts` + search 测试文件 → 全 PASS Run: `npm run typecheck` → 无错误 --- ### Task 3: 共享组件(BottomSheet scrollable / FilterSheet / TransactionCard 重刷 / SwipeableTransactionCard / 手势根) **Files:** - Modify: `src/components/BottomSheet.tsx`、`src/components/TransactionCard.tsx`、`src/app/_layout.tsx` - Create: `src/components/FilterSheet.tsx`、`src/components/SwipeableTransactionCard.tsx` - Modify: `src/i18n/zh.ts`、`src/i18n/en.ts` - [ ] **Step 1: BottomSheet 加 scrollable 选项(P2 遗留)** props 加 `scrollable?: boolean`;为 true 时 children 包一层 ``(import ScrollView)。 - [ ] **Step 2: i18n 加键(zh/en 对等)** zh: ```typescript 'transactions.filter': '筛选', 'transactions.amountMin': '最小金额', 'transactions.amountMax': '最大金额', 'transactions.today': '今天', 'transactions.yesterday': '昨天', 'transactions.daySummary': '收 {{income}} · 支 {{expense}}', 'transactions.duplicate': '复制', 'transactions.deleteTitle': '删除交易', 'transactions.deleteMessage': '确定删除「{{name}}」吗?此操作会同时从 main.bean 移除。', 'filter.title': '高级筛选', 'filter.reset': '重置', 'filter.apply': '完成', ``` en 对应:'Filter' / 'Min amount' / 'Max amount' / 'Today' / 'Yesterday' / '+{{income}} · -{{expense}}' / 'Duplicate' / 'Delete transaction' / 'Delete "{{name}}"? This also removes it from main.bean.' / 'Advanced filters' / 'Reset' / 'Done'。 (若 transactions.deleteTitle/deleteMessage 等键已存在则复用,先 grep 确认,勿重复添加;i18n 插值语法以现有键为准。) - [ ] **Step 3: TransactionCard 重刷(spec §7.2)** 完整替换 `src/components/TransactionCard.tsx`: ```tsx import React from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../theme'; import { useT } from '../i18n'; import { CategoryIcon } from './CategoryIcon'; import type { Transaction } from '../domain/types'; interface TransactionCardProps { transaction: Transaction; onPress?: (t: Transaction) => void; /** 命中分类 id(调用方按 linkedAccount 匹配);缺省用方向兜底图标。 */ categoryId?: string; /** 时间线分组下隐藏日期(默认 true 保持旧行为)。 */ showDate?: boolean; } /** 交易卡片:CategoryIcon 圆底 + 摘要/账户小字 + 右侧等宽金额(支出黑、收入绿+、转账蓝)。 */ export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress, categoryId, showDate = true }: TransactionCardProps) { const { theme } = useTheme(); const t = useT(); const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses')); const isIncome = transaction.postings.some(p => p.account.startsWith('Income')); const direction = isExpense ? 'expense' : isIncome ? 'income' : 'transfer'; const rawAmount = transaction.postings.find(p => p.amount)?.amount ?? ''; const absAmount = rawAmount.replace(/^-/, ''); const amountColor = direction === 'income' ? theme.colors.financial.income : direction === 'transfer' ? theme.colors.financial.transfer : theme.colors.fgPrimary; const amountText = direction === 'income' ? `+${absAmount}` : direction === 'expense' ? `-${absAmount}` : absAmount; // 账户小字:第一条资金腿的短名 const assetAccount = transaction.postings.find(p => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'))?.account; const accountShort = assetAccount?.split(':').pop(); const subtitle = [ showDate ? transaction.date.slice(0, 10) : '', accountShort ?? '', ].filter(Boolean).join(' · '); return ( onPress?.(transaction)} style={({ pressed }) => [ styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md, borderWidth: StyleSheet.hairlineWidth, borderColor: theme.colors.border, opacity: pressed ? 0.8 : 1, }, ]} > {transaction.narration || transaction.payee || t('transaction.noSummary')} {subtitle ? ( {subtitle} ) : null} {amountText} {transaction.tags.length > 0 && ( {transaction.tags.map(tag => ( #{tag} ))} )} ); }); const styles = StyleSheet.create({ card: { marginBottom: 8, flexDirection: 'row', alignItems: 'center', gap: 10, flexWrap: 'wrap' }, body: { flex: 1 }, tags: { flexDirection: 'row', gap: 4, marginLeft: 46, flexBasis: '100%', flexWrap: 'wrap' }, tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' }, }); ``` 注意:tags 行布局改为 `flexBasis: '100%'` 换行(card 变 flexWrap 行容器)。金额 `fontFamily: 'monospace'` 改为 `fontVariant: ['tabular-nums']`(P1 决策)。 - [ ] **Step 4: SwipeableTransactionCard.tsx** ```tsx /** * 可左滑的交易卡片(spec §7.2):右滑出「复制 / 删除」快捷操作。 * 用 gesture-handler 传统 Swipeable(不依赖 reanimated worklet)。 */ import React, { useRef } from 'react'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; import { Swipeable } from 'react-native-gesture-handler'; import { Ionicons } from '@expo/vector-icons'; import { useTheme } from '../theme'; import { useT } from '../i18n'; import { TransactionCard } from './TransactionCard'; import type { Transaction } from '../domain/types'; interface SwipeableTransactionCardProps { transaction: Transaction; categoryId?: string; showDate?: boolean; onPress: (t: Transaction) => void; /** 复制:跳转录入页预填(由调用方实现)。 */ onDuplicate: (t: Transaction) => void; /** 删除:调用方负责 confirm + deleteTransaction。 */ onDelete: (t: Transaction) => void; } export function SwipeableTransactionCard({ transaction, categoryId, showDate, onPress, onDuplicate, onDelete }: SwipeableTransactionCardProps) { const { theme } = useTheme(); const t = useT(); const swipeRef = useRef(null); const renderRightActions = () => ( { swipeRef.current?.close(); onDuplicate(transaction); }} style={[styles.actionBtn, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.md }]} accessibilityRole="button" accessibilityLabel={t('transactions.duplicate')} > {t('transactions.duplicate')} { swipeRef.current?.close(); onDelete(transaction); }} style={[styles.actionBtn, { backgroundColor: theme.colors.financial.expense, borderRadius: theme.radii.md }]} accessibilityRole="button" accessibilityLabel={t('common.delete')} > {t('common.delete')} ); return ( ); } const styles = StyleSheet.create({ actions: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingLeft: 8, marginBottom: 8 }, actionBtn: { width: 64, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 10 }, actionText: { fontSize: 11, fontWeight: '600' }, }); ``` (Alert import 若未用可去;onDelete 的确认弹窗由页面实现。) - [ ] **Step 5: FilterSheet.tsx** ```tsx /** * 高级筛选底部弹层(spec §6):账户 / 日期范围 / 金额区间。 * 受控组件:值由父级持有,「完成」回调应用,「重置」清空。 */ import React from 'react'; import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { useTheme, createCommonStyles } from '../theme'; import { useT } from '../i18n'; import { BottomSheet } from './BottomSheet'; import { DatePickerField } from './DatePickerField'; import { Button } from './Button'; export interface AdvancedFilterValues { account: string; dateFrom: string; dateTo: string; amountMin: string; amountMax: string; } interface FilterSheetProps { visible: boolean; onClose: () => void; values: AdvancedFilterValues; onChange: (values: AdvancedFilterValues) => void; onReset: () => void; } export function FilterSheet({ visible, onClose, values, onChange, onReset }: FilterSheetProps) { const { theme } = useTheme(); const commonStyles = createCommonStyles(theme); const t = useT(); const set = (patch: Partial) => onChange({ ...values, ...patch }); return ( {t('transactions.accountFilterPlaceholder')} set({ account: v })} placeholder={t('transactions.accountFilterPlaceholder')} placeholderTextColor={theme.colors.fgSecondary} style={commonStyles.input} /> {t('transactions.dateFrom')} ~ {t('transactions.dateTo')} set({ dateFrom: v })} placeholder={t('transactions.dateFrom')} /> set({ dateTo: v })} placeholder={t('transactions.dateTo')} /> {t('transactions.amountMin')} ~ {t('transactions.amountMax')} set({ amountMin: v })} placeholder={t('transactions.amountMin')} placeholderTextColor={theme.colors.fgSecondary} keyboardType="decimal-pad" style={[commonStyles.input, styles.flex1]} /> set({ amountMax: v })} placeholder={t('transactions.amountMax')} placeholderTextColor={theme.colors.fgSecondary} keyboardType="decimal-pad" style={[commonStyles.input, styles.flex1]} />