qr-scanner.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. <template>
  2. <view class="qr-scanner-page">
  3. <!-- 顶部导航 -->
  4. <view class="qr-scanner-nav">
  5. <view class="qr-scanner-nav-back" @click="goBack">
  6. <text class="qr-scanner-nav-back-icon">&larr;</text>
  7. </view>
  8. <text class="qr-scanner-nav-title">扫描二维码</text>
  9. <view class="qr-scanner-nav-placeholder" />
  10. </view>
  11. <!-- 相机区域 -->
  12. <view class="qr-scanner-viewfinder">
  13. <view id="qr-reader" class="qr-scanner-reader" />
  14. <!-- 扫描框覆盖层 -->
  15. <view class="qr-scanner-overlay">
  16. <view class="qr-scanner-corner qr-scanner-corner--tl" />
  17. <view class="qr-scanner-corner qr-scanner-corner--tr" />
  18. <view class="qr-scanner-corner qr-scanner-corner--bl" />
  19. <view class="qr-scanner-corner qr-scanner-corner--br" />
  20. <view class="qr-scanner-line" />
  21. </view>
  22. <!-- 相机未授权时的提示 -->
  23. <view v-if="cameraError" class="qr-scanner-camera-error">
  24. <view class="qr-scanner-camera-error-icon">
  25. <AiIcon icon="/static/icons/ai-icon/alert-triangle.svg" color="#f59e0b" size="lg" />
  26. </view>
  27. <text class="qr-scanner-camera-error-title">无法访问摄像头</text>
  28. <text class="qr-scanner-camera-error-desc">{{ cameraErrorMsg }}</text>
  29. <view class="qr-scanner-camera-error-actions">
  30. <AiButton size="sm" @click="startScanner">重试</AiButton>
  31. </view>
  32. </view>
  33. </view>
  34. <!-- 底部提示 -->
  35. <view class="qr-scanner-footer">
  36. <text class="qr-scanner-footer-text">将二维码放入框内,即可自动扫描</text>
  37. <view class="qr-scanner-footer-actions">
  38. <view class="qr-scanner-footer-btn" @click="handleAlbum">
  39. <AiIcon icon="/static/icons/ai-icon/image.svg" color="#ffffff" size="sm" />
  40. <text class="qr-scanner-footer-btn-text">从相册选择</text>
  41. </view>
  42. </view>
  43. </view>
  44. </view>
  45. </template>
  46. <script setup>
  47. import { onMounted, onBeforeUnmount, ref } from 'vue'
  48. import AiIcon from '@/components/AiIcon.vue'
  49. import AiButton from '@/components/AiButton.vue'
  50. import { Html5Qrcode, Html5QrcodeScanType } from 'html5-qrcode'
  51. import { toast } from '@/utils/notify'
  52. const cameraError = ref(false)
  53. const cameraErrorMsg = ref('')
  54. let html5QrCode = null
  55. let isDestroyed = false
  56. onMounted(() => {
  57. startScanner()
  58. })
  59. onBeforeUnmount(() => {
  60. stopScanner()
  61. })
  62. async function startScanner() {
  63. cameraError.value = false
  64. cameraErrorMsg.value = ''
  65. try {
  66. if (html5QrCode) {
  67. try {
  68. await html5QrCode.stop()
  69. } catch (e) {
  70. // ignore
  71. }
  72. html5QrCode.clear()
  73. html5QrCode = null
  74. }
  75. html5QrCode = new Html5Qrcode('qr-reader', { verbose: false })
  76. const config = {
  77. fps: 15,
  78. qrbox: (viewfinderWidth, viewfinderHeight) => {
  79. const minEdge = Math.min(viewfinderWidth, viewfinderHeight)
  80. const size = Math.floor(minEdge * 0.7)
  81. return { width: size, height: size }
  82. },
  83. aspectRatio: 1.0,
  84. }
  85. // 优先使用后置摄像头
  86. const cameras = await Html5Qrcode.getCameras()
  87. if (!cameras || cameras.length === 0) {
  88. throw new Error('未检测到摄像头设备')
  89. }
  90. let cameraId = cameras[0].id
  91. // 查找后置摄像头
  92. const backCamera = cameras.find(c =>
  93. /back|rear|environment/i.test(c.label)
  94. )
  95. if (backCamera) {
  96. cameraId = backCamera.id
  97. }
  98. await html5QrCode.start(
  99. cameraId,
  100. config,
  101. onScanSuccess,
  102. () => { /* onScanFailure - ignore */ }
  103. )
  104. } catch (err) {
  105. console.error('Camera error:', err)
  106. cameraError.value = true
  107. if (err.name === 'NotAllowedError' || err.message?.includes('Permission')) {
  108. cameraErrorMsg.value = '请在浏览器设置中允许访问摄像头'
  109. } else if (err.name === 'NotFoundError' || err.message?.includes('未检测')) {
  110. cameraErrorMsg.value = '未检测到摄像头设备,请确认设备已连接'
  111. } else if (err.name === 'NotReadableError') {
  112. cameraErrorMsg.value = '摄像头被占用,请关闭其他应用后重试'
  113. } else if (err.message?.includes('HTTPS') || err.message?.includes('secure')) {
  114. cameraErrorMsg.value = '摄像头功能需要 HTTPS 或 localhost 环境'
  115. } else {
  116. cameraErrorMsg.value = err.message || '摄像头启动失败'
  117. }
  118. }
  119. }
  120. async function stopScanner() {
  121. isDestroyed = true
  122. if (html5QrCode) {
  123. try {
  124. if (html5QrCode.isScanning) {
  125. await html5QrCode.stop()
  126. }
  127. } catch (e) {
  128. // ignore
  129. }
  130. try {
  131. html5QrCode.clear()
  132. } catch (e) {
  133. // ignore
  134. }
  135. html5QrCode = null
  136. }
  137. }
  138. function onScanSuccess(decodedText) {
  139. // 扫码成功,震动反馈
  140. uni.vibrateShort()
  141. const params = parseQrUrl(decodedText)
  142. if (params) {
  143. stopScanner()
  144. uni.redirectTo({
  145. url: `/pages/hotel/scan-bind?c=${params.c}&t=${params.t}&s=${params.s}`,
  146. })
  147. } else {
  148. toast('无法识别该二维码,请扫描酒店房间二维码', { type: 'warning' })
  149. }
  150. }
  151. function parseQrUrl(url) {
  152. try {
  153. const matchC = url.match(/[?&]c=([^&]+)/)
  154. const matchT = url.match(/[?&]t=([^&]+)/)
  155. const matchS = url.match(/[?&]s=([^&]+)/)
  156. if (matchC && matchS) {
  157. return {
  158. c: matchC[1],
  159. t: matchT ? matchT[1] : '1',
  160. s: matchS[1],
  161. }
  162. }
  163. } catch (e) {
  164. // ignore
  165. }
  166. return null
  167. }
  168. function goBack() {
  169. uni.navigateBack({ delta: 1 })
  170. }
  171. async function handleAlbum() {
  172. if (!html5QrCode) return
  173. try {
  174. // 暂停实时扫描
  175. if (html5QrCode.isScanning) {
  176. await html5QrCode.pause(/* shouldPauseResume */true)
  177. }
  178. // 使用 html5-qrcode 的文件扫描功能
  179. const input = document.createElement('input')
  180. input.type = 'file'
  181. input.accept = 'image/*'
  182. input.capture = 'environment'
  183. input.style.display = 'none'
  184. input.onchange = async (e) => {
  185. const file = e.target.files?.[0]
  186. if (!file) {
  187. resumeScanner()
  188. return
  189. }
  190. try {
  191. const result = await html5QrCode.scanFileV2(file, {
  192. formatsSupported: Object.values(Html5QrcodeScanType),
  193. })
  194. onScanSuccess(result)
  195. } catch (err) {
  196. toast('未能从图片中识别二维码', { type: 'warning' })
  197. resumeScanner()
  198. } finally {
  199. input.remove()
  200. }
  201. }
  202. input.oncancel = () => {
  203. resumeScanner()
  204. input.remove()
  205. }
  206. document.body.appendChild(input)
  207. input.click()
  208. } catch (err) {
  209. console.error('Album scan error:', err)
  210. toast('打开相册失败', { type: 'error' })
  211. resumeScanner()
  212. }
  213. }
  214. async function resumeScanner() {
  215. if (html5QrCode && !isDestroyed) {
  216. try {
  217. await html5QrCode.resume()
  218. } catch (e) {
  219. // ignore
  220. }
  221. }
  222. }
  223. </script>
  224. <style lang="scss" scoped>
  225. .qr-scanner-page {
  226. position: relative;
  227. min-height: 100vh;
  228. background: #000;
  229. display: flex;
  230. flex-direction: column;
  231. }
  232. // Nav
  233. .qr-scanner-nav {
  234. position: relative;
  235. z-index: 20;
  236. display: flex;
  237. align-items: center;
  238. justify-content: space-between;
  239. padding: 0 24rpx;
  240. padding-top: env(safe-area-inset-top, 44px);
  241. height: calc(88rpx + env(safe-area-inset-top, 44px));
  242. background: rgba(0, 0, 0, 0.6);
  243. }
  244. .qr-scanner-nav-back {
  245. width: 72rpx;
  246. height: 72rpx;
  247. display: flex;
  248. align-items: center;
  249. justify-content: center;
  250. }
  251. .qr-scanner-nav-back-icon {
  252. color: #fff;
  253. font-size: 40rpx;
  254. }
  255. .qr-scanner-nav-title {
  256. color: #fff;
  257. font-size: 32rpx;
  258. font-weight: 700;
  259. }
  260. .qr-scanner-nav-placeholder {
  261. width: 72rpx;
  262. }
  263. // Viewfinder
  264. .qr-scanner-viewfinder {
  265. flex: 1;
  266. position: relative;
  267. display: flex;
  268. align-items: center;
  269. justify-content: center;
  270. overflow: hidden;
  271. }
  272. .qr-scanner-reader {
  273. width: 100%;
  274. max-width: 600rpx;
  275. aspect-ratio: 1;
  276. :deep(video) {
  277. width: 100% !important;
  278. height: 100% !important;
  279. object-fit: cover;
  280. border-radius: 24rpx;
  281. }
  282. :deep(#qr-reader-dashboard) {
  283. display: none !important;
  284. }
  285. :deep(#qr-reader-status-container) {
  286. display: none !important;
  287. }
  288. }
  289. // Overlay with corners
  290. .qr-scanner-overlay {
  291. position: absolute;
  292. top: 50%;
  293. left: 50%;
  294. transform: translate(-50%, -50%);
  295. width: 70%;
  296. aspect-ratio: 1;
  297. pointer-events: none;
  298. }
  299. .qr-scanner-corner {
  300. position: absolute;
  301. width: 48rpx;
  302. height: 48rpx;
  303. border-color: #2563eb;
  304. border-style: solid;
  305. border-width: 0;
  306. &--tl {
  307. top: 0;
  308. left: 0;
  309. border-top-width: 6rpx;
  310. border-left-width: 6rpx;
  311. border-top-left-radius: 16rpx;
  312. }
  313. &--tr {
  314. top: 0;
  315. right: 0;
  316. border-top-width: 6rpx;
  317. border-right-width: 6rpx;
  318. border-top-right-radius: 16rpx;
  319. }
  320. &--bl {
  321. bottom: 0;
  322. left: 0;
  323. border-bottom-width: 6rpx;
  324. border-left-width: 6rpx;
  325. border-bottom-left-radius: 16rpx;
  326. }
  327. &--br {
  328. bottom: 0;
  329. right: 0;
  330. border-bottom-width: 6rpx;
  331. border-right-width: 6rpx;
  332. border-bottom-right-radius: 16rpx;
  333. }
  334. }
  335. .qr-scanner-line {
  336. position: absolute;
  337. left: 5%;
  338. right: 5%;
  339. height: 4rpx;
  340. background: linear-gradient(90deg, transparent, #2563eb, transparent);
  341. animation: scanLine 2.5s ease-in-out infinite;
  342. }
  343. @keyframes scanLine {
  344. 0%, 100% { top: 5%; }
  345. 50% { top: 95%; }
  346. }
  347. // Camera error
  348. .qr-scanner-camera-error {
  349. position: absolute;
  350. inset: 0;
  351. display: flex;
  352. flex-direction: column;
  353. align-items: center;
  354. justify-content: center;
  355. gap: 16rpx;
  356. background: rgba(0, 0, 0, 0.85);
  357. padding: 40rpx;
  358. }
  359. .qr-scanner-camera-error-icon {
  360. margin-bottom: 8rpx;
  361. }
  362. .qr-scanner-camera-error-title {
  363. color: #fff;
  364. font-size: 32rpx;
  365. font-weight: 700;
  366. }
  367. .qr-scanner-camera-error-desc {
  368. color: #94a3b8;
  369. font-size: 26rpx;
  370. text-align: center;
  371. margin-bottom: 16rpx;
  372. }
  373. .qr-scanner-camera-error-actions {
  374. margin-top: 8rpx;
  375. }
  376. // Footer
  377. .qr-scanner-footer {
  378. position: relative;
  379. z-index: 20;
  380. display: flex;
  381. flex-direction: column;
  382. align-items: center;
  383. gap: 24rpx;
  384. padding: 40rpx 0;
  385. padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
  386. background: rgba(0, 0, 0, 0.6);
  387. }
  388. .qr-scanner-footer-text {
  389. color: rgba(255, 255, 255, 0.7);
  390. font-size: 26rpx;
  391. font-weight: 500;
  392. }
  393. .qr-scanner-footer-actions {
  394. display: flex;
  395. gap: 32rpx;
  396. }
  397. .qr-scanner-footer-btn {
  398. display: flex;
  399. align-items: center;
  400. gap: 8rpx;
  401. padding: 16rpx 32rpx;
  402. border-radius: 40rpx;
  403. background: rgba(255, 255, 255, 0.15);
  404. border: 1rpx solid rgba(255, 255, 255, 0.2);
  405. &:active {
  406. background: rgba(255, 255, 255, 0.25);
  407. }
  408. }
  409. .qr-scanner-footer-btn-text {
  410. color: #fff;
  411. font-size: 26rpx;
  412. font-weight: 600;
  413. }
  414. </style>