This commit is contained in:
2026-10-04 16:07:41 +07:00
parent 70d1f4956d
commit 31d93a9916
5 changed files with 1181 additions and 529 deletions
+357
View File
@@ -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>