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:
@@ -137,29 +137,29 @@ export const PINGAN_BANK_DATA = {
|
|||||||
// 十大流通股东(字段名与组件期望格式匹配)
|
// 十大流通股东(字段名与组件期望格式匹配)
|
||||||
topCirculationShareholders: [
|
topCirculationShareholders: [
|
||||||
{ shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, circulation_share_ratio: 52.38, shareholder_type: '法人', end_date: '2024-09-30' },
|
{ shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, circulation_share_ratio: 52.38, shareholder_type: '法人', end_date: '2024-09-30' },
|
||||||
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司', holding_shares: 542138600, circulation_share_ratio: 2.79, shareholder_type: 'QFII', end_date: '2024-09-30' },
|
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 542138600, circulation_share_ratio: 2.79, shareholder_type: 'QFII', end_date: '2024-09-30' },
|
||||||
{ shareholder_rank: 3, shareholder_name: '深圳市投资控股有限公司', holding_shares: 382456100, circulation_share_ratio: 1.97, shareholder_type: '法人', end_date: '2024-09-30' },
|
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 382456100, circulation_share_ratio: 1.97, shareholder_type: '保险', end_date: '2024-09-30' },
|
||||||
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, circulation_share_ratio: 1.54, shareholder_type: '券商', end_date: '2024-09-30' },
|
{ 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: 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: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, circulation_share_ratio: 0.80, shareholder_type: '社保', end_date: '2024-09-30' },
|
||||||
{ shareholder_rank: 7, shareholder_name: '全国社保基金一零一组合', holding_shares: 142356700, circulation_share_ratio: 0.73, 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: '中国人寿保险股份有限公司', holding_shares: 128945600, circulation_share_ratio: 0.66, 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: 'GIC PRIVATE LIMITED', holding_shares: 98765400, circulation_share_ratio: 0.51, shareholder_type: 'QFII', 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: '挪威中央银行', holding_shares: 87654300, circulation_share_ratio: 0.45, shareholder_type: 'QFII', 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' }
|
||||||
],
|
],
|
||||||
|
|
||||||
// 十大股东(字段名与组件期望格式匹配)
|
// 十大股东(字段名与组件期望格式匹配)
|
||||||
topShareholders: [
|
topShareholders: [
|
||||||
{ shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, total_share_ratio: 52.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
{ shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, total_share_ratio: 52.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司', holding_shares: 542138600, total_share_ratio: 2.79, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' },
|
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 542138600, total_share_ratio: 2.79, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
{ shareholder_rank: 3, shareholder_name: '深圳市投资控股有限公司', holding_shares: 382456100, total_share_ratio: 1.97, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 382456100, total_share_ratio: 1.97, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
{ 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: 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: 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: 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: '全国社保基金一零一组合', holding_shares: 142356700, total_share_ratio: 0.73, 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: '中国人寿保险股份有限公司', holding_shares: 128945600, total_share_ratio: 0.66, 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: 'GIC PRIVATE LIMITED', holding_shares: 98765400, total_share_ratio: 0.51, shareholder_type: 'QFII', 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: '挪威中央银行', holding_shares: 87654300, total_share_ratio: 0.45, shareholder_type: 'QFII', 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' }
|
||||||
],
|
],
|
||||||
|
|
||||||
// 分支机构
|
// 分支机构
|
||||||
@@ -1034,23 +1034,30 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
|
|||||||
{ name: '赵六', position: '财务总监', gender: '男', age: 48, education: '硕士', annual_compensation: 200.5, status: 'active' },
|
{ name: '赵六', position: '财务总监', gender: '男', age: 48, education: '硕士', annual_compensation: 200.5, status: 'active' },
|
||||||
{ name: '钱七', position: '技术总监', gender: '男', age: 42, education: '博士', annual_compensation: 250.8, status: 'active' },
|
{ name: '钱七', position: '技术总监', gender: '男', age: 42, education: '博士', annual_compensation: 250.8, status: 'active' },
|
||||||
],
|
],
|
||||||
topCirculationShareholders: Array(10).fill(null).map((_, i) => ({
|
topCirculationShareholders: [
|
||||||
shareholder_rank: i + 1,
|
{ shareholder_rank: 1, shareholder_name: '某控股集团有限公司', holding_shares: 560000000, circulation_share_ratio: 35.50, shareholder_type: '法人', end_date: '2024-09-30' },
|
||||||
shareholder_name: `流通股东${i + 1}`,
|
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 156000000, circulation_share_ratio: 9.88, shareholder_type: 'QFII', end_date: '2024-09-30' },
|
||||||
holding_shares: Math.floor(Math.random() * 100000000) + 10000000,
|
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 89000000, circulation_share_ratio: 5.64, shareholder_type: '保险', end_date: '2024-09-30' },
|
||||||
circulation_share_ratio: parseFloat(((10 - i) * 0.8 + Math.random() * 2).toFixed(2)),
|
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 67000000, circulation_share_ratio: 4.24, shareholder_type: '券商', end_date: '2024-09-30' },
|
||||||
shareholder_type: i < 3 ? '法人' : (i < 5 ? '基金' : (i < 7 ? '社保' : 'QFII')),
|
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 45000000, circulation_share_ratio: 2.85, shareholder_type: '法人', end_date: '2024-09-30' },
|
||||||
end_date: '2024-09-30'
|
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 34000000, circulation_share_ratio: 2.15, shareholder_type: '社保', end_date: '2024-09-30' },
|
||||||
})),
|
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 28000000, circulation_share_ratio: 1.77, shareholder_type: '基金', end_date: '2024-09-30' },
|
||||||
topShareholders: Array(10).fill(null).map((_, i) => ({
|
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 23000000, circulation_share_ratio: 1.46, shareholder_type: '保险', end_date: '2024-09-30' },
|
||||||
shareholder_rank: i + 1,
|
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 19000000, circulation_share_ratio: 1.20, shareholder_type: '基金', end_date: '2024-09-30' },
|
||||||
shareholder_name: `股东${i + 1}`,
|
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 15000000, circulation_share_ratio: 0.95, shareholder_type: '基金', end_date: '2024-09-30' }
|
||||||
holding_shares: Math.floor(Math.random() * 100000000) + 10000000,
|
],
|
||||||
total_share_ratio: parseFloat(((10 - i) * 0.8 + Math.random() * 2).toFixed(2)),
|
topShareholders: [
|
||||||
shareholder_type: i < 3 ? '法人' : (i < 5 ? '基金' : (i < 7 ? '社保' : 'QFII')),
|
{ shareholder_rank: 1, shareholder_name: '某控股集团有限公司', holding_shares: 560000000, total_share_ratio: 35.50, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
share_nature: i < 2 ? '限售股' : '流通A股',
|
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 156000000, total_share_ratio: 9.88, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
end_date: '2024-09-30'
|
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 89000000, total_share_ratio: 5.64, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
})),
|
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 67000000, total_share_ratio: 4.24, shareholder_type: '券商', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
|
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 45000000, total_share_ratio: 2.85, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
|
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 34000000, total_share_ratio: 2.15, shareholder_type: '社保', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
|
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 28000000, total_share_ratio: 1.77, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
|
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 23000000, total_share_ratio: 1.46, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
|
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 19000000, total_share_ratio: 1.20, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
|
||||||
|
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 15000000, total_share_ratio: 0.95, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }
|
||||||
|
],
|
||||||
branches: [
|
branches: [
|
||||||
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司', establish_date: '2012-05-01' },
|
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司', establish_date: '2012-05-01' },
|
||||||
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司', establish_date: '2013-08-15' },
|
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司', establish_date: '2013-08-15' },
|
||||||
|
|||||||
@@ -67,10 +67,19 @@ export const companyHandlers = [
|
|||||||
await delay(150);
|
await delay(150);
|
||||||
const { stockCode } = params;
|
const { stockCode } = params;
|
||||||
const data = getCompanyData(stockCode);
|
const data = getCompanyData(stockCode);
|
||||||
|
const raw = data.actualControl;
|
||||||
|
|
||||||
|
// 数据已经是数组格式,只做数值转换(holding_ratio 从 0-100 转为 0-1)
|
||||||
|
const formatted = Array.isArray(raw)
|
||||||
|
? raw.map(item => ({
|
||||||
|
...item,
|
||||||
|
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: data.actualControl
|
data: formatted
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -79,10 +88,19 @@ export const companyHandlers = [
|
|||||||
await delay(150);
|
await delay(150);
|
||||||
const { stockCode } = params;
|
const { stockCode } = params;
|
||||||
const data = getCompanyData(stockCode);
|
const data = getCompanyData(stockCode);
|
||||||
|
const raw = data.concentration;
|
||||||
|
|
||||||
|
// 数据已经是数组格式,只做数值转换(holding_ratio 从 0-100 转为 0-1)
|
||||||
|
const formatted = Array.isArray(raw)
|
||||||
|
? raw.map(item => ({
|
||||||
|
...item,
|
||||||
|
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: data.concentration
|
data: formatted
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +1,28 @@
|
|||||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
|
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
|
||||||
// 股权结构 Tab Panel
|
// 股权结构 Tab Panel - 使用拆分后的子组件
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { VStack, SimpleGrid, Box } from "@chakra-ui/react";
|
||||||
Box,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Text,
|
|
||||||
Heading,
|
|
||||||
Badge,
|
|
||||||
Icon,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardHeader,
|
|
||||||
SimpleGrid,
|
|
||||||
Table,
|
|
||||||
Thead,
|
|
||||||
Tbody,
|
|
||||||
Tr,
|
|
||||||
Th,
|
|
||||||
Td,
|
|
||||||
TableContainer,
|
|
||||||
Tooltip,
|
|
||||||
Stat,
|
|
||||||
StatLabel,
|
|
||||||
StatNumber,
|
|
||||||
StatHelpText,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import {
|
|
||||||
FaCrown,
|
|
||||||
FaChartPie,
|
|
||||||
FaUsers,
|
|
||||||
FaChartLine,
|
|
||||||
FaArrowUp,
|
|
||||||
FaArrowDown,
|
|
||||||
FaChartBar,
|
|
||||||
FaBuilding,
|
|
||||||
FaGlobe,
|
|
||||||
FaShieldAlt,
|
|
||||||
FaBriefcase,
|
|
||||||
FaCircle,
|
|
||||||
FaUserTie,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
|
|
||||||
import { useShareholderData } from "../../hooks/useShareholderData";
|
import { useShareholderData } from "../../hooks/useShareholderData";
|
||||||
import { THEME } from "../config";
|
import {
|
||||||
import { formatPercentage, formatShares, formatDate } from "../utils";
|
ActualControlCard,
|
||||||
|
ConcentrationCard,
|
||||||
|
ShareholdersTable,
|
||||||
|
} from "../../components/shareholder";
|
||||||
import LoadingState from "./LoadingState";
|
import LoadingState from "./LoadingState";
|
||||||
|
|
||||||
interface ShareholderPanelProps {
|
interface ShareholderPanelProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 股东类型标签组件
|
/**
|
||||||
const ShareholderTypeBadge: React.FC<{ type: string }> = ({ type }) => {
|
* 股权结构面板
|
||||||
const typeConfig: Record<string, { color: string; icon: React.ElementType }> = {
|
* 使用拆分后的子组件:
|
||||||
基金: { color: "blue", icon: FaChartBar },
|
* - ActualControlCard: 实际控制人卡片
|
||||||
个人: { color: "green", icon: FaUserTie },
|
* - ConcentrationCard: 股权集中度卡片
|
||||||
法人: { color: "purple", icon: FaBuilding },
|
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
|
||||||
QFII: { color: "orange", icon: FaGlobe },
|
*/
|
||||||
社保: { color: "red", icon: FaShieldAlt },
|
|
||||||
保险: { color: "teal", icon: FaShieldAlt },
|
|
||||||
信托: { color: "cyan", icon: FaBriefcase },
|
|
||||||
券商: { color: "pink", icon: FaChartLine },
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = Object.entries(typeConfig).find(([key]) =>
|
|
||||||
type?.includes(key)
|
|
||||||
)?.[1] || { color: "gray", icon: FaCircle };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge colorScheme={config.color} size="sm">
|
|
||||||
<Icon as={config.icon} mr={1} boxSize={3} />
|
|
||||||
{type}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
|
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
|
||||||
const {
|
const {
|
||||||
actualControl,
|
actualControl,
|
||||||
@@ -86,226 +32,31 @@ const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
|
|||||||
loading,
|
loading,
|
||||||
} = useShareholderData(stockCode);
|
} = useShareholderData(stockCode);
|
||||||
|
|
||||||
// 计算股权集中度变化
|
|
||||||
const getConcentrationTrend = () => {
|
|
||||||
const grouped: Record<string, Record<string, any>> = {};
|
|
||||||
concentration.forEach((item: any) => {
|
|
||||||
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, 5);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState message="加载股权结构数据..." />;
|
return <LoadingState message="加载股权结构数据..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 实际控制人 */}
|
{/* 实际控制人 + 股权集中度 左右分布 */}
|
||||||
{actualControl.length > 0 && (
|
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||||
<Box>
|
<Box>
|
||||||
<HStack mb={4}>
|
<ActualControlCard actualControl={actualControl} />
|
||||||
<Icon as={FaCrown} color={THEME.gold} boxSize={5} />
|
|
||||||
<Heading size="sm" color={THEME.textPrimary}>实际控制人</Heading>
|
|
||||||
</HStack>
|
|
||||||
<Card bg={THEME.tableBg} border="1px solid" borderColor={THEME.border}>
|
|
||||||
<CardBody>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<VStack align="start">
|
|
||||||
<Text fontWeight="bold" fontSize="lg" color={THEME.textPrimary}>
|
|
||||||
{actualControl[0].actual_controller_name}
|
|
||||||
</Text>
|
|
||||||
<HStack>
|
|
||||||
<Badge bg={THEME.gold} color="gray.900">
|
|
||||||
{actualControl[0].control_type}
|
|
||||||
</Badge>
|
|
||||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
|
||||||
截至 {formatDate(actualControl[0].end_date)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
<Stat textAlign="right">
|
|
||||||
<StatLabel color={THEME.textSecondary}>控制比例</StatLabel>
|
|
||||||
<StatNumber color={THEME.goldLight}>
|
|
||||||
{formatPercentage(actualControl[0].holding_ratio)}
|
|
||||||
</StatNumber>
|
|
||||||
<StatHelpText color={THEME.textSecondary}>
|
|
||||||
{formatShares(actualControl[0].holding_shares)}
|
|
||||||
</StatHelpText>
|
|
||||||
</Stat>
|
|
||||||
</HStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
<Box>
|
||||||
|
<ConcentrationCard concentration={concentration} />
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
{/* 股权集中度 */}
|
{/* 十大股东 + 十大流通股东 左右分布 */}
|
||||||
{concentration.length > 0 && (
|
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||||
<Box>
|
<Box>
|
||||||
<HStack mb={4}>
|
<ShareholdersTable type="top" shareholders={topShareholders} />
|
||||||
<Icon as={FaChartPie} color={THEME.gold} boxSize={5} />
|
|
||||||
<Heading size="sm" color={THEME.textPrimary}>股权集中度</Heading>
|
|
||||||
</HStack>
|
|
||||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
|
||||||
{getConcentrationTrend()
|
|
||||||
.slice(0, 1)
|
|
||||||
.map(([date, items]) => (
|
|
||||||
<Card key={date} bg={THEME.tableBg} border="1px solid" borderColor={THEME.border}>
|
|
||||||
<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]: [string, any]) => (
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 十大股东 */}
|
|
||||||
{topShareholders.length > 0 && (
|
|
||||||
<Box>
|
<Box>
|
||||||
<HStack mb={4}>
|
<ShareholdersTable type="circulation" shareholders={topCirculationShareholders} />
|
||||||
<Icon as={FaUsers} color={THEME.gold} boxSize={5} />
|
|
||||||
<Heading size="sm" color={THEME.textPrimary}>十大股东</Heading>
|
|
||||||
<Badge bg={THEME.gold} color="gray.900">
|
|
||||||
{formatDate(topShareholders[0].end_date)}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
<TableContainer>
|
|
||||||
<Table size="sm" variant="unstyled">
|
|
||||||
<Thead>
|
|
||||||
<Tr borderBottom="1px solid" borderColor={THEME.border}>
|
|
||||||
<Th color={THEME.textSecondary}>排名</Th>
|
|
||||||
<Th color={THEME.textSecondary}>股东名称</Th>
|
|
||||||
<Th color={THEME.textSecondary}>股东类型</Th>
|
|
||||||
<Th isNumeric color={THEME.textSecondary}>持股数量</Th>
|
|
||||||
<Th isNumeric color={THEME.textSecondary}>持股比例</Th>
|
|
||||||
<Th color={THEME.textSecondary}>股份性质</Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{topShareholders.slice(0, 10).map((shareholder: any, idx: number) => (
|
|
||||||
<Tr key={idx} borderBottom="1px solid" borderColor={THEME.border} _hover={{ bg: THEME.tableHoverBg }}>
|
|
||||||
<Td>
|
|
||||||
<Badge bg={idx < 3 ? THEME.gold : "gray.600"} color={idx < 3 ? "gray.900" : THEME.textPrimary}>
|
|
||||||
{shareholder.shareholder_rank}
|
|
||||||
</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Tooltip label={shareholder.shareholder_name}>
|
|
||||||
<Text noOfLines={1} maxW="200px" color={THEME.textPrimary}>
|
|
||||||
{shareholder.shareholder_name}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<ShareholderTypeBadge type={shareholder.shareholder_type} />
|
|
||||||
</Td>
|
|
||||||
<Td isNumeric fontWeight="medium" color={THEME.textPrimary}>
|
|
||||||
{formatShares(shareholder.holding_shares)}
|
|
||||||
</Td>
|
|
||||||
<Td isNumeric>
|
|
||||||
<Text color={THEME.goldLight} fontWeight="bold">
|
|
||||||
{formatPercentage(shareholder.total_share_ratio)}
|
|
||||||
</Text>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Badge size="sm" bg="transparent" border="1px solid" borderColor={THEME.border} color={THEME.textSecondary}>
|
|
||||||
{shareholder.share_nature || "流通股"}
|
|
||||||
</Badge>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
</SimpleGrid>
|
||||||
|
|
||||||
{/* 十大流通股东 */}
|
|
||||||
{topCirculationShareholders.length > 0 && (
|
|
||||||
<Box>
|
|
||||||
<HStack mb={4}>
|
|
||||||
<Icon as={FaChartLine} color={THEME.gold} boxSize={5} />
|
|
||||||
<Heading size="sm" color={THEME.textPrimary}>十大流通股东</Heading>
|
|
||||||
<Badge bg={THEME.gold} color="gray.900">
|
|
||||||
{formatDate(topCirculationShareholders[0].end_date)}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
<TableContainer>
|
|
||||||
<Table size="sm" variant="unstyled">
|
|
||||||
<Thead>
|
|
||||||
<Tr borderBottom="1px solid" borderColor={THEME.border}>
|
|
||||||
<Th color={THEME.textSecondary}>排名</Th>
|
|
||||||
<Th color={THEME.textSecondary}>股东名称</Th>
|
|
||||||
<Th color={THEME.textSecondary}>股东类型</Th>
|
|
||||||
<Th isNumeric color={THEME.textSecondary}>持股数量</Th>
|
|
||||||
<Th isNumeric color={THEME.textSecondary}>流通股比例</Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{topCirculationShareholders.slice(0, 10).map((shareholder: any, idx: number) => (
|
|
||||||
<Tr key={idx} borderBottom="1px solid" borderColor={THEME.border} _hover={{ bg: THEME.tableHoverBg }}>
|
|
||||||
<Td>
|
|
||||||
<Badge bg={idx < 3 ? THEME.gold : "gray.600"} color={idx < 3 ? "gray.900" : THEME.textPrimary}>
|
|
||||||
{shareholder.shareholder_rank}
|
|
||||||
</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Tooltip label={shareholder.shareholder_name}>
|
|
||||||
<Text noOfLines={1} maxW="250px" color={THEME.textPrimary}>
|
|
||||||
{shareholder.shareholder_name}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<ShareholderTypeBadge type={shareholder.shareholder_type} />
|
|
||||||
</Td>
|
|
||||||
<Td isNumeric fontWeight="medium" color={THEME.textPrimary}>
|
|
||||||
{formatShares(shareholder.holding_shares)}
|
|
||||||
</Td>
|
|
||||||
<Td isNumeric>
|
|
||||||
<Text color={THEME.goldLight} fontWeight="bold">
|
|
||||||
{formatPercentage(shareholder.circulation_share_ratio)}
|
|
||||||
</Text>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// src/views/Company/components/CompanyOverview/components/shareholder/ActualControlCard.tsx
|
||||||
|
// 实际控制人卡片组件
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Heading,
|
||||||
|
Badge,
|
||||||
|
Icon,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Stat,
|
||||||
|
StatLabel,
|
||||||
|
StatNumber,
|
||||||
|
StatHelpText,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { FaCrown } from "react-icons/fa";
|
||||||
|
import type { ActualControl } 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 formatShares = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
if (value >= 100000000) {
|
||||||
|
return `${(value / 100000000).toFixed(2)}亿股`;
|
||||||
|
} else if (value >= 10000) {
|
||||||
|
return `${(value / 10000).toFixed(2)}万股`;
|
||||||
|
}
|
||||||
|
return `${value.toLocaleString()}股`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null | undefined): string => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
return dateStr.split("T")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ActualControlCardProps {
|
||||||
|
actualControl: ActualControl[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际控制人卡片
|
||||||
|
*/
|
||||||
|
const ActualControlCard: React.FC<ActualControlCardProps> = ({ actualControl = [] }) => {
|
||||||
|
if (!actualControl.length) return null;
|
||||||
|
|
||||||
|
const data = actualControl[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<HStack mb={4}>
|
||||||
|
<Icon as={FaCrown} color={THEME.gold} boxSize={5} />
|
||||||
|
<Heading size="sm" color={THEME.gold}>实际控制人</Heading>
|
||||||
|
</HStack>
|
||||||
|
<Card bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
|
||||||
|
<CardBody>
|
||||||
|
<HStack
|
||||||
|
justify="space-between"
|
||||||
|
flexDir={{ base: "column", md: "row" }}
|
||||||
|
align={{ base: "stretch", md: "center" }}
|
||||||
|
gap={4}
|
||||||
|
>
|
||||||
|
<VStack align={{ base: "center", md: "start" }}>
|
||||||
|
<Text fontWeight="bold" fontSize="lg" color={THEME.textPrimary}>
|
||||||
|
{data.actual_controller_name}
|
||||||
|
</Text>
|
||||||
|
<HStack>
|
||||||
|
<Badge colorScheme="purple">{data.control_type}</Badge>
|
||||||
|
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||||
|
截至 {formatDate(data.end_date)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
<Stat textAlign={{ base: "center", md: "right" }}>
|
||||||
|
<StatLabel color={THEME.textSecondary}>控制比例</StatLabel>
|
||||||
|
<StatNumber color={THEME.goldLight}>
|
||||||
|
{formatPercentage(data.holding_ratio)}
|
||||||
|
</StatNumber>
|
||||||
|
<StatHelpText color={THEME.textSecondary}>{formatShares(data.holding_shares)}</StatHelpText>
|
||||||
|
</Stat>
|
||||||
|
</HStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActualControlCard;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx
|
||||||
|
// 股东表格组件(合并版)- 支持十大股东和十大流通股东
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Box, HStack, Heading, Badge, Icon, useBreakpointValue } from "@chakra-ui/react";
|
||||||
|
import { Table, Tag, Tooltip, ConfigProvider } from "antd";
|
||||||
|
import type { ColumnsType } from "antd/es/table";
|
||||||
|
import { FaUsers, FaChartLine } from "react-icons/fa";
|
||||||
|
import type { Shareholder } from "../../types";
|
||||||
|
import { THEME } from "../../BasicInfoTab/config";
|
||||||
|
|
||||||
|
// antd 表格黑金主题配置
|
||||||
|
const TABLE_THEME = {
|
||||||
|
token: {
|
||||||
|
colorBgContainer: "#2D3748", // gray.700
|
||||||
|
colorText: "white",
|
||||||
|
colorTextHeading: "#D4AF37", // 金色
|
||||||
|
colorBorderSecondary: "rgba(212, 175, 55, 0.3)",
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Table: {
|
||||||
|
headerBg: "#1A202C", // gray.900
|
||||||
|
headerColor: "#D4AF37", // 金色
|
||||||
|
rowHoverBg: "rgba(212, 175, 55, 0.15)", // 金色半透明,文字更清晰
|
||||||
|
borderColor: "rgba(212, 175, 55, 0.2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化工具函数
|
||||||
|
const formatPercentage = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
return `${(value * 100).toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatShares = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
if (value >= 100000000) {
|
||||||
|
return `${(value / 100000000).toFixed(2)}亿股`;
|
||||||
|
} else if (value >= 10000) {
|
||||||
|
return `${(value / 10000).toFixed(2)}万股`;
|
||||||
|
}
|
||||||
|
return `${value.toLocaleString()}股`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null | undefined): string => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
return dateStr.split("T")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 股东类型颜色映射
|
||||||
|
const shareholderTypeColors: Record<string, string> = {
|
||||||
|
基金: "blue",
|
||||||
|
个人: "green",
|
||||||
|
法人: "purple",
|
||||||
|
QFII: "orange",
|
||||||
|
社保: "red",
|
||||||
|
保险: "cyan",
|
||||||
|
信托: "geekblue",
|
||||||
|
券商: "magenta",
|
||||||
|
企业: "purple",
|
||||||
|
机构: "blue",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getShareholderTypeColor = (type: string | undefined): string => {
|
||||||
|
if (!type) return "default";
|
||||||
|
for (const [key, color] of Object.entries(shareholderTypeColors)) {
|
||||||
|
if (type.includes(key)) return color;
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShareholdersTableProps {
|
||||||
|
type?: "top" | "circulation";
|
||||||
|
shareholders: Shareholder[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股东表格组件
|
||||||
|
* @param type - 表格类型: "top" 十大股东 | "circulation" 十大流通股东
|
||||||
|
* @param shareholders - 股东数据数组
|
||||||
|
* @param title - 自定义标题
|
||||||
|
*/
|
||||||
|
const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||||
|
type = "top",
|
||||||
|
shareholders = [],
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const config = useMemo(() => {
|
||||||
|
if (type === "circulation") {
|
||||||
|
return {
|
||||||
|
title: title || "十大流通股东",
|
||||||
|
icon: FaChartLine,
|
||||||
|
iconColor: "purple.500",
|
||||||
|
ratioField: "circulation_share_ratio" as keyof Shareholder,
|
||||||
|
ratioLabel: "流通股比例",
|
||||||
|
rankColor: "orange",
|
||||||
|
showNature: true, // 与十大股东保持一致
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: title || "十大股东",
|
||||||
|
icon: FaUsers,
|
||||||
|
iconColor: "green.500",
|
||||||
|
ratioField: "total_share_ratio" as keyof Shareholder,
|
||||||
|
ratioLabel: "持股比例",
|
||||||
|
rankColor: "red",
|
||||||
|
showNature: true,
|
||||||
|
};
|
||||||
|
}, [type, title]);
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns: ColumnsType<Shareholder> = useMemo(() => {
|
||||||
|
const baseColumns: ColumnsType<Shareholder> = [
|
||||||
|
{
|
||||||
|
title: "排名",
|
||||||
|
dataIndex: "shareholder_rank",
|
||||||
|
key: "rank",
|
||||||
|
width: 45,
|
||||||
|
render: (rank: number, _: Shareholder, index: number) => (
|
||||||
|
<Tag color={index < 3 ? config.rankColor : "default"}>
|
||||||
|
{rank || index + 1}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "股东名称",
|
||||||
|
dataIndex: "shareholder_name",
|
||||||
|
key: "name",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (name: string) => (
|
||||||
|
<Tooltip title={name}>
|
||||||
|
<span style={{ fontWeight: 500 }}>{name}</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "股东类型",
|
||||||
|
dataIndex: "shareholder_type",
|
||||||
|
key: "type",
|
||||||
|
width: 90,
|
||||||
|
responsive: ["md"],
|
||||||
|
render: (shareholderType: string) => (
|
||||||
|
<Tag color={getShareholderTypeColor(shareholderType)}>{shareholderType || "-"}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "持股数量",
|
||||||
|
dataIndex: "holding_shares",
|
||||||
|
key: "shares",
|
||||||
|
width: 100,
|
||||||
|
align: "right",
|
||||||
|
responsive: ["md"],
|
||||||
|
sorter: (a: Shareholder, b: Shareholder) => (a.holding_shares || 0) - (b.holding_shares || 0),
|
||||||
|
render: (shares: number) => formatShares(shares),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <span style={{ whiteSpace: "nowrap" }}>{config.ratioLabel}</span>,
|
||||||
|
dataIndex: config.ratioField as string,
|
||||||
|
key: "ratio",
|
||||||
|
width: 110,
|
||||||
|
align: "right",
|
||||||
|
sorter: (a: Shareholder, b: Shareholder) => {
|
||||||
|
const aVal = (a[config.ratioField] as number) || 0;
|
||||||
|
const bVal = (b[config.ratioField] as number) || 0;
|
||||||
|
return aVal - bVal;
|
||||||
|
},
|
||||||
|
defaultSortOrder: "descend",
|
||||||
|
render: (ratio: number) => (
|
||||||
|
<span style={{ color: type === "circulation" ? "#805AD5" : "#3182CE", fontWeight: "bold" }}>
|
||||||
|
{formatPercentage(ratio)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 十大股东显示股份性质
|
||||||
|
if (config.showNature) {
|
||||||
|
baseColumns.push({
|
||||||
|
title: "股份性质",
|
||||||
|
dataIndex: "share_nature",
|
||||||
|
key: "nature",
|
||||||
|
width: 80,
|
||||||
|
responsive: ["lg"],
|
||||||
|
render: (nature: string) => (
|
||||||
|
<Tag color="default">{nature || "流通股"}</Tag>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumns;
|
||||||
|
}, [config, type]);
|
||||||
|
|
||||||
|
if (!shareholders.length) return null;
|
||||||
|
|
||||||
|
// 获取数据日期
|
||||||
|
const reportDate = shareholders[0]?.end_date;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<HStack mb={4}>
|
||||||
|
<Icon as={config.icon} color={THEME.gold} boxSize={5} />
|
||||||
|
<Heading size="sm" color={THEME.gold}>{config.title}</Heading>
|
||||||
|
{reportDate && <Badge colorScheme="yellow" variant="subtle">{formatDate(reportDate)}</Badge>}
|
||||||
|
</HStack>
|
||||||
|
<ConfigProvider theme={TABLE_THEME}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={shareholders.slice(0, 10)}
|
||||||
|
rowKey={(record: Shareholder, index?: number) => `${record.shareholder_name}-${index}`}
|
||||||
|
pagination={false}
|
||||||
|
size={isMobile ? "small" : "middle"}
|
||||||
|
scroll={{ x: isMobile ? 400 : undefined }}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShareholdersTable;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// src/views/Company/components/CompanyOverview/components/shareholder/index.ts
|
||||||
|
// 股权结构子组件汇总导出
|
||||||
|
|
||||||
|
export { default as ActualControlCard } from "./ActualControlCard";
|
||||||
|
export { default as ConcentrationCard } from "./ConcentrationCard";
|
||||||
|
export { default as ShareholdersTable } from "./ShareholdersTable";
|
||||||
Reference in New Issue
Block a user