diff --git a/proto/main.proto b/proto/main.proto index cab872f..b437909 100644 --- a/proto/main.proto +++ b/proto/main.proto @@ -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) { option (google.api.http) = { post: "/api/screens/results" body: "*" }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { @@ -1783,6 +1787,14 @@ enum ScreenMode { SCREEN_MODE_RESULTS = 6; SCREEN_MODE_TIMER = 7; 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 { @@ -1830,6 +1842,8 @@ message ScreenState { ScreenTimerState timer_state = 19; string text = 20; MusicState music = 21; + string qr_url = 22; + ScreenTimerType timer_type = 23; } message CreateScreenSessionReq {} @@ -1966,6 +1980,8 @@ message UpdateGameMapItemRsp {} message SetScreenModeReq { int32 gameID = 1; ScreenMode mode = 2; + string qr_url = 3; + string text = 4; } message SetScreenModeRsp { @@ -1975,6 +1991,7 @@ message SetScreenTimerReq { int32 gameID = 1; int64 duration_seconds = 2; string label = 3; + ScreenTimerType timer_type = 4; } message SetScreenTimerRsp { @@ -1995,6 +2012,8 @@ message ScreenPlanStep { string text = 2; string timer_label = 3; int64 timer_duration_seconds = 4; + string qr_url = 5; + ScreenTimerType timer_type = 6; } message ScreenPlan { @@ -2029,6 +2048,18 @@ message SetScreenPlanStepRsp { 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 { string screenID = 1; string accessToken = 2; diff --git a/src/api/generated/crabs/evening_detective_server/index.ts b/src/api/generated/crabs/evening_detective_server/index.ts index 0c34190..6b0220e 100644 --- a/src/api/generated/crabs/evening_detective_server/index.ts +++ b/src/api/generated/crabs/evening_detective_server/index.ts @@ -9,7 +9,13 @@ export type ScreenMode = | "SCREEN_MODE_CONCLUSION" | "SCREEN_MODE_RESULTS" | "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 = | "SCREEN_TIMER_STATE_UNSPECIFIED" | "SCREEN_TIMER_STATE_STOPPED" @@ -631,6 +637,8 @@ export type ScreenState = { timerState: ScreenTimerState | undefined; text: string | undefined; music: MusicState | undefined; + qrUrl: string | undefined; + timerType: ScreenTimerType | undefined; }; export type MusicState = { @@ -762,6 +770,8 @@ export type UpdateGameMapItemRsp = { export type SetScreenModeReq = { gameID: number | undefined; mode: ScreenMode | undefined; + qrUrl: string | undefined; + text: string | undefined; }; export type SetScreenModeRsp = { @@ -771,6 +781,7 @@ export type SetScreenTimerReq = { gameID: number | undefined; durationSeconds: number | undefined; label: string | undefined; + timerType: ScreenTimerType | undefined; }; export type SetScreenTimerRsp = { @@ -791,6 +802,8 @@ export type ScreenPlanStep = { text: string | undefined; timerLabel: string | undefined; timerDurationSeconds: number | undefined; + qrUrl: string | undefined; + timerType: ScreenTimerType | undefined; }; export type ScreenPlan = { @@ -825,6 +838,20 @@ export type SetScreenPlanStepRsp = { 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 = { screenID: string | undefined; accessToken: string | undefined; @@ -1471,6 +1498,7 @@ export interface EveningDetectiveServer { GetScreenPlan(request: GetScreenPlanReq): Promise; SaveScreenPlan(request: SaveScreenPlanReq): Promise; SetScreenPlanStep(request: SetScreenPlanStepReq): Promise; + GetScreenTeams(request: GetScreenTeamsReq): Promise; GetScreenResults(request: GetScreenResultsReq): Promise; DownloadGamePrintMaterials(request: GetGameReq): Promise; PreviewTeamReceipt(request: TeamReceiptReq): Promise; @@ -3163,6 +3191,23 @@ export function createEveningDetectiveServerClient( method: "SetScreenPlanStep", }) as Promise; }, + 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; + }, GetScreenResults(request) { // eslint-disable-line @typescript-eslint/no-unused-vars const path = `api/screens/results`; // eslint-disable-line quotes const body = JSON.stringify(request); diff --git a/src/components/GamePage.vue b/src/components/GamePage.vue index 2fbc987..7139823 100644 --- a/src/components/GamePage.vue +++ b/src/components/GamePage.vue @@ -64,6 +64,7 @@ import { useAuthStore } from '@/stores/auth' import { useUIStore } from '@/stores/ui' import { applicationFileType, applicationFileURL } from '@/utils/application' import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime' +import { validScreenQR } from '@/utils/screenPresentation' import { hasNewTeamApplications } from '@/utils/teamApplications' const emit = defineEmits<{ resultsApplied: [] }>() @@ -262,6 +263,7 @@ const screenPlanMode = ref<'use' | 'edit'>('use') let screenPlanRequestVersion = 0 const previewQuestions = ref([]) const previewTeams = ref([]) +const screenTeamNames = ref() const mapItems = ref([]) const mapSaving = ref(false) const mapDragging = ref(false) @@ -402,6 +404,18 @@ async function getGameScreens() { const res = await client.GetGameScreens({ gameID: Number(gameId) }) screens.value = res.screens || [] 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 ( (screenPlanMode.value === 'edit' ? screenPlanDraft.value : screenPlan.value?.steps)?.some( (step) => step.mode === 'SCREEN_MODE_RESULTS', @@ -467,6 +481,8 @@ function addScreenPlanStep() { text: 'Новый экран', timerLabel: 'Перерыв', timerDurationSeconds: 600, + qrUrl: '', + timerType: 'SCREEN_TIMER_TYPE_BREAK', }) screenPlanDirty.value = true screenPlanMode.value = 'edit' @@ -512,13 +528,14 @@ async function saveScreenPlan() { const invalidStep = screenPlanDraft.value.findIndex( (step) => (step.mode === 'SCREEN_MODE_TEXT' && !step.text?.trim()) || + (step.mode === 'SCREEN_MODE_QR' && !validScreenQR(step.qrUrl, step.text)) || (step.mode === 'SCREEN_MODE_TIMER' && (!Number.isInteger(step.timerDurationSeconds) || (step.timerDurationSeconds ?? 0) < 1 || (step.timerDurationSeconds ?? 0) > 86400)), ) if (invalidStep !== -1) { - message.error(`Проверьте текст или длительность таймера в шаге ${invalidStep + 1}`) + message.error(`Проверьте ссылку QR, текст или длительность таймера в шаге ${invalidStep + 1}`) return } screenPlanRequestVersion += 1 @@ -1579,6 +1596,7 @@ useSimplePolling(() => { :state="previewScreen" :questions="previewQuestions" :teams="previewTeams" + :team-names="screenTeamNames" />
Экран не подключён
@@ -1596,6 +1614,7 @@ useSimplePolling(() => { :screens="screens" :preview-questions="previewQuestions" :preview-teams="previewTeams" + :team-names="screenTeamNames" :screen-plan="screenPlan" :screen-plan-draft="screenPlanDraft" :screen-plan-saving="screenPlanSaving" diff --git a/src/components/GameScreenPlanPanel.vue b/src/components/GameScreenPlanPanel.vue index bd8eda8..47a3b4b 100644 --- a/src/components/GameScreenPlanPanel.vue +++ b/src/components/GameScreenPlanPanel.vue @@ -11,15 +11,18 @@ import type { ScreenPlan, ScreenPlanStep, ScreenState, + ScreenTimerType, TeamResult, } from '@/api/generated/crabs/evening_detective_server' import ScreenPreview from '@/components/ScreenPreview.vue' import UiBlock from '@/components/UiBlock.vue' +import { changeTimerLabel, timerTypes } from '@/utils/screenPresentation' const props = defineProps<{ screens: ScreenState[] previewQuestions: ResultsQuestion[] previewTeams: TeamResult[] + teamNames?: string[] screenPlan?: ScreenPlan screenPlanDraft: ScreenPlanStep[] screenPlanSaving: boolean @@ -76,7 +79,14 @@ const modeOptions = [ { label: 'Результаты', value: 'SCREEN_MODE_RESULTS' }, { label: 'Таймер', value: 'SCREEN_MODE_TIMER' }, { 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 { return { ...previewScreen.value, @@ -146,6 +156,7 @@ function thumbnail(step: ScreenPlanStep): Partial { :state="thumbnail(step)" :questions="previewQuestions" :teams="previewTeams" + :team-names="teamNames" is-thumbnail /> @@ -184,7 +195,7 @@ function thumbnail(step: ScreenPlanStep): Partial { @update:value="emit('dirty')" /> { :input-props="{ 'aria-label': `Текст шага ${index + 1}` }" @update:value="emit('dirty')" /> + () +const screenTeamNames = ref() +let lastTeamsLoaded = -Infinity const resultTeams = ref([]) const resultQuestions = ref<{ code: string | undefined; text: string | undefined }[]>([]) const resultsContainer = ref() @@ -130,9 +133,14 @@ async function loadState(recreateOnUnauthorized = true) { if (!screenId.value || !accessToken.value) return try { 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 syncTimer(state.value) 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) { if (recreateOnUnauthorized && err instanceof Error && err.message === 'unauthorized') { 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() { if (!screenId.value || !accessToken.value) return const res = await screenClient.GetScreenResults({ screenID: screenId.value, accessToken: accessToken.value }) @@ -380,6 +402,14 @@ onUnmounted(() => {