ai-chatbot/components/elements/response-pdf-download.tsx
2026-06-24 18:33:47 +08:00

309 lines
7.8 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 = `
: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;
}
[data-pdf-exclude="true"] {
display: none !important;
}
.chat-card-x-scroll {
overflow: visible !important;
padding-bottom: 0 !important;
scrollbar-width: none !important;
}
.chat-card-x-scroll::-webkit-scrollbar {
display: none !important;
}
table {
page-break-inside: auto;
}
tr,
svg,
img {
page-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);
}
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());
content.appendChild(clonedContent);
shell.appendChild(content);
printWindow.document.body.appendChild(shell);
window.setTimeout(() => {
setIsPreparing(false);
printWindow.focus();
printWindow.print();
}, 600);
} 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>
);
}