马一丁

Improve PDF Export

@@ -193,10 +193,22 @@ class HTMLRenderer: @@ -193,10 +193,22 @@ class HTMLRenderer:
193 chapters = "".join(self._render_chapter(chapter) for chapter in self.chapters) 193 chapters = "".join(self._render_chapter(chapter) for chapter in self.chapters)
194 widget_scripts = "\n".join(self.widget_scripts) 194 widget_scripts = "\n".join(self.widget_scripts)
195 hydration = self._hydration_script() 195 hydration = self._hydration_script()
  196 + overlay = """
  197 +<div id="export-overlay" class="export-overlay no-print" aria-hidden="true">
  198 + <div class="export-dialog" role="status" aria-live="assertive">
  199 + <div class="export-spinner" aria-hidden="true"></div>
  200 + <p class="export-status">正在导出PDF,请稍候...</p>
  201 + <div class="export-progress" role="progressbar" aria-valuetext="正在导出">
  202 + <div class="export-progress-bar"></div>
  203 + </div>
  204 + </div>
  205 +</div>
  206 +""".strip()
196 207
197 return f""" 208 return f"""
198 <body> 209 <body>
199 {header} 210 {header}
  211 +{overlay}
200 <main> 212 <main>
201 {cover} 213 {cover}
202 {hero} 214 {hero}
@@ -1524,6 +1536,75 @@ body {{ @@ -1524,6 +1536,75 @@ body {{
1524 .action-btn:hover {{ 1536 .action-btn:hover {{
1525 transform: translateY(-1px); 1537 transform: translateY(-1px);
1526 }} 1538 }}
  1539 +body.exporting {{
  1540 + cursor: progress;
  1541 +}}
  1542 +.export-overlay {{
  1543 + position: fixed;
  1544 + inset: 0;
  1545 + background: rgba(3, 9, 26, 0.55);
  1546 + backdrop-filter: blur(2px);
  1547 + display: flex;
  1548 + align-items: center;
  1549 + justify-content: center;
  1550 + opacity: 0;
  1551 + pointer-events: none;
  1552 + transition: opacity 0.3s ease;
  1553 + z-index: 999;
  1554 +}}
  1555 +.export-overlay.active {{
  1556 + opacity: 1;
  1557 + pointer-events: all;
  1558 +}}
  1559 +.export-dialog {{
  1560 + background: rgba(12, 19, 38, 0.92);
  1561 + padding: 24px 32px;
  1562 + border-radius: 18px;
  1563 + color: #fff;
  1564 + text-align: center;
  1565 + min-width: 280px;
  1566 + box-shadow: 0 16px 40px rgba(0,0,0,0.45);
  1567 +}}
  1568 +.export-spinner {{
  1569 + width: 48px;
  1570 + height: 48px;
  1571 + border-radius: 50%;
  1572 + border: 3px solid rgba(255,255,255,0.2);
  1573 + border-top-color: var(--secondary-color);
  1574 + margin: 0 auto 16px;
  1575 + animation: export-spin 1s linear infinite;
  1576 +}}
  1577 +.export-status {{
  1578 + margin: 0;
  1579 + font-size: 1rem;
  1580 +}}
  1581 +.export-progress {{
  1582 + width: 220px;
  1583 + height: 6px;
  1584 + background: rgba(255,255,255,0.25);
  1585 + border-radius: 999px;
  1586 + overflow: hidden;
  1587 + margin: 20px auto 0;
  1588 + position: relative;
  1589 +}}
  1590 +.export-progress-bar {{
  1591 + position: absolute;
  1592 + top: 0;
  1593 + bottom: 0;
  1594 + width: 45%;
  1595 + border-radius: inherit;
  1596 + background: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
  1597 + animation: export-progress 1.4s ease-in-out infinite;
  1598 +}}
  1599 +@keyframes export-spin {{
  1600 + from {{ transform: rotate(0deg); }}
  1601 + to {{ transform: rotate(360deg); }}
  1602 +}}
  1603 +@keyframes export-progress {{
  1604 + 0% {{ left: -45%; }}
  1605 + 50% {{ left: 20%; }}
  1606 + 100% {{ left: 110%; }}
  1607 +}}
1527 main {{ 1608 main {{
1528 max-width: {container_width}; 1609 max-width: {container_width};
1529 margin: 40px auto; 1610 margin: 40px auto;
@@ -1777,6 +1858,23 @@ pre.code-block {{ @@ -1777,6 +1858,23 @@ pre.code-block {{
1777 box-shadow: none; 1858 box-shadow: none;
1778 margin: 0; 1859 margin: 0;
1779 }} 1860 }}
  1861 + .chapter > *,
  1862 + .hero-section,
  1863 + .callout,
  1864 + .chart-card,
  1865 + .kpi-grid,
  1866 + .table-wrap,
  1867 + figure,
  1868 + blockquote {{
  1869 + break-inside: avoid;
  1870 + page-break-inside: avoid;
  1871 + }}
  1872 + .chapter h2,
  1873 + .chapter h3,
  1874 + .chapter h4 {{
  1875 + break-after: avoid;
  1876 + page-break-after: avoid;
  1877 + }}
1780 }} 1878 }}
1781 """ 1879 """
1782 1880
@@ -2103,32 +2201,112 @@ function hydrateCharts() { @@ -2103,32 +2201,112 @@ function hydrateCharts() {
2103 }); 2201 });
2104 } 2202 }
2105 2203
  2204 +function getExportOverlayParts() {
  2205 + const overlay = document.getElementById('export-overlay');
  2206 + if (!overlay) {
  2207 + return null;
  2208 + }
  2209 + return {
  2210 + overlay,
  2211 + status: overlay.querySelector('.export-status')
  2212 + };
  2213 +}
  2214 +
  2215 +function showExportOverlay(message) {
  2216 + const parts = getExportOverlayParts();
  2217 + if (!parts) return;
  2218 + if (message && parts.status) {
  2219 + parts.status.textContent = message;
  2220 + }
  2221 + parts.overlay.classList.add('active');
  2222 + document.body.classList.add('exporting');
  2223 +}
  2224 +
  2225 +function updateExportOverlay(message) {
  2226 + if (!message) return;
  2227 + const parts = getExportOverlayParts();
  2228 + if (parts && parts.status) {
  2229 + parts.status.textContent = message;
  2230 + }
  2231 +}
  2232 +
  2233 +function hideExportOverlay(delay) {
  2234 + const parts = getExportOverlayParts();
  2235 + if (!parts) return;
  2236 + const close = () => {
  2237 + parts.overlay.classList.remove('active');
  2238 + document.body.classList.remove('exporting');
  2239 + };
  2240 + if (delay && delay > 0) {
  2241 + setTimeout(close, delay);
  2242 + } else {
  2243 + close();
  2244 + }
  2245 +}
  2246 +
2106 function exportPdf() { 2247 function exportPdf() {
2107 const target = document.querySelector('main'); 2248 const target = document.querySelector('main');
2108 - if (!target || typeof html2canvas === 'undefined' || typeof jspdf === 'undefined') { 2249 + if (!target || typeof jspdf === 'undefined' || typeof jspdf.jsPDF !== 'function') {
2109 alert('PDF导出依赖未就绪'); 2250 alert('PDF导出依赖未就绪');
2110 return; 2251 return;
2111 } 2252 }
2112 - html2canvas(target, {scale: 2}).then(canvas => {  
2113 - const imgData = canvas.toDataURL('image/png');  
2114 - const pdf = new jspdf.jsPDF('p', 'mm', 'a4');  
2115 - const pageWidth = pdf.internal.pageSize.getWidth();  
2116 - const pageHeight = pdf.internal.pageSize.getHeight();  
2117 - const imgHeight = canvas.height * pageWidth / canvas.width;  
2118 - let heightLeft = imgHeight;  
2119 - let position = 0;  
2120 -  
2121 - pdf.addImage(imgData, 'PNG', 0, position, pageWidth, imgHeight);  
2122 - heightLeft -= pageHeight;  
2123 -  
2124 - while (heightLeft > 0) {  
2125 - position = heightLeft - imgHeight;  
2126 - pdf.addPage();  
2127 - pdf.addImage(imgData, 'PNG', 0, position, pageWidth, imgHeight);  
2128 - heightLeft -= pageHeight; 2253 + const exportBtn = document.getElementById('export-btn');
  2254 + if (exportBtn) {
  2255 + exportBtn.disabled = true;
  2256 + }
  2257 + showExportOverlay('正在导出PDF,请稍候...');
  2258 + const pdf = new jspdf.jsPDF('p', 'mm', 'a4');
  2259 + const pageWidth = pdf.internal.pageSize.getWidth();
  2260 + const pxWidth = Math.max(target.scrollWidth, document.documentElement.scrollWidth);
  2261 + const restoreButton = () => {
  2262 + if (exportBtn) {
  2263 + exportBtn.disabled = false;
2129 } 2264 }
2130 - pdf.save('report.pdf');  
2131 - }); 2265 + };
  2266 + let renderTask;
  2267 + try {
  2268 + renderTask = pdf.html(target, {
  2269 + x: 8,
  2270 + y: 12,
  2271 + width: pageWidth - 16,
  2272 + margin: [12, 12, 20, 12],
  2273 + autoPaging: 'text',
  2274 + windowWidth: pxWidth,
  2275 + pagebreak: {
  2276 + mode: ['css', 'legacy'],
  2277 + avoid: ['.chapter > *', '.callout', '.chart-card', '.table-wrap', '.kpi-grid', '.hero-section']
  2278 + },
  2279 + html2canvas: {
  2280 + scale: 0.72,
  2281 + useCORS: true,
  2282 + logging: false
  2283 + },
  2284 + callback: (doc) => doc.save('report.pdf')
  2285 + });
  2286 + } catch (err) {
  2287 + console.error('PDF 导出失败', err);
  2288 + updateExportOverlay('导出失败,请稍后重试');
  2289 + hideExportOverlay(1200);
  2290 + restoreButton();
  2291 + alert('PDF导出失败,请稍后重试');
  2292 + return;
  2293 + }
  2294 + if (renderTask && typeof renderTask.then === 'function') {
  2295 + renderTask.then(() => {
  2296 + updateExportOverlay('导出完成,正在保存...');
  2297 + hideExportOverlay(800);
  2298 + restoreButton();
  2299 + }).catch(err => {
  2300 + console.error('PDF 导出失败', err);
  2301 + updateExportOverlay('导出失败,请稍后重试');
  2302 + hideExportOverlay(1200);
  2303 + restoreButton();
  2304 + alert('PDF导出失败,请稍后重试');
  2305 + });
  2306 + } else {
  2307 + hideExportOverlay();
  2308 + restoreButton();
  2309 + }
2132 } 2310 }
2133 2311
2134 document.addEventListener('DOMContentLoaded', () => { 2312 document.addEventListener('DOMContentLoaded', () => {