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

1475 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# UI 重设计 P3录入闭环NumpadSheetImplementation 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:** 用金额优先的数字键盘面板NumpadSheet重写录入闭环双腿账户 chip 选择、+/ 连续计算、全局唤起灰度开关、transaction/new 整页重写为面板宿主、SpeedDial 退役。
**Architecture:** 纯逻辑键盘表达式、postings→双腿解析抽成可测模块NumpadSheet 是唯一录入 UI同时服务整页路由/transaction/new含编辑/草稿/OCR 预填)与全局 ModalNumpadSheetHost 挂根布局);落账仍走 `buildAndSaveTransaction()`/`editTransaction`domain 写路径零改动。
**Tech Stack:** React Native + Expo Router + Zustand + Ionicons + Vitest。
**Spec:** `docs/ui-redesign-design.md` §4NumpadSheet、§5导航。**与 spec 的偏差**分录预览行常驻显示不做设置开关YAGNI灰度开关只控制「 开全局面板 vs 跳整页」,默认 **false**P5 稳定后翻默认)。
**前置状态:** P1/P2 已完成547 测试全绿BottomSheet/DatePickerField/CategoryIcon/CategoryPicker 已可用)。
---
### Task 1: 纯逻辑失败测试
**Files:**
- Create: `tests/numpad.test.ts`
- [ ] **Step 1: 新建 tests/numpad.test.ts**
```typescript
import { describe, expect, it } from 'vitest';
import { pressKey, evaluateExpression } from '../src/components/numpadExpression';
import { parsePostingsToLegs } from '../src/domain/postingLegs';
import { buildPostings } from '../src/domain/transactionBuilder';
describe('pressKey 表达式编辑', () => {
it('数字键追加', () => {
expect(pressKey('', '5')).toBe('5');
expect(pressKey('5', '3')).toBe('53');
});
it('前导零被替换', () => {
expect(pressKey('0', '5')).toBe('5');
});
it('小数点:空段补 0同段不重复', () => {
expect(pressKey('', '.')).toBe('0.');
expect(pressKey('5', '.')).toBe('5.');
expect(pressKey('5.5', '.')).toBe('5.5');
});
it('小数最多两位', () => {
expect(pressKey('5.55', '5')).toBe('5.55');
expect(pressKey('5.5', '5')).toBe('5.55');
});
it('整数位最多 9 位', () => {
expect(pressKey('123456789', '0')).toBe('123456789');
expect(pressKey('12345678', '9')).toBe('123456789');
});
it('运算符:数字后可加,连续运算符替换,空表达式不可加', () => {
expect(pressKey('5', '+')).toBe('5+');
expect(pressKey('5+', '-')).toBe('5-');
expect(pressKey('', '+')).toBe('');
expect(pressKey('', '-')).toBe('');
});
it('运算符后开启新数字段(小数规则独立)', () => {
expect(pressKey('5.5+', '.')).toBe('5.5+0.');
expect(pressKey('5.55+3', '.')).toBe('5.55+3.');
});
it('backspace 删除末字符', () => {
expect(pressKey('5.5', 'backspace')).toBe('5.');
expect(pressKey('5', 'backspace')).toBe('');
expect(pressKey('', 'backspace')).toBe('');
});
});
describe('evaluateExpression 求值', () => {
it('单值', () => {
expect(evaluateExpression('35')).toBe('35');
});
it('加减混合(字符串十进制,无浮点误差)', () => {
expect(evaluateExpression('20+15')).toBe('35');
expect(evaluateExpression('20+15-5.5')).toBe('29.5');
expect(evaluateExpression('0.1+0.2')).toBe('0.3');
});
it('尾随运算符被忽略', () => {
expect(evaluateExpression('20+')).toBe('20');
expect(evaluateExpression('20+15-')).toBe('35');
});
it('空表达式返回空串', () => {
expect(evaluateExpression('')).toBe('');
});
});
describe('parsePostingsToLegs 双腿解析', () => {
it('与 buildPostings 往返一致:支出', () => {
const legs = parsePostingsToLegs(buildPostings('expense', 'Assets:招行', 'Expenses:餐饮', '35', 'CNY'));
expect(legs).toEqual({
direction: 'expense',
sourceAccount: 'Assets:招行',
targetAccount: 'Expenses:餐饮',
amount: '35',
currency: 'CNY',
});
});
it('与 buildPostings 往返一致:收入', () => {
const legs = parsePostingsToLegs(buildPostings('income', 'Assets:招行', 'Income:工资', '12000', 'CNY'));
expect(legs).toEqual({
direction: 'income',
sourceAccount: 'Assets:招行',
targetAccount: 'Income:工资',
amount: '12000',
currency: 'CNY',
});
});
it('与 buildPostings 往返一致:转账', () => {
const legs = parsePostingsToLegs(buildPostings('transfer', 'Assets:招行', 'Liabilities:花呗', '2000', 'CNY'));
expect(legs).toEqual({
direction: 'transfer',
sourceAccount: 'Assets:招行',
targetAccount: 'Liabilities:花呗',
amount: '2000',
currency: 'CNY',
});
});
it('金额取绝对值(去掉负号)', () => {
const legs = parsePostingsToLegs([
{ account: 'Assets:招行', amount: '-35', currency: 'CNY' },
{ account: 'Expenses:餐饮', amount: '35', currency: 'CNY' },
]);
expect(legs?.amount).toBe('35');
});
it('无法识别时返回 null', () => {
expect(parsePostingsToLegs([])).toBeNull();
expect(parsePostingsToLegs([{ account: 'Assets:招行', amount: '35', currency: 'CNY' }])).toBeNull();
});
});
```
- [ ] **Step 2: 运行确认失败**
Run: `npx vitest run tests/numpad.test.ts`
Expected: FAIL —— `Cannot find module '../src/components/numpadExpression'`。**若意外通过,报告 BLOCKED。**
---
### Task 2: numpadExpression + postingLegs 纯逻辑
**Files:**
- Create: `src/components/numpadExpression.ts`
- Create: `src/domain/postingLegs.ts`
- Modify: `src/domain/index.ts`barrel 加一行导出)
- [ ] **Step 1: 创建 src/components/numpadExpression.ts**
```typescript
/**
* 数字键盘表达式编辑与求值(+/ 连续记账,如 20+15 直接出 35
* 金额运算走 domain/decimal 的字符串十进制,无浮点误差。
*/
import { addDecimals, subtractDecimals } from '../domain/decimal';
export type NumpadKey =
| '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
| '.' | '+' | '-' | 'backspace';
const MAX_INT_DIGITS = 9;
const MAX_DECIMAL_DIGITS = 2;
/** 处理一次按键,返回新表达式字符串。 */
export function pressKey(expr: string, key: NumpadKey): string {
if (key === 'backspace') return expr.slice(0, -1);
if (key === '+' || key === '-') {
if (expr === '') return ''; // 金额恒正,不允许运算符开头
const last = expr[expr.length - 1];
if (last === '+' || last === '-') return expr.slice(0, -1) + key; // 替换运算符
return expr + key;
}
// 当前数字段(最后一个运算符之后)
const seg = expr.split(/[+-]/).pop() ?? '';
if (key === '.') {
if (seg.includes('.')) return expr;
if (seg === '') return expr + '0.';
return expr + '.';
}
// 数字键
if (seg.includes('.')) {
const decimals = seg.split('.')[1] ?? '';
if (decimals.length >= MAX_DECIMAL_DIGITS) return expr;
} else if (seg === '0') {
return expr.slice(0, -1) + key; // 前导零替换
}
const intPart = seg.split('.')[0];
if (intPart.length >= MAX_INT_DIGITS) return expr;
return expr + key;
}
/** 求值:忽略尾随运算符;空表达式返回空串。 */
export function evaluateExpression(expr: string): string {
const cleaned = expr.replace(/[+-]$/, '');
if (!cleaned) return '';
// 按符号切分:'20+15-5.5' → ['20', '+15', '-5.5']
const parts = cleaned.split(/(?=[+-])/).filter(Boolean);
let sum = '0';
for (const part of parts) {
if (part.startsWith('-')) sum = subtractDecimals(sum, part.slice(1));
else if (part.startsWith('+')) sum = addDecimals([sum, part.slice(1)]);
else sum = addDecimals([sum, part]);
}
return sum;
}
```
- [ ] **Step 2: 创建 src/domain/postingLegs.ts**
```typescript
/**
* postings → 「双腿」解析NumpadSheet 编辑/草稿预填用)。
* 是 buildPostings 的逆操作:从一组分录推断方向、资金账户、目标账户与绝对值金额。
*/
import type { Posting } from './types';
export interface TransactionLegs {
direction: 'expense' | 'income' | 'transfer';
/** 资金腿Assets/Liabilities转账时为转出方。 */
sourceAccount: string;
/** 分类账户Expenses/Income或转账的目标资金账户。 */
targetAccount: string;
/** 绝对值金额(无负号)。 */
amount: string;
currency: string;
}
function isAssetLike(account: string): boolean {
return account.startsWith('Assets') || account.startsWith('Liabilities');
}
function abs(amount: string | undefined): string {
return (amount ?? '').replace(/^-/, '');
}
/** 从分录推断双腿;无法识别(少于 2 条或无资金腿)时返回 null。 */
export function parsePostingsToLegs(postings: Posting[]): TransactionLegs | null {
if (postings.length < 2) return null;
const expensePosting = postings.find(p => p.account.startsWith('Expenses'));
const incomePosting = postings.find(p => p.account.startsWith('Income'));
const assetPosting = postings.find(p => isAssetLike(p.account));
if (expensePosting) {
return {
direction: 'expense',
sourceAccount: assetPosting?.account ?? '',
targetAccount: expensePosting.account,
amount: abs(expensePosting.amount),
currency: expensePosting.currency ?? 'CNY',
};
}
if (incomePosting) {
return {
direction: 'income',
sourceAccount: assetPosting?.account ?? '',
targetAccount: incomePosting.account,
amount: abs(incomePosting.amount),
currency: incomePosting.currency ?? 'CNY',
};
}
// 转账:两条资金腿,第一条为转出
if (isAssetLike(postings[0].account) && isAssetLike(postings[1].account)) {
return {
direction: 'transfer',
sourceAccount: postings[0].account,
targetAccount: postings[1].account,
amount: abs(postings[0].amount),
currency: postings[0].currency ?? 'CNY',
};
}
return null;
}
```
- [ ] **Step 3: domain/index.ts 加 barrel 导出**
`src/domain/index.ts` 中追加(保持字母序附近的合适位置):
```typescript
export * from './postingLegs';
```
- [ ] **Step 4: 验证**
Run: `npx vitest run tests/numpad.test.ts tests/transactionBuilder.test.ts` → Expected: 全部 PASS
Run: `npm run typecheck` → Expected: 无错误
> **评审修订(已实施,以实际代码为准):** 代码评审后已修复并补回归测试——①`evaluateExpression` 剥段尾 `.``5.`/`5.+3` 不再崩溃);②`pressKey` 整数 9 位上限仅对无小数段生效;③`parsePostingsToLegs` 对 `postings.length !== 2` 一律返回 null拆分交易走高级模式④识别腿缺金额回退对侧资金腿⑤转账按负号腿判定转出方⑥currency 兜底顺序:识别腿 → 对侧腿 → CNY。
---
### Task 3: settingsStore 新字段 + 偏好开关 + i18n
**Files:**
- Modify: `src/store/settingsStore.ts`
- Modify: `src/app/settings/preferences.tsx`
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: settingsStore.ts 加 4 个字段(完全镜像 floatingBallEnabled 的模式)**
- State 接口加:
```typescript
/** 按钮是否打开全局记账面板P3 灰度,默认 falseP5 稳定后翻默认)。 */
numpadGlobalEntry: boolean;
setNumpadGlobalEntry: (enabled: boolean) => void;
/** 上次记账的资金账户NumpadSheet 默认选中)。 */
lastUsedAccount: string | null;
/** 上次使用的分类 id。 */
lastUsedCategoryId: string | null;
setLastUsedEntry: (account: string | null, categoryId: string | null) => void;
```
- DEFAULTS 加:`numpadGlobalEntry: false, lastUsedAccount: null, lastUsedCategoryId: null,`
- 持久化选择器pick PersistableSettings 处,参照 `floatingBallEnabled: state.floatingBallEnabled,`)加:
```typescript
numpadGlobalEntry: state.numpadGlobalEntry,
lastUsedAccount: state.lastUsedAccount,
lastUsedCategoryId: state.lastUsedCategoryId,
```
- 实现加:
```typescript
setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }),
setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),
```
注意:`PersistableSettings` 类型若显式列字段,需同步加这 4 个字段lastUsed* 为 string | null
- [ ] **Step 2: preferences.tsx 加「记账」Card放在「安全设置」Card 之前)**
```tsx
{/* 记账 */}
<Card title={t('settings.entryTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.numpadEntry')}
</Text>
<Switch
value={numpadGlobalEntry}
onValueChange={setNumpadGlobalEntry}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 6 }]}>
{t('settings.numpadEntryDesc')}
</Text>
</Card>
```
组件顶部加 store 订阅:
```typescript
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry);
```
- [ ] **Step 3: i18n 加键zh.ts 与 en.ts 必须同步加i18n.test.ts 校验键位对等)**
zh.ts 加:
```typescript
'settings.entryTitle': '记账',
'settings.numpadEntry': '全局记账面板',
'settings.numpadEntryDesc': '开启后,底部 在任意页面直接弹出记账面板(灰度功能)',
'numpad.from': '从',
'numpad.to': '到',
'numpad.outFrom': '转出',
'numpad.transferTo': '转入',
'numpad.swap': '互换',
'numpad.today': '今天',
'numpad.done': '完成',
'numpad.more': '更多',
'numpad.less': '收起',
'numpad.allAccounts': '全部账户',
'numpad.allCategories': '全部',
'numpad.advanced': '高级模式',
'numpad.noAccount': '去创建账户',
```
en.ts 加对应英文:
```typescript
'settings.entryTitle': 'Entry',
'settings.numpadEntry': 'Global entry panel',
'settings.numpadEntryDesc': 'When enabled, the button opens the entry panel on any screen (beta)',
'numpad.from': 'From',
'numpad.to': 'To',
'numpad.outFrom': 'From',
'numpad.transferTo': 'To',
'numpad.swap': 'Swap',
'numpad.today': 'Today',
'numpad.done': 'Done',
'numpad.more': 'More',
'numpad.less': 'Less',
'numpad.allAccounts': 'All accounts',
'numpad.allCategories': 'All',
'numpad.advanced': 'Advanced mode',
'numpad.noAccount': 'Create account',
```
- [ ] **Step 4: 验证**
Run: `npx vitest run tests/i18n.test.ts tests/stores.test.ts tests/automationStore.test.ts` → Expected: PASS
Run: `npm run typecheck` → Expected: 无错误
---
### Task 4: NumpadKeyboard + useOcrScan + numpadUiStore
**Files:**
- Create: `src/components/NumpadKeyboard.tsx`
- Create: `src/components/useOcrScan.ts`
- Create: `src/store/numpadUiStore.ts`
- [ ] **Step 1: 创建 src/components/NumpadKeyboard.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
/** 键值:数字/小数点/加减走 numpadExpressiontoday/done 由宿主处理。 */
export type KeyboardKey = string;
interface NumpadKeyboardProps {
onKey: (key: KeyboardKey) => void;
todayLabel: string;
doneLabel: string;
}
const ROWS: KeyboardKey[][] = [
['1', '2', '3', 'today'],
['4', '5', '6', '+'],
['7', '8', '9', '-'],
['.', '0', 'backspace', 'done'],
];
/** 内嵌数字键盘4×4金额优先/ 连续计算,「今天」快速设日期。 */
export function NumpadKeyboard({ onKey, todayLabel, doneLabel }: NumpadKeyboardProps) {
const { theme } = useTheme();
const renderKey = (key: KeyboardKey) => {
if (key === 'done') {
return (
<Pressable
key={key}
onPress={() => onKey(key)}
style={[styles.key, styles.doneKey, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.lg }]}
accessibilityRole="button"
accessibilityLabel={doneLabel}
>
<Text style={[styles.doneText, { color: theme.colors.fgInverse }]}>{doneLabel}</Text>
</Pressable>
);
}
if (key === 'backspace') {
return (
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel="backspace">
<Ionicons name="backspace-outline" size={22} color={theme.colors.fgPrimary} />
</Pressable>
);
}
if (key === 'today') {
return (
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={todayLabel}>
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{todayLabel}</Text>
</Pressable>
);
}
if (key === '+' || key === '-') {
return (
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}>
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{key === '+' ? '' : ''}</Text>
</Pressable>
);
}
return (
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}>
<Text style={[styles.digitText, { color: theme.colors.fgPrimary }]}>{key}</Text>
</Pressable>
);
};
return (
<View style={styles.grid}>
{ROWS.map((row, i) => (
<View key={i} style={styles.row}>
{row.map(renderKey)}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
grid: { gap: 2 },
row: { flexDirection: 'row' },
key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14 },
digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] },
opText: { fontSize: 16, fontWeight: '600' },
doneKey: { margin: 4 },
doneText: { fontSize: 15, fontWeight: '700' },
});
```
- [ ] **Step 2: 创建 src/components/useOcrScan.ts**
```typescript
/**
* OCR 拍照识账 hook从旧 transaction/new.tsx 提取,逻辑不变)。
* 返回 scan():选图 → OcrProcessor 三级管线 → 解析出账单事件(或 null
*/
import { useState } from 'react';
import { Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { getNativeOcrModule, getNativeOcrBridge } from '../services/ocrBridge';
import { OcrProcessor } from '../domain/ocrProcessor';
import { useT } from '../i18n';
export type OcrEvent = NonNullable<Awaited<ReturnType<OcrProcessor['process']>>['event']>;
let processor: OcrProcessor | null = null;
export function useOcrScan() {
const t = useT();
const [status, setStatus] = useState('');
const scan = async (): Promise<OcrEvent | null> => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.8,
base64: true,
});
if (result.canceled || !result.assets?.[0]?.base64) return null;
const nativeModule = getNativeOcrModule();
if (nativeModule && !(await nativeModule.isReady())) {
Alert.alert(t('ocr.notReady'));
return null;
}
setStatus(t('ocr.scanning'));
if (!processor) processor = new OcrProcessor(getNativeOcrBridge());
const ocrResult = await processor.process(result.assets[0].base64);
setStatus(ocrResult.event ? t('ocr.success') : t('ocr.failed'));
return ocrResult.event;
} catch (e) {
setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e)));
return null;
}
};
return { scan, status };
}
```
- [ ] **Step 3: 创建 src/store/numpadUiStore.ts**
```typescript
/**
* 全局记账面板 UI 状态(+按钮唤起 NumpadSheet 全局弹层)。
*/
import { create } from 'zustand';
export interface NumpadPrefill {
editId?: string;
draftJson?: string;
autoOcr?: boolean;
}
interface NumpadUiState {
visible: boolean;
prefill: NumpadPrefill | null;
open: (prefill?: NumpadPrefill) => void;
close: () => void;
}
export const useNumpadUiStore = create<NumpadUiState>((set) => ({
visible: false,
prefill: null,
open: (prefill) => set({ visible: true, prefill: prefill ?? null }),
close: () => set({ visible: false, prefill: null }),
}));
```
- [ ] **Step 4: 验证**
Run: `npm run typecheck` → Expected: 无错误(若 OcrEvent 类型推导报错,读 src/domain/ocrProcessor.ts 确认 process 返回类型后如实报告,不要改 domain
---
### Task 5: NumpadSheet 主组件
**Files:**
- Create: `src/components/NumpadSheet.tsx`
- [ ] **Step 1: 创建 src/components/NumpadSheet.tsx**
完整代码(此为 P3 核心,逐字实现):
```tsx
/**
* NumpadSheet 记一笔面板P3 录入闭环核心)。
*
* 金额优先:方向 chip → 大金额 → 账户 chip 行(双腿之一)→ 分类快捷行(另一腿)→ 数字键盘。
* 「更多」抽屉:日期/摘要/收款方/标签/高级模式PostingEditor
* 同时服务两种载体:
* - presentation="page"/transaction/new 整页宿主(含 editId/draftJson 预填、未保存守卫)
* - presentation="modal"全局弹层NumpadSheetHost按钮唤起
* 落账走 buildAndSaveTransaction()/editTransaction与旧表单完全一致。
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView,
StyleSheet, Switch, Text, TextInput, View,
} from 'react-native';
import { useNavigation, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { useSettingsStore } from '../store/settingsStore';
import { TAG_COLORS } from '../theme/palette';
import { BottomSheet } from './BottomSheet';
import { Button } from './Button';
import { CategoryIcon } from './CategoryIcon';
import { CategoryPicker } from './CategoryPicker';
import { DatePickerField } from './DatePickerField';
import { NumpadKeyboard } from './NumpadKeyboard';
import { PostingEditor } from './PostingEditor';
import { TagPicker } from './TagPicker';
import { pressKey, evaluateExpression, type NumpadKey } from './numpadExpression';
import { useOcrScan } from './useOcrScan';
import { buildAndSaveTransaction, buildPostings } from '../domain/transactionBuilder';
import { parsePostingsToLegs } from '../domain/postingLegs';
import { serializeTransaction } from '../domain/ledger';
import { addDecimals, compareDecimals, toDateString } from '../domain/decimal';
import { matchCategory } from '../domain/categories';
import type { Category } from '../domain/categories';
import type { Tag } from '../domain/tags';
import type { Posting } from '../domain/types';
type Direction = 'expense' | 'income' | 'transfer';
interface NumpadSheetProps {
presentation: 'page' | 'modal';
editId?: string;
draftJson?: string;
autoOcr?: boolean;
onClose?: () => void;
}
export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose }: NumpadSheetProps) {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const navigation = useNavigation();
const ledger = useLedgerStore(s => s.ledger);
const editTransaction = useLedgerStore(s => s.editTransaction);
const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags);
const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount);
const lastUsedCategoryId = useSettingsStore(s => s.lastUsedCategoryId);
const setLastUsedEntry = useSettingsStore(s => s.setLastUsedEntry);
const [direction, setDirection] = useState<Direction>('expense');
const [expr, setExpr] = useState('');
const [currency] = useState('CNY');
const [date, setDate] = useState(() => toDateString(new Date()));
const [narration, setNarration] = useState('');
const [payee, setPayee] = useState('');
const [sourceAccount, setSourceAccount] = useState('');
const [targetAccount, setTargetAccount] = useState('');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [showMore, setShowMore] = useState(false);
const [isAdvanced, setIsAdvanced] = useState(false);
const [postings, setPostings] = useState<Posting[]>([]);
const [status, setStatus] = useState('');
const [submitting, setSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null);
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false);
// 编辑模式保留字段
const [oldRaw, setOldRaw] = useState('');
const [editFlag, setEditFlag] = useState<string | undefined>(undefined);
const [editLinks, setEditLinks] = useState<string[] | undefined>(undefined);
const [editMetadata, setEditMetadata] = useState<Record<string, string> | undefined>(undefined);
const amount = useMemo(() => evaluateExpression(expr), [expr]);
const { scan, status: ocrStatus } = useOcrScan();
// ===== 账户列表双腿之一的资金账户Assets + Liabilities =====
const assetAccounts = useMemo(() => {
if (!ledger) return [] as string[];
return Array.from(ledger.accounts.keys()).filter(
a => a.startsWith('Assets') || a.startsWith('Liabilities'),
);
}, [ledger]);
// chip 行:上次使用优先,最多 4 个 + 「⋯」
const chipAccounts = useMemo(() => {
const rest = assetAccounts.filter(a => a !== lastUsedAccount);
const pinned = lastUsedAccount && assetAccounts.includes(lastUsedAccount) ? [lastUsedAccount] : [];
return [...pinned, ...rest].slice(0, 4);
}, [assetAccounts, lastUsedAccount]);
// 默认资金账户:上次使用 > 列表第一个
useEffect(() => {
if (sourceAccount) return;
const def = lastUsedAccount && assetAccounts.includes(lastUsedAccount)
? lastUsedAccount
: assetAccounts[0];
if (def) setSourceAccount(def);
}, [assetAccounts, lastUsedAccount, sourceAccount]);
// ===== 分类快捷行 =====
const availableCategories = useMemo(
() => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')),
[categories, direction],
);
const quickCategories = useMemo(() => {
const pinned = availableCategories.find(c => c.id === lastUsedCategoryId);
const rest = availableCategories.filter(c => c.id !== lastUsedCategoryId);
return [...(pinned ? [pinned] : []), ...rest].slice(0, 3);
}, [availableCategories, lastUsedCategoryId]);
// ===== 预填draftJson草稿 =====
useEffect(() => {
if (!draftJson) return;
try {
const draft = JSON.parse(draftJson);
if (draft.date) setDate(String(draft.date).slice(0, 10));
setNarration(draft.narration || '');
setPayee(draft.payee || '');
const legs = parsePostingsToLegs(draft.postings ?? []);
if (legs) {
setDirection(legs.direction);
setExpr(legs.amount);
setSourceAccount(legs.sourceAccount);
setTargetAccount(legs.targetAccount);
if (legs.direction !== 'transfer') {
const matched = categories.find(c => c.linkedAccount === legs.targetAccount);
if (matched) setSelectedCategory(matched);
}
}
if (Array.isArray(draft.postings) && draft.postings.length > 0) {
setPostings(draft.postings.map((p: any) => ({
account: p.account, amount: p.amount, currency: p.currency,
cost: p.cost, price: p.price, metadata: p.metadata,
})));
// 无法双腿解析(拆分交易等)→ 直接进高级模式,避免丢腿
if (!legs) setIsAdvanced(true);
}
} catch (e) {
console.error('Failed to parse draftJson', e);
}
}, [draftJson, categories]);
// ===== 预填editId编辑模式 =====
const editingTx = useMemo(
() => (editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined),
[editId, ledger],
);
useEffect(() => {
if (!editingTx) return;
setOldRaw(editingTx.raw);
setDate(editingTx.date.slice(0, 10));
setNarration(editingTx.narration || '');
setPayee(editingTx.payee || '');
setEditFlag(editingTx.flag || '*');
setEditLinks(editingTx.links || []);
setEditMetadata(editingTx.metadata);
const legs = parsePostingsToLegs(editingTx.postings);
if (legs) {
setDirection(legs.direction);
setExpr(legs.amount);
setSourceAccount(legs.sourceAccount);
setTargetAccount(legs.targetAccount);
if (legs.direction !== 'transfer') {
const matched = categories.find(c => c.linkedAccount === legs.targetAccount);
if (matched) setSelectedCategory(matched);
}
}
setPostings(editingTx.postings.map(p => ({
account: p.account, amount: p.amount, currency: p.currency,
cost: p.cost, price: p.price, metadata: p.metadata,
})));
// 无法双腿解析(拆分交易等)→ 直接进高级模式,避免丢腿
if (!legs && editingTx.postings.length > 0) setIsAdvanced(true);
if (editingTx.tags.length > 0) {
setSelectedTags(editingTx.tags.map(name => {
const found = tags.find(tg => tg.name === name);
return found ?? { id: name, name, color: TAG_COLORS[3] };
}));
}
}, [editingTx, categories, tags]);
// ===== OCR =====
const applyOcrEvent = (event: import('./useOcrScan').OcrEvent) => {
if (event.amount) setExpr(event.amount.replace(/^-/, ''));
if (event.direction === 'income' || event.direction === 'transfer') setDirection(event.direction);
else setDirection('expense');
const text = [event.counterparty, event.memo].filter(Boolean).join(' - ');
if (text) setNarration(text);
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
if (matchText) {
const type = event.direction === 'income' ? 'income' : 'expense';
const match = matchCategory(matchText, categories.filter(c => c.type === type));
if (match.category) setSelectedCategory(match.category);
}
};
const handleOcr = async () => {
const event = await scan();
if (event) applyOcrEvent(event);
};
useEffect(() => {
if (autoOcr) handleOcr();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoOcr]);
// ===== 未保存守卫(仅整页模式) =====
const formRef = useRef({ expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted });
formRef.current = { expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted };
useEffect(() => {
if (presentation !== 'page') return;
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
const s = formRef.current;
const dirty =
evaluateExpression(s.expr) !== '' ||
s.narration.trim() !== '' ||
s.payee.trim() !== '' ||
s.selectedTags.length > 0 ||
(s.isAdvanced && s.postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== ''));
if (!dirty || s.isSubmitted) return;
e.preventDefault();
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{ text: t('common.discard'), style: 'destructive', onPress: () => navigation.dispatch(e.data.action) },
]);
});
return unsubscribe;
}, [navigation, presentation, t]);
// ===== 键盘 =====
const handleKey = (key: string) => {
if (key === 'done') { submit(); return; }
if (key === 'today') { setDate(toDateString(new Date())); return; }
setExpr(prev => pressKey(prev, key as NumpadKey));
};
const swapAccounts = () => {
const s = sourceAccount;
setSourceAccount(targetAccount);
setTargetAccount(s);
};
const closeSoon = () => {
if (presentation === 'modal') onClose?.();
else setTimeout(() => router.back(), 600);
};
// ===== 提交 =====
const submit = async () => {
if (submitting) return;
setSubmitting(true);
const tagNames = selectedTags.map(tg => tg.name);
try {
if (isAdvanced) {
// ===== 高级模式:多行分录 =====
const validPostings = postings.filter(p => p.account.trim());
if (validPostings.length < 2) { setStatus(t('transaction.atLeast2Postings')); return; }
const currencySum: Record<string, string[]> = {};
for (const p of validPostings) {
if (p.amount && p.currency) {
(currencySum[p.currency] = currencySum[p.currency] || []).push(p.amount);
}
}
const balanced = Object.values(currencySum).every(amounts => compareDecimals(addDecimals(amounts), '0') === 0);
if (!balanced) { setStatus(t('transaction.unbalanced')); return; }
const draft = {
date,
flag: editId ? editFlag : undefined,
narration: narration.trim() || t('transaction.defaultAdvancedNarration'),
postings: validPostings,
tags: tagNames,
links: editId && editLinks && editLinks.length > 0 ? editLinks : undefined,
metadata: editId ? editMetadata : undefined,
};
if (editId && oldRaw) {
await editTransaction(oldRaw, serializeTransaction(draft));
setStatus(t('transaction.editSuccess'));
} else {
await useLedgerStore.getState().addTransaction(draft);
setStatus(t('transaction.appended'));
}
setIsSubmitted(true);
closeSoon();
return;
}
// ===== 简单模式:双腿 =====
const amt = amount;
if (!amt || compareDecimals(amt, '0') <= 0) { setStatus(t('transaction.amountRequired')); return; }
if (direction === 'transfer' && sourceAccount === targetAccount) {
setStatus(t('transaction.sameAccountError'));
return;
}
const categoryAccount = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
const defaultNarration =
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer');
if (editId && oldRaw) {
const draft = {
date,
payee: payee.trim() || undefined,
flag: editFlag,
narration: narration.trim() || defaultNarration,
postings: buildPostings(direction, sourceAccount, categoryAccount, amt, currency),
tags: tagNames,
links: editLinks && editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
};
await editTransaction(oldRaw, serializeTransaction(draft));
setStatus(t('transaction.editSuccess'));
setIsSubmitted(true);
closeSoon();
} else {
await buildAndSaveTransaction({
date,
amount: amt,
payee: payee.trim() || undefined,
narration: narration.trim() || defaultNarration,
categoryAccount,
sourceAccount,
direction,
currency,
tags: tagNames,
}, useLedgerStore.getState());
setLastUsedEntry(sourceAccount || null, selectedCategory?.id ?? null);
setStatus(t('transaction.appended'));
setIsSubmitted(true);
setExpr('');
setNarration('');
setSelectedCategory(null);
setSelectedTags([]);
closeSoon();
}
} catch (e) {
setStatus(String(e instanceof Error ? e.message : e));
} finally {
setSubmitting(false);
}
};
// ===== 高级模式切换时初始化分录 =====
const toggleAdvanced = (val: boolean) => {
setIsAdvanced(val);
if (val) {
const amt = amount;
const categoryAccount = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
setPostings(buildPostings(direction, sourceAccount, categoryAccount, amt || '0', currency)
.map(p => ({ ...p, amount: p.amount === '-0' || p.amount === '0' ? '' : p.amount })));
}
};
const directions: { key: Direction; label: string }[] = [
{ key: 'expense', label: t('transaction.directionExpense') },
{ key: 'income', label: t('transaction.directionIncome') },
{ key: 'transfer', label: t('transaction.directionTransfer') },
];
const shortName = (acct: string) => acct.split(':').pop() || acct;
const previewTarget = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? '';
const renderAccountRow = (label: string, value: string, onSelect: (a: string) => void, pickerKey: 'source' | 'target') => (
<View style={styles.accountRow}>
<Text style={[theme.typography.caption, styles.accountLabel, { color: theme.colors.fgSecondary }]}>{label}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.accountChips}>
{assetAccounts.length === 0 ? (
<Pressable onPress={() => router.push('/account')} style={commonStyles.chip}>
<Text style={[commonStyles.chipText, { color: theme.colors.accent }]}>{t('numpad.noAccount')}</Text>
</Pressable>
) : (
<>
{chipAccounts.map(acct => {
const active = value === acct;
return (
<Pressable
key={`${pickerKey}-${acct}`}
onPress={() => onSelect(acct)}
style={[commonStyles.chip, active && commonStyles.chipActive]}
>
<Text style={[commonStyles.chipText, active && commonStyles.chipTextActive]}>{shortName(acct)}</Text>
</Pressable>
);
})}
<Pressable onPress={() => setAccountPicker(pickerKey)} style={commonStyles.chip}>
<Text style={[commonStyles.chipText, { color: theme.colors.fgSecondary }]}></Text>
</Pressable>
</>
)}
</ScrollView>
</View>
);
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={presentation === 'page' ? styles.flex1 : undefined}>
<View style={[
styles.sheet,
presentation === 'modal' && {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
},
presentation === 'page' && { backgroundColor: theme.colors.bgPrimary },
]}>
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={styles.content}>
{/* 工具行 */}
<View style={styles.toolRow}>
{presentation === 'modal' ? (
<Pressable onPress={onClose} hitSlop={8} accessibilityRole="button" accessibilityLabel="close">
<Ionicons name="chevron-down" size={24} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
<View style={styles.flex1} />
<Pressable onPress={handleOcr} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('home.ocrScan')}>
<Ionicons name="camera-outline" size={22} color={theme.colors.fgSecondary} />
</Pressable>
{presentation === 'page' ? (
<Pressable onPress={() => router.push('/import')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('tab.import')} style={{ marginLeft: 16 }}>
<Ionicons name="download-outline" size={22} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
</View>
{/* 方向 */}
<View style={styles.directionRow}>
{directions.map(d => (
<Pressable
key={d.key}
onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
style={[commonStyles.chip, direction === d.key && commonStyles.chipActive, styles.directionChip]}
>
<Text style={[commonStyles.chipText, direction === d.key && commonStyles.chipTextActive]}>{d.label}</Text>
</Pressable>
))}
</View>
{/* 金额 */}
<Text style={[theme.typography.display, styles.amount, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
¥ {amount || '0'}
</Text>
{/* 账户行(双腿之一) */}
{direction === 'transfer' ? (
<>
{renderAccountRow(t('numpad.outFrom'), sourceAccount, setSourceAccount, 'source')}
<Pressable onPress={swapAccounts} style={styles.swapBtn} accessibilityRole="button" accessibilityLabel={t('numpad.swap')}>
<Ionicons name="swap-vertical" size={18} color={theme.colors.fgSecondary} />
</Pressable>
{renderAccountRow(t('numpad.transferTo'), targetAccount, setTargetAccount, 'target')}
</>
) : (
renderAccountRow(t('numpad.from'), sourceAccount, setSourceAccount, 'source')
)}
{/* 分类快捷行(另一腿,非转账) */}
{direction !== 'transfer' && (
<View style={styles.categoryRow}>
{quickCategories.map(cat => {
const active = selectedCategory?.id === cat.id;
return (
<Pressable
key={cat.id}
onPress={() => setSelectedCategory(active ? null : cat)}
style={[styles.categoryCell, {
backgroundColor: active ? theme.colors.accentLight : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.md,
}]}
>
<CategoryIcon categoryId={cat.id} size={28} />
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]} numberOfLines={1}>{cat.name}</Text>
</Pressable>
);
})}
<Pressable
onPress={() => setCategoryPickerOpen(true)}
style={[styles.categoryCell, { backgroundColor: theme.colors.bgTertiary, borderColor: 'transparent', borderRadius: theme.radii.md }]}
>
<Ionicons name="grid-outline" size={22} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('numpad.allCategories')}</Text>
</Pressable>
</View>
)}
{/* 数字键盘(简单模式) */}
{!isAdvanced && (
<NumpadKeyboard onKey={handleKey} todayLabel={t('numpad.today')} doneLabel={t('numpad.done')} />
)}
{/* 分录预览(常驻小字) */}
{!isAdvanced && amount && sourceAccount && previewTarget ? (
<Text style={[theme.typography.caption, styles.preview, { color: theme.colors.fgSecondary }]} numberOfLines={1}>
{shortName(sourceAccount)} {shortName(previewTarget)} · ¥{amount} · {date}
</Text>
) : null}
{/* 更多抽屉 */}
<Pressable onPress={() => setShowMore(v => !v)} style={styles.moreToggle} accessibilityRole="button">
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>
{showMore ? t('numpad.less') : t('numpad.more')}
</Text>
<Ionicons name={showMore ? 'chevron-up-outline' : 'chevron-down-outline'} size={16} color={theme.colors.accent} />
</Pressable>
{showMore && (
<View style={styles.moreSection}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('transaction.formDate')}</Text>
<DatePickerField value={date} onChange={setDate} />
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formNarration')}</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formPayee')}</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formTags')}</Text>
<TagPicker
tags={tags}
selectedNames={selectedTags.map(tg => tg.name)}
onToggle={(tag) => setSelectedTags(prev =>
prev.some(x => x.name === tag.name) ? prev.filter(x => x.name !== tag.name) : [...prev, tag],
)}
/>
{/* 高级模式 */}
<View style={[commonStyles.switchRow, styles.advancedRow]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{t('numpad.advanced')}
</Text>
<Switch
value={isAdvanced}
onValueChange={toggleAdvanced}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</View>
)}
{/* 高级模式分录编辑器 */}
{isAdvanced && (
<View style={styles.advancedSection}>
<PostingEditor postings={postings} onChange={setPostings} />
<View style={styles.postingBtnRow}>
<View style={styles.flex1}>
<Button label={t('transaction.addPosting')} onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])} variant="secondary" />
</View>
<View style={styles.flex1}>
<Button label={t('transaction.deleteLastPosting')} onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))} variant="secondary" />
</View>
</View>
<Button label={t('transaction.submit')} onPress={submit} disabled={submitting} />
</View>
)}
{status || ocrStatus ? (
<Text style={[theme.typography.bodySmall, styles.status, { color: theme.colors.fgSecondary }]}>{status || ocrStatus}</Text>
) : null}
</ScrollView>
{/* 全部账户 BottomSheet */}
<BottomSheet visible={accountPicker !== null} onClose={() => setAccountPicker(null)} title={t('numpad.allAccounts')}>
<ScrollView style={styles.accountList}>
{assetAccounts.map(acct => (
<Pressable
key={acct}
onPress={() => {
if (accountPicker === 'source') setSourceAccount(acct);
else if (accountPicker === 'target') setTargetAccount(acct);
setAccountPicker(null);
}}
style={[styles.accountListItem, { borderBottomColor: theme.colors.divider }]}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{acct}</Text>
</Pressable>
))}
</ScrollView>
</BottomSheet>
{/* 全部分类 BottomSheet */}
<BottomSheet visible={categoryPickerOpen} onClose={() => setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<ScrollView style={styles.accountList}>
<CategoryPicker
categories={availableCategories}
selectedId={selectedCategory?.id}
onSelect={(cat) => { setSelectedCategory(cat); setCategoryPickerOpen(false); }}
/>
</ScrollView>
</BottomSheet>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex1: { flex: 1 },
sheet: { maxHeight: '100%' },
content: { paddingHorizontal: 16, paddingBottom: 24 },
toolRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8 },
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
directionChip: { flex: 1, alignItems: 'center' },
amount: { textAlign: 'right', paddingVertical: 8, fontVariant: ['tabular-nums'] },
accountRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4 },
accountLabel: { width: 34 },
accountChips: { gap: 6, paddingRight: 8 },
swapBtn: { alignSelf: 'center', padding: 4 },
categoryRow: { flexDirection: 'row', gap: 6, marginTop: 10, marginBottom: 6 },
categoryCell: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 8, gap: 4, borderWidth: 1.5 },
preview: { textAlign: 'center', marginTop: 6 },
moreToggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 10 },
moreSection: { gap: 4 },
fieldGap: { marginTop: 10, marginBottom: 4 },
advancedRow: { marginTop: 14 },
advancedSection: { marginTop: 8, gap: 8 },
postingBtnRow: { flexDirection: 'row', gap: 8 },
status: { textAlign: 'center', marginTop: 8 },
accountList: { maxHeight: 360 },
accountListItem: { paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
Run: `npm test` → Expected: 全部通过
> **评审修订(已实施,以实际代码为准):** ①C1 币种全链路currency 可写、预填回填 legs.currency、「更多」抽屉可编辑i18n 新增 `transaction.formCurrency`);②收入账户行 label 用 `numpad.to`③submit 与提交按钮加 `isSubmitted` 防护(防 600ms 窗口重复提交④toggleAdvanced 兜底方向感知Income/Expenses:Uncategorized⑤切转账清空非资金 targetAccount + 提交前资金账户校验i18n 新增 `numpad.transferTargetError`);⑥含 cost/price/metadata 的交易预填直接进高级模式;⑦转账保存不清空 lastUsedCategoryId⑧sourceAccount 空校验⑨OCR 状态 3s 自清 + 金额/摘要非空跳过。
---
### Task 6: transaction/new 重写为面板宿主
**Files:**
- Modify: `src/app/transaction/new.tsx`(完整重写)
- Modify: `src/app/_layout.tsx`transaction/new 的 Stack.Screen 去掉原生 header
- [ ] **Step 1: 完整重写 src/app/transaction/new.tsx**
```tsx
/**
* 记一笔NumpadSheet 整页宿主P3
* 编辑editId/ 草稿draftJson/ OCRmode=ocr预填复用同一面板。
*/
import React from 'react';
import { StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams } from 'expo-router';
import { useTheme } from '../../theme';
import { NumpadSheet } from '../../components/NumpadSheet';
export default function NewTransactionScreen() {
const { theme } = useTheme();
const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
<NumpadSheet
presentation="page"
editId={editId}
draftJson={draftJson}
autoOcr={mode === 'ocr'}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
});
```
- [ ] **Step 2: _layout.tsx 中 transaction/new 的 Stack.Screen 改为 headerShown: false**
找到注册 `transaction/new``Stack.Screen`(当前是少数 headerShown 为 true 的),把 options 改为 `headerShown: false`(面板自带工具行,不需要原生标题栏;返回由系统手势/返回键处理)。
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → Expected: 全部通过
---
### Task 7: 全局唤起NumpadSheetHost + 根布局 + AppTabBar 分流)
**Files:**
- Create: `src/components/NumpadSheetHost.tsx`
- Modify: `src/app/_layout.tsx`(挂载 Host
- Modify: `src/components/AppTabBar.tsx`(+按开关分流)
- [ ] **Step 1: 创建 src/components/NumpadSheetHost.tsx**
```tsx
import React from 'react';
import { Modal, Pressable, StyleSheet } from 'react-native';
import { useTheme } from '../theme';
import { NumpadSheet } from './NumpadSheet';
import { useNumpadUiStore } from '../store/numpadUiStore';
/** 全局记账面板宿主:挂载于根布局,任意页面可唤起(不丢上下文)。 */
export function NumpadSheetHost() {
const { theme } = useTheme();
const visible = useNumpadUiStore(s => s.visible);
const prefill = useNumpadUiStore(s => s.prefill);
const close = useNumpadUiStore(s => s.close);
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={close}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={close}>
<Pressable style={styles.sheetWrap} onPress={e => e.stopPropagation()}>
<NumpadSheet
presentation="modal"
editId={prefill?.editId}
draftJson={prefill?.draftJson}
autoOcr={prefill?.autoOcr}
onClose={close}
/>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheetWrap: { maxHeight: '94%' },
});
```
- [ ] **Step 2: _layout.tsx 挂载 Host**
`AppShell` 的 ready 分支渲染 `<Stack>` 的位置Stack 之后加 `<NumpadSheetHost />`(仍在 ThemeProvider 内;若 ready 分支返回单个元素,用 `<>` Fragment 包裹)。顶部加 import
```typescript
import { NumpadSheetHost } from '../components/NumpadSheetHost';
```
- [ ] **Step 3: AppTabBar.tsx +按开关分流**
- 顶部加:
```typescript
import { useSettingsStore } from '../store/settingsStore';
import { useNumpadUiStore } from '../store/numpadUiStore';
```
- 组件内加:
```typescript
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
const openNumpad = useNumpadUiStore(s => s.open);
```
- +按钮 onPress 改为:
```typescript
onPress={() => { if (numpadGlobalEntry) openNumpad(); else router.navigate('/transaction/new'); }}
```
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → Expected: 全部通过
---
### Task 8: SpeedDial 退役
**Files:**
- Modify: `src/app/(tabs)/index.tsx`
- Delete: `src/components/SpeedDial.tsx`
- [ ] **Step 1: index.tsx 移除 SpeedDial**
- 删除 `import { SpeedDial } from '../../components/SpeedDial';`
- 删除文件末尾的 `<SpeedDial actions={[...]} />` JSX约 211215 行)
- styles.content 的 `paddingBottom: 96` 改为 `paddingBottom: 40`(不再需要给 FAB 留位)
- 检查 `router` 变量是否仍被使用(其他地方有跳转则保留,否则清理未使用变量与 import
- [ ] **Step 2: 删除 src/components/SpeedDial.tsx**
Run: `rm src/components/SpeedDial.tsx`
然后确认无引用残留:`grep -rn "SpeedDial" src/` → Expected: 无输出
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → Expected: 全部通过
---
### Task 9: P3 全量验收
- [ ] **Step 1: 全量测试 + typecheck**
Run: `npm test` → Expected: 全部通过(含 numpad.test.ts 新测试)
Run: `npm run typecheck` → Expected: 无错误
- [ ] **Step 2: 审计**
Run: `grep -rn "SpeedDial" src/` → 无输出
Run: `grep -n "2196F3" src/` → 仅剩 `src/theme/palette.ts`TAG_COLORS 定义处transaction/new.tsx:219 的遗留已随重写消除)
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. +按钮(开关关闭时)→ 整页 NumpadSheet方向/金额/账户 chip/分类快捷/键盘/连续计算20+15=35/完成入账
2. 设置→偏好→开启「全局记账面板」→ +在任意页面弹出 Modal 面板,记账后自动关闭,原页面状态保留
3. 转账:双账户行 + 互换;同账户提交报「账户相同」错误
4. 编辑:交易详情 → 编辑 → 双腿/金额/标签正确回填,保存后内容替换
5. OCR工具行相机 → 选图 → 金额/方向/分类自动预填
6. 无 Assets 账户的账本:账户行显示「去创建账户」
7. 首页 SpeedDial 已消失;键盘「今天」键设置日期为当天
---
## 后续计划(不在本文件)
- **P4 四个 Tab 页**:首页待办条 / 交易时间线FilterSheet/ 报表 anchor 统一 / 我的 4 分组
- **P5 管理页全面模板化** + 清零审计 + `numpadGlobalEntry` 默认翻 true