# UI 重设计 P3:录入闭环(NumpadSheet)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:** 用金额优先的数字键盘面板(NumpadSheet)重写录入闭环:双腿账户 chip 选择、+/- 连续计算、全局+唤起(灰度开关)、transaction/new 整页重写为面板宿主、SpeedDial 退役。
**Architecture:** 纯逻辑(键盘表达式、postings→双腿解析)抽成可测模块;NumpadSheet 是唯一录入 UI,同时服务整页路由(/transaction/new,含编辑/草稿/OCR 预填)与全局 Modal(NumpadSheetHost 挂根布局);落账仍走 `buildAndSaveTransaction()`/`editTransaction`,domain 写路径零改动。
**Tech Stack:** React Native + Expo Router + Zustand + Ionicons + Vitest。
**Spec:** `docs/ui-redesign-design.md` §4(NumpadSheet)、§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 灰度,默认 false;P5 稳定后翻默认)。 */
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
{/* 记账 */}
{t('settings.numpadEntry')}
{t('settings.numpadEntryDesc')}
```
组件顶部加 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';
/** 键值:数字/小数点/加减走 numpadExpression;today/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 (
onKey(key)}
style={[styles.key, styles.doneKey, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.lg }]}
accessibilityRole="button"
accessibilityLabel={doneLabel}
>
{doneLabel}
);
}
if (key === 'backspace') {
return (
onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel="backspace">
);
}
if (key === 'today') {
return (
onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={todayLabel}>
{todayLabel}
);
}
if (key === '+' || key === '-') {
return (
onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}>
{key === '+' ? '+' : '-'}
);
}
return (
onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}>
{key}
);
};
return (
{ROWS.map((row, i) => (
{row.map(renderKey)}
))}
);
}
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>['event']>;
let processor: OcrProcessor | null = null;
export function useOcrScan() {
const t = useT();
const [status, setStatus] = useState('');
const scan = async (): Promise => {
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((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('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(null);
const [selectedTags, setSelectedTags] = useState([]);
const [showMore, setShowMore] = useState(false);
const [isAdvanced, setIsAdvanced] = useState(false);
const [postings, setPostings] = useState([]);
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(undefined);
const [editLinks, setEditLinks] = useState(undefined);
const [editMetadata, setEditMetadata] = useState | 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 = {};
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') => (
{label}
{assetAccounts.length === 0 ? (
router.push('/account')} style={commonStyles.chip}>
{t('numpad.noAccount')}
) : (
<>
{chipAccounts.map(acct => {
const active = value === acct;
return (
onSelect(acct)}
style={[commonStyles.chip, active && commonStyles.chipActive]}
>
{shortName(acct)}
);
})}
setAccountPicker(pickerKey)} style={commonStyles.chip}>
⋯
>
)}
);
return (
{/* 工具行 */}
{presentation === 'modal' ? (
) : null}
{presentation === 'page' ? (
router.push('/import')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('tab.import')} style={{ marginLeft: 16 }}>
) : null}
{/* 方向 */}
{directions.map(d => (
{ setDirection(d.key); setSelectedCategory(null); }}
style={[commonStyles.chip, direction === d.key && commonStyles.chipActive, styles.directionChip]}
>
{d.label}
))}
{/* 金额 */}
¥ {amount || '0'}
{/* 账户行(双腿之一) */}
{direction === 'transfer' ? (
<>
{renderAccountRow(t('numpad.outFrom'), sourceAccount, setSourceAccount, 'source')}
{renderAccountRow(t('numpad.transferTo'), targetAccount, setTargetAccount, 'target')}
>
) : (
renderAccountRow(t('numpad.from'), sourceAccount, setSourceAccount, 'source')
)}
{/* 分类快捷行(另一腿,非转账) */}
{direction !== 'transfer' && (
{quickCategories.map(cat => {
const active = selectedCategory?.id === cat.id;
return (
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,
}]}
>
{cat.name}
);
})}
setCategoryPickerOpen(true)}
style={[styles.categoryCell, { backgroundColor: theme.colors.bgTertiary, borderColor: 'transparent', borderRadius: theme.radii.md }]}
>
{t('numpad.allCategories')}
)}
{/* 数字键盘(简单模式) */}
{!isAdvanced && (
)}
{/* 分录预览(常驻小字) */}
{!isAdvanced && amount && sourceAccount && previewTarget ? (
{shortName(sourceAccount)} → {shortName(previewTarget)} · ¥{amount} · {date}
) : null}
{/* 更多抽屉 */}
setShowMore(v => !v)} style={styles.moreToggle} accessibilityRole="button">
{showMore ? t('numpad.less') : t('numpad.more')}
{showMore && (
{t('transaction.formDate')}
{t('transaction.formNarration')}
{t('transaction.formPayee')}
{t('transaction.formTags')}
tg.name)}
onToggle={(tag) => setSelectedTags(prev =>
prev.some(x => x.name === tag.name) ? prev.filter(x => x.name !== tag.name) : [...prev, tag],
)}
/>
{/* 高级模式 */}
{t('numpad.advanced')}
)}
{/* 高级模式分录编辑器 */}
{isAdvanced && (
)}
{status || ocrStatus ? (
{status || ocrStatus}
) : null}
{/* 全部账户 BottomSheet */}
setAccountPicker(null)} title={t('numpad.allAccounts')}>
{assetAccounts.map(acct => (
{
if (accountPicker === 'source') setSourceAccount(acct);
else if (accountPicker === 'target') setTargetAccount(acct);
setAccountPicker(null);
}}
style={[styles.accountListItem, { borderBottomColor: theme.colors.divider }]}
>
{acct}
))}
{/* 全部分类 BottomSheet */}
setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
{ setSelectedCategory(cat); setCategoryPickerOpen(false); }}
/>
);
}
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)/ OCR(mode=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 (
);
}
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 (
e.stopPropagation()}>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheetWrap: { maxHeight: '94%' },
});
```
- [ ] **Step 2: _layout.tsx 挂载 Host**
在 `AppShell` 的 ready 分支渲染 `` 的位置,Stack 之后加 ``(仍在 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';`
- 删除文件末尾的 `` JSX(约 211–215 行)
- 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