import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DeviceEventEmitter, FlatList, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { SafeAreaView } from 'react-native-safe-area-context'; import { COLORS, FONT_SIZE, LEDGER_EVENTS, RADIUS, SPACING, findCategory } from '../constants'; import { getDaySummaries, getMaxDailyExpense, getRecordsByDate, getRecordsByRange, } from '../database/ledgerDB'; import { formatMoney, getMonthRange, getToday, parseDate, addMonths, getMonthName, getDaysInMonth, } from '../utils/dateUtils'; import { hapticLight } from '../utils/haptics'; import { useToast } from '../hooks/useToast'; import { confirmDeleteRecord } from '../hooks/useDeleteRecord'; import { getCachedMembers, type MemberInfo } from '../sync/memberUtils'; import MonthHeatmap from '../components/MonthHeatmap'; import RecordList, { RecordRow } from '../components/RecordList'; import EditRecordModal from '../components/EditRecordModal'; import type { LedgerRecord, RecordType } from '../types'; type FilterType = 'all' | RecordType; // 流水模式拍平后的列表项(虚拟化渲染;按收支记录分组) type FlowItem = { kind: 'header'; date: string; count: number } | { kind: 'record'; record: LedgerRecord }; interface Props { active: boolean; // 当前 Tab 激活(App 常驻挂载,激活时滚回顶部) } export default function LedgerScreen({ active }: Props) { const [mode, setMode] = useState<'calendar' | 'list'>('calendar'); const [viewDate, setViewDate] = useState(new Date()); const [dailyExpense, setDailyExpense] = useState>({}); const [maxExpense, setMaxExpense] = useState(0); const [selectedDate, setSelectedDate] = useState(getToday()); const [dayRecords, setDayRecords] = useState([]); // 流水模式 const [monthRecords, setMonthRecords] = useState([]); const [filterType, setFilterType] = useState('all'); const [searchText, setSearchText] = useState(''); const [members, setMembers] = useState([]); // 家庭成员缓存(v0.5 记账人标识) const [memberFilter, setMemberFilter] = useState(0); // 0=全部,>0 按记账人筛选(v0.9.1) const [editing, setEditing] = useState(null); // 点击编辑的记录(v0.10) const { showToast } = useToast(); const calendarScrollRef = useRef(null); const listScrollRef = useRef>(null); const { start, end } = useMemo(() => getMonthRange(viewDate), [viewDate]); // 加载竞态守卫:挂载 / 切 Tab / 记一笔 / 导入 会并发触发多轮查询, // 慢的那轮后回来就会把新数据盖成旧的(首页与统计页都有这个守卫,本页此前缺)。 // 月份与单日各自计数,互不误伤。 const mountedRef = useRef(true); const monthSeq = useRef(0); const daySeq = useRef(0); useEffect( () => () => { mountedRef.current = false; }, [], ); const loadMonth = useCallback(async () => { const seq = ++monthSeq.current; try { const [days, max, records] = await Promise.all([ getDaySummaries(start, end), getMaxDailyExpense(start, end), getRecordsByRange(start, end), ]); if (!mountedRef.current || seq !== monthSeq.current) return; const map: Record = {}; for (const d of days) map[d.date] = d.expense; setDailyExpense(map); setMaxExpense(max); setMonthRecords(records); } catch { if (mountedRef.current && seq === monthSeq.current) showToast('明细数据加载失败', 'error'); } }, [start, end, showToast]); const loadDay = useCallback(async (date: string) => { const seq = ++daySeq.current; try { const records = await getRecordsByDate(date); if (!mountedRef.current || seq !== daySeq.current) return; setDayRecords(records); } catch { // 单日加载失败保持现状 } }, []); useEffect(() => { loadMonth(); }, [loadMonth]); useEffect(() => { loadDay(selectedDate); }, [selectedDate, loadDay]); useEffect(() => { getCachedMembers().then(setMembers); // 记账人标识(v0.5) }, []); // v0.11.21:成员被移出家庭/退出登录后,残留的筛选 id 会让列表与合计一直查空, // 而成员≤1 时筛选胶囊整行隐藏、无处复位 → 随成员列表变化自动回到「全部」 useEffect(() => { setMemberFilter((cur) => (cur > 0 && !members.some((m) => m.id === cur) ? 0 : cur)); }, [members]); // Tab 激活时滚回顶部(按当前模式滚动对应列表,v0.5.4) useEffect(() => { if (!active) { // 切走本页时收起编辑页,避免回到明细时停留在半开编辑态(v0.11.5) setEditing(null); return; } if (mode === 'calendar') calendarScrollRef.current?.scrollTo({ y: 0, animated: false }); else listScrollRef.current?.scrollToOffset({ offset: 0, animated: false }); }, [active, mode]); // Tab 激活时重载数据(激活刷新保证数据即时,v0.5.6) useEffect(() => { if (!active) return; loadMonth(); loadDay(selectedDate); // eslint-disable-next-line react-hooks/exhaustive-deps }, [active]); // 全局刷新 useEffect(() => { const subs = [ DeviceEventEmitter.addListener(LEDGER_EVENTS.RECORDED, () => { loadMonth(); loadDay(selectedDate); }), DeviceEventEmitter.addListener(LEDGER_EVENTS.DATA_IMPORTED, () => { loadMonth(); loadDay(selectedDate); }), // 登录态/同步完成 → 刷新成员缓存(v0.5) DeviceEventEmitter.addListener(LEDGER_EVENTS.AUTH_CHANGED, () => { getCachedMembers().then(setMembers); }), DeviceEventEmitter.addListener(LEDGER_EVENTS.SYNC_DONE, () => { getCachedMembers().then(setMembers); }), ]; return () => subs.forEach((s) => s.remove()); }, [loadMonth, loadDay, selectedDate]); // 切月:选中日同步到目标月同日(超出月末则 clamp,与系统日历一致) // 不在 setViewDate 的 updater 里再调 setSelectedDate —— updater 必须纯(StrictMode 会双调用) const changeMonth = useCallback( (delta: number) => { const next = addMonths(viewDate, delta); const [y, m, d] = selectedDate.split('-').map(Number); // 目标月与当前选中日同月才需要同步(跨月选中日始终在 viewDate 月内) const sameMonth = y === viewDate.getFullYear() && m === viewDate.getMonth() + 1; setViewDate(next); if (sameMonth) { const day = Math.min(d, getDaysInMonth(next.getFullYear(), next.getMonth() + 1)); setSelectedDate( `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`, ); } }, [viewDate, selectedDate], ); const handleDelete = useCallback( (record: LedgerRecord) => { confirmDeleteRecord(record.id, (msg, isError) => showToast(msg, isError ? 'error' : 'success')); }, [showToast], ); // 流水筛选(records 按记账人 / 类型 / 关键词) const filteredRecords = useMemo(() => { let list = monthRecords; if (memberFilter !== 0) list = list.filter((r) => r.userId === memberFilter); if (filterType !== 'all') { list = list.filter((r) => r.type === filterType); } if (searchText.trim()) { const kw = searchText.trim().toLowerCase(); list = list.filter((r) => { const cat = findCategory(r.category, r.type); return ( r.note.toLowerCase().includes(kw) || cat.label.toLowerCase().includes(kw) || cat.key.toLowerCase().includes(kw) ); }); } return list; }, [monthRecords, memberFilter, filterType, searchText]); // 日历模式:当日记录按记账人筛选 + 当日收支小计(与筛选一致) const visibleDayRecords = useMemo( () => (memberFilter === 0 ? dayRecords : dayRecords.filter((r) => r.userId === memberFilter)), [dayRecords, memberFilter], ); const dayTotals = useMemo(() => { let exp = 0; let inc = 0; for (const r of visibleDayRecords) { if (r.type === 'expense' && !r.reimbursable) exp += r.amount; else if (r.type === 'income') inc += r.amount; } return { exp, inc }; }, [visibleDayRecords]); // 拍平为虚拟化列表数据(日期头 + 记录行按时间降序分组) const flowItems = useMemo(() => { type Mixed = { date: string; timestamp: number; item: FlowItem }; const mixed: Mixed[] = []; for (const r of filteredRecords) mixed.push({ date: r.date, timestamp: r.timestamp, item: { kind: 'record', record: r } }); mixed.sort((a, b) => (a.date === b.date ? b.timestamp - a.timestamp : b.date < a.date ? -1 : 1)); const items: FlowItem[] = []; let curDate = ''; let count = 0; const flush = () => { if (curDate) items.push({ kind: 'header', date: curDate, count }); }; for (const m of mixed) { if (m.date !== curDate) { flush(); curDate = m.date; count = 1; } else { count += 1; } items.push(m.item); } flush(); return items; }, [filteredRecords]); // 合计直接走 filteredRecords,与下面列出的行同口径。 // 此前只跟记账人筛选,切到「只看支出」或搜索关键词后合计仍按全月算, // 数字和列表互相矛盾(且把「支出排除待报销」这条规则在 UI 层又抄了一遍)。 const monthTotal = useMemo(() => { let exp = 0; let inc = 0; for (const r of filteredRecords) { if (r.type === 'expense' && !r.reimbursable) exp += r.amount; else if (r.type === 'income') inc += r.amount; } return { exp, inc }; }, [filteredRecords]); const isCurrentMonth = useMemo(() => { const now = new Date(); return viewDate.getFullYear() === now.getFullYear() && viewDate.getMonth() === now.getMonth(); }, [viewDate]); const selectedLabel = useMemo(() => { const d = parseDate(selectedDate); const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]; return `${selectedDate} ${week}`; }, [selectedDate]); const renderFlowItem = useCallback( ({ item }: { item: FlowItem }) => { if (item.kind === 'header') { return ( {item.date} {item.count} 笔 ); } return ( ); }, [handleDelete, members], ); // 成员筛选(家庭多成员账本才显示;0=全部) const renderMemberFilter = () => { if (members.length <= 1) return null; const chips: { id: number; label: string }[] = [ { id: 0, label: '👨‍👩‍👧 全部' }, ...members.map((m) => ({ id: m.id, label: `${m.avatarEmoji} ${m.displayName}` })), ]; return ( {chips.map((c) => ( { setMemberFilter(c.id); hapticLight(); }} accessibilityRole="button" accessibilityLabel={c.id === 0 ? '全部成员' : `只看 ${c.label}`} accessibilityState={{ selected: memberFilter === c.id }} > {c.label} ))} ); }; return ( {/* 列表页与编辑页互斥显示(display 切换,同 App 切 Tab;编辑页为普通页面流,触摸/安全区与首页一致) */} {mode === 'calendar' ? ( 明细 {(['calendar', 'list'] as const).map((m) => ( setMode(m)} accessibilityRole="tab" accessibilityLabel={m === 'calendar' ? '日历模式' : '流水模式'} accessibilityState={{ selected: mode === m }} > {m === 'calendar' ? '日历' : '流水'} ))} {/* 月份切换 + 热力图 */} changeMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月" > ‹ { if (!isCurrentMonth) { setViewDate(new Date()); setSelectedDate(getToday()); } }} accessibilityRole="button" accessibilityLabel="回到本月" > {getMonthName(viewDate)} changeMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月" > › {renderMemberFilter()} {/* 选中日期明细 */} {selectedLabel} {dayTotals.exp > 0 ? ( 支出{' '} ¥{formatMoney(dayTotals.exp)} ) : null} {dayTotals.inc > 0 ? ( 收入{' '} ¥{formatMoney(dayTotals.inc)} ) : null} ) : ( /* 流水模式:FlatList 虚拟化(长月数据不卡顿) */ (item.kind === 'header' ? `h-${item.date}` : `r-${item.record.id}`)} renderItem={renderFlowItem} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" ListHeaderComponent={ 明细 {(['calendar', 'list'] as const).map((m) => ( setMode(m)} accessibilityRole="tab" accessibilityLabel={m === 'calendar' ? '日历模式' : '流水模式'} accessibilityState={{ selected: mode === m }} > {m === 'calendar' ? '日历' : '流水'} ))} {renderMemberFilter()} {/* 月份切换(流水模式与日历模式共享 viewDate,v0.5.1) */} changeMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月" > ‹ { if (!isCurrentMonth) { setViewDate(new Date()); setSelectedDate(getToday()); } }} accessibilityRole="button" accessibilityLabel="回到本月" > {getMonthName(viewDate)} changeMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月" > › {/* 流水筛选 */} {( [ ['all', '全部'], ['expense', '支出'], ['income', '收入'], ] as [FilterType, string][] ).map(([k, label]) => ( setFilterType(k)} accessibilityRole="button" accessibilityLabel={`筛选${label}`} accessibilityState={{ selected: filterType === k }} > {label} ))} {searchText.length > 0 ? ( setSearchText('')} hitSlop={8} accessibilityRole="button" accessibilityLabel="清除搜索" > ✕ ) : null} 收{' '} {formatMoney(monthTotal.inc)} 支{' '} {formatMoney(monthTotal.exp)} {flowItems.length === 0 ? : null} } /> )} {/* 点击记录编辑(v0.10):保存后 RECORDED 事件刷新列表并自动同步 */} setEditing(null)} /> ); } const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: COLORS.background, }, page: { flex: 1, }, pageHidden: { flex: 1, display: 'none', }, scroll: { flex: 1, }, content: { padding: SPACING.lg, paddingBottom: SPACING.xxl, }, titleRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.md, }, pageTitle: { fontSize: FONT_SIZE.xxl, fontWeight: '800', color: COLORS.text, }, modeSwitch: { flexDirection: 'row', backgroundColor: COLORS.bgAlt, borderRadius: RADIUS.pill, padding: 3, }, modeBtn: { paddingHorizontal: SPACING.md, paddingVertical: 6, borderRadius: RADIUS.pill, }, modeBtnActive: { backgroundColor: COLORS.surface, }, modeText: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, modeTextActive: { color: COLORS.accentDark, fontWeight: '700', }, card: { backgroundColor: COLORS.surface, borderRadius: RADIUS.lg, borderWidth: 1, borderColor: COLORS.border, padding: SPACING.md, }, monthRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.sm + 2, }, monthBtn: { width: 34, height: 34, borderRadius: RADIUS.pill, backgroundColor: COLORS.bgAlt, alignItems: 'center', justifyContent: 'center', }, monthBtnText: { fontSize: FONT_SIZE.xl, color: COLORS.textSecondary, lineHeight: 24, }, monthTitle: { fontSize: FONT_SIZE.lg, fontWeight: '700', color: COLORS.text, }, dayHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginTop: SPACING.lg, marginBottom: SPACING.sm, }, dayTitle: { fontSize: FONT_SIZE.lg, fontWeight: '700', color: COLORS.text, }, daySummary: { flexDirection: 'row', gap: SPACING.md, }, daySummaryText: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, }, filterRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.sm, }, chips: { flexDirection: 'row', gap: SPACING.sm, }, filterChip: { paddingHorizontal: SPACING.md, paddingVertical: 6, borderRadius: RADIUS.pill, backgroundColor: COLORS.surface, borderWidth: 1, borderColor: COLORS.border, }, filterChipActive: { backgroundColor: COLORS.accent, borderColor: COLORS.accent, }, filterChipText: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, filterChipTextActive: { color: COLORS.white, fontWeight: '700', }, memberScroll: { flexGrow: 0, marginBottom: SPACING.sm, }, memberChipsRow: { gap: SPACING.sm, paddingVertical: 2, }, memberChip: { paddingHorizontal: SPACING.md, paddingVertical: 6, borderRadius: RADIUS.pill, backgroundColor: COLORS.surface, borderWidth: 1, borderColor: COLORS.border, }, memberChipActive: { backgroundColor: COLORS.accent, borderColor: COLORS.accent, }, memberChipText: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, memberChipTextActive: { color: COLORS.white, fontWeight: '700', }, searchRow: { flexDirection: 'row', gap: SPACING.sm, marginBottom: SPACING.md, }, searchWrap: { flex: 1, position: 'relative', justifyContent: 'center', }, searchInput: { backgroundColor: COLORS.surface, borderRadius: RADIUS.md, borderWidth: 1, borderColor: COLORS.border, paddingHorizontal: SPACING.md, paddingRight: 36, paddingVertical: 9, fontSize: FONT_SIZE.md, color: COLORS.text, }, searchClear: { position: 'absolute', right: 8, width: 22, height: 22, borderRadius: RADIUS.pill, backgroundColor: COLORS.bgAlt, alignItems: 'center', justifyContent: 'center', }, searchClearText: { fontSize: FONT_SIZE.xs - 1, color: COLORS.textTertiary, fontWeight: '600', }, monthTotal: { flexDirection: 'row', gap: SPACING.sm, alignItems: 'center', backgroundColor: COLORS.surface, borderRadius: RADIUS.md, borderWidth: 1, borderColor: COLORS.border, paddingHorizontal: SPACING.sm + 2, flexShrink: 1, }, monthTotalText: { fontSize: FONT_SIZE.xs, color: COLORS.textSecondary, }, monthBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.sm, }, groupHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginTop: SPACING.sm, marginBottom: SPACING.xs + 2, }, flowRecordWrap: { marginBottom: SPACING.sm, }, groupDate: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, groupCount: { fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, }, });