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

935 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# UI 重设计 P2核心组件库 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:** 建立新设计系统的核心组件库CategoryIcon去 emoji、BottomSheet、DatePickerField、ScreenHeader、StatCard、AppTabBar中央凸起、ManagementScreen 模板tag 页试点),并将 Button/Card/SearchBar/FormModal 重刷到新 token。
**Architecture:** 全部为 `src/components/` 下的新组件或既有组件改造;纯逻辑(图标映射、月历网格)抽成无 RN 依赖的独立模块以便 Vitest(node) 测试。AppTabBar 通过 expo-router Tabs 的 `tabBar` 插槽接入;+按钮 P2 暂跳 `/transaction/new`P3 换 NumpadSheet。
**Tech Stack:** React Native + Expo Router + Ionicons + Vitest。
**Spec:** `docs/ui-redesign-design.md` §5导航、§6组件库。**与 spec 的偏差**FilterSheet 移至 P4其形态依赖交易页筛选状态P4 一并设计CategoryPicker 的"改底部弹层"移至 P3随 NumpadSheet 一起设计P2 仅完成 Ionicon 化FormModal 增加 `children` 支持ManagementScreen 的颜色选择器需要)。
**前置状态:** P1 已完成(新 tokens/presets/palette 已生效系统字体538 测试全绿)。
---
### Task 1: 组件纯逻辑的失败测试
**Files:**
- Create: `tests/components-p2.test.ts`
- [ ] **Step 1: 新建 tests/components-p2.test.ts**
```typescript
import { describe, expect, it } from 'vitest';
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/categoryIcons';
import { buildMonthGrid } from '../src/components/dateGrid';
describe('getCategoryIcon', () => {
it('15 个内置分类都有具名图标', () => {
const builtin = [
'food', 'transport', 'shopping', 'housing_utility', 'housing_rent',
'housing_communication', 'entertainment', 'services', 'personal_care',
'clothing', 'health', 'learning', 'salary', 'income_activity', 'income_investment',
];
for (const id of builtin) {
const icon = getCategoryIcon(id);
expect(typeof icon).toBe('string');
expect(icon.length).toBeGreaterThan(0);
}
});
it('未知分类返回 fallback 图标', () => {
expect(getCategoryIcon('whatever_custom')).toBe('pricetag-outline');
});
it('原型链属性名 id 不穿透(返回 fallback', () => {
expect(getCategoryIcon('constructor')).toBe('pricetag-outline');
});
it('所有图标名以 -outline 结尾(风格统一)', () => {
for (const name of Object.values(CATEGORY_ICON_NAMES)) {
expect(name).toMatch(/-outline$/);
}
});
});
describe('buildMonthGrid', () => {
it('固定 6 行 × 7 列', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid).toHaveLength(6);
for (const week of grid) expect(week).toHaveLength(7);
});
it('2026-07首日周三周一开头填充 6 月末两天', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid[0][0]).toEqual({ date: '2026-06-29', inMonth: false });
expect(grid[0][1]).toEqual({ date: '2026-06-30', inMonth: false });
expect(grid[0][2]).toEqual({ date: '2026-07-01', inMonth: true });
expect(grid[5][6]).toEqual({ date: '2026-08-09', inMonth: false });
});
it('2026-02首日周日填充 1 月最后 6 天', () => {
const grid = buildMonthGrid(2026, 2);
expect(grid[0][0].date).toBe('2026-01-26');
expect(grid[0][6]).toEqual({ date: '2026-02-01', inMonth: true });
});
it('首日恰为周一时无跨月填充2026-06', () => {
const grid = buildMonthGrid(2026, 6);
expect(grid[0][0]).toEqual({ date: '2026-06-01', inMonth: true });
});
it('inMonth 标记与当月天数一致2026-07 共 31 天)', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid.flat().filter(c => c.inMonth)).toHaveLength(31);
});
});
```
- [ ] **Step 2: 运行确认失败**
Run: `npx vitest run tests/components-p2.test.ts`
Expected: FAIL —— `Cannot find module '../src/components/categoryIcons'`。**若意外通过,报告 BLOCKED。**
---
### Task 2: categoryIcons + CategoryIcon + CategoryPicker 去 emoji
**Files:**
- Create: `src/components/categoryIcons.ts`
- Create: `src/components/CategoryIcon.tsx`
- Modify: `src/components/CategoryPicker.tsx`
- [ ] **Step 1: 创建 src/components/categoryIcons.ts**
```typescript
/**
* 分类 id → Ionicons 图标名映射(替换原 CategoryPicker 的 emoji 表)。
* 纯数据模块:仅含类型导入(运行时零依赖),可在 Vitest(node) 中直接测试。
*/
import type { ComponentProps } from 'react';
import type { Ionicons } from '@expo/vector-icons';
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
food: 'fast-food-outline',
transport: 'bus-outline',
shopping: 'bag-handle-outline',
housing_utility: 'water-outline',
housing_rent: 'home-outline',
housing_communication: 'call-outline',
entertainment: 'game-controller-outline',
services: 'construct-outline',
personal_care: 'sparkles-outline',
clothing: 'shirt-outline',
health: 'medkit-outline',
learning: 'book-outline',
salary: 'wallet-outline',
income_activity: 'gift-outline',
income_investment: 'trending-up-outline',
};
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
/** 取分类图标:具名映射,否则 fallbackhasOwn 防原型链穿透)。 */
export function getCategoryIcon(categoryId: string): IoniconName {
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
? CATEGORY_ICON_NAMES[categoryId]
: FALLBACK_ICON;
}
```
- [ ] **Step 2: 创建 src/components/CategoryIcon.tsx**
```tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { getCategoryColor } from '../theme/palette';
import { getCategoryIcon } from './categoryIcons';
interface CategoryIconProps {
categoryId: string;
/** 圆形底色直径,默认 36。 */
size?: number;
}
/** 分类图标Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji。 */
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
const color = getCategoryColor(categoryId);
return (
<View
style={[styles.circle, {
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color + '1A',
}]}
>
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
</View>
);
}
const styles = StyleSheet.create({
circle: { alignItems: 'center', justifyContent: 'center' },
});
```
- [ ] **Step 3: CategoryPicker.tsx 接入 CategoryIcon**
- 删除整个 `CATEGORY_ICONS` 常量(第 1330 行)。
- 顶部加 `import { CategoryIcon } from './CategoryIcon';``getCategoryColor` 的 import 保留)。
- 删除 `const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;` 一行。
- JSX 中 `<Text style={[styles.icon, { color }]}>{icon}</Text>` 替换为 `<CategoryIcon categoryId={cat.id} size={32} />`
- 删除 label 样式里的 `fontFamily: theme.typography.caption.fontFamily,` 一行。
- 删除 styles 中的 `icon` 条目。
- 注意:`const color = getCategoryColor(cat.id);` 保留active 底色仍用)。
- [ ] **Step 4: 验证**
Run: `npx vitest run tests/components-p2.test.ts` → categoryIcons 相关 4 条 PASSdateGrid 仍 FAILTask 3 修复)
Run: `npm run typecheck` → Expected: 无错误
---
### Task 3: dateGrid 月历网格纯逻辑
**Files:**
- Create: `src/components/dateGrid.ts`
- [ ] **Step 1: 创建 src/components/dateGrid.ts**
```typescript
/**
* 月历网格生成(周一开头,固定 6 行×7 列,含前后月填充)。
* 纯 TS无 RN 依赖,供 DatePickerField / CalendarView 复用。
*/
export interface DayCell {
/** YYYY-MM-DD本地时区。 */
date: string;
inMonth: boolean;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
function fmt(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/** 生成指定年月的月历网格。month 为 1-12。 */
export function buildMonthGrid(year: number, month: number): DayCell[][] {
const first = new Date(year, month - 1, 1);
// 周一为一周起点getDay() 周日=0 → 偏移 (day+6)%7
const offset = (first.getDay() + 6) % 7;
const cursor = new Date(year, month - 1, 1 - offset);
const weeks: DayCell[][] = [];
for (let w = 0; w < 6; w++) {
const week: DayCell[] = [];
for (let d = 0; d < 7; d++) {
week.push({ date: fmt(cursor), inMonth: cursor.getMonth() === month - 1 });
cursor.setDate(cursor.getDate() + 1);
}
weeks.push(week);
}
return weeks;
}
```
- [ ] **Step 2: 验证**
Run: `npx vitest run tests/components-p2.test.ts` → Expected: 全部 9 条 PASS
---
### Task 4: BottomSheet 通用底部弹层
**Files:**
- Create: `src/components/BottomSheet.tsx`
- [ ] **Step 1: 创建 src/components/BottomSheet.tsx**
```tsx
import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface BottomSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
export function BottomSheet({ visible, onClose, title, children }: BottomSheetProps) {
const { theme } = useTheme();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
{title ? (
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
) : null}
{children}
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
title: { marginTop: 8, marginBottom: 12 },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 5: DatePickerField 日期选择字段
**Files:**
- Create: `src/components/DatePickerField.tsx`
- [ ] **Step 1: 创建 src/components/DatePickerField.tsx**
```tsx
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { BottomSheet } from './BottomSheet';
import { buildMonthGrid } from './dateGrid';
interface DatePickerFieldProps {
/** YYYY-MM-DD空串表示未选。 */
value: string;
onChange: (date: string) => void;
placeholder?: string;
}
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD。 */
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
const openPicker = () => {
const y = Number(value.slice(0, 4));
const m = Number(value.slice(5, 7));
if (y) {
setViewYear(y);
setViewMonth(m || 1);
}
setOpen(true);
};
const shiftMonth = (delta: number) => {
const m = viewMonth - 1 + delta;
setViewYear(viewYear + Math.floor(m / 12));
setViewMonth(((m % 12) + 12) % 12 + 1);
};
const pick = (date: string) => {
onChange(date);
setOpen(false);
};
return (
<>
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button">
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
{value || placeholder || 'YYYY-MM-DD'}
</Text>
</Pressable>
<BottomSheet visible={open} onClose={() => setOpen(false)}>
{/* 月份导航 */}
<View style={styles.navRow}>
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
{viewYear}-{String(viewMonth).padStart(2, '0')}
</Text>
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
</Pressable>
</View>
{/* 星期头 */}
<View style={styles.weekRow}>
{WEEKDAYS.map(w => (
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
))}
</View>
{/* 日期网格 */}
{grid.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const selected = cell.date === value;
return (
<Pressable
key={cell.date}
onPress={() => pick(cell.date)}
style={[styles.dayCell, {
backgroundColor: selected ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.full,
}]}
>
<Text style={[theme.typography.bodySmall, {
color: selected
? theme.colors.fgInverse
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
opacity: cell.inMonth ? 1 : 0.5,
fontVariant: ['tabular-nums'],
}]}>
{Number(cell.date.slice(8, 10))}
</Text>
</Pressable>
);
})}
</View>
))}
</BottomSheet>
</>
);
}
const styles = StyleSheet.create({
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
weekRow: { flexDirection: 'row' },
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 6: ScreenHeader + StatCard
**Files:**
- Create: `src/components/ScreenHeader.tsx`
- Create: `src/components/StatCard.tsx`
- [ ] **Step 1: 创建 src/components/ScreenHeader.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
interface ScreenHeaderProps {
title: string;
/** 右侧操作位(图标按钮/文本按钮)。 */
right?: React.ReactNode;
/** 自定义返回行为;默认 router.back()。 */
onBack?: () => void;
/** 返回按钮的无障碍标签。 */
backLabel?: string;
}
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header。 */
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
const { theme } = useTheme();
const router = useRouter();
return (
<View style={styles.row}>
<Pressable
onPress={onBack ?? (() => router.back())}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={backLabel}
>
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{title}
</Text>
{right ?? <View style={styles.rightPlaceholder} />}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
title: { flex: 1 },
rightPlaceholder: { width: 24 },
});
```
- [ ] **Step 2: 创建 src/components/StatCard.tsx**
```tsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface StatCardProps {
label: string;
value: string;
caption?: string;
/** 默认 fgPrimary收支场景传 financial.income/expense。 */
valueColor?: string;
}
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
const { theme } = useTheme();
return (
<View
style={[styles.card, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.xl,
borderColor: theme.colors.border,
}, theme.shadows.sm]}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
<Text
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
numberOfLines={1}
>
{value}
</Text>
{caption ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
});
```
- [ ] **Step 3: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 7: AppTabBar 中央凸起+导航
**Files:**
- Create: `src/components/AppTabBar.tsx`
- Modify: `src/app/(tabs)/_layout.tsx`
- [ ] **Step 1: 创建 src/components/AppTabBar.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useTheme } from '../theme';
import type { IoniconName } from './categoryIcons';
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
const TAB_ICONS: Record<string, IoniconName> = {
index: 'home-outline',
transactions: 'list-outline',
report: 'pie-chart-outline',
settings: 'settings-outline',
};
/**
* 自定义底部导航4 个内容 Tab + 中央凸起+。
* 不是路由——P2 暂跳转 /transaction/newP3 改为唤起全局 NumpadSheet。
*/
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const { theme } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
const renderTab = (route: (typeof state.routes)[number], index: number) => {
const focused = state.index === index;
const options = descriptors[route.key].options;
const label = (options.title ?? route.name) as string;
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
const onPress = () => {
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
};
return (
<Pressable
key={route.key}
onPress={onPress}
style={styles.tab}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
accessibilityLabel={label}
>
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
</Pressable>
);
};
return (
<View
style={[styles.bar, {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
paddingBottom: Math.max(insets.bottom, 8),
}]}
>
{state.routes.slice(0, 2).map(renderTab)}
<View style={styles.plusSlot}>
<Pressable
onPress={() => router.push('/transaction/new')}
style={[styles.plus, {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.bgPrimary,
}, theme.shadows.lg]}
accessibilityRole="button"
accessibilityLabel="记一笔"
>
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
</Pressable>
</View>
{state.routes.slice(2).map(renderTab)}
</View>
);
}
const styles = StyleSheet.create({
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
tabLabel: { fontSize: 10, fontWeight: '600' },
plusSlot: { width: 64, alignItems: 'center' },
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
});
```
- [ ] **Step 2: 接入 (tabs)/_layout.tsx**
- 顶部加 `import { AppTabBar } from '../../components/AppTabBar';`,删除 `import { Ionicons } from '@expo/vector-icons';`(不再需要)。
- 第 7 行注释改为 `/** 底部 Tab 导航:首页/交易/报表/设置 + 中央AppTabBar。 */`
- `Tabs` 组件加 prop`tabBar={(props) => <AppTabBar {...props} />}`。
- 删除 4 个 `Tabs.Screen` 里的 `tabBarIcon` 属性(图标由 AppTabBar 的 TAB_ICONS 决定),`title` 保留。
- screenOptions 中 `tabBarActiveTintColor` / `tabBarInactiveTintColor` / `tabBarStyle` 可删除(自定义 tabBar 不用),仅保留 `headerShown: false`
- [ ] **Step 3: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 8: FormModal children 支持 + ManagementScreen + tag 页试点
**Files:**
- Modify: `src/components/FormModal.tsx`
- Create: `src/components/ManagementScreen.tsx`
- Modify: `src/app/tag/index.tsx`(完整重写)
- [ ] **Step 1: FormModal 增加 children + ✕ 换图标 + 圆角 xl**
- `FormModalProps` 加字段:
```typescript
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
children?: React.ReactNode;
```
- 函数签名解构加 `children`;在 `</ScrollView>` 之后、`<View style={styles.actions}>` 之前插入 `{children}`
- 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `<Text ...>✕</Text>` 替换为 `<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />`
- sheet 的 `borderRadius: theme.radii.lg` 改为 `theme.radii.xl`
- [ ] **Step 2: 创建 src/components/ManagementScreen.tsx**
```tsx
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { ScreenHeader } from './ScreenHeader';
import { FormModal, type FormField } from './FormModal';
/**
* 管理页模板P2统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
* 各管理页只声明字段配置与数据读写,不再复制 header/Alert/Modal 样板。
*/
interface ManagementScreenProps<T> {
title: string;
items: T[];
keyExtractor: (item: T) => string;
/** 渲染单个条目handlers.openEdit 打开编辑弹窗handlers.confirmDelete 弹删除确认。 */
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
addLabel: string;
emptyText?: string;
formTitle: (editing: T | null) => string;
formFields: (editing: T | null) => FormField[];
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
onDelete: (item: T) => void;
deleteConfirmText: (item: T) => string;
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
formExtra?: (editing: T | null) => React.ReactNode;
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
onOpenForm?: (editing: T | null) => void;
/** 列表上方的额外内容(分组 Tab 等)。 */
headerContent?: React.ReactNode;
footer?: React.ReactNode;
}
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
const { theme } = useTheme();
const t = useT();
const [editing, setEditing] = useState<T | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openForm = (item: T | null) => {
setEditing(item);
props.onOpenForm?.(item);
setModalOpen(true);
};
const confirmDelete = (item: T) => {
Alert.alert(t('common.delete'), props.deleteConfirmText(item), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
]);
};
const handleSubmit = (values: Record<string, string>) => {
if (props.onSubmit(values, editing)) setModalOpen(false);
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader
title={props.title}
right={
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
<Ionicons name="add" size={26} color={theme.colors.accent} />
</Pressable>
}
/>
<ScrollView contentContainerStyle={styles.content}>
{props.headerContent}
{props.items.length === 0 && props.emptyText ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
) : null}
{props.items.map(item => (
<View key={props.keyExtractor(item)}>
{props.renderItem(item, {
openEdit: () => openForm(item),
confirmDelete: () => confirmDelete(item),
})}
</View>
))}
{props.footer}
</ScrollView>
<FormModal
visible={modalOpen}
title={props.formTitle(editing)}
fields={props.formFields(editing)}
onConfirm={handleSubmit}
onCancel={() => setModalOpen(false)}
>
{props.formExtra?.(editing)}
</FormModal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, gap: 12 },
});
```
- [ ] **Step 3: 完整重写 src/app/tag/index.tsxManagementScreen 试点)**
```tsx
/**
* 标签管理页面 —— ManagementScreen 模板试点P2
*
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
*/
import React, { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { TAG_COLORS } from '../../theme/palette';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { ManagementScreen } from '../../components/ManagementScreen';
import { isValidTagName } from '../../domain/tags';
import type { Tag } from '../../domain/tags';
export default function TagScreen() {
const { theme } = useTheme();
const t = useT();
const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag);
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
return (
<ManagementScreen<Tag>
title={t('tag.title')}
items={tags}
keyExtractor={tag => tag.id}
addLabel={t('tag.add')}
emptyText={t('tag.empty')}
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
renderItem={(tag, { openEdit, confirmDelete }) => (
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={[styles.chip, { backgroundColor: tag.color }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable>
)}
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
formFields={editing => [
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
]}
formExtra={() => (
<View style={styles.colorRow}>
{TAG_COLORS.map(c => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
backgroundColor: c,
borderWidth: selectedColor === c ? 3 : 0,
borderColor: theme.colors.fgPrimary,
}]}
/>
))}
</View>
)}
onSubmit={(values, editing) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return false;
}
if (editing) updateTag(editing.id, { name, color: selectedColor });
else addTag({ id: generateId('tag'), name, color: selectedColor });
return true;
}}
onDelete={tag => removeTag(tag.id)}
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
const styles = StyleSheet.create({
chip: { alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 },
});
```
- [ ] **Step 4: 验证**
Run: `npm test` → Expected: 全部通过tag 页逻辑变化不影响单测,但全量跑一遍防回归)
Run: `npm run typecheck` → Expected: 无错误
---
### Task 9: Button / Card / SearchBar 重刷
**Files:**
- Modify: `src/components/Button.tsx`
- Modify: `src/components/Card.tsx`
- Modify: `src/components/SearchBar.tsx`
- [ ] **Step 1: Button.tsx**
- `borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
- 删除 Text 样式中的 `fontFamily: theme.typography.body.fontFamily`(系统字体)。
- [ ] **Step 2: Card.tsx**
- cardStyle 中 `borderRadius: theme.radii.lg,` 改为 `borderRadius: theme.radii.xl,`
- [ ] **Step 3: SearchBar.tsx**
- `borderRadius: theme.radii.lg, // 升级为 lg 圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → Expected: 全部通过
---
### Task 10: P2 全量验收
- [ ] **Step 1: 全量测试 + typecheck**
Run: `npm test` → Expected: 全部通过(含 components-p2 的 9 条新测试)
Run: `npm run typecheck` → Expected: 无错误
- [ ] **Step 2: 审计**
Run: `grep -rn "🍔\|🚗\|🛍\|💧\|🏠\|📞\|🎮\|⚙️\|💅\|👕\|🏥\|📚\|💰\|🧧\|📈\|🏷" src/` → Expected: 无输出(分类 emoji 清零)
Run: `grep -n "fontFamily" src/components/Button.tsx src/components/CategoryPicker.tsx` → Expected: 无输出
Run: `grep -n "✕" src/components/` → Expected: 无输出
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. 底部导航为自定义 bar中央黑色凸起4 个 tab 可切换
2. → 跳转记一笔页P2 占位行为)
3. 记一笔页分类网格:图标为 Ionicons 圆底彩色,无 emoji
4. 标签管理页:新 ScreenHeader +号新增、点按编辑、长按删除、颜色选择器在弹窗内正常工作
5. 任意 FormModal关闭按钮为 Ionicons 图标而非 ✕ 字符
---
## 后续计划(不在本文件)
- **P3 录入闭环**NumpadSheet + 双腿账户选择 + AppTabBar 改全局唤起 + transaction/new 重写 + SpeedDial 退役
- **P4 四个 Tab 页**:首页待办条 / 交易时间线FilterSheet 在此设计)/ 报表 anchor 统一 / 我的 4 分组
- **P5 管理页全面模板化**tag 页为已验证模板)+ 清零审计