- 新增 ActualControlCard 实际控制人卡片组件 - 新增 ConcentrationCard 股权集中度卡片(含 ECharts 饼图) - 新增 ShareholdersTable 合并表格(支持十大股东/十大流通股东) - Mock 数据优化:股东名称改为真实格式 - Handler 修复:数组格式处理 + holding_ratio 百分比转换 - UI: 黑金主题统一、表格 hover 金色半透明 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
235 lines
7.2 KiB
TypeScript
235 lines
7.2 KiB
TypeScript
// src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx
|
|
// 股权集中度卡片组件
|
|
|
|
import React, { useMemo, useRef, useEffect } from "react";
|
|
import {
|
|
Box,
|
|
VStack,
|
|
HStack,
|
|
Text,
|
|
Heading,
|
|
Badge,
|
|
Icon,
|
|
Card,
|
|
CardBody,
|
|
CardHeader,
|
|
SimpleGrid,
|
|
} from "@chakra-ui/react";
|
|
import { FaChartPie, FaArrowUp, FaArrowDown } from "react-icons/fa";
|
|
import * as echarts from "echarts";
|
|
import type { Concentration } from "../../types";
|
|
import { THEME } from "../../BasicInfoTab/config";
|
|
|
|
// 格式化工具函数
|
|
const formatPercentage = (value: number | null | undefined): string => {
|
|
if (value === null || value === undefined) return "-";
|
|
return `${(value * 100).toFixed(2)}%`;
|
|
};
|
|
|
|
const formatDate = (dateStr: string | null | undefined): string => {
|
|
if (!dateStr) return "-";
|
|
return dateStr.split("T")[0];
|
|
};
|
|
|
|
interface ConcentrationCardProps {
|
|
concentration: Concentration[];
|
|
}
|
|
|
|
// 饼图颜色配置(黑金主题)
|
|
const PIE_COLORS = [
|
|
"#D4AF37", // 金色 - 前1大股东
|
|
"#F0D78C", // 浅金色 - 第2-3大股东
|
|
"#B8860B", // 暗金色 - 第4-5大股东
|
|
"#DAA520", // 金麒麟色 - 第6-10大股东
|
|
"#4A5568", // 灰色 - 其他股东
|
|
];
|
|
|
|
/**
|
|
* 股权集中度卡片
|
|
*/
|
|
const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [] }) => {
|
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
const chartInstance = useRef<echarts.ECharts | null>(null);
|
|
|
|
// 按日期分组
|
|
const groupedData = useMemo(() => {
|
|
const grouped: Record<string, Record<string, Concentration>> = {};
|
|
concentration.forEach((item) => {
|
|
if (!grouped[item.end_date]) {
|
|
grouped[item.end_date] = {};
|
|
}
|
|
grouped[item.end_date][item.stat_item] = item;
|
|
});
|
|
return Object.entries(grouped)
|
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
|
.slice(0, 1); // 只取最新一期
|
|
}, [concentration]);
|
|
|
|
// 计算饼图数据
|
|
const pieData = useMemo(() => {
|
|
if (groupedData.length === 0) return [];
|
|
|
|
const [, items] = groupedData[0];
|
|
const top1 = items["前1大股东"]?.holding_ratio || 0;
|
|
const top3 = items["前3大股东"]?.holding_ratio || 0;
|
|
const top5 = items["前5大股东"]?.holding_ratio || 0;
|
|
const top10 = items["前10大股东"]?.holding_ratio || 0;
|
|
|
|
return [
|
|
{ name: "前1大股东", value: Number((top1 * 100).toFixed(2)) },
|
|
{ name: "第2-3大股东", value: Number(((top3 - top1) * 100).toFixed(2)) },
|
|
{ name: "第4-5大股东", value: Number(((top5 - top3) * 100).toFixed(2)) },
|
|
{ name: "第6-10大股东", value: Number(((top10 - top5) * 100).toFixed(2)) },
|
|
{ name: "其他股东", value: Number(((1 - top10) * 100).toFixed(2)) },
|
|
].filter(item => item.value > 0);
|
|
}, [groupedData]);
|
|
|
|
// 初始化和更新图表
|
|
useEffect(() => {
|
|
if (!chartRef.current || pieData.length === 0) return;
|
|
|
|
// 使用 requestAnimationFrame 确保 DOM 渲染完成后再初始化
|
|
const initChart = () => {
|
|
if (!chartRef.current) return;
|
|
|
|
// 初始化图表
|
|
if (!chartInstance.current) {
|
|
chartInstance.current = echarts.init(chartRef.current);
|
|
}
|
|
|
|
const option: echarts.EChartsOption = {
|
|
backgroundColor: "transparent",
|
|
tooltip: {
|
|
trigger: "item",
|
|
formatter: "{b}: {c}%",
|
|
backgroundColor: "rgba(0,0,0,0.8)",
|
|
borderColor: THEME.gold,
|
|
textStyle: { color: "#fff" },
|
|
},
|
|
legend: {
|
|
orient: "vertical",
|
|
right: 10,
|
|
top: "center",
|
|
textStyle: { color: THEME.textSecondary, fontSize: 11 },
|
|
itemWidth: 12,
|
|
itemHeight: 12,
|
|
},
|
|
series: [
|
|
{
|
|
name: "股权集中度",
|
|
type: "pie",
|
|
radius: ["40%", "70%"],
|
|
center: ["35%", "50%"],
|
|
avoidLabelOverlap: false,
|
|
itemStyle: {
|
|
borderRadius: 4,
|
|
borderColor: THEME.cardBg,
|
|
borderWidth: 2,
|
|
},
|
|
label: {
|
|
show: false,
|
|
},
|
|
emphasis: {
|
|
label: {
|
|
show: true,
|
|
fontSize: 12,
|
|
fontWeight: "bold",
|
|
color: THEME.textPrimary,
|
|
formatter: "{b}\n{c}%",
|
|
},
|
|
},
|
|
labelLine: { show: false },
|
|
data: pieData.map((item, index) => ({
|
|
...item,
|
|
itemStyle: { color: PIE_COLORS[index] },
|
|
})),
|
|
},
|
|
],
|
|
};
|
|
|
|
chartInstance.current.setOption(option);
|
|
|
|
// 延迟 resize 确保容器尺寸已计算完成
|
|
setTimeout(() => {
|
|
chartInstance.current?.resize();
|
|
}, 100);
|
|
};
|
|
|
|
// 延迟初始化,确保布局完成
|
|
const rafId = requestAnimationFrame(initChart);
|
|
|
|
// 响应式
|
|
const handleResize = () => chartInstance.current?.resize();
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(rafId);
|
|
window.removeEventListener("resize", handleResize);
|
|
};
|
|
}, [pieData]);
|
|
|
|
// 组件卸载时销毁图表
|
|
useEffect(() => {
|
|
return () => {
|
|
chartInstance.current?.dispose();
|
|
};
|
|
}, []);
|
|
|
|
if (!concentration.length) return null;
|
|
|
|
return (
|
|
<Box>
|
|
<HStack mb={4}>
|
|
<Icon as={FaChartPie} color={THEME.gold} boxSize={5} />
|
|
<Heading size="sm" color={THEME.gold}>股权集中度</Heading>
|
|
</HStack>
|
|
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
|
{/* 数据卡片 */}
|
|
{groupedData.map(([date, items]) => (
|
|
<Card key={date} bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
|
|
<CardHeader pb={2}>
|
|
<Text fontSize="sm" color={THEME.textSecondary}>
|
|
{formatDate(date)}
|
|
</Text>
|
|
</CardHeader>
|
|
<CardBody pt={2}>
|
|
<VStack spacing={3} align="stretch">
|
|
{Object.entries(items).map(([key, item]) => (
|
|
<HStack key={key} justify="space-between">
|
|
<Text fontSize="sm" color={THEME.textPrimary}>{item.stat_item}</Text>
|
|
<HStack>
|
|
<Text fontWeight="bold" color={THEME.goldLight}>
|
|
{formatPercentage(item.holding_ratio)}
|
|
</Text>
|
|
{item.ratio_change && (
|
|
<Badge
|
|
colorScheme={item.ratio_change > 0 ? "red" : "green"}
|
|
>
|
|
<Icon
|
|
as={item.ratio_change > 0 ? FaArrowUp : FaArrowDown}
|
|
mr={1}
|
|
boxSize={3}
|
|
/>
|
|
{Math.abs(item.ratio_change).toFixed(2)}%
|
|
</Badge>
|
|
)}
|
|
</HStack>
|
|
</HStack>
|
|
))}
|
|
</VStack>
|
|
</CardBody>
|
|
</Card>
|
|
))}
|
|
{/* 饼图 */}
|
|
<Card bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
|
|
<CardBody p={2}>
|
|
<Box ref={chartRef} h="180px" w="100%" />
|
|
</CardBody>
|
|
</Card>
|
|
</SimpleGrid>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default ConcentrationCard;
|