HouYunFei commited on
Commit
ec87435
·
1 Parent(s): 1a79029

feat(version): 添加版本更新检查和发布对话框功能

Browse files

- 实现了版本更新检查Hook useVersionCheck,支持远程版本检测
- 新增版本发布对话框组件VersionReleaseDialog,显示版本信息和更新日志
- 添加了CHANGELOG.md文件解析功能,支持版本历史展示
- 在顶部导航栏中替换静态版本号为可交互的版本对话框
- 集成GitHub远程版本检测,支持手动检查更新功能
- 添加版本卡片UI组件,区分当前版本和最新版本状态

CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 1.2.0 - 2026-05-28
6
+
7
+ + [新增] 当前版本基线,包含 Web 面板、画图、号池管理、注册机、图片管理、日志管理和设置能力。
8
+ + [新增] 前端版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和更新日志。
9
+ + [优化] 优化注册机效率,成功率大幅提高。
10
+ + [优化] 优化生图页面配置选项。
web/next.config.ts CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
2
  import { dirname, join } from 'node:path'
3
  import { fileURLToPath } from 'node:url'
4
  import type { NextConfig } from 'next'
 
5
 
6
  const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
7
 
@@ -15,11 +16,13 @@ function readAppVersion() {
15
  }
16
 
17
  const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion()
 
18
 
19
  const nextConfig: NextConfig = {
20
  allowedDevOrigins: ['127.0.0.1'],
21
  env: {
22
  NEXT_PUBLIC_APP_VERSION: appVersion,
 
23
  },
24
  output: 'export',
25
  trailingSlash: true,
 
2
  import { dirname, join } from 'node:path'
3
  import { fileURLToPath } from 'node:url'
4
  import type { NextConfig } from 'next'
5
+ import { parseChangelog } from './src/lib/release'
6
 
7
  const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
8
 
 
16
  }
17
 
18
  const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion()
19
+ const appReleases = JSON.stringify(parseChangelog(readFileSync(join(projectRoot, 'CHANGELOG.md'), 'utf-8')))
20
 
21
  const nextConfig: NextConfig = {
22
  allowedDevOrigins: ['127.0.0.1'],
23
  env: {
24
  NEXT_PUBLIC_APP_VERSION: appVersion,
25
+ NEXT_PUBLIC_APP_RELEASES: appReleases,
26
  },
27
  output: 'export',
28
  trailingSlash: true,
web/src/components/top-nav.tsx CHANGED
@@ -5,8 +5,8 @@ import { useEffect, useState } from "react";
5
  import { Github } from "lucide-react";
6
  import { usePathname, useRouter } from "next/navigation";
7
 
8
- import webConfig from "@/constants/common-env";
9
  import { ThemeToggle } from "@/components/theme-toggle";
 
10
  import { getValidatedAuthSession } from "@/lib/auth-session";
11
  import { cn } from "@/lib/utils";
12
  import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
@@ -121,9 +121,7 @@ export function TopNav() {
121
  <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 dark:bg-white/8 dark:text-stone-300 sm:inline-block sm:text-[11px]">
122
  {roleLabel} · {displayName}
123
  </span>
124
- <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 dark:bg-white/8 dark:text-stone-300 sm:inline-block sm:text-[11px]">
125
- v{webConfig.appVersion}
126
- </span>
127
  <button
128
  type="button"
129
  className="py-1 text-xs text-stone-400 transition hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200 sm:text-sm"
 
5
  import { Github } from "lucide-react";
6
  import { usePathname, useRouter } from "next/navigation";
7
 
 
8
  import { ThemeToggle } from "@/components/theme-toggle";
9
+ import { VersionReleaseDialog } from "@/components/version-release-dialog";
10
  import { getValidatedAuthSession } from "@/lib/auth-session";
11
  import { cn } from "@/lib/utils";
12
  import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
 
121
  <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 dark:bg-white/8 dark:text-stone-300 sm:inline-block sm:text-[11px]">
122
  {roleLabel} · {displayName}
123
  </span>
124
+ <VersionReleaseDialog />
 
 
125
  <button
126
  type="button"
127
  className="py-1 text-xs text-stone-400 transition hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200 sm:text-sm"
web/src/components/version-release-dialog.tsx ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import webConfig from "@/constants/common-env";
14
+ import { useVersionCheck } from "@/hooks/use-version-check";
15
+ import { cn } from "@/lib/utils";
16
+
17
+ function typeVariant(type: string): "success" | "danger" | "info" | "violet" | "outline" {
18
+ if (type === "新增") return "success";
19
+ if (type === "修复") return "danger";
20
+ if (type === "调整") return "info";
21
+ if (type === "文档") return "violet";
22
+ return "outline";
23
+ }
24
+
25
+ export function VersionReleaseDialog({ className }: { className?: string }) {
26
+ const {
27
+ open,
28
+ setOpen,
29
+ openReleaseModal,
30
+ latestVersion,
31
+ releases,
32
+ checking,
33
+ hasNewVersion,
34
+ checkLatestRelease,
35
+ } = useVersionCheck();
36
+
37
+ return (
38
+ <>
39
+ <button
40
+ type="button"
41
+ className={cn(
42
+ "relative rounded-md bg-stone-100 px-2 py-1 text-[11px] font-medium text-stone-500 transition hover:text-stone-900 dark:bg-white/8 dark:text-stone-300 dark:hover:text-white",
43
+ className,
44
+ )}
45
+ onClick={openReleaseModal}
46
+ title="查看版本更新"
47
+ >
48
+ v{webConfig.appVersion}
49
+ {hasNewVersion ? (
50
+ <span className="absolute -top-1 -right-1 size-2 rounded-full bg-emerald-500" />
51
+ ) : null}
52
+ </button>
53
+ <Dialog open={open} onOpenChange={setOpen}>
54
+ <DialogContent className="w-[min(94vw,680px)] rounded-2xl">
55
+ <DialogHeader>
56
+ <DialogTitle>版本更新</DialogTitle>
57
+ </DialogHeader>
58
+ <div className="grid grid-cols-2 gap-3">
59
+ <VersionCard label="当前版本" value={webConfig.appVersion} />
60
+ <VersionCard
61
+ label="最新版本"
62
+ value={latestVersion}
63
+ action={
64
+ <button
65
+ type="button"
66
+ className="text-[11px] text-stone-400 underline-offset-2 hover:text-stone-700 hover:underline dark:hover:text-stone-200"
67
+ onClick={() => void checkLatestRelease(true)}
68
+ >
69
+ {checking ? "检查中..." : "检查更新"}
70
+ </button>
71
+ }
72
+ />
73
+ </div>
74
+ <div className="max-h-[56vh] space-y-5 overflow-y-auto pr-1">
75
+ {releases.map((release) => (
76
+ <div key={release.version} className="border-l border-stone-200 pl-4 dark:border-white/10">
77
+ <div className="flex flex-wrap items-center gap-2">
78
+ <span className="text-sm font-semibold text-stone-950 dark:text-stone-100">
79
+ {release.version === "Unreleased" ? "未发布" : release.version}
80
+ </span>
81
+ <span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
82
+ {release.version === latestVersion ? <Badge variant="success">最新</Badge> : null}
83
+ {release.version === webConfig.appVersion ? <Badge variant="outline">当前</Badge> : null}
84
+ </div>
85
+ <div className="mt-2 space-y-1.5">
86
+ {release.items.map((item, index) => (
87
+ <div key={index} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
88
+ <Badge variant={typeVariant(item.type)} className="mt-0.5 shrink-0">
89
+ {item.type}
90
+ </Badge>
91
+ <span className="min-w-0 flex-1">{item.content}</span>
92
+ </div>
93
+ ))}
94
+ </div>
95
+ </div>
96
+ ))}
97
+ </div>
98
+ <Button variant="outline" size="sm" asChild>
99
+ <a href="https://github.com/basketikun/chatgpt2api" target="_blank" rel="noreferrer">
100
+ 前往 GitHub 更新
101
+ </a>
102
+ </Button>
103
+ </DialogContent>
104
+ </Dialog>
105
+ </>
106
+ );
107
+ }
108
+
109
+ function VersionCard({
110
+ label,
111
+ value,
112
+ action,
113
+ }: {
114
+ label: string;
115
+ value: string;
116
+ action?: ReactNode;
117
+ }) {
118
+ return (
119
+ <div className="rounded-xl border border-stone-200 bg-white/55 p-3 dark:border-white/10 dark:bg-white/5">
120
+ <div className="flex items-center justify-between gap-2">
121
+ <div className="text-xs text-stone-500 dark:text-stone-400">{label}</div>
122
+ {action}
123
+ </div>
124
+ <div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{value}</div>
125
+ </div>
126
+ );
127
+ }
web/src/hooks/use-version-check.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { toast } from "sonner";
5
+
6
+ import webConfig from "@/constants/common-env";
7
+ import { parseChangelog, type ReleaseInfo } from "@/lib/release";
8
+
9
+ const latestVersionUrl =
10
+ "https://raw.githubusercontent.com/basketikun/chatgpt2api/main/VERSION";
11
+ const latestChangelogUrl =
12
+ "https://raw.githubusercontent.com/basketikun/chatgpt2api/main/CHANGELOG.md";
13
+
14
+ function readLocalReleases(): ReleaseInfo[] {
15
+ return JSON.parse(process.env.NEXT_PUBLIC_APP_RELEASES || "[]");
16
+ }
17
+
18
+ function toVersionParts(version: string) {
19
+ const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
20
+ return match ? match.slice(1).map(Number) : null;
21
+ }
22
+
23
+ function isNewerVersion(latestVersion: string, currentVersion: string) {
24
+ const latest = toVersionParts(latestVersion);
25
+ const current = toVersionParts(currentVersion);
26
+ if (!latest || !current) return false;
27
+ return latest.some(
28
+ (value, index) =>
29
+ value > current[index] &&
30
+ latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex]),
31
+ );
32
+ }
33
+
34
+ export function useVersionCheck() {
35
+ const currentVersion = webConfig.appVersion;
36
+ const localReleases = useMemo(readLocalReleases, []);
37
+ const [latestVersion, setLatestVersion] = useState(currentVersion);
38
+ const [releases, setReleases] = useState<ReleaseInfo[]>(localReleases);
39
+ const [checking, setChecking] = useState(false);
40
+ const [open, setOpen] = useState(false);
41
+ const hasNewVersion = isNewerVersion(latestVersion, currentVersion);
42
+
43
+ const checkLatestVersion = useCallback(async () => {
44
+ const response = await fetch(latestVersionUrl);
45
+ if (response.ok) setLatestVersion((await response.text()).trim() || currentVersion);
46
+ }, [currentVersion]);
47
+
48
+ const checkLatestRelease = useCallback(
49
+ async (showMessage = false) => {
50
+ setChecking(true);
51
+ try {
52
+ const [versionResponse, changelogResponse] = await Promise.all([
53
+ fetch(latestVersionUrl),
54
+ fetch(latestChangelogUrl),
55
+ ]);
56
+ if (!versionResponse.ok || !changelogResponse.ok) throw new Error();
57
+ const [version, changelog] = await Promise.all([
58
+ versionResponse.text(),
59
+ changelogResponse.text(),
60
+ ]);
61
+ setLatestVersion(version.trim() || currentVersion);
62
+ if (changelog.trim()) setReleases(parseChangelog(changelog));
63
+ if (showMessage) toast.success("已获取最新版本信息");
64
+ } catch {
65
+ setLatestVersion(currentVersion);
66
+ setReleases(localReleases);
67
+ if (showMessage) toast.error("获取最新版本信息失败");
68
+ } finally {
69
+ setChecking(false);
70
+ }
71
+ },
72
+ [currentVersion, localReleases],
73
+ );
74
+
75
+ useEffect(() => {
76
+ void checkLatestVersion();
77
+ }, [checkLatestVersion]);
78
+
79
+ const openReleaseModal = () => {
80
+ setOpen(true);
81
+ void checkLatestRelease();
82
+ };
83
+
84
+ return {
85
+ open,
86
+ setOpen,
87
+ openReleaseModal,
88
+ latestVersion,
89
+ releases,
90
+ checking,
91
+ hasNewVersion,
92
+ checkLatestRelease,
93
+ };
94
+ }
web/src/lib/release.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type ReleaseInfo = {
2
+ version: string;
3
+ date: string;
4
+ items: { type: string; content: string }[];
5
+ };
6
+
7
+ export function parseChangelog(content: string): ReleaseInfo[] {
8
+ return content
9
+ .split(/^## /m)
10
+ .slice(1)
11
+ .map((block) => {
12
+ const [title = "", ...lines] = block.trim().split("\n");
13
+ const [, version = title.trim(), date = ""] =
14
+ title.match(/^(.+?)(?:\s+-\s+(.+))?$/) || [];
15
+ return {
16
+ version: version.trim(),
17
+ date: date.trim(),
18
+ items: lines
19
+ .map((line) => line.trim().match(/^\+\s+\[(.+?)\]\s+(.+)$/))
20
+ .filter((match): match is RegExpMatchArray => Boolean(match))
21
+ .map((match) => ({ type: match[1], content: match[2] })),
22
+ };
23
+ })
24
+ .filter((release) => release.items.length);
25
+ }