From a0ce3d8b089f7117b820d92d4db1e3bff7392d29 Mon Sep 17 00:00:00 2001 From: MT-Fire <798521692@qq.com> Date: Sun, 29 Mar 2026 00:06:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E6=81=A2=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20artifact-file-detail=20=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artifacts/artifact-file-detail.tsx | 555 ++++++++++++++---- 1 file changed, 448 insertions(+), 107 deletions(-) diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 267320b8..64e5cbe2 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -1,14 +1,11 @@ +import { DownloadIcon, FileTextIcon, LoaderIcon, FileTypeIcon } from "lucide-react"; import { - Code2Icon, - CopyIcon, - DownloadIcon, - EyeIcon, - LoaderIcon, - PackageIcon, - SquareArrowOutUpRightIcon, - XIcon, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; + useCallback, + useEffect, + useMemo, + useState, + type HTMLAttributes, +} from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -20,27 +17,26 @@ import { ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; -import { Select, SelectItem } from "@/components/ui/select"; import { - SelectContent, - SelectGroup, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { DropdownSelector } from "@/components/ui/dropdown-selector"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { useArtifactContent } from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; +import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; -import { env } from "@/env"; -import { cn } from "@/lib/utils"; +import { useMarkdownDownload } from "@/core/utils/markdown-download"; +import { cn, copyToClipboard, truncateMiddle } from "@/lib/utils"; -import { ArtifactLink } from "../citations/artifact-link"; -import { useThread } from "../messages/context"; -import { Tooltip } from "../tooltip"; +import { CitationLink } from "../citations/citation-link"; import { useArtifacts } from "./context"; @@ -54,7 +50,8 @@ export function ArtifactFileDetail({ threadId: string; }) { const { t } = useI18n(); - const { artifacts, setOpen, select } = useArtifacts(); + const { artifacts, setOpen, select, fullscreen, setFullscreen } = + useArtifacts(); const isWriteFile = useMemo(() => { return filepathFromProps.startsWith("write-file:"); }, [filepathFromProps]); @@ -80,9 +77,9 @@ export function ArtifactFileDetail({ } return checkCodeFile(filepath); }, [filepath, isWriteFile, isSkillFile]); - const isSupportPreview = useMemo(() => { - return language === "html" || language === "markdown"; - }, [language]); + const previewable = useMemo(() => { + return (language === "html" && !isWriteFile) || language === "markdown"; + }, [isWriteFile, language]); const { content } = useArtifactContent({ threadId, filepath: filepathFromProps, @@ -91,16 +88,62 @@ export function ArtifactFileDetail({ const displayContent = content ?? ""; + const artifactOptions = useMemo(() => { + return (artifacts ?? []).map((artifactPath) => ({ + value: artifactPath, + label: getFileName(artifactPath), + })); + }, [artifacts]); + const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [isInstalling, setIsInstalling] = useState(false); - const { isMock } = useThread(); + const [zoom, setZoom] = useState(80); + + // 获取文件名(不含路径) + const fileName = useMemo(() => getFileName(filepath), [filepath]); + + // 是否可以转换为docx/pdf(仅markdown文件支持) + const canConvertToDocxPdf = language === "markdown"; + + // 使用 Markdown 下载 hook + const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({ + onError: (error, format) => { + console.error(`Failed to download as ${format}:`, error); + toast.error(`Failed to download as ${format.toUpperCase()}`); + }, + }); + + // 下载为 DOCX + const handleDownloadDocx = useCallback(() => { + if (content) { + void downloadAsDocx(content, fileName); + } + }, [content, fileName, downloadAsDocx]); + + // 下载为 PDF + const handleDownloadPdf = useCallback(() => { + if (content) { + void downloadAsPdf(content, fileName); + } + }, [content, fileName, downloadAsPdf]); + + // 全屏切换处理 + const handleFullscreenToggle = useCallback(() => { + const newFullscreen = !fullscreen; + setFullscreen(newFullscreen); + sendToParent({ + type: POST_MESSAGE_TYPES.FULLSCREEN, + fullscreen: newFullscreen, + }); + }, [fullscreen, setFullscreen]); + useEffect(() => { - if (isSupportPreview) { + if (previewable) { setViewMode("preview"); } else { setViewMode("code"); } - }, [isSupportPreview]); + }, [previewable]); const handleInstallSkill = useCallback(async () => { if (isInstalling) return; @@ -123,38 +166,18 @@ export function ArtifactFileDetail({ setIsInstalling(false); } }, [threadId, filepath, isInstalling]); + return ( - - -
- - {isWriteFile ? ( -
{getFileName(filepath)}
- ) : ( - - )} -
-
-
- {isSupportPreview && ( + // 给滚动遮挡头部定位relative + + +
+ {previewable && ( { if (value) { @@ -163,47 +186,75 @@ export function ArtifactFileDetail({ }} > - + + + + + - + + + + )} + {/* 放大缩小选择器 */} +
-
+
+ + {isWriteFile ? ( +
{truncateMiddle(getFileName(filepath), 50)}
+ ) : ( + + )} +
+
+
- {!isWriteFile && filepath.endsWith(".skill") && ( - - - - )} - {!isWriteFile && ( - - - - )} {isCodeFile && ( { try { - await navigator.clipboard.writeText(displayContent ?? ""); + await copyToClipboard(displayContent ?? ""); toast.success(t.clipboard.copiedToClipboard); } catch (error) { toast.error("Failed to copy to clipboard"); @@ -211,49 +262,210 @@ export function ArtifactFileDetail({ } }} tooltip={t.clipboard.copyToClipboard} - /> + > + + + + + )} {!isWriteFile && ( - - - + + + + {isDownloading ? ( + + ) : ( + + + + + )} + + + + + + + {t.common.downloadOriginal} + + + {/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */} + {canConvertToDocxPdf && ( + <> + + + {isDownloading === "docx" ? t.common.loading : t.common.downloadAsDocx} + + + + {isDownloading === "pdf" ? t.common.loading : t.common.downloadAsPdf} + + + )} + + )} + {/* 全屏按钮 */} setOpen(false)} - tooltip={t.common.close} - /> + label={ + fullscreen ? t.common.closeFullScreen : t.common.fullScreen + } + onClick={handleFullscreenToggle} + tooltip={ + fullscreen ? t.common.closeFullScreen : t.common.fullScreen + } + > + {fullscreen ? ( + + + + + + + ) : ( + + + + )} + + {!fullscreen && ( + setOpen(false)} + tooltip={t.common.close} + > + + + + + )}
- - {isSupportPreview && + + {/* 遮挡多余的滚动顶部 */} +
+ {previewable && viewMode === "preview" && (language === "markdown" || language === "html") && ( )} {isCodeFile && viewMode === "code" && ( )} {!isCodeFile && (