Compare commits
11
Commits
e5de2f5dbd
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3894c0c599 | ||
|
|
90b1ceddb6 | ||
|
|
7c10e4b168 | ||
|
|
6b32cafeeb | ||
|
|
bf716627a1 | ||
|
|
d88b1e0485 | ||
|
|
b16c9546b8 | ||
|
|
54af762365 | ||
|
|
120e701443 | ||
|
|
778ac54bfa | ||
|
|
619abfb0c5 |
No files matched your search
@@ -97,7 +97,7 @@ Content-Type: multipart/form-data
|
|||||||
"relations": {
|
"relations": {
|
||||||
"projects": [{
|
"projects": [{
|
||||||
"slug": "mantle", // 关联项目的 slug
|
"slug": "mantle", // 关联项目的 slug
|
||||||
"projectID": "74924", // 可选,用于精确匹配项目
|
"projectID": 74924, // 可选,用于精确匹配项目
|
||||||
"type": "requiredDependency" // 关联类型,可选值见下方
|
"type": "requiredDependency" // 关联类型,可选值见下方
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ Content-Type: multipart/form-data
|
|||||||
"relations": {
|
"relations": {
|
||||||
"projects": [{
|
"projects": [{
|
||||||
"slug": "mantle",
|
"slug": "mantle",
|
||||||
"projectID": "74924",
|
"projectID": 74924,
|
||||||
"type": "requiredDependency"
|
"type": "requiredDependency"
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@
|
|||||||
- 任一请求失败则取消后续所有任务
|
- 任一请求失败则取消后续所有任务
|
||||||
- 点击确认后显示两个进度条(仅显示有发布任务的平台):
|
- 点击确认后显示两个进度条(仅显示有发布任务的平台):
|
||||||
- `平台名: 已发布数 / 总发布数`
|
- `平台名: 已发布数 / 总发布数`
|
||||||
- 全部完成后或失败后,弹出结果 Modal 显示最终状态
|
- 全部完成后或失败后,在第 3 步进度页内嵌显示最终状态(Alert)
|
||||||
|
|
||||||
### ConfigModal
|
### ConfigModal
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ src/types/
|
|||||||
| 文件 | 说明 |
|
| 文件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `upload.md` | 新建版本 API(`POST /version`) |
|
| `upload.md` | 新建版本 API(`POST /version`) |
|
||||||
|
| `edit.md` | 修改版本 API(`PATCH /version/{id}`) |
|
||||||
| `get.md` | 获取版本信息 API(列出项目版本、获取单个版本等) |
|
| `get.md` | 获取版本信息 API(列出项目版本、获取单个版本等) |
|
||||||
| `get-meta.md` | 元数据查询 API(加载器列表、游戏版本列表等) |
|
| `get-meta.md` | 元数据查询 API(加载器列表、游戏版本列表等) |
|
||||||
|
|
||||||
|
|||||||
@@ -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` | 版本 ID(8 位 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)
|
||||||
+2
-2
@@ -137,14 +137,14 @@
|
|||||||
- CurseForge:构造 multipart/form-data 请求,含 `metadata` JSON + 文件
|
- CurseForge:构造 multipart/form-data 请求,含 `metadata` JSON + 文件
|
||||||
- 通过 tRPC subscription 推送进度更新
|
- 通过 tRPC subscription 推送进度更新
|
||||||
6. **任一请求失败则取消当前平台的后续所有任务**,不影响另一个平台
|
6. **任一请求失败则取消当前平台的后续所有任务**,不影响另一个平台
|
||||||
7. 全部完成或失败后,弹出结果 Modal 显示最终状态(成功数 / 失败数 + 各平台失败原因列表)
|
7. 全部完成或失败后,在第 3 步进度页内嵌显示最终状态(成功数 / 失败数 + 各平台失败原因列表)
|
||||||
|
|
||||||
### 错误处理与可见性
|
### 错误处理与可见性
|
||||||
|
|
||||||
- 单个版本上传失败:记入该平台 `errors`,发出 `failed` 进度事件(进度条变红),该平台后续版本取消;
|
- 单个版本上传失败:记入该平台 `errors`,发出 `failed` 进度事件(进度条变红),该平台后续版本取消;
|
||||||
- 平台准备阶段失败(元数据拉取、版本范围计算):记为该平台**全部失败**,同样发出 `failed` 事件,并在服务端终端输出 `console.error`;
|
- 平台准备阶段失败(元数据拉取、版本范围计算):记为该平台**全部失败**,同样发出 `failed` 事件,并在服务端终端输出 `console.error`;
|
||||||
- mutation 层兜底:任何未被捕获的平台异常记入 `errors` 并输出服务端日志;
|
- mutation 层兜底:任何未被捕获的平台异常记入 `errors` 并输出服务端日志;
|
||||||
- 前端结果 Modal 按平台分行列出所有失败原因。
|
- 前端进度页的结果区按平台分行列出所有失败原因。
|
||||||
|
|
||||||
## Dry-run(模拟发布)
|
## Dry-run(模拟发布)
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,43 @@ body {
|
|||||||
color: #1f1f1f; /* Ant Design on-surface / colorText */
|
color: #1f1f1f; /* Ant Design on-surface / colorText */
|
||||||
background-color: #f5f5f5; /* Ant Design bg-layout */
|
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);
|
||||||
|
}
|
||||||
@@ -293,9 +293,9 @@ const LIST_DEFS: ListValidationDef<Config>[] = [
|
|||||||
target: "projectID",
|
target: "projectID",
|
||||||
deps: ["projectID"],
|
deps: ["projectID"],
|
||||||
async validate(getVal, row, setVal) {
|
async validate(getVal, row, setVal) {
|
||||||
const id = getVal("projectID") as string | undefined;
|
const id = getVal("projectID") as number | undefined;
|
||||||
if (!id) return null;
|
if (id == null) return null;
|
||||||
const r = await lookupCurseforgeProject(Number(id));
|
const r = await lookupCurseforgeProject(id);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
setVal("slug", r.slug);
|
setVal("slug", r.slug);
|
||||||
return { status: "success", help: `项目:${r.name}` };
|
return { status: "success", help: `项目:${r.name}` };
|
||||||
@@ -967,7 +967,6 @@ function ConfigModalForm({
|
|||||||
name={[name, "projectID"]}
|
name={[name, "projectID"]}
|
||||||
rules={[{ required: true, message: "必填" }]}
|
rules={[{ required: true, message: "必填" }]}
|
||||||
style={{ marginBottom: 0 }}
|
style={{ marginBottom: 0 }}
|
||||||
normalize={(v) => v != null ? String(v) : undefined}
|
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
placeholder="ID"
|
placeholder="ID"
|
||||||
@@ -1007,7 +1006,7 @@ function ConfigModalForm({
|
|||||||
<Button
|
<Button
|
||||||
type="dashed"
|
type="dashed"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
add({ projectID: "", slug: "", type: "requiredDependency" })
|
add({ slug: "", type: "requiredDependency" })
|
||||||
}
|
}
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
block
|
block
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Typography, Space, Card, Button, theme } from "antd";
|
import { Typography, Space, Card, Button, theme } from "antd";
|
||||||
import { CloudUploadOutlined } from "@ant-design/icons";
|
import { CloudUploadOutlined, SyncOutlined } from "@ant-design/icons";
|
||||||
import { ConfigSelector } from "./ConfigSelector";
|
import { ConfigSelector } from "./ConfigSelector";
|
||||||
import { VersionTable } from "./VersionTable";
|
import { VersionTable } from "./VersionTable";
|
||||||
import { PublishModal } from "./PublishModal";
|
import { PublishModal } from "./PublishModal";
|
||||||
|
import { UpdateModal } from "./UpdateModal";
|
||||||
import type { Config } from "@/types";
|
import type { Config } from "@/types";
|
||||||
import type { ParsedProject } from "@/services/project";
|
import type { ParsedProject } from "@/services/project";
|
||||||
import type { McVersionEntry } from "@/lib/utils/mcVersion";
|
import type { McVersionEntry } from "@/lib/utils/mcVersion";
|
||||||
@@ -60,11 +61,16 @@ export function MainPage({ initialConfigs }: MainPageProps) {
|
|||||||
// ------- Publish -------
|
// ------- Publish -------
|
||||||
|
|
||||||
const [publishOpen, setPublishOpen] = useState(false);
|
const [publishOpen, setPublishOpen] = useState(false);
|
||||||
|
const [updateOpen, setUpdateOpen] = useState(false);
|
||||||
|
|
||||||
const handlePublishClose = useCallback(() => {
|
const handlePublishClose = useCallback(() => {
|
||||||
setPublishOpen(false);
|
setPublishOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateClose = useCallback(() => {
|
||||||
|
setUpdateOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRangesChange = useCallback(
|
const handleRangesChange = useCallback(
|
||||||
(_ranges: McVersionEntry[] | null, end: string | null) => {
|
(_ranges: McVersionEntry[] | null, end: string | null) => {
|
||||||
setLastVersionEnd(end);
|
setLastVersionEnd(end);
|
||||||
@@ -104,15 +110,23 @@ export function MainPage({ initialConfigs }: MainPageProps) {
|
|||||||
onRangesChange={handleRangesChange}
|
onRangesChange={handleRangesChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Publish button */}
|
{/* Publish & Update buttons */}
|
||||||
{parsedProject && parsedProject.artifacts.length > 0 && (
|
{parsedProject && parsedProject.artifacts.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
gap: token.marginSM,
|
||||||
marginTop: token.marginLG,
|
marginTop: token.marginLG,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Button
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
onClick={() => setUpdateOpen(true)}
|
||||||
|
disabled={!selectedConfig}
|
||||||
|
>
|
||||||
|
更新版本
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<CloudUploadOutlined />}
|
icon={<CloudUploadOutlined />}
|
||||||
@@ -137,6 +151,17 @@ export function MainPage({ initialConfigs }: MainPageProps) {
|
|||||||
onClose={handlePublishClose}
|
onClose={handlePublishClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 4. Update modal */}
|
||||||
|
{updateOpen && selectedConfig && parsedProject && selectedConfigFile && (
|
||||||
|
<UpdateModal
|
||||||
|
open={updateOpen}
|
||||||
|
configFile={selectedConfigFile}
|
||||||
|
config={selectedConfig}
|
||||||
|
project={parsedProject}
|
||||||
|
onClose={handleUpdateClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+223
-21
@@ -22,14 +22,18 @@ import {
|
|||||||
import { ReloadOutlined } from "@ant-design/icons";
|
import { ReloadOutlined } from "@ant-design/icons";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useSubscription } from "@trpc/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 type { ParsedProject } from "@/services/project";
|
||||||
import { platforms } from "@/services/publish.schemas";
|
import { platforms } from "@/services/publish.schemas";
|
||||||
import type { PlatformResult, ProgressEvent } from "@/services/publish.schemas";
|
import type { PlatformResult, ProgressEvent } from "@/services/publish.schemas";
|
||||||
import { Template } from "@/lib/utils/template";
|
import { Template } from "@/lib/utils/template";
|
||||||
import type { McVersionEntry } from "@/lib/utils/mcVersion";
|
import type { McVersionEntry } from "@/lib/utils/mcVersion";
|
||||||
import { computeVersionRanges } from "@/lib/utils/mcVersion";
|
import { computeVersionRanges } from "@/lib/utils/mcVersion";
|
||||||
|
import { SemVer } from "@/lib/utils/templates/semver";
|
||||||
import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta";
|
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 { trpc } from "@/lib/trpc";
|
||||||
import { ChangelogEditor } from "./ChangelogEditor";
|
import { ChangelogEditor } from "./ChangelogEditor";
|
||||||
|
|
||||||
@@ -52,6 +56,26 @@ interface PublishVersion {
|
|||||||
existsCurseforge: boolean;
|
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 {
|
interface PublishModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** 配置文件名(configs/ 下的 .json 文件名) */
|
/** 配置文件名(configs/ 下的 .json 文件名) */
|
||||||
@@ -138,25 +162,48 @@ export function PublishModal({
|
|||||||
const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]);
|
const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]);
|
||||||
const [existingModrinth, setExistingModrinth] = useState<string[]>([]);
|
const [existingModrinth, setExistingModrinth] = useState<string[]>([]);
|
||||||
const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]);
|
const [existingCurseforge, setExistingCurseforge] = useState<string[]>([]);
|
||||||
|
// 两平台全量已有版本(用于第一页下方灰色行展示)
|
||||||
|
const [modrinthExistingVersions, setModrinthExistingVersions] = useState<
|
||||||
|
Version[]
|
||||||
|
>([]);
|
||||||
|
const [curseforgeExistingFiles, setCurseforgeExistingFiles] = useState<
|
||||||
|
CurseForgeFile[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
const loadExisting = useCallback(async () => {
|
const loadExisting = useCallback(async () => {
|
||||||
setLoadingExisting(true);
|
setLoadingExisting(true);
|
||||||
try {
|
try {
|
||||||
const [mrVersions, cfVersions] = await Promise.all([
|
const mcVersions = project.artifacts.map((a) => a.mc_version);
|
||||||
|
// 元数据列表和已有版本比对同属第一页数据,任一失败都不允许进入下一步,
|
||||||
|
// 否则无法区分"未发布"和"没查到",有重复发布的风险
|
||||||
|
const [mrVersions, cfVersions, comparison] = await Promise.all([
|
||||||
getModrinthMcVersions(),
|
getModrinthMcVersions(),
|
||||||
getCurseForgeMcVersions(),
|
getCurseForgeMcVersions(),
|
||||||
|
compareVersions(config, project.version, mcVersions),
|
||||||
]);
|
]);
|
||||||
setModrinthMcVersions(mrVersions);
|
setModrinthMcVersions(mrVersions);
|
||||||
setCurseforgeMcVersions(cfVersions);
|
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([]);
|
setExistingModrinth([]);
|
||||||
setExistingCurseforge([]);
|
setExistingCurseforge([]);
|
||||||
} catch (e) {
|
setModrinthExistingVersions([]);
|
||||||
console.error(e);
|
setCurseforgeExistingFiles([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingExisting(false);
|
setLoadingExisting(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [config, project, message]);
|
||||||
|
|
||||||
// Auto-load MC versions when modal opens
|
// Auto-load MC versions when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -215,6 +262,117 @@ export function PublishModal({
|
|||||||
[project, versionTmpl, versionNameTmpl, mrExplicitMap, cfExplicitMap, existingModrinth, existingCurseforge],
|
[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
|
// Auto-select all non-existing versions when data first loads
|
||||||
const initRef = useRef(false);
|
const initRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -222,8 +380,12 @@ export function PublishModal({
|
|||||||
initRef.current = false;
|
initRef.current = false;
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
const dataReady =
|
||||||
|
!loadingExisting &&
|
||||||
|
modrinthMcVersions.length > 0 &&
|
||||||
|
curseforgeMcVersions.length > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadingExisting && publishVersions.length > 0 && !initRef.current) {
|
if (dataReady && publishVersions.length > 0 && !initRef.current) {
|
||||||
initRef.current = true;
|
initRef.current = true;
|
||||||
setSelectedModrinth(
|
setSelectedModrinth(
|
||||||
publishVersions.filter((v) => !v.existsModrinth).map((v) => v.key),
|
publishVersions.filter((v) => !v.existsModrinth).map((v) => v.key),
|
||||||
@@ -231,11 +393,21 @@ export function PublishModal({
|
|||||||
setSelectedCurseforge(
|
setSelectedCurseforge(
|
||||||
publishVersions.filter((v) => !v.existsCurseforge).map((v) => v.key),
|
publishVersions.filter((v) => !v.existsCurseforge).map((v) => v.key),
|
||||||
);
|
);
|
||||||
|
console.log(selectedModrinth, selectedCurseforge);
|
||||||
}
|
}
|
||||||
}, [loadingExisting, publishVersions]);
|
}, [dataReady, publishVersions]);
|
||||||
|
|
||||||
// --------------- Steps handling ---------------
|
// --------------- Steps handling ---------------
|
||||||
|
|
||||||
|
// 进入第二页的前置条件:
|
||||||
|
// 1. 数据加载完成且成功(元数据为空说明加载失败或未加载,已有版本状态未知);
|
||||||
|
// 2. 至少勾选一个待发布版本。
|
||||||
|
const canProceed =
|
||||||
|
!loadingExisting &&
|
||||||
|
modrinthMcVersions.length > 0 &&
|
||||||
|
curseforgeMcVersions.length > 0 &&
|
||||||
|
(selectedModrinth.length > 0 || selectedCurseforge.length > 0);
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, 2));
|
setCurrentStep((prev) => Math.min(prev + 1, 2));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -300,6 +472,7 @@ export function PublishModal({
|
|||||||
modrinth: selectedModrinth.map((mc_version) => ({ mc_version })),
|
modrinth: selectedModrinth.map((mc_version) => ({ mc_version })),
|
||||||
curseforge: selectedCurseforge.map((mc_version) => ({ mc_version })),
|
curseforge: selectedCurseforge.map((mc_version) => ({ mc_version })),
|
||||||
},
|
},
|
||||||
|
allMcVersions: projectVersions,
|
||||||
cutoffMcVersion: lastVersionEnd ?? "",
|
cutoffMcVersion: lastVersionEnd ?? "",
|
||||||
changelog,
|
changelog,
|
||||||
versionType,
|
versionType,
|
||||||
@@ -341,6 +514,7 @@ export function PublishModal({
|
|||||||
title: "版本号",
|
title: "版本号",
|
||||||
dataIndex: "modrinthVersion",
|
dataIndex: "modrinthVersion",
|
||||||
key: "modrinthVersion",
|
key: "modrinthVersion",
|
||||||
|
width: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "兼容范围",
|
title: "兼容范围",
|
||||||
@@ -361,7 +535,7 @@ export function PublishModal({
|
|||||||
title: "MC 版本",
|
title: "MC 版本",
|
||||||
dataIndex: "mc_version",
|
dataIndex: "mc_version",
|
||||||
key: "mc_version",
|
key: "mc_version",
|
||||||
width: 100,
|
width: 80,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "兼容范围",
|
title: "兼容范围",
|
||||||
@@ -402,23 +576,35 @@ export function PublishModal({
|
|||||||
{loadingExisting ? (
|
{loadingExisting ? (
|
||||||
<div style={{ textAlign: "center", padding: 40 }}>加载中…</div>
|
<div style={{ textAlign: "center", padding: 40 }}>加载中…</div>
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={token.margin}>
|
<Row gutter={token.margin} className="publish-version-tables" style={newRowCssVars}>
|
||||||
<Col span={14}>
|
<Col span={14}>
|
||||||
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
|
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
|
||||||
Modrinth
|
Modrinth
|
||||||
</Text>
|
</Text>
|
||||||
{publishVersions.length > 0 ? (
|
{modrinthRows.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
dataSource={publishVersions}
|
dataSource={modrinthRows}
|
||||||
columns={modrinthColumns}
|
columns={modrinthColumns}
|
||||||
|
rowKey="key"
|
||||||
|
rowClassName={(r) =>
|
||||||
|
rowClassName(
|
||||||
|
r.kind,
|
||||||
|
r.kind === "pending"
|
||||||
|
? (r.pending?.existsModrinth ?? false)
|
||||||
|
: true,
|
||||||
|
)
|
||||||
|
}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedModrinth,
|
selectedRowKeys: selectedModrinth,
|
||||||
onChange: (keys) => setSelectedModrinth(keys as string[]),
|
onChange: (keys) => {setSelectedModrinth(keys as string[]); console.log(selectedModrinth);},
|
||||||
getCheckboxProps: (r: PublishVersion) => ({
|
getCheckboxProps: (r: ModrinthTableRow) => ({
|
||||||
disabled: r.existsModrinth,
|
disabled:
|
||||||
|
r.kind === "existing" ||
|
||||||
|
(r.pending?.existsModrinth ?? false),
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ y: 480 }}
|
||||||
size="small"
|
size="small"
|
||||||
bordered
|
bordered
|
||||||
/>
|
/>
|
||||||
@@ -430,18 +616,30 @@ export function PublishModal({
|
|||||||
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
|
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
|
||||||
CurseForge
|
CurseForge
|
||||||
</Text>
|
</Text>
|
||||||
{publishVersions.length > 0 ? (
|
{curseforgeRows.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
dataSource={publishVersions}
|
dataSource={curseforgeRows}
|
||||||
columns={curseforgeColumns}
|
columns={curseforgeColumns}
|
||||||
|
rowKey="key"
|
||||||
|
rowClassName={(r) =>
|
||||||
|
rowClassName(
|
||||||
|
r.kind,
|
||||||
|
r.kind === "pending"
|
||||||
|
? (r.pending?.existsCurseforge ?? false)
|
||||||
|
: true,
|
||||||
|
)
|
||||||
|
}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedCurseforge,
|
selectedRowKeys: selectedCurseforge,
|
||||||
onChange: (keys) => setSelectedCurseforge(keys as string[]),
|
onChange: (keys) => {setSelectedCurseforge(keys as string[]); console.log(selectedCurseforge);},
|
||||||
getCheckboxProps: (r: PublishVersion) => ({
|
getCheckboxProps: (r: CurseforgeTableRow) => ({
|
||||||
disabled: r.existsCurseforge,
|
disabled:
|
||||||
|
r.kind === "existing" ||
|
||||||
|
(r.pending?.existsCurseforge ?? false),
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ y: 480 }}
|
||||||
size="small"
|
size="small"
|
||||||
bordered
|
bordered
|
||||||
/>
|
/>
|
||||||
@@ -604,7 +802,11 @@ export function PublishModal({
|
|||||||
<Button onClick={handlePrev}>上一步</Button>
|
<Button onClick={handlePrev}>上一步</Button>
|
||||||
)}
|
)}
|
||||||
{currentStep === 0 ? (
|
{currentStep === 0 ? (
|
||||||
<Button type="primary" onClick={handleNext}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canProceed}
|
||||||
|
>
|
||||||
下一步
|
下一步
|
||||||
</Button>
|
</Button>
|
||||||
) : currentStep === 1 ? (
|
) : currentStep === 1 ? (
|
||||||
|
|||||||
@@ -0,0 +1,684 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Steps,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Select,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Flex,
|
||||||
|
Progress,
|
||||||
|
Typography,
|
||||||
|
Empty,
|
||||||
|
Tag,
|
||||||
|
Alert,
|
||||||
|
App,
|
||||||
|
theme,
|
||||||
|
} from "antd";
|
||||||
|
import { ReloadOutlined, ArrowRightOutlined } from "@ant-design/icons";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useSubscription } from "@trpc/tanstack-react-query";
|
||||||
|
import type { Config } from "@/types";
|
||||||
|
import type { ParsedProject } from "@/services/project";
|
||||||
|
import { platforms } from "@/services/publish.schemas";
|
||||||
|
import type {
|
||||||
|
ExistingRelease,
|
||||||
|
PlatformResult,
|
||||||
|
ProgressEvent,
|
||||||
|
} from "@/services/publish.schemas";
|
||||||
|
import { Template } from "@/lib/utils/template";
|
||||||
|
import type { McVersionEntry } from "@/lib/utils/mcVersion";
|
||||||
|
import {
|
||||||
|
computeVersionRanges,
|
||||||
|
collectAutoRange,
|
||||||
|
sameVersionSet,
|
||||||
|
} from "@/lib/utils/mcVersion";
|
||||||
|
import { SemVer } from "@/lib/utils/templates/semver";
|
||||||
|
import { getModrinthMcVersions, getCurseForgeMcVersions } from "@/services/meta";
|
||||||
|
import { fetchExistingReleases, type ExistingReleases } from "@/services/versions";
|
||||||
|
import { errorMessage } from "@/lib/utils/error";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface UpdateRow {
|
||||||
|
/** 行 key,提交时作为 mc_version 直接传入后端 API,严禁加任何前缀 */
|
||||||
|
key: string;
|
||||||
|
mc_version: string;
|
||||||
|
/** 平台当前范围(升序,仅 MC 版本) */
|
||||||
|
currentRange: string[];
|
||||||
|
/** 新范围(升序) */
|
||||||
|
newRange: string[];
|
||||||
|
/** 新范围显示(通配符,无法通配时回退显式范围) */
|
||||||
|
newRangeDisplay: string;
|
||||||
|
currentTitle: string;
|
||||||
|
newTitle: string;
|
||||||
|
/** 范围或标题有变化 */
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateModalProps {
|
||||||
|
open: boolean;
|
||||||
|
/** 配置文件名(configs/ 下的 .json 文件名) */
|
||||||
|
configFile: string;
|
||||||
|
config: Config;
|
||||||
|
project: ParsedProject;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 将项目产物与平台已有版本按模板匹配,构建更新行(仅含平台上已存在的版本) */
|
||||||
|
function buildUpdateRows(
|
||||||
|
project: ParsedProject,
|
||||||
|
entries: McVersionEntry[] | null,
|
||||||
|
existingList: ExistingRelease[] | undefined,
|
||||||
|
matchTmpl: Template,
|
||||||
|
nameTmpl: Template,
|
||||||
|
): UpdateRow[] {
|
||||||
|
if (!entries || !existingList) return [];
|
||||||
|
const entryMap = new Map(entries.map((e) => [e.version, e]));
|
||||||
|
const rows: UpdateRow[] = [];
|
||||||
|
|
||||||
|
for (const artifact of project.artifacts) {
|
||||||
|
const entry = entryMap.get(artifact.mc_version);
|
||||||
|
if (!entry) continue;
|
||||||
|
|
||||||
|
const matchKey = matchTmpl.format({
|
||||||
|
version: project.version,
|
||||||
|
mc_version: artifact.mc_version,
|
||||||
|
});
|
||||||
|
const found = existingList.find((e) => e.matchKey === matchKey);
|
||||||
|
// 平台上不存在的版本不属于更新范围(应走发布流程)
|
||||||
|
if (!found) continue;
|
||||||
|
|
||||||
|
const newTitle = nameTmpl.format({
|
||||||
|
version: project.version,
|
||||||
|
mc_version: artifact.mc_version,
|
||||||
|
mc_version_range: entry.explicit,
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
key: artifact.mc_version,
|
||||||
|
mc_version: artifact.mc_version,
|
||||||
|
currentRange: [...found.gameVersions].sort((a, b) => SemVer.compare(a, b)),
|
||||||
|
newRange: entry.range,
|
||||||
|
newRangeDisplay: entry.wildcard,
|
||||||
|
currentTitle: found.title,
|
||||||
|
newTitle,
|
||||||
|
changed:
|
||||||
|
found.title !== newTitle ||
|
||||||
|
!sameVersionSet(found.gameVersions, entry.range),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function UpdateModal({
|
||||||
|
open,
|
||||||
|
configFile,
|
||||||
|
config,
|
||||||
|
project,
|
||||||
|
onClose,
|
||||||
|
}: UpdateModalProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
// Version selection
|
||||||
|
const [selectedModrinth, setSelectedModrinth] = useState<string[]>([]);
|
||||||
|
const [selectedCurseforge, setSelectedCurseforge] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Platform data — loaded when modal opens
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modrinthMcVersions, setModrinthMcVersions] = useState<string[]>([]);
|
||||||
|
const [curseforgeMcVersions, setCurseforgeMcVersions] = useState<string[]>([]);
|
||||||
|
const [existing, setExisting] = useState<ExistingReleases | null>(null);
|
||||||
|
|
||||||
|
// 最后一个版本的截止 MC 版本(更新流程的核心输入)
|
||||||
|
const [cutoff, setCutoff] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Progress & result
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [updateDone, setUpdateDone] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<
|
||||||
|
Record<
|
||||||
|
typeof platforms[number],
|
||||||
|
{ done: number; total: number; status: ProgressEvent["status"] }
|
||||||
|
>
|
||||||
|
>({
|
||||||
|
modrinth: { done: 0, total: 0, status: "running" },
|
||||||
|
curseforge: { done: 0, total: 0, status: "running" },
|
||||||
|
});
|
||||||
|
const [updateResult, setUpdateResult] = useState<{
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
errors: Record<typeof platforms[number], string[]>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 元数据与已有版本同属第一页数据,任一失败都不允许进入下一步,
|
||||||
|
// 否则无法区分"无变化"和"没查到"
|
||||||
|
const [mrVersions, cfVersions, existingReleases] = await Promise.all([
|
||||||
|
getModrinthMcVersions(),
|
||||||
|
getCurseForgeMcVersions(),
|
||||||
|
fetchExistingReleases(config),
|
||||||
|
]);
|
||||||
|
setModrinthMcVersions(mrVersions);
|
||||||
|
setCurseforgeMcVersions(cfVersions);
|
||||||
|
setExisting(existingReleases);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(
|
||||||
|
`加载平台数据失败,请检查网络后点击“刷新平台数据”重试:${errorMessage(e)}`,
|
||||||
|
);
|
||||||
|
setModrinthMcVersions([]);
|
||||||
|
setCurseforgeMcVersions([]);
|
||||||
|
setExisting(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [config, message]);
|
||||||
|
|
||||||
|
// Auto-load when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [open, loadData]);
|
||||||
|
|
||||||
|
const projectVersions = useMemo(
|
||||||
|
() => project.artifacts.map((a) => a.mc_version),
|
||||||
|
[project],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 截止版本下拉框选项:两平台目录并集中 >= 最后一个项目版本的同系列版本
|
||||||
|
const allMcVersionsUnion = useMemo(() => {
|
||||||
|
const union = new Set([...modrinthMcVersions, ...curseforgeMcVersions]);
|
||||||
|
return [...union].sort((a, b) => SemVer.compare(a, b));
|
||||||
|
}, [modrinthMcVersions, curseforgeMcVersions]);
|
||||||
|
|
||||||
|
const cutoffOptions = useMemo(() => {
|
||||||
|
const lastVer = project.artifacts[0]?.mc_version;
|
||||||
|
if (!lastVer || allMcVersionsUnion.length === 0) return [];
|
||||||
|
return collectAutoRange(lastVer, allMcVersionsUnion)
|
||||||
|
.filter(
|
||||||
|
(v) => v.localeCompare(lastVer, undefined, { numeric: true }) >= 0,
|
||||||
|
)
|
||||||
|
.map((v) => ({ value: v, label: v }));
|
||||||
|
}, [project, allMcVersionsUnion]);
|
||||||
|
|
||||||
|
// 目录加载后默认截止版本为同系列最新
|
||||||
|
useEffect(() => {
|
||||||
|
if (allMcVersionsUnion.length === 0) return;
|
||||||
|
const lastVer = project.artifacts[0]?.mc_version;
|
||||||
|
if (!lastVer) return;
|
||||||
|
const series = collectAutoRange(lastVer, allMcVersionsUnion);
|
||||||
|
setCutoff(series[series.length - 1] ?? null);
|
||||||
|
}, [allMcVersionsUnion, project]);
|
||||||
|
|
||||||
|
// Per-platform new ranges
|
||||||
|
const mrRanges = useMemo(() => {
|
||||||
|
if (modrinthMcVersions.length === 0) return null;
|
||||||
|
return computeVersionRanges(projectVersions, modrinthMcVersions, cutoff ?? undefined);
|
||||||
|
}, [projectVersions, modrinthMcVersions, cutoff]);
|
||||||
|
|
||||||
|
const cfRanges = useMemo(() => {
|
||||||
|
if (curseforgeMcVersions.length === 0) return null;
|
||||||
|
return computeVersionRanges(projectVersions, curseforgeMcVersions, cutoff ?? undefined);
|
||||||
|
}, [projectVersions, curseforgeMcVersions, cutoff]);
|
||||||
|
|
||||||
|
// Templates(与服务端 matchKey / resolveVersionName 保持同一渲染逻辑)
|
||||||
|
const tmplRefs = useMemo(
|
||||||
|
() => ({ filename_format: config.filename_format }),
|
||||||
|
[config.filename_format],
|
||||||
|
);
|
||||||
|
const mrVersionTmpl = useMemo(
|
||||||
|
() => new Template(config.modrinth.version, tmplRefs),
|
||||||
|
[config.modrinth.version, tmplRefs],
|
||||||
|
);
|
||||||
|
const mrNameTmpl = useMemo(
|
||||||
|
() => new Template(config.modrinth.version_name, tmplRefs),
|
||||||
|
[config.modrinth.version_name, tmplRefs],
|
||||||
|
);
|
||||||
|
// CurseForge 匹配键与显示名称共用同一 version_name 模板(通常为 #{filename_format})
|
||||||
|
const cfNameTmpl = useMemo(
|
||||||
|
() => new Template(config.curseforge.version_name, tmplRefs),
|
||||||
|
[config.curseforge.version_name, tmplRefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modrinthRows = useMemo(
|
||||||
|
() => buildUpdateRows(project, mrRanges, existing?.modrinth, mrVersionTmpl, mrNameTmpl),
|
||||||
|
[project, mrRanges, existing, mrVersionTmpl, mrNameTmpl],
|
||||||
|
);
|
||||||
|
const curseforgeRows = useMemo(
|
||||||
|
() => buildUpdateRows(project, cfRanges, existing?.curseforge, cfNameTmpl, cfNameTmpl),
|
||||||
|
[project, cfRanges, existing, cfNameTmpl],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-select changed rows when data first loads
|
||||||
|
const initRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
initRef.current = false;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
const dataReady =
|
||||||
|
!loading &&
|
||||||
|
existing !== null &&
|
||||||
|
modrinthMcVersions.length > 0 &&
|
||||||
|
curseforgeMcVersions.length > 0;
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataReady && !initRef.current) {
|
||||||
|
initRef.current = true;
|
||||||
|
setSelectedModrinth(modrinthRows.filter((r) => r.changed).map((r) => r.key));
|
||||||
|
setSelectedCurseforge(curseforgeRows.filter((r) => r.changed).map((r) => r.key));
|
||||||
|
}
|
||||||
|
}, [dataReady, modrinthRows, curseforgeRows]);
|
||||||
|
|
||||||
|
// --------------- Steps handling ---------------
|
||||||
|
|
||||||
|
const canProceed = dataReady && (selectedModrinth.length > 0 || selectedCurseforge.length > 0);
|
||||||
|
|
||||||
|
const handleConfirmUpdate = useCallback(() => {
|
||||||
|
// 重置状态并进入进度页;真正发起 mutation 在下方 useSubscription 的
|
||||||
|
// onStarted 里(保证先连上 SSE 再更新,不漏早期进度事件)
|
||||||
|
setUpdating(true);
|
||||||
|
setUpdateDone(false);
|
||||||
|
setUpdateResult(null);
|
||||||
|
setProgress({
|
||||||
|
modrinth: { done: 0, total: selectedModrinth.length, status: "running" },
|
||||||
|
curseforge: { done: 0, total: selectedCurseforge.length, status: "running" },
|
||||||
|
});
|
||||||
|
setCurrentStep(1);
|
||||||
|
}, [selectedModrinth.length, selectedCurseforge.length]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation(
|
||||||
|
trpc.update.mutationOptions({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const errors = { modrinth: [], curseforge: [] } as Record<
|
||||||
|
typeof platforms[number],
|
||||||
|
string[]
|
||||||
|
>;
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const r: PlatformResult = result[platform];
|
||||||
|
success += r.success;
|
||||||
|
failed += r.fail;
|
||||||
|
errors[platform] = r.errors;
|
||||||
|
}
|
||||||
|
setUpdateResult({ success, failed, errors });
|
||||||
|
setUpdateDone(true);
|
||||||
|
setUpdating(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(`更新请求失败:${error.message}`);
|
||||||
|
setUpdateDone(true);
|
||||||
|
setUpdating(false);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useSubscription(
|
||||||
|
trpc.progress.subscriptionOptions(undefined, {
|
||||||
|
enabled: updating,
|
||||||
|
onStarted: () => {
|
||||||
|
// SSE 已连接,现在发起更新不会漏事件
|
||||||
|
updateMutation.mutate({
|
||||||
|
configFile,
|
||||||
|
version: project.version,
|
||||||
|
mcVersions: {
|
||||||
|
modrinth: selectedModrinth.map((mc_version) => ({ mc_version })),
|
||||||
|
curseforge: selectedCurseforge.map((mc_version) => ({ mc_version })),
|
||||||
|
},
|
||||||
|
allMcVersions: projectVersions,
|
||||||
|
cutoffMcVersion: cutoff ?? "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onData: (event) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[event.platform]: {
|
||||||
|
done: event.current,
|
||||||
|
total: event.total,
|
||||||
|
status: event.status,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Progress subscription error:", error);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (updating) return;
|
||||||
|
setCurrentStep(0);
|
||||||
|
setSelectedModrinth([]);
|
||||||
|
setSelectedCurseforge([]);
|
||||||
|
setUpdateResult(null);
|
||||||
|
setUpdateDone(false);
|
||||||
|
onClose();
|
||||||
|
}, [updating, onClose]);
|
||||||
|
|
||||||
|
// --------------- Render: diff cells ---------------
|
||||||
|
|
||||||
|
const renderRangeCell = useCallback(
|
||||||
|
(row: UpdateRow) => {
|
||||||
|
const current = row.currentRange.join(", ");
|
||||||
|
const rangeChanged = !sameVersionSet(row.currentRange, row.newRange);
|
||||||
|
if (!rangeChanged) {
|
||||||
|
return <Text type={row.changed ? undefined : "secondary"}>{current}</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={4} wrap>
|
||||||
|
<Text delete type="secondary">{current}</Text>
|
||||||
|
<ArrowRightOutlined style={{ fontSize: 10, color: token.colorTextTertiary }} />
|
||||||
|
<Text strong>{row.newRangeDisplay}</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTitleCell = useCallback(
|
||||||
|
(row: UpdateRow) => {
|
||||||
|
if (row.currentTitle === row.newTitle) {
|
||||||
|
return <Text type={row.changed ? undefined : "secondary"}>{row.currentTitle}</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={4} wrap>
|
||||||
|
<Text delete type="secondary">{row.currentTitle}</Text>
|
||||||
|
<ArrowRightOutlined style={{ fontSize: 10, color: token.colorTextTertiary }} />
|
||||||
|
<Text strong>{row.newTitle}</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --------------- Render: Step 1 — Version Selection ---------------
|
||||||
|
|
||||||
|
const renderStep1 = () => {
|
||||||
|
const makeColumns = (titleHeader: string) => [
|
||||||
|
{
|
||||||
|
title: "MC 版本",
|
||||||
|
dataIndex: "mc_version",
|
||||||
|
key: "mc_version",
|
||||||
|
width: 80,
|
||||||
|
render: (val: string) => <Tag>{val}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "兼容范围",
|
||||||
|
key: "range",
|
||||||
|
width: "42%",
|
||||||
|
render: (_: unknown, row: UpdateRow) => renderRangeCell(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: titleHeader,
|
||||||
|
key: "title",
|
||||||
|
render: (_: unknown, row: UpdateRow) => renderTitleCell(row),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: token.margin,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={token.marginXS}>
|
||||||
|
<Text strong>选择要更新的版本</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
截止
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
style={{ width: 110 }}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
value={cutoff ?? undefined}
|
||||||
|
onChange={setCutoff}
|
||||||
|
options={cutoffOptions}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={loading}
|
||||||
|
onClick={loadData}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
刷新平台数据
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: token.marginSM }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
仅列出平台上已存在的版本;范围或标题有变化的版本已自动勾选。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: "center", padding: 40 }}>加载中…</div>
|
||||||
|
) : (
|
||||||
|
<Row gutter={token.margin}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
|
||||||
|
Modrinth
|
||||||
|
</Text>
|
||||||
|
{modrinthRows.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={modrinthRows}
|
||||||
|
columns={makeColumns("版本名称")}
|
||||||
|
rowKey="key"
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedModrinth,
|
||||||
|
onChange: (keys) => setSelectedModrinth(keys as string[]),
|
||||||
|
getCheckboxProps: (r: UpdateRow) => ({ disabled: !r.changed }),
|
||||||
|
}}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 420 }}
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="平台上没有可更新的版本" />
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text strong style={{ display: "block", marginBottom: token.marginXS }}>
|
||||||
|
CurseForge
|
||||||
|
</Text>
|
||||||
|
{curseforgeRows.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={curseforgeRows}
|
||||||
|
columns={makeColumns("显示名称")}
|
||||||
|
rowKey="key"
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedCurseforge,
|
||||||
|
onChange: (keys) => setSelectedCurseforge(keys as string[]),
|
||||||
|
getCheckboxProps: (r: UpdateRow) => ({ disabled: !r.changed }),
|
||||||
|
}}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 420 }}
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="平台上没有可更新的版本" />
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- Render: Step 2 — Progress & Result ---------------
|
||||||
|
|
||||||
|
const renderStep2 = () => {
|
||||||
|
const hasModrinth = selectedModrinth.length > 0;
|
||||||
|
const hasCurseforge = selectedCurseforge.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Progress bars */}
|
||||||
|
<div style={{ marginBottom: token.marginLG }}>
|
||||||
|
<Flex vertical gap={token.marginSM}>
|
||||||
|
{hasModrinth && (
|
||||||
|
<Flex align="center" gap={token.marginXS}>
|
||||||
|
<img
|
||||||
|
src="/platform/modrinth.svg"
|
||||||
|
alt="Modrinth"
|
||||||
|
style={{ width: 30, height: 30, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ width: 80, flexShrink: 0, fontFamily: "Inter", fontWeight: "bold" }}>Modrinth</Text>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(
|
||||||
|
(progress.modrinth.done /
|
||||||
|
Math.max(progress.modrinth.total, 1)) *
|
||||||
|
100,
|
||||||
|
)}
|
||||||
|
status={
|
||||||
|
progress.modrinth.status === "failed"
|
||||||
|
? "exception"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
format={() =>
|
||||||
|
`${progress.modrinth.done} / ${progress.modrinth.total}`
|
||||||
|
}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
{hasCurseforge && (
|
||||||
|
<Flex align="center" gap={token.marginXS}>
|
||||||
|
<img
|
||||||
|
src="/platform/curseforge.svg"
|
||||||
|
alt="CurseForge"
|
||||||
|
style={{ width: 30, height: 30, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ width: 80, flexShrink: 0, fontFamily: "Inter", fontWeight: "bold" }}>CurseForge</Text>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(
|
||||||
|
(progress.curseforge.done /
|
||||||
|
Math.max(progress.curseforge.total, 1)) *
|
||||||
|
100,
|
||||||
|
)}
|
||||||
|
status={
|
||||||
|
progress.curseforge.status === "failed"
|
||||||
|
? "exception"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
format={() =>
|
||||||
|
`${progress.curseforge.done} / ${progress.curseforge.total}`
|
||||||
|
}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{updateDone && updateResult && (
|
||||||
|
<Alert
|
||||||
|
type={updateResult.failed > 0 ? "warning" : "success"}
|
||||||
|
title="更新结果"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
成功 {updateResult.success} 个,失败{" "}
|
||||||
|
{updateResult.failed} 个
|
||||||
|
</div>
|
||||||
|
{platforms.map((platform) => {
|
||||||
|
const errors = updateResult.errors[platform];
|
||||||
|
if (errors.length === 0) return null;
|
||||||
|
const label =
|
||||||
|
platform === "modrinth" ? "Modrinth" : "CurseForge";
|
||||||
|
return (
|
||||||
|
<div key={platform} style={{ marginTop: token.marginSM }}>
|
||||||
|
<Text strong>{label}:</Text>
|
||||||
|
<ul style={{ margin: "4px 0 0", paddingLeft: 20 }}>
|
||||||
|
{errors.map((e, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Text type="danger">{e}</Text>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- Render ---------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="更新版本"
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
width={1100}
|
||||||
|
destroyOnHidden
|
||||||
|
footer={
|
||||||
|
updating ? null : (
|
||||||
|
<Space>
|
||||||
|
{currentStep === 0 ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleConfirmUpdate}
|
||||||
|
disabled={!canProceed}
|
||||||
|
>
|
||||||
|
确认更新
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" onClick={handleClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Steps
|
||||||
|
current={currentStep}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
items={[
|
||||||
|
{ title: "版本选择" },
|
||||||
|
{ title: "更新进度" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentStep === 0 ? renderStep1() : renderStep2()}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,4 +60,31 @@ export class ModrinthClient {
|
|||||||
}
|
}
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 PATCH 请求,body 为 JSON。
|
||||||
|
* 成功响应为 204 No Content,无响应体,故返回 void。
|
||||||
|
*/
|
||||||
|
async patch(path: string, body: unknown): Promise<void> {
|
||||||
|
const url = `${this.base}${path}`;
|
||||||
|
if (isPublishDryRun()) {
|
||||||
|
await interceptUpload("Modrinth", "PATCH", url, JSON.stringify(body, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
getVersionByNumber,
|
getVersionByNumber,
|
||||||
getVersions,
|
getVersions,
|
||||||
createVersion,
|
createVersion,
|
||||||
|
updateVersion,
|
||||||
} from "./version";
|
} from "./version";
|
||||||
export { getLoaders, getGameVersions } from "./tag";
|
export { getLoaders, getGameVersions } from "./tag";
|
||||||
export { getProject, getProjects } from "./project";
|
export { getProject, getProjects } from "./project";
|
||||||
@@ -3,8 +3,12 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ModrinthClient } from "./client";
|
import { ModrinthClient } from "./client";
|
||||||
import { createVersion } from "./version";
|
import { createVersion, updateVersion } from "./version";
|
||||||
import type { CreatableVersion } from "@/types";
|
import {
|
||||||
|
EditableVersionSchema,
|
||||||
|
type CreatableVersion,
|
||||||
|
type EditableVersion,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
describe("createVersion (dry-run)", () => {
|
describe("createVersion (dry-run)", () => {
|
||||||
const OLD_ENV = process.env;
|
const OLD_ENV = process.env;
|
||||||
@@ -71,3 +75,59 @@ describe("createVersion (dry-run)", () => {
|
|||||||
).rejects.toThrow('file field "sources" declared in file_parts');
|
).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
VersionSchema,
|
VersionSchema,
|
||||||
type Version,
|
type Version,
|
||||||
type CreatableVersion,
|
type CreatableVersion,
|
||||||
|
type EditableVersion,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,3 +117,23 @@ export async function createVersion(
|
|||||||
const result = await client.post<unknown>("/version", form);
|
const result = await client.post<unknown>("/version", form);
|
||||||
return VersionSchema.parse(result);
|
return VersionSchema.parse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改已有版本的元数据(如兼容的 MC 版本范围、changelog 等)。
|
||||||
|
* PATCH /version/{id}
|
||||||
|
*
|
||||||
|
* 仅传入需要修改的字段即可,未传入的字段保持不变。
|
||||||
|
* 成功响应为 204 No Content,无返回值。
|
||||||
|
*
|
||||||
|
* @param client Modrinth 客户端实例
|
||||||
|
* @param versionId 版本 ID(8 位 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);
|
||||||
|
}
|
||||||
@@ -25,9 +25,9 @@ export async function interceptUpload(
|
|||||||
platform: string,
|
platform: string,
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
body: FormData,
|
body: FormData | string,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const summary = summarizeFormData(body);
|
const summary = typeof body === "string" ? body : summarizeFormData(body);
|
||||||
const logDir = process.env.PUBLISH_DRY_RUN_LOG_DIR ?? tmpdir();
|
const logDir = process.env.PUBLISH_DRY_RUN_LOG_DIR ?? tmpdir();
|
||||||
const logFile = join(logDir, `dry-run-${Date.now()}-${platform}.log`);
|
const logFile = join(logDir, `dry-run-${Date.now()}-${platform}.log`);
|
||||||
|
|
||||||
@@ -50,6 +50,6 @@ export async function interceptUpload(
|
|||||||
await new Promise((resolve) => setTimeout(resolve, DRY_RUN_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, DRY_RUN_DELAY_MS));
|
||||||
|
|
||||||
return platform === "Modrinth"
|
return platform === "Modrinth"
|
||||||
? { name: "(dry-run)", version_number: "(dry-run)" }
|
? { id: "(dry-run)", name: "(dry-run)", version_number: "(dry-run)", game_versions: [] }
|
||||||
: { id: 0 };
|
: { id: 0 };
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ function collectRange(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判断两个版本列表作为集合是否相等(顺序无关) */
|
||||||
|
export function sameVersionSet(a: string[], b: string[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const set = new Set(a);
|
||||||
|
return b.every((v) => set.has(v));
|
||||||
|
}
|
||||||
|
|
||||||
/** 自动匹配:收集与 start 同系列的所有版本 */
|
/** 自动匹配:收集与 start 同系列的所有版本 */
|
||||||
export function collectAutoRange(
|
export function collectAutoRange(
|
||||||
start: string,
|
start: string,
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
import { curseforgeClient } from "@/services/clients";
|
import { curseforgeClient } from "@/services/clients";
|
||||||
import { uploadFile, getGameVersions } from "@/lib/curseforge";
|
import { uploadFile, updateFile, getFiles, getGameVersions, getMinecraftVersions } from "@/lib/curseforge";
|
||||||
import { resolveVersionName } from "@/lib/utils/format";
|
import { resolveVersionName } from "@/lib/utils/format";
|
||||||
import type { UploadMetadata } from "@/types";
|
import { sameVersionSet } from "@/lib/utils/mcVersion";
|
||||||
import { PlatformPublishFn } from "../publish.schemas";
|
import { Template } from "@/lib/utils/template";
|
||||||
|
import type { Config, CurseForgeFile, UploadMetadata } from "@/types";
|
||||||
|
import {
|
||||||
|
PlatformListExistingFn,
|
||||||
|
PlatformMatchKeyFn,
|
||||||
|
PlatformPublishFn,
|
||||||
|
PlatformUpdateFn,
|
||||||
|
} from "../publish.schemas";
|
||||||
|
|
||||||
export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
|
export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
|
||||||
const { input, config } = ctx;
|
const { input, config } = ctx;
|
||||||
|
|
||||||
const displayName = resolveVersionName(config.curseforge.version_name, config, input.version, mcVersion);
|
const displayName = resolveVersionName(config.curseforge.version_name, config, input.version, mcVersion);
|
||||||
|
|
||||||
const gameVersions = await getGameVersions(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 metadata: UploadMetadata = {
|
const metadata: UploadMetadata = {
|
||||||
changelog: input.changelog,
|
changelog: input.changelog,
|
||||||
changelogType: "markdown",
|
changelogType: "markdown",
|
||||||
displayName,
|
displayName,
|
||||||
releaseType: input.versionType,
|
releaseType: input.versionType,
|
||||||
gameVersions: [
|
gameVersions: await resolveGameVersionIds(config, mcVersion.range),
|
||||||
...config.curseforge.loaders.map(resolveId),
|
|
||||||
...config.curseforge.environment.map(resolveId),
|
|
||||||
...mcVersion.range.map(resolveId),
|
|
||||||
],
|
|
||||||
relations: config.curseforge.relations,
|
relations: config.curseforge.relations,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,3 +32,85 @@ export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion
|
|||||||
files.primary,
|
files.primary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 匹配键:按 version_name 模板渲染文件名(与 compareVersions 一致) */
|
||||||
|
export const curseForgeMatchKey: PlatformMatchKeyFn = (config, version, mcVersion) =>
|
||||||
|
new Template(config.curseforge.version_name, {
|
||||||
|
filename_format: config.filename_format,
|
||||||
|
}).format({ version, mc_version: mcVersion });
|
||||||
|
|
||||||
|
export const listExistingCurseForge: PlatformListExistingFn = async (config) => {
|
||||||
|
const pageSize = 50;
|
||||||
|
let index = 0;
|
||||||
|
const allFiles: CurseForgeFile[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await getFiles(curseforgeClient, {
|
||||||
|
modId: config.curseforge.project_id,
|
||||||
|
index,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
allFiles.push(...response.data);
|
||||||
|
|
||||||
|
if (allFiles.length >= response.pagination.totalCount) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index += pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// file.gameVersions 混合了 MC 版本 / loader / 环境等标签,只保留 MC 版本用于 diff
|
||||||
|
const mcCatalog = new Set(
|
||||||
|
(await getMinecraftVersions(curseforgeClient)).map((v) => v.versionString),
|
||||||
|
);
|
||||||
|
|
||||||
|
return allFiles.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
matchKey: f.fileName,
|
||||||
|
title: f.displayName,
|
||||||
|
gameVersions: f.gameVersions.filter((v) => mcCatalog.has(v)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateCurseForgeVersion: PlatformUpdateFn = async (ctx, mcVersion, existing) => {
|
||||||
|
const { input, config } = ctx;
|
||||||
|
|
||||||
|
const displayName = resolveVersionName(config.curseforge.version_name, config, input.version, mcVersion);
|
||||||
|
|
||||||
|
// 标题与范围都无变化时不调 API
|
||||||
|
if (existing.title === displayName && sameVersionSet(existing.gameVersions, mcVersion.range)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update-file 的 gameVersions 是整体替换:必须连同 loader/环境 ID 一起提交,
|
||||||
|
// 只传 MC 版本会把原有 loader/环境标签清掉
|
||||||
|
const gameVersions = await resolveGameVersionIds(config, mcVersion.range);
|
||||||
|
|
||||||
|
await updateFile(curseforgeClient, config.curseforge.project_id, {
|
||||||
|
fileID: Number(existing.id),
|
||||||
|
displayName,
|
||||||
|
gameVersions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 loaders + environment + MC 版本范围解析为 CurseForge Game Version ID 列表 */
|
||||||
|
async function resolveGameVersionIds(config: Config, mcRange: string[]): Promise<number[]> {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
...config.curseforge.loaders.map(resolveId),
|
||||||
|
...config.curseforge.environment.map(resolveId),
|
||||||
|
...mcRange.map(resolveMinecraftVersionId),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { modrinthClient } from "@/services/clients";
|
import { modrinthClient } from "@/services/clients";
|
||||||
import { createVersion } from "@/lib/modrinth";
|
import { createVersion, listVersions, updateVersion } from "@/lib/modrinth";
|
||||||
import { resolveVersionName } from "@/lib/utils/format";
|
import { resolveVersionName } from "@/lib/utils/format";
|
||||||
|
import { sameVersionSet } from "@/lib/utils/mcVersion";
|
||||||
|
import { Template } from "@/lib/utils/template";
|
||||||
import type { CreatableVersion } from "@/types";
|
import type { CreatableVersion } from "@/types";
|
||||||
import { PlatformPublishFn } from "../publish.schemas";
|
import {
|
||||||
|
PlatformListExistingFn,
|
||||||
|
PlatformMatchKeyFn,
|
||||||
|
PlatformPublishFn,
|
||||||
|
PlatformUpdateFn,
|
||||||
|
} from "../publish.schemas";
|
||||||
|
|
||||||
export const publishModrinthVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
|
export const publishModrinthVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
|
||||||
const { input, config } = ctx;
|
const { input, config } = ctx;
|
||||||
@@ -36,3 +43,37 @@ export const publishModrinthVersion: PlatformPublishFn = async (ctx, mcVersion,
|
|||||||
sources: files.source,
|
sources: files.source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 匹配键:按 version 模板渲染 version_number(与 compareVersions 一致) */
|
||||||
|
export const modrinthMatchKey: PlatformMatchKeyFn = (config, version, mcVersion) =>
|
||||||
|
new Template(config.modrinth.version, {
|
||||||
|
filename_format: config.filename_format,
|
||||||
|
}).format({ version, mc_version: mcVersion });
|
||||||
|
|
||||||
|
export const listExistingModrinth: PlatformListExistingFn = async (config) => {
|
||||||
|
const versions = await listVersions(modrinthClient, config.modrinth.project_id, {
|
||||||
|
include_changelog: false,
|
||||||
|
});
|
||||||
|
return versions.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
matchKey: v.version_number,
|
||||||
|
title: v.name,
|
||||||
|
gameVersions: v.game_versions,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateModrinthVersion: PlatformUpdateFn = async (ctx, mcVersion, existing) => {
|
||||||
|
const { input, config } = ctx;
|
||||||
|
|
||||||
|
const name = resolveVersionName(config.modrinth.version_name, config, input.version, mcVersion);
|
||||||
|
|
||||||
|
// 标题与范围都无变化时不调 API,避免产生无效的 PATCH 请求
|
||||||
|
if (existing.title === name && sameVersionSet(existing.gameVersions, mcVersion.range)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateVersion(modrinthClient, String(existing.id), {
|
||||||
|
name,
|
||||||
|
game_versions: mcVersion.range,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const platforms = ["modrinth", "curseforge"] as const;
|
export const platforms = ["modrinth", "curseforge"] as const;
|
||||||
|
|
||||||
export interface PlatformContext {
|
export interface PlatformContext<I = PublishInput> {
|
||||||
input: PublishInput;
|
input: I;
|
||||||
config: Config;
|
config: Config;
|
||||||
}
|
}
|
||||||
export type PlatformPublishFn = (
|
export type PlatformPublishFn = (
|
||||||
@@ -19,8 +19,36 @@ export type PlatformPublishFn = (
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
export type PlatformGetGameVersionsFn = () => Promise<string[]>;
|
export type PlatformGetGameVersionsFn = () => Promise<string[]>;
|
||||||
|
|
||||||
|
/** 平台上已有版本/文件的规范化视图,供更新流程匹配与 diff */
|
||||||
|
export interface ExistingRelease {
|
||||||
|
/** 平台内部 ID:Modrinth 版本 ID(base62 字符串)/ CurseForge 文件 ID(数字) */
|
||||||
|
id: string | number;
|
||||||
|
/** 模板匹配键:Modrinth version_number / CurseForge fileName */
|
||||||
|
matchKey: string;
|
||||||
|
/** 当前标题:Modrinth name / CurseForge displayName */
|
||||||
|
title: string;
|
||||||
|
/** 当前 MC 版本范围(CurseForge 已剔除 loader/环境等非 MC 版本项) */
|
||||||
|
gameVersions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlatformUpdateFn = (
|
||||||
|
ctx: PlatformContext<UpdateInput>,
|
||||||
|
mcVersion: McVersionEntry,
|
||||||
|
existing: ExistingRelease,
|
||||||
|
) => Promise<void>;
|
||||||
|
export type PlatformListExistingFn = (config: Config) => Promise<ExistingRelease[]>;
|
||||||
|
/** 生成已有版本的模板匹配键(前端预览与服务端正式匹配共用同一逻辑) */
|
||||||
|
export type PlatformMatchKeyFn = (
|
||||||
|
config: Config,
|
||||||
|
version: string,
|
||||||
|
mcVersion: string,
|
||||||
|
) => string;
|
||||||
|
|
||||||
export type PlatformAdaptor = {
|
export type PlatformAdaptor = {
|
||||||
publish: PlatformPublishFn,
|
publish: PlatformPublishFn,
|
||||||
|
update: PlatformUpdateFn,
|
||||||
|
listExisting: PlatformListExistingFn,
|
||||||
|
matchKey: PlatformMatchKeyFn,
|
||||||
getGameVersions: PlatformGetGameVersionsFn,
|
getGameVersions: PlatformGetGameVersionsFn,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,11 +60,20 @@ export const PublishInputSchema = z.object({
|
|||||||
mcVersions: z.object(createRecord(
|
mcVersions: z.object(createRecord(
|
||||||
platforms, z.array(z.object({ mc_version: z.string() }))
|
platforms, z.array(z.object({ mc_version: z.string() }))
|
||||||
)),
|
)),
|
||||||
|
// 项目全部 MC 版本(完整列表)。范围计算必须基于它:只传勾选子集会让
|
||||||
|
// 子集末尾被误判为"最后一个版本",范围按 lastVersionEnd 一路放大到最新 MC 版本
|
||||||
|
allMcVersions: z.array(z.string()).min(1),
|
||||||
cutoffMcVersion: z.string(), // 最后一个版本的截止 MC 版本,两个平台共用
|
cutoffMcVersion: z.string(), // 最后一个版本的截止 MC 版本,两个平台共用
|
||||||
changelog: z.string(),
|
changelog: z.string(),
|
||||||
versionType: BaseVersionSchema.shape.version_type,
|
versionType: BaseVersionSchema.shape.version_type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更新已有版本:不上传文件、不改 changelog,只改标题与兼容范围
|
||||||
|
export const UpdateInputSchema = PublishInputSchema.omit({
|
||||||
|
changelog: true,
|
||||||
|
versionType: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const ProgressEventSchema = z.object({
|
export const ProgressEventSchema = z.object({
|
||||||
platform: z.enum(platforms),
|
platform: z.enum(platforms),
|
||||||
current: z.number().int().min(0),
|
current: z.number().int().min(0),
|
||||||
@@ -58,6 +95,7 @@ export const PublishResultSchema = z.object(createRecord(
|
|||||||
// ── Inferred types ─────────────────────────────────────
|
// ── Inferred types ─────────────────────────────────────
|
||||||
|
|
||||||
export type PublishInput = z.infer<typeof PublishInputSchema>;
|
export type PublishInput = z.infer<typeof PublishInputSchema>;
|
||||||
|
export type UpdateInput = z.infer<typeof UpdateInputSchema>;
|
||||||
export type ProgressEvent = z.infer<typeof ProgressEventSchema>;
|
export type ProgressEvent = z.infer<typeof ProgressEventSchema>;
|
||||||
export type PlatformResult = z.infer<typeof PlatformResultSchema>;
|
export type PlatformResult = z.infer<typeof PlatformResultSchema>;
|
||||||
export type PublishResult = z.infer<typeof PublishResultSchema>;
|
export type PublishResult = z.infer<typeof PublishResultSchema>;
|
||||||
+182
-20
@@ -4,6 +4,7 @@ import { getConfig } from "./config";
|
|||||||
import { errorMessage } from "@/lib/utils/error";
|
import { errorMessage } from "@/lib/utils/error";
|
||||||
import {
|
import {
|
||||||
PublishInputSchema,
|
PublishInputSchema,
|
||||||
|
UpdateInputSchema,
|
||||||
type ProgressEvent,
|
type ProgressEvent,
|
||||||
type PlatformResult,
|
type PlatformResult,
|
||||||
type PublishResult,
|
type PublishResult,
|
||||||
@@ -11,11 +12,23 @@ import {
|
|||||||
type PlatformContext,
|
type PlatformContext,
|
||||||
type PlatformAdaptor,
|
type PlatformAdaptor,
|
||||||
type PublishInput,
|
type PublishInput,
|
||||||
|
type UpdateInput,
|
||||||
|
type ExistingRelease,
|
||||||
PublishResultSchema,
|
PublishResultSchema,
|
||||||
ProgressEventSchema,
|
ProgressEventSchema,
|
||||||
} from "./publish.schemas";
|
} from "./publish.schemas";
|
||||||
import { publishModrinthVersion } from "./platforms/modrinth";
|
import {
|
||||||
import { publishCurseForgeVersion } from "./platforms/curseforge";
|
publishModrinthVersion,
|
||||||
|
updateModrinthVersion,
|
||||||
|
listExistingModrinth,
|
||||||
|
modrinthMatchKey,
|
||||||
|
} from "./platforms/modrinth";
|
||||||
|
import {
|
||||||
|
publishCurseForgeVersion,
|
||||||
|
updateCurseForgeVersion,
|
||||||
|
listExistingCurseForge,
|
||||||
|
curseForgeMatchKey,
|
||||||
|
} from "./platforms/curseforge";
|
||||||
import { computeVersionRanges, type McVersionEntry } from "@/lib/utils/mcVersion";
|
import { computeVersionRanges, type McVersionEntry } from "@/lib/utils/mcVersion";
|
||||||
import { getCurseForgeMcVersions, getModrinthMcVersions } from "./meta";
|
import { getCurseForgeMcVersions, getModrinthMcVersions } from "./meta";
|
||||||
import { readAsFile } from "@/lib/utils/file";
|
import { readAsFile } from "@/lib/utils/file";
|
||||||
@@ -27,10 +40,16 @@ export const platformAdaptors: {
|
|||||||
} = {
|
} = {
|
||||||
modrinth: {
|
modrinth: {
|
||||||
publish: publishModrinthVersion,
|
publish: publishModrinthVersion,
|
||||||
|
update: updateModrinthVersion,
|
||||||
|
listExisting: listExistingModrinth,
|
||||||
|
matchKey: modrinthMatchKey,
|
||||||
getGameVersions: getModrinthMcVersions,
|
getGameVersions: getModrinthMcVersions,
|
||||||
},
|
},
|
||||||
curseforge: {
|
curseforge: {
|
||||||
publish: publishCurseForgeVersion,
|
publish: publishCurseForgeVersion,
|
||||||
|
update: updateCurseForgeVersion,
|
||||||
|
listExisting: listExistingCurseForge,
|
||||||
|
matchKey: curseForgeMatchKey,
|
||||||
getGameVersions: getCurseForgeMcVersions,
|
getGameVersions: getCurseForgeMcVersions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -45,6 +64,49 @@ const ee = new EventEmitter();
|
|||||||
|
|
||||||
// ── Platform orchestrator ──────────────────────────────
|
// ── Platform orchestrator ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算勾选版本的兼容范围条目(发布与更新流程共用)。
|
||||||
|
* 范围必须基于完整项目版本列表计算,勾选只决定处理哪些条目。
|
||||||
|
*/
|
||||||
|
async function computeSelectedEntries(
|
||||||
|
platform: typeof platforms[number],
|
||||||
|
selectedMcVersions: string[],
|
||||||
|
ctx: PlatformContext<UpdateInput>,
|
||||||
|
): Promise<McVersionEntry[]> {
|
||||||
|
const selected = new Set(selectedMcVersions);
|
||||||
|
const entries = computeVersionRanges(
|
||||||
|
ctx.input.allMcVersions,
|
||||||
|
await platformAdaptors[platform].getGameVersions(),
|
||||||
|
ctx.input.cutoffMcVersion
|
||||||
|
).filter((e) => selected.has(e.version));
|
||||||
|
if (entries.length !== selected.size) {
|
||||||
|
throw new Error("勾选版本与项目版本列表不一致,请刷新后重试");
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台准备阶段失败的兜底:记为该平台全部失败并发出 failed 事件,
|
||||||
|
* 否则前端进度条会停在 running 假象里,错误也被吞掉。 */
|
||||||
|
function failPlatformPreparation(
|
||||||
|
platform: typeof platforms[number],
|
||||||
|
total: number,
|
||||||
|
result: PlatformResult,
|
||||||
|
err: unknown,
|
||||||
|
logPrefix: string,
|
||||||
|
): void {
|
||||||
|
const msg = errorMessage(err);
|
||||||
|
console.error(`[${logPrefix}] ${platform} 准备阶段失败:`, err);
|
||||||
|
result.fail = total;
|
||||||
|
result.errors.push(msg);
|
||||||
|
ee.emit("progress", {
|
||||||
|
platform,
|
||||||
|
current: 0,
|
||||||
|
total,
|
||||||
|
status: "failed",
|
||||||
|
errors: result.errors,
|
||||||
|
} satisfies ProgressEvent);
|
||||||
|
}
|
||||||
|
|
||||||
async function runPlatform(
|
async function runPlatform(
|
||||||
platform: typeof platforms[number],
|
platform: typeof platforms[number],
|
||||||
mcVersions: PublishInput["mcVersions"][keyof PublishInput["mcVersions"]],
|
mcVersions: PublishInput["mcVersions"][keyof PublishInput["mcVersions"]],
|
||||||
@@ -74,30 +136,18 @@ async function runPlatform(
|
|||||||
} satisfies ProgressEvent);
|
} satisfies ProgressEvent);
|
||||||
|
|
||||||
const publishFn = platformAdaptors[platform].publish;
|
const publishFn = platformAdaptors[platform].publish;
|
||||||
const getGameVersionsFn = platformAdaptors[platform].getGameVersions;
|
|
||||||
|
|
||||||
// 平台准备阶段(元数据拉取 + 版本范围计算)。此处异常不在逐版本的
|
// 平台准备阶段(元数据拉取 + 版本范围计算)。此处异常不在逐版本的
|
||||||
// try/catch 内,必须单独兜底:记为该平台全部失败并发出 failed 事件,
|
// try/catch 内,必须单独兜底。
|
||||||
// 否则前端进度条会停在 running 假象里,错误也被吞掉。
|
|
||||||
let mcVersionEntries: McVersionEntry[];
|
let mcVersionEntries: McVersionEntry[];
|
||||||
try {
|
try {
|
||||||
mcVersionEntries = computeVersionRanges(
|
mcVersionEntries = await computeSelectedEntries(
|
||||||
mcVersions.map(v => v.mc_version),
|
platform,
|
||||||
await getGameVersionsFn(),
|
mcVersions.map((v) => v.mc_version),
|
||||||
ctx.input.cutoffMcVersion
|
ctx,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = errorMessage(err);
|
failPlatformPreparation(platform, total, result, err, "publish");
|
||||||
console.error(`[publish] ${platform} 准备阶段失败:`, err);
|
|
||||||
result.fail = total;
|
|
||||||
result.errors.push(msg);
|
|
||||||
ee.emit("progress", {
|
|
||||||
platform,
|
|
||||||
current: 0,
|
|
||||||
total,
|
|
||||||
status: "failed",
|
|
||||||
errors: result.errors,
|
|
||||||
} satisfies ProgressEvent);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +196,95 @@ async function runPlatform(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Update orchestrator ────────────────────────────────
|
||||||
|
|
||||||
|
async function runPlatformUpdate(
|
||||||
|
platform: typeof platforms[number],
|
||||||
|
mcVersions: UpdateInput["mcVersions"][keyof UpdateInput["mcVersions"]],
|
||||||
|
ctx: PlatformContext<UpdateInput>,
|
||||||
|
): Promise<PlatformResult> {
|
||||||
|
const result: PlatformResult = { success: 0, fail: 0, errors: [] };
|
||||||
|
const total = mcVersions.length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
ee.emit("progress", {
|
||||||
|
platform,
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
status: "completed",
|
||||||
|
errors: [],
|
||||||
|
} satisfies ProgressEvent);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
ee.emit("progress", {
|
||||||
|
platform,
|
||||||
|
current: 0,
|
||||||
|
total,
|
||||||
|
status: "running",
|
||||||
|
errors: [],
|
||||||
|
} satisfies ProgressEvent);
|
||||||
|
|
||||||
|
const adaptor = platformAdaptors[platform];
|
||||||
|
|
||||||
|
// 准备阶段(范围计算 + 已有版本列表拉取),异常处理同 runPlatform
|
||||||
|
let mcVersionEntries: McVersionEntry[];
|
||||||
|
let existingReleases: ExistingRelease[];
|
||||||
|
try {
|
||||||
|
mcVersionEntries = await computeSelectedEntries(
|
||||||
|
platform,
|
||||||
|
mcVersions.map((v) => v.mc_version),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
existingReleases = await adaptor.listExisting(ctx.config);
|
||||||
|
} catch (err) {
|
||||||
|
failPlatformPreparation(platform, total, result, err, "update");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < mcVersionEntries.length; i++) {
|
||||||
|
const mcVersionEntry = mcVersionEntries[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 服务端按模板重新匹配已有版本,不信任前端传入的任何 ID
|
||||||
|
const key = adaptor.matchKey(ctx.config, ctx.input.version, mcVersionEntry.version);
|
||||||
|
const existing = existingReleases.find((e) => e.matchKey === key);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`平台上未找到版本 "${key}",请改用发布流程`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await adaptor.update(ctx, mcVersionEntry, existing);
|
||||||
|
result.success++;
|
||||||
|
|
||||||
|
const isLast = i + 1 === total;
|
||||||
|
ee.emit("progress", {
|
||||||
|
platform,
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
status: isLast ? "completed" : "running",
|
||||||
|
errors: result.errors,
|
||||||
|
} satisfies ProgressEvent);
|
||||||
|
} catch (err) {
|
||||||
|
result.fail++;
|
||||||
|
const msg = `${mcVersionEntry.version}: ${errorMessage(err)}`;
|
||||||
|
result.errors.push(msg);
|
||||||
|
|
||||||
|
ee.emit("progress", {
|
||||||
|
platform,
|
||||||
|
current: i,
|
||||||
|
total,
|
||||||
|
status: "failed",
|
||||||
|
errors: result.errors,
|
||||||
|
} satisfies ProgressEvent);
|
||||||
|
|
||||||
|
// Cancel remaining for this platform only
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Router ─────────────────────────────────────────────
|
// ── Router ─────────────────────────────────────────────
|
||||||
|
|
||||||
export const appRouter = t.router({
|
export const appRouter = t.router({
|
||||||
@@ -172,6 +311,29 @@ export const appRouter = t.router({
|
|||||||
return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
|
return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
update: t.procedure
|
||||||
|
.input(UpdateInputSchema)
|
||||||
|
.mutation(async ({ input }): Promise<PublishResult> => {
|
||||||
|
const config = await getConfig(input.configFile);
|
||||||
|
|
||||||
|
const ctx: PlatformContext<UpdateInput> = { input, config };
|
||||||
|
|
||||||
|
const results = await Promise.all(platforms.map(async (platform) => {
|
||||||
|
try {
|
||||||
|
const result = await runPlatformUpdate(platform, input.mcVersions[platform], ctx);
|
||||||
|
return {platform, result};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[update] ${platform} 执行异常:`, err);
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
result: { success: 0, fail: 0, errors: [errorMessage(err)] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
|
||||||
|
}),
|
||||||
|
|
||||||
progress: t.procedure.subscription(async function* (opts) {
|
progress: t.procedure.subscription(async function* (opts) {
|
||||||
for await (const [data] of on(ee, "progress", {
|
for await (const [data] of on(ee, "progress", {
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { modrinthClient, curseforgeClient } from "./clients";
|
|||||||
import { listVersions } from "@/lib/modrinth";
|
import { listVersions } from "@/lib/modrinth";
|
||||||
import { getFiles } from "@/lib/curseforge";
|
import { getFiles } from "@/lib/curseforge";
|
||||||
import { Template } from "@/lib/utils/template";
|
import { Template } from "@/lib/utils/template";
|
||||||
|
import { listExistingModrinth } from "./platforms/modrinth";
|
||||||
|
import { listExistingCurseForge } from "./platforms/curseforge";
|
||||||
|
import type { ExistingRelease } from "./publish.schemas";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Modrinth 项目的全部已有版本(不含 changelog 以减小响应体)。
|
* 获取 Modrinth 项目的全部已有版本(不含 changelog 以减小响应体)。
|
||||||
@@ -60,6 +63,10 @@ export interface FileMatch {
|
|||||||
export interface VersionComparison {
|
export interface VersionComparison {
|
||||||
modrinth: Record<string, VersionMatch>;
|
modrinth: Record<string, VersionMatch>;
|
||||||
curseforge: Record<string, FileMatch>;
|
curseforge: Record<string, FileMatch>;
|
||||||
|
/** Modrinth 全量已有版本(含未匹配项),用于第一页展示 */
|
||||||
|
modrinthVersions: Version[];
|
||||||
|
/** CurseForge 全量已有文件(含未匹配项),用于第一页展示 */
|
||||||
|
curseforgeFiles: CurseForgeFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,7 +75,7 @@ export interface VersionComparison {
|
|||||||
* @param config 项目配置
|
* @param config 项目配置
|
||||||
* @param version 项目版本号(如 "1.0.0")
|
* @param version 项目版本号(如 "1.0.0")
|
||||||
* @param mc_versions 待发布的 MC 版本列表
|
* @param mc_versions 待发布的 MC 版本列表
|
||||||
* @returns 按 MC 版本分组的比对结果
|
* @returns 按 MC 版本分组的比对结果 + 两平台全量已有版本列表
|
||||||
*/
|
*/
|
||||||
export async function compareVersions(
|
export async function compareVersions(
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -83,7 +90,9 @@ export async function compareVersions(
|
|||||||
|
|
||||||
// ── Modrinth:按 version 模板生成 version_number 后匹配 ──
|
// ── Modrinth:按 version 模板生成 version_number 后匹配 ──
|
||||||
const modrinth: Record<string, VersionMatch> = {};
|
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) {
|
for (const mc_version of mc_versions) {
|
||||||
const generatedVersion = modrinthTmpl.format({ version, mc_version });
|
const generatedVersion = modrinthTmpl.format({ version, mc_version });
|
||||||
@@ -97,7 +106,9 @@ export async function compareVersions(
|
|||||||
|
|
||||||
// ── CurseForge:按 filename_format 模板生成文件名后匹配 ──
|
// ── CurseForge:按 filename_format 模板生成文件名后匹配 ──
|
||||||
const curseforge: Record<string, FileMatch> = {};
|
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) {
|
for (const mc_version of mc_versions) {
|
||||||
const generatedFilename = curseforgeTmpl.format({ version, mc_version });
|
const generatedFilename = curseforgeTmpl.format({ version, mc_version });
|
||||||
@@ -109,5 +120,23 @@ export async function compareVersions(
|
|||||||
: { exists: false };
|
: { exists: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { modrinth, curseforge, modrinthVersions, curseforgeFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 两平台已有版本/文件的规范化列表(更新版本流程用) */
|
||||||
|
export interface ExistingReleases {
|
||||||
|
modrinth: ExistingRelease[];
|
||||||
|
curseforge: ExistingRelease[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取两平台已有版本/文件的规范化列表,含平台内部 ID 与当前 MC 版本范围。
|
||||||
|
* 供“更新版本”弹窗展示 diff;服务端执行更新时会重新拉取,不信任此结果。
|
||||||
|
*/
|
||||||
|
export async function fetchExistingReleases(config: Config): Promise<ExistingReleases> {
|
||||||
|
const [modrinth, curseforge] = await Promise.all([
|
||||||
|
listExistingModrinth(config),
|
||||||
|
listExistingCurseForge(config),
|
||||||
|
]);
|
||||||
return { modrinth, curseforge };
|
return { modrinth, curseforge };
|
||||||
}
|
}
|
||||||
@@ -172,8 +172,10 @@ export const CurseForgeFileSchema = z.object({
|
|||||||
/** 文件模块列表 */
|
/** 文件模块列表 */
|
||||||
modules: z.array(FileModuleSchema),
|
modules: z.array(FileModuleSchema),
|
||||||
}).pick({
|
}).pick({
|
||||||
|
id: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
fileName: true,
|
fileName: true,
|
||||||
|
gameVersions: true,
|
||||||
});
|
});
|
||||||
export type CurseForgeFile = z.infer<typeof CurseForgeFileSchema>;
|
export type CurseForgeFile = z.infer<typeof CurseForgeFileSchema>;
|
||||||
// endregion
|
// endregion
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const MinecraftGameVersionSchema = z.object({
|
|||||||
/** 版本类型状态:1=Normal 2=Deleted */
|
/** 版本类型状态:1=Normal 2=Deleted */
|
||||||
gameVersionTypeStatus: z.union([z.literal(1), z.literal(2)]),
|
gameVersionTypeStatus: z.union([z.literal(1), z.literal(2)]),
|
||||||
}).pick({
|
}).pick({
|
||||||
|
gameVersionId: true,
|
||||||
versionString: true,
|
versionString: true,
|
||||||
});
|
});
|
||||||
export type MinecraftGameVersion = z.infer<typeof MinecraftGameVersionSchema>;
|
export type MinecraftGameVersion = z.infer<typeof MinecraftGameVersionSchema>;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const RelationProjectSchema = z.object({
|
|||||||
/** 关联项目的 slug */
|
/** 关联项目的 slug */
|
||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
/** 关联项目的 ID(精确匹配),可选 */
|
/** 关联项目的 ID(精确匹配),可选 */
|
||||||
projectID: z.string().optional(),
|
projectID: z.number().int().positive().optional(),
|
||||||
/** 关联类型 */
|
/** 关联类型 */
|
||||||
type: UploadRelationTypeSchema,
|
type: UploadRelationTypeSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,6 +34,13 @@ export {
|
|||||||
type CreateVersionBody,
|
type CreateVersionBody,
|
||||||
} from "./create-version";
|
} from "./create-version";
|
||||||
|
|
||||||
|
export {
|
||||||
|
EditableFileTypeSchema,
|
||||||
|
EditableVersionSchema,
|
||||||
|
type EditableFileType,
|
||||||
|
type EditableVersion,
|
||||||
|
} from "./edit-version";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ProjectLicenseSchema,
|
ProjectLicenseSchema,
|
||||||
ProjectDonationURLSchema,
|
ProjectDonationURLSchema,
|
||||||
|
|||||||
@@ -114,8 +114,10 @@ export const VersionSchema = BaseVersionSchema.extend({
|
|||||||
/** 该版本的所有文件列表 */
|
/** 该版本的所有文件列表 */
|
||||||
files: z.array(VersionFileSchema),
|
files: z.array(VersionFileSchema),
|
||||||
}).pick({
|
}).pick({
|
||||||
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
version_number: true,
|
version_number: true,
|
||||||
|
game_versions: true,
|
||||||
});
|
});
|
||||||
export type Version = z.infer<typeof VersionSchema>;
|
export type Version = z.infer<typeof VersionSchema>;
|
||||||
// endregion
|
// endregion
|
||||||
Reference in new issue
Block a user