Files
frontend/src/screens/message/GroupMembersScreen.tsx
lafay 2ddb9cadd8
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m27s
Frontend CI / ota-android (push) Successful in 11m6s
Frontend CI / build-android-apk (push) Successful in 1h16m45s
refactor(App, navigation): migrate to Expo Router and clean up navigation structure
- Updated App entry point to utilize Expo Router, simplifying the navigation setup.
- Removed legacy navigation components and services to streamline the codebase.
- Adjusted package.json to reflect the new entry point and updated dependencies for compatibility.
- Enhanced overall application structure by consolidating navigation logic and improving maintainability.
2026-03-24 14:21:31 +08:00

768 lines
22 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 { useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { useAuthStore } from '../../stores';
import { groupService } from '../../services/groupService';
import {
createRemoteGroupMemberListSource,
GroupMemberListSourceKind,
IGroupMemberListPagedSource,
} from '../../stores/groupMemberListSources';
import { enrichGroupMembersWithUserProfiles } from '../../stores/groupMemberProfileResolver';
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 { firstRouteParam } from '../../navigation/paramUtils';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor';
// 网格布局配置
const GRID_CONFIG = {
mobile: { columns: 1, itemWidth: '100%' },
tablet: { columns: 2, itemWidth: '48%' },
desktop: { columns: 3, itemWidth: '31%' },
};
// 成员分组
interface MemberGroup {
title: string;
data: GroupMemberResponse[];
}
const GroupMembersScreen: React.FC = () => {
const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>();
const groupId = firstRouteParam(groupIdParam) ?? '';
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 memberListSource = useMemo<IGroupMemberListPagedSource>(() => {
return createRemoteGroupMemberListSource(GROUP_MEMBER_REMOTE_LIST_KIND, groupId, 50);
}, [groupId]);
// 使用统一数据源 + 游标 Hook 管理成员列表
const {
list: members,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error,
} = useCursorPagination(
async ({ cursor }) => {
// cursor 为空表示第一页/刷新,此时重置数据源状态
if (!cursor) {
memberListSource.restart();
}
return await memberListSource.loadNext();
},
{ pageSize: 50 }
);
// 本地成员状态(用于乐观更新)
const [localMembers, setLocalMembers] = useState<GroupMemberResponse[]>([]);
const [loading, setLoading] = useState(true);
// 同步分页数据到本地状态
useEffect(() => {
let active = true;
const syncMembers = async () => {
const enrichedMembers = await enrichGroupMembersWithUserProfiles(members);
if (!active) return;
setLocalMembers(enrichedMembers);
setLoading(false);
};
syncMembers();
return () => {
active = 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(() => {
if (!groupId) return;
refresh();
}, [groupId, refresh]);
// 按角色分组
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;