Files
frontend/src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx

633 lines
18 KiB
TypeScript
Raw Normal View History

/**
* GroupInfoPanel.web.tsx
* Web端群信息侧边栏组件
*
*
*/
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
View,
StyleSheet,
TouchableOpacity,
ScrollView,
Dimensions,
Animated,
Alert,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text } from '../../../../components/common';
import { useAppColors, spacing, fontSizes, shadows, type AppColors } from '../../../../theme';
import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto';
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
import { groupManager } from '../../../../stores/groupManager';
import { groupService } from '../../../../services/groupService';
const { width: screenWidth } = Dimensions.get('window');
const PANEL_WIDTH = 360;
interface GroupInfoPanelProps {
visible: boolean;
groupId?: string;
groupInfo: GroupResponse | null;
conversationId?: string;
isPinned?: boolean;
currentUserId?: string;
onClose: () => void;
onTogglePin?: (pinned: boolean) => void;
onLeaveGroup?: () => void;
onInviteMembers?: () => void;
onViewAllMembers?: () => void;
}
export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
visible,
groupId,
groupInfo,
conversationId,
isPinned,
currentUserId,
onClose,
onTogglePin,
onLeaveGroup,
onInviteMembers,
onViewAllMembers,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createGroupInfoPanelStyles(colors), [colors]);
const isWideScreen = useBreakpointGTE('lg');
// Web 端不使用 useNativeDriver
const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current;
const fadeAnim = React.useRef(new Animated.Value(0)).current;
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
const [announcements, setAnnouncements] = useState<GroupAnnouncementResponse[]>([]);
const [loading, setLoading] = useState(false);
const [showAllMembers, setShowAllMembers] = useState(false);
const [allMembers, setAllMembers] = useState<GroupMemberResponse[]>([]);
const [loadingMoreMembers, setLoadingMoreMembers] = useState(false);
// 加载群成员初始只加载10个
const loadMembers = useCallback(async () => {
if (!groupId) return;
try {
setLoading(true);
const response = await groupManager.getMembers(groupId, 1, 10);
setMembers(response.list || []);
} catch (error) {
console.error('[GroupInfoPanel] 加载成员失败:', error);
} finally {
setLoading(false);
}
}, [groupId]);
// 加载全部群成员
const loadAllMembers = useCallback(async () => {
if (!groupId) return;
try {
setLoadingMoreMembers(true);
const response = await groupManager.getMembers(groupId, 1, 100);
setAllMembers(response.list || []);
setShowAllMembers(true);
} catch (error) {
console.error('[GroupInfoPanel] 加载全部成员失败:', error);
} finally {
setLoadingMoreMembers(false);
}
}, [groupId]);
// 加载群公告
const loadAnnouncements = useCallback(async () => {
if (!groupId) return;
try {
const response = await groupService.getAnnouncements(groupId, 1, 10);
setAnnouncements(response.list || []);
} catch (error) {
console.error('[GroupInfoPanel] 加载公告失败:', error);
}
}, [groupId]);
// 每次打开面板时重置状态
useEffect(() => {
if (visible) {
setShowAllMembers(false);
setAllMembers([]);
}
}, [visible]);
useEffect(() => {
if (visible && groupId) {
loadMembers();
loadAnnouncements();
}
}, [visible, groupId, loadMembers, loadAnnouncements]);
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
// Web 端不使用 useNativeDriver
useNativeDriver: false,
}),
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
// Web 端不使用 useNativeDriver
useNativeDriver: false,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: PANEL_WIDTH,
duration: 300,
// Web 端不使用 useNativeDriver
useNativeDriver: false,
}),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
// Web 端不使用 useNativeDriver
useNativeDriver: false,
}),
]).start();
}
}, [visible, slideAnim, fadeAnim]);
if (!isWideScreen) {
return null;
}
// 获取加群方式文本
const getJoinTypeText = (joinType?: number): string => {
switch (joinType) {
case 0:
return '允许任何人加入';
case 1:
return '需要管理员审批';
case 2:
return '不允许任何人加入';
default:
return '未知';
}
};
return (
<>
{/* 遮罩层 */}
{visible && (
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<TouchableOpacity
style={styles.overlayTouchable}
onPress={onClose}
activeOpacity={1}
/>
</Animated.View>
)}
{/* 侧边栏面板 */}
<Animated.View
style={[
styles.panel,
{
transform: [{ translateX: slideAnim }],
},
]}
>
{/* 头部 */}
<View style={styles.header}>
<Text style={styles.headerTitle}></Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color="#333" />
</TouchableOpacity>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 群头像和名称 */}
<View style={styles.groupInfoSection}>
<Avatar
source={groupInfo?.avatar}
size={80}
name={groupInfo?.name || '群聊'}
/>
<Text style={styles.groupName}>{groupInfo?.name || '群聊'}</Text>
<Text style={styles.memberCount}>
{groupInfo?.member_count || members.length || 0}
</Text>
{groupInfo?.join_type !== undefined && (
<Text style={styles.joinType}>
{getJoinTypeText(groupInfo.join_type)}
</Text>
)}
{/* 邀请新成员按钮 */}
{onInviteMembers && (
<TouchableOpacity
style={styles.inviteButton}
onPress={onInviteMembers}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="account-plus" size={18} color={colors.primary.main} />
<Text style={styles.inviteButtonText}></Text>
</TouchableOpacity>
)}
</View>
{/* 分割线 */}
<View style={styles.divider} />
{/* 群公告 */}
{announcements.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons name="bullhorn" size={18} color={colors.warning.main} />
<Text style={styles.sectionTitle}></Text>
</View>
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>{announcements[0].content}</Text>
<Text style={styles.noticeTime}>
{new Date(announcements[0].created_at).toLocaleDateString()}
</Text>
</View>
</View>
)}
{/* 群简介 */}
{!!groupInfo?.description && (
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.descriptionBox}>
<Text style={styles.description}>{groupInfo.description}</Text>
</View>
</View>
)}
{/* 群成员列表 */}
<View style={styles.section}>
<View style={styles.memberListHeader}>
<Text style={styles.sectionTitle}> ({groupInfo?.member_count || members.length})</Text>
{onViewAllMembers && !showAllMembers && members.length >= 10 && (
<TouchableOpacity onPress={loadAllMembers} style={styles.viewAllButton} disabled={loadingMoreMembers}>
<Text style={styles.viewAllText}>
{loadingMoreMembers ? '加载中...' : '查看全部'}
</Text>
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.primary.main} />
</TouchableOpacity>
)}
{onViewAllMembers && showAllMembers && (
<TouchableOpacity onPress={() => setShowAllMembers(false)} style={styles.viewAllButton}>
<Text style={styles.viewAllText}></Text>
<MaterialCommunityIcons name="chevron-up" size={16} color={colors.primary.main} />
</TouchableOpacity>
)}
</View>
<View style={styles.memberList}>
{(showAllMembers ? allMembers : members).map((member) => (
<View key={member.id || member.user_id} style={styles.memberItem}>
<Avatar
source={member.user?.avatar}
size={44}
name={member.nickname || member.user?.nickname || '用户'}
/>
<View style={styles.memberInfo}>
<Text style={styles.memberName} numberOfLines={1}>
{member.nickname || member.user?.nickname || '用户'}
</Text>
{member.role === 'owner' && (
<Text style={styles.ownerBadge}></Text>
)}
{member.role === 'admin' && (
<Text style={styles.adminBadge}></Text>
)}
</View>
</View>
))}
</View>
</View>
{/* 群信息 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>{groupInfo?.id || '-'}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>
{groupInfo?.created_at
? new Date(groupInfo.created_at).toLocaleDateString('zh-CN')
: '-'}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>
{groupInfo?.mute_all ? '已开启' : '已关闭'}
</Text>
</View>
</View>
{/* 操作按钮 */}
<View style={styles.actionSection}>
{conversationId && onTogglePin && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onTogglePin(!isPinned)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={isPinned ? "pin-off" : "pin"}
size={22}
color={colors.primary.main}
/>
<Text style={styles.actionButtonText}>
{isPinned ? '取消置顶' : '置顶群聊'}
</Text>
</TouchableOpacity>
)}
{onLeaveGroup && (
<TouchableOpacity
style={[styles.actionButton, styles.leaveButton]}
onPress={onLeaveGroup}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="logout"
size={22}
color={colors.error.main}
/>
<Text style={[styles.actionButtonText, styles.leaveButtonText]}>
退
</Text>
</TouchableOpacity>
)}
</View>
</ScrollView>
</Animated.View>
</>
);
};
function createGroupInfoPanelStyles(colors: AppColors) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.35)',
zIndex: 100,
},
overlayTouchable: {
flex: 1,
},
panel: {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: PANEL_WIDTH,
backgroundColor: colors.background.paper,
zIndex: 101,
borderLeftWidth: 0.5,
borderLeftColor: colors.divider + '60',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 0.5,
borderBottomColor: colors.divider + '60',
backgroundColor: colors.background.paper,
},
headerTitle: {
fontSize: fontSizes.xl + 1,
fontWeight: '800',
color: colors.text.primary,
letterSpacing: 0.5,
},
closeButton: {
padding: spacing.xs,
borderRadius: borderRadius.md,
},
content: {
flex: 1,
},
groupInfoSection: {
alignItems: 'center',
paddingVertical: spacing.xl,
paddingHorizontal: spacing.md,
},
groupName: {
fontSize: fontSizes.xl + 1,
fontWeight: '800',
color: colors.text.primary,
marginTop: spacing.md,
letterSpacing: 0.3,
},
memberCount: {
fontSize: fontSizes.md,
color: colors.text.secondary,
marginTop: spacing.xs,
fontWeight: '500',
},
joinType: {
fontSize: fontSizes.sm,
color: colors.primary.main,
marginTop: spacing.xs,
fontWeight: '600',
},
inviteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
backgroundColor: colors.primary.light + '12',
borderRadius: borderRadius.lg,
marginTop: spacing.md,
borderWidth: 1.5,
borderColor: colors.primary.light + '60',
borderStyle: 'dashed',
},
inviteButtonText: {
fontSize: fontSizes.sm,
color: colors.primary.main,
marginLeft: spacing.xs,
fontWeight: '700',
letterSpacing: 0.3,
},
divider: {
height: 0.5,
backgroundColor: colors.divider + '60',
marginHorizontal: spacing.lg,
},
section: {
padding: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontSize: fontSizes.md + 1,
fontWeight: '700',
color: colors.text.primary,
marginLeft: spacing.xs,
letterSpacing: 0.3,
},
noticeBox: {
backgroundColor: colors.warning.light + '08',
padding: spacing.md,
borderRadius: borderRadius.lg,
borderLeftWidth: 3,
borderLeftColor: colors.warning.main,
},
noticeText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
lineHeight: 22,
fontWeight: '400',
},
noticeTime: {
fontSize: fontSizes.xs,
color: colors.text.hint,
marginTop: spacing.xs,
textAlign: 'right',
fontWeight: '500',
},
descriptionBox: {
backgroundColor: colors.background.default,
padding: spacing.md,
borderRadius: borderRadius.lg,
borderWidth: 0.5,
borderColor: colors.divider + '30',
},
description: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
lineHeight: 22,
fontWeight: '400',
},
memberList: {
gap: spacing.sm,
},
memberListHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.sm,
},
viewAllButton: {
flexDirection: 'row',
alignItems: 'center',
},
viewAllText: {
fontSize: fontSizes.sm,
color: colors.primary.main,
fontWeight: '600',
},
memberItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.md,
marginLeft: -spacing.sm,
marginRight: -spacing.sm,
},
memberInfo: {
flex: 1,
marginLeft: spacing.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
memberName: {
fontSize: fontSizes.md,
color: colors.text.primary,
flex: 1,
fontWeight: '600',
},
ownerBadge: {
fontSize: fontSizes.xs,
color: colors.background.paper,
marginLeft: spacing.xs,
backgroundColor: colors.warning.main,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
fontWeight: '800',
},
adminBadge: {
fontSize: fontSizes.xs,
color: colors.primary.main,
marginLeft: spacing.xs,
backgroundColor: colors.primary.light + '20',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
fontWeight: '700',
},
moreMembers: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
textAlign: 'center',
paddingVertical: spacing.sm,
fontWeight: '500',
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: spacing.md,
borderBottomWidth: 0.5,
borderBottomColor: colors.divider + '40',
},
infoLabel: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
fontWeight: '500',
},
infoValue: {
fontSize: fontSizes.sm,
color: colors.text.primary,
fontWeight: '600',
textAlign: 'right',
},
actionSection: {
padding: spacing.lg,
paddingBottom: spacing.xl,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
backgroundColor: colors.primary.light + '10',
borderRadius: borderRadius.lg,
marginBottom: spacing.sm,
borderWidth: 1.5,
borderColor: colors.primary.light + '50',
},
actionButtonText: {
fontSize: fontSizes.md,
fontWeight: '700',
color: colors.primary.main,
marginLeft: spacing.sm,
letterSpacing: 0.3,
},
leaveButton: {
backgroundColor: colors.error.light + '08',
borderColor: colors.error.light + '50',
borderWidth: 1.5,
},
leaveButtonText: {
color: colors.error.main,
fontWeight: '700',
},
});
}
export default GroupInfoPanel;