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:
liuyun 2026-10-11 11:34:41 +08:00
parent b9c3a64c34
commit c95aa594cb
11 changed files with 829 additions and 70 deletions

1
auto-imports.d.ts vendored
View File

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

8
components.d.ts vendored
View File

@ -16,6 +16,7 @@ declare module 'vue' {
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
@ -28,6 +29,7 @@ declare module 'vue' {
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
@ -41,8 +43,6 @@ declare module 'vue' {
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] 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'] ElTag: typeof import('element-plus/es')['ElTag']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
Icon: typeof import('./src/components/icon/icon.vue')['default'] Icon: typeof import('./src/components/icon/icon.vue')['default']
@ -73,6 +73,7 @@ declare global {
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
const ElButton: typeof import('element-plus/es')['ElButton'] const ElButton: typeof import('element-plus/es')['ElButton']
const ElCard: typeof import('element-plus/es')['ElCard'] 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 ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
const ElDialog: typeof import('element-plus/es')['ElDialog'] const ElDialog: typeof import('element-plus/es')['ElDialog']
@ -85,6 +86,7 @@ declare global {
const ElFormItem: typeof import('element-plus/es')['ElFormItem'] const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElInput: typeof import('element-plus/es')['ElInput'] const ElInput: typeof import('element-plus/es')['ElInput']
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] 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 ElMenu: typeof import('element-plus/es')['ElMenu']
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
const ElOption: typeof import('element-plus/es')['ElOption'] const ElOption: typeof import('element-plus/es')['ElOption']
@ -98,8 +100,6 @@ declare global {
const ElSwitch: typeof import('element-plus/es')['ElSwitch'] const ElSwitch: typeof import('element-plus/es')['ElSwitch']
const ElTable: typeof import('element-plus/es')['ElTable'] const ElTable: typeof import('element-plus/es')['ElTable']
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] 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 ElTag: typeof import('element-plus/es')['ElTag']
const ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] const ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
const Icon: typeof import('./src/components/icon/icon.vue')['default'] const Icon: typeof import('./src/components/icon/icon.vue')['default']

61
src/config/login.ts Normal file
View 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
View 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>

View 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>

View File

@ -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
View 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
View 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>

View File

@ -24,6 +24,8 @@ export interface JsonSchemaProperty {
minimum?: number minimum?: number
maximum?: number maximum?: number
placeholder?: string placeholder?: string
/** 输入框前缀图标(iconify 图标名,如 mdi:account)。 */
prefixIcon?: string
/** 控件宽度(如 '160px');缺省时搜索行内默认 200px、弹窗表单默认 100%。 */ /** 控件宽度(如 '160px');缺省时搜索行内默认 200px、弹窗表单默认 100%。 */
width?: string width?: string
/** format=tree-select 时的树数据([{ label, value, children }])。 */ /** format=tree-select 时的树数据([{ label, value, children }])。 */

File diff suppressed because one or more lines are too long

34
typed-router.d.ts vendored
View File

@ -44,13 +44,27 @@ declare module 'vue-router/auto-routes' {
Record<never, never>, Record<never, never>,
| never | never
>, >,
'/login': RouteRecordInfo< '/forgot-password': RouteRecordInfo<
'/login', '/forgot-password',
'/forgot-password',
Record<never, never>,
Record<never, never>,
| never
>,
'/login/': RouteRecordInfo<
'/login/',
'/login', '/login',
Record<never, never>, Record<never, never>,
Record<never, never>, Record<never, never>,
| never | never
>, >,
'/register': RouteRecordInfo<
'/register',
'/register',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/config/': RouteRecordInfo< '/system/config/': RouteRecordInfo<
'/system/config/', '/system/config/',
'/system/config', '/system/config',
@ -124,9 +138,21 @@ declare module 'vue-router/auto-routes' {
views: views:
| never | never
} }
'src/pages/login.vue': { 'src/pages/forgot-password.vue': {
routes: routes:
| '/login' | '/forgot-password'
views:
| never
}
'src/pages/login/index.vue': {
routes:
| '/login/'
views:
| never
}
'src/pages/register.vue': {
routes:
| '/register'
views: views:
| never | never
} }