Add interactive version range display in VersionTable
This commit is contained in:
parent
e45db0ffc0
commit
5f6cab6529
@ -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}
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user