chore: initial commit
This commit is contained in:
271
src/components/BackCover.tsx
Normal file
271
src/components/BackCover.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { FC } from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import { ASSETS } from '../constants';
|
||||
import type { BarChartCompareItem } from '../types/data';
|
||||
|
||||
interface BackCoverProps {
|
||||
riskScanning?: BarChartCompareItem[];
|
||||
riskScanningDescription?: string;
|
||||
}
|
||||
|
||||
export const BackCover: FC<BackCoverProps> = ({ riskScanning, riskScanningDescription }: BackCoverProps) => {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden print-page">
|
||||
{riskScanning && riskScanning.length > 0 ? (
|
||||
<>
|
||||
{/* White top section with chart */}
|
||||
<div className="absolute top-0 left-0 right-0" style={{ height: '120mm' }}>
|
||||
<div className="px-[40px] pt-[40px]">
|
||||
<ExportRiskBar data={riskScanning} description={riskScanningDescription} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blue footer section */}
|
||||
<BackCoverFooter />
|
||||
</>
|
||||
) : (
|
||||
<BackCoverOnly />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExportRiskBar = ({ data, description }: { data: BarChartCompareItem[]; description?: string }) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
const myChart = echarts.init(chartRef.current);
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
animation: false,
|
||||
silent: true,
|
||||
tooltip: { show: false },
|
||||
legend: {
|
||||
data: ['本机构', '全量客户'],
|
||||
right: 10,
|
||||
top: 0,
|
||||
textStyle: { fontSize: 9, color: '#666' },
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 12
|
||||
},
|
||||
grid: {
|
||||
top: 30,
|
||||
bottom: 60,
|
||||
left: 35,
|
||||
right: 15,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.label),
|
||||
axisLabel: {
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
interval: 0,
|
||||
rotate: 0,
|
||||
margin: 8
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#F0F0F0' } },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
interval: 20,
|
||||
axisLabel: { fontSize: 9, color: '#666', formatter: '{value}%' },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#F0F0F0' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '本机构',
|
||||
type: 'bar',
|
||||
data: data.map(d => d.customerValue),
|
||||
barGap: '10%',
|
||||
barWidth: 22,
|
||||
itemStyle: { color: '#45dad1', borderRadius: [2, 2, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
formatter: '{c}%'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '全量客户',
|
||||
type: 'bar',
|
||||
data: data.map(d => d.marketValue),
|
||||
barGap: '10%',
|
||||
barWidth: 22,
|
||||
itemStyle: { color: '#40a9ff', borderRadius: [2, 2, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
formatter: '{c}%'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
|
||||
const handleResize = () => myChart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
myChart.dispose();
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full break-inside-avoid">
|
||||
<div className="subtitle-container mb-1">
|
||||
<span className="blue-bar-subtitle h-[12px]"></span>
|
||||
<h4 className="text-[16px] font-semibold text-[#333]">企业风险扫描结果分布</h4>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md px-3 py-2 mb-2">
|
||||
<p className="text-[10px] text-gray-600 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full bg-white border-[0.5px] border-gray-100 rounded-lg p-2">
|
||||
<div className="echarts-canvas-container relative w-full" style={{ height: '290px' }}>
|
||||
<div ref={chartRef} className="echarts-inner w-full h-full" />
|
||||
<img className="echarts-export-image absolute inset-0 w-full h-full object-contain hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BackCoverFooter: FC = () => {
|
||||
return (
|
||||
<div className="absolute left-0 right-0 bottom-0 bg-[#2485EE]" style={{ height: '177mm' }}>
|
||||
{/* Background Image */}
|
||||
<img
|
||||
src={ASSETS.coverBg2}
|
||||
className="absolute inset-0 w-full h-full object-cover block"
|
||||
alt=""
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
|
||||
{/* Content - scaled up 20% */}
|
||||
<div className="absolute left-0 right-0 z-20 px-[50px]" style={{ bottom: '48px' }}>
|
||||
<div className="text-white mb-[48px] scale-[1.2] origin-bottom-left">
|
||||
<h2 className="font-semibold text-[16px] leading-[24px] mb-6 font-['PingFang_SC'] tracking-tight">
|
||||
本次报告由泾渭云提供,如有问题,可与我们联系。
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="font-normal text-[14px] leading-[18px] font-['PingFang_SC'] opacity-90">
|
||||
电话:0755-86072727
|
||||
</div>
|
||||
<div className="font-normal text-[14px] leading-[18px] font-['PingFang_SC'] opacity-90">
|
||||
邮箱:kdjr@kingdee.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-[14px] scale-[1.2] origin-bottom-left">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-[90px] h-[90px] bg-white flex items-center justify-center rounded-sm shadow-lg mb-3">
|
||||
<img
|
||||
src={ASSETS.qrCode1}
|
||||
alt="泾渭云二维码"
|
||||
className="w-[82px] h-[82px] object-contain block"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
<p className="font-['PingFang_SC'] font-normal text-[9px] leading-[14px] text-white text-center opacity-90">
|
||||
扫码了解更多<br/>泾渭云产品
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-[90px] h-[90px] bg-white flex items-center justify-center rounded-sm shadow-lg mb-3">
|
||||
<img
|
||||
src={ASSETS.qrCode2}
|
||||
alt="改善计划二维码"
|
||||
className="w-[82px] h-[82px] object-contain block"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
<p className="font-['PingFang_SC'] font-normal text-[9px] leading-[14px] text-white text-center opacity-90">
|
||||
扫码留言<br/>加入泾渭云改善计划
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BackCoverOnly: FC = () => {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10">
|
||||
{/* Background Image */}
|
||||
<img
|
||||
src={ASSETS.coverBg2}
|
||||
className="absolute inset-0 w-full h-full object-cover block"
|
||||
alt=""
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
|
||||
{/* Content - scaled up 20% */}
|
||||
<div className="absolute left-0 right-0 z-20 px-[50px]" style={{ bottom: '48px' }}>
|
||||
<div className="text-white mb-[48px] scale-[1.2] origin-bottom-left">
|
||||
<h2 className="font-semibold text-[16px] leading-[24px] mb-6 font-['PingFang_SC'] tracking-tight">
|
||||
本次报告由泾渭云提供,如有问题,可与我们联系。
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="font-normal text-[14px] leading-[18px] font-['PingFang_SC'] opacity-90">
|
||||
电话:0755-86072727
|
||||
</div>
|
||||
<div className="font-normal text-[14px] leading-[18px] font-['PingFang_SC'] opacity-90">
|
||||
邮箱:kdjr@kingdee.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-[14px] scale-[1.2] origin-bottom-left">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-[90px] h-[90px] bg-white flex items-center justify-center rounded-sm shadow-lg mb-3">
|
||||
<img
|
||||
src={ASSETS.qrCode1}
|
||||
alt="泾渭云二维码"
|
||||
className="w-[82px] h-[82px] object-contain block"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
<p className="font-['PingFang_SC'] font-normal text-[9px] leading-[14px] text-white text-center opacity-90">
|
||||
扫码了解更多<br/>泾渭云产品
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-[90px] h-[90px] bg-white flex items-center justify-center rounded-sm shadow-lg mb-3">
|
||||
<img
|
||||
src={ASSETS.qrCode2}
|
||||
alt="改善计划二维码"
|
||||
className="w-[82px] h-[82px] object-contain block"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
<p className="font-['PingFang_SC'] font-normal text-[9px] leading-[14px] text-white text-center opacity-90">
|
||||
扫码留言<br/>加入泾渭云改善计划
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
170
src/components/ChinaMap.tsx
Normal file
170
src/components/ChinaMap.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import chinaGeoJSON from '../assets/china.json';
|
||||
import type { MapItem } from '../types/data';
|
||||
|
||||
const geoNameMap: Record<string, string> = {
|
||||
'北京': '北京市', '天津': '天津市', '河北': '河北省', '山西': '山西省', '内蒙古': '内蒙古自治区',
|
||||
'辽宁': '辽宁省', '吉林': '吉林省', '黑龙江': '黑龙江省', '上海': '上海市', '江苏': '江苏省',
|
||||
'浙江': '浙江省', '安徽': '安徽省', '福建': '福建省', '江西': '江西省', '山东': '山东省',
|
||||
'河南': '河南省', '湖北': '湖北省', '湖南': '湖南省', '广东': '广东省', '广西': '广西壮族自治区',
|
||||
'海南': '海南省', '重庆': '重庆市', '四川': '四川省', '贵州': '贵州省', '云南': '云南省',
|
||||
'西藏': '西藏自治区', '陕西': '陕西省', '甘肃': '甘肃省', '青海': '青海省', '宁夏': '宁夏回族自治区',
|
||||
'新疆': '新疆维吾尔自治区', '台湾': '台湾省', '香港': '香港特别行政区', '澳门': '澳门特别行政区',
|
||||
};
|
||||
|
||||
const cityToGeoProvince: Record<string, string> = {
|
||||
'大连市': '辽宁省', '大连': '辽宁省', '青岛市': '山东省', '青岛': '山东省',
|
||||
'深圳市': '广东省', '深圳': '广东省', '厦门市': '福建省', '厦门': '福建省',
|
||||
'宁波市': '浙江省', '宁波': '浙江省'
|
||||
};
|
||||
|
||||
interface ChinaMapProps {
|
||||
data: MapItem[];
|
||||
}
|
||||
|
||||
export interface ChinaMapHandle {
|
||||
getChartInstance: () => echarts.ECharts | null;
|
||||
}
|
||||
|
||||
const ChinaMap = forwardRef<ChinaMapHandle, ChinaMapProps>(({ data }, ref) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
if (!echarts.getMap('china')) {
|
||||
// @ts-expect-error - geoJSON type
|
||||
echarts.registerMap('china', chinaGeoJSON);
|
||||
}
|
||||
|
||||
// Process input data to match GeoJSON region names
|
||||
const dataMap: Record<string, number> = {};
|
||||
data.forEach(item => {
|
||||
let geoName = '';
|
||||
const rawName = item.name;
|
||||
if (cityToGeoProvince[rawName]) {
|
||||
geoName = cityToGeoProvince[rawName];
|
||||
} else {
|
||||
const shortName = rawName.replace(/(省|市|自治区|维吾尔|壮族|回族|特别行政区)/g, '');
|
||||
geoName = geoNameMap[shortName] || geoNameMap[rawName] || rawName;
|
||||
}
|
||||
|
||||
dataMap[geoName] = (dataMap[geoName] || 0) + item.value;
|
||||
});
|
||||
|
||||
const uniqueProvinces = Array.from(new Set(Object.values(geoNameMap)));
|
||||
const chartData: any[] = uniqueProvinces.map(name => {
|
||||
return { name, value: dataMap[name] || 0 };
|
||||
});
|
||||
|
||||
const maxVal = Math.max(...chartData.map(i => i.value), 1);
|
||||
|
||||
// Ensure 南海诸岛 is visible with a subtle style
|
||||
if (!chartData.find(d => d.name === '南海诸岛')) {
|
||||
chartData.push({
|
||||
name: '南海诸岛',
|
||||
value: 0,
|
||||
itemStyle: {
|
||||
areaColor: '#F1F5F9',
|
||||
borderWidth: 0
|
||||
},
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor: '#CBD5E1',
|
||||
borderWidth: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: 'transparent',
|
||||
silent: true,
|
||||
animation: false,
|
||||
visualMap: {
|
||||
show: false,
|
||||
min: 0,
|
||||
max: maxVal,
|
||||
inRange: {
|
||||
color: ['#F0F7FF', '#BAE0FF', '#69B1FF', '#1677FF', '#003A8C']
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '采集数量',
|
||||
type: 'map',
|
||||
map: 'china',
|
||||
roam: false,
|
||||
zoom: 1.3
|
||||
,
|
||||
layoutCenter: ['50%', '65%'],
|
||||
layoutSize: '100%',
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 12,
|
||||
color: '#1e293b',
|
||||
fontWeight: 'bold',
|
||||
textBorderColor: '#fff',
|
||||
textBorderWidth: 2,
|
||||
formatter: (params: any) => {
|
||||
if (['香港特别行政区', '澳门特别行政区', '台湾省'].includes(params.name)) return '';
|
||||
const shortName = (params.name as string).replace(/(省|市|自治区|特别行政区|壮族|回族|维吾尔)/g, '');
|
||||
const val = params.value as number;
|
||||
return val > 0 ? `${shortName}\n{val|${val}}` : shortName;
|
||||
},
|
||||
rich: {
|
||||
val: {
|
||||
fontSize: 10,
|
||||
fontWeight: 'normal',
|
||||
color: '#475569',
|
||||
textBorderColor: '#fff',
|
||||
textBorderWidth: 2,
|
||||
padding: [2, 0, 0, 0]
|
||||
}
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
areaColor: '#f8fafc'
|
||||
},
|
||||
data: chartData
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
chartInstance.current.setOption(option);
|
||||
|
||||
return () => {
|
||||
// Don't dispose immediately to allow capture, but it's fine in React
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// 暴露图表实例给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
getChartInstance: () => chartInstance.current
|
||||
}));
|
||||
|
||||
// 将图表实例保存到 DOM 元素上,方便外部访问
|
||||
useEffect(() => {
|
||||
if (chartInstance.current && chartRef.current) {
|
||||
// 将实例保存到 DOM 元素上
|
||||
(chartRef.current as any)._echarts_instance = chartInstance.current;
|
||||
}
|
||||
}, [chartInstance.current]);
|
||||
|
||||
return (
|
||||
<div className="echarts-canvas-container relative w-full h-full">
|
||||
<div ref={chartRef} className="echarts-inner w-full h-full" data-echarts-instance="true" />
|
||||
<img className="echarts-export-image absolute inset-0 w-full h-full object-contain hidden" alt="图表导出图片" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { ChinaMap };
|
||||
32
src/components/ContentPage.tsx
Normal file
32
src/components/ContentPage.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ContentPageProps {
|
||||
pageNumber: number;
|
||||
hidePageNumber?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ContentPage = ({ pageNumber, children, hidePageNumber }: ContentPageProps) => {
|
||||
const pageNumString = pageNumber.toString().padStart(2, '0');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-white overflow-hidden text-black w-full print-page"
|
||||
>
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 px-[60px] py-[40px]">
|
||||
{/* Page Number (if not hidden) */}
|
||||
{!hidePageNumber && (
|
||||
<div className="mb-8">
|
||||
<span className="text-[60px] font-bebas text-[#E5E5E5] leading-none">{pageNumString}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Content Area */}
|
||||
<div className="space-y-6 w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
src/components/CoverPage.tsx
Normal file
109
src/components/CoverPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ASSETS, reportItems } from '../constants';
|
||||
import type { ReportData } from '../types/data';
|
||||
|
||||
interface CoverPageProps {
|
||||
data: ReportData;
|
||||
}
|
||||
|
||||
export const CoverPage = ({ data }: CoverPageProps) => {
|
||||
const currentMonth = "2026Q1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-[#FFFFFF] overflow-hidden text-black w-full print-page"
|
||||
>
|
||||
{/* Top Background Image - Width: 100%, Height: auto */}
|
||||
<div className="absolute top-0 left-0 right-0 z-0 pointer-events-none">
|
||||
<img
|
||||
src={ASSETS.coverBg}
|
||||
className="w-full h-auto block"
|
||||
alt=""
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header Content */}
|
||||
<div className="pt-[40px] px-[60px] relative z-10">
|
||||
|
||||
{/* Top Row: Logo & Slogan & Date Badge */}
|
||||
<div className="flex justify-between items-center mb-[60px]">
|
||||
{/* Left: Logo + Vertical Line + Slogan */}
|
||||
<div className="flex items-center h-[40px]">
|
||||
<div className="h-[40px] flex items-center mr-4">
|
||||
<img
|
||||
src={ASSETS.logo}
|
||||
alt="Logo"
|
||||
className="h-full w-auto object-contain block"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[1px] h-[20px] bg-white opacity-40 mr-4"></div>
|
||||
|
||||
<span className="text-white text-[20px] opacity-90 tracking-wider font-light">
|
||||
让每个企业拥有数字信用
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Date Badge */}
|
||||
<div className="bg-[#1677FF] bg-opacity-80 px-6 py-2 rounded-full shadow-lg">
|
||||
<span className="text-white text-[18px] font-medium">{currentMonth}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Titles */}
|
||||
<div className="mb-[30px]">
|
||||
<h1 className="text-[56px] font-bold text-white mb-2 tracking-tight">
|
||||
hi,{data.companyName}
|
||||
</h1>
|
||||
<h2 className="text-[56px] font-bold text-white tracking-tight leading-tight">
|
||||
您的运营报告来了
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Intro Text */}
|
||||
<div className="mb-[40px]">
|
||||
<p className="text-[18px] font-semibold text-white mb-3">
|
||||
感谢您选择泾渭云数据征信解决方案。
|
||||
</p>
|
||||
<p className="text-[14px] leading-[1.6] text-white max-w-[70%] text-justify opacity-80 font-light">
|
||||
泾渭云为合作金融机构、政府部门及广大中小微企业提供精准、智能、专业的企业数据征信解决方案,助力金融信贷产业数字化、场景化、智慧化转型。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Statistics Card */}
|
||||
<div className="relative w-full rounded-[20px] bg-white shadow-[0px_10px_40px_rgba(0,0,0,0.08)] mb-[50px] p-[32px]">
|
||||
<div className="flex items-baseline gap-4 mb-4">
|
||||
<span className="text-[#1677FF] text-[24px] font-bold">2026年一季度采集成功率</span>
|
||||
<span className="text-[#1677FF] text-[56px] font-normal font-bebas leading-none tracking-tight">{data.stats.successRate}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-[#333333] text-[16px] leading-[1.8]">
|
||||
<p>2026年一季度您在泾渭云累计采集了<span className="font-bold">{data.stats.collectedCount}</span>户企业:较2025年一季度增长了<span className="font-bold">{data.stats.growthRate}</span>。</p>
|
||||
<p><span className="font-bold">{data.stats.fiveMinRate}</span>的企业都在5分钟内完成,平均采集时长<span className="font-bold">{data.stats.avgDuration}</span>分钟。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Index List */}
|
||||
<div className="px-[10px]">
|
||||
<div className="flex flex-col space-y-[12px]">
|
||||
{reportItems.map((item, idx) => (
|
||||
<div key={idx} className="flex items-end justify-between w-full">
|
||||
<span className="text-[#999999] text-[16px] font-medium shrink-0">
|
||||
{item.title}
|
||||
</span>
|
||||
|
||||
<div className="flex-grow mx-4 mb-[5px] border-b border-dotted border-[#CCCCCC]"></div>
|
||||
|
||||
<span className="text-[#999999] text-[16px] font-medium font-sans shrink-0">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
101
src/components/Page02Industry.tsx
Normal file
101
src/components/Page02Industry.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ContentPage } from './ContentPage';
|
||||
import { ChinaMap } from './ChinaMap';
|
||||
import type { RegionData, MapItem } from '../types/data';
|
||||
|
||||
interface Page02IndustryProps {
|
||||
mapData: MapItem[];
|
||||
tableData: RegionData[];
|
||||
}
|
||||
|
||||
const geoNameMap: Record<string, string> = {
|
||||
'北京': '北京市', '天津': '天津市', '河北': '河北省', '山西': '山西省', '内蒙古': '内蒙古自治区',
|
||||
'辽宁': '辽宁省', '吉林': '吉林省', '黑龙江': '黑龙江省', '上海': '上海市', '江苏': '江苏省',
|
||||
'浙江': '浙江省', '安徽': '安徽省', '福建': '福建省', '江西': '江西省', '山东': '山东省',
|
||||
'河南': '河南省', '湖北': '湖北省', '湖南': '湖南省', '广东': '广东省', '广西': '广西壮族自治区',
|
||||
'海南': '海南省', '重庆': '重庆市', '四川': '四川省', '贵州': '贵州省', '云南': '云南省',
|
||||
'西藏': '西藏自治区', '陕西': '陕西省', '甘肃': '甘肃省', '青海': '青海省', '宁夏': '宁夏回族自治区',
|
||||
'新疆': '新疆维吾尔自治区', '台湾': '台湾省', '香港': '香港特别行政区', '澳门': '澳门特别行政区',
|
||||
};
|
||||
|
||||
const cityToGeoProvince: Record<string, string> = {
|
||||
'大连市': '辽宁省', '大连': '辽宁省', '青岛市': '山东省', '青岛': '山东省',
|
||||
'深圳市': '广东省', '深圳': '广东省', '厦门市': '福建省', '厦门': '福建省',
|
||||
'宁波市': '浙江省', '宁波': '浙江省'
|
||||
};
|
||||
|
||||
export const Page02Industry = ({ mapData = [], tableData = [] }: Page02IndustryProps) => {
|
||||
// Aggregate mapData to find real max value per province
|
||||
const aggregatedMax = () => {
|
||||
const dataMap: Record<string, number> = {};
|
||||
mapData.forEach(item => {
|
||||
let geoName = '';
|
||||
const rawName = item.name;
|
||||
if (cityToGeoProvince[rawName]) {
|
||||
geoName = cityToGeoProvince[rawName];
|
||||
} else {
|
||||
const shortName = rawName.replace(/(省|市|自治区|维吾尔|壮族|回族|特别行政区)/g, '');
|
||||
geoName = geoNameMap[shortName] || geoNameMap[rawName] || rawName;
|
||||
}
|
||||
dataMap[geoName] = (dataMap[geoName] || 0) + item.value;
|
||||
});
|
||||
const values = Object.values(dataMap);
|
||||
return values.length > 0 ? Math.max(...values) : 1;
|
||||
};
|
||||
|
||||
const maxCount = aggregatedMax();
|
||||
|
||||
return (
|
||||
<ContentPage pageNumber={2} hidePageNumber={true}>
|
||||
<div className="flex flex-col space-y-8 -mx-[20px] px-[20px]">
|
||||
<div className="title-container mb-4">
|
||||
<span className="blue-bar-title h-[22px]"></span>
|
||||
<h3 className="text-[22px] font-semibold text-[#333333] tracking-tight">重点地区采集数据统计</h3>
|
||||
</div>
|
||||
<div className="relative w-full h-[400px] overflow-hidden bg-white break-inside-avoid mb-4">
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-[20px] left-[20px] flex flex-col items-center z-10 pointer-events-none">
|
||||
<div className="text-[10px] text-gray-400 mb-[4px]">高</div>
|
||||
<div className="text-[12px] text-gray-600 font-bebas font-bold mb-[4px]">{maxCount}</div>
|
||||
<div
|
||||
className="w-[12px] h-[80px] rounded-sm shadow-inner border border-gray-100"
|
||||
style={{
|
||||
background: 'linear-gradient(to top, #F0F7FF, #BAE0FF, #69B1FF, #1677FF, #003A8C)'
|
||||
}}
|
||||
></div>
|
||||
<div className="text-[12px] text-gray-400 font-bebas font-bold mt-[4px]">0</div>
|
||||
<div className="text-[10px] text-gray-400 mt-[4px]">低</div>
|
||||
</div>
|
||||
|
||||
{/* Real ECharts Map */}
|
||||
<ChinaMap data={mapData} />
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className="rounded-xl overflow-hidden border border-gray-100 break-inside-avoid shadow-sm">
|
||||
<table className="w-full text-[14px] border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#ECF5FF] text-gray-700">
|
||||
<th className="py-3 px-6 font-semibold text-left border-r border-white last:border-r-0">地区</th>
|
||||
<th className="py-3 px-6 font-semibold text-center border-r border-white last:border-r-0">采集数量</th>
|
||||
<th className="py-3 px-6 font-semibold text-center border-r border-white last:border-r-0">转换率</th>
|
||||
<th className="py-3 px-6 font-semibold text-center border-r border-white last:border-r-0">成功率</th>
|
||||
<th className="py-3 px-6 font-semibold text-center border-r border-white last:border-r-0">采集时长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.slice(0, 10).map((row, index) => (
|
||||
<tr key={index} className="border-b border-gray-50 last:border-b-0 hover:bg-gray-50 transition-colors">
|
||||
<td className="py-3 px-6 text-gray-600 text-left border-r border-gray-50 last:border-r-0">{row.region}</td>
|
||||
<td className="py-3 px-6 text-gray-800 font-medium text-center border-r border-gray-50 last:border-r-0">{row.count}</td>
|
||||
<td className="py-3 px-6 text-gray-600 text-center border-r border-gray-50 last:border-r-0">{row.conversion}</td>
|
||||
<td className="py-3 px-6 text-gray-600 text-center border-r border-gray-50 last:border-r-0">{row.success}</td>
|
||||
<td className="py-3 px-6 text-gray-600 text-center border-r border-gray-50 last:border-r-0">{row.duration}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ContentPage>
|
||||
);
|
||||
};
|
||||
102
src/components/Page03Duration.tsx
Normal file
102
src/components/Page03Duration.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ContentPage } from './ContentPage';
|
||||
import type { DurationStats, FunnelItem } from '../types/data';
|
||||
|
||||
interface Funnel3DProps {
|
||||
data: FunnelItem[];
|
||||
}
|
||||
|
||||
const Funnel3D = ({ data }: Funnel3DProps) => {
|
||||
const maxVal = Math.max(...data.map(d => d.value), 1);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-2 py-4">
|
||||
{data.map((item, index) => {
|
||||
const ratio = item.value / maxVal;
|
||||
const width = 15 + (ratio * 85);
|
||||
const lightness = 100 - (ratio * 55);
|
||||
const bgColor = `hsl(210, 85%, ${lightness}%)`;
|
||||
const textColor = lightness < 65 ? '#ffffff' : '#1e293b';
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center w-full">
|
||||
<div className="w-[150px] text-left text-[13px] font-semibold text-gray-500 truncate">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="flex-grow h-[1px] bg-gradient-to-r from-gray-100 to-gray-300 mx-4 opacity-40"></div>
|
||||
<div className="w-[380px] flex justify-center">
|
||||
<div
|
||||
className="relative h-[28px] rounded-[50%/40%] flex justify-center items-center font-bold text-[13px] shadow-sm border border-black/5"
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: bgColor,
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0 h-[10px] bg-white/20 rounded-[50%/100%]"></div>
|
||||
<span>{item.value.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Page03DurationProps {
|
||||
durationData: DurationStats[];
|
||||
funnelData: FunnelItem[];
|
||||
}
|
||||
|
||||
export const Page03Duration = ({ durationData = [], funnelData = [] }: Page03DurationProps) => {
|
||||
return (
|
||||
<ContentPage pageNumber={3} hidePageNumber={true}>
|
||||
<div className="flex flex-col space-y-8 -mx-[20px] px-[20px]">
|
||||
{/* Module 1: 重点地区采集时长分布 */}
|
||||
<div>
|
||||
<div className="title-container mb-4">
|
||||
<span className="blue-bar-title h-[22px]"></span>
|
||||
<h3 className="text-[22px] font-semibold text-[#333333] tracking-tight">重点地区采集时长分布</h3>
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden border border-gray-100 bg-white shadow-sm break-inside-avoid">
|
||||
<table className="w-full text-[14px] border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#ECF5FF] text-gray-700">
|
||||
<th className="py-4 px-6 font-semibold text-left border-r border-white last:border-r-0">地区</th>
|
||||
<th className="py-4 px-6 font-semibold text-center border-r border-white last:border-r-0">0-5分钟</th>
|
||||
<th className="py-4 px-6 font-semibold text-center border-r border-white last:border-r-0">5-10分钟</th>
|
||||
<th className="py-4 px-6 font-semibold text-center border-r border-white last:border-r-0">10-20分钟</th>
|
||||
<th className="py-4 px-6 font-semibold text-center border-r border-white last:border-r-0">20分钟以上</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{durationData.slice(0, 10).map((row, index) => (
|
||||
<tr key={index} className="border-b border-gray-50 last:border-b-0 hover:bg-gray-50 transition-colors text-gray-600">
|
||||
<td className="py-3 px-6 text-left border-r border-gray-50 last:border-r-0 font-medium text-gray-700">{row.region}</td>
|
||||
<td className="py-3 px-6 text-center border-r border-gray-50 last:border-r-0">{row.p0_5}</td>
|
||||
<td className="py-3 px-6 text-center border-r border-gray-50 last:border-r-0">{row.p5_10}</td>
|
||||
<td className="py-3 px-6 text-center border-r border-gray-50 last:border-r-0">{row.p10_20}</td>
|
||||
<td className="py-3 px-6 text-center border-r border-gray-50 last:border-r-0 font-sans">{row.p20plus}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module 2: 页面漏损分析 */}
|
||||
{funnelData.length > 0 && (
|
||||
<div>
|
||||
<div className="title-container mb-4">
|
||||
<span className="blue-bar-title h-[22px]"></span>
|
||||
<h3 className="text-[22px] font-semibold text-[#333333] tracking-tight">页面漏损分析</h3>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 border border-gray-50 shadow-sm flex justify-center break-inside-avoid">
|
||||
<Funnel3D data={funnelData} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContentPage>
|
||||
);
|
||||
};
|
||||
210
src/components/Page04Portrait.tsx
Normal file
210
src/components/Page04Portrait.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import { ContentPage } from './ContentPage';
|
||||
import type { SunburstNode } from '../types/data';
|
||||
|
||||
interface SunburstChartProps {
|
||||
data: SunburstNode[];
|
||||
height?: number;
|
||||
innerRadius?: string;
|
||||
outerRadius?: string;
|
||||
level1FontSize?: number;
|
||||
level2FontSize?: number;
|
||||
}
|
||||
|
||||
const SunburstChart = ({
|
||||
data = [],
|
||||
height = 600,
|
||||
innerRadius = '15%',
|
||||
outerRadius = '95%',
|
||||
level1FontSize = 10,
|
||||
level2FontSize = 9,
|
||||
}: SunburstChartProps) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
const myChart = echarts.init(chartRef.current);
|
||||
|
||||
const sortedData = [...data].map(item => ({
|
||||
...item,
|
||||
children: item.children ? [...item.children].sort((a, b) => (b.value || 0) - (a.value || 0)) : []
|
||||
})).sort((a, b) => {
|
||||
const aSum = a.children ? a.children.reduce((sum: number, c: any) => sum + (c.value || 0), 0) : (a.value || 0);
|
||||
const bSum = b.children ? b.children.reduce((sum: number, c: any) => sum + (c.value || 0), 0) : (b.value || 0);
|
||||
return bSum - aSum;
|
||||
});
|
||||
|
||||
const option = {
|
||||
animation: false,
|
||||
series: {
|
||||
type: 'sunburst',
|
||||
data: sortedData,
|
||||
radius: [innerRadius, outerRadius],
|
||||
sort: 'desc',
|
||||
silent: true,
|
||||
emphasis: {
|
||||
disabled: true
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true
|
||||
},
|
||||
label: {
|
||||
rotate: 'radial' as const,
|
||||
color: '#fff',
|
||||
minShowAngle: 30
|
||||
},
|
||||
levels: [
|
||||
{},
|
||||
{
|
||||
r0: innerRadius,
|
||||
r: '55%',
|
||||
itemStyle: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
label: {
|
||||
fontSize: level1FontSize,
|
||||
align: 'center' as const,
|
||||
minShowAngle: 30
|
||||
}
|
||||
},
|
||||
{
|
||||
r0: '55%',
|
||||
r: outerRadius,
|
||||
itemStyle: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
label: {
|
||||
fontSize: level2FontSize,
|
||||
align: 'right' as const,
|
||||
padding: [0, 4, 0, 0],
|
||||
minShowAngle: 25
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
|
||||
const handleResize = () => {
|
||||
myChart.resize();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
myChart.dispose();
|
||||
};
|
||||
}, [data, innerRadius, outerRadius, level1FontSize, level2FontSize]);
|
||||
|
||||
return (
|
||||
<div className="echarts-canvas-container relative w-full h-full">
|
||||
<div ref={chartRef} className="echarts-inner w-full h-full" />
|
||||
<img className="echarts-export-image absolute inset-0 w-full h-full object-contain hidden" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PortraitSectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
data: SunburstNode[];
|
||||
height?: number;
|
||||
innerRadius?: string;
|
||||
outerRadius?: string;
|
||||
level1FontSize?: number;
|
||||
level2FontSize?: number;
|
||||
}
|
||||
|
||||
const PortraitSection = ({
|
||||
title,
|
||||
description,
|
||||
data,
|
||||
height = 600,
|
||||
innerRadius = '15%',
|
||||
outerRadius = '95%',
|
||||
level1FontSize = 10,
|
||||
level2FontSize = 9,
|
||||
}: PortraitSectionProps) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-[18px] font-semibold text-[#333333] mb-1">{title}</h4>
|
||||
{description && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5 mb-1">
|
||||
<p className="text-[11px] text-gray-600 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full flex justify-center items-center break-inside-avoid" style={{ height: `${height}px` }}>
|
||||
<SunburstChart data={data} height={height - 50} level1FontSize={level1FontSize} level2FontSize={level2FontSize} innerRadius={innerRadius} outerRadius={outerRadius} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Page04PortraitProps {
|
||||
data: SunburstNode[];
|
||||
poorCustomerData: SunburstNode[];
|
||||
goodCustomerData: SunburstNode[];
|
||||
allDescription?: string;
|
||||
poorDescription?: string;
|
||||
goodDescription?: string;
|
||||
}
|
||||
|
||||
export const Page04Portrait = ({
|
||||
data = [],
|
||||
poorCustomerData = [],
|
||||
goodCustomerData = [],
|
||||
allDescription,
|
||||
poorDescription,
|
||||
goodDescription,
|
||||
}: Page04PortraitProps) => {
|
||||
return (
|
||||
<ContentPage pageNumber={4} hidePageNumber={true}>
|
||||
<div className="flex flex-col space-y-4 -mx-[20px] px-[20px]">
|
||||
{/* Section header */}
|
||||
<div className="title-container mb-1">
|
||||
<span className="blue-bar-title h-[22px]"></span>
|
||||
<h3 className="text-[22px] font-semibold text-[#333333] tracking-tight">企业画像数据</h3>
|
||||
</div>
|
||||
|
||||
{/* 企业全景洞察 - large chart */}
|
||||
<PortraitSection
|
||||
title="企业全景洞察"
|
||||
description={allDescription}
|
||||
data={data}
|
||||
height={480}
|
||||
level1FontSize={10}
|
||||
level2FontSize={9}
|
||||
/>
|
||||
|
||||
{/* 劣质客户洞察 + 优质客户洞察 - side by side */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<PortraitSection
|
||||
title="风险客户洞察"
|
||||
description={poorDescription}
|
||||
data={poorCustomerData}
|
||||
height={340}
|
||||
level1FontSize={8}
|
||||
level2FontSize={7}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<PortraitSection
|
||||
title="优质客户洞察"
|
||||
description={goodDescription}
|
||||
data={goodCustomerData}
|
||||
height={340}
|
||||
level1FontSize={8}
|
||||
level2FontSize={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentPage>
|
||||
);
|
||||
};
|
||||
137
src/components/Page05Risk.tsx
Normal file
137
src/components/Page05Risk.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import { ContentPage } from './ContentPage';
|
||||
import type { RiskIndicator } from '../types/data';
|
||||
|
||||
interface RiskPieProps {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const RiskPie = ({ label, value }: RiskPieProps) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
const myChart = echarts.init(chartRef.current);
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
animation: false,
|
||||
silent: true,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['35%', '65%'],
|
||||
center: ['50%', '48%'],
|
||||
startAngle: 90,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: '{b}\n{d}%',
|
||||
fontSize: 9,
|
||||
color: '#666'
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 8,
|
||||
length2: 5,
|
||||
lineStyle: { color: '#ccc' }
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: value,
|
||||
name: label,
|
||||
itemStyle: { color: '#36CBCB' }
|
||||
},
|
||||
{
|
||||
value: 100 - value,
|
||||
name: '其他',
|
||||
itemStyle: { color: '#40A9FF' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
return () => myChart.dispose();
|
||||
}, [value, label]);
|
||||
|
||||
return (
|
||||
<div className="echarts-canvas-container relative w-full h-full">
|
||||
<div ref={chartRef} className="echarts-inner w-full h-full" />
|
||||
<img className="echarts-export-image absolute inset-0 w-full h-full object-contain hidden" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RiskPiePairProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
indicator: RiskIndicator;
|
||||
}
|
||||
|
||||
const RiskPiePair = ({ title, description, indicator }: RiskPiePairProps) => {
|
||||
return (
|
||||
<div className="flex flex-col w-full break-inside-avoid">
|
||||
<div className="subtitle-container mb-1">
|
||||
<span className="blue-bar-subtitle h-[12px]"></span>
|
||||
<h4 className="text-[16px] font-semibold text-[#333]">{title}</h4>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5 mb-1">
|
||||
<p className="text-[11px] text-gray-600 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="w-full bg-white border-[0.5px] border-gray-100 rounded-lg p-2" style={{ height: '150px' }}>
|
||||
<RiskPie label={indicator.label} value={indicator.value} />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-black mt-1">本机构</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="w-full bg-white border-[0.5px] border-gray-100 rounded-lg p-2" style={{ height: '150px' }}>
|
||||
<RiskPie label={indicator.label} value={indicator.marketValue} />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-black mt-1">全量大盘数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RiskIndicatorWithDesc extends RiskIndicator {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Page05RiskProps {
|
||||
riskData: {
|
||||
invoiceRisk: RiskIndicatorWithDesc;
|
||||
highTech: RiskIndicatorWithDesc;
|
||||
importExport: RiskIndicatorWithDesc;
|
||||
prepaidTax: RiskIndicatorWithDesc;
|
||||
};
|
||||
}
|
||||
|
||||
export const Page05Risk = ({ riskData }: Page05RiskProps) => {
|
||||
return (
|
||||
<ContentPage pageNumber={5} hidePageNumber={true}>
|
||||
<div className="flex flex-col space-y-3 -mx-[20px] px-[20px]">
|
||||
<div className="title-container mb-0">
|
||||
<span className="blue-bar-title h-[20px]"></span>
|
||||
<h3 className="text-[20px] font-semibold text-[#333333] tracking-tight">税务与进出口情况分析</h3>
|
||||
</div>
|
||||
|
||||
<RiskPiePair title="发票高风险企业占比" description={riskData.invoiceRisk.description} indicator={riskData.invoiceRisk} />
|
||||
<RiskPiePair title="高新技术企业占比" description={riskData.highTech.description} indicator={riskData.highTech} />
|
||||
<RiskPiePair title="进出口企业占比" description={riskData.importExport.description} indicator={riskData.importExport} />
|
||||
<RiskPiePair title="预缴税款企业占比" description={riskData.prepaidTax.description} indicator={riskData.prepaidTax} />
|
||||
</div>
|
||||
</ContentPage>
|
||||
);
|
||||
};
|
||||
156
src/components/Page06ExportTax.tsx
Normal file
156
src/components/Page06ExportTax.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import { ContentPage } from './ContentPage';
|
||||
import type { BarChartCompareItem } from '../types/data';
|
||||
|
||||
interface ExportTaxBarProps {
|
||||
title: string;
|
||||
data: BarChartCompareItem[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const ExportTaxBar = ({ title, data = [], description }: ExportTaxBarProps) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
const myChart = echarts.init(chartRef.current);
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
animation: false,
|
||||
silent: true,
|
||||
tooltip: {
|
||||
show: false
|
||||
},
|
||||
legend: {
|
||||
data: ['本机构', '全量客户'],
|
||||
right: 10,
|
||||
top: 0,
|
||||
textStyle: { fontSize: 9, color: '#666' },
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 12
|
||||
},
|
||||
grid: {
|
||||
top: 30,
|
||||
bottom: 75,
|
||||
left: 35,
|
||||
right: 15,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.label),
|
||||
axisLabel: {
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
interval: 0,
|
||||
rotate: 0,
|
||||
margin: 8,
|
||||
formatter: (value: string) => {
|
||||
if (value.includes('出口免抵退税额')) {
|
||||
return value.replace('额', '额\n');
|
||||
}
|
||||
if (value.includes('出口退税额')) {
|
||||
return value.replace('额', '额\n');
|
||||
}
|
||||
if (value.includes('关联企业数量')) {
|
||||
return value.replace('数量', '数量\n');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#F0F0F0' } },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
interval: 20,
|
||||
axisLabel: { fontSize: 9, color: '#666', formatter: '{value}%' },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#F0F0F0' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '本机构',
|
||||
type: 'bar',
|
||||
data: data.map(d => d.customerValue),
|
||||
barGap: '10%',
|
||||
barWidth: 22,
|
||||
itemStyle: { color: '#45dad1', borderRadius: [2, 2, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
formatter: '{c}%'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '全量客户',
|
||||
type: 'bar',
|
||||
data: data.map(d => d.marketValue),
|
||||
barGap: '10%',
|
||||
barWidth: 22,
|
||||
itemStyle: { color: '#40a9ff', borderRadius: [2, 2, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
formatter: '{c}%'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
|
||||
const handleResize = () => myChart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
myChart.dispose();
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full break-inside-avoid">
|
||||
<div className="subtitle-container mb-1">
|
||||
<span className="blue-bar-subtitle h-[12px]"></span>
|
||||
<h4 className="text-[16px] font-semibold text-[#333]">{title}</h4>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md px-3 py-2 mb-2">
|
||||
<p className="text-[10px] text-gray-600 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full bg-white border-[0.5px] border-gray-100 rounded-lg p-2">
|
||||
<div className="echarts-canvas-container relative w-full" style={{ height: '320px' }}>
|
||||
<div ref={chartRef} className="echarts-inner w-full h-full" />
|
||||
<img className="echarts-export-image absolute inset-0 w-full h-full object-contain hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Page06ExportTaxProps {
|
||||
exportFreeTaxRebate: BarChartCompareItem[];
|
||||
exportTaxRebate: BarChartCompareItem[];
|
||||
chartDescriptions?: { exportFreeTaxRebate?: string; exportTaxRebate?: string };
|
||||
}
|
||||
|
||||
export const Page06ExportTax = ({ exportFreeTaxRebate = [], exportTaxRebate = [], chartDescriptions }: Page06ExportTaxProps) => {
|
||||
return (
|
||||
<ContentPage pageNumber={6} hidePageNumber={true}>
|
||||
<div className="flex flex-col space-y-8 -mx-[20px] px-[20px]">
|
||||
<ExportTaxBar title="不同出口免抵退税金额企业分布" data={exportFreeTaxRebate} description={chartDescriptions?.exportFreeTaxRebate} />
|
||||
<ExportTaxBar title="不同出口退税金额企业分布" data={exportTaxRebate} description={chartDescriptions?.exportTaxRebate} />
|
||||
</div>
|
||||
</ContentPage>
|
||||
);
|
||||
};
|
||||
20
src/components/Page07Association.tsx
Normal file
20
src/components/Page07Association.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ExportTaxBar } from './Page06ExportTax';
|
||||
import { ContentPage } from './ContentPage';
|
||||
import type { BarChartCompareItem } from '../types/data';
|
||||
|
||||
interface Page07AssociationProps {
|
||||
loginPersonAssociation: BarChartCompareItem[];
|
||||
legalPersonAssociation: BarChartCompareItem[];
|
||||
chartDescriptions?: { loginPersonAssociation?: string; legalPersonAssociation?: string };
|
||||
}
|
||||
|
||||
export const Page07Association = ({ loginPersonAssociation = [], legalPersonAssociation = [], chartDescriptions }: Page07AssociationProps) => {
|
||||
return (
|
||||
<ContentPage pageNumber={7} hidePageNumber={true}>
|
||||
<div className="flex flex-col space-y-8 -mx-[20px] px-[20px]">
|
||||
<ExportTaxBar title="登录人关联企业数量分布" data={loginPersonAssociation} description={chartDescriptions?.loginPersonAssociation} />
|
||||
<ExportTaxBar title="法定代表人关联企业数量分布" data={legalPersonAssociation} description={chartDescriptions?.legalPersonAssociation} />
|
||||
</div>
|
||||
</ContentPage>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user