# 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 && (