Add interactive version range display in VersionTable

This commit is contained in:
CPTProgrammer 2026-07-16 14:17:49 +08:00
parent e45db0ffc0
commit 5f6cab6529
No known key found for this signature in database
4 changed files with 219 additions and 71 deletions

View File

@ -1,8 +1,10 @@
"use client";
import { Table, Empty, Tag, Typography } from "antd";
import { useMemo } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import { Table, Empty, Tag, Typography, Select, Flex } from "antd";
import type { ParsedProject } from "@/services/project";
import { computeVersionRanges, collectAutoRange, type McVersionEntry } from "@/lib/utils/mcVersion";
import { getMcVersionUnion } from "@/services/meta";
interface ArtifactRow {
key: string;
@ -22,67 +24,192 @@ export function VersionTable({
loading,
onLoadingChange,
}: VersionTableProps) {
const dataSource: ArtifactRow[] = useMemo(() => {
if (!project) return [];
return project.artifacts.map((a) => ({
key: a.mc_version,
mc_version: a.mc_version,
artifact: a.artifact,
sources: a.sources,
}));
}, [project]);
// ── MC version union from both platforms ──
const [allMcVersions, setAllMcVersions] = useState<string[]>([]);
const columns = [
{
title: "MC 版本",
dataIndex: "mc_version",
key: "mc_version",
width: 120,
render: (val: string) => <Tag>{val}</Tag>,
},
{
title: "兼容范围",
dataIndex: "mc_version",
key: "range",
width: 200,
render: (_: string, __: ArtifactRow, index: number) => {
const isLast =
project && index === project.artifacts.length - 1;
// 兼容范围在发布前由 PublishModal 加载元数据后完整计算
// 此处仅作占位显示
return isLast ? (
<Tag color="blue"></Tag>
) : (
<Typography.Text type="secondary"></Typography.Text>
);
// Last version end selection (the editable dropdown value)
const [lastVersionEnd, setLastVersionEnd] = useState<string | null>(null);
// Fetch MC version union when project changes
useEffect(() => {
if (!project || project.artifacts.length === 0) {
setAllMcVersions([]);
return;
}
let cancelled = false;
onLoadingChange(true);
getMcVersionUnion()
.then((versions) => {
if (cancelled) return;
setAllMcVersions(versions);
// Default last version end to the latest available version
setLastVersionEnd(versions[versions.length - 1] ?? null);
})
.finally(() => {
if (!cancelled) {
onLoadingChange(false);
}
});
return () => {
cancelled = true;
};
}, [project, onLoadingChange]);
// ── Compute version ranges ──
const versionRanges: McVersionEntry[] | null = useMemo(() => {
if (!project || allMcVersions.length === 0) return null;
const projectVersions = project.artifacts.map((a) => a.mc_version);
// computeVersionRanges expects ascending order; project versions are descending
const ascending = [...projectVersions].reverse();
const entries = computeVersionRanges(
ascending,
allMcVersions,
lastVersionEnd ?? undefined,
);
// Reverse back to match descending display order
return [...entries].reverse();
}, [project, allMcVersions, lastVersionEnd]);
// ── Dropdown options: 同系列中 >= 最后一个项目版本的版本 ──
const lastVersionDropdownOptions = useMemo(() => {
if (!project || allMcVersions.length === 0) return [];
// Descending display, index 0 is the largest (= last in compatibility)
const lastVer = project.artifacts[0]?.mc_version;
if (!lastVer) return [];
const series = collectAutoRange(lastVer, allMcVersions);
return series
.filter(
(v) =>
v.localeCompare(lastVer, undefined, { numeric: true }) >= 0,
)
.map((v) => ({ value: v, label: v }));
}, [project, allMcVersions]);
// Reset lastVersionEnd to latest in the same series
useEffect(() => {
if (allMcVersions.length === 0 || !project) return;
const lastVer = project.artifacts[0]?.mc_version;
if (!lastVer) return;
const series = collectAutoRange(lastVer, allMcVersions);
setLastVersionEnd(series[series.length - 1] ?? null);
}, [allMcVersions, project]);
// ── Table data ──
const dataSource: (ArtifactRow & { rangeEntry?: McVersionEntry })[] =
useMemo(() => {
if (!project) return [];
return project.artifacts.map((a, i) => ({
key: a.mc_version,
mc_version: a.mc_version,
artifact: a.artifact,
sources: a.sources,
rangeEntry: versionRanges?.[i],
}));
}, [project, versionRanges]);
// ── Dropdown change handler ──
const handleLastVersionEndChange = useCallback((value: string) => {
setLastVersionEnd(value);
}, []);
// ── Columns ──
const columns = useMemo(
() => [
{
title: "MC 版本",
dataIndex: "mc_version",
key: "mc_version",
width: 120,
render: (val: string) => <Tag>{val}</Tag>,
},
},
{
title: "构建产物",
dataIndex: "artifact",
key: "artifact",
ellipsis: true,
render: (val: string | null) =>
val ? (
<Typography.Text>{val}</Typography.Text>
) : (
<Typography.Text type="danger"></Typography.Text>
),
},
{
title: "源码",
dataIndex: "sources",
key: "sources",
ellipsis: true,
render: (val: string | null) =>
val ? (
<Typography.Text>{val}</Typography.Text>
) : (
<Typography.Text type="danger"></Typography.Text>
),
},
];
{
title: "兼容范围",
key: "range",
width: 280,
render: (
_: unknown,
record: ArtifactRow & { rangeEntry?: McVersionEntry },
index: number,
) => {
const entry = record.rangeEntry;
if (!entry) {
return (
<Typography.Text type="secondary">
</Typography.Text>
);
}
// Descending order: index 0 is the largest (= last in compatibility terms)
const isLast = index === 0;
if (!isLast) {
// Non-last: show wildcard if it differs, otherwise explicit
return <Tag color="blue">{entry.wildcard}</Tag>;
}
// Last version: range + editable end-version dropdown
return (
<Flex align="center" gap={6}>
<Tag color="blue">{entry.wildcard}</Tag>
<Typography.Text
type="secondary"
style={{ fontSize: 12, whiteSpace: "nowrap" }}
>
</Typography.Text>
<Select
size="small"
style={{ width: 110 }}
showSearch
optionFilterProp="label"
value={lastVersionEnd ?? undefined}
onChange={handleLastVersionEndChange}
options={lastVersionDropdownOptions}
/>
</Flex>
);
},
},
{
title: "构建产物",
dataIndex: "artifact",
key: "artifact",
ellipsis: true,
render: (val: string | null) =>
val ? (
<Typography.Text>{val}</Typography.Text>
) : (
<Typography.Text type="danger"></Typography.Text>
),
},
{
title: "源码",
dataIndex: "sources",
key: "sources",
ellipsis: true,
render: (val: string | null) =>
val ? (
<Typography.Text>{val}</Typography.Text>
) : (
<Typography.Text type="danger"></Typography.Text>
),
},
],
[lastVersionEnd, lastVersionDropdownOptions, handleLastVersionEndChange],
);
// ── Empty state ──
if (!project) {
return (
<Empty
@ -93,7 +220,7 @@ export function VersionTable({
}
return (
<Table<ArtifactRow>
<Table<ArtifactRow & { rangeEntry?: McVersionEntry }>
dataSource={dataSource}
columns={columns}
loading={loading}

View File

@ -88,10 +88,10 @@ describe("computeVersionRanges", () => {
const result = computeVersionRanges(
["1.20", "1.20.3", "1.21"],
ALL_AVAILABLE,
"1.21.3",
"1.21.2",
);
const last = result[2];
// [1.21, 1.21.3) → 1.21, 1.21.1, 1.21.2
// [1.21, 1.21.2] → 1.21, 1.21.1, 1.21.2
expect(last.range).toEqual(["1.21", "1.21.1", "1.21.2"]);
expect(last.wildcard).toBe("1.21-1.21.2");
expect(last.explicit).toBe("1.21-1.21.2");
@ -114,9 +114,9 @@ describe("computeVersionRanges", () => {
const result = computeVersionRanges(
["26.1"],
ALL_AVAILABLE,
"27.0",
"26.3",
);
// [26.1, 27.0) 包含 26.1~26.3 — 完整 26.x 系列
// [26.1, 26.3] 包含 26.1~26.3 — 完整 26.x 系列
expect(result[0]).toMatchObject({
version: "26.1",
range: ["26.1", "26.2", "26.3"],

View File

@ -17,7 +17,7 @@ export interface McVersionEntry {
*
* @param projectVersions MC
* @param allAvailable MC
* @param lastVersionEnd
* @param lastVersionEnd
*/
export function computeVersionRanges(
projectVersions: string[],
@ -27,7 +27,8 @@ export function computeVersionRanges(
return projectVersions.map((version, i) => {
const isLast = i === projectVersions.length - 1;
const end = isLast ? (lastVersionEnd ?? null) : projectVersions[i + 1];
return computeEntry(version, end, allAvailable);
const inclusiveEnd = isLast && lastVersionEnd != null;
return computeEntry(version, end, allAvailable, inclusiveEnd);
});
}
@ -37,9 +38,10 @@ function computeEntry(
start: string,
end: string | null,
allAvailable: string[],
inclusiveEnd?: boolean,
): McVersionEntry {
const range = end !== null
? collectRange(start, end, allAvailable)
? collectRange(start, end, allAvailable, inclusiveEnd)
: collectAutoRange(start, allAvailable);
const lastInRange = range[range.length - 1] ?? start;
@ -49,16 +51,20 @@ function computeEntry(
return { version: start, range, wildcard, explicit };
}
/** 收集 [start, end) 区间内的所有版本 */
/** 收集区间内的所有版本。inclusiveEnd 为 true 时使用 [start, end],否则 [start, end) */
function collectRange(
start: string,
end: string,
allAvailable: string[],
inclusiveEnd?: boolean,
): string[] {
const op = inclusiveEnd
? (v: string) => SemVer.compare(v, end) <= 0
: (v: string) => SemVer.compare(v, end) < 0;
const result = allAvailable.filter(
(v) =>
SemVer.compare(v, start) >= 0 &&
SemVer.compare(v, end) < 0,
op(v),
);
if (!result.some((v) => SemVer.compare(v, start) === 0)) {
result.unshift(start);
@ -67,7 +73,7 @@ function collectRange(
}
/** 自动匹配:收集与 start 同系列的所有版本 */
function collectAutoRange(
export function collectAutoRange(
start: string,
allAvailable: string[],
): string[] {

View File

@ -196,3 +196,18 @@ export async function getCurseForgeMcVersions(): Promise<string[]> {
})
.map((v) => v.versionString);
}
/**
* Modrinth CurseForge Minecraft
* VersionTable
*/
export async function getMcVersionUnion(): Promise<string[]> {
const [modrinthVersions, curseforgeVersions] = await Promise.all([
getModrinthMcVersions(),
getCurseForgeMcVersions(),
]);
const union = new Set([...modrinthVersions, ...curseforgeVersions]);
return [...union].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
);
}