新增功能: - 发票管理页面:支持查看发票列表、统计、Tab筛选 - 发票申请流程:支持电子/纸质发票、个人/企业抬头 - 发票状态追踪:待处理、处理中、已完成、已取消 - 发票抬头模板:支持保存和复用常用抬头 - 发票下载:已完成的电子发票可下载 组件架构: - InvoiceCard: 发票卡片展示(React.memo优化) - InvoiceApplyForm: 开票申请表单 - InvoiceApplyModal: 申请弹窗 - InvoiceStatusBadge: 状态徽章 - InvoiceTitleSelector: 抬头选择器 - InvoiceTypeSelector: 发票类型选择 入口集成: - 设置页添加"账单与发票"Tab - Billing页面添加发票管理入口 - 订阅支付成功后提示开票 Mock数据: - 多用户发票数据(不同状态) - 可开票订单 - 抬头模板 性能优化: - InvoiceCard 使用 React.memo 避免不必要重渲染 - useColorModeValue 移到组件顶层调用 - loadInvoices/loadStats 使用 useCallback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
611 lines
31 KiB
JavaScript
611 lines
31 KiB
JavaScript
// src/views/Settings/SettingsPage.js
|
||
import React, { useState } from 'react';
|
||
import {
|
||
Box,
|
||
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 { Link2, Trash2, Pencil, Smartphone, Mail, FileText, CreditCard } from 'lucide-react';
|
||
import { WechatOutlined } from '@ant-design/icons';
|
||
import { useNavigate } from 'react-router-dom';
|
||
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();
|
||
const navigate = useNavigate();
|
||
|
||
// 深色模式固定颜色(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 (
|
||
<Box 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>
|
||
<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>
|
||
<Smartphone />
|
||
<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={<Trash2 size={16} />}
|
||
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={<Link2 size={16} />} 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>
|
||
<Mail />
|
||
<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={<Pencil size={16} />} 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>
|
||
<WechatOutlined style={{ 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={<Trash2 size={16} />}
|
||
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={<Link2 size={16} />} 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>
|
||
|
||
{/* 账单与发票 */}
|
||
<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>
|
||
<CreditCard size={20} />
|
||
<Text fontWeight="medium" color={textColor}>
|
||
{user?.subscription_type === 'max' ? 'Max 会员' :
|
||
user?.subscription_type === 'pro' ? 'Pro 会员' : '免费版'}
|
||
</Text>
|
||
{user?.subscription_type && user?.subscription_type !== 'free' && (
|
||
<Badge colorScheme="purple" size="sm">有效</Badge>
|
||
)}
|
||
</HStack>
|
||
<Text fontSize="sm" color={subTextColor}>
|
||
管理您的会员订阅和续费
|
||
</Text>
|
||
</VStack>
|
||
<Button
|
||
colorScheme="purple"
|
||
onClick={() => navigate('/home/pages/account/subscription')}
|
||
>
|
||
管理订阅
|
||
</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>
|
||
<FileText size={20} />
|
||
<Text fontWeight="medium" color={textColor}>
|
||
电子发票申请与下载
|
||
</Text>
|
||
</HStack>
|
||
<Text fontSize="sm" color={subTextColor}>
|
||
已支付的订单可申请开具电子发票
|
||
</Text>
|
||
</VStack>
|
||
<Button
|
||
colorScheme="teal"
|
||
onClick={() => navigate('/home/pages/account/invoice')}
|
||
>
|
||
管理发票
|
||
</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>
|
||
|
||
</Box>
|
||
);
|
||
} |