import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState, BackHandler, DeviceEventEmitter, Pressable, ScrollView, StyleSheet, Text, 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, SETTING_KEYS } from '../constants'; import { getCategorySummary, getRangeSummary, getRecordsByCategory, getDaySummaries, getSetting, getMemberExpenseSummary, getReimbursableSummary, } from '../database/ledgerDB'; import { formatDate, formatMoney, getDaysInMonth, getLastNDates, getMonthName, getMonthRange, getToday, } from '../utils/dateUtils'; import { hapticLight } from '../utils/haptics'; import { useToast } from '../hooks/useToast'; import { getCachedMembers, memberColor, type MemberInfo } from '../sync/memberUtils'; import CategoryPieChart from '../components/CategoryPieChart'; import TrendBarChart from '../components/TrendBarChart'; import MonthSwitcher from '../components/MonthSwitcher'; import RecordList from '../components/RecordList'; import EditRecordModal from '../components/EditRecordModal'; import ReimburseScreen from './manage/ReimburseScreen'; import type { LedgerRecord } from '../types'; type RangeKey = 'week' | 'month' | 'year'; type Page = 'main' | 'reimburse' | 'category'; interface Props { active: boolean; // 当前 Tab 激活(App 常驻挂载,激活时滚回顶部) } export default function StatsScreen({ active }: Props) { const [page, setPage] = useState('main'); const [range, setRange] = useState('month'); // 统计月份:0=本月,负数=往前翻(v0.11.25 支持查看历史月份) const [monthOffset, setMonthOffset] = useState(0); // 月份切换后的查询状态:查询完成前给内容一个轻量反馈 const [statsLoading, setStatsLoading] = useState(false); const [expense, setExpense] = useState(0); const [income, setIncome] = useState(0); const [categoryData, setCategoryData] = useState<{ category: string; total: number }[]>([]); const [trendValues, setTrendValues] = useState([]); const [trendLabels, setTrendLabels] = useState([]); const [budget, setBudget] = useState(0); const [tick, setTick] = useState(0); const [incomeShown, setIncomeShown] = useState(false); // 收入默认脱敏,点一下才显示真数 const [members, setMembers] = useState([]); // 家庭成员缓存(v0.5) const [memberFilter, setMemberFilter] = useState(0); // 0=全部成员 const [memberStats, setMemberStats] = useState<{ userId: number; total: number; count: number }[]>([]); const [reimburseSummary, setReimburseSummary] = useState({ total: 0, count: 0 }); // 分类下钻(点「支出分类排行」某一行 → 看这个分类到底是些什么内容) const [drill, setDrill] = useState<{ key: string; label: string } | null>(null); const [drillRecords, setDrillRecords] = useState([]); const [drillLoading, setDrillLoading] = useState(false); // 明细里点某一条 → 就地编辑(复用明细页同一个 EditRecordModal 组件,不另造编辑器: // v0.11.4/0.11.5 两轮才把触摸与键盘避让修对,组件要求宿主用「并列页 + display 互斥」承载) const [drillEdit, setDrillEdit] = useState(null); const { showToast } = useToast(); const scrollRef = useRef(null); // 报销摘要加载(统计页入口展示用) const loadReimburseSummary = useCallback(async () => { try { setReimburseSummary(await getReimbursableSummary()); } catch { showToast('报销摘要加载失败', 'error'); } }, [showToast]); // 数据变更事件同时影响主图和报销摘要,合成一个回调(此前两组监听器分别注册, // RECORDED/DATA_IMPORTED/SYNC_DONE 每个事件都跑两遍) const refreshAll = useCallback(() => { setTick((t) => t + 1); loadReimburseSummary(); }, [loadReimburseSummary]); // Tab 激活时滚回顶部 + 重载数据 + 回主页(切 Tab 再回来回到 main,v0.5.6) // 激活重载已经覆盖摘要,不再单独挂一个「挂载预载」effect(此前两者重复跑一次查询) useEffect(() => { if (!active) { // 切走时收起编辑页(与明细页同处理),回到统计页不该还停在半开编辑态 setDrillEdit(null); return; } setPage('main'); setDrillEdit(null); // 切走再回来不该还停在半开的编辑页(与明细页一致) setIncomeShown(false); // 离开再回来要重新遮上,否则脱敏形同虚设 // 主页现在是常驻页(此前是重新挂载才天然在顶部),刚由 display:none 切回可见时 // 同帧 scrollTo 可能被忽略,故放到下一帧再回顶——切 Tab 回顶这个行为要保留 requestAnimationFrame(() => scrollRef.current?.scrollTo({ y: 0, animated: false })); refreshAll(); }, [active, refreshAll]); // 收入脱敏的第二道:停在统计页没换 Tab,但切去后台/下拉通知栏/进多任务时也要重新遮上。 // 用 !== 'active'(而不是只判 'background'):'inactive' 正是系统截取多任务预览的时刻, // 这时候遮上才有机会让缩略图里也是 ****。 useEffect(() => { const sub = AppState.addEventListener('change', (status) => { if (status !== 'active') setIncomeShown(false); }); return () => sub.remove(); }, []); // 成员缓存加载(登录态/同步完成事件触发) const loadMembers = useCallback(async () => { setMembers(await getCachedMembers()); }, []); useEffect(() => { loadMembers(); }, [loadMembers]); // v0.11.21:成员被移出家庭/退出登录后复位筛选(同明细页),避免停在查无此人的空统计 useEffect(() => { setMemberFilter((cur) => (cur > 0 && !members.some((m) => m.id === cur) ? 0 : cur)); }, [members]); // 月份锚点:0=本月,-1=上月…;tick 让跨月/回页时跟着当前日期重新计算 const monthAnchor = useMemo(() => { const now = new Date(); return new Date(now.getFullYear(), now.getMonth() + monthOffset, 1); // 表达式里没直接引用 tick,它只用于「回到本页/数据变更时重算当前月」 // eslint-disable-next-line react-hooks/exhaustive-deps }, [monthOffset, tick]); const isCurrentMonth = monthOffset === 0; // 当前范围 const { rangeLabel, start, end, trendDates } = useMemo(() => { if (range === 'week') { const dates = getLastNDates(7); return { rangeLabel: '近 7 天', start: dates[0], end: getToday(), trendDates: dates }; } if (range === 'month') { const mr = getMonthRange(monthAnchor); // 当前月只画到今天(与「本月」口径一致);历史月份按整月展示 const lastDay = isCurrentMonth ? new Date().getDate() : getDaysInMonth(monthAnchor.getFullYear(), monthAnchor.getMonth() + 1); const dates: string[] = []; for (let day = 1; day <= lastDay; day++) { dates.push(formatDate(new Date(monthAnchor.getFullYear(), monthAnchor.getMonth(), day))); } return { rangeLabel: isCurrentMonth ? '本月' : getMonthName(monthAnchor), start: mr.start, end: mr.end, trendDates: dates, }; } // 近 12 个月(v0.11 修复:start 取回溯窗口的首月月初,跨年时去年月份不再恒为 0) const now = new Date(); const today = getToday(); const dates: string[] = []; for (let i = 11; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); dates.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`); } return { rangeLabel: '近 12 个月', start: dates[0], end: today, trendDates: dates }; // tick 进依赖:4 个 tab 常驻挂载,跨零点后切回本页要重算窗口, // 否则「本月/近 7 天」还停在昨天(数据会按旧 end 查)。 // 表达式里没直接引用 tick(它只通过 new Date() 生效),所以要显式关掉这条规则 // eslint-disable-next-line react-hooks/exhaustive-deps }, [range, tick, monthAnchor, isCurrentMonth]); // 加载数据(memberFilter > 0 时按记账人筛选,v0.5) useEffect(() => { let cancelled = false; setStatsLoading(true); (async () => { try { const [summary, cats, days, budgetStr, mStats] = await Promise.all([ getRangeSummary(start, end, memberFilter), getCategorySummary(start, end, 'expense', memberFilter), getDaySummaries(start, end, memberFilter), getSetting(SETTING_KEYS.MONTHLY_BUDGET), getMemberExpenseSummary(start, end), ]); if (cancelled) return; setExpense(summary.expense); setIncome(summary.income); setCategoryData(cats); setBudget(parseFloat(budgetStr ?? '0') || 0); setMemberStats(mStats); // 趋势 if (range === 'year') { // 按月聚合 const monthMap = new Map(); for (const d of days) { monthMap.set(d.date.slice(0, 7), (monthMap.get(d.date.slice(0, 7)) ?? 0) + d.expense); } setTrendValues(trendDates.map((d) => monthMap.get(d.slice(0, 7)) ?? 0)); setTrendLabels(trendDates.map((d) => `${Number(d.slice(5, 7))}月`)); } else { const values = trendDates.map((d) => days.find((x) => x.date === d)?.expense ?? 0); setTrendValues(values); setTrendLabels( trendDates.map((d) => { const dt = new Date(Number(d.slice(0, 4)), Number(d.slice(5, 7)) - 1, Number(d.slice(8, 10))); return range === 'week' ? (['日', '一', '二', '三', '四', '五', '六'][dt.getDay()] ?? '') : `${dt.getDate()}`; // 本月视图只显示「日」,图内自动抽样不拥挤 }), ); } } catch { if (!cancelled) showToast('统计数据加载失败', 'error'); } finally { if (!cancelled) setStatsLoading(false); } })(); return () => { cancelled = true; }; }, [start, end, range, trendDates, tick, memberFilter, showToast]); // 分类下钻明细:时间区间 / 成员筛选 / 数据变更都要跟着重查, // 离开子页或连续点不同分类时丢弃过期结果(与本页主查询同样的 cancelled 模式) useEffect(() => { if (page !== 'category' || !drill) return; let cancelled = false; setDrillLoading(true); (async () => { try { const rows = await getRecordsByCategory(start, end, drill.key, memberFilter); if (!cancelled) setDrillRecords(rows); } catch { if (!cancelled) { setDrillRecords([]); showToast('分类明细加载失败', 'error'); } } finally { if (!cancelled) setDrillLoading(false); } })(); return () => { cancelled = true; }; }, [page, drill, start, end, memberFilter, tick, showToast]); const openCategory = useCallback((c: { category: string; label: string }) => { setDrill({ key: c.category, label: c.label }); setDrillEdit(null); setPage('category'); }, []); const backToMain = useCallback(() => { setPage('main'); setDrill(null); setDrillEdit(null); }, []); // 全局刷新(含登录态/同步事件 → 更新成员缓存,v0.5) useEffect(() => { const subs = [ DeviceEventEmitter.addListener(LEDGER_EVENTS.RECORDED, refreshAll), DeviceEventEmitter.addListener(LEDGER_EVENTS.DATA_IMPORTED, refreshAll), DeviceEventEmitter.addListener(LEDGER_EVENTS.AUTH_CHANGED, loadMembers), DeviceEventEmitter.addListener(LEDGER_EVENTS.SYNC_DONE, () => { loadMembers(); refreshAll(); }), // 设置变更(月度预算)→ 即时刷新预算卡(v0.5.5) DeviceEventEmitter.addListener(LEDGER_EVENTS.SETTINGS_CHANGED, refreshAll), ]; return () => subs.forEach((s) => s.remove()); }, [refreshAll, loadMembers]); // Android 系统返回键:在报销 / 分类明细子页时返回主页(主页时不消费,走默认) // v0.11 修复:仅激活 tab 注册,避免与管理页同时消费返回键(两页常驻挂载) useEffect(() => { if (!active) return; const sub = BackHandler.addEventListener('hardwareBackPress', () => { if (page !== 'main') { backToMain(); return true; } return false; }); return () => sub.remove(); }, [page, active, backToMain]); const budgetPercent = budget > 0 ? Math.min(expense / budget, 1) : 0; const budgetOver = budget > 0 && expense > budget; // 分类排行 Top5 const topCategories = useMemo(() => { return categoryData.slice(0, 5).map((c) => ({ ...c, def: findCategory(c.category, 'expense'), })); }, [categoryData]); // 排行条相对最大值归一化(第 1 名满格,其余按比例,避免占比>33% 全部顶满的误导) const maxCategoryTotal = topCategories.length > 0 ? topCategories[0].total : 0; // 分类下钻:合计直接由明细累加,与 getCategorySummary 同口径,所以必然等于排行条上的数字 const drillTotal = useMemo(() => drillRecords.reduce((s, r) => s + r.amount, 0), [drillRecords]); const drillScopeText = useMemo(() => { const who = memberFilter > 0 ? (members.find((m) => m.id === memberFilter)?.displayName ?? `成员${memberFilter}`) : ''; return [rangeLabel, who].filter(Boolean).join(' · '); }, [rangeLabel, memberFilter, members]); // 空数组也要走占位:此前 length > 0 的条件让「一条记录都还没有」时画出一张空白图 const trendEmpty = trendValues.length === 0 || trendValues.every((v) => v <= 0); // 成员支出排行(多成员且未筛选时显示,v0.5;v0.10 增加笔数) const multiMember = members.length > 1; const memberRows = useMemo(() => { if (!multiMember) return []; const allExpense = memberStats.reduce((s, m) => s + m.total, 0); const maxTotal = memberStats.length > 0 ? Math.max(...memberStats.map((m) => m.total)) : 0; return memberStats .filter((m) => m.total > 0) .map((m) => { const info = members.find((x) => x.id === m.userId); return { userId: m.userId, name: info?.displayName ?? (m.userId === 0 ? '未标记' : `成员${m.userId}`), emoji: info?.avatarEmoji ?? '👤', total: m.total, count: m.count, pct: allExpense > 0 ? (m.total / allExpense) * 100 : 0, barPct: maxTotal > 0 ? (m.total / maxTotal) * 100 : 0, }; }); }, [multiMember, memberStats, members]); const reimburseStatusText = reimburseSummary.count > 0 ? `¥${formatMoney(reimburseSummary.total)} · ${reimburseSummary.count} 笔待核销` : '暂无待核销'; return ( {/* 三个页面常驻、display 互斥切换(同 App 切 Tab 与明细页编辑态的做法)。 此前这里是三元条件只渲染一支:从分类明细/报销页返回时,统计主页被卸载重建, ScrollView 归零 → 刚看的排行位置丢了。同一页内的上下钻不该重置滚动, 只有切 Tab 回顶(见上面 active effect 的 scrollTo)。 */} 统计 {( [ ['week', '近7天'], ['month', '月度'], ['year', '年度'], ] as [RangeKey, string][] ).map(([r, label]) => ( setRange(r)} accessibilityRole="tab" accessibilityLabel={label} accessibilityState={{ selected: range === r }} > {label} ))} {/* 月份切换:只在「月度」视图显示;不能翻到未来月份 */} {range === 'month' ? ( { setMonthOffset((v) => v - 1); hapticLight(); }} onNext={() => { if (monthOffset >= 0) return; setMonthOffset((v) => Math.min(v + 1, 0)); hapticLight(); }} nextDisabled={isCurrentMonth} /> {/* 右侧动作绝对定位:出现/消失都不影响中间月份胶囊保持居中 */} {statsLoading ? ( 加载中… ) : isCurrentMonth ? null : ( { setMonthOffset(0); hapticLight(); }} hitSlop={6} accessibilityRole="button" accessibilityLabel="回到本月" > 本月 )} ) : null} {/* 成员筛选(多成员账本显示,v0.5) */} {multiMember ? ( setMemberFilter(0)} accessibilityRole="tab" accessibilityLabel="全部成员" accessibilityState={{ selected: memberFilter === 0 }} > 👨‍👩‍👧 全部 {members.map((m) => ( setMemberFilter(m.id)} accessibilityRole="tab" accessibilityLabel={`只看${m.displayName}`} accessibilityState={{ selected: memberFilter === m.id }} > {m.avatarEmoji} {m.displayName} ))} ) : null} {/* 总览卡片(金额自适应字号,大金额不换行溢出) */} 收支总览 · {rangeLabel} 支出 ¥{formatMoney(expense)} {/* 收入默认脱敏(v0.11.19):公共场合打开统计页不暴露工资数字, 点一下看真数、再点收回;切走再回统计页会重新遮上(见 active 副作用) */} { setIncomeShown((v) => !v); hapticLight(); }} accessibilityRole="button" accessibilityLabel={ incomeShown ? `收入 ¥${formatMoney(income)},点击隐藏` : '收入已脱敏,点击显示实际金额' } > 收入 {incomeShown ? '🔓' : '🔒'} {incomeShown ? `¥${formatMoney(income)}` : '¥ ****'} {/* 预算对比(预算只存了「当前月」一个值,历史月份对比会误导,故仅本月显示) */} {budget > 0 && range === 'month' && isCurrentMonth ? ( 预算对比 · 本月 {budgetOver ? '已超支' : `${Math.round(budgetPercent * 100)}%`} 已用 ¥{formatMoney(expense)} / 预算 ¥{formatMoney(budget)} · 剩余 ¥ {formatMoney(Math.max(budget - expense, 0))} ) : null} {/* 报销入口(紧跟预算对比之后,v0.5.9) */} setPage('reimburse')} accessibilityRole="button" accessibilityLabel={`待报销,${reimburseStatusText}`} > 🧾 待报销 {reimburseStatusText} › {/* 支出占比 */} 支出占比 · {rangeLabel} {categoryData.length > 0 ? ( ) : ( 这个时间段还没有支出记录 )} {/* 分类排行(点任意一行 → 该分类在这个时间段的具体明细) */} {topCategories.length > 0 ? ( <> 支出分类排行 · 点击查看明细 {topCategories.map((c, i) => { const pct = expense > 0 ? (c.total / expense) * 100 : 0; const barPct = maxCategoryTotal > 0 ? (c.total / maxCategoryTotal) * 100 : 0; return ( [styles.rankRow, pressed && styles.rankRowPressed]} android_ripple={{ color: `${COLORS.accent}22` }} onPress={() => openCategory({ category: c.category, label: c.def.label })} accessibilityRole="button" accessibilityLabel={`查看${c.def.label}明细,${formatMoney(c.total)}元,${pct.toFixed(0)}%`} > {i + 1} {c.def.emoji} {c.def.label} ¥{formatMoney(c.total)} · {pct.toFixed(1)}% › ); })} ) : null} {/* 成员支出排行(多成员且未筛选时显示,v0.5) */} {multiMember && memberFilter === 0 && memberRows.length > 0 ? ( <> 成员支出排行 · {rangeLabel} {memberRows.map((m) => ( setMemberFilter(m.userId)} accessibilityRole="button" accessibilityLabel={`查看${m.name}的支出,共${formatMoney(m.total)}元`} > {m.emoji} {m.name} ¥{formatMoney(m.total)} · {m.pct.toFixed(1)}% · {m.count}笔 ))} ) : null} {/* 支出趋势(时间范围与支出占比统一用简化标签) */} 支出趋势 · {rangeLabel} {trendEmpty ? ( 这个时间段还没有支出记录 ) : ( )} ‹ 返回 报销管理 {/* 分类明细下钻页:区间/成员筛选与排行完全同口径,所以顶部合计必然等于排行条上的数字 */} {/* 明细列表与编辑页互斥显示(display 切换,与明细页同构): EditRecordModal 明确要求宿主这样承载,不能用绝对定位叠层或 RNModal ——那两轮修复(触摸失灵 / 键盘遮挡)就是从这里来的 */} ‹ 返回 {drill ? `${drill.label}明细` : '分类明细'} {drillScopeText} · 不含待报销 · 点击条目可编辑 ¥{formatMoney(drillTotal)} {drillRecords.length} 笔 {drillLoading && drillRecords.length === 0 ? ( 正在加载明细… ) : ( setDrillEdit(r)} emptyText="这个时间段该分类没有符合条件的支出" /> )} {/* 保存后组件内部广播 RECORDED → 本页 refreshAll 递增 tick → 下钻查询自动重跑, 列表就地更新(分类被改掉的那条会移出本列表),用户仍停在这个分类的明细上 */} setDrillEdit(null)} /> ); } const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: COLORS.background, }, 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, }, rangeSwitch: { flexDirection: 'row', backgroundColor: COLORS.bgAlt, borderRadius: RADIUS.pill, padding: 3, }, rangeBtn: { paddingHorizontal: SPACING.sm + 2, paddingVertical: 6, borderRadius: RADIUS.pill, }, rangeBtnActive: { backgroundColor: COLORS.surface, }, rangeText: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, rangeTextActive: { color: COLORS.accentDark, fontWeight: '700', }, titleRowCompact: { marginBottom: SPACING.sm, }, // ===== 月份切换(v0.11.25,箭头样式与日期面板共用 MonthSwitcher)===== monthBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginBottom: SPACING.md, // 右侧动作绝对定位,保证中间月份胶囊始终居中、不随按钮出现而左右移动 position: 'relative', }, monthAction: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center', }, monthTodayBtn: { paddingHorizontal: SPACING.xs, paddingVertical: SPACING.xs, borderRadius: RADIUS.pill, }, monthTodayText: { fontSize: FONT_SIZE.sm, color: COLORS.accentDark, fontWeight: '700', }, loadingHint: { fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, }, overview: { flexDirection: 'row', alignItems: 'center', backgroundColor: COLORS.surface, borderRadius: RADIUS.lg, borderWidth: 1, borderColor: COLORS.border, paddingVertical: SPACING.md, paddingHorizontal: SPACING.xs, marginBottom: SPACING.md, }, overviewLoading: { opacity: 0.55, }, overviewItem: { flex: 1, alignItems: 'center', paddingHorizontal: 2, }, overviewLabel: { fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, marginBottom: 3, }, overviewLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 3, }, overviewLock: { fontSize: FONT_SIZE.xs - 2, }, overviewValue: { fontSize: FONT_SIZE.md, fontWeight: '800', }, overviewDivider: { width: 1, height: 28, backgroundColor: COLORS.border, }, sectionTitle: { fontSize: FONT_SIZE.lg, fontWeight: '700', color: COLORS.text, marginBottom: SPACING.sm, marginTop: SPACING.xs, }, card: { backgroundColor: COLORS.surface, borderRadius: RADIUS.lg, borderWidth: 1, borderColor: COLORS.border, padding: SPACING.md, marginBottom: SPACING.md, }, cardTitle: { fontSize: FONT_SIZE.md, fontWeight: '700', color: COLORS.text, }, budgetHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.sm, }, budgetPct: { fontSize: FONT_SIZE.md, color: COLORS.accentDark, fontWeight: '800', }, budgetTrack: { height: 8, borderRadius: 4, backgroundColor: COLORS.bgAlt, overflow: 'hidden', }, budgetFill: { height: '100%', borderRadius: 4, }, budgetHint: { fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, marginTop: SPACING.sm, }, empty: { paddingVertical: SPACING.xl, alignItems: 'center', }, emptyText: { color: COLORS.textTertiary, fontSize: FONT_SIZE.sm, }, rankRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, paddingVertical: SPACING.xs + 2, }, rankRowPressed: { opacity: 0.6, }, // 分类明细下钻页 drillScope: { fontSize: FONT_SIZE.sm, color: COLORS.textTertiary, marginBottom: SPACING.sm, }, drillSummary: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', backgroundColor: COLORS.surface, borderRadius: RADIUS.md, borderWidth: 1, borderColor: COLORS.border, paddingHorizontal: SPACING.md, paddingVertical: SPACING.md, marginBottom: SPACING.md, }, drillSummaryAmount: { fontSize: FONT_SIZE.xxl, fontWeight: '800', color: COLORS.expense, }, drillSummaryCount: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, }, rankIndex: { width: 16, fontSize: FONT_SIZE.sm, color: COLORS.textTertiary, fontWeight: '700', }, rankIcon: { width: 32, height: 32, borderRadius: RADIUS.sm, alignItems: 'center', justifyContent: 'center', }, rankEmoji: { fontSize: FONT_SIZE.lg - 2, }, rankInfo: { flex: 1, gap: 4, }, rankHead: { flexDirection: 'row', justifyContent: 'space-between', }, rankLabel: { fontSize: FONT_SIZE.md, color: COLORS.text, fontWeight: '600', }, rankAmount: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, rankTrack: { height: 5, borderRadius: 2.5, backgroundColor: COLORS.bgAlt, overflow: 'hidden', }, rankFill: { height: '100%', borderRadius: 2.5, }, // ===== 成员筛选 chips(v0.5) ===== memberScroll: { flexGrow: 0, marginBottom: SPACING.sm, }, memberChips: { 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', }, // ===== 成员支出排行(v0.5) ===== memberRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, paddingVertical: SPACING.xs + 2, }, memberAvatar: { width: 32, height: 32, borderRadius: RADIUS.sm, alignItems: 'center', justifyContent: 'center', }, memberAvatarEmoji: { fontSize: FONT_SIZE.lg - 2, }, memberInfo: { flex: 1, gap: 4, }, memberHead: { flexDirection: 'row', justifyContent: 'space-between', }, memberName: { fontSize: FONT_SIZE.md, fontWeight: '700', }, memberAmount: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, fontWeight: '600', }, memberTrack: { height: 5, borderRadius: 2.5, backgroundColor: COLORS.bgAlt, overflow: 'hidden', }, memberFill: { height: '100%', borderRadius: 2.5, }, // ===== 报销入口卡片(v0.5.9) ===== reimburseRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, }, reimburseIcon: { width: 36, height: 36, borderRadius: RADIUS.sm, alignItems: 'center', justifyContent: 'center', }, reimburseEmoji: { fontSize: FONT_SIZE.lg - 1, }, reimburseInfo: { flex: 1, }, reimburseTitle: { fontSize: FONT_SIZE.md, color: COLORS.text, fontWeight: '600', }, reimburseStatus: { fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, marginTop: 1, }, reimburseArrow: { fontSize: FONT_SIZE.xl + 4, color: COLORS.textTertiary, fontWeight: '600', }, // ===== 子页面顶栏 ===== subPage: { flex: 1, }, // 常驻并列页:显示时占满,隐藏时 display:none(保留滚动位置与已挂载状态) page: { flex: 1, }, // 与明细页同款:两页互斥显示(列表页 / 编辑页),不用 absolute 叠层与 RNModal pageHidden: { flex: 1, display: 'none', }, navBar: { height: 48, flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING.md, }, navBack: { fontSize: FONT_SIZE.lg, color: COLORS.accent, fontWeight: '700', }, navTitle: { fontSize: FONT_SIZE.lg, fontWeight: '800', color: COLORS.text, marginLeft: SPACING.sm, }, });