Skip to content

Commit d630d26

Browse files
committed
feat: ID 冲突提示与拖放导入
谱面页支持拖入文件夹导入、拖入单文件替换封面/音频/谱面; 选中曲目时显示跨 option 目录的 ID 冲突警告
1 parent 2473892 commit d630d26

9 files changed

Lines changed: 149 additions & 29 deletions

File tree

ChuChartManager/Controllers/MusicController.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ public ActionResult<List<string>> GetSources()
7373
return Ok(scanner.AvailableSources);
7474
}
7575

76+
[HttpGet]
77+
public ActionResult<List<string>> GetIdConflicts([FromQuery] int id, [FromQuery] string assetDir)
78+
{
79+
var scanner = scannerService.Scanner;
80+
if (scanner == null) return Ok(new List<string>());
81+
82+
var dirs = scanner.MusicBySource
83+
.Where(kv => kv.Key != assetDir && kv.Value.Any(m => m.Id == id))
84+
.Select(kv => kv.Key)
85+
.OrderBy(d => d)
86+
.ToList();
87+
return Ok(dirs);
88+
}
89+
7690
[HttpGet]
7791
public ActionResult<Dictionary<int, string>> GetGenreMap()
7892
{

ChuChartManager/Front/src/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ export async function getStartupErrors(): Promise<string[]> {
6565
return data
6666
}
6767

68+
export async function getIdConflicts(id: number, assetDir: string): Promise<string[]> {
69+
const { data } = await apiClient.get('/api/Music/GetIdConflicts', { params: { id, assetDir } })
70+
return data
71+
}
72+
6873
export async function getSources(): Promise<string[]> {
6974
const { data } = await apiClient.get('/api/Music/GetSources')
7075
return data
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { defineComponent, ref, watch } from 'vue'
2+
import { Popover } from '@munet/ui'
3+
import { useI18n } from 'vue-i18n'
4+
import { getIdConflicts } from '@/api'
5+
6+
export default defineComponent({
7+
props: {
8+
id: { type: Number, required: true },
9+
assetDir: { type: String, required: true },
10+
},
11+
setup(props) {
12+
const { t } = useI18n()
13+
const conflicts = ref<string[]>([])
14+
15+
watch(() => [props.id, props.assetDir], async () => {
16+
conflicts.value = await getIdConflicts(props.id, props.assetDir)
17+
}, { immediate: true })
18+
19+
return () => !!conflicts.value.length && (
20+
<Popover trigger="hover">
21+
{{
22+
trigger: () => <div class="text-#f0a020 i-mdi-alert-outline text-1.2em shrink-0" />,
23+
default: () => (
24+
<div class="flex flex-col gap-1">
25+
{t('music.idConflictWarning')}
26+
{conflicts.value.map(dir => <div key={dir} class="font-mono">{dir}</div>)}
27+
</div>
28+
),
29+
}}
30+
</Popover>
31+
)
32+
},
33+
})

ChuChartManager/Front/src/fs-access.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
interface FileSystemFileHandle {
22
getFile(): Promise<File>
33
createWritable(): Promise<FileSystemWritableFileStream>
4+
readonly kind: 'file'
5+
readonly name: string
46
}
57

68
interface FileSystemDirectoryHandle {
@@ -33,3 +35,7 @@ interface Window {
3335
showOpenFilePicker(options?: ShowOpenFilePickerOptions): Promise<FileSystemFileHandle[]>
3436
showDirectoryPicker(options?: ShowDirectoryPickerOptions): Promise<FileSystemDirectoryHandle>
3537
}
38+
39+
interface DataTransferItem {
40+
getAsFileSystemHandle(): Promise<FileSystemDirectoryHandle | FileSystemFileHandle | null>
41+
}

ChuChartManager/Front/src/locales/en.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ music:
125125
worldsEnd: "WORLD'S END"
126126
trackCount: "{count} tracks"
127127
statusLine: "{tracks} tracks | {options} options"
128+
idConflictWarning: "This ID also exists in the following option directories and may conflict in game:"
129+
dropHint: Drop to import files
128130
saved: "Saved: {name}"
129131
copiedTo: "Copied to {dir}"
130132
copyFailed: "Copy failed: {error}"

ChuChartManager/Front/src/locales/ja.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ music:
125125
worldsEnd: "WORLD'S END"
126126
trackCount: "{count} 曲"
127127
statusLine: "{tracks} 曲 | {options} オプション"
128+
idConflictWarning: このIDは以下のoptionディレクトリにも存在し、ゲーム内で競合する可能性があります:
129+
dropHint: ドロップしてファイルをインポート
128130
saved: "保存しました: {name}"
129131
copiedTo: "{dir} にコピーしました"
130132
copyFailed: "コピー失敗: {error}"

ChuChartManager/Front/src/locales/zh.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ music:
125125
worldsEnd: "WORLD'S END"
126126
trackCount: "{count} 首"
127127
statusLine: "{tracks} 首曲目 | {options} 个 option"
128+
idConflictWarning: 此 ID 在以下 option 目录中也存在,游戏内可能相互覆盖:
129+
dropHint: 松开以导入文件
128130
saved: "已保存: {name}"
129131
copiedTo: "已复制到 {dir}"
130132
copyFailed: "复制失败: {error}"

ChuChartManager/Front/src/views/ImportMusicModal.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import BottomOverlay from '@/components/BottomOverlay.vue'
1010
import FileTypeIcon from '@/components/FileTypeIcon.vue'
1111
1212
const emit = defineEmits<{ imported: [] }>()
13+
const props = withDefaults(defineProps<{ showButton?: boolean }>(), { showButton: true })
1314
const { t } = useI18n()
1415
1516
const loading = ref(false)
@@ -107,6 +108,10 @@ async function startImport() {
107108
return
108109
}
109110
111+
await startImportWithHandle(dirHandle)
112+
}
113+
114+
async function startImportWithHandle(dirHandle: FileSystemDirectoryHandle) {
110115
step.value = 'checking'
111116
112117
const files: File[] = []
@@ -184,11 +189,11 @@ function close() {
184189
step.value = 'idle'
185190
}
186191
187-
defineExpose({ startImport })
192+
defineExpose({ startImport, startImportWithHandle })
188193
</script>
189194

190195
<template>
191-
<Button @click="startImport">{{ t('music.importMusic') }}</Button>
196+
<Button v-if="props.showButton" @click="startImport">{{ t('music.importMusic') }}</Button>
192197

193198
<BottomOverlay :show="step === 'picking'" :title="t('music.importSelectFolder')">
194199
<div class="flex flex-col gap-3 items-center text-white">

ChuChartManager/Front/src/views/MusicList.vue

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { ref, onMounted, computed, watch, nextTick } from 'vue'
33
import { Button, Select, TextInput, CheckBox, DropMenu, NumberInput, Modal, theme } from '@munet/ui'
44
import type { SelectOption } from '@munet/ui'
5-
import { useStorage } from '@vueuse/core'
5+
import { useStorage, useDropZone } from '@vueuse/core'
66
import { VList } from 'virtua/vue'
77
import { getMusicList, getSources, getGenreMap, getJacketUrl, saveMusic, getExportMp3Url, ensureBackendUrl, importJacket, importChart, getExportChartUrl, getExportOptUrl, getExportCustomUrl, openExplorer, openXml, changeId, deleteMusic, setJacket, setAudio, replaceChart, isWebView, getBaseUrl } from '@/api'
88
import type { MusicListItem } from '@/api'
@@ -15,6 +15,7 @@ import ImportMusicModal from '@/views/ImportMusicModal.vue'
1515
import PlayerBar from '@/components/PlayerBar.vue'
1616
import BottomOverlay from '@/components/BottomOverlay.vue'
1717
import FileTypeIcon from '@/components/FileTypeIcon.vue'
18+
import MusicIdConflictNotifier from '@/components/MusicIdConflictNotifier'
1819
import { BlobWriter, ZipReader } from '@zip.js/zip.js'
1920
import getSubDirFile from '@/utils/getSubDirFile'
2021
import { useI18n } from 'vue-i18n'
@@ -272,6 +273,13 @@ async function exportToFolder(url: string) {
272273
}
273274
}
274275
276+
async function applyJacket(file: File) {
277+
if (!selectedMusic.value) return
278+
await setJacket(selectedMusic.value.id, selectedMusic.value.assetDir, file)
279+
selectedMusic.value.hasJacket = true
280+
setStatus(t('music.jacketImported'))
281+
}
282+
275283
async function handleSetJacket() {
276284
if (!selectedMusic.value) return
277285
let fileHandle: FileSystemFileHandle
@@ -281,14 +289,22 @@ async function handleSetJacket() {
281289
types: [{ description: 'Image', accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.bmp'] } }],
282290
})
283291
} catch { return }
284-
const file = await fileHandle.getFile()
285-
await setJacket(selectedMusic.value.id, selectedMusic.value.assetDir, file)
286-
selectedMusic.value.hasJacket = true
287-
setStatus(t('music.jacketImported'))
292+
await applyJacket(await fileHandle.getFile())
288293
}
289294
290295
const showAudioOverlay = ref(false)
291296
297+
async function applyAudio(file: File) {
298+
if (!selectedMusic.value) return
299+
setStatus(t('music.audioImporting'))
300+
try {
301+
await setAudio(selectedMusic.value.id, selectedMusic.value.assetDir, file)
302+
setStatus(t('music.audioReplaced'))
303+
} catch (e: any) {
304+
setStatus(t('music.audioReplaceFailed', { error: e?.response?.data || e?.message }))
305+
}
306+
}
307+
292308
async function handleReplaceAudio() {
293309
if (!selectedMusic.value) return
294310
showAudioOverlay.value = true
@@ -303,18 +319,26 @@ async function handleReplaceAudio() {
303319
return
304320
}
305321
showAudioOverlay.value = false
306-
const file = await fileHandle.getFile()
307-
setStatus(t('music.audioImporting'))
322+
await applyAudio(await fileHandle.getFile())
323+
}
324+
325+
const showChartOverlay = ref(false)
326+
327+
async function applyChart(diffIndex: number, file: File) {
328+
if (!selectedMusic.value) return
308329
try {
309-
await setAudio(selectedMusic.value.id, selectedMusic.value.assetDir, file)
310-
setStatus(t('music.audioReplaced'))
330+
const res = await replaceChart(selectedMusic.value.id, selectedMusic.value.assetDir, diffIndex, file)
331+
if (res.imported) {
332+
const suffix = res.convertedFrom ? ` (${res.convertedFrom.toUpperCase()} → C2S)` : ''
333+
setStatus(t('music.chartImported', { diff: diffNames[diffIndex], suffix }))
334+
await loadMusic()
335+
selectMusic(selectedMusic.value)
336+
}
311337
} catch (e: any) {
312-
setStatus(t('music.audioReplaceFailed', { error: e?.response?.data || e?.message }))
338+
setStatus(t('music.chartReplaceFailed', { error: e?.response?.data?.error || e?.message }))
313339
}
314340
}
315341
316-
const showChartOverlay = ref(false)
317-
318342
async function handleReplaceChart(diffIndex: number) {
319343
if (!selectedMusic.value) return
320344
showChartOverlay.value = true
@@ -329,18 +353,7 @@ async function handleReplaceChart(diffIndex: number) {
329353
return
330354
}
331355
showChartOverlay.value = false
332-
const file = await fileHandle.getFile()
333-
try {
334-
const res = await replaceChart(selectedMusic.value.id, selectedMusic.value.assetDir, diffIndex, file)
335-
if (res.imported) {
336-
const suffix = res.convertedFrom ? ` (${res.convertedFrom.toUpperCase()} → C2S)` : ''
337-
setStatus(t('music.chartImported', { diff: diffNames[diffIndex], suffix }))
338-
await loadMusic()
339-
selectMusic(selectedMusic.value)
340-
}
341-
} catch (e: any) {
342-
setStatus(t('music.chartReplaceFailed', { error: e?.response?.data?.error || e?.message }))
343-
}
356+
await applyChart(diffIndex, await fileHandle.getFile())
344357
}
345358
346359
function handleExportChart(diffIndex: number, format: 'c2s' | 'ugc' | 'sus') {
@@ -429,6 +442,35 @@ async function handleChangeId() {
429442
}
430443
}
431444
445+
const rootRef = ref<HTMLDivElement>()
446+
const importModalRef = ref<InstanceType<typeof ImportMusicModal> | null>(null)
447+
448+
async function onDrop(_files: File[] | null, e: DragEvent) {
449+
const items = e.dataTransfer?.items
450+
if (!items?.length) return
451+
const handles = (await Promise.all(Array.from(items).map(item => item.getAsFileSystemHandle())))
452+
.filter(h => h != null)
453+
if (!handles.length) return
454+
455+
if (handles[0].kind === 'directory') {
456+
importModalRef.value?.startImportWithHandle(handles[0] as FileSystemDirectoryHandle)
457+
return
458+
}
459+
460+
if (handles.length !== 1 || isA000.value || !selectedMusic.value) return
461+
const file = await (handles[0] as FileSystemFileHandle).getFile()
462+
const name = file.name.toLowerCase()
463+
if (/\.(png|jpe?g|bmp)$/.test(name)) await applyJacket(file)
464+
else if (/\.(wav|mp3|ogg|awb)$/.test(name)) await applyAudio(file)
465+
else if (/\.(c2s|ugc|sus)$/.test(name)) await applyChart(selectedDiff.value, file)
466+
}
467+
468+
const { isOverDropZone } = useDropZone(rootRef, {
469+
onDrop,
470+
multiple: true,
471+
preventDefaultForUnhandled: true,
472+
})
473+
432474
const copyExportOptions = computed(() => {
433475
if (!selectedMusic.value) return []
434476
const m = selectedMusic.value
@@ -482,7 +524,13 @@ const copyExportOptions = computed(() => {
482524
</script>
483525

484526
<template>
485-
<div class="h-full flex">
527+
<div ref="rootRef" class="h-full flex relative">
528+
<div
529+
v-if="isOverDropZone"
530+
class="absolute inset-0 z-100 bg-white/50 backdrop-blur-sm flex items-center justify-center text-xl pointer-events-none"
531+
>
532+
{{ t('music.dropHint') }}
533+
</div>
486534
<div class="w-40em max-w-[40vw] border-r border-white/10 flex flex-col relative">
487535
<Transition
488536
enter-active-class="panel-transition"
@@ -551,15 +599,18 @@ const copyExportOptions = computed(() => {
551599
<template v-else-if="isA000">
552600
<span class="text-sm op-50">{{ t('music.a000Hint') }}</span>
553601
</template>
554-
<ImportMusicModal v-if="!isA000" @imported="refresh" />
602+
<ImportMusicModal ref="importModalRef" :show-button="!isA000" @imported="refresh" />
555603
</div>
556604

557605
<div v-if="selectedMusic" class="of-y-auto cst flex-1 min-h-0 p-6">
558606
<div class="flex gap-6 mb-6">
559607
<img v-if="selectedMusic.hasJacket" :src="getJacketUrl(selectedMusic.id, selectedMusic.assetDir)" class="w-48 h-48 rounded-lg object-cover shrink-0 cursor-pointer hover:op-80 transition-opacity" :title="t('music.clickToReplaceJacket')" @click="!isA000 && handleSetJacket()" />
560608
<div v-else class="w-48 h-48 rounded-lg bg-white/10 flex items-center justify-center op-30 text-2xl shrink-0 cursor-pointer hover:op-50 transition-opacity" @click="!isA000 && handleSetJacket()">?</div>
561609
<div class="flex-1 min-w-0">
562-
<h2 class="text-xl font-bold mb-1">{{ selectedMusic.name }}</h2>
610+
<h2 class="text-xl font-bold mb-1 flex items-center gap-2">
611+
{{ selectedMusic.name }}
612+
<MusicIdConflictNotifier :id="selectedMusic.id" :assetDir="selectedMusic.assetDir" />
613+
</h2>
563614
<p class="op-60 mb-2">{{ selectedMusic.artist }}</p>
564615
<p class="text-sm op-40 mb-1">{{ genreMap[selectedMusic.genreId] || '' }}</p>
565616
<p class="text-sm op-40 mb-3">{{ releaseTagMap[selectedMusic.releaseTagId] || selectedMusic.releaseTagStr || '' }}</p>

0 commit comments

Comments
 (0)