This commit is contained in:
2026-10-13 13:50:34 +07:00
parent d77638308e
commit 0fa88bca22
7 changed files with 303 additions and 15 deletions
+47
View File
@@ -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
View File
@@ -915,11 +915,27 @@ service EveningDetectiveServer {
rpc DownloadGamePrintMaterials(GetGameReq) returns (google.api.HttpBody) {
option (google.api.http) = {get: "/api/games/{ID}/print-materials"};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Игры";
tags : "Игры";
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) {
option (google.api.http) = {
get: "/api/games/{ID}/teams/qr.pdf"
@@ -2409,13 +2425,13 @@ message GameResultsPackageRsp {
}
message DeviceHeartbeatReq {
string deviceID = 1;
string connectionID = 2;
optional int32 organizerGameID = 3;
string deviceID = 1;
string connectionID = 2;
optional int32 organizerGameID = 3;
optional string teamID = 4;
string password = 5;
string screenID = 6;
string accessToken = 7;
string password = 5;
string screenID = 6;
string accessToken = 7;
}
message DeviceHeartbeatRsp {
@@ -2428,7 +2444,7 @@ message GetDevicesReq { optional int32 gameID = 1; }
message GetDevicesRsp {
repeated VenueDevice devices = 1;
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 {
@@ -2453,16 +2469,23 @@ message DeviceConnection {
}
message VenueWiFi {
bool enabled = 1;
bool enabled = 1;
google.protobuf.Timestamp collectedAt = 2;
bool stale = 3;
bool hasError = 4;
repeated WiFiClient clients = 5;
bool stale = 3;
bool hasError = 4;
repeated WiFiClient clients = 5;
}
message WiFiClient {
string IP = 1;
string MAC = 2;
string band = 3;
string IP = 1;
string MAC = 2;
string band = 3;
optional int32 signal = 4; // dBm; absent when the router does not report it.
}
message TeamReceiptReq {
int32 gameID = 1;
string teamID = 2;
}
message PrintTeamReceiptRsp {}
+8
View File
@@ -61,6 +61,13 @@ function buildBinaryClient(
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> {
return download(`api/games/${id}/print-materials`, `game-${id}-print-materials.zip`)
}
@@ -136,6 +143,7 @@ function buildBinaryClient(
return {
downloadScenarioArchive,
downloadTeamQR,
downloadTeamReceipt,
downloadGamePrintMaterials,
uploadScenarioArchive,
downloadGamePackage,
@@ -1342,6 +1342,14 @@ export type WiFiClient = {
signal?: number;
};
export type TeamReceiptReq = {
gameID: number | undefined;
teamID: string | undefined;
};
export type PrintTeamReceiptRsp = {
};
export interface EveningDetectiveServer {
DeviceHeartbeat(request: DeviceHeartbeatReq): Promise<DeviceHeartbeatRsp>;
GetDevices(request: GetDevicesReq): Promise<GetDevicesRsp>;
@@ -1441,6 +1449,8 @@ export interface EveningDetectiveServer {
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>;
DownloadGamePrintMaterials(request: GetGameReq): Promise<googleapi_HttpBody>;
PreviewTeamReceipt(request: TeamReceiptReq): Promise<googleapi_HttpBody>;
PrintTeamReceipt(request: TeamReceiptReq): Promise<PrintTeamReceiptRsp>;
DownloadTeamQR(request: GetGameReq): Promise<googleapi_HttpBody>;
GetGameAnswers(request: GetGameAnswersReq): Promise<GetGameAnswersRsp>;
GetGameResults(request: GetGameResultsReq): Promise<GetGameResultsRsp>;
@@ -3126,6 +3136,52 @@ export function createEveningDetectiveServerClient(
method: "DownloadGamePrintMaterials",
}) 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
if (!request.ID) {
throw new Error("missing required field request.ID");
+78
View File
@@ -50,6 +50,7 @@ import GameScreensPanel from '@/components/GameScreensPanel.vue'
import GameStatePanel from '@/components/GameStatePanel.vue'
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
import TeamReceiptModal from '@/components/TeamReceiptModal.vue'
import TicketCheckModal from '@/components/TicketCheckModal.vue'
import UiBlock from '@/components/UiBlock.vue'
import { useDeviceContext } from '@/composables/useDeviceContext'
@@ -108,6 +109,13 @@ const deletedGameName = ref('')
const showQR = ref(false)
const urlQR = 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 paymentURL =
'https://www.sberbank.ru/ru/choise_bank?requisiteNumber=79609361994&bankCode=100000000111'
@@ -281,6 +289,8 @@ onMounted(() => {
})
onUnmounted(() => {
isDisposed = true
receiptPreparationVersion++
clearReceiptPreview()
window.removeEventListener('click', enableNotificationSound)
window.removeEventListener('keydown', enableNotificationSound)
void notificationAudio?.close().catch((error: unknown) => {
@@ -892,6 +902,62 @@ function openTeamQR(team: Team) {
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) {
urlQR.value = url
titleQR.value = text
@@ -1276,9 +1342,11 @@ useSimplePolling(() => {
</n-flex>
<GameTeamsPanel
:teams="game.teams || []"
:can-print-receipts="uiStore.environment === 'venue'"
:participation-options="participationOptions"
@add="showAddTeamModal = true"
@open-qr="openTeamQR"
@print="openTeamReceipt"
@preview="(id, password) => toTeamStory(id, password, true)"
@delete="deleteTeam"
@give-application="giveApplication"
@@ -1492,6 +1560,16 @@ useSimplePolling(() => {
</div>
<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">
+8
View File
@@ -6,12 +6,14 @@ import UiBlock from '@/components/UiBlock.vue'
defineProps<{
teams: Team[]
canPrintReceipts: boolean
participationOptions: { label: string; value: string }[]
}>()
const emit = defineEmits<{
add: []
'open-qr': [team: Team]
print: [team: Team]
preview: [id: string, password: string]
delete: [team: Team]
'give-application': [team: Team, application: Application]
@@ -47,6 +49,12 @@ const emit = defineEmits<{
</div>
</div>
<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)"
>QR</n-button
>
+68
View File
@@ -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>