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/Charts/Services/MaidataImportService.cs b/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs index 0ca704aa..227b1d5c 100644 --- a/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs +++ b/MaiChartManager/Controllers/Charts/Services/MaidataImportService.cs @@ -306,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) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 5754761a..bd57fab4 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; @@ -22,6 +23,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); public enum MaidataSubdirMode { @@ -454,6 +457,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) @@ -583,15 +591,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,19 +616,37 @@ 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; - 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; } // 保存 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); @@ -619,6 +654,159 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) // 重新扫描全部 await settings.RescanAll(); + 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 固定后四位、顺延序号)和谱面转换 + /// 都在这个接口里一次做完,避免前端分步调用留下半改状态。 + /// 返回 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; + // 切到宴会场时记下的原 ID,宴会场 ID 里没有 STD/DX 信息,切回普通流派时只靠 ID 还原不回去 + int? utageBaseId = null; + if (toUtage) + { + // 宴会场只认 Basic 谱面,先确认只有一个启用的难度 + var error = UtageConverter.FindSingleEnabledChart(music, out chartIndex); + if (error is not null) return new GenreChangeResult(false, error, 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; + utageBaseId = id; + } + } + else + { + // 含宴会场特有 L/R 双人谱的乐曲无法转成普通谱面 + if (UtageConverter.HasSideCharts(music)) + return new GenreChangeResult(false, GenreChangeError.DoublePlayerChart, id); + // 流派是宴会场但 ID 不是宴会场 ID(历史数据、手动改过)时没有可恢复的 ID,保持 ID 不动 + if (UtageConverter.IsUtageId(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; + } + } + + if (newId != id) + { +#if WINDOWS + if (IapManager.License != IapManager.LicenseStatus.Active) + return new GenreChangeResult(false, GenreChangeError.LicenseRequired, id); +#endif + // 切到宴会场时挑的本来就是空闲 ID,只有切回普通流派时目标 ID 可能已被占用 + if (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) + { + if (utageBaseId is not null) music.UtageBaseId = utageBaseId; + var error = await ModifyIdCore(music, newId, assetDir); + if (error is not null) + { + TryRollback(); + logger.LogError("切换流派时改 ID 失败: {error}", error); + return new GenreChangeResult(false, GenreChangeError.IdChangeFailed, id); + } + targetId = newId; + } + + var target = settings.GetMusic(targetId, assetDir); + if (target is null) + { + TryRollback(); + return new GenreChangeResult(false, GenreChangeError.MusicNotFound, id); + } + + target.GenreId = genreId; + // 离开宴会场后原 ID 记录就没用了,清掉避免下次切回来时还原到错误的 ID + if (!toUtage) target.UtageBaseId = null; + target.Save(); + return new GenreChangeResult(true, null, targetId); + } + catch (Exception e) + { + logger.LogError(e, "切换流派失败: {id} -> {genreId}", id, genreId); + TryRollback(); + return new GenreChangeResult(false, GenreChangeError.Unexpected, id); + } + + // 谱面已经转换成功、但后续步骤失败时,把谱面换回去 + void TryRollback() + { + if (!converted) return; + try + { + var error = UtageConverter.RestoreFromBasic(music, chartIndex, logger); + if (error is not null) logger.LogError("回滚谱面转换失败: {error}", error); + } + catch (Exception rollbackError) + { + logger.LogError(rollbackError, "回滚谱面转换失败"); + } + } } [HttpGet] @@ -950,4 +1138,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/Services/UtageConverter.cs b/MaiChartManager/Controllers/Music/Services/UtageConverter.cs new file mode 100644 index 00000000..c60f97bc --- /dev/null +++ b/MaiChartManager/Controllers/Music/Services/UtageConverter.cs @@ -0,0 +1,264 @@ +using MaiChartManager.Models; +using MaiChartManager.Platform; +using MusicXml = MaiChartManager.Models.MusicXml; + +namespace MaiChartManager.Controllers.Music.Services; + +/// +/// 流派切换接口的错误码,前端据此显示本地化文案。 +/// +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 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 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"; + public const string Unexpected = "unexpected"; +} + +/// +/// 乐曲在「宴会场」与其他流派之间切换时的谱面转换工具。 +/// 宴会场只识别 Basic(绿谱)槽位的谱面,所以切到宴会场时要把唯一启用的难度整体挪到 Basic 槽位。 +/// +internal static class UtageConverter +{ + public const int UtageGenreId = 107; + + /// + /// 宴会场 ID 的起点。宴会场 ID 的规则是 `序号 * 10000 + 原曲 ID 的后四位`,序号从 10 开始, + /// 所以同一首歌可以同时拥有 100xxx / 110xxx / 120xxx…… 多条宴会场谱面。 + /// + public const int UtageIdStart = 100_000; + + /// + /// 宴会场序号的最后一个。再往后就进 OocMod 的 7 位自定义曲目命名空间了,不算宴会场。 + /// + public const int UtageMaxSeq = 99; + + /// + /// 难度槽位数量(Basic ~ Re:Master)。Music.xml 里的第 6 个槽位不是难度,统计启用难度时要排除。 + /// + 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 分别对应 STD、DX 号段)。 + /// 返回 null 表示推不出普通曲 ID(序号更靠后的官方宴谱),只能让用户手动指定。 + /// + public static int? RestoreNormalId(int utageId) + { + var restored = utageId - UtageIdStart; + // 落在普通曲号段外的一律不认,避免算出 20005 这类不存在的 ID + return restored is >= 0 and < NormalIdLimit ? restored : null; + } + + /// + /// 找出该曲下一个可用的宴会场 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 表示通过,否则是错误码。 + /// 只数 Basic~Re:Master 这前 5 个槽位,和前端展示的难度口径保持一致。 + /// + public static string? FindSingleEnabledChart(MusicXmlWithABJacket music, out int index) + { + index = -1; + var enabledIndexes = new List(); + for (var i = 0; i < Math.Min(music.Charts.Length, DifficultyCount); 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, requireSourceFile: true, logger); + + /// + /// MoveToBasic 的逆操作,用于失败时回滚。 + /// 回滚时源难度的谱面文件可能已经不存在(它被挪到了 Basic),所以不校验文件。 + /// + public static string? RestoreFromBasic(MusicXmlWithABJacket music, int sourceIndex, ILogger logger) + => SwapWithBasic(music, sourceIndex, sourceIsEnabled: true, requireSourceFile: false, logger); + + private static string? SwapWithBasic(MusicXmlWithABJacket music, int sourceIndex, bool sourceIsEnabled, + bool requireSourceFile, 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]; + + // 谱面路径必须落在乐曲目录内,防止 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 对不上。 + // 回滚方向不做这个校验:转换后源难度的文件已经挪走了。 + 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; + + // 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"))); + + /// + /// 乐曲是否存在宴会场特有的双人谱(_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 + { + var fullPath = Path.GetFullPath(Path.Combine(musicDir, path)); + var root = Path.GetFullPath(musicDir) + Path.DirectorySeparatorChar; + // Windows 文件系统大小写不敏感,Linux 敏感,比较方式要和平台一致 + return fullPath.StartsWith(root, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + catch (Exception) + { + // 非法路径字符等,直接当作不合法处理 + return false; + } + } + + 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 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)>(); + + try + { + // 1. 源谱面先挪到临时名,给旧的 Basic 谱面腾位置 + if (otherExists) Move(otherFile, tempFile); + // 2. 旧的 Basic 谱面挪到源难度的文件名 + if (basicExists) Move(basicFile, otherFile); + // 3. 临时文件挪到 Basic 的文件名 + if (otherExists) 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 c280581e..dd8712f3 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -241,6 +241,18 @@ export interface GenreAddRequest { assetDir?: string | null; } +export interface GenreChangeRequest { + /** @format int32 */ + genreId?: number; +} + +export interface GenreChangeResult { + success?: boolean; + error?: string | null; + /** @format int32 */ + newId?: number; +} + export interface GenreEditRequest { name?: string | null; nameTwoLine?: string | null; @@ -2481,6 +2493,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/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/consts.ts b/MaiChartManager/Front/src/consts.ts index 1910b56f..c51d6191 100644 --- a/MaiChartManager/Front/src/consts.ts +++ b/MaiChartManager/Front/src/consts.ts @@ -4,3 +4,7 @@ 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_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/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 76bf0f81..997b4701 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -135,6 +135,29 @@ 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 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 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 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? + 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. + 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. + 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 bec90c6a..ba76acd8 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -127,6 +127,29 @@ music: audioPreviewEnd: 終點時間(秒) includeB35: 計入 B35 includeB15: 計入 B15 + utageConvertTitle: 樂曲流派更改為宴會場 + utageConvertConfirmPlain: 樂曲流派將更改為宴會場。是否繼續? + utageConvertConfirmIdOnly: 樂曲流派將更改為宴會場,樂曲 ID 將改為該曲的宴會場 ID(末尾四位仍為 {last4})。是否繼續? + utageConvertConfirmChartOnly: 樂曲流派將更改為宴會場,並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? + utageConvertConfirmBoth: 樂曲流派將更改為宴會場,樂曲 ID 將改為該曲的宴會場 ID(末尾四位仍為 {last4}),並把已啟用的 {difficulty} 譜面轉換為 Basic 譜面。是否繼續? + utageConvertMultipleCharts: 譜面有多個難度,請只保留一個啟用的難度再進行轉換 + utageConvertNoChart: 目前沒有啟用的譜面,請先啟用一個難度再進行轉換 + utageConvertDoublePlayerChart: 該譜面包含宴會場特有的 L 譜面和 R 譜面,不支援自動轉換,請手動處理後再操作 + utageConvertIdExists: ID {newId} 已被佔用,請先處理該 ID 上的樂曲 + utageConvertNoAvailableId: 該曲可用的宴會場 ID 已全部被佔用,無法轉換為宴會場 + utageConvertFailed: 變更為宴會場失敗 + utageRevertTitle: 樂曲流派變更 + utageRevertConfirm: 樂曲流派將從宴會場變更為 {genreName},並把樂曲 ID 從 {oldId} 改為 {newId}。是否繼續? + utageRevertFailed: 從宴會場變更流派失敗 + utageRevertNotRestorable: 無法從宴會場 ID {oldId} 推回普通曲 ID,請先手動把樂曲 ID 改回普通曲 ID 再變更流派 + utageConvertMusicNotFound: 找不到該樂曲,請重新整理後再試 + utageConvertLicenseRequired: 需要有效的授權才能變更樂曲 ID + utageConvertInvalidMusicDir: 樂曲目錄不存在或無法存取 + utageConvertIdChangeFailed: 修改樂曲 ID 失敗,請查看日誌 + 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 ee8230a5..04609e2b 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -127,6 +127,29 @@ music: audioPreviewEnd: 终点时间(秒) includeB35: 计入 B35 includeB15: 计入 B15 + utageConvertTitle: 乐曲流派更改为宴会场 + utageConvertConfirmPlain: 乐曲流派将更改为宴会场。是否继续? + utageConvertConfirmIdOnly: 乐曲流派将更改为宴会场,乐曲 ID 将改为该曲的宴会场 ID(末尾四位仍为 {last4})。是否继续? + utageConvertConfirmChartOnly: 乐曲流派将更改为宴会场,并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? + utageConvertConfirmBoth: 乐曲流派将更改为宴会场,乐曲 ID 将改为该曲的宴会场 ID(末尾四位仍为 {last4}),并把已启用的 {difficulty} 谱面转换为 Basic 谱面。是否继续? + utageConvertMultipleCharts: 谱面有多个难度,请只保留一个启用的难度再进行转换 + utageConvertNoChart: 当前没有启用的谱面,请先启用一个难度再进行转换 + utageConvertDoublePlayerChart: 该谱面包含宴会场特有的 L 谱面和 R 谱面,不支持自动转换,请手动处理后再操作 + utageConvertIdExists: ID {newId} 已被占用,请先处理该 ID 上的乐曲 + utageConvertNoAvailableId: 该曲可用的宴会场 ID 已全部被占用,无法转换为宴会场 + utageConvertFailed: 更改为宴会场失败 + utageRevertTitle: 乐曲流派更改 + utageRevertConfirm: 乐曲流派将从宴会场更改为 {genreName},并把乐曲 ID 从 {oldId} 改为 {newId}。是否继续? + utageRevertFailed: 从宴会场更改流派失败 + utageRevertNotRestorable: 无法从宴会场 ID {oldId} 推回普通曲 ID,请先手动把乐曲 ID 改回普通曲 ID 再切换流派 + utageConvertMusicNotFound: 找不到该乐曲,请刷新后重试 + utageConvertLicenseRequired: 需要有效的授权才能更改乐曲 ID + utageConvertInvalidMusicDir: 乐曲目录不存在或不可访问 + utageConvertIdChangeFailed: 修改乐曲 ID 失败,请查看日志 + utageConvertInvalidChartPath: 谱面路径不合法,请检查 Music.xml 里的谱面文件名 + utageConvertChartFileNotFound: 启用的难度找不到谱面文件,请先确认谱面文件完整 + utageConvertSwapFailed: 交换谱面文件失败,谱面未被修改 + utageConvertUnexpected: 切换流派失败,请查看日志 versionHint: >- 如果游戏版本是 1.{gameVersion} 的话,这里的数字大于等于 {b15ver} 就会让歌出现在 B15 里面,否则就会出现在 B35 里面。这个值还会控制按上线时间排序时的顺序 diff --git a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx index 18b2b6c8..6b82f9f9 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,24 @@ 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; + 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); + } } }) 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 +114,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, diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/index.tsx index 7692d4a4..8c3b7b32 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"; @@ -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'; @@ -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 () => { @@ -44,11 +44,195 @@ 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 || value === undefined) return; + info.value.genreId = value; + }; + + // 宴会场只认 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) { + await showTransactionalDialog( + t('music.edit.utageConvertTitle'), + enabledIndexes.length === 0 + ? t('music.edit.utageConvertNoChart') + : t('music.edit.utageConvertMultipleCharts'), + undefined, true + ); + revertGenre(oldGenreId); + return; + } + + // 宴会场特有的双人谱(L/R)没法直接转换 + if (info.value.utagePlayStyle === 1) { + await showTransactionalDialog( + t('music.edit.utageConvertTitle'), + t('music.edit.utageConvertDoublePlayerChart'), + undefined, true + ); + revertGenre(oldGenreId); + return; + } + + const oldId = info.value.id!; + // 宴会场 ID 由后端挑选(后四位固定、序号顺延),前端只用来生成文案 + const needChangeId = !isUtageId(oldId); + + 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, + last4: String(oldId % 1e4).padStart(4, '0'), + difficulty, + }), + [{ text: t('common.cancel'), action: false }, { text: t('common.confirm'), action: true }] + ); + if (!confirmed) { + revertGenre(oldGenreId); + return; + } + + await requestGenreChange(UTAGE_GENRE, oldGenreId, 'music.edit.utageConvertTitle', + t('music.edit.utageConvertFailed')); + }; + + // 从宴会场切回其他流派:宴会场 ID 需要减 100000 + const revertGenreFromUtage = async (newGenreId: number, oldGenreId?: number) => { + if (!info.value) return; + info.value.genreId = newGenreId; + + // 含宴会场特有 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!; + + // 流派是宴会场但 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'), + t('music.edit.utageRevertConfirm', { genreName, oldId, newId }), + [{ text: t('common.cancel'), action: false }, { text: t('common.confirm'), action: true }] + ); + if (!confirmed) { + revertGenre(oldGenreId); + return; + } + + await requestGenreChange(newGenreId, oldGenreId, 'music.edit.utageRevertTitle', + t('music.edit.utageRevertFailed')); + }; + + // 后端返回的错误码 → 文案 + 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', + 'no-available-id': 'music.edit.utageConvertNoAvailableId', + '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', + 'unexpected': 'music.edit.utageConvertUnexpected', + }; + + // 谱面转换 + ID 变更 + 流派变更都由后端一次做完,前端只负责刷新 + const requestGenreChange = async (genreId: number, oldGenreId: number | undefined, + titleKey: string, failMessage: string) => { + const oldId = info.value!.id!; + try { + const result = (await api.ChangeMusicGenre(oldId, info.value!.assetDir!, { genreId })).data; + 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), + t(key, { newId: oldId - UTAGE_ID_START, oldId }), + undefined, true + ); + return; + } + await updateMusicList(); + if (result.newId && result.newId !== oldId) selectMusicId.value = result.newId; + } catch (e) { + revertGenre(oldGenreId); + await globalCapture(e, failMessage); + } + }; + // 流派改动的唯一入口。不能靠 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 (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?.genreId, sync('genreId', api.EditMusicGenre)); watch(() => info.value?.addVersionId, sync('addVersionId', api.EditMusicAddVersion)); watch(() => info.value?.utageKanji, sync('utageKanji', api.EditMusicUtageKanji)); watch(() => info.value?.comment, sync('comment', api.EditMusicComment)); @@ -106,7 +290,7 @@ const Component = defineComponent({
{t('music.edit.genre')}
- +
{t('music.edit.versionCategory')}
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;