cwg-scan.vue 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. <template>
  2. <view class="cwg-language cursor-pointer" :data-tooltip="t('Downloadpage.item1')">
  3. <view class="pc-header-btn" @click="handleScanClick">
  4. <cwg-icon name="crm-scan" color="#97A1C0" :size="20" />
  5. <cwg-match-media :min-width="791">
  6. <view>{{ t('vu.login.item9') }}</view>
  7. </cwg-match-media>
  8. </view>
  9. <cwg-confirm-popup />
  10. <canvas :canvas-id="CANVAS_ID" :id="CANVAS_ID" class="scan-decode-canvas" />
  11. </view>
  12. </template>
  13. <script setup lang="ts">
  14. import { ref, getCurrentInstance } from 'vue'
  15. import { useI18n } from 'vue-i18n'
  16. import { userApi } from '@/api/user'
  17. import { useConfirm } from '@/hooks/useConfirm'
  18. import { decodeQrFromAlbumImage, CANVAS_ID } from '@/utils/decodeQrImage'
  19. import {
  20. ensureScanCameraPermission,
  21. ensureScanAlbumPermission,
  22. isPermissionError,
  23. isIosPermissionDenied,
  24. shouldGuideToSettings,
  25. openAppPermissionSetting,
  26. } from '@/utils/scanPermission'
  27. type ScanPermissionType = 'camera' | 'album'
  28. const confirm = useConfirm()
  29. const { t } = useI18n()
  30. const instance = getCurrentInstance()
  31. const SCAN_LOGIN_SCENE_REG = /^[a-f0-9]{32}$/i
  32. function parseScene(result: string): string {
  33. if (!result) return ''
  34. const trimmed = result.trim()
  35. const directMatch = trimmed.match(/[a-f0-9]{32}/i)
  36. if (directMatch?.[0]) return directMatch[0]
  37. try {
  38. const url = new URL(trimmed)
  39. const sceneParam = url.searchParams.get('scene')
  40. if (sceneParam && SCAN_LOGIN_SCENE_REG.test(sceneParam)) return sceneParam
  41. const lastPath = url.pathname.split('/').pop() || ''
  42. if (SCAN_LOGIN_SCENE_REG.test(lastPath)) return lastPath
  43. return trimmed
  44. } catch {
  45. return trimmed
  46. }
  47. }
  48. function isValidScanLoginScene(value: string): boolean {
  49. return SCAN_LOGIN_SCENE_REG.test(value)
  50. }
  51. const scene = ref('')
  52. const scanLoadingVisible = ref(false)
  53. function showScanLoading() {
  54. if (scanLoadingVisible.value) return
  55. scanLoadingVisible.value = true
  56. uni.showLoading({ title: t('vu.login.item14'), mask: true })
  57. }
  58. function hideScanLoading() {
  59. if (!scanLoadingVisible.value) return
  60. scanLoadingVisible.value = false
  61. uni.hideLoading()
  62. }
  63. function isUserCancelled(error: any) {
  64. if (error?._userCancelled) return true
  65. const errMsg = String(error?.errMsg || error?.message || error).toLowerCase()
  66. return (
  67. errMsg.includes('cancel')
  68. || errMsg.includes('取消')
  69. || errMsg.includes('no image')
  70. )
  71. }
  72. function isQrDecodeError(error: any) {
  73. if (error?._decodeFailed) return true
  74. const errMsg = String(error?.errMsg || error?.message || error).toLowerCase()
  75. return (
  76. errMsg.includes('decode')
  77. || errMsg.includes('识别')
  78. || errMsg.includes('image load')
  79. || error?.code === 8
  80. )
  81. }
  82. function getPermissionGuideContent(type: ScanPermissionType) {
  83. const usage = type === 'camera'
  84. ? (t('mine.p16') || '相机的使用')
  85. : (t('mine.p17') || '相册的使用')
  86. const tip = t('mine.p14') || '首次使用需授权。若已拒绝,请前往系统设置开启权限。'
  87. return `${usage},${tip}`
  88. }
  89. async function guideToPermissionSetting(type: ScanPermissionType) {
  90. const content = getPermissionGuideContent(type)
  91. await new Promise((resolve) => setTimeout(resolve, 400))
  92. return new Promise<void>((resolve) => {
  93. uni.showModal({
  94. title: t('Msg.SystemPrompt') || '系统提示',
  95. content,
  96. confirmText: t('mine.p13') || '去设置',
  97. cancelText: t('Btn.Cancel') || '取消',
  98. showCancel: true,
  99. success: (res) => {
  100. if (res.confirm) {
  101. openAppPermissionSetting()
  102. }
  103. resolve()
  104. },
  105. fail: () => resolve(),
  106. })
  107. })
  108. }
  109. async function ensureScanPermission(type: ScanPermissionType): Promise<boolean> {
  110. const state = type === 'camera'
  111. ? await ensureScanCameraPermission()
  112. : await ensureScanAlbumPermission()
  113. if (state === 'granted') {
  114. return true
  115. }
  116. await guideToPermissionSetting(type)
  117. return false
  118. }
  119. async function runWithPermission<T>(
  120. type: ScanPermissionType,
  121. action: () => Promise<T>,
  122. ): Promise<T> {
  123. if (!await ensureScanPermission(type)) {
  124. const err: any = new Error('permission denied')
  125. err._permissionDenied = true
  126. err._alreadyGuided = true
  127. throw err
  128. }
  129. try {
  130. return await action()
  131. } catch (error: any) {
  132. if (!error?._alreadyGuided && (
  133. isIosPermissionDenied(type)
  134. || shouldGuideToSettings(
  135. type === 'camera'
  136. ? await ensureScanCameraPermission()
  137. : await ensureScanAlbumPermission(),
  138. )
  139. || isPermissionError(error)
  140. )) {
  141. await guideToPermissionSetting(type)
  142. error._permissionDenied = true
  143. error._alreadyGuided = true
  144. }
  145. throw error
  146. }
  147. }
  148. function scanByCamera(): Promise<string> {
  149. return new Promise((resolve, reject) => {
  150. uni.scanCode({
  151. onlyFromCamera: true,
  152. scanType: ['qrCode'],
  153. success: (res) => resolve(res.result),
  154. fail: (err) => {
  155. if (isIosPermissionDenied('camera')) {
  156. reject({ ...err, _permissionDenied: true })
  157. return
  158. }
  159. reject(err)
  160. },
  161. })
  162. })
  163. }
  164. function scanByAlbumApp(path: string): Promise<string> {
  165. return decodeQrFromAlbumImage(path, instance)
  166. }
  167. function scanByAlbum(): Promise<string> {
  168. // #ifdef APP-PLUS
  169. return new Promise((resolve, reject) => {
  170. uni.chooseImage({
  171. count: 1,
  172. sourceType: ['album'],
  173. sizeType: ['compressed'],
  174. success: async (chooseRes) => {
  175. const path = chooseRes.tempFilePaths[0]
  176. if (!path) {
  177. reject({ message: 'no image', _userCancelled: true })
  178. return
  179. }
  180. showScanLoading()
  181. try {
  182. resolve(await scanByAlbumApp(path))
  183. } catch (err: any) {
  184. hideScanLoading()
  185. err._decodeFailed = true
  186. reject(err)
  187. }
  188. },
  189. fail: (err) => {
  190. if (isIosPermissionDenied('album')) {
  191. reject({ ...err, _permissionDenied: true })
  192. return
  193. }
  194. reject({ ...err, _userCancelled: true })
  195. },
  196. })
  197. })
  198. // #endif
  199. // #ifndef APP-PLUS
  200. return new Promise((resolve, reject) => {
  201. uni.scanCode({
  202. onlyFromCamera: false,
  203. scanType: ['qrCode'],
  204. success: (res) => resolve(res.result),
  205. fail: reject,
  206. })
  207. })
  208. // #endif
  209. }
  210. function pickScanResult(): Promise<string> {
  211. return new Promise((resolve, reject) => {
  212. uni.showActionSheet({
  213. itemList: [t('vu.login.item13'), t('vu.login.item12')],
  214. success: (sheetRes) => {
  215. resolve(sheetRes.tapIndex)
  216. },
  217. fail: reject,
  218. })
  219. }).then(async (tapIndex) => {
  220. // 等 actionSheet 完全关闭后再检查权限、弹窗,避免 iOS 吞掉 showModal
  221. await new Promise((r) => setTimeout(r, 400))
  222. if (tapIndex === 0) {
  223. return runWithPermission('camera', scanByCamera)
  224. }
  225. return runWithPermission('album', scanByAlbum)
  226. })
  227. }
  228. async function processScanResult(rawResult: string) {
  229. showScanLoading()
  230. try {
  231. scene.value = parseScene(rawResult)
  232. if (!isValidScanLoginScene(scene.value)) {
  233. hideScanLoading()
  234. uni.$u.toast(t('vu.login.item10') || t('login.msg0'))
  235. return
  236. }
  237. await userApi.updateAppScanLoginStatus({ scene: scene.value, status: 3 })
  238. hideScanLoading()
  239. await confirm({
  240. title: t('vu.login.item8'),
  241. content: t('vu.login.item7'),
  242. confirmText: t('newSignin.item7'),
  243. cancelText: t('Btn.Cancel'),
  244. })
  245. const res = await userApi.confirmAppScanLogin({ scene: scene.value })
  246. scene.value = ''
  247. if (res.code === 200) {
  248. uni.$u.toast(res.msg || t('login.msg0_1'))
  249. } else {
  250. uni.$u.toast(res.msg || t('login.msg0'))
  251. }
  252. } catch (error) {
  253. hideScanLoading()
  254. throw error
  255. }
  256. }
  257. async function handleScanClick() {
  258. try {
  259. const rawResult = await pickScanResult()
  260. await processScanResult(rawResult)
  261. } catch (error: any) {
  262. const errMsg = String(error?.errMsg || error?.message || error)
  263. if (isUserCancelled(error)) {
  264. if (isValidScanLoginScene(scene.value)) {
  265. try {
  266. await userApi.updateAppScanLoginStatus({
  267. scene: scene.value,
  268. status: 0,
  269. })
  270. scene.value = ''
  271. } catch {
  272. // 二维码已过期等情况,忽略回滚失败
  273. }
  274. }
  275. return
  276. }
  277. scene.value = ''
  278. if (isPermissionError(error) || errMsg.includes('permission denied')) {
  279. return
  280. }
  281. if (isQrDecodeError(error)) {
  282. uni.$u.toast(
  283. t('vu.login.item15')
  284. || '图片二维码反光/模糊,请截图后重试或正对屏幕拍摄',
  285. )
  286. return
  287. }
  288. uni.$u.toast(error?.msg || error?.errMsg || t('login.msg0'))
  289. }
  290. }
  291. </script>
  292. <style scoped lang="scss">
  293. @import "@/uni.scss";
  294. .pc-header-btn {
  295. width: auto;
  296. display: flex;
  297. align-items: center;
  298. cursor: pointer;
  299. gap: px2rpx(6);
  300. padding: 0 px2rpx(5);
  301. }
  302. .cwg-language {
  303. @media screen and (max-width: 991px) {
  304. :deep(.cwg-dropdown-menu-container) {
  305. right: px2rpx(-20) !important;
  306. }
  307. }
  308. }
  309. :deep(.cwg-dropdown-menu-container .menu .menu-item) {
  310. min-height: px2rpx(36);
  311. }
  312. :deep(.cwg-dropdown) {
  313. overflow: visible !important;
  314. }
  315. .scan-decode-canvas {
  316. position: fixed;
  317. left: -9999px;
  318. top: -9999px;
  319. width: 1200px;
  320. height: 1200px;
  321. opacity: 0;
  322. pointer-events: none;
  323. }
  324. </style>