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
108 lines
4.7 KiB
TypeScript
108 lines
4.7 KiB
TypeScript
/**
|
||
* 分类管理页面(plan.md「1.1 category/index」+ 决策 1 双轨制)。
|
||
*
|
||
* 功能:分类列表(支出/收入 chips 切换)+ 添加/编辑/删除。
|
||
* 数据来源:metadataStore(持久化),linkedAccount 映射到 Beancount 账户。
|
||
* P5:套 ManagementScreen 模板,headerContent 放支出/收入 chips。
|
||
*/
|
||
import React, { useMemo, useState } from 'react';
|
||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||
import { useTheme, createCommonStyles } from '../../theme';
|
||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||
import { useT } from '../../i18n';
|
||
import { Card } from '../../components/Card';
|
||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||
import type { Category } from '../../domain/categories';
|
||
|
||
export default function CategoryScreen() {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||
|
||
const categories = useMetadataStore(s => s.categories);
|
||
const addCategory = useMetadataStore(s => s.addCategory);
|
||
const updateCategory = useMetadataStore(s => s.updateCategory);
|
||
const removeCategory = useMetadataStore(s => s.removeCategory);
|
||
|
||
/** 当前展示的分类类型(支出/收入),新增分支按此落库。 */
|
||
const [catType, setCatType] = useState<'expense' | 'income'>('expense');
|
||
|
||
return (
|
||
<ManagementScreen<Category>
|
||
title={t('category.title')}
|
||
items={categories.filter(c => c.type === catType)}
|
||
keyExtractor={cat => cat.id}
|
||
addLabel={t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })}
|
||
headerContent={
|
||
<View style={styles.chipRow}>
|
||
{(['expense', 'income'] as const).map(tp => (
|
||
<Pressable
|
||
key={tp}
|
||
onPress={() => setCatType(tp)}
|
||
style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
|
||
>
|
||
<Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
|
||
{tp === 'expense' ? t('category.expense') : t('category.income')}
|
||
</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
}
|
||
renderItem={(cat, { openEdit, confirmDelete }) => (
|
||
<Pressable
|
||
onPress={openEdit}
|
||
onLongPress={confirmDelete}
|
||
style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
|
||
>
|
||
<Card title={cat.name}>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
|
||
{cat.keywords.length > 0 && (
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
|
||
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
|
||
</Text>
|
||
)}
|
||
</Card>
|
||
</Pressable>
|
||
)}
|
||
formTitle={editing => (editing
|
||
? t('category.editTitle')
|
||
: t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') }))}
|
||
formFields={editing => [
|
||
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: editing?.name },
|
||
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: editing?.linkedAccount },
|
||
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: editing?.keywords.join(', ') },
|
||
]}
|
||
onSubmit={(values, editing) => {
|
||
if (editing) {
|
||
updateCategory(editing.id, {
|
||
name: values.name || editing.name,
|
||
linkedAccount: values.linkedAccount || editing.linkedAccount,
|
||
keywords: values.keywords ? values.keywords.split(/[,,\s]+/).filter(Boolean) : [],
|
||
});
|
||
} else {
|
||
addCategory({
|
||
id: generateId('cat'),
|
||
name: values.name || t('common.untitled'),
|
||
type: catType,
|
||
linkedAccount: values.linkedAccount || (catType === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized'),
|
||
keywords: values.keywords ? values.keywords.split(/[,,\s]+/).filter(Boolean) : [],
|
||
});
|
||
}
|
||
return true;
|
||
}}
|
||
onDelete={cat => removeCategory(cat.id)}
|
||
deleteConfirmText={cat => t('category.deleteConfirm', { name: cat.name })}
|
||
deleteConfirmTitle={t('category.deleteTitle')}
|
||
footer={
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||
{t('common.clickEditLongDelete')}
|
||
</Text>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
chipRow: { flexDirection: 'row', gap: 8 },
|
||
});
|