634 lines
16 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|