Files
frontend/src/screens/profile/FollowListScreen.tsx

556 lines
16 KiB
TypeScript
Raw Normal View History

/**
* FollowListScreen /
*
* /
* 使
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
FlatList,
StyleSheet,
RefreshControl,
TouchableOpacity,
ListRenderItem,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { spacing, borderRadius, shadows, useAppColors, type AppColors } 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 { SearchBar } from '../../components/business';
import * as hrefs from '../../navigation/hrefs';
import { useResponsive, useColumnCount } from '../../hooks';
const FollowListScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createFollowListStyles(colors), [colors]);
const router = useRouter();
const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>();
const type = typeParam === 'followers' ? 'followers' : 'following';
const currentUser = useAuthStore((s) => s.currentUser);
const { followUser, unfollowUser } = useUserStore.getState();
// 响应式布局
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 [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isCurrentUser = currentUser?.id === userId;
const title = type === 'following' ? '关注' : '粉丝';
const isSearching = debouncedKeyword.trim().length > 0;
// 加载用户列表
const loadUsers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
if (!hasMore && !refresh) return;
try {
const pageSize = 20;
let userList: User[] = [];
const trimmedKeyword = debouncedKeyword.trim();
if (type === 'following') {
userList = await authService.getFollowingList(userId, pageNum, pageSize, trimmedKeyword);
} else {
userList = await authService.getFollowersList(userId, pageNum, pageSize, trimmedKeyword);
}
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, debouncedKeyword]);
// 初始加载(包括 type/userId 切换以及防抖后关键词变化)
useEffect(() => {
setLoading(true);
setHasMore(true);
loadUsers(1, true);
// 仅在 userId/type/debouncedKeyword 变化时重新加载
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, type, debouncedKeyword]);
// 关键词输入防抖300ms
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setDebouncedKeyword(keyword);
}, 300);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
};
}, [keyword]);
// 下拉刷新
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) {
router.push(hrefs.hrefUserProfile(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?.trim() ? (
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.bio}>
{item.bio}
</Text>
) : null}
</View>
{isCurrentUser && !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?.trim() ? (
<Text variant="caption" color={colors.text.hint} numberOfLines={2} style={styles.userCardBio}>
{item.bio}
</Text>
) : null}
</View>
{isCurrentUser && !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 />;
if (isSearching) {
return (
<EmptyState
title="没有找到匹配的用户"
description="换个关键词试试"
icon="account-search-outline"
variant="modern"
/>
);
}
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>
{!isSearching ? (
<View style={styles.headerCountPill}>
<Text variant="caption" color={colors.text.secondary}>
{users.length}
</Text>
</View>
) : (
<View style={styles.headerCountPill}>
<Text variant="caption" color={colors.text.secondary}>
</Text>
</View>
)}
</View>
<Text variant="caption" color={colors.text.secondary} style={styles.headerSubtitle}>
{type === 'following'
? (isCurrentUser ? '你已关注的用户' : 'TA关注的用户')
: (isCurrentUser ? '关注你的用户' : 'TA的粉丝')}
</Text>
<View style={styles.headerSearchWrap}>
<SearchBar
value={keyword}
onChangeText={setKeyword}
onSubmit={() => setDebouncedKeyword(keyword)}
placeholder={type === 'following' ? '搜索关注' : '搜索粉丝'}
compact
/>
</View>
</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={['top', '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={['top', '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>
);
};
function createFollowListStyles(colors: AppColors) {
return 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,
},
headerSearchWrap: {
marginTop: spacing.md,
},
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;