二维码扫描优化,但是开票页面必须在微信浏览器中打开。
该项目暂停。
This commit is contained in:
parent
0652323b85
commit
7e2f4ec210
9
_build_apk.sh
Normal file
9
_build_apk.sh
Normal file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd /mnt/d/workbench/tauri-android-invoice
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
export NDK_HOME="$(ls -d $HOME/android-sdk/ndk/* 2>/dev/null | head -1)"
|
||||
export JAVA_HOME="${JAVA_HOME:-/home/cheney/.local/share/mise/installs/java/17.0.2}"
|
||||
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||
echo "NDK_HOME=$NDK_HOME"
|
||||
npm run tauri android android-studio-script --release --target aarch64 2>&1 | tail -n 15
|
||||
244
qr-test.html
Normal file
244
qr-test.html
Normal file
@ -0,0 +1,244 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>QR 诊断测试</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f5f5f5; }
|
||||
h2 { margin: 0 0 8px; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
||||
input[type=file] { margin: 4px 0 12px; }
|
||||
img, canvas { max-width: 100%; border: 1px solid #ccc; border-radius: 6px; margin: 6px 0; }
|
||||
button { padding: 8px 14px; border: none; border-radius: 6px; background: #1976d2; color: #fff; font-size: 14px; cursor: pointer; margin-right: 8px; }
|
||||
.result { margin-top: 10px; padding: 10px; border-radius: 6px; font-size: 13px; white-space: pre-wrap; word-break: break-all; }
|
||||
.ok { background: #e8f5e9; color: #2e7d32; }
|
||||
.fail { background: #ffebee; color: #c62828; }
|
||||
.info { background: #e3f2fd; color: #1565c0; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.col { flex: 1; min-width: 280px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>jsQR 二维码诊断工具</h2>
|
||||
|
||||
<div class="card">
|
||||
<input type="file" id="file" accept="image/*" />
|
||||
<button onclick="testAll()">全面诊断</button>
|
||||
<button onclick="testFull()">原始尺寸识别</button>
|
||||
<button onclick="testHalf()">50%缩放识别</button>
|
||||
<button onclick="testQuarter()">25%缩放识别</button>
|
||||
<button onclick="testCenter()">中心裁剪识别</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3>原图</h3>
|
||||
<img id="preview" style="display:none" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3>当前处理结果</h3>
|
||||
<canvas id="resultCanvas" style="display:none"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="diag" style="display:none">
|
||||
<h3>诊断日志</h3>
|
||||
<div id="log"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const fileInput = document.getElementById('file');
|
||||
const preview = document.getElementById('preview');
|
||||
const resultCanvas = document.getElementById('resultCanvas');
|
||||
const logDiv = document.getElementById('log');
|
||||
const diagDiv = document.getElementById('diag');
|
||||
|
||||
let cachedImageData = null;
|
||||
|
||||
/** 获取图片的 ImageData 缓存。@returns {Promise<ImageData>} */
|
||||
async function getImageData() {
|
||||
if (cachedImageData) return cachedImageData;
|
||||
const file = fileInput.files[0];
|
||||
if (!file) throw new Error('请先选择图片');
|
||||
const url = URL.createObjectURL(file);
|
||||
preview.src = url;
|
||||
preview.style.display = 'block';
|
||||
const img = await loadImage(url);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.width; c.height = img.height;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
cachedImageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||
URL.revokeObjectURL(url);
|
||||
return cachedImageData;
|
||||
}
|
||||
|
||||
/** 加载图片。@param {string} url @returns {Promise<HTMLImageElement>} */
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('图片加载失败'));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试 JSQR 识别并返回结果。
|
||||
* @param {ImageData} imageData 图片数据。
|
||||
* @param {string} label 测试标签。
|
||||
* @returns {{ok:boolean, data?:string, msg:string}}
|
||||
*/
|
||||
function tryDecode(imageData, label) {
|
||||
const start = performance.now();
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: 'attemptBoth',
|
||||
});
|
||||
const ms = (performance.now() - start).toFixed(0);
|
||||
if (code) {
|
||||
return { ok: true, data: code.data, msg: `${label}: 成功 (${ms}ms) → ${code.data}` };
|
||||
}
|
||||
return { ok: false, msg: `${label}: 失败 (${ms}ms) — ${imageData.width}x${imageData.height}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放 ImageData 到指定倍数。
|
||||
* @param {ImageData} src 源数据。
|
||||
* @param {number} scale 缩放倍率 (0.5=一半, 0.25=四分之一)。
|
||||
* @returns {ImageData}
|
||||
*/
|
||||
function scaleImageData(src, scale) {
|
||||
const w = Math.floor(src.width * scale);
|
||||
const h = Math.floor(src.height * scale);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const ctx = c.getContext('2d');
|
||||
const temp = document.createElement('canvas');
|
||||
temp.width = src.width; temp.height = src.height;
|
||||
temp.getContext('2d').putImageData(src, 0, 0);
|
||||
ctx.drawImage(temp, 0, 0, w, h);
|
||||
return ctx.getImageData(0, 0, w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪图片中心区域(可能包含二维码的核心部分)。
|
||||
* @param {ImageData} src 源数据。
|
||||
* @returns {ImageData}
|
||||
*/
|
||||
function cropCenter(src) {
|
||||
const cw = Math.floor(src.width * 0.6);
|
||||
const ch = Math.floor(src.height * 0.6);
|
||||
const ox = Math.floor((src.width - cw) / 2);
|
||||
const oy = Math.floor((src.height - ch) / 2);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = cw; c.height = ch;
|
||||
const ctx = c.getContext('2d');
|
||||
const temp = document.createElement('canvas');
|
||||
temp.width = src.width; temp.height = src.height;
|
||||
temp.getContext('2d').putImageData(src, 0, 0);
|
||||
ctx.drawImage(temp, ox, oy, cw, ch, 0, 0, cw, ch);
|
||||
return ctx.getImageData(0, 0, cw, ch);
|
||||
}
|
||||
|
||||
/** 显示处理后的图片到 canvas。@param {ImageData} data */
|
||||
function showResult(data) {
|
||||
resultCanvas.width = data.width;
|
||||
resultCanvas.height = data.height;
|
||||
resultCanvas.getContext('2d').putImageData(data, 0, 0);
|
||||
resultCanvas.style.display = 'block';
|
||||
}
|
||||
|
||||
/** 添加日志。@param {string} msg @param {string} cls */
|
||||
function addLog(msg, cls) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'result ' + cls;
|
||||
div.textContent = msg;
|
||||
logDiv.appendChild(div);
|
||||
}
|
||||
|
||||
/** 原始尺寸识别。@returns {Promise<void>} */
|
||||
async function testFull() {
|
||||
cachedImageData = null;
|
||||
diagDiv.style.display = 'block';
|
||||
logDiv.innerHTML = '';
|
||||
try {
|
||||
const data = await getImageData();
|
||||
addLog(`图片原始尺寸: ${data.width}x${data.height}`, 'info');
|
||||
showResult(data);
|
||||
const r = tryDecode(data, '原始尺寸');
|
||||
addLog(r.msg, r.ok ? 'ok' : 'fail');
|
||||
} catch (e) { addLog('错误: ' + e.message, 'fail'); }
|
||||
}
|
||||
|
||||
/** 50% 缩放识别。@returns {Promise<void>} */
|
||||
async function testHalf() {
|
||||
try {
|
||||
const data = await getImageData();
|
||||
const half = scaleImageData(data, 0.5);
|
||||
showResult(half);
|
||||
addLog(`缩放至 50%: ${half.width}x${half.height}`, 'info');
|
||||
const r = tryDecode(half, '50%缩放');
|
||||
addLog(r.msg, r.ok ? 'ok' : 'fail');
|
||||
} catch (e) { addLog('错误: ' + e.message, 'fail'); }
|
||||
}
|
||||
|
||||
/** 25% 缩放识别。@returns {Promise<void>} */
|
||||
async function testQuarter() {
|
||||
try {
|
||||
const data = await getImageData();
|
||||
const quarter = scaleImageData(data, 0.25);
|
||||
showResult(quarter);
|
||||
addLog(`缩放至 25%: ${quarter.width}x${quarter.height}`, 'info');
|
||||
const r = tryDecode(quarter, '25%缩放');
|
||||
addLog(r.msg, r.ok ? 'ok' : 'fail');
|
||||
} catch (e) { addLog('错误: ' + e.message, 'fail'); }
|
||||
}
|
||||
|
||||
/** 中心裁剪识别。@returns {Promise<void>} */
|
||||
async function testCenter() {
|
||||
try {
|
||||
const data = await getImageData();
|
||||
const crop = cropCenter(data);
|
||||
showResult(crop);
|
||||
addLog(`中心裁剪: ${crop.width}x${crop.height}`, 'info');
|
||||
const r = tryDecode(crop, '中心裁剪');
|
||||
addLog(r.msg, r.ok ? 'ok' : 'fail');
|
||||
} catch (e) { addLog('错误: ' + e.message, 'fail'); }
|
||||
}
|
||||
|
||||
/** 全面诊断:依次尝试多种策略。@returns {Promise<void>} */
|
||||
async function testAll() {
|
||||
diagDiv.style.display = 'block';
|
||||
logDiv.innerHTML = '';
|
||||
try {
|
||||
const data = await getImageData();
|
||||
addLog(`图片原始尺寸: ${data.width}x${data.height} (${(data.width * data.height / 1000000).toFixed(1)}MP)`, 'info');
|
||||
|
||||
const tests = [
|
||||
{ fn: () => tryDecode(data, '原始尺寸'), show: () => showResult(data) },
|
||||
{ fn: () => tryDecode(scaleImageData(data, 0.5), '50%缩放'), show: () => showResult(scaleImageData(data, 0.5)) },
|
||||
{ fn: () => tryDecode(scaleImageData(data, 0.25), '25%缩放'), show: () => showResult(scaleImageData(data, 0.25)) },
|
||||
{ fn: () => tryDecode(scaleImageData(data, 0.125), '12.5%缩放'), show: () => showResult(scaleImageData(data, 0.125)) },
|
||||
{ fn: () => tryDecode(cropCenter(data), '中心60%裁剪'), show: () => showResult(cropCenter(data)) },
|
||||
{ fn: () => tryDecode(scaleImageData(cropCenter(data), 0.5), '中心裁剪+50%缩放'), show: () => showResult(scaleImageData(cropCenter(data), 0.5)) },
|
||||
];
|
||||
|
||||
for (const t of tests) {
|
||||
t.show();
|
||||
const r = t.fn();
|
||||
addLog(r.msg, r.ok ? 'ok' : 'fail');
|
||||
if (r.ok) {
|
||||
addLog('🎉 识别成功!内容: ' + r.data, 'ok');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
addLog('错误: ' + e.message, 'fail');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,7 +1,10 @@
|
||||
// 二维码解码核心逻辑:与 UI、相机、Tauri 解耦,便于单元测试。
|
||||
// 二维码解码核心逻辑:与 UI、相机、Tauri 解耦,便于单元测试。
|
||||
// 相机扫码走原生插件,图片扫码走纯 JS 的 jsQR,桌面端也可用。
|
||||
import jsQR from "jsqr";
|
||||
|
||||
/** 多级缩放倍率,由大到小依次尝试,命中即停止。 */
|
||||
const SCALE_TRIES = [1, 0.5, 0.25, 0.125];
|
||||
|
||||
/**
|
||||
* 校验扫码内容是否为有效网址(仅接受 http/https)。
|
||||
* @param {unknown} content 扫码得到的原始内容。
|
||||
@ -14,8 +17,56 @@ export function extractUrl(content) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从像素数据中解码二维码。
|
||||
* @param {{data: Uint8ClampedArray, width: number, height: number}} imageData
|
||||
* 纯像素运算将 ImageData 等比缩放至指定倍率(不依赖 Canvas API,可测试)。
|
||||
* 对每个输出像素取对应输入区域的平均值,适合缩小场景。
|
||||
* @param {{data:Uint8ClampedArray,width:number,height:number}} src 源像素数据。
|
||||
* @param {number} scale 缩放倍率(0.5 = 一半,0.25 = 四分之一)。
|
||||
* @returns {{data:Uint8ClampedArray,width:number,height:number}} 缩放后的像素数据。
|
||||
*/
|
||||
export function scaleImageData(src, scale) {
|
||||
if (scale >= 1) return src;
|
||||
const sw = src.width;
|
||||
const sh = src.height;
|
||||
const dw = Math.max(1, Math.floor(sw * scale));
|
||||
const dh = Math.max(1, Math.floor(sh * scale));
|
||||
const srcData = src.data;
|
||||
const out = new Uint8ClampedArray(dw * dh * 4);
|
||||
const xRatio = sw / dw;
|
||||
const yRatio = sh / dh;
|
||||
for (let dy = 0; dy < dh; dy += 1) {
|
||||
const sy0 = Math.floor(dy * yRatio);
|
||||
const sy1 = Math.min(sh, Math.floor((dy + 1) * yRatio));
|
||||
for (let dx = 0; dx < dw; dx += 1) {
|
||||
const sx0 = Math.floor(dx * xRatio);
|
||||
const sx1 = Math.min(sw, Math.floor((dx + 1) * xRatio));
|
||||
let r = 0, g = 0, b = 0, a = 0, count = 0;
|
||||
for (let sy = sy0; sy < sy1; sy += 1) {
|
||||
for (let sx = sx0; sx < sx1; sx += 1) {
|
||||
const idx = (sy * sw + sx) * 4;
|
||||
r += srcData[idx];
|
||||
g += srcData[idx + 1];
|
||||
b += srcData[idx + 2];
|
||||
a += srcData[idx + 3];
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
const oi = (dy * dw + dx) * 4;
|
||||
if (count > 0) {
|
||||
out[oi] = Math.round(r / count);
|
||||
out[oi + 1] = Math.round(g / count);
|
||||
out[oi + 2] = Math.round(b / count);
|
||||
out[oi + 3] = Math.round(a / count);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data: out, width: dw, height: dh };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从像素数据中解码二维码(多级缩放回退)。
|
||||
* 对手机拍摄的高分辨率照片,二维码可能只占很小区域,jsQR 全图扫描容易失败。
|
||||
* 本函数先尝试原始尺寸,失败后依次等比缩小再试,大幅提升实际场景识别率。
|
||||
* @param {{data:Uint8ClampedArray,width:number,height:number}} imageData
|
||||
* 画布像素数据(RGBA)。
|
||||
* @returns {string|null} 解码到的文本;未识别到返回 null。
|
||||
*/
|
||||
@ -23,11 +74,15 @@ export function decodeQrFromImageData(imageData) {
|
||||
if (!imageData || !imageData.data || !imageData.width || !imageData.height) {
|
||||
return null;
|
||||
}
|
||||
// attemptBoth 允许识别反色(浅底深码或深底浅码)二维码,提升清晰码的识别率。
|
||||
const result = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: "attemptBoth",
|
||||
});
|
||||
return result ? result.data : null;
|
||||
const opts = { inversionAttempts: "attemptBoth" };
|
||||
let current = imageData;
|
||||
for (const scale of SCALE_TRIES) {
|
||||
const result = jsQR(current.data, current.width, current.height, opts);
|
||||
if (result) return result.data;
|
||||
if (scale === SCALE_TRIES[SCALE_TRIES.length - 1]) break;
|
||||
current = scaleImageData(current, 0.5); // 每次缩一半
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
@ -11,8 +11,7 @@ import {
|
||||
} from "@tauri-apps/plugin-barcode-scanner";
|
||||
import { extractUrl, decodeQrFromFile } from "../qrscan.js";
|
||||
|
||||
// 扫码页:既支持相机扫码(原生插件,仅移动端),也支持从图片扫码(纯 JS,全平台)。
|
||||
// Android 上相机预览渲染在 WebView 下层,扫码期间需把页面背景透明化才能看到画面。
|
||||
// 扫码页:相机扫码走原生插件全屏模式(最可靠),图片扫码走 jsQR 多级缩放。
|
||||
const router = useRouter();
|
||||
const error = ref("");
|
||||
const denied = ref(false);
|
||||
@ -35,20 +34,6 @@ function toMessage(e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换扫码透明态:透明时可看到 WebView 下层的相机预览。
|
||||
* 同时作用于 html/body 与 #app 挂载点,避免残留不透明背景挡住画面。
|
||||
* @param {boolean} on 是否进入透明态。
|
||||
* @returns {void}
|
||||
*/
|
||||
function setTransparent(on) {
|
||||
const val = on ? "transparent" : "";
|
||||
document.documentElement.style.background = val;
|
||||
document.body.style.background = val;
|
||||
const app = document.getElementById("app");
|
||||
if (app) app.style.background = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中网址后跳转到 WebForm 页;非网址则提示。
|
||||
* @param {string} content 扫码/解码得到的原始文本。
|
||||
@ -77,7 +62,11 @@ async function ensurePermission() {
|
||||
return state === "granted";
|
||||
}
|
||||
|
||||
/** 启动相机扫码,成功后携带 URL 跳转到 WebForm 页。@returns {Promise<void>} */
|
||||
/**
|
||||
* 启动相机扫码(原生全屏模式,最可靠,无需处理透明背景)。
|
||||
* 成功后携带 URL 跳转到 WebForm 页。
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function startScan() {
|
||||
error.value = "";
|
||||
if (scanning.value) return;
|
||||
@ -88,18 +77,15 @@ async function startScan() {
|
||||
return;
|
||||
}
|
||||
scanning.value = true;
|
||||
setTransparent(true);
|
||||
const res = await scan({
|
||||
windowed: true,
|
||||
windowed: false,
|
||||
formats: [Format.QRCode],
|
||||
cameraDirection: "back",
|
||||
});
|
||||
handleContent(res.content);
|
||||
} catch (e) {
|
||||
error.value = "扫码失败或已取消:" + toMessage(e);
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
setTransparent(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,7 +97,6 @@ async function stopScan() {
|
||||
/* 忽略取消异常 */
|
||||
}
|
||||
scanning.value = false;
|
||||
setTransparent(false);
|
||||
}
|
||||
|
||||
/** 触发图片选择框。@returns {void} */
|
||||
@ -121,13 +106,13 @@ function pickImage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户选择的图片解码二维码。
|
||||
* 从用户选择的图片解码二维码(jsQR 多级缩放回退)。
|
||||
* @param {Event} evt input change 事件。
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function onFileChange(evt) {
|
||||
const file = evt.target.files && evt.target.files[0];
|
||||
evt.target.value = ""; // 允许连续选择同一文件
|
||||
evt.target.value = "";
|
||||
if (!file) return;
|
||||
error.value = "";
|
||||
decoding.value = true;
|
||||
@ -145,22 +130,24 @@ async function onFileChange(evt) {
|
||||
}
|
||||
}
|
||||
|
||||
// 离开页面时确保关闭相机并复原背景,避免占用与残留透明态。
|
||||
// 离开页面时确保关闭相机,避免占用。
|
||||
onUnmounted(() => {
|
||||
if (scanning.value) stopScan();
|
||||
setTransparent(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 非扫码态:说明与入口 -->
|
||||
<div v-if="!scanning" class="card">
|
||||
<div class="card">
|
||||
<p>点击下方按钮,将小票二维码对准取景框即可自动识别;也可从相册选择二维码图片。</p>
|
||||
<button @click="startScan">开始扫码</button>
|
||||
<button class="plain" style="margin-left:8px" :disabled="decoding" @click="pickImage">
|
||||
{{ decoding ? "识别中..." : "从图片扫描" }}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button :disabled="scanning" @click="startScan">
|
||||
{{ scanning ? "扫码中…" : "开始扫码" }}
|
||||
</button>
|
||||
<button class="plain" :disabled="decoding" @click="pickImage">
|
||||
{{ decoding ? "识别中…" : "从图片扫描" }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@ -173,44 +160,9 @@ onUnmounted(() => {
|
||||
去系统设置开启相机权限
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 扫码态:透明覆盖层,仅显示取景框与取消按钮 -->
|
||||
<div v-else class="scan-overlay">
|
||||
<div class="scan-frame">
|
||||
<span class="corner tl"></span><span class="corner tr"></span>
|
||||
<span class="corner bl"></span><span class="corner br"></span>
|
||||
</div>
|
||||
<p class="scan-tip">将二维码放入框内,自动识别</p>
|
||||
<button class="plain" @click="stopScan">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 扫码覆盖层:铺满全屏,除取景框外区域半透明以聚焦二维码 */
|
||||
.scan-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.scan-frame {
|
||||
position: relative;
|
||||
width: 66vw;
|
||||
height: 66vw;
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.45);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.corner { position: absolute; width: 24px; height: 24px; border: 3px solid #21c17a; }
|
||||
.corner.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
|
||||
.corner.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
|
||||
.corner.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
|
||||
.corner.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
|
||||
.scan-tip { color: #fff; font-size: 14px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
</style>
|
||||
|
||||
BIN
tests/images/二维码1.jpg
Normal file
BIN
tests/images/二维码1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 529 KiB |
BIN
tests/images/二维码2.jpg
Normal file
BIN
tests/images/二维码2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 612 KiB |
@ -1,8 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import QRCode from "qrcode";
|
||||
import {
|
||||
extractUrl,
|
||||
decodeQrFromImageData,
|
||||
scaleImageData,
|
||||
} from "../src/qrscan.js";
|
||||
|
||||
/**
|
||||
@ -18,11 +19,10 @@ function renderQrImageData(text, scale = 8, quiet = 4) {
|
||||
const modules = qr.modules.data;
|
||||
const dim = (size + quiet * 2) * scale;
|
||||
const data = new Uint8ClampedArray(dim * dim * 4);
|
||||
// 先整体填白(含静区)。
|
||||
data.fill(255);
|
||||
for (let row = 0; row < size; row += 1) {
|
||||
for (let col = 0; col < size; col += 1) {
|
||||
if (!modules[row * size + col]) continue; // 0 = 浅色,跳过
|
||||
if (!modules[row * size + col]) continue;
|
||||
const x0 = (col + quiet) * scale;
|
||||
const y0 = (row + quiet) * scale;
|
||||
for (let dy = 0; dy < scale; dy += 1) {
|
||||
@ -39,6 +39,45 @@ function renderQrImageData(text, scale = 8, quiet = 4) {
|
||||
return { data, width: dim, height: dim };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将小二维码嵌入大画布中央,模拟手机拍摄的远景照片(二维码占比小)。
|
||||
* @param {string} text 二维码内容。
|
||||
* @param {number} canvasW 大画布宽度。
|
||||
* @param {number} canvasH 大画布高度。
|
||||
* @returns {ImageData} 大画布像素数据。
|
||||
*/
|
||||
function embedSmallQr(text, canvasW = 600, canvasH = 400) {
|
||||
const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
|
||||
const size = qr.modules.size;
|
||||
const modules = qr.modules.data;
|
||||
const scale = 3; // 每个模块 3px,二维码约 80px
|
||||
const quiet = 1;
|
||||
const qrW = (size + quiet * 2) * scale;
|
||||
const qrH = qrW;
|
||||
const ox = Math.floor((canvasW - qrW) / 2);
|
||||
const oy = Math.floor((canvasH - qrH) / 2);
|
||||
|
||||
const data = new Uint8ClampedArray(canvasW * canvasH * 4);
|
||||
data.fill(255);
|
||||
for (let row = 0; row < size; row += 1) {
|
||||
for (let col = 0; col < size; col += 1) {
|
||||
if (!modules[row * size + col]) continue;
|
||||
const x0 = ox + (col + quiet) * scale;
|
||||
const y0 = oy + (row + quiet) * scale;
|
||||
for (let dy = 0; dy < scale; dy += 1) {
|
||||
for (let dx = 0; dx < scale; dx += 1) {
|
||||
const idx = ((y0 + dy) * canvasW + (x0 + dx)) * 4;
|
||||
data[idx] = 0;
|
||||
data[idx + 1] = 0;
|
||||
data[idx + 2] = 0;
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, width: canvasW, height: canvasH };
|
||||
}
|
||||
|
||||
describe("qrscan", () => {
|
||||
// 4-1 网址校验:接受 http/https,拒绝其它内容。
|
||||
it("4-1 extractUrl 仅接受 http/https 网址", () => {
|
||||
@ -68,4 +107,25 @@ describe("qrscan", () => {
|
||||
expect(decodeQrFromImageData(blank)).toBeNull();
|
||||
expect(decodeQrFromImageData(null)).toBeNull();
|
||||
});
|
||||
|
||||
// 4-4 多级缩放回退:大画布中小二维码,原始尺寸失败但缩小后成功。
|
||||
it("4-4 多级缩放回退能识别大图中的小二维码", () => {
|
||||
const url = "https://inv.example.com/small";
|
||||
const imageData = embedSmallQr(url, 800, 600);
|
||||
const result = decodeQrFromImageData(imageData);
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
// 4-5 scaleImageData 等比缩放正确。
|
||||
it("4-5 scaleImageData 等比缩放", () => {
|
||||
const src = renderQrImageData("test", 4);
|
||||
const half = scaleImageData(src, 0.5);
|
||||
expect(half.width).toBe(Math.floor(src.width * 0.5));
|
||||
expect(half.height).toBe(Math.floor(src.height * 0.5));
|
||||
expect(half.data).toBeInstanceOf(Uint8ClampedArray);
|
||||
expect(half.data.length).toBe(half.width * half.height * 4);
|
||||
expect(half.height).toBe(Math.floor(src.height * 0.5));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user