feat(app): 添加版本更新弹窗功能
- 在顶部导航栏和用户状态组件中集成版本更新弹窗 - 实现版本检查钩子,支持检查最新版本和更新日志 - 解析 CHANGELOG.md 文件并展示版本更新时间线 - 添加版本更新弹窗 UI 组件,显示当前版本和最新版本 - 在 Dockerfile 中更新 bun 版本依赖 - 调整 Next.js 配置以解析版本信息并注入环境变量 - 更新待测试文档中的版本更新功能描述
This commit is contained in:
+1
-1
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [新增] 右上角版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和按时间线整理的更新日志。
|
||||||
|
|
||||||
## v0.0.4 - 2026-05-20
|
## v0.0.4 - 2026-05-20
|
||||||
|
|
||||||
|
|
||||||
+ [调整] Docker 运行入口改为 Next.js 对外提供页面,`/api/*` 由 Next.js 代理到内部 Go 服务。
|
+ [调整] Docker 运行入口改为 Next.js 对外提供页面,`/api/*` 由 Next.js 代理到内部 Go 服务。
|
||||||
+ [修复] 文本复制在局域网 IP 访问时可能失败的问题。
|
+ [修复] 文本复制在局域网 IP 访问时可能失败的问题。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# 构建 Next.js 前端产物。
|
# 构建 Next.js 前端产物。
|
||||||
FROM oven/bun:1 AS web-build
|
FROM oven/bun:1.3.13 AS web-build
|
||||||
|
|
||||||
WORKDIR /app/web
|
WORKDIR /app/web
|
||||||
COPY web/package.json web/bun.lock ./
|
COPY web/package.json web/bun.lock ./
|
||||||
@@ -24,7 +24,7 @@ COPY main.go ./
|
|||||||
RUN go build -o /server .
|
RUN go build -o /server .
|
||||||
|
|
||||||
# 运行镜像:Next.js 对外监听 3000,Go 只在容器内部监听 8080。
|
# 运行镜像:Next.js 对外监听 3000,Go 只在容器内部监听 8080。
|
||||||
FROM oven/bun:1
|
FROM oven/bun:1.3.13
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY VERSION /app/VERSION
|
COPY VERSION /app/VERSION
|
||||||
|
|||||||
@@ -87,3 +87,14 @@ docker compose -f docker-compose.local.yml up -d --build
|
|||||||
## 开源协议
|
## 开源协议
|
||||||
|
|
||||||
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
|
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=basketikun%2Finfinite-canvas&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=basketikun/infinite-canvas&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=basketikun/infinite-canvas&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=basketikun/infinite-canvas&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
- 通过局域网 IP 访问前台提示词、素材库、我的素材和后台相关页面时,复制文本应能正常写入剪贴板。
|
- 点击右上角版本号时,应弹出版本更新弹窗,并展示当前版本、最新版本、检查更新入口和可滚动的版本时间线。
|
||||||
|
|||||||
+7
-2
@@ -4,21 +4,26 @@ import { loadEnvConfig } from "@next/env";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { parseChangelog } from "./src/lib/release-info";
|
||||||
|
|
||||||
const webDir = dirname(fileURLToPath(import.meta.url));
|
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 {
|
export default function nextConfig(phase: string): NextConfig {
|
||||||
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
|
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
|
||||||
loadEnvConfig(resolve(webDir, ".."), isDev, undefined, true);
|
loadEnvConfig(resolve(webDir, ".."), isDev, undefined, true);
|
||||||
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
|
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
|
||||||
|
const releases = parseChangelog(localChangelog);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
|
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_APP_VERSION: version,
|
NEXT_PUBLIC_APP_VERSION: localVersion,
|
||||||
|
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [{ source: "/api/:path*", destination: `${apiBaseUrl}/api/:path*` }];
|
return [{ source: "/api/:path*", destination: `${apiBaseUrl}/api/:path*` }];
|
||||||
|
|||||||
@@ -2188,6 +2188,7 @@ function CanvasTopBar({
|
|||||||
iconStyle={{ color: theme.node.text }}
|
iconStyle={{ color: theme.node.text }}
|
||||||
gitHubClassName="size-11 text-base"
|
gitHubClassName="size-11 text-base"
|
||||||
gitHubStyle={{ color: theme.node.text }}
|
gitHubStyle={{ color: theme.node.text }}
|
||||||
|
versionStyle={{ color: theme.node.text }}
|
||||||
avatarStyle={{ borderColor: theme.toolbar.border, color: theme.node.text }}
|
avatarStyle={{ borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||||
userLabel={initial}
|
userLabel={initial}
|
||||||
menuItems={[
|
menuItems={[
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ModelPicker } from "@/components/model-picker";
|
|||||||
import { GitHubLink } from "@/components/github-link";
|
import { GitHubLink } from "@/components/github-link";
|
||||||
import { UserStatusActions } from "@/components/user-status-actions";
|
import { UserStatusActions } from "@/components/user-status-actions";
|
||||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||||
|
import { VersionReleaseModal } from "@/components/version-release-modal";
|
||||||
import type { AiConfig } from "@/lib/ai-config";
|
import type { AiConfig } from "@/lib/ai-config";
|
||||||
import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
|
import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
|
||||||
import { fetchImageModels } from "@/services/api/image";
|
import { fetchImageModels } from "@/services/api/image";
|
||||||
@@ -153,7 +154,7 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader =
|
|||||||
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||||
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||||
/>
|
/>
|
||||||
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{appVersion}</span>
|
<VersionReleaseModal currentVersion={appVersion} />
|
||||||
<GitHubLink />
|
<GitHubLink />
|
||||||
<Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100">
|
<Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100">
|
||||||
登录
|
登录
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { ItemType } from "antd/es/menu/interface";
|
|||||||
|
|
||||||
import { GitHubLink } from "@/components/github-link";
|
import { GitHubLink } from "@/components/github-link";
|
||||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||||
|
import { VersionReleaseModal } from "@/components/version-release-modal";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ThemeName } from "@/stores/use-theme-store";
|
import type { ThemeName } from "@/stores/use-theme-store";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ type UserStatusActionsProps = {
|
|||||||
avatarStyle?: CSSProperties;
|
avatarStyle?: CSSProperties;
|
||||||
gitHubClassName?: string;
|
gitHubClassName?: string;
|
||||||
gitHubStyle?: CSSProperties;
|
gitHubStyle?: CSSProperties;
|
||||||
|
versionStyle?: CSSProperties;
|
||||||
userLabel?: ReactNode;
|
userLabel?: ReactNode;
|
||||||
iconStyle?: CSSProperties;
|
iconStyle?: CSSProperties;
|
||||||
};
|
};
|
||||||
@@ -48,6 +50,7 @@ export function UserStatusActions({
|
|||||||
avatarStyle,
|
avatarStyle,
|
||||||
gitHubClassName,
|
gitHubClassName,
|
||||||
gitHubStyle,
|
gitHubStyle,
|
||||||
|
versionStyle,
|
||||||
userLabel,
|
userLabel,
|
||||||
iconStyle,
|
iconStyle,
|
||||||
}: UserStatusActionsProps) {
|
}: UserStatusActionsProps) {
|
||||||
@@ -76,7 +79,7 @@ export function UserStatusActions({
|
|||||||
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||||
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||||
/>
|
/>
|
||||||
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{version}</span>
|
<VersionReleaseModal currentVersion={version} style={versionStyle} />
|
||||||
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
||||||
<div ref={accountRef}>
|
<div ref={accountRef}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { App, Modal, Tag, Timeline } from "antd";
|
||||||
|
import { useVersionCheck } from "@/hooks/use-version-check";
|
||||||
|
|
||||||
|
function getTagColor(type: string) {
|
||||||
|
if (type === "新增") return "green";
|
||||||
|
if (type === "修复") return "red";
|
||||||
|
if (type === "调整") return "blue";
|
||||||
|
if (type === "文档") return "purple";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReleaseTitle(version: string) {
|
||||||
|
return version === "Unreleased" ? "未发布" : version;
|
||||||
|
}
|
||||||
|
|
||||||
|
type VersionReleaseModalProps = {
|
||||||
|
currentVersion: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VersionReleaseModal({ currentVersion, className, style }: VersionReleaseModalProps) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck(currentVersion);
|
||||||
|
|
||||||
|
const handleCheckLatestRelease = useCallback((showMessage = false) => {
|
||||||
|
void checkLatestRelease().then((success) => {
|
||||||
|
if (!showMessage) return;
|
||||||
|
if (success) message.success("已获取最新版本信息");
|
||||||
|
else message.error("获取最新版本信息失败");
|
||||||
|
});
|
||||||
|
}, [checkLatestRelease, message]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
handleCheckLatestRelease();
|
||||||
|
}, [handleCheckLatestRelease, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
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)}
|
||||||
|
title="查看版本更新"
|
||||||
|
>
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
{currentVersion}
|
||||||
|
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<Modal
|
||||||
|
title="版本更新"
|
||||||
|
open={open}
|
||||||
|
width={680}
|
||||||
|
centered
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="mb-5 grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||||
|
<div className="text-xs text-stone-500 dark:text-stone-400">当前版本</div>
|
||||||
|
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{currentVersion}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs text-stone-500 dark:text-stone-400">最新版本</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
|
||||||
|
onClick={() => handleCheckLatestRelease(true)}
|
||||||
|
>
|
||||||
|
{checking ? "检查中..." : "检查更新"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[56vh] overflow-y-auto pr-2">
|
||||||
|
<Timeline
|
||||||
|
items={releases.map((release) => ({
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span>
|
||||||
|
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{release.version === latestVersion ? <Tag color="green">最新</Tag> : null}
|
||||||
|
{release.version === currentVersion ? <Tag>当前</Tag> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{release.items.map((item, index) => (
|
||||||
|
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
|
||||||
|
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">{item.type}</Tag>
|
||||||
|
<span className="min-w-0 flex-1">{item.content}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ReleaseInfo[]>(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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user