Skip to content

Commit b294f8b

Browse files
committed
feat: 导入自制谱 + 复制导出菜单 + 清理旧转谱代码
- 新增 ImportMusicModal:showDirectoryPicker 扫描文件夹导入自制谱 - 新增 ImportMusicCheck/ImportMusicExecute 后端 API(UGC/SUS→C2S + WAV→HCA→AWB + PNG→DDS) - 新增 ExportOpt/ExportUgc/OpenExplorer/OpenXml 后端 API - 新增 DropMenu「复制与导出」下拉菜单(导出 ZIP / 导出 UGC / 打开目录 / 编辑 XML) - 删除旧转谱代码:C2sChart、UgcConverter、UgcParser、UmiguriService、UgcToolService(已被 MuConvert 子模块取代) - 修复 i18n YAML 重复 key 导致前端编译失败 - 优化 FumenData 注释
1 parent 2518fdb commit b294f8b

13 files changed

Lines changed: 819 additions & 1902 deletions

File tree

ChuChartManager/Controllers/MusicController.cs

Lines changed: 416 additions & 0 deletions
Large diffs are not rendered by default.

ChuChartManager/Front/src/api/index.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,69 @@ export async function importChart(id: number, assetDir: string, diffIndex: numbe
117117
export function getExportChartUrl(id: number, assetDir: string, diffIndex: number, format: 'c2s' | 'ugc' | 'sus' = 'ugc'): string {
118118
return `${getBaseUrl()}/api/Music/ExportChart?id=${id}&assetDir=${assetDir}&diffIndex=${diffIndex}&format=${format}`
119119
}
120+
121+
export interface ImportCheckResult {
122+
success: boolean
123+
format: string
124+
alerts: string[]
125+
suggestedId: number
126+
}
127+
128+
export interface ImportExecuteResult {
129+
success: boolean
130+
alerts: string[]
131+
}
132+
133+
export async function importMusicCheck(chart: File): Promise<ImportCheckResult> {
134+
const form = new FormData()
135+
form.append('chart', chart)
136+
const { data } = await apiClient.post('/api/Music/ImportMusicCheck', form)
137+
return data
138+
}
139+
140+
export async function importMusicExecute(params: {
141+
chart: File
142+
audio: File
143+
cover?: File
144+
id: number
145+
title: string
146+
artist: string
147+
genreId: number
148+
genreName: string
149+
difficulty: number
150+
level: number
151+
levelDecimal: number
152+
targetDir: string
153+
}): Promise<ImportExecuteResult> {
154+
const form = new FormData()
155+
form.append('chart', params.chart)
156+
form.append('audio', params.audio)
157+
if (params.cover) form.append('cover', params.cover)
158+
form.append('id', params.id.toString())
159+
form.append('title', params.title)
160+
form.append('artist', params.artist)
161+
form.append('genreId', params.genreId.toString())
162+
form.append('genreName', params.genreName)
163+
form.append('difficulty', params.difficulty.toString())
164+
form.append('level', params.level.toString())
165+
form.append('levelDecimal', params.levelDecimal.toString())
166+
form.append('targetDir', params.targetDir)
167+
const { data } = await apiClient.post('/api/Music/ImportMusicExecute', form, { timeout: 120000 })
168+
return data
169+
}
170+
171+
export function getExportOptUrl(id: number, assetDir: string): string {
172+
return `${getBaseUrl()}/api/Music/ExportOpt?id=${id}&assetDir=${assetDir}`
173+
}
174+
175+
export function getExportUgcUrl(id: number, assetDir: string): string {
176+
return `${getBaseUrl()}/api/Music/ExportUgc?id=${id}&assetDir=${assetDir}`
177+
}
178+
179+
export async function openExplorer(id: number, assetDir: string): Promise<void> {
180+
await apiClient.post(`/api/Music/OpenExplorer?id=${id}&assetDir=${assetDir}`)
181+
}
182+
183+
export async function openXml(id: number, assetDir: string): Promise<void> {
184+
await apiClient.post(`/api/Music/OpenXml?id=${id}&assetDir=${assetDir}`)
185+
}

ChuChartManager/Front/src/locales/en.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,27 @@ music:
120120
exportC2S: Export C2S
121121
exportUGC: Export UGC
122122
exportSUS: Export SUS
123-
save: Save Changes
123+
importMusic: Import Chart
124+
importTitle: Import Custom Chart
125+
importNoChart: "Chart file not found (.ugc/.c2s/.sus)"
126+
importNoAudio: "Audio file not found (.wav/.mp3/.ogg)"
127+
importSuccess: Import successful
128+
importFailed: Import failed
129+
importChecking: Checking...
130+
importExecuting: Importing...
131+
importFoundFiles: Files found
132+
importId: Music ID
133+
importDifficulty: Difficulty
134+
importLevel: Level
135+
importTargetDir: Target Option
124136
copyToOption: Copy to other Option
125137
copy: Copy
138+
copyAndExport: Copy & Export
139+
copyToOptionShort: "Copy to {dir}"
140+
exportOpt: Export OPT (Raw Format)
141+
exportUgcZip: Export UGC (Custom Chart Format)
142+
openExplorer: Open in Explorer
143+
openXml: Edit Music.xml
126144
mods:
127145
wip: Loading...
128146
loaderNotInstalled: ChuModLoader is not installed

ChuChartManager/Front/src/locales/ja.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,27 @@ music:
120120
exportC2S: C2S エクスポート
121121
exportUGC: UGC エクスポート
122122
exportSUS: SUS エクスポート
123-
save: 変更を保存
123+
importMusic: 譜面をインポート
124+
importTitle: カスタム譜面をインポート
125+
importNoChart: "譜面ファイルが見つかりません (.ugc/.c2s/.sus)"
126+
importNoAudio: "音声ファイルが見つかりません (.wav/.mp3/.ogg)"
127+
importSuccess: インポート成功
128+
importFailed: インポート失敗
129+
importChecking: 確認中...
130+
importExecuting: インポート中...
131+
importFoundFiles: 見つかったファイル
132+
importId: Music ID
133+
importDifficulty: 難易度
134+
importLevel: レベル
135+
importTargetDir: 対象オプション
124136
copyToOption: 他のオプションにコピー
125137
copy: コピー
138+
copyAndExport: コピーとエクスポート
139+
copyToOptionShort: "{dir} にコピー"
140+
exportOpt: OPT エクスポート (原始形式)
141+
exportUgcZip: UGC エクスポート (自作譜面形式)
142+
openExplorer: エクスプローラーで開く
143+
openXml: Music.xml を編集
126144
mods:
127145
wip: 読み込み中...
128146
loaderNotInstalled: ChuModLoader は未インストールです

ChuChartManager/Front/src/locales/zh.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,27 @@ music:
120120
exportC2S: 导出 C2S
121121
exportUGC: 导出 UGC
122122
exportSUS: 导出 SUS
123-
save: 保存修改
123+
importMusic: 导入自制谱
124+
importTitle: 导入自制谱
125+
importNoChart: 未找到谱面文件 (.ugc/.c2s/.sus)
126+
importNoAudio: 未找到音频文件 (.wav/.mp3/.ogg)
127+
importSuccess: 导入成功
128+
importFailed: 导入失败
129+
importChecking: 正在检查...
130+
importExecuting: 正在导入...
131+
importFoundFiles: 已找到文件
132+
importId: Music ID
133+
importDifficulty: 难度
134+
importLevel: 等级
135+
importTargetDir: 目标 Option
124136
copyToOption: 复制到其他 Option
125137
copy: 复制
138+
copyAndExport: 复制与导出
139+
copyToOptionShort: "复制到 {dir}"
140+
exportOpt: 导出 OPT (原始格式)
141+
exportUgcZip: 导出 UGC (自制谱格式)
142+
openExplorer: 在资源管理器中打开
143+
openXml: 编辑 Music.xml
126144
mods:
127145
wip: 正在加载...
128146
loaderNotInstalled: ChuModLoader 未安装
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
<script setup lang="ts">
2+
import { ref, computed, onMounted } from 'vue'
3+
import { useI18n } from 'vue-i18n'
4+
import { Button, TextInput, NumberInput, Select, addToast } from '@munet/ui'
5+
import { getGenreMap, getSources, importMusicCheck, importMusicExecute } from '@/api'
6+
import type { ImportCheckResult } from '@/api'
7+
8+
const emit = defineEmits<{ imported: [] }>()
9+
const { t } = useI18n()
10+
11+
const show = ref(false)
12+
const loading = ref(false)
13+
const step = ref<'idle' | 'checking' | 'form' | 'executing'>('idle')
14+
15+
const chartFile = ref<File | null>(null)
16+
const audioFile = ref<File | null>(null)
17+
const coverFile = ref<File | null>(null)
18+
const checkResult = ref<ImportCheckResult | null>(null)
19+
20+
const title = ref('')
21+
const artist = ref('')
22+
const genreId = ref(99)
23+
const difficulty = ref(3)
24+
const level = ref(10)
25+
const levelDecimal = ref(0)
26+
const targetDir = ref('')
27+
const musicId = ref(8000)
28+
29+
const genreMap = ref<Record<number, string>>({})
30+
const sources = ref<string[]>([])
31+
32+
const genreOptions = computed(() =>
33+
Object.entries(genreMap.value).map(([id, name]) => ({ label: name, value: Number(id) }))
34+
)
35+
const sourceOptions = computed(() =>
36+
sources.value.filter(s => s !== 'A000').map(s => ({ label: s, value: s }))
37+
)
38+
const diffOptions = [
39+
{ label: 'BASIC', value: 0 },
40+
{ label: 'ADVANCED', value: 1 },
41+
{ label: 'EXPERT', value: 2 },
42+
{ label: 'MASTER', value: 3 },
43+
{ label: 'ULTIMA', value: 4 },
44+
]
45+
46+
onMounted(async () => {
47+
const [g, s] = await Promise.all([getGenreMap(), getSources()])
48+
genreMap.value = g
49+
sources.value = s
50+
if (sourceOptions.value.length > 0) targetDir.value = sourceOptions.value[0].value
51+
})
52+
53+
function parseFolderName(name: string) {
54+
const cleaned = name.replace(/\s*\(v\d+\)\s*$/, '').trim()
55+
const sep = cleaned.indexOf(' - ')
56+
if (sep > 0) {
57+
title.value = cleaned.substring(0, sep).trim()
58+
artist.value = cleaned.substring(sep + 3).trim()
59+
} else {
60+
title.value = cleaned
61+
artist.value = ''
62+
}
63+
}
64+
65+
function findByExt(files: File[], exts: string[]): File | null {
66+
return files.find(f => exts.some(e => f.name.toLowerCase().endsWith(e))) ?? null
67+
}
68+
69+
async function pickFolder() {
70+
let dirHandle: FileSystemDirectoryHandle
71+
try {
72+
dirHandle = await (window as any).showDirectoryPicker({ id: 'import-chart', startIn: 'downloads' })
73+
} catch { return }
74+
75+
step.value = 'checking'
76+
show.value = true
77+
78+
const files: File[] = []
79+
for await (const entry of (dirHandle as any).values()) {
80+
if (entry.kind === 'file') files.push(await entry.getFile())
81+
}
82+
83+
chartFile.value = findByExt(files, ['.ugc', '.c2s', '.sus'])
84+
audioFile.value = findByExt(files, ['.wav', '.mp3', '.ogg'])
85+
coverFile.value = findByExt(files, ['.png', '.jpg', '.jpeg'])
86+
87+
if (!chartFile.value) {
88+
addToast({ message: t('music.importNoChart'), type: 'error' })
89+
show.value = false
90+
step.value = 'idle'
91+
return
92+
}
93+
if (!audioFile.value) {
94+
addToast({ message: t('music.importNoAudio'), type: 'error' })
95+
show.value = false
96+
step.value = 'idle'
97+
return
98+
}
99+
100+
parseFolderName(dirHandle.name)
101+
102+
try {
103+
checkResult.value = await importMusicCheck(chartFile.value)
104+
musicId.value = checkResult.value.suggestedId
105+
step.value = 'form'
106+
} catch (e: any) {
107+
addToast({ message: e.message || t('music.importFailed'), type: 'error' })
108+
show.value = false
109+
step.value = 'idle'
110+
}
111+
}
112+
113+
async function doImport() {
114+
if (!chartFile.value || !audioFile.value) return
115+
step.value = 'executing'
116+
loading.value = true
117+
118+
try {
119+
const genreName = genreMap.value[genreId.value] || ''
120+
const result = await importMusicExecute({
121+
chart: chartFile.value,
122+
audio: audioFile.value,
123+
cover: coverFile.value ?? undefined,
124+
id: musicId.value,
125+
title: title.value,
126+
artist: artist.value,
127+
genreId: genreId.value,
128+
genreName,
129+
difficulty: difficulty.value,
130+
level: level.value,
131+
levelDecimal: levelDecimal.value,
132+
targetDir: targetDir.value,
133+
})
134+
135+
if (result.success) {
136+
addToast({ message: t('music.importSuccess'), type: 'success' })
137+
emit('imported')
138+
show.value = false
139+
} else {
140+
addToast({ message: result.alerts?.join('\n') || t('music.importFailed'), type: 'error' })
141+
}
142+
} catch (e: any) {
143+
addToast({ message: e.response?.data?.error || e.message || t('music.importFailed'), type: 'error' })
144+
} finally {
145+
loading.value = false
146+
step.value = 'idle'
147+
}
148+
}
149+
150+
function close() {
151+
show.value = false
152+
step.value = 'idle'
153+
}
154+
</script>
155+
156+
<template>
157+
<Button @click="pickFolder">{{ t('music.importMusic') }}</Button>
158+
159+
<Teleport to="body">
160+
<Transition enter-active-class="transition-opacity duration-200" leave-active-class="transition-opacity duration-200" enter-from-class="opacity-0" leave-to-class="opacity-0">
161+
<div v-if="show" class="fixed inset-0 z-1000 flex items-center justify-center" @click.self="close">
162+
<div class="absolute inset-0 bg-black/70" />
163+
<div class="relative bg-[rgba(30,30,30,0.95)] backdrop-blur-xl rd-lg p-6 w-120 max-w-[90vw] max-h-[80vh] of-y-auto flex flex-col gap-4">
164+
<div class="text-lg font-bold">{{ t('music.importTitle') }}</div>
165+
166+
<div v-if="step === 'checking'" class="flex items-center justify-center py-8 op-60">
167+
{{ t('music.importChecking') }}
168+
</div>
169+
170+
<template v-if="step === 'form' || step === 'executing'">
171+
<div class="flex flex-col gap-1 text-sm op-70 bg-white/5 rd p-3">
172+
<div>{{ t('music.importFoundFiles') }}:</div>
173+
<div class="flex gap-3">
174+
<span v-if="chartFile">{{ chartFile.name }}</span>
175+
<span v-if="audioFile">{{ audioFile.name }}</span>
176+
<span v-if="coverFile">{{ coverFile.name }}</span>
177+
</div>
178+
</div>
179+
180+
<div v-if="checkResult?.alerts?.length" class="text-sm c-orange bg-orange/10 rd p-2">
181+
<div v-for="(a, i) in checkResult.alerts" :key="i">{{ a }}</div>
182+
</div>
183+
184+
<div class="grid grid-cols-2 gap-3">
185+
<div class="col-span-2">
186+
<label class="block text-sm op-60 mb-1">{{ t('music.title') }}</label>
187+
<TextInput v-model:value="title" :disabled="loading" />
188+
</div>
189+
<div class="col-span-2">
190+
<label class="block text-sm op-60 mb-1">{{ t('music.artist') }}</label>
191+
<TextInput v-model:value="artist" :disabled="loading" />
192+
</div>
193+
<div>
194+
<label class="block text-sm op-60 mb-1">{{ t('music.genre') }}</label>
195+
<Select :options="genreOptions" v-model:value="genreId" :disabled="loading" />
196+
</div>
197+
<div>
198+
<label class="block text-sm op-60 mb-1">{{ t('music.importDifficulty') }}</label>
199+
<Select :options="diffOptions" v-model:value="difficulty" :disabled="loading" />
200+
</div>
201+
<div>
202+
<label class="block text-sm op-60 mb-1">{{ t('music.importLevel') }}</label>
203+
<NumberInput v-model:value="level" :min="1" :max="15" :decimal="0" :step="1" :disabled="loading" />
204+
</div>
205+
<div>
206+
<label class="block text-sm op-60 mb-1">+0.</label>
207+
<NumberInput v-model:value="levelDecimal" :min="0" :max="99" :decimal="0" :step="10" :disabled="loading" />
208+
</div>
209+
<div>
210+
<label class="block text-sm op-60 mb-1">{{ t('music.importTargetDir') }}</label>
211+
<Select :options="sourceOptions" v-model:value="targetDir" :disabled="loading" />
212+
</div>
213+
<div>
214+
<label class="block text-sm op-60 mb-1">{{ t('music.importId') }}</label>
215+
<NumberInput v-model:value="musicId" :min="1" :max="99999" :decimal="0" :step="1" :disabled="loading" />
216+
</div>
217+
</div>
218+
219+
<div v-if="step === 'executing'" class="text-center op-60 py-2">
220+
{{ t('music.importExecuting') }}
221+
</div>
222+
223+
<div class="flex justify-end gap-2 mt-2">
224+
<Button @click="close" :disabled="loading">{{ $t('common.cancel') }}</Button>
225+
<Button @click="doImport" :disabled="loading || !title || !targetDir">
226+
{{ loading ? t('music.importExecuting') : t('music.importMusic') }}
227+
</Button>
228+
</div>
229+
</template>
230+
</div>
231+
</div>
232+
</Transition>
233+
</Teleport>
234+
</template>

0 commit comments

Comments
 (0)