feat: 集成二维码扫描功能,更新相关组件和国际化支持
This commit is contained in:
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -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
2
auto-imports.d.ts
vendored
@@ -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'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
18
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -6,6 +6,17 @@
|
|||||||
"transfer": "转账",
|
"transfer": "转账",
|
||||||
"balance": "余额"
|
"balance": "余额"
|
||||||
},
|
},
|
||||||
|
"scanner": {
|
||||||
|
"title": "扫描二维码",
|
||||||
|
"hint": "将二维码对准扫描框进行扫描",
|
||||||
|
"fromGallery": "从相册选择",
|
||||||
|
"notSupported": "当前平台不支持二维码扫描",
|
||||||
|
"permissionDenied": "相机权限被拒绝",
|
||||||
|
"openError": "打开扫描器失败",
|
||||||
|
"noQRCodeFound": "未识别到二维码",
|
||||||
|
"galleryError": "读取相册失败",
|
||||||
|
"galleryNotSupported": "当前不支持从相册选择"
|
||||||
|
},
|
||||||
"recharge": {
|
"recharge": {
|
||||||
"channel": {
|
"channel": {
|
||||||
"chainRecharge": "链上充值",
|
"chainRecharge": "链上充值",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user