更新ios

This commit is contained in:
2026-01-22 19:30:48 +08:00
parent fd772ebdf2
commit 939d5ee77e
6 changed files with 1223 additions and 42 deletions

View File

@@ -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

View File

@@ -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,
}; };

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

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

View File

@@ -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>
{/* 底部其他登录方式 */} {/* 底部其他登录方式 */}

View File

@@ -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>
); );