generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# Печать чеков команды на MXW01
|
||||||
|
|
||||||
|
Печать работает только на локальной площадке (`APP_MODE=venue`). На вкладке
|
||||||
|
«Команды» выберите «Печать чека» → проверьте предпросмотр → нажмите «Печатать».
|
||||||
|
Firefox и Chrome используют один серверный API; Web Bluetooth и настройки
|
||||||
|
браузера не нужны. Сервер берёт название и пароль команды из базы, а адрес QR —
|
||||||
|
из `APP_BASE_URL` площадки.
|
||||||
|
|
||||||
|
## Настройка площадки на Linux
|
||||||
|
|
||||||
|
1. Сервер venue и MXW01 должны находиться рядом на одном Linux-компьютере
|
||||||
|
с включённым Bluetooth. Установите термобумагу и включите принтер.
|
||||||
|
2. В локальном `venue/.env` задайте `MXW01_ADDRESS=<Bluetooth MAC принтера>`.
|
||||||
|
Адрес текущего MXW01 уже настроен локально; `.env` не включается в Git.
|
||||||
|
3. Соберите комплект существующей командой `make build`, затем запустите
|
||||||
|
`make venue`. Служба `receipt_printer` запускается из того же Go-образа.
|
||||||
|
4. Если приложение телефона подключено к принтеру, закройте его.
|
||||||
|
|
||||||
|
Служба печати использует прямое Bluetooth LE-соединение. Её контейнер работает
|
||||||
|
с `network_mode: host` и `NET_RAW`, без `privileged`, доступа к базе и TCP-порта.
|
||||||
|
API передаёт растровые данные через закрытый Unix-сокет в общем Docker-volume.
|
||||||
|
Остальные контейнеры сохраняют существующую сеть. Python, Bleak и BlueZ D-Bus
|
||||||
|
для печати не нужны.
|
||||||
|
|
||||||
|
Без `MXW01_ADDRESS` игра продолжает работать; при попытке печати сервер сообщает,
|
||||||
|
что принтер не настроен. На публичном сервере кнопки печати нет, и API её запрещает.
|
||||||
|
|
||||||
|
## Поведение и проверка
|
||||||
|
|
||||||
|
- За один запрос печатается один чек шириной 384 пикселя. Одновременный запрос
|
||||||
|
получает сообщение «принтер занят»; постоянной очереди нет.
|
||||||
|
- Сервер проверяет бумагу и состояние MXW01, затем ждёт подтверждение завершения
|
||||||
|
и проверяет контрольную сумму изображения. Общий таймаут — 110 секунд.
|
||||||
|
- Длинное название переносится. Неподдерживаемые символы, слишком длинная ссылка
|
||||||
|
или чек выше 1024 пикселей отклоняются до печати.
|
||||||
|
- После разрыва связи или таймаута сначала проверьте бумажный чек: он мог уже
|
||||||
|
выйти. Автоматической повторной печати нет. После устранения проблемы повторите
|
||||||
|
печать вручную; спящий принтер разбудите кнопкой.
|
||||||
|
- Перед игрой напечатайте две разные команды, проверьте названия и отсканируйте
|
||||||
|
QR телефоном в Wi-Fi площадки. Ссылка должна открывать напечатанную команду.
|
||||||
|
|
||||||
|
Чек содержит ссылку доступа команды; передавайте его участникам этой команды.
|
||||||
|
PNG-предпросмотр требует авторизации и отдаётся с `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
Протокол проверен на текущем MXW01: фактический ответ состояния содержит
|
||||||
|
10 байт, а ответ завершения — подтверждение и две копии CRC изображения.
|
||||||
|
Источник описания формата: [измерения протокола MXW01](https://github.com/eliorpom-cmd/print-on-my-desk/blob/main/docs/09-protocol.md).
|
||||||
+38
-15
@@ -915,11 +915,27 @@ service EveningDetectiveServer {
|
|||||||
rpc DownloadGamePrintMaterials(GetGameReq) returns (google.api.HttpBody) {
|
rpc DownloadGamePrintMaterials(GetGameReq) returns (google.api.HttpBody) {
|
||||||
option (google.api.http) = {get: "/api/games/{ID}/print-materials"};
|
option (google.api.http) = {get: "/api/games/{ID}/print-materials"};
|
||||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
tags: "Игры";
|
tags : "Игры";
|
||||||
summary: "Скачать материалы игры для печати ZIP-архивом";
|
summary: "Скачать материалы игры для печати ZIP-архивом";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rpc PreviewTeamReceipt(TeamReceiptReq) returns (google.api.HttpBody) {
|
||||||
|
option (google.api.http) = {get: "/api/games/{gameID}/teams/{teamID}/receipt.png"};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags: "Игры";
|
||||||
|
summary: "Предпросмотр чека команды на локальной площадке";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc PrintTeamReceipt(TeamReceiptReq) returns (PrintTeamReceiptRsp) {
|
||||||
|
option (google.api.http) = {post: "/api/games/{gameID}/teams/{teamID}/receipt/print" body: "*"};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags: "Игры";
|
||||||
|
summary: "Напечатать один чек команды на MXW01 локальной площадки";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
rpc DownloadTeamQR(GetGameReq) returns (google.api.HttpBody) {
|
rpc DownloadTeamQR(GetGameReq) returns (google.api.HttpBody) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
get: "/api/games/{ID}/teams/qr.pdf"
|
get: "/api/games/{ID}/teams/qr.pdf"
|
||||||
@@ -2409,13 +2425,13 @@ message GameResultsPackageRsp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message DeviceHeartbeatReq {
|
message DeviceHeartbeatReq {
|
||||||
string deviceID = 1;
|
string deviceID = 1;
|
||||||
string connectionID = 2;
|
string connectionID = 2;
|
||||||
optional int32 organizerGameID = 3;
|
optional int32 organizerGameID = 3;
|
||||||
optional string teamID = 4;
|
optional string teamID = 4;
|
||||||
string password = 5;
|
string password = 5;
|
||||||
string screenID = 6;
|
string screenID = 6;
|
||||||
string accessToken = 7;
|
string accessToken = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DeviceHeartbeatRsp {
|
message DeviceHeartbeatRsp {
|
||||||
@@ -2428,7 +2444,7 @@ message GetDevicesReq { optional int32 gameID = 1; }
|
|||||||
message GetDevicesRsp {
|
message GetDevicesRsp {
|
||||||
repeated VenueDevice devices = 1;
|
repeated VenueDevice devices = 1;
|
||||||
google.protobuf.Timestamp serverTime = 2;
|
google.protobuf.Timestamp serverTime = 2;
|
||||||
VenueWiFi wifi = 3; // Entire venue, independent of the game filter.
|
VenueWiFi wifi = 3; // Entire venue, independent of the game filter.
|
||||||
}
|
}
|
||||||
|
|
||||||
message VenueDevice {
|
message VenueDevice {
|
||||||
@@ -2453,16 +2469,23 @@ message DeviceConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message VenueWiFi {
|
message VenueWiFi {
|
||||||
bool enabled = 1;
|
bool enabled = 1;
|
||||||
google.protobuf.Timestamp collectedAt = 2;
|
google.protobuf.Timestamp collectedAt = 2;
|
||||||
bool stale = 3;
|
bool stale = 3;
|
||||||
bool hasError = 4;
|
bool hasError = 4;
|
||||||
repeated WiFiClient clients = 5;
|
repeated WiFiClient clients = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message WiFiClient {
|
message WiFiClient {
|
||||||
string IP = 1;
|
string IP = 1;
|
||||||
string MAC = 2;
|
string MAC = 2;
|
||||||
string band = 3;
|
string band = 3;
|
||||||
optional int32 signal = 4; // dBm; absent when the router does not report it.
|
optional int32 signal = 4; // dBm; absent when the router does not report it.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message TeamReceiptReq {
|
||||||
|
int32 gameID = 1;
|
||||||
|
string teamID = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PrintTeamReceiptRsp {}
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ function buildBinaryClient(
|
|||||||
return download(`api/games/${id}/teams/qr.pdf`, `game-${id}.pdf`)
|
return download(`api/games/${id}/teams/qr.pdf`, `game-${id}.pdf`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadTeamReceipt(gameID: number, teamID: string): Promise<DownloadFileResult> {
|
||||||
|
return download(
|
||||||
|
`api/games/${gameID}/teams/${encodeURIComponent(teamID)}/receipt.png`,
|
||||||
|
'receipt.png',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function downloadGamePrintMaterials(id: number): Promise<DownloadFileResult> {
|
function downloadGamePrintMaterials(id: number): Promise<DownloadFileResult> {
|
||||||
return download(`api/games/${id}/print-materials`, `game-${id}-print-materials.zip`)
|
return download(`api/games/${id}/print-materials`, `game-${id}-print-materials.zip`)
|
||||||
}
|
}
|
||||||
@@ -136,6 +143,7 @@ function buildBinaryClient(
|
|||||||
return {
|
return {
|
||||||
downloadScenarioArchive,
|
downloadScenarioArchive,
|
||||||
downloadTeamQR,
|
downloadTeamQR,
|
||||||
|
downloadTeamReceipt,
|
||||||
downloadGamePrintMaterials,
|
downloadGamePrintMaterials,
|
||||||
uploadScenarioArchive,
|
uploadScenarioArchive,
|
||||||
downloadGamePackage,
|
downloadGamePackage,
|
||||||
|
|||||||
@@ -1342,6 +1342,14 @@ export type WiFiClient = {
|
|||||||
signal?: number;
|
signal?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TeamReceiptReq = {
|
||||||
|
gameID: number | undefined;
|
||||||
|
teamID: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrintTeamReceiptRsp = {
|
||||||
|
};
|
||||||
|
|
||||||
export interface EveningDetectiveServer {
|
export interface EveningDetectiveServer {
|
||||||
DeviceHeartbeat(request: DeviceHeartbeatReq): Promise<DeviceHeartbeatRsp>;
|
DeviceHeartbeat(request: DeviceHeartbeatReq): Promise<DeviceHeartbeatRsp>;
|
||||||
GetDevices(request: GetDevicesReq): Promise<GetDevicesRsp>;
|
GetDevices(request: GetDevicesReq): Promise<GetDevicesRsp>;
|
||||||
@@ -1441,6 +1449,8 @@ export interface EveningDetectiveServer {
|
|||||||
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
|
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
|
||||||
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>;
|
||||||
|
PrintTeamReceipt(request: TeamReceiptReq): Promise<PrintTeamReceiptRsp>;
|
||||||
DownloadTeamQR(request: GetGameReq): Promise<googleapi_HttpBody>;
|
DownloadTeamQR(request: GetGameReq): Promise<googleapi_HttpBody>;
|
||||||
GetGameAnswers(request: GetGameAnswersReq): Promise<GetGameAnswersRsp>;
|
GetGameAnswers(request: GetGameAnswersReq): Promise<GetGameAnswersRsp>;
|
||||||
GetGameResults(request: GetGameResultsReq): Promise<GetGameResultsRsp>;
|
GetGameResults(request: GetGameResultsReq): Promise<GetGameResultsRsp>;
|
||||||
@@ -3126,6 +3136,52 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DownloadGamePrintMaterials",
|
method: "DownloadGamePrintMaterials",
|
||||||
}) as Promise<googleapi_HttpBody>;
|
}) as Promise<googleapi_HttpBody>;
|
||||||
},
|
},
|
||||||
|
PreviewTeamReceipt(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
if (!request.gameID) {
|
||||||
|
throw new Error("missing required field request.gameID");
|
||||||
|
}
|
||||||
|
if (!request.teamID) {
|
||||||
|
throw new Error("missing required field request.teamID");
|
||||||
|
}
|
||||||
|
const path = `api/games/${request.gameID}/teams/${request.teamID}/receipt.png`; // 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: "PreviewTeamReceipt",
|
||||||
|
}) as Promise<googleapi_HttpBody>;
|
||||||
|
},
|
||||||
|
PrintTeamReceipt(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
if (!request.gameID) {
|
||||||
|
throw new Error("missing required field request.gameID");
|
||||||
|
}
|
||||||
|
if (!request.teamID) {
|
||||||
|
throw new Error("missing required field request.teamID");
|
||||||
|
}
|
||||||
|
const path = `api/games/${request.gameID}/teams/${request.teamID}/receipt/print`; // 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: "PrintTeamReceipt",
|
||||||
|
}) as Promise<PrintTeamReceiptRsp>;
|
||||||
|
},
|
||||||
DownloadTeamQR(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
DownloadTeamQR(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.ID) {
|
if (!request.ID) {
|
||||||
throw new Error("missing required field request.ID");
|
throw new Error("missing required field request.ID");
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import GameScreensPanel from '@/components/GameScreensPanel.vue'
|
|||||||
import GameStatePanel from '@/components/GameStatePanel.vue'
|
import GameStatePanel from '@/components/GameStatePanel.vue'
|
||||||
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
|
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
|
||||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import TeamReceiptModal from '@/components/TeamReceiptModal.vue'
|
||||||
import TicketCheckModal from '@/components/TicketCheckModal.vue'
|
import TicketCheckModal from '@/components/TicketCheckModal.vue'
|
||||||
import UiBlock from '@/components/UiBlock.vue'
|
import UiBlock from '@/components/UiBlock.vue'
|
||||||
import { useDeviceContext } from '@/composables/useDeviceContext'
|
import { useDeviceContext } from '@/composables/useDeviceContext'
|
||||||
@@ -108,6 +109,13 @@ const deletedGameName = ref('')
|
|||||||
const showQR = ref(false)
|
const showQR = ref(false)
|
||||||
const urlQR = ref('')
|
const urlQR = ref('')
|
||||||
const titleQR = ref('')
|
const titleQR = ref('')
|
||||||
|
const showTeamReceipt = ref(false)
|
||||||
|
const teamReceipt = ref({ name: '', teamID: '' })
|
||||||
|
const isPrintingReceipt = ref(false)
|
||||||
|
const isPreparingReceipt = ref(false)
|
||||||
|
const receiptPreview = ref('')
|
||||||
|
const receiptPreparationError = ref('')
|
||||||
|
let receiptPreparationVersion = 0
|
||||||
const showTicketCheckModal = ref(false)
|
const showTicketCheckModal = ref(false)
|
||||||
const paymentURL =
|
const paymentURL =
|
||||||
'https://www.sberbank.ru/ru/choise_bank?requisiteNumber=79609361994&bankCode=100000000111'
|
'https://www.sberbank.ru/ru/choise_bank?requisiteNumber=79609361994&bankCode=100000000111'
|
||||||
@@ -281,6 +289,8 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
isDisposed = true
|
isDisposed = true
|
||||||
|
receiptPreparationVersion++
|
||||||
|
clearReceiptPreview()
|
||||||
window.removeEventListener('click', enableNotificationSound)
|
window.removeEventListener('click', enableNotificationSound)
|
||||||
window.removeEventListener('keydown', enableNotificationSound)
|
window.removeEventListener('keydown', enableNotificationSound)
|
||||||
void notificationAudio?.close().catch((error: unknown) => {
|
void notificationAudio?.close().catch((error: unknown) => {
|
||||||
@@ -892,6 +902,62 @@ function openTeamQR(team: Team) {
|
|||||||
openQR(getTeamStoryURL(team.ID!, team.password!), 'Подсмотреть игру')
|
openQR(getTeamStoryURL(team.ID!, team.password!), 'Подсмотреть игру')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTeamReceipt(team: Team) {
|
||||||
|
if (
|
||||||
|
uiStore.environment !== 'venue' ||
|
||||||
|
!team.ID ||
|
||||||
|
!team.name ||
|
||||||
|
team.participationStatus === 'cancelled'
|
||||||
|
) {
|
||||||
|
message.error('У команды нет данных для печати чека')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
teamReceipt.value = { name: team.name, teamID: team.ID }
|
||||||
|
showTeamReceipt.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[showTeamReceipt, () => teamReceipt.value.teamID, canManageGame, () => uiStore.environment],
|
||||||
|
prepareTeamReceipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
function clearReceiptPreview() {
|
||||||
|
if (receiptPreview.value) URL.revokeObjectURL(receiptPreview.value)
|
||||||
|
receiptPreview.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareTeamReceipt() {
|
||||||
|
const version = ++receiptPreparationVersion
|
||||||
|
clearReceiptPreview()
|
||||||
|
receiptPreparationError.value = ''
|
||||||
|
isPreparingReceipt.value = false
|
||||||
|
if (!showTeamReceipt.value || !canManageGame.value || uiStore.environment !== 'venue') return
|
||||||
|
isPreparingReceipt.value = true
|
||||||
|
try {
|
||||||
|
const { blob } = await binaryClient.downloadTeamReceipt(Number(gameId), teamReceipt.value.teamID)
|
||||||
|
if (version === receiptPreparationVersion) receiptPreview.value = URL.createObjectURL(blob)
|
||||||
|
} catch (error) {
|
||||||
|
if (version === receiptPreparationVersion)
|
||||||
|
receiptPreparationError.value =
|
||||||
|
error instanceof Error ? error.message : 'Не удалось подготовить чек'
|
||||||
|
} finally {
|
||||||
|
if (version === receiptPreparationVersion) isPreparingReceipt.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printTeamReceipt() {
|
||||||
|
if (!receiptPreview.value || isPrintingReceipt.value || isPreparingReceipt.value) return
|
||||||
|
isPrintingReceipt.value = true
|
||||||
|
try {
|
||||||
|
await client.PrintTeamReceipt({ gameID: Number(gameId), teamID: teamReceipt.value.teamID })
|
||||||
|
message.success('Чек напечатан')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : 'Не удалось напечатать чек')
|
||||||
|
} finally {
|
||||||
|
isPrintingReceipt.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openQR(url: string, text: string) {
|
async function openQR(url: string, text: string) {
|
||||||
urlQR.value = url
|
urlQR.value = url
|
||||||
titleQR.value = text
|
titleQR.value = text
|
||||||
@@ -1276,9 +1342,11 @@ useSimplePolling(() => {
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
<GameTeamsPanel
|
<GameTeamsPanel
|
||||||
:teams="game.teams || []"
|
:teams="game.teams || []"
|
||||||
|
:can-print-receipts="uiStore.environment === 'venue'"
|
||||||
:participation-options="participationOptions"
|
:participation-options="participationOptions"
|
||||||
@add="showAddTeamModal = true"
|
@add="showAddTeamModal = true"
|
||||||
@open-qr="openTeamQR"
|
@open-qr="openTeamQR"
|
||||||
|
@print="openTeamReceipt"
|
||||||
@preview="(id, password) => toTeamStory(id, password, true)"
|
@preview="(id, password) => toTeamStory(id, password, true)"
|
||||||
@delete="deleteTeam"
|
@delete="deleteTeam"
|
||||||
@give-application="giveApplication"
|
@give-application="giveApplication"
|
||||||
@@ -1492,6 +1560,16 @@ useSimplePolling(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TicketCheckModal v-if="canManageGame" v-model:show="showTicketCheckModal" />
|
<TicketCheckModal v-if="canManageGame" v-model:show="showTicketCheckModal" />
|
||||||
|
<TeamReceiptModal
|
||||||
|
v-if="canManageGame && uiStore.environment === 'venue'"
|
||||||
|
v-model:show="showTeamReceipt"
|
||||||
|
:name="teamReceipt.name"
|
||||||
|
:preview="receiptPreview"
|
||||||
|
:preparation-error="receiptPreparationError"
|
||||||
|
:is-preparing="isPreparingReceipt"
|
||||||
|
:is-printing="isPrintingReceipt"
|
||||||
|
@print="printTeamReceipt"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Окно выдачи улик -->
|
<!-- Окно выдачи улик -->
|
||||||
<n-modal v-if="canManageGame" v-model:show="showGiveApplicationModal">
|
<n-modal v-if="canManageGame" v-model:show="showGiveApplicationModal">
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import UiBlock from '@/components/UiBlock.vue'
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
teams: Team[]
|
teams: Team[]
|
||||||
|
canPrintReceipts: boolean
|
||||||
participationOptions: { label: string; value: string }[]
|
participationOptions: { label: string; value: string }[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
add: []
|
add: []
|
||||||
'open-qr': [team: Team]
|
'open-qr': [team: Team]
|
||||||
|
print: [team: Team]
|
||||||
preview: [id: string, password: string]
|
preview: [id: string, password: string]
|
||||||
delete: [team: Team]
|
delete: [team: Team]
|
||||||
'give-application': [team: Team, application: Application]
|
'give-application': [team: Team, application: Application]
|
||||||
@@ -47,6 +49,12 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
|
<n-button
|
||||||
|
v-if="canPrintReceipts && team.participationStatus !== 'cancelled'"
|
||||||
|
@click="emit('print', team)"
|
||||||
|
>
|
||||||
|
Печать чека
|
||||||
|
</n-button>
|
||||||
<n-button v-if="team.participationStatus !== 'cancelled'" @click="emit('open-qr', team)"
|
<n-button v-if="team.participationStatus !== 'cancelled'" @click="emit('open-qr', team)"
|
||||||
>QR</n-button
|
>QR</n-button
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCard, NFlex, NModal } from 'naive-ui'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
name: string
|
||||||
|
preview: string
|
||||||
|
preparationError: string
|
||||||
|
isPreparing: boolean
|
||||||
|
isPrinting: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
print: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
:mask-closable="!isPrinting"
|
||||||
|
:close-on-esc="!isPrinting"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<n-card class="receipt-modal" title="Чек команды" role="dialog" aria-modal="true">
|
||||||
|
<n-alert v-if="preparationError" type="error">{{ preparationError }}</n-alert>
|
||||||
|
<img
|
||||||
|
v-else-if="preview"
|
||||||
|
class="receipt-preview"
|
||||||
|
:src="preview"
|
||||||
|
:alt="`Чек команды ${name}`"
|
||||||
|
/>
|
||||||
|
<p v-else-if="isPreparing">Подготовка чека…</p>
|
||||||
|
<p>Включите MXW01 рядом с сервером площадки. Сервер напечатает один чек.</p>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button :disabled="isPrinting" @click="emit('update:show', false)">Закрыть</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:loading="isPrinting"
|
||||||
|
:disabled="!preview || isPreparing || isPrinting"
|
||||||
|
@click="emit('print')"
|
||||||
|
>Печатать</n-button
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.receipt-modal {
|
||||||
|
max-height: 90vh;
|
||||||
|
max-width: 460px;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-preview {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 384px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-modal p {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user