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
+
+
+
+
+
+
+
+
\ 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);
+}