generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -634,6 +634,30 @@ service EveningDetectiveServer {
|
||||
};
|
||||
}
|
||||
|
||||
// Dedicated HTTP handlers stream ZIPs; gRPC retains its message size limit.
|
||||
rpc DownloadGameResultsPackage(GameResultsPackageReq) returns (google.api.HttpBody) {
|
||||
option (google.api.http) = {get: "/api/games/{ID}/results-package/download"};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags: "Игры";
|
||||
summary: "Скачать свежий ZIP результатов завершённой venue-игры";
|
||||
produces: "application/zip";
|
||||
};
|
||||
}
|
||||
|
||||
rpc ApplyGameResultsPackage(ApplyGameResultsPackageReq) returns (GameResultsPackageRsp) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/games/{ID}/results-package/apply"
|
||||
body: "package"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags: "Игры";
|
||||
summary: "Применить ZIP результатов на исходной prod-игре";
|
||||
description: "HTTP принимает сырой ZIP до 1 ГиБ, включая поток без Content-Length. Ошибки: 400 неверный пакет/игра, 413 размер, 409 конфликт, 503 временный отказ.";
|
||||
consumes: "application/zip";
|
||||
produces: "application/json";
|
||||
};
|
||||
}
|
||||
|
||||
rpc StartGameExport(StartGameExportReq) returns (GameExportRsp) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/games/{ID}/export"
|
||||
@@ -2345,3 +2369,14 @@ message DeleteTaskReq {
|
||||
message TaskRsp { DeveloperTask task = 1; }
|
||||
|
||||
message DeleteTaskRsp {}
|
||||
|
||||
message GameResultsPackageReq { int32 ID = 1; }
|
||||
message ApplyGameResultsPackageReq {
|
||||
int32 ID = 1;
|
||||
google.api.HttpBody package = 2;
|
||||
}
|
||||
message GameResultsPackageRsp {
|
||||
int32 gameID = 1;
|
||||
string packageID = 2;
|
||||
string appliedAt = 3;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { GameImportRsp } from '@/api/generated/crabs/evening_detective_server'
|
||||
import type {
|
||||
GameImportRsp,
|
||||
GameResultsPackageRsp,
|
||||
} from '@/api/generated/crabs/evening_detective_server'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
import { retryWithAuth } from './authRetry'
|
||||
@@ -62,6 +65,32 @@ function buildBinaryClient(
|
||||
return download(`api/games/${id}/export/download`, `game-${id}.zip`)
|
||||
}
|
||||
|
||||
function downloadGameResultsPackage(id: number): Promise<DownloadFileResult> {
|
||||
return download(`api/games/${id}/results-package/download`, `game-results-${id}.zip`)
|
||||
}
|
||||
|
||||
async function applyGameResultsPackage(id: number, file: File): Promise<GameResultsPackageRsp> {
|
||||
const response = await request(`api/games/${id}/results-package/apply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
body: file,
|
||||
})
|
||||
if (!response.ok) throw new Error(await gatewayErrorMessage(response))
|
||||
const data: unknown = await response.json()
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
!('gameID' in data) ||
|
||||
data.gameID !== id ||
|
||||
!('packageID' in data) ||
|
||||
typeof data.packageID !== 'string' ||
|
||||
!('appliedAt' in data) ||
|
||||
typeof data.appliedAt !== 'string'
|
||||
)
|
||||
throw new Error('Некорректный ответ применения результатов')
|
||||
return { gameID: data.gameID, packageID: data.packageID, appliedAt: data.appliedAt }
|
||||
}
|
||||
|
||||
async function uploadGamePackage(file: File): Promise<GameImportRsp> {
|
||||
const response = await request('api/games/import', {
|
||||
method: 'POST',
|
||||
@@ -106,6 +135,8 @@ function buildBinaryClient(
|
||||
uploadScenarioArchive,
|
||||
downloadGamePackage,
|
||||
uploadGamePackage,
|
||||
downloadGameResultsPackage,
|
||||
applyGameResultsPackage,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1216,6 +1216,71 @@ export type TaskRsp = {
|
||||
export type DeleteTaskRsp = {
|
||||
};
|
||||
|
||||
export type GameResultsPackageReq = {
|
||||
ID: number | undefined;
|
||||
};
|
||||
|
||||
export type ApplyGameResultsPackageReq = {
|
||||
ID: number | undefined;
|
||||
package: googleapi_HttpBody | undefined;
|
||||
};
|
||||
|
||||
// Message that represents an arbitrary HTTP body. It should only be used for
|
||||
// payload formats that can't be represented as JSON, such as raw binary or
|
||||
// an HTML page.
|
||||
// This message can be used both in streaming and non-streaming API methods in
|
||||
// the request as well as the response.
|
||||
// It can be used as a top-level request field, which is convenient if one
|
||||
// wants to extract parameters from either the URL or HTTP template into the
|
||||
// request fields and also want access to the raw HTTP body.
|
||||
// Example:
|
||||
// message GetResourceRequest {
|
||||
// // A unique request id.
|
||||
// string request_id = 1;
|
||||
// // The raw HTTP body is bound to this field.
|
||||
// google.api.HttpBody http_body = 2;
|
||||
// }
|
||||
// service ResourceService {
|
||||
// rpc GetResource(GetResourceRequest)
|
||||
// returns (google.api.HttpBody);
|
||||
// rpc UpdateResource(google.api.HttpBody)
|
||||
// returns (google.protobuf.Empty);
|
||||
// }
|
||||
// Example with streaming methods:
|
||||
// service CaldavService {
|
||||
// rpc GetCalendar(stream google.api.HttpBody)
|
||||
// returns (stream google.api.HttpBody);
|
||||
// rpc UpdateCalendar(stream google.api.HttpBody)
|
||||
// returns (stream google.api.HttpBody);
|
||||
// }
|
||||
// Use of this type only changes how the request and response bodies are
|
||||
// handled, all other features will continue to work unchanged.
|
||||
export type googleapi_HttpBody = {
|
||||
// The HTTP Content-Type header value specifying the content type of the body.
|
||||
contentType: string | undefined;
|
||||
// The HTTP request/response body as raw binary.
|
||||
data: string | undefined;
|
||||
// Application specific response metadata. Must be set in the first response
|
||||
// for streaming APIs.
|
||||
extensions: wellKnownAny[] | undefined;
|
||||
};
|
||||
|
||||
// If the Any contains a value that has a special JSON mapping,
|
||||
// it will be converted as follows:
|
||||
// {"@type": xxx, "value": yyy}.
|
||||
// Otherwise, the value will be converted into a JSON object,
|
||||
// and the "@type" field will be inserted to indicate the actual data type.
|
||||
interface wellKnownAny {
|
||||
"@type": string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type GameResultsPackageRsp = {
|
||||
gameID: number | undefined;
|
||||
packageID: string | undefined;
|
||||
appliedAt: string | undefined;
|
||||
};
|
||||
|
||||
export interface EveningDetectiveServer {
|
||||
GetTasks(request: GetTasksReq): Promise<GetTasksRsp>;
|
||||
GetTaskAssignees(request: GetTaskAssigneesReq): Promise<GetTaskAssigneesRsp>;
|
||||
@@ -1280,6 +1345,9 @@ export interface EveningDetectiveServer {
|
||||
GetGame(request: GetGameReq): Promise<GetGameRsp>;
|
||||
// HTTP uploads are streamed by a dedicated handler, bypassing gRPC message limits.
|
||||
ImportGame(request: googleapi_HttpBody): Promise<GameImportRsp>;
|
||||
// Dedicated HTTP handlers stream ZIPs; gRPC retains its message size limit.
|
||||
DownloadGameResultsPackage(request: GameResultsPackageReq): Promise<googleapi_HttpBody>;
|
||||
ApplyGameResultsPackage(request: ApplyGameResultsPackageReq): Promise<GameResultsPackageRsp>;
|
||||
StartGameExport(request: StartGameExportReq): Promise<GameExportRsp>;
|
||||
GetGameExport(request: GetGameExportReq): Promise<GameExportRsp>;
|
||||
IssueGameTickets(request: IssueGameTicketsReq): Promise<IssueGameTicketsRsp>;
|
||||
@@ -2443,6 +2511,46 @@ export function createEveningDetectiveServerClient(
|
||||
method: "ImportGame",
|
||||
}) as Promise<GameImportRsp>;
|
||||
},
|
||||
DownloadGameResultsPackage(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
if (!request.ID) {
|
||||
throw new Error("missing required field request.ID");
|
||||
}
|
||||
const path = `api/games/${request.ID}/results-package/download`; // 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: "DownloadGameResultsPackage",
|
||||
}) as Promise<googleapi_HttpBody>;
|
||||
},
|
||||
ApplyGameResultsPackage(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
if (!request.ID) {
|
||||
throw new Error("missing required field request.ID");
|
||||
}
|
||||
const path = `api/games/${request.ID}/results-package/apply`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request?.package ?? {});
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "POST",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "ApplyGameResultsPackage",
|
||||
}) as Promise<GameResultsPackageRsp>;
|
||||
},
|
||||
StartGameExport(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
if (!request.ID) {
|
||||
throw new Error("missing required field request.ID");
|
||||
@@ -3422,55 +3530,5 @@ export function createEveningDetectiveServerClient(
|
||||
},
|
||||
};
|
||||
}
|
||||
// Message that represents an arbitrary HTTP body. It should only be used for
|
||||
// payload formats that can't be represented as JSON, such as raw binary or
|
||||
// an HTML page.
|
||||
// This message can be used both in streaming and non-streaming API methods in
|
||||
// the request as well as the response.
|
||||
// It can be used as a top-level request field, which is convenient if one
|
||||
// wants to extract parameters from either the URL or HTTP template into the
|
||||
// request fields and also want access to the raw HTTP body.
|
||||
// Example:
|
||||
// message GetResourceRequest {
|
||||
// // A unique request id.
|
||||
// string request_id = 1;
|
||||
// // The raw HTTP body is bound to this field.
|
||||
// google.api.HttpBody http_body = 2;
|
||||
// }
|
||||
// service ResourceService {
|
||||
// rpc GetResource(GetResourceRequest)
|
||||
// returns (google.api.HttpBody);
|
||||
// rpc UpdateResource(google.api.HttpBody)
|
||||
// returns (google.protobuf.Empty);
|
||||
// }
|
||||
// Example with streaming methods:
|
||||
// service CaldavService {
|
||||
// rpc GetCalendar(stream google.api.HttpBody)
|
||||
// returns (stream google.api.HttpBody);
|
||||
// rpc UpdateCalendar(stream google.api.HttpBody)
|
||||
// returns (stream google.api.HttpBody);
|
||||
// }
|
||||
// Use of this type only changes how the request and response bodies are
|
||||
// handled, all other features will continue to work unchanged.
|
||||
export type googleapi_HttpBody = {
|
||||
// The HTTP Content-Type header value specifying the content type of the body.
|
||||
contentType: string | undefined;
|
||||
// The HTTP request/response body as raw binary.
|
||||
data: string | undefined;
|
||||
// Application specific response metadata. Must be set in the first response
|
||||
// for streaming APIs.
|
||||
extensions: wellKnownAny[] | undefined;
|
||||
};
|
||||
|
||||
// If the Any contains a value that has a special JSON mapping,
|
||||
// it will be converted as follows:
|
||||
// {"@type": xxx, "value": yyy}.
|
||||
// Otherwise, the value will be converted into a JSON object,
|
||||
// and the "@type" field will be inserted to indicate the actual data type.
|
||||
interface wellKnownAny {
|
||||
"@type": string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
// @@protoc_insertion_point(typescript-http-eof)
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
NTabPane,
|
||||
NTabs,
|
||||
NText,
|
||||
NUpload,
|
||||
type UploadFileInfo,
|
||||
} from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -54,6 +56,8 @@ import { useUIStore } from '@/stores/ui'
|
||||
import { applicationFileType, applicationFileURL } from '@/utils/application'
|
||||
import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime'
|
||||
|
||||
const emit = defineEmits<{ resultsApplied: [] }>()
|
||||
|
||||
const client = getAuthClient()
|
||||
const binaryClient = getBinaryClient()
|
||||
const isDownloadingQR = ref(false)
|
||||
@@ -118,6 +122,16 @@ const canExport = computed(
|
||||
)
|
||||
const isStartingExport = ref(false)
|
||||
const isDownloadingPackage = ref(false)
|
||||
const canDownloadResults = computed(
|
||||
() =>
|
||||
uiStore.environment === 'venue' &&
|
||||
game.value.status === 'finish' &&
|
||||
authStore.hasAnyRole(['admin', 'organizer', 'developer']),
|
||||
)
|
||||
const isDownloadingResults = ref(false)
|
||||
const isApplyingResults = ref(false)
|
||||
const resultsFiles = ref<UploadFileInfo[]>([])
|
||||
const resultsError = ref('')
|
||||
|
||||
const participationOptions = [
|
||||
{ label: 'Ожидает ответа', value: 'pending' },
|
||||
@@ -516,6 +530,60 @@ async function downloadPackage() {
|
||||
}
|
||||
}
|
||||
|
||||
function changeResultsFile({ fileList }: { fileList: UploadFileInfo[] }) {
|
||||
if (isApplyingResults.value) return
|
||||
resultsFiles.value = fileList.slice(-1)
|
||||
resultsError.value = ''
|
||||
}
|
||||
|
||||
async function downloadResults() {
|
||||
if (!canDownloadResults.value || isDownloadingResults.value) return
|
||||
isDownloadingResults.value = true
|
||||
try {
|
||||
const { blob, filename } = await binaryClient.downloadGameResultsPackage(game.value.ID!)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось скачать результаты')
|
||||
} finally {
|
||||
isDownloadingResults.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyResults() {
|
||||
if (!canExport.value || isApplyingResults.value) return
|
||||
const file = resultsFiles.value[0]?.file
|
||||
if (!file || file.size === 0) {
|
||||
resultsError.value = 'Выберите непустой ZIP-файл'
|
||||
return
|
||||
}
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
resultsError.value = 'Выберите файл с расширением .zip'
|
||||
return
|
||||
}
|
||||
if (file.size > 1024 ** 3) {
|
||||
resultsError.value = 'Размер пакета не должен превышать 1 ГиБ'
|
||||
return
|
||||
}
|
||||
isApplyingResults.value = true
|
||||
resultsError.value = ''
|
||||
try {
|
||||
await binaryClient.applyGameResultsPackage(game.value.ID!, file)
|
||||
message.success('Результаты приняты')
|
||||
emit('resultsApplied')
|
||||
} catch (err) {
|
||||
resultsError.value = err instanceof Error ? err.message : 'Не удалось применить результаты'
|
||||
} finally {
|
||||
isApplyingResults.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTeamQR() {
|
||||
if (isDownloadingQR.value || !game.value.teams?.length) return
|
||||
isDownloadingQR.value = true
|
||||
@@ -679,6 +747,7 @@ async function issueTickets() {
|
||||
}
|
||||
|
||||
useSimplePolling(() => {
|
||||
if (isApplyingResults.value) return
|
||||
getGame(Number(gameId))
|
||||
getGameScreens()
|
||||
getGameMusic()
|
||||
@@ -905,6 +974,42 @@ useSimplePolling(() => {
|
||||
<n-button @click="updateGame()">Сохранить изменения</n-button>
|
||||
</n-flex>
|
||||
</UiBlock>
|
||||
<UiBlock v-if="canDownloadResults">
|
||||
<n-button
|
||||
:loading="isDownloadingResults"
|
||||
:disabled="isDownloadingResults"
|
||||
@click="downloadResults"
|
||||
>
|
||||
Скачать результаты
|
||||
</n-button>
|
||||
</UiBlock>
|
||||
<UiBlock v-if="canExport">
|
||||
<p class="screens-title">Возврат результатов с площадки</p>
|
||||
<n-space vertical>
|
||||
<n-alert type="warning">
|
||||
Команды, билеты, результаты и игровой сценарий будут заменены данными пакета.
|
||||
Онлайн-правки этих данных потеряются. Оформление и музыка сохраняются.
|
||||
</n-alert>
|
||||
<n-upload
|
||||
:file-list="resultsFiles"
|
||||
:default-upload="false"
|
||||
:max="1"
|
||||
accept=".zip"
|
||||
:disabled="isApplyingResults"
|
||||
@change="changeResultsFile"
|
||||
>
|
||||
<n-button :disabled="isApplyingResults">Выбрать ZIP результатов</n-button>
|
||||
</n-upload>
|
||||
<n-alert v-if="resultsError" type="error" role="alert">{{ resultsError }}</n-alert>
|
||||
<n-button
|
||||
:loading="isApplyingResults"
|
||||
:disabled="isApplyingResults || !resultsFiles.length"
|
||||
@click="applyResults"
|
||||
>
|
||||
Применить ZIP и перезаписать результаты
|
||||
</n-button>
|
||||
</n-space>
|
||||
</UiBlock>
|
||||
<UiBlock v-if="canExport">
|
||||
<p class="screens-title">Передача игры на площадку</p>
|
||||
<n-space vertical>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import GamePage from '@/components/GamePage.vue'
|
||||
|
||||
const pageVersion = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GamePage />
|
||||
<GamePage :key="pageVersion" @results-applied="pageVersion++" />
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user