Files
frontend/src/screens/message/GroupMembersScreen.tsx

750 lines
22 KiB
TypeScript
Raw Normal View History

/**
* GroupMembersScreen
*
*
* 使
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
View,
StyleSheet,
FlatList,
TouchableOpacity,
Alert,
RefreshControl,
ListRenderItem,
Modal,
TextInput,
Dimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { useAuthStore } from '../../stores';
import { groupService } from '../../services/groupService';
import { groupManager } from '../../stores/groupManager';
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import {
GroupMemberResponse,
GroupRole,
} from '../../types/dto';
import { RootStackParamList } from '../../navigation/types';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 网格布局配置
const GRID_CONFIG = {
mobile: { columns: 1, itemWidth: '100%' },
tablet: { columns: 2, itemWidth: '48%' },
desktop: { columns: 3, itemWidth: '31%' },
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type GroupMembersRouteProp = RouteProp<RootStackParamList, 'GroupMembers'>;
// 成员分组
interface MemberGroup {
title: string;
data: GroupMemberResponse[];
}
const GroupMembersScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const route = useRoute<GroupMembersRouteProp>();
const { groupId } = route.params;
const { currentUser } = useAuthStore();
// 响应式布局
const { isDesktop, isTablet, width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
// 计算网格列数
const gridConfig = useMemo(() => {
if (width >= 1024) return GRID_CONFIG.desktop;
if (width >= 768) return GRID_CONFIG.tablet;
return GRID_CONFIG.mobile;
}, [width]);
// 使用游标分页 Hook 管理成员列表
const {
items: members,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await groupService.getGroupMembersCursor(groupId, {
cursor,
page_size: pageSize,
});
},
{ pageSize: 50 }
);
// 本地成员状态(用于乐观更新)
const [localMembers, setLocalMembers] = useState<GroupMemberResponse[]>([]);
const [loading, setLoading] = useState(true);
// 同步分页数据到本地状态
useEffect(() => {
setLocalMembers(members);
setLoading(false);
}, [members]);
// 当前用户的成员信息
const [currentMember, setCurrentMember] = useState<GroupMemberResponse | null>(null);
// 操作模态框状态
const [actionModalVisible, setActionModalVisible] = useState(false);
const [selectedMember, setSelectedMember] = useState<GroupMemberResponse | null>(null);
const [actionLoading, setActionLoading] = useState(false);
// 设置群昵称模态框
const [nicknameModalVisible, setNicknameModalVisible] = useState(false);
const [newNickname, setNewNickname] = useState('');
// 计算当前用户的角色
const isOwner = currentMember?.role === 'owner';
const isAdmin = currentMember?.role === 'admin' || isOwner;
// 查找当前用户的成员信息
useEffect(() => {
const myMember = localMembers.find(m => m.user_id === currentUser?.id);
if (myMember) {
setCurrentMember(myMember);
}
}, [localMembers, currentUser]);
// 下拉刷新
const onRefresh = useCallback(async () => {
setLoading(true);
await refresh();
setLoading(false);
}, [refresh]);
// 初始加载
useEffect(() => {
refresh();
}, [groupId]);
// 按角色分组
const groupMembers = useCallback((): MemberGroup[] => {
const owners = localMembers.filter(m => m.role === 'owner');
const admins = localMembers.filter(m => m.role === 'admin');
const normalMembers = localMembers.filter(m => m.role === 'member');
const groups: MemberGroup[] = [];
if (owners.length > 0) {
groups.push({ title: '群主', data: owners });
}
if (admins.length > 0) {
groups.push({ title: '管理员', data: admins });
}
if (normalMembers.length > 0) {
groups.push({ title: '成员', data: normalMembers });
}
return groups;
}, [localMembers]);
// 打开操作菜单
const openActionModal = (member: GroupMemberResponse) => {
// 不能操作群主
if (member.role === 'owner') return;
// 普通成员不能操作其他人
if (!isAdmin) return;
// 管理员不能操作其他管理员(只有群主可以)
if (!isOwner && member.role === 'admin') return;
setSelectedMember(member);
setActionModalVisible(true);
};
// 设置/取消管理员
const handleToggleAdmin = async () => {
if (!selectedMember) return;
const newRole: GroupRole = selectedMember.role === 'admin' ? 'member' : 'admin';
const actionText = newRole === 'admin' ? '设为管理员' : '取消管理员';
Alert.alert(
actionText,
`确定要${actionText} "${selectedMember.user?.nickname || selectedMember.nickname}" 吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
onPress: async () => {
setActionLoading(true);
try {
await groupService.setMemberRole(groupId, selectedMember.user_id, {
role: newRole as 'admin' | 'member',
});
// 更新本地数据
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, role: newRole };
}
return m;
}));
setActionModalVisible(false);
Alert.alert('成功', `${actionText}`);
} catch (error: any) {
console.error('设置角色失败:', error);
Alert.alert('错误', error.message || '操作失败');
} finally {
setActionLoading(false);
}
},
},
]
);
};
// 禁言/解禁成员
const handleToggleMute = async () => {
if (!selectedMember) return;
const newMuted = !selectedMember.muted;
const actionText = newMuted ? '禁言' : '解禁';
Alert.alert(
`${actionText}成员`,
`确定要${actionText} "${selectedMember.user?.nickname || selectedMember.nickname}" 吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
onPress: async () => {
setActionLoading(true);
try {
// duration: 0 表示解除禁言,-1 表示永久禁言
await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0);
// 更新本地数据
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, muted: newMuted };
}
return m;
}));
// 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言
await refresh();
setActionModalVisible(false);
Alert.alert('成功', `${actionText}`);
} catch (error: any) {
console.error('禁言操作失败:', error);
Alert.alert('错误', error.message || '操作失败');
} finally {
setActionLoading(false);
}
},
},
]
);
};
// 移除成员
const handleRemoveMember = () => {
if (!selectedMember) return;
Alert.alert(
'移除成员',
`确定要将 "${selectedMember.user?.nickname || selectedMember.nickname}" 移出群聊吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: async () => {
setActionLoading(true);
try {
await groupService.removeMember(groupId, selectedMember.user_id);
// 更新本地数据
setLocalMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id));
setActionModalVisible(false);
Alert.alert('成功', '已移除成员');
} catch (error: any) {
console.error('移除成员失败:', error);
Alert.alert('错误', error.message || '操作失败');
} finally {
setActionLoading(false);
}
},
},
]
);
};
// 打开设置群昵称模态框
const openNicknameModal = (member: GroupMemberResponse) => {
setSelectedMember(member);
setNewNickname(member.nickname || member.user?.nickname || '');
setNicknameModalVisible(true);
};
// 设置群昵称(仅限自己)
const handleSetNickname = async () => {
if (!selectedMember || selectedMember.user_id !== currentUser?.id) return;
setActionLoading(true);
try {
await groupService.setNickname(groupId, {
nickname: newNickname.trim() || selectedMember.user?.nickname || '',
});
// 更新本地数据
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, nickname: newNickname.trim() };
}
return m;
}));
setNicknameModalVisible(false);
Alert.alert('成功', '群昵称已更新');
} catch (error: any) {
console.error('设置群昵称失败:', error);
Alert.alert('错误', error.message || '操作失败');
} finally {
setActionLoading(false);
}
};
// 获取角色标签颜色
const getRoleBadgeColor = (role: GroupRole): string => {
switch (role) {
case 'owner':
return colors.warning.main;
case 'admin':
return colors.primary.main;
default:
return colors.text.hint;
}
};
// 获取角色标签文本
const getRoleBadgeText = (role: GroupRole): string => {
switch (role) {
case 'owner':
return '群主';
case 'admin':
return '管理员';
default:
return '';
}
};
// 渲染成员项
const renderMember: ListRenderItem<GroupMemberResponse> = ({ item }) => {
const isSelf = item.user_id === currentUser?.id;
const canManage = isAdmin && item.role !== 'owner' && (isOwner || item.role === 'member');
return (
<TouchableOpacity
style={styles.memberItem}
onPress={() => canManage ? openActionModal(item) : null}
onLongPress={() => isSelf ? openNicknameModal(item) : null}
activeOpacity={canManage ? 0.7 : 1}
>
<Avatar
source={item.user?.avatar}
size={48}
name={item.user?.nickname || item.nickname}
/>
<View style={styles.memberInfo}>
<View style={styles.memberNameRow}>
<Text variant="body" style={styles.memberName}>
{item.nickname || item.user?.nickname || '未知用户'}
</Text>
{item.role !== 'member' && (
<View style={[styles.roleBadge, { backgroundColor: getRoleBadgeColor(item.role) }]}>
<Text variant="label" color={colors.background.paper}>
{getRoleBadgeText(item.role)}
</Text>
</View>
)}
</View>
<Text variant="caption" color={colors.text.secondary}>
@{item.user?.username || 'unknown'}
</Text>
{item.muted && (
<View style={styles.mutedBadge}>
<MaterialCommunityIcons name="microphone-off" size={12} color={colors.error.main} />
<Text variant="label" color={colors.error.main}> </Text>
</View>
)}
</View>
{canManage && (
<MaterialCommunityIcons name="dots-vertical" size={20} color={colors.text.hint} />
)}
</TouchableOpacity>
);
};
// 渲染分组头部
const renderSectionHeader = (title: string, count: number) => (
<View style={styles.sectionHeader}>
<Text variant="label" color={colors.text.secondary}>
{title}
</Text>
<Text variant="caption" color={colors.text.hint}>
{count}
</Text>
</View>
);
// 渲染空状态
const renderEmpty = () => {
if (loading || isLoading) return <Loading />;
return (
<EmptyState
title="暂无成员"
description="群组还没有成员"
icon="account-group-outline"
/>
);
};
// 渲染操作菜单
const renderActionModal = () => {
if (!selectedMember) return null;
return (
<Modal
visible={actionModalVisible}
animationType="slide"
transparent
onRequestClose={() => setActionModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Avatar
source={selectedMember.user?.avatar}
size={60}
name={selectedMember.user?.nickname || selectedMember.nickname}
/>
<Text variant="h3" style={styles.modalTitle}>
{selectedMember.user?.nickname || selectedMember.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary}>
@{selectedMember.user?.username}
</Text>
</View>
<Divider />
{/* 设置/取消管理员(仅群主) */}
{isOwner && selectedMember.role !== 'owner' && (
<TouchableOpacity
style={styles.actionItem}
onPress={handleToggleAdmin}
disabled={actionLoading}
>
<MaterialCommunityIcons
name={selectedMember.role === 'admin' ? 'account-remove' : 'account-plus'}
size={22}
color={colors.text.primary}
/>
<Text variant="body" style={styles.actionText}>
{selectedMember.role === 'admin' ? '取消管理员' : '设为管理员'}
</Text>
</TouchableOpacity>
)}
{/* 禁言/解禁 */}
<TouchableOpacity
style={styles.actionItem}
onPress={handleToggleMute}
disabled={actionLoading}
>
<MaterialCommunityIcons
name={selectedMember.muted ? 'microphone' : 'microphone-off'}
size={22}
color={selectedMember.muted ? colors.success.main : colors.error.main}
/>
<Text
variant="body"
color={selectedMember.muted ? colors.success.main : colors.error.main}
style={styles.actionText}
>
{selectedMember.muted ? '解除禁言' : '禁言'}
</Text>
</TouchableOpacity>
{/* 移除成员 */}
<TouchableOpacity
style={styles.actionItem}
onPress={handleRemoveMember}
disabled={actionLoading}
>
<MaterialCommunityIcons name="account-remove" size={22} color={colors.error.main} />
<Text variant="body" color={colors.error.main} style={styles.actionText}>
</Text>
</TouchableOpacity>
<Divider />
<Button
title="取消"
variant="outline"
onPress={() => setActionModalVisible(false)}
fullWidth
disabled={actionLoading}
/>
</View>
</View>
</Modal>
);
};
// 渲染设置群昵称模态框
const renderNicknameModal = () => {
if (!selectedMember) return null;
return (
<Modal
visible={nicknameModalVisible}
animationType="slide"
transparent
onRequestClose={() => setNicknameModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text variant="h3" style={styles.modalTitle}></Text>
<Text variant="caption" color={colors.text.secondary} style={styles.inputLabel}>
</Text>
<TextInput
style={styles.input}
value={newNickname}
onChangeText={setNewNickname}
placeholder={selectedMember.user?.nickname || '请输入群昵称'}
placeholderTextColor={colors.text.hint}
maxLength={20}
/>
<View style={styles.modalButtons}>
<Button
title="取消"
variant="outline"
onPress={() => setNicknameModalVisible(false)}
style={styles.modalButton}
disabled={actionLoading}
/>
<Button
title="保存"
onPress={handleSetNickname}
loading={actionLoading}
style={styles.modalButton}
/>
</View>
</View>
</View>
</Modal>
);
};
// 渲染分组列表
const renderGroupedList = () => {
const groups = groupMembers();
if (groups.length === 0) {
return renderEmpty();
}
return (
<FlatList
data={groups}
keyExtractor={(item) => item.title}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListFooterComponent={
isLoading ? (
<View style={styles.loadingFooter}>
<Loading size="sm" />
</View>
) : hasMore ? (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
<Text variant="caption" color={colors.primary.main}>
</Text>
</TouchableOpacity>
) : localMembers.length > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
</Text>
) : null
}
renderItem={({ item: group }) => (
<View style={styles.section}>
{renderSectionHeader(group.title, group.data.length)}
{group.data.map((member, index) => (
<View key={member.id}>
{renderMember({ item: member, index } as any)}
</View>
))}
</View>
)}
/>
);
};
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{loading ? <Loading /> : renderGroupedList()}
{renderActionModal()}
{renderNicknameModal()}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
section: {
marginBottom: spacing.md,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.default,
},
memberItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
memberInfo: {
flex: 1,
marginLeft: spacing.md,
},
memberNameRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 2,
},
memberName: {
fontWeight: '500',
},
roleBadge: {
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginLeft: spacing.sm,
},
mutedBadge: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
// 模态框样式
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: colors.background.paper,
borderTopLeftRadius: borderRadius.lg,
borderTopRightRadius: borderRadius.lg,
padding: spacing.lg,
maxHeight: '80%',
},
modalHeader: {
alignItems: 'center',
marginBottom: spacing.md,
},
modalTitle: {
fontWeight: '700',
marginTop: spacing.sm,
marginBottom: spacing.xs,
},
actionItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
actionText: {
marginLeft: spacing.md,
},
inputLabel: {
marginBottom: spacing.xs,
},
input: {
backgroundColor: colors.background.default,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: fontSizes.md,
color: colors.text.primary,
borderWidth: 1,
borderColor: colors.divider,
marginBottom: spacing.md,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: spacing.md,
},
modalButton: {
flex: 1,
marginHorizontal: spacing.xs,
},
// 分页加载样式
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
},
});
export default GroupMembersScreen;