Add interactive version range display in VersionTable
This commit is contained in:
parent
e45db0ffc0
commit
5f6cab6529
@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Table, Empty, Tag, Typography } from "antd";
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import { useMemo } from "react";
|
import { Table, Empty, Tag, Typography, Select, Flex } from "antd";
|
||||||
import type { ParsedProject } from "@/services/project";
|
import type { ParsedProject } from "@/services/project";
|
||||||
|
import { computeVersionRanges, collectAutoRange, type McVersionEntry } from "@/lib/utils/mcVersion";
|
||||||
|
import { getMcVersionUnion } from "@/services/meta";
|
||||||
|
|
||||||
interface ArtifactRow {
|
interface ArtifactRow {
|
||||||
key: string;
|
key: string;
|
||||||
@ -22,67 +24,192 @@ export function VersionTable({
|
|||||||
loading,
|
loading,
|
||||||
onLoadingChange,
|
onLoadingChange,
|
||||||
}: VersionTableProps) {
|
}: VersionTableProps) {
|
||||||
const dataSource: ArtifactRow[] = useMemo(() => {
|
// ── MC version union from both platforms ──
|
||||||
if (!project) return [];
|
const [allMcVersions, setAllMcVersions] = useState<string[]>([]);
|
||||||
return project.artifacts.map((a) => ({
|
|
||||||
key: a.mc_version,
|
|
||||||
mc_version: a.mc_version,
|
|
||||||
artifact: a.artifact,
|
|
||||||
sources: a.sources,
|
|
||||||
}));
|
|
||||||
}, [project]);
|
|
||||||
|
|
||||||
const columns = [
|
// Last version end selection (the editable dropdown value)
|
||||||
{
|
const [lastVersionEnd, setLastVersionEnd] = useState<string | null>(null);
|
||||||
title: "MC 版本",
|
|
||||||
dataIndex: "mc_version",
|
// Fetch MC version union when project changes
|
||||||
key: "mc_version",
|
useEffect(() => {
|
||||||
width: 120,
|
if (!project || project.artifacts.length === 0) {
|
||||||
render: (val: string) => <Tag>{val}</Tag>,
|
setAllMcVersions([]);
|
||||||
},
|
return;
|
||||||
{
|
}
|
||||||
title: "兼容范围",
|
|
||||||
dataIndex: "mc_version",
|
let cancelled = false;
|
||||||
key: "range",
|
onLoadingChange(true);
|
||||||
width: 200,
|
|
||||||
render: (_: string, __: ArtifactRow, index: number) => {
|
getMcVersionUnion()
|
||||||
const isLast =
|
.then((versions) => {
|
||||||
project && index === project.artifacts.length - 1;
|
if (cancelled) return;
|
||||||
// 兼容范围在发布前由 PublishModal 加载元数据后完整计算
|
setAllMcVersions(versions);
|
||||||
// 此处仅作占位显示
|
// Default last version end to the latest available version
|
||||||
return isLast ? (
|
setLastVersionEnd(versions[versions.length - 1] ?? null);
|
||||||
<Tag color="blue">加载元数据后计算</Tag>
|
})
|
||||||
) : (
|
.finally(() => {
|
||||||
<Typography.Text type="secondary">—</Typography.Text>
|
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: "兼容范围",
|
||||||
title: "构建产物",
|
key: "range",
|
||||||
dataIndex: "artifact",
|
width: 280,
|
||||||
key: "artifact",
|
render: (
|
||||||
ellipsis: true,
|
_: unknown,
|
||||||
render: (val: string | null) =>
|
record: ArtifactRow & { rangeEntry?: McVersionEntry },
|
||||||
val ? (
|
index: number,
|
||||||
<Typography.Text>{val}</Typography.Text>
|
) => {
|
||||||
) : (
|
const entry = record.rangeEntry;
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
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) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<Empty
|
<Empty
|
||||||
@ -93,7 +220,7 @@ export function VersionTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table<ArtifactRow>
|
<Table<ArtifactRow & { rangeEntry?: McVersionEntry }>
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@ -88,10 +88,10 @@ describe("computeVersionRanges", () => {
|
|||||||
const result = computeVersionRanges(
|
const result = computeVersionRanges(
|
||||||
["1.20", "1.20.3", "1.21"],
|
["1.20", "1.20.3", "1.21"],
|
||||||
ALL_AVAILABLE,
|
ALL_AVAILABLE,
|
||||||
"1.21.3",
|
"1.21.2",
|
||||||
);
|
);
|
||||||
const last = result[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.range).toEqual(["1.21", "1.21.1", "1.21.2"]);
|
||||||
expect(last.wildcard).toBe("1.21-1.21.2");
|
expect(last.wildcard).toBe("1.21-1.21.2");
|
||||||
expect(last.explicit).toBe("1.21-1.21.2");
|
expect(last.explicit).toBe("1.21-1.21.2");
|
||||||
@ -114,9 +114,9 @@ describe("computeVersionRanges", () => {
|
|||||||
const result = computeVersionRanges(
|
const result = computeVersionRanges(
|
||||||
["26.1"],
|
["26.1"],
|
||||||
ALL_AVAILABLE,
|
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({
|
expect(result[0]).toMatchObject({
|
||||||
version: "26.1",
|
version: "26.1",
|
||||||
range: ["26.1", "26.2", "26.3"],
|
range: ["26.1", "26.2", "26.3"],
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export interface McVersionEntry {
|
|||||||
*
|
*
|
||||||
* @param projectVersions 项目 MC 版本列表(已排序,从小到大)
|
* @param projectVersions 项目 MC 版本列表(已排序,从小到大)
|
||||||
* @param allAvailable 全平台可用 MC 版本(已排序,从小到大)
|
* @param allAvailable 全平台可用 MC 版本(已排序,从小到大)
|
||||||
* @param lastVersionEnd 最后一个版本的截止版本,不传则自动泛匹配
|
* @param lastVersionEnd 最后一个版本的截止版本(包含关系),不传则自动泛匹配
|
||||||
*/
|
*/
|
||||||
export function computeVersionRanges(
|
export function computeVersionRanges(
|
||||||
projectVersions: string[],
|
projectVersions: string[],
|
||||||
@ -27,7 +27,8 @@ export function computeVersionRanges(
|
|||||||
return projectVersions.map((version, i) => {
|
return projectVersions.map((version, i) => {
|
||||||
const isLast = i === projectVersions.length - 1;
|
const isLast = i === projectVersions.length - 1;
|
||||||
const end = isLast ? (lastVersionEnd ?? null) : projectVersions[i + 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,
|
start: string,
|
||||||
end: string | null,
|
end: string | null,
|
||||||
allAvailable: string[],
|
allAvailable: string[],
|
||||||
|
inclusiveEnd?: boolean,
|
||||||
): McVersionEntry {
|
): McVersionEntry {
|
||||||
const range = end !== null
|
const range = end !== null
|
||||||
? collectRange(start, end, allAvailable)
|
? collectRange(start, end, allAvailable, inclusiveEnd)
|
||||||
: collectAutoRange(start, allAvailable);
|
: collectAutoRange(start, allAvailable);
|
||||||
|
|
||||||
const lastInRange = range[range.length - 1] ?? start;
|
const lastInRange = range[range.length - 1] ?? start;
|
||||||
@ -49,16 +51,20 @@ function computeEntry(
|
|||||||
return { version: start, range, wildcard, explicit };
|
return { version: start, range, wildcard, explicit };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 收集 [start, end) 区间内的所有版本 */
|
/** 收集区间内的所有版本。inclusiveEnd 为 true 时使用 [start, end],否则 [start, end) */
|
||||||
function collectRange(
|
function collectRange(
|
||||||
start: string,
|
start: string,
|
||||||
end: string,
|
end: string,
|
||||||
allAvailable: string[],
|
allAvailable: string[],
|
||||||
|
inclusiveEnd?: boolean,
|
||||||
): string[] {
|
): string[] {
|
||||||
|
const op = inclusiveEnd
|
||||||
|
? (v: string) => SemVer.compare(v, end) <= 0
|
||||||
|
: (v: string) => SemVer.compare(v, end) < 0;
|
||||||
const result = allAvailable.filter(
|
const result = allAvailable.filter(
|
||||||
(v) =>
|
(v) =>
|
||||||
SemVer.compare(v, start) >= 0 &&
|
SemVer.compare(v, start) >= 0 &&
|
||||||
SemVer.compare(v, end) < 0,
|
op(v),
|
||||||
);
|
);
|
||||||
if (!result.some((v) => SemVer.compare(v, start) === 0)) {
|
if (!result.some((v) => SemVer.compare(v, start) === 0)) {
|
||||||
result.unshift(start);
|
result.unshift(start);
|
||||||
@ -67,7 +73,7 @@ function collectRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 自动匹配:收集与 start 同系列的所有版本 */
|
/** 自动匹配:收集与 start 同系列的所有版本 */
|
||||||
function collectAutoRange(
|
export function collectAutoRange(
|
||||||
start: string,
|
start: string,
|
||||||
allAvailable: string[],
|
allAvailable: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|||||||
@ -196,3 +196,18 @@ export async function getCurseForgeMcVersions(): Promise<string[]> {
|
|||||||
})
|
})
|
||||||
.map((v) => v.versionString);
|
.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