Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
496 lines
14 KiB
TypeScript
496 lines
14 KiB
TypeScript
/**
|
|
* FollowListScreen 关注/粉丝列表页面(响应式适配)
|
|
* 显示用户的关注列表或粉丝列表
|
|
* 支持互关状态显示和关注/回关操作
|
|
* 在宽屏下使用网格布局
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
FlatList,
|
|
StyleSheet,
|
|
RefreshControl,
|
|
TouchableOpacity,
|
|
ListRenderItem,
|
|
ActivityIndicator,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
|
import { User } from '../../types';
|
|
import { useAuthStore, useUserStore } from '../../stores';
|
|
import { authService } from '../../services';
|
|
import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
|
import { HomeStackParamList } from '../../navigation/types';
|
|
import { useResponsive, useColumnCount } from '../../hooks';
|
|
|
|
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'FollowList'>;
|
|
type FollowListRouteProp = RouteProp<HomeStackParamList, 'FollowList'>;
|
|
|
|
const FollowListScreen: React.FC = () => {
|
|
const navigation = useNavigation<NavigationProp>();
|
|
const route = useRoute<FollowListRouteProp>();
|
|
const { userId, type } = route.params;
|
|
|
|
const { currentUser } = useAuthStore();
|
|
const { followUser, unfollowUser } = useUserStore();
|
|
|
|
// 响应式布局
|
|
const { isWideScreen, isDesktop, width } = useResponsive();
|
|
const columnCount = useColumnCount({
|
|
xs: 1,
|
|
sm: 1,
|
|
md: 2,
|
|
lg: 2,
|
|
xl: 3,
|
|
'2xl': 3,
|
|
'3xl': 4,
|
|
'4xl': 4,
|
|
});
|
|
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
|
|
const isCurrentUser = currentUser?.id === userId;
|
|
const title = type === 'following' ? '关注' : '粉丝';
|
|
|
|
// 加载用户列表
|
|
const loadUsers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
|
|
if (!hasMore && !refresh) return;
|
|
|
|
try {
|
|
const pageSize = 20;
|
|
let userList: User[] = [];
|
|
|
|
if (type === 'following') {
|
|
userList = await authService.getFollowingList(userId, pageNum, pageSize);
|
|
} else {
|
|
userList = await authService.getFollowersList(userId, pageNum, pageSize);
|
|
}
|
|
|
|
if (refresh) {
|
|
setUsers(userList);
|
|
setPage(1);
|
|
} else {
|
|
setUsers(prev => [...prev, ...userList]);
|
|
}
|
|
|
|
setHasMore(userList.length === pageSize);
|
|
} catch (error) {
|
|
console.error('加载用户列表失败:', error);
|
|
}
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}, [userId, type, hasMore]);
|
|
|
|
// 初始加载
|
|
useEffect(() => {
|
|
loadUsers(1, true);
|
|
}, [userId, type]);
|
|
|
|
// 下拉刷新
|
|
const onRefresh = useCallback(() => {
|
|
setRefreshing(true);
|
|
setHasMore(true);
|
|
loadUsers(1, true);
|
|
}, [loadUsers]);
|
|
|
|
// 加载更多
|
|
const loadMore = useCallback(() => {
|
|
if (!loading && hasMore) {
|
|
const nextPage = page + 1;
|
|
setPage(nextPage);
|
|
loadUsers(nextPage);
|
|
}
|
|
}, [loading, hasMore, page, loadUsers]);
|
|
|
|
// 关注/取消关注
|
|
const handleFollowToggle = async (user: User) => {
|
|
const isFollowing = user.is_following ?? false;
|
|
|
|
// 乐观更新
|
|
setUsers(prev => prev.map(u => {
|
|
if (u.id === user.id) {
|
|
return {
|
|
...u,
|
|
is_following: !isFollowing,
|
|
followers_count: isFollowing ? u.followers_count - 1 : u.followers_count + 1,
|
|
};
|
|
}
|
|
return u;
|
|
}));
|
|
|
|
if (isFollowing) {
|
|
await unfollowUser(user.id);
|
|
} else {
|
|
await followUser(user.id);
|
|
}
|
|
};
|
|
|
|
// 跳转到用户主页
|
|
const handleUserPress = (targetUserId: string) => {
|
|
if (targetUserId !== currentUser?.id) {
|
|
navigation.push('UserProfile', { userId: targetUserId });
|
|
}
|
|
};
|
|
|
|
// 获取按钮状态
|
|
const getButtonConfig = (user: User): { title: string; variant: 'primary' | 'outline' } => {
|
|
const isFollowing = user.is_following ?? false;
|
|
const isFollowingMe = user.is_following_me ?? false;
|
|
|
|
if (isFollowing && isFollowingMe) {
|
|
// 已互关
|
|
return { title: '互相关注', variant: 'outline' };
|
|
} else if (isFollowing) {
|
|
// 已关注但对方未回关
|
|
return { title: '已关注', variant: 'outline' };
|
|
} else if (isFollowingMe) {
|
|
// 对方关注了我,但我没关注对方
|
|
return { title: '回关', variant: 'primary' };
|
|
} else {
|
|
// 互不关注
|
|
return { title: '关注', variant: 'primary' };
|
|
}
|
|
};
|
|
|
|
// 渲染用户项 - 列表模式(移动端)
|
|
const renderUserListItem: ListRenderItem<User> = ({ item }) => {
|
|
const buttonConfig = getButtonConfig(item);
|
|
const isSelf = item.id === currentUser?.id;
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={styles.userItem}
|
|
onPress={() => handleUserPress(item.id)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Avatar
|
|
source={item.avatar}
|
|
size={52}
|
|
name={item.nickname}
|
|
/>
|
|
<View style={styles.userInfo}>
|
|
<Text variant="body" style={styles.nickname} numberOfLines={1}>
|
|
{item.nickname}
|
|
</Text>
|
|
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
|
@{item.username}
|
|
</Text>
|
|
{item.bio && (
|
|
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.bio}>
|
|
{item.bio}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
{!isSelf && (
|
|
<Button
|
|
title={buttonConfig.title}
|
|
variant={buttonConfig.variant}
|
|
size="sm"
|
|
onPress={() => handleFollowToggle(item)}
|
|
style={styles.followButton}
|
|
/>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
// 渲染用户卡片 - 网格模式(宽屏)
|
|
const renderUserGridItem: ListRenderItem<User> = ({ item }) => {
|
|
const buttonConfig = getButtonConfig(item);
|
|
const isSelf = item.id === currentUser?.id;
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={styles.userCard}
|
|
onPress={() => handleUserPress(item.id)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={styles.userCardHeader}>
|
|
<Avatar
|
|
source={item.avatar}
|
|
size={56}
|
|
name={item.nickname}
|
|
/>
|
|
</View>
|
|
<View style={styles.userCardContent}>
|
|
<Text variant="body" style={styles.userCardNickname} numberOfLines={1}>
|
|
{item.nickname}
|
|
</Text>
|
|
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
|
@{item.username}
|
|
</Text>
|
|
{item.bio && (
|
|
<Text variant="caption" color={colors.text.hint} numberOfLines={2} style={styles.userCardBio}>
|
|
{item.bio}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
{!isSelf && (
|
|
<View style={styles.userCardFooter}>
|
|
<Button
|
|
title={buttonConfig.title}
|
|
variant={buttonConfig.variant}
|
|
size="sm"
|
|
onPress={() => handleFollowToggle(item)}
|
|
style={styles.userCardFollowButton}
|
|
/>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
// 渲染空状态
|
|
const renderEmpty = () => {
|
|
if (loading) return <Loading />;
|
|
|
|
const emptyText = type === 'following'
|
|
? '还没有关注任何人'
|
|
: '还没有粉丝';
|
|
const emptyDesc = type === 'following'
|
|
? '去发现更多有趣的用户吧'
|
|
: '发布更多优质内容来吸引粉丝吧';
|
|
|
|
return (
|
|
<EmptyState
|
|
title={emptyText}
|
|
description={emptyDesc}
|
|
icon={type === 'following' ? 'account-plus-outline' : 'account-group-outline'}
|
|
variant="modern"
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderListHeader = () => (
|
|
<View style={styles.headerCard}>
|
|
<View style={styles.headerAccent} />
|
|
<View style={styles.headerMainRow}>
|
|
<Text variant="h2" style={styles.headerTitle}>{title}</Text>
|
|
<View style={styles.headerCountPill}>
|
|
<Text variant="caption" color={colors.text.secondary}>
|
|
共 {users.length}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.headerSubtitle}>
|
|
{type === 'following' ? '你已关注的用户' : '关注你的用户'}
|
|
</Text>
|
|
</View>
|
|
);
|
|
|
|
const renderFooter = () => {
|
|
if (!hasMore || loading || users.length === 0) return null;
|
|
return (
|
|
<View style={styles.footerLoading}>
|
|
<ActivityIndicator size="small" color={colors.primary.main} />
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.footerLoadingText}>
|
|
正在加载更多
|
|
</Text>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// 宽屏使用网格布局
|
|
if (isWideScreen && columnCount > 1) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
|
<ResponsiveContainer maxWidth={1200}>
|
|
<FlatList
|
|
data={users}
|
|
renderItem={renderUserGridItem}
|
|
keyExtractor={item => item.id}
|
|
key={`grid-${columnCount}`}
|
|
numColumns={columnCount}
|
|
contentContainerStyle={[
|
|
styles.gridContent,
|
|
users.length === 0 && styles.emptyGridContent,
|
|
]}
|
|
columnWrapperStyle={styles.gridRow}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
colors={[colors.primary.main]}
|
|
tintColor={colors.primary.main}
|
|
/>
|
|
}
|
|
onEndReached={loadMore}
|
|
onEndReachedThreshold={0.3}
|
|
ListHeaderComponent={renderListHeader}
|
|
ListEmptyComponent={renderEmpty}
|
|
ListFooterComponent={renderFooter}
|
|
showsVerticalScrollIndicator={false}
|
|
/>
|
|
</ResponsiveContainer>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
// 移动端使用列表布局
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
|
<FlatList
|
|
data={users}
|
|
renderItem={renderUserListItem}
|
|
keyExtractor={item => item.id}
|
|
contentContainerStyle={styles.listContent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
colors={[colors.primary.main]}
|
|
tintColor={colors.primary.main}
|
|
/>
|
|
}
|
|
onEndReached={loadMore}
|
|
onEndReachedThreshold={0.3}
|
|
ListHeaderComponent={renderListHeader}
|
|
ListEmptyComponent={renderEmpty}
|
|
ListFooterComponent={renderFooter}
|
|
showsVerticalScrollIndicator={false}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.background.default,
|
|
},
|
|
// 列表布局样式
|
|
listContent: {
|
|
flexGrow: 1,
|
|
paddingHorizontal: spacing.md,
|
|
paddingTop: spacing.md,
|
|
paddingBottom: spacing.lg,
|
|
},
|
|
headerCard: {
|
|
backgroundColor: colors.background.paper,
|
|
borderRadius: borderRadius.xl,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.md,
|
|
marginBottom: spacing.md,
|
|
borderWidth: 1,
|
|
borderColor: colors.divider + '4A',
|
|
...shadows.sm,
|
|
},
|
|
headerAccent: {
|
|
width: 28,
|
|
height: 3,
|
|
borderRadius: 999,
|
|
backgroundColor: colors.primary.main + 'A6',
|
|
marginBottom: spacing.sm,
|
|
},
|
|
headerMainRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
headerTitle: {
|
|
fontWeight: '700',
|
|
},
|
|
headerCountPill: {
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 4,
|
|
borderRadius: borderRadius.full,
|
|
backgroundColor: colors.background.default,
|
|
},
|
|
headerSubtitle: {
|
|
marginTop: spacing.xs,
|
|
},
|
|
userItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.background.paper,
|
|
borderRadius: borderRadius.lg,
|
|
padding: spacing.md,
|
|
marginBottom: spacing.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.divider + '45',
|
|
...shadows.sm,
|
|
},
|
|
userInfo: {
|
|
flex: 1,
|
|
marginLeft: spacing.md,
|
|
marginRight: spacing.sm,
|
|
},
|
|
nickname: {
|
|
fontWeight: '600',
|
|
marginBottom: 2,
|
|
},
|
|
bio: {
|
|
marginTop: 2,
|
|
},
|
|
followButton: {
|
|
minWidth: 82,
|
|
},
|
|
// 网格布局样式
|
|
gridContent: {
|
|
flexGrow: 1,
|
|
padding: spacing.lg,
|
|
},
|
|
emptyGridContent: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
gridRow: {
|
|
justifyContent: 'flex-start',
|
|
gap: spacing.md,
|
|
marginBottom: spacing.md,
|
|
},
|
|
userCard: {
|
|
flex: 1,
|
|
backgroundColor: colors.background.paper,
|
|
borderRadius: borderRadius.xl,
|
|
padding: spacing.md,
|
|
alignItems: 'center',
|
|
borderWidth: 1,
|
|
borderColor: colors.divider + '45',
|
|
...shadows.sm,
|
|
},
|
|
userCardHeader: {
|
|
marginBottom: spacing.sm,
|
|
},
|
|
userCardContent: {
|
|
alignItems: 'center',
|
|
width: '100%',
|
|
},
|
|
userCardNickname: {
|
|
fontWeight: '600',
|
|
marginBottom: 2,
|
|
textAlign: 'center',
|
|
},
|
|
userCardBio: {
|
|
marginTop: spacing.xs,
|
|
textAlign: 'center',
|
|
},
|
|
userCardFooter: {
|
|
marginTop: spacing.md,
|
|
width: '100%',
|
|
},
|
|
userCardFollowButton: {
|
|
width: '100%',
|
|
},
|
|
footerLoading: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: spacing.md,
|
|
gap: spacing.xs,
|
|
},
|
|
footerLoadingText: {
|
|
marginLeft: spacing.xs,
|
|
},
|
|
});
|
|
|
|
export default FollowListScreen;
|