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

563 lines
15 KiB
TypeScript
Raw Normal View History

import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
Clipboard,
FlatList,
RefreshControl,
Animated,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import Avatar from '../../components/common/Avatar';
import Text from '../../components/common/Text';
2026-04-13 01:30:37 +08:00
import { groupService } from '@/services/message';
import { groupManager } from '../../stores/group';
import { GroupResponse, JoinType } from '../../types/dto';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { EmptyState } from '../../components/common';
function createJoinGroupStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 28,
paddingTop: 40,
paddingBottom: 40,
},
content: {
flex: 1,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
},
// 标题区域
titleSection: {
marginBottom: 32,
marginTop: 12,
},
title: {
fontSize: 28,
fontWeight: '700',
color: colors.text.primary,
lineHeight: 36,
},
underline: {
width: 40,
height: 4,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginTop: 12,
marginBottom: 12,
},
subtitle: {
fontSize: 15,
color: colors.text.secondary,
},
// 搜索区域
searchSection: {
marginBottom: 24,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 10,
},
searchRow: {
flexDirection: 'row',
alignItems: 'center',
},
inputWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
borderWidth: 1,
borderColor: 'transparent',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text.primary,
height: 56,
},
searchBtn: {
width: 56,
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
},
searchBtnDisabled: {
opacity: 0.5,
},
// 搜索结果区域
searchResultSection: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 14,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 12,
},
emptyText: {
textAlign: 'center',
fontSize: 14,
color: colors.text.hint,
fontWeight: '500',
paddingVertical: spacing.lg,
},
// 群卡片
groupCard: {
backgroundColor: colors.background.default,
borderRadius: 16,
padding: spacing.lg,
marginBottom: spacing.md,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
groupMeta: {
marginLeft: spacing.md,
flex: 1,
},
groupName: {
fontWeight: '700',
fontSize: 16,
color: colors.text.primary,
marginBottom: 2,
},
groupDesc: {
fontSize: 13,
color: colors.text.secondary,
lineHeight: 20,
marginTop: spacing.sm,
},
groupInfoRow: {
marginTop: spacing.sm,
marginBottom: spacing.xs,
flexDirection: 'row',
justifyContent: 'space-between',
},
groupInfoText: {
fontSize: 12,
color: colors.text.secondary,
},
groupNoRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
groupNoText: {
fontSize: 12,
color: colors.text.secondary,
},
copyBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.sm,
backgroundColor: colors.primary.light + '15',
},
copyBtnText: {
marginLeft: 4,
fontSize: 12,
fontWeight: '600',
color: colors.primary.main,
},
submitBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 14,
backgroundColor: colors.primary.main,
height: 48,
},
submitBtnDisabled: {
opacity: 0.5,
},
submitText: {
marginLeft: spacing.xs,
fontWeight: '700',
fontSize: 15,
color: colors.text.inverse,
},
// 推荐区域
recommendSection: {
flex: 1,
},
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreText: {
fontSize: 14,
fontWeight: '500',
color: colors.primary.main,
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
fontWeight: '500',
fontSize: 13,
color: colors.text.hint,
},
});
}
const JoinGroupScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createJoinGroupStyles(colors), [colors]);
const router = useRouter();
const [keyword, setKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [joiningGroupId, setJoiningGroupId] = useState<string | null>(null);
const [searchedGroup, setSearchedGroup] = useState<GroupResponse | null>(null);
const [searched, setSearched] = useState(false);
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
// 使用游标分页 Hook 管理群组列表
const {
list: groups,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await groupService.getGroupsCursor({
cursor,
page_size: pageSize,
});
},
{ pageSize: 20 }
);
const getJoinTypeText = (joinType: JoinType) => {
if (joinType === 0) return '允许加入';
if (joinType === 1) return '需要审批';
return '禁止加入';
};
const handleSearch = async () => {
const trimmed = keyword.trim();
if (!trimmed) {
Alert.alert('提示', '请输入群ID进行搜索');
return;
}
setSearching(true);
setSearched(true);
try {
const result = await groupManager.getGroup(trimmed, true);
setSearchedGroup(result);
} catch (error: any) {
setSearchedGroup(null);
const message = error?.response?.data?.message || error?.message || '';
if (String(message).includes('不存在') || error?.response?.status === 404) {
Alert.alert('未找到', '未搜索到该群聊请确认群ID是否正确');
} else {
Alert.alert('搜索失败', '请稍后重试');
}
} finally {
setSearching(false);
}
};
const handleJoin = async (group: GroupResponse) => {
if (!group?.id) return;
setJoiningGroupId(String(group.id));
try {
await groupService.joinGroup(group.id);
Alert.alert('成功', '操作已提交', [
{
text: '确定',
onPress: () => router.back(),
},
]);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'操作失败,请稍后重试';
Alert.alert('操作失败', String(message));
} finally {
setJoiningGroupId(null);
}
};
const handleCopyGroupId = (groupId: string) => {
Clipboard.setString(groupId);
Alert.alert('已复制', '群号已复制到剪贴板');
};
const formatGroupNo = (id: string) => {
const raw = id;
if (raw.length <= 12) return raw;
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
};
const renderGroupItem = ({ item: group }: { item: GroupResponse }) => {
const isJoining = joiningGroupId === String(group.id);
return (
<View style={styles.groupCard}>
<View style={styles.groupHeader}>
<Avatar source={group.avatar} size={52} name={group.name} />
<View style={styles.groupMeta}>
<Text style={styles.groupName} numberOfLines={1}>
{group.name}
</Text>
</View>
</View>
{!!group.description && (
<Text style={styles.groupDesc}>
{group.description}
</Text>
)}
<View style={styles.groupInfoRow}>
<Text style={styles.groupInfoText}>
{group.member_count}/{group.max_members}
</Text>
<Text style={styles.groupInfoText}>
{getJoinTypeText(group.join_type)}
</Text>
</View>
<View style={styles.groupNoRow}>
<Text style={styles.groupNoText}>
{formatGroupNo(group.id)}
</Text>
<TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}>
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
<Text style={styles.copyBtnText}>
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.submitBtn, isJoining && styles.submitBtnDisabled]}
onPress={() => handleJoin(group)}
disabled={isJoining}
>
{isJoining ? (
<ActivityIndicator color={colors.text.inverse} />
) : (
<>
<MaterialCommunityIcons name="send-outline" size={18} color={colors.text.inverse} />
<Text style={styles.submitText}>
</Text>
</>
)}
</TouchableOpacity>
</View>
);
};
const renderEmptyList = () => {
if (isLoading) return null;
return (
<EmptyState
title="暂无群组"
description="还没有可加入的群组"
icon="account-group-outline"
/>
);
};
const renderSearchResult = () => {
if (!searched) return null;
if (searchedGroup) {
return (
<View style={styles.searchResultSection}>
<Text style={styles.sectionTitle}></Text>
{renderGroupItem({ item: searchedGroup })}
</View>
);
}
if (!searching) {
return (
<View style={styles.searchResultSection}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.emptyText}>
ID后重试
</Text>
</View>
);
}
return null;
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
{/* 标题区域 */}
<View style={styles.titleSection}>
<Text style={styles.title}></Text>
<View style={styles.underline} />
<Text style={styles.subtitle}>ID或浏览推荐群组</Text>
</View>
{/* 搜索区域 */}
<View style={styles.searchSection}>
<Text style={styles.inputLabel}>ID</Text>
<View style={styles.searchRow}>
<View style={styles.inputWrapper}>
<TextInput
value={keyword}
onChangeText={setKeyword}
placeholder="例如7391234567890"
placeholderTextColor={colors.text.hint}
style={styles.input}
editable={!searching && !joiningGroupId}
autoCapitalize="none"
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
</View>
<TouchableOpacity
style={[
styles.searchBtn,
(!keyword.trim() || searching || !!joiningGroupId) && styles.searchBtnDisabled,
]}
onPress={handleSearch}
disabled={!keyword.trim() || searching || !!joiningGroupId}
>
{searching ? (
<ActivityIndicator color={colors.text.inverse} />
) : (
<MaterialCommunityIcons name="magnify" size={22} color={colors.text.inverse} />
)}
</TouchableOpacity>
</View>
</View>
{/* 搜索结果 */}
{renderSearchResult()}
{/* 推荐群组 */}
<View style={styles.recommendSection}>
<Text style={styles.sectionTitle}></Text>
{groups.length === 0 && !isLoading ? (
renderEmptyList()
) : (
<>
{groups.map((group) => (
<View key={String(group.id)}>
{renderGroupItem({ item: group })}
</View>
))}
{isLoading && (
<View style={styles.loadingFooter}>
<ActivityIndicator color={colors.primary.main} />
</View>
)}
{hasMore && !isLoading && (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
<Text style={styles.loadMoreText}>
</Text>
</TouchableOpacity>
)}
{!hasMore && groups.length > 0 && (
<Text style={styles.noMoreText}>
</Text>
)}
</>
)}
</View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
export default JoinGroupScreen;