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) {
|
||||
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 {}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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