Files
frontend/src/screens/message/GroupMembersScreen.tsx
lan be84c01abd Migrate frontend realtime messaging to SSE.
Switch service integrations and screen/store consumers from websocket events to SSE, and ignore generated dist-web artifacts.

Made-with: Cursor
2026-03-10 12:58:23 +08:00

730 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
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]);
// 成员列表状态
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
// 当前用户的成员信息
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;
// 加载成员列表
const loadMembers = useCallback(
async (
pageNum: number = 1,
refresh: boolean = false,
forceRefresh: boolean = false
) => {
if (!hasMore && !refresh) return;
try {
const response = await groupManager.getMembers(groupId, pageNum, 50, forceRefresh);
if (refresh) {
setMembers(response.list);
setPage(1);
} else {
setMembers(prev => [...prev, ...response.list]);
}
setHasMore(response.list.length === 50);
// 查找当前用户的成员信息
const myMember = response.list.find(m => m.user_id === currentUser?.id);
if (myMember) {
setCurrentMember(myMember);
}
} catch (error) {
console.error('加载成员列表失败:', error);
}
setLoading(false);
setRefreshing(false);
}, [groupId, currentUser, hasMore]);
// 初始加载
useEffect(() => {
loadMembers(1, true, true);
}, [groupId]);
// 下拉刷新
const onRefresh = useCallback(() => {
setRefreshing(true);
setHasMore(true);
loadMembers(1, true, true);
}, [loadMembers]);
// 加载更多
const loadMore = useCallback(() => {
if (!loading && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
loadMembers(nextPage);
}
}, [loading, hasMore, page, loadMembers]);
// 按角色分组
const groupMembers = useCallback((): MemberGroup[] => {
const owners = members.filter(m => m.role === 'owner');
const admins = members.filter(m => m.role === 'admin');
const normalMembers = members.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;
}, [members]);
// 打开操作菜单
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',
});
// 更新本地数据
setMembers(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);
// 更新本地数据
setMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, muted: newMuted };
}
return m;
}));
// 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言
await loadMembers(1, true, true);
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);
// 更新本地数据
setMembers(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 || '',
});
// 更新本地数据
setMembers(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) 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={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
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,
},
});
export default GroupMembersScreen;