This commit is contained in:
2026-10-04 13:19:25 +07:00
parent 9aa824711b
commit c04e60ffb8
5 changed files with 385 additions and 40 deletions
+71
View File
@@ -685,6 +685,36 @@ service EveningDetectiveServer {
}; };
} }
rpc GetScreenPlan(GetScreenPlanReq) returns (GetScreenPlanRsp) {
option (google.api.http) = { get: "/api/games/{gameID}/screen-plan" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Экраны";
summary: "Получить план экранов";
};
}
rpc SaveScreenPlan(SaveScreenPlanReq) returns (SaveScreenPlanRsp) {
option (google.api.http) = {
put: "/api/games/{gameID}/screen-plan"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Экраны";
summary: "Сохранить план экранов";
};
}
rpc SetScreenPlanStep(SetScreenPlanStepReq) returns (SetScreenPlanStepRsp) {
option (google.api.http) = {
post: "/api/games/{gameID}/screen-plan/step"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Экраны";
summary: "Перейти на шаг плана экранов";
};
}
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) = {
@@ -1410,6 +1440,7 @@ enum ScreenMode {
SCREEN_MODE_CONCLUSION_DONE = 5; SCREEN_MODE_CONCLUSION_DONE = 5;
SCREEN_MODE_RESULTS = 6; SCREEN_MODE_RESULTS = 6;
SCREEN_MODE_TIMER = 7; SCREEN_MODE_TIMER = 7;
SCREEN_MODE_TEXT = 8;
} }
enum ScreenTimerState { enum ScreenTimerState {
@@ -1455,6 +1486,7 @@ message ScreenState {
int64 timer_duration_seconds = 17; int64 timer_duration_seconds = 17;
int64 timer_remaining_seconds = 18; int64 timer_remaining_seconds = 18;
ScreenTimerState timer_state = 19; ScreenTimerState timer_state = 19;
string text = 20;
} }
message CreateScreenSessionReq {} message CreateScreenSessionReq {}
@@ -1563,6 +1595,45 @@ message SendScreenCommandReq {
message SendScreenCommandRsp { message SendScreenCommandRsp {
} }
message ScreenPlanStep {
ScreenMode mode = 1;
string text = 2;
string timer_label = 3;
int64 timer_duration_seconds = 4;
}
message ScreenPlan {
repeated ScreenPlanStep steps = 1;
int32 current_step = 2;
}
message GetScreenPlanReq {
int32 gameID = 1;
}
message GetScreenPlanRsp {
ScreenPlan plan = 1;
}
message SaveScreenPlanReq {
int32 gameID = 1;
repeated ScreenPlanStep steps = 2;
int32 current_step = 3;
}
message SaveScreenPlanRsp {
ScreenPlan plan = 1;
}
message SetScreenPlanStepReq {
int32 gameID = 1;
int32 current_step = 2;
}
message SetScreenPlanStepRsp {
ScreenPlan plan = 1;
}
message GetScreenResultsReq { message GetScreenResultsReq {
string screenID = 1; string screenID = 1;
string accessToken = 2; string accessToken = 2;
@@ -10,7 +10,8 @@ export type ScreenMode =
| "SCREEN_MODE_CONCLUSION" | "SCREEN_MODE_CONCLUSION"
| "SCREEN_MODE_CONCLUSION_DONE" | "SCREEN_MODE_CONCLUSION_DONE"
| "SCREEN_MODE_RESULTS" | "SCREEN_MODE_RESULTS"
| "SCREEN_MODE_TIMER"; | "SCREEN_MODE_TIMER"
| "SCREEN_MODE_TEXT";
export type ScreenTimerState = export type ScreenTimerState =
| "SCREEN_TIMER_STATE_UNSPECIFIED" | "SCREEN_TIMER_STATE_UNSPECIFIED"
| "SCREEN_TIMER_STATE_STOPPED" | "SCREEN_TIMER_STATE_STOPPED"
@@ -502,6 +503,7 @@ export type ScreenState = {
timerDurationSeconds: number | undefined; timerDurationSeconds: number | undefined;
timerRemainingSeconds: number | undefined; timerRemainingSeconds: number | undefined;
timerState: ScreenTimerState | undefined; timerState: ScreenTimerState | undefined;
text: string | undefined;
}; };
export type CreateScreenSessionReq = { export type CreateScreenSessionReq = {
@@ -612,6 +614,45 @@ export type SendScreenCommandReq = {
export type SendScreenCommandRsp = { export type SendScreenCommandRsp = {
}; };
export type ScreenPlanStep = {
mode: ScreenMode | undefined;
text: string | undefined;
timerLabel: string | undefined;
timerDurationSeconds: number | undefined;
};
export type ScreenPlan = {
steps: ScreenPlanStep[] | undefined;
currentStep: number | undefined;
};
export type GetScreenPlanReq = {
gameID: number | undefined;
};
export type GetScreenPlanRsp = {
plan: ScreenPlan | undefined;
};
export type SaveScreenPlanReq = {
gameID: number | undefined;
steps: ScreenPlanStep[] | undefined;
currentStep: number | undefined;
};
export type SaveScreenPlanRsp = {
plan: ScreenPlan | undefined;
};
export type SetScreenPlanStepReq = {
gameID: number | undefined;
currentStep: number | undefined;
};
export type SetScreenPlanStepRsp = {
plan: ScreenPlan | undefined;
};
export type GetScreenResultsReq = { export type GetScreenResultsReq = {
screenID: string | undefined; screenID: string | undefined;
accessToken: string | undefined; accessToken: string | undefined;
@@ -1017,6 +1058,9 @@ export interface EveningDetectiveServer {
SetScreenTimer(request: SetScreenTimerReq): Promise<SetScreenTimerRsp>; SetScreenTimer(request: SetScreenTimerReq): Promise<SetScreenTimerRsp>;
// Отправить общую команду экранам игры. // Отправить общую команду экранам игры.
SendScreenCommand(request: SendScreenCommandReq): Promise<SendScreenCommandRsp>; SendScreenCommand(request: SendScreenCommandReq): Promise<SendScreenCommandRsp>;
GetScreenPlan(request: GetScreenPlanReq): Promise<GetScreenPlanRsp>;
SaveScreenPlan(request: SaveScreenPlanReq): Promise<SaveScreenPlanRsp>;
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>; GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>;
DownloadTeamQR(request: GetGameReq): Promise<googleapi_HttpBody>; DownloadTeamQR(request: GetGameReq): Promise<googleapi_HttpBody>;
GetGameAnswers(request: GetGameAnswersReq): Promise<GetGameAnswersRsp>; GetGameAnswers(request: GetGameAnswersReq): Promise<GetGameAnswersRsp>;
@@ -2167,6 +2211,66 @@ export function createEveningDetectiveServerClient(
method: "SendScreenCommand", method: "SendScreenCommand",
}) as Promise<SendScreenCommandRsp>; }) as Promise<SendScreenCommandRsp>;
}, },
GetScreenPlan(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.gameID) {
throw new Error("missing required field request.gameID");
}
const path = `api/games/${request.gameID}/screen-plan`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetScreenPlan",
}) as Promise<GetScreenPlanRsp>;
},
SaveScreenPlan(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.gameID) {
throw new Error("missing required field request.gameID");
}
const path = `api/games/${request.gameID}/screen-plan`; // 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: "PUT",
body,
}, {
service: "EveningDetectiveServer",
method: "SaveScreenPlan",
}) as Promise<SaveScreenPlanRsp>;
},
SetScreenPlanStep(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.gameID) {
throw new Error("missing required field request.gameID");
}
const path = `api/games/${request.gameID}/screen-plan/step`; // 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: "SetScreenPlanStep",
}) as Promise<SetScreenPlanStepRsp>;
},
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);
+168
View File
@@ -29,6 +29,8 @@ import type {
ResultsQuestion, ResultsQuestion,
ScreenCommand, ScreenCommand,
ScreenMode, ScreenMode,
ScreenPlan,
ScreenPlanStep,
ScreenState, ScreenState,
Team, Team,
TeamAnswers, TeamAnswers,
@@ -106,6 +108,10 @@ const teamsNeedingApplications = computed(
), ),
) )
const screens = ref<ScreenState[]>([]) const screens = ref<ScreenState[]>([])
const screenPlan = ref<ScreenPlan>()
const screenPlanDraft = ref<ScreenPlanStep[]>([])
const screenPlanDirty = ref(false)
const screenPlanSaving = ref(false)
const previewQuestions = ref<ResultsQuestion[]>([]) const previewQuestions = ref<ResultsQuestion[]>([])
const previewTeams = ref<TeamResult[]>([]) const previewTeams = ref<TeamResult[]>([])
const mapItems = ref<MapItem[]>([]) const mapItems = ref<MapItem[]>([])
@@ -123,6 +129,16 @@ const currentScreenMode = computed<ScreenMode | undefined>(() => {
const timerScreen = computed(() => screens.value[0]) const timerScreen = computed(() => screens.value[0])
const previewScreen = computed(() => screens.value.find((screen) => screen.status === 'connected') || screens.value[0]) const previewScreen = computed(() => screens.value.find((screen) => screen.status === 'connected') || screens.value[0])
const hasDisconnectedScreen = computed(() => screens.value.some((screen) => screen.status === 'disconnected')) const hasDisconnectedScreen = computed(() => screens.value.some((screen) => screen.status === 'disconnected'))
const screenPlanModeOptions = [
{ label: 'Обложка', value: 'SCREEN_MODE_COVER' },
{ label: 'Введение', value: 'SCREEN_MODE_INTRO' },
{ label: 'Успехов', value: 'SCREEN_MODE_INTRO_DONE' },
{ label: 'Закрывашка', value: 'SCREEN_MODE_CONCLUSION' },
{ label: 'Спасибо за игру', value: 'SCREEN_MODE_CONCLUSION_DONE' },
{ label: 'Результаты', value: 'SCREEN_MODE_RESULTS' },
{ label: 'Таймер', value: 'SCREEN_MODE_TIMER' },
{ label: 'Текст', value: 'SCREEN_MODE_TEXT' },
]
const timerStateLabel = computed(() => { const timerStateLabel = computed(() => {
switch (timerScreen.value?.timerState) { switch (timerScreen.value?.timerState) {
case 'SCREEN_TIMER_STATE_RUNNING': case 'SCREEN_TIMER_STATE_RUNNING':
@@ -196,6 +212,77 @@ async function getGameScreens() {
} }
getGameScreens() getGameScreens()
async function getScreenPlan() {
if (screenPlanDirty.value) return
const res = await client.GetScreenPlan({ gameID: Number(gameId) })
screenPlan.value = res.plan
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
}
getScreenPlan()
function addScreenPlanStep() {
screenPlanDraft.value.push({
mode: 'SCREEN_MODE_TEXT',
text: 'Новый экран',
timerLabel: 'Перерыв',
timerDurationSeconds: 600,
})
screenPlanDirty.value = true
}
function removeScreenPlanStep(index: number) {
screenPlanDraft.value.splice(index, 1)
screenPlanDirty.value = true
}
function moveScreenPlanStep(index: number, offset: number) {
const target = index + offset
if (target < 0 || target >= screenPlanDraft.value.length) return
const [step] = screenPlanDraft.value.splice(index, 1)
screenPlanDraft.value.splice(target, 0, step)
screenPlanDirty.value = true
}
function markScreenPlanDirty() {
screenPlanDirty.value = true
}
async function saveScreenPlan() {
if (screenPlanSaving.value) return
screenPlanSaving.value = true
try {
const currentStep = Math.min(
screenPlan.value?.currentStep || 0,
Math.max(0, screenPlanDraft.value.length - 1),
)
const res = await client.SaveScreenPlan({
gameID: Number(gameId),
steps: screenPlanDraft.value,
currentStep,
})
screenPlan.value = res.plan
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
screenPlanDirty.value = false
await getGameScreens()
message.success(res.plan ? 'План экранов сохранён' : 'План экранов удалён')
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось сохранить план экранов')
} finally {
screenPlanSaving.value = false
}
}
async function setScreenPlanStep(index: number) {
if (screenPlanDirty.value || !screenPlan.value) return
try {
const res = await client.SetScreenPlanStep({ gameID: Number(gameId), currentStep: index })
screenPlan.value = res.plan
await getGameScreens()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось перейти на шаг плана')
}
}
function stopScreenScanner() { function stopScreenScanner() {
cancelAnimationFrame(screenScannerFrame) cancelAnimationFrame(screenScannerFrame)
screenScannerStream?.getTracks().forEach((track) => track.stop()) screenScannerStream?.getTracks().forEach((track) => track.stop())
@@ -607,6 +694,7 @@ async function issueTickets() {
useSimplePolling(() => { useSimplePolling(() => {
getGame(Number(gameId)) getGame(Number(gameId))
getGameScreens() getGameScreens()
getScreenPlan()
getGameMap() getGameMap()
}, 2000) }, 2000)
@@ -952,6 +1040,86 @@ useSimplePolling(() => {
>Перемотать</n-button >Перемотать</n-button
> >
</n-space> </n-space>
<div class="screen-plan">
<n-flex align="center" justify="space-between">
<p class="screens-title">План экранов</p>
<n-space>
<n-button v-if="!screenPlan && !screenPlanDirty" size="small" @click="addScreenPlanStep">
Создать план
</n-button>
<n-button
v-if="screenPlan || screenPlanDirty"
size="small"
type="primary"
:loading="screenPlanSaving"
@click="saveScreenPlan"
>
Сохранить план
</n-button>
</n-space>
</n-flex>
<template v-if="screenPlan || screenPlanDirty">
<n-space v-for="(step, index) in screenPlanDraft" :key="index" align="center" wrap>
<n-text>{{ index + 1 }}</n-text>
<n-select
v-model:value="step.mode"
:options="screenPlanModeOptions"
size="small"
style="width: 180px"
@update:value="markScreenPlanDirty"
/>
<n-input
v-if="step.mode === 'SCREEN_MODE_TEXT'"
v-model:value="step.text"
size="small"
placeholder="Текст экрана"
style="min-width: 240px"
@update:value="markScreenPlanDirty"
/>
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
<n-input
v-model:value="step.timerLabel"
size="small"
placeholder="Надпись таймера"
@update:value="markScreenPlanDirty"
/>
<n-input-number
v-model:value="step.timerDurationSeconds"
:min="1"
:max="86400"
size="small"
placeholder="Секунды"
@update:value="markScreenPlanDirty"
/>
</template>
<n-button size="small" :disabled="index === 0" @click="moveScreenPlanStep(index, -1)">↑</n-button>
<n-button
size="small"
:disabled="index === screenPlanDraft.length - 1"
@click="moveScreenPlanStep(index, 1)"
>
↓
</n-button>
<n-button size="small" type="error" ghost @click="removeScreenPlanStep(index)">
Удалить
</n-button>
<n-button
size="small"
:type="screenPlan?.currentStep === index ? 'primary' : 'default'"
:disabled="screenPlanDirty || screenPlan?.currentStep === index"
@click="setScreenPlanStep(index)"
>
Показать
</n-button>
</n-space>
<n-space>
<n-button size="small" @click="addScreenPlanStep">Добавить шаг</n-button>
<n-button v-if="screenPlanDraft.length" size="small" @click="screenPlanDraft = []; markScreenPlanDirty()">
Удалить план
</n-button>
</n-space>
</template>
</div>
</template> </template>
</div> </div>
+1
View File
@@ -281,6 +281,7 @@ onUnmounted(() => {
<div v-else-if="screenMode === 'SCREEN_MODE_CONCLUSION'" class="screen-message">Видео закрывашки не задано</div> <div v-else-if="screenMode === 'SCREEN_MODE_CONCLUSION'" class="screen-message">Видео закрывашки не задано</div>
<div v-else-if="screenMode === 'SCREEN_MODE_INTRO_DONE'" class="screen-message">Успехов</div> <div v-else-if="screenMode === 'SCREEN_MODE_INTRO_DONE'" class="screen-message">Успехов</div>
<div v-else-if="screenMode === 'SCREEN_MODE_CONCLUSION_DONE'" class="screen-message">Спасибо за игру!</div> <div v-else-if="screenMode === 'SCREEN_MODE_CONCLUSION_DONE'" class="screen-message">Спасибо за игру!</div>
<div v-else-if="screenMode === 'SCREEN_MODE_TEXT'" class="screen-message">{{ state.text }}</div>
<div v-else-if="screenMode === 'SCREEN_MODE_TIMER'" class="timer-screen"> <div v-else-if="screenMode === 'SCREEN_MODE_TIMER'" class="timer-screen">
<h1 class="timer-label">{{ state.timerLabel || 'Перерыв' }}</h1> <h1 class="timer-label">{{ state.timerLabel || 'Перерыв' }}</h1>
<div class="timer-value">{{ timerDisplay }}</div> <div class="timer-value">{{ timerDisplay }}</div>
+1
View File
@@ -87,6 +87,7 @@ onUnmounted(() => {
<div v-else-if="state.mode === 'SCREEN_MODE_CONCLUSION_DONE'" class="preview-message"> <div v-else-if="state.mode === 'SCREEN_MODE_CONCLUSION_DONE'" class="preview-message">
Спасибо за игру! Спасибо за игру!
</div> </div>
<div v-else-if="state.mode === 'SCREEN_MODE_TEXT'" class="preview-message">{{ state.text }}</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>