Compare commits

..

1 Commits

Author SHA1 Message Date
zdl
c44389f4fe feat(invoice): 实现发票申请与管理功能
新增功能:
- 发票管理页面:支持查看发票列表、统计、Tab筛选
- 发票申请流程:支持电子/纸质发票、个人/企业抬头
- 发票状态追踪:待处理、处理中、已完成、已取消
- 发票抬头模板:支持保存和复用常用抬头
- 发票下载:已完成的电子发票可下载

组件架构:
- InvoiceCard: 发票卡片展示(React.memo优化)
- InvoiceApplyForm: 开票申请表单
- InvoiceApplyModal: 申请弹窗
- InvoiceStatusBadge: 状态徽章
- InvoiceTitleSelector: 抬头选择器
- InvoiceTypeSelector: 发票类型选择

入口集成:
- 设置页添加"账单与发票"Tab
- Billing页面添加发票管理入口
- 订阅支付成功后提示开票

Mock数据:
- 多用户发票数据(不同状态)
- 可开票订单
- 抬头模板

性能优化:
- InvoiceCard 使用 React.memo 避免不必要重渲染
- useColorModeValue 移到组件顶层调用
- loadInvoices/loadStats 使用 useCallback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-26 12:49:51 +08:00
85 changed files with 6406 additions and 3138 deletions

View File

@@ -0,0 +1,472 @@
/**
* 发票申请表单组件
*/
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>
);
}

View File

@@ -0,0 +1,222 @@
/**
* 发票申请弹窗
*/
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>
);
}

View File

@@ -0,0 +1,175 @@
/**
* 发票卡片组件
* 用于展示发票信息
*/
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);

View File

@@ -0,0 +1,37 @@
/**
* 发票状态徽章组件
*/
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>
);
}

View File

@@ -0,0 +1,179 @@
/**
* 发票抬头选择器
* 支持选择常用抬头或新增抬头
*/
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>
);
}

View File

@@ -0,0 +1,95 @@
/**
* 发票类型选择器
* 支持选择电子发票或纸质发票
*/
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>
);
}

View File

@@ -0,0 +1,10 @@
/**
* 发票组件统一导出
*/
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';

View File

@@ -9,7 +9,6 @@ import {
MenuItem,
MenuDivider,
Box,
Flex,
Text,
Badge,
useColorModeValue

View File

@@ -50,14 +50,6 @@ export interface SubTabConfig {
fallback?: React.ReactNode;
}
/**
* Tab 分组配置
*/
export interface SubTabGroup {
name: string;
tabs: SubTabConfig[];
}
/**
* 深空 FUI 主题配置
*/
@@ -137,10 +129,8 @@ const THEME_PRESETS: Record<string, SubTabTheme> = {
};
export interface SubTabContainerProps {
/** Tab 配置数组(与 groups 二选一) */
tabs?: SubTabConfig[];
/** Tab 分组配置(与 tabs 二选一) */
groups?: SubTabGroup[];
/** Tab 配置数组 */
tabs: SubTabConfig[];
/** 传递给 Tab 内容组件的 props */
componentProps?: Record<string, any>;
/** 默认选中的 Tab 索引 */
@@ -166,8 +156,7 @@ export interface SubTabContainerProps {
}
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
tabs: tabsProp,
groups,
tabs,
componentProps = {},
defaultIndex = 0,
index: controlledIndex,
@@ -182,21 +171,6 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
}) => {
// 获取尺寸配置
const sizeConfig = SIZE_CONFIG[size];
// 将分组展平为 tabs 数组,同时保留分组信息用于渲染分隔符
const { tabs, groupBoundaries } = React.useMemo(() => {
if (groups && groups.length > 0) {
const flatTabs: SubTabConfig[] = [];
const boundaries: number[] = []; // 记录每个分组的起始索引
groups.forEach((group) => {
boundaries.push(flatTabs.length);
flatTabs.push(...group.tabs);
});
return { tabs: flatTabs, groupBoundaries: boundaries };
}
return { tabs: tabsProp || [], groupBoundaries: [] };
}, [groups, tabsProp]);
// 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex);
@@ -306,90 +280,64 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
>
{tabs.map((tab, idx) => {
const isSelected = idx === currentIndex;
// 检查是否需要在此 Tab 前显示分组标签
const groupIndex = groupBoundaries.indexOf(idx);
const showGroupLabel = groups && groupIndex !== -1;
return (
<React.Fragment key={tab.key}>
{/* 分组标签 */}
{showGroupLabel && (
<HStack
spacing={2}
flexShrink={0}
pl={groupIndex > 0 ? 3 : 0}
pr={2}
borderLeft={groupIndex > 0 ? '1px solid' : 'none'}
borderColor={DEEP_SPACE.borderGold}
ml={groupIndex > 0 ? 2 : 0}
>
<Text
fontSize="xs"
color={DEEP_SPACE.textMuted}
fontWeight="500"
whiteSpace="nowrap"
letterSpacing="0.05em"
>
{groups[groupIndex].name}
</Text>
</HStack>
)}
<Tab
color={theme.tabUnselectedColor}
borderRadius={DEEP_SPACE.radius}
px={sizeConfig.px}
py={sizeConfig.py}
fontSize={sizeConfig.fontSize}
fontWeight="500"
whiteSpace="nowrap"
flexShrink={0}
border="1px solid transparent"
position="relative"
letterSpacing="0.03em"
transition={DEEP_SPACE.transition}
_before={{
content: '""',
position: 'absolute',
bottom: '-1px',
left: '50%',
transform: 'translateX(-50%)',
width: isSelected ? '70%' : '0%',
height: '2px',
bg: '#D4AF37',
borderRadius: 'full',
transition: 'width 0.3s ease',
boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none',
}}
_selected={{
bg: theme.tabSelectedBg,
color: theme.tabSelectedColor,
fontWeight: '700',
boxShadow: DEEP_SPACE.glowGold,
border: `1px solid ${DEEP_SPACE.borderGoldHover}`,
transform: 'translateY(-2px)',
}}
_hover={{
bg: isSelected ? undefined : theme.tabHoverBg,
border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`,
transform: 'translateY(-1px)',
}}
_active={{
transform: 'translateY(0)',
}}
>
<HStack spacing={size === 'sm' ? 1.5 : 2}>
{tab.icon && (
<Icon
as={tab.icon}
boxSize={sizeConfig.iconSize}
opacity={isSelected ? 1 : 0.7}
transition="opacity 0.2s"
/>
)}
<Text>{tab.name}</Text>
</HStack>
</Tab>
</React.Fragment>
<Tab
key={tab.key}
color={theme.tabUnselectedColor}
borderRadius={DEEP_SPACE.radius}
px={sizeConfig.px}
py={sizeConfig.py}
fontSize={sizeConfig.fontSize}
fontWeight="500"
whiteSpace="nowrap"
flexShrink={0}
border="1px solid transparent"
position="relative"
letterSpacing="0.03em"
transition={DEEP_SPACE.transition}
_before={{
content: '""',
position: 'absolute',
bottom: '-1px',
left: '50%',
transform: 'translateX(-50%)',
width: isSelected ? '70%' : '0%',
height: '2px',
bg: '#D4AF37',
borderRadius: 'full',
transition: 'width 0.3s ease',
boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none',
}}
_selected={{
bg: theme.tabSelectedBg,
color: theme.tabSelectedColor,
fontWeight: '700',
boxShadow: DEEP_SPACE.glowGold,
border: `1px solid ${DEEP_SPACE.borderGoldHover}`,
transform: 'translateY(-2px)',
}}
_hover={{
bg: isSelected ? undefined : theme.tabHoverBg,
border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`,
transform: 'translateY(-1px)',
}}
_active={{
transform: 'translateY(0)',
}}
>
<HStack spacing={size === 'sm' ? 1.5 : 2}>
{tab.icon && (
<Icon
as={tab.icon}
boxSize={sizeConfig.iconSize}
opacity={isSelected ? 1 : 0.7}
transition="opacity 0.2s"
/>
)}
<Text>{tab.name}</Text>
</HStack>
</Tab>
);
})}
</TabList>

View File

@@ -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: 5000,
duration: 6000,
isClosable: true,
});
@@ -683,9 +683,9 @@ export default function SubscriptionContentNew() {
toast({
title: '支付成功!',
description: '您的订阅已激活',
description: '您的订阅已激活。如需发票,请前往「发票管理」申请',
status: 'success',
duration: 5000,
duration: 6000,
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: 5000,
duration: 6000,
isClosable: true,
});

View File

@@ -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,105 +843,53 @@ export const PINGAN_BANK_DATA = {
// 关键因素时间线 - 结构与组件期望格式匹配
keyFactorsTimeline: {
key_factors: {
total_factors: 8,
total_factors: 5,
categories: [
{
category_name: '财务指标',
category_name: '正面因素',
category_type: 'positive',
factors: [
{
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: 9.2,
description: '零售业务收入占比持续提升已超过50%客户基础和AUM稳步增长',
trend: 'improving'
},
{
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.8,
description: 'AI、大数据等技术应用深化智能化转型成效显著',
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'
factor_name: '资产质量稳定',
impact_score: 8.5,
description: '不良贷款率控制在较低水平,风险抵御能力强',
trend: 'stable'
}
]
},
{
category_name: '业务发展',
category_name: '负面因素',
category_type: 'negative',
factors: [
{
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'
factor_name: '息差压力',
impact_score: 6.5,
description: '利率市场化持续推进,净息差面临收窄压力',
trend: 'declining'
}
]
},
{
category_name: '风险因素',
category_name: '中性因素',
category_type: 'neutral',
factors: [
{
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'
factor_name: '监管趋严',
impact_score: 7.0,
description: '金融监管持续强化,合规成本有所上升',
trend: 'stable'
}
]
}
@@ -1474,80 +1422,41 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
},
keyFactorsTimeline: {
key_factors: {
total_factors: 6,
total_factors: 3,
categories: [
{
category_name: '财务指标',
category_name: '正面因素',
category_type: 'positive',
factors: [
{
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'
factor_name: '业绩增长',
impact_score: 8.5,
description: '营收和利润保持稳定增长态势',
trend: 'improving'
}
]
},
{
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: '风险因素',
category_name: '负面因素',
category_type: 'negative',
factors: [
{
factor_name: '原材料成本',
factor_value: 28.5,
factor_unit: '%',
factor_desc: '原材料成本占比上升,需关注价格波动影响',
impact_direction: 'negative',
impact_weight: 70,
year_on_year: 3.2,
report_period: '2024Q3'
},
impact_score: 6.0,
description: '原材料价格波动影响毛利率',
trend: 'declining'
}
]
},
{
category_name: '中性因素',
category_type: 'neutral',
factors: [
{
factor_name: '应收账款周转',
factor_value: 85,
factor_unit: '天',
factor_desc: '应收账款周转天数有所增加,需加强回款管理',
impact_direction: 'negative',
impact_weight: 65,
year_on_year: 8,
report_period: '2024Q3'
factor_name: '市场竞争',
impact_score: 7.0,
description: '行业竞争加剧,需持续提升竞争力',
trend: 'stable'
}
]
}
@@ -1556,50 +1465,14 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
development_timeline: {
statistics: {
positive_events: 4,
negative_events: 1,
neutral_events: 1
negative_events: 0,
neutral_events: 0
},
events: [
{
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%' }
}
{ 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' }
]
}
},

View File

@@ -155,13 +155,13 @@ export const generateFinancialData = (stockCode) => {
asset_disposal_income: 340 - i * 10
},
profit: {
operating_profit: (68450 - i * 1500) * 10000,
total_profit: (69500 - i * 1500) * 10000,
income_tax_expense: (16640 - i * 300) * 10000,
net_profit: (52860 - i * 1200) * 10000,
parent_net_profit: (51200 - i * 1150) * 10000,
minority_profit: (1660 - i * 50) * 10000,
continuing_operations_net_profit: (52860 - i * 1200) * 10000,
operating_profit: 68450 - i * 1500,
total_profit: 69500 - i * 1500,
income_tax_expense: 16640 - i * 300,
net_profit: 52860 - i * 1200,
parent_net_profit: 51200 - i * 1150,
minority_profit: 1660 - i * 50,
continuing_operations_net_profit: 52860 - i * 1200,
discontinued_operations_net_profit: 0
},
non_operating: {
@@ -272,19 +272,16 @@ export const generateFinancialData = (stockCode) => {
}
})),
// 主营业务 - 按产品/业务分类6个业务
// 主营业务 - 按产品/业务分类
mainBusiness: {
product_classification: [
{
period: '2024-09-30',
report_type: '2024年三季报',
products: [
{ content: '零售业务', revenue: 56822500000, gross_margin: 68.5, profit_margin: 42.3, profit: 24035877500 },
{ content: '金融务', revenue: 32470000000, gross_margin: 62.8, profit_margin: 38.6, profit: 12533420000 },
{ content: '对冲投资', revenue: 24352500000, gross_margin: 75.2, profit_margin: 52.1, profit: 12687652500 },
{ content: '云服务', revenue: 19482000000, gross_margin: 72.0, profit_margin: 48.5, profit: 9448770000 },
{ content: '物流网络', revenue: 16235000000, gross_margin: 58.5, profit_margin: 32.8, profit: 5325080000 },
{ content: '其他业务', revenue: 12988000000, gross_margin: 55.2, profit_margin: 28.6, profit: 3714568000 },
{ content: '零售金融业务', revenue: 81320000000, gross_margin: 68.5, profit_margin: 42.3, profit: 34398160000 },
{ content: '对公金融务', revenue: 68540000000, gross_margin: 62.8, profit_margin: 38.6, profit: 26456440000 },
{ content: '金融市场业务', revenue: 12490000000, gross_margin: 75.2, profit_margin: 52.1, profit: 6507290000 },
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
]
},
@@ -292,12 +289,9 @@ export const generateFinancialData = (stockCode) => {
period: '2024-06-30',
report_type: '2024年中报',
products: [
{ content: '零售业务', revenue: 54880000000, gross_margin: 67.8, profit_margin: 41.5, profit: 22775200000 },
{ content: '金融务', revenue: 31360000000, gross_margin: 61.9, profit_margin: 37.8, profit: 11854080000 },
{ content: '对冲投资', revenue: 23520000000, gross_margin: 74.5, profit_margin: 51.2, profit: 12042240000 },
{ content: '云服务', revenue: 18816000000, gross_margin: 71.2, profit_margin: 47.8, profit: 8994048000 },
{ content: '物流网络', revenue: 15680000000, gross_margin: 57.8, profit_margin: 32.1, profit: 5033280000 },
{ content: '其他业务', revenue: 12544000000, gross_margin: 54.5, profit_margin: 28.0, profit: 3512320000 },
{ content: '零售金融业务', revenue: 78650000000, gross_margin: 67.8, profit_margin: 41.5, profit: 32639750000 },
{ content: '对公金融务', revenue: 66280000000, gross_margin: 61.9, profit_margin: 37.8, profit: 25053840000 },
{ content: '金融市场业务', revenue: 11870000000, gross_margin: 74.5, profit_margin: 51.2, profit: 6077440000 },
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
]
},
@@ -305,12 +299,9 @@ export const generateFinancialData = (stockCode) => {
period: '2024-03-31',
report_type: '2024年一季报',
products: [
{ content: '零售业务', revenue: 27090000000, gross_margin: 67.2, profit_margin: 40.8, profit: 11052720000 },
{ content: '金融务', revenue: 15480000000, gross_margin: 61.2, profit_margin: 37.1, profit: 5743080000 },
{ content: '对冲投资', revenue: 11610000000, gross_margin: 73.8, profit_margin: 50.5, profit: 5863050000 },
{ content: '云服务', revenue: 9288000000, gross_margin: 70.5, profit_margin: 47.0, profit: 4365360000 },
{ content: '物流网络', revenue: 7740000000, gross_margin: 57.0, profit_margin: 31.5, profit: 2438100000 },
{ content: '其他业务', revenue: 6192000000, gross_margin: 53.8, profit_margin: 27.2, profit: 1684224000 },
{ content: '零售金融业务', revenue: 38920000000, gross_margin: 67.2, profit_margin: 40.8, profit: 15879360000 },
{ content: '对公金融务', revenue: 32650000000, gross_margin: 61.2, profit_margin: 37.1, profit: 12113150000 },
{ content: '金融市场业务', revenue: 5830000000, gross_margin: 73.8, profit_margin: 50.5, profit: 2944150000 },
{ content: '合计', revenue: 77400000000, gross_margin: 66.1, profit_margin: 39.8, profit: 30805200000 },
]
},
@@ -318,12 +309,9 @@ export const generateFinancialData = (stockCode) => {
period: '2023-12-31',
report_type: '2023年年报',
products: [
{ content: '零售业务', revenue: 106400000000, gross_margin: 66.5, profit_margin: 40.2, profit: 42772800000 },
{ content: '金融务', revenue: 60800000000, gross_margin: 60.5, profit_margin: 36.5, profit: 22192000000 },
{ content: '对冲投资', revenue: 45600000000, gross_margin: 73.2, profit_margin: 49.8, profit: 22708800000 },
{ content: '云服务', revenue: 36480000000, gross_margin: 69.8, profit_margin: 46.2, profit: 16853760000 },
{ content: '物流网络', revenue: 30400000000, gross_margin: 56.2, profit_margin: 30.8, profit: 9363200000 },
{ content: '其他业务', revenue: 24320000000, gross_margin: 53.0, profit_margin: 26.5, profit: 6444800000 },
{ content: '零售金融业务', revenue: 152680000000, gross_margin: 66.5, profit_margin: 40.2, profit: 61377360000 },
{ content: '对公金融务', revenue: 128450000000, gross_margin: 60.5, profit_margin: 36.5, profit: 46884250000 },
{ content: '金融市场业务', revenue: 22870000000, gross_margin: 73.2, profit_margin: 49.8, profit: 11389260000 },
{ content: '合计', revenue: 304000000000, gross_margin: 65.2, profit_margin: 39.2, profit: 119168000000 },
]
},

View File

@@ -170,84 +170,22 @@ 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 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 = ['好', '中', '差'];
const isLimitUp = Math.random() < 0.05; // 5%概率涨停
return {
stock_code: stockCode,
stock_name: stockInfo.name,
trade_date: tradeDate,
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'
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 ? '股价触及涨停板,资金流入明显' : '股价正常波动,交投活跃'
};
})
},

View File

@@ -19,6 +19,7 @@ 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';
@@ -42,5 +43,6 @@ export const handlers = [
...bytedeskHandlers, // ⚡ Bytedesk 客服 Widget passthrough
...predictionHandlers, // 预测市场
...forumHandlers, // 价值论坛帖子 (ES)
...invoiceHandlers, // 发票管理
// ...userHandlers,
];

View File

@@ -0,0 +1,920 @@
// 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,
});
}),
];

View File

@@ -69,6 +69,17 @@ 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',

View File

@@ -17,6 +17,7 @@ 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')),
@@ -61,6 +62,7 @@ export const {
ForumMyPoints,
SettingsPage,
Subscription,
Invoice,
PrivacyPolicy,
UserAgreement,
WechatCallback,

View File

@@ -0,0 +1,251 @@
/**
* 发票服务
* 处理发票申请、查询、下载等操作
*/
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();
};

124
src/types/invoice.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* 发票相关类型定义
*/
/** 发票类型 */
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; // 已完成
}

View File

@@ -0,0 +1,117 @@
/*!
=========================================================
* 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;

View File

@@ -0,0 +1,109 @@
/*!
=========================================================
* 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;

View File

@@ -0,0 +1,81 @@
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;

View File

@@ -0,0 +1,125 @@
/*!
=========================================================
* 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;

View File

@@ -0,0 +1,109 @@
/*!
=========================================================
* 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;

View File

@@ -0,0 +1,81 @@
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;

View File

@@ -0,0 +1,116 @@
/*!
=========================================================
* 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;

View File

@@ -0,0 +1,104 @@
/*!
=========================================================
* 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;

View File

@@ -0,0 +1,93 @@
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;

View File

@@ -0,0 +1,31 @@
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;

View File

@@ -0,0 +1,31 @@
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

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx
// 分支机构 Tab Panel - 黑金风格
import React, { memo, useMemo } from "react";
import React from "react";
import {
Box,
VStack,
@@ -11,13 +11,7 @@ import {
SimpleGrid,
Center,
} from "@chakra-ui/react";
import {
GitBranch,
Building2,
CheckCircle,
XCircle,
HelpCircle,
} from "lucide-react";
import { GitBranch, Building2, CheckCircle, XCircle } from "lucide-react";
import { useBranchesData } from "../../hooks/useBranchesData";
import { THEME } from "../config";
@@ -30,42 +24,23 @@ interface BranchesPanelProps {
isActive?: boolean;
}
// 状态分类关键词
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 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 STATUS_BADGE_BASE = {
// 状态徽章样式
const getStatusBadgeStyles = (isActive: boolean) => ({
display: "inline-flex",
alignItems: "center",
gap: "4px",
@@ -74,38 +49,14 @@ const STATUS_BADGE_BASE = {
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",
} as const;
borderColor: isActive ? "rgba(212, 175, 55, 0.3)" : "rgba(255, 100, 100, 0.3)",
});
// 预计算各状态的完整徽章样式,避免每次渲染创建新对象
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 }) => (
// 信息项组件
const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
<VStack align="start" spacing={0.5}>
<Text fontSize="xs" color={THEME.textSecondary} letterSpacing="0.5px">
{label}
@@ -114,127 +65,106 @@ const InfoItem = memo<InfoItemProps>(({ label, value }) => (
{value || "-"}
</Text>
</VStack>
));
InfoItem.displayName = "BranchInfoItem";
// 空状态组件 - 独立 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>
));
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 (
<Box sx={THEME.card}>
{/* 顶部金色装饰线 */}
<Box h="2px" bgGradient={THEME.gradients.decorLine} />
<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 sx={STATUS_BADGE_STYLES[statusType]}>
<Icon as={StatusIcon} boxSize={3} />
<Text>{branch.business_status || "未知"}</Text>
</Box>
</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";
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode, isActive = true }) => {
const { branches, loading } = useBranchesData({ stockCode, enabled: isActive });
if (loading) {
return <BranchesSkeleton />;
}
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>
);
}
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{branches.map((branch: any, idx: number) => {
const isActive = branch.business_status === "存续";
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="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>
</Box>
);
})}
</SimpleGrid>
);
};
export default BranchesPanel;

View File

@@ -44,7 +44,6 @@ const ManagementCard: React.FC<ManagementCardProps> = ({ person, categoryColor }
name={person.name}
size="md"
bg={categoryColor}
color="black"
/>
<VStack align="start" spacing={1} flex={1}>
{/* 姓名和性别 */}

View File

@@ -3,28 +3,6 @@
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;
@@ -43,23 +21,6 @@ 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;
}
// 黑金主题配置
@@ -69,62 +30,17 @@ 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", // 未选中使用亮金色
},
// 状态颜色配置(用于分支机构等状态显示)
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)",
},
color: "#F4D03F", // 未选中使用亮金色
},
};

View File

@@ -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="white">
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
: {formatPercentage(business.financial_metrics?.revenue_ratio)}
</Tag>
<Tag size="sm" bg="gray.600" color="white">
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
: {formatPercentage(business.financial_metrics?.gross_margin)}
</Tag>
{business.growth_metrics?.revenue_growth !== undefined && (

View File

@@ -5,23 +5,20 @@
* 使用位置:竞争力分析区域(共 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);
@@ -30,8 +27,10 @@ const ScoreBar: React.FC<ScoreBarProps> = ({ label, score, icon }) => {
<Box>
<HStack justify="space-between" mb={1}>
<HStack>
{icon && <Icon as={icon} boxSize={4} color={`${colorScheme}.400`} />}
<Text fontSize="sm" fontWeight="medium" color={THEME_GOLD}>
{icon && (
<Icon as={icon} boxSize={4} color={`${colorScheme}.500`} />
)}
<Text fontSize="sm" fontWeight="medium">
{label}
</Text>
</HStack>

View File

@@ -29,9 +29,8 @@ const THEME = {
innerCardBg: 'gray.700',
gold: '#F4D03F',
goldLight: '#F0D78C',
textTitle: '#F4D03F', // 标题用金色
textContent: 'white', // 内容用白色,提高辨识度
textSecondary: 'gray.400', // 小标题用灰色
textPrimary: '#F4D03F',
textSecondary: 'gray.400',
border: 'rgba(212, 175, 55, 0.3)',
};
@@ -54,7 +53,7 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
<CardHeader>
<HStack>
<Icon as={Factory} color={THEME.gold} />
<Heading size="sm" color={THEME.textTitle}></Heading>
<Heading size="sm" color={THEME.textPrimary}></Heading>
<Badge bg={THEME.gold} color="gray.900">{businessSegments.length} </Badge>
</HStack>
</CardHeader>
@@ -68,7 +67,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.textTitle}>
<Text fontWeight="bold" fontSize="md" color={THEME.textPrimary}>
{segment.segment_name}
</Text>
<Button
@@ -86,12 +85,12 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
</HStack>
<Box>
<Text fontSize="xs" color={THEME.gold} fontWeight="bold" mb={1}>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
color={THEME.textContent}
color={THEME.textPrimary}
noOfLines={isExpanded ? undefined : 3}
>
{segment.segment_description || '暂无描述'}
@@ -99,12 +98,12 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
</Box>
<Box>
<Text fontSize="xs" color={THEME.gold} fontWeight="bold" mb={1}>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
color={THEME.textContent}
color={THEME.textPrimary}
noOfLines={isExpanded ? undefined : 2}
>
{segment.competitive_position || '暂无数据'}
@@ -112,13 +111,13 @@ const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
</Box>
<Box>
<Text fontSize="xs" color={THEME.gold} fontWeight="bold" mb={1}>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
noOfLines={isExpanded ? undefined : 2}
color={THEME.textContent}
color={THEME.goldLight}
>
{segment.future_potential || '暂无数据'}
</Text>

View File

@@ -5,7 +5,7 @@
* 包含行业排名弹窗功能
*/
import React, { memo, useMemo } from "react";
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
@@ -28,12 +28,10 @@ import {
ModalOverlay,
ModalContent,
ModalHeader,
UnorderedList,
ListItem,
ModalBody,
ModalCloseButton,
useDisclosure,
} from "@chakra-ui/react";
} from '@chakra-ui/react';
import {
Trophy,
Settings,
@@ -45,51 +43,47 @@ 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;
@@ -124,11 +118,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 }) => (
@@ -144,52 +138,7 @@ const ScoreSection = memo<ScoreSectionProps>(({ scores }) => (
</VStack>
));
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";
ScoreSection.displayName = 'ScoreSection';
// 竞争优劣势组件
interface AdvantagesSectionProps {
@@ -200,13 +149,27 @@ interface AdvantagesSectionProps {
const AdvantagesSection = memo<AdvantagesSectionProps>(
({ advantages, disadvantages }) => (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<AdvantageList title="竞争优势" content={advantages} color="green.400" />
<AdvantageList title="竞争劣势" content={disadvantages} color="red.400" />
<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>
</SimpleGrid>
)
);
AdvantagesSection.displayName = "AdvantagesSection";
AdvantagesSection.displayName = 'AdvantagesSection';
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
({ comprehensiveData, industryRankData }) => {
@@ -216,15 +179,16 @@ 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]
);
@@ -238,9 +202,7 @@ 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}
@@ -248,13 +210,9 @@ 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}
@@ -267,7 +225,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>
@@ -276,9 +234,7 @@ 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}>
@@ -302,20 +258,13 @@ 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}>
@@ -341,6 +290,6 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
}
);
CompetitiveAnalysisCard.displayName = "CompetitiveAnalysisCard";
CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard';
export default CompetitiveAnalysisCard;

View File

@@ -4,7 +4,7 @@
* 显示公司战略方向和战略举措
*/
import React, { memo, useMemo } from "react";
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
@@ -18,29 +18,27 @@ import {
Grid,
GridItem,
Center,
UnorderedList,
ListItem,
} from "@chakra-ui/react";
import { Rocket, BarChart2 } from "lucide-react";
import type { Strategy } from "../types";
} 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;
@@ -66,17 +64,7 @@ const EmptyState = memo(() => (
</Box>
));
EmptyState.displayName = "StrategyEmptyState";
// 将文本按分号拆分为列表项
const parseToList = (text: string): string[] => {
if (!text) return [];
// 按中英文分号拆分,过滤空项
return text
.split(/[;]/)
.map((s) => s.trim())
.filter(Boolean);
};
EmptyState.displayName = 'StrategyEmptyState';
// 内容项组件 - 复用结构
interface ContentItemProps {
@@ -84,40 +72,24 @@ interface ContentItemProps {
content: string;
}
const ContentItem = memo<ContentItemProps>(({ title, content }) => {
// 缓存解析结果,避免每次渲染重新计算
const items = useMemo(() => parseToList(content), [content]);
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>
));
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";
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]
);
@@ -126,9 +98,7 @@ 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>
@@ -140,13 +110,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>
@@ -158,6 +128,6 @@ const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
}
);
StrategyAnalysisCard.displayName = "StrategyAnalysisCard";
StrategyAnalysisCard.displayName = 'StrategyAnalysisCard';
export default StrategyAnalysisCard;

View File

@@ -131,16 +131,16 @@ const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
return (
<Card bg={THEME.cardBg} shadow="md">
{/* 头部区域 */}
<CardHeader py={3}>
<HStack flexWrap="wrap" spacing={3}>
<Icon as={Network} color={THEME.gold} boxSize={5} />
<CardHeader py={0}>
<HStack flexWrap="wrap" gap={0}>
<Icon as={Network} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}>
</Heading>
<Text color={THEME.textSecondary} fontSize="sm">
{companyName}
| {companyName}
</Text>
<Badge bg={THEME.gold} color="gray.900" px={2} py={1}>
<Badge bg={THEME.gold} color="gray.900">
{totalNodes}
</Badge>
</HStack>

View File

@@ -246,7 +246,7 @@ const RelatedCompaniesModal: React.FC<RelatedCompaniesModalProps> = ({
variant="ghost"
colorScheme="blue"
onClick={() => {
window.open(`/company?scode=${company.stock_code}`, '_blank');
window.location.href = `/company?stock_code=${company.stock_code}`;
}}
aria-label="查看公司详情"
/>

View File

@@ -8,10 +8,7 @@
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;
}
@@ -189,7 +186,7 @@ export interface ValueChainData {
// ==================== 相关公司类型 ====================
export interface RelatedCompanyRelationship {
role: "source" | "target";
role: 'source' | 'target';
connected_node: string;
}
@@ -208,7 +205,7 @@ export interface RelatedCompany {
// ==================== 关键因素类型 ====================
export type ImpactDirection = "positive" | "negative" | "neutral" | "mixed";
export type ImpactDirection = 'positive' | 'negative' | 'neutral' | 'mixed';
export interface KeyFactor {
factor_name: string;
@@ -300,11 +297,7 @@ export interface IndustryRankData {
// ==================== 主组件 Props 类型 ====================
/** Tab 类型 */
export type DeepAnalysisTabKey =
| "strategy"
| "business"
| "valueChain"
| "development";
export type DeepAnalysisTabKey = 'strategy' | 'business' | 'valueChain' | 'development';
export interface DeepAnalysisTabProps {
comprehensiveData?: ComprehensiveData;
@@ -360,23 +353,12 @@ export interface RadarIndicator {
}
export interface RadarChartOption {
tooltip: {
trigger: string;
backgroundColor?: string;
borderColor?: string;
textStyle?: { color: string };
};
tooltip: { trigger: string };
radar: {
indicator: RadarIndicator[];
shape: string;
splitNumber: number;
name: {
textStyle: {
color: string;
fontSize: number;
fontWeight?: string;
};
};
name: { textStyle: { color: string; fontSize: number } };
splitLine: { lineStyle: { color: string[] } };
splitArea: { show: boolean; areaStyle: { color: string[] } };
axisLine: { lineStyle: { color: string } };
@@ -391,12 +373,7 @@ 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 };
}>;
}>;
}

View File

@@ -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,71 +44,40 @@ 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",
backgroundColor: "rgba(30, 30, 35, 0.95)",
borderColor: THEME.goldDim,
textStyle: { color: "#fff" },
},
tooltip: { trigger: 'item' },
radar: {
indicator: indicators,
shape: "polygon",
shape: 'polygon',
splitNumber: 4,
// 指标名称 - 使用金色确保在深色背景上清晰可见
name: {
textStyle: {
color: THEME.gold,
fontSize: 12,
fontWeight: "bold",
},
},
// 分割线 - 金色系
name: { textStyle: { color: '#666', fontSize: 12 } },
splitLine: {
lineStyle: {
color: [
THEME.goldDim,
"rgba(212, 175, 55, 0.2)",
"rgba(212, 175, 55, 0.15)",
"rgba(212, 175, 55, 0.1)",
],
},
lineStyle: { color: ['#e8e8e8', '#e0e0e0', '#d0d0d0', '#c0c0c0'] },
},
// 分割区域 - 深色透明
splitArea: {
show: true,
areaStyle: {
color: ["rgba(30, 30, 35, 0.6)", "rgba(40, 40, 45, 0.4)"],
color: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
},
},
// 轴线 - 金色
axisLine: { lineStyle: { color: THEME.goldDim } },
axisLine: { lineStyle: { color: '#ddd' } },
},
series: [
{
name: "竞争力评分",
type: "radar",
name: '竞争力评分',
type: 'radar',
data: [
{
value: data,
name: "当前评分",
symbol: "circle",
name: '当前评分',
symbol: 'circle',
symbolSize: 5,
lineStyle: { width: 2, color: THEME.blue },
areaStyle: { color: THEME.blueArea },
lineStyle: { width: 2, color: '#3182ce' },
areaStyle: { color: 'rgba(49, 130, 206, 0.3)' },
label: {
show: true,
formatter: (params: { value: number }) => params.value,
color: THEME.gold,
color: '#3182ce',
fontSize: 10,
},
},
@@ -148,22 +117,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 },
},
],
};

View File

@@ -4,7 +4,6 @@
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";
@@ -23,7 +22,7 @@ const TABLE_THEME = {
Table: {
headerBg: "#1A202C", // gray.900
headerColor: "#F4D03F", // 亮金色(提高对比度)
rowHoverBg: "rgba(156, 163, 175, 0.15)", // 浅灰色悬停背景
rowHoverBg: "rgba(212, 175, 55, 0.15)", // 金色半透明,文字更清晰
borderColor: "rgba(212, 175, 55, 0.2)",
},
},
@@ -134,13 +133,10 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
title: "股东名称",
dataIndex: "shareholder_name",
key: "name",
width: 200,
ellipsis: {
showTitle: false,
},
ellipsis: true,
render: (name: string) => (
<Tooltip title={name} placement="topLeft">
<span style={{ fontWeight: 500, color: "#F4D03F", display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>{name}</span>
<Tooltip title={name}>
<span style={{ fontWeight: 500, color: "#F4D03F" }}>{name}</span>
</Tooltip>
),
},
@@ -148,9 +144,10 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
title: "股东类型",
dataIndex: "shareholder_type",
key: "type",
width: 90,
responsive: ["md"],
render: (shareholderType: string) => (
<Tag color={getShareholderTypeColor(shareholderType)} style={{ whiteSpace: 'nowrap' }}>{shareholderType || "-"}</Tag>
<Tag color={getShareholderTypeColor(shareholderType)}>{shareholderType || "-"}</Tag>
),
},
{
@@ -161,7 +158,6 @@ 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>
),
@@ -177,7 +173,6 @@ 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" }}>
@@ -193,9 +188,10 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
title: "股份性质",
dataIndex: "share_nature",
key: "nature",
width: 80,
responsive: ["lg"],
render: (nature: string) => (
<Tag color="default" style={{ whiteSpace: 'nowrap' }}>{nature || "流通股"}</Tag>
<Tag color="default">{nature || "流通股"}</Tag>
),
});
}
@@ -215,15 +211,14 @@ 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} locale={zhCN}>
<ConfigProvider theme={TABLE_THEME}>
<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: 'max-content' }}
showSorterTooltip={{ title: '点击切换排序' }}
scroll={{ x: isMobile ? 400 : undefined }}
/>
</ConfigProvider>
</Box>

View File

@@ -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,9 +48,7 @@ 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);
@@ -93,9 +91,7 @@ export const useDeepAnalysisData = (
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) {
@@ -107,13 +103,13 @@ export const useDeepAnalysisData = (
);
/**
* 根据 Tab 加载对应数据(支持一个 Tab 对应多个 API
* 根据 Tab 加载对应数据
*/
const loadTabData = useCallback(
(tabKey: string) => {
const apiKeys = TAB_API_MAP[tabKey];
if (apiKeys && apiKeys.length > 0) {
apiKeys.forEach((apiKey) => loadApiData(apiKey));
const apiKey = TAB_API_MAP[tabKey];
if (apiKey) {
loadApiData(apiKey);
}
},
[loadApiData]
@@ -138,11 +134,8 @@ export const useDeepAnalysisData = (
if (stockCode) {
currentStockCodeRef.current = stockCode;
resetData();
// 加载默认 Tab (strategy) 所需的所有数据
const defaultTabApis = TAB_API_MAP["strategy"];
if (defaultTabApis) {
defaultTabApis.forEach((apiKey) => loadApiData(apiKey));
}
// 加载默认 Tabcomprehensive
loadApiData('comprehensive');
}
}, [stockCode, loadApiData, resetData]);

View File

@@ -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,12 +21,10 @@ 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);
@@ -34,7 +32,7 @@ const DeepAnalysis: React.FC<DeepAnalysisProps> = memo(({ stockCode }) => {
// stockCode 变更时重置 UI 状态
useEffect(() => {
if (stockCode) {
setActiveTab("strategy");
setActiveTab('strategy');
setExpandedSegments({});
}
}, [stockCode]);
@@ -56,11 +54,10 @@ const DeepAnalysis: React.FC<DeepAnalysisProps> = memo(({ stockCode }) => {
[loadTabData]
);
// 获取当前 Tab 的 loading 状态(任一相关 API loading 则显示 loading
// 获取当前 Tab 的 loading 状态
const currentLoading = (() => {
const apiKeys = TAB_API_MAP[activeTab];
if (!apiKeys || apiKeys.length === 0) return false;
return apiKeys.some((apiKey) => loading[apiKey]);
const apiKey = TAB_API_MAP[activeTab];
return apiKey ? loading[apiKey] : false;
})();
return (
@@ -79,6 +76,6 @@ const DeepAnalysis: React.FC<DeepAnalysisProps> = memo(({ stockCode }) => {
);
});
DeepAnalysis.displayName = "DeepAnalysis";
DeepAnalysis.displayName = 'DeepAnalysis';
export default DeepAnalysis;

View File

@@ -9,21 +9,17 @@ 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 映射(支持一个 Tab 对应多个 API */
export const TAB_API_MAP: Record<string, ApiKey[]> = {
strategy: ["comprehensive", "industryRank"], // 战略分析需要综合分析 + 行业排名
business: ["comprehensive"],
valueChain: ["valueChain"],
development: ["keyFactors"],
/** Tab 与 API 映射 */
export const TAB_API_MAP: Record<string, ApiKey> = {
strategy: 'comprehensive',
business: 'comprehensive',
valueChain: 'valueChain',
development: 'keyFactors',
} as const;
/** API 加载状态 */
@@ -73,4 +69,4 @@ import type {
ValueChainData,
KeyFactorsData,
IndustryRankData,
} from "../CompanyOverview/DeepAnalysisTab/types";
} from '../CompanyOverview/DeepAnalysisTab/types';

View File

@@ -2,6 +2,7 @@
// 新闻动态 Tab 组件 - 黑金主题
import React, { memo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { VStack, Card, CardBody } from '@chakra-ui/react';
import { getEventDetailUrl } from '@/utils/idEncoder';
import { THEME_PRESETS } from './constants';
@@ -51,18 +52,20 @@ const NewsEventsTab: React.FC<NewsEventsTabProps> = ({
cardBg,
themePreset = 'default',
}) => {
const navigate = useNavigate();
// 获取主题配色
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
const isBlackGold = themePreset === 'blackGold';
// 点击事件卡片,在新标签页打开详情页
// 点击事件卡片,跳转到详情页
const handleEventClick = useCallback(
(eventId: string | number | undefined) => {
if (eventId) {
window.open(getEventDetailUrl(eventId), '_blank');
navigate(getEventDetailUrl(eventId));
}
},
[]
[navigate]
);
// 处理搜索输入

View File

@@ -1,40 +1,23 @@
/**
* 财务全景面板组件 - 列布局
* 财务全景面板组件 - 列布局
* 复用 MarketDataView 的 MetricCard 组件
*/
import React, { memo, useMemo } from 'react';
import {
SimpleGrid,
HStack,
VStack,
Text,
Badge,
Box,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
useDisclosure,
} from '@chakra-ui/react';
import { TrendingUp, Coins, Shield, TrendingDown, Activity, History, PieChart } from 'lucide-react';
import React, { memo } from 'react';
import { SimpleGrid, HStack, VStack, Text, Badge } from '@chakra-ui/react';
import { TrendingUp, Coins, Shield, TrendingDown, Activity, PieChart } from 'lucide-react';
import { formatUtils } from '@services/financialService';
// 复用 MarketDataView 的组件
import MetricCard from '../../MarketDataView/components/StockSummaryCard/MetricCard';
import { StatusTag } from '../../MarketDataView/components/StockSummaryCard/atoms';
import { darkGoldTheme } from '../../MarketDataView/constants';
import { HistoricalComparisonTable } from './MainBusinessAnalysis';
import type { StockInfo, FinancialMetricsData, MainBusinessData, BusinessItem, ProductClassification, IndustryClassification } from '../types';
import type { StockInfo, FinancialMetricsData } from '../types';
export interface FinancialOverviewPanelProps {
stockInfo: StockInfo | null;
financialMetrics: FinancialMetricsData[];
mainBusiness?: MainBusinessData | null;
}
/**
@@ -77,42 +60,7 @@ const getDebtStatus = (value: number | undefined): { text: string; color: string
export const FinancialOverviewPanel: React.FC<FinancialOverviewPanelProps> = memo(({
stockInfo,
financialMetrics,
mainBusiness,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
// 主营业务数据处理
const mainBusinessData = useMemo(() => {
if (!mainBusiness) return null;
const hasProductData = mainBusiness.product_classification && mainBusiness.product_classification.length > 0;
const hasIndustryData = mainBusiness.industry_classification && mainBusiness.industry_classification.length > 0;
if (!hasProductData && !hasIndustryData) return null;
// 获取最新期间数据
const latestPeriod = hasProductData
? mainBusiness.product_classification![0] as ProductClassification
: mainBusiness.industry_classification![0] as IndustryClassification;
// 获取业务项目
const businessItems: BusinessItem[] = hasProductData
? (latestPeriod as ProductClassification).products
: (latestPeriod as IndustryClassification).industries;
// 历史对比数据
const historicalData = hasProductData
? mainBusiness.product_classification! as ProductClassification[]
: mainBusiness.industry_classification! as IndustryClassification[];
return {
hasProductData,
latestPeriod,
businessItems,
historicalData,
};
}, [mainBusiness]);
if (!stockInfo && (!financialMetrics || financialMetrics.length === 0)) {
return null;
}
@@ -148,8 +96,7 @@ export const FinancialOverviewPanel: React.FC<FinancialOverviewPanelProps> = mem
};
return (
<>
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3}>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
{/* 卡片1: 成长能力 */}
<MetricCard
title="成长能力"
@@ -232,118 +179,7 @@ export const FinancialOverviewPanel: React.FC<FinancialOverviewPanelProps> = mem
</VStack>
}
/>
{/* 卡片4: 主营业务 - 使用 MetricCard 组件 */}
<MetricCard
title="主营业务"
subtitle="核心构成"
leftIcon={<PieChart size={14} />}
rightIcon={
<Button
size="xs"
bg="rgba(59, 130, 246, 0.9)"
color="white"
_hover={{ bg: 'rgba(59, 130, 246, 1)' }}
leftIcon={<History size={10} />}
onClick={onOpen}
isDisabled={!mainBusinessData}
fontSize="2xs"
h="18px"
px={1.5}
borderRadius="full"
>
</Button>
}
mainLabel="营收总额"
mainValue={mainBusinessData ? `¥${((() => {
const items = mainBusinessData.businessItems.filter((item: BusinessItem) => item.content !== '合计');
const totalItem = mainBusinessData.businessItems.find((item: BusinessItem) => item.content === '合计');
return totalItem?.revenue || items.reduce((sum: number, item: BusinessItem) => sum + (item.revenue || 0), 0);
})() / 100000000).toFixed(1)}` : '-'}
mainColor={darkGoldTheme.gold}
mainSuffix="亿"
subText={
mainBusinessData ? (
(() => {
const BUSINESS_COLORS = ['#D4AF37', '#4A90D9', '#6B7280', '#10B981', '#9333EA', '#EF4444'];
const items = mainBusinessData.businessItems.filter((item: BusinessItem) => item.content !== '合计');
const totalItem = mainBusinessData.businessItems.find((item: BusinessItem) => item.content === '合计');
const totalRevenue = totalItem?.revenue || items.reduce((sum: number, item: BusinessItem) => sum + (item.revenue || 0), 0);
const businessWithRatio = items.map((item: BusinessItem, idx: number) => ({
...item,
ratio: totalRevenue > 0 ? ((item.revenue || 0) / totalRevenue * 100) : 0,
color: BUSINESS_COLORS[idx % BUSINESS_COLORS.length],
})).sort((a, b) => b.ratio - a.ratio);
return (
<VStack spacing={2} align="stretch">
{/* 堆叠条形图 */}
<HStack
h="8px"
borderRadius="sm"
overflow="hidden"
spacing={0}
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
>
{businessWithRatio.slice(0, 6).map((item, idx) => (
<Box
key={idx}
w={`${item.ratio}%`}
h="100%"
bg={item.color}
transition="width 0.5s ease"
/>
))}
</HStack>
{/* 图例网格2列3行 */}
<SimpleGrid columns={2} spacing={2} spacingY={0.5}>
{businessWithRatio.slice(0, 6).map((item, idx) => (
<HStack key={idx} spacing={1.5}>
<Box w="10px" h="10px" borderRadius="sm" bg={item.color} flexShrink={0} />
<Text fontSize="xs" fontWeight="medium" color="gray.300" noOfLines={1} flex={1}>
{item.content}
</Text>
<Text fontSize="xs" fontWeight="bold" color="gray.100" flexShrink={0}>
{item.ratio.toFixed(1)}%
</Text>
</HStack>
))}
</SimpleGrid>
</VStack>
);
})()
) : (
<Text textAlign="center"></Text>
)
}
/>
</SimpleGrid>
{/* 历史数据弹窗 */}
<Modal isOpen={isOpen} onClose={onClose} size="5xl" isCentered>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent bg="gray.900" maxW="1000px" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<ModalHeader color={darkGoldTheme.gold} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody pb={6}>
{mainBusinessData && (
<HistoricalComparisonTable
historicalData={mainBusinessData.historicalData}
businessItems={mainBusinessData.businessItems}
hasProductData={mainBusinessData.hasProductData}
latestReportType={mainBusinessData.latestPeriod.report_type}
hideTitle
/>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
});

View File

@@ -48,7 +48,7 @@ const BLACK_GOLD_THEME = {
Table: {
headerBg: 'rgba(212, 175, 55, 0.1)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(156, 163, 175, 0.15)', // 浅灰色悬停背景
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
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(156, 163, 175, 0.15) !important;
background: rgba(212, 175, 55, 0.08) !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 {
@@ -88,35 +88,19 @@ interface HistoricalRowData {
}
// 历史对比表格组件(整合业务明细)
export interface HistoricalComparisonTableProps {
interface HistoricalComparisonTableProps {
historicalData: (ProductClassification | IndustryClassification)[];
businessItems: BusinessItem[];
hasProductData: boolean;
latestReportType: string;
/** 是否隐藏标题(用于弹窗中避免重复) */
hideTitle?: boolean;
}
export const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
historicalData,
businessItems,
hasProductData,
latestReportType,
hideTitle = false,
}) => {
// 简化报告类型显示2024年三季报 -> 24Q3
const formatReportType = (reportType: string): string => {
const match = reportType.match(/(\d{4})年(.+)/);
if (!match) return reportType;
const year = match[1].slice(2); // 2024 -> 24
const type = match[2];
if (type.includes('一季')) return `${year}Q1`;
if (type.includes('中报') || type.includes('半年')) return `${year}Q2`;
if (type.includes('三季')) return `${year}Q3`;
if (type.includes('年报')) return `${year}Y`;
return `${year}`;
};
// 动态生成列配置
const columns: ColumnsType<HistoricalRowData> = useMemo(() => {
const cols: ColumnsType<HistoricalRowData> = [
@@ -125,24 +109,23 @@ export const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps>
dataIndex: 'business',
key: 'business',
fixed: 'left',
width: 120,
ellipsis: true,
width: 150,
},
{
title: `毛利率`,
title: `毛利率(${latestReportType})`,
dataIndex: 'grossMargin',
key: 'grossMargin',
align: 'right',
width: 80,
width: 120,
render: (value: number | undefined) =>
value !== undefined ? formatUtils.formatPercent(value) : '-',
},
{
title: `利润`,
title: `利润(${latestReportType})`,
dataIndex: 'profit',
key: 'profit',
align: 'right',
width: 90,
width: 100,
render: (value: number | undefined) =>
value !== undefined ? formatUtils.formatLargeNumber(value) : '-',
},
@@ -150,13 +133,12 @@ export const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps>
// 添加各期间营收列
historicalData.slice(0, 4).forEach((period) => {
const shortPeriod = formatReportType(period.report_type);
cols.push({
title: shortPeriod,
title: `营收(${period.report_type})`,
dataIndex: period.period,
key: period.period,
align: 'right',
width: 90,
width: 120,
render: (value: number | string | undefined) =>
value !== undefined && value !== '-'
? formatUtils.formatLargeNumber(value as number)
@@ -197,19 +179,20 @@ export const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps>
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
h="100%"
className="main-business-table"
>
<style>{fixedColumnStyles}</style>
{!hideTitle && (
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
)}
<Box p={2} overflowX="auto">
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
<Box p={4} overflowX="auto">
<ConfigProvider theme={BLACK_GOLD_THEME}>
<AntTable<HistoricalRowData>
columns={columns}
@@ -265,8 +248,8 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
}));
const pieOption = getMainBusinessPieOption(
`主营业务构成${latestPeriod.report_type}`,
'',
`主营业务构成 - ${latestPeriod.report_type}`,
dataSource === 'industry' ? '按行业分类' : '按产品分类',
pieData
);

View File

@@ -1,163 +0,0 @@
/**
* 归母净利润趋势分析图表
* 柱状图(净利润)+ 折线图(同比)+ 筛选器 + 查看详细数据
*/
import React, { useState, useMemo, memo, useCallback } from 'react';
import {
Box,
HStack,
Text,
Link,
Skeleton,
useDisclosure,
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { ExternalLink } from 'lucide-react';
import PeriodFilterDropdown from './PeriodFilterDropdown';
import { MetricChartModal } from './MetricChartModal';
import { useNetProfitData, type ReportPeriodType } from '../hooks/useNetProfitData';
import { getNetProfitTrendChartOption } from '../utils/chartOptions';
import type { IncomeStatementData } from '../types';
/** 主题配置 */
const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.1)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
textPrimary: '#E2E8F0',
textSecondary: '#A0AEC0',
cardBg: 'transparent',
};
export interface NetProfitTrendChartProps {
/** 利润表数据 */
incomeStatement: IncomeStatementData[];
/** 加载状态 */
loading?: boolean;
}
const NetProfitTrendChart: React.FC<NetProfitTrendChartProps> = ({
incomeStatement,
loading = false,
}) => {
// 筛选状态
const [viewMode, setViewMode] = useState<'quarterly' | 'annual'>('annual');
const [annualType, setAnnualType] = useState<ReportPeriodType>('annual');
// 详情弹窗状态
const { isOpen, onOpen, onClose } = useDisclosure();
// 根据视图模式决定筛选类型
const filterType = useMemo(() => {
if (viewMode === 'quarterly') {
return 'all'; // 单季度显示所有数据
}
return annualType;
}, [viewMode, annualType]);
// 获取处理后的数据
const netProfitData = useNetProfitData(incomeStatement, filterType);
// 生成图表配置
const chartOption = useMemo(() => {
if (!netProfitData || netProfitData.length === 0) return null;
return getNetProfitTrendChartOption(netProfitData);
}, [netProfitData]);
// 处理查看详细数据
const handleViewDetail = useCallback(() => {
onOpen();
}, [onOpen]);
// 转换为 MetricChartModal 需要的数据格式
const modalData = useMemo(() => {
return incomeStatement.map((item) => ({
period: item.period,
profit: item.profit,
}));
}, [incomeStatement]);
// 加载状态
if (loading) {
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
p={4}
>
<HStack justify="space-between" mb={4}>
<Skeleton height="24px" width="150px" />
<Skeleton height="32px" width="200px" />
</HStack>
<Skeleton height="350px" />
</Box>
);
}
// 无数据状态
if (!netProfitData || netProfitData.length === 0) {
return null;
}
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
p={4}
>
{/* 头部:标题 + 筛选器 */}
<HStack justify="space-between" mb={4}>
<HStack spacing={3}>
<Text fontSize="lg" fontWeight="bold" color={THEME.gold}>
</Text>
<Link
fontSize="sm"
color={THEME.gold}
_hover={{ textDecoration: 'underline' }}
onClick={handleViewDetail}
cursor="pointer"
>
<HStack spacing={1}>
<Text></Text>
<ExternalLink size={12} />
</HStack>
</Link>
</HStack>
<PeriodFilterDropdown
viewMode={viewMode}
annualType={annualType}
onViewModeChange={setViewMode}
onAnnualTypeChange={setAnnualType}
/>
</HStack>
{/* 图表 */}
{chartOption && (
<ReactECharts
option={chartOption}
style={{ height: '350px', width: '100%' }}
opts={{ renderer: 'canvas' }}
/>
)}
{/* 详细数据弹窗 */}
<MetricChartModal
isOpen={isOpen}
onClose={onClose}
metricName="归母净利润"
data={modalData}
dataPath="profit.parent_net_profit"
/>
</Box>
);
};
export default memo(NetProfitTrendChart);

View File

@@ -1,116 +0,0 @@
/**
* 期数筛选下拉组件
* 支持:单季度 | 年报(含子选项:全部、年报、中报、一季报、三季报)
*/
import React, { memo } from 'react';
import {
HStack,
Button,
Menu,
MenuButton,
MenuList,
MenuItem,
Text,
} from '@chakra-ui/react';
import { ChevronDown } from 'lucide-react';
import type { ReportPeriodType } from '../hooks/useNetProfitData';
/** 筛选选项配置 */
const FILTER_OPTIONS: { value: ReportPeriodType; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'annual', label: '年报' },
{ value: 'mid', label: '中报' },
{ value: 'q1', label: '一季报' },
{ value: 'q3', label: '三季报' },
];
/** 主题配置 */
const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.1)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
textPrimary: '#E2E8F0',
textSecondary: '#A0AEC0',
menuBg: 'rgba(26, 32, 44, 0.98)',
};
export interface PeriodFilterDropdownProps {
/** 当前视图模式:单季度 | 年报 */
viewMode: 'quarterly' | 'annual';
/** 年报类型筛选 */
annualType: ReportPeriodType;
/** 视图模式变化回调 */
onViewModeChange: (mode: 'quarterly' | 'annual') => void;
/** 年报类型变化回调 */
onAnnualTypeChange: (type: ReportPeriodType) => void;
}
const PeriodFilterDropdown: React.FC<PeriodFilterDropdownProps> = ({
viewMode,
annualType,
onViewModeChange,
onAnnualTypeChange,
}) => {
const currentLabel = FILTER_OPTIONS.find((o) => o.value === annualType)?.label || '年报';
return (
<HStack spacing={2}>
{/* 单季度按钮 */}
<Button
size="sm"
variant={viewMode === 'quarterly' ? 'solid' : 'ghost'}
bg={viewMode === 'quarterly' ? THEME.goldLight : 'transparent'}
color={viewMode === 'quarterly' ? THEME.gold : THEME.textSecondary}
borderWidth="1px"
borderColor={viewMode === 'quarterly' ? THEME.goldBorder : 'transparent'}
_hover={{ bg: THEME.goldLight, color: THEME.gold }}
onClick={() => onViewModeChange('quarterly')}
>
</Button>
{/* 年报下拉菜单 */}
<Menu>
<MenuButton
as={Button}
size="sm"
variant={viewMode === 'annual' ? 'solid' : 'ghost'}
bg={viewMode === 'annual' ? THEME.goldLight : 'transparent'}
color={viewMode === 'annual' ? THEME.gold : THEME.textSecondary}
borderWidth="1px"
borderColor={viewMode === 'annual' ? THEME.goldBorder : 'transparent'}
_hover={{ bg: THEME.goldLight, color: THEME.gold }}
rightIcon={<ChevronDown size={14} />}
onClick={() => onViewModeChange('annual')}
>
{currentLabel}
</MenuButton>
<MenuList
bg={THEME.menuBg}
borderColor={THEME.goldBorder}
minW="100px"
py={1}
>
{FILTER_OPTIONS.map((option) => (
<MenuItem
key={option.value}
bg="transparent"
color={annualType === option.value ? THEME.gold : THEME.textPrimary}
fontWeight={annualType === option.value ? 'bold' : 'normal'}
_hover={{ bg: THEME.goldLight, color: THEME.gold }}
onClick={() => {
onAnnualTypeChange(option.value);
onViewModeChange('annual');
}}
>
<Text fontSize="sm">{option.label}</Text>
</MenuItem>
))}
</MenuList>
</Menu>
</HStack>
);
};
export default memo(PeriodFilterDropdown);

View File

@@ -10,6 +10,7 @@ import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, Button, Spinner, Center } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY, getValueByPath, isNegativeIndicator } from '../utils';
import type { MetricConfig, MetricSectionConfig } from '../types';
@@ -307,19 +308,15 @@ const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
if (type === 'metrics') {
return (
<Text
fontSize="xs"
<Eye
size={14}
color="#D4AF37"
cursor="pointer"
opacity={0.7}
_hover={{ opacity: 1 }}
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
>
</Text>
/>
);
}

View File

@@ -16,6 +16,3 @@ export { FinancialPanoramaSkeleton } from './FinancialPanoramaSkeleton';
// 统一财务表格组件
export { UnifiedFinancialTable } from './UnifiedFinancialTable';
export type { UnifiedFinancialTableProps, TableType, FinancialDataItem } from './UnifiedFinancialTable';
// 归母净利润趋势图表
export { default as NetProfitTrendChart } from './NetProfitTrendChart';
export { default as PeriodFilterDropdown } from './PeriodFilterDropdown';

View File

@@ -5,6 +5,3 @@
export { useFinancialData } from './useFinancialData';
export type { DataTypeKey } from './useFinancialData';
export type { default as UseFinancialDataReturn } from './useFinancialData';
export { useNetProfitData } from './useNetProfitData';
export type { ReportPeriodType, NetProfitDataItem } from './useNetProfitData';

View File

@@ -83,7 +83,7 @@ export const useFinancialData = (
// 参数状态
const [stockCode, setStockCode] = useState(initialStockCode);
const [selectedPeriods, setSelectedPeriodsState] = useState(initialPeriods);
const [activeTab, setActiveTab] = useState<DataTypeKey>('balance');
const [activeTab, setActiveTab] = useState<DataTypeKey>('profitability');
// 加载状态
const [loading, setLoading] = useState(false);
@@ -252,21 +252,17 @@ export const useFinancialData = (
setError(null);
try {
// 只加载核心数据(概览面板 + 归母净利润趋势图 + 默认Tab资产负债表需要的)
// 只加载核心数据(概览面板需要的)
const [
stockInfoRes,
metricsRes,
comparisonRes,
businessRes,
incomeRes,
balanceRes,
] = await Promise.all([
financialService.getStockInfo(stockCode, options),
financialService.getFinancialMetrics(stockCode, selectedPeriods, options),
financialService.getPeriodComparison(stockCode, selectedPeriods, options),
financialService.getMainBusiness(stockCode, 4, options),
financialService.getIncomeStatement(stockCode, selectedPeriods, options),
financialService.getBalanceSheet(stockCode, selectedPeriods, options),
]);
// 设置数据
@@ -277,14 +273,6 @@ export const useFinancialData = (
}
if (comparisonRes.success) setComparison(comparisonRes.data);
if (businessRes.success) setMainBusiness(businessRes.data);
if (incomeRes.success) {
setIncomeStatement(incomeRes.data);
dataPeriodsRef.current.income = selectedPeriods;
}
if (balanceRes.success) {
setBalanceSheet(balanceRes.data);
dataPeriodsRef.current.balance = selectedPeriods;
}
logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
} catch (err) {

View File

@@ -1,131 +0,0 @@
/**
* 归母净利润数据处理 Hook
* 从利润表提取归母净利润并计算同比增长率
*/
import { useMemo } from 'react';
import { formatUtils } from '@services/financialService';
import type { IncomeStatementData } from '../types';
/** 报告期类型筛选 */
export type ReportPeriodType = 'all' | 'annual' | 'mid' | 'q1' | 'q3';
/** 净利润数据项 */
export interface NetProfitDataItem {
period: string; // 原始期间 "20231231"
periodLabel: string; // 显示标签 "2023年报"
netProfit: number; // 归母净利润(亿)
yoyGrowth: number | null;// 同比增长率(%
}
/**
* 获取报告期类型
* @param period 期间字符串 "20231231" 或 "2023-12-31"
* @returns 报告类型 'annual' | 'mid' | 'q1' | 'q3'
*/
const getReportPeriodType = (period: string): ReportPeriodType => {
// 兼容两种格式20231231 或 2023-12-31
const normalized = period.replace(/-/g, '');
const month = normalized.slice(4, 6);
switch (month) {
case '12':
return 'annual';
case '06':
return 'mid';
case '03':
return 'q1';
case '09':
return 'q3';
default:
return 'annual';
}
};
/**
* 查找同比数据(去年同期)
* 兼容两种格式20231231 或 2023-12-31
*/
const findYoYData = (
data: IncomeStatementData[],
currentPeriod: string
): IncomeStatementData | undefined => {
// 检测是否带有连字符
const hasHyphen = currentPeriod.includes('-');
const normalized = currentPeriod.replace(/-/g, '');
const currentYear = parseInt(normalized.slice(0, 4), 10);
const currentMonthDay = normalized.slice(4);
// 根据原格式构建去年同期
const lastYearPeriod = hasHyphen
? `${currentYear - 1}-${currentMonthDay.slice(0, 2)}-${currentMonthDay.slice(2)}`
: `${currentYear - 1}${currentMonthDay}`;
return data.find((item) => item.period === lastYearPeriod);
};
/**
* 计算同比增长率
*/
const calculateYoYGrowth = (
current: number | undefined,
previous: number | undefined
): number | null => {
if (
current === undefined ||
previous === undefined ||
previous === 0
) {
return null;
}
return ((current - previous) / Math.abs(previous)) * 100;
};
/**
* 归母净利润数据 Hook
* @param incomeStatement 利润表数据
* @param filterType 筛选类型 'all' | 'annual' | 'mid' | 'q1' | 'q3'
*/
export const useNetProfitData = (
incomeStatement: IncomeStatementData[],
filterType: ReportPeriodType = 'all'
): NetProfitDataItem[] => {
return useMemo(() => {
if (!incomeStatement || incomeStatement.length === 0) {
return [];
}
// 按期间排序(升序,旧的在前)
const sortedData = [...incomeStatement].sort((a, b) =>
a.period.localeCompare(b.period)
);
// 筛选数据
const filteredData =
filterType === 'all'
? sortedData
: sortedData.filter(
(item) => getReportPeriodType(item.period) === filterType
);
// 转换数据
return filteredData.map((item) => {
const netProfit = item.profit?.parent_net_profit ?? 0;
// 转换为亿元
const netProfitInBillion = netProfit / 100000000;
// 计算同比
const lastYearItem = findYoYData(incomeStatement, item.period);
const lastYearProfit = lastYearItem?.profit?.parent_net_profit;
const yoyGrowth = calculateYoYGrowth(netProfit, lastYearProfit);
return {
period: item.period,
periodLabel: formatUtils.getReportType(item.period),
netProfit: parseFloat(netProfitInBillion.toFixed(2)),
yoyGrowth: yoyGrowth !== null ? parseFloat(yoyGrowth.toFixed(2)) : null,
};
});
}, [incomeStatement, filterType]);
};
export default useNetProfitData;

View File

@@ -5,10 +5,12 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Container,
VStack,
Card,
CardBody,
Text,
Alert,
AlertIcon,
useDisclosure,
@@ -27,7 +29,7 @@ import {
} from 'lucide-react';
// 通用组件
import SubTabContainer, { type SubTabConfig, type SubTabGroup } from '@components/SubTabContainer';
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
// 内部模块导入
import { useFinancialData, type DataTypeKey } from './hooks';
@@ -36,10 +38,10 @@ import { calculateYoYChange, getCellBackground } from './utils';
import {
PeriodSelector,
FinancialOverviewPanel,
MainBusinessAnalysis,
ComparisonAnalysis,
MetricChartModal,
FinancialPanoramaSkeleton,
NetProfitTrendChart,
} from './components';
import {
BalanceSheetTab,
@@ -59,13 +61,7 @@ import type { FinancialPanoramaProps } from './types';
* 财务全景主组件
*/
// Tab key 映射表SubTabContainer index -> DataTypeKey
// 顺序基础报表3个 + 财务指标分析7个
const TAB_KEY_MAP: DataTypeKey[] = [
// 基础报表
'balance',
'income',
'cashflow',
// 财务指标分析
'profitability',
'perShare',
'growth',
@@ -73,6 +69,9 @@ const TAB_KEY_MAP: DataTypeKey[] = [
'solvency',
'expense',
'cashflowMetrics',
'balance',
'income',
'cashflow',
];
const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propStockCode }) => {
@@ -125,29 +124,21 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
onOpen();
}, [onOpen]);
// Tab 分组配置 - 基础报表 + 财务指标分析
const tabGroups: SubTabGroup[] = useMemo(
// Tab 配置 - 财务指标分类 + 三大财务报表
const tabConfigs: SubTabConfig[] = useMemo(
() => [
{
name: '基础报表',
tabs: [
{ key: 'balance', name: '资产负债表', icon: BarChart3, component: BalanceSheetTab },
{ key: 'income', name: '利润表', icon: DollarSign, component: IncomeStatementTab },
{ key: 'cashflow', name: '现金流量表', icon: TrendingDown, component: CashflowTab },
],
},
{
name: '财务指标分析',
tabs: [
{ key: 'profitability', name: '盈利能力', icon: PieChart, component: ProfitabilityTab },
{ key: 'perShare', name: '每股指标', icon: Percent, component: PerShareTab },
{ key: 'growth', name: '成长能力', icon: TrendingUp, component: GrowthTab },
{ key: 'operational', name: '运营效率', icon: Activity, component: OperationalTab },
{ key: 'solvency', name: '偿债能力', icon: Shield, component: SolvencyTab },
{ key: 'expense', name: '费用率', icon: Receipt, component: ExpenseTab },
{ key: 'cashflowMetrics', name: '现金流指标', icon: Banknote, component: CashflowMetricsTab },
],
},
// 财务指标分类7个
{ key: 'profitability', name: '盈利能力', icon: PieChart, component: ProfitabilityTab },
{ key: 'perShare', name: '每股指标', icon: Percent, component: PerShareTab },
{ key: 'growth', name: '成长能力', icon: TrendingUp, component: GrowthTab },
{ key: 'operational', name: '运营效率', icon: Activity, component: OperationalTab },
{ key: 'solvency', name: '偿债能力', icon: Shield, component: SolvencyTab },
{ key: 'expense', name: '费用率', icon: Receipt, component: ExpenseTab },
{ key: 'cashflowMetrics', name: '现金流指标', icon: Banknote, component: CashflowMetricsTab },
// 三大财务报表
{ key: 'balance', name: '资产负债表', icon: BarChart3, component: BalanceSheetTab },
{ key: 'income', name: '利润表', icon: DollarSign, component: IncomeStatementTab },
{ key: 'cashflow', name: '现金流量表', icon: TrendingDown, component: CashflowTab },
],
[]
);
@@ -193,11 +184,10 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
return (
<Container maxW="container.xl" py={5}>
<VStack spacing={6} align="stretch">
{/* 财务全景面板(列布局:成长能力、盈利与回报、风险与运营、主营业务按钮 */}
{/* 财务全景面板(列布局:成长能力、盈利与回报、风险与运营) */}
<FinancialOverviewPanel
stockInfo={stockInfo}
financialMetrics={financialMetrics}
mainBusiness={mainBusiness}
/>
{/* 营收与利润趋势 */}
@@ -205,20 +195,21 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
<ComparisonAnalysis comparison={comparison} />
)}
{/* 归母净利润趋势分析 */}
{incomeStatement && incomeStatement.length > 0 && (
<NetProfitTrendChart
incomeStatement={incomeStatement}
loading={loadingTab === 'income'}
/>
{/* 主营业务 */}
{stockInfo && (
<Box>
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
</Text>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
</Box>
)}
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={0}>
<SubTabContainer
groups={tabGroups}
defaultIndex={0}
tabs={tabConfigs}
componentProps={componentProps}
themePreset="blackGold"
isLazy

View File

@@ -97,28 +97,3 @@ 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;
};

View File

@@ -127,10 +127,7 @@ export const getComparisonChartOption = (
},
},
legend: {
data: [
{ name: '营业收入', itemStyle: { color: '#EF4444' } },
{ name: '净利润', itemStyle: { color: fui.gold } },
],
data: ['营业收入', '净利润'],
bottom: 0,
textStyle: {
color: '#A0AEC0',
@@ -203,7 +200,14 @@ export const getComparisonChartOption = (
type: 'bar',
data: revenueData.map((d) => d.value?.toFixed(2)),
itemStyle: {
color: '#EF4444', // 统一红色
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';
},
},
},
{
@@ -234,22 +238,25 @@ 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',
confine: true,
backgroundColor: chartTheme.tooltip.bg,
borderColor: chartTheme.tooltip.border,
textStyle: {
@@ -262,40 +269,28 @@ export const getMainBusinessPieOption = (
},
},
legend: {
orient: 'horizontal',
bottom: 5,
left: 'center',
orient: 'vertical',
left: 'left',
top: 'center',
textStyle: {
color: '#E2E8F0',
fontSize: 10,
fontSize: 12,
},
itemWidth: 10,
itemHeight: 10,
itemGap: 6,
},
color: BLACK_GOLD_PIE_COLORS,
series: [
{
type: 'pie',
radius: '50%',
center: ['50%', '42%'],
radius: '55%',
center: ['55%', '50%'],
data: data,
label: {
show: true,
color: '#E2E8F0',
fontSize: 11,
formatter: (params: { name: string; percent: number }) => {
// 业务名称过长时截断
const name = params.name.length > 6
? params.name.slice(0, 6) + '...'
: params.name;
return `${name}\n${params.percent.toFixed(1)}%`;
},
lineHeight: 14,
formatter: '{b}: {d}%',
},
labelLine: {
length: 12,
length2: 8,
lineStyle: {
color: alpha('gold', 0.5),
},
@@ -313,7 +308,7 @@ export const getMainBusinessPieOption = (
};
/**
* 生成对比柱状图配置 - 黑金主题
* 生成对比柱状图配置
* @param title 标题
* @param stockName1 股票1名称
* @param stockName2 股票2名称
@@ -331,50 +326,13 @@ export const getCompareBarChartOption = (
data2: (number | undefined)[]
) => {
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltip.bg,
borderColor: chartTheme.tooltip.border,
textStyle: {
color: '#E2E8F0',
},
},
legend: {
data: [stockName1, stockName2],
textStyle: {
color: '#E2E8F0',
},
},
tooltip: { trigger: 'axis' },
legend: { data: [stockName1, stockName2] },
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,
@@ -389,170 +347,3 @@ export const getCompareBarChartOption = (
],
};
};
/** 净利润数据项接口 */
interface NetProfitDataItem {
periodLabel: string;
netProfit: number;
yoyGrowth: number | null;
}
/**
* 生成归母净利润趋势图表配置 - 黑金主题
* 柱状图(净利润)+ 折线图(同比增长率)
* @param data 净利润数据数组
* @returns ECharts 配置
*/
export const getNetProfitTrendChartOption = (data: NetProfitDataItem[]) => {
const barColor = '#5B8DEF'; // 统一蓝色
const lineColor = '#F6AD55'; // 橙色
const periods = data.map((d) => d.periodLabel);
const profitValues = data.map((d) => d.netProfit);
const growthValues = data.map((d) => d.yoyGrowth);
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltip.bg,
borderColor: chartTheme.tooltip.border,
textStyle: {
color: '#E2E8F0',
},
axisPointer: {
type: 'cross',
crossStyle: {
color: alpha('gold', 0.5),
},
},
formatter: (params: Array<{ seriesName: string; value: number | null; color: string; axisValue: string }>) => {
if (!params || params.length === 0) return '';
const period = params[0].axisValue;
let html = `<div style="font-weight:600;margin-bottom:8px;color:${fui.gold}">${period}</div>`;
params.forEach((item) => {
const value = item.value;
const unit = item.seriesName === '归母净利润' ? '亿' : '%';
const displayValue = value !== null && value !== undefined ? `${value.toFixed(2)}${unit}` : '-';
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
<span style="display:flex;align-items:center">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
${item.seriesName}
</span>
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${displayValue}</span>
</div>`;
});
return html;
},
},
legend: {
data: [
{ name: '归母净利润', itemStyle: { color: barColor } },
{ name: '同比(右)', itemStyle: { color: lineColor } },
],
bottom: 0,
textStyle: {
color: '#A0AEC0',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '12%',
top: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: periods,
axisLine: {
lineStyle: {
color: chartTheme.axisLine,
},
},
axisLabel: {
color: '#A0AEC0',
rotate: 30,
},
},
yAxis: [
{
type: 'value',
name: '(亿)',
position: 'left',
nameTextStyle: {
color: barColor,
},
axisLine: {
lineStyle: {
color: chartTheme.axisLine,
},
},
axisLabel: {
color: barColor,
},
splitLine: {
lineStyle: {
color: chartTheme.splitLine,
},
},
},
{
type: 'value',
name: '(%)',
position: 'right',
nameTextStyle: {
color: lineColor,
},
axisLine: {
lineStyle: {
color: chartTheme.axisLine,
},
},
axisLabel: {
color: lineColor,
formatter: '{value}',
},
splitLine: {
show: false,
},
},
],
series: [
{
name: '归母净利润',
type: 'bar',
data: profitValues,
barMaxWidth: 40,
itemStyle: {
color: barColor,
},
label: {
show: true,
position: 'top',
color: '#E2E8F0',
fontSize: 11,
formatter: (params: { value: number }) => {
return params.value?.toFixed(2) ?? '';
},
},
},
{
name: '同比(右)',
type: 'line',
yAxisIndex: 1,
data: growthValues,
smooth: true,
symbol: 'circle',
symbolSize: 8,
itemStyle: {
color: lineColor,
},
lineStyle: {
width: 2,
color: lineColor,
},
},
],
};
};

View File

@@ -7,7 +7,6 @@ export {
getCellBackground,
getValueByPath,
isNegativeIndicator,
calculateDiff,
} from './calculations';
export {
@@ -15,7 +14,6 @@ export {
getComparisonChartOption,
getMainBusinessPieOption,
getCompareBarChartOption,
getNetProfitTrendChartOption,
} from './chartOptions';
export {

View File

@@ -18,7 +18,7 @@ export const BLACK_GOLD_TABLE_THEME: ThemeConfig = {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(156, 163, 175, 0.15)', // 浅灰色悬停背景
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
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(156, 163, 175, 0.15) !important;
background: rgba(212, 175, 55, 0.08) !important;
}
.${className} .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;

View File

@@ -27,7 +27,7 @@ const BLACK_GOLD_THEME = {
Table: {
headerBg: 'rgba(212, 175, 55, 0.12)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(156, 163, 175, 0.15)', // 浅灰色悬停背景
rowHoverBg: 'rgba(212, 175, 55, 0.08)',
borderColor: 'rgba(212, 175, 55, 0.2)',
cellPaddingBlock: 12, // 增加行高
cellPaddingInline: 14,
@@ -89,15 +89,10 @@ const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
const isGrowthMetric = record['指标']?.includes('增长') || record['指标']?.includes('率');
const isPositiveGrowth = isGrowthMetric && !isNaN(numValue) && numValue > 0;
// PE 和 PEG 需要显示单位"倍"
const metricName = record['指标'] as string;
const isPeOrPeg = metricName === 'PE' || metricName === 'PEG';
const displayValue = isPeOrPeg ? `${value}` : value;
// 数值类添加样式类名
const className = `data-cell ${isNegative ? 'negative-value' : ''} ${isPositiveGrowth ? 'positive-growth' : ''}`;
return <span className={className}>{displayValue}</span>;
return <span className={className}>{value}</span>;
},
});
});

View File

@@ -51,10 +51,7 @@ const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
},
legend: {
...BASE_CHART_CONFIG.legend,
data: [
{ name: 'EPS(稀释)', itemStyle: { color: CHART_COLORS.eps } },
{ name: '行业平均', itemStyle: { color: CHART_COLORS.epsAvg } },
],
data: ['EPS(稀释)', '行业平均'],
bottom: 0,
},
xAxis: {

View File

@@ -61,11 +61,7 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
},
legend: {
...BASE_CHART_CONFIG.legend,
data: [
{ name: '营业总收入', itemStyle: { color: CHART_COLORS.income } },
{ name: '归母净利润', itemStyle: { color: CHART_COLORS.profit } },
{ name: '营收增长率', itemStyle: { color: CHART_COLORS.growth } },
],
data: ['营业总收入', '归母净利润', '营收增长率'],
bottom: 0,
},
grid: {

View File

@@ -31,7 +31,7 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
</div>`;
params.forEach((item: any) => {
const unit = '倍'; // PE 和 PEG 都是倍数单位
const unit = item.seriesName === 'PE' ? '倍' : '';
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
<span style="display:flex;align-items:center">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
@@ -46,19 +46,9 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
},
legend: {
...BASE_CHART_CONFIG.legend,
data: [
{ name: 'PE', itemStyle: { color: CHART_COLORS.pe } },
{ name: 'PEG', itemStyle: { color: CHART_COLORS.peg } },
],
data: ['PE', 'PEG'],
bottom: 0,
},
grid: {
left: 50,
right: 50, // 增加右侧间距,避免 PEG 轴数字被遮挡
bottom: 40,
top: 40,
containLabel: false,
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
@@ -80,7 +70,7 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: 'PEG(倍)',
name: 'PEG',
nameTextStyle: { color: CHART_COLORS.peg },
axisLine: { lineStyle: { color: CHART_COLORS.peg } },
axisLabel: { color: CHART_COLORS.peg },

View File

@@ -22,7 +22,7 @@ export const CHART_COLORS = {
profit: '#F6AD55', // 利润 - 橙金色
growth: '#10B981', // 增长率 - 翠绿色
eps: '#DAA520', // EPS - 金菊色
epsAvg: '#A0AEC0', // EPS行业平均 - 灰色(提高对比度)
epsAvg: '#4A5568', // EPS行业平均 - 灰色
pe: '#D4AF37', // PE - 金色
peg: '#38B2AC', // PEG - 青色(优化对比度)
};
@@ -126,32 +126,25 @@ 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: rgba(156, 163, 175, 0.15) !important;
background: #242d3d !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-size: 13px;
font-weight: 500;
font-weight: 600;
}
/* 斑马纹 - 奇数行 */
@@ -159,7 +152,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(156, 163, 175, 0.15) !important;
background: rgba(212, 175, 55, 0.08) !important;
}
/* 等宽字体 - 数值列 */

View File

@@ -50,7 +50,7 @@ const MetricCard: React.FC<MetricCardProps> = ({
const ambientColor = getAmbientColor(mainColor);
return (
<DarkGoldCard cornerDecor={true} position="relative" overflow="hidden" display="flex" flexDirection="column">
<DarkGoldCard cornerDecor={true} position="relative" overflow="hidden">
{/* James Turrell 环境光效果 */}
<Box
position="absolute"
@@ -98,25 +98,18 @@ const MetricCard: React.FC<MetricCardProps> = ({
/>
</VStack>
{/* 辅助信息 - 在剩余高度居中 */}
{/* 辅助信息 */}
<Box
flex={1}
display="flex"
alignItems="center"
color={darkGoldTheme.textMuted}
fontSize="xs"
position="relative"
zIndex={1}
p={2}
bg="rgba(0, 0, 0, 0.2)"
borderRadius="md"
border="1px solid rgba(212, 175, 55, 0.1)"
>
<Box
w="100%"
color={darkGoldTheme.textMuted}
fontSize="xs"
p={2}
bg="rgba(0, 0, 0, 0.2)"
borderRadius="md"
border="1px solid rgba(212, 175, 55, 0.1)"
>
{subText}
</Box>
{subText}
</Box>
{/* 底部渐变线 */}

View File

@@ -111,7 +111,7 @@ const DarkGoldCard: React.FC<DarkGoldCardProps> = ({
/>
{/* 内容 */}
<Box position="relative" zIndex={1} display="inherit" flexDirection="inherit" flex={1}>
<Box position="relative" zIndex={1}>
{children}
</Box>
</Box>

View File

@@ -4,7 +4,7 @@
*/
import React, { useState, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useSelector } from 'react-redux';
import {
Box,
HStack,
@@ -18,8 +18,6 @@ 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;
@@ -42,21 +40,13 @@ 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 = T.textSecondary;
const textColor = 'rgba(245, 240, 225, 0.95)';
const goldColor = '#F4D03F';
const bgColor = '#1A202C';
@@ -118,7 +108,7 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
<Search size={12} color={textColor} />
</InputLeftElement>
<Input
placeholder="请输入股票代码"
placeholder="对比股票"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
@@ -131,7 +121,7 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
fontSize="sm"
borderColor={borderColor}
bg="transparent"
_placeholder={{ color: T.textPlaceholder, fontSize: 'sm' }}
_placeholder={{ color: textColor, fontSize: 'sm' }}
_focus={{
borderColor: goldColor,
boxShadow: `0 0 0 1px ${goldColor}`,

View File

@@ -1,35 +1,43 @@
/**
* StockCompareModal - 股票对比弹窗组件
* 展示对比明细、盈利能力对比、成长力对比
*
* 使用 Ant Design Modal + Table
* 主题配置使用 Company/theme 统一配置
*/
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 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 ReactECharts from 'echarts-for-react';
import { COMPARE_METRICS } from '../../FinancialPanorama/constants';
import { getValueByPath, getCompareBarChartOption, calculateDiff } from '../../FinancialPanorama/utils';
import { getValueByPath, getCompareBarChartOption } 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;
@@ -40,70 +48,6 @@ 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,
@@ -113,201 +57,187 @@ const StockCompareModal: React.FC<StockCompareModalProps> = ({
compareStockInfo,
isLoading = false,
}) => {
// 构建表格数据
const tableData = useMemo<CompareTableRow[]>(() => {
if (!currentStockInfo || !compareStockInfo) return [];
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 />;
// 黑金主题颜色
const bgColor = '#1A202C';
const borderColor = '#C9A961';
const goldColor = '#F4D03F';
const positiveColor = '#EF4444'; // 红涨
const negativeColor = '#10B981'; // 绿跌
// 加载中或无数据时的显示
if (isLoading || !currentStockInfo || !compareStockInfo) {
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>
<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 (
<ConfigProvider theme={antdDarkTheme}>
<Modal
open={isOpen}
onCancel={onClose}
title={modalTitle}
footer={null}
width={1000}
centered
destroyOnHidden
styles={modalStyles}
>
{renderContent()}
</Modal>
</ConfigProvider>
<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>
);
};

View File

@@ -7,7 +7,7 @@
* - 操作按钮悬停态有玻璃效果
*/
import React, { memo, useState } from 'react';
import React, { memo } from 'react';
import { Flex, HStack, Text, Badge, IconButton, Tooltip } from '@chakra-ui/react';
import { Share2 } from 'lucide-react';
import FavoriteButton from '@components/FavoriteButton';
@@ -25,6 +25,8 @@ export interface StockHeaderProps {
industry?: string;
/** 指数标签沪深300、中证500等 */
indexTags?: string[];
/** 更新时间 */
updateTime?: string;
// 关注相关
isInWatchlist?: boolean;
isWatchlistLoading?: boolean;
@@ -49,6 +51,7 @@ export const StockHeader: React.FC<StockHeaderProps> = memo(({
industryL1,
industry,
indexTags,
updateTime,
isInWatchlist = false,
isWatchlistLoading = false,
onWatchlistToggle,
@@ -56,17 +59,6 @@ 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">
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
@@ -139,7 +131,7 @@ export const StockHeader: React.FC<StockHeaderProps> = memo(({
/>
</Tooltip>
<Text fontSize="13px" color={T.textMuted}>
{openTime}
{updateTime?.split(' ')[1] || '--:--'}
</Text>
</HStack>
</Flex>

View File

@@ -61,8 +61,6 @@ 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)',

View File

@@ -14,8 +14,8 @@
* - MainForceInfo主力动态
*/
import React, { memo, useCallback } from 'react';
import { Box, Flex, VStack, useDisclosure, useToast } from '@chakra-ui/react';
import React, { memo } from 'react';
import { Box, Flex, VStack, useDisclosure } from '@chakra-ui/react';
import { CardGlow } from '@components/FUI';
// 子组件导入
@@ -64,34 +64,6 @@ 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 />;
@@ -111,10 +83,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}
/>

View File

@@ -0,0 +1,91 @@
// 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;

View File

@@ -0,0 +1,166 @@
// 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;

View File

@@ -1,270 +0,0 @@
/**
* 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;

View File

@@ -5,7 +5,6 @@
* 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';
*/
// 完整主题对象
@@ -19,17 +18,6 @@ export {
FUI_STYLES,
} from './fui';
// Ant Design 主题配置
export {
antdDarkTheme,
modalStyles,
cardStyle,
cardStyles,
chartCardStyles,
tableStyle,
createAntdTheme,
} from './antdTheme';
// 主题组件
export * from './components';

View File

@@ -0,0 +1,29 @@
// 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,
}}
/>
);
}

View File

@@ -36,7 +36,6 @@ 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';
@@ -46,10 +45,10 @@ import {
Calendar,
Gem,
CreditCard,
FileText,
} from 'lucide-react';
import {
billingData,
invoicesData,
newestTransactions,
olderTransactions,
} from 'variables/general';
@@ -70,7 +69,7 @@ function Billing() {
templateColumns={{
sm: '1fr',
md: '1fr 1fr',
xl: '1fr 1fr 1fr 1fr',
xl: '1fr 1fr 1fr 1fr 1fr',
}}
templateRows={{ sm: 'auto auto auto', md: '1fr auto', xl: '1fr' }}
gap='26px'
@@ -97,7 +96,7 @@ function Billing() {
>
<Flex justify='space-between' align='center'>
<Text fontSize='md' fontWeight='bold'>
Argon x Chakra
价值前沿
</Text>
<Icon
as={CreditCard}
@@ -110,20 +109,20 @@ function Billing() {
<Flex direction='column'>
<Box>
<Text fontSize='2xl' letterSpacing='2px' fontWeight='bold'>
7812 2139 0823 XXXX
**** **** **** 1234
</Text>
</Box>
<Flex mt='14px'>
<Flex direction='column' me='34px'>
<Text fontSize='xs'>VALID THRU</Text>
<Text fontSize='xs'>有效期</Text>
<Text fontSize='xs' fontWeight='bold'>
05/24
12/26
</Text>
</Flex>
<Flex direction='column'>
<Text fontSize='xs'>CVV</Text>
<Text fontSize='xs' fontWeight='bold'>
09X
***
</Text>
</Flex>
</Flex>
@@ -144,7 +143,7 @@ function Billing() {
w='100%'
>
<Text fontSize='md' color={textColor} fontWeight='bold'>
Salary
账户余额
</Text>
<Text
mb='24px'
@@ -152,12 +151,12 @@ function Billing() {
color='gray.400'
fontWeight='semibold'
>
Belong Interactive
可用于支付
</Text>
<HSeparator />
</Flex>
<Text fontSize='lg' color={textColor} fontWeight='bold'>
+$2000
¥0.00
</Text>
</Flex>
</Card>
@@ -193,15 +192,56 @@ 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>
@@ -212,10 +252,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>
@@ -292,7 +332,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'
@@ -301,26 +341,25 @@ 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%'>
{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 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>
</Card>
</Grid>
@@ -329,7 +368,7 @@ function Billing() {
<Flex direction='column'>
<CardHeader py='12px'>
<Text color={textColor} fontSize='lg' fontWeight='bold'>
Billing Information
账单信息
</Text>
</CardHeader>
@@ -364,7 +403,7 @@ function Billing() {
fontSize={{ sm: 'lg', md: 'xl', lg: 'lg' }}
fontWeight='bold'
>
Your Transactions
交易记录
</Text>
<Flex align='center'>
<Icon
@@ -388,7 +427,7 @@ function Billing() {
fontWeight='semibold'
my='12px'
>
NEWEST
最近
</Text>
{newestTransactions.map((row, index) => {
return (
@@ -408,7 +447,7 @@ function Billing() {
fontWeight='semibold'
my='12px'
>
OLDER
更早
</Text>
{olderTransactions.map((row, index) => {
return (

View File

@@ -0,0 +1,358 @@
/**
* 发票管理页面
*/
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>
);
}

View File

@@ -20,7 +20,6 @@ import {
Box,
Button,
Flex,
Image,
Stack,
Table,
Tbody,
@@ -77,10 +76,10 @@ class ComponentToPrint extends React.Component {
fontSize="lg"
mb="12px"
>
St. Independence Embankment, 050105 Bucharest, Romania
北京市海淀区中关村大街1号
</Text>
<Text color="gray.400" fontWeight="normal" fontSize="md">
tel: +4 (074) 1090873
电话: 010-12345678
</Text>
</Flex>
<Flex
@@ -94,10 +93,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>
@@ -118,7 +117,7 @@ class ComponentToPrint extends React.Component {
fontSize="md"
mb="8px"
>
Invoice no
发票号码
</Text>
<Text color={secondaryColor} fontWeight="bold" fontSize="lg">
#0453119
@@ -127,18 +126,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">
06/03/2022
2024/03/06
</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">
29/07/2022
2024/07/29
</Text>
</Stack>
</Flex>
@@ -154,7 +153,7 @@ class ComponentToPrint extends React.Component {
fontWeight="normal"
ps="0px"
>
Item
项目
</Th>
<Th
borderColor={borderColor}
@@ -162,7 +161,7 @@ class ComponentToPrint extends React.Component {
fontSize="sm"
fontWeight="normal"
>
Quantity
数量
</Th>
<Th
borderColor={borderColor}
@@ -170,7 +169,7 @@ class ComponentToPrint extends React.Component {
fontSize="sm"
fontWeight="normal"
>
Rate
单价
</Th>
<Th
borderColor={borderColor}
@@ -178,7 +177,7 @@ class ComponentToPrint extends React.Component {
fontSize="sm"
fontWeight="normal"
>
Amount
金额
</Th>
</Tr>
</Thead>
@@ -194,7 +193,7 @@ class ComponentToPrint extends React.Component {
fontWeight="normal"
fontSize="md"
>
Premium Support
Pro 专业版会员服务
</Text>
</Td>
<Td borderColor={borderColor}>
@@ -216,7 +215,7 @@ class ComponentToPrint extends React.Component {
fontWeight="normal"
fontSize="md"
>
$ 9.00
¥ 2699.00
</Text>
</Td>
<Td borderColor={borderColor}>
@@ -225,49 +224,7 @@ class ComponentToPrint extends React.Component {
fontWeight="normal"
fontSize="md"
>
$ 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
¥ 2699.00
</Text>
</Td>
</Tr>
@@ -278,40 +235,12 @@ 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>
@@ -323,12 +252,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">
$ 9.00
¥ 2699.00
</Text>
</Td>
</Tr>
@@ -341,7 +270,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"
@@ -350,13 +279,12 @@ 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@creative-tim.com
support@valuefrontier.cn
</Text>
</Text>
</Flex>
@@ -368,7 +296,7 @@ class ComponentToPrint extends React.Component {
alignSelf={{ sm: "flex-start", md: "flex-end" }}
mt={{ sm: "16px", md: "0px" }}
>
PRINT
打印
</Button>
</Flex>
</Flex>
@@ -391,7 +319,12 @@ function Invoice() {
});
return (
<Flex direction="column" pt={{ sm: "100px", lg: "50px" }}>
<Flex
direction="column"
minH="100vh"
justify="center"
align="center"
>
<ComponentToPrint
ref={componentRef}
handlePrint={handlePrint}

View File

@@ -0,0 +1,35 @@
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",
],
},
];

129
src/views/Pricing/index.tsx Normal file
View File

@@ -0,0 +1,129 @@
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;

View File

@@ -31,8 +31,9 @@ import {
PinInput,
PinInputField
} from '@chakra-ui/react';
import { Link2, Trash2, Pencil, Smartphone, Mail } from 'lucide-react';
import { Link2, Trash2, Pencil, Smartphone, Mail, FileText, CreditCard } from 'lucide-react';
import { WechatOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { getApiBase } from '../../utils/apiConfig';
import { logger } from '../../utils/logger';
@@ -41,6 +42,7 @@ import { useProfileEvents } from '../../hooks/useProfileEvents';
export default function SettingsPage() {
const { user, updateUser } = useAuth();
const toast = useToast();
const navigate = useNavigate();
// 深色模式固定颜色Settings 页面始终使用深色主题)
const headingColor = 'white';
@@ -222,6 +224,7 @@ 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>
@@ -403,6 +406,71 @@ 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>