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>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user