This commit is contained in:
2026-09-16 02:57:09 +07:00
parent 521306f376
commit 1069f76353
42 changed files with 321 additions and 115 deletions
+275 -69
View File
@@ -2,7 +2,17 @@
import { Flag, Pause, Play, Reset, Settings, Trophy } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { NButton, NTag } from 'naive-ui'
import { NCard, NFlex, NInput, NInputNumber, NModal, NQrCode, NSelect, NSpace, NText } from 'naive-ui'
import {
NCard,
NFlex,
NInput,
NInputNumber,
NModal,
NQrCode,
NSelect,
NSpace,
NText,
} from 'naive-ui'
import { useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -67,7 +77,9 @@ const participationOptions = [
const participationSummary = computed(() =>
participationOptions.slice(1).map((option) => {
const teams = (game.value.teams || []).filter(
(team) => team.participationStatus === option.value,
(team) =>
team.participationStatus === option.value &&
(option.value !== 'pending' || Boolean(team.captainEmail)),
)
return {
...option,
@@ -81,7 +93,8 @@ const activeTeamCount = computed(
)
const visibleTeams = computed(() =>
(game.value.teams || []).filter(
(team) => participationFilter.value === 'all' || team.participationStatus === participationFilter.value,
(team) =>
participationFilter.value === 'all' || team.participationStatus === participationFilter.value,
),
)
const screens = ref<ScreenState[]>([])
@@ -96,7 +109,8 @@ const currentScreenMode = computed<ScreenMode | undefined>(() => {
})
const mapScale = ref(1)
const mapOffset = ref({ x: 0, y: 0 })
let dragState: { item: MapItem; startX: number; startY: number; itemX: number; itemY: number } | undefined
let dragState:
{ item: MapItem; startX: number; startY: number; itemX: number; itemY: number } | undefined
let panState: { startX: number; startY: number; offsetX: number; offsetY: number } | undefined
async function getGame(id: number) {
@@ -154,7 +168,14 @@ getGameMap()
async function saveMapItem(item: MapItem, hidden = item.hidden) {
mapSaving.value = true
try {
const res = await client.UpdateGameMapItem({ gameId: Number(gameId), kind: item.kind, itemId: item.itemId, x: Math.round(item.x || 0), y: Math.round(item.y || 0), hidden })
const res = await client.UpdateGameMapItem({
gameId: Number(gameId),
kind: item.kind,
itemId: item.itemId,
x: Math.round(item.x || 0),
y: Math.round(item.y || 0),
hidden,
})
if (res.error != '') message.error(res.error || 'Не удалось сохранить координаты')
} finally {
mapSaving.value = false
@@ -204,7 +225,12 @@ function resetMapView() {
function startMapPan(event: PointerEvent) {
if (event.button !== 0) return
panState = { startX: event.clientX, startY: event.clientY, offsetX: mapOffset.value.x, offsetY: mapOffset.value.y }
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 })
}
@@ -623,7 +649,13 @@ useSimplePolling(() => {
</Icon>
Итоги
</n-button>
<n-button v-if="activeTeamCount" quaternary :loading="isDownloadingQR" :disabled="isDownloadingQR" @click="downloadTeamQR()">
<n-button
v-if="activeTeamCount"
quaternary
:loading="isDownloadingQR"
:disabled="isDownloadingQR"
@click="downloadTeamQR()"
>
QR команд
</n-button>
<n-button quaternary @click="openSettings()">
@@ -632,7 +664,12 @@ useSimplePolling(() => {
</Icon>
Настройки
</n-button>
<n-button v-if="(game.teams?.length || 0) == 0" type="error" quaternary @click="showDeleteGameModal = true">
<n-button
v-if="(game.teams?.length || 0) == 0"
type="error"
quaternary
@click="showDeleteGameModal = true"
>
Удалить игру
</n-button>
@@ -678,31 +715,72 @@ useSimplePolling(() => {
<n-flex justify="end">
<n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag>
<n-button v-else size="small" @click="setSoundScreen(screen)">Назначить звук</n-button>
<n-button size="small" type="error" ghost @click="unbindScreen(screen)">Отвязать</n-button>
<n-button size="small" type="error" ghost @click="unbindScreen(screen)"
>Отвязать</n-button
>
</n-flex>
</div>
<n-space class="screen-mode-controls">
<n-button size="small" :type="currentScreenMode === 'SCREEN_MODE_COVER' ? 'primary' : 'default'" @click="setScreenMode('SCREEN_MODE_COVER')">Обложка</n-button>
<n-button size="small" :type="currentScreenMode === 'SCREEN_MODE_INTRO' ? 'primary' : 'default'" @click="setScreenMode('SCREEN_MODE_INTRO')">Введение</n-button>
<n-button size="small" :type="currentScreenMode === 'SCREEN_MODE_INTRO_DONE' ? 'primary' : 'default'" @click="setScreenMode('SCREEN_MODE_INTRO_DONE')">Успехов</n-button>
<n-button size="small" :type="currentScreenMode === 'SCREEN_MODE_CONCLUSION' ? 'primary' : 'default'" @click="setScreenMode('SCREEN_MODE_CONCLUSION')">Закрывашка</n-button>
<n-button size="small" :type="currentScreenMode === 'SCREEN_MODE_CONCLUSION_DONE' ? 'primary' : 'default'" @click="setScreenMode('SCREEN_MODE_CONCLUSION_DONE')">Спасибо за игру!</n-button>
<n-button v-if="game.status === 'finish'" size="small" :type="currentScreenMode === 'SCREEN_MODE_RESULTS' ? 'primary' : 'default'" @click="setScreenMode('SCREEN_MODE_RESULTS')">Результаты</n-button>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_COVER' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_COVER')"
>Обложка</n-button
>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_INTRO' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_INTRO')"
>Введение</n-button
>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_INTRO_DONE' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_INTRO_DONE')"
>Успехов</n-button
>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_CONCLUSION' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_CONCLUSION')"
>Закрывашка</n-button
>
<n-button
size="small"
:type="currentScreenMode === 'SCREEN_MODE_CONCLUSION_DONE' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_CONCLUSION_DONE')"
>Спасибо за игру!</n-button
>
<n-button
v-if="game.status === 'finish'"
size="small"
:type="currentScreenMode === 'SCREEN_MODE_RESULTS' ? 'primary' : 'default'"
@click="setScreenMode('SCREEN_MODE_RESULTS')"
>Результаты</n-button
>
</n-space>
<n-space class="screen-mode-controls">
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_PLAY')">Запуск</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_PAUSE')">Пауза</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_RESUME')">Продолжить</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_RESUME')"
>Продолжить</n-button
>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_STOP')">Стоп</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_REPLAY')">Повтор</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_REPLAY')"
>Повтор</n-button
>
</n-space>
<n-space class="screen-mode-controls" align="center">
<n-text>Громкость</n-text>
<n-input-number v-model:value="screenVolume" :min="0" :max="100" :step="5" size="small" />
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_VOLUME')">Применить</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_VOLUME')"
>Применить</n-button
>
<n-text>Позиция, сек.</n-text>
<n-input-number v-model:value="screenPosition" :min="0" :step="5" size="small" />
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_SEEK')">Перемотать</n-button>
<n-button size="small" @click="sendScreenCommand('SCREEN_COMMAND_SEEK')"
>Перемотать</n-button
>
</n-space>
</div>
@@ -717,20 +795,36 @@ useSimplePolling(() => {
</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' : 'map-team'"
<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' : 'map-team'"
:style="{ left: `${item.x || 0}px`, top: `${item.y || 0}px` }"
@pointerdown.stop="startMapDrag(item, $event)">
@pointerdown.stop="startMapDrag(item, $event)"
>
<span>{{ item.label }}</span>
<small v-if="item.kind === 'screen'">{{ item.status }}{{ item.soundEnabled ? ' · звук' : '' }}</small>
<small v-if="item.kind === 'screen'"
>{{ item.status }}{{ item.soundEnabled ? ' · звук' : '' }}</small
>
<button 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)">
<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>
@@ -744,7 +838,11 @@ useSimplePolling(() => {
<n-tag v-for="item in participationSummary" :key="item.value">
{{ item.label }}: {{ item.count }} команд · {{ item.participants }} участников
</n-tag>
<n-select v-model:value="participationFilter" :options="participationOptions" aria-label="Статус участия" />
<n-select
v-model:value="participationFilter"
:options="participationOptions"
aria-label="Статус участия"
/>
</n-space>
<div v-for="team in visibleTeams" :key="team.id" class="team-block">
@@ -754,28 +852,49 @@ useSimplePolling(() => {
<span class="team-name-block">
{{ team.name }}
<small v-if="team.captainEmail"> — {{ team.captainEmail }}</small>
<small v-if="team.participantsCount"> · Участников: {{ team.participantsCount }}</small>
<small v-if="team.participantsCount">
· Участников: {{ team.participantsCount }}</small
>
</span>
<div>{{ participationOptions.find((option) => option.value === team.participationStatus)?.label }}<span v-if="team.participationSource === 'automatic'"> · автоматически</span></div>
<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.participationEmailError">
Ошибка отправки: {{ team.participationEmailError }}
</div>
<div v-else-if="team.participationEmailSent">Письмо отправлено</div>
<div v-if="team.participationStatus !== 'cancelled'">Поездки: {{ team.actionsCount }}</div>
<div v-if="team.participationStatus !== 'cancelled'">
Поездки: {{ team.actionsCount }}
</div>
</div>
<n-flex justify="end">
<n-button v-if="team.participationStatus !== 'cancelled'" @click="openQR(getTeamStoryURL(team.id!, team.password!), 'Подсмотреть игру')">
<n-button
v-if="team.participationStatus !== 'cancelled'"
@click="openQR(getTeamStoryURL(team.id!, team.password!), 'Подсмотреть игру')"
>
QR
</n-button>
<n-button v-if="team.participationStatus !== 'cancelled'" @click="toTeamStory(team.id!, team.password!, true)">
<n-button
v-if="team.participationStatus !== 'cancelled'"
@click="toTeamStory(team.id!, team.password!, true)"
>
Подсмотреть игру
</n-button>
<n-button type="error" ghost @click="deleteTeam(team)"> Удалить </n-button>
</n-flex>
</n-flex>
<n-button v-for="application in team.applications" :key="application.name" class="link-button"
@click="giveApplication(team, application)">
<n-button
v-for="application in team.applications"
:key="application.name"
class="link-button"
@click="giveApplication(team, application)"
>
Выдать: {{ application.name }}
</n-button>
</div>
@@ -785,23 +904,41 @@ useSimplePolling(() => {
<!-- Окно настроек -->
<n-modal v-model:show="showSettingsModal">
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog" aria-modal="true">
<n-card
style="width: 600px"
title="Настройки игры"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="closeSettings()"> x </n-button>
</template>
<n-space vertical>
<p>Название</p>
<n-input v-model:value="settingsDraft.name" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
<n-input
v-model:value="settingsDraft.name"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<p class="settings-header">Формат игры</p>
<n-select
v-model:value="settingsDraft.format"
:options="[{ label: 'Online', value: 'online' }, { label: 'Offline', value: 'offline' }]"
:options="[
{ label: 'Online', value: 'online' },
{ label: 'Offline', value: 'offline' },
]"
aria-label="Формат игры"
/>
<p class="settings-header">Описание</p>
<n-input v-model:value="settingsDraft.description" type="textarea" placeholder="Убийство пастуха..." />
<n-input
v-model:value="settingsDraft.description"
type="textarea"
placeholder="Убийство пастуха..."
/>
</n-space>
<template #footer>
<n-flex justify="end">
@@ -813,7 +950,14 @@ useSimplePolling(() => {
<!-- Окно выдачи улик -->
<n-modal v-model:show="showGiveApplicationModal">
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog" aria-modal="true">
<n-card
style="width: 600px"
title="Настройки игры"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="showGiveApplicationModal = false"> x </n-button>
</template>
@@ -824,22 +968,38 @@ useSimplePolling(() => {
<p>Улика</p>
<h3>{{ giveApplicationApplication.name }}</h3>
<div v-if="
applicationFileType(giveApplicationApplication) == 'image' &&
giveApplicationApplication.image
" class="give-application-image-block">
<img :src="applicationFileURL(giveApplicationApplication.image)" class="give-application-image" />
<div
v-if="
applicationFileType(giveApplicationApplication) == 'image' &&
giveApplicationApplication.image
"
class="give-application-image-block"
>
<img
:src="applicationFileURL(giveApplicationApplication.image)"
class="give-application-image"
/>
</div>
<audio v-else-if="
applicationFileType(giveApplicationApplication) == 'audio' &&
giveApplicationApplication.image
" :src="applicationFileURL(giveApplicationApplication.image)" controls preload="none"
class="give-application-audio"></audio>
<a v-else-if="
applicationFileType(giveApplicationApplication) == 'pdf' &&
giveApplicationApplication.image
" :href="applicationFileURL(giveApplicationApplication.image)" target="_blank" rel="noopener"
class="give-application-pdf-link">
<audio
v-else-if="
applicationFileType(giveApplicationApplication) == 'audio' &&
giveApplicationApplication.image
"
:src="applicationFileURL(giveApplicationApplication.image)"
controls
preload="none"
class="give-application-audio"
></audio>
<a
v-else-if="
applicationFileType(giveApplicationApplication) == 'pdf' &&
giveApplicationApplication.image
"
:href="applicationFileURL(giveApplicationApplication.image)"
target="_blank"
rel="noopener"
class="give-application-pdf-link"
>
PDF
</a>
</n-space>
@@ -853,7 +1013,14 @@ useSimplePolling(() => {
<!-- Окно создания команды -->
<n-modal v-model:show="showAddTeamModal">
<n-card style="width: 600px" title="Создание команды" :bordered="false" size="huge" role="dialog" aria-modal="true">
<n-card
style="width: 600px"
title="Создание команды"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="showAddTeamModal = false"> x </n-button>
</template>
@@ -868,7 +1035,14 @@ useSimplePolling(() => {
<!-- Окно создания команды -->
<n-modal v-model:show="showDeleteTeamModal">
<n-card style="width: 600px" title="Удаление команды" :bordered="false" size="huge" role="dialog" aria-modal="true">
<n-card
style="width: 600px"
title="Удаление команды"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="showDeleteTeamModal = false"> x </n-button>
</template>
@@ -892,7 +1066,14 @@ useSimplePolling(() => {
<!-- Окно удаления игры -->
<n-modal v-model:show="showDeleteGameModal">
<n-card style="width: 600px" title="Удаление игры" :bordered="false" size="huge" role="dialog" aria-modal="true">
<n-card
style="width: 600px"
title="Удаление игры"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="showDeleteGameModal = false"> x </n-button>
</template>
@@ -901,7 +1082,11 @@ useSimplePolling(() => {
Введите название игры
<n-text class="name-text" strong>{{ game.name }}</n-text> чтобы удалить её
</p>
<n-input v-model:value="deletedGameName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
<n-input
v-model:value="deletedGameName"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
</n-space>
<template #footer>
@@ -916,7 +1101,14 @@ useSimplePolling(() => {
<!-- Окно qr -->
<n-modal v-model:show="showQR">
<n-card style="width: 600px" :title="titleQR" :bordered="false" size="huge" role="dialog" aria-modal="true">
<n-card
style="width: 600px"
:title="titleQR"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button @click="showQR = false"> x </n-button>
</template>
@@ -932,8 +1124,15 @@ useSimplePolling(() => {
<!-- Окно ответов команд и оценки -->
<n-modal v-model:show="showAnswersModal">
<n-card style="width: min(94vw, 900px)" title="Ответы команд" :bordered="false" size="huge" role="dialog"
aria-modal="true" :content-style="{ maxHeight: '70vh', overflow: 'auto' }">
<n-card
style="width: min(94vw, 900px)"
title="Ответы команд"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
:content-style="{ maxHeight: '70vh', overflow: 'auto' }"
>
<template #header-extra>
<n-button @click="showAnswersModal = false"> x </n-button>
</template>
@@ -952,9 +1151,12 @@ useSimplePolling(() => {
<div class="answers-team-answer">
{{ teamAnswer(team, question.code) || '—' }}
</div>
<n-input-number :value="scores[scoreKey(team.team?.id, question.code)]"
@update:value="onScoreUpdate(scoreKey(team.team?.id, question.code), $event)" placeholder="Балл"
class="answers-score-input" />
<n-input-number
:value="scores[scoreKey(team.team?.id, question.code)]"
@update:value="onScoreUpdate(scoreKey(team.team?.id, question.code), $event)"
placeholder="Балл"
class="answers-score-input"
/>
</div>
</div>
<template #footer>
@@ -1161,7 +1363,9 @@ useSimplePolling(() => {
}
.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-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;
@@ -1192,7 +1396,7 @@ useSimplePolling(() => {
.map-item small {
display: block;
opacity: .75;
opacity: 0.75;
}
.map-item button {
@@ -1205,5 +1409,7 @@ useSimplePolling(() => {
right: 5px;
top: 5px;
}
.map-hidden-items { margin-top: 10px; }
.map-hidden-items {
margin-top: 10px;
}
</style>