DriftLedger/docs/design/ui-redesign-p3-plan.md
fengmengqi bf04400852 feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

OCR 三层独立控制:
- OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关
- 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision)
- OCR 处理增加耗时与字符数日志

日志系统升级:
- logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log)
- 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx
- 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理
- _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号)

原生浮层 UI 主题同步(P6):
- 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生
- 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取
- FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal
- FloatingHelper / FloatingTip 同步适配
- _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送

UI 与组件增强:
- FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange
- 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗
- 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择
- AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型
- 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗)

去重优化:
- 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复
- DedupResult 新增 matchedItem 返回匹配对比项

文档重构:
- README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表)
- 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图)
- 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md
- UI 重设计文档(design spec + p1-p8)移入 docs/design/

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
2026-07-23 19:03:38 +08:00

1475 lines
57 KiB
Markdown
Raw Permalink 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