Преглед на файлове

feat(auth): 添加注册页面和登录注册切换功能

refactor(auth): 重构登录页面结构并优化表单验证
feat(components): 新增 SVG 图标组件及配套插件
build: 添加 vite-plugin-svg-icons 依赖
piks преди 1 седмица
родител
ревизия
f7651939ba

+ 1 - 0
auto-imports.d.ts

@@ -7,6 +7,7 @@
 export {}
 declare global {
   const EffectScope: typeof import('vue').EffectScope
+  const ElMessage: typeof import('element-plus/es').ElMessage
   const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
   const computed: typeof import('vue').computed
   const createApp: typeof import('vue').createApp

+ 2 - 1
components.d.ts

@@ -26,6 +26,7 @@ declare module 'vue' {
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
-    ThemeToggle: typeof import('./src/components/ThemeToggle.vue')['default']
+    SvgIcon: typeof import('./src/components/SvgIcon/index.vue')['default']
+    ThemeToggle: typeof import('./src/components/ThemeToggle/index.vue')['default']
   }
 }

+ 2 - 0
package.json

@@ -23,12 +23,14 @@
     "@types/node": "^24.12.0",
     "@vitejs/plugin-vue": "^6.0.5",
     "@vue/tsconfig": "^0.9.0",
+    "fast-glob": "^3.3.3",
     "sass-embedded": "^1.98.0",
     "typescript": "~5.9.3",
     "unplugin-auto-import": "^21.0.0",
     "unplugin-vue-components": "^31.0.0",
     "vite": "^8.0.0",
     "vite-plugin-compression": "^0.5.1",
+    "vite-plugin-svg-icons": "^2.0.1",
     "vue-tsc": "^3.2.5"
   }
 }

Файловите разлики са ограничени, защото са твърде много
+ 705 - 1
pnpm-lock.yaml


+ 4 - 0
src/assets/svg/protect.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 3.08519L8.00287 1.33331L14 3.08519V6.67788C14 10.454 11.5834 13.8064 8.00087 15.0001C4.41737 13.8065 2 10.4533 2 6.67621V3.08519Z" stroke="white" stroke-width="1.2" stroke-linejoin="round"/>
+<path d="M5 7.66667L7.33333 10L11.3333 6" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 27 - 0
src/components/SvgIcon/index.vue

@@ -0,0 +1,27 @@
+<template>
+  <svg :class="svgClass" aria-hidden="true" v-bind="$attrs">
+    <use :xlink:href="iconName" :fill="color" />
+  </svg>
+</template>
+
+<script setup lang="ts">
+const props = defineProps<{
+  iconClass: string   // 图标文件名(不含 .svg)
+  className?: string  // 自定义 CSS 类
+  color?: string  // 图标颜色
+}>()
+
+const iconName = computed(() => `#icon-${props.iconClass}`)
+const svgClass = computed(() =>
+  props.className ? `svg-icon ${props.className}` : 'svg-icon'
+)
+</script>
+
+<style scoped>
+.svg-icon {
+  width: 1em;
+  height: 1em;
+  fill: currentColor;
+  overflow: hidden;
+}
+</style>

+ 0 - 0
src/components/ThemeToggle.vue → src/components/ThemeToggle/index.vue


+ 6 - 0
src/main.ts

@@ -10,6 +10,10 @@ import 'normalize.css/normalize.css'
 import '@/styles/theme.scss'
 import '@/styles/element.scss'
 
+// 注册 SVG 图标组件
+import SvgIcon from '@/components/SvgIcon/index.vue'
+import 'virtual:svg-icons-register'
+
 // ⚠️ 路由守卫必须在 createApp 后、mount 前引入
 import './permission'
 
@@ -20,6 +24,8 @@ const pinia = createPinia()
 for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
   app.component(key, component)
 }
+// 注册 SVG 图标组件
+app.component('SvgIcon', SvgIcon)
 
 app.use(pinia).use(router).use(ElementPlus)
 app.mount('#app')

+ 140 - 18
src/views/auth/components/login.vue

@@ -9,9 +9,9 @@
         手机号登录
       </div>
     </div>
-    <el-form class="login-form">
+    <el-form ref="formRef" :model="accountForm" :rules="rules" class="login-form">
       <template v-if="activeTab === 'account'">
-        <el-form-item>
+        <el-form-item prop="username">
           <el-input v-model="accountForm.username" placeholder="请输入手机号或账号" size="large">
             <template #prefix>
               <div class="input-prefix">
@@ -23,7 +23,7 @@
             </template>
           </el-input>
         </el-form-item>
-        <el-form-item>
+        <el-form-item prop="password">
           <el-input v-model="accountForm.password" type="password" placeholder="请输入密码" size="large" show-password>
             <template #prefix>
               <div class="input-prefix">
@@ -35,42 +35,133 @@
             </template>
           </el-input>
         </el-form-item>
-        <div class="form-end">
-          <el-button size="large" style="width:100%" type="primary" class="is-gradient">发送验证码</el-button>
-          <div>
-            还没有账号?<span>去注册</span>
-          </div>
-        </div>
       </template>
       <template v-else>
         <el-form-item>
-          <el-input v-model="phoneForm.phone" placeholder="请输入手机号" :prefix-icon="Iphone" size="large" />
+          <el-input v-model="phoneForm.phone" placeholder="请输入手机号" size="large">
+            <template #prefix>
+              <div class="input-prefix">
+                <el-icon :size="16">
+                  <Iphone />
+                </el-icon>
+                <span>手机号</span>
+              </div>
+            </template>
+          </el-input>
         </el-form-item>
         <el-form-item>
-          <el-input v-model="phoneForm.code" placeholder="请输入验证码" :prefix-icon="CircleCheck" size="large">
-          </el-input>
+          <div class="login-code">
+            <el-input style="width: 60%;" v-model="phoneForm.code" placeholder="请输入验证码" size="large">
+              <template #prefix>
+                <div class="input-prefix">
+                  <SvgIcon iconClass="protect" />
+                  <span>验证码</span>
+                </div>
+              </template>
+            </el-input>
+            <el-button size="large" style="width: 30%; min-width: 100px;" type="primary" class="is-gradient"
+              :disabled="isCountingDown" @click="handleGetCode">
+              {{ isCountingDown ? `${countdown}s` : '获取验证码' }}
+            </el-button>
+          </div>
         </el-form-item>
       </template>
+      <div class="form-end">
+        <el-button size="large" style="width:100%" type="primary" class="is-gradient" :loading="loading" @click="handleLogin">立即登录</el-button>
+        <div class="form-links">
+          <div class="left-links">
+            <span class="link-text">还没有账号?</span>
+            <span class="link-btn" @click="handleRegister">立即注册</span>
+          </div>
+          <span class="link-btn" @click="handleForgot">忘记密码?</span>
+        </div>
+      </div>
     </el-form>
   </div>
 </template>
+
 <script setup lang="ts">
-import { User, Lock, Iphone, CircleCheck } from '@element-plus/icons-vue'
+import { User, Lock, Iphone } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
+import { useUserStore } from '@/store/modules/user'
 
+const emit = defineEmits<{
+  (e: 'switch', component: 'register' | 'forgot'): void
+}>()
 type TabType = 'account' | 'phone'
 const activeTab = ref<TabType>('account')
+const isCountingDown = ref(false)
+const countdown = ref(60)
+let countdownTimer: ReturnType<typeof setInterval> | null = null
+const formRef = ref<FormInstance>()
+const loading = ref(false)
+const router = useRouter()
+const route = useRoute()
+const userStore = useUserStore()
 
 const accountForm = reactive({
-  username: '',
-  password: '',
-  code: ''
+  username: 'admin',
+  password: '123456'
 })
 
 const phoneForm = reactive({
   phone: '',
   code: ''
 })
+
+
+const rules: FormRules = {
+  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
+  password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
+}
+
+async function handleLogin() {
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+
+  loading.value = true
+  try {
+    await userStore.login(accountForm.username, accountForm.password)
+    const redirect = (route.query.redirect as string) || '/'
+    await router.replace(redirect)
+    ElMessage.success('登录成功')
+  } catch (error: unknown) {
+    ElMessage.error(error instanceof Error ? error.message : '登录失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+
+function handleGetCode() {
+  if (!phoneForm.phone) {
+    ElMessage.warning('请输入手机号')
+    return
+  }
+  if (isCountingDown.value) return
+
+  isCountingDown.value = true
+  countdown.value = 60
+
+  countdownTimer = setInterval(() => {
+    countdown.value--
+    if (countdown.value <= 0) {
+      clearInterval(countdownTimer!)
+      isCountingDown.value = false
+    }
+  }, 1000)
+}
+
+function handleRegister() {
+  emit('switch', 'register')
+}
+
+function handleForgot() {
+  emit('switch', 'forgot')
+}
 </script>
+
 <style lang="scss" scoped>
 .login-page {
   margin-top: 242px;
@@ -128,7 +219,6 @@ const phoneForm = reactive({
     color: #999;
   }
 
-  /* 密码可见性切换按钮设置为白色 */
   :deep(.el-input__password) {
     color: #fff;
   }
@@ -164,4 +254,36 @@ const phoneForm = reactive({
 .form-end {
   margin-top: 30px;
 }
-</style>
+
+.form-links {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 20px;
+  font-size: 14px;
+
+  .left-links {
+    display: flex;
+    align-items: center;
+  }
+
+  .link-text {
+    color: #999;
+  }
+
+  .link-btn {
+    color: #8077FF;
+    cursor: pointer;
+
+    &:hover {
+      color: #9f95ff;
+    }
+  }
+}
+
+.login-code {
+  display: flex;
+  justify-content: space-between;
+  gap: 10px;
+}
+</style>

+ 177 - 0
src/views/auth/components/register.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="register-page">
+    <h1>注册账号</h1>
+    <el-form class="register-form">
+      <el-form-item>
+        <el-input v-model="form.username" placeholder="请输入账号" size="large">
+          <template #prefix>
+            <div class="input-prefix">
+              <el-icon :size="16">
+                <User />
+              </el-icon>
+              <span>账号</span>
+            </div>
+          </template>
+        </el-input>
+      </el-form-item>
+      <el-form-item>
+        <el-input v-model="form.phone" placeholder="请输入手机号" size="large">
+          <template #prefix>
+            <div class="input-prefix">
+              <el-icon :size="16">
+                <Iphone />
+              </el-icon>
+              <span>手机号</span>
+            </div>
+          </template>
+        </el-input>
+      </el-form-item>
+      <el-form-item>
+        <el-input v-model="form.code" placeholder="请输入验证码" size="large">
+          <template #prefix>
+            <div class="input-prefix">
+              <el-icon :size="16">
+                <CircleCheck />
+              </el-icon>
+              <span>验证码</span>
+            </div>
+          </template>
+          <template #append>
+            <el-button type="primary" class="code-btn">获取验证码</el-button>
+          </template>
+        </el-input>
+      </el-form-item>
+      <el-form-item>
+        <el-input v-model="form.password" type="password" placeholder="请输入密码" size="large" show-password>
+          <template #prefix>
+            <div class="input-prefix">
+              <el-icon :size="16">
+                <Lock />
+              </el-icon>
+              <span>密码</span>
+            </div>
+          </template>
+        </el-input>
+      </el-form-item>
+      <div class="form-end">
+        <el-button size="large" style="width:100%" type="primary" class="is-gradient">立即注册</el-button>
+        <div class="form-links">
+          <span class="link-text">已有账号?</span>
+          <span class="link-btn" @click="handleLogin">去登录</span>
+        </div>
+      </div>
+    </el-form>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { User, Lock, Iphone, CircleCheck } from '@element-plus/icons-vue'
+
+const emit = defineEmits<{
+  (e: 'switch', component: 'login'): void
+}>()
+
+const form = reactive({
+  username: '',
+  phone: '',
+  code: '',
+  password: ''
+})
+
+function handleLogin() {
+  emit('switch', 'login')
+}
+</script>
+
+<style lang="scss" scoped>
+.register-page {
+  margin-top: 242px;
+  width: 400px;
+  display: flex;
+  flex-direction: column;
+  color: #fff;
+}
+
+
+.register-form {
+  :deep(.el-input__wrapper) {
+    background: rgba(255, 255, 255, 0.1);
+    box-shadow: none;
+    border: 1px solid transparent;
+    border-radius: 8px;
+
+    &.is-focus {
+      border-color: #7c3aed;
+    }
+  }
+
+  :deep(.el-input__inner) {
+    color: #fff;
+
+    &::placeholder {
+      color: #999;
+    }
+  }
+
+  :deep(.el-input__icon) {
+    color: #999;
+  }
+
+  :deep(.el-input__password) {
+    color: #fff;
+  }
+
+  :deep(.el-input-group__append) {
+    background: transparent;
+    border: none;
+    border-radius: 0 8px 8px 0;
+    padding: 0;
+
+    .code-btn {
+      color: #8077FF;
+      border: none;
+      background: transparent;
+      padding: 0 15px;
+      height: 100%;
+      border-radius: 0 8px 8px 0;
+
+      &:hover {
+        color: #9f95ff;
+      }
+    }
+  }
+
+  .input-prefix {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    color: #fff;
+  }
+}
+
+.form-end {
+  margin-top: 30px;
+}
+
+.form-links {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 20px;
+  font-size: 14px;
+
+  .link-text {
+    color: #999;
+  }
+
+  .link-btn {
+    color: #8077FF;
+    cursor: pointer;
+    margin-left: 4px;
+
+    &:hover {
+      color: #9f95ff;
+    }
+  }
+}
+</style>

+ 11 - 29
src/views/auth/index.vue

@@ -1,45 +1,27 @@
 <template>
   <div class="auth-page">
     <div class="auth-box">
-      <Login />
+      <component :is="componentMap[currentComponent]" @switch="handleSwitch" />
     </div>
   </div>
 </template>
+
 <script setup lang="ts">
 import Login from './components/login.vue'
-import { ElMessage } from 'element-plus'
-import type { FormInstance, FormRules } from 'element-plus'
-import { useUserStore } from '@/store/modules/user'
-
-const router = useRouter()
-const route = useRoute()
-const userStore = useUserStore()
+import Register from './components/register.vue'
 
-const formRef = ref<FormInstance>()
-const loading = ref(false)
+type AuthComponent = 'login' | 'register' | 'forgot'
 
-const form = reactive({ username: 'admin', password: '123456' })
+const currentComponent = ref<AuthComponent>('login')
 
-const rules: FormRules = {
-  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
-  password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
+const componentMap: Record<AuthComponent, typeof Login | typeof Register> = {
+  login: Login,
+  register: Register,
+  forgot: Login // 暂时用 Login 代替,后续可添加 ForgotPassword 组件
 }
 
-async function handleLogin() {
-  const valid = await formRef.value?.validate().catch(() => false)
-  if (!valid) return
-
-  loading.value = true
-  try {
-    await userStore.login(form.username, form.password)
-    const redirect = (route.query.redirect as string) || '/'
-    await router.replace(redirect)
-    ElMessage.success('登录成功')
-  } catch (error: unknown) {
-    ElMessage.error(error instanceof Error ? error.message : '登录失败')
-  } finally {
-    loading.value = false
-  }
+function handleSwitch(component: AuthComponent) {
+  currentComponent.value = component
 }
 </script>
 

+ 2 - 1
tsconfig.app.json

@@ -23,6 +23,7 @@
     "src/**/*.ts",
     "src/**/*.tsx",
     "src/**/*.vue",
-    "auto-imports.d.ts"
+    "auto-imports.d.ts",
+    "src/main.ts"
   ]
 }

+ 2 - 0
vite/plugins/index.ts

@@ -2,6 +2,7 @@ import vue from '@vitejs/plugin-vue'
 import type { PluginOption } from 'vite'
 import createAutoImport from './auto-import'
 import createCompression from './compression'
+import createSvgIcon from './svg-icon'
 
 interface ViteEnv {
   VITE_BUILD_COMPRESS?: string
@@ -10,6 +11,7 @@ interface ViteEnv {
 export default function createVitePlugins(viteEnv: ViteEnv, isBuild = false) {
   const vitePlugins: PluginOption[] = [vue()]
   vitePlugins.push(...createAutoImport())
+  vitePlugins.push(createSvgIcon(isBuild))
   isBuild && vitePlugins.push(...createCompression(viteEnv))
   return vitePlugins
 }

+ 10 - 0
vite/plugins/svg-icon.ts

@@ -0,0 +1,10 @@
+import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
+import path from 'path'
+
+export default function createSvgIcon(isBuild: boolean) {
+  return createSvgIconsPlugin({
+    iconDirs: [path.resolve(process.cwd(), 'src/assets/svg')],
+    symbolId: 'icon-[name]',
+    svgoOptions: isBuild
+  })
+}

Някои файлове не бяха показани, защото твърде много файлове са промени