DriftLedger/docs/ui-redesign-p4-plan.md
fengmengqi 2e73b5a2c6 feat: UI 全面重设计(Bento 明亮风)+ 记一笔 NumpadSheet + 管理页模板 + OCR v5→v6 升级
UI 重设计(P1-P5):
- 主题从靛蓝 accent 切换为近黑白单色(#111318),品牌感靠排版与财务语义色
- 暗色模式适配 OLED 纯黑,财务色整体提亮一档保证对比度
- 圆角体系新增 xl(24),卡片/弹窗统一大圆角
- 移除 Caveat/Quicksand 自定义字体,切换为系统字体
- 新增 display(34/800) 字阶用于净资产等大数字展示
- 分类/标签/渠道颜色统一到 palette.ts 色板(12 色循环 + FNV 哈希)
记一笔 NumpadSheet(P3 录入闭环):
- 全新计算器风格录入面板:方向 chip → 大金额 → 账户 chip → 分类快捷行 → 数字键盘
- 支持表达式输入(20+15-5.5)与字符串十进制求值,避免浮点精度问题
- 高级模式保留多行分录编辑器,简单模式自动推断双腿
- postingLegs.ts 实现 buildPostings 的逆操作(编辑/草稿预填)
- 未保存守卫:整页 beforeRemove + modal 脏状态上报
- 全局弹层(NumpadSheetHost)由+按钮唤起,可通过设置开关
Tab 栏重构:
- AppTabBar 替换默认 tab bar,中央+按钮唤起全局记账面板
- Tab 从 5 个精简为 4 个(首页/交易/报表/设置),+按钮独立
管理页模板 ManagementScreen:
- 统一「列表 + 新增 + 点按编辑 + 长按删除 + FormModal」模式
- 预算/分类/标签/信用卡等管理页消除手写样板
其他:
- OCR 模型从 PP-OCRv5 升级到 v6(det/rec/dict 全部替换)
- GBK 解码器 import 修复(text-encoding-gbk ESM 兼容)
- 批量确认失败数计算修正(用 failedPayees.length 替代 store 快照差值)
- 新增 diagnostics 诊断页、TodoStrip 首页待办条、SwipeableTransactionCard 左滑操作
- periodNav.ts 报表周期导航(anchor+period 统一状态管理,本地时区运算)
- 测试覆盖 numpadExpression/postingLegs/periodNav/palette/search/transactionGroups/categoryIcons/dateGrid
2026-07-22 13:33:29 +08:00

1382 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.17.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
**前置状态:** P1P3 已完成570 测试全绿。BottomSheet/DatePickerField/CategoryIcon/StatCard/ScreenHeader 可用gesture-handler 2.28 已安装;`_layout.tsx` **无** GestureHandlerRootViewT3 加);`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<string, DayGroup>();
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 包一层 `<ScrollView style={{ maxHeight: 480 }} keyboardShouldPersistTaps="handled">`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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${amountText} ${transaction.date.slice(0, 10)}`}
onPress={() => 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,
},
]}
>
<CategoryIcon categoryId={categoryId ?? direction} size={36} />
<View style={styles.body}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
{transaction.narration || transaction.payee || t('transaction.noSummary')}
</Text>
{subtitle ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>
<Text style={[theme.typography.body, { color: amountColor, fontWeight: '700', fontSize: 15, fontVariant: ['tabular-nums'] }]}>
{amountText}
</Text>
{transaction.tags.length > 0 && (
<View style={styles.tags}>
{transaction.tags.map(tag => (
<Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accent, fontWeight: '600' }]}>
#{tag}
</Text>
))}
</View>
)}
</Pressable>
);
});
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<Swipeable>(null);
const renderRightActions = () => (
<View style={styles.actions}>
<Pressable
onPress={() => { swipeRef.current?.close(); onDuplicate(transaction); }}
style={[styles.actionBtn, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.md }]}
accessibilityRole="button"
accessibilityLabel={t('transactions.duplicate')}
>
<Ionicons name="copy-outline" size={20} color={theme.colors.fgPrimary} />
<Text style={[styles.actionText, { color: theme.colors.fgPrimary }]}>{t('transactions.duplicate')}</Text>
</Pressable>
<Pressable
onPress={() => { swipeRef.current?.close(); onDelete(transaction); }}
style={[styles.actionBtn, { backgroundColor: theme.colors.financial.expense, borderRadius: theme.radii.md }]}
accessibilityRole="button"
accessibilityLabel={t('common.delete')}
>
<Ionicons name="trash-outline" size={20} color={theme.colors.fgInverse} />
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{t('common.delete')}</Text>
</Pressable>
</View>
);
return (
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
<TransactionCard transaction={transaction} categoryId={categoryId} showDate={showDate} onPress={onPress} />
</Swipeable>
);
}
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<AdvancedFilterValues>) => onChange({ ...values, ...patch });
return (
<BottomSheet visible={visible} onClose={onClose} title={t('filter.title')} scrollable>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.accountFilterPlaceholder')}
</Text>
<TextInput
value={values.account}
onChangeText={v => set({ account: v })}
placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.dateFrom')} ~ {t('transactions.dateTo')}
</Text>
<View style={styles.row}>
<View style={styles.flex1}><DatePickerField value={values.dateFrom} onChange={v => set({ dateFrom: v })} placeholder={t('transactions.dateFrom')} /></View>
<View style={styles.flex1}><DatePickerField value={values.dateTo} onChange={v => set({ dateTo: v })} placeholder={t('transactions.dateTo')} /></View>
</View>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.amountMin')} ~ {t('transactions.amountMax')}
</Text>
<View style={styles.row}>
<TextInput
value={values.amountMin}
onChangeText={v => set({ amountMin: v })}
placeholder={t('transactions.amountMin')}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType="decimal-pad"
style={[commonStyles.input, styles.flex1]}
/>
<TextInput
value={values.amountMax}
onChangeText={v => set({ amountMax: v })}
placeholder={t('transactions.amountMax')}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType="decimal-pad"
style={[commonStyles.input, styles.flex1]}
/>
</View>
<View style={[styles.row, styles.actions]}>
<View style={styles.flex1}>
<Button label={t('filter.reset')} variant="secondary" onPress={onReset} />
</View>
<View style={styles.flex1}>
<Button label={t('filter.apply')} onPress={onClose} />
</View>
</View>
</BottomSheet>
);
}
const styles = StyleSheet.create({
label: { marginTop: 10, marginBottom: 4 },
row: { flexDirection: 'row', gap: 8 },
flex1: { flex: 1 },
actions: { marginTop: 16 },
});
```
- [ ] **Step 6: _layout.tsx 加 GestureHandlerRootView**
`import { GestureHandlerRootView } from 'react-native-gesture-handler';`RootLayout 最外层包裹:`<GestureHandlerRootView style={{ flex: 1 }}><ThemeProvider></ThemeProvider></GestureHandlerRootView>`。
- [ ] **Step 7: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 4: 首页重写 + TodoStrip
**Files:**
- Create: `src/components/TodoStrip.tsx`
- Modify: `src/app/(tabs)/index.tsx`(完整重写)、`src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: i18n 加键zh/en 对等)**
zh
```typescript
'home.greetingMorning': '早上好',
'home.greetingAfternoon': '下午好',
'home.greetingEvening': '晚上好',
'home.monthExpense': '本月支出',
'home.monthIncome': '本月收入',
'home.budgetRemaining': '预算剩余',
'home.recentTransactions': '最近交易',
'home.viewAll': '查看全部',
'todo.title': '待办',
'todo.dueRecurring': '周期记账「{{name}}」到期',
'todo.creditCardDue': '{{name}} 还款日 {{date}}',
'todo.pendingDrafts': '{{count}} 条自动账单待确认',
'todo.confirm': '入账',
```
en 对应:'Good morning' / 'Good afternoon' / 'Good evening' / 'Spent this month' / 'Earned this month' / 'Budget left' / 'Recent transactions' / 'View all' / 'To-do' / 'Recurring "{{name}}" is due' / '{{name}} payment due {{date}}' / '{{count}} auto bills to confirm' / 'Post'。
- [ ] **Step 2: src/components/TodoStrip.tsx**
```tsx
/**
* 首页待办条spec §7.1):周期记账到期 / 信用卡还款提醒 / 未确认自动账单。
* 三类都无待办时不渲染。
*/
import React, { useMemo } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { Card } from './Card';
import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { useAutomationStore } from '../store/automationStore';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../domain/recurring';
import { calculateBillingPeriod } from '../domain/creditCards';
import { toDateString } from '../domain/decimal';
import { shiftAnchor } from '../domain/periodNav';
export function TodoStrip() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
const creditCards = useMetadataStore(s => s.creditCards);
const draftsCount = useAutomationStore(s => s.drafts.length);
const todayStr = useMemo(() => toDateString(new Date()), []);
const weekLater = useMemo(() => shiftAnchor(todayStr, 'weekly', 1), [todayStr]);
// autoConfirm 的由首页自动处理,待办条只列需手动确认的
const dueRecurring = useMemo(
() => getDueRecurring(recurringTransactions, todayStr).filter(r => !r.autoConfirm),
[recurringTransactions, todayStr],
);
const dueCards = useMemo(
() => creditCards.filter(card => {
const period = calculateBillingPeriod(card, new Date());
return period.dueDate >= todayStr && period.dueDate <= weekLater;
}),
[creditCards, todayStr, weekLater],
);
if (dueRecurring.length === 0 && dueCards.length === 0 && draftsCount === 0) return null;
const handleConfirmRecurring = async (rec: (typeof dueRecurring)[number]) => {
try {
const draft = instantiateRecurring(rec);
await addTransaction(draft);
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
updateRecurringTransaction(rec.id, { nextDueDate });
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
} catch (e) {
Alert.alert(t('home.recurringFail'), String(e));
}
};
const renderRow = (icon: keyof typeof Ionicons.glyphMap, text: string, action?: { label: string; onPress: () => void }, key: string) => (
<View key={key} style={[styles.row, { borderBottomColor: theme.colors.divider }]}>
<Ionicons name={icon} size={18} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, styles.rowText, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{text}
</Text>
{action ? (
<Pressable
onPress={action.onPress}
style={[styles.actionBtn, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.full }]}
accessibilityRole="button"
accessibilityLabel={action.label}
>
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{action.label}</Text>
</Pressable>
) : (
<Ionicons name="chevron-forward" size={16} color={theme.colors.fgSecondary} />
)}
</View>
);
return (
<Card title={t('todo.title')}>
{dueRecurring.map(rec =>
renderRow('repeat-outline', t('todo.dueRecurring', { name: rec.name }), { label: t('todo.confirm'), onPress: () => handleConfirmRecurring(rec) }, `rec-${rec.id}`),
)}
{dueCards.map(card =>
renderRow('card-outline', t('todo.creditCardDue', { name: card.name, date: calculateBillingPeriod(card, new Date()).dueDate.slice(5) }), { label: t('common.confirm'), onPress: () => router.push('/credit-card' as any) }, `card-${card.id}`),
)}
{draftsCount > 0 &&
renderRow('flash-outline', t('todo.pendingDrafts', { count: draftsCount }), { label: t('common.confirm'), onPress: () => router.push('/automation' as any) }, 'drafts')}
</Card>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth },
rowText: { flex: 1 },
actionBtn: { paddingHorizontal: 12, paddingVertical: 5 },
actionText: { fontSize: 12, fontWeight: '700' },
});
```
`router.push('/credit-card' as any)`:若 typedRoutes 接受字面量则去 `as any`——参考现有 settings.tsx 的 `as Href` 写法,与之一致即可。)
- [ ] **Step 3: 完整重写 src/app/(tabs)/index.tsx**
结构(保留现有数据计算与 autoConfirm effect删除 AccountTree/Bento 双格/Hero accent 卡):
```tsx
/**
* 首页spec §7.1):今日视角。
* 问候 header → 净资产白卡(本月收支/预算剩余小字)→ 待办条 → 最近 5 条 → 月度趋势。
* 账户树已下沉到「我的」页(/account
*/
import React, { useEffect, useMemo, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { TransactionCard } from '../../components/TransactionCard';
import { TodoStrip } from '../../components/TodoStrip';
import { TrendLine } from '../../components/charts/TrendLine';
import { calculateNetWorth } from '../../domain/netWorth';
import { groupByMonth } from '../../domain/chartStats';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
import { calculateBudgetProgress } from '../../domain/budgets';
import { toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';
export default function HomeScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
const budgets = useMetadataStore(s => s.budgets);
const categories = useMetadataStore(s => s.categories);
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);
const todayStr = useMemo(() => toDateString(new Date()), []);
const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);
// 周期记账自动触发autoConfirm 项启动时自动入账)——逻辑与旧版一致
const autoConfirmed = useRef(false);
useEffect(() => { /* …原样保留旧实现… */ }, [dueRecurring, addTransaction, updateRecurringTransaction]);
// 月度预算剩余合计(无月度预算则不显示)
const budgetRemaining = useMemo(() => {
const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
if (monthlyBudgets.length === 0) return null;
const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
return remainings.reduce((a, b) => addDecimals([a, b]), '0');
}, [budgets, transactions, todayStr]);
// 最近 5 条(按日期+序号倒序)
const recentTxs = useMemo(() =>
transactions.map((tx, idx) => ({ tx, idx }))
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
.slice(0, 5)
.map(item => item.tx),
[transactions]);
// 分类匹配TransactionCard 图标)
const categoryIdFor = (tx: Transaction): string | undefined => {
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
};
// 问候语 + 日期行
const hour = new Date().getHours();
const greeting = hour < 12 ? t('home.greetingMorning') : hour < 18 ? t('home.greetingAfternoon') : t('home.greetingEvening');
const dateLine = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });
const fmtAmount = (s: string) => { /* …原样保留旧实现… */ };
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 净资产白卡 */}
<Card>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
<Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
{fmtAmount(netWorth.netWorth)}
</Text>
<View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
-{fmtAmount(currentMonth?.expense.toString() ?? '0')}
</Text>
</View>
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
+{fmtAmount(currentMonth?.income.toString() ?? '0')}
</Text>
</View>
{budgetRemaining !== null && (
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
{fmtAmount(budgetRemaining)}
</Text>
</View>
)}
</View>
</Card>
<TodoStrip />
{/* 最近 5 条交易 */}
{recentTxs.length > 0 && (
<Card title={t('home.recentTransactions')}>
{recentTxs.map(tx => (
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
))}
<Pressable onPress={() => router.push('/(tabs)/transactions' as any)} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} </Text>
</Pressable>
</Card>
)}
{/* 月度趋势 */}
{monthlyData.length > 0 && (
<Card title={t('home.monthlyTrend')}>
<TrendLine transactions={transactions} />
</Card>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 40 },
heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
heroStat: { gap: 2 },
viewAll: { alignItems: 'center', paddingVertical: 6 },
});
```
要点①autoConfirm effect 与 fmtAmount 从旧文件原样搬运;②`addDecimals` 需 import③`router.push('/(tabs)/transactions' as any)` 若 typedRoutes 不接受,用 `router.navigate('/(tabs)/transactions')` 或相对段——以实现时 typecheck 为准并报告;④旧 importsAccountTree/buildAccountTree/handleConfirmRecurring/bento 样式)全部删除。
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -n "AccountTree" "src/app/(tabs)/index.tsx"` → 无输出(账户树已下沉)
---
### Task 5: 交易页重写(时间线 + FilterSheet + 左滑)
**Files:**
- Modify: `src/app/(tabs)/transactions.tsx`(完整重写)
- [ ] **Step 1: 完整重写**
```tsx
/**
* 交易页spec §7.2):搜索优先 + 日期分组时间线。
* 搜索框常驻;方向 chip高级筛选收进 FilterSheet账户/日期/金额区间);
* 列表按日期分组(今天/昨天/具体日期 + 当日收支小计);左滑复制/删除。
* 解析诊断已移至 我的 → 数据 → 解析诊断(/settings/diagnostics
*/
import React, { useMemo, useState } from 'react';
import { Alert, Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { SearchBar } from '../../components/SearchBar';
import { FilterSheet, type AdvancedFilterValues } from '../../components/FilterSheet';
import { SwipeableTransactionCard } from '../../components/SwipeableTransactionCard';
import { groupTransactionsByDate } from '../../components/transactionGroups';
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
import { toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };
export default function TransactionsScreen() {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
const categories = useMetadataStore(s => s.categories);
const [keyword, setKeyword] = useState('');
const [direction, setDirection] = useState<DirectionFilter>('all');
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
const allTransactions = useMemo(() => {
if (!ledger?.transactions) return [];
return ledger.transactions
.map((tx, idx) => ({ tx, idx }))
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
.map(item => item.tx);
}, [ledger]);
const filters: SearchFilters = useMemo(() => ({
keyword: keyword || undefined,
direction: direction === 'all' ? undefined : direction,
account: advanced.account || undefined,
dateFrom: advanced.dateFrom || undefined,
dateTo: advanced.dateTo || undefined,
amountMin: advanced.amountMin || undefined,
amountMax: advanced.amountMax || undefined,
}), [keyword, direction, advanced]);
const filtered = useSearch(allTransactions, filters);
const sections = useMemo(() =>
groupTransactionsByDate(filtered).map(g => ({ ...g, data: g.items })),
[filtered]);
const advancedActive = advanced.account !== '' || advanced.dateFrom !== '' || advanced.dateTo !== '' || advanced.amountMin !== '' || advanced.amountMax !== '';
const todayStr = toDateString(new Date());
const yesterdayStr = toDateString(new Date(Date.now() - 86400000));
const dateLabel = (date: string) =>
date === todayStr ? t('transactions.today') : date === yesterdayStr ? t('transactions.yesterday') : date;
const categoryIdFor = (tx: Transaction): string | undefined => {
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
};
// 左滑:复制(预填今天的录入页)
const handleDuplicate = (tx: Transaction) => {
const draftJson = JSON.stringify({
date: todayStr,
payee: tx.payee,
narration: tx.narration,
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
});
router.push({ pathname: '/transaction/new', params: { draftJson } });
};
// 左滑:删除(确认后从 main.bean 移除)
const handleDelete = (tx: Transaction) => {
Alert.alert(
t('transactions.deleteTitle'),
t('transactions.deleteMessage', { name: tx.narration || tx.payee || tx.date.slice(0, 10) }),
[
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => { deleteTransaction(tx.raw).catch(e => Alert.alert(t('common.error'), String(e))); } },
],
);
};
const directionTabs: { key: DirectionFilter; label: string }[] = [
{ key: 'all', label: t('transactions.filterAll') },
{ key: 'expense', label: t('transactions.filterExpense') },
{ key: 'income', label: t('transactions.filterIncome') },
{ key: 'transfer', label: t('transactions.filterTransfer') },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
</View>
<SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} />
{/* 方向筛选 + 高级筛选入口 */}
<View style={styles.filterRow}>
{directionTabs.map(tab => (
<Pressable key={tab.key} onPress={() => setDirection(tab.key)}
style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
<Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
</Pressable>
))}
<View style={styles.flex1} />
<Pressable
onPress={() => setFilterSheetOpen(true)}
style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
accessibilityRole="button"
accessibilityLabel={t('transactions.filter')}
>
<Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
<Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
</Pressable>
</View>
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderItem={({ item: tx }) => (
<SwipeableTransactionCard
transaction={tx}
categoryId={categoryIdFor(tx)}
showDate={false}
onPress={() => router.push(`/transaction/${tx.id}`)}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
)}
renderSectionHeader={({ section }) => (
<View style={styles.sectionHeader}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{dateLabel(section.date)}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
</Text>
</View>
)}
ListHeaderComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
{filtered.length === allTransactions.length
? t('transactions.countTotal', { count: filtered.length })
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
</Text>
}
ListEmptyComponent={
<Card title={t('transactions.noMatchTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
</Text>
</Card>
}
contentContainerStyle={styles.content}
stickySectionHeadersEnabled={false}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
/>
<FilterSheet
visible={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)}
values={advanced}
onChange={setAdvanced}
onReset={() => setAdvanced(EMPTY_FILTERS)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8, alignItems: 'center' },
filterBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
flex1: { flex: 1 },
content: { padding: 16, paddingTop: 8 },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
});
```
要点:①删除旧的 showAdvancedFilters/showDiagnostics 全部代码与 `Ionicons` 诊断 UI②`common.error` 键若不存在改用现有错误键grep 确认③daySummary 当收支均为 0 时仍显示——可接受④SectionList section 类型由 `{...g, data}` 推导。
- [ ] **Step 2: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -n "diagnostics\|showAdvancedFilters" "src/app/(tabs)/transactions.tsx"` → 无输出
---
### Task 6: 报表页重写anchor 统一 + ⋯ 菜单 + 年报去重)
**Files:**
- Modify: `src/app/(tabs)/report.tsx`(重写状态与导航;看板渲染保留并适配)
- Modify: `src/components/charts/AnnualReport.tsx`(去 MonthlyReport加月度节奏迷你图
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: i18n 加键**
zh`'report.menu': '更多操作'`、`'report.exportImage': '导出图片'`、`'report.monthlyRhythm': '月度节奏'`en'More actions' / 'Export image' / 'Monthly rhythm'。report.aiSummary 已存在。)
- [ ] **Step 2: report.tsx 状态合并为单一 anchor**
- 删除 `viewYear/viewMonth/viewWeekDate` 三个 state 及 prevMonth/nextMonth/changeWeek替换为
```typescript
const [period, setPeriod] = useState<ReportPeriod>('monthly');
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
const [menuOpen, setMenuOpen] = useState(false);
const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
const rangeTxs = useMemo(
() => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
[transactions, range],
);
const rangeStats = useMemo(() => {
// 与旧 weeklyStats/monthlyStats 相同算法,作用于 rangeTxs
}, [rangeTxs]);
const shift = (delta: number) => {
setSelectedReportDate(null);
setAnchor(a => shiftAnchor(a, period, delta));
};
```
- 周期 label统一渲染一个月切换器替代三个
```typescript
const periodLabel = useMemo(() => {
if (period === 'weekly') {
const monday = range.start; const sunday = range.end;
return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5,7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5,7))}/${Number(sunday.slice(8))})`;
}
if (period === 'monthly') return anchor.slice(0, 7);
return `${anchor.slice(0, 4)}年`;
}, [period, anchor, range]);
```
- `monthlyTxs`→`rangeTxs`monthly 时等价);`annualTxs` 删除(年报直接用 transactions + year`netWorthDates` 由 anchor 派生(`new Date(Number(anchor.slice(0,4)), Number(anchor.slice(5,7)) - 1 - i, 1)``handleMonthlySummary` 的 viewYear/viewMonth 参数改从 anchor 派生(`Number(anchor.slice(0,4))`、`Number(anchor.slice(5,7))`)。
- Tab 切换 `setActiveTab``setPeriod`,三个 switcher 合并为一个:` {periodLabel} `onPress `shift(-1)`/`shift(1)`)。
- 收支看板卡weekly/monthly 两个分支合并为一个activeTab 不为 annual 时渲染),数据用 rangeStats + rangeTxsweekly/monthly 文案键按 period 动态取(`t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')` 等)。
- 月 Tab 专属CategoryPie(rangeTxs) + CalendarViewyear/month 从 anchor 派生)+ 选中日期明细 + NetWorthChart 保留;周 Tab看板 + CategoryPie(rangeTxs);年 TabAnnualReport(transactions, year=Number(anchor.slice(0,4)))。
- 空值判断统一为 `rangeTxs.length === 0`(年报也用它)。
- [ ] **Step 3: AI/导出入口收进 ⋯ 菜单(文字标签)**
header 右侧两个图标按钮替换为单个 `ellipsis-horizontal` 图标 → 打开 BottomSheet
```tsx
<BottomSheet visible={menuOpen} onClose={() => setMenuOpen(false)} title={t('report.menu')}>
<Pressable style={menuRow} onPress={() => { setMenuOpen(false); handleMonthlySummary(); }}>
<Ionicons name="sparkles-outline" size={20} color={theme.colors.accent} />
<Text body>{t('report.aiSummary')}</Text>
</Pressable>
<Pressable style={menuRow} onPress={() => { setMenuOpen(false); handleExportImage(); }}>
<Ionicons name="share-outline" size={20} color={theme.colors.accent} />
<Text body>{t('report.exportImage')}</Text>
</Pressable>
</BottomSheet>
```
- [ ] **Step 4: AnnualReport.tsx 去重 + 月度节奏迷你图**
- 删除 `import { MonthlyReport }``<MonthlyReport …/>` 用法。
- 末尾加月度节奏卡12 根迷你柱,高度 = expense/maxExpense
```tsx
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyRhythm')}</Text>
<View style={styles.rhythmRow}>
{report.monthlyTrend.map(m => {
const ratio = maxExpense > 0 ? Math.min(parseFloat(m.expense) / maxExpense, 1) : 0;
return (
<View key={m.month} style={styles.rhythmCol}>
<View style={[styles.rhythmTrack, { backgroundColor: theme.colors.bgTertiary }]}>
<View style={[styles.rhythmFill, { height: `${Math.max(ratio * 100, 2)}%`, backgroundColor: theme.colors.financial.expense }]} />
</View>
<Text style={[styles.rhythmLabel, { color: theme.colors.fgSecondary }]}>{Number(m.month.slice(5))}</Text>
</View>
);
})}
</View>
</View>
```
其中 `const maxExpense = Math.max(...report.monthlyTrend.map(m => parseFloat(m.expense) || 0));`(展示层比率计算,允许 parseFloat——非记账金额。样式`rhythmRow: {flexDirection:'row', gap:4, height:64, marginTop:8}`、`rhythmCol: {flex:1, alignItems:'center', gap:4}`、`rhythmTrack: {flex:1, width:'100%', borderRadius:4, justifyContent:'flex-end', overflow:'hidden'}`、`rhythmFill: {width:'100%', borderRadius:4}`、`rhythmLabel: {fontSize:9}`。
- 检查 MonthlyReport 是否还有其他引用方grep若无引用**保留文件不删**P5 图表重刷时统一处理),仅在报告里注明。
- [ ] **Step 5: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `npx vitest run tests/periodNav.test.ts` → PASS
Run: `grep -n "viewWeekDate\|MonthlyReport" "src/app/(tabs)/report.tsx"` → 无输出
---
### Task 7: 我的页 4 分组 + 解析诊断页
**Files:**
- Modify: `src/app/(tabs)/settings.tsx`(完整重写)
- Create: `src/app/settings/diagnostics.tsx`
- Modify: `src/app/_layout.tsx`(注册新页)、`src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: i18n 加键**
zh
```typescript
'tab.settings': '我的', // 改值(原'设置'key 不变
'settings.groupAccount': '账户与分类',
'settings.groupAutomation': '记账自动化',
'settings.groupData': '数据',
'settings.groupPreferences': '偏好',
'settings.diagnostics': '解析诊断',
'settings.syncBackup': '同步与备份',
'settings.preferencesEntry': '偏好设置',
'settings.import': '导入账单',
```
en'tab.settings': 'Me''Accounts & categories' / 'Automation' / 'Data' / 'Preferences' / 'Parse diagnostics' / 'Sync & backup' / 'Preferences' / 'Import bills'。
- [ ] **Step 2: 完整重写 src/app/(tabs)/settings.tsx**
保留 renderNavLink 模式(删 `fontFamily` 引用4 张分组卡 + 关于卡:
```typescript
// 账户与分类wallet-outline 组)
/account账户树含余额→ t('account.title')
/category → t('settings.categories')
/tag → t('settings.tags')
/budget → t('settings.budgets')
/credit-card → t('settings.creditCards')
/remark-template → t('remark.title')
// 记账自动化flash-outline 组)
/rules → t('tab.rules')
/recurring → t('settings.recurringTitle')
/automation → t('automation.title')
/import → t('settings.import')
// 数据cloud-outline 组)
/settings/sync → t('settings.syncBackup')
/settings/diagnostics → t('settings.diagnostics')
// 偏好cog-outline 组)
/settings/preferences → t('settings.preferencesEntry')
/settings/ai → t('settings.aiSettingsTitle')
/ai/chat → t('ai.chatTitle')
```
组卡用 `Card title={t('settings.groupXxx')}`;关于卡原样保留。页面 header 标题改 `t('tab.settings')`(我的)。
- [ ] **Step 3: 新建 src/app/settings/diagnostics.tsx**(内容从旧交易页 footer 迁移)
```tsx
/** 解析诊断自交易页迁入spec §7.2/§7.4 数据组)。 */
import React from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLedgerStore } from '../../store/ledgerStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { ScreenHeader } from '../../components/ScreenHeader';
export default function DiagnosticsScreen() {
const { theme } = useTheme();
const t = useT();
const ledger = useLedgerStore(s => s.ledger);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader title={t('settings.diagnostics')} />
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('diagnostics.title')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('diagnostics.hint')}
</Text>
</Card>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16 },
});
```
ScreenHeader props 先 Read 确认——title/右侧 slot若需要显式返回处理按其现有用法。
- [ ] **Step 4: _layout.tsx 注册**
在 settings/preferences 行后加:
```tsx
<Stack.Screen name="settings/diagnostics" options={{ headerShown: false }} />
```
- [ ] **Step 5: 验证**
Run: `npm run typecheck && npm test` → 全绿i18n 对等校验会覆盖 tab.settings 改值)
---
### Task 8: P4 全量验收
- [ ] **Step 1: 全量测试 + typecheck**
Run: `npm test` → 全绿(含 periodNav/transactionGroups/search 新测试)
Run: `npm run typecheck` → 无错误
- [ ] **Step 2: 审计**
Run: `grep -rn "fontFamily" "src/app/(tabs)"` → 无输出P1 遗留清理)
Run: `grep -n "rgba(255,255,255" "src/app/(tabs)/index.tsx"` → 无输出Hero 硬编码白字已消除)
Run: `grep -rn "MonthlyReport" src/` → 仅 components/charts/MonthlyReport.tsx 自身定义report.tsx 已无引用)
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. 首页:问候+日期、净资产白卡大数字、本月收支/预算剩余小字、待办条(制造一个到期周期记账验证「入账」按钮)、最近 5 条、查看全部跳交易页
2. 交易页:搜索、方向 chip、筛选弹层账户/日期/金额区间 + 重置)、日期分组(今天/昨天)、组头收支小计、左滑复制(预填今天)/删除(确认弹窗)、点击进详情
3. 报表页:周/月/年 Tab 切换 anchor 不跳变、左右箭头统一、⋯ 菜单两个文字入口、年报无内嵌月报且有月度节奏迷你图、月 Tab 日历点选出明细
4. 我的4 分组条目齐全、每个入口可达、解析诊断页显示计数、Tab 标签为「我的」
5. +面板在四个 Tab 上均可唤起(全局开关开/关两条路径)
---
## 后续计划(不在本文件)
- **P5 管理页+收尾**8 页套 ManagementScreen图表重刷 tokenemoji 清零;硬编码色 grep 审计;无障碍补全;`numpadGlobalEntry` 默认翻 trueMonthlyReport 等死文件清理DatePickerField WEEKDAYS i18n间距 token 化审计