chore: initial commit

This commit is contained in:
hailin
2026-05-18 13:52:47 +08:00
commit 0753129afe
148 changed files with 14202 additions and 0 deletions

View 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
View 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 };

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};