mirror of
https://github.com/lingke-net/koring-space.git
synced 2026-09-12 05:45:17 +08:00
275 lines
8.5 KiB
TypeScript
275 lines
8.5 KiB
TypeScript
import React from "react";
|
||||
|
|
import {
|
|||
|
|
Sparkles,
|
|||
|
|
Bug,
|
|||
|
|
Zap,
|
|||
|
|
RefreshCw,
|
|||
|
|
FileText,
|
|||
|
|
Rocket,
|
|||
|
|
Wrench,
|
|||
|
|
ChevronRight,
|
|||
|
|
type LucideIcon,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
import { MarkdownLite } from "./markdown-lite";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Release Notes 结构化渲染器
|
|||
|
|
*
|
|||
|
|
* 适配 Koring Launcher 发布说明格式(scripts/release-notes.ps1 生成):
|
|||
|
|
* # Koring Launcher Releases X
|
|||
|
|
* ## 版本信息
|
|||
|
|
* 当前版本 {FullVersion}
|
|||
|
|
* 编译状态:BETA / RUN
|
|||
|
|
* 签名状态:已签名
|
|||
|
|
* 构建来源:commit xxxxxx
|
|||
|
|
* ## 更新了什么内容
|
|||
|
|
* <details><summary>·Commit 1cf906d</summary>提交说明…</details>
|
|||
|
|
*
|
|||
|
|
* 渲染策略:
|
|||
|
|
* - 版本信息行 -> 顶部小徽章(编译状态 / 签名状态 / 构建来源)
|
|||
|
|
* - 每条 <details> commit -> 按提交类型(feat/fix/perf/… 或 修复/新增/优化/…)
|
|||
|
|
* 归类到独立卡片,每种类型带独立图标;卡片内每条 commit 仍是可折叠的
|
|||
|
|
* <details>(原生 HTML,无 JS 依赖)
|
|||
|
|
* - 非 commit 结构内容 -> 回退 MarkdownLite 常规渲染
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
export type ChangeType =
|
|||
|
|
| "feat"
|
|||
|
|
| "fix"
|
|||
|
|
| "perf"
|
|||
|
|
| "refactor"
|
|||
|
|
| "docs"
|
|||
|
|
| "build"
|
|||
|
|
| "other";
|
|||
|
|
|
|||
|
|
interface TypeMeta {
|
|||
|
|
label: string;
|
|||
|
|
icon: LucideIcon;
|
|||
|
|
color: string; // 图标颜色 class
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const TYPE_META: Record<ChangeType, TypeMeta> = {
|
|||
|
|
feat: { label: "新增功能", icon: Sparkles, color: "text-emerald-500" },
|
|||
|
|
fix: { label: "问题修复", icon: Bug, color: "text-rose-500" },
|
|||
|
|
perf: { label: "体验优化", icon: Zap, color: "text-amber-500" },
|
|||
|
|
refactor: { label: "代码重构", icon: RefreshCw, color: "text-violet-500" },
|
|||
|
|
docs: { label: "文档", icon: FileText, color: "text-sky-500" },
|
|||
|
|
build: { label: "构建与发布", icon: Rocket, color: "text-slate-500" },
|
|||
|
|
other: { label: "其他", icon: Wrench, color: "text-muted-foreground" },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const CARD_ORDER: ChangeType[] = [
|
|||
|
|
"feat",
|
|||
|
|
"fix",
|
|||
|
|
"perf",
|
|||
|
|
"refactor",
|
|||
|
|
"docs",
|
|||
|
|
"build",
|
|||
|
|
"other",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
export interface CommitInfo {
|
|||
|
|
hash: string;
|
|||
|
|
subject: string;
|
|||
|
|
body: string;
|
|||
|
|
type: ChangeType;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ReleaseInfoItem {
|
|||
|
|
label: string;
|
|||
|
|
value: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ParsedReleaseBody {
|
|||
|
|
mode: "structured" | "plain";
|
|||
|
|
info: ReleaseInfoItem[];
|
|||
|
|
commits: CommitInfo[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 根据提交说明推断变更类型(Conventional Commits / 中文前缀 / 英文动词) */
|
|||
|
|
export function classifyCommit(subject: string): ChangeType {
|
|||
|
|
const s = (subject || "").trim();
|
|||
|
|
if (!s) return "other";
|
|||
|
|
|
|||
|
|
// Conventional Commits: type(scope)?: message
|
|||
|
|
const m = s.match(
|
|||
|
|
/^(feat|feature|fix|perf|refactor|docs|style|test|chore|build|ci|revert)(?:\([^)]*\))?:\s*[\s\S]*$/i
|
|||
|
|
);
|
|||
|
|
if (m) {
|
|||
|
|
const t = m[1].toLowerCase();
|
|||
|
|
if (t === "feat" || t === "feature") return "feat";
|
|||
|
|
if (t === "fix") return "fix";
|
|||
|
|
if (t === "perf") return "perf";
|
|||
|
|
if (t === "refactor") return "refactor";
|
|||
|
|
if (t === "docs") return "docs";
|
|||
|
|
if (t === "build" || t === "ci") return "build";
|
|||
|
|
return "other"; // style/test/chore/revert
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 中文关键词
|
|||
|
|
if (/修复/.test(s)) return "fix";
|
|||
|
|
if (/新增|添加|新功能|支持/.test(s)) return "feat";
|
|||
|
|
if (/优化|改进|提升|完善|增强/.test(s)) return "perf";
|
|||
|
|
if (/重构/.test(s)) return "refactor";
|
|||
|
|
|
|||
|
|
// 无前缀英文
|
|||
|
|
const en = s.match(/^([A-Za-z]+)\b/);
|
|||
|
|
if (en) {
|
|||
|
|
const w = en[1].toLowerCase();
|
|||
|
|
if (/^(fix|correct|resolve|repair)/.test(w)) return "fix";
|
|||
|
|
if (
|
|||
|
|
/^(add|new|create|implement|introduce|integrate|enable|import|support|build)/.test(w)
|
|||
|
|
)
|
|||
|
|
return "feat";
|
|||
|
|
if (/^(stabilize|adjust|improve|optimize|update|refactor|polish)/.test(w))
|
|||
|
|
return "perf";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return "other";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stripHtml(s: string) {
|
|||
|
|
return s.replace(/<[^>]+>/g, "").trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 解析发布说明正文 */
|
|||
|
|
export function parseReleaseBody(text: string): ParsedReleaseBody {
|
|||
|
|
if (!text || !text.trim()) return { mode: "plain", info: [], commits: [] };
|
|||
|
|
|
|||
|
|
const detailsBlocks = [...text.matchAll(/<details([^>]*)>([\s\S]*?)<\/details>/gi)];
|
|||
|
|
|
|||
|
|
if (detailsBlocks.length === 0) {
|
|||
|
|
return { mode: "plain", info: [], commits: [] };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 版本信息行(正文顶层,不含 details 内部)
|
|||
|
|
const info: ReleaseInfoItem[] = [];
|
|||
|
|
for (const line of text.split(/\r?\n/)) {
|
|||
|
|
const m = line.match(
|
|||
|
|
/^(当前版本|编译状态|签名状态|构建来源)\s*[::]?\s*(.+)$/
|
|||
|
|
);
|
|||
|
|
if (m) info.push({ label: m[1], value: m[2].trim() });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const commits: CommitInfo[] = detailsBlocks.map((m) => {
|
|||
|
|
const inner = m[2];
|
|||
|
|
// summary 是标题行;正文 = inner 去掉 summary 后的部分
|
|||
|
|
const summaryMatch = inner.match(
|
|||
|
|
/<summary[^>]*>([\s\S]*?)<\/summary>/i
|
|||
|
|
);
|
|||
|
|
const summaryText = summaryMatch ? stripHtml(summaryMatch[1]) : "";
|
|||
|
|
const content = summaryMatch
|
|||
|
|
? inner.replace(summaryMatch[0], "")
|
|||
|
|
: inner;
|
|||
|
|
|
|||
|
|
const hashMatch =
|
|||
|
|
summaryText.match(/[0-9a-fA-F]{7,40}/) ||
|
|||
|
|
content.match(/[0-9a-fA-F]{7,40}/);
|
|||
|
|
const hash = hashMatch ? hashMatch[0] : "";
|
|||
|
|
|
|||
|
|
const lines = content
|
|||
|
|
.split(/\r?\n/)
|
|||
|
|
.map((l) => l.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
const subject =
|
|||
|
|
lines[0] ||
|
|||
|
|
summaryText.replace(/^[·\s]*Commit\s*/i, "").trim() ||
|
|||
|
|
"未命名变更";
|
|||
|
|
const body = lines.slice(1).join("\n");
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
hash,
|
|||
|
|
subject,
|
|||
|
|
body,
|
|||
|
|
type: classifyCommit(subject),
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { mode: "structured", info, commits };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function CommitRow({ commit }: { commit: CommitInfo }) {
|
|||
|
|
const meta = TYPE_META[commit.type];
|
|||
|
|
return (
|
|||
|
|
<li className="border-t border-border/40 first:border-t-0">
|
|||
|
|
<details className="group">
|
|||
|
|
<summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-sm transition-colors hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
|
|||
|
|
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground">
|
|||
|
|
{commit.hash ? `#${commit.hash.slice(0, 7)}` : "commit"}
|
|||
|
|
</span>
|
|||
|
|
<meta.icon className={cn("size-4 shrink-0", meta.color)} />
|
|||
|
|
<span className="min-w-0 flex-1 leading-snug">{commit.subject}</span>
|
|||
|
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
|||
|
|
</summary>
|
|||
|
|
{commit.body && (
|
|||
|
|
<div className="border-l-2 border-border/60 px-4 pb-3 ml-[3.4rem]">
|
|||
|
|
<MarkdownLite text={commit.body} />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</details>
|
|||
|
|
</li>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function TypeCard({ type, commits }: { type: ChangeType; commits: CommitInfo[] }) {
|
|||
|
|
const meta = TYPE_META[type];
|
|||
|
|
if (commits.length === 0) return null;
|
|||
|
|
return (
|
|||
|
|
<section className="overflow-hidden rounded-xl border border-border/50 bg-background/40">
|
|||
|
|
<header className="flex items-center gap-2 border-b border-border/50 bg-background/60 px-4 py-3">
|
|||
|
|
<span className="flex size-6 items-center justify-center rounded-md bg-muted">
|
|||
|
|
<meta.icon className={cn("size-4", meta.color)} />
|
|||
|
|
</span>
|
|||
|
|
<span className="font-semibold text-sm">{meta.label}</span>
|
|||
|
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|||
|
|
{commits.length}
|
|||
|
|
</span>
|
|||
|
|
</header>
|
|||
|
|
<ul className="divide-y divide-border/40">
|
|||
|
|
{commits.map((c, i) => (
|
|||
|
|
<CommitRow key={c.hash || `${c.subject}-${i}`} commit={c} />
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ReleaseNotes({ text, className }: { text: string; className?: string }) {
|
|||
|
|
const parsed = React.useMemo(() => parseReleaseBody(text), [text]);
|
|||
|
|
|
|||
|
|
if (parsed.mode === "plain") {
|
|||
|
|
return <MarkdownLite text={text} className={className} />;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const grouped = new Map<ChangeType, CommitInfo[]>();
|
|||
|
|
for (const t of CARD_ORDER) grouped.set(t, []);
|
|||
|
|
for (const c of parsed.commits) {
|
|||
|
|
grouped.get(c.type)?.push(c);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className={cn("flex flex-col gap-4", className)}>
|
|||
|
|
{/* 版本信息徽章 */}
|
|||
|
|
{parsed.info.length > 0 && (
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{parsed.info.map((it) => (
|
|||
|
|
<span
|
|||
|
|
key={it.label}
|
|||
|
|
className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/40 px-3 py-1 text-xs"
|
|||
|
|
>
|
|||
|
|
<span className="text-muted-foreground">{it.label}</span>
|
|||
|
|
<span className="font-medium">{it.value}</span>
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 提交类型卡片 */}
|
|||
|
|
{CARD_ORDER.map((t) => (
|
|||
|
|
<TypeCard key={t} type={t} commits={grouped.get(t) || []} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|