mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-01 16:21:20 +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[];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- 更新日志(平台右上角头像下拉入口) -->
|
||||
<template>
|
||||
<div class="mx-auto pt-5 mb-5">
|
||||
<h3 class="text-2xl font-medium text-g-900 mb-8">更新日志</h3>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
v-for="item in upgradeLogList"
|
||||
:key="item.version"
|
||||
class="art-card-sm rounded-lg p-6 transition-shadow max-md:p-4"
|
||||
>
|
||||
<div class="flex-cb gap-3 mb-4 flex-wrap">
|
||||
<span class="px-3 py-1 bg-theme/10 text-theme text-sm font-medium rounded-full">
|
||||
{{ item.version }}
|
||||
</span>
|
||||
<span class="text-sm text-g-500">{{ item.date }}</span>
|
||||
</div>
|
||||
|
||||
<h4 class="text-lg font-medium text-g-900 mb-3">{{ item.title }}</h4>
|
||||
|
||||
<ul v-if="item.detail?.length" class="space-y-2 mb-4">
|
||||
<li
|
||||
v-for="(detail, index) in item.detail"
|
||||
:key="index"
|
||||
class="flex-c gap-2 text-sm text-g-600"
|
||||
>
|
||||
<span class="mt-0.5">•</span>
|
||||
<span class="text-g-700">{{ detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="item.remark" class="text-sm text-g-800 bg-g-300/60 rounded p-3 mb-3">
|
||||
{{ item.remark }}
|
||||
</div>
|
||||
|
||||
<ElTag v-if="item.requireReLogin" type="warning" size="small">需要重新登录</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { upgradeLogList } from "@/mock/upgrade/changeLog";
|
||||
|
||||
defineOptions({ name: "SystemChangeLog" });
|
||||
</script>
|
||||
@@ -1,389 +1,365 @@
|
||||
<!-- 部门配置 -->
|
||||
<!-- 部门配置:Art + 树形表格(对齐 system/menu 模版) -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
<div class="art-full-height">
|
||||
<ArtSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="deptSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:dept:create']"
|
||||
:perm-delete="['module_system:dept:delete']"
|
||||
:perm-patch="['module_system:dept:patch']"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
|
||||
</div>
|
||||
</template>
|
||||
<ElCard class="art-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
|
||||
<ArtTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="loadDeptData"
|
||||
>
|
||||
<template #left>
|
||||
<div class="inline-flex flex-wrap items-center gap-2">
|
||||
<ArtTableHeaderLeft
|
||||
:remove-ids="selectedIds"
|
||||
:perm-create="['module_system:dept:create']"
|
||||
:perm-delete="['module_system:dept:delete']"
|
||||
:perm-patch="['module_system:dept:patch']"
|
||||
:delete-loading="batchDeleting"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="handleBatchDelete"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<ElButton @click="toggleExpand">{{ isExpanded ? "收起" : "展开" }}</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
height="100%"
|
||||
border
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'selection')?.show"
|
||||
type="selection"
|
||||
min-width="55"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'index')?.show"
|
||||
type="index"
|
||||
fixed
|
||||
label="序号"
|
||||
min-width="60"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'name')?.show"
|
||||
key="name"
|
||||
label="部门名称"
|
||||
prop="name"
|
||||
min-width="120"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'code')?.show"
|
||||
key="code"
|
||||
label="部门编码"
|
||||
prop="code"
|
||||
min-width="120"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'status')?.show"
|
||||
key="status"
|
||||
label="状态"
|
||||
prop="status"
|
||||
min-width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'order')?.show"
|
||||
key="order"
|
||||
label="排序"
|
||||
prop="order"
|
||||
min-width="60"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'description')?.show"
|
||||
key="description"
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="100"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
|
||||
key="created_time"
|
||||
label="创建时间"
|
||||
prop="created_time"
|
||||
min-width="120"
|
||||
sortable
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_time')?.show"
|
||||
key="updated_time"
|
||||
label="更新时间"
|
||||
prop="updated_time"
|
||||
min-width="120"
|
||||
sortable
|
||||
/>
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
:default-expand-all="false"
|
||||
@selection-change="onTableSelectionChange"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'operation')?.show"
|
||||
fixed="right"
|
||||
label="操作"
|
||||
align="center"
|
||||
min-width="200"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dept:create']"
|
||||
type="success"
|
||||
size="small"
|
||||
link
|
||||
icon="plus"
|
||||
@click="handleOpenDialog('create', undefined, scope.row.id)"
|
||||
>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dept:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dept:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dept:delete']"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
@click="handleRowDelete(scope.row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</PageContent>
|
||||
|
||||
<EnhancedDialog
|
||||
<ArtDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="640px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="4" border>
|
||||
<el-descriptions-item label="部门名称" :span="2">
|
||||
{{ detailFormData.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="部门编码" :span="2">
|
||||
{{ detailFormData.code }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上级部门" :span="2">
|
||||
{{ detailFormData.parent_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="detailFormData.status ? 'success' : 'danger'">
|
||||
{{ detailFormData.status ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序" :span="2">
|
||||
{{ detailFormData.order }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
{{ detailFormData.created_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ detailFormData.updated_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="4">
|
||||
{{ detailFormData.description }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<ElScrollbar max-height="75vh" :view-style="{ overflowX: 'hidden' }">
|
||||
<ElDescriptions :column="4" border>
|
||||
<ElDescriptionsItem label="部门名称" :span="2">
|
||||
{{ detailFormData.name }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="部门编码" :span="2">
|
||||
{{ detailFormData.code }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="上级部门" :span="2">
|
||||
{{ detailFormData.parent_name }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="状态" :span="2">
|
||||
<ElTag :type="detailFormData.status === '0' ? 'success' : 'danger'">
|
||||
{{ detailFormData.status === "0" ? "启用" : "停用" }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="排序" :span="2">
|
||||
{{ detailFormData.order }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="创建时间" :span="2">
|
||||
{{ detailFormData.created_time }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="更新时间" :span="2">
|
||||
{{ detailFormData.updated_time }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="描述" :span="4">
|
||||
{{ detailFormData.description }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElScrollbar>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form
|
||||
ref="dataFormRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
label-width="auto"
|
||||
label-position="right"
|
||||
>
|
||||
<el-form-item label="部门名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入部门名称" :maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门编码" prop="code">
|
||||
<el-input
|
||||
v-model="formData.code"
|
||||
placeholder="字母开头,2-16位字母/数字/下划线"
|
||||
:maxlength="16"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上级部门" prop="parent_id">
|
||||
<el-tree-select
|
||||
v-model="formData.parent_id"
|
||||
placeholder="请选择上级部门"
|
||||
:data="deptOptions"
|
||||
filterable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="order">
|
||||
<el-input-number
|
||||
v-model="formData.order"
|
||||
controls-position="right"
|
||||
:min="1"
|
||||
:max="999"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio value="0">启用</el-radio>
|
||||
<el-radio value="1">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
:rows="4"
|
||||
:maxlength="100"
|
||||
show-word-limit
|
||||
type="textarea"
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<ElScrollbar max-height="75vh" :view-style="{ overflowX: 'hidden' }">
|
||||
<ArtForm
|
||||
:key="deptFormRenderKey"
|
||||
ref="dataFormRef"
|
||||
v-model="formData"
|
||||
:items="deptDialogFormItems"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
:label-width="'auto'"
|
||||
label-position="right"
|
||||
:span="24"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="crud-dialog-art-form"
|
||||
>
|
||||
<template #status>
|
||||
<ElRadioGroup v-model="formData.status">
|
||||
<ElRadio value="0">启用</ElRadio>
|
||||
<ElRadio value="1">停用</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</template>
|
||||
</ArtForm>
|
||||
</ElScrollbar>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
|
||||
<div class="dialog-footer" style="padding-right: var(--el-dialog-padding-primary)">
|
||||
<ElButton @click="handleCloseDialog">取消</ElButton>
|
||||
<ElButton v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
</ElButton>
|
||||
<ElButton v-else type="primary" @click="handleCloseDialog">确定</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
</ArtDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, ref, reactive, computed, nextTick, onMounted } from "vue";
|
||||
import { useTableColumns } from "@/hooks/core/useTableColumns";
|
||||
import DeptAPI, {
|
||||
type DeptForm,
|
||||
type DeptPageQuery,
|
||||
type DeptTable,
|
||||
} from "@/api/module_system/dept";
|
||||
import ArtTable from "@/components/Core/tables/art-table/index.vue";
|
||||
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
|
||||
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
|
||||
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
|
||||
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
|
||||
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
|
||||
import ArtForm from "@/components/Core/forms/art-form/index.vue";
|
||||
import type { FormItem } from "@/components/Core/forms/art-form/index.vue";
|
||||
import { ElMessage, ElMessageBox, ElTag } from "element-plus";
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
import { useUserStore } from "@stores";
|
||||
import { formatTree } from "@utils/common";
|
||||
import { renderTableOperationCell, type TableOperationAction } from "@utils/table";
|
||||
|
||||
defineOptions({
|
||||
name: "Dept",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import DeptAPI, { DeptTable, DeptForm, DeptPageQuery } from "@/api/module_system/dept";
|
||||
import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
|
||||
import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
|
||||
import PageSearch from "@/components/CURD/PageSearch.vue";
|
||||
import PageContent from "@/components/CURD/PageContent.vue";
|
||||
import EnhancedDialog from "@/components/CURD/EnhancedDialog.vue";
|
||||
import type { ISearchConfig, IContentConfig } from "@/components/CURD/types";
|
||||
import { useCrudList } from "@/components/CURD/useCrudList";
|
||||
import { useUserStore } from "@/store";
|
||||
import { formatTree } from "@/utils/common";
|
||||
import { ref, reactive } from "vue";
|
||||
const { hasAuth } = useAuth();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
type DeptSearchForm = {
|
||||
name?: string;
|
||||
status?: string;
|
||||
created_time?: string[];
|
||||
};
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: "module_system:dept",
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: "auto" },
|
||||
formItems: [
|
||||
function buildDeptQuery(p: DeptSearchForm): DeptPageQuery {
|
||||
return {
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
created_time:
|
||||
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeptRowActions(
|
||||
row: DeptTable,
|
||||
ctx: {
|
||||
onAddChild: (parentId: number) => void;
|
||||
onDetail: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
): TableOperationAction[] {
|
||||
const all: TableOperationAction[] = [
|
||||
{
|
||||
prop: "name",
|
||||
label: "部门名称",
|
||||
type: "input",
|
||||
attrs: { placeholder: "请输入部门名称", clearable: true },
|
||||
key: "add",
|
||||
label: "新增",
|
||||
artType: "add",
|
||||
perm: "module_system:dept:create",
|
||||
run: () => ctx.onAddChild(row.id!),
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "启用", value: "0" },
|
||||
{ label: "停用", value: "1" },
|
||||
],
|
||||
attrs: { placeholder: "请选择状态", clearable: true, style: { width: "167.5px" } },
|
||||
key: "detail",
|
||||
label: "详情",
|
||||
artType: "view",
|
||||
perm: "module_system:dept:detail",
|
||||
run: () => ctx.onDetail(row.id!),
|
||||
},
|
||||
{
|
||||
prop: "created_time",
|
||||
label: "创建时间",
|
||||
type: "date-picker",
|
||||
attrs: {
|
||||
type: "datetimerange",
|
||||
rangeSeparator: "至",
|
||||
startPlaceholder: "开始日期",
|
||||
endPlaceholder: "结束日期",
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
valueFormat: "YYYY-MM-DD HH:mm:ss",
|
||||
style: { width: "340px" },
|
||||
},
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
artType: "edit",
|
||||
perm: "module_system:dept:update",
|
||||
run: () => ctx.onEdit(row.id!),
|
||||
},
|
||||
],
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
perm: "module_system:dept:delete",
|
||||
run: () => ctx.onDelete(row.id!),
|
||||
},
|
||||
];
|
||||
return all.filter((a) => a.perm != null && hasAuth(a.perm));
|
||||
}
|
||||
|
||||
function formatDeptOperationCell(row: DeptTable, ctx: Parameters<typeof buildDeptRowActions>[1]) {
|
||||
const actions = buildDeptRowActions(row, ctx);
|
||||
return renderTableOperationCell(actions, {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 dept-table-actions",
|
||||
});
|
||||
}
|
||||
|
||||
const searchForm = ref<DeptSearchForm>({
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
created_time: undefined,
|
||||
});
|
||||
|
||||
const contentCols = reactive<
|
||||
Array<{
|
||||
prop?: string;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
}>
|
||||
>([
|
||||
{ prop: "selection", label: "选择框", show: true },
|
||||
{ prop: "index", label: "序号", show: true },
|
||||
{ prop: "name", label: "部门名称", show: true },
|
||||
{ prop: "code", label: "部门编码", show: true },
|
||||
{ prop: "order", label: "排序", show: true },
|
||||
{ prop: "status", label: "状态", show: true },
|
||||
{ prop: "description", label: "描述", show: true },
|
||||
{ prop: "created_time", label: "创建时间", show: true },
|
||||
{ prop: "updated_time", label: "更新时间", show: true },
|
||||
{ prop: "operation", label: "操作", show: true },
|
||||
const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof ArtSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const statusOptions = ref([
|
||||
{ label: "启用", value: "0" },
|
||||
{ label: "停用", value: "1" },
|
||||
]);
|
||||
|
||||
const contentConfig = reactive<IContentConfig<DeptPageQuery>>({
|
||||
permPrefix: "module_system:dept",
|
||||
pk: "id",
|
||||
cols: contentCols as IContentConfig["cols"],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ["refresh", "filter"],
|
||||
pagination: false,
|
||||
indexAction: async (params) => {
|
||||
const res = await DeptAPI.listDept(params as DeptPageQuery);
|
||||
const tree = res.data.data || [];
|
||||
deptOptions.value = formatTree(tree);
|
||||
return tree;
|
||||
const deptSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "部门名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "请输入部门名称",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await DeptAPI.deleteDept(
|
||||
ids
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
options: statusOptions.value,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: "警告",
|
||||
message: "确认删除该项数据?",
|
||||
type: "warning",
|
||||
{
|
||||
label: "创建时间",
|
||||
key: "created_time",
|
||||
type: "datetimerange",
|
||||
span: 6,
|
||||
props: {
|
||||
type: "datetimerange",
|
||||
rangeSeparator: "至",
|
||||
startPlaceholder: "开始日期",
|
||||
endPlaceholder: "结束日期",
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
valueFormat: "YYYY-MM-DD HH:mm:ss",
|
||||
style: { width: "100%" },
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const tableRef = ref<{
|
||||
elTableRef?: { toggleRowExpansion: (row: DeptTable, expanded?: boolean) => void };
|
||||
} | null>(null);
|
||||
const tableData = ref<DeptTable[]>([]);
|
||||
const loading = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
const selectedRows = ref<DeptTable[]>([]);
|
||||
const selectedIds = computed(() =>
|
||||
selectedRows.value.map((r) => r.id).filter((id): id is number => id != null && !Number.isNaN(id))
|
||||
);
|
||||
const batchDeleting = ref(false);
|
||||
const deptOptions = ref<OptionType[]>([]);
|
||||
|
||||
function onTableSelectionChange(rows: DeptTable[]) {
|
||||
selectedRows.value = rows;
|
||||
}
|
||||
|
||||
async function loadDeptData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await DeptAPI.listDept(buildDeptQuery(searchForm.value));
|
||||
const tree = res.data.data || [];
|
||||
tableData.value = tree;
|
||||
deptOptions.value = formatTree(tree);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteDeptRow(id: number) {
|
||||
ElMessageBox.confirm("确认删除该项数据?", "警告", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
await DeptAPI.deleteDept([id]);
|
||||
await userStore.getUserInfo();
|
||||
ElMessage.success("删除成功");
|
||||
selectedRows.value = [];
|
||||
await loadDeptData();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const opCtx = {
|
||||
onAddChild: (parentId: number) => void handleOpenDialog("create", undefined, parentId),
|
||||
onDetail: (id: number) => void handleOpenDialog("detail", id),
|
||||
onEdit: (id: number) => void handleOpenDialog("update", id),
|
||||
onDelete: deleteDeptRow,
|
||||
};
|
||||
|
||||
const { columnChecks, columns } = useTableColumns<DeptTable>(() => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ prop: "name", label: "部门名称", minWidth: 120, showOverflowTooltip: true },
|
||||
{ prop: "code", label: "部门编码", minWidth: 120, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 88,
|
||||
formatter: (row: DeptTable) =>
|
||||
h(ElTag, { type: row.status === "0" ? "success" : "danger" }, () =>
|
||||
row.status === "0" ? "启用" : "停用"
|
||||
),
|
||||
},
|
||||
{ prop: "order", label: "排序", width: 88, showOverflowTooltip: true },
|
||||
{ prop: "description", label: "描述", minWidth: 100, showOverflowTooltip: true },
|
||||
{ prop: "created_time", label: "创建时间", width: 168, showOverflowTooltip: true },
|
||||
{ prop: "updated_time", label: "更新时间", width: 168, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 220,
|
||||
fixed: "right",
|
||||
align: "right",
|
||||
formatter: (row: DeptTable) => formatDeptOperationCell(row, opCtx),
|
||||
},
|
||||
]);
|
||||
|
||||
const detailFormData = ref<DeptTable>({ code: "" });
|
||||
|
||||
const formData = reactive<DeptForm>({
|
||||
@@ -428,15 +404,92 @@ const initialFormData: DeptForm = {
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
const dataFormRef = ref<InstanceType<typeof ArtForm> | null>(null);
|
||||
const deptFormRenderKey = ref(0);
|
||||
|
||||
const deptDialogFormItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
label: "部门名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "请输入部门名称", maxlength: 50 },
|
||||
},
|
||||
{
|
||||
label: "部门编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
placeholder: "字母开头,2-16位字母/数字/下划线",
|
||||
maxlength: 16,
|
||||
showWordLimit: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "上级部门",
|
||||
key: "parent_id",
|
||||
type: "treeselect",
|
||||
span: 24,
|
||||
props: {
|
||||
placeholder: "请选择上级部门",
|
||||
data: deptOptions.value,
|
||||
filterable: true,
|
||||
checkStrictly: true,
|
||||
renderAfterExpand: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "排序",
|
||||
key: "order",
|
||||
type: "number",
|
||||
span: 24,
|
||||
props: {
|
||||
controlsPosition: "right",
|
||||
min: 1,
|
||||
max: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "描述",
|
||||
key: "description",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
type: "textarea",
|
||||
rows: 4,
|
||||
maxlength: 100,
|
||||
showWordLimit: true,
|
||||
placeholder: "请输入描述",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
async function handleSearchBarSearch(params: DeptSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
searchForm.value = { ...params };
|
||||
await loadDeptData();
|
||||
}
|
||||
|
||||
function onResetSearch() {
|
||||
searchForm.value = {
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
created_time: undefined,
|
||||
};
|
||||
void loadDeptData();
|
||||
}
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
dataFormRef.value?.ref?.resetFields();
|
||||
dataFormRef.value?.ref?.clearValidate();
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
@@ -455,46 +508,68 @@ async function handleOpenDialog(
|
||||
const response = await DeptAPI.detailDept(id);
|
||||
if (type === "detail") {
|
||||
dialogVisible.title = "部门详情";
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
Object.assign(detailFormData.value, response.data.data ?? {});
|
||||
} else if (type === "update") {
|
||||
dialogVisible.title = "修改部门";
|
||||
Object.assign(formData, response.data.data);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = "新增部门";
|
||||
Object.assign(formData, initialFormData);
|
||||
formData.id = undefined;
|
||||
if (parentId) {
|
||||
formData.parent_id = parentId;
|
||||
}
|
||||
}
|
||||
deptFormRenderKey.value += 1;
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await DeptAPI.updateDept(id, { id, ...formData });
|
||||
} else {
|
||||
await DeptAPI.createDept(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
dataFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (!valid) return;
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await DeptAPI.updateDept(id, { id, ...formData });
|
||||
} else {
|
||||
await DeptAPI.createDept(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
await loadDeptData();
|
||||
await userStore.getUserInfo();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMoreClick(status: string) {
|
||||
const rows = contentRef.value?.getSelectionData() as DeptTable[] | undefined;
|
||||
const ids = (rows ?? []).map((r) => r.id).filter((id): id is number => id != null);
|
||||
function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条数据吗?`, "批量删除", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
batchDeleting.value = true;
|
||||
await DeptAPI.deleteDept(ids);
|
||||
await userStore.getUserInfo();
|
||||
ElMessage.success("删除成功");
|
||||
selectedRows.value = [];
|
||||
await loadDeptData();
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function handleMoreClick(status: string) {
|
||||
const ids = selectedIds.value;
|
||||
if (!ids.length) {
|
||||
ElMessage.warning("请先选择要操作的数据");
|
||||
return;
|
||||
@@ -507,15 +582,51 @@ async function handleMoreClick(status: string) {
|
||||
.then(async () => {
|
||||
try {
|
||||
await DeptAPI.batchDept({ ids, status });
|
||||
refreshList();
|
||||
const userStore = useUserStore();
|
||||
await loadDeptData();
|
||||
await userStore.getUserInfo();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
nextTick(() => {
|
||||
const el = tableRef.value?.elTableRef;
|
||||
if (!el || !tableData.value.length) return;
|
||||
const walk = (rows: DeptTable[]) => {
|
||||
rows.forEach((row) => {
|
||||
if (row.children?.length) {
|
||||
el.toggleRowExpansion(row, isExpanded.value);
|
||||
walk(row.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(tableData.value);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDeptData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.art-table-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.crud-dialog-art-form :deep(.el-row > .el-col:last-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crud-dialog-art-form :deep(.el-form-item__content) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.dept-table-actions .inline-flex) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<EnhancedDrawer
|
||||
<ArtDrawer
|
||||
v-model="drawerVisible"
|
||||
title="配置中心"
|
||||
:size="drawerSize"
|
||||
destroy-on-close
|
||||
@close="onDrawerClosed"
|
||||
>
|
||||
<el-tabs v-model="activeTabRef" type="border-card">
|
||||
<ElTabs v-model="activeTabRef" type="border-card">
|
||||
<!-- 网站配置 -->
|
||||
<el-tab-pane label="网站配置" name="website">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<ElTabPane label="网站配置" name="website">
|
||||
<ElForm :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>网站配置</el-divider>
|
||||
<ElDivider>网站配置</ElDivider>
|
||||
<div v-for="(item, key) in systemConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<span class="flex items-center gap-2 w-full">
|
||||
<el-input
|
||||
<ElInput
|
||||
v-model="item.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
@@ -23,13 +23,13 @@
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</span>
|
||||
</el-form-item>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<!-- logo配置 -->
|
||||
<el-divider>网站图标</el-divider>
|
||||
<ElDivider>网站图标</ElDivider>
|
||||
<div v-for="(item, key) in logoConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<SingleImageUpload
|
||||
v-model="item.config_value"
|
||||
@@ -38,22 +38,24 @@
|
||||
:max-file-size="item.maxFileSize"
|
||||
:show-tip="true"
|
||||
:enable-preview="true"
|
||||
:enable-crop="true"
|
||||
v-bind="logoCropBind(String(key))"
|
||||
@success="(fileInfo: UploadFilePath) => handleUploadSuccess(fileInfo, key)"
|
||||
@error="handleUploadError"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="安全隐私" name="securityPrivacy">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="安全隐私" name="securityPrivacy">
|
||||
<ElForm :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>安全隐私</el-divider>
|
||||
<ElDivider>安全隐私</ElDivider>
|
||||
<div v-for="(item, key) in securityPrivacyConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<span class="flex items-center gap-2 w-full">
|
||||
<el-input
|
||||
<ElInput
|
||||
v-model="item.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
@@ -61,18 +63,18 @@
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</span>
|
||||
</el-form-item>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="用户协议" name="userAgreement">
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="用户协议" name="userAgreement">
|
||||
<!-- 系统配置 -->
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<el-divider>用户协议</el-divider>
|
||||
<ElForm :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<ElDivider>用户协议</ElDivider>
|
||||
<div v-for="(item, key) in userAgreementConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<span class="flex items-center gap-2 w-full">
|
||||
<el-input
|
||||
<ElInput
|
||||
v-model="item.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
@@ -80,23 +82,23 @@
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</span>
|
||||
</el-form-item>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="接口白名单" name="apiWhitelist">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="接口白名单" name="apiWhitelist">
|
||||
<ElForm :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>接口白名单</el-divider>
|
||||
<ElDivider>接口白名单</ElDivider>
|
||||
<div v-for="(item, key) in apiWhitelistConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="listItem in apiWhitelistItems"
|
||||
:key="listItem.id"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<el-input
|
||||
<ElInput
|
||||
v-model="listItem.value"
|
||||
:placeholder="'/api/v1/users/get'"
|
||||
clearable
|
||||
@@ -109,7 +111,7 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<el-button
|
||||
<ElButton
|
||||
type="danger"
|
||||
icon="minus"
|
||||
circle
|
||||
@@ -117,7 +119,7 @@
|
||||
@click="removeApiWhitelistItem(listItem.id)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
<ElButton
|
||||
type="primary"
|
||||
icon="plus"
|
||||
size="small"
|
||||
@@ -125,28 +127,28 @@
|
||||
@click="addApiWhitelistItem"
|
||||
>
|
||||
添加接口路径
|
||||
</el-button>
|
||||
</ElButton>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
配置说明:添加到白名单的接口路径无需登录即可访问,支持完整路径配置。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="IP黑名单" name="ipBlacklist">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="IP黑名单" name="ipBlacklist">
|
||||
<ElForm :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>IP黑名单</el-divider>
|
||||
<ElDivider>IP黑名单</ElDivider>
|
||||
<div v-for="(item, key) in ipBlacklistConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="listItem in ipBlacklistItems"
|
||||
:key="listItem.id"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<el-input
|
||||
<ElInput
|
||||
v-model="listItem.value"
|
||||
:placeholder="'192.168.1.1'"
|
||||
clearable
|
||||
@@ -160,7 +162,7 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<el-button
|
||||
<ElButton
|
||||
type="danger"
|
||||
icon="minus"
|
||||
circle
|
||||
@@ -168,7 +170,7 @@
|
||||
@click="removeIpBlacklistItem(listItem.id)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
<ElButton
|
||||
type="primary"
|
||||
icon="plus"
|
||||
size="small"
|
||||
@@ -176,24 +178,24 @@
|
||||
@click="addIpBlacklistItem"
|
||||
>
|
||||
添加IP地址
|
||||
</el-button>
|
||||
</ElButton>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
配置说明:添加到黑名单的IP地址将无法访问系统,支持单个IP配置。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="演示环境配置" name="demo">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="演示环境配置" name="demo">
|
||||
<ElForm :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>演示环境配置</el-divider>
|
||||
<ElDivider>演示环境配置</ElDivider>
|
||||
<div v-for="(item, key) in demoConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<ElFormItem :label="item.config_name">
|
||||
<!-- 演示模式开关 -->
|
||||
<template v-if="key === 'demo_enable'">
|
||||
<el-switch
|
||||
<ElSwitch
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
@@ -217,7 +219,7 @@
|
||||
:key="listItem.id"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<el-input
|
||||
<ElInput
|
||||
v-model="listItem.value"
|
||||
:placeholder="'192.168.1.1'"
|
||||
clearable
|
||||
@@ -231,7 +233,7 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<el-button
|
||||
<ElButton
|
||||
type="danger"
|
||||
icon="minus"
|
||||
circle
|
||||
@@ -239,7 +241,7 @@
|
||||
@click="removeDemoIpWhitelistItem(listItem.id)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
<ElButton
|
||||
type="primary"
|
||||
icon="plus"
|
||||
size="small"
|
||||
@@ -247,7 +249,7 @@
|
||||
@click="addDemoIpWhitelistItem"
|
||||
>
|
||||
添加IP地址
|
||||
</el-button>
|
||||
</ElButton>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
配置说明:演示模式下,只有白名单中的IP地址可以访问系统,支持单个IP配置。
|
||||
</div>
|
||||
@@ -255,7 +257,7 @@
|
||||
</template>
|
||||
<!-- 其他配置项 -->
|
||||
<template v-else>
|
||||
<el-input
|
||||
<ElInput
|
||||
v-model="item.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
@@ -263,34 +265,34 @@
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
<template #footer>
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
<ElButton @click="handleCloseDialog">取消</ElButton>
|
||||
<ElButton
|
||||
v-hasPerm="['module_system:config:update']"
|
||||
type="primary"
|
||||
:disabled="!hasChanges"
|
||||
@click="submitChanges"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
</ElButton>
|
||||
</template>
|
||||
</EnhancedDrawer>
|
||||
</ArtDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted, computed } from "vue";
|
||||
import ParamsAPI, { type ConfigTable } from "@/api/module_system/params";
|
||||
import { useConfigStore } from "@/store";
|
||||
import { useConfigStore } from "@stores";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import EnhancedDrawer from "@/components/CURD/EnhancedDrawer.vue";
|
||||
import ArtDrawer from "@/components/Core/modal/art-drawer/index.vue";
|
||||
import SingleImageUpload from "@/components/Upload/SingleImageUpload.vue";
|
||||
import { useAppStore } from "@/store/modules/app.store";
|
||||
import { useAppStore } from "@stores/modules/app.store";
|
||||
import { DeviceEnum } from "@/enums/settings/device.enum";
|
||||
|
||||
// 定义列表项类型
|
||||
@@ -322,6 +324,45 @@ const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "60%
|
||||
|
||||
const t = useI18n().t;
|
||||
const configStore = useConfigStore();
|
||||
|
||||
/** 网站图标裁剪:favicon 方形、Logo 横版、登录背景 16:9 */
|
||||
function logoCropBind(key: string) {
|
||||
switch (key) {
|
||||
case "sys_web_favicon":
|
||||
return {
|
||||
cropCutWidth: 64,
|
||||
cropCutHeight: 64,
|
||||
cropBoxWidth: 380,
|
||||
cropBoxHeight: 320,
|
||||
cropDialogTitle: "裁剪网站图标",
|
||||
cropInnerTitle: "调整图标",
|
||||
cropPreviewTitle: "预览",
|
||||
};
|
||||
case "sys_web_logo":
|
||||
return {
|
||||
cropCutWidth: 320,
|
||||
cropCutHeight: 96,
|
||||
cropBoxWidth: 520,
|
||||
cropBoxHeight: 360,
|
||||
cropDialogTitle: "裁剪站点 Logo",
|
||||
cropInnerTitle: "调整 Logo",
|
||||
cropPreviewTitle: "预览",
|
||||
};
|
||||
case "sys_login_background":
|
||||
return {
|
||||
cropCutWidth: 960,
|
||||
cropCutHeight: 540,
|
||||
cropBoxWidth: 560,
|
||||
cropBoxHeight: 380,
|
||||
cropDialogTitle: "裁剪登录背景",
|
||||
cropInnerTitle: "调整背景图",
|
||||
cropPreviewTitle: "预览",
|
||||
cropFileType: "jpeg" as const,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const activeTabRef = ref("website");
|
||||
|
||||
// 与父组件的 v-model 同步
|
||||
@@ -701,15 +742,19 @@ onMounted(() => {
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
<!-- 角色授权 -->
|
||||
<template>
|
||||
<EnhancedDrawer
|
||||
<ArtDrawer
|
||||
v-model="drawerVisible"
|
||||
:title="'【' + props.roleName + '】权限分配'"
|
||||
:size="drawerSize"
|
||||
@@ -8,9 +8,9 @@
|
||||
@close="handleCancel"
|
||||
>
|
||||
<div class="drawer-perm-content flex flex-col min-h-0 flex-1 overflow-hidden">
|
||||
<el-container class="h-full min-h-0 flex-1">
|
||||
<ElContainer class="h-full min-h-0 flex-1">
|
||||
<!-- 数据权限 -->
|
||||
<el-aside>
|
||||
<ElAside>
|
||||
<div
|
||||
class="border-r-1 border-r-[var(--el-border-color-lighter)] b-r-solid h-[100%] p-[20px] box-border"
|
||||
>
|
||||
@@ -19,36 +19,36 @@
|
||||
<div style="width: 10px; background-color: var(--el-color-primary)"></div>
|
||||
<div>
|
||||
<span style="font-size: 16px">数据授权</span>
|
||||
<el-tooltip placement="right">
|
||||
<ElTooltip placement="right">
|
||||
<template #content>
|
||||
<span>授权用户可操作的数据范围</span>
|
||||
</template>
|
||||
<el-icon class="ml-1 inline-block cursor-pointer">
|
||||
<ElIcon class="ml-1 inline-block cursor-pointer">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</ElIcon>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<el-form ref="dataFormRef" :model="permissionState">
|
||||
<el-form-item prop="data_scope">
|
||||
<el-select v-model="permissionState.data_scope">
|
||||
<el-option :key="1" label="仅本人数据权限" :value="1" />
|
||||
<el-option :key="2" label="本部门数据权限" :value="2" />
|
||||
<el-option :key="3" label="本部门及以下数据权限" :value="3" />
|
||||
<el-option :key="4" label="全部数据权限" :value="4" />
|
||||
<el-option :key="5" label="自定义数据权限" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<ElForm ref="dataFormRef" :model="permissionState">
|
||||
<ElFormItem prop="data_scope">
|
||||
<ElSelect v-model="permissionState.data_scope">
|
||||
<ElOption :key="1" label="仅本人数据权限" :value="1" />
|
||||
<ElOption :key="2" label="本部门数据权限" :value="2" />
|
||||
<ElOption :key="3" label="本部门及以下数据权限" :value="3" />
|
||||
<ElOption :key="4" label="全部数据权限" :value="4" />
|
||||
<ElOption :key="5" label="自定义数据权限" :value="5" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div
|
||||
v-if="permissionState.data_scope === 5 && deptTreeData.length"
|
||||
class="mt-5 max-h-[60vh] b-1 b-solid b-[var(--el-border-color-lighter)] p-10px overflow-auto box-border"
|
||||
>
|
||||
<el-input v-model="deptFilterText" placeholder="部门名称" />
|
||||
<el-tree
|
||||
<ElInput v-model="deptFilterText" placeholder="部门名称" />
|
||||
<ElTree
|
||||
ref="deptTreeRef"
|
||||
node-key="value"
|
||||
show-checkbox
|
||||
@@ -61,62 +61,62 @@
|
||||
@check="deptTreeCheck"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
<ElEmpty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
</el-tree>
|
||||
</ElTree>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-aside>
|
||||
</ElAside>
|
||||
|
||||
<!-- 菜单权限 -->
|
||||
<el-main>
|
||||
<ElMain>
|
||||
<div style="display: flex; gap: 10px">
|
||||
<div style="width: 10px; background-color: var(--el-color-primary)"></div>
|
||||
<div>
|
||||
<span style="font-size: 16px">菜单授权</span>
|
||||
<el-tooltip placement="right">
|
||||
<ElTooltip placement="right">
|
||||
<template #content>
|
||||
<span>授权用户可操作的菜单权限</span>
|
||||
</template>
|
||||
<el-icon class="ml-1 inline-block cursor-pointer">
|
||||
<ElIcon class="ml-1 inline-block cursor-pointer">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</ElIcon>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex-x-between">
|
||||
<el-input v-model="permFilterText" placeholder="菜单名称" />
|
||||
<ElInput v-model="permFilterText" placeholder="菜单名称" />
|
||||
<div class="flex-center ml-5">
|
||||
<el-button type="primary" size="small" plain @click="togglePermTree">
|
||||
<ElButton type="primary" size="small" plain @click="togglePermTree">
|
||||
<template #icon>
|
||||
<SwitchIcon />
|
||||
</template>
|
||||
{{ isExpanded ? "收缩" : "展开" }}
|
||||
</el-button>
|
||||
<el-checkbox
|
||||
</ElButton>
|
||||
<ElCheckbox
|
||||
v-model="parentChildLinked"
|
||||
class="ml-5"
|
||||
@change="handleParentChildLinkedChange"
|
||||
>
|
||||
父子联动
|
||||
</el-checkbox>
|
||||
</ElCheckbox>
|
||||
|
||||
<el-tooltip placement="bottom">
|
||||
<ElTooltip placement="bottom">
|
||||
<template #content>
|
||||
如果只需勾选菜单权限,不需要勾选子菜单或者按钮权限,请关闭父子联动
|
||||
</template>
|
||||
<el-icon class="ml-1 color-[--el-color-primary] inline-block cursor-pointer">
|
||||
<ElIcon class="ml-1 color-[--el-color-primary] inline-block cursor-pointer">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</ElIcon>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-5 max-h-[69vh] b-1 b-solid b-[var(--el-border-color-lighter)] p-10px overflow-auto box-border"
|
||||
>
|
||||
<el-tree
|
||||
<ElTree
|
||||
ref="permTreeRef"
|
||||
node-key="value"
|
||||
show-checkbox
|
||||
@@ -129,31 +129,29 @@
|
||||
@check="menuTreeCheck"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
<ElEmpty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
</el-tree>
|
||||
</ElTree>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCancel">取 消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click.stop="handleDrawerSave">
|
||||
确 定
|
||||
</el-button>
|
||||
<ElButton @click="handleCancel">取 消</ElButton>
|
||||
<ElButton type="primary" :loading="loading" @click.stop="handleDrawerSave">确 定</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDrawer>
|
||||
</ArtDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from "vue";
|
||||
import { QuestionFilled, Switch as SwitchIcon } from "@element-plus/icons-vue";
|
||||
import type { TreeInstance } from "element-plus";
|
||||
import EnhancedDrawer from "@/components/CURD/EnhancedDrawer.vue";
|
||||
import { listToTree, formatTree } from "@/utils/common";
|
||||
import ArtDrawer from "@/components/Core/modal/art-drawer/index.vue";
|
||||
import { listToTree, formatTree } from "@utils/common";
|
||||
import RoleAPI, {
|
||||
permissionDataType,
|
||||
permissionDeptType,
|
||||
@@ -161,9 +159,9 @@ import RoleAPI, {
|
||||
} from "@/api/module_system/role";
|
||||
import DeptAPI from "@/api/module_system/dept";
|
||||
import MenuAPI from "@/api/module_system/menu";
|
||||
import { useAppStore } from "@/store/modules/app.store";
|
||||
import { useAppStore } from "@stores/modules/app.store";
|
||||
import { DeviceEnum } from "@/enums/settings/device.enum";
|
||||
import { useUserStore } from "@/store";
|
||||
import { useUserStore } from "@stores";
|
||||
|
||||
/** formatTree 后的菜单节点带 value(同原 id),与接口里的 id 二选一存在 */
|
||||
type MenuTreeNode = permissionMenuType & { value?: number };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,40 @@
|
||||
<!-- 部门树 -->
|
||||
<!-- 部门树(无外层卡片,由用户页左侧 ElCard 统一包裹) -->
|
||||
<template>
|
||||
<el-card shadow="hover">
|
||||
<div class="dept-tree-root">
|
||||
<div class="dept-tree-toolbar">
|
||||
<el-input v-model="deptName" placeholder="部门名称" class="dept-tree-search">
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Search />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
<ElInput
|
||||
v-model="deptName"
|
||||
class="dept-tree-search"
|
||||
placeholder="部门名称"
|
||||
size="small"
|
||||
class="dept-tree-expand-btn"
|
||||
@click="toggleTreeExpandAll"
|
||||
clearable
|
||||
>
|
||||
<el-icon :size="14">
|
||||
<Switch />
|
||||
</el-icon>
|
||||
<span>{{ treeExpanded ? "收起" : "展开" }}</span>
|
||||
</el-button>
|
||||
<template #prefix>
|
||||
<ElIcon class="dept-tree-search__prefix-icon">
|
||||
<Search />
|
||||
</ElIcon>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<ElTooltip :content="treeExpanded ? '收起全部' : '展开全部'" placement="top">
|
||||
<span
|
||||
class="dept-tree-expand-trigger"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="toggleTreeExpandAll"
|
||||
@keydown.enter.prevent="toggleTreeExpandAll"
|
||||
>
|
||||
<ElIcon :size="15">
|
||||
<Switch />
|
||||
</ElIcon>
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<el-tree
|
||||
<ElTree
|
||||
ref="deptTreeRef"
|
||||
class="mt-2"
|
||||
class="dept-tree-body"
|
||||
node-key="value"
|
||||
:data="deptOptions"
|
||||
:props="{ children: 'children', label: 'label', disabled: 'disabled' }"
|
||||
@@ -35,16 +44,16 @@
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
<ElEmpty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-card>
|
||||
</ElTree>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Switch } from "@element-plus/icons-vue";
|
||||
import { Search, Switch } from "@element-plus/icons-vue";
|
||||
import DeptAPI, { DeptPageQuery } from "@/api/module_system/dept";
|
||||
import { formatTree } from "@/utils/common";
|
||||
import { formatTree } from "@utils/common";
|
||||
import type { FilterNodeMethodFunction, TreeInstance } from "element-plus";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -156,22 +165,51 @@ onBeforeMount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dept-tree-root {
|
||||
box-sizing: border-box;
|
||||
padding: 16px 6px 12px 10px;
|
||||
}
|
||||
|
||||
.dept-tree-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
|
||||
.dept-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dept-tree-expand-btn {
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
min-height: unset;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
.dept-tree-search__prefix-icon {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.dept-tree-expand-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
margin-right: -2px;
|
||||
vertical-align: middle;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--el-border-radius-small);
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary-light-5);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dept-tree-body {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
@clear-click="handleClearSelection"
|
||||
>
|
||||
<template #status="scope">
|
||||
<el-tag :type="scope.row[scope.prop] === '0' ? 'success' : 'danger'">
|
||||
<ElTag :type="scope.row[scope.prop] === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row[scope.prop] === "0" ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</ElTag>
|
||||
</template>
|
||||
</table-select>
|
||||
</template>
|
||||
@@ -29,7 +29,8 @@ const emit = defineEmits<{
|
||||
|
||||
const selectConfig: ISelectConfig = {
|
||||
pk: "id",
|
||||
width: "167.5px", // 与搜索表单其他输入宽度一致
|
||||
/** 与 ArtSearchBar 栅格内 ElInput 一致,占满表单项内容区 */
|
||||
width: "100%",
|
||||
placeholder: "请选择用户",
|
||||
popover: {
|
||||
width: 720, // 弹出层宽度,提升可用性(约 720px)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user