refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试

This commit is contained in:
ddcat666 committed 2026-09-30 10:01:39 +08:00
1 parent e0a2697c60
commit 7f5d87ef1a
186 files changed
+33727 -29905

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
// 画布节点复制到系统剪贴板:图片统一转成 PNG 写入。
//
// 资源型图片必须经资源访问合同读取(本地/代理分发需要会话 Cookie,CDN 分发又必须不带 Cookie),
// 因此只从资源缓存取 Blob,不回退到直接 fetch 源地址。
import { getCachedResourceBlob } from "@/services/resource-blob-cache";
export async function copyImageToSystemClipboard(source: string, storageKey?: string) {
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片");
if (typeof window !== "undefined") window.focus();
const fetchPNG = async (): Promise<Blob> => {
let sourceBlob: Blob | null = null;
if (storageKey) {
sourceBlob = await getCachedResourceBlob(storageKey).catch(() => null);
// A resource-backed image must be read through the resource access
// contract. Do not fall back to fetch(source) here: local/proxy
// deliveries may require the session cookie and a separate API
// origin, while CDN deliveries must omit that cookie.
if (!sourceBlob) throw new Error("图片资源读取失败");
}
if (!sourceBlob) {
const response = await fetch(source);
if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`);
sourceBlob = await response.blob();
}
return sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob);
};
// 优先尝试将 Promise 直接传给 ClipboardItem(现代浏览器标准),在用户激活手势内立即声明写入,
// 避免因 fetch / 格式转换耗时导致手势过期或窗口失焦抛出 "Document is not focused" 错误。
try {
const item = new ClipboardItem({ "image/png": fetchPNG() });
await navigator.clipboard.write([item]);
return;
} catch {
// 部分浏览器环境不支持延迟 Promise,回退到先取 Blob 再写入
}
const blob = await fetchPNG();
if (typeof window !== "undefined") window.focus();
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
}
export async function convertClipboardImageToPNG(blob: Blob) {
if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式");
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("当前浏览器无法处理这张图片");
context.drawImage(bitmap, 0, 0);
return await new Promise<Blob>((resolve, reject) => canvas.toBlob((value) => (value ? resolve(value) : reject(new Error("图片格式转换失败"))), "image/png"));
} finally {
bitmap.close();
}
}
+8 -75
View File
@@ -3,20 +3,18 @@ import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
import { canCancelGenerationTask } from "@/lib/generation-task-display";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams, useSearchParams } from "react-router";
import { loadAssetsForUse } from "@/services/user-data-sync";
import { canvasAssetHandoffIds } from "@/lib/canvas/canvas-asset-handoff";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { uploadMediaFile } from "@/services/file-storage";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer";
import { getActiveUserScope } from "@/lib/user-scope";
import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
import { uploadImage } from "@/services/image-storage";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
import { getCachedResourceBlob } from "@/services/resource-blob-cache";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
@@ -74,7 +72,7 @@ import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog";
import { batchReferenceColumns, promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table";
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
import { useFocusMode } from "@/hooks/use-focus-mode";
@@ -92,7 +90,7 @@ import {
replaceCanvasReferenceMentions,
type CanvasResourceReference,
} from "@/lib/canvas/canvas-resource-references";
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
@@ -105,7 +103,7 @@ import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvas
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { cancelGenerationTask } from "@/services/api/task-center";
import { CanvasSyncStatus } from "./canvas-sync-status";
import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
@@ -164,15 +162,14 @@ import {
type StoryboardColumn,
type StoryboardShotCount,
type StoryboardShotDuration,
type CanvasWorkflowKind,
type CanvasWorkspaceMode,
type CanvasToolMode,
type ContextMenuState,
type Position,
type ViewportTransform,
} from "@/types/canvas";
import type { ReferenceImage } from "@/types/image";
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
import { copyImageToSystemClipboard } from "./canvas-clipboard";
const CanvasDirectorWorkbench = lazy(() => import("@/components/canvas/director/canvas-director-workbench").then((module) => ({ default: module.CanvasDirectorWorkbench })));
const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-drawing-editor-modal").then((module) => ({ default: module.CanvasDrawingEditorModal })));
@@ -180,60 +177,6 @@ const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-d
const NODE_STATUS_SUCCESS = "success" as const;
const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
async function copyImageToSystemClipboard(source: string, storageKey?: string) {
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片");
if (typeof window !== "undefined") window.focus();
const fetchPNG = async (): Promise<Blob> => {
let sourceBlob: Blob | null = null;
if (storageKey) {
sourceBlob = await getCachedResourceBlob(storageKey).catch(() => null);
// A resource-backed image must be read through the resource access
// contract. Do not fall back to fetch(source) here: local/proxy
// deliveries may require the session cookie and a separate API
// origin, while CDN deliveries must omit that cookie.
if (!sourceBlob) throw new Error("图片资源读取失败");
}
if (!sourceBlob) {
const response = await fetch(source);
if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`);
sourceBlob = await response.blob();
}
return sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob);
};
// 优先尝试将 Promise 直接传给 ClipboardItem(现代浏览器标准),在用户激活手势内立即声明写入,
// 避免因 fetch / 格式转换耗时导致手势过期或窗口失焦抛出 "Document is not focused" 错误。
try {
const item = new ClipboardItem({ "image/png": fetchPNG() });
await navigator.clipboard.write([item]);
return;
} catch {
// 部分浏览器环境不支持延迟 Promise,回退到先取 Blob 再写入
}
const blob = await fetchPNG();
if (typeof window !== "undefined") window.focus();
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
}
async function convertClipboardImageToPNG(blob: Blob) {
if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式");
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("当前浏览器无法处理这张图片");
context.drawImage(bitmap, 0, 0);
return await new Promise<Blob>((resolve, reject) => canvas.toBlob((value) => (value ? resolve(value) : reject(new Error("图片格式转换失败"))), "image/png"));
} finally {
bitmap.close();
}
}
function visibleGenerationBatch(node: CanvasNodeData) {
const batches = node.metadata?.generationBatches || [];
for (let index = batches.length - 1; index >= 0; index -= 1) {
@@ -268,9 +211,7 @@ function InfiniteCanvasPage() {
const toolbarHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const assetHandoffRef = useRef("");
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const assets = useAssetStore((state) => state.assets);
const assetsHydrated = useAssetStore((state) => state.hydrated);
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
@@ -757,7 +698,6 @@ function InfiniteCanvasPage() {
handleUploadRequest,
imageInputRef,
openAssetsAtPosition,
pasteAssistantImage,
pasteSystemClipboard,
replaceNodeMedia,
createFileNode,
@@ -1104,7 +1044,6 @@ function InfiniteCanvasPage() {
startBatchConnection,
mouseWorld,
pendingConnectionCreate,
setConnecting,
} = useCanvasConnectionController({
projectId,
config: effectiveConfig,
@@ -1590,7 +1529,7 @@ function InfiniteCanvasPage() {
}),
[addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent],
);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({
const { dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({
projectId,
domainProjectId: currentProject?.projectId,
projectTitle: currentProject?.title || "未命名画布",
@@ -1612,7 +1551,7 @@ function InfiniteCanvasPage() {
focusSelection: fitCanvasSelection,
});
const { selectCanvasStyle, applyCanvasStyleAsync, styleApplying } = useCanvasStyleWorkflow({
const { selectCanvasStyle, styleApplying } = useCanvasStyleWorkflow({
canvasId: projectId,
domainProjectId: currentProject?.projectId,
nodesRef,
@@ -1730,12 +1669,6 @@ function InfiniteCanvasPage() {
beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
});
const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {
chatSessionsRef.current = sessions;
activeChatIdRef.current = activeId;
setChatSessions(sessions);
setActiveChatId(activeId);
}, []);
const startTitleEditing = useCallback(() => {
setTitleDraft(currentProject?.title || "未命名画布");