ai-chatbot/components/elements/response-pdf-download.tsx
2026-06-25 22:49:12 +08:00

634 lines
16 KiB
TypeScript

"use client";
import { FileDownIcon } from "lucide-react";
import { useState, type RefObject } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
type ResponsePdfDownloadButtonProps = {
contentRef: RefObject<HTMLElement | null>;
fileName?: string;
siteLogo?: string | null;
siteName?: string | null;
title?: string;
};
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function getSafeTitle(value?: string | null) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function getSafeFileName(value?: string | null) {
const normalized = getSafeTitle(value || "Claw 分析报告")
.replace(/[\\/:*?"<>|]/g, "_")
.slice(0, 80);
return normalized || "Claw 分析报告";
}
function clonePrintableStyles(targetWindow: Window) {
document
.querySelectorAll<HTMLLinkElement | HTMLStyleElement>(
'link[rel="stylesheet"], style'
)
.forEach((node) => {
targetWindow.document.head.appendChild(node.cloneNode(true));
});
}
function createPrintOverrides(targetWindow: Window) {
const style = targetWindow.document.createElement("style");
style.textContent = `
* {
box-sizing: border-box;
}
:root {
--background: #ffffff;
--foreground: #111827;
--border: #e5e7eb;
--card: #ffffff;
--muted: #f3f4f6;
--muted-foreground: #6b7280;
--primary: #111827;
}
html,
body {
margin: 0;
background: #ffffff;
color: #111827;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.claw-pdf-shell {
box-sizing: border-box;
width: 1120px;
max-width: 100%;
margin: 0 auto;
padding: 32px;
background: #ffffff;
}
.claw-pdf-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding-bottom: 18px;
margin-bottom: 24px;
border-bottom: 1px solid #e5e7eb;
}
.claw-pdf-brand {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.claw-pdf-logo,
.claw-pdf-logo-fallback {
width: 38px;
height: 38px;
flex: 0 0 auto;
border-radius: 10px;
}
.claw-pdf-logo {
object-fit: contain;
background: #ffffff;
}
.claw-pdf-logo-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
background: #111827;
color: #ffffff;
font-size: 15px;
font-weight: 700;
}
.claw-pdf-site {
font-size: 18px;
line-height: 1.2;
font-weight: 700;
color: #111827;
}
.claw-pdf-title {
margin-top: 4px;
font-size: 12px;
line-height: 1.4;
color: #6b7280;
}
.claw-pdf-time {
flex: 0 0 auto;
text-align: right;
font-size: 11px;
color: #6b7280;
}
.claw-pdf-content {
font-size: 14px;
line-height: 1.7;
overflow: visible;
}
.claw-pdf-content,
.claw-pdf-content * {
max-width: 100%;
}
[data-pdf-exclude="true"] {
display: none !important;
}
.claw-pdf-table-card {
break-inside: auto;
page-break-inside: auto;
overflow: visible !important;
width: 100% !important;
max-width: 100% !important;
border-radius: 10px !important;
}
.claw-pdf-split-table-group {
display: block;
width: 100%;
margin: 12px 0;
break-inside: auto;
page-break-inside: auto;
}
.claw-pdf-split-card {
width: 100%;
margin: 0 0 10px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 10px;
break-inside: auto;
page-break-inside: auto;
background: #ffffff;
}
.claw-pdf-split-caption {
padding: 5px 8px;
border-bottom: 1px solid #e5e7eb;
background: #f8fafc;
color: #6b7280;
font-size: 10px;
line-height: 1.4;
font-weight: 600;
text-align: right;
}
.chat-card-x-scroll {
display: block !important;
overflow: visible !important;
width: 100% !important;
max-width: 100% !important;
padding-bottom: 0 !important;
scrollbar-width: none !important;
}
.chat-card-x-scroll::-webkit-scrollbar {
display: none !important;
}
table {
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
table-layout: fixed !important;
border-collapse: collapse !important;
page-break-inside: auto;
break-inside: auto;
font-size: 12px !important;
line-height: 1.5 !important;
}
.claw-pdf-split-table {
margin: 0 !important;
border: 0 !important;
border-radius: 0 !important;
font-size: 11.5px !important;
}
.claw-pdf-wide-table {
font-size: 11px !important;
}
.claw-pdf-extra-wide-table {
font-size: 10px !important;
}
thead {
display: table-header-group;
}
tbody {
display: table-row-group;
}
tr {
page-break-inside: avoid;
break-inside: avoid;
}
th,
td {
min-width: 0 !important;
width: auto !important;
max-width: none !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
word-break: break-word !important;
text-overflow: clip !important;
vertical-align: top !important;
padding: 5px 7px !important;
line-height: 1.5 !important;
}
th *,
td * {
min-width: 0 !important;
max-width: 100% !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
word-break: break-word !important;
text-overflow: clip !important;
}
th {
text-align: left !important;
font-weight: 700 !important;
}
.claw-pdf-wide-table th,
.claw-pdf-wide-table td,
.claw-pdf-extra-wide-table th,
.claw-pdf-extra-wide-table td {
padding-left: 5px !important;
padding-right: 5px !important;
}
svg,
img {
page-break-inside: avoid;
break-inside: avoid;
}
svg {
max-width: 100% !important;
height: auto;
}
@page {
size: A4 landscape;
margin: 12mm;
}
@media print {
.claw-pdf-shell {
width: auto;
max-width: none;
padding: 0;
}
}
`;
targetWindow.document.head.appendChild(style);
}
function getTableColumnCount(table: HTMLTableElement) {
return Array.from(table.rows).reduce(
(maxColumns, row) => Math.max(maxColumns, row.cells.length),
0
);
}
const PRINTABLE_TABLE_SPLIT_THRESHOLD = 5;
const PRINTABLE_TABLE_PINNED_COLUMNS = 1;
const PRINTABLE_TABLE_MAX_COLUMNS_PER_SEGMENT = 4;
function getPrintableTableColumnGroups(columnCount: number) {
if (columnCount < PRINTABLE_TABLE_SPLIT_THRESHOLD) return null;
const pinnedColumnCount = Math.min(
PRINTABLE_TABLE_PINNED_COLUMNS,
columnCount
);
const pinnedColumns = Array.from(
{ length: pinnedColumnCount },
(_, index) => index
);
const remainingColumns = Array.from(
{ length: columnCount - pinnedColumnCount },
(_, index) => index + pinnedColumnCount
);
const chunkSize = Math.max(
1,
PRINTABLE_TABLE_MAX_COLUMNS_PER_SEGMENT - pinnedColumnCount
);
const columnGroups: number[][] = [];
for (let index = 0; index < remainingColumns.length; index += chunkSize) {
columnGroups.push([
...pinnedColumns,
...remainingColumns.slice(index, index + chunkSize),
]);
}
return columnGroups.length > 1 ? columnGroups : null;
}
function getPrintableTableRows(table: HTMLTableElement) {
let headRows = table.tHead ? Array.from(table.tHead.rows) : [];
const allRows = Array.from(table.rows);
if (!headRows.length && allRows[0]) {
const firstRow = allRows[0];
if (Array.from(firstRow.cells).some((cell) => cell.tagName === "TH")) {
headRows = [firstRow];
}
}
const headRowSet = new Set(headRows);
const bodyRows = allRows.filter((row) => !headRowSet.has(row));
return { headRows, bodyRows };
}
function appendSplitTableRows(
targetDocument: Document,
targetSection: HTMLTableSectionElement,
sourceRows: HTMLTableRowElement[],
columnIndexes: number[],
fallbackCellTag: "td" | "th"
) {
sourceRows.forEach((sourceRow) => {
const targetRow = targetDocument.createElement("tr");
columnIndexes.forEach((columnIndex) => {
const sourceCell = sourceRow.cells[columnIndex];
const cellTag =
sourceCell?.tagName.toLowerCase() === "th" || fallbackCellTag === "th"
? "th"
: "td";
const targetCell = targetDocument.createElement(cellTag);
if (sourceCell) {
targetCell.innerHTML = sourceCell.innerHTML;
}
targetRow.appendChild(targetCell);
});
targetSection.appendChild(targetRow);
});
}
function createSplitPrintableTableGroup(
targetDocument: Document,
table: HTMLTableElement,
columnGroups: number[][]
) {
const { headRows, bodyRows } = getPrintableTableRows(table);
const group = targetDocument.createElement("div");
group.className = "claw-pdf-split-table-group";
columnGroups.forEach((columnIndexes, index) => {
const card = targetDocument.createElement("section");
card.className = "claw-pdf-split-card";
const caption = targetDocument.createElement("div");
caption.className = "claw-pdf-split-caption";
caption.textContent = `续表 ${index + 1}/${columnGroups.length}`;
card.appendChild(caption);
const splitTable = targetDocument.createElement("table");
splitTable.className = "claw-pdf-split-table";
splitTable.dataset.pdfColumnCount = String(columnIndexes.length);
if (headRows.length) {
const thead = targetDocument.createElement("thead");
appendSplitTableRows(
targetDocument,
thead,
headRows,
columnIndexes,
"th"
);
splitTable.appendChild(thead);
}
const tbody = targetDocument.createElement("tbody");
appendSplitTableRows(
targetDocument,
tbody,
bodyRows,
columnIndexes,
"td"
);
splitTable.appendChild(tbody);
card.appendChild(splitTable);
group.appendChild(card);
});
return group;
}
function preparePrintableTables(root: HTMLElement) {
root.querySelectorAll<HTMLTableElement>("table").forEach((table) => {
if (!table.isConnected) return;
const columnCount = getTableColumnCount(table);
const columnGroups = getPrintableTableColumnGroups(columnCount);
const scrollWrapper = table.closest(".chat-card-x-scroll");
const tableCard = scrollWrapper?.parentElement;
if (columnGroups) {
const splitGroup = createSplitPrintableTableGroup(
root.ownerDocument,
table,
columnGroups
);
(tableCard || table).replaceWith(splitGroup);
return;
}
table.dataset.pdfColumnCount = String(columnCount);
table.classList.toggle("claw-pdf-wide-table", columnCount >= 5);
table.classList.toggle("claw-pdf-extra-wide-table", columnCount >= 7);
tableCard?.classList.add("claw-pdf-table-card");
});
}
function waitForFrame(targetWindow: Window) {
return new Promise<void>((resolve) => {
targetWindow.requestAnimationFrame(() => resolve());
});
}
async function waitForPrintableAssets(targetWindow: Window, root: HTMLElement) {
const fontReady =
"fonts" in targetWindow.document
? targetWindow.document.fonts.ready.catch(() => undefined)
: Promise.resolve();
const images = Array.from(root.querySelectorAll<HTMLImageElement>("img"));
const imageReady = Promise.all(
images.map((image) => {
if (image.complete && image.naturalWidth > 0) return Promise.resolve();
if (typeof image.decode === "function") {
return image.decode().catch(() => undefined);
}
return new Promise<void>((resolve) => {
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
});
})
);
await Promise.race([
Promise.all([fontReady, imageReady]),
new Promise((resolve) => targetWindow.setTimeout(resolve, 1800)),
]);
await waitForFrame(targetWindow);
await waitForFrame(targetWindow);
}
export function ResponsePdfDownloadButton({
contentRef,
fileName,
siteLogo,
siteName,
title = "Claw 分析报告",
}: ResponsePdfDownloadButtonProps) {
const [isPreparing, setIsPreparing] = useState(false);
const resolvedSiteName = getSafeTitle(siteName) || "AI Chatbot";
const resolvedTitle = getSafeTitle(title) || "Claw 分析报告";
const handleDownloadPdf = () => {
const contentElement = contentRef.current;
if (!contentElement) {
toast.error("未找到可导出的内容");
return;
}
const printWindow = window.open(
"",
"_blank",
"width=1200,height=820"
);
if (!printWindow) {
toast.error("浏览器阻止了 PDF 导出窗口,请允许弹窗后重试");
return;
}
setIsPreparing(true);
try {
const safeDocumentTitle = getSafeFileName(fileName || resolvedTitle);
printWindow.document.open();
printWindow.document.write(
`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(
safeDocumentTitle
)}</title><base href="${escapeHtml(window.location.origin)}"></head><body></body></html>`
);
printWindow.document.close();
clonePrintableStyles(printWindow);
createPrintOverrides(printWindow);
const shell = printWindow.document.createElement("main");
shell.className = "claw-pdf-shell";
const header = printWindow.document.createElement("header");
header.className = "claw-pdf-header";
const brand = printWindow.document.createElement("div");
brand.className = "claw-pdf-brand";
if (siteLogo) {
const logo = printWindow.document.createElement("img");
logo.className = "claw-pdf-logo";
logo.alt = resolvedSiteName;
logo.src = siteLogo;
brand.appendChild(logo);
} else {
const fallback = printWindow.document.createElement("span");
fallback.className = "claw-pdf-logo-fallback";
fallback.textContent = resolvedSiteName.slice(0, 1).toUpperCase();
brand.appendChild(fallback);
}
const brandText = printWindow.document.createElement("div");
brandText.innerHTML = `<div class="claw-pdf-site">${escapeHtml(
resolvedSiteName
)}</div><div class="claw-pdf-title">${escapeHtml(resolvedTitle)}</div>`;
brand.appendChild(brandText);
const time = printWindow.document.createElement("div");
time.className = "claw-pdf-time";
time.textContent = new Date().toLocaleString("zh-CN", {
hour12: false,
});
header.appendChild(brand);
header.appendChild(time);
shell.appendChild(header);
const content = printWindow.document.createElement("section");
content.className = "claw-pdf-content";
const clonedContent = contentElement.cloneNode(true) as HTMLElement;
clonedContent
.querySelectorAll('[data-pdf-exclude="true"]')
.forEach((node) => node.remove());
preparePrintableTables(clonedContent);
content.appendChild(clonedContent);
shell.appendChild(content);
printWindow.document.body.appendChild(shell);
void waitForPrintableAssets(printWindow, shell)
.catch((error) => {
console.warn("[Response PDF] asset readiness check failed:", error);
})
.finally(() => {
setIsPreparing(false);
if (printWindow.closed) return;
printWindow.focus();
printWindow.print();
});
} catch (error) {
setIsPreparing(false);
printWindow.close();
console.error("[Response PDF] export failed:", error);
toast.error("PDF 导出失败,请稍后重试");
}
};
return (
<button
type="button"
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-2.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground",
isPreparing && "pointer-events-none opacity-70"
)}
onClick={handleDownloadPdf}
disabled={isPreparing}
title="导出整段回复为 PDF"
>
<FileDownIcon className="h-3.5 w-3.5" />
<span>{isPreparing ? "准备中" : "下载 PDF"}</span>
</button>
);
}