| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- <template>
- <view class="qr-scanner-page">
- <!-- 顶部导航 -->
- <view class="qr-scanner-nav">
- <view class="qr-scanner-nav-back" @click="goBack">
- <text class="qr-scanner-nav-back-icon">←</text>
- </view>
- <text class="qr-scanner-nav-title">扫描二维码</text>
- <view class="qr-scanner-nav-placeholder" />
- </view>
- <!-- 相机区域 -->
- <view class="qr-scanner-viewfinder">
- <view id="qr-reader" class="qr-scanner-reader" />
- <!-- 扫描框覆盖层 -->
- <view class="qr-scanner-overlay">
- <view class="qr-scanner-corner qr-scanner-corner--tl" />
- <view class="qr-scanner-corner qr-scanner-corner--tr" />
- <view class="qr-scanner-corner qr-scanner-corner--bl" />
- <view class="qr-scanner-corner qr-scanner-corner--br" />
- <view class="qr-scanner-line" />
- </view>
- <!-- 相机未授权时的提示 -->
- <view v-if="cameraError" class="qr-scanner-camera-error">
- <view class="qr-scanner-camera-error-icon">
- <AiIcon icon="/static/icons/ai-icon/alert-triangle.svg" color="#f59e0b" size="lg" />
- </view>
- <text class="qr-scanner-camera-error-title">无法访问摄像头</text>
- <text class="qr-scanner-camera-error-desc">{{ cameraErrorMsg }}</text>
- <view class="qr-scanner-camera-error-actions">
- <AiButton size="sm" @click="startScanner">重试</AiButton>
- </view>
- </view>
- </view>
- <!-- 底部提示 -->
- <view class="qr-scanner-footer">
- <text class="qr-scanner-footer-text">将二维码放入框内,即可自动扫描</text>
- <view class="qr-scanner-footer-actions">
- <view class="qr-scanner-footer-btn" @click="handleAlbum">
- <AiIcon icon="/static/icons/ai-icon/image.svg" color="#ffffff" size="sm" />
- <text class="qr-scanner-footer-btn-text">从相册选择</text>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script setup>
- import { onMounted, onBeforeUnmount, ref } from 'vue'
- import AiIcon from '@/components/AiIcon.vue'
- import AiButton from '@/components/AiButton.vue'
- import { Html5Qrcode, Html5QrcodeScanType } from 'html5-qrcode'
- import { toast } from '@/utils/notify'
- const cameraError = ref(false)
- const cameraErrorMsg = ref('')
- let html5QrCode = null
- let isDestroyed = false
- onMounted(() => {
- startScanner()
- })
- onBeforeUnmount(() => {
- stopScanner()
- })
- async function startScanner() {
- cameraError.value = false
- cameraErrorMsg.value = ''
- try {
- if (html5QrCode) {
- try {
- await html5QrCode.stop()
- } catch (e) {
- // ignore
- }
- html5QrCode.clear()
- html5QrCode = null
- }
- html5QrCode = new Html5Qrcode('qr-reader', { verbose: false })
- const config = {
- fps: 15,
- qrbox: (viewfinderWidth, viewfinderHeight) => {
- const minEdge = Math.min(viewfinderWidth, viewfinderHeight)
- const size = Math.floor(minEdge * 0.7)
- return { width: size, height: size }
- },
- aspectRatio: 1.0,
- }
- // 优先使用后置摄像头
- const cameras = await Html5Qrcode.getCameras()
- if (!cameras || cameras.length === 0) {
- throw new Error('未检测到摄像头设备')
- }
- let cameraId = cameras[0].id
- // 查找后置摄像头
- const backCamera = cameras.find(c =>
- /back|rear|environment/i.test(c.label)
- )
- if (backCamera) {
- cameraId = backCamera.id
- }
- await html5QrCode.start(
- cameraId,
- config,
- onScanSuccess,
- () => { /* onScanFailure - ignore */ }
- )
- } catch (err) {
- console.error('Camera error:', err)
- cameraError.value = true
- if (err.name === 'NotAllowedError' || err.message?.includes('Permission')) {
- cameraErrorMsg.value = '请在浏览器设置中允许访问摄像头'
- } else if (err.name === 'NotFoundError' || err.message?.includes('未检测')) {
- cameraErrorMsg.value = '未检测到摄像头设备,请确认设备已连接'
- } else if (err.name === 'NotReadableError') {
- cameraErrorMsg.value = '摄像头被占用,请关闭其他应用后重试'
- } else if (err.message?.includes('HTTPS') || err.message?.includes('secure')) {
- cameraErrorMsg.value = '摄像头功能需要 HTTPS 或 localhost 环境'
- } else {
- cameraErrorMsg.value = err.message || '摄像头启动失败'
- }
- }
- }
- async function stopScanner() {
- isDestroyed = true
- if (html5QrCode) {
- try {
- if (html5QrCode.isScanning) {
- await html5QrCode.stop()
- }
- } catch (e) {
- // ignore
- }
- try {
- html5QrCode.clear()
- } catch (e) {
- // ignore
- }
- html5QrCode = null
- }
- }
- function onScanSuccess(decodedText) {
- // 扫码成功,震动反馈
- uni.vibrateShort()
- const params = parseQrUrl(decodedText)
- if (params) {
- stopScanner()
- uni.redirectTo({
- url: `/pages/hotel/scan-bind?c=${params.c}&t=${params.t}&s=${params.s}`,
- })
- } else {
- toast('无法识别该二维码,请扫描酒店房间二维码', { type: 'warning' })
- }
- }
- function parseQrUrl(url) {
- try {
- const matchC = url.match(/[?&]c=([^&]+)/)
- const matchT = url.match(/[?&]t=([^&]+)/)
- const matchS = url.match(/[?&]s=([^&]+)/)
- if (matchC && matchS) {
- return {
- c: matchC[1],
- t: matchT ? matchT[1] : '1',
- s: matchS[1],
- }
- }
- } catch (e) {
- // ignore
- }
- return null
- }
- function goBack() {
- uni.navigateBack({ delta: 1 })
- }
- async function handleAlbum() {
- if (!html5QrCode) return
- try {
- // 暂停实时扫描
- if (html5QrCode.isScanning) {
- await html5QrCode.pause(/* shouldPauseResume */true)
- }
- // 使用 html5-qrcode 的文件扫描功能
- const input = document.createElement('input')
- input.type = 'file'
- input.accept = 'image/*'
- input.capture = 'environment'
- input.style.display = 'none'
- input.onchange = async (e) => {
- const file = e.target.files?.[0]
- if (!file) {
- resumeScanner()
- return
- }
- try {
- const result = await html5QrCode.scanFileV2(file, {
- formatsSupported: Object.values(Html5QrcodeScanType),
- })
- onScanSuccess(result)
- } catch (err) {
- toast('未能从图片中识别二维码', { type: 'warning' })
- resumeScanner()
- } finally {
- input.remove()
- }
- }
- input.oncancel = () => {
- resumeScanner()
- input.remove()
- }
- document.body.appendChild(input)
- input.click()
- } catch (err) {
- console.error('Album scan error:', err)
- toast('打开相册失败', { type: 'error' })
- resumeScanner()
- }
- }
- async function resumeScanner() {
- if (html5QrCode && !isDestroyed) {
- try {
- await html5QrCode.resume()
- } catch (e) {
- // ignore
- }
- }
- }
- </script>
- <style lang="scss" scoped>
- .qr-scanner-page {
- position: relative;
- min-height: 100vh;
- background: #000;
- display: flex;
- flex-direction: column;
- }
- // Nav
- .qr-scanner-nav {
- position: relative;
- z-index: 20;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 24rpx;
- padding-top: env(safe-area-inset-top, 44px);
- height: calc(88rpx + env(safe-area-inset-top, 44px));
- background: rgba(0, 0, 0, 0.6);
- }
- .qr-scanner-nav-back {
- width: 72rpx;
- height: 72rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .qr-scanner-nav-back-icon {
- color: #fff;
- font-size: 40rpx;
- }
- .qr-scanner-nav-title {
- color: #fff;
- font-size: 32rpx;
- font-weight: 700;
- }
- .qr-scanner-nav-placeholder {
- width: 72rpx;
- }
- // Viewfinder
- .qr-scanner-viewfinder {
- flex: 1;
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- }
- .qr-scanner-reader {
- width: 100%;
- max-width: 600rpx;
- aspect-ratio: 1;
- :deep(video) {
- width: 100% !important;
- height: 100% !important;
- object-fit: cover;
- border-radius: 24rpx;
- }
- :deep(#qr-reader-dashboard) {
- display: none !important;
- }
- :deep(#qr-reader-status-container) {
- display: none !important;
- }
- }
- // Overlay with corners
- .qr-scanner-overlay {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 70%;
- aspect-ratio: 1;
- pointer-events: none;
- }
- .qr-scanner-corner {
- position: absolute;
- width: 48rpx;
- height: 48rpx;
- border-color: #2563eb;
- border-style: solid;
- border-width: 0;
- &--tl {
- top: 0;
- left: 0;
- border-top-width: 6rpx;
- border-left-width: 6rpx;
- border-top-left-radius: 16rpx;
- }
- &--tr {
- top: 0;
- right: 0;
- border-top-width: 6rpx;
- border-right-width: 6rpx;
- border-top-right-radius: 16rpx;
- }
- &--bl {
- bottom: 0;
- left: 0;
- border-bottom-width: 6rpx;
- border-left-width: 6rpx;
- border-bottom-left-radius: 16rpx;
- }
- &--br {
- bottom: 0;
- right: 0;
- border-bottom-width: 6rpx;
- border-right-width: 6rpx;
- border-bottom-right-radius: 16rpx;
- }
- }
- .qr-scanner-line {
- position: absolute;
- left: 5%;
- right: 5%;
- height: 4rpx;
- background: linear-gradient(90deg, transparent, #2563eb, transparent);
- animation: scanLine 2.5s ease-in-out infinite;
- }
- @keyframes scanLine {
- 0%, 100% { top: 5%; }
- 50% { top: 95%; }
- }
- // Camera error
- .qr-scanner-camera-error {
- position: absolute;
- inset: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 16rpx;
- background: rgba(0, 0, 0, 0.85);
- padding: 40rpx;
- }
- .qr-scanner-camera-error-icon {
- margin-bottom: 8rpx;
- }
- .qr-scanner-camera-error-title {
- color: #fff;
- font-size: 32rpx;
- font-weight: 700;
- }
- .qr-scanner-camera-error-desc {
- color: #94a3b8;
- font-size: 26rpx;
- text-align: center;
- margin-bottom: 16rpx;
- }
- .qr-scanner-camera-error-actions {
- margin-top: 8rpx;
- }
- // Footer
- .qr-scanner-footer {
- position: relative;
- z-index: 20;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 24rpx;
- padding: 40rpx 0;
- padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
- background: rgba(0, 0, 0, 0.6);
- }
- .qr-scanner-footer-text {
- color: rgba(255, 255, 255, 0.7);
- font-size: 26rpx;
- font-weight: 500;
- }
- .qr-scanner-footer-actions {
- display: flex;
- gap: 32rpx;
- }
- .qr-scanner-footer-btn {
- display: flex;
- align-items: center;
- gap: 8rpx;
- padding: 16rpx 32rpx;
- border-radius: 40rpx;
- background: rgba(255, 255, 255, 0.15);
- border: 1rpx solid rgba(255, 255, 255, 0.2);
- &:active {
- background: rgba(255, 255, 255, 0.25);
- }
- }
- .qr-scanner-footer-btn-text {
- color: #fff;
- font-size: 26rpx;
- font-weight: 600;
- }
- </style>
|