From f85c7bfb6f6b4201f29b1f375d0611f790b83ea5 Mon Sep 17 00:00:00 2001 From: huimanQAQ Date: Tue, 27 Oct 2026 08:39:27 +0800 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E4=BA=86?= =?UTF-8?q?=E4=B9=90=E6=9B=B2=E5=88=87=E6=8D=A2=E6=B5=81=E6=B4=BE=E8=87=B3?= =?UTF-8?q?=E5=AE=B4=E4=BC=9A=E5=9C=BA=E7=9A=84=E8=87=AA=E5=8A=A8=E6=9B=B4?= =?UTF-8?q?=E6=94=B9id=E5=B9=B6=E5=88=87=E6=8D=A2=E9=93=BA=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 乐曲流派切换至宴会场时二次确认,自动把乐曲 ID 改为原 ID + 100000(仅原 ID 小于 100000 时) - 把唯一启用的难度谱面转换为 Basic 谱面:交换两个槽位的谱面文件,原难度的等级/定数/谱师/音符数搬到 Basic 槽位并启用 Basic,原难度禁用(新增 ConvertSingleChartToBasicApi) - 本来就只有 Basic 启用时不再提示转换谱面 - 启用多个难度 / 没有启用谱面 / 目标 ID 已被占用时给出提示并回滚流派 - 流派由宴会场切回其他流派时提示并把 ID 减 100000 --- .../Controllers/Music/MusicController.cs | 61 +++++++- MaiChartManager/Front/src/client/apiGen.ts | 19 +++ MaiChartManager/Front/src/locales/en.yaml | 12 ++ MaiChartManager/Front/src/locales/zh-TW.yaml | 12 ++ MaiChartManager/Front/src/locales/zh.yaml | 12 ++ .../src/views/Charts/MusicEdit/index.tsx | 146 +++++++++++++++++- 6 files changed, 258 insertions(+), 4 deletions(-) diff --git a/MaiChartManager/Controllers/Music/MusicController.cs b/MaiChartManager/Controllers/Music/MusicController.cs index 54fca47f..40bff1f5 100644 --- a/MaiChartManager/Controllers/Music/MusicController.cs +++ b/MaiChartManager/Controllers/Music/MusicController.cs @@ -121,6 +121,65 @@ public void EditMusicLong(int id, [FromBody] bool value, string assetDir) } } + /// + /// 把唯一启用的难度谱面转换成 Basic(绿谱)谱面。 + /// 宴会场只识别 Basic 槽位的谱面,所以把普通乐曲改成宴会场之前需要先把选中的难度整体挪到 Basic 槽位。 + /// 返回空字符串表示成功,否则是错误信息。 + /// + [HttpPost] + public string ConvertSingleChartToBasic(int id, string assetDir) + { + var music = settings.GetMusic(id, assetDir); + if (music is null) return "Music not found"; + + var enabledIndexes = Enumerable.Range(0, 5).Where(index => music.Charts[index].Enable).ToList(); + if (enabledIndexes.Count == 0) return "No enabled chart"; + if (enabledIndexes.Count > 1) return "Multiple charts enabled"; + + var sourceIndex = enabledIndexes[0]; + if (sourceIndex == 0) return ""; // 已经是 Basic 谱面,无需转换 + + var musicDir = Path.GetDirectoryName(music.FilePath); + if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir)) return "Invalid music directory"; + + var sourceChart = music.Charts[sourceIndex]; + var targetChart = music.Charts[0]; + + // 交换两个槽位的谱面文件:源难度的谱面挪到 Basic 的文件名(_00), + // 旧的 Basic 谱面挪到源难度的文件名,这样两个槽位的 path 都仍然指向真实存在的文件。 + // 注意:path 本身不动,槽位还是指向自己原来的文件名。 + foreach (var suffix in new[] { "", "_L", "_R" }) + { + var sourcePath = Path.Combine(musicDir, sourceChart.Path.Replace(".ma2", suffix + ".ma2")); + var targetPath = Path.Combine(musicDir, targetChart.Path.Replace(".ma2", suffix + ".ma2")); + if (!System.IO.File.Exists(sourcePath)) continue; + var tempPath = sourcePath + ".mcmswap"; + PlatformFile.MoveFile(sourcePath, tempPath); + if (System.IO.File.Exists(targetPath)) PlatformFile.MoveFile(targetPath, sourcePath); + PlatformFile.MoveFile(tempPath, targetPath); + } + + // 把原难度的谱面信息搬到 Basic 槽位并启用 Basic,原难度禁用 + var level = sourceChart.Level; + var levelDecimal = sourceChart.LevelDecimal; + var levelId = sourceChart.LevelId; + var maxNotes = sourceChart.MaxNotes; + var designer = sourceChart.Designer; + + targetChart.Level = level; + targetChart.LevelDecimal = levelDecimal; + targetChart.LevelId = levelId; + targetChart.MaxNotes = maxNotes; + targetChart.Designer = designer ?? ""; + targetChart.Enable = true; + + sourceChart.Enable = false; + + music.Save(); + music.Refresh(); + return ""; + } + [HttpPost] public void SaveMusic(int id, string assetDir) { @@ -229,4 +288,4 @@ public void RequestOpenXml(int id, string assetDir) }); } } -} \ No newline at end of file +} diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index c280581e..39daab01 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -2237,6 +2237,25 @@ export class Api< ...params, }), + /** + * No description + * + * @tags Music + * @name ConvertSingleChartToBasic + * @request POST:/MaiChartManagerServlet/ConvertSingleChartToBasicApi/{assetDir}/{id} + */ + ConvertSingleChartToBasic: ( + id: number, + assetDir: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/MaiChartManagerServlet/ConvertSingleChartToBasicApi/${assetDir}/${id}`, + method: "POST", + format: "json", + ...params, + }), + /** * No description * diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 76bf0f81..13215bca 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -135,6 +135,18 @@ music: audioPreviewEnd: End Time (s) includeB35: Include in B35 includeB15: Include in B15 + utageConvertTitle: Change genre to Utage + utageConvertConfirmPlain: The genre will be changed to Utage. Continue? + utageConvertConfirmIdOnly: The genre will be changed to Utage, and the music ID will be changed from {oldId} to {newId}. Continue? + utageConvertConfirmChartOnly: The genre will be changed to Utage, and the enabled {difficulty} chart will be converted to a Basic chart. Continue? + utageConvertConfirmBoth: The genre will be changed to Utage, the music ID will be changed from {oldId} to {newId}, and the enabled {difficulty} chart will be converted to a Basic chart. Continue? + utageConvertMultipleCharts: Multiple difficulties are enabled. Please disable all but one before converting. + utageConvertNoChart: No chart is enabled. Please enable one before converting. + utageConvertIdExists: ID {newId} is already taken. Please delete that music first or use another ID. + utageConvertFailed: Failed to change to Utage + utageRevertTitle: Change music genre + utageRevertConfirm: The genre will be changed from Utage to {genreName}, and the music ID will be changed from {oldId} to {newId}. Continue? + utageRevertFailed: Failed to change genre from Utage versionHint: >- If game version is 1.{gameVersion}, numbers >= {b15ver} will appear in B15, otherwise in B35. This value also controls the order when sorting by diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index bec90c6a..d02377a8 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -127,6 +127,18 @@ music: audioPreviewEnd: 終點時間(秒) includeB35: 計入 B35 includeB15: 計入 B15 + utageConvertTitle: 樂曲流派更改為宴會場 + utageConvertConfirmPlain: 樂曲流派將更改為宴會場。是否繼續? + utageConvertConfirmIdOnly: 樂曲流派將更改為宴會場,並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? + utageConvertConfirmChartOnly: 樂曲流派將更改為宴會場,並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? + utageConvertConfirmBoth: 樂曲流派將更改為宴會場,把樂曲 ID 從 {oldId} 改為 {newId},並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? + utageConvertMultipleCharts: 譜面有多個難度,請禁用一個難度再進行轉換 + utageConvertNoChart: 目前沒有啟用的譜面,請先啟用一個難度再進行轉換 + utageConvertIdExists: ID {newId} 已被佔用,請先刪除該樂曲或改用其他 ID + utageConvertFailed: 變更為宴會場失敗 + utageRevertTitle: 樂曲流派變更 + utageRevertConfirm: 樂曲流派將從宴會場變更為 {genreName},並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? + utageRevertFailed: 從宴會場變更流派失敗 versionHint: >- 如果遊戲版本是 1.{gameVersion} 的話,這裡的數字大於等於 {b15ver} 就會讓歌出現在 B15 裡面,否則就會出現在 B35 裡面。這個值還會控制按上線時間排序時的順序 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index ee8230a5..4e4e2517 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -127,6 +127,18 @@ music: audioPreviewEnd: 终点时间(秒) includeB35: 计入 B35 includeB15: 计入 B15 + utageConvertTitle: 乐曲流派更改为宴会场 + utageConvertConfirmPlain: 乐曲流派将更改为宴会场。是否继续? + utageConvertConfirmIdOnly: 乐曲流派将更改为宴会场,并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? + utageConvertConfirmChartOnly: 乐曲流派将更改为宴会场,并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? + utageConvertConfirmBoth: 乐曲流派将更改为宴会场,把乐曲 ID 从 {oldId} 改为 {newId},并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? + utageConvertMultipleCharts: 铺面有多个难度,请禁用一个难度再进行转换 + utageConvertNoChart: 当前没有启用的谱面,请先启用一个难度再进行转换 + utageConvertIdExists: ID {newId} 已被占用,请先删除该乐曲或改用其他 ID + utageConvertFailed: 更改为宴会场失败 + utageRevertTitle: 乐曲流派更改 + utageRevertConfirm: 乐曲流派将从宴会场更改为 {genreName},并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? + utageRevertFailed: 从宴会场更改流派失败 versionHint: >- 如果游戏版本是 1.{gameVersion} 的话,这里的数字大于等于 {b15ver} 就会让歌出现在 B15 里面,否则就会出现在 B35 里面。这个值还会控制按上线时间排序时的顺序 diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index 7692d4a4..c782b2f7 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -1,5 +1,5 @@ import { computed, defineComponent, onMounted, PropType, provide, ref, watch } from "vue"; -import { addVersionList, genreList, globalCapture, selectedADir, selectedMusic as info, selectMusicId, updateAddVersionList, updateGenreList, updateMusicList, selectedLevel, disableSync, gameVersion, b15ver } from "@/store/refs"; +import { addVersionList, genreList, globalCapture, musicList, selectedADir, selectedMusic as info, selectMusicId, updateAddVersionList, updateGenreList, updateMusicList, selectedLevel, disableSync, gameVersion, b15ver } from "@/store/refs"; import type { Chart, MusicXmlWithABJacket } from "@/client/apiGen"; import api from "@/client/api"; @@ -16,7 +16,7 @@ import { captureException } from "@sentry/vue" import noJacket from "@/assets/noJacket.webp"; import { getUrl } from "@/client/api"; import { t } from "@/locales"; -import { CheckBox, NumberInput, TextInput, Tabs, TabPane, Popover } from "@munet/ui"; +import { CheckBox, NumberInput, TextInput, Tabs, TabPane, Popover, showTransactionalDialog } from "@munet/ui"; import { NTabPane, NTabs } from "naive-ui"; import styles from './index.module.sass'; @@ -44,11 +44,151 @@ const Component = defineComponent({ } } + // 已启用的难度,正常情况下应该只有一个 + const enabledChartIndexes = computed(() => (info.value?.charts ?? []) + .slice(0, DIFFICULTY.length) + .map((chart, index) => chart?.enable ? index : -1) + .filter(index => index >= 0)); + + // 取消或失败时,把下拉框里的流派恢复原样 + const revertGenre = (value?: number) => { + if (!info.value) return; + disableSync.value = true; + info.value.genreId = value; + // 让 watch 先忽略掉这次回滚,下一帧再恢复同步 + setTimeout(() => disableSync.value = false); + }; + + // 宴会场只认 Basic(绿)谱面:切换到宴会场时顺带改 ID,并把唯一启用的难度谱面转成 Basic + const changeGenreToUtage = async (oldGenreId?: number) => { + if (!info.value) return; + const enabledIndexes = enabledChartIndexes.value; + + if (enabledIndexes.length !== 1) { + await showTransactionalDialog( + t('music.edit.utageConvertTitle'), + enabledIndexes.length === 0 + ? t('music.edit.utageConvertNoChart') + : t('music.edit.utageConvertMultipleCharts'), + undefined, true + ); + revertGenre(oldGenreId); + return; + } + + const oldId = info.value.id!; + const newId = oldId < 1e5 ? oldId + 1e5 : oldId; + const needChangeId = newId !== oldId; + + // 改 ID 时目标 ID 上的乐曲会被删掉,先确认没有同 ID 的乐曲,避免误删 + if (needChangeId && musicList.value.some(music => music.id === newId)) { + await showTransactionalDialog( + t('music.edit.utageConvertTitle'), + t('music.edit.utageConvertIdExists', { newId }), + undefined, true + ); + revertGenre(oldGenreId); + return; + } + + const enabledIndex = enabledIndexes[0]; + const difficulty = DIFFICULTY[enabledIndex]; + // 本来就只有 Basic 启用的话,不需要再提「把 Basic 转为 Basic」 + const needConvertChart = enabledIndex !== 0; + const messageKey = needChangeId + ? (needConvertChart ? 'utageConvertConfirmBoth' : 'utageConvertConfirmIdOnly') + : (needConvertChart ? 'utageConvertConfirmChartOnly' : 'utageConvertConfirmPlain'); + const confirmed = await showTransactionalDialog( + t('music.edit.utageConvertTitle'), + t(`music.edit.${messageKey}`, { oldId, newId, difficulty }), + [{ text: t('common.cancel'), action: false }, { text: t('common.confirm'), action: true }] + ); + if (!confirmed) { + revertGenre(oldGenreId); + return; + } + + try { + if (needConvertChart) { + const error = (await api.ConvertSingleChartToBasic(oldId, selectedADir.value)).data; + if (error) throw new Error(error); + } + if (needChangeId) { + await api.ModifyId(oldId, selectedADir.value, newId); + } + await api.EditMusicGenre(newId, selectedADir.value, UTAGE_GENRE); + await api.SaveMusic(newId, selectedADir.value); + await updateMusicList(); + if (needChangeId) selectMusicId.value = newId; + } catch (e) { + revertGenre(oldGenreId); + await globalCapture(e, t('music.edit.utageConvertFailed')); + } + }; + + // 从宴会场切回其他流派:宴会场 ID 需要减 100000 + const revertGenreFromUtage = async (newGenreId: number, oldGenreId?: number) => { + if (!info.value) return; + const oldId = info.value.id!; + const newId = oldId > 1e5 ? oldId - 1e5 : oldId; + + // 不需要改 ID 时,保持原来的普通修改流程 + if (newId === oldId) { + info.value.modified = true; + await api.EditMusicGenre(oldId, info.value.assetDir!, newGenreId); + return; + } + + if (musicList.value.some(music => music.id === newId)) { + await showTransactionalDialog( + t('music.edit.utageRevertTitle'), + t('music.edit.utageConvertIdExists', { newId }), + undefined, true + ); + revertGenre(oldGenreId); + return; + } + + const genreName = genreList.value.find(genre => genre.id === newGenreId)?.genreName ?? ''; + const confirmed = await showTransactionalDialog( + t('music.edit.utageRevertTitle'), + t('music.edit.utageRevertConfirm', { genreName, oldId, newId }), + [{ text: t('common.cancel'), action: false }, { text: t('common.confirm'), action: true }] + ); + if (!confirmed) { + revertGenre(oldGenreId); + return; + } + + try { + await api.ModifyId(oldId, selectedADir.value, newId); + await api.EditMusicGenre(newId, selectedADir.value, newGenreId); + await api.SaveMusic(newId, selectedADir.value); + await updateMusicList(); + selectMusicId.value = newId; + } catch (e) { + revertGenre(oldGenreId); + await globalCapture(e, t('music.edit.utageRevertFailed')); + } + }; + watch(() => info.value?.name, sync('name', api.EditMusicName)); watch(() => info.value?.artist, sync('artist', api.EditMusicArtist)); watch(() => info.value?.bpm, sync('bpm', api.EditMusicBpm)); watch(() => info.value?.version, sync('version', api.EditMusicVersion)); - watch(() => info.value?.genreId, sync('genreId', api.EditMusicGenre)); + watch(() => info.value?.genreId, async (value, oldValue) => { + if (disableSync.value || !info.value || value === undefined) return; + if (value === UTAGE_GENRE && oldValue !== UTAGE_GENRE) { + await changeGenreToUtage(oldValue); + return; + } + if (oldValue === UTAGE_GENRE && value !== UTAGE_GENRE) { + await revertGenreFromUtage(value, oldValue); + return; + } + info.value.modified = true; + await api.EditMusicGenre(info.value.id!, info.value.assetDir!, value); + }); watch(() => info.value?.addVersionId, sync('addVersionId', api.EditMusicAddVersion)); watch(() => info.value?.utageKanji, sync('utageKanji', api.EditMusicUtageKanji)); watch(() => info.value?.comment, sync('comment', api.EditMusicComment)); From 566359cc95049a3a66535f476d0537aa1e1b0378 Mon Sep 17 00:00:00 2001 From: huimanQAQ Date: Tue, 27 Oct 2026 09:02:53 +0800 Subject: [PATCH 02/22] =?UTF-8?q?fix:=20=E6=B5=81=E6=B4=BE=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=94=B9=E4=B8=BA=E5=90=8E=E7=AB=AF=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=BF=AE=E6=AD=A3=E5=AE=B4=E4=BC=9A?= =?UTF-8?q?=E5=9C=BA=E8=BD=AC=E6=8D=A2=E7=9A=84=E6=96=87=E4=BB=B6=E5=A4=84?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E6=8F=90=E7=A4=BA=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 谱面转换、ID 变更(±100000)、流派变更合并到 ChangeMusicGenreApi,失败时回滚谱面转换,避免半改状态 - 抽出 ModifyIdCore,供 ModifyId 与流派切换复用 - 转换前校验:统计全部 6 个谱面槽位、源难度必须有谱面文件、双人谱(L/R)直接拒绝并提示 - 谱面文件交换改为临时名三段式 + 移动日志 + 逆序回滚,失败不再留下改了一半的 XML - ID 冲突只在当前资源目录内判断,冲突时提示是否覆盖该 ID 上的乐曲 - 文案:多难度改为「只保留一个启用的难度」,ID 占用提示不再让用户「改用其他 ID」 --- .../Controllers/Music/MusicController.cs | 59 ------- .../Music/MusicTransferController.cs | 121 ++++++++++++- .../Controllers/Music/UtageConverter.cs | 163 ++++++++++++++++++ MaiChartManager/Front/src/client/apiGen.ts | 54 ++++-- MaiChartManager/Front/src/locales/en.yaml | 6 +- MaiChartManager/Front/src/locales/zh-TW.yaml | 6 +- MaiChartManager/Front/src/locales/zh.yaml | 6 +- .../src/views/Charts/MusicEdit/index.tsx | 87 ++++++---- 8 files changed, 378 insertions(+), 124 deletions(-) create mode 100644 MaiChartManager/Controllers/Music/UtageConverter.cs diff --git a/MaiChartManager/Controllers/Music/MusicController.cs b/MaiChartManager/Controllers/Music/MusicController.cs index 40bff1f5..cb69340b 100644 --- a/MaiChartManager/Controllers/Music/MusicController.cs +++ b/MaiChartManager/Controllers/Music/MusicController.cs @@ -121,65 +121,6 @@ public void EditMusicLong(int id, [FromBody] bool value, string assetDir) } } - /// - /// 把唯一启用的难度谱面转换成 Basic(绿谱)谱面。 - /// 宴会场只识别 Basic 槽位的谱面,所以把普通乐曲改成宴会场之前需要先把选中的难度整体挪到 Basic 槽位。 - /// 返回空字符串表示成功,否则是错误信息。 - /// - [HttpPost] - public string ConvertSingleChartToBasic(int id, string assetDir) - { - var music = settings.GetMusic(id, assetDir); - if (music is null) return "Music not found"; - - var enabledIndexes = Enumerable.Range(0, 5).Where(index => music.Charts[index].Enable).ToList(); - if (enabledIndexes.Count == 0) return "No enabled chart"; - if (enabledIndexes.Count > 1) return "Multiple charts enabled"; - - var sourceIndex = enabledIndexes[0]; - if (sourceIndex == 0) return ""; // 已经是 Basic 谱面,无需转换 - - var musicDir = Path.GetDirectoryName(music.FilePath); - if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir)) return "Invalid music directory"; - - var sourceChart = music.Charts[sourceIndex]; - var targetChart = music.Charts[0]; - - // 交换两个槽位的谱面文件:源难度的谱面挪到 Basic 的文件名(_00), - // 旧的 Basic 谱面挪到源难度的文件名,这样两个槽位的 path 都仍然指向真实存在的文件。 - // 注意:path 本身不动,槽位还是指向自己原来的文件名。 - foreach (var suffix in new[] { "", "_L", "_R" }) - { - var sourcePath = Path.Combine(musicDir, sourceChart.Path.Replace(".ma2", suffix + ".ma2")); - var targetPath = Path.Combine(musicDir, targetChart.Path.Replace(".ma2", suffix + ".ma2")); - if (!System.IO.File.Exists(sourcePath)) continue; - var tempPath = sourcePath + ".mcmswap"; - PlatformFile.MoveFile(sourcePath, tempPath); - if (System.IO.File.Exists(targetPath)) PlatformFile.MoveFile(targetPath, sourcePath); - PlatformFile.MoveFile(tempPath, targetPath); - } - - // 把原难度的谱面信息搬到 Basic 槽位并启用 Basic,原难度禁用 - var level = sourceChart.Level; - var levelDecimal = sourceChart.LevelDecimal; - var levelId = sourceChart.LevelId; - var maxNotes = sourceChart.MaxNotes; - var designer = sourceChart.Designer; - - targetChart.Level = level; - targetChart.LevelDecimal = levelDecimal; - targetChart.LevelId = levelId; - targetChart.MaxNotes = maxNotes; - targetChart.Designer = designer ?? ""; - targetChart.Enable = true; - - sourceChart.Enable = false; - - music.Save(); - music.Refresh(); - return ""; - } - [HttpPost] public void SaveMusic(int id, string assetDir) { diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 5754761a..806c61dd 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -22,6 +22,8 @@ public partial class MusicTransferController( IProgressController progressController) : ControllerBase { public record RequestCopyToRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool removeEvents, bool legacyFormat); + public record GenreChangeResult(bool Success, string? Error, int NewId); + public record GenreChangeRequest(int GenreId, bool Overwrite = false); public enum MaidataSubdirMode { @@ -583,15 +585,24 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) #endif var music = settings.GetMusic(id, assetDir); if (music is null) return; + var error = await ModifyIdCore(music, newId, assetDir); + if (error is not null) throw new InvalidOperationException(error); + } + + /// + /// 改乐曲 ID 的实现。返回 null 表示成功,否则是错误信息。 + /// + private async Task ModifyIdCore(MusicXmlWithABJacket music, int newId, string assetDir) + { var musicDir = Path.GetDirectoryName(music.FilePath); if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir)) { var message = $"Invalid source directory for music {music.Id}: {music.FilePath}"; logger.LogError("{message}", message); - throw new DirectoryNotFoundException(message); + return message; } - if (music.Id == newId) return; - + if (music.Id == newId) return null; + var newMusicDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "music", $"music{newId:000000}"); DeleteIfExists(newMusicDir); @@ -599,7 +610,7 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) // 谱面 var oldMusicDir = Path.GetDirectoryName(music.FilePath)!; - for (var i = 0; i < 6; i++) + for (var i = 0; i < music.Charts.Length; i++) { var chart = music.Charts[i]; if (!chart.Enable) continue; @@ -619,6 +630,106 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) // 重新扫描全部 await settings.RescanAll(); + return null; + } + + /// + /// 切换乐曲流派。切到/切回宴会场时,ID 变更(±100000)和谱面转换都在这个接口里一次做完, + /// 避免前端分步调用留下半改状态。返回 Error 为 null 表示成功,NewId 是处理后的乐曲 ID。 + /// + [HttpPost] + public async Task ChangeMusicGenre(int id, [FromBody] GenreChangeRequest request, string assetDir) + { + var genreId = request.GenreId; + var music = settings.GetMusic(id, assetDir); + if (music is null) return new GenreChangeResult(false, GenreChangeError.MusicNotFound, id); + + var wasUtage = music.GenreId == UtageConverter.UtageGenreId; + var toUtage = genreId == UtageConverter.UtageGenreId; + + // 和宴会场无关的流派切换保持原来的行为:只改流派 + if (wasUtage == toUtage) + { + music.GenreId = genreId; + music.Save(); + return new GenreChangeResult(true, null, id); + } + + var newId = id; + var chartIndex = -1; + if (toUtage) + { + // 宴会场只认 Basic 谱面,先确认只有一个启用的难度 + var error = UtageConverter.FindSingleEnabledChart(music, out chartIndex); + if (error is not null) return new GenreChangeResult(false, error, id); + newId = id < UtageConverter.UtageIdOffset ? id + UtageConverter.UtageIdOffset : id; + } + else + { + newId = id > UtageConverter.UtageIdOffset ? id - UtageConverter.UtageIdOffset : id; + } + + if (newId != id) + { +#if WINDOWS + if (IapManager.License != IapManager.LicenseStatus.Active) + return new GenreChangeResult(false, GenreChangeError.LicenseRequired, id); +#endif + // 只检查当前资源目录;其他目录(其他包)里同 ID 的乐曲互不影响 + if (!request.Overwrite && settings.GetMusic(newId, assetDir) is not null) + return new GenreChangeResult(false, GenreChangeError.TargetIdExists, id); + } + + var converted = false; + try + { + if (toUtage && chartIndex > 0) + { + var error = UtageConverter.MoveToBasic(music, chartIndex, logger); + if (error is not null) return new GenreChangeResult(false, error, id); + converted = true; + } + + var targetId = id; + if (newId != id) + { + var error = await ModifyIdCore(music, newId, assetDir); + if (error is not null) + { + if (converted) UtageConverter.RestoreFromBasic(music, chartIndex, logger); + logger.LogError("切换流派时改 ID 失败: {error}", error); + return new GenreChangeResult(false, GenreChangeError.InvalidMusicDirectory, id); + } + targetId = newId; + } + + var target = settings.GetMusic(targetId, assetDir); + if (target is null) + { + if (converted) UtageConverter.RestoreFromBasic(music, chartIndex, logger); + return new GenreChangeResult(false, GenreChangeError.MusicNotFound, id); + } + + target.GenreId = genreId; + target.Save(); + return new GenreChangeResult(true, null, targetId); + } + catch (Exception e) + { + logger.LogError(e, "切换流派失败: {id} -> {genreId}", id, genreId); + if (converted) + { + try + { + UtageConverter.RestoreFromBasic(music, chartIndex, logger); + } + catch (Exception rollbackError) + { + logger.LogError(rollbackError, "回滚谱面转换失败"); + } + } + return new GenreChangeResult(false, GenreChangeError.Unexpected, id); + } } [HttpGet] @@ -950,4 +1061,4 @@ public async Task RequestExportMaidata([FromBody] RequestExportMaidataRequest re [GeneratedRegex(@"VERSION\t0.00.00\t1.(\d\d).00")] private static partial Regex MA2VersionRegex(); -} \ No newline at end of file +} diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/UtageConverter.cs new file mode 100644 index 00000000..568c3876 --- /dev/null +++ b/MaiChartManager/Controllers/Music/UtageConverter.cs @@ -0,0 +1,163 @@ +using MaiChartManager.Models; +using MaiChartManager.Platform; +using MusicXml = MaiChartManager.Models.MusicXml; + +namespace MaiChartManager.Controllers.Music; + +/// +/// 流派切换接口的错误码,前端据此显示本地化文案。 +/// +internal static class GenreChangeError +{ + public const string MusicNotFound = "music-not-found"; + public const string NoEnabledChart = "no-enabled-chart"; + public const string MultipleEnabledCharts = "multiple-enabled-charts"; + public const string DoublePlayerChart = "double-player-chart"; + public const string TargetIdExists = "target-id-exists"; + public const string LicenseRequired = "license-required"; + public const string InvalidMusicDirectory = "invalid-music-directory"; + public const string ChartFileNotFound = "chart-file-not-found"; + public const string SwapFailed = "swap-failed"; + public const string Unexpected = "unexpected"; +} + +/// +/// 乐曲在「宴会场」与其他流派之间切换时的谱面转换工具。 +/// 宴会场只识别 Basic(绿谱)槽位的谱面,所以切到宴会场时要把唯一启用的难度整体挪到 Basic 槽位。 +/// +internal static class UtageConverter +{ + public const int UtageGenreId = 107; + public const int UtageIdOffset = 100000; + + /// + /// 找出唯一启用的难度槽位。返回 null 表示通过,否则是错误码。 + /// + public static string? FindSingleEnabledChart(MusicXmlWithABJacket music, out int index) + { + index = -1; + var enabledIndexes = new List(); + for (var i = 0; i < music.Charts.Length; i++) + { + if (music.Charts[i].Enable) enabledIndexes.Add(i); + } + + if (enabledIndexes.Count == 0) return GenreChangeError.NoEnabledChart; + if (enabledIndexes.Count > 1) return GenreChangeError.MultipleEnabledCharts; + + index = enabledIndexes[0]; + return null; + } + + /// + /// 把 sourceIndex 槽位的谱面搬到 Basic 槽位,并把原难度禁用。已是 Basic 时不做任何事。 + /// 返回 null 表示成功,否则是错误码。 + /// + public static string? MoveToBasic(MusicXmlWithABJacket music, int sourceIndex, ILogger logger) + => SwapWithBasic(music, sourceIndex, sourceIsEnabled: false, logger); + + /// + /// MoveToBasic 的逆操作,用于失败时回滚。 + /// + public static string? RestoreFromBasic(MusicXmlWithABJacket music, int sourceIndex, ILogger logger) + => SwapWithBasic(music, sourceIndex, sourceIsEnabled: true, logger); + + private static string? SwapWithBasic(MusicXmlWithABJacket music, int sourceIndex, bool sourceIsEnabled, + ILogger logger) + { + if (sourceIndex <= 0 || sourceIndex >= music.Charts.Length) return null; + + var musicDir = Path.GetDirectoryName(music.FilePath); + if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir)) + return GenreChangeError.InvalidMusicDirectory; + + var basicChart = music.Charts[0]; + var otherChart = music.Charts[sourceIndex]; + + // 宴会场特有的双人谱(_L / _R)不能这样转,需要先改成普通宴会场乐曲 + if (HasSideChart(musicDir, otherChart.Path)) return GenreChangeError.DoublePlayerChart; + // 源难度必须真的有谱面文件,否则转换后 Basic 的内容会和 XML 对不上 + if (!File.Exists(Path.Combine(musicDir, otherChart.Path))) return GenreChangeError.ChartFileNotFound; + + var error = SwapChartFiles(musicDir, basicChart.Path, otherChart.Path, logger); + if (error is not null) return error; + + // path 不动(槽位仍指向自己原来的文件名),只互换谱面信息 + SwapChartInfo(basicChart, otherChart); + basicChart.Enable = !sourceIsEnabled; + otherChart.Enable = sourceIsEnabled; + + music.Save(); + music.Refresh(); + return null; + } + + private static bool HasSideChart(string musicDir, string path) + => File.Exists(Path.Combine(musicDir, WithSuffix(path, "_L"))) + || File.Exists(Path.Combine(musicDir, WithSuffix(path, "_R"))); + + private static void SwapChartInfo(MusicXml.Chart a, MusicXml.Chart b) + { + (a.Level, b.Level) = (b.Level, a.Level); + (a.LevelDecimal, b.LevelDecimal) = (b.LevelDecimal, a.LevelDecimal); + (a.LevelId, b.LevelId) = (b.LevelId, a.LevelId); + (a.MaxNotes, b.MaxNotes) = (b.MaxNotes, a.MaxNotes); + (a.Designer, b.Designer) = (b.Designer, a.Designer); + } + + /// + /// 互换两个槽位的谱面文件:源难度的谱面挪到 Basic 的文件名,旧的 Basic 谱面挪到源难度的文件名, + /// 保证交换后两个槽位的 path 都仍然指向真实存在的文件。 + /// 任意一步失败都会按逆序回滚已经做过的移动。 + /// + private static string? SwapChartFiles(string musicDir, string basicPath, string otherPath, ILogger logger) + { + var basicFile = Path.Combine(musicDir, basicPath); + var otherFile = Path.Combine(musicDir, otherPath); + var tempFile = Path.Combine(musicDir, $".mcmswap-{Guid.NewGuid():N}.tmp"); + + var journal = new List<(string From, string To)>(); + + try + { + // 1. 源谱面先挪到临时名,给旧的 Basic 谱面腾位置 + Move(otherFile, tempFile); + // 2. 旧的 Basic 谱面挪到源难度的文件名 + if (File.Exists(basicFile)) Move(basicFile, otherFile); + // 3. 临时文件挪到 Basic 的文件名 + Move(tempFile, basicFile); + return null; + } + catch (Exception e) + { + logger.LogError(e, "交换谱面文件失败,正在回滚"); + Rollback(); + return GenreChangeError.SwapFailed; + } + + void Move(string from, string to) + { + // 不覆盖:出现同名文件说明状态异常,宁可失败走回滚 + PlatformFile.MoveFile(from, to, false); + journal.Add((from, to)); + } + + void Rollback() + { + for (var i = journal.Count - 1; i >= 0; i--) + { + var (from, to) = journal[i]; + try + { + if (File.Exists(to)) PlatformFile.MoveFile(to, from, false); + } + catch (Exception rollbackError) + { + logger.LogError(rollbackError, "回滚谱面文件失败: {From} -> {To}", to, from); + } + } + } + } + + private static string WithSuffix(string path, string suffix) => path.Replace(".ma2", suffix + ".ma2"); +} diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index 39daab01..6cb3132d 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -241,6 +241,19 @@ export interface GenreAddRequest { assetDir?: string | null; } +export interface GenreChangeRequest { + /** @format int32 */ + genreId?: number; + overwrite?: boolean; +} + +export interface GenreChangeResult { + success?: boolean; + error?: string | null; + /** @format int32 */ + newId?: number; +} + export interface GenreEditRequest { name?: string | null; nameTwoLine?: string | null; @@ -2237,25 +2250,6 @@ export class Api< ...params, }), - /** - * No description - * - * @tags Music - * @name ConvertSingleChartToBasic - * @request POST:/MaiChartManagerServlet/ConvertSingleChartToBasicApi/{assetDir}/{id} - */ - ConvertSingleChartToBasic: ( - id: number, - assetDir: string, - params: RequestParams = {}, - ) => - this.request({ - path: `/MaiChartManagerServlet/ConvertSingleChartToBasicApi/${assetDir}/${id}`, - method: "POST", - format: "json", - ...params, - }), - /** * No description * @@ -2500,6 +2494,28 @@ export class Api< ...params, }), + /** + * No description + * + * @tags MusicTransfer + * @name ChangeMusicGenre + * @request POST:/MaiChartManagerServlet/ChangeMusicGenreApi/{assetDir}/{id} + */ + ChangeMusicGenre: ( + id: number, + assetDir: string, + data: GenreChangeRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/MaiChartManagerServlet/ChangeMusicGenreApi/${assetDir}/${id}`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + /** * No description * diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 13215bca..1e5b96a2 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -140,9 +140,11 @@ music: utageConvertConfirmIdOnly: The genre will be changed to Utage, and the music ID will be changed from {oldId} to {newId}. Continue? utageConvertConfirmChartOnly: The genre will be changed to Utage, and the enabled {difficulty} chart will be converted to a Basic chart. Continue? utageConvertConfirmBoth: The genre will be changed to Utage, the music ID will be changed from {oldId} to {newId}, and the enabled {difficulty} chart will be converted to a Basic chart. Continue? - utageConvertMultipleCharts: Multiple difficulties are enabled. Please disable all but one before converting. + utageConvertMultipleCharts: Multiple difficulties are enabled. Please keep only one enabled difficulty before converting. utageConvertNoChart: No chart is enabled. Please enable one before converting. - utageConvertIdExists: ID {newId} is already taken. Please delete that music first or use another ID. + utageConvertDoublePlayerChart: This chart contains Utage-only L and R charts. Please convert it to a normal Utage music first. + utageConvertIdExists: ID {newId} is already taken. Please delete the music with this ID first. + utageConvertOverwrite: ID {newId} is already taken. Overwrite the music with ID {newId}? utageConvertFailed: Failed to change to Utage utageRevertTitle: Change music genre utageRevertConfirm: The genre will be changed from Utage to {genreName}, and the music ID will be changed from {oldId} to {newId}. Continue? diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index d02377a8..d155fde6 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -132,9 +132,11 @@ music: utageConvertConfirmIdOnly: 樂曲流派將更改為宴會場,並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? utageConvertConfirmChartOnly: 樂曲流派將更改為宴會場,並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? utageConvertConfirmBoth: 樂曲流派將更改為宴會場,把樂曲 ID 從 {oldId} 改為 {newId},並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? - utageConvertMultipleCharts: 譜面有多個難度,請禁用一個難度再進行轉換 + utageConvertMultipleCharts: 譜面有多個難度,請只保留一個啟用的難度再進行轉換 utageConvertNoChart: 目前沒有啟用的譜面,請先啟用一個難度再進行轉換 - utageConvertIdExists: ID {newId} 已被佔用,請先刪除該樂曲或改用其他 ID + utageConvertDoublePlayerChart: 該譜面包含宴會場特有的 L 譜面和 R 譜面,請先變更為普通宴會場樂曲後再操作 + utageConvertIdExists: ID {newId} 已被佔用,請先刪除該 ID 上的樂曲 + utageConvertOverwrite: ID {newId} 已被佔用,是否覆蓋 ID {newId} 的樂曲? utageConvertFailed: 變更為宴會場失敗 utageRevertTitle: 樂曲流派變更 utageRevertConfirm: 樂曲流派將從宴會場變更為 {genreName},並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index 4e4e2517..963681b8 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -132,9 +132,11 @@ music: utageConvertConfirmIdOnly: 乐曲流派将更改为宴会场,并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? utageConvertConfirmChartOnly: 乐曲流派将更改为宴会场,并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? utageConvertConfirmBoth: 乐曲流派将更改为宴会场,把乐曲 ID 从 {oldId} 改为 {newId},并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? - utageConvertMultipleCharts: 铺面有多个难度,请禁用一个难度再进行转换 + utageConvertMultipleCharts: 谱面有多个难度,请只保留一个启用的难度再进行转换 utageConvertNoChart: 当前没有启用的谱面,请先启用一个难度再进行转换 - utageConvertIdExists: ID {newId} 已被占用,请先删除该乐曲或改用其他 ID + utageConvertDoublePlayerChart: 该谱面包含宴会场特有的 L 谱面和 R 谱面,请先更改为普通宴会场乐曲后再操作 + utageConvertIdExists: ID {newId} 已被占用,请先删除该 ID 上的乐曲 + utageConvertOverwrite: ID {newId} 已被占用,是否覆盖 ID {newId} 的乐曲? utageConvertFailed: 更改为宴会场失败 utageRevertTitle: 乐曲流派更改 utageRevertConfirm: 乐曲流派将从宴会场更改为 {genreName},并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index c782b2f7..78bca215 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -76,21 +76,27 @@ const Component = defineComponent({ return; } - const oldId = info.value.id!; - const newId = oldId < 1e5 ? oldId + 1e5 : oldId; - const needChangeId = newId !== oldId; - - // 改 ID 时目标 ID 上的乐曲会被删掉,先确认没有同 ID 的乐曲,避免误删 - if (needChangeId && musicList.value.some(music => music.id === newId)) { + // 宴会场特有的双人谱(L/R)没法直接转换 + if (info.value.utagePlayStyle === 1) { await showTransactionalDialog( t('music.edit.utageConvertTitle'), - t('music.edit.utageConvertIdExists', { newId }), + t('music.edit.utageConvertDoublePlayerChart'), undefined, true ); revertGenre(oldGenreId); return; } + const oldId = info.value.id!; + const newId = oldId < 1e5 ? oldId + 1e5 : oldId; + const needChangeId = newId !== oldId; + const needOverwrite = needChangeId && hasIdConflict(newId); + + if (needOverwrite && !(await confirmOverwrite(newId))) { + revertGenre(oldGenreId); + return; + } + const enabledIndex = enabledIndexes[0]; const difficulty = DIFFICULTY[enabledIndex]; // 本来就只有 Basic 启用的话,不需要再提「把 Basic 转为 Basic」 @@ -108,22 +114,7 @@ const Component = defineComponent({ return; } - try { - if (needConvertChart) { - const error = (await api.ConvertSingleChartToBasic(oldId, selectedADir.value)).data; - if (error) throw new Error(error); - } - if (needChangeId) { - await api.ModifyId(oldId, selectedADir.value, newId); - } - await api.EditMusicGenre(newId, selectedADir.value, UTAGE_GENRE); - await api.SaveMusic(newId, selectedADir.value); - await updateMusicList(); - if (needChangeId) selectMusicId.value = newId; - } catch (e) { - revertGenre(oldGenreId); - await globalCapture(e, t('music.edit.utageConvertFailed')); - } + await requestGenreChange(UTAGE_GENRE, oldGenreId, needOverwrite, t('music.edit.utageConvertFailed')); }; // 从宴会场切回其他流派:宴会场 ID 需要减 100000 @@ -139,12 +130,8 @@ const Component = defineComponent({ return; } - if (musicList.value.some(music => music.id === newId)) { - await showTransactionalDialog( - t('music.edit.utageRevertTitle'), - t('music.edit.utageConvertIdExists', { newId }), - undefined, true - ); + const needOverwrite = hasIdConflict(newId); + if (needOverwrite && !(await confirmOverwrite(newId))) { revertGenre(oldGenreId); return; } @@ -160,18 +147,48 @@ const Component = defineComponent({ return; } + await requestGenreChange(newGenreId, oldGenreId, needOverwrite, t('music.edit.utageRevertFailed')); + }; + + // 目标 ID 在当前资源目录里已有乐曲时询问是否覆盖(其他目录/包里的同 ID 不受影响) + const hasIdConflict = (id: number) => musicList.value.some(music => music.id === id); + + const confirmOverwrite = async (id: number) => showTransactionalDialog( + t('music.edit.utageConvertTitle'), + t('music.edit.utageConvertOverwrite', { newId: id }), + [{ text: t('common.cancel'), action: false }, { text: t('copy.overwrite'), action: true, danger: true }] + ); + + // 后端返回的错误码 → 文案 + const genreChangeErrorKeys: Record = { + 'no-enabled-chart': 'music.edit.utageConvertNoChart', + 'multiple-enabled-charts': 'music.edit.utageConvertMultipleCharts', + 'double-player-chart': 'music.edit.utageConvertDoublePlayerChart', + 'target-id-exists': 'music.edit.utageConvertIdExists', + }; + + // 谱面转换 + ID 变更 + 流派变更都由后端一次做完,前端只负责刷新 + const requestGenreChange = async (genreId: number, oldGenreId: number | undefined, overwrite: boolean, failMessage: string) => { + const oldId = info.value!.id!; try { - await api.ModifyId(oldId, selectedADir.value, newId); - await api.EditMusicGenre(newId, selectedADir.value, newGenreId); - await api.SaveMusic(newId, selectedADir.value); + const result = (await api.ChangeMusicGenre(oldId, selectedADir.value, { genreId, overwrite })).data; + if (!result.success) { + revertGenre(oldGenreId); + const key = result.error ? genreChangeErrorKeys[result.error] : undefined; + if (key) { + await showTransactionalDialog(t('music.edit.utageConvertTitle'), t(key), undefined, true); + } else { + await globalCapture(new Error(result.error ?? ''), failMessage); + } + return; + } await updateMusicList(); - selectMusicId.value = newId; + if (result.newId && result.newId !== oldId) selectMusicId.value = result.newId; } catch (e) { revertGenre(oldGenreId); - await globalCapture(e, t('music.edit.utageRevertFailed')); + await globalCapture(e, failMessage); } }; - watch(() => info.value?.name, sync('name', api.EditMusicName)); watch(() => info.value?.artist, sync('artist', api.EditMusicArtist)); watch(() => info.value?.bpm, sync('bpm', api.EditMusicBpm)); From 3b49046dcbfd6cc69897f8713c3f40fb84bc72eb Mon Sep 17 00:00:00 2001 From: huimanQAQ Date: Tue, 27 Oct 2026 09:05:34 +0800 Subject: [PATCH 03/22] =?UTF-8?q?fix:=20=E6=A0=A1=E9=AA=8C=E8=B0=B1?= =?UTF-8?q?=E9=9D=A2=E8=B7=AF=E5=BE=84=EF=BC=8C=E5=B9=B6=E8=AE=A9=E6=94=B9?= =?UTF-8?q?=20ID=20=E6=97=B6=E4=B8=80=E5=B9=B6=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E5=8F=8C=E4=BA=BA=E8=B0=B1=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 谱面转换前校验 Basic / 源难度的谱面路径都在乐曲目录内,防止 XML 里的 ../ 影响其他文件 - ModifyIdCore 重命名谱面时同时处理双人谱的 _L / _R 文件,改 ID 后不会留下旧 ID 命名的分段谱面 --- .../Music/MusicTransferController.cs | 26 +++++++++++++++++-- .../Controllers/Music/UtageConverter.cs | 19 ++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 806c61dd..233a2680 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -456,6 +456,11 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l } } + /// + /// 双人谱的文件名:xxx.ma2 -> xxx_L.ma2 + /// + private static string WithSide(string path, string side) => path.Replace(".ma2", side + ".ma2"); + private void DeleteIfExists(params string[] path) { foreach (var p in path) @@ -614,10 +619,27 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) { var chart = music.Charts[i]; if (!chart.Enable) continue; - if (!System.IO.File.Exists(Path.Combine(oldMusicDir, chart.Path))) continue; + + var oldFilePath = Path.Combine(oldMusicDir, chart.Path); var newFileName = $"{newId:000000}_0{i}.ma2"; + var newFilePath = Path.Combine(oldMusicDir, newFileName); + + // 双人谱只有 _L / _R 两个文件,没有单谱文件,任意一种存在就算有谱面 + if (!System.IO.File.Exists(oldFilePath) + && !System.IO.File.Exists(WithSide(oldFilePath, "_L")) + && !System.IO.File.Exists(WithSide(oldFilePath, "_R"))) + continue; + logger.LogInformation("Move chart: {chart.Path} -> {newFileName}", chart.Path, newFileName); - PlatformFile.MoveFile(Path.Combine(oldMusicDir, chart.Path), Path.Combine(oldMusicDir, newFileName)); + if (System.IO.File.Exists(oldFilePath)) PlatformFile.MoveFile(oldFilePath, newFilePath); + // 双人谱的左右谱面文件一起改名 + foreach (var side in new[] { "_L", "_R" }) + { + var oldSideFile = WithSide(oldFilePath, side); + if (System.IO.File.Exists(oldSideFile)) + PlatformFile.MoveFile(oldSideFile, WithSide(newFilePath, side)); + } + chart.Path = newFileName; } diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/UtageConverter.cs index 568c3876..a71a7b7c 100644 --- a/MaiChartManager/Controllers/Music/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/UtageConverter.cs @@ -16,6 +16,7 @@ internal static class GenreChangeError public const string TargetIdExists = "target-id-exists"; public const string LicenseRequired = "license-required"; public const string InvalidMusicDirectory = "invalid-music-directory"; + public const string InvalidChartPath = "invalid-chart-path"; public const string ChartFileNotFound = "chart-file-not-found"; public const string SwapFailed = "swap-failed"; public const string Unexpected = "unexpected"; @@ -74,6 +75,9 @@ internal static class UtageConverter var basicChart = music.Charts[0]; var otherChart = music.Charts[sourceIndex]; + // 谱面路径必须落在乐曲目录内,防止 XML 里的 ../ 之类内容去动别的文件 + if (!IsInsideMusicDir(musicDir, basicChart.Path) || !IsInsideMusicDir(musicDir, otherChart.Path)) + return GenreChangeError.InvalidChartPath; // 宴会场特有的双人谱(_L / _R)不能这样转,需要先改成普通宴会场乐曲 if (HasSideChart(musicDir, otherChart.Path)) return GenreChangeError.DoublePlayerChart; // 源难度必须真的有谱面文件,否则转换后 Basic 的内容会和 XML 对不上 @@ -96,6 +100,21 @@ private static bool HasSideChart(string musicDir, string path) => File.Exists(Path.Combine(musicDir, WithSuffix(path, "_L"))) || File.Exists(Path.Combine(musicDir, WithSuffix(path, "_R"))); + private static bool IsInsideMusicDir(string musicDir, string path) + { + try + { + var fullPath = Path.GetFullPath(Path.Combine(musicDir, path)); + var root = Path.GetFullPath(musicDir) + Path.DirectorySeparatorChar; + return fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase); + } + catch (Exception) + { + // 非法路径字符等,直接当作不合法处理 + return false; + } + } + private static void SwapChartInfo(MusicXml.Chart a, MusicXml.Chart b) { (a.Level, b.Level) = (b.Level, a.Level); From 79c163ca9bde8cb36dc2945a66c0b6ac588b62f9 Mon Sep 17 00:00:00 2001 From: huimanQAQ Date: Tue, 27 Oct 2026 09:09:00 +0800 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20=E5=90=AB=20L/R=20=E5=8F=8C?= =?UTF-8?q?=E4=BA=BA=E8=B0=B1=E7=9A=84=E5=AE=B4=E4=BC=9A=E5=9C=BA=E4=B9=90?= =?UTF-8?q?=E6=9B=B2=E6=8B=92=E7=BB=9D=E8=BD=AC=E6=8D=A2=E4=B8=BA=E6=99=AE?= =?UTF-8?q?=E9=80=9A=E8=B0=B1=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 流派由宴会场切回其他流派时,先检查乐曲是否带宴会场特有的 L / R 谱面(_L / _R 文件或 utagePlayStyle=1),命中直接返回 double-player-chart 拒绝,不再改 ID 和流派 - 前端同步提前拦截,提示文案改为通用表述 --- .../Music/MusicTransferController.cs | 3 +++ .../Controllers/Music/UtageConverter.cs | 18 ++++++++++++++++++ MaiChartManager/Front/src/locales/en.yaml | 2 +- MaiChartManager/Front/src/locales/zh-TW.yaml | 2 +- MaiChartManager/Front/src/locales/zh.yaml | 2 +- .../Front/src/views/Charts/MusicEdit/index.tsx | 12 ++++++++++++ 6 files changed, 36 insertions(+), 3 deletions(-) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 233a2680..40091f2b 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -688,6 +688,9 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh } else { + // 含宴会场特有 L/R 双人谱的乐曲无法转成普通谱面 + if (UtageConverter.HasSideCharts(music)) + return new GenreChangeResult(false, GenreChangeError.DoublePlayerChart, id); newId = id > UtageConverter.UtageIdOffset ? id - UtageConverter.UtageIdOffset : id; } diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/UtageConverter.cs index a71a7b7c..5d851f6f 100644 --- a/MaiChartManager/Controllers/Music/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/UtageConverter.cs @@ -100,6 +100,24 @@ private static bool HasSideChart(string musicDir, string path) => File.Exists(Path.Combine(musicDir, WithSuffix(path, "_L"))) || File.Exists(Path.Combine(musicDir, WithSuffix(path, "_R"))); + /// + /// 乐曲是否存在宴会场特有的双人谱(_L / _R)。这类谱面无法和普通谱面互相转换。 + /// + public static bool HasSideCharts(MusicXmlWithABJacket music) + { + if (music.UtagePlayStyle == 1) return true; + + var musicDir = Path.GetDirectoryName(music.FilePath); + if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir)) return false; + + foreach (var chart in music.Charts) + { + if (HasSideChart(musicDir, chart.Path)) return true; + } + + return false; + } + private static bool IsInsideMusicDir(string musicDir, string path) { try diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 1e5b96a2..b8120363 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -142,7 +142,7 @@ music: utageConvertConfirmBoth: The genre will be changed to Utage, the music ID will be changed from {oldId} to {newId}, and the enabled {difficulty} chart will be converted to a Basic chart. Continue? utageConvertMultipleCharts: Multiple difficulties are enabled. Please keep only one enabled difficulty before converting. utageConvertNoChart: No chart is enabled. Please enable one before converting. - utageConvertDoublePlayerChart: This chart contains Utage-only L and R charts. Please convert it to a normal Utage music first. + utageConvertDoublePlayerChart: This chart contains Utage-only L and R charts and cannot be converted automatically. Please handle it manually first. utageConvertIdExists: ID {newId} is already taken. Please delete the music with this ID first. utageConvertOverwrite: ID {newId} is already taken. Overwrite the music with ID {newId}? utageConvertFailed: Failed to change to Utage diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index d155fde6..a71a5d34 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -134,7 +134,7 @@ music: utageConvertConfirmBoth: 樂曲流派將更改為宴會場,把樂曲 ID 從 {oldId} 改為 {newId},並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? utageConvertMultipleCharts: 譜面有多個難度,請只保留一個啟用的難度再進行轉換 utageConvertNoChart: 目前沒有啟用的譜面,請先啟用一個難度再進行轉換 - utageConvertDoublePlayerChart: 該譜面包含宴會場特有的 L 譜面和 R 譜面,請先變更為普通宴會場樂曲後再操作 + utageConvertDoublePlayerChart: 該譜面包含宴會場特有的 L 譜面和 R 譜面,不支援自動轉換,請手動處理後再操作 utageConvertIdExists: ID {newId} 已被佔用,請先刪除該 ID 上的樂曲 utageConvertOverwrite: ID {newId} 已被佔用,是否覆蓋 ID {newId} 的樂曲? utageConvertFailed: 變更為宴會場失敗 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index 963681b8..868f3bf9 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -134,7 +134,7 @@ music: utageConvertConfirmBoth: 乐曲流派将更改为宴会场,把乐曲 ID 从 {oldId} 改为 {newId},并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? utageConvertMultipleCharts: 谱面有多个难度,请只保留一个启用的难度再进行转换 utageConvertNoChart: 当前没有启用的谱面,请先启用一个难度再进行转换 - utageConvertDoublePlayerChart: 该谱面包含宴会场特有的 L 谱面和 R 谱面,请先更改为普通宴会场乐曲后再操作 + utageConvertDoublePlayerChart: 该谱面包含宴会场特有的 L 谱面和 R 谱面,不支持自动转换,请手动处理后再操作 utageConvertIdExists: ID {newId} 已被占用,请先删除该 ID 上的乐曲 utageConvertOverwrite: ID {newId} 已被占用,是否覆盖 ID {newId} 的乐曲? utageConvertFailed: 更改为宴会场失败 diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index 78bca215..e63bd2f3 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -120,6 +120,18 @@ const Component = defineComponent({ // 从宴会场切回其他流派:宴会场 ID 需要减 100000 const revertGenreFromUtage = async (newGenreId: number, oldGenreId?: number) => { if (!info.value) return; + + // 含宴会场特有 L/R 双人谱的乐曲无法转成普通谱面 + if (info.value.utagePlayStyle === 1) { + await showTransactionalDialog( + t('music.edit.utageRevertTitle'), + t('music.edit.utageConvertDoublePlayerChart'), + undefined, true + ); + revertGenre(oldGenreId); + return; + } + const oldId = info.value.id!; const newId = oldId > 1e5 ? oldId - 1e5 : oldId; From 7314ecf57c639932df122162227ed3ae2f7d47af Mon Sep 17 00:00:00 2001 From: huimanQAQ Date: Tue, 27 Oct 2026 09:44:33 +0800 Subject: [PATCH 05/22] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E6=B5=81?= =?UTF-8?q?=E6=B4=BE=E5=88=87=E6=8D=A2=E7=9A=84=E5=A4=B1=E8=B4=A5=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=E4=B8=8E=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MoveToBasic / RestoreFromBasic 区分转换与回滚方向:回滚不再要求源难度谱面文件存在,文件交换支持单边缺失,回滚失败会记录日志 - 谱面路径校验按平台区分大小写(Windows 不敏感 / Linux 敏感) - 前端补齐所有后端错误码的文案并映射,不再把原始错误码显示给用户 - 失败弹窗与覆盖确认弹窗的标题按操作方向传入,不再固定显示「更改为宴会场」 - 错误文案统一传入目标 ID,修复 {newId} 占位符未替换的问题 --- .../Music/MusicTransferController.cs | 29 ++++++++------- .../Controllers/Music/UtageConverter.cs | 29 ++++++++++----- MaiChartManager/Front/src/locales/en.yaml | 7 ++++ MaiChartManager/Front/src/locales/zh-TW.yaml | 7 ++++ MaiChartManager/Front/src/locales/zh.yaml | 7 ++++ .../src/views/Charts/MusicEdit/index.tsx | 36 ++++++++++++------- 6 files changed, 82 insertions(+), 33 deletions(-) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 40091f2b..51c56079 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -721,7 +721,7 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh var error = await ModifyIdCore(music, newId, assetDir); if (error is not null) { - if (converted) UtageConverter.RestoreFromBasic(music, chartIndex, logger); + TryRollback(); logger.LogError("切换流派时改 ID 失败: {error}", error); return new GenreChangeResult(false, GenreChangeError.InvalidMusicDirectory, id); } @@ -731,7 +731,7 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh var target = settings.GetMusic(targetId, assetDir); if (target is null) { - if (converted) UtageConverter.RestoreFromBasic(music, chartIndex, logger); + TryRollback(); return new GenreChangeResult(false, GenreChangeError.MusicNotFound, id); } @@ -742,18 +742,23 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh catch (Exception e) { logger.LogError(e, "切换流派失败: {id} -> {genreId}", id, genreId); - if (converted) + TryRollback(); + return new GenreChangeResult(false, GenreChangeError.Unexpected, id); + } + + // 谱面已经转换成功、但后续步骤失败时,把谱面换回去 + void TryRollback() + { + if (!converted) return; + try { - try - { - UtageConverter.RestoreFromBasic(music, chartIndex, logger); - } - catch (Exception rollbackError) - { - logger.LogError(rollbackError, "回滚谱面转换失败"); - } + var error = UtageConverter.RestoreFromBasic(music, chartIndex, logger); + if (error is not null) logger.LogError("回滚谱面转换失败: {error}", error); + } + catch (Exception rollbackError) + { + logger.LogError(rollbackError, "回滚谱面转换失败"); } - return new GenreChangeResult(false, GenreChangeError.Unexpected, id); } } diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/UtageConverter.cs index 5d851f6f..5c0c943b 100644 --- a/MaiChartManager/Controllers/Music/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/UtageConverter.cs @@ -55,16 +55,17 @@ internal static class UtageConverter /// 返回 null 表示成功,否则是错误码。 /// public static string? MoveToBasic(MusicXmlWithABJacket music, int sourceIndex, ILogger logger) - => SwapWithBasic(music, sourceIndex, sourceIsEnabled: false, logger); + => SwapWithBasic(music, sourceIndex, sourceIsEnabled: false, requireSourceFile: true, logger); /// /// MoveToBasic 的逆操作,用于失败时回滚。 + /// 回滚时源难度的谱面文件可能已经不存在(它被挪到了 Basic),所以不校验文件。 /// public static string? RestoreFromBasic(MusicXmlWithABJacket music, int sourceIndex, ILogger logger) - => SwapWithBasic(music, sourceIndex, sourceIsEnabled: true, logger); + => SwapWithBasic(music, sourceIndex, sourceIsEnabled: true, requireSourceFile: false, logger); private static string? SwapWithBasic(MusicXmlWithABJacket music, int sourceIndex, bool sourceIsEnabled, - ILogger logger) + bool requireSourceFile, ILogger logger) { if (sourceIndex <= 0 || sourceIndex >= music.Charts.Length) return null; @@ -80,8 +81,10 @@ internal static class UtageConverter return GenreChangeError.InvalidChartPath; // 宴会场特有的双人谱(_L / _R)不能这样转,需要先改成普通宴会场乐曲 if (HasSideChart(musicDir, otherChart.Path)) return GenreChangeError.DoublePlayerChart; - // 源难度必须真的有谱面文件,否则转换后 Basic 的内容会和 XML 对不上 - if (!File.Exists(Path.Combine(musicDir, otherChart.Path))) return GenreChangeError.ChartFileNotFound; + // 源难度必须真的有谱面文件,否则转换后 Basic 的内容会和 XML 对不上。 + // 回滚方向不做这个校验:转换后源难度的文件已经挪走了。 + if (requireSourceFile && !File.Exists(Path.Combine(musicDir, otherChart.Path))) + return GenreChangeError.ChartFileNotFound; var error = SwapChartFiles(musicDir, basicChart.Path, otherChart.Path, logger); if (error is not null) return error; @@ -124,7 +127,9 @@ private static bool IsInsideMusicDir(string musicDir, string path) { var fullPath = Path.GetFullPath(Path.Combine(musicDir, path)); var root = Path.GetFullPath(musicDir) + Path.DirectorySeparatorChar; - return fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase); + // Windows 文件系统大小写不敏感,Linux 敏感,比较方式要和平台一致 + return fullPath.StartsWith(root, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } catch (Exception) { @@ -151,6 +156,12 @@ private static void SwapChartInfo(MusicXml.Chart a, MusicXml.Chart b) { var basicFile = Path.Combine(musicDir, basicPath); var otherFile = Path.Combine(musicDir, otherPath); + var basicExists = File.Exists(basicFile); + var otherExists = File.Exists(otherFile); + + // 两边都没有文件就没法交换(正常情况下调用方已经拦掉了) + if (!basicExists && !otherExists) return GenreChangeError.ChartFileNotFound; + var tempFile = Path.Combine(musicDir, $".mcmswap-{Guid.NewGuid():N}.tmp"); var journal = new List<(string From, string To)>(); @@ -158,11 +169,11 @@ private static void SwapChartInfo(MusicXml.Chart a, MusicXml.Chart b) try { // 1. 源谱面先挪到临时名,给旧的 Basic 谱面腾位置 - Move(otherFile, tempFile); + if (otherExists) Move(otherFile, tempFile); // 2. 旧的 Basic 谱面挪到源难度的文件名 - if (File.Exists(basicFile)) Move(basicFile, otherFile); + if (basicExists) Move(basicFile, otherFile); // 3. 临时文件挪到 Basic 的文件名 - Move(tempFile, basicFile); + if (otherExists) Move(tempFile, basicFile); return null; } catch (Exception e) diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index b8120363..ce827d8f 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -149,6 +149,13 @@ music: utageRevertTitle: Change music genre utageRevertConfirm: The genre will be changed from Utage to {genreName}, and the music ID will be changed from {oldId} to {newId}. Continue? utageRevertFailed: Failed to change genre from Utage + utageConvertMusicNotFound: Could not find this music. Please refresh and try again. + utageConvertLicenseRequired: A valid license is required to change the music ID. + utageConvertInvalidMusicDir: The music directory does not exist or is not accessible. + utageConvertInvalidChartPath: Invalid chart path. Please check the chart file names in Music.xml. + utageConvertChartFileNotFound: The enabled difficulty has no chart file. Please make sure the chart files are complete. + utageConvertSwapFailed: Failed to swap chart files. No chart was modified. + utageConvertUnexpected: Failed to change the genre. Please check the log. versionHint: >- If game version is 1.{gameVersion}, numbers >= {b15ver} will appear in B15, otherwise in B35. This value also controls the order when sorting by diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index a71a5d34..d7718beb 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -141,6 +141,13 @@ music: utageRevertTitle: 樂曲流派變更 utageRevertConfirm: 樂曲流派將從宴會場變更為 {genreName},並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? utageRevertFailed: 從宴會場變更流派失敗 + utageConvertMusicNotFound: 找不到該樂曲,請重新整理後再試 + utageConvertLicenseRequired: 需要有效的授權才能變更樂曲 ID + utageConvertInvalidMusicDir: 樂曲目錄不存在或無法存取 + utageConvertInvalidChartPath: 譜面路徑不合法,請檢查 Music.xml 裡的譜面檔名 + utageConvertChartFileNotFound: 啟用的難度找不到譜面檔案,請先確認譜面檔案完整 + utageConvertSwapFailed: 交換譜面檔案失敗,譜面未被修改 + utageConvertUnexpected: 變更流派失敗,請查看日誌 versionHint: >- 如果遊戲版本是 1.{gameVersion} 的話,這裡的數字大於等於 {b15ver} 就會讓歌出現在 B15 裡面,否則就會出現在 B35 裡面。這個值還會控制按上線時間排序時的順序 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index 868f3bf9..b7fae430 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -141,6 +141,13 @@ music: utageRevertTitle: 乐曲流派更改 utageRevertConfirm: 乐曲流派将从宴会场更改为 {genreName},并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? utageRevertFailed: 从宴会场更改流派失败 + utageConvertMusicNotFound: 找不到该乐曲,请刷新后重试 + utageConvertLicenseRequired: 需要有效的授权才能更改乐曲 ID + utageConvertInvalidMusicDir: 乐曲目录不存在或不可访问 + utageConvertInvalidChartPath: 谱面路径不合法,请检查 Music.xml 里的谱面文件名 + utageConvertChartFileNotFound: 启用的难度找不到谱面文件,请先确认谱面文件完整 + utageConvertSwapFailed: 交换谱面文件失败,谱面未被修改 + utageConvertUnexpected: 切换流派失败,请查看日志 versionHint: >- 如果游戏版本是 1.{gameVersion} 的话,这里的数字大于等于 {b15ver} 就会让歌出现在 B15 里面,否则就会出现在 B35 里面。这个值还会控制按上线时间排序时的顺序 diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index e63bd2f3..541d8274 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -92,7 +92,7 @@ const Component = defineComponent({ const needChangeId = newId !== oldId; const needOverwrite = needChangeId && hasIdConflict(newId); - if (needOverwrite && !(await confirmOverwrite(newId))) { + if (needOverwrite && !(await confirmOverwrite(newId, 'music.edit.utageConvertTitle'))) { revertGenre(oldGenreId); return; } @@ -114,7 +114,8 @@ const Component = defineComponent({ return; } - await requestGenreChange(UTAGE_GENRE, oldGenreId, needOverwrite, t('music.edit.utageConvertFailed')); + await requestGenreChange(UTAGE_GENRE, newId, oldGenreId, needOverwrite, + 'music.edit.utageConvertTitle', t('music.edit.utageConvertFailed')); }; // 从宴会场切回其他流派:宴会场 ID 需要减 100000 @@ -143,7 +144,7 @@ const Component = defineComponent({ } const needOverwrite = hasIdConflict(newId); - if (needOverwrite && !(await confirmOverwrite(newId))) { + if (needOverwrite && !(await confirmOverwrite(newId, 'music.edit.utageRevertTitle'))) { revertGenre(oldGenreId); return; } @@ -159,39 +160,50 @@ const Component = defineComponent({ return; } - await requestGenreChange(newGenreId, oldGenreId, needOverwrite, t('music.edit.utageRevertFailed')); + await requestGenreChange(newGenreId, newId, oldGenreId, needOverwrite, + 'music.edit.utageRevertTitle', t('music.edit.utageRevertFailed')); }; // 目标 ID 在当前资源目录里已有乐曲时询问是否覆盖(其他目录/包里的同 ID 不受影响) const hasIdConflict = (id: number) => musicList.value.some(music => music.id === id); - const confirmOverwrite = async (id: number) => showTransactionalDialog( - t('music.edit.utageConvertTitle'), + const confirmOverwrite = async (id: number, titleKey: string) => showTransactionalDialog( + t(titleKey), t('music.edit.utageConvertOverwrite', { newId: id }), [{ text: t('common.cancel'), action: false }, { text: t('copy.overwrite'), action: true, danger: true }] ); // 后端返回的错误码 → 文案 const genreChangeErrorKeys: Record = { + 'music-not-found': 'music.edit.utageConvertMusicNotFound', 'no-enabled-chart': 'music.edit.utageConvertNoChart', 'multiple-enabled-charts': 'music.edit.utageConvertMultipleCharts', 'double-player-chart': 'music.edit.utageConvertDoublePlayerChart', 'target-id-exists': 'music.edit.utageConvertIdExists', + 'license-required': 'music.edit.utageConvertLicenseRequired', + 'invalid-music-directory': 'music.edit.utageConvertInvalidMusicDir', + 'invalid-chart-path': 'music.edit.utageConvertInvalidChartPath', + 'chart-file-not-found': 'music.edit.utageConvertChartFileNotFound', + 'swap-failed': 'music.edit.utageConvertSwapFailed', + 'unexpected': 'music.edit.utageConvertUnexpected', }; // 谱面转换 + ID 变更 + 流派变更都由后端一次做完,前端只负责刷新 - const requestGenreChange = async (genreId: number, oldGenreId: number | undefined, overwrite: boolean, failMessage: string) => { + const requestGenreChange = async (genreId: number, targetId: number, oldGenreId: number | undefined, + overwrite: boolean, titleKey: string, failMessage: string) => { const oldId = info.value!.id!; try { const result = (await api.ChangeMusicGenre(oldId, selectedADir.value, { genreId, overwrite })).data; if (!result.success) { revertGenre(oldGenreId); const key = result.error ? genreChangeErrorKeys[result.error] : undefined; - if (key) { - await showTransactionalDialog(t('music.edit.utageConvertTitle'), t(key), undefined, true); - } else { - await globalCapture(new Error(result.error ?? ''), failMessage); - } + await showTransactionalDialog( + t(titleKey), + key + ? t(key, { newId: targetId }) + : `${failMessage}${result.error ? ` (${result.error})` : ''}`, + undefined, true + ); return; } await updateMusicList(); From 4a131b4fc91ec73edd9cc6816a49ff44f95ffdc2 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:26:39 +0800 Subject: [PATCH 06/22] =?UTF-8?q?fix:=20=E6=B5=81=E6=B4=BE=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E6=94=B9=E7=94=B1=E4=B8=8B=E6=8B=89=E6=A1=86=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=EF=BC=8C=E9=81=BF=E5=85=8D=E6=8D=A2=E6=AD=8C=E6=97=B6?= =?UTF-8?q?=E8=AF=AF=E5=BC=B9=E5=AE=B4=E4=BC=9A=E5=9C=BA=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 info.value.genreId 的 watch,改用 GenreInput 的 update:value 回调,只有用户主动改流派才会进入宴会场转换流程 - 换歌/刷新列表导致 genreId 变化时不再触发转换流程,也不会把上一首曲子的流派回滚到当前曲子上 - GenreInput 声明 emits: ['update:value'],让 TSX 里能精确拦截用户选择 - 回滚流派时不再写入 undefined,也不再需要用 disableSync 绕开 watch --- .../Front/src/components/GenreInput.tsx | 2 ++ .../src/views/Charts/MusicEdit/index.tsx | 35 ++++++++++--------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/MaiChartManager/Front/src/components/GenreInput.tsx b/MaiChartManager/Front/src/components/GenreInput.tsx index 845ee3a2..a2064045 100644 --- a/MaiChartManager/Front/src/components/GenreInput.tsx +++ b/MaiChartManager/Front/src/components/GenreInput.tsx @@ -7,6 +7,8 @@ export default defineComponent({ options: {type: Array as PropType, required: true}, value: Number }, + // 声明 emits 才能在 TSX 里用 onUpdate:value 精确拦截用户的选择 + emits: ['update:value'], setup(props, {emit}) { const options = computed(() => props.options.map(genre => ({label: ()=> it.id === genre.id)!}/>, value: genre.id}))); const value = computed({ diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index 541d8274..99aae472 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -52,16 +52,14 @@ const Component = defineComponent({ // 取消或失败时,把下拉框里的流派恢复原样 const revertGenre = (value?: number) => { - if (!info.value) return; - disableSync.value = true; + if (!info.value || value === undefined) return; info.value.genreId = value; - // 让 watch 先忽略掉这次回滚,下一帧再恢复同步 - setTimeout(() => disableSync.value = false); }; // 宴会场只认 Basic(绿)谱面:切换到宴会场时顺带改 ID,并把唯一启用的难度谱面转成 Basic const changeGenreToUtage = async (oldGenreId?: number) => { if (!info.value) return; + info.value.genreId = UTAGE_GENRE; const enabledIndexes = enabledChartIndexes.value; if (enabledIndexes.length !== 1) { @@ -121,6 +119,7 @@ const Component = defineComponent({ // 从宴会场切回其他流派:宴会场 ID 需要减 100000 const revertGenreFromUtage = async (newGenreId: number, oldGenreId?: number) => { if (!info.value) return; + info.value.genreId = newGenreId; // 含宴会场特有 L/R 双人谱的乐曲无法转成普通谱面 if (info.value.utagePlayStyle === 1) { @@ -213,23 +212,27 @@ const Component = defineComponent({ await globalCapture(e, failMessage); } }; - watch(() => info.value?.name, sync('name', api.EditMusicName)); - watch(() => info.value?.artist, sync('artist', api.EditMusicArtist)); - watch(() => info.value?.bpm, sync('bpm', api.EditMusicBpm)); - watch(() => info.value?.version, sync('version', api.EditMusicVersion)); - watch(() => info.value?.genreId, async (value, oldValue) => { - if (disableSync.value || !info.value || value === undefined) return; - if (value === UTAGE_GENRE && oldValue !== UTAGE_GENRE) { - await changeGenreToUtage(oldValue); + // 流派改动的唯一入口。不能靠 watch 拦截:换歌、刷新列表同样会改变 info.value.genreId, + // watch 会把「上一首曲子的流派」当成这次的旧值,误触发宴会场转换流程。 + const onGenreChange = async (value: number) => { + if (!info.value || value === undefined || value === info.value.genreId) return; + const oldGenreId = info.value.genreId; + if (value === UTAGE_GENRE) { + await changeGenreToUtage(oldGenreId); return; } - if (oldValue === UTAGE_GENRE && value !== UTAGE_GENRE) { - await revertGenreFromUtage(value, oldValue); + if (oldGenreId === UTAGE_GENRE) { + await revertGenreFromUtage(value, oldGenreId); return; } + info.value.genreId = value; info.value.modified = true; await api.EditMusicGenre(info.value.id!, info.value.assetDir!, value); - }); + }; + watch(() => info.value?.name, sync('name', api.EditMusicName)); + watch(() => info.value?.artist, sync('artist', api.EditMusicArtist)); + watch(() => info.value?.bpm, sync('bpm', api.EditMusicBpm)); + watch(() => info.value?.version, sync('version', api.EditMusicVersion)); watch(() => info.value?.addVersionId, sync('addVersionId', api.EditMusicAddVersion)); watch(() => info.value?.utageKanji, sync('utageKanji', api.EditMusicUtageKanji)); watch(() => info.value?.comment, sync('comment', api.EditMusicComment)); @@ -287,7 +290,7 @@ const Component = defineComponent({
{t('music.edit.genre')}
- +
{t('music.edit.versionCategory')}
From d5beb4920aadf6d31cfa0c3f9d90e238913d36b7 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:28:09 +0800 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20=E5=AE=B4=E4=BC=9A=E5=9C=BA=20ID?= =?UTF-8?q?=20=E8=A2=AB=E5=8D=A0=E7=94=A8=E6=97=B6=E9=A1=BA=E5=BB=B6?= =?UTF-8?q?=E5=BA=8F=E5=8F=B7=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E4=B9=90=E6=9B=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端按「宴会场 ID = 序号 * 10000 + 原曲后四位」顺延找一个空闲 ID(序号 10~99),全部占用时返回 no-available-id - 移除 ChangeMusicGenreApi 的 overwrite 参数与前端覆盖确认流程,避免覆盖时把别的乐曲整个删掉 - 前端不再自己推算目标 ID,确认文案改为说明后四位保持不变,最终 ID 以后端返回的 newId 为准 - 补充 no-available-id 的简体/繁体/英文文案,删掉不再使用的覆盖文案 --- .../Music/MusicTransferController.cs | 27 +++++++--- .../Controllers/Music/UtageConverter.cs | 33 +++++++++++- MaiChartManager/Front/src/client/apiGen.ts | 1 - MaiChartManager/Front/src/locales/en.yaml | 8 +-- MaiChartManager/Front/src/locales/zh-TW.yaml | 8 +-- MaiChartManager/Front/src/locales/zh.yaml | 8 +-- .../src/views/Charts/MusicEdit/index.tsx | 50 +++++++------------ 7 files changed, 81 insertions(+), 54 deletions(-) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 51c56079..0ef8d4d2 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -23,7 +23,7 @@ public partial class MusicTransferController( { public record RequestCopyToRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool removeEvents, bool legacyFormat); public record GenreChangeResult(bool Success, string? Error, int NewId); - public record GenreChangeRequest(int GenreId, bool Overwrite = false); + public record GenreChangeRequest(int GenreId); public enum MaidataSubdirMode { @@ -656,8 +656,9 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) } /// - /// 切换乐曲流派。切到/切回宴会场时,ID 变更(±100000)和谱面转换都在这个接口里一次做完, - /// 避免前端分步调用留下半改状态。返回 Error 为 null 表示成功,NewId 是处理后的乐曲 ID。 + /// 切换乐曲流派。切到/切回宴会场时,ID 变更(宴会场 ID 固定后四位、顺延序号)和谱面转换 + /// 都在这个接口里一次做完,避免前端分步调用留下半改状态。 + /// 返回 Error 为 null 表示成功,NewId 是处理后的乐曲 ID。 /// [HttpPost] public async Task ChangeMusicGenre(int id, [FromBody] GenreChangeRequest request, string assetDir) @@ -684,14 +685,26 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh // 宴会场只认 Basic 谱面,先确认只有一个启用的难度 var error = UtageConverter.FindSingleEnabledChart(music, out chartIndex); if (error is not null) return new GenreChangeResult(false, error, id); - newId = id < UtageConverter.UtageIdOffset ? id + UtageConverter.UtageIdOffset : id; + if (UtageConverter.IsUtageId(id)) + { + // 已经是宴会场 ID 了,只改流派 + newId = id; + } + else + { + // 目标 ID 被占用时顺延一个序号(后四位不变),而不是覆盖掉已有的宴会场谱面 + var freeId = UtageConverter.FindFreeUtageId(id, + candidate => settings.GetMusic(candidate, assetDir) is not null); + if (freeId is null) return new GenreChangeResult(false, GenreChangeError.NoAvailableId, id); + newId = freeId.Value; + } } else { // 含宴会场特有 L/R 双人谱的乐曲无法转成普通谱面 if (UtageConverter.HasSideCharts(music)) return new GenreChangeResult(false, GenreChangeError.DoublePlayerChart, id); - newId = id > UtageConverter.UtageIdOffset ? id - UtageConverter.UtageIdOffset : id; + newId = id > UtageConverter.UtageIdStart ? id - UtageConverter.UtageIdStart : id; } if (newId != id) @@ -700,8 +713,8 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh if (IapManager.License != IapManager.LicenseStatus.Active) return new GenreChangeResult(false, GenreChangeError.LicenseRequired, id); #endif - // 只检查当前资源目录;其他目录(其他包)里同 ID 的乐曲互不影响 - if (!request.Overwrite && settings.GetMusic(newId, assetDir) is not null) + // 切到宴会场时挑的本来就是空闲 ID,只有切回普通流派时目标 ID 可能已被占用 + if (settings.GetMusic(newId, assetDir) is not null) return new GenreChangeResult(false, GenreChangeError.TargetIdExists, id); } diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/UtageConverter.cs index 5c0c943b..22f81426 100644 --- a/MaiChartManager/Controllers/Music/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/UtageConverter.cs @@ -14,6 +14,7 @@ internal static class GenreChangeError public const string MultipleEnabledCharts = "multiple-enabled-charts"; public const string DoublePlayerChart = "double-player-chart"; public const string TargetIdExists = "target-id-exists"; + public const string NoAvailableId = "no-available-id"; public const string LicenseRequired = "license-required"; public const string InvalidMusicDirectory = "invalid-music-directory"; public const string InvalidChartPath = "invalid-chart-path"; @@ -29,7 +30,37 @@ internal static class GenreChangeError internal static class UtageConverter { public const int UtageGenreId = 107; - public const int UtageIdOffset = 100000; + + /// + /// 宴会场 ID 的起点。宴会场 ID 的规则是 `序号 * 10000 + 原曲 ID 的后四位`,序号从 10 开始, + /// 所以同一首歌可以同时拥有 100xxx / 110xxx / 120xxx…… 多条宴会场谱面。 + /// + public const int UtageIdStart = 100_000; + + /// + /// 宴会场序号的最后一个。再往后就进 OocMod 的 7 位自定义曲目命名空间了,不算宴会场。 + /// + public const int UtageMaxSeq = 99; + + /// 是否为宴会场 ID(6 位,后四位是原曲 ID) + public static bool IsUtageId(int id) => id is >= UtageIdStart and < 1_000_000; + + /// + /// 找出该曲下一个可用的宴会场 ID(后四位保持不变,只顺延序号)。 + /// 用来避开「目标 ID 已被别的宴会场谱面占用」,这样就不需要覆盖掉已有乐曲。 + /// 返回 null 表示序号已经用完。 + /// + public static int? FindFreeUtageId(int musicId, Func isOccupied) + { + var baseId = musicId % 10000; + for (var seq = UtageIdStart / 10000; seq <= UtageMaxSeq; seq++) + { + var candidate = seq * 10000 + baseId; + if (!isOccupied(candidate)) return candidate; + } + + return null; + } /// /// 找出唯一启用的难度槽位。返回 null 表示通过,否则是错误码。 diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index 6cb3132d..dd8712f3 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -244,7 +244,6 @@ export interface GenreAddRequest { export interface GenreChangeRequest { /** @format int32 */ genreId?: number; - overwrite?: boolean; } export interface GenreChangeResult { diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index ce827d8f..c3bfd281 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -137,14 +137,14 @@ music: includeB15: Include in B15 utageConvertTitle: Change genre to Utage utageConvertConfirmPlain: The genre will be changed to Utage. Continue? - utageConvertConfirmIdOnly: The genre will be changed to Utage, and the music ID will be changed from {oldId} to {newId}. Continue? + utageConvertConfirmIdOnly: The genre will be changed to Utage, and the music ID will be changed to this song's Utage ID (the last four digits stay {last4}). Continue? utageConvertConfirmChartOnly: The genre will be changed to Utage, and the enabled {difficulty} chart will be converted to a Basic chart. Continue? - utageConvertConfirmBoth: The genre will be changed to Utage, the music ID will be changed from {oldId} to {newId}, and the enabled {difficulty} chart will be converted to a Basic chart. Continue? + utageConvertConfirmBoth: The genre will be changed to Utage, the music ID will be changed to this song's Utage ID (the last four digits stay {last4}), and the enabled {difficulty} chart will be converted to a Basic chart. Continue? utageConvertMultipleCharts: Multiple difficulties are enabled. Please keep only one enabled difficulty before converting. utageConvertNoChart: No chart is enabled. Please enable one before converting. utageConvertDoublePlayerChart: This chart contains Utage-only L and R charts and cannot be converted automatically. Please handle it manually first. - utageConvertIdExists: ID {newId} is already taken. Please delete the music with this ID first. - utageConvertOverwrite: ID {newId} is already taken. Overwrite the music with ID {newId}? + utageConvertIdExists: ID {newId} is already taken. Please deal with the music with this ID first. + utageConvertNoAvailableId: All Utage IDs of this song are taken, so it cannot be converted to Utage. utageConvertFailed: Failed to change to Utage utageRevertTitle: Change music genre utageRevertConfirm: The genre will be changed from Utage to {genreName}, and the music ID will be changed from {oldId} to {newId}. Continue? diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index d7718beb..c0ea860e 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -129,14 +129,14 @@ music: includeB15: 計入 B15 utageConvertTitle: 樂曲流派更改為宴會場 utageConvertConfirmPlain: 樂曲流派將更改為宴會場。是否繼續? - utageConvertConfirmIdOnly: 樂曲流派將更改為宴會場,並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? + utageConvertConfirmIdOnly: 樂曲流派將更改為宴會場,樂曲 ID 將改為該曲的宴會場 ID(末尾四位仍為 {last4})。是否繼續? utageConvertConfirmChartOnly: 樂曲流派將更改為宴會場,並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? - utageConvertConfirmBoth: 樂曲流派將更改為宴會場,把樂曲 ID 從 {oldId} 改為 {newId},並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? + utageConvertConfirmBoth: 樂曲流派將更改為宴會場,樂曲 ID 將改為該曲的宴會場 ID(末尾四位仍為 {last4}),並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? utageConvertMultipleCharts: 譜面有多個難度,請只保留一個啟用的難度再進行轉換 utageConvertNoChart: 目前沒有啟用的譜面,請先啟用一個難度再進行轉換 utageConvertDoublePlayerChart: 該譜面包含宴會場特有的 L 譜面和 R 譜面,不支援自動轉換,請手動處理後再操作 - utageConvertIdExists: ID {newId} 已被佔用,請先刪除該 ID 上的樂曲 - utageConvertOverwrite: ID {newId} 已被佔用,是否覆蓋 ID {newId} 的樂曲? + utageConvertIdExists: ID {newId} 已被佔用,請先處理該 ID 上的樂曲 + utageConvertNoAvailableId: 該曲可用的宴會場 ID 已全部被佔用,無法轉換為宴會場 utageConvertFailed: 變更為宴會場失敗 utageRevertTitle: 樂曲流派變更 utageRevertConfirm: 樂曲流派將從宴會場變更為 {genreName},並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index b7fae430..1134cac0 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -129,14 +129,14 @@ music: includeB15: 计入 B15 utageConvertTitle: 乐曲流派更改为宴会场 utageConvertConfirmPlain: 乐曲流派将更改为宴会场。是否继续? - utageConvertConfirmIdOnly: 乐曲流派将更改为宴会场,并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? + utageConvertConfirmIdOnly: 乐曲流派将更改为宴会场,乐曲 ID 将改为该曲的宴会场 ID(末尾四位仍为 {last4})。是否继续? utageConvertConfirmChartOnly: 乐曲流派将更改为宴会场,并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? - utageConvertConfirmBoth: 乐曲流派将更改为宴会场,把乐曲 ID 从 {oldId} 改为 {newId},并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? + utageConvertConfirmBoth: 乐曲流派将更改为宴会场,乐曲 ID 将改为该曲的宴会场 ID(末尾四位仍为 {last4}),并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? utageConvertMultipleCharts: 谱面有多个难度,请只保留一个启用的难度再进行转换 utageConvertNoChart: 当前没有启用的谱面,请先启用一个难度再进行转换 utageConvertDoublePlayerChart: 该谱面包含宴会场特有的 L 谱面和 R 谱面,不支持自动转换,请手动处理后再操作 - utageConvertIdExists: ID {newId} 已被占用,请先删除该 ID 上的乐曲 - utageConvertOverwrite: ID {newId} 已被占用,是否覆盖 ID {newId} 的乐曲? + utageConvertIdExists: ID {newId} 已被占用,请先处理该 ID 上的乐曲 + utageConvertNoAvailableId: 该曲可用的宴会场 ID 已全部被占用,无法转换为宴会场 utageConvertFailed: 更改为宴会场失败 utageRevertTitle: 乐曲流派更改 utageRevertConfirm: 乐曲流派将从宴会场更改为 {genreName},并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index 99aae472..fce0fff1 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -1,5 +1,5 @@ import { computed, defineComponent, onMounted, PropType, provide, ref, watch } from "vue"; -import { addVersionList, genreList, globalCapture, musicList, selectedADir, selectedMusic as info, selectMusicId, updateAddVersionList, updateGenreList, updateMusicList, selectedLevel, disableSync, gameVersion, b15ver } from "@/store/refs"; +import { addVersionList, genreList, globalCapture, selectedADir, selectedMusic as info, selectMusicId, updateAddVersionList, updateGenreList, updateMusicList, selectedLevel, disableSync, gameVersion, b15ver } from "@/store/refs"; import type { Chart, MusicXmlWithABJacket } from "@/client/apiGen"; import api from "@/client/api"; @@ -86,14 +86,8 @@ const Component = defineComponent({ } const oldId = info.value.id!; - const newId = oldId < 1e5 ? oldId + 1e5 : oldId; - const needChangeId = newId !== oldId; - const needOverwrite = needChangeId && hasIdConflict(newId); - - if (needOverwrite && !(await confirmOverwrite(newId, 'music.edit.utageConvertTitle'))) { - revertGenre(oldGenreId); - return; - } + // 宴会场 ID 由后端挑选(后四位固定、序号顺延),前端只用来生成文案 + const needChangeId = oldId < 1e5; const enabledIndex = enabledIndexes[0]; const difficulty = DIFFICULTY[enabledIndex]; @@ -104,7 +98,11 @@ const Component = defineComponent({ : (needConvertChart ? 'utageConvertConfirmChartOnly' : 'utageConvertConfirmPlain'); const confirmed = await showTransactionalDialog( t('music.edit.utageConvertTitle'), - t(`music.edit.${messageKey}`, { oldId, newId, difficulty }), + t(`music.edit.${messageKey}`, { + oldId, + last4: String(oldId % 1e4).padStart(4, '0'), + difficulty, + }), [{ text: t('common.cancel'), action: false }, { text: t('common.confirm'), action: true }] ); if (!confirmed) { @@ -112,8 +110,8 @@ const Component = defineComponent({ return; } - await requestGenreChange(UTAGE_GENRE, newId, oldGenreId, needOverwrite, - 'music.edit.utageConvertTitle', t('music.edit.utageConvertFailed')); + await requestGenreChange(UTAGE_GENRE, oldGenreId, 'music.edit.utageConvertTitle', + t('music.edit.utageConvertFailed')); }; // 从宴会场切回其他流派:宴会场 ID 需要减 100000 @@ -142,12 +140,6 @@ const Component = defineComponent({ return; } - const needOverwrite = hasIdConflict(newId); - if (needOverwrite && !(await confirmOverwrite(newId, 'music.edit.utageRevertTitle'))) { - revertGenre(oldGenreId); - return; - } - const genreName = genreList.value.find(genre => genre.id === newGenreId)?.genreName ?? ''; const confirmed = await showTransactionalDialog( t('music.edit.utageRevertTitle'), @@ -159,19 +151,10 @@ const Component = defineComponent({ return; } - await requestGenreChange(newGenreId, newId, oldGenreId, needOverwrite, - 'music.edit.utageRevertTitle', t('music.edit.utageRevertFailed')); + await requestGenreChange(newGenreId, oldGenreId, 'music.edit.utageRevertTitle', + t('music.edit.utageRevertFailed')); }; - // 目标 ID 在当前资源目录里已有乐曲时询问是否覆盖(其他目录/包里的同 ID 不受影响) - const hasIdConflict = (id: number) => musicList.value.some(music => music.id === id); - - const confirmOverwrite = async (id: number, titleKey: string) => showTransactionalDialog( - t(titleKey), - t('music.edit.utageConvertOverwrite', { newId: id }), - [{ text: t('common.cancel'), action: false }, { text: t('copy.overwrite'), action: true, danger: true }] - ); - // 后端返回的错误码 → 文案 const genreChangeErrorKeys: Record = { 'music-not-found': 'music.edit.utageConvertMusicNotFound', @@ -179,6 +162,7 @@ const Component = defineComponent({ 'multiple-enabled-charts': 'music.edit.utageConvertMultipleCharts', 'double-player-chart': 'music.edit.utageConvertDoublePlayerChart', 'target-id-exists': 'music.edit.utageConvertIdExists', + 'no-available-id': 'music.edit.utageConvertNoAvailableId', 'license-required': 'music.edit.utageConvertLicenseRequired', 'invalid-music-directory': 'music.edit.utageConvertInvalidMusicDir', 'invalid-chart-path': 'music.edit.utageConvertInvalidChartPath', @@ -188,18 +172,18 @@ const Component = defineComponent({ }; // 谱面转换 + ID 变更 + 流派变更都由后端一次做完,前端只负责刷新 - const requestGenreChange = async (genreId: number, targetId: number, oldGenreId: number | undefined, - overwrite: boolean, titleKey: string, failMessage: string) => { + const requestGenreChange = async (genreId: number, oldGenreId: number | undefined, + titleKey: string, failMessage: string) => { const oldId = info.value!.id!; try { - const result = (await api.ChangeMusicGenre(oldId, selectedADir.value, { genreId, overwrite })).data; + const result = (await api.ChangeMusicGenre(oldId, selectedADir.value, { genreId })).data; if (!result.success) { revertGenre(oldGenreId); const key = result.error ? genreChangeErrorKeys[result.error] : undefined; await showTransactionalDialog( t(titleKey), key - ? t(key, { newId: targetId }) + ? t(key, { newId: oldId - 1e5 }) : `${failMessage}${result.error ? ` (${result.error})` : ''}`, undefined, true ); From d6c9a9a7270c47d6fe3ae012869920eb86b73b84 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:29:00 +0800 Subject: [PATCH 08/22] =?UTF-8?q?fix:=20=E5=88=87=E5=87=BA=E5=AE=B4?= =?UTF-8?q?=E4=BC=9A=E5=9C=BA=E5=89=8D=E5=85=88=E5=88=A4=E6=96=AD=E5=BD=93?= =?UTF-8?q?=E5=89=8D=20ID=20=E6=98=AF=E5=90=A6=E4=B8=BA=E5=AE=B4=E4=BC=9A?= =?UTF-8?q?=E5=9C=BA=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 流派是宴会场但 ID 不是宴会场 ID(历史数据、手动改过)时,切回其它流派不再弹窗也不再改 ID,因为没有可恢复的 ID - 宴会场序号 ≥ 20 时减 100000 推不出普通曲 ID,前后端都改成明确提示用户手动改 ID,不再产生 2xxxx 之类的无效 ID - 宴会场 ID 的判定收敛到 consts.ts 的 isUtageId/UTAGE_ID_START 与 UtageConverter.IsUtageId,避免散落的 1e5 字面量 --- .../Music/MusicTransferController.cs | 9 ++++++- .../Controllers/Music/UtageConverter.cs | 11 ++++++++ MaiChartManager/Front/src/consts.ts | 3 +++ MaiChartManager/Front/src/locales/en.yaml | 1 + MaiChartManager/Front/src/locales/zh-TW.yaml | 1 + MaiChartManager/Front/src/locales/zh.yaml | 1 + .../src/views/Charts/MusicEdit/index.tsx | 26 ++++++++++++++----- 7 files changed, 44 insertions(+), 8 deletions(-) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 0ef8d4d2..0934e3fc 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -704,7 +704,14 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh // 含宴会场特有 L/R 双人谱的乐曲无法转成普通谱面 if (UtageConverter.HasSideCharts(music)) return new GenreChangeResult(false, GenreChangeError.DoublePlayerChart, id); - newId = id > UtageConverter.UtageIdStart ? id - UtageConverter.UtageIdStart : id; + // 流派是宴会场但 ID 不是宴会场 ID(历史数据、手动改过)时没有可恢复的 ID,保持 ID 不动 + if (UtageConverter.IsUtageId(id)) + { + var restored = UtageConverter.RestoreNormalId(id); + if (restored is null) + return new GenreChangeResult(false, GenreChangeError.UtageIdNotRestorable, id); + newId = restored.Value; + } } if (newId != id) diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/UtageConverter.cs index 22f81426..e770c808 100644 --- a/MaiChartManager/Controllers/Music/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/UtageConverter.cs @@ -15,6 +15,7 @@ internal static class GenreChangeError public const string DoublePlayerChart = "double-player-chart"; public const string TargetIdExists = "target-id-exists"; public const string NoAvailableId = "no-available-id"; + public const string UtageIdNotRestorable = "utage-id-not-restorable"; public const string LicenseRequired = "license-required"; public const string InvalidMusicDirectory = "invalid-music-directory"; public const string InvalidChartPath = "invalid-chart-path"; @@ -45,6 +46,16 @@ internal static class UtageConverter /// 是否为宴会场 ID(6 位,后四位是原曲 ID) public static bool IsUtageId(int id) => id is >= UtageIdStart and < 1_000_000; + /// + /// 宴会场 ID 对应的普通曲 ID(序号 10、11 分别对应 0、10000 号段)。 + /// 返回 null 表示序号太大、推不出普通曲 ID,只能让用户手动指定。 + /// + public static int? RestoreNormalId(int utageId) + { + var restored = utageId - UtageIdStart; + return IsUtageId(restored) ? null : restored; + } + /// /// 找出该曲下一个可用的宴会场 ID(后四位保持不变,只顺延序号)。 /// 用来避开「目标 ID 已被别的宴会场谱面占用」,这样就不需要覆盖掉已有乐曲。 diff --git a/MaiChartManager/Front/src/consts.ts b/MaiChartManager/Front/src/consts.ts index 1910b56f..ff55e85e 100644 --- a/MaiChartManager/Front/src/consts.ts +++ b/MaiChartManager/Front/src/consts.ts @@ -4,3 +4,6 @@ export const LEVEL_COLOR = ['rgb(34, 187, 91)', 'rgb(251, 156, 45)', 'rgb(246, 7 export const LEVEL_HUE = [150, 64, 17.17, 306.5, 315] as const; export const DIFFICULTY = ['Basic', 'Advanced', 'Expert', 'Master', 'Re:Master'] as const; export const UTAGE_GENRE = 107; +// 宴会场 ID = 序号 * 10000 + 原曲 ID 后四位,序号从 10 开始,所以宴会场 ID 起步就是 10 万 +export const UTAGE_ID_START = 1e5; +export const isUtageId = (id?: number) => (id ?? 0) >= UTAGE_ID_START; diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index c3bfd281..dba2953a 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -149,6 +149,7 @@ music: utageRevertTitle: Change music genre utageRevertConfirm: The genre will be changed from Utage to {genreName}, and the music ID will be changed from {oldId} to {newId}. Continue? utageRevertFailed: Failed to change genre from Utage + utageRevertNotRestorable: Cannot derive a normal music ID from Utage ID {oldId}. Please change the music ID manually first. utageConvertMusicNotFound: Could not find this music. Please refresh and try again. utageConvertLicenseRequired: A valid license is required to change the music ID. utageConvertInvalidMusicDir: The music directory does not exist or is not accessible. diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index c0ea860e..66f67fe9 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -141,6 +141,7 @@ music: utageRevertTitle: 樂曲流派變更 utageRevertConfirm: 樂曲流派將從宴會場變更為 {genreName},並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? utageRevertFailed: 從宴會場變更流派失敗 + utageRevertNotRestorable: 無法從宴會場 ID {oldId} 推回普通曲 ID,請先手動把樂曲 ID 改回普通曲 ID 再變更流派 utageConvertMusicNotFound: 找不到該樂曲,請重新整理後再試 utageConvertLicenseRequired: 需要有效的授權才能變更樂曲 ID utageConvertInvalidMusicDir: 樂曲目錄不存在或無法存取 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index 1134cac0..ba093879 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -141,6 +141,7 @@ music: utageRevertTitle: 乐曲流派更改 utageRevertConfirm: 乐曲流派将从宴会场更改为 {genreName},并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? utageRevertFailed: 从宴会场更改流派失败 + utageRevertNotRestorable: 无法从宴会场 ID {oldId} 推回普通曲 ID,请先手动把乐曲 ID 改回普通曲 ID 再切换流派 utageConvertMusicNotFound: 找不到该乐曲,请刷新后重试 utageConvertLicenseRequired: 需要有效的授权才能更改乐曲 ID utageConvertInvalidMusicDir: 乐曲目录不存在或不可访问 diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index fce0fff1..2c65d3a8 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -7,7 +7,7 @@ import JacketBox from "../../../components/JacketBox"; import dxIcon from "@/assets/dxIcon.png"; import stdIcon from "@/assets/stdIcon.png"; import ChartPanel from "./ChartPanel"; -import { DIFFICULTY, LEVEL_COLOR, LEVEL_HUE, UTAGE_GENRE } from "@/consts"; +import { DIFFICULTY, isUtageId, LEVEL_COLOR, LEVEL_HUE, UTAGE_GENRE, UTAGE_ID_START } from "@/consts"; import ProblemsDisplay from "@/components/ProblemsDisplay"; import AcbAwb from "@/views/Charts/MusicEdit/AcbAwb"; import GenreInput from "@/components/GenreInput"; @@ -30,7 +30,7 @@ const Component = defineComponent({ selectedLevel.value = firstEnabledChart; } const isUtage = computed(() => info.value !== undefined && - (info.value.genreId === UTAGE_GENRE || (info.value.id ?? 0) >= 1e5)); + (info.value.genreId === UTAGE_GENRE || isUtageId(info.value.id))); const chartIndexes = computed(() => isUtage.value ? [0] : [0, 1, 2, 3, 4]); const sync = (key: keyof MusicXmlWithABJacket, method: Function) => async () => { @@ -87,7 +87,7 @@ const Component = defineComponent({ const oldId = info.value.id!; // 宴会场 ID 由后端挑选(后四位固定、序号顺延),前端只用来生成文案 - const needChangeId = oldId < 1e5; + const needChangeId = !isUtageId(oldId); const enabledIndex = enabledIndexes[0]; const difficulty = DIFFICULTY[enabledIndex]; @@ -131,15 +131,26 @@ const Component = defineComponent({ } const oldId = info.value.id!; - const newId = oldId > 1e5 ? oldId - 1e5 : oldId; - // 不需要改 ID 时,保持原来的普通修改流程 - if (newId === oldId) { + // 流派是宴会场但 ID 不是宴会场 ID(历史数据、手动改过)时没有可恢复的 ID,直接改流派 + if (!isUtageId(oldId)) { info.value.modified = true; await api.EditMusicGenre(oldId, info.value.assetDir!, newGenreId); return; } + const newId = oldId - UTAGE_ID_START; + // 序号太大时推不出普通曲 ID,只能让用户手动改 + if (isUtageId(newId)) { + await showTransactionalDialog( + t('music.edit.utageRevertTitle'), + t('music.edit.utageRevertNotRestorable', { oldId }), + undefined, true + ); + revertGenre(oldGenreId); + return; + } + const genreName = genreList.value.find(genre => genre.id === newGenreId)?.genreName ?? ''; const confirmed = await showTransactionalDialog( t('music.edit.utageRevertTitle'), @@ -163,6 +174,7 @@ const Component = defineComponent({ 'double-player-chart': 'music.edit.utageConvertDoublePlayerChart', 'target-id-exists': 'music.edit.utageConvertIdExists', 'no-available-id': 'music.edit.utageConvertNoAvailableId', + 'utage-id-not-restorable': 'music.edit.utageRevertNotRestorable', 'license-required': 'music.edit.utageConvertLicenseRequired', 'invalid-music-directory': 'music.edit.utageConvertInvalidMusicDir', 'invalid-chart-path': 'music.edit.utageConvertInvalidChartPath', @@ -183,7 +195,7 @@ const Component = defineComponent({ await showTransactionalDialog( t(titleKey), key - ? t(key, { newId: oldId - 1e5 }) + ? t(key, { newId: oldId - UTAGE_ID_START, oldId }) : `${failMessage}${result.error ? ` (${result.error})` : ''}`, undefined, true ); From 8b419265a6db56ee9a979db804564626cb5b6903 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:29:21 +0800 Subject: [PATCH 09/22] =?UTF-8?q?fix:=20=E6=B5=81=E6=B4=BE=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=81=87=E5=88=B0=20unexpected=20=E6=97=B6=E8=A1=A5?= =?UTF-8?q?=E5=9B=9E=20Sentry=20=E4=B8=8A=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 失败分支不再把错误码直接拼进用户文案:有对应文案的错误码走提示弹窗 - 没有文案的错误码(unexpected 等)改为走 globalCapture 上报 Sentry 并显示带 errorId 的全局错误弹窗 --- .../Front/src/views/Charts/MusicEdit/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index 2c65d3a8..41ab7087 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -192,11 +192,14 @@ const Component = defineComponent({ if (!result.success) { revertGenre(oldGenreId); const key = result.error ? genreChangeErrorKeys[result.error] : undefined; + // 没有对应文案的错误码(unexpected 等后端异常)走全局错误弹窗并上报 Sentry + if (!key) { + await globalCapture(new Error(`${failMessage}: ${result.error ?? 'no error code'}`), failMessage); + return; + } await showTransactionalDialog( t(titleKey), - key - ? t(key, { newId: oldId - UTAGE_ID_START, oldId }) - : `${failMessage}${result.error ? ` (${result.error})` : ''}`, + t(key, { newId: oldId - UTAGE_ID_START, oldId }), undefined, true ); return; From 486affdede5486a11571ff592bc866f0bdb04965 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:29:35 +0800 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20=E6=94=B9=20ID=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E8=BF=94=E5=9B=9E=E4=B8=93=E9=97=A8=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 之前不论 ModifyIdCore 因为什么失败都映射成 invalid-music-directory,提示词会误导用户去查目录 - 新增 id-change-failed 错误码与三语文案 --- MaiChartManager/Controllers/Music/MusicTransferController.cs | 2 +- MaiChartManager/Controllers/Music/UtageConverter.cs | 1 + MaiChartManager/Front/src/locales/en.yaml | 1 + MaiChartManager/Front/src/locales/zh-TW.yaml | 1 + MaiChartManager/Front/src/locales/zh.yaml | 1 + MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx | 1 + 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 0934e3fc..19a33e4c 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -743,7 +743,7 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh { TryRollback(); logger.LogError("切换流派时改 ID 失败: {error}", error); - return new GenreChangeResult(false, GenreChangeError.InvalidMusicDirectory, id); + return new GenreChangeResult(false, GenreChangeError.IdChangeFailed, id); } targetId = newId; } diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/UtageConverter.cs index e770c808..c3263a96 100644 --- a/MaiChartManager/Controllers/Music/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/UtageConverter.cs @@ -18,6 +18,7 @@ internal static class GenreChangeError public const string UtageIdNotRestorable = "utage-id-not-restorable"; public const string LicenseRequired = "license-required"; public const string InvalidMusicDirectory = "invalid-music-directory"; + public const string IdChangeFailed = "id-change-failed"; public const string InvalidChartPath = "invalid-chart-path"; public const string ChartFileNotFound = "chart-file-not-found"; public const string SwapFailed = "swap-failed"; diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index dba2953a..997b4701 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -153,6 +153,7 @@ music: utageConvertMusicNotFound: Could not find this music. Please refresh and try again. utageConvertLicenseRequired: A valid license is required to change the music ID. utageConvertInvalidMusicDir: The music directory does not exist or is not accessible. + utageConvertIdChangeFailed: Failed to change the music ID. Please check the log. utageConvertInvalidChartPath: Invalid chart path. Please check the chart file names in Music.xml. utageConvertChartFileNotFound: The enabled difficulty has no chart file. Please make sure the chart files are complete. utageConvertSwapFailed: Failed to swap chart files. No chart was modified. diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index 66f67fe9..ba76acd8 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -145,6 +145,7 @@ music: utageConvertMusicNotFound: 找不到該樂曲,請重新整理後再試 utageConvertLicenseRequired: 需要有效的授權才能變更樂曲 ID utageConvertInvalidMusicDir: 樂曲目錄不存在或無法存取 + utageConvertIdChangeFailed: 修改樂曲 ID 失敗,請查看日誌 utageConvertInvalidChartPath: 譜面路徑不合法,請檢查 Music.xml 裡的譜面檔名 utageConvertChartFileNotFound: 啟用的難度找不到譜面檔案,請先確認譜面檔案完整 utageConvertSwapFailed: 交換譜面檔案失敗,譜面未被修改 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index ba093879..04609e2b 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -145,6 +145,7 @@ music: utageConvertMusicNotFound: 找不到该乐曲,请刷新后重试 utageConvertLicenseRequired: 需要有效的授权才能更改乐曲 ID utageConvertInvalidMusicDir: 乐曲目录不存在或不可访问 + utageConvertIdChangeFailed: 修改乐曲 ID 失败,请查看日志 utageConvertInvalidChartPath: 谱面路径不合法,请检查 Music.xml 里的谱面文件名 utageConvertChartFileNotFound: 启用的难度找不到谱面文件,请先确认谱面文件完整 utageConvertSwapFailed: 交换谱面文件失败,谱面未被修改 diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index 41ab7087..9466103a 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -177,6 +177,7 @@ const Component = defineComponent({ 'utage-id-not-restorable': 'music.edit.utageRevertNotRestorable', 'license-required': 'music.edit.utageConvertLicenseRequired', 'invalid-music-directory': 'music.edit.utageConvertInvalidMusicDir', + 'id-change-failed': 'music.edit.utageConvertIdChangeFailed', 'invalid-chart-path': 'music.edit.utageConvertInvalidChartPath', 'chart-file-not-found': 'music.edit.utageConvertChartFileNotFound', 'swap-failed': 'music.edit.utageConvertSwapFailed', From 817879b8db685f240f7727ebcebe22667c2f9055 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:29:53 +0800 Subject: [PATCH 11/22] =?UTF-8?q?refactor:=20=E6=B5=81=E6=B4=BE=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=8E=A5=E5=8F=A3=E7=BB=9F=E4=B8=80=E7=94=A8=E4=B9=90?= =?UTF-8?q?=E6=9B=B2=E8=87=AA=E5=B7=B1=E7=9A=84=20assetDir=20=E8=B0=83?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requestGenreChange 之前用 selectedADir,和文件里其它修改都用的 info.value.assetDir 不一致,跨目录场景下语义不同 - 改为统一取乐曲自身的 assetDir --- MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index 9466103a..8c3b7b32 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx @@ -189,7 +189,7 @@ const Component = defineComponent({ titleKey: string, failMessage: string) => { const oldId = info.value!.id!; try { - const result = (await api.ChangeMusicGenre(oldId, selectedADir.value, { genreId })).data; + const result = (await api.ChangeMusicGenre(oldId, info.value!.assetDir!, { genreId })).data; if (!result.success) { revertGenre(oldGenreId); const key = result.error ? genreChangeErrorKeys[result.error] : undefined; From 9bda2fb499ca5bc2b03f71b102bc43dea537d921 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:30:45 +0800 Subject: [PATCH 12/22] =?UTF-8?q?fix:=20=E7=BB=9F=E8=AE=A1=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E9=9A=BE=E5=BA=A6=E6=97=B6=E5=BF=BD=E7=95=A5=E7=AC=AC?= =?UTF-8?q?=206=20=E4=B8=AA=E9=9D=9E=E9=9A=BE=E5=BA=A6=E6=A7=BD=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 FindSingleEnabledChart 遍历了 Music.xml 全部 6 个槽位,前端只数 Basic~Re:Master 这 5 个,两边口径不一致 - 抽出 DifficultyCount 常量,后端只统计前 5 个槽位 --- MaiChartManager/Controllers/Music/UtageConverter.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/UtageConverter.cs index c3263a96..fa27342d 100644 --- a/MaiChartManager/Controllers/Music/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/UtageConverter.cs @@ -44,6 +44,11 @@ internal static class UtageConverter /// public const int UtageMaxSeq = 99; + /// + /// 难度槽位数量(Basic ~ Re:Master)。Music.xml 里的第 6 个槽位不是难度,统计启用难度时要排除。 + /// + public const int DifficultyCount = 5; + /// 是否为宴会场 ID(6 位,后四位是原曲 ID) public static bool IsUtageId(int id) => id is >= UtageIdStart and < 1_000_000; @@ -76,12 +81,13 @@ internal static class UtageConverter /// /// 找出唯一启用的难度槽位。返回 null 表示通过,否则是错误码。 + /// 只数 Basic~Re:Master 这前 5 个槽位,和前端展示的难度口径保持一致。 /// public static string? FindSingleEnabledChart(MusicXmlWithABJacket music, out int index) { index = -1; var enabledIndexes = new List(); - for (var i = 0; i < music.Charts.Length; i++) + for (var i = 0; i < Math.Min(music.Charts.Length, DifficultyCount); i++) { if (music.Charts[i].Enable) enabledIndexes.Add(i); } From 7a67a4cbfb59f27552706ac4c750ced6911434c1 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:30:51 +0800 Subject: [PATCH 13/22] =?UTF-8?q?chore:=20=E6=92=A4=E6=8E=89=20MusicContro?= =?UTF-8?q?ller.cs=20=E6=9C=AB=E5=B0=BE=E6=8D=A2=E8=A1=8C=E7=9A=84?= =?UTF-8?q?=E6=97=A0=E6=84=8F=E4=B9=89=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 该文件相对 main 只有文件末尾换行的差异,属于噪音,还原成 main 的版本 --- MaiChartManager/Controllers/Music/MusicController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MaiChartManager/Controllers/Music/MusicController.cs b/MaiChartManager/Controllers/Music/MusicController.cs index cb69340b..54fca47f 100644 --- a/MaiChartManager/Controllers/Music/MusicController.cs +++ b/MaiChartManager/Controllers/Music/MusicController.cs @@ -229,4 +229,4 @@ public void RequestOpenXml(int id, string assetDir) }); } } -} +} \ No newline at end of file From 7e1785571619027d8603fa88ae65c971a9527af5 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:32:09 +0800 Subject: [PATCH 14/22] =?UTF-8?q?refactor:=20UtageConverter=20=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20Music/Services=20=E9=A2=86=E5=9F=9F=E5=86=85?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 控制器目录下不该放非控制器类,按 Charts/Services 的先例下沉到 Controllers/Music/Services - 更新 Controllers/AGENTS.md 的目录说明 --- MaiChartManager/Controllers/AGENTS.md | 3 ++- MaiChartManager/Controllers/Music/MusicTransferController.cs | 1 + .../Controllers/Music/{ => Services}/UtageConverter.cs | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) rename MaiChartManager/Controllers/Music/{ => Services}/UtageConverter.cs (99%) diff --git a/MaiChartManager/Controllers/AGENTS.md b/MaiChartManager/Controllers/AGENTS.md index 75c41840..ee10fa58 100644 --- a/MaiChartManager/Controllers/AGENTS.md +++ b/MaiChartManager/Controllers/AGENTS.md @@ -15,6 +15,7 @@ ASP.NET Core REST API 控制器,按业务领域分 7 个子目录。所有控 | `Charts/Services/` | 谱面导入业务逻辑(领域内私有服务) | IMaidataImportService, MaidataImportService, LegacyMaidataImportService | | `Mod/` | AquaMai mod 配置、安装、路径管理 | ConfigurationController, InstallationController, ManualInstallController, MuModController | | `Music/` | 音乐 CRUD、音频/视频转换、批量操作、迁移、VRC 处理 | MusicController, MusicBatchController, CueConvertController, MovieConvertController, MusicTransferController, VrcProcessController | +| `Music/Services/` | 音乐领域内私有逻辑(宴会场谱面转换等) | UtageConverter | | `Tools/` | 独立音频/视频/图片转换工具接口 | AudioConvertToolController, VideoConvertToolController, ImageToAbToolController | ## WHERE TO LOOK @@ -52,6 +53,6 @@ ASP.NET Core REST API 控制器,按业务领域分 7 个子目录。所有控 - `Catagory/` 是历史 typo(正确拼写应为 Category),已是既定命名,保持一致,勿重命名 - 前端 API client(`Front/src/client/apiGen.ts`)由 `genClient.ts` 从这些控制器自动生成,修改接口后需重新运行 `pnpm genClient` - `Mod/ModPaths.cs` 不是控制器,是 Mod 子目录的路径常量辅助类 -- `Charts/Services/` 是领域内私有服务层(非全局 `Services/`),仅 Charts 控制器使用 +- `Charts/Services/`、`Music/Services/` 是领域内私有服务层(非全局 `Services/`),仅各自的控制器使用 - 控制器通过构造注入获取服务,返回强类型 DTO(非统一包装),部分 action 自行 try/catch 转业务结果 - `ChartController.ReplaceChart()` 根据 `StaticSettings.Config.UseLegacyMaiLib` 在新旧导入实现间切换 diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 19a33e4c..4f93914e 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using MaiChartManager.Models; using MaiChartManager.Platform; +using MaiChartManager.Controllers.Music.Services; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; using MuConvert.mai; diff --git a/MaiChartManager/Controllers/Music/UtageConverter.cs b/MaiChartManager/Controllers/Music/Services/UtageConverter.cs similarity index 99% rename from MaiChartManager/Controllers/Music/UtageConverter.cs rename to MaiChartManager/Controllers/Music/Services/UtageConverter.cs index fa27342d..efad80b0 100644 --- a/MaiChartManager/Controllers/Music/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/Services/UtageConverter.cs @@ -2,7 +2,7 @@ using MaiChartManager.Platform; using MusicXml = MaiChartManager.Models.MusicXml; -namespace MaiChartManager.Controllers.Music; +namespace MaiChartManager.Controllers.Music.Services; /// /// 流派切换接口的错误码,前端据此显示本地化文案。 From a1dc94d0f500b97fb5e635edbc00556adcf1f970 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 02:34:13 +0800 Subject: [PATCH 15/22] =?UTF-8?q?fix:=20=E5=AF=BC=E5=85=A5=E8=B0=B1?= =?UTF-8?q?=E9=9D=A2=E5=88=87=E6=8D=A2=E6=B5=81=E6=B4=BE=E6=97=B6=E6=8C=89?= =?UTF-8?q?=E5=90=8E=E5=9B=9B=E4=BD=8D=E8=AE=A1=E7=AE=97=E5=AE=B4=E4=BC=9A?= =?UTF-8?q?=E5=9C=BA=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原来用 id % 1e5 + 1e5,DX 曲会算成 115000(序号 11),而宴会场 ID 规则是「序号 * 10000 + 原曲后四位」,应以序号 10 起步得到 105000 - 宴会场 ID 本身不区分 STD/DX,切回普通流派时改为按 isDx 把 10000 补回来,不再依赖取模猜 - 输入框下限同步修正:宴会场模式下不再叠加 DX 的 10000 - consts.ts 抽出 UTAGE_SEQ_START,UTAGE_ID_START 由它推导 --- MaiChartManager/Front/src/consts.ts | 3 ++- .../ImportChartButton/ErrorDisplayIdInput.tsx | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/MaiChartManager/Front/src/consts.ts b/MaiChartManager/Front/src/consts.ts index ff55e85e..c51d6191 100644 --- a/MaiChartManager/Front/src/consts.ts +++ b/MaiChartManager/Front/src/consts.ts @@ -5,5 +5,6 @@ export const LEVEL_HUE = [150, 64, 17.17, 306.5, 315] as const; export const DIFFICULTY = ['Basic', 'Advanced', 'Expert', 'Master', 'Re:Master'] as const; export const UTAGE_GENRE = 107; // 宴会场 ID = 序号 * 10000 + 原曲 ID 后四位,序号从 10 开始,所以宴会场 ID 起步就是 10 万 -export const UTAGE_ID_START = 1e5; +export const UTAGE_SEQ_START = 10; +export const UTAGE_ID_START = UTAGE_SEQ_START * 1e4; export const isUtageId = (id?: number) => (id ?? 0) >= UTAGE_ID_START; diff --git a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx index 18b2b6c8..edf06355 100644 --- a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx +++ b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx @@ -5,7 +5,7 @@ import noJacket from '@/assets/noJacket.webp'; import { addVersionList, genreList, showNeedPurchaseDialog, version } from "@/store/refs"; import GenreInput from "@/components/GenreInput"; import VersionInput from "@/components/VersionInput"; -import { UTAGE_GENRE } from "@/consts"; +import { isUtageId, UTAGE_GENRE, UTAGE_ID_START, UTAGE_SEQ_START } from "@/consts"; import MusicIdConflictNotifier from "@/components/MusicIdConflictNotifier"; import { useI18n } from 'vue-i18n'; import ImportAlert from "@/views/Charts/ImportCreateChartButton/ImportChartButton/ImportAlert"; @@ -29,14 +29,19 @@ export default defineComponent({ set: (val) => props.closeModal() }) + // 宴会场 ID = 序号 * 10000 + 原曲 ID 后四位,序号从 10 开始。 + // 宴会场 ID 本身不区分 STD/DX,所以切流派时要按 isDx 补上/去掉那 10000。 watch([() => props.savedOptions.genreId, () => show.value], ([val]) => { for (const meta of props.meta) { - meta.id = meta.id % 1e5 + (val === UTAGE_GENRE ? 1e5 : 0); + const baseId = meta.id % 1e4; + // 已经在宴会场区间时沿用现有序号,避免把第二条宴会场谱面改成第一条 + const seq = isUtageId(meta.id) ? Math.floor(meta.id / 1e4) : UTAGE_SEQ_START; + meta.id = val === UTAGE_GENRE ? seq * 1e4 + baseId : baseId + (meta.isDx ? 1e4 : 0); } }) const hasInvalidUtageMapping = computed(() => props.meta.some(item => { - const isUtage = props.savedOptions.genreId === UTAGE_GENRE || item.id >= 1e5; + const isUtage = props.savedOptions.genreId === UTAGE_GENRE || isUtageId(item.id); if (!isUtage || item.maidataLevels.length < 2) return false; const mapping = item.utageMapping; return !mapping || mapping.isDoublePlayer && mapping.leftLevel === mapping.rightLevel; @@ -104,12 +109,12 @@ const MusicIdInput = defineComponent({ setup(props) { const { t } = useI18n(); const dxBase = computed(() => { - const dx = props.meta.isDx ? 1e4 : 0 - const utage = props.utage ? 1e5 : 0 - return dx + utage; + // 宴会场 ID 里不区分 STD/DX,序号从 10 起,所以下限就是宴会场 ID 的起点 + if (props.utage) return UTAGE_ID_START; + return props.meta.isDx ? 1e4 : 0 }); const img = computed(() => props.meta.bg ? URL.createObjectURL(props.meta.bg) : noJacket); - const isUtage = computed(() => props.utage || props.meta.id >= 1e5); + const isUtage = computed(() => props.utage || isUtageId(props.meta.id)); const levelOptions = computed(() => props.meta.maidataLevels.map(level => ({ label: `inote_${level}`, value: level, From a058f2d8d1dd8634ae627cc90b4c72cd3116e2b6 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 03:16:26 +0800 Subject: [PATCH 16/22] =?UTF-8?q?fix:=20=E5=AE=B4=E4=BC=9A=E5=9C=BA=20ID?= =?UTF-8?q?=20=E5=8F=AA=E5=9C=A8=E5=BA=8F=E5=8F=B7=2010=E3=80=8111=20?= =?UTF-8?q?=E6=97=B6=E6=89=8D=E8=83=BD=E6=8E=A8=E5=9B=9E=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E6=9B=B2=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 之前只判断「减 100000 后不在宴会场号段」,序号 12 以后的官方宴谱(如 120005)会算出 20005 这种不存在的普通曲 ID - 普通曲 ID 只有 0~9999(STD)和 10000~19999(DX)两段,落在范围外一律认为推不出来 --- .../Controllers/Music/Services/UtageConverter.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/MaiChartManager/Controllers/Music/Services/UtageConverter.cs b/MaiChartManager/Controllers/Music/Services/UtageConverter.cs index efad80b0..c60f97bc 100644 --- a/MaiChartManager/Controllers/Music/Services/UtageConverter.cs +++ b/MaiChartManager/Controllers/Music/Services/UtageConverter.cs @@ -49,17 +49,21 @@ internal static class UtageConverter /// public const int DifficultyCount = 5; + /// 普通曲 ID 的上限(不含):0~9999 是 STD,10000~19999 是 DX。 + public const int NormalIdLimit = 20_000; + /// 是否为宴会场 ID(6 位,后四位是原曲 ID) public static bool IsUtageId(int id) => id is >= UtageIdStart and < 1_000_000; /// - /// 宴会场 ID 对应的普通曲 ID(序号 10、11 分别对应 0、10000 号段)。 - /// 返回 null 表示序号太大、推不出普通曲 ID,只能让用户手动指定。 + /// 宴会场 ID 对应的普通曲 ID(序号 10、11 分别对应 STD、DX 号段)。 + /// 返回 null 表示推不出普通曲 ID(序号更靠后的官方宴谱),只能让用户手动指定。 /// public static int? RestoreNormalId(int utageId) { var restored = utageId - UtageIdStart; - return IsUtageId(restored) ? null : restored; + // 落在普通曲号段外的一律不认,避免算出 20005 这类不存在的 ID + return restored is >= 0 and < NormalIdLimit ? restored : null; } /// From b5b9a862a32166e3fe99e47a337effeac64aaadb Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 03:17:09 +0800 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20=E5=88=87=E5=88=B0=E5=AE=B4?= =?UTF-8?q?=E4=BC=9A=E5=9C=BA=E6=97=B6=E8=AE=B0=E5=BD=95=E5=8E=9F=20ID?= =?UTF-8?q?=EF=BC=8C=E5=88=87=E5=9B=9E=E6=97=B6=E8=BF=98=E5=8E=9F=20STD/DX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 宴会场 ID 是「序号 * 10000 + 原曲后四位」,不含 STD/DX 信息,DX 曲 15003 转成 105003 后只能减回 5003,切回来会变成 STD 曲或误报 target-id-exists - Music.xml 的 X-MCM 节点里记录 utageBaseId(切到宴会场前的原 ID),切回普通流派时优先用它还原,离开宴会场后清掉 - 没有记录的老数据(官方宴谱)仍然按序号硬算 --- .../Music/MusicTransferController.cs | 11 +++++++++- MaiChartManager/Models/MusicXml.cs | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 4f93914e..44877e6e 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -681,6 +681,8 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh var newId = id; var chartIndex = -1; + // 切到宴会场时记下的原 ID,宴会场 ID 里没有 STD/DX 信息,切回普通流派时只靠 ID 还原不回去 + int? utageBaseId = null; if (toUtage) { // 宴会场只认 Basic 谱面,先确认只有一个启用的难度 @@ -698,6 +700,7 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh candidate => settings.GetMusic(candidate, assetDir) is not null); if (freeId is null) return new GenreChangeResult(false, GenreChangeError.NoAvailableId, id); newId = freeId.Value; + utageBaseId = id; } } else @@ -708,7 +711,10 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh // 流派是宴会场但 ID 不是宴会场 ID(历史数据、手动改过)时没有可恢复的 ID,保持 ID 不动 if (UtageConverter.IsUtageId(id)) { - var restored = UtageConverter.RestoreNormalId(id); + // 优先还原切到宴会场时记录的原 ID(能保住 STD/DX),推不出来时才按序号硬算 + int? restored = music.UtageBaseId; + if (restored is null || UtageConverter.IsUtageId(restored.Value)) + restored = UtageConverter.RestoreNormalId(id); if (restored is null) return new GenreChangeResult(false, GenreChangeError.UtageIdNotRestorable, id); newId = restored.Value; @@ -739,6 +745,7 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh var targetId = id; if (newId != id) { + if (utageBaseId is not null) music.UtageBaseId = utageBaseId; var error = await ModifyIdCore(music, newId, assetDir); if (error is not null) { @@ -757,6 +764,8 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh } target.GenreId = genreId; + // 离开宴会场后原 ID 记录就没用了,清掉避免下次切回来时还原到错误的 ID + if (!toUtage) target.UtageBaseId = null; target.Save(); return new GenreChangeResult(true, null, targetId); } diff --git a/MaiChartManager/Models/MusicXml.cs b/MaiChartManager/Models/MusicXml.cs index 5b3c9677..177758b4 100644 --- a/MaiChartManager/Models/MusicXml.cs +++ b/MaiChartManager/Models/MusicXml.cs @@ -486,6 +486,28 @@ public string ShiftMethod } } + /// + /// 切到宴会场之前的乐曲 ID。宴会场 ID 是「序号 * 10000 + 原曲后四位」,不含 STD/DX 信息, + /// 所以切回普通流派时只靠 ID 无法还原成原来那一条,这里记下来供还原用。 + /// 置 null 会删掉这个节点。 + /// + public int? UtageBaseId + { + get => int.TryParse(RootNode.SelectSingleNode("X-MCM/utageBaseId")?.InnerText, out var result) ? result : null; + set + { + Modified = true; + if (value is null) + { + var node = RootNode.SelectSingleNode("X-MCM/utageBaseId"); + node?.ParentNode?.RemoveChild(node); + return; + } + + SelectSingleNodeOrCreate(RootNode, "X-MCM/utageBaseId").InnerText = value.Value.ToString(); + } + } + public class Chart { private readonly XmlNode _node; From 4c3351d97a8bb1f68fe410d86de516f256114f9d Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 03:17:22 +0800 Subject: [PATCH 18/22] =?UTF-8?q?fix:=20=E5=AF=BC=E5=85=A5=E6=97=B6?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=B5=81=E6=B4=BE=E4=B8=8D=E5=86=8D=E6=94=B9?= =?UTF-8?q?=E5=86=99=E6=89=8B=E8=BE=93=E7=9A=84=E6=99=AE=E9=80=9A=E6=9B=B2?= =?UTF-8?q?=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 之前切流派时无条件按 id % 1e4 重算,普通流派下手输的 11234 会被改成 1234 - 改成只有当前 ID 处于宴会场区间时才还原成普通 ID,本来就已经是普通 ID 时保持用户输入不动 --- .../ImportChartButton/ErrorDisplayIdInput.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx index edf06355..6b82f9f9 100644 --- a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx +++ b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx @@ -34,9 +34,14 @@ export default defineComponent({ watch([() => props.savedOptions.genreId, () => show.value], ([val]) => { for (const meta of props.meta) { const baseId = meta.id % 1e4; - // 已经在宴会场区间时沿用现有序号,避免把第二条宴会场谱面改成第一条 - const seq = isUtageId(meta.id) ? Math.floor(meta.id / 1e4) : UTAGE_SEQ_START; - meta.id = val === UTAGE_GENRE ? seq * 1e4 + baseId : baseId + (meta.isDx ? 1e4 : 0); + if (val === UTAGE_GENRE) { + // 已经在宴会场区间时沿用现有序号,避免把第二条宴会场谱面改成第一条 + const seq = isUtageId(meta.id) ? Math.floor(meta.id / 1e4) : UTAGE_SEQ_START; + meta.id = seq * 1e4 + baseId; + } else if (isUtageId(meta.id)) { + // 只在从宴会场 ID 还原时改写;本来就是普通 ID 的话保留用户手输的值 + meta.id = baseId + (meta.isDx ? 1e4 : 0); + } } }) From 7d547bddbf9a78c483869e0d3da2dd5a6886447c Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 03:21:12 +0800 Subject: [PATCH 19/22] =?UTF-8?q?feat:=20=E5=AF=BC=E5=85=A5=E5=AE=B4?= =?UTF-8?q?=E4=BC=9A=E5=9C=BA=E8=B0=B1=E9=9D=A2=E6=97=B6=E4=B8=80=E5=B9=B6?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8E=9F=E6=9B=B2=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导入时可以直接把谱面导入成宴会场曲,这条路径不写 utageBaseId,导致这类曲目以后切回普通流派只能按序号硬算、DX 曲会退回成 STD 曲 - 导入/替换宴会场谱面时按「原曲后四位 + 谱面本身的 STD/DX」记录 utageBaseId,和切流派时记录的值语义一致 --- .../Controllers/Charts/Services/MaidataImportService.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs b/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs index 0ca704aa..ef86521f 100644 --- a/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs +++ b/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs @@ -241,6 +241,9 @@ public ImportChartResult ImportMaidata( { music.UtagePlayStyle = utageLevelMap?.Values.Any(side => side is not null) == true ? 1 : 0; music.Charts[0].MaxNotes = 0; + // 宴会场 ID 里只有原曲 ID 的后四位,没有 STD/DX 信息,这里把 import 的谱面类型补上, + // 以后切回普通流派时才能还原成原来那一条 + music.UtageBaseId = id % 10000 + (validCharts.Any(chart => chart.IsDxChart) ? 10000 : 0); } float lastChartBpm = 0; // 最后一个谱面的bpm,当没有指定wholebpm时用作fallback From 799f93575293e5b8609e43d40761651e10db8753 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 03:25:57 +0800 Subject: [PATCH 20/22] =?UTF-8?q?fix:=20=E5=8E=9F=20ID=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E7=9A=84=E5=90=8E=E5=9B=9B=E4=BD=8D=E5=AF=B9=E4=B8=8D?= =?UTF-8?q?=E4=B8=8A=E6=97=B6=E4=B8=8D=E5=86=8D=E9=87=87=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 宴会场 ID 中途被改过(例如通过改 ID 对话框)后,UtageBaseId 里的旧原 ID 会被直接采用,切回普通流派时得到和当前 ID 不匹配的普通曲 ID - 现在只有记录值确实是普通曲 ID、且后四位与当前宴会场 ID 一致时才采用,否则退化到按序号推算 --- .../Controllers/Music/MusicTransferController.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 44877e6e..11fb1da8 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -711,10 +711,14 @@ public async Task ChangeMusicGenre(int id, [FromBody] GenreCh // 流派是宴会场但 ID 不是宴会场 ID(历史数据、手动改过)时没有可恢复的 ID,保持 ID 不动 if (UtageConverter.IsUtageId(id)) { - // 优先还原切到宴会场时记录的原 ID(能保住 STD/DX),推不出来时才按序号硬算 - int? restored = music.UtageBaseId; - if (restored is null || UtageConverter.IsUtageId(restored.Value)) - restored = UtageConverter.RestoreNormalId(id); + // 优先还原切到宴会场时记录的原 ID(能保住 STD/DX)。 + // 记录值的后四位必须和当前宴会场 ID 对得上,否则说明中途改过 ID,记录已经不可信了。 + int? restored = null; + if (music.UtageBaseId is { } recorded + && !UtageConverter.IsUtageId(recorded) + && recorded % 10000 == id % 10000) + restored = recorded; + restored ??= UtageConverter.RestoreNormalId(id); if (restored is null) return new GenreChangeResult(false, GenreChangeError.UtageIdNotRestorable, id); newId = restored.Value; From 6bc08a960e043751bddeefca80eb5ec7869a00c4 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 03:26:15 +0800 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20=E6=94=B9=20ID=20=E6=97=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=AE=B4=E4=BC=9A=E5=9C=BA=E5=8E=9F=E6=9B=B2?= =?UTF-8?q?=20ID=20=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 手动改 ID(改 ID 对话框)走的是 ModifyIdCore,之前不会动 utageBaseId,导致记录陈旧 - 新增 SyncUtageBaseId:新 ID 仍是宴会场 ID 时把记录的后四位一起换掉并保留 STD/DX,改回普通 ID 时清空记录 --- .../Controllers/Music/MusicTransferController.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 11fb1da8..bd57fab4 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -646,6 +646,7 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) // 保存 XML music.Id = newId; + SyncUtageBaseId(music, newId); music.Save(); Directory.CreateDirectory(Path.Combine(StaticSettings.StreamingAssets, assetDir, "music")); logger.LogInformation("Move music dir: {oldMusicDir} -> {newMusicDir}", oldMusicDir, newMusicDir); @@ -656,6 +657,18 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) return null; } + /// + /// 改完 ID 后同步宴会场用的原曲 ID 记录:新 ID 还是宴会场 ID 就跟着换后四位(保留 STD/DX), + /// 改回普通 ID 就清掉,避免以后切流派时还原到错误的 ID。 + /// + private static void SyncUtageBaseId(MusicXmlWithABJacket music, int newId) + { + if (music.UtageBaseId is not { } utageBaseId) return; + music.UtageBaseId = UtageConverter.IsUtageId(newId) + ? newId % 10000 + (utageBaseId >= 10000 ? 10000 : 0) + : null; + } + /// /// 切换乐曲流派。切到/切回宴会场时,ID 变更(宴会场 ID 固定后四位、顺延序号)和谱面转换 /// 都在这个接口里一次做完,避免前端分步调用留下半改状态。 From 7ddad3d1056b065f111ca8a15eed47c9bf8ed9d6 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 03:34:39 +0800 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20=E5=AF=BC=E5=85=A5=E5=AE=B4?= =?UTF-8?q?=E4=BC=9A=E5=9C=BA=E8=B0=B1=E9=9D=A2=E6=97=B6=E7=AD=89=E8=B0=B1?= =?UTF-8?q?=E9=9D=A2=E5=86=99=E5=AE=8C=E5=86=8D=E8=AE=B0=E5=BD=95=E5=8E=9F?= =?UTF-8?q?=E6=9B=B2=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utageBaseId 原本写在可能 Fatal 返回的谱面写入循环之前,而 GetMusic 返回的是缓存实例,导入失败后脏值会在之后任意一次 Save 时被写进 XML - 挪到谱面写入和 L/R 清理之后再赋值,半路失败就不会留下错误的原曲 ID 记录 --- .../Controllers/Charts/Services/MaidataImportService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs b/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs index ef86521f..227b1d5c 100644 --- a/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs +++ b/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs @@ -241,9 +241,6 @@ public ImportChartResult ImportMaidata( { music.UtagePlayStyle = utageLevelMap?.Values.Any(side => side is not null) == true ? 1 : 0; music.Charts[0].MaxNotes = 0; - // 宴会场 ID 里只有原曲 ID 的后四位,没有 STD/DX 信息,这里把 import 的谱面类型补上, - // 以后切回普通流派时才能还原成原来那一条 - music.UtageBaseId = id % 10000 + (validCharts.Any(chart => chart.IsDxChart) ? 10000 : 0); } float lastChartBpm = 0; // 最后一个谱面的bpm,当没有指定wholebpm时用作fallback @@ -309,6 +306,11 @@ public ImportChartResult ImportMaidata( File.Delete(basePath.Replace(".ma2", "_L.ma2")); File.Delete(basePath.Replace(".ma2", "_R.ma2")); } + + // 谱面都写完了再记原曲 ID:宴会场 ID 里只有后四位、没有 STD/DX 信息, + // 把谱面类型补上,以后切回普通流派时才能还原成原来那一条。 + // 放在这里而不是前面的 if (isUtage) 里,是为了半路 Fatal 时不会把缓存实例里的记录改脏。 + music.UtageBaseId = id % 10000 + (validCharts.Any(chart => chart.IsDxChart) ? 10000 : 0); } if (!isReplacement)