Compare commits

...

10 Commits

Author SHA1 Message Date
CPTProgrammer
90b1ceddb6
Fix Minecraft version resolution to CurseForge publisher 2026-07-19 10:36:42 +08:00
CPTProgrammer
7c10e4b168
Replace canSelect state with derived dataReady check 2026-07-19 10:20:46 +08:00
CPTProgrammer
6b32cafeeb
Fix wrong selection in PublishModal 2026-07-18 22:50:10 +08:00
CPTProgrammer
bf716627a1
Fix Minecraft version resolution to CurseForge publisher 2026-07-18 22:20:50 +08:00
CPTProgrammer
d88b1e0485
Fix version range calculation by passing full project version list 2026-07-18 22:17:54 +08:00
CPTProgrammer
b16c9546b8
Change Curseforge relation projectID type from string to number 2026-07-18 22:09:50 +08:00
CPTProgrammer
54af762365
Remove pending- prefix from publish table row keys 2026-07-18 21:16:32 +08:00
CPTProgrammer
120e701443
Add green state colors to new version rows 2026-07-18 15:14:44 +08:00
CPTProgrammer
778ac54bfa
Add Modrinth edit version API support 2026-07-18 12:33:11 +08:00
CPTProgrammer
619abfb0c5
Show existing platform versions in publish modal 2026-07-18 10:04:16 +08:00
22 changed files with 744 additions and 42 deletions

View File

@ -97,7 +97,7 @@ Content-Type: multipart/form-data
"relations": {
"projects": [{
"slug": "mantle", // 关联项目的 slug
"projectID": "74924", // 可选,用于精确匹配项目
"projectID": 74924, // 可选,用于精确匹配项目
"type": "requiredDependency" // 关联类型,可选值见下方
}]
}
@ -152,7 +152,7 @@ Content-Type: multipart/form-data
"relations": {
"projects": [{
"slug": "mantle",
"projectID": "74924",
"projectID": 74924,
"type": "requiredDependency"
}]
}

View File

@ -72,7 +72,7 @@
- 任一请求失败则取消后续所有任务
- 点击确认后显示两个进度条(仅显示有发布任务的平台):
- `平台名: 已发布数 / 总发布数`
- 全部完成后或失败后,弹出结果 Modal 显示最终状态
- 全部完成后或失败后,在第 3 步进度页内嵌显示最终状态Alert
### ConfigModal

View File

@ -92,6 +92,7 @@ src/types/
| 文件 | 说明 |
|------|------|
| `upload.md` | 新建版本 API`POST /version` |
| `edit.md` | 修改版本 API`PATCH /version/{id}` |
| `get.md` | 获取版本信息 API列出项目版本、获取单个版本等 |
| `get-meta.md` | 元数据查询 API加载器列表、游戏版本列表等 |

181
docs/modrinth/edit.md Normal file
View File

@ -0,0 +1,181 @@
# Modrinth 修改版本 API 使用文档
本文档描述如何通过 Modrinth API 修改一个**已有版本**的元数据,例如更新兼容的 Minecraft 版本范围(`game_versions`、changelog、加载器列表等无需重新上传文件。
---
## 基本信息
| 项目 | 内容 |
|------|------|
| **端点** | `PATCH /version/{id}` |
| **生产环境** | `https://api.modrinth.com/v2/version/{id}` |
| **测试环境** | `https://staging-api.modrinth.com/v2/version/{id}` |
| **Content-Type** | `application/json` |
| **认证** | 必需 — Personal Access Token (PAT) |
| **所需 Scope** | `VERSION_WRITE` |
| **成功响应码** | `204 No Content`(无响应体) |
> 创建版本用 `POST /version`(见 [upload.md](./upload.md));修改版本不需要重新上传文件,只要传 JSON 即可。
---
## 路径参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `id` | 版本 ID8 位 base62 字符串) | `IIJJKKLL` |
> 版本 ID 可通过 `GET /project/{id|slug}/version` 列表获取(见 [get.md](./get.md))。
---
## 请求体EditableVersion
请求体为 JSON**所有字段均为可选,只需传入要修改的字段**,未传入的字段保持不变。
### 继承自 BaseVersion 的字段
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `name` | `string` | 版本名称 | `"Version 1.0.0"` |
| `version_number` | `string` | 版本号 | `"1.0.0"` |
| `changelog` | `string \| null` | 更新日志Markdown/纯文本) | `"修复了若干 Bug"` |
| `dependencies` | `object[]` | 依赖列表(整体替换),结构同创建版本 | 见 [upload.md](./upload.md#依赖结构) |
| `game_versions` | `string[]` | **支持的 Minecraft 版本列表(整体替换)** | `["1.21", "1.21.1"]` |
| `version_type` | `string` | 发布渠道:`release`、`beta`、`alpha` | `"release"` |
| `loaders` | `string[]` | 支持的加载器列表(整体替换) | `["fabric"]` |
| `featured` | `boolean` | 是否为精选版本 | `false` |
| `status` | `string` | 版本状态:`listed`、`archived`、`draft`、`unlisted`、`scheduled`、`unknown` | `"listed"` |
| `requested_status` | `string \| null` | 请求的状态(用于审核流程):`listed`、`archived`、`draft`、`unlisted` | `"listed"` |
> **注意:** `game_versions`、`loaders`、`dependencies` 都是**整体替换**语义,传入的值会完全覆盖原值,而不是增量添加。修改兼容范围时应先读取版本当前的 `game_versions`,在此基础上增删后再整体提交。
### EditableVersion 独有字段
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `primary_file` | `[string, string]` | 新的主文件,二元组 `[哈希算法, 哈希值]` | `["sha1", "aaaabbbb..."]` |
| `file_types` | `object[]` | 要修改文件类型的文件列表(见下方) | 见下方 |
#### `file_types` 数组项EditableFileType
通过文件哈希定位版本中的某个文件并修改其类型标记:
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `algorithm` | `string` | ✅ | 哈希算法(如 `sha1`、`sha512` | `"sha1"` |
| `hash` | `string` | ✅ | 要修改的文件的哈希值 | `"aaaabbbb..."` |
| `file_type` | `string \| null` | ✅ | 新的文件类型;`null` 表示清除类型标记。枚举值见 [upload.md](./upload.md) 的 `FileTypeEnum` 表 | `"sources-jar"` |
> 文件的 `sha1` / `sha512` 哈希可从版本详情的 `files[].hashes` 字段获取(见 [get.md](./get.md))。
---
## 请求示例
### 修改兼容的 MC 版本范围
```bash
curl -X PATCH "https://api.modrinth.com/v2/version/IIJJKKLL" \
-H "Authorization: YOUR_PAT_TOKEN" \
-H "User-Agent: your_username/your_project/1.0.0" \
-H "Content-Type: application/json" \
-d '{
"game_versions": ["1.21", "1.21.1", "1.21.4"]
}'
```
### Node.js (fetch) 示例
```js
const TOKEN = "YOUR_PAT_TOKEN";
const VERSION_ID = "IIJJKKLL";
const response = await fetch(
`https://api.modrinth.com/v2/version/${VERSION_ID}`,
{
method: "PATCH",
headers: {
Authorization: TOKEN,
"User-Agent": "your_username/your_project/1.0.0",
"Content-Type": "application/json",
},
body: JSON.stringify({
game_versions: ["1.21", "1.21.1", "1.21.4"],
}),
},
);
// 成功时 response.status === 204无响应体
console.log(response.status); // 204
```
### 同时修改 changelog 和发布渠道
```js
await fetch(`https://api.modrinth.com/v2/version/${VERSION_ID}`, {
method: "PATCH",
headers: {
Authorization: TOKEN,
"Content-Type": "application/json",
},
body: JSON.stringify({
changelog: "## 更新内容\n\n- 修复了某某 Bug",
version_type: "beta",
}),
});
```
---
## 响应
成功时返回 **`204 No Content`,无响应体**。修改后的完整版本信息可通过 `GET /version/{id}` 再次获取确认。
---
## 错误码
| 状态码 | 说明 |
|--------|------|
| `204` | 成功 |
| `401` | Token 无效、未提供 Token或 Token 不包含 `VERSION_WRITE` 权限范围 |
| `404` | 版本不存在,或无权访问该版本 |
---
## 注意事项
### 1. 增量修改语义
PATCH 只更新传入的字段,未传入的字段保持服务器上的原值不变。这与 `POST /version`(全量创建)不同。
### 2. 数组字段是整体替换
`game_versions`、`loaders`、`dependencies` 传入后会**完全覆盖**原值。若只想"添加一个 MC 版本",正确做法是:
1. `GET /version/{id}` 读取当前 `game_versions`
2. 在数组中追加新版本号;
3. PATCH 提交完整的新数组。
### 3. 不要传递未修改的 status
客户端序列化请求体时,未显式设置的字段不应出现在 JSON 中。尤其注意不要让 `status` 被默认值(如 `"listed"`)填充,否则会意外改变版本状态(例如把 `draft` 变成 `listed`)。
### 4. 与"追加文件"的区别
本接口只修改元数据,不能上传新文件。向已有版本追加文件应使用 `POST /version/{id}/file`(见 [upload.md](./upload.md) 注意事项第 8 条,当前项目尚未封装该接口)。
### 5. Rate Limit
与 Modrinth 其他接口相同:每 IP 每分钟最多 **300** 个请求。
---
## 参考
- [Modrinth API 文档](https://docs.modrinth.com)
- [OpenAPI 规范文件](openapi.yml)`modifyVersion` 操作 / `EditableVersion` schema
- [新建版本 API 文档](upload.md)
- [获取版本信息 API 文档](get.md)

View File

@ -137,14 +137,14 @@
- CurseForge构造 multipart/form-data 请求,含 `metadata` JSON + 文件
- 通过 tRPC subscription 推送进度更新
6. **任一请求失败则取消当前平台的后续所有任务**,不影响另一个平台
7. 全部完成或失败后,弹出结果 Modal 显示最终状态(成功数 / 失败数 + 各平台失败原因列表)
7. 全部完成或失败后,在第 3 步进度页内嵌显示最终状态(成功数 / 失败数 + 各平台失败原因列表)
### 错误处理与可见性
- 单个版本上传失败:记入该平台 `errors`,发出 `failed` 进度事件(进度条变红),该平台后续版本取消;
- 平台准备阶段失败(元数据拉取、版本范围计算):记为该平台**全部失败**,同样发出 `failed` 事件,并在服务端终端输出 `console.error`
- mutation 层兜底:任何未被捕获的平台异常记入 `errors` 并输出服务端日志;
- 前端结果 Modal 按平台分行列出所有失败原因。
- 前端进度页的结果区按平台分行列出所有失败原因。
## Dry-run模拟发布

View File

@ -24,3 +24,43 @@ body {
color: #1f1f1f; /* Ant Design on-surface / colorText */
background-color: #f5f5f5; /* Ant Design bg-layout */
}
/* PublishModal 第一页新增版本行配色
变量由组件注入--row-new-bg / --row-new-text /
--row-new-text-active 直接取 antd theme token
--row-new-bg-hover / --row-new-bg-active /
--row-new-bg-active-hover green-1 green-2 之间的
color-mix 半步过渡色25% / 50% / 75% */
/* 基础:浅绿底 + 深绿字 */
.publish-version-tables .ant-table-tbody > tr.row-new > td {
background: var(--row-new-bg);
color: var(--row-new-text);
}
/* 悬停:比背景深一点的绿 */
.publish-version-tables .ant-table-tbody > tr.row-new:hover > td {
background: var(--row-new-bg-hover);
}
/* 勾选背景再深半度文字最深绿覆盖默认蓝色选中态
注意antd 的选中样式作用在 td.ant-table-cell而非 td 本身
hover 不是 :hover 伪类而是 JS 注入的 .ant-table-cell-row-hover class
两条规则都必须针对它们书写才能生效 */
.publish-version-tables .ant-table-tbody > tr.row-new.ant-table-row-selected
> td.ant-table-cell {
background: var(--row-new-bg-active);
color: var(--row-new-text-active);
}
.publish-version-tables .ant-table-tbody > tr.row-new
> td.ant-table-cell-row-hover {
background: var(--row-new-bg-hover);
}
/* 选中 + 悬停:背景再深半度 */
.publish-version-tables .ant-table-tbody > tr.row-new.ant-table-row-selected
> td.ant-table-cell-row-hover {
background: var(--row-new-bg-active-hover);
color: var(--row-new-text-active);
}

View File

@ -293,9 +293,9 @@ const LIST_DEFS: ListValidationDef<Config>[] = [
target: "projectID",
deps: ["projectID"],
async validate(getVal, row, setVal) {
const id = getVal("projectID") as string | undefined;
if (!id) return null;
const r = await lookupCurseforgeProject(Number(id));
const id = getVal("projectID") as number | undefined;
if (id == null) return null;
const r = await lookupCurseforgeProject(id);
if (r.ok) {
setVal("slug", r.slug);
return { status: "success", help: `项目:${r.name}` };
@ -967,7 +967,6 @@ function ConfigModalForm({
name={[name, "projectID"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
normalize={(v) => v != null ? String(v) : undefined}
>
<InputNumber
placeholder="ID"
@ -1007,7 +1006,7 @@ function ConfigModalForm({
<Button
type="dashed"
onClick={() =>
add({ projectID: "", slug: "", type: "requiredDependency" })
add({ slug: "", type: "requiredDependency" })
}
icon={<PlusOutlined />}
block

View File

@ -22,14 +22,18 @@ import {
import { ReloadOutlined } from "@ant-design/icons";
import { useMutation } from "@tanstack/react-query";
import { useSubscription } from "@trpc/tanstack-react-query";
import type { BaseVersion, Config, UploadReleaseType } from "@/types";
import type { BaseVersion, Config, CurseForgeFile, UploadReleaseType, Version } from "@/types";
import type { ParsedProject } from "@/services/project";
import { platforms } from "@/services/publish.schemas";
import type { PlatformResult, ProgressEvent } from "@/services/publish.schemas";
import { Template } from "@/lib/utils/template";
import type { McVersionEntry } from "@/lib/utils/mcVersion";
import { computeVersionRanges } from "@/lib/utils/mcVersion";
import { SemVer } from "@/lib/utils/templates/semver";
import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta";
import { compareVersions } from "@/services/versions";
import { errorMessage } from "@/lib/utils/error";
import { compareParsed, parseExisting } from "@/lib/utils/existingVersion";
import { trpc } from "@/lib/trpc";
import { ChangelogEditor } from "./ChangelogEditor";
@ -52,6 +56,26 @@ interface PublishVersion {
existsCurseforge: boolean;
}
/** Modrinth 表格行待发布kind="pending"+ 平台已有版本kind="existing" */
interface ModrinthTableRow {
key: string;
kind: "pending" | "existing";
pending: PublishVersion | null;
modrinthVersion: string;
mrRange: string;
modrinthVersionName: string;
}
/** CurseForge 表格行 */
interface CurseforgeTableRow {
key: string;
kind: "pending" | "existing";
pending: PublishVersion | null;
mc_version: string;
cfRange: string;
artifact: string | null;
}
interface PublishModalProps {
open: boolean;
/** 配置文件名configs/ 下的 .json 文件名) */
@ -138,25 +162,48 @@ export function PublishModal({
const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]);
const [existingModrinth, setExistingModrinth] = useState<string[]>([]);
const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]);
// 两平台全量已有版本(用于第一页下方灰色行展示)
const [modrinthExistingVersions, setModrinthExistingVersions] = useState<
Version[]
>([]);
const [curseforgeExistingFiles, setCurseforgeExistingFiles] = useState<
CurseForgeFile[]
>([]);
const loadExisting = useCallback(async () => {
setLoadingExisting(true);
try {
const [mrVersions, cfVersions] = await Promise.all([
const mcVersions = project.artifacts.map((a) => a.mc_version);
// 元数据列表和已有版本比对同属第一页数据,任一失败都不允许进入下一步,
// 否则无法区分"未发布"和"没查到",有重复发布的风险
const [mrVersions, cfVersions, comparison] = await Promise.all([
getModrinthMcVersions(),
getCurseForgeMcVersions(),
compareVersions(config, project.version, mcVersions),
]);
setModrinthMcVersions(mrVersions);
setCurseforgeMcVersions(cfVersions);
// TODO: Fetch existing versions from both platforms via tRPC/Server Action
setExistingModrinth(
mcVersions.filter((v) => comparison.modrinth[v]?.exists),
);
setExistingCurseforge(
mcVersions.filter((v) => comparison.curseforge[v]?.exists),
);
setModrinthExistingVersions(comparison.modrinthVersions);
setCurseforgeExistingFiles(comparison.curseforgeFiles);
} catch (e) {
message.error(
`加载已有版本失败,请检查网络后点击“刷新已有版本”重试:${errorMessage(e)}`,
);
// 清空比对数据,已有版本状态未知时禁用"下一步"
setExistingModrinth([]);
setExistingCurseforge([]);
} catch (e) {
console.error(e);
setModrinthExistingVersions([]);
setCurseforgeExistingFiles([]);
} finally {
setLoadingExisting(false);
}
}, []);
}, [config, project, message]);
// Auto-load MC versions when modal opens
useEffect(() => {
@ -215,6 +262,117 @@ export function PublishModal({
[project, versionTmpl, versionNameTmpl, mrExplicitMap, cfExplicitMap, existingModrinth, existingCurseforge],
);
// ── 第一页表格数据源:待发布行(上)+ 平台已有版本行(下,灰色禁用)──
// 与待发布版本匹配的已有对象不再重复出现在已有版本区
const matchedMrNames = useMemo(
() =>
new Set(
publishVersions
.filter((v) => v.existsModrinth)
.map((v) => v.modrinthVersion),
),
[publishVersions],
);
const modrinthRows: ModrinthTableRow[] = useMemo(() => {
// 行 key 发布时会作为 mc_version 直接传入后端 API严禁加任何前缀
// 此处曾加 "pending-" 前缀,导致后端准备阶段报 Invalid SemVer: "pending-1.19"。
// 教训:所有要传入 API 的 key 都不能加前缀(行类型区分请用 kind 字段)。
const pending: ModrinthTableRow[] = publishVersions.map((v) => ({
key: v.key,
kind: "pending",
pending: v,
modrinthVersion: v.modrinthVersion,
mrRange: v.mrRange,
modrinthVersionName: v.modrinthVersionName,
}));
const existingTmpl = new Template(config.modrinth.version);
const existing = modrinthExistingVersions
.filter((v) => !matchedMrNames.has(v.version_number))
.map((v) => ({
row: {
key: `existing-mr-${v.version_number}`,
kind: "existing" as const,
pending: null,
modrinthVersion: v.version_number,
mrRange: "",
modrinthVersionName: v.name,
},
parsed: parseExisting(existingTmpl, v.version_number),
}))
.sort((a, b) => compareParsed(a.parsed, b.parsed))
.map((e) => e.row);
return [...pending, ...existing];
}, [publishVersions, modrinthExistingVersions, matchedMrNames, config.modrinth.version]);
const matchedCfFileNames = useMemo(
() =>
new Set(
publishVersions
.filter((v) => v.existsCurseforge && v.artifact)
.map((v) => v.artifact as string),
),
[publishVersions],
);
const curseforgeRows: CurseforgeTableRow[] = useMemo(() => {
// 行 key 发布时会作为 mc_version 直接传入后端 API严禁加任何前缀
// 此处曾加 "pending-" 前缀,导致后端准备阶段报 Invalid SemVer: "pending-1.19"。
// 教训:所有要传入 API 的 key 都不能加前缀(行类型区分请用 kind 字段)。
const pending: CurseforgeTableRow[] = publishVersions.map((v) => ({
key: v.key,
kind: "pending",
pending: v,
mc_version: v.mc_version,
cfRange: v.cfRange,
artifact: v.artifact,
}));
const filenameTmpl = new Template(config.filename_format);
const existing = curseforgeExistingFiles
.filter((f) => !matchedCfFileNames.has(f.fileName))
.map((f) => {
const parsed = parseExisting(filenameTmpl, f.fileName);
return {
row: {
key: `existing-cf-${f.fileName}`,
kind: "existing" as const,
pending: null,
mc_version: parsed.mc_version ?? "—",
cfRange: "",
artifact: f.displayName || f.fileName,
},
parsed,
};
})
.sort((a, b) => compareParsed(a.parsed, b.parsed))
.map((e) => e.row);
return [...pending, ...existing];
}, [publishVersions, curseforgeExistingFiles, matchedCfFileNames, config.filename_format]);
/** 新增版本行 className已有版本行用默认样式 */
const rowClassName = useCallback(
(kind: "pending" | "existing", exists: boolean) =>
kind === "pending" && !exists ? "row-new" : "",
[],
);
// 注入 antd 绿色系 token 作为 CSS 变量(供 globals.css 的 .row-new 规则使用)。
// 三个过渡色用 color-mix 在 green-1 / green-2 之间混出半步色阶:
// hover = 25%,选中 = 50%,选中+hover = 75%
const newRowCssVars = {
"--row-new-bg": token.colorSuccessBg,
"--row-new-text": token.colorSuccessText,
"--row-new-bg-hover": `color-mix(in srgb, ${token.colorSuccessBg} 75%, ${token.colorSuccessBgHover} 25%)`,
"--row-new-bg-active": `color-mix(in srgb, ${token.colorSuccessBg} 50%, ${token.colorSuccessBgHover} 50%)`,
"--row-new-bg-active-hover": `color-mix(in srgb, ${token.colorSuccessBg} 25%, ${token.colorSuccessBgHover} 75%)`,
"--row-new-text-active": token.colorSuccessTextActive,
} as React.CSSProperties;
// Auto-select all non-existing versions when data first loads
const initRef = useRef(false);
useEffect(() => {
@ -222,8 +380,12 @@ export function PublishModal({
initRef.current = false;
}
}, [open]);
const dataReady =
!loadingExisting &&
modrinthMcVersions.length > 0 &&
curseforgeMcVersions.length > 0;
useEffect(() => {
if (!loadingExisting && publishVersions.length > 0 && !initRef.current) {
if (dataReady && publishVersions.length > 0 && !initRef.current) {
initRef.current = true;
setSelectedModrinth(
publishVersions.filter((v) => !v.existsModrinth).map((v) => v.key),
@ -231,11 +393,21 @@ export function PublishModal({
setSelectedCurseforge(
publishVersions.filter((v) => !v.existsCurseforge).map((v) => v.key),
);
console.log(selectedModrinth, selectedCurseforge);
}
}, [loadingExisting, publishVersions]);
}, [dataReady, publishVersions]);
// --------------- Steps handling ---------------
// 进入第二页的前置条件:
// 1. 数据加载完成且成功(元数据为空说明加载失败或未加载,已有版本状态未知);
// 2. 至少勾选一个待发布版本。
const canProceed =
!loadingExisting &&
modrinthMcVersions.length > 0 &&
curseforgeMcVersions.length > 0 &&
(selectedModrinth.length > 0 || selectedCurseforge.length > 0);
const handleNext = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, 2));
}, []);
@ -300,6 +472,7 @@ export function PublishModal({
modrinth: selectedModrinth.map((mc_version) => ({ mc_version })),
curseforge: selectedCurseforge.map((mc_version) => ({ mc_version })),
},
allMcVersions: projectVersions,
cutoffMcVersion: lastVersionEnd ?? "",
changelog,
versionType,
@ -341,6 +514,7 @@ export function PublishModal({
title: "版本号",
dataIndex: "modrinthVersion",
key: "modrinthVersion",
width: 150,
},
{
title: "兼容范围",
@ -361,7 +535,7 @@ export function PublishModal({
title: "MC 版本",
dataIndex: "mc_version",
key: "mc_version",
width: 100,
width: 80,
},
{
title: "兼容范围",
@ -402,23 +576,35 @@ export function PublishModal({
{loadingExisting ? (
<div style={{ textAlign: "center", padding: 40 }}></div>
) : (
<Row gutter={token.margin}>
<Row gutter={token.margin} className="publish-version-tables" style={newRowCssVars}>
<Col span={14}>
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
Modrinth
</Text>
{publishVersions.length > 0 ? (
{modrinthRows.length > 0 ? (
<Table
dataSource={publishVersions}
dataSource={modrinthRows}
columns={modrinthColumns}
rowKey="key"
rowClassName={(r) =>
rowClassName(
r.kind,
r.kind === "pending"
? (r.pending?.existsModrinth ?? false)
: true,
)
}
rowSelection={{
selectedRowKeys: selectedModrinth,
onChange: (keys) => setSelectedModrinth(keys as string[]),
getCheckboxProps: (r: PublishVersion) => ({
disabled: r.existsModrinth,
onChange: (keys) => {setSelectedModrinth(keys as string[]); console.log(selectedModrinth);},
getCheckboxProps: (r: ModrinthTableRow) => ({
disabled:
r.kind === "existing" ||
(r.pending?.existsModrinth ?? false),
}),
}}
pagination={false}
scroll={{ y: 480 }}
size="small"
bordered
/>
@ -430,18 +616,30 @@ export function PublishModal({
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
CurseForge
</Text>
{publishVersions.length > 0 ? (
{curseforgeRows.length > 0 ? (
<Table
dataSource={publishVersions}
dataSource={curseforgeRows}
columns={curseforgeColumns}
rowKey="key"
rowClassName={(r) =>
rowClassName(
r.kind,
r.kind === "pending"
? (r.pending?.existsCurseforge ?? false)
: true,
)
}
rowSelection={{
selectedRowKeys: selectedCurseforge,
onChange: (keys) => setSelectedCurseforge(keys as string[]),
getCheckboxProps: (r: PublishVersion) => ({
disabled: r.existsCurseforge,
onChange: (keys) => {setSelectedCurseforge(keys as string[]); console.log(selectedCurseforge);},
getCheckboxProps: (r: CurseforgeTableRow) => ({
disabled:
r.kind === "existing" ||
(r.pending?.existsCurseforge ?? false),
}),
}}
pagination={false}
scroll={{ y: 480 }}
size="small"
bordered
/>
@ -604,7 +802,11 @@ export function PublishModal({
<Button onClick={handlePrev}></Button>
)}
{currentStep === 0 ? (
<Button type="primary" onClick={handleNext}>
<Button
type="primary"
onClick={handleNext}
disabled={!canProceed}
>
</Button>
) : currentStep === 1 ? (

View File

@ -60,4 +60,26 @@ export class ModrinthClient {
}
return res.json() as Promise<T>;
}
/**
* PATCH body JSON
* 204 No Content void
*/
async patch(path: string, body: unknown): Promise<void> {
const res = await fetch(`${this.base}${path}`, {
method: "PATCH",
headers: {
...this.headers(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const reqBody = JSON.stringify(body);
const resBody = await readErrorBody(res);
throw new Error(
`Modrinth API PATCH ${path} failed: ${res.status} ${res.statusText}\n\n--Request--:\n${reqBody}\n\n--Response--:\n${resBody}`,
);
}
}
}

View File

@ -5,6 +5,7 @@ export {
getVersionByNumber,
getVersions,
createVersion,
updateVersion,
} from "./version";
export { getLoaders, getGameVersions } from "./tag";
export { getProject, getProjects } from "./project";

View File

@ -3,8 +3,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ModrinthClient } from "./client";
import { createVersion } from "./version";
import type { CreatableVersion } from "@/types";
import { createVersion, updateVersion } from "./version";
import {
EditableVersionSchema,
type CreatableVersion,
type EditableVersion,
} from "@/types";
describe("createVersion (dry-run)", () => {
const OLD_ENV = process.env;
@ -71,3 +75,59 @@ describe("createVersion (dry-run)", () => {
).rejects.toThrow('file field "sources" declared in file_parts');
});
});
describe("updateVersion", () => {
const client = new ModrinthClient("fake-token", "test-agent");
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("发送 PATCH 请求到 /v2/version/{id}body 为 JSON", async () => {
const fields: EditableVersion = {
game_versions: ["1.21", "1.21.1"],
changelog: "updated changelog",
};
await updateVersion(client, "IIJJKKLL", fields);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://api.modrinth.com/v2/version/IIJJKKLL");
expect(init.method).toBe("PATCH");
expect(init.headers).toMatchObject({
Authorization: "fake-token",
"Content-Type": "application/json",
});
expect(JSON.parse(init.body as string)).toEqual(fields);
});
it("EditableVersionSchema 不注入 status 默认值", () => {
// Zod v4 中 .partial() 会触发 default此处验证 schema 已规避该问题
expect(EditableVersionSchema.parse({})).toEqual({});
expect(EditableVersionSchema.parse({ game_versions: ["1.21"] })).toEqual({
game_versions: ["1.21"],
});
});
it("204 响应无 body正常返回 void", async () => {
await expect(
updateVersion(client, "IIJJKKLL", { game_versions: ["1.21"] }),
).resolves.toBeUndefined();
});
it("非 2xx 响应抛出含状态码的错误", async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: "not_found" }), { status: 404 }),
);
await expect(
updateVersion(client, "IIJJKKLL", { game_versions: ["1.21"] }),
).rejects.toThrow("404");
});
});

View File

@ -3,6 +3,7 @@ import {
VersionSchema,
type Version,
type CreatableVersion,
type EditableVersion,
} from "@/types";
/**
@ -116,3 +117,23 @@ export async function createVersion(
const result = await client.post<unknown>("/version", form);
return VersionSchema.parse(result);
}
/**
* MC changelog
* PATCH /version/{id}
*
*
* 204 No Content
*
* @param client Modrinth
* @param versionId ID8 base62
* @param fields Zod EditableVersion
*/
export async function updateVersion(
client: ModrinthClient,
versionId: string,
fields: EditableVersion,
): Promise<void> {
const path = `/version/${encodeURIComponent(versionId)}`;
await client.patch(path, fields);
}

View File

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { Template } from "./template";
import { compareParsed, parseExisting } from "./existingVersion";
describe("parseExisting", () => {
it("从 version_number 模板反向解析 version 和 mc_version", () => {
const tmpl = new Template("${version}-mc${mc_version}");
expect(parseExisting(tmpl, "1.2.3-mc1.21")).toEqual({
version: "1.2.3",
mc_version: "1.21",
});
});
it("从 filename_format 模板反向解析(含字面量前缀)", () => {
const tmpl = new Template("modname-${version}-mc${mc_version}.jar");
expect(parseExisting(tmpl, "modname-0.9.0-mc1.20.1.jar")).toEqual({
version: "0.9.0",
mc_version: "1.20.1",
});
});
it("不匹配模板时返回 null", () => {
const tmpl = new Template("${version}-mc${mc_version}");
expect(parseExisting(tmpl, "random-string")).toEqual({
version: null,
mc_version: null,
});
});
it("捕获段不是合法 SemVer 时返回 null", () => {
const tmpl = new Template("${version}-mc${mc_version}");
expect(parseExisting(tmpl, "abc-mc1.21")).toEqual({
version: null,
mc_version: null,
});
});
});
describe("compareParsed", () => {
const p = (version: string | null, mc_version: string | null = null) => ({
version,
mc_version,
});
it("mod_version 降序优先", () => {
const list = [p("1.0.0"), p("1.2.0"), p("0.9.0")];
list.sort(compareParsed);
expect(list.map((x) => x.version)).toEqual(["1.2.0", "1.0.0", "0.9.0"]);
});
it("同 mod_version 时 mc_version 降序", () => {
const list = [
p("1.0.0", "1.20"),
p("1.0.0", "1.21"),
p("1.0.0", "1.19.4"),
];
list.sort(compareParsed);
expect(list.map((x) => x.mc_version)).toEqual(["1.21", "1.20", "1.19.4"]);
});
it("无法解析的排最后", () => {
const list = [p(null), p("1.0.0", "1.21"), p("0.9.0", "1.20")];
list.sort(compareParsed);
expect(list[0]!.version).toBe("1.0.0");
expect(list[1]!.version).toBe("0.9.0");
expect(list[2]!.version).toBeNull();
});
});

View File

@ -0,0 +1,42 @@
import { Template } from "./template";
import { SemVer } from "./templates/semver";
/** 用于已有版本排序的反向解析结果 */
export interface ParsedExisting {
version: string | null;
mc_version: string | null;
}
/** 反向解析已有版本字符串Modrinth version_number / CF fileName失败返回 null */
export function parseExisting(
tmpl: Template,
concrete: string,
): ParsedExisting {
try {
const parsed = tmpl.parse(concrete, {
version: SemVer.parse,
mc_version: SemVer.parse,
});
return {
version: parsed.version.format(),
mc_version: parsed.mc_version.format(),
};
} catch {
return { version: null, mc_version: null };
}
}
/** 已有版本排序mod_version 降序 → mc_version 降序;无法解析的排最后 */
export function compareParsed(a: ParsedExisting, b: ParsedExisting): number {
if (a.version === null && b.version === null) return 0;
if (a.version === null) return 1;
if (b.version === null) return -1;
const byVersion = SemVer.compare(b.version, a.version);
if (byVersion !== 0) return byVersion;
if (a.mc_version === null && b.mc_version === null) return 0;
if (a.mc_version === null) return 1;
if (b.mc_version === null) return -1;
return SemVer.compare(b.mc_version, a.mc_version);
}

View File

@ -1,5 +1,5 @@
import { curseforgeClient } from "@/services/clients";
import { uploadFile, getGameVersions } from "@/lib/curseforge";
import { uploadFile, getGameVersions, getMinecraftVersions } from "@/lib/curseforge";
import { resolveVersionName } from "@/lib/utils/format";
import type { UploadMetadata } from "@/types";
import { PlatformPublishFn } from "../publish.schemas";
@ -10,12 +10,18 @@ export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion
const displayName = resolveVersionName(config.curseforge.version_name, config, input.version, mcVersion);
const gameVersions = await getGameVersions(curseforgeClient);
const minecraftVersions = await getMinecraftVersions(curseforgeClient);
const resolveId = (name: string): number => {
const found = gameVersions.find((v) => v.name === name);
if (!found) throw new Error(`Game version "${name}" not found in CurseForge`);
return found.id;
};
const resolveMinecraftVersionId = (name: string): number => {
const found = minecraftVersions.find((v) => v.versionString === name);
if (!found) throw new Error(`Minecraft version "${name}" not found in CurseForge`);
return found.gameVersionId;
};
const metadata: UploadMetadata = {
changelog: input.changelog,
@ -25,7 +31,7 @@ export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion
gameVersions: [
...config.curseforge.loaders.map(resolveId),
...config.curseforge.environment.map(resolveId),
...mcVersion.range.map(resolveId),
...mcVersion.range.map(resolveMinecraftVersionId),
],
relations: config.curseforge.relations,
};

View File

@ -32,6 +32,9 @@ export const PublishInputSchema = z.object({
mcVersions: z.object(createRecord(
platforms, z.array(z.object({ mc_version: z.string() }))
)),
// 项目全部 MC 版本(完整列表)。范围计算必须基于它:只传勾选子集会让
// 子集末尾被误判为"最后一个版本",范围按 lastVersionEnd 一路放大到最新 MC 版本
allMcVersions: z.array(z.string()).min(1),
cutoffMcVersion: z.string(), // 最后一个版本的截止 MC 版本,两个平台共用
changelog: z.string(),
versionType: BaseVersionSchema.shape.version_type,

View File

@ -81,11 +81,16 @@ async function runPlatform(
// 否则前端进度条会停在 running 假象里,错误也被吞掉。
let mcVersionEntries: McVersionEntry[];
try {
// 范围必须基于完整项目版本列表计算,勾选只决定发布哪些条目
const selected = new Set(mcVersions.map((v) => v.mc_version));
mcVersionEntries = computeVersionRanges(
mcVersions.map(v => v.mc_version),
ctx.input.allMcVersions,
await getGameVersionsFn(),
ctx.input.cutoffMcVersion
);
).filter((e) => selected.has(e.version));
if (mcVersionEntries.length !== selected.size) {
throw new Error("勾选版本与项目版本列表不一致,请刷新后重试");
}
} catch (err) {
const msg = errorMessage(err);
console.error(`[publish] ${platform} 准备阶段失败:`, err);

View File

@ -60,6 +60,10 @@ export interface FileMatch {
export interface VersionComparison {
modrinth: Record<string, VersionMatch>;
curseforge: Record<string, FileMatch>;
/** Modrinth 全量已有版本(含未匹配项),用于第一页展示 */
modrinthVersions: Version[];
/** CurseForge 全量已有文件(含未匹配项),用于第一页展示 */
curseforgeFiles: CurseForgeFile[];
}
/**
@ -68,7 +72,7 @@ export interface VersionComparison {
* @param config
* @param version "1.0.0"
* @param mc_versions MC
* @returns MC
* @returns MC +
*/
export async function compareVersions(
config: Config,
@ -83,7 +87,9 @@ export async function compareVersions(
// ── Modrinth按 version 模板生成 version_number 后匹配 ──
const modrinth: Record<string, VersionMatch> = {};
const modrinthTmpl = new Template(config.modrinth.version);
const modrinthTmpl = new Template(config.modrinth.version, {
filename_format: config.filename_format,
});
for (const mc_version of mc_versions) {
const generatedVersion = modrinthTmpl.format({ version, mc_version });
@ -97,7 +103,9 @@ export async function compareVersions(
// ── CurseForge按 filename_format 模板生成文件名后匹配 ──
const curseforge: Record<string, FileMatch> = {};
const curseforgeTmpl = new Template(config.filename_format);
const curseforgeTmpl = new Template(config.curseforge.version_name, {
filename_format: config.filename_format,
});
for (const mc_version of mc_versions) {
const generatedFilename = curseforgeTmpl.format({ version, mc_version });
@ -109,5 +117,5 @@ export async function compareVersions(
: { exists: false };
}
return { modrinth, curseforge };
return { modrinth, curseforge, modrinthVersions, curseforgeFiles };
}

View File

@ -58,6 +58,7 @@ export const MinecraftGameVersionSchema = z.object({
/** 版本类型状态1=Normal 2=Deleted */
gameVersionTypeStatus: z.union([z.literal(1), z.literal(2)]),
}).pick({
gameVersionId: true,
versionString: true,
});
export type MinecraftGameVersion = z.infer<typeof MinecraftGameVersionSchema>;

View File

@ -34,7 +34,7 @@ export const RelationProjectSchema = z.object({
/** 关联项目的 slug */
slug: z.string(),
/** 关联项目的 ID精确匹配可选 */
projectID: z.string().optional(),
projectID: z.number().int().positive().optional(),
/** 关联类型 */
type: UploadRelationTypeSchema,
});

View File

@ -0,0 +1,35 @@
import { z } from "zod";
import { BaseVersionSchema, FileTypeEnumSchema } from "./version";
// region EditableFileType — PATCH 时修改单个文件的类型
// OpenAPI EditableFileType.required: algorithm, hash, file_type
export const EditableFileTypeSchema = z.object({
/** 哈希算法(如 sha1、sha512 */
algorithm: z.string(),
/** 要修改的文件的哈希值 */
hash: z.string(),
/** 新的文件类型null 表示清除类型标记 */
file_type: FileTypeEnumSchema.nullable(),
});
export type EditableFileType = z.infer<typeof EditableFileTypeSchema>;
// endregion
// region EditableVersion — PATCH /version/{id} 请求体
// OpenAPI EditableVersion = BaseVersion全字段可选+ primary_file / file_types
//
// 注意:不能直接 BaseVersionSchema.partial() —— Zod v4 中 .partial() 仍会
// 触发 status 字段的 .default("listed"),导致 PATCH 时误改版本状态。
// 因此显式将 status 覆盖为无 default 的可选字段。
export const EditableVersionSchema = BaseVersionSchema.partial()
.extend({
/** 版本状态;不传则不修改 */
status: z
.enum(["listed", "archived", "draft", "unlisted", "scheduled", "unknown"])
.optional(),
/** 新的主文件,格式为 [哈希算法, 哈希值],如 ["sha1", "aaaa..."] */
primary_file: z.tuple([z.string(), z.string()]).optional(),
/** 要修改文件类型的文件列表 */
file_types: z.array(EditableFileTypeSchema).optional(),
});
export type EditableVersion = z.infer<typeof EditableVersionSchema>;
// endregion

View File

@ -34,6 +34,13 @@ export {
type CreateVersionBody,
} from "./create-version";
export {
EditableFileTypeSchema,
EditableVersionSchema,
type EditableFileType,
type EditableVersion,
} from "./edit-version";
export {
ProjectLicenseSchema,
ProjectDonationURLSchema,