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