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

276
src/App-fixed.tsx Normal file
View File

@@ -0,0 +1,276 @@
import { useRef, useState } from 'react';
import html2pdf from 'html2pdf.js';
import * as echarts from 'echarts';
import { Download, Upload, RefreshCw, FileSpreadsheet, CheckCircle2, AlertCircle } from 'lucide-react';
import { CoverPage } from './components/CoverPage';
import { Page02Industry } from './components/Page02Industry';
import { Page03Duration } from './components/Page03Duration';
import { Page04Portrait } from './components/Page04Portrait';
import { Page05Risk } from './components/Page05Risk';
import { BackCover } from './components/BackCover';
import { defaultReportData } from './data/defaultData';
import { parseExcelReportData, generateExcelTemplate } from './utils/excelUtils';
import type { ReportData } from './types/data';
function App() {
const reportRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [reportData, setReportData] = useState<ReportData>(defaultReportData);
const [showImport, setShowImport] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [importStatus, setImportStatus] = useState<'idle' | 'success' | 'error'>('idle');
const handleDownloadPDF = async () => {
console.log('开始生成报告...');
const element = reportRef.current;
if (!element) {
console.error('报告元素未找到');
alert('无法找到报告内容,请刷新页面重试');
return;
}
console.log('找到报告元素');
try {
// 1. 切换到导出状态样式
console.log('应用导出样式...');
element.classList.add('is-exporting');
// 强制重绘
element.getBoundingClientRect();
await new Promise(resolve => setTimeout(resolve, 300));
// 2. 处理图表:将 ECharts 转换为图片
console.log('处理图表...');
const chartContainers = element.querySelectorAll('.echarts-canvas-container');
console.log(`找到 ${chartContainers.length} 个图表容器`);
const chartPromises = Array.from(chartContainers).map(async (container, index) => {
const inner = container.querySelector('.echarts-inner');
const exportImg = container.querySelector('.echarts-export-image') as HTMLImageElement;
if (inner && exportImg) {
try {
// 尝试获取图表实例
const instance = echarts.getInstanceByDom(inner as HTMLElement);
if (instance) {
const imgUrl = instance.getDataURL({
pixelRatio: 2,
backgroundColor: '#fff',
type: 'png'
});
exportImg.src = imgUrl;
console.log(`图表 ${index} 转换成功`);
} else {
console.warn(`图表 ${index} 实例未找到,使用透明占位`);
exportImg.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
}
} catch (error) {
console.error(`图表 ${index} 转换失败:`, error);
exportImg.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
}
}
});
await Promise.all(chartPromises);
console.log('图表处理完成');
// 3. 等待短暂时间让图片显示
await new Promise(resolve => setTimeout(resolve, 500));
// 4. 生成 PDF
console.log('开始生成PDF...');
const opt = {
margin: 0,
filename: `${reportData.companyName}_运营报告_${new Date().toLocaleDateString()}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
enableLinks: false,
html2canvas: {
scale: 2,
useCORS: true,
logging: false,
width: 794,
windowWidth: 794,
scrollY: 0,
letterRendering: true,
backgroundColor: '#ffffff',
onclone: (clonedDoc) => {
// 在克隆的文档中确保所有图片都显示
const imgs = clonedDoc.querySelectorAll('img');
imgs.forEach(img => {
if (img.style.display === 'none') {
img.style.display = 'block';
}
});
}
},
jsPDF: { unit: 'px', format: [794, 1123], orientation: 'portrait' },
pagebreak: { mode: ['avoid-all', 'css', 'legacy'] }
};
// 显示加载提示
alert('正在生成PDF请稍候...这可能需要几秒钟时间。');
// @ts-expect-error - html2pdf call signature
await html2pdf().set(opt).from(element).save();
console.log('PDF生成成功');
alert('PDF生成成功文件已开始下载。');
} catch (error) {
console.error('PDF导出失败:', error);
alert(`PDF生成失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
// 5. 恢复原始样式
console.log('恢复原始样式...');
element.classList.remove('is-exporting');
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsImporting(true);
setImportStatus('idle');
try {
const newData = await parseExcelReportData(file);
setReportData(newData);
setImportStatus('success');
setTimeout(() => {
setShowImport(false);
setImportStatus('idle');
}, 1500);
} catch (err) {
console.error('Import failed:', err);
setImportStatus('error');
} finally {
setIsImporting(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<div className="min-h-screen bg-gray-100 flex font-sans relative overflow-x-hidden">
{/* Control Panel (Sidebar) */}
<div className={`fixed left-0 top-0 h-full bg-white shadow-2xl z-[60] transition-all duration-300 no-print flex flex-col ${showImport ? 'w-[380px]' : 'w-0 overflow-hidden'}`}>
<div className="p-8 flex flex-col h-full w-[380px]">
<div className="flex items-center justify-between mb-8">
<h3 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<Upload size={22} className="text-blue-600" />
</h3>
<button onClick={() => setShowImport(false)} className="text-gray-400 hover:text-gray-600 transition-colors text-2xl">&times;</button>
</div>
<div className="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-8">
<h4 className="text-sm font-bold text-blue-800 mb-2 flex items-center gap-2">
<FileSpreadsheet size={16} />
</h4>
<p className="text-xs text-blue-600 mb-4 leading-relaxed">
使 Excel 7 Sheet
</p>
<button
onClick={generateExcelTemplate}
className="w-full bg-white border border-blue-200 text-blue-600 hover:bg-blue-50 py-2.5 rounded-lg text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm"
>
<Download size={14} />
Excel
</button>
</div>
<div className="flex-grow">
<h4 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
<RefreshCw size={16} className={isImporting ? 'animate-spin' : ''} />
</h4>
<div
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-10 flex flex-col items-center justify-center cursor-pointer transition-all ${
importStatus === 'success' ? 'border-green-400 bg-green-50' :
importStatus === 'error' ? 'border-red-400 bg-red-50' :
'border-gray-200 hover:border-blue-400 hover:bg-blue-50'
}`}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept=".xlsx, .xls"
/>
{isImporting ? (
<div className="flex flex-col items-center animate-pulse">
<RefreshCw size={40} className="text-blue-500 animate-spin mb-3" />
<span className="text-sm text-blue-600 font-medium">...</span>
</div>
) : importStatus === 'success' ? (
<div className="flex flex-col items-center text-green-600">
<CheckCircle2 size={40} className="mb-3" />
<span className="text-sm font-bold"></span>
</div>
) : importStatus === 'error' ? (
<div className="flex flex-col items-center text-red-600">
<AlertCircle size={40} className="mb-3" />
<span className="text-sm font-bold"></span>
</div>
) : (
<>
<Upload size={40} className="text-gray-300 mb-3" />
<span className="text-sm text-gray-500 font-medium"> Excel </span>
<span className="text-[10px] text-gray-400 mt-2"> .xlsx, .xls </span>
</>
)}
</div>
</div>
<div className="mt-auto pt-8 border-t border-gray-100 text-center">
<p className="text-[10px] text-gray-400">
v2.0
</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="fixed bottom-10 right-10 z-50 flex flex-col gap-3 no-print">
<button
onClick={() => setShowImport(!showImport)}
className="bg-white hover:bg-gray-50 text-blue-600 font-bold py-3 px-8 rounded-full shadow-2xl flex items-center gap-2 transition-all transform hover:scale-105 border border-blue-100 active:scale-95"
>
<Upload size={20} />
</button>
<button
onClick={handleDownloadPDF}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-full shadow-2xl flex items-center gap-2 transition-all transform hover:scale-105 active:scale-95"
>
<Download size={20} />
</button>
</div>
{/* Main Display Area - Fluid Content */}
<div className={`flex-1 flex flex-col transition-all duration-300 ${showImport ? 'pl-[380px]' : ''}`}>
<div
ref={reportRef}
className="bg-white shadow-2xl overflow-hidden print:shadow-none w-full"
>
<CoverPage data={reportData} />
<Page02Industry mapData={reportData.mapData} tableData={reportData.regionStatistics} />
<Page03Duration durationData={reportData.durationStatistics} funnelData={reportData.funnelData} />
<Page04Portrait data={reportData.sunburstData} />
<Page05Risk riskData={reportData.riskIndicators} />
<BackCover />
</div>
</div>
</div>
);
}
export default App;

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

358
src/App.tsx Normal file
View File

@@ -0,0 +1,358 @@
import { useRef, useState, useEffect } from 'react';
import html2pdf from 'html2pdf.js';
import * as echarts from 'echarts';
import { Download, Upload, RefreshCw, FileSpreadsheet, CheckCircle2, AlertCircle, Server } from 'lucide-react';
import { CoverPage } from './components/CoverPage';
import { Page02Industry } from './components/Page02Industry';
import { Page03Duration } from './components/Page03Duration';
import { Page04Portrait } from './components/Page04Portrait';
import { Page05Risk } from './components/Page05Risk';
import { Page06ExportTax } from './components/Page06ExportTax';
import { Page07Association } from './components/Page07Association';
import { BackCover } from './components/BackCover';
import { defaultReportData } from './data/defaultData';
import { parseExcelReportData, generateExcelTemplate } from './utils/excelUtils';
import type { ReportData } from './types/data';
function App() {
const reportRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [reportData, setReportData] = useState<ReportData>(defaultReportData);
const [showImport, setShowImport] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [importStatus, setImportStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [isServerGenerating, setIsServerGenerating] = useState(false);
// 暴露给 Puppeteer 的接口
useEffect(() => {
window.loadServerData = (data: ReportData) => {
console.log('Received data from server injection');
setReportData(data);
};
// 标记页面已加载,但还没准备好(需要等待图表渲染)
window.isReportReady = false;
// 检测是否是打印模式
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('print')) {
document.body.classList.add('print-mode');
}
return () => {
delete window.loadServerData;
};
}, []);
// 监听数据变化,设置渲染完成信号
useEffect(() => {
// 给 ECharts 一点渲染时间
const timer = setTimeout(() => {
window.isReportReady = true;
console.log('Report is ready for capture');
}, 2000);
return () => {
clearTimeout(timer);
window.isReportReady = false;
};
}, [reportData]);
const handleDownloadPDF = async () => {
console.log('开始生成报告...');
const element = reportRef.current;
if (!element) {
console.error('报告元素未找到');
alert('无法找到报告内容,请刷新页面重试');
return;
}
console.log('找到报告元素');
try {
// 1. 切换到导出状态样式
console.log('应用导出样式...');
element.classList.add('is-exporting');
// 强制重绘
element.getBoundingClientRect();
await new Promise(resolve => setTimeout(resolve, 300));
// 2. 处理图表:将 ECharts 转换为图片
console.log('处理图表...');
const chartContainers = element.querySelectorAll('.echarts-canvas-container');
console.log(`找到 ${chartContainers.length} 个图表容器`);
const chartPromises = Array.from(chartContainers).map(async (container, index) => {
const inner = container.querySelector('.echarts-inner');
const exportImg = container.querySelector('.echarts-export-image') as HTMLImageElement;
if (inner && exportImg) {
try {
// 尝试获取图表实例
const instance = echarts.getInstanceByDom(inner as HTMLElement);
if (instance) {
const imgUrl = instance.getDataURL({
pixelRatio: 2,
backgroundColor: '#fff',
type: 'png'
});
exportImg.src = imgUrl;
console.log(`图表 ${index} 转换成功`);
} else {
console.warn(`图表 ${index} 实例未找到,使用透明占位`);
exportImg.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
}
} catch (error) {
console.error(`图表 ${index} 转换失败:`, error);
exportImg.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
}
}
});
await Promise.all(chartPromises);
console.log('图表处理完成');
// 3. 等待短暂时间让图片显示
await new Promise(resolve => setTimeout(resolve, 500));
// 4. 生成 PDF
console.log('开始生成PDF...');
const opt = {
margin: 0,
filename: `${reportData.companyName}_运营报告_${new Date().toLocaleDateString()}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
enableLinks: false,
html2canvas: {
scale: 2,
useCORS: true,
logging: false,
width: 794,
windowWidth: 794,
scrollY: 0,
letterRendering: true,
backgroundColor: '#ffffff',
onclone: (clonedDoc: Document) => {
// 在克隆的文档中确保所有图片都显示
const imgs = clonedDoc.querySelectorAll('img');
imgs.forEach((img: HTMLImageElement) => {
if (img.style.display === 'none') {
img.style.display = 'block';
}
});
}
},
jsPDF: { unit: 'px', format: [794, 1123], orientation: 'portrait' },
pagebreak: { mode: ['avoid-all', 'css', 'legacy'] }
};
// 显示加载提示
alert('正在生成PDF请稍候...这可能需要几秒钟时间。');
// @ts-expect-error - html2pdf call signature
await html2pdf().set(opt).from(element).save();
console.log('PDF生成成功');
alert('PDF生成成功文件已开始下载。');
} catch (error) {
console.error('PDF导出失败:', error);
alert(`PDF生成失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
// 5. 恢复原始样式
console.log('恢复原始样式...');
element.classList.remove('is-exporting');
}
};
const handleServerGenerate = async () => {
setIsServerGenerating(true);
try {
const response = await fetch('/api/generate-pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(reportData),
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${reportData.companyName}_运营报告_服务端_${new Date().toLocaleDateString()}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
alert('服务端生成 PDF 成功!');
} catch (error) {
console.error('Server PDF generation failed:', error);
alert(`服务端生成失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsServerGenerating(false);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsImporting(true);
setImportStatus('idle');
try {
const newData = await parseExcelReportData(file);
setReportData(newData);
setImportStatus('success');
setTimeout(() => {
setShowImport(false);
setImportStatus('idle');
}, 1500);
} catch (err) {
console.error('Import failed:', err);
setImportStatus('error');
} finally {
setIsImporting(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<div className="min-h-screen bg-gray-100 flex font-sans relative overflow-x-hidden">
{/* Control Panel (Sidebar) */}
<div className={`fixed left-0 top-0 h-full bg-white shadow-2xl z-[60] transition-all duration-300 no-print flex flex-col ${showImport ? 'w-[380px]' : 'w-0 overflow-hidden'}`}>
<div className="p-8 flex flex-col h-full w-[380px]">
<div className="flex items-center justify-between mb-8">
<h3 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<Upload size={22} className="text-blue-600" />
</h3>
<button onClick={() => setShowImport(false)} className="text-gray-400 hover:text-gray-600 transition-colors text-2xl">&times;</button>
</div>
<div className="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-8">
<h4 className="text-sm font-bold text-blue-800 mb-2 flex items-center gap-2">
<FileSpreadsheet size={16} />
</h4>
<p className="text-xs text-blue-600 mb-4 leading-relaxed">
使 Excel 13 Sheet
</p>
<button
onClick={generateExcelTemplate}
className="w-full bg-white border border-blue-200 text-blue-600 hover:bg-blue-50 py-2.5 rounded-lg text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm"
>
<Download size={14} />
Excel
</button>
</div>
<div className="flex-grow">
<h4 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
<RefreshCw size={16} className={isImporting ? 'animate-spin' : ''} />
</h4>
<div
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-10 flex flex-col items-center justify-center cursor-pointer transition-all ${
importStatus === 'success' ? 'border-green-400 bg-green-50' :
importStatus === 'error' ? 'border-red-400 bg-red-50' :
'border-gray-200 hover:border-blue-400 hover:bg-blue-50'
}`}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept=".xlsx, .xls"
/>
{isImporting ? (
<div className="flex flex-col items-center animate-pulse">
<RefreshCw size={40} className="text-blue-500 animate-spin mb-3" />
<span className="text-sm text-blue-600 font-medium">...</span>
</div>
) : importStatus === 'success' ? (
<div className="flex flex-col items-center text-green-600">
<CheckCircle2 size={40} className="mb-3" />
<span className="text-sm font-bold"></span>
</div>
) : importStatus === 'error' ? (
<div className="flex flex-col items-center text-red-600">
<AlertCircle size={40} className="mb-3" />
<span className="text-sm font-bold"></span>
</div>
) : (
<>
<Upload size={40} className="text-gray-300 mb-3" />
<span className="text-sm text-gray-500 font-medium"> Excel </span>
<span className="text-[10px] text-gray-400 mt-2"> .xlsx, .xls </span>
</>
)}
</div>
</div>
<div className="mt-auto pt-8 border-t border-gray-100 text-center">
<p className="text-[10px] text-gray-400">
v2.0
</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="fixed bottom-10 right-10 z-50 flex flex-col gap-3 no-print">
<button
onClick={() => setShowImport(!showImport)}
className="bg-white hover:bg-gray-50 text-blue-600 font-bold py-3 px-8 rounded-full shadow-2xl flex items-center gap-2 transition-all transform hover:scale-105 border border-blue-100 active:scale-95"
>
<Upload size={20} />
</button>
{/* <button
onClick={handleDownloadPDF}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-full shadow-2xl flex items-center gap-2 transition-all transform hover:scale-105 active:scale-95"
>
<Download size={20} />
浏览器生成
</button> */}
<button
onClick={handleServerGenerate}
disabled={isServerGenerating}
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-bold py-3 px-8 rounded-full shadow-2xl flex items-center gap-2 transition-all transform hover:scale-105 active:scale-95"
>
{isServerGenerating ? <RefreshCw size={20} className="animate-spin" /> : <Server size={20} />}
PDF
</button>
</div>
{/* Main Display Area - Fluid Content */}
<div className={`flex-1 flex flex-col transition-all duration-300 ${showImport ? 'pl-[380px]' : ''}`}>
<div
ref={reportRef}
className="bg-white shadow-2xl overflow-hidden print:overflow-visible print:shadow-none w-full"
>
<CoverPage data={reportData} />
<Page02Industry mapData={reportData.mapData} tableData={reportData.regionStatistics} />
<Page03Duration durationData={reportData.durationStatistics} funnelData={reportData.funnelData} />
<Page04Portrait data={reportData.sunburstData} poorCustomerData={reportData.poorCustomerData} goodCustomerData={reportData.goodCustomerData} allDescription={reportData.portraitDescriptions?.all} poorDescription={reportData.portraitDescriptions?.poor} goodDescription={reportData.portraitDescriptions?.good} />
<Page05Risk riskData={reportData.riskIndicators} />
<Page06ExportTax exportFreeTaxRebate={reportData.exportFreeTaxRebate} exportTaxRebate={reportData.exportTaxRebate} chartDescriptions={reportData.chartDescriptions} />
<Page07Association loginPersonAssociation={reportData.loginPersonAssociation} legalPersonAssociation={reportData.legalPersonAssociation} chartDescriptions={reportData.chartDescriptions} />
<BackCover riskScanning={reportData.riskScanning} riskScanningDescription={reportData.chartDescriptions?.riskScanning} />
</div>
</div>
</div>
);
}
export default App;

1
src/assets/china.json Normal file

File diff suppressed because one or more lines are too long

126
src/assets/images/10@2x.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 723 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 560 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 484 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 214 KiB

124
src/assets/images/3@2x.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 774 KiB

221
src/assets/images/4@2x.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 338 KiB

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,35 @@
<svg width="105" height="36" viewBox="0 0 105 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7455 0C11.9231 0 5.03327 3.3306 1.00099 8.38444C-0.752414 10.5816 -0.13011 13.8256 2.30355 15.2314C4.1022 16.2696 6.38266 15.9831 7.84714 14.5099C12.4009 9.9275 19.9194 6.93346 28.4245 6.93346C32.0186 6.93346 35.4349 7.46845 38.5274 8.43207C34.4999 3.35124 27.5926 0 19.7455 0Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.1557 7.85349C28.9291 7.85349 25.8453 8.28529 23.01 9.07032C26.4478 10.3887 29.4053 12.2565 31.6437 14.5099C33.1082 15.9831 35.3887 16.2697 37.1873 15.2315C39.6043 13.836 40.2329 10.6277 38.5256 8.43134C36.4912 8.05431 34.3568 7.85349 32.1557 7.85349Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7448 36C27.5672 36 34.457 32.6694 38.4893 27.6156C40.2427 25.4184 39.6204 22.1744 37.1868 20.7686C35.3881 19.7304 33.1076 20.0169 31.6432 21.4901C27.0894 26.0725 19.5709 29.0673 11.0658 29.0673C7.47171 29.0673 4.05538 28.5316 0.962912 27.5679C4.99043 32.6488 11.8977 36 19.7448 36Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.33467 28.147C10.5613 28.147 13.645 27.7144 16.4803 26.9301C13.0426 25.6117 10.085 23.7432 7.84664 21.4897C6.38216 20.0165 4.1017 19.73 2.30305 20.769C-0.113939 22.1636 -0.742593 25.3728 0.964775 27.5683C2.99917 27.9454 5.13358 28.147 7.33467 28.147Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M91.3893 6.18185H101.916C101.986 6.18185 102.043 6.12549 102.043 6.05484V4.77849C102.043 4.70864 101.986 4.65228 101.916 4.65228H91.3893C91.3194 4.65228 91.2623 4.70864 91.2623 4.77849V6.05484C91.2623 6.12549 91.3194 6.18185 91.3893 6.18185Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M104.794 11.2916H88.5105C88.4406 11.2916 88.3843 11.348 88.3843 11.4186V12.695C88.3843 12.7649 88.4406 12.8212 88.5105 12.8212H93.1603L90.0035 21.5335C89.8226 22.032 90.1924 22.559 90.7227 22.559H102.718C103.249 22.559 103.618 22.0312 103.436 21.5319L101.684 16.7257C101.659 16.6598 101.587 16.6257 101.521 16.6495L100.322 17.0877C100.256 17.1115 100.222 17.1837 100.246 17.2496L101.625 21.0294H91.8181L94.7883 12.8212H104.794C104.864 12.8212 104.921 12.7649 104.921 12.695V11.4186C104.921 11.348 104.864 11.2916 104.794 11.2916Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M52.1665 5.06447H49.2399C49.1693 5.06447 49.1129 5.12083 49.1129 5.19147V6.46783C49.1129 6.53768 49.1693 6.59404 49.2399 6.59404H52.1665C52.2363 6.59404 52.2935 6.53768 52.2935 6.46783V5.19147C52.2935 5.12083 52.2363 5.06447 52.1665 5.06447Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M52.1667 8.77671H48.2003C48.1304 8.77671 48.0741 8.83307 48.0741 8.90371V10.1801C48.0741 10.2499 48.1304 10.3063 48.2003 10.3063H52.1667C52.2365 10.3063 52.2937 10.2499 52.2937 10.1801V8.90371C52.2937 8.83307 52.2365 8.77671 52.1667 8.77671Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.8123 12.1679L50.555 11.9457C50.4867 11.9338 50.4208 11.9798 50.4081 12.0481L48.5992 22.2852C48.5873 22.3542 48.6325 22.4201 48.7016 22.432L49.9589 22.6543C50.0279 22.6662 50.093 22.6201 50.1057 22.5519L51.9147 12.3148C51.9274 12.2457 51.8813 12.1799 51.8123 12.1679Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.3347 11.8501C53.3529 11.9184 53.4236 11.9581 53.491 11.9398C55.3317 11.4302 57.105 10.7 58.7743 9.77525C60.4467 10.7016 62.2215 11.4302 64.0607 11.9398C64.1281 11.9581 64.1988 11.9184 64.217 11.8501L64.5512 10.6182C64.5695 10.5508 64.5298 10.4825 64.4631 10.4634C63.0288 10.0666 61.6358 9.52602 60.3054 8.85609C61.5905 8.01709 62.7986 7.05664 63.9099 5.98586C64.1099 5.79219 64.2067 5.50644 64.1401 5.23577C64.0543 4.8881 63.7456 4.65235 63.3971 4.65235H53.5323C53.4625 4.65235 53.4061 4.70871 53.4061 4.77856V6.05571C53.4061 6.12557 53.4625 6.18192 53.5323 6.18192H61.381C58.9402 8.16155 56.1088 9.62682 53.0886 10.4634C53.0219 10.4825 52.9822 10.5508 53.0005 10.6182L53.3347 11.8501Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.6548 21.0295H59.5565V13.9754H62.9363C63.0062 13.9754 63.0625 13.9183 63.0625 13.8484V12.5721C63.0625 12.5022 63.0062 12.4458 62.9363 12.4458H54.6471C54.5773 12.4458 54.5209 12.5022 54.5209 12.5721V13.8484C54.5209 13.9183 54.5773 13.9754 54.6471 13.9754H58.0269V21.0295H52.9286C52.8588 21.0295 52.8024 21.0859 52.8024 21.1565V22.4329C52.8024 22.5027 52.8588 22.5591 52.9286 22.5591H64.6548C64.7246 22.5591 64.7818 22.5027 64.7818 22.4329V21.1565C64.7818 21.0859 64.7246 21.0295 64.6548 21.0295Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.3222 5.06447H69.3956C69.325 5.06447 69.2686 5.12083 69.2686 5.19147V6.46783C69.2686 6.53768 69.325 6.59404 69.3956 6.59404H72.3222C72.392 6.59404 72.4492 6.53768 72.4492 6.46783V5.19147C72.4492 5.12083 72.392 5.06447 72.3222 5.06447Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.3221 8.77671H68.3557C68.2859 8.77671 68.2295 8.83307 68.2295 8.90371V10.1801C68.2295 10.2499 68.2859 10.3063 68.3557 10.3063H72.3221C72.392 10.3063 72.4491 10.2499 72.4491 10.1801V8.90371C72.4491 8.83307 72.392 8.77671 72.3221 8.77671Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M71.9678 12.1679L70.7105 11.9457C70.6422 11.9338 70.5763 11.9798 70.5636 12.0481L68.7546 22.2852C68.7427 22.3542 68.788 22.4201 68.857 22.432L70.1143 22.6543C70.1834 22.6662 70.2485 22.6201 70.2612 22.5519L72.0702 12.3148C72.0829 12.2457 72.0368 12.1799 71.9678 12.1679Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.3645 14.6696H82.6756V13.0598H76.3645V14.6696ZM76.3645 17.5072H82.5994V15.8975H76.3645V17.5072ZM83.4408 11.8279H75.6001C75.1778 11.8279 74.8349 12.1708 74.8349 12.5931V15.2839V18.1216V22.4325C74.8349 22.5023 74.8921 22.5595 74.9619 22.5595H76.2383C76.3081 22.5595 76.3645 22.5023 76.3645 22.4325V18.736H82.6756V21.3308H80.4158C80.3452 21.3308 80.2888 21.3879 80.2888 21.4578V22.4325C80.2888 22.5023 80.3452 22.5595 80.4158 22.5595H83.4408C83.8631 22.5595 84.2052 22.2166 84.2052 21.7943V12.5931C84.2052 12.1708 83.8631 11.8279 83.4408 11.8279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M80.2847 6.95346H83.3343V5.52787H80.2847V6.95346ZM80.2847 9.3625H83.3343V8.1814H80.2847V9.3625ZM75.7055 6.95346H78.7551V5.52787H75.7055V6.95346ZM75.7055 9.3625H78.7551V8.1814H75.7055V9.3625ZM84.0995 4.29914H74.9411C74.5189 4.29914 74.176 4.64204 74.176 5.06432V9.82606C74.176 10.2483 74.5189 10.5912 74.9411 10.5912H84.0995C84.5218 10.5912 84.8639 10.2483 84.8639 9.82606V5.06432C84.8639 4.64204 84.5218 4.29914 84.0995 4.29914Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.5705 27.4989C52.0269 27.9149 52.4905 28.2729 52.9778 28.5848H50.1632C50.6505 28.2729 51.1141 27.9149 51.5705 27.4989ZM53.5652 31.0574L52.9588 30.8621C52.8802 31.1042 52.623 31.7773 52.4095 32.1591H51.8896V30.5914H53.9549V29.9548H51.8896V29.2222H53.3295V28.7991C53.6358 28.9769 53.951 29.1388 54.2828 29.2873L54.5423 28.7055C53.5604 28.2681 52.7183 27.7029 51.892 26.9274L51.7999 26.8417H51.3411L51.249 26.9274C50.4227 27.7029 49.5805 28.2681 48.5995 28.7055L48.8582 29.2873C49.19 29.1388 49.5059 28.9769 49.8115 28.7991V29.2222H51.2514V29.9548H49.186V30.5914H51.2514V32.1591H50.7315C50.518 31.7765 50.2608 31.1042 50.183 30.8621L49.5766 31.0574C49.6361 31.2439 49.8187 31.736 50.0179 32.1591H48.7288V32.7965H50.3854H52.7564H54.4121V32.1591H53.1239C53.3382 31.7043 53.5247 31.1812 53.5652 31.0574Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M57.3293 30.0795H57.6119V28.3626H57.3293V30.0795ZM58.181 30.0795H58.4636V28.3626H58.181V30.0795ZM61.0774 28.5412H61.6124V28.1031H61.0774V28.5412ZM61.8735 31.0876H62.7149V30.4502H61.7005H61.2743V30.0208H62.607V29.3834H60.0662V28.1031H60.5083V28.8524C60.5083 28.9945 60.6234 29.1103 60.7662 29.1103H61.9235C62.0656 29.1103 62.1815 28.9945 62.1815 28.8524V28.1031H62.7149V27.4657H62.1815V26.8402H61.6124V27.4657H61.0774V26.8402H60.5083V27.4657H60.0662V26.8402H59.497V27.4657H59.1962V28.1031H59.497V29.7636C59.497 29.9057 59.6121 30.0208 59.755 30.0208H60.6369V30.4502H60.2098H59.1962V31.0876H60.0376C59.8836 31.4686 59.6272 31.8131 59.2676 32.1147L59.0279 30.9042L58.4691 31.0154L58.6199 31.7742L58.181 31.8305V30.6486H58.7755C58.9176 30.6486 59.0327 30.5328 59.0327 30.3907V28.0515C59.0327 27.9086 58.9176 27.7935 58.7755 27.7935H58.181V26.8402H57.6119V27.7935H57.0181C56.8761 27.7935 56.761 27.9086 56.761 28.0515V30.3907C56.761 30.5328 56.8761 30.6486 57.0181 30.6486H57.6119V31.9036L56.5879 32.0345L56.6601 32.5989L59.0644 32.2909L59.4288 32.7965C60.0074 32.3798 60.4114 31.8813 60.6369 31.3106V32.7965H61.2743V31.3106C61.4997 31.8813 61.9037 32.3798 62.4823 32.7965L62.8546 32.2798C62.3831 31.9393 62.0561 31.5392 61.8735 31.0876Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M66.7264 27.0253L66.1319 26.7959C65.8699 27.4746 65.3818 28.0556 64.7563 28.4319L65.0857 28.978C65.8311 28.5295 66.4145 27.8357 66.7264 27.0253Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M66.8489 28.674L66.2544 28.4446C65.9694 29.1844 65.4376 29.8162 64.7566 30.2266L65.086 30.7735C65.2955 30.6457 65.4924 30.5005 65.6765 30.3417V32.796H66.3139V29.6464C66.533 29.3479 66.7147 29.0209 66.8489 28.674Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.4271 32.1592V29.9954H70.84V29.358H69.4271V27.7475H70.84V27.1101H66.991V27.7475H68.7897V32.1592H67.9634V28.692H67.326V32.1592H66.8323V32.7966H70.9987V32.1592H69.4271Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M74.8926 26.9943L74.275 26.8355C73.9797 27.9849 73.4344 28.9628 72.7399 29.5922L73.1669 30.0661C73.359 29.8915 73.5392 29.6946 73.7091 29.4803V32.7966H74.3464V28.5103H74.321C74.5576 28.0468 74.7528 27.5388 74.8926 26.9943Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M77.3886 27.5663C77.264 27.3258 77.1211 26.952 77.0775 26.8162L76.471 27.0131C76.4925 27.0782 76.5782 27.3179 76.6853 27.5663H74.7851V28.2037H79.1301V27.5663H77.3886Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M75.3476 29.1778H78.5687V28.6087H75.3476V29.1778Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M75.3469 30.1137H78.5679V29.5445H75.3469V30.1137Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M75.8239 32.2274H78.0916V31.0932H75.8239V32.2274ZM78.456 30.524H75.4595C75.3087 30.524 75.1865 30.6471 75.1865 30.7979V32.5235C75.1865 32.6743 75.3087 32.7966 75.4595 32.7966H78.456C78.6068 32.7966 78.729 32.6743 78.729 32.5235V30.7979C78.729 30.6471 78.6068 30.524 78.456 30.524Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M81.8922 27.7475H86.6404V27.1775H81.8922V27.7475Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M87.2291 29.5771V29.0072H83.1127L83.0683 28.9889L83.0603 29.0072H81.2569V29.5771H82.8206L81.6236 32.4195C81.5474 32.5997 81.68 32.7997 81.8753 32.7997H86.6576C86.8529 32.7997 86.9855 32.5997 86.9093 32.4187L86.2346 30.8312L85.7107 31.0527L86.2107 32.2306H82.3206L83.4382 29.5771H87.2291Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M102.64 30.3802H100.731V29.8897H100.093V30.3802H97.9707V31.0184H99.9035C99.5868 31.5089 98.8843 31.9233 97.8913 32.1797L98.0501 32.7964C99.7622 32.3551 100.392 31.601 100.615 31.0184H102.275V31.4002C102.275 31.8193 101.936 32.159 101.516 32.159H100.783V32.7964H101.517C102.288 32.7964 102.913 32.1717 102.913 31.4002V30.6541C102.913 30.5024 102.79 30.3802 102.64 30.3802Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M99.7461 28.0214H101.619C101.327 28.273 101.013 28.4945 100.683 28.685C100.352 28.4945 100.037 28.273 99.7461 28.0214ZM101.354 29.0191C101.823 28.7096 102.254 28.3397 102.636 27.9158L102.4 27.384H99.716C99.7675 27.3023 99.8176 27.2189 99.8644 27.1324L99.3032 26.8292C98.9452 27.492 98.4086 28.0166 97.7927 28.3048L98.0634 28.8818C98.4102 28.7199 98.7333 28.4945 99.0238 28.223C99.3286 28.5207 99.6588 28.7866 100.01 29.0191C99.354 29.2962 98.6499 29.4581 97.9284 29.4914V30.1264C98.8936 30.0875 99.8326 29.8399 100.683 29.4097C101.532 29.8399 102.471 30.0875 103.436 30.1264V29.4914C102.715 29.4581 102.011 29.2962 101.354 29.0191Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M90.3535 28.4825H91.3124V27.5506H90.3535V28.4825ZM90.3535 30.0097H91.3124V29.0508H90.3535V30.0097ZM91.6077 26.9807H90.0575C89.9067 26.9807 89.7844 27.1037 89.7844 27.2545V28.4825V30.1811C89.7844 31.1908 89.7201 31.7051 89.3613 32.5806L89.8876 32.7965C90.2345 31.9512 90.3297 31.3908 90.3488 30.5788H91.3124V31.8385C91.3124 32.0544 91.137 32.2298 90.9203 32.2298V32.7989C91.4505 32.7989 91.8815 32.3679 91.8815 31.8385V30.5788V28.4825V27.2545C91.8815 27.1037 91.7593 26.9807 91.6077 26.9807Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M94.4314 28.1532C94.4314 28.3175 94.2981 28.4508 94.1338 28.4508H93.3273V29.0199H94.1338C94.6124 29.0199 95.0006 28.6326 95.0006 28.1532V27.2546C95.0006 27.103 94.8783 26.9808 94.7267 26.9808H92.1129V32.7974H92.682V27.5499H94.4314V28.1532Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M93.5292 29.8469H94.4007C94.3515 30.285 94.2015 30.7081 93.9681 31.0883C93.7332 30.7057 93.58 30.2811 93.5292 29.8469ZM94.3777 31.6249C94.8119 31.0073 95.0564 30.2787 95.0564 29.5286V29.2095H92.8727V29.5286C92.8727 30.2676 93.1228 31.001 93.5593 31.6225C93.3331 31.8622 93.068 32.0726 92.7704 32.244L93.0879 32.7965C93.4165 32.6084 93.7117 32.3774 93.9681 32.117C94.2229 32.3798 94.5166 32.6099 94.8413 32.7965L95.1588 32.244C94.8659 32.0765 94.6031 31.8654 94.3777 31.6249Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,3 @@
<svg width="2" height="19" viewBox="0 0 2 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.576513" d="M0.578029 0.00184827L0.508818 18.9982" stroke="white"/>
</svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@@ -0,0 +1,3 @@
<svg width="372" height="1" viewBox="0 0 372 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0.25H372" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 184 B

View File

@@ -0,0 +1,3 @@
<svg width="372" height="1" viewBox="0 0 372 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0.25H372" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 184 B

View File

@@ -0,0 +1,3 @@
<svg width="344" height="1" viewBox="0 0 344 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0.250727H345.502" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 192 B

View File

@@ -0,0 +1,3 @@
<svg width="344" height="1" viewBox="0 0 344 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0.250727H345.502" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 192 B

View File

@@ -0,0 +1,3 @@
<svg width="331" height="1" viewBox="0 0 331 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.00024451 0.250359L331 0.579427" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@@ -0,0 +1,3 @@
<svg width="359" height="1" viewBox="0 0 359 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.000244481 0.722434L359 0.250348" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 207 B

View File

@@ -0,0 +1,3 @@
<svg width="289" height="1" viewBox="0 0 289 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.000489126 0.250446L289 0.69407" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@@ -0,0 +1,3 @@
<svg width="289" height="1" viewBox="0 0 289 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0.250416L289 0.321968" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 197 B

View File

@@ -0,0 +1,3 @@
<svg width="302" height="1" viewBox="0 0 302 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.000244949 0.586001L303.502 0.249175" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 211 B

View File

@@ -0,0 +1,3 @@
<svg width="317" height="1" viewBox="0 0 317 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.000244526 0.565176L317 0.250395" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 207 B

View File

@@ -0,0 +1,3 @@
<svg width="277" height="1" viewBox="0 0 277 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.000490035 0.250908L278.497 0.709693" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 211 B

View File

@@ -0,0 +1,3 @@
<svg width="359" height="1" viewBox="0 0 359 1" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.000244481 0.250354L359 0.479243" stroke="#979797" stroke-width="0.5" stroke-dasharray="3"/>
</svg>

After

Width:  |  Height:  |  Size: 207 B

View File

@@ -0,0 +1,9 @@
<svg width="222" height="301" viewBox="0 0 222 301" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M54.0853 0L0 300.788H167.269L222 0H54.0853Z" fill="url(#paint0_linear_6_14)"/>
<defs>
<linearGradient id="paint0_linear_6_14" x1="9.09855" y1="52.5665" x2="130.709" y2="229.325" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.01"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 491 B

View File

@@ -0,0 +1,9 @@
<svg width="222" height="481" viewBox="0 0 222 481" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.2" fill-rule="evenodd" clip-rule="evenodd" d="M185.693 0L0 195.389L221.229 481V0H185.693Z" fill="url(#paint0_linear_6_15)"/>
<defs>
<linearGradient id="paint0_linear_6_15" x1="99.432" y1="-72.2036" x2="-99.6703" y2="141.781" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.01"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 492 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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

26
src/constants.ts Normal file
View File

@@ -0,0 +1,26 @@
import logo from './assets/images/logo.png';
import coverBg from './assets/images/封面背景.png';
import coverBg1 from './assets/images/封面背景-1.png';
import coverBg2 from './assets/images/封面背景-2.png';
import qrCode1 from './assets/images/泾渭云二维码.png';
import qrCode2 from './assets/images/改善计划二维码.png';
export const ASSETS = {
logo,
coverBg,
coverBg1,
coverBg2,
qrCode1,
qrCode2,
};
export const reportItems = [
{ title: '重点地区采集数据统计', value: '02' },
{ title: '重点地区采集时长分布', value: '03' },
{ title: '页面漏损分析', value: '03' },
{ title: '企业全景洞察', value: '04' },
{ title: '税务与进出口情况分析', value: '05' },
{ title: '出口退税金额企业分布', value: '06' },
{ title: '关联企业数量分布', value: '07' },
{ title: '企业风险扫描结果分布', value: '08' },
];

194
src/data/defaultData.ts Normal file
View File

@@ -0,0 +1,194 @@
import type { ReportData } from '../types/data';
export const defaultReportData: ReportData = {
companyName: '百信',
stats: {
successRate: '99.99%',
collectedCount: 999,
growthRate: '9%',
fiveMinRate: '99%',
avgDuration: 2,
},
mapData: [
{ name: '广东省', value: 9058 },
{ name: '江苏省', value: 4712 },
{ name: '四川省', value: 4560 },
{ name: '山东省', value: 3229 },
{ name: '浙江省', value: 3031 },
{ name: '河南省', value: 2779 },
{ name: '河北省', value: 2714 },
{ name: '湖北省', value: 1786 },
{ name: '湖南省', value: 1777 },
{ name: '上海市', value: 1621 },
],
regionStatistics: [
{ region: '广东省', count: 6726, conversion: '85.96%', success: '99.62%', duration: '10.62' },
{ region: '江苏省', count: 4712, conversion: '89.37%', success: '99.53%', duration: '4.91' },
{ region: '四川省', count: 4560, conversion: '89.04%', success: '99.78%', duration: '3.43' },
{ region: '山东省', count: 3229, conversion: '87.27%', success: '99.33%', duration: '7.34' },
{ region: '浙江省', count: 3031, conversion: '89.15%', success: '99.93%', duration: '5.65' },
{ region: '河南省', count: 3031, conversion: '89.15%', success: '99.93%', duration: '5.65' },
{ region: '河北省', count: 3031, conversion: '89.15%', success: '99.93%', duration: '5.65' },
{ region: '深圳市', count: 3031, conversion: '89.15%', success: '99.93%', duration: '5.65' },
{ region: '湖北省', count: 2800, conversion: '88.00%', success: '99.00%', duration: '6.00' },
{ region: '湖南省', count: 2500, conversion: '87.00%', success: '99.10%', duration: '6.50' },
],
durationStatistics: [
{ region: '广东省', p0_5: '77.11%', p5_10: '16.77%', p10_20: '99.62%', p20plus: '10.62' },
{ region: '江苏省', p0_5: '86.47%', p5_10: '11.86%', p10_20: '99.53%', p20plus: '4.91' },
{ region: '四川省', p0_5: '92.09%', p5_10: '89.04%', p10_20: '99.78%', p20plus: '3.43' },
{ region: '山东省', p0_5: '36.25%', p5_10: '87.27%', p10_20: '99.33%', p20plus: '7.34' },
{ region: '浙江省', p0_5: '85.21%', p5_10: '89.15%', p10_20: '99.93%', p20plus: '5.65' },
{ region: '河南省', p0_5: '92.58%', p5_10: '89.15%', p10_20: '99.93%', p20plus: '5.65' },
{ region: '河北省', p0_5: '82.06%', p5_10: '89.15%', p10_20: '99.93%', p20plus: '5.65' },
{ region: '深圳市', p0_5: '61.09%', p5_10: '89.15%', p10_20: '99.93%', p20plus: '5.65' },
{ region: '湖北省', p0_5: '75.00%', p5_10: '15.00%', p10_20: '5.00%', p20plus: '5.00' },
{ region: '湖南省', p0_5: '70.00%', p5_10: '20.00%', p10_20: '5.00%', p20plus: '5.00' },
],
funnelData: [
{ label: '录入企业信息', value: 74 },
{ label: '查询税局维护状态', value: 6 },
{ label: '四要素异常', value: 6 },
{ label: '税局维护中', value: 0 },
{ label: '签署授权', value: 29 },
{ label: '登录税局(新版)', value: 100 },
{ label: '登录帮助', value: 3 },
{ label: '忘记密码', value: 18 },
],
sunburstData: [
{
name: '制造业',
itemStyle: { color: '#1B65B5' },
children: [
{ name: '金属制品业', value: 146, itemStyle: { color: '#1B65B5' } },
{ name: '通用设备制造业', value: 109, itemStyle: { color: '#1B65B5' } },
{ name: '电气机械和器材制造业', value: 102, itemStyle: { color: '#1B65B5' } },
],
},
{
name: '批发和零售业',
itemStyle: { color: '#2386EE' },
children: [
{ name: '批发业', value: 250, itemStyle: { color: '#2386EE' } },
{ name: '零售业', value: 120, itemStyle: { color: '#2386EE' } },
],
},
],
poorCustomerData: [
{
name: '制造业',
itemStyle: { color: '#1B65B5' },
children: [
{ name: '金属制品业', value: 30, itemStyle: { color: '#1B65B5' } },
{ name: '通用设备制造业', value: 20, itemStyle: { color: '#1B65B5' } },
{ name: '电气机械和器材制造业', value: 15, itemStyle: { color: '#1B65B5' } },
],
},
{
name: '批发和零售业',
itemStyle: { color: '#2386EE' },
children: [
{ name: '批发业', value: 50, itemStyle: { color: '#2386EE' } },
{ name: '零售业', value: 25, itemStyle: { color: '#2386EE' } },
],
},
],
goodCustomerData: [
{
name: '制造业',
itemStyle: { color: '#1B65B5' },
children: [
{ name: '金属制品业', value: 116, itemStyle: { color: '#1B65B5' } },
{ name: '通用设备制造业', value: 89, itemStyle: { color: '#1B65B5' } },
{ name: '电气机械和器材制造业', value: 87, itemStyle: { color: '#1B65B5' } },
],
},
{
name: '批发和零售业',
itemStyle: { color: '#2386EE' },
children: [
{ name: '批发业', value: 200, itemStyle: { color: '#2386EE' } },
{ name: '零售业', value: 95, itemStyle: { color: '#2386EE' } },
],
},
],
riskIndicators: {
invoiceRisk: { label: '发票高风险记录', value: 0.08, marketValue: 0.05, description: '统计近1年有效开票记录中存在发票高风险的企业占比情况。' },
highTech: { label: '高新技术企业', value: 4.25, marketValue: 3.80, description: '统计近1年有效开票记录中被认定为高新技术企业的占比情况。' },
importExport: { label: '进出口企业', value: 1.94, marketValue: 1.50, description: '统计近1年有效开票记录中具有进出口资质的企业占比情况。' },
prepaidTax: { label: '预缴税款企业', value: 12.21, marketValue: 10.50, description: '统计近1年有效开票记录中存在预缴税款行为的企业占比情况。' },
},
exportFreeTaxRebate: [
{ label: '出口免抵退税额0到100万', customerValue: 71.43, marketValue: 75 },
{ label: '出口免抵退税额100到500万', customerValue: 16.67, marketValue: 18 },
{ label: '出口免抵退税额500到1000万', customerValue: 0, marketValue: 2 },
{ label: '出口免抵退税额1000万以上', customerValue: 11.9, marketValue: 5 },
],
exportTaxRebate: [
{ label: '出口退税额0到100万', customerValue: 68.33, marketValue: 70 },
{ label: '出口退税额100到500万', customerValue: 21.67, marketValue: 20 },
{ label: '出口退税额500到1000万', customerValue: 8.33, marketValue: 7 },
{ label: '出口退税额1000万以上', customerValue: 1.67, marketValue: 3 },
],
loginPersonAssociation: [
{ label: '登录人关联企业数量: 0~3', customerValue: 90.06, marketValue: 92 },
{ label: '登录人关联企业数量: 4~10', customerValue: 8.58, marketValue: 7 },
{ label: '登录人关联企业数量: 10+', customerValue: 1.36, marketValue: 1 },
],
legalPersonAssociation: [
{ label: '法人代表关联企业数量: 0~3', customerValue: 92.91, marketValue: 90 },
{ label: '法人代表关联企业数量: 4~10', customerValue: 6.49, marketValue: 8 },
{ label: '法人代表关联企业数量: 10+', customerValue: 0.61, marketValue: 2 },
],
riskScanning: [
{ label: '无风险', customerValue: 77.58, marketValue: 75 },
{ label: '低风险', customerValue: 12.41, marketValue: 13 },
{ label: '中风险', customerValue: 10.01, marketValue: 9 },
{ label: '高风险', customerValue: 0, marketValue: 3 },
],
portraitDescriptions: {
all: '基于行业分布与企业特征,全面展示企业画像数据,帮助您了解整体客户结构。',
poor: '识别可能存在合作风险或转化困难的客户群体,帮助您提前制定应对策略。',
good: '展示高价值、高忠诚度的优质客户特征,帮助您优化服务策略和资源配置。',
},
chartDescriptions: {
exportFreeTaxRebate: '近1年有效开票金额最大月份占比',
exportTaxRebate: '近1年有效开票金额最大月份占比',
loginPersonAssociation: '近1年有效开票金额最大月份占比',
legalPersonAssociation: '近1年有效开票金额最大月份占比',
riskScanning: '近1年有效开票金额最大月份占比',
},
summaryData: {
totalUsers: '9,999+',
userGrowth: '9%',
avgDuration: '2.5分钟',
successRate: '99.99%',
topRegions: [
{ label: '广东省', value: 9058 },
{ label: '江苏省', value: 4712 },
{ label: '四川省', value: 4560 },
{ label: '山东省', value: 3229 },
{ label: '浙江省', value: 3031 },
],
industryDistribution: [
{ label: '制造业', value: 357 },
{ label: '批发零售业', value: 370 },
{ label: '信息技术业', value: 120 },
{ label: '金融业', value: 85 },
{ label: '建筑业', value: 67 },
],
riskDistribution: [
{ label: '无风险', value: 600 },
{ label: '低风险', value: 200 },
{ label: '中风险', value: 100 },
{ label: '高风险', value: 99 },
],
recommendations: [
'加强广东省等重点区域的用户支持服务,提升转化率',
'优化制造业和批发零售业的专项服务流程',
'建立中高风险企业的预警机制和专项服务通道',
'提升科技企业和高新技术企业的服务覆盖率',
'加强进出口企业的税务合规指导服务',
],
},
};

189
src/index.css Normal file
View File

@@ -0,0 +1,189 @@
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Noto+Sans+SC:wght@400;500;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'PingFang SC', 'Noto Sans SC', system-ui, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
background-color: #f0f0f0;
}
body {
margin: 0;
display: flex;
justify-content: center;
min-height: 100vh;
background-color: #e5e5e5;
}
/* Ensure print styles are clean */
@media print {
body {
background-color: white;
margin: 0;
padding: 0;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.no-print {
display: none !important;
}
/* Reset fluid padding for print */
.pl-\[380px\] {
padding-left: 0 !important;
}
@page {
size: A4;
margin: 0;
}
/* 强制每个页面组件占满一页,并且后面强制分页 */
.print-page {
width: 210mm !important;
min-height: 297mm !important; /* 改为 min-height允许微量溢出而不被切断 */
page-break-after: always !important;
break-after: page !important;
overflow: hidden !important;
position: relative !important;
box-sizing: border-box !important;
background-color: white !important;
margin: 0 !important;
padding: 0 !important;
}
/* 最后一页不需要分页 */
.print-page:last-child {
page-break-after: avoid !important;
break-after: avoid !important;
}
}
/* 打印模式(模拟打印环境,如 Puppeteer 注入 print-mode 时) */
.print-mode .print-page {
width: 210mm !important;
min-height: 297mm !important;
page-break-after: always !important;
break-after: page !important;
margin: 0 auto !important;
box-shadow: none !important;
overflow: hidden !important;
}
.print-mode {
overflow: visible !important;
}
.print-mode body {
overflow: visible !important;
background-color: white !important;
}
.print-mode .print-page:last-child {
page-break-after: avoid !important;
break-after: avoid !important;
}
/* 非打印预览模式下,为了开发查看方便,也可以给个间距 */
.print-page {
margin: 0 auto 20px auto;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
width: 210mm;
height: 297mm;
background-color: white;
position: relative;
overflow: hidden;
}
.is-exporting .print-page {
margin: 0 !important;
box-shadow: none !important;
}
/* Export State: Force A4 width during PDF generation */
.is-exporting {
width: 794px !important; /* Standard A4 width at 96 DPI */
min-height: 1123px !important; /* Standard A4 height at 96 DPI */
margin: 0 auto !important;
transform: none !important;
box-shadow: none !important;
background: white !important;
overflow: visible !important; /* 确保不被截断 */
}
/* Fix for html2canvas flex and alignment issues */
.is-exporting * {
box-sizing: border-box !important;
}
/* Ensure images don't overflow */
.is-exporting img {
max-width: none !important;
}
/* Force page break after specific elements */
.pdf-page-break {
page-break-after: always;
}
/* Stable Header Pattern for html2canvas */
.title-container {
display: flex !important;
align-items: center !important;
height: 32px !important;
line-height: 32px !important;
margin-bottom: 12px !important;
}
.subtitle-container {
display: flex !important;
align-items: center !important;
height: 24px !important;
line-height: 24px !important;
margin-bottom: 8px !important;
}
.blue-bar-title {
display: inline-block !important;
width: 4px !important;
height: 18px !important;
background-color: #2386EE !important;
margin-right: 10px !important;
border-radius: 2px !important;
flex-shrink: 0 !important;
}
.blue-bar-subtitle {
display: inline-block !important;
width: 3px !important;
height: 14px !important;
background-color: #2386EE !important;
margin-right: 8px !important;
border-radius: 1.5px !important;
flex-shrink: 0 !important;
}
/* Force block and line-height for export stability */
.is-exporting {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif !important;
}
.is-exporting .title-container,
.is-exporting .subtitle-container {
display: flex !important;
}
/* ECharts Export Toggle */
.is-exporting .echarts-inner {
display: none !important;
}
.is-exporting .echarts-export-image {
display: block !important;
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

101
src/types/data.ts Normal file
View File

@@ -0,0 +1,101 @@
export interface RegionData {
region: string;
count: number;
conversion: string;
success: string;
duration: string;
}
export interface DurationStats {
region: string;
p0_5: string;
p5_10: string;
p10_20: string;
p20plus: string;
}
export interface FunnelItem {
label: string;
value: number;
}
export interface SunburstNode {
name: string;
value?: number;
itemStyle?: { color: string };
children?: SunburstNode[];
}
export interface RiskIndicator {
label: string;
value: number;
marketValue: number;
description?: string;
}
export interface BarChartData {
label: string;
value: number;
}
export interface BarChartCompareItem {
label: string;
customerValue: number;
marketValue: number;
}
export interface MapItem {
name: string;
value: number;
}
export interface ReportData {
companyName: string;
stats: {
successRate: string;
collectedCount: number;
growthRate: string;
fiveMinRate: string;
avgDuration: number;
};
mapData: MapItem[];
regionStatistics: RegionData[];
durationStatistics: DurationStats[];
funnelData: FunnelItem[];
sunburstData: SunburstNode[];
poorCustomerData: SunburstNode[];
goodCustomerData: SunburstNode[];
riskIndicators: {
invoiceRisk: RiskIndicator;
highTech: RiskIndicator;
importExport: RiskIndicator;
prepaidTax: RiskIndicator;
};
exportFreeTaxRebate: BarChartCompareItem[];
exportTaxRebate: BarChartCompareItem[];
loginPersonAssociation: BarChartCompareItem[];
legalPersonAssociation: BarChartCompareItem[];
riskScanning: BarChartCompareItem[];
portraitDescriptions?: {
all?: string;
poor?: string;
good?: string;
};
chartDescriptions?: {
exportFreeTaxRebate?: string;
exportTaxRebate?: string;
loginPersonAssociation?: string;
legalPersonAssociation?: string;
riskScanning?: string;
};
summaryData: {
totalUsers: string;
userGrowth: string;
avgDuration: string;
successRate: string;
topRegions: BarChartData[];
industryDistribution: BarChartData[];
riskDistribution: BarChartData[];
recommendations: string[];
};
}

493
src/utils/excelUtils.ts Normal file
View File

@@ -0,0 +1,493 @@
import * as XLSX from 'xlsx';
import type { ReportData, SunburstNode, RegionData, DurationStats, FunnelItem, BarChartCompareItem } from '../types/data';
/**
* Excel Sheets Structure:
* 1. 基础信息: Key-Value pairs for cover page
* 2. 地图展示: Columns [省份, 数值]
* 3. 地区统计: Columns [地区, 采集数量, 转换率, 成功率, 采集时长]
* 4. 时长分布: Columns [地区, 0-5分钟, 5-10分钟, 10-20分钟, 20分钟以上]
* 5. 页面漏损: Columns [环节, 占比]
* 6. 企业全景洞察: Columns [一级行业, 二级行业, 数值]
* 7. 劣质客户洞察: Columns [一级行业, 二级行业, 数值]
* 8. 优质客户洞察: Columns [一级行业, 二级行业, 数值]
* 9. 风险占比: Columns [指标类型, 标签, 数值, 全量大盘数值]
* 10. 出口退税分布: Columns [指标类型, 标签, 本机构数值, 全量客户数值]
* 11. 关联企业分布: Columns [指标类型, 标签, 本机构数值, 全量客户数值]
* 12. 风险扫描分布: Columns [标签, 本机构数值, 全量客户数值]
* 13. 图表描述: Columns [页面模块, 指标类型, 描述, 填写说明]
*/
interface MapRow {
省份: string;
数值: number;
}
interface BaseInfoRow {
字段: string;
数值: string | number;
}
interface RegionRow {
地区: string;
采集数量: number;
转换率: string;
成功率: string;
采集时长: string | number;
}
interface DurationRow {
地区: string;
'0-5分钟': string;
'5-10分钟': string;
'10-20分钟': string;
'20分钟以上': string;
}
interface FunnelRow {
环节: string;
占比: number;
}
interface PortraitRow {
一级行业: string;
二级行业: string;
数值: number;
}
interface RiskPieRow {
: 'invoiceRisk' | 'highTech' | 'importExport' | 'prepaidTax';
标签: string;
数值: number;
全量大盘数值: number;
描述?: string;
}
interface ExportTaxRow {
: 'exportFreeTaxRebate' | 'exportTaxRebate';
标签: string;
本机构数值: number;
全量客户数值: number;
描述?: string;
}
interface AssociationRow {
: 'loginPersonAssociation' | 'legalPersonAssociation';
标签: string;
本机构数值: number;
全量客户数值: number;
描述?: string;
}
interface RiskScanningRow {
?: 'riskScanning';
标签: string;
本机构数值: number;
全量客户数值: number;
描述?: string;
}
interface BarChartDescRow {
: 'exportFreeTaxRebate' | 'exportTaxRebate' | 'loginPersonAssociation' | 'legalPersonAssociation' | 'riskScanning';
描述: string;
}
import { defaultReportData } from '../data/defaultData';
type ChartDescriptionKey = keyof NonNullable<ReportData['chartDescriptions']>;
const chartDescriptionKeys: ChartDescriptionKey[] = [
'exportFreeTaxRebate',
'exportTaxRebate',
'loginPersonAssociation',
'legalPersonAssociation',
'riskScanning',
];
const ensurePercent = (val: unknown): string => {
if (val === undefined || val === null) return '0%';
if (typeof val === 'number') {
return `${(val * 100).toFixed(2)}%`;
}
const str = String(val).trim();
if (!str) return '0%';
if (str.includes('%')) return str;
const num = parseFloat(str);
if (!isNaN(num)) {
return `${(num * 100).toFixed(2)}%`;
}
return str.endsWith('%') ? str : `${str}%`;
};
const hasBarChartLabel = (row: { 标签?: unknown }): boolean => {
return String(row['标签'] ?? '').trim().length > 0;
};
const isChartDescriptionKey = (value: unknown): value is ChartDescriptionKey => {
return chartDescriptionKeys.includes(value as ChartDescriptionKey);
};
const setChartDescription = (
reportData: ReportData,
key: unknown,
description: unknown
): void => {
if (!isChartDescriptionKey(key)) return;
const text = String(description ?? '').trim();
if (!text) return;
if (!reportData.chartDescriptions) reportData.chartDescriptions = {};
reportData.chartDescriptions[key] = text;
};
const formatNumber = (val: unknown): number => {
if (val === undefined || val === null) return 0;
const num = parseFloat(String(val));
if (isNaN(num)) return 0;
return Math.round(num * 100) / 100;
};
export const parseExcelReportData = async (file: File): Promise<ReportData> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
// Use default as base to ensure all keys exist
const reportData = JSON.parse(JSON.stringify(defaultReportData)) as ReportData;
// 1. 基础信息
const baseSheet = workbook.Sheets['基础信息'];
if (baseSheet) {
const baseRows = XLSX.utils.sheet_to_json<BaseInfoRow>(baseSheet, { raw: true });
const baseObj: Record<string, unknown> = {};
baseRows.forEach(row => {
baseObj[row['字段']] = row['数值'];
});
reportData.companyName = String(baseObj['公司名称'] || '未知企业');
reportData.stats = {
successRate: ensurePercent(baseObj['采集成功率']),
collectedCount: parseInt(String(baseObj['累计采集户数'])) || 0,
growthRate: ensurePercent(baseObj['月增长率']),
fiveMinRate: ensurePercent(baseObj['5分钟内完成比例']),
avgDuration: formatNumber(baseObj['平均采集时长']),
};
reportData.portraitDescriptions = {
all: String(baseObj['企业全景洞察描述'] || ''),
poor: String(baseObj['风险客户洞察描述'] || ''),
good: String(baseObj['优质客户洞察描述'] || ''),
};
}
// 2. 地图展示
const mapSheet = workbook.Sheets['地图展示'];
if (mapSheet) {
reportData.mapData = XLSX.utils.sheet_to_json<MapRow>(mapSheet).map((row) => ({
name: row['省份'],
value: row['数值'] || 0,
}));
}
// 3. 地区统计
const regionSheet = workbook.Sheets['地区统计'];
if (regionSheet) {
reportData.regionStatistics = XLSX.utils.sheet_to_json<RegionRow>(regionSheet, { raw: true }).map((row): RegionData => ({
region: row['地区'],
count: parseInt(String(row['采集数量'])) || 0,
conversion: ensurePercent(row['转换率']),
success: ensurePercent(row['成功率']),
duration: String(formatNumber(row['采集时长'] || '0')),
}));
}
// 3. 时长分布
const durationSheet = workbook.Sheets['时长分布'];
if (durationSheet) {
reportData.durationStatistics = XLSX.utils.sheet_to_json<DurationRow>(durationSheet, { raw: true }).map((row): DurationStats => ({
region: row['地区'],
p0_5: ensurePercent(row['0-5分钟']),
p5_10: ensurePercent(row['5-10分钟']),
p10_20: ensurePercent(row['10-20分钟']),
p20plus: ensurePercent(row['20分钟以上']),
}));
}
// 4. 页面漏损
const funnelSheet = workbook.Sheets['页面漏损'];
if (funnelSheet) {
reportData.funnelData = XLSX.utils.sheet_to_json<FunnelRow>(funnelSheet).map((row): FunnelItem => ({
label: row['环节'],
value: row['占比'] * 100 || 0,
}));
}
// 5. 企业全景洞察
const BLUE_PALETTE = ['#1B65B5', '#2386EE', '#52A3FF', '#85C1FF', '#B8DEFF'];
const buildSunburst = (rows: PortraitRow[]) => {
const categories: Record<string, SunburstNode> = {};
rows.forEach(row => {
const l1 = row['一级行业'];
const l2 = row['二级行业'];
const val = row['数值'] || 0;
if (!categories[l1]) {
categories[l1] = { name: l1, children: [] };
}
categories[l1].children?.push({ name: l2, value: val });
});
return Object.values(categories).map((cat, idx) => {
const color = BLUE_PALETTE[idx % BLUE_PALETTE.length];
return {
...cat,
itemStyle: { color },
children: cat.children?.map(child => ({ ...child, itemStyle: { color } }))
};
});
};
const allSheet = workbook.Sheets['企业全景洞察'];
if (allSheet) {
reportData.sunburstData = buildSunburst(XLSX.utils.sheet_to_json<PortraitRow>(allSheet));
}
const poorSheet = workbook.Sheets['劣质客户洞察'];
if (poorSheet) {
reportData.poorCustomerData = buildSunburst(XLSX.utils.sheet_to_json<PortraitRow>(poorSheet));
}
const goodSheet = workbook.Sheets['优质客户洞察'];
if (goodSheet) {
reportData.goodCustomerData = buildSunburst(XLSX.utils.sheet_to_json<PortraitRow>(goodSheet));
}
// 6. 风险占比
const riskPieSheet = workbook.Sheets['风险占比'];
if (riskPieSheet) {
const rows = XLSX.utils.sheet_to_json<RiskPieRow>(riskPieSheet);
rows.forEach(row => {
const key = row['指标类型'];
if (key) {
reportData.riskIndicators[key] = {
label: row['标签'],
value: row['数值'] || 0,
marketValue: row['全量大盘数值'] || 0,
description: row['描述'] || '',
};
}
});
}
// 7. 出口退税分布
const exportTaxSheet = workbook.Sheets['出口退税分布'];
if (exportTaxSheet) {
const rows = XLSX.utils.sheet_to_json<ExportTaxRow>(exportTaxSheet);
const groups: Record<string, BarChartCompareItem[]> = {};
rows.forEach(row => {
const cat = row['指标类型'];
setChartDescription(reportData, cat, row['描述']);
if (cat && hasBarChartLabel(row)) {
if (!groups[cat]) groups[cat] = [];
groups[cat].push({
label: String(row['标签']).trim(),
customerValue: formatNumber(row['本机构数值']),
marketValue: formatNumber(row['全量客户数值']),
});
}
});
if (groups['exportFreeTaxRebate']) reportData.exportFreeTaxRebate = groups['exportFreeTaxRebate'];
if (groups['exportTaxRebate']) reportData.exportTaxRebate = groups['exportTaxRebate'];
}
// 8. 关联企业分布
const associationSheet = workbook.Sheets['关联企业分布'];
if (associationSheet) {
const rows = XLSX.utils.sheet_to_json<AssociationRow>(associationSheet);
const groups: Record<string, BarChartCompareItem[]> = {};
rows.forEach(row => {
const cat = row['指标类型'];
setChartDescription(reportData, cat, row['描述']);
if (cat && hasBarChartLabel(row)) {
if (!groups[cat]) groups[cat] = [];
groups[cat].push({
label: String(row['标签']).trim(),
customerValue: formatNumber(row['本机构数值']),
marketValue: formatNumber(row['全量客户数值']),
});
}
});
if (groups['loginPersonAssociation']) reportData.loginPersonAssociation = groups['loginPersonAssociation'];
if (groups['legalPersonAssociation']) reportData.legalPersonAssociation = groups['legalPersonAssociation'];
}
// 9. 风险扫描分布
const riskScanningSheet = workbook.Sheets['风险扫描分布'];
if (riskScanningSheet) {
const rows = XLSX.utils.sheet_to_json<RiskScanningRow>(riskScanningSheet);
rows.forEach(row => {
setChartDescription(reportData, row['指标类型'], row['描述']);
});
reportData.riskScanning = rows.filter(hasBarChartLabel).map((row): BarChartCompareItem => ({
label: String(row['标签']).trim(),
customerValue: formatNumber(row['本机构数值']),
marketValue: formatNumber(row['全量客户数值']),
}));
}
// 10. 图表描述(新模板优先)
const chartDescriptionSheet = workbook.Sheets['图表描述'];
if (chartDescriptionSheet) {
const rows = XLSX.utils.sheet_to_json<BarChartDescRow>(chartDescriptionSheet);
rows.forEach(row => {
setChartDescription(reportData, row['指标类型'], row['描述']);
});
}
resolve(reportData);
} catch (err) {
reject(err);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
};
export const generateExcelTemplate = () => {
const wb = XLSX.utils.book_new();
// Helper to ensure cells are treated as strings to avoid auto-formatting
const toSheet = (data: Record<string, unknown>[]) => {
const ws = XLSX.utils.json_to_sheet(data);
// Force all cells to be strings to avoid Excel's "helpful" formatting
Object.keys(ws).forEach(key => {
if (key[0] === '!') return;
const cell = ws[key];
if (cell.v !== undefined) {
cell.t = 's'; // Set type to string
cell.v = String(cell.v);
}
});
return ws;
};
// 1. 基础信息
const baseWS = toSheet([
{ '字段': '公司名称', '数值': '某某科技公司' },
{ '字段': '采集成功率', '数值': '99.99%' },
{ '字段': '累计采集户数', '数值': '1200' },
{ '字段': '月增长率', '数值': '12%' },
{ '字段': '5分钟内完成比例', '数值': '95%' },
{ '字段': '平均采集时长', '数值': '3.5' },
{ '字段': '企业全景洞察描述', '数值': '基于行业分布与企业特征,全面展示企业画像数据。' },
{ '字段': '风险客户洞察描述', '数值': '识别可能存在合作风险的客户群体,帮助您提前制定应对策略。' },
{ '字段': '优质客户洞察描述', '数值': '展示高价值优质客户特征,帮助您优化服务策略。' },
]);
XLSX.utils.book_append_sheet(wb, baseWS, '基础信息');
// 2. 地图展示
const mapWS = XLSX.utils.json_to_sheet([
{ '省份': '广东省', '数值': 9058 },
{ '省份': '江苏省', '数值': 4712 },
{ '省份': '四川省', '数值': 4560 },
]);
XLSX.utils.book_append_sheet(wb, mapWS, '地图展示');
// 3. 地区统计
const regionWS = toSheet([
{ '地区': '广东省', '采集数量': '5000', '转换率': '85.5%', '成功率': '99.9%', '采集时长': '5.2' },
{ '地区': '江苏省', '采集数量': '4000', '转换率': '88.2%', '成功率': '99.5%', '采集时长': '4.8' },
]);
XLSX.utils.book_append_sheet(wb, regionWS, '地区统计');
// 3. 时长分布
const durationWS = toSheet([
{ '地区': '广东省', '0-5分钟': '70%', '5-10分钟': '20%', '10-20分钟': '5%', '20分钟以上': '5%' },
]);
XLSX.utils.book_append_sheet(wb, durationWS, '时长分布');
// 4. 页面漏损
const funnelWS = XLSX.utils.json_to_sheet([ // Numbers are fine for charts
{ '环节': '登录税局', '占比': 100 },
{ '环节': '签署授权', '占比': 85 },
]);
XLSX.utils.book_append_sheet(wb, funnelWS, '页面漏损');
// 5. 企业全景洞察
const allWS = XLSX.utils.json_to_sheet([
{ '一级行业': '制造业', '二级行业': '通用设备', '数值': 150 },
{ '一级行业': '制造业', '二级行业': '金属加工', '数值': 100 },
{ '一级行业': '批发业', '二级行业': '食品批发', '数值': 200 },
]);
XLSX.utils.book_append_sheet(wb, allWS, '企业全景洞察');
// 6. 劣质客户洞察
const poorWS = XLSX.utils.json_to_sheet([
{ '一级行业': '制造业', '二级行业': '通用设备', '数值': 30 },
{ '一级行业': '制造业', '二级行业': '金属加工', '数值': 20 },
{ '一级行业': '批发业', '二级行业': '食品批发', '数值': 50 },
]);
XLSX.utils.book_append_sheet(wb, poorWS, '劣质客户洞察');
// 7. 优质客户洞察
const goodWS = XLSX.utils.json_to_sheet([
{ '一级行业': '制造业', '二级行业': '通用设备', '数值': 120 },
{ '一级行业': '制造业', '二级行业': '金属加工', '数值': 80 },
{ '一级行业': '批发业', '二级行业': '食品批发', '数值': 150 },
]);
XLSX.utils.book_append_sheet(wb, goodWS, '优质客户洞察');
// 9. 风险占比
const riskPieWS = toSheet([
{ '指标类型': 'invoiceRisk', '标签': '发票高风险记录', '数值': '0.5', '全量大盘数值': '0.3', '描述': '统计近1年有效开票记录中存在发票高风险的企业占比情况。' },
{ '指标类型': 'highTech', '标签': '高新技术企业', '数值': '5.2', '全量大盘数值': '4.8', '描述': '统计近1年有效开票记录中被认定为高新技术企业的占比情况。' },
{ '指标类型': 'importExport', '标签': '进出口企业', '数值': '2.1', '全量大盘数值': '1.8', '描述': '统计近1年有效开票记录中具有进出口资质的企业占比情况。' },
{ '指标类型': 'prepaidTax', '标签': '预缴税款企业', '数值': '15.0', '全量大盘数值': '12.5', '描述': '统计近1年有效开票记录中存在预缴税款行为的企业占比情况。' },
]);
XLSX.utils.book_append_sheet(wb, riskPieWS, '风险占比');
// 10. 出口退税分布
const exportTaxWS = XLSX.utils.json_to_sheet([
{ '指标类型': 'exportFreeTaxRebate', '标签': '出口免抵退税额0到100万', '本机构数值': 71.43, '全量客户数值': 75 },
{ '指标类型': 'exportFreeTaxRebate', '标签': '出口免抵退税额100到500万', '本机构数值': 16.67, '全量客户数值': 18 },
{ '指标类型': 'exportFreeTaxRebate', '标签': '出口免抵退税额500到1000万', '本机构数值': 0, '全量客户数值': 2 },
{ '指标类型': 'exportFreeTaxRebate', '标签': '出口免抵退税额1000万以上', '本机构数值': 11.9, '全量客户数值': 5 },
{ '指标类型': 'exportTaxRebate', '标签': '出口退税额0到100万', '本机构数值': 68.33, '全量客户数值': 70 },
{ '指标类型': 'exportTaxRebate', '标签': '出口退税额100到500万', '本机构数值': 21.67, '全量客户数值': 20 },
{ '指标类型': 'exportTaxRebate', '标签': '出口退税额500到1000万', '本机构数值': 8.33, '全量客户数值': 7 },
{ '指标类型': 'exportTaxRebate', '标签': '出口退税额1000万以上', '本机构数值': 1.67, '全量客户数值': 3 },
]);
XLSX.utils.book_append_sheet(wb, exportTaxWS, '出口退税分布');
// 11. 关联企业分布
const associationWS = XLSX.utils.json_to_sheet([
{ '指标类型': 'loginPersonAssociation', '标签': '登录人关联企业数量: 0~3', '本机构数值': 90.06, '全量客户数值': 92 },
{ '指标类型': 'loginPersonAssociation', '标签': '登录人关联企业数量: 4~10', '本机构数值': 8.58, '全量客户数值': 7 },
{ '指标类型': 'loginPersonAssociation', '标签': '登录人关联企业数量: 10+', '本机构数值': 1.36, '全量客户数值': 1 },
{ '指标类型': 'legalPersonAssociation', '标签': '法人代表关联企业数量: 0~3', '本机构数值': 92.91, '全量客户数值': 90 },
{ '指标类型': 'legalPersonAssociation', '标签': '法人代表关联企业数量: 4~10', '本机构数值': 6.49, '全量客户数值': 8 },
{ '指标类型': 'legalPersonAssociation', '标签': '法人代表关联企业数量: 10+', '本机构数值': 0.61, '全量客户数值': 2 },
]);
XLSX.utils.book_append_sheet(wb, associationWS, '关联企业分布');
// 12. 风险扫描分布
const riskScanningWS = XLSX.utils.json_to_sheet([
{ '标签': '无风险', '本机构数值': 77.58, '全量客户数值': 75 },
{ '标签': '低风险', '本机构数值': 12.41, '全量客户数值': 13 },
{ '标签': '中风险', '本机构数值': 10.01, '全量客户数值': 9 },
{ '标签': '高风险', '本机构数值': 0, '全量客户数值': 3 },
]);
XLSX.utils.book_append_sheet(wb, riskScanningWS, '风险扫描分布');
// 13. 图表描述
const chartDescriptionWS = toSheet([
{ '页面模块': '不同出口免抵退税金额企业分布', '指标类型': 'exportFreeTaxRebate', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' },
{ '页面模块': '不同出口退税金额企业分布', '指标类型': 'exportTaxRebate', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' },
{ '页面模块': '登录人关联企业数量分布', '指标类型': 'loginPersonAssociation', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' },
{ '页面模块': '法定代表人关联企业数量分布', '指标类型': 'legalPersonAssociation', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' },
{ '页面模块': '企业风险扫描结果分布', '指标类型': 'riskScanning', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' },
]);
XLSX.utils.book_append_sheet(wb, chartDescriptionWS, '图表描述');
XLSX.writeFile(wb, '报告数据导入模板.xlsx');
};

7
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module 'html2pdf.js';
interface Window {
loadServerData?: (data: any) => void;
isReportReady?: boolean;
}