Files
frontend/src/screens/message/JoinGroupScreen.tsx
lafay 774b5c4b47
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m54s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
feat(CommentItem, PostDetailScreen, ReportDialog): integrate report functionality
- Added report button to CommentItem for reporting comments and replies.
- Integrated ReportDialog into PostDetailScreen for handling report submissions.
- Enhanced ReportDialog with detailed report reasons and improved UI interactions.
- Updated styles across various components for a more modern look and feel.

Made-with: Cursor
2026-03-30 17:53:30 +08:00

474 lines
14 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, useMemo } from 'react';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
Clipboard,
FlatList,
RefreshControl,
} from 'react-native';
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';
import { groupService } from '../../services/groupService';
import { groupManager } from '../../stores/groupManager';
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.default,
padding: spacing.md,
},
heroCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
padding: spacing.lg,
marginBottom: spacing.md,
borderWidth: 0.5,
borderColor: colors.divider + '40',
},
heroIconWrap: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.primary.light + '15',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
heroTitle: {
marginBottom: spacing.xs,
fontWeight: '800',
fontSize: fontSizes.xl + 1,
letterSpacing: 0.3,
},
tip: {
lineHeight: 22,
fontWeight: '400',
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
padding: spacing.lg,
flex: 1,
borderWidth: 0.5,
borderColor: colors.divider + '40',
},
label: {
marginBottom: spacing.xs,
fontWeight: '700',
fontSize: fontSizes.sm + 1,
},
input: {
flex: 1,
borderWidth: 1.5,
borderColor: colors.divider + '80',
borderRadius: borderRadius.lg,
backgroundColor: colors.background.default,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
color: colors.text.primary,
fontSize: fontSizes.md,
},
searchRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.md,
},
searchBtn: {
width: 46,
height: 46,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
},
searchResultSection: {
marginBottom: spacing.md,
},
sectionTitle: {
marginBottom: spacing.sm,
fontWeight: '800',
fontSize: fontSizes.md + 1,
letterSpacing: 0.3,
},
listSection: {
flex: 1,
},
groupCard: {
borderWidth: 0.5,
borderColor: colors.divider + '40',
borderRadius: borderRadius.lg,
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: '700',
fontSize: fontSizes.md + 1,
letterSpacing: 0.3,
},
groupDesc: {
marginTop: spacing.sm,
lineHeight: 20,
fontWeight: '400',
},
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 + '15',
},
copyBtnText: {
marginLeft: 4,
fontWeight: '600',
},
submitBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
minHeight: 46,
},
submitBtnDisabled: {
opacity: 0.5,
},
submitText: {
marginLeft: spacing.xs,
fontWeight: '700',
fontSize: fontSizes.md,
},
emptyText: {
marginTop: spacing.sm,
textAlign: 'center',
fontWeight: '500',
},
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
fontWeight: '500',
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);
// 使用游标分页 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 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>
);
};
export default JoinGroupScreen;