zhb před 9 hodinami
rodič
revize
ec7d215909

+ 17 - 1
api/user.ts

@@ -54,5 +54,21 @@ export const userApi = {
     // 获取客户经理信息
     getManagerInfo: () => {
         return post('/chat/get/manager/info', {})
-    }
+    },
+    // 获取APP扫描登录
+    getAppScanLogin: () => {
+        return post('/custom/app/scan/login/create', {})
+    },
+    // 扫码确认
+    confirmAppScanLogin: (params: { scene: string }) => {
+        return post('/custom/app/scan/login/confirm', params)
+    },
+    // 查询扫码状态
+    getAppScanLoginStatus: (params: { scene: string }) => {
+        return post('/custom/app/scan/login/status', params)
+    },
+    // 更新扫码状态
+    updateAppScanLoginStatus: (params: { scene: string; status: string }) => {
+        return post('/custom/app/scan/login/status/update', params)
+    },
 }

+ 3 - 1
components/cwg-download.vue

@@ -2,7 +2,9 @@
   <view class="cwg-language cursor-pointer" :data-tooltip="t('Downloadpage.item1')">
     <view class="pc-header-btn" @click="handleMenuClick">
       <cwg-icon name="crm-download" color="#97A1C0" :size="20" />
-      <view>{{t('Downloadpage.item1')}}</view>
+      <cwg-match-media :min-width="791">
+        <view>{{t('Downloadpage.item1')}}</view>
+      </cwg-match-media>
     </view>
   </view>
 

+ 4 - 2
components/cwg-notice.vue

@@ -5,7 +5,9 @@
                 <text v-if="isRed"
                     class="position-absolute top-0 left-15 p-1 mt-1 me-1 bg-danger border border-3 border-light rounded-circle" />
                 <cwg-icon name="cwg-bell" color="#97A1C0" :size="20" @click="openNotice" />
-                <view class="ml-5">{{ t('News.Notice') }}</view>
+                <cwg-match-media :min-width="791">
+                  <view class="ml-5">{{ t('News.Notice') }}</view>
+                </cwg-match-media>
             </view>
             <template #btn>
                 <view class="dropdown-menu dropdown-menu-lg-end p-0 w-300px mt-2 show">
@@ -141,7 +143,7 @@ onUnmounted(() => {
     }
     @media screen and (max-width: 700px) {
         :deep(.cwg-dropdown-menu-container) {
-            left: px2rpx(-160) !important;
+            left: px2rpx(-220) !important;
             max-width: px2rpx(400);
         }
     }

+ 10 - 29
components/cwg-pc-header.vue

@@ -1,46 +1,27 @@
 <template>
-	<uni-nav-bar
-		:leftWidth="0"
-		:rightWidth="0"
-		:statusBar="true"
-		:fixed="true"
-		:height="55"
-		:border="false"
-	>
+	<uni-nav-bar :leftWidth="0" :rightWidth="0" :statusBar="true" :fixed="true" :height="55" :border="false">
 		<view class="cwg-pc-header bg-body">
 			<!-- 左侧 -->
 			<view class="left">
-				<cwg-icon
-					:name="!sidebarVisible ? 'crm-bars-staggered' : 'cwg-close'"
-					color="#97A1C0"
-					@click="openLeftDrawer"
-				/>
+				<cwg-icon :name="!sidebarVisible ? 'crm-bars-staggered' : 'cwg-close'" color="#97A1C0"
+					@click="openLeftDrawer" />
 			</view>
 
 			<!-- 中间 Logo -->
 			<view class="center-logo" v-if="!isMobile">
-				<image
-					v-if="!isDark"
-					class="logo-img"
-					src="/static/images/vu/logo-full.svg"
-					mode="widthFix"
-					alt="logo"
-				/>
-
-				<image
-					v-else
-					class="logo-img"
-					src="/static/images/vu/logo-full-white.svg"
-					mode="widthFix"
-					alt="logo"
-				/>
+				<image v-if="!isDark" class="logo-img" src="/static/images/vu/logo-full.svg" mode="widthFix"
+					alt="logo" />
+
+				<image v-else class="logo-img" src="/static/images/vu/logo-full-white.svg" mode="widthFix" alt="logo" />
 			</view>
 
 			<!-- 右侧 -->
 			<view class="right">
 				<cwg-download />
 				<cwg-notice />
-				<!-- <cwg-scan /> -->
+				// #ifdef APP-PLUS
+				<cwg-scan />
+				// #endif
 				<cwg-right-drawer />
 			</view>
 		</view>

+ 9 - 1
components/cwg-right-drawer.vue

@@ -3,7 +3,9 @@
         <cwg-dropdown ref="dropdownRef" :menu-list="[]">
             <view class="pc-header-btn cursor-pointer" :data-tooltip="t('vu.tooltip.t100')">
                 <cwg-icon name="icon_my" color="#97A1C0" @click="openNotice" />
-              <view>{{ name }}</view>
+              <cwg-match-media :min-width="791">
+                <view>{{ name }}</view>
+              </cwg-match-media>
             </view>
             <template #btn>
                 <view class="dropdown-menu dropdown-menu-end w-225px mt-1 show">
@@ -205,6 +207,12 @@ defineExpose({ openNotice, close })
             max-width: px2rpx(400);
         }
     }
+    @media screen and (max-width: 768px) {
+        :deep(.cwg-dropdown-menu-container) {
+            left: px2rpx(-186) !important;
+            max-width: px2rpx(400);
+        }
+    }
 
     .pc-header-btn {
       width: auto;

+ 201 - 4
components/cwg-scan.vue

@@ -2,18 +2,205 @@
   <view class="cwg-language cursor-pointer" :data-tooltip="t('Downloadpage.item1')">
     <view class="pc-header-btn" @click="handleScanClick">
       <cwg-icon name="crm-scan" color="#97A1C0" :size="20" />
-      <!-- <view></view> -->
+      <cwg-match-media :min-width="791">
+        <view>{{ t('vu.login.item9') }}</view>
+      </cwg-match-media>
     </view>
+    <cwg-confirm-popup />
+    <canvas :canvas-id="CANVAS_ID" :id="CANVAS_ID" class="scan-decode-canvas" />
   </view>
 </template>
 
 <script setup lang="ts">
+import { ref, getCurrentInstance } from 'vue'
 import { useI18n } from 'vue-i18n'
+import { userApi } from '@/api/user'
+import { useConfirm } from '@/hooks/useConfirm'
+import { decodeQrFromAlbumImage, CANVAS_ID } from '@/utils/decodeQrImage'
+
+const confirm = useConfirm()
 const { t } = useI18n()
-function handleScanClick() {
-  uni.navigateTo({
-    url: '/pages/common/scan',
+const instance = getCurrentInstance()
+
+const SCAN_LOGIN_SCENE_REG = /^[a-f0-9]{32}$/i
+
+function parseScene(result: string): string {
+  if (!result) return ''
+  const trimmed = result.trim()
+  const directMatch = trimmed.match(/[a-f0-9]{32}/i)
+  if (directMatch?.[0]) return directMatch[0]
+  try {
+    const url = new URL(trimmed)
+    const sceneParam = url.searchParams.get('scene')
+    if (sceneParam && SCAN_LOGIN_SCENE_REG.test(sceneParam)) return sceneParam
+    const lastPath = url.pathname.split('/').pop() || ''
+    if (SCAN_LOGIN_SCENE_REG.test(lastPath)) return lastPath
+    return trimmed
+  } catch {
+    return trimmed
+  }
+}
+
+function isValidScanLoginScene(value: string): boolean {
+  return SCAN_LOGIN_SCENE_REG.test(value)
+}
+
+const scene = ref('')
+const scanLoadingVisible = ref(false)
+
+function showScanLoading() {
+  if (scanLoadingVisible.value) return
+  scanLoadingVisible.value = true
+  uni.showLoading({ title: t('vu.login.item14'), mask: true })
+}
+
+function hideScanLoading() {
+  if (!scanLoadingVisible.value) return
+  scanLoadingVisible.value = false
+  uni.hideLoading()
+}
+
+function scanByCamera(): Promise<string> {
+  return new Promise((resolve, reject) => {
+    uni.scanCode({
+      onlyFromCamera: true,
+      scanType: ['qrCode'],
+      success: (res) => resolve(res.result),
+      fail: reject,
+    })
+  })
+}
+
+function scanByAlbumApp(path: string): Promise<string> {
+  return decodeQrFromAlbumImage(path, instance)
+}
+
+function scanByAlbum(): Promise<string> {
+  // #ifdef APP-PLUS
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count: 1,
+      sourceType: ['album'],
+      sizeType: ['compressed'],
+      success: async (chooseRes) => {
+        const path = chooseRes.tempFilePaths[0]
+        if (!path) {
+          reject(new Error('no image'))
+          return
+        }
+        showScanLoading()
+        try {
+          resolve(await scanByAlbumApp(path))
+        } catch (err) {
+          hideScanLoading()
+          reject(err)
+        }
+      },
+      fail: reject,
+    })
   })
+  // #endif
+  // #ifndef APP-PLUS
+  return new Promise((resolve, reject) => {
+    uni.scanCode({
+      onlyFromCamera: false,
+      scanType: ['qrCode'],
+      success: (res) => resolve(res.result),
+      fail: reject,
+    })
+  })
+  // #endif
+}
+
+function pickScanResult(): Promise<string> {
+  return new Promise((resolve, reject) => {
+    uni.showActionSheet({
+      itemList: [t('vu.login.item13'), t('vu.login.item12')],
+      success: async (sheetRes) => {
+        try {
+          const result = sheetRes.tapIndex === 0
+            ? await scanByCamera()
+            : await scanByAlbum()
+          resolve(result)
+        } catch (err) {
+          reject(err)
+        }
+      },
+      fail: reject,
+    })
+  })
+}
+
+async function processScanResult(rawResult: string) {
+  showScanLoading()
+  try {
+    scene.value = parseScene(rawResult)
+    if (!isValidScanLoginScene(scene.value)) {
+      hideScanLoading()
+      uni.$u.toast(t('vu.login.item10') || t('login.msg0'))
+      return
+    }
+
+    await userApi.updateAppScanLoginStatus({ scene: scene.value, status: 3 })
+    hideScanLoading()
+    await confirm({
+      title: t('vu.login.item8'),
+      content: t('vu.login.item7'),
+      confirmText: t('newSignin.item7'),
+      cancelText: t('Btn.Cancel'),
+    })
+
+    const res = await userApi.confirmAppScanLogin({ scene: scene.value })
+    scene.value = ''
+    if (res.code === 200) {
+      uni.$u.toast(res.msg || t('login.msg0_1'))
+    } else {
+      uni.$u.toast(res.msg || t('login.msg0'))
+    }
+  } catch (error) {
+    hideScanLoading()
+    throw error
+  }
+}
+
+async function handleScanClick() {
+  try {
+    const rawResult = await pickScanResult()
+    await processScanResult(rawResult)
+  } catch (error: any) {
+    const errMsg = String(error?.errMsg || error?.message || error)
+    const isCancel = errMsg.includes('cancel') || errMsg.includes('Cancel')
+    if (isCancel) {
+      if (isValidScanLoginScene(scene.value)) {
+        try {
+          await userApi.updateAppScanLoginStatus({
+            scene: scene.value,
+            status: 0,
+          })
+          scene.value = ''
+        } catch {
+          // 二维码已过期等情况,忽略回滚失败
+        }
+      }
+      return
+    }
+    scene.value = ''
+    const isDecodeError = (
+      errMsg.includes('fail')
+      || errMsg.includes('识别')
+      || errMsg.includes('decode')
+      || errMsg.includes('image load')
+      || error?.code === 8
+    )
+    if (isDecodeError) {
+      uni.$u.toast(
+        t('vu.login.item15')
+        || '图片二维码反光/模糊,请截图后重试或正对屏幕拍摄',
+      )
+      return
+    }
+    uni.$u.toast(error?.msg || error?.errMsg || t('login.msg0'))
+  }
 }
 </script>
 
@@ -45,4 +232,14 @@ function handleScanClick() {
 :deep(.cwg-dropdown) {
   overflow: visible !important;
 }
+
+.scan-decode-canvas {
+  position: fixed;
+  left: -9999px;
+  top: -9999px;
+  width: 1200px;
+  height: 1200px;
+  opacity: 0;
+  pointer-events: none;
+}
 </style>

+ 33 - 14
config/domainState.ts

@@ -32,19 +32,38 @@ export function resetToDefaultDomain() {
 
 export function buildHostUrls() {
   return {
-    HostWs: `wss://ws.${ho}.${dt}`,
-    Host80: `${ht}//secure.${ho}.${dt}`,
-    Host00: `${ht}//ucard.${ho}.${dt}`,
-    Host85: `${ht}//ucard.${ho}.${dt}`,
-    Host04: `${ht}//pay.${ho}.${dt}`,
-    Host90: `${ht}//data.${ho}.${dt}`,
-    HostShop: `${ht}//shopcustom.${ho}.${dt}`,
-    HostShopImg: `${ht}//shopmanager.${ho}.${dt}`,
-    Host87: `${ht}//followup.${ho}.${dt}`,
-    Host05: `${ht}//file.${ho}.${dt}`,
-    HostEnter: `${ht}//ad.${ho}.${dt}`,
-    // Host80:'http://192.168.0.23:8000',
-    // Host04: `http://192.168.0.23:8004`,
-    // Host90: `http://192.168.0.23:9000`,
+    // HostWs: `wss://ws.${ho}.${dt}`,
+    // Host80: `${ht}//secure.${ho}.${dt}`,
+    // Host00: `${ht}//ucard.${ho}.${dt}`,
+    // Host85: `${ht}//ucard.${ho}.${dt}`,
+    // Host04: `${ht}//pay.${ho}.${dt}`,
+    // Host90: `${ht}//data.${ho}.${dt}`,
+    // HostShop: `${ht}//shopcustom.${ho}.${dt}`,
+    // HostShopImg: `${ht}//shopmanager.${ho}.${dt}`,
+    // Host87: `${ht}//followup.${ho}.${dt}`,
+    // Host05: `${ht}//file.${ho}.${dt}`,
+    // HostEnter: `${ht}//ad.${ho}.${dt}`,
+
+    HostWs: `wss://ws.${'44a5c8109e4'}.${'com'}`,
+    Host80: `${ht}//secure.${'44a5c8109e4'}.${'com'}`,
+    Host00: `${ht}//ucard.${'44a5c8109e4'}.${'com'}`,
+    Host85: `${ht}//ucard.${'44a5c8109e4'}.${'com'}`,
+    Host04: `${ht}//pay.${'44a5c8109e4'}.${'com'}`,
+    Host90: `${ht}//data.${'44a5c8109e4'}.${'com'}`,
+    HostShop: `${ht}//shopcustom.${'44a5c8109e4'}.${'com'}`,
+    HostShopImg: `${ht}//shopmanager.${'44a5c8109e4'}.${'com'}`,
+    Host87: `${ht}//followup.${'44a5c8109e4'}.${'com'}`,
+    Host05: `${ht}//file.${'44a5c8109e4'}.${'com'}`,
+    HostEnter: `${ht}//ad.${'44a5c8109e4'}.${'com'}`,
+
+    // HostWs: "ws://192.168.0.23:8105",
+    // Host80: "http://192.168.0.23:8000",
+    // Host04: "http://192.168.0.23:8004",
+    // Host90: "http://192.168.0.23:9000",
+    // HostShop: "http://192.168.0.23:8200",
+    // HostShopImg: "http://192.168.0.23:8501",
+    // Host87: "http://192.168.0.23:8800",
+    // Host05: "http://192.168.0.23:8705",
+    // HostEnter: "http://localhost:8088"
   }
 }

+ 1 - 1
config/index.ts

@@ -3,7 +3,7 @@ import { getDomainParts, setDomainParts, buildHostUrls } from './domainState'
 // #ifdef H5
 const [p, h] = [window.location.protocol, window.location.host]
 const isIP = /^\d+\.\d+\.\d+\.\d+:\d+$/.test(h)
-const [h5Ho, h5Dt] = isIP ? ['44a5c8109e4', 'com'] : h.split('.').slice(-2)
+const [h5Ho, h5Dt] = isIP ? ['cwgbroker', 'club'] : h.split('.').slice(-2)
 const h5Ht = p == 'http:' ? 'https:' : p
 setDomainParts({ ho: h5Ho, dt: h5Dt, ht: h5Ht })
 // #endif

+ 11 - 1
locale/ar.json

@@ -49,7 +49,17 @@
       "t4": "إجمالي السحب"
     },
     "login": {
-      "agreemnet1": " اضغط \"تسجيل الدخول\" لضمان قراءتك وفهمك واتفاقك على جميع المعلومات الواردة في هذا المستند. "
+      "agreemnet1": " اضغط \"تسجيل الدخول\" لضمان قراءتك وفهمك واتفاقك على جميع المعلومات الواردة في هذا المستند. ",
+      "item7": "تسجيل الدخول من جهاز آخر",
+      "item8": "تأكيد تسجيل الدخول",
+      "item9": "مسح رمز الاستجابة السريعة",
+      "item10": "فشل المسح، يرجى إعادة المسح",
+      "item11": "رمز الاستجابة السريعة منتهي الصلاحية، يرجى إعادة المسح",
+      "item12": "اختر من الألبوم",
+      "item13": "مسح بالكاميرا",
+      "item14": "يتم التعرف...",
+      "item15": "رمز الاستجابة السريعة في الصورة عاكس الضوء/ضبابي. يرجى التقاط لقطة شاشة ومحاولة مرة أخرى، أو التقاط صورة مباشرة على الشاشة.",
+      "item16": "امسح لتسجيل الدخول إلى حسابك."
     },
     "account": {
       "label1": "ابدأ التداول",

+ 11 - 1
locale/cn.json

@@ -49,7 +49,17 @@
       "t4": "总取款"
     },
     "login": {
-      "agreemnet1": " 单击“登录”确认您已阅读、理解并同意本文中的所有信息 "
+      "agreemnet1": " 单击“登录”确认您已阅读、理解并同意本文中的所有信息 ",
+      "item7": "登录其他设备",
+      "item8": "登录确认",
+      "item9": "扫一扫",
+      "item10": "扫码失败,请重新扫描",
+      "item11": "二维码已过期,请重新扫描",
+      "item12": "从相册选择",
+      "item13": "相机扫码",
+      "item14": "识别中...",
+      "item15": "图片二维码反光/模糊,请截图后重试或正对屏幕拍摄",
+      "item16": "扫码登录到您的账户。"
     },
     "account": {
       "label1": "开始交易",

+ 11 - 1
locale/de.json

@@ -49,7 +49,17 @@
       "t4": "Gesamtauszahlung"
     },
     "login": {
-      "agreemnet1": " Klicken Sie auf \"Anmelden\", um zu bestätigen, dass Sie alle Informationen in diesem Dokument gelesen, verstanden und zugestimmt haben. "
+      "agreemnet1": " Klicken Sie auf \"Anmelden\", um zu bestätigen, dass Sie alle Informationen in diesem Dokument gelesen, verstanden und zugestimmt haben. ",
+      "item7": "Auf anderem Gerät anmelden",
+      "item8": "Anmeldebestätigung",
+      "item9": "QR-Code scannen",
+      "item10": "Scan fehlgeschlagen, bitte neu scannen",
+      "item11": "QR-Code abgelaufen, bitte neu scannen",
+      "item12": "Aus Album wählen",
+      "item13": "Kamera-Scan",
+      "item14": "Erkennen...",
+      "item15": "Der QR-Code im Bild ist reflektierend/unscharf. Bitte machen Sie einen Screenshot und versuchen Sie es erneut, oder fotografieren Sie direkt auf den Bildschirm.",
+      "item16": "Scannen Sie, um sich in Ihr Konto einzuloggen."
     },
     "account": {
       "label1": "Handel beginnen",

+ 11 - 1
locale/en.json

@@ -50,7 +50,17 @@
       "t5": ""
     },
     "login": {
-      "agreemnet1": " Click “Log in” to confirm that you have read, understood, and agreed to all the information in this document. "
+      "agreemnet1": " Click \"Log in\" to confirm that you have read, understood, and agreed to all the information in this document. ",
+      "item7": "Login on Other Device",
+      "item8": "Login Confirmation",
+      "item9": "Scan QR Code",
+      "item10": "Scan failed, please scan again",
+      "item11": "QR code has expired, please scan again",
+      "item12": "Select from Album",
+      "item13": "Camera Scan",
+      "item14": "Recognizing...",
+      "item15": "The QR code in the image is reflective/blurry. Please screenshot and try again, or shoot directly at the screen.",
+      "item16": "Scan to log in to your account."
     },
     "account": {
       "label1": "Start Trading",

+ 11 - 1
locale/es.json

@@ -49,7 +49,17 @@
       "t4": "Retiro Total"
     },
     "login": {
-      "agreemnet1": " Haga clic en \"Iniciar sesión\" para confirmar que ha leído, entendido y aceptado toda la información de este documento. "
+      "agreemnet1": " Haga clic en \"Iniciar sesión\" para confirmar que ha leído, entendido y aceptado toda la información de este documento. ",
+      "item7": "Iniciar sesión en otro dispositivo",
+      "item8": "Confirmación de inicio de sesión",
+      "item9": "Escanear código QR",
+      "item10": "Escaneo fallido, por favor escanee de nuevo",
+      "item11": "El código QR ha caducado, por favor escanee de nuevo",
+      "item12": "Seleccionar desde Álbum",
+      "item13": "Escaneo con Cámara",
+      "item14": "Reconociendo...",
+      "item15": "El código QR en la imagen está reflejado/borroso. Por favor, capture una captura de pantalla y vuelva a intentarlo, o fotografie directamente la pantalla.",
+      "item16": "Escanea para iniciar sesión en tu cuenta."
     },
     "account": {
       "label1": "Comenzar a operar",

+ 11 - 1
locale/fa.json

@@ -49,7 +49,17 @@
       "t4": "مجموع برداشت"
     },
     "login": {
-      "agreemnet1": " با کلیک بر \"ورود\" تأیید می‌کنید که تمام اطلاعات این سند را خوانده، فهمیده و موافقت کرده‌اید. "
+      "agreemnet1": " با کلیک بر \"ورود\" تأیید می‌کنید که تمام اطلاعات این سند را خوانده، فهمیده و موافقت کرده‌اید. ",
+      "item7": "ورود در دستگاه دیگر",
+      "item8": "تأیید ورود",
+      "item9": "اسکن کد QR",
+      "item10": "اسکن ناموفق بود، لطفاً دوباره اسکن کنید",
+      "item11": "کد QR منقضی شده است، لطفاً دوباره اسکن کنید",
+      "item12": "از آلبوم انتخاب کنید",
+      "item13": "اسکن با دوربین",
+      "item14": "در حال شناسایی...",
+      "item15": "کد QR در تصویر بازتابی/مرئی نیست. لطفاً اسکرین‌شات بگیرید و دوباره امتحان کنید، یا مستقیماً به صفحه عکس بگیرید.",
+      "item16": "اسکن کنید تا وارد حساب خود شوید."
     },
     "account": {
       "label1": "شروع معاملات",

+ 11 - 1
locale/id.json

@@ -49,7 +49,17 @@
       "t4": "Penarikan Total"
     },
     "login": {
-      "agreemnet1": " Klik \"Masuk\" untuk mengonfirmasi bahwa Anda telah membaca, memahami, dan menyetujui semua informasi dalam dokumen ini. "
+      "agreemnet1": " Klik \"Masuk\" untuk mengonfirmasi bahwa Anda telah membaca, memahami, dan menyetujui semua informasi dalam dokumen ini. ",
+      "item7": "Masuk di perangkat lain",
+      "item8": "Konfirmasi masuk",
+      "item9": "Pindai kode QR",
+      "item10": "Pindai gagal, silakan pindai lagi",
+      "item11": "Kode QR telah kedaluwarsa, silakan pindai lagi",
+      "item12": "Pilih dari Album",
+      "item13": "Pindai Kamera",
+      "item14": "Mengenali...",
+      "item15": "Kode QR pada gambar terpantulkan/kabur. Silakan tangkap layar dan coba lagi, atau foto langsung ke layar.",
+      "item16": "Pindai untuk masuk ke akun Anda."
     },
     "account": {
       "label1": "Mulai Trading",

+ 11 - 1
locale/ko.json

@@ -49,7 +49,17 @@
       "t4": "총 출금"
     },
     "login": {
-      "agreemnet1": " \"로그인\"을 클릭하여 본 문서의 모든 정보를 읽고 이해하고 동의했음을 확인합니다. "
+      "agreemnet1": " \"로그인\"을 클릭하여 본 문서의 모든 정보를 읽고 이해하며 동의했음을 확인합니다. ",
+      "item7": "다른 기기에서 로그인",
+      "item8": "로그인 확인",
+      "item9": "QR 코드 스캔",
+      "item10": "스캔 실패, 다시 스캔해주세요",
+      "item11": "QR 코드가 만료되었습니다, 다시 스캔해주세요",
+      "item12": "앨범에서 선택",
+      "item13": "카메라 스캔",
+      "item14": "인식 중...",
+      "item15": "이미지의 QR 코드가 반사/흐릿합니다. 스크린샷을 찍고 다시 시도하거나 화면에 직접 촬영해주세요.",
+      "item16": "스캔하여 계정에 로그인하세요."
     },
     "account": {
       "label1": "거래 시작",

+ 11 - 1
locale/ms.json

@@ -49,7 +49,17 @@
       "t4": "Jumlah Penarikan"
     },
     "login": {
-      "agreemnet1": " Klik \"Log Masuk\" untuk mengesahkan bahawa anda telah membaca, memahami dan bersetuju dengan semua maklumat dalam dokumen ini. "
+      "agreemnet1": " Klik \"Log Masuk\" untuk mengesahkan bahawa anda telah membaca, memahami dan bersetuju dengan semua maklumat dalam dokumen ini. ",
+      "item7": "Log masuk pada peranti lain",
+      "item8": "Pengesahan log masuk",
+      "item9": "Imbas kod QR",
+      "item10": "Imbas gagal, sila imbas semula",
+      "item11": "Kod QR telah tamat tempoh, sila imbas semula",
+      "item12": "Pilih dari Album",
+      "item13": "Imbas Kamera",
+      "item14": "Mengenali...",
+      "item15": "Kod QR dalam imej bersinar/buram. Sila tangkap skrin dan cuba lagi, atau tangkap gambar terus ke skrin.",
+      "item16": "Imbas untuk log masuk ke akaun anda."
     },
     "account": {
       "label1": "Mulakan Dagangan",

+ 11 - 1
locale/pt.json

@@ -49,7 +49,17 @@
       "t4": "Retiro Total"
     },
     "login": {
-      "agreemnet1": " Clique em \"Entrar\" para confirmar que você leu, entendeu e concordou com todas as informações neste documento. "
+      "agreemnet1": " Clique em \"Entrar\" para confirmar que você leu, entendeu e concordou com todas as informações neste documento. ",
+      "item7": "Entrar em outro dispositivo",
+      "item8": "Confirmação de login",
+      "item9": "Escanear código QR",
+      "item10": "Leitura falhou, por favor escaneie novamente",
+      "item11": "Código QR expirou, por favor escaneie novamente",
+      "item12": "Selecionar do Álbum",
+      "item13": "Leitura pela Câmera",
+      "item14": "Reconhecendo...",
+      "item15": "O código QR na imagem está refletindo/borroso. Por favor, capture uma screenshot e tente novamente, ou tire uma foto diretamente na tela.",
+      "item16": "Escaneie para fazer login na sua conta."
     },
     "account": {
       "label1": "Começar a negociar",

+ 11 - 1
locale/th.json

@@ -49,7 +49,17 @@
       "t4": "ถอนทั้งหมด"
     },
     "login": {
-      "agreemnet1": " คลิก \"เข้าสู่ระบบ\" เพื่อยืนยันว่าคุณได้อ่าน, เข้าใจ, และยอมรับทุกข้อมูลในเอกสารนี้. "
+      "agreemnet1": " คลิก \"เข้าสู่ระบบ\" เพื่อยืนยันว่าคุณได้อ่าน, เข้าใจ, และยอมรับทุกข้อมูลในเอกสารนี้. ",
+      "item7": "เข้าสู่ระบบบนอุปกรณ์อื่น",
+      "item8": "ยืนยันการเข้าสู่ระบบ",
+      "item9": "สแกน QR Code",
+      "item10": "สแกนล้มเหลว กรุณาสแกนอีกครั้ง",
+      "item11": "QR Code หมดอายุ กรุณาสแกนอีกครั้ง",
+      "item12": "เลือกจากอัลบั้ม",
+      "item13": "สแกนด้วยกล้อง",
+      "item14": "กำลังรู้จำ...",
+      "item15": "QR Code ในรูปภาพมีแสงสะท้อน/คลุมเครือ. กรุณาถ่ายหน้าจอแล้วลองใหม่ หรือถ่ายตรงหน้าจอ",
+      "item16": "สแกนเพื่อเข้าสู่ระบบบัญชีของคุณ"
     },
     "account": {
       "label1": "เริ่มเทรด",

+ 11 - 1
locale/tr.json

@@ -49,7 +49,17 @@
       "t4": "Toplam Çekim"
     },
     "login": {
-      "agreemnet1": " \"Giriş\"e tıklayarak bu belgedeki tüm bilgileri okuduğunuzu, anladığınızı ve kabul ettiğinizi onaylayın. "
+      "agreemnet1": " \"Giriş yap\"a tıklayarak bu belgedeki tüm bilgileri okuduğunuzu, anladığınızı ve kabul ettiğinizi onaylayın. ",
+      "item7": "Başka cihazda giriş yap",
+      "item8": "Giriş onayı",
+      "item9": "QR kodu tara",
+      "item10": "Taramada hata oluştu, lütfen tekrar tarayın",
+      "item11": "QR kodu süresi doldu, lütfen tekrar tarayın",
+      "item12": "Albümden Seç",
+      "item13": "Kamera ile Tara",
+      "item14": "Tanımlanıyor...",
+      "item15": "Resimdeki QR kodu yansıtılıyor/bulanık. Lütfen ekran görüntüsü alıp tekrar deneyin, veya doğrudan ekranı fotoğraflayın.",
+      "item16": "Hesabınıza giriş yapmak için tarayın."
     },
     "account": {
       "label1": "İşleme Başla",

+ 11 - 1
locale/ug.json

@@ -49,7 +49,17 @@
       "t4": "جەمئىي چىقىرىش"
     },
     "login": {
-      "agreemnet1": "«كىرگۈزۈش» نى باسىش سىز بۇ يازمادىكى ھەممە ئۇچۇرلارنى ئوقۇپ، چۈشەنۈپ، رازى بولغانلىقىڭىزنى جەزىملەيدۇ"
+      "agreemnet1": "«كىرگۈزۈش» نى باسىش سىز بۇ يازمادىكى ھەممە ئۇچۇرلارنى ئوقۇپ، چۈشەنۈپ، رازى بولغانلىقىڭىزنى جەزىملەيدۇ",
+      "item7": "باشقا ئۈسكۈنىدە كىرىش",
+      "item8": "كىرىش جەزملەش",
+      "item9": "QR كودنى سىكاننېرلاش",
+      "item10": "سىكاننېرلاش مەسئۇلىيەتسىز بولدى، قايتا سىكاننېرلاش",
+      "item11": "QR كودنىڭ مۇددىتى ئەتىپ كەتتى، قايتا سىكاننېرلاش",
+      "item12": "فوتو ئۇسلۇبىدىن تاللاش",
+      "item13": "كامېرا سىكاننېرلاش",
+      "item14": "تەكشۈرۈۋاتىدۇ...",
+      "item15": "سۈرەتتىكى QR كودى ئاينىنىڭ قارىغىسى/مۈپىس. ئېكراننى چاپ قىلىپ قايتا سىكاننېرلاش ياكى ئېكراننى ئاڭلىق تەكشۈرۈش",
+      "item16": "ھېساباتىڭىزغا كىرىش ئۈچۈن سىكاننېرلاڭ."
     },
     "account": {
       "label1": "سودانى باشلاش",

+ 11 - 1
locale/vn.json

@@ -49,7 +49,17 @@
       "t4": "Tổng tiền rút"
     },
     "login": {
-      "agreemnet1": " Nhấp \"Đăng nhập\" để xác nhận bạn đã đọc, hiểu và đồng ý với tất cả thông tin trong tài liệu này. "
+      "agreemnet1": " Nhấn \"Đăng nhập\" để xác nhận bạn đã đọc, hiểu và đồng ý với tất cả thông tin trong tài liệu này. ",
+      "item7": "Đăng nhập thiết bị khác",
+      "item8": "Xác nhận đăng nhập",
+      "item9": "Quét mã QR",
+      "item10": "Quét mã thất bại, vui lòng quét lại",
+      "item11": "Mã QR đã hết hạn, vui lòng quét lại",
+      "item12": "Chọn từ Album",
+      "item13": "Quét bằng camera",
+      "item14": "Đang nhận dạng...",
+      "item15": "Mã QR trong ảnh bị phản chiếu/mờ. Vui lòng chụp ảnh và thử lại, hoặc chụp trực tiếp màn hình.",
+      "item16": "Quét mã để đăng nhập vào tài khoản của bạn."
     },
     "account": {
       "label1": "Bắt đầu giao dịch",

+ 11 - 1
locale/zhHant.json

@@ -49,7 +49,17 @@
       "t4": "總取款"
     },
     "login": {
-      "agreemnet1": " 單擊「登錄」確認您已閱讀、理解並同意本文中的所有信息 "
+      "agreemnet1": " 單擊「登錄」確認您已閱讀、理解並同意本文中的所有信息 ",
+      "item7": "登錄其他設備",
+      "item8": "登錄確認",
+      "item9": "掃一掃",
+      "item10": "掃碼失敗,請重新掃描",
+      "item11": "二維碼已過期,請重新掃描",
+      "item12": "從相冊選擇",
+      "item13": "相機掃碼",
+      "item14": "識別中...",
+      "item15": "圖片二維碼反光/模糊,請截圖後重試或正對螢幕拍攝",
+      "item16": "掃碼登錄到您的賬戶。"
     },
     "account": {
       "label1": "開始交易",

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.13",
     "decimal.js": "^10.6.0",
+    "jsqr": "^1.4.0",
     "lodash": "^4.17.21",
     "mp-html": "^2.5.2",
     "pdfh5": "^3.0.0",

+ 167 - 39
pages/login/index.vue

@@ -64,12 +64,12 @@
                 :class="isMobile ? 'company-icon-mini' : 'company-icon'" mode="widthFix"></image>
 
             </view>
-<!--            <view class="tab-list">-->
-<!--              <view v-for="tab in tabs" :key="tab.value" class="tab-item cursor-pointer" :class="{ active: activeTab === tab.value }"-->
-<!--                @click="handleClick(tab.value)" :data-tooltip="tab.text" data-placement="top">-->
-<!--                <text class="tab-label">{{ tab.text }}</text>-->
-<!--              </view>-->
-<!--            </view>-->
+            <!--            <view class="tab-list">-->
+            <!--              <view v-for="tab in tabs" :key="tab.value" class="tab-item cursor-pointer" :class="{ active: activeTab === tab.value }"-->
+            <!--                @click="handleClick(tab.value)" :data-tooltip="tab.text" data-placement="top">-->
+            <!--                <text class="tab-label">{{ tab.text }}</text>-->
+            <!--              </view>-->
+            <!--            </view>-->
 
             <view v-show="activeTab == 1" style="overflow-y: scroll;">
               <view class="title">
@@ -98,36 +98,38 @@
                       :label="t('newSignin.item5')" name="记住我" class="wcg-checkbox"></up-checkbox>
                   </up-checkbox-group>
                 </view>
-                <navigator url="/pages/login/reset" class="account-tip cursor-pointer" :data-tooltip="t('signin.forget')" data-placement="top">
+                <navigator url="/pages/login/reset" class="account-tip cursor-pointer"
+                  :data-tooltip="t('signin.forget')" data-placement="top">
                   <text>{{ t('signin.forget') }}</text>
                 </navigator>
               </view>
               <view class="cwg-button ">
-                <button type="primary" class="" @click="submit" >
+                <button type="primary" class="" @click="submit">
                   {{ t('signin.login') }}
                 </button>
               </view>
-              <view class="account-tip " >
+              <view class="account-tip ">
                 {{ t('signin.words') }}
-                <text @click="activeTab = 2" class="cursor-pointer" :data-tooltip="t('signin.signup')" data-placement="top">{{ t('signin.signup') }}</text>
+                <text @click="activeTab = 2" class="cursor-pointer" :data-tooltip="t('signin.signup')"
+                  data-placement="top">{{ t('signin.signup') }}</text>
               </view>
               <view class="des-bottom">
                 <text v-t="'vu.login.agreemnet1'"></text>
-                <cwg-link type="pdf" url='pdf/Client_Agreement.pdf'  target="_blank" :title="'signup.agreemnet2'"
-                          class="desc-link" />
+                <cwg-link type="pdf" url='pdf/Client_Agreement.pdf' target="_blank" :title="'signup.agreemnet2'"
+                  class="desc-link" />
                 <text v-t="'signup.agreemnet3'"></text>
                 <cwg-link type="pdf" url='pdf/Terms_Conditions.pdf' target="_blank" :title="'signup.agreemnet4'"
-                          class="desc-link" />
+                  class="desc-link" />
                 <text v-t="'signup.agreemnet5'"></text>
                 <cwg-link type="pdf" url='pdf/Privacy_Policy.pdf' target="_blank" :title="'signup.agreemnet6'"
-                          class="desc-link" />
+                  class="desc-link" />
                 <text v-t="'signup.agreemnet7'"></text>
               </view>
               <cwg-match-media :min-width="791">
-                <view class="qr-container">
+                <view class="qr-container" v-show="activeTab == 1 && loginQrCode">
                   <view class="qr-title">
                     <view class="line"></view>
-                    <view class="qr-tit2">{{ t('newSignin.item2') }}</view>
+                    <view class="qr-tit2">{{ t('vu.login.item16') }}</view>
                     <view class="line"></view>
                   </view>
                   <QrCode width="200" height="200" :text="loginQrCode"></QrCode>
@@ -161,7 +163,7 @@
                         <uni-col :xs="24" :md="12">
                           <uni-forms-item name="birthDate" :label="t('newSignup.item18')">
                             <uni-datetime-picker type="date" v-model="formData.birthDate"
-                                                 :placeholder="t('newSignup.item19')" @change="checkAge" />
+                              :placeholder="t('newSignup.item19')" @change="checkAge" />
                           </uni-forms-item>
                         </uni-col>
                       </uni-row>
@@ -235,14 +237,15 @@
                   </uni-forms-item>
                 </uni-forms>
               </view>
-              <view >
+              <view>
                 <button class="regiset-btn" @click="register">
                   {{ t('signup.button') }}
                 </button>
               </view>
-              <view class="login-link " >
+              <view class="login-link ">
                 <text>{{ t('newSignup.item16') }}</text>
-                <view class="link cursor-pointer" :data-tooltip="t('newSignup.item17')" data-placement="top" @click="activeTab = 1">{{ t('newSignup.item17') }}</view>
+                <view class="link cursor-pointer" :data-tooltip="t('newSignup.item17')" data-placement="top"
+                  @click="activeTab = 1">{{ t('newSignup.item17') }}</view>
               </view>
               <view class="des-bottom">
                 <text v-t="'signup.agreemnet1'"></text>
@@ -261,15 +264,16 @@
         </uni-col>
       </uni-row>
     </view>
-    <view class="chat-icon cursor-pointer" :data-tooltip="t('Downloadpage.item16')" :class="{ 'chat-icon-expanded': isChatIconExpanded, 'chat-icon-hidden': type == 'chat' }"
+    <view class="chat-icon cursor-pointer" :data-tooltip="t('Downloadpage.item16')"
+      :class="{ 'chat-icon-expanded': isChatIconExpanded, 'chat-icon-hidden': type == 'chat' }"
       @click="handleChatIconClick">
       <cwg-icon name="chat" color="#fff" />
     </view>
   </view>
-  <cwg-global-popup/>
+  <cwg-global-popup />
 </template>
 <script setup>
-import { ref, watch, onMounted, computed } from 'vue'
+import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
 import ls from "@/utils/store2";
 import QrCode from '@/components/QrCode.vue'
@@ -294,12 +298,13 @@ import LiveChatService from '@/utils/liveChat.js'
 import { useWindowWidth } from '@/composables/useWindowWidth'
 import { openLocalPdf } from '@/utils/pdf.js'
 import { usePopup } from '@/hooks/usePopup'
+import tool from '@/global/tool.js'
 
 const windowWidth = useWindowWidth(300)
 const isMobile = computed(() => windowWidth.value <= 991)
 const router = useRouter()
 
-const { t ,locale} = useI18n()
+const { t, locale } = useI18n()
 const { Code } = Config
 const userStore = useUserStore()
 const globalStore = useGlobalStore()
@@ -311,9 +316,19 @@ const tabs = computed(() => [
 ])
 
 // 登录二维码
-const loginQrCode =  ref('https://secure.44a5c8109e4.com/wgt/CWG Center.apk')
+const loginQrCode = ref('')
+const scanLoginScene = ref('')
+let scanLoginWs = null
+let scanLoginPollTimer = null
+let scanLoginHandled = false
+
+const SCAN_LOGIN_STATUS = {
+  INIT: 0,
+  CONFIRMED: 1,
+  EXPIRED: 2,
+}
 
-const { confirm,toast } = usePopup()
+const { confirm, toast } = usePopup()
 const activeTab = ref(1)
 
 // ========== 注册表单相关 ==========
@@ -344,7 +359,7 @@ const formData = ref({
   ibInvalid: null,
 })
 
-const rules = computed(()=>({
+const rules = computed(() => ({
   country: { rules: [{ required: true, errorMessage: t('vaildate.country.empty'), trigger: 'blur' }] },
   email: {
     rules: [
@@ -434,9 +449,9 @@ const rule2 = computed(() => {
   return /^(?=.*?[a-z])(?=.*?[A-Z]).*$/.test(formData.value.password)
 })
 const rule3 = computed(() => {
-    return /^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[~!@&%$^*./\\(\\)\\+\\=#_-])[A-Za-z0-9~!@&%$^*./\\(\\)\\+\\=#_-]{8,16}$/.test(
-        formData.value.password,
-    );
+  return /^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[~!@&%$^*./\\(\\)\\+\\=#_-])[A-Za-z0-9~!@&%$^*./\\(\\)\\+\\=#_-]{8,16}$/.test(
+    formData.value.password,
+  );
 });
 
 const isAgeValid = computed(() => {
@@ -624,11 +639,11 @@ const register = async () => {
     const xSystemValue = isChina ? 'B' : 'A'
 
     let resData = await loginApi.Register(
-      { ...formData.value,agree: formData.value.agree ==1 ?  true : false },
+      { ...formData.value, agree: formData.value.agree == 1 ? true : false },
       { headers: { 'X-System': xSystemValue } },
     )
     if (resData.code == 200) {
-      showToast(t('Msg.registerSuc'),'success')
+      showToast(t('Msg.registerSuc'), 'success')
       registLogin()
     } else {
       confirm({
@@ -638,14 +653,14 @@ const register = async () => {
         confirmText: t('Btn.Confirm'),
       })
     }
-  }catch (e) {
+  } catch (e) {
     confirm({
       title: t('Msg.SystemPrompt'),
       content: e.msg,
       showCancel: false,
       confirmText: t('Btn.Confirm'),
     })
-    console.log(e,'err')
+    console.log(e, 'err')
   }
 }
 
@@ -710,7 +725,6 @@ function submit() {
 
   handleLogin()
 }
-
 const customStyle = {
   height: '48px',
   'border-radius': '8px',
@@ -759,10 +773,10 @@ async function handleLogin() {
       //  console.log(12112);
     }
   } catch (error) {
-    loginLoading.value =false
+    loginLoading.value = false
     // console.log(error)
     // uni.showToast({ title: error.msg, icon: 'error' })
-    toast(error.msg||t('common.error'))
+    toast(error.msg || t('common.error'))
     //  console.log(error, 19089);
   }
 }
@@ -774,7 +788,7 @@ async function getCustomLoginInfo() {
     uni.removeStorageSync('systemListCache')
     // 登录更新金额
     uni.$emit('updatePayment')
-    loginLoading.value =false
+    loginLoading.value = false
     // uni.$emit('updateSystemList', 'login')
     if (res.code === 200) {
       switch (modeStore.value) {
@@ -792,6 +806,7 @@ async function getCustomLoginInfo() {
         default:
           break
       }
+      cleanupScanLogin()
     } else {
       uni.$u.toast(res.msg || t('login.msg0'))
     }
@@ -844,13 +859,121 @@ function pickFields(source, fields = ['content', 'enContent']) {
   userStore.saveReasonsOptions(result)
 }
 
+const cleanupScanLogin = () => {
+  if (scanLoginPollTimer) {
+    clearInterval(scanLoginPollTimer)
+    scanLoginPollTimer = null
+  }
+  if (scanLoginWs) {
+    try {
+      scanLoginWs.close({})
+    } catch (e) { /* ignore */ }
+    scanLoginWs = null
+  }
+}
+
+const handleScanLoginResult = async (data) => {
+  if (scanLoginHandled) return
+  if (data.scene && scanLoginScene.value && data.scene !== scanLoginScene.value) return
+
+  if (data.status === SCAN_LOGIN_STATUS.CONFIRMED && data.accessToken) {
+    scanLoginHandled = true
+    cleanupScanLogin()
+    userToken.value = data.accessToken
+    uni.showToast({ title: t('login.msg0_1'), icon: 'success' })
+    await getCustomLoginInfo()
+    reasonsRefusalList()
+    return
+  }
+
+  if (data.status === SCAN_LOGIN_STATUS.EXPIRED) {
+    cleanupScanLogin()
+    scanLoginHandled = false
+    getLoginQrCode()
+  }
+}
+
+const initScanLoginWs = (socketAccessToken) => {
+  if (!socketAccessToken) return
+  const token = tool.tokenReplace(socketAccessToken)
+  const wsUrl = `${Config.HostWs}/webSocket?Access-Token=${token}`
+
+  // H5 必须传 success/complete,否则返回 Promise 而非 SocketTask,onMessage 无法绑定
+  scanLoginWs = uni.connectSocket({
+    url: wsUrl,
+    complete: () => { },
+  })
+
+  scanLoginWs.onOpen(() => {
+    console.log('scan login ws opened')
+  })
+
+  scanLoginWs.onMessage((res) => {
+    try {
+      let raw = res.data
+      if (raw instanceof ArrayBuffer) {
+        raw = new TextDecoder('utf-8').decode(raw)
+      }
+      const data = typeof raw === 'string' ? JSON.parse(raw) : raw
+      if (data.type === 'scan') {
+        handleScanLoginResult(data)
+      }
+    } catch (e) { }
+  })
+
+  scanLoginWs.onError((err) => {
+    console.log('scan login ws error', err)
+  })
+}
+
+const pollScanLoginStatus = async () => {
+  if (!scanLoginScene.value || scanLoginHandled) return
+  try {
+    const res = await userApi.getAppScanLoginStatus({ scene: scanLoginScene.value })
+    if (res.code === 200 && res.data) {
+      handleScanLoginResult(res.data)
+    }
+  } catch (e) { /* ignore */ }
+}
+
+const startScanLoginPoll = () => {
+  if (scanLoginPollTimer) clearInterval(scanLoginPollTimer)
+  scanLoginPollTimer = setInterval(pollScanLoginStatus, 5000)
+}
+
+const getLoginQrCode = async () => {
+  try {
+    cleanupScanLogin()
+    scanLoginHandled = false
+    scanLoginScene.value = ''
+    const res = await userApi.getAppScanLogin()
+    if (res.code === 200) {
+      const { scene, qrContent, socketAccessToken } = res.data
+      scanLoginScene.value = scene
+      loginQrCode.value = qrContent || scene
+      initScanLoginWs(socketAccessToken)
+      startScanLoginPoll()
+    } else {
+      uni.$u.toast(res.msg || t('login.msg0'))
+    }
+  } catch (error) {
+    //  console.log(error, 111);
+  }
+}
+
+
 onMounted(() => {
   if (uni.getStorageSync('logoutToSystem') == 1) {
     uni.removeStorageSync('logoutToSystem')
     uni.removeStorageSync('systemListCache')
   }
+  // #ifdef H5
+  if (!isMobile.value) {
+    // getLoginQrCode()
+  }
+  // #endif
   uni.$emit('updateSystemList', 'login')
-  uni.$on('updateAppSuccess',()=>{
+  uni.$on('updateAppSuccess', () => {
     uni.showToast('')
   })
   const accountInfo = userStore.accountInfo
@@ -869,6 +992,11 @@ onMounted(() => {
   getCountryMsg()
   globalStore.setMode(ls.get('mode') || 'customer');
 })
+
+onBeforeUnmount(() => {
+  cleanupScanLogin()
+})
+
 const inputType = ref('password')
 const isChatIconExpanded = ref(false)
 // 处理聊天图标点击

+ 17 - 2
static/scss/global/global.scss

@@ -1758,7 +1758,7 @@ uni-content.collapsed {
 }
 
 .pc-header-btn {
-    width: px2rpx(40);
+    min-width: px2rpx(40);
     height: px2rpx(40);
     border-radius: px2rpx(4);
     border: 1px solid rgba(108, 133, 149, 0);
@@ -1768,11 +1768,26 @@ uni-content.collapsed {
     cursor: pointer;
     justify-content: center;
 
-    &:hover {
+    @media screen and (min-width: 991px) {
+        &:hover {
+           // #ifdef H5
+           background: rgba(108, 133, 149, 0.12);
+           border-color: rgb(145, 163, 176);
+           // #endif
+        }
+    }
+    &:active {
         background: rgba(108, 133, 149, 0.12);
         border-color: rgb(145, 163, 176);
     }
 }
+.button-hover {
+  background-color: inherit !important;
+  color: inherit !important;
+  border-color: inherit !important;
+  transform: none !important;
+  opacity: 1 !important;
+}
 
 .popup-content {
     // padding: px2rpx(30) px2rpx(20);

+ 192 - 0
utils/decodeQrImage.js

@@ -0,0 +1,192 @@
+import jsQR from 'jsqr'
+
+export const CANVAS_ID = 'scanDecodeCanvas'
+const RETRY_SIZES = [600, 400, 800]
+const MAX_CANVAS = 1200
+
+function prepareImagePath(path) {
+  // #ifdef APP-PLUS
+  return plus.io.convertLocalFileSystemURL(path)
+  // #endif
+  // #ifndef APP-PLUS
+  return path
+  // #endif
+}
+
+/** 灰度二值化,消除屏幕摩尔纹、反光 */
+function binarizeImageData(dataArr) {
+  const data = new Uint8ClampedArray(dataArr)
+  for (let i = 0; i < data.length; i += 4) {
+    const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114
+    const val = gray > 130 ? 255 : 0
+    data[i] = val
+    data[i + 1] = val
+    data[i + 2] = val
+  }
+  return data
+}
+
+function decodeFromPixels(data, width, height) {
+  const cleanData = binarizeImageData(data)
+  return jsQR(cleanData, width, height, { inversionAttempts: 'attemptBoth' })
+}
+
+function calcDrawSize(width, height, maxSide) {
+  const longest = Math.max(width, height)
+  const scale = Math.min(1, maxSide / longest, MAX_CANVAS / longest)
+  return {
+    width: Math.max(1, Math.floor(width * scale)),
+    height: Math.max(1, Math.floor(height * scale)),
+  }
+}
+
+function compressImage(path, maxSize) {
+  const quality = maxSize < 500 ? 70 : 90
+  return new Promise((resolve) => {
+    uni.getImageInfo({
+      src: prepareImagePath(path),
+      success: (info) => {
+        const longest = Math.max(info.width, info.height)
+        const scale = longest > maxSize ? maxSize / longest : 1
+        const compressedWidth = Math.max(1, Math.floor(info.width * scale))
+        const compressedHeight = Math.max(1, Math.floor(info.height * scale))
+
+        uni.compressImage({
+          src: prepareImagePath(path),
+          quality,
+          compressedWidth,
+          compressedHeight,
+          success: (res) => resolve(res.tempFilePath),
+          fail: () => resolve(path),
+        })
+      },
+      fail: () => {
+        uni.compressImage({
+          src: prepareImagePath(path),
+          quality,
+          width: `${maxSize}px`,
+          success: (res) => resolve(res.tempFilePath),
+          fail: () => resolve(path),
+        })
+      },
+    })
+  })
+}
+
+function getComponentScope(component) {
+  return component?.proxy || component
+}
+
+function decodeByLegacyCanvas(imagePath, component, maxSide) {
+  const scope = getComponentScope(component)
+  return new Promise((resolve, reject) => {
+    uni.getImageInfo({
+      src: prepareImagePath(imagePath),
+      success: (info) => {
+        const { width, height } = calcDrawSize(info.width, info.height, maxSide)
+        const ctx = uni.createCanvasContext(CANVAS_ID, scope)
+
+        ctx.drawImage(info.path, 0, 0, width, height)
+        ctx.draw(false, () => {
+          uni.canvasGetImageData({
+            canvasId: CANVAS_ID,
+            x: 0,
+            y: 0,
+            width,
+            height,
+            success: (res) => {
+              const code = decodeFromPixels(
+                new Uint8ClampedArray(res.data),
+                res.width,
+                res.height,
+              )
+              if (code?.data) {
+                resolve(code.data)
+              } else {
+                reject(new Error('decode failed'))
+              }
+            },
+            fail: reject,
+          })
+        })
+      },
+      fail: reject,
+    })
+  })
+}
+
+// #ifdef H5
+function decodeByCanvas2d(imagePath, component, maxSide) {
+  const scope = getComponentScope(component)
+  return new Promise((resolve, reject) => {
+    uni.getImageInfo({
+      src: prepareImagePath(imagePath),
+      success: (info) => {
+        const query = uni.createSelectorQuery().in(scope)
+        query
+          .select(`#${CANVAS_ID}`)
+          .fields({ node: true, size: true })
+          .exec((res) => {
+            const canvas = res?.[0]?.node
+            if (!canvas || typeof canvas.getContext !== 'function') {
+              reject(new Error('canvas 2d not supported'))
+              return
+            }
+
+            const { width, height } = calcDrawSize(info.width, info.height, maxSide)
+            const ctx = canvas.getContext('2d', { willReadFrequently: true })
+            const image = canvas.createImage()
+
+            canvas.width = width
+            canvas.height = height
+
+            image.onload = () => {
+              ctx.clearRect(0, 0, width, height)
+              ctx.drawImage(image, 0, 0, width, height)
+              const imageData = ctx.getImageData(0, 0, width, height)
+              const code = decodeFromPixels(imageData.data, width, height)
+              if (code?.data) {
+                resolve(code.data)
+              } else {
+                reject(new Error('decode failed'))
+              }
+            }
+            image.onerror = () => reject(new Error('image load failed'))
+            image.src = info.path
+          })
+      },
+      fail: reject,
+    })
+  })
+}
+// #endif
+
+async function decodeOnce(imagePath, component, maxSide) {
+  // #ifdef H5
+  try {
+    return await decodeByCanvas2d(imagePath, component, maxSide)
+  } catch {
+    return decodeByLegacyCanvas(imagePath, component, maxSide)
+  }
+  // #endif
+  // #ifndef H5
+  return decodeByLegacyCanvas(imagePath, component, maxSide)
+  // #endif
+}
+
+export async function decodeQrFromAlbumImage(path, component) {
+  if (!component) {
+    throw new Error('component required')
+  }
+
+  let lastError = new Error('decode failed')
+  for (const size of RETRY_SIZES) {
+    try {
+      const compressedPath = await compressImage(path, size)
+      return await decodeOnce(compressedPath, component, size)
+    } catch (err) {
+      lastError = err
+    }
+  }
+  throw lastError
+}