Skip to content

Commit e3bf36a

Browse files
committed
feat(batch): 重构批量处理对齐 MCM 流式导出模式
- ChooseAction 用 Radio 列表 + Popover hover 提示 A000 限制 - 新增批量导出 OPT 包、UGC/SUS 谱面(按曲名 / 按 ID) - 流式 ZipReader + showDirectoryPicker 边解压边写盘 - 并行 worker 基于 CPU 核数(UGC/SUS 用 CPU/4,OPT 用 CPU/2) - 子目录分组选项(无 / 按流派),useStorage 持久化 - 进度细化显示当前曲名 + 百分比 - ExportCustom 加 stripRoot 参数控制外层目录命名 - 抽出 EditProps/ProgressDisplay 独立组件
1 parent 33bbe8c commit e3bf36a

10 files changed

Lines changed: 517 additions & 156 deletions

File tree

ChuChartManager/Controllers/MusicController.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,7 +1101,7 @@ public ActionResult ExportOpt([FromQuery] int id, [FromQuery] string assetDir)
11011101
}
11021102

11031103
[HttpGet]
1104-
public ActionResult ExportCustom([FromQuery] int id, [FromQuery] string assetDir, [FromQuery] string format = "ugc")
1104+
public ActionResult ExportCustom([FromQuery] int id, [FromQuery] string assetDir, [FromQuery] string format = "ugc", [FromQuery] bool stripRoot = false)
11051105
{
11061106
var scanner = scannerService.Scanner;
11071107
if (scanner == null) return NotFound();
@@ -1112,6 +1112,7 @@ public ActionResult ExportCustom([FromQuery] int id, [FromQuery] string assetDir
11121112
var safeName = string.Join("_", music.Name.Split(Path.GetInvalidFileNameChars())).TrimEnd('.', ' ');
11131113
var ext = format.ToLowerInvariant() == "sus" ? "sus" : "ugc";
11141114
var diffFileNames = new[] { "bas", "adv", "exp", "mas", "ult", "we" };
1115+
var prefix = stripRoot ? "" : $"{safeName}/";
11151116

11161117
var ms = new MemoryStream();
11171118
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, true))
@@ -1150,21 +1151,21 @@ public ActionResult ExportCustom([FromQuery] int id, [FromQuery] string assetDir
11501151
}
11511152

11521153
var fileName = i < diffFileNames.Length ? diffFileNames[i] : $"diff{i}";
1153-
var entry = zip.CreateEntry($"{safeName}/{fileName}.{ext}");
1154+
var entry = zip.CreateEntry($"{prefix}{fileName}.{ext}");
11541155
using var w = new StreamWriter(entry.Open(), Encoding.UTF8);
11551156
w.Write(content);
11561157
}
11571158
catch
11581159
{
11591160
var fileName = i < diffFileNames.Length ? diffFileNames[i] : $"diff{i}";
1160-
zip.CreateEntryFromFile(c2sPath, $"{safeName}/{fileName}.c2s");
1161+
zip.CreateEntryFromFile(c2sPath, $"{prefix}{fileName}.c2s");
11611162
}
11621163
}
11631164

11641165
var wav = AudioHelper.GetWavFromMusic(music);
11651166
if (wav != null)
11661167
{
1167-
var entry = zip.CreateEntry($"{safeName}/bgm.wav");
1168+
var entry = zip.CreateEntry($"{prefix}bgm.wav");
11681169
using var s = entry.Open();
11691170
s.Write(wav);
11701171
}
@@ -1175,13 +1176,13 @@ public ActionResult ExportCustom([FromQuery] int id, [FromQuery] string assetDir
11751176
var pngData = ConvertDdsToPng(jacketPath);
11761177
if (pngData != null)
11771178
{
1178-
var entry = zip.CreateEntry($"{safeName}/jacket.png");
1179+
var entry = zip.CreateEntry($"{prefix}jacket.png");
11791180
using var s = entry.Open();
11801181
s.Write(pngData);
11811182
}
11821183
else if (Path.GetExtension(jacketPath).ToLowerInvariant() is ".png" or ".jpg" or ".jpeg")
11831184
{
1184-
zip.CreateEntryFromFile(jacketPath, $"{safeName}/jacket{Path.GetExtension(jacketPath)}");
1185+
zip.CreateEntryFromFile(jacketPath, $"{prefix}jacket{Path.GetExtension(jacketPath)}");
11851186
}
11861187
}
11871188
}

ChuChartManager/Front/src/locales/en.yaml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,22 @@ batch:
332332
selected: "{count} selected"
333333
action: Action
334334
setGenre: Set Genre
335-
exportJackets: Export Jackets
336-
exportMp3: Export MP3
335+
exportJackets: Export Jackets (ZIP)
336+
exportMp3: Export MP3 (ZIP)
337+
exportOpt: Export OPT Package
338+
exportUgcByName: Export UGC Charts (by Name)
339+
exportUgcById: Export UGC Charts (by ID)
340+
exportSusByName: Export SUS Charts (by Name)
341+
exportSusById: Export SUS Charts (by ID)
342+
exportSuccess: Export complete
343+
exportFailed: Export failed
344+
currentProgress: Progress
345+
currentProcessing: Processing
346+
unknown: unknown
347+
subdir:
348+
label: Subfolder grouping
349+
none: None
350+
genre: By genre
337351
genre: Genre
338352
execute: Execute
339353
executing: Executing…

ChuChartManager/Front/src/locales/ja.yaml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,22 @@ batch:
323323
selected: "{count} 曲選択中"
324324
action: 操作
325325
setGenre: ジャンル設定
326-
exportJackets: ジャケット書き出し
327-
exportMp3: MP3書き出し
326+
exportJackets: ジャケット書き出し (ZIP)
327+
exportMp3: MP3書き出し (ZIP)
328+
exportOpt: OPT パッケージ書き出し
329+
exportUgcByName: UGC 譜面を書き出し(曲名フォルダ)
330+
exportUgcById: UGC 譜面を書き出し(ID フォルダ)
331+
exportSusByName: SUS 譜面を書き出し(曲名フォルダ)
332+
exportSusById: SUS 譜面を書き出し(ID フォルダ)
333+
exportSuccess: 書き出し完了
334+
exportFailed: 書き出し失敗
335+
currentProgress: 進捗
336+
currentProcessing: 処理中
337+
unknown: 不明
338+
subdir:
339+
label: サブフォルダ分け
340+
none: なし
341+
genre: ジャンル別
328342
genre: ジャンル
329343
execute: 実行
330344
executing: 実行中…

ChuChartManager/Front/src/locales/zh.yaml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,22 @@ batch:
332332
selected: "已选 {count} 首"
333333
action: 操作
334334
setGenre: 设置分类
335-
exportJackets: 导出封面
336-
exportMp3: 导出 MP3
335+
exportJackets: 导出封面 (ZIP)
336+
exportMp3: 导出 MP3 (ZIP)
337+
exportOpt: 导出 OPT 包
338+
exportUgcByName: 导出 UGC 谱面(按曲名分目录)
339+
exportUgcById: 导出 UGC 谱面(按 ID 分目录)
340+
exportSusByName: 导出 SUS 谱面(按曲名分目录)
341+
exportSusById: 导出 SUS 谱面(按 ID 分目录)
342+
exportSuccess: 导出完成
343+
exportFailed: 导出失败
344+
currentProgress: 当前进度
345+
currentProcessing: 正在处理
346+
unknown: 未知
347+
subdir:
348+
label: 子目录分组
349+
none:
350+
genre: 按流派
337351
genre: 分类
338352
execute: 执行
339353
executing: 执行中…
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const INVALID_RE = /[<>:"/\\|?*\x00-\x1f]/g
2+
3+
export function sanitizeFsSegment(name: string, fallback = 'unknown'): string {
4+
if (!name) return fallback
5+
const cleaned = name.replace(INVALID_RE, '_').trim().replace(/[. ]+$/, '')
6+
return cleaned || fallback
7+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { defineComponent, PropType, ref } from 'vue'
2+
import { Button, Radio, Select, Popover, addToast } from '@munet/ui'
3+
import type { SelectOption } from '@munet/ui'
4+
import { useI18n } from 'vue-i18n'
5+
import { useStorage } from '@vueuse/core'
6+
import { apiClient, getMusicList, type MusicListItem } from '@/api'
7+
import { deleteMusic } from '@/api/customResource'
8+
import { STEP } from './index'
9+
import remoteExport from './remoteExport'
10+
11+
export enum OPTIONS {
12+
None,
13+
EditProps,
14+
Delete,
15+
ExportOpt,
16+
ExportUgcByName,
17+
ExportUgcById,
18+
ExportSusByName,
19+
ExportSusById,
20+
ExportJackets,
21+
ExportMp3,
22+
}
23+
24+
export enum SUBDIR {
25+
None,
26+
Genre,
27+
}
28+
29+
const SUPPORTS_SUBDIR = (a: OPTIONS) =>
30+
a === OPTIONS.ExportUgcByName ||
31+
a === OPTIONS.ExportUgcById ||
32+
a === OPTIONS.ExportSusByName ||
33+
a === OPTIONS.ExportSusById
34+
35+
const DISABLE_ON_A000 = (a: OPTIONS) => a === OPTIONS.EditProps || a === OPTIONS.Delete
36+
37+
export default defineComponent({
38+
props: {
39+
selectedMusic: { type: Array as PropType<MusicListItem[]>, required: true },
40+
continue: { type: Function as PropType<(step: STEP) => void>, required: true },
41+
onListUpdated: { type: Function as PropType<(list: MusicListItem[]) => void>, required: true },
42+
},
43+
setup(props) {
44+
const { t } = useI18n()
45+
const selected = ref(OPTIONS.None)
46+
const subdir = useStorage<SUBDIR>('batchExportSubdir', SUBDIR.None)
47+
const loading = ref(false)
48+
const hasA000 = () => props.selectedMusic.some(m => m.assetDir === 'A000')
49+
50+
const subdirOptions = (): SelectOption[] => [
51+
{ label: t('batch.subdir.none'), value: SUBDIR.None },
52+
{ label: t('batch.subdir.genre'), value: SUBDIR.Genre },
53+
]
54+
55+
const items: { key: OPTIONS; label: string }[] = [
56+
{ key: OPTIONS.EditProps, label: t('batch.editProps') },
57+
{ key: OPTIONS.Delete, label: t('common.delete') },
58+
{ key: OPTIONS.ExportOpt, label: t('batch.exportOpt') },
59+
{ key: OPTIONS.ExportUgcByName, label: t('batch.exportUgcByName') },
60+
{ key: OPTIONS.ExportUgcById, label: t('batch.exportUgcById') },
61+
{ key: OPTIONS.ExportSusByName, label: t('batch.exportSusByName') },
62+
{ key: OPTIONS.ExportSusById, label: t('batch.exportSusById') },
63+
{ key: OPTIONS.ExportJackets, label: t('batch.exportJackets') },
64+
{ key: OPTIONS.ExportMp3, label: t('batch.exportMp3') },
65+
]
66+
67+
const proceed = async () => {
68+
switch (selected.value) {
69+
case OPTIONS.EditProps:
70+
props.continue(STEP.EditProps)
71+
return
72+
case OPTIONS.Delete: {
73+
loading.value = true
74+
try {
75+
for (const m of props.selectedMusic) await deleteMusic(m.id, m.assetDir)
76+
addToast({ message: t('batch.done'), type: 'success' })
77+
props.onListUpdated(await getMusicList())
78+
props.continue(STEP.Select)
79+
} catch (e: any) {
80+
addToast({ message: String(e?.response?.data || e?.message), type: 'error' })
81+
} finally {
82+
loading.value = false
83+
}
84+
return
85+
}
86+
case OPTIONS.ExportJackets:
87+
case OPTIONS.ExportMp3: {
88+
const endpoint = selected.value === OPTIONS.ExportJackets ? 'BatchExportJackets' : 'BatchExportMp3'
89+
const filename = selected.value === OPTIONS.ExportJackets ? 'jackets.zip' : 'audio.zip'
90+
loading.value = true
91+
try {
92+
const ids = props.selectedMusic.map(m => ({ id: m.id, assetDir: m.assetDir }))
93+
const resp = await apiClient.post(`/api/Music/${endpoint}`, { ids }, { responseType: 'blob', timeout: 600000 })
94+
const url = URL.createObjectURL(resp.data)
95+
const a = document.createElement('a')
96+
a.href = url
97+
a.download = filename
98+
a.click()
99+
URL.revokeObjectURL(url)
100+
addToast({ message: t('batch.exportSuccess'), type: 'success' })
101+
props.continue(STEP.Select)
102+
} catch (e: any) {
103+
addToast({ message: String(e?.response?.data || e?.message), type: 'error' })
104+
} finally {
105+
loading.value = false
106+
}
107+
return
108+
}
109+
case OPTIONS.ExportOpt:
110+
case OPTIONS.ExportUgcByName:
111+
case OPTIONS.ExportUgcById:
112+
case OPTIONS.ExportSusByName:
113+
case OPTIONS.ExportSusById:
114+
await remoteExport(props.continue, props.selectedMusic, selected.value, subdir.value, t)
115+
return
116+
}
117+
}
118+
119+
return () => (
120+
<div class="flex flex-col gap-3">
121+
<h3 class="text-lg font-bold m-0">{t('batch.chooseAction')}</h3>
122+
<p class="text-sm op-50 m-0">{t('batch.selected', { count: props.selectedMusic.length })}</p>
123+
124+
<fieldset disabled={loading.value} class="border-none p-0 m-0">
125+
<div class="flex flex-col gap-2">
126+
{items.map(opt => {
127+
const disabled = DISABLE_ON_A000(opt.key) && hasA000()
128+
if (disabled) {
129+
return (
130+
<Popover key={opt.key} trigger="hover">
131+
{{
132+
trigger: () => (
133+
<div class="flex gap-2 items-center op-50">
134+
<input type="radio" disabled />
135+
<label>{opt.label}</label>
136+
</div>
137+
),
138+
default: () => t('batch.a000Warning'),
139+
}}
140+
</Popover>
141+
)
142+
}
143+
return (
144+
<Radio key={opt.key} k={opt.key} v-model:value={selected.value}>
145+
{opt.label}
146+
</Radio>
147+
)
148+
})}
149+
150+
{SUPPORTS_SUBDIR(selected.value) && (
151+
<div class="flex items-center gap-2 mt-2 max-w-xs">
152+
<span class="text-sm op-60 shrink-0">{t('batch.subdir.label')}</span>
153+
<Select v-model:value={subdir.value} options={subdirOptions()} />
154+
</div>
155+
)}
156+
</div>
157+
</fieldset>
158+
159+
<div class="flex justify-end gap-2 mt-4">
160+
<Button onClick={() => props.continue(STEP.Select)} disabled={loading.value}>
161+
{t('batch.previous')}
162+
</Button>
163+
<Button ing={loading.value} disabled={selected.value === OPTIONS.None} onClick={proceed}>
164+
{t('batch.next')}
165+
</Button>
166+
</div>
167+
</div>
168+
)
169+
},
170+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { defineComponent, PropType, ref } from 'vue'
2+
import { Button, Select, addToast } from '@munet/ui'
3+
import type { SelectOption } from '@munet/ui'
4+
import { useI18n } from 'vue-i18n'
5+
import { apiClient, getMusicList, type MusicListItem } from '@/api'
6+
7+
export default defineComponent({
8+
props: {
9+
selectedMusic: { type: Array as PropType<MusicListItem[]>, required: true },
10+
genreMap: { type: Object as PropType<Record<number, string>>, required: true },
11+
closeModal: { type: Function as PropType<() => void>, required: true },
12+
onListUpdated: { type: Function as PropType<(list: MusicListItem[]) => void>, required: true },
13+
},
14+
setup(props) {
15+
const { t } = useI18n()
16+
const genreId = ref(-1)
17+
const loading = ref(false)
18+
19+
const genreOptions = (): SelectOption[] => [
20+
{ label: t('batch.notChange'), value: -1 },
21+
...Object.entries(props.genreMap).map(([id, name]) => ({ label: name, value: Number(id) })),
22+
]
23+
24+
const save = async () => {
25+
loading.value = true
26+
try {
27+
const ids = props.selectedMusic.map(m => ({ id: m.id, assetDir: m.assetDir }))
28+
await apiClient.post('/api/Music/BatchSetProps', {
29+
ids,
30+
genreId: genreId.value,
31+
genreName: props.genreMap[genreId.value] ?? '',
32+
})
33+
addToast({ message: t('batch.done'), type: 'success' })
34+
props.onListUpdated(await getMusicList())
35+
props.closeModal()
36+
} catch (e: any) {
37+
addToast({ message: String(e?.response?.data || e?.message), type: 'error' })
38+
} finally {
39+
loading.value = false
40+
}
41+
}
42+
43+
return () => (
44+
<fieldset disabled={loading.value} class="border-none p-0 m-0 flex flex-col gap-3">
45+
<h3 class="text-lg font-bold m-0">{t('batch.editProps')}</h3>
46+
<p class="text-sm op-50 m-0">{t('batch.selected', { count: props.selectedMusic.length })}</p>
47+
<div class="mt-4 max-w-md">
48+
<label class="text-xs op-50 mb-1 block">{t('batch.genre')}</label>
49+
<Select v-model:value={genreId.value} options={genreOptions()} />
50+
</div>
51+
<div class="flex justify-end gap-2 mt-4">
52+
<Button onClick={props.closeModal} disabled={loading.value}>{t('batch.previous')}</Button>
53+
<Button ing={loading.value} onClick={save}>{t('common.save')}</Button>
54+
</div>
55+
</fieldset>
56+
)
57+
},
58+
})

0 commit comments

Comments
 (0)