generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex, NSpace, NText } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
Application,
|
||||
MapItem,
|
||||
ResultsQuestion,
|
||||
ScreenState,
|
||||
Team,
|
||||
TeamResult,
|
||||
} from '@/api/generated/crabs/evening_detective_server'
|
||||
import ScreenPreview from '@/components/ScreenPreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
mapItems: MapItem[]
|
||||
teams: Team[]
|
||||
screens: ScreenState[]
|
||||
previewQuestions: ResultsQuestion[]
|
||||
previewTeams: TeamResult[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saveItem: [item: MapItem, hidden?: boolean]
|
||||
giveApplication: [team: Team, application: Application]
|
||||
}>()
|
||||
|
||||
const mapScale = ref(1)
|
||||
const mapOffset = ref({ x: 0, y: 0 })
|
||||
const previewScreen = computed(
|
||||
() => props.screens.find((screen) => screen.status === 'connected') || props.screens[0],
|
||||
)
|
||||
const teamsNeedingApplications = computed(
|
||||
() =>
|
||||
new Map(props.teams.filter((team) => team.applications?.length).map((team) => [team.ID, team])),
|
||||
)
|
||||
let dragState:
|
||||
{ item: MapItem; startX: number; startY: number; itemX: number; itemY: number } | undefined
|
||||
let panState: { startX: number; startY: number; offsetX: number; offsetY: number } | undefined
|
||||
|
||||
function startMapDrag(item: MapItem, event: PointerEvent) {
|
||||
dragState = {
|
||||
item,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
itemX: item.x || 0,
|
||||
itemY: item.y || 0,
|
||||
}
|
||||
window.addEventListener('pointermove', moveMapDrag)
|
||||
window.addEventListener('pointerup', endMapDrag, { once: true })
|
||||
}
|
||||
|
||||
function moveMapDrag(event: PointerEvent) {
|
||||
if (!dragState) return
|
||||
const step = dragState.item.kind === 'team' ? 20 : 1
|
||||
dragState.item.x =
|
||||
Math.round((dragState.itemX + (event.clientX - dragState.startX) / mapScale.value) / step) *
|
||||
step
|
||||
dragState.item.y =
|
||||
Math.round((dragState.itemY + (event.clientY - dragState.startY) / mapScale.value) / step) *
|
||||
step
|
||||
}
|
||||
|
||||
function endMapDrag() {
|
||||
if (!dragState) return
|
||||
const item = dragState.item
|
||||
dragState = undefined
|
||||
window.removeEventListener('pointermove', moveMapDrag)
|
||||
emit('saveItem', item)
|
||||
}
|
||||
|
||||
function toggleMapItem(item: MapItem) {
|
||||
item.hidden = !item.hidden
|
||||
emit('saveItem', item, item.hidden)
|
||||
}
|
||||
|
||||
function changeMapScale(delta: number) {
|
||||
mapScale.value = Math.min(2, Math.max(0.5, Number((mapScale.value + delta).toFixed(2))))
|
||||
}
|
||||
|
||||
function resetMapView() {
|
||||
mapScale.value = 1
|
||||
mapOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function startMapPan(event: PointerEvent) {
|
||||
if (event.button !== 0) return
|
||||
panState = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
offsetX: mapOffset.value.x,
|
||||
offsetY: mapOffset.value.y,
|
||||
}
|
||||
window.addEventListener('pointermove', moveMapPan)
|
||||
window.addEventListener('pointerup', endMapPan, { once: true })
|
||||
}
|
||||
|
||||
function moveMapPan(event: PointerEvent) {
|
||||
if (!panState) return
|
||||
mapOffset.value = {
|
||||
x: panState.offsetX + event.clientX - panState.startX,
|
||||
y: panState.offsetY + event.clientY - panState.startY,
|
||||
}
|
||||
}
|
||||
|
||||
function endMapPan() {
|
||||
panState = undefined
|
||||
window.removeEventListener('pointermove', moveMapPan)
|
||||
}
|
||||
|
||||
function zoomMap(event: WheelEvent) {
|
||||
event.preventDefault()
|
||||
changeMapScale(event.deltaY < 0 ? 0.1 : -0.1)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointermove', moveMapDrag)
|
||||
window.removeEventListener('pointermove', moveMapPan)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-preview-layout">
|
||||
<div class="map-block">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<p class="screens-title">Карта помещения</p>
|
||||
<n-space>
|
||||
<n-button size="small" @click="changeMapScale(-0.1)">−</n-button>
|
||||
<n-text>{{ Math.round(mapScale * 100) }}%</n-text>
|
||||
<n-button size="small" @click="changeMapScale(0.1)">+</n-button>
|
||||
<n-button size="small" @click="resetMapView">Сбросить</n-button>
|
||||
</n-space>
|
||||
</n-flex>
|
||||
<div class="map-field" @pointerdown="startMapPan" @wheel="zoomMap">
|
||||
<div
|
||||
class="map-canvas"
|
||||
:style="{ transform: `translate(${mapOffset.x}px, ${mapOffset.y}px) scale(${mapScale})` }"
|
||||
>
|
||||
<div
|
||||
v-for="item in mapItems"
|
||||
v-show="!item.hidden"
|
||||
:key="`${item.kind}-${item.itemID}`"
|
||||
class="map-item"
|
||||
:class="[
|
||||
item.kind === 'screen'
|
||||
? 'map-screen'
|
||||
: item.kind === 'organizer_desk'
|
||||
? 'map-organizer-desk'
|
||||
: 'map-team',
|
||||
{
|
||||
'map-team-needs-applications':
|
||||
item.kind === 'team' && teamsNeedingApplications.has(item.itemID),
|
||||
},
|
||||
]"
|
||||
:style="{ left: `${item.x || 0}px`, top: `${item.y || 0}px` }"
|
||||
@pointerdown.stop="item.kind !== 'team' && startMapDrag(item, $event)"
|
||||
>
|
||||
<div
|
||||
v-if="item.kind === 'team'"
|
||||
class="map-team-handle"
|
||||
@pointerdown.stop="startMapDrag(item, $event)"
|
||||
/>
|
||||
<span>{{ item.label }}</span>
|
||||
<n-space
|
||||
v-if="item.kind === 'team' && teamsNeedingApplications.has(item.itemID)"
|
||||
class="map-team-applications"
|
||||
vertical
|
||||
>
|
||||
<n-button
|
||||
v-for="application in teamsNeedingApplications.get(item.itemID)?.applications"
|
||||
:key="application.name"
|
||||
size="small"
|
||||
@click="
|
||||
emit('giveApplication', teamsNeedingApplications.get(item.itemID)!, application)
|
||||
"
|
||||
>
|
||||
Выдать: {{ application.name }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<small v-if="item.kind === 'screen'"
|
||||
>{{ item.status }}{{ item.soundEnabled ? ' · звук' : '' }}</small
|
||||
>
|
||||
<button
|
||||
class="map-item-hide"
|
||||
type="button"
|
||||
@pointerdown.stop
|
||||
@click.stop="toggleMapItem(item)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-space v-if="mapItems.some((item) => item.hidden)" class="map-hidden-items">
|
||||
<n-button
|
||||
v-for="item in mapItems.filter((item) => item.hidden)"
|
||||
:key="`hidden-${item.kind}-${item.itemID}`"
|
||||
size="small"
|
||||
@click="toggleMapItem(item)"
|
||||
>
|
||||
Добавить {{ item.label }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<aside class="screen-preview-panel">
|
||||
<p class="screens-title">Превью экрана</p>
|
||||
<ScreenPreview
|
||||
v-if="previewScreen?.status === 'connected'"
|
||||
:state="previewScreen"
|
||||
:questions="previewQuestions"
|
||||
:teams="previewTeams"
|
||||
/>
|
||||
<div v-else class="screen-preview-placeholder">Экран не подключён</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-preview-layout {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
|
||||
}
|
||||
|
||||
.map-block,
|
||||
.screen-preview-panel {
|
||||
background-color: #222;
|
||||
border-radius: 10px;
|
||||
margin: 12px 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.screens-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.screen-preview-placeholder {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-color: #111;
|
||||
color: #aaa;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-field {
|
||||
background-color: #151515;
|
||||
border: 1px dashed rgb(255 255 255 / 20%);
|
||||
cursor: grab;
|
||||
height: 420px;
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
resize: vertical;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.map-field:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 8%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 8%) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
height: 1000px;
|
||||
position: absolute;
|
||||
transform-origin: 0 0;
|
||||
width: 2000px;
|
||||
}
|
||||
|
||||
.map-item {
|
||||
border-radius: 6px;
|
||||
cursor: grab;
|
||||
min-width: 90px;
|
||||
padding: 8px 24px 8px 10px;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.map-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.map-screen {
|
||||
background-color: #355b75;
|
||||
}
|
||||
|
||||
.map-organizer-desk {
|
||||
background-color: #48633a;
|
||||
}
|
||||
|
||||
.map-team {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.map-team,
|
||||
.map-team:active {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.map-team-handle {
|
||||
background-color: #48633a;
|
||||
border-radius: 6px 6px 0 0;
|
||||
cursor: grab;
|
||||
margin: -8px -24px 8px -10px;
|
||||
padding: 8px 24px 8px 10px;
|
||||
}
|
||||
|
||||
.map-team-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.map-team-applications {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.map-team-needs-applications {
|
||||
outline: 2px solid gold;
|
||||
}
|
||||
|
||||
.map-item small {
|
||||
display: block;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.map-item-hide {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.map-team .map-item-hide {
|
||||
line-height: 16px;
|
||||
padding: 0 2px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.map-hidden-items {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (width < 1024px) {
|
||||
.map-preview-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+237
-521
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NFlex,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTag,
|
||||
NText,
|
||||
} from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ScreenCommand,
|
||||
ScreenMode,
|
||||
ScreenPlan,
|
||||
ScreenPlanStep,
|
||||
ScreenState,
|
||||
} from '@/api/generated/crabs/evening_detective_server'
|
||||
import ScreenBindingModal from '@/components/ScreenBindingModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
screens: ScreenState[]
|
||||
screenPlan?: ScreenPlan
|
||||
screenPlanDraft: ScreenPlanStep[]
|
||||
screenPlanDirty: boolean
|
||||
screenPlanSaving: boolean
|
||||
screenPlanMode: 'use' | 'edit'
|
||||
screenVolume: number
|
||||
screenPosition: number
|
||||
screenBinding: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
bind: [screenID: string, accessToken: string]
|
||||
setSound: [screen: ScreenState]
|
||||
unbind: [screen: ScreenState]
|
||||
addStep: []
|
||||
edit: []
|
||||
cancel: []
|
||||
removeStep: [index: number]
|
||||
moveStep: [index: number, offset: number]
|
||||
dirty: []
|
||||
clearPlan: []
|
||||
save: []
|
||||
setStep: [index: number]
|
||||
command: [mode: ScreenMode | undefined, command: ScreenCommand]
|
||||
'update:screenVolume': [value: number | null]
|
||||
'update:screenPosition': [value: number | null]
|
||||
}>()
|
||||
|
||||
const showBindingModal = ref(false)
|
||||
|
||||
const screenPlanModeOptions = [
|
||||
{ label: 'Обложка', value: 'SCREEN_MODE_COVER' },
|
||||
{ label: 'Введение', value: 'SCREEN_MODE_INTRO' },
|
||||
{ label: 'Успехов', value: 'SCREEN_MODE_INTRO_DONE' },
|
||||
{ label: 'Закрывашка', value: 'SCREEN_MODE_CONCLUSION' },
|
||||
{ label: 'Спасибо за игру', value: 'SCREEN_MODE_CONCLUSION_DONE' },
|
||||
{ label: 'Результаты', value: 'SCREEN_MODE_RESULTS' },
|
||||
{ label: 'Таймер', value: 'SCREEN_MODE_TIMER' },
|
||||
{ label: 'Текст', value: 'SCREEN_MODE_TEXT' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="screens-block">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<p class="screens-title">Экраны</p>
|
||||
<n-button size="small" type="primary" @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>
|
||||
<div class="screen-plan">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<p class="screens-title">План экранов</p>
|
||||
<n-space>
|
||||
<n-button
|
||||
v-if="!screenPlan && screenPlanMode === 'use'"
|
||||
size="small"
|
||||
@click="emit('addStep')"
|
||||
>
|
||||
Создать план
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="screenPlan && screenPlanMode === 'use'"
|
||||
size="small"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
Редактировать
|
||||
</n-button>
|
||||
<template v-if="screenPlanMode === 'edit'">
|
||||
<n-button size="small" :disabled="screenPlanSaving" @click="emit('cancel')"
|
||||
>Отменить</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="screenPlanSaving"
|
||||
:disabled="screenPlanSaving"
|
||||
@click="emit('save')"
|
||||
>
|
||||
Сохранить план
|
||||
</n-button>
|
||||
</template>
|
||||
</n-space>
|
||||
</n-flex>
|
||||
<p v-if="!screenPlan && screenPlanMode === 'use'" class="screen-empty">
|
||||
План экранов ещё не создан.
|
||||
</p>
|
||||
<template v-else-if="screenPlanMode === 'edit'">
|
||||
<n-space v-for="(step, index) in screenPlanDraft" :key="index" align="center" wrap>
|
||||
<n-text>{{ index + 1 }}</n-text>
|
||||
<n-select
|
||||
v-model:value="step.mode"
|
||||
:options="screenPlanModeOptions"
|
||||
:disabled="screenPlanSaving"
|
||||
size="small"
|
||||
style="width: 180px"
|
||||
@update:value="emit('dirty')"
|
||||
/>
|
||||
<n-input
|
||||
v-if="step.mode === 'SCREEN_MODE_TEXT'"
|
||||
v-model:value="step.text"
|
||||
:disabled="screenPlanSaving"
|
||||
size="small"
|
||||
placeholder="Текст экрана"
|
||||
style="min-width: 240px"
|
||||
@update:value="emit('dirty')"
|
||||
/>
|
||||
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||||
<n-input
|
||||
v-model:value="step.timerLabel"
|
||||
:disabled="screenPlanSaving"
|
||||
size="small"
|
||||
placeholder="Надпись таймера"
|
||||
@update:value="emit('dirty')"
|
||||
/>
|
||||
<n-input-number
|
||||
v-model:value="step.timerDurationSeconds"
|
||||
:min="1"
|
||||
:max="86400"
|
||||
:disabled="screenPlanSaving"
|
||||
size="small"
|
||||
placeholder="Секунды"
|
||||
@update:value="emit('dirty')"
|
||||
/>
|
||||
</template>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="screenPlanSaving || index === 0"
|
||||
@click="emit('moveStep', index, -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="screenPlanSaving || index === screenPlanDraft.length - 1"
|
||||
@click="emit('moveStep', index, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="screenPlanSaving"
|
||||
@click="emit('removeStep', index)"
|
||||
>Удалить</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-button size="small" :disabled="screenPlanSaving" @click="emit('addStep')"
|
||||
>Добавить шаг</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="screenPlanDraft.length"
|
||||
size="small"
|
||||
:disabled="screenPlanSaving"
|
||||
@click="emit('clearPlan')"
|
||||
>Удалить план</n-button
|
||||
>
|
||||
</n-space>
|
||||
</template>
|
||||
<template v-else-if="screenPlan">
|
||||
<n-space v-for="(step, index) in screenPlan.steps || []" :key="index" align="center" wrap>
|
||||
<n-text>{{ index + 1 }}</n-text>
|
||||
<n-text>
|
||||
{{ screenPlanModeOptions.find((option) => option.value === step.mode)?.label }}
|
||||
<template v-if="step.mode === 'SCREEN_MODE_TEXT'">: {{ step.text }}</template>
|
||||
<template v-else-if="step.mode === 'SCREEN_MODE_TIMER'"
|
||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||
>
|
||||
</n-text>
|
||||
<n-button
|
||||
size="small"
|
||||
:type="screenPlan.currentStep === index ? 'primary' : 'default'"
|
||||
:disabled="screenPlan.currentStep === index"
|
||||
@click="emit('setStep', index)"
|
||||
>Показать</n-button
|
||||
>
|
||||
<template v-if="screenPlan.currentStep === index">
|
||||
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||||
<n-button
|
||||
size="small"
|
||||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_START')"
|
||||
>Старт</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_PAUSE')"
|
||||
>Пауза</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESUME')"
|
||||
>Продолжить</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESET')"
|
||||
>Сбросить</n-button
|
||||
>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
step.mode === 'SCREEN_MODE_INTRO' || step.mode === 'SCREEN_MODE_CONCLUSION'
|
||||
"
|
||||
>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_PLAY')"
|
||||
>Запуск</n-button
|
||||
>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_PAUSE')"
|
||||
>Пауза</n-button
|
||||
>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_RESUME')"
|
||||
>Продолжить</n-button
|
||||
>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_STOP')"
|
||||
>Стоп</n-button
|
||||
>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_REPLAY')"
|
||||
>Повтор</n-button
|
||||
>
|
||||
<n-input-number
|
||||
:value="screenVolume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="5"
|
||||
size="small"
|
||||
@update:value="emit('update:screenVolume', $event)"
|
||||
/>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_VOLUME')"
|
||||
>Громкость</n-button
|
||||
>
|
||||
<n-input-number
|
||||
:value="screenPosition"
|
||||
:min="0"
|
||||
:step="5"
|
||||
size="small"
|
||||
@update:value="emit('update:screenPosition', $event)"
|
||||
/>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_SEEK')"
|
||||
>Перемотать</n-button
|
||||
>
|
||||
</template>
|
||||
</template>
|
||||
</n-space>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<ScreenBindingModal
|
||||
v-model:show="showBindingModal"
|
||||
:binding="props.screenBinding"
|
||||
@scanned="(screenID, accessToken) => emit('bind', screenID, accessToken)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screens-block {
|
||||
background-color: #222;
|
||||
border-radius: 10px;
|
||||
margin: 12px 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.screens-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.screen-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.screen-status {
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.screen-empty {
|
||||
color: #aaa;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.screen-error {
|
||||
color: #f2a2a2;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex } from 'naive-ui'
|
||||
|
||||
import type { Application, Team } from '@/api/generated/crabs/evening_detective_server'
|
||||
|
||||
defineProps<{
|
||||
teams: Team[]
|
||||
participationOptions: { label: string; value: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: []
|
||||
openQR: [team: Team]
|
||||
preview: [id: number, password: string]
|
||||
delete: [team: Team]
|
||||
giveApplication: [team: Team, application: Application]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="team-block team-block-hover" @click="emit('add')">
|
||||
<p class="place-image-plus">+</p>
|
||||
</div>
|
||||
<div v-for="team in teams" :key="team.ID" class="team-block">
|
||||
<div class="team-content-block">
|
||||
<n-flex justify="space-between">
|
||||
<div class="title-block">
|
||||
<span class="team-name-block">
|
||||
{{ team.name }}
|
||||
<small v-if="team.captainEmail"> — {{ team.captainEmail }}</small>
|
||||
<small v-if="team.participantsCount"> · Участников: {{ team.participantsCount }}</small>
|
||||
</span>
|
||||
<div v-if="team.captainEmail || team.participationStatus !== 'pending'">
|
||||
{{
|
||||
participationOptions.find((option) => option.value === team.participationStatus)
|
||||
?.label
|
||||
}}<span v-if="team.participationSource === 'automatic'"> · автоматически</span>
|
||||
</div>
|
||||
<div v-if="!team.captainEmail">Почта капитана не указана</div>
|
||||
<div v-else-if="team.participationEmailError">
|
||||
Ошибка отправки: {{ team.participationEmailError }}
|
||||
</div>
|
||||
<div v-else-if="team.participationEmailSent">Письмо отправлено</div>
|
||||
<div v-if="team.participationStatus !== 'cancelled'">
|
||||
Поездки: {{ team.actionsCount }}
|
||||
</div>
|
||||
</div>
|
||||
<n-flex justify="end">
|
||||
<n-button v-if="team.participationStatus !== 'cancelled'" @click="emit('openQR', team)"
|
||||
>QR</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="team.participationStatus !== 'cancelled'"
|
||||
@click="emit('preview', team.ID!, team.password!)"
|
||||
>Подсмотреть игру</n-button
|
||||
>
|
||||
<n-button type="error" ghost @click="emit('delete', team)">Удалить</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-button
|
||||
v-for="application in team.applications"
|
||||
:key="application.name"
|
||||
class="link-button"
|
||||
@click="emit('giveApplication', team, application)"
|
||||
>
|
||||
Выдать: {{ application.name }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.team-block {
|
||||
background-color: #222;
|
||||
border: 1px solid #222;
|
||||
border-radius: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.team-block-hover:hover {
|
||||
background-color: #333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-content-block {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.team-name-block {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
margin: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.place-image-plus {
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCard, NModal, NSpace } from 'naive-ui'
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
binding: boolean
|
||||
}>()
|
||||
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
scanned: [screenID: string, accessToken: string]
|
||||
}>()
|
||||
|
||||
type BarcodeDetector = { detect(video: HTMLVideoElement): Promise<{ rawValue: string }[]> }
|
||||
|
||||
const scannerError = ref('')
|
||||
const scannerVideo = ref<HTMLVideoElement>()
|
||||
const scanning = ref(false)
|
||||
let scannerStream: MediaStream | undefined
|
||||
let scannerFrame = 0
|
||||
|
||||
function stopScanning() {
|
||||
cancelAnimationFrame(scannerFrame)
|
||||
scannerStream?.getTracks().forEach((track) => track.stop())
|
||||
scannerStream = undefined
|
||||
scanning.value = false
|
||||
}
|
||||
|
||||
function parseScreenQRCode(rawValue: string) {
|
||||
try {
|
||||
const url = new URL(rawValue, window.location.origin)
|
||||
const screenID = url.searchParams.get('screenId')
|
||||
const accessToken = url.searchParams.get('accessToken')
|
||||
if (url.pathname !== '/screen' || !screenID || !accessToken) return
|
||||
return { screenID, accessToken }
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function startScanning() {
|
||||
const DetectorClass = (window as Window & { BarcodeDetector?: new () => BarcodeDetector })
|
||||
.BarcodeDetector
|
||||
if (!DetectorClass || !navigator.mediaDevices?.getUserMedia) {
|
||||
scannerError.value = 'Сканирование камерой не поддерживается в этом браузере.'
|
||||
return
|
||||
}
|
||||
scannerError.value = ''
|
||||
try {
|
||||
scannerStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
})
|
||||
scanning.value = true
|
||||
await nextTick()
|
||||
if (!scannerVideo.value) return
|
||||
scannerVideo.value.srcObject = scannerStream
|
||||
await scannerVideo.value.play()
|
||||
const detector = new DetectorClass()
|
||||
const scan = async () => {
|
||||
if (!scannerVideo.value || !scannerStream) return
|
||||
try {
|
||||
const code = (await detector.detect(scannerVideo.value))[0]?.rawValue
|
||||
if (code) {
|
||||
const screen = parseScreenQRCode(code)
|
||||
if (screen) {
|
||||
stopScanning()
|
||||
show.value = false
|
||||
emit('scanned', screen.screenID, screen.accessToken)
|
||||
return
|
||||
}
|
||||
scannerError.value = 'Это не QR-код экрана.'
|
||||
}
|
||||
scannerFrame = requestAnimationFrame(scan)
|
||||
} catch {
|
||||
scannerError.value = 'Не удалось распознать QR-код.'
|
||||
stopScanning()
|
||||
}
|
||||
}
|
||||
scannerFrame = requestAnimationFrame(scan)
|
||||
} catch {
|
||||
scannerError.value = 'Не удалось открыть камеру.'
|
||||
stopScanning()
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopScanning()
|
||||
show.value = false
|
||||
}
|
||||
|
||||
watch(show, (isShown) => {
|
||||
if (isShown) startScanning()
|
||||
else stopScanning()
|
||||
})
|
||||
|
||||
onBeforeUnmount(stopScanning)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="show">
|
||||
<n-card
|
||||
style="width: min(600px, 94vw)"
|
||||
title="Привязать экран"
|
||||
:bordered="false"
|
||||
size="huge"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-button @click="close"> x </n-button>
|
||||
</template>
|
||||
<n-space vertical>
|
||||
<video ref="scannerVideo" class="screen-scanner-video" muted playsinline />
|
||||
<n-alert v-if="scannerError" type="error">{{ scannerError }}</n-alert>
|
||||
<n-button v-if="!scanning && !binding" type="primary" @click="startScanning">
|
||||
Сканировать QR-код
|
||||
</n-button>
|
||||
<n-button v-if="scanning" @click="stopScanning">Остановить камеру</n-button>
|
||||
<n-button v-if="binding" type="primary" loading>Привязка…</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen-scanner-video {
|
||||
aspect-ratio: 1;
|
||||
background-color: #000;
|
||||
max-height: 60vh;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user