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
59 KiB
UI 重设计 P4:四个 Tab 页 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax. 不执行任何 git add/commit(用户要求,改动留工作区)。不创建额外任务清单。
Goal: 按 spec §7.1–7.4 重做四个 Tab 页:首页(问候 + 净资产白卡 + 待办条 + 最近交易)、交易页(搜索优先 + 日期分组时间线 + FilterSheet + 左滑操作)、报表页(单一 anchor 日期统一导航 + ⋯ 菜单 + 年报去重)、我的页(4 分组)。
Architecture: 纯逻辑(periodNav 周期导航 / transactionGroups 日期分组 / useSearch 金额区间)先 TDD 落地;共享组件(FilterSheet / 重刷 TransactionCard / SwipeableTransactionCard / TodoStrip)随后;四个页面逐页替换,每页替换后全量测试。
Spec: docs/ui-redesign-design.md §7。与 spec 的偏差:①「数据」组的备份恢复/导出入口并入现有「同步与备份」页(/settings/sync 已含备份 UI),不新建页面;②左滑用 gesture-handler 传统 Swipeable(不依赖 reanimated worklet 配置);③报表周期 label 维持现有中文格式(P5 统一 i18n)。
前置状态: P1–P3 已完成(570 测试全绿)。BottomSheet/DatePickerField/CategoryIcon/StatCard/ScreenHeader 可用;gesture-handler 2.28 已安装;_layout.tsx 无 GestureHandlerRootView(T3 加);settings/ 现有 ai/preferences/sync 三个子页,Stack.Screen 需显式注册。
Task 1: 纯逻辑失败测试
Files:
-
Create:
tests/periodNav.test.ts -
Create:
tests/transactionGroups.test.ts -
Modify: 现有 search 测试文件(先
ls tests/ | grep -i search找到它,追加金额区间用例) -
Step 1: tests/periodNav.test.ts
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.ts、src/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 金额区间
-
SearchFilters加amountMin?: 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
compareDecimalsfrom '../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.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:
'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.ts、src/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 需 import;③router.push('/(tabs)/transactions' as any) 若 typedRoutes 不接受,用 router.navigate('/(tabs)/transactions') 或相对段——以实现时 typecheck 为准并报告;④旧 imports(AccountTree/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 诊断 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,替换为:
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]);
-
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} ›(onPressshift(-1)/shift(1))。 -
收支看板卡:weekly/monthly 两个分支合并为一个(activeTab 不为 annual 时渲染),数据用 rangeStats + rangeTxs;weekly/monthly 文案键按 period 动态取(
t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')等)。 -
月 Tab 专属:CategoryPie(rangeTxs) + CalendarView(year/month 从 anchor 派生)+ 选中日期明细 + NetWorthChart 保留;周 Tab:看板 + CategoryPie(rangeTxs);年 Tab:AnnualReport(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.ts、src/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
走查清单(浅/暗双主题):
- 首页:问候+日期、净资产白卡大数字、本月收支/预算剩余小字、待办条(制造一个到期周期记账验证「入账」按钮)、最近 5 条、查看全部跳交易页
- 交易页:搜索、方向 chip、筛选弹层(账户/日期/金额区间 + 重置)、日期分组(今天/昨天)、组头收支小计、左滑复制(预填今天)/删除(确认弹窗)、点击进详情
- 报表页:周/月/年 Tab 切换 anchor 不跳变、左右箭头统一、⋯ 菜单两个文字入口、年报无内嵌月报且有月度节奏迷你图、月 Tab 日历点选出明细
- 我的:4 分组条目齐全、每个入口可达、解析诊断页显示计数、Tab 标签为「我的」
- +面板在四个 Tab 上均可唤起(全局开关开/关两条路径)
后续计划(不在本文件)
- P5 管理页+收尾:8 页套 ManagementScreen;图表重刷 token;emoji 清零;硬编码色 grep 审计;无障碍补全;
numpadGlobalEntry默认翻 true;MonthlyReport 等死文件清理;DatePickerField WEEKDAYS i18n;间距 token 化审计