import React, { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { COLORS, FONT_SIZE, RADIUS, SPACING } from '../constants'; import Modal from './Modal'; import { hapticError, hapticLight } from '../utils/haptics'; import type { FamilyInfo, FamilyMember } from '../sync/serverTypes'; interface Props { visible: boolean; baseUrl: string; token: string; currentUserId: number; // 当前登录用户 id(判断是否 owner) onClose: () => void; onFamilyChanged: () => void; // 创建/加入/退出后刷新外层状态 onError: (msg: string) => void; } // 家庭弹窗:未入家(创建/邀请码加入)|已入家(成员列表 + 邀请码管理 + 退出) export default function FamilyModal({ visible, baseUrl, token, currentUserId, onClose, onFamilyChanged, onError, }: Props) { const [family, setFamily] = useState(null); // 首次回读没落地前先显示加载态:否则已有家庭的成员打开弹窗会闪一下「创建新家庭」, // 看起来像家庭凭空消失了 const [loaded, setLoaded] = useState(false); const [members, setMembers] = useState([]); const [name, setName] = useState(''); const [inviteCode, setInviteCode] = useState(''); const [busy, setBusy] = useState(false); const [editingProfile, setEditingProfile] = useState(false); // 编辑自己的昵称/头像(v0.5) const [profileName, setProfileName] = useState(''); const [profileEmoji, setProfileEmoji] = useState('🙂'); const isOwner = family != null && family.ownerId === currentUserId; // 可选头像池(与登录注册一致) const AVATARS = ['🙂', '😊', '😎', '🥳', '🧑', '👩', '👨', '🧕', '👴', '👵', '🐱', '🐶']; const reload = async () => { try { const { apiGetFamily, apiFamilyMembers } = await import('../sync/apiClient'); const [f, m] = await Promise.all([ apiGetFamily(baseUrl, token), apiFamilyMembers(baseUrl, token).catch(() => ({ members: [] as FamilyMember[] })), ]); setFamily(f.family); setMembers(m.members); // 同步自己的资料到编辑态 const me = m.members.find((x) => x.id === currentUserId); if (me) { setProfileName(me.displayName); setProfileEmoji(me.avatarEmoji || '🙂'); } } catch (e) { onError(e instanceof Error ? e.message : '加载失败'); } finally { setLoaded(true); } }; useEffect(() => { if (!visible) return; setName(''); setInviteCode(''); setEditingProfile(false); setLoaded(false); reload(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]); // 创建家庭 const createFamily = async () => { if (!name.trim()) { hapticError(); onError('请输入家庭名称'); return; } setBusy(true); try { const { apiCreateFamily } = await import('../sync/apiClient'); await apiCreateFamily(baseUrl, token, name.trim()); hapticLight(); onFamilyChanged(); reload(); } catch (e) { hapticError(); onError(e instanceof Error ? e.message : '创建失败'); } finally { setBusy(false); } }; // 邀请码加入 const joinFamily = async () => { const code = inviteCode.trim().toUpperCase(); if (code.length !== 6) { hapticError(); onError('请输入 6 位邀请码'); return; } setBusy(true); try { const { apiJoinFamily } = await import('../sync/apiClient'); await apiJoinFamily(baseUrl, token, code); hapticLight(); onFamilyChanged(); reload(); } catch (e) { hapticError(); onError(e instanceof Error ? e.message : '加入失败'); } finally { setBusy(false); } }; // 重置邀请码 const regenerate = async () => { Alert.alert('重置邀请码', '旧邀请码将失效,确定重置?', [ { text: '取消', style: 'cancel' }, { text: '重置', style: 'destructive', onPress: async () => { try { const { apiRegenerateInvite } = await import('../sync/apiClient'); const res = await apiRegenerateInvite(baseUrl, token); hapticLight(); setFamily((prev) => (prev ? { ...prev, inviteCode: res.inviteCode } : prev)); } catch (e) { onError(e instanceof Error ? e.message : '重置失败'); } }, }, ]); }; // 退出/解散家庭 const leaveFamily = () => { Alert.alert( isOwner ? '解散家庭' : '退出家庭', isOwner ? '你是唯一成员,解散后账本数据将从服务器删除(本地保留)。确定?' : '退出后不再同步该家庭账本(历史记录保留在家庭中)。确定?', [ { text: '取消', style: 'cancel' }, { text: isOwner ? '解散' : '退出', style: 'destructive', onPress: async () => { try { const { apiLeaveFamily } = await import('../sync/apiClient'); await apiLeaveFamily(baseUrl, token); hapticLight(); onFamilyChanged(); setFamily(null); setMembers([]); } catch (e) { onError(e instanceof Error ? e.message : '操作失败'); } }, }, ], ); }; // owner 移除成员(v0.5;不可移除自己) const removeMember = (m: FamilyMember) => { Alert.alert('移除成员', `确定将「${m.displayName}」移出家庭?其历史记录保留在账本中。`, [ { text: '取消', style: 'cancel' }, { text: '移除', style: 'destructive', onPress: async () => { try { const { apiRemoveMember } = await import('../sync/apiClient'); await apiRemoveMember(baseUrl, token, m.id); hapticLight(); reload(); } catch (e) { hapticError(); onError(e instanceof Error ? e.message : '移除失败'); } }, }, ]); }; // 修改自己的昵称/头像(v0.5) const saveProfile = async () => { const trimmed = profileName.trim(); if (!trimmed) { hapticError(); onError('昵称不能为空'); return; } setBusy(true); try { const { apiUpdateMe } = await import('../sync/apiClient'); await apiUpdateMe(baseUrl, token, { displayName: trimmed, avatarEmoji: profileEmoji }); hapticLight(); setEditingProfile(false); reload(); onFamilyChanged(); } catch (e) { hapticError(); onError(e instanceof Error ? e.message : '保存失败'); } finally { setBusy(false); } }; return ( {!loaded ? ( 正在读取家庭信息… ) : family ? ( <> {/* 家庭信息 */} 🏠 {family.name} { // 复制邀请码(长按复制体验的轻量替代:点击提示) Alert.alert('邀请码', `把邀请码告诉家人:${family.inviteCode}`); }} > 邀请码 {family.inviteCode} {isOwner ? ( 重置邀请码 ) : null} {/* 成员列表 */} 成员({members.length}) {members.map((m) => ( {m.avatarEmoji} {m.displayName} {m.role === 'owner' ? '创建者' : '成员'} {/* owner 可移除其他成员(v0.5) */} {isOwner && m.id !== currentUserId ? ( removeMember(m)} hitSlop={8} accessibilityRole="button" accessibilityLabel={`移除成员${m.displayName}`} > 移除 ) : null} ))} {/* 修改自己的资料(v0.5) */} {editingProfile ? ( 我的昵称 我的头像 {AVATARS.map((a) => ( { setProfileEmoji(a); hapticLight(); }} accessibilityRole="button" accessibilityLabel={`选择头像${a}`} accessibilityState={{ selected: profileEmoji === a }} > {a} ))} setEditingProfile(false)} accessibilityRole="button" > 取消 保存 ) : ( setEditingProfile(true)} hitSlop={8} accessibilityRole="button"> ✏️ 修改我的昵称 / 头像 )} {isOwner ? '解散家庭' : '退出家庭'} ) : ( <> {/* 创建家庭 */} 创建新家庭(你是创建者) 创建 {/* 邀请码加入 */} 或用家人分享的邀请码加入 setInviteCode(t.toUpperCase())} maxLength={6} autoCapitalize="characters" /> 加入 )} ); } const styles = StyleSheet.create({ loadingBox: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: SPACING.sm, paddingVertical: SPACING.xl, }, loadingText: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, }, formGroup: { marginBottom: SPACING.md, }, fieldLabel: { fontSize: FONT_SIZE.xs, color: COLORS.textTertiary, fontWeight: '600', marginBottom: 6, }, familyName: { fontSize: FONT_SIZE.lg, fontWeight: '800', color: COLORS.text, marginBottom: SPACING.sm, }, inviteRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md, backgroundColor: COLORS.surfaceAlt, borderRadius: RADIUS.md, paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm + 2, marginBottom: SPACING.xs, }, inviteLabel: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, }, inviteCode: { fontSize: FONT_SIZE.xxl, fontWeight: '800', color: COLORS.accentDark, letterSpacing: 4, }, linkText: { fontSize: FONT_SIZE.xs, color: COLORS.accentDark, fontWeight: '600', marginTop: SPACING.xs, }, memberRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, paddingVertical: SPACING.xs + 2, }, memberAvatar: { width: 36, height: 36, borderRadius: RADIUS.pill, backgroundColor: COLORS.surfaceAlt, borderWidth: 1, borderColor: COLORS.border, alignItems: 'center', justifyContent: 'center', }, memberEmoji: { fontSize: FONT_SIZE.lg - 1, }, memberName: { flex: 1, fontSize: FONT_SIZE.md, color: COLORS.text, fontWeight: '600', }, roleBadge: { borderRadius: RADIUS.pill, backgroundColor: COLORS.bgAlt, paddingHorizontal: 8, paddingVertical: 2, }, roleBadgeOwner: { backgroundColor: COLORS.warningBg, }, roleText: { fontSize: FONT_SIZE.xs - 1, color: COLORS.textSecondary, fontWeight: '700', }, inlineRow: { flexDirection: 'row', gap: SPACING.sm, }, inlineInput: { flex: 1, backgroundColor: COLORS.bgAlt, borderRadius: RADIUS.sm, paddingHorizontal: SPACING.md, paddingVertical: 10, fontSize: FONT_SIZE.md, color: COLORS.text, }, inlineBtn: { backgroundColor: COLORS.accent, borderRadius: RADIUS.sm, paddingHorizontal: SPACING.lg, justifyContent: 'center', }, inlineBtnText: { color: COLORS.white, fontSize: FONT_SIZE.md, fontWeight: '700', }, // ===== 成员管理(v0.5) ===== removeBtn: { borderRadius: RADIUS.pill, borderWidth: 1, borderColor: COLORS.danger, paddingHorizontal: 10, paddingVertical: 3, }, removeText: { fontSize: FONT_SIZE.xs, color: COLORS.danger, fontWeight: '700', }, profileEdit: { marginTop: SPACING.sm, backgroundColor: COLORS.surfaceAlt, borderRadius: RADIUS.md, padding: SPACING.md, }, inlineInputFull: { backgroundColor: COLORS.bgAlt, borderRadius: RADIUS.sm, paddingHorizontal: SPACING.md, paddingVertical: 10, fontSize: FONT_SIZE.md, color: COLORS.text, }, avatarGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.sm, }, avatarCell: { width: 44, height: 44, borderRadius: RADIUS.pill, backgroundColor: COLORS.bgAlt, alignItems: 'center', justifyContent: 'center', borderWidth: 2, borderColor: 'transparent', }, avatarCellActive: { borderColor: COLORS.accent, backgroundColor: COLORS.surface, }, avatarCellEmoji: { fontSize: FONT_SIZE.lg, }, profileBtnRow: { flexDirection: 'row', gap: SPACING.sm, marginTop: SPACING.md, }, cancelBtn: { flex: 1, borderRadius: RADIUS.sm, borderWidth: 1, borderColor: COLORS.border, paddingVertical: 10, alignItems: 'center', }, cancelText: { fontSize: FONT_SIZE.md, color: COLORS.textSecondary, fontWeight: '600', }, btnDisabled: { opacity: 0.6, }, leaveBtn: { borderRadius: RADIUS.lg, paddingVertical: 12, alignItems: 'center', borderWidth: 1, borderColor: COLORS.danger, marginTop: SPACING.xs, marginBottom: SPACING.md, }, leaveText: { color: COLORS.danger, fontSize: FONT_SIZE.md, fontWeight: '700', }, });