重构下载功能,将单一下载按钮改为 DropdownMenu 下拉菜单,新增 CSV 导出和 escapeCsvCell 辅助函数,downloadTextFile 支持自定义 MIME 类型。
257 lines
7.6 KiB
TypeScript
257 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import { DownloadIcon } from "lucide-react";
|
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import type {
|
|
AnchorHTMLAttributes,
|
|
ComponentPropsWithoutRef,
|
|
ReactNode,
|
|
} from "react";
|
|
|
|
import {
|
|
MessageResponse,
|
|
type MessageResponseProps,
|
|
} from "@/components/ai-elements/message";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { streamdownPlugins } from "@/core/streamdown";
|
|
import { CopyButton } from "@/components/workspace/copy-button";
|
|
import { Tooltip } from "@/components/workspace/tooltip";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { CitationLink } from "../citations/citation-link";
|
|
|
|
function isExternalUrl(href: string | undefined): boolean {
|
|
return !!href && /^https?:\/\//.test(href);
|
|
}
|
|
|
|
export type MarkdownContentProps = {
|
|
content: string;
|
|
isLoading: boolean;
|
|
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
|
className?: string;
|
|
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
|
components?: MessageResponseProps["components"];
|
|
};
|
|
|
|
type TableData = {
|
|
headers: string[];
|
|
rows: string[][];
|
|
};
|
|
|
|
function parseTableData(table: HTMLTableElement): TableData {
|
|
const headers = Array.from(table.querySelectorAll("thead th")).map((cell) =>
|
|
(cell.textContent ?? "").trim(),
|
|
);
|
|
const rows = Array.from(table.querySelectorAll("tbody tr")).map((row) =>
|
|
Array.from(row.querySelectorAll("td")).map((cell) =>
|
|
(cell.textContent ?? "").trim(),
|
|
),
|
|
);
|
|
return { headers, rows };
|
|
}
|
|
|
|
function toMarkdownTable(data: TableData): string {
|
|
if (data.headers.length === 0) return "";
|
|
const headerLine = `| ${data.headers.join(" | ")} |`;
|
|
const dividerLine = `| ${data.headers.map(() => "---").join(" | ")} |`;
|
|
const rowLines = data.rows.map((row) => `| ${row.join(" | ")} |`);
|
|
return [headerLine, dividerLine, ...rowLines].join("\n");
|
|
}
|
|
|
|
function escapeCsvCell(cell: string): string {
|
|
const normalized = cell.replace(/\r\n/g, "\n");
|
|
if (!/["\n,]/.test(normalized)) return normalized;
|
|
return `"${normalized.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
function toCsvTable(data: TableData): string {
|
|
if (data.headers.length === 0) return "";
|
|
const headerLine = data.headers.map(escapeCsvCell).join(",");
|
|
const rowLines = data.rows.map((row) => row.map(escapeCsvCell).join(","));
|
|
return [headerLine, ...rowLines].join("\r\n");
|
|
}
|
|
|
|
function downloadTextFile(content: string, filename: string, mimeType: string) {
|
|
const blob = new Blob(["\uFEFF", content], {
|
|
type: mimeType,
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
anchor.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export function MarkdownTable({
|
|
className,
|
|
children,
|
|
copyLabel: _copyLabel,
|
|
downloadLabel,
|
|
...props
|
|
}: ComponentPropsWithoutRef<"table"> & {
|
|
copyLabel: string;
|
|
downloadLabel: string;
|
|
}) {
|
|
const tableRef = useRef<HTMLTableElement>(null);
|
|
const [, forceUpdate] = useState(0);
|
|
|
|
// 首次 mount 后 tableRef 才被赋值,用 useLayoutEffect 在 paint 前强制刷新
|
|
useLayoutEffect(() => {
|
|
forceUpdate((n) => n + 1);
|
|
}, []);
|
|
|
|
// 在 render 阶段直接从 DOM ref 计算,不依赖 effect 异步更新
|
|
// tableRef 在上一次渲染的 commit 阶段已设置,本次渲染可用
|
|
const clipboardData = (() => {
|
|
const table = tableRef.current;
|
|
if (!table) return "";
|
|
const data = parseTableData(table);
|
|
if (!data) return "";
|
|
return toMarkdownTable(data);
|
|
})();
|
|
|
|
const handleDownload = useCallback(() => {
|
|
const table = tableRef.current;
|
|
if (!table) return;
|
|
const data = parseTableData(table);
|
|
if (!data) return;
|
|
const csv = toCsvTable(data);
|
|
if (!csv) return;
|
|
downloadTextFile(csv, "table.csv", "text/csv;charset=utf-8");
|
|
}, []);
|
|
|
|
const handleDownloadMarkdown = useCallback(() => {
|
|
const table = tableRef.current;
|
|
if (!table) return;
|
|
const data = parseTableData(table);
|
|
if (!data) return;
|
|
const markdown = toMarkdownTable(data);
|
|
if (!markdown) return;
|
|
downloadTextFile(markdown, "table.md", "text/markdown;charset=utf-8");
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className="my-4 flex flex-col space-y-2"
|
|
data-streamdown="table-wrapper"
|
|
>
|
|
<div className="flex items-center justify-end gap-1">
|
|
<CopyButton className="text-muted-foreground hover:bg-transparent hover:text-foreground cursor-pointer p-1 transition-all" clipboardData={clipboardData} />
|
|
<DropdownMenu>
|
|
<Tooltip content={downloadLabel}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className="h-[32px] w-[32px] text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
|
type="button"
|
|
>
|
|
<DownloadIcon size={16} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end" className="min-w-[140px] p-1">
|
|
<DropdownMenuItem onSelect={handleDownload}>CSV</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={handleDownloadMarkdown}>
|
|
Markdown
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table
|
|
ref={tableRef}
|
|
className={cn(
|
|
"border-border w-full border-collapse border",
|
|
className,
|
|
)}
|
|
data-streamdown="table"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Renders markdown content. */
|
|
export function MarkdownContent({
|
|
content,
|
|
isLoading,
|
|
rehypePlugins,
|
|
className,
|
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
|
components: componentsFromProps,
|
|
}: MarkdownContentProps) {
|
|
const { t } = useI18n();
|
|
|
|
const components = useMemo(() => {
|
|
return {
|
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
|
if (typeof props.children === "string") {
|
|
const match = /^citation:(.+)$/.exec(props.children);
|
|
if (match) {
|
|
const [, text] = match;
|
|
return <CitationLink {...props}>{text}</CitationLink>;
|
|
}
|
|
}
|
|
const { className, target, rel, ...rest } = props;
|
|
const external = isExternalUrl(props.href);
|
|
return (
|
|
<a
|
|
{...rest}
|
|
className={cn(
|
|
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
|
className,
|
|
)}
|
|
target={target ?? (external ? "_blank" : undefined)}
|
|
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
|
/>
|
|
);
|
|
},
|
|
table: ({
|
|
children,
|
|
className,
|
|
...props
|
|
}: ComponentPropsWithoutRef<"table"> & { children?: ReactNode }) => (
|
|
<MarkdownTable
|
|
className={className}
|
|
copyLabel={t.clipboard.copyToClipboard}
|
|
downloadLabel={t.common.download}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</MarkdownTable>
|
|
),
|
|
...componentsFromProps,
|
|
};
|
|
}, [
|
|
componentsFromProps,
|
|
isLoading,
|
|
t.clipboard.copyToClipboard,
|
|
t.common.download,
|
|
]);
|
|
|
|
if (!content) return null;
|
|
|
|
return (
|
|
<MessageResponse
|
|
className={className}
|
|
isAnimating={isLoading}
|
|
controls={{ table: false }}
|
|
parseIncompleteMarkdown={!isLoading}
|
|
remarkPlugins={remarkPlugins}
|
|
rehypePlugins={rehypePlugins}
|
|
components={components}
|
|
>
|
|
{content}
|
|
</MessageResponse>
|
|
);
|
|
}
|