From ef9e87fb293d9683191971a53a600bba07ed617d Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Thu, 21 May 2026 14:43:09 +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规范化逻辑 --- AGENTS.md | 1 + web/next.config.ts | 2 +- .../(user)/canvas/[id]/canvas-client-page.tsx | 5 ++- .../components/canvas-assistant-panel.tsx | 5 ++- web/src/app/(user)/image/page.tsx | 5 ++- web/src/components/app-top-nav.tsx | 14 ++---- web/src/components/user-status-actions.tsx | 3 +- web/src/components/version-release-modal.tsx | 34 ++++----------- web/src/constant/env.ts | 1 + web/src/hooks/use-version-check.ts | 32 +++++++++++--- web/src/lib/{release-info.ts => release.ts} | 0 web/src/stores/use-config-store.ts | 43 +++++++++++-------- 12 files changed, 78 insertions(+), 67 deletions(-) create mode 100644 web/src/constant/env.ts rename web/src/lib/{release-info.ts => release.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index a45645d..abc2f76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ - API 请求统一放在 `web/src/services/api/`。 - 全局或跨页面状态优先放在 `web/src/stores/`。 - 已经放在全局 store 或全局 hook 中的状态/动作,组件需要时直接使用对应 store/hook,不要为了“纯组件”层层透传 props;避免一个组件传递过多参数。 +- 全局组件、全局常量、全局配置等全局性质的内容不要作为 props 或参数层层传递;哪里需要就在哪里直接从对应全局入口获取。 - 多个页面重复出现的 UI 副作用动作,例如复制文本并提示、下载并提示、统一确认弹窗,优先抽成 `web/src/hooks/` 下的全局 hook;不要放进 store,除非它确实是需要共享/订阅的状态。 - 画布相关状态和组件放在 `web/src/app/(user)/canvas/` 内部。 - 页面里只有一个主业务组件时直接写在 `page.tsx`,不要单独拆 `Manager` 组件再传一堆 props。 diff --git a/web/next.config.ts b/web/next.config.ts index ccd041e..d303a54 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -4,7 +4,7 @@ import { loadEnvConfig } from "@next/env"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; -import { parseChangelog } from "./src/lib/release-info"; +import { parseChangelog } from "@/lib/release"; const webDir = dirname(fileURLToPath(import.meta.url)); const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev"; 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 21c2e3f..f42602e 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -6,7 +6,7 @@ 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, resolveEffectiveConfig, type AiConfig, useConfigStore } from "@/stores/use-config-store"; +import { defaultConfig, type AiConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage"; import { nanoid } from "nanoid"; import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; @@ -231,7 +231,8 @@ function InfiniteCanvasPage() { }); const config = useConfigStore((state) => state.config); - const effectiveConfig = useConfigStore((state) => resolveEffectiveConfig(state.config, state.publicSettings?.modelChannel || null)); + const effectiveConfig = useEffectiveConfig(); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const addAsset = useAssetStore((state) => state.addAsset); const cleanupAssetImages = useAssetStore((state) => state.cleanupImages); 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 5ace602..5d8c896 100644 --- a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx @@ -7,7 +7,7 @@ import { motion } from "motion/react"; import { ImageGenerationPending } from "@/components/image-generation-pending"; import { ModelPicker } from "@/components/model-picker"; -import { isAiConfigReady, resolveEffectiveConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store"; +import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; import { nanoid } from "nanoid"; import { cn } from "@/lib/utils"; @@ -41,9 +41,10 @@ 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 = useConfigStore((state) => resolveEffectiveConfig(state.config, state.publicSettings?.modelChannel || null)); + const effectiveConfig = useEffectiveConfig(); const cleanupImages = useAssetStore((state) => state.cleanupImages); const updateConfig = useConfigStore((state) => state.updateConfig); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const [width, setWidth] = useState(390); const [view, setView] = useState<"chat" | "history">("chat"); diff --git a/web/src/app/(user)/image/page.tsx b/web/src/app/(user)/image/page.tsx index 5fe7e1c..fbc73bf 100644 --- a/web/src/app/(user)/image/page.tsx +++ b/web/src/app/(user)/image/page.tsx @@ -8,7 +8,7 @@ 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, resolveEffectiveConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store"; +import { useConfigStore, useEffectiveConfig, 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"; @@ -60,8 +60,9 @@ export default function ImagePage() { const { message } = App.useApp(); const fileInputRef = useRef(null); const config = useConfigStore((state) => state.config); - const effectiveConfig = useConfigStore((state) => resolveEffectiveConfig(state.config, state.publicSettings?.modelChannel || null)); + const effectiveConfig = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const addAsset = useAssetStore((state) => state.addAsset); const [prompt, setPrompt] = useState(""); diff --git a/web/src/components/app-top-nav.tsx b/web/src/components/app-top-nav.tsx index 1c51272..db8d17f 100644 --- a/web/src/components/app-top-nav.tsx +++ b/web/src/components/app-top-nav.tsx @@ -9,7 +9,7 @@ import { GitHubLink } from "@/components/github-link"; import { UserStatusActions } from "@/components/user-status-actions"; import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; import { VersionReleaseModal } from "@/components/version-release-modal"; -import { useConfigStore, type AiConfig } from "@/stores/use-config-store"; +import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools"; import { fetchImageModels } from "@/services/api/image"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -28,7 +28,6 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader = const { message } = App.useApp(); const [loadingModels, setLoadingModels] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); - const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev"; const isConfigOpen = useConfigStore((state) => state.isConfigOpen); const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); @@ -40,16 +39,11 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader = const isReady = useUserStore((state) => state.isReady); const logout = useUserStore((state) => state.clearSession); const publicSettings = useConfigStore((state) => state.publicSettings); + const effectiveConfig = useEffectiveConfig(); const modelChannel = publicSettings?.modelChannel; const allowCustomChannel = modelChannel?.allowCustomChannel === true; const effectiveMode = allowCustomChannel ? config.channelMode : "remote"; - const modelConfig = effectiveMode === "remote" && modelChannel ? { - ...config, - models: modelChannel.availableModels, - imageModel: modelChannel.availableModels.includes(config.imageModel) ? config.imageModel : modelChannel.defaultImageModel || modelChannel.defaultModel, - textModel: modelChannel.availableModels.includes(config.textModel) ? config.textModel : modelChannel.defaultTextModel || modelChannel.defaultModel, - systemPrompt: modelChannel.systemPrompt, - } : config; + const modelConfig = effectiveMode === "remote" ? effectiveConfig : config; const finishConfig = () => { setConfigDialogOpen(false); @@ -163,7 +157,7 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader = aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} /> - + 登录 diff --git a/web/src/components/user-status-actions.tsx b/web/src/components/user-status-actions.tsx index c5c3184..2951ec2 100644 --- a/web/src/components/user-status-actions.tsx +++ b/web/src/components/user-status-actions.tsx @@ -52,7 +52,6 @@ export function UserStatusActions({ const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); const storedUserName = useUserStore((state) => state.user?.username); - const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev"; const resolvedUserName = userName || storedUserName; const avatarText = initial || (resolvedUserName?.trim()[0] || "U").toUpperCase(); const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"; @@ -79,7 +78,7 @@ export function UserStatusActions({ aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} /> - +
{ - void checkLatestRelease().then((success) => { - if (!showMessage) return; - if (success) message.success("已获取最新版本信息"); - else message.error("获取最新版本信息失败"); - }); - }, [checkLatestRelease, message]); - - useEffect(() => { - if (!open) return; - handleCheckLatestRelease(); - }, [handleCheckLatestRelease, open]); +export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) { + const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck(); return ( <> @@ -47,11 +31,11 @@ export function VersionReleaseModal({ currentVersion, className, style }: Versio type="button" className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"} style={style} - onClick={() => setOpen(true)} + onClick={openReleaseModal} title="查看版本更新" > - {currentVersion} + {APP_VERSION} {hasNewVersion ? : null} @@ -66,7 +50,7 @@ export function VersionReleaseModal({ currentVersion, className, style }: Versio
当前版本
-
{currentVersion}
+
{APP_VERSION}
@@ -74,7 +58,7 @@ export function VersionReleaseModal({ currentVersion, className, style }: Versio @@ -92,7 +76,7 @@ export function VersionReleaseModal({ currentVersion, className, style }: Versio {release.date}
{release.version === latestVersion ? 最新 : null} - {release.version === currentVersion ? 当前 : null} + {release.version === APP_VERSION ? 当前 : null}
diff --git a/web/src/constant/env.ts b/web/src/constant/env.ts new file mode 100644 index 0000000..b20b378 --- /dev/null +++ b/web/src/constant/env.ts @@ -0,0 +1 @@ +export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "dev"; diff --git a/web/src/hooks/use-version-check.ts b/web/src/hooks/use-version-check.ts index 451bdb1..d2be51c 100644 --- a/web/src/hooks/use-version-check.ts +++ b/web/src/hooks/use-version-check.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; - -import { parseChangelog, type ReleaseInfo } from "@/lib/release-info"; +import { App } from "antd"; +import { APP_VERSION } from "@/constant/env"; +import { parseChangelog, type ReleaseInfo } from "@/lib/release"; const latestVersionUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/VERSION"; const latestChangelogUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/CHANGELOG.md"; @@ -25,11 +26,14 @@ function isNewerVersion(latestVersion: string, currentVersion: string) { return latest.some((value, index) => value > current[index] && latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex])); } -export function useVersionCheck(currentVersion: string) { +export function useVersionCheck() { + const currentVersion = APP_VERSION; + const { message } = App.useApp(); const localReleases = useMemo(readLocalReleases, []); const [latestVersion, setLatestVersion] = useState(currentVersion); const [releases, setReleases] = useState(localReleases); const [checking, setChecking] = useState(false); + const [open, setOpen] = useState(false); const hasNewVersion = isNewerVersion(latestVersion, currentVersion); const checkLatestVersion = useCallback(async () => { @@ -44,7 +48,7 @@ export function useVersionCheck(currentVersion: string) { } }, [currentVersion]); - const checkLatestRelease = useCallback(async () => { + const checkLatestRelease = useCallback(async (showMessage = false) => { setChecking(true); try { const [versionResponse, changelogResponse] = await Promise.all([ @@ -56,19 +60,35 @@ export function useVersionCheck(currentVersion: string) { const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]); setLatestVersion(version.trim() || currentVersion); if (changelog.trim()) setReleases(parseChangelog(changelog)); + if (showMessage) message.success("已获取最新版本信息"); return true; } catch { setLatestVersion(currentVersion); setReleases(localReleases); + if (showMessage) message.error("获取最新版本信息失败"); return false; } finally { setChecking(false); } - }, [currentVersion, localReleases]); + }, [currentVersion, localReleases, message]); useEffect(() => { void checkLatestVersion(); }, [checkLatestVersion]); - return { latestVersion, releases, checking, hasNewVersion, checkLatestRelease }; + const openReleaseModal = useCallback(() => { + setOpen(true); + void checkLatestRelease(); + }, [checkLatestRelease]); + + return { + open, + setOpen, + openReleaseModal, + latestVersion, + releases, + checking, + hasNewVersion, + checkLatestRelease, + }; } diff --git a/web/src/lib/release-info.ts b/web/src/lib/release.ts similarity index 100% rename from web/src/lib/release-info.ts rename to web/src/lib/release.ts diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 4763ef8..6869961 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -44,11 +45,31 @@ type ConfigStore = { shouldPromptContinue: boolean; updateConfig: (key: K, value: AiConfig[K]) => void; loadPublicSettings: () => Promise; + isAiConfigReady: (config: AiConfig, model: string) => boolean; openConfigDialog: (shouldPromptContinue?: boolean) => void; setConfigDialogOpen: (isOpen: boolean) => void; clearPromptContinue: () => void; }; +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, + }; +} + +function isAiConfigReady(config: AiConfig, model: string) { + return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim())); +} + export const useConfigStore = create()( persist( (set, get) => ({ @@ -73,6 +94,7 @@ export const useConfigStore = create()( set({ isPublicSettingsLoading: false }); } }, + isAiConfigReady: (config, model) => isAiConfigReady(config, model), openConfigDialog: (shouldPromptContinue = false) => set({ isConfigOpen: true, shouldPromptContinue }), setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }), clearPromptContinue: () => set({ shouldPromptContinue: false }), @@ -88,19 +110,10 @@ export const useConfigStore = create()( ), ); -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 useEffectiveConfig() { + const config = useConfigStore((state) => state.config); + const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel || null); + return useMemo(() => resolveEffectiveConfig(config, modelChannel), [config, modelChannel]); } export function buildApiUrl(baseUrl: string, path: string) { @@ -108,7 +121,3 @@ export function buildApiUrl(baseUrl: string, path: string) { const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`; return `${apiBaseUrl}${path}`; } - -export function isAiConfigReady(config: AiConfig, model: string) { - return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim())); -}