mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-08 18:49:09 +00:00
feat: 迁移前端资源文件并重构项目结构
refactor: 优化前端代码结构和资源管理 style: 调整前端代码格式和样式 chore: 更新.gitignore和构建配置 fix: 修复前端资源路径和引用问题 docs: 更新前端文档和注释 perf: 优化前端性能和加载速度 test: 更新前端测试用例 build: 调整前端构建配置 ci: 更新CI/CD配置
This commit is contained in:
@@ -1,623 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 text-center m-0 mb-20px>{{ t("login.title") }}</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.placeholder.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.placeholder.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.rememberPwd") }}</el-checkbox>
|
||||
<el-link type="primary" underline="never" @click="toOtherForm('resetPwd')">
|
||||
{{ t("login.forgetPwd") }}
|
||||
</el-link>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<el-form-item>
|
||||
<el-button :loading="loading" type="primary" class="w-full" @click="handleLoginSubmit">
|
||||
{{ t("login.btnText") }}
|
||||
</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>
|
||||
@@ -1,192 +0,0 @@
|
||||
<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.placeholder.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.placeholder.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.backLoginBtnText") }}
|
||||
</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>
|
||||
@@ -1,175 +0,0 @@
|
||||
<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.placeholder.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.placeholder.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("common.confirm") }}
|
||||
</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.backLoginBtnText") }}
|
||||
</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,36 @@
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
/** 左侧插画背景与右侧登录区的分栏比例 */
|
||||
export type LoginPanelAlign = "left" | "center" | "right";
|
||||
|
||||
const STORAGE_KEY = "login-panel-align";
|
||||
|
||||
function readInitial(): LoginPanelAlign {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v === "left" || v === "center" || v === "right") return v;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
/** 默认:插画在左、表单在右 */
|
||||
return "right";
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录页布局(本地持久化)
|
||||
* - left/right:交换插画与表单列左右位置(列宽约 65%/35%)
|
||||
* - center:隐藏插画,全宽背景 + 440px 卡片在顶栏与页脚之间居中悬浮
|
||||
*/
|
||||
export function useLoginPanelAlign() {
|
||||
const panelAlign = ref<LoginPanelAlign>(readInitial());
|
||||
|
||||
watch(panelAlign, (v) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, v);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
return { panelAlign };
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="login-container"
|
||||
:style="{
|
||||
'background-image': configStore.configData?.sys_login_background?.config_value
|
||||
? `url(${configStore.configData.sys_login_background.config_value})`
|
||||
: '/background.svg',
|
||||
}"
|
||||
>
|
||||
<!-- 右侧切换主题、语言按钮 -->
|
||||
<div class="action-bar">
|
||||
<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 flex-1 flex-center>
|
||||
<div
|
||||
class="p-4xl w-full h-auto sm:w-450px border-rd-10px sm:h-680px shadow-[var(--el-box-shadow-light)] backdrop-blur-3px"
|
||||
>
|
||||
<div w-full flex flex-col items-center>
|
||||
<!-- logo -->
|
||||
<!-- <el-image :src="logo" style="width: 84px" /> -->
|
||||
<el-image :src="configStore.configData.sys_web_logo.config_value" style="width: 140px" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<!-- 添加小图标用于显示提示信息 -->
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<el-tooltip
|
||||
:content="configStore.configData.sys_web_description.config_value"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-icon class="cursor-help"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<div class="ml-2 text-xl font-bold">
|
||||
<el-badge
|
||||
:value="`v ${configStore.configData.sys_web_version.config_value}`"
|
||||
type="success"
|
||||
>
|
||||
{{ configStore.configData.sys_web_title.config_value }}
|
||||
</el-badge>
|
||||
</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="w-90%"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 登录页底部版权 -->
|
||||
<el-text size="small" class="py-2.5! fixed bottom-0 text-center">
|
||||
<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>
|
||||
</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();
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
configStore.getConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.125rem;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
top: 10px;
|
||||
right: auto;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
top: 40px;
|
||||
right: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* fade-slide */
|
||||
.fade-slide-leave-active,
|
||||
.fade-slide-enter-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,517 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,227 @@
|
||||
<!-- 账号密码登录表单(含快捷账号、验证码、滑块) -->
|
||||
<template>
|
||||
<div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="loginForm"
|
||||
:rules="rules"
|
||||
:key="formKey"
|
||||
class="login-page-form"
|
||||
:validate-on-rule-change="false"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<ElFormItem>
|
||||
<ElSelect
|
||||
:model-value="demoAccountKey"
|
||||
class="w-full"
|
||||
:placeholder="$t('login.quickSelectAccount')"
|
||||
@update:model-value="$emit('setupAccount', $event as AccountKey)"
|
||||
>
|
||||
<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
|
||||
class="custom-height"
|
||||
v-model.trim="loginForm.username"
|
||||
clearable
|
||||
:placeholder="$t('login.placeholder.username')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><User /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
|
||||
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
class="custom-height"
|
||||
v-model.trim="loginForm.password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
clearable
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Lock /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
</ElTooltip>
|
||||
|
||||
<ElFormItem v-if="captchaState.enable" prop="captcha" class="login-captcha-row">
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
<ElInput
|
||||
v-model.trim="loginForm.captcha"
|
||||
class="custom-height flex-1"
|
||||
clearable
|
||||
:placeholder="$t('login.captchaCode')"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ArtSvgIcon
|
||||
icon="mdi:shield-lock-outline"
|
||||
class="size-[18px] text-[var(--el-text-color-secondary)]"
|
||||
/>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div
|
||||
class="login-captcha-img flex h-10 w-[100px] shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded"
|
||||
role="button"
|
||||
:title="$t('login.captchaClickHint')"
|
||||
@click="$emit('getCaptcha')"
|
||||
>
|
||||
<ElIcon v-if="codeLoading" class="is-loading" :size="20">
|
||||
<Loading />
|
||||
</ElIcon>
|
||||
<ElImage
|
||||
v-else-if="captchaState.img_base"
|
||||
class="h-full w-full object-cover"
|
||||
fit="cover"
|
||||
:src="captchaState.img_base"
|
||||
/>
|
||||
<ElText v-else type="info" size="small">
|
||||
{{ $t("login.captchaClickHint") }}
|
||||
</ElText>
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<div class="login-form-tail flex flex-col gap-[1.1rem]">
|
||||
<div class="relative pb-3">
|
||||
<div
|
||||
class="relative z-[2] overflow-hidden select-none rounded-lg border border-transparent tad-300"
|
||||
:class="{ '!border-[#FF4E4F]': !isPassing && isClickPass }"
|
||||
>
|
||||
<ArtDragVerify
|
||||
ref="dragVerifyRef"
|
||||
v-model:value="isPassing"
|
||||
:text="$t('login.sliderText')"
|
||||
:text-color="dragVerifyTextColor"
|
||||
:success-text="$t('login.sliderSuccessText')"
|
||||
progress-bar-bg="var(--el-color-primary)"
|
||||
:background="isDark ? '#26272F' : '#F1F1F4'"
|
||||
handler-bg="var(--default-box-color)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="absolute top-0 z-[1] mt-2 px-px text-xs text-[#f56c6c] tad-300"
|
||||
:class="{ 'translate-y-10': !isPassing && isClickPass }"
|
||||
>
|
||||
{{ $t("login.placeholder.slider") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="login-options-row flex-cb text-sm">
|
||||
<ElCheckbox v-model="loginForm.remember" class="login-remember">
|
||||
{{ $t("login.rememberPwd") }}
|
||||
</ElCheckbox>
|
||||
<ElLink
|
||||
type="primary"
|
||||
:underline="false"
|
||||
class="inline-flex items-center text-sm !leading-[inherit]"
|
||||
@click="$emit('forget')"
|
||||
>
|
||||
{{ $t("login.forgetPwd") }}
|
||||
</ElLink>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ElButton
|
||||
class="h-11 w-full !rounded-lg text-base font-medium"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
v-ripple
|
||||
@click="$emit('submit')"
|
||||
>
|
||||
{{ $t("login.btnText") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="login-secondary-actions grid grid-cols-2 gap-2">
|
||||
<ElButton class="login-secondary-btn" plain @click="$emit('openMobile')">
|
||||
{{ $t("login.mobileLogin") }}
|
||||
</ElButton>
|
||||
<ElButton class="login-secondary-btn" plain @click="$emit('openQr')">
|
||||
{{ $t("login.qrLogin") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<LoginThirdPartySection @oauth="$emit('oauth', $event)" />
|
||||
|
||||
<LoginAuthLinkRow
|
||||
:hint="$t('login.noAccount')"
|
||||
:link-text="$t('login.register')"
|
||||
@link="$emit('register')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Loading, Lock, User } from "@element-plus/icons-vue";
|
||||
import type { CaptchaInfo, LoginFormData } from "@/api/module_system/auth";
|
||||
import type { FormRules } from "element-plus";
|
||||
import type { Account, AccountKey } from "../types";
|
||||
import LoginAuthLinkRow from "./LoginAuthLinkRow.vue";
|
||||
import LoginThirdPartySection from "./LoginThirdPartySection.vue";
|
||||
|
||||
defineProps<{
|
||||
loginForm: LoginFormData;
|
||||
rules: FormRules;
|
||||
captchaState: CaptchaInfo;
|
||||
codeLoading: boolean;
|
||||
demoAccountKey: AccountKey;
|
||||
accounts: Account[];
|
||||
formKey: number | string;
|
||||
isDark: boolean;
|
||||
dragVerifyTextColor: string;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const isPassing = defineModel<boolean>("isPassing", { required: true });
|
||||
const isClickPass = defineModel<boolean>("isClickPass", { required: true });
|
||||
|
||||
defineEmits<{
|
||||
submit: [];
|
||||
setupAccount: [key: AccountKey];
|
||||
getCaptcha: [];
|
||||
openMobile: [];
|
||||
openQr: [];
|
||||
forget: [];
|
||||
register: [];
|
||||
oauth: [provider: "wechat" | "qq" | "github" | "gitee"];
|
||||
}>();
|
||||
|
||||
const formRef = ref();
|
||||
const dragVerifyRef = ref<{ reset?: () => void } | null>(null);
|
||||
const isCapsLock = ref(false);
|
||||
|
||||
function checkCapsLock(event: KeyboardEvent) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
isCapsLock.value = event.getModifierState("CapsLock");
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate?.(),
|
||||
clearValidate: () => formRef.value?.clearValidate?.(),
|
||||
resetDragVerify: () => dragVerifyRef.value?.reset?.(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- 登录区底部:灰色提示 + 主题链(注册 / 返回登录等) -->
|
||||
<template>
|
||||
<div
|
||||
class="login-auth-link-row mt-6 flex flex-wrap items-center justify-center gap-1 text-sm leading-normal"
|
||||
>
|
||||
<span class="inline-flex items-center text-[var(--el-text-color-secondary)]">{{ hint }}</span>
|
||||
<ElLink
|
||||
type="primary"
|
||||
:underline="false"
|
||||
class="inline-flex items-center font-medium !leading-[inherit]"
|
||||
@click="$emit('link')"
|
||||
>
|
||||
{{ linkText }}
|
||||
</ElLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ hint: string; linkText: string }>();
|
||||
defineEmits<{ link: [] }>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-auth-link-row {
|
||||
:deep(.el-link),
|
||||
:deep(.el-link__inner) {
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<!-- 重置密码 / 忘记密码 -->
|
||||
<template>
|
||||
<div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="forgetForm"
|
||||
:rules="forgetRules"
|
||||
:key="formKey"
|
||||
class="login-page-form mt-4"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<ElFormItem prop="username">
|
||||
<ElInput
|
||||
v-model.trim="forgetForm.username"
|
||||
class="custom-height"
|
||||
clearable
|
||||
:placeholder="$t('login.placeholder.username')"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><User /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
|
||||
<ElFormItem prop="new_password">
|
||||
<ElInput
|
||||
v-model.trim="forgetForm.new_password"
|
||||
class="custom-height"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="$t('login.placeholder.password')"
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Lock /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
</ElTooltip>
|
||||
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
|
||||
<ElFormItem prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model.trim="forgetForm.confirmPassword"
|
||||
class="custom-height"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="$t('login.message.password.confirm')"
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Lock /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
</ElTooltip>
|
||||
<div class="mt-6">
|
||||
<ElButton
|
||||
class="h-11 w-full min-w-0 !rounded-lg text-base font-medium"
|
||||
type="primary"
|
||||
:loading="forgetLoading"
|
||||
v-ripple
|
||||
@click="$emit('submit')"
|
||||
>
|
||||
{{ $t("common.confirm") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<LoginAuthLinkRow
|
||||
:hint="$t('login.thinkOfPasswd')"
|
||||
:link-text="$t('login.backLoginBtnText')"
|
||||
@link="$emit('toLogin')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Lock, User } from "@element-plus/icons-vue";
|
||||
import type { ForgetPasswordForm } from "@/api/module_system/user";
|
||||
import type { FormRules } from "element-plus";
|
||||
import LoginAuthLinkRow from "./LoginAuthLinkRow.vue";
|
||||
|
||||
defineProps<{
|
||||
forgetForm: ForgetPasswordForm;
|
||||
forgetRules: FormRules<ForgetPasswordForm>;
|
||||
formKey: number | string;
|
||||
forgetLoading: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ submit: []; toLogin: [] }>();
|
||||
|
||||
const formRef = ref();
|
||||
const isCapsLock = ref(false);
|
||||
|
||||
function checkCapsLock(event: KeyboardEvent) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
isCapsLock.value = event.getModifierState("CapsLock");
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate?.(),
|
||||
clearValidate: () => formRef.value?.clearValidate?.(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
</style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<!-- 手机号登录(无后端接口,演示交互) -->
|
||||
<template>
|
||||
<div>
|
||||
<div class="login-page-form login-mobile-flow">
|
||||
<div class="mb-[1.1rem]">
|
||||
<ElInput
|
||||
v-model.trim="mobileForm.phone"
|
||||
class="custom-height"
|
||||
maxlength="11"
|
||||
inputmode="numeric"
|
||||
clearable
|
||||
:placeholder="$t('login.mobilePhonePlaceholder')"
|
||||
@keyup.enter="submitMobileLogin"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Iphone /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<div class="login-mobile-code-row mb-[1.1rem] flex items-stretch gap-2 sm:gap-3">
|
||||
<div
|
||||
ref="otpWrapRef"
|
||||
class="flex min-w-0 flex-1 gap-1.5 sm:gap-2"
|
||||
@paste.prevent="onOtpPaste"
|
||||
>
|
||||
<input
|
||||
v-for="idx in otpIndices"
|
||||
:key="idx"
|
||||
:value="otpDigits[idx]"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="1"
|
||||
class="login-mobile-otp-cell"
|
||||
@input="onOtpCellInput(idx, $event)"
|
||||
@keydown="onOtpCellKeydown(idx, $event)"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
class="login-mobile-sms-btn h-10 shrink-0 self-center px-3 sm:px-4"
|
||||
plain
|
||||
:disabled="smsCountdown > 0"
|
||||
@click="sendSmsCodeMock"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : $t("login.getSmsCode") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="login-mobile-actions flex w-full min-w-0 flex-col items-stretch gap-3">
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="h-11 w-full min-w-0 !rounded-lg text-base font-medium"
|
||||
v-ripple
|
||||
@click="submitMobileLogin"
|
||||
>
|
||||
{{ $t("login.btnText") }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
class="login-secondary-btn h-11 w-full min-w-0 !rounded-lg text-base font-medium"
|
||||
plain
|
||||
@click="$emit('back')"
|
||||
>
|
||||
{{ $t("login.backToAccountLogin") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginAuthLinkRow
|
||||
:hint="$t('login.noAccount')"
|
||||
:link-text="$t('login.register')"
|
||||
@link="$emit('register')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Iphone } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import LoginAuthLinkRow from "./LoginAuthLinkRow.vue";
|
||||
|
||||
defineEmits<{ back: []; register: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const mobileForm = reactive({
|
||||
phone: "",
|
||||
});
|
||||
|
||||
const otpDigits = ref<string[]>(Array.from({ length: 6 }, () => ""));
|
||||
const otpIndices = [0, 1, 2, 3, 4, 5];
|
||||
const otpWrapRef = ref<HTMLElement | null>(null);
|
||||
const smsCountdown = ref(0);
|
||||
let smsTimerId: number | null = null;
|
||||
|
||||
function clearSmsTimer() {
|
||||
if (smsTimerId != null) {
|
||||
clearInterval(smsTimerId);
|
||||
smsTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetMobileLoginUi() {
|
||||
mobileForm.phone = "";
|
||||
otpDigits.value = Array.from({ length: 6 }, () => "");
|
||||
smsCountdown.value = 0;
|
||||
clearSmsTimer();
|
||||
}
|
||||
|
||||
defineExpose({ resetMobileLoginUi });
|
||||
|
||||
function focusOtpCell(index: number) {
|
||||
nextTick(() => {
|
||||
const root = otpWrapRef.value;
|
||||
if (!root) return;
|
||||
const inputs = root.querySelectorAll<HTMLInputElement>(".login-mobile-otp-cell");
|
||||
inputs[index]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function onOtpCellInput(index: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const digit = target.value.replace(/\D/g, "").slice(-1);
|
||||
otpDigits.value[index] = digit;
|
||||
target.value = digit;
|
||||
if (digit && index < 5) {
|
||||
focusOtpCell(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function onOtpCellKeydown(index: number, event: KeyboardEvent) {
|
||||
if (event.key === "Backspace" && !otpDigits.value[index] && index > 0) {
|
||||
event.preventDefault();
|
||||
otpDigits.value[index - 1] = "";
|
||||
focusOtpCell(index - 1);
|
||||
const root = otpWrapRef.value;
|
||||
const inputs = root?.querySelectorAll<HTMLInputElement>(".login-mobile-otp-cell");
|
||||
const prev = inputs?.[index - 1];
|
||||
if (prev) prev.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function onOtpPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData("text")?.replace(/\D/g, "").slice(0, 6) ?? "";
|
||||
if (!text) return;
|
||||
event.preventDefault();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
otpDigits.value[i] = text[i] ?? "";
|
||||
}
|
||||
nextTick(() => {
|
||||
const root = otpWrapRef.value;
|
||||
if (!root) return;
|
||||
const inputs = root.querySelectorAll<HTMLInputElement>(".login-mobile-otp-cell");
|
||||
inputs.forEach((el, i) => {
|
||||
el.value = otpDigits.value[i] ?? "";
|
||||
});
|
||||
const nextIdx = Math.min(text.length, 5);
|
||||
focusOtpCell(nextIdx);
|
||||
});
|
||||
}
|
||||
|
||||
function sendSmsCodeMock() {
|
||||
const phone = mobileForm.phone.trim();
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
ElMessage.warning(t("login.message.mobile.invalid"));
|
||||
return;
|
||||
}
|
||||
if (smsCountdown.value > 0) return;
|
||||
ElMessage.success(t("login.smsCodeSentMock"));
|
||||
smsCountdown.value = 60;
|
||||
clearSmsTimer();
|
||||
smsTimerId = window.setInterval(() => {
|
||||
smsCountdown.value--;
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearSmsTimer();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function submitMobileLogin() {
|
||||
const phone = mobileForm.phone.trim();
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
ElMessage.warning(t("login.message.mobile.invalid"));
|
||||
return;
|
||||
}
|
||||
const code = otpDigits.value.join("");
|
||||
if (code.length !== 6) {
|
||||
ElMessage.warning(t("login.smsCodeRequired"));
|
||||
return;
|
||||
}
|
||||
ElMessage.info(t("login.mobileLoginPending"));
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSmsTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../style.css";
|
||||
|
||||
.login-mobile-flow {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-mobile-otp-cell {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 48px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
text-align: center;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
}
|
||||
|
||||
.login-mobile-sms-btn {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.login-mobile-otp-cell {
|
||||
background: rgb(255 255 255 / 6%);
|
||||
border-color: rgb(255 255 255 / 14%);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* EP 全局规则:相邻 .el-button { margin-left: 12px },纵向叠放时仍会命中,导致第二颗按钮横向错位 */
|
||||
.login-mobile-actions {
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!-- 扫码登录(无后端会话,演示二维码与交互) -->
|
||||
<template>
|
||||
<div>
|
||||
<div class="login-page-form login-qr-flow flex w-full min-w-0 flex-col items-center">
|
||||
<div
|
||||
class="login-qr-card flex w-full max-w-[280px] flex-col items-center rounded-2xl bg-white p-6 shadow-[0_8px_24px_rgb(0_0_0_/12%)] dark:shadow-[0_8px_28px_rgb(0_0_0_/45%)]"
|
||||
>
|
||||
<QrcodeVue class="block" :value="qrPayload" v-bind="qrCodeRenderOptions" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="login-qr-hint mt-4 max-w-[280px] text-center text-sm leading-relaxed text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
{{ $t("login.qrLoginHint") }}
|
||||
</p>
|
||||
|
||||
<div class="login-mobile-actions mt-8 w-full min-w-0">
|
||||
<ElButton
|
||||
class="login-secondary-btn h-11 w-full min-w-0 !rounded-lg text-base font-medium"
|
||||
plain
|
||||
@click="$emit('back')"
|
||||
>
|
||||
{{ $t("login.backToAccountLogin") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginAuthLinkRow
|
||||
:hint="$t('login.noAccount')"
|
||||
:link-text="$t('login.register')"
|
||||
@link="$emit('register')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import QrcodeVue from "qrcode.vue";
|
||||
import type { Level, RenderAs } from "qrcode.vue";
|
||||
import LoginAuthLinkRow from "./LoginAuthLinkRow.vue";
|
||||
|
||||
defineEmits<{ back: []; register: [] }>();
|
||||
|
||||
/** 集中配置 + H 级纠错 + SVG(矢量清晰、易对齐容器) */
|
||||
const qrCodeRenderOptions: {
|
||||
size: number;
|
||||
level: Level;
|
||||
renderAs: RenderAs;
|
||||
margin: number;
|
||||
background: string;
|
||||
foreground: string;
|
||||
} = {
|
||||
size: 220,
|
||||
level: "H",
|
||||
renderAs: "svg",
|
||||
margin: 0,
|
||||
background: "#ffffff",
|
||||
foreground: "#000000",
|
||||
};
|
||||
|
||||
/** 演示用载荷;接入后端后替换为会话 URL / ticket */
|
||||
const qrPayload = computed(() => {
|
||||
if (typeof window === "undefined") return "https://example.com/login";
|
||||
const { origin, pathname } = window.location;
|
||||
return `${origin}${pathname}?qrLogin=demo`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../style.css";
|
||||
|
||||
.login-qr-flow {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-qr-card {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* EP:相邻按钮横向间距;单列返回按钮无需处理 */
|
||||
.login-mobile-actions {
|
||||
:deep(.el-button) {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,138 @@
|
||||
<!-- 注册表单 -->
|
||||
<template>
|
||||
<div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
:key="formKey"
|
||||
class="login-page-form"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<ElFormItem prop="username">
|
||||
<ElInput
|
||||
class="custom-height"
|
||||
v-model.trim="registerForm.username"
|
||||
clearable
|
||||
:placeholder="$t('login.placeholder.username')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><User /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
class="custom-height"
|
||||
v-model.trim="registerForm.password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="$t('login.placeholder.password')"
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Lock /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
</ElTooltip>
|
||||
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
|
||||
<ElFormItem prop="confirmPassword">
|
||||
<ElInput
|
||||
class="custom-height"
|
||||
v-model.trim="registerForm.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="$t('login.message.password.confirm')"
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="$emit('submit')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Lock /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
</ElTooltip>
|
||||
<ElFormItem>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ElCheckbox v-model="registerAgreementReadModel">
|
||||
{{ $t("login.agree") }}
|
||||
</ElCheckbox>
|
||||
<ElLink
|
||||
type="primary"
|
||||
:underline="false"
|
||||
class="text-sm font-medium"
|
||||
:href="userAgreementHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ $t("login.userAgreement") }}
|
||||
</ElLink>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<div class="mt-6">
|
||||
<ElButton
|
||||
class="h-11 w-full !rounded-lg text-base font-medium"
|
||||
type="primary"
|
||||
:loading="registerLoading"
|
||||
v-ripple
|
||||
@click="$emit('submit')"
|
||||
>
|
||||
{{ $t("login.register") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<LoginAuthLinkRow
|
||||
:hint="$t('login.haveAccount')"
|
||||
:link-text="$t('login.backLoginBtnText')"
|
||||
@link="$emit('toLogin')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Lock, User } from "@element-plus/icons-vue";
|
||||
import type { RegisterForm } from "@/api/module_system/user";
|
||||
import type { FormRules } from "element-plus";
|
||||
import LoginAuthLinkRow from "./LoginAuthLinkRow.vue";
|
||||
|
||||
defineProps<{
|
||||
registerForm: RegisterForm;
|
||||
registerRules: FormRules<RegisterForm>;
|
||||
formKey: number | string;
|
||||
registerLoading: boolean;
|
||||
userAgreementHref: string;
|
||||
}>();
|
||||
|
||||
const registerAgreementReadModel = defineModel<boolean>("registerAgreementRead", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
defineEmits<{ submit: []; toLogin: [] }>();
|
||||
|
||||
const formRef = ref();
|
||||
const isCapsLock = ref(false);
|
||||
|
||||
function checkCapsLock(event: KeyboardEvent) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
isCapsLock.value = event.getModifierState("CapsLock");
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate?.(),
|
||||
clearValidate: () => formRef.value?.clearValidate?.(),
|
||||
validateField: (prop: string | string[]) => formRef.value?.validateField?.(prop),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
</style>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<!-- 第三方 OAuth 登录(后端需配置各渠道 ClientId/Secret) -->
|
||||
<template>
|
||||
<div class="third-party-login login-third-on-dark">
|
||||
<div class="divider-container">
|
||||
<div class="divider-line" />
|
||||
<span class="divider-text">{{ $t("login.otherLoginMethods") }}</span>
|
||||
<div class="divider-line" />
|
||||
</div>
|
||||
<div class="login-third-party-icons flex w-full items-center justify-center gap-x-3 sm:gap-x-4">
|
||||
<ElTooltip
|
||||
v-for="item in oauthItems"
|
||||
:key="item.provider"
|
||||
:content="item.tip"
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="oauth-social-btn flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent transition-colors duration-200 outline-none"
|
||||
:aria-label="item.tip"
|
||||
@click="$emit('oauth', item.provider)"
|
||||
>
|
||||
<ArtSvgIcon :icon="item.icon" :class="item.iconClass" />
|
||||
</button>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OAuthProvider } from "@/api/module_system/auth";
|
||||
|
||||
defineEmits<{ oauth: [provider: OAuthProvider] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const oauthItems = computed(() => [
|
||||
{
|
||||
provider: "wechat" as const,
|
||||
tip: t("login.oauthTooltip.wechat"),
|
||||
icon: "simple-icons:wechat",
|
||||
iconClass: "size-[22px] text-[#07c160]",
|
||||
},
|
||||
{
|
||||
provider: "qq" as const,
|
||||
tip: t("login.oauthTooltip.qq"),
|
||||
icon: "simple-icons:tencentqq",
|
||||
iconClass: "size-[22px] text-[#12b7f5]",
|
||||
},
|
||||
{
|
||||
provider: "github" as const,
|
||||
tip: t("login.oauthTooltip.github"),
|
||||
icon: "mdi:github",
|
||||
iconClass: "size-[22px] text-g-800 dark:text-white/85",
|
||||
},
|
||||
{
|
||||
provider: "gitee" as const,
|
||||
tip: t("login.oauthTooltip.gitee"),
|
||||
icon: "simple-icons:gitee",
|
||||
iconClass: "size-[22px] text-[#c71d23]",
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-third-on-dark {
|
||||
margin-top: 1rem;
|
||||
|
||||
.divider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--el-border-color-lighter), transparent);
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .login-third-on-dark {
|
||||
.divider-line {
|
||||
background: linear-gradient(to right, transparent, rgb(255 255 255 / 15%), transparent);
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
color: rgb(255 255 255 / 38%);
|
||||
}
|
||||
}
|
||||
|
||||
.oauth-social-btn:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.oauth-social-btn:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
html.dark .oauth-social-btn:hover {
|
||||
background-color: rgb(255 255 255 / 10%);
|
||||
}
|
||||
</style>
|
||||
+799
@@ -0,0 +1,799 @@
|
||||
<!-- 登录页:顶栏固定;仅插画列与表单区随布局切换 -->
|
||||
<template>
|
||||
<div class="login-page-root flex h-screen w-full flex-col overflow-hidden">
|
||||
<LoginCenterBackdrop v-if="panelAlign === 'center'" viewport-fixed />
|
||||
<AuthTopBar v-model:panel-align="panelAlign" />
|
||||
|
||||
<div
|
||||
class="login-auth-split relative z-[1] flex min-h-0 flex-1 overflow-hidden"
|
||||
:class="`login-auth-split--${panelAlign}`"
|
||||
>
|
||||
<div
|
||||
v-if="panelAlign !== 'center'"
|
||||
class="login-auth-split__col login-auth-split__col--illustration"
|
||||
>
|
||||
<LoginLeftView hide-top-branding />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="login-auth-split__col login-auth-split__col--form login-page-panel relative flex min-h-0 min-w-0 flex-col"
|
||||
:class="panelAlign === 'center' ? 'bg-transparent' : 'bg-[var(--el-bg-color-page)]'"
|
||||
>
|
||||
<div
|
||||
class="login-page-panel__main relative z-[1] flex min-h-0 flex-1 flex-col overflow-hidden px-5 pb-2 pt-14 md:px-10 md:pt-[4.5rem]"
|
||||
>
|
||||
<div
|
||||
class="login-page-panel__scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-6 [-webkit-overflow-scrolling:touch]"
|
||||
:class="panelAlign === 'center' && 'login-page-panel__scroll--centered'"
|
||||
>
|
||||
<div
|
||||
class="login-panel-align-row flex w-full items-center justify-center max-sm:min-h-0"
|
||||
:class="
|
||||
panelAlign === 'center'
|
||||
? 'min-h-0 flex-1 py-4'
|
||||
: 'min-h-[min(720px,calc(100vh-13rem))]'
|
||||
"
|
||||
>
|
||||
<div class="auth-right-wrap">
|
||||
<div class="form">
|
||||
<div class="form-intro">
|
||||
<h3 class="title">{{ panelTitle }}</h3>
|
||||
<p class="sub-title">{{ panelSubTitle }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="authPanel === 'login'">
|
||||
<template v-if="loginFlowMode === 'account'">
|
||||
<LoginAccountForm
|
||||
ref="accountFormRef"
|
||||
v-model:is-passing="isPassing"
|
||||
v-model:is-click-pass="isClickPass"
|
||||
:login-form="loginForm"
|
||||
:rules="rules"
|
||||
:captcha-state="captchaState"
|
||||
:code-loading="codeLoading"
|
||||
:demo-account-key="demoAccountKey"
|
||||
:accounts="accounts"
|
||||
:form-key="formKey"
|
||||
:is-dark="isDark"
|
||||
:drag-verify-text-color="dragVerifyTextColor"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
@setup-account="setupAccount"
|
||||
@get-captcha="getCaptcha"
|
||||
@open-mobile="openMobileLogin"
|
||||
@open-qr="openQrLogin"
|
||||
@forget="setAuthPanel('forget')"
|
||||
@register="setAuthPanel('register')"
|
||||
@oauth="handleOAuthLogin"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<LoginMobilePanel
|
||||
v-else-if="loginFlowMode === 'mobile'"
|
||||
@back="backToAccountLogin"
|
||||
@register="setAuthPanel('register')"
|
||||
/>
|
||||
|
||||
<LoginQrPanel
|
||||
v-else-if="loginFlowMode === 'qr'"
|
||||
@back="backToAccountLogin"
|
||||
@register="setAuthPanel('register')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<LoginRegisterPanel
|
||||
v-else-if="authPanel === 'register'"
|
||||
ref="registerPanelRef"
|
||||
v-model:register-agreement-read="registerAgreementRead"
|
||||
:register-form="registerForm"
|
||||
:register-rules="registerRules"
|
||||
:form-key="formKey"
|
||||
:register-loading="registerLoading"
|
||||
:user-agreement-href="userAgreementHref"
|
||||
@submit="submitRegister"
|
||||
@to-login="setAuthPanel('login')"
|
||||
/>
|
||||
|
||||
<LoginForgetPanel
|
||||
v-else
|
||||
ref="forgetPanelRef"
|
||||
:forget-form="forgetForm"
|
||||
:forget-rules="forgetRules"
|
||||
:form-key="formKey"
|
||||
:forget-loading="forgetLoading"
|
||||
@submit="submitForget"
|
||||
@to-login="setAuthPanel('login')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="login-page-footer login-page-footer--pinned flex-shrink-0 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
|
||||
:class="panelAlign === 'center' && 'login-page-footer--floating-layout'"
|
||||
>
|
||||
<div class="login-footer-text text-sm">
|
||||
<a
|
||||
:href="configStore.configData?.sys_git_code?.config_value || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="login-page-footer__link"
|
||||
>
|
||||
{{ configStore.configData?.sys_web_copyright?.config_value || "" }}
|
||||
</a>
|
||||
<span class="login-page-footer__sep">|</span>
|
||||
<a
|
||||
:href="configStore.configData?.sys_help_doc?.config_value || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="login-page-footer__link"
|
||||
>
|
||||
帮助
|
||||
</a>
|
||||
<span class="login-page-footer__sep">|</span>
|
||||
<a
|
||||
:href="configStore.configData?.sys_web_privacy?.config_value || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="login-page-footer__link"
|
||||
>
|
||||
隐私
|
||||
</a>
|
||||
<span class="login-page-footer__sep">|</span>
|
||||
<a
|
||||
:href="configStore.configData?.sys_web_clause?.config_value || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="login-page-footer__link"
|
||||
>
|
||||
条款
|
||||
</a>
|
||||
<span
|
||||
v-if="configStore.configData?.sys_keep_record?.config_value"
|
||||
class="login-page-footer__record"
|
||||
>
|
||||
{{ configStore.configData.sys_keep_record.config_value }}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LocationQuery, RouteLocationRaw } from "vue-router";
|
||||
import AuthAPI, {
|
||||
type CaptchaInfo,
|
||||
type LoginFormData,
|
||||
type OAuthProvider,
|
||||
} from "@/api/module_system/auth";
|
||||
import UserAPI, { type ForgetPasswordForm, type RegisterForm } from "@/api/module_system/user";
|
||||
import { useConfigStore } from "@stores/modules/config.store";
|
||||
import { useAppStore } from "@stores/modules/app.store";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { Auth } from "@utils/auth";
|
||||
import { HttpError } from "@utils/http";
|
||||
import { startOAuthLogin } from "@utils/oauth";
|
||||
import { ElMessage, ElNotification, type FormRules } from "element-plus";
|
||||
import LoginAccountForm from "./components/LoginAccountForm.vue";
|
||||
import LoginForgetPanel from "./components/LoginForgetPanel.vue";
|
||||
import LoginMobilePanel from "./components/LoginMobilePanel.vue";
|
||||
import LoginQrPanel from "./components/LoginQrPanel.vue";
|
||||
import LoginRegisterPanel from "./components/LoginRegisterPanel.vue";
|
||||
import type { Account, AccountKey } from "./types";
|
||||
import { useLoginPanelAlign } from "../composables/useLoginPanelAlign";
|
||||
|
||||
defineOptions({ name: "Login" });
|
||||
|
||||
type AuthPanel = "login" | "register" | "forget";
|
||||
|
||||
/** 登录区内:账号密码 ↔ 手机号 ↔ 扫码(扫码 / 手机号为演示交互) */
|
||||
type LoginFlowMode = "account" | "mobile" | "qr";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const settingStore = useSettingsStore();
|
||||
const appStore = useAppStore();
|
||||
const { isDark } = storeToRefs(settingStore);
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const { panelAlign } = useLoginPanelAlign();
|
||||
|
||||
const authPanel = ref<AuthPanel>("login");
|
||||
const loginFlowMode = ref<LoginFlowMode>("account");
|
||||
|
||||
const panelTitle = computed(() => {
|
||||
if (authPanel.value === "register") return t("login.reg");
|
||||
if (authPanel.value === "forget") return t("login.resetPassword");
|
||||
if (
|
||||
authPanel.value === "login" &&
|
||||
(loginFlowMode.value === "mobile" || loginFlowMode.value === "qr")
|
||||
) {
|
||||
return t("login.qrLoginTitle");
|
||||
}
|
||||
return t("login.title");
|
||||
});
|
||||
|
||||
const panelSubTitle = computed(() => {
|
||||
if (authPanel.value === "register") return t("register.subTitle");
|
||||
if (authPanel.value === "forget") return t("forgetPassword.subTitle");
|
||||
if (authPanel.value === "login" && loginFlowMode.value === "mobile") {
|
||||
return t("login.mobileLoginSubTitle");
|
||||
}
|
||||
if (authPanel.value === "login" && loginFlowMode.value === "qr") {
|
||||
return t("login.qrLoginSubTitle");
|
||||
}
|
||||
return t("login.subTitle");
|
||||
});
|
||||
|
||||
const userAgreementHref = computed(
|
||||
() => configStore.configData?.sys_web_clause?.config_value || "#"
|
||||
);
|
||||
|
||||
function setAuthPanel(panel: AuthPanel) {
|
||||
authPanel.value = panel;
|
||||
if (panel !== "login") {
|
||||
loginFlowMode.value = "account";
|
||||
}
|
||||
nextTick(() => {
|
||||
accountFormRef.value?.clearValidate?.();
|
||||
registerPanelRef.value?.clearValidate?.();
|
||||
forgetPanelRef.value?.clearValidate?.();
|
||||
});
|
||||
}
|
||||
|
||||
function openMobileLogin() {
|
||||
loginFlowMode.value = "mobile";
|
||||
}
|
||||
|
||||
function openQrLogin() {
|
||||
loginFlowMode.value = "qr";
|
||||
}
|
||||
|
||||
function backToAccountLogin() {
|
||||
loginFlowMode.value = "account";
|
||||
nextTick(() => {
|
||||
getCaptcha();
|
||||
loginForm.captcha = "";
|
||||
accountFormRef.value?.resetDragVerify?.();
|
||||
isPassing.value = false;
|
||||
isClickPass.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleOAuthLogin(provider: OAuthProvider) {
|
||||
startOAuthLogin(provider);
|
||||
}
|
||||
|
||||
async function tryConsumeOAuthCallback() {
|
||||
const q = route.query;
|
||||
const oauthError = q.oauth_error as string | undefined;
|
||||
const access = q.access_token as string | undefined;
|
||||
const refresh = q.refresh_token as string | undefined;
|
||||
|
||||
if (!oauthError && !(access && refresh)) return;
|
||||
|
||||
const rest: Record<string, unknown> = { ...q };
|
||||
delete rest.oauth_error;
|
||||
delete rest.access_token;
|
||||
delete rest.refresh_token;
|
||||
delete rest.token_type;
|
||||
|
||||
if (oauthError) {
|
||||
ElMessage.error(decodeURIComponent(oauthError));
|
||||
await router.replace({ path: route.path, query: rest as LocationQuery });
|
||||
return;
|
||||
}
|
||||
|
||||
if (access && refresh) {
|
||||
try {
|
||||
Auth.setTokens(access, refresh, true);
|
||||
userStore.setToken(access, refresh);
|
||||
userStore.setLoginStatus(true);
|
||||
await userStore.getUserInfo();
|
||||
ElNotification({
|
||||
title: t("login.oauthNoticeTitle"),
|
||||
message: t("login.oauthLoginSuccess"),
|
||||
type: "success",
|
||||
});
|
||||
await router.replace(resolveRedirectTarget(rest as LocationQuery));
|
||||
if (settingStore.showGuide) {
|
||||
appStore.showGuide(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Login] OAuth callback:", error);
|
||||
ElMessage.error(t("login.oauthLoginFailed"));
|
||||
await router.replace({ path: route.path, query: rest as LocationQuery });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dragVerifyTextColor = computed(() =>
|
||||
isDark.value ? "rgba(255, 255, 255, 0.45)" : "var(--art-gray-700)"
|
||||
);
|
||||
const formKey = ref(0);
|
||||
|
||||
watch(locale, () => {
|
||||
formKey.value++;
|
||||
});
|
||||
|
||||
watch(authPanel, (panel) => {
|
||||
if (panel !== "login") return;
|
||||
if (loginFlowMode.value !== "account") return;
|
||||
getCaptcha();
|
||||
loginForm.captcha = "";
|
||||
accountFormRef.value?.resetDragVerify?.();
|
||||
isPassing.value = false;
|
||||
isClickPass.value = false;
|
||||
});
|
||||
|
||||
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 demoAccountKey = ref<AccountKey>("super");
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const isPassing = ref(false);
|
||||
const isClickPass = ref(false);
|
||||
|
||||
const accountFormRef = ref<InstanceType<typeof LoginAccountForm> | null>(null);
|
||||
const registerPanelRef = ref<InstanceType<typeof LoginRegisterPanel> | null>(null);
|
||||
const forgetPanelRef = ref<InstanceType<typeof LoginForgetPanel> | null>(null);
|
||||
|
||||
const loading = ref(false);
|
||||
const registerLoading = ref(false);
|
||||
const forgetLoading = ref(false);
|
||||
const codeLoading = ref(false);
|
||||
|
||||
const registerAgreementRead = ref(false);
|
||||
|
||||
const registerForm = reactive<RegisterForm>({
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const forgetForm = reactive<ForgetPasswordForm>({
|
||||
username: "",
|
||||
new_password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const validateRegisterPassword = (_rule: unknown, value: string, callback: (e?: Error) => void) => {
|
||||
if (!value) {
|
||||
callback(new Error(t("login.message.password.required")));
|
||||
return;
|
||||
}
|
||||
if (registerForm.confirmPassword) {
|
||||
registerPanelRef.value?.validateField?.("confirmPassword");
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const validateRegisterConfirm = (_rule: unknown, value: string, callback: (e?: Error) => void) => {
|
||||
if (!value) {
|
||||
callback(new Error(t("login.message.password.required")));
|
||||
return;
|
||||
}
|
||||
if (value !== registerForm.password) {
|
||||
callback(new Error(t("login.message.password.inconformity")));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const registerRules = computed<FormRules<RegisterForm>>(() => ({
|
||||
username: [{ required: true, message: t("login.message.username.required"), trigger: "blur" }],
|
||||
password: [
|
||||
{ required: true, validator: validateRegisterPassword, trigger: "blur" },
|
||||
{ min: 6, message: t("login.message.password.min"), trigger: "blur" },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: t("login.message.password.required"), trigger: "blur" },
|
||||
{ min: 6, message: t("login.message.password.min"), trigger: "blur" },
|
||||
{ validator: validateRegisterConfirm, trigger: "blur" },
|
||||
],
|
||||
}));
|
||||
|
||||
const validateForgetConfirm = (_rule: unknown, value: string, callback: (e?: Error) => void) => {
|
||||
if (!value) {
|
||||
callback(new Error(t("login.message.password.required")));
|
||||
return;
|
||||
}
|
||||
if (value !== forgetForm.new_password) {
|
||||
callback(new Error(t("login.message.password.inconformity")));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const forgetRules = computed<FormRules<ForgetPasswordForm>>(() => ({
|
||||
username: [{ required: true, message: t("login.message.username.required"), trigger: "blur" }],
|
||||
new_password: [
|
||||
{ required: true, message: t("login.message.password.required"), trigger: "blur" },
|
||||
{ min: 6, message: t("login.message.password.min"), trigger: "blur" },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: t("login.message.password.required"), trigger: "blur" },
|
||||
{ min: 6, message: t("login.message.password.min"), trigger: "blur" },
|
||||
{ validator: validateForgetConfirm, trigger: "blur" },
|
||||
],
|
||||
}));
|
||||
|
||||
const loginForm = reactive<LoginFormData>({
|
||||
username: "",
|
||||
password: "",
|
||||
captcha: "",
|
||||
captcha_key: "",
|
||||
remember: true,
|
||||
login_type: "PC端",
|
||||
});
|
||||
|
||||
const captchaState = reactive<CaptchaInfo>({
|
||||
enable: false,
|
||||
key: "",
|
||||
img_base: "",
|
||||
});
|
||||
|
||||
const rules = computed<FormRules>(() => {
|
||||
const base: FormRules = {
|
||||
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) {
|
||||
base.captcha = [
|
||||
{
|
||||
required: true,
|
||||
trigger: "blur",
|
||||
message: t("login.message.captchaCode.required"),
|
||||
},
|
||||
];
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
function setupAccount(key: AccountKey) {
|
||||
const selected = accounts.value.find((a: Account) => a.key === key);
|
||||
demoAccountKey.value = key;
|
||||
loginForm.username = selected?.username ?? "";
|
||||
loginForm.password = selected?.password ?? "";
|
||||
}
|
||||
|
||||
async function getCaptcha() {
|
||||
try {
|
||||
codeLoading.value = true;
|
||||
const response = await AuthAPI.getCaptcha();
|
||||
const data = response.data.data;
|
||||
loginForm.captcha_key = data.key;
|
||||
captchaState.img_base = data.img_base;
|
||||
captchaState.enable = data.enable;
|
||||
} catch {
|
||||
captchaState.enable = false;
|
||||
loginForm.captcha = "";
|
||||
loginForm.captcha_key = "";
|
||||
} finally {
|
||||
codeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRedirectTarget(query: LocationQuery): RouteLocationRaw {
|
||||
const defaultPath = "/";
|
||||
const rawRedirect = (query.redirect as string) || defaultPath;
|
||||
try {
|
||||
const resolved = router.resolve(rawRedirect);
|
||||
return {
|
||||
path: resolved.path,
|
||||
query: resolved.query,
|
||||
};
|
||||
} catch {
|
||||
return { path: defaultPath };
|
||||
}
|
||||
}
|
||||
|
||||
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(async () => {
|
||||
setupAccount("super");
|
||||
await configStore.getConfig(true);
|
||||
await tryConsumeOAuthCallback();
|
||||
getCaptcha();
|
||||
setTimeout(showVoteNotification, 500);
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (authPanel.value !== "login" || loginFlowMode.value !== "account") return;
|
||||
getCaptcha();
|
||||
loginForm.captcha = "";
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
notificationInstance?.close();
|
||||
notificationInstance = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (authPanel.value !== "login" || loginFlowMode.value !== "account") return;
|
||||
getCaptcha();
|
||||
loginForm.captcha = "";
|
||||
}
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!accountFormRef.value) return;
|
||||
|
||||
try {
|
||||
const valid = await accountFormRef.value.validate?.();
|
||||
if (!valid) return;
|
||||
|
||||
if (!isPassing.value) {
|
||||
isClickPass.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
await userStore.login(loginForm);
|
||||
|
||||
await router.replace(resolveRedirectTarget(route.query));
|
||||
|
||||
if (settingStore.showGuide) {
|
||||
appStore.showGuide(true);
|
||||
}
|
||||
} catch (error) {
|
||||
await getCaptcha();
|
||||
if (!(error instanceof HttpError)) {
|
||||
console.error("[Login] Unexpected error:", error);
|
||||
ElNotification({
|
||||
title: "提示",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
accountFormRef.value?.resetDragVerify?.();
|
||||
}
|
||||
};
|
||||
|
||||
async function submitRegister() {
|
||||
if (!registerAgreementRead.value) {
|
||||
ElMessage.warning(t("login.message.agree.required"));
|
||||
return;
|
||||
}
|
||||
if (!registerPanelRef.value) return;
|
||||
try {
|
||||
await registerPanelRef.value.validate?.();
|
||||
registerLoading.value = true;
|
||||
await UserAPI.registerUser(registerForm);
|
||||
loginForm.username = registerForm.username;
|
||||
loginForm.password = registerForm.password;
|
||||
registerForm.username = "";
|
||||
registerForm.password = "";
|
||||
registerForm.confirmPassword = "";
|
||||
registerAgreementRead.value = false;
|
||||
setAuthPanel("login");
|
||||
} catch (error) {
|
||||
console.error("[Login] register:", error);
|
||||
} finally {
|
||||
registerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForget() {
|
||||
if (!forgetPanelRef.value) return;
|
||||
try {
|
||||
await forgetPanelRef.value.validate?.();
|
||||
forgetLoading.value = true;
|
||||
await UserAPI.forgetPassword(forgetForm);
|
||||
loginForm.username = forgetForm.username;
|
||||
loginForm.password = forgetForm.new_password;
|
||||
forgetForm.username = "";
|
||||
forgetForm.new_password = "";
|
||||
forgetForm.confirmPassword = "";
|
||||
setAuthPanel("login");
|
||||
} catch (error) {
|
||||
console.error("[Login] forget password:", error);
|
||||
} finally {
|
||||
forgetLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "./style.css";
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page-panel {
|
||||
:deep(.btn) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .login-page-panel {
|
||||
:deep(.text-g-800) {
|
||||
color: rgb(255 255 255 / 78%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 副标题与表单块间距(login-page-form 在子组件内,须 :deep) */
|
||||
:deep(.login-page-form) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
:deep(.login-page-form .el-form-item) {
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper) {
|
||||
min-height: 42px !important;
|
||||
}
|
||||
|
||||
/* 仅账号登录底部「手机号 / 扫码」双列按钮需要 42px;手机号登录页的返回钮与主按钮同为 h-11,勿全局压高度 */
|
||||
:deep(.login-secondary-actions .login-secondary-btn) {
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
:deep(.login-page-form .el-input__wrapper) {
|
||||
background-color: rgb(255 255 255 / 6%) !important;
|
||||
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset !important;
|
||||
}
|
||||
|
||||
:deep(.login-page-form .el-input__inner) {
|
||||
color: rgb(255 255 255 / 92%);
|
||||
}
|
||||
|
||||
:deep(.login-page-form .el-input__inner::placeholder) {
|
||||
color: rgb(255 255 255 / 35%);
|
||||
}
|
||||
|
||||
:deep(.login-page-form .el-select .el-select__wrapper) {
|
||||
background-color: rgb(255 255 255 / 6%) !important;
|
||||
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset !important;
|
||||
}
|
||||
|
||||
:deep(.login-page-form .el-select__placeholder) {
|
||||
color: rgb(255 255 255 / 40%);
|
||||
}
|
||||
|
||||
:deep(.login-page-form .el-select__selected-item) {
|
||||
color: rgb(255 255 255 / 92%);
|
||||
}
|
||||
|
||||
:deep(.login-remember .el-checkbox__label) {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
:deep(.login-secondary-btn) {
|
||||
color: rgb(255 255 255 / 85%) !important;
|
||||
background: transparent !important;
|
||||
border-color: rgb(255 255 255 / 22%) !important;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary) !important;
|
||||
background: var(--el-color-primary-light-9) !important;
|
||||
border-color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.login-secondary-actions .login-secondary-btn) {
|
||||
height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-footer {
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.login-footer-text {
|
||||
line-height: 1.65;
|
||||
color: var(--el-text-color-secondary) !important;
|
||||
}
|
||||
|
||||
.login-page-footer__link {
|
||||
color: var(--el-text-color-regular);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-footer__sep {
|
||||
margin: 0 0.35rem;
|
||||
color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.login-page-footer__record {
|
||||
display: inline-block;
|
||||
margin-left: 0.35rem;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .login-page-footer {
|
||||
border-top-color: rgb(255 255 255 / 8%);
|
||||
|
||||
.login-footer-text {
|
||||
color: rgb(255 255 255 / 42%) !important;
|
||||
}
|
||||
|
||||
.login-page-footer__link {
|
||||
color: rgb(255 255 255 / 48%);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.login-page-footer__sep {
|
||||
color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
|
||||
.login-page-footer__record {
|
||||
color: rgb(255 255 255 / 35%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
/**
|
||||
* 登录根布局
|
||||
* - 顶栏 fixed 透明叠在页面上(不占一条实体顶区);仅插画列 / 表单区随布局切换
|
||||
* - left / right:插画列与表单列横向排列(约 65% / 35%)
|
||||
* - center:全宽 + 视口级背景;登录卡居中、阴影高亮
|
||||
*/
|
||||
.login-auth-split {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.login-auth-split__col--illustration {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.login-auth-split__col--form {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 横向:插画 ~65%,表单列占剩余 */
|
||||
.login-auth-split--left,
|
||||
.login-auth-split--right {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.login-auth-split--left .login-auth-split__col--illustration,
|
||||
.login-auth-split--right .login-auth-split__col--illustration {
|
||||
flex: 0 0 65%;
|
||||
}
|
||||
|
||||
.login-auth-split--left .login-auth-split__col--form,
|
||||
.login-auth-split--right .login-auth-split__col--form {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.login-auth-split--left {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
@media only screen and (width <= 1600px) {
|
||||
.login-auth-split--left .login-auth-split__col--illustration,
|
||||
.login-auth-split--right .login-auth-split__col--illustration {
|
||||
flex: 0 0 60%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 居中模式:仅一列表单壳占满屏宽 */
|
||||
.login-auth-split--center {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-auth-split--center .login-auth-split__col--form {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 顶栏与页脚之间的区域:纵向居中承载登录卡 */
|
||||
.login-auth-split--center .login-page-panel__scroll.login-page-panel__scroll--centered {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
/* 盖过 Tailwind overflow-y-auto:仅卡片内滚动,外层不出滚动条 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page-panel__scroll--centered .login-panel-align-row {
|
||||
flex: 0 1 auto;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* 悬浮卡片:居中模式尺寸与原先一致(520 宽、720/视口上限),无边框 + 主色高亮阴影 */
|
||||
.login-auth-split--center .auth-right-wrap {
|
||||
width: min(520px, calc(100vw - 2rem)) !important;
|
||||
max-width: min(520px, calc(100vw - 2rem));
|
||||
height: auto;
|
||||
max-height: calc(100dvh - 6.5rem);
|
||||
padding: 1.75rem 2rem 2rem;
|
||||
overflow: hidden auto;
|
||||
background-color: var(--el-bg-color);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 6px 20px rgb(15 23 42 / 9%),
|
||||
0 14px 36px rgb(15 23 42 / 10%),
|
||||
0 0 18px color-mix(in srgb, var(--el-color-primary) 16%, transparent),
|
||||
0 0 32px color-mix(in srgb, var(--el-color-primary) 7%, transparent);
|
||||
}
|
||||
|
||||
.login-auth-split--center .auth-right-wrap .form {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
html.dark .login-auth-split--center .auth-right-wrap {
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 8px 28px rgb(0 0 0 / 42%),
|
||||
0 16px 44px rgb(0 0 0 / 46%),
|
||||
0 0 22px color-mix(in srgb, var(--el-color-primary) 20%, transparent),
|
||||
0 0 36px color-mix(in srgb, var(--el-color-primary) 9%, transparent);
|
||||
}
|
||||
|
||||
/* 居中模式下页脚弱化,减少「分区后台」感 */
|
||||
.login-page-footer--floating-layout {
|
||||
border-top-color: color-mix(in srgb, var(--el-border-color-lighter) 55%, transparent);
|
||||
}
|
||||
|
||||
html.dark .login-page-footer--floating-layout {
|
||||
border-top-color: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
@media only screen and (width <= 1180px) {
|
||||
.login-auth-split {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.login-auth-split__col--illustration {
|
||||
flex: 0 0 auto !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-auth-split__col--form {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 窄屏横排切竖排:恢复插画;居中模式本身无插画,不受影响 */
|
||||
.login-auth-split--center .auth-right-wrap {
|
||||
width: min(520px, calc(100vw - 1.5rem)) !important;
|
||||
max-width: min(520px, calc(100vw - 1.5rem));
|
||||
max-height: calc(100dvh - 6rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* 授权页表单区域(左右分栏时) */
|
||||
.auth-right-wrap {
|
||||
@apply relative w-[440px] shrink-0 h-[720px] py-[5px] overflow-hidden
|
||||
max-sm:px-7 max-sm:w-full max-sm:h-auto max-sm:max-h-[calc(100vh-10rem)]
|
||||
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,9 @@
|
||||
export type AccountKey = "super" | "admin" | "user";
|
||||
|
||||
export interface Account {
|
||||
key: AccountKey;
|
||||
label: string;
|
||||
username: string;
|
||||
password: string;
|
||||
roles: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user