Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
495
src/screens/profile/FollowListScreen.tsx
Normal file
495
src/screens/profile/FollowListScreen.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user