chore: vendor FBA UI 工程源码(fastapi-best-architecture-ui master,Epic #163 受控源码基础)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# \_core
|
||||
|
||||
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { About } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'About' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<About />
|
||||
</template>
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'CodeLogin' });
|
||||
|
||||
const loading = ref(false);
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.mobile'),
|
||||
},
|
||||
fieldName: 'phoneNumber',
|
||||
label: $t('authentication.mobile'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.mobileTip') })
|
||||
.refine((v) => /^\d{11}$/.test(v), {
|
||||
message: $t('authentication.mobileErrortip'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'VbenPinInput',
|
||||
componentProps: {
|
||||
codeLength: CODE_LENGTH,
|
||||
createText: (countdown: number) => {
|
||||
const text =
|
||||
countdown > 0
|
||||
? $t('authentication.sendText', [countdown])
|
||||
: $t('authentication.sendCode');
|
||||
return text;
|
||||
},
|
||||
placeholder: $t('authentication.code'),
|
||||
},
|
||||
fieldName: 'code',
|
||||
label: $t('authentication.code'),
|
||||
rules: z.string().length(CODE_LENGTH, {
|
||||
message: $t('authentication.codeTip', [CODE_LENGTH]),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
* @param values 登录表单数据
|
||||
*/
|
||||
async function handleLogin(values: Recordable<any>) {
|
||||
void values;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationCodeLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleLogin"
|
||||
/>
|
||||
</template>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'ForgetPassword' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: 'example@example.com',
|
||||
},
|
||||
fieldName: 'email',
|
||||
label: $t('authentication.email'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.emailTip') })
|
||||
.email($t('authentication.emailValidErrorTip')),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
void value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationForgetPassword
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { BasicOption } from '@vben/types';
|
||||
|
||||
import { computed, h, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { Image } from 'antdv-next';
|
||||
|
||||
import OAuth2Login from '#/plugins/oauth2/views/login.vue';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
const MOCK_USER_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
},
|
||||
{
|
||||
label: 'Test',
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const imageSrc = ref('');
|
||||
const captchaEnabled = ref(false);
|
||||
const captchaExpireSeconds = ref(0);
|
||||
let refreshTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
const captcha = await authStore.captcha();
|
||||
captchaEnabled.value = captcha.is_enabled;
|
||||
captchaExpireSeconds.value = captcha.expire_seconds;
|
||||
imageSrc.value = `data:image/png;base64, ${captcha.image}`;
|
||||
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
|
||||
// 自动刷新(提前3秒刷新)
|
||||
if (captcha.is_enabled && captcha.expire_seconds > 0) {
|
||||
const refreshDelay = Math.max((captcha.expire_seconds - 3) * 1000, 1000);
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshCaptcha();
|
||||
}, refreshDelay);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
const baseFields: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
options: MOCK_USER_OPTIONS,
|
||||
placeholder: $t('authentication.selectAccount'),
|
||||
},
|
||||
fieldName: 'selectAccount',
|
||||
label: $t('authentication.selectAccount'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.selectAccount') })
|
||||
.optional()
|
||||
.default('admin'),
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.usernameTip'),
|
||||
},
|
||||
dependencies: {
|
||||
trigger(values, form) {
|
||||
if (values.selectAccount) {
|
||||
const findUser = MOCK_USER_OPTIONS.find(
|
||||
(item) => item.value === values.selectAccount,
|
||||
);
|
||||
if (findUser) {
|
||||
form.setValues({
|
||||
password: '123456',
|
||||
username: findUser.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
triggerFields: ['selectAccount'],
|
||||
},
|
||||
fieldName: 'username',
|
||||
label: $t('authentication.username'),
|
||||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
];
|
||||
|
||||
if (captchaEnabled.value) {
|
||||
baseFields.push(
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('page.auth.captchaPlaceholder'),
|
||||
},
|
||||
fieldName: 'captcha',
|
||||
label: $t('authentication.password'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('page.auth.captchaRequired') })
|
||||
.optional()
|
||||
.default(''),
|
||||
formItemClass: 'w-2/3',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'uuid',
|
||||
formItemClass: 'hidden',
|
||||
dependencies: {
|
||||
trigger: (_, form) => {
|
||||
form.setValues({
|
||||
uuid: accessStore.captchaUuid,
|
||||
});
|
||||
},
|
||||
triggerFields: ['captchaImg'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: h(Image),
|
||||
componentProps: {
|
||||
src: imageSrc.value,
|
||||
width: 120,
|
||||
height: 40,
|
||||
preview: false,
|
||||
onClick: refreshCaptcha,
|
||||
},
|
||||
fieldName: 'captchaImg',
|
||||
formItemClass: 'ml-auto -mt-[74px]',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return baseFields;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="authStore.loginLoading"
|
||||
@submit="authStore.authLogin"
|
||||
>
|
||||
<template #third-party-login>
|
||||
<OAuth2Login />
|
||||
</template>
|
||||
</AuthenticationLogin>
|
||||
</template>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { AuthenticationQrCodeLogin } from '@vben/common-ui';
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
|
||||
defineOptions({ name: 'QrCodeLogin' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationQrCodeLogin :login-path="LOGIN_PATH" />
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, h, ref } from 'vue';
|
||||
|
||||
import { AuthenticationRegister, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'Register' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.usernameTip'),
|
||||
},
|
||||
fieldName: 'username',
|
||||
label: $t('authentication.username'),
|
||||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
renderComponentContent() {
|
||||
return {
|
||||
strengthText: () => $t('authentication.passwordStrength'),
|
||||
};
|
||||
},
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.confirmPassword'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { password } = values;
|
||||
return z
|
||||
.string({ required_error: $t('authentication.passwordTip') })
|
||||
.min(1, { message: $t('authentication.passwordTip') })
|
||||
.refine((value) => value === password, {
|
||||
message: $t('authentication.confirmPasswordTip'),
|
||||
});
|
||||
},
|
||||
triggerFields: ['password'],
|
||||
},
|
||||
fieldName: 'confirmPassword',
|
||||
label: $t('authentication.confirmPassword'),
|
||||
},
|
||||
{
|
||||
component: 'VbenCheckbox',
|
||||
fieldName: 'agreePolicy',
|
||||
renderComponentContent: () => ({
|
||||
default: () =>
|
||||
h('span', [
|
||||
$t('authentication.agree'),
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
class: 'vben-link ml-1 ',
|
||||
href: '',
|
||||
},
|
||||
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
|
||||
),
|
||||
]),
|
||||
}),
|
||||
rules: z.boolean().refine((value) => !!value, {
|
||||
message: $t('authentication.agreeTip'),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
void value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationRegister
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="coming-soon" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback403Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="403" />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { IFrameView } from '@vben/layouts';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IFrameView />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback500Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="500" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback404Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="404" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'FallbackOfflineDemo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="offline" />
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
SysUpdateUserAvatarParams,
|
||||
SysUpdateUserNicknameParams,
|
||||
} from '#/api';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { updateSysUserAvatarApi, updateSysUserNicknameApi } from '#/api';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { avatarSchema, nicknameSchema } from './data';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const [AvatarForm, avatarFormApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema: avatarSchema,
|
||||
});
|
||||
|
||||
const [avatarModal, avatarModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await avatarFormApi.validate();
|
||||
if (valid) {
|
||||
avatarModalApi.lock();
|
||||
const data = await avatarFormApi.getValues<SysUpdateUserAvatarParams>();
|
||||
try {
|
||||
await updateSysUserAvatarApi(data);
|
||||
message.success('头像更新成功');
|
||||
await avatarModalApi.close();
|
||||
await authStore.fetchUserInfo();
|
||||
} finally {
|
||||
avatarModalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = avatarModalApi.getData();
|
||||
avatarFormApi.resetForm();
|
||||
if (data) {
|
||||
avatarFormApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [NicknameForm, nicknameFormApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema: nicknameSchema,
|
||||
});
|
||||
|
||||
const [nicknameModal, nicknameModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await nicknameFormApi.validate();
|
||||
if (valid) {
|
||||
nicknameModalApi.lock();
|
||||
const data =
|
||||
await nicknameFormApi.getValues<SysUpdateUserNicknameParams>();
|
||||
try {
|
||||
await updateSysUserNicknameApi(data);
|
||||
message.success('昵称更新成功');
|
||||
await nicknameModalApi.close();
|
||||
await authStore.fetchUserInfo();
|
||||
} finally {
|
||||
nicknameModalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = nicknameModalApi.getData();
|
||||
nicknameFormApi.resetForm();
|
||||
if (data) {
|
||||
nicknameFormApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const basicInfoItems = computed(() => [
|
||||
{
|
||||
key: 'username',
|
||||
label: '用户名',
|
||||
content: userStore.userInfo?.username,
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
label: '手机',
|
||||
content: userStore.userInfo?.phone || '暂无',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: '邮箱',
|
||||
content: userStore.userInfo?.email,
|
||||
},
|
||||
{
|
||||
key: 'dept',
|
||||
label: '部门',
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
label: '角色',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="基本信息" :styles="{ header: { borderBottom: 'none' } }">
|
||||
<div class="mb-8 mt-2 text-center">
|
||||
<a-tooltip>
|
||||
<template #title>点击上传头像</template>
|
||||
<a-avatar
|
||||
class="cursor-pointer"
|
||||
:size="128"
|
||||
:src="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
|
||||
@click="avatarModalApi.setData(null).open()"
|
||||
/>
|
||||
</a-tooltip>
|
||||
<p class="mt-5 text-lg">
|
||||
{{ userStore.userInfo?.nickname }}
|
||||
<a-button
|
||||
ghost
|
||||
size="small"
|
||||
@click="nicknameModalApi.setData(null).open()"
|
||||
>
|
||||
<span class="icon-[cuida--edit-outline]"></span>
|
||||
</a-button>
|
||||
</p>
|
||||
<div class="mt-3 flex items-center justify-center gap-2">
|
||||
<span
|
||||
class="icon-[ix--id-filled]"
|
||||
style="width: 1.2em; height: 1.2em"
|
||||
></span>
|
||||
<p class="text-sm text-gray-500">{{ userStore.userInfo?.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions class="ml-6" :column="1" :items="basicInfoItems">
|
||||
<template #contentRender="{ item }">
|
||||
<template v-if="item.key === 'dept'">
|
||||
<span v-if="userStore.userInfo?.dept">
|
||||
<a-tag :key="userStore.userInfo?.dept" color="green">
|
||||
{{ userStore.userInfo?.dept }}
|
||||
</a-tag>
|
||||
</span>
|
||||
<span v-else>未绑定</span>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'roles'">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a-tag
|
||||
v-for="role in userStore.userInfo?.roles"
|
||||
:key="role"
|
||||
color="purple"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ role }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-descriptions>
|
||||
<template #actions>
|
||||
最后登录时间:{{ userStore.userInfo?.last_login_time }}
|
||||
</template>
|
||||
</a-card>
|
||||
<avatarModal title="更新头像">
|
||||
<AvatarForm />
|
||||
</avatarModal>
|
||||
<nicknameModal title="更新昵称">
|
||||
<NicknameForm />
|
||||
</nicknameModal>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import type { OAuth2BindingParams } from '#/plugins/oauth2/api';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { confirm } from '@vben/common-ui';
|
||||
import { SvgGithubIcon, SvgGoogleIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import {
|
||||
deleteOAuth2Binding,
|
||||
getOAuth2BindingAuthUrl,
|
||||
getOAuth2Bindings,
|
||||
} from '#/plugins/oauth2/api';
|
||||
|
||||
const bindings = ref<string[]>([]);
|
||||
const loading = ref<boolean>(false);
|
||||
const actionLoadingSource = ref<null | string>(null);
|
||||
|
||||
const securityOptions = computed(() => [
|
||||
{
|
||||
source: 'Github' as const,
|
||||
description: '绑定 Github 账号',
|
||||
status: bindings.value?.includes('Github'),
|
||||
statusString: bindings.value?.includes('Github') ? '已绑定' : '未绑定',
|
||||
},
|
||||
{
|
||||
source: 'Google' as const,
|
||||
description: '绑定 Google 账号',
|
||||
status: bindings.value?.includes('Google'),
|
||||
statusString: bindings.value?.includes('Google') ? '已绑定' : '未绑定',
|
||||
},
|
||||
]);
|
||||
|
||||
const getBindings = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
bindings.value = await getOAuth2Bindings();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const OAuth2Binding = async (ob: OAuth2BindingParams) => {
|
||||
actionLoadingSource.value = ob.source;
|
||||
try {
|
||||
switch (ob.source) {
|
||||
case 'Github': {
|
||||
window.location.href = await getOAuth2BindingAuthUrl({
|
||||
source: ob.source,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'Google': {
|
||||
window.location.href = await getOAuth2BindingAuthUrl({
|
||||
source: ob.source,
|
||||
});
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
actionLoadingSource.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
function deleteConfirm(ob: OAuth2BindingParams) {
|
||||
confirm({
|
||||
icon: 'warning',
|
||||
content: '确认解绑此账号吗?',
|
||||
}).then(async () => {
|
||||
actionLoadingSource.value = ob.source;
|
||||
try {
|
||||
await deleteOAuth2Binding({ source: ob.source });
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
await getBindings();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionLoadingSource.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getBindings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-spin :spinning="loading">
|
||||
<div class="-ml-4">
|
||||
<div
|
||||
v-for="(item, index) in securityOptions"
|
||||
:key="index"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<SvgGithubIcon
|
||||
v-if="item.source === 'Github'"
|
||||
class="mt-1.5 size-8"
|
||||
/>
|
||||
<SvgGoogleIcon
|
||||
v-if="item.source === 'Google'"
|
||||
class="mt-1.5 size-8"
|
||||
/>
|
||||
<div>
|
||||
<div>
|
||||
{{ item.source }}
|
||||
<span
|
||||
class="ml-2 text-xs"
|
||||
:class="item.status ? 'text-green-500' : 'text-orange-500'"
|
||||
>
|
||||
{{ item.statusString }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">{{ item.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="!item.status"
|
||||
type="primary"
|
||||
:loading="actionLoadingSource === item.source"
|
||||
@click="OAuth2Binding({ source: item.source })"
|
||||
>
|
||||
绑定
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
danger
|
||||
:loading="actionLoadingSource === item.source"
|
||||
@click="deleteConfirm({ source: item.source })"
|
||||
>
|
||||
解绑
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</template>
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { OnlineMonitorResult } from '#/api';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getPhoneCaptchaApi } from '#/plugins/aliyun_sms/api';
|
||||
import { getEmailCaptchaApi } from '#/plugins/email/api';
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const avatarSchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'avatar',
|
||||
label: '头像链接',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
|
||||
export const nicknameSchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'nickname',
|
||||
label: '昵称',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
|
||||
const CODE_LENGTH = 6;
|
||||
const phoneValue = ref<string>('');
|
||||
export const phoneSchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'phone',
|
||||
label: '手机号',
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.mobileTip') })
|
||||
.refine((v) => /^\d{11}$/.test(v), {
|
||||
message: $t('authentication.mobileErrortip'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'VbenPinInput',
|
||||
componentProps: {
|
||||
codeLength: CODE_LENGTH,
|
||||
createText: (countdown: number) => {
|
||||
return countdown > 0
|
||||
? $t('authentication.sendText', [countdown])
|
||||
: $t('authentication.sendCode');
|
||||
},
|
||||
handleSendCode: async () => {
|
||||
try {
|
||||
await getPhoneCaptchaApi({ phone: phoneValue.value });
|
||||
message.success('短信验证码已发送,请注意查收');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
placeholder: $t('authentication.code'),
|
||||
},
|
||||
dependencies: {
|
||||
trigger(values) {
|
||||
phoneValue.value = values.phone;
|
||||
},
|
||||
triggerFields: ['phone'],
|
||||
},
|
||||
fieldName: 'captcha',
|
||||
label: '验证码',
|
||||
rules: z.string().length(CODE_LENGTH, {
|
||||
message: $t('authentication.codeTip', [CODE_LENGTH]),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const emailValue = ref<string>('');
|
||||
export const emailSchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'email',
|
||||
label: '邮箱',
|
||||
rules: z.string().email({ message: '无效的邮箱地址' }),
|
||||
},
|
||||
{
|
||||
component: 'VbenPinInput',
|
||||
componentProps: {
|
||||
codeLength: CODE_LENGTH,
|
||||
createText: (countdown: number) => {
|
||||
return countdown > 0
|
||||
? $t('authentication.sendText', [countdown])
|
||||
: $t('authentication.sendCode');
|
||||
},
|
||||
handleSendCode: async () => {
|
||||
try {
|
||||
await getEmailCaptchaApi({ recipients: emailValue.value });
|
||||
message.success('邮箱验证码已发送,请注意查收');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
placeholder: $t('authentication.code'),
|
||||
},
|
||||
dependencies: {
|
||||
trigger(values) {
|
||||
emailValue.value = values.email;
|
||||
},
|
||||
triggerFields: ['email'],
|
||||
},
|
||||
fieldName: 'captcha',
|
||||
label: '验证码',
|
||||
rules: z.string().length(CODE_LENGTH, {
|
||||
message: $t('authentication.codeTip', [CODE_LENGTH]),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const passwordSchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'InputPassword',
|
||||
fieldName: 'old_password',
|
||||
label: '当前密码',
|
||||
rules: z
|
||||
.string({ message: '请输入当前密码' })
|
||||
.min(6, '密码长度不能少于 6 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符'),
|
||||
},
|
||||
{
|
||||
component: 'InputPassword',
|
||||
fieldName: 'new_password',
|
||||
label: '新密码',
|
||||
rules: z
|
||||
.string({ message: '请输入新密码' })
|
||||
.min(6, '密码长度不能少于 6 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符'),
|
||||
},
|
||||
{
|
||||
component: 'InputPassword',
|
||||
fieldName: 'confirm_password',
|
||||
label: '确认密码',
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
return z
|
||||
.string({ message: '请输入确认密码' })
|
||||
.min(6, '密码长度不能少于 6 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符')
|
||||
.refine(
|
||||
(value) => value === values.new_password,
|
||||
'两次密码输出不一致',
|
||||
);
|
||||
},
|
||||
triggerFields: ['new_password', 'confirm_password'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<OnlineMonitorResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{ field: 'ip', title: 'IP 地址' },
|
||||
{ field: 'os', title: '操作系统' },
|
||||
{ field: 'browser', title: '浏览器' },
|
||||
{ field: 'device', title: '设备' },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { color: 'success', label: '在线', value: 1 },
|
||||
// { color: 'warning', label: '离线', value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.USER_ONLINE_STATUS),
|
||||
},
|
||||
},
|
||||
{ field: 'last_login_time', title: '最后登录时间' },
|
||||
{ field: 'expire_time', title: '过期时间' },
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'nickname',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'delete',
|
||||
text: '强制下线',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { ColPage } from '@vben/common-ui';
|
||||
|
||||
import BasicInfo from '#/views/_core/profile/basic-info.vue';
|
||||
import Binding from '#/views/_core/profile/binding.vue';
|
||||
import OnlineDevice from '#/views/_core/profile/online-device.vue';
|
||||
import Security from '#/views/_core/profile/security.vue';
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
key: '0',
|
||||
tab: '安全设置',
|
||||
},
|
||||
{
|
||||
key: '1',
|
||||
tab: '第三方账号',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
tab: '在线设备',
|
||||
},
|
||||
];
|
||||
const tabKey = ref<string>('0');
|
||||
|
||||
const onTabChange = (value: string) => {
|
||||
tabKey.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ColPage
|
||||
auto-content-height
|
||||
:resizable="false"
|
||||
:left-width="30"
|
||||
:right-width="70"
|
||||
>
|
||||
<template #left>
|
||||
<div>
|
||||
<BasicInfo />
|
||||
</div>
|
||||
</template>
|
||||
<a-card
|
||||
:styles="{
|
||||
header: {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
}"
|
||||
:tab-list="tabList"
|
||||
:active-tab-key="tabKey"
|
||||
@tab-change="(key: string) => onTabChange(key)"
|
||||
>
|
||||
<div v-if="tabKey === '0'">
|
||||
<Security />
|
||||
</div>
|
||||
<div v-else-if="tabKey === '1'">
|
||||
<Binding />
|
||||
</div>
|
||||
<div v-else>
|
||||
<OnlineDevice />
|
||||
</div>
|
||||
</a-card>
|
||||
</ColPage>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { OnlineMonitorResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getOnlineMonitorApi, kickOutOnlineApi } from '#/api';
|
||||
|
||||
import { useColumns } from './data';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const gridOptions: VxeTableGridOptions<OnlineMonitorResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'session_uuid',
|
||||
},
|
||||
virtualYConfig: {
|
||||
enabled: true,
|
||||
gt: 0,
|
||||
},
|
||||
minHeight: '294',
|
||||
maxHeight: '686',
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
return await getOnlineMonitorApi({
|
||||
username: userStore.userInfo?.username || '',
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<OnlineMonitorResult>) {
|
||||
if (code === 'delete') {
|
||||
kickOutOnlineApi(row.id, { session_uuid: row.session_uuid }).then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.nickname]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<div class="mr-1 pl-1 text-[1rem]">我的已登录设备</div>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
SysUpdatePasswordParams,
|
||||
SysUpdateUserEmailParams,
|
||||
SysUpdateUserPhoneParams,
|
||||
} from '#/api';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
updateSysUserEmailApi,
|
||||
updateSysUserPasswordApi,
|
||||
updateSysUserPhoneApi,
|
||||
} from '#/api';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { emailSchema, passwordSchema, phoneSchema } from './data';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const securityOptions = computed(() => [
|
||||
{
|
||||
class: 'icon-[fluent--phone-48-regular]',
|
||||
title: '安全手机',
|
||||
description: '手机号可用于登录、身份验证、密码找回、通知接收',
|
||||
type: 'phone',
|
||||
status: !!userStore.userInfo?.phone,
|
||||
statusString: userStore.userInfo?.phone ? '已绑定' : '未绑定',
|
||||
},
|
||||
{
|
||||
class: 'icon-[ic--outline-email]',
|
||||
title: '安全邮箱',
|
||||
description: '邮箱可用于登录、身份验证、密码找回、通知接收',
|
||||
type: 'email',
|
||||
status: !!userStore.userInfo?.email,
|
||||
statusString: userStore.userInfo?.email ? '已绑定' : '未绑定',
|
||||
},
|
||||
{
|
||||
class: 'icon-[mdi--password-outline]',
|
||||
title: '登录密码',
|
||||
description: '为了您的账号安全,建议定期修改密码',
|
||||
type: 'password',
|
||||
status: true,
|
||||
statusString: '已设置',
|
||||
},
|
||||
]);
|
||||
|
||||
const [phoneForm, phoneFormApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema: phoneSchema,
|
||||
});
|
||||
|
||||
const [phoneModal, phoneModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await phoneFormApi.validate();
|
||||
if (valid) {
|
||||
phoneModalApi.lock();
|
||||
const data = await phoneFormApi.getValues<SysUpdateUserPhoneParams>();
|
||||
try {
|
||||
await updateSysUserPhoneApi(data);
|
||||
message.success('手机号更新成功');
|
||||
await phoneModalApi.close();
|
||||
await authStore.fetchUserInfo();
|
||||
} finally {
|
||||
phoneModalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = phoneModalApi.getData();
|
||||
phoneFormApi.resetForm();
|
||||
if (data) {
|
||||
phoneFormApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [emailForm, emailFormApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema: emailSchema,
|
||||
});
|
||||
|
||||
const [emailModal, emailModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await emailFormApi.validate();
|
||||
if (valid) {
|
||||
emailModalApi.lock();
|
||||
const data = await emailFormApi.getValues<SysUpdateUserEmailParams>();
|
||||
try {
|
||||
await updateSysUserEmailApi(data);
|
||||
message.success('邮箱更新成功');
|
||||
await emailModalApi.close();
|
||||
await authStore.fetchUserInfo();
|
||||
} finally {
|
||||
emailModalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = emailModalApi.getData();
|
||||
emailFormApi.resetForm();
|
||||
if (data) {
|
||||
emailFormApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [passwordForm, passwordFormApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema: passwordSchema,
|
||||
});
|
||||
|
||||
const [passwordModal, passwordModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await passwordFormApi.validate();
|
||||
if (valid) {
|
||||
passwordModalApi.lock();
|
||||
const data = await passwordFormApi.getValues<SysUpdatePasswordParams>();
|
||||
try {
|
||||
await updateSysUserPasswordApi(data);
|
||||
message.success('密码更新成功,请重新登录');
|
||||
await passwordModalApi.close();
|
||||
await authStore.logout(false);
|
||||
} finally {
|
||||
passwordModalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = passwordModalApi.getData();
|
||||
passwordFormApi.resetForm();
|
||||
if (data) {
|
||||
passwordFormApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const openModal = (type: string) => {
|
||||
if (type === 'phone') {
|
||||
phoneModalApi.setData(null).open();
|
||||
}
|
||||
if (type === 'email') {
|
||||
emailModalApi.setData(null).open();
|
||||
}
|
||||
if (type === 'password') {
|
||||
passwordModalApi.setData(null).open();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="-ml-4">
|
||||
<div
|
||||
v-for="(item, index) in securityOptions"
|
||||
:key="index"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<a-avatar size="large">
|
||||
<template #icon>
|
||||
<span :class="item.class"></span>
|
||||
</template>
|
||||
</a-avatar>
|
||||
<div>
|
||||
<div>
|
||||
{{ item.title }}
|
||||
<span
|
||||
class="ml-2 text-xs"
|
||||
:class="item.status ? 'text-green-500' : 'text-orange-500'"
|
||||
>
|
||||
{{ item.statusString }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">{{ item.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<VbenButton
|
||||
:variant="item.status ? 'outline' : 'default'"
|
||||
@click="openModal(item.type)"
|
||||
>
|
||||
{{ item.status ? '修改' : '绑定' }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</div>
|
||||
<phoneModal title="绑定手机号">
|
||||
<phoneForm />
|
||||
</phoneModal>
|
||||
<emailModal title="绑定邮箱">
|
||||
<emailForm />
|
||||
</emailModal>
|
||||
<passwordModal title="重置密码">
|
||||
<passwordForm />
|
||||
</passwordModal>
|
||||
</template>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
grid: {
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
left: '1%',
|
||||
right: '1%',
|
||||
top: '2 %',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
areaStyle: {},
|
||||
data: [
|
||||
111, 2000, 6000, 16_000, 33_333, 55_555, 64_000, 33_333, 18_000,
|
||||
36_000, 70_000, 42_444, 23_222, 13_000, 8000, 4000, 1200, 333, 222,
|
||||
111,
|
||||
],
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
smooth: true,
|
||||
type: 'line',
|
||||
},
|
||||
{
|
||||
areaStyle: {},
|
||||
data: [
|
||||
33, 66, 88, 333, 3333, 6200, 20_000, 3000, 1200, 13_000, 22_000,
|
||||
11_000, 2221, 1201, 390, 198, 60, 30, 22, 11,
|
||||
],
|
||||
itemStyle: {
|
||||
color: '#019680',
|
||||
},
|
||||
smooth: true,
|
||||
type: 'line',
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: '#019680',
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
trigger: 'axis',
|
||||
},
|
||||
// xAxis: {
|
||||
// axisTick: {
|
||||
// show: false,
|
||||
// },
|
||||
// boundaryGap: false,
|
||||
// data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
|
||||
// type: 'category',
|
||||
// },
|
||||
xAxis: {
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
boundaryGap: false,
|
||||
data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'solid',
|
||||
width: 1,
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
max: 80_000,
|
||||
splitArea: {
|
||||
show: true,
|
||||
},
|
||||
splitNumber: 4,
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: ['访问', '趋势'],
|
||||
},
|
||||
radar: {
|
||||
indicator: [
|
||||
{
|
||||
name: '网页',
|
||||
},
|
||||
{
|
||||
name: '移动端',
|
||||
},
|
||||
{
|
||||
name: 'Ipad',
|
||||
},
|
||||
{
|
||||
name: '客户端',
|
||||
},
|
||||
{
|
||||
name: '第三方',
|
||||
},
|
||||
{
|
||||
name: '其它',
|
||||
},
|
||||
],
|
||||
radius: '60%',
|
||||
splitNumber: 8,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
areaStyle: {
|
||||
opacity: 1,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0,0,0,.2)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 10,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
itemStyle: {
|
||||
color: '#b6a2de',
|
||||
},
|
||||
name: '访问',
|
||||
value: [90, 50, 86, 40, 50, 20],
|
||||
},
|
||||
{
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
name: '趋势',
|
||||
value: [70, 75, 70, 76, 20, 85],
|
||||
},
|
||||
],
|
||||
itemStyle: {
|
||||
// borderColor: '#fff',
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
},
|
||||
symbolSize: 0,
|
||||
type: 'radar',
|
||||
},
|
||||
],
|
||||
tooltip: {},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
series: [
|
||||
{
|
||||
animationDelay() {
|
||||
return Math.random() * 400;
|
||||
},
|
||||
animationEasing: 'exponentialInOut',
|
||||
animationType: 'scale',
|
||||
center: ['50%', '50%'],
|
||||
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
|
||||
data: [
|
||||
{ name: '外包', value: 500 },
|
||||
{ name: '定制', value: 310 },
|
||||
{ name: '技术支持', value: 274 },
|
||||
{ name: '远程', value: 400 },
|
||||
].toSorted((a, b) => {
|
||||
return a.value - b.value;
|
||||
}),
|
||||
name: '商业占比',
|
||||
radius: '80%',
|
||||
roseType: 'radius',
|
||||
type: 'pie',
|
||||
},
|
||||
],
|
||||
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
legend: {
|
||||
bottom: '2%',
|
||||
left: 'center',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
animationDelay() {
|
||||
return Math.random() * 100;
|
||||
},
|
||||
animationEasing: 'exponentialInOut',
|
||||
animationType: 'scale',
|
||||
avoidLabelOverlap: false,
|
||||
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
|
||||
data: [
|
||||
{ name: '搜索引擎', value: 1048 },
|
||||
{ name: '直接访问', value: 735 },
|
||||
{ name: '邮件营销', value: 580 },
|
||||
{ name: '联盟广告', value: 484 },
|
||||
],
|
||||
emphasis: {
|
||||
label: {
|
||||
fontSize: '12',
|
||||
fontWeight: 'bold',
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
itemStyle: {
|
||||
// borderColor: '#fff',
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
position: 'center',
|
||||
show: false,
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
name: '访问来源',
|
||||
radius: ['40%', '65%'],
|
||||
type: 'pie',
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
grid: {
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
left: '1%',
|
||||
right: '1%',
|
||||
top: '2 %',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
barMaxWidth: 80,
|
||||
// color: '#4f69fd',
|
||||
data: [
|
||||
3000, 2000, 3333, 5000, 3200, 4200, 3200, 2100, 3000, 5100, 6000,
|
||||
3200, 4800,
|
||||
],
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
// color: '#4f69fd',
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
trigger: 'axis',
|
||||
},
|
||||
xAxis: {
|
||||
data: Array.from({ length: 12 }).map((_item, index) => `${index + 1}月`),
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
max: 8000,
|
||||
splitNumber: 4,
|
||||
type: 'value',
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AnalysisOverviewItem } from '@vben/common-ui';
|
||||
import type { TabOption } from '@vben/types';
|
||||
|
||||
import {
|
||||
AnalysisChartCard,
|
||||
AnalysisChartsTabs,
|
||||
AnalysisOverview,
|
||||
} from '@vben/common-ui';
|
||||
import {
|
||||
SvgBellIcon,
|
||||
SvgCakeIcon,
|
||||
SvgCardIcon,
|
||||
SvgDownloadIcon,
|
||||
} from '@vben/icons';
|
||||
|
||||
import AnalyticsTrends from './analytics-trends.vue';
|
||||
import AnalyticsVisitsData from './analytics-visits-data.vue';
|
||||
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
|
||||
import AnalyticsVisitsSource from './analytics-visits-source.vue';
|
||||
import AnalyticsVisits from './analytics-visits.vue';
|
||||
|
||||
const overviewItems: AnalysisOverviewItem[] = [
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '用户量',
|
||||
totalTitle: '总用户量',
|
||||
totalValue: 120_000,
|
||||
value: 2000,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '访问量',
|
||||
totalTitle: '总访问量',
|
||||
totalValue: 500_000,
|
||||
value: 20_000,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '下载量',
|
||||
totalTitle: '总下载量',
|
||||
totalValue: 120_000,
|
||||
value: 8000,
|
||||
},
|
||||
{
|
||||
icon: SvgBellIcon,
|
||||
title: '使用量',
|
||||
totalTitle: '总使用量',
|
||||
totalValue: 50_000,
|
||||
value: 5000,
|
||||
},
|
||||
];
|
||||
|
||||
const chartTabs: TabOption[] = [
|
||||
{
|
||||
label: '流量趋势',
|
||||
value: 'trends',
|
||||
},
|
||||
{
|
||||
label: '月访问量',
|
||||
value: 'visits',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<AnalysisOverview :items="overviewItems" />
|
||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
||||
<template #trends>
|
||||
<AnalyticsTrends />
|
||||
</template>
|
||||
<template #visits>
|
||||
<AnalyticsVisits />
|
||||
</template>
|
||||
</AnalysisChartsTabs>
|
||||
|
||||
<div class="mt-5 w-full md:flex">
|
||||
<AnalysisChartCard class="mt-5 md:mt-0 md:mr-4 md:w-1/3" title="访问数量">
|
||||
<AnalyticsVisitsData />
|
||||
</AnalysisChartCard>
|
||||
<AnalysisChartCard class="mt-5 md:mt-0 md:mr-4 md:w-1/3" title="访问来源">
|
||||
<AnalyticsVisitsSource />
|
||||
</AnalysisChartCard>
|
||||
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
|
||||
<AnalyticsVisitsSales />
|
||||
</AnalysisChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
WorkbenchProjectItem,
|
||||
WorkbenchQuickNavItem,
|
||||
WorkbenchTodoItem,
|
||||
WorkbenchTrendItem,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
AnalysisChartCard,
|
||||
WorkbenchHeader,
|
||||
WorkbenchProject,
|
||||
WorkbenchQuickNav,
|
||||
WorkbenchTodo,
|
||||
WorkbenchTrends,
|
||||
} from '@vben/common-ui';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
|
||||
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
|
||||
// 例如:url: /dashboard/workspace
|
||||
const projectItems: WorkbenchProjectItem[] = [
|
||||
{
|
||||
color: '',
|
||||
content: '不要等待机会,而要创造机会。',
|
||||
date: '2021-04-01',
|
||||
group: '开源组',
|
||||
icon: 'carbon:logo-github',
|
||||
title: 'Github',
|
||||
url: 'https://github.com',
|
||||
},
|
||||
{
|
||||
color: '#3fb27f',
|
||||
content: '现在的你决定将来的你。',
|
||||
date: '2021-04-01',
|
||||
group: '算法组',
|
||||
icon: 'ion:logo-vue',
|
||||
title: 'Vue',
|
||||
url: 'https://vuejs.org',
|
||||
},
|
||||
{
|
||||
color: '#e18525',
|
||||
content: '没有什么才能比努力更重要。',
|
||||
date: '2021-04-01',
|
||||
group: '上班摸鱼',
|
||||
icon: 'ion:logo-html5',
|
||||
title: 'Html5',
|
||||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
|
||||
},
|
||||
{
|
||||
color: '#bf0c2c',
|
||||
content: '热情和欲望可以突破一切难关。',
|
||||
date: '2021-04-01',
|
||||
group: 'UI',
|
||||
icon: 'ion:logo-angular',
|
||||
title: 'Angular',
|
||||
url: 'https://angular.io',
|
||||
},
|
||||
{
|
||||
color: '#00d8ff',
|
||||
content: '健康的身体是实现目标的基石。',
|
||||
date: '2021-04-01',
|
||||
group: '技术牛',
|
||||
icon: 'bx:bxl-react',
|
||||
title: 'React',
|
||||
url: 'https://reactjs.org',
|
||||
},
|
||||
{
|
||||
color: '#EBD94E',
|
||||
content: '路是走出来的,而不是空想出来的。',
|
||||
date: '2021-04-01',
|
||||
group: '架构组',
|
||||
icon: 'ion:logo-javascript',
|
||||
title: 'Js',
|
||||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
|
||||
},
|
||||
];
|
||||
|
||||
// 同样,这里的 url 也可以使用以 http 开头的外部链接
|
||||
const quickNavItems: WorkbenchQuickNavItem[] = [
|
||||
{
|
||||
color: '#1fdaca',
|
||||
icon: 'ion:home-outline',
|
||||
title: '首页',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
color: '#bf0c2c',
|
||||
icon: 'ion:grid-outline',
|
||||
title: '仪表盘',
|
||||
url: '/dashboard',
|
||||
},
|
||||
{
|
||||
color: '#e18525',
|
||||
icon: 'ion:layers-outline',
|
||||
title: '组件',
|
||||
url: '/demos/features/icons',
|
||||
},
|
||||
{
|
||||
color: '#3fb27f',
|
||||
icon: 'ion:settings-outline',
|
||||
title: '系统管理',
|
||||
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
|
||||
},
|
||||
{
|
||||
color: '#4daf1bc9',
|
||||
icon: 'ion:key-outline',
|
||||
title: '权限管理',
|
||||
url: '/demos/access/page-control',
|
||||
},
|
||||
{
|
||||
color: '#00d8ff',
|
||||
icon: 'ion:bar-chart-outline',
|
||||
title: '图表',
|
||||
url: '/analytics',
|
||||
},
|
||||
];
|
||||
|
||||
const todoItems = ref<WorkbenchTodoItem[]>([
|
||||
{
|
||||
completed: false,
|
||||
content: `审查最近提交到Git仓库的前端代码,确保代码质量和规范。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '审查前端代码提交',
|
||||
},
|
||||
{
|
||||
completed: true,
|
||||
content: `检查并优化系统性能,降低CPU使用率。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '系统性能优化',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '安全检查',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `更新项目中的所有npm依赖包,确保使用最新版本。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '更新项目依赖',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `修复用户报告的页面UI显示问题,确保在不同浏览器中显示一致。 `,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '修复UI显示问题',
|
||||
},
|
||||
]);
|
||||
const trendItems: WorkbenchTrendItem[] = [
|
||||
{
|
||||
avatar: 'svg:avatar-1',
|
||||
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
|
||||
date: '刚刚',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-2',
|
||||
content: `关注了 <a>威廉</a> `,
|
||||
date: '1个小时前',
|
||||
title: '艾文',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-3',
|
||||
content: `发布了 <a>个人动态</a> `,
|
||||
date: '1天前',
|
||||
title: '克里斯',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `发表文章 <a>如何编写一个Vite插件</a> `,
|
||||
date: '2天前',
|
||||
title: 'Vben',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-1',
|
||||
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
|
||||
date: '3天前',
|
||||
title: '皮特',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-2',
|
||||
content: `关闭了问题 <a>如何运行项目</a> `,
|
||||
date: '1周前',
|
||||
title: '杰克',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-3',
|
||||
content: `发布了 <a>个人动态</a> `,
|
||||
date: '1周前',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `推送了代码到 <a>Github</a>`,
|
||||
date: '2021-04-01 20:00',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
|
||||
date: '2021-03-01 20:00',
|
||||
title: 'Vben',
|
||||
},
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
|
||||
// This is a sample method, adjust according to the actual project requirements
|
||||
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
if (nav.url?.startsWith('http')) {
|
||||
openWindow(nav.url);
|
||||
return;
|
||||
}
|
||||
if (nav.url?.startsWith('/')) {
|
||||
router.push(nav.url).catch((error) => {
|
||||
console.error('Navigation failed:', error);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<WorkbenchHeader
|
||||
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
|
||||
>
|
||||
<template #title>
|
||||
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧!
|
||||
</template>
|
||||
<template #description> 今日晴,20℃ - 32℃! </template>
|
||||
</WorkbenchHeader>
|
||||
|
||||
<div class="mt-5 flex flex-col lg:flex-row">
|
||||
<div class="mr-4 w-full lg:w-3/5">
|
||||
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
|
||||
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
|
||||
</div>
|
||||
<div class="w-full lg:w-2/5">
|
||||
<WorkbenchQuickNav
|
||||
:items="quickNavItems"
|
||||
class="mt-5 lg:mt-0"
|
||||
title="快捷导航"
|
||||
@click="navTo"
|
||||
/>
|
||||
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
|
||||
<AnalysisChartCard class="mt-5" title="访问来源">
|
||||
<AnalyticsVisitsSource />
|
||||
</AnalysisChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, message, notification, Space } from 'antdv-next';
|
||||
|
||||
type NotificationType = 'error' | 'info' | 'success' | 'warning';
|
||||
|
||||
function info() {
|
||||
message.info('How many roads must a man walk down');
|
||||
}
|
||||
|
||||
function error() {
|
||||
message.error({
|
||||
content: 'Once upon a time you dressed so fine',
|
||||
duration: 2.5,
|
||||
});
|
||||
}
|
||||
|
||||
function warning() {
|
||||
message.warning('How many roads must a man walk down');
|
||||
}
|
||||
function success() {
|
||||
message.success('Cause you walked hand in hand With another man in my place');
|
||||
}
|
||||
|
||||
function notify(type: NotificationType) {
|
||||
notification[type]({
|
||||
duration: 2.5,
|
||||
title: '说点啥呢',
|
||||
type,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page
|
||||
description="支持多语言,主题功能集成切换等"
|
||||
title="Ant Design Vue组件使用演示"
|
||||
>
|
||||
<Card class="mb-5" title="按钮">
|
||||
<Space>
|
||||
<Button>Default</Button>
|
||||
<Button type="primary"> Primary </Button>
|
||||
<Button> Info </Button>
|
||||
<Button danger> Error </Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card class="mb-5" title="Message">
|
||||
<Space>
|
||||
<Button @click="info"> 信息 </Button>
|
||||
<Button danger @click="error"> 错误 </Button>
|
||||
<Button @click="warning"> 警告 </Button>
|
||||
<Button @click="success"> 成功 </Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card class="mb-5" title="Notification">
|
||||
<Space>
|
||||
<Button @click="notify('info')"> 信息 </Button>
|
||||
<Button danger @click="notify('error')"> 错误 </Button>
|
||||
<Button @click="notify('warning')"> 警告 </Button>
|
||||
<Button @click="notify('success')"> 成功 </Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'ip',
|
||||
label: 'IP 地址',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
// options: [
|
||||
// {
|
||||
// label: '成功',
|
||||
// value: 1,
|
||||
// },
|
||||
// {
|
||||
// label: '失败',
|
||||
// value: 0,
|
||||
// },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_LOGIN_STATUS),
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('common.form.status'),
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{ field: 'checkbox', type: 'checkbox', align: 'left', width: 50 },
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{ field: 'username', title: '用户名' },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { color: 'success', label: '成功', value: 1 },
|
||||
// { color: 'error', label: '失败', value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_LOGIN_STATUS),
|
||||
},
|
||||
},
|
||||
{ field: 'ip', title: 'IP 地址' },
|
||||
{ field: 'country', title: '国家' },
|
||||
{ field: 'region', title: '地区' },
|
||||
{ field: 'os', title: '操作系统' },
|
||||
{ field: 'browser', title: '浏览器' },
|
||||
{ field: 'device', title: '设备' },
|
||||
{ field: 'msg', title: '消息', width: 150 },
|
||||
{ field: 'login_time', title: '登录时间', width: 168 },
|
||||
{
|
||||
field: 'created_time',
|
||||
title: $t('common.table.created_time'),
|
||||
width: 168,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
VxeGridListeners,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { LoginLogResult } from '#/api';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { confirm, Page, VbenButton } from '@vben/common-ui';
|
||||
import { MaterialSymbolsDelete } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteLoginLogApi, getLoginLogListApi } from '#/api';
|
||||
|
||||
import { columns, querySchema } from './data';
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<LoginLogResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getLoginLogListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const gridEvents: VxeGridListeners<LoginLogResult> = {
|
||||
checkboxChange: () => {
|
||||
const data = gridApi.grid.getCheckboxRecords(true);
|
||||
checkedRows.value = data.map((item: any) => item.id);
|
||||
},
|
||||
checkboxAll: () => {
|
||||
const data = gridApi.grid.getCheckboxRecords(true);
|
||||
checkedRows.value = data.map((item: any) => item.id);
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const checkedRows = ref<number[]>([]);
|
||||
const deleteDisable = ref<boolean>(true);
|
||||
const deleteLoading = ref<boolean>(false);
|
||||
|
||||
const deleteLoginLog = async () => {
|
||||
confirm({
|
||||
icon: 'warning',
|
||||
content: '确定删除已勾选的记录吗?',
|
||||
}).then(async () => {
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
await deleteLoginLogApi(checkedRows.value);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
onRefresh();
|
||||
deleteDisable.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watch(checkedRows, () => {
|
||||
deleteDisable.value = checkedRows.value.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton
|
||||
variant="destructive"
|
||||
:disabled="deleteDisable"
|
||||
:loading="deleteLoading"
|
||||
@click="deleteLoginLog"
|
||||
>
|
||||
<MaterialSymbolsDelete class="size-5" />
|
||||
删除日志
|
||||
</VbenButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { OperaLogResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'ip',
|
||||
label: 'IP 地址',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
// options: [
|
||||
// {
|
||||
// label: '正常',
|
||||
// value: 1,
|
||||
// },
|
||||
// {
|
||||
// label: '异常',
|
||||
// value: 0,
|
||||
// },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_LOGIN_STATUS),
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('common.form.status'),
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<OperaLogResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{ field: 'checkbox', type: 'checkbox', align: 'left', width: 50 },
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{ field: 'username', title: '用户名' },
|
||||
{
|
||||
field: 'method',
|
||||
title: '请求方法',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ color: 'processing', label: 'GET', value: 'GET' },
|
||||
{ color: 'success', label: 'POST', value: 'POST' },
|
||||
{ color: 'cyan', label: 'PUT', value: 'PUT' },
|
||||
{ color: 'error', label: 'PUT', value: 'DELETE' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ field: 'title', title: '操作标题', align: 'left', width: 200 },
|
||||
{ field: 'path', title: '请求路径', align: 'left', width: 250 },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { color: 'success', label: '成功', value: 1 },
|
||||
// { color: 'error', label: '失败', value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_LOGIN_STATUS),
|
||||
},
|
||||
},
|
||||
{ field: 'cost_time', title: '耗时(ms)' },
|
||||
{ field: 'opera_time', title: '操作时间' },
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'details',
|
||||
text: '详情',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeGridListeners,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { LoginLogResult, OperaLogResult } from '#/api';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
confirm,
|
||||
JsonViewer,
|
||||
Page,
|
||||
useVbenDrawer,
|
||||
VbenButton,
|
||||
} from '@vben/common-ui';
|
||||
import { MaterialSymbolsDelete } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteOperaLogApi, getOperaLogListApi } from '#/api';
|
||||
|
||||
import { querySchema, useColumns } from './data';
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<OperaLogResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getOperaLogListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const gridEvents: VxeGridListeners<LoginLogResult> = {
|
||||
checkboxChange: () => {
|
||||
const data = gridApi.grid.getCheckboxRecords(true);
|
||||
checkedRows.value = data.map((item: any) => item.id);
|
||||
},
|
||||
checkboxAll: () => {
|
||||
const data = gridApi.grid.getCheckboxRecords(true);
|
||||
checkedRows.value = data.map((item: any) => item.id);
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<OperaLogResult>) {
|
||||
switch (code) {
|
||||
case 'details': {
|
||||
operaLogDetails.value = row;
|
||||
drawerApi.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
destroyOnClose: true,
|
||||
footer: false,
|
||||
class: 'w-2/5',
|
||||
});
|
||||
|
||||
const operaLogDetails = ref<OperaLogResult>();
|
||||
|
||||
const operaLogDescItems = computed(() => {
|
||||
const d = operaLogDetails.value;
|
||||
return [
|
||||
{ key: 'trace_id', label: '追踪 ID', content: d?.trace_id, span: 2 },
|
||||
{ key: 'path', label: '请求路径', content: d?.path, span: 2 },
|
||||
{ key: 'username', label: '用户名', content: d?.username },
|
||||
{ key: 'title', label: '操作标题', content: d?.title },
|
||||
{ key: 'method', label: '请求方法', content: d?.method },
|
||||
{ key: 'os', label: '操作系统', content: d?.os },
|
||||
{ key: 'device', label: '设备', content: d?.device },
|
||||
{ key: 'browser', label: '浏览器', content: d?.browser },
|
||||
{ key: 'ip', label: '请求 IP', content: d?.ip },
|
||||
{
|
||||
key: 'country',
|
||||
label: '国家',
|
||||
content: d?.country === 'None' || !d?.country ? 'N/A' : d?.country,
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
label: '地区',
|
||||
content: d?.region === 'None' || !d?.region ? 'N/A' : d?.region,
|
||||
},
|
||||
{ key: 'city', label: '城市', content: d?.city },
|
||||
{ key: 'code', label: '状态码', content: d?.code },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'opera_time', label: '操作时间', content: d?.opera_time },
|
||||
{ key: 'cost_time', label: '耗时' },
|
||||
{ key: 'user_agent', label: '用户代理', content: d?.user_agent, span: 2 },
|
||||
{ key: 'args', label: '请求参数', span: 2 },
|
||||
{ key: 'msg', label: '响应消息', content: d?.msg },
|
||||
];
|
||||
});
|
||||
|
||||
const checkedRows = ref<number[]>([]);
|
||||
const deleteDisable = ref<boolean>(true);
|
||||
const deleteLoading = ref<boolean>(false);
|
||||
|
||||
const deleteLoginLog = async () => {
|
||||
confirm({
|
||||
icon: 'warning',
|
||||
content: '确定删除已勾选的记录吗?',
|
||||
}).then(async () => {
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
await deleteOperaLogApi(checkedRows.value);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
onRefresh();
|
||||
deleteDisable.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watch(checkedRows, () => {
|
||||
deleteDisable.value = checkedRows.value.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton
|
||||
variant="destructive"
|
||||
:disabled="deleteDisable"
|
||||
:loading="deleteLoading"
|
||||
@click="deleteLoginLog"
|
||||
>
|
||||
<MaterialSymbolsDelete class="size-5" />
|
||||
删除日志
|
||||
</VbenButton>
|
||||
</template>
|
||||
</Grid>
|
||||
<Drawer title="操作日志详情">
|
||||
<a-descriptions
|
||||
:styles="{ label: { color: '#6b7280' } }"
|
||||
class="ml-1"
|
||||
:column="2"
|
||||
:items="operaLogDescItems"
|
||||
>
|
||||
<template #contentRender="{ item }">
|
||||
<template v-if="item.key === 'status'">
|
||||
<a-tag v-if="operaLogDetails?.status === 1" color="success">
|
||||
成功
|
||||
</a-tag>
|
||||
<a-tag v-else color="error"> 失败 </a-tag>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'cost_time'">
|
||||
<a-tag
|
||||
v-if="(operaLogDetails?.cost_time || 0) < 200"
|
||||
color="success"
|
||||
>
|
||||
{{ operaLogDetails?.cost_time }} ms
|
||||
</a-tag>
|
||||
<a-tag v-else color="warning">
|
||||
{{ operaLogDetails?.cost_time }} ms
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'args'">
|
||||
<JsonViewer
|
||||
class="mr-8 w-full"
|
||||
:value="operaLogDetails?.args"
|
||||
:copyable="!!operaLogDetails?.args"
|
||||
boxed
|
||||
expanded
|
||||
:expand-depth="3"
|
||||
:show-array-index="false"
|
||||
@copied="message.success('已复制请求参数')"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-descriptions>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { OnlineMonitorResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<OnlineMonitorResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{ field: 'session_uuid', title: '会话 UUID', width: 280 },
|
||||
{ field: 'username', title: '用户名' },
|
||||
{ field: 'nickname', title: '昵称' },
|
||||
{ field: 'ip', title: 'IP 地址' },
|
||||
{ field: 'os', title: '操作系统' },
|
||||
{ field: 'browser', title: '浏览器' },
|
||||
{ field: 'device', title: '设备' },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { color: 'success', label: '在线', value: 1 },
|
||||
// { color: 'warning', label: '离线', value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.USER_ONLINE_STATUS),
|
||||
},
|
||||
},
|
||||
{ field: 'last_login_time', title: '最后登录时间' },
|
||||
{ field: 'expire_time', title: '过期时间' },
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 130,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'nickname',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'delete',
|
||||
text: '强制下线',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { OnlineMonitorResult } from '#/api';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getOnlineMonitorApi, kickOutOnlineApi } from '#/api';
|
||||
|
||||
import { querySchema, useColumns } from './data';
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<OnlineMonitorResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'session_uuid',
|
||||
},
|
||||
virtualYConfig: {
|
||||
enabled: true,
|
||||
gt: 0,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (_, formValues) => {
|
||||
const res = await getOnlineMonitorApi({
|
||||
...formValues,
|
||||
});
|
||||
onlineCount.value = res.length;
|
||||
return res;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
const onlineCount = ref<number>(0);
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<OnlineMonitorResult>) {
|
||||
if (code === 'delete') {
|
||||
kickOutOnlineApi(row.id, { session_uuid: row.session_uuid }).then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.nickname]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<div class="mr-1 pl-1 text-[1rem]">
|
||||
<div>
|
||||
当前已登录人数:
|
||||
<span class="font-bold text-primary">{{ onlineCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const props = defineProps({
|
||||
memory: {
|
||||
type: Array as PropType<Record<string, any>[]>,
|
||||
default: () => [] as Record<string, any>[],
|
||||
},
|
||||
});
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
const renderChart = () => {
|
||||
renderEcharts({
|
||||
progress: {
|
||||
show: true,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
data: props.memory,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 30,
|
||||
color: [
|
||||
[0.3, '#67e0e3'],
|
||||
[0.7, '#37a2da'],
|
||||
[1, '#fd666d'],
|
||||
],
|
||||
},
|
||||
},
|
||||
pointer: {
|
||||
itemStyle: {
|
||||
color: 'auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
detail: {
|
||||
valueAnimation: true,
|
||||
formatter: '{value} M',
|
||||
color: 'auto',
|
||||
},
|
||||
tooltip: {
|
||||
formatter: '{a} <br/>{b} : {c} M',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
renderChart();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.memory,
|
||||
() => {
|
||||
renderChart();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" height="350px" />
|
||||
</template>
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
const props = defineProps({
|
||||
stats: {
|
||||
type: Array as PropType<Record<string, any>[]>,
|
||||
default: () => [] as Record<string, any>[],
|
||||
},
|
||||
});
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
const labelColor = computed(() =>
|
||||
isDark.value ? 'rgba(255, 255, 255, 0.7)' : '#1F2329',
|
||||
);
|
||||
|
||||
const renderChart = () => {
|
||||
renderEcharts({
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
data: props.stats,
|
||||
label: {
|
||||
formatter: '{b}: {d}%',
|
||||
fontSize: 14,
|
||||
color: labelColor.value,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
renderChart();
|
||||
});
|
||||
|
||||
watch(
|
||||
[isDark, () => props.stats],
|
||||
() => {
|
||||
renderChart();
|
||||
},
|
||||
{ deep: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" height="350px" />
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { getRedisMonitorApi } from '#/api';
|
||||
import ActiveSeries from '#/views/monitor/redis/components/active-series.vue';
|
||||
import CommandsSeries from '#/views/monitor/redis/components/commands-series.vue';
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
const redisInfo = ref<Record<string, any>>({});
|
||||
const redisStats = ref<Record<string, any>[]>([]);
|
||||
const usedMemory = ref<number>(0);
|
||||
const redisUsedMemory = computed(() => [
|
||||
{
|
||||
name: $t('page.monitor.redis.info.used_memory_human'),
|
||||
value: usedMemory.value,
|
||||
},
|
||||
]);
|
||||
|
||||
const fetchRedisData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getRedisMonitorApi();
|
||||
redisInfo.value = res.info;
|
||||
redisStats.value = res.stats;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
fetchRedisData();
|
||||
|
||||
const redisDescriptionItems = computed(() => [
|
||||
{
|
||||
key: 'redis_version',
|
||||
label: $t('page.monitor.redis.info.redis_version'),
|
||||
content: redisInfo.value?.redis_version,
|
||||
},
|
||||
{
|
||||
key: 'redis_mode',
|
||||
label: $t('page.monitor.redis.info.redis_mode'),
|
||||
content: redisInfo.value?.redis_mode,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: $t('page.monitor.redis.info.role'),
|
||||
content: redisInfo.value?.role,
|
||||
},
|
||||
{
|
||||
key: 'tcp_port',
|
||||
label: $t('page.monitor.redis.info.tcp_port'),
|
||||
content: redisInfo.value?.tcp_port,
|
||||
},
|
||||
{
|
||||
key: 'uptime',
|
||||
label: $t('page.monitor.redis.info.uptime'),
|
||||
content: redisInfo.value?.uptime,
|
||||
},
|
||||
{
|
||||
key: 'connected_clients',
|
||||
label: $t('page.monitor.redis.info.connected_clients'),
|
||||
content: redisInfo.value?.connected_clients,
|
||||
},
|
||||
{
|
||||
key: 'blocked_clients',
|
||||
label: $t('page.monitor.redis.info.blocked_clients'),
|
||||
content: redisInfo.value?.blocked_clients,
|
||||
},
|
||||
{
|
||||
key: 'used_memory_human',
|
||||
label: $t('page.monitor.redis.info.used_memory_human'),
|
||||
content: redisInfo.value?.used_memory_human,
|
||||
},
|
||||
{
|
||||
key: 'used_memory_rss_human',
|
||||
label: $t('page.monitor.redis.info.used_memory_rss_human'),
|
||||
content: redisInfo.value?.used_memory_rss_human,
|
||||
},
|
||||
{
|
||||
key: 'maxmemory_human',
|
||||
label: $t('page.monitor.redis.info.maxmemory_human'),
|
||||
content: redisInfo.value?.maxmemory_human,
|
||||
},
|
||||
{
|
||||
key: 'mem_fragmentation_ratio',
|
||||
label: $t('page.monitor.redis.info.mem_fragmentation_ratio'),
|
||||
content: redisInfo.value?.mem_fragmentation_ratio,
|
||||
},
|
||||
{
|
||||
key: 'total_commands_processed',
|
||||
label: $t('page.monitor.redis.info.total_commands_processed'),
|
||||
content: redisInfo.value?.total_commands_processed,
|
||||
},
|
||||
{
|
||||
key: 'instantaneous_ops_per_sec',
|
||||
label: $t('page.monitor.redis.info.instantaneous_ops_per_sec'),
|
||||
content: redisInfo.value?.instantaneous_ops_per_sec,
|
||||
},
|
||||
{
|
||||
key: 'rejected_connections',
|
||||
label: $t('page.monitor.redis.info.rejected_connections'),
|
||||
content: redisInfo.value?.rejected_connections,
|
||||
},
|
||||
{
|
||||
key: 'keys_num',
|
||||
label: $t('page.monitor.redis.info.keys_num'),
|
||||
content: redisInfo.value?.keys_num,
|
||||
},
|
||||
]);
|
||||
|
||||
const parseMemoryToMB = (memStr: string): number => {
|
||||
if (!memStr) return 0;
|
||||
const match = memStr.match(/^([\d.]+)([BKMG]?)$/i);
|
||||
if (!match || !match[1]) return 0;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
const unit = (match[2] || 'B').toUpperCase();
|
||||
switch (unit) {
|
||||
case 'G': {
|
||||
return value * 1024;
|
||||
}
|
||||
case 'K': {
|
||||
return value / 1024;
|
||||
}
|
||||
case 'M': {
|
||||
return value;
|
||||
}
|
||||
default: {
|
||||
return value / 1024 / 1024;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(redisInfo, (val) => {
|
||||
usedMemory.value = Number.parseFloat(
|
||||
parseMemoryToMB(val.used_memory_human).toFixed(2),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height content-class="flex flex-col">
|
||||
<a-card :title="$t('page.monitor.redis.info.title')" :loading="loading">
|
||||
<a-descriptions :items="redisDescriptionItems" />
|
||||
</a-card>
|
||||
<div class="mt-4 flex min-h-0 flex-1 space-x-4">
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<a-card
|
||||
class="flex flex-1 flex-col"
|
||||
:title="$t('page.monitor.redis.cards.commands.title')"
|
||||
:loading="loading"
|
||||
>
|
||||
<CommandsSeries class="min-h-0 flex-1" :stats="redisStats" />
|
||||
</a-card>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<a-card
|
||||
class="flex flex-1 flex-col"
|
||||
:title="$t('page.monitor.redis.cards.memory.title')"
|
||||
:loading="loading"
|
||||
>
|
||||
<ActiveSeries class="min-h-0 flex-1" :memory="redisUsedMemory" />
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getServerMonitorApi } from '#/api';
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
|
||||
const setServerData = ref<Record<string, any>>({});
|
||||
|
||||
const diskData = ref<any[]>([]);
|
||||
|
||||
const cpuData = computed(() => {
|
||||
return {
|
||||
usage: setServerData.value.cpu?.usage,
|
||||
current_freq: setServerData.value.cpu?.current_freq,
|
||||
max_freq: setServerData.value.cpu?.max_freq,
|
||||
min_freq: setServerData.value.cpu?.min_freq,
|
||||
logical_num: setServerData.value.cpu?.logical_num,
|
||||
physical_num: setServerData.value.cpu?.physical_num,
|
||||
};
|
||||
});
|
||||
|
||||
const memData = computed(() => {
|
||||
return {
|
||||
total: setServerData.value.memory?.total,
|
||||
used: setServerData.value.memory?.used,
|
||||
free: setServerData.value.memory?.free,
|
||||
usage: setServerData.value.memory?.usage,
|
||||
};
|
||||
});
|
||||
|
||||
const serviceData = computed(() => {
|
||||
const data: any[] = [];
|
||||
if (setServerData.value.service) {
|
||||
Object.keys(setServerData.value.service).forEach((key) => {
|
||||
data.push({
|
||||
key,
|
||||
label: $t(`page.monitor.server.service.${key}`),
|
||||
content: setServerData.value.service[key],
|
||||
});
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
const osData = computed(() => {
|
||||
const data: any[] = [];
|
||||
if (setServerData.value.system) {
|
||||
Object.keys(setServerData.value.system).forEach((key) => {
|
||||
data.push({
|
||||
key,
|
||||
label: $t(`page.monitor.server.system.${key}`),
|
||||
content: setServerData.value.system[key],
|
||||
});
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
const usageStyle = (type: string) => {
|
||||
let num = 0;
|
||||
if (type === 'cpu') {
|
||||
num = cpuData.value.usage;
|
||||
} else if (type === 'memory') {
|
||||
num = memData.value.usage;
|
||||
}
|
||||
if (num < 50) {
|
||||
return { color: '#32CD32' };
|
||||
}
|
||||
if (num < 80) {
|
||||
return { color: '#FFD700' };
|
||||
}
|
||||
return { color: '#DC143C' };
|
||||
};
|
||||
|
||||
const fetchServerData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getServerMonitorApi();
|
||||
setServerData.value.cpu = res.cpu;
|
||||
setServerData.value.memory = res.mem;
|
||||
setServerData.value.system = res.sys;
|
||||
setServerData.value.service = res.service;
|
||||
diskData.value = res.disk;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ field: 'dir', title: $t('page.monitor.server.disk.dir') },
|
||||
{ field: 'type', title: $t('page.monitor.server.disk.type') },
|
||||
{ field: 'device', title: $t('page.monitor.server.disk.device') },
|
||||
{ field: 'total', title: $t('page.monitor.server.disk.total') },
|
||||
{ field: 'free', title: $t('page.monitor.server.disk.free') },
|
||||
{ field: 'used', title: $t('page.monitor.server.disk.used') },
|
||||
{ field: 'usage', title: $t('page.monitor.server.disk.usage') },
|
||||
],
|
||||
stripe: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchServerData();
|
||||
gridApi.setGridOptions({ data: diskData.value });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<div class="min-w-0">
|
||||
<a-card :loading="loading" :title="$t('page.monitor.server.cpu.title')">
|
||||
<div class="grid gap-6 px-6 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="min-w-0">
|
||||
<a-statistic
|
||||
:title="$t('page.monitor.server.cpu.usage')"
|
||||
:value="cpuData.usage"
|
||||
:styles="{ content: usageStyle('cpu') }"
|
||||
suffix=" %"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<a-statistic
|
||||
:title="$t('page.monitor.server.cpu.current_freq')"
|
||||
:value="cpuData.current_freq"
|
||||
suffix=" MHz"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<a-statistic
|
||||
:title="$t('page.monitor.server.cpu.logical_num')"
|
||||
:value="cpuData.logical_num"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<a-statistic
|
||||
:title="$t('page.monitor.server.cpu.physical_num')"
|
||||
:value="cpuData.physical_num"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<a-card
|
||||
:loading="loading"
|
||||
:title="$t('page.monitor.server.memory.title')"
|
||||
>
|
||||
<div class="grid gap-6 px-6 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="min-w-0">
|
||||
<a-statistic
|
||||
:title="$t('page.monitor.server.memory.total')"
|
||||
:value="memData.total"
|
||||
suffix=" GB"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<a-statistic
|
||||
:title="$t('page.monitor.server.memory.used')"
|
||||
:value="memData.used"
|
||||
suffix=" GB"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<a-statistic
|
||||
:title="$t('page.monitor.server.memory.free')"
|
||||
:value="memData.free"
|
||||
suffix=" GB"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<a-statistic
|
||||
:title="$t('page.monitor.server.memory.usage')"
|
||||
:value="memData.usage"
|
||||
:styles="{ content: usageStyle('memory') }"
|
||||
suffix=" %"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-6">
|
||||
<a-card
|
||||
:loading="loading"
|
||||
:title="$t('page.monitor.server.service.title')"
|
||||
>
|
||||
<a-descriptions :items="serviceData" />
|
||||
</a-card>
|
||||
<a-card
|
||||
:loading="loading"
|
||||
:title="$t('page.monitor.server.system.title')"
|
||||
>
|
||||
<a-descriptions size="middle" :column="4" :items="osData" />
|
||||
</a-card>
|
||||
<a-card :loading="loading" :title="$t('page.monitor.server.disk.title')">
|
||||
<Grid />
|
||||
</a-card>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Cron } from 'croner';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { CRONTAB_PRESETS } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
value?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [value: string];
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
const WEEKDAY_LABELS: Record<number, string> = {
|
||||
0: '周日',
|
||||
1: '周一',
|
||||
2: '周二',
|
||||
3: '周三',
|
||||
4: '周四',
|
||||
5: '周五',
|
||||
6: '周六',
|
||||
};
|
||||
|
||||
interface CronField {
|
||||
mode: 'all' | 'specific' | 'step';
|
||||
step: number;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
const CRON_FIELD_KEYS = ['minute', 'hour', 'day', 'month', 'weekday'] as const;
|
||||
|
||||
type CronFieldKey = (typeof CRON_FIELD_KEYS)[number];
|
||||
|
||||
const activeTab = ref('minute');
|
||||
|
||||
const segmentOptions = [
|
||||
{ label: '分', value: 'minute' },
|
||||
{ label: '时', value: 'hour' },
|
||||
{ label: '日', value: 'day' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '周', value: 'weekday' },
|
||||
];
|
||||
|
||||
const cronBuilder = reactive<{
|
||||
day: CronField;
|
||||
hour: CronField;
|
||||
minute: CronField;
|
||||
month: CronField;
|
||||
weekday: CronField;
|
||||
}>({
|
||||
minute: { mode: 'all', step: 1, values: [] },
|
||||
hour: { mode: 'all', step: 1, values: [] },
|
||||
day: { mode: 'all', step: 1, values: [] },
|
||||
month: { mode: 'all', step: 1, values: [] },
|
||||
weekday: { mode: 'all', step: 1, values: [] },
|
||||
});
|
||||
|
||||
function resetCronBuilder() {
|
||||
for (const key of CRON_FIELD_KEYS) {
|
||||
cronBuilder[key].mode = 'all';
|
||||
cronBuilder[key].step = 1;
|
||||
cronBuilder[key].values = [];
|
||||
}
|
||||
}
|
||||
|
||||
function cronFieldToString(field: CronField): string {
|
||||
if (field.mode === 'all') return '*';
|
||||
if (field.mode === 'step') return `*/${field.step}`;
|
||||
if (field.values.length === 0) return '*';
|
||||
return [...field.values].toSorted((a, b) => a - b).join(',');
|
||||
}
|
||||
|
||||
function expandCronPart(part: string): number[] {
|
||||
const values: number[] = [];
|
||||
for (const segment of part.split(',')) {
|
||||
if (segment.includes('-')) {
|
||||
const nums = segment.split('-').map((v) => Number.parseInt(v, 10));
|
||||
const start = nums[0];
|
||||
const end = nums[1];
|
||||
if (
|
||||
start !== undefined &&
|
||||
end !== undefined &&
|
||||
!Number.isNaN(start) &&
|
||||
!Number.isNaN(end)
|
||||
) {
|
||||
for (let i = start; i <= end; i++) values.push(i);
|
||||
}
|
||||
} else {
|
||||
const v = Number.parseInt(segment, 10);
|
||||
if (!Number.isNaN(v)) values.push(v);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseCronPart(key: CronFieldKey, part: string) {
|
||||
if (part === '*') {
|
||||
cronBuilder[key].mode = 'all';
|
||||
} else if (part.startsWith('*/')) {
|
||||
cronBuilder[key].mode = 'step';
|
||||
cronBuilder[key].step = Number.parseInt(part.slice(2), 10) || 1;
|
||||
} else {
|
||||
cronBuilder[key].mode = 'specific';
|
||||
cronBuilder[key].values = expandCronPart(part);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCronExpression(expr: string): string {
|
||||
return expr
|
||||
.split(/\s+/)
|
||||
.map((part) => {
|
||||
if (part === '*' || part.startsWith('*/')) return part;
|
||||
return expandCronPart(part)
|
||||
.toSorted((a, b) => a - b)
|
||||
.join(',');
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const cronParts = computed(() => ({
|
||||
minute: cronFieldToString(cronBuilder.minute),
|
||||
hour: cronFieldToString(cronBuilder.hour),
|
||||
day: cronFieldToString(cronBuilder.day),
|
||||
month: cronFieldToString(cronBuilder.month),
|
||||
weekday: cronFieldToString(cronBuilder.weekday),
|
||||
}));
|
||||
|
||||
const cronExpression = computed(() => {
|
||||
const p = cronParts.value;
|
||||
return `${p.minute} ${p.hour} ${p.day} ${p.month} ${p.weekday}`;
|
||||
});
|
||||
|
||||
const cronPreviewRuns = computed(() => {
|
||||
try {
|
||||
return new Cron(cronExpression.value).nextRuns(5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
function parseCronExpression(expr: string) {
|
||||
const parts = expr.split(/\s+/);
|
||||
if (parts.length !== 5) return;
|
||||
|
||||
CRON_FIELD_KEYS.forEach((key, i) => {
|
||||
parseCronPart(key, parts[i] || '*');
|
||||
});
|
||||
}
|
||||
|
||||
function applyPreset(expr: string) {
|
||||
parseCronExpression(expr);
|
||||
}
|
||||
|
||||
function parseFieldString(key: CronFieldKey, val: string) {
|
||||
parseCronPart(key, val.trim() || '*');
|
||||
}
|
||||
|
||||
function toggleCronValue(field: CronField, val: number) {
|
||||
const idx = field.values.indexOf(val);
|
||||
if (idx === -1) {
|
||||
field.values.push(val);
|
||||
} else {
|
||||
field.values.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionColor(selected: boolean) {
|
||||
return selected ? 'blue' : 'default';
|
||||
}
|
||||
|
||||
function isPresetActive(expr: string) {
|
||||
return cronExpression.value === normalizeCronExpression(expr);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', cronExpression.value);
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
resetCronBuilder();
|
||||
activeTab.value = 'minute';
|
||||
if (props.value) {
|
||||
parseCronExpression(props.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="Crontab 可视化配置"
|
||||
width="720px"
|
||||
:styles="{
|
||||
container: { background: 'hsl(var(--background))' },
|
||||
}"
|
||||
@ok="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="rounded-lg border border-border bg-accent/20 p-3">
|
||||
<div class="mb-2 text-sm">快捷预设</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a-button
|
||||
v-for="preset in CRONTAB_PRESETS"
|
||||
:key="preset.value"
|
||||
size="small"
|
||||
variant="dashed"
|
||||
:color="isPresetActive(preset.value) ? 'primary' : 'default'"
|
||||
class="rounded-md"
|
||||
@click="applyPreset(preset.value)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-segmented v-model:value="activeTab" block :options="segmentOptions" />
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'minute'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.minute.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.minute.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.minute.mode === 'all'" />
|
||||
<span class="text-sm">每分钟</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.minute.mode === 'step' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.minute.mode = 'step'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.minute.mode === 'step'" />
|
||||
<span class="text-sm">每隔</span>
|
||||
<a-input-number
|
||||
v-model:value="cronBuilder.minute.step"
|
||||
:min="1"
|
||||
:max="59"
|
||||
size="small"
|
||||
class="w-16"
|
||||
@focus="cronBuilder.minute.mode = 'step'"
|
||||
/>
|
||||
<span class="text-sm">分钟</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.minute.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.minute.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.minute.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-10 gap-1 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 60"
|
||||
:key="v - 1"
|
||||
:color="getOptionColor(cronBuilder.minute.values.includes(v - 1))"
|
||||
class="cursor-pointer select-none text-center transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.minute.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.minute, v - 1);
|
||||
"
|
||||
>
|
||||
{{ v - 1 }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'hour'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.hour.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.hour.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.hour.mode === 'all'" />
|
||||
<span class="text-sm">每小时</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.hour.mode === 'step' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.hour.mode = 'step'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.hour.mode === 'step'" />
|
||||
<span class="text-sm">每隔</span>
|
||||
<a-input-number
|
||||
v-model:value="cronBuilder.hour.step"
|
||||
:min="1"
|
||||
:max="23"
|
||||
size="small"
|
||||
class="w-16"
|
||||
@focus="cronBuilder.hour.mode = 'step'"
|
||||
/>
|
||||
<span class="text-sm">小时</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.hour.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.hour.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.hour.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-6 gap-1 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 24"
|
||||
:key="v - 1"
|
||||
:color="getOptionColor(cronBuilder.hour.values.includes(v - 1))"
|
||||
class="cursor-pointer select-none text-center transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.hour.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.hour, v - 1);
|
||||
"
|
||||
>
|
||||
{{ v - 1 }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'day'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.day.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.day.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.day.mode === 'all'" />
|
||||
<span class="text-sm">每天</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.day.mode === 'step' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.day.mode = 'step'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.day.mode === 'step'" />
|
||||
<span class="text-sm">每隔</span>
|
||||
<a-input-number
|
||||
v-model:value="cronBuilder.day.step"
|
||||
:min="1"
|
||||
:max="31"
|
||||
size="small"
|
||||
class="w-16"
|
||||
@focus="cronBuilder.day.mode = 'step'"
|
||||
/>
|
||||
<span class="text-sm">天</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.day.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.day.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.day.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 31"
|
||||
:key="v"
|
||||
:color="getOptionColor(cronBuilder.day.values.includes(v))"
|
||||
class="cursor-pointer select-none text-center transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.day.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.day, v);
|
||||
"
|
||||
>
|
||||
{{ v }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'month'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.month.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.month.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.month.mode === 'all'" />
|
||||
<span class="text-sm">每月</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.month.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.month.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.month.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 12"
|
||||
:key="v"
|
||||
:color="getOptionColor(cronBuilder.month.values.includes(v))"
|
||||
class="cursor-pointer select-none text-center transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.month.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.month, v);
|
||||
"
|
||||
>
|
||||
{{ v }}月
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'weekday'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.weekday.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.weekday.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.weekday.mode === 'all'" />
|
||||
<span class="text-sm">每天</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.weekday.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.weekday.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.weekday.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 7"
|
||||
:key="v - 1"
|
||||
:color="
|
||||
getOptionColor(cronBuilder.weekday.values.includes(v - 1))
|
||||
"
|
||||
class="cursor-pointer select-none px-4 py-1 transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.weekday.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.weekday, v - 1);
|
||||
"
|
||||
>
|
||||
{{ WEEKDAY_LABELS[v - 1] }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 border-border">
|
||||
<div class="flex-1">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="item in segmentOptions"
|
||||
:key="item.value"
|
||||
class="flex items-center overflow-hidden rounded-md border border-input transition-colors"
|
||||
:class="{
|
||||
'border-primary shadow-sm': activeTab === item.value,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 border-r border-border px-2.5 py-1 text-sm font-medium text-muted-foreground"
|
||||
:class="{
|
||||
'text-primary': activeTab === item.value,
|
||||
}"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<a-input
|
||||
:value="cronParts[item.value as keyof typeof cronParts]"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="font-mono text-sm"
|
||||
@change="
|
||||
(e: Event) =>
|
||||
parseFieldString(
|
||||
item.value as any,
|
||||
(e.target as HTMLInputElement).value,
|
||||
)
|
||||
"
|
||||
@focus="activeTab = item.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 flex items-center overflow-hidden rounded-md border border-input"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 border-r border-border px-2.5 py-1 text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
表达式
|
||||
</span>
|
||||
<a-input
|
||||
:value="cronExpression"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="font-mono text-sm"
|
||||
@change="
|
||||
(e: Event) =>
|
||||
parseCronExpression((e.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-52 flex-shrink-0 border-l border-border pl-4">
|
||||
<div class="mb-1 text-sm font-medium text-primary">
|
||||
近五次执行时间
|
||||
</div>
|
||||
<template v-if="cronPreviewRuns.length > 0">
|
||||
<div
|
||||
v-for="(time, idx) in cronPreviewRuns"
|
||||
:key="idx"
|
||||
class="py-0.5 font-mono text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{{ dayjs(time).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-sm text-muted-foreground/60">
|
||||
无法计算运行时间
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { TaskSchedulerResult } from '#/api';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getTaskRegisteredApi } from '#/api';
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const CRONTAB_PRESETS = [
|
||||
{ label: '每分钟', value: '* * * * *' },
|
||||
{ label: '每小时整点', value: '0 * * * *' },
|
||||
{ label: '每天午夜', value: '0 0 * * *' },
|
||||
{ label: '每天 9:00', value: '0 9 * * *' },
|
||||
{ label: '工作日 9:00', value: '0 9 * * 1-5' },
|
||||
{ label: '每15分钟', value: '*/15 * * * *' },
|
||||
{ label: '每3小时', value: '0 */3 * * *' },
|
||||
];
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '任务名称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
|
||||
},
|
||||
fieldName: 'type',
|
||||
label: '策略类型',
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<TaskSchedulerResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '任务名称',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'task',
|
||||
title: 'Celery 任务',
|
||||
minWidth: 200,
|
||||
showOverflow: true,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '策略类型',
|
||||
width: 150,
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'schedule',
|
||||
title: '触发策略',
|
||||
minWidth: 150,
|
||||
slots: { default: 'schedule' },
|
||||
titleSuffix: {
|
||||
icon: 'vxe-icon-question-circle-fill',
|
||||
content:
|
||||
'Crontab 表达式:https://docs.celeryq.dev/en/latest/userguide/periodic-tasks.html#crontab-schedules',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: { default: 'enabled' },
|
||||
},
|
||||
{
|
||||
field: 'total_run_count',
|
||||
title: '执行总计',
|
||||
width: 100,
|
||||
slots: { default: 'total_run_count' },
|
||||
},
|
||||
{
|
||||
field: 'last_run_time',
|
||||
title: '最近执行',
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'execute',
|
||||
title: '手动执行',
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 80,
|
||||
slots: { default: 'execute' },
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'log',
|
||||
text: '日志',
|
||||
},
|
||||
'edit',
|
||||
'delete',
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createSchema(
|
||||
onOpenCrontabBuilder: () => void,
|
||||
): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: 'Interval(间隔)', value: 0 },
|
||||
// { label: 'Crontab(计划)', value: 1 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'type',
|
||||
formItemClass: 'col-span-2 md:col-span-2',
|
||||
label: '策略类型',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '任务名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
api: getTaskRegisteredApi,
|
||||
class: 'w-full',
|
||||
labelField: 'name',
|
||||
valueField: 'task',
|
||||
placeholder: 'Celery 任务名称或任务模块化字符串',
|
||||
},
|
||||
fieldName: 'task',
|
||||
label: 'Celery 任务',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'args',
|
||||
label: '位置参数',
|
||||
help: 'JSON 数组格式,例如:[1, "hello", true]',
|
||||
rules: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val || val.trim() === '') return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (!Array.isArray(parsed)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '必须是 JSON 数组格式,例如:[1, "hello", true]',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return JSON.stringify(parsed);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '无效的 JSON 格式,请输入合法的 JSON 数组',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'kwargs',
|
||||
label: '关键字参数',
|
||||
help: 'JSON 对象格式,例如:{"key": "value", "count": 10}',
|
||||
rules: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val || val.trim() === '') return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (
|
||||
Array.isArray(parsed) ||
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '必须是 JSON 对象格式,例如:{"key": "value"}',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return JSON.stringify(parsed);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '无效的 JSON 格式,请输入合法的 JSON 对象',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'queue',
|
||||
label: '执行队列',
|
||||
help: '将任务下发到指定队列',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'exchange',
|
||||
label: '消息交换机',
|
||||
help: '参考:https://docs.celeryq.dev/en/stable/userguide/routing.html#exchanges-queues-and-routing-keys',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'routing_key',
|
||||
label: '路由密钥',
|
||||
help: '参考:https://docs.celeryq.dev/en/stable/userguide/routing.html#exchanges-queues-and-routing-keys',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
showTime: true,
|
||||
},
|
||||
fieldName: 'start_time',
|
||||
label: '开始执行时间',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
showTime: true,
|
||||
},
|
||||
dependencies: {
|
||||
disabled: (values) => {
|
||||
return !!values.expire_seconds;
|
||||
},
|
||||
triggerFields: ['expire_seconds'],
|
||||
},
|
||||
fieldName: 'expire_time',
|
||||
label: '过期时间',
|
||||
help: '如果任务执行到该时间没有执行完,则取消执行',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
dependencies: {
|
||||
disabled: (values) => {
|
||||
return !!values.expire_time;
|
||||
},
|
||||
triggerFields: ['expire_time'],
|
||||
},
|
||||
fieldName: 'expire_seconds',
|
||||
label: '过期秒数',
|
||||
help: '如果任务执行超过该秒后没有执行完,则取消执行',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
min: 1,
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type === 0;
|
||||
},
|
||||
required: (values) => {
|
||||
return values.type === 0;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'interval_every',
|
||||
label: '执行周期',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
options: getDictOptions(DictEnum.TASK_PERIOD_TYPE),
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type === 0;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
defaultValue: 'seconds',
|
||||
fieldName: 'interval_period',
|
||||
label: '周期类型',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type === 1;
|
||||
},
|
||||
required: (values) => {
|
||||
return values.type === 1;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'crontab',
|
||||
label: '执行计划',
|
||||
rules: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) =>
|
||||
!val ||
|
||||
val.trim() === '' ||
|
||||
/^(?:[\d*/,-]+\s+){4}[\d*/,-]+$/.test(val.trim()),
|
||||
{
|
||||
message: '无效的 Crontab 表达式',
|
||||
},
|
||||
),
|
||||
help: 'Crontab 表达式:https://docs.celeryq.dev/en/latest/userguide/periodic-tasks.html#crontab-schedules',
|
||||
renderComponentContent: () => ({
|
||||
suffix: () =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'cursor: default;',
|
||||
title: '可视化配置',
|
||||
onMousedown: (e: Event) => e.preventDefault(),
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation();
|
||||
onOpenCrontabBuilder();
|
||||
},
|
||||
},
|
||||
'⚙',
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: $t('common.enabled'), value: true },
|
||||
// { label: $t('common.disabled'), value: false },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_CHOOSE),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: false,
|
||||
fieldName: 'one_off',
|
||||
label: '只执行一次',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { CreateTaskSchedulerParams, TaskSchedulerResult } from '#/api';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { confirm, Page, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { MaterialSymbolsAdd } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createTaskSchedulerApi,
|
||||
deleteTaskSchedulerApi,
|
||||
executeTaskSchedulerApi,
|
||||
getTaskSchedulerListApi,
|
||||
updateTaskSchedulerApi,
|
||||
updateTaskSchedulerStatusApi,
|
||||
} from '#/api';
|
||||
import { router } from '#/router';
|
||||
import { useWebSocketStore } from '#/store';
|
||||
import CronBuilder from '#/views/scheduler/manage/cron-builder.vue';
|
||||
|
||||
import { createSchema, querySchema, useColumns } from './data';
|
||||
import { getNextRuns, getScheduleLabel } from './schedule';
|
||||
|
||||
const wsStore = useWebSocketStore();
|
||||
|
||||
const taskWorkerStatus = ref<any[]>([]);
|
||||
const executeLoadingMap = ref<Record<number, boolean>>({});
|
||||
const statusLoadingMap = ref<Record<number, boolean>>({});
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<TaskSchedulerResult>) {
|
||||
switch (code) {
|
||||
case 'delete': {
|
||||
confirm({
|
||||
icon: 'warning',
|
||||
content: '确认删除此任务计划吗?',
|
||||
}).then(async () => {
|
||||
gridApi.setLoading(true);
|
||||
try {
|
||||
await deleteTaskSchedulerApi(row.id);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
gridApi.setLoading(false);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
modalApi.setData(row).open();
|
||||
break;
|
||||
}
|
||||
case 'log': {
|
||||
searchLog(row.task);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions<TaskSchedulerResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getTaskSchedulerListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const cronBuilderVisible = ref(false);
|
||||
|
||||
function openCrontabBuilder() {
|
||||
cronBuilderVisible.value = true;
|
||||
}
|
||||
|
||||
function onCronConfirm(value: string) {
|
||||
formApi.setFieldValue('crontab', value);
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2',
|
||||
showDefaultActions: false,
|
||||
schema: createSchema(openCrontabBuilder),
|
||||
});
|
||||
|
||||
interface formTaskSchedulerParams extends CreateTaskSchedulerParams {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
const formData = ref<formTaskSchedulerParams>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['任务'])
|
||||
: $t('ui.actionTitle.create', ['任务']);
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-5/12',
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
modalApi.lock();
|
||||
const data = await formApi.getValues<CreateTaskSchedulerParams>();
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateTaskSchedulerApi(formData.value?.id, data)
|
||||
: createTaskSchedulerApi(data));
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
await formApi.resetForm();
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<formTaskSchedulerParams>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
data.crontab = data.id ? data.crontab : '* * * * *';
|
||||
if (data.start_time) {
|
||||
data.start_time = dayjs(data.start_time, 'YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
if (data.expire_time) {
|
||||
data.expire_time = dayjs(data.expire_time, 'YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
formApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const executeTask = async (pk: number) => {
|
||||
executeLoadingMap.value[pk] = true;
|
||||
try {
|
||||
await executeTaskSchedulerApi(pk);
|
||||
message.success('执行成功,任务已下发');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
executeLoadingMap.value[pk] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchLog = (task: string) => {
|
||||
router.push({ path: '/scheduler/record', query: { name: task } });
|
||||
};
|
||||
|
||||
const handleStatusChange = async (
|
||||
row: TaskSchedulerResult,
|
||||
checked: boolean,
|
||||
) => {
|
||||
statusLoadingMap.value[row.id] = true;
|
||||
try {
|
||||
await updateTaskSchedulerStatusApi(row.id);
|
||||
row.enabled = checked;
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
statusLoadingMap.value[row.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
function onEnabledChange(row: TaskSchedulerResult, checked: boolean) {
|
||||
handleStatusChange(row, checked);
|
||||
}
|
||||
|
||||
let cleanupWS: (() => void) | null = null;
|
||||
const intervalId = ref<any>(null);
|
||||
const emitWS = () => {
|
||||
wsStore.emit('task_worker_status');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
cleanupWS = wsStore.on('task_worker_status', (data: any[]) => {
|
||||
taskWorkerStatus.value = data;
|
||||
});
|
||||
emitWS();
|
||||
intervalId.value = setInterval(emitWS, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanupWS) {
|
||||
cleanupWS();
|
||||
}
|
||||
if (intervalId.value) {
|
||||
clearInterval(intervalId.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton @click="() => modalApi.setData(null).open()">
|
||||
<MaterialSymbolsAdd class="size-5" />
|
||||
创建任务
|
||||
</VbenButton>
|
||||
</template>
|
||||
<template #schedule="{ row }">
|
||||
<a-popover placement="top">
|
||||
<template #content>
|
||||
<div class="text-sm">
|
||||
<div class="mb-1 font-medium">接下来 5 次运行时间:</div>
|
||||
<template v-if="getNextRuns(row).length > 0">
|
||||
<div
|
||||
v-for="(time, idx) in getNextRuns(row)"
|
||||
:key="idx"
|
||||
class="py-0.5"
|
||||
>
|
||||
{{ dayjs(time).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-gray-400">无法计算运行时间</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag class="cursor-pointer">
|
||||
{{ getScheduleLabel(row) }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
<template #enabled="{ row }">
|
||||
<a-switch
|
||||
:checked="row.enabled"
|
||||
:checked-value="true"
|
||||
:un-checked-value="false"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
:loading="!!statusLoadingMap[row.id]"
|
||||
@change="onEnabledChange(row, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template #total_run_count="{ row }">
|
||||
{{ row.total_run_count }} 次
|
||||
</template>
|
||||
<template #execute="{ row }">
|
||||
<a-tooltip
|
||||
v-if="taskWorkerStatus.length === 0"
|
||||
title="任务执行服务暂不可用,请联系系统管理员"
|
||||
>
|
||||
<a-button size="small" disabled>执行</a-button>
|
||||
</a-tooltip>
|
||||
<a-button
|
||||
v-else
|
||||
size="small"
|
||||
:loading="!!executeLoadingMap[row.id]"
|
||||
@click="executeTask(row.id)"
|
||||
>
|
||||
执行
|
||||
</a-button>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal :title="modalTitle">
|
||||
<Form />
|
||||
</Modal>
|
||||
<CronBuilder
|
||||
v-model:open="cronBuilderVisible"
|
||||
:value="formApi.form?.values?.crontab"
|
||||
@confirm="onCronConfirm"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { TaskSchedulerResult } from '#/api';
|
||||
|
||||
import { Cron } from 'croner';
|
||||
|
||||
const periodLabelMap: Record<string, string> = {
|
||||
days: '天',
|
||||
hours: '小时',
|
||||
minutes: '分钟',
|
||||
seconds: '秒',
|
||||
};
|
||||
|
||||
const periodToSeconds: Record<string, number> = {
|
||||
days: 86_400,
|
||||
hours: 3600,
|
||||
minutes: 60,
|
||||
seconds: 1,
|
||||
};
|
||||
|
||||
function getScheduleLabel(row: TaskSchedulerResult): string {
|
||||
if (row.type === 0) {
|
||||
const label =
|
||||
periodLabelMap[row.interval_period || 'seconds'] || row.interval_period;
|
||||
return `每 ${row.interval_every || '?'} ${label}`;
|
||||
}
|
||||
return row.crontab || '';
|
||||
}
|
||||
|
||||
function getNextRuns(row: TaskSchedulerResult): Date[] {
|
||||
try {
|
||||
if (row.type === 0) {
|
||||
const every = row.interval_every;
|
||||
const period = row.interval_period || 'seconds';
|
||||
if (!every || every <= 0) return [];
|
||||
const intervalMs = every * (periodToSeconds[period] || 1) * 1000;
|
||||
const baseTime = row.last_run_time
|
||||
? new Date(row.last_run_time)
|
||||
: new Date();
|
||||
return Array.from(
|
||||
{ length: 5 },
|
||||
(_, i) => new Date(baseTime.getTime() + intervalMs * (i + 1)),
|
||||
);
|
||||
}
|
||||
if (!row.crontab) return [];
|
||||
return new Cron(row.crontab).nextRuns(5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export { getNextRuns, getScheduleLabel };
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { TaskResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'task_id',
|
||||
label: '任务 ID',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '任务名称',
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<TaskResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{ field: 'checkbox', type: 'checkbox', align: 'left', width: 50 },
|
||||
{
|
||||
field: 'task_id',
|
||||
title: '任务 ID',
|
||||
minWidth: 200,
|
||||
showOverflow: true,
|
||||
},
|
||||
{ field: 'name', title: '任务名称' },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ color: 'success', label: 'SUCCESS', value: 'SUCCESS' },
|
||||
{ color: 'error', label: 'FAILURE', value: 'FAILURE' },
|
||||
{ color: 'processing', label: 'PENDING', value: 'PENDING' },
|
||||
{ color: 'processing', label: 'STARTED', value: 'STARTED' },
|
||||
{ color: 'warning', label: 'RETRY', value: 'RETRY' },
|
||||
{ color: 'default', label: 'REVOKED', value: 'REVOKED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'result',
|
||||
title: '结果',
|
||||
showOverflow: true,
|
||||
},
|
||||
{ field: 'retries', title: '重试次数' },
|
||||
{ field: 'date_done', title: '结束时间', width: 168 },
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'details',
|
||||
text: '详情',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeGridListeners,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { TaskResult } from '#/api';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import {
|
||||
confirm,
|
||||
JsonViewer,
|
||||
Page,
|
||||
useVbenDrawer,
|
||||
VbenButton,
|
||||
} from '@vben/common-ui';
|
||||
import { MaterialSymbolsDelete } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteTaskResultApi, getTaskResultListApi } from '#/api';
|
||||
|
||||
import { querySchema, useColumns } from './data';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<TaskResult>) {
|
||||
switch (code) {
|
||||
case 'details': {
|
||||
taskResultDetails.value = row;
|
||||
drawerApi.open();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions<TaskResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getTaskResultListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const gridEvents: VxeGridListeners<TaskResult> = {
|
||||
checkboxChange: () => {
|
||||
const data = gridApi.grid.getCheckboxRecords(true);
|
||||
checkedRows.value = data.map((item: any) => item.id);
|
||||
},
|
||||
checkboxAll: () => {
|
||||
const data = gridApi.grid.getCheckboxRecords(true);
|
||||
checkedRows.value = data.map((item: any) => item.id);
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
destroyOnClose: true,
|
||||
footer: false,
|
||||
class: 'w-2/5',
|
||||
});
|
||||
|
||||
const taskResultDetails = ref<TaskResult>();
|
||||
|
||||
function parseJsonSafe(value: any): any {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const taskResultDescItems = computed(() => {
|
||||
const d = taskResultDetails.value;
|
||||
return [
|
||||
{ key: 'task_id', label: '任务 ID', content: d?.task_id, span: 2 },
|
||||
{ key: 'name', label: '任务名称', content: d?.name },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'date_done', label: '结束时间', content: d?.date_done || 'N/A' },
|
||||
{ key: 'retries', label: '重试次数', content: d?.retries ?? 0 },
|
||||
{ key: 'worker', label: 'Worker', content: d?.worker || 'N/A', span: 2 },
|
||||
{ key: 'queue', label: '队列', content: d?.queue || 'N/A', span: 2 },
|
||||
{ key: 'args', label: '位置参数', span: 2 },
|
||||
{ key: 'kwargs', label: '关键参数', span: 2 },
|
||||
{ key: 'result', label: '执行结果', span: 2 },
|
||||
{ key: 'traceback', label: '错误回溯', span: 2 },
|
||||
];
|
||||
});
|
||||
|
||||
const checkedRows = ref<number[]>([]);
|
||||
const deleteDisable = ref<boolean>(true);
|
||||
const deleteLoading = ref<boolean>(false);
|
||||
|
||||
const deleteTaskResult = async () => {
|
||||
confirm({
|
||||
icon: 'warning',
|
||||
content: '确定删除已勾选的记录吗?',
|
||||
}).then(async () => {
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
await deleteTaskResultApi(checkedRows.value);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
onRefresh();
|
||||
deleteDisable.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watch(checkedRows, () => {
|
||||
deleteDisable.value = checkedRows.value.length === 0;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query) {
|
||||
gridApi.reload({ name: route.query.name ?? undefined });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton
|
||||
variant="destructive"
|
||||
:disabled="deleteDisable"
|
||||
:loading="deleteLoading"
|
||||
@click="deleteTaskResult"
|
||||
>
|
||||
<MaterialSymbolsDelete class="size-5" />
|
||||
删除记录
|
||||
</VbenButton>
|
||||
</template>
|
||||
</Grid>
|
||||
<Drawer title="任务执行记录详情">
|
||||
<a-descriptions
|
||||
:styles="{ label: { color: '#6b7280' } }"
|
||||
class="ml-1"
|
||||
:column="2"
|
||||
:items="taskResultDescItems"
|
||||
>
|
||||
<template #contentRender="{ item }">
|
||||
<template v-if="item.key === 'status'">
|
||||
<a-tag
|
||||
v-if="taskResultDetails?.status === 'SUCCESS'"
|
||||
color="success"
|
||||
>
|
||||
SUCCESS
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-else-if="taskResultDetails?.status === 'FAILURE'"
|
||||
color="error"
|
||||
>
|
||||
FAILURE
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-else-if="
|
||||
taskResultDetails?.status === 'PENDING' ||
|
||||
taskResultDetails?.status === 'STARTED'
|
||||
"
|
||||
color="processing"
|
||||
>
|
||||
{{ taskResultDetails?.status }}
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-else-if="taskResultDetails?.status === 'RETRY'"
|
||||
color="warning"
|
||||
>
|
||||
RETRY
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-else-if="taskResultDetails?.status === 'REVOKED'"
|
||||
color="default"
|
||||
>
|
||||
REVOKED
|
||||
</a-tag>
|
||||
<a-tag v-else color="processing">
|
||||
{{ taskResultDetails?.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'args'">
|
||||
<JsonViewer
|
||||
v-if="taskResultDetails?.args"
|
||||
class="mr-8 w-full"
|
||||
:value="parseJsonSafe(taskResultDetails?.args)"
|
||||
:copyable="!!taskResultDetails?.args"
|
||||
boxed
|
||||
expanded
|
||||
:expand-depth="3"
|
||||
:show-array-index="false"
|
||||
@copied="message.success('已复制位置参数')"
|
||||
/>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'kwargs'">
|
||||
<JsonViewer
|
||||
v-if="taskResultDetails?.kwargs"
|
||||
class="mr-8 w-full"
|
||||
:value="parseJsonSafe(taskResultDetails?.kwargs)"
|
||||
:copyable="!!taskResultDetails?.kwargs"
|
||||
boxed
|
||||
expanded
|
||||
:expand-depth="3"
|
||||
:show-array-index="false"
|
||||
@copied="message.success('已复制关键字参数')"
|
||||
/>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'result'">
|
||||
<div v-if="taskResultDetails?.result">
|
||||
{{ taskResultDetails?.result }}
|
||||
</div>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'traceback'">
|
||||
<div v-if="taskResultDetails?.traceback">
|
||||
{{ taskResultDetails?.traceback }}
|
||||
</div>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-descriptions>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { SysDataRuleResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { getSysDataRuleModelColumnsApi } from '#/api';
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '数据规则名称',
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<SysDataRuleResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{ field: 'name', title: '规则名称' },
|
||||
{ field: 'model', title: '模型名称' },
|
||||
{ field: 'column', title: '列名称' },
|
||||
{
|
||||
field: 'operator',
|
||||
title: '操作符',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { color: 'cyan', label: 'OR', value: 1 },
|
||||
// { color: 'success', label: 'AND', value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_DATA_RULE_OPERATOR),
|
||||
},
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'expression',
|
||||
title: '表达式',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { label: 'not in', value: 7 },
|
||||
// { label: 'in', value: 6 },
|
||||
// { label: '<=', value: 5 },
|
||||
// { label: '<', value: 4 },
|
||||
// { label: '>=', value: 3 },
|
||||
// { label: '>', value: 2 },
|
||||
// { label: '!=', value: 1 },
|
||||
// { label: '==', value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_DATA_RULE_EXPRESSION),
|
||||
},
|
||||
width: 80,
|
||||
},
|
||||
{ field: 'value', title: '规则值' },
|
||||
{
|
||||
field: 'created_time',
|
||||
title: $t('common.table.created_time'),
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'updated_time',
|
||||
title: $t('common.table.updated_time'),
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const schema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '规则名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
options: [],
|
||||
},
|
||||
fieldName: 'model',
|
||||
label: '模型',
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'column',
|
||||
label: '字段',
|
||||
rules: 'selectRequired',
|
||||
dependencies: {
|
||||
componentProps: async (values) => {
|
||||
if (values.model) {
|
||||
const res = await getSysDataRuleModelColumnsApi(values.model);
|
||||
return {
|
||||
class: 'w-full',
|
||||
options: res.map((item) => ({
|
||||
label: item.comment,
|
||||
value: item.key,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { class: 'w-full' };
|
||||
},
|
||||
disabled(values) {
|
||||
return !values.model;
|
||||
},
|
||||
triggerFields: ['model'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: 'OR', value: 1 },
|
||||
// { label: 'AND', value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_DATA_RULE_OPERATOR),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'operator',
|
||||
label: '操作符',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
// options: [
|
||||
// { label: 'not in', value: 7 },
|
||||
// { label: 'in', value: 6 },
|
||||
// { label: '<=', value: 5 },
|
||||
// { label: '<', value: 4 },
|
||||
// { label: '>=', value: 3 },
|
||||
// { label: '>', value: 2 },
|
||||
// { label: '!=', value: 1 },
|
||||
// { label: '==', value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_DATA_RULE_EXPRESSION),
|
||||
},
|
||||
fieldName: 'expression',
|
||||
label: '表达式',
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
component: 'AutoComplete',
|
||||
componentProps: {
|
||||
placeholder: '请输入值或选择参数',
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
showSearch: {
|
||||
filterOption: (input: string, option: any) =>
|
||||
(option?.value || '').toLowerCase().includes(input.toLowerCase()) ||
|
||||
(option?.label || '').toLowerCase().includes(input.toLowerCase()),
|
||||
},
|
||||
options: [],
|
||||
},
|
||||
fieldName: 'value',
|
||||
label: '规则值',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { CreateSysDataRuleParams, SysDataRuleResult } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { MaterialSymbolsAdd } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createSysDataRuleApi,
|
||||
deleteSysDataRuleApi,
|
||||
getSysDataRuleListApi,
|
||||
getSysDataRuleModelsApi,
|
||||
getSysDataRuleTemplateVariablesApi,
|
||||
updateSysDataRuleApi,
|
||||
} from '#/api';
|
||||
|
||||
import { querySchema, schema, useColumns } from './data';
|
||||
|
||||
const dataRuleFormOptions: VbenFormProps = {
|
||||
showCollapseButton: false,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const dataRuleGridOptions: VxeTableGridOptions<SysDataRuleResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getSysDataRuleListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: dataRuleFormOptions,
|
||||
gridOptions: dataRuleGridOptions,
|
||||
});
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<SysDataRuleResult>) {
|
||||
switch (code) {
|
||||
case 'delete': {
|
||||
deleteSysDataRuleApi([row.id]).then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
openAddModal(row, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
showDefaultActions: false,
|
||||
schema,
|
||||
});
|
||||
|
||||
interface formSysDataRuleParams extends CreateSysDataRuleParams {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
const formData = ref<formSysDataRuleParams>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['数据规则'])
|
||||
: $t('ui.actionTitle.create', ['数据规则']);
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
destroyOnClose: false,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
modalApi.lock();
|
||||
const data = await formApi.getValues<CreateSysDataRuleParams>();
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateSysDataRuleApi(formData.value?.id, data)
|
||||
: createSysDataRuleApi(data));
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
onRefresh();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<formSysDataRuleParams>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
formApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const openAddModal = (row: any, isAdd: boolean) => {
|
||||
try {
|
||||
getSysDataRuleModelsApi().then((res) => {
|
||||
formApi.updateSchema([
|
||||
{
|
||||
componentProps: {
|
||||
options: res.map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
})),
|
||||
},
|
||||
fieldName: 'model',
|
||||
},
|
||||
]);
|
||||
});
|
||||
getSysDataRuleTemplateVariablesApi().then((res) => {
|
||||
formApi.updateSchema([
|
||||
{
|
||||
componentProps: {
|
||||
options: res.map((item) => ({
|
||||
label: `${item.key}(${item.comment})`,
|
||||
value: item.key,
|
||||
})),
|
||||
},
|
||||
fieldName: 'value',
|
||||
},
|
||||
]);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
if (isAdd) {
|
||||
modalApi.setData(null).open();
|
||||
} else {
|
||||
modalApi.setData(row).open();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton @click="openAddModal(null, true)">
|
||||
<MaterialSymbolsAdd class="size-5" />
|
||||
新增数据规则
|
||||
</VbenButton>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal :title="modalTitle">
|
||||
<Form />
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { SysDataScopeResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '数据范围名称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
// options: [
|
||||
// {
|
||||
// label: '正常',
|
||||
// value: 1,
|
||||
// },
|
||||
// {
|
||||
// label: '停用',
|
||||
// value: 0,
|
||||
// },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('common.form.status'),
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<SysDataScopeResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{ field: 'name', title: '范围名称' },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
},
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'created_time',
|
||||
title: $t('common.table.created_time'),
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'updated_time',
|
||||
title: $t('common.table.updated_time'),
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'rule',
|
||||
text: '规则设置',
|
||||
},
|
||||
'edit',
|
||||
'delete',
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const schema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '数据范围名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: $t('common.enabled'), value: 1 },
|
||||
// { label: $t('common.disabled'), value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
|
||||
export const drawerColumns: VxeGridProps['columns'] = [
|
||||
{
|
||||
type: 'checkbox',
|
||||
title: '规则名称',
|
||||
align: 'left',
|
||||
},
|
||||
];
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type {
|
||||
CreateSysDataScopeParams,
|
||||
SysDataRuleResult,
|
||||
SysDataScopeResult,
|
||||
} from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { MaterialSymbolsAdd } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createSysDataScope,
|
||||
deleteSysDataScopeApi,
|
||||
getSysDataRulesApi,
|
||||
getSysDataScopeListApi,
|
||||
getSysDataScopeRulesApi,
|
||||
updateSysDataScope,
|
||||
updateSysDataScopeRulesApi,
|
||||
} from '#/api';
|
||||
|
||||
import { drawerColumns, querySchema, schema, useColumns } from './data';
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
showCollapseButton: false,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<SysDataScopeResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getSysDataScopeListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<SysDataScopeResult>) {
|
||||
switch (code) {
|
||||
case 'delete': {
|
||||
deleteSysDataScopeApi([row.id]).then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
modalApi.setData(row).open();
|
||||
break;
|
||||
}
|
||||
case 'rule': {
|
||||
getSysDataScopeRulesApi(row.id).then((res) => {
|
||||
dataScopeRuleGridApi.setGridOptions({
|
||||
checkboxConfig: {
|
||||
checkRowKeys: res.rules.map((item) => item.id),
|
||||
},
|
||||
});
|
||||
});
|
||||
clickDataScope.value = row.id;
|
||||
drawerApi.open();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema,
|
||||
});
|
||||
|
||||
interface formSysDataScopeParams extends CreateSysDataScopeParams {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
const formData = ref<formSysDataScopeParams>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['数据范围'])
|
||||
: $t('ui.actionTitle.create', ['数据范围']);
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
modalApi.lock();
|
||||
const data = await formApi.getValues<CreateSysDataScopeParams>();
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateSysDataScope(formData.value?.id, data)
|
||||
: createSysDataScope(data));
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
onRefresh();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<formSysDataScopeParams>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
formApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const clickDataScope = ref<number>(0);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
destroyOnClose: true,
|
||||
closable: false,
|
||||
class: 'w-2/5',
|
||||
onConfirm() {
|
||||
const checkedRows = dataScopeRuleGridApi.grid.getCheckboxRecords(true);
|
||||
updateSysDataScopeRulesApi(
|
||||
clickDataScope.value,
|
||||
checkedRows.map((item: any) => item.id),
|
||||
).then(() => {
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
drawerApi.close();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const dataScopeRulesGridOptions: VxeTableGridOptions<SysDataRuleResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
height: 'auto',
|
||||
virtualYConfig: {
|
||||
enabled: true,
|
||||
gt: 0,
|
||||
},
|
||||
checkboxConfig: {
|
||||
labelField: 'name',
|
||||
highlight: true,
|
||||
reserve: true,
|
||||
checkRowKeys: [],
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
columns: drawerColumns,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
return await getSysDataRulesApi();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [DataScopeRuleGrid, dataScopeRuleGridApi] = useVbenVxeGrid({
|
||||
gridOptions: dataScopeRulesGridOptions,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid
|
||||
table-title="数据范围"
|
||||
table-title-help="数据范围是一种用于定义数据规则集的机制,可将多个数据规则关联绑定至同一数据范围,从而构建支持多条件组合筛选的数据权限体系"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<VbenButton @click="() => modalApi.setData(null).open()">
|
||||
<MaterialSymbolsAdd class="size-5" />
|
||||
新增数据范围
|
||||
</VbenButton>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal :title="modalTitle">
|
||||
<Form />
|
||||
</Modal>
|
||||
<Drawer title="关联数据规则">
|
||||
<DataScopeRuleGrid />
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { SysDeptTreeResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSysDeptTreeApi } from '#/api';
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '部门名称',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'leader',
|
||||
label: '负责人',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'phone',
|
||||
label: '手机号码',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
// options: [
|
||||
// {
|
||||
// label: '正常',
|
||||
// value: 1,
|
||||
// },
|
||||
// {
|
||||
// label: '停用',
|
||||
// value: 0,
|
||||
// },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('common.form.status'),
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<SysDeptTreeResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{ field: 'name', title: '名称', align: 'left', treeNode: true },
|
||||
{ field: 'leader', title: '负责人' },
|
||||
{ field: 'phone', title: '手机号码' },
|
||||
{ field: 'email', title: '邮箱' },
|
||||
{ field: 'sort', title: '排序' },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'created_time',
|
||||
title: $t('common.table.created_time'),
|
||||
width: 168,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'add',
|
||||
text: '新增下级',
|
||||
},
|
||||
'edit',
|
||||
{
|
||||
code: 'delete',
|
||||
disabled: (row: SysDeptTreeResult) => {
|
||||
return row.id === 1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const schema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '部门名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
api: getSysDeptTreeApi,
|
||||
class: 'w-full',
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
},
|
||||
fieldName: 'parent_id',
|
||||
label: '父级部门',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'leader',
|
||||
label: '负责人',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
},
|
||||
fieldName: 'phone',
|
||||
label: '手机号码',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
},
|
||||
fieldName: 'email',
|
||||
label: '邮箱地址',
|
||||
rules: z.string().email({ message: '无效的邮箱地址' }).optional(),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: $t('common.enabled'), value: 1 },
|
||||
// { label: $t('common.disabled'), value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SysDeptParams, SysDeptTreeResult } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { MaterialSymbolsAdd } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createSysDeptApi,
|
||||
deleteSysDeptApi,
|
||||
getSysDeptTreeApi,
|
||||
updateSysDeptApi,
|
||||
} from '#/api';
|
||||
|
||||
import { querySchema, schema, useColumns } from './data';
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<SysDeptTreeResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
treeConfig: {
|
||||
parentField: 'parent_id',
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (_, formValues) => {
|
||||
return await getSysDeptTreeApi({ ...formValues });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const expandAll = () => {
|
||||
gridApi.grid?.setAllTreeExpand(true);
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
gridApi.grid?.setAllTreeExpand(false);
|
||||
};
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<SysDeptTreeResult>) {
|
||||
switch (code) {
|
||||
case 'add': {
|
||||
modalApi.setData({ parent_id: row.id }).open();
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
deleteSysDeptApi(row.id).then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
modalApi.setData(row).open();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema,
|
||||
});
|
||||
|
||||
interface formSysDeptParams extends SysDeptParams {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
const formData = ref<formSysDeptParams>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['部门'])
|
||||
: $t('ui.actionTitle.create', ['部门']);
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
modalApi.lock();
|
||||
const data = await formApi.getValues<SysDeptParams>();
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateSysDeptApi(formData.value.id, data)
|
||||
: createSysDeptApi(data));
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
onRefresh();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<formSysDeptParams>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
if (data.parent_id === 0) {
|
||||
data.parent_id = undefined;
|
||||
}
|
||||
formData.value = data;
|
||||
formApi.setValues(formData.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton @click="() => modalApi.setData(null).open()">
|
||||
<MaterialSymbolsAdd class="size-5" />
|
||||
新增部门
|
||||
</VbenButton>
|
||||
</template>
|
||||
<template #toolbar-tools>
|
||||
<a-button class="mr-2" type="primary" @click="expandAll">
|
||||
展开全部
|
||||
</a-button>
|
||||
<a-button type="primary" @click="collapseAll">折叠全部</a-button>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal :title="modalTitle">
|
||||
<Form />
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { SysMenuTreeResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSysMenuTreeApi } from '#/api';
|
||||
import { componentKeys } from '#/router/routes';
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'title',
|
||||
label: '菜单标题',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
// options: [
|
||||
// {
|
||||
// label: '正常',
|
||||
// value: 1,
|
||||
// },
|
||||
// {
|
||||
// label: '停用',
|
||||
// value: 0,
|
||||
// },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('common.form.status'),
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<SysMenuTreeResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'title',
|
||||
title: '标题',
|
||||
align: 'left',
|
||||
fixed: 'left',
|
||||
slots: { default: 'title_default' },
|
||||
treeNode: true,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '类型',
|
||||
width: 80,
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { color: 'orange', label: '目录', value: 0 },
|
||||
// { color: 'default', label: '菜单', value: 1 },
|
||||
// { color: 'blue', label: '按钮', value: 2 },
|
||||
// { color: 'warning', label: '内嵌', value: 3 },
|
||||
// { color: 'success', label: '外链', value: 4 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_MENU_TYPE),
|
||||
},
|
||||
},
|
||||
{ field: 'sort', title: '排序', width: 50 },
|
||||
{ field: 'perms', title: '权限标识', align: 'left', width: 160 },
|
||||
{ field: 'name', title: '菜单名称', align: 'left', width: 180 },
|
||||
{ field: 'path', title: '路由地址', align: 'left', width: 180 },
|
||||
{ field: 'component', title: '页面组件', align: 'left', width: 300 },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 80,
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
},
|
||||
},
|
||||
{ field: 'remark', title: '备注' },
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'add',
|
||||
text: '新增子菜单',
|
||||
disabled: (row: SysMenuTreeResult) => {
|
||||
return [2, 3, 4].includes(row.type);
|
||||
},
|
||||
},
|
||||
'edit',
|
||||
{
|
||||
code: 'delete',
|
||||
disabled: (row: SysMenuTreeResult) => {
|
||||
return row.name === 'System' || row.name === 'Log';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const schema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: '目录', value: 0 },
|
||||
// { label: '菜单', value: 1 },
|
||||
// { label: '按钮', value: 2 },
|
||||
// { label: '内嵌', value: 3 },
|
||||
// { label: '外链', value: 4 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_MENU_TYPE),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'type',
|
||||
formItemClass: 'md:col-span-2',
|
||||
label: '菜单类型',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'title',
|
||||
label: '菜单标题',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
api: getSysMenuTreeApi,
|
||||
class: 'w-full',
|
||||
labelField: 'title',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
},
|
||||
fieldName: 'parent_id',
|
||||
label: '父级部门',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '菜单名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return [0, 1, 3, 4].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'path',
|
||||
label: '路由地址',
|
||||
rules: z.string().regex(/^\/.*/, { message: '必须以为 "/" 开始' }),
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
defaultValue: 0,
|
||||
min: 0,
|
||||
style: { width: '100%' },
|
||||
},
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
},
|
||||
{
|
||||
component: 'IconPicker',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return [0, 1, 3, 4].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'icon',
|
||||
label: '图标',
|
||||
},
|
||||
{
|
||||
component: 'AutoComplete',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
filterOption(input: string, option: { value: string }) {
|
||||
return option.value.toLowerCase().includes(input.toLowerCase());
|
||||
},
|
||||
options: componentKeys.map((v: any) => ({ value: v })),
|
||||
},
|
||||
dependencies: {
|
||||
rules: (values) => {
|
||||
return values.type === 1 ? 'required' : null;
|
||||
},
|
||||
show: (values) => {
|
||||
return values.type === 1;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'component',
|
||||
label: '组件路径',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
rules: (values) => {
|
||||
return values.type === 2 ? 'required' : null;
|
||||
},
|
||||
show: (values) => {
|
||||
return [1, 2, 3].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'perms',
|
||||
label: '权限标识',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return [3, 4].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'link',
|
||||
label: '链接地址',
|
||||
rules: z.string().url($t('ui.formRules.invalidURL')),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: $t('common.enabled'), value: 1 },
|
||||
// { label: $t('common.disabled'), value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Switch',
|
||||
componentProps: {
|
||||
checkedValue: 1,
|
||||
unCheckedValue: 0,
|
||||
},
|
||||
defaultValue: 1,
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type !== 2;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'display',
|
||||
label: '是否显示',
|
||||
},
|
||||
{
|
||||
component: 'Switch',
|
||||
componentProps: {
|
||||
checkedValue: 1,
|
||||
unCheckedValue: 0,
|
||||
},
|
||||
defaultValue: 1,
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type === 1;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'cache',
|
||||
label: '是否缓存',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import type { OnActionClickParams } from '#/adapter/vxe-table';
|
||||
import type { SysMenuParams, SysMenuTreeResult } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { IconifyIcon, MaterialSymbolsAdd } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createSysMenuApi,
|
||||
deleteSysMenuApi,
|
||||
getSysMenuTreeApi,
|
||||
updateSysMenuApi,
|
||||
} from '#/api';
|
||||
import { querySchema, schema, useColumns } from '#/views/system/menu/data';
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<SysMenuTreeResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
treeConfig: {
|
||||
parentField: 'parent_id',
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (_, formValues) => {
|
||||
return await getSysMenuTreeApi({ ...formValues });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const expandAll = () => {
|
||||
gridApi.grid?.setAllTreeExpand(true);
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
gridApi.grid?.setAllTreeExpand(false);
|
||||
};
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<SysMenuTreeResult>) {
|
||||
switch (code) {
|
||||
case 'add': {
|
||||
modalApi.setData({ parent_id: row.id }).open();
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
deleteSysMenuApi(row.id).then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
modalApi.setData(row).open();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'md:grid-cols-2',
|
||||
showDefaultActions: false,
|
||||
schema,
|
||||
});
|
||||
|
||||
interface formSysMenuParams extends SysMenuParams {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
const formData = ref<formSysMenuParams>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['菜单'])
|
||||
: $t('ui.actionTitle.create', ['菜单']);
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-5/12',
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
modalApi.lock();
|
||||
const data = await formApi.getValues<SysMenuParams>();
|
||||
if (data.type === 3 || data.type === 4) {
|
||||
data.component = '/_core/fallback/iframe.vue';
|
||||
}
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateSysMenuApi(formData.value.id, data)
|
||||
: createSysMenuApi(data));
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
onRefresh();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<SysMenuParams>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
if (data.parent_id === 0) {
|
||||
data.parent_id = 0;
|
||||
}
|
||||
formData.value = data;
|
||||
formApi.setValues(formData.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton @click="() => modalApi.setData(null).open()">
|
||||
<MaterialSymbolsAdd class="size-5" />
|
||||
新增菜单
|
||||
</VbenButton>
|
||||
</template>
|
||||
<template #toolbar-tools>
|
||||
<a-button class="mr-2" type="primary" @click="expandAll">
|
||||
展开全部
|
||||
</a-button>
|
||||
<a-button type="primary" @click="collapseAll">折叠全部</a-button>
|
||||
</template>
|
||||
<template #title_default="{ row }">
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<div class="size-5 flex-shrink-0">
|
||||
<IconifyIcon
|
||||
v-if="row.type === 2"
|
||||
icon="carbon:security"
|
||||
class="size-full"
|
||||
/>
|
||||
<a-image
|
||||
v-else-if="/^https?:\/\/.*$/.test(row.icon || '')"
|
||||
:src="row.icon"
|
||||
class="size-full"
|
||||
/>
|
||||
<IconifyIcon
|
||||
v-else
|
||||
:icon="row.icon || 'carbon:circle-dash'"
|
||||
class="size-full"
|
||||
/>
|
||||
</div>
|
||||
<span class="ml-1 flex-auto">{{ row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal :title="modalTitle">
|
||||
<Form />
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { Button } from 'antdv-next';
|
||||
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export function userSchema(fileList: any): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
defaultValue: 0,
|
||||
componentProps: {
|
||||
// options: [
|
||||
// {
|
||||
// label: '压缩包',
|
||||
// value: 0,
|
||||
// },
|
||||
// {
|
||||
// label: 'GIT',
|
||||
// value: 1,
|
||||
// },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_PLUGIN_TYPE),
|
||||
},
|
||||
fieldName: 'installType',
|
||||
label: '安装方式',
|
||||
},
|
||||
{
|
||||
component: 'Upload',
|
||||
dependencies: {
|
||||
show: (values) => values && values.installType === 0,
|
||||
triggerFields: ['installType'],
|
||||
},
|
||||
componentProps: {
|
||||
name: 'file',
|
||||
accept: '.zip',
|
||||
maxCount: 1,
|
||||
multiple: false,
|
||||
directory: false,
|
||||
fileList: fileList.value,
|
||||
beforeUpload: (file: any) => {
|
||||
fileList.value = [file];
|
||||
return false;
|
||||
},
|
||||
onRemove: () => {
|
||||
fileList.value = [];
|
||||
},
|
||||
},
|
||||
renderComponentContent: () => ({
|
||||
default: () => {
|
||||
return h(Button, {}, { default: () => 'Upload' });
|
||||
},
|
||||
}),
|
||||
fieldName: 'uploadField',
|
||||
label: 'ZIP 压缩包',
|
||||
rules: 'required',
|
||||
help: '仅能上传一个 zip 压缩包文件,重新上传则覆盖',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: (values) => values && values.installType === 1,
|
||||
triggerFields: ['installType'],
|
||||
},
|
||||
fieldName: 'repo_url',
|
||||
label: 'GIT 地址',
|
||||
rules: 'required',
|
||||
help: '仓库内容无法实时检测,请谨慎操作,避免非插件代码植入',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import type { PluginResult } from '#/api';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { confirm, Page, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
downloadPluginApi,
|
||||
getPluginChangedApi,
|
||||
getPluginListApi,
|
||||
installGitPluginApi,
|
||||
installZipPluginApi,
|
||||
uninstallPluginApi,
|
||||
updatePluginStatus,
|
||||
} from '#/api';
|
||||
|
||||
import { userSchema } from './data';
|
||||
|
||||
const pluginChanged = ref<boolean>(false);
|
||||
const localPluginChanged = ref(false);
|
||||
const pluginInfo = ref<PluginResult[]>();
|
||||
const pageLoading = ref(false);
|
||||
const deletingPlugin = ref('');
|
||||
const downloadingPlugin = ref('');
|
||||
const switchingPlugin = ref('');
|
||||
const showPluginChangedAlert = computed(() => {
|
||||
return pluginChanged.value || localPluginChanged.value;
|
||||
});
|
||||
|
||||
const fetchPluginChanged = async () => {
|
||||
try {
|
||||
pluginChanged.value = await getPluginChangedApi();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPlugin = async () => {
|
||||
try {
|
||||
pluginInfo.value = await getPluginListApi();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPluginData = async (showLoading = false) => {
|
||||
if (showLoading) {
|
||||
pageLoading.value = true;
|
||||
}
|
||||
try {
|
||||
await Promise.all([fetchPluginChanged(), fetchPlugin()]);
|
||||
localPluginChanged.value = false;
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fileList = ref<any[]>([]);
|
||||
type PluginEnableValue = '0' | '1';
|
||||
type PluginSwitchValue = boolean | PluginEnableValue;
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'mt-8',
|
||||
schema: userSchema(fileList),
|
||||
});
|
||||
|
||||
function downloadConfirm(plugin: string) {
|
||||
confirm({
|
||||
icon: 'success',
|
||||
content: '确认打包并下载此插件吗?',
|
||||
}).then(async () => {
|
||||
downloadingPlugin.value = plugin;
|
||||
try {
|
||||
const res = await downloadPluginApi(plugin);
|
||||
downloadFileFromBlobPart({ fileName: `${plugin}.zip`, source: res });
|
||||
message.success('插件包已开始下载');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
downloadingPlugin.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteConfirm(plugin: string) {
|
||||
confirm({
|
||||
icon: 'warning',
|
||||
content: '确认删除此插件吗?',
|
||||
}).then(async () => {
|
||||
deletingPlugin.value = plugin;
|
||||
try {
|
||||
await uninstallPluginApi(plugin);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
await refreshPluginData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
deletingPlugin.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const editPluginStatus = async (
|
||||
info: PluginResult,
|
||||
checked: PluginSwitchValue,
|
||||
) => {
|
||||
const plugin = info.plugin.name;
|
||||
const nextEnableValue: PluginEnableValue =
|
||||
checked === true || checked === '1' ? '1' : '0';
|
||||
switchingPlugin.value = plugin;
|
||||
try {
|
||||
await updatePluginStatus(plugin);
|
||||
info.plugin.enable = nextEnableValue;
|
||||
localPluginChanged.value = true;
|
||||
await fetchPluginChanged();
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
switchingPlugin.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
function onPluginStatusChange(info: PluginResult, checked: PluginSwitchValue) {
|
||||
editPluginStatus(info, checked);
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
try {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
modalApi.lock();
|
||||
formApi.form.values.installType === 0
|
||||
? await installZipPluginApi(fileList.value[0])
|
||||
: await installGitPluginApi(formApi.form.values.repo_url);
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
fileList.value = [];
|
||||
await formApi.resetForm();
|
||||
await refreshPluginData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
refreshPluginData(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<div class="mb-4">
|
||||
<VbenButton @click="() => modalApi.open()">安装插件</VbenButton>
|
||||
</div>
|
||||
<Modal title="安装插件">
|
||||
<Form />
|
||||
</Modal>
|
||||
<a-spin class="block" :spinning="pageLoading">
|
||||
<a-alert
|
||||
v-if="showPluginChangedAlert"
|
||||
title="检测到插件状态存在变更,为了确保系统能够正常运行,请尽快联系系统管理员进行相关调整"
|
||||
type="warning"
|
||||
show-icon
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<div class="grid items-stretch gap-6 lg:grid-cols-4">
|
||||
<a-card
|
||||
v-for="info in pluginInfo"
|
||||
:key="info.plugin.name"
|
||||
class="flex h-full flex-col"
|
||||
:styles="{
|
||||
body: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
{{ info.plugin.summary }}
|
||||
<span class="ml-1 text-sm">
|
||||
<span class="text-gray-500">@{{ info.plugin.author }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<p class="mb-0 flex-1">{{ info.plugin.description }}</p>
|
||||
<template #extra>
|
||||
<a-switch
|
||||
:checked="info.plugin.enable"
|
||||
checked-value="1"
|
||||
un-checked-value="0"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
:loading="switchingPlugin === info.plugin.name"
|
||||
@change="onPluginStatusChange(info, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<p>
|
||||
<span
|
||||
class="icon-[mingcute--version-line] -mb-1 h-5 w-5"
|
||||
></span>
|
||||
{{ info.plugin.version }}
|
||||
</p>
|
||||
<a-button
|
||||
size="small"
|
||||
danger
|
||||
:loading="deletingPlugin === info.plugin.name"
|
||||
@click="deleteConfirm(info.plugin.name)"
|
||||
>
|
||||
卸载
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="downloadingPlugin === info.plugin.name"
|
||||
@click="downloadConfirm(info.plugin.name)"
|
||||
>
|
||||
打包
|
||||
</a-button>
|
||||
</template>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import type { SysDataScopeRulesResult } from '#/api';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
||||
|
||||
import { getSysDataScopeRulesApi } from '#/api';
|
||||
import { drawerDataRuleColumns } from '#/views/system/role/data';
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
destroyOnClose: true,
|
||||
header: false,
|
||||
footer: false,
|
||||
class: 'w-2/5',
|
||||
});
|
||||
|
||||
const dataScopeRuleOptions: VxeTableGridOptions<SysDataScopeRulesResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
height: 'auto',
|
||||
virtualYConfig: {
|
||||
enabled: true,
|
||||
gt: 0,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
columns: drawerDataRuleColumns,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
const res = await getSysDataScopeRulesApi(
|
||||
drawerApi.getData().clickedDataScopeRow.id,
|
||||
);
|
||||
return res.rules;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
gridOptions: dataScopeRuleOptions,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer title="数据权限规则详情">
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<a-alert class="mb-2" type="warning" show-icon>
|
||||
<template #title>
|
||||
此页面仅用于数据展示,如需操作,请前往 【系统管理】 -> 【数据权限】
|
||||
</template>
|
||||
</a-alert>
|
||||
</template>
|
||||
</Grid>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { SysRoleResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '角色名称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
// options: [
|
||||
// {
|
||||
// label: '已启用',
|
||||
// value: 1,
|
||||
// },
|
||||
// {
|
||||
// label: '已停用',
|
||||
// value: 0,
|
||||
// },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('common.form.status'),
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<SysRoleResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{ field: 'name', title: '角色名称' },
|
||||
{
|
||||
field: 'is_filter_scopes',
|
||||
title: '过滤数据权限',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { color: 'success', label: $t('common.enabled'), value: true },
|
||||
// { color: 'error', label: $t('common.disabled'), value: false },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_CHOOSE),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
},
|
||||
width: 100,
|
||||
},
|
||||
{ field: 'remark', title: $t('common.table.mark') },
|
||||
{
|
||||
field: 'created_time',
|
||||
title: $t('common.table.created_time'),
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'updated_time',
|
||||
title: $t('common.table.updated_time'),
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'perm',
|
||||
text: '权限设置',
|
||||
},
|
||||
'edit',
|
||||
{
|
||||
code: 'delete',
|
||||
disabled: (row: SysRoleResult) => {
|
||||
return row.id === 1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const schema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '角色名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: $t('common.enabled'), value: true },
|
||||
// { label: $t('common.disabled'), value: false },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_CHOOSE),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: true,
|
||||
fieldName: 'is_filter_scopes',
|
||||
label: '过滤数据权限',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: $t('common.enabled'), value: 1 },
|
||||
// { label: $t('common.disabled'), value: 0 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
},
|
||||
];
|
||||
|
||||
export const drawerQuerySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'title',
|
||||
label: '菜单标题',
|
||||
},
|
||||
];
|
||||
|
||||
export const drawerColumns: VxeGridProps['columns'] = [
|
||||
{
|
||||
type: 'checkbox',
|
||||
title: '标题',
|
||||
align: 'left',
|
||||
fixed: 'left',
|
||||
treeNode: true,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '类型',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
// options: [
|
||||
// { color: 'orange', label: '目录', value: 0 },
|
||||
// { color: 'default', label: '菜单', value: 1 },
|
||||
// { color: 'blue', label: '按钮', value: 2 },
|
||||
// { color: 'warning', label: '内嵌', value: 3 },
|
||||
// { color: 'success', label: '外链', value: 4 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_MENU_TYPE),
|
||||
},
|
||||
},
|
||||
{ field: 'perms', title: '权限标识' },
|
||||
{ field: 'remark', title: '备注' },
|
||||
];
|
||||
|
||||
export function drawerDataScopeColumns(
|
||||
onActionClick?: OnActionClickFn<SysRoleResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
type: 'checkbox',
|
||||
title: '范围名称',
|
||||
align: 'left',
|
||||
fixed: 'left',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
},
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'details',
|
||||
text: '规则详情',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const drawerDataRuleColumns: VxeGridProps['columns'] = [
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{ field: 'name', title: '规则名称' },
|
||||
];
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { CreateSysRoleParams, SysRoleResult } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { MaterialSymbolsAdd } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { traverseTreeValues } from '@vben/utils';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createSysRoleApi,
|
||||
deleteSysRoleApi,
|
||||
getSysRoleListApi,
|
||||
getSysRoleMenuApi,
|
||||
updateSysRoleApi,
|
||||
} from '#/api';
|
||||
|
||||
import { querySchema, schema, useColumns } from './data';
|
||||
import ExtraDrawer from './menu-perm.vue';
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<SysRoleResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getSysRoleListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<SysRoleResult>) {
|
||||
switch (code) {
|
||||
case 'delete': {
|
||||
deleteSysRoleApi([row.id]).then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
modalApi.setData(row).open();
|
||||
break;
|
||||
}
|
||||
case 'perm': {
|
||||
openDrawer(row.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema,
|
||||
});
|
||||
|
||||
interface formSysRoleParams extends CreateSysRoleParams {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
const formData = ref<formSysRoleParams>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['角色'])
|
||||
: $t('ui.actionTitle.create', ['角色']);
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
modalApi.lock();
|
||||
const data = await formApi.getValues<CreateSysRoleParams>();
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateSysRoleApi(formData.value?.id, data)
|
||||
: createSysRoleApi(data));
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
onRefresh();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<formSysRoleParams>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
formApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
connectedComponent: ExtraDrawer,
|
||||
});
|
||||
|
||||
const openDrawer = async (pk: number) => {
|
||||
try {
|
||||
const roleMenu = await getSysRoleMenuApi(pk);
|
||||
drawerApi
|
||||
.setData({
|
||||
pk,
|
||||
checkedRoleMenu: traverseTreeValues(roleMenu, (item: any) => item.id),
|
||||
})
|
||||
.open();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton @click="() => modalApi.setData(null).open()">
|
||||
<MaterialSymbolsAdd class="size-5" />
|
||||
新增角色
|
||||
</VbenButton>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal :title="modalTitle">
|
||||
<Form />
|
||||
</Modal>
|
||||
<Drawer />
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import type { OnActionClickParams } from '#/adapter/vxe-table';
|
||||
import type { SysDataScopeResult, SysMenuTreeResult } from '#/api';
|
||||
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getSysDataScopesApi,
|
||||
getSysMenuTreeApi,
|
||||
getSysRoleDataScopesApi,
|
||||
updateSysRoleDataScopesApi,
|
||||
updateSysRoleMenuApi,
|
||||
} from '#/api';
|
||||
|
||||
import {
|
||||
drawerColumns,
|
||||
drawerDataScopeColumns,
|
||||
drawerQuerySchema,
|
||||
} from './data';
|
||||
import ExtraDataRuleDrawer from './data-perm.vue';
|
||||
|
||||
const activeKey = ref('0');
|
||||
const tabItems = [
|
||||
{ key: '0', label: '菜单权限' },
|
||||
{ key: '1', label: '数据权限' },
|
||||
];
|
||||
const clickRow = ref<number>(0);
|
||||
const defaultCheckedRoleMenuKeys = ref<number[]>([]);
|
||||
const checkStrictly = ref<boolean>(true);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
destroyOnClose: true,
|
||||
header: false,
|
||||
class: 'w-1/2',
|
||||
onCancel() {
|
||||
drawerApi.close();
|
||||
},
|
||||
onConfirm() {
|
||||
if (activeKey.value === '0') {
|
||||
const indeterminateRows = gridApi.grid.getCheckboxIndeterminateRecords();
|
||||
const checkedRows = gridApi.grid.getCheckboxRecords(true);
|
||||
updateSysRoleMenuApi(clickRow.value, [
|
||||
...indeterminateRows.map((item: any) => item.id),
|
||||
...checkedRows.map((item: any) => item.id),
|
||||
]).then(() => {
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
drawerApi.close();
|
||||
});
|
||||
} else {
|
||||
const checkedRows = dataScopeGridApi.grid.getCheckboxRecords(true);
|
||||
updateSysRoleDataScopesApi(
|
||||
clickRow.value,
|
||||
checkedRows.map((item: any) => item.id),
|
||||
).then(() => {
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
drawerApi.close();
|
||||
});
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
clickRow.value = drawerApi.getData().pk;
|
||||
if (isOpen && activeKey.value === '0') {
|
||||
defaultCheckedRoleMenuKeys.value = drawerApi.getData().checkedRoleMenu;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 菜单权限
|
||||
*/
|
||||
const formOptions: VbenFormProps = {
|
||||
showCollapseButton: false,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
schema: drawerQuerySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<SysMenuTreeResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
zoom: true,
|
||||
},
|
||||
checkboxConfig: {
|
||||
labelField: 'title',
|
||||
highlight: true,
|
||||
checkRowKeys: [],
|
||||
showHeader: false,
|
||||
checkStrictly: true,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
treeConfig: {
|
||||
parentField: 'parent_id',
|
||||
expandAll: true,
|
||||
},
|
||||
columns: drawerColumns,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (_, formValues) => {
|
||||
return await getSysMenuTreeApi(formValues);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
const expandAll = () => {
|
||||
gridApi.grid?.setAllTreeExpand(true);
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
gridApi.grid?.setAllTreeExpand(false);
|
||||
};
|
||||
|
||||
watch(defaultCheckedRoleMenuKeys, async (newValue) => {
|
||||
await nextTick();
|
||||
gridApi.setGridOptions({
|
||||
checkboxConfig: {
|
||||
checkRowKeys: newValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
watch(checkStrictly, (newValue) => {
|
||||
gridApi.setGridOptions({
|
||||
checkboxConfig: {
|
||||
showHeader: !newValue,
|
||||
checkStrictly: newValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 数据权限
|
||||
*/
|
||||
const dataScopeGridOptions: VxeTableGridOptions<SysMenuTreeResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
height: 'auto',
|
||||
virtualYConfig: {
|
||||
enabled: true,
|
||||
gt: 0,
|
||||
},
|
||||
checkboxConfig: {
|
||||
labelField: 'name',
|
||||
highlight: true,
|
||||
checkRowKeys: [],
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
columns: drawerDataScopeColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
autoLoad: false,
|
||||
ajax: {
|
||||
query: async () => {
|
||||
const [data, checkedKeys] = await Promise.all([
|
||||
getSysDataScopesApi(),
|
||||
getSysRoleDataScopesApi(clickRow.value),
|
||||
]);
|
||||
dataScopeGridApi.setGridOptions({
|
||||
checkboxConfig: {
|
||||
checkRowKeys: checkedKeys,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [DataScopeGrid, dataScopeGridApi] = useVbenVxeGrid({
|
||||
gridOptions: dataScopeGridOptions,
|
||||
});
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<SysDataScopeResult>) {
|
||||
switch (code) {
|
||||
case 'details': {
|
||||
dataRuleDrawerApi
|
||||
.setData({
|
||||
clickedDataScopeRow: row,
|
||||
})
|
||||
.open();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeKey, async (newValue) => {
|
||||
if (newValue === '1') {
|
||||
await dataScopeGridApi.query();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 规则详情
|
||||
*/
|
||||
const [DataRuleDrawer, dataRuleDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: ExtraDataRuleDrawer,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Drawer>
|
||||
<a-tabs v-model:active-key="activeKey" type="card" :items="tabItems">
|
||||
<template #contentRender="{ item }">
|
||||
<template v-if="item.key === '0'">
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<a-radio-group
|
||||
v-model:value="checkStrictly"
|
||||
class="h-8"
|
||||
button-style="solid"
|
||||
>
|
||||
<a-radio-button :value="true">父子独立</a-radio-button>
|
||||
<a-radio-button :value="false">父子联动</a-radio-button>
|
||||
</a-radio-group>
|
||||
</template>
|
||||
<template #toolbar-tools>
|
||||
<a-button class="mr-2" type="primary" @click="expandAll">
|
||||
展开全部
|
||||
</a-button>
|
||||
<a-button type="primary" @click="collapseAll">折叠全部</a-button>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
<template v-else-if="item.key === '1'">
|
||||
<div class="h-[775px]">
|
||||
<DataScopeGrid />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-tabs>
|
||||
</Drawer>
|
||||
<DataRuleDrawer />
|
||||
</template>
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { SysRoleResult, SysUserResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSysDeptTreeApi, updateSysUserPermissionApi } from '#/api';
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'phone',
|
||||
label: '手机号',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
// options: [
|
||||
// {
|
||||
// label: '正常',
|
||||
// value: 1,
|
||||
// },
|
||||
// {
|
||||
// label: '禁用',
|
||||
// value: 0,
|
||||
// },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_STATUS),
|
||||
placeholder: $t('common.form.select'),
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('common.form.status'),
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<SysUserResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
const createPermissionSwitch = (
|
||||
type: string,
|
||||
props?: Record<string, any>,
|
||||
) => ({
|
||||
name: 'CellSwitch',
|
||||
attrs: {
|
||||
async beforeChange(_checked: any, row: SysUserResult) {
|
||||
await updateSysUserPermissionApi(row.id, type);
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
},
|
||||
},
|
||||
props,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
fixed: 'left',
|
||||
width: 50,
|
||||
},
|
||||
{ field: 'username', title: '用户名', fixed: 'left', width: 100 },
|
||||
{ field: 'nickname', title: '昵称', width: 100 },
|
||||
{
|
||||
field: 'avatar',
|
||||
title: '头像',
|
||||
width: 80,
|
||||
slots: { default: 'avatar' },
|
||||
},
|
||||
{
|
||||
field: 'dept',
|
||||
title: '部门',
|
||||
width: 120,
|
||||
slots: { default: 'dept' },
|
||||
},
|
||||
{
|
||||
field: 'roles',
|
||||
title: '角色',
|
||||
width: 200,
|
||||
showOverflow: 'ellipsis',
|
||||
slots: { default: 'roles' },
|
||||
},
|
||||
{
|
||||
field: 'phone',
|
||||
title: '手机号',
|
||||
width: 150,
|
||||
formatter({ cellValue }) {
|
||||
return cellValue || '暂无';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
title: '邮箱',
|
||||
width: 150,
|
||||
formatter({ cellValue }) {
|
||||
return cellValue || '暂无';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cellRender: createPermissionSwitch('status'),
|
||||
},
|
||||
{
|
||||
field: 'is_superuser',
|
||||
title: '超级管理员',
|
||||
width: 100,
|
||||
cellRender: createPermissionSwitch('superuser', {
|
||||
checkedValue: true,
|
||||
unCheckedValue: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
field: 'is_staff',
|
||||
title: '后台登录',
|
||||
width: 100,
|
||||
cellRender: createPermissionSwitch('staff', {
|
||||
checkedValue: true,
|
||||
unCheckedValue: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
field: 'is_multi_login',
|
||||
title: '多端登录',
|
||||
width: 100,
|
||||
cellRender: createPermissionSwitch('multi_login', {
|
||||
checkedValue: true,
|
||||
unCheckedValue: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
field: 'join_time',
|
||||
title: '注册时间',
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'last_login_time',
|
||||
title: '最后登录时间',
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'username',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
'edit',
|
||||
{
|
||||
code: 'delete',
|
||||
disabled: (row: SysUserResult) => {
|
||||
return row.username === 'admin';
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'more',
|
||||
items: [{ code: 'reset_password', text: '重置密码' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useEditSchema(roleSelectOptions: any): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'nickname',
|
||||
label: '昵称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'avatar',
|
||||
label: '头像地址',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'phone',
|
||||
label: '手机号码',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'email',
|
||||
label: '邮箱',
|
||||
},
|
||||
{
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
api: getSysDeptTreeApi,
|
||||
class: 'w-full',
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
},
|
||||
fieldName: 'dept_id',
|
||||
label: '所属部门',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
mode: 'multiple',
|
||||
options: roleSelectOptions,
|
||||
fieldNames: { label: 'name', value: 'id' },
|
||||
filterOption: (input: string, option: SysRoleResult) => {
|
||||
return (
|
||||
option.name?.toLowerCase()?.includes(input.toLowerCase()) ?? false
|
||||
);
|
||||
},
|
||||
},
|
||||
fieldName: 'roles',
|
||||
label: '角色',
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useAddSchema(roleSelectOptions: any): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'nickname',
|
||||
label: '昵称',
|
||||
},
|
||||
{
|
||||
component: 'InputPassword',
|
||||
fieldName: 'password',
|
||||
label: '密码',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'phone',
|
||||
label: '手机号码',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'email',
|
||||
label: '邮箱',
|
||||
},
|
||||
{
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
api: getSysDeptTreeApi,
|
||||
class: 'w-full',
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
},
|
||||
fieldName: 'dept_id',
|
||||
label: '所属部门',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
mode: 'multiple',
|
||||
options: roleSelectOptions,
|
||||
fieldNames: { label: 'name', value: 'id' },
|
||||
filterOption: (input: string, option: SysRoleResult) => {
|
||||
return (
|
||||
option.name?.toLowerCase()?.includes(input.toLowerCase()) ?? false
|
||||
);
|
||||
},
|
||||
},
|
||||
fieldName: 'roles',
|
||||
label: '角色',
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const resetPwdSchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'InputPassword',
|
||||
fieldName: 'password',
|
||||
label: '新密码',
|
||||
rules: z
|
||||
.string({ message: '请输入新密码' })
|
||||
.min(6, '密码长度不能少于 6 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符'),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,379 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type {
|
||||
SysAddUserParams,
|
||||
SysDeptTreeResult,
|
||||
SysResetPasswordParams,
|
||||
SysRoleResult,
|
||||
SysUpdateUserParams,
|
||||
SysUserResult,
|
||||
} from '#/api';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { ColPage, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { MaterialSymbolsAdd } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createSysUserApi,
|
||||
deleteSysUserApi,
|
||||
getAllSysRoleApi,
|
||||
getSysDeptTreeApi,
|
||||
getSysUserListApi,
|
||||
resetSysUserPasswordApi,
|
||||
updateSysUserApi,
|
||||
} from '#/api';
|
||||
import {
|
||||
querySchema,
|
||||
resetPwdSchema,
|
||||
useAddSchema,
|
||||
useColumns,
|
||||
useEditSchema,
|
||||
} from '#/views/system/user/data';
|
||||
|
||||
/**
|
||||
* 左侧
|
||||
*/
|
||||
const treeData = ref<SysDeptTreeResult[]>([]);
|
||||
const treeLoading = ref(false);
|
||||
|
||||
const fetchDeptTree = async (name: string | undefined) => {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
treeData.value = await getSysDeptTreeApi({ name });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchDeptValue = ref<string>();
|
||||
const searchDept = async (searchValue: string | undefined) => {
|
||||
await fetchDeptTree(searchValue);
|
||||
};
|
||||
|
||||
/**
|
||||
* 右侧
|
||||
*/
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
const gridOptions: VxeTableGridOptions<SysUserResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getSysUserListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
async function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<SysUserResult>) {
|
||||
switch (code) {
|
||||
case 'delete': {
|
||||
gridApi.setLoading(true);
|
||||
try {
|
||||
await deleteSysUserApi(row.id);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.username]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
} finally {
|
||||
gridApi.setLoading(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
editUser.value = row.id;
|
||||
editModalApi.setData(row).open();
|
||||
break;
|
||||
}
|
||||
case 'reset_password': {
|
||||
editUser.value = row.id;
|
||||
resetPwdModalApi.setData(null).open();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSysUserListByDept = (selectedKeys: (number | string)[]) => {
|
||||
try {
|
||||
gridApi.query({ dept: selectedKeys[0] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const roleSelectOptions = ref<SysRoleResult[]>([]);
|
||||
const fetchAllSysRole = async () => {
|
||||
try {
|
||||
roleSelectOptions.value = await getAllSysRoleApi();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const [EditForm, formApi] = useVbenForm({
|
||||
showDefaultActions: false,
|
||||
schema: useEditSchema(roleSelectOptions),
|
||||
});
|
||||
|
||||
const editUser = ref<number>(0);
|
||||
|
||||
const [editModal, editModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
editModalApi.lock();
|
||||
const data = await formApi.getValues<SysUpdateUserParams>();
|
||||
try {
|
||||
await updateSysUserApi(editUser.value, data);
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await editModalApi.close();
|
||||
onRefresh();
|
||||
} finally {
|
||||
editModalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = editModalApi.getData<any>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formApi.setValues({
|
||||
...data,
|
||||
roles: data.roles?.map((item: SysRoleResult) => item.id) || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [AddForm, addFormApi] = useVbenForm({
|
||||
showDefaultActions: false,
|
||||
schema: useAddSchema(roleSelectOptions),
|
||||
});
|
||||
|
||||
const [addModal, addModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await addFormApi.validate();
|
||||
if (valid) {
|
||||
addModalApi.lock();
|
||||
const data = await addFormApi.getValues<SysAddUserParams>();
|
||||
try {
|
||||
await createSysUserApi(data);
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await addModalApi.close();
|
||||
onRefresh();
|
||||
} finally {
|
||||
addModalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = addModalApi.getData();
|
||||
addFormApi.resetForm();
|
||||
if (data) {
|
||||
addFormApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [ResetPwdForm, resetPwdFormApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
showDefaultActions: false,
|
||||
schema: resetPwdSchema,
|
||||
});
|
||||
|
||||
const [resetPwdModal, resetPwdModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
centered: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await resetPwdFormApi.validate();
|
||||
if (valid) {
|
||||
resetPwdModalApi.lock();
|
||||
const data = await resetPwdFormApi.getValues<SysResetPasswordParams>();
|
||||
try {
|
||||
await resetSysUserPasswordApi(editUser.value, data);
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await resetPwdModalApi.close();
|
||||
} finally {
|
||||
resetPwdModalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = resetPwdModalApi.getData();
|
||||
resetPwdFormApi.resetForm();
|
||||
if (data) {
|
||||
resetPwdFormApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchDeptTree(undefined);
|
||||
fetchAllSysRole();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ColPage
|
||||
auto-content-height
|
||||
:resizable="false"
|
||||
:left-width="20"
|
||||
:right-width="80"
|
||||
>
|
||||
<template #left>
|
||||
<div class="h-full overflow-y-auto rounded-[var(--radius)] bg-card">
|
||||
<div class="mt-1 p-2">
|
||||
<a-input-search
|
||||
v-model:value="searchDeptValue"
|
||||
:loading="treeLoading"
|
||||
:placeholder="$t('common.search')"
|
||||
size="small"
|
||||
enter-button
|
||||
@search="searchDept"
|
||||
/>
|
||||
</div>
|
||||
<a-spin :spinning="treeLoading">
|
||||
<div class="-mt-3 p-3">
|
||||
<div class="mb-1">Xxx集团</div>
|
||||
<a-tree
|
||||
v-if="treeData.length > 0"
|
||||
:show-line="{ showLeafIcon: false }"
|
||||
:tree-data="treeData"
|
||||
:field-names="{ title: 'name', key: 'id' }"
|
||||
default-expand-all
|
||||
@select="fetchSysUserListByDept"
|
||||
>
|
||||
<template #titleRender="{ name }">
|
||||
<span v-if="searchDeptValue && name.includes(searchDeptValue)">
|
||||
{{ name.substring(0, name.indexOf(searchDeptValue)) }}
|
||||
<span style="color: #f50">{{ searchDeptValue }}</span>
|
||||
{{
|
||||
name.substring(
|
||||
name.indexOf(searchDeptValue) + searchDeptValue.length,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>{{ name }}</span>
|
||||
</template>
|
||||
</a-tree>
|
||||
<a-empty v-else description="暂无部门数据" />
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton @click="() => addModalApi.setData(null).open()">
|
||||
<MaterialSymbolsAdd class="size-5" />
|
||||
添加用户
|
||||
</VbenButton>
|
||||
</template>
|
||||
<template #avatar="{ row }">
|
||||
<a-avatar :src="row.avatar || preferences.app.defaultAvatar" />
|
||||
</template>
|
||||
<template #dept="{ row }">
|
||||
<span v-if="row.dept">
|
||||
<a-tag :key="row.dept.name" color="green">
|
||||
{{ row.dept.name }}
|
||||
</a-tag>
|
||||
</span>
|
||||
<span v-else>未绑定</span>
|
||||
</template>
|
||||
<template #roles="{ row }">
|
||||
<span v-if="row.roles.length === 1">
|
||||
<a-tag color="purple">
|
||||
{{ row.roles[0]?.name }}
|
||||
</a-tag>
|
||||
</span>
|
||||
<span v-else-if="row.roles.length > 1">
|
||||
<a-popover
|
||||
placement="topLeft"
|
||||
:styles="{ root: { maxWidth: '20%' } }"
|
||||
>
|
||||
<template #content>
|
||||
<a-tag v-for="role in row.roles" :key="role.name" color="purple">
|
||||
{{ role.name }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-tag v-for="role in row.roles" :key="role.name" color="purple">
|
||||
{{ role.name }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
</span>
|
||||
<span v-else>未绑定</span>
|
||||
</template>
|
||||
</Grid>
|
||||
<editModal title="修改用户">
|
||||
<EditForm />
|
||||
</editModal>
|
||||
<addModal title="添加用户">
|
||||
<AddForm />
|
||||
</addModal>
|
||||
<resetPwdModal title="重置密码">
|
||||
<ResetPwdForm />
|
||||
</resetPwdModal>
|
||||
</ColPage>
|
||||
</template>
|
||||
Reference in New Issue
Block a user