Przeglądaj źródła

feat(miniprogram): add web QR phone confirm page for admin login.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 miesiąc temu
rodzic
commit
cce8fae673

+ 2 - 0
miniprogram/config/api.js

@@ -37,6 +37,8 @@ export const AUTH_API = {
   LOGIN_RSA_PUBLIC_KEY: '/web/auth/login-rsa-public-key',
   LOGIN: '/web/auth/login',
   WECHAT_LOGIN: '/mini/auth/wechat-login',
+  WEB_QR_SCANNED: '/mini/auth/web-qr-scanned',
+  WEB_QR_CONFIRM: '/mini/auth/web-qr-confirm',
   LOGOUT: '/web/auth/logout',
   CURRENT_USER: '/web/auth/current-user',
   REFRESH_TOKEN: '/web/auth/refresh-token',

+ 8 - 0
miniprogram/pages.json

@@ -22,6 +22,14 @@
         "navigationBarTextStyle": "black"
       }
     },
+    {
+      "path": "pages/login/web-qr/index",
+      "style": {
+        "navigationBarTitleText": "网页登录授权",
+        "navigationBarBackgroundColor": "#f3f7f5",
+        "navigationBarTextStyle": "black"
+      }
+    },
     {
       "path": "pages/workbench/workbench",
       "style": {

+ 437 - 0
miniprogram/pages/login/web-qr/index.vue

@@ -0,0 +1,437 @@
+<script setup>
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { useUserStore } from '@/stores/user'
+import { DEFAULT_PLACEHOLDER_IMAGE_URL } from '@/utils/staticAsset'
+
+const userStore = useUserStore()
+
+const sessionId = ref('')
+const done = ref(false)
+const agreeProtocol = ref(false)
+const isLoading = ref(false)
+const protocolDialogVisible = ref(false)
+const protocolDialogForWechat = ref(false)
+
+onLoad((options) => {
+  const opts = options || {}
+  let sid = ''
+  if (opts.scene) {
+    try {
+      sid = decodeURIComponent(String(opts.scene))
+    } catch (e) {
+      sid = String(opts.scene)
+    }
+  } else if (opts.sessionId) {
+    sid = String(opts.sessionId)
+  }
+  sid = (sid || '').trim()
+  sessionId.value = sid
+  if (!sid) return
+  userStore.webQrScanned(sid).catch(() => {
+    /* 软失败:PC 仍可继续轮询或用户可重试授权 */
+  })
+})
+
+const goUserAgreement = () => {
+  uni.navigateTo({ url: '/pages/login/user-agreement' })
+}
+
+const goPrivacyPolicy = () => {
+  uni.navigateTo({ url: '/pages/login/privacy-policy' })
+}
+
+const toggleProtocolAgreement = () => {
+  agreeProtocol.value = !agreeProtocol.value
+}
+
+const resolveProtocolDialog = (confirmed) => {
+  protocolDialogVisible.value = false
+  protocolDialogForWechat.value = false
+  if (confirmed) {
+    agreeProtocol.value = true
+  }
+}
+
+const onWechatGate = () => {
+  if (isLoading.value || done.value) return
+  protocolDialogForWechat.value = true
+  protocolDialogVisible.value = true
+}
+
+const onAgreeGetPhoneNumber = (e) => {
+  agreeProtocol.value = true
+  protocolDialogVisible.value = false
+  protocolDialogForWechat.value = false
+  onGetPhoneNumber(e)
+}
+
+const onGetPhoneNumber = async (e) => {
+  if (isLoading.value || done.value || !sessionId.value) return
+  if (!agreeProtocol.value) {
+    onWechatGate()
+    return
+  }
+  const detail = (e && e.detail) || {}
+  const errMsg = detail.errMsg || ''
+  if ((errMsg && errMsg.indexOf('ok') === -1) || !detail.code) {
+    uni.showToast({
+      title: '需要授权手机号才能完成扫码登录',
+      icon: 'none'
+    })
+    return
+  }
+  isLoading.value = true
+  try {
+    const loginRes = await new Promise((resolve, reject) => {
+      uni.login({ provider: 'weixin', success: resolve, fail: reject })
+    })
+    const loginCode = loginRes && loginRes.code
+    if (!loginCode) {
+      throw new Error('微信登录失败,请重试')
+    }
+    const result = await userStore.webQrConfirm({
+      sessionId: sessionId.value,
+      loginCode,
+      phoneCode: detail.code
+    })
+    if (result && result.ok) {
+      done.value = true
+      uni.showToast({
+        title: result.message || '电脑端即将登录,可关闭本页',
+        icon: 'none'
+      })
+      return
+    }
+    uni.showToast({
+      title: (result && result.message) || '授权失败',
+      icon: 'none'
+    })
+  } catch (err) {
+    uni.showToast({
+      title: (err && err.message) || '授权失败',
+      icon: 'none'
+    })
+  } finally {
+    isLoading.value = false
+  }
+}
+</script>
+
+<template>
+  <view class="web-qr-page">
+    <view class="web-qr-brand">
+      <image class="web-qr-brand__logo" :src="DEFAULT_PLACEHOLDER_IMAGE_URL" mode="widthFix" />
+      <view class="web-qr-brand__title">网页登录授权</view>
+    </view>
+
+    <view v-if="!sessionId" class="web-qr-card web-qr-card--error">
+      <text class="web-qr-msg">请从电脑登录页重新扫码</text>
+    </view>
+
+    <view v-else-if="done" class="web-qr-card web-qr-card--ok">
+      <text class="web-qr-msg web-qr-msg--ok">电脑端即将登录,可关闭本页</text>
+    </view>
+
+    <view v-else class="web-qr-card">
+      <text class="web-qr-desc">正在登录电脑端管理后台,请授权手机号完成验证</text>
+
+      <view class="footer-line">
+        <view class="agree-check" @tap="toggleProtocolAgreement">
+          <view class="agree-check__icon" :class="{ 'agree-check__icon--checked': agreeProtocol }">
+            <text class="agree-check__tick">✓</text>
+          </view>
+        </view>
+        <text class="footer-text">授权即表示同意</text>
+        <text class="link" @tap="goUserAgreement">《用户服务协议》</text>
+        <text class="footer-text">和</text>
+        <text class="link" @tap="goPrivacyPolicy">《隐私政策》</text>
+      </view>
+
+      <button
+        v-if="agreeProtocol"
+        class="web-qr-btn"
+        :disabled="isLoading"
+        open-type="getPhoneNumber"
+        @getphonenumber="onGetPhoneNumber"
+      >
+        <text class="web-qr-btn__label">{{ isLoading ? '授权中...' : '授权手机号并登录电脑端' }}</text>
+      </button>
+      <view
+        v-else
+        class="web-qr-btn"
+        :class="{ 'is-disabled': isLoading }"
+        @tap="onWechatGate"
+      >
+        <text class="web-qr-btn__label">授权手机号并登录电脑端</text>
+      </view>
+    </view>
+
+    <view
+      v-if="protocolDialogVisible"
+      class="protocol-dialog-mask"
+      @tap="resolveProtocolDialog(false)"
+    >
+      <view class="protocol-dialog" @tap.stop>
+        <text class="protocol-dialog__title">协议确认</text>
+        <text class="protocol-dialog__desc">授权前请先同意《用户服务协议》和《隐私政策》</text>
+        <view class="protocol-dialog__actions">
+          <view
+            class="protocol-dialog__btn protocol-dialog__btn--cancel"
+            @tap="resolveProtocolDialog(false)"
+          >
+            否
+          </view>
+          <button
+            v-if="protocolDialogForWechat"
+            class="protocol-dialog__btn protocol-dialog__btn--confirm"
+            open-type="getPhoneNumber"
+            @getphonenumber="onAgreeGetPhoneNumber"
+          >
+            同意并授权
+          </button>
+          <view
+            v-else
+            class="protocol-dialog__btn protocol-dialog__btn--confirm"
+            @tap="resolveProtocolDialog(true)"
+          >
+            是,继续
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<style lang="scss" scoped>
+.web-qr-page {
+  min-height: 100vh;
+  padding: 120rpx 48rpx 64rpx;
+  box-sizing: border-box;
+  background: #fff;
+}
+
+.web-qr-brand {
+  text-align: center;
+  margin-bottom: 48rpx;
+}
+
+.web-qr-brand__logo {
+  display: block;
+  width: 320rpx;
+  margin: 0 auto;
+  border-radius: 16rpx;
+}
+
+.web-qr-brand__title {
+  margin-top: 24rpx;
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #0a9f6d;
+}
+
+.web-qr-card {
+  background: #f7faf8;
+  border: 1px solid #e2ebe6;
+  border-radius: 24rpx;
+  padding: 40rpx 32rpx;
+}
+
+.web-qr-card--error .web-qr-msg {
+  color: #c45656;
+}
+
+.web-qr-card--ok {
+  background: #e9f9f2;
+  border-color: #a7f3d0;
+}
+
+.web-qr-desc {
+  display: block;
+  font-size: 28rpx;
+  line-height: 1.6;
+  color: #334155;
+  margin-bottom: 36rpx;
+}
+
+.web-qr-msg {
+  display: block;
+  font-size: 28rpx;
+  line-height: 1.6;
+  text-align: center;
+  color: #334155;
+}
+
+.web-qr-msg--ok {
+  color: #087f57;
+  font-weight: 500;
+}
+
+.footer-line {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  align-items: center;
+  column-gap: 4rpx;
+  row-gap: 8rpx;
+  margin-bottom: 8rpx;
+}
+
+.agree-check {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 8rpx;
+  margin-right: 4rpx;
+  flex-shrink: 0;
+}
+
+.agree-check__icon {
+  width: 30rpx;
+  height: 30rpx;
+  border-radius: 50%;
+  border: 2rpx solid #c0c4cc;
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #fff;
+}
+
+.agree-check__tick {
+  font-size: 18rpx;
+  font-weight: 700;
+  line-height: 1;
+  color: transparent;
+}
+
+.agree-check__icon--checked {
+  border-color: #0a9f6d;
+  background: #0a9f6d;
+}
+
+.agree-check__icon--checked .agree-check__tick {
+  color: #fff;
+}
+
+.footer-text,
+.link {
+  font-size: 22rpx;
+  line-height: 1.6;
+}
+
+.footer-text {
+  color: #909399;
+}
+
+.link {
+  color: #0a9f6d;
+}
+
+.web-qr-btn {
+  margin-top: 32rpx;
+  height: 88rpx;
+  border-radius: 44rpx;
+  background: #0a9f6d;
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  padding: 0;
+  line-height: 1;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.web-qr-btn.is-disabled {
+  opacity: 0.75;
+}
+
+.web-qr-btn__label {
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 500;
+}
+
+button.web-qr-btn::after {
+  border: none;
+}
+
+.protocol-dialog-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.42);
+  z-index: 99;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 40rpx;
+  box-sizing: border-box;
+}
+
+.protocol-dialog {
+  width: 100%;
+  max-width: 620rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 36rpx 30rpx 28rpx;
+  box-sizing: border-box;
+  box-shadow: 0 14rpx 48rpx rgba(0, 0, 0, 0.16);
+}
+
+.protocol-dialog__title {
+  display: block;
+  text-align: center;
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #303133;
+}
+
+.protocol-dialog__desc {
+  display: block;
+  margin-top: 16rpx;
+  text-align: center;
+  font-size: 26rpx;
+  line-height: 1.6;
+  color: #606266;
+}
+
+.protocol-dialog__actions {
+  margin-top: 30rpx;
+  display: flex;
+  gap: 16rpx;
+}
+
+.protocol-dialog__btn {
+  flex: 1;
+  min-height: 78rpx;
+  border-radius: 999rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 28rpx;
+  font-weight: 600;
+}
+
+.protocol-dialog__btn--cancel {
+  background: #f4f7f6;
+  color: #5b6b64;
+  border: 1px solid #ddebe5;
+}
+
+.protocol-dialog__btn--confirm {
+  background: #0a9f6d;
+  color: #fff;
+  border: none;
+  margin: 0;
+  padding: 0;
+  line-height: normal;
+}
+
+button.protocol-dialog__btn--confirm::after {
+  border: none;
+}
+</style>

+ 34 - 0
miniprogram/stores/user.js

@@ -159,6 +159,38 @@ export const useUserStore = defineStore('user', () => {
     }
   }
 
+  /** 网页扫码:通知已扫;不写本地登录态 */
+  async function webQrScanned(sessionId) {
+    const res = await request({
+      url: AUTH_API.WEB_QR_SCANNED,
+      method: 'POST',
+      data: { sessionId }
+    })
+    const result = parseResponseJson(res)
+    return !!(result && result.code === 200)
+  }
+
+  /** 网页扫码:手机号授权确认;不写本地登录态、不跳转工作台 */
+  async function webQrConfirm(params) {
+    const res = await request({
+      url: AUTH_API.WEB_QR_CONFIRM,
+      method: 'POST',
+      data: {
+        sessionId: params.sessionId,
+        loginCode: params.loginCode,
+        phoneCode: params.phoneCode
+      }
+    })
+    const result = parseResponseJson(res)
+    if (result && result.code === 200) {
+      return {
+        ok: true,
+        message: (result.data && result.data.message) || '电脑端即将登录,可关闭本页'
+      }
+    }
+    return { ok: false, message: (result && result.message) || '授权失败' }
+  }
+
   function clearLoginInfo() {
     token.value = ''
     refreshToken.value = ''
@@ -223,6 +255,8 @@ export const useUserStore = defineStore('user', () => {
     checkLoginStatus,
     login,
     wechatLogin,
+    webQrScanned,
+    webQrConfirm,
     logout,
     clearLoginInfo,
     navigateToHome,

+ 1 - 1
miniprogram/utils/request.js

@@ -9,7 +9,7 @@ const API_ENCRYPT_FAIL_OPEN = false
 const API_ENCRYPT_FIXED_SECRET = 'shanxia-web-api-crypto-fixed-secret'
 // TODO(config-hardcoded): 排除路径清单后续改为集中配置,避免多端重复维护。
 const API_ENCRYPT_EXCLUDE_PATHS = String(
-  '/web/auth/login-rsa-public-key,/web/auth/login,/web/auth/refresh-token,/mini/auth/wechat-login'
+  '/web/auth/login-rsa-public-key,/web/auth/login,/web/auth/refresh-token,/mini/auth/wechat-login,/mini/auth/web-qr-scanned,/mini/auth/web-qr-confirm'
 )
   .split(',')
   .map((item) => item.trim())