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

59 KiB
Raw Blame History

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

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
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 测试文件,在末尾追加(构造交易的辅助函数沿用该文件现有风格):

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.tssrc/domain/index.ts

  • Step 1: src/domain/periodNav.ts

/**
 * 报表周期导航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
/**
 * 交易按日期分组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 金额区间

  • SearchFiltersamountMin?: string; amountMax?: string;

  • filter 链中direction 判断之后)加:

      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

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.tsxsrc/components/TransactionCard.tsxsrc/app/_layout.tsx

  • Create: src/components/FilterSheet.tsxsrc/components/SwipeableTransactionCard.tsx

  • Modify: src/i18n/zh.tssrc/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

  '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

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
/**
 * 可左滑的交易卡片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
/**
 * 高级筛选底部弹层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.tssrc/i18n/en.ts

  • Step 1: i18n 加键zh/en 对等)

zh

  '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
/**
 * 首页待办条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 卡):

/**
 * 首页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 需 importrouter.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: 完整重写

/**
 * 交易页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 诊断 UIcommon.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.tssrc/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替换为

  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统一渲染一个月切换器替代三个
  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]);
  • monthlyTxsrangeTxsmonthly 时等价);annualTxs 删除(年报直接用 transactions + yearnetWorthDates 由 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 切换 setActiveTabsetPeriod,三个 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

<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

      <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.tssrc/i18n/en.ts

  • Step 1: i18n 加键

zh

  '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 张分组卡 + 关于卡:

// 账户与分类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 迁移)
/** 解析诊断自交易页迁入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 行后加:

<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 化审计