This commit is contained in:
2026-10-23 03:03:06 +07:00
parent 0ca8bc70d2
commit 24ddcdf66b
8 changed files with 300 additions and 5 deletions
+31
View File
@@ -921,6 +921,10 @@ service EveningDetectiveServer {
}; };
} }
rpc GetScreenTeams(GetScreenTeamsReq) returns (GetScreenTeamsRsp) {
option (google.api.http) = { post: "/api/screens/teams" body: "*" };
}
rpc GetScreenResults(GetScreenResultsReq) returns (GetScreenResultsRsp) { rpc GetScreenResults(GetScreenResultsReq) returns (GetScreenResultsRsp) {
option (google.api.http) = { post: "/api/screens/results" body: "*" }; option (google.api.http) = { post: "/api/screens/results" body: "*" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
@@ -1783,6 +1787,14 @@ enum ScreenMode {
SCREEN_MODE_RESULTS = 6; SCREEN_MODE_RESULTS = 6;
SCREEN_MODE_TIMER = 7; SCREEN_MODE_TIMER = 7;
SCREEN_MODE_TEXT = 8; SCREEN_MODE_TEXT = 8;
SCREEN_MODE_QR = 9;
SCREEN_MODE_TEAMS = 10;
}
enum ScreenTimerType {
SCREEN_TIMER_TYPE_BREAK = 0;
SCREEN_TIMER_TYPE_BEFORE_GAME = 1;
SCREEN_TIMER_TYPE_UNTIL_END = 2;
} }
enum ScreenTimerState { enum ScreenTimerState {
@@ -1830,6 +1842,8 @@ message ScreenState {
ScreenTimerState timer_state = 19; ScreenTimerState timer_state = 19;
string text = 20; string text = 20;
MusicState music = 21; MusicState music = 21;
string qr_url = 22;
ScreenTimerType timer_type = 23;
} }
message CreateScreenSessionReq {} message CreateScreenSessionReq {}
@@ -1966,6 +1980,8 @@ message UpdateGameMapItemRsp {}
message SetScreenModeReq { message SetScreenModeReq {
int32 gameID = 1; int32 gameID = 1;
ScreenMode mode = 2; ScreenMode mode = 2;
string qr_url = 3;
string text = 4;
} }
message SetScreenModeRsp { message SetScreenModeRsp {
@@ -1975,6 +1991,7 @@ message SetScreenTimerReq {
int32 gameID = 1; int32 gameID = 1;
int64 duration_seconds = 2; int64 duration_seconds = 2;
string label = 3; string label = 3;
ScreenTimerType timer_type = 4;
} }
message SetScreenTimerRsp { message SetScreenTimerRsp {
@@ -1995,6 +2012,8 @@ message ScreenPlanStep {
string text = 2; string text = 2;
string timer_label = 3; string timer_label = 3;
int64 timer_duration_seconds = 4; int64 timer_duration_seconds = 4;
string qr_url = 5;
ScreenTimerType timer_type = 6;
} }
message ScreenPlan { message ScreenPlan {
@@ -2029,6 +2048,18 @@ message SetScreenPlanStepRsp {
ScreenPlan plan = 1; ScreenPlan plan = 1;
} }
message ScreenTeam {
string ID = 1;
string name = 2;
}
message GetScreenTeamsReq {
string screenID = 1;
string access_token = 2;
}
message GetScreenTeamsRsp {
repeated ScreenTeam teams = 1;
}
message GetScreenResultsReq { message GetScreenResultsReq {
string screenID = 1; string screenID = 1;
string accessToken = 2; string accessToken = 2;
@@ -9,7 +9,13 @@ export type ScreenMode =
| "SCREEN_MODE_CONCLUSION" | "SCREEN_MODE_CONCLUSION"
| "SCREEN_MODE_RESULTS" | "SCREEN_MODE_RESULTS"
| "SCREEN_MODE_TIMER" | "SCREEN_MODE_TIMER"
| "SCREEN_MODE_TEXT"; | "SCREEN_MODE_TEXT"
| "SCREEN_MODE_QR"
| "SCREEN_MODE_TEAMS";
export type ScreenTimerType =
| "SCREEN_TIMER_TYPE_BREAK"
| "SCREEN_TIMER_TYPE_BEFORE_GAME"
| "SCREEN_TIMER_TYPE_UNTIL_END";
export type ScreenTimerState = export type ScreenTimerState =
| "SCREEN_TIMER_STATE_UNSPECIFIED" | "SCREEN_TIMER_STATE_UNSPECIFIED"
| "SCREEN_TIMER_STATE_STOPPED" | "SCREEN_TIMER_STATE_STOPPED"
@@ -631,6 +637,8 @@ export type ScreenState = {
timerState: ScreenTimerState | undefined; timerState: ScreenTimerState | undefined;
text: string | undefined; text: string | undefined;
music: MusicState | undefined; music: MusicState | undefined;
qrUrl: string | undefined;
timerType: ScreenTimerType | undefined;
}; };
export type MusicState = { export type MusicState = {
@@ -762,6 +770,8 @@ export type UpdateGameMapItemRsp = {
export type SetScreenModeReq = { export type SetScreenModeReq = {
gameID: number | undefined; gameID: number | undefined;
mode: ScreenMode | undefined; mode: ScreenMode | undefined;
qrUrl: string | undefined;
text: string | undefined;
}; };
export type SetScreenModeRsp = { export type SetScreenModeRsp = {
@@ -771,6 +781,7 @@ export type SetScreenTimerReq = {
gameID: number | undefined; gameID: number | undefined;
durationSeconds: number | undefined; durationSeconds: number | undefined;
label: string | undefined; label: string | undefined;
timerType: ScreenTimerType | undefined;
}; };
export type SetScreenTimerRsp = { export type SetScreenTimerRsp = {
@@ -791,6 +802,8 @@ export type ScreenPlanStep = {
text: string | undefined; text: string | undefined;
timerLabel: string | undefined; timerLabel: string | undefined;
timerDurationSeconds: number | undefined; timerDurationSeconds: number | undefined;
qrUrl: string | undefined;
timerType: ScreenTimerType | undefined;
}; };
export type ScreenPlan = { export type ScreenPlan = {
@@ -825,6 +838,20 @@ export type SetScreenPlanStepRsp = {
plan: ScreenPlan | undefined; plan: ScreenPlan | undefined;
}; };
export type ScreenTeam = {
ID: string | undefined;
name: string | undefined;
};
export type GetScreenTeamsReq = {
screenID: string | undefined;
accessToken: string | undefined;
};
export type GetScreenTeamsRsp = {
teams: ScreenTeam[] | undefined;
};
export type GetScreenResultsReq = { export type GetScreenResultsReq = {
screenID: string | undefined; screenID: string | undefined;
accessToken: string | undefined; accessToken: string | undefined;
@@ -1471,6 +1498,7 @@ export interface EveningDetectiveServer {
GetScreenPlan(request: GetScreenPlanReq): Promise<GetScreenPlanRsp>; GetScreenPlan(request: GetScreenPlanReq): Promise<GetScreenPlanRsp>;
SaveScreenPlan(request: SaveScreenPlanReq): Promise<SaveScreenPlanRsp>; SaveScreenPlan(request: SaveScreenPlanReq): Promise<SaveScreenPlanRsp>;
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>; SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
GetScreenTeams(request: GetScreenTeamsReq): Promise<GetScreenTeamsRsp>;
GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>; GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>;
DownloadGamePrintMaterials(request: GetGameReq): Promise<googleapi_HttpBody>; DownloadGamePrintMaterials(request: GetGameReq): Promise<googleapi_HttpBody>;
PreviewTeamReceipt(request: TeamReceiptReq): Promise<googleapi_HttpBody>; PreviewTeamReceipt(request: TeamReceiptReq): Promise<googleapi_HttpBody>;
@@ -3163,6 +3191,23 @@ export function createEveningDetectiveServerClient(
method: "SetScreenPlanStep", method: "SetScreenPlanStep",
}) as Promise<SetScreenPlanStepRsp>; }) as Promise<SetScreenPlanStepRsp>;
}, },
GetScreenTeams(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/screens/teams`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "GetScreenTeams",
}) as Promise<GetScreenTeamsRsp>;
},
GetScreenResults(request) { // eslint-disable-line @typescript-eslint/no-unused-vars GetScreenResults(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/screens/results`; // eslint-disable-line quotes const path = `api/screens/results`; // eslint-disable-line quotes
const body = JSON.stringify(request); const body = JSON.stringify(request);
+20 -1
View File
@@ -64,6 +64,7 @@ import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui' import { useUIStore } from '@/stores/ui'
import { applicationFileType, applicationFileURL } from '@/utils/application' import { applicationFileType, applicationFileURL } from '@/utils/application'
import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime' import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime'
import { validScreenQR } from '@/utils/screenPresentation'
import { hasNewTeamApplications } from '@/utils/teamApplications' import { hasNewTeamApplications } from '@/utils/teamApplications'
const emit = defineEmits<{ resultsApplied: [] }>() const emit = defineEmits<{ resultsApplied: [] }>()
@@ -262,6 +263,7 @@ const screenPlanMode = ref<'use' | 'edit'>('use')
let screenPlanRequestVersion = 0 let screenPlanRequestVersion = 0
const previewQuestions = ref<ResultsQuestion[]>([]) const previewQuestions = ref<ResultsQuestion[]>([])
const previewTeams = ref<TeamResult[]>([]) const previewTeams = ref<TeamResult[]>([])
const screenTeamNames = ref<string[]>()
const mapItems = ref<MapItem[]>([]) const mapItems = ref<MapItem[]>([])
const mapSaving = ref(false) const mapSaving = ref(false)
const mapDragging = ref(false) const mapDragging = ref(false)
@@ -402,6 +404,18 @@ async function getGameScreens() {
const res = await client.GetGameScreens({ gameID: Number(gameId) }) const res = await client.GetGameScreens({ gameID: Number(gameId) })
screens.value = res.screens || [] screens.value = res.screens || []
screensError.value = '' screensError.value = ''
if (
previewScreen.value?.mode === 'SCREEN_MODE_TEAMS' ||
(screenPlanMode.value === 'edit' ? screenPlanDraft.value : screenPlan.value?.steps)?.some(
(step) => step.mode === 'SCREEN_MODE_TEAMS',
)
) {
const gameResponse = await client.GetGame({ ID: Number(gameId) })
screenTeamNames.value = (gameResponse.game?.teams ?? [])
.filter((team) => team.participationStatus !== 'cancelled')
.map((team) => team.name ?? '')
.sort((a, b) => a.localeCompare(b, 'ru'))
}
if ( if (
(screenPlanMode.value === 'edit' ? screenPlanDraft.value : screenPlan.value?.steps)?.some( (screenPlanMode.value === 'edit' ? screenPlanDraft.value : screenPlan.value?.steps)?.some(
(step) => step.mode === 'SCREEN_MODE_RESULTS', (step) => step.mode === 'SCREEN_MODE_RESULTS',
@@ -467,6 +481,8 @@ function addScreenPlanStep() {
text: 'Новый экран', text: 'Новый экран',
timerLabel: 'Перерыв', timerLabel: 'Перерыв',
timerDurationSeconds: 600, timerDurationSeconds: 600,
qrUrl: '',
timerType: 'SCREEN_TIMER_TYPE_BREAK',
}) })
screenPlanDirty.value = true screenPlanDirty.value = true
screenPlanMode.value = 'edit' screenPlanMode.value = 'edit'
@@ -512,13 +528,14 @@ async function saveScreenPlan() {
const invalidStep = screenPlanDraft.value.findIndex( const invalidStep = screenPlanDraft.value.findIndex(
(step) => (step) =>
(step.mode === 'SCREEN_MODE_TEXT' && !step.text?.trim()) || (step.mode === 'SCREEN_MODE_TEXT' && !step.text?.trim()) ||
(step.mode === 'SCREEN_MODE_QR' && !validScreenQR(step.qrUrl, step.text)) ||
(step.mode === 'SCREEN_MODE_TIMER' && (step.mode === 'SCREEN_MODE_TIMER' &&
(!Number.isInteger(step.timerDurationSeconds) || (!Number.isInteger(step.timerDurationSeconds) ||
(step.timerDurationSeconds ?? 0) < 1 || (step.timerDurationSeconds ?? 0) < 1 ||
(step.timerDurationSeconds ?? 0) > 86400)), (step.timerDurationSeconds ?? 0) > 86400)),
) )
if (invalidStep !== -1) { if (invalidStep !== -1) {
message.error(`Проверьте текст или длительность таймера в шаге ${invalidStep + 1}`) message.error(`Проверьте ссылку QR, текст или длительность таймера в шаге ${invalidStep + 1}`)
return return
} }
screenPlanRequestVersion += 1 screenPlanRequestVersion += 1
@@ -1579,6 +1596,7 @@ useSimplePolling(() => {
:state="previewScreen" :state="previewScreen"
:questions="previewQuestions" :questions="previewQuestions"
:teams="previewTeams" :teams="previewTeams"
:team-names="screenTeamNames"
/> />
<div v-else class="screen-preview-placeholder">Экран не подключён</div> <div v-else class="screen-preview-placeholder">Экран не подключён</div>
</UiBlock> </UiBlock>
@@ -1596,6 +1614,7 @@ useSimplePolling(() => {
:screens="screens" :screens="screens"
:preview-questions="previewQuestions" :preview-questions="previewQuestions"
:preview-teams="previewTeams" :preview-teams="previewTeams"
:team-names="screenTeamNames"
:screen-plan="screenPlan" :screen-plan="screenPlan"
:screen-plan-draft="screenPlanDraft" :screen-plan-draft="screenPlanDraft"
:screen-plan-saving="screenPlanSaving" :screen-plan-saving="screenPlanSaving"
+31 -2
View File
@@ -11,15 +11,18 @@ import type {
ScreenPlan, ScreenPlan,
ScreenPlanStep, ScreenPlanStep,
ScreenState, ScreenState,
ScreenTimerType,
TeamResult, TeamResult,
} from '@/api/generated/crabs/evening_detective_server' } from '@/api/generated/crabs/evening_detective_server'
import ScreenPreview from '@/components/ScreenPreview.vue' import ScreenPreview from '@/components/ScreenPreview.vue'
import UiBlock from '@/components/UiBlock.vue' import UiBlock from '@/components/UiBlock.vue'
import { changeTimerLabel, timerTypes } from '@/utils/screenPresentation'
const props = defineProps<{ const props = defineProps<{
screens: ScreenState[] screens: ScreenState[]
previewQuestions: ResultsQuestion[] previewQuestions: ResultsQuestion[]
previewTeams: TeamResult[] previewTeams: TeamResult[]
teamNames?: string[]
screenPlan?: ScreenPlan screenPlan?: ScreenPlan
screenPlanDraft: ScreenPlanStep[] screenPlanDraft: ScreenPlanStep[]
screenPlanSaving: boolean screenPlanSaving: boolean
@@ -76,7 +79,14 @@ const modeOptions = [
{ label: 'Результаты', value: 'SCREEN_MODE_RESULTS' }, { label: 'Результаты', value: 'SCREEN_MODE_RESULTS' },
{ label: 'Таймер', value: 'SCREEN_MODE_TIMER' }, { label: 'Таймер', value: 'SCREEN_MODE_TIMER' },
{ label: 'Текст', value: 'SCREEN_MODE_TEXT' }, { label: 'Текст', value: 'SCREEN_MODE_TEXT' },
{ label: 'QR-код', value: 'SCREEN_MODE_QR' },
{ label: 'Список команд', value: 'SCREEN_MODE_TEAMS' },
] ]
function setTimerType(step: ScreenPlanStep, type: ScreenTimerType) {
step.timerLabel = changeTimerLabel(step.timerLabel, step.timerType, type)
step.timerType = type
emit('dirty')
}
function thumbnail(step: ScreenPlanStep): Partial<ScreenState> { function thumbnail(step: ScreenPlanStep): Partial<ScreenState> {
return { return {
...previewScreen.value, ...previewScreen.value,
@@ -146,6 +156,7 @@ function thumbnail(step: ScreenPlanStep): Partial<ScreenState> {
:state="thumbnail(step)" :state="thumbnail(step)"
:questions="previewQuestions" :questions="previewQuestions"
:teams="previewTeams" :teams="previewTeams"
:team-names="teamNames"
is-thumbnail is-thumbnail
/> />
</div> </div>
@@ -184,7 +195,7 @@ function thumbnail(step: ScreenPlanStep): Partial<ScreenState> {
@update:value="emit('dirty')" @update:value="emit('dirty')"
/> />
<n-input <n-input
v-if="step.mode === 'SCREEN_MODE_TEXT'" v-if="step.mode === 'SCREEN_MODE_TEXT' || step.mode === 'SCREEN_MODE_QR'"
v-model:value="step.text" v-model:value="step.text"
type="textarea" type="textarea"
placeholder="Текст экрана" placeholder="Текст экрана"
@@ -192,7 +203,25 @@ function thumbnail(step: ScreenPlanStep): Partial<ScreenState> {
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }" :input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
@update:value="emit('dirty')" @update:value="emit('dirty')"
/> />
<template v-if="step.mode === 'SCREEN_MODE_QR'">
<label :for="`qr-link-${index}`">Ссылка для QR-кода (обязательно)</label>
<n-input
v-model:value="step.qrUrl"
:disabled="isDisabled"
:input-props="{ id: `qr-link-${index}` }"
placeholder="https://example.com"
@update:value="emit('dirty')"
/>
</template>
<template v-if="step.mode === 'SCREEN_MODE_TIMER'"> <template v-if="step.mode === 'SCREEN_MODE_TIMER'">
<label :for="`timer-type-${index}`">Тип таймера</label>
<n-select
:value="step.timerType ?? 'SCREEN_TIMER_TYPE_BREAK'"
:options="timerTypes"
:disabled="isDisabled"
:input-props="{ id: `timer-type-${index}` }"
@update:value="setTimerType(step, $event)"
/>
<label :for="`timer-label-${index}`">Надпись таймера</label> <label :for="`timer-label-${index}`">Надпись таймера</label>
<n-input <n-input
v-model:value="step.timerLabel" v-model:value="step.timerLabel"
@@ -216,7 +245,7 @@ function thumbnail(step: ScreenPlanStep): Partial<ScreenState> {
<span <span
>{{ index + 1 }}. >{{ index + 1 }}.
{{ modeOptions.find((option) => option.value === step.mode)?.label {{ modeOptions.find((option) => option.value === step.mode)?.label
}}<template v-if="step.mode === 'SCREEN_MODE_TEXT'">: {{ step.text }}</template }}<template v-if="step.mode === 'SCREEN_MODE_TEXT' || step.mode === 'SCREEN_MODE_QR'">: {{ step.text }}</template
><template v-if="step.mode === 'SCREEN_MODE_TIMER'" ><template v-if="step.mode === 'SCREEN_MODE_TIMER'"
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template >: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
></span ></span
+33
View File
@@ -7,6 +7,7 @@ import { getAuthClient } from '@/api/auth_client'
import type { Game, ScreenState, TeamResult } from '@/api/generated/crabs/evening_detective_server' import type { Game, ScreenState, TeamResult } from '@/api/generated/crabs/evening_detective_server'
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client' import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
import HeaderMenu from '@/components/HeaderMenu.vue' import HeaderMenu from '@/components/HeaderMenu.vue'
import ScreenPreview from '@/components/ScreenPreview.vue'
import { useDeviceContext } from '@/composables/useDeviceContext' import { useDeviceContext } from '@/composables/useDeviceContext'
import { useSimplePolling } from '@/composables/useSimplePolling' import { useSimplePolling } from '@/composables/useSimplePolling'
import { applicationFileURL } from '@/utils/application' import { applicationFileURL } from '@/utils/application'
@@ -21,6 +22,8 @@ const route = useRoute()
const screenId = ref('') const screenId = ref('')
const accessToken = ref('') const accessToken = ref('')
const state = ref<ScreenState>() const state = ref<ScreenState>()
const screenTeamNames = ref<string[]>()
let lastTeamsLoaded = -Infinity
const resultTeams = ref<TeamResult[]>([]) const resultTeams = ref<TeamResult[]>([])
const resultQuestions = ref<{ code: string | undefined; text: string | undefined }[]>([]) const resultQuestions = ref<{ code: string | undefined; text: string | undefined }[]>([])
const resultsContainer = ref<HTMLElement>() const resultsContainer = ref<HTMLElement>()
@@ -130,9 +133,14 @@ async function loadState(recreateOnUnauthorized = true) {
if (!screenId.value || !accessToken.value) return if (!screenId.value || !accessToken.value) return
try { try {
const res = await screenClient.GetScreenState({ screenID: screenId.value, accessToken: accessToken.value }) const res = await screenClient.GetScreenState({ screenID: screenId.value, accessToken: accessToken.value })
if (state.value?.gameID !== res.state?.gameID) {
screenTeamNames.value = undefined
lastTeamsLoaded = -Infinity
}
state.value = res.state state.value = res.state
syncTimer(state.value) syncTimer(state.value)
if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_RESULTS') await loadResults() if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_RESULTS') await loadResults()
if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_TEAMS' && Date.now() - lastTeamsLoaded >= 5000) await loadScreenTeams()
} catch (err) { } catch (err) {
if (recreateOnUnauthorized && err instanceof Error && err.message === 'unauthorized') { if (recreateOnUnauthorized && err instanceof Error && err.message === 'unauthorized') {
sessionStorage.removeItem(sessionKey) sessionStorage.removeItem(sessionKey)
@@ -146,6 +154,20 @@ async function loadState(recreateOnUnauthorized = true) {
} }
} }
async function loadScreenTeams() {
const gameID = state.value?.gameID
const sessionID = screenId.value
lastTeamsLoaded = Date.now()
const res = await screenClient.GetScreenTeams({
screenID: sessionID,
accessToken: accessToken.value,
})
if (state.value?.gameID !== gameID || screenId.value !== sessionID) return
screenTeamNames.value = (res.teams ?? [])
.map((team) => team.name ?? '')
.sort((a, b) => a.localeCompare(b, 'ru'))
}
async function loadResults() { async function loadResults() {
if (!screenId.value || !accessToken.value) return if (!screenId.value || !accessToken.value) return
const res = await screenClient.GetScreenResults({ screenID: screenId.value, accessToken: accessToken.value }) const res = await screenClient.GetScreenResults({ screenID: screenId.value, accessToken: accessToken.value })
@@ -380,6 +402,14 @@ onUnmounted(() => {
<video ref="conclusionVideoElement" :src="conclusionVideo" :muted="!state.soundEnabled" playsinline /> <video ref="conclusionVideoElement" :src="conclusionVideo" :muted="!state.soundEnabled" playsinline />
</div> </div>
<div v-if="screenMode === 'SCREEN_MODE_CONCLUSION' && !state.conclusionVideo" class="screen-message">Видео закрывашки не задано</div> <div v-if="screenMode === 'SCREEN_MODE_CONCLUSION' && !state.conclusionVideo" class="screen-message">Видео закрывашки не задано</div>
<ScreenPreview
v-if="screenMode === 'SCREEN_MODE_QR' || screenMode === 'SCREEN_MODE_TEAMS'"
class="static-screen"
:state="state"
:questions="[]"
:teams="[]"
:team-names="screenTeamNames"
/>
<div v-if="screenMode === 'SCREEN_MODE_TEXT'" class="screen-message">{{ state.text }}</div> <div v-if="screenMode === 'SCREEN_MODE_TEXT'" class="screen-message">{{ state.text }}</div>
<div v-if="screenMode === 'SCREEN_MODE_TIMER'" class="timer-screen"> <div v-if="screenMode === 'SCREEN_MODE_TIMER'" class="timer-screen">
<h1 class="timer-label">{{ state.timerLabel || 'Перерыв' }}</h1> <h1 class="timer-label">{{ state.timerLabel || 'Перерыв' }}</h1>
@@ -597,4 +627,7 @@ onUnmounted(() => {
} }
.static-screen {
height: 100dvh;
}
</style> </style>
+67
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NQrCode } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { import type {
@@ -7,14 +8,18 @@ import type {
TeamResult, TeamResult,
} from '@/api/generated/crabs/evening_detective_server' } from '@/api/generated/crabs/evening_detective_server'
import { applicationFileURL } from '@/utils/application' import { applicationFileURL } from '@/utils/application'
import { validScreenQR } from '@/utils/screenPresentation'
const props = defineProps<{ const props = defineProps<{
state: Partial<ScreenState> state: Partial<ScreenState>
questions: ResultsQuestion[] questions: ResultsQuestion[]
teams: TeamResult[] teams: TeamResult[]
teamNames?: string[]
isThumbnail?: boolean isThumbnail?: boolean
}>() }>()
const teamColumns = computed(() => Math.max(1, Math.ceil(Math.sqrt((props.teamNames?.length ?? 0) / 4))))
const teamRows = computed(() => Math.max(1, Math.ceil((props.teamNames?.length ?? 0) / teamColumns.value)))
const timerNow = ref(Date.now()) const timerNow = ref(Date.now())
const timerRemainingAtSync = ref(0) const timerRemainingAtSync = ref(0)
const timerSyncedAt = ref(Date.now()) const timerSyncedAt = ref(Date.now())
@@ -101,6 +106,18 @@ onUnmounted(() => {
<div v-else-if="state.mode === 'SCREEN_MODE_TEXT'" class="preview-message"> <div v-else-if="state.mode === 'SCREEN_MODE_TEXT'" class="preview-message">
{{ state.text }} {{ state.text }}
</div> </div>
<div v-else-if="state.mode === 'SCREEN_MODE_QR'" class="preview-qr">
<NQrCode v-if="validScreenQR(state.qrUrl)" :value="state.qrUrl" :size="512" :padding="24" />
<span v-else>Укажите ссылку для QR-кода</span>
<p v-if="state.text">{{ state.text }}</p>
</div>
<div v-else-if="state.mode === 'SCREEN_MODE_TEAMS'" class="preview-team-list">
<p v-if="!teamNames" class="preview-message">Список команд ещё не загружен</p>
<div v-else-if="teamNames.length" class="team-names" :style="{ gridTemplateColumns: `repeat(${teamColumns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${teamRows}, minmax(0, 1fr))`, fontSize: `min(${22.5 / teamRows}cqw, ${5 / teamColumns}cqw)` }">
<p v-for="(name, index) in teamNames" :key="index">{{ name }}</p>
</div>
<p v-else class="preview-message">Команды пока не зарегистрированы</p>
</div>
<div v-else-if="state.mode === 'SCREEN_MODE_RESULTS'" class="preview-results"> <div v-else-if="state.mode === 'SCREEN_MODE_RESULTS'" class="preview-results">
<h2>Результаты</h2> <h2>Результаты</h2>
<table> <table>
@@ -222,4 +239,54 @@ onUnmounted(() => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
white-space: pre-wrap; white-space: pre-wrap;
} }
.preview-qr {
align-items: center;
display: flex;
gap: 4cqw;
justify-content: center;
max-width: 92%;
}
/* stylelint-disable-next-line selector-pseudo-class-no-unknown -- Vue scoped: размер QR в дочернем компоненте. */
.preview-qr :deep(.n-qr-code) {
box-sizing: border-box;
flex-shrink: 0;
height: auto !important;
padding: 1.8cqw !important;
width: 38cqw !important;
}
/* stylelint-disable-next-line selector-pseudo-class-no-unknown -- Vue scoped: размер QR в дочернем компоненте. */
.preview-qr :deep(canvas) {
height: auto !important;
width: 100% !important;
}
.preview-qr p {
font-size: 3.5cqw;
max-height: 48cqw;
max-width: 40cqw;
overflow: hidden;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.preview-team-list {
height: 90%;
width: 92%;
}
.team-names {
align-items: center;
display: grid;
gap: 1.5cqw;
grid-auto-flow: column;
height: 100%;
}
.team-names p {
margin: 0;
overflow-wrap: anywhere;
}
</style> </style>
+38
View File
@@ -0,0 +1,38 @@
import type { ScreenTimerType } from '@/api/generated/crabs/evening_detective_server'
export const timerTypes = [
{ value: 'SCREEN_TIMER_TYPE_BREAK', label: 'Перерыв' },
{ value: 'SCREEN_TIMER_TYPE_BEFORE_GAME', label: 'До начала игры' },
{ value: 'SCREEN_TIMER_TYPE_UNTIL_END', label: 'До конца игры' },
] satisfies { value: ScreenTimerType; label: string }[]
export function changeTimerLabel(
label: string | undefined,
previous: ScreenTimerType | undefined,
next: ScreenTimerType,
): string {
const oldLabel = timerTypes.find(
(type) => type.value === (previous ?? 'SCREEN_TIMER_TYPE_BREAK'),
)!.label
return !label?.trim() || label === oldLabel
? timerTypes.find((type) => type.value === next)!.label
: label
}
export function validScreenQR(link = '', caption = ''): boolean {
try {
const url = new URL(link)
return (
/^https?:\/\/[^/\\\s?#]/.test(link) &&
['http:', 'https:'].includes(url.protocol) &&
!!url.hostname &&
!url.username &&
!url.password &&
link === link.trim() &&
new TextEncoder().encode(link).length <= 2048 &&
new TextEncoder().encode(caption).length <= 10000
)
} catch {
return false
}
}
+34 -1
View File
@@ -7,13 +7,15 @@ import ts from 'typescript'
import * as vue from 'vue' import * as vue from 'vue'
import { compileScript, parse } from 'vue/compiler-sfc' import { compileScript, parse } from 'vue/compiler-sfc'
const presentationModule = { exports: {} }
runInNewContext(ts.transpile(readFileSync(new URL('../src/utils/screenPresentation.ts', import.meta.url), 'utf8'), { module: ts.ModuleKind.CommonJS }), { exports: presentationModule.exports, URL, TextEncoder })
function source(name) { function source(name) {
return parse(readFileSync(new URL(`../src/components/${name}.vue`, import.meta.url), 'utf8')) return parse(readFileSync(new URL(`../src/components/${name}.vue`, import.meta.url), 'utf8'))
.descriptor .descriptor
} }
function setup(name, props) { function setup(name, props) {
const script = compileScript(source(name), { id: name }) const script = compileScript(source(name), { id: name })
const context = { exports: {}, require: (name) => (name === 'vue' ? vue : {}) } const context = { exports: {}, require: (name) => (name === 'vue' ? vue : name === '@/utils/screenPresentation' ? presentationModule.exports : {}) }
runInNewContext(ts.transpile(script.content, { module: ts.ModuleKind.CommonJS }), context) runInNewContext(ts.transpile(script.content, { module: ts.ModuleKind.CommonJS }), context)
const events = [] const events = []
const scope = vue.effectScope() const scope = vue.effectScope()
@@ -110,6 +112,14 @@ assert.equal(
screens.state.thumbnail({ mode: 'SCREEN_MODE_TEXT', text: 'Offline draft' }).text, screens.state.thumbnail({ mode: 'SCREEN_MODE_TEXT', text: 'Offline draft' }).text,
'Offline draft', 'Offline draft',
) )
const timerStep = { timerLabel: 'Перерыв', timerType: 'SCREEN_TIMER_TYPE_BREAK' }
screens.state.setTimerType(timerStep, 'SCREEN_TIMER_TYPE_BEFORE_GAME')
assert.equal(timerStep.timerLabel, 'До начала игры')
timerStep.timerLabel = 'Обсуждение'
screens.state.setTimerType(timerStep, 'SCREEN_TIMER_TYPE_UNTIL_END')
assert.equal(timerStep.timerLabel, 'Обсуждение')
assert.equal(presentationModule.exports.validScreenQR('https://example.org'), true)
assert.equal(presentationModule.exports.validScreenQR('javascript:alert(1)'), false)
screens.stop() screens.stop()
function functions(name, names) { function functions(name, names) {
@@ -320,3 +330,26 @@ assert.equal(media.currentTime, 23, 'Polling must not seek the player')
console.log( console.log(
'Screen controls: drafts, conflicts, explicit playlist IDs, timer/music controls, errors, stale polls and playback positions passed', 'Screen controls: drafts, conflicts, explicit playlist IDs, timer/music controls, errors, stale polls and playback positions passed',
) )
const browserTeams = {
state: vue.ref({ gameID: 7 }),
screenId: vue.ref('12'),
accessToken: vue.ref('test-token'),
screenTeamNames: vue.ref(),
lastTeamsLoaded: -Infinity,
screenClient: { GetScreenTeams: async () => ({ teams: [{ name: 'Ястребы' }, { name: 'Агенты' }] }) },
}
runInNewContext(ts.transpile(functions('ScreenPage', ['loadScreenTeams']), { target: ts.ScriptTarget.ES2022 }), browserTeams)
await browserTeams.loadScreenTeams()
assert.deepEqual(Array.from(browserTeams.screenTeamNames.value), ['Агенты', 'Ястребы'])
browserTeams.screenClient.GetScreenTeams = async () => { throw new Error('offline') }
await assert.rejects(browserTeams.loadScreenTeams(), /offline/)
assert.equal(browserTeams.screenTeamNames.value.length, 2)
let resolveTeams
browserTeams.screenClient.GetScreenTeams = () => new Promise(resolve => { resolveTeams = resolve })
const pendingTeams = browserTeams.loadScreenTeams()
browserTeams.state.value.gameID = 8
browserTeams.screenTeamNames.value = undefined
resolveTeams({ teams: [{ name: 'Из прошлой игры' }] })
await pendingTeams
assert.equal(browserTeams.screenTeamNames.value, undefined)