diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f601a6..ef5fc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased ++ [新增] 右上角版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和按时间线整理的更新日志。 ## v0.0.4 - 2026-05-20 - + [调整] Docker 运行入口改为 Next.js 对外提供页面,`/api/*` 由 Next.js 代理到内部 Go 服务。 + [修复] 文本复制在局域网 IP 访问时可能失败的问题。 diff --git a/Dockerfile b/Dockerfile index 7eb3f98..f81a319 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # 构建 Next.js 前端产物。 -FROM oven/bun:1 AS web-build +FROM oven/bun:1.3.13 AS web-build WORKDIR /app/web COPY web/package.json web/bun.lock ./ @@ -24,7 +24,7 @@ COPY main.go ./ RUN go build -o /server . # 运行镜像:Next.js 对外监听 3000,Go 只在容器内部监听 8080。 -FROM oven/bun:1 +FROM oven/bun:1.3.13 WORKDIR /app COPY VERSION /app/VERSION diff --git a/README.md b/README.md index 62e8ebe..0b7076d 100644 --- a/README.md +++ b/README.md @@ -87,3 +87,14 @@ docker compose -f docker-compose.local.yml up -d --build ## 开源协议 本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。 + + +## Star History + + + + + + Star History Chart + + \ No newline at end of file diff --git a/docs/pending-test.md b/docs/pending-test.md index 34439d4..9749ed7 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,3 +1,3 @@ # 待测试 -- 通过局域网 IP 访问前台提示词、素材库、我的素材和后台相关页面时,复制文本应能正常写入剪贴板。 +- 点击右上角版本号时,应弹出版本更新弹窗,并展示当前版本、最新版本、检查更新入口和可滚动的版本时间线。 diff --git a/web/next.config.ts b/web/next.config.ts index 6c78716..ccd041e 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -4,21 +4,26 @@ 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"; const webDir = dirname(fileURLToPath(import.meta.url)); -const version = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev"; +const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev"; +const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8"); export default function nextConfig(phase: string): NextConfig { const isDev = phase === PHASE_DEVELOPMENT_SERVER; loadEnvConfig(resolve(webDir, ".."), isDev, undefined, true); const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080"; + const releases = parseChangelog(localChangelog); + return { allowedDevOrigins: isDev ? ["*.*.*.*"] : [], typescript: { ignoreBuildErrors: true, }, env: { - NEXT_PUBLIC_APP_VERSION: version, + NEXT_PUBLIC_APP_VERSION: localVersion, + NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases), }, async rewrites() { return [{ source: "/api/:path*", destination: `${apiBaseUrl}/api/:path*` }]; 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 7a7841a..90eb482 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -2188,6 +2188,7 @@ function CanvasTopBar({ iconStyle={{ color: theme.node.text }} gitHubClassName="size-11 text-base" gitHubStyle={{ color: theme.node.text }} + versionStyle={{ color: theme.node.text }} avatarStyle={{ borderColor: theme.toolbar.border, color: theme.node.text }} userLabel={initial} menuItems={[ diff --git a/web/src/components/app-top-nav.tsx b/web/src/components/app-top-nav.tsx index 715864c..7d5c640 100644 --- a/web/src/components/app-top-nav.tsx +++ b/web/src/components/app-top-nav.tsx @@ -9,6 +9,7 @@ import { ModelPicker } from "@/components/model-picker"; 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 type { AiConfig } from "@/lib/ai-config"; import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools"; import { fetchImageModels } from "@/services/api/image"; @@ -153,7 +154,7 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader = aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} /> - {appVersion} + 登录 diff --git a/web/src/components/user-status-actions.tsx b/web/src/components/user-status-actions.tsx index d7490a2..e355c39 100644 --- a/web/src/components/user-status-actions.tsx +++ b/web/src/components/user-status-actions.tsx @@ -7,6 +7,7 @@ import type { ItemType } from "antd/es/menu/interface"; import { GitHubLink } from "@/components/github-link"; import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; +import { VersionReleaseModal } from "@/components/version-release-modal"; import { cn } from "@/lib/utils"; import type { ThemeName } from "@/stores/use-theme-store"; @@ -27,6 +28,7 @@ type UserStatusActionsProps = { avatarStyle?: CSSProperties; gitHubClassName?: string; gitHubStyle?: CSSProperties; + versionStyle?: CSSProperties; userLabel?: ReactNode; iconStyle?: CSSProperties; }; @@ -48,6 +50,7 @@ export function UserStatusActions({ avatarStyle, gitHubClassName, gitHubStyle, + versionStyle, userLabel, iconStyle, }: UserStatusActionsProps) { @@ -76,7 +79,7 @@ export function UserStatusActions({ aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} /> - {version} +
{ + void checkLatestRelease().then((success) => { + if (!showMessage) return; + if (success) message.success("已获取最新版本信息"); + else message.error("获取最新版本信息失败"); + }); + }, [checkLatestRelease, message]); + + useEffect(() => { + if (!open) return; + handleCheckLatestRelease(); + }, [handleCheckLatestRelease, open]); + + return ( + <> + + setOpen(false)} + > +
+
+
当前版本
+
{currentVersion}
+
+
+
+
最新版本
+ +
+
{latestVersion}
+
+
+
+ ({ + content: ( +
+
+ {getReleaseTitle(release.version)} + {release.date} +
+ {release.version === latestVersion ? 最新 : null} + {release.version === currentVersion ? 当前 : null} +
+
+
+ {release.items.map((item, index) => ( +
+ {item.type} + {item.content} +
+ ))} +
+
+ ), + }))} + /> +
+
+ + ); +} diff --git a/web/src/hooks/use-version-check.ts b/web/src/hooks/use-version-check.ts new file mode 100644 index 0000000..451bdb1 --- /dev/null +++ b/web/src/hooks/use-version-check.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { parseChangelog, type ReleaseInfo } from "@/lib/release-info"; + +const latestVersionUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/VERSION"; +const latestChangelogUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/CHANGELOG.md"; + +function readLocalReleases(): ReleaseInfo[] { + try { + return JSON.parse(process.env.NEXT_PUBLIC_APP_RELEASES || "[]"); + } catch { + return []; + } +} + +function toVersionParts(version: string) { + const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); + return match ? match.slice(1).map(Number) : null; +} + +function isNewerVersion(latestVersion: string, currentVersion: string) { + const latest = toVersionParts(latestVersion); + const current = toVersionParts(currentVersion); + if (!latest || !current) return false; + return latest.some((value, index) => value > current[index] && latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex])); +} + +export function useVersionCheck(currentVersion: string) { + const localReleases = useMemo(readLocalReleases, []); + const [latestVersion, setLatestVersion] = useState(currentVersion); + const [releases, setReleases] = useState(localReleases); + const [checking, setChecking] = useState(false); + const hasNewVersion = isNewerVersion(latestVersion, currentVersion); + + const checkLatestVersion = useCallback(async () => { + try { + const response = await fetch(latestVersionUrl); + if (!response.ok) return false; + const version = await response.text(); + setLatestVersion(version.trim() || currentVersion); + return true; + } catch { + return false; + } + }, [currentVersion]); + + const checkLatestRelease = useCallback(async () => { + setChecking(true); + try { + const [versionResponse, changelogResponse] = await Promise.all([ + fetch(latestVersionUrl), + fetch(latestChangelogUrl), + ]); + if (!versionResponse.ok) throw new Error("版本读取失败"); + if (!changelogResponse.ok) throw new Error("更新日志读取失败"); + const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]); + setLatestVersion(version.trim() || currentVersion); + if (changelog.trim()) setReleases(parseChangelog(changelog)); + return true; + } catch { + setLatestVersion(currentVersion); + setReleases(localReleases); + return false; + } finally { + setChecking(false); + } + }, [currentVersion, localReleases]); + + useEffect(() => { + void checkLatestVersion(); + }, [checkLatestVersion]); + + return { latestVersion, releases, checking, hasNewVersion, checkLatestRelease }; +} diff --git a/web/src/lib/release-info.ts b/web/src/lib/release-info.ts new file mode 100644 index 0000000..97bbb49 --- /dev/null +++ b/web/src/lib/release-info.ts @@ -0,0 +1,24 @@ +export type ReleaseInfo = { + version: string; + date: string; + items: { type: string; content: string }[]; +}; + +export function parseChangelog(content: string): ReleaseInfo[] { + return content + .split(/^## /m) + .slice(1) + .map((block) => { + const [title = "", ...lines] = block.trim().split("\n"); + const [, version = title.trim(), date = ""] = title.match(/^(.+?)(?:\s+-\s+(.+))?$/) || []; + return { + version: version.trim(), + date: date.trim(), + items: lines + .map((line) => line.trim().match(/^\+\s+\[(.+?)\]\s+(.+)$/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map((match) => ({ type: match[1], content: match[2] })), + }; + }) + .filter((release) => release.items.length); +}