feat: configurable login page + register/forgot-password pages
- 登录页配置化:src/config/login.ts(品牌/布局/字段/开关),split/card 两种布局 - JsonSchemaProperty 新增 prefixIcon - 注册 /register、忘记密码 /forgot-password 独立页面(blank 布局) - 登录/注册/忘记密码统一 blank 布局(新增 blank.vue) - 注册/重置成功后带 username 回填登录页 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b9c3a64c34
commit
c95aa594cb
1
auto-imports.d.ts
vendored
1
auto-imports.d.ts
vendored
@ -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 asyncComputed: typeof import('@vueuse/core').asyncComputed
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
|
||||
8
components.d.ts
vendored
8
components.d.ts
vendored
@ -16,6 +16,7 @@ declare module 'vue' {
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
@ -28,6 +29,7 @@ declare module 'vue' {
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
@ -41,8 +43,6 @@ declare module 'vue' {
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
Icon: typeof import('./src/components/icon/icon.vue')['default']
|
||||
@ -73,6 +73,7 @@ declare global {
|
||||
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
const ElButton: typeof import('element-plus/es')['ElButton']
|
||||
const ElCard: typeof import('element-plus/es')['ElCard']
|
||||
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
const ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
const ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
@ -85,6 +86,7 @@ declare global {
|
||||
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
const ElInput: typeof import('element-plus/es')['ElInput']
|
||||
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
const ElLink: typeof import('element-plus/es')['ElLink']
|
||||
const ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
const ElOption: typeof import('element-plus/es')['ElOption']
|
||||
@ -98,8 +100,6 @@ declare global {
|
||||
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
const ElTable: typeof import('element-plus/es')['ElTable']
|
||||
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
const ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
const ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
const ElTag: typeof import('element-plus/es')['ElTag']
|
||||
const ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
const Icon: typeof import('./src/components/icon/icon.vue')['default']
|
||||
|
||||
61
src/config/login.ts
Normal file
61
src/config/login.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { JsonSchema } from '@/types/crud'
|
||||
import logo from '@/assets/images/logo.png'
|
||||
|
||||
/** 登录页布局:split=左右分栏(左品牌右表单)/ card=居中卡片。 */
|
||||
export type LoginLayout = 'split' | 'card'
|
||||
|
||||
/** 登录页配置。默认值在此集中配置,fork 项目时改这里即可。 */
|
||||
export interface LoginConfig {
|
||||
/** 系统名称。 */
|
||||
title: string
|
||||
/** Logo 图片地址。 */
|
||||
logo?: string
|
||||
/** 副标题/标语。 */
|
||||
subtitle?: string
|
||||
/** 版权信息。 */
|
||||
copyright: string
|
||||
/** 布局形态。 */
|
||||
layout: LoginLayout
|
||||
/** 背景(图 URL 或 CSS 渐变/颜色)。 */
|
||||
background?: string
|
||||
/** 登录字段(JSON Schema,字段名须与后端 DTO 一致)。 */
|
||||
fields: JsonSchema
|
||||
/** 是否显示「记住我」。 */
|
||||
showRemember: boolean
|
||||
/** 是否显示「忘记密码」。 */
|
||||
showForgetPassword: boolean
|
||||
/** 是否显示「注册」。 */
|
||||
showRegister: boolean
|
||||
/** 是否显示验证码(当前为 UI 占位,后端 DTO 暂无 captcha)。 */
|
||||
showCaptcha: boolean
|
||||
/** 登录成功跳转路径。 */
|
||||
homePath: string
|
||||
}
|
||||
|
||||
export const defaultLoginConfig: LoginConfig = {
|
||||
title: '投云系统管理平台',
|
||||
logo,
|
||||
subtitle: '通用系统管理脚手架',
|
||||
copyright: '云南投云科技有限公司',
|
||||
layout: 'split',
|
||||
fields: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tenantCode: { type: 'string', title: '租户编码', placeholder: '请输入租户编码', prefixIcon: 'mdi:domain' },
|
||||
username: { type: 'string', title: '用户名', placeholder: '请输入用户名', prefixIcon: 'mdi:account' },
|
||||
password: {
|
||||
type: 'string',
|
||||
title: '密码',
|
||||
format: 'password',
|
||||
placeholder: '请输入密码',
|
||||
prefixIcon: 'mdi:lock',
|
||||
},
|
||||
},
|
||||
required: ['tenantCode', 'username', 'password'],
|
||||
},
|
||||
showRemember: true,
|
||||
showForgetPassword: true,
|
||||
showRegister: true,
|
||||
showCaptcha: true,
|
||||
homePath: '/',
|
||||
}
|
||||
12
src/layouts/blank.vue
Normal file
12
src/layouts/blank.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<!--
|
||||
@name: blank.vue
|
||||
@description: 空布局
|
||||
@author: 程序员优雅哥 youyacoder
|
||||
@time: 2026/10/11 11:27
|
||||
-->
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<style scoped lang="scss"></style>
|
||||
165
src/pages/forgot-password.vue
Normal file
165
src/pages/forgot-password.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: 'blank'
|
||||
requiresAuth: false
|
||||
</route>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<div class="auth__card">
|
||||
<div class="auth__head">
|
||||
<img v-if="config.logo" :src="config.logo" class="auth__logo" alt="logo" />
|
||||
<h1 class="auth__title">忘记密码</h1>
|
||||
<p class="auth__subtitle">输入账号以重置密码</p>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" class="auth__form" :model="model" :rules="rules" size="large" @keyup.enter="onSubmit">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="model.username" placeholder="用户名">
|
||||
<template #prefix><icon type="iconify" icon="mdi:account" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="newPassword">
|
||||
<el-input v-model="model.newPassword" type="password" show-password placeholder="新密码">
|
||||
<template #prefix><icon type="iconify" icon="mdi:lock" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input v-model="model.confirmPassword" type="password" show-password placeholder="确认新密码">
|
||||
<template #prefix><icon type="iconify" icon="mdi:lock-check" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" size="large" class="auth__submit" :loading="loading" @click="onSubmit">
|
||||
重 置 密 码
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="auth__footer">
|
||||
想起来了?
|
||||
<el-link type="primary" :underline="false" @click="router.push('/login')">返回登录</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
import { defaultLoginConfig } from '@/config/login'
|
||||
import { applyTheme } from '@/utils/theme'
|
||||
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
const config = defaultLoginConfig
|
||||
|
||||
// 忘记密码页不在布局组件内,手动应用主题
|
||||
applyTheme(app.themeColor, app.isDark)
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const model = reactive({ username: '', newPassword: '', confirmPassword: '' })
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value !== model.newPassword) callback(new Error('两次输入的密码不一致'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (loading.value) return
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// mock:后端暂无重置密码接口,模拟耗时
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
loading.value = false
|
||||
ElMessage.success('密码重置成功,请使用新密码登录')
|
||||
router.push({ path: '/login', query: { username: model.username } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth {
|
||||
@apply flex min-h-screen items-center justify-center;
|
||||
background: var(--wm-bg-color-page);
|
||||
}
|
||||
|
||||
.auth__card {
|
||||
@apply rounded-lg border border-solid p-2xl shadow-md;
|
||||
width: 440px;
|
||||
max-width: 92vw;
|
||||
background: var(--wm-bg-color-base);
|
||||
border-color: var(--wm-border-color-light);
|
||||
}
|
||||
|
||||
.auth__head {
|
||||
@apply mb-lg text-center;
|
||||
}
|
||||
|
||||
.auth__logo {
|
||||
@apply mx-auto h-16 w-16 object-contain;
|
||||
}
|
||||
|
||||
.auth__title {
|
||||
@apply mt-md text-2xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.auth__subtitle {
|
||||
@apply mt-xs text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.auth__form {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.auth__form :deep(.el-form-item) {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.auth__form :deep(.el-input__wrapper) {
|
||||
@apply rounded-base;
|
||||
background-color: var(--wm-bg-color-page);
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth__form :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px var(--wm-border-color-light) inset;
|
||||
}
|
||||
|
||||
.auth__form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--wm-color-primary) inset;
|
||||
}
|
||||
|
||||
.auth__submit {
|
||||
@apply mt-lg h-11 w-full border-none text-base rounded-full;
|
||||
background: linear-gradient(90deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
|
||||
letter-spacing: 2px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.auth__footer {
|
||||
@apply mt-lg text-center text-sm text-text-secondary;
|
||||
}
|
||||
</style>
|
||||
@ -1,61 +0,0 @@
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: false
|
||||
requiresAuth: false
|
||||
</route>
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<form @submit.prevent="onSubmit">
|
||||
<h1>登录</h1>
|
||||
<input v-model="tenantCode" placeholder="租户编码" />
|
||||
<input v-model="username" placeholder="用户名" />
|
||||
<input v-model="password" type="password" placeholder="密码" />
|
||||
<button type="submit">登录</button>
|
||||
<p v-if="error">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/modules/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const tenantCode = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.login(tenantCode.value, username.value, password.value);
|
||||
router.push('/');
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 280px;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
374
src/pages/login/index.vue
Normal file
374
src/pages/login/index.vue
Normal file
@ -0,0 +1,374 @@
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: 'blank'
|
||||
requiresAuth: false
|
||||
</route>
|
||||
|
||||
<template>
|
||||
<div class="login" :class="`login--${config.layout}`" :style="rootStyle">
|
||||
<div v-if="config.layout === 'split'" class="login__brand">
|
||||
<div class="login__brand-decor" aria-hidden="true">
|
||||
<span class="login__brand-decor-1"></span>
|
||||
<span class="login__brand-decor-2"></span>
|
||||
</div>
|
||||
<div class="login__brand-inner">
|
||||
<div v-if="config.logo" class="login__brand-logo-box">
|
||||
<img :src="config.logo" class="login__brand-logo" alt="logo" />
|
||||
</div>
|
||||
<h1 class="login__brand-title">{{ config.title }}</h1>
|
||||
<p v-if="config.subtitle" class="login__brand-subtitle">{{ config.subtitle }}</p>
|
||||
</div>
|
||||
<p class="login__brand-copyright">{{ config.copyright }}</p>
|
||||
</div>
|
||||
|
||||
<div class="login__card">
|
||||
<div class="login__panel">
|
||||
<div v-if="config.layout === 'card'" class="login__panel-head">
|
||||
<img v-if="config.logo" :src="config.logo" class="login__panel-logo" alt="logo" />
|
||||
<div class="login__panel-title">{{ config.title }}</div>
|
||||
<div v-if="config.subtitle" class="login__panel-subtitle">{{ config.subtitle }}</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
class="login__form"
|
||||
:model="model"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
@keyup.enter="onSubmit"
|
||||
>
|
||||
<div v-if="config.layout === 'split'" class="login__form-head">
|
||||
<h2 class="login__form-title">欢迎登录</h2>
|
||||
<p class="login__form-subtitle">请输入账号信息以继续</p>
|
||||
</div>
|
||||
|
||||
<el-form-item v-for="(prop, key) in config.fields.properties" :key="key" :prop="key">
|
||||
<el-input
|
||||
v-model="model[key]"
|
||||
:type="prop.format === 'password' ? 'password' : 'text'"
|
||||
:show-password="prop.format === 'password'"
|
||||
:placeholder="prop.placeholder ?? String(prop.title ?? key)"
|
||||
>
|
||||
<template v-if="prop.prefixIcon" #prefix>
|
||||
<icon type="iconify" :icon="prop.prefixIcon" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="config.showCaptcha">
|
||||
<div class="login__captcha">
|
||||
<el-input v-model="captcha" placeholder="请输入验证码">
|
||||
<template #prefix>
|
||||
<icon type="iconify" icon="mdi:shield-check-outline" />
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="login__captcha-code" @click="refreshCaptcha">{{ captchaCode }}</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login__row">
|
||||
<el-checkbox v-if="config.showRemember" v-model="remember">记住我</el-checkbox>
|
||||
<el-link
|
||||
v-if="config.showForgetPassword"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="onForget"
|
||||
>
|
||||
忘记密码?
|
||||
</el-link>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login__submit"
|
||||
:loading="loading"
|
||||
@click="onSubmit"
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
|
||||
<div v-if="config.showRegister" class="login__register">
|
||||
还没有账号?
|
||||
<el-link type="primary" :underline="false" @click="onRegister">立即注册</el-link>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="login__error">{{ error }}</p>
|
||||
</el-form>
|
||||
|
||||
<div v-if="config.layout === 'card'" class="login__panel-copyright">
|
||||
{{ config.copyright }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
import { defaultLoginConfig } from '@/config/login'
|
||||
import { applyTheme } from '@/utils/theme'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const app = useAppStore()
|
||||
const config = defaultLoginConfig
|
||||
|
||||
// 登录页不在布局组件内,手动应用主题(颜色 + 暗黑)
|
||||
applyTheme(app.themeColor, app.isDark)
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const model = reactive<Record<string, any>>({})
|
||||
for (const key of Object.keys(config.fields.properties)) model[key] = ''
|
||||
|
||||
// 注册/忘记密码成功后带 username 回填
|
||||
const qUsername = route.query.username
|
||||
if (qUsername) model.username = String(qUsername)
|
||||
|
||||
const captcha = ref('')
|
||||
const captchaCode = ref('')
|
||||
const remember = ref(true)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const rootStyle = computed(() => (config.background ? { background: config.background } : {}))
|
||||
|
||||
const rules = computed<FormRules>(() => {
|
||||
const result: FormRules = {}
|
||||
for (const [key, prop] of Object.entries(config.fields.properties)) {
|
||||
const itemRules: Record<string, unknown>[] = []
|
||||
if (config.fields.required?.includes(key)) {
|
||||
itemRules.push({ required: true, message: `请输入${prop.title ?? key}`, trigger: 'blur' })
|
||||
}
|
||||
if (prop.minLength) {
|
||||
itemRules.push({
|
||||
min: prop.minLength,
|
||||
message: `长度不能少于 ${prop.minLength} 位`,
|
||||
trigger: 'blur',
|
||||
})
|
||||
}
|
||||
if (itemRules.length) result[key] = itemRules
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function refreshCaptcha(): void {
|
||||
captchaCode.value = String(Math.floor(1000 + Math.random() * 9000))
|
||||
}
|
||||
refreshCaptcha()
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (loading.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (config.showCaptcha && captcha.value !== captchaCode.value) {
|
||||
error.value = '验证码错误'
|
||||
refreshCaptcha()
|
||||
captcha.value = ''
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(model.tenantCode, model.username, model.password)
|
||||
router.push(config.homePath)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onForget(): void {
|
||||
router.push('/forgot-password')
|
||||
}
|
||||
|
||||
function onRegister(): void {
|
||||
router.push('/register')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login {
|
||||
@apply flex min-h-screen;
|
||||
background: var(--wm-bg-color-page);
|
||||
}
|
||||
|
||||
.login--card {
|
||||
@apply items-center justify-center;
|
||||
}
|
||||
|
||||
/* ---------- 品牌区(split) ---------- */
|
||||
.login__brand {
|
||||
@apply relative hidden md:flex flex-col overflow-hidden;
|
||||
width: 44%;
|
||||
background: linear-gradient(160deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login__brand-decor {
|
||||
@apply absolute inset-0;
|
||||
}
|
||||
|
||||
.login__brand-decor-1 {
|
||||
@apply absolute rounded-full;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
top: -140px;
|
||||
right: -140px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.login__brand-decor-2 {
|
||||
@apply absolute rounded-full;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
bottom: -120px;
|
||||
left: -100px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.login__brand-inner {
|
||||
@apply relative flex flex-1 flex-col items-center justify-center px-xl text-center;
|
||||
}
|
||||
|
||||
.login__brand-logo-box {
|
||||
@apply flex h-20 w-20 items-center justify-center rounded-lg p-sm backdrop-blur;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.login__brand-logo {
|
||||
@apply h-full w-full object-contain;
|
||||
}
|
||||
|
||||
.login__brand-title {
|
||||
@apply mt-xl text-3xl font-semibold tracking-wide;
|
||||
}
|
||||
|
||||
.login__brand-subtitle {
|
||||
@apply mt-md text-base;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.login__brand-copyright {
|
||||
@apply relative mx-xl mb-lg border-t border-white/20 pt-lg text-center text-sm;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ---------- 表单区 ---------- */
|
||||
.login__card {
|
||||
@apply flex flex-1 items-center justify-center px-xl;
|
||||
}
|
||||
|
||||
.login__panel {
|
||||
@apply rounded-lg border border-solid p-2xl shadow-md;
|
||||
width: 440px;
|
||||
max-width: 92vw;
|
||||
background: var(--wm-bg-color-base);
|
||||
border-color: var(--wm-border-color-light);
|
||||
}
|
||||
|
||||
.login--card .login__card {
|
||||
@apply flex-none;
|
||||
}
|
||||
|
||||
.login__form {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.login__form-head {
|
||||
@apply mb-lg;
|
||||
}
|
||||
|
||||
.login__form-title {
|
||||
@apply text-2xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.login__form-subtitle {
|
||||
@apply mt-xs text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
/* 填充式输入框 */
|
||||
.login__form :deep(.el-form-item) {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.login__form :deep(.el-input__wrapper) {
|
||||
@apply rounded-base;
|
||||
background-color: var(--wm-bg-color-page);
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login__form :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px var(--wm-border-color-light) inset;
|
||||
}
|
||||
|
||||
.login__form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--wm-color-primary) inset;
|
||||
}
|
||||
|
||||
.login__captcha {
|
||||
@apply flex w-full gap-sm;
|
||||
}
|
||||
|
||||
.login__captcha-code {
|
||||
@apply flex w-28 flex-shrink-0 cursor-pointer select-none items-center justify-center rounded-base font-bold text-lg;
|
||||
background: var(--wm-bg-color-page);
|
||||
color: var(--wm-color-primary);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.login__row {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.login__submit {
|
||||
@apply mt-lg h-11 w-full border-none text-base rounded-full;
|
||||
background: linear-gradient(90deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
|
||||
letter-spacing: 2px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.login__register {
|
||||
@apply mt-sm text-center text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.login__error {
|
||||
@apply mt-sm text-center text-sm;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
/* ---------- 卡片布局(card) ---------- */
|
||||
.login__panel-head {
|
||||
@apply mb-lg text-center;
|
||||
}
|
||||
|
||||
.login__panel-logo {
|
||||
@apply mx-auto h-16 w-16 object-contain;
|
||||
}
|
||||
|
||||
.login__panel-title {
|
||||
@apply mt-md text-2xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.login__panel-subtitle {
|
||||
@apply mt-xs text-base text-text-secondary;
|
||||
}
|
||||
|
||||
.login__panel-copyright {
|
||||
@apply mt-xl text-center text-xs text-text-secondary;
|
||||
}
|
||||
</style>
|
||||
179
src/pages/register.vue
Normal file
179
src/pages/register.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: 'blank'
|
||||
requiresAuth: false
|
||||
</route>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<div class="auth__card">
|
||||
<div class="auth__head">
|
||||
<img v-if="config.logo" :src="config.logo" class="auth__logo" alt="logo" />
|
||||
<h1 class="auth__title">注册账号</h1>
|
||||
<p class="auth__subtitle">创建一个新账号</p>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" class="auth__form" :model="model" :rules="rules" size="large" @keyup.enter="onSubmit">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="model.username" placeholder="用户名">
|
||||
<template #prefix><icon type="iconify" icon="mdi:account" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="model.password" type="password" show-password placeholder="密码">
|
||||
<template #prefix><icon type="iconify" icon="mdi:lock" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input v-model="model.confirmPassword" type="password" show-password placeholder="确认密码">
|
||||
<template #prefix><icon type="iconify" icon="mdi:lock-check" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="email">
|
||||
<el-input v-model="model.email" placeholder="邮箱">
|
||||
<template #prefix><icon type="iconify" icon="mdi:email-outline" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="phone">
|
||||
<el-input v-model="model.phone" placeholder="手机号(可选)">
|
||||
<template #prefix><icon type="iconify" icon="mdi:cellphone" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" size="large" class="auth__submit" :loading="loading" @click="onSubmit">
|
||||
注 册
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="auth__footer">
|
||||
已有账号?
|
||||
<el-link type="primary" :underline="false" @click="router.push('/login')">返回登录</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
import { defaultLoginConfig } from '@/config/login'
|
||||
import { applyTheme } from '@/utils/theme'
|
||||
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
const config = defaultLoginConfig
|
||||
|
||||
// 注册页不在布局组件内,手动应用主题
|
||||
applyTheme(app.themeColor, app.isDark)
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const model = reactive({ username: '', password: '', confirmPassword: '', email: '', phone: '' })
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value !== model.password) callback(new Error('两次输入的密码不一致'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (loading.value) return
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// mock:后端暂无注册接口,模拟耗时
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
loading.value = false
|
||||
ElMessage.success('注册成功,请登录')
|
||||
router.push({ path: '/login', query: { username: model.username } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth {
|
||||
@apply flex min-h-screen items-center justify-center;
|
||||
background: var(--wm-bg-color-page);
|
||||
}
|
||||
|
||||
.auth__card {
|
||||
@apply rounded-lg border border-solid p-2xl shadow-md;
|
||||
width: 440px;
|
||||
max-width: 92vw;
|
||||
background: var(--wm-bg-color-base);
|
||||
border-color: var(--wm-border-color-light);
|
||||
}
|
||||
|
||||
.auth__head {
|
||||
@apply mb-lg text-center;
|
||||
}
|
||||
|
||||
.auth__logo {
|
||||
@apply mx-auto h-16 w-16 object-contain;
|
||||
}
|
||||
|
||||
.auth__title {
|
||||
@apply mt-md text-2xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.auth__subtitle {
|
||||
@apply mt-xs text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.auth__form {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.auth__form :deep(.el-form-item) {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.auth__form :deep(.el-input__wrapper) {
|
||||
@apply rounded-base;
|
||||
background-color: var(--wm-bg-color-page);
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth__form :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px var(--wm-border-color-light) inset;
|
||||
}
|
||||
|
||||
.auth__form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--wm-color-primary) inset;
|
||||
}
|
||||
|
||||
.auth__submit {
|
||||
@apply mt-lg h-11 w-full border-none text-base rounded-full;
|
||||
background: linear-gradient(90deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
|
||||
letter-spacing: 2px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.auth__footer {
|
||||
@apply mt-lg text-center text-sm text-text-secondary;
|
||||
}
|
||||
</style>
|
||||
@ -24,6 +24,8 @@ export interface JsonSchemaProperty {
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
placeholder?: string
|
||||
/** 输入框前缀图标(iconify 图标名,如 mdi:account)。 */
|
||||
prefixIcon?: string
|
||||
/** 控件宽度(如 '160px');缺省时搜索行内默认 200px、弹窗表单默认 100%。 */
|
||||
width?: string
|
||||
/** format=tree-select 时的树数据([{ label, value, children }])。 */
|
||||
|
||||
File diff suppressed because one or more lines are too long
34
typed-router.d.ts
vendored
34
typed-router.d.ts
vendored
@ -44,13 +44,27 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/login': RouteRecordInfo<
|
||||
'/login',
|
||||
'/forgot-password': RouteRecordInfo<
|
||||
'/forgot-password',
|
||||
'/forgot-password',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/login/': RouteRecordInfo<
|
||||
'/login/',
|
||||
'/login',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/register': RouteRecordInfo<
|
||||
'/register',
|
||||
'/register',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/system/config/': RouteRecordInfo<
|
||||
'/system/config/',
|
||||
'/system/config',
|
||||
@ -124,9 +138,21 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/login.vue': {
|
||||
'src/pages/forgot-password.vue': {
|
||||
routes:
|
||||
| '/login'
|
||||
| '/forgot-password'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/login/index.vue': {
|
||||
routes:
|
||||
| '/login/'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/register.vue': {
|
||||
routes:
|
||||
| '/register'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user