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

452 lines
13 KiB
TypeScript
Raw Normal View History

import React, { useState, useCallback } from 'react';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
Clipboard,
FlatList,
RefreshControl,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../../theme';
import Avatar from '../../components/common/Avatar';
import Text from '../../components/common/Text';
import { groupService } from '../../services/groupService';
import { groupManager } from '../../stores/groupManager';
import { RootStackParamList } from '../../navigation/types';
import { GroupResponse, JoinType } from '../../types/dto';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { EmptyState } from '../../components/common';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
const JoinGroupScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
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);
// 使用游标分页 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: () => navigation.goBack(),
},
]);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'操作失败,请稍后重试';
Alert.alert('操作失败', String(message));
} finally {
setJoiningGroupId(null);
}
};
const handleCopyGroupId = (groupId: string | number) => {
Clipboard.setString(String(groupId));
Alert.alert('已复制', '群号已复制到剪贴板');
};
const formatGroupNo = (id: string | number) => {
const raw = String(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 variant="body" style={styles.groupName} numberOfLines={1}>
{group.name}
</Text>
</View>
</View>
{!!group.description && (
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
{group.description}
</Text>
)}
<View style={styles.groupInfoRow}>
<Text variant="caption" color={colors.text.secondary}>
{group.member_count}/{group.max_members}
</Text>
<Text variant="caption" color={colors.text.secondary}>
{getJoinTypeText(group.join_type)}
</Text>
</View>
<View style={styles.groupNoRow}>
<Text variant="caption" color={colors.text.secondary}>
{formatGroupNo(group.id)}
</Text>
<TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}>
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}>
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.submitBtn, isJoining && styles.submitBtnDisabled]}
onPress={() => handleJoin(group)}
disabled={isJoining}
>
{isJoining ? (
<ActivityIndicator color="#fff" />
) : (
<>
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
<Text variant="body" color="#fff" 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 variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
{renderGroupItem({ item: searchedGroup })}
</View>
);
}
if (!searching) {
return (
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
ID后重试
</Text>
);
}
return null;
};
return (
<View style={styles.container}>
<View style={styles.heroCard}>
<View style={styles.heroIconWrap}>
<MaterialCommunityIcons name="account-group-outline" size={28} color={colors.primary.main} />
</View>
<Text variant="h3" style={styles.heroTitle}></Text>
<Text variant="body" color={colors.text.secondary} style={styles.tip}>
ID
</Text>
</View>
<View style={styles.formCard}>
<Text variant="caption" color={colors.text.secondary} style={styles.label}>
ID
</Text>
<View style={styles.searchRow}>
<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`}
/>
<TouchableOpacity
style={[styles.searchBtn, (!keyword.trim() || searching || !!joiningGroupId) && styles.submitBtnDisabled]}
onPress={handleSearch}
disabled={!keyword.trim() || searching || !!joiningGroupId}
>
{searching ? (
<ActivityIndicator color="#fff" />
) : (
<MaterialCommunityIcons name="magnify" size={20} color="#fff" />
)}
</TouchableOpacity>
</View>
{/* 搜索结果 */}
{renderSearchResult()}
{/* 群组列表 */}
<View style={styles.listSection}>
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
<FlatList
data={groups}
renderItem={renderGroupItem}
keyExtractor={(item) => String(item.id)}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListEmptyComponent={renderEmptyList}
ListFooterComponent={
isLoading ? (
<View style={styles.loadingFooter}>
<ActivityIndicator color={colors.primary.main} />
</View>
) : hasMore ? (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
<Text variant="caption" color={colors.primary.main}>
</Text>
</TouchableOpacity>
) : groups.length > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
</Text>
) : null
}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
padding: spacing.lg,
},
heroCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.lg,
},
heroIconWrap: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.primary.light + '26',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
heroTitle: {
marginBottom: spacing.xs,
},
tip: {
lineHeight: 20,
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
flex: 1,
},
label: {
marginBottom: spacing.xs,
},
input: {
flex: 1,
borderWidth: 1,
borderColor: colors.divider,
borderRadius: borderRadius.md,
backgroundColor: colors.background.paper,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
color: colors.text.primary,
},
searchRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.md,
},
searchBtn: {
width: 46,
height: 46,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
},
searchResultSection: {
marginBottom: spacing.lg,
},
sectionTitle: {
marginBottom: spacing.sm,
fontWeight: '600',
},
listSection: {
flex: 1,
},
groupCard: {
borderWidth: 1,
borderColor: colors.divider,
borderRadius: borderRadius.md,
padding: spacing.md,
backgroundColor: colors.background.default,
marginBottom: spacing.md,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
},
groupMeta: {
marginLeft: spacing.md,
flex: 1,
},
groupName: {
marginBottom: spacing.xs,
fontWeight: '600',
},
groupDesc: {
marginTop: spacing.sm,
lineHeight: 18,
},
groupInfoRow: {
marginTop: spacing.sm,
marginBottom: spacing.xs,
flexDirection: 'row',
justifyContent: 'space-between',
},
groupNoRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
copyBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.sm,
backgroundColor: colors.primary.light + '22',
},
copyBtnText: {
marginLeft: 4,
},
submitBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
minHeight: 46,
},
submitBtnDisabled: {
opacity: 0.5,
},
submitText: {
marginLeft: spacing.xs,
},
emptyText: {
marginTop: spacing.sm,
textAlign: 'center',
},
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
},
});
export default JoinGroupScreen;