This commit is contained in:
2026-09-09 12:39:28 +07:00
parent 95e4236691
commit 25b4954bbd
6 changed files with 88 additions and 16 deletions
@@ -2,7 +2,7 @@ import { useAuthStore } from '@/stores/auth'
import { getURL } from './generated/crabs/evening_detective_server/client'
export type DownloadArchiveResult = {
export type DownloadFileResult = {
blob: Blob
filename: string
}
@@ -12,15 +12,14 @@ export type UploadArchiveResult = {
error: string | null
}
// Клиент для бинарных операций со сценарием (ZIP-архив). Сгенерированный
// HTTP-хендлер всегда парсит ответ как JSON, поэтому для архивов используем
// сырой fetch: скачивание отдаёт байты архива, загрузка принимает их напрямую.
export function getArchiveClient() {
// Клиент бинарных файлов: ZIP сценариев и PDF команд. Сгенерированный
// HTTP-хендлер парсит ответы как JSON, поэтому здесь используется fetch.
export function getBinaryClient() {
const authStore = useAuthStore()
return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken)
return buildBinaryClient(authStore.refreshTokenAction, authStore.getToken)
}
function buildArchiveClient(refreshTokens: () => Promise<boolean>, getToken: () => string | null) {
function buildBinaryClient(refreshTokens: () => Promise<boolean>, getToken: () => string | null) {
async function request(path: string, init: RequestInit): Promise<Response> {
const headers: Record<string, string> = {}
const token = getToken()
@@ -49,18 +48,26 @@ function buildArchiveClient(refreshTokens: () => Promise<boolean>, getToken: ()
return response
}
async function downloadScenarioArchive(id: number): Promise<DownloadArchiveResult> {
const response = await request(`api/scenarios/${id}/archive`, { method: 'GET' })
async function download(path: string, fallbackName: string): Promise<DownloadFileResult> {
const response = await request(path, { method: 'GET' })
if (!response.ok) {
throw new Error(await gatewayErrorMessage(response))
}
const blob = await response.blob()
const filename =
parseContentDispositionFilename(response.headers.get('Content-Disposition')) ||
`scenario-${id}.zip`
fallbackName
return { blob, filename }
}
function downloadScenarioArchive(id: number): Promise<DownloadFileResult> {
return download(`api/scenarios/${id}/archive`, `scenario-${id}.zip`)
}
function downloadTeamQR(id: number): Promise<DownloadFileResult> {
return download(`api/games/${id}/teams/qr.pdf`, `game-${id}.pdf`)
}
async function uploadScenarioArchive(file: File): Promise<UploadArchiveResult> {
const contentType = file.type || 'application/zip'
const response = await request('api/scenarios/archive', {
@@ -78,7 +85,7 @@ function buildArchiveClient(refreshTokens: () => Promise<boolean>, getToken: ()
return { id: typeof data?.id === 'number' ? data.id : null, error }
}
return { downloadScenarioArchive, uploadScenarioArchive }
return { downloadScenarioArchive, downloadTeamQR, uploadScenarioArchive }
}
// Извлекает имя файла из Content-Disposition: attachment; filename="name.zip"
@@ -86,6 +93,14 @@ function parseContentDispositionFilename(value: string | null): string {
if (!value) {
return ''
}
const utf8 = value.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8) {
try {
return decodeURIComponent(utf8[1]!.trim()).replace(/[\\/:*?"<>|]/g, '_')
} catch {
// Некорректный filename*: пробуем обычный filename ниже.
}
}
const match = value.match(/filename="?([^";]+)"?/i)
const filename = match ? match[1] : ''
return filename.replace(/[\\/:*?"<>|]/g, '_')
@@ -729,6 +729,7 @@ export interface EveningDetectiveServer {
AddGame(request: AddGameReq): Promise<AddGameRsp>;
GetGames(request: GetGamesReq): Promise<GetGamesRsp>;
GetGame(request: GetGameReq): Promise<GetGameRsp>;
DownloadTeamQR(request: GetGameReq): Promise<googleapi_HttpBody>;
GetGameAnswers(request: GetGameAnswersReq): Promise<GetGameAnswersRsp>;
GetGameResults(request: GetGameResultsReq): Promise<GetGameResultsRsp>;
UpdateGame(request: UpdateGameReq): Promise<UpdateGameRsp>;
@@ -1537,6 +1538,26 @@ export function createEveningDetectiveServerClient(
method: "GetGame",
}) as Promise<GetGameRsp>;
},
DownloadTeamQR(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}/teams/qr.pdf`; // 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: "DownloadTeamQR",
}) as Promise<googleapi_HttpBody>;
},
GetGameAnswers(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.gameId) {
throw new Error("missing required field request.gameId");
+26
View File
@@ -8,6 +8,7 @@ import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import { getBinaryClient } from '@/api/binary_client'
import type {
Application,
Game,
@@ -22,6 +23,8 @@ import { applicationFileType, applicationFileURL } from '@/utils/application'
import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime'
const client = getAuthClient()
const binaryClient = getBinaryClient()
const isDownloadingQR = ref(false)
const router = useRouter()
const route = useRoute()
const gameId = route.params.id
@@ -95,6 +98,26 @@ async function toTeamStory(id: number, password: string, newTab: boolean = false
router.push(uri)
}
async function downloadTeamQR() {
if (isDownloadingQR.value || !game.value.teams?.length) return
isDownloadingQR.value = true
try {
const { blob, filename } = await binaryClient.downloadTeamQR(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 : 'Не удалось скачать QR команд')
} finally {
isDownloadingQR.value = false
}
}
function toResults() {
router.push('/games/' + gameId + '/results')
}
@@ -410,6 +433,9 @@ useSimplePolling(() => {
</Icon>
Итоги
</n-button>
<n-button v-if="game.teams?.length" quaternary :loading="isDownloadingQR" :disabled="isDownloadingQR" @click="downloadTeamQR()">
QR команд
</n-button>
<n-button quaternary @click="openSettings()">
<Icon class="settings-icon">
<Settings />
+2 -2
View File
@@ -17,8 +17,8 @@ import {
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getArchiveClient } from '@/api/archive_client'
import { getAuthClient } from '@/api/auth_client'
import { getBinaryClient } from '@/api/binary_client'
import type { Conclusion, Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue'
@@ -36,7 +36,7 @@ import { applicationFileURL } from '@/utils/application'
const authStore = useAuthStore()
const client = getAuthClient()
const archiveClient = getArchiveClient()
const archiveClient = getBinaryClient()
const route = useRoute()
const scenarioId = route.params.id
+3 -3
View File
@@ -17,13 +17,13 @@ import {
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getArchiveClient } from '@/api/archive_client'
import { getAuthClient } from '@/api/auth_client'
import { getBinaryClient } from '@/api/binary_client'
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
const client = getAuthClient()
const archiveClient = getArchiveClient()
const archiveClient = getBinaryClient()
const scenarios = ref<Scenario[]>([])
@@ -218,8 +218,8 @@ getScenarios()
<style scoped>
.center-block-custom {
height: calc(100dvh - 70px);
-webkit-overflow-scrolling: touch;
overflow: hidden auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 24px;
}