Compare commits
26 Commits
feature_20
...
feature_bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d083ab3aa2 | ||
|
|
2f1177b1c2 | ||
|
|
bf10e3a419 | ||
|
|
1a6d359bb7 | ||
|
|
20e14b496b | ||
|
|
5fbfa0eb42 | ||
|
|
85e24f64f0 | ||
|
|
35f31d4e1b | ||
|
|
58ddb48385 | ||
|
|
1b61b56e3f | ||
|
|
caff57d1f1 | ||
|
|
e93d1e5e81 | ||
|
|
03c925b53d | ||
|
|
3b998b4339 | ||
|
|
b7ad35ba12 | ||
|
|
8d609d5fbf | ||
|
|
a7c5a72061 | ||
|
|
951d867b67 | ||
|
|
c1394dbf19 | ||
|
|
086cc3cddb | ||
|
|
c41e1dba8f | ||
|
|
0017302e5c | ||
|
|
8cf9465412 | ||
|
|
0f72cc633d | ||
|
|
9d6c1e0396 | ||
|
|
dfc59926a3 |
@@ -1,472 +0,0 @@
|
||||
/**
|
||||
* 发票申请表单组件
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
Button,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
Checkbox,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import InvoiceTypeSelector from './InvoiceTypeSelector';
|
||||
import InvoiceTitleSelector from './InvoiceTitleSelector';
|
||||
import type {
|
||||
InvoiceType,
|
||||
InvoiceTitleType,
|
||||
InvoiceTitleTemplate,
|
||||
InvoiceableOrder,
|
||||
CreateInvoiceRequest,
|
||||
} from '@/types/invoice';
|
||||
|
||||
interface InvoiceApplyFormProps {
|
||||
order: InvoiceableOrder;
|
||||
onSubmit: (data: CreateInvoiceRequest) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
invoiceType: InvoiceType;
|
||||
titleType: InvoiceTitleType;
|
||||
title: string;
|
||||
taxNumber: string;
|
||||
companyAddress: string;
|
||||
companyPhone: string;
|
||||
bankName: string;
|
||||
bankAccount: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mailingAddress: string;
|
||||
recipientName: string;
|
||||
recipientPhone: string;
|
||||
remark: string;
|
||||
saveTemplate: boolean;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
title?: string;
|
||||
taxNumber?: string;
|
||||
email?: string;
|
||||
mailingAddress?: string;
|
||||
recipientName?: string;
|
||||
recipientPhone?: string;
|
||||
}
|
||||
|
||||
const planNameMap: Record<string, string> = {
|
||||
pro: 'Pro 专业版',
|
||||
max: 'Max 旗舰版',
|
||||
};
|
||||
|
||||
const billingCycleMap: Record<string, string> = {
|
||||
monthly: '月付',
|
||||
quarterly: '季付',
|
||||
semiannual: '半年付',
|
||||
yearly: '年付',
|
||||
};
|
||||
|
||||
export default function InvoiceApplyForm({
|
||||
order,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: InvoiceApplyFormProps) {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '',
|
||||
taxNumber: '',
|
||||
companyAddress: '',
|
||||
companyPhone: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mailingAddress: '',
|
||||
recipientName: '',
|
||||
recipientPhone: '',
|
||||
remark: '',
|
||||
saveTemplate: false,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [showNewTitleForm, setShowNewTitleForm] = useState(false);
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgCard = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 更新表单字段
|
||||
const updateField = <K extends keyof FormData>(field: K, value: FormData[K]) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// 清除对应错误
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
// 选择抬头模板
|
||||
const handleSelectTemplate = (template: InvoiceTitleTemplate | null) => {
|
||||
if (template) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
title: template.title,
|
||||
titleType: template.titleType,
|
||||
taxNumber: template.taxNumber || '',
|
||||
companyAddress: template.companyAddress || '',
|
||||
companyPhone: template.companyPhone || '',
|
||||
bankName: template.bankName || '',
|
||||
bankAccount: template.bankAccount || '',
|
||||
}));
|
||||
setShowNewTitleForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换抬头类型时清空相关字段
|
||||
useEffect(() => {
|
||||
if (formData.titleType === 'personal') {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
taxNumber: '',
|
||||
companyAddress: '',
|
||||
companyPhone: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
}));
|
||||
}
|
||||
}, [formData.titleType]);
|
||||
|
||||
// 表单验证
|
||||
const validate = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = '请输入发票抬头';
|
||||
}
|
||||
|
||||
if (formData.titleType === 'company' && !formData.taxNumber.trim()) {
|
||||
newErrors.taxNumber = '企业开票必须填写税号';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = '请输入接收邮箱';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = '请输入有效的邮箱地址';
|
||||
}
|
||||
|
||||
// 纸质发票需要邮寄信息
|
||||
if (formData.invoiceType === 'paper') {
|
||||
if (!formData.mailingAddress.trim()) {
|
||||
newErrors.mailingAddress = '请输入邮寄地址';
|
||||
}
|
||||
if (!formData.recipientName.trim()) {
|
||||
newErrors.recipientName = '请输入收件人姓名';
|
||||
}
|
||||
if (!formData.recipientPhone.trim()) {
|
||||
newErrors.recipientPhone = '请输入收件人电话';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
const request: CreateInvoiceRequest = {
|
||||
orderId: order.id,
|
||||
invoiceType: formData.invoiceType,
|
||||
titleType: formData.titleType,
|
||||
title: formData.title,
|
||||
taxNumber: formData.taxNumber || undefined,
|
||||
companyAddress: formData.companyAddress || undefined,
|
||||
companyPhone: formData.companyPhone || undefined,
|
||||
bankName: formData.bankName || undefined,
|
||||
bankAccount: formData.bankAccount || undefined,
|
||||
email: formData.email,
|
||||
phone: formData.phone || undefined,
|
||||
mailingAddress: formData.mailingAddress || undefined,
|
||||
recipientName: formData.recipientName || undefined,
|
||||
recipientPhone: formData.recipientPhone || undefined,
|
||||
remark: formData.remark || undefined,
|
||||
};
|
||||
|
||||
await onSubmit(request);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{/* 订单信息 */}
|
||||
<Box p={4} borderRadius="lg" bg={bgCard}>
|
||||
<Text fontSize="sm" fontWeight="600" mb={3}>
|
||||
开票订单
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
订单号
|
||||
</Text>
|
||||
<Text fontSize="sm">{order.orderNo}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
套餐
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
{planNameMap[order.planName] || order.planName} ·{' '}
|
||||
{billingCycleMap[order.billingCycle] || order.billingCycle}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
开票金额
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="600" color="blue.500">
|
||||
¥{order.amount.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 发票类型 */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm" fontWeight="600">
|
||||
发票类型
|
||||
</FormLabel>
|
||||
<InvoiceTypeSelector
|
||||
value={formData.invoiceType}
|
||||
onChange={(type) => updateField('invoiceType', type)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 抬头类型 */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm" fontWeight="600">
|
||||
抬头类型
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={formData.titleType}
|
||||
onChange={(value: InvoiceTitleType) => updateField('titleType', value)}
|
||||
>
|
||||
<Stack direction="row" spacing={6}>
|
||||
<Radio value="personal">个人</Radio>
|
||||
<Radio value="company">企业</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{/* 发票抬头选择 */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm" fontWeight="600">
|
||||
发票抬头
|
||||
</FormLabel>
|
||||
<InvoiceTitleSelector
|
||||
titleType={formData.titleType}
|
||||
onSelect={handleSelectTemplate}
|
||||
onAddNew={() => setShowNewTitleForm(true)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* 新抬头表单 */}
|
||||
<Collapse in={showNewTitleForm} animateOpacity>
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={4}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel fontSize="sm">发票抬头</FormLabel>
|
||||
<Input
|
||||
placeholder={
|
||||
formData.titleType === 'company'
|
||||
? '请输入公司名称'
|
||||
: '请输入个人姓名'
|
||||
}
|
||||
value={formData.title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.title}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{formData.titleType === 'company' && (
|
||||
<>
|
||||
<FormControl isRequired isInvalid={!!errors.taxNumber}>
|
||||
<FormLabel fontSize="sm">税号</FormLabel>
|
||||
<Input
|
||||
placeholder="请输入纳税人识别号"
|
||||
value={formData.taxNumber}
|
||||
onChange={(e) => updateField('taxNumber', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.taxNumber}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<Alert status="info" fontSize="sm" borderRadius="md">
|
||||
<AlertIcon />
|
||||
以下为选填信息,可用于开具增值税专用发票
|
||||
</Alert>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">公司地址</FormLabel>
|
||||
<Input
|
||||
placeholder="请输入公司地址(选填)"
|
||||
value={formData.companyAddress}
|
||||
onChange={(e) => updateField('companyAddress', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">公司电话</FormLabel>
|
||||
<Input
|
||||
placeholder="请输入公司电话(选填)"
|
||||
value={formData.companyPhone}
|
||||
onChange={(e) => updateField('companyPhone', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<HStack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">开户银行</FormLabel>
|
||||
<Input
|
||||
placeholder="开户银行(选填)"
|
||||
value={formData.bankName}
|
||||
onChange={(e) => updateField('bankName', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">银行账号</FormLabel>
|
||||
<Input
|
||||
placeholder="银行账号(选填)"
|
||||
value={formData.bankAccount}
|
||||
onChange={(e) => updateField('bankAccount', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
isChecked={formData.saveTemplate}
|
||||
onChange={(e) => updateField('saveTemplate', e.target.checked)}
|
||||
>
|
||||
<Text fontSize="sm">保存为常用抬头</Text>
|
||||
</Checkbox>
|
||||
</VStack>
|
||||
</Collapse>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 接收信息 */}
|
||||
<FormControl isRequired isInvalid={!!errors.email}>
|
||||
<FormLabel fontSize="sm" fontWeight="600">
|
||||
接收邮箱
|
||||
</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="发票将发送至此邮箱"
|
||||
value={formData.email}
|
||||
onChange={(e) => updateField('email', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.email}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">联系电话(选填)</FormLabel>
|
||||
<Input
|
||||
placeholder="方便开票人员联系"
|
||||
value={formData.phone}
|
||||
onChange={(e) => updateField('phone', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* 纸质发票邮寄信息 */}
|
||||
<Collapse in={formData.invoiceType === 'paper'} animateOpacity>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Divider />
|
||||
<Text fontSize="sm" fontWeight="600">
|
||||
邮寄信息
|
||||
</Text>
|
||||
|
||||
<FormControl isRequired isInvalid={!!errors.mailingAddress}>
|
||||
<FormLabel fontSize="sm">邮寄地址</FormLabel>
|
||||
<Textarea
|
||||
placeholder="请输入详细的邮寄地址"
|
||||
value={formData.mailingAddress}
|
||||
onChange={(e) => updateField('mailingAddress', e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<FormErrorMessage>{errors.mailingAddress}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<HStack spacing={4}>
|
||||
<FormControl isRequired isInvalid={!!errors.recipientName}>
|
||||
<FormLabel fontSize="sm">收件人</FormLabel>
|
||||
<Input
|
||||
placeholder="收件人姓名"
|
||||
value={formData.recipientName}
|
||||
onChange={(e) => updateField('recipientName', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.recipientName}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.recipientPhone}>
|
||||
<FormLabel fontSize="sm">联系电话</FormLabel>
|
||||
<Input
|
||||
placeholder="收件人电话"
|
||||
value={formData.recipientPhone}
|
||||
onChange={(e) => updateField('recipientPhone', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.recipientPhone}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Collapse>
|
||||
|
||||
{/* 备注 */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">备注(选填)</FormLabel>
|
||||
<Textarea
|
||||
placeholder="如有特殊要求请在此说明"
|
||||
value={formData.remark}
|
||||
onChange={(e) => updateField('remark', e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<HStack justify="flex-end" spacing={3} pt={4}>
|
||||
<Button variant="ghost" onClick={onCancel} isDisabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSubmit}
|
||||
isLoading={loading}
|
||||
loadingText="提交中..."
|
||||
>
|
||||
提交申请
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* 发票申请弹窗
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Text,
|
||||
VStack,
|
||||
Box,
|
||||
HStack,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import InvoiceApplyForm from './InvoiceApplyForm';
|
||||
import { getAvailableOrders, applyInvoice } from '@/services/invoiceService';
|
||||
import type { InvoiceableOrder, CreateInvoiceRequest } from '@/types/invoice';
|
||||
|
||||
interface InvoiceApplyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
orderId?: string; // 可选:指定订单ID
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const planNameMap: Record<string, string> = {
|
||||
pro: 'Pro 专业版',
|
||||
max: 'Max 旗舰版',
|
||||
};
|
||||
|
||||
const billingCycleMap: Record<string, string> = {
|
||||
monthly: '月付',
|
||||
quarterly: '季付',
|
||||
semiannual: '半年付',
|
||||
yearly: '年付',
|
||||
};
|
||||
|
||||
export default function InvoiceApplyModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
orderId,
|
||||
onSuccess,
|
||||
}: InvoiceApplyModalProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [orders, setOrders] = useState<InvoiceableOrder[]>([]);
|
||||
const [selectedOrder, setSelectedOrder] = useState<InvoiceableOrder | null>(null);
|
||||
|
||||
const toast = useToast();
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||
const bgSelected = useColorModeValue('blue.50', 'blue.900');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadOrders();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadOrders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getAvailableOrders();
|
||||
if (res.code === 200 && res.data) {
|
||||
setOrders(res.data);
|
||||
// 如果指定了订单ID,直接选中
|
||||
if (orderId) {
|
||||
const order = res.data.find((o) => o.id === orderId);
|
||||
if (order) {
|
||||
setSelectedOrder(order);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载可开票订单失败:', error);
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法获取可开票订单列表',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: CreateInvoiceRequest) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await applyInvoice(data);
|
||||
if (res.code === 200) {
|
||||
toast({
|
||||
title: '申请成功',
|
||||
description: res.message || '开票申请已提交,预计1-3个工作日内处理',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
});
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
toast({
|
||||
title: '申请失败',
|
||||
description: res.message || '开票申请提交失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交开票申请失败:', error);
|
||||
toast({
|
||||
title: '申请失败',
|
||||
description: '网络错误,请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedOrder(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
size="xl"
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="85vh">
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<FileText size={20} />
|
||||
<Text>申请开票</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{loading ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="lg" />
|
||||
<Text color="gray.500">加载中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : orders.length === 0 ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="lg">暂无可开票订单</Text>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
仅已支付且未开票的订单可申请开票
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : selectedOrder ? (
|
||||
<InvoiceApplyForm
|
||||
order={selectedOrder}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setSelectedOrder(null)}
|
||||
loading={submitting}
|
||||
/>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
请选择要开票的订单
|
||||
</Text>
|
||||
{orders.map((order) => (
|
||||
<Box
|
||||
key={order.id}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
_hover={{ bg: bgHover, borderColor: 'blue.400' }}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontWeight="600">
|
||||
{planNameMap[order.planName] || order.planName}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{order.orderNo} ·{' '}
|
||||
{billingCycleMap[order.billingCycle] || order.billingCycle}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
支付时间: {new Date(order.paidAt).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack align="flex-end" spacing={1}>
|
||||
<Text fontWeight="600" color="blue.500">
|
||||
¥{order.amount.toFixed(2)}
|
||||
</Text>
|
||||
<Button size="sm" colorScheme="blue" variant="outline">
|
||||
申请开票
|
||||
</Button>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* 发票卡片组件
|
||||
* 用于展示发票信息
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { Download, Eye, X, FileText } from 'lucide-react';
|
||||
import InvoiceStatusBadge from './InvoiceStatusBadge';
|
||||
import type { InvoiceInfo } from '@/types/invoice';
|
||||
|
||||
interface InvoiceCardProps {
|
||||
invoice: InvoiceInfo;
|
||||
onView?: () => void;
|
||||
onDownload?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const invoiceTypeMap: Record<string, string> = {
|
||||
electronic: '电子发票',
|
||||
paper: '纸质发票',
|
||||
};
|
||||
|
||||
const titleTypeMap: Record<string, string> = {
|
||||
personal: '个人',
|
||||
company: '企业',
|
||||
};
|
||||
|
||||
function InvoiceCard({
|
||||
invoice,
|
||||
onView,
|
||||
onDownload,
|
||||
onCancel,
|
||||
}: InvoiceCardProps) {
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const headerBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const canDownload = invoice.status === 'completed' && invoice.invoiceType === 'electronic';
|
||||
const canCancel = invoice.status === 'pending';
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
overflow="hidden"
|
||||
transition="all 0.2s"
|
||||
_hover={{ shadow: 'md' }}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<HStack justify="space-between" p={4} bg={headerBg}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FileText} boxSize={5} color="blue.500" />
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Text fontWeight="600" fontSize="sm">
|
||||
{invoice.title}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{invoiceTypeMap[invoice.invoiceType]} · {titleTypeMap[invoice.titleType]}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 内容 */}
|
||||
<VStack align="stretch" spacing={3} p={4}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
订单号
|
||||
</Text>
|
||||
<Text fontSize="sm">{invoice.orderNo}</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
开票金额
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="600" color="blue.500">
|
||||
¥{invoice.amount.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{invoice.invoiceNo && (
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
发票号码
|
||||
</Text>
|
||||
<Text fontSize="sm">{invoice.invoiceNo}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
申请时间
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
{new Date(invoice.createdAt).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{invoice.completedAt && (
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
开具时间
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
{new Date(invoice.completedAt).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{invoice.rejectReason && (
|
||||
<Box p={2} bg="red.50" borderRadius="md">
|
||||
<Text fontSize="xs" color="red.600">
|
||||
拒绝原因: {invoice.rejectReason}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<HStack justify="flex-end" spacing={2} p={3}>
|
||||
{onView && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Icon as={Eye} boxSize={4} />}
|
||||
onClick={onView}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
{canDownload && onDownload && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
leftIcon={<Icon as={Download} boxSize={4} />}
|
||||
onClick={onDownload}
|
||||
>
|
||||
下载发票
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && onCancel && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
leftIcon={<Icon as={X} boxSize={4} />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消申请
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(InvoiceCard);
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 发票状态徽章组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Badge } from '@chakra-ui/react';
|
||||
import type { InvoiceStatus } from '@/types/invoice';
|
||||
|
||||
interface InvoiceStatusBadgeProps {
|
||||
status: InvoiceStatus;
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
InvoiceStatus,
|
||||
{ label: string; colorScheme: string }
|
||||
> = {
|
||||
pending: { label: '待处理', colorScheme: 'yellow' },
|
||||
processing: { label: '处理中', colorScheme: 'blue' },
|
||||
completed: { label: '已开具', colorScheme: 'green' },
|
||||
rejected: { label: '已拒绝', colorScheme: 'red' },
|
||||
cancelled: { label: '已取消', colorScheme: 'gray' },
|
||||
};
|
||||
|
||||
export default function InvoiceStatusBadge({ status }: InvoiceStatusBadgeProps) {
|
||||
const config = statusConfig[status] || { label: '未知', colorScheme: 'gray' };
|
||||
|
||||
return (
|
||||
<Badge
|
||||
colorScheme={config.colorScheme}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* 发票抬头选择器
|
||||
* 支持选择常用抬头或新增抬头
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { Plus, Building2, User } from 'lucide-react';
|
||||
import { getTitleTemplates } from '@/services/invoiceService';
|
||||
import type { InvoiceTitleTemplate, InvoiceTitleType } from '@/types/invoice';
|
||||
|
||||
interface InvoiceTitleSelectorProps {
|
||||
titleType: InvoiceTitleType;
|
||||
onSelect: (template: InvoiceTitleTemplate | null) => void;
|
||||
onAddNew: () => void;
|
||||
}
|
||||
|
||||
export default function InvoiceTitleSelector({
|
||||
titleType,
|
||||
onSelect,
|
||||
onAddNew,
|
||||
}: InvoiceTitleSelectorProps) {
|
||||
const [templates, setTemplates] = useState<InvoiceTitleTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string>('');
|
||||
|
||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getTitleTemplates();
|
||||
if (res.code === 200 && res.data) {
|
||||
setTemplates(res.data);
|
||||
// 自动选中默认模板
|
||||
const defaultTemplate = res.data.find(
|
||||
(t) => t.isDefault && t.titleType === titleType
|
||||
);
|
||||
if (defaultTemplate) {
|
||||
setSelectedId(defaultTemplate.id);
|
||||
onSelect(defaultTemplate);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载发票抬头失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 根据抬头类型筛选模板
|
||||
const filteredTemplates = templates.filter((t) => t.titleType === titleType);
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setSelectedId(id);
|
||||
if (id === 'new') {
|
||||
onSelect(null);
|
||||
onAddNew();
|
||||
} else {
|
||||
const template = templates.find((t) => t.id === id);
|
||||
onSelect(template || null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box py={4} textAlign="center">
|
||||
<Spinner size="sm" />
|
||||
<Text fontSize="sm" color="gray.500" mt={2}>
|
||||
加载常用抬头...
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{filteredTemplates.length > 0 && (
|
||||
<Text fontSize="sm" color="gray.500" mb={1}>
|
||||
选择常用抬头
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<RadioGroup value={selectedId} onChange={handleSelect}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{filteredTemplates.map((template) => (
|
||||
<Box
|
||||
key={template.id}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor={selectedId === template.id ? 'blue.500' : borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
onClick={() => handleSelect(template.id)}
|
||||
_hover={{ bg: bgHover }}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={3}>
|
||||
<Radio value={template.id} />
|
||||
<Icon
|
||||
as={template.titleType === 'company' ? Building2 : User}
|
||||
boxSize={4}
|
||||
color="gray.500"
|
||||
/>
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<HStack>
|
||||
<Text fontSize="sm" fontWeight="500">
|
||||
{template.title}
|
||||
</Text>
|
||||
{template.isDefault && (
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
默认
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{template.taxNumber && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
税号: {template.taxNumber}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 新增抬头选项 */}
|
||||
<Box
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
border="1px dashed"
|
||||
borderColor={borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
onClick={() => handleSelect('new')}
|
||||
_hover={{ bg: bgHover, borderColor: 'blue.400' }}
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
<Radio value="new" />
|
||||
<Icon as={Plus} boxSize={4} color="blue.500" />
|
||||
<Text fontSize="sm" color="blue.500">
|
||||
使用新的发票抬头
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</RadioGroup>
|
||||
|
||||
{filteredTemplates.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Icon as={Plus} />}
|
||||
onClick={onAddNew}
|
||||
colorScheme="blue"
|
||||
>
|
||||
添加发票抬头
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 发票类型选择器
|
||||
* 支持选择电子发票或纸质发票
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { FileText, Truck } from 'lucide-react';
|
||||
import type { InvoiceType } from '@/types/invoice';
|
||||
|
||||
interface InvoiceTypeSelectorProps {
|
||||
value: InvoiceType;
|
||||
onChange: (type: InvoiceType) => void;
|
||||
}
|
||||
|
||||
interface TypeOption {
|
||||
type: InvoiceType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof FileText;
|
||||
}
|
||||
|
||||
const typeOptions: TypeOption[] = [
|
||||
{
|
||||
type: 'electronic',
|
||||
label: '电子发票',
|
||||
description: '即时开具,发送至邮箱',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
type: 'paper',
|
||||
label: '纸质发票',
|
||||
description: '需填写邮寄地址,3-5个工作日',
|
||||
icon: Truck,
|
||||
},
|
||||
];
|
||||
|
||||
export default function InvoiceTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: InvoiceTypeSelectorProps) {
|
||||
const bgSelected = useColorModeValue('blue.50', 'blue.900');
|
||||
const borderSelected = useColorModeValue('blue.500', 'blue.400');
|
||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderDefault = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
return (
|
||||
<HStack spacing={4} w="100%">
|
||||
{typeOptions.map((option) => {
|
||||
const isSelected = value === option.type;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={option.type}
|
||||
flex={1}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border="2px solid"
|
||||
borderColor={isSelected ? borderSelected : borderDefault}
|
||||
bg={isSelected ? bgSelected : 'transparent'}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
onClick={() => onChange(option.type)}
|
||||
_hover={{
|
||||
bg: isSelected ? bgSelected : bgHover,
|
||||
borderColor: isSelected ? borderSelected : 'gray.300',
|
||||
}}
|
||||
>
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<HStack>
|
||||
<Icon
|
||||
as={option.icon}
|
||||
boxSize={5}
|
||||
color={isSelected ? 'blue.500' : 'gray.500'}
|
||||
/>
|
||||
<Text fontWeight="600" fontSize="sm">
|
||||
{option.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{option.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* 发票组件统一导出
|
||||
*/
|
||||
|
||||
export { default as InvoiceStatusBadge } from './InvoiceStatusBadge';
|
||||
export { default as InvoiceTypeSelector } from './InvoiceTypeSelector';
|
||||
export { default as InvoiceTitleSelector } from './InvoiceTitleSelector';
|
||||
export { default as InvoiceApplyForm } from './InvoiceApplyForm';
|
||||
export { default as InvoiceApplyModal } from './InvoiceApplyModal';
|
||||
export { default as InvoiceCard } from './InvoiceCard';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MenuItem,
|
||||
MenuDivider,
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Badge,
|
||||
useColorModeValue
|
||||
|
||||
@@ -231,9 +231,9 @@ export default function SubscriptionContentNew() {
|
||||
if (data.success && (data.data?.status === 'paid' || data.payment_success)) {
|
||||
toast({
|
||||
title: '支付成功!',
|
||||
description: '您的订阅已激活。如需发票,请前往「发票管理」申请',
|
||||
description: '您的订阅已激活',
|
||||
status: 'success',
|
||||
duration: 6000,
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
@@ -683,9 +683,9 @@ export default function SubscriptionContentNew() {
|
||||
|
||||
toast({
|
||||
title: '支付成功!',
|
||||
description: '您的订阅已激活。如需发票,请前往「发票管理」申请',
|
||||
description: '您的订阅已激活',
|
||||
status: 'success',
|
||||
duration: 6000,
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
@@ -721,9 +721,9 @@ export default function SubscriptionContentNew() {
|
||||
if (data.success && (data.data.status === 'paid' || data.payment_success)) {
|
||||
toast({
|
||||
title: '支付成功!',
|
||||
description: '您的订阅已激活。如需发票,请前往「发票管理」申请',
|
||||
description: '您的订阅已激活',
|
||||
status: 'success',
|
||||
duration: 6000,
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -361,8 +361,8 @@ export const PINGAN_BANK_DATA = {
|
||||
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, circulation_share_ratio: 1.54, shareholder_type: '券商', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 267842100, circulation_share_ratio: 1.38, shareholder_type: '法人', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, circulation_share_ratio: 0.80, shareholder_type: '社保', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, circulation_share_ratio: 0.73, shareholder_type: '基金', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, circulation_share_ratio: 0.66, shareholder_type: '保险', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, circulation_share_ratio: 0.73, shareholder_type: '资产管理公司资产管理计划', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, circulation_share_ratio: 0.66, shareholder_type: '保险资产管理产品', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 98765400, circulation_share_ratio: 0.51, shareholder_type: '基金', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 87654300, circulation_share_ratio: 0.45, shareholder_type: '基金', end_date: '2024-09-30' }
|
||||
],
|
||||
@@ -375,8 +375,8 @@ export const PINGAN_BANK_DATA = {
|
||||
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, total_share_ratio: 1.54, shareholder_type: '券商', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 267842100, total_share_ratio: 1.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, total_share_ratio: 0.80, shareholder_type: '社保', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, total_share_ratio: 0.73, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, total_share_ratio: 0.66, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, total_share_ratio: 0.73, shareholder_type: '资产管理公司资产管理计划', share_nature: '限售流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, total_share_ratio: 0.66, shareholder_type: '保险资产管理产品', share_nature: '限售流通A股、质押股份', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 98765400, total_share_ratio: 0.51, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 87654300, total_share_ratio: 0.45, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }
|
||||
],
|
||||
@@ -843,53 +843,105 @@ export const PINGAN_BANK_DATA = {
|
||||
// 关键因素时间线 - 结构与组件期望格式匹配
|
||||
keyFactorsTimeline: {
|
||||
key_factors: {
|
||||
total_factors: 5,
|
||||
total_factors: 8,
|
||||
categories: [
|
||||
{
|
||||
category_name: '正面因素',
|
||||
category_type: 'positive',
|
||||
category_name: '财务指标',
|
||||
factors: [
|
||||
{
|
||||
factor_name: '零售转型深化',
|
||||
impact_score: 9.2,
|
||||
description: '零售业务收入占比持续提升,已超过50%,客户基础和AUM稳步增长',
|
||||
trend: 'improving'
|
||||
factor_name: '净息差',
|
||||
factor_value: 2.45,
|
||||
factor_unit: '%',
|
||||
factor_desc: '利率市场化推进下,净息差较上年同期收窄12bp,但仍处于行业中上水平',
|
||||
impact_direction: 'negative',
|
||||
impact_weight: 85,
|
||||
year_on_year: -4.7,
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
factor_name: '金融科技领先',
|
||||
impact_score: 8.8,
|
||||
description: 'AI、大数据等技术应用深化,智能化转型成效显著',
|
||||
trend: 'stable'
|
||||
factor_name: '不良贷款率',
|
||||
factor_value: 1.05,
|
||||
factor_unit: '%',
|
||||
factor_desc: '资产质量稳中向好,不良贷款率环比下降3bp,拨备覆盖率保持在280%以上',
|
||||
impact_direction: 'positive',
|
||||
impact_weight: 90,
|
||||
year_on_year: -2.8,
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
factor_name: '资产质量稳定',
|
||||
impact_score: 8.5,
|
||||
description: '不良贷款率控制在较低水平,风险抵御能力强',
|
||||
trend: 'stable'
|
||||
factor_name: '零售AUM',
|
||||
factor_value: 4.2,
|
||||
factor_unit: '万亿',
|
||||
factor_desc: '零售客户资产管理规模持续增长,同比增长15.3%,财富管理转型成效显著',
|
||||
impact_direction: 'positive',
|
||||
impact_weight: 88,
|
||||
year_on_year: 15.3,
|
||||
report_period: '2024Q3'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category_name: '负面因素',
|
||||
category_type: 'negative',
|
||||
category_name: '业务发展',
|
||||
factors: [
|
||||
{
|
||||
factor_name: '息差压力',
|
||||
impact_score: 6.5,
|
||||
description: '利率市场化持续推进,净息差面临收窄压力',
|
||||
trend: 'declining'
|
||||
factor_name: '零售营收占比',
|
||||
factor_value: 58.3,
|
||||
factor_unit: '%',
|
||||
factor_desc: '零售银行转型深化,零售业务收入占比持续提升,成为最主要的盈利来源',
|
||||
impact_direction: 'positive',
|
||||
impact_weight: 92,
|
||||
year_on_year: 3.2,
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
factor_name: '对公贷款增速',
|
||||
factor_value: 8.5,
|
||||
factor_unit: '%',
|
||||
factor_desc: '对公业务稳步发展,聚焦制造业、绿色金融等重点领域',
|
||||
impact_direction: 'neutral',
|
||||
impact_weight: 70,
|
||||
year_on_year: -1.2,
|
||||
report_period: '2024Q3'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category_name: '中性因素',
|
||||
category_type: 'neutral',
|
||||
category_name: '风险因素',
|
||||
factors: [
|
||||
{
|
||||
factor_name: '监管趋严',
|
||||
impact_score: 7.0,
|
||||
description: '金融监管持续强化,合规成本有所上升',
|
||||
trend: 'stable'
|
||||
factor_name: '房地产敞口',
|
||||
factor_value: 3200,
|
||||
factor_unit: '亿元',
|
||||
factor_desc: '房地产相关贷款余额约3200亿,占比8.5%,需持续关注行业风险演变',
|
||||
impact_direction: 'negative',
|
||||
impact_weight: 75,
|
||||
year_on_year: -5.2,
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
factor_name: '信用成本',
|
||||
factor_value: 1.32,
|
||||
factor_unit: '%',
|
||||
factor_desc: '信用成本率保持相对稳定,风险抵补能力充足',
|
||||
impact_direction: 'neutral',
|
||||
impact_weight: 72,
|
||||
year_on_year: 0.05,
|
||||
report_period: '2024Q3'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category_name: '战略布局',
|
||||
factors: [
|
||||
{
|
||||
factor_name: '科技投入',
|
||||
factor_value: 120,
|
||||
factor_unit: '亿元',
|
||||
factor_desc: '持续加大金融科技投入,AI、大数据应用深化,数字化转型领先同业',
|
||||
impact_direction: 'positive',
|
||||
impact_weight: 85,
|
||||
year_on_year: 18.5,
|
||||
report_period: '2024'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1422,41 +1474,80 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
|
||||
},
|
||||
keyFactorsTimeline: {
|
||||
key_factors: {
|
||||
total_factors: 3,
|
||||
total_factors: 6,
|
||||
categories: [
|
||||
{
|
||||
category_name: '正面因素',
|
||||
category_type: 'positive',
|
||||
category_name: '财务指标',
|
||||
factors: [
|
||||
{
|
||||
factor_name: '业绩增长',
|
||||
impact_score: 8.5,
|
||||
description: '营收和利润保持稳定增长态势',
|
||||
trend: 'improving'
|
||||
factor_name: '营收增速',
|
||||
factor_value: 12.5,
|
||||
factor_unit: '%',
|
||||
factor_desc: '营业收入保持稳定增长,主营业务发展良好',
|
||||
impact_direction: 'positive',
|
||||
impact_weight: 85,
|
||||
year_on_year: 2.3,
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
factor_name: '毛利率',
|
||||
factor_value: 35.2,
|
||||
factor_unit: '%',
|
||||
factor_desc: '毛利率水平稳定,成本控制能力较强',
|
||||
impact_direction: 'neutral',
|
||||
impact_weight: 75,
|
||||
year_on_year: -0.8,
|
||||
report_period: '2024Q3'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category_name: '负面因素',
|
||||
category_type: 'negative',
|
||||
category_name: '业务发展',
|
||||
factors: [
|
||||
{
|
||||
factor_name: '市场份额',
|
||||
factor_value: 15.8,
|
||||
factor_unit: '%',
|
||||
factor_desc: '市场份额稳步提升,竞争优势逐步巩固',
|
||||
impact_direction: 'positive',
|
||||
impact_weight: 82,
|
||||
year_on_year: 1.5,
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
factor_name: '新产品贡献',
|
||||
factor_value: 22.3,
|
||||
factor_unit: '%',
|
||||
factor_desc: '新产品收入占比提升,创新驱动成效明显',
|
||||
impact_direction: 'positive',
|
||||
impact_weight: 78,
|
||||
year_on_year: 5.2,
|
||||
report_period: '2024Q3'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category_name: '风险因素',
|
||||
factors: [
|
||||
{
|
||||
factor_name: '原材料成本',
|
||||
impact_score: 6.0,
|
||||
description: '原材料价格波动影响毛利率',
|
||||
trend: 'declining'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category_name: '中性因素',
|
||||
category_type: 'neutral',
|
||||
factors: [
|
||||
factor_value: 28.5,
|
||||
factor_unit: '%',
|
||||
factor_desc: '原材料成本占比上升,需关注价格波动影响',
|
||||
impact_direction: 'negative',
|
||||
impact_weight: 70,
|
||||
year_on_year: 3.2,
|
||||
report_period: '2024Q3'
|
||||
},
|
||||
{
|
||||
factor_name: '市场竞争',
|
||||
impact_score: 7.0,
|
||||
description: '行业竞争加剧,需持续提升竞争力',
|
||||
trend: 'stable'
|
||||
factor_name: '应收账款周转',
|
||||
factor_value: 85,
|
||||
factor_unit: '天',
|
||||
factor_desc: '应收账款周转天数有所增加,需加强回款管理',
|
||||
impact_direction: 'negative',
|
||||
impact_weight: 65,
|
||||
year_on_year: 8,
|
||||
report_period: '2024Q3'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1465,14 +1556,50 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
|
||||
development_timeline: {
|
||||
statistics: {
|
||||
positive_events: 4,
|
||||
negative_events: 0,
|
||||
neutral_events: 0
|
||||
negative_events: 1,
|
||||
neutral_events: 1
|
||||
},
|
||||
events: [
|
||||
{ date: '2024-10-28', event: '发布三季报', type: '业绩公告', importance: 'high', impact: '业绩超预期', change: '+3.5%', sentiment: 'positive' },
|
||||
{ date: '2024-08-28', event: '发布中报', type: '业绩公告', importance: 'high', impact: '业绩稳定增长', change: '+2.8%', sentiment: 'positive' },
|
||||
{ date: '2024-06-15', event: '新产品发布', type: '产品发布', importance: 'medium', impact: '丰富产品线', change: '+1.5%', sentiment: 'positive' },
|
||||
{ date: '2024-04-28', event: '发布一季报', type: '业绩公告', importance: 'high', impact: '开门红', change: '+2.2%', sentiment: 'positive' }
|
||||
{
|
||||
event_date: '2024-10-28',
|
||||
event_title: '发布2024年三季报',
|
||||
event_type: '业绩公告',
|
||||
event_desc: '前三季度业绩稳健增长,营收和净利润均实现双位数增长',
|
||||
impact_metrics: { impact_score: 82, is_positive: true },
|
||||
related_info: { financial_impact: '股价当日上涨3.5%' }
|
||||
},
|
||||
{
|
||||
event_date: '2024-08-28',
|
||||
event_title: '发布2024年中报',
|
||||
event_type: '业绩公告',
|
||||
event_desc: '上半年业绩稳定增长,各项经营指标符合预期',
|
||||
impact_metrics: { impact_score: 75, is_positive: true },
|
||||
related_info: { financial_impact: '股价累计上涨2.8%' }
|
||||
},
|
||||
{
|
||||
event_date: '2024-06-15',
|
||||
event_title: '新产品发布会',
|
||||
event_type: '产品发布',
|
||||
event_desc: '发布新一代产品系列,技术升级明显,市场反响良好',
|
||||
impact_metrics: { impact_score: 70, is_positive: true },
|
||||
related_info: { financial_impact: '预计贡献增量收入10-15%' }
|
||||
},
|
||||
{
|
||||
event_date: '2024-05-20',
|
||||
event_title: '原材料价格上涨',
|
||||
event_type: '成本压力',
|
||||
event_desc: '主要原材料价格上涨约8%,对毛利率形成一定压力',
|
||||
impact_metrics: { impact_score: 55, is_positive: false },
|
||||
related_info: { financial_impact: '预计影响毛利率0.5-1个百分点' }
|
||||
},
|
||||
{
|
||||
event_date: '2024-04-28',
|
||||
event_title: '发布2024年一季报',
|
||||
event_type: '业绩公告',
|
||||
event_desc: '一季度业绩实现开门红,主要指标好于市场预期',
|
||||
impact_metrics: { impact_score: 78, is_positive: true },
|
||||
related_info: { financial_impact: '股价上涨2.2%' }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -170,22 +170,84 @@ export const generateMarketData = (stockCode) => {
|
||||
}
|
||||
},
|
||||
|
||||
// 涨停分析 - 返回数组格式,每个元素对应一个交易日
|
||||
// 涨幅分析 - 匹配 RiseAnalysis 类型,每个交易日一条记录
|
||||
riseAnalysisData: {
|
||||
success: true,
|
||||
data: Array(30).fill(null).map((_, i) => {
|
||||
const tradeDate = new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const isLimitUp = Math.random() < 0.05; // 5%概率涨停
|
||||
const riseRate = parseFloat(((Math.random() - 0.5) * 10).toFixed(2)); // -5% ~ +5%
|
||||
const closePrice = parseFloat((basePrice * (1 + riseRate / 100)).toFixed(2));
|
||||
const volume = Math.floor(Math.random() * 500000000) + 100000000;
|
||||
const amount = Math.floor(volume * closePrice);
|
||||
|
||||
// 涨幅分析详情模板
|
||||
const riseReasons = [
|
||||
{
|
||||
brief: '业绩超预期',
|
||||
detail: `## 业绩驱动\n\n${stockInfo.name}发布业绩公告,主要经营指标超出市场预期:\n\n- **营业收入**:同比增长15.3%,环比增长5.2%\n- **净利润**:同比增长18.7%,创历史新高\n- **毛利率**:提升2.1个百分点至35.8%\n\n### 核心亮点\n\n1. 主营业务增长强劲,市场份额持续提升\n2. 成本管控效果显著,盈利能力改善\n3. 新产品放量,贡献增量收入`,
|
||||
announcements: `**重要公告**\n\n1. [${stockInfo.name}:关于2024年度业绩预告的公告](javascript:void(0))\n2. [${stockInfo.name}:关于获得政府补助的公告](javascript:void(0))`
|
||||
},
|
||||
{
|
||||
brief: '政策利好',
|
||||
detail: `## 政策催化\n\n近期行业政策密集出台,对${stockInfo.name}形成重大利好:\n\n### 政策要点\n\n- **行业支持政策**:国家出台支持措施,加大对行业的扶持力度\n- **税收优惠**:符合条件的企业可享受税收减免\n- **融资支持**:拓宽企业融资渠道,降低融资成本\n\n### 受益分析\n\n公司作为行业龙头,有望充分受益于政策红利,预计:\n\n1. 订单量将显著增长\n2. 毛利率有望提升\n3. 市场份额进一步扩大`,
|
||||
announcements: `**相关公告**\n\n1. [${stockInfo.name}:关于行业政策影响的说明公告](javascript:void(0))`
|
||||
},
|
||||
{
|
||||
brief: '资金流入',
|
||||
detail: `## 资金面分析\n\n今日${stockInfo.name}获得主力资金大幅流入:\n\n### 资金流向\n\n| 指标 | 数值 | 变化 |\n|------|------|------|\n| 主力净流入 | 3.2亿 | +156% |\n| 超大单净流入 | 1.8亿 | +89% |\n| 大单净流入 | 1.4亿 | +67% |\n\n### 分析结论\n\n1. 机构资金持续加仓,看好公司长期价值\n2. 北向资金连续3日净买入\n3. 融资余额创近期新高`,
|
||||
announcements: ''
|
||||
},
|
||||
{
|
||||
brief: '技术突破',
|
||||
detail: `## 技术面分析\n\n${stockInfo.name}今日实现技术突破:\n\n### 技术信号\n\n- **突破关键阻力位**:成功站上${(closePrice * 0.95).toFixed(2)}元重要阻力\n- **量价配合良好**:成交量较昨日放大1.5倍\n- **均线多头排列**:5日、10日、20日均线呈多头排列\n\n### 后市展望\n\n技术面看,股价有望继续向上挑战${(closePrice * 1.05).toFixed(2)}元目标位。建议关注:\n\n1. 能否持续放量\n2. 均线支撑情况\n3. MACD金叉确认`,
|
||||
announcements: ''
|
||||
}
|
||||
];
|
||||
|
||||
const reasonIndex = i % riseReasons.length;
|
||||
const reason = riseReasons[reasonIndex];
|
||||
|
||||
// 研报数据
|
||||
const publishers = ['中信证券', '华泰证券', '国泰君安', '招商证券', '中金公司', '海通证券'];
|
||||
const authors = ['张三', '李四', '王五', '赵六', '钱七', '孙八'];
|
||||
const matchScores = ['好', '中', '差'];
|
||||
|
||||
return {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockInfo.name,
|
||||
trade_date: tradeDate,
|
||||
is_limit_up: isLimitUp,
|
||||
limit_up_price: (basePrice * 1.10).toFixed(2),
|
||||
current_price: (basePrice + (Math.random() - 0.5) * 0.5).toFixed(2),
|
||||
distance_to_limit: (Math.random() * 10).toFixed(2), // %
|
||||
consecutive_days: isLimitUp ? Math.floor(Math.random() * 3) + 1 : 0,
|
||||
reason: isLimitUp ? '业绩超预期' : '',
|
||||
concept_tags: ['银行', '深圳国资', 'MSCI', '沪深300'],
|
||||
analysis: isLimitUp ? '股价触及涨停板,资金流入明显' : '股价正常波动,交投活跃'
|
||||
rise_rate: riseRate,
|
||||
close_price: closePrice,
|
||||
volume: volume,
|
||||
amount: amount,
|
||||
main_business: stockInfo.business || '金融服务、零售银行、对公业务、资产管理等',
|
||||
rise_reason_brief: reason.brief,
|
||||
rise_reason_detail: reason.detail,
|
||||
announcements: reason.announcements || '',
|
||||
verification_reports: [
|
||||
{
|
||||
publisher: publishers[i % publishers.length],
|
||||
match_score: matchScores[Math.floor(Math.random() * 3)],
|
||||
match_ratio: parseFloat((Math.random() * 0.5 + 0.5).toFixed(2)),
|
||||
declare_date: tradeDate,
|
||||
report_title: `${stockInfo.name}深度研究:${reason.brief}带来投资机会`,
|
||||
author: authors[i % authors.length],
|
||||
verification_item: `${reason.brief}对公司业绩的影响分析`,
|
||||
content: `我们认为${stockInfo.name}在${reason.brief}的背景下,有望实现业绩的持续增长。维持"买入"评级,目标价${(closePrice * 1.2).toFixed(2)}元。`
|
||||
},
|
||||
{
|
||||
publisher: publishers[(i + 1) % publishers.length],
|
||||
match_score: matchScores[Math.floor(Math.random() * 3)],
|
||||
match_ratio: parseFloat((Math.random() * 0.4 + 0.3).toFixed(2)),
|
||||
declare_date: tradeDate,
|
||||
report_title: `${stockInfo.name}跟踪报告:关注${reason.brief}`,
|
||||
author: authors[(i + 1) % authors.length],
|
||||
verification_item: '估值分析与投资建议',
|
||||
content: `当前估值处于历史中低位,安全边际充足。建议投资者积极关注。`
|
||||
}
|
||||
],
|
||||
update_time: new Date().toISOString().split('T')[0] + ' 18:30:00',
|
||||
create_time: tradeDate + ' 15:30:00'
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ import { agentHandlers } from './agent';
|
||||
import { bytedeskHandlers } from './bytedesk';
|
||||
import { predictionHandlers } from './prediction';
|
||||
import { forumHandlers } from './forum';
|
||||
import { invoiceHandlers } from './invoice';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -43,6 +42,5 @@ export const handlers = [
|
||||
...bytedeskHandlers, // ⚡ Bytedesk 客服 Widget passthrough
|
||||
...predictionHandlers, // 预测市场
|
||||
...forumHandlers, // 价值论坛帖子 (ES)
|
||||
...invoiceHandlers, // 发票管理
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
@@ -1,920 +0,0 @@
|
||||
// src/mocks/handlers/invoice.js
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import { getCurrentUser } from '../data/users';
|
||||
|
||||
// 模拟网络延迟(毫秒)
|
||||
const NETWORK_DELAY = 500;
|
||||
|
||||
// 模拟发票数据存储
|
||||
const mockInvoices = new Map();
|
||||
const mockTitleTemplates = new Map();
|
||||
let invoiceIdCounter = 1000;
|
||||
let templateIdCounter = 100;
|
||||
|
||||
// 模拟可开票订单数据
|
||||
const mockInvoiceableOrders = [
|
||||
{
|
||||
id: 'ORDER_1001_1703001600000',
|
||||
orderNo: 'VF20241220001',
|
||||
planName: 'pro',
|
||||
billingCycle: 'yearly',
|
||||
amount: 2699,
|
||||
paidAt: '2024-12-20T10:00:00Z',
|
||||
invoiceApplied: false,
|
||||
},
|
||||
{
|
||||
id: 'ORDER_1002_1703088000000',
|
||||
orderNo: 'VF20241221001',
|
||||
planName: 'max',
|
||||
billingCycle: 'monthly',
|
||||
amount: 599,
|
||||
paidAt: '2024-12-21T10:00:00Z',
|
||||
invoiceApplied: false,
|
||||
},
|
||||
];
|
||||
|
||||
// 为每个用户生成模拟发票数据
|
||||
const initMockInvoices = () => {
|
||||
// 为用户 ID 1-4 都生成一些发票数据
|
||||
const userInvoiceData = [
|
||||
// 用户1 (免费用户) - 无发票
|
||||
// 用户2 (Pro会员) - 有多张发票
|
||||
{
|
||||
id: 'INV_001',
|
||||
orderId: 'ORDER_999_1702396800000',
|
||||
orderNo: 'VF20241213001',
|
||||
userId: 2,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'company',
|
||||
title: '北京价值前沿科技有限公司',
|
||||
taxNumber: '91110108MA01XXXXX',
|
||||
amount: 2699,
|
||||
email: 'pro@example.com',
|
||||
status: 'completed',
|
||||
invoiceNo: 'E20241213001',
|
||||
invoiceCode: '011001900111',
|
||||
invoiceUrl: 'https://example.com/invoices/E20241213001.pdf',
|
||||
createdAt: '2024-12-13T10:00:00Z',
|
||||
updatedAt: '2024-12-14T15:30:00Z',
|
||||
completedAt: '2024-12-14T15:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_002',
|
||||
orderId: 'ORDER_998_1701792000000',
|
||||
orderNo: 'VF20241206001',
|
||||
userId: 2,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '张三',
|
||||
amount: 599,
|
||||
email: 'pro@example.com',
|
||||
status: 'processing',
|
||||
createdAt: '2024-12-06T10:00:00Z',
|
||||
updatedAt: '2024-12-06T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_003',
|
||||
orderId: 'ORDER_997_1700000000000',
|
||||
orderNo: 'VF20241115001',
|
||||
userId: 2,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '李四',
|
||||
amount: 299,
|
||||
email: 'pro@example.com',
|
||||
status: 'pending',
|
||||
createdAt: '2024-12-24T10:00:00Z',
|
||||
updatedAt: '2024-12-24T10:00:00Z',
|
||||
},
|
||||
// 用户3 (Max会员) - 有发票
|
||||
{
|
||||
id: 'INV_004',
|
||||
orderId: 'ORDER_996_1703000000000',
|
||||
orderNo: 'VF20241220002',
|
||||
userId: 3,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'company',
|
||||
title: '上海科技发展有限公司',
|
||||
taxNumber: '91310115MA01YYYYY',
|
||||
amount: 5999,
|
||||
email: 'max@example.com',
|
||||
status: 'completed',
|
||||
invoiceNo: 'E20241220001',
|
||||
invoiceCode: '011001900222',
|
||||
invoiceUrl: 'https://example.com/invoices/E20241220001.pdf',
|
||||
createdAt: '2024-12-20T10:00:00Z',
|
||||
updatedAt: '2024-12-21T09:00:00Z',
|
||||
completedAt: '2024-12-21T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_005',
|
||||
orderId: 'ORDER_995_1702500000000',
|
||||
orderNo: 'VF20241214001',
|
||||
userId: 3,
|
||||
invoiceType: 'paper',
|
||||
titleType: 'company',
|
||||
title: '上海科技发展有限公司',
|
||||
taxNumber: '91310115MA01YYYYY',
|
||||
amount: 2699,
|
||||
email: 'max@example.com',
|
||||
status: 'processing',
|
||||
createdAt: '2024-12-14T10:00:00Z',
|
||||
updatedAt: '2024-12-15T10:00:00Z',
|
||||
},
|
||||
// 用户1 (测试用户) - 也添加一些发票方便测试
|
||||
{
|
||||
id: 'INV_006',
|
||||
orderId: 'ORDER_994_1703100000000',
|
||||
orderNo: 'VF20241222001',
|
||||
userId: 1,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '测试用户',
|
||||
amount: 299,
|
||||
email: 'test@example.com',
|
||||
status: 'completed',
|
||||
invoiceNo: 'E20241222001',
|
||||
invoiceCode: '011001900333',
|
||||
invoiceUrl: 'https://example.com/invoices/E20241222001.pdf',
|
||||
createdAt: '2024-12-22T10:00:00Z',
|
||||
updatedAt: '2024-12-23T10:00:00Z',
|
||||
completedAt: '2024-12-23T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_007',
|
||||
orderId: 'ORDER_993_1703200000000',
|
||||
orderNo: 'VF20241223001',
|
||||
userId: 1,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'company',
|
||||
title: '测试科技有限公司',
|
||||
taxNumber: '91110108MA01ZZZZZ',
|
||||
amount: 599,
|
||||
email: 'test@example.com',
|
||||
status: 'processing',
|
||||
createdAt: '2024-12-23T14:00:00Z',
|
||||
updatedAt: '2024-12-23T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_008',
|
||||
orderId: 'ORDER_992_1703250000000',
|
||||
orderNo: 'VF20241225001',
|
||||
userId: 1,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '王五',
|
||||
amount: 199,
|
||||
email: 'test@example.com',
|
||||
status: 'pending',
|
||||
createdAt: '2024-12-25T10:00:00Z',
|
||||
updatedAt: '2024-12-25T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_009',
|
||||
orderId: 'ORDER_991_1702000000000',
|
||||
orderNo: 'VF20241208001',
|
||||
userId: 1,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '赵六',
|
||||
amount: 99,
|
||||
email: 'test@example.com',
|
||||
status: 'cancelled',
|
||||
createdAt: '2024-12-08T10:00:00Z',
|
||||
updatedAt: '2024-12-09T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
userInvoiceData.forEach((invoice) => {
|
||||
mockInvoices.set(invoice.id, invoice);
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化模拟抬头模板 - 为每个用户生成
|
||||
const initMockTemplates = () => {
|
||||
const sampleTemplates = [
|
||||
// 用户1 (测试用户) 的模板
|
||||
{
|
||||
id: 'TPL_001',
|
||||
userId: 1,
|
||||
isDefault: true,
|
||||
titleType: 'company',
|
||||
title: '测试科技有限公司',
|
||||
taxNumber: '91110108MA01ZZZZZ',
|
||||
companyAddress: '北京市朝阳区建国路1号',
|
||||
companyPhone: '010-88888888',
|
||||
bankName: '中国建设银行北京分行',
|
||||
bankAccount: '1100001234567890123',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'TPL_002',
|
||||
userId: 1,
|
||||
isDefault: false,
|
||||
titleType: 'personal',
|
||||
title: '测试用户',
|
||||
createdAt: '2024-06-01T00:00:00Z',
|
||||
},
|
||||
// 用户2 (Pro会员) 的模板
|
||||
{
|
||||
id: 'TPL_003',
|
||||
userId: 2,
|
||||
isDefault: true,
|
||||
titleType: 'company',
|
||||
title: '北京价值前沿科技有限公司',
|
||||
taxNumber: '91110108MA01XXXXX',
|
||||
companyAddress: '北京市海淀区中关村大街1号',
|
||||
companyPhone: '010-12345678',
|
||||
bankName: '中国工商银行北京分行',
|
||||
bankAccount: '0200001234567890123',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'TPL_004',
|
||||
userId: 2,
|
||||
isDefault: false,
|
||||
titleType: 'personal',
|
||||
title: '张三',
|
||||
createdAt: '2024-06-01T00:00:00Z',
|
||||
},
|
||||
// 用户3 (Max会员) 的模板
|
||||
{
|
||||
id: 'TPL_005',
|
||||
userId: 3,
|
||||
isDefault: true,
|
||||
titleType: 'company',
|
||||
title: '上海科技发展有限公司',
|
||||
taxNumber: '91310115MA01YYYYY',
|
||||
companyAddress: '上海市浦东新区陆家嘴金融中心',
|
||||
companyPhone: '021-66666666',
|
||||
bankName: '中国银行上海分行',
|
||||
bankAccount: '4400001234567890123',
|
||||
createdAt: '2024-02-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
sampleTemplates.forEach((template) => {
|
||||
mockTitleTemplates.set(template.id, template);
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化数据
|
||||
initMockInvoices();
|
||||
initMockTemplates();
|
||||
|
||||
export const invoiceHandlers = [
|
||||
// ==================== 发票申请管理 ====================
|
||||
|
||||
// 1. 获取可开票订单列表
|
||||
http.get('/api/invoice/available-orders', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 返回未申请开票的订单
|
||||
const availableOrders = mockInvoiceableOrders.filter((order) => !order.invoiceApplied);
|
||||
|
||||
console.log('[Mock] 获取可开票订单:', { count: availableOrders.length });
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: availableOrders,
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 申请开票
|
||||
http.post('/api/invoice/apply', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { orderId, invoiceType, titleType, title, taxNumber, email, phone, remark } = body;
|
||||
|
||||
console.log('[Mock] 申请开票:', { orderId, invoiceType, titleType, title });
|
||||
|
||||
// 验证订单
|
||||
const order = mockInvoiceableOrders.find((o) => o.id === orderId);
|
||||
if (!order) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '订单不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (order.invoiceApplied) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '该订单已申请开票',
|
||||
data: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 企业开票必须有税号
|
||||
if (titleType === 'company' && !taxNumber) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '企业开票必须填写税号',
|
||||
data: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建发票申请
|
||||
const invoiceId = `INV_${invoiceIdCounter++}`;
|
||||
const invoice = {
|
||||
id: invoiceId,
|
||||
orderId: order.id,
|
||||
orderNo: order.orderNo,
|
||||
userId: currentUser.id,
|
||||
invoiceType,
|
||||
titleType,
|
||||
title,
|
||||
taxNumber: taxNumber || null,
|
||||
companyAddress: body.companyAddress || null,
|
||||
companyPhone: body.companyPhone || null,
|
||||
bankName: body.bankName || null,
|
||||
bankAccount: body.bankAccount || null,
|
||||
amount: order.amount,
|
||||
email,
|
||||
phone: phone || null,
|
||||
mailingAddress: body.mailingAddress || null,
|
||||
recipientName: body.recipientName || null,
|
||||
recipientPhone: body.recipientPhone || null,
|
||||
status: 'pending',
|
||||
remark: remark || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockInvoices.set(invoiceId, invoice);
|
||||
order.invoiceApplied = true;
|
||||
|
||||
console.log('[Mock] 发票申请创建成功:', invoice);
|
||||
|
||||
// 模拟3秒后自动变为处理中
|
||||
setTimeout(() => {
|
||||
const existingInvoice = mockInvoices.get(invoiceId);
|
||||
if (existingInvoice && existingInvoice.status === 'pending') {
|
||||
existingInvoice.status = 'processing';
|
||||
existingInvoice.updatedAt = new Date().toISOString();
|
||||
console.log(`[Mock] 发票开始处理: ${invoiceId}`);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// 模拟10秒后自动开具完成(电子发票)
|
||||
if (invoiceType === 'electronic') {
|
||||
setTimeout(() => {
|
||||
const existingInvoice = mockInvoices.get(invoiceId);
|
||||
if (existingInvoice && existingInvoice.status === 'processing') {
|
||||
existingInvoice.status = 'completed';
|
||||
existingInvoice.invoiceNo = `E${Date.now()}`;
|
||||
existingInvoice.invoiceCode = '011001900111';
|
||||
existingInvoice.invoiceUrl = `https://example.com/invoices/${existingInvoice.invoiceNo}.pdf`;
|
||||
existingInvoice.completedAt = new Date().toISOString();
|
||||
existingInvoice.updatedAt = new Date().toISOString();
|
||||
console.log(`[Mock] 电子发票开具完成: ${invoiceId}`);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '开票申请已提交,预计1-3个工作日内处理',
|
||||
data: invoice,
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 获取发票列表
|
||||
http.get('/api/invoice/list', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
|
||||
const statusFilter = url.searchParams.get('status');
|
||||
|
||||
// 获取用户的发票
|
||||
let userInvoices = Array.from(mockInvoices.values())
|
||||
.filter((invoice) => invoice.userId === currentUser.id)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
// 状态筛选
|
||||
if (statusFilter) {
|
||||
userInvoices = userInvoices.filter((invoice) => invoice.status === statusFilter);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const total = userInvoices.length;
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginatedInvoices = userInvoices.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
console.log('[Mock] 获取发票列表:', { total, page, pageSize });
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
list: paginatedInvoices,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 获取发票详情
|
||||
http.get('/api/invoice/:invoiceId', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { invoiceId } = params;
|
||||
const invoice = mockInvoices.get(invoiceId);
|
||||
|
||||
if (!invoice) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '发票不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权访问此发票',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Mock] 获取发票详情:', { invoiceId });
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: invoice,
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 取消发票申请
|
||||
http.post('/api/invoice/:invoiceId/cancel', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { invoiceId } = params;
|
||||
const invoice = mockInvoices.get(invoiceId);
|
||||
|
||||
if (!invoice) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '发票不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权操作此发票',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.status !== 'pending') {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '只能取消待处理的发票申请',
|
||||
data: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
invoice.status = 'cancelled';
|
||||
invoice.updatedAt = new Date().toISOString();
|
||||
|
||||
// 恢复订单的开票状态
|
||||
const order = mockInvoiceableOrders.find((o) => o.id === invoice.orderId);
|
||||
if (order) {
|
||||
order.invoiceApplied = false;
|
||||
}
|
||||
|
||||
console.log('[Mock] 发票申请已取消:', invoiceId);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '发票申请已取消',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 下载电子发票
|
||||
http.get('/api/invoice/:invoiceId/download', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { invoiceId } = params;
|
||||
const invoice = mockInvoices.get(invoiceId);
|
||||
|
||||
if (!invoice) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '发票不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权下载此发票',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.status !== 'completed') {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '发票尚未开具完成',
|
||||
data: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Mock] 下载电子发票:', invoiceId);
|
||||
|
||||
// 返回模拟的 PDF 内容
|
||||
const pdfContent = `%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
|
||||
endobj
|
||||
trailer
|
||||
<< /Root 1 0 R >>
|
||||
%%EOF`;
|
||||
|
||||
return new HttpResponse(pdfContent, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="invoice_${invoice.invoiceNo}.pdf"`,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// 7. 获取发票统计
|
||||
http.get('/api/invoice/stats', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userInvoices = Array.from(mockInvoices.values()).filter(
|
||||
(invoice) => invoice.userId === currentUser.id
|
||||
);
|
||||
|
||||
// 计算可开票金额(未申请开票的订单)
|
||||
const availableOrders = mockInvoiceableOrders.filter((order) => !order.invoiceApplied);
|
||||
const availableAmount = availableOrders.reduce((sum, order) => sum + order.amount, 0);
|
||||
|
||||
// 计算已开票金额
|
||||
const invoicedAmount = userInvoices
|
||||
.filter((i) => i.status === 'completed')
|
||||
.reduce((sum, invoice) => sum + invoice.amount, 0);
|
||||
|
||||
// 计算处理中金额
|
||||
const processingAmount = userInvoices
|
||||
.filter((i) => i.status === 'processing' || i.status === 'pending')
|
||||
.reduce((sum, invoice) => sum + invoice.amount, 0);
|
||||
|
||||
const stats = {
|
||||
total: userInvoices.length,
|
||||
pending: userInvoices.filter((i) => i.status === 'pending').length,
|
||||
processing: userInvoices.filter((i) => i.status === 'processing').length,
|
||||
completed: userInvoices.filter((i) => i.status === 'completed').length,
|
||||
cancelled: userInvoices.filter((i) => i.status === 'cancelled').length,
|
||||
availableAmount,
|
||||
invoicedAmount,
|
||||
processingAmount,
|
||||
};
|
||||
|
||||
console.log('[Mock] 获取发票统计:', stats);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: stats,
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 发票抬头模板管理 ====================
|
||||
|
||||
// 8. 获取发票抬头模板列表
|
||||
http.get('/api/invoice/title-templates', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userTemplates = Array.from(mockTitleTemplates.values())
|
||||
.filter((template) => template.userId === currentUser.id)
|
||||
.sort((a, b) => {
|
||||
// 默认的排在前面
|
||||
if (a.isDefault !== b.isDefault) {
|
||||
return b.isDefault ? 1 : -1;
|
||||
}
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
});
|
||||
|
||||
console.log('[Mock] 获取抬头模板列表:', { count: userTemplates.length });
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: userTemplates,
|
||||
});
|
||||
}),
|
||||
|
||||
// 9. 保存发票抬头模板
|
||||
http.post('/api/invoice/title-template', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const templateId = `TPL_${templateIdCounter++}`;
|
||||
const template = {
|
||||
id: templateId,
|
||||
userId: currentUser.id,
|
||||
isDefault: body.isDefault || false,
|
||||
titleType: body.titleType,
|
||||
title: body.title,
|
||||
taxNumber: body.taxNumber || null,
|
||||
companyAddress: body.companyAddress || null,
|
||||
companyPhone: body.companyPhone || null,
|
||||
bankName: body.bankName || null,
|
||||
bankAccount: body.bankAccount || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 如果设为默认,取消其他模板的默认状态
|
||||
if (template.isDefault) {
|
||||
mockTitleTemplates.forEach((t) => {
|
||||
if (t.userId === currentUser.id) {
|
||||
t.isDefault = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mockTitleTemplates.set(templateId, template);
|
||||
|
||||
console.log('[Mock] 保存抬头模板:', template);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '保存成功',
|
||||
data: template,
|
||||
});
|
||||
}),
|
||||
|
||||
// 10. 删除发票抬头模板
|
||||
http.delete('/api/invoice/title-template/:templateId', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { templateId } = params;
|
||||
const template = mockTitleTemplates.get(templateId);
|
||||
|
||||
if (!template) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '模板不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (template.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权删除此模板',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
mockTitleTemplates.delete(templateId);
|
||||
|
||||
console.log('[Mock] 删除抬头模板:', templateId);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '删除成功',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// 11. 设置默认发票抬头
|
||||
http.post('/api/invoice/title-template/:templateId/default', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { templateId } = params;
|
||||
const template = mockTitleTemplates.get(templateId);
|
||||
|
||||
if (!template) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '模板不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (template.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权操作此模板',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 取消其他模板的默认状态
|
||||
mockTitleTemplates.forEach((t) => {
|
||||
if (t.userId === currentUser.id) {
|
||||
t.isDefault = false;
|
||||
}
|
||||
});
|
||||
|
||||
template.isDefault = true;
|
||||
|
||||
console.log('[Mock] 设置默认抬头:', templateId);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '设置成功',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -69,17 +69,6 @@ export const homeRoutes = [
|
||||
}
|
||||
},
|
||||
|
||||
// 发票管理 - /home/pages/account/invoice
|
||||
{
|
||||
path: 'pages/account/invoice',
|
||||
component: lazyComponents.Invoice,
|
||||
protection: PROTECTION_MODES.REDIRECT,
|
||||
meta: {
|
||||
title: '发票管理',
|
||||
description: '发票申请与管理'
|
||||
}
|
||||
},
|
||||
|
||||
// 隐私政策 - /home/privacy-policy
|
||||
{
|
||||
path: 'privacy-policy',
|
||||
|
||||
@@ -17,7 +17,6 @@ export const lazyComponents = {
|
||||
ForumMyPoints: React.lazy(() => import('@views/Profile')),
|
||||
SettingsPage: React.lazy(() => import('@views/Settings/SettingsPage')),
|
||||
Subscription: React.lazy(() => import('@views/Pages/Account/Subscription')),
|
||||
Invoice: React.lazy(() => import('@views/Pages/Account/Invoice')),
|
||||
PrivacyPolicy: React.lazy(() => import('@views/Pages/PrivacyPolicy')),
|
||||
UserAgreement: React.lazy(() => import('@views/Pages/UserAgreement')),
|
||||
WechatCallback: React.lazy(() => import('@views/Pages/WechatCallback')),
|
||||
@@ -62,7 +61,6 @@ export const {
|
||||
ForumMyPoints,
|
||||
SettingsPage,
|
||||
Subscription,
|
||||
Invoice,
|
||||
PrivacyPolicy,
|
||||
UserAgreement,
|
||||
WechatCallback,
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* 发票服务
|
||||
* 处理发票申请、查询、下载等操作
|
||||
*/
|
||||
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import type {
|
||||
InvoiceInfo,
|
||||
CreateInvoiceRequest,
|
||||
InvoiceTitleTemplate,
|
||||
InvoiceableOrder,
|
||||
InvoiceListParams,
|
||||
InvoiceStats,
|
||||
} from '@/types/invoice';
|
||||
import type { ApiResponse } from '@/types/api';
|
||||
|
||||
/** 发票列表分页响应 */
|
||||
interface InvoiceListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
list: InvoiceInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可开票订单列表
|
||||
* 返回已支付且未申请开票的订单
|
||||
*/
|
||||
export const getAvailableOrders = async (): Promise<ApiResponse<InvoiceableOrder[]>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/available-orders`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 申请开票
|
||||
*/
|
||||
export const applyInvoice = async (
|
||||
data: CreateInvoiceRequest
|
||||
): Promise<ApiResponse<InvoiceInfo>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/apply`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取发票列表
|
||||
*/
|
||||
export const getInvoiceList = async (
|
||||
params?: InvoiceListParams
|
||||
): Promise<InvoiceListResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) searchParams.set('page', params.page.toString());
|
||||
if (params?.pageSize) searchParams.set('pageSize', params.pageSize.toString());
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${getApiBase()}/api/invoice/list${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取发票详情
|
||||
*/
|
||||
export const getInvoiceDetail = async (invoiceId: string): Promise<ApiResponse<InvoiceInfo>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/${invoiceId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消发票申请
|
||||
*/
|
||||
export const cancelInvoice = async (invoiceId: string): Promise<ApiResponse<null>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/${invoiceId}/cancel`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载电子发票
|
||||
*/
|
||||
export const downloadInvoice = async (invoiceId: string): Promise<Blob> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/${invoiceId}/download`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取发票统计信息
|
||||
*/
|
||||
export const getInvoiceStats = async (): Promise<ApiResponse<InvoiceStats>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/stats`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取发票抬头模板列表
|
||||
*/
|
||||
export const getTitleTemplates = async (): Promise<ApiResponse<InvoiceTitleTemplate[]>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/title-templates`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存发票抬头模板
|
||||
*/
|
||||
export const saveTitleTemplate = async (
|
||||
data: Omit<InvoiceTitleTemplate, 'id' | 'userId' | 'createdAt'>
|
||||
): Promise<ApiResponse<InvoiceTitleTemplate>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/title-template`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除发票抬头模板
|
||||
*/
|
||||
export const deleteTitleTemplate = async (templateId: string): Promise<ApiResponse<null>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/title-template/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置默认发票抬头
|
||||
*/
|
||||
export const setDefaultTemplate = async (templateId: string): Promise<ApiResponse<null>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/title-template/${templateId}/default`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* 发票相关类型定义
|
||||
*/
|
||||
|
||||
/** 发票类型 */
|
||||
export type InvoiceType = 'electronic' | 'paper';
|
||||
|
||||
/** 发票抬头类型 */
|
||||
export type InvoiceTitleType = 'personal' | 'company';
|
||||
|
||||
/** 发票状态 */
|
||||
export type InvoiceStatus =
|
||||
| 'pending' // 待处理
|
||||
| 'processing' // 处理中
|
||||
| 'completed' // 已开具
|
||||
| 'rejected' // 已拒绝
|
||||
| 'cancelled'; // 已取消
|
||||
|
||||
/** 发票信息 */
|
||||
export interface InvoiceInfo {
|
||||
id: string;
|
||||
orderId: string; // 关联订单ID
|
||||
orderNo: string; // 订单号
|
||||
userId: number;
|
||||
|
||||
// 发票基本信息
|
||||
invoiceType: InvoiceType; // 电子/纸质
|
||||
titleType: InvoiceTitleType; // 个人/企业
|
||||
title: string; // 发票抬头
|
||||
|
||||
// 企业开票信息
|
||||
taxNumber?: string; // 税号(企业必填)
|
||||
companyAddress?: string; // 公司地址
|
||||
companyPhone?: string; // 公司电话
|
||||
bankName?: string; // 开户银行
|
||||
bankAccount?: string; // 银行账号
|
||||
|
||||
// 发票金额
|
||||
amount: number; // 开票金额
|
||||
|
||||
// 接收信息
|
||||
email: string; // 接收邮箱
|
||||
phone?: string; // 联系电话
|
||||
|
||||
// 纸质发票邮寄信息
|
||||
mailingAddress?: string; // 邮寄地址
|
||||
recipientName?: string; // 收件人姓名
|
||||
recipientPhone?: string; // 收件人电话
|
||||
|
||||
// 状态信息
|
||||
status: InvoiceStatus;
|
||||
invoiceNo?: string; // 发票号码
|
||||
invoiceCode?: string; // 发票代码
|
||||
invoiceUrl?: string; // 电子发票下载链接
|
||||
|
||||
// 时间戳
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string; // 开具完成时间
|
||||
|
||||
// 备注
|
||||
remark?: string; // 用户备注
|
||||
rejectReason?: string; // 拒绝原因
|
||||
}
|
||||
|
||||
/** 创建发票申请请求 */
|
||||
export interface CreateInvoiceRequest {
|
||||
orderId: string;
|
||||
invoiceType: InvoiceType;
|
||||
titleType: InvoiceTitleType;
|
||||
title: string;
|
||||
taxNumber?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
mailingAddress?: string;
|
||||
recipientName?: string;
|
||||
recipientPhone?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** 发票抬头模板(用户保存的常用抬头) */
|
||||
export interface InvoiceTitleTemplate {
|
||||
id: string;
|
||||
userId: number;
|
||||
isDefault: boolean;
|
||||
titleType: InvoiceTitleType;
|
||||
title: string;
|
||||
taxNumber?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 可开票订单 */
|
||||
export interface InvoiceableOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
planName: string; // 套餐名称
|
||||
billingCycle: string; // 计费周期
|
||||
amount: number; // 订单金额
|
||||
paidAt: string; // 支付时间
|
||||
invoiceApplied: boolean; // 是否已申请开票
|
||||
}
|
||||
|
||||
/** 发票列表查询参数 */
|
||||
export interface InvoiceListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: InvoiceStatus;
|
||||
}
|
||||
|
||||
/** 发票统计信息 */
|
||||
export interface InvoiceStats {
|
||||
total: number; // 总申请数
|
||||
pending: number; // 待处理
|
||||
processing: number; // 处理中
|
||||
completed: number; // 已完成
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Icon,
|
||||
Input,
|
||||
Link,
|
||||
Switch,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import BasicImage from "assets/img/BasicImage.png";
|
||||
import React from "react";
|
||||
import AuthBasic from "layouts/AuthBasic";
|
||||
|
||||
function LockBasic() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.700", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
return (
|
||||
<AuthBasic
|
||||
title="Welcome!"
|
||||
description="Use these awesome forms to login or create new account in your project for free."
|
||||
image={BasicImage}
|
||||
>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "0px" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "20px", md: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
textAlign="center"
|
||||
mb="10px"
|
||||
fontSize={{ base: "3xl", md: "4xl" }}
|
||||
>
|
||||
Mike Priesler
|
||||
</Text>
|
||||
<Text
|
||||
fontWeight="regular"
|
||||
textAlign="center"
|
||||
color="gray.400"
|
||||
mb="35px"
|
||||
>
|
||||
Enter your password to unlock your account.
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
UNLOCK
|
||||
</Button>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthBasic>
|
||||
);
|
||||
}
|
||||
|
||||
export default LockBasic;
|
||||
@@ -1,109 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import CoverImage from "assets/img/CoverImage.png";
|
||||
import React from "react";
|
||||
import AuthCover from "layouts/AuthCover";
|
||||
|
||||
function LockCover() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.700", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
return (
|
||||
<AuthCover image={CoverImage}>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "30vh" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "20px", md: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
textAlign="center"
|
||||
mb="10px"
|
||||
fontSize={{ base: "3xl", md: "4xl" }}
|
||||
>
|
||||
Mike Priesler
|
||||
</Text>
|
||||
<Text
|
||||
fontWeight="regular"
|
||||
textAlign="center"
|
||||
color="gray.400"
|
||||
mb="35px"
|
||||
>
|
||||
Enter your password to unlock your account.
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="text"
|
||||
placeholder="Your email address"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
UNLOCK
|
||||
</Button>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default LockCover;
|
||||
@@ -1,81 +0,0 @@
|
||||
import React from "react";
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
LightMode,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import illustration from "assets/img/illustration-auth.png";
|
||||
import AuthIllustration from "layouts/AuthIllustration";
|
||||
|
||||
function LockIllustration() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("blue.500", "blue.500");
|
||||
return (
|
||||
<AuthIllustration
|
||||
illustrationBackground='linear-gradient(180deg, #3182CE 0%, #63B3ED 100%)'
|
||||
image={illustration}>
|
||||
<Flex
|
||||
w='100%'
|
||||
h='100%'
|
||||
alignItems='start'
|
||||
justifyContent='start'
|
||||
mb={{ base: "0px", md: "60px" }}
|
||||
mt={{ base: "60px", md: "34vh" }}>
|
||||
<Flex
|
||||
zIndex='2'
|
||||
direction='column'
|
||||
w='445px'
|
||||
background='transparent'
|
||||
borderRadius='15px'
|
||||
pe={{ base: "0px", md: "80px" }}
|
||||
mx={{ base: "20px", md: "0px" }}
|
||||
mb={{ base: "20px", md: "auto" }}>
|
||||
<Text
|
||||
fontWeight='bold'
|
||||
color={textColor}
|
||||
mb='10px'
|
||||
fontSize={{ base: "3xl", md: "4xl" }}>
|
||||
Mike Priesler
|
||||
</Text>
|
||||
<Text fontWeight='regular' color='gray.400' mb='35px'>
|
||||
Enter your password to unlock your account.
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms='4px' fontSize='sm' fontWeight='normal'>
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant='main'
|
||||
fontSize='sm'
|
||||
ms='4px'
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
mb='24px'
|
||||
size='lg'
|
||||
/>
|
||||
<LightMode>
|
||||
<Button
|
||||
fontSize='10px'
|
||||
colorScheme='blue'
|
||||
fontWeight='bold'
|
||||
w='100%'
|
||||
h='45'
|
||||
mb='24px'>
|
||||
UNLOCK
|
||||
</Button>
|
||||
</LightMode>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthIllustration>
|
||||
);
|
||||
}
|
||||
|
||||
export default LockIllustration;
|
||||
@@ -1,125 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import BasicImage from "assets/img/BasicImage.png";
|
||||
import React from "react";
|
||||
import AuthBasic from "layouts/AuthBasic";
|
||||
|
||||
function ResetCover() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.700", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
return (
|
||||
<AuthBasic
|
||||
title="Welcome!"
|
||||
description="Use these awesome forms to login or create new account in your project for free."
|
||||
image={BasicImage}
|
||||
>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "0px" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "20px", md: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
textAlign="center"
|
||||
mb="10px"
|
||||
fontSize={{ base: "3xl", md: "4xl" }}
|
||||
>
|
||||
Reset password
|
||||
</Text>
|
||||
<Text
|
||||
fontWeight="regular"
|
||||
textAlign="center"
|
||||
color="gray.400"
|
||||
mb="35px"
|
||||
>
|
||||
You will receive an e-mail in maximum 60 seconds.
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="text"
|
||||
placeholder="Your email address"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
{/* <FormLabel ms='4px' fontSize='sm' fontWeight='normal'>
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant='auth'
|
||||
fontSize='sm'
|
||||
ms='4px'
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
mb='24px'
|
||||
size='lg'
|
||||
/> */}
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
SEND
|
||||
</Button>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthBasic>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResetCover;
|
||||
@@ -1,109 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import CoverImage from "assets/img/CoverImage.png";
|
||||
import React from "react";
|
||||
import AuthCover from "layouts/AuthCover";
|
||||
|
||||
function ResetCover() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.700", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
return (
|
||||
<AuthCover image={CoverImage}>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "30vh" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "20px", md: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
textAlign="center"
|
||||
mb="10px"
|
||||
fontSize={{ base: "3xl", md: "4xl" }}
|
||||
>
|
||||
Reset password
|
||||
</Text>
|
||||
<Text
|
||||
fontWeight="regular"
|
||||
textAlign="center"
|
||||
color="gray.400"
|
||||
mb="35px"
|
||||
>
|
||||
You will receive an e-mail in maximum 60 seconds.
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="text"
|
||||
placeholder="Your email address"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
SEND
|
||||
</Button>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResetCover;
|
||||
@@ -1,81 +0,0 @@
|
||||
import React from "react";
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
LightMode,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import illustration from "assets/img/illustration-auth.png";
|
||||
import AuthIllustration from "layouts/AuthIllustration";
|
||||
|
||||
function ResetIllustration() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("blue.500", "blue.500");
|
||||
return (
|
||||
<AuthIllustration
|
||||
illustrationBackground='linear-gradient(180deg, #3182CE 0%, #63B3ED 100%)'
|
||||
image={illustration}>
|
||||
<Flex
|
||||
w='100%'
|
||||
h='100%'
|
||||
alignItems='start'
|
||||
justifyContent='start'
|
||||
mb={{ base: "0px", md: "60px" }}
|
||||
mt={{ base: "60px", md: "34vh" }}>
|
||||
<Flex
|
||||
zIndex='2'
|
||||
direction='column'
|
||||
w='445px'
|
||||
background='transparent'
|
||||
borderRadius='15px'
|
||||
pe={{ base: "0px", md: "80px" }}
|
||||
mx={{ base: "20px", md: "0px" }}
|
||||
mb={{ base: "20px", md: "auto" }}>
|
||||
<Text
|
||||
fontWeight='bold'
|
||||
color={textColor}
|
||||
mb='10px'
|
||||
fontSize={{ base: "3xl", md: "4xl" }}>
|
||||
Reset password
|
||||
</Text>
|
||||
<Text fontWeight='regular' color='gray.400' mb='35px'>
|
||||
You will receive an e-mail in maximum 60 seconds.
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms='4px' fontSize='sm' fontWeight='normal'>
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant='main'
|
||||
fontSize='sm'
|
||||
ms='4px'
|
||||
type='text'
|
||||
placeholder='Your email address'
|
||||
mb='24px'
|
||||
size='lg'
|
||||
/>
|
||||
<LightMode>
|
||||
<Button
|
||||
fontSize='10px'
|
||||
colorScheme='blue'
|
||||
fontWeight='bold'
|
||||
w='100%'
|
||||
h='45'
|
||||
mb='24px'>
|
||||
SEND
|
||||
</Button>
|
||||
</LightMode>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthIllustration>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResetIllustration;
|
||||
@@ -1,116 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Link,
|
||||
Flex,
|
||||
FormControl,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import BasicImage from "assets/img/BasicImage.png";
|
||||
import React from "react";
|
||||
import AuthBasic from "layouts/AuthBasic";
|
||||
import { PinInputLight } from "components/PinInput/PinInput";
|
||||
import { Rocket } from "lucide-react";
|
||||
|
||||
function LockBasic() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.700", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
return (
|
||||
<AuthBasic
|
||||
title="Welcome!"
|
||||
description="Use these awesome forms to login or create new account in your project for free."
|
||||
image={BasicImage}
|
||||
>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "0px" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "20px", md: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Flex
|
||||
mx="auto"
|
||||
borderRadius="50%"
|
||||
bg="blue.500"
|
||||
w={{ base: "100px" }}
|
||||
h={{ base: "100px" }}
|
||||
justify="center"
|
||||
align="center"
|
||||
mb="30px"
|
||||
>
|
||||
<Icon as={Rocket} color="white" w="36px" h="36px" />
|
||||
</Flex>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
textAlign="center"
|
||||
mb="10px"
|
||||
fontSize={{ base: "3xl", md: "4xl" }}
|
||||
>
|
||||
2-Step Verification
|
||||
</Text>
|
||||
<FormControl>
|
||||
<Flex justify="center" align="center" mx="auto" mb="30px">
|
||||
<PinInputLight />
|
||||
</Flex>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
UNLOCK
|
||||
</Button>
|
||||
</FormControl>
|
||||
<Text color="gray.400" fontWeight="400" textAlign="center">
|
||||
Haven't received it?{" "}
|
||||
<Link color={textColor} as="span" fontWeight="700">
|
||||
Resend a new code.
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthBasic>
|
||||
);
|
||||
}
|
||||
|
||||
export default LockBasic;
|
||||
@@ -1,104 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Link,
|
||||
Icon,
|
||||
FormControl,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import { PinInputLight } from "components/PinInput/PinInput";
|
||||
import { Rocket } from "lucide-react";
|
||||
import CoverImage from "assets/img/CoverImage.png";
|
||||
import React from "react";
|
||||
import AuthCover from "layouts/AuthCover";
|
||||
|
||||
function LockCover() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.700", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
return (
|
||||
<AuthCover image={CoverImage}>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "20px", md: "auto" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
mt="25vh"
|
||||
>
|
||||
<Flex
|
||||
mx="auto"
|
||||
borderRadius="50%"
|
||||
bg="blue.500"
|
||||
w={{ base: "100px" }}
|
||||
h={{ base: "100px" }}
|
||||
justify="center"
|
||||
align="center"
|
||||
mb="30px"
|
||||
>
|
||||
<Icon as={Rocket} color="white" w="36px" h="36px" />
|
||||
</Flex>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
textAlign="center"
|
||||
mb="10px"
|
||||
fontSize={{ base: "3xl", md: "4xl" }}
|
||||
>
|
||||
2-Step Verification
|
||||
</Text>
|
||||
<FormControl>
|
||||
<Flex justify="center" align="center" mx="auto" mb="30px">
|
||||
<PinInputLight />
|
||||
</Flex>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
UNLOCK
|
||||
</Button>
|
||||
</FormControl>
|
||||
<Text color="gray.400" fontWeight="400" textAlign="center">
|
||||
Haven't received it?{" "}
|
||||
<Link color={textColor} as="span" fontWeight="700">
|
||||
Resend a new code.
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default LockCover;
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from "react";
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
Link,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import illustration from "assets/img/illustration-auth.png";
|
||||
import AuthIllustration from "layouts/AuthIllustration";
|
||||
import { PinInputDark } from "components/PinInput/PinInput";
|
||||
import { Rocket } from "lucide-react";
|
||||
|
||||
function LockIllustration() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("blue.500", "blue.500");
|
||||
const inputBg = useColorModeValue(
|
||||
{ background: "white !important" },
|
||||
{ background: "red !important" }
|
||||
);
|
||||
return (
|
||||
<AuthIllustration
|
||||
illustrationBackground='linear-gradient(180deg, #3182CE 0%, #63B3ED 100%)'
|
||||
image={illustration}>
|
||||
<Flex
|
||||
w='100%'
|
||||
h='100%'
|
||||
alignItems='start'
|
||||
justifyContent='start'
|
||||
mb={{ base: "0px", md: "60px" }}
|
||||
mt={{ base: "60px", md: "30vh" }}>
|
||||
<Flex
|
||||
zIndex='2'
|
||||
direction='column'
|
||||
w='445px'
|
||||
background='transparent'
|
||||
borderRadius='15px'
|
||||
pe={{ base: "0px", md: "80px" }}
|
||||
mx={{ base: "20px", md: "0px" }}
|
||||
mb={{ base: "20px", md: "auto" }}>
|
||||
<Flex
|
||||
mx={{ base: "auto", md: "0px" }}
|
||||
borderRadius='50%'
|
||||
bg='blue.500'
|
||||
w={{ base: "100px" }}
|
||||
h={{ base: "100px" }}
|
||||
justify='center'
|
||||
align='center'
|
||||
mb='30px'>
|
||||
<Icon as={Rocket} color='white' w='36px' h='36px' />
|
||||
</Flex>
|
||||
<Text
|
||||
fontWeight='bold'
|
||||
color={textColor}
|
||||
textAlign='start'
|
||||
mb='10px'
|
||||
fontSize={{ base: "3xl", md: "4xl" }}>
|
||||
2-Step Verification
|
||||
</Text>
|
||||
<FormControl>
|
||||
<Flex mx={{ base: "auto", md: "0px" }} mb='30px'>
|
||||
<PinInputDark />
|
||||
</Flex>
|
||||
<Button
|
||||
fontSize='10px'
|
||||
variant='dark'
|
||||
fontWeight='bold'
|
||||
w='100%'
|
||||
h='45'
|
||||
mb='24px'>
|
||||
UNLOCK
|
||||
</Button>
|
||||
</FormControl>
|
||||
<Text
|
||||
color='gray.400'
|
||||
fontWeight='400'
|
||||
textAlign={{ base: "center", md: "start" }}>
|
||||
Haven't received it?{" "}
|
||||
<Link color={textColor} as='span' fontWeight='700'>
|
||||
Resend a new code.
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthIllustration>
|
||||
);
|
||||
}
|
||||
|
||||
export default LockIllustration;
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, Icon } from '@chakra-ui/react';
|
||||
import { LineChart } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 预测报告组件 - 占位符
|
||||
* TODO: 实现完整功能
|
||||
*/
|
||||
const ForecastReport = ({ stockCode }) => {
|
||||
return (
|
||||
<Box
|
||||
p={8}
|
||||
borderRadius="lg"
|
||||
bg="gray.50"
|
||||
_dark={{ bg: 'gray.800' }}
|
||||
textAlign="center"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Icon as={LineChart} boxSize={12} color="gray.400" />
|
||||
<Text fontSize="lg" fontWeight="medium" color="gray.600" _dark={{ color: 'gray.400' }}>
|
||||
预测报告功能开发中
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
股票代码: {stockCode || '未选择'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastReport;
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, Icon } from '@chakra-ui/react';
|
||||
import { BarChart2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 市场数据视图组件 - 占位符
|
||||
* TODO: 实现完整功能
|
||||
*/
|
||||
const MarketDataView = ({ stockCode }) => {
|
||||
return (
|
||||
<Box
|
||||
p={8}
|
||||
borderRadius="lg"
|
||||
bg="gray.50"
|
||||
_dark={{ bg: 'gray.800' }}
|
||||
textAlign="center"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Icon as={BarChart2} boxSize={12} color="gray.400" />
|
||||
<Text fontSize="lg" fontWeight="medium" color="gray.600" _dark={{ color: 'gray.400' }}>
|
||||
市场数据功能开发中
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
股票代码: {stockCode || '未选择'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketDataView;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx
|
||||
// 分支机构 Tab Panel - 黑金风格
|
||||
|
||||
import React from "react";
|
||||
import React, { memo, useMemo } from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
SimpleGrid,
|
||||
Center,
|
||||
} from "@chakra-ui/react";
|
||||
import { GitBranch, Building2, CheckCircle, XCircle } from "lucide-react";
|
||||
import {
|
||||
GitBranch,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useBranchesData } from "../../hooks/useBranchesData";
|
||||
import { THEME } from "../config";
|
||||
@@ -24,23 +30,42 @@ interface BranchesPanelProps {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// 黑金卡片样式
|
||||
const cardStyles = {
|
||||
bg: "linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))",
|
||||
border: "1px solid",
|
||||
borderColor: "rgba(212, 175, 55, 0.3)",
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.3s ease",
|
||||
_hover: {
|
||||
borderColor: "rgba(212, 175, 55, 0.6)",
|
||||
boxShadow: "0 4px 20px rgba(212, 175, 55, 0.15), inset 0 1px 0 rgba(212, 175, 55, 0.1)",
|
||||
transform: "translateY(-2px)",
|
||||
// 状态分类关键词
|
||||
const ACTIVE_KEYWORDS = ["存续", "在营", "开业", "在册", "在业"];
|
||||
const INACTIVE_KEYWORDS = ["吊销", "注销", "撤销", "关闭", "歇业", "迁出"];
|
||||
|
||||
// 获取状态分类
|
||||
type StatusType = "active" | "inactive" | "unknown";
|
||||
const getStatusType = (status: string | undefined): StatusType => {
|
||||
if (!status || status === "其他") return "unknown";
|
||||
// 优先判断异常状态(因为"吊销,未注销"同时包含两个关键词)
|
||||
if (INACTIVE_KEYWORDS.some((keyword) => status.includes(keyword))) {
|
||||
return "inactive";
|
||||
}
|
||||
if (ACTIVE_KEYWORDS.some((keyword) => status.includes(keyword))) {
|
||||
return "active";
|
||||
}
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
// 状态样式配置(使用 THEME.status 配置)
|
||||
const STATUS_CONFIG = {
|
||||
active: {
|
||||
icon: CheckCircle,
|
||||
...THEME.status.active,
|
||||
},
|
||||
inactive: {
|
||||
icon: XCircle,
|
||||
...THEME.status.inactive,
|
||||
},
|
||||
unknown: {
|
||||
icon: HelpCircle,
|
||||
...THEME.status.unknown,
|
||||
},
|
||||
};
|
||||
|
||||
// 状态徽章样式
|
||||
const getStatusBadgeStyles = (isActive: boolean) => ({
|
||||
// 状态徽章基础样式(静态部分)
|
||||
const STATUS_BADGE_BASE = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
@@ -49,14 +74,38 @@ const getStatusBadgeStyles = (isActive: boolean) => ({
|
||||
borderRadius: "full",
|
||||
fontSize: "xs",
|
||||
fontWeight: "medium",
|
||||
bg: isActive ? "rgba(212, 175, 55, 0.15)" : "rgba(255, 100, 100, 0.15)",
|
||||
color: isActive ? THEME.gold : "#ff6b6b",
|
||||
border: "1px solid",
|
||||
borderColor: isActive ? "rgba(212, 175, 55, 0.3)" : "rgba(255, 100, 100, 0.3)",
|
||||
});
|
||||
} as const;
|
||||
|
||||
// 信息项组件
|
||||
const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
|
||||
// 预计算各状态的完整徽章样式,避免每次渲染创建新对象
|
||||
const STATUS_BADGE_STYLES = {
|
||||
active: {
|
||||
...STATUS_BADGE_BASE,
|
||||
bg: STATUS_CONFIG.active.bgColor,
|
||||
color: STATUS_CONFIG.active.color,
|
||||
borderColor: STATUS_CONFIG.active.borderColor,
|
||||
},
|
||||
inactive: {
|
||||
...STATUS_BADGE_BASE,
|
||||
bg: STATUS_CONFIG.inactive.bgColor,
|
||||
color: STATUS_CONFIG.inactive.color,
|
||||
borderColor: STATUS_CONFIG.inactive.borderColor,
|
||||
},
|
||||
unknown: {
|
||||
...STATUS_BADGE_BASE,
|
||||
bg: STATUS_CONFIG.unknown.bgColor,
|
||||
color: STATUS_CONFIG.unknown.color,
|
||||
borderColor: STATUS_CONFIG.unknown.borderColor,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// 信息项组件 - memo 优化
|
||||
interface InfoItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
const InfoItem = memo<InfoItemProps>(({ label, value }) => (
|
||||
<VStack align="start" spacing={0.5}>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} letterSpacing="0.5px">
|
||||
{label}
|
||||
@@ -65,106 +114,127 @@ const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label,
|
||||
{value || "-"}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
));
|
||||
|
||||
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode, isActive = true }) => {
|
||||
const { branches, loading } = useBranchesData({ stockCode, enabled: isActive });
|
||||
InfoItem.displayName = "BranchInfoItem";
|
||||
|
||||
if (loading) {
|
||||
return <BranchesSkeleton />;
|
||||
}
|
||||
// 空状态组件 - 独立 memo 避免重复渲染
|
||||
const EmptyState = memo(() => (
|
||||
<Center h="200px">
|
||||
<VStack spacing={3}>
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="full"
|
||||
bg={THEME.iconBg}
|
||||
border="1px solid"
|
||||
borderColor={THEME.iconBgLight}
|
||||
>
|
||||
<Icon as={GitBranch} boxSize={10} color={THEME.gold} opacity={0.6} />
|
||||
</Box>
|
||||
<Text color={THEME.textSecondary} fontSize="sm">
|
||||
暂无分支机构信息
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
));
|
||||
|
||||
if (branches.length === 0) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack spacing={3}>
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="full"
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.2)"
|
||||
>
|
||||
<Icon as={GitBranch} boxSize={10} color={THEME.gold} opacity={0.6} />
|
||||
</Box>
|
||||
<Text color={THEME.textSecondary} fontSize="sm">
|
||||
暂无分支机构信息
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
EmptyState.displayName = "BranchesEmptyState";
|
||||
|
||||
// 分支卡片组件 - 独立 memo 优化列表渲染
|
||||
interface BranchCardProps {
|
||||
branch: any;
|
||||
}
|
||||
|
||||
const BranchCard = memo<BranchCardProps>(({ branch }) => {
|
||||
const statusType = useMemo(() => getStatusType(branch.business_status), [
|
||||
branch.business_status,
|
||||
]);
|
||||
const StatusIcon = STATUS_CONFIG[statusType].icon;
|
||||
|
||||
// 缓存关联企业显示值
|
||||
const relatedCompanyValue = useMemo(
|
||||
() => `${branch.related_company_count || 0} 家`,
|
||||
[branch.related_company_count]
|
||||
);
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{branches.map((branch: any, idx: number) => {
|
||||
const isActive = branch.business_status === "存续";
|
||||
<Box sx={THEME.card}>
|
||||
{/* 顶部金色装饰线 */}
|
||||
<Box h="2px" bgGradient={THEME.gradients.decorLine} />
|
||||
|
||||
return (
|
||||
<Box key={idx} sx={cardStyles}>
|
||||
{/* 顶部金色装饰线 */}
|
||||
<Box
|
||||
h="2px"
|
||||
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.6), transparent)"
|
||||
/>
|
||||
<Box p={4}>
|
||||
<VStack align="start" spacing={4}>
|
||||
{/* 标题行 */}
|
||||
<HStack justify="space-between" w="full" align="flex-start">
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Box p={1.5} borderRadius="md" bg={THEME.iconBg}>
|
||||
<Icon as={Building2} boxSize={3.5} color={THEME.gold} />
|
||||
</Box>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={THEME.textPrimary}
|
||||
fontSize="sm"
|
||||
noOfLines={2}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{branch.branch_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Box p={4}>
|
||||
<VStack align="start" spacing={4}>
|
||||
{/* 标题行 */}
|
||||
<HStack justify="space-between" w="full" align="flex-start">
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Box
|
||||
p={1.5}
|
||||
borderRadius="md"
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
>
|
||||
<Icon as={Building2} boxSize={3.5} color={THEME.gold} />
|
||||
</Box>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={THEME.textPrimary}
|
||||
fontSize="sm"
|
||||
noOfLines={2}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{branch.branch_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 状态徽章 */}
|
||||
<Box sx={getStatusBadgeStyles(isActive)}>
|
||||
<Icon
|
||||
as={isActive ? CheckCircle : XCircle}
|
||||
boxSize={3}
|
||||
/>
|
||||
<Text>{branch.business_status}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box
|
||||
w="full"
|
||||
h="1px"
|
||||
bgGradient="linear(to-r, rgba(212, 175, 55, 0.3), transparent)"
|
||||
/>
|
||||
|
||||
{/* 信息网格 */}
|
||||
<SimpleGrid columns={2} spacing={3} w="full">
|
||||
<InfoItem label="注册资本" value={branch.register_capital} />
|
||||
<InfoItem label="法人代表" value={branch.legal_person} />
|
||||
<InfoItem label="成立日期" value={formatDate(branch.register_date)} />
|
||||
<InfoItem
|
||||
label="关联企业"
|
||||
value={`${branch.related_company_count || 0} 家`}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
{/* 状态徽章 */}
|
||||
<Box sx={STATUS_BADGE_STYLES[statusType]}>
|
||||
<Icon as={StatusIcon} boxSize={3} />
|
||||
<Text>{branch.business_status || "未知"}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</HStack>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box w="full" h="1px" bgGradient={THEME.gradients.divider} />
|
||||
|
||||
{/* 信息网格 */}
|
||||
<SimpleGrid columns={2} spacing={3} w="full">
|
||||
<InfoItem label="注册资本" value={branch.register_capital} />
|
||||
<InfoItem label="法人代表" value={branch.legal_person} />
|
||||
<InfoItem
|
||||
label="成立日期"
|
||||
value={formatDate(branch.register_date)}
|
||||
/>
|
||||
<InfoItem label="关联企业" value={relatedCompanyValue} />
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
BranchCard.displayName = "BranchCard";
|
||||
|
||||
// 主组件
|
||||
const BranchesPanel: React.FC<BranchesPanelProps> = memo(
|
||||
({ stockCode, isActive = true }) => {
|
||||
const { branches, loading } = useBranchesData({
|
||||
stockCode,
|
||||
enabled: isActive,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <BranchesSkeleton />;
|
||||
}
|
||||
|
||||
if (branches.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{branches.map((branch: any, idx: number) => (
|
||||
<BranchCard key={branch.branch_name || idx} branch={branch} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BranchesPanel.displayName = "BranchesPanel";
|
||||
|
||||
export default BranchesPanel;
|
||||
|
||||
@@ -44,6 +44,7 @@ const ManagementCard: React.FC<ManagementCardProps> = ({ person, categoryColor }
|
||||
name={person.name}
|
||||
size="md"
|
||||
bg={categoryColor}
|
||||
color="black"
|
||||
/>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
{/* 姓名和性别 */}
|
||||
|
||||
@@ -3,6 +3,28 @@
|
||||
|
||||
import { LucideIcon, Share2, UserRound, GitBranch, Info } from "lucide-react";
|
||||
|
||||
// 状态颜色类型
|
||||
export interface StatusColors {
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
// 卡片样式类型
|
||||
export interface CardStyles {
|
||||
bg: string;
|
||||
border: string;
|
||||
borderColor: string;
|
||||
borderRadius: string;
|
||||
overflow: string;
|
||||
transition: string;
|
||||
_hover: {
|
||||
borderColor: string;
|
||||
boxShadow: string;
|
||||
transform: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 主题类型定义
|
||||
export interface Theme {
|
||||
bg: string;
|
||||
@@ -21,6 +43,23 @@ export interface Theme {
|
||||
tabUnselected: {
|
||||
color: string;
|
||||
};
|
||||
// 状态颜色配置
|
||||
status: {
|
||||
active: StatusColors;
|
||||
inactive: StatusColors;
|
||||
unknown: StatusColors;
|
||||
};
|
||||
// 渐变配置
|
||||
gradients: {
|
||||
cardBg: string;
|
||||
decorLine: string;
|
||||
divider: string;
|
||||
};
|
||||
// 图标容器背景
|
||||
iconBg: string;
|
||||
iconBgLight: string;
|
||||
// 通用卡片样式
|
||||
card: CardStyles;
|
||||
}
|
||||
|
||||
// 黑金主题配置
|
||||
@@ -30,17 +69,62 @@ export const THEME: Theme = {
|
||||
cardBg: "gray.800",
|
||||
tableBg: "gray.700",
|
||||
tableHoverBg: "gray.600",
|
||||
gold: "#F4D03F", // 亮黄金色(用于文字,对比度更好)
|
||||
goldLight: "#F0D78C", // 浅金色(用于次要文字)
|
||||
gold: "#F4D03F", // 亮黄金色(用于文字,对比度更好)
|
||||
goldLight: "#F0D78C", // 浅金色(用于次要文字)
|
||||
textPrimary: "white",
|
||||
textSecondary: "gray.400",
|
||||
border: "rgba(212, 175, 55, 0.3)", // 边框保持原色
|
||||
border: "rgba(212, 175, 55, 0.3)", // 边框保持原色
|
||||
tabSelected: {
|
||||
bg: "#D4AF37", // 选中背景保持深金色
|
||||
bg: "#D4AF37", // 选中背景保持深金色
|
||||
color: "gray.900",
|
||||
},
|
||||
tabUnselected: {
|
||||
color: "#F4D03F", // 未选中使用亮金色
|
||||
color: "#F4D03F", // 未选中使用亮金色
|
||||
},
|
||||
// 状态颜色配置(用于分支机构等状态显示)
|
||||
status: {
|
||||
active: {
|
||||
color: "#F4D03F",
|
||||
bgColor: "rgba(212, 175, 55, 0.15)",
|
||||
borderColor: "rgba(212, 175, 55, 0.3)",
|
||||
},
|
||||
inactive: {
|
||||
color: "#ff6b6b",
|
||||
bgColor: "rgba(255, 100, 100, 0.15)",
|
||||
borderColor: "rgba(255, 100, 100, 0.3)",
|
||||
},
|
||||
unknown: {
|
||||
color: "#a0aec0",
|
||||
bgColor: "rgba(160, 174, 192, 0.15)",
|
||||
borderColor: "rgba(160, 174, 192, 0.3)",
|
||||
},
|
||||
},
|
||||
// 渐变配置
|
||||
gradients: {
|
||||
cardBg:
|
||||
"linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))",
|
||||
decorLine:
|
||||
"linear(to-r, transparent, rgba(212, 175, 55, 0.6), transparent)",
|
||||
divider: "linear(to-r, rgba(212, 175, 55, 0.3), transparent)",
|
||||
},
|
||||
// 图标容器背景
|
||||
iconBg: "rgba(212, 175, 55, 0.1)",
|
||||
iconBgLight: "rgba(212, 175, 55, 0.2)",
|
||||
// 通用卡片样式
|
||||
card: {
|
||||
bg:
|
||||
"linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))",
|
||||
border: "1px solid",
|
||||
borderColor: "rgba(212, 175, 55, 0.3)",
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.3s ease",
|
||||
_hover: {
|
||||
borderColor: "rgba(212, 175, 55, 0.6)",
|
||||
boxShadow:
|
||||
"0 4px 20px rgba(212, 175, 55, 0.15), inset 0 1px 0 rgba(212, 175, 55, 0.1)",
|
||||
transform: "translateY(-2px)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -58,10 +58,10 @@ const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={4} flexWrap="wrap">
|
||||
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
|
||||
<Tag size="sm" bg="gray.600" color="white">
|
||||
营收占比: {formatPercentage(business.financial_metrics?.revenue_ratio)}
|
||||
</Tag>
|
||||
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
|
||||
<Tag size="sm" bg="gray.600" color="white">
|
||||
毛利率: {formatPercentage(business.financial_metrics?.gross_margin)}
|
||||
</Tag>
|
||||
{business.growth_metrics?.revenue_growth !== undefined && (
|
||||
|
||||
@@ -5,20 +5,23 @@
|
||||
* 使用位置:竞争力分析区域(共 8 处)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, HStack, Text, Badge, Progress, Icon } from '@chakra-ui/react';
|
||||
import type { ScoreBarProps } from '../types';
|
||||
import React from "react";
|
||||
import { Box, HStack, Text, Badge, Progress, Icon } from "@chakra-ui/react";
|
||||
import type { ScoreBarProps } from "../types";
|
||||
|
||||
/**
|
||||
* 根据分数百分比获取颜色方案
|
||||
*/
|
||||
const getColorScheme = (percentage: number): string => {
|
||||
if (percentage >= 80) return 'purple';
|
||||
if (percentage >= 60) return 'blue';
|
||||
if (percentage >= 40) return 'yellow';
|
||||
return 'orange';
|
||||
if (percentage >= 80) return "purple";
|
||||
if (percentage >= 60) return "blue";
|
||||
if (percentage >= 40) return "yellow";
|
||||
return "orange";
|
||||
};
|
||||
|
||||
// 黑金主题颜色
|
||||
const THEME_GOLD = "#F4D03F";
|
||||
|
||||
const ScoreBar: React.FC<ScoreBarProps> = ({ label, score, icon }) => {
|
||||
const percentage = ((score || 0) / 100) * 100;
|
||||
const colorScheme = getColorScheme(percentage);
|
||||
@@ -27,10 +30,8 @@ const ScoreBar: React.FC<ScoreBarProps> = ({ label, score, icon }) => {
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<HStack>
|
||||
{icon && (
|
||||
<Icon as={icon} boxSize={4} color={`${colorScheme}.500`} />
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{icon && <Icon as={icon} boxSize={4} color={`${colorScheme}.400`} />}
|
||||
<Text fontSize="sm" fontWeight="medium" color={THEME_GOLD}>
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
@@ -29,8 +29,9 @@ const THEME = {
|
||||
innerCardBg: 'gray.700',
|
||||
gold: '#F4D03F',
|
||||
goldLight: '#F0D78C',
|
||||
textPrimary: '#F4D03F',
|
||||
textSecondary: 'gray.400',
|
||||
textTitle: '#F4D03F', // 标题用金色
|
||||
textContent: 'white', // 内容用白色,提高辨识度
|
||||
textSecondary: 'gray.400', // 小标题用灰色
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
};
|
||||
|
||||
@@ -53,7 +54,7 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={Factory} color={THEME.gold} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>业务板块详情</Heading>
|
||||
<Heading size="sm" color={THEME.textTitle}>业务板块详情</Heading>
|
||||
<Badge bg={THEME.gold} color="gray.900">{businessSegments.length} 个板块</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
@@ -67,7 +68,7 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
||||
<CardBody px={2}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="bold" fontSize="md" color={THEME.textPrimary}>
|
||||
<Text fontWeight="bold" fontSize="md" color={THEME.textTitle}>
|
||||
{segment.segment_name}
|
||||
</Text>
|
||||
<Button
|
||||
@@ -85,12 +86,12 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
||||
</HStack>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
<Text fontSize="xs" color={THEME.gold} fontWeight="bold" mb={1}>
|
||||
业务描述
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.textPrimary}
|
||||
color={THEME.textContent}
|
||||
noOfLines={isExpanded ? undefined : 3}
|
||||
>
|
||||
{segment.segment_description || '暂无描述'}
|
||||
@@ -98,12 +99,12 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
<Text fontSize="xs" color={THEME.gold} fontWeight="bold" mb={1}>
|
||||
竞争地位
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.textPrimary}
|
||||
color={THEME.textContent}
|
||||
noOfLines={isExpanded ? undefined : 2}
|
||||
>
|
||||
{segment.competitive_position || '暂无数据'}
|
||||
@@ -111,13 +112,13 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
<Text fontSize="xs" color={THEME.gold} fontWeight="bold" mb={1}>
|
||||
未来潜力
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
noOfLines={isExpanded ? undefined : 2}
|
||||
color={THEME.goldLight}
|
||||
color={THEME.textContent}
|
||||
>
|
||||
{segment.future_potential || '暂无数据'}
|
||||
</Text>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 包含行业排名弹窗功能
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo, useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
@@ -28,10 +28,12 @@ import {
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
UnorderedList,
|
||||
ListItem,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
Trophy,
|
||||
Settings,
|
||||
@@ -43,47 +45,51 @@ import {
|
||||
Rocket,
|
||||
Users,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { ScoreBar } from '../atoms';
|
||||
import { getRadarChartOption } from '../utils/chartOptions';
|
||||
import { IndustryRankingView } from '../../../FinancialPanorama/components';
|
||||
import type { ComprehensiveData, CompetitivePosition, IndustryRankData } from '../types';
|
||||
} from "lucide-react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import { ScoreBar } from "../atoms";
|
||||
import { getRadarChartOption } from "../utils/chartOptions";
|
||||
import { IndustryRankingView } from "../../../FinancialPanorama/components";
|
||||
import type {
|
||||
ComprehensiveData,
|
||||
CompetitivePosition,
|
||||
IndustryRankData,
|
||||
} from "../types";
|
||||
|
||||
// 黑金主题弹窗样式
|
||||
const MODAL_STYLES = {
|
||||
content: {
|
||||
bg: 'gray.900',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: '1px',
|
||||
maxW: '900px',
|
||||
bg: "gray.900",
|
||||
borderColor: "rgba(212, 175, 55, 0.3)",
|
||||
borderWidth: "1px",
|
||||
maxW: "900px",
|
||||
},
|
||||
header: {
|
||||
color: 'yellow.500',
|
||||
borderBottomColor: 'rgba(212, 175, 55, 0.2)',
|
||||
borderBottomWidth: '1px',
|
||||
color: "yellow.500",
|
||||
borderBottomColor: "rgba(212, 175, 55, 0.2)",
|
||||
borderBottomWidth: "1px",
|
||||
},
|
||||
closeButton: {
|
||||
color: 'yellow.500',
|
||||
_hover: { bg: 'rgba(212, 175, 55, 0.1)' },
|
||||
color: "yellow.500",
|
||||
_hover: { bg: "rgba(212, 175, 55, 0.1)" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// 样式常量 - 避免每次渲染创建新对象
|
||||
const CARD_STYLES = {
|
||||
bg: 'transparent',
|
||||
shadow: 'md',
|
||||
bg: "transparent",
|
||||
shadow: "md",
|
||||
} as const;
|
||||
|
||||
const CONTENT_BOX_STYLES = {
|
||||
p: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
border: "1px solid",
|
||||
borderColor: "yellow.600",
|
||||
borderRadius: "md",
|
||||
} as const;
|
||||
|
||||
const GRID_COLSPAN = { base: 2, lg: 1 } as const;
|
||||
const CHART_STYLE = { height: '320px' } as const;
|
||||
const CHART_STYLE = { height: "320px" } as const;
|
||||
|
||||
interface CompetitiveAnalysisCardProps {
|
||||
comprehensiveData: ComprehensiveData;
|
||||
@@ -118,11 +124,11 @@ const CompetitorTags = memo<CompetitorTagsProps>(({ competitors }) => (
|
||||
</Box>
|
||||
));
|
||||
|
||||
CompetitorTags.displayName = 'CompetitorTags';
|
||||
CompetitorTags.displayName = "CompetitorTags";
|
||||
|
||||
// 评分区域组件
|
||||
interface ScoreSectionProps {
|
||||
scores: CompetitivePosition['scores'];
|
||||
scores: CompetitivePosition["scores"];
|
||||
}
|
||||
|
||||
const ScoreSection = memo<ScoreSectionProps>(({ scores }) => (
|
||||
@@ -138,7 +144,52 @@ const ScoreSection = memo<ScoreSectionProps>(({ scores }) => (
|
||||
</VStack>
|
||||
));
|
||||
|
||||
ScoreSection.displayName = 'ScoreSection';
|
||||
ScoreSection.displayName = "ScoreSection";
|
||||
|
||||
// 将文本按换行符或分号拆分为列表项
|
||||
const parseToList = (text: string): string[] => {
|
||||
if (!text) return [];
|
||||
// 优先按换行符拆分,其次按分号拆分
|
||||
const items = text.includes("\n")
|
||||
? text.split("\n")
|
||||
: text.split(/[;;、,,]/);
|
||||
// 清理数字序号(如 "1. ")并过滤空项
|
||||
return items.map((s) => s.trim().replace(/^\d+\.\s*/, "")).filter(Boolean);
|
||||
};
|
||||
|
||||
// 优劣势列表项组件
|
||||
interface AdvantageListProps {
|
||||
title: string;
|
||||
content?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const AdvantageList = memo<AdvantageListProps>(({ title, content, color }) => {
|
||||
const items = useMemo(() => parseToList(content || ""), [content]);
|
||||
|
||||
return (
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color={color}>
|
||||
{title}
|
||||
</Text>
|
||||
{items.length > 0 ? (
|
||||
<UnorderedList spacing={1} pl={2} styleType="disc" color={color}>
|
||||
{items.map((item, index) => (
|
||||
<ListItem key={index} fontSize="sm" color="white">
|
||||
{item}
|
||||
</ListItem>
|
||||
))}
|
||||
</UnorderedList>
|
||||
) : (
|
||||
<Text fontSize="sm" color="white">
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
AdvantageList.displayName = "AdvantageList";
|
||||
|
||||
// 竞争优劣势组件
|
||||
interface AdvantagesSectionProps {
|
||||
@@ -149,27 +200,13 @@ interface AdvantagesSectionProps {
|
||||
const AdvantagesSection = memo<AdvantagesSectionProps>(
|
||||
({ advantages, disadvantages }) => (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color="green.400">
|
||||
竞争优势
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{advantages || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color="red.400">
|
||||
竞争劣势
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{disadvantages || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
<AdvantageList title="竞争优势" content={advantages} color="green.400" />
|
||||
<AdvantageList title="竞争劣势" content={disadvantages} color="red.400" />
|
||||
</SimpleGrid>
|
||||
)
|
||||
);
|
||||
|
||||
AdvantagesSection.displayName = 'AdvantagesSection';
|
||||
AdvantagesSection.displayName = "AdvantagesSection";
|
||||
|
||||
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
({ comprehensiveData, industryRankData }) => {
|
||||
@@ -179,16 +216,15 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
if (!competitivePosition) return null;
|
||||
|
||||
// 缓存雷达图配置
|
||||
const radarOption = useMemo(
|
||||
() => getRadarChartOption(comprehensiveData),
|
||||
[comprehensiveData]
|
||||
);
|
||||
const radarOption = useMemo(() => getRadarChartOption(comprehensiveData), [
|
||||
comprehensiveData,
|
||||
]);
|
||||
|
||||
// 缓存竞争对手列表
|
||||
const competitors = useMemo(
|
||||
() =>
|
||||
competitivePosition.analysis?.main_competitors
|
||||
?.split(',')
|
||||
?.split(",")
|
||||
.map((c) => c.trim()) || [],
|
||||
[competitivePosition.analysis?.main_competitors]
|
||||
);
|
||||
@@ -202,7 +238,9 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={Trophy} color="yellow.500" />
|
||||
<Heading size="sm" color="yellow.500">竞争地位分析</Heading>
|
||||
<Heading size="sm" color="yellow.500">
|
||||
竞争地位分析
|
||||
</Heading>
|
||||
{competitivePosition.ranking && (
|
||||
<Badge
|
||||
ml={2}
|
||||
@@ -210,9 +248,13 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
border="1px solid"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
cursor={hasIndustryRankData ? 'pointer' : 'default'}
|
||||
cursor={hasIndustryRankData ? "pointer" : "default"}
|
||||
onClick={hasIndustryRankData ? onOpen : undefined}
|
||||
_hover={hasIndustryRankData ? { bg: 'rgba(212, 175, 55, 0.1)' } : undefined}
|
||||
_hover={
|
||||
hasIndustryRankData
|
||||
? { bg: "rgba(212, 175, 55, 0.1)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
行业排名 {competitivePosition.ranking.industry_rank}/
|
||||
{competitivePosition.ranking.total_companies}
|
||||
@@ -225,7 +267,7 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
color="yellow.500"
|
||||
rightIcon={<Icon as={ExternalLink} boxSize={3} />}
|
||||
onClick={onOpen}
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
|
||||
_hover={{ bg: "rgba(212, 175, 55, 0.1)" }}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
@@ -234,7 +276,9 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* 主要竞争对手 */}
|
||||
{competitors.length > 0 && <CompetitorTags competitors={competitors} />}
|
||||
{competitors.length > 0 && (
|
||||
<CompetitorTags competitors={competitors} />
|
||||
)}
|
||||
|
||||
{/* 评分和雷达图 */}
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
@@ -258,13 +302,20 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
{/* 竞争优势和劣势 */}
|
||||
<AdvantagesSection
|
||||
advantages={competitivePosition.analysis?.competitive_advantages}
|
||||
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
|
||||
disadvantages={
|
||||
competitivePosition.analysis?.competitive_disadvantages
|
||||
}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 行业排名弹窗 - 黑金主题 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay bg="blackAlpha.700" />
|
||||
<ModalContent {...MODAL_STYLES.content}>
|
||||
<ModalHeader {...MODAL_STYLES.header}>
|
||||
@@ -290,6 +341,6 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
}
|
||||
);
|
||||
|
||||
CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard';
|
||||
CompetitiveAnalysisCard.displayName = "CompetitiveAnalysisCard";
|
||||
|
||||
export default CompetitiveAnalysisCard;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 显示公司战略方向和战略举措
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo, useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
@@ -18,27 +18,29 @@ import {
|
||||
Grid,
|
||||
GridItem,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { Rocket, BarChart2 } from 'lucide-react';
|
||||
import type { Strategy } from '../types';
|
||||
UnorderedList,
|
||||
ListItem,
|
||||
} from "@chakra-ui/react";
|
||||
import { Rocket, BarChart2 } from "lucide-react";
|
||||
import type { Strategy } from "../types";
|
||||
|
||||
// 样式常量 - 避免每次渲染创建新对象
|
||||
const CARD_STYLES = {
|
||||
bg: 'transparent',
|
||||
shadow: 'md',
|
||||
bg: "transparent",
|
||||
shadow: "md",
|
||||
} as const;
|
||||
|
||||
const CONTENT_BOX_STYLES = {
|
||||
p: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
border: "1px solid",
|
||||
borderColor: "yellow.600",
|
||||
borderRadius: "md",
|
||||
} as const;
|
||||
|
||||
const EMPTY_BOX_STYLES = {
|
||||
border: '1px dashed',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
border: "1px dashed",
|
||||
borderColor: "yellow.600",
|
||||
borderRadius: "md",
|
||||
py: 12,
|
||||
} as const;
|
||||
|
||||
@@ -64,7 +66,17 @@ const EmptyState = memo(() => (
|
||||
</Box>
|
||||
));
|
||||
|
||||
EmptyState.displayName = 'StrategyEmptyState';
|
||||
EmptyState.displayName = "StrategyEmptyState";
|
||||
|
||||
// 将文本按分号拆分为列表项
|
||||
const parseToList = (text: string): string[] => {
|
||||
if (!text) return [];
|
||||
// 按中英文分号拆分,过滤空项
|
||||
return text
|
||||
.split(/[;;]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
// 内容项组件 - 复用结构
|
||||
interface ContentItemProps {
|
||||
@@ -72,24 +84,40 @@ interface ContentItemProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const ContentItem = memo<ContentItemProps>(({ title, content }) => (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="yellow.500">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{content}
|
||||
</Text>
|
||||
</VStack>
|
||||
));
|
||||
const ContentItem = memo<ContentItemProps>(({ title, content }) => {
|
||||
// 缓存解析结果,避免每次渲染重新计算
|
||||
const items = useMemo(() => parseToList(content), [content]);
|
||||
|
||||
ContentItem.displayName = 'StrategyContentItem';
|
||||
return (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="yellow.500">
|
||||
{title}
|
||||
</Text>
|
||||
{items.length > 1 ? (
|
||||
<UnorderedList spacing={1} pl={2} styleType="disc" color="yellow.500">
|
||||
{items.map((item, index) => (
|
||||
<ListItem key={index} fontSize="sm" color="white">
|
||||
{item}
|
||||
</ListItem>
|
||||
))}
|
||||
</UnorderedList>
|
||||
) : (
|
||||
<Text fontSize="sm" color="white">
|
||||
{content || "暂无数据"}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
|
||||
ContentItem.displayName = "StrategyContentItem";
|
||||
|
||||
const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
|
||||
({ strategy }) => {
|
||||
// 缓存数据检测结果
|
||||
const hasData = useMemo(
|
||||
() => !!(strategy?.strategy_description || strategy?.strategic_initiatives),
|
||||
() =>
|
||||
!!(strategy?.strategy_description || strategy?.strategic_initiatives),
|
||||
[strategy?.strategy_description, strategy?.strategic_initiatives]
|
||||
);
|
||||
|
||||
@@ -98,7 +126,9 @@ const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={Rocket} color="yellow.500" />
|
||||
<Heading size="sm" color="yellow.500">战略分析</Heading>
|
||||
<Heading size="sm" color="yellow.500">
|
||||
战略分析
|
||||
</Heading>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
@@ -110,13 +140,13 @@ const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
|
||||
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
|
||||
<ContentItem
|
||||
title="战略方向"
|
||||
content={strategy.strategy_description || '暂无数据'}
|
||||
content={strategy.strategy_description || "暂无数据"}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
|
||||
<ContentItem
|
||||
title="战略举措"
|
||||
content={strategy.strategic_initiatives || '暂无数据'}
|
||||
content={strategy.strategic_initiatives || "暂无数据"}
|
||||
/>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
@@ -128,6 +158,6 @@ const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
|
||||
}
|
||||
);
|
||||
|
||||
StrategyAnalysisCard.displayName = 'StrategyAnalysisCard';
|
||||
StrategyAnalysisCard.displayName = "StrategyAnalysisCard";
|
||||
|
||||
export default StrategyAnalysisCard;
|
||||
|
||||
@@ -131,16 +131,16 @@ const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md">
|
||||
{/* 头部区域 */}
|
||||
<CardHeader py={0}>
|
||||
<HStack flexWrap="wrap" gap={0}>
|
||||
<Icon as={Network} color={THEME.gold} />
|
||||
<CardHeader py={3}>
|
||||
<HStack flexWrap="wrap" spacing={3}>
|
||||
<Icon as={Network} color={THEME.gold} boxSize={5} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>
|
||||
产业链分析
|
||||
</Heading>
|
||||
<Text color={THEME.textSecondary} fontSize="sm">
|
||||
| {companyName}供应链图谱
|
||||
{companyName}供应链图谱
|
||||
</Text>
|
||||
<Badge bg={THEME.gold} color="gray.900">
|
||||
<Badge bg={THEME.gold} color="gray.900" px={2} py={1}>
|
||||
节点 {totalNodes}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
@@ -246,7 +246,7 @@ const RelatedCompaniesModal: React.FC<RelatedCompaniesModalProps> = ({
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={() => {
|
||||
window.location.href = `/company?stock_code=${company.stock_code}`;
|
||||
window.open(`/company?scode=${company.stock_code}`, '_blank');
|
||||
}}
|
||||
aria-label="查看公司详情"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
|
||||
export interface FormatUtils {
|
||||
formatCurrency: (value: number | null | undefined) => string;
|
||||
formatBusinessRevenue: (value: number | null | undefined, unit?: string) => string;
|
||||
formatBusinessRevenue: (
|
||||
value: number | null | undefined,
|
||||
unit?: string
|
||||
) => string;
|
||||
formatPercentage: (value: number | null | undefined) => string;
|
||||
}
|
||||
|
||||
@@ -186,7 +189,7 @@ export interface ValueChainData {
|
||||
// ==================== 相关公司类型 ====================
|
||||
|
||||
export interface RelatedCompanyRelationship {
|
||||
role: 'source' | 'target';
|
||||
role: "source" | "target";
|
||||
connected_node: string;
|
||||
}
|
||||
|
||||
@@ -205,7 +208,7 @@ export interface RelatedCompany {
|
||||
|
||||
// ==================== 关键因素类型 ====================
|
||||
|
||||
export type ImpactDirection = 'positive' | 'negative' | 'neutral' | 'mixed';
|
||||
export type ImpactDirection = "positive" | "negative" | "neutral" | "mixed";
|
||||
|
||||
export interface KeyFactor {
|
||||
factor_name: string;
|
||||
@@ -297,7 +300,11 @@ export interface IndustryRankData {
|
||||
// ==================== 主组件 Props 类型 ====================
|
||||
|
||||
/** Tab 类型 */
|
||||
export type DeepAnalysisTabKey = 'strategy' | 'business' | 'valueChain' | 'development';
|
||||
export type DeepAnalysisTabKey =
|
||||
| "strategy"
|
||||
| "business"
|
||||
| "valueChain"
|
||||
| "development";
|
||||
|
||||
export interface DeepAnalysisTabProps {
|
||||
comprehensiveData?: ComprehensiveData;
|
||||
@@ -353,12 +360,23 @@ export interface RadarIndicator {
|
||||
}
|
||||
|
||||
export interface RadarChartOption {
|
||||
tooltip: { trigger: string };
|
||||
tooltip: {
|
||||
trigger: string;
|
||||
backgroundColor?: string;
|
||||
borderColor?: string;
|
||||
textStyle?: { color: string };
|
||||
};
|
||||
radar: {
|
||||
indicator: RadarIndicator[];
|
||||
shape: string;
|
||||
splitNumber: number;
|
||||
name: { textStyle: { color: string; fontSize: number } };
|
||||
name: {
|
||||
textStyle: {
|
||||
color: string;
|
||||
fontSize: number;
|
||||
fontWeight?: string;
|
||||
};
|
||||
};
|
||||
splitLine: { lineStyle: { color: string[] } };
|
||||
splitArea: { show: boolean; areaStyle: { color: string[] } };
|
||||
axisLine: { lineStyle: { color: string } };
|
||||
@@ -373,7 +391,12 @@ export interface RadarChartOption {
|
||||
symbolSize: number;
|
||||
lineStyle: { width: number; color: string };
|
||||
areaStyle: { color: string };
|
||||
label: { show: boolean; formatter: (params: { value: number }) => number; color: string; fontSize: number };
|
||||
label: {
|
||||
show: boolean;
|
||||
formatter: (params: { value: number }) => number;
|
||||
color: string;
|
||||
fontSize: number;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
ValueChainData,
|
||||
RadarChartOption,
|
||||
SankeyChartOption,
|
||||
} from '../types';
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* 生成竞争力雷达图配置
|
||||
@@ -23,14 +23,14 @@ export const getRadarChartOption = (
|
||||
|
||||
const scores = comprehensiveData.competitive_position.scores;
|
||||
const indicators = [
|
||||
{ name: '市场地位', max: 100 },
|
||||
{ name: '技术实力', max: 100 },
|
||||
{ name: '品牌价值', max: 100 },
|
||||
{ name: '运营效率', max: 100 },
|
||||
{ name: '财务健康', max: 100 },
|
||||
{ name: '创新能力', max: 100 },
|
||||
{ name: '风险控制', max: 100 },
|
||||
{ name: '成长潜力', max: 100 },
|
||||
{ name: "市场地位", max: 100 },
|
||||
{ name: "技术实力", max: 100 },
|
||||
{ name: "品牌价值", max: 100 },
|
||||
{ name: "运营效率", max: 100 },
|
||||
{ name: "财务健康", max: 100 },
|
||||
{ name: "创新能力", max: 100 },
|
||||
{ name: "风险控制", max: 100 },
|
||||
{ name: "成长潜力", max: 100 },
|
||||
];
|
||||
|
||||
const data = [
|
||||
@@ -44,40 +44,71 @@ export const getRadarChartOption = (
|
||||
scores.growth || 0,
|
||||
];
|
||||
|
||||
// 黑金主题配色
|
||||
const THEME = {
|
||||
gold: "#F4D03F",
|
||||
goldLight: "rgba(212, 175, 55, 0.6)",
|
||||
goldDim: "rgba(212, 175, 55, 0.3)",
|
||||
blue: "#3182ce",
|
||||
blueArea: "rgba(49, 130, 206, 0.3)",
|
||||
};
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
backgroundColor: "rgba(30, 30, 35, 0.95)",
|
||||
borderColor: THEME.goldDim,
|
||||
textStyle: { color: "#fff" },
|
||||
},
|
||||
radar: {
|
||||
indicator: indicators,
|
||||
shape: 'polygon',
|
||||
shape: "polygon",
|
||||
splitNumber: 4,
|
||||
name: { textStyle: { color: '#666', fontSize: 12 } },
|
||||
splitLine: {
|
||||
lineStyle: { color: ['#e8e8e8', '#e0e0e0', '#d0d0d0', '#c0c0c0'] },
|
||||
// 指标名称 - 使用金色确保在深色背景上清晰可见
|
||||
name: {
|
||||
textStyle: {
|
||||
color: THEME.gold,
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
// 分割线 - 金色系
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
THEME.goldDim,
|
||||
"rgba(212, 175, 55, 0.2)",
|
||||
"rgba(212, 175, 55, 0.15)",
|
||||
"rgba(212, 175, 55, 0.1)",
|
||||
],
|
||||
},
|
||||
},
|
||||
// 分割区域 - 深色透明
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
|
||||
color: ["rgba(30, 30, 35, 0.6)", "rgba(40, 40, 45, 0.4)"],
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
// 轴线 - 金色
|
||||
axisLine: { lineStyle: { color: THEME.goldDim } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '竞争力评分',
|
||||
type: 'radar',
|
||||
name: "竞争力评分",
|
||||
type: "radar",
|
||||
data: [
|
||||
{
|
||||
value: data,
|
||||
name: '当前评分',
|
||||
symbol: 'circle',
|
||||
name: "当前评分",
|
||||
symbol: "circle",
|
||||
symbolSize: 5,
|
||||
lineStyle: { width: 2, color: '#3182ce' },
|
||||
areaStyle: { color: 'rgba(49, 130, 206, 0.3)' },
|
||||
lineStyle: { width: 2, color: THEME.blue },
|
||||
areaStyle: { color: THEME.blueArea },
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { value: number }) => params.value,
|
||||
color: '#3182ce',
|
||||
color: THEME.gold,
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
@@ -117,22 +148,22 @@ export const getSankeyChartOption = (
|
||||
links.push({
|
||||
source: flow.source.node_name,
|
||||
target: flow.target.node_name,
|
||||
value: parseFloat(flow.flow_metrics?.flow_ratio || '1') || 1,
|
||||
lineStyle: { color: 'source', opacity: 0.6 },
|
||||
value: parseFloat(flow.flow_metrics?.flow_ratio || "1") || 1,
|
||||
lineStyle: { color: "source", opacity: 0.6 },
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
|
||||
tooltip: { trigger: "item", triggerOn: "mousemove" },
|
||||
series: [
|
||||
{
|
||||
type: 'sankey',
|
||||
layout: 'none',
|
||||
emphasis: { focus: 'adjacency' },
|
||||
type: "sankey",
|
||||
layout: "none",
|
||||
emphasis: { focus: "adjacency" },
|
||||
data: Array.from(nodes).map((name) => ({ name })),
|
||||
links: links,
|
||||
lineStyle: { color: 'gradient', curveness: 0.5 },
|
||||
label: { color: '#333', fontSize: 10 },
|
||||
lineStyle: { color: "gradient", curveness: 0.5 },
|
||||
label: { color: "#333", fontSize: 10 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { Box, HStack, Heading, Badge, Icon, useBreakpointValue } from "@chakra-ui/react";
|
||||
import { Table, Tag, Tooltip, ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Users, LineChart } from "lucide-react";
|
||||
import type { Shareholder } from "../../types";
|
||||
@@ -22,7 +23,7 @@ const TABLE_THEME = {
|
||||
Table: {
|
||||
headerBg: "#1A202C", // gray.900
|
||||
headerColor: "#F4D03F", // 亮金色(提高对比度)
|
||||
rowHoverBg: "rgba(212, 175, 55, 0.15)", // 金色半透明,文字更清晰
|
||||
rowHoverBg: "rgba(156, 163, 175, 0.15)", // 浅灰色悬停背景
|
||||
borderColor: "rgba(212, 175, 55, 0.2)",
|
||||
},
|
||||
},
|
||||
@@ -133,10 +134,13 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||
title: "股东名称",
|
||||
dataIndex: "shareholder_name",
|
||||
key: "name",
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
ellipsis: {
|
||||
showTitle: false,
|
||||
},
|
||||
render: (name: string) => (
|
||||
<Tooltip title={name}>
|
||||
<span style={{ fontWeight: 500, color: "#F4D03F" }}>{name}</span>
|
||||
<Tooltip title={name} placement="topLeft">
|
||||
<span style={{ fontWeight: 500, color: "#F4D03F", display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>{name}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
@@ -144,10 +148,9 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||
title: "股东类型",
|
||||
dataIndex: "shareholder_type",
|
||||
key: "type",
|
||||
width: 90,
|
||||
responsive: ["md"],
|
||||
render: (shareholderType: string) => (
|
||||
<Tag color={getShareholderTypeColor(shareholderType)}>{shareholderType || "-"}</Tag>
|
||||
<Tag color={getShareholderTypeColor(shareholderType)} style={{ whiteSpace: 'nowrap' }}>{shareholderType || "-"}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -158,6 +161,7 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||
align: "right",
|
||||
responsive: ["md"],
|
||||
sorter: (a: Shareholder, b: Shareholder) => (a.holding_shares || 0) - (b.holding_shares || 0),
|
||||
sortDirections: ["descend", "ascend"],
|
||||
render: (shares: number) => (
|
||||
<span style={{ color: "#F4D03F" }}>{formatShares(shares)}</span>
|
||||
),
|
||||
@@ -173,6 +177,7 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||
const bVal = (b[config.ratioField] as number) || 0;
|
||||
return aVal - bVal;
|
||||
},
|
||||
sortDirections: ["descend", "ascend"],
|
||||
defaultSortOrder: "descend",
|
||||
render: (ratio: number) => (
|
||||
<span style={{ color: type === "circulation" ? "#805AD5" : "#3182CE", fontWeight: "bold" }}>
|
||||
@@ -188,10 +193,9 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||
title: "股份性质",
|
||||
dataIndex: "share_nature",
|
||||
key: "nature",
|
||||
width: 80,
|
||||
responsive: ["lg"],
|
||||
render: (nature: string) => (
|
||||
<Tag color="default">{nature || "流通股"}</Tag>
|
||||
<Tag color="default" style={{ whiteSpace: 'nowrap' }}>{nature || "流通股"}</Tag>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -211,14 +215,15 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||
<Heading size="sm" color={THEME.gold}>{config.title}</Heading>
|
||||
{reportDate && <Badge colorScheme="yellow" variant="subtle">{formatDate(reportDate)}</Badge>}
|
||||
</HStack>
|
||||
<ConfigProvider theme={TABLE_THEME}>
|
||||
<ConfigProvider theme={TABLE_THEME} locale={zhCN}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={shareholders.slice(0, 10)}
|
||||
rowKey={(record: Shareholder) => `${record.shareholder_name}-${record.shareholder_rank ?? ''}-${record.end_date ?? ''}`}
|
||||
pagination={false}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
scroll={{ x: isMobile ? 400 : undefined }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
showSorterTooltip={{ title: '点击切换排序' }}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
|
||||
@@ -7,23 +7,23 @@
|
||||
* - 竞态条件处理
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import axios from '@utils/axiosConfig';
|
||||
import { logger } from '@utils/logger';
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import axios from "@utils/axiosConfig";
|
||||
import { logger } from "@utils/logger";
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiLoadingState,
|
||||
DataState,
|
||||
UseDeepAnalysisDataReturn,
|
||||
} from '../types';
|
||||
import { TAB_API_MAP } from '../types';
|
||||
} from "../types";
|
||||
import { TAB_API_MAP } from "../types";
|
||||
|
||||
/** API 端点映射 */
|
||||
const API_ENDPOINTS: Record<ApiKey, string> = {
|
||||
comprehensive: '/api/company/comprehensive-analysis',
|
||||
valueChain: '/api/company/value-chain-analysis',
|
||||
keyFactors: '/api/company/key-factors-timeline',
|
||||
industryRank: '/api/financial/industry-rank',
|
||||
comprehensive: "/api/company/comprehensive-analysis",
|
||||
valueChain: "/api/company/value-chain-analysis",
|
||||
keyFactors: "/api/company/key-factors-timeline",
|
||||
industryRank: "/api/financial/industry-rank",
|
||||
};
|
||||
|
||||
/** 初始数据状态 */
|
||||
@@ -48,7 +48,9 @@ const initialLoadingState: ApiLoadingState = {
|
||||
* @param stockCode 股票代码
|
||||
* @returns 数据、loading 状态、加载函数
|
||||
*/
|
||||
export const useDeepAnalysisData = (stockCode: string): UseDeepAnalysisDataReturn => {
|
||||
export const useDeepAnalysisData = (
|
||||
stockCode: string
|
||||
): UseDeepAnalysisDataReturn => {
|
||||
// 数据状态
|
||||
const [data, setData] = useState<DataState>(initialDataState);
|
||||
|
||||
@@ -91,7 +93,9 @@ export const useDeepAnalysisData = (stockCode: string): UseDeepAnalysisDataRetur
|
||||
loadedApisRef.current[apiKey] = true;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('DeepAnalysis', `loadApiData:${apiKey}`, err, { stockCode });
|
||||
logger.error("DeepAnalysis", `loadApiData:${apiKey}`, err, {
|
||||
stockCode,
|
||||
});
|
||||
} finally {
|
||||
// 清除 loading(再次检查 stockCode)
|
||||
if (currentStockCodeRef.current === stockCode) {
|
||||
@@ -103,13 +107,13 @@ export const useDeepAnalysisData = (stockCode: string): UseDeepAnalysisDataRetur
|
||||
);
|
||||
|
||||
/**
|
||||
* 根据 Tab 加载对应数据
|
||||
* 根据 Tab 加载对应数据(支持一个 Tab 对应多个 API)
|
||||
*/
|
||||
const loadTabData = useCallback(
|
||||
(tabKey: string) => {
|
||||
const apiKey = TAB_API_MAP[tabKey];
|
||||
if (apiKey) {
|
||||
loadApiData(apiKey);
|
||||
const apiKeys = TAB_API_MAP[tabKey];
|
||||
if (apiKeys && apiKeys.length > 0) {
|
||||
apiKeys.forEach((apiKey) => loadApiData(apiKey));
|
||||
}
|
||||
},
|
||||
[loadApiData]
|
||||
@@ -134,8 +138,11 @@ export const useDeepAnalysisData = (stockCode: string): UseDeepAnalysisDataRetur
|
||||
if (stockCode) {
|
||||
currentStockCodeRef.current = stockCode;
|
||||
resetData();
|
||||
// 只加载默认 Tab(comprehensive)
|
||||
loadApiData('comprehensive');
|
||||
// 加载默认 Tab (strategy) 所需的所有数据
|
||||
const defaultTabApis = TAB_API_MAP["strategy"];
|
||||
if (defaultTabApis) {
|
||||
defaultTabApis.forEach((apiKey) => loadApiData(apiKey));
|
||||
}
|
||||
}
|
||||
}, [stockCode, loadApiData, resetData]);
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
* - 业务板块展开状态管理
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, memo } from 'react';
|
||||
import DeepAnalysisTab from '../CompanyOverview/DeepAnalysisTab';
|
||||
import type { DeepAnalysisTabKey } from '../CompanyOverview/DeepAnalysisTab/types';
|
||||
import { useDeepAnalysisData } from './hooks';
|
||||
import { TAB_API_MAP } from './types';
|
||||
import type { DeepAnalysisProps } from './types';
|
||||
import React, { useState, useCallback, useEffect, memo } from "react";
|
||||
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
|
||||
import type { DeepAnalysisTabKey } from "../CompanyOverview/DeepAnalysisTab/types";
|
||||
import { useDeepAnalysisData } from "./hooks";
|
||||
import { TAB_API_MAP } from "./types";
|
||||
import type { DeepAnalysisProps } from "./types";
|
||||
|
||||
/**
|
||||
* 深度分析组件
|
||||
@@ -21,10 +21,12 @@ import type { DeepAnalysisProps } from './types';
|
||||
*/
|
||||
const DeepAnalysis: React.FC<DeepAnalysisProps> = memo(({ stockCode }) => {
|
||||
// 当前 Tab
|
||||
const [activeTab, setActiveTab] = useState<DeepAnalysisTabKey>('strategy');
|
||||
const [activeTab, setActiveTab] = useState<DeepAnalysisTabKey>("strategy");
|
||||
|
||||
// 业务板块展开状态
|
||||
const [expandedSegments, setExpandedSegments] = useState<Record<number, boolean>>({});
|
||||
const [expandedSegments, setExpandedSegments] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
// 数据获取 Hook
|
||||
const { data, loading, loadTabData } = useDeepAnalysisData(stockCode);
|
||||
@@ -32,7 +34,7 @@ const DeepAnalysis: React.FC<DeepAnalysisProps> = memo(({ stockCode }) => {
|
||||
// stockCode 变更时重置 UI 状态
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
setActiveTab('strategy');
|
||||
setActiveTab("strategy");
|
||||
setExpandedSegments({});
|
||||
}
|
||||
}, [stockCode]);
|
||||
@@ -54,10 +56,11 @@ const DeepAnalysis: React.FC<DeepAnalysisProps> = memo(({ stockCode }) => {
|
||||
[loadTabData]
|
||||
);
|
||||
|
||||
// 获取当前 Tab 的 loading 状态
|
||||
// 获取当前 Tab 的 loading 状态(任一相关 API loading 则显示 loading)
|
||||
const currentLoading = (() => {
|
||||
const apiKey = TAB_API_MAP[activeTab];
|
||||
return apiKey ? loading[apiKey] : false;
|
||||
const apiKeys = TAB_API_MAP[activeTab];
|
||||
if (!apiKeys || apiKeys.length === 0) return false;
|
||||
return apiKeys.some((apiKey) => loading[apiKey]);
|
||||
})();
|
||||
|
||||
return (
|
||||
@@ -76,6 +79,6 @@ const DeepAnalysis: React.FC<DeepAnalysisProps> = memo(({ stockCode }) => {
|
||||
);
|
||||
});
|
||||
|
||||
DeepAnalysis.displayName = 'DeepAnalysis';
|
||||
DeepAnalysis.displayName = "DeepAnalysis";
|
||||
|
||||
export default DeepAnalysis;
|
||||
|
||||
@@ -9,17 +9,21 @@ export type {
|
||||
KeyFactorsData,
|
||||
IndustryRankData,
|
||||
DeepAnalysisTabKey,
|
||||
} from '../CompanyOverview/DeepAnalysisTab/types';
|
||||
} from "../CompanyOverview/DeepAnalysisTab/types";
|
||||
|
||||
/** API 接口类型 */
|
||||
export type ApiKey = 'comprehensive' | 'valueChain' | 'keyFactors' | 'industryRank';
|
||||
export type ApiKey =
|
||||
| "comprehensive"
|
||||
| "valueChain"
|
||||
| "keyFactors"
|
||||
| "industryRank";
|
||||
|
||||
/** Tab 与 API 映射 */
|
||||
export const TAB_API_MAP: Record<string, ApiKey> = {
|
||||
strategy: 'comprehensive',
|
||||
business: 'comprehensive',
|
||||
valueChain: 'valueChain',
|
||||
development: 'keyFactors',
|
||||
/** Tab 与 API 映射(支持一个 Tab 对应多个 API) */
|
||||
export const TAB_API_MAP: Record<string, ApiKey[]> = {
|
||||
strategy: ["comprehensive", "industryRank"], // 战略分析需要综合分析 + 行业排名
|
||||
business: ["comprehensive"],
|
||||
valueChain: ["valueChain"],
|
||||
development: ["keyFactors"],
|
||||
} as const;
|
||||
|
||||
/** API 加载状态 */
|
||||
@@ -69,4 +73,4 @@ import type {
|
||||
ValueChainData,
|
||||
KeyFactorsData,
|
||||
IndustryRankData,
|
||||
} from '../CompanyOverview/DeepAnalysisTab/types';
|
||||
} from "../CompanyOverview/DeepAnalysisTab/types";
|
||||
|
||||
@@ -48,7 +48,7 @@ const BLACK_GOLD_THEME = {
|
||||
Table: {
|
||||
headerBg: 'rgba(212, 175, 55, 0.1)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
|
||||
rowHoverBg: 'rgba(156, 163, 175, 0.15)', // 浅灰色悬停背景
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
@@ -67,7 +67,7 @@ const fixedColumnStyles = `
|
||||
background: rgba(26, 32, 44, 0.95) !important;
|
||||
}
|
||||
.main-business-table .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
background: rgba(156, 163, 175, 0.15) !important;
|
||||
}
|
||||
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
|
||||
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-right {
|
||||
@@ -248,8 +248,8 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
|
||||
}));
|
||||
|
||||
const pieOption = getMainBusinessPieOption(
|
||||
`主营业务构成 - ${latestPeriod.report_type}`,
|
||||
dataSource === 'industry' ? '按行业分类' : '按产品分类',
|
||||
`主营业务构成(${latestPeriod.report_type})`,
|
||||
'',
|
||||
pieData
|
||||
);
|
||||
|
||||
|
||||
@@ -97,3 +97,28 @@ export const isNegativeIndicator = (key: string): boolean => {
|
||||
key.includes('debt_ratio')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算两个值的差异百分比
|
||||
* @param value1 当前股票值
|
||||
* @param value2 对比股票值
|
||||
* @param format 格式类型:percent 直接相减,number 计算变化率
|
||||
* @returns 差异百分比或 null
|
||||
*/
|
||||
export const calculateDiff = (
|
||||
value1: number | null | undefined,
|
||||
value2: number | null | undefined,
|
||||
format: 'percent' | 'number'
|
||||
): number | null => {
|
||||
if (value1 == null || value2 == null) return null;
|
||||
|
||||
if (format === 'percent') {
|
||||
return value1 - value2;
|
||||
}
|
||||
|
||||
if (value2 !== 0) {
|
||||
return ((value1 - value2) / Math.abs(value2)) * 100;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -127,7 +127,10 @@ export const getComparisonChartOption = (
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['营业收入', '净利润'],
|
||||
data: [
|
||||
{ name: '营业收入', itemStyle: { color: '#EF4444' } },
|
||||
{ name: '净利润', itemStyle: { color: fui.gold } },
|
||||
],
|
||||
bottom: 0,
|
||||
textStyle: {
|
||||
color: '#A0AEC0',
|
||||
@@ -200,14 +203,7 @@ export const getComparisonChartOption = (
|
||||
type: 'bar',
|
||||
data: revenueData.map((d) => d.value?.toFixed(2)),
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number; value: number }) => {
|
||||
const idx = params.dataIndex;
|
||||
if (idx === 0) return fui.gold; // 金色作为基准
|
||||
const prevValue = revenueData[idx - 1].value;
|
||||
const currValue = params.value;
|
||||
// 红涨绿跌
|
||||
return currValue >= prevValue ? '#EF4444' : '#10B981';
|
||||
},
|
||||
color: '#EF4444', // 统一红色
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -238,22 +234,18 @@ const BLACK_GOLD_PIE_COLORS = chartTheme.goldSeries;
|
||||
*/
|
||||
export const getMainBusinessPieOption = (
|
||||
title: string,
|
||||
subtitle: string,
|
||||
_subtitle: string,
|
||||
data: { name: string; value: number }[]
|
||||
) => {
|
||||
return {
|
||||
title: {
|
||||
text: title,
|
||||
subtext: subtitle,
|
||||
left: 'center',
|
||||
top: 0,
|
||||
textStyle: {
|
||||
color: fui.gold,
|
||||
fontSize: 14,
|
||||
},
|
||||
subtextStyle: {
|
||||
color: '#A0AEC0',
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
@@ -269,26 +261,29 @@ export const getMainBusinessPieOption = (
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'center',
|
||||
orient: 'horizontal',
|
||||
bottom: 5,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: '#E2E8F0',
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
},
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 6,
|
||||
},
|
||||
color: BLACK_GOLD_PIE_COLORS,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: '55%',
|
||||
center: ['55%', '50%'],
|
||||
radius: '45%',
|
||||
center: ['50%', '45%'],
|
||||
data: data,
|
||||
label: {
|
||||
show: true,
|
||||
color: '#E2E8F0',
|
||||
fontSize: 11,
|
||||
formatter: '{b}: {d}%',
|
||||
fontSize: 10,
|
||||
formatter: '{d}%',
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
@@ -308,7 +303,7 @@ export const getMainBusinessPieOption = (
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成对比柱状图配置
|
||||
* 生成对比柱状图配置 - 黑金主题
|
||||
* @param title 标题
|
||||
* @param stockName1 股票1名称
|
||||
* @param stockName2 股票2名称
|
||||
@@ -326,13 +321,50 @@ export const getCompareBarChartOption = (
|
||||
data2: (number | undefined)[]
|
||||
) => {
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [stockName1, stockName2] },
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: chartTheme.tooltip.bg,
|
||||
borderColor: chartTheme.tooltip.border,
|
||||
textStyle: {
|
||||
color: '#E2E8F0',
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: [stockName1, stockName2],
|
||||
textStyle: {
|
||||
color: '#E2E8F0',
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: chartTheme.axisLine,
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#A0AEC0',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: '{value}%',
|
||||
color: '#A0AEC0',
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: chartTheme.axisLine,
|
||||
},
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: chartTheme.splitLine,
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
|
||||
series: [
|
||||
{
|
||||
name: stockName1,
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
getCellBackground,
|
||||
getValueByPath,
|
||||
isNegativeIndicator,
|
||||
calculateDiff,
|
||||
} from './calculations';
|
||||
|
||||
export {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const BLACK_GOLD_TABLE_THEME: ThemeConfig = {
|
||||
Table: {
|
||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
rowHoverBg: 'rgba(156, 163, 175, 0.15)', // 浅灰色悬停背景
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
cellPaddingBlock: 6,
|
||||
cellPaddingInline: 8,
|
||||
@@ -58,7 +58,7 @@ export const getTableStyles = (className: string): string => `
|
||||
font-size: 12px;
|
||||
}
|
||||
.${className} .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
background: rgba(156, 163, 175, 0.15) !important;
|
||||
}
|
||||
.${className} .ant-table-tbody > tr.total-row > td {
|
||||
background: rgba(212, 175, 55, 0.15) !important;
|
||||
|
||||
@@ -27,7 +27,7 @@ const BLACK_GOLD_THEME = {
|
||||
Table: {
|
||||
headerBg: 'rgba(212, 175, 55, 0.12)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.08)',
|
||||
rowHoverBg: 'rgba(156, 163, 175, 0.15)', // 浅灰色悬停背景
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
cellPaddingBlock: 12, // 增加行高
|
||||
cellPaddingInline: 14,
|
||||
|
||||
@@ -126,25 +126,32 @@ export const DETAIL_TABLE_STYLES = `
|
||||
background: rgba(26, 32, 44, 0.98) !important;
|
||||
}
|
||||
.forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left {
|
||||
background: #242d3d !important;
|
||||
background: rgba(156, 163, 175, 0.15) !important;
|
||||
}
|
||||
|
||||
/* 指标标签样式 */
|
||||
/* 所有行悬停背景色 - 统一浅灰色 */
|
||||
.forecast-detail-table .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(156, 163, 175, 0.15) !important;
|
||||
}
|
||||
|
||||
/* 指标标签样式 - 统一字体字号和粗细 */
|
||||
.forecast-detail-table .metric-tag {
|
||||
background: rgba(212, 175, 55, 0.15);
|
||||
border-color: rgba(212, 175, 55, 0.3);
|
||||
color: #D4AF37;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 重要指标行高亮 */
|
||||
/* 重要指标行高亮 - 保持字体字号一致,仅背景色区分 */
|
||||
.forecast-detail-table .important-row {
|
||||
background: rgba(212, 175, 55, 0.06) !important;
|
||||
}
|
||||
.forecast-detail-table .important-row .metric-tag {
|
||||
background: rgba(212, 175, 55, 0.25);
|
||||
color: #FFD700;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 斑马纹 - 奇数行 */
|
||||
@@ -152,7 +159,7 @@ export const DETAIL_TABLE_STYLES = `
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd):hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
background: rgba(156, 163, 175, 0.15) !important;
|
||||
}
|
||||
|
||||
/* 等宽字体 - 数值列 */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { BarChart2, Search } from 'lucide-react';
|
||||
import { useStockSearch, type Stock } from '../../../hooks/useStockSearch';
|
||||
import { loadAllStocks } from '@store/slices/stockSlice';
|
||||
import { DEEP_SPACE_THEME as T } from './theme';
|
||||
|
||||
interface CompareStockInputProps {
|
||||
onCompare: (stockCode: string) => void;
|
||||
@@ -40,13 +42,21 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 从 Redux 获取全部股票列表
|
||||
const allStocks = useSelector((state: RootState) => state.stock.allStocks);
|
||||
|
||||
// 确保股票列表已加载(用于模糊搜索)
|
||||
useEffect(() => {
|
||||
if (!allStocks || allStocks.length === 0) {
|
||||
dispatch(loadAllStocks() as any);
|
||||
}
|
||||
}, [dispatch, allStocks]);
|
||||
|
||||
// 黑金主题颜色(提高对比度)
|
||||
const borderColor = '#E8C14D';
|
||||
const textColor = 'rgba(245, 240, 225, 0.95)';
|
||||
const textColor = T.textSecondary;
|
||||
const goldColor = '#F4D03F';
|
||||
const bgColor = '#1A202C';
|
||||
|
||||
@@ -108,7 +118,7 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
|
||||
<Search size={12} color={textColor} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="对比股票"
|
||||
placeholder="请输入股票代码"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
@@ -121,7 +131,7 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
|
||||
fontSize="sm"
|
||||
borderColor={borderColor}
|
||||
bg="transparent"
|
||||
_placeholder={{ color: textColor, fontSize: 'sm' }}
|
||||
_placeholder={{ color: T.textPlaceholder, fontSize: 'sm' }}
|
||||
_focus={{
|
||||
borderColor: goldColor,
|
||||
boxShadow: `0 0 0 1px ${goldColor}`,
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
/**
|
||||
* StockCompareModal - 股票对比弹窗组件
|
||||
* 展示对比明细、盈利能力对比、成长力对比
|
||||
*
|
||||
* 使用 Ant Design Modal + Table
|
||||
* 主题配置使用 Company/theme 统一配置
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Grid,
|
||||
GridItem,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
Text,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Modal, Table, Spin, Row, Col, Card, Typography, Space, ConfigProvider } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import { COMPARE_METRICS } from '../../FinancialPanorama/constants';
|
||||
import { getValueByPath, getCompareBarChartOption } from '../../FinancialPanorama/utils';
|
||||
import { getValueByPath, getCompareBarChartOption, calculateDiff } from '../../FinancialPanorama/utils';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import type { StockInfo } from '../../FinancialPanorama/types';
|
||||
import {
|
||||
antdDarkTheme,
|
||||
modalStyles,
|
||||
cardStyle,
|
||||
cardStyles,
|
||||
chartCardStyles,
|
||||
FUI_COLORS,
|
||||
} from '../../../theme';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// ============================================
|
||||
// 类型定义
|
||||
// ============================================
|
||||
interface StockCompareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -48,6 +40,70 @@ interface StockCompareModalProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface CompareTableRow {
|
||||
key: string;
|
||||
metric: string;
|
||||
currentValue: number | null | undefined;
|
||||
compareValue: number | null | undefined;
|
||||
diff: number | null;
|
||||
format: 'percent' | 'number';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 工具函数
|
||||
// ============================================
|
||||
const formatValue = (
|
||||
value: number | null | undefined,
|
||||
format: 'percent' | 'number'
|
||||
): string => {
|
||||
if (value == null) return '-';
|
||||
return format === 'percent'
|
||||
? formatUtils.formatPercent(value)
|
||||
: formatUtils.formatLargeNumber(value);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 子组件
|
||||
// ============================================
|
||||
const DiffCell: React.FC<{ diff: number | null }> = ({ diff }) => {
|
||||
if (diff === null) return <span style={{ color: FUI_COLORS.text.muted }}>-</span>;
|
||||
|
||||
const isPositive = diff > 0;
|
||||
const color = isPositive ? FUI_COLORS.status.positive : FUI_COLORS.status.negative;
|
||||
const Icon = isPositive ? ArrowUpOutlined : ArrowDownOutlined;
|
||||
|
||||
return (
|
||||
<Space size={4} style={{ color, justifyContent: 'center' }}>
|
||||
<Icon style={{ fontSize: 11 }} />
|
||||
<span style={{ fontWeight: 500 }}>{Math.abs(diff).toFixed(2)}%</span>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const CardTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<Title level={5} style={{ margin: 0, color: FUI_COLORS.gold[400], fontSize: 14 }}>
|
||||
{children}
|
||||
</Title>
|
||||
);
|
||||
|
||||
const LoadingState: React.FC = () => (
|
||||
<div style={{ textAlign: 'center', padding: '80px 0' }}>
|
||||
<Spin size="large" />
|
||||
<Text style={{ display: 'block', marginTop: 16, color: FUI_COLORS.text.muted }}>
|
||||
加载对比数据中...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EmptyState: React.FC = () => (
|
||||
<div style={{ textAlign: 'center', padding: '80px 0' }}>
|
||||
<Text style={{ color: FUI_COLORS.text.muted, fontSize: 14 }}>暂无对比数据</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 主组件
|
||||
// ============================================
|
||||
const StockCompareModal: React.FC<StockCompareModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -57,187 +113,201 @@ const StockCompareModal: React.FC<StockCompareModalProps> = ({
|
||||
compareStockInfo,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
// 黑金主题颜色
|
||||
const bgColor = '#1A202C';
|
||||
const borderColor = '#C9A961';
|
||||
const goldColor = '#F4D03F';
|
||||
const positiveColor = '#EF4444'; // 红涨
|
||||
const negativeColor = '#10B981'; // 绿跌
|
||||
// 构建表格数据
|
||||
const tableData = useMemo<CompareTableRow[]>(() => {
|
||||
if (!currentStockInfo || !compareStockInfo) return [];
|
||||
|
||||
// 加载中或无数据时的显示
|
||||
if (isLoading || !currentStockInfo || !compareStockInfo) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="5xl" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<ModalHeader color={goldColor}>股票对比</ModalHeader>
|
||||
<ModalCloseButton color={borderColor} />
|
||||
<ModalBody pb={6}>
|
||||
<Center py={20}>
|
||||
{isLoading ? (
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color={goldColor} />
|
||||
<Text color={borderColor}>加载对比数据中...</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<Text color={borderColor}>暂无对比数据</Text>
|
||||
)}
|
||||
</Center>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
return COMPARE_METRICS.map((metric) => {
|
||||
const currentValue = getValueByPath<number>(currentStockInfo, metric.path);
|
||||
const compareValue = getValueByPath<number>(compareStockInfo, metric.path);
|
||||
const format = (metric.format || 'number') as 'percent' | 'number';
|
||||
|
||||
return {
|
||||
key: metric.key,
|
||||
metric: metric.label,
|
||||
currentValue,
|
||||
compareValue,
|
||||
diff: calculateDiff(currentValue, compareValue, format),
|
||||
format,
|
||||
};
|
||||
});
|
||||
}, [currentStockInfo, compareStockInfo]);
|
||||
|
||||
// 表格列定义
|
||||
const columns = useMemo<ColumnsType<CompareTableRow>>(() => [
|
||||
{
|
||||
title: '指标',
|
||||
dataIndex: 'metric',
|
||||
key: 'metric',
|
||||
align: 'center',
|
||||
width: '25%',
|
||||
render: (text) => <span style={{ color: FUI_COLORS.gold[400] }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: currentStockInfo?.stock_name || currentStock,
|
||||
dataIndex: 'currentValue',
|
||||
key: 'currentValue',
|
||||
align: 'center',
|
||||
width: '25%',
|
||||
render: (value, record) => (
|
||||
<span style={{ color: FUI_COLORS.gold[400], fontWeight: 500 }}>
|
||||
{formatValue(value, record.format)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: compareStockInfo?.stock_name || compareStock,
|
||||
dataIndex: 'compareValue',
|
||||
key: 'compareValue',
|
||||
align: 'center',
|
||||
width: '25%',
|
||||
render: (value, record) => (
|
||||
<span style={{ color: FUI_COLORS.gold[400], fontWeight: 500 }}>
|
||||
{formatValue(value, record.format)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '差异',
|
||||
dataIndex: 'diff',
|
||||
key: 'diff',
|
||||
align: 'center',
|
||||
width: '25%',
|
||||
render: (diff: number | null) => <DiffCell diff={diff} />,
|
||||
},
|
||||
], [currentStock, compareStock, currentStockInfo?.stock_name, compareStockInfo?.stock_name]);
|
||||
|
||||
// 盈利能力图表配置
|
||||
const profitabilityChartOption = useMemo(() => {
|
||||
if (!currentStockInfo || !compareStockInfo) return {};
|
||||
|
||||
return getCompareBarChartOption(
|
||||
'盈利能力对比',
|
||||
currentStockInfo.stock_name || '',
|
||||
compareStockInfo.stock_name || '',
|
||||
['ROE', 'ROA', '毛利率', '净利率'],
|
||||
[
|
||||
currentStockInfo.key_metrics?.roe,
|
||||
currentStockInfo.key_metrics?.roa,
|
||||
currentStockInfo.key_metrics?.gross_margin,
|
||||
currentStockInfo.key_metrics?.net_margin,
|
||||
],
|
||||
[
|
||||
compareStockInfo.key_metrics?.roe,
|
||||
compareStockInfo.key_metrics?.roa,
|
||||
compareStockInfo.key_metrics?.gross_margin,
|
||||
compareStockInfo.key_metrics?.net_margin,
|
||||
]
|
||||
);
|
||||
}
|
||||
}, [currentStockInfo, compareStockInfo]);
|
||||
|
||||
// 成长能力图表配置
|
||||
const growthChartOption = useMemo(() => {
|
||||
if (!currentStockInfo || !compareStockInfo) return {};
|
||||
|
||||
return getCompareBarChartOption(
|
||||
'成长能力对比',
|
||||
currentStockInfo.stock_name || '',
|
||||
compareStockInfo.stock_name || '',
|
||||
['营收增长', '利润增长', '资产增长', '权益增长'],
|
||||
[
|
||||
currentStockInfo.growth_rates?.revenue_growth,
|
||||
currentStockInfo.growth_rates?.profit_growth,
|
||||
currentStockInfo.growth_rates?.asset_growth,
|
||||
currentStockInfo.growth_rates?.equity_growth,
|
||||
],
|
||||
[
|
||||
compareStockInfo.growth_rates?.revenue_growth,
|
||||
compareStockInfo.growth_rates?.profit_growth,
|
||||
compareStockInfo.growth_rates?.asset_growth,
|
||||
compareStockInfo.growth_rates?.equity_growth,
|
||||
]
|
||||
);
|
||||
}, [currentStockInfo, compareStockInfo]);
|
||||
|
||||
// Modal 标题
|
||||
const modalTitle = useMemo(() => {
|
||||
if (!currentStockInfo || !compareStockInfo) return '股票对比';
|
||||
return `${currentStockInfo.stock_name} (${currentStock}) vs ${compareStockInfo.stock_name} (${compareStock})`;
|
||||
}, [currentStock, compareStock, currentStockInfo, compareStockInfo]);
|
||||
|
||||
// 渲染内容
|
||||
const renderContent = () => {
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (!currentStockInfo || !compareStockInfo) return <EmptyState />;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
{/* 对比明细表格 */}
|
||||
<Card
|
||||
title={<CardTitle>对比明细</CardTitle>}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
styles={{
|
||||
...cardStyles,
|
||||
body: { padding: '12px 0' },
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
showHeader
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 对比图表 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={<CardTitle>盈利能力对比</CardTitle>}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
styles={chartCardStyles}
|
||||
>
|
||||
<ReactECharts
|
||||
option={profitabilityChartOption}
|
||||
style={{ height: 280 }}
|
||||
notMerge
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={<CardTitle>成长能力对比</CardTitle>}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
styles={chartCardStyles}
|
||||
>
|
||||
<ReactECharts
|
||||
option={growthChartOption}
|
||||
style={{ height: 280 }}
|
||||
notMerge
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="5xl" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<ModalHeader color={goldColor}>
|
||||
{currentStockInfo?.stock_name} ({currentStock}) vs {compareStockInfo?.stock_name} ({compareStock})
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color={borderColor} />
|
||||
<ModalBody pb={6}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 对比明细表格 */}
|
||||
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<CardHeader pb={2}>
|
||||
<Heading size="sm" color={goldColor}>对比明细</Heading>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<TableContainer>
|
||||
<Table size="sm" variant="unstyled">
|
||||
<Thead>
|
||||
<Tr borderBottom="1px solid" borderColor={borderColor}>
|
||||
<Th color={borderColor} fontSize="xs">指标</Th>
|
||||
<Th isNumeric color={borderColor} fontSize="xs">{currentStockInfo?.stock_name}</Th>
|
||||
<Th isNumeric color={borderColor} fontSize="xs">{compareStockInfo?.stock_name}</Th>
|
||||
<Th isNumeric color={borderColor} fontSize="xs">差异</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{COMPARE_METRICS.map((metric) => {
|
||||
const value1 = getValueByPath<number>(currentStockInfo, metric.path);
|
||||
const value2 = getValueByPath<number>(compareStockInfo, metric.path);
|
||||
|
||||
let diff: number | null = null;
|
||||
let diffColor = borderColor;
|
||||
|
||||
if (value1 !== undefined && value2 !== undefined && value1 !== null && value2 !== null) {
|
||||
if (metric.format === 'percent') {
|
||||
diff = value1 - value2;
|
||||
diffColor = diff > 0 ? positiveColor : negativeColor;
|
||||
} else if (value2 !== 0) {
|
||||
diff = ((value1 - value2) / Math.abs(value2)) * 100;
|
||||
diffColor = diff > 0 ? positiveColor : negativeColor;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr key={metric.key} borderBottom="1px solid" borderColor="whiteAlpha.100">
|
||||
<Td color={borderColor} fontSize="sm">{metric.label}</Td>
|
||||
<Td isNumeric color={goldColor} fontSize="sm">
|
||||
{metric.format === 'percent'
|
||||
? formatUtils.formatPercent(value1)
|
||||
: formatUtils.formatLargeNumber(value1)}
|
||||
</Td>
|
||||
<Td isNumeric color={goldColor} fontSize="sm">
|
||||
{metric.format === 'percent'
|
||||
? formatUtils.formatPercent(value2)
|
||||
: formatUtils.formatLargeNumber(value2)}
|
||||
</Td>
|
||||
<Td isNumeric color={diffColor} fontSize="sm">
|
||||
{diff !== null ? (
|
||||
<HStack spacing={1} justify="flex-end">
|
||||
{diff > 0 && <ArrowUp size={12} />}
|
||||
{diff < 0 && <ArrowDown size={12} />}
|
||||
<Text>
|
||||
{metric.format === 'percent'
|
||||
? `${Math.abs(diff).toFixed(2)}pp`
|
||||
: `${Math.abs(diff).toFixed(2)}%`}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 对比图表 */}
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
|
||||
<GridItem>
|
||||
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<CardHeader pb={2}>
|
||||
<Heading size="sm" color={goldColor}>盈利能力对比</Heading>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<ReactECharts
|
||||
option={getCompareBarChartOption(
|
||||
'盈利能力对比',
|
||||
currentStockInfo?.stock_name || '',
|
||||
compareStockInfo?.stock_name || '',
|
||||
['ROE', 'ROA', '毛利率', '净利率'],
|
||||
[
|
||||
currentStockInfo?.key_metrics?.roe,
|
||||
currentStockInfo?.key_metrics?.roa,
|
||||
currentStockInfo?.key_metrics?.gross_margin,
|
||||
currentStockInfo?.key_metrics?.net_margin,
|
||||
],
|
||||
[
|
||||
compareStockInfo?.key_metrics?.roe,
|
||||
compareStockInfo?.key_metrics?.roa,
|
||||
compareStockInfo?.key_metrics?.gross_margin,
|
||||
compareStockInfo?.key_metrics?.net_margin,
|
||||
]
|
||||
)}
|
||||
style={{ height: '280px' }}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
|
||||
<GridItem>
|
||||
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<CardHeader pb={2}>
|
||||
<Heading size="sm" color={goldColor}>成长能力对比</Heading>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<ReactECharts
|
||||
option={getCompareBarChartOption(
|
||||
'成长能力对比',
|
||||
currentStockInfo?.stock_name || '',
|
||||
compareStockInfo?.stock_name || '',
|
||||
['营收增长', '利润增长', '资产增长', '股东权益增长'],
|
||||
[
|
||||
currentStockInfo?.growth_rates?.revenue_growth,
|
||||
currentStockInfo?.growth_rates?.profit_growth,
|
||||
currentStockInfo?.growth_rates?.asset_growth,
|
||||
currentStockInfo?.growth_rates?.equity_growth,
|
||||
],
|
||||
[
|
||||
compareStockInfo?.growth_rates?.revenue_growth,
|
||||
compareStockInfo?.growth_rates?.profit_growth,
|
||||
compareStockInfo?.growth_rates?.asset_growth,
|
||||
compareStockInfo?.growth_rates?.equity_growth,
|
||||
]
|
||||
)}
|
||||
style={{ height: '280px' }}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<ConfigProvider theme={antdDarkTheme}>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
title={modalTitle}
|
||||
footer={null}
|
||||
width={1000}
|
||||
centered
|
||||
destroyOnHidden
|
||||
styles={modalStyles}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - 操作按钮悬停态有玻璃效果
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { Flex, HStack, Text, Badge, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import FavoriteButton from '@components/FavoriteButton';
|
||||
@@ -25,8 +25,6 @@ export interface StockHeaderProps {
|
||||
industry?: string;
|
||||
/** 指数标签(沪深300、中证500等) */
|
||||
indexTags?: string[];
|
||||
/** 更新时间 */
|
||||
updateTime?: string;
|
||||
// 关注相关
|
||||
isInWatchlist?: boolean;
|
||||
isWatchlistLoading?: boolean;
|
||||
@@ -51,7 +49,6 @@ export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
||||
industryL1,
|
||||
industry,
|
||||
indexTags,
|
||||
updateTime,
|
||||
isInWatchlist = false,
|
||||
isWatchlistLoading = false,
|
||||
onWatchlistToggle,
|
||||
@@ -59,6 +56,17 @@ export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
||||
isCompareLoading = false,
|
||||
onCompare,
|
||||
}) => {
|
||||
// 页面打开时间(组件首次渲染时记录,格式:年月日时分)
|
||||
const [openTime] = useState(() => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = now.getDate().toString().padStart(2, '0');
|
||||
const hour = now.getHours().toString().padStart(2, '0');
|
||||
const minute = now.getMinutes().toString().padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
|
||||
@@ -131,7 +139,7 @@ export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text fontSize="13px" color={T.textMuted}>
|
||||
{updateTime?.split(' ')[1] || '--:--'}
|
||||
{openTime}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
@@ -61,6 +61,8 @@ export const DEEP_SPACE_THEME = {
|
||||
textSecondary: 'rgba(245, 240, 225, 0.95)',
|
||||
// 标签文字(柔和白色)
|
||||
textMuted: 'rgba(235, 230, 215, 0.85)',
|
||||
// 占位符文字(低透明度,避免过亮)
|
||||
textPlaceholder: 'rgba(235, 230, 215, 0.45)',
|
||||
// 纯白文字
|
||||
textWhite: 'rgba(255, 255, 255, 0.98)',
|
||||
textWhiteMuted: 'rgba(255, 255, 255, 0.8)',
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
* - MainForceInfo:主力动态
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, Flex, VStack, useDisclosure } from '@chakra-ui/react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { Box, Flex, VStack, useDisclosure, useToast } from '@chakra-ui/react';
|
||||
import { CardGlow } from '@components/FUI';
|
||||
|
||||
// 子组件导入
|
||||
@@ -64,6 +64,34 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
clearCompare();
|
||||
};
|
||||
|
||||
// Toast 提示
|
||||
const toast = useToast();
|
||||
|
||||
// 分享功能:复制链接到剪贴板
|
||||
const handleShare = useCallback(async () => {
|
||||
const shareUrl = `https://valuefrontier.cn/company?scode=${stockCode}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast({
|
||||
title: '链接已复制',
|
||||
description: '快去分享给好友吧~',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: '复制失败',
|
||||
description: '请手动复制链接',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
position: 'top',
|
||||
});
|
||||
}
|
||||
}, [stockCode, toast]);
|
||||
|
||||
// 加载中
|
||||
if (isLoading || !quoteData) {
|
||||
return <LoadingSkeleton />;
|
||||
@@ -83,10 +111,10 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
industryL1={quoteData.industryL1}
|
||||
industry={quoteData.industry}
|
||||
indexTags={quoteData.indexTags}
|
||||
updateTime={quoteData.updateTime}
|
||||
isInWatchlist={isInWatchlist}
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
onShare={handleShare}
|
||||
isCompareLoading={isCompareLoading}
|
||||
onCompare={handleCompare}
|
||||
/>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// src/views/Company/hooks/useCompanyStock.js
|
||||
// 股票代码管理 Hook - 处理 URL 参数同步和搜索逻辑
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { DEFAULT_STOCK_CODE, URL_PARAM_NAME } from '../constants';
|
||||
|
||||
/**
|
||||
* 股票代码管理 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 管理当前股票代码状态
|
||||
* - 双向同步 URL 参数
|
||||
* - 处理搜索输入和提交
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} [options.defaultCode] - 默认股票代码
|
||||
* @param {string} [options.paramName] - URL 参数名
|
||||
* @param {Function} [options.onStockChange] - 股票代码变化回调 (newCode, prevCode) => void
|
||||
* @returns {Object} 股票代码状态和操作方法
|
||||
*/
|
||||
export const useCompanyStock = (options = {}) => {
|
||||
const {
|
||||
defaultCode = DEFAULT_STOCK_CODE,
|
||||
paramName = URL_PARAM_NAME,
|
||||
onStockChange,
|
||||
} = options;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// 从 URL 参数初始化股票代码
|
||||
const [stockCode, setStockCode] = useState(
|
||||
searchParams.get(paramName) || defaultCode
|
||||
);
|
||||
|
||||
// 输入框状态(默认为空,不显示默认股票代码)
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
|
||||
/**
|
||||
* 监听 URL 参数变化,同步到本地状态
|
||||
* 支持浏览器前进/后退按钮
|
||||
*/
|
||||
useEffect(() => {
|
||||
const urlCode = searchParams.get(paramName);
|
||||
if (urlCode && urlCode !== stockCode) {
|
||||
setStockCode(urlCode);
|
||||
setInputCode(urlCode);
|
||||
}
|
||||
}, [searchParams, paramName, stockCode]);
|
||||
|
||||
/**
|
||||
* 执行搜索 - 更新 stockCode 和 URL
|
||||
* @param {string} [code] - 可选,直接传入股票代码(用于下拉选择)
|
||||
*/
|
||||
const handleSearch = useCallback((code) => {
|
||||
const trimmedCode = code || inputCode?.trim();
|
||||
|
||||
if (trimmedCode && trimmedCode !== stockCode) {
|
||||
// 触发变化回调(用于追踪)
|
||||
onStockChange?.(trimmedCode, stockCode);
|
||||
|
||||
// 更新状态
|
||||
setStockCode(trimmedCode);
|
||||
|
||||
// 更新 URL 参数
|
||||
setSearchParams({ [paramName]: trimmedCode });
|
||||
}
|
||||
}, [inputCode, stockCode, paramName, setSearchParams, onStockChange]);
|
||||
|
||||
/**
|
||||
* 处理键盘事件 - 回车键触发搜索
|
||||
*/
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}, [handleSearch]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
stockCode, // 当前确认的股票代码
|
||||
inputCode, // 输入框中的值(未确认)
|
||||
|
||||
// 操作方法
|
||||
setInputCode, // 更新输入框
|
||||
handleSearch, // 执行搜索
|
||||
handleKeyDown, // 处理回车键(改用 onKeyDown)
|
||||
};
|
||||
};
|
||||
|
||||
export default useCompanyStock;
|
||||
@@ -1,166 +0,0 @@
|
||||
// src/views/Company/hooks/useCompanyWatchlist.js
|
||||
// 自选股管理 Hook - Company 页面专用,复用 Redux stockSlice
|
||||
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
import { logger } from '@utils/logger';
|
||||
import {
|
||||
loadWatchlist,
|
||||
toggleWatchlist,
|
||||
optimisticAddWatchlist,
|
||||
optimisticRemoveWatchlist
|
||||
} from '@store/slices/stockSlice';
|
||||
import { TOAST_MESSAGES } from '../constants';
|
||||
|
||||
/**
|
||||
* Company 页面自选股管理 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 检查当前股票是否在自选股中
|
||||
* - 提供添加/移除自选股功能
|
||||
* - 与 Redux stockSlice 同步
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.stockCode - 当前股票代码
|
||||
* @param {Object} [options.tracking] - 追踪回调
|
||||
* @param {Function} [options.tracking.onAdd] - 添加自选时的追踪回调
|
||||
* @param {Function} [options.tracking.onRemove] - 移除自选时的追踪回调
|
||||
* @returns {Object} 自选股状态和操作方法
|
||||
*/
|
||||
export const useCompanyWatchlist = ({ stockCode, tracking = {} } = {}) => {
|
||||
const dispatch = useDispatch();
|
||||
const toast = useToast();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// 从 Redux 获取自选股列表
|
||||
const watchlist = useSelector((state) => state.stock.watchlist);
|
||||
const watchlistLoading = useSelector((state) => state.stock.loading.watchlist);
|
||||
|
||||
// 追踪是否已初始化(防止无限循环)
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 派生状态:判断当前股票是否在自选股中
|
||||
* 使用 useMemo 避免重复计算
|
||||
*/
|
||||
const isInWatchlist = useMemo(() => {
|
||||
if (!stockCode || !Array.isArray(watchlist)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 标准化股票代码(提取6位数字)
|
||||
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
|
||||
const targetCode = normalize(stockCode);
|
||||
|
||||
return watchlist.some((item) => normalize(item.stock_code) === targetCode);
|
||||
}, [watchlist, stockCode]);
|
||||
|
||||
/**
|
||||
* 初始化:加载自选股列表
|
||||
* 使用 hasInitializedRef 防止无限循环(用户可能确实没有自选股)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current && isAuthenticated && !watchlistLoading) {
|
||||
hasInitializedRef.current = true;
|
||||
dispatch(loadWatchlist());
|
||||
}
|
||||
}, [isAuthenticated, watchlistLoading, dispatch]);
|
||||
|
||||
/**
|
||||
* 切换自选股状态(乐观更新模式)
|
||||
* 1. 立即更新 UI(无 loading)
|
||||
* 2. 后台静默请求 API
|
||||
* 3. 失败时回滚并提示
|
||||
*/
|
||||
const toggle = useCallback(async () => {
|
||||
// 参数校验
|
||||
if (!stockCode) {
|
||||
logger.warn('useCompanyWatchlist', 'toggle', '无效的股票代码', { stockCode });
|
||||
toast(TOAST_MESSAGES.INVALID_CODE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
if (!isAuthenticated) {
|
||||
logger.warn('useCompanyWatchlist', 'toggle', '用户未登录', { stockCode });
|
||||
toast(TOAST_MESSAGES.LOGIN_REQUIRED);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标准化股票代码用于匹配
|
||||
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
|
||||
const targetCode = normalize(stockCode);
|
||||
|
||||
// 从 watchlist 中找到原始 stock_code(保持与后端数据结构一致)
|
||||
const matchedItem = watchlist.find(
|
||||
item => normalize(item.stock_code) === targetCode
|
||||
);
|
||||
// 移除时使用原始 stock_code,添加时使用传入的 stockCode
|
||||
const codeForApi = isInWatchlist ? (matchedItem?.stock_code || stockCode) : stockCode;
|
||||
|
||||
// 保存当前状态用于回滚
|
||||
const wasInWatchlist = isInWatchlist;
|
||||
|
||||
logger.debug('useCompanyWatchlist', '切换自选股(乐观更新)', {
|
||||
stockCode,
|
||||
codeForApi,
|
||||
wasInWatchlist,
|
||||
action: wasInWatchlist ? 'remove' : 'add',
|
||||
});
|
||||
|
||||
// 1. 乐观更新:立即更新 UI(不显示 loading)
|
||||
if (wasInWatchlist) {
|
||||
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
|
||||
} else {
|
||||
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 后台静默请求 API
|
||||
await dispatch(
|
||||
toggleWatchlist({
|
||||
stockCode: codeForApi,
|
||||
stockName: matchedItem?.stock_name || '',
|
||||
isInWatchlist: wasInWatchlist,
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
// 3. 成功:触发追踪回调(不显示 toast,状态已更新)
|
||||
if (wasInWatchlist) {
|
||||
tracking.onRemove?.(stockCode);
|
||||
} else {
|
||||
tracking.onAdd?.(stockCode);
|
||||
}
|
||||
} catch (error) {
|
||||
// 4. 失败:回滚状态 + 显示错误提示
|
||||
logger.error('useCompanyWatchlist', 'toggle', error, {
|
||||
stockCode,
|
||||
wasInWatchlist,
|
||||
});
|
||||
|
||||
// 回滚操作
|
||||
if (wasInWatchlist) {
|
||||
// 之前在自选中,乐观删除了,现在要恢复
|
||||
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
|
||||
} else {
|
||||
// 之前不在自选中,乐观添加了,现在要移除
|
||||
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
|
||||
}
|
||||
|
||||
toast(TOAST_MESSAGES.WATCHLIST_ERROR);
|
||||
}
|
||||
}, [stockCode, isAuthenticated, isInWatchlist, watchlist, dispatch, toast, tracking]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isInWatchlist, // 是否在自选股中
|
||||
isLoading: watchlistLoading, // 仅初始加载时显示 loading(乐观更新模式)
|
||||
|
||||
// 操作方法
|
||||
toggle, // 切换自选状态
|
||||
};
|
||||
};
|
||||
|
||||
export default useCompanyWatchlist;
|
||||
270
src/views/Company/theme/antdTheme.ts
Normal file
270
src/views/Company/theme/antdTheme.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Company 页面 Ant Design 主题配置
|
||||
*
|
||||
* 与 FUI 主题系统保持一致的 Ant Design ConfigProvider 主题配置
|
||||
*
|
||||
* @example
|
||||
* import { antdDarkTheme, modalStyles, cardStyle } from '@views/Company/theme/antdTheme';
|
||||
*
|
||||
* <ConfigProvider theme={antdDarkTheme}>
|
||||
* <Modal styles={modalStyles}>...</Modal>
|
||||
* </ConfigProvider>
|
||||
*/
|
||||
|
||||
import { theme } from 'antd';
|
||||
import { FUI_COLORS, FUI_GLOW, FUI_GLASS } from './fui';
|
||||
import { alpha, fui } from './utils';
|
||||
|
||||
// ============================================
|
||||
// Ant Design 深空主题 Token
|
||||
// ============================================
|
||||
|
||||
export const antdDarkTheme = {
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
// 主色调
|
||||
colorPrimary: FUI_COLORS.gold[400],
|
||||
colorPrimaryHover: FUI_COLORS.gold[300],
|
||||
colorPrimaryActive: FUI_COLORS.gold[500],
|
||||
|
||||
// 背景色
|
||||
colorBgBase: FUI_COLORS.bg.deep,
|
||||
colorBgContainer: FUI_COLORS.bg.elevated,
|
||||
colorBgElevated: FUI_COLORS.bg.surface,
|
||||
colorBgLayout: FUI_COLORS.bg.primary,
|
||||
colorBgMask: 'rgba(0, 0, 0, 0.6)',
|
||||
|
||||
// 边框
|
||||
colorBorder: fui.border('default'),
|
||||
colorBorderSecondary: fui.border('subtle'),
|
||||
|
||||
// 文字
|
||||
colorText: FUI_COLORS.text.primary,
|
||||
colorTextSecondary: FUI_COLORS.text.secondary,
|
||||
colorTextTertiary: FUI_COLORS.text.muted,
|
||||
colorTextQuaternary: FUI_COLORS.text.dim,
|
||||
colorTextHeading: FUI_COLORS.gold[400],
|
||||
|
||||
// 链接
|
||||
colorLink: FUI_COLORS.gold[400],
|
||||
colorLinkHover: FUI_COLORS.gold[300],
|
||||
colorLinkActive: FUI_COLORS.gold[500],
|
||||
|
||||
// 成功/错误状态(涨跌色)
|
||||
colorSuccess: FUI_COLORS.status.negative, // 绿色
|
||||
colorError: FUI_COLORS.status.positive, // 红色
|
||||
colorWarning: FUI_COLORS.status.warning,
|
||||
colorInfo: FUI_COLORS.status.info,
|
||||
|
||||
// 圆角
|
||||
borderRadius: 8,
|
||||
borderRadiusLG: 12,
|
||||
borderRadiusSM: 6,
|
||||
borderRadiusXS: 4,
|
||||
|
||||
// 字体
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 14,
|
||||
|
||||
// 间距
|
||||
padding: 16,
|
||||
paddingLG: 24,
|
||||
paddingSM: 12,
|
||||
paddingXS: 8,
|
||||
},
|
||||
components: {
|
||||
// Modal 组件
|
||||
Modal: {
|
||||
headerBg: FUI_COLORS.bg.deep,
|
||||
contentBg: FUI_COLORS.bg.deep,
|
||||
footerBg: FUI_COLORS.bg.deep,
|
||||
titleColor: FUI_COLORS.gold[400],
|
||||
titleFontSize: 18,
|
||||
colorIcon: FUI_COLORS.text.muted,
|
||||
colorIconHover: FUI_COLORS.gold[400],
|
||||
},
|
||||
|
||||
// Table 组件
|
||||
Table: {
|
||||
headerBg: alpha('gold', 0.05),
|
||||
headerColor: FUI_COLORS.text.muted,
|
||||
headerSplitColor: fui.border('subtle'),
|
||||
rowHoverBg: 'rgba(156, 163, 175, 0.15)', // 浅灰色悬停背景
|
||||
rowSelectedBg: alpha('white', 0.12),
|
||||
rowSelectedHoverBg: alpha('white', 0.15),
|
||||
borderColor: fui.border('subtle'),
|
||||
cellFontSize: 13,
|
||||
cellPaddingBlock: 14,
|
||||
cellPaddingInline: 16,
|
||||
},
|
||||
|
||||
// Card 组件
|
||||
Card: {
|
||||
headerBg: 'transparent',
|
||||
colorBgContainer: FUI_COLORS.bg.elevated,
|
||||
colorBorderSecondary: fui.border('default'),
|
||||
paddingLG: 16,
|
||||
},
|
||||
|
||||
// Button 组件
|
||||
Button: {
|
||||
primaryColor: FUI_COLORS.bg.deep,
|
||||
colorPrimaryHover: FUI_COLORS.gold[300],
|
||||
colorPrimaryActive: FUI_COLORS.gold[500],
|
||||
defaultBg: 'transparent',
|
||||
defaultBorderColor: fui.border('default'),
|
||||
defaultColor: FUI_COLORS.text.secondary,
|
||||
},
|
||||
|
||||
// Input 组件
|
||||
Input: {
|
||||
colorBgContainer: FUI_COLORS.bg.primary,
|
||||
colorBorder: fui.border('default'),
|
||||
hoverBorderColor: fui.border('hover'),
|
||||
activeBorderColor: FUI_COLORS.gold[400],
|
||||
activeShadow: FUI_GLOW.gold.sm,
|
||||
},
|
||||
|
||||
// Select 组件
|
||||
Select: {
|
||||
colorBgContainer: FUI_COLORS.bg.primary,
|
||||
colorBorder: fui.border('default'),
|
||||
optionSelectedBg: alpha('gold', 0.15),
|
||||
},
|
||||
|
||||
// Spin 组件
|
||||
Spin: {
|
||||
colorPrimary: FUI_COLORS.gold[400],
|
||||
},
|
||||
|
||||
// Tabs 组件
|
||||
Tabs: {
|
||||
inkBarColor: FUI_COLORS.gold[400],
|
||||
itemActiveColor: FUI_COLORS.gold[400],
|
||||
itemHoverColor: FUI_COLORS.gold[300],
|
||||
itemSelectedColor: FUI_COLORS.gold[400],
|
||||
},
|
||||
|
||||
// Tag 组件
|
||||
Tag: {
|
||||
defaultBg: alpha('gold', 0.1),
|
||||
defaultColor: FUI_COLORS.gold[400],
|
||||
},
|
||||
|
||||
// Tooltip 组件
|
||||
Tooltip: {
|
||||
colorBgSpotlight: FUI_COLORS.bg.surface,
|
||||
colorTextLightSolid: FUI_COLORS.text.primary,
|
||||
},
|
||||
|
||||
// Divider 组件
|
||||
Divider: {
|
||||
colorSplit: fui.border('subtle'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 组件样式预设
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Modal 样式配置
|
||||
* 用于 Modal 组件的 styles 属性
|
||||
*/
|
||||
export const modalStyles = {
|
||||
mask: {
|
||||
backdropFilter: FUI_GLASS.blur.md,
|
||||
},
|
||||
content: {
|
||||
background: FUI_COLORS.bg.deep,
|
||||
border: fui.glassBorder('default'),
|
||||
borderRadius: 16,
|
||||
boxShadow: FUI_GLOW.gold.md,
|
||||
},
|
||||
header: {
|
||||
background: 'transparent',
|
||||
borderBottom: fui.glassBorder('subtle'),
|
||||
padding: '16px 24px',
|
||||
},
|
||||
body: {
|
||||
padding: 24,
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflowY: 'auto' as const,
|
||||
},
|
||||
footer: {
|
||||
background: 'transparent',
|
||||
borderTop: fui.glassBorder('subtle'),
|
||||
padding: '12px 24px',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 玻璃卡片样式
|
||||
* 用于 Card 组件的 style 属性
|
||||
*/
|
||||
export const cardStyle = {
|
||||
background: FUI_COLORS.bg.elevated,
|
||||
border: fui.glassBorder('default'),
|
||||
borderRadius: 12,
|
||||
};
|
||||
|
||||
/**
|
||||
* Card 内部样式配置
|
||||
* 用于 Card 组件的 styles 属性
|
||||
*/
|
||||
export const cardStyles = {
|
||||
header: {
|
||||
borderBottom: fui.glassBorder('subtle'),
|
||||
padding: '12px 16px',
|
||||
},
|
||||
body: {
|
||||
padding: 16,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 图表卡片样式配置
|
||||
*/
|
||||
export const chartCardStyles = {
|
||||
header: {
|
||||
borderBottom: fui.glassBorder('subtle'),
|
||||
padding: '12px 16px',
|
||||
},
|
||||
body: {
|
||||
padding: 12,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 表格样式
|
||||
* 用于 Table 组件的 style 属性
|
||||
*/
|
||||
export const tableStyle = {
|
||||
background: 'transparent',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 工具函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 创建自定义 Ant Design 主题
|
||||
* 可在基础主题上覆盖特定配置
|
||||
*/
|
||||
export function createAntdTheme(overrides?: Partial<typeof antdDarkTheme>) {
|
||||
return {
|
||||
...antdDarkTheme,
|
||||
...overrides,
|
||||
token: {
|
||||
...antdDarkTheme.token,
|
||||
...overrides?.token,
|
||||
},
|
||||
components: {
|
||||
...antdDarkTheme.components,
|
||||
...overrides?.components,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default antdDarkTheme;
|
||||
@@ -5,6 +5,7 @@
|
||||
* import { COLORS, GLOW, GLASS } from '@views/Company/theme';
|
||||
* import { FUI_COLORS, FUI_THEME } from '@views/Company/theme';
|
||||
* import { alpha, fui, chartTheme } from '@views/Company/theme';
|
||||
* import { antdDarkTheme, modalStyles, cardStyle } from '@views/Company/theme';
|
||||
*/
|
||||
|
||||
// 完整主题对象
|
||||
@@ -18,6 +19,17 @@ export {
|
||||
FUI_STYLES,
|
||||
} from './fui';
|
||||
|
||||
// Ant Design 主题配置
|
||||
export {
|
||||
antdDarkTheme,
|
||||
modalStyles,
|
||||
cardStyle,
|
||||
cardStyles,
|
||||
chartCardStyles,
|
||||
tableStyle,
|
||||
createAntdTheme,
|
||||
} from './antdTheme';
|
||||
|
||||
// 主题组件
|
||||
export * from './components';
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// src/views/Landing/index.js
|
||||
// Landing 页面 - 使用 iframe 嵌入静态 landing.html
|
||||
|
||||
/**
|
||||
* Landing 组件
|
||||
*
|
||||
* 使用 iframe 全屏嵌入 landing.html,保持静态页面的完整功能
|
||||
* 同时可以通过 React 路由访问
|
||||
*/
|
||||
export default function Landing() {
|
||||
return (
|
||||
<iframe
|
||||
src="/landing.html"
|
||||
title="价值前沿 - 金融AI舆情分析系统"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import IconBox from 'components/Icons/IconBox';
|
||||
import { MastercardIcon, VisaIcon } from 'components/Icons/Icons';
|
||||
import { HSeparator } from 'components/Separator/Separator';
|
||||
import BillingRow from 'components/Tables/BillingRow';
|
||||
import InvoicesRow from 'components/Tables/InvoicesRow';
|
||||
import TransactionRow from 'components/Tables/TransactionRow';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -45,10 +46,10 @@ import {
|
||||
Calendar,
|
||||
Gem,
|
||||
CreditCard,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
billingData,
|
||||
invoicesData,
|
||||
newestTransactions,
|
||||
olderTransactions,
|
||||
} from 'variables/general';
|
||||
@@ -69,7 +70,7 @@ function Billing() {
|
||||
templateColumns={{
|
||||
sm: '1fr',
|
||||
md: '1fr 1fr',
|
||||
xl: '1fr 1fr 1fr 1fr 1fr',
|
||||
xl: '1fr 1fr 1fr 1fr',
|
||||
}}
|
||||
templateRows={{ sm: 'auto auto auto', md: '1fr auto', xl: '1fr' }}
|
||||
gap='26px'
|
||||
@@ -96,7 +97,7 @@ function Billing() {
|
||||
>
|
||||
<Flex justify='space-between' align='center'>
|
||||
<Text fontSize='md' fontWeight='bold'>
|
||||
价值前沿
|
||||
Argon x Chakra
|
||||
</Text>
|
||||
<Icon
|
||||
as={CreditCard}
|
||||
@@ -109,20 +110,20 @@ function Billing() {
|
||||
<Flex direction='column'>
|
||||
<Box>
|
||||
<Text fontSize='2xl' letterSpacing='2px' fontWeight='bold'>
|
||||
**** **** **** 1234
|
||||
7812 2139 0823 XXXX
|
||||
</Text>
|
||||
</Box>
|
||||
<Flex mt='14px'>
|
||||
<Flex direction='column' me='34px'>
|
||||
<Text fontSize='xs'>有效期</Text>
|
||||
<Text fontSize='xs'>VALID THRU</Text>
|
||||
<Text fontSize='xs' fontWeight='bold'>
|
||||
12/26
|
||||
05/24
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex direction='column'>
|
||||
<Text fontSize='xs'>CVV</Text>
|
||||
<Text fontSize='xs' fontWeight='bold'>
|
||||
***
|
||||
09X
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -143,7 +144,7 @@ function Billing() {
|
||||
w='100%'
|
||||
>
|
||||
<Text fontSize='md' color={textColor} fontWeight='bold'>
|
||||
账户余额
|
||||
Salary
|
||||
</Text>
|
||||
<Text
|
||||
mb='24px'
|
||||
@@ -151,12 +152,12 @@ function Billing() {
|
||||
color='gray.400'
|
||||
fontWeight='semibold'
|
||||
>
|
||||
可用于支付
|
||||
Belong Interactive
|
||||
</Text>
|
||||
<HSeparator />
|
||||
</Flex>
|
||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
||||
¥0.00
|
||||
+$2000
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
@@ -192,56 +193,15 @@ function Billing() {
|
||||
</Text>
|
||||
<HSeparator />
|
||||
</Flex>
|
||||
<Button
|
||||
size='sm'
|
||||
colorScheme='purple'
|
||||
<Button
|
||||
size='sm'
|
||||
colorScheme='purple'
|
||||
onClick={() => navigate('/home/pages/account/subscription')}
|
||||
>
|
||||
管理订阅
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Card p='16px' display='flex' align='center' justify='center'>
|
||||
<Flex
|
||||
direction='column'
|
||||
align='center'
|
||||
justify='center'
|
||||
w='100%'
|
||||
py='14px'
|
||||
>
|
||||
<IconBox h={'60px'} w={'60px'} bg='teal.500'>
|
||||
<Icon h={'24px'} w={'24px'} color='white' as={FileText} />
|
||||
</IconBox>
|
||||
<Flex
|
||||
direction='column'
|
||||
m='14px'
|
||||
justify='center'
|
||||
textAlign='center'
|
||||
align='center'
|
||||
w='100%'
|
||||
>
|
||||
<Text fontSize='md' color={textColor} fontWeight='bold'>
|
||||
发票管理
|
||||
</Text>
|
||||
<Text
|
||||
mb='24px'
|
||||
fontSize='xs'
|
||||
color='gray.400'
|
||||
fontWeight='semibold'
|
||||
>
|
||||
申请与下载发票
|
||||
</Text>
|
||||
<HSeparator />
|
||||
</Flex>
|
||||
<Button
|
||||
size='sm'
|
||||
colorScheme='teal'
|
||||
onClick={() => navigate('/home/pages/account/invoice')}
|
||||
>
|
||||
管理发票
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Card p='16px' mt='24px'>
|
||||
<CardHeader>
|
||||
@@ -252,10 +212,10 @@ function Billing() {
|
||||
w='100%'
|
||||
>
|
||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
||||
支付方式
|
||||
Payment Method
|
||||
</Text>
|
||||
<Button variant={colorMode === 'dark' ? 'primary' : 'dark'}>
|
||||
添加新卡
|
||||
ADD A NEW CARD
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
@@ -332,7 +292,7 @@ function Billing() {
|
||||
<CardHeader>
|
||||
<Flex justify='space-between' align='center' mb='1rem' w='100%'>
|
||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
||||
发票记录
|
||||
Invoices
|
||||
</Text>
|
||||
<Button
|
||||
variant='outlined'
|
||||
@@ -341,25 +301,26 @@ function Billing() {
|
||||
_hover={colorMode === 'dark' && 'none'}
|
||||
minW='110px'
|
||||
maxH='35px'
|
||||
onClick={() => navigate('/home/pages/account/invoice')}
|
||||
>
|
||||
查看全部
|
||||
VIEW ALL
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Flex direction='column' w='100%' align='center' py='20px'>
|
||||
<Icon as={FileText} w='40px' h='40px' color='gray.300' mb='10px' />
|
||||
<Text color='gray.400' fontSize='sm' mb='15px'>
|
||||
暂无发票记录
|
||||
</Text>
|
||||
<Button
|
||||
size='sm'
|
||||
colorScheme='blue'
|
||||
onClick={() => navigate('/home/pages/account/invoice')}
|
||||
>
|
||||
申请开票
|
||||
</Button>
|
||||
<Flex direction='column' w='100%'>
|
||||
{invoicesData.map((row, index) => {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<InvoicesRow
|
||||
date={row.date}
|
||||
code={row.code}
|
||||
price={row.price}
|
||||
logo={row.logo}
|
||||
format={row.format}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Grid>
|
||||
@@ -368,7 +329,7 @@ function Billing() {
|
||||
<Flex direction='column'>
|
||||
<CardHeader py='12px'>
|
||||
<Text color={textColor} fontSize='lg' fontWeight='bold'>
|
||||
账单信息
|
||||
Billing Information
|
||||
</Text>
|
||||
</CardHeader>
|
||||
|
||||
@@ -403,7 +364,7 @@ function Billing() {
|
||||
fontSize={{ sm: 'lg', md: 'xl', lg: 'lg' }}
|
||||
fontWeight='bold'
|
||||
>
|
||||
交易记录
|
||||
Your Transactions
|
||||
</Text>
|
||||
<Flex align='center'>
|
||||
<Icon
|
||||
@@ -427,7 +388,7 @@ function Billing() {
|
||||
fontWeight='semibold'
|
||||
my='12px'
|
||||
>
|
||||
最近
|
||||
NEWEST
|
||||
</Text>
|
||||
{newestTransactions.map((row, index) => {
|
||||
return (
|
||||
@@ -447,7 +408,7 @@ function Billing() {
|
||||
fontWeight='semibold'
|
||||
my='12px'
|
||||
>
|
||||
更早
|
||||
OLDER
|
||||
</Text>
|
||||
{olderTransactions.map((row, index) => {
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Image,
|
||||
Stack,
|
||||
Table,
|
||||
Tbody,
|
||||
@@ -76,10 +77,10 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="lg"
|
||||
mb="12px"
|
||||
>
|
||||
北京市海淀区中关村大街1号
|
||||
St. Independence Embankment, 050105 Bucharest, Romania
|
||||
</Text>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
电话: 010-12345678
|
||||
tel: +4 (074) 1090873
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex
|
||||
@@ -93,10 +94,10 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="lg"
|
||||
mb="12px"
|
||||
>
|
||||
购买方: 张三
|
||||
Billed to: John Doe
|
||||
</Text>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
上海市浦东新区陆家嘴金融中心
|
||||
4006 Locust View Drive San Francisco CA California
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -117,7 +118,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="md"
|
||||
mb="8px"
|
||||
>
|
||||
发票号码
|
||||
Invoice no
|
||||
</Text>
|
||||
<Text color={secondaryColor} fontWeight="bold" fontSize="lg">
|
||||
#0453119
|
||||
@@ -126,18 +127,18 @@ class ComponentToPrint extends React.Component {
|
||||
<Flex direction="column">
|
||||
<Stack direction="row" mb="8px" justify={{ md: "end" }}>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
开票日期:{" "}
|
||||
Invoice date:{" "}
|
||||
</Text>
|
||||
<Text color={secondaryColor} fontWeight="bold" fontSize="lg">
|
||||
2024/03/06
|
||||
06/03/2022
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" justify={{ md: "end" }}>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
有效期至:{" "}
|
||||
Due date:{" "}
|
||||
</Text>
|
||||
<Text color={secondaryColor} fontWeight="bold" fontSize="lg">
|
||||
2024/07/29
|
||||
29/07/2022
|
||||
</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
@@ -153,7 +154,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontWeight="normal"
|
||||
ps="0px"
|
||||
>
|
||||
项目
|
||||
Item
|
||||
</Th>
|
||||
<Th
|
||||
borderColor={borderColor}
|
||||
@@ -161,7 +162,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
>
|
||||
数量
|
||||
Quantity
|
||||
</Th>
|
||||
<Th
|
||||
borderColor={borderColor}
|
||||
@@ -169,7 +170,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
>
|
||||
单价
|
||||
Rate
|
||||
</Th>
|
||||
<Th
|
||||
borderColor={borderColor}
|
||||
@@ -177,7 +178,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
>
|
||||
金额
|
||||
Amount
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
@@ -193,7 +194,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
Pro 专业版会员服务
|
||||
Premium Support
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
@@ -215,7 +216,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
¥ 2699.00
|
||||
$ 9.00
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
@@ -224,7 +225,49 @@ class ComponentToPrint extends React.Component {
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
¥ 2699.00
|
||||
$ 9.00
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td
|
||||
borderColor={borderColor}
|
||||
ps="0px"
|
||||
minW={{ sm: "300px" }}
|
||||
>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
Chakra UI - Dashboard PRO
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
3
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 99.00
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 297.00
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -235,12 +278,40 @@ class ComponentToPrint extends React.Component {
|
||||
minW={{ sm: "300px" }}
|
||||
border="none"
|
||||
>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
Parts for Service
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor} border="none">
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
1
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor} border="none">
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 89.00
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor} border="none">
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 89.00
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
@@ -252,12 +323,12 @@ class ComponentToPrint extends React.Component {
|
||||
<Td borderColor={borderColor}></Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text color={textColor} fontWeight="bold" fontSize="xl">
|
||||
合计
|
||||
Total
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text color={textColor} fontWeight="bold" fontSize="xl">
|
||||
¥ 2699.00
|
||||
$ 9.00
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -270,7 +341,7 @@ class ComponentToPrint extends React.Component {
|
||||
>
|
||||
<Flex direction="column" maxW="270px">
|
||||
<Text color={secondaryColor} fontWeight="bold" fontSize="xl">
|
||||
感谢您的支持!
|
||||
Thank You!
|
||||
</Text>
|
||||
<Text
|
||||
color="gray.400"
|
||||
@@ -279,12 +350,13 @@ class ComponentToPrint extends React.Component {
|
||||
mt="6px"
|
||||
mb="30px"
|
||||
>
|
||||
如果您对发票有任何问题,请联系我们:
|
||||
If you encounter any issues related to the invoice you can
|
||||
contact us at:
|
||||
</Text>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
邮箱:{" "}
|
||||
email:{" "}
|
||||
<Text as="span" color={secondaryColor} fontWeight="bold">
|
||||
support@valuefrontier.cn
|
||||
support@creative-tim.com
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
@@ -296,7 +368,7 @@ class ComponentToPrint extends React.Component {
|
||||
alignSelf={{ sm: "flex-start", md: "flex-end" }}
|
||||
mt={{ sm: "16px", md: "0px" }}
|
||||
>
|
||||
打印
|
||||
PRINT
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -319,12 +391,7 @@ function Invoice() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
minH="100vh"
|
||||
justify="center"
|
||||
align="center"
|
||||
>
|
||||
<Flex direction="column" pt={{ sm: "100px", lg: "50px" }}>
|
||||
<ComponentToPrint
|
||||
ref={componentRef}
|
||||
handlePrint={handlePrint}
|
||||
@@ -1,358 +0,0 @@
|
||||
/**
|
||||
* 发票管理页面
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
SimpleGrid,
|
||||
Spinner,
|
||||
Center,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
useDisclosure,
|
||||
AlertDialog,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogBody,
|
||||
AlertDialogFooter,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
} from '@chakra-ui/react';
|
||||
import { FileText, Plus, RefreshCw, Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import Card from '@components/Card/Card';
|
||||
import CardHeader from '@components/Card/CardHeader';
|
||||
import { InvoiceCard, InvoiceApplyModal } from '@components/Invoice';
|
||||
import {
|
||||
getInvoiceList,
|
||||
getInvoiceStats,
|
||||
cancelInvoice,
|
||||
downloadInvoice,
|
||||
} from '@/services/invoiceService';
|
||||
import type { InvoiceInfo, InvoiceStatus, InvoiceStats } from '@/types/invoice';
|
||||
|
||||
type TabType = 'all' | 'pending' | 'processing' | 'completed';
|
||||
|
||||
const tabConfig: { key: TabType; label: string; status?: InvoiceStatus }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '待处理', status: 'pending' },
|
||||
{ key: 'processing', label: '处理中', status: 'processing' },
|
||||
{ key: 'completed', label: '已完成', status: 'completed' },
|
||||
];
|
||||
|
||||
export default function InvoicePage() {
|
||||
const [invoices, setInvoices] = useState<InvoiceInfo[]>([]);
|
||||
const [stats, setStats] = useState<InvoiceStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [cancelingId, setCancelingId] = useState<string | null>(null);
|
||||
|
||||
const toast = useToast();
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const cancelDialogRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const {
|
||||
isOpen: isApplyOpen,
|
||||
onOpen: onApplyOpen,
|
||||
onClose: onApplyClose,
|
||||
} = useDisclosure();
|
||||
|
||||
const {
|
||||
isOpen: isCancelOpen,
|
||||
onOpen: onCancelOpen,
|
||||
onClose: onCancelClose,
|
||||
} = useDisclosure();
|
||||
|
||||
// 加载发票列表
|
||||
const loadInvoices = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const status = tabConfig.find((t) => t.key === activeTab)?.status;
|
||||
const res = await getInvoiceList({ status, pageSize: 50 });
|
||||
if (res.code === 200 && res.data) {
|
||||
setInvoices(res.data.list || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载发票列表失败:', error);
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法获取发票列表',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab, toast]);
|
||||
|
||||
// 加载统计信息
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await getInvoiceStats();
|
||||
if (res.code === 200 && res.data) {
|
||||
setStats(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载发票统计失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadInvoices();
|
||||
loadStats();
|
||||
}, [loadInvoices, loadStats]);
|
||||
|
||||
// 取消发票申请
|
||||
const handleCancel = async () => {
|
||||
if (!cancelingId) return;
|
||||
|
||||
try {
|
||||
const res = await cancelInvoice(cancelingId);
|
||||
if (res.code === 200) {
|
||||
toast({
|
||||
title: '取消成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadInvoices();
|
||||
loadStats();
|
||||
} else {
|
||||
toast({
|
||||
title: '取消失败',
|
||||
description: res.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '取消失败',
|
||||
description: '网络错误',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setCancelingId(null);
|
||||
onCancelClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 下载发票
|
||||
const handleDownload = async (invoice: InvoiceInfo) => {
|
||||
try {
|
||||
const blob = await downloadInvoice(invoice.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `发票_${invoice.invoiceNo || invoice.id}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '下载失败',
|
||||
description: '无法下载发票文件',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 开始取消流程
|
||||
const startCancel = (invoiceId: string) => {
|
||||
setCancelingId(invoiceId);
|
||||
onCancelOpen();
|
||||
};
|
||||
|
||||
// 申请成功回调
|
||||
const handleApplySuccess = () => {
|
||||
loadInvoices();
|
||||
loadStats();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex direction="column" pt={{ base: '120px', md: '75px' }}>
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} mb={6}>
|
||||
<Card p={4}>
|
||||
<Stat>
|
||||
<StatLabel color="gray.500">全部申请</StatLabel>
|
||||
<StatNumber color={textColor}>{stats.total}</StatNumber>
|
||||
<StatHelpText>
|
||||
<Icon as={FileText} boxSize={3} mr={1} />
|
||||
累计
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Card>
|
||||
<Card p={4}>
|
||||
<Stat>
|
||||
<StatLabel color="gray.500">待处理</StatLabel>
|
||||
<StatNumber color="yellow.500">{stats.pending}</StatNumber>
|
||||
<StatHelpText>
|
||||
<Icon as={Clock} boxSize={3} mr={1} />
|
||||
等待中
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Card>
|
||||
<Card p={4}>
|
||||
<Stat>
|
||||
<StatLabel color="gray.500">处理中</StatLabel>
|
||||
<StatNumber color="blue.500">{stats.processing}</StatNumber>
|
||||
<StatHelpText>
|
||||
<Icon as={AlertCircle} boxSize={3} mr={1} />
|
||||
开具中
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Card>
|
||||
<Card p={4}>
|
||||
<Stat>
|
||||
<StatLabel color="gray.500">已完成</StatLabel>
|
||||
<StatNumber color="green.500">{stats.completed}</StatNumber>
|
||||
<StatHelpText>
|
||||
<Icon as={CheckCircle} boxSize={3} mr={1} />
|
||||
已开具
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Flex justify="space-between" align="center" w="100%" mb={4}>
|
||||
<HStack>
|
||||
<Icon as={FileText} boxSize={6} color="blue.500" />
|
||||
<Text fontSize="xl" fontWeight="bold" color={textColor}>
|
||||
发票管理
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Icon as={RefreshCw} />}
|
||||
onClick={() => {
|
||||
loadInvoices();
|
||||
loadStats();
|
||||
}}
|
||||
isLoading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<Icon as={Plus} />}
|
||||
onClick={onApplyOpen}
|
||||
>
|
||||
申请开票
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Tabs
|
||||
index={tabConfig.findIndex((t) => t.key === activeTab)}
|
||||
onChange={(index) => setActiveTab(tabConfig[index].key)}
|
||||
>
|
||||
<TabList>
|
||||
{tabConfig.map((tab) => (
|
||||
<Tab key={tab.key}>{tab.label}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{tabConfig.map((tab) => (
|
||||
<TabPanel key={tab.key} px={0}>
|
||||
{loading ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="lg" />
|
||||
<Text color="gray.500">加载中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : invoices.length === 0 ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Icon as={FileText} boxSize={12} color="gray.300" />
|
||||
<Text color="gray.500">暂无发票记录</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<Icon as={Plus} />}
|
||||
onClick={onApplyOpen}
|
||||
>
|
||||
申请开票
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{invoices.map((invoice) => (
|
||||
<InvoiceCard
|
||||
key={invoice.id}
|
||||
invoice={invoice}
|
||||
onDownload={() => handleDownload(invoice)}
|
||||
onCancel={() => startCancel(invoice.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* 申请开票弹窗 */}
|
||||
<InvoiceApplyModal
|
||||
isOpen={isApplyOpen}
|
||||
onClose={onApplyClose}
|
||||
onSuccess={handleApplySuccess}
|
||||
/>
|
||||
|
||||
{/* 取消确认对话框 */}
|
||||
<AlertDialog
|
||||
isOpen={isCancelOpen}
|
||||
leastDestructiveRef={cancelDialogRef}
|
||||
onClose={onCancelClose}
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
取消开票申请
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>确定要取消这个开票申请吗?取消后可重新申请。</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelDialogRef} onClick={onCancelClose}>
|
||||
返回
|
||||
</Button>
|
||||
<Button colorScheme="red" onClick={handleCancel} ml={3}>
|
||||
确认取消
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
export const pricing = [
|
||||
{
|
||||
title: "STARTER",
|
||||
price: 99,
|
||||
features: [
|
||||
"1 Active Bot",
|
||||
"1,000 Conversations per month",
|
||||
"Web & WhatsApp Integration",
|
||||
"Basic Dashboard & Chat Reports",
|
||||
"Email Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "PRO",
|
||||
price: 149,
|
||||
features: [
|
||||
"Up to 5 Active Bots",
|
||||
"10,000 Conversations per month",
|
||||
"Multi-Channel (Web, WhatsApp, IG, Telegram)",
|
||||
"Custom Workflows & Automation",
|
||||
"Real-Time Reports & Zapier Integration",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "ENTERPRISE",
|
||||
price: 199,
|
||||
features: [
|
||||
"Unlimited Bots & Chats",
|
||||
"Role-Based Access & Team Management",
|
||||
"Integration to CRM & Custom APIs",
|
||||
"Advanced AI Training (LLM/NLP)",
|
||||
"Dedicated Onboarding Team",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,129 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
import { pricing } from "./content";
|
||||
|
||||
const Pricing = () => (
|
||||
<div
|
||||
id="pricing"
|
||||
className="pt-34.5 pb-25 max-2xl:pt-25 max-lg:py-20 max-md:py-15"
|
||||
>
|
||||
<div className="center">
|
||||
<motion.div
|
||||
className="max-w-175 mx-auto mb-17.5 text-center max-xl:mb-14 max-md:mb-8"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.7 }}
|
||||
viewport={{ amount: 0.7 }}
|
||||
>
|
||||
<div className="label mb-3 max-md:mb-1.5">Pricing</div>
|
||||
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:text-title-1-mobile">
|
||||
Start Automation Today
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="flex gap-4 max-lg:-mx-10 max-lg:px-10 max-lg:overflow-x-auto max-lg:scrollbar-none max-md:-mx-5 max-md:px-5"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.7 }}
|
||||
viewport={{ amount: 0.35 }}
|
||||
>
|
||||
{pricing.map((item, index) => (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 rounded-[1.25rem] overflow-hidden after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84 ${
|
||||
item.title === "PRO"
|
||||
? "shadow-2 before:absolute before:-top-20 before:left-1/2 before:z-1 before:-translate-x-1/2 before:w-65 before:h-57 before:bg-green/10 before:rounded-full before:blur-[3.375rem]"
|
||||
: "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset]"
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
{item.title === "PRO" && (
|
||||
<div className="absolute -top-36 left-13 w-105 mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
|
||||
<video
|
||||
className="w-full"
|
||||
src="/videos/video-1.mp4"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 ${
|
||||
item.title === "PRO"
|
||||
? "bg-[#175673]/20 rounded-t-[1.25rem] text-green"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none ${
|
||||
item.title === "PRO"
|
||||
? "backdrop-blur-[2rem] shadow-2 bg-white/7"
|
||||
: "backdrop-blur-[1.25rem] bg-white/1"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative mb-8 p-5 rounded-[0.8125rem] backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none ${
|
||||
item.title === "PRO"
|
||||
? "bg-line"
|
||||
: "bg-white/2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-end gap-3 mb-4">
|
||||
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">
|
||||
${item.price}
|
||||
</div>
|
||||
<div className="text-title-5">/Month</div>
|
||||
</div>
|
||||
<Button
|
||||
className={`w-full bg-line ${
|
||||
item.title !== "PRO"
|
||||
? "!text-description hover:!text-white"
|
||||
: ""
|
||||
}`}
|
||||
isPrimary={item.title === "PRO"}
|
||||
isSecondary={item.title !== "PRO"}
|
||||
>
|
||||
{item.title === "STARTER"
|
||||
? "Start with Beginner"
|
||||
: item.title === "PRO"
|
||||
? "Choose Pro Plan"
|
||||
: "Contact for Enterprise"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
|
||||
{item.features.map((feature, index) => (
|
||||
<div
|
||||
className="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile"
|
||||
key={index}
|
||||
>
|
||||
<div className="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg
|
||||
className="size-5 fill-black"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="mt-13.5 text-center max-md:mt-8 max-md:text-title-3-mobile">
|
||||
Free 7 Day Trial
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
@@ -31,9 +31,8 @@ import {
|
||||
PinInput,
|
||||
PinInputField
|
||||
} from '@chakra-ui/react';
|
||||
import { Link2, Trash2, Pencil, Smartphone, Mail, FileText, CreditCard } from 'lucide-react';
|
||||
import { Link2, Trash2, Pencil, Smartphone, Mail } 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';
|
||||
@@ -42,7 +41,6 @@ import { useProfileEvents } from '../../hooks/useProfileEvents';
|
||||
export default function SettingsPage() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 深色模式固定颜色(Settings 页面始终使用深色主题)
|
||||
const headingColor = 'white';
|
||||
@@ -224,7 +222,6 @@ export default function SettingsPage() {
|
||||
<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>
|
||||
@@ -406,71 +403,6 @@ export default function SettingsPage() {
|
||||
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user