mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-01 00:05:17 +00:00
feat: 重构前端项目结构并优化代码
refactor: 迁移前端资源文件至web目录 feat: 新增多种图标资源 style: 统一代码风格和格式化配置 docs: 更新README和文档说明 chore: 更新依赖和配置文件 fix: 修复部分类型定义和枚举 perf: 优化路由和组件加载逻辑
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 text-center m-0 mb-20px>{{ t('login.login') }}</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" class="login-tabs">
|
||||
<!-- 账号登录 -->
|
||||
<el-tab-pane label="账号登录" name="password">
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
size="large"
|
||||
:validate-on-rule-change="false"
|
||||
>
|
||||
<!-- 用户名 -->
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model.trim="loginForm.username"
|
||||
:placeholder="t('login.username')"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model.trim="loginForm.password"
|
||||
:placeholder="t('login.password')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="handleLoginSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<el-form-item v-if="captchaState.enable" prop="captcha">
|
||||
<div flex items-center gap-10px class="flex-1">
|
||||
<el-input
|
||||
v-model.trim="loginForm.captcha"
|
||||
:placeholder="t('login.captchaCode')"
|
||||
clearable
|
||||
class="flex-1"
|
||||
@keyup.enter="handleLoginSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<div class="i-svg:captcha" />
|
||||
</template>
|
||||
</el-input>
|
||||
<div cursor-pointer flex-center h-40px w-100px>
|
||||
<el-icon v-if="codeLoading" class="is-loading" size="20">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<el-image
|
||||
v-else-if="captchaState.img_base"
|
||||
border-rd-4px
|
||||
object-cover
|
||||
shadow="[0_0_0_1px_var(--el-border-color)_inset]"
|
||||
:src="captchaState.img_base"
|
||||
class="w-full h-full"
|
||||
@click="getCaptcha"
|
||||
/>
|
||||
<el-text v-else type="info" size="small">点击获取验证码</el-text>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="flex-x-between w-full">
|
||||
<el-checkbox v-model="loginForm.remember">{{ t('login.rememberMe') }}</el-checkbox>
|
||||
<el-link type="primary" underline="never" @click="toOtherForm('resetPwd')">
|
||||
{{ t('login.forgetPassword') }}
|
||||
</el-link>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<el-form-item>
|
||||
<el-button :loading="loading" type="primary" class="w-full" @click="handleLoginSubmit">
|
||||
{{ t('login.login') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div flex-center gap-10px>
|
||||
<el-text size="default">{{ t('login.noAccount') }}</el-text>
|
||||
<el-link type="primary" underline="never" @click="toOtherForm('register')">
|
||||
{{ t('login.reg') }}
|
||||
</el-link>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 快速登录 -->
|
||||
<el-tab-pane v-if="autoLoginUsers.length > 0" label="快速登录" name="quick">
|
||||
<div class="quick-login-section">
|
||||
<div class="quick-login-tip">
|
||||
<el-text type="info">{{ t('login.quickLoginTip') }}</el-text>
|
||||
</div>
|
||||
<!-- 当用户数量大于4个时使用下拉选择,否则使用网格展示 -->
|
||||
<template v-if="autoLoginUsers.length > 3">
|
||||
<el-select
|
||||
v-model="selectedUserId"
|
||||
class="w-full"
|
||||
:placeholder="t('login.selectUser')"
|
||||
size="large"
|
||||
@change="handleAutoLogin"
|
||||
>
|
||||
<el-option
|
||||
v-for="user in autoLoginUsers"
|
||||
:key="user.id"
|
||||
:label="`${user.username}@${user.name}`"
|
||||
:value="user.id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="auto-login-users">
|
||||
<div
|
||||
v-for="user in autoLoginUsers"
|
||||
:key="user.id"
|
||||
class="auto-login-user-item"
|
||||
@click="handleAutoLogin(user.id)"
|
||||
>
|
||||
<el-avatar :size="60" :src="user.avatar || ''" class="user-avatar">
|
||||
<el-icon size="24"><User /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="user-name">{{ user.name }}</span>
|
||||
<span class="user-username">{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 第三方登录 -->
|
||||
<div class="third-party-login">
|
||||
<div class="divider-container">
|
||||
<div class="divider-line"></div>
|
||||
<span class="divider-text">{{ t('login.otherLoginMethods') }}</span>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
<div class="flex-center gap-x-5 w-full text-[var(--el-text-color-secondary)]">
|
||||
<CommonWrapper>
|
||||
<div text-20px class="i-svg:wechat" />
|
||||
</CommonWrapper>
|
||||
<CommonWrapper>
|
||||
<div text-20px cursor-pointer class="i-svg:qq" />
|
||||
</CommonWrapper>
|
||||
<CommonWrapper>
|
||||
<div text-20px cursor-pointer class="i-svg:github" />
|
||||
</CommonWrapper>
|
||||
<CommonWrapper>
|
||||
<div text-20px cursor-pointer class="i-svg:gitee" />
|
||||
</CommonWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import { LocationQuery, RouteLocationRaw, useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { onActivated, onMounted, watch } from 'vue';
|
||||
import AuthAPI, {
|
||||
type AutoLoginUser,
|
||||
type LoginFormData,
|
||||
type CaptchaInfo,
|
||||
} from '@/api/module_system/auth';
|
||||
import { useAppStore, useUserStore, useSettingsStore } from '@/store';
|
||||
import CommonWrapper from '@/components/CommonWrapper/index.vue';
|
||||
import { User, Loading, Lock } from '@element-plus/icons-vue';
|
||||
import { Auth } from '@/utils/auth';
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 激活的标签页
|
||||
const activeTab = ref('password');
|
||||
|
||||
// 选择的用户ID(用于下拉选择)
|
||||
const selectedUserId = ref<number | null>(null);
|
||||
|
||||
// 来自父容器的预填用户名和密码
|
||||
const props = defineProps<{ presetUsername?: string; presetPassword?: string }>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 组件挂载时获取验证码和免登录用户列表
|
||||
onMounted(() => {
|
||||
getCaptcha();
|
||||
getAutoLoginUsers();
|
||||
});
|
||||
|
||||
// 组件激活时获取验证码(适用于KeepAlive缓存的情况)
|
||||
onActivated(() => {
|
||||
getCaptcha();
|
||||
// 重置登录表单
|
||||
loginForm.captcha = '';
|
||||
});
|
||||
|
||||
// 监听路由变化,确保每次进入登录页面都有最新验证码
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
getCaptcha();
|
||||
loginForm.captcha = '';
|
||||
}
|
||||
);
|
||||
|
||||
const loginFormRef = ref<FormInstance>();
|
||||
const loading = ref(false);
|
||||
// 是否大写锁定
|
||||
const isCapsLock = ref(false);
|
||||
|
||||
const loginForm = reactive<LoginFormData>({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
captcha_key: '',
|
||||
remember: true,
|
||||
login_type: 'PC端',
|
||||
});
|
||||
|
||||
// 监听父组件传入的预填信息,立即填充到登录表单
|
||||
watch(
|
||||
() => [props.presetUsername, props.presetPassword],
|
||||
([presetUsername, presetPassword]) => {
|
||||
if (typeof presetUsername === 'string') {
|
||||
loginForm.username = presetUsername;
|
||||
}
|
||||
if (typeof presetPassword === 'string') {
|
||||
loginForm.password = presetPassword;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const captchaState = reactive<CaptchaInfo>({
|
||||
enable: true,
|
||||
key: '',
|
||||
img_base: '',
|
||||
});
|
||||
|
||||
// 免登录用户列表
|
||||
const autoLoginUsers = ref<AutoLoginUser[]>([]);
|
||||
const autoLoginLoading = ref(false);
|
||||
|
||||
// 获取免登录用户列表
|
||||
async function getAutoLoginUsers() {
|
||||
try {
|
||||
const response = await AuthAPI.getAutoLoginUsers();
|
||||
autoLoginUsers.value = response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error('获取免登录用户列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 免登录
|
||||
async function handleAutoLogin(userId: number) {
|
||||
if (autoLoginLoading.value) return;
|
||||
|
||||
try {
|
||||
autoLoginLoading.value = true;
|
||||
|
||||
// 1. 获取免登录Token
|
||||
const tokenResponse = await AuthAPI.getAutoLoginToken(userId);
|
||||
const { token } = tokenResponse.data.data;
|
||||
|
||||
// 2. 使用Token登录
|
||||
const loginResponse = await AuthAPI.autoLogin(token);
|
||||
const loginData = loginResponse.data.data;
|
||||
|
||||
// 3. 设置登录状态
|
||||
userStore.rememberMe = true;
|
||||
Auth.setTokens(loginData.access_token, loginData.refresh_token, true);
|
||||
|
||||
// 4. 获取用户信息
|
||||
await userStore.getUserInfo();
|
||||
|
||||
// 5. 跳转
|
||||
const redirect = resolveRedirectTarget(route.query);
|
||||
await router.replace(redirect);
|
||||
|
||||
// 6. 显示引导
|
||||
if (settingsStore.showGuide) {
|
||||
appStore.showGuide(true);
|
||||
}
|
||||
|
||||
ElMessage.success('登录成功');
|
||||
} catch (error: any) {
|
||||
console.error('免登录失败:', error);
|
||||
ElMessage.error(error?.response?.data?.msg || '登录失败');
|
||||
} finally {
|
||||
autoLoginLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const loginRules = computed(() => {
|
||||
const rules: any = {
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.username.required'),
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.password.required'),
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: t('login.message.password.min'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 只有在验证码开启时才添加验证码验证规则
|
||||
if (captchaState.enable) {
|
||||
rules.captcha = [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.captchaCode.required'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return rules;
|
||||
});
|
||||
|
||||
// 获取验证码
|
||||
const codeLoading = ref(false);
|
||||
async function getCaptcha() {
|
||||
try {
|
||||
codeLoading.value = true;
|
||||
const response = await AuthAPI.getCaptcha();
|
||||
loginForm.captcha_key = response.data.data.key;
|
||||
captchaState.img_base = response.data.data.img_base;
|
||||
captchaState.enable = response.data.data.enable;
|
||||
} catch (error: any) {
|
||||
// 验证码获取失败时,默认关闭验证码功能
|
||||
console.error('获取验证码失败:', error);
|
||||
captchaState.enable = false;
|
||||
// 清空验证码相关字段,避免影响登录
|
||||
loginForm.captcha = '';
|
||||
loginForm.captcha_key = '';
|
||||
} finally {
|
||||
codeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录提交
|
||||
*/
|
||||
async function handleLoginSubmit() {
|
||||
try {
|
||||
// 1. 表单验证
|
||||
const valid = await loginFormRef.value?.validate();
|
||||
if (!valid) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
// 2. 执行登录
|
||||
await userStore.login(loginForm);
|
||||
|
||||
// 4. 登录成功,让路由守卫处理跳转逻辑
|
||||
// 解析目标地址,但不直接跳转
|
||||
const redirect = resolveRedirectTarget(route.query);
|
||||
|
||||
// 通过替换当前路由触发路由守卫,让守卫处理后续的路由生成和跳转
|
||||
await router.replace(redirect);
|
||||
|
||||
// 5. 记住我功能已实现,根据用户选择决定token的存储方式:
|
||||
// - 选中"记住我": token存储在localStorage中,浏览器关闭后仍然有效
|
||||
// - 未选中"记住我": token存储在sessionStorage中,浏览器关闭后失效
|
||||
|
||||
// 登录成功后自动开启项目引导
|
||||
if (settingsStore.showGuide) {
|
||||
appStore.showGuide(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error) {
|
||||
getCaptcha(); // 刷新验证码
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析重定向目标
|
||||
*
|
||||
* @param query 路由查询参数
|
||||
* @returns 标准化后的路由地址
|
||||
*/
|
||||
function resolveRedirectTarget(query: LocationQuery): RouteLocationRaw {
|
||||
// 默认跳转路径
|
||||
const defaultPath = '/';
|
||||
|
||||
// 获取原始重定向路径
|
||||
const rawRedirect = (query.redirect as string) || defaultPath;
|
||||
|
||||
try {
|
||||
// 6. 使用Vue Router解析路径
|
||||
const resolved = router.resolve(rawRedirect);
|
||||
return {
|
||||
path: resolved.path,
|
||||
query: resolved.query,
|
||||
};
|
||||
} catch {
|
||||
// 7. 异常处理:返回安全路径
|
||||
return { path: defaultPath };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查输入大小写
|
||||
function checkCapsLock(event: KeyboardEvent) {
|
||||
// 防止浏览器密码自动填充时报错
|
||||
if (event instanceof KeyboardEvent) {
|
||||
isCapsLock.value = event.getModifierState('CapsLock');
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
function toOtherForm(type: 'register' | 'resetPwd') {
|
||||
emit('update:modelValue', type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-tabs {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
min-height: 300px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-login-section {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
padding: 20px 0;
|
||||
|
||||
.quick-login-tip {
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 下拉菜单样式
|
||||
.user-dropdown {
|
||||
.user-dropdown-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
|
||||
.btn-text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.user-dropdown-menu) {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
display: flex !important;
|
||||
gap: 20px !important;
|
||||
align-items: center !important;
|
||||
padding: 14px 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.user-option {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
|
||||
.user-option-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-option-username {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-option-avatar {
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--el-border-color-light);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.auto-login-users {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
|
||||
.auto-login-user-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border: 2px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border: 2px solid var(--el-border-color);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.third-party-login {
|
||||
.divider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--el-border-color-light),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
padding: 0 16px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 text-center m-0 mb-20px>{{ t('login.reg') }}</h3>
|
||||
<el-form ref="formRef" :model="model" :rules="rules" size="large" label-suffix=":">
|
||||
<!-- 账号 -->
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model.trim="model.username" :placeholder="t('login.username')" clearable>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model.trim="model.password"
|
||||
:placeholder="t('login.password')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model.trim="model.confirmPassword"
|
||||
:placeholder="t('login.message.password.confirm')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
|
||||
<el-form-item>
|
||||
<div class="flex-y-center w-full gap-10px">
|
||||
<el-checkbox v-model="isRead">{{ t('login.agree') }}</el-checkbox>
|
||||
<el-link
|
||||
type="primary"
|
||||
underline="never"
|
||||
:href="configStore.configData.sys_web_clause.config_value"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('login.userAgreement') }}
|
||||
</el-link>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<el-form-item>
|
||||
<el-button :loading="loading" type="success" class="w-full" @click="submit">
|
||||
{{ t('login.register') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div flex-center gap-10px>
|
||||
<el-text size="default">{{ t('login.haveAccount') }}</el-text>
|
||||
<el-link type="primary" underline="never" @click="toLogin">{{ t('login.login') }}</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import { Lock } from '@element-plus/icons-vue';
|
||||
import UserAPI, { type RegisterForm } from '@/api/module_system/user';
|
||||
import { useConfigStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 使用 defineModel 简化具名 v-model 绑定
|
||||
const modelValue = defineModel<string>();
|
||||
const presetUsername = defineModel<string>('presetUsername');
|
||||
const presetPassword = defineModel<string>('presetPassword');
|
||||
const toLogin = () => {
|
||||
modelValue.value = 'login';
|
||||
};
|
||||
|
||||
const configStore = useConfigStore();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const loading = ref(false); // 按钮 loading 状态
|
||||
const isCapsLock = ref(false); // 是否大写锁定
|
||||
const isRead = ref(false);
|
||||
|
||||
const model = ref<RegisterForm>({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const rules = computed(() => {
|
||||
return {
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.username.required'),
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.password.required'),
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: t('login.message.password.min'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
confirmPassword: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.password.required'),
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: t('login.message.password.min'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
return value === model.value.password;
|
||||
},
|
||||
trigger: 'blur',
|
||||
message: t('login.message.password.inconformity'),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// 检查输入大小写
|
||||
function checkCapsLock(event: KeyboardEvent) {
|
||||
// 防止浏览器密码自动填充时报错
|
||||
if (event instanceof KeyboardEvent) {
|
||||
isCapsLock.value = event.getModifierState('CapsLock');
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// 0. 检查是否已勾选协议
|
||||
if (!isRead.value) {
|
||||
ElMessage.warning(t('login.message.agree.required'));
|
||||
return;
|
||||
}
|
||||
// 1. 表单验证
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
await UserAPI.registerUser(model.value);
|
||||
// 注册成功后,双向绑定回写父容器的用户名和密码,并切回登录
|
||||
presetUsername.value = model.value.username;
|
||||
presetPassword.value = model.value.password;
|
||||
toLogin();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {});
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 text-center m-0 mb-20px>{{ t('login.resetPassword') }}</h3>
|
||||
<el-form ref="formRef" :model="model" :rules="rules" size="large" label-suffix=":">
|
||||
<!-- 用户名 -->
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model.trim="model.username" :placeholder="t('login.username')" clearable>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 新密码 -->
|
||||
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
|
||||
<el-form-item prop="new_password">
|
||||
<el-input
|
||||
v-model.trim="model.new_password"
|
||||
:placeholder="t('login.password')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<el-tooltip :visible="isCapsLock" :content="t('login.capsLock')" placement="right">
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model.trim="model.confirmPassword"
|
||||
:placeholder="t('login.message.password.confirm')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
@keyup="checkCapsLock"
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 重置密码按钮 -->
|
||||
<el-form-item>
|
||||
<el-button type="warning" class="w-full" @click="submit">
|
||||
{{ t('login.resetPassword') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div flex-center gap-10px>
|
||||
<el-text size="default">{{ t('login.thinkOfPasswd') }}</el-text>
|
||||
<el-link type="primary" underline="never" @click="toLogin">{{ t('login.login') }}</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import UserAPI, { type ForgetPasswordForm } from '@/api/module_system/user';
|
||||
|
||||
const loading = ref(false); // 按钮 loading 状态
|
||||
const isCapsLock = ref(false); // 是否大写锁定
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 使用 defineModel 简化具名 v-model 绑定
|
||||
const modelValue = defineModel<string>();
|
||||
const presetUsername = defineModel<string>('presetUsername');
|
||||
const presetPassword = defineModel<string>('presetPassword');
|
||||
const toLogin = () => {
|
||||
modelValue.value = 'login';
|
||||
};
|
||||
|
||||
const model = ref<ForgetPasswordForm>({
|
||||
username: '',
|
||||
new_password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const rules = computed(() => {
|
||||
return {
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.username.required'),
|
||||
},
|
||||
],
|
||||
new_password: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.password.required'),
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: t('login.message.password.min'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
confirmPassword: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.password.required'),
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: t('login.message.password.min'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
return value === model.value.new_password;
|
||||
},
|
||||
trigger: 'blur',
|
||||
message: t('login.message.password.inconformity'),
|
||||
},
|
||||
],
|
||||
mobile: [
|
||||
{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
message: t('login.message.captchaCode.required'),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// 1. 表单验证
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
await UserAPI.forgetPassword(model.value);
|
||||
// 重置成功后,双向绑定回写父容器的用户名和新密码,并切回登录
|
||||
presetUsername.value = model.value.username;
|
||||
presetPassword.value = model.value.new_password;
|
||||
toLogin();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查输入大小写
|
||||
function checkCapsLock(event: KeyboardEvent) {
|
||||
// 防止浏览器密码自动填充时报错
|
||||
if (event instanceof KeyboardEvent) {
|
||||
isCapsLock.value = event.getModifierState('CapsLock');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex w-full h-screen">
|
||||
<LoginLeftView />
|
||||
|
||||
<div class="relative flex-1">
|
||||
<AuthTopBar />
|
||||
|
||||
<div class="auth-right-wrap">
|
||||
<div class="form">
|
||||
<h3 class="title">{{ $t('forgetPassword.title') }}</h3>
|
||||
<p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
|
||||
<div class="mt-5">
|
||||
<span v-if="showInputLabel" class="input-label">账号</span>
|
||||
<ElInput
|
||||
v-model.trim="username"
|
||||
class="custom-height"
|
||||
:placeholder="$t('forgetPassword.placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton
|
||||
v-ripple
|
||||
class="w-full custom-height"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="register"
|
||||
>
|
||||
{{ $t('forgetPassword.submitBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton class="w-full custom-height" plain @click="toLogin">
|
||||
{{ $t('forgetPassword.backBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ForgetPassword' });
|
||||
|
||||
const router = useRouter();
|
||||
const showInputLabel = ref(false);
|
||||
|
||||
const username = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
const register = async () => {};
|
||||
|
||||
const toLogin = () => {
|
||||
router.push({ name: 'Login' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../login/style.css';
|
||||
</style>
|
||||
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<div class="auth-view" :style="{ '--login-background-url': `url(${loginBackgroundUrl})` }">
|
||||
<!-- 右侧切换主题、语言按钮 -->
|
||||
<div class="auth-view__toolbar">
|
||||
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
|
||||
<CommonWrapper>
|
||||
<ThemeSwitch />
|
||||
</CommonWrapper>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('login.languageToggle')" placement="bottom">
|
||||
<CommonWrapper>
|
||||
<LangSelect size="text-20px" />
|
||||
</CommonWrapper>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<!-- 登录页主体 -->
|
||||
<div class="auth-view__wrapper">
|
||||
<!-- 可选:左侧产品介绍区域,如不需要可整段删除,右侧登录表单会自动居中展示 -->
|
||||
<section class="auth-feature">
|
||||
<div class="auth-feature__badge">
|
||||
<span class="auth-feature__dot" />
|
||||
Enterprise Ready
|
||||
</div>
|
||||
<h1 class="auth-feature__title">企业级管理系统</h1>
|
||||
<p class="auth-feature__subtitle">
|
||||
提供安全、高效、可扩展的管理解决方案,助力企业数字化转型与业务增长。
|
||||
</p>
|
||||
<ul class="auth-feature__highlights">
|
||||
<li>
|
||||
<span>✓</span>
|
||||
统一身份认证与权限管理
|
||||
</li>
|
||||
<li>
|
||||
<span>✓</span>
|
||||
支持定时任务与任务调度
|
||||
</li>
|
||||
<li>
|
||||
<span>✓</span>
|
||||
数据安全与操作审计
|
||||
</li>
|
||||
<li>
|
||||
<span>✓</span>
|
||||
灵活扩展与高可用架构
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 登录页主体容器 -->
|
||||
<section class="auth-panel">
|
||||
<!-- 标题 -->
|
||||
<div class="auth-panel__brand">
|
||||
<div class="auth-panel__logo-wrap">
|
||||
<!-- logo -->
|
||||
<el-image
|
||||
:src="configStore.configData?.sys_web_logo?.config_value || ''"
|
||||
class="auth-panel__logo"
|
||||
/>
|
||||
</div>
|
||||
<div class="auth-panel__meta">
|
||||
<div class="auth-panel__title-row">
|
||||
<span class="auth-panel__title">
|
||||
{{ configStore.configData?.sys_web_title?.config_value || '' }}
|
||||
</span>
|
||||
<el-tooltip
|
||||
:content="configStore.configData?.sys_web_description?.config_value || ''"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-icon class="cursor-help"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="auth-panel__version-row">
|
||||
<span class="auth-panel__version-label">Version</span>
|
||||
<span class="auth-panel__version-pill">
|
||||
v{{ configStore.configData?.sys_web_version?.config_value || '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 组件切换 -->
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component
|
||||
:is="formComponents[component]"
|
||||
v-model="component"
|
||||
v-model:preset-username="loginPreset.username"
|
||||
v-model:preset-password="loginPreset.password"
|
||||
class="auth-panel__form"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<!-- 登录页底部版权 -->
|
||||
<footer class="auth-panel__footer">
|
||||
<el-text size="small">
|
||||
<a :href="configStore.configData?.sys_git_code?.config_value || ''" target="_blank">
|
||||
{{ configStore.configData?.sys_web_copyright?.config_value || '' }}
|
||||
</a>
|
||||
|
|
||||
<a :href="configStore.configData?.sys_help_doc?.config_value || ''" target="_blank">
|
||||
帮助
|
||||
</a>
|
||||
|
|
||||
<a :href="configStore.configData?.sys_web_privacy?.config_value || ''" target="_blank">
|
||||
隐私
|
||||
</a>
|
||||
|
|
||||
<a :href="configStore.configData?.sys_web_clause?.config_value || ''" target="_blank">
|
||||
条款
|
||||
</a>
|
||||
{{ configStore.configData?.sys_keep_record?.config_value || '' }}
|
||||
</el-text>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// import logo from "@/assets/logo.png";
|
||||
// import { defaultSettings } from "@/settings";
|
||||
import CommonWrapper from '@/components/CommonWrapper/index.vue';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch/index.vue';
|
||||
import { useConfigStore } from '@/store';
|
||||
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 添加计算属性处理背景图片URL
|
||||
const loginBackgroundUrl = computed(() => {
|
||||
// 使用可选链操作符确保安全访问
|
||||
return (
|
||||
configStore.configData?.sys_login_background?.config_value ||
|
||||
new URL('@/assets/images/login-bg.svg', import.meta.url).href
|
||||
);
|
||||
});
|
||||
|
||||
type LayoutMap = 'login' | 'register' | 'resetPwd';
|
||||
|
||||
const t = useI18n().t;
|
||||
|
||||
const component = ref<LayoutMap>('login'); // 切换显示的组件
|
||||
const formComponents = {
|
||||
login: defineAsyncComponent(() => import('./components/Login.vue')),
|
||||
register: defineAsyncComponent(() => import('./components/Register.vue')),
|
||||
resetPwd: defineAsyncComponent(() => import('./components/ResetPwd.vue')),
|
||||
};
|
||||
|
||||
// 预填登录信息(通过具名 v-model 双向绑定传递)
|
||||
const loginPreset = reactive<{ username: string; password: string }>({
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
});
|
||||
|
||||
let notificationInstance: ReturnType<typeof ElNotification> | null = null;
|
||||
|
||||
const showVoteNotification = () => {
|
||||
notificationInstance = ElNotification({
|
||||
title: '⭐ FastapiAdmin 完全开源 · 期待您的 Star 支持 🙏',
|
||||
message: `项目持续迭代中,若对您有所帮助,欢迎点亮 Star 支持!
|
||||
<br/><a href="https://github.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-primary); text-decoration: none; font-weight: 500;">Github仓库 →</a>
|
||||
<br/><a href="https://gitee.com/fastapiadmin/FastapiAdmin" target="_blank" style="color: var(--el-color-warning); text-decoration: none; font-weight: 500;">Gitee仓库 →</a>`,
|
||||
type: 'success',
|
||||
position: 'bottom-left',
|
||||
duration: 0,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(showVoteNotification, 500);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (notificationInstance) {
|
||||
notificationInstance.close();
|
||||
notificationInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auth-view {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
overflow: hidden;
|
||||
background-color: var(--el-bg-color-page);
|
||||
|
||||
&::before {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
content: '';
|
||||
background: var(--login-background-url) center/cover no-repeat;
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background: linear-gradient(120deg, var(--el-bg-color), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-view__toolbar {
|
||||
display: inline-flex;
|
||||
gap: 0.75rem;
|
||||
align-self: flex-end;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--el-box-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
z-index: 20;
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// 暗色/亮色交给 Element Plus 变量处理,不在页面里写死颜色分支
|
||||
}
|
||||
|
||||
/* 暗色样式交给全局主题变量 */
|
||||
|
||||
.auth-view__wrapper {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: clamp(1.5rem, 3vw, 3rem);
|
||||
align-items: stretch;
|
||||
padding: clamp(1.5rem, 2vw, 2.5rem);
|
||||
}
|
||||
|
||||
.auth-feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: clamp(1.5rem, 3vw, 3rem);
|
||||
color: var(--el-text-color-primary);
|
||||
animation: featureFade 0.8s ease-out;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-view__wrapper {
|
||||
display: block;
|
||||
padding: 1.25rem 0.75rem 1.75rem;
|
||||
}
|
||||
|
||||
.auth-feature {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-feature__badge {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--el-color-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.auth-feature__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.auth-feature__title {
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.auth-feature__subtitle {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.auth-feature__highlights {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(6px);
|
||||
|
||||
span {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
justify-content: flex-start;
|
||||
justify-self: end;
|
||||
width: min(520px, 100%);
|
||||
padding: clamp(2rem, 3vw, 2.75rem);
|
||||
margin-inline: auto;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--el-box-shadow);
|
||||
backdrop-filter: blur(20px);
|
||||
animation: panelLift 0.7s ease;
|
||||
}
|
||||
|
||||
.auth-panel__brand {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.auth-panel__logo-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.auth-panel__logo {
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.auth-panel__meta {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-panel__title-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.auth-panel__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-panel__version-row {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.auth-panel__version-label {
|
||||
color: var(--el-text-color-placeholder);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.auth-panel__version-pill {
|
||||
padding: 0.1rem 0.55rem;
|
||||
font-weight: 500;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.auth-panel__form {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px var(--el-border-color) inset;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
|
||||
}
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-card) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-panel__footer {
|
||||
padding-top: 0.875rem;
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
a {
|
||||
margin-left: 0.1rem;
|
||||
color: var(--el-text-color-regular);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes featureFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelLift {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-40px) scale(0.95);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(40px) scale(0.95);
|
||||
}
|
||||
|
||||
.fade-slide-enter-to,
|
||||
.fade-slide-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
</style>
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
<!-- 登录页面 -->
|
||||
<template>
|
||||
<div class="flex w-full h-screen">
|
||||
<LoginLeftView />
|
||||
|
||||
<div class="relative flex-1">
|
||||
<AuthTopBar />
|
||||
|
||||
<div class="auth-right-wrap">
|
||||
<div class="form">
|
||||
<h3 class="title">{{ $t('login.title') }}</h3>
|
||||
<p class="sub-title">{{ $t('login.subTitle') }}</p>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:key="formKey"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
style="margin-top: 25px"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<ElFormItem prop="account">
|
||||
<ElSelect v-model="formData.account" @change="setupAccount">
|
||||
<ElOption
|
||||
v-for="account in accounts"
|
||||
:key="account.key"
|
||||
:label="account.label"
|
||||
:value="account.key"
|
||||
>
|
||||
<span>{{ account.label }}</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="username">
|
||||
<ElInput
|
||||
v-model.trim="formData.username"
|
||||
class="custom-height"
|
||||
:placeholder="$t('login.placeholder.username')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model.trim="formData.password"
|
||||
class="custom-height"
|
||||
:placeholder="$t('login.placeholder.password')"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 推拽验证 -->
|
||||
<div class="relative pb-5 mt-6">
|
||||
<div
|
||||
class="relative z-[2] overflow-hidden select-none rounded-lg border border-transparent tad-300"
|
||||
:class="{ '!border-[#FF4E4F]': !isPassing && isClickPass }"
|
||||
>
|
||||
<ArtDragVerify
|
||||
ref="dragVerify"
|
||||
v-model:value="isPassing"
|
||||
:text="$t('login.sliderText')"
|
||||
text-color="var(--art-gray-700)"
|
||||
:success-text="$t('login.sliderSuccessText')"
|
||||
progress-bar-bg="var(--main-color)"
|
||||
:background="isDark ? '#26272F' : '#F1F1F4'"
|
||||
handler-bg="var(--default-box-color)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="absolute top-0 z-[1] px-px mt-2 text-xs text-[#f56c6c] tad-300"
|
||||
:class="{ 'translate-y-10': !isPassing && isClickPass }"
|
||||
>
|
||||
{{ $t('login.placeholder.slider') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-cb mt-2 text-sm">
|
||||
<ElCheckbox v-model="formData.rememberPassword">
|
||||
{{ $t('login.rememberPwd') }}
|
||||
</ElCheckbox>
|
||||
<RouterLink class="text-theme" :to="{ name: 'ForgetPassword' }">
|
||||
{{ $t('login.forgetPwd') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px">
|
||||
<ElButton
|
||||
v-ripple
|
||||
class="w-full custom-height"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ $t('login.btnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-sm text-gray-600">
|
||||
<span>{{ $t('login.noAccount') }}</span>
|
||||
<RouterLink class="text-theme" :to="{ name: 'Register' }">
|
||||
{{ $t('login.register') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpError } from '@/utils/http/error';
|
||||
import { fetchLogin } from '@/api/auth';
|
||||
import { ElNotification, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { useSettingStore } from '@/store/modules/setting';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
const { isDark } = storeToRefs(settingStore);
|
||||
const { t, locale } = useI18n();
|
||||
const formKey = ref(0);
|
||||
|
||||
// 监听语言切换,重置表单
|
||||
watch(locale, () => {
|
||||
formKey.value++;
|
||||
});
|
||||
|
||||
type AccountKey = 'super' | 'admin' | 'user';
|
||||
|
||||
export interface Account {
|
||||
key: AccountKey;
|
||||
label: string;
|
||||
userName: string;
|
||||
password: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const accounts = computed<Account[]>(() => [
|
||||
{
|
||||
key: 'super',
|
||||
label: t('login.roles.super'),
|
||||
userName: 'Super',
|
||||
password: '123456',
|
||||
roles: ['R_SUPER'],
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
label: t('login.roles.admin'),
|
||||
userName: 'Admin',
|
||||
password: '123456',
|
||||
roles: ['R_ADMIN'],
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: t('login.roles.user'),
|
||||
userName: 'User',
|
||||
password: '123456',
|
||||
roles: ['R_USER'],
|
||||
},
|
||||
]);
|
||||
|
||||
const dragVerify = ref();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const isPassing = ref(false);
|
||||
const isClickPass = ref(false);
|
||||
|
||||
const systemName = AppConfig.systemInfo.name;
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const formData = reactive({
|
||||
account: '',
|
||||
username: '',
|
||||
password: '',
|
||||
rememberPassword: true,
|
||||
});
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
username: [{ required: true, message: t('login.placeholder.username'), trigger: 'blur' }],
|
||||
password: [{ required: true, message: t('login.placeholder.password'), trigger: 'blur' }],
|
||||
}));
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
setupAccount('super');
|
||||
});
|
||||
|
||||
// 设置账号
|
||||
const setupAccount = (key: AccountKey) => {
|
||||
const selectedAccount = accounts.value.find((account: Account) => account.key === key);
|
||||
formData.account = key;
|
||||
formData.username = selectedAccount?.userName ?? '';
|
||||
formData.password = selectedAccount?.password ?? '';
|
||||
};
|
||||
|
||||
// 登录
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
try {
|
||||
// 表单验证
|
||||
const valid = await formRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 拖拽验证
|
||||
if (!isPassing.value) {
|
||||
isClickPass.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
// 登录请求
|
||||
const { username, password } = formData;
|
||||
|
||||
const { token, refreshToken } = await fetchLogin({
|
||||
userName: username,
|
||||
password,
|
||||
});
|
||||
|
||||
// 验证token
|
||||
if (!token) {
|
||||
throw new Error('Login failed - no token received');
|
||||
}
|
||||
|
||||
// 存储 token 和登录状态
|
||||
userStore.setToken(token, refreshToken);
|
||||
userStore.setLoginStatus(true);
|
||||
|
||||
// 登录成功处理
|
||||
showLoginSuccessNotice();
|
||||
|
||||
// 获取 redirect 参数,如果存在则跳转到指定页面,否则跳转到首页
|
||||
const redirect = route.query.redirect as string;
|
||||
router.push(redirect || '/');
|
||||
} catch (error) {
|
||||
// 处理 HttpError
|
||||
if (error instanceof HttpError) {
|
||||
// console.log(error.code)
|
||||
} else {
|
||||
// 处理非 HttpError
|
||||
// ElMessage.error('登录失败,请稍后重试')
|
||||
console.error('[Login] Unexpected error:', error);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
resetDragVerify();
|
||||
}
|
||||
};
|
||||
|
||||
// 重置拖拽验证
|
||||
const resetDragVerify = () => {
|
||||
dragVerify.value.reset();
|
||||
};
|
||||
|
||||
// 登录成功提示
|
||||
const showLoginSuccessNotice = () => {
|
||||
setTimeout(() => {
|
||||
ElNotification({
|
||||
title: t('login.success.title'),
|
||||
type: 'success',
|
||||
duration: 2500,
|
||||
zIndex: 10000,
|
||||
message: `${t('login.success.message')}, ${systemName}!`,
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './style.css';
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-select__wrapper) {
|
||||
height: 40px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
/* 授权页右侧区域 */
|
||||
.auth-right-wrap {
|
||||
@apply absolute inset-0 w-[440px] h-[650px] py-[5px] m-auto overflow-hidden
|
||||
max-sm:px-7 max-sm:w-full
|
||||
animate-[slideInRight_0.6s_cubic-bezier(0.25,0.46,0.45,0.94)_forwards]
|
||||
max-md:animate-none;
|
||||
|
||||
.form {
|
||||
@apply h-full py-[40px];
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-g-900 text-4xl font-semibold max-md:text-3xl max-sm:pt-10;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
@apply mt-[10px] text-g-600 text-sm;
|
||||
}
|
||||
|
||||
.custom-height {
|
||||
@apply !h-[40px];
|
||||
}
|
||||
}
|
||||
|
||||
/* 滑入动画 */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<!-- 注册页面 -->
|
||||
<template>
|
||||
<div class="flex w-full h-screen">
|
||||
<LoginLeftView />
|
||||
|
||||
<div class="relative flex-1">
|
||||
<AuthTopBar />
|
||||
|
||||
<div class="auth-right-wrap">
|
||||
<div class="form">
|
||||
<h3 class="title">{{ $t('register.title') }}</h3>
|
||||
<p class="sub-title">{{ $t('register.subTitle') }}</p>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:key="formKey"
|
||||
class="mt-7.5"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
>
|
||||
<ElFormItem prop="username">
|
||||
<ElInput
|
||||
v-model.trim="formData.username"
|
||||
class="custom-height"
|
||||
:placeholder="$t('register.placeholder.username')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model.trim="formData.password"
|
||||
class="custom-height"
|
||||
:placeholder="$t('register.placeholder.password')"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model.trim="formData.confirmPassword"
|
||||
class="custom-height"
|
||||
:placeholder="$t('register.placeholder.confirmPassword')"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
@keyup.enter="register"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem prop="agreement">
|
||||
<ElCheckbox v-model="formData.agreement">
|
||||
{{ $t('register.agreeText') }}
|
||||
<RouterLink
|
||||
style="color: var(--theme-color); text-decoration: none"
|
||||
to="/privacy-policy"
|
||||
>
|
||||
{{ $t('register.privacyPolicy') }}
|
||||
</RouterLink>
|
||||
</ElCheckbox>
|
||||
</ElFormItem>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton
|
||||
v-ripple
|
||||
class="w-full custom-height"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="register"
|
||||
>
|
||||
{{ $t('register.submitBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-sm text-g-600">
|
||||
<span>{{ $t('register.hasAccount') }}</span>
|
||||
<RouterLink class="text-theme" :to="{ name: 'Login' }">
|
||||
{{ $t('register.toLogin') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
defineOptions({ name: 'Register' });
|
||||
|
||||
interface RegisterForm {
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
agreement: boolean;
|
||||
}
|
||||
|
||||
const USERNAME_MIN_LENGTH = 3;
|
||||
const USERNAME_MAX_LENGTH = 20;
|
||||
const PASSWORD_MIN_LENGTH = 6;
|
||||
const REDIRECT_DELAY = 1000;
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const loading = ref(false);
|
||||
const formKey = ref(0);
|
||||
|
||||
// 监听语言切换,重置表单
|
||||
watch(locale, () => {
|
||||
formKey.value++;
|
||||
});
|
||||
|
||||
const formData = reactive<RegisterForm>({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreement: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
* 当密码输入后,如果确认密码已填写,则触发确认密码的验证
|
||||
*/
|
||||
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
|
||||
if (!value) {
|
||||
callback(new Error(t('register.placeholder.password')));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.confirmPassword) {
|
||||
formRef.value?.validateField('confirmPassword');
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证确认密码
|
||||
* 检查确认密码是否与密码一致
|
||||
*/
|
||||
const validateConfirmPassword = (
|
||||
_rule: any,
|
||||
value: string,
|
||||
callback: (error?: Error) => void
|
||||
) => {
|
||||
if (!value) {
|
||||
callback(new Error(t('register.rule.confirmPasswordRequired')));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== formData.password) {
|
||||
callback(new Error(t('register.rule.passwordMismatch')));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证用户协议
|
||||
* 确保用户已勾选同意协议
|
||||
*/
|
||||
const validateAgreement = (_rule: any, value: boolean, callback: (error?: Error) => void) => {
|
||||
if (!value) {
|
||||
callback(new Error(t('register.rule.agreementRequired')));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const rules = computed<FormRules<RegisterForm>>(() => ({
|
||||
username: [
|
||||
{ required: true, message: t('register.placeholder.username'), trigger: 'blur' },
|
||||
{
|
||||
min: USERNAME_MIN_LENGTH,
|
||||
max: USERNAME_MAX_LENGTH,
|
||||
message: t('register.rule.usernameLength'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{ required: true, validator: validatePassword, trigger: 'blur' },
|
||||
{ min: PASSWORD_MIN_LENGTH, message: t('register.rule.passwordLength'), trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: 'blur' }],
|
||||
agreement: [{ validator: validateAgreement, trigger: 'change' }],
|
||||
}));
|
||||
|
||||
/**
|
||||
* 注册用户
|
||||
* 验证表单后提交注册请求
|
||||
*/
|
||||
const register = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
|
||||
// TODO: 替换为真实 API 调用
|
||||
// const params = {
|
||||
// username: formData.username,
|
||||
// password: formData.password
|
||||
// }
|
||||
// const res = await AuthService.register(params)
|
||||
// if (res.code === ApiStatus.success) {
|
||||
// ElMessage.success('注册成功')
|
||||
// toLogin()
|
||||
// }
|
||||
|
||||
// 模拟注册请求
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
ElMessage.success('注册成功');
|
||||
toLogin();
|
||||
}, REDIRECT_DELAY);
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error);
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 跳转到登录页面
|
||||
*/
|
||||
const toLogin = () => {
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'Login' });
|
||||
}, REDIRECT_DELAY);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../login/style.css';
|
||||
</style>
|
||||
@@ -0,0 +1,521 @@
|
||||
<!-- 部门配置 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
/>
|
||||
|
||||
<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
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button 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>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:dept',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'name',
|
||||
label: '部门名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入部门名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: '0' },
|
||||
{ label: '停用', value: '1' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
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' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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 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;
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await DeptAPI.deleteDept(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
const deptOptions = ref<OptionType[]>([]);
|
||||
|
||||
const detailFormData = ref<DeptTable>({ code: '' });
|
||||
|
||||
const formData = reactive<DeptForm>({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
code: '',
|
||||
order: 1,
|
||||
parent_id: undefined,
|
||||
status: '0',
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const CODE_PATTERN = /^[A-Za-z][A-Za-z0-9_]{1,15}$/;
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
|
||||
code: [
|
||||
{ required: true, message: '请输入部门编码', trigger: 'blur' },
|
||||
{
|
||||
pattern: CODE_PATTERN,
|
||||
message: '字母开头,2-16位字母/数字/下划线',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
order: [{ required: true, message: '请输入排序', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const initialFormData: DeptForm = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
code: '',
|
||||
order: 1,
|
||||
parent_id: undefined,
|
||||
status: '0',
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(
|
||||
type: 'create' | 'update' | 'detail',
|
||||
id?: number,
|
||||
parentId?: number
|
||||
) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await DeptAPI.detailDept(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '部门详情';
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
} else if (type === 'update') {
|
||||
dialogVisible.title = '修改部门';
|
||||
Object.assign(formData, response.data.data);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = '新增部门';
|
||||
formData.id = undefined;
|
||||
if (parentId) {
|
||||
formData.parent_id = parentId;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请先选择要操作的数据');
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm(`确认${status === '0' ? '启用' : '停用'}该项数据?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await DeptAPI.batchDept({ ids, status });
|
||||
refreshList();
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,822 @@
|
||||
<!-- 字典数据 -->
|
||||
<template>
|
||||
<EnhancedDrawer
|
||||
v-model="drawerVisible"
|
||||
:title="'【' + dictLabel + '】字典数据'"
|
||||
direction="rtl"
|
||||
:size="drawerSize"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:dict_data:create']"
|
||||
:perm-delete="['module_system:dict_data:delete']"
|
||||
:perm-patch="['module_system:dict_data:patch']"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar">
|
||||
<template #prepend>
|
||||
<el-tooltip content="导出">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dict_data:export']"
|
||||
type="warning"
|
||||
icon="download"
|
||||
circle
|
||||
@click="handleOpenExportsModal"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CrudToolbarRight>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@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"
|
||||
fixed
|
||||
label="序号"
|
||||
min-width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'dict_label')?.show"
|
||||
label="标签"
|
||||
prop="dict_label"
|
||||
min-width="150"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'status')?.show"
|
||||
label="状态"
|
||||
prop="status"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'dict_type')?.show"
|
||||
label="类型"
|
||||
prop="dict_type"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag type="primary">{{ scope.row.dict_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'dict_value')?.show"
|
||||
label="值"
|
||||
prop="dict_value"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'css_class')?.show"
|
||||
label="样式属性"
|
||||
prop="css_class"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'list_class')?.show"
|
||||
label="列表类样式"
|
||||
prop="list_class"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'dict_sort')?.show"
|
||||
label="排序"
|
||||
prop="dict_sort"
|
||||
min-width="60"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'is_default')?.show"
|
||||
label="是否默认"
|
||||
prop="is_default"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.is_default" type="success">是</el-tag>
|
||||
<el-tag v-else type="danger">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'description')?.show"
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
|
||||
label="创建时间"
|
||||
prop="created_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_time')?.show"
|
||||
label="更新时间"
|
||||
prop="updated_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
/>
|
||||
<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:dict_data:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dict_data:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dict_data: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
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="4" border>
|
||||
<el-descriptions-item label="数据标签" :span="2">
|
||||
{{ detailFormData.dict_label }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据类型" :span="2">
|
||||
{{ detailFormData.dict_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据值" :span="2">
|
||||
{{ detailFormData.dict_value }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="样式属性" :span="2">
|
||||
{{ detailFormData.css_class }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="列表样式属性" :span="2">
|
||||
{{ detailFormData.list_class }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="是否默认" :span="2">
|
||||
<el-tag v-if="detailFormData.is_default" type="success">是</el-tag>
|
||||
<el-tag v-else type="danger">否</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag v-if="detailFormData.status === '0'" type="success">启用</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序" :span="2">
|
||||
{{ detailFormData.dict_sort }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ detailFormData.description }}
|
||||
</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>
|
||||
</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="dict_type">
|
||||
<el-input
|
||||
v-model="formData.dict_type"
|
||||
placeholder="请输入数据类型"
|
||||
:maxlength="50"
|
||||
:disabled="true"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据标签" prop="dict_label">
|
||||
<el-input
|
||||
v-model="formData.dict_label"
|
||||
placeholder="请输入数据标签"
|
||||
:maxlength="255"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据值" prop="dict_value">
|
||||
<el-input v-model="formData.dict_value" placeholder="请输入数据值" :maxlength="255" />
|
||||
</el-form-item>
|
||||
<el-form-item label="样式属性" prop="css_class">
|
||||
<el-select
|
||||
v-model="formData.css_class"
|
||||
placeholder="请选择常用颜色或输入自定义"
|
||||
clearable
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
>
|
||||
<el-option value="primary" label="主要(primary)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('primary')">
|
||||
主要(primary)
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option value="success" label="成功(success)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('success')">
|
||||
成功(success)
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option value="warning" label="警告(warning)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('warning')">
|
||||
警告(warning)
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option value="danger" label="危险(danger)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('danger')">
|
||||
危险(danger)
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option value="info" label="信息(info)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('info')">
|
||||
信息(info)
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="列表类样式" prop="list_class">
|
||||
<el-select v-model="formData.list_class" placeholder="请选择列表类样式" clearable>
|
||||
<el-option value="default" label="默认(default)">
|
||||
<span class="tag-option-preview tag-option-preview--default">默认(default)</span>
|
||||
</el-option>
|
||||
<el-option value="primary" label="主要(primary)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('primary')">
|
||||
主要(primary)
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option value="success" label="成功(success)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('success')">
|
||||
成功(success)
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option value="warning" label="警告(warning)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('warning')">
|
||||
警告(warning)
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option value="danger" label="危险(danger)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('danger')">
|
||||
危险(danger)
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option value="info" label="信息(info)">
|
||||
<span class="tag-option-preview" :style="getTagPreviewStyle('info')">
|
||||
信息(info)
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否默认" prop="is_default">
|
||||
<el-radio-group v-model="formData.is_default">
|
||||
<el-radio :value="true">是</el-radio>
|
||||
<el-radio :value="false">否</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="dict_sort">
|
||||
<el-input-number v-model="formData.dict_sort" controls-position="right" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="formData.status"
|
||||
inline-prompt
|
||||
:active-value="'0'"
|
||||
:inactive-value="'1'"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<ExportModal
|
||||
v-model="exportsDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="exportPageData"
|
||||
:selection-data="exportSelectionData"
|
||||
/>
|
||||
</div>
|
||||
</EnhancedDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DictAPI, {
|
||||
DictDataTable,
|
||||
DictDataForm,
|
||||
DictDataPageQuery,
|
||||
} from '@/api/module_system/dict';
|
||||
import { useDictStore } from '@/store';
|
||||
import { useAppStore } from '@/store/modules/app.store';
|
||||
import { DeviceEnum } from '@/enums/settings/device.enum';
|
||||
import ExportModal from '@/components/CURD/ExportModal.vue';
|
||||
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 EnhancedDrawer from '@/components/CURD/EnhancedDrawer.vue';
|
||||
import type { ISearchConfig, IContentConfig, IObject } from '@/components/CURD/types';
|
||||
import { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import { computed, ref, reactive, unref } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
|
||||
const props = defineProps({
|
||||
dictType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
dictLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
dictTypeId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const TAG_TYPE_STYLE_MAP: Record<string, { background: string; color: string; border: string }> =
|
||||
{
|
||||
primary: {
|
||||
background: 'var(--el-color-primary-light-9)',
|
||||
color: 'var(--el-color-primary)',
|
||||
border: 'var(--el-color-primary-light-7)',
|
||||
},
|
||||
success: {
|
||||
background: 'var(--el-color-success-light-9)',
|
||||
color: 'var(--el-color-success)',
|
||||
border: 'var(--el-color-success-light-7)',
|
||||
},
|
||||
warning: {
|
||||
background: 'var(--el-color-warning-light-9)',
|
||||
color: 'var(--el-color-warning)',
|
||||
border: 'var(--el-color-warning-light-7)',
|
||||
},
|
||||
danger: {
|
||||
background: 'var(--el-color-danger-light-9)',
|
||||
color: 'var(--el-color-danger)',
|
||||
border: 'var(--el-color-danger-light-7)',
|
||||
},
|
||||
info: {
|
||||
background: 'var(--el-color-info-light-9)',
|
||||
color: 'var(--el-color-info)',
|
||||
border: 'var(--el-color-info-light-7)',
|
||||
},
|
||||
};
|
||||
|
||||
const drawerVisible = defineModel<boolean>({ required: true });
|
||||
|
||||
const appStore = useAppStore();
|
||||
const dictStore = useDictStore();
|
||||
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? '80%' : '60%'));
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:dict_data',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'dict_label',
|
||||
label: '字典数据标签',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入字典标签', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: '0' },
|
||||
{ label: '停用', value: '1' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
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' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentCols = reactive<
|
||||
Array<{
|
||||
prop?: string;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
}>
|
||||
>([
|
||||
{ prop: 'selection', label: '选择框', show: true },
|
||||
{ prop: 'index', label: '序号', show: true },
|
||||
{ prop: 'dict_label', label: '标签', show: true },
|
||||
{ prop: 'status', label: '状态', show: true },
|
||||
{ prop: 'dict_type', label: '类型', show: true },
|
||||
{ prop: 'dict_value', label: '值', show: true },
|
||||
{ prop: 'css_class', label: '样式属性', show: true },
|
||||
{ prop: 'list_class', label: '列表类样式', show: true },
|
||||
{ prop: 'dict_sort', label: '排序', show: true },
|
||||
{ prop: 'is_default', 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 },
|
||||
]);
|
||||
|
||||
function getTagPreviewStyle(value?: string) {
|
||||
const preset = value ? TAG_TYPE_STYLE_MAP[value] : undefined;
|
||||
if (preset) {
|
||||
return {
|
||||
backgroundColor: preset.background,
|
||||
color: preset.color,
|
||||
borderColor: preset.border,
|
||||
};
|
||||
}
|
||||
if (!value) return {};
|
||||
return {
|
||||
backgroundColor: value,
|
||||
color: '#fff',
|
||||
borderColor: value,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDictQuery(params: IObject): DictDataPageQuery {
|
||||
return {
|
||||
...params,
|
||||
dict_type: props.dictType,
|
||||
dict_type_id: props.dictTypeId,
|
||||
} as DictDataPageQuery;
|
||||
}
|
||||
|
||||
const contentConfig = reactive<IContentConfig<DictDataPageQuery>>({
|
||||
permPrefix: 'module_system:dict_data',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await DictAPI.listDictData(mergeDictQuery(params as IObject));
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await DictAPI.deleteDictData(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
dictStore.clearDictData();
|
||||
if (props.dictType) {
|
||||
await dictStore.getDict([props.dictType]);
|
||||
}
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
const detailFormData = ref<DictDataTable>({});
|
||||
|
||||
const formData = reactive<DictDataForm>({
|
||||
id: undefined,
|
||||
dict_sort: 1,
|
||||
dict_label: '',
|
||||
dict_value: '',
|
||||
dict_type: '',
|
||||
css_class: '',
|
||||
list_class: undefined,
|
||||
is_default: false,
|
||||
status: '0',
|
||||
description: '',
|
||||
dict_type_id: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
dict_label: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
|
||||
dict_type: [{ required: true, message: '请输入字典类型', trigger: 'blur' }],
|
||||
dict_value: [{ required: true, message: '请输入字典键值', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'blur' }],
|
||||
dict_sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
|
||||
is_default: [{ required: true, message: '请选择是否默认', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const initialFormData: DictDataForm = {
|
||||
id: undefined,
|
||||
dict_sort: 1,
|
||||
dict_label: '',
|
||||
dict_value: '',
|
||||
dict_type: '',
|
||||
css_class: '',
|
||||
list_class: undefined,
|
||||
is_default: false,
|
||||
status: '0',
|
||||
description: '',
|
||||
dict_type_id: props.dictTypeId,
|
||||
};
|
||||
|
||||
const exportsDialogVisible = ref(false);
|
||||
|
||||
const exportQueryParams = computed(() => mergeDictQuery(searchRef.value?.getQueryParams() ?? {}));
|
||||
|
||||
const exportPageData = computed(() => {
|
||||
const pd = contentRef.value?.pageData;
|
||||
return (unref(pd) ?? []) as DictDataTable[];
|
||||
});
|
||||
|
||||
const exportSelectionData = computed(
|
||||
() => (contentRef.value?.getSelectionData() ?? []) as DictDataTable[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ prop: 'dict_label', label: '数据标签' },
|
||||
{ prop: 'dict_type', label: '数据类型' },
|
||||
{ prop: 'dict_value', label: '数据值' },
|
||||
{ prop: 'css_class', label: '样式属性' },
|
||||
{ prop: 'list_class', label: '列表类样式' },
|
||||
{ prop: 'dict_sort', label: '排序' },
|
||||
{ prop: 'is_default', label: '是否默认' },
|
||||
{ prop: 'status', label: '状态' },
|
||||
{ prop: 'description', label: '描述' },
|
||||
{ prop: 'created_time', label: '创建时间' },
|
||||
{ prop: 'updated_time', label: '更新时间' },
|
||||
];
|
||||
|
||||
const curdContentConfig = {
|
||||
permPrefix: 'module_system:dict_data',
|
||||
cols: exportColumns as any,
|
||||
exportsAction: async (params: any) => {
|
||||
const query: Record<string, unknown> = { ...mergeDictQuery(params) };
|
||||
return fetchAllPages({
|
||||
initialQuery: query,
|
||||
fetchPage: async (q) => {
|
||||
const res = await DictAPI.listDictData(q as unknown as DictDataPageQuery);
|
||||
return {
|
||||
total: res.data?.data?.total ?? 0,
|
||||
list: res.data?.data?.items ?? [],
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
} as unknown as IContentConfig;
|
||||
|
||||
function handleOpenExportsModal() {
|
||||
exportsDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, { ...initialFormData, dict_type_id: props.dictTypeId });
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await DictAPI.detailDictData(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '字典数据详情';
|
||||
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.dict_type = props.dictType;
|
||||
formData.status = '0';
|
||||
formData.id = undefined;
|
||||
formData.dict_type_id = props.dictTypeId;
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: any) => {
|
||||
if (valid) {
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await DictAPI.updateDictData(id, { id, ...formData });
|
||||
} else {
|
||||
await DictAPI.createDictData(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
dictStore.clearDictData();
|
||||
if (formData.dict_type) {
|
||||
await dictStore.getDict([formData.dict_type]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMoreClick(status: string) {
|
||||
const rows = contentRef.value?.getSelectionData() as DictDataTable[] | undefined;
|
||||
const ids = (rows ?? []).map((r) => r.id).filter((id): id is number => id != null);
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请先选择要操作的数据');
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm(`确认${status === '0' ? '启用' : '停用'}该项数据?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await DictAPI.batchDictData({ ids, status });
|
||||
refreshList();
|
||||
dictStore.clearDictData();
|
||||
if (props.dictType) {
|
||||
await dictStore.getDict([props.dictType]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.drawer-content :deep(.el-card.data-table) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tag-option-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 120px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tag-option-preview--default {
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,581 @@
|
||||
<!-- 字典 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent
|
||||
ref="contentRef"
|
||||
:content-config="contentConfig"
|
||||
@add-click="handleOpenDialog('create')"
|
||||
>
|
||||
<!-- 与 PageContent 默认结构一致:不再套一层 data-table__toolbar(外层已由组件提供) -->
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:dict_type:create']"
|
||||
:perm-delete="['module_system:dict_type:delete']"
|
||||
:perm-patch="['module_system:dict_type:patch']"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar">
|
||||
<template #prepend>
|
||||
<el-tooltip content="导出">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dict_type:export']"
|
||||
type="warning"
|
||||
icon="download"
|
||||
circle
|
||||
@click="handleOpenExportsModal"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CrudToolbarRight>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@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"
|
||||
fixed
|
||||
label="序号"
|
||||
min-width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'dict_name')?.show"
|
||||
key="dict_name"
|
||||
label="字典名称"
|
||||
prop="dict_name"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'dict_type')?.show"
|
||||
key="dict_type"
|
||||
label="字典类型"
|
||||
prop="dict_type"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag type="primary">{{ scope.row.dict_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'status')?.show"
|
||||
key="status"
|
||||
label="状态"
|
||||
prop="status"
|
||||
min-width="80"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'description')?.show"
|
||||
key="description"
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
|
||||
key="created_time"
|
||||
label="创建时间"
|
||||
prop="created_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_time')?.show"
|
||||
key="updated_time"
|
||||
label="更新时间"
|
||||
prop="updated_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'operation')?.show"
|
||||
fixed="right"
|
||||
label="操作"
|
||||
align="center"
|
||||
min-width="260"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dict_data:query']"
|
||||
type="warning"
|
||||
size="small"
|
||||
link
|
||||
icon="Document"
|
||||
@click="handleDictDataDrawer(scope.row)"
|
||||
>
|
||||
字典
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dict_type:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dict_type:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:dict_type: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
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="4" border>
|
||||
<el-descriptions-item label="字典名称" :span="2">
|
||||
{{ detailFormData.dict_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="字典类型" :span="2">
|
||||
<el-tag type="primary">{{ detailFormData.dict_type }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag v-if="detailFormData.status === '0'" type="success">启用</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ detailFormData.description }}
|
||||
</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>
|
||||
</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="dict_name">
|
||||
<el-input v-model="formData.dict_name" placeholder="请输入字典名称" :maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="字典类型" prop="dict_type">
|
||||
<el-input v-model="formData.dict_type" placeholder="请输入字典类型" :maxlength="50" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<ExportModal
|
||||
v-model="exportsDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="exportPageData"
|
||||
:selection-data="exportSelectionData"
|
||||
/>
|
||||
<DataDrawer
|
||||
v-if="drawerVisible"
|
||||
v-model="drawerVisible"
|
||||
:dict-type="currentDictType"
|
||||
:dict-label="currentDictLabel"
|
||||
:dict-type-id="currentDictTypeId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Dict',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import DictAPI, { DictTable, DictForm, DictPageQuery } from '@/api/module_system/dict';
|
||||
import { useDictStore } from '@/store';
|
||||
import DataDrawer from '@/views/module_system/dict/components/DataDrawer.vue';
|
||||
import ExportModal from '@/components/CURD/ExportModal.vue';
|
||||
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 { computed, ref, reactive, unref } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:dict_type',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'dict_name',
|
||||
label: '字典名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入字典名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'dict_type',
|
||||
label: '字典类型',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入字典类型', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: '0' },
|
||||
{ label: '停用', value: '1' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
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' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentCols = reactive<
|
||||
Array<{
|
||||
prop?: string;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
}>
|
||||
>([
|
||||
{ prop: 'selection', label: '选择框', show: true },
|
||||
{ prop: 'index', label: '序号', show: true },
|
||||
{ prop: 'dict_name', label: '字典名称', show: true },
|
||||
{ prop: 'dict_type', 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 dictStore = useDictStore();
|
||||
|
||||
const contentConfig = reactive<IContentConfig<DictPageQuery>>({
|
||||
permPrefix: 'module_system:dict_type',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await DictAPI.listDictType(params as DictPageQuery);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await DictAPI.deleteDictType(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
dictStore.clearDictData();
|
||||
const dictTypes = Object.keys(dictStore.dictData);
|
||||
if (dictTypes.length > 0) {
|
||||
await dictStore.getDict(dictTypes);
|
||||
}
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
const detailFormData = ref<DictTable>({});
|
||||
|
||||
const formData = reactive<DictForm>({
|
||||
id: undefined,
|
||||
dict_name: '',
|
||||
dict_type: '',
|
||||
status: '0',
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
dict_name: [{ required: true, message: '请输入字典名称', trigger: 'blur' }],
|
||||
dict_type: [{ required: true, message: '请选择字典类型', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择字典状态', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const initialFormData: DictForm = {
|
||||
id: undefined,
|
||||
dict_name: '',
|
||||
dict_type: '',
|
||||
status: '0',
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
const exportsDialogVisible = ref(false);
|
||||
|
||||
const exportQueryParams = computed(() => searchRef.value?.getQueryParams() ?? {});
|
||||
|
||||
const exportPageData = computed(() => {
|
||||
const pd = contentRef.value?.pageData;
|
||||
return (unref(pd) ?? []) as DictTable[];
|
||||
});
|
||||
|
||||
const exportSelectionData = computed(
|
||||
() => (contentRef.value?.getSelectionData() ?? []) as DictTable[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ prop: 'dict_name', label: '字典名称' },
|
||||
{ prop: 'dict_type', label: '字典类型' },
|
||||
{ prop: 'status', label: '状态' },
|
||||
{ prop: 'description', label: '描述' },
|
||||
{ prop: 'created_time', label: '创建时间' },
|
||||
{ prop: 'updated_time', label: '更新时间' },
|
||||
];
|
||||
|
||||
const curdContentConfig = {
|
||||
permPrefix: 'module_system:dict_type',
|
||||
cols: exportColumns as any,
|
||||
exportsAction: async (params: any) => {
|
||||
const query: Record<string, unknown> = { ...params };
|
||||
if (typeof query.status === 'string') query.status = query.status === 'true';
|
||||
return fetchAllPages({
|
||||
initialQuery: query,
|
||||
fetchPage: async (q) => {
|
||||
const res = await DictAPI.listDictType(q as unknown as DictPageQuery);
|
||||
return {
|
||||
total: res.data?.data?.total ?? 0,
|
||||
list: res.data?.data?.items ?? [],
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
} as unknown as IContentConfig;
|
||||
|
||||
function handleOpenExportsModal() {
|
||||
exportsDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await DictAPI.detailDictType(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '字典详情';
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
} else if (type === 'update') {
|
||||
dialogVisible.title = '修改字典';
|
||||
Object.assign(formData, response.data.data);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = '新增字典';
|
||||
formData.id = undefined;
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: any) => {
|
||||
if (valid) {
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await DictAPI.updateDictType(id, { id, ...formData });
|
||||
} else {
|
||||
await DictAPI.createDictType(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
dictStore.clearDictData();
|
||||
if (formData.dict_type) {
|
||||
await dictStore.getDict([formData.dict_type]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMoreClick(status: string) {
|
||||
const rows = contentRef.value?.getSelectionData() as DictTable[] | undefined;
|
||||
const ids = (rows ?? []).map((r) => r.id).filter((id): id is number => id != null);
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请先选择要操作的数据');
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm(`确认${status === '0' ? '启用' : '停用'}该项数据?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await DictAPI.batchDictType({ ids, status });
|
||||
refreshList();
|
||||
dictStore.clearDictData();
|
||||
const dictTypes = Object.keys(dictStore.dictData);
|
||||
if (dictTypes.length > 0) {
|
||||
await dictStore.getDict(dictTypes);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const currentDictType = ref('');
|
||||
const currentDictLabel = ref('');
|
||||
const currentDictTypeId = ref(1);
|
||||
|
||||
function handleDictDataDrawer(dictType: DictTable) {
|
||||
currentDictType.value = dictType.dict_type || '';
|
||||
currentDictLabel.value = dictType.dict_name || '';
|
||||
currentDictTypeId.value = dictType.id || 0;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,555 @@
|
||||
<!-- 日志管理 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-delete="['module_system:log:delete']"
|
||||
@delete="onToolbar('delete')"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar">
|
||||
<template #prepend>
|
||||
<el-tooltip content="导出">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:log:export']"
|
||||
type="warning"
|
||||
icon="download"
|
||||
circle
|
||||
@click="handleOpenExportsModal"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CrudToolbarRight>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@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"
|
||||
fixed
|
||||
label="序号"
|
||||
min-width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'type')?.show"
|
||||
label="日志类型"
|
||||
prop="type"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.type === 1 ? 'success' : 'primary'">
|
||||
{{ scope.row.type === 1 ? '登录日志' : '操作日志' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'request_path')?.show"
|
||||
label="请求路径"
|
||||
prop="request_path"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'request_method')?.show"
|
||||
label="请求方法"
|
||||
prop="request_method"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="getMethodType(scope.row.request_method)">
|
||||
{{ scope.row.request_method }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'response_code')?.show"
|
||||
label="状态码"
|
||||
prop="response_code"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusCodeType(scope.row.response_code)">
|
||||
{{ scope.row.response_code }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'request_ip')?.show"
|
||||
label="请求IP"
|
||||
prop="request_ip"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-text>{{ scope.row.request_ip }}</el-text>
|
||||
<CopyButton
|
||||
v-if="scope.row.request_ip"
|
||||
:text="scope.row.request_ip"
|
||||
:style="{ marginLeft: '2px' }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'process_time')?.show"
|
||||
label="处理时间"
|
||||
prop="process_time"
|
||||
min-width="120"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'request_browser')?.show"
|
||||
label="浏览器"
|
||||
prop="request_browser"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'request_os')?.show"
|
||||
label="系统"
|
||||
prop="request_os"
|
||||
min-width="100"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'description')?.show"
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
|
||||
label="创建时间"
|
||||
prop="created_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_id')?.show"
|
||||
label="创建人"
|
||||
prop="created_id"
|
||||
min-width="120"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.created_by?.name }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_id')?.show"
|
||||
label="更新人"
|
||||
prop="updated_id"
|
||||
min-width="120"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.updated_by?.name }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'operation')?.show"
|
||||
fixed="right"
|
||||
label="操作"
|
||||
align="center"
|
||||
min-width="150"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:log:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:log: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
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="8" border label-width="200px">
|
||||
<el-descriptions-item label="日志类型" :span="2">
|
||||
<el-tag :type="formData.type === 1 ? 'success' : 'primary'">
|
||||
{{ formData.type === 1 ? '登录日志' : '操作日志' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求路径" :span="2">
|
||||
{{ formData.request_path }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法" :span="2">
|
||||
<el-tag :type="getMethodType(formData.request_method)">
|
||||
{{ formData.request_method }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="响应状态码" :span="2">
|
||||
<el-tag :type="getStatusCodeType(formData.response_code)">
|
||||
{{ formData.response_code }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求IP" :span="2">
|
||||
{{ formData.request_ip }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理时间" :span="2">
|
||||
{{ formData.process_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="浏览器" :span="2">
|
||||
{{ formData.request_browser }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作系统" :span="2">
|
||||
{{ formData.request_os }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求参数" :span="8">
|
||||
<JsonPretty :value="formData.request_payload" height="80px" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="响应数据" :span="8">
|
||||
<JsonPretty :value="formData.response_json" height="140px" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录地点" :span="4">
|
||||
{{ formData.login_location }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="8">
|
||||
{{ formData.description }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人" :span="4">
|
||||
{{ formData.created_by?.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新人" :span="4">
|
||||
{{ formData.updated_by?.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="4">
|
||||
{{ formData.created_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="4">
|
||||
{{ formData.updated_time }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:log:detail']"
|
||||
type="primary"
|
||||
@click="handleCloseDialog"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<ExportModal
|
||||
v-model="exportsDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="exportPageData"
|
||||
:selection-data="exportSelectionData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Log',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import { ref, reactive, computed, unref, markRaw, nextTick } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
import LogAPI, { LogTable, LogPageQuery } from '@/api/module_system/log';
|
||||
import UserTableSelect from '@/views/module_system/user/components/UserTableSelect.vue';
|
||||
import ExportModal from '@/components/CURD/ExportModal.vue';
|
||||
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 JsonPretty from '@/components/JsonPretty/index.vue';
|
||||
import { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
|
||||
function triggerUserSearch() {
|
||||
nextTick(() => refreshList());
|
||||
}
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:log',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'request_path',
|
||||
label: '请求路径',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入请求路径', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'type',
|
||||
label: '日志类型',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '登录日志', value: 1 },
|
||||
{ label: '操作日志', value: 2 },
|
||||
],
|
||||
attrs: { placeholder: '请选择日志类型', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
prop: 'created_id',
|
||||
label: '创建人',
|
||||
type: 'user-table-select',
|
||||
initialValue: null,
|
||||
events: {
|
||||
'confirm-click': triggerUserSearch,
|
||||
'clear-click': triggerUserSearch,
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'created_time',
|
||||
label: '创建时间',
|
||||
type: 'date-picker',
|
||||
initialValue: [],
|
||||
attrs: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
style: { width: '340px' },
|
||||
},
|
||||
},
|
||||
],
|
||||
customComponents: {
|
||||
'user-table-select': markRaw(UserTableSelect),
|
||||
},
|
||||
});
|
||||
|
||||
const contentCols = reactive<
|
||||
Array<{
|
||||
prop?: string;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
}>
|
||||
>([
|
||||
{ prop: 'selection', label: '选择框', show: true },
|
||||
{ prop: 'index', label: '序号', show: true },
|
||||
{ prop: 'type', label: '日志类型', show: true },
|
||||
{ prop: 'request_path', label: '请求路径', show: true },
|
||||
{ prop: 'request_method', label: '请求方法', show: true },
|
||||
{ prop: 'response_code', label: '状态码', show: true },
|
||||
{ prop: 'request_ip', label: '请求IP', show: true },
|
||||
{ prop: 'process_time', label: '处理时间', show: true },
|
||||
{ prop: 'request_browser', label: '浏览器', show: true },
|
||||
{ prop: 'request_os', label: '系统', show: true },
|
||||
{ prop: 'description', label: '描述', show: true },
|
||||
{ prop: 'created_time', label: '创建时间', show: true },
|
||||
{ prop: 'created_id', label: '创建人', show: true },
|
||||
{ prop: 'updated_id', label: '更新人', show: true },
|
||||
{ prop: 'operation', label: '操作', show: true },
|
||||
]);
|
||||
|
||||
const contentConfig = reactive<IContentConfig<LogPageQuery>>({
|
||||
permPrefix: 'module_system:log',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await LogAPI.listLog(params as LogPageQuery);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await LogAPI.deleteLog(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
const formData = ref<LogTable>({});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const getStatusCodeType = (code?: number) => {
|
||||
if (code === undefined) {
|
||||
return 'info';
|
||||
}
|
||||
if (code >= 200 && code < 300) {
|
||||
return 'success';
|
||||
} else if (code >= 300 && code < 400) {
|
||||
return 'warning';
|
||||
} else if (code >= 400 && code < 500) {
|
||||
return 'danger';
|
||||
} else {
|
||||
return 'danger';
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodType = (method?: string) => {
|
||||
if (method === undefined) {
|
||||
return 'info';
|
||||
}
|
||||
if (method === 'GET') {
|
||||
return 'info';
|
||||
} else if (method === 'POST') {
|
||||
return 'success';
|
||||
} else if (method === 'PUT' || method === 'PATCH') {
|
||||
return 'warning';
|
||||
} else if (method === 'DELETE') {
|
||||
return 'danger';
|
||||
} else {
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
async function resetForm() {
|
||||
formData.value.id = undefined;
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await LogAPI.detailLog(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '日志详情';
|
||||
Object.assign(formData.value, response.data.data);
|
||||
}
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
const exportsDialogVisible = ref(false);
|
||||
|
||||
const exportQueryParams = computed(() => searchRef.value?.getQueryParams() ?? {});
|
||||
|
||||
const exportPageData = computed(() => {
|
||||
const pd = contentRef.value?.pageData;
|
||||
return (unref(pd) ?? []) as LogTable[];
|
||||
});
|
||||
|
||||
const exportSelectionData = computed(
|
||||
() => (contentRef.value?.getSelectionData() ?? []) as LogTable[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ prop: 'type', label: '日志类型' },
|
||||
{ prop: 'request_path', label: '请求路径' },
|
||||
{ prop: 'request_method', label: '请求方法' },
|
||||
{ prop: 'response_code', label: '状态码' },
|
||||
{ prop: 'request_ip', label: '请求IP' },
|
||||
{ prop: 'login_location', label: '登录地点' },
|
||||
{ prop: 'process_time', label: '处理时间' },
|
||||
{ prop: 'request_browser', label: '浏览器' },
|
||||
{ prop: 'request_os', label: '系统' },
|
||||
{ prop: 'description', label: '描述' },
|
||||
{ prop: 'created_time', label: '创建时间' },
|
||||
{ prop: 'updated_time', label: '更新时间' },
|
||||
];
|
||||
|
||||
const curdContentConfig = {
|
||||
permPrefix: 'module_system:log',
|
||||
cols: exportColumns as any,
|
||||
exportsAction: async (params: any) => {
|
||||
const query: Record<string, unknown> = { ...params };
|
||||
return fetchAllPages({
|
||||
initialQuery: query,
|
||||
fetchPage: async (q) => {
|
||||
const res = await LogAPI.listLog(q as unknown as LogPageQuery);
|
||||
return {
|
||||
total: res.data?.data?.total ?? 0,
|
||||
list: res.data?.data?.items ?? [],
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
} as unknown as IContentConfig;
|
||||
|
||||
function handleOpenExportsModal() {
|
||||
exportsDialogVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,637 @@
|
||||
<!-- 公告通知配置 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:notice:create']"
|
||||
:perm-delete="['module_system:notice:delete']"
|
||||
:perm-patch="['module_system:notice:patch']"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar">
|
||||
<template #prepend>
|
||||
<el-tooltip content="导出">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:notice:export']"
|
||||
type="warning"
|
||||
icon="download"
|
||||
circle
|
||||
@click="handleOpenExportsModal"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CrudToolbarRight>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="70" 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"
|
||||
fixed
|
||||
label="序号"
|
||||
min-width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'notice_title')?.show"
|
||||
label="通知标题"
|
||||
prop="notice_title"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'status')?.show"
|
||||
label="状态"
|
||||
prop="status"
|
||||
min-width="80"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'notice_type')?.show"
|
||||
label="类型"
|
||||
prop="notice_type"
|
||||
min-width="80"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.notice_type === '1' ? 'primary' : 'warning'">
|
||||
{{
|
||||
(scope.row.notice_type
|
||||
? (dictStore.getDictLabel('sys_notice_type', scope.row.notice_type) as any)
|
||||
: undefined
|
||||
)?.dict_label || scope.row.notice_type
|
||||
}}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'notice_content')?.show"
|
||||
label="内容"
|
||||
prop="notice_content"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'description')?.show"
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
|
||||
label="创建时间"
|
||||
prop="created_time"
|
||||
min-width="180"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_time')?.show"
|
||||
label="更新时间"
|
||||
prop="updated_time"
|
||||
min-width="180"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_id')?.show"
|
||||
key="created_id"
|
||||
label="创建人"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.created_by?.name }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_id')?.show"
|
||||
key="updated_id"
|
||||
label="更新人"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.updated_by?.name }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<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:notice:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:notice:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:notice: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
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="4" border label-width="120px">
|
||||
<el-descriptions-item label="标题" :span="2">
|
||||
{{ detailFormData.notice_title }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型" :span="2">
|
||||
<el-tag :type="detailFormData.notice_type === '1' ? 'primary' : 'warning'">
|
||||
{{
|
||||
(detailFormData.notice_type
|
||||
? (dictStore.getDictLabel('sys_notice_type', detailFormData.notice_type) as any)
|
||||
: undefined
|
||||
)?.dict_label || detailFormData.notice_type
|
||||
}}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="detailFormData.status === '0' ? 'success' : 'danger'">
|
||||
{{ detailFormData.status === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ detailFormData.description }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="内容" :span="4">
|
||||
<WangEditor v-model="detailFormData.notice_content" :readonly="true" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人" :span="2">
|
||||
{{ detailFormData.created_by?.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新人" :span="2">
|
||||
{{ detailFormData.updated_by?.name }}
|
||||
</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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form
|
||||
ref="dataFormRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
label-width="auto"
|
||||
label-position="right"
|
||||
:inline="true"
|
||||
>
|
||||
<el-form-item label="标题" prop="notice_title">
|
||||
<el-input v-model="formData.notice_title" placeholder="请输入标题" :maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
:rows="1"
|
||||
:maxlength="100"
|
||||
show-word-limit
|
||||
type="textarea"
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="notice_type" class="w-50">
|
||||
<el-select v-model="formData.notice_type" placeholder="请选择类型" clearable>
|
||||
<el-option
|
||||
v-for="item in dictStore.getDictArray('sys_notice_type')"
|
||||
:key="item.dict_value"
|
||||
:value="item.dict_value"
|
||||
:label="item.dict_label"
|
||||
/>
|
||||
</el-select>
|
||||
</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="notice_content">
|
||||
<WangEditor v-model="formData.notice_content" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
v-if="dialogVisible.type !== 'detail'"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<ExportModal
|
||||
v-model="exportsDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="exportPageData"
|
||||
:selection-data="exportSelectionData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, markRaw, nextTick, onMounted, unref } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
import { useDictStore, useNoticeStore } from '@/store/index';
|
||||
import UserTableSelect from '@/views/module_system/user/components/UserTableSelect.vue';
|
||||
import ExportModal from '@/components/CURD/ExportModal.vue';
|
||||
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 { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
import { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import NoticeAPI, { NoticeTable, NoticeForm, NoticePageQuery } from '@/api/module_system/notice';
|
||||
|
||||
const dictStore = useDictStore();
|
||||
defineOptions({
|
||||
name: 'Notice',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
|
||||
function triggerUserSearch() {
|
||||
nextTick(() => refreshList());
|
||||
}
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:notice',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 3,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'notice_title',
|
||||
label: '标题',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入标题', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'notice_type',
|
||||
label: '类型',
|
||||
type: 'select',
|
||||
options: [] as { label: string; value: string }[],
|
||||
attrs: { placeholder: '请选择类型', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: '0' },
|
||||
{ label: '停用', value: '1' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
prop: 'created_time',
|
||||
label: '创建时间',
|
||||
type: 'date-picker',
|
||||
initialValue: [],
|
||||
attrs: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
style: { width: '340px' },
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'created_id',
|
||||
label: '创建人',
|
||||
type: 'user-table-select',
|
||||
initialValue: null,
|
||||
events: {
|
||||
'confirm-click': triggerUserSearch,
|
||||
'clear-click': triggerUserSearch,
|
||||
},
|
||||
},
|
||||
],
|
||||
customComponents: {
|
||||
'user-table-select': markRaw(UserTableSelect),
|
||||
},
|
||||
});
|
||||
|
||||
const contentCols = reactive<
|
||||
Array<{
|
||||
prop?: string;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
}>
|
||||
>([
|
||||
{ prop: 'selection', label: '选择框', show: true },
|
||||
{ prop: 'index', label: '序号', show: true },
|
||||
{ prop: 'notice_title', label: '标题', show: true },
|
||||
{ prop: 'notice_type', label: '类型', show: true },
|
||||
{ prop: 'notice_content', label: '内容', show: true },
|
||||
{ prop: 'status', label: '状态', show: true },
|
||||
{ prop: 'description', label: '描述', show: true },
|
||||
{ prop: 'created_time', label: '创建时间', show: true },
|
||||
{ prop: 'updated_id', label: '更新人', show: true },
|
||||
{ prop: 'updated_time', label: '更新时间', show: true },
|
||||
{ prop: 'created_id', label: '创建人', show: true },
|
||||
{ prop: 'operation', label: '操作', show: true },
|
||||
]);
|
||||
|
||||
const noticeStore = useNoticeStore();
|
||||
|
||||
const contentConfig = reactive<IContentConfig<NoticePageQuery>>({
|
||||
permPrefix: 'module_system:notice',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await NoticeAPI.listNotice(params as NoticePageQuery);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await NoticeAPI.deleteNotice(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
await noticeStore.getNotice();
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
const exportsDialogVisible = ref(false);
|
||||
|
||||
const exportQueryParams = computed(() => searchRef.value?.getQueryParams() ?? {});
|
||||
|
||||
const exportPageData = computed(() => (unref(contentRef.value?.pageData) ?? []) as NoticeTable[]);
|
||||
|
||||
const exportSelectionData = computed(
|
||||
() => (contentRef.value?.getSelectionData() ?? []) as NoticeTable[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ prop: 'notice_title', label: '标题' },
|
||||
{ prop: 'status', label: '状态' },
|
||||
{ prop: 'notice_type', label: '类型' },
|
||||
{ prop: 'notice_content', label: '内容' },
|
||||
{ prop: 'description', label: '描述' },
|
||||
{ prop: 'created_time', label: '创建时间' },
|
||||
{ prop: 'updated_time', label: '更新时间' },
|
||||
];
|
||||
|
||||
const curdContentConfig = {
|
||||
permPrefix: 'module_system:notice',
|
||||
cols: exportColumns as any,
|
||||
exportsAction: async (params: any) => {
|
||||
const query: Record<string, unknown> = { ...params };
|
||||
if (typeof query.status === 'string') query.status = query.status === 'true';
|
||||
return fetchAllPages({
|
||||
initialQuery: query,
|
||||
fetchPage: async (q) => {
|
||||
const res = await NoticeAPI.listNotice(q as unknown as NoticePageQuery);
|
||||
return {
|
||||
total: res.data?.data?.total ?? 0,
|
||||
list: res.data?.data?.items ?? [],
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
} as unknown as IContentConfig;
|
||||
|
||||
function handleOpenExportsModal() {
|
||||
exportsDialogVisible.value = true;
|
||||
}
|
||||
|
||||
const detailFormData = ref<NoticeTable>({});
|
||||
|
||||
const formData = reactive<NoticeForm>({
|
||||
id: undefined,
|
||||
notice_title: '',
|
||||
notice_type: '',
|
||||
notice_content: '',
|
||||
status: '0',
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
notice_title: [{ required: true, message: '请输入公告通知标题', trigger: 'blur' }],
|
||||
notice_type: [{ required: true, message: '请选择公告通知类型', trigger: 'blur' }],
|
||||
notice_content: [{ required: true, message: '请输入公告通知内容', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择公告通知状态', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const initialFormData: NoticeForm = {
|
||||
id: undefined,
|
||||
notice_title: '',
|
||||
notice_type: '',
|
||||
notice_content: '',
|
||||
status: '0',
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await NoticeAPI.detailNotice(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '公告通知详情';
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
} else if (type === 'update') {
|
||||
dialogVisible.title = '修改公告通知';
|
||||
Object.assign(formData, response.data.data);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = '新增公告通知';
|
||||
formData.id = undefined;
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: any) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await NoticeAPI.updateNotice(id, { id, ...formData });
|
||||
} else {
|
||||
await NoticeAPI.createNotice(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
await noticeStore.getNotice();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMoreClick(status: string) {
|
||||
const rows = contentRef.value?.getSelectionData() as NoticeTable[] | undefined;
|
||||
const ids = (rows ?? []).map((r) => r.id).filter((id): id is number => id != null);
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请先选择要操作的数据');
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm(`确认${status === '0' ? '启用' : '停用'}该项数据?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await NoticeAPI.batchNotice({ ids, status });
|
||||
refreshList();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await dictStore.getDict(['sys_notice_type']);
|
||||
const typeItem = searchConfig.formItems?.find((i) => i.prop === 'notice_type');
|
||||
if (typeItem && typeItem.type === 'select') {
|
||||
typeItem.options = dictStore.getDictArray('sys_notice_type').map((item) => ({
|
||||
label: item.dict_label,
|
||||
value: item.dict_value,
|
||||
}));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,718 @@
|
||||
<template>
|
||||
<EnhancedDrawer
|
||||
v-model="drawerVisible"
|
||||
title="配置中心"
|
||||
:size="drawerSize"
|
||||
destroy-on-close
|
||||
@close="onDrawerClosed"
|
||||
>
|
||||
<el-tabs v-model="activeTabRef" type="border-card">
|
||||
<!-- 网站配置 -->
|
||||
<el-tab-pane label="网站配置" name="website">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>网站配置</el-divider>
|
||||
<div v-for="(item, key) in systemConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<span class="flex items-center gap-2 w-full">
|
||||
<el-input
|
||||
v-model="item.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- logo配置 -->
|
||||
<el-divider>网站图标</el-divider>
|
||||
<div v-for="(item, key) in logoConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<SingleImageUpload
|
||||
v-model="item.config_value"
|
||||
:data="{ type: key }"
|
||||
:name="'file'"
|
||||
:max-file-size="item.maxFileSize"
|
||||
:show-tip="true"
|
||||
:enable-preview="true"
|
||||
@success="(fileInfo: UploadFilePath) => handleUploadSuccess(fileInfo, key)"
|
||||
@error="handleUploadError"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="安全隐私" name="securityPrivacy">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>安全隐私</el-divider>
|
||||
<div v-for="(item, key) in securityPrivacyConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<span class="flex items-center gap-2 w-full">
|
||||
<el-input
|
||||
v-model="item.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="用户协议" name="userAgreement">
|
||||
<!-- 系统配置 -->
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<el-divider>用户协议</el-divider>
|
||||
<div v-for="(item, key) in userAgreementConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<span class="flex items-center gap-2 w-full">
|
||||
<el-input
|
||||
v-model="item.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="接口白名单" name="apiWhitelist">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>接口白名单</el-divider>
|
||||
<div v-for="(item, key) in apiWhitelistConfigs" :key="key">
|
||||
<el-form-item :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
|
||||
v-model="listItem.value"
|
||||
:placeholder="'/api/v1/users/get'"
|
||||
clearable
|
||||
@input="markModified(key)"
|
||||
@blur="
|
||||
{
|
||||
if (!isValidApiPath(listItem.value) && listItem.value.trim()) {
|
||||
ElMessage.warning('请输入有效的接口路径格式(以/开头)');
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="removeApiWhitelistItem(listItem.id)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
icon="plus"
|
||||
size="small"
|
||||
style="margin-top: 10px"
|
||||
@click="addApiWhitelistItem"
|
||||
>
|
||||
添加接口路径
|
||||
</el-button>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
配置说明:添加到白名单的接口路径无需登录即可访问,支持完整路径配置。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</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">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>IP黑名单</el-divider>
|
||||
<div v-for="(item, key) in ipBlacklistConfigs" :key="key">
|
||||
<el-form-item :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
|
||||
v-model="listItem.value"
|
||||
:placeholder="'192.168.1.1'"
|
||||
clearable
|
||||
style="flex: 1"
|
||||
@input="markModified(key)"
|
||||
@blur="
|
||||
{
|
||||
if (!isValidIp(listItem.value) && listItem.value.trim()) {
|
||||
ElMessage.warning('请输入有效的IP地址格式');
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="removeIpBlacklistItem(listItem.id)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
icon="plus"
|
||||
size="small"
|
||||
style="margin-top: 10px"
|
||||
@click="addIpBlacklistItem"
|
||||
>
|
||||
添加IP地址
|
||||
</el-button>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
配置说明:添加到黑名单的IP地址将无法访问系统,支持单个IP配置。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="演示环境配置" name="demo">
|
||||
<el-form :model="configState" label-suffix=":" label-width="auto" label-position="right">
|
||||
<!-- 系统配置 -->
|
||||
<el-divider>演示环境配置</el-divider>
|
||||
<div v-for="(item, key) in demoConfigs" :key="key">
|
||||
<el-form-item :label="item.config_name">
|
||||
<!-- 演示模式开关 -->
|
||||
<template v-if="key === 'demo_enable'">
|
||||
<el-switch
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
:model-value="item.config_value === 'true'"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
item.config_value = value ? 'true' : 'false';
|
||||
markModified(key);
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
配置说明:启用后系统将进入演示模式,部分功能可能受限。
|
||||
</div>
|
||||
</template>
|
||||
<!-- IP白名单 -->
|
||||
<template v-else-if="key === 'ip_white_list'">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="listItem in demoIpWhitelistItems"
|
||||
:key="listItem.id"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<el-input
|
||||
v-model="listItem.value"
|
||||
:placeholder="'192.168.1.1'"
|
||||
clearable
|
||||
style="flex: 1"
|
||||
@input="markModified(key)"
|
||||
@blur="
|
||||
{
|
||||
if (!isValidIp(listItem.value) && listItem.value.trim()) {
|
||||
ElMessage.warning('请输入有效的IP地址格式');
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="removeDemoIpWhitelistItem(listItem.id)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
icon="plus"
|
||||
size="small"
|
||||
style="margin-top: 10px"
|
||||
@click="addDemoIpWhitelistItem"
|
||||
>
|
||||
添加IP地址
|
||||
</el-button>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
配置说明:演示模式下,只有白名单中的IP地址可以访问系统,支持单个IP配置。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 其他配置项 -->
|
||||
<template v-else>
|
||||
<el-input
|
||||
v-model="item.config_value"
|
||||
:placeholder="t('common.inputText')"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@input="markModified(key)"
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<template #footer>
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:config:update']"
|
||||
type="primary"
|
||||
:disabled="!hasChanges"
|
||||
@click="submitChanges"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</EnhancedDrawer>
|
||||
</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 { useI18n } from 'vue-i18n';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import EnhancedDrawer from '@/components/CURD/EnhancedDrawer.vue';
|
||||
import SingleImageUpload from '@/components/Upload/SingleImageUpload.vue';
|
||||
import { useAppStore } from '@/store/modules/app.store';
|
||||
import { DeviceEnum } from '@/enums/settings/device.enum';
|
||||
|
||||
// 定义列表项类型
|
||||
interface ListItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return Math.random().toString(36).substr(2, 9);
|
||||
};
|
||||
|
||||
// IP地址验证函数
|
||||
const isValidIp = (ip: string): boolean => {
|
||||
const ipRegex =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipRegex.test(ip);
|
||||
};
|
||||
|
||||
// 接口路径验证函数
|
||||
const isValidApiPath = (path: string): boolean => {
|
||||
const pathRegex = /^\/[\w\-/]+$/;
|
||||
return pathRegex.test(path);
|
||||
};
|
||||
|
||||
const appStore = useAppStore();
|
||||
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? '60%' : '60%'));
|
||||
|
||||
const t = useI18n().t;
|
||||
const configStore = useConfigStore();
|
||||
const activeTabRef = ref('website');
|
||||
|
||||
// 与父组件的 v-model 同步
|
||||
const props = defineProps<{ modelValue: boolean }>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const drawerVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: boolean) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
// 配置状态管理
|
||||
const configState = reactive<ConfigTable>({
|
||||
id: undefined,
|
||||
config_name: '',
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
config_type: undefined,
|
||||
description: '',
|
||||
});
|
||||
|
||||
// 记录修改过的字段
|
||||
const modifiedFields = reactive<Record<string, boolean>>({});
|
||||
|
||||
// 标记字段为已修改
|
||||
const markModified = (key: string) => {
|
||||
modifiedFields[key] = true;
|
||||
};
|
||||
|
||||
// 判断是否有修改
|
||||
const hasChanges = computed(() => Object.keys(modifiedFields).length > 0);
|
||||
|
||||
// 提交修改
|
||||
const submitChanges = async () => {
|
||||
const keysToSubmit = Object.keys(modifiedFields);
|
||||
if (keysToSubmit.length === 0) return;
|
||||
|
||||
try {
|
||||
// 准备提交数据
|
||||
// 1. 处理接口白名单
|
||||
if (
|
||||
'white_api_list_path' in modifiedFields &&
|
||||
apiWhitelistConfigs.value.white_api_list_path?.id
|
||||
) {
|
||||
const apiWhitelistArray = apiWhitelistItems.value
|
||||
.map((item) => item.value.trim())
|
||||
.filter(Boolean);
|
||||
// 转换为JSON字符串格式保存
|
||||
const apiWhitelistJson = JSON.stringify(apiWhitelistArray);
|
||||
await ParamsAPI.updateParams(apiWhitelistConfigs.value.white_api_list_path.id, {
|
||||
...apiWhitelistConfigs.value.white_api_list_path,
|
||||
config_value: apiWhitelistJson,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 处理IP黑名单
|
||||
if ('ip_black_list' in modifiedFields && ipBlacklistConfigs.value.ip_black_list?.id) {
|
||||
const ipBlacklistArray = ipBlacklistItems.value
|
||||
.map((item) => item.value.trim())
|
||||
.filter(Boolean);
|
||||
// 转换为JSON字符串格式保存
|
||||
const ipBlacklistJson = JSON.stringify(ipBlacklistArray);
|
||||
await ParamsAPI.updateParams(ipBlacklistConfigs.value.ip_black_list.id, {
|
||||
...ipBlacklistConfigs.value.ip_black_list,
|
||||
config_value: ipBlacklistJson,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 处理演示环境IP白名单
|
||||
if ('ip_white_list' in modifiedFields && demoConfigs.value.ip_white_list?.id) {
|
||||
const demoIpWhitelistArray = demoIpWhitelistItems.value
|
||||
.map((item) => item.value.trim())
|
||||
.filter(Boolean);
|
||||
// 转换为JSON字符串格式保存
|
||||
const demoIpWhitelistJson = JSON.stringify(demoIpWhitelistArray);
|
||||
await ParamsAPI.updateParams(demoConfigs.value.ip_white_list.id, {
|
||||
...demoConfigs.value.ip_white_list,
|
||||
config_value: demoIpWhitelistJson,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 处理其他配置项
|
||||
const otherKeys = keysToSubmit.filter(
|
||||
(key) => !['white_api_list_path', 'ip_black_list', 'ip_white_list'].includes(key)
|
||||
);
|
||||
const otherUpdatePromises = otherKeys.map((key) => {
|
||||
const item =
|
||||
systemConfigs.value[key as keyof typeof systemConfigs.value] ||
|
||||
logoConfigs.value[key as keyof typeof logoConfigs.value] ||
|
||||
securityPrivacyConfigs.value[key as keyof typeof securityPrivacyConfigs.value] ||
|
||||
userAgreementConfigs.value[key as keyof typeof userAgreementConfigs.value] ||
|
||||
demoConfigs.value[key as keyof typeof demoConfigs.value];
|
||||
return item && item.id ? ParamsAPI.updateParams(item.id, { ...item }) : Promise.resolve();
|
||||
});
|
||||
await Promise.all(otherUpdatePromises);
|
||||
|
||||
// 清除已提交的修改标记
|
||||
keysToSubmit.forEach((key) => {
|
||||
delete modifiedFields[key];
|
||||
});
|
||||
|
||||
// 重新加载配置数据(强制重新加载以同步到浏览器内存)
|
||||
configStore.isConfigLoaded = false;
|
||||
await configStore.getConfig();
|
||||
initializeLists();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消修改:重置所有修改字段的状态并恢复初始值
|
||||
const resetForm = async () => {
|
||||
// 强制重新加载配置数据(从服务器获取最新数据)
|
||||
await configStore.getConfig(true);
|
||||
|
||||
// 重置动态列表
|
||||
initializeLists();
|
||||
|
||||
// 重置其他配置项
|
||||
const keysToReset = Object.keys(modifiedFields);
|
||||
for (const key of keysToReset) {
|
||||
if (systemConfigs.value[key as keyof typeof systemConfigs.value]) {
|
||||
systemConfigs.value[key as keyof typeof systemConfigs.value].config_value =
|
||||
configStore.configData[key as keyof typeof configStore.configData]?.config_value || '';
|
||||
} else if (logoConfigs.value[key as keyof typeof logoConfigs.value]) {
|
||||
logoConfigs.value[key as keyof typeof logoConfigs.value].config_value =
|
||||
configStore.configData[key as keyof typeof configStore.configData]?.config_value || '';
|
||||
} else if (securityPrivacyConfigs.value[key as keyof typeof securityPrivacyConfigs.value]) {
|
||||
securityPrivacyConfigs.value[
|
||||
key as keyof typeof securityPrivacyConfigs.value
|
||||
].config_value =
|
||||
configStore.configData[key as keyof typeof configStore.configData]?.config_value || '';
|
||||
} else if (userAgreementConfigs.value[key as keyof typeof userAgreementConfigs.value]) {
|
||||
userAgreementConfigs.value[key as keyof typeof userAgreementConfigs.value].config_value =
|
||||
configStore.configData[key as keyof typeof configStore.configData]?.config_value || '';
|
||||
} else if (demoConfigs.value[key as keyof typeof demoConfigs.value]) {
|
||||
// 除了IP白名单外的演示配置项
|
||||
if (key !== 'ip_white_list') {
|
||||
demoConfigs.value[key as keyof typeof demoConfigs.value].config_value =
|
||||
configStore.configData[key as keyof typeof configStore.configData]?.config_value || '';
|
||||
}
|
||||
}
|
||||
delete modifiedFields[key];
|
||||
}
|
||||
ElMessageBox.close();
|
||||
};
|
||||
|
||||
async function handleCloseDialog() {
|
||||
// 仅关闭抽屉,等待关闭动画结束后再重置
|
||||
drawerVisible.value = false;
|
||||
}
|
||||
|
||||
async function onDrawerClosed() {
|
||||
// 抽屉关闭动画结束后再执行重置,避免打断动画
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
// 系统配置项
|
||||
const systemConfigs = computed(() => ({
|
||||
sys_web_title: configStore.configData.sys_web_title,
|
||||
sys_web_version: configStore.configData.sys_web_version,
|
||||
sys_web_description: configStore.configData.sys_web_description,
|
||||
}));
|
||||
|
||||
// 安全隐私配置项
|
||||
const securityPrivacyConfigs = computed(() => ({
|
||||
sys_help_doc: configStore.configData.sys_help_doc,
|
||||
sys_git_code: configStore.configData.sys_git_code,
|
||||
sys_keep_record: configStore.configData.sys_keep_record,
|
||||
sys_web_copyright: configStore.configData.sys_web_copyright,
|
||||
sys_web_privacy: configStore.configData.sys_web_privacy,
|
||||
}));
|
||||
|
||||
// 用户协议配置项
|
||||
const userAgreementConfigs = computed(() => ({
|
||||
sys_web_clause: configStore.configData.sys_web_clause,
|
||||
}));
|
||||
|
||||
// 接口白名单配置 - 动态管理
|
||||
const apiWhitelistItems = ref<ListItem[]>([]);
|
||||
// IP黑名单配置 - 动态管理
|
||||
const ipBlacklistItems = ref<ListItem[]>([]);
|
||||
// IP白名单配置 - 动态管理
|
||||
const demoIpWhitelistItems = ref<ListItem[]>([]);
|
||||
|
||||
// 从配置数据初始化列表
|
||||
const initializeLists = () => {
|
||||
// 初始化接口白名单
|
||||
const apiWhitelistStr = configStore.configData.white_api_list_path?.config_value || '';
|
||||
try {
|
||||
// 尝试解析为JSON数组
|
||||
const apiWhitelistArray = JSON.parse(apiWhitelistStr);
|
||||
if (Array.isArray(apiWhitelistArray)) {
|
||||
apiWhitelistItems.value = apiWhitelistArray
|
||||
.filter((item) => typeof item === 'string' && item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }));
|
||||
} else {
|
||||
// 如果不是数组,回退到按换行符分割
|
||||
apiWhitelistItems.value = apiWhitelistStr
|
||||
? apiWhitelistStr
|
||||
.split('\n')
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: '' }];
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,回退到按换行符分割
|
||||
apiWhitelistItems.value = apiWhitelistStr
|
||||
? apiWhitelistStr
|
||||
.split('\n')
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: '' }];
|
||||
}
|
||||
|
||||
// 初始化IP黑名单
|
||||
const ipBlacklistStr = configStore.configData.ip_black_list?.config_value || '';
|
||||
try {
|
||||
// 尝试解析为JSON数组
|
||||
const ipBlacklistArray = JSON.parse(ipBlacklistStr);
|
||||
if (Array.isArray(ipBlacklistArray)) {
|
||||
ipBlacklistItems.value = ipBlacklistArray
|
||||
.filter((item) => typeof item === 'string' && item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }));
|
||||
} else {
|
||||
// 如果不是数组,回退到按换行符分割
|
||||
ipBlacklistItems.value = ipBlacklistStr
|
||||
? ipBlacklistStr
|
||||
.split('\n')
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: '' }];
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,回退到按换行符分割
|
||||
ipBlacklistItems.value = ipBlacklistStr
|
||||
? ipBlacklistStr
|
||||
.split('\n')
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: '' }];
|
||||
}
|
||||
|
||||
// 初始化演示环境IP白名单
|
||||
const demoIpWhitelistStr = configStore.configData.ip_white_list?.config_value || '';
|
||||
try {
|
||||
// 尝试解析为JSON数组
|
||||
const demoIpWhitelistArray = JSON.parse(demoIpWhitelistStr);
|
||||
if (Array.isArray(demoIpWhitelistArray)) {
|
||||
demoIpWhitelistItems.value = demoIpWhitelistArray
|
||||
.filter((item) => typeof item === 'string' && item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }));
|
||||
} else {
|
||||
// 如果不是数组,回退到按换行符分割
|
||||
demoIpWhitelistItems.value = demoIpWhitelistStr
|
||||
? demoIpWhitelistStr
|
||||
.split('\n')
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: '' }];
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,回退到按换行符分割
|
||||
demoIpWhitelistItems.value = demoIpWhitelistStr
|
||||
? demoIpWhitelistStr
|
||||
.split('\n')
|
||||
.filter((item) => item.trim())
|
||||
.map((item) => ({ id: generateId(), value: item.trim() }))
|
||||
: [{ id: generateId(), value: '' }];
|
||||
}
|
||||
};
|
||||
|
||||
// 添加接口白名单项
|
||||
const addApiWhitelistItem = () => {
|
||||
apiWhitelistItems.value.push({ id: generateId(), value: '' });
|
||||
markModified('white_api_list_path');
|
||||
};
|
||||
|
||||
// 移除接口白名单项
|
||||
const removeApiWhitelistItem = (id: string) => {
|
||||
if (apiWhitelistItems.value.length <= 1) {
|
||||
ElMessage.warning('至少需要保留一个接口白名单配置');
|
||||
return;
|
||||
}
|
||||
apiWhitelistItems.value = apiWhitelistItems.value.filter((item) => item.id !== id);
|
||||
markModified('white_api_list_path');
|
||||
};
|
||||
|
||||
// 添加IP黑名单项
|
||||
const addIpBlacklistItem = () => {
|
||||
ipBlacklistItems.value.push({ id: generateId(), value: '' });
|
||||
markModified('ip_black_list');
|
||||
};
|
||||
|
||||
// 移除IP黑名单项
|
||||
const removeIpBlacklistItem = (id: string) => {
|
||||
if (ipBlacklistItems.value.length <= 1) {
|
||||
ElMessage.warning('至少需要保留一个IP黑名单配置');
|
||||
return;
|
||||
}
|
||||
ipBlacklistItems.value = ipBlacklistItems.value.filter((item) => item.id !== id);
|
||||
markModified('ip_black_list');
|
||||
};
|
||||
|
||||
// 添加演示环境IP白名单项
|
||||
const addDemoIpWhitelistItem = () => {
|
||||
demoIpWhitelistItems.value.push({ id: generateId(), value: '' });
|
||||
markModified('ip_white_list');
|
||||
};
|
||||
|
||||
// 移除演示环境IP白名单项
|
||||
const removeDemoIpWhitelistItem = (id: string) => {
|
||||
if (demoIpWhitelistItems.value.length <= 1) {
|
||||
ElMessage.warning('至少需要保留一个IP白名单配置');
|
||||
return;
|
||||
}
|
||||
demoIpWhitelistItems.value = demoIpWhitelistItems.value.filter((item) => item.id !== id);
|
||||
markModified('ip_white_list');
|
||||
};
|
||||
|
||||
// 接口白名单配置项
|
||||
const apiWhitelistConfigs = computed(() => ({
|
||||
white_api_list_path: configStore.configData.white_api_list_path,
|
||||
}));
|
||||
|
||||
// IP黑名单配置项
|
||||
const ipBlacklistConfigs = computed(() => ({
|
||||
ip_black_list: configStore.configData.ip_black_list,
|
||||
}));
|
||||
|
||||
// 演示环境配置项
|
||||
const demoConfigs = computed(() => ({
|
||||
demo_enable: configStore.configData.demo_enable,
|
||||
ip_white_list: configStore.configData.ip_white_list,
|
||||
}));
|
||||
|
||||
// logo配置项
|
||||
const logoConfigs = computed(() => ({
|
||||
sys_web_logo: {
|
||||
...configStore.configData.sys_web_logo,
|
||||
maxFileSize: 5,
|
||||
},
|
||||
sys_web_favicon: {
|
||||
...configStore.configData.sys_web_favicon,
|
||||
maxFileSize: 5,
|
||||
},
|
||||
sys_login_background: {
|
||||
...configStore.configData.sys_login_background,
|
||||
maxFileSize: 10,
|
||||
},
|
||||
}));
|
||||
|
||||
// 图片上传成功的回调处理
|
||||
const handleUploadSuccess = (fileInfo: UploadFilePath, type: string) => {
|
||||
const fileUrl = fileInfo.file_url;
|
||||
if (type in configStore.configData) {
|
||||
(configStore.configData as any)[type].config_value = fileUrl;
|
||||
}
|
||||
if (type in systemConfigs.value) {
|
||||
(systemConfigs.value as any)[type].config_value = fileUrl;
|
||||
} else if (type in logoConfigs.value) {
|
||||
(logoConfigs.value as any)[type].config_value = fileUrl;
|
||||
}
|
||||
markModified(type);
|
||||
};
|
||||
|
||||
// 图片上传失败的回调处理
|
||||
const handleUploadError = (error: any) => {
|
||||
console.error('上传失败:', error.message || '未知错误');
|
||||
ElMessage.error(`上传失败:${error.message || '请稍后重试'}`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeLists();
|
||||
configStore.getConfig(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,544 @@
|
||||
<!-- 系统配置 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<!-- 与 PageContent 默认工具栏一致:flex 换行,避免 el-row/el-col 与外层 flex 挤压右侧按钮 -->
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:param:create']"
|
||||
:perm-delete="['module_system:param:delete']"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar">
|
||||
<template #prepend>
|
||||
<el-tooltip content="导出">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:param:export']"
|
||||
type="warning"
|
||||
icon="download"
|
||||
circle
|
||||
@click="handleOpenExportsModal"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CrudToolbarRight>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@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"
|
||||
fixed
|
||||
label="序号"
|
||||
min-width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'config_name')?.show"
|
||||
key="config_name"
|
||||
label="配置名称"
|
||||
prop="config_name"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'config_key')?.show"
|
||||
key="config_key"
|
||||
label="配置键"
|
||||
prop="config_key"
|
||||
min-width="200"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'config_value')?.show"
|
||||
key="config_value"
|
||||
label="配置值"
|
||||
prop="config_value"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'config_type')?.show"
|
||||
key="config_type"
|
||||
label="系统内置"
|
||||
prop="config_type"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.config_type" type="success">是</el-tag>
|
||||
<el-tag v-else type="danger">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'description')?.show"
|
||||
key="description"
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="120"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
|
||||
key="created_time"
|
||||
label="创建时间"
|
||||
prop="created_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_time')?.show"
|
||||
key="updated_time"
|
||||
label="更新时间"
|
||||
prop="updated_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<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:param:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:param:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:param: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
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="4" border>
|
||||
<el-descriptions-item label="配置名称" :span="2">
|
||||
{{ detailFormData.config_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="系统内置" :span="2">
|
||||
<el-tag v-if="detailFormData.config_type" type="success">是</el-tag>
|
||||
<el-tag v-else type="danger">否</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配置键" :span="2">
|
||||
{{ detailFormData.config_key }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配置值" :span="2">
|
||||
{{ detailFormData.config_value }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ detailFormData.description }}
|
||||
</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>
|
||||
</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="config_name">
|
||||
<el-input v-model="formData.config_name" placeholder="请输入配置名称" :maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="配置键" prop="config_key">
|
||||
<el-input v-model="formData.config_key" placeholder="请输入配置键" :maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="配置值" prop="config_value">
|
||||
<el-input v-model="formData.config_value" placeholder="请输入配置值" :maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="系统内置" prop="config_type">
|
||||
<el-radio-group v-model="formData.config_type">
|
||||
<el-radio :value="true">是</el-radio>
|
||||
<el-radio :value="false">否</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>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
v-if="dialogVisible.type !== 'detail'"
|
||||
v-hasPerm="['module_system:param:create']"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
v-hasPerm="['module_system:param:detail']"
|
||||
type="primary"
|
||||
@click="handleCloseDialog"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<ExportModal
|
||||
v-model="exportsDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="exportPageData"
|
||||
:selection-data="exportSelectionData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, unref } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
import ParamsAPI, { ConfigTable, ConfigForm, ConfigPageQuery } from '@/api/module_system/params';
|
||||
import { useConfigStore } from '@/store';
|
||||
import ExportModal from '@/components/CURD/ExportModal.vue';
|
||||
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 { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import type { IContentConfig, ISearchConfig, IObject } from '@/components/CURD/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'Params',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:param',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'config_name',
|
||||
label: '配置名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入配置名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'config_key',
|
||||
label: '配置键名',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入配置键名', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'config_type',
|
||||
label: '系统内置',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '是', value: 'true' },
|
||||
{ label: '否', value: 'false' },
|
||||
],
|
||||
attrs: { placeholder: '请选择系统内置', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
prop: 'created_time',
|
||||
label: '创建时间',
|
||||
type: 'date-picker',
|
||||
initialValue: [],
|
||||
attrs: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
style: { width: '340px' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentCols = reactive<
|
||||
Array<{
|
||||
prop?: string;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
}>
|
||||
>([
|
||||
{ prop: 'selection', label: '选择框', show: true },
|
||||
{ prop: 'index', label: '序号', show: true },
|
||||
{ prop: 'config_name', label: '配置名称', show: true },
|
||||
{ prop: 'config_key', label: '配置键', show: true },
|
||||
{ prop: 'config_value', label: '配置值', show: true },
|
||||
{ prop: 'config_type', 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 configStore = useConfigStore();
|
||||
|
||||
function normalizeListQuery(params: IObject): ConfigPageQuery {
|
||||
const q = { ...params } as IObject;
|
||||
if (typeof q.config_type === 'string') {
|
||||
q.config_type = q.config_type === 'true';
|
||||
}
|
||||
return q as unknown as ConfigPageQuery;
|
||||
}
|
||||
|
||||
const contentConfig = reactive<IContentConfig<ConfigPageQuery>>({
|
||||
permPrefix: 'module_system:param',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await ParamsAPI.listParams(normalizeListQuery(params as IObject));
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await ParamsAPI.deleteParams(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
configStore.isConfigLoaded = false;
|
||||
await configStore.getConfig();
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
const detailFormData = ref<ConfigTable>({} as ConfigTable);
|
||||
|
||||
const formData = reactive<ConfigForm>({
|
||||
id: undefined,
|
||||
config_name: '',
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
config_type: false,
|
||||
description: '',
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
config_name: [{ required: true, message: '请输入系统配置名称', trigger: 'blur' }],
|
||||
config_key: [{ required: true, message: '请输入系统配置键', trigger: 'blur' }],
|
||||
config_value: [{ required: true, message: '请输入系统配置值', trigger: 'blur' }],
|
||||
config_type: [{ required: true, message: '请选择系统配置类型', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const initialFormData: ConfigForm = {
|
||||
id: undefined,
|
||||
config_name: '',
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
config_type: false,
|
||||
description: '',
|
||||
};
|
||||
|
||||
const exportsDialogVisible = ref(false);
|
||||
|
||||
const exportQueryParams = computed(() => searchRef.value?.getQueryParams() ?? {});
|
||||
|
||||
const exportPageData = computed(() => (unref(contentRef.value?.pageData) ?? []) as ConfigTable[]);
|
||||
|
||||
const exportSelectionData = computed(
|
||||
() => (contentRef.value?.getSelectionData() ?? []) as ConfigTable[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ prop: 'config_name', label: '配置名称' },
|
||||
{ prop: 'config_key', label: '配置键' },
|
||||
{ prop: 'config_value', label: '配置值' },
|
||||
{ prop: 'config_type', label: '系统内置' },
|
||||
{ prop: 'description', label: '描述' },
|
||||
{ prop: 'created_time', label: '创建时间' },
|
||||
{ prop: 'updated_time', label: '更新时间' },
|
||||
];
|
||||
|
||||
const curdContentConfig = {
|
||||
permPrefix: 'module_system:param',
|
||||
cols: exportColumns as any,
|
||||
exportsAction: async (params: any) => {
|
||||
const query = { ...normalizeListQuery(params) } as Record<string, unknown>;
|
||||
return fetchAllPages({
|
||||
initialQuery: query,
|
||||
fetchPage: async (q) => {
|
||||
const res = await ParamsAPI.listParams(q as unknown as ConfigPageQuery);
|
||||
return {
|
||||
total: res.data?.data?.total ?? 0,
|
||||
list: res.data?.data?.items ?? [],
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
} as unknown as IContentConfig;
|
||||
|
||||
function handleOpenExportsModal() {
|
||||
exportsDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await ParamsAPI.detailParams(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '系统配置详情';
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
} else if (type === 'update') {
|
||||
dialogVisible.title = '修改系统配置';
|
||||
Object.assign(formData, response.data.data);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = '新增系统配置';
|
||||
formData.id = undefined;
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: any) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await ParamsAPI.updateParams(id, { id, ...formData });
|
||||
} else {
|
||||
await ParamsAPI.createParams(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
configStore.isConfigLoaded = false;
|
||||
await configStore.getConfig();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,580 @@
|
||||
<!-- 岗位管理 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:position:create']"
|
||||
:perm-delete="['module_system:position:delete']"
|
||||
:perm-patch="['module_system:position:patch']"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar">
|
||||
<template #prepend>
|
||||
<el-tooltip content="导出">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:position:export']"
|
||||
type="warning"
|
||||
icon="download"
|
||||
circle
|
||||
@click="handleOpenExportsModal"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CrudToolbarRight>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@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"
|
||||
fixed
|
||||
label="序号"
|
||||
min-width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'name')?.show"
|
||||
key="name"
|
||||
label="岗位名称"
|
||||
prop="name"
|
||||
min-width="100"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'status')?.show"
|
||||
key="status"
|
||||
label="状态"
|
||||
prop="status"
|
||||
min-width="80"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '启用' : '停用' }}
|
||||
</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="80"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'description')?.show"
|
||||
key="description"
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="120"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
|
||||
key="created_time"
|
||||
label="创建时间"
|
||||
prop="created_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_time')?.show"
|
||||
key="updated_time"
|
||||
label="更新时间"
|
||||
prop="updated_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_id')?.show"
|
||||
key="created_id"
|
||||
label="创建人"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.created_by?.name }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_id')?.show"
|
||||
key="updated_id"
|
||||
label="更新人"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.updated_by?.name }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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:position:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:position:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:position: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
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@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.order }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag v-if="detailFormData.status === '0'" type="success">启用</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人" :span="2">
|
||||
{{ detailFormData.created_by?.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新人" :span="2">
|
||||
{{ detailFormData.updated_by?.name }}
|
||||
</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>
|
||||
</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="order">
|
||||
<el-input-number v-model="formData.order" controls-position="right" :min="1" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
v-if="dialogVisible.type !== 'detail'"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<ExportModal
|
||||
v-model="exportsDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="exportPageData"
|
||||
:selection-data="exportSelectionData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Position',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import { ref, reactive, computed, markRaw, nextTick, unref } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
import PositionAPI, {
|
||||
PositionTable,
|
||||
PositionForm,
|
||||
PositionPageQuery,
|
||||
} from '@/api/module_system/position';
|
||||
import { useUserStore } from '@/store';
|
||||
import UserTableSelect from '@/views/module_system/user/components/UserTableSelect.vue';
|
||||
import ExportModal from '@/components/CURD/ExportModal.vue';
|
||||
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 { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
|
||||
function triggerUserSearch() {
|
||||
nextTick(() => refreshList());
|
||||
}
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:position',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'name',
|
||||
label: '岗位名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入岗位名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: '0' },
|
||||
{ label: '停用', value: '1' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
prop: 'created_time',
|
||||
label: '创建时间',
|
||||
type: 'date-picker',
|
||||
initialValue: [],
|
||||
attrs: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
style: { width: '340px' },
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'created_id',
|
||||
label: '创建人',
|
||||
type: 'user-table-select',
|
||||
initialValue: null,
|
||||
events: {
|
||||
'confirm-click': triggerUserSearch,
|
||||
'clear-click': triggerUserSearch,
|
||||
},
|
||||
},
|
||||
],
|
||||
customComponents: {
|
||||
'user-table-select': markRaw(UserTableSelect),
|
||||
},
|
||||
});
|
||||
|
||||
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: '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: 'created_id', label: '创建人', show: true },
|
||||
{ prop: 'updated_id', label: '更新人', show: true },
|
||||
{ prop: 'operation', label: '操作', show: true },
|
||||
]);
|
||||
|
||||
const contentConfig = reactive<IContentConfig<PositionPageQuery>>({
|
||||
permPrefix: 'module_system:position',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await PositionAPI.listPosition(params as PositionPageQuery);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await PositionAPI.deletePosition(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
const exportsDialogVisible = ref(false);
|
||||
|
||||
const exportQueryParams = computed(() => searchRef.value?.getQueryParams() ?? {});
|
||||
|
||||
const exportPageData = computed(
|
||||
() => (unref(contentRef.value?.pageData) ?? []) as PositionTable[]
|
||||
);
|
||||
|
||||
const exportSelectionData = computed(
|
||||
() => (contentRef.value?.getSelectionData() ?? []) as PositionTable[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ prop: 'name', label: '岗位名称' },
|
||||
{ prop: 'order', label: '岗位排序' },
|
||||
{ prop: 'status', label: '状态' },
|
||||
{ prop: 'description', label: '描述' },
|
||||
{ prop: 'created_time', label: '创建时间' },
|
||||
{ prop: 'updated_time', label: '更新时间' },
|
||||
];
|
||||
|
||||
const curdContentConfig = {
|
||||
permPrefix: 'module_system:position',
|
||||
cols: exportColumns as unknown as IContentConfig['cols'],
|
||||
exportsAction: async (params: Record<string, unknown>) => {
|
||||
const query = { ...params } as Record<string, unknown>;
|
||||
return fetchAllPages<PositionTable>({
|
||||
initialQuery: query,
|
||||
fetchPage: async (q) => {
|
||||
const res = await PositionAPI.listPosition(q as unknown as PositionPageQuery);
|
||||
return {
|
||||
total: res.data?.data?.total ?? 0,
|
||||
list: res.data?.data?.items ?? [],
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
} as unknown as IContentConfig;
|
||||
|
||||
function handleOpenExportsModal() {
|
||||
exportsDialogVisible.value = true;
|
||||
}
|
||||
|
||||
const detailFormData = ref<PositionTable>({});
|
||||
|
||||
const formData = reactive<PositionForm>({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
order: 1,
|
||||
status: '0',
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: '请输入岗位名称', trigger: 'blur' }],
|
||||
order: [{ required: true, message: '请输入岗位排序', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择岗位状态', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const initialFormData: PositionForm = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
order: 1,
|
||||
status: '0',
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await PositionAPI.detailPosition(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '岗位详情';
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
} else if (type === 'update') {
|
||||
dialogVisible.title = '修改岗位';
|
||||
Object.assign(formData, response.data.data);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = '新增岗位';
|
||||
formData.id = undefined;
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await PositionAPI.updatePosition(id, { id, ...formData });
|
||||
} else {
|
||||
await PositionAPI.createPosition(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMoreClick(status: string) {
|
||||
const rows = contentRef.value?.getSelectionData() as PositionTable[] | undefined;
|
||||
const ids = (rows ?? []).map((r) => r.id).filter((id): id is number => id != null);
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请先选择要操作的数据');
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm(`确认${status === '0' ? '启用' : '停用'}该项数据?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await PositionAPI.batchPosition({ ids, status });
|
||||
refreshList();
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,411 @@
|
||||
<!-- 角色授权 -->
|
||||
<template>
|
||||
<EnhancedDrawer
|
||||
v-model="drawerVisible"
|
||||
:title="'【' + props.roleName + '】权限分配'"
|
||||
:size="drawerSize"
|
||||
destroy-on-close
|
||||
@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">
|
||||
<!-- 数据权限 -->
|
||||
<el-aside>
|
||||
<div
|
||||
class="border-r-1 border-r-[var(--el-border-color-lighter)] b-r-solid h-[100%] p-[20px] box-border"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<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">
|
||||
<template #content>
|
||||
<span>授权用户可操作的数据范围</span>
|
||||
</template>
|
||||
<el-icon class="ml-1 inline-block cursor-pointer">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</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>
|
||||
|
||||
<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
|
||||
ref="deptTreeRef"
|
||||
node-key="value"
|
||||
show-checkbox
|
||||
:data="deptTreeData"
|
||||
:filter-node-method="handleFilter"
|
||||
default-expand-all
|
||||
:highlight-current="true"
|
||||
:check-strictly="!parentChildLinked"
|
||||
style="height: calc(100% - 60px); margin-top: 10px; overflow-y: auto"
|
||||
@check="deptTreeCheck"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-aside>
|
||||
|
||||
<!-- 菜单权限 -->
|
||||
<el-main>
|
||||
<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">
|
||||
<template #content>
|
||||
<span>授权用户可操作的菜单权限</span>
|
||||
</template>
|
||||
<el-icon class="ml-1 inline-block cursor-pointer">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex-x-between">
|
||||
<el-input v-model="permFilterText" placeholder="菜单名称" />
|
||||
<div class="flex-center ml-5">
|
||||
<el-button type="primary" size="small" plain @click="togglePermTree">
|
||||
<template #icon>
|
||||
<SwitchIcon />
|
||||
</template>
|
||||
{{ isExpanded ? '收缩' : '展开' }}
|
||||
</el-button>
|
||||
<el-checkbox
|
||||
v-model="parentChildLinked"
|
||||
class="ml-5"
|
||||
@change="handleParentChildLinkedChange"
|
||||
>
|
||||
父子联动
|
||||
</el-checkbox>
|
||||
|
||||
<el-tooltip placement="bottom">
|
||||
<template #content>
|
||||
如果只需勾选菜单权限,不需要勾选子菜单或者按钮权限,请关闭父子联动
|
||||
</template>
|
||||
<el-icon class="ml-1 color-[--el-color-primary] inline-block cursor-pointer">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</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
|
||||
ref="permTreeRef"
|
||||
node-key="value"
|
||||
show-checkbox
|
||||
:data="menuTreeData"
|
||||
:filter-node-method="handleFilter"
|
||||
default-expand-all
|
||||
:highlight-current="true"
|
||||
:check-strictly="!parentChildLinked"
|
||||
style="height: calc(100% - 60px); margin-top: 10px; overflow: auto"
|
||||
@check="menuTreeCheck"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCancel">取 消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click.stop="handleDrawerSave">
|
||||
确 定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDrawer>
|
||||
</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 RoleAPI, {
|
||||
permissionDataType,
|
||||
permissionDeptType,
|
||||
permissionMenuType,
|
||||
} 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 { DeviceEnum } from '@/enums/settings/device.enum';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
/** formatTree 后的菜单节点带 value(同原 id),与接口里的 id 二选一存在 */
|
||||
type MenuTreeNode = permissionMenuType & { value?: number };
|
||||
|
||||
const props = defineProps<{
|
||||
roleName: string;
|
||||
roleId: number;
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [v: boolean];
|
||||
saved: [];
|
||||
}>();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? '800px' : '90%'));
|
||||
|
||||
const drawerVisible = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value: boolean) {
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
|
||||
const permTreeRef = ref<TreeInstance>();
|
||||
const deptTreeRef = ref<TreeInstance>();
|
||||
const deptFilterText = ref('');
|
||||
const permFilterText = ref('');
|
||||
const dataFormRef = ref();
|
||||
const isExpanded = ref(true);
|
||||
const parentChildLinked = ref(false);
|
||||
const loading = ref(false);
|
||||
const deptTreeData = ref<permissionDeptType[]>([]);
|
||||
const menuTreeData = ref<MenuTreeNode[]>([]);
|
||||
const permissionState = ref<permissionDataType>({
|
||||
role_ids: [],
|
||||
menu_ids: [],
|
||||
data_scope: 1,
|
||||
dept_ids: [],
|
||||
});
|
||||
|
||||
const init = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const deptResponse = await DeptAPI.listDept();
|
||||
deptTreeData.value = formatTree(listToTree(deptResponse.data.data));
|
||||
|
||||
const menuResponse = await MenuAPI.listMenu();
|
||||
menuTreeData.value = formatTree(listToTree(menuResponse.data.data));
|
||||
|
||||
const roleResponse = await RoleAPI.detailRole(props.roleId);
|
||||
|
||||
permissionState.value = {
|
||||
role_ids: [props.roleId],
|
||||
menu_ids: roleResponse.data.data.menus?.map((menu) => menu.id) || [],
|
||||
data_scope: roleResponse.data.data.data_scope || 1,
|
||||
dept_ids: roleResponse.data.data.depts?.map((dept) => dept.id) || [],
|
||||
};
|
||||
|
||||
parentChildLinked.value = checkParentChildLinked(
|
||||
permissionState.value.menu_ids,
|
||||
menuTreeData.value
|
||||
);
|
||||
|
||||
if (permTreeRef.value) {
|
||||
await permTreeRef.value.setCheckedKeys(permissionState.value.menu_ids);
|
||||
}
|
||||
|
||||
if (permissionState.value.data_scope === 5 && deptTreeRef.value) {
|
||||
await deptTreeRef.value.setCheckedKeys(permissionState.value.dept_ids);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ElMessage.error('获取权限数据失败: ' + msg);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
function handleCancel() {
|
||||
drawerVisible.value = false;
|
||||
}
|
||||
|
||||
async function handleDrawerSave() {
|
||||
try {
|
||||
if (props.roleId === 1) {
|
||||
ElMessage.warning('系统默认角色,不可操作');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
|
||||
const rawChecked = (permTreeRef.value?.getCheckedKeys() || []).map((key) => Number(key));
|
||||
const menu_ids = expandMenuIdsWithAncestors(rawChecked, menuTreeData.value);
|
||||
|
||||
const submitData: permissionDataType = {
|
||||
role_ids: [props.roleId],
|
||||
menu_ids,
|
||||
data_scope: permissionState.value.data_scope,
|
||||
dept_ids: (deptTreeRef.value?.getCheckedKeys() || []).map((key) => Number(key)),
|
||||
};
|
||||
|
||||
await RoleAPI.setPermission(submitData);
|
||||
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
|
||||
drawerVisible.value = false;
|
||||
emit('saved');
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const deptTreeCheck = (checkedIds: number[]) => {
|
||||
permissionState.value.dept_ids = checkedIds;
|
||||
};
|
||||
|
||||
const menuTreeCheck = (checkedIds: number[]) => {
|
||||
permissionState.value.menu_ids = checkedIds;
|
||||
};
|
||||
|
||||
function togglePermTree() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
if (permTreeRef.value) {
|
||||
Object.values(permTreeRef.value.store.nodesMap).forEach((node: any) => {
|
||||
if (isExpanded.value) {
|
||||
node.expand();
|
||||
} else {
|
||||
node.collapse();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(deptFilterText, (val) => {
|
||||
deptTreeRef.value!.filter(val);
|
||||
});
|
||||
|
||||
watch(permFilterText, (val) => {
|
||||
permTreeRef.value!.filter(val);
|
||||
});
|
||||
|
||||
function handleFilter(value: string, data: { [key: string]: any }) {
|
||||
if (!value) return true;
|
||||
return data.label.includes(value);
|
||||
}
|
||||
|
||||
function menuTreeNodeId(node: MenuTreeNode): number {
|
||||
const v = node.value ?? node.id;
|
||||
return Number(v);
|
||||
}
|
||||
|
||||
function checkParentChildLinked(menuIds: number[], menuTreeData: MenuTreeNode[]): boolean {
|
||||
if (!menuIds.length || !menuTreeData.length) return false;
|
||||
|
||||
const menuMap = new Map<number, MenuTreeNode>();
|
||||
const buildMenuMap = (menus: MenuTreeNode[]) => {
|
||||
menus.forEach((menu) => {
|
||||
menuMap.set(menuTreeNodeId(menu), menu);
|
||||
if (menu.children) {
|
||||
buildMenuMap(menu.children as MenuTreeNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
buildMenuMap(menuTreeData);
|
||||
|
||||
let hasParentChildConflict = false;
|
||||
|
||||
for (const menuId of menuIds) {
|
||||
const menu = menuMap.get(menuId);
|
||||
if (!menu) continue;
|
||||
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const hasUnselectedChildren = menu.children.some(
|
||||
(child) => !menuIds.includes(menuTreeNodeId(child as MenuTreeNode))
|
||||
);
|
||||
if (hasUnselectedChildren) {
|
||||
hasParentChildConflict = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const parentMenu = findParentMenu(menuId, menuTreeData);
|
||||
if (parentMenu && !menuIds.includes(menuTreeNodeId(parentMenu))) {
|
||||
hasParentChildConflict = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return !hasParentChildConflict;
|
||||
}
|
||||
|
||||
function findParentMenu(menuId: number, menuTreeData: MenuTreeNode[]): MenuTreeNode | null {
|
||||
for (const menu of menuTreeData) {
|
||||
if (menu.children) {
|
||||
for (const child of menu.children) {
|
||||
if (menuTreeNodeId(child as MenuTreeNode) === menuId) {
|
||||
return menu;
|
||||
}
|
||||
const found = findParentMenu(menuId, [child as MenuTreeNode]);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 为已选菜单补齐所有祖先 id,避免仅选子节点时父级未入库导致侧栏/路由缺入口(BUG #1/#14) */
|
||||
function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTreeNode[]): number[] {
|
||||
const parentById = new Map<number, number | undefined>();
|
||||
const walk = (nodes: MenuTreeNode[], parent: number | undefined) => {
|
||||
for (const n of nodes) {
|
||||
const id = menuTreeNodeId(n);
|
||||
parentById.set(id, parent);
|
||||
if (n.children?.length) walk(n.children as MenuTreeNode[], id);
|
||||
}
|
||||
};
|
||||
walk(roots, undefined);
|
||||
const out = new Set<number>();
|
||||
for (const id of checkedIds) {
|
||||
let cur: number | undefined = id;
|
||||
while (cur !== undefined) {
|
||||
out.add(cur);
|
||||
cur = parentById.get(cur);
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function handleParentChildLinkedChange(val: any) {
|
||||
parentChildLinked.value = val;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,683 @@
|
||||
<!-- 角色管理 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:role:create']"
|
||||
:perm-delete="['module_system:role:delete']"
|
||||
:perm-patch="['module_system:role:patch']"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar">
|
||||
<template #prepend>
|
||||
<el-tooltip content="导出">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:role:export']"
|
||||
type="warning"
|
||||
icon="download"
|
||||
circle
|
||||
@click="handleOpenExportsModal"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CrudToolbarRight>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@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"
|
||||
width="55"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'index')?.show"
|
||||
fixed
|
||||
label="序号"
|
||||
width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'name')?.show"
|
||||
key="name"
|
||||
label="角色名称"
|
||||
prop="name"
|
||||
min-width="100"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'code')?.show"
|
||||
key="code"
|
||||
label="角色编码"
|
||||
prop="code"
|
||||
min-width="100"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'data_scope')?.show"
|
||||
key="data_scope"
|
||||
label="数据权限"
|
||||
prop="data_scope"
|
||||
min-width="200"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.data_scope === 1" type="primary">仅本人数据权限</el-tag>
|
||||
<el-tag v-else-if="scope.row.data_scope === 2" type="info">本部门数据权限</el-tag>
|
||||
<el-tag v-else-if="scope.row.data_scope === 3" type="warning">
|
||||
本部门及以下数据权限
|
||||
</el-tag>
|
||||
<el-tag v-else-if="scope.row.data_scope === 4" type="success">全部数据权限</el-tag>
|
||||
<el-tag v-else type="danger">自定义数据权限</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'depts')?.show"
|
||||
key="depts"
|
||||
label="所属部门"
|
||||
prop="depts"
|
||||
min-width="200"
|
||||
>
|
||||
<template #default="scope">
|
||||
<template v-if="scope.row.depts && scope.row.depts.length > 0">
|
||||
<el-tag
|
||||
v-for="dept in scope.row.depts.slice(0, 3)"
|
||||
:key="dept.id"
|
||||
type="info"
|
||||
style="margin-right: 4px; margin-bottom: 4px"
|
||||
>
|
||||
{{ dept.name }}
|
||||
</el-tag>
|
||||
<el-tag v-if="scope.row.depts.length > 3" type="info" style="margin-bottom: 4px">
|
||||
+{{ scope.row.depts.length - 3 }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else style="color: var(--el-text-color-placeholder)">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'order')?.show"
|
||||
key="order"
|
||||
label="排序"
|
||||
prop="order"
|
||||
min-width="80"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'status')?.show"
|
||||
key="status"
|
||||
label="状态"
|
||||
prop="status"
|
||||
min-width="80"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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="200"
|
||||
sortable
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'updated_time')?.show"
|
||||
key="updated_time"
|
||||
label="更新时间"
|
||||
prop="updated_time"
|
||||
min-width="200"
|
||||
sortable
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'operation')?.show"
|
||||
fixed="right"
|
||||
label="操作"
|
||||
align="center"
|
||||
min-width="280"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:role:permission']"
|
||||
type="warning"
|
||||
size="small"
|
||||
link
|
||||
icon="position"
|
||||
:disabled="scope.row.id === 1"
|
||||
@click="
|
||||
scope.row.id === 1
|
||||
? ElMessage.warning('系统默认角色,不可操作')
|
||||
: handleOpenAssignPermDialog(scope.row.id, scope.row.name)
|
||||
"
|
||||
>
|
||||
分配权限
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:role:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:role:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
:disabled="scope.row.id === 1"
|
||||
@click="
|
||||
scope.row.id === 1
|
||||
? ElMessage.warning('系统默认角色,不可操作')
|
||||
: handleOpenDialog('update', scope.row.id)
|
||||
"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:role:delete']"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
:disabled="scope.row.id === 1"
|
||||
@click="
|
||||
scope.row.id === 1
|
||||
? ElMessage.warning('系统默认角色,不可操作')
|
||||
: handleRowDelete(scope.row.id)
|
||||
"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</PageContent>
|
||||
|
||||
<EnhancedDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@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.order }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色编码" :span="2">
|
||||
{{ detailFormData.code }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据权限" :span="2">
|
||||
<el-tag v-if="detailFormData.data_scope === 1" type="primary">仅本人数据权限</el-tag>
|
||||
<el-tag v-else-if="detailFormData.data_scope === 2" type="info">本部门数据权限</el-tag>
|
||||
<el-tag v-else-if="detailFormData.data_scope === 3" type="warning">
|
||||
本部门及以下数据权限
|
||||
</el-tag>
|
||||
<el-tag v-else-if="detailFormData.data_scope === 4" type="success">全部数据权限</el-tag>
|
||||
<el-tag v-else type="danger">自定义数据权限</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="所属部门" :span="2">
|
||||
<template v-if="detailFormData.depts && detailFormData.depts.length > 0">
|
||||
<el-tag
|
||||
v-for="dept in detailFormData.depts"
|
||||
:key="dept.id"
|
||||
type="info"
|
||||
style="margin-right: 4px; margin-bottom: 4px"
|
||||
>
|
||||
{{ dept.name }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else style="color: var(--el-text-color-placeholder)">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="detailFormData.status === '0' ? 'success' : 'danger'">
|
||||
{{ detailFormData.status === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</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>
|
||||
</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="请输入角色名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="order">
|
||||
<el-input-number
|
||||
v-model="formData.order"
|
||||
controls-position="right"
|
||||
:min="0"
|
||||
style="width: 100px"
|
||||
/>
|
||||
</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="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>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
v-if="dialogVisible.type !== 'detail'"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<PermissonDrawer
|
||||
v-if="drawerVisible"
|
||||
v-model="drawerVisible"
|
||||
:role-name="checkedRole.name"
|
||||
:role-id="checkedRole.id"
|
||||
@saved="refreshList"
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
v-model="exportsDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="exportPageData"
|
||||
:selection-data="exportSelectionData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Role',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { ref, reactive, computed, unref } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
import RoleAPI, { RoleTable, RoleForm, TablePageQuery } from '@/api/module_system/role';
|
||||
import { useUserStore } from '@/store';
|
||||
import ExportModal from '@/components/CURD/ExportModal.vue';
|
||||
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 PermissonDrawer from './components/PermissonDrawer.vue';
|
||||
import { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:role',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'name',
|
||||
label: '角色名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入角色名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: 'true' },
|
||||
{ label: '停用', value: 'false' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
prop: 'created_time',
|
||||
label: '创建时间',
|
||||
type: 'date-picker',
|
||||
initialValue: [],
|
||||
attrs: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
style: { width: '340px' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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: 'data_scope', label: '数据权限', show: true },
|
||||
{ prop: 'depts', label: '所属部门', show: true },
|
||||
{ prop: 'order', label: '排序', show: true },
|
||||
{ prop: 'code', 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 contentConfig = reactive<IContentConfig<TablePageQuery>>({
|
||||
permPrefix: 'module_system:role',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await RoleAPI.listRole(params as TablePageQuery);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await RoleAPI.deleteRole(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const checkedRole = ref({ id: 0, name: '' });
|
||||
|
||||
const exportsDialogVisible = ref(false);
|
||||
|
||||
const exportQueryParams = computed(() => searchRef.value?.getQueryParams() ?? {});
|
||||
|
||||
const exportPageData = computed(() => (unref(contentRef.value?.pageData) ?? []) as RoleTable[]);
|
||||
|
||||
const exportSelectionData = computed(
|
||||
() => (contentRef.value?.getSelectionData() ?? []) as RoleTable[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ prop: 'name', label: '角色名称' },
|
||||
{ prop: 'code', label: '角色编码' },
|
||||
{ prop: 'data_scope', label: '数据权限' },
|
||||
{ prop: 'depts', label: '所属部门' },
|
||||
{ prop: 'order', label: '排序' },
|
||||
{ prop: 'status', label: '状态' },
|
||||
{ prop: 'description', label: '描述' },
|
||||
{ prop: 'created_time', label: '创建时间' },
|
||||
{ prop: 'updated_time', label: '更新时间' },
|
||||
];
|
||||
|
||||
const curdContentConfig = {
|
||||
permPrefix: 'module_system:role',
|
||||
cols: exportColumns as unknown as IContentConfig['cols'],
|
||||
exportsAction: async (params: Record<string, unknown>) => {
|
||||
const query: Record<string, unknown> = { ...params };
|
||||
if (typeof query.status === 'string') {
|
||||
query.status = query.status === 'true';
|
||||
}
|
||||
return fetchAllPages<RoleTable>({
|
||||
initialQuery: query,
|
||||
fetchPage: async (q) => {
|
||||
const res = await RoleAPI.listRole(q as unknown as TablePageQuery);
|
||||
return {
|
||||
total: res.data?.data?.total ?? 0,
|
||||
list: res.data?.data?.items ?? [],
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
} as unknown as IContentConfig;
|
||||
|
||||
function handleOpenExportsModal() {
|
||||
exportsDialogVisible.value = true;
|
||||
}
|
||||
|
||||
const detailFormData = ref<RoleTable>({} as RoleTable);
|
||||
|
||||
const formData = reactive<RoleForm>({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
order: 1,
|
||||
code: '',
|
||||
status: '0',
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const CODE_PATTERN = /^[A-Za-z][A-Za-z0-9_]{1,15}$/;
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [
|
||||
{ required: true, message: '请输入角色编码', trigger: 'blur' },
|
||||
{
|
||||
pattern: CODE_PATTERN,
|
||||
message: '字母开头,2-16位字母/数字/下划线',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
order: [{ required: true, message: '请输入角色排序', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const initialFormData: RoleForm = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
order: 1,
|
||||
code: '',
|
||||
status: '0',
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await RoleAPI.detailRole(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '角色详情';
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
} else if (type === 'update') {
|
||||
dialogVisible.title = '修改角色';
|
||||
Object.assign(formData, response.data.data);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = '新增角色';
|
||||
formData.id = undefined;
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await RoleAPI.updateRole(id, { id, ...formData });
|
||||
} else {
|
||||
await RoleAPI.createRole(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMoreClick(status: string) {
|
||||
const rows = contentRef.value?.getSelectionData() as RoleTable[] | undefined;
|
||||
const ids = (rows ?? []).map((r) => r.id).filter((id): id is number => id != null);
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请先选择要操作的数据');
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm(`确认${status === '0' ? '启用' : '停用'}该项数据?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await RoleAPI.batchRole({ ids, status });
|
||||
refreshList();
|
||||
const userStore = useUserStore();
|
||||
await userStore.getUserInfo();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenAssignPermDialog(roleId: number, roleName: string) {
|
||||
checkedRole.value = { id: roleId, name: roleName };
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,525 @@
|
||||
<!-- 租户管理 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:tenant:create']"
|
||||
:perm-delete="['module_system:tenant:delete']"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@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"
|
||||
fixed
|
||||
label="序号"
|
||||
min-width="60"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'name')?.show"
|
||||
key="name"
|
||||
label="租户名称"
|
||||
prop="name"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'code')?.show"
|
||||
key="code"
|
||||
label="租户编码"
|
||||
prop="code"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'status')?.show"
|
||||
key="status"
|
||||
label="状态"
|
||||
prop="status"
|
||||
min-width="80"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'start_time')?.show"
|
||||
key="start_time"
|
||||
label="开始时间"
|
||||
prop="start_time"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'end_time')?.show"
|
||||
key="end_time"
|
||||
label="结束时间"
|
||||
prop="end_time"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'description')?.show"
|
||||
key="description"
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="150"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
|
||||
key="created_time"
|
||||
label="创建时间"
|
||||
prop="created_time"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<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:tenant:query']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:tenant:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:tenant: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
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="2" 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="状态">
|
||||
<el-tag :type="detailFormData.status === '0' ? 'success' : 'danger'">
|
||||
{{ detailFormData.status === '0' ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">
|
||||
{{ detailFormData.start_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">
|
||||
{{ detailFormData.end_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ detailFormData.description }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
{{ detailFormData.created_time }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</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="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="租户编码" prop="code">
|
||||
<el-input
|
||||
v-model="formData.code"
|
||||
placeholder="字母与数字,创建后不可改"
|
||||
:maxlength="100"
|
||||
:disabled="dialogVisible.type === 'update'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
|
||||
<el-option label="正常" value="0" />
|
||||
<el-option label="禁用" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入描述"
|
||||
:maxlength="255"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间" prop="start_time">
|
||||
<el-date-picker
|
||||
v-model="formData.start_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="可选"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间" prop="end_time">
|
||||
<el-date-picker
|
||||
v-model="formData.end_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="可选"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
v-if="dialogVisible.type === 'create'"
|
||||
v-hasPerm="['module_system:tenant:create']"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="dialogVisible.type === 'update'"
|
||||
v-hasPerm="['module_system:tenant:update']"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import TenantAPI, {
|
||||
TenantForm,
|
||||
TenantCreateForm,
|
||||
TenantUpdateForm,
|
||||
TenantPageQuery,
|
||||
TenantTable,
|
||||
} from '@/api/module_system/tenant';
|
||||
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 { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'Tenant',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:tenant',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'name',
|
||||
label: '租户名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入租户名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '租户编码',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入租户编码', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
attrs: {
|
||||
placeholder: '请选择状态',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '正常', value: '0' },
|
||||
{ label: '禁用', value: '1' },
|
||||
],
|
||||
style: { width: '167.5px' },
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'created_time',
|
||||
label: '创建时间',
|
||||
type: 'date-picker',
|
||||
initialValue: [],
|
||||
attrs: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
style: { width: '340px' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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: 'status', label: '状态', show: true },
|
||||
{ prop: 'description', label: '描述', show: true },
|
||||
{ prop: 'created_time', label: '创建时间', show: true },
|
||||
{ prop: 'operation', label: '操作', show: true },
|
||||
]);
|
||||
|
||||
const contentConfig = reactive<IContentConfig<TenantPageQuery>>({
|
||||
permPrefix: 'module_system:tenant',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['import', 'export', 'refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await TenantAPI.listTenant(params as TenantPageQuery);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
const idList = ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
await TenantAPI.deleteTenant(idList);
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
const detailFormData = ref<TenantTable>({ code: '', name: '', status: '0' });
|
||||
|
||||
const currentEditId = ref<number | null>(null);
|
||||
|
||||
const formData = reactive<TenantForm>({
|
||||
name: '',
|
||||
code: '',
|
||||
status: '0',
|
||||
description: '',
|
||||
start_time: undefined,
|
||||
end_time: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const CODE_PATTERN = /^[A-Za-z0-9]+$/;
|
||||
|
||||
const validateTimeRange = (rule: any, value: any, callback: any) => {
|
||||
if (formData.start_time && formData.end_time && formData.start_time > formData.end_time) {
|
||||
callback(new Error('结束时间不能早于开始时间'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
|
||||
code: [
|
||||
{ required: true, message: '请输入租户编码', trigger: 'blur' },
|
||||
{
|
||||
pattern: CODE_PATTERN,
|
||||
message: '编码仅允许字母与数字',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
end_time: [{ validator: validateTimeRange, trigger: 'change' }],
|
||||
});
|
||||
|
||||
const initialFormData: TenantForm = {
|
||||
name: '',
|
||||
code: '',
|
||||
status: '0',
|
||||
description: '',
|
||||
start_time: undefined,
|
||||
end_time: undefined,
|
||||
};
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
currentEditId.value = null;
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await TenantAPI.detailTenant(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '租户详情';
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
} else if (type === 'update') {
|
||||
dialogVisible.title = '修改租户';
|
||||
Object.assign(formData, response.data.data);
|
||||
currentEditId.value = id;
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = '新增租户';
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
const id = currentEditId.value;
|
||||
try {
|
||||
if (id) {
|
||||
const payload: TenantUpdateForm = {
|
||||
name: formData.name,
|
||||
start_time: formData.start_time,
|
||||
end_time: formData.end_time,
|
||||
};
|
||||
await TenantAPI.updateTenant(id, payload);
|
||||
} else {
|
||||
const payload: TenantCreateForm = {
|
||||
name: formData.name as string,
|
||||
code: formData.code as string,
|
||||
start_time: formData.start_time,
|
||||
end_time: formData.end_time,
|
||||
};
|
||||
await TenantAPI.createTenant(payload);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<!-- 部门树 -->
|
||||
<template>
|
||||
<el-card shadow="hover">
|
||||
<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
|
||||
size="small"
|
||||
class="dept-tree-expand-btn"
|
||||
@click="toggleTreeExpandAll"
|
||||
>
|
||||
<el-icon :size="14">
|
||||
<Switch />
|
||||
</el-icon>
|
||||
<span>{{ treeExpanded ? '收起' : '展开' }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-tree
|
||||
ref="deptTreeRef"
|
||||
class="mt-2"
|
||||
node-key="value"
|
||||
:data="deptOptions"
|
||||
:props="{ children: 'children', label: 'label', disabled: 'disabled' }"
|
||||
:expand-on-click-node="false"
|
||||
:filter-node-method="handleFilter"
|
||||
default-expand-all
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Switch } from '@element-plus/icons-vue';
|
||||
import DeptAPI, { DeptPageQuery } from '@/api/module_system/dept';
|
||||
import { formatTree } from '@/utils/common';
|
||||
import type { FilterNodeMethodFunction, TreeInstance } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const deptOptions = ref<OptionType[]>([]); // 部门列表
|
||||
const deptTreeRef = ref<TreeInstance>(); // 部门树
|
||||
const deptName = ref(); // 部门名称
|
||||
/** 与「全部展开」状态同步,用于按钮提示(手动展开单节点后可能不完全一致) */
|
||||
const treeExpanded = ref(true);
|
||||
|
||||
const emits = defineEmits(['node-click', 'update:modelValue']);
|
||||
|
||||
const deptId = useVModel(props, 'modelValue', emits);
|
||||
|
||||
watch(deptName, (val) => {
|
||||
deptTreeRef.value?.filter(val);
|
||||
});
|
||||
|
||||
type TreeStoreNode = {
|
||||
childNodes?: TreeStoreNode[];
|
||||
expand: () => void;
|
||||
collapse: () => void;
|
||||
};
|
||||
|
||||
function getTreeRoot() {
|
||||
const tree = deptTreeRef.value as TreeInstance & { store?: { root: TreeStoreNode } };
|
||||
return tree?.store?.root;
|
||||
}
|
||||
|
||||
/** 展开整棵树 */
|
||||
function expandAllTreeNodes() {
|
||||
const root = getTreeRoot();
|
||||
if (!root) return;
|
||||
const walk = (node: TreeStoreNode) => {
|
||||
node.childNodes?.forEach((child) => {
|
||||
if (child.childNodes?.length) {
|
||||
child.expand();
|
||||
walk(child);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(root);
|
||||
}
|
||||
|
||||
/** 收起整棵树 */
|
||||
function collapseAllTreeNodes() {
|
||||
const root = getTreeRoot();
|
||||
if (!root) return;
|
||||
const walk = (node: TreeStoreNode) => {
|
||||
node.childNodes?.forEach((child) => {
|
||||
walk(child);
|
||||
child.collapse();
|
||||
});
|
||||
};
|
||||
walk(root);
|
||||
}
|
||||
|
||||
function toggleTreeExpandAll() {
|
||||
if (treeExpanded.value) {
|
||||
collapseAllTreeNodes();
|
||||
treeExpanded.value = false;
|
||||
} else {
|
||||
expandAllTreeNodes();
|
||||
treeExpanded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
interface Tree {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门筛选
|
||||
*/
|
||||
const handleFilter: FilterNodeMethodFunction = (value: string, data: Tree) => {
|
||||
if (!value) return true;
|
||||
return data.label.includes(value);
|
||||
};
|
||||
|
||||
/** 部门树节点 Click */
|
||||
function handleNodeClick(data: { [key: string]: any }) {
|
||||
deptId.value = data.value;
|
||||
emits('node-click');
|
||||
}
|
||||
|
||||
const queryFormData = reactive<DeptPageQuery>({
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
created_time: undefined,
|
||||
});
|
||||
|
||||
const loading = ref(true);
|
||||
|
||||
onBeforeMount(() => {
|
||||
loading.value = true;
|
||||
DeptAPI.listDept(queryFormData)
|
||||
.then((response) => {
|
||||
deptOptions.value = formatTree(response.data.data);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dept-tree-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<!-- 列表选择器 -->
|
||||
<template>
|
||||
<table-select
|
||||
:text="text"
|
||||
:select-config="selectConfig"
|
||||
@confirm-click="handleConfirm"
|
||||
@clear-click="handleClearSelection"
|
||||
>
|
||||
<template #status="scope">
|
||||
<el-tag :type="scope.row[scope.prop] === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row[scope.prop] === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</table-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ISelectConfig } from '@/components/TableSelect/index.vue';
|
||||
import UserAPI from '@/api/module_system/user';
|
||||
|
||||
// 父组件双向绑定的值(选中用户ID)
|
||||
const props = defineProps<{ modelValue?: number }>();
|
||||
|
||||
// 事件向上派发,支持父组件监听和 v-model(仅回传选中用户ID)
|
||||
const emit = defineEmits<{
|
||||
confirmClick: [data: IUser[]];
|
||||
'update:modelValue': [val: number | undefined];
|
||||
}>();
|
||||
|
||||
const selectConfig: ISelectConfig = {
|
||||
pk: 'id',
|
||||
width: '167.5px', // 与搜索表单其他输入宽度一致
|
||||
placeholder: '请选择用户',
|
||||
popover: {
|
||||
width: 720, // 弹出层宽度,提升可用性(约 720px)
|
||||
},
|
||||
formItems: [
|
||||
{
|
||||
type: 'select',
|
||||
label: '状态',
|
||||
prop: 'status',
|
||||
initialValue: '0',
|
||||
attrs: {
|
||||
placeholder: '全部',
|
||||
clearable: true,
|
||||
style: {
|
||||
width: '140px',
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{ label: '启用', value: '0' },
|
||||
{ label: '停用', value: '1' },
|
||||
],
|
||||
},
|
||||
],
|
||||
indexAction(params) {
|
||||
// 映射查询参数到后端接口
|
||||
const query: any = { ...params };
|
||||
// 清理空字符串/空值,避免后端 422
|
||||
Object.keys(query).forEach((k) => {
|
||||
const v = query[k];
|
||||
if (v === '' || v === null || v === undefined) {
|
||||
delete query[k];
|
||||
}
|
||||
});
|
||||
// 规范化状态为布尔值
|
||||
if (typeof query.status === 'string') {
|
||||
if (query.status === 'true') query.status = true;
|
||||
else if (query.status === 'false') query.status = false;
|
||||
}
|
||||
// 请求用户分页列表并适配 TableSelect 需要的结构
|
||||
return UserAPI.listUser(query).then((res: any) => {
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
});
|
||||
},
|
||||
tableColumns: [
|
||||
{ type: 'selection', width: 50, align: 'center' },
|
||||
{ label: '编号', align: 'center', prop: 'id', width: 100 },
|
||||
{ label: '账号', align: 'center', prop: 'username' },
|
||||
{ label: '用户名', align: 'center', prop: 'name', width: 120 },
|
||||
{
|
||||
label: '状态',
|
||||
align: 'center',
|
||||
prop: 'status',
|
||||
templet: 'custom',
|
||||
slotName: 'status',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface IUser {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
mobile?: string;
|
||||
gender?: string;
|
||||
avatar?: string;
|
||||
email?: string | null;
|
||||
status?: string;
|
||||
dept_name?: string;
|
||||
role_names?: string[];
|
||||
created_time?: string;
|
||||
}
|
||||
const selectedUser = ref<IUser>();
|
||||
function handleConfirm(data: IUser[]) {
|
||||
selectedUser.value = data[0];
|
||||
const id = selectedUser.value?.id;
|
||||
emit('update:modelValue', id);
|
||||
emit('confirmClick', data);
|
||||
}
|
||||
function handleClearSelection() {
|
||||
selectedUser.value = undefined;
|
||||
emit('update:modelValue', undefined);
|
||||
}
|
||||
|
||||
// 当父组件重置 v-model(modelValue)为 undefined 时,清空本地显示
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val === undefined || val === null) {
|
||||
selectedUser.value = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const text = computed(() => {
|
||||
return selectedUser.value ? `${selectedUser.value.username} - ${selectedUser.value.name}` : '';
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,898 @@
|
||||
<!-- 用户管理 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-row class="page-row" :gutter="12" justify="start">
|
||||
<el-col :span="4" class="dept-col">
|
||||
<DeptTree v-model="deptFilterId" class="w-full h-full" @node-click="handleDeptNodeClick" />
|
||||
</el-col>
|
||||
|
||||
<el-col :span="20" class="right-col">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" class="flex-1 min-h-0" :content-config="contentConfig">
|
||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
||||
<CrudToolbarLeft
|
||||
:remove-ids="removeIds"
|
||||
:perm-create="['module_system:user:create']"
|
||||
:perm-delete="['module_system:user:delete']"
|
||||
:perm-patch="['module_system:user:patch']"
|
||||
:delete-loading="submitLoading"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="onToolbar('delete')"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar">
|
||||
<template #prepend>
|
||||
<el-tooltip content="导入">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:user:import']"
|
||||
type="info"
|
||||
icon="upload"
|
||||
circle
|
||||
@click="handleOpenImportDialog"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="导出">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:user:export']"
|
||||
type="warning"
|
||||
icon="download"
|
||||
circle
|
||||
@click="handleOpenExportsModal"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CrudToolbarRight>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column type="selection" min-width="55" align="center" />
|
||||
<el-table-column fixed label="序号" min-width="60">
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="头像" prop="avatar" min-width="80" align="center">
|
||||
<template #default="scope">
|
||||
<template v-if="scope.row.avatar">
|
||||
<el-avatar size="small" :src="scope.row.avatar" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-avatar size="small" icon="UserFilled" />
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号" prop="username" min-width="100" />
|
||||
<el-table-column label="用户名" prop="name" min-width="100" />
|
||||
<el-table-column label="状态" prop="status" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="部门" prop="dept" min-width="100">
|
||||
<template #default="scope">
|
||||
{{ scope.row.dept ? scope.row.dept.name : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="性别" prop="gender" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.gender === '0'" type="success">男</el-tag>
|
||||
<el-tag v-else-if="scope.row.gender === '1'" type="warning">女</el-tag>
|
||||
<el-tag v-else type="info">未知</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="created_time" min-width="160" />
|
||||
<el-table-column label="更新时间" prop="updated_time" min-width="160" />
|
||||
<el-table-column label="操作" fixed="right" align="center" min-width="280">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPerm="['module_system:user:update']"
|
||||
type="warning"
|
||||
icon="RefreshLeft"
|
||||
size="small"
|
||||
link
|
||||
:disabled="scope.row.is_superuser === true"
|
||||
@click="hancleResetPassword(scope.row)"
|
||||
>
|
||||
重置密码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:user:detail']"
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:user:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
:disabled="scope.row.is_superuser === true"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_system:user:delete']"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
:disabled="scope.row.is_superuser === true"
|
||||
@click="handleRowDelete(scope.row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</PageContent>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<EnhancedDrawer
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
append-to-body
|
||||
:size="drawerSize"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="编号" :span="2">
|
||||
{{ detailFormData.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="头像" :span="2">
|
||||
<template v-if="detailFormData.avatar">
|
||||
<el-avatar :src="detailFormData.avatar" size="small"></el-avatar>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-avatar icon="UserFilled" size="small"></el-avatar>
|
||||
</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="账号" :span="2">
|
||||
{{ detailFormData.username }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名" :span="2">
|
||||
{{ detailFormData.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="性别" :span="2">
|
||||
<el-tag v-if="detailFormData.gender === '0'" type="success">男</el-tag>
|
||||
<el-tag v-else-if="detailFormData.gender === '1'" type="warning">女</el-tag>
|
||||
<el-tag v-else type="info">未知</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="部门" :span="2">
|
||||
{{ detailFormData.dept ? detailFormData.dept.name : '' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色" :span="2">
|
||||
{{
|
||||
detailFormData.roles ? detailFormData.roles.map((item) => item.name).join('、') : ''
|
||||
}}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="岗位" :span="2">
|
||||
{{
|
||||
detailFormData.positions
|
||||
? detailFormData.positions.map((item) => item.name).join('、')
|
||||
: ''
|
||||
}}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱" :span="2">
|
||||
{{ detailFormData.email }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号" :span="2">
|
||||
{{ detailFormData.mobile }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="是否超管" :span="2">
|
||||
<el-tag :type="detailFormData.is_superuser ? 'success' : 'info'">
|
||||
{{ detailFormData.is_superuser ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="detailFormData.status === '0' ? 'success' : 'danger'">
|
||||
{{ detailFormData.status === '0' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上次登录时间" :span="2">
|
||||
{{ detailFormData.last_login }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人" :span="2">
|
||||
{{ detailFormData.created_by?.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新人" :span="2">
|
||||
{{ detailFormData.updated_by?.name }}
|
||||
</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>
|
||||
</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="username">
|
||||
<el-input
|
||||
v-model="formData.username"
|
||||
:disabled="!!formData.id"
|
||||
placeholder="请输入账号"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-select v-model="formData.gender" placeholder="请选择性别">
|
||||
<el-option label="男" value="0" />
|
||||
<el-option label="女" value="1" />
|
||||
<el-option label="未知" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机号" prop="mobile">
|
||||
<el-input v-model="formData.mobile" placeholder="请输入手机号码" maxlength="11" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="formData.email" placeholder="请输入邮箱" maxlength="50" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="部门" prop="dept_id">
|
||||
<el-tree-select
|
||||
v-model="formData.dept_id"
|
||||
placeholder="请选择上级部门"
|
||||
:data="deptOptions"
|
||||
:props="{ children: 'children', label: 'label', disabled: 'disabled' }"
|
||||
filterable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="角色" prop="role_ids">
|
||||
<el-select v-model="formData.role_ids" multiple placeholder="请选择角色">
|
||||
<el-option
|
||||
v-for="item in roleOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="岗位" prop="position_ids">
|
||||
<el-select v-model="formData.position_ids" multiple placeholder="请选择岗位">
|
||||
<el-option
|
||||
v-for="item in positionOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="!formData.id" label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="formData.password"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="是否超管" prop="is_superuser">
|
||||
<el-switch v-model="formData.is_superuser" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button
|
||||
v-if="dialogVisible.type === 'create' || dialogVisible.type === 'update'"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDrawer>
|
||||
|
||||
<ImportModal
|
||||
v-model="importDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:loading="uploadLoading"
|
||||
@upload="handleUpload"
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
v-model="exportsDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:query-params="exportQueryParams"
|
||||
:page-data="exportPageData"
|
||||
:selection-data="exportSelectionData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'User',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import { useAppStore } from '@/store/modules/app.store';
|
||||
import { DeviceEnum } from '@/enums/settings/device.enum';
|
||||
import { ResultEnum } from '@/enums/api/result.enum';
|
||||
|
||||
import UserAPI, {
|
||||
type UserForm,
|
||||
type UserInfo,
|
||||
type UserPageQuery,
|
||||
} from '@/api/module_system/user';
|
||||
import { formatTree } from '@/utils/common';
|
||||
import PositionAPI from '@/api/module_system/position';
|
||||
import DeptAPI from '@/api/module_system/dept';
|
||||
import RoleAPI from '@/api/module_system/role';
|
||||
|
||||
import DeptTree from './components/DeptTree.vue';
|
||||
import UserTableSelect from './components/UserTableSelect.vue';
|
||||
import { useUserStore } from '@/store';
|
||||
import ImportModal from '@/components/CURD/ImportModal.vue';
|
||||
import ExportModal from '@/components/CURD/ExportModal.vue';
|
||||
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 EnhancedDrawer from '@/components/CURD/EnhancedDrawer.vue';
|
||||
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
import { ref, reactive, computed, markRaw, nextTick, unref } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
|
||||
const appStore = useAppStore();
|
||||
|
||||
const searchRef = ref<InstanceType<typeof PageSearch>>();
|
||||
const contentRef = ref<InstanceType<typeof PageContent>>();
|
||||
const dataFormRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
const deptFilterId = ref<number | undefined>(undefined);
|
||||
|
||||
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? '450px' : '90%'));
|
||||
const deptOptions = ref<OptionType[]>();
|
||||
const roleOptions = ref<Array<{ value: number; label: string; disabled?: boolean }>>();
|
||||
const positionOptions = ref<Array<{ value: number; label: string; disabled?: boolean }>>();
|
||||
const importDialogVisible = ref(false);
|
||||
const exportsDialogVisible = ref(false);
|
||||
const detailFormData = ref<UserInfo>({});
|
||||
|
||||
function triggerUserSearch() {
|
||||
nextTick(() => {
|
||||
contentRef.value?.fetchPageData(getMergedListParams(), true);
|
||||
});
|
||||
}
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_system:user',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 3,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'username',
|
||||
label: '账号',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入账号', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'name',
|
||||
label: '用户名',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入用户名', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: '0' },
|
||||
{ label: '停用', value: '1' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '167.5px' } },
|
||||
},
|
||||
{
|
||||
prop: 'created_id',
|
||||
label: '创建人',
|
||||
type: 'user-table-select',
|
||||
initialValue: null,
|
||||
events: {
|
||||
'confirm-click': triggerUserSearch,
|
||||
'clear-click': triggerUserSearch,
|
||||
},
|
||||
},
|
||||
],
|
||||
customComponents: {
|
||||
'user-table-select': markRaw(UserTableSelect),
|
||||
},
|
||||
});
|
||||
|
||||
const contentCols = reactive<
|
||||
Array<{
|
||||
prop?: string;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
}>
|
||||
>([
|
||||
{ prop: 'selection', label: '选择框', show: true },
|
||||
{ prop: 'index', label: '序号', show: true },
|
||||
{ prop: 'avatar', label: '头像', show: true },
|
||||
{ prop: 'username', label: '账号', show: true },
|
||||
{ prop: 'name', label: '用户名', show: true },
|
||||
{ prop: 'status', label: '状态', show: true },
|
||||
{ prop: 'dept', label: '部门', show: true },
|
||||
{ prop: 'gender', label: '性别', show: true },
|
||||
{ prop: 'created_time', label: '创建时间', show: true },
|
||||
{ prop: 'updated_time', label: '更新时间', show: true },
|
||||
{ prop: 'operation', label: '操作', show: true },
|
||||
]);
|
||||
|
||||
const contentConfig = reactive<IContentConfig<UserPageQuery>>({
|
||||
permPrefix: 'module_system:user',
|
||||
pk: 'id',
|
||||
cols: contentCols as IContentConfig['cols'],
|
||||
hideColumnFilter: false,
|
||||
initialFetch: false,
|
||||
toolbar: [],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await UserAPI.listUser(params as UserPageQuery);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: async (ids) => {
|
||||
await UserAPI.deleteUser(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
);
|
||||
const userStore = useUserStore();
|
||||
const idSet = ids.split(',').map((s) => Number(s.trim()));
|
||||
if (userStore.basicInfo.id && idSet.includes(userStore.basicInfo.id)) {
|
||||
userStore.clearUserInfo();
|
||||
}
|
||||
},
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除该项数据?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
function getMergedListParams(): UserPageQuery {
|
||||
const base = searchRef.value?.getQueryParams() ?? {};
|
||||
return {
|
||||
...base,
|
||||
dept_id: deptFilterId.value,
|
||||
} as UserPageQuery;
|
||||
}
|
||||
|
||||
function handleQueryClick() {
|
||||
contentRef.value?.fetchPageData(getMergedListParams(), true);
|
||||
}
|
||||
|
||||
function handleResetClick() {
|
||||
deptFilterId.value = undefined;
|
||||
contentRef.value?.fetchPageData(getMergedListParams(), true);
|
||||
}
|
||||
|
||||
function handleDeptNodeClick() {
|
||||
contentRef.value?.fetchPageData(getMergedListParams(), true);
|
||||
}
|
||||
|
||||
function refreshList() {
|
||||
contentRef.value?.fetchPageData(getMergedListParams(), true);
|
||||
}
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
const exportQueryParams = computed(() => getMergedListParams());
|
||||
|
||||
const exportPageData = computed(() => (unref(contentRef.value?.pageData) ?? []) as UserInfo[]);
|
||||
|
||||
const exportSelectionData = computed(
|
||||
() => (contentRef.value?.getSelectionData() ?? []) as UserInfo[]
|
||||
);
|
||||
|
||||
const formData = reactive<UserForm>({
|
||||
id: undefined,
|
||||
username: undefined,
|
||||
name: undefined,
|
||||
dept_id: undefined,
|
||||
dept_name: undefined,
|
||||
role_ids: undefined,
|
||||
role_names: undefined,
|
||||
position_ids: undefined,
|
||||
position_names: undefined,
|
||||
password: undefined,
|
||||
gender: undefined,
|
||||
email: undefined,
|
||||
mobile: undefined,
|
||||
is_superuser: false,
|
||||
status: '0',
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
gender: [{ required: false, message: '请选择性别', trigger: 'blur' }],
|
||||
email: [
|
||||
{
|
||||
pattern: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/,
|
||||
message: '请输入正确的邮箱地址',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
mobile: [
|
||||
{
|
||||
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
|
||||
message: '请输入正确的手机号码',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
is_superuser: [{ required: true, message: '请选择是否超管', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const exportColumns = [
|
||||
{ prop: 'username', label: '账号' },
|
||||
{ prop: 'name', label: '名称' },
|
||||
{ prop: 'status', label: '状态' },
|
||||
{ prop: 'gender', label: '性别' },
|
||||
{ prop: 'email', label: '邮箱' },
|
||||
{ prop: 'mobile', label: '手机号' },
|
||||
{ prop: 'is_superuser', label: '是否超管' },
|
||||
{ prop: 'description', label: '描述' },
|
||||
{ prop: 'created_time', label: '创建时间' },
|
||||
{ prop: 'updated_time', label: '更新时间' },
|
||||
];
|
||||
|
||||
const curdContentConfig = {
|
||||
permPrefix: 'module_system:user',
|
||||
cols: exportColumns as unknown as IContentConfig['cols'],
|
||||
importTemplate: () => UserAPI.downloadTemplateUser(),
|
||||
exportsAction: async (params: Record<string, unknown>) => {
|
||||
const query: Record<string, unknown> = { ...params };
|
||||
if (typeof query.status === 'string') {
|
||||
query.status = query.status === 'true';
|
||||
}
|
||||
return fetchAllPages<UserInfo>({
|
||||
pageSize: 9999,
|
||||
initialQuery: query,
|
||||
fetchPage: async (q) => {
|
||||
const res = await UserAPI.listUser(q as unknown as UserPageQuery);
|
||||
return {
|
||||
total: res.data?.data?.total ?? 0,
|
||||
list: res.data?.data?.items ?? [],
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
} as unknown as IContentConfig;
|
||||
|
||||
function hancleResetPassword(row: UserInfo) {
|
||||
ElMessageBox.prompt('请输入用户【' + row.username + '】的新密码', '重置密码', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
}).then(
|
||||
async ({ value }) => {
|
||||
if (!value || value.length < 6) {
|
||||
ElMessage.warning('密码至少需要6位字符,请重新输入');
|
||||
return false;
|
||||
}
|
||||
await UserAPI.resetUserPassword({ id: row.id!, password: value });
|
||||
},
|
||||
() => {
|
||||
ElMessageBox.close();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
}
|
||||
|
||||
const initialFormData: UserForm = {
|
||||
id: undefined,
|
||||
username: undefined,
|
||||
name: undefined,
|
||||
dept_id: undefined,
|
||||
dept_name: undefined,
|
||||
role_ids: undefined,
|
||||
role_names: undefined,
|
||||
position_ids: undefined,
|
||||
position_names: undefined,
|
||||
password: undefined,
|
||||
gender: undefined,
|
||||
email: undefined,
|
||||
mobile: undefined,
|
||||
is_superuser: false,
|
||||
status: '0',
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await UserAPI.detailUser(id);
|
||||
if (type === 'detail') {
|
||||
dialogVisible.title = '用户详情';
|
||||
Object.assign(detailFormData.value, response.data.data);
|
||||
} else if (type === 'update') {
|
||||
dialogVisible.title = '修改用户';
|
||||
Object.assign(formData, response.data.data);
|
||||
formData.role_ids = (response.data.data.roles || []).map((item) => item.id as number);
|
||||
formData.position_ids = (response.data.data.positions || []).map(
|
||||
(item) => item.id as number
|
||||
);
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = '新增用户';
|
||||
formData.id = undefined;
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
await nextTick();
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
|
||||
const deptResponse = await DeptAPI.listDept({});
|
||||
deptOptions.value = formatTree(deptResponse.data.data);
|
||||
|
||||
const roleResponse = await RoleAPI.listRole();
|
||||
roleOptions.value = roleResponse.data.data.items
|
||||
.filter((item) => item.id !== undefined && item.name !== undefined)
|
||||
.map((item) => ({
|
||||
value: item.id as number,
|
||||
label: item.name as string,
|
||||
disabled: item.status === '1',
|
||||
}))
|
||||
.filter((opt) => !opt.disabled);
|
||||
|
||||
const positionResponse = await PositionAPI.listPosition();
|
||||
positionOptions.value = positionResponse.data.data.items
|
||||
.filter((item) => item.id !== undefined && item.name !== undefined)
|
||||
.map((item) => ({
|
||||
value: item.id as number,
|
||||
label: item.name as string,
|
||||
disabled: item.status === '1',
|
||||
}))
|
||||
.filter((opt) => !opt.disabled);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
await UserAPI.updateUser(id, { id, ...formData });
|
||||
} else {
|
||||
await UserAPI.createUser(formData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
await resetForm();
|
||||
refreshList();
|
||||
const userStore = useUserStore();
|
||||
if (id === userStore.basicInfo.id) {
|
||||
await userStore.getUserInfo();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMoreClick(status: string) {
|
||||
const rows = contentRef.value?.getSelectionData() as UserInfo[] | undefined;
|
||||
const ids = (rows ?? []).map((r) => r.id).filter((id): id is number => id != null);
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请先选择要操作的数据');
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm('确认启用或停用该项数据?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
await UserAPI.batchUser({ ids, status });
|
||||
refreshList();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenImportDialog() {
|
||||
importDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleOpenExportsModal() {
|
||||
exportsDialogVisible.value = true;
|
||||
}
|
||||
|
||||
const emit = defineEmits(['import-success']);
|
||||
|
||||
const handleUpload = async (formDataUpload: FormData) => {
|
||||
try {
|
||||
uploadLoading.value = true;
|
||||
const response = await UserAPI.importUser(formDataUpload);
|
||||
if (response.data.code === ResultEnum.SUCCESS) {
|
||||
ElMessage.success(`${response.data.msg},${response.data.data}`);
|
||||
importDialogVisible.value = false;
|
||||
await refreshList();
|
||||
emit('import-success');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
ElMessage.error('上传失败:' + error);
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
contentRef.value?.fetchPageData(getMergedListParams(), true);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-row {
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dept-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right-col :deep(.data-table) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right-col :deep(.data-table__content) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.el-row {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.el-col {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user