Files
vf_react/src/views/Settings/SettingsPage.js
2025-12-12 11:04:09 +08:00

548 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/Settings/SettingsPage.js
import React, { useState } from 'react';
import {
Box,
Container,
VStack,
HStack,
Text,
Heading,
Button,
Input,
FormControl,
FormLabel,
Card,
CardBody,
CardHeader,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure,
Badge,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
PinInput,
PinInputField
} from '@chakra-ui/react';
import {
LinkIcon,
DeleteIcon,
EditIcon
} from '@chakra-ui/icons';
import { FaWeixin, FaMobile, FaEnvelope } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { getApiBase } from '../../utils/apiConfig';
import { logger } from '../../utils/logger';
import { useProfileEvents } from '../../hooks/useProfileEvents';
export default function SettingsPage() {
const { user, updateUser } = useAuth();
const toast = useToast();
// 深色模式固定颜色Settings 页面始终使用深色主题)
const headingColor = 'white';
const textColor = 'gray.100';
const subTextColor = 'gray.400';
const cardBg = 'gray.800';
const borderColor = 'gray.600';
// 🎯 初始化设置页面埋点Hook
const profileEvents = useProfileEvents({ pageType: 'settings' });
// 模态框状态
const { isOpen: isPhoneOpen, onOpen: onPhoneOpen, onClose: onPhoneClose } = useDisclosure();
const { isOpen: isEmailOpen, onOpen: onEmailOpen, onClose: onEmailClose } = useDisclosure();
// 表单状态
const [isLoading, setIsLoading] = useState(false);
const [phoneForm, setPhoneForm] = useState({
phone: '',
verificationCode: ''
});
const [emailForm, setEmailForm] = useState({
email: '',
verificationCode: ''
});
// 发送验证码
const sendVerificationCode = async (type) => {
setIsLoading(true);
try {
if (type === 'phone') {
const url = '/api/account/phone/send-code';
logger.api.request('POST', url, { phone: phoneForm.phone.substring(0, 3) + '****' });
const res = await fetch(getApiBase() + url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone: phoneForm.phone })
});
const data = await res.json();
logger.api.response('POST', url, res.status, data);
if (!res.ok) throw new Error(data.error || '发送失败');
} else {
const url = '/api/account/email/send-bind-code';
logger.api.request('POST', url, { email: emailForm.email.substring(0, 3) + '***@***' });
// 使用绑定邮箱的验证码API
const res = await fetch(getApiBase() + url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: emailForm.email })
});
const data = await res.json();
logger.api.response('POST', url, res.status, data);
if (!res.ok) throw new Error(data.error || '发送失败');
}
// ❌ 移除验证码发送成功toast
logger.info('SettingsPage', `${type === 'phone' ? '短信' : '邮件'}验证码已发送`);
} catch (error) {
logger.error('SettingsPage', 'sendVerificationCode', error, { type });
toast({
title: "发送失败",
description: error.message,
status: "error",
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
// 绑定手机号
const handlePhoneBind = async () => {
setIsLoading(true);
try {
const res = await fetch(getApiBase() + '/api/account/phone/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone: phoneForm.phone, code: phoneForm.verificationCode })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '绑定失败');
updateUser({
phone: phoneForm.phone,
phone_confirmed: true
});
toast({
title: "手机号绑定成功",
status: "success",
duration: 3000,
isClosable: true,
});
setPhoneForm({ phone: '', verificationCode: '' });
onPhoneClose();
} catch (error) {
toast({
title: "绑定失败",
description: error.message,
status: "error",
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
// 更换邮箱
const handleEmailBind = async () => {
setIsLoading(true);
try {
// 调用真实的邮箱绑定API
const res = await fetch(getApiBase() + '/api/account/email/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email: emailForm.email,
code: emailForm.verificationCode
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || '绑定失败');
}
// 更新用户信息
updateUser({
email: data.user.email,
email_confirmed: data.user.email_confirmed
});
// 🎯 追踪邮箱绑定成功
profileEvents.trackAccountBound('email', true);
toast({
title: "邮箱绑定成功",
status: "success",
duration: 3000,
isClosable: true,
});
setEmailForm({ email: '', verificationCode: '' });
onEmailClose();
} catch (error) {
// 🎯 追踪邮箱绑定失败
profileEvents.trackAccountBound('email', false);
toast({
title: "绑定失败",
description: error.message,
status: "error",
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
return (
<Container maxW="container.xl" py={8}>
<VStack spacing={8} align="stretch">
{/* 页面标题 */}
<Heading size="lg" color={headingColor}>账户设置</Heading>
<Tabs variant="enclosed" colorScheme="blue">
<TabList>
<Tab color={textColor} _selected={{ color: 'blue.500', borderColor: 'blue.500' }}>账户绑定</Tab>
</TabList>
<TabPanels>
{/* 账户绑定 */}
<TabPanel>
<VStack spacing={6} align="stretch">
{/* 手机号绑定 */}
<Card bg={cardBg} borderColor={borderColor}>
<CardHeader>
<Heading size="md" color={headingColor}>手机号绑定</Heading>
</CardHeader>
<CardBody>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<HStack>
<FaMobile />
<Text fontWeight="medium" color={textColor}>
{user?.phone || '未绑定手机号'}
</Text>
{user?.phone_confirmed && (
<Badge colorScheme="green" size="sm">已验证</Badge>
)}
</HStack>
<Text fontSize="sm" color={subTextColor}>
绑定手机号可用于登录和接收重要通知
</Text>
</VStack>
{user?.phone ? (
<Button
leftIcon={<DeleteIcon />}
colorScheme="red"
variant="outline"
onClick={async () => {
setIsLoading(true);
try {
const res = await fetch(getApiBase() + '/api/account/phone/unbind', {
method: 'POST',
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '解绑失败');
updateUser({ phone: null, phone_confirmed: false });
toast({ title: '手机号解绑成功', status: 'success', duration: 3000, isClosable: true });
} catch (e) {
toast({ title: '解绑失败', description: e.message, status: 'error', duration: 3000, isClosable: true });
} finally {
setIsLoading(false);
}
}}
isLoading={isLoading}
>
解绑
</Button>
) : (
<Button leftIcon={<LinkIcon />} onClick={onPhoneOpen}>
绑定手机号
</Button>
)}
</HStack>
</CardBody>
</Card>
{/* 邮箱绑定 */}
<Card bg={cardBg} borderColor={borderColor}>
<CardHeader>
<Heading size="md" color={headingColor}>邮箱设置</Heading>
</CardHeader>
<CardBody>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<HStack>
<FaEnvelope />
<Text fontWeight="medium" color={textColor}>{user?.email}</Text>
{user?.email_confirmed && (
<Badge colorScheme="green" size="sm">已验证</Badge>
)}
</HStack>
<Text fontSize="sm" color={subTextColor}>
邮箱用于登录和接收重要通知
</Text>
</VStack>
<Button leftIcon={<EditIcon />} onClick={onEmailOpen}>
更换邮箱
</Button>
</HStack>
</CardBody>
</Card>
{/* 微信绑定 */}
<Card bg={cardBg} borderColor={borderColor}>
<CardHeader>
<Heading size="md" color={headingColor}>微信绑定</Heading>
</CardHeader>
<CardBody>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<HStack>
<FaWeixin color="#1aad19" />
<Text fontWeight="medium" color={textColor}>
{user?.has_wechat ? '已绑定微信' : '未绑定微信'}
</Text>
{user?.has_wechat && (
<Badge colorScheme="green" size="sm">已绑定</Badge>
)}
</HStack>
<Text fontSize="sm" color={subTextColor}>
绑定微信可使用微信一键登录
</Text>
</VStack>
{user?.has_wechat ? (
<Button
leftIcon={<DeleteIcon />}
colorScheme="red"
variant="outline"
onClick={async () => {
setIsLoading(true);
try {
const res = await fetch(getApiBase() + '/api/account/wechat/unbind', {
method: 'POST',
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '解绑失败');
updateUser({ has_wechat: false });
toast({ title: '解绑成功', status: 'success', duration: 3000, isClosable: true });
} catch (e) {
toast({ title: '解绑失败', description: e.message, status: 'error', duration: 3000, isClosable: true });
} finally {
setIsLoading(false);
}
}}
>
解绑微信
</Button>
) : (
<Button leftIcon={<LinkIcon />} colorScheme="green" onClick={async () => {
setIsLoading(true);
try {
const base = getApiBase();
const res = await fetch(base + '/api/account/wechat/qrcode', { method: 'GET', credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || '获取二维码失败');
// 打开新的窗口进行扫码
window.open(data.auth_url, '_blank');
// 轮询绑定状态
const sessionId = data.session_id;
const start = Date.now();
const poll = async () => {
if (Date.now() - start > 300000) return; // 5分钟
const r = await fetch(base + '/api/account/wechat/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ session_id: sessionId })
});
const j = await r.json();
if (j.status === 'bind_ready') {
updateUser({ has_wechat: true });
toast({ title: '微信绑定成功', status: 'success', duration: 3000, isClosable: true });
} else if (j.status === 'bind_conflict' || j.status === 'bind_failed' || j.status === 'expired') {
toast({ title: '绑定未完成', description: j.status, status: 'error', duration: 3000, isClosable: true });
} else {
setTimeout(poll, 2000);
}
};
setTimeout(poll, 1500);
} catch (e) {
toast({ title: '获取二维码失败', description: e.message, status: 'error', duration: 3000, isClosable: true });
} finally {
setIsLoading(false);
}
}}>
绑定微信
</Button>
)}
</HStack>
</CardBody>
</Card>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
{/* 绑定手机号模态框 */}
<Modal isOpen={isPhoneOpen} onClose={onPhoneClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>绑定手机号</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl>
<FormLabel>手机号</FormLabel>
<Input
value={phoneForm.phone}
onChange={(e) => setPhoneForm(prev => ({
...prev,
phone: e.target.value
}))}
placeholder="请输入11位手机号"
/>
</FormControl>
<FormControl>
<FormLabel>验证码</FormLabel>
<HStack>
<HStack spacing={2} flex="1">
<PinInput
value={phoneForm.verificationCode}
onChange={(value) => setPhoneForm(prev => ({
...prev,
verificationCode: value
}))}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
</HStack>
<Button
size="sm"
onClick={() => sendVerificationCode('phone')}
isLoading={isLoading}
>
发送验证码
</Button>
</HStack>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onPhoneClose}>
取消
</Button>
<Button
colorScheme="blue"
onClick={handlePhoneBind}
isLoading={isLoading}
>
确认绑定
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 更换邮箱模态框 */}
<Modal isOpen={isEmailOpen} onClose={onEmailClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>更换邮箱</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl>
<FormLabel>新邮箱</FormLabel>
<Input
type="email"
value={emailForm.email}
onChange={(e) => setEmailForm(prev => ({
...prev,
email: e.target.value
}))}
placeholder="请输入新邮箱地址"
/>
</FormControl>
<FormControl>
<FormLabel>验证码</FormLabel>
<HStack>
<HStack spacing={2} flex="1">
<PinInput
value={emailForm.verificationCode}
onChange={(value) => setEmailForm(prev => ({
...prev,
verificationCode: value
}))}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
</HStack>
<Button
size="sm"
onClick={() => sendVerificationCode('email')}
isLoading={isLoading}
>
发送验证码
</Button>
</HStack>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onEmailClose}>
取消
</Button>
<Button
colorScheme="blue"
onClick={handleEmailBind}
isLoading={isLoading}
>
确认更换
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Container>
);
}