import { useUserStore } from "@/stores/use-user-store"; import { FEED_TABS, SUB_TAB_TAGS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation"; type FeedTab = ToolScope; import { memo, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Eye, Loader2, MoreHorizontal, Plus, Star, Trash2, Upload, Wrench, X } from "lucide-react"; import { App, Button, Dropdown, Form, Input, Radio, Space, type MenuProps } from "antd"; import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview"; import { AppModal } from "@/components/ui/product/app-modal"; import { VideoPlayer } from "@/components/video-player"; import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state"; import { uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { createTool, deleteTool, listTools, setToolFavorite, type ToolScope, type ToolSummary, type ToolType, type ToolVisibility } from "@/services/api/tools"; import { Select } from "@/components/ui/base/select"; type ToolSubTab = "style" | "effect" | "motion" | "nine_grid"; // 工具类型与后端一致。 const TOOL_SUB_TABS: Array<{ id: ToolSubTab; type: ToolType; label: string }> = [ { id: "style", type: "style", label: "风格" }, { id: "nine_grid", type: "nine_grid", label: "九宫格" }, { id: "effect", type: "effect", label: "特效" }, { id: "motion", type: "motion", label: "运镜" }, ]; const TOOL_PAGE_SIZE = 40; const TOOL_SEARCH_DEBOUNCE_MS = 250; const VIDEO_URL_RE = /\.(mp4|webm|mov|m4v)(?:$|[?#])/i; // 上传到资源服务的文件 URL(/api/resources/:id/file)没有扩展名,按路径形态识别。 const RESOURCE_FILE_URL_RE = /\/resources\/[^/?#]+\/file(?:[?#]|$)/i; function isVideoUrl(url: string) { return VIDEO_URL_RE.test(url) || RESOURCE_FILE_URL_RE.test(url); } // 悬停播放预览视频的工具类型:运镜、特效 const HOVER_VIDEO_TYPES = new Set(["motion", "effect"]); type CreateToolFormValues = { label: string; tag?: string; visibility: ToolVisibility; desc?: string; prompt: string; cover?: string; mediaUrl?: string; ratio?: string; }; export type CanvasWorkspaceToolPanelProps = { onInsert?: (tool: ToolSummary) => void; }; // 种子数据的 mediaUrl 多为相对路径(如 "period_idol/cover.webp"),无法直接预览; // 绝对 http(s) URL 原样保留,站内资源路径(/api/resources/…)按当前 origin 绝对化。 function resolveToolPreview(tool: ToolSummary): { kind: "video" | "image"; src: string } | null { const media = toAbsoluteUrl(tool.mediaUrl); if (media && isVideoUrl(media)) return { kind: "video", src: media }; const cover = toAbsoluteUrl(tool.cover) || media; return cover ? { kind: "image", src: cover } : null; } export function CanvasWorkspaceToolPanel({ onInsert }: CanvasWorkspaceToolPanelProps) { const userId = useUserStore((s) => s.user?.id); const { message, modal } = App.useApp(); const queryClient = useQueryClient(); const [subTab, setSubTab] = useState("style"); const [feedTab, setFeedTab] = useState("public"); const [tagFilter, setTagFilter] = useState(""); const [searchInput, setSearchInput] = useState(""); const [searchText, setSearchText] = useState(""); const [previewTool, setPreviewTool] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [createForm] = Form.useForm(); const [coverUploading, setCoverUploading] = useState(false); const [mediaUploading, setMediaUploading] = useState(false); const coverInputRef = useRef(null); const mediaInputRef = useRef(null); // 上传成功才回填表单;本地降级(pendingRemoteUpload)拿到的是页面级 objectURL, // 刷新即失效,绝不能写进工具数据。 async function handleCoverUpload(file: File) { setCoverUploading(true); try { const image = await uploadImage(file); if (image.pendingRemoteUpload) throw new Error(image.remoteUploadError || "图片暂存本机,尚未上传到云端,请稍后重试"); createForm.setFieldValue("cover", toAbsoluteUrl(image.url)); message.success("封面已上传"); } catch (error) { message.error(error instanceof Error ? error.message : "封面上传失败"); } finally { setCoverUploading(false); } } async function handleMediaUpload(file: File) { setMediaUploading(true); try { const uploaded = await uploadMediaFile(file, "video"); if (uploaded.pendingRemoteUpload) throw new Error(uploaded.remoteUploadError || "视频暂存本机,尚未上传到云端,请稍后重试"); createForm.setFieldValue("mediaUrl", toAbsoluteUrl(uploaded.url)); message.success("演示视频已上传"); } catch (error) { message.error(error instanceof Error ? error.message : "视频上传失败"); } finally { setMediaUploading(false); } } useEffect(() => { const timer = window.setTimeout(() => setSearchText(searchInput.trim()), TOOL_SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timer); }, [searchInput]); const activeType = TOOL_SUB_TABS.find((tab) => tab.id === subTab)?.type ?? "style"; const subTabLabel = TOOL_SUB_TABS.find((tab) => tab.id === subTab)?.label || ""; const feedTabLabel = FEED_TABS.find((tab) => tab.id === feedTab)?.label || ""; const tagChips = useMemo(() => { const tags = subTab === "style" ? SUB_TAB_TAGS.style : subTab === "motion" ? SUB_TAB_TAGS.motion : []; return [{ id: "", label: "全部" }, ...tags]; }, [subTab]); const tagOptions = useMemo(() => { const tags = subTab === "style" ? SUB_TAB_TAGS.style : subTab === "motion" ? SUB_TAB_TAGS.motion : []; return tags.map((tag) => ({ value: tag.id, label: tag.label })); }, [subTab]); const toolsQuery = useInfiniteQuery({ queryKey: ["canvas-tools", userId, feedTab, activeType, tagFilter, searchText], queryFn: ({ pageParam, signal }) => listTools( { page: pageParam as number, pageSize: TOOL_PAGE_SIZE, scope: feedTab, type: activeType, tag: tagFilter || undefined, search: searchText || undefined, }, { signal }, ), initialPageParam: 1, getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined), enabled: Boolean(userId), }); const tools = useMemo(() => toolsQuery.data?.pages.flatMap((page) => page.tools) ?? [], [toolsQuery.data]); const totalCount = toolsQuery.data?.pages[0]?.totalCount ?? 0; const invalidateTools = () => queryClient.invalidateQueries({ queryKey: ["canvas-tools"] }); const favoriteMutation = useMutation({ mutationFn: ({ id, favorite }: { id: number; favorite: boolean }) => setToolFavorite(id, favorite), onSuccess: () => void invalidateTools(), onError: (error) => message.error(error instanceof Error ? error.message : "收藏状态更新失败"), }); const deleteMutation = useMutation({ mutationFn: deleteTool, onSuccess: () => { void invalidateTools(); message.success("工具已删除"); }, onError: (error) => message.error(error instanceof Error ? error.message : "工具删除失败"), }); const createMutation = useMutation({ mutationFn: (values: CreateToolFormValues) => createTool({ type: activeType, label: values.label.trim(), desc: values.desc?.trim() || undefined, tag: values.tag?.trim() || undefined, cover: values.cover?.trim() || undefined, prompt: values.prompt.trim(), ratio: values.ratio?.trim() || undefined, mediaUrl: values.mediaUrl?.trim() || undefined, visibility: values.visibility, }), onSuccess: () => { message.success("工具已创建"); setCreateOpen(false); setFeedTab("custom"); void invalidateTools(); }, onError: (error) => message.error(error instanceof Error ? error.message : "工具创建失败"), }); function confirmDelete(tool: ToolSummary) { modal.confirm({ title: `删除“${tool.label}”?`, content: "删除后不可恢复,该工具的收藏记录也会一同移除。", okText: "删除", cancelText: "取消", okButtonProps: { danger: true }, onOk: () => deleteMutation.mutateAsync(tool.id), }); } function handleScroll(event: UIEvent) { const el = event.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 120) return; if (toolsQuery.hasNextPage && !toolsQuery.isFetchingNextPage) void toolsQuery.fetchNextPage(); } const emptyTitle = feedTab === "favorites" ? "还没有收藏工具" : feedTab === "custom" ? "还没有自定义工具" : "没有匹配工具"; const emptyDescription = feedTab === "custom" ? "点击右上角“+”新建一个工具。" : "换一个分类或关键词继续搜索。"; const preview = previewTool ? resolveToolPreview(previewTool) : null; return ( <>
工具 {totalCount.toLocaleString("zh-CN")}
{TOOL_SUB_TABS.map((tab) => ( ))}
{FEED_TABS.map((tab) => ( ))}
setSearchInput(event.target.value)} aria-label="搜索工具" /> {searchInput ? ( ) : null}
{tagChips.map((tag) => ( ))}
{toolsQuery.isLoading ? ( ) : toolsQuery.isError ? ( void toolsQuery.refetch()} /> ) : tools.length ? ( <>
{tools.map((tool) => ( favoriteMutation.mutate({ id: item.id, favorite: !item.favorited })} onDelete={confirmDelete} /> ))}
{toolsQuery.isFetchingNextPage ? (
正在加载…
) : null} ) : ( )}
{previewTool && preview ? ( preview.kind === "video" ? ( setPreviewTool(null)} footer={null} width="min(1200px, calc(100vw - 32px))"> ) : ( setPreviewTool(null)} /> ) ) : null} setCreateOpen(false)} onOk={() => createForm.submit()} afterClose={() => createForm.resetFields()} > form={createForm} layout="vertical" requiredMark={false} initialValues={{ visibility: "private" }} onFinish={(values) => createMutation.mutate(values)} className="max-h-[72vh] overflow-y-auto p-6 px-8">
{tagOptions.length ? }

预览仅支持本人上传的资源;公开工具请移除私人预览。

{HOVER_VIDEO_TYPES.has(activeType) ? ( ) : null} { const file = event.target.files?.[0]; event.target.value = ""; if (file) void handleCoverUpload(file); }} /> {HOVER_VIDEO_TYPES.has(activeType) ? ( { const file = event.target.files?.[0]; event.target.value = ""; if (file) void handleMediaUpload(file); }} /> ) : null} {/* */}
); } const ToolGridSkeleton = memo(function ToolGridSkeleton() { return (
{Array.from({ length: 8 }, (_, index) => (
))}
); }); const ToolPresetCard = memo(function ToolPresetCard({ tool, feedTabLabel, favoritePending, onInsert, onView, onToggleFavorite, onDelete, }: { tool: ToolSummary; feedTabLabel: string; favoritePending: boolean; onInsert?: (tool: ToolSummary) => void; onView?: (tool: ToolSummary) => void; onToggleFavorite?: (tool: ToolSummary) => void; onDelete?: (tool: ToolSummary) => void; }) { const [failed, setFailed] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [hovered, setHovered] = useState(false); const [videoReady, setVideoReady] = useState(false); const [videoFailed, setVideoFailed] = useState(false); const rootRef = useRef(null); const videoRef = useRef(null); const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" }; const coverUrl = toAbsoluteUrl(tool.cover); const reduceMotion = useMemo(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches, []); const mediaAbsoluteUrl = toAbsoluteUrl(tool.mediaUrl); const hoverVideoUrl = mediaAbsoluteUrl && isVideoUrl(mediaAbsoluteUrl) ? mediaAbsoluteUrl : ""; const showHoverVideo = !reduceMotion && HOVER_VIDEO_TYPES.has(tool.type) && Boolean(hoverVideoUrl) && !videoFailed; useEffect(() => { if (!menuOpen) return; const handler = (e: PointerEvent) => { const target = e.target instanceof Element ? e.target : null; if (target && !rootRef.current?.contains(target) && !target.closest(".ant-dropdown")) { setMenuOpen(false); } }; document.addEventListener("pointerdown", handler, true); return () => document.removeEventListener("pointerdown", handler, true); }, [menuOpen]); // 视频只在悬停时挂载;浏览器自动播放策略要求静音,React 首次渲染的 muted 属性不一定写入 DOM 属性,需手动赋值后再 play。 useEffect(() => { if (!hovered || !showHoverVideo) { setVideoReady(false); return; } const video = videoRef.current; if (!video) return; video.muted = true; // 缓存命中时 canplay 可能已派发,直接按 readyState 标记可淡入。 if (video.readyState >= 3) setVideoReady(true); const handle = window.setTimeout(() => { void video.play().catch(() => {}); }, 0); return () => window.clearTimeout(handle); }, [hovered, showHoverVideo]); const menuItems: MenuProps["items"] = useMemo(() => { const items: NonNullable = []; items.push({ key: "insert", icon: , label: "插入画布", onClick: () => onInsert?.(tool) }); items.push({ key: "view", icon: , label: "查看", onClick: () => onView?.(tool) }); items.push({ type: "divider" as const }); items.push({ key: "favorite", icon: , label: tool.favorited ? "取消收藏" : "收藏", onClick: () => onToggleFavorite?.(tool), }); if (tool.source === "user" && tool.ownerId === useUserStore.getState().user?.id) { items.push({ type: "divider" as const }); items.push({ key: "delete", danger: true, icon: , label: "删除", onClick: () => onDelete?.(tool) }); } return items; }, [tool, onInsert, onView, onToggleFavorite, onDelete]); return (
{ if (showHoverVideo) setHovered(true); }} onMouseLeave={() => setHovered(false)} onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }} >
{coverUrl && !failed ? ( setFailed(true)} /> ) : (
)} {/* 悬停播放的预览视频:仅 hover 时挂载,静音/循环/内联满足各浏览器自动播放策略,首帧就绪后淡入覆盖封面 */} {showHoverVideo && hovered ? (
{tool.label} {tool.desc ? {tool.desc} : null}
); });