Explorar el Código

修改密码功能完成 登录页ui完成

yuanmingze hace 8 meses
padre
commit
96c21e662a

+ 2 - 0
components.d.ts

@@ -7,7 +7,9 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
+    AuthInputItem: typeof import('./src/components/auth/AuthInputItem.vue')['default']
     WdConfigProvider: typeof import('wot-design-uni/components/wd-config-provider/wd-config-provider.vue')['default']
+    WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
     WdNavbar: typeof import('wot-design-uni/components/wd-navbar/wd-navbar.vue')['default']
   }
 }

+ 191 - 0
src/components/auth/AuthInputItem.vue

@@ -0,0 +1,191 @@
+<template>
+  <view class="form-item">
+    <!-- label -->
+    <view v-if="label" class="form-label">
+      {{ label }}
+    </view>
+
+    <!-- input 行 -->
+    <view class="form-input">
+      <input
+        class="form-input-inner"
+        v-model="innerValue"
+        :type="inputType"
+        :password="passwordToggle && isPassword"
+        :maxlength="maxlength"
+        :placeholder="placeholder"
+        @focus="onFocus"
+        @blur="onBlur"
+      />
+
+      <!-- 清除 -->
+      <view
+        v-if="showClear"
+        class="clear"
+        :class="{ visible: isFocused }"
+        @touchstart.stop.prevent="clear"
+      >
+        <wd-icon name="error-fill" size="28rpx" :color="CLEAR_ICON_COLOR" />
+      </view>
+
+      <!-- 密码显隐 -->
+      <view v-if="passwordToggle" class="input-type-toggle" @click="togglePassword">
+        <wd-icon :name="isPassword ? 'view' : 'eye-close'" size="28rpx" :color="CLEAR_ICON_COLOR" />
+      </view>
+
+      <!-- 右侧插槽(验证码按钮等) -->
+      <view class="suffix">
+        <slot name="suffix" />
+      </view>
+    </view>
+
+    <!-- 错误提示 -->
+    <text v-if="error" class="error">
+      {{ error }}
+    </text>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+
+interface Props {
+  label?: string
+  modelValue: string
+  placeholder?: string
+  type?: 'text' | 'number' | 'password'
+  maxlength?: number
+  error?: string
+  passwordToggle?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  type: 'text',
+  placeholder: '',
+  maxlength: undefined,
+  error: '',
+  passwordToggle: false,
+})
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: string): void
+  (e: 'clear'): void
+  (e: 'validate'): void
+}>()
+
+const CLEAR_ICON_COLOR = '#9CA3AF'
+
+const isFocused = ref(false)
+const isPassword = ref(true)
+
+/** v-model 桥接 */
+const innerValue = computed<string>({
+  get: () => props.modelValue,
+  set: (val) => {
+    emit('update:modelValue', val)
+  },
+})
+
+/** 记录上一次是否有值 */
+const hadValue = ref(false)
+
+/** 统一的“清除检测” */
+watch(
+  () => innerValue.value,
+  (val) => {
+    const hasValue = !!val
+    if (hadValue.value && !hasValue) {
+      emit('clear')
+    }
+    hadValue.value = hasValue
+  },
+  { immediate: true }
+)
+
+const inputType = computed(() => {
+  if (props.passwordToggle) {
+    return isPassword.value ? 'password' : 'text'
+  }
+  return props.type
+})
+
+const showClear = computed(() => !!innerValue.value)
+
+const onFocus = () => {
+  isFocused.value = true
+}
+
+const onBlur = () => {
+  isFocused.value = false
+  if (innerValue.value) {
+    emit('validate')
+  }
+}
+
+const clear = () => {
+  innerValue.value = ''
+}
+
+const togglePassword = () => {
+  isPassword.value = !isPassword.value
+}
+</script>
+
+<style scoped lang="scss">
+.form-item {
+  display: flex;
+  flex-direction: column;
+  padding: 20rpx 0;
+  box-sizing: border-box;
+  border-bottom: 1rpx solid #e4e7ed;
+
+  .form-label {
+    margin-bottom: 8rpx;
+    color: var(--font-color-333);
+    font-size: 16px;
+    line-height: 40rpx;
+  }
+
+  .form-input {
+    display: flex;
+    align-items: center;
+    min-width: 0; /* ⭐ 必须 */
+
+    .form-input-inner {
+      flex: 1;
+      min-width: 0; /* ⭐ 小程序必需 */
+      height: 70rpx;
+      line-height: 70rpx;
+      font-size: 28rpx;
+    }
+
+    .clear {
+      margin-left: 20rpx;
+      opacity: 0;
+      pointer-events: none;
+    }
+
+    .clear.visible {
+      opacity: 1;
+      pointer-events: auto;
+    }
+
+    .input-type-toggle {
+      flex-shrink: 0;
+      margin-left: 20rpx;
+    }
+
+    .suffix {
+      flex-shrink: 0;
+      margin-left: 20rpx;
+    }
+  }
+
+  .error {
+    margin-top: 8rpx;
+    color: var(--form-error-font-color);
+    font-size: 24rpx;
+    line-height: 28rpx;
+  }
+}
+</style>

+ 11 - 5
src/composables/useFormValidator.ts

@@ -7,7 +7,6 @@ export function useFormValidator<T extends Record<string, any>>(rules: Rules, mo
 
   /** 全量校验 */
   const validate = async () => {
-    // ⭐ 核心:每次全量校验前,清空全部错误
     errors.value = {}
 
     try {
@@ -19,9 +18,8 @@ export function useFormValidator<T extends Record<string, any>>(rules: Rules, mo
     }
   }
 
-  /** 单字段校验(获取验证码 / blur 等) */
+  /** 单字段校验 */
   const validateField = async (field: keyof T) => {
-    // ⭐ 核心:每次字段校验前,只清空该字段错误
     delete errors.value[field]
 
     try {
@@ -38,11 +36,19 @@ export function useFormValidator<T extends Record<string, any>>(rules: Rules, mo
     }
   }
 
+  /** 清除指定字段的错误 */
+  const clearFieldError = (field: keyof T) => {
+    if (errors.value[field]) {
+      delete errors.value[field]
+    }
+  }
+
+  /** 清除所有错误 */
   const clearErrors = () => {
     errors.value = {}
   }
 
-  /** 只负责“写入错误”,不负责清理 */
+  /** 只负责“写入错误” */
   const applyErrors = (e: unknown, onlyField?: keyof T) => {
     const err = e as { errors: ValidateError[] }
     const map = { ...errors.value }
@@ -50,7 +56,6 @@ export function useFormValidator<T extends Record<string, any>>(rules: Rules, mo
     err.errors.forEach((item) => {
       const f = item.field as keyof T
       if (!onlyField || f === onlyField) {
-        // 同一字段只保留第一条错误
         if (!map[f]) {
           map[f] = item.message
         }
@@ -64,6 +69,7 @@ export function useFormValidator<T extends Record<string, any>>(rules: Rules, mo
     errors,
     validate,
     validateField,
+    clearFieldError,
     clearErrors,
   }
 }

+ 97 - 42
src/pages/login/index.vue

@@ -1,47 +1,55 @@
 <template>
   <view class="auth-content">
     <wd-navbar fixed placeholder :title="title" left-arrow safeAreaInsetTop customClass="navbar" />
-    <!-- 背景层 -->
-    <view class="auth-bg"></view>
-    <!-- 内容层 -->
+    <view class="auth-bg" />
     <view class="auth-body">
       <view class="auth-title"> 欢迎登录 </view>
-
-      <!-- 表单(内部类名不动) -->
       <view class="auth-form">
-        <form>
-          <view class="form-item">
-            <view class="form-label">账号</view>
-            <view class="form-input">
-              <input
-                class="form-input-inner"
-                type="number"
-                focus
-                maxlength="11"
-                placeholder="请输入账号"
-              />
-            </view>
-          </view>
-
-          <view class="form-item">
-            <view class="form-label">密码</view>
-            <view class="form-input">
-              <input class="form-input-inner" password placeholder="请输入密码" />
-              <view class="forget-password" @click="forgetPassword">忘记密码?</view>
-            </view>
-          </view>
-
-          <view class="form-item">
-            <view class="form-label">验证码</view>
-            <view class="form-input">
-              <input class="form-input-inner" placeholder="请输入验证码" />
-              <button class="get-code">发送验证码</button>
-            </view>
-          </view>
-        </form>
+        <!-- 账号 -->
+        <AuthInputItem
+          label="账号"
+          v-model="form.username"
+          type="number"
+          :maxlength="11"
+          placeholder="请输入账号"
+          :error="errors.username"
+          @clear="clearFieldError('username')"
+          @validate="validateField('username')"
+        />
+        <AuthInputItem
+          label="密码"
+          v-model="form.password"
+          type="number"
+          :maxlength="11"
+          placeholder="请输入密码"
+          passwordToggle
+          :error="errors.password"
+          @validate="validateField('password')"
+          @clear="clearFieldError('password')"
+        >
+          <template #suffix>
+            <view class="forget-password" @click="forgetPassword">忘记密码?</view>
+          </template>
+        </AuthInputItem>
+        <AuthInputItem
+          label="验证码"
+          v-model="form.code"
+          type="number"
+          :maxlength="11"
+          placeholder="请输入验证码"
+          :error="errors.code"
+          @clear="clearFieldError('code')"
+          @validate="validateField('code')"
+        >
+          <template #suffix>
+            <button class="get-code" :disabled="codeDisabled" @click.stop.prevent="sendCode">
+              {{ countdown > 0 ? `${countdown}s 后重试` : '发送验证码' }}
+            </button>
+          </template>
+        </AuthInputItem>
 
         <view class="submit">
-          <button class="submit-btn">确定</button>
+          <button class="submit-btn" @click.prevent="submit">登录</button>
         </view>
       </view>
     </view>
@@ -49,20 +57,67 @@
 </template>
 
 <script setup lang="ts">
+import AuthInputItem from '@/components/auth/AuthInputItem.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'
 import projectConfig from '@/config/presets/index'
-const { title } = projectConfig
 
-const form = reactive({
-  phone: '',
-  password: '',
-  code: '',
-})
+const { title } = projectConfig
 
 const forgetPassword = () => {
   uni.navigateTo({
     url: '/pages/reset-password/index',
   })
 }
+
+const form = reactive({
+  username: '',
+  code: '',
+  password: '',
+})
+
+const { errors, validate, validateField, clearFieldError } = useFormValidator(
+  resetPasswordRules,
+  form
+)
+
+const { countdown, disabled: codeDisabled, start: startCountdown } = useCodeCountdown()
+
+const toast = (title: string, duration = 800) => uni.showToast({ title, icon: 'none', duration })
+
+const sendCodeImpl = async () => {
+  if (codeDisabled.value) return
+  if (!(await validateField('username'))) return
+
+  const { code, data, msg } = await getPwdCodeForNoAuthApi(form.username)
+  if (code !== 0 || !data) {
+    toast(msg || '发送验证码失败')
+    return
+  }
+
+  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) {
+    toast(msg || '修改密码失败')
+    return
+  }
+
+  toast('密码重置成功,请使用新密码登录')
+  setTimeout(() => {
+    uni.reLaunch({ url: '/pages/login/index' })
+  }, 1000)
+}
+const submit = debounce(submitImpl)
 </script>
 
 <style lang="scss" scoped>

+ 68 - 61
src/pages/reset-password/index.vue

@@ -7,58 +7,54 @@
       left-arrow
       safeAreaInsetTop
       customClass="navbar"
+      @click-left="handleClickLeft"
     />
-    <!-- 背景层 -->
-    <view class="auth-bg"></view>
-    <!-- 内容层 -->
+    <view class="auth-bg" />
     <view class="auth-body">
-      <!-- 表单-->
       <view class="auth-form">
-        <form>
-          <view class="form-item">
-            <view class="form-label">账号</view>
-            <view class="form-input">
-              <input
-                class="form-input-inner"
-                type="number"
-                maxlength="11"
-                placeholder="请输入账号"
-                v-model="form.username"
-              />
-            </view>
-            <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" :disabled="codeDisabled" @click="sendCode">
-                {{ countdown > 0 ? `${countdown}s 后重试` : '发送验证码' }}
-              </button>
-            </view>
-            <text class="error" v-if="errors.code">{{ errors.code }}</text>
-          </view>
-
-          <view class="form-item">
-            <view class="form-label">密码</view>
-            <view class="form-input">
-              <input
-                class="form-input-inner"
-                v-model="form.password"
-                password
-                placeholder="请输入密码"
-              />
-            </view>
-            <text class="error" v-if="errors.password">{{ errors.password }}</text>
-          </view>
-          <view class="tips">
-            密码为8~16位包含大小写字母、数字、!#$%^&*@特殊符号,且不能三位连续数字!
-          </view>
-        </form>
-
+        <!-- 账号 -->
+        <AuthInputItem
+          label="账号"
+          v-model="form.username"
+          type="number"
+          :maxlength="11"
+          placeholder="请输入账号"
+          :error="errors.username"
+          @clear="clearFieldError('username')"
+          @validate="validateField('username')"
+        />
+        <AuthInputItem
+          label="验证码"
+          v-model="form.code"
+          type="number"
+          :maxlength="11"
+          placeholder="请输入验证码"
+          :error="errors.code"
+          @clear="clearFieldError('code')"
+          @validate="validateField('code')"
+        >
+          <template #suffix>
+            <button class="get-code" :disabled="codeDisabled" @click.stop.prevent="sendCode">
+              {{ countdown > 0 ? `${countdown}s 后重试` : '发送验证码' }}
+            </button>
+          </template>
+        </AuthInputItem>
+        <AuthInputItem
+          label="密码"
+          v-model="form.password"
+          type="number"
+          :maxlength="11"
+          placeholder="请输入密码"
+          passwordToggle
+          :error="errors.password"
+          @validate="validateField('password')"
+          @clear="clearFieldError('password')"
+        />
+        <view class="tips">
+          密码为8~16位包含大小写字母、数字、!#$%^&*@特殊符号,且不能三位连续数字!
+        </view>
         <view class="submit">
-          <button class="submit-btn" @click="submit">确定</button>
+          <button class="submit-btn" @click.prevent="submit">确定</button>
         </view>
       </view>
     </view>
@@ -67,6 +63,7 @@
 
 <script setup lang="ts">
 import { reactive } from 'vue'
+import AuthInputItem from '@/components/auth/AuthInputItem.vue'
 import { useFormValidator } from '@/composables/useFormValidator'
 import { resetPasswordRules } from '@/validators/resetPassword'
 import { debounce } from '@/plugins/debounce'
@@ -79,38 +76,48 @@ const form = reactive({
   password: '',
 })
 
-const { errors, validate, validateField } = useFormValidator(resetPasswordRules, form)
+const { errors, validate, validateField, clearFieldError } = useFormValidator(
+  resetPasswordRules,
+  form
+)
+
+const handleClickLeft = () => {
+  uni.navigateBack()
+}
 
-/** 倒计时 */
 const { countdown, disabled: codeDisabled, start: startCountdown } = useCodeCountdown()
 
 const toast = (title: string, duration = 800) => uni.showToast({ title, icon: 'none', duration })
 
-const goLoginLater = (delay = 1000) => {
-  setTimeout(() => {
-    uni.reLaunch({ url: '/pages/login/index' })
-  }, delay)
-}
-
 const sendCodeImpl = async () => {
   if (codeDisabled.value) return
   if (!(await validateField('username'))) return
 
   const { code, data, msg } = await getPwdCodeForNoAuthApi(form.username)
-  if (code !== 0 || !data) return toast(msg || '发送验证码失败')
+  if (code !== 0 || !data) {
+    toast(msg || '发送验证码失败')
+    return
+  }
+
   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 || '修改密码失败')
+  if (code !== 0 || !data) {
+    toast(msg || '修改密码失败')
+    return
+  }
+
   toast('密码重置成功,请使用新密码登录')
-  goLoginLater()
+  setTimeout(() => {
+    uni.reLaunch({ url: '/pages/login/index' })
+  }, 1000)
 }
-
 const submit = debounce(submitImpl)
 </script>
 

+ 21 - 55
src/styles/modules/auth-page.scss

@@ -1,4 +1,4 @@
-//* 适用于 登录页/修改密码 等相关页面的样式 */
+/* 适用于 登录页 / 修改密码 / 注册页 的页面级样式 */
 
 .auth-content {
   position: relative;
@@ -6,14 +6,14 @@
   padding: 0 50rpx;
   box-sizing: border-box;
 
-  /* 外部组件:navbar */
+  /* navbar 背景 */
   :deep(.navbar) {
     background-image: var(--login-bg);
     background-size: 750rpx 800rpx;
     background-repeat: no-repeat;
   }
 
-  /* 背景层(不要负 z-index) */
+  /* 顶部背景层 */
   .auth-bg {
     position: absolute;
     top: 0;
@@ -32,67 +32,33 @@
     padding-top: 80rpx;
 
     .auth-title {
-      margin: 80rpx 0 80rpx;
+      margin: 80rpx 0;
       color: var(--font-color-333);
       font-weight: 600;
-      font-family: 'PingFang SC';
       font-size: 64rpx;
     }
 
-    /* 仅改容器名:login-form -> auth-form,其余不动 */
     .auth-form {
       margin: 30px auto;
 
-      .form-item {
-        display: flex;
-        padding: 20rpx 0;
+      /* 验证码按钮(slot 注入) */
+      :deep(.get-code) {
+        margin-left: 20rpx;
+        width: 200rpx;
+        height: 64rpx;
+        line-height: 64rpx;
+        border: none;
         font-size: 28rpx;
-        color: #303133;
-        box-sizing: border-box;
-        line-height: 70rpx;
-        flex-direction: column;
-        border-bottom: 1rpx solid #e4e7ed;
-
-        .form-label {
-          color: var(--font-color-333);
-          font-size: 16px;
-          justify-content: flex-start;
-        }
-
-        .form-input {
-          display: flex;
-          align-items: center;
-
-          .form-input-inner {
-            flex: 1;
-            height: 70rpx;
-          }
-
-          .forget-password {
-            margin-left: 20rpx;
-            font-size: 26rpx;
-            color: var(--font-color-999);
-          }
-
-          .get-code {
-            margin-left: 20rpx;
-            width: 200rpx;
-            height: 64rpx;
-            line-height: 64rpx;
-            border: none;
-            font-size: 28rpx;
-            color: rgb(var(--main-color-rgb));
-            background: rgba(var(--main-color-rgb), 0.1);
-            border-radius: 16rpx;
-          }
-        }
+        color: rgb(var(--main-color-rgb));
+        background: rgba(var(--main-color-rgb), 0.1);
+        border-radius: 16rpx;
       }
-      .error {
-        color: var(--form-error-font-color);
-        font-size: 24rpx;
-        margin-top: 8rpx;
-        line-height: 28rpx;
+      .forget-password {
+        margin-left: 10rpx;
+        font-size: 26rpx;
+        color: var(--font-color-999);
       }
+
       .tips {
         margin-top: 30rpx;
         font-size: 24rpx;
@@ -108,11 +74,11 @@
 
         .submit-btn {
           flex: 1;
-          background-color: var(--main-color);
-          color: var(--font-color-fff);
           height: 96rpx;
           border-radius: 60rpx;
           font-size: 40rpx;
+          background-color: var(--main-color);
+          color: var(--font-color-fff);
         }
       }
     }