feat(canvas): 画布能力 - 完善 LibTV 导入与资源预览
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user