Przeglądaj źródła

feat(h5+wechat): 新增H5端支持与微信公众号登录支付能力

1.  配置开发/生产环境变量,新增微信公众号AppID配置项
2.  新增H5编译脚本与依赖,配置H5路由和基础路径
3.  新增微信H5网页授权工具类,实现微信授权登录流程
4.  新增短信验证码登录、微信H5授权登录的API接口
5.  重构登录页面,适配H5与非微信小程序环境
6.  实现H5端微信JSAPI支付,适配微信浏览器场景
7.  调整登录banner适配H5页面安全区域
```
yanxh 2 tygodni temu
rodzic
commit
3c18921d93

+ 3 - 1
.env.development

@@ -1,2 +1,4 @@
-VITE_API_BASE_URL=http://127.0.0.1:8086/mobile
+VITE_API_BASE_URL=/mobile
 VITE_DEV_API_TARGET=http://127.0.0.1:8086
+# 微信公众号 AppID(H5 微信网页授权登录使用,仅在微信浏览器内生效)
+VITE_WX_MP_APPID=

+ 2 - 0
.env.production

@@ -1 +1,3 @@
 VITE_API_BASE_URL=/mobile
+# 微信公众号 AppID(H5 微信网页授权登录使用,仅在微信浏览器内生效)
+VITE_WX_MP_APPID=

+ 1 - 0
package-lock.json

@@ -10,6 +10,7 @@
             "dependencies": {
                 "@dcloudio/uni-app": "3.0.0-4080420251103001",
                 "@dcloudio/uni-components": "3.0.0-4080420251103001",
+                "@dcloudio/uni-h5": "3.0.0-4080420251103001",
                 "@dcloudio/uni-mp-weixin": "3.0.0-4080420251103001",
                 "pinia": "^2.1.7",
                 "uview-plus": "^3.7.36",

+ 4 - 1
package.json

@@ -6,12 +6,15 @@
     "scripts": {
         "dev:mp-weixin": "uni -p mp-weixin",
         "build:mp-weixin": "uni build -p mp-weixin",
+        "dev:h5": "uni -p h5",
+        "build:h5": "uni build -p h5",
         "gen:tabbar-icons": "node scripts/gen-tabbar-icons.mjs",
         "type-check": "vue-tsc --noEmit"
     },
     "dependencies": {
         "@dcloudio/uni-app": "3.0.0-4080420251103001",
         "@dcloudio/uni-components": "3.0.0-4080420251103001",
+        "@dcloudio/uni-h5": "3.0.0-4080420251103001",
         "@dcloudio/uni-mp-weixin": "3.0.0-4080420251103001",
         "pinia": "^2.1.7",
         "uview-plus": "^3.7.36",
@@ -33,4 +36,4 @@
     "uni-app": {
         "scripts": {}
     }
-}
+}

+ 26 - 0
src/api/modules/memberAuth.ts

@@ -44,3 +44,29 @@ export function loginByWechatMiniPhone(payload: {
 export function getPhoneByCode(phoneCode: string): Promise<ApiResponse<string>> {
   return post<string>('/member/auth/getPhoneByCode', { phoneCode })
 }
+
+/** 发送短信验证码 */
+export function sendSmsCode(params: {
+  mobile: string
+  scene: string
+}): Promise<ApiResponse<{ expireSeconds: number }>> {
+  return post<{ expireSeconds: number }>('/member/auth/sendSmsCode', params, { showLoading: false, skipAuth: true })
+}
+
+/** 手机验证码登录(H5 通用登录方式) */
+export function loginByMobile(payload: {
+  mobile: string
+  smsCode: string
+  inviteCode?: string
+  platform?: string
+}): Promise<ApiResponse<MemberLoginResultVO>> {
+  return post<MemberLoginResultVO>('/member/auth/loginByMobile', payload, { showLoading: false, skipAuth: true })
+}
+
+/** 微信公众号 H5 网页授权登录 */
+export function loginByWechatMp(payload: {
+  code: string
+  inviteCode?: string
+}): Promise<ApiResponse<MemberLoginResultVO>> {
+  return post<MemberLoginResultVO>('/member/auth/loginByWechatMp', payload, { showLoading: false, skipAuth: true })
+}

+ 3 - 1
src/config/env.ts

@@ -1,3 +1,5 @@
 export const envConfig = {
-  apiBaseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8086/mobile'
+  apiBaseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8086/mobile',
+  /** 微信公众号 AppID,H5 微信网页授权登录使用 */
+  wxMpAppId: import.meta.env.VITE_WX_MP_APPID || ''
 }

+ 1 - 0
src/env.d.ts

@@ -3,6 +3,7 @@
 interface ImportMetaEnv {
   readonly VITE_API_BASE_URL?: string
   readonly VITE_DEV_API_TARGET?: string
+  readonly VITE_WX_MP_APPID?: string
 }
 
 interface ImportMeta {

+ 9 - 1
src/manifest.json

@@ -13,5 +13,13 @@
             "minified": true
         },
         "usingComponents": true
+    },
+    "h5": {
+        "router": {
+            "mode": "history",
+            "base": "/"
+        },
+        "title": "订餐小程序",
+        "template": "index.html"
     }
-}
+}

+ 214 - 21
src/package-views/pages/login/index.vue

@@ -2,7 +2,7 @@
   <view class="safe-page login">
     <view class="hero">
       <view class="brand">订餐小程序</view>
-      <view class="subtitle">授权本机号码,一键登录订餐服务</view>
+      <view class="subtitle">登录后享受订餐服务</view>
     </view>
 
     <view class="card box">
@@ -12,8 +12,28 @@
       </button>
       <!-- #endif -->
 
-      <!-- #ifndef MP-WEIXIN -->
-      <view class="tip-box">一键登录仅支持微信小程序,请在微信中打开本小程序。</view>
+      <!-- #ifdef H5 -->
+      <block v-if="inWechat && !showMobileForm">
+        <button class="phone-btn" :loading="logging" @click="onWechatAuthLogin">微信授权登录</button>
+        <view class="switch-link" @click="showMobileForm = true">使用手机号验证码登录</view>
+      </block>
+      <block v-else>
+        <view class="form-item">
+          <input v-model="mobile" type="number" maxlength="11" placeholder="请输入手机号" class="form-input" />
+        </view>
+        <view class="form-item code-row">
+          <input v-model="smsCode" type="number" maxlength="6" placeholder="请输入验证码" class="form-input" />
+          <view class="code-btn" :class="{ disabled: countdown > 0 }" @click="onSendSmsCode">
+            {{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
+          </view>
+        </view>
+        <button class="phone-btn" :loading="logging" @click="onMobileLogin">登录</button>
+        <view v-if="inWechat" class="switch-link" @click="showMobileForm = false">返回微信授权登录</view>
+      </block>
+      <!-- #endif -->
+
+      <!-- #ifndef MP-WEIXIN || H5 -->
+      <view class="tip-box">当前环境暂不支持登录,请在微信小程序或H5中打开。</view>
       <!-- #endif -->
 
       <view class="agreement-section">
@@ -35,25 +55,81 @@
 
 <script setup lang="ts">
 import { onLoad } from '@dcloudio/uni-app'
-import { ref } from 'vue'
-import { loginByWechatMiniPhone } from '@/api/modules/memberAuth'
+import { ref, onUnmounted } from 'vue'
+import { loginByWechatMiniPhone, loginByMobile, loginByWechatMp, sendSmsCode, type MemberLoginResultVO } from '@/api/modules/memberAuth'
 import { useAuthStore } from '@/store/modules/auth'
 import { isTabBarPage } from '@/utils/auth'
 import { removeSsoToken } from '@/utils/tokenStorage'
+import { isWechatBrowser, buildWechatOAuthUrl, getWechatOAuthCode } from '@/utils/wechatOAuth'
 
 const auth = useAuthStore()
 const redirect = ref('')
 const agreed = ref(false)
 const logging = ref(false)
+const mobile = ref('')
+const smsCode = ref('')
+const countdown = ref(0)
+const showMobileForm = ref(false)
+const inWechat = ref(false)
+let countdownTimer: ReturnType<typeof setInterval> | null = null
+
+// #ifdef H5
+inWechat.value = isWechatBrowser()
+// #endif
 
 onLoad(query => {
   redirect.value = decodeURIComponent(String(query?.redirect || ''))
+  // #ifdef H5
+  if (inWechat.value) {
+    const code = getWechatOAuthCode()
+    if (code) doWechatMpLogin(code)
+  }
+  // #endif
+})
+
+onUnmounted(() => {
+  if (countdownTimer) {
+    clearInterval(countdownTimer)
+    countdownTimer = null
+  }
 })
 
 function openAgreement(type: 'service' | 'privacy') {
   uni.showToast({ title: type === 'service' ? '用户协议页面待配置' : '隐私政策页面待配置', icon: 'none' })
 }
 
+function applyLoginResult(data: MemberLoginResultVO) {
+  auth.setToken(data.accessToken)
+  if (data.refreshToken) {
+    auth.setSsoToken(data.refreshToken)
+  } else {
+    removeSsoToken()
+  }
+  auth.setUser({
+    id: data.memberId,
+    nickname: (data.nickname || '会员').trim(),
+    loginName: data.memberNo?.trim(),
+    avatar: data.avatar || '',
+    phone: (data.mobile || '').trim()
+  })
+  uni.hideLoading()
+  uni.showToast({ title: '登录成功', icon: 'none' })
+  setTimeout(goAfterLogin, 300)
+}
+
+/** 清理 H5 地址栏上的微信授权 code,避免刷新重复登录 */
+function cleanOAuthCodeFromUrl() {
+  // #ifdef H5
+  if (window.location.search) {
+    const url = new URL(window.location.href)
+    url.searchParams.delete('code')
+    url.searchParams.delete('state')
+    window.history.replaceState({}, '', url.toString())
+  }
+  // #endif
+}
+
+// #ifdef MP-WEIXIN
 async function onGetPhoneNumber(e: { detail?: { errMsg?: string; code?: string } }) {
   if (logging.value) return
   if (!agreed.value) {
@@ -81,24 +157,98 @@ async function onGetPhoneNumber(e: { detail?: { errMsg?: string; code?: string }
     if (!jsCode) throw new Error('获取微信登录凭证失败')
 
     const res = await loginByWechatMiniPhone({ code: jsCode, phoneCode })
-    const data = res.data
-    auth.setToken(data.accessToken)
-    if (data.refreshToken) {
-      auth.setSsoToken(data.refreshToken)
-    } else {
-      removeSsoToken()
-    }
-    auth.setUser({
-      id: data.memberId,
-      nickname: (data.nickname || '会员').trim(),
-      loginName: data.memberNo?.trim(),
-      avatar: data.avatar || '',
-      phone: (data.mobile || '').trim()
-    })
+    applyLoginResult(res.data)
+  } catch (error) {
+    uni.hideLoading()
+    uni.showToast({ title: (error as Error).message || '登录失败', icon: 'none' })
+  } finally {
+    logging.value = false
+  }
+}
+// #endif
 
+// #ifdef H5
+function onWechatAuthLogin() {
+  if (logging.value) return
+  if (!agreed.value) {
+    uni.showToast({ title: '请先阅读并同意用户协议与隐私政策', icon: 'none' })
+    return
+  }
+  try {
+    const oauthUrl = buildWechatOAuthUrl('meal_login')
+    window.location.href = oauthUrl
+  } catch (error) {
+    uni.showToast({ title: (error as Error).message || '微信授权登录配置异常', icon: 'none' })
+  }
+}
+
+async function doWechatMpLogin(code: string) {
+  if (logging.value) return
+  logging.value = true
+  uni.showLoading({ title: '登录中...', mask: true })
+  try {
+    const res = await loginByWechatMp({ code })
+    if (res.data.needBindMobile) {
+      uni.hideLoading()
+      showMobileForm.value = true
+      uni.showToast({ title: '该微信账号未绑定手机号,请使用手机号登录', icon: 'none' })
+      return
+    }
+    applyLoginResult(res.data)
+  } catch (error) {
     uni.hideLoading()
-    uni.showToast({ title: '登录成功', icon: 'none' })
-    setTimeout(goAfterLogin, 300)
+    uni.showToast({ title: (error as Error).message || '微信登录失败', icon: 'none' })
+  } finally {
+    cleanOAuthCodeFromUrl()
+    logging.value = false
+  }
+}
+
+async function onSendSmsCode() {
+  if (countdown.value > 0) return
+  if (!/^1[3-9]\d{9}$/.test(mobile.value)) {
+    uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
+    return
+  }
+  try {
+    await sendSmsCode({ mobile: mobile.value, scene: 'LOGIN' })
+    uni.showToast({ title: '验证码已发送', icon: 'none' })
+    startCountdown()
+  } catch (error) {
+    uni.showToast({ title: (error as Error).message || '验证码发送失败', icon: 'none' })
+  }
+}
+
+function startCountdown() {
+  countdown.value = 60
+  countdownTimer = setInterval(() => {
+    countdown.value--
+    if (countdown.value <= 0 && countdownTimer) {
+      clearInterval(countdownTimer)
+      countdownTimer = null
+    }
+  }, 1000)
+}
+
+async function onMobileLogin() {
+  if (logging.value) return
+  if (!agreed.value) {
+    uni.showToast({ title: '请先阅读并同意用户协议与隐私政策', icon: 'none' })
+    return
+  }
+  if (!/^1[3-9]\d{9}$/.test(mobile.value)) {
+    uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
+    return
+  }
+  if (!smsCode.value) {
+    uni.showToast({ title: '请输入验证码', icon: 'none' })
+    return
+  }
+  logging.value = true
+  uni.showLoading({ title: '登录中...', mask: true })
+  try {
+    const res = await loginByMobile({ mobile: mobile.value, smsCode: smsCode.value, platform: 'H5' })
+    applyLoginResult(res.data)
   } catch (error) {
     uni.hideLoading()
     uni.showToast({ title: (error as Error).message || '登录失败', icon: 'none' })
@@ -106,6 +256,7 @@ async function onGetPhoneNumber(e: { detail?: { errMsg?: string; code?: string }
     logging.value = false
   }
 }
+// #endif
 
 function goAfterLogin() {
   const fallbackUrl = '/pages/meal/index'
@@ -175,6 +326,48 @@ function goAfterLogin() {
   font-size: 26rpx;
   line-height: 1.5;
 }
+.form-item {
+  margin-bottom: 24rpx;
+}
+.form-input {
+  width: 100%;
+  height: 88rpx;
+  padding: 0 24rpx;
+  border-radius: 16rpx;
+  background: #f3f6f8;
+  font-size: 30rpx;
+  color: #16343a;
+  box-sizing: border-box;
+}
+.code-row {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.code-row .form-input {
+  flex: 1;
+}
+.code-btn {
+  flex-shrink: 0;
+  height: 88rpx;
+  line-height: 88rpx;
+  padding: 0 24rpx;
+  border-radius: 16rpx;
+  background: #e0f7fa;
+  color: #00838f;
+  font-size: 26rpx;
+  font-weight: 600;
+
+  &.disabled {
+    opacity: 0.5;
+  }
+}
+.switch-link {
+  margin-top: 28rpx;
+  text-align: center;
+  font-size: 26rpx;
+  color: #00a9bf;
+}
 .agreement-section {
   display: flex;
   align-items: flex-start;

+ 5 - 0
src/pages/meal/index.vue

@@ -423,6 +423,11 @@ function goLogin() {
   align-items: center;
   box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.18);
 }
+/* #ifdef H5 */
+.login-banner {
+  bottom: calc(28rpx + 50px + env(safe-area-inset-bottom));
+}
+/* #endif */
 .login-title {
   font-size: 28rpx;
   font-weight: 800;

+ 2 - 0
src/uni.scss

@@ -1,3 +1,5 @@
+@import "uview-plus/theme.scss";
+
 $meal-primary: #00bcd4;
 $meal-primary-dark: #0097a7;
 $meal-bg: #f5f7fa;

+ 39 - 1
src/utils/pay.ts

@@ -21,8 +21,46 @@ export function requestWechatPay(params: WechatPayParams) {
       fail: error => reject(normalizePayError(error))
     } as UniApp.RequestPaymentOptions)
     // #endif
-    // #ifndef MP-WEIXIN
+
+    // #ifdef H5
+    requestH5WechatPay(params).then(resolve).catch(reject)
+    // #endif
+
+    // #ifndef MP-WEIXIN || H5
     reject(new Error('当前环境不支持微信支付,请在微信小程序中完成支付'))
     // #endif
   })
 }
+
+/**
+ * H5 微信 JSAPI 支付(需在微信浏览器内、且配置了支付授权目录)
+ */
+function requestH5WechatPay(params: WechatPayParams): Promise<void> {
+  return new Promise((resolve, reject) => {
+    // @ts-ignore - WeixinJSBridge 仅在微信浏览器内全局可用
+    if (typeof WeixinJSBridge === 'undefined') {
+      reject(new Error('当前环境不支持微信支付,请在微信中打开后重试'))
+      return
+    }
+    // @ts-ignore
+    WeixinJSBridge.invoke(
+      'getBrandWCPayRequest',
+      {
+        timeStamp: params.timeStamp || '',
+        nonceStr: params.nonceStr || '',
+        package: params.packageValue || '',
+        signType: params.signType || 'RSA',
+        paySign: params.paySign || ''
+      },
+      (res: { err_msg?: string }) => {
+        if (res.err_msg === 'get_brand_wcpay_request:ok') {
+          resolve()
+        } else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
+          reject(new Error('支付已取消,可在订单详情中继续支付'))
+        } else {
+          reject(new Error('支付失败,可在订单详情中继续处理'))
+        }
+      }
+    )
+  })
+}

+ 63 - 0
src/utils/wechatOAuth.ts

@@ -0,0 +1,63 @@
+import { envConfig } from '@/config/env'
+
+/**
+ * 微信公众号 H5 网页授权工具(仅 H5 环境生效)
+ */
+
+/** 是否在微信浏览器内 */
+export function isWechatBrowser(): boolean {
+  // #ifdef H5
+  const ua = (navigator.userAgent || '').toLowerCase()
+  return ua.includes('micromessenger')
+  // #endif
+  // #ifndef H5
+  return false
+  // #endif
+}
+
+/** 是否为 H5 环境 */
+export function isH5(): boolean {
+  // #ifdef H5
+  return true
+  // #endif
+  // #ifndef H5
+  return false
+  // #endif
+}
+
+/** 获取当前页面去除 code/state 参数后的 URL,作为 oauth 回调地址 */
+function getOAuthRedirectUri(): string {
+  // #ifdef H5
+  const url = new URL(window.location.href)
+  url.searchParams.delete('code')
+  url.searchParams.delete('state')
+  return url.toString()
+  // #endif
+  // #ifndef H5
+  return ''
+  // #endif
+}
+
+/** 构造微信公众号网页授权链接 */
+export function buildWechatOAuthUrl(state = 'meal_login'): string {
+  // #ifdef H5
+  const appid = envConfig.wxMpAppId
+  if (!appid) throw new Error('未配置微信公众号 AppID,无法进行微信授权登录')
+  const redirectUri = encodeURIComponent(getOAuthRedirectUri())
+  return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`
+  // #endif
+  // #ifndef H5
+  return ''
+  // #endif
+}
+
+/** 从当前页面 URL 中提取微信授权 code */
+export function getWechatOAuthCode(): string {
+  // #ifdef H5
+  const params = new URLSearchParams(window.location.search)
+  return params.get('code') || ''
+  // #endif
+  // #ifndef H5
+  return ''
+  // #endif
+}