[E14][@ant] feat: QR-передача партии поставщик→оператор (Story 14.3)
Основной сценарий ПВЗ: поставщик показывает QR сформированной партии, оператор сканирует — идентификатор подставляется и акт приёмки открывается без ручного ввода shipment_id. Два переиспользуемых канон-виджета (DRY, под Story 14.4 заказчик→выдача): - HandoffQr — генерация QR из идентификатора (qrcode, toDataURL) + копируемый код как запасной путь; - QrScanner — нативный BarcodeDetector (Chromium) + камера, с ручным вводом кода если камера/API недоступны; зависимостей не добавляем. Поставщик (OffererSupplyPreparation): колонка «Передача» с кнопкой QR на партии в статусе SUPPLY_PREPARED/RECEPTION_IN_PROGRESS → диалог с QR. Оператор (OperatorReception): кнопка «Сканировать QR» → диалог сканера → onQrScanned подставляет код и создаёт акт приёмки. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+55
-3
@@ -1,10 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { FailAlert } from 'src/shared/api';
|
||||
import { BaseBadge, BaseButton, EmptyState, TableSkeleton } from 'src/shared/ui/base';
|
||||
import { BaseBadge, BaseButton, BaseDialog, EmptyState, TableSkeleton } from 'src/shared/ui/base';
|
||||
import type { BaseBadgeVariant, TableSkeletonColumn } from 'src/shared/ui/base';
|
||||
import { PageHint } from 'src/shared/ui/domain';
|
||||
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
|
||||
import { HandoffQr } from 'src/widgets/Marketplace/HandoffQr';
|
||||
import { listShipments, type MarketplaceShipmentView } from '../api';
|
||||
import { fetchSupplierOrders } from '../../OffererIncomingOrders/api';
|
||||
import type { MarketplaceOrderView } from '../../MyOrders/types';
|
||||
@@ -51,6 +52,21 @@ function openFormation(cycle: ShipmentFormationCycle): void {
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
// QR-код передачи: поставщик показывает его оператору на ПВЗ — тот
|
||||
// сканирует и сразу открывает приёмку именно этой партии.
|
||||
const qrDialogOpen = ref(false);
|
||||
const qrShipment = ref<MarketplaceShipmentView | null>(null);
|
||||
|
||||
function openQr(row: MarketplaceShipmentView): void {
|
||||
qrShipment.value = row;
|
||||
qrDialogOpen.value = true;
|
||||
}
|
||||
|
||||
// QR имеет смысл, пока партия не принята/не отменена.
|
||||
function canShowQr(row: MarketplaceShipmentView): boolean {
|
||||
return row.status === 'SUPPLY_PREPARED' || row.status === 'RECEPTION_IN_PROGRESS';
|
||||
}
|
||||
|
||||
// Статус партии → метка + canon-вариант бейджа.
|
||||
const SHIPMENT_STATUS: Record<string, { label: string; variant: BaseBadgeVariant }> = {
|
||||
DRAFT: { label: 'Черновик', variant: 'neutral' },
|
||||
@@ -106,6 +122,7 @@ const skeletonColumns: TableSkeletonColumn[] = [
|
||||
{ label: 'Статус', cell: 'badge' },
|
||||
{ label: 'Сумма', class: 'col-num', cell: 'text', cellWidth: '80px' },
|
||||
{ label: 'ТТН', cell: 'text', cellWidth: '90px' },
|
||||
{ label: 'Передача', class: 'col-qr', cell: 'text', cellWidth: '96px' },
|
||||
];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -148,7 +165,7 @@ q-page.offerer-supply
|
||||
v-if='loading && !shipments.length && !formationCycles.length',
|
||||
:columns='skeletonColumns',
|
||||
:rows='6',
|
||||
min-width='960px'
|
||||
min-width="1040px"
|
||||
)
|
||||
|
||||
//- Раздел 1: заявки, ожидающие явного формирования партии.
|
||||
@@ -185,6 +202,7 @@ q-page.offerer-supply
|
||||
th.col-status Статус
|
||||
th.col-num Сумма
|
||||
th.col-ttn ТТН
|
||||
th.col-qr Передача
|
||||
tbody
|
||||
tr(v-for='row in shipments', :key='row.id')
|
||||
td.col-id {{ row.cycle_id }}
|
||||
@@ -195,6 +213,12 @@ q-page.offerer-supply
|
||||
.offerer-supply__next(v-if='nextStep(row)') {{ nextStep(row) }}
|
||||
td.col-num {{ row.total_amount }} ₽
|
||||
td.col-ttn {{ row.ttn_number || '—' }}
|
||||
td.col-qr
|
||||
BaseButton(v-if='canShowQr(row)', variant='ghost', size='sm', @click='openQr(row)')
|
||||
template(#icon-left)
|
||||
q-icon(name='qr_code_2', size='16px')
|
||||
| QR
|
||||
span(v-else) —
|
||||
|
||||
EmptyState(
|
||||
v-if='isEmpty',
|
||||
@@ -209,6 +233,15 @@ q-page.offerer-supply
|
||||
:cycle='selectedCycle',
|
||||
@created='onCreated'
|
||||
)
|
||||
|
||||
BaseDialog(v-model='qrDialogOpen', title='QR-код передачи партии', size='sm')
|
||||
.offerer-supply__qr(v-if='qrShipment')
|
||||
HandoffQr(
|
||||
:value='qrShipment.id',
|
||||
caption='Покажите этот код оператору пункта выдачи — он отсканирует и откроет приёмку партии.'
|
||||
)
|
||||
.offerer-supply__qr-meta
|
||||
| {{ qrShipment.braname }} · {{ deliveryVariantLabel(qrShipment.delivery_variant) }} · {{ qrShipment.total_amount }} ₽
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -311,6 +344,21 @@ q-page.offerer-supply
|
||||
line-height: 1.3;
|
||||
color: var(--p-ink-3);
|
||||
}
|
||||
|
||||
&__qr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--p-3, 12px);
|
||||
padding: var(--p-2, 8px) 0;
|
||||
}
|
||||
|
||||
&__qr-meta {
|
||||
font-size: var(--p-fs-body-sm, 13px);
|
||||
color: var(--p-ink-2);
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
@@ -318,7 +366,7 @@ q-page.offerer-supply
|
||||
}
|
||||
.table {
|
||||
table-layout: fixed;
|
||||
min-width: 960px;
|
||||
min-width: 1040px;
|
||||
}
|
||||
.col-id {
|
||||
width: 150px;
|
||||
@@ -338,6 +386,10 @@ q-page.offerer-supply
|
||||
.col-ttn {
|
||||
width: 120px;
|
||||
}
|
||||
.col-qr {
|
||||
width: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.offerer-supply {
|
||||
|
||||
+19
-1
@@ -5,9 +5,10 @@ import { useRoute } from 'vue-router';
|
||||
import { Loading } from 'quasar';
|
||||
import { SuccessAlert, FailAlert } from 'src/shared/api';
|
||||
import { OperatorBranchBar, useOperatorBranchStore } from 'src/entities/OperatorBranch';
|
||||
import { BaseBadge, BaseButton, BaseInput, EmptyState } from 'src/shared/ui/base';
|
||||
import { BaseBadge, BaseButton, BaseDialog, BaseInput, EmptyState } from 'src/shared/ui/base';
|
||||
import type { BaseBadgeVariant } from 'src/shared/ui/base';
|
||||
import { PageHint } from 'src/shared/ui/domain';
|
||||
import { QrScanner } from 'src/widgets/Marketplace/QrScanner';
|
||||
import {
|
||||
createAplReception,
|
||||
listAplReceptionsByBraname,
|
||||
@@ -113,6 +114,16 @@ async function createReceptionForShipment(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// QR-код передачи: поставщик показывает QR партии, оператор сканирует —
|
||||
// идентификатор подставляется и акт приёмки открывается без ручного ввода.
|
||||
const scanDialogOpen = ref(false);
|
||||
|
||||
async function onQrScanned(code: string): Promise<void> {
|
||||
scanDialogOpen.value = false;
|
||||
shipmentIdInput.value = code;
|
||||
await createReceptionForShipment();
|
||||
}
|
||||
|
||||
const signDialogOpen = ref(false);
|
||||
const signTarget = ref<MarketplaceAplReceptionView | null>(null);
|
||||
|
||||
@@ -160,6 +171,10 @@ q-page.reception(role='region', aria-label='Приёмка партии')
|
||||
template(#icon-left)
|
||||
q-icon(name='add', size='16px')
|
||||
| Создать акт приёмки
|
||||
BaseButton(variant='secondary', @click='scanDialogOpen = true')
|
||||
template(#icon-left)
|
||||
q-icon(name='qr_code_scanner', size='16px')
|
||||
| Сканировать QR
|
||||
|
||||
q-table.reception__table(
|
||||
:rows='items',
|
||||
@@ -198,6 +213,9 @@ q-page.reception(role='region', aria-label='Приёмка партии')
|
||||
:reception='signTarget',
|
||||
@signed='onChairmanSigned'
|
||||
)
|
||||
|
||||
BaseDialog(v-model='scanDialogOpen', title='Сканирование QR партии', size='sm')
|
||||
QrScanner(@scanned='onQrScanned')
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// QR-передача (Эпик 14, Story 14.3/14.4). Минимальные декларации для
|
||||
// генерации QR (`qrcode` — без собственных типов) и нативного декодера
|
||||
// `BarcodeDetector` (поддерживается Chromium; зависимостей не добавляем).
|
||||
|
||||
declare module 'qrcode' {
|
||||
interface QRCodeToDataURLOptions {
|
||||
margin?: number;
|
||||
width?: number;
|
||||
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
|
||||
color?: { dark?: string; light?: string };
|
||||
}
|
||||
export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise<string>;
|
||||
const _default: { toDataURL: typeof toDataURL };
|
||||
export default _default;
|
||||
}
|
||||
|
||||
interface DetectedBarcode {
|
||||
rawValue: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
interface BarcodeDetectorOptions {
|
||||
formats?: string[];
|
||||
}
|
||||
|
||||
declare class BarcodeDetector {
|
||||
constructor(options?: BarcodeDetectorOptions);
|
||||
detect(source: CanvasImageSource | Blob | ImageData): Promise<DetectedBarcode[]>;
|
||||
static getSupportedFormats(): Promise<string[]>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
BarcodeDetector?: typeof BarcodeDetector;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import QRCode from 'qrcode';
|
||||
import { copyToClipboard } from 'quasar';
|
||||
import { SuccessAlert, FailAlert } from 'src/shared/api';
|
||||
import { BaseButton } from 'src/shared/ui/base';
|
||||
|
||||
/**
|
||||
* Эпик 14 / Story 14.3, 14.4: QR-код передачи на ПВЗ.
|
||||
*
|
||||
* Один переиспользуемый компонент для обоих сценариев:
|
||||
* - поставщик показывает QR партии оператору приёмки (value = shipment_id);
|
||||
* - заказчик показывает QR заказа оператору выдачи (value = order id).
|
||||
*
|
||||
* QR кодирует идентификатор — оператор сканирует и сразу видит, что
|
||||
* принять / выдать. Под QR — тот же код текстом (копируемый) как запасной
|
||||
* путь, если камера недоступна.
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string;
|
||||
caption?: string;
|
||||
size?: number;
|
||||
}>(),
|
||||
{ caption: '', size: 240 },
|
||||
);
|
||||
|
||||
const dataUrl = ref('');
|
||||
const failed = ref(false);
|
||||
|
||||
async function render(): Promise<void> {
|
||||
failed.value = false;
|
||||
if (!props.value) {
|
||||
dataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dataUrl.value = await QRCode.toDataURL(props.value, {
|
||||
margin: 1,
|
||||
width: props.size,
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
} catch {
|
||||
failed.value = true;
|
||||
dataUrl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.value, props.size], render, { immediate: true });
|
||||
|
||||
async function copyCode(): Promise<void> {
|
||||
try {
|
||||
await copyToClipboard(props.value);
|
||||
SuccessAlert('Код скопирован');
|
||||
} catch (e) {
|
||||
FailAlert(e, 'Не удалось скопировать код');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.handoff-qr
|
||||
.handoff-qr__frame(:style='{ width: size + "px", height: size + "px" }')
|
||||
img.handoff-qr__img(v-if='dataUrl', :src='dataUrl', alt='QR-код передачи')
|
||||
.handoff-qr__fallback(v-else-if='failed')
|
||||
q-icon(name='error_outline', size='32px')
|
||||
span Не удалось построить QR
|
||||
.handoff-qr__caption(v-if='caption') {{ caption }}
|
||||
.handoff-qr__code
|
||||
code.handoff-qr__code-text {{ value }}
|
||||
BaseButton(variant='ghost', size='sm', icon-only, aria-label='Скопировать код', @click='copyCode')
|
||||
template(#icon-left)
|
||||
q-icon(name='content_copy', size='16px')
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.handoff-qr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--p-3, 12px);
|
||||
|
||||
&__frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--p-line);
|
||||
border-radius: var(--p-r-md, 12px);
|
||||
padding: var(--p-2, 8px);
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
&__fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--p-2, 8px);
|
||||
color: var(--p-ink-3);
|
||||
font-size: var(--p-fs-body-sm, 13px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__caption {
|
||||
font-size: var(--p-fs-body-sm, 13px);
|
||||
color: var(--p-ink-3);
|
||||
text-align: center;
|
||||
max-width: 320px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--p-2, 8px);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__code-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--p-fs-body-sm, 13px);
|
||||
color: var(--p-ink-2);
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--p-surface-2, var(--p-surface));
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--p-r-sm, 8px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HandoffQr } from './HandoffQr.vue';
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, useTemplateRef } from 'vue';
|
||||
import { BaseButton, BaseInput } from 'src/shared/ui/base';
|
||||
|
||||
/**
|
||||
* Эпик 14 / Story 14.3, 14.4: сканер QR-кода передачи на ПВЗ.
|
||||
*
|
||||
* Переиспользуется оператором приёмки (партия) и оператором выдачи (заказ).
|
||||
* Декодирование — нативный `BarcodeDetector` (Chromium), без внешних
|
||||
* зависимостей. Если API/камера недоступны — ручной ввод кода как запасной
|
||||
* путь (тот же код напечатан под QR у показывающего).
|
||||
*/
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'scanned', code: string): void;
|
||||
}>();
|
||||
|
||||
type ScanState = 'idle' | 'requesting' | 'scanning' | 'error';
|
||||
|
||||
const state = ref<ScanState>('idle');
|
||||
const errorMessage = ref('');
|
||||
const manualCode = ref('');
|
||||
|
||||
const video = useTemplateRef<HTMLVideoElement>('video');
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
let detector: BarcodeDetector | null = null;
|
||||
let rafId: number | null = null;
|
||||
let stopped = false;
|
||||
|
||||
const supported = typeof window !== 'undefined' && 'BarcodeDetector' in window;
|
||||
|
||||
function teardown(): void {
|
||||
stopped = true;
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (stream) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
if (stopped || !detector || !video.value) return;
|
||||
try {
|
||||
const codes = await detector.detect(video.value);
|
||||
const hit = codes.find((c) => c.rawValue);
|
||||
if (hit) {
|
||||
teardown();
|
||||
state.value = 'idle';
|
||||
emit('scanned', hit.rawValue.trim());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Кадр мог быть не готов — продолжаем цикл, не падая.
|
||||
}
|
||||
rafId = requestAnimationFrame(() => void tick());
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
errorMessage.value = '';
|
||||
if (!supported) {
|
||||
state.value = 'error';
|
||||
errorMessage.value = 'Камера-сканер недоступна в этом браузере — введите код вручную.';
|
||||
return;
|
||||
}
|
||||
state.value = 'requesting';
|
||||
stopped = false;
|
||||
try {
|
||||
detector = new BarcodeDetector({ formats: ['qr_code'] });
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
});
|
||||
if (stopped) {
|
||||
teardown();
|
||||
return;
|
||||
}
|
||||
if (video.value) {
|
||||
video.value.srcObject = stream;
|
||||
await video.value.play();
|
||||
}
|
||||
state.value = 'scanning';
|
||||
rafId = requestAnimationFrame(() => void tick());
|
||||
} catch {
|
||||
teardown();
|
||||
state.value = 'error';
|
||||
errorMessage.value = 'Не удалось получить доступ к камере — введите код вручную.';
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
teardown();
|
||||
state.value = 'idle';
|
||||
}
|
||||
|
||||
function submitManual(): void {
|
||||
const code = manualCode.value.trim();
|
||||
if (!code) return;
|
||||
teardown();
|
||||
state.value = 'idle';
|
||||
emit('scanned', code);
|
||||
manualCode.value = '';
|
||||
}
|
||||
|
||||
onBeforeUnmount(teardown);
|
||||
|
||||
defineExpose({ start, stop });
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.qr-scanner
|
||||
.qr-scanner__viewport
|
||||
video.qr-scanner__video(
|
||||
ref='video',
|
||||
v-show='state === "scanning"',
|
||||
muted,
|
||||
playsinline
|
||||
)
|
||||
.qr-scanner__overlay(v-if='state === "idle"')
|
||||
q-icon.qr-scanner__muted(name='qr_code_scanner', size='56px')
|
||||
.qr-scanner__caption Наведите камеру на QR-код передачи
|
||||
BaseButton(variant='primary', size='sm', @click='start') Включить камеру
|
||||
.qr-scanner__overlay(v-else-if='state === "requesting"')
|
||||
q-spinner(color='primary', size='40px')
|
||||
.qr-scanner__caption Запрос доступа к камере…
|
||||
.qr-scanner__frame(v-else-if='state === "scanning"')
|
||||
.qr-scanner__corners
|
||||
.qr-scanner__hint Поместите QR-код в рамку
|
||||
.qr-scanner__overlay(v-else-if='state === "error"')
|
||||
q-icon(name='videocam_off', size='48px')
|
||||
.qr-scanner__caption {{ errorMessage }}
|
||||
|
||||
q-btn.qr-scanner__stop(
|
||||
v-if='state === "scanning"',
|
||||
flat,
|
||||
no-caps,
|
||||
dense,
|
||||
color='primary',
|
||||
label='Остановить',
|
||||
@click='stop'
|
||||
)
|
||||
|
||||
//- Запасной путь: ручной ввод кода (тот же, что напечатан под QR).
|
||||
.qr-scanner__manual
|
||||
BaseInput.qr-scanner__manual-input(
|
||||
v-model='manualCode',
|
||||
label='Или введите код вручную',
|
||||
placeholder='идентификатор',
|
||||
mono,
|
||||
@keyup.enter='submitManual'
|
||||
)
|
||||
BaseButton(variant='secondary', size='sm', :disabled='!manualCode.trim()', @click='submitManual') Применить
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.qr-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-3, 12px);
|
||||
|
||||
&__viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
aspect-ratio: 4 / 3;
|
||||
margin: 0 auto;
|
||||
background: var(--p-ink);
|
||||
border-radius: var(--p-r-md, 12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--p-3, 12px);
|
||||
padding: var(--p-4, 16px);
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&__caption {
|
||||
font-size: var(--p-fs-body-sm, 13px);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__corners {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
border-radius: var(--p-r-sm, 8px);
|
||||
}
|
||||
|
||||
&__hint {
|
||||
position: absolute;
|
||||
bottom: var(--p-3, 12px);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: var(--p-fs-body-sm, 13px);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
&__stop {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
&__manual {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--p-2, 8px);
|
||||
}
|
||||
|
||||
&__manual-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as QrScanner } from './QrScanner.vue';
|
||||
Reference in New Issue
Block a user