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
1475 lines
57 KiB
Markdown
1475 lines
57 KiB
Markdown
# 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
|
||
{/* 记账 */}
|
||
<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';
|
||
|
||
/** 键值:数字/小数点/加减走 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 (
|
||
<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)/ 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 (
|
||
<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(约 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
|