更新ios
This commit is contained in:
@@ -26,8 +26,8 @@ import Profile from "../screens/Profile";
|
||||
import React from "react";
|
||||
import Register from "../screens/Register";
|
||||
import Search from "../screens/Search";
|
||||
// settings
|
||||
import SettingsScreen from "../screens/Settings";
|
||||
// settings - 使用新的个人信息设置页面
|
||||
import SettingsScreen from "../src/screens/Profile/SettingsScreen";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { createDrawerNavigator } from "@react-navigation/drawer";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
@@ -114,10 +114,8 @@ function SettingsStack(props) {
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
header: ({ navigation, scene }) => (
|
||||
<Header title="Settings" scene={scene} navigation={navigation} />
|
||||
),
|
||||
cardStyle: { backgroundColor: "#F8F9FE" },
|
||||
headerShown: false,
|
||||
cardStyle: { backgroundColor: "#0A0A0F" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
|
||||
@@ -128,6 +128,18 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 更新用户信息(本地状态)
|
||||
const updateUser = useCallback((updates) => {
|
||||
setUser(prev => {
|
||||
const newUser = { ...prev, ...updates };
|
||||
// 同步更新本地存储(使用 authService 内部已导入的 AsyncStorage)
|
||||
import('@react-native-async-storage/async-storage').then((module) => {
|
||||
module.default.setItem('@auth_user_info', JSON.stringify(newUser));
|
||||
});
|
||||
return newUser;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 检查是否有指定订阅级别
|
||||
const hasSubscriptionLevel = useCallback(
|
||||
(requiredLevel) => {
|
||||
@@ -153,6 +165,7 @@ export const AuthProvider = ({ children }) => {
|
||||
loginWithCode,
|
||||
logout,
|
||||
refreshUser,
|
||||
updateUser,
|
||||
hasSubscriptionLevel,
|
||||
};
|
||||
|
||||
|
||||
627
MeAgent/src/screens/Profile/SettingsScreen.js
Normal file
627
MeAgent/src/screens/Profile/SettingsScreen.js
Normal file
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* 设置页面 - 个人信息编辑
|
||||
* 参考 Web 版本设计,适配移动端
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Pressable,
|
||||
Input,
|
||||
TextArea,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from 'native-base';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { updateProfile } from '../../services/profileService';
|
||||
|
||||
// 性别选项
|
||||
const GENDER_OPTIONS = [
|
||||
{ value: 'male', label: '男' },
|
||||
{ value: 'female', label: '女' },
|
||||
{ value: 'secret', label: '保密' },
|
||||
];
|
||||
|
||||
// 省份选项
|
||||
const LOCATION_OPTIONS = [
|
||||
'北京', '上海', '广东', '浙江', '江苏', '四川', '湖北', '湖南',
|
||||
'山东', '河南', '福建', '陕西', '重庆', '天津', '其他',
|
||||
];
|
||||
|
||||
/**
|
||||
* 胶囊选择按钮
|
||||
*/
|
||||
const TagButton = ({ label, isSelected, onPress }) => (
|
||||
<Pressable onPress={onPress}>
|
||||
<Box
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
borderWidth={1}
|
||||
borderColor={isSelected ? '#7C3AED' : 'gray.600'}
|
||||
bg={isSelected ? 'rgba(124, 58, 237, 0.2)' : 'transparent'}
|
||||
>
|
||||
<Text
|
||||
color={isSelected ? '#A78BFA' : 'gray.400'}
|
||||
fontSize={13}
|
||||
fontWeight={isSelected ? 'bold' : 'normal'}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
/**
|
||||
* 下拉选择组件(简化版)
|
||||
*/
|
||||
const SelectButton = ({ value, placeholder, options, onSelect }) => {
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Pressable onPress={() => setShowOptions(!showOptions)}>
|
||||
<Box
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
borderRadius={12}
|
||||
px={4}
|
||||
py={3}
|
||||
>
|
||||
<HStack justifyContent="space-between" alignItems="center">
|
||||
<Text color={value ? 'white' : 'gray.500'} fontSize={14}>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name={showOptions ? 'chevron-up' : 'chevron-down'}
|
||||
size="sm"
|
||||
color="gray.500"
|
||||
/>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Pressable>
|
||||
|
||||
{showOptions && (
|
||||
<Box
|
||||
mt={2}
|
||||
bg="gray.800"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor="gray.700"
|
||||
maxH={200}
|
||||
>
|
||||
<ScrollView nestedScrollEnabled>
|
||||
{options.map((option, index) => (
|
||||
<Pressable
|
||||
key={option}
|
||||
onPress={() => {
|
||||
onSelect(option);
|
||||
setShowOptions(false);
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
px={4}
|
||||
py={3}
|
||||
borderBottomWidth={index < options.length - 1 ? 1 : 0}
|
||||
borderBottomColor="gray.700"
|
||||
bg={value === option ? 'rgba(124, 58, 237, 0.2)' : 'transparent'}
|
||||
>
|
||||
<Text color={value === option ? '#A78BFA' : 'white'} fontSize={14}>
|
||||
{option}
|
||||
</Text>
|
||||
</Box>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单分组卡片
|
||||
*/
|
||||
const FormCard = ({ title, children }) => (
|
||||
<Box
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255, 255, 255, 0.06)"
|
||||
borderRadius={16}
|
||||
p={4}
|
||||
mb={4}
|
||||
>
|
||||
{title && (
|
||||
<Text color="white" fontSize={16} fontWeight="bold" mb={4}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* 表单项
|
||||
*/
|
||||
const FormItem = ({ label, children, style }) => (
|
||||
<Box mb={4} style={style}>
|
||||
<Text color="gray.400" fontSize={13} mb={2}>
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* 账号信息展示行
|
||||
*/
|
||||
const InfoRow = ({ icon, label, value, verified, color = 'white' }) => (
|
||||
<HStack
|
||||
py={3}
|
||||
alignItems="center"
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor="rgba(255, 255, 255, 0.05)"
|
||||
>
|
||||
<Box
|
||||
bg={`${color}20`}
|
||||
p={2}
|
||||
borderRadius={10}
|
||||
mr={3}
|
||||
>
|
||||
<Icon as={Ionicons} name={icon} size="sm" color={color} />
|
||||
</Box>
|
||||
<VStack flex={1}>
|
||||
<Text color="gray.500" fontSize={12}>
|
||||
{label}
|
||||
</Text>
|
||||
<HStack alignItems="center" space={2}>
|
||||
<Text color="white" fontSize={14} fontWeight="medium">
|
||||
{value || '未设置'}
|
||||
</Text>
|
||||
{verified && (
|
||||
<Box bg="rgba(34, 197, 94, 0.2)" px={2} py={0.5} borderRadius={4}>
|
||||
<Text color="#22C55E" fontSize={10}>
|
||||
已验证
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
const MenuItem = ({ icon, label, color = '#7C3AED', onPress }) => (
|
||||
<Pressable onPress={onPress}>
|
||||
{({ pressed }) => (
|
||||
<HStack
|
||||
py={3.5}
|
||||
alignItems="center"
|
||||
opacity={pressed ? 0.7 : 1}
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor="rgba(255, 255, 255, 0.05)"
|
||||
>
|
||||
<Box bg={`${color}20`} p={2} borderRadius={10} mr={3}>
|
||||
<Icon as={Ionicons} name={icon} size="sm" color={color} />
|
||||
</Box>
|
||||
<Text color="white" fontSize={14} flex={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Icon as={Ionicons} name="chevron-forward" size="sm" color="gray.600" />
|
||||
</HStack>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
/**
|
||||
* 手机号脱敏
|
||||
*/
|
||||
const maskPhone = (phone) => {
|
||||
if (!phone) return '未绑定';
|
||||
if (phone.length < 7) return phone;
|
||||
return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4);
|
||||
};
|
||||
|
||||
/**
|
||||
* 邮箱脱敏
|
||||
*/
|
||||
const maskEmail = (email) => {
|
||||
if (!email) return '未绑定';
|
||||
const atIndex = email.indexOf('@');
|
||||
if (atIndex <= 0) return email;
|
||||
const localPart = email.substring(0, atIndex);
|
||||
const domain = email.substring(atIndex);
|
||||
const visibleChars = Math.min(3, localPart.length);
|
||||
return localPart.substring(0, visibleChars) + '***' + domain;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置页面主组件
|
||||
*/
|
||||
const SettingsScreen = () => {
|
||||
const navigation = useNavigation();
|
||||
const toast = useToast();
|
||||
const { user, updateUser, refreshUser } = useAuth();
|
||||
|
||||
// 表单状态
|
||||
const [form, setForm] = useState({
|
||||
nickname: '',
|
||||
gender: 'secret',
|
||||
location: '',
|
||||
birthday: '',
|
||||
bio: '',
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setForm({
|
||||
nickname: user.nickname || '',
|
||||
gender: user.gender || 'secret',
|
||||
location: user.location || '',
|
||||
birthday: user.birthday || '',
|
||||
bio: user.bio || '',
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 检测表单变化
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const changed =
|
||||
form.nickname !== (user.nickname || '') ||
|
||||
form.gender !== (user.gender || 'secret') ||
|
||||
form.location !== (user.location || '') ||
|
||||
form.birthday !== (user.birthday || '') ||
|
||||
form.bio !== (user.bio || '');
|
||||
setHasChanges(changed);
|
||||
}, [form, user]);
|
||||
|
||||
// 更新表单字段
|
||||
const updateField = useCallback((field, value) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 保存资料
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!hasChanges) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateProfile(form);
|
||||
if (result.success) {
|
||||
updateUser(form);
|
||||
toast.show({
|
||||
title: '保存成功',
|
||||
status: 'success',
|
||||
placement: 'top',
|
||||
});
|
||||
setHasChanges(false);
|
||||
} else {
|
||||
throw new Error(result.error || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.show({
|
||||
title: '保存失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
placement: 'top',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [form, hasChanges, updateUser, toast]);
|
||||
|
||||
// 重置表单
|
||||
const handleReset = useCallback(() => {
|
||||
if (user) {
|
||||
setForm({
|
||||
nickname: user.nickname || '',
|
||||
gender: user.gender || 'secret',
|
||||
location: user.location || '',
|
||||
birthday: user.birthday || '',
|
||||
bio: user.bio || '',
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Box flex={1} bg="#0A0A0F">
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['rgba(124, 58, 237, 0.1)', 'transparent']}
|
||||
style={styles.headerGradient}
|
||||
/>
|
||||
|
||||
{/* 顶部导航 */}
|
||||
<HStack px={4} py={3} alignItems="center" justifyContent="space-between">
|
||||
<Pressable onPress={() => navigation.goBack()} hitSlop={10}>
|
||||
<Icon as={Ionicons} name="arrow-back" size="md" color="white" />
|
||||
</Pressable>
|
||||
<Text color="white" fontSize={18} fontWeight="bold">
|
||||
设置
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={!hasChanges || isLoading}
|
||||
hitSlop={10}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" color="#7C3AED" />
|
||||
) : (
|
||||
<Text
|
||||
color={hasChanges ? '#7C3AED' : 'gray.600'}
|
||||
fontSize={14}
|
||||
fontWeight="bold"
|
||||
>
|
||||
保存
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</HStack>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.content}
|
||||
>
|
||||
{/* 头像区域 */}
|
||||
<Box alignItems="center" mb={6}>
|
||||
<Pressable onPress={() => {
|
||||
Alert.alert('提示', '头像上传功能即将上线');
|
||||
}}>
|
||||
<LinearGradient
|
||||
colors={['#7C3AED', '#EC4899']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.avatar}
|
||||
>
|
||||
<Text color="white" fontSize={32} fontWeight="bold">
|
||||
{(user?.nickname || user?.username || 'U').charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
right={0}
|
||||
bg="#7C3AED"
|
||||
p={2}
|
||||
borderRadius="full"
|
||||
borderWidth={2}
|
||||
borderColor="#0A0A0F"
|
||||
>
|
||||
<Icon as={Ionicons} name="camera" size="xs" color="white" />
|
||||
</Box>
|
||||
</Pressable>
|
||||
<Text color="gray.500" fontSize={12} mt={2}>
|
||||
点击更换头像
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<FormCard title="基本信息">
|
||||
<FormItem label="昵称">
|
||||
<Input
|
||||
value={form.nickname}
|
||||
onChangeText={(val) => updateField('nickname', val)}
|
||||
placeholder="请输入昵称"
|
||||
placeholderTextColor="#6B7280"
|
||||
color="white"
|
||||
fontSize={14}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
borderRadius={12}
|
||||
px={4}
|
||||
py={3}
|
||||
maxLength={20}
|
||||
_focus={{
|
||||
borderColor: '#7C3AED',
|
||||
bg: 'rgba(124, 58, 237, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="性别">
|
||||
<HStack space={3} flexWrap="wrap">
|
||||
{GENDER_OPTIONS.map((option) => (
|
||||
<TagButton
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
isSelected={form.gender === option.value}
|
||||
onPress={() => updateField('gender', option.value)}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="所在地">
|
||||
<SelectButton
|
||||
value={form.location}
|
||||
placeholder="请选择所在地"
|
||||
options={LOCATION_OPTIONS}
|
||||
onSelect={(val) => updateField('location', val)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="生日">
|
||||
<Input
|
||||
value={form.birthday}
|
||||
onChangeText={(val) => updateField('birthday', val)}
|
||||
placeholder="YYYY-MM-DD"
|
||||
placeholderTextColor="#6B7280"
|
||||
color="white"
|
||||
fontSize={14}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
borderRadius={12}
|
||||
px={4}
|
||||
py={3}
|
||||
_focus={{
|
||||
borderColor: '#7C3AED',
|
||||
bg: 'rgba(124, 58, 237, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="个人简介" style={{ marginBottom: 0 }}>
|
||||
<TextArea
|
||||
value={form.bio}
|
||||
onChangeText={(val) => updateField('bio', val)}
|
||||
placeholder="介绍一下自己..."
|
||||
placeholderTextColor="#6B7280"
|
||||
color="white"
|
||||
fontSize={14}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
borderRadius={12}
|
||||
px={4}
|
||||
py={3}
|
||||
h={100}
|
||||
maxLength={200}
|
||||
_focus={{
|
||||
borderColor: '#7C3AED',
|
||||
bg: 'rgba(124, 58, 237, 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Text color="gray.600" fontSize={11} textAlign="right" mt={1}>
|
||||
{form.bio.length}/200
|
||||
</Text>
|
||||
</FormItem>
|
||||
</FormCard>
|
||||
|
||||
{/* 账号安全 */}
|
||||
<FormCard title="账号安全">
|
||||
<InfoRow
|
||||
icon="call"
|
||||
label="手机号"
|
||||
value={maskPhone(user?.phone)}
|
||||
verified={user?.phone_confirmed}
|
||||
color="#3B82F6"
|
||||
/>
|
||||
<InfoRow
|
||||
icon="mail"
|
||||
label="邮箱"
|
||||
value={maskEmail(user?.email)}
|
||||
verified={user?.email_confirmed}
|
||||
color="#F59E0B"
|
||||
/>
|
||||
{/* 微信登录功能暂未实现,先隐藏
|
||||
<Box py={3}>
|
||||
<HStack alignItems="center">
|
||||
<Box bg="rgba(34, 197, 94, 0.2)" p={2} borderRadius={10} mr={3}>
|
||||
<Icon as={Ionicons} name="logo-wechat" size="sm" color="#22C55E" />
|
||||
</Box>
|
||||
<VStack flex={1}>
|
||||
<Text color="gray.500" fontSize={12}>
|
||||
微信
|
||||
</Text>
|
||||
<Text
|
||||
color={user?.has_wechat ? '#22C55E' : 'gray.400'}
|
||||
fontSize={14}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{user?.has_wechat ? '已绑定' : '未绑定'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
*/}
|
||||
</FormCard>
|
||||
|
||||
{/* 其他选项 */}
|
||||
<FormCard>
|
||||
<MenuItem
|
||||
icon="document-text"
|
||||
label="用户协议"
|
||||
onPress={() => navigation.navigate('Agreement')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="shield-checkmark"
|
||||
label="隐私政策"
|
||||
onPress={() => navigation.navigate('Privacy')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="information-circle"
|
||||
label="关于我们"
|
||||
color="#06B6D4"
|
||||
onPress={() => navigation.navigate('About')}
|
||||
/>
|
||||
</FormCard>
|
||||
|
||||
{/* 账户信息 */}
|
||||
<HStack justifyContent="center" space={4} mt={2} mb={8}>
|
||||
<Text color="gray.600" fontSize={11}>
|
||||
注册时间:{user?.created_at
|
||||
? new Date(user.created_at).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</Text>
|
||||
<Text color="gray.600" fontSize={11}>
|
||||
UID:{user?.id || '-'}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 底部间距 */}
|
||||
<Box height={50} />
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
headerGradient: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 200,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
avatar: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SettingsScreen;
|
||||
98
MeAgent/src/services/profileService.js
Normal file
98
MeAgent/src/services/profileService.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 用户资料服务
|
||||
* 处理个人信息更新、头像上传等
|
||||
*/
|
||||
|
||||
import { apiRequest, API_BASE } from './api';
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
* @param {object} data - 更新数据
|
||||
* @param {string} data.nickname - 昵称
|
||||
* @param {string} data.gender - 性别 (male/female/secret)
|
||||
* @param {string} data.location - 所在地
|
||||
* @param {string} data.birthday - 生日 (YYYY-MM-DD)
|
||||
* @param {string} data.bio - 个人简介
|
||||
*/
|
||||
export const updateProfile = async (data) => {
|
||||
try {
|
||||
console.log('[ProfileService] 更新用户资料:', data);
|
||||
|
||||
const response = await apiRequest('/api/account/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.code === 200 || response.success) {
|
||||
return { success: true, data: response.data };
|
||||
}
|
||||
|
||||
throw new Error(response.message || response.error || '更新失败');
|
||||
} catch (error) {
|
||||
console.error('[ProfileService] 更新用户资料失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传头像
|
||||
* @param {string} uri - 图片 URI
|
||||
*/
|
||||
export const uploadAvatar = async (uri) => {
|
||||
try {
|
||||
console.log('[ProfileService] 上传头像:', uri);
|
||||
|
||||
// 创建 FormData
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', {
|
||||
uri,
|
||||
type: 'image/jpeg',
|
||||
name: 'avatar.jpg',
|
||||
});
|
||||
|
||||
// 使用原生 fetch 上传
|
||||
const response = await fetch(`${API_BASE}/api/account/avatar`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && (result.code === 200 || result.avatar_url)) {
|
||||
return { success: true, avatarUrl: result.avatar_url || result.data?.avatar_url };
|
||||
}
|
||||
|
||||
throw new Error(result.message || result.error || '上传失败');
|
||||
} catch (error) {
|
||||
console.error('[ProfileService] 上传头像失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户资料详情
|
||||
*/
|
||||
export const getProfile = async () => {
|
||||
try {
|
||||
const response = await apiRequest('/api/account/profile');
|
||||
|
||||
if (response.code === 200 || response.success) {
|
||||
return { success: true, data: response.data };
|
||||
}
|
||||
|
||||
throw new Error(response.message || response.error || '获取失败');
|
||||
} catch (error) {
|
||||
console.error('[ProfileService] 获取用户资料失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
getProfile,
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Form, Input, Button, Typography, Modal, message, Tooltip } from 'antd';
|
||||
import { LockOutlined, WechatOutlined, MobileOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||
import { LockOutlined, WechatOutlined, MobileOutlined, QrcodeOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons';
|
||||
import { useBreakpointValue } from "@chakra-ui/react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useAuthModal } from "../../hooks/useAuthModal";
|
||||
@@ -162,6 +162,11 @@ const AUTH_CONFIG = {
|
||||
title: "手机号登录",
|
||||
subtitle: "未注册手机号登录时将自动创建价值前沿账号",
|
||||
},
|
||||
// 密码登录内容区文案
|
||||
password: {
|
||||
title: "密码登录",
|
||||
subtitle: "使用手机号/邮箱 + 密码登录",
|
||||
},
|
||||
buttonText: "登录/注册",
|
||||
loadingText: "验证中...",
|
||||
successDescription: "欢迎!",
|
||||
@@ -184,7 +189,7 @@ export default function AuthFormContent() {
|
||||
const isMountedRef = useRef(true);
|
||||
const wechatRef = useRef(null);
|
||||
|
||||
// Tab 状态: 'wechat' | 'phone'
|
||||
// Tab 状态: 'wechat' | 'phone' | 'password'
|
||||
const [activeTab, setActiveTab] = useState('wechat');
|
||||
|
||||
// 表单状态
|
||||
@@ -196,6 +201,9 @@ export default function AuthFormContent() {
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
// 密码登录表单状态
|
||||
const [passwordFormData, setPasswordFormData] = useState({ username: "", password: "" });
|
||||
|
||||
// 响应式
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
@@ -204,8 +212,8 @@ export default function AuthFormContent() {
|
||||
|
||||
// 切换 Tab
|
||||
const handleTabChange = (tab) => {
|
||||
// 切换到手机登录时,停止微信轮询
|
||||
if (tab === 'phone' && wechatRef.current) {
|
||||
// 切换到非微信登录时,停止微信轮询
|
||||
if (tab !== 'wechat' && wechatRef.current) {
|
||||
wechatRef.current.stopPolling();
|
||||
}
|
||||
// 切换到微信登录时,自动获取二维码
|
||||
@@ -387,6 +395,52 @@ export default function AuthFormContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// 密码登录
|
||||
const handlePasswordLogin = async (e) => {
|
||||
e?.preventDefault?.();
|
||||
const { username, password } = passwordFormData;
|
||||
|
||||
if (!username || !password) {
|
||||
message.warning("请输入账号和密码");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 使用 URLSearchParams 发送表单数据(与后端 API 要求一致)
|
||||
const formDataToSend = new URLSearchParams();
|
||||
formDataToSend.append('username', username.trim());
|
||||
formDataToSend.append('password', password);
|
||||
|
||||
const response = await fetch(`${getApiBase()}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
credentials: 'include',
|
||||
body: formDataToSend.toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (response.ok && data.success) {
|
||||
await checkSession();
|
||||
authEvents.trackLoginSuccess(data.user, 'password', false);
|
||||
message.success('登录成功');
|
||||
setTimeout(() => handleLoginSuccess({ username }), config.features.successDelay);
|
||||
} else {
|
||||
throw new Error(data.error || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
authEvents.trackLoginFailed('password', 'api', error.message, {
|
||||
username_masked: username ? username.substring(0, 3) + '***' : 'N/A',
|
||||
});
|
||||
logger.error('AuthFormContent', 'handlePasswordLogin', error);
|
||||
message.error(error.message || "登录失败");
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
authEvents.trackLoginPageViewed();
|
||||
@@ -504,53 +558,162 @@ export default function AuthFormContent() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染密码登录表单
|
||||
const renderPasswordForm = () => (
|
||||
<div>
|
||||
{/* 内容区小标题 */}
|
||||
<div style={styles.contentTitle}>
|
||||
<span style={styles.contentTitleText}>{config.password.title}</span>
|
||||
</div>
|
||||
<div style={styles.contentSubtitle}>{config.password.subtitle}</div>
|
||||
|
||||
<Form layout="vertical" onFinish={handlePasswordLogin}>
|
||||
<Form.Item style={{ marginBottom: '16px' }}>
|
||||
<Input
|
||||
value={passwordFormData.username}
|
||||
onChange={(e) => setPasswordFormData(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="手机号 / 邮箱 / 用户名"
|
||||
size="large"
|
||||
style={styles.input}
|
||||
prefix={<UserOutlined style={{ color: THEME.textMuted }} />}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: '24px' }}>
|
||||
<Input.Password
|
||||
value={passwordFormData.password}
|
||||
onChange={(e) => setPasswordFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
style={styles.input}
|
||||
prefix={<KeyOutlined style={{ color: THEME.textMuted }} />}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: '16px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={isLoading}
|
||||
icon={<LockOutlined />}
|
||||
style={styles.submitBtn}
|
||||
>
|
||||
{isLoading ? config.loadingText : "登录"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center', marginBottom: '16px' }}>
|
||||
<span
|
||||
style={{
|
||||
color: THEME.textMuted,
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleTabChange('phone')}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = THEME.goldPrimary; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = THEME.textMuted; }}
|
||||
>
|
||||
没有密码?使用验证码登录/注册
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.privacyText}>
|
||||
登录即表示您同意价值前沿{" "}
|
||||
<Link
|
||||
href="/home/user-agreement"
|
||||
target="_blank"
|
||||
onClick={authEvents.trackUserAgreementClicked}
|
||||
style={styles.privacyLink}
|
||||
>
|
||||
《用户协议》
|
||||
</Link>
|
||||
{" "}和{" "}
|
||||
<Link
|
||||
href="/home/privacy-policy"
|
||||
target="_blank"
|
||||
onClick={authEvents.trackPrivacyPolicyClicked}
|
||||
style={styles.privacyLink}
|
||||
>
|
||||
《隐私政策》
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染底部其他登录方式
|
||||
const renderBottomDivider = () => {
|
||||
const isWechatTab = activeTab === 'wechat';
|
||||
// 密码登录页面不显示底部切换(已经有内部切换链接)
|
||||
if (activeTab === 'password') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 移动端微信 Tab 下不显示底部切换(因为移动端微信是 H5 跳转)
|
||||
if (isMobile && isWechatTab) {
|
||||
if (isMobile && activeTab === 'wechat') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据当前 Tab 决定显示哪些其他登录方式
|
||||
const otherMethods = [];
|
||||
|
||||
if (activeTab !== 'wechat') {
|
||||
otherMethods.push({
|
||||
key: 'wechat',
|
||||
label: '微信',
|
||||
icon: <WechatOutlined />,
|
||||
color: THEME.wechat,
|
||||
hoverBg: 'rgba(7, 193, 96, 0.1)',
|
||||
onClick: isMobile ? handleWechatH5Login : () => handleTabChange('wechat'),
|
||||
});
|
||||
}
|
||||
|
||||
if (activeTab !== 'phone') {
|
||||
otherMethods.push({
|
||||
key: 'phone',
|
||||
label: '验证码',
|
||||
icon: <MobileOutlined />,
|
||||
color: THEME.goldPrimary,
|
||||
hoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
onClick: () => handleTabChange('phone'),
|
||||
});
|
||||
}
|
||||
|
||||
if (activeTab !== 'password') {
|
||||
otherMethods.push({
|
||||
key: 'password',
|
||||
label: '密码',
|
||||
icon: <KeyOutlined />,
|
||||
color: THEME.textSecondary,
|
||||
hoverBg: 'rgba(255, 255, 255, 0.1)',
|
||||
onClick: () => handleTabChange('password'),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.bottomDivider}>
|
||||
<div style={styles.dividerLine} />
|
||||
<div style={styles.otherLoginWrapper}>
|
||||
<span>其他登录方式:</span>
|
||||
{isWechatTab ? (
|
||||
<span>其他方式:</span>
|
||||
{otherMethods.map((method) => (
|
||||
<span
|
||||
key={method.key}
|
||||
style={{
|
||||
...styles.otherLoginIcon,
|
||||
color: THEME.goldPrimary,
|
||||
color: method.color,
|
||||
}}
|
||||
onClick={() => handleTabChange('phone')}
|
||||
onClick={method.onClick}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(212, 175, 55, 0.1)';
|
||||
e.currentTarget.style.background = method.hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<MobileOutlined /> 手机
|
||||
{method.icon} {method.label}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
...styles.otherLoginIcon,
|
||||
color: THEME.wechat,
|
||||
}}
|
||||
onClick={isMobile ? handleWechatH5Login : () => handleTabChange('wechat')}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(7, 193, 96, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<WechatOutlined /> 微信
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<div style={styles.dividerLine} />
|
||||
</div>
|
||||
@@ -559,26 +722,52 @@ export default function AuthFormContent() {
|
||||
|
||||
// 获取右上角折角的颜色(根据要切换到的登录方式)
|
||||
const getCornerColor = () => {
|
||||
// 显示要切换到的方式的颜色
|
||||
// 微信 Tab -> 显示手机颜色(金色),手机/密码 Tab -> 显示微信颜色(绿色)
|
||||
return activeTab === 'wechat' ? THEME.goldPrimary : THEME.wechat;
|
||||
};
|
||||
|
||||
// 获取右上角图标
|
||||
const getCornerIcon = () => {
|
||||
// 显示要切换到的方式的图标:手机图标 或 二维码图标
|
||||
// 微信 Tab -> 显示手机图标,手机/密码 Tab -> 显示二维码图标
|
||||
return activeTab === 'wechat' ? <MobileOutlined /> : <QrcodeOutlined />;
|
||||
};
|
||||
|
||||
// 获取右上角切换的目标 Tab
|
||||
const getCornerTargetTab = () => {
|
||||
// 微信 Tab -> 切换到手机,手机/密码 Tab -> 切换到微信
|
||||
return activeTab === 'wechat' ? 'phone' : 'wechat';
|
||||
};
|
||||
|
||||
// 获取右上角提示文字
|
||||
const getCornerTooltip = () => {
|
||||
if (activeTab === 'wechat') return '切换到验证码登录';
|
||||
return '切换到微信登录';
|
||||
};
|
||||
|
||||
// 渲染内容区域
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'wechat':
|
||||
return renderWechatLogin();
|
||||
case 'phone':
|
||||
return renderPhoneForm();
|
||||
case 'password':
|
||||
return renderPasswordForm();
|
||||
default:
|
||||
return renderWechatLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-form-content" style={{ position: 'relative' }}>
|
||||
{/* 右上角折角切换图标 */}
|
||||
<Tooltip
|
||||
title={activeTab === 'wechat' ? '切换到验证码登录' : '切换到微信登录'}
|
||||
title={getCornerTooltip()}
|
||||
placement="left"
|
||||
>
|
||||
<div
|
||||
style={styles.cornerSwitch}
|
||||
onClick={() => handleTabChange(activeTab === 'wechat' ? 'phone' : 'wechat')}
|
||||
onClick={() => handleTabChange(getCornerTargetTab())}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -597,7 +786,7 @@ export default function AuthFormContent() {
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div style={styles.contentArea}>
|
||||
{activeTab === 'wechat' ? renderWechatLogin() : renderPhoneForm()}
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* 底部其他登录方式 */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Settings/SettingsPage.js
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
Save,
|
||||
RotateCcw,
|
||||
TrendingUp,
|
||||
Lock,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import { WechatOutlined } from "@ant-design/icons";
|
||||
import { Form, Input as AntInput, Select as AntSelect, DatePicker, ConfigProvider, Modal as AntModal, Upload, message, Button as AntButton, Space } from "antd";
|
||||
@@ -268,7 +270,7 @@ const maskEmail = (email) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 账号安全面板(手机/邮箱/微信绑定)
|
||||
* 账号安全面板(手机/邮箱/微信绑定/密码设置)
|
||||
*/
|
||||
const SecurityPanel = ({
|
||||
user,
|
||||
@@ -278,6 +280,8 @@ const SecurityPanel = ({
|
||||
setIsLoading,
|
||||
onPhoneOpen,
|
||||
onEmailOpen,
|
||||
onPasswordOpen,
|
||||
passwordStatus,
|
||||
cardBg,
|
||||
borderColor,
|
||||
headingColor,
|
||||
@@ -287,6 +291,48 @@ const SecurityPanel = ({
|
||||
}) => {
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 登录密码设置 */}
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Heading size="md" fontWeight="bold" color={headingColor}>
|
||||
登录密码
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack>
|
||||
<KeyRound size={20} color="#D4AF37" />
|
||||
<Text fontWeight="bold" fontSize="lg" color="white">
|
||||
{passwordStatus?.hasPassword ? "已设置密码" : "未设置密码"}
|
||||
</Text>
|
||||
{passwordStatus?.hasPassword && (
|
||||
<Badge colorScheme="green" size="sm">
|
||||
已设置
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<Text fontSize="sm" color={subTextColor}>
|
||||
{passwordStatus?.hasPassword
|
||||
? "设置密码后可使用账号密码登录"
|
||||
: "设置密码后可使用手机号/邮箱 + 密码登录"}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
leftIcon={passwordStatus?.hasPassword ? <Pencil size={16} /> : <Lock size={16} />}
|
||||
onClick={onPasswordOpen}
|
||||
variant="outline"
|
||||
borderColor="#D4AF37"
|
||||
color="#D4AF37"
|
||||
bg="transparent"
|
||||
_hover={{ bg: "whiteAlpha.100", borderColor: "#F6E5A3", color: "#F6E5A3" }}
|
||||
>
|
||||
{passwordStatus?.hasPassword ? "修改密码" : "设置密码"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 手机号绑定 */}
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
@@ -1107,6 +1153,50 @@ export default function SettingsPage() {
|
||||
onOpen: onEmailOpen,
|
||||
onClose: onEmailClose,
|
||||
} = useDisclosure();
|
||||
const {
|
||||
isOpen: isPasswordOpen,
|
||||
onOpen: onPasswordOpen,
|
||||
onClose: onPasswordClose,
|
||||
} = useDisclosure();
|
||||
|
||||
// 密码状态
|
||||
const [passwordStatus, setPasswordStatus] = useState({
|
||||
hasPassword: false,
|
||||
isWechatUser: false,
|
||||
});
|
||||
|
||||
// 密码表单状态
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
// 获取密码状态
|
||||
const fetchPasswordStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(getApiBase() + "/api/account/password-status", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setPasswordStatus({
|
||||
hasPassword: data.data.hasPassword,
|
||||
isWechatUser: data.data.isWechatUser,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("SettingsPage", "fetchPasswordStatus", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件挂载时获取密码状态
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchPasswordStatus();
|
||||
}
|
||||
}, [user, fetchPasswordStatus]);
|
||||
|
||||
// 表单状态
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -1224,6 +1314,94 @@ export default function SettingsPage() {
|
||||
setSearchParams({ tab });
|
||||
};
|
||||
|
||||
// 修改/设置密码
|
||||
const handlePasswordChange = async () => {
|
||||
const { currentPassword, newPassword, confirmPassword } = passwordForm;
|
||||
|
||||
// 验证新密码
|
||||
if (!newPassword) {
|
||||
toast({
|
||||
title: "请输入新密码",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
toast({
|
||||
title: "密码至少需要6个字符",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast({
|
||||
title: "两次输入的密码不一致",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已有密码且不是微信用户首次设置,需要验证当前密码
|
||||
if (passwordStatus.hasPassword && !passwordStatus.isWechatUser && !currentPassword) {
|
||||
toast({
|
||||
title: "请输入当前密码",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch(getApiBase() + "/api/account/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
currentPassword: currentPassword || undefined,
|
||||
newPassword,
|
||||
isFirstSet: !passwordStatus.hasPassword,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "操作失败");
|
||||
|
||||
toast({
|
||||
title: passwordStatus.hasPassword ? "密码修改成功" : "密码设置成功",
|
||||
description: "现在可以使用密码登录了",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// 重置表单并关闭模态框
|
||||
setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||
onPasswordClose();
|
||||
|
||||
// 刷新密码状态
|
||||
fetchPasswordStatus();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "操作失败",
|
||||
description: error.message,
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = async (type) => {
|
||||
setIsLoading(true);
|
||||
@@ -1405,6 +1583,8 @@ export default function SettingsPage() {
|
||||
setIsLoading={setIsLoading}
|
||||
onPhoneOpen={onPhoneOpen}
|
||||
onEmailOpen={onEmailOpen}
|
||||
onPasswordOpen={onPasswordOpen}
|
||||
passwordStatus={passwordStatus}
|
||||
profileEvents={profileEvents}
|
||||
{...commonProps}
|
||||
/>
|
||||
@@ -1605,6 +1785,82 @@ export default function SettingsPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</AntModal>
|
||||
|
||||
{/* 设置/修改密码模态框 - antd 黑金主题 */}
|
||||
<AntModal
|
||||
title={passwordStatus.hasPassword ? "修改密码" : "设置密码"}
|
||||
open={isPasswordOpen}
|
||||
onCancel={() => {
|
||||
setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||
onPasswordClose();
|
||||
}}
|
||||
centered
|
||||
closeIcon={<Text color="#D4AF37" fontSize="lg">×</Text>}
|
||||
footer={
|
||||
<Space>
|
||||
<AntButton
|
||||
onClick={() => {
|
||||
setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||
onPasswordClose();
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
borderColor: "#666",
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</AntButton>
|
||||
<AntButton
|
||||
onClick={handlePasswordChange}
|
||||
loading={isLoading}
|
||||
style={{
|
||||
background: "transparent",
|
||||
borderColor: "#D4AF37",
|
||||
color: "#D4AF37",
|
||||
}}
|
||||
>
|
||||
{passwordStatus.hasPassword ? "确认修改" : "确认设置"}
|
||||
</AntButton>
|
||||
</Space>
|
||||
}
|
||||
styles={{
|
||||
header: { background: "#0D0D0D", borderBottom: "1px solid #D4AF37", paddingBottom: 16, color: "#D4AF37" },
|
||||
body: { background: "#0D0D0D", padding: "24px 24px 16px" },
|
||||
content: { background: "#0D0D0D", borderRadius: 12, border: "1px solid #D4AF37" },
|
||||
footer: { background: "#0D0D0D", borderTop: "1px solid #333", paddingTop: 16 },
|
||||
}}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
{/* 如果已有密码且不是微信用户首次设置,需要输入当前密码 */}
|
||||
{passwordStatus.hasPassword && (
|
||||
<Form.Item label={<Text color="#D4AF37">当前密码</Text>}>
|
||||
<AntInput.Password
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, currentPassword: e.target.value }))}
|
||||
placeholder="请输入当前密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label={<Text color="#D4AF37">新密码</Text>}>
|
||||
<AntInput.Password
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, newPassword: e.target.value }))}
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={<Text color="#D4AF37">确认新密码</Text>}>
|
||||
<AntInput.Password
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
密码要求:至少6个字符,设置后可使用手机号/邮箱 + 密码登录
|
||||
</Text>
|
||||
</Form>
|
||||
</AntModal>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user