feat: 新增版本更新日志页面并调整初始化引导流程

- 新增 AboutVersion 组件,支持拉取 GitHub Release 日志并按提交类型分类展示
- 为 OOBE 和 UPvP 流程新增 about-version 页面,调整原版本页的跳转逻辑,先展示更新日志再进入后续步骤
- 注册新的路由配置,更新 README 与 DEV.md 文档说明新功能
This commit is contained in:
2026-09-04 19:48:00 +08:00
parent fad8f612bf
commit 9eaa482a4d
11 changed files with 404 additions and 4 deletions
+9
View File
@@ -107,6 +107,9 @@ koring-launcher/
│ │ │ ├── SectionTitle.tsx # PageHeader/SectionTitleHeroUI Typography
│ │ │ ├── controls.tsx # 设置控件(Select/NumberField/Switch/Radio/TextArea/FilePicker + fieldCls
│ │ │ └── Surface.tsx # Surface 兼容别名(旧 API,样式与卡片统一)
│ │ ├── about-version/ # 版本更新内容展示
│ │ │ ├── parse.ts # release-notes 解析(details 切块 + conventional commit 分类)
│ │ │ └── index.tsx # AboutVersion 组件(getReleaseNotes → 分类卡片)
│ │ ├── VersionCard.tsx # 版本/更新卡片
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件
│ │ └── StartupPopup.tsx # 启动弹窗
@@ -562,3 +565,9 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
| 网络 | 安全识别服务 | 实装 | `network.securityId` |
| 网络 | 以太联机 / 陶瓦联机 | 占位 | 无后端接口 |
| 其他 | 服务与反馈 / 赞助我们 / 开发者选项 | 保留 | — |
### 版本更新内容(AboutVersion
- `src/components/about-version/`AboutVersion 组件通过 `getReleaseNotes()`GitHub release-notes.md,主进程自动切加速源、回退最新版)拉取**当前版本**说明,经 `parseReleaseNotes()` 解析后按提交类型分组,每组卡片 + 图标展示(feat 新增 / fix 修复 / perf 优化 / refactor 重构 / docs 文档 / other 其他)。
- **OOBE / UPvP 流程**`版本卡片 → 关于此版本 → …``oobe/about-version``upvp/about-version` 引用 AboutVersion;原 step-version 的下一步分别指向这两个新页)。
- **Release notes 受控格式约定(与 CI release 模板对齐)**`## 更新了什么内容` 下每条变更一个 `<details>``<summary>·Commit {sha}</summary>`,正文首行为 conventional commit `type(scope): subject`,换行后为说明);无提交时写 `· 无提交记录`。解析器亦兼容无 details 的列表兜底。
+1
View File
@@ -182,6 +182,7 @@ import { APP_ICON, DEFAULT_BG, LOGO_SVG, BUILD_MODE, isDev } from "@/lib/mode";
- **游戏目录导入**:版本从**当前扫描目录**导入(`sourceGamePath`),实例建在主库;主目录变更自动重扫;批量导入幂等(已存在跳过);相对 `gameDir`(默认 `.minecraft`)由主进程 `resolveGamePath` 按 exe 目录/项目根归一化。
- **设置页结构(参考 PCL2)**:通用(主页/Koring 账户/关于/版权)与其他(服务与反馈/赞助/开发者)保留;游戏组(账户·离线登录、Java、目录、高级·含快速进入服务器)、个性化组(主题背景/主界面/语言/辅助)、网络组(下载/安全识别)已对接设置接口;以太/陶瓦联机页暂为占位。新增配置段:`app.language`(语言偏好)、`ui.showInstanceTitle/showTaskButton`(主界面元素)、`advanced.server`(快速进入服务器,启动自动加入)。
- **离线账号登录**`auth:offline-login` 处理器生成离线 UUIDMD5(OfflinePlayer:用户名));微软登录 UI 标注"开发中"。
- **AboutVersion(关于此版本)**`src/components/about-version/` 拉取当前版本 GitHub 发布说明,解析后**按提交类型(新增/修复/优化/重构/文档/其他)分类卡片**展示(不渲染原始 Markdown);OOBE/UPvP 流程步骤为「版本卡片 → 关于此版本」。
## Tech Stack
File diff suppressed because one or more lines are too long
+4
View File
@@ -32,12 +32,14 @@ import { OobeAgreement } from "./pages/oobe/step-agreement";
import { OobeLogin } from "./pages/oobe/step-login";
import { OobeWelcome } from "./pages/oobe/step-welcome";
import { OobeVersion } from "./pages/oobe/step-version";
import { OobeAboutVersion } from "./pages/oobe/about-version";
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 { UpvpAboutVersion } from "./pages/upvp/about-version";
import { UpvpCheck } from "./pages/upvp/step-check";
import { UpvpBetaTest } from "./pages/upvp/step-beta-test";
import { UpvpFinish } from "./pages/upvp/step-finish";
@@ -59,6 +61,7 @@ const pageMap = {
"oobe/login": OobeLogin,
"oobe/welcome": OobeWelcome,
"oobe/version": OobeVersion,
"oobe/about-version": OobeAboutVersion,
"oobe/beta-test": OobeBetaTest,
"oobe/finish": OobeFinish,
"oobe/about-info": OobeAboutInfo,
@@ -66,6 +69,7 @@ const pageMap = {
upvp: UpvpComplete,
"upvp/complete": UpvpComplete,
"upvp/version": UpvpVersion,
"upvp/about-version": UpvpAboutVersion,
"upvp/check": UpvpCheck,
"upvp/beta-test": UpvpBetaTest,
"upvp/finish": UpvpFinish,
+167
View File
@@ -0,0 +1,167 @@
// AboutVersion:展示当前版本的更新内容。
// 数据源:getReleaseNotes()GitHub release-notes.md,主进程自动切加速源,回退最新版)。
// 不直接渲染 Markdown —— 解析后按提交类型(新增/修复/优化/重构/文档/其他)分组,
// 每组配图标,用卡片展示。
import { useCallback, useEffect, useState } from "react";
import { Sparkles, Bug, Gauge, RefreshCw, FileText, Wrench, ExternalLink, GitCommitHorizontal } from "lucide-react";
import { getReleaseNotes, type ReleaseNotesResult } from "@/api/update";
import { VERSION } from "@/lib/version";
import { parseReleaseNotes, type ChangeType, type VersionChange } from "./parse";
const GITHUB_RELEASES = "https://github.com/dream-pep/koring-launcher/releases";
const CATEGORY_ORDER: ChangeType[] = ["feat", "fix", "perf", "refactor", "docs", "other"];
const CATEGORY_META: Record<ChangeType, { label: string; icon: React.ComponentType<{ className?: string }>; iconCls: string; dotCls: string }> = {
feat: { label: "新增功能", icon: Sparkles, iconCls: "text-sky-600 dark:text-sky-400 bg-sky-500/10", dotCls: "bg-sky-500" },
fix: { label: "修复", icon: Bug, iconCls: "text-red-600 dark:text-red-400 bg-red-500/10", dotCls: "bg-red-500" },
perf: { label: "性能优化", icon: Gauge, iconCls: "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10", dotCls: "bg-emerald-500" },
refactor: { label: "重构", icon: RefreshCw, iconCls: "text-violet-600 dark:text-violet-400 bg-violet-500/10", dotCls: "bg-violet-500" },
docs: { label: "文档", icon: FileText, iconCls: "text-amber-600 dark:text-amber-400 bg-amber-500/10", dotCls: "bg-amber-500" },
other: { label: "其他", icon: Wrench, iconCls: "text-foreground/60 bg-foreground/[0.06]", dotCls: "bg-foreground/40" },
};
/** 一条变更(含 commit 标)+ 短分隔条 */
function ChangeItem({ change }: { change: VersionChange }) {
return (
<div className="flex items-start gap-2.5 px-4 py-2.5 first:pt-3 last:pb-3">
{change.commit && (
<span className="inline-flex items-center gap-1 shrink-0 mt-[3px] font-mono text-[10px] px-1.5 py-0.5 rounded bg-foreground/[0.05] dark:bg-white/[0.05] text-muted-foreground/80">
<GitCommitHorizontal className="w-3 h-3" />
{change.commit}
</span>
)}
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-foreground leading-snug">{change.title}</p>
{change.description && (
<p className="text-[12px] text-muted-foreground/80 leading-relaxed mt-0.5">{change.description}</p>
)}
</div>
</div>
);
}
/** 一个分类卡片:图标头部 + 该类型的变更列表 */
function CategoryCard({ type, items }: { type: ChangeType; items: VersionChange[] }) {
const meta = CATEGORY_META[type];
const Icon = meta.icon;
return (
<div className="rounded-xl overflow-hidden border border-black/[0.06] dark:border-white/[0.07] bg-white/85 dark:bg-black/45 backdrop-blur-[12px]">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-black/[0.05] dark:border-white/[0.06]">
<span className={`flex items-center justify-center w-6 h-6 rounded-md ${meta.iconCls}`}>
<Icon className="w-3.5 h-3.5" />
</span>
<span className="text-[13px] font-semibold text-foreground">{meta.label}</span>
<span className="text-[11px] text-muted-foreground/60 ml-auto tabular-nums">{items.length} </span>
</div>
<div className="divide-y divide-black/[0.04] dark:divide-white/[0.05]">
{items.map((c, i) => (
<ChangeItem key={`${c.commit ?? ""}-${i}`} change={c} />
))}
</div>
</div>
);
}
function EmptyCard({ text }: { text: string }) {
return (
<div className="rounded-xl border border-black/[0.06] dark:border-white/[0.07] bg-white/85 dark:bg-black/45 backdrop-blur-[12px] px-5 py-10 text-center">
<p className="text-[13px] text-muted-foreground">{text}</p>
</div>
);
}
export function AboutVersion({ className }: { className?: string }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ReleaseNotesResult | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const r = await getReleaseNotes();
setResult(r);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const parsed = result ? parseReleaseNotes(result.notes, result.version, result.tag) : null;
const grouped = parsed
? CATEGORY_ORDER.map((t) => ({ type: t, items: parsed.changes.filter((c) => c.type === t) })).filter(
(g) => g.items.length > 0,
)
: [];
return (
<div className={className}>
{/* 头部:版本 + 来源 */}
<div className="flex items-center justify-between mb-2 px-1">
<span className="text-[12px] text-muted-foreground/80">
{parsed ? (
<>
v{parsed.version}
{result?.isLatest && parsed.version !== VERSION && (
<span className="ml-2 opacity-70"></span>
)}
</>
) : (
<> v{VERSION}</>
)}
</span>
</div>
{loading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-[76px] rounded-xl bg-white/50 dark:bg-white/[0.04] animate-pulse" />
))}
</div>
) : error ? (
<EmptyCard text="获取此版本的更新内容失败" />
) : !result || !parsed ? (
<EmptyCard text="未能获取到发布说明(GitHub 与加速源均不可用,或该版本尚未发布)" />
) : (
<div className="space-y-2.5">
{parsed.noCommits && parsed.changes.length === 0 ? (
<EmptyCard text="此版本暂无变更记录" />
) : grouped.length === 0 ? (
<EmptyCard text="此版本暂无变更记录" />
) : (
grouped.map((g) => <CategoryCard key={g.type} type={g.type} items={g.items} />)
)}
</div>
)}
{/* 底部操作 */}
<div className="flex items-center gap-2 mt-3 px-1">
{error && (
<>
<p className="text-[11px] text-destructive/80 flex-1 truncate">{error}</p>
<button
onClick={load}
className="text-[12px] font-medium text-primary hover:underline shrink-0"
>
</button>
</>
)}
<button
onClick={() => window.electronAPI?.openExternal(GITHUB_RELEASES)}
className="inline-flex items-center gap-0.5 text-[12px] text-muted-foreground hover:text-foreground transition-colors ml-auto shrink-0"
>
GitHub Releases
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
// 版本发布说明(release-notes.md / GitHub Release body)解析器。
// 目标:不渲染原始 Markdown,把每条变更提取出来并按提交类型(conventional commit)分类。
//
// 受控格式约定(与 CI release 模板对齐):
// # Koring Launcher Releases x
// ## 版本信息
// 当前版本 x
// ...
// ## 更新了什么内容
// <details>
// <summary>·Commit abc1234</summary>
//
// fix(updater): 标题
//
// 详细说明…
// </details>
// 或:· 无提交记录
export type ChangeType = "feat" | "fix" | "perf" | "refactor" | "docs" | "other";
export interface VersionChange {
/** commit 短 hash(无则省略) */
commit?: string;
type: ChangeType;
/** 去除 type(scope): 前缀后的标题 */
title: string;
/** 详细说明(纯文本,已剥离 md 记号) */
description?: string;
}
export interface ParsedRelease {
version: string;
tag: string;
/** notes 中含「无提交记录」 */
noCommits: boolean;
changes: VersionChange[];
}
/** conventional commit type → 业务分类(大小写不敏感) */
const TYPE_ALIAS: Record<string, ChangeType> = {
feat: "feat",
feature: "feat",
add: "feat",
fix: "fix",
bugfix: "fix",
perf: "perf",
optimize: "perf",
performance: "perf",
improve: "perf",
refactor: "refactor",
docs: "docs",
doc: "docs",
chore: "other",
ci: "other",
build: "other",
style: "other",
test: "other",
revert: "other",
};
/** 去掉行内的常见 markdown 记号,得到纯文本 */
function stripInlineMarkdown(text: string): string {
return text
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 链接 [t](url) → t
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // 图片 ![t](url) → t
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/[_~]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
/** 解析 conventional commit 首行:type(scope): subject */
function parseCommitTitle(line: string): { type: ChangeType; title: string } {
const m = line.trim().match(/^([a-zA-Z][\w-]*)(?:\(([^)]*)\))?!?:\s*(.*)$/);
if (!m) {
return { type: "other", title: stripInlineMarkdown(line) };
}
const rawType = m[1].toLowerCase();
const subject = stripInlineMarkdown(m[3] || m[2] || line);
return { type: TYPE_ALIAS[rawType] ?? "other", title: subject };
}
/** 提取一个 <details> 块中的 summary sha 与正文 */
function splitDetailsBlock(block: string): { commit?: string; content: string } {
const summary = block.match(/<summary>\s*[·•-]?\s*Commit\s*([0-9a-fA-F]{4,40})?/i);
const content = block
.replace(/<summary>[\s\S]*?<\/summary>/i, "")
.replace(/<\/?details>/gi, "")
.trim();
return { commit: summary?.[1]?.slice(0, 7), content };
}
/** 把一段文本按行解析为一条变更 */
function parseChangeLines(text: string): VersionChange {
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
const head = lines[0] || "";
const { type, title } = parseCommitTitle(head);
const rest = lines.slice(1);
// 剩余行通常为换行后的说明;合并(保留相对短行),剥离剩余 md 记号
const description = rest.length > 0 ? stripInlineMarkdown(rest.join(" ")) : undefined;
return { type, title, description: description || undefined };
}
/**
* 解析发布说明 notes 为结构化变更列表。
* 优先切分 <details> 块(受控格式);无 details 时按「更新了什么内容」节下的行兜底。
*/
export function parseReleaseNotes(notes: string, version: string, tag: string): ParsedRelease {
const noCommits = /无提交记录|no commits?/i.test(notes);
// 定位「更新了什么内容」节(找不到则用整篇)
const sectionIdx = notes.search(/^##\s*更新了什么内容/m);
const body = sectionIdx >= 0 ? notes.slice(sectionIdx) : notes;
const changes: VersionChange[] = [];
// 1) <details> 块切分(受控格式)
const detailRe = /<details[^>]*>([\s\S]*?)<\/details>/gi;
let match: RegExpExecArray | null;
let detailsFound = false;
while ((match = detailRe.exec(body)) !== null) {
detailsFound = true;
const { commit, content } = splitDetailsBlock(match[1]);
if (!content) continue;
const change = parseChangeLines(content);
if (commit) change.commit = commit;
changes.push(change);
}
// 2) 兜底:无 <details> 时按行扫描「· Commit / - 」开头的条目
if (!detailsFound) {
const lines = body
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !/^#/.test(l));
let current: VersionChange | null = null;
for (const line of lines) {
const entry = line.match(/^[·•\-]\s*(?:Commit\s*)?([0-9a-fA-F]{7,40})?\s*(.*)$/i);
if (entry) {
const { type, title } = parseCommitTitle(entry[2] || entry[1] || line);
current = { type, title };
if (entry[1]) current.commit = entry[1].slice(0, 7);
changes.push(current);
} else if (current) {
// 说明行并入上一条
const desc = stripInlineMarkdown(line);
if (desc) current.description = current.description ? `${current.description} ${desc}` : desc;
} else {
// 游离正文行(版本信息等)跳过
}
}
}
return { version, tag, noCommits, changes };
}
+31
View File
@@ -0,0 +1,31 @@
import { useRouteStore } from "@/stores/routeStore";
import { BUILD_MODE } from "@/lib/mode";
import { VERSION } from "@/lib/version";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
import { AboutVersion } from "@/components/about-version";
/** 版本卡片之后:关于此版本(展示当前版本更新内容,引用 AboutVersion 组件) */
export function OobeAboutVersion() {
const navigate = useRouteStore((s) => s.navigate);
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
// 与原本 step-version 的分流一致:测试版需先同意 Beta 测试协议
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/finish";
return (
<OobeLayout>
<div className="w-full max-w-lg flex flex-col items-center px-6">
<h2 className="text-lg font-bold text-foreground mb-0.5"></h2>
<p className="text-[12px] text-muted-foreground mb-4"> v{VERSION} </p>
{/* 内容滚动区:限高避免遮挡底部下一步按钮 */}
<div className="w-full max-h-[58vh] overflow-y-auto scroll-area pr-1 -mr-1 min-h-[140px]">
<AboutVersion />
</div>
</div>
<NextButton onClick={() => navigate(nextRoute)} />
</OobeLayout>
);
}
+2 -2
View File
@@ -10,9 +10,9 @@ export function OobeVersion() {
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
// 到达本页前已依次经过 协议(agreement) → 法律(legal) → 欢迎(welcome)
// 因此正式版下一步直接结束;测试版需先同意 Beta 测试协议。
// 先进入「关于此版本」查看当前版本更新内容,再按构建类型结束或进入 Beta 测试协议。
// (不要跳回 agreement——那会形成 agreement → legal → welcome → version → agreement 死循环)
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/finish";
const nextRoute = "oobe/about-version";
return (
<OobeLayout>
+26
View File
@@ -0,0 +1,26 @@
import { useRouteStore } from "@/stores/routeStore";
import { VERSION } from "@/lib/version";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
import { AboutVersion } from "@/components/about-version";
/** 版本卡片之后:关于此版本(展示当前版本更新内容,引用 AboutVersion 组件) */
export function UpvpAboutVersion() {
const navigate = useRouteStore((s) => s.navigate);
return (
<UpvpLayout>
<div className="w-full max-w-lg flex flex-col items-center px-6">
<h2 className="text-lg font-bold text-foreground mb-0.5"></h2>
<p className="text-[12px] text-muted-foreground mb-4"> v{VERSION} </p>
{/* 内容滚动区:限高避免遮挡底部下一步按钮 */}
<div className="w-full max-h-[58vh] overflow-y-auto scroll-area pr-1 -mr-1 min-h-[140px]">
<AboutVersion />
</div>
</div>
<NextButton onClick={() => navigate("upvp/check")} />
</UpvpLayout>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function UpvpVersion() {
)}
</div>
<NextButton onClick={() => navigate("upvp/check")} />
<NextButton onClick={() => navigate("upvp/about-version")} />
</UpvpLayout>
);
}
+4
View File
@@ -14,6 +14,7 @@ export type RouteKey =
| "oobe/language"
| "oobe/agreement"
| "oobe/version"
| "oobe/about-version"
| "oobe/beta-test"
| "oobe/login"
| "oobe/welcome"
@@ -23,6 +24,7 @@ export type RouteKey =
| "upvp"
| "upvp/complete"
| "upvp/version"
| "upvp/about-version"
| "upvp/check"
| "upvp/beta-test"
| "upvp/finish"
@@ -65,6 +67,7 @@ export const allRoutes: RouteItem[] = [
{ key: "oobe/language", label: "语言设置", path: "/oobe/language", hidden: true },
{ key: "oobe/agreement", label: "同意协议", path: "/oobe/agreement", hidden: true },
{ key: "oobe/version", label: "当前版本", path: "/oobe/version", hidden: true },
{ key: "oobe/about-version", label: "关于此版本", path: "/oobe/about-version", hidden: true },
{ key: "oobe/beta-test", label: "测试协议", path: "/oobe/beta-test", hidden: true },
{ key: "oobe/login", label: "登录", path: "/oobe/login", hidden: true },
{ key: "oobe/welcome", label: "欢迎", path: "/oobe/welcome", hidden: true },
@@ -74,6 +77,7 @@ export const allRoutes: RouteItem[] = [
{ 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/about-version", label: "关于此版本", path: "/upvp/about-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 },