Files
evening_detective_screen/src/components/ScreenPlanPanel.vue
T
2026-10-23 03:02:55 +07:00

342 lines
12 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, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui'
import { computed } from 'vue'
import { playbackControl } from '../utils/playbackControl'
import type { Catalog, ChannelState, Intent, ResultsSnapshot, ScreenMode, ScreenStep, TimerState } from '../types/screen'
import { changeTimerType, timerLabels, modeLabels } from '../utils/presentation'
import ScreenStepPreview from './ScreenStepPreview.vue'
const props = defineProps<{
catalog: Catalog
results: ResultsSnapshot | null
teamNames?: string[]
viewport: { width: number; height: number }
steps: ScreenStep[]
currentIndex: number | null
draft: ScreenStep[]
isEditing: boolean
isBusy: boolean
canPlayMain: boolean
mainDesired: ChannelState['desired']
mainStatus: ChannelState['status']
timerStatus: TimerState['status']
canEdit: boolean
notice: string
}>()
const emit = defineEmits<{
add: []
remove: [index: number]
move: [index: number, offset: number]
clear: []
updateStep: [index: number, patch: Partial<ScreenStep>]
command: [intent: Intent]
}>()
const mainControl = computed(() =>
playbackControl({ desired: props.mainDesired, status: props.mainStatus }),
)
const timerTypeOptions = timerLabels.map((label, value) => ({ label, value }))
function updateTimerType(index: number, value: 0 | 1 | 2) {
const step = props.draft[index]!
emit('updateStep', index, {
timerType: value,
timerLabel: changeTimerType(step.timerLabel, step.timerType, value),
})
}
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
value: Number(mode) as ScreenMode,
label,
}))
function timerLabel(index: number) {
if (props.currentIndex !== index) return 'Старт'
if (props.timerStatus === 'running') return 'Пауза'
return props.timerStatus === 'paused' ? 'Продолжить' : 'Старт'
}
</script>
<template>
<n-space vertical data-testid="screen-plan">
<n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert>
<p v-if="!canEdit">Для редактирования войдите на сервер игры.</p>
<template v-if="isEditing">
<div v-for="(step, index) in draft" :key="index" class="plan-step">
<div class="plan-edit-header">
<div class="plan-step-preview">
<ScreenStepPreview
:step="step"
:catalog="catalog"
:results="results"
:team-names="teamNames"
:viewport="viewport"
/>
</div>
<div class="plan-edit-controls">
<n-select
:value="step.mode"
:options="modeOptions"
:disabled="isBusy"
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
@update:value="emit('updateStep', index, { mode: $event })"
/>
<n-button size="small"
:disabled="isBusy || index === 0"
:aria-label="`Поднять шаг ${index + 1}`"
@click="emit('move', index, -1)"
>↑</n-button
>
<n-button size="small"
:disabled="isBusy || index === draft.length - 1"
:aria-label="`Опустить шаг ${index + 1}`"
@click="emit('move', index, 1)"
>↓</n-button
>
<n-button size="small" :disabled="isBusy" type="error" ghost @click="emit('remove', index)"
>Удалить</n-button
>
</div>
</div>
<n-input
v-if="step.mode === 8 || step.mode === 9"
:value="step.text"
type="textarea"
:disabled="isBusy"
placeholder="Текст экрана"
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
@update:value="emit('updateStep', index, { text: $event })"
/>
<div v-if="step.mode === 7">
<label :for="`plan-type-${index}`">Тип таймера</label>
<n-select
:value="step.timerType ?? 0"
:options="timerTypeOptions"
:disabled="isBusy"
:input-props="{ id: `plan-type-${index}` }"
@update:value="updateTimerType(index, $event)"
/>
<label :for="`plan-label-${index}`">Надпись таймера</label>
<n-input
:value="step.timerLabel"
:disabled="isBusy"
:input-props="{ id: `plan-label-${index}` }"
@update:value="emit('updateStep', index, { timerLabel: $event })"
/>
<label :for="`plan-seconds-${index}`">Длительность в секундах</label>
<n-input-number
:value="step.timerDurationSeconds"
:min="1"
:max="86400"
:disabled="isBusy"
:input-props="{ id: `plan-seconds-${index}` }"
@update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })"
/>
</div>
<div v-if="step.mode === 9">
<label :for="`plan-qr-${index}`">Ссылка для QR-кода (обязательно)</label>
<n-input
:value="step.qrUrl ?? ''"
:disabled="isBusy"
:input-props="{ id: `plan-qr-${index}` }"
placeholder="https://example.com"
@update:value="emit('updateStep', index, { qrUrl: $event })"
/>
</div>
</div>
<n-space>
<n-button size="small" :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
<n-button size="small" :disabled="isBusy || !draft.length" @click="emit('clear')">Удалить план</n-button>
</n-space>
</template>
<template v-else>
<p v-if="!steps.length">План экранов пуст.</p>
<div
v-for="(step, index) in steps"
:key="index"
class="plan-step"
:class="{ 'plan-step-current': currentIndex === index }"
:data-step-index="index"
:aria-current="currentIndex === index ? 'step' : undefined"
>
<div class="plan-step-header">
<div class="plan-step-preview">
<ScreenStepPreview
:step="step"
:catalog="catalog"
:results="results"
:team-names="teamNames"
:viewport="viewport"
/>
</div>
<div class="plan-step-content">
<div class="plan-step-title">
<span
>{{ modeLabels[step.mode]
}}<template v-if="step.mode === 8 || step.mode === 9">: {{ step.text }}</template
><template v-if="step.mode === 7"
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
></span
>
<n-button
data-testid="show-step"
size="small"
:disabled="isBusy || currentIndex === index"
@click="emit('command', { action: 'step', index })"
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
>
</div>
<div class="plan-step-actions">
<n-space v-if="[2, 4, 7].includes(step.mode)" align="center" :wrap="false">
<template v-if="step.mode === 7">
<n-button size="small"
square
:aria-label="timerLabel(index)"
:title="timerLabel(index)"
:disabled="isBusy || currentIndex !== index"
@click="
emit('command', {
action:
timerStatus === 'running'
? 'timer-pause'
: timerStatus === 'paused'
? 'timer-resume'
: 'timer-start',
})
"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path v-if="currentIndex === index && timerStatus === 'running'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
<path v-else d="M7 4v16l13-8z" />
</svg>
</n-button>
<n-button size="small" square aria-label="Сбросить" title="Сбросить" :disabled="isBusy || currentIndex !== index" @click="emit('command', { action: 'timer-reset' })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 4v6h6M3 10a9 9 0 1 1 0 6" />
</svg>
</n-button
>
</template>
<template v-if="step.mode === 2 || step.mode === 4">
<n-button size="small"
square
:aria-label="currentIndex === index ? mainControl.label : 'Воспроизвести'"
:title="currentIndex === index ? mainControl.label : 'Воспроизвести'"
:disabled="isBusy || currentIndex !== index || !canPlayMain"
@click="emit('command', { action: mainControl.action, channel: 'main' })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path v-if="currentIndex === index && mainControl.action === 'pause'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
<path v-else d="M7 4v16l13-8z" />
</svg>
</n-button
>
<n-button size="small"
square
aria-label="Стоп"
title="Стоп"
:disabled="isBusy || currentIndex !== index || !canPlayMain"
@click="emit('command', { action: 'stop', channel: 'main' })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M5 5h14v14H5z" />
</svg>
</n-button
>
<n-button size="small"
square
aria-label="Сначала"
title="Сначала"
:disabled="isBusy || currentIndex !== index || !canPlayMain"
@click="emit('command', { action: 'replay', channel: 'main' })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M20 11a8 8 0 1 0-2 6M20 4v7h-7" />
</svg>
</n-button
>
<slot name="main-volume" :index="index" :active="currentIndex === index" />
</template>
</n-space>
</div>
<slot
v-if="step.mode === 2 || step.mode === 4"
name="main-controls"
:index="index"
:active="currentIndex === index"
/>
</div>
</div>
</div>
</template>
</n-space>
</template>
<style scoped>
.plan-notice {
overflow-wrap: anywhere;
}
.plan-step {
border: 1px solid var(--brand-background);
display: flex;
flex-direction: column;
gap: 12px;
overflow-wrap: anywhere;
padding: 12px;
}
.plan-step-header {
align-items: stretch;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.plan-step-content {
display: flex;
flex: 1 1 380px;
flex-direction: column;
gap: 8px;
justify-content: space-between;
min-width: 0;
}
.plan-step-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-start;
}
.plan-step-title {
align-items: start;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
}
.plan-step-preview {
flex: 0 0 160px;
}
.plan-edit-header {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.plan-edit-controls {
align-items: center;
display: grid;
flex: 1 1 320px;
gap: 8px;
grid-template-columns: minmax(0, 1fr) auto auto auto;
min-width: 0;
}
.plan-step-current {
border-color: var(--brand-text);
}
</style>