GrCodeScanner

Пакет: @feugene/granularity-mediaспутникГруппа: Прочее

Берут, когда вход по QR.

Когда брать

  • вход по QR — на экране логина другого устройства, вместо ручного набора кода;
  • товар на складе — приёмка сканирует штрихкоды подряд, continuous для этого и есть;
  • оплата или ссылка из афиши — код ведёт на страницу, приложение открывает её;
  • привязка устройства — код показывает второй экран, а телефон подтверждает.

Когда взять другое

НужноБерите
Сфотографировать, а не прочитать кодGrCameraCapture
Выбрать кадр из готовой картинкиGrImageCrop
Принять файл со снимком кодаGrFileUpload

Пакет не несёт декодер

Нативный BarcodeDetector есть в Chrome и Edge, но его нет ни в Safari, ни в Firefox — то есть на iPhone его нет вовсе, а сканируют чаще всего именно телефоном. Встроить декодер в пакет нельзя: у него нет ни одной зависимости, и ради одного компонента появилась бы самая тяжёлая — притом у всех, включая тех, кто взял только кроп.

Поэтому нативный путь компонент умеет сам, а остальные браузеры закрывает детектор, который передаёт приложение:

import { BrowserMultiFormatReader } from '@zxing/browser'

const reader = new BrowserMultiFormatReader()

async function detector(source) {
  const canvas = Object.assign(document.createElement('canvas'), {
    width: source.videoWidth,
    height: source.videoHeight,
  })
  canvas.getContext('2d')?.drawImage(source, 0, 0)

  const result = reader.decodeFromCanvas(canvas)

  return result ? [{ value: result.getText(), format: String(result.getBarcodeFormat()) }] : []
}
<GrCodeScanner :detector="detector" />

Проп сильнее нативного пути намеренно: приложение, подключившее свою библиотеку, обычно делает это ради формата, которого нативный детектор не знает.

Читать нечем — и это отдельное состояние: компонент говорит прямо, что браузер не умеет и что нужен detector. Предлагать «включите камеру» в этом случае значило бы отправить пользователя решать не ту задачу.

Один код в кадре — одно событие

Камера отдаёт десятки кадров в секунду, и код распознаётся в каждом. Без фильтра приложение получило бы поток одинаковых событий и оформило бы двадцать заказов вместо одного, поэтому сообщается только то, чего не было в предыдущем кадре.

Символика — часть тождества: одно и то же значение в qr_code и в ean_13 считается разными кодами, потому что и означает разное.

continuous снимает фильтр: на приёмке сканируют одинаковые упаковки подряд, и там повтор — законное второе событие.

Разбор идёт по таймеру, а не по каждому кадру

interval (250 мс по умолчанию) — размен между отзывчивостью и нагревом: разбирать каждый кадр значит держать процессор занятым непрерывно, а телефон в руке греется заметно.

Кадр, который не разобрался, — норма, а не сбой: в объектив попала рука, отражение, размытие. Такой заход молча пропускается, следующий придёт через interval.

Границы

  • нет разбора картинки из файла. Компонент работает с живым потоком; для снимка кода приложение вызывает свой детектор напрямую;
  • нет подсветки и зума. torch и zoom — расширения трека, поддержка у них разная даже внутри Chrome;
  • нет разметки найденного кода на превью. Нативный API отдаёт координаты, но чужой детектор — не обязательно, и рисовать рамку по половине источников значило бы обещать то, чего нет.

Установка

npm i @feugene/granularity-media

Импорт

import { GrCodeScanner } from '@feugene/granularity-media/components/GrCodeScanner'

API

API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.

Примеры 2

Basic

Basic
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrSwitch } from '@feugene/granularity'
import type { GrCodeResult } from '@feugene/granularity-media'

const found = ref<GrCodeResult[]>([])
const continuous = ref(false)

function onDetect(codes: GrCodeResult[]) {
  // Свежие коды приходят пачкой: в кадр попадает и наклейка, и ценник рядом.
  found.value = [...codes, ...found.value].slice(0, 8)
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <GrCodeScanner :continuous="continuous" @detect="onDetect" />

    <div class="showcase-demo-panel grid content-start gap-3 rounded-[var(--gr-radius-lg)] border p-4">
      <GrSwitch v-model="continuous" size="sm">
        Сообщать повторы
      </GrSwitch>

      <p class="showcase-demo-text text-sm">
        Без этого один код в кадре даёт одно событие: камера отдаёт десятки кадров в секунду,
        и приложение оформило бы двадцать заказов вместо одного. На приёмке, где сканируют
        одинаковые упаковки подряд, повтор — законное второе событие.
      </p>

      <template v-if="found.length > 0">
        <p class="showcase-demo-text text-sm">
          Найдено:
        </p>
        <ul class="grid gap-2">
          <li v-for="code in found" :key="`${code.format}:${code.value}`" class="flex items-center gap-2">
            <GrBadge size="sm" tone="neutral">{{ code.format }}</GrBadge>
            <code class="showcase-demo-text text-sm">{{ code.value }}</code>
          </li>
        </ul>
      </template>
      <p v-else class="showcase-demo-text text-sm">
        Наведите камеру на QR или штрихкод — содержимое появится здесь.
      </p>
    </div>
  </div>
</template>

Receiving

Receiving
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrBadge, GrButton, GrEmptyState, GrTable } from '@feugene/granularity'
import type { GrCodeResult } from '@feugene/granularity-media'

/**
 * Приёмка: коробки сканируют подряд, и одинаковых среди них большинство.
 *
 * Здесь видно, зачем `continuous`: без него второй такой же штрихкод не дал бы
 * события вовсе, и кладовщик решил бы, что сканер не сработал. С ним повтор —
 * законная вторая единица товара.
 */
/**
 * Справочник — `Map`, а не объект: ключ штрихкода это строка, и у объекта она
 * превратилась бы в число. Код с ведущим нулём (UPC-A, записанный как EAN-13)
 * потерял бы его молча — и позиция перестала бы находиться.
 */
const CATALOG = new Map([
  ['4600051000057', 'Кофе зерновой, 1 кг'],
  ['5901234123457', 'Бумага А4, 500 л'],
  ['4008400402222', 'Батарейки AA, 4 шт'],
])

interface Position {
  code: string
  title: string
  count: number
}

const positions = ref<Position[]>([])

const total = computed(() => positions.value.reduce((sum, item) => sum + item.count, 0))

function onDetect(codes: GrCodeResult[]) {
  for (const code of codes) {
    const found = positions.value.find(item => item.code === code.value)

    if (found) {
      found.count += 1
      continue
    }

    positions.value = [
      { code: code.value, title: CATALOG.get(code.value) ?? 'Неизвестная позиция', count: 1 },
      ...positions.value,
    ]
  }
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <div class="grid gap-3">
      <GrCodeScanner continuous :formats="['ean_13', 'code_128', 'qr_code']" @detect="onDetect" />
      <p class="showcase-demo-text text-sm">
        Наводите на штрихкоды подряд — повтор увеличивает количество, новый код добавляет строку.
      </p>
    </div>

    <div class="grid content-start gap-3">
      <div class="flex items-center justify-between gap-2">
        <span class="text-[length:var(--gr-text-sm)] leading-[var(--gr-leading-sm)] font-600">
          Принято: {{ total }} шт
        </span>
        <GrButton
          size="xs"
          variant="outline"
          :disabled="positions.length === 0"
          @click="positions = []"
        >
          Очистить
        </GrButton>
      </div>

      <GrTable v-if="positions.length > 0" :column-count="3">
        <template #header>
          <tr>
            <th class="text-left">
              Позиция
            </th>
            <th class="text-left">
              Код
            </th>
            <th class="text-right">
              Кол-во
            </th>
          </tr>
        </template>

        <tr v-for="item in positions" :key="item.code">
          <td>{{ item.title }}</td>
          <td><code class="showcase-demo-text text-xs">{{ item.code }}</code></td>
          <td class="text-right">
            <GrBadge size="sm" :tone="item.count > 1 ? 'success' : 'neutral'">
              {{ item.count }}
            </GrBadge>
          </td>
        </tr>
      </GrTable>

      <GrEmptyState
        v-else
        title="Пока ничего не отсканировано"
        description="Три кода из справочника опознаются по названию, остальные попадут как «Неизвестная позиция» — на складе это отдельная задача приёмщика."
      />
    </div>
  </div>
</template>

Документация компонентаВсе компоненты