This commit is contained in:
2026-10-18 05:25:23 +07:00
parent f5e645ec00
commit 30999630f0
18 changed files with 1023 additions and 185 deletions
+202
View File
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { NAlert, NButton, NInput, NSelect, NSpace, useDialog } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { MusicPlaylist, OnlineIntent } from '../types/online'
const props = defineProps<{
playlists: MusicPlaylist[]
canEdit: boolean
canUse: boolean
isBusy: boolean
}>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
const dialog = useDialog()
const selectedId = ref<number | null>(null)
const name = ref('')
const newName = ref('')
const order = ref<number[]>([])
const original = ref<number[]>([])
const selected = computed(() => props.playlists.find((p) => p.id === selectedId.value))
const isDirty = computed(() => JSON.stringify(order.value) !== JSON.stringify(original.value))
const hasConflict = computed(
() =>
selected.value &&
JSON.stringify(selected.value.tracks.map((t) => t.id).sort((a, b) => a - b)) !==
JSON.stringify([...original.value].sort((a, b) => a - b)),
)
const isDisabled = computed(() => !props.canEdit || props.isBusy)
let timer: ReturnType<typeof setInterval> | undefined
function reset() {
original.value = selected.value?.tracks.map((t) => t.id) ?? []
order.value = [...original.value]
name.value = selected.value?.name ?? ''
}
watch(selectedId, reset)
watch(
() => props.playlists,
(items, old) => {
const added = items.find((p) => !old.some((o) => o.id === p.id))
if (!isDirty.value && added) selectedId.value = added.id
if (selectedId.value && !selected.value) selectedId.value = null
const oldName = old.find((p) => p.id === selectedId.value)?.name
if (name.value === oldName) name.value = selected.value?.name ?? ''
const ids = selected.value?.tracks.map((t) => t.id) ?? []
if (!isDirty.value || JSON.stringify(order.value) === JSON.stringify(ids)) {
original.value = ids
order.value = [...ids]
}
},
)
function refresh() {
if (props.canEdit && !props.isBusy) emit('action', { action: 'playlists' })
}
watch(
() => props.canEdit,
(value) => {
if (value) refresh()
},
)
onMounted(() => {
refresh()
timer = setInterval(refresh, 10000)
})
onUnmounted(() => clearInterval(timer))
function move(index: number, offset: number) {
const target = index + offset
if (target < 0 || target >= order.value.length) return
const [id] = order.value.splice(index, 1)
if (id !== undefined) order.value.splice(target, 0, id)
}
function create() {
emit('action', { action: 'playlist-create', name: newName.value })
}
function remove(trackId?: number) {
if (!selected.value) return
const playlistId = selected.value.id
dialog.warning({
title: trackId === undefined ? 'Удалить плейлист?' : 'Удалить трек?',
content: 'Изменение сохранится на сервере. Используемые плейлисты и треки удалить нельзя.',
positiveText: 'Удалить',
negativeText: 'Отмена',
onPositiveClick: () =>
emit(
'action',
trackId === undefined
? { action: 'playlist-delete', playlistId }
: { action: 'playlist-track-delete', playlistId, trackId },
),
})
}
</script>
<template>
<n-space vertical data-testid="music-plan-editor">
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
<template v-else>
<n-button :disabled="isDisabled" @click="refresh">Обновить плейлисты</n-button>
<label for="new-playlist-name">Новый плейлист</label>
<n-input
v-model:value="newName"
:input-props="{ id: 'new-playlist-name' }"
:disabled="isDisabled"
placeholder="Название плейлиста"
/>
<n-button :disabled="isDisabled || isDirty || !newName.trim()" @click="create"
>Создать плейлист</n-button
>
<n-select
v-model:value="selectedId"
:options="playlists.map((p) => ({ label: p.name, value: p.id }))"
:disabled="isDisabled || isDirty"
placeholder="Плейлист для редактирования"
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
/>
<p v-if="!playlists.length">Плейлистов пока нет. Создайте первый.</p>
<template v-if="selected">
<label for="playlist-name">Название плейлиста</label>
<n-input
v-model:value="name"
:input-props="{ id: 'playlist-name' }"
:disabled="isDisabled"
/>
<n-space>
<n-button
:disabled="isDisabled || !name.trim() || name === selected.name"
@click="emit('action', { action: 'playlist-rename', playlistId: selected.id, name })"
>Сохранить название</n-button
>
<n-button :disabled="isDisabled || isDirty" @click="remove()">Удалить плейлист</n-button>
<n-button
:disabled="isDisabled || isDirty"
@click="emit('action', { action: 'playlist-upload', playlistId: selected.id })"
>Добавить MP3</n-button
>
<n-button
:disabled="isDisabled || isDirty || !canUse || !selected.tracks.length"
@click="emit('action', { action: 'playlist-use', playlistId: selected.id })"
>Использовать в игре</n-button
>
</n-space>
<p>Выбор в редакторе не меняет музыку игры. MP3 — до 64 МиБ.</p>
<p v-if="!order.length">Треков пока нет.</p>
<ol class="track-list">
<li v-for="(id, index) in order" :key="id">
<span>{{
selected.tracks.find((t) => t.id === id)?.name ?? `Трек №${id} удалён на сервере`
}}</span>
<n-space>
<n-button
:disabled="isDisabled || index === 0"
:aria-label="`Поднять трек ${index + 1}`"
@click="move(index, -1)"
>↑</n-button
>
<n-button
:disabled="isDisabled || index === order.length - 1"
:aria-label="`Опустить трек ${index + 1}`"
@click="move(index, 1)"
>↓</n-button
>
<n-button :disabled="isDisabled || isDirty" @click="remove(id)"
>Удалить трек</n-button
>
</n-space>
</li>
</ol>
<n-alert v-if="hasConflict" type="warning"
>Состав треков изменился на сервере. Отмените черновик, чтобы принять актуальный
список.</n-alert
>
<n-space v-if="isDirty || hasConflict">
<n-button
:disabled="isDisabled || hasConflict"
@click="
emit('action', {
action: 'playlist-reorder',
playlistId: selected.id,
trackIds: [...order],
})
"
>Сохранить порядок</n-button
>
<n-button :disabled="props.isBusy" @click="reset">Отменить</n-button>
</n-space>
</template>
</template>
</n-space>
</template>
<style scoped>
.track-list {
display: grid;
gap: 12px;
padding-left: 24px;
}
.track-list li > span {
display: block;
margin-bottom: 8px;
overflow-wrap: anywhere;
}
</style>