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
1382 lines
59 KiB
Markdown
1382 lines
59 KiB
Markdown
# UI 重设计 P4:四个 Tab 页 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
|
||
|
||
**Goal:** 按 spec §7.1–7.4 重做四个 Tab 页:首页(问候 + 净资产白卡 + 待办条 + 最近交易)、交易页(搜索优先 + 日期分组时间线 + FilterSheet + 左滑操作)、报表页(单一 anchor 日期统一导航 + ⋯ 菜单 + 年报去重)、我的页(4 分组)。
|
||
|
||
**Architecture:** 纯逻辑(periodNav 周期导航 / transactionGroups 日期分组 / useSearch 金额区间)先 TDD 落地;共享组件(FilterSheet / 重刷 TransactionCard / SwipeableTransactionCard / TodoStrip)随后;四个页面逐页替换,每页替换后全量测试。
|
||
|
||
**Spec:** `docs/ui-redesign-design.md` §7。**与 spec 的偏差**:①「数据」组的备份恢复/导出入口并入现有「同步与备份」页(/settings/sync 已含备份 UI),不新建页面;②左滑用 gesture-handler 传统 `Swipeable`(不依赖 reanimated worklet 配置);③报表周期 label 维持现有中文格式(P5 统一 i18n)。
|
||
|
||
**前置状态:** P1–P3 已完成(570 测试全绿)。BottomSheet/DatePickerField/CategoryIcon/StatCard/ScreenHeader 可用;gesture-handler 2.28 已安装;`_layout.tsx` **无** GestureHandlerRootView(T3 加);`settings/` 现有 ai/preferences/sync 三个子页,Stack.Screen 需显式注册。
|
||
|
||
---
|
||
|
||
### Task 1: 纯逻辑失败测试
|
||
|
||
**Files:**
|
||
- Create: `tests/periodNav.test.ts`
|
||
- Create: `tests/transactionGroups.test.ts`
|
||
- Modify: 现有 search 测试文件(先 `ls tests/ | grep -i search` 找到它,追加金额区间用例)
|
||
|
||
- [ ] **Step 1: tests/periodNav.test.ts**
|
||
|
||
```typescript
|
||
import { describe, expect, it } from 'vitest';
|
||
import { shiftAnchor, periodRange, isoWeekNumber } from '../src/domain/periodNav';
|
||
|
||
describe('shiftAnchor 周期导航', () => {
|
||
it('周:±7 天', () => {
|
||
expect(shiftAnchor('2026-07-21', 'weekly', 1)).toBe('2026-07-28');
|
||
expect(shiftAnchor('2026-07-21', 'weekly', -1)).toBe('2026-07-14');
|
||
});
|
||
|
||
it('月:月末回退钳制(1月31日 → 2月28日)', () => {
|
||
expect(shiftAnchor('2026-01-31', 'monthly', 1)).toBe('2026-02-28');
|
||
expect(shiftAnchor('2026-03-31', 'monthly', -1)).toBe('2026-02-28');
|
||
});
|
||
|
||
it('月:跨年', () => {
|
||
expect(shiftAnchor('2026-12-15', 'monthly', 1)).toBe('2027-01-15');
|
||
expect(shiftAnchor('2026-01-15', 'monthly', -1)).toBe('2025-12-15');
|
||
});
|
||
|
||
it('年:±1 年', () => {
|
||
expect(shiftAnchor('2026-07-21', 'annual', 1)).toBe('2027-07-21');
|
||
expect(shiftAnchor('2026-07-21', 'annual', -1)).toBe('2025-07-21');
|
||
});
|
||
|
||
it('多步 delta', () => {
|
||
expect(shiftAnchor('2026-07-21', 'monthly', 3)).toBe('2026-10-21');
|
||
expect(shiftAnchor('2026-07-21', 'weekly', -2)).toBe('2026-07-07');
|
||
});
|
||
});
|
||
|
||
describe('periodRange 周期范围', () => {
|
||
it('周:周一起周日止(周二 anchor)', () => {
|
||
expect(periodRange('2026-07-21', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' });
|
||
});
|
||
|
||
it('周:周日 anchor 属于本周(周一开头)', () => {
|
||
expect(periodRange('2026-07-26', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' });
|
||
});
|
||
|
||
it('月:整月', () => {
|
||
expect(periodRange('2026-02-10', 'monthly')).toEqual({ start: '2026-02-01', end: '2026-02-28' });
|
||
});
|
||
|
||
it('年:整年', () => {
|
||
expect(periodRange('2026-07-21', 'annual')).toEqual({ start: '2026-01-01', end: '2026-12-31' });
|
||
});
|
||
});
|
||
|
||
describe('isoWeekNumber ISO 周数', () => {
|
||
it('2026-01-01 是第 1 周', () => {
|
||
expect(isoWeekNumber('2026-01-01')).toBe(1);
|
||
});
|
||
|
||
it('2026-07-20 是第 30 周', () => {
|
||
expect(isoWeekNumber('2026-07-20')).toBe(30);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: tests/transactionGroups.test.ts**
|
||
|
||
```typescript
|
||
import { describe, expect, it } from 'vitest';
|
||
import { groupTransactionsByDate } from '../src/components/transactionGroups';
|
||
import type { Transaction } from '../src/domain/types';
|
||
|
||
function tx(id: string, date: string, postings: { account: string; amount?: string }[]): Transaction {
|
||
return {
|
||
id, date, flag: '*', narration: id,
|
||
postings: postings.map(p => ({ account: p.account, amount: p.amount, currency: 'CNY' })),
|
||
tags: [], links: [], raw: '',
|
||
} as Transaction;
|
||
}
|
||
|
||
describe('groupTransactionsByDate 日期分组', () => {
|
||
it('同日合并并计算收支小计', () => {
|
||
const groups = groupTransactionsByDate([
|
||
tx('a', '2026-07-21', [{ account: 'Assets:招行', amount: '-35' }, { account: 'Expenses:餐饮', amount: '35' }]),
|
||
tx('b', '2026-07-21', [{ account: 'Assets:招行', amount: '100' }, { account: 'Income:工资', amount: '-100' }]),
|
||
]);
|
||
expect(groups).toHaveLength(1);
|
||
expect(groups[0].date).toBe('2026-07-21');
|
||
expect(groups[0].expense).toBe('35');
|
||
expect(groups[0].income).toBe('100');
|
||
expect(groups[0].items.map(t => t.id)).toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('保持输入顺序(调用方已按日期倒序)', () => {
|
||
const groups = groupTransactionsByDate([
|
||
tx('a', '2026-07-21', [{ account: 'Expenses:餐饮', amount: '35' }]),
|
||
tx('b', '2026-07-20', [{ account: 'Expenses:餐饮', amount: '20' }]),
|
||
]);
|
||
expect(groups.map(g => g.date)).toEqual(['2026-07-21', '2026-07-20']);
|
||
});
|
||
|
||
it('转账日收支小计为零', () => {
|
||
const groups = groupTransactionsByDate([
|
||
tx('a', '2026-07-21', [{ account: 'Assets:A', amount: '-50' }, { account: 'Assets:B', amount: '50' }]),
|
||
]);
|
||
expect(groups[0].income).toBe('0');
|
||
expect(groups[0].expense).toBe('0');
|
||
});
|
||
|
||
it('空输入返回空数组', () => {
|
||
expect(groupTransactionsByDate([])).toEqual([]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: search 测试追加金额区间用例**
|
||
|
||
找到现有 search 测试文件,在末尾追加(构造交易的辅助函数沿用该文件现有风格):
|
||
|
||
```typescript
|
||
describe('金额区间筛选', () => {
|
||
it('amountMin/amountMax 按主金额绝对值过滤', () => {
|
||
// 三笔:支出 35、支出 200、收入 5000(收入主金额为负 → 绝对值 5000)
|
||
// filters { amountMin: '30', amountMax: '300' } 应只剩支出 35 与支出 200
|
||
// filters { amountMin: '1000' } 应只剩收入 5000
|
||
});
|
||
|
||
it('无金额 posting 的交易在设了下限时被排除', () => {
|
||
// filters { amountMin: '1' } 排除 postings 全无 amount 的交易
|
||
});
|
||
});
|
||
```
|
||
|
||
(实现者按现有测试文件的构造风格补全具体数据与断言。)
|
||
|
||
- [ ] **Step 4: 运行确认失败**
|
||
|
||
Run: `npx vitest run tests/periodNav.test.ts tests/transactionGroups.test.ts` → Expected: FAIL(模块不存在)。金额区间用例也应 FAIL。**若意外通过,报告 BLOCKED。**
|
||
|
||
---
|
||
|
||
### Task 2: periodNav + transactionGroups + useSearch 金额区间
|
||
|
||
**Files:**
|
||
- Create: `src/domain/periodNav.ts`
|
||
- Create: `src/components/transactionGroups.ts`
|
||
- Modify: `src/hooks/useSearch.ts`、`src/domain/index.ts`
|
||
|
||
- [ ] **Step 1: src/domain/periodNav.ts**
|
||
|
||
```typescript
|
||
/**
|
||
* 报表周期导航(P4):单一 anchor 日期 + 周期类型,替代周/月/年三套独立状态。
|
||
* 全部本地时区字符串运算,避免 toISOString 的 UTC 偏移。
|
||
*/
|
||
import { toDateString } from './decimal';
|
||
|
||
export type ReportPeriod = 'weekly' | 'monthly' | 'annual';
|
||
|
||
function parse(dateStr: string): Date {
|
||
const [y, m, d] = dateStr.split('-').map(Number);
|
||
return new Date(y, m - 1, d);
|
||
}
|
||
|
||
/** 平移 anchor:周 ±7 天;月 ±N(月末日钳制);年 ±N。 */
|
||
export function shiftAnchor(anchor: string, period: ReportPeriod, delta: number): string {
|
||
const d = parse(anchor);
|
||
if (period === 'weekly') {
|
||
d.setDate(d.getDate() + 7 * delta);
|
||
} else if (period === 'monthly') {
|
||
const day = d.getDate();
|
||
d.setDate(1);
|
||
d.setMonth(d.getMonth() + delta);
|
||
// 月末钳制:目标月天数不足时退到月末
|
||
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||
d.setDate(Math.min(day, lastDay));
|
||
} else {
|
||
d.setFullYear(d.getFullYear() + delta);
|
||
}
|
||
return toDateString(d);
|
||
}
|
||
|
||
/** anchor 所在周期的起止日期(含两端)。周 = 周一~周日。 */
|
||
export function periodRange(anchor: string, period: ReportPeriod): { start: string; end: string } {
|
||
const d = parse(anchor);
|
||
if (period === 'weekly') {
|
||
const day = d.getDay(); // 0=周日
|
||
const diffToMonday = day === 0 ? -6 : 1 - day;
|
||
const monday = new Date(d);
|
||
monday.setDate(d.getDate() + diffToMonday);
|
||
const sunday = new Date(monday);
|
||
sunday.setDate(monday.getDate() + 6);
|
||
return { start: toDateString(monday), end: toDateString(sunday) };
|
||
}
|
||
if (period === 'monthly') {
|
||
const start = new Date(d.getFullYear(), d.getMonth(), 1);
|
||
const end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||
return { start: toDateString(start), end: toDateString(end) };
|
||
}
|
||
return { start: `${d.getFullYear()}-01-01`, end: `${d.getFullYear()}-12-31` };
|
||
}
|
||
|
||
/** ISO-8601 周数(周一开头)。 */
|
||
export function isoWeekNumber(dateStr: string): number {
|
||
const date = parse(dateStr);
|
||
date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7));
|
||
const week1 = new Date(date.getFullYear(), 0, 4);
|
||
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: src/components/transactionGroups.ts**
|
||
|
||
```typescript
|
||
/**
|
||
* 交易按日期分组(P4 时间线):组内保持输入顺序,组头带当日收支小计。
|
||
* 输入需已由调用方按日期倒序排列。
|
||
*/
|
||
import type { Transaction } from '../domain/types';
|
||
import { addDecimals, negateDecimal } from '../domain/decimal';
|
||
|
||
export interface DayGroup {
|
||
date: string; // YYYY-MM-DD
|
||
income: string;
|
||
expense: string;
|
||
items: Transaction[];
|
||
}
|
||
|
||
export function groupTransactionsByDate(txs: Transaction[]): DayGroup[] {
|
||
const groups: DayGroup[] = [];
|
||
const byDate = new Map<string, DayGroup>();
|
||
for (const t of txs) {
|
||
const date = t.date.slice(0, 10);
|
||
let group = byDate.get(date);
|
||
if (!group) {
|
||
group = { date, income: '0', expense: '0', items: [] };
|
||
byDate.set(date, group);
|
||
groups.push(group);
|
||
}
|
||
group.items.push(t);
|
||
const incomeAmounts: string[] = [];
|
||
const expenseAmounts: string[] = [];
|
||
for (const p of t.postings) {
|
||
if (!p.amount) continue;
|
||
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
|
||
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
|
||
}
|
||
if (incomeAmounts.length) group.income = addDecimals([group.income, ...incomeAmounts]);
|
||
if (expenseAmounts.length) group.expense = addDecimals([group.expense, ...expenseAmounts]);
|
||
}
|
||
return groups;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: useSearch.ts 金额区间**
|
||
|
||
- `SearchFilters` 加 `amountMin?: string; amountMax?: string;`
|
||
- filter 链中(direction 判断之后)加:
|
||
|
||
```typescript
|
||
if (debouncedFilters.amountMin || debouncedFilters.amountMax) {
|
||
const raw = t.postings.find(p => p.amount)?.amount;
|
||
const abs = raw?.replace(/^-/, '');
|
||
if (debouncedFilters.amountMin && (!abs || compareDecimals(abs, debouncedFilters.amountMin) < 0)) return false;
|
||
if (debouncedFilters.amountMax && abs && compareDecimals(abs, debouncedFilters.amountMax) > 0) return false;
|
||
}
|
||
```
|
||
|
||
- 顶部 import `compareDecimals` from '../domain/decimal'。
|
||
|
||
- [ ] **Step 4: domain/index.ts 加 barrel**
|
||
|
||
```typescript
|
||
export * from './periodNav';
|
||
```
|
||
|
||
- [ ] **Step 5: 验证**
|
||
|
||
Run: `npx vitest run tests/periodNav.test.ts tests/transactionGroups.test.ts` + search 测试文件 → 全 PASS
|
||
Run: `npm run typecheck` → 无错误
|
||
|
||
---
|
||
|
||
### Task 3: 共享组件(BottomSheet scrollable / FilterSheet / TransactionCard 重刷 / SwipeableTransactionCard / 手势根)
|
||
|
||
**Files:**
|
||
- Modify: `src/components/BottomSheet.tsx`、`src/components/TransactionCard.tsx`、`src/app/_layout.tsx`
|
||
- Create: `src/components/FilterSheet.tsx`、`src/components/SwipeableTransactionCard.tsx`
|
||
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`
|
||
|
||
- [ ] **Step 1: BottomSheet 加 scrollable 选项(P2 遗留)**
|
||
|
||
props 加 `scrollable?: boolean`;为 true 时 children 包一层 `<ScrollView style={{ maxHeight: 480 }} keyboardShouldPersistTaps="handled">`(import ScrollView)。
|
||
|
||
- [ ] **Step 2: i18n 加键(zh/en 对等)**
|
||
|
||
zh:
|
||
|
||
```typescript
|
||
'transactions.filter': '筛选',
|
||
'transactions.amountMin': '最小金额',
|
||
'transactions.amountMax': '最大金额',
|
||
'transactions.today': '今天',
|
||
'transactions.yesterday': '昨天',
|
||
'transactions.daySummary': '收 {{income}} · 支 {{expense}}',
|
||
'transactions.duplicate': '复制',
|
||
'transactions.deleteTitle': '删除交易',
|
||
'transactions.deleteMessage': '确定删除「{{name}}」吗?此操作会同时从 main.bean 移除。',
|
||
'filter.title': '高级筛选',
|
||
'filter.reset': '重置',
|
||
'filter.apply': '完成',
|
||
```
|
||
|
||
en 对应:'Filter' / 'Min amount' / 'Max amount' / 'Today' / 'Yesterday' / '+{{income}} · -{{expense}}' / 'Duplicate' / 'Delete transaction' / 'Delete "{{name}}"? This also removes it from main.bean.' / 'Advanced filters' / 'Reset' / 'Done'。
|
||
|
||
(若 transactions.deleteTitle/deleteMessage 等键已存在则复用,先 grep 确认,勿重复添加;i18n 插值语法以现有键为准。)
|
||
|
||
- [ ] **Step 3: TransactionCard 重刷(spec §7.2)**
|
||
|
||
完整替换 `src/components/TransactionCard.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||
import { useTheme } from '../theme';
|
||
import { useT } from '../i18n';
|
||
import { CategoryIcon } from './CategoryIcon';
|
||
import type { Transaction } from '../domain/types';
|
||
|
||
interface TransactionCardProps {
|
||
transaction: Transaction;
|
||
onPress?: (t: Transaction) => void;
|
||
/** 命中分类 id(调用方按 linkedAccount 匹配);缺省用方向兜底图标。 */
|
||
categoryId?: string;
|
||
/** 时间线分组下隐藏日期(默认 true 保持旧行为)。 */
|
||
showDate?: boolean;
|
||
}
|
||
|
||
/** 交易卡片:CategoryIcon 圆底 + 摘要/账户小字 + 右侧等宽金额(支出黑、收入绿+、转账蓝)。 */
|
||
export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress, categoryId, showDate = true }: TransactionCardProps) {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
|
||
const isIncome = transaction.postings.some(p => p.account.startsWith('Income'));
|
||
const direction = isExpense ? 'expense' : isIncome ? 'income' : 'transfer';
|
||
const rawAmount = transaction.postings.find(p => p.amount)?.amount ?? '';
|
||
const absAmount = rawAmount.replace(/^-/, '');
|
||
const amountColor = direction === 'income' ? theme.colors.financial.income
|
||
: direction === 'transfer' ? theme.colors.financial.transfer
|
||
: theme.colors.fgPrimary;
|
||
const amountText = direction === 'income' ? `+${absAmount}` : direction === 'expense' ? `-${absAmount}` : absAmount;
|
||
// 账户小字:第一条资金腿的短名
|
||
const assetAccount = transaction.postings.find(p => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'))?.account;
|
||
const accountShort = assetAccount?.split(':').pop();
|
||
const subtitle = [
|
||
showDate ? transaction.date.slice(0, 10) : '',
|
||
accountShort ?? '',
|
||
].filter(Boolean).join(' · ');
|
||
|
||
return (
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${amountText} ${transaction.date.slice(0, 10)}`}
|
||
onPress={() => onPress?.(transaction)}
|
||
style={({ pressed }) => [
|
||
styles.card,
|
||
{
|
||
backgroundColor: theme.colors.bgSecondary,
|
||
borderRadius: theme.radii.lg,
|
||
padding: theme.spacing.md,
|
||
borderWidth: StyleSheet.hairlineWidth,
|
||
borderColor: theme.colors.border,
|
||
opacity: pressed ? 0.8 : 1,
|
||
},
|
||
]}
|
||
>
|
||
<CategoryIcon categoryId={categoryId ?? direction} size={36} />
|
||
<View style={styles.body}>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
|
||
{transaction.narration || transaction.payee || t('transaction.noSummary')}
|
||
</Text>
|
||
{subtitle ? (
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]} numberOfLines={1}>
|
||
{subtitle}
|
||
</Text>
|
||
) : null}
|
||
</View>
|
||
<Text style={[theme.typography.body, { color: amountColor, fontWeight: '700', fontSize: 15, fontVariant: ['tabular-nums'] }]}>
|
||
{amountText}
|
||
</Text>
|
||
{transaction.tags.length > 0 && (
|
||
<View style={styles.tags}>
|
||
{transaction.tags.map(tag => (
|
||
<Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accent, fontWeight: '600' }]}>
|
||
#{tag}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
)}
|
||
</Pressable>
|
||
);
|
||
});
|
||
|
||
const styles = StyleSheet.create({
|
||
card: { marginBottom: 8, flexDirection: 'row', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
||
body: { flex: 1 },
|
||
tags: { flexDirection: 'row', gap: 4, marginLeft: 46, flexBasis: '100%', flexWrap: 'wrap' },
|
||
tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
|
||
});
|
||
```
|
||
|
||
注意:tags 行布局改为 `flexBasis: '100%'` 换行(card 变 flexWrap 行容器)。金额 `fontFamily: 'monospace'` 改为 `fontVariant: ['tabular-nums']`(P1 决策)。
|
||
|
||
- [ ] **Step 4: SwipeableTransactionCard.tsx**
|
||
|
||
```tsx
|
||
/**
|
||
* 可左滑的交易卡片(spec §7.2):右滑出「复制 / 删除」快捷操作。
|
||
* 用 gesture-handler 传统 Swipeable(不依赖 reanimated worklet)。
|
||
*/
|
||
import React, { useRef } from 'react';
|
||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||
import { Swipeable } from 'react-native-gesture-handler';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useTheme } from '../theme';
|
||
import { useT } from '../i18n';
|
||
import { TransactionCard } from './TransactionCard';
|
||
import type { Transaction } from '../domain/types';
|
||
|
||
interface SwipeableTransactionCardProps {
|
||
transaction: Transaction;
|
||
categoryId?: string;
|
||
showDate?: boolean;
|
||
onPress: (t: Transaction) => void;
|
||
/** 复制:跳转录入页预填(由调用方实现)。 */
|
||
onDuplicate: (t: Transaction) => void;
|
||
/** 删除:调用方负责 confirm + deleteTransaction。 */
|
||
onDelete: (t: Transaction) => void;
|
||
}
|
||
|
||
export function SwipeableTransactionCard({ transaction, categoryId, showDate, onPress, onDuplicate, onDelete }: SwipeableTransactionCardProps) {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const swipeRef = useRef<Swipeable>(null);
|
||
|
||
const renderRightActions = () => (
|
||
<View style={styles.actions}>
|
||
<Pressable
|
||
onPress={() => { swipeRef.current?.close(); onDuplicate(transaction); }}
|
||
style={[styles.actionBtn, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.md }]}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('transactions.duplicate')}
|
||
>
|
||
<Ionicons name="copy-outline" size={20} color={theme.colors.fgPrimary} />
|
||
<Text style={[styles.actionText, { color: theme.colors.fgPrimary }]}>{t('transactions.duplicate')}</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={() => { swipeRef.current?.close(); onDelete(transaction); }}
|
||
style={[styles.actionBtn, { backgroundColor: theme.colors.financial.expense, borderRadius: theme.radii.md }]}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('common.delete')}
|
||
>
|
||
<Ionicons name="trash-outline" size={20} color={theme.colors.fgInverse} />
|
||
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{t('common.delete')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
);
|
||
|
||
return (
|
||
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
|
||
<TransactionCard transaction={transaction} categoryId={categoryId} showDate={showDate} onPress={onPress} />
|
||
</Swipeable>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
actions: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingLeft: 8, marginBottom: 8 },
|
||
actionBtn: { width: 64, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 10 },
|
||
actionText: { fontSize: 11, fontWeight: '600' },
|
||
});
|
||
```
|
||
|
||
(Alert import 若未用可去;onDelete 的确认弹窗由页面实现。)
|
||
|
||
- [ ] **Step 5: FilterSheet.tsx**
|
||
|
||
```tsx
|
||
/**
|
||
* 高级筛选底部弹层(spec §6):账户 / 日期范围 / 金额区间。
|
||
* 受控组件:值由父级持有,「完成」回调应用,「重置」清空。
|
||
*/
|
||
import React from 'react';
|
||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||
import { useTheme, createCommonStyles } from '../theme';
|
||
import { useT } from '../i18n';
|
||
import { BottomSheet } from './BottomSheet';
|
||
import { DatePickerField } from './DatePickerField';
|
||
import { Button } from './Button';
|
||
|
||
export interface AdvancedFilterValues {
|
||
account: string;
|
||
dateFrom: string;
|
||
dateTo: string;
|
||
amountMin: string;
|
||
amountMax: string;
|
||
}
|
||
|
||
interface FilterSheetProps {
|
||
visible: boolean;
|
||
onClose: () => void;
|
||
values: AdvancedFilterValues;
|
||
onChange: (values: AdvancedFilterValues) => void;
|
||
onReset: () => void;
|
||
}
|
||
|
||
export function FilterSheet({ visible, onClose, values, onChange, onReset }: FilterSheetProps) {
|
||
const { theme } = useTheme();
|
||
const commonStyles = createCommonStyles(theme);
|
||
const t = useT();
|
||
const set = (patch: Partial<AdvancedFilterValues>) => onChange({ ...values, ...patch });
|
||
|
||
return (
|
||
<BottomSheet visible={visible} onClose={onClose} title={t('filter.title')} scrollable>
|
||
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
|
||
{t('transactions.accountFilterPlaceholder')}
|
||
</Text>
|
||
<TextInput
|
||
value={values.account}
|
||
onChangeText={v => set({ account: v })}
|
||
placeholder={t('transactions.accountFilterPlaceholder')}
|
||
placeholderTextColor={theme.colors.fgSecondary}
|
||
style={commonStyles.input}
|
||
/>
|
||
|
||
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
|
||
{t('transactions.dateFrom')} ~ {t('transactions.dateTo')}
|
||
</Text>
|
||
<View style={styles.row}>
|
||
<View style={styles.flex1}><DatePickerField value={values.dateFrom} onChange={v => set({ dateFrom: v })} placeholder={t('transactions.dateFrom')} /></View>
|
||
<View style={styles.flex1}><DatePickerField value={values.dateTo} onChange={v => set({ dateTo: v })} placeholder={t('transactions.dateTo')} /></View>
|
||
</View>
|
||
|
||
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
|
||
{t('transactions.amountMin')} ~ {t('transactions.amountMax')}
|
||
</Text>
|
||
<View style={styles.row}>
|
||
<TextInput
|
||
value={values.amountMin}
|
||
onChangeText={v => set({ amountMin: v })}
|
||
placeholder={t('transactions.amountMin')}
|
||
placeholderTextColor={theme.colors.fgSecondary}
|
||
keyboardType="decimal-pad"
|
||
style={[commonStyles.input, styles.flex1]}
|
||
/>
|
||
<TextInput
|
||
value={values.amountMax}
|
||
onChangeText={v => set({ amountMax: v })}
|
||
placeholder={t('transactions.amountMax')}
|
||
placeholderTextColor={theme.colors.fgSecondary}
|
||
keyboardType="decimal-pad"
|
||
style={[commonStyles.input, styles.flex1]}
|
||
/>
|
||
</View>
|
||
|
||
<View style={[styles.row, styles.actions]}>
|
||
<View style={styles.flex1}>
|
||
<Button label={t('filter.reset')} variant="secondary" onPress={onReset} />
|
||
</View>
|
||
<View style={styles.flex1}>
|
||
<Button label={t('filter.apply')} onPress={onClose} />
|
||
</View>
|
||
</View>
|
||
</BottomSheet>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
label: { marginTop: 10, marginBottom: 4 },
|
||
row: { flexDirection: 'row', gap: 8 },
|
||
flex1: { flex: 1 },
|
||
actions: { marginTop: 16 },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: _layout.tsx 加 GestureHandlerRootView**
|
||
|
||
`import { GestureHandlerRootView } from 'react-native-gesture-handler';`,RootLayout 最外层包裹:`<GestureHandlerRootView style={{ flex: 1 }}><ThemeProvider>…</ThemeProvider></GestureHandlerRootView>`。
|
||
|
||
- [ ] **Step 7: 验证**
|
||
|
||
Run: `npm run typecheck && npm test` → 全绿
|
||
|
||
---
|
||
|
||
### Task 4: 首页重写 + TodoStrip
|
||
|
||
**Files:**
|
||
- Create: `src/components/TodoStrip.tsx`
|
||
- Modify: `src/app/(tabs)/index.tsx`(完整重写)、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||
|
||
- [ ] **Step 1: i18n 加键(zh/en 对等)**
|
||
|
||
zh:
|
||
|
||
```typescript
|
||
'home.greetingMorning': '早上好',
|
||
'home.greetingAfternoon': '下午好',
|
||
'home.greetingEvening': '晚上好',
|
||
'home.monthExpense': '本月支出',
|
||
'home.monthIncome': '本月收入',
|
||
'home.budgetRemaining': '预算剩余',
|
||
'home.recentTransactions': '最近交易',
|
||
'home.viewAll': '查看全部',
|
||
'todo.title': '待办',
|
||
'todo.dueRecurring': '周期记账「{{name}}」到期',
|
||
'todo.creditCardDue': '{{name}} 还款日 {{date}}',
|
||
'todo.pendingDrafts': '{{count}} 条自动账单待确认',
|
||
'todo.confirm': '入账',
|
||
```
|
||
|
||
en 对应:'Good morning' / 'Good afternoon' / 'Good evening' / 'Spent this month' / 'Earned this month' / 'Budget left' / 'Recent transactions' / 'View all' / 'To-do' / 'Recurring "{{name}}" is due' / '{{name}} payment due {{date}}' / '{{count}} auto bills to confirm' / 'Post'。
|
||
|
||
- [ ] **Step 2: src/components/TodoStrip.tsx**
|
||
|
||
```tsx
|
||
/**
|
||
* 首页待办条(spec §7.1):周期记账到期 / 信用卡还款提醒 / 未确认自动账单。
|
||
* 三类都无待办时不渲染。
|
||
*/
|
||
import React, { useMemo } from 'react';
|
||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||
import { useRouter } from 'expo-router';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useTheme } from '../theme';
|
||
import { useT } from '../i18n';
|
||
import { Card } from './Card';
|
||
import { useLedgerStore } from '../store/ledgerStore';
|
||
import { useMetadataStore } from '../store/metadataStore';
|
||
import { useAutomationStore } from '../store/automationStore';
|
||
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../domain/recurring';
|
||
import { calculateBillingPeriod } from '../domain/creditCards';
|
||
import { toDateString } from '../domain/decimal';
|
||
import { shiftAnchor } from '../domain/periodNav';
|
||
|
||
export function TodoStrip() {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const router = useRouter();
|
||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
||
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
||
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
|
||
const creditCards = useMetadataStore(s => s.creditCards);
|
||
const draftsCount = useAutomationStore(s => s.drafts.length);
|
||
|
||
const todayStr = useMemo(() => toDateString(new Date()), []);
|
||
const weekLater = useMemo(() => shiftAnchor(todayStr, 'weekly', 1), [todayStr]);
|
||
|
||
// autoConfirm 的由首页自动处理,待办条只列需手动确认的
|
||
const dueRecurring = useMemo(
|
||
() => getDueRecurring(recurringTransactions, todayStr).filter(r => !r.autoConfirm),
|
||
[recurringTransactions, todayStr],
|
||
);
|
||
const dueCards = useMemo(
|
||
() => creditCards.filter(card => {
|
||
const period = calculateBillingPeriod(card, new Date());
|
||
return period.dueDate >= todayStr && period.dueDate <= weekLater;
|
||
}),
|
||
[creditCards, todayStr, weekLater],
|
||
);
|
||
|
||
if (dueRecurring.length === 0 && dueCards.length === 0 && draftsCount === 0) return null;
|
||
|
||
const handleConfirmRecurring = async (rec: (typeof dueRecurring)[number]) => {
|
||
try {
|
||
const draft = instantiateRecurring(rec);
|
||
await addTransaction(draft);
|
||
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
|
||
updateRecurringTransaction(rec.id, { nextDueDate });
|
||
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
|
||
} catch (e) {
|
||
Alert.alert(t('home.recurringFail'), String(e));
|
||
}
|
||
};
|
||
|
||
const renderRow = (icon: keyof typeof Ionicons.glyphMap, text: string, action?: { label: string; onPress: () => void }, key: string) => (
|
||
<View key={key} style={[styles.row, { borderBottomColor: theme.colors.divider }]}>
|
||
<Ionicons name={icon} size={18} color={theme.colors.accent} />
|
||
<Text style={[theme.typography.bodySmall, styles.rowText, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
|
||
{text}
|
||
</Text>
|
||
{action ? (
|
||
<Pressable
|
||
onPress={action.onPress}
|
||
style={[styles.actionBtn, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.full }]}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={action.label}
|
||
>
|
||
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{action.label}</Text>
|
||
</Pressable>
|
||
) : (
|
||
<Ionicons name="chevron-forward" size={16} color={theme.colors.fgSecondary} />
|
||
)}
|
||
</View>
|
||
);
|
||
|
||
return (
|
||
<Card title={t('todo.title')}>
|
||
{dueRecurring.map(rec =>
|
||
renderRow('repeat-outline', t('todo.dueRecurring', { name: rec.name }), { label: t('todo.confirm'), onPress: () => handleConfirmRecurring(rec) }, `rec-${rec.id}`),
|
||
)}
|
||
{dueCards.map(card =>
|
||
renderRow('card-outline', t('todo.creditCardDue', { name: card.name, date: calculateBillingPeriod(card, new Date()).dueDate.slice(5) }), { label: t('common.confirm'), onPress: () => router.push('/credit-card' as any) }, `card-${card.id}`),
|
||
)}
|
||
{draftsCount > 0 &&
|
||
renderRow('flash-outline', t('todo.pendingDrafts', { count: draftsCount }), { label: t('common.confirm'), onPress: () => router.push('/automation' as any) }, 'drafts')}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth },
|
||
rowText: { flex: 1 },
|
||
actionBtn: { paddingHorizontal: 12, paddingVertical: 5 },
|
||
actionText: { fontSize: 12, fontWeight: '700' },
|
||
});
|
||
```
|
||
|
||
(`router.push('/credit-card' as any)`:若 typedRoutes 接受字面量则去 `as any`——参考现有 settings.tsx 的 `as Href` 写法,与之一致即可。)
|
||
|
||
- [ ] **Step 3: 完整重写 src/app/(tabs)/index.tsx**
|
||
|
||
结构(保留现有数据计算与 autoConfirm effect,删除 AccountTree/Bento 双格/Hero accent 卡):
|
||
|
||
```tsx
|
||
/**
|
||
* 首页(spec §7.1):今日视角。
|
||
* 问候 header → 净资产白卡(本月收支/预算剩余小字)→ 待办条 → 最近 5 条 → 月度趋势。
|
||
* 账户树已下沉到「我的」页(/account)。
|
||
*/
|
||
import React, { useEffect, useMemo, useRef } from 'react';
|
||
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { useRouter } from 'expo-router';
|
||
import { useLedgerStore } from '../../store/ledgerStore';
|
||
import { useMetadataStore } from '../../store/metadataStore';
|
||
import { useTheme } from '../../theme';
|
||
import { useT } from '../../i18n';
|
||
import { Card } from '../../components/Card';
|
||
import { TransactionCard } from '../../components/TransactionCard';
|
||
import { TodoStrip } from '../../components/TodoStrip';
|
||
import { TrendLine } from '../../components/charts/TrendLine';
|
||
import { calculateNetWorth } from '../../domain/netWorth';
|
||
import { groupByMonth } from '../../domain/chartStats';
|
||
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
|
||
import { calculateBudgetProgress } from '../../domain/budgets';
|
||
import { toDateString } from '../../domain/decimal';
|
||
import type { Transaction } from '../../domain/types';
|
||
|
||
export default function HomeScreen() {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const router = useRouter();
|
||
|
||
const ledger = useLedgerStore(s => s.ledger);
|
||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
||
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
||
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
|
||
const budgets = useMetadataStore(s => s.budgets);
|
||
const categories = useMetadataStore(s => s.categories);
|
||
|
||
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
||
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
|
||
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
|
||
const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);
|
||
|
||
const todayStr = useMemo(() => toDateString(new Date()), []);
|
||
const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);
|
||
|
||
// 周期记账自动触发(autoConfirm 项启动时自动入账)——逻辑与旧版一致
|
||
const autoConfirmed = useRef(false);
|
||
useEffect(() => { /* …原样保留旧实现… */ }, [dueRecurring, addTransaction, updateRecurringTransaction]);
|
||
|
||
// 月度预算剩余合计(无月度预算则不显示)
|
||
const budgetRemaining = useMemo(() => {
|
||
const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
|
||
if (monthlyBudgets.length === 0) return null;
|
||
const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
|
||
return remainings.reduce((a, b) => addDecimals([a, b]), '0');
|
||
}, [budgets, transactions, todayStr]);
|
||
|
||
// 最近 5 条(按日期+序号倒序)
|
||
const recentTxs = useMemo(() =>
|
||
transactions.map((tx, idx) => ({ tx, idx }))
|
||
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
|
||
.slice(0, 5)
|
||
.map(item => item.tx),
|
||
[transactions]);
|
||
|
||
// 分类匹配(TransactionCard 图标)
|
||
const categoryIdFor = (tx: Transaction): string | undefined => {
|
||
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
|
||
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
|
||
};
|
||
|
||
// 问候语 + 日期行
|
||
const hour = new Date().getHours();
|
||
const greeting = hour < 12 ? t('home.greetingMorning') : hour < 18 ? t('home.greetingAfternoon') : t('home.greetingEvening');
|
||
const dateLine = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });
|
||
|
||
const fmtAmount = (s: string) => { /* …原样保留旧实现… */ };
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||
<View style={styles.header}>
|
||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
|
||
</View>
|
||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||
{/* 净资产白卡 */}
|
||
<Card>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
|
||
<Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
|
||
{fmtAmount(netWorth.netWorth)}
|
||
</Text>
|
||
<View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
|
||
<View style={styles.heroStat}>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||
-{fmtAmount(currentMonth?.expense.toString() ?? '0')}
|
||
</Text>
|
||
</View>
|
||
<View style={styles.heroStat}>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||
+{fmtAmount(currentMonth?.income.toString() ?? '0')}
|
||
</Text>
|
||
</View>
|
||
{budgetRemaining !== null && (
|
||
<View style={styles.heroStat}>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||
{fmtAmount(budgetRemaining)}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</Card>
|
||
|
||
<TodoStrip />
|
||
|
||
{/* 最近 5 条交易 */}
|
||
{recentTxs.length > 0 && (
|
||
<Card title={t('home.recentTransactions')}>
|
||
{recentTxs.map(tx => (
|
||
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
|
||
))}
|
||
<Pressable onPress={() => router.push('/(tabs)/transactions' as any)} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} →</Text>
|
||
</Pressable>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 月度趋势 */}
|
||
{monthlyData.length > 0 && (
|
||
<Card title={t('home.monthlyTrend')}>
|
||
<TrendLine transactions={transactions} />
|
||
</Card>
|
||
)}
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
page: { flex: 1 },
|
||
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||
content: { padding: 16, paddingBottom: 40 },
|
||
heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
|
||
heroStat: { gap: 2 },
|
||
viewAll: { alignItems: 'center', paddingVertical: 6 },
|
||
});
|
||
```
|
||
|
||
要点:①autoConfirm effect 与 fmtAmount 从旧文件原样搬运;②`addDecimals` 需 import;③`router.push('/(tabs)/transactions' as any)` 若 typedRoutes 不接受,用 `router.navigate('/(tabs)/transactions')` 或相对段——以实现时 typecheck 为准并报告;④旧 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: 完整重写**
|
||
|
||
```tsx
|
||
/**
|
||
* 交易页(spec §7.2):搜索优先 + 日期分组时间线。
|
||
* 搜索框常驻;方向 chip;高级筛选收进 FilterSheet(账户/日期/金额区间);
|
||
* 列表按日期分组(今天/昨天/具体日期 + 当日收支小计);左滑复制/删除。
|
||
* 解析诊断已移至 我的 → 数据 → 解析诊断(/settings/diagnostics)。
|
||
*/
|
||
import React, { useMemo, useState } from 'react';
|
||
import { Alert, Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { useRouter } from 'expo-router';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useLedgerStore } from '../../store/ledgerStore';
|
||
import { useMetadataStore } from '../../store/metadataStore';
|
||
import { useTheme, createCommonStyles } from '../../theme';
|
||
import { useT } from '../../i18n';
|
||
import { Card } from '../../components/Card';
|
||
import { SearchBar } from '../../components/SearchBar';
|
||
import { FilterSheet, type AdvancedFilterValues } from '../../components/FilterSheet';
|
||
import { SwipeableTransactionCard } from '../../components/SwipeableTransactionCard';
|
||
import { groupTransactionsByDate } from '../../components/transactionGroups';
|
||
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
|
||
import { toDateString } from '../../domain/decimal';
|
||
import type { Transaction } from '../../domain/types';
|
||
|
||
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
|
||
|
||
const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };
|
||
|
||
export default function TransactionsScreen() {
|
||
const { theme } = useTheme();
|
||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||
const t = useT();
|
||
const router = useRouter();
|
||
const ledger = useLedgerStore(s => s.ledger);
|
||
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
|
||
const categories = useMetadataStore(s => s.categories);
|
||
|
||
const [keyword, setKeyword] = useState('');
|
||
const [direction, setDirection] = useState<DirectionFilter>('all');
|
||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||
const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
|
||
|
||
const allTransactions = useMemo(() => {
|
||
if (!ledger?.transactions) return [];
|
||
return ledger.transactions
|
||
.map((tx, idx) => ({ tx, idx }))
|
||
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
|
||
.map(item => item.tx);
|
||
}, [ledger]);
|
||
|
||
const filters: SearchFilters = useMemo(() => ({
|
||
keyword: keyword || undefined,
|
||
direction: direction === 'all' ? undefined : direction,
|
||
account: advanced.account || undefined,
|
||
dateFrom: advanced.dateFrom || undefined,
|
||
dateTo: advanced.dateTo || undefined,
|
||
amountMin: advanced.amountMin || undefined,
|
||
amountMax: advanced.amountMax || undefined,
|
||
}), [keyword, direction, advanced]);
|
||
|
||
const filtered = useSearch(allTransactions, filters);
|
||
const sections = useMemo(() =>
|
||
groupTransactionsByDate(filtered).map(g => ({ ...g, data: g.items })),
|
||
[filtered]);
|
||
|
||
const advancedActive = advanced.account !== '' || advanced.dateFrom !== '' || advanced.dateTo !== '' || advanced.amountMin !== '' || advanced.amountMax !== '';
|
||
|
||
const todayStr = toDateString(new Date());
|
||
const yesterdayStr = toDateString(new Date(Date.now() - 86400000));
|
||
const dateLabel = (date: string) =>
|
||
date === todayStr ? t('transactions.today') : date === yesterdayStr ? t('transactions.yesterday') : date;
|
||
|
||
const categoryIdFor = (tx: Transaction): string | undefined => {
|
||
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
|
||
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
|
||
};
|
||
|
||
// 左滑:复制(预填今天的录入页)
|
||
const handleDuplicate = (tx: Transaction) => {
|
||
const draftJson = JSON.stringify({
|
||
date: todayStr,
|
||
payee: tx.payee,
|
||
narration: tx.narration,
|
||
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
|
||
});
|
||
router.push({ pathname: '/transaction/new', params: { draftJson } });
|
||
};
|
||
|
||
// 左滑:删除(确认后从 main.bean 移除)
|
||
const handleDelete = (tx: Transaction) => {
|
||
Alert.alert(
|
||
t('transactions.deleteTitle'),
|
||
t('transactions.deleteMessage', { name: tx.narration || tx.payee || tx.date.slice(0, 10) }),
|
||
[
|
||
{ text: t('common.cancel'), style: 'cancel' },
|
||
{ text: t('common.delete'), style: 'destructive', onPress: () => { deleteTransaction(tx.raw).catch(e => Alert.alert(t('common.error'), String(e))); } },
|
||
],
|
||
);
|
||
};
|
||
|
||
const directionTabs: { key: DirectionFilter; label: string }[] = [
|
||
{ key: 'all', label: t('transactions.filterAll') },
|
||
{ key: 'expense', label: t('transactions.filterExpense') },
|
||
{ key: 'income', label: t('transactions.filterIncome') },
|
||
{ key: 'transfer', label: t('transactions.filterTransfer') },
|
||
];
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||
<View style={styles.header}>
|
||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
|
||
</View>
|
||
|
||
<SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} />
|
||
|
||
{/* 方向筛选 + 高级筛选入口 */}
|
||
<View style={styles.filterRow}>
|
||
{directionTabs.map(tab => (
|
||
<Pressable key={tab.key} onPress={() => setDirection(tab.key)}
|
||
style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
|
||
<Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
|
||
</Pressable>
|
||
))}
|
||
<View style={styles.flex1} />
|
||
<Pressable
|
||
onPress={() => setFilterSheetOpen(true)}
|
||
style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('transactions.filter')}
|
||
>
|
||
<Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
|
||
<Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
<SectionList
|
||
sections={sections}
|
||
keyExtractor={(item) => item.id}
|
||
renderItem={({ item: tx }) => (
|
||
<SwipeableTransactionCard
|
||
transaction={tx}
|
||
categoryId={categoryIdFor(tx)}
|
||
showDate={false}
|
||
onPress={() => router.push(`/transaction/${tx.id}`)}
|
||
onDuplicate={handleDuplicate}
|
||
onDelete={handleDelete}
|
||
/>
|
||
)}
|
||
renderSectionHeader={({ section }) => (
|
||
<View style={styles.sectionHeader}>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||
{dateLabel(section.date)}
|
||
</Text>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
|
||
{t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
ListHeaderComponent={
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
|
||
{filtered.length === allTransactions.length
|
||
? t('transactions.countTotal', { count: filtered.length })
|
||
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
|
||
</Text>
|
||
}
|
||
ListEmptyComponent={
|
||
<Card title={t('transactions.noMatchTitle')}>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||
{allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
|
||
</Text>
|
||
</Card>
|
||
}
|
||
contentContainerStyle={styles.content}
|
||
stickySectionHeadersEnabled={false}
|
||
initialNumToRender={10}
|
||
maxToRenderPerBatch={10}
|
||
windowSize={5}
|
||
/>
|
||
|
||
<FilterSheet
|
||
visible={filterSheetOpen}
|
||
onClose={() => setFilterSheetOpen(false)}
|
||
values={advanced}
|
||
onChange={setAdvanced}
|
||
onReset={() => setAdvanced(EMPTY_FILTERS)}
|
||
/>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
page: { flex: 1 },
|
||
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
|
||
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8, alignItems: 'center' },
|
||
filterBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||
flex1: { flex: 1 },
|
||
content: { padding: 16, paddingTop: 8 },
|
||
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
|
||
});
|
||
```
|
||
|
||
要点:①删除旧的 showAdvancedFilters/showDiagnostics 全部代码与 `Ionicons` 诊断 UI;②`common.error` 键若不存在改用现有错误键(grep 确认);③daySummary 当收支均为 0 时仍显示——可接受;④SectionList section 类型由 `{...g, data}` 推导。
|
||
|
||
- [ ] **Step 2: 验证**
|
||
|
||
Run: `npm run typecheck && npm test` → 全绿
|
||
Run: `grep -n "diagnostics\|showAdvancedFilters" "src/app/(tabs)/transactions.tsx"` → 无输出
|
||
|
||
---
|
||
|
||
### Task 6: 报表页重写(anchor 统一 + ⋯ 菜单 + 年报去重)
|
||
|
||
**Files:**
|
||
- Modify: `src/app/(tabs)/report.tsx`(重写状态与导航;看板渲染保留并适配)
|
||
- Modify: `src/components/charts/AnnualReport.tsx`(去 MonthlyReport,加月度节奏迷你图)
|
||
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`
|
||
|
||
- [ ] **Step 1: i18n 加键**
|
||
|
||
zh:`'report.menu': '更多操作'`、`'report.exportImage': '导出图片'`、`'report.monthlyRhythm': '月度节奏'`;en:'More actions' / 'Export image' / 'Monthly rhythm'。(report.aiSummary 已存在。)
|
||
|
||
- [ ] **Step 2: report.tsx 状态合并为单一 anchor**
|
||
|
||
- 删除 `viewYear/viewMonth/viewWeekDate` 三个 state 及 prevMonth/nextMonth/changeWeek,替换为:
|
||
|
||
```typescript
|
||
const [period, setPeriod] = useState<ReportPeriod>('monthly');
|
||
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
|
||
const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
|
||
const rangeTxs = useMemo(
|
||
() => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
|
||
[transactions, range],
|
||
);
|
||
const rangeStats = useMemo(() => {
|
||
// 与旧 weeklyStats/monthlyStats 相同算法,作用于 rangeTxs
|
||
}, [rangeTxs]);
|
||
|
||
const shift = (delta: number) => {
|
||
setSelectedReportDate(null);
|
||
setAnchor(a => shiftAnchor(a, period, delta));
|
||
};
|
||
```
|
||
|
||
- 周期 label(统一渲染一个月切换器,替代三个):
|
||
|
||
```typescript
|
||
const periodLabel = useMemo(() => {
|
||
if (period === 'weekly') {
|
||
const monday = range.start; const sunday = range.end;
|
||
return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5,7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5,7))}/${Number(sunday.slice(8))})`;
|
||
}
|
||
if (period === 'monthly') return anchor.slice(0, 7);
|
||
return `${anchor.slice(0, 4)}年`;
|
||
}, [period, anchor, range]);
|
||
```
|
||
|
||
- `monthlyTxs`→`rangeTxs`(monthly 时等价);`annualTxs` 删除(年报直接用 transactions + year);`netWorthDates` 由 anchor 派生(`new Date(Number(anchor.slice(0,4)), Number(anchor.slice(5,7)) - 1 - i, 1)`);`handleMonthlySummary` 的 viewYear/viewMonth 参数改从 anchor 派生(`Number(anchor.slice(0,4))`、`Number(anchor.slice(5,7))`)。
|
||
- Tab 切换 `setActiveTab` → `setPeriod`,三个 switcher 合并为一个:`‹ {periodLabel} ›`(onPress `shift(-1)`/`shift(1)`)。
|
||
- 收支看板卡:weekly/monthly 两个分支合并为一个(activeTab 不为 annual 时渲染),数据用 rangeStats + 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:
|
||
|
||
```tsx
|
||
<BottomSheet visible={menuOpen} onClose={() => setMenuOpen(false)} title={t('report.menu')}>
|
||
<Pressable style={menuRow} onPress={() => { setMenuOpen(false); handleMonthlySummary(); }}>
|
||
<Ionicons name="sparkles-outline" size={20} color={theme.colors.accent} />
|
||
<Text body>{t('report.aiSummary')}</Text>
|
||
</Pressable>
|
||
<Pressable style={menuRow} onPress={() => { setMenuOpen(false); handleExportImage(); }}>
|
||
<Ionicons name="share-outline" size={20} color={theme.colors.accent} />
|
||
<Text body>{t('report.exportImage')}</Text>
|
||
</Pressable>
|
||
</BottomSheet>
|
||
```
|
||
|
||
- [ ] **Step 4: AnnualReport.tsx 去重 + 月度节奏迷你图**
|
||
|
||
- 删除 `import { MonthlyReport }` 与 `<MonthlyReport …/>` 用法。
|
||
- 末尾加月度节奏卡(12 根迷你柱,高度 = expense/maxExpense):
|
||
|
||
```tsx
|
||
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
|
||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyRhythm')}</Text>
|
||
<View style={styles.rhythmRow}>
|
||
{report.monthlyTrend.map(m => {
|
||
const ratio = maxExpense > 0 ? Math.min(parseFloat(m.expense) / maxExpense, 1) : 0;
|
||
return (
|
||
<View key={m.month} style={styles.rhythmCol}>
|
||
<View style={[styles.rhythmTrack, { backgroundColor: theme.colors.bgTertiary }]}>
|
||
<View style={[styles.rhythmFill, { height: `${Math.max(ratio * 100, 2)}%`, backgroundColor: theme.colors.financial.expense }]} />
|
||
</View>
|
||
<Text style={[styles.rhythmLabel, { color: theme.colors.fgSecondary }]}>{Number(m.month.slice(5))}</Text>
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
```
|
||
|
||
其中 `const maxExpense = Math.max(...report.monthlyTrend.map(m => parseFloat(m.expense) || 0));`(展示层比率计算,允许 parseFloat——非记账金额)。样式:`rhythmRow: {flexDirection:'row', gap:4, height:64, marginTop:8}`、`rhythmCol: {flex:1, alignItems:'center', gap:4}`、`rhythmTrack: {flex:1, width:'100%', borderRadius:4, justifyContent:'flex-end', overflow:'hidden'}`、`rhythmFill: {width:'100%', borderRadius:4}`、`rhythmLabel: {fontSize:9}`。
|
||
- 检查 MonthlyReport 是否还有其他引用方(grep);若无引用,**保留文件不删**(P5 图表重刷时统一处理),仅在报告里注明。
|
||
|
||
- [ ] **Step 5: 验证**
|
||
|
||
Run: `npm run typecheck && npm test` → 全绿
|
||
Run: `npx vitest run tests/periodNav.test.ts` → PASS
|
||
Run: `grep -n "viewWeekDate\|MonthlyReport" "src/app/(tabs)/report.tsx"` → 无输出
|
||
|
||
---
|
||
|
||
### Task 7: 我的页 4 分组 + 解析诊断页
|
||
|
||
**Files:**
|
||
- Modify: `src/app/(tabs)/settings.tsx`(完整重写)
|
||
- Create: `src/app/settings/diagnostics.tsx`
|
||
- Modify: `src/app/_layout.tsx`(注册新页)、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||
|
||
- [ ] **Step 1: i18n 加键**
|
||
|
||
zh:
|
||
|
||
```typescript
|
||
'tab.settings': '我的', // 改值(原'设置');key 不变
|
||
'settings.groupAccount': '账户与分类',
|
||
'settings.groupAutomation': '记账自动化',
|
||
'settings.groupData': '数据',
|
||
'settings.groupPreferences': '偏好',
|
||
'settings.diagnostics': '解析诊断',
|
||
'settings.syncBackup': '同步与备份',
|
||
'settings.preferencesEntry': '偏好设置',
|
||
'settings.import': '导入账单',
|
||
```
|
||
|
||
en:'tab.settings': 'Me';'Accounts & categories' / 'Automation' / 'Data' / 'Preferences' / 'Parse diagnostics' / 'Sync & backup' / 'Preferences' / 'Import bills'。
|
||
|
||
- [ ] **Step 2: 完整重写 src/app/(tabs)/settings.tsx**
|
||
|
||
保留 renderNavLink 模式(删 `fontFamily` 引用),4 张分组卡 + 关于卡:
|
||
|
||
```typescript
|
||
// 账户与分类(wallet-outline 组)
|
||
/account(账户树,含余额)→ t('account.title')
|
||
/category → t('settings.categories')
|
||
/tag → t('settings.tags')
|
||
/budget → t('settings.budgets')
|
||
/credit-card → t('settings.creditCards')
|
||
/remark-template → t('remark.title')
|
||
|
||
// 记账自动化(flash-outline 组)
|
||
/rules → t('tab.rules')
|
||
/recurring → t('settings.recurringTitle')
|
||
/automation → t('automation.title')
|
||
/import → t('settings.import')
|
||
|
||
// 数据(cloud-outline 组)
|
||
/settings/sync → t('settings.syncBackup')
|
||
/settings/diagnostics → t('settings.diagnostics')
|
||
|
||
// 偏好(cog-outline 组)
|
||
/settings/preferences → t('settings.preferencesEntry')
|
||
/settings/ai → t('settings.aiSettingsTitle')
|
||
/ai/chat → t('ai.chatTitle')
|
||
```
|
||
|
||
组卡用 `Card title={t('settings.groupXxx')}`;关于卡原样保留。页面 header 标题改 `t('tab.settings')`(我的)。
|
||
|
||
- [ ] **Step 3: 新建 src/app/settings/diagnostics.tsx**(内容从旧交易页 footer 迁移)
|
||
|
||
```tsx
|
||
/** 解析诊断(自交易页迁入,spec §7.2/§7.4 数据组)。 */
|
||
import React from 'react';
|
||
import { ScrollView, StyleSheet, Text } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { useLedgerStore } from '../../store/ledgerStore';
|
||
import { useTheme } from '../../theme';
|
||
import { useT } from '../../i18n';
|
||
import { Card } from '../../components/Card';
|
||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||
|
||
export default function DiagnosticsScreen() {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const ledger = useLedgerStore(s => s.ledger);
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||
<ScreenHeader title={t('settings.diagnostics')} />
|
||
<ScrollView contentContainerStyle={styles.content}>
|
||
<Card title={t('diagnostics.title')}>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
|
||
</Text>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
|
||
</Text>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
|
||
</Text>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
||
{t('diagnostics.hint')}
|
||
</Text>
|
||
</Card>
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
page: { flex: 1 },
|
||
content: { padding: 16 },
|
||
});
|
||
```
|
||
|
||
(ScreenHeader props 先 Read 确认——title/右侧 slot;若需要显式返回处理按其现有用法。)
|
||
|
||
- [ ] **Step 4: _layout.tsx 注册**
|
||
|
||
在 settings/preferences 行后加:
|
||
|
||
```tsx
|
||
<Stack.Screen name="settings/diagnostics" options={{ headerShown: false }} />
|
||
```
|
||
|
||
- [ ] **Step 5: 验证**
|
||
|
||
Run: `npm run typecheck && npm test` → 全绿(i18n 对等校验会覆盖 tab.settings 改值)
|
||
|
||
---
|
||
|
||
### Task 8: P4 全量验收
|
||
|
||
- [ ] **Step 1: 全量测试 + typecheck**
|
||
|
||
Run: `npm test` → 全绿(含 periodNav/transactionGroups/search 新测试)
|
||
Run: `npm run typecheck` → 无错误
|
||
|
||
- [ ] **Step 2: 审计**
|
||
|
||
Run: `grep -rn "fontFamily" "src/app/(tabs)"` → 无输出(P1 遗留清理)
|
||
Run: `grep -n "rgba(255,255,255" "src/app/(tabs)/index.tsx"` → 无输出(Hero 硬编码白字已消除)
|
||
Run: `grep -rn "MonthlyReport" src/` → 仅 components/charts/MonthlyReport.tsx 自身定义(report.tsx 已无引用)
|
||
|
||
- [ ] **Step 3: 手工走查(需设备/模拟器)**
|
||
|
||
Run: `npm run android`
|
||
走查清单(浅/暗双主题):
|
||
1. 首页:问候+日期、净资产白卡大数字、本月收支/预算剩余小字、待办条(制造一个到期周期记账验证「入账」按钮)、最近 5 条、查看全部跳交易页
|
||
2. 交易页:搜索、方向 chip、筛选弹层(账户/日期/金额区间 + 重置)、日期分组(今天/昨天)、组头收支小计、左滑复制(预填今天)/删除(确认弹窗)、点击进详情
|
||
3. 报表页:周/月/年 Tab 切换 anchor 不跳变、左右箭头统一、⋯ 菜单两个文字入口、年报无内嵌月报且有月度节奏迷你图、月 Tab 日历点选出明细
|
||
4. 我的:4 分组条目齐全、每个入口可达、解析诊断页显示计数、Tab 标签为「我的」
|
||
5. +面板在四个 Tab 上均可唤起(全局开关开/关两条路径)
|
||
|
||
---
|
||
|
||
## 后续计划(不在本文件)
|
||
|
||
- **P5 管理页+收尾**:8 页套 ManagementScreen;图表重刷 token;emoji 清零;硬编码色 grep 审计;无障碍补全;`numpadGlobalEntry` 默认翻 true;MonthlyReport 等死文件清理;DatePickerField WEEKDAYS i18n;间距 token 化审计
|