src/screens/StatsScreen.tsx
Rama main · réplica de lectura del repositorio original.
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<Page>('main');
const [range, setRange] = useState<RangeKey>('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<number[]>([]);
const [trendLabels, setTrendLabels] = useState<string[]>([]);
const [budget, setBudget] = useState(0);
const [tick, setTick] = useState(0);
const [incomeShown, setIncomeShown] = useState(false); // 收入默认脱敏,点一下才显示真数
const [members, setMembers] = useState<MemberInfo[]>([]); // 家庭成员缓存(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<LedgerRecord[]>([]);
const [drillLoading, setDrillLoading] = useState(false);
// 明细里点某一条 → 就地编辑(复用明细页同一个 EditRecordModal 组件,不另造编辑器:
// v0.11.4/0.11.5 两轮才把触摸与键盘避让修对,组件要求宿主用「并列页 + display 互斥」承载)
const [drillEdit, setDrillEdit] = useState<LedgerRecord | null>(null);
const { showToast } = useToast();
const scrollRef = useRef<ScrollView>(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<string, number>();
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 (
<SafeAreaView style={styles.safe} edges={['top']}>
<StatusBar style="dark" />
{/* 三个页面常驻、display 互斥切换(同 App 切 Tab 与明细页编辑态的做法)。
此前这里是三元条件只渲染一支:从分类明细/报销页返回时,统计主页被卸载重建,
ScrollView 归零 → 刚看的排行位置丢了。同一页内的上下钻不该重置滚动,
只有切 Tab 回顶(见上面 active effect 的 scrollTo)。 */}
<View style={page === 'main' ? styles.page : styles.pageHidden}>
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={[styles.titleRow, range === 'month' && styles.titleRowCompact]}>
<Text style={styles.pageTitle}>统计</Text>
<View style={styles.rangeSwitch}>
{(
[
['week', '近7天'],
['month', '月度'],
['year', '年度'],
] as [RangeKey, string][]
).map(([r, label]) => (
<Pressable
key={r}
style={[styles.rangeBtn, range === r && styles.rangeBtnActive]}
onPress={() => setRange(r)}
accessibilityRole="tab"
accessibilityLabel={label}
accessibilityState={{ selected: range === r }}
>
<Text style={[styles.rangeText, range === r && styles.rangeTextActive]}>{label}</Text>
</Pressable>
))}
</View>
</View>
{/* 月份切换:只在「月度」视图显示;不能翻到未来月份 */}
{range === 'month' ? (
<View style={styles.monthBar}>
<MonthSwitcher
label={getMonthName(monthAnchor)}
onPrev={() => {
setMonthOffset((v) => v - 1);
hapticLight();
}}
onNext={() => {
if (monthOffset >= 0) return;
setMonthOffset((v) => Math.min(v + 1, 0));
hapticLight();
}}
nextDisabled={isCurrentMonth}
/>
{/* 右侧动作绝对定位:出现/消失都不影响中间月份胶囊保持居中 */}
<View style={styles.monthAction}>
{statsLoading ? (
<Text style={styles.loadingHint}>加载中…</Text>
) : isCurrentMonth ? null : (
<Pressable
style={styles.monthTodayBtn}
onPress={() => {
setMonthOffset(0);
hapticLight();
}}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel="回到本月"
>
<Text style={styles.monthTodayText}>本月</Text>
</Pressable>
)}
</View>
</View>
) : null}
{/* 成员筛选(多成员账本显示,v0.5) */}
{multiMember ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.memberScroll}
contentContainerStyle={styles.memberChips}
>
<Pressable
style={[styles.memberChip, memberFilter === 0 && styles.memberChipActive]}
onPress={() => setMemberFilter(0)}
accessibilityRole="tab"
accessibilityLabel="全部成员"
accessibilityState={{ selected: memberFilter === 0 }}
>
<Text
style={[styles.memberChipText, memberFilter === 0 && styles.memberChipTextActive]}
numberOfLines={1}
>
👨👩👧 全部
</Text>
</Pressable>
{members.map((m) => (
<Pressable
key={m.id}
style={[styles.memberChip, memberFilter === m.id && styles.memberChipActive]}
onPress={() => setMemberFilter(m.id)}
accessibilityRole="tab"
accessibilityLabel={`只看${m.displayName}`}
accessibilityState={{ selected: memberFilter === m.id }}
>
<Text
style={[styles.memberChipText, memberFilter === m.id && styles.memberChipTextActive]}
numberOfLines={1}
>
{m.avatarEmoji} {m.displayName}
</Text>
</Pressable>
))}
</ScrollView>
) : null}
{/* 总览卡片(金额自适应字号,大金额不换行溢出) */}
<Text style={styles.sectionTitle}>收支总览 · {rangeLabel}</Text>
<View style={[styles.overview, statsLoading && styles.overviewLoading]}>
<View style={styles.overviewItem}>
<Text style={styles.overviewLabel}>支出</Text>
<Text
style={[styles.overviewValue, { color: COLORS.expense }]}
adjustsFontSizeToFit
numberOfLines={1}
>
¥{formatMoney(expense)}
</Text>
</View>
<View style={styles.overviewDivider} />
{/* 收入默认脱敏(v0.11.19):公共场合打开统计页不暴露工资数字,
点一下看真数、再点收回;切走再回统计页会重新遮上(见 active 副作用) */}
<Pressable
style={styles.overviewItem}
onPress={() => {
setIncomeShown((v) => !v);
hapticLight();
}}
accessibilityRole="button"
accessibilityLabel={
incomeShown ? `收入 ¥${formatMoney(income)},点击隐藏` : '收入已脱敏,点击显示实际金额'
}
>
<View style={styles.overviewLabelRow}>
<Text style={styles.overviewLabel}>收入</Text>
<Text style={styles.overviewLock} accessibilityElementsHidden>
{incomeShown ? '🔓' : '🔒'}
</Text>
</View>
<Text
style={[styles.overviewValue, { color: COLORS.income }]}
adjustsFontSizeToFit
numberOfLines={1}
>
{incomeShown ? `¥${formatMoney(income)}` : '¥ ****'}
</Text>
</Pressable>
</View>
{/* 预算对比(预算只存了「当前月」一个值,历史月份对比会误导,故仅本月显示) */}
{budget > 0 && range === 'month' && isCurrentMonth ? (
<View style={styles.card}>
<View style={styles.budgetHead}>
<Text style={styles.cardTitle}>预算对比 · 本月</Text>
<Text style={[styles.budgetPct, budgetOver && { color: COLORS.danger }]}>
{budgetOver ? '已超支' : `${Math.round(budgetPercent * 100)}%`}
</Text>
</View>
<View style={styles.budgetTrack}>
<View
style={[
styles.budgetFill,
{
width: `${Math.round(budgetPercent * 100)}%`,
backgroundColor: budgetOver ? COLORS.danger : COLORS.accent,
},
]}
/>
</View>
<Text style={styles.budgetHint}>
已用 ¥{formatMoney(expense)} / 预算 ¥{formatMoney(budget)} · 剩余 ¥
{formatMoney(Math.max(budget - expense, 0))}
</Text>
</View>
) : null}
{/* 报销入口(紧跟预算对比之后,v0.5.9) */}
<Pressable
style={styles.card}
onPress={() => setPage('reimburse')}
accessibilityRole="button"
accessibilityLabel={`待报销,${reimburseStatusText}`}
>
<View style={styles.reimburseRow}>
<View style={[styles.reimburseIcon, { backgroundColor: `${COLORS.warningText}15` }]}>
<Text style={styles.reimburseEmoji}>🧾</Text>
</View>
<View style={styles.reimburseInfo}>
<Text style={styles.reimburseTitle}>待报销</Text>
<Text style={styles.reimburseStatus} numberOfLines={1}>
{reimburseStatusText}
</Text>
</View>
<Text style={styles.reimburseArrow}>›</Text>
</View>
</Pressable>
{/* 支出占比 */}
<Text style={styles.sectionTitle}>支出占比 · {rangeLabel}</Text>
<View style={styles.card}>
{categoryData.length > 0 ? (
<CategoryPieChart data={categoryData} type="expense" />
) : (
<View style={styles.empty}>
<Text style={styles.emptyText}>这个时间段还没有支出记录</Text>
</View>
)}
</View>
{/* 分类排行(点任意一行 → 该分类在这个时间段的具体明细) */}
{topCategories.length > 0 ? (
<>
<Text style={styles.sectionTitle}>支出分类排行 · 点击查看明细</Text>
<View style={styles.card}>
{topCategories.map((c, i) => {
const pct = expense > 0 ? (c.total / expense) * 100 : 0;
const barPct = maxCategoryTotal > 0 ? (c.total / maxCategoryTotal) * 100 : 0;
return (
<Pressable
key={c.category}
style={({ pressed }) => [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)}%`}
>
<Text style={styles.rankIndex}>{i + 1}</Text>
<View style={[styles.rankIcon, { backgroundColor: `${c.def.color}22` }]}>
<Text style={styles.rankEmoji}>{c.def.emoji}</Text>
</View>
<View style={styles.rankInfo}>
<View style={styles.rankHead}>
<Text style={styles.rankLabel}>{c.def.label}</Text>
<Text style={styles.rankAmount}>
¥{formatMoney(c.total)} · {pct.toFixed(1)}%
</Text>
</View>
<View style={styles.rankTrack}>
<View
style={[
styles.rankFill,
{ width: `${Math.round(barPct)}%`, backgroundColor: c.def.color },
]}
/>
</View>
</View>
<Text style={styles.reimburseArrow}>›</Text>
</Pressable>
);
})}
</View>
</>
) : null}
{/* 成员支出排行(多成员且未筛选时显示,v0.5) */}
{multiMember && memberFilter === 0 && memberRows.length > 0 ? (
<>
<Text style={styles.sectionTitle}>成员支出排行 · {rangeLabel}</Text>
<View style={styles.card}>
{memberRows.map((m) => (
<Pressable
key={m.userId}
style={styles.memberRow}
onPress={() => setMemberFilter(m.userId)}
accessibilityRole="button"
accessibilityLabel={`查看${m.name}的支出,共${formatMoney(m.total)}元`}
>
<View style={[styles.memberAvatar, { backgroundColor: `${memberColor(m.userId)}22` }]}>
<Text style={styles.memberAvatarEmoji}>{m.emoji}</Text>
</View>
<View style={styles.memberInfo}>
<View style={styles.memberHead}>
<Text style={[styles.memberName, { color: memberColor(m.userId) }]}>{m.name}</Text>
<Text style={styles.memberAmount}>
¥{formatMoney(m.total)} · {m.pct.toFixed(1)}% · {m.count}笔
</Text>
</View>
<View style={styles.memberTrack}>
<View
style={[
styles.memberFill,
{ width: `${Math.round(m.barPct)}%`, backgroundColor: memberColor(m.userId) },
]}
/>
</View>
</View>
</Pressable>
))}
</View>
</>
) : null}
{/* 支出趋势(时间范围与支出占比统一用简化标签) */}
<Text style={styles.sectionTitle}>支出趋势 · {rangeLabel}</Text>
<View style={styles.card}>
{trendEmpty ? (
<View style={styles.empty}>
<Text style={styles.emptyText}>这个时间段还没有支出记录</Text>
</View>
) : (
<TrendBarChart values={trendValues} labels={trendLabels} color={COLORS.expense} />
)}
</View>
</ScrollView>
</View>
<View style={page === 'reimburse' ? styles.page : styles.pageHidden}>
<View style={styles.navBar}>
<Pressable
hitSlop={8}
onPress={backToMain}
accessibilityRole="button"
accessibilityLabel="返回统计"
>
<Text style={styles.navBack}>‹ 返回</Text>
</Pressable>
<Text style={styles.navTitle}>报销管理</Text>
</View>
<ReimburseScreen />
</View>
{/* 分类明细下钻页:区间/成员筛选与排行完全同口径,所以顶部合计必然等于排行条上的数字 */}
<View style={page === 'category' ? styles.page : styles.pageHidden}>
<View style={styles.subPage}>
{/* 明细列表与编辑页互斥显示(display 切换,与明细页同构):
EditRecordModal 明确要求宿主这样承载,不能用绝对定位叠层或 RNModal
——那两轮修复(触摸失灵 / 键盘遮挡)就是从这里来的 */}
<View style={drillEdit ? styles.pageHidden : styles.subPage}>
<View style={styles.navBar}>
<Pressable
hitSlop={8}
onPress={backToMain}
accessibilityRole="button"
accessibilityLabel="返回统计"
>
<Text style={styles.navBack}>‹ 返回</Text>
</Pressable>
<Text style={styles.navTitle} numberOfLines={1}>
{drill ? `${drill.label}明细` : '分类明细'}
</Text>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<Text style={styles.drillScope}>{drillScopeText} · 不含待报销 · 点击条目可编辑</Text>
<View style={styles.drillSummary}>
<Text style={styles.drillSummaryAmount}>¥{formatMoney(drillTotal)}</Text>
<Text style={styles.drillSummaryCount}>{drillRecords.length} 笔</Text>
</View>
{drillLoading && drillRecords.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyText}>正在加载明细…</Text>
</View>
) : (
<RecordList
records={drillRecords}
showDate
members={members}
onEdit={(r) => setDrillEdit(r)}
emptyText="这个时间段该分类没有符合条件的支出"
/>
)}
</ScrollView>
</View>
<View style={drillEdit ? styles.subPage : styles.pageHidden}>
{/* 保存后组件内部广播 RECORDED → 本页 refreshAll 递增 tick → 下钻查询自动重跑,
列表就地更新(分类被改掉的那条会移出本列表),用户仍停在这个分类的明细上 */}
<EditRecordModal
visible={drillEdit !== null}
record={drillEdit}
onClose={() => setDrillEdit(null)}
/>
</View>
</View>
</View>
</SafeAreaView>
);
}
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,
},
});