feat: 迁移前端资源文件并重构项目结构

refactor: 优化前端代码结构和资源管理

style: 调整前端代码格式和样式

chore: 更新.gitignore和构建配置

fix: 修复前端资源路径和引用问题

docs: 更新前端文档和注释

perf: 优化前端性能和加载速度

test: 更新前端测试用例

build: 调整前端构建配置

ci: 更新CI/CD配置
This commit is contained in:
zhangtao
2026-05-09 00:46:43 +08:00
parent 1bd125d99b
commit bf81480ada
967 changed files with 21018 additions and 83019 deletions
@@ -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>
@@ -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
View File
@@ -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[];
}