|
| 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