feat: 新增自动更新版本引导流程及相关配置调整

- 新增完整的upvp更新引导页面套件,涵盖版本展示、版本检查、测试协议确认与完成页流程
- 新增appVersion配置字段用于记录当前应用版本,调整配置文件存储路径与旧配置迁移逻辑
- 更新路由配置与全局状态管理以支持更新引导流程
- 更新自动更新计划文档与示例配置文件
This commit is contained in:
2026-08-31 03:04:23 +08:00
parent fa44d41d44
commit b3da357d57
16 changed files with 391 additions and 28 deletions
+27 -1
View File
@@ -35,6 +35,12 @@ import { OobeBetaTest } from "./pages/oobe/step-beta-test";
import { OobeFinish } from "./pages/oobe/step-finish";
import { OobeLegal } from "./pages/oobe/step-legal";
import { OobeAboutInfo } from "./pages/oobe/about-info";
import { UpvpComplete } from "./pages/upvp/step-complete";
import { UpvpVersion } from "./pages/upvp/step-version";
import { UpvpCheck } from "./pages/upvp/step-check";
import { UpvpBetaTest } from "./pages/upvp/step-beta-test";
import { UpvpFinish } from "./pages/upvp/step-finish";
import { VERSION } from "./lib/version";
const pageMap = {
home: Home,
@@ -56,6 +62,12 @@ const pageMap = {
"oobe/finish": OobeFinish,
"oobe/about-info": OobeAboutInfo,
"oobe/legal": OobeLegal,
upvp: UpvpComplete,
"upvp/complete": UpvpComplete,
"upvp/version": UpvpVersion,
"upvp/check": UpvpCheck,
"upvp/beta-test": UpvpBetaTest,
"upvp/finish": UpvpFinish,
debug: Debug,
"debug-splash": SplashDebug,
"debug-display": DisplayDebug,
@@ -83,9 +95,23 @@ function App() {
syncBackgroundFromConfig();
useAuthStore.getState().initFromRegistry();
useKoringAuthStore.getState().initFromDisk();
// Navigate to OOBE on first launch or if oobe not completed
// 首次启动 / 未完成 OOBE → OOBE
if (isFirstLaunch || cfg.oobe) {
useRouteStore.getState().navigate("oobe");
return;
}
// 配置里没有版本记录 → 补写当前版本(新装/旧配置迁移),直接进主页
if (!cfg.appVersion) {
useConfigStore.getState().setAppVersion(VERSION);
return;
}
// 程序版本 ≠ 配置版本 → 进入更新引导(upvp):
// 升级后 appVersion 仍是旧版本号(主进程不自动刷新),由 upvp 流程完成时写入新版本
if (cfg.appVersion !== VERSION) {
useRouteStore.getState().navigate("upvp/complete");
}
});
+2
View File
@@ -107,6 +107,8 @@ export interface InstanceMeta {
export interface AppConfig {
version: number;
/** 当前应用版本号(写入配置时由主进程刷新) */
appVersion: string;
oobe: boolean;
app: AppInfoConfig;
theme: ThemeConfig;
+14
View File
@@ -0,0 +1,14 @@
import { type ReactNode } from "react";
interface UpvpLayoutProps {
children: ReactNode;
}
/** 与 OOBE 相同的全屏居中布局框架 */
export function UpvpLayout({ children }: UpvpLayoutProps) {
return (
<div className="h-full flex flex-col items-center justify-center relative">
{children}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
interface NextButtonProps {
onClick: () => void;
disabled?: boolean;
}
/** 与 OOBE 相同的下一步圆形按钮 */
export function NextButton({ onClick, disabled = false }: NextButtonProps) {
return (
<div className="absolute bottom-12">
<button
onClick={onClick}
disabled={disabled}
className="w-12 h-12 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-xl disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/[0.06] disabled:hover:text-foreground/60"
>
</button>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
import { Loader2 } from "lucide-react";
/** 第三步:Beta 测试协议(仅测试版更新需要同意;框架与 OOBE 一致) */
export function UpvpBetaTest() {
const navigate = useRouteStore((s) => s.navigate);
const [loading, setLoading] = useState(true);
const [text, setText] = useState("");
const [checked, setChecked] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
fetch(`${import.meta.env.BASE_URL}protocol-beta.txt`)
.then((r) => r.text())
.then(setText)
.catch(() => setText("无法加载协议内容"))
.finally(() => setLoading(false));
}, 1500);
return () => clearTimeout(timer);
}, []);
if (loading) {
return (
<UpvpLayout>
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 animate-spin text-foreground/40" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</UpvpLayout>
);
}
return (
<UpvpLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{/* 标题 */}
<div className="text-center space-y-1">
<h2 className="text-lg font-bold text-foreground">Koring APP Beta </h2>
<p className="text-xs text-muted-foreground"></p>
</div>
{/* 协议内容 */}
<div className="w-full h-[300px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
{text}
</pre>
</div>
{/* 勾选框 */}
<label className="flex items-start gap-2.5 cursor-pointer select-none group">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="sr-only peer"
/>
<div className="w-4 h-4 rounded border border-border/60 bg-foreground/[0.03] peer-checked:bg-primary peer-checked:border-primary transition-colors flex items-center justify-center">
{checked && (
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2 6 5 9 10 3" />
</svg>
)}
</div>
</div>
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
</span>
</label>
</div>
<NextButton onClick={() => navigate("upvp/finish")} disabled={!checked} />
</UpvpLayout>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { VERSION } from "@/lib/version";
import { BUILD_MODE } from "@/lib/mode";
import { compareVersions } from "@/api/update";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
import { Loader2, ArrowUpCircle, AlertTriangle } from "lucide-react";
/** 第三步:检查版本(程序版本 vs 配置文件 appVersion */
export function UpvpCheck() {
const navigate = useRouteStore((s) => s.navigate);
const appVersion = useConfigStore((s) => s.config.appVersion);
const [state, setState] = useState<"loading" | "updated" | "rollback">("loading");
useEffect(() => {
let cancelled = false;
compareVersions(VERSION, appVersion)
.then((r) => {
if (cancelled) return;
// a>b → 已更新;a<b → 版本倒退;相等/无效 → 视为已更新(正常流程)
setState(r.result === "a<b" ? "rollback" : "updated");
})
.catch(() => {
if (!cancelled) setState("updated");
});
return () => {
cancelled = true;
};
}, [appVersion]);
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
const nextRoute = isTestBuild ? "upvp/beta-test" : "upvp/finish";
return (
<UpvpLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{state === "loading" ? (
<div className="flex flex-col items-center gap-3 py-4">
<Loader2 className="w-6 h-6 animate-spin text-foreground/40" />
<span className="text-sm text-muted-foreground">...</span>
</div>
) : state === "updated" ? (
<div className="flex flex-col items-center gap-3 text-center">
<ArrowUpCircle className="w-14 h-14 text-emerald-500" />
<h2 className="text-lg font-bold text-foreground">{VERSION}</h2>
<div className="text-xs text-muted-foreground space-y-1 font-mono">
<p>{VERSION}</p>
<p>{appVersion || "(无记录)"}</p>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3 text-center">
<AlertTriangle className="w-14 h-14 text-amber-500" />
<h2 className="text-lg font-bold text-foreground">退</h2>
<p className="text-xs text-muted-foreground leading-relaxed max-w-sm">
退
</p>
<div className="text-xs text-muted-foreground space-y-1 font-mono">
<p>{VERSION}</p>
<p>{appVersion}</p>
</div>
</div>
)}
</div>
{state !== "loading" && <NextButton onClick={() => navigate(nextRoute)} />}
</UpvpLayout>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useRouteStore } from "@/stores/routeStore";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
import { CheckCircle2 } from "lucide-react";
/** 第一步:更新已完成 */
export function UpvpComplete() {
const navigate = useRouteStore((s) => s.navigate);
return (
<UpvpLayout>
<div className="flex flex-col items-center gap-4">
<CheckCircle2 className="w-16 h-16 text-emerald-500" />
<h2 className="text-2xl font-bold text-foreground"></h2>
<p className="text-sm text-muted-foreground">Koring Launcher </p>
</div>
<NextButton onClick={() => navigate("upvp/version")} />
</UpvpLayout>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { VERSION } from "@/lib/version";
import { compareVersions } from "@/api/update";
import { UpvpLayout } from "./layout";
import { AppleHelloEnglishEffect } from "@/components/ui/apple-hello-effect";
/**
* 第四步(结束):与 OOBE 结束页一致。
* 写入 appVersion 规则:
* - 程序版本 > appVersion(正常升级)→ 更新 appVersion 为当前版本号
* - 程序版本 < appVersion(版本倒退)→ 不修改,保持原样
*/
export function UpvpFinish() {
const navigate = useRouteStore((s) => s.navigate);
const appVersion = useConfigStore((s) => s.config.appVersion);
const setAppVersion = useConfigStore((s) => s.setAppVersion);
const [ready, setReady] = useState(false);
const [shouldWrite, setShouldWrite] = useState(false);
useEffect(() => {
let cancelled = false;
compareVersions(VERSION, appVersion)
.then((r) => {
if (cancelled) return;
// 仅版本倒退(a<b)保持原样;相等/无效也写入(无害,值为当前版本)
setShouldWrite(r.result !== "a<b");
})
.catch(() => {
if (!cancelled) setShouldWrite(true);
})
.finally(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, [appVersion]);
const handleFinish = () => {
if (shouldWrite) {
// 正常升级:记录当前版本到配置,下次启动程序版本与配置版本一致 → 正常进入主页
setAppVersion(VERSION);
}
// 版本倒退:不修改 appVersion,下次启动仍会进入 upvp 提醒
navigate("home");
};
return (
<UpvpLayout>
<div className="flex flex-col items-center gap-6">
<AppleHelloEnglishEffect className="text-foreground" />
</div>
<div className="absolute bottom-12">
<button
onClick={handleFinish}
disabled={!ready}
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div>
</UpvpLayout>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { useRouteStore } from "@/stores/routeStore";
import { BUILD_MODE } from "@/lib/mode";
import { VersionCard } from "@/components/VersionCard";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
/** 第二步:版本展示(与 OOBE 版本页一致,纯展示版本卡片;检查在下一步) */
export function UpvpVersion() {
const navigate = useRouteStore((s) => s.navigate);
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
return (
<UpvpLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{/* 版本卡片(OOBE 模式:仅展示,无按钮) */}
<VersionCard oobe className="w-full" />
{/* 测试版警告(与 OOBE 版本页一致) */}
{isTestBuild && (
<p className="text-xs text-muted-foreground text-center max-w-sm leading-relaxed">
使
</p>
)}
</div>
<NextButton onClick={() => navigate("upvp/check")} />
</UpvpLayout>
);
}
+9
View File
@@ -44,10 +44,13 @@ interface ConfigState {
setUi: (patch: Partial<UiConfig>) => void;
setInstances: (instances: InstanceMeta[]) => void;
setOobe: (value: boolean) => void;
/** 记录当前应用版本号到配置(升级引导完成后由 upvp 流程写入) */
setAppVersion: (version: string) => void;
}
const DEFAULT_CONFIG: AppConfig = {
version: 1,
appVersion: "",
oobe: true,
app: { language: "zh-CN" },
theme: { darkMode: "auto", parallax: true },
@@ -167,4 +170,10 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
set({ config: { ...config, oobe: value } });
submit("oobe", value);
},
setAppVersion: (version) => {
const { config } = get();
set({ config: { ...config, appVersion: version } });
submit("appVersion", version);
},
}));
+13 -1
View File
@@ -20,6 +20,12 @@ export type RouteKey =
| "oobe/finish"
| "oobe/about-info"
| "oobe/legal"
| "upvp"
| "upvp/complete"
| "upvp/version"
| "upvp/check"
| "upvp/beta-test"
| "upvp/finish"
| "debug"
| "debug-splash"
| "debug-display"
@@ -64,6 +70,12 @@ export const allRoutes: RouteItem[] = [
{ key: "oobe/finish", label: "完成", path: "/oobe/finish", hidden: true },
{ key: "oobe/about-info", label: "关于信息", path: "/oobe/about-info", hidden: true, backable: true },
{ key: "oobe/legal", label: "法律信息", path: "/oobe/legal", hidden: true },
{ key: "upvp", label: "更新引导", path: "/upvp", hidden: true },
{ key: "upvp/complete", label: "更新已完成", path: "/upvp/complete", hidden: true },
{ key: "upvp/version", label: "当前版本", path: "/upvp/version", hidden: true },
{ key: "upvp/check", label: "检查版本", path: "/upvp/check", hidden: true },
{ key: "upvp/beta-test", label: "测试协议", path: "/upvp/beta-test", hidden: true },
{ key: "upvp/finish", label: "完成", path: "/upvp/finish", hidden: true },
{ key: "debug", label: "调试", path: "/debug", hidden: true },
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
@@ -75,7 +87,7 @@ export const allRoutes: RouteItem[] = [
const topLevelKeys = new Set(routes.map((r) => r.key));
function getRouteTitleBarMode(key: RouteKey): TitleBarMode {
if (key === "oobe" || key.startsWith("oobe/")) return "oobe";
if (key === "oobe" || key.startsWith("oobe/") || key === "upvp" || key.startsWith("upvp/")) return "oobe";
return topLevelKeys.has(key) ? "default" : "sub";
}