Procházet zdrojové kódy

feat(auth): 实现手机号短信验证码登录功能

- 新增发送手机短信验证码的 API 接口调用封装
- 创建登录页面,支持手机号输入、验证码输入与登录操作
- 增加验证码倒计时和发送按钮状态控制
- 登录成功后自动获取当前登录会员信息并存储
- 在路由守卫中增加未登录跳转登录页的逻辑及二次确认提示
- 用户状态管理中新增 userInfo 类型定义,合并会员信息结构
- 统一添加请求头 Authorization 用于身份验证
- 新增登录页面路由配置及页面相关样式
- 优化路由跳转逻辑,支持登录后重定向功能
- 项目配置文件调整,增加端口和代理设置用于开发调试
zhangtao před 1 týdnem
rodič
revize
bbdfd2b62f

+ 1 - 1
alova.config.ts

@@ -13,7 +13,7 @@ export default <Config>{
        * 1. openapi json file url
        * 2. local file
        */
-      input: 'http://127.0.0.1:4523/export/openapi/4?version=2.0',
+      input: 'http://127.0.0.1:4523/export/openapi/2?version=2.0',
       /**
        * input file platform. Currently only swagger is supported.
        * When this parameter is specified, the input field only needs to specify the document address without specifying the openapi file

+ 3 - 1
src/api/apiDefinitions.ts

@@ -22,5 +22,7 @@ export default {
   'app.get_smqjh_system_app_api_coupon_findbyid': ['GET', '/smqjh-system/app-api/coupon/findById'],
   'app.get_smqjh_system_app_api_coupon_exchangeinfo': ['GET', '/smqjh-system/app-api/coupon/exchangeInfo'],
   'app.get_smqjh_system_app_api_coupon_exchangepoints': ['GET', '/smqjh-system/app-api/coupon/exchangePoints'],
-  'general.post_smqjh_auth_oauth2_token': ['POST', '/smqjh-auth/oauth2/token']
+  'general.post_smqjh_auth_oauth2_token': ['POST', '/smqjh-auth/oauth2/token'],
+  'general.post_smqjh_auth_api_v1_auth_sms_code': ['POST', '/smqjh-auth/api/v1/auth/sms_code'],
+  'general.get_smqjh_system_app_api_v1_members_me': ['GET', '/smqjh-system/app-api/v1/members/me']
 };

+ 2 - 0
src/api/core/instance.ts

@@ -14,6 +14,8 @@ export const alovaInstance = createAlova({
       method.config.headers['Content-Type'] = 'application/json'
     }
 
+    const { token } = useUserStore()
+    method.config.headers.Authorization = token || 'Basic c21xamgtYXBwbGV0OjEyMzQ1Ng=='
     // Add timestamp to prevent caching for GET requests
     if (method.type === 'GET' && CommonUtil.isObj(method.config.params)) {
       method.config.params._t = Date.now()

+ 173 - 20
src/api/globals.d.ts

@@ -88,6 +88,52 @@ type Alova2Method<
       >
     : never;
 
+export interface MemberVO {
+  /**
+   * 会员ID
+   */
+  id?: number;
+  /**
+   * 会员昵称
+   */
+  nickName?: string;
+  /**
+   * 会员头像地址
+   */
+  avatarUrl?: string;
+  /**
+   * 会员手机号
+   */
+  mobile?: string;
+  /**
+   * 会员余额(单位:分)
+   */
+  balance?: number;
+  /**
+   * 所属企业ID
+   */
+  channelId?: number;
+  /**
+   * 所属企业名称
+   */
+  channelName?: string;
+  /**
+   * 配送费
+   */
+  freightFee?: number;
+  /**
+   * 所属企业顶级ID
+   */
+  channelTopId?: number;
+}
+export interface ResultMemberVO {
+  /**
+   * 返回状态码
+   */
+  code?: string;
+  data?: MemberVO;
+  msg?: string;
+}
 export interface AppMemberCouponVO {
   /**
    * id
@@ -404,6 +450,17 @@ export interface ResultCouponExchangePointsVo {
   data?: CouponExchangePointsVo;
   msg?: string;
 }
+export interface Result {
+  /**
+   * 返回状态码
+   */
+  code?: string;
+  /**
+   * 返回数据对象
+   */
+  data?: null;
+  msg?: string;
+}
 declare global {
   interface Apis {
     app: {
@@ -830,7 +887,7 @@ declare global {
       /**
        * ---
        *
-       * [POST] 公众号登录
+       * [POST] 手机号短信验证码登录
        *
        * **path:** /smqjh-auth/oauth2/token
        *
@@ -840,53 +897,149 @@ declare global {
        * ```ts
        * type QueryParameters = {
        *   grant_type?: string
+       *   mobile?: string
        *   code?: string
        * }
        * ```
        *
        * ---
        *
-       * **RequestBody**
-       * ```ts
-       * type RequestBody = {
-       *   grant_type?: string
-       *   code?: string
-       *   phoneCode?: string
-       * }
-       * ```
-       *
-       * ---
-       *
        * **Response**
        * ```ts
        * type Response = {
-       *   message: string
+       *   code: string
+       *   data: {
+       *     access_token: string
+       *     refresh_token: string
+       *     code: string
+       *     mobile: string
+       *     token_type: string
+       *     expires_in: number
+       *   }
+       *   msg: string
        * }
        * ```
        */
       post_smqjh_auth_oauth2_token<
         Config extends Alova2MethodConfig<{
-          message: string;
+          code: string;
+          data: {
+            access_token: string;
+            refresh_token: string;
+            code: string;
+            mobile: string;
+            token_type: string;
+            expires_in: number;
+          };
+          msg: string;
         }> & {
           params: {
             grant_type?: string;
+            mobile?: string;
             code?: string;
           };
-          data: {
-            grant_type?: string;
-            code?: string;
-            phoneCode?: string;
-          };
         }
       >(
         config: Config
       ): Alova2Method<
         {
-          message: string;
+          code: string;
+          data: {
+            access_token: string;
+            refresh_token: string;
+            code: string;
+            mobile: string;
+            token_type: string;
+            expires_in: number;
+          };
+          msg: string;
         },
         'general.post_smqjh_auth_oauth2_token',
         Config
       >;
+      /**
+       * ---
+       *
+       * [POST] 发送手机短信验证码
+       *
+       * **path:** /smqjh-auth/api/v1/auth/sms_code
+       *
+       * ---
+       *
+       * **Query Parameters**
+       * ```ts
+       * type QueryParameters = {
+       *   // 手机号
+       *   mobile: string
+       * }
+       * ```
+       *
+       * ---
+       *
+       * **Response**
+       * ```ts
+       * type Response = {
+       *   // 返回状态码
+       *   code?: string
+       *   // 返回数据对象
+       *   data?: null
+       *   msg?: string
+       * }
+       * ```
+       */
+      post_smqjh_auth_api_v1_auth_sms_code<
+        Config extends Alova2MethodConfig<Result> & {
+          params: {
+            /**
+             * 手机号
+             */
+            mobile: string;
+          };
+        }
+      >(
+        config: Config
+      ): Alova2Method<Result, 'general.post_smqjh_auth_api_v1_auth_sms_code', Config>;
+      /**
+       * ---
+       *
+       * [GET] 获取登录会员信息
+       *
+       * **path:** /smqjh-system/app-api/v1/members/me
+       *
+       * ---
+       *
+       * **Response**
+       * ```ts
+       * type Response = {
+       *   // 返回状态码
+       *   code?: string
+       *   data?: {
+       *     // 会员ID
+       *     id?: number
+       *     // 会员昵称
+       *     nickName?: string
+       *     // 会员头像地址
+       *     avatarUrl?: string
+       *     // 会员手机号
+       *     mobile?: string
+       *     // 会员余额(单位:分)
+       *     balance?: number
+       *     // 所属企业ID
+       *     channelId?: number
+       *     // 所属企业名称
+       *     channelName?: string
+       *     // 配送费
+       *     freightFee?: number
+       *     // 所属企业顶级ID
+       *     channelTopId?: number
+       *   }
+       *   msg?: string
+       * }
+       * ```
+       */
+      get_smqjh_system_app_api_v1_members_me<Config extends Alova2MethodConfig<ResultMemberVO>>(
+        config?: Config
+      ): Alova2Method<ResultMemberVO, 'general.get_smqjh_system_app_api_v1_members_me', Config>;
     };
   }
 

+ 0 - 2
src/components.d.ts

@@ -20,8 +20,6 @@ declare module 'vue' {
     WdMessageBox: typeof import('wot-design-uni/components/wd-message-box/wd-message-box.vue')['default']
     WdNotify: typeof import('wot-design-uni/components/wd-notify/wd-notify.vue')['default']
     WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
-    WdRadio: typeof import('wot-design-uni/components/wd-radio/wd-radio.vue')['default']
-    WdRadioGroup: typeof import('wot-design-uni/components/wd-radio-group/wd-radio-group.vue')['default']
     WdSticky: typeof import('wot-design-uni/components/wd-sticky/wd-sticky.vue')['default']
     WdTabbar: typeof import('wot-design-uni/components/wd-tabbar/wd-tabbar.vue')['default']
     WdTabbarItem: typeof import('wot-design-uni/components/wd-tabbar-item/wd-tabbar-item.vue')['default']

+ 9 - 0
src/pages.json

@@ -18,6 +18,15 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/login/index",
+      "name": "login",
+      "islogin": false,
+      "style": {
+        "navigationBarTitleText": "登录",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/order/index",
       "name": "order",

+ 142 - 0
src/pages/login/index.vue

@@ -0,0 +1,142 @@
+<script setup lang="ts">
+import type { MemberVO } from '@/api/globals'
+import router from '@/router'
+
+definePage({
+  name: 'login',
+  islogin: false,
+  style: {
+    navigationBarTitleText: '登录',
+    navigationStyle: 'custom',
+  },
+})
+
+// 表单数据
+const phone = ref('')
+const code = ref('')
+// 验证码倒计时
+const countdown = ref(0)
+const isSending = ref(false)
+const { token, redirectName, userInfo } = storeToRefs(useUserStore())
+const tabList = ['/pages/order/index', '/pages/voucher/index', '/pages/index/index']
+// 发送验证码
+async function handleSendCode() {
+  if (!phone.value) {
+    useGlobalToast().show('请输入手机号')
+
+    return
+  }
+  if (!/^1[3-9]\d{9}$/.test(phone.value)) {
+    useGlobalToast().show('请输入正确的手机号')
+    return
+  }
+  if (isSending.value)
+    return
+
+  isSending.value = true
+  countdown.value = 60
+
+  const timer = setInterval(() => {
+    countdown.value--
+    if (countdown.value <= 0) {
+      clearInterval(timer)
+      isSending.value = false
+    }
+  }, 1000)
+
+  // TODO: 调用发送验证码接口
+  await Apis.general.post_smqjh_auth_api_v1_auth_sms_code({ params: { mobile: phone.value } })
+  useGlobalToast().show('验证码已发送')
+}
+
+// 登录
+async function handleLogin() {
+  if (!phone.value) {
+    useGlobalToast().show('请输入手机号')
+    return
+  }
+  if (!code.value) {
+    useGlobalToast().show('请输入验证码')
+    return
+  }
+  uni.showLoading({ mask: true })
+  try {
+    const res = await Apis.general.post_smqjh_auth_oauth2_token({ params: { grant_type: 'sms_code', mobile: phone.value, code: code.value } })
+    uni.hideLoading()
+    token.value = `Bearer ${res.data.access_token}`
+    useGlobalToast().show('登录成功')
+    const user = await Apis.general.get_smqjh_system_app_api_v1_members_me()
+    userInfo.value = user.data as MemberVO
+    setTimeout(() => {
+      if (tabList.includes(redirectName.value)) {
+        router.pushTab({ path: redirectName.value })
+      }
+      else {
+        router.replace({ path: redirectName.value })
+      }
+    }, 1000)
+  }
+  catch {
+    uni.hideLoading()
+  }
+}
+</script>
+
+<template>
+  <view class="login-page min-h-100vh px48rpx">
+    <!-- 表单区域 -->
+    <view class="pt292rpx">
+      <!-- 手机号 -->
+      <view class="mb48rpx">
+        <view class="mb24rpx text-32rpx text-#333 font-medium">
+          手机号
+        </view>
+        <view class="h96rpx flex items-center rounded-48rpx bg-#E5E8D8 px32rpx">
+          <input
+            v-model="phone"
+            type="number"
+            :maxlength="11"
+            placeholder="请输入手机号"
+            placeholder-class="text-#999"
+            class="flex-1 text-32rpx"
+          >
+        </view>
+      </view>
+
+      <!-- 验证码 -->
+      <view class="mb64rpx">
+        <view class="mb24rpx text-32rpx text-#333 font-medium">
+          验证码
+        </view>
+        <view class="h96rpx flex items-center rounded-48rpx bg-#E5E8D8 px32rpx">
+          <input
+            v-model="code"
+            type="tel"
+            :maxlength="4"
+            placeholder="请输入验证码"
+            placeholder-class="text-#999"
+            class="flex-1 text-32rpx"
+          >
+          <view
+            class="ml20rpx flex-shrink-0 text-28rpx"
+            :class="[isSending ? 'text-#999' : 'text-#7CB305']"
+            @click="handleSendCode"
+          >
+            {{ isSending ? `${countdown}秒后重新发送` : '获取验证码' }}
+          </view>
+        </view>
+      </view>
+
+      <!-- 登录按钮 -->
+      <wd-button block size="large" @click="handleLogin">
+        登录
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<style lang="scss" scoped>
+.login-page {
+background: linear-gradient( 180deg, #F3FFD1 0%, #FFFFFF 37.42%, #F3FFD1 100%);
+}
+</style>

+ 54 - 26
src/router/index.ts

@@ -32,34 +32,39 @@ const router = createRouter({
  * 过滤出所有需要登录的name 返回不需要登录的name
  *
  */
-// function whitePathName() {
-//   return generateRoutes().filter(it => !it.islogin).map(it => it.name)
-// }
+function whitePathName() {
+  return generateRoutes().filter(it => !it.islogin).map(it => it.name)
+}
 router.beforeEach((to, from, next) => {
   console.log('🚀 beforeEach 守卫触发:', { to, from }, '')
-  // const { token } = storeToRefs(useUserStore())
-  // if (!whitePathName().includes(to.name) && !token.value) {
-  //   const { confirm: showConfirm, close } = useGlobalMessage()
-  //   return new Promise<void>((resolve, reject) => {
-  //     showConfirm({
-  //       title: '警告',
-  //       msg: '检测到当前状态未登录,是否登录',
-  //       confirmButtonText: '登录',
-  //       cancelButtonText: '取消',
-  //       success() {
-  //         // console.log('✅ 用户确认访问,允许导航')
-  //         // resolve()
-  //         close()
-  //         useUserStore().wxAuthLogin()
-  //       },
-  //       fail() {
-  //         // console.log('❌ 用户取消访问,阻止导航')
-  //         next(false)
-  //         reject(new Error('用户取消访问'))
-  //       },
-  //     })
-  //   })
-  // }
+  const { token, redirectName } = storeToRefs(useUserStore())
+  if (to.name === 'login') {
+    redirectName.value = appendParamsToPath(String(from.path), from.params as any)
+    console.log(redirectName.value, ' redirectName.value')
+  }
+  if (!whitePathName().includes(to.name) && !token.value) {
+    const { confirm: showConfirm } = useGlobalMessage()
+
+    return new Promise<void>((resolve, reject) => {
+      showConfirm({
+        title: '警告',
+        msg: '检测到当前状态未登录,是否登录',
+        confirmButtonText: '登录',
+        cancelButtonText: '取消',
+        success() {
+          redirectName.value = String(to.path)
+          // console.log('✅ 用户确认访问,允许导航')
+          router.replace({ name: 'login' })
+          // resolve()
+        },
+        fail() {
+          // console.log('❌ 用户取消访问,阻止导航')
+          next(false)
+          reject(new Error('用户取消访问'))
+        },
+      })
+    })
+  }
   // 演示:基本的导航日志记录
   if (to.path && from.path) {
     console.log(`📍 导航: ${from.path} → ${to.path}`)
@@ -79,3 +84,26 @@ router.afterEach((to, from) => {
 })
 
 export default router
+/**
+ * 将 params 参数拼接到 path 路径后面
+ * @param path 基础路径
+ * @param params 参数对象
+ * @returns 拼接后的完整路径
+ */
+function appendParamsToPath(path: string, params: Record<string, any>): string {
+  if (!params || Object.keys(params).length === 0) {
+    return path
+  }
+  const queryParams: string[] = []
+  Object.entries(params).forEach(([key, value]) => {
+    if (value !== undefined && value !== null) {
+      // 对参数进行 URL 编码
+      const encodedKey = encodeURIComponent(key)
+      const encodedValue = encodeURIComponent(String(value))
+      queryParams.push(`${encodedKey}=${encodedValue}`)
+    }
+  })
+
+  const queryString = queryParams.join('&')
+  return queryString ? `${path}?${queryString}` : path
+}

+ 4 - 41
src/store/user.ts

@@ -1,3 +1,4 @@
+import type { MemberVO } from '@/api/globals'
 import { defineStore } from 'pinia'
 
 interface userStroe {
@@ -5,46 +6,8 @@ interface userStroe {
   /**
    * 用户登录信息
    */
-  userInfo: {
-    /**
-     * 顶级id
-     */
-    channelTopId?: number
-    /**
-     * 运费(单位:分)
-     */
-    freightFee?: number
-    /**
-     * 企业名称
-     */
-    channelName: string
-    /**
-     * 渠道(企业ID)
-     */
-    channelId: number
-    /**
-     * 会员头像地址
-     */
-    avatarUrl?: string
-    /**
-     * 会员余额(单位:分)
-     */
-    balance?: number
-    /**
-     * 会员ID
-     */
-    id: number
-    /**
-     * 会员手机号
-     */
-    mobile?: string
-    /**
-     * 会员昵称
-     */
-    nickName?: string
-    [property: string]: any
-  }
-  isShowLogin: boolean
+  userInfo: MemberVO
+  redirectName: string
 }
 export const useUserStore = defineStore('user', {
   state: (): userStroe => ({
@@ -54,7 +17,7 @@ export const useUserStore = defineStore('user', {
       channelId: 0,
       channelName: '',
     },
-    isShowLogin: false,
+    redirectName: '',
   }),
   actions: {
 

+ 1 - 0
src/uni-pages.d.ts

@@ -5,6 +5,7 @@
 
 interface NavigateToOptions {
   url: "/pages/confirmOrder/index" |
+       "/pages/login/index" |
        "/pages/refuelDetaile/index";
 }
 

+ 67 - 57
vite.config.ts

@@ -13,63 +13,73 @@ import { UniEchartsResolver } from 'uni-echarts/resolver'
 import { UniEcharts } from 'uni-echarts/vite'
 import UnoCSS from 'unocss/vite'
 import AutoImport from 'unplugin-auto-import/vite'
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
 // https://vitejs.dev/config/
-export default defineConfig({
-  base: './',
-  optimizeDeps: {
-    exclude: process.env.NODE_ENV === 'development' ? ['wot-design-uni', 'uni-echarts'] : [],
-  },
-  plugins: [
+export default defineConfig(({ mode }) => {
+  return {
+    base: './',
+    optimizeDeps: {
+      exclude: process.env.NODE_ENV === 'development' ? ['wot-design-uni', 'uni-echarts'] : [],
+    },
+    plugins: [
     // https://github.com/uni-helper/vite-plugin-uni-manifest
-    UniHelperManifest(),
-    // https://github.com/uni-helper/vite-plugin-uni-pages
-    pagesJson({
-      hooks: [hookUniPlatform], // 支持 vite-plugin-uni-platform
-      dts: 'src/uni-pages.d.ts',
-      exclude: ['**/components/**/*.*'],
-    }),
-    // https://github.com/uni-helper/vite-plugin-uni-layouts
-    UniHelperLayouts(),
-    // https://github.com/uni-helper/vite-plugin-uni-components
-    UniHelperComponents({
-      resolvers: [WotResolver(), UniEchartsResolver()],
-      dts: 'src/components.d.ts',
-      dirs: ['src/components', 'src/business'],
-      directoryAsNamespace: true,
-    }),
-    // https://github.com/uni-ku/root
-    UniKuRoot(),
-    // https://uni-echarts.xiaohe.ink
-    UniEcharts(),
-    // https://uni-helper.cn/plugin-uni
-    Uni(),
-    // https://github.com/uni-ku/bundle-optimizer
-    Optimization({
-      enable: isMpWeixin,
-      logger: false,
-    }),
-    // https://github.com/antfu/unplugin-auto-import
-    AutoImport({
-      imports: ['vue', '@vueuse/core', 'pinia', 'uni-app', {
-        from: '@wot-ui/router',
-        imports: ['createRouter', 'useRouter', 'useRoute'],
-      }, {
-        from: 'wot-design-uni',
-        imports: ['useToast', 'useMessage', 'useNotify', 'CommonUtil'],
-      }, {
-        from: 'alova/client',
-        imports: ['usePagination', 'useRequest'],
-      }],
-      dts: 'src/auto-imports.d.ts',
-      dirs: ['src/composables', 'src/store', 'src/utils', 'src/api'],
-      vueTemplate: true,
-    }),
-    // https://github.com/antfu/unocss
-    // see unocss.config.ts for config
-    UnoCSS(),
-  ],
-  server: {
-    host: '0.0.0.0',
-  },
+      UniHelperManifest(),
+      // https://github.com/uni-helper/vite-plugin-uni-pages
+      pagesJson({
+        hooks: [hookUniPlatform], // 支持 vite-plugin-uni-platform
+        dts: 'src/uni-pages.d.ts',
+        exclude: ['**/components/**/*.*'],
+      }),
+      // https://github.com/uni-helper/vite-plugin-uni-layouts
+      UniHelperLayouts(),
+      // https://github.com/uni-helper/vite-plugin-uni-components
+      UniHelperComponents({
+        resolvers: [WotResolver(), UniEchartsResolver()],
+        dts: 'src/components.d.ts',
+        dirs: ['src/components', 'src/business'],
+        directoryAsNamespace: true,
+      }),
+      // https://github.com/uni-ku/root
+      UniKuRoot(),
+      // https://uni-echarts.xiaohe.ink
+      UniEcharts(),
+      // https://uni-helper.cn/plugin-uni
+      Uni(),
+      // https://github.com/uni-ku/bundle-optimizer
+      Optimization({
+        enable: isMpWeixin,
+        logger: false,
+      }),
+      // https://github.com/antfu/unplugin-auto-import
+      AutoImport({
+        imports: ['vue', '@vueuse/core', 'pinia', 'uni-app', {
+          from: '@wot-ui/router',
+          imports: ['createRouter', 'useRouter', 'useRoute'],
+        }, {
+          from: 'wot-design-uni',
+          imports: ['useToast', 'useMessage', 'useNotify', 'CommonUtil'],
+        }, {
+          from: 'alova/client',
+          imports: ['usePagination', 'useRequest'],
+        }],
+        dts: 'src/auto-imports.d.ts',
+        dirs: ['src/composables', 'src/store', 'src/utils', 'src/api'],
+        vueTemplate: true,
+      }),
+      // https://github.com/antfu/unocss
+      // see unocss.config.ts for config
+      UnoCSS(),
+    ],
+    server: {
+      host: '0.0.0.0',
+      port: 8181,
+      proxy: {
+        '/api': {
+          target: loadEnv(mode, process.cwd()),
+          changeOrigin: true,
+          rewrite: path => path.replace(/^\/api/, ''),
+        },
+      },
+    },
+  }
 })