index.vue 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. <script setup>
  2. import { ref } from 'vue'
  3. import { onLoad } from '@dcloudio/uni-app'
  4. import { useUserStore } from '@/stores/user'
  5. import { DEFAULT_PLACEHOLDER_IMAGE_URL } from '@/utils/staticAsset'
  6. const userStore = useUserStore()
  7. const sessionId = ref('')
  8. const done = ref(false)
  9. const agreeProtocol = ref(false)
  10. const isLoading = ref(false)
  11. const protocolDialogVisible = ref(false)
  12. const protocolDialogForWechat = ref(false)
  13. onLoad((options) => {
  14. const opts = options || {}
  15. let sid = ''
  16. if (opts.scene) {
  17. try {
  18. sid = decodeURIComponent(String(opts.scene))
  19. } catch (e) {
  20. sid = String(opts.scene)
  21. }
  22. } else if (opts.sessionId) {
  23. sid = String(opts.sessionId)
  24. }
  25. sid = (sid || '').trim()
  26. sessionId.value = sid
  27. if (!sid) return
  28. userStore.webQrScanned(sid).catch(() => {
  29. /* 软失败:PC 仍可继续轮询或用户可重试授权 */
  30. })
  31. })
  32. const goUserAgreement = () => {
  33. uni.navigateTo({ url: '/pages/login/user-agreement' })
  34. }
  35. const goPrivacyPolicy = () => {
  36. uni.navigateTo({ url: '/pages/login/privacy-policy' })
  37. }
  38. const toggleProtocolAgreement = () => {
  39. agreeProtocol.value = !agreeProtocol.value
  40. }
  41. const resolveProtocolDialog = (confirmed) => {
  42. protocolDialogVisible.value = false
  43. protocolDialogForWechat.value = false
  44. if (confirmed) {
  45. agreeProtocol.value = true
  46. }
  47. }
  48. const onWechatGate = () => {
  49. if (isLoading.value || done.value) return
  50. protocolDialogForWechat.value = true
  51. protocolDialogVisible.value = true
  52. }
  53. const onAgreeGetPhoneNumber = (e) => {
  54. agreeProtocol.value = true
  55. protocolDialogVisible.value = false
  56. protocolDialogForWechat.value = false
  57. onGetPhoneNumber(e)
  58. }
  59. const onGetPhoneNumber = async (e) => {
  60. if (isLoading.value || done.value || !sessionId.value) return
  61. if (!agreeProtocol.value) {
  62. onWechatGate()
  63. return
  64. }
  65. const detail = (e && e.detail) || {}
  66. const errMsg = detail.errMsg || ''
  67. if ((errMsg && errMsg.indexOf('ok') === -1) || !detail.code) {
  68. uni.showToast({
  69. title: '需要授权手机号才能完成扫码登录',
  70. icon: 'none'
  71. })
  72. return
  73. }
  74. isLoading.value = true
  75. try {
  76. const loginRes = await new Promise((resolve, reject) => {
  77. uni.login({ provider: 'weixin', success: resolve, fail: reject })
  78. })
  79. const loginCode = loginRes && loginRes.code
  80. if (!loginCode) {
  81. throw new Error('微信登录失败,请重试')
  82. }
  83. const result = await userStore.webQrConfirm({
  84. sessionId: sessionId.value,
  85. loginCode,
  86. phoneCode: detail.code
  87. })
  88. if (result && result.ok) {
  89. done.value = true
  90. uni.showToast({
  91. title: result.message || '电脑端即将登录,可关闭本页',
  92. icon: 'none'
  93. })
  94. return
  95. }
  96. uni.showToast({
  97. title: (result && result.message) || '授权失败',
  98. icon: 'none'
  99. })
  100. } catch (err) {
  101. uni.showToast({
  102. title: (err && err.message) || '授权失败',
  103. icon: 'none'
  104. })
  105. } finally {
  106. isLoading.value = false
  107. }
  108. }
  109. </script>
  110. <template>
  111. <view class="web-qr-page">
  112. <view class="web-qr-brand">
  113. <image class="web-qr-brand__logo" :src="DEFAULT_PLACEHOLDER_IMAGE_URL" mode="widthFix" />
  114. <view class="web-qr-brand__title">网页登录授权</view>
  115. </view>
  116. <view v-if="!sessionId" class="web-qr-card web-qr-card--error">
  117. <text class="web-qr-msg">请从电脑登录页重新扫码</text>
  118. </view>
  119. <view v-else-if="done" class="web-qr-card web-qr-card--ok">
  120. <text class="web-qr-msg web-qr-msg--ok">电脑端即将登录,可关闭本页</text>
  121. </view>
  122. <view v-else class="web-qr-card">
  123. <text class="web-qr-desc">正在登录电脑端管理后台,请授权手机号完成验证</text>
  124. <view class="footer-line">
  125. <view class="agree-check" @tap="toggleProtocolAgreement">
  126. <view class="agree-check__icon" :class="{ 'agree-check__icon--checked': agreeProtocol }">
  127. <text class="agree-check__tick">✓</text>
  128. </view>
  129. </view>
  130. <text class="footer-text">授权即表示同意</text>
  131. <text class="link" @tap="goUserAgreement">《用户服务协议》</text>
  132. <text class="footer-text">和</text>
  133. <text class="link" @tap="goPrivacyPolicy">《隐私政策》</text>
  134. </view>
  135. <button
  136. v-if="agreeProtocol"
  137. class="web-qr-btn"
  138. :disabled="isLoading"
  139. open-type="getPhoneNumber"
  140. @getphonenumber="onGetPhoneNumber"
  141. >
  142. <text class="web-qr-btn__label">{{ isLoading ? '授权中...' : '授权手机号并登录电脑端' }}</text>
  143. </button>
  144. <view
  145. v-else
  146. class="web-qr-btn"
  147. :class="{ 'is-disabled': isLoading }"
  148. @tap="onWechatGate"
  149. >
  150. <text class="web-qr-btn__label">授权手机号并登录电脑端</text>
  151. </view>
  152. </view>
  153. <view
  154. v-if="protocolDialogVisible"
  155. class="protocol-dialog-mask"
  156. @tap="resolveProtocolDialog(false)"
  157. >
  158. <view class="protocol-dialog" @tap.stop>
  159. <text class="protocol-dialog__title">协议确认</text>
  160. <text class="protocol-dialog__desc">授权前请先同意《用户服务协议》和《隐私政策》</text>
  161. <view class="protocol-dialog__actions">
  162. <view
  163. class="protocol-dialog__btn protocol-dialog__btn--cancel"
  164. @tap="resolveProtocolDialog(false)"
  165. >
  166. 否
  167. </view>
  168. <button
  169. v-if="protocolDialogForWechat"
  170. class="protocol-dialog__btn protocol-dialog__btn--confirm"
  171. open-type="getPhoneNumber"
  172. @getphonenumber="onAgreeGetPhoneNumber"
  173. >
  174. 同意并授权
  175. </button>
  176. <view
  177. v-else
  178. class="protocol-dialog__btn protocol-dialog__btn--confirm"
  179. @tap="resolveProtocolDialog(true)"
  180. >
  181. 是,继续
  182. </view>
  183. </view>
  184. </view>
  185. </view>
  186. </view>
  187. </template>
  188. <style lang="scss" scoped>
  189. .web-qr-page {
  190. min-height: 100vh;
  191. padding: 120rpx 48rpx 64rpx;
  192. box-sizing: border-box;
  193. background: #fff;
  194. }
  195. .web-qr-brand {
  196. text-align: center;
  197. margin-bottom: 48rpx;
  198. }
  199. .web-qr-brand__logo {
  200. display: block;
  201. width: 320rpx;
  202. margin: 0 auto;
  203. border-radius: 16rpx;
  204. }
  205. .web-qr-brand__title {
  206. margin-top: 24rpx;
  207. font-size: 34rpx;
  208. font-weight: 600;
  209. color: #0a9f6d;
  210. }
  211. .web-qr-card {
  212. background: #f7faf8;
  213. border: 1px solid #e2ebe6;
  214. border-radius: 24rpx;
  215. padding: 40rpx 32rpx;
  216. }
  217. .web-qr-card--error .web-qr-msg {
  218. color: #c45656;
  219. }
  220. .web-qr-card--ok {
  221. background: #e9f9f2;
  222. border-color: #a7f3d0;
  223. }
  224. .web-qr-desc {
  225. display: block;
  226. font-size: 28rpx;
  227. line-height: 1.6;
  228. color: #334155;
  229. margin-bottom: 36rpx;
  230. }
  231. .web-qr-msg {
  232. display: block;
  233. font-size: 28rpx;
  234. line-height: 1.6;
  235. text-align: center;
  236. color: #334155;
  237. }
  238. .web-qr-msg--ok {
  239. color: #087f57;
  240. font-weight: 500;
  241. }
  242. .footer-line {
  243. display: flex;
  244. flex-wrap: wrap;
  245. justify-content: center;
  246. align-items: center;
  247. column-gap: 4rpx;
  248. row-gap: 8rpx;
  249. margin-bottom: 8rpx;
  250. }
  251. .agree-check {
  252. display: inline-flex;
  253. align-items: center;
  254. justify-content: center;
  255. padding: 8rpx;
  256. margin-right: 4rpx;
  257. flex-shrink: 0;
  258. }
  259. .agree-check__icon {
  260. width: 30rpx;
  261. height: 30rpx;
  262. border-radius: 50%;
  263. border: 2rpx solid #c0c4cc;
  264. box-sizing: border-box;
  265. display: flex;
  266. align-items: center;
  267. justify-content: center;
  268. background: #fff;
  269. }
  270. .agree-check__tick {
  271. font-size: 18rpx;
  272. font-weight: 700;
  273. line-height: 1;
  274. color: transparent;
  275. }
  276. .agree-check__icon--checked {
  277. border-color: #0a9f6d;
  278. background: #0a9f6d;
  279. }
  280. .agree-check__icon--checked .agree-check__tick {
  281. color: #fff;
  282. }
  283. .footer-text,
  284. .link {
  285. font-size: 22rpx;
  286. line-height: 1.6;
  287. }
  288. .footer-text {
  289. color: #909399;
  290. }
  291. .link {
  292. color: #0a9f6d;
  293. }
  294. .web-qr-btn {
  295. margin-top: 32rpx;
  296. height: 88rpx;
  297. border-radius: 44rpx;
  298. background: #0a9f6d;
  299. color: #fff;
  300. font-size: 30rpx;
  301. font-weight: 500;
  302. display: flex;
  303. align-items: center;
  304. justify-content: center;
  305. border: none;
  306. padding: 0;
  307. line-height: 1;
  308. width: 100%;
  309. box-sizing: border-box;
  310. }
  311. .web-qr-btn.is-disabled {
  312. opacity: 0.75;
  313. }
  314. .web-qr-btn__label {
  315. color: #fff;
  316. font-size: 30rpx;
  317. font-weight: 500;
  318. }
  319. button.web-qr-btn::after {
  320. border: none;
  321. }
  322. .protocol-dialog-mask {
  323. position: fixed;
  324. inset: 0;
  325. background: rgba(0, 0, 0, 0.42);
  326. z-index: 99;
  327. display: flex;
  328. align-items: center;
  329. justify-content: center;
  330. padding: 40rpx;
  331. box-sizing: border-box;
  332. }
  333. .protocol-dialog {
  334. width: 100%;
  335. max-width: 620rpx;
  336. background: #fff;
  337. border-radius: 24rpx;
  338. padding: 36rpx 30rpx 28rpx;
  339. box-sizing: border-box;
  340. box-shadow: 0 14rpx 48rpx rgba(0, 0, 0, 0.16);
  341. }
  342. .protocol-dialog__title {
  343. display: block;
  344. text-align: center;
  345. font-size: 34rpx;
  346. font-weight: 700;
  347. color: #303133;
  348. }
  349. .protocol-dialog__desc {
  350. display: block;
  351. margin-top: 16rpx;
  352. text-align: center;
  353. font-size: 26rpx;
  354. line-height: 1.6;
  355. color: #606266;
  356. }
  357. .protocol-dialog__actions {
  358. margin-top: 30rpx;
  359. display: flex;
  360. gap: 16rpx;
  361. }
  362. .protocol-dialog__btn {
  363. flex: 1;
  364. min-height: 78rpx;
  365. border-radius: 999rpx;
  366. display: flex;
  367. align-items: center;
  368. justify-content: center;
  369. font-size: 28rpx;
  370. font-weight: 600;
  371. }
  372. .protocol-dialog__btn--cancel {
  373. background: #f4f7f6;
  374. color: #5b6b64;
  375. border: 1px solid #ddebe5;
  376. }
  377. .protocol-dialog__btn--confirm {
  378. background: #0a9f6d;
  379. color: #fff;
  380. border: none;
  381. margin: 0;
  382. padding: 0;
  383. line-height: normal;
  384. }
  385. button.protocol-dialog__btn--confirm::after {
  386. border: none;
  387. }
  388. </style>