feat: 集成二维码扫描功能,更新相关组件和国际化支持
This commit is contained in:
@@ -1,162 +1,342 @@
|
||||
<!-- filepath: /src/components/ui/qr-scanner/QRScanner.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { QRScanResult } from "@/composables/useQRScanner";
|
||||
import { CapacitorBarcodeScanner } from "@capacitor/barcode-scanner";
|
||||
import { closeOutline, flashlightOutline, imagesOutline } from "ionicons/icons";
|
||||
|
||||
interface Props {
|
||||
modelValue?: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
"update:modelValue": [value: boolean];
|
||||
"scanSuccess": [result: QRScanResult];
|
||||
"scanError": [error: Error];
|
||||
"scanCancelled": [];
|
||||
scanSuccess: [result: QRScanResult];
|
||||
scanFromGallery: [];
|
||||
cancel: [];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
title: "扫描二维码",
|
||||
subtitle: "将二维码对准扫描框进行扫描",
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { isSupported, startScan, stopScan } = useQRScanner();
|
||||
const { t } = useI18n();
|
||||
const { vibrate } = useHaptics();
|
||||
const isScanning = ref(false);
|
||||
const isTorchEnabled = ref(false);
|
||||
|
||||
// 开始扫描
|
||||
async function handleStartScan() {
|
||||
async function startScanning() {
|
||||
try {
|
||||
if (!isSupported) {
|
||||
throw new Error("当前平台不支持二维码扫描");
|
||||
}
|
||||
|
||||
isScanning.value = true;
|
||||
const result = await startScan();
|
||||
|
||||
if (result) {
|
||||
emit("scanSuccess", result);
|
||||
await handleClose();
|
||||
// 开始扫描 - Google Barcode Scanner 会打开原生UI
|
||||
const result = await CapacitorBarcodeScanner.scanBarcode({
|
||||
hint: 17, // ALL - 扫描所有格式
|
||||
scanInstructions: props.title,
|
||||
scanButton: false,
|
||||
cameraDirection: 1, // BACK camera
|
||||
});
|
||||
|
||||
if (result.ScanResult) {
|
||||
// 震动反馈
|
||||
vibrate();
|
||||
|
||||
// 发送扫描结果
|
||||
emit("scanSuccess", {
|
||||
text: result.ScanResult,
|
||||
format: result.format?.toString() || "UNKNOWN",
|
||||
rawValue: result.ScanResult,
|
||||
displayValue: result.ScanResult,
|
||||
});
|
||||
}
|
||||
else {
|
||||
emit("scanCancelled");
|
||||
await handleClose();
|
||||
// 用户取消了扫描
|
||||
emit("cancel");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Scan failed:", error);
|
||||
emit("scanError", error as Error);
|
||||
await handleClose();
|
||||
catch (error: any) {
|
||||
console.error("Start scanning failed:", error);
|
||||
if (error.message !== "User cancelled") {
|
||||
emit("cancel");
|
||||
}
|
||||
}
|
||||
finally {
|
||||
isScanning.value = false;
|
||||
await stopScanning();
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭扫描
|
||||
async function handleClose() {
|
||||
if (isScanning.value) {
|
||||
await stopScan();
|
||||
// 停止扫描
|
||||
async function stopScanning() {
|
||||
try {
|
||||
document.querySelector("body")?.classList.remove("barcode-scanner-active");
|
||||
isScanning.value = false;
|
||||
isTorchEnabled.value = false;
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Stop scanning failed:", error);
|
||||
}
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
// 监听模态框打开
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen && isSupported) {
|
||||
nextTick(() => {
|
||||
handleStartScan();
|
||||
});
|
||||
// 切换手电筒
|
||||
async function toggleTorch() {
|
||||
try {
|
||||
// Google Barcode Scanner 不支持手电筒控制
|
||||
// 此功能在使用 Google Scanner UI 时不可用
|
||||
vibrate();
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Toggle torch failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 从相册选择
|
||||
function handleGalleryClick() {
|
||||
stopScanning();
|
||||
emit("scanFromGallery");
|
||||
}
|
||||
|
||||
// 取消扫描
|
||||
function handleCancel() {
|
||||
stopScanning();
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
// 组件挂载后自动开始扫描
|
||||
onMounted(() => {
|
||||
startScanning();
|
||||
});
|
||||
|
||||
// 组件卸载时确保停止扫描
|
||||
// 组件卸载前停止扫描
|
||||
onUnmounted(() => {
|
||||
if (isScanning.value) {
|
||||
stopScan();
|
||||
}
|
||||
stopScanning();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IonModal :is-open="modelValue" @did-dismiss="handleClose">
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>{{ title }}</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton @click="handleClose">
|
||||
<IonIcon :icon="close" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<div class="qr-scanner-container">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="qr-scanner-header">
|
||||
<IonButton fill="clear" class="header-btn" @click="handleCancel">
|
||||
<IonIcon slot="icon-only" :icon="closeOutline" />
|
||||
</IonButton>
|
||||
|
||||
<IonContent class="qr-scanner-content">
|
||||
<div v-if="!isSupported" class="flex flex-col items-center justify-center h-full p-6 text-center">
|
||||
<IonIcon :icon="qrCodeOutline" size="large" class="text-gray-400 mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">
|
||||
不支持的平台
|
||||
</h3>
|
||||
<p class="text-gray-600">
|
||||
二维码扫描仅在移动设备上支持
|
||||
</p>
|
||||
<div class="header-title">
|
||||
{{ title }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="isScanning" class="flex flex-col h-full">
|
||||
<!-- 扫描指示 -->
|
||||
<div class="flex flex-col items-center justify-center flex-1 p-6 text-center text-white">
|
||||
<div class="relative mb-8">
|
||||
<div class="w-64 h-64 border-2 border-white border-dashed rounded-lg animate-pulse" />
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<IonIcon :icon="qrCodeOutline" size="large" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-sm opacity-80">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<IonButton fill="clear" class="header-btn" @click="toggleTorch">
|
||||
<IonIcon slot="icon-only" :icon="flashlightOutline" :class="{ 'torch-on': isTorchEnabled }" />
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="p-6 bg-white">
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
@click="handleClose"
|
||||
>
|
||||
取消扫描
|
||||
</IonButton>
|
||||
</div>
|
||||
<!-- 扫描区域 -->
|
||||
<div class="qr-scanner-content">
|
||||
<!-- 扫描框 -->
|
||||
<div class="scan-frame">
|
||||
<!-- 四个角的装饰 -->
|
||||
<div class="corner corner-tl" />
|
||||
<div class="corner corner-tr" />
|
||||
<div class="corner corner-bl" />
|
||||
<div class="corner corner-br" />
|
||||
|
||||
<!-- 扫描线 -->
|
||||
<div v-if="isScanning" class="scan-line" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center h-full p-6 text-center">
|
||||
<IonIcon :icon="qrCodeOutline" size="large" class="text-primary mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">
|
||||
准备扫描
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
点击开始按钮开始扫描二维码
|
||||
</p>
|
||||
<IonButton expand="block" @click="handleStartScan">
|
||||
开始扫描
|
||||
</IonButton>
|
||||
<!-- 提示文字 -->
|
||||
<div class="scan-hint">
|
||||
{{ t("scanner.hint") }}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区 -->
|
||||
<div class="qr-scanner-footer">
|
||||
<IonButton expand="block" fill="clear" class="footer-btn" @click="handleGalleryClick">
|
||||
<IonIcon slot="start" :icon="imagesOutline" />
|
||||
{{ t("scanner.fromGallery") }}
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-scanner-content {
|
||||
--background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
.qr-scanner-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 当扫描时,隐藏背景以显示相机预览 */
|
||||
.qr-scanner-content.scanning {
|
||||
/* 顶部操作栏 */
|
||||
.qr-scanner-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: env(safe-area-inset-top) 8px 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
--color: white;
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: white;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
|
||||
.torch-on {
|
||||
color: #fbbf24 !important;
|
||||
}
|
||||
|
||||
/* 扫描内容区 */
|
||||
.qr-scanner-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 扫描框 */
|
||||
.scan-frame {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
position: relative;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* 四个角 */
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-color: #00d4ff;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.corner-tl {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-width: 3px 0 0 3px;
|
||||
border-radius: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.corner-tr {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-width: 3px 3px 0 0;
|
||||
border-radius: 0 4px 0 0;
|
||||
}
|
||||
|
||||
.corner-bl {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-width: 0 0 3px 3px;
|
||||
border-radius: 0 0 0 4px;
|
||||
}
|
||||
|
||||
.corner-br {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-width: 0 3px 3px 0;
|
||||
border-radius: 0 0 4px 0;
|
||||
}
|
||||
|
||||
/* 扫描线动画 */
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #00d4ff, transparent);
|
||||
box-shadow: 0 0 10px #00d4ff;
|
||||
animation: scan 2s linear infinite;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% {
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
top: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 提示文字 */
|
||||
.scan-hint {
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 底部操作区 */
|
||||
.qr-scanner-footer {
|
||||
padding: 0 20px env(safe-area-inset-bottom);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
padding-top: 20px;
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
--color: white;
|
||||
--border-radius: 12px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.footer-btn ion-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 全局样式:隐藏 body 内容以显示相机 */
|
||||
body.barcode-scanner-active {
|
||||
background: transparent !important;
|
||||
--ion-background-color: transparent !important;
|
||||
}
|
||||
|
||||
body.barcode-scanner-active ion-app,
|
||||
body.barcode-scanner-active ion-content {
|
||||
background: transparent !important;
|
||||
--background: transparent !important;
|
||||
}
|
||||
|
||||
/* Modal 样式 */
|
||||
.qr-scanner-modal {
|
||||
--background: transparent;
|
||||
--backdrop-opacity: 0;
|
||||
}
|
||||
|
||||
.qr-scanner-modal ion-modal {
|
||||
--background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user