From 22d34a0c991b5eac91e253ecc3ff70119e65b1e1 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Thu, 21 May 2026 13:50:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor(canvas):=20=E6=9B=BF=E6=8D=A2=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89ID=E7=94=9F=E6=88=90=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E4=B8=BAnanoid=E5=B9=B6=E4=BC=98=E5=8C=96=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除自定义createId函数,统一使用nanoid库生成唯一标识符 - 将useEffectiveAiConfig Hook替换为resolveEffectiveConfig工具函数 - 直接从config store获取publicSettings避免额外的Hook依赖 - 更新所有组件中的ID生成调用以使用nanoid - 简化buildApiUrl函数中的基础URL规范化逻辑 --- .../(user)/canvas/[id]/canvas-client-page.tsx | 28 +++++++------- .../components/canvas-assistant-panel.tsx | 12 +++--- .../(user)/canvas/stores/use-canvas-store.ts | 6 +-- web/src/app/(user)/image/page.tsx | 20 +++++----- web/src/lib/id.ts | 5 --- web/src/services/api/image.ts | 4 +- web/src/services/image-storage.ts | 4 +- web/src/stores/use-asset-store.ts | 4 +- web/src/stores/use-config-store.ts | 37 +++++++------------ 9 files changed, 53 insertions(+), 67 deletions(-) delete mode 100644 web/src/lib/id.ts diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 18f1b21..21c2e3f 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -6,9 +6,9 @@ import { useParams, useRouter } from "next/navigation"; import { Home, ImageIcon, Images, Keyboard, List, LogOut, Menu, MessageSquare, Plus, Redo2, Settings2, Trash2, Undo2, Upload } from "lucide-react"; import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image"; -import { defaultConfig, isAiConfigReady, type AiConfig, useConfigStore, useEffectiveAiConfig } from "@/stores/use-config-store"; +import { defaultConfig, isAiConfigReady, resolveEffectiveConfig, type AiConfig, useConfigStore } from "@/stores/use-config-store"; import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage"; -import { createId } from "@/lib/id"; +import { nanoid } from "nanoid"; import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -231,7 +231,7 @@ function InfiniteCanvasPage() { }); const config = useConfigStore((state) => state.config); - const effectiveConfig = useEffectiveAiConfig(config); + const effectiveConfig = useConfigStore((state) => resolveEffectiveConfig(state.config, state.publicSettings?.modelChannel || null)); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const addAsset = useAssetStore((state) => state.addAsset); const cleanupAssetImages = useAssetStore((state) => state.cleanupImages); @@ -486,7 +486,7 @@ function InfiniteCanvasPage() { return; } setNodes((prev) => [...prev, newNode]); - setConnections((prev) => [...prev, { id: createId(), ...connection }]); + setConnections((prev) => [...prev, { id: nanoid(), ...connection }]); setSelectedNodeIds(new Set([newNode.id])); setSelectedConnectionId(null); setDialogNodeId(newNode.id); @@ -1287,7 +1287,7 @@ function InfiniteCanvasPage() { const cropped = await cropDataUrl(node.metadata.content, crop); const image = await uploadImage(cropped); const width = Math.min(node.width, Math.max(220, image.width)); - const childId = createId(); + const childId = nanoid(); const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, @@ -1301,7 +1301,7 @@ function InfiniteCanvasPage() { }, }; setNodes((prev) => [...prev, child]); - setConnections((prev) => [...prev, { id: createId(), fromNodeId: node.id, toNodeId: childId }]); + setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); setSelectedNodeIds(new Set([childId])); setDialogNodeId(childId); setCropNodeId(null); @@ -1314,7 +1314,7 @@ function InfiniteCanvasPage() { openConfigDialog(true); return; } - const childId = createId(); + const childId = nanoid(); const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const title = buildAngleLabel(params); const prompt = buildAnglePrompt(params); @@ -1329,7 +1329,7 @@ function InfiniteCanvasPage() { height: imageConfig.height, metadata: { prompt, status: NODE_STATUS_LOADING }, }]); - setConnections((prev) => [...prev, { id: createId(), fromNodeId: node.id, toNodeId: childId }]); + setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); setSelectedNodeIds(new Set([childId])); setDialogNodeId(childId); try { @@ -1466,8 +1466,8 @@ function InfiniteCanvasPage() { const parentPosition = sourceNode?.position || { x: 0, y: 0 }; const gap = 96; const rowGap = 36; - const rootId = createId(); - const childIds = count > 1 ? Array.from({ length: count }, () => createId()) : []; + const rootId = nanoid(); + const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; const targetIds = count > 1 ? childIds : [rootId]; pendingChildIds = [rootId, ...childIds]; const rootNode: CanvasNodeData = { @@ -1494,7 +1494,7 @@ function InfiniteCanvasPage() { height: imageConfig.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined }, })); - const batchConnections = [{ id: createId(), fromNodeId: nodeId, toNodeId: rootId }, ...childIds.map((childId) => ({ id: createId(), fromNodeId: rootId, toNodeId: childId }))]; + const batchConnections = [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; setNodes((prev) => [ ...prev.map((node) => @@ -1564,7 +1564,7 @@ function InfiniteCanvasPage() { const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text]; const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text]; const parentPosition = sourceNode?.position || { x: 0, y: 0 }; - const childIds = isConfigNode || editingTextNode ? Array.from({ length: textCount }, () => createId()) : []; + const childIds = isConfigNode || editingTextNode ? Array.from({ length: textCount }, () => nanoid()) : []; pendingChildIds = childIds; if (isConfigNode || editingTextNode) { const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ @@ -1580,7 +1580,7 @@ function InfiniteCanvasPage() { metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 }, })); setNodes((prev) => [...prev.map((node) => node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node), ...childNodes]); - setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: createId(), fromNodeId: nodeId, toNodeId: childId }))]); + setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]); } const answers = await Promise.all( @@ -1690,7 +1690,7 @@ function InfiniteCanvasPage() { size: effectiveConfig.size, count: 3, }); - const connection = { id: createId(), fromNodeId: sourceNode.id, toNodeId: configNode.id }; + const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: configNode.id }; const nextNodes = nodesRef.current.map((item) => item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS } } : item).concat(configNode); const nextConnections = [...connectionsRef.current, connection]; nodesRef.current = nextNodes; diff --git a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx index bf2e0a6..5ace602 100644 --- a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx @@ -7,9 +7,9 @@ import { motion } from "motion/react"; import { ImageGenerationPending } from "@/components/image-generation-pending"; import { ModelPicker } from "@/components/model-picker"; -import { isAiConfigReady, useConfigStore, useEffectiveAiConfig, type AiConfig } from "@/stores/use-config-store"; +import { isAiConfigReady, resolveEffectiveConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; -import { createId } from "@/lib/id"; +import { nanoid } from "nanoid"; import { cn } from "@/lib/utils"; import { requestEdit, requestGeneration, requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image"; import { imageToDataUrl, uploadImage } from "@/services/image-storage"; @@ -41,7 +41,7 @@ type CanvasAssistantPanelProps = { export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const config = useConfigStore((state) => state.config); - const effectiveConfig = useEffectiveAiConfig(config); + const effectiveConfig = useConfigStore((state) => resolveEffectiveConfig(state.config, state.publicSettings?.modelChannel || null)); const cleanupImages = useAssetStore((state) => state.cleanupImages); const updateConfig = useConfigStore((state) => state.updateConfig); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); @@ -149,8 +149,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS } const refs = savedReferences || selectedReferences; - const userMessage: CanvasAssistantMessage = { id: createId(), role: "user", mode: nextMode, text, references: refs }; - const assistantId = createId(); + const userMessage: CanvasAssistantMessage = { id: nanoid(), role: "user", mode: nextMode, text, references: refs }; + const assistantId = nanoid(); appendMessage(session.id, userMessage); appendMessage(session.id, { id: assistantId, role: "assistant", mode: nextMode, text: nextMode === "image" ? "正在生成图片" : "正在回答", isLoading: true }); setPrompt(""); @@ -616,5 +616,5 @@ async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise()( projects: [], createProject: (title = "未命名画布") => { const now = new Date().toISOString(); - const id = createId(); + const id = nanoid(); const project: CanvasProject = { id, title, @@ -83,7 +83,7 @@ export const useCanvasStore = create()( importProject: (source) => { const now = new Date().toISOString(); const project: CanvasProject = { - id: createId(), + id: nanoid(), title: source.title || "导入画布", createdAt: source.createdAt || now, updatedAt: now, diff --git a/web/src/app/(user)/image/page.tsx b/web/src/app/(user)/image/page.tsx index ae9d361..5fe7e1c 100644 --- a/web/src/app/(user)/image/page.tsx +++ b/web/src/app/(user)/image/page.tsx @@ -8,8 +8,8 @@ import localforage from "localforage"; import { ModelPicker } from "@/components/model-picker"; import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog"; import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal"; -import { isAiConfigReady, useConfigStore, useEffectiveAiConfig, type AiConfig } from "@/stores/use-config-store"; -import { createId } from "@/lib/id"; +import { isAiConfigReady, resolveEffectiveConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store"; +import { nanoid } from "nanoid"; import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; import { requestEdit, requestGeneration } from "@/services/api/image"; import { uploadImage } from "@/services/image-storage"; @@ -60,7 +60,7 @@ export default function ImagePage() { const { message } = App.useApp(); const fileInputRef = useRef(null); const config = useConfigStore((state) => state.config); - const effectiveConfig = useEffectiveAiConfig(config); + const effectiveConfig = useConfigStore((state) => resolveEffectiveConfig(state.config, state.publicSettings?.modelChannel || null)); const updateConfig = useConfigStore((state) => state.updateConfig); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const addAsset = useAssetStore((state) => state.addAsset); @@ -97,7 +97,7 @@ export default function ImagePage() { const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/")); const nextReferences = await Promise.all(imageFiles.map(async (file) => { const image = await uploadImage(file); - return { id: createId(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; + return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; })); setReferences((value) => [...value, ...nextReferences]); }; @@ -112,7 +112,7 @@ export default function ImagePage() { } const nextReferences = await Promise.all(blobs.map(async (blob, index) => { const image = await uploadImage(blob); - return { id: createId(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; + return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; })); setReferences((value) => [...value, ...nextReferences]); message.success(`已读取 ${nextReferences.length} 张参考图`); @@ -139,7 +139,7 @@ export default function ImagePage() { setElapsedMs(0); setRunning(true); setPreviewLog(null); - setResults(Array.from({ length: generationCount }, () => ({ id: createId(), status: "pending" }))); + setResults(Array.from({ length: generationCount }, () => ({ id: nanoid(), status: "pending" }))); const batchStartedAt = performance.now(); setStartedAt(batchStartedAt); @@ -170,7 +170,7 @@ export default function ImagePage() { const addResultToReferences = async (image: GeneratedImage, index: number) => { const stored = await uploadImage(image.dataUrl); - setReferences((value) => [...value, { id: createId(), name: `result-${index + 1}.png`, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); + setReferences((value) => [...value, { id: nanoid(), name: `result-${index + 1}.png`, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); message.success("已加入参考图"); }; @@ -193,7 +193,7 @@ export default function ImagePage() { setPrompt(payload.content); } else { const stored = await uploadImage(payload.dataUrl); - setReferences((value) => [...value, { id: createId(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); + setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); } setAssetPickerOpen(false); }; @@ -583,7 +583,7 @@ async function readStoredLogs() { function normalizeLog(log: Partial): GenerationLog { return { - id: log.id || createId(), + id: log.id || nanoid(), createdAt: log.createdAt || Date.now(), title: log.title || log.model || "未命名", time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }), @@ -601,7 +601,7 @@ function normalizeLog(log: Partial): GenerationLog { function buildLog({ prompt, model, config, durationMs, successCount, failCount, status, thumbnails }: { prompt: string; model: string; config: { size: string; quality: string; count?: string }; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; thumbnails: string[] }): GenerationLog { return { - id: createId(), + id: nanoid(), createdAt: Date.now(), title: prompt.slice(0, 12) || "未命名", time: new Date().toLocaleString("zh-CN", { hour12: false }), diff --git a/web/src/lib/id.ts b/web/src/lib/id.ts deleted file mode 100644 index c7dccd1..0000000 --- a/web/src/lib/id.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { nanoid } from "nanoid"; - -export function createId() { - return nanoid(); -} diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index d86e343..2ecc14e 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { buildApiUrl, type AiConfig } from "@/stores/use-config-store"; -import { createId } from "@/lib/id"; +import { nanoid } from "nanoid"; import { dataUrlToFile } from "@/lib/image-utils"; import { imageToDataUrl } from "@/services/image-storage"; import type { ReferenceImage } from "@/types/image"; @@ -31,7 +31,7 @@ function parseImagePayload(payload: ImageApiResponse) { payload.data ?.map(resolveImageDataUrl) .filter((value): value is string => Boolean(value)) - .map((dataUrl) => ({ id: createId(), dataUrl })) || []; + .map((dataUrl) => ({ id: nanoid(), dataUrl })) || []; if (images.length === 0) { throw new Error("接口没有返回图片"); diff --git a/web/src/services/image-storage.ts b/web/src/services/image-storage.ts index f4dbedd..c2dcdce 100644 --- a/web/src/services/image-storage.ts +++ b/web/src/services/image-storage.ts @@ -2,7 +2,7 @@ import localforage from "localforage"; -import { createId } from "@/lib/id"; +import { nanoid } from "nanoid"; import { readImageMeta } from "@/lib/image-utils"; export type UploadedImage = { @@ -19,7 +19,7 @@ const objectUrls = new Map(); export async function uploadImage(input: string | Blob): Promise { const blob = typeof input === "string" ? await (await fetch(input)).blob() : input; - const storageKey = `image:${createId()}`; + const storageKey = `image:${nanoid()}`; await store.setItem(storageKey, blob); const url = URL.createObjectURL(blob); objectUrls.set(storageKey, url); diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index 78e398d..412f373 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -3,7 +3,7 @@ import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; -import { createId } from "@/lib/id"; +import { nanoid } from "nanoid"; import { localForageStorage } from "@/lib/localforage-storage"; import { cleanupUnusedImages, resolveImageUrl, uploadImage } from "@/services/image-storage"; @@ -63,7 +63,7 @@ export const useAssetStore = create()( assets: [], addAsset: (asset) => { const now = new Date().toISOString(); - const id = createId(); + const id = nanoid(); set((state) => ({ assets: [{ ...asset, id, createdAt: now, updatedAt: now } as Asset, ...state.assets] })); return id; }, diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index af68e1c..4763ef8 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -1,6 +1,5 @@ "use client"; -import { useMemo } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -89,31 +88,23 @@ export const useConfigStore = create()( ), ); -export function useEffectiveAiConfig(config: AiConfig) { - const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel); - - return useMemo(() => { - const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote"; - if (channelMode === "local" || !modelChannel) return { ...config, channelMode }; - const models = modelChannel.availableModels; - return { - ...config, - channelMode, - models, - model: models.includes(config.model) ? config.model : modelChannel.defaultModel, - imageModel: models.includes(config.imageModel) ? config.imageModel : modelChannel.defaultImageModel || modelChannel.defaultModel, - textModel: models.includes(config.textModel) ? config.textModel : modelChannel.defaultTextModel || modelChannel.defaultModel, - systemPrompt: modelChannel.systemPrompt, - }; - }, [config, modelChannel]); -} - -export function normalizeBaseUrl(value: string) { - return value.trim().replace(/\/+$/, ""); +export function resolveEffectiveConfig(config: AiConfig, modelChannel: AdminPublicSettings["modelChannel"] | null) { + const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote"; + if (channelMode === "local" || !modelChannel) return { ...config, channelMode }; + const models = modelChannel.availableModels; + return { + ...config, + channelMode, + models, + model: models.includes(config.model) ? config.model : modelChannel.defaultModel, + imageModel: models.includes(config.imageModel) ? config.imageModel : modelChannel.defaultImageModel || modelChannel.defaultModel, + textModel: models.includes(config.textModel) ? config.textModel : modelChannel.defaultTextModel || modelChannel.defaultModel, + systemPrompt: modelChannel.systemPrompt, + }; } export function buildApiUrl(baseUrl: string, path: string) { - const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, ""); const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`; return `${apiBaseUrl}${path}`; }