feat: 重构前端项目结构并优化代码

refactor: 迁移前端资源文件至web目录
feat: 新增多种图标资源
style: 统一代码风格和格式化配置
docs: 更新README和文档说明
chore: 更新依赖和配置文件
fix: 修复部分类型定义和枚举
perf: 优化路由和组件加载逻辑
This commit is contained in:
zhangtao
2026-05-01 01:01:17 +08:00
parent 1355a09441
commit 80263e98e1
887 changed files with 116349 additions and 33825 deletions
@@ -0,0 +1,628 @@
<template>
<div>
<h3 text-center m-0 mb-20px>{{ t('login.login') }}</h3>
<el-tabs v-model="activeTab" class="login-tabs">
<!-- 账号登录 -->
<el-tab-pane label="账号登录" name="password">
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
size="large"
:validate-on-rule-change="false"
>
<!-- 用户名 -->
<el-form-item prop="username">
<el-input
v-model.trim="loginForm.username"
:placeholder="t('login.username')"
clearable
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<!-- 密码 -->
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
<el-form-item prop="password">
<el-input
v-model.trim="loginForm.password"
:placeholder="t('login.password')"
type="password"
show-password
clearable
@keyup="checkCapsLock"
@keyup.enter="handleLoginSubmit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
</el-tooltip>
<!-- 验证码 -->
<el-form-item v-if="captchaState.enable" prop="captcha">
<div flex items-center gap-10px class="flex-1">
<el-input
v-model.trim="loginForm.captcha"
:placeholder="t('login.captchaCode')"
clearable
class="flex-1"
@keyup.enter="handleLoginSubmit"
>
<template #prefix>
<div class="i-svg:captcha" />
</template>
</el-input>
<div cursor-pointer flex-center h-40px w-100px>
<el-icon v-if="codeLoading" class="is-loading" size="20">
<Loading />
</el-icon>
<el-image
v-else-if="captchaState.img_base"
border-rd-4px
object-cover
shadow="[0_0_0_1px_var(--el-border-color)_inset]"
:src="captchaState.img_base"
class="w-full h-full"
@click="getCaptcha"
/>
<el-text v-else type="info" size="small">点击获取验证码</el-text>
</div>
</div>
</el-form-item>
<div class="flex-x-between w-full">
<el-checkbox v-model="loginForm.remember">{{ t('login.rememberMe') }}</el-checkbox>
<el-link type="primary" underline="never" @click="toOtherForm('resetPwd')">
{{ t('login.forgetPassword') }}
</el-link>
</div>
<!-- 登录按钮 -->
<el-form-item>
<el-button :loading="loading" type="primary" class="w-full" @click="handleLoginSubmit">
{{ t('login.login') }}
</el-button>
</el-form-item>
</el-form>
<div flex-center gap-10px>
<el-text size="default">{{ t('login.noAccount') }}</el-text>
<el-link type="primary" underline="never" @click="toOtherForm('register')">
{{ t('login.reg') }}
</el-link>
</div>
</el-tab-pane>
<!-- 快速登录 -->
<el-tab-pane v-if="autoLoginUsers.length > 0" label="快速登录" name="quick">
<div class="quick-login-section">
<div class="quick-login-tip">
<el-text type="info">{{ t('login.quickLoginTip') }}</el-text>
</div>
<!-- 当用户数量大于4个时使用下拉选择,否则使用网格展示 -->
<template v-if="autoLoginUsers.length > 3">
<el-select
v-model="selectedUserId"
class="w-full"
:placeholder="t('login.selectUser')"
size="large"
@change="handleAutoLogin"
>
<el-option
v-for="user in autoLoginUsers"
:key="user.id"
:label="`${user.username}@${user.name}`"
:value="user.id"
/>
</el-select>
</template>
<template v-else>
<div class="auto-login-users">
<div
v-for="user in autoLoginUsers"
:key="user.id"
class="auto-login-user-item"
@click="handleAutoLogin(user.id)"
>
<el-avatar :size="60" :src="user.avatar || ''" class="user-avatar">
<el-icon size="24"><User /></el-icon>
</el-avatar>
<span class="user-name">{{ user.name }}</span>
<span class="user-username">{{ user.username }}</span>
</div>
</div>
</template>
</div>
</el-tab-pane>
</el-tabs>
<!-- 第三方登录 -->
<div class="third-party-login">
<div class="divider-container">
<div class="divider-line"></div>
<span class="divider-text">{{ t('login.otherLoginMethods') }}</span>
<div class="divider-line"></div>
</div>
<div class="flex-center gap-x-5 w-full text-[var(--el-text-color-secondary)]">
<CommonWrapper>
<div text-20px class="i-svg:wechat" />
</CommonWrapper>
<CommonWrapper>
<div text-20px cursor-pointer class="i-svg:qq" />
</CommonWrapper>
<CommonWrapper>
<div text-20px cursor-pointer class="i-svg:github" />
</CommonWrapper>
<CommonWrapper>
<div text-20px cursor-pointer class="i-svg:gitee" />
</CommonWrapper>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { LocationQuery, RouteLocationRaw, useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { onActivated, onMounted, watch } from 'vue';
import AuthAPI, {
type AutoLoginUser,
type LoginFormData,
type CaptchaInfo,
} from '@/api/module_system/auth';
import { useAppStore, useUserStore, useSettingsStore } from '@/store';
import CommonWrapper from '@/components/CommonWrapper/index.vue';
import { User, Loading, Lock } from '@element-plus/icons-vue';
import { Auth } from '@/utils/auth';
const { t } = useI18n();
const userStore = useUserStore();
const appStore = useAppStore();
const settingsStore = useSettingsStore();
// 激活的标签页
const activeTab = ref('password');
// 选择的用户ID(用于下拉选择)
const selectedUserId = ref<number | null>(null);
// 来自父容器的预填用户名和密码
const props = defineProps<{ presetUsername?: string; presetPassword?: string }>();
const route = useRoute();
const router = useRouter();
// 组件挂载时获取验证码和免登录用户列表
onMounted(() => {
getCaptcha();
getAutoLoginUsers();
});
// 组件激活时获取验证码(适用于KeepAlive缓存的情况)
onActivated(() => {
getCaptcha();
// 重置登录表单
loginForm.captcha = '';
});
// 监听路由变化,确保每次进入登录页面都有最新验证码
watch(
() => route.fullPath,
() => {
getCaptcha();
loginForm.captcha = '';
}
);
const loginFormRef = ref<FormInstance>();
const loading = ref(false);
// 是否大写锁定
const isCapsLock = ref(false);
const loginForm = reactive<LoginFormData>({
username: '',
password: '',
captcha: '',
captcha_key: '',
remember: true,
login_type: 'PC端',
});
// 监听父组件传入的预填信息,立即填充到登录表单
watch(
() => [props.presetUsername, props.presetPassword],
([presetUsername, presetPassword]) => {
if (typeof presetUsername === 'string') {
loginForm.username = presetUsername;
}
if (typeof presetPassword === 'string') {
loginForm.password = presetPassword;
}
},
{ immediate: true }
);
const captchaState = reactive<CaptchaInfo>({
enable: true,
key: '',
img_base: '',
});
// 免登录用户列表
const autoLoginUsers = ref<AutoLoginUser[]>([]);
const autoLoginLoading = ref(false);
// 获取免登录用户列表
async function getAutoLoginUsers() {
try {
const response = await AuthAPI.getAutoLoginUsers();
autoLoginUsers.value = response.data.data || [];
} catch (error) {
console.error('获取免登录用户列表失败:', error);
}
}
// 免登录
async function handleAutoLogin(userId: number) {
if (autoLoginLoading.value) return;
try {
autoLoginLoading.value = true;
// 1. 获取免登录Token
const tokenResponse = await AuthAPI.getAutoLoginToken(userId);
const { token } = tokenResponse.data.data;
// 2. 使用Token登录
const loginResponse = await AuthAPI.autoLogin(token);
const loginData = loginResponse.data.data;
// 3. 设置登录状态
userStore.rememberMe = true;
Auth.setTokens(loginData.access_token, loginData.refresh_token, true);
// 4. 获取用户信息
await userStore.getUserInfo();
// 5. 跳转
const redirect = resolveRedirectTarget(route.query);
await router.replace(redirect);
// 6. 显示引导
if (settingsStore.showGuide) {
appStore.showGuide(true);
}
ElMessage.success('登录成功');
} catch (error: any) {
console.error('免登录失败:', error);
ElMessage.error(error?.response?.data?.msg || '登录失败');
} finally {
autoLoginLoading.value = false;
}
}
const loginRules = computed(() => {
const rules: any = {
username: [
{
required: true,
trigger: 'blur',
message: t('login.message.username.required'),
},
],
password: [
{
required: true,
trigger: 'blur',
message: t('login.message.password.required'),
},
{
min: 6,
message: t('login.message.password.min'),
trigger: 'blur',
},
],
};
// 只有在验证码开启时才添加验证码验证规则
if (captchaState.enable) {
rules.captcha = [
{
required: true,
trigger: 'blur',
message: t('login.message.captchaCode.required'),
},
];
}
return rules;
});
// 获取验证码
const codeLoading = ref(false);
async function getCaptcha() {
try {
codeLoading.value = true;
const response = await AuthAPI.getCaptcha();
loginForm.captcha_key = response.data.data.key;
captchaState.img_base = response.data.data.img_base;
captchaState.enable = response.data.data.enable;
} catch (error: any) {
// 验证码获取失败时,默认关闭验证码功能
console.error('获取验证码失败:', error);
captchaState.enable = false;
// 清空验证码相关字段,避免影响登录
loginForm.captcha = '';
loginForm.captcha_key = '';
} finally {
codeLoading.value = false;
}
}
/**
* 登录提交
*/
async function handleLoginSubmit() {
try {
// 1. 表单验证
const valid = await loginFormRef.value?.validate();
if (!valid) return;
loading.value = true;
// 2. 执行登录
await userStore.login(loginForm);
// 4. 登录成功,让路由守卫处理跳转逻辑
// 解析目标地址,但不直接跳转
const redirect = resolveRedirectTarget(route.query);
// 通过替换当前路由触发路由守卫,让守卫处理后续的路由生成和跳转
await router.replace(redirect);
// 5. 记住我功能已实现,根据用户选择决定token的存储方式:
// - 选中"记住我": token存储在localStorage中,浏览器关闭后仍然有效
// - 未选中"记住我": token存储在sessionStorage中,浏览器关闭后失效
// 登录成功后自动开启项目引导
if (settingsStore.showGuide) {
appStore.showGuide(true);
}
} catch (error: any) {
if (error) {
getCaptcha(); // 刷新验证码
}
} finally {
loading.value = false;
}
}
/**
* 解析重定向目标
*
* @param query 路由查询参数
* @returns 标准化后的路由地址
*/
function resolveRedirectTarget(query: LocationQuery): RouteLocationRaw {
// 默认跳转路径
const defaultPath = '/';
// 获取原始重定向路径
const rawRedirect = (query.redirect as string) || defaultPath;
try {
// 6. 使用Vue Router解析路径
const resolved = router.resolve(rawRedirect);
return {
path: resolved.path,
query: resolved.query,
};
} catch {
// 7. 异常处理:返回安全路径
return { path: defaultPath };
}
}
// 检查输入大小写
function checkCapsLock(event: KeyboardEvent) {
// 防止浏览器密码自动填充时报错
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState('CapsLock');
}
}
const emit = defineEmits(['update:modelValue']);
function toOtherForm(type: 'register' | 'resetPwd') {
emit('update:modelValue', type);
}
</script>
<style lang="scss" scoped>
.login-tabs {
margin-bottom: 20px;
:deep(.el-tabs__content) {
min-height: 300px;
overflow: visible;
}
:deep(.el-tab-pane) {
position: relative;
overflow: visible;
}
}
.quick-login-section {
position: relative;
min-height: 200px;
padding: 20px 0;
.quick-login-tip {
margin-bottom: 20px;
font-size: 14px;
text-align: center;
}
// 下拉菜单样式
.user-dropdown {
.user-dropdown-btn {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 48px;
padding: 0 16px;
.btn-text {
font-size: 14px;
color: var(--el-text-color-regular);
}
}
}
:deep(.user-dropdown-menu) {
max-height: 320px;
overflow-y: auto;
.el-dropdown-menu__item {
display: flex !important;
gap: 20px !important;
align-items: center !important;
padding: 14px 24px !important;
}
}
.user-option {
display: flex;
gap: 16px;
align-items: center;
padding: 10px 0;
.user-info {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
.user-option-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.user-option-username {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
}
}
.user-option-avatar {
flex-shrink: 0;
border: 2px solid var(--el-border-color-light);
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
}
}
.auto-login-users {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
padding: 10px 0;
.auto-login-user-item {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
width: 120px;
padding: 12px;
cursor: pointer;
background-color: var(--el-fill-color-lighter);
border: 2px solid var(--el-border-color-light);
border-radius: 12px;
box-shadow: var(--el-box-shadow-light);
transition: all 0.3s ease;
&:hover {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
box-shadow: var(--el-box-shadow);
transform: translateY(-2px);
}
.user-avatar {
border: 2px solid var(--el-border-color);
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
}
}
.user-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 500;
text-align: center;
white-space: nowrap;
}
.user-username {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
text-align: center;
white-space: nowrap;
}
}
}
}
.third-party-login {
.divider-container {
display: flex;
align-items: center;
margin: 20px 0;
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(
to right,
transparent,
var(--el-border-color-light),
transparent
);
}
.divider-text {
padding: 0 16px;
font-size: 12px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
}
}
</style>
@@ -0,0 +1,186 @@
<template>
<div>
<h3 text-center m-0 mb-20px>{{ t('login.reg') }}</h3>
<el-form ref="formRef" :model="model" :rules="rules" size="large" label-suffix=":">
<!-- 账号 -->
<el-form-item prop="username">
<el-input v-model.trim="model.username" :placeholder="t('login.username')" clearable>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<!-- 密码 -->
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
<el-form-item prop="password">
<el-input
v-model.trim="model.password"
:placeholder="t('login.password')"
type="password"
show-password
clearable
@keyup="checkCapsLock"
@keyup.enter="submit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
</el-tooltip>
<!-- 确认密码 -->
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
<el-form-item prop="confirmPassword">
<el-input
v-model.trim="model.confirmPassword"
:placeholder="t('login.message.password.confirm')"
type="password"
show-password
clearable
@keyup="checkCapsLock"
@keyup.enter="submit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
</el-tooltip>
<el-form-item>
<div class="flex-y-center w-full gap-10px">
<el-checkbox v-model="isRead">{{ t('login.agree') }}</el-checkbox>
<el-link
type="primary"
underline="never"
:href="configStore.configData.sys_web_clause.config_value"
target="_blank"
>
{{ t('login.userAgreement') }}
</el-link>
</div>
</el-form-item>
<!-- 注册按钮 -->
<el-form-item>
<el-button :loading="loading" type="success" class="w-full" @click="submit">
{{ t('login.register') }}
</el-button>
</el-form-item>
</el-form>
<div flex-center gap-10px>
<el-text size="default">{{ t('login.haveAccount') }}</el-text>
<el-link type="primary" underline="never" @click="toLogin">{{ t('login.login') }}</el-link>
</div>
</div>
</template>
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { Lock } from '@element-plus/icons-vue';
import UserAPI, { type RegisterForm } from '@/api/module_system/user';
import { useConfigStore } from '@/store';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// 使用 defineModel 简化具名 v-model 绑定
const modelValue = defineModel<string>();
const presetUsername = defineModel<string>('presetUsername');
const presetPassword = defineModel<string>('presetPassword');
const toLogin = () => {
modelValue.value = 'login';
};
const configStore = useConfigStore();
const formRef = ref<FormInstance>();
const loading = ref(false); // 按钮 loading 状态
const isCapsLock = ref(false); // 是否大写锁定
const isRead = ref(false);
const model = ref<RegisterForm>({
username: '',
password: '',
confirmPassword: '',
});
const rules = computed(() => {
return {
username: [
{
required: true,
trigger: 'blur',
message: t('login.message.username.required'),
},
],
password: [
{
required: true,
trigger: 'blur',
message: t('login.message.password.required'),
},
{
min: 6,
message: t('login.message.password.min'),
trigger: 'blur',
},
],
confirmPassword: [
{
required: true,
trigger: 'blur',
message: t('login.message.password.required'),
},
{
min: 6,
message: t('login.message.password.min'),
trigger: 'blur',
},
{
validator: (_: any, value: string) => {
return value === model.value.password;
},
trigger: 'blur',
message: t('login.message.password.inconformity'),
},
],
};
});
// 检查输入大小写
function checkCapsLock(event: KeyboardEvent) {
// 防止浏览器密码自动填充时报错
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState('CapsLock');
}
}
const submit = async () => {
try {
// 0. 检查是否已勾选协议
if (!isRead.value) {
ElMessage.warning(t('login.message.agree.required'));
return;
}
// 1. 表单验证
const valid = await formRef.value?.validate();
if (!valid) return;
loading.value = true;
await UserAPI.registerUser(model.value);
// 注册成功后,双向绑定回写父容器的用户名和密码,并切回登录
presetUsername.value = model.value.username;
presetPassword.value = model.value.password;
toLogin();
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
onMounted(() => {});
</script>
@@ -0,0 +1,169 @@
<template>
<div>
<h3 text-center m-0 mb-20px>{{ t('login.resetPassword') }}</h3>
<el-form ref="formRef" :model="model" :rules="rules" size="large" label-suffix=":">
<!-- 用户名 -->
<el-form-item prop="username">
<el-input v-model.trim="model.username" :placeholder="t('login.username')" clearable>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<!-- 新密码 -->
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
<el-form-item prop="new_password">
<el-input
v-model.trim="model.new_password"
:placeholder="t('login.password')"
type="password"
show-password
clearable
@keyup="checkCapsLock"
@keyup.enter="submit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
</el-tooltip>
<!-- 确认密码 -->
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
<el-form-item prop="confirmPassword">
<el-input
v-model.trim="model.confirmPassword"
:placeholder="t('login.message.password.confirm')"
type="password"
show-password
clearable
@keyup="checkCapsLock"
@keyup.enter="submit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
</el-tooltip>
<!-- 重置密码按钮 -->
<el-form-item>
<el-button type="warning" class="w-full" @click="submit">
{{ t('login.resetPassword') }}
</el-button>
</el-form-item>
</el-form>
<div flex-center gap-10px>
<el-text size="default">{{ t('login.thinkOfPasswd') }}</el-text>
<el-link type="primary" underline="never" @click="toLogin">{{ t('login.login') }}</el-link>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { FormInstance } from 'element-plus';
import UserAPI, { type ForgetPasswordForm } from '@/api/module_system/user';
const loading = ref(false); // 按钮 loading 状态
const isCapsLock = ref(false); // 是否大写锁定
const { t } = useI18n();
// 使用 defineModel 简化具名 v-model 绑定
const modelValue = defineModel<string>();
const presetUsername = defineModel<string>('presetUsername');
const presetPassword = defineModel<string>('presetPassword');
const toLogin = () => {
modelValue.value = 'login';
};
const model = ref<ForgetPasswordForm>({
username: '',
new_password: '',
confirmPassword: '',
});
const rules = computed(() => {
return {
username: [
{
required: true,
trigger: 'blur',
message: t('login.message.username.required'),
},
],
new_password: [
{
required: true,
trigger: 'blur',
message: t('login.message.password.required'),
},
{
min: 6,
message: t('login.message.password.min'),
trigger: 'blur',
},
],
confirmPassword: [
{
required: true,
trigger: 'blur',
message: t('login.message.password.required'),
},
{
min: 6,
message: t('login.message.password.min'),
trigger: 'blur',
},
{
validator: (_: any, value: string) => {
return value === model.value.new_password;
},
trigger: 'blur',
message: t('login.message.password.inconformity'),
},
],
mobile: [
{
required: true,
trigger: 'blur',
message: t('login.message.captchaCode.required'),
},
],
};
});
const formRef = ref<FormInstance>();
const submit = async () => {
try {
// 1. 表单验证
const valid = await formRef.value?.validate();
if (!valid) return;
loading.value = true;
await UserAPI.forgetPassword(model.value);
// 重置成功后,双向绑定回写父容器的用户名和新密码,并切回登录
presetUsername.value = model.value.username;
presetPassword.value = model.value.new_password;
toLogin();
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
// 检查输入大小写
function checkCapsLock(event: KeyboardEvent) {
// 防止浏览器密码自动填充时报错
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState('CapsLock');
}
}
</script>
@@ -0,0 +1,62 @@
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('forgetPassword.title') }}</h3>
<p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
<div class="mt-5">
<span v-if="showInputLabel" class="input-label">账号</span>
<ElInput
v-model.trim="username"
class="custom-height"
:placeholder="$t('forgetPassword.placeholder')"
/>
</div>
<div style="margin-top: 15px">
<ElButton
v-ripple
class="w-full custom-height"
type="primary"
:loading="loading"
@click="register"
>
{{ $t('forgetPassword.submitBtnText') }}
</ElButton>
</div>
<div style="margin-top: 15px">
<ElButton class="w-full custom-height" plain @click="toLogin">
{{ $t('forgetPassword.backBtnText') }}
</ElButton>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ForgetPassword' });
const router = useRouter();
const showInputLabel = ref(false);
const username = ref('');
const loading = ref(false);
const register = async () => {};
const toLogin = () => {
router.push({ name: 'Login' });
};
</script>
<style scoped>
@import '../login/style.css';
</style>
@@ -0,0 +1,517 @@
<template>
<div class="auth-view" :style="{ '--login-background-url': `url(${loginBackgroundUrl})` }">
<!-- 右侧切换主题、语言按钮 -->
<div class="auth-view__toolbar">
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
<CommonWrapper>
<ThemeSwitch />
</CommonWrapper>
</el-tooltip>
<el-tooltip :content="t('login.languageToggle')" placement="bottom">
<CommonWrapper>
<LangSelect size="text-20px" />
</CommonWrapper>
</el-tooltip>
</div>
<!-- 登录页主体 -->
<div class="auth-view__wrapper">
<!-- 可选:左侧产品介绍区域,如不需要可整段删除,右侧登录表单会自动居中展示 -->
<section class="auth-feature">
<div class="auth-feature__badge">
<span class="auth-feature__dot" />
Enterprise Ready
</div>
<h1 class="auth-feature__title">企业级管理系统</h1>
<p class="auth-feature__subtitle">
提供安全、高效、可扩展的管理解决方案,助力企业数字化转型与业务增长。
</p>
<ul class="auth-feature__highlights">
<li>
<span>✓</span>
统一身份认证与权限管理
</li>
<li>
<span>✓</span>
支持定时任务与任务调度
</li>
<li>
<span>✓</span>
数据安全与操作审计
</li>
<li>
<span>✓</span>
灵活扩展与高可用架构
</li>
</ul>
</section>
<!-- 登录页主体容器 -->
<section class="auth-panel">
<!-- 标题 -->
<div class="auth-panel__brand">
<div class="auth-panel__logo-wrap">
<!-- logo -->
<el-image
:src="configStore.configData?.sys_web_logo?.config_value || ''"
class="auth-panel__logo"
/>
</div>
<div class="auth-panel__meta">
<div class="auth-panel__title-row">
<span class="auth-panel__title">
{{ configStore.configData?.sys_web_title?.config_value || '' }}
</span>
<el-tooltip
:content="configStore.configData?.sys_web_description?.config_value || ''"
placement="bottom"
>
<el-icon class="cursor-help"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="auth-panel__version-row">
<span class="auth-panel__version-label">Version</span>
<span class="auth-panel__version-pill">
v{{ configStore.configData?.sys_web_version?.config_value || '' }}
</span>
</div>
</div>
</div>
<!-- 组件切换 -->
<transition name="fade-slide" mode="out-in">
<component
:is="formComponents[component]"
v-model="component"
v-model:preset-username="loginPreset.username"
v-model:preset-password="loginPreset.password"
class="auth-panel__form"
/>
</transition>
<!-- 登录页底部版权 -->
<footer class="auth-panel__footer">
<el-text size="small">
<a :href="configStore.configData?.sys_git_code?.config_value || ''" target="_blank">
{{ configStore.configData?.sys_web_copyright?.config_value || '' }}
</a>
|
<a :href="configStore.configData?.sys_help_doc?.config_value || ''" target="_blank">
帮助
</a>
|
<a :href="configStore.configData?.sys_web_privacy?.config_value || ''" target="_blank">
隐私
</a>
|
<a :href="configStore.configData?.sys_web_clause?.config_value || ''" target="_blank">
条款
</a>
{{ configStore.configData?.sys_keep_record?.config_value || '' }}
</el-text>
</footer>
</section>
</div>
</div>
</template>
<script setup lang="ts">
// import logo from "@/assets/logo.png";
// import { defaultSettings } from "@/settings";
import CommonWrapper from '@/components/CommonWrapper/index.vue';
import ThemeSwitch from '@/components/ThemeSwitch/index.vue';
import { useConfigStore } from '@/store';
const configStore = useConfigStore();
// 添加计算属性处理背景图片URL
const loginBackgroundUrl = computed(() => {
// 使用可选链操作符确保安全访问
return (
configStore.configData?.sys_login_background?.config_value ||
new URL('@/assets/images/login-bg.svg', import.meta.url).href
);
});
type LayoutMap = 'login' | 'register' | 'resetPwd';
const t = useI18n().t;
const component = ref<LayoutMap>('login'); // 切换显示的组件
const formComponents = {
login: defineAsyncComponent(() => import('./components/Login.vue')),
register: defineAsyncComponent(() => import('./components/Register.vue')),
resetPwd: defineAsyncComponent(() => import('./components/ResetPwd.vue')),
};
// 预填登录信息(通过具名 v-model 双向绑定传递)
const loginPreset = reactive<{ username: string; password: string }>({
username: 'admin',
password: '123456',
});
let notificationInstance: ReturnType<typeof ElNotification> | null = null;
const showVoteNotification = () => {
notificationInstance = ElNotification({
title: '⭐ FastapiAdmin 完全开源 · 期待您的 Star 支持 🙏',
message: `项目持续迭代中,若对您有所帮助,欢迎点亮 Star 支持!
<br/><a href="https://github.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-primary); text-decoration: none; font-weight: 500;">Github仓库 →</a>
<br/><a href="https://gitee.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-warning); text-decoration: none; font-weight: 500;">Gitee仓库 →</a>`,
type: 'success',
position: 'bottom-left',
duration: 0,
dangerouslyUseHTMLString: true,
});
};
onMounted(() => {
setTimeout(showVoteNotification, 500);
});
onBeforeUnmount(() => {
if (notificationInstance) {
notificationInstance.close();
notificationInstance = null;
}
});
</script>
<style lang="scss" scoped>
.auth-view {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: clamp(1rem, 3vw, 2rem);
overflow: hidden;
background-color: var(--el-bg-color-page);
&::before {
position: fixed;
inset: 0;
z-index: -2;
content: '';
background: var(--login-background-url) center/cover no-repeat;
}
&::after {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
content: '';
background: linear-gradient(120deg, var(--el-bg-color), transparent);
}
}
.auth-view__toolbar {
display: inline-flex;
gap: 0.75rem;
align-self: flex-end;
padding: 0.5rem 0.75rem;
background-color: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 999px;
box-shadow: var(--el-box-shadow-light);
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
&:hover {
box-shadow: var(--el-box-shadow);
transform: translateY(-2px);
}
@media (max-width: 640px) {
position: fixed;
top: 12px;
right: 16px;
z-index: 20;
align-self: flex-end;
justify-content: center;
}
// 暗色/亮色交给 Element Plus 变量处理,不在页面里写死颜色分支
}
/* 暗色样式交给全局主题变量 */
.auth-view__wrapper {
display: grid;
flex: 1;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: clamp(1.5rem, 3vw, 3rem);
align-items: stretch;
padding: clamp(1.5rem, 2vw, 2.5rem);
}
.auth-feature {
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(1.5rem, 3vw, 3rem);
color: var(--el-text-color-primary);
animation: featureFade 0.8s ease-out;
}
@media (max-width: 768px) {
.auth-view__wrapper {
display: block;
padding: 1.25rem 0.75rem 1.75rem;
}
.auth-feature {
display: none;
}
.auth-panel {
width: 100%;
margin-inline: 0;
box-shadow: var(--el-box-shadow);
}
}
.auth-feature__badge {
display: inline-flex;
gap: 0.5rem;
align-items: center;
width: fit-content;
padding: 0.3rem 0.9rem;
font-size: 0.875rem;
color: var(--el-color-primary);
text-transform: uppercase;
letter-spacing: 0.08em;
background: var(--el-color-primary-light-9);
border-radius: 999px;
}
.auth-feature__dot {
width: 0.5rem;
height: 0.5rem;
background: var(--el-color-primary);
border-radius: 50%;
box-shadow: var(--el-box-shadow-light);
}
.auth-feature__title {
margin: 1.5rem 0 0.5rem;
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: 600;
line-height: 1.2;
}
.auth-feature__subtitle {
margin-bottom: 1.5rem;
font-size: 1rem;
line-height: 1.7;
color: var(--el-text-color-regular);
}
.auth-feature__highlights {
display: grid;
gap: 0.75rem;
padding: 0;
margin: 0;
list-style: none;
li {
display: flex;
gap: 0.5rem;
align-items: flex-start;
padding: 0.75rem 1rem;
font-weight: 500;
color: var(--el-text-color-primary);
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
backdrop-filter: blur(6px);
span {
font-size: 0.75rem;
line-height: 1.6;
color: var(--el-color-primary);
}
}
}
.auth-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
justify-content: flex-start;
justify-self: end;
width: min(520px, 100%);
padding: clamp(2rem, 3vw, 2.75rem);
margin-inline: auto;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 24px;
box-shadow: var(--el-box-shadow);
backdrop-filter: blur(20px);
animation: panelLift 0.7s ease;
}
.auth-panel__brand {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding-bottom: 0.85rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.auth-panel__logo-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
background: var(--el-fill-color-light);
border-radius: 18px;
box-shadow: var(--el-box-shadow-light);
}
.auth-panel__logo {
flex-shrink: 0;
width: 52px;
height: 52px;
}
.auth-panel__meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.auth-panel__title-row {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.auth-panel__title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 1.2rem;
font-weight: 650;
line-height: 1.4;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.auth-panel__version-row {
display: inline-flex;
gap: 0.5rem;
align-items: center;
font-size: 0.78rem;
}
.auth-panel__version-label {
color: var(--el-text-color-placeholder);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.auth-panel__version-pill {
padding: 0.1rem 0.55rem;
font-weight: 500;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-border-color-lighter);
border-radius: 999px;
}
.auth-panel__form {
width: 100%;
max-width: 100%;
margin-inline: auto;
:deep(.el-form-item) {
margin-bottom: 1.25rem;
}
:deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px var(--el-border-color) inset;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
}
:deep(.el-card) {
background: transparent;
box-shadow: none;
}
}
.auth-panel__footer {
padding-top: 0.875rem;
margin-top: 0.125rem;
font-size: 0.875rem;
text-align: center;
border-top: 1px solid var(--el-border-color-lighter);
a {
margin-left: 0.1rem;
color: var(--el-text-color-regular);
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--el-color-primary);
}
}
}
@keyframes featureFade {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes panelLift {
from {
opacity: 0;
transform: translateY(30px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-40px) scale(0.95);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(40px) scale(0.95);
}
.fade-slide-enter-to,
.fade-slide-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
</style>
+284
View File
@@ -0,0 +1,284 @@
<!-- 登录页面 -->
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('login.title') }}</h3>
<p class="sub-title">{{ $t('login.subTitle') }}</p>
<ElForm
ref="formRef"
:key="formKey"
:model="formData"
:rules="rules"
style="margin-top: 25px"
@keyup.enter="handleSubmit"
>
<ElFormItem prop="account">
<ElSelect v-model="formData.account" @change="setupAccount">
<ElOption
v-for="account in accounts"
:key="account.key"
:label="account.label"
:value="account.key"
>
<span>{{ account.label }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem prop="username">
<ElInput
v-model.trim="formData.username"
class="custom-height"
:placeholder="$t('login.placeholder.username')"
/>
</ElFormItem>
<ElFormItem prop="password">
<ElInput
v-model.trim="formData.password"
class="custom-height"
:placeholder="$t('login.placeholder.password')"
type="password"
autocomplete="off"
show-password
/>
</ElFormItem>
<!-- 推拽验证 -->
<div class="relative pb-5 mt-6">
<div
class="relative z-[2] overflow-hidden select-none rounded-lg border border-transparent tad-300"
:class="{ '!border-[#FF4E4F]': !isPassing && isClickPass }"
>
<ArtDragVerify
ref="dragVerify"
v-model:value="isPassing"
:text="$t('login.sliderText')"
text-color="var(--art-gray-700)"
:success-text="$t('login.sliderSuccessText')"
progress-bar-bg="var(--main-color)"
:background="isDark ? '#26272F' : '#F1F1F4'"
handler-bg="var(--default-box-color)"
/>
</div>
<p
class="absolute top-0 z-[1] px-px mt-2 text-xs text-[#f56c6c] tad-300"
:class="{ 'translate-y-10': !isPassing && isClickPass }"
>
{{ $t('login.placeholder.slider') }}
</p>
</div>
<div class="flex-cb mt-2 text-sm">
<ElCheckbox v-model="formData.rememberPassword">
{{ $t('login.rememberPwd') }}
</ElCheckbox>
<RouterLink class="text-theme" :to="{ name: 'ForgetPassword' }">
{{ $t('login.forgetPwd') }}
</RouterLink>
</div>
<div style="margin-top: 30px">
<ElButton
v-ripple
class="w-full custom-height"
type="primary"
:loading="loading"
@click="handleSubmit"
>
{{ $t('login.btnText') }}
</ElButton>
</div>
<div class="mt-5 text-sm text-gray-600">
<span>{{ $t('login.noAccount') }}</span>
<RouterLink class="text-theme" :to="{ name: 'Register' }">
{{ $t('login.register') }}
</RouterLink>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config';
import { useUserStore } from '@/store/modules/user';
import { useI18n } from 'vue-i18n';
import { HttpError } from '@/utils/http/error';
import { fetchLogin } from '@/api/auth';
import { ElNotification, type FormInstance, type FormRules } from 'element-plus';
import { useSettingStore } from '@/store/modules/setting';
defineOptions({ name: 'Login' });
const settingStore = useSettingStore();
const { isDark } = storeToRefs(settingStore);
const { t, locale } = useI18n();
const formKey = ref(0);
// 监听语言切换,重置表单
watch(locale, () => {
formKey.value++;
});
type AccountKey = 'super' | 'admin' | 'user';
export interface Account {
key: AccountKey;
label: string;
userName: string;
password: string;
roles: string[];
}
const accounts = computed<Account[]>(() => [
{
key: 'super',
label: t('login.roles.super'),
userName: 'Super',
password: '123456',
roles: ['R_SUPER'],
},
{
key: 'admin',
label: t('login.roles.admin'),
userName: 'Admin',
password: '123456',
roles: ['R_ADMIN'],
},
{
key: 'user',
label: t('login.roles.user'),
userName: 'User',
password: '123456',
roles: ['R_USER'],
},
]);
const dragVerify = ref();
const userStore = useUserStore();
const router = useRouter();
const route = useRoute();
const isPassing = ref(false);
const isClickPass = ref(false);
const systemName = AppConfig.systemInfo.name;
const formRef = ref<FormInstance>();
const formData = reactive({
account: '',
username: '',
password: '',
rememberPassword: true,
});
const rules = computed<FormRules>(() => ({
username: [{ required: true, message: t('login.placeholder.username'), trigger: 'blur' }],
password: [{ required: true, message: t('login.placeholder.password'), trigger: 'blur' }],
}));
const loading = ref(false);
onMounted(() => {
setupAccount('super');
});
// 设置账号
const setupAccount = (key: AccountKey) => {
const selectedAccount = accounts.value.find((account: Account) => account.key === key);
formData.account = key;
formData.username = selectedAccount?.userName ?? '';
formData.password = selectedAccount?.password ?? '';
};
// 登录
const handleSubmit = async () => {
if (!formRef.value) return;
try {
// 表单验证
const valid = await formRef.value.validate();
if (!valid) return;
// 拖拽验证
if (!isPassing.value) {
isClickPass.value = true;
return;
}
loading.value = true;
// 登录请求
const { username, password } = formData;
const { token, refreshToken } = await fetchLogin({
userName: username,
password,
});
// 验证token
if (!token) {
throw new Error('Login failed - no token received');
}
// 存储 token 和登录状态
userStore.setToken(token, refreshToken);
userStore.setLoginStatus(true);
// 登录成功处理
showLoginSuccessNotice();
// 获取 redirect 参数,如果存在则跳转到指定页面,否则跳转到首页
const redirect = route.query.redirect as string;
router.push(redirect || '/');
} catch (error) {
// 处理 HttpError
if (error instanceof HttpError) {
// console.log(error.code)
} else {
// 处理非 HttpError
// ElMessage.error('登录失败,请稍后重试')
console.error('[Login] Unexpected error:', error);
}
} finally {
loading.value = false;
resetDragVerify();
}
};
// 重置拖拽验证
const resetDragVerify = () => {
dragVerify.value.reset();
};
// 登录成功提示
const showLoginSuccessNotice = () => {
setTimeout(() => {
ElNotification({
title: t('login.success.title'),
type: 'success',
duration: 2500,
zIndex: 10000,
message: `${t('login.success.message')}, ${systemName}!`,
});
}, 1000);
};
</script>
<style scoped>
@import './style.css';
</style>
<style lang="scss" scoped>
:deep(.el-select__wrapper) {
height: 40px !important;
}
</style>
@@ -0,0 +1,38 @@
@reference '@styles/core/tailwind.css';
/* 授权页右侧区域 */
.auth-right-wrap {
@apply absolute inset-0 w-[440px] h-[650px] py-[5px] m-auto overflow-hidden
max-sm:px-7 max-sm:w-full
animate-[slideInRight_0.6s_cubic-bezier(0.25,0.46,0.45,0.94)_forwards]
max-md:animate-none;
.form {
@apply h-full py-[40px];
}
.title {
@apply text-g-900 text-4xl font-semibold max-md:text-3xl max-sm:pt-10;
}
.sub-title {
@apply mt-[10px] text-g-600 text-sm;
}
.custom-height {
@apply !h-[40px];
}
}
/* 滑入动画 */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@@ -0,0 +1,241 @@
<!-- 注册页面 -->
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('register.title') }}</h3>
<p class="sub-title">{{ $t('register.subTitle') }}</p>
<ElForm
ref="formRef"
:key="formKey"
class="mt-7.5"
:model="formData"
:rules="rules"
label-position="top"
>
<ElFormItem prop="username">
<ElInput
v-model.trim="formData.username"
class="custom-height"
:placeholder="$t('register.placeholder.username')"
/>
</ElFormItem>
<ElFormItem prop="password">
<ElInput
v-model.trim="formData.password"
class="custom-height"
:placeholder="$t('register.placeholder.password')"
type="password"
autocomplete="off"
show-password
/>
</ElFormItem>
<ElFormItem prop="confirmPassword">
<ElInput
v-model.trim="formData.confirmPassword"
class="custom-height"
:placeholder="$t('register.placeholder.confirmPassword')"
type="password"
autocomplete="off"
show-password
@keyup.enter="register"
/>
</ElFormItem>
<ElFormItem prop="agreement">
<ElCheckbox v-model="formData.agreement">
{{ $t('register.agreeText') }}
<RouterLink
style="color: var(--theme-color); text-decoration: none"
to="/privacy-policy"
>
{{ $t('register.privacyPolicy') }}
</RouterLink>
</ElCheckbox>
</ElFormItem>
<div style="margin-top: 15px">
<ElButton
v-ripple
class="w-full custom-height"
type="primary"
:loading="loading"
@click="register"
>
{{ $t('register.submitBtnText') }}
</ElButton>
</div>
<div class="mt-5 text-sm text-g-600">
<span>{{ $t('register.hasAccount') }}</span>
<RouterLink class="text-theme" :to="{ name: 'Login' }">
{{ $t('register.toLogin') }}
</RouterLink>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { FormInstance, FormRules } from 'element-plus';
defineOptions({ name: 'Register' });
interface RegisterForm {
username: string;
password: string;
confirmPassword: string;
agreement: boolean;
}
const USERNAME_MIN_LENGTH = 3;
const USERNAME_MAX_LENGTH = 20;
const PASSWORD_MIN_LENGTH = 6;
const REDIRECT_DELAY = 1000;
const { t, locale } = useI18n();
const router = useRouter();
const formRef = ref<FormInstance>();
const loading = ref(false);
const formKey = ref(0);
// 监听语言切换,重置表单
watch(locale, () => {
formKey.value++;
});
const formData = reactive<RegisterForm>({
username: '',
password: '',
confirmPassword: '',
agreement: false,
});
/**
* 验证密码
* 当密码输入后,如果确认密码已填写,则触发确认密码的验证
*/
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error(t('register.placeholder.password')));
return;
}
if (formData.confirmPassword) {
formRef.value?.validateField('confirmPassword');
}
callback();
};
/**
* 验证确认密码
* 检查确认密码是否与密码一致
*/
const validateConfirmPassword = (
_rule: any,
value: string,
callback: (error?: Error) => void
) => {
if (!value) {
callback(new Error(t('register.rule.confirmPasswordRequired')));
return;
}
if (value !== formData.password) {
callback(new Error(t('register.rule.passwordMismatch')));
return;
}
callback();
};
/**
* 验证用户协议
* 确保用户已勾选同意协议
*/
const validateAgreement = (_rule: any, value: boolean, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error(t('register.rule.agreementRequired')));
return;
}
callback();
};
const rules = computed<FormRules<RegisterForm>>(() => ({
username: [
{ required: true, message: t('register.placeholder.username'), trigger: 'blur' },
{
min: USERNAME_MIN_LENGTH,
max: USERNAME_MAX_LENGTH,
message: t('register.rule.usernameLength'),
trigger: 'blur',
},
],
password: [
{ required: true, validator: validatePassword, trigger: 'blur' },
{ min: PASSWORD_MIN_LENGTH, message: t('register.rule.passwordLength'), trigger: 'blur' },
],
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: 'blur' }],
agreement: [{ validator: validateAgreement, trigger: 'change' }],
}));
/**
* 注册用户
* 验证表单后提交注册请求
*/
const register = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
loading.value = true;
// TODO: 替换为真实 API 调用
// const params = {
// username: formData.username,
// password: formData.password
// }
// const res = await AuthService.register(params)
// if (res.code === ApiStatus.success) {
// ElMessage.success('注册成功')
// toLogin()
// }
// 模拟注册请求
setTimeout(() => {
loading.value = false;
ElMessage.success('注册成功');
toLogin();
}, REDIRECT_DELAY);
} catch (error) {
console.error('表单验证失败:', error);
loading.value = false;
}
};
/**
* 跳转到登录页面
*/
const toLogin = () => {
setTimeout(() => {
router.push({ name: 'Login' });
}, REDIRECT_DELAY);
};
</script>
<style scoped>
@import '../login/style.css';
</style>