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:
2026-03-09 21:29:03 +08:00
commit 3968660048
129 changed files with 55599 additions and 0 deletions

View File

@@ -0,0 +1,447 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks';
import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService';
import { useAuthStore } from '../../stores';
export const AccountSecurityScreen: React.FC = () => {
const { isWideScreen } = useResponsive();
const currentUser = useAuthStore((state) => state.currentUser);
const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser);
const [email, setEmail] = useState(currentUser?.email || '');
const [verificationCode, setVerificationCode] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [verifyingEmail, setVerifyingEmail] = useState(false);
const [countdown, setCountdown] = useState(0);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [changePasswordCode, setChangePasswordCode] = useState('');
const [sendingChangePwdCode, setSendingChangePwdCode] = useState(false);
const [changePwdCountdown, setChangePwdCountdown] = useState(0);
const [updatingPassword, setUpdatingPassword] = useState(false);
useEffect(() => {
if (currentUser?.email) {
setEmail(currentUser.email);
}
}, [currentUser?.email]);
useEffect(() => {
if (countdown <= 0) {
return;
}
const timer = setInterval(() => {
setCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [countdown]);
useEffect(() => {
if (changePwdCountdown <= 0) {
return;
}
const timer = setInterval(() => {
setChangePwdCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [changePwdCountdown]);
const isEmailVerified = !!currentUser?.email_verified;
const emailStatusText = useMemo(() => {
if (!currentUser?.email) {
return '未绑定邮箱';
}
return isEmailVerified ? '已验证' : '未验证';
}, [currentUser?.email, isEmailVerified]);
const validateEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const handleSendCode = async () => {
const targetEmail = email.trim();
if (!validateEmail(targetEmail)) {
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
return;
}
if (countdown > 0) {
return;
}
setSendingCode(true);
try {
const ok = await authService.sendCurrentUserEmailVerifyCode(targetEmail);
if (ok) {
setCountdown(60);
showPrompt({ title: '发送成功', message: '验证码已发送,请查收邮箱', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
} finally {
setSendingCode(false);
}
};
const handleVerifyEmail = async () => {
const targetEmail = email.trim();
if (!validateEmail(targetEmail)) {
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
return;
}
if (!verificationCode.trim()) {
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
return;
}
setVerifyingEmail(true);
try {
const ok = await authService.verifyCurrentUserEmail({
email: targetEmail,
verification_code: verificationCode.trim(),
});
if (ok) {
setVerificationCode('');
await fetchCurrentUser();
showPrompt({ title: '验证成功', message: '邮箱已完成验证', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '验证失败', message: resolveAuthApiError(error, '邮箱验证失败'), type: 'error' });
} finally {
setVerifyingEmail(false);
}
};
const handleChangePassword = async () => {
if (!oldPassword) {
showPrompt({ title: '提示', message: '请输入当前密码', type: 'warning' });
return;
}
if (!changePasswordCode.trim()) {
showPrompt({ title: '提示', message: '请输入邮箱验证码', type: 'warning' });
return;
}
if (newPassword.length < 6) {
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' });
return;
}
if (newPassword !== confirmPassword) {
showPrompt({ title: '提示', message: '两次输入的新密码不一致', type: 'warning' });
return;
}
setUpdatingPassword(true);
try {
const ok = await authService.changePassword(oldPassword, newPassword, changePasswordCode.trim());
if (ok) {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setChangePasswordCode('');
showPrompt({ title: '修改成功', message: '密码已更新', type: 'success' });
} else {
showPrompt({ title: '修改失败', message: '请检查当前密码后重试', type: 'error' });
}
} catch (error: any) {
showPrompt({ title: '修改失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
} finally {
setUpdatingPassword(false);
}
};
const handleSendChangePasswordCode = async () => {
if (changePwdCountdown > 0) {
return;
}
setSendingChangePwdCode(true);
try {
const ok = await authService.sendChangePasswordCode();
if (ok) {
setChangePwdCountdown(60);
showPrompt({ title: '发送成功', message: '改密验证码已发送到你的邮箱', type: 'success' });
}
} catch (error: any) {
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
} finally {
setSendingChangePwdCode(false);
}
};
const content = (
<>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons name="email-check-outline" size={18} color={colors.primary.main} />
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
</View>
<View style={styles.card}>
<View style={styles.statusRow}>
<Text variant="body" color={colors.text.primary}></Text>
<View style={[styles.statusBadge, isEmailVerified ? styles.statusVerified : styles.statusUnverified]}>
<Text variant="caption" color={isEmailVerified ? '#1B5E20' : '#B26A00'}>
{emailStatusText}
</Text>
</View>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="email-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="请输入邮箱"
placeholderTextColor={colors.text.hint}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="验证码"
placeholderTextColor={colors.text.hint}
value={verificationCode}
onChangeText={setVerificationCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.buttonDisabled]}
onPress={handleSendCode}
disabled={sendingCode || countdown > 0}
>
{sendingCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.primaryButton, verifyingEmail && styles.buttonDisabled]}
onPress={handleVerifyEmail}
disabled={verifyingEmail}
>
{verifyingEmail ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.primaryButtonText}></Text>}
</TouchableOpacity>
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons name="lock-reset" size={18} color={colors.primary.main} />
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
</View>
<View style={styles.card}>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="当前密码"
placeholderTextColor={colors.text.hint}
value={oldPassword}
onChangeText={setOldPassword}
secureTextEntry
/>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-plus-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="新密码至少6位"
placeholderTextColor={colors.text.hint}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
</View>
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="邮箱验证码"
placeholderTextColor={colors.text.hint}
value={changePasswordCode}
onChangeText={setChangePasswordCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingChangePwdCode || changePwdCountdown > 0) && styles.buttonDisabled]}
onPress={handleSendChangePasswordCode}
disabled={sendingChangePwdCode || changePwdCountdown > 0}
>
{sendingChangePwdCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>
{changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'}
</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-check-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="确认新密码"
placeholderTextColor={colors.text.hint}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.primaryButton, updatingPassword && styles.buttonDisabled]}
onPress={handleChangePassword}
disabled={updatingPassword}
>
{updatingPassword ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.primaryButtonText}></Text>}
</TouchableOpacity>
</View>
</View>
</>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
scrollContent: {
paddingVertical: spacing.md,
},
section: {
marginBottom: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
marginBottom: spacing.sm,
gap: spacing.xs,
},
sectionTitle: {
fontWeight: '600',
},
card: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
padding: spacing.md,
},
statusRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
statusBadge: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.sm,
},
statusVerified: {
backgroundColor: '#E8F5E9',
},
statusUnverified: {
backgroundColor: '#FFF3E0',
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 48,
marginBottom: spacing.sm,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
color: colors.text.primary,
fontSize: fontSizes.md,
},
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm,
},
codeInput: {
flex: 1,
marginBottom: 0,
},
sendCodeButton: {
height: 48,
minWidth: 110,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
},
sendCodeButtonText: {
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
primaryButton: {
height: 48,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.xs,
},
primaryButtonText: {
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '700',
},
buttonDisabled: {
opacity: 0.6,
},
});
export default AccountSecurityScreen;

View File

@@ -0,0 +1,177 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
FlatList,
RefreshControl,
StyleSheet,
TouchableOpacity,
View,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common';
import { authService } from '../../services';
import { colors, spacing, borderRadius, shadows } from '../../theme';
import { User } from '../../types';
import { RootStackParamList } from '../../navigation/types';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export const BlockedUsersScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [processingUserId, setProcessingUserId] = useState<string | null>(null);
const loadBlockedUsers = useCallback(async () => {
try {
const response = await authService.getBlockedUsers(1, 100);
setUsers(response.list || []);
} catch (error) {
console.error('加载黑名单失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadBlockedUsers();
}, [loadBlockedUsers]);
const onRefresh = useCallback(() => {
setRefreshing(true);
loadBlockedUsers();
}, [loadBlockedUsers]);
const handleUnblock = useCallback((user: User) => {
Alert.alert(
'取消拉黑',
`确定取消拉黑 ${user.nickname} 吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
onPress: async () => {
try {
setProcessingUserId(user.id);
const ok = await authService.unblockUser(user.id);
if (!ok) {
Alert.alert('失败', '取消拉黑失败,请稍后重试');
return;
}
setUsers(prev => prev.filter(u => u.id !== user.id));
} finally {
setProcessingUserId(null);
}
},
},
]
);
}, []);
const renderItem = ({ item }: { item: User }) => (
<TouchableOpacity
style={styles.item}
activeOpacity={0.75}
onPress={() => navigation.navigate('UserProfile', { userId: item.id })}
>
<Avatar source={item.avatar} size={46} name={item.nickname} />
<View style={styles.content}>
<Text variant="body" style={styles.nickname} numberOfLines={1}>
{item.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
@{item.username}
</Text>
</View>
<Button
title={processingUserId === item.id ? '处理中...' : '取消拉黑'}
size="sm"
variant="outline"
onPress={() => handleUnblock(item)}
disabled={processingUserId === item.id}
/>
</TouchableOpacity>
);
if (loading) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingWrap}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ResponsiveContainer maxWidth={900}>
<FlatList
data={users}
keyExtractor={item => item.id}
renderItem={renderItem}
contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
ListEmptyComponent={
<EmptyState
title="黑名单为空"
description="你还没有拉黑任何用户"
icon="account-off-outline"
variant="modern"
/>
}
/>
</ResponsiveContainer>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
loadingWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
listContent: {
padding: spacing.md,
gap: spacing.sm,
},
emptyContent: {
flexGrow: 1,
justifyContent: 'center',
},
item: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.md,
...shadows.sm,
},
content: {
flex: 1,
marginLeft: spacing.md,
marginRight: spacing.sm,
},
nickname: {
fontWeight: '600',
},
});
export default BlockedUsersScreen;

View File

@@ -0,0 +1,791 @@
/**
* 编辑资料页 EditProfileScreen响应式适配
* 胡萝卜BBS - 编辑用户资料
* 与用户资料页样式完全一致
* 表单在宽屏下居中显示
*/
import React, { useState } from 'react';
import {
View,
ScrollView,
StyleSheet,
TouchableOpacity,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
Image,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
import { useAuthStore } from '../../stores';
import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common';
import { authService, uploadService } from '../../services';
import { useResponsive } from '../../hooks';
// 表单输入项组件
interface FormFieldProps {
icon: string;
label: string;
value: string;
onChangeText: (text: string) => void;
placeholder: string;
maxLength?: number;
multiline?: boolean;
numberOfLines?: number;
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'url';
editable?: boolean;
}
const FormField: React.FC<FormFieldProps> = ({
icon,
label,
value,
onChangeText,
placeholder,
maxLength,
multiline = false,
numberOfLines = 1,
autoCapitalize = 'sentences',
keyboardType = 'default',
editable = true,
}) => {
return (
<View style={styles.formField}>
<View style={styles.fieldIconContainer}>
<MaterialCommunityIcons name={icon as any} size={22} color={colors.primary.main} />
</View>
<View style={styles.fieldContent}>
<Text variant="caption" style={styles.fieldLabel}>{label}</Text>
<TextInput
style={[
styles.fieldInput,
multiline && styles.textArea,
!editable && styles.disabledInput
]}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
maxLength={maxLength}
multiline={multiline}
numberOfLines={numberOfLines}
autoCapitalize={autoCapitalize}
keyboardType={keyboardType}
editable={editable}
/>
</View>
</View>
);
};
export const EditProfileScreen: React.FC = () => {
const navigation = useNavigation();
const { currentUser, updateUser } = useAuthStore();
const { isWideScreen, isMobile, width } = useResponsive();
const insets = useSafeAreaInsets();
const [nickname, setNickname] = useState(currentUser?.nickname || '');
const [bio, setBio] = useState(currentUser?.bio || '');
const [location, setLocation] = useState(currentUser?.location || '');
const [website, setWebsite] = useState(currentUser?.website || '');
const [phone, setPhone] = useState(currentUser?.phone || '');
const [email, setEmail] = useState(currentUser?.email || '');
const [avatar, setAvatar] = useState(currentUser?.avatar || '');
const [coverUrl, setCoverUrl] = useState(currentUser?.cover_url || '');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false);
const [saving, setSaving] = useState(false);
const bottomSafeDistance = isMobile ? insets.bottom + 92 : spacing.xl;
// 根据屏幕宽度计算封面高度
const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16;
// 选择头图
const handlePickCover = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择头图');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [16, 9],
quality: 0.9,
});
if (!result.canceled && result.assets[0]) {
const selectedImage = result.assets[0];
setCoverUrl(selectedImage.uri);
try {
setUploadingCover(true);
const uploadResult = await uploadService.uploadCover({
uri: selectedImage.uri,
name: selectedImage.fileName || 'cover.jpg',
type: selectedImage.mimeType || 'image/jpeg',
});
if (uploadResult) {
updateUser({ cover_url: uploadResult.url });
Alert.alert('成功', '头图已更新');
} else {
Alert.alert('错误', '头图上传失败,请重试');
}
} catch (error) {
console.error('上传头图失败:', error);
Alert.alert('错误', '头图上传失败,请重试');
} finally {
setUploadingCover(false);
}
}
};
// 选择头像
const handlePickAvatar = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择头像');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const selectedImage = result.assets[0];
setAvatar(selectedImage.uri);
try {
setUploadingAvatar(true);
const uploadResult = await uploadService.uploadAvatar({
uri: selectedImage.uri,
name: selectedImage.fileName || 'avatar.jpg',
type: selectedImage.mimeType || 'image/jpeg',
});
if (uploadResult) {
updateUser({ avatar: uploadResult.url });
Alert.alert('成功', '头像已更新');
} else {
Alert.alert('错误', '头像上传失败,请重试');
}
} catch (error) {
console.error('上传头像失败:', error);
Alert.alert('错误', '头像上传失败,请重试');
} finally {
setUploadingAvatar(false);
}
}
};
// 保存资料
const handleSave = async () => {
if (!nickname.trim()) {
Alert.alert('错误', '昵称不能为空');
return;
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
Alert.alert('错误', '请输入正确的邮箱地址');
return;
}
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
Alert.alert('错误', '请输入正确的手机号');
return;
}
setSaving(true);
try {
const updatedUser = await authService.updateUser({
nickname: nickname.trim(),
bio: bio.trim() || undefined,
location: location.trim() || undefined,
website: website.trim() || undefined,
phone: phone.trim() || undefined,
email: email.trim() || undefined,
});
if (updatedUser) {
updateUser({
nickname: nickname.trim(),
bio: bio.trim() || null,
location: location.trim() || null,
website: website.trim() || null,
phone: phone.trim() || null,
email: email.trim() || null,
});
Alert.alert('成功', '资料已更新', [
{ text: '确定', onPress: () => navigation.goBack() }
]);
} else {
Alert.alert('错误', '保存失败,请重试');
}
} catch (error) {
console.error('保存资料失败:', error);
Alert.alert('错误', '保存失败,请重试');
} finally {
setSaving(false);
}
};
// 渲染表单内容
const renderFormContent = () => (
<>
{/* ===== 用户资料预览区域 - 与 UserProfileHeader 完全一致 ===== */}
<View style={styles.previewContainer}>
{/* 封面背景 */}
<View style={[styles.coverContainer, { height: coverHeight }]}>
<TouchableOpacity
style={styles.coverTouchable}
onPress={handlePickCover}
activeOpacity={0.8}
>
{coverUrl ? (
<Image
source={{ uri: coverUrl }}
style={styles.coverImage}
resizeMode="cover"
/>
) : (
<LinearGradient
colors={['#FF8F66', '#FF6B35', '#E5521D']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
/>
)}
{/* 头图编辑蒙版 */}
{uploadingCover ? (
<View style={styles.coverUploadingOverlay}>
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} />
</View>
) : (
<View style={styles.coverEditOverlay}>
<MaterialCommunityIcons name="camera" size={28} color={colors.text.inverse} />
<Text style={styles.coverEditText}></Text>
</View>
)}
</TouchableOpacity>
{/* 装饰性波浪 */}
<View style={styles.waveDecoration}>
<View style={styles.wave} />
</View>
</View>
{/* 用户信息卡片 */}
<View style={styles.profileCard}>
{/* 悬浮头像 */}
<View style={styles.avatarWrapper}>
<TouchableOpacity
style={styles.avatarContainer}
onPress={handlePickAvatar}
activeOpacity={0.8}
>
<Avatar
source={avatar || null}
size={isWideScreen ? 110 : 90}
name={nickname}
/>
{uploadingAvatar && (
<View style={styles.avatarUploadingOverlay}>
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} />
</View>
)}
<View style={styles.editAvatarButton}>
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
</View>
</TouchableOpacity>
</View>
{/* 用户名和简介 */}
<View style={styles.userInfo}>
<Text variant="h2" style={styles.nickname}>
{nickname || currentUser?.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
@{currentUser?.username}
</Text>
{bio ? (
<Text variant="body" color={colors.text.secondary} style={styles.bio}>
{bio}
</Text>
) : (
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
~
</Text>
)}
</View>
{/* 个人信息标签 */}
<View style={styles.metaInfo}>
{location ? (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
{location}
</Text>
</View>
) : (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.metaTagText}>
</Text>
</View>
)}
{website ? (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
{website.replace(/^https?:\/\//, '')}
</Text>
</View>
) : (
<View style={styles.metaTag}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.metaTagText}>
</Text>
</View>
)}
<View style={styles.metaTag}>
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
{new Date(currentUser?.created_at || Date.now()).getFullYear()}
</Text>
</View>
</View>
{/* 编辑提示 */}
<View style={styles.editHint}>
<MaterialCommunityIcons name="information-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.editHintText}>
</Text>
</View>
</View>
</View>
{/* ===== 表单区域 ===== */}
<View style={styles.formCard}>
<Text variant="body" style={[styles.sectionTitle, { fontWeight: '600' }]}>
</Text>
<FormField
icon="account-outline"
label="昵称"
value={nickname}
onChangeText={setNickname}
placeholder="请输入昵称"
maxLength={20}
/>
<View style={styles.divider} />
<FormField
icon="information-outline"
label="个人简介"
value={bio}
onChangeText={setBio}
placeholder="介绍一下自己,让大家更了解你"
maxLength={100}
multiline
numberOfLines={3}
/>
<View style={styles.divider} />
<FormField
icon="map-marker-outline"
label="地区"
value={location}
onChangeText={setLocation}
placeholder="你所在的城市"
maxLength={30}
/>
<View style={styles.divider} />
<FormField
icon="link-variant"
label="个人网站"
value={website}
onChangeText={setWebsite}
placeholder="https://example.com"
maxLength={100}
autoCapitalize="none"
keyboardType="url"
/>
</View>
{/* 联系方式区域 */}
<View style={styles.formCard}>
<Text variant="body" style={[styles.sectionTitle, { fontWeight: '600' }]}>
</Text>
<FormField
icon="phone-outline"
label="手机号"
value={phone}
onChangeText={setPhone}
placeholder="请输入手机号"
maxLength={11}
keyboardType="phone-pad"
/>
<View style={styles.divider} />
<FormField
icon="email-outline"
label="邮箱"
value={email}
onChangeText={setEmail}
placeholder="请输入邮箱地址"
maxLength={100}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
{/* 保存按钮 */}
<View style={[styles.buttonContainer, { marginBottom: bottomSafeDistance }]}>
<TouchableOpacity
style={[
styles.saveButton,
(saving || uploadingAvatar || uploadingCover) && styles.saveButtonDisabled
]}
onPress={handleSave}
disabled={saving || uploadingAvatar || uploadingCover}
activeOpacity={0.8}
>
<View style={styles.saveButtonContent}>
{saving ? (
<MaterialCommunityIcons
name="loading"
size={20}
color={colors.text.inverse}
style={styles.buttonIcon}
/>
) : (
<MaterialCommunityIcons
name="check"
size={20}
color={colors.text.inverse}
style={styles.buttonIcon}
/>
)}
<Text variant="body" style={[styles.saveButtonText, { fontWeight: '600' }]}>
{saving ? '保存中...' : '保存修改'}
</Text>
</View>
</TouchableOpacity>
</View>
</>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
)}
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
keyboardView: {
flex: 1,
},
scrollContent: {
padding: spacing.lg,
},
// ===== 预览区域 - 与 UserProfileHeader 完全一致 =====
previewContainer: {
marginBottom: spacing.lg,
},
coverContainer: {
position: 'relative',
overflow: 'hidden',
borderRadius: borderRadius.lg,
},
coverTouchable: {
width: '100%',
height: '100%',
},
coverImage: {
width: '100%',
height: '100%',
},
gradient: {
width: '100%',
height: '100%',
},
coverUploadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '60%',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditText: {
color: colors.text.inverse,
fontSize: fontSizes.sm,
marginTop: spacing.xs,
fontWeight: '500',
},
waveDecoration: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
},
wave: {
width: '100%',
height: '100%',
backgroundColor: colors.background.paper,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
},
// 用户信息卡片
profileCard: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.md,
marginTop: -50,
borderRadius: borderRadius.xl,
padding: spacing.lg,
...shadows.md,
},
avatarWrapper: {
alignItems: 'center',
marginTop: -60,
marginBottom: spacing.md,
},
avatarContainer: {
position: 'relative',
padding: 4,
backgroundColor: colors.background.paper,
borderRadius: 50,
},
avatarUploadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
},
editAvatarButton: {
position: 'absolute',
bottom: 0,
right: 0,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
},
userInfo: {
alignItems: 'center',
marginBottom: spacing.md,
},
nickname: {
marginBottom: spacing.xs,
fontWeight: '700',
},
username: {
marginBottom: spacing.sm,
},
bio: {
textAlign: 'center',
marginTop: spacing.sm,
lineHeight: 20,
},
bioPlaceholder: {
textAlign: 'center',
marginTop: spacing.sm,
fontStyle: 'italic',
},
metaInfo: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
marginBottom: spacing.md,
gap: spacing.sm,
},
metaTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
},
metaTagText: {
marginLeft: spacing.xs,
fontSize: fontSizes.xs,
},
editHint: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.md,
paddingVertical: spacing.sm,
gap: spacing.xs,
},
editHintText: {
fontSize: fontSizes.xs,
},
// ===== 表单区域 =====
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.lg,
shadowColor: colors.text.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
sectionTitle: {
marginBottom: spacing.lg,
},
formField: {
flexDirection: 'row',
alignItems: 'flex-start',
},
fieldIconContainer: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
marginTop: spacing.xs,
},
fieldContent: {
flex: 1,
},
fieldLabel: {
marginBottom: spacing.xs,
fontWeight: '500',
},
fieldInput: {
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
disabledInput: {
color: colors.text.disabled,
},
divider: {
height: 1,
backgroundColor: colors.divider,
marginVertical: spacing.md,
marginLeft: 56,
},
// 保存按钮
buttonContainer: {
marginTop: spacing.sm,
},
saveButton: {
backgroundColor: colors.primary.main,
borderRadius: borderRadius.lg,
paddingVertical: spacing.md,
shadowColor: colors.primary.main,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonContent: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
buttonIcon: {
marginRight: spacing.sm,
},
saveButtonText: {
color: colors.text.inverse,
},
});
export default EditProfileScreen;

View 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;

View File

@@ -0,0 +1,234 @@
/**
* 通知设置页 NotificationSettingsScreen响应式适配
* 胡萝卜BBS - 通知相关设置
* 在宽屏下居中显示
*/
import React, { useState, useEffect } from 'react';
import {
View,
StyleSheet,
Switch,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common';
import { setVibrationEnabled } from '../../services/backgroundService';
import { useResponsive } from '../../hooks';
const VIBRATION_ENABLED_KEY = 'vibration_enabled';
interface NotificationSettingItem {
key: string;
title: string;
subtitle?: string;
icon: string;
value: boolean;
onValueChange: (value: boolean) => void;
}
export const NotificationSettingsScreen: React.FC = () => {
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
const [pushEnabled, setPushEnabled] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(true);
const { isWideScreen } = useResponsive();
// 加载设置
useEffect(() => {
const loadSettings = async () => {
try {
const vibrationStored = await AsyncStorage.getItem(VIBRATION_ENABLED_KEY);
if (vibrationStored !== null) {
const enabled = JSON.parse(vibrationStored);
setVibrationEnabledState(enabled);
setVibrationEnabled(enabled);
}
} catch (error) {
console.error('加载震动设置失败:', error);
}
};
loadSettings();
}, []);
// 切换震动
const toggleVibration = async (value: boolean) => {
try {
setVibrationEnabledState(value);
setVibrationEnabled(value);
await AsyncStorage.setItem(VIBRATION_ENABLED_KEY, JSON.stringify(value));
} catch (error) {
console.error('保存震动设置失败:', error);
}
};
const settings: NotificationSettingItem[] = [
{
key: 'push',
title: '接收推送通知',
subtitle: '接收新消息、点赞、评论等推送',
icon: 'bell-outline',
value: pushEnabled,
onValueChange: setPushEnabled,
},
{
key: 'vibration',
title: '消息震动',
subtitle: '收到新消息时震动提醒',
icon: 'vibrate',
value: vibrationEnabled,
onValueChange: toggleVibration,
},
{
key: 'sound',
title: '消息提示音',
subtitle: '收到新消息时播放提示音',
icon: 'volume-high',
value: soundEnabled,
onValueChange: setSoundEnabled,
},
];
// 渲染内容
const renderContent = () => (
<>
{/* 消息通知设置 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons name="message-text-outline" size={18} color={colors.primary.main} />
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
</View>
<View style={styles.card}>
{settings.map((item, index) => (
<View key={item.key}>
<View style={styles.settingItem}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons
name={item.icon as any}
size={20}
color={colors.primary.main}
/>
</View>
<View style={styles.settingContent}>
<Text variant="body" color={colors.text.primary}>
{item.title}
</Text>
{item.subtitle && (
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}>
{item.subtitle}
</Text>
)}
</View>
<Switch
value={item.value}
onValueChange={item.onValueChange}
trackColor={{ false: colors.divider, true: colors.primary.main }}
thumbColor={colors.background.paper}
/>
</View>
{index < settings.length - 1 && <View style={styles.divider} />}
</View>
))}
</View>
</View>
{/* 提示信息 */}
<View style={styles.tipContainer}>
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
</Text>
</View>
</>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}>
{renderContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={styles.scrollContent}>
{renderContent()}
</ScrollView>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
scrollContent: {
paddingVertical: spacing.md,
},
section: {
marginBottom: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
marginBottom: spacing.sm,
gap: spacing.xs,
},
sectionTitle: {
fontWeight: '600',
},
card: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
},
iconContainer: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
settingContent: {
flex: 1,
},
subtitle: {
marginTop: 2,
},
divider: {
height: 1,
backgroundColor: colors.divider,
marginLeft: 36 + spacing.md + spacing.md,
},
tipContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
marginHorizontal: spacing.lg,
padding: spacing.md,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
gap: spacing.sm,
},
tipText: {
flex: 1,
lineHeight: 20,
},
});
export default NotificationSettingsScreen;

View File

@@ -0,0 +1,490 @@
/**
* 个人主页 ProfileScreen - 美化版(响应式适配)
* 胡萝卜BBS - 当前用户个人主页
* 采用现代卡片式设计,优化视觉层次和交互体验
* 支持桌面端双栏布局
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
View,
StyleSheet,
RefreshControl,
Animated,
ScrollView,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { colors, spacing } from '../../theme';
import { Post } from '../../types';
import { useAuthStore, useUserStore } from '../../stores';
import { postService } from '../../services';
import { UserProfileHeader, PostCard, TabBar } from '../../components/business';
import { Loading, EmptyState, Text } from '../../components/common';
import { ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks';
import { ProfileStackParamList, HomeStackParamList, RootStackParamList } from '../../navigation/types';
type NavigationProp = NativeStackNavigationProp<ProfileStackParamList, 'Profile'>;
type HomeNavigationProp = NativeStackNavigationProp<HomeStackParamList>;
type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>;
const TABS = ['帖子', '收藏'];
const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
export const ProfileScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const insets = useSafeAreaInsets();
const tabBarHeight = useBottomTabBarHeight();
const homeNavigation = useNavigation<HomeNavigationProp>();
// 使用 any 类型来访问根导航
const rootNavigation = useNavigation<RootNavigationProp>();
const { currentUser, updateUser, fetchCurrentUser } = useAuthStore();
const { followUser, unfollowUser, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
// 响应式布局
const { isDesktop, isTablet, width } = useResponsive();
// 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡
const scrollBottomInset = useMemo(() => {
if (isDesktop) return spacing.lg;
return tabBarHeight + insets.bottom + spacing.md;
}, [isDesktop, tabBarHeight, insets.bottom]);
const [activeTab, setActiveTab] = useState(0);
const [posts, setPosts] = useState<Post[]>([]);
const [favorites, setFavorites] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const scrollY = new Animated.Value(0);
// 跳转到关注列表
const handleFollowingPress = useCallback(() => {
if (currentUser) {
(rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'following' });
}
}, [currentUser, rootNavigation]);
// 跳转到粉丝列表
const handleFollowersPress = useCallback(() => {
if (currentUser) {
(rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'followers' });
}
}, [currentUser, rootNavigation]);
// 加载用户帖子
const loadUserPosts = useCallback(async () => {
if (currentUser) {
try {
const response = await postService.getUserPosts(currentUser.id);
setPosts(response.list);
} catch (error) {
console.error('获取用户帖子失败:', error);
}
}
setLoading(false);
}, [currentUser]);
// 加载用户收藏
const loadUserFavorites = useCallback(async () => {
if (currentUser) {
try {
console.log('[ProfileScreen] load, userUserFavorites calledId:', currentUser.id);
const response = await postService.getUserFavorites(currentUser.id);
console.log('[ProfileScreen] getUserFavorites response:', response);
setFavorites(response.list);
} catch (error) {
console.error('获取用户收藏失败:', error);
}
}
setLoading(false);
}, [currentUser]);
// 监听 tab 切换,只在数据为空时加载对应数据
React.useEffect(() => {
if (activeTab === 0 && posts.length === 0) {
loadUserPosts();
} else if (activeTab === 1 && favorites.length === 0) {
loadUserFavorites();
}
}, [activeTab, loadUserPosts, loadUserFavorites, posts.length, favorites.length]);
// 初始加载
React.useEffect(() => {
loadUserPosts();
}, [loadUserPosts]);
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
React.useEffect(() => {
// 同步帖子列表状态
if (posts.length > 0) {
let hasChanges = false;
const updatedPosts = posts.map(localPost => {
const storePost = storePosts.find(sp => sp.id === localPost.id);
if (storePost && (
storePost.is_liked !== localPost.is_liked ||
storePost.is_favorited !== localPost.is_favorited ||
storePost.likes_count !== localPost.likes_count ||
storePost.favorites_count !== localPost.favorites_count
)) {
hasChanges = true;
return {
...localPost,
is_liked: storePost.is_liked,
is_favorited: storePost.is_favorited,
likes_count: storePost.likes_count,
favorites_count: storePost.favorites_count,
};
}
return localPost;
});
if (hasChanges) {
setPosts(updatedPosts);
}
}
// 同步收藏列表状态
if (favorites.length > 0) {
let hasChanges = false;
const updatedFavorites = favorites.map(localPost => {
const storePost = storePosts.find(sp => sp.id === localPost.id);
if (storePost && (
storePost.is_liked !== localPost.is_liked ||
storePost.is_favorited !== localPost.is_favorited ||
storePost.likes_count !== localPost.likes_count ||
storePost.favorites_count !== localPost.favorites_count
)) {
hasChanges = true;
return {
...localPost,
is_liked: storePost.is_liked,
is_favorited: storePost.is_favorited,
likes_count: storePost.likes_count,
favorites_count: storePost.favorites_count,
};
}
return localPost;
});
if (hasChanges) {
setFavorites(updatedFavorites);
}
}
}, [storePosts]);
// 下拉刷新
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
// 刷新用户信息
await fetchCurrentUser();
// 刷新帖子列表
await loadUserPosts();
} catch (error) {
console.error('刷新失败:', error);
} finally {
setRefreshing(false);
}
}, [fetchCurrentUser, loadUserPosts]);
// 跳转到设置页
const handleSettings = useCallback(() => {
navigation.navigate('Settings');
}, [navigation]);
// 跳转到编辑资料页
const handleEditProfile = useCallback(() => {
navigation.navigate('EditProfile');
}, [navigation]);
// 关注/取消关注
const handleFollow = useCallback(() => {
if (!currentUser) return;
if (currentUser.is_following) {
unfollowUser(currentUser.id);
} else {
followUser(currentUser.id);
}
}, [currentUser, unfollowUser, followUser]);
// 跳转到帖子详情
const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => {
homeNavigation.navigate('PostDetail', { postId, scrollToComments });
}, [homeNavigation]);
// 跳转到用户主页(当前用户)
const handleUserPress = useCallback((userId: string) => {
// 个人主页点击自己的头像不跳转
}, []);
// 删除帖子
const handleDeletePost = useCallback(async (postId: string) => {
try {
const success = await postService.deletePost(postId);
if (success) {
// 从帖子列表中移除
setPosts(prev => prev.filter(p => p.id !== postId));
// 也从收藏列表中移除(如果存在)
setFavorites(prev => prev.filter(p => p.id !== postId));
} else {
console.error('删除帖子失败');
}
} catch (error) {
console.error('删除帖子失败:', error);
throw error;
}
}, []);
// 渲染内容
const renderContent = useCallback(() => {
if (loading) return <Loading />;
if (activeTab === 0) {
// 帖子
if (posts.length === 0) {
return (
<EmptyState
title="还没有帖子"
description="分享你的想法,发布第一条帖子吧"
icon="file-document-edit-outline"
variant="modern"
/>
);
}
return (
<View style={styles.postsContainer}>
{posts.map((post, index) => {
const isPostAuthor = currentUser?.id === post.author?.id;
return (
<View key={post.id} style={[
styles.postWrapper,
index === posts.length - 1 && styles.lastPost,
]}>
<PostCard
post={post}
onPress={() => handlePostPress(post.id)}
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)}
onComment={() => handlePostPress(post.id, true)}
onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)}
onShare={() => {}}
onDelete={() => handleDeletePost(post.id)}
isPostAuthor={isPostAuthor}
/>
</View>
);
})}
</View>
);
}
if (activeTab === 1) {
// 收藏
if (favorites.length === 0) {
return (
<EmptyState
title="还没有收藏"
description="发现喜欢的内容,点击收藏按钮保存"
icon="bookmark-heart-outline"
variant="modern"
/>
);
}
return (
<View style={styles.postsContainer}>
{favorites.map((post, index) => {
const isPostAuthor = currentUser?.id === post.author?.id;
return (
<View key={post.id} style={[
styles.postWrapper,
index === favorites.length - 1 && styles.lastPost,
]}>
<PostCard
post={post}
onPress={() => handlePostPress(post.id)}
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)}
onComment={() => handlePostPress(post.id, true)}
onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)}
onShare={() => {}}
onDelete={() => handleDeletePost(post.id)}
isPostAuthor={isPostAuthor}
/>
</View>
);
})}
</View>
);
}
return null;
}, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost, unlikePost, likePost, unfavoritePost, favoritePost]);
// 渲染用户信息头部 - 不随 tab 变化,使用 useMemo 缓存
const renderUserHeader = useMemo(() => (
<UserProfileHeader
user={currentUser!}
isCurrentUser={true}
onFollow={handleFollow}
onSettings={handleSettings}
onEditProfile={handleEditProfile}
onFollowingPress={handleFollowingPress}
onFollowersPress={handleFollowersPress}
/>
), [currentUser, handleFollow, handleSettings, handleEditProfile, handleFollowingPress, handleFollowersPress]);
// 渲染 TabBar 和内容
const renderTabBarAndContent = useMemo(() => (
<>
<View style={styles.tabBarContainer}>
<TabBar
tabs={TABS}
activeIndex={activeTab}
onTabChange={setActiveTab}
variant="modern"
icons={TAB_ICONS}
/>
</View>
<View style={styles.contentContainer}>
{renderContent()}
</View>
</>
), [activeTab, renderContent]);
if (!currentUser) {
return (
<SafeAreaView style={styles.container}>
<EmptyState
title="未登录"
description="请先登录"
icon="account-off-outline"
/>
</SafeAreaView>
);
}
// 桌面端使用双栏布局
if (isDesktop || isTablet) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<ResponsiveContainer maxWidth={1400}>
<View style={styles.desktopContainer}>
{/* 左侧:用户信息 */}
<View style={styles.desktopSidebar}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: scrollBottomInset }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
{renderUserHeader}
</ScrollView>
</View>
{/* 右侧:帖子列表 */}
<View style={styles.desktopContent}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.desktopScrollContent, { paddingBottom: scrollBottomInset }]}
>
{renderTabBarAndContent}
</ScrollView>
</View>
</View>
</ResponsiveContainer>
</SafeAreaView>
);
}
// 移动端使用单栏布局
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<ScrollView
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
>
{/* 用户信息头部 - 固定在顶部,不受 tab 切换影响 */}
{renderUserHeader}
{/* TabBar - 分离出来,切换 tab 不会影响上面的用户信息 */}
{renderTabBarAndContent}
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
scrollContent: {
flexGrow: 1,
},
// 桌面端双栏布局
desktopContainer: {
flex: 1,
flexDirection: 'row',
gap: spacing.lg,
padding: spacing.lg,
},
desktopSidebar: {
width: 380,
flexShrink: 0,
},
desktopContent: {
flex: 1,
minWidth: 0,
},
desktopScrollContent: {
flexGrow: 1,
},
tabBarContainer: {
marginTop: spacing.xs,
marginBottom: 2,
},
contentContainer: {
flex: 1,
minHeight: 350,
paddingTop: spacing.xs,
},
postsContainer: {
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
},
postWrapper: {
marginBottom: spacing.md,
backgroundColor: colors.background.paper,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
},
lastPost: {
marginBottom: spacing['2xl'],
},
});
export default ProfileScreen;

View File

@@ -0,0 +1,306 @@
/**
* 设置页 SettingsScreen响应式适配
* 胡萝卜BBS - 应用设置
* 在宽屏下居中显示,最大宽度限制
*/
import React from 'react';
import {
View,
StyleSheet,
TouchableOpacity,
Alert,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { useAuthStore } from '../../stores';
import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks';
interface SettingsItem {
key: string;
title: string;
icon: string;
onPress?: () => void;
showArrow?: boolean;
danger?: boolean;
subtitle?: string;
}
// 设置分组配置
const SETTINGS_GROUPS = [
{
title: '账号与安全',
icon: 'shield-check-outline',
items: [
{ key: 'edit_profile', title: '编辑资料', icon: 'account-edit-outline', showArrow: true },
{ key: 'privacy', title: '隐私设置', icon: 'shield-account-outline', showArrow: true },
{ key: 'security', title: '账号安全', icon: 'lock-outline', showArrow: true },
{ key: 'blocked_users', title: '黑名单', icon: 'account-off-outline', showArrow: true },
],
},
{
title: '通知与通用',
icon: 'bell-outline',
items: [
{ key: 'notification_settings', title: '通知设置', icon: 'bell-cog-outline', showArrow: true, subtitle: '推送、震动、提示音' },
],
},
{
title: '关于与帮助',
icon: 'information-outline',
items: [
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: '版本 1.0.2' },
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
],
},
];
export const SettingsScreen: React.FC = () => {
const navigation = useNavigation();
const { logout } = useAuthStore();
const { isWideScreen } = useResponsive();
// 处理设置项点击
const handleItemPress = (key: string) => {
switch (key) {
case 'edit_profile':
navigation.navigate('EditProfile' as never);
break;
case 'notification_settings':
navigation.navigate('NotificationSettings' as never);
break;
case 'blocked_users':
navigation.navigate('BlockedUsers' as never);
break;
case 'security':
navigation.navigate('AccountSecurity' as never);
break;
case 'logout':
Alert.alert(
'退出登录',
'确定要退出登录吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
style: 'destructive',
onPress: () => {
logout();
}
},
]
);
break;
default:
console.log('Settings item pressed:', key);
}
};
// 渲染单个设置项
const renderSettingItem = (item: SettingsItem, index: number, total: number) => (
<TouchableOpacity
key={item.key}
style={[
styles.settingItem,
index === 0 && styles.settingItemFirst,
index === total - 1 && styles.settingItemLast,
]}
onPress={() => item.onPress ? item.onPress() : handleItemPress(item.key)}
activeOpacity={0.7}
>
<View style={styles.settingItemLeft}>
<View style={[
styles.iconContainer,
item.danger && styles.dangerIconContainer
]}>
<MaterialCommunityIcons
name={item.icon as any}
size={20}
color={item.danger ? colors.error.main : colors.primary.main}
/>
</View>
<View style={styles.settingContent}>
<Text
variant="body"
color={item.danger ? colors.error.main : colors.text.primary}
>
{item.title}
</Text>
{item.subtitle && (
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}>
{item.subtitle}
</Text>
)}
</View>
</View>
{item.showArrow && (
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
)}
</TouchableOpacity>
);
// 渲染分组
const renderGroup = (group: typeof SETTINGS_GROUPS[0], groupIndex: number) => (
<View key={group.title} style={styles.groupContainer}>
<View style={styles.groupHeader}>
<MaterialCommunityIcons name={group.icon as any} size={16} color={colors.primary.main} />
<Text variant="caption" color={colors.text.secondary} style={styles.groupTitle}>
{group.title}
</Text>
</View>
<View style={styles.card}>
{group.items.map((item, index) =>
renderSettingItem(item, index, group.items.length)
)}
</View>
</View>
);
// 退出登录按钮
const renderLogoutButton = () => (
<TouchableOpacity
style={styles.logoutButton}
onPress={() => handleItemPress('logout')}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="logout-variant" size={20} color={colors.error.main} />
<Text variant="body" color={colors.error.main} style={styles.logoutText}>
退
</Text>
</TouchableOpacity>
);
// 渲染内容
const renderContent = () => (
<>
{SETTINGS_GROUPS.map((group, index) => renderGroup(group, index))}
{renderLogoutButton()}
{/* 底部版权信息 */}
<View style={styles.footer}>
<Text variant="caption" color={colors.text.hint}>
v1.0.2
</Text>
<Text variant="caption" color={colors.text.hint} style={styles.copyright}>
© 2024 Carrot BBS. All rights reserved.
</Text>
</View>
</>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={styles.scrollContent}>
{renderContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={styles.scrollContent}>
{renderContent()}
</ScrollView>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
scrollContent: {
paddingVertical: spacing.md,
},
groupContainer: {
marginBottom: spacing.lg,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
marginBottom: spacing.sm,
gap: spacing.xs,
},
groupTitle: {
fontWeight: '600',
fontSize: fontSizes.sm,
},
card: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
settingItemFirst: {
borderTopLeftRadius: borderRadius.lg,
borderTopRightRadius: borderRadius.lg,
},
settingItemLast: {
borderBottomLeftRadius: borderRadius.lg,
borderBottomRightRadius: borderRadius.lg,
borderBottomWidth: 0,
},
settingItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
dangerIconContainer: {
backgroundColor: colors.error.light + '20',
},
settingContent: {
flex: 1,
},
subtitle: {
marginTop: 2,
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
marginBottom: spacing.lg,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
gap: spacing.sm,
},
logoutText: {
fontWeight: '500',
},
footer: {
alignItems: 'center',
marginTop: spacing.xl,
paddingBottom: spacing.xl,
},
copyright: {
marginTop: spacing.xs,
},
});
export default SettingsScreen;

View File

@@ -0,0 +1,558 @@
/**
* 用户主页 UserScreen响应式适配
* 胡萝卜BBS - 查看其他用户资料
* 支持桌面端双栏布局
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
FlatList,
StyleSheet,
RefreshControl,
ScrollView,
Alert,
} 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 } from '../../theme';
import { Post, User } from '../../types';
import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore';
import { authService, postService, messageService } from '../../services';
import { userManager } from '../../stores/userManager';
import { UserProfileHeader, PostCard, TabBar } from '../../components/business';
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks';
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'UserProfile'>;
type UserRouteProp = RouteProp<HomeStackParamList, 'UserProfile'>;
const TABS = ['帖子', '收藏'];
const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
export const UserScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const route = useRoute<UserRouteProp>();
const userId = route.params?.userId || '';
// 使用 any 类型来访问根导航
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { followUser, unfollowUser, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
const currentUser = useCurrentUser();
// 响应式布局
const { isDesktop, isTablet } = useResponsive();
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [favorites, setFavorites] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [activeTab, setActiveTab] = useState(0);
const [isBlocked, setIsBlocked] = useState(false);
// 加载用户信息
const loadUserData = useCallback(async (forceRefresh = false) => {
if (!userId) {
setLoading(false);
return;
}
try {
// 强制从服务器获取最新数据,确保关注状态是最新的
const userData = await userManager.getUserById(userId, forceRefresh);
setUser(userData || null);
const blockStatus = await authService.getBlockStatus(userId);
setIsBlocked(blockStatus);
const response = await postService.getUserPosts(userId);
setPosts(response.list);
} catch (error) {
console.error('加载用户数据失败:', error);
}
setLoading(false);
}, [userId]);
// 加载用户收藏
const loadUserFavorites = useCallback(async () => {
if (!userId) return;
try {
console.log('[UserScreen] getUserFavorites called, userId:', userId);
const response = await postService.getUserFavorites(userId);
console.log('[UserScreen] getUserFavorites response:', response);
setFavorites(response.list);
} catch (error) {
console.error('获取用户收藏失败:', error);
}
}, [userId]);
// 监听 tab 切换
useEffect(() => {
if (activeTab === 1) {
loadUserFavorites();
}
}, [activeTab, loadUserFavorites]);
useEffect(() => {
// 首次加载时强制刷新,确保关注状态是最新的
loadUserData(true);
}, [loadUserData]);
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
useEffect(() => {
// 同步帖子列表状态
if (posts.length > 0) {
let hasChanges = false;
const updatedPosts = posts.map(localPost => {
const storePost = storePosts.find(sp => sp.id === localPost.id);
if (storePost && (
storePost.is_liked !== localPost.is_liked ||
storePost.is_favorited !== localPost.is_favorited ||
storePost.likes_count !== localPost.likes_count ||
storePost.favorites_count !== localPost.favorites_count
)) {
hasChanges = true;
return {
...localPost,
is_liked: storePost.is_liked,
is_favorited: storePost.is_favorited,
likes_count: storePost.likes_count,
favorites_count: storePost.favorites_count,
};
}
return localPost;
});
if (hasChanges) {
setPosts(updatedPosts);
}
}
// 同步收藏列表状态
if (favorites.length > 0) {
let hasChanges = false;
const updatedFavorites = favorites.map(localPost => {
const storePost = storePosts.find(sp => sp.id === localPost.id);
if (storePost && (
storePost.is_liked !== localPost.is_liked ||
storePost.is_favorited !== localPost.is_favorited ||
storePost.likes_count !== localPost.likes_count ||
storePost.favorites_count !== localPost.favorites_count
)) {
hasChanges = true;
return {
...localPost,
is_liked: storePost.is_liked,
is_favorited: storePost.is_favorited,
likes_count: storePost.likes_count,
favorites_count: storePost.favorites_count,
};
}
return localPost;
});
if (hasChanges) {
setFavorites(updatedFavorites);
}
}
}, [storePosts]);
// 下拉刷新
const onRefresh = useCallback(() => {
setRefreshing(true);
loadUserData(true);
setRefreshing(false);
}, [loadUserData]);
// 关注/取消关注
const handleFollow = () => {
if (!user) return;
if (user.is_following) {
unfollowUser(user.id);
setUser({ ...user, is_following: false, followers_count: user.followers_count - 1 });
} else {
followUser(user.id);
setUser({ ...user, is_following: true, followers_count: user.followers_count + 1 });
}
};
// 跳转到关注列表
const handleFollowingPress = () => {
(rootNavigation as any).navigate('FollowList', { userId, type: 'following' });
};
// 跳转到粉丝列表
const handleFollowersPress = () => {
(rootNavigation as any).navigate('FollowList', { userId, type: 'followers' });
};
// 跳转到帖子详情
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
navigation.navigate('PostDetail', { postId, scrollToComments });
};
// 跳转到用户主页(这里不做处理)
const handleUserPress = (postUserId: string) => {
if (postUserId !== userId) {
navigation.push('UserProfile', { userId: postUserId });
}
};
// 删除帖子
const handleDeletePost = async (postId: string) => {
try {
const success = await postService.deletePost(postId);
if (success) {
// 从帖子列表中移除
setPosts(prev => prev.filter(p => p.id !== postId));
// 也从收藏列表中移除(如果存在)
setFavorites(prev => prev.filter(p => p.id !== postId));
} else {
console.error('删除帖子失败');
}
} catch (error) {
console.error('删除帖子失败:', error);
throw error;
}
};
// 跳转到聊天界面
const handleMessage = async () => {
if (!user) return;
try {
// 前端只提供对方的用户ID会话ID由后端生成
const conversation = await messageService.createConversation(user.id);
if (conversation) {
// 跳转到聊天界面 - 使用 rootNavigation 确保在正确的导航栈中
(rootNavigation as any).navigate('Chat', {
conversationId: conversation.id.toString(),
userId: user.id
});
}
} catch (error) {
console.error('创建会话失败:', error);
}
};
// 处理更多按钮点击
const handleMore = () => {
if (!user) return;
Alert.alert(
'更多操作',
undefined,
[
{ text: '取消', style: 'cancel' },
{
text: isBlocked ? '取消拉黑' : '拉黑用户',
style: 'destructive',
onPress: () => {
Alert.alert(
isBlocked ? '确认取消拉黑' : '确认拉黑',
isBlocked
? '取消拉黑后,对方可以重新与你建立关系。'
: '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。',
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
style: 'destructive',
onPress: async () => {
const ok = isBlocked
? await authService.unblockUser(user.id)
: await authService.blockUser(user.id);
if (!ok) {
Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试');
return;
}
setUser(prev =>
prev
? {
...prev,
is_following: false,
is_following_me: false,
}
: prev
);
setIsBlocked(!isBlocked);
Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户');
},
},
]
);
},
},
]
);
};
// 渲染内容
const renderContent = () => {
if (loading) return <Loading />;
if (activeTab === 0) {
// 帖子
if (posts.length === 0) {
return (
<EmptyState
title="还没有帖子"
description="这个用户还没有发布任何帖子"
icon="file-document-edit-outline"
variant="modern"
/>
);
}
return (
<View style={styles.postsContainer}>
{posts.map((post, index) => {
const isPostAuthor = currentUser?.id === post.author?.id;
return (
<View key={post.id} style={[
styles.postWrapper,
index === posts.length - 1 && styles.lastPost,
]}>
<PostCard
post={post}
onPress={() => handlePostPress(post.id)}
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)}
onComment={() => handlePostPress(post.id, true)}
onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)}
onShare={() => {}}
onDelete={() => handleDeletePost(post.id)}
isPostAuthor={isPostAuthor}
/>
</View>
);
})}
</View>
);
}
if (activeTab === 1) {
// 收藏
if (favorites.length === 0) {
return (
<EmptyState
title="还没有收藏"
description="这个用户还没有收藏任何帖子"
icon="bookmark-heart-outline"
variant="modern"
/>
);
}
return (
<View style={styles.postsContainer}>
{favorites.map((post, index) => {
const isPostAuthor = currentUser?.id === post.author?.id;
return (
<View key={post.id} style={[
styles.postWrapper,
index === favorites.length - 1 && styles.lastPost,
]}>
<PostCard
post={post}
onPress={() => handlePostPress(post.id)}
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)}
onComment={() => handlePostPress(post.id, true)}
onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)}
onShare={() => {}}
onDelete={() => handleDeletePost(post.id)}
isPostAuthor={isPostAuthor}
/>
</View>
);
})}
</View>
);
}
return null;
};
// 渲染用户信息头部
const renderUserHeader = () => {
if (!user) return null;
return (
<UserProfileHeader
user={user}
isCurrentUser={false}
onFollow={handleFollow}
onMessage={handleMessage}
onMore={handleMore}
onFollowingPress={handleFollowingPress}
onFollowersPress={handleFollowersPress}
/>
);
};
// 渲染 TabBar 和内容
const renderTabBarAndContent = () => (
<>
<View style={styles.tabBarContainer}>
<TabBar
tabs={TABS}
activeIndex={activeTab}
onTabChange={setActiveTab}
variant="modern"
icons={TAB_ICONS}
/>
</View>
<View style={styles.contentContainer}>
{renderContent()}
</View>
</>
);
if (loading) {
return <Loading fullScreen />;
}
if (!user) {
return (
<SafeAreaView style={styles.container}>
<EmptyState
title="用户不存在"
description="该用户可能已被删除"
icon="account-off-outline"
/>
</SafeAreaView>
);
}
// 桌面端使用双栏布局
if (isDesktop || isTablet) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ResponsiveContainer maxWidth={1400}>
<View style={styles.desktopContainer}>
{/* 左侧:用户信息 */}
<View style={styles.desktopSidebar}>
<ScrollView
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
{renderUserHeader()}
</ScrollView>
</View>
{/* 右侧:帖子列表 */}
<View style={styles.desktopContent}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.desktopScrollContent}
>
{renderTabBarAndContent()}
</ScrollView>
</View>
</View>
</ResponsiveContainer>
</SafeAreaView>
);
}
// 移动端使用单栏布局
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<FlatList
data={[{ key: 'header' }]}
renderItem={({ item }) => (
<View>
{renderUserHeader()}
<View style={styles.tabBarContainer}>
<TabBar
tabs={TABS}
activeIndex={activeTab}
onTabChange={setActiveTab}
variant="modern"
icons={TAB_ICONS}
/>
</View>
<View style={styles.contentContainer}>
{renderContent()}
</View>
</View>
)}
keyExtractor={item => item.key}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
// 桌面端双栏布局
desktopContainer: {
flex: 1,
flexDirection: 'row',
gap: spacing.lg,
padding: spacing.lg,
},
desktopSidebar: {
width: 380,
flexShrink: 0,
},
desktopContent: {
flex: 1,
minWidth: 0,
},
desktopScrollContent: {
flexGrow: 1,
},
tabBarContainer: {
marginTop: spacing.sm,
marginBottom: spacing.xs,
},
contentContainer: {
flex: 1,
minHeight: 350,
paddingTop: spacing.sm,
},
postsContainer: {
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
},
postWrapper: {
marginBottom: spacing.md,
backgroundColor: colors.background.paper,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
},
lastPost: {
marginBottom: spacing['2xl'],
},
});
export default UserScreen;

View File

@@ -0,0 +1,12 @@
/**
* 个人中心模块导出
*/
export { ProfileScreen } from './ProfileScreen';
export { SettingsScreen } from './SettingsScreen';
export { EditProfileScreen } from './EditProfileScreen';
export { UserScreen } from './UserScreen';
export { default as FollowListScreen } from './FollowListScreen';
export { NotificationSettingsScreen } from './NotificationSettingsScreen';
export { BlockedUsersScreen } from './BlockedUsersScreen';
export { AccountSecurityScreen } from './AccountSecurityScreen';