chore: vendor FBA UI 工程源码(fastapi-best-architecture-ui master,Epic #163 受控源码基础)

This commit is contained in:
2026-08-06 12:50:35 +08:00
parent 9728fa40fe
commit 88d84757ac
1166 changed files with 109805 additions and 0 deletions
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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',
},
];
@@ -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>
@@ -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',
},
];
@@ -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>