# 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 页为已验证模板）+ 清零审计
