src/components/FamilyModal.tsx
Rama main · réplica de lectura del repositorio original.
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<FamilyInfo | null>(null);
// 首次回读没落地前先显示加载态:否则已有家庭的成员打开弹窗会闪一下「创建新家庭」,
// 看起来像家庭凭空消失了
const [loaded, setLoaded] = useState(false);
const [members, setMembers] = useState<FamilyMember[]>([]);
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 (
<Modal visible={visible} title="家庭账本" onClose={onClose}>
{!loaded ? (
<View style={styles.loadingBox}>
<ActivityIndicator size="small" color={COLORS.accent} />
<Text style={styles.loadingText}>正在读取家庭信息…</Text>
</View>
) : family ? (
<>
{/* 家庭信息 */}
<View style={styles.formGroup}>
<Text style={styles.familyName}>🏠 {family.name}</Text>
<Pressable
style={styles.inviteRow}
accessibilityRole="button"
accessibilityLabel={`邀请码${family.inviteCode},点击查看`}
onPress={() => {
// 复制邀请码(长按复制体验的轻量替代:点击提示)
Alert.alert('邀请码', `把邀请码告诉家人:${family.inviteCode}`);
}}
>
<Text style={styles.inviteLabel}>邀请码</Text>
<Text style={styles.inviteCode}>{family.inviteCode}</Text>
</Pressable>
{isOwner ? (
<Pressable onPress={regenerate} hitSlop={8} accessibilityRole="button">
<Text style={styles.linkText}>重置邀请码</Text>
</Pressable>
) : null}
</View>
{/* 成员列表 */}
<View style={styles.formGroup}>
<Text style={styles.fieldLabel}>成员({members.length})</Text>
{members.map((m) => (
<View key={m.id} style={styles.memberRow}>
<View style={styles.memberAvatar}>
<Text style={styles.memberEmoji}>{m.avatarEmoji}</Text>
</View>
<Text style={styles.memberName}>{m.displayName}</Text>
<View style={[styles.roleBadge, m.role === 'owner' && styles.roleBadgeOwner]}>
<Text style={styles.roleText}>{m.role === 'owner' ? '创建者' : '成员'}</Text>
</View>
{/* owner 可移除其他成员(v0.5) */}
{isOwner && m.id !== currentUserId ? (
<Pressable
style={styles.removeBtn}
onPress={() => removeMember(m)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`移除成员${m.displayName}`}
>
<Text style={styles.removeText}>移除</Text>
</Pressable>
) : null}
</View>
))}
{/* 修改自己的资料(v0.5) */}
{editingProfile ? (
<View style={styles.profileEdit}>
<Text style={styles.fieldLabel}>我的昵称</Text>
<TextInput
style={styles.inlineInputFull}
value={profileName}
onChangeText={setProfileName}
maxLength={12}
placeholder="昵称(最多 12 字)"
placeholderTextColor={COLORS.textTertiary}
/>
<Text style={[styles.fieldLabel, { marginTop: SPACING.sm }]}>我的头像</Text>
<View style={styles.avatarGrid}>
{AVATARS.map((a) => (
<Pressable
key={a}
style={[styles.avatarCell, profileEmoji === a && styles.avatarCellActive]}
onPress={() => {
setProfileEmoji(a);
hapticLight();
}}
accessibilityRole="button"
accessibilityLabel={`选择头像${a}`}
accessibilityState={{ selected: profileEmoji === a }}
>
<Text style={styles.avatarCellEmoji}>{a}</Text>
</Pressable>
))}
</View>
<View style={styles.profileBtnRow}>
<Pressable
style={styles.cancelBtn}
onPress={() => setEditingProfile(false)}
accessibilityRole="button"
>
<Text style={styles.cancelText}>取消</Text>
</Pressable>
<Pressable
style={[styles.inlineBtn, busy && styles.btnDisabled]}
onPress={saveProfile}
disabled={busy}
accessibilityRole="button"
>
<Text style={styles.inlineBtnText}>保存</Text>
</Pressable>
</View>
</View>
) : (
<Pressable onPress={() => setEditingProfile(true)} hitSlop={8} accessibilityRole="button">
<Text style={styles.linkText}>✏️ 修改我的昵称 / 头像</Text>
</Pressable>
)}
</View>
<Pressable style={[styles.leaveBtn]} onPress={leaveFamily} accessibilityRole="button">
<Text style={styles.leaveText}>{isOwner ? '解散家庭' : '退出家庭'}</Text>
</Pressable>
</>
) : (
<>
{/* 创建家庭 */}
<View style={styles.formGroup}>
<Text style={styles.fieldLabel}>创建新家庭(你是创建者)</Text>
<View style={styles.inlineRow}>
<TextInput
style={styles.inlineInput}
placeholder="家庭名称,如 我们家"
placeholderTextColor={COLORS.textTertiary}
value={name}
onChangeText={setName}
maxLength={20}
/>
<Pressable
style={[styles.inlineBtn, busy && styles.btnDisabled]}
onPress={createFamily}
disabled={busy}
accessibilityRole="button"
>
<Text style={styles.inlineBtnText}>创建</Text>
</Pressable>
</View>
</View>
{/* 邀请码加入 */}
<View style={styles.formGroup}>
<Text style={styles.fieldLabel}>或用家人分享的邀请码加入</Text>
<View style={styles.inlineRow}>
<TextInput
style={styles.inlineInput}
placeholder="6 位邀请码"
placeholderTextColor={COLORS.textTertiary}
value={inviteCode}
onChangeText={(t) => setInviteCode(t.toUpperCase())}
maxLength={6}
autoCapitalize="characters"
/>
<Pressable
style={[styles.inlineBtn, busy && styles.btnDisabled]}
onPress={joinFamily}
disabled={busy}
accessibilityRole="button"
>
<Text style={styles.inlineBtnText}>加入</Text>
</Pressable>
</View>
</View>
</>
)}
</Modal>
);
}
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',
},
});