generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user