2025-2026 學藝QR系統

1. 伺服器與 PHP 環境

必備條件:

  1. Web Server:例如 Apache 或 Nginx + PHP-FPM
  2. PHP 版本:
    • 程式開頭 declare(strict_types=1);,加上用到 Throwable、PDO 等,一般建議 PHP 7.4+ 或 8.x
  3. 已安裝 PDO MySQL extension,並啟用:
    • pdo_mysql 模組要可用,否則 new PDO(...) 會失敗
  4. 此 PHP 檔案所在目錄要允許寫入 log.txt
    • 若目前沒有 log.txtwrite_log() 會直接建立,所以目錄要有寫入權限

2. MySQL 資料庫與帳號

你必須:

  1. 建立 graduation 資料庫(或你改成自己的名稱,記得上面 $DB_NAME 一起改):

				
					-- 建立資料庫(若已存在可略過)
CREATE DATABASE IF NOT EXISTS graduation
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

-- 建議:建立一個專用使用者,而不要用 root
CREATE USER IF NOT EXISTS 'grad_user'@'localhost'
  IDENTIFIED BY '請改成你的密碼';

GRANT SELECT, INSERT, UPDATE
ON graduation.*
TO 'grad_user'@'localhost';

FLUSH PRIVILEGES;

CREATE TABLE IF NOT EXISTS ticket_checkins (
  id            INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  ticket_id     INT UNSIGNED NOT NULL,
  ticket_code   VARCHAR(255) NOT NULL,
  checkin_no    TINYINT UNSIGNED NOT NULL,    -- 第幾次 1 / 2
  checked_in_at DATETIME NOT NULL,
  ip            VARCHAR(45)  NOT NULL,
  user_agent    VARCHAR(255) NOT NULL,

  CONSTRAINT fk_ticket_checkins_ticket
    FOREIGN KEY (ticket_id) REFERENCES tickets(id)
    ON DELETE CASCADE,

  INDEX idx_ticket      (ticket_id),
  INDEX idx_ticket_code (ticket_code)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;
				
			

「單檔」的票務入場系統(掃 QR code 做入場點名),功能包括:

  • 每張票最多可「Check-in 2 次」
  • 使用 MySQL + PDO,對同一張票的更新用交易 + SELECT ... FOR UPDATE 確保「併發安全」
  • 後端有防抖機制(1 秒內重複掃同一張票不會多算次數)
  • 前端也有掃描節流 + 去重(Android 手機連續回呼的問題)
  • 前端用 html5-qrcode 做攝像頭掃描,也支援手動輸入票碼
  • 有成功 / 失敗聲音提示
  • UI 顯示票資料與歷史 2 次入場記錄
				
					<?php
declare(strict_types=1);

/**
 * RCPS Graduation — Ticket Check-in (single file)
 * Features:
 * - Fixed limit: 2 check-ins per ticket
 * - Records check-in #1 and #2 into ticket_checkins
 * - Concurrency safe: transaction + SELECT ... FOR UPDATE
 * - Server-side debounce (prevents Android rapid double scans consuming both entries)
 * - Frontend throttle 0.5s + dedupe + pause/resume scanning
 * - Success/fail sounds (WebAudio)
 * - Responsive UI: tablet/phone landscape shows scanner+result side-by-side
 * - Shows backend JSON message even for HTTP 400/404
 */

// -------------------- CONFIG --------------------
$DB_HOST = 'localhost';
$DB_NAME = 'graduation';
$DB_USER = '****';
$DB_PASS = '****;            // <-- CHANGE THIS
$DB_CHARSET = 'utf8mb4';

$MAX_CHECKINS = 2;

// Server-side debounce: minimum seconds between processing same ticket
$DEBOUNCE_SECONDS = 1;    // 1 second is enough to stop "0.1s spam"

// Optional simple shared secret (disable if empty)
$API_KEY = ''; // e.g. 'change-me'

// Ticket code format: short letters like "rcpsTcVeC"
// If you want to allow digits: '/^[A-Za-z0-9]{6,32}$/'
$TICKET_REGEX = '/^[A-Za-z]{6,32}$/';

// -------------------- helpers --------------------
function write_log(string $message): void {
    $filename = __DIR__ . '/log.txt';
    file_put_contents($filename, $message . PHP_EOL, FILE_APPEND | LOCK_EX);
}

function respond_json(array $payload, int $code = 200): void {
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($payload, JSON_UNESCAPED_UNICODE);
    exit;
}

function base_payload(): array {
    return [
        'success' => false,
        'message' => '',
        'ticket_code' => '',
        'name' => '',
        'engname' => '',
        'class' => '',
        'class_number' => '',
        'count' => 0,
        'left' => 0,
        'checked_in_at' => '',
        'checkins' => [],
    ];
}

function client_ip(): string {
    // For LAN direct access, REMOTE_ADDR is usually correct.
    // If behind a trusted reverse proxy, you can extend this safely.
    return $_SERVER['REMOTE_ADDR'] ?? '';
}

// -------------------- DB CONNECT --------------------
$dsn = "mysql:host={$DB_HOST};dbname={$DB_NAME};charset={$DB_CHARSET}";
$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
    $pdo = new PDO($dsn, $DB_USER, $DB_PASS, $options);
} catch (PDOException $e) {
    http_response_code(500);
    echo 'Database connection error';
    exit;
}

// -------------------- API --------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['qr_data'])) {

    if ($API_KEY !== '') {
        $hdr = $_SERVER['HTTP_X_API_KEY'] ?? '';
        if (!hash_equals($API_KEY, $hdr)) {
            respond_json(['success' => false, 'message' => 'Unauthorized'], 401);
        }
    }

    $scan_time = date('Y-m-d H:i:s');
    $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
    $ip = client_ip();

    $qr_data = trim((string)($_POST['qr_data'] ?? ''));

    $p = base_payload();
    $p['ticket_code'] = $qr_data;

    if ($qr_data === '') {
        write_log("[FAIL ] {$scan_time} | empty qr_data | IP: {$ip} | UA: {$ua}");
        $p['message'] = 'Empty code.';
        respond_json($p, 400);
    }

    // Allow any ticket_code format; only enforce a max length to protect server.
    if (strlen($qr_data) > 255) {
        write_log("[FAIL ] {$scan_time} | too long: " . substr($qr_data, 0, 80) . "... | IP: {$ip} | UA: {$ua}");
        $p['message'] = 'Code too long.';
        respond_json($p, 400);
    }

    try {
        $pdo->beginTransaction();

        // Lock row
        $stmt = $pdo->prepare("
            SELECT id, class, class_number, engname, name, ticket_code,
                   checkin_count, last_checked_in_at, last_scan_at
            FROM tickets
            WHERE ticket_code = ?
            FOR UPDATE
        ");
        $stmt->execute([$qr_data]);
        $ticket = $stmt->fetch();

        if (!$ticket) {
            $pdo->rollBack();
            write_log("[FAIL ] {$scan_time} | not found: {$qr_data} | IP: {$ip} | UA: {$ua}");
            $p['message'] = 'No record found for this ticket code.';
            respond_json($p, 404);
        }

        $ticket_id = (int)$ticket['id'];
        $count = (int)$ticket['checkin_count'];

        // Fill ticket info
        $p['name'] = (string)$ticket['name'];
        $p['engname'] = (string)$ticket['engname'];
        $p['class'] = (string)$ticket['class'];
        $p['class_number'] = (string)$ticket['class_number'];
        $p['count'] = $count;
        $p['left'] = max(0, $MAX_CHECKINS - $count);
        $p['checked_in_at'] = $ticket['last_checked_in_at'] ? (string)$ticket['last_checked_in_at'] : '';

        // Load existing history (show even when failed/limit)
        $logsStmt = $pdo->prepare("
            SELECT checkin_no, checked_in_at
            FROM ticket_checkins
            WHERE ticket_id = ?
            ORDER BY checkin_no ASC
        ");
        $logsStmt->execute([$ticket_id]);
        $logs = $logsStmt->fetchAll();
        $p['checkins'] = array_map(static fn($row) => ['no' => (int)$row['checkin_no'], 'at' => (string)$row['checked_in_at']], $logs);

        // -------- Server-side debounce (hard protection) --------
        // Prevent rapid repeated scans from consuming both entries.
        $last_scan_at = $ticket['last_scan_at'] ? strtotime((string)$ticket['last_scan_at']) : 0;
        $now_ts = time();
        if ($last_scan_at && ($now_ts - $last_scan_at) < $DEBOUNCE_SECONDS) {
            // Update last_scan_at optionally (not required). We'll keep it as is.
            $pdo->commit();
            write_log("[FAST ] {$scan_time} | {$p['name']} | {$qr_data} | debounced | IP: {$ip} | UA: {$ua}");
            $p['message'] = 'Too fast. Please wait 1 second and scan again.';
            $p['success'] = false;
            respond_json($p, 200);
        }

        // If already at limit, still update last_scan_at (so spamming doesn't hammer DB)
        if ($count >= $MAX_CHECKINS) {
            $updScan = $pdo->prepare("UPDATE tickets SET last_scan_at = NOW() WHERE id = ?");
            $updScan->execute([$ticket_id]);

            $pdo->commit();
            write_log("[LIMIT] {$scan_time} | {$p['name']} | {$qr_data} | limit reached ({$count}) | IP: {$ip} | UA: {$ua}");
            $p['message'] = 'Limit reached: already checked in 2 times.';
            $p['success'] = false;
            $p['left'] = 0;
            respond_json($p, 200);
        }

        // Next check-in number
        $checkin_no = $count + 1; // 1 or 2

        // Update ticket + set last_scan_at NOW to block immediate repeats
        $upd = $pdo->prepare("
            UPDATE tickets
            SET checkin_count = checkin_count + 1,
                last_checked_in_at = NOW(),
                last_scan_at = NOW()
            WHERE id = ?
        ");
        $upd->execute([$ticket_id]);

        // Insert log row
        $ins = $pdo->prepare("
            INSERT INTO ticket_checkins (ticket_id, ticket_code, checkin_no, checked_in_at, ip, user_agent)
            VALUES (?, ?, ?, NOW(), ?, ?)
        ");
        $ins->execute([$ticket_id, $qr_data, $checkin_no, $ip, $ua]);

        // Reload for accurate display
        $after = $pdo->prepare("SELECT checkin_count, last_checked_in_at FROM tickets WHERE id = ?");
        $after->execute([$ticket_id]);
        $afterRow = $after->fetch();

        $logsStmt->execute([$ticket_id]);
        $logs2 = $logsStmt->fetchAll();

        $pdo->commit();

        $newCount = (int)$afterRow['checkin_count'];
        $p['success'] = true;
        $p['count'] = $newCount;
        $p['left'] = max(0, $MAX_CHECKINS - $newCount);
        $p['checked_in_at'] = (string)$afterRow['last_checked_in_at'];
        $p['checkins'] = array_map(static fn($row) => ['no' => (int)$row['checkin_no'], 'at' => (string)$row['checked_in_at']], $logs2);

        $p['message'] = $newCount === 1
            ? 'Check-in OK. (1/2)'
            : 'Check-in OK. (2/2) Limit reached.';

        write_log("[PASS ] {$scan_time} | {$p['name']} | {$qr_data} | checkin #{$newCount} | IP: {$ip} | UA: {$ua}");
        respond_json($p, 200);

    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        write_log("[ERROR] {$scan_time} | {$qr_data} | {$e->getMessage()} | IP: {$ip} | UA: {$ua}");
        $p['message'] = 'Server error. Please try again.';
        respond_json($p, 500);
    }
}

?>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>2025-2026 Talent Quest & Prize-giving Ceremony — Ticket Check-in</title>
  <script src="https://unpkg.com/html5-qrcode" type="text/javascript"></script>
  <style>
    :root{
      --bg: #0b1220;
      --panel: rgba(255,255,255,.06);
      --text: rgba(255,255,255,.92);
      --muted: rgba(255,255,255,.68);
      --border: rgba(255,255,255,.12);
      --shadow: 0 16px 50px rgba(0,0,0,.45);
    }
    *{box-sizing:border-box}
    body{
      margin:0;
      font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Liberation Sans", sans-serif;
      background: radial-gradient(1200px 600px at 20% 0%, rgba(96,165,250,.25), transparent 55%),
                  radial-gradient(900px 500px at 100% 20%, rgba(45,212,191,.18), transparent 55%),
                  var(--bg);
      color: var(--text);
      min-height: 100vh;
      display:flex;
      align-items:center;
      justify-content:center;
      padding: 18px;
    }
    .wrap{ width:min(980px, 100%); }
    .topbar{
      display:flex;
      align-items:flex-end;
      justify-content:space-between;
      gap:12px;
      margin-bottom: 14px;
    }
    .brand h1{ margin:0; font-size: 18px; letter-spacing:.3px; font-weight: 800; }
    .brand p{ margin:6px 0 0; color: var(--muted); font-size: 13px; }
    .badge{
      font-size:12px;
      padding:6px 10px;
      border-radius: 999px;
      border: 1px solid var(--border);
      color: var(--muted);
      background: rgba(255,255,255,.06);
      white-space: nowrap;
    }

    .grid{
      display:grid;
      grid-template-columns: 1.05fr .95fr;
      gap: 14px;
      align-items:start;
    }
    @media (max-width: 640px){
      .grid{ grid-template-columns: 1fr; }
    }
    @media (max-width: 900px) and (orientation: landscape){
      .grid{ grid-template-columns: 1.05fr .95fr; }
    }

    .card{
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: 16px;
      box-shadow: var(--shadow);
      overflow:hidden;
    }
    .card-h{
      padding: 14px 16px;
      background: linear-gradient(180deg, rgba(255,255,255,.08), transparent);
      border-bottom: 1px solid var(--border);
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap: 10px;
      flex-wrap: wrap;
    }
    .card-h .title{ font-weight:800; font-size: 14px; letter-spacing:.2px; }

    @media (min-width: 641px){
      .grid > section.card:last-child{
        position: sticky;
        top: 14px;
        align-self: start;
      }
    }

    #reader{
      width: 100%;
      max-width: 520px;
      margin: 0 auto;
      border-radius: 14px;
      overflow:hidden;
    }

    .row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
    .btn{
      appearance:none;
      border: 1px solid var(--border);
      background: rgba(255,255,255,.06);
      color: var(--text);
      padding: 10px 12px;
      border-radius: 12px;
      cursor:pointer;
      font-weight: 750;
      font-size: 13px;
    }
    .btn:hover{ background: rgba(255,255,255,.10); }
    .btn.primary{ border-color: rgba(96,165,250,.35); background: rgba(96,165,250,.14); }

    .input{
      flex:1;
      min-width: 220px;
      border: 1px solid var(--border);
      background: rgba(0,0,0,.20);
      color: var(--text);
      padding: 10px 12px;
      border-radius: 12px;
      outline: none;
      font-size: 14px;
    }

    .card-b{ padding: 14px 16px 16px; }

    .status{
      border: 1px solid var(--border);
      background: rgba(0,0,0,.18);
      border-radius: 14px;
      padding: 14px;
    }
    .status-head{
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:10px;
      margin-bottom: 10px;
      flex-wrap: wrap;
    }
    .pill{
      display:inline-flex;
      align-items:center;
      gap:8px;
      font-size: 12px;
      padding: 6px 10px;
      border-radius: 999px;
      border: 1px solid var(--border);
      background: rgba(255,255,255,.06);
      color: var(--muted);
      white-space: nowrap;
    }
    .pill.pass{ border-color: rgba(45,212,191,.35); color: rgba(45,212,191,.95); background: rgba(45,212,191,.10); }
    .pill.fail{ border-color: rgba(251,113,133,.35); color: rgba(251,113,133,.95); background: rgba(251,113,133,.10); }
    .pill.warn{ border-color: rgba(251,191,36,.40); color: rgba(251,191,36,.95); background: rgba(251,191,36,.10); }

    .msg{ font-weight: 850; margin: 0; font-size: 15px; letter-spacing: .2px; }
    .sub{ margin: 6px 0 0; color: var(--muted); font-size: 13px; line-height: 1.4; }

    .info{
      margin-top: 12px;
      display:grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
    }
    @media (max-width: 520px){ .info{ grid-template-columns: 1fr; } }

    .kv{
      border: 1px solid var(--border);
      background: rgba(255,255,255,.05);
      border-radius: 14px;
      padding: 10px 12px;
    }
    .kv .k{ font-size: 11px; color: var(--muted); letter-spacing: .3px; text-transform: uppercase; }
    .kv .v{ margin-top: 4px; font-size: 14px; font-weight: 780; overflow:hidden; text-overflow: ellipsis; }

    .timeline{
      margin-top: 12px;
      border: 1px solid var(--border);
      background: rgba(255,255,255,.05);
      border-radius: 14px;
      padding: 10px 12px;
    }
    .timeline h4{ margin:0 0 8px; font-size: 12px; color: var(--muted); letter-spacing:.3px; text-transform: uppercase;}
    .trow{
      display:flex;
      justify-content:space-between;
      gap:10px;
      padding: 8px 0;
      border-top: 1px dashed rgba(255,255,255,.12);
    }
    .trow:first-of-type{ border-top: none; }
    .trow .left{ font-weight: 850; }
    .trow .right{ color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 12px; }

    .hint{ color: var(--muted); font-size: 12px; margin-top: 10px; line-height: 1.35; }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="topbar">
      <div class="brand">
        <h1>2025-2026 Talent Quest & Prize-giving Ceremony — Ticket Check-in</h1>
        <p>Scan QR code or use manual entry. Fixed limit: 2 check-ins per ticket.</p>
      </div>
      <span class="badge" id="connBadge">Ready</span>
    </div>

    <div class="grid">
      <section class="card">
        <div class="card-h">
          <div class="title">Scanner</div>
          <div class="row">
            <button class="btn primary" id="btnResume" type="button">Resume Scan</button>
            <button class="btn" id="btnManualToggle" type="button">Manual Entry</button>
          </div>
        </div>
        <div class="card-b">
          <div id="reader"></div>
          <div id="manualBox" style="display:none; margin-top: 12px;">
            <div class="row">
              <input class="input" id="manualInput" type="text" placeholder="Enter ticket code (e.g. rcpsTcVeC)" autocomplete="off" />
              <button class="btn primary" id="btnManualSubmit" type="button">Check</button>
              <button class="btn" id="btnManualClear" type="button">Clear</button>
            </div>
            <div class="hint">Tip: on mobile, sound requires a user tap first (Resume / Manual Entry).</div>
          </div>
        </div>
      </section>

      <section class="card">
        <div class="card-h">
          <div class="title">Result</div>
          <span class="badge" id="scanState">Idle</span>
        </div>
        <div class="card-b">
          <div class="status">
            <div class="status-head">
              <span class="pill" id="pill">Waiting</span>
              <span class="pill" id="pillMeta">0/2</span>
            </div>
            <p class="msg" id="msg">Ready to scan.</p>
            <p class="sub" id="sub">Place a QR code inside the frame.</p>

            <div class="info" id="infoGrid" style="display:none;">
              <div class="kv"><div class="k">Chinese Name</div><div class="v" id="vName">-</div></div>
              <div class="kv"><div class="k">English Name</div><div class="v" id="vEng">-</div></div>
              <div class="kv"><div class="k">Class</div><div class="v" id="vClass">-</div></div>
              <div class="kv"><div class="k">Class No.</div><div class="v" id="vNo">-</div></div>
              <div class="kv"><div class="k">Ticket Code</div><div class="v" id="vCode">-</div></div>
              <div class="kv"><div class="k">Last Check-in</div><div class="v" id="vLast">-</div></div>
            </div>

            <div class="timeline" id="timeline" style="display:none;">
              <h4>Check-in History (max 2)</h4>
              <div id="timelineRows"></div>
            </div>

            <div class="hint" id="hintBox" style="display:none;"></div>
          </div>
        </div>
      </section>
    </div>
  </div>

<script>
  const $ = (id) => document.getElementById(id);
  const setText = (id, text) => { $(id).textContent = (text ?? '') === '' ? '-' : String(text); };

  // Scan control
  let scanning = true;
  const COOLDOWN_MS = 500;     // requested: 0.5 sec per request
  let nextAllowedAt = 0;

  // Dedupe: same code repeated quickly (Android Chrome)
  let lastCode = "";
  let lastCodeAt = 0;
  const DEDUPE_MS = 900;

  // Audio
  let audioCtx = null;
  function initAudio() {
    if (audioCtx) return;
    try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch {}
  }
  function beep(type="ok") {
    if (!audioCtx) return;
    const t0 = audioCtx.currentTime;
    const o = audioCtx.createOscillator();
    const g = audioCtx.createGain();
    o.connect(g); g.connect(audioCtx.destination);

    if (type === "ok") {
      o.type = "sine";
      o.frequency.setValueAtTime(880, t0);
      g.gain.setValueAtTime(0.0001, t0);
      g.gain.exponentialRampToValueAtTime(0.14, t0 + 0.01);
      g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.12);
      o.start(t0); o.stop(t0 + 0.16);
    } else {
      o.type = "square";
      o.frequency.setValueAtTime(220, t0);
      g.gain.setValueAtTime(0.0001, t0);
      g.gain.exponentialRampToValueAtTime(0.16, t0 + 0.01);
      g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
      o.start(t0); o.stop(t0 + 0.22);
    }
  }

  function setPill(status, meta, kind) {
    const p = $("pill");
    const m = $("pillMeta");
    p.className = "pill " + (kind || "");
    p.textContent = status;
    m.textContent = meta;
    m.className = "pill";
  }
  function setScanState(text) { $("scanState").textContent = text; }

  function resetUI() {
    setPill("Waiting", "0/2", "");
    $("msg").textContent = "Ready to scan.";
    $("sub").textContent = "Place a QR code inside the frame.";
    $("infoGrid").style.display = "none";
    $("timeline").style.display = "none";
    $("hintBox").style.display = "none";
    setScanState("Idle");
  }

  function applyStatusStyle(data) {
  const count = Number(data.count ?? 0);
  const msg = String(data.message ?? "").toLowerCase();

  if (data.success) {
    setPill("PASS", `${count}/2`, "pass");
    setScanState("Accepted");
    return;
  }

  if (msg.includes("too fast")) {
    setPill("HOLD", `${count}/2`, "warn");
    setScanState("Hold still");
    return;
  }

  if (count >= 2) {
    setPill("LIMIT", `${count}/2`, "warn");
    setScanState("Limit Reached");
    return;
  }

  setPill("FAIL", `${count}/2`, "fail");
  setScanState("Rejected");
}

  function showResult(data) {
    $("infoGrid").style.display = "grid";
    $("timeline").style.display = "block";
    $("hintBox").style.display = "block";

    setText("vName", data.name);
    setText("vEng", data.engname);
    setText("vClass", data.class);
    setText("vNo", data.class_number);
    setText("vCode", data.ticket_code);
    setText("vLast", data.checked_in_at);

    const count = Number(data.count ?? 0);
    const left = Number(data.left ?? 0);

    const rows = $("timelineRows");
    rows.textContent = "";
    const checkins = Array.isArray(data.checkins) ? data.checkins : [];
    if (checkins.length === 0) {
      const div = document.createElement("div");
      div.className = "trow";
      const l = document.createElement("div");
      l.className = "left";
      l.textContent = "No check-in yet";
      const r = document.createElement("div");
      r.className = "right";
      r.textContent = "-";
      div.appendChild(l); div.appendChild(r);
      rows.appendChild(div);
    } else {
      checkins.forEach(ci => {
        const div = document.createElement("div");
        div.className = "trow";
        const l = document.createElement("div");
        l.className = "left";
        l.textContent = "Check-in #" + (ci.no ?? "?");
        const r = document.createElement("div");
        r.className = "right";
        r.textContent = ci.at ?? "-";
        div.appendChild(l); div.appendChild(r);
        rows.appendChild(div);
      });
    }

    $("msg").textContent = data.message || "";
    if (data.success) {
      $("sub").textContent = `Remaining entries: ${left}.`;
    } else {
      const msg = (data.message || "").toLowerCase();
      if (msg.includes("no record")) $("sub").textContent = "This code does not exist in the database.";
      else if (msg.includes("too fast")) $("sub").textContent = "Hold still and try again in a moment.";
      else if (count >= 2) $("sub").textContent = "This ticket has reached the limit (2/2).";
      else $("sub").textContent = "Verification failed. Please try again.";
    }
    $("hintBox").textContent = "Tip: Keep the QR steady and centered for best results.";
  }

  async function sendToBackend(code) {
    setScanState("Verifying...");

    // Pause scanner while verifying (prevents repeated callbacks on Android)
    try { scanner && scanner.pause && scanner.pause(true); } catch {}

    const body = new URLSearchParams({ qr_data: code });

    try {
      const resp = await fetch("", {
        method: "POST",
        headers: { "Accept": "application/json" },
        body
      });

      let data = null;
      try { data = await resp.json(); } catch {}

      if (!resp.ok) {
        if (data && typeof data === "object") {
          data.success = false;
          applyStatusStyle(data);
          showResult(data);
          beep("bad");
          return;
        }
        throw new Error("HTTP " + resp.status);
      }

      applyStatusStyle(data);
      showResult(data);
      beep(data && data.success ? "ok" : "bad");

    } catch (e) {
      setPill("ERROR", "-/2", "fail");
      $("msg").textContent = "Server error.";
      $("sub").textContent = "Please check backend logs and try again.";
      $("infoGrid").style.display = "none";
      $("timeline").style.display = "none";
      $("hintBox").style.display = "none";
      setScanState("Error");
      beep("bad");
    } finally {
      setTimeout(() => {
        try { scanner && scanner.resume && scanner.resume(); } catch {}
        scanning = true;
      }, COOLDOWN_MS);
    }
  }

  function onScanSuccess(decodedText) {
    const now = Date.now();

    // Dedupe
    if (decodedText === lastCode && (now - lastCodeAt) < DEDUPE_MS) return;
    lastCode = decodedText;
    lastCodeAt = now;

    // Throttle
    if (!scanning) return;
    if (now < nextAllowedAt) return;
    nextAllowedAt = now + COOLDOWN_MS;

    scanning = false;
    resetUI();
    setScanState("Scanned");
    $("msg").textContent = "Code scanned. Verifying...";
    $("sub").textContent = decodedText;
    sendToBackend(decodedText);
  }

  let scanner = null;

  function startScanner() {
    // Hard guard: prevent double initialization (can cause double callbacks)
    if (scanner) return;

    scanner = new Html5QrcodeScanner(
      "reader",
      // Lower fps helps Android reduce repeated decodes
      { fps: 6, qrbox: { width: 260, height: 260 } },
      false
    );
    scanner.render(onScanSuccess);
  }

  // UI events (also unlock audio)
  $("btnManualToggle").addEventListener("click", () => {
    initAudio();
    const box = $("manualBox");
    box.style.display = (box.style.display === "none") ? "block" : "none";
    if (box.style.display === "block") setTimeout(() => $("manualInput").focus(), 50);
  });

  $("btnManualSubmit").addEventListener("click", () => {
    initAudio();
    const v = $("manualInput").value.trim();
    if (!v) return;

    const now = Date.now();
    if (!scanning) return;
    if (now < nextAllowedAt) return;
    nextAllowedAt = now + COOLDOWN_MS;

    scanning = false;
    resetUI();
    $("msg").textContent = "Manual check. Verifying...";
    $("sub").textContent = v;
    sendToBackend(v);
  });

  $("btnManualClear").addEventListener("click", () => {
    initAudio();
    $("manualInput").value = "";
    $("manualInput").focus();
  });

  $("btnResume").addEventListener("click", () => {
    initAudio();
    scanning = true;
    nextAllowedAt = 0;
    resetUI();
  });

  // init
  resetUI();
  startScanner();
</script>
</body>
</html>
				
			
  • 與 DB 連線相關的變數
  • $MAX_CHECKINS:每張票最多幾次 check-in(目前是 2)
  • $DEBOUNCE_SECONDS:伺服器端防抖(同一票 1 秒內重複掃不會再次扣額度)
  • $API_KEY:如果你要讓只有帶特定 HTTP Header 的人才能呼叫 API,就在這裡設;目前是空字串 = 沒啟用
  • $TICKET_REGEX:原本用來驗證票碼格式,現在註解說明改為「只限制長度」而不檢查格式,實際 code 也沒用到這個 regex
				
					INSERT INTO tickets (ticket_code, name, engname, class, class_number)
VALUES
('rcpsTcVeC', '張三', 'ZHANG SAN', '6A', '01'),
('AbCdEfGh', '李四', 'LEE SI', '6B', '02');