Files
frontend/src/screens/message/JoinGroupScreen.tsx
lafay 26ae288470
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m8s
Frontend CI / ota-android (push) Successful in 11m22s
Frontend CI / build-android-apk (push) Successful in 45m46s
refactor: standardize pagination response structure and enhance cursor handling
- Update various services and hooks to replace 'items' with 'list' for consistency in pagination responses.
- Modify PostRepository, commentService, groupService, messageService, and postService to align with the new response structure.
- Enhance cursor pagination logic in hooks and components to ensure proper handling of pagination states.
- Refactor HomeScreen, PostDetailScreen, SearchScreen, and other components to utilize the updated pagination structure.
2026-03-23 03:58:26 +08:00

452 lines
13 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.
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;