feat(canvas): 画布能力 - 完善 LibTV 导入与资源预览

This commit is contained in:
ddCat committed 2026-08-19 07:16:07 +08:00
1 parent 4b9d510129
commit d2a089f1ec
36 files changed
+1722 -68

No files matched your search

@@ -8,6 +8,7 @@ import {
HardDrive,
Home,
Infinity as InfinityIcon,
KeyRound,
Layers3,
Mail,
MessageSquareText,
@@ -72,6 +73,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
{ path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: <HardDrive className="size-4" /> },
{ path: "/admin/settings/response-interception", label: "模型响应拦截", description: "替换用户可见的上游异常", icon: <ShieldAlert className="size-4" /> },
{ path: "/admin/settings/third-party", label: "第三方参数配置", description: "集中维护第三方平台凭证", icon: <KeyRound className="size-4" /> },
],
},
];
@@ -0,0 +1,143 @@
import { App, Button, Input, Popconfirm, Switch } from "antd";
import { KeyRound, Save, Trash2, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { getAdminLibTVSetting, testAdminLibTV, updateAdminLibTVSetting, type AdminLibTVSetting } from "@/services/api/libtv";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
export default function LibTVSettingsPage() {
const { message } = App.useApp();
const [setting, setSetting] = useState<AdminLibTVSetting>({ enabled: false, hasToken: false });
const [enabled, setEnabled] = useState(false);
const [token, setToken] = useState("");
const [testUuid, setTestUuid] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
let cancelled = false;
void getAdminLibTVSetting()
.then(({ setting: next }) => {
if (cancelled) return;
setSetting(next);
setEnabled(next.enabled);
})
.catch((error) => {
if (!cancelled) message.error(error instanceof Error ? error.message : "读取 LibTV 配置失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [message]);
const save = async () => {
setSaving(true);
try {
const { setting: next } = await updateAdminLibTVSetting({ enabled, token: token.trim() || undefined });
setSetting(next);
setToken("");
setEnabled(next.enabled);
message.success("LibTV 配置已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存 LibTV 配置失败");
} finally {
setSaving(false);
}
};
const clearToken = async () => {
setClearing(true);
try {
const { setting: next } = await updateAdminLibTVSetting({ enabled: false, clearToken: true });
setSetting(next);
setToken("");
setEnabled(false);
message.success("LibTV Token 已清空,导入功能已停用");
} catch (error) {
message.error(error instanceof Error ? error.message : "清空 LibTV Token 失败");
} finally {
setClearing(false);
}
};
const test = async () => {
if (!testUuid.trim()) {
message.error("请填写用于测试的 LibTV 画布 UUID");
return;
}
setTesting(true);
try {
await testAdminLibTV(testUuid.trim());
message.success("LibTV 连接测试成功");
} catch (error) {
message.error(error instanceof Error ? error.message : "LibTV 连接测试失败");
} finally {
setTesting(false);
}
};
return (
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。">
<div className="pt-4">
<SettingsSectionCard
icon={<KeyRound className="size-4" />}
title="LibTV 配置"
description="Token 仅保存在服务端加密配置中,不会回传到浏览器。"
status={<AdminStatusBadge label={setting.hasToken ? "已配置" : "未配置"} tone={setting.hasToken ? "success" : "warning"} />}
footer={
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs text-foreground/50">{setting.hasToken ? "已保存 Token;输入新值可替换,留空保存会保留现有 Token。" : "请先配置 Token,再启用导入功能。"}</span>
<div className="flex items-center gap-2">
{setting.hasToken ? (
<Popconfirm title="确认清空 LibTV Token?" description="清空后将同时停用 LibTV 画布导入。" okText="清空" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={clearToken}>
<Button danger icon={<Trash2 className="size-4" />} loading={clearing} disabled={loading || saving}>
清空 Token
</Button>
</Popconfirm>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || clearing} onClick={() => void save()}>
保存配置
</Button>
</div>
</div>
}
>
<div className="space-y-5 px-4 py-4">
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div>
<label className="mb-2 block text-sm font-medium">LibTV Token</label>
<Input.Password value={token} onChange={(event) => setToken(event.target.value)} placeholder={setting.hasToken ? "已配置(输入新 Token 可替换)" : "输入 LibTV Token"} disabled={loading || saving || clearing} autoComplete="new-password" />
</div>
<div>
<label className="mb-2 block text-sm font-medium">启用导入</label>
<div className="flex h-8 items-center gap-3">
<Switch checked={enabled} onChange={setEnabled} disabled={loading || saving || clearing} />
<span className="text-sm text-foreground/60">{enabled ? "已启用" : "已停用"}</span>
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<Wifi className="size-4" />
测试连接
</div>
<div className="flex flex-wrap gap-3">
<Input className="min-w-64 flex-1" value={testUuid} onChange={(event) => setTestUuid(event.target.value)} placeholder="输入可访问的 LibTV 画布 UUID" />
<Button loading={testing} onClick={() => void test()}>
测试连接
</Button>
</div>
<div className="mt-2 text-xs text-foreground/50">用于验证当前 Token 是否可读取指定 LibTV 画布,不会向当前画布写入节点。</div>
</div>
</div>
</SettingsSectionCard>
</div>
</AdminPageFrame>
);
}
@@ -1,7 +1,7 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Check, ChevronDown, Clapperboard, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Bot, Check, ChevronDown, Clapperboard, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
@@ -28,6 +28,7 @@ type CanvasTopBarProps = {
onCreateProject: () => void;
onDeleteProject: () => void;
onImportImage: () => void;
onImportLibTV: () => void;
onUndo: () => void;
onRedo: () => void;
onShare: () => void;
@@ -58,6 +59,7 @@ export function CanvasTopBar({
onCreateProject,
onDeleteProject,
onImportImage,
onImportLibTV,
onUndo,
onRedo,
onShare,
@@ -191,6 +193,9 @@ export function CanvasTopBar({
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CopyPlus className="size-4" />} onClick={onImportLibTV} aria-label="一键导入 LibTV 画布" title="一键导入 LibTV 画布">
<span className="hidden xl:inline">导入 LibTV</span>
</Button>
<Dropdown
trigger={["click"]}
menu={{
@@ -0,0 +1,176 @@
import { App, Button, Input, Modal, Tag } from "antd";
import { CircleAlert, ExternalLink, Import, LoaderCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { buildLibTVImagePreviewUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "@/lib/canvas/libtv-import";
import { importLibTVCanvas, type LibTVImportResult } from "@/services/api/libtv";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
type Props = {
open: boolean;
projectId: string;
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
onClose: () => void;
onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise<void>;
};
function buildCanvasNodes(result: LibTVImportResult, viewport: ViewportTransform, viewportSize: { width: number; height: number }) {
const minX = Math.min(...result.nodes.map((node) => node.x));
const minY = Math.min(...result.nodes.map((node) => node.y));
const maxX = Math.max(...result.nodes.map((node) => node.x + node.width));
const maxY = Math.max(...result.nodes.map((node) => node.y + node.height));
const offsetX = (viewportSize.width / 2 - viewport.x) / viewport.k - (minX + maxX) / 2;
const offsetY = (viewportSize.height / 2 - viewport.y) / viewport.k - (minY + maxY) / 2;
return result.nodes.map<CanvasNodeData>((node) => ({
id: node.id,
type: node.type === "video" ? CanvasNodeType.Video : CanvasNodeType.Image,
title: node.title,
position: { x: node.x + offsetX, y: node.y + offsetY },
width: node.width,
height: node.height,
metadata: {
content: node.content,
previewContent: node.type === "image" ? buildLibTVImagePreviewUrl(node.content) : undefined,
prompt: node.prompt,
model: node.model,
status: node.status || "idle",
errorDetails: node.errorDetails,
naturalWidth: node.naturalWidth,
naturalHeight: node.naturalHeight,
durationMs: node.durationMs,
mimeType: node.mimeType,
importSource: node.metadata,
},
}));
}
export function LibTVImportDialog({ open, projectId, viewport, viewportSize, onClose, onApply }: Props) {
const { message } = App.useApp();
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<LibTVImportResult | null>(null);
const uuid = useMemo(() => parseLibTVProjectUUID(value), [value]);
const reset = () => {
setValue("");
setResult(null);
};
const close = () => {
if (loading) return;
reset();
onClose();
};
const changeValue = (nextValue: string) => {
setValue(nextValue);
setResult(null);
};
const load = async () => {
if (!uuid) {
message.error("请填写 LibTV 画布 UUID 或链接");
return;
}
setLoading(true);
try {
setResult(await importLibTVCanvas(projectId, uuid));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取 LibTV 画布失败");
} finally {
setLoading(false);
}
};
const apply = async () => {
if (!result) return;
setLoading(true);
try {
await onApply(buildCanvasNodes(result, viewport, viewportSize), result.connections);
reset();
onClose();
message.success(`已导入 ${result.importedNodeCount} 个节点和 ${result.importedConnectionCount} 条连接`);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存导入结果失败");
} finally {
setLoading(false);
}
};
return (
<Modal
className="workspace-modal"
open={open}
onCancel={close}
title="导入 LibTV 画布"
width={620}
footer={
result ? (
[
<Button key="close" onClick={close}>
关闭
</Button>,
<Button key="apply" type="primary" icon={<Import className="size-4" />} loading={loading} onClick={() => void apply()}>
确认导入
</Button>,
]
) : (
<Button type="primary" loading={loading} onClick={() => void load()}>
读取画布
</Button>
)
}
>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">LibTV 画布 UUID 或链接</label>
<Input
value={value}
onChange={(event) => changeValue(event.target.value)}
placeholder="粘贴 32 位 UUID、画布链接或分享链接"
disabled={loading}
suffix={uuid && value !== uuid ? <ExternalLink className="size-4 text-foreground/35" /> : null}
/>
</div>
{loading && !result ? (
<div className="flex items-center gap-2 text-sm text-foreground/55">
<LoaderCircle className="size-4 animate-spin" />
正在读取 LibTV 画布…
</div>
) : null}
{result ? (
<div className="space-y-3">
<div className="rounded-xl p-4" style={{ background: "var(--library-surface)" }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{result.projectName || "LibTV 画布"}</div>
<div className="mt-1 text-sm text-foreground/60">可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线</div>
</div>
<Tag color="blue">批次:{formatLibTVBatchTime(result.batchCreatedAt)}</Tag>
</div>
<div className="mt-3 text-xs leading-5 text-foreground/50">节点会保留相对位置,并整体放到当前可视区域中心。</div>
</div>
{result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.staleNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.convertedSpecialCount ? (
<div className="flex items-start gap-2 rounded-lg px-3 py-2.5 text-xs leading-5 text-foreground/60" style={{ background: "var(--surface-hover)" }}>
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<div>
{result.skippedNodes.length ? <div>{result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。</div> : null}
{!result.skippedNodes.length && result.skippedConnections.length ? <div>{result.skippedConnections.length} 条无效连线已自动忽略。</div> : null}
{result.reusedFailedNodeCount ? <div>{result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。</div> : null}
{result.placeholderNodeCount ? <div>{result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。</div> : null}
{result.convertedSpecialCount ? <div>{result.convertedSpecialCount} 个特殊节点已转换为图片参考节点。</div> : null}
{result.multiResultNodeCount ? <div>{result.multiResultNodeCount} 个多结果节点已使用首个结果。</div> : null}
{result.staleNodeCount ? <div>{result.staleNodeCount} 个过期标记节点仍保留现有结果。</div> : null}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Tag>等待确认导入</Tag>
</div>
</div>
) : null}
</div>
</Modal>
);
}
+26
View File
@@ -72,6 +72,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
@@ -210,6 +211,7 @@ function InfiniteCanvasPage() {
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
const [directorNodeId, setDirectorNodeId] = useState<string | null>(null);
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(null);
const [libTVImportOpen, setLibTVImportOpen] = useState(false);
const codexAutoConnect = shouldAutoConnectCanvasRuntime(searchParams);
const codexCompactAgent = codexAutoConnect && readLocalRuntimeBootstrapState().legacyDeepLinkRejected;
const [titleEditing, setTitleEditing] = useState(false);
@@ -331,6 +333,28 @@ function InfiniteCanvasPage() {
cleanupAssetImages,
cleanupCanvasFiles,
});
const applyLibTVImport = useCallback(
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
const previousNodes = nodesRef.current;
const previousConnections = connectionsRef.current;
const nextNodes = [...nodesRef.current, ...importedNodes];
const nextConnections = [...connectionsRef.current, ...importedConnections];
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
const saved = await saveCanvasProject();
if (!saved) {
nodesRef.current = previousNodes;
connectionsRef.current = previousConnections;
setNodes(previousNodes);
setConnections(previousConnections);
throw new Error("画布保存失败,已撤销本次 LibTV 导入");
}
},
[saveCanvasProject, setConnections, setNodes],
);
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
const refetchLinkedProject = linkedProjectQuery.refetch;
@@ -1639,6 +1663,7 @@ function InfiniteCanvasPage() {
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onImportImage={() => handleUploadRequest()}
onImportLibTV={() => setLibTVImportOpen(true)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onShare={() => setShareModalOpen(true)}
@@ -1686,6 +1711,7 @@ function InfiniteCanvasPage() {
) : null}
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
<LibTVImportDialog open={libTVImportOpen} projectId={projectId} viewport={viewport} viewportSize={size} onClose={() => setLibTVImportOpen(false)} onApply={applyLibTVImport} />
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
@@ -187,7 +187,7 @@ export function useCanvasProjectLifecycle({
renameProject(projectId, title);
}, [projectId, renameProject]);
const saveCanvasProject = useCallback(async () => {
const saveCanvasProject = useCallback(async (): Promise<boolean> => {
try {
updateProject(projectId, {
nodes: nodesRef.current,
@@ -202,7 +202,7 @@ export function useCanvasProjectLifecycle({
await flushCanvasStorePersistence();
} catch {
message.error("画布保存失败,请稍后重试");
return;
return false;
}
try {
await saveRemoteUserDataNow();
@@ -211,6 +211,7 @@ export function useCanvasProjectLifecycle({
const detail = error instanceof Error ? error.message : "未知错误";
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
}
return true;
}, [activeChatId, backgroundMode, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const clearCanvasFiles = useCallback(() => {
@@ -109,20 +109,23 @@ export function useCanvasRenderModel({
});
return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) };
}, [nodes]);
const visibleNodes = useMemo(() => {
const renderBounds = useMemo(() => {
const padding = (reduceMediaEffects ? Math.max(240, Math.max(viewportSize.width, viewportSize.height) * 0.4) : Math.max(800, Math.max(viewportSize.width, viewportSize.height) * 1.5)) / viewport.k;
const viewLeft = -viewport.x / viewport.k - padding;
const viewTop = -viewport.y / viewport.k - padding;
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
const viewBottom = viewTop + viewportSize.height / viewport.k + padding * 2;
return { left: viewLeft, top: viewTop, right: viewRight, bottom: viewBottom };
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const visibleNodes = useMemo(() => {
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
nodes.forEach((node) => {
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= viewLeft || node.position.x >= viewRight || node.position.y + node.height <= viewTop || node.position.y >= viewBottom) return;
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom) return;
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [nodes, reduceMediaEffects, renderHiddenNodeIds, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
}, [nodes, renderBounds, renderHiddenNodeIds]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
@@ -228,8 +231,13 @@ export function useCanvasRenderModel({
if (displayFrom.id === displayTo.id) return [];
const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
return [{ connection, from, to }];
}), [collapsedBatchChildIds, connections, dragPreview, nodeById]);
}), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]);
const configInputsById = useMemo(() => {
const map = new Map<string, NodeGenerationInput[]>();
+2 -1
View File
@@ -5,6 +5,7 @@ import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2,
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
@@ -296,7 +297,7 @@ function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, o
<Modal className="workspace-modal workspace-modal-wide library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
{asset ? <div className="project-asset-preview-layout">
<div className={`project-asset-preview-stage ${previewClass}`}>
{asset.category === "character" ? characterCover ? <img src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <img src={personalAsset.data.dataUrl || personalAsset.coverUrl} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
{asset.category === "character" ? characterCover ? <CachedResourceImage storageKey={`resource:${characterCover.resourceId}`} src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" fallback={<div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div>} /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <AssetMediaPreview asset={personalAsset} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
</div>
<aside className="project-asset-preview-details">
<div className="project-asset-preview-eyebrow">{asset.category === "character" ? "角色卡" : mediaLabel(asset.mediaType)}</div>
@@ -2,6 +2,7 @@ import { Button, Popconfirm } from "antd";
import { Image as ImageIcon, Pencil, Sparkles, Trash2, UserRound, Volume2 } from "lucide-react";
import type { ReactNode } from "react";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { resourceFileUrl } from "@/services/api/resources";
import type { ProjectAsset } from "@/services/api/projects";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
@@ -30,7 +31,7 @@ export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEd
<AssetLibraryCard className="project-character-card">
<AssetLibraryCardMedia className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
<button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看角色卡:${asset.title}`}>
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
{cover ? <CachedResourceImage storageKey={`resource:${cover.resourceId}`} src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" fallback={<div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>} /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
</button>
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] font-medium text-white">角色卡 · v{character?.version || 1}</span>