Files
vf_react/src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx
zdl 395dc27fe2 refactor: ShareholderPanel 拆分为子组件 + 黑金主题优化
- 新增 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>
2025-12-10 18:00:00 +08:00

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;