# 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['name']; export const CATEGORY_ICON_NAMES: Record = { 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 ( ); } 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 中 `{icon}` 替换为 ``。 - 删除 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 ( e.stopPropagation()} > {title ? ( {title} ) : null} {children} ); } 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 ( <> {value || placeholder || 'YYYY-MM-DD'} setOpen(false)}> {/* 月份导航 */} shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月"> {viewYear}-{String(viewMonth).padStart(2, '0')} shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月"> {/* 星期头 */} {WEEKDAYS.map(w => ( {w} ))} {/* 日期网格 */} {grid.map((week, wi) => ( {week.map(cell => { const selected = cell.date === value; return ( pick(cell.date)} style={[styles.dayCell, { backgroundColor: selected ? theme.colors.accent : 'transparent', borderRadius: theme.radii.full, }]} > {Number(cell.date.slice(8, 10))} ); })} ))} ); } 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 ( router.back())} hitSlop={8} accessibilityRole="button" accessibilityLabel={backLabel} > {title} {right ?? } ); } 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 ( {label} {value} {caption ? ( {caption} ) : null} ); } 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 = { 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 ( {label} ); }; return ( {state.routes.slice(0, 2).map(renderTab)} router.push('/transaction/new')} style={[styles.plus, { backgroundColor: theme.colors.accent, borderColor: theme.colors.bgPrimary, }, theme.shadows.lg]} accessibilityRole="button" accessibilityLabel="记一笔" > {state.routes.slice(2).map(renderTab)} ); } 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) => }`。 - 删除 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`;在 `` 之后、`` 之前插入 `{children}`。 - 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `` 替换为 ``。 - 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 { 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, 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(props: ManagementScreenProps) { const { theme } = useTheme(); const t = useT(); const [editing, setEditing] = useState(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) => { if (props.onSubmit(values, editing)) setModalOpen(false); }; return ( openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}> } /> {props.headerContent} {props.items.length === 0 && props.emptyText ? ( {props.emptyText} ) : null} {props.items.map(item => ( {props.renderItem(item, { openEdit: () => openForm(item), confirmDelete: () => confirmDelete(item), })} ))} {props.footer} setModalOpen(false)} > {props.formExtra?.(editing)} ); } 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(TAG_COLORS[0]); return ( 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 }) => ( #{tag.name} )} formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))} formFields={editing => [ { key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name }, ]} formExtra={() => ( {TAG_COLORS.map(c => ( setSelectedColor(c)} style={[styles.colorDot, { backgroundColor: c, borderWidth: selectedColor === c ? 3 : 0, borderColor: theme.colors.fgPrimary, }]} /> ))} )} 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={ {t('common.clickEditLongDelete')} } /> ); } 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 页为已验证模板)+ 清零审计