更新ios
This commit is contained in:
@@ -26,8 +26,8 @@ import Profile from "../screens/Profile";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Register from "../screens/Register";
|
import Register from "../screens/Register";
|
||||||
import Search from "../screens/Search";
|
import Search from "../screens/Search";
|
||||||
// settings
|
// settings - 使用新的个人信息设置页面
|
||||||
import SettingsScreen from "../screens/Settings";
|
import SettingsScreen from "../src/screens/Profile/SettingsScreen";
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
import { createDrawerNavigator } from "@react-navigation/drawer";
|
import { createDrawerNavigator } from "@react-navigation/drawer";
|
||||||
import { createStackNavigator } from "@react-navigation/stack";
|
import { createStackNavigator } from "@react-navigation/stack";
|
||||||
@@ -114,10 +114,8 @@ function SettingsStack(props) {
|
|||||||
name="Settings"
|
name="Settings"
|
||||||
component={SettingsScreen}
|
component={SettingsScreen}
|
||||||
options={{
|
options={{
|
||||||
header: ({ navigation, scene }) => (
|
headerShown: false,
|
||||||
<Header title="Settings" scene={scene} navigation={navigation} />
|
cardStyle: { backgroundColor: "#0A0A0F" },
|
||||||
),
|
|
||||||
cardStyle: { backgroundColor: "#F8F9FE" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<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(
|
const hasSubscriptionLevel = useCallback(
|
||||||
(requiredLevel) => {
|
(requiredLevel) => {
|
||||||
@@ -153,6 +165,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
loginWithCode,
|
loginWithCode,
|
||||||
logout,
|
logout,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
|
updateUser,
|
||||||
hasSubscriptionLevel,
|
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 React, { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Form, Input, Button, Typography, Modal, message, Tooltip } from 'antd';
|
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 { useBreakpointValue } from "@chakra-ui/react";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import { useAuthModal } from "../../hooks/useAuthModal";
|
import { useAuthModal } from "../../hooks/useAuthModal";
|
||||||
@@ -162,6 +162,11 @@ const AUTH_CONFIG = {
|
|||||||
title: "手机号登录",
|
title: "手机号登录",
|
||||||
subtitle: "未注册手机号登录时将自动创建价值前沿账号",
|
subtitle: "未注册手机号登录时将自动创建价值前沿账号",
|
||||||
},
|
},
|
||||||
|
// 密码登录内容区文案
|
||||||
|
password: {
|
||||||
|
title: "密码登录",
|
||||||
|
subtitle: "使用手机号/邮箱 + 密码登录",
|
||||||
|
},
|
||||||
buttonText: "登录/注册",
|
buttonText: "登录/注册",
|
||||||
loadingText: "验证中...",
|
loadingText: "验证中...",
|
||||||
successDescription: "欢迎!",
|
successDescription: "欢迎!",
|
||||||
@@ -184,7 +189,7 @@ export default function AuthFormContent() {
|
|||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const wechatRef = useRef(null);
|
const wechatRef = useRef(null);
|
||||||
|
|
||||||
// Tab 状态: 'wechat' | 'phone'
|
// Tab 状态: 'wechat' | 'phone' | 'password'
|
||||||
const [activeTab, setActiveTab] = useState('wechat');
|
const [activeTab, setActiveTab] = useState('wechat');
|
||||||
|
|
||||||
// 表单状态
|
// 表单状态
|
||||||
@@ -196,6 +201,9 @@ export default function AuthFormContent() {
|
|||||||
const [sendingCode, setSendingCode] = useState(false);
|
const [sendingCode, setSendingCode] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
|
||||||
|
// 密码登录表单状态
|
||||||
|
const [passwordFormData, setPasswordFormData] = useState({ username: "", password: "" });
|
||||||
|
|
||||||
// 响应式
|
// 响应式
|
||||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||||
|
|
||||||
@@ -204,8 +212,8 @@ export default function AuthFormContent() {
|
|||||||
|
|
||||||
// 切换 Tab
|
// 切换 Tab
|
||||||
const handleTabChange = (tab) => {
|
const handleTabChange = (tab) => {
|
||||||
// 切换到手机登录时,停止微信轮询
|
// 切换到非微信登录时,停止微信轮询
|
||||||
if (tab === 'phone' && wechatRef.current) {
|
if (tab !== 'wechat' && wechatRef.current) {
|
||||||
wechatRef.current.stopPolling();
|
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(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
authEvents.trackLoginPageViewed();
|
authEvents.trackLoginPageViewed();
|
||||||
@@ -504,53 +558,162 @@ export default function AuthFormContent() {
|
|||||||
</div>
|
</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 renderBottomDivider = () => {
|
||||||
const isWechatTab = activeTab === 'wechat';
|
// 密码登录页面不显示底部切换(已经有内部切换链接)
|
||||||
|
if (activeTab === 'password') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 移动端微信 Tab 下不显示底部切换(因为移动端微信是 H5 跳转)
|
// 移动端微信 Tab 下不显示底部切换(因为移动端微信是 H5 跳转)
|
||||||
if (isMobile && isWechatTab) {
|
if (isMobile && activeTab === 'wechat') {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div style={styles.bottomDivider}>
|
<div style={styles.bottomDivider}>
|
||||||
<div style={styles.dividerLine} />
|
<div style={styles.dividerLine} />
|
||||||
<div style={styles.otherLoginWrapper}>
|
<div style={styles.otherLoginWrapper}>
|
||||||
<span>其他登录方式:</span>
|
<span>其他方式:</span>
|
||||||
{isWechatTab ? (
|
{otherMethods.map((method) => (
|
||||||
<span
|
<span
|
||||||
|
key={method.key}
|
||||||
style={{
|
style={{
|
||||||
...styles.otherLoginIcon,
|
...styles.otherLoginIcon,
|
||||||
color: THEME.goldPrimary,
|
color: method.color,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleTabChange('phone')}
|
onClick={method.onClick}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'rgba(212, 175, 55, 0.1)';
|
e.currentTarget.style.background = method.hoverBg;
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
e.currentTarget.style.background = 'transparent';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MobileOutlined /> 手机
|
{method.icon} {method.label}
|
||||||
</span>
|
</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>
|
||||||
<div style={styles.dividerLine} />
|
<div style={styles.dividerLine} />
|
||||||
</div>
|
</div>
|
||||||
@@ -559,26 +722,52 @@ export default function AuthFormContent() {
|
|||||||
|
|
||||||
// 获取右上角折角的颜色(根据要切换到的登录方式)
|
// 获取右上角折角的颜色(根据要切换到的登录方式)
|
||||||
const getCornerColor = () => {
|
const getCornerColor = () => {
|
||||||
// 显示要切换到的方式的颜色
|
// 微信 Tab -> 显示手机颜色(金色),手机/密码 Tab -> 显示微信颜色(绿色)
|
||||||
return activeTab === 'wechat' ? THEME.goldPrimary : THEME.wechat;
|
return activeTab === 'wechat' ? THEME.goldPrimary : THEME.wechat;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取右上角图标
|
// 获取右上角图标
|
||||||
const getCornerIcon = () => {
|
const getCornerIcon = () => {
|
||||||
// 显示要切换到的方式的图标:手机图标 或 二维码图标
|
// 微信 Tab -> 显示手机图标,手机/密码 Tab -> 显示二维码图标
|
||||||
return activeTab === 'wechat' ? <MobileOutlined /> : <QrcodeOutlined />;
|
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 (
|
return (
|
||||||
<div className="auth-form-content" style={{ position: 'relative' }}>
|
<div className="auth-form-content" style={{ position: 'relative' }}>
|
||||||
{/* 右上角折角切换图标 */}
|
{/* 右上角折角切换图标 */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={activeTab === 'wechat' ? '切换到验证码登录' : '切换到微信登录'}
|
title={getCornerTooltip()}
|
||||||
placement="left"
|
placement="left"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={styles.cornerSwitch}
|
style={styles.cornerSwitch}
|
||||||
onClick={() => handleTabChange(activeTab === 'wechat' ? 'phone' : 'wechat')}
|
onClick={() => handleTabChange(getCornerTargetTab())}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -597,7 +786,7 @@ export default function AuthFormContent() {
|
|||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<div style={styles.contentArea}>
|
<div style={styles.contentArea}>
|
||||||
{activeTab === 'wechat' ? renderWechatLogin() : renderPhoneForm()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部其他登录方式 */}
|
{/* 底部其他登录方式 */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/views/Settings/SettingsPage.js
|
// src/views/Settings/SettingsPage.js
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
VStack,
|
VStack,
|
||||||
@@ -50,6 +50,8 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
Lock,
|
||||||
|
KeyRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { WechatOutlined } from "@ant-design/icons";
|
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";
|
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 = ({
|
const SecurityPanel = ({
|
||||||
user,
|
user,
|
||||||
@@ -278,6 +280,8 @@ const SecurityPanel = ({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
onPhoneOpen,
|
onPhoneOpen,
|
||||||
onEmailOpen,
|
onEmailOpen,
|
||||||
|
onPasswordOpen,
|
||||||
|
passwordStatus,
|
||||||
cardBg,
|
cardBg,
|
||||||
borderColor,
|
borderColor,
|
||||||
headingColor,
|
headingColor,
|
||||||
@@ -287,6 +291,48 @@ const SecurityPanel = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<VStack spacing={6} align="stretch">
|
<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}>
|
<Card bg={cardBg} borderColor={borderColor}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1107,6 +1153,50 @@ export default function SettingsPage() {
|
|||||||
onOpen: onEmailOpen,
|
onOpen: onEmailOpen,
|
||||||
onClose: onEmailClose,
|
onClose: onEmailClose,
|
||||||
} = useDisclosure();
|
} = 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);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -1224,6 +1314,94 @@ export default function SettingsPage() {
|
|||||||
setSearchParams({ tab });
|
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) => {
|
const sendVerificationCode = async (type) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -1405,6 +1583,8 @@ export default function SettingsPage() {
|
|||||||
setIsLoading={setIsLoading}
|
setIsLoading={setIsLoading}
|
||||||
onPhoneOpen={onPhoneOpen}
|
onPhoneOpen={onPhoneOpen}
|
||||||
onEmailOpen={onEmailOpen}
|
onEmailOpen={onEmailOpen}
|
||||||
|
onPasswordOpen={onPasswordOpen}
|
||||||
|
passwordStatus={passwordStatus}
|
||||||
profileEvents={profileEvents}
|
profileEvents={profileEvents}
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
/>
|
/>
|
||||||
@@ -1605,6 +1785,82 @@ export default function SettingsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</AntModal>
|
</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>
|
</ConfigProvider>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user