import React, { useMemo, useRef } from 'react'; import { Animated, PanResponder, Pressable, StyleSheet, Text, View } from 'react-native'; import { COLORS, FONT_SIZE, RADIUS, SPACING, findCategory } from '../constants'; import { formatMoney } from '../utils/dateUtils'; import { findMember, memberColor } from '../sync/memberUtils'; import type { LedgerRecord } from '../types'; import type { MemberInfo } from '../sync/memberUtils'; interface Props { records: LedgerRecord[]; onDelete?: (record: LedgerRecord) => void; onEdit?: (record: LedgerRecord) => void; // 点击行编辑(v0.10,不传则整行不可点) emptyText?: string; showTime?: boolean; // 显示记录时间(今日明细用) showDate?: boolean; // 显示记录日期(跨多日列表用,如报销明细) members?: MemberInfo[]; // 家庭成员(显示记账人标识,v0.5) onToggleReimbursed?: (record: LedgerRecord) => void; // 报销页单条核销/撤销 } // 单条记录行(memo:父组件 state 变化时避免整表重渲染;导出供 FlatList 虚拟化列表使用) export const RecordRow = React.memo(function RecordRow({ record, onDelete, onEdit, showTime, showDate, members, onToggleReimbursed, }: { record: LedgerRecord; onDelete?: (record: LedgerRecord) => void; onEdit?: (record: LedgerRecord) => void; showTime?: boolean; showDate?: boolean; members?: MemberInfo[]; onToggleReimbursed?: (record: LedgerRecord) => void; }) { const cat = findCategory(record.category, record.type); const isExpense = record.type === 'expense'; // 记账人标识:已登录且能解析出成员即显示(v0.10.1:家庭账本即使只有 1 人也显示, // 家人后续加入时历史记录已有归属;members 为空数组 = 未加入家庭,不显示) const member = members && record.userId > 0 ? findMember(members, record.userId) : null; // 单条核销/撤销按钮(只有报销页传 onToggleReimbursed):放在左侧图标下方 const showReimburseBtn = !!onToggleReimbursed && record.reimbursable; // 左滑删除:仅在明显横向拖动时接管手势(不抢点击),越过阈值触发删除后弹回;✕ 仍作为兜底 const translateX = useRef(new Animated.Value(0)).current; const swipeable = !!onDelete; const panResponder = useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => false, onStartShouldSetPanResponderCapture: () => false, onMoveShouldSetPanResponder: (_, g) => !!onDelete && g.dx < -8 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5, onPanResponderMove: (_, g) => { if (g.dx < 0) translateX.setValue(Math.max(g.dx, -96)); }, onPanResponderRelease: (_, g) => { if (onDelete && g.dx <= -64) onDelete(record); Animated.spring(translateX, { toValue: 0, useNativeDriver: true, friction: 8 }).start(); }, onPanResponderTerminate: () => Animated.spring(translateX, { toValue: 0, useNativeDriver: true, friction: 8 }).start(), }), [onDelete, record, translateX], ); return ( {swipeable ? ( 删除 ) : null} {/* 两行结构:第一行「图标 + 分类信息 + 金额/删除」,第二行「核销 + 日期 + 备注」。 v0.11.8:核销/删除不包进编辑热区——嵌套 Pressable 手指能点(内层优先), 但 TalkBack 会把外层标签下的子树合并成一个节点,核销和删除就对读屏不可达了。 v0.11.12:日期与备注挪到第二行用整行宽(原先和分类名挤在一列里,被压到零宽); v0.11.14/15:核销按钮从右侧挪到第二行的行首,正好落在图标正下方—— 窄屏上「图标+分类+成员+待报销+核销+金额」超过一行宽,RN 不裁剪溢出就会盖住徽章。 */} onEdit?.(record)} disabled={!onEdit} accessibilityRole={onEdit ? 'button' : undefined} accessibilityLabel={onEdit ? `编辑${cat.label}记录${formatMoney(record.amount)}元` : undefined} > {cat.emoji} {cat.label} {member ? ( {member.avatarEmoji} {member.displayName} ) : null} {record.reimbursable ? ( {record.reimbursed ? '已报销' : '待报销'} ) : null} {/* v0.11.21:金额也是高频点击位置,给金额本身补编辑热区(删除按钮仍独立在外) */} onEdit?.(record)} disabled={!onEdit} hitSlop={{ top: 8, bottom: 8, left: 8, right: 4 }} accessibilityRole={onEdit ? 'button' : undefined} accessibilityLabel={ onEdit ? `编辑${cat.label}记录${formatMoney(record.amount)}元` : undefined } > {isExpense ? '-' : '+'} {formatMoney(record.amount)} {onDelete ? ( onDelete(record)} hitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }} accessibilityRole="button" accessibilityLabel={`删除${cat.label}记录${formatMoney(record.amount)}元`} > ✕ ) : null} {/* 第二行:核销按钮(行首,与图标同宽同左边缘)+ 日期 + 备注。 没有核销按钮时,日期/备注用缩进对齐分类文字左边缘。 */} {showReimburseBtn || record.note || showDate || showTime ? ( {showReimburseBtn ? ( onToggleReimbursed?.(record)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel={record.reimbursed ? '撤销核销' : '核销报销'} > {record.reimbursed ? '撤销' : '核销'} ) : null} onEdit(record) : undefined} disabled={!onEdit} accessibilityRole={onEdit ? 'button' : undefined} > {showDate ? {record.date} : null} {showTime ? ( {new Date(record.timestamp).toTimeString().slice(0, 5)} ) : null} {record.note ? ( {record.note} ) : null} ) : null} ); }); // 记录列表(暖色卡片风格) function RecordList({ records, onDelete, onEdit, emptyText = '还没有记录,记一笔吧 ✨', showTime, showDate, members, onToggleReimbursed, }: Props) { if (records.length === 0) { return ( 📭 {emptyText} ); } return ( {records.map((record) => ( ))} ); } export default React.memo(RecordList); const styles = StyleSheet.create({ empty: { paddingVertical: SPACING.xl, alignItems: 'center', gap: SPACING.sm, }, emptyEmoji: { fontSize: 34, opacity: 0.5, }, emptyText: { color: COLORS.textTertiary, fontSize: FONT_SIZE.sm, }, list: { gap: SPACING.sm, }, swipeWrap: { position: 'relative', borderRadius: RADIUS.md, overflow: 'hidden', backgroundColor: COLORS.danger, }, swipeDelete: { position: 'absolute', top: 0, bottom: 0, right: 0, width: 84, alignItems: 'center', justifyContent: 'center', }, swipeDeleteText: { color: COLORS.white, fontSize: FONT_SIZE.sm, fontWeight: '700', }, row: { backgroundColor: COLORS.surface, borderRadius: RADIUS.md, borderWidth: 1, borderColor: COLORS.border, paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm + 2, gap: 2, }, // 第一行:图标 + 分类 + 右侧金额/删除 rowMain: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm + 4, }, // 编辑热区(图标 + 分类信息),与右侧动作区分开,读屏可分别聚焦 rowContent: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: SPACING.sm + 4, }, rowActions: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm + 4, }, iconWrap: { width: 40, height: 40, borderRadius: RADIUS.sm, alignItems: 'center', justifyContent: 'center', }, icon: { fontSize: FONT_SIZE.xxl - 8, }, catRow: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', // 极窄屏/大字号下让徽章换行,而不是溢出压到右侧金额上 gap: 6, }, catLabel: { fontSize: FONT_SIZE.md, color: COLORS.text, fontWeight: '600', }, badge: { borderRadius: RADIUS.pill, paddingHorizontal: 6, paddingVertical: 1, }, memberTag: { flexDirection: 'row', alignItems: 'center', gap: 2, borderRadius: RADIUS.pill, paddingHorizontal: 6, paddingVertical: 1, maxWidth: 96, }, memberEmoji: { fontSize: FONT_SIZE.xs, }, memberName: { fontSize: FONT_SIZE.xs - 1.5, fontWeight: '700', flexShrink: 1, }, badgePending: { backgroundColor: COLORS.warningBg, }, badgeDone: { backgroundColor: COLORS.incomeBg, }, badgeText: { fontSize: FONT_SIZE.xs - 1.5, color: COLORS.warningText, fontWeight: '700', }, badgeTextDone: { color: COLORS.incomeText, }, // 第二行:行首是核销按钮(与图标同宽),其后是日期/备注 metaRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm + 4, }, metaLine: { flex: 1, flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 6, }, // 没有核销按钮占位时,用缩进把日期/备注对齐到分类文字左边缘(图标宽 + 行间距) metaIndented: { paddingLeft: 40 + SPACING.sm + 4, }, note: { fontSize: FONT_SIZE.xs + 1, color: COLORS.textTertiary, }, timeTag: { fontSize: FONT_SIZE.xs - 1, color: COLORS.textTertiary, }, dateTag: { fontSize: FONT_SIZE.xs - 1, color: COLORS.textSecondary, backgroundColor: COLORS.bgAlt, borderRadius: RADIUS.pill, paddingHorizontal: 6, paddingVertical: 1, }, amount: { fontSize: FONT_SIZE.lg - 2, fontWeight: '700', }, reimburseToggle: { width: 40, // 与上方图标同宽、同左边缘,整列对齐 flexShrink: 0, // 第二行内容再挤也不能压掉这个宽度,否则就和图标错开了 paddingVertical: 4, borderRadius: RADIUS.pill, alignItems: 'center', backgroundColor: COLORS.warningBg, borderWidth: 1, borderColor: COLORS.warningBorder, }, reimburseToggleUndo: { backgroundColor: COLORS.bgAlt, borderColor: COLORS.border, }, reimburseToggleText: { fontSize: FONT_SIZE.xs, color: COLORS.warningText, fontWeight: '700', }, deleteBtn: { width: 26, height: 26, borderRadius: RADIUS.pill, backgroundColor: COLORS.bgAlt, alignItems: 'center', justifyContent: 'center', }, deleteText: { fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, fontWeight: '600', }, });