548 lines
27 KiB
JavaScript
548 lines
27 KiB
JavaScript
// 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>
|
||
);
|
||
} |