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
935 lines
33 KiB
Markdown
935 lines
33 KiB
Markdown
# 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';
|
||
|
||
/** 取分类图标:具名映射,否则 fallback(hasOwn 防原型链穿透)。 */
|
||
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` 常量(第 13–30 行)。
|
||
- 顶部加 `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 条 PASS(dateGrid 仍 FAIL,Task 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/new,P3 改为唤起全局 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.tsx(ManagementScreen 试点)**
|
||
|
||
```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 页为已验证模板)+ 清零审计
|