up
This commit is contained in:
+4
-2
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NConfigProvider, NMessageProvider, darkTheme } from 'naive-ui'
|
||||
import { NConfigProvider, NDialogProvider, NMessageProvider, darkTheme } from 'naive-ui'
|
||||
import ControlPage from './components/ControlPage.vue'
|
||||
import OutputPage from './components/OutputPage.vue'
|
||||
|
||||
@@ -11,7 +11,9 @@ const isOutput = new URLSearchParams(location.search).has('output')
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<n-message-provider>
|
||||
<OutputPage v-if="isOutput && hasBridge" />
|
||||
<ControlPage v-else />
|
||||
<n-dialog-provider v-else>
|
||||
<ControlPage />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
NSpace,
|
||||
NSwitch,
|
||||
useMessage,
|
||||
useDialog,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type {
|
||||
@@ -20,13 +21,23 @@ import type {
|
||||
PreviewFrame,
|
||||
ScreenMode,
|
||||
State,
|
||||
ScreenStep,
|
||||
PlanStatus,
|
||||
} from '../types/screen'
|
||||
|
||||
import ConnectionPanel from './ConnectionPanel.vue'
|
||||
import ScreenPlanPanel from './ScreenPlanPanel.vue'
|
||||
import { modeLabels } from '../utils/presentation'
|
||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const planStatus = ref<PlanStatus>({ key: null, editable: false, notice: '' })
|
||||
const isPlanEditing = ref(false)
|
||||
const isPlanSaving = ref(false)
|
||||
const planDraft = ref<ScreenStep[]>([])
|
||||
const originalPlan = ref('')
|
||||
const isPlanDirty = computed(() => JSON.stringify(planDraft.value) !== originalPlan.value)
|
||||
const bridge = window.screenBridge
|
||||
const state = ref<State | null>(null)
|
||||
const packages = ref<PackageSummary[]>([])
|
||||
@@ -54,12 +65,6 @@ const previewURL = computed(() =>
|
||||
? preview.value?.dataURL
|
||||
: null,
|
||||
)
|
||||
const stepOptions = computed(() =>
|
||||
(state.value?.catalog.steps ?? []).map((step, index) => ({
|
||||
label: `${index + 1}. ${modeLabels[step.mode]}${step.mode === 8 ? ` · ${step.text.slice(0, 60)}` : ''}`,
|
||||
value: index,
|
||||
})),
|
||||
)
|
||||
const sceneLabels = {
|
||||
idle: 'Окно показа закрыто',
|
||||
pending: 'Подготовка показа',
|
||||
@@ -122,8 +127,60 @@ let timer: ReturnType<typeof setInterval> | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let session = ''
|
||||
let sequence = 0
|
||||
function editPlan() {
|
||||
planDraft.value = state.value?.catalog.steps.map((step) => ({ ...step })) ?? []
|
||||
originalPlan.value = JSON.stringify(planDraft.value)
|
||||
isPlanEditing.value = true
|
||||
if (!planDraft.value.length) addPlanStep()
|
||||
}
|
||||
function addPlanStep() {
|
||||
planDraft.value.push({
|
||||
mode: 8,
|
||||
text: 'Новый экран',
|
||||
timerLabel: 'Перерыв',
|
||||
timerDurationSeconds: 600,
|
||||
})
|
||||
}
|
||||
function movePlanStep(index: number, offset: number) {
|
||||
const target = index + offset
|
||||
if (target < 0 || target >= planDraft.value.length) return
|
||||
const [step] = planDraft.value.splice(index, 1)
|
||||
if (step) planDraft.value.splice(target, 0, step)
|
||||
}
|
||||
function discardPlan(action: () => void) {
|
||||
if (!isPlanEditing.value || !isPlanDirty.value) return action()
|
||||
dialog.warning({
|
||||
title: 'Несохранённые изменения',
|
||||
content: 'Отменить несохранённые изменения плана?',
|
||||
positiveText: 'Отменить изменения',
|
||||
negativeText: 'Продолжить редактирование',
|
||||
onPositiveClick: action,
|
||||
})
|
||||
}
|
||||
async function savePlan() {
|
||||
if (!bridge || !state.value?.catalog.key || isPlanSaving.value) return
|
||||
const key = state.value.catalog.key
|
||||
isPlanSaving.value = true
|
||||
try {
|
||||
await bridge.onlineAction({
|
||||
action: 'plan-save',
|
||||
key,
|
||||
steps: planDraft.value.map((step) => ({ ...step })),
|
||||
})
|
||||
if (state.value.catalog.key === key) isPlanEditing.value = false
|
||||
message.success('План экранов сохранён')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'Не удалось сохранить план')
|
||||
} finally {
|
||||
isPlanSaving.value = false
|
||||
}
|
||||
}
|
||||
function acceptState(value: State) {
|
||||
if (state.value && value.revision < state.value.revision) return
|
||||
if (value.catalog.key !== state.value?.catalog.key) {
|
||||
isPlanEditing.value = false
|
||||
planDraft.value = []
|
||||
}
|
||||
if (value.session !== state.value?.session || value.scene.text !== state.value?.scene.text)
|
||||
textDraft.value = value.scene.text
|
||||
if (
|
||||
@@ -164,7 +221,12 @@ async function send(intent: Intent) {
|
||||
message.error(error instanceof Error ? error.message : 'Команда не выполнена')
|
||||
}
|
||||
}
|
||||
async function selectPackage(key: string) {
|
||||
function selectPackage(key: string) {
|
||||
discardPlan(() => {
|
||||
void changePackage(key)
|
||||
})
|
||||
}
|
||||
async function changePackage(key: string) {
|
||||
await run(async () => {
|
||||
if (!bridge) return
|
||||
try {
|
||||
@@ -186,6 +248,7 @@ async function removePackage(key: string) {
|
||||
async function refreshHost() {
|
||||
if (!bridge) return
|
||||
host.value = await bridge.host()
|
||||
planStatus.value = await bridge.planStatus()
|
||||
if (display.value === null) display.value = host.value.preferredDisplayId
|
||||
if (!host.value.displays.some((d) => d.id === display.value)) display.value = null
|
||||
}
|
||||
@@ -244,7 +307,7 @@ onUnmounted(() => {
|
||||
<n-select
|
||||
:value="state?.catalog.key ?? 'fixtures'"
|
||||
:options="packageOptions"
|
||||
:disabled="!bridge || isBusy"
|
||||
:disabled="!bridge || isBusy || isPlanSaving"
|
||||
:input-props="{ 'aria-label': 'Подготовленный пакет' }"
|
||||
@update:value="selectPackage"
|
||||
/>
|
||||
@@ -298,35 +361,30 @@ onUnmounted(() => {
|
||||
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
|
||||
>
|
||||
<section class="presentation-grid">
|
||||
<n-card title="План показа">
|
||||
<n-card title="План экранов">
|
||||
<n-space vertical>
|
||||
<n-select
|
||||
:value="state.scene.stepIndex"
|
||||
:options="stepOptions"
|
||||
:disabled="isBusy || !state.catalog.steps.length"
|
||||
placeholder="Выберите шаг"
|
||||
:input-props="{ 'aria-label': 'Шаг плана' }"
|
||||
@update:value="send({ action: 'step', index: $event })"
|
||||
<ScreenPlanPanel
|
||||
:steps="state.catalog.steps"
|
||||
:current-index="state.scene.stepIndex"
|
||||
:draft="planDraft"
|
||||
:is-editing="isPlanEditing"
|
||||
:is-busy="isBusy || isPlanSaving"
|
||||
:can-edit="planStatus.key === state.catalog.key && planStatus.editable"
|
||||
:notice="planStatus.key === state.catalog.key ? planStatus.notice : ''"
|
||||
@edit="editPlan"
|
||||
@cancel="
|
||||
discardPlan(() => {
|
||||
isPlanEditing = false
|
||||
})
|
||||
"
|
||||
@save="savePlan"
|
||||
@add="addPlanStep"
|
||||
@remove="planDraft.splice($event, 1)"
|
||||
@move="movePlanStep"
|
||||
@clear="planDraft = []"
|
||||
@update-step="(index, patch) => Object.assign(planDraft[index]!, patch)"
|
||||
@command="send"
|
||||
/>
|
||||
<p v-if="!state.catalog.steps.length">
|
||||
В пакете нет плана. Используйте переключатель режимов.
|
||||
</p>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="isBusy || state.scene.stepIndex === null || state.scene.stepIndex <= 0"
|
||||
@click="send({ action: 'step', index: (state.scene.stepIndex ?? 0) - 1 })"
|
||||
>Предыдущий шаг</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="
|
||||
isBusy ||
|
||||
!state.catalog.steps.length ||
|
||||
(state.scene.stepIndex ?? -1) >= state.catalog.steps.length - 1
|
||||
"
|
||||
@click="send({ action: 'step', index: (state.scene.stepIndex ?? -1) + 1 })"
|
||||
>Следующий шаг</n-button
|
||||
>
|
||||
</n-space>
|
||||
<label>Режим показа</label>
|
||||
<n-select
|
||||
:value="state.scene.mode"
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui'
|
||||
import type { Intent, ScreenMode, ScreenStep } from '../types/screen'
|
||||
import { modeLabels } from '../utils/presentation'
|
||||
|
||||
defineProps<{
|
||||
steps: ScreenStep[]
|
||||
currentIndex: number | null
|
||||
draft: ScreenStep[]
|
||||
isEditing: boolean
|
||||
isBusy: boolean
|
||||
canEdit: boolean
|
||||
notice: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
edit: []
|
||||
cancel: []
|
||||
save: []
|
||||
add: []
|
||||
remove: [index: number]
|
||||
move: [index: number, offset: number]
|
||||
clear: []
|
||||
updateStep: [index: number, patch: Partial<ScreenStep>]
|
||||
command: [intent: Intent]
|
||||
}>()
|
||||
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||
value: Number(mode) as ScreenMode,
|
||||
label,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-space vertical data-testid="screen-plan">
|
||||
<n-space>
|
||||
<n-button v-if="!isEditing" :disabled="isBusy || !canEdit" @click="emit('edit')">
|
||||
{{ steps.length ? 'Редактировать' : 'Создать план' }}
|
||||
</n-button>
|
||||
<template v-else>
|
||||
<n-button :disabled="isBusy" @click="emit('cancel')">Отменить</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="isBusy || !canEdit"
|
||||
:loading="isBusy"
|
||||
@click="emit('save')"
|
||||
>
|
||||
Сохранить план
|
||||
</n-button>
|
||||
</template>
|
||||
</n-space>
|
||||
<n-alert v-if="notice" type="warning" role="status">{{ notice }}</n-alert>
|
||||
<p v-if="!canEdit">Для редактирования войдите на сервер выбранной подготовленной игры.</p>
|
||||
<template v-if="isEditing">
|
||||
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step">
|
||||
<span>Шаг {{ index + 1 }}</span>
|
||||
<n-select
|
||||
:value="step.mode"
|
||||
:options="modeOptions"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
|
||||
@update:value="emit('updateStep', index, { mode: $event })"
|
||||
/>
|
||||
<n-input
|
||||
v-if="step.mode === 8"
|
||||
:value="step.text"
|
||||
type="textarea"
|
||||
:disabled="isBusy"
|
||||
placeholder="Текст экрана"
|
||||
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
|
||||
@update:value="emit('updateStep', index, { text: $event })"
|
||||
/>
|
||||
<template v-if="step.mode === 7">
|
||||
<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 })"
|
||||
/>
|
||||
</template>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="isBusy || index === 0"
|
||||
:aria-label="`Поднять шаг ${index + 1}`"
|
||||
@click="emit('move', index, -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || index === draft.length - 1"
|
||||
:aria-label="`Опустить шаг ${index + 1}`"
|
||||
@click="emit('move', index, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy" type="error" ghost @click="emit('remove', index)"
|
||||
>Удалить</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-button :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
|
||||
<n-button :disabled="isBusy || !draft.length" @click="emit('clear')">Удалить план</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="!steps.length">План экранов ещё не создан. Используйте переключатель режимов.</p>
|
||||
<n-space
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
vertical
|
||||
class="plan-step"
|
||||
:class="{ 'plan-step-current': currentIndex === index }"
|
||||
:data-step-index="index"
|
||||
:aria-current="currentIndex === index ? 'step' : undefined"
|
||||
>
|
||||
<span
|
||||
>{{ index + 1 }}. {{ modeLabels[step.mode]
|
||||
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
||||
><template v-if="step.mode === 7"
|
||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||
></span
|
||||
>
|
||||
<n-space>
|
||||
<n-button
|
||||
:type="currentIndex === index ? 'primary' : 'default'"
|
||||
:disabled="isBusy || currentIndex === index"
|
||||
@click="emit('command', { action: 'step', index })"
|
||||
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
|
||||
>
|
||||
<template v-if="currentIndex === index && step.mode === 7">
|
||||
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-start' })"
|
||||
>Старт</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-pause' })"
|
||||
>Пауза</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-resume' })"
|
||||
>Продолжить</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })"
|
||||
>Сбросить</n-button
|
||||
>
|
||||
</template>
|
||||
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)">
|
||||
<n-button
|
||||
:disabled="isBusy"
|
||||
@click="emit('command', { action: 'play', channel: 'main' })"
|
||||
>Запуск</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy"
|
||||
@click="emit('command', { action: 'pause', channel: 'main' })"
|
||||
>Пауза</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy"
|
||||
@click="emit('command', { action: 'resume', channel: 'main' })"
|
||||
>Продолжить</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy"
|
||||
@click="emit('command', { action: 'stop', channel: 'main' })"
|
||||
>Стоп</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy"
|
||||
@click="emit('command', { action: 'replay', channel: 'main' })"
|
||||
>Повтор</n-button
|
||||
>
|
||||
</template>
|
||||
</n-space>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="isBusy || currentIndex === null || currentIndex <= 0"
|
||||
@click="emit('command', { action: 'step', index: (currentIndex ?? 0) - 1 })"
|
||||
>Предыдущий шаг</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || !steps.length || (currentIndex ?? -1) >= steps.length - 1"
|
||||
@click="emit('command', { action: 'step', index: (currentIndex ?? -1) + 1 })"
|
||||
>Следующий шаг</n-button
|
||||
>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-step {
|
||||
border: 1px solid var(--brand-background);
|
||||
overflow-wrap: anywhere;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.plan-step-current {
|
||||
border-color: var(--brand-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ScreenStep } from './screen.js'
|
||||
|
||||
export type OnlineIntent =
|
||||
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
|
||||
| { action: 'plan-refresh' }
|
||||
| { action: 'login'; server: string; email: string; password: string }
|
||||
| { action: 'logout' | 'games' | 'connect' | 'disconnect' | 'sound' | 'results' | 'cancel' }
|
||||
| { action: 'download' | 'check'; gameId: number }
|
||||
|
||||
@@ -135,7 +135,17 @@ export interface PackageSummary {
|
||||
version: string
|
||||
name: string
|
||||
}
|
||||
export interface ScreenPlan {
|
||||
steps: ScreenStep[]
|
||||
currentStep: number
|
||||
}
|
||||
export interface PlanStatus {
|
||||
key: string | null
|
||||
editable: boolean
|
||||
notice: string
|
||||
}
|
||||
export interface ScreenBridge {
|
||||
planStatus: () => Promise<PlanStatus>
|
||||
onlineState: () => Promise<OnlineState>
|
||||
onlineAction: (intent: OnlineIntent) => Promise<OnlineState>
|
||||
closeOutput: () => Promise<void>
|
||||
|
||||
Reference in New Issue
Block a user