feat(canvas): 绘图工具 - 集成 Excalidraw 并支持后台切换默认引擎
This commit is contained in:
1 parent
7fa5d7215a
commit
6058602bf2
28 files changed
+1617
-177
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { Tooltip } from "antd";
|
||||
import { ArrowLeft, BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Infinity as InfinityIcon, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
|
||||
import { ArrowLeft, BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Infinity as InfinityIcon, Mail, MessageSquareText, Paintbrush, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link, NavLink, Outlet } from "react-router";
|
||||
|
||||
@@ -40,6 +40,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{
|
||||
label: "系统配置",
|
||||
items: [
|
||||
{ path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: <Paintbrush className="size-4" /> },
|
||||
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
|
||||
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" /> },
|
||||
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { App, Button, Segmented, Tag } from "antd";
|
||||
import { Brush, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { drawingEngineLabel, isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import { getAdminDrawingEngineSetting, updateAdminDrawingEngineSetting } from "@/services/api/auth";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
export default function DrawingEngineSettingsPage() {
|
||||
const { message } = App.useApp();
|
||||
const current = useUserStore((state) => state.drawingEngine);
|
||||
const setDrawingEngine = useUserStore((state) => state.setDrawingEngine);
|
||||
const [engine, setEngine] = useState<CanvasDrawingEngine>(current.defaultEngine);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getAdminDrawingEngineSetting().then(({ setting }) => {
|
||||
if (cancelled) return;
|
||||
setDrawingEngine(setting);
|
||||
setEngine(setting.defaultEngine);
|
||||
}).catch((error) => {
|
||||
if (!cancelled) message.error(error instanceof Error ? error.message : "读取绘图工具配置失败");
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [message, setDrawingEngine]);
|
||||
|
||||
const save = async () => {
|
||||
if (!isDrawingEngineAvailable(engine)) {
|
||||
message.error("当前生产构建未配置 tldraw License Key");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { setting } = await updateAdminDrawingEngineSetting(engine);
|
||||
setDrawingEngine(setting);
|
||||
setEngine(setting.defaultEngine);
|
||||
message.success("默认绘图工具已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存绘图工具配置失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = engine !== current.defaultEngine;
|
||||
return (
|
||||
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器">
|
||||
<div className="mx-auto max-w-4xl space-y-5">
|
||||
<SettingsSectionCard icon={<Brush className="size-4" />} title="默认绘图引擎" description="已有绘图保持原引擎,仅新建绘图使用这里的选择。" status={<Tag variant="filled" color="blue">{drawingEngineLabel(current.defaultEngine)}</Tag>}>
|
||||
<div className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Segmented<CanvasDrawingEngine>
|
||||
block
|
||||
className="w-full sm:max-w-md"
|
||||
disabled={loading || saving}
|
||||
value={engine}
|
||||
onChange={setEngine}
|
||||
options={[
|
||||
{ label: "Excalidraw", value: "excalidraw" },
|
||||
{ label: "tldraw", value: "tldraw", disabled: !isDrawingEngineAvailable("tldraw") },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || !dirty} onClick={() => void save()}>保存配置</Button>
|
||||
</div>
|
||||
{!isDrawingEngineAvailable("tldraw") ? <div className="border-t border-border px-5 py-3 text-xs text-amber-600 dark:text-amber-400">当前生产构建没有 tldraw License Key,不能将其设为新建默认值。</div> : null}
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +91,13 @@ export default function CanvasPage() {
|
||||
),
|
||||
);
|
||||
await Promise.all(data.projects.map(async (item) => {
|
||||
const importedProjectId = importProject(item.project);
|
||||
const drawingEngineById = new Map((item.drawingDocuments || []).map((document) => [document.drawingId, document.engine || "tldraw"]));
|
||||
const importedProjectId = importProject({
|
||||
...item.project,
|
||||
nodes: item.project.nodes.map((node) => node.type === "drawing" && node.metadata?.drawingId
|
||||
? { ...node, metadata: { ...node.metadata, drawingEngine: drawingEngineById.get(node.metadata.drawingId) || node.metadata.drawingEngine || "tldraw" } }
|
||||
: node),
|
||||
});
|
||||
await Promise.all((item.drawingDocuments || []).map((document) => {
|
||||
const previewFile = document.previewPath ? zip.get(document.previewPath) : undefined;
|
||||
const preview = previewFile && !previewFile.type ? previewFile.slice(0, previewFile.size, "image/png") : previewFile;
|
||||
@@ -107,7 +113,16 @@ export default function CanvasPage() {
|
||||
background: document.generationRender.background,
|
||||
} satisfies CanvasDrawingRenderDraft
|
||||
: undefined;
|
||||
return saveCanvasDrawing(importedProjectId, document.drawingId, document.snapshot, { ...document, version: 1, revision: Math.max(0, document.revision - 1) }, preview, render);
|
||||
const engine = document.engine || "tldraw";
|
||||
return saveCanvasDrawing(importedProjectId, document.drawingId, engine, document.snapshot, {
|
||||
version: 2,
|
||||
engine,
|
||||
snapshot: document.snapshot,
|
||||
revision: Math.max(0, document.revision - 1),
|
||||
updatedAt: document.updatedAt,
|
||||
shapeCount: document.shapeCount,
|
||||
pageCount: document.pageCount,
|
||||
}, preview, render);
|
||||
}));
|
||||
}));
|
||||
message.success(`已导入 ${data.projects.length} 个画布`);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
|
||||
import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { App } from "antd";
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
|
||||
@@ -192,6 +193,7 @@ function InfiniteCanvasPage() {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
|
||||
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
|
||||
const [connections, setConnections] = useState<CanvasConnection[]>([]);
|
||||
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
|
||||
@@ -603,6 +605,7 @@ function InfiniteCanvasPage() {
|
||||
toggleNodeLocked,
|
||||
} = useCanvasNodeOperations({
|
||||
projectId,
|
||||
defaultDrawingEngine,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
selectedNodeIdsRef,
|
||||
@@ -629,6 +632,7 @@ function InfiniteCanvasPage() {
|
||||
setConnecting,
|
||||
} = useCanvasConnectionController({
|
||||
projectId,
|
||||
defaultDrawingEngine,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
viewportRef,
|
||||
@@ -1668,7 +1672,7 @@ function InfiniteCanvasPage() {
|
||||
open={Boolean(drawingNode)}
|
||||
onClose={() => setDrawingNodeId(null)}
|
||||
onSaved={(nodeId, summary) => {
|
||||
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, drawingRevision: summary.revision, drawingUpdatedAt: summary.updatedAt, drawingShapeCount: summary.shapeCount, drawingPageCount: summary.pageCount } } : node));
|
||||
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, drawingEngine: summary.engine, drawingRevision: summary.revision, drawingUpdatedAt: summary.updatedAt, drawingShapeCount: summary.shapeCount, drawingPageCount: summary.pageCount } } : node));
|
||||
message.success("绘图已保存");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,11 +6,13 @@ import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspa
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
|
||||
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type UseCanvasConnectionControllerOptions = {
|
||||
projectId: string;
|
||||
defaultDrawingEngine: CanvasDrawingEngine;
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
viewportRef: { current: ViewportTransform };
|
||||
@@ -38,6 +40,7 @@ const NODE_STATUS_IDLE = "idle" as const;
|
||||
|
||||
export function useCanvasConnectionController({
|
||||
projectId,
|
||||
defaultDrawingEngine,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
viewportRef,
|
||||
@@ -111,11 +114,17 @@ export function useCanvasConnectionController({
|
||||
}, [connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
|
||||
|
||||
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
|
||||
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine)) {
|
||||
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
|
||||
return;
|
||||
}
|
||||
const storyboardRow = type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
|
||||
const videoPrompt = storyboardRow ? (storyboardRow.videoMotionPrompt || storyboardRow.plotDescription).trim() : "";
|
||||
const sourceNode = pending.connection.handleType === "source" ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
|
||||
const scriptPrompt = type === CanvasNodeType.Script && sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : "";
|
||||
const metadata = type === CanvasNodeType.Script && scriptPrompt
|
||||
const metadata = type === CanvasNodeType.Drawing
|
||||
? { drawingEngine: defaultDrawingEngine }
|
||||
: type === CanvasNodeType.Script && scriptPrompt
|
||||
? { prompt: scriptPrompt, composerContent: scriptPrompt }
|
||||
: type === CanvasNodeType.Video && storyboardRow
|
||||
? { prompt: videoPrompt, composerContent: videoPrompt, generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
|
||||
@@ -148,7 +157,7 @@ export function useCanvasConnectionController({
|
||||
closeConnectionCreateMenu();
|
||||
setConnecting(null);
|
||||
try {
|
||||
const saved = await createCanvasDrawingFromImage(projectId, newNode.metadata.drawingId, {
|
||||
const saved = await createCanvasDrawingFromImage(projectId, newNode.metadata.drawingId, defaultDrawingEngine, {
|
||||
url: sourceUrl,
|
||||
storageKey: drawingSourceNode.metadata?.storageKey,
|
||||
name: drawingSourceNode.title || "来源图片",
|
||||
@@ -157,6 +166,7 @@ export function useCanvasConnectionController({
|
||||
newNode.title = `${drawingSourceNode.title || "图片"} · 绘图`;
|
||||
newNode.metadata = {
|
||||
...newNode.metadata,
|
||||
drawingEngine: saved.engine,
|
||||
drawingRevision: saved.revision,
|
||||
drawingUpdatedAt: saved.updatedAt,
|
||||
drawingShapeCount: saved.shapeCount,
|
||||
@@ -180,7 +190,7 @@ export function useCanvasConnectionController({
|
||||
else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
closeConnectionCreateMenu();
|
||||
setConnecting(null);
|
||||
}, [closeConnectionCreateMenu, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
}, [closeConnectionCreateMenu, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
|
||||
const world = screenToCanvas(clientX, clientY);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createCanvasNode, removeCanvasNodes } from "@/lib/canvas/canvas-project
|
||||
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
|
||||
type CanvasClipboard = {
|
||||
nodes: CanvasNodeData[];
|
||||
@@ -21,6 +22,7 @@ const CANVAS_NODES_CLIPBOARD_STORAGE_KEY = "open-ai-canvas:nodes-clipboard";
|
||||
|
||||
type UseCanvasNodeOperationsOptions = {
|
||||
projectId: string;
|
||||
defaultDrawingEngine: CanvasDrawingEngine;
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
selectedNodeIdsRef: { current: Set<string> };
|
||||
@@ -36,6 +38,7 @@ type UseCanvasNodeOperationsOptions = {
|
||||
|
||||
export function useCanvasNodeOperations({
|
||||
projectId,
|
||||
defaultDrawingEngine,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
selectedNodeIdsRef,
|
||||
@@ -100,6 +103,7 @@ export function useCanvasNodeOperations({
|
||||
...node,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
drawingEngine: saved.engine,
|
||||
drawingRevision: saved.revision,
|
||||
drawingUpdatedAt: saved.updatedAt,
|
||||
drawingShapeCount: saved.shapeCount,
|
||||
@@ -116,11 +120,15 @@ export function useCanvasNodeOperations({
|
||||
}, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const createNode = useCallback((type: CanvasNodeType, position?: Position) => {
|
||||
const node = createCanvasNode(type, position || getCanvasCenter());
|
||||
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine)) {
|
||||
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
|
||||
return;
|
||||
}
|
||||
const node = createCanvasNode(type, position || getCanvasCenter(), type === CanvasNodeType.Drawing ? { drawingEngine: defaultDrawingEngine } : undefined);
|
||||
commitNodes([...nodesRef.current, node]);
|
||||
selectNodes(new Set([node.id]));
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
|
||||
}, [commitNodes, getCanvasCenter, nodesRef, selectNodes, setDialogNodeId]);
|
||||
}, [commitNodes, defaultDrawingEngine, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId]);
|
||||
|
||||
const arrangeSelectedNodes = useCallback((mode: "row" | "column" | "grid" | "flow") => {
|
||||
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));
|
||||
|
||||
Reference in new issue
Block a user