refactor(*): 代码质量 - 优化前后端实现并完善协议与画布验证

This commit is contained in:
ddcat666 committed 2026-10-08 19:38:57 +08:00
1 parent d74f783912
commit 295374650a
724 files changed
+3045 -3884

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ import { type LucideIcon, FileText, Image as ImageIcon, Clapperboard, AudioLines
export type LibraryAsset = Asset;
export const ASSET_GRID_DENSITY_KEY = "infinite-canvas:asset-grid-density";
export const ASSET_GRID_DENSITY_KEY = "yingce:asset-grid-density";
export const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
text: FileText,
+39 -70
View File
@@ -1,77 +1,46 @@
import { saveAs } from "file-saver";
import { createZip, readZip } from "@/lib/zip";
import { getMediaBlob, setMediaBlob } from "@/services/file-storage";
import { getImageBlob, setImageBlob } from "@/services/image-storage";
import { readZip } from "@/lib/zip";
import { MediaPackage } from "@/lib/media-package";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import type { Asset } from "@/stores/use-asset-store";
import type { CanvasExportAsset } from "@/types/canvas-export";
type AssetExportFile = {
app: "infinite-canvas";
version: 1;
exportedAt: string;
assets: Asset[];
files: AssetExportItem[];
};
type AssetExportFile = { app: "yingce"; version: 1; exportedAt: string; assets: Asset[]; files: CanvasExportAsset[] };
type AssetExportItem = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
export async function exportAssets(assets: Asset[]) {
const files: AssetExportItem[] = [];
const zipFiles: { name: string; data: BlobPart }[] = [];
await Promise.all(
assets.map(async (asset) => {
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model") return;
const storageKey = asset.data.storageKey;
if (!storageKey) return;
const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return;
const path = `files/${safeFileName(storageKey)}.${fileExtension(blob.type, asset.kind)}`;
files.push({ storageKey, path, mimeType: blob.type || asset.data.mimeType, bytes: blob.size });
zipFiles.push({ name: path, data: blob });
}),
);
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, "我的素材.zip");
export async function exportAssets(assets: Asset[]): Promise<void> {
const media = assets.filter((asset) => ["image", "video", "audio", "model"].includes(asset.kind));
const images = new Set<string>();
const keys: string[] = [];
for (const asset of media) {
if (!("storageKey" in asset.data) || typeof asset.data.storageKey !== "string" || !asset.data.storageKey) continue;
keys.push(asset.data.storageKey);
if (asset.kind === "image") images.add(asset.data.storageKey);
}
const archive = new MediaPackage();
const manifest: AssetExportFile = { app: "yingce", version: 1, assets, exportedAt: new Date().toISOString(), files: await archive.collect(keys, "files", images) };
saveAs(await archive.finish("assets.json", manifest), "我的素材.zip");
}
export async function readAssetPackage(file: File) {
const zip = await readZip(file);
const assetFile = zip.get("assets.json");
if (!assetFile) throw new Error("missing assets.json");
const data = JSON.parse(await assetFile.text()) as AssetExportFile;
await Promise.all(
data.files.map(async (item) => {
const blob = zip.get(item.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
}),
);
return data.assets;
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, kind: Asset["kind"]) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
if (mimeType.includes("mpeg")) return "mp3";
if (mimeType.includes("wav")) return "wav";
if (mimeType.includes("gltf-binary")) return "glb";
if (mimeType.includes("gltf+json") || mimeType.includes("json")) return "gltf";
return kind === "image" ? "png" : "bin";
export async function readAssetPackage(file: File): Promise<Asset[]> {
const archive = await readZip(file);
const manifestBlob = archive.get("assets.json");
if (!manifestBlob) throw new Error("素材包缺少 assets.json");
const manifest: unknown = JSON.parse(await manifestBlob.text());
if (!manifest || typeof manifest !== "object" || !("app" in manifest) || manifest.app !== "yingce" || !("version" in manifest) || manifest.version !== 1 || !("assets" in manifest) || !Array.isArray(manifest.assets) || !("files" in manifest) || !Array.isArray(manifest.files)) throw new Error("素材包格式或版本不受支持");
const images = new Set<string>();
for (const asset of manifest.assets) {
if (!asset || typeof asset !== "object" || typeof asset.id !== "string" || !asset.data || typeof asset.data !== "object" || typeof asset.kind !== "string") throw new Error("素材包包含无效素材");
if (asset.kind === "image" && typeof asset.data.storageKey === "string") images.add(asset.data.storageKey);
}
// Validate the whole manifest before making the first persistent write.
const writes = manifest.files.map((entry: unknown) => {
if (!entry || typeof entry !== "object" || !("storageKey" in entry) || typeof entry.storageKey !== "string" || !entry.storageKey || !("path" in entry) || typeof entry.path !== "string" || !("mimeType" in entry) || typeof entry.mimeType !== "string") throw new Error("素材包文件索引无效");
const blob = archive.get(entry.path);
if (!blob) throw new Error(`素材包缺少文件:${entry.path}`);
if ("bytes" in entry && entry.bytes !== blob.size) throw new Error(`素材包文件大小不匹配:${entry.path}`);
return { key: entry.storageKey, blob: blob.slice(0, blob.size, entry.mimeType) };
});
for (const { key, blob } of writes) await (images.has(key) || key.startsWith("image:") ? setImageBlob(key, blob) : setMediaBlob(key, blob));
return manifest.assets as Asset[];
}
+1 -1
View File
@@ -77,7 +77,7 @@ export default function RegisterPage() {
await register({ username, ...(settings?.firstUser ? { email } : verification), displayName, password, acceptedTerms: agreementAccepted });
const { applyUserSession } = await import("@/lib/user-session");
await applyUserSession(await getAuthSession());
if (!settings?.firstUser) window.sessionStorage.setItem("infinite-canvas:model-setup-guide", "1");
if (!settings?.firstUser) window.sessionStorage.setItem("yingce:model-setup-guide", "1");
message.success(settings?.firstUser ? "管理员账号已创建" : "注册成功");
navigate(next, { replace: true });
} catch (error) {
@@ -2,7 +2,7 @@ import { EmptyState } from "@/components/ui/product/empty-state";
import { Button, Spin } from "antd";
import { ArrowLeft, Eye, History, Maximize, Minus, Plus } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { CanvasViewport } from "@/components/canvas/canvas-viewport";
import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
@@ -124,7 +124,7 @@ export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { pr
{/* No editor callbacks, drawing cache key, or action context may cross this boundary. */}
<CanvasNodeActionContext.Provider value={readOnlyActions}>
<CanvasNodeGraphContext.Provider value={graph}>
<InfiniteCanvas
<CanvasViewport
containerRef={containerRef}
viewport={viewport}
appearance={project.appearance}
@@ -180,7 +180,7 @@ export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { pr
/>
),
)}
</InfiniteCanvas>
</CanvasViewport>
</CanvasNodeGraphContext.Provider>
</CanvasNodeActionContext.Provider>
{!nodes.length ? (
+5 -5
View File
@@ -59,7 +59,7 @@ import { CanvasPrevisTemplateModal } from "@/components/canvas/previs/canvas-pre
import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal";
import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-config-modal";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { CanvasViewport } from "@/components/canvas/canvas-viewport";
import { Minimap } from "@/components/canvas/canvas-mini-map";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { handleListGenerate } from "./list-mode-generator";
@@ -196,10 +196,10 @@ export default function CanvasPage() {
if (!mounted) return <CanvasRefreshShell />;
return <InfiniteCanvasPage />;
return <CanvasViewportPage />;
}
function InfiniteCanvasPage() {
function CanvasViewportPage() {
// 命令式确认必须走 App.useApp().modal;静态 Modal.confirm 拿不到主题和 App 上下文。
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
@@ -2609,7 +2609,7 @@ function InfiniteCanvasPage() {
<div className="relative flex min-h-0 min-w-0 flex-1">
<div className="relative min-w-0 flex-1 overflow-hidden">
<InfiniteCanvas
<CanvasViewport
interactive={!versions.preview}
containerRef={containerRef}
viewport={viewport}
@@ -2725,7 +2725,7 @@ function InfiniteCanvasPage() {
/>
</CanvasNodeGraphContext.Provider>
</CanvasNodeActionContext.Provider>
</InfiniteCanvas>
</CanvasViewport>
<CanvasActiveTaskPanel tasks={activeTasks} onCancelTask={cancelCanvasTask} topInset={focusMode ? "var(--space-3)" : "var(--canvas-topbar-offset)"} />
+3 -3
View File
@@ -10,7 +10,7 @@ import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { CanvasViewport } from "@/components/canvas/canvas-viewport";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
@@ -258,7 +258,7 @@ export default function SharedCanvasPage() {
<Link className="pointer-events-auto" to="/login"><Button type="text" icon={<LogIn className="size-4" />}>登录</Button></Link>
</header>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<CanvasViewport containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<svg className="absolute overflow-visible" viewBox={`${connectionBounds.left} ${connectionBounds.top} ${connectionBounds.width} ${connectionBounds.height}`} style={{ left: connectionBounds.left, top: connectionBounds.top, width: connectionBounds.width, height: connectionBounds.height, pointerEvents: "none", zIndex: 0 }}>
{visibleConnections.map(({ connection, from, to }) => <ConnectionPath key={connection.id} connection={connection} from={from} to={to} active={false} onSelect={() => setInfoNodeId(to.id)} />)}
</svg>
@@ -280,7 +280,7 @@ export default function SharedCanvasPage() {
dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false };
document.body.style.cursor = "grabbing";
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
</InfiniteCanvas>
</CanvasViewport>
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} onNineGrid={unauthorized} />