Files
vf_react/src/views/Settings/SettingsPage.js
zdl c44389f4fe feat(invoice): 实现发票申请与管理功能
新增功能:
- 发票管理页面:支持查看发票列表、统计、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>
2025-12-26 12:49:51 +08:00

611 lines
31 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,
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>
);
}