mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-08 10:39:59 +00:00
feat: 重构前端项目结构并优化代码
refactor: 迁移前端资源文件至web目录 feat: 新增多种图标资源 style: 统一代码风格和格式化配置 docs: 更新README和文档说明 chore: 更新依赖和配置文件 fix: 修复部分类型定义和枚举 perf: 优化路由和组件加载逻辑
This commit is contained in:
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user