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
111 lines
5.0 KiB
TypeScript
111 lines
5.0 KiB
TypeScript
/**
|
||
* 首页待办条(spec §7.1):周期记账到期 / 信用卡还款提醒 / 未确认自动账单。
|
||
* 三类都无待办时不渲染。
|
||
*/
|
||
import React, { useMemo } from 'react';
|
||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||
import { useRouter } from 'expo-router';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useTheme } from '../theme';
|
||
import { useT } from '../i18n';
|
||
import { Card } from './Card';
|
||
import { useLedgerStore } from '../store/ledgerStore';
|
||
import { useMetadataStore } from '../store/metadataStore';
|
||
import { useAutomationStore } from '../store/automationStore';
|
||
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../domain/recurring';
|
||
import { calculateBillingPeriod } from '../domain/creditCards';
|
||
import type { CreditCard } from '../domain/creditCards';
|
||
import { toDateString } from '../domain/decimal';
|
||
import { shiftAnchor } from '../domain/periodNav';
|
||
|
||
export function TodoStrip() {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const router = useRouter();
|
||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
||
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
||
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
|
||
const creditCards = useMetadataStore(s => s.creditCards);
|
||
const draftsCount = useAutomationStore(s => s.drafts.length);
|
||
|
||
const todayStr = useMemo(() => toDateString(new Date()), []);
|
||
const weekLater = useMemo(() => shiftAnchor(todayStr, 'weekly', 1), [todayStr]);
|
||
|
||
// 下一还款日:当月还款日已过则取下月周期(月末跨月场景,filter 与展示共用)
|
||
const nextDueDate = (card: CreditCard): string => {
|
||
const now = new Date();
|
||
const period = calculateBillingPeriod(card, now);
|
||
if (period.dueDate >= todayStr) return period.dueDate;
|
||
return calculateBillingPeriod(card, new Date(now.getFullYear(), now.getMonth() + 1, 1)).dueDate;
|
||
};
|
||
|
||
// autoConfirm 的由首页自动处理,待办条只列需手动确认的
|
||
const dueRecurring = useMemo(
|
||
() => getDueRecurring(recurringTransactions, todayStr).filter(r => !r.autoConfirm),
|
||
[recurringTransactions, todayStr],
|
||
);
|
||
const dueCards = useMemo(
|
||
() => creditCards.filter(card => {
|
||
const due = nextDueDate(card);
|
||
return due >= todayStr && due <= weekLater;
|
||
}),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[creditCards, todayStr, weekLater],
|
||
);
|
||
|
||
if (dueRecurring.length === 0 && dueCards.length === 0 && draftsCount === 0) return null;
|
||
|
||
const handleConfirmRecurring = async (rec: (typeof dueRecurring)[number]) => {
|
||
try {
|
||
const draft = instantiateRecurring(rec);
|
||
await addTransaction(draft);
|
||
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
|
||
updateRecurringTransaction(rec.id, { nextDueDate });
|
||
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
|
||
} catch (e) {
|
||
Alert.alert(t('home.recurringFail'), String(e));
|
||
}
|
||
};
|
||
|
||
const renderRow = (icon: keyof typeof Ionicons.glyphMap, text: string, key: string, action?: { label: string; onPress: () => void }) => (
|
||
<View key={key} style={[styles.row, { borderBottomColor: theme.colors.divider }]}>
|
||
<Ionicons name={icon} size={18} color={theme.colors.accent} />
|
||
<Text style={[theme.typography.bodySmall, styles.rowText, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
|
||
{text}
|
||
</Text>
|
||
{action ? (
|
||
<Pressable
|
||
onPress={action.onPress}
|
||
style={[styles.actionBtn, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.full }]}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={action.label}
|
||
>
|
||
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{action.label}</Text>
|
||
</Pressable>
|
||
) : (
|
||
<Ionicons name="chevron-forward" size={16} color={theme.colors.fgSecondary} />
|
||
)}
|
||
</View>
|
||
);
|
||
|
||
return (
|
||
<Card title={t('todo.title')}>
|
||
{dueRecurring.map(rec =>
|
||
renderRow('repeat-outline', t('todo.dueRecurring', { name: rec.name }), `rec-${rec.id}`, { label: t('todo.confirm'), onPress: () => handleConfirmRecurring(rec) }),
|
||
)}
|
||
{dueCards.map(card =>
|
||
renderRow('card-outline', t('todo.creditCardDue', { name: card.name, date: nextDueDate(card).slice(5) }), `card-${card.id}`, { label: t('todo.view'), onPress: () => router.push('/credit-card') }),
|
||
)}
|
||
{draftsCount > 0 &&
|
||
renderRow('flash-outline', t('todo.pendingDrafts', { count: draftsCount }), 'drafts', { label: t('todo.view'), onPress: () => router.push('/automation') })}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth },
|
||
rowText: { flex: 1 },
|
||
actionBtn: { paddingHorizontal: 12, paddingVertical: 5 },
|
||
actionText: { fontSize: 12, fontWeight: '700' },
|
||
});
|