Files
evening_detective_frontend/src/components/GameScreensPanel.vue
T
2026-10-07 02:17:53 +07:00

329 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NAlert, NButton, NFlex, NInput, NInputNumber, NSelect, NSwitch, NTag, NUpload, type UploadFileInfo } from 'naive-ui'
import { ref } from 'vue'
import type { MusicState, Playlist, ScreenState } from '@/api/generated/crabs/evening_detective_server'
import ScreenBindingModal from '@/components/ScreenBindingModal.vue'
import UiBlock from '@/components/UiBlock.vue'
const props = defineProps<{
screens: ScreenState[]
screenBinding: boolean
playlists: Playlist[]
music?: MusicState
}>()
const emit = defineEmits<{
bind: [screenID: string, accessToken: string]
setSound: [screen: ScreenState]
unbind: [screen: ScreenState]
setMusic: [change: { playlistID?: number; trackIndex?: number; positionMs?: number; playback?: MusicState['playback']; loop?: boolean; volume?: number }]
createPlaylist: [name: string]
deletePlaylist: [id: number]
uploadTrack: [file: File]
deleteTrack: [id: number]
}>()
const showBindingModal = ref(false)
const playlistName = ref('')
function uploadTrack(options: { file: UploadFileInfo }) {
if (options.file.file) emit('uploadTrack', options.file.file)
}
</script>
<template>
<UiBlock>
<n-flex align="center" justify="space-between" class="screen-header">
<p class="screens-title">Экраны</p>
<n-button size="small" @click="showBindingModal = true"> Привязать экран </n-button>
</n-flex>
<n-alert
v-if="screens.some((screen) => screen.status === 'disconnected')"
type="warning"
title="Проверьте подключение экрана"
>
Один или несколько экранов не выходят на связь более минуты.
</n-alert>
<p v-if="screens.length === 0" class="screen-empty">К игре пока не привязаны экраны.</p>
<template v-else>
<div v-for="screen in screens" :key="screen.ID" class="screen-row">
<div>
<div>Экран {{ screen.ID }}</div>
<div class="screen-status">
{{ screen.status === 'connected' ? 'Онлайн' : 'Оффлайн' }}
</div>
<div v-if="screen.error" class="screen-error">{{ screen.error }}</div>
</div>
<n-flex justify="end">
<n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag>
<n-button v-else size="small" @click="emit('setSound', screen)">Назначить звук</n-button>
<n-button size="small" type="error" ghost @click="emit('unbind', screen)">
Отвязать
</n-button>
</n-flex>
</div>
</template>
</UiBlock>
<ScreenBindingModal
v-model:show="showBindingModal"
:binding="props.screenBinding"
@scanned="(screenID, accessToken) => emit('bind', screenID, accessToken)"
/>
<UiBlock class="music-block">
<p class="screens-title">Музыка</p>
<n-flex align="center" wrap class="playlist-actions">
<n-input v-model:value="playlistName" placeholder="Новый плейлист" style="max-width: 220px" />
<n-button
size="small"
:disabled="!playlistName.trim()"
@click="emit('createPlaylist', playlistName.trim()); playlistName = ''"
>
Создать
</n-button>
<n-button
v-if="props.music?.playlist?.ID"
size="small"
type="error"
ghost
@click="emit('deletePlaylist', props.music.playlist.ID)"
>
Удалить плейлист
</n-button>
<n-upload accept=".mp3,audio/mpeg" :default-upload="false" :show-file-list="false" @change="uploadTrack">
<n-button size="small">Добавить MP3</n-button>
</n-upload>
</n-flex>
<n-flex align="center" wrap>
<n-select
:value="props.music?.playlist?.ID"
:options="props.playlists.map((playlist) => ({ label: playlist.name, value: playlist.ID }))"
placeholder="Плейлист"
clearable
style="min-width: 220px"
@update:value="(value) => emit('setMusic', { playlistID: value || 0, trackIndex: 0, playback: 'MUSIC_PLAYBACK_STOPPED' })"
/>
<div v-if="props.music?.playlist" class="track-list">
<p v-if="!props.music.playlist.tracks?.length" class="track-empty">В плейлисте пока нет треков.</p>
<div
v-for="(track, index) in props.music.playlist.tracks"
:key="track.ID"
class="track-row"
:class="{ 'track-row-active': index === (props.music.trackIndex ?? 0) }"
>
<div class="track-main">
<span class="track-number">{{ index + 1 }}</span>
<span class="track-details">
<span class="track-name">{{ track.name || track.filename || `Трек ${index + 1}` }}</span>
<span class="track-filename">{{ track.filename }}</span>
</span>
</div>
<n-flex align="center" class="track-actions">
<n-tag v-if="index === (props.music.trackIndex ?? 0)" type="success" size="small">
{{ props.music.playback === 'MUSIC_PLAYBACK_PLAYING' ? 'Играет' : 'Выбран' }}
</n-tag>
<n-button
v-else
size="small"
type="primary"
ghost
@click="emit('setMusic', { trackIndex: index, positionMs: 0 })"
>
Включить
</n-button>
<n-button size="small" type="error" ghost @click="emit('deleteTrack', track.ID!)">
Удалить
</n-button>
</n-flex>
</div>
</div>
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_PLAYING' })">Старт</n-button>
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_PAUSED' })">Пауза</n-button>
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_STOPPED', positionMs: 0 })">Стоп</n-button>
<div class="volume-control">
<span class="volume-label">Громкость</span>
<n-input-number
:value="props.music?.volume ?? 100"
:min="0"
:max="100"
:step="5"
size="small"
:show-button="true"
aria-label="Громкость музыки"
class="volume-input"
@update:value="(value) => value != null && emit('setMusic', { volume: value })"
>
<template #suffix>%</template>
</n-input-number>
</div>
<n-switch
:value="props.music?.loop"
:round="false"
@update:value="(value) => emit('setMusic', { loop: value })"
>
<template #checked>Loop</template>
<template #unchecked>Loop</template>
</n-switch>
</n-flex>
</UiBlock>
</template>
<style scoped>
.screens-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.screen-header {
margin-bottom: 8px;
}
.music-block {
margin-top: 12px;
}
.playlist-actions {
margin: 8px 0;
}
.volume-control {
align-items: center;
display: inline-flex;
gap: 6px;
}
.volume-label {
white-space: nowrap;
}
.volume-input {
width: 96px;
}
.screen-row {
align-items: center;
border-top: 1px solid rgb(255 255 255 / 10%);
display: flex;
gap: 8px;
justify-content: space-between;
padding: 8px 0;
}
.screen-status {
color: #aaa;
font-size: 13px;
}
.screen-empty {
color: #aaa;
margin: 16px 0 0;
}
.screen-error {
color: #f2a2a2;
font-size: 13px;
}
.track-list {
flex-basis: 100%;
margin-top: 8px;
padding: 0 8px;
}
.track-empty {
color: #aaa;
margin: 0;
}
.track-row {
align-items: center;
border-top: 1px solid rgb(255 255 255 / 10%);
display: flex;
gap: 8px;
justify-content: space-between;
min-height: 56px;
padding: 12px 8px;
}
.track-row-active {
background: rgb(255 255 255 / 12%);
}
.track-main {
align-items: center;
display: flex;
flex: 1;
overflow: hidden;
padding-left: 8px;
}
.track-number {
color: #aaa;
flex: 0 0 28px;
font-variant-numeric: tabular-nums;
text-align: center;
}
.track-details {
display: flex;
flex-direction: column;
min-width: 0;
text-align: left;
}
.track-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-filename {
color: #aaa;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-actions {
flex: 0 0 auto;
}
@media (width < 1024px) {
.track-list {
padding: 0;
}
.track-row {
align-items: stretch;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.track-main {
padding-left: 0;
width: 100%;
}
.track-actions {
justify-content: flex-end;
width: 100%;
}
.track-actions .n-button {
flex: 1;
}
.volume-control {
justify-content: space-between;
width: 100%;
}
.volume-input {
flex: 1;
width: auto;
}
}
</style>