Преглед изворни кода

修复密码页面重构完成

yuanmingze пре 8 месеци
родитељ
комит
1ae4cf23c7

+ 0 - 1
package.json

@@ -35,7 +35,6 @@
     "async-validator": "^4.2.5",
     "dayjs": "^1.11.19",
     "lodash-es": "^4.17.22",
-    "luch-request": "^3.1.1",
     "nanoid": "^5.1.6",
     "pinia": "^3.0.4",
     "pinia-plugin-persistedstate": "^4.7.1",

+ 0 - 15
pnpm-lock.yaml

@@ -68,9 +68,6 @@ importers:
       lodash-es:
         specifier: ^4.17.22
         version: 4.17.22
-      luch-request:
-        specifier: ^3.1.1
-        version: 3.1.1
       nanoid:
         specifier: ^5.1.6
         version: 5.1.6
@@ -806,9 +803,6 @@ packages:
   '@bcoe/v8-coverage@0.2.3':
     resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
 
-  '@dcloudio/types@2.6.12':
-    resolution: {integrity: sha512-mrCMwcINy1IFjU9VUqLeWBkj404yWs5paLDttBcA+eqUjanuUQbBcTVPqlrGgkyzLXDcV2oDDZRSNxNpXi4kMQ==}
-
   '@dcloudio/types@3.4.28':
     resolution: {integrity: sha512-uVIRp1VLBkrL0LaGLgIS/sT3bl1zzVTKZQbfqJEQcSAvBffRdirbSh5OvOHfA1WV5lmCAGfjhKsUQouNEVUQHg==}
 
@@ -3290,9 +3284,6 @@ packages:
   lru-cache@5.1.1:
     resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
 
-  luch-request@3.1.1:
-    resolution: {integrity: sha512-p7+mlcEtgRcd0OfXC4XZbyiwSr1XgCeqNT7LlVUjnk7InYl/8d5Rk7BUqAYNA2WRafI1wRIUQWRWZRpeUwWR0w==}
-
   magic-string@0.30.11:
     resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
 
@@ -5243,8 +5234,6 @@ snapshots:
 
   '@bcoe/v8-coverage@0.2.3': {}
 
-  '@dcloudio/types@2.6.12': {}
-
   '@dcloudio/types@3.4.28': {}
 
   '@dcloudio/uni-app-harmony@3.0.0-4080420251103001(postcss@8.5.6)(rollup@4.55.1)(vite@5.2.8(@types/node@25.0.3)(sass@1.78.0)(terser@5.44.1))(vue@3.5.26(typescript@4.9.5))':
@@ -8660,10 +8649,6 @@ snapshots:
     dependencies:
       yallist: 3.1.1
 
-  luch-request@3.1.1:
-    dependencies:
-      '@dcloudio/types': 2.6.12
-
   magic-string@0.30.11:
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.5

+ 47 - 0
src/composables/useCodeCountdown.ts

@@ -0,0 +1,47 @@
+import { ref, computed, onBeforeUnmount } from 'vue'
+
+export function useCodeCountdown() {
+  const countdown = ref(0)
+  const running = ref(false)
+
+  let timer: ReturnType<typeof setInterval> | null = null
+
+  /** 按钮禁用态 */
+  const disabled = computed(() => running.value || countdown.value > 0)
+
+  /** 开始倒计时 */
+  const start = (seconds = 60) => {
+    stop()
+
+    running.value = true
+    countdown.value = seconds
+
+    timer = setInterval(() => {
+      countdown.value -= 1
+      if (countdown.value <= 0) {
+        stop()
+      }
+    }, 1000)
+  }
+
+  /** 停止并清理 */
+  const stop = () => {
+    if (timer) {
+      clearInterval(timer)
+      timer = null
+    }
+    countdown.value = 0
+    running.value = false
+  }
+
+  onBeforeUnmount(() => {
+    stop()
+  })
+
+  return {
+    countdown,
+    disabled,
+    start,
+    stop,
+  }
+}

+ 34 - 20
src/pages/reset-password/index.vue

@@ -23,17 +23,19 @@
                 type="number"
                 maxlength="11"
                 placeholder="请输入账号"
-                v-model="form.phone"
+                v-model="form.username"
               />
             </view>
-            <text class="error" v-if="errors.phone">{{ errors.phone }}</text>
+            <text class="error" v-if="errors.username">{{ errors.username }}</text>
           </view>
 
           <view class="form-item">
             <view class="form-label">验证码</view>
             <view class="form-input">
               <input class="form-input-inner" v-model="form.code" placeholder="请输入验证码" />
-              <button class="get-code" @click="sendCode">发送验证码</button>
+              <button class="get-code" :disabled="codeDisabled" @click="sendCode">
+                {{ countdown > 0 ? `${countdown}s 后重试` : '发送验证码' }}
+              </button>
             </view>
             <text class="error" v-if="errors.code">{{ errors.code }}</text>
           </view>
@@ -67,37 +69,49 @@
 import { reactive } from 'vue'
 import { useFormValidator } from '@/composables/useFormValidator'
 import { resetPasswordRules } from '@/validators/resetPassword'
+import { debounce } from '@/plugins/debounce'
+import { useCodeCountdown } from '@/composables/useCodeCountdown'
+import { getPwdCodeForNoAuthApi, updUserPwdApi } from '@/services/modules/auth/index'
 
-/** 表单模型 */
 const form = reactive({
-  phone: '',
+  username: '',
   code: '',
   password: '',
 })
 
-/** 表单校验 Hook */
 const { errors, validate, validateField } = useFormValidator(resetPasswordRules, form)
 
-/** 提交 */
-const submit = async () => {
-  const ok = await validate()
-  console.log('ok', ok)
-  console.log('form', form)
+/** 倒计时 */
+const { countdown, disabled: codeDisabled, start: startCountdown } = useCodeCountdown()
 
-  if (!ok) return
+const toast = (title: string, duration = 800) => uni.showToast({ title, icon: 'none', duration })
 
-  console.log('校验通过', form)
-  // TODO: 提交重置密码接口
+const goLoginLater = (delay = 1000) => {
+  setTimeout(() => {
+    uni.reLaunch({ url: '/pages/login/index' })
+  }, delay)
 }
 
-/** 获取验证码前校验 */
-const sendCode = async () => {
-  const ok = await validateField('phone')
-  if (!ok) return
+const sendCodeImpl = async () => {
+  if (codeDisabled.value) return
+  if (!(await validateField('username'))) return
 
-  console.log('发送验证码', form.phone)
-  // TODO: 调用发送验证码接口
+  const { code, data, msg } = await getPwdCodeForNoAuthApi(form.username)
+  if (code !== 0 || !data) return toast(msg || '发送验证码失败')
+  toast('验证码已发送,请注意查收')
+  startCountdown(60)
 }
+
+const sendCode = debounce(sendCodeImpl)
+const submitImpl = async () => {
+  if (!(await validate())) return
+  const { code, data, msg } = await updUserPwdApi(form)
+  if (code !== 0 || !data) return toast(msg || '修改密码失败')
+  toast('密码重置成功,请使用新密码登录')
+  goLoginLater()
+}
+
+const submit = debounce(submitImpl)
 </script>
 
 <style lang="scss" scoped>

+ 26 - 0
src/plugins/debounce.ts

@@ -0,0 +1,26 @@
+import { debounce as _debounce } from 'lodash-es'
+
+export type Debounced<T extends (...args: any[]) => any> = ((...args: Parameters<T>) => void) & {
+  cancel: () => void
+  flush: () => void
+}
+
+export interface DebounceOptions {
+  wait?: number
+  leading?: boolean
+  trailing?: boolean
+  maxWait?: number
+}
+
+/**
+ * lodash-es debounce 的统一封装
+ * - 默认:300ms、leading true、trailing false(更符合“按钮点击一次”的语义)
+ */
+export function debounce<T extends (...args: any[]) => any>(
+  fn: T,
+  options: DebounceOptions = {}
+): Debounced<T> {
+  const { wait = 300, leading = true, trailing = false, maxWait } = options
+
+  return _debounce(fn, wait, { leading, trailing, maxWait }) as Debounced<T>
+}

+ 10 - 0
src/services/modules/auth/index.ts

@@ -0,0 +1,10 @@
+import http from '../../index'
+import type { UpdUserPwdRequest } from './type'
+
+export const getPwdCodeForNoAuthApi = (username: string) => {
+  return http.get<boolean>(`/admin/mobile/for-pwd-noauth?username=${username}`)
+}
+
+export const updUserPwdApi = (data: UpdUserPwdRequest) => {
+  return http.post<boolean>('/admin/user/upd-for-app', data)
+}

+ 5 - 0
src/services/modules/auth/type.d.ts

@@ -0,0 +1,5 @@
+export interface UpdUserPwdRequest {
+  username: string
+  code: string
+  password: string
+}

+ 186 - 72
src/services/request/index.ts

@@ -1,5 +1,3 @@
-// src/utils/request.ts
-import Request from 'luch-request'
 import {
   BASE_API,
   MODE,
@@ -10,93 +8,209 @@ import {
 } from './config'
 import { useUserStore } from '@/stores/modules/user'
 
-const http = new Request({
-  timeout: TIMEOUT,
-})
-
 /* -------------------------------------------------------------------------- */
-/*                                  全局配置                                   */
+/*                                类型定义                                    */
 /* -------------------------------------------------------------------------- */
 
-// #ifdef MP-WEIXIN
-http.setConfig((config) => {
-  config.baseURL = BASE_API
-  return config
-})
-// #endif
-
-// #ifdef H5
-http.setConfig((config) => {
-  config.baseURL = MODE === 'development' ? '' : BASE_API
-  return config
-})
-// #endif
+/** 后端统一返回结构 */
+export interface ApiResponse<T = any> {
+  code: number
+  msg: string
+  data: T
+}
+
+export interface BaseOptions {
+  header?: UniApp.RequestOptions['header']
+  timeout?: number
+  silent?: boolean
+}
+
+export interface RequestConfig extends BaseOptions {
+  url: string
+  method?: UniApp.RequestOptions['method']
+  data?: any
+}
 
 /* -------------------------------------------------------------------------- */
-/*                               白名单接口                                   */
+/*                               核心 request                                  */
 /* -------------------------------------------------------------------------- */
 
-const RESPONSE_WHITE_LIST = ['/auth/oauth/token', '/auth/mobile/token/sms']
+/**
+ * 默认请求:永远返回 ApiResponse<T>
+ * - 白名单:不校验 code,但仍返回 ApiResponse<T>
+ * - 非白名单:校验 code,不通过 reject(ApiResponse<T>)
+ */
+export function request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
+  const userStore = useUserStore()
+
+  return new Promise((resolve, reject) => {
+    uni.request({
+      url: resolveUrl(config.url),
+      method: config.method || 'GET',
+      data: config.data,
+      timeout: config.timeout ?? TIMEOUT,
+      header: buildHeaders(config.url, userStore, config.header),
+
+      success(res) {
+        const result = res.data as ApiResponse<T>
+
+        // 白名单接口:不校验 code,直接放行(仍返回 ApiResponse<T>)
+        if (isWhiteList(config.url)) {
+          return resolve(result)
+        }
+
+        // 业务失败
+        if (!SUCCESS_CODE_LIST.includes(result.code)) {
+          if (!config.silent) {
+            uni.showToast({
+              title: result.msg || '业务错误',
+              icon: 'none',
+            })
+          }
+          return reject(result)
+        }
+
+        resolve(result)
+      },
+
+      fail(err) {
+        uni.showToast({
+          title: '网络异常',
+          icon: 'none',
+        })
+        reject(err)
+      },
+    })
+  })
+}
+
+/**
+ * 特殊接口:后端不包 ApiResponse,直接返回 T
+ * - 不做 code 校验
+ * - 适用于少量 legacy 接口
+ */
+export function requestRaw<T = any>(config: RequestConfig): Promise<T> {
+  const userStore = useUserStore()
+  return new Promise((resolve, reject) => {
+    uni.request({
+      url: resolveUrl(config.url),
+      method: config.method || 'GET',
+      data: config.data,
+      timeout: config.timeout ?? TIMEOUT,
+      header: buildHeaders(config.url, userStore, config.header),
+      success(res) {
+        resolve(res.data as T)
+      },
+      fail(err) {
+        uni.showToast({
+          title: '网络异常',
+          icon: 'none',
+        })
+        reject(err)
+      },
+    })
+  })
+}
 
 /* -------------------------------------------------------------------------- */
-/*                               请求拦截器                                   */
+/*                       默认 http(get / post 语法糖)                         */
 /* -------------------------------------------------------------------------- */
 
-http.interceptors.request.use(
-  (config) => {
-    config.header = config.header || {}
-    const userStore = useUserStore()
-    const token = userStore.token
-    const url = config.url || ''
-
-    if (url.includes('auth/mobile/token/sms')) {
-      config.header.Authorization = NOT_OAUTH_BASIC_TOKEN
-    } else if (url.includes('auth/oauth/token')) {
-      config.header.Authorization = OAUTH_BASIC_TOKEN
-      config.header['TENANT-ID'] = '1'
-    } else if (token) {
-      config.header.Authorization = `Bearer ${token}`
-    }
-
-    return config
+const http = {
+  /**
+   * 默认 get:返回 ApiResponse<T>
+   */
+  get<T = any>(url: string, params?: any, options?: BaseOptions) {
+    return request<T>({
+      url,
+      method: 'GET',
+      data: params,
+      ...options,
+    })
   },
-  (config) => Promise.reject(config)
-)
 
-/* -------------------------------------------------------------------------- */
-/*                               响应拦截器                                   */
-/* -------------------------------------------------------------------------- */
+  /**
+   * 默认 post:返回 ApiResponse<T>
+   */
+  post<T = any>(url: string, data?: any, options?: BaseOptions) {
+    return request<T>({
+      url,
+      method: 'POST',
+      data,
+      ...options,
+    })
+  },
+
+  /**
+   * 特殊接口 get:返回 T
+   */
+  getRaw<T = any>(url: string, params?: any, options?: BaseOptions) {
+    return requestRaw<T>({
+      url,
+      method: 'GET',
+      data: params,
+      ...options,
+    })
+  },
 
-http.interceptors.response.use(
-  (response) => {
-    const { config, data } = response
-    const url = config?.url || ''
+  /**
+   * 特殊接口 post:返回 T
+   */
+  postRaw<T = any>(url: string, data?: any, options?: BaseOptions) {
+    return requestRaw<T>({
+      url,
+      method: 'POST',
+      data,
+      ...options,
+    })
+  },
+}
 
-    // 1️⃣ 白名单接口:直接放行
-    if (RESPONSE_WHITE_LIST.some((item) => url.includes(item))) {
-      return response
-    }
+export default http
 
-    const { code, msg } = (data || {}) as ApiResponse<any>
+/* -------------------------------------------------------------------------- */
+/*                               工具函数                                     */
+/* -------------------------------------------------------------------------- */
 
-    if (!SUCCESS_CODE_LIST.includes(code)) {
-      uni.showToast({
-        title: msg || '业务错误',
-        icon: 'none',
-      })
-      return Promise.reject(response)
-    }
+function resolveUrl(url: string) {
+  // 小程序环境:必须使用绝对路径
+  // #ifdef MP
+  return BASE_API + url
+  // #endif
+
+  // H5 环境:开发时允许走代理(相对路径)
+  // #ifdef H5
+  if (MODE === 'development') return url
+  return BASE_API + url
+  // #endif
+
+  // 其他平台兜底
+  return BASE_API + url
+}
+
+function isWhiteList(url: string) {
+  return ['/auth/oauth/token', '/auth/mobile/token/sms'].some((item) => url.includes(item))
+}
+
+function buildHeaders(
+  url: string,
+  userStore: ReturnType<typeof useUserStore>,
+  extraHeader?: UniApp.RequestOptions['header']
+) {
+  const headers: Record<string, any> = {
+    ...(extraHeader || {}),
+  }
 
-    // ⚠️ 注意:不解包,仍然返回 { code, msg, data }
-    return response
-  },
-  (error) => {
-    uni.showToast({
-      title: '网络异常',
-      icon: 'none',
-    })
-    return Promise.reject(error)
+  const token = userStore.token
+
+  if (url.includes('auth/mobile/token/sms')) {
+    headers.Authorization = NOT_OAUTH_BASIC_TOKEN
+  } else if (url.includes('auth/oauth/token')) {
+    headers.Authorization = OAUTH_BASIC_TOKEN
+    headers['TENANT-ID'] = '1'
+  } else if (token) {
+    headers.Authorization = `Bearer ${token}`
   }
-)
 
-export default http
+  return headers
+}

+ 1 - 1
src/validators/resetPassword.ts

@@ -56,7 +56,7 @@ function createPasswordRules(): RuleItem[] {
 
 /** 重置密码表单校验规则 */
 export const resetPasswordRules: Rules = {
-  phone: [
+  username: [
     { required: true, message: '账号必填' },
     { pattern: /^\d{11}$/, message: '账号必须为 11 位纯数字' },
   ],