feat: 集成二维码扫描功能,更新相关组件和国际化支持

This commit is contained in:
2025-12-24 04:34:40 +07:00
parent 648ca3edc9
commit 5ff44e4de0
12 changed files with 450 additions and 218 deletions

View File

@@ -138,6 +138,7 @@ API 配置集中在 src/api/index.ts
认证逻辑封装在 src/auth/index.ts 认证逻辑封装在 src/auth/index.ts
支持登录、注册组件复用 支持登录、注册组件复用
认证状态管理通过 useAuth composable 认证状态管理通过 useAuth composable
开发任务指引 开发任务指引
当收到开发任务时,请: 当收到开发任务时,请:
@@ -159,3 +160,4 @@ API 类型来自私有包 @riwa/api-types需要特定访问权限
Capacitor 配置本地开发服务器地址为 http://localhost:5173 Capacitor 配置本地开发服务器地址为 http://localhost:5173
国际化支持中文和英文,配置文件在 src/locales/ 目前多语言只需要支持中英文,其他语言先不要更改 国际化支持中文和英文,配置文件在 src/locales/ 目前多语言只需要支持中英文,其他语言先不要更改
样式使用 TailwindCSS 4.x + Ionic CSS Variables 混合模式 样式使用 TailwindCSS 4.x + Ionic CSS Variables 混合模式
函数风格使用function关键字定义一般不要使用箭头函数

2
auto-imports.d.ts vendored
View File

@@ -351,7 +351,7 @@ declare global {
export type { Language } from './src/composables/useLanguage' export type { Language } from './src/composables/useLanguage'
import('./src/composables/useLanguage') import('./src/composables/useLanguage')
// @ts-ignore // @ts-ignore
export type { QRScanResult } from './src/composables/useQRScanner' export type { QRScanResult, ScannerOptions } from './src/composables/useQRScanner'
import('./src/composables/useQRScanner') import('./src/composables/useQRScanner')
// @ts-ignore // @ts-ignore
export type { ThemeMode } from './src/composables/useTheme' export type { ThemeMode } from './src/composables/useTheme'

View File

@@ -297,6 +297,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = G6N4M926R4;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -305,7 +306,7 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = io.ionic.starter; PRODUCT_BUNDLE_IDENTIFIER = riwa.ionic.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -319,6 +320,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = G6N4M926R4;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -326,7 +328,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.ionic.starter; PRODUCT_BUNDLE_IDENTIFIER = riwa.ionic.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

@@ -24,6 +24,21 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>This app needs access to camera to scan QR codes</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to photo library to select images for QR code scanning</string>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -47,19 +62,5 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>This app needs access to camera to scan QR codes</string>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -13,6 +13,7 @@ let package = Package(
dependencies: [ dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.0.0"), .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.0.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/app"), .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/app"),
.package(name: "CapacitorBarcodeScanner", path: "../../../node_modules/.pnpm/@capacitor+barcode-scanner@3.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/barcode-scanner"),
.package(name: "CapacitorCamera", path: "../../../node_modules/.pnpm/@capacitor+camera@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/camera"), .package(name: "CapacitorCamera", path: "../../../node_modules/.pnpm/@capacitor+camera@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/camera"),
.package(name: "CapacitorClipboard", path: "../../../node_modules/.pnpm/@capacitor+clipboard@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/clipboard"), .package(name: "CapacitorClipboard", path: "../../../node_modules/.pnpm/@capacitor+clipboard@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/clipboard"),
.package(name: "CapacitorFileTransfer", path: "../../../node_modules/.pnpm/@capacitor+file-transfer@2.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/file-transfer"), .package(name: "CapacitorFileTransfer", path: "../../../node_modules/.pnpm/@capacitor+file-transfer@2.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/file-transfer"),
@@ -28,6 +29,7 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"), .product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"),
.product(name: "CapacitorCamera", package: "CapacitorCamera"), .product(name: "CapacitorCamera", package: "CapacitorCamera"),
.product(name: "CapacitorClipboard", package: "CapacitorClipboard"), .product(name: "CapacitorClipboard", package: "CapacitorClipboard"),
.product(name: "CapacitorFileTransfer", package: "CapacitorFileTransfer"), .product(name: "CapacitorFileTransfer", package: "CapacitorFileTransfer"),

View File

@@ -20,6 +20,7 @@
"@capacitor-community/barcode-scanner": "^4.0.1", "@capacitor-community/barcode-scanner": "^4.0.1",
"@capacitor-mlkit/barcode-scanning": "^8.0.0", "@capacitor-mlkit/barcode-scanning": "^8.0.0",
"@capacitor/app": "8.0.0", "@capacitor/app": "8.0.0",
"@capacitor/barcode-scanner": "^3.0.0",
"@capacitor/camera": "^8.0.0", "@capacitor/camera": "^8.0.0",
"@capacitor/clipboard": "^8.0.0", "@capacitor/clipboard": "^8.0.0",
"@capacitor/core": "8.0.0", "@capacitor/core": "8.0.0",

18
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@capacitor/app': '@capacitor/app':
specifier: 8.0.0 specifier: 8.0.0
version: 8.0.0(@capacitor/core@8.0.0) version: 8.0.0(@capacitor/core@8.0.0)
'@capacitor/barcode-scanner':
specifier: ^3.0.0
version: 3.0.0(@capacitor/core@8.0.0)
'@capacitor/camera': '@capacitor/camera':
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0(@capacitor/core@8.0.0) version: 8.0.0(@capacitor/core@8.0.0)
@@ -831,6 +834,11 @@ packages:
peerDependencies: peerDependencies:
'@capacitor/core': '>=8.0.0' '@capacitor/core': '>=8.0.0'
'@capacitor/barcode-scanner@3.0.0':
resolution: {integrity: sha512-UWcN+9uSk1+RxR/Sh794727QH7I47/N1NoTGcumjRjhbhAb6CHJPyxOf1LyIoBILt4PinUsLhegCmzlBDrB3lA==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/camera@8.0.0': '@capacitor/camera@8.0.0':
resolution: {integrity: sha512-Iu8j2oxoIhY2mLuoEckbL7PFgw1XFm1nqmeWdIkILpcT3H9A+BrSDUDlzWqM/EeaDKo6JnhR59tYHwUhOdXaUg==} resolution: {integrity: sha512-Iu8j2oxoIhY2mLuoEckbL7PFgw1XFm1nqmeWdIkILpcT3H9A+BrSDUDlzWqM/EeaDKo6JnhR59tYHwUhOdXaUg==}
peerDependencies: peerDependencies:
@@ -3141,6 +3149,9 @@ packages:
html-entities@2.6.0: html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
html5-qrcode@2.3.8:
resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==}
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -5839,6 +5850,11 @@ snapshots:
dependencies: dependencies:
'@capacitor/core': 8.0.0 '@capacitor/core': 8.0.0
'@capacitor/barcode-scanner@3.0.0(@capacitor/core@8.0.0)':
dependencies:
'@capacitor/core': 8.0.0
html5-qrcode: 2.3.8
'@capacitor/camera@8.0.0(@capacitor/core@8.0.0)': '@capacitor/camera@8.0.0(@capacitor/core@8.0.0)':
dependencies: dependencies:
'@capacitor/core': 8.0.0 '@capacitor/core': 8.0.0
@@ -8363,6 +8379,8 @@ snapshots:
html-entities@2.6.0: {} html-entities@2.6.0: {}
html5-qrcode@2.3.8: {}
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4

View File

@@ -1,162 +1,342 @@
<!-- filepath: /src/components/ui/qr-scanner/QRScanner.vue -->
<script setup lang="ts"> <script setup lang="ts">
import type { QRScanResult } from "@/composables/useQRScanner"; import type { QRScanResult } from "@/composables/useQRScanner";
import { CapacitorBarcodeScanner } from "@capacitor/barcode-scanner";
import { closeOutline, flashlightOutline, imagesOutline } from "ionicons/icons";
interface Props { interface Props {
modelValue?: boolean;
title?: string; title?: string;
subtitle?: string;
} }
interface Emits { interface Emits {
"update:modelValue": [value: boolean]; scanSuccess: [result: QRScanResult];
"scanSuccess": [result: QRScanResult]; scanFromGallery: [];
"scanError": [error: Error]; cancel: [];
"scanCancelled": [];
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
modelValue: false,
title: "扫描二维码", title: "扫描二维码",
subtitle: "将二维码对准扫描框进行扫描",
}); });
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const { isSupported, startScan, stopScan } = useQRScanner(); const { t } = useI18n();
const { vibrate } = useHaptics();
const isScanning = ref(false); const isScanning = ref(false);
const isTorchEnabled = ref(false);
// 开始扫描 // 开始扫描
async function handleStartScan() { async function startScanning() {
try { try {
if (!isSupported) {
throw new Error("当前平台不支持二维码扫描");
}
isScanning.value = true; isScanning.value = true;
const result = await startScan();
if (result) { // 开始扫描 - Google Barcode Scanner 会打开原生UI
emit("scanSuccess", result); const result = await CapacitorBarcodeScanner.scanBarcode({
await handleClose(); hint: 17, // ALL - 扫描所有格式
} scanInstructions: props.title,
else { scanButton: false,
emit("scanCancelled"); cameraDirection: 1, // BACK camera
await handleClose(); });
}
}
catch (error) {
console.error("Scan failed:", error);
emit("scanError", error as Error);
await handleClose();
}
finally {
isScanning.value = false;
}
}
// 关闭扫描 if (result.ScanResult) {
async function handleClose() { // 震动反馈
if (isScanning.value) { vibrate();
await stopScan();
isScanning.value = false;
}
emit("update:modelValue", false);
}
// 监听模态框打开 // 发送扫描结果
watch(() => props.modelValue, (isOpen) => { emit("scanSuccess", {
if (isOpen && isSupported) { text: result.ScanResult,
nextTick(() => { format: result.format?.toString() || "UNKNOWN",
handleStartScan(); rawValue: result.ScanResult,
displayValue: result.ScanResult,
}); });
} }
else {
// 用户取消了扫描
emit("cancel");
}
}
catch (error: any) {
console.error("Start scanning failed:", error);
if (error.message !== "User cancelled") {
emit("cancel");
}
}
finally {
await stopScanning();
}
}
// 停止扫描
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);
}
}
// 切换手电筒
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(() => { onUnmounted(() => {
if (isScanning.value) { stopScanning();
stopScan();
}
}); });
</script> </script>
<template> <template>
<IonModal :is-open="modelValue" @did-dismiss="handleClose"> <div class="qr-scanner-container">
<IonHeader> <!-- 顶部操作栏 -->
<IonToolbar> <div class="qr-scanner-header">
<IonTitle>{{ title }}</IonTitle> <IonButton fill="clear" class="header-btn" @click="handleCancel">
<IonButtons slot="end"> <IonIcon slot="icon-only" :icon="closeOutline" />
<IonButton @click="handleClose">
<IonIcon :icon="close" />
</IonButton> </IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent class="qr-scanner-content"> <div class="header-title">
<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>
<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 }} {{ title }}
</h3>
<p class="text-sm opacity-80">
{{ subtitle }}
</p>
</div> </div>
<!-- 控制按钮 --> <IonButton fill="clear" class="header-btn" @click="toggleTorch">
<div class="p-6 bg-white"> <IonIcon slot="icon-only" :icon="flashlightOutline" :class="{ 'torch-on': isTorchEnabled }" />
<IonButton
expand="block"
fill="outline"
@click="handleClose"
>
取消扫描
</IonButton> </IonButton>
</div> </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 class="scan-hint">
{{ t("scanner.hint") }}
</div>
</div> </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" /> <div class="qr-scanner-footer">
<h3 class="text-lg font-semibold mb-2"> <IonButton expand="block" fill="clear" class="footer-btn" @click="handleGalleryClick">
准备扫描 <IonIcon slot="start" :icon="imagesOutline" />
</h3> {{ t("scanner.fromGallery") }}
<p class="text-gray-600 mb-6">
点击开始按钮开始扫描二维码
</p>
<IonButton expand="block" @click="handleStartScan">
开始扫描
</IonButton> </IonButton>
</div> </div>
</IonContent> </div>
</IonModal>
</template> </template>
<style scoped> <style scoped>
.qr-scanner-content { .qr-scanner-container {
--background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 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; --background: transparent;
} }
</style> </style>

View File

@@ -1,82 +1,71 @@
import { BarcodeScanner } from "@capacitor-community/barcode-scanner"; import { CapacitorBarcodeScanner } from "@capacitor/barcode-scanner";
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import { toastController } from "@ionic/vue";
export interface QRScanResult { export interface QRScanResult {
text: string; text: string;
format: string; format: string;
rawValue: string;
displayValue: string;
}
export interface ScannerOptions {
title?: string;
} }
export function useQRScanner() { export function useQRScanner() {
const { t } = useI18n();
const { vibrate } = useHaptics();
const isSupported = Capacitor.isNativePlatform(); const isSupported = Capacitor.isNativePlatform();
// 检查权限 const showError = async (message: string) => {
const checkPermission = async (): Promise<boolean> => { const toast = await toastController.create({
try { message,
const status = await BarcodeScanner.checkPermission({ force: true }); duration: 2000,
return status.granted || false; position: "top",
} color: "danger",
catch (error) { });
console.error("Permission check failed:", error); await toast.present();
return false;
}
}; };
// 开始扫描 const openScanner = async (options?: ScannerOptions): Promise<QRScanResult | null> => {
const startScan = async (): Promise<QRScanResult | null> => {
try { try {
// 检查是否为原生平台
if (!isSupported) { if (!isSupported) {
console.warn("QR Scanner is only supported on native platforms"); await showError(t("scanner.notSupported"));
return null; return null;
} }
// 检查权限 vibrate();
const hasPermission = await checkPermission();
if (!hasPermission) {
throw new Error("Camera permission denied");
}
// 隐藏背景以显示相机预览 const result = await CapacitorBarcodeScanner.scanBarcode({
await BarcodeScanner.hideBackground(); hint: 0,
scanInstructions: options?.title || t("scanner.hint"),
cameraDirection: 1,
});
// 开始扫描 if (result.ScanResult) {
const result = await BarcodeScanner.startScan(); vibrate();
// 恢复背景
await BarcodeScanner.showBackground();
if (result?.hasContent) {
return { return {
text: result.content, text: result.ScanResult,
format: result.format || "QR_CODE", format: result.format?.toString() || "UNKNOWN",
rawValue: result.ScanResult,
displayValue: result.ScanResult,
}; };
} }
return null; return null;
} }
catch (error) { catch (error: any) {
// 确保恢复背景 console.log("error.message", error.message);
await BarcodeScanner.showBackground(); if (error.code !== "OS-PLUG-BARC-0006") {
console.error("QR scan failed:", error); await showError(t("scanner.openError"));
throw error;
} }
}; return null;
// 停止扫描
const stopScan = async () => {
try {
await BarcodeScanner.stopScan();
await BarcodeScanner.showBackground();
}
catch (error) {
console.error("Stop scan failed:", error);
} }
}; };
return { return {
isSupported, isSupported,
checkPermission, openScanner,
startScan,
stopScan,
}; };
} }

View File

@@ -6,6 +6,17 @@
"transfer": "Transfer", "transfer": "Transfer",
"balance": "Balance" "balance": "Balance"
}, },
"scanner": {
"title": "Scan QR Code",
"hint": "Align QR code within frame to scan",
"fromGallery": "Choose from Gallery",
"notSupported": "QR code scanning not supported on this platform",
"permissionDenied": "Camera permission denied",
"openError": "Failed to open scanner",
"noQRCodeFound": "No QR code detected",
"galleryError": "Failed to read from gallery",
"galleryNotSupported": "Gallery selection not supported currently"
},
"recharge": { "recharge": {
"channel": { "channel": {
"chainRecharge": "Chain recharge", "chainRecharge": "Chain recharge",

View File

@@ -6,6 +6,17 @@
"transfer": "转账", "transfer": "转账",
"balance": "余额" "balance": "余额"
}, },
"scanner": {
"title": "扫描二维码",
"hint": "将二维码对准扫描框进行扫描",
"fromGallery": "从相册选择",
"notSupported": "当前平台不支持二维码扫描",
"permissionDenied": "相机权限被拒绝",
"openError": "打开扫描器失败",
"noQRCodeFound": "未识别到二维码",
"galleryError": "读取相册失败",
"galleryNotSupported": "当前不支持从相册选择"
},
"recharge": { "recharge": {
"channel": { "channel": {
"chainRecharge": "链上充值", "chainRecharge": "链上充值",

View File

@@ -10,6 +10,7 @@ import WalletCard from "./components/wallet-card.vue";
const { vibrate } = useHaptics(); const { vibrate } = useHaptics();
const walletStore = useWalletStore(); const walletStore = useWalletStore();
const { openScanner } = useQRScanner();
async function handleRefresh(event: RefresherCustomEvent) { async function handleRefresh(event: RefresherCustomEvent) {
vibrate(); vibrate();
@@ -18,6 +19,20 @@ async function handleRefresh(event: RefresherCustomEvent) {
event.target.complete(); event.target.complete();
}, 500); }, 500);
} }
// 处理扫描二维码
async function handleScan() {
vibrate();
const result = await openScanner({
title: "扫描二维码",
});
if (result) {
console.log("扫描结果:", result);
// TODO: 根据扫描结果进行相应处理
// 例如:跳转到对应页面、显示信息等
}
}
</script> </script>
<template> <template>
@@ -25,7 +40,7 @@ async function handleRefresh(event: RefresherCustomEvent) {
<ion-header class="ion-no-border"> <ion-header class="ion-no-border">
<ion-toolbar class="ui-toolbar"> <ion-toolbar class="ui-toolbar">
<div slot="end"> <div slot="end">
<ion-button fill="clear"> <ion-button fill="clear" @click="handleScan">
<ion-icon slot="icon-only" :icon="scanOutline" /> <ion-icon slot="icon-only" :icon="scanOutline" />
</ion-button> </ion-button>
<ion-button fill="clear"> <ion-button fill="clear">