feat: add QRCode scanner

This commit is contained in:
2025-12-17 23:57:04 +07:00
parent 5b5fcf9d44
commit bec77d187d
9 changed files with 1067 additions and 75 deletions

5
auto-imports.d.ts vendored
View File

@@ -270,6 +270,7 @@ declare global {
const usePreferredReducedMotion: typeof import('@vueuse/core').usePreferredReducedMotion
const usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
const usePrevious: typeof import('@vueuse/core').usePrevious
const useQRScanner: typeof import('./src/composables/useQRScanner').useQRScanner
const useRafFn: typeof import('@vueuse/core').useRafFn
const useRefHistory: typeof import('@vueuse/core').useRefHistory
const useResetRef: typeof import('./src/composables/useResetRef').useResetRef
@@ -354,6 +355,9 @@ declare global {
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { QRScanResult } from './src/composables/useQRScanner'
import('./src/composables/useQRScanner')
// @ts-ignore
export type { PageInstance, InputInstance, ModalInstance, FormInstance } from './src/utils/ionic-helper'
import('./src/utils/ionic-helper')
}
@@ -598,6 +602,7 @@ declare module 'vue' {
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useQRScanner: UnwrapRef<typeof import('./src/composables/useQRScanner')['useQRScanner']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResetRef: UnwrapRef<typeof import('./src/composables/useResetRef')['useResetRef']>

View File

@@ -5,7 +5,7 @@ const config: CapacitorConfig = {
appName: "riwa-ionic",
webDir: "dist",
server: {
url: "http://localhost:5173", // Vite默认端口
url: "http://192.168.1.55:5173", // Vite默认端口
cleartext: true, // 允许HTTP连接
},
};

26
components.d.ts vendored
View File

@@ -12,16 +12,9 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Avatar: typeof import('./src/components/ui/avatar/index.vue')['default']
Collapse: typeof import('./src/components/ui/collapse/index.vue')['default']
Datetime: typeof import('./src/components/ui/datetime/index.vue')['default']
Default: typeof import('./src/components/layout/default.vue')['default']
Divider: typeof import('./src/components/ui/divider/index.vue')['default']
IIcBaselineDataSaverOff: typeof import('~icons/ic/baseline-data-saver-off')['default']
IIcBaselineDownloading: typeof import('~icons/ic/baseline-downloading')['default']
IIcRoundArrowForwardIos: typeof import('~icons/ic/round-arrow-forward-ios')['default']
Input: typeof import('./src/components/ui/input/index.vue')['default']
InputLabel: typeof import('./src/components/ui/input-label/index.vue')['default']
IonApp: typeof import('@ionic/vue')['IonApp']
IonAvatar: typeof import('@ionic/vue')['IonAvatar']
IonBackButton: typeof import('@ionic/vue')['IonBackButton']
@@ -48,14 +41,13 @@ declare module 'vue' {
IonTabBar: typeof import('@ionic/vue')['IonTabBar']
IonTabButton: typeof import('@ionic/vue')['IonTabButton']
IonTabs: typeof import('@ionic/vue')['IonTabs']
IonText: typeof import('@ionic/vue')['IonText']
IonTitle: typeof import('@ionic/vue')['IonTitle']
IonToolbar: typeof import('@ionic/vue')['IonToolbar']
LayoutDefault: typeof import('./src/components/layout/default.vue')['default']
Result: typeof import('./src/components/ui/result/index.vue')['default']
QrScanner: typeof import('./src/components/qr-scanner/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TabPane: typeof import('./src/components/ui/tabs/tab-pane.vue')['default']
Tabs: typeof import('./src/components/ui/tabs/index.vue')['default']
UiAvatar: typeof import('./src/components/ui/avatar/index.vue')['default']
UiCollapse: typeof import('./src/components/ui/collapse/index.vue')['default']
UiDatetime: typeof import('./src/components/ui/datetime/index.vue')['default']
@@ -65,22 +57,14 @@ declare module 'vue' {
UiResult: typeof import('./src/components/ui/result/index.vue')['default']
UiTabPane: typeof import('./src/components/ui/tab-pane/index.vue')['default']
UiTabs: typeof import('./src/components/ui/tabs/index.vue')['default']
UiTabsTabPane: typeof import('./src/components/ui/tabs/tab-pane.vue')['default']
}
}
// For TSX support
declare global {
const Avatar: typeof import('./src/components/ui/avatar/index.vue')['default']
const Collapse: typeof import('./src/components/ui/collapse/index.vue')['default']
const Datetime: typeof import('./src/components/ui/datetime/index.vue')['default']
const Default: typeof import('./src/components/layout/default.vue')['default']
const Divider: typeof import('./src/components/ui/divider/index.vue')['default']
const IIcBaselineDataSaverOff: typeof import('~icons/ic/baseline-data-saver-off')['default']
const IIcBaselineDownloading: typeof import('~icons/ic/baseline-downloading')['default']
const IIcRoundArrowForwardIos: typeof import('~icons/ic/round-arrow-forward-ios')['default']
const Input: typeof import('./src/components/ui/input/index.vue')['default']
const InputLabel: typeof import('./src/components/ui/input-label/index.vue')['default']
const IonApp: typeof import('@ionic/vue')['IonApp']
const IonAvatar: typeof import('@ionic/vue')['IonAvatar']
const IonBackButton: typeof import('@ionic/vue')['IonBackButton']
@@ -107,14 +91,13 @@ declare global {
const IonTabBar: typeof import('@ionic/vue')['IonTabBar']
const IonTabButton: typeof import('@ionic/vue')['IonTabButton']
const IonTabs: typeof import('@ionic/vue')['IonTabs']
const IonText: typeof import('@ionic/vue')['IonText']
const IonTitle: typeof import('@ionic/vue')['IonTitle']
const IonToolbar: typeof import('@ionic/vue')['IonToolbar']
const LayoutDefault: typeof import('./src/components/layout/default.vue')['default']
const Result: typeof import('./src/components/ui/result/index.vue')['default']
const QrScanner: typeof import('./src/components/qr-scanner/index.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const TabPane: typeof import('./src/components/ui/tabs/tab-pane.vue')['default']
const Tabs: typeof import('./src/components/ui/tabs/index.vue')['default']
const UiAvatar: typeof import('./src/components/ui/avatar/index.vue')['default']
const UiCollapse: typeof import('./src/components/ui/collapse/index.vue')['default']
const UiDatetime: typeof import('./src/components/ui/datetime/index.vue')['default']
@@ -124,5 +107,4 @@ declare global {
const UiResult: typeof import('./src/components/ui/result/index.vue')['default']
const UiTabPane: typeof import('./src/components/ui/tab-pane/index.vue')['default']
const UiTabs: typeof import('./src/components/ui/tabs/index.vue')['default']
const UiTabsTabPane: typeof import('./src/components/ui/tabs/tab-pane.vue')['default']
}

View File

@@ -3,49 +3,51 @@
<plist version="1.0">
<dict>
<key>CAPACITOR_DEBUG</key>
<string>$(CAPACITOR_DEBUG)</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>$(CAPACITOR_DEBUG)</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>riwa-ionic</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>This app needs access to camera to scan QR codes</string>
</dict>
</plist>

View File

@@ -13,6 +13,7 @@ let package = Package(
dependencies: [
.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: "CapacitorCamera", path: "../../../node_modules/.pnpm/@capacitor+camera@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/camera"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/.pnpm/@capacitor+haptics@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/keyboard"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/.pnpm/@capacitor+status-bar@8.0.0_@capacitor+core@8.0.0/node_modules/@capacitor/status-bar")
@@ -24,6 +25,7 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorCamera", package: "CapacitorCamera"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")

View File

@@ -6,15 +6,20 @@
"description": "An Ionic project",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"build": "vite build",
"preview": "vite preview",
"run:ios": "ionic capacitor run ios -l --external",
"run:android": "ionic capacitor run android -l --external",
"test:e2e": "cypress run",
"test:unit": "vitest",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"@capacitor-community/barcode-scanner": "^4.0.1",
"@capacitor-mlkit/barcode-scanning": "^8.0.0",
"@capacitor/app": "8.0.0",
"@capacitor/camera": "^8.0.0",
"@capacitor/core": "8.0.0",
"@capacitor/haptics": "8.0.0",
"@capacitor/ios": "^8.0.0",
@@ -23,7 +28,7 @@
"@elysiajs/eden": "^1.4.5",
"@ionic/vue": "^8.7.11",
"@ionic/vue-router": "^8.7.11",
"@riwa/api-types": "http://192.168.1.36:9527/api/riwa-api-types-0.0.24.tgz",
"@riwa/api-types": "http://192.168.1.36:9527/api/riwa-api-types-0.0.29.tgz",
"@tailwindcss/vite": "^4.1.18",
"@vee-validate/yup": "^4.15.1",
"@vueuse/core": "^14.1.0",
@@ -46,6 +51,7 @@
"@iconify-json/ic": "^1.2.4",
"@iconify-json/material-icon-theme": "^1.2.44",
"@iconify/vue": "^5.0.0",
"@ionic/cli": "^7.2.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.2",
"@vitejs/plugin-legacy": "^7.2.1",

763
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
<!-- filepath: /src/components/ui/qr-scanner/QRScanner.vue -->
<script setup lang="ts">
import type { QRScanResult } from "@/composables/useQRScanner";
interface Props {
modelValue?: boolean;
title?: string;
subtitle?: string;
}
interface Emits {
"update:modelValue": [value: boolean];
"scan-success": [result: QRScanResult];
"scan-error": [error: Error];
"scan-cancelled": [];
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
title: "扫描二维码",
subtitle: "将二维码对准扫描框进行扫描",
});
const emit = defineEmits<Emits>();
const { isSupported, startScan, stopScan } = useQRScanner();
const isScanning = ref(false);
// 开始扫描
async function handleStartScan() {
try {
if (!isSupported) {
throw new Error("当前平台不支持二维码扫描");
}
isScanning.value = true;
const result = await startScan();
if (result) {
emit("scan-success", result);
await handleClose();
}
else {
emit("scan-cancelled");
await handleClose();
}
}
catch (error) {
console.error("Scan failed:", error);
emit("scan-error", error as Error);
await handleClose();
}
finally {
isScanning.value = false;
}
}
// 关闭扫描
async function handleClose() {
if (isScanning.value) {
await stopScan();
isScanning.value = false;
}
emit("update:modelValue", false);
}
// 监听模态框打开
watch(() => props.modelValue, (isOpen) => {
if (isOpen && isSupported) {
nextTick(() => {
handleStartScan();
});
}
});
// 组件卸载时确保停止扫描
onUnmounted(() => {
if (isScanning.value) {
stopScan();
}
});
</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>
<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>
<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>
<!-- 控制按钮 -->
<div class="p-6 bg-white">
<IonButton
expand="block"
fill="outline"
@click="handleClose"
>
取消扫描
</IonButton>
</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" />
<h3 class="text-lg font-semibold mb-2">
准备扫描
</h3>
<p class="text-gray-600 mb-6">
点击开始按钮开始扫描二维码
</p>
<IonButton expand="block" @click="handleStartScan">
开始扫描
</IonButton>
</div>
</IonContent>
</IonModal>
</template>
<style scoped>
.qr-scanner-content {
--background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* 当扫描时,隐藏背景以显示相机预览 */
.qr-scanner-content.scanning {
--background: transparent;
}
</style>

View File

@@ -0,0 +1,82 @@
import { BarcodeScanner } from "@capacitor-community/barcode-scanner";
import { Capacitor } from "@capacitor/core";
export interface QRScanResult {
text: string;
format: string;
}
export function useQRScanner() {
const isSupported = Capacitor.isNativePlatform();
// 检查权限
const checkPermission = async (): Promise<boolean> => {
try {
const status = await BarcodeScanner.checkPermission({ force: true });
return status.granted || false;
}
catch (error) {
console.error("Permission check failed:", error);
return false;
}
};
// 开始扫描
const startScan = async (): Promise<QRScanResult | null> => {
try {
// 检查是否为原生平台
if (!isSupported) {
console.warn("QR Scanner is only supported on native platforms");
return null;
}
// 检查权限
const hasPermission = await checkPermission();
if (!hasPermission) {
throw new Error("Camera permission denied");
}
// 隐藏背景以显示相机预览
await BarcodeScanner.hideBackground();
// 开始扫描
const result = await BarcodeScanner.startScan();
// 恢复背景
await BarcodeScanner.showBackground();
if (result?.hasContent) {
return {
text: result.content,
format: result.format || "QR_CODE",
};
}
return null;
}
catch (error) {
// 确保恢复背景
await BarcodeScanner.showBackground();
console.error("QR scan failed:", error);
throw error;
}
};
// 停止扫描
const stopScan = async () => {
try {
await BarcodeScanner.stopScan();
await BarcodeScanner.showBackground();
}
catch (error) {
console.error("Stop scan failed:", error);
}
};
return {
isSupported,
checkPermission,
startScan,
stopScan,
};
}