feat: 初步适配naive 未测试

pull/98/head^2
xingyu4j 2025-05-09 18:17:33 +08:00
parent d59c137036
commit 695524c37f
129 changed files with 18444 additions and 0 deletions

View File

@ -0,0 +1,173 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraApiAccessLogApi } from '#/api/infra/api-access-log';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
allowClear: true,
placeholder: '请选择用户类型',
},
},
{
fieldName: 'applicationName',
label: '应用名',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入应用名',
},
},
{
fieldName: 'beginTime',
label: '请求时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
{
fieldName: 'duration',
label: '执行时长',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入执行时长',
},
},
{
fieldName: 'resultCode',
label: '结果码',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入结果码',
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraApiAccessLogApi.ApiAccessLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 100,
},
{
field: 'userId',
title: '用户编号',
minWidth: 100,
},
{
field: 'userType',
title: '用户类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'applicationName',
title: '应用名',
minWidth: 150,
},
{
field: 'requestMethod',
title: '请求方法',
minWidth: 80,
},
{
field: 'requestUrl',
title: '请求地址',
minWidth: 300,
},
{
field: 'beginTime',
title: '请求时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'duration',
title: '执行时长',
minWidth: 120,
formatter: ({ row }) => `${row.duration} ms`,
},
{
field: 'resultCode',
title: '操作结果',
minWidth: 150,
formatter: ({ row }) => {
return row.resultCode === 0 ? '成功' : `失败(${row.resultMsg})`;
},
},
{
field: 'operateModule',
title: '操作模块',
minWidth: 150,
},
{
field: 'operateName',
title: '操作名',
minWidth: 220,
},
{
field: 'operateType',
title: '操作类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_OPERATE_TYPE },
},
},
{
field: 'operation',
title: '操作',
minWidth: 80,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'id',
nameTitle: 'API访问日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详情',
show: hasAccessByCodes(['infra:api-access-log:query']),
},
],
},
},
];
}

View File

@ -0,0 +1,110 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraApiAccessLogApi } from '#/api/infra/api-access-log';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
exportApiAccessLog,
getApiAccessLogPage,
} from '#/api/infra/api-access-log';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportApiAccessLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: 'API 访问日志.xls', source: data });
}
/** 查看 API 访问日志详情 */
function onDetail(row: InfraApiAccessLogApi.ApiAccessLog) {
detailModalApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraApiAccessLogApi.ApiAccessLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getApiAccessLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraApiAccessLogApi.ApiAccessLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="系统日志" url="https://doc.iocoder.cn/system-log/" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="API 访">
<template #toolbar-tools>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:api-access-log:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,106 @@
<script lang="ts" setup>
import type { InfraApiAccessLogApi } from '#/api/infra/api-access-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { NDescriptions, NDescriptionsItem } from 'naive-ui';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<InfraApiAccessLogApi.ApiAccessLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<InfraApiAccessLogApi.ApiAccessLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="API 访问日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<NDescriptions
bordered
:column="1"
size="middle"
class="mx-4"
:label-style="{ width: '110px' }"
>
<NDescriptionsItem label="日志编号">
{{ formData?.id }}
</NDescriptionsItem>
<NDescriptionsItem label="链路追踪">
{{ formData?.traceId }}
</NDescriptionsItem>
<NDescriptionsItem label="应用名">
{{ formData?.applicationName }}
</NDescriptionsItem>
<NDescriptionsItem label="用户信息">
{{ formData?.userId }}
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
</NDescriptionsItem>
<NDescriptionsItem label="用户IP">
{{ formData?.userIp }}
</NDescriptionsItem>
<NDescriptionsItem label="用户UA">
{{ formData?.userAgent }}
</NDescriptionsItem>
<NDescriptionsItem label="请求信息">
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
</NDescriptionsItem>
<NDescriptionsItem label="请求参数">
{{ formData?.requestParams }}
</NDescriptionsItem>
<NDescriptionsItem label="请求结果">
{{ formData?.responseBody }}
</NDescriptionsItem>
<NDescriptionsItem label="请求时间">
{{ formatDateTime(formData?.beginTime || '') }} ~
{{ formatDateTime(formData?.endTime || '') }}
</NDescriptionsItem>
<NDescriptionsItem label="请求耗时">
{{ formData?.duration }} ms
</NDescriptionsItem>
<NDescriptionsItem label="操作结果">
<div v-if="formData?.resultCode === 0"></div>
<div v-else-if="formData && formData?.resultCode > 0">
失败 | {{ formData?.resultCode }} | {{ formData?.resultMsg }}
</div>
</NDescriptionsItem>
<NDescriptionsItem label="操作模块">
{{ formData?.operateModule }}
</NDescriptionsItem>
<NDescriptionsItem label="操作名">
{{ formData?.operateName }}
</NDescriptionsItem>
<NDescriptionsItem label="操作类型">
<DictTag
:type="DICT_TYPE.INFRA_OPERATE_TYPE"
:value="formData?.operateType"
/>
</NDescriptionsItem>
</NDescriptions>
</Modal>
</template>

View File

@ -0,0 +1,175 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
import { useAccess } from '@vben/access';
import {
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
InfraApiErrorLogProcessStatusEnum,
} from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
allowClear: true,
placeholder: '请选择用户类型',
},
},
{
fieldName: 'applicationName',
label: '应用名',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入应用名',
},
},
{
fieldName: 'exceptionTime',
label: '异常时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
{
fieldName: 'processStatus',
label: '处理状态',
component: 'Select',
componentProps: {
options: getDictOptions(
DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS,
'number',
),
allowClear: true,
placeholder: '请选择处理状态',
},
defaultValue: InfraApiErrorLogProcessStatusEnum.INIT,
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraApiErrorLogApi.ApiErrorLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 100,
},
{
field: 'userId',
title: '用户编号',
minWidth: 100,
},
{
field: 'userType',
title: '用户类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'applicationName',
title: '应用名',
minWidth: 150,
},
{
field: 'requestMethod',
title: '请求方法',
minWidth: 80,
},
{
field: 'requestUrl',
title: '请求地址',
minWidth: 200,
},
{
field: 'exceptionTime',
title: '异常发生时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'exceptionName',
title: '异常名',
minWidth: 180,
},
{
field: 'processStatus',
title: '处理状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS },
},
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'id',
nameTitle: 'API错误日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详情',
show: hasAccessByCodes(['infra:api-error-log:query']),
},
{
code: 'done',
text: '已处理',
show: (row: InfraApiErrorLogApi.ApiErrorLog) => {
return (
row.processStatus === InfraApiErrorLogProcessStatusEnum.INIT &&
hasAccessByCodes(['infra:api-error-log:update-status'])
);
},
},
{
code: 'ignore',
text: '已忽略',
show: (row: InfraApiErrorLogApi.ApiErrorLog) => {
return (
row.processStatus === InfraApiErrorLogProcessStatusEnum.INIT &&
hasAccessByCodes(['infra:api-error-log:update-status'])
);
},
},
],
},
},
];
}

View File

@ -0,0 +1,133 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { Download } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
exportApiErrorLog,
getApiErrorLogPage,
updateApiErrorLogStatus,
} from '#/api/infra/api-error-log';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { InfraApiErrorLogProcessStatusEnum } from '#/utils';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportApiErrorLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: 'API 错误日志.xls', source: data });
}
/** 查看 API 错误日志详情 */
function onDetail(row: InfraApiErrorLogApi.ApiErrorLog) {
detailModalApi.setData(row).open();
}
/** 处理已处理 / 已忽略的操作 */
async function onProcess(id: number, processStatus: number) {
confirm({
content: `确认标记为${InfraApiErrorLogProcessStatusEnum.DONE ? '已处理' : '已忽略'}?`,
}).then(async () => {
await updateApiErrorLogStatus(id, processStatus);
//
message.success($t('ui.actionMessage.operationSuccess'));
onRefresh();
});
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraApiErrorLogApi.ApiErrorLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
case 'done': {
onProcess(row.id, InfraApiErrorLogProcessStatusEnum.DONE);
break;
}
case 'ignore': {
onProcess(row.id, InfraApiErrorLogProcessStatusEnum.IGNORE);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getApiErrorLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraApiErrorLogApi.ApiErrorLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="系统日志" url="https://doc.iocoder.cn/system-log/" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="API ">
<template #toolbar-tools>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:api-error-log:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,104 @@
<script lang="ts" setup>
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { NDescriptions, NDescriptionsItem, NInput } from 'naive-ui';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<InfraApiErrorLogApi.ApiErrorLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<InfraApiErrorLogApi.ApiErrorLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="API错误日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<NDescriptions
bordered
:column="1"
size="middle"
class="mx-4"
:label-style="{ width: '110px' }"
>
<NDescriptionsItem label="日志编号">
{{ formData?.id }}
</NDescriptionsItem>
<NDescriptionsItem label="链路追踪">
{{ formData?.traceId }}
</NDescriptionsItem>
<NDescriptionsItem label="应用名">
{{ formData?.applicationName }}
</NDescriptionsItem>
<NDescriptionsItem label="用户编号">
{{ formData?.userId }}
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
</NDescriptionsItem>
<NDescriptionsItem label="用户IP">
{{ formData?.userIp }}
</NDescriptionsItem>
<NDescriptionsItem label="用户UA">
{{ formData?.userAgent }}
</NDescriptionsItem>
<NDescriptionsItem label="请求信息">
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
</NDescriptionsItem>
<NDescriptionsItem label="请求参数">
{{ formData?.requestParams }}
</NDescriptionsItem>
<NDescriptionsItem label="异常时间">
{{ formatDateTime(formData?.exceptionTime || '') }}
</NDescriptionsItem>
<NDescriptionsItem label="异常名">
{{ formData?.exceptionName }}
</NDescriptionsItem>
<NDescriptionsItem v-if="formData?.exceptionStackTrace" label="异常堆栈">
<NInput
type="textarea"
:value="formData?.exceptionStackTrace"
:auto-size="{ maxRows: 20 }"
readonly
/>
</NDescriptionsItem>
<NDescriptionsItem label="处理状态">
<DictTag
:type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS"
:value="formData?.processStatus"
/>
</NDescriptionsItem>
<NDescriptionsItem v-if="formData?.processUserId" label="处理人">
{{ formData?.processUserId }}
</NDescriptionsItem>
<NDescriptionsItem v-if="formData?.processTime" label="处理时间">
{{ formatDateTime(formData?.processTime || '') }}
</NDescriptionsItem>
</NDescriptions>
</Modal>
</template>

View File

@ -0,0 +1,183 @@
<!-- eslint-disable no-useless-escape -->
<script setup lang="ts">
import { onMounted, ref, unref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { isString } from '@vben/utils';
import formCreate from '@form-create/ant-design-vue';
import FcDesigner from '@form-create/antd-designer';
import { useClipboard } from '@vueuse/core';
import hljs from 'highlight.js';
import xml from 'highlight.js/lib/languages/java';
import json from 'highlight.js/lib/languages/json';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useFormCreateDesigner } from '#/components/form-create';
import 'highlight.js/styles/github.css';
defineOptions({ name: 'InfraBuild' });
const [Modal, modalApi] = useVbenModal();
const designer = ref(); //
//
const designerConfig = ref({
switchType: [], // ,
autoActive: true, //
useTemplate: false, // vue2
formOptions: {
form: {
labelWidth: '100px', // label 100px
},
}, //
fieldReadonly: false, // field
hiddenDragMenu: false, //
hiddenDragBtn: false, //
hiddenMenu: [], //
hiddenItem: [], //
hiddenItemConfig: {}, //
disabledItemConfig: {}, //
showSaveBtn: false, //
showConfig: true, //
showBaseForm: true, //
showControl: true, //
showPropsForm: true, //
showEventForm: true, //
showValidateForm: true, //
showFormConfig: true, //
showInputData: true, //
showDevice: true, //
appendConfigData: [], // formData
});
const dialogVisible = ref(false); //
const dialogTitle = ref(''); //
const formType = ref(-1); // 0 - JSON1 - Options2 -
const formData = ref(''); //
useFormCreateDesigner(designer); //
/** 打开弹窗 */
const openModel = (title: string) => {
dialogVisible.value = true;
dialogTitle.value = title;
modalApi.open();
};
/** 生成 JSON */
const showJson = () => {
openModel('生成 JSON');
formType.value = 0;
formData.value = designer.value.getRule();
};
/** 生成 Options */
const showOption = () => {
openModel('生成 Options');
formType.value = 1;
formData.value = designer.value.getOption();
};
/** 生成组件 */
const showTemplate = () => {
openModel('生成组件');
formType.value = 2;
formData.value = makeTemplate();
};
const makeTemplate = () => {
const rule = designer.value.getRule();
const opt = designer.value.getOption();
return `<template>
<form-create
v-model:api="fApi"
:rule="rule"
:option="option"
@submit="onSubmit"
></form-create>
</template>
<script setup lang=ts>
const faps = ref(null)
const rule = ref('')
const option = ref('')
const init = () => {
rule.value = formCreate.parseJson('${formCreate.toJson(rule).replaceAll('\\', '\\\\')}')
option.value = formCreate.parseJson('${JSON.stringify(opt, null, 2)}')
}
const onSubmit = (formData) => {
//todo
}
init()
<\/script>`;
};
/** 复制 */
const copy = async (text: string) => {
const textToCopy = JSON.stringify(text, null, 2);
const { copy, copied, isSupported } = useClipboard({ source: textToCopy });
if (isSupported) {
await copy();
if (unref(copied)) {
message.success('复制成功');
}
} else {
message.error('复制失败');
}
};
/**
* 代码高亮
*/
const highlightedCode = (code: string) => {
//
let language = 'json';
if (formType.value === 2) {
language = 'xml';
}
// debugger
if (!isString(code)) {
code = JSON.stringify(code, null, 2);
}
//
const result = hljs.highlight(code, { language, ignoreIllegals: true });
return result.value || '&nbsp;';
};
/** 初始化 */
onMounted(async () => {
//
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('json', json);
});
</script>
<template>
<Page auto-content-height>
<FcDesigner ref="designer" height="90vh" :config="designerConfig">
<template #handle>
<NButton size="small" type="primary" ghost @click="showJson">
生成JSON
</NButton>
<NButton size="small" type="primary" ghost @click="showOption">
生成Options
</NButton>
<NButton size="small" type="primary" ghost @click="showTemplate">
生成组件
</NButton>
</template>
</FcDesigner>
<!-- 弹窗表单预览 -->
<Modal :title="dialogTitle" :footer="false" :fullscreen-button="false">
<div>
<NButton style="float: right" @click="copy(formData)"> </NButton>
<div>
<pre><code v-dompurify-html="highlightedCode(formData)" class="hljs"></code></pre>
</div>
</div>
</Modal>
</Page>
</template>

View File

@ -0,0 +1,591 @@
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraCodegenApi } from '#/api/infra/codegen';
import type { SystemMenuApi } from '#/api/system/menu';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { IconifyIcon } from '@vben/icons';
import { handleTree } from '@vben/utils';
import { getDataSourceConfigList } from '#/api/infra/data-source-config';
import { getMenuList } from '#/api/system/menu';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 导入数据库表的表单 */
export function useImportTableFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'dataSourceConfigId',
label: '数据源',
component: 'ApiSelect',
componentProps: {
api: async () => {
const data = await getDataSourceConfigList();
return data.map((item) => ({
label: item.name,
value: item.id,
}));
},
autoSelect: 'first',
placeholder: '请选择数据源',
},
rules: 'selectRequired',
},
{
fieldName: 'name',
label: '表名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入表名称',
},
},
{
fieldName: 'comment',
label: '表描述',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入表描述',
},
},
];
}
/** 导入数据库表表格列定义 */
export function useImportTableColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{ field: 'name', title: '表名称', minWidth: 200 },
{ field: 'comment', title: '表描述', minWidth: 200 },
];
}
/** 基本信息表单的 schema */
export function useBasicInfoFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'tableName',
label: '表名称',
component: 'Input',
componentProps: {
placeholder: '请输入仓库名称',
},
rules: 'required',
},
{
fieldName: 'tableComment',
label: '表描述',
component: 'Input',
componentProps: {
placeholder: '请输入表描述',
},
rules: 'required',
},
{
fieldName: 'className',
label: '实体类名称',
component: 'Input',
componentProps: {
placeholder: '请输入实体类名称',
},
rules: 'required',
help: '默认去除表名的前缀。如果存在重复,则需要手动添加前缀,避免 MyBatis 报 Alias 重复的问题。',
},
{
fieldName: 'author',
label: '作者',
component: 'Input',
componentProps: {
placeholder: '请输入作者',
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
rows: 3,
placeholder: '请输入备注',
},
formItemClass: 'md:col-span-2',
},
];
}
/** 生成信息表单基础 schema */
export function useGenerationInfoBaseFormSchema(): VbenFormSchema[] {
return [
{
component: 'Select',
fieldName: 'templateType',
label: '生成模板',
componentProps: {
options: getDictOptions(
DICT_TYPE.INFRA_CODEGEN_TEMPLATE_TYPE,
'number',
),
class: 'w-full',
},
rules: 'selectRequired',
},
{
component: 'Select',
fieldName: 'frontType',
label: '前端类型',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_CODEGEN_FRONT_TYPE, 'number'),
class: 'w-full',
},
rules: 'selectRequired',
},
{
component: 'Select',
fieldName: 'scene',
label: '生成场景',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_CODEGEN_SCENE, 'number'),
class: 'w-full',
},
rules: 'selectRequired',
},
{
fieldName: 'parentMenuId',
label: '上级菜单',
help: '分配到指定菜单下,例如 系统管理',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getMenuList();
data.unshift({
id: 0,
name: '顶级菜单',
} as SystemMenuApi.Menu);
return handleTree(data);
},
class: 'w-full',
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级菜单',
filterTreeNode(input: string, node: Recordable<any>) {
if (!input || input.length === 0) {
return true;
}
const name: string = node.label ?? '';
if (!name) return false;
return name.includes(input) || $t(name).includes(input);
},
showSearch: true,
treeDefaultExpandedKeys: [0],
},
rules: 'selectRequired',
renderComponentContent() {
return {
title({ label, icon }: { icon: string; label: string }) {
const components = [];
if (!label) return '';
if (icon) {
components.push(h(IconifyIcon, { class: 'size-4', icon }));
}
components.push(h('span', { class: '' }, $t(label || '')));
return h('div', { class: 'flex items-center gap-1' }, components);
},
};
},
},
{
component: 'Input',
fieldName: 'moduleName',
label: '模块名',
help: '模块名,即一级目录,例如 system、infra、tool 等等',
rules: 'required',
},
{
component: 'Input',
fieldName: 'businessName',
label: '业务名',
help: '业务名,即二级目录,例如 user、permission、dict 等等',
rules: 'required',
},
{
component: 'Input',
fieldName: 'className',
label: '类名称',
help: '类名称首字母大写例如SysUser、SysMenu、SysDictData 等等',
rules: 'required',
},
{
component: 'Input',
fieldName: 'classComment',
label: '类描述',
help: '用作类描述,例如 用户',
rules: 'required',
},
];
}
/** 树表信息 schema */
export function useGenerationInfoTreeFormSchema(
columns: InfraCodegenApi.CodegenColumn[] = [],
): VbenFormSchema[] {
return [
{
component: 'Divider',
fieldName: 'treeDivider',
label: '',
renderComponentContent: () => {
return {
default: () => ['树表信息'],
};
},
formItemClass: 'md:col-span-2',
},
{
component: 'Select',
fieldName: 'treeParentColumnId',
label: '父编号字段',
help: '树显示的父编码字段名,例如 parent_Id',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择',
options: columns.map((column) => ({
label: column.columnName,
value: column.id,
})),
},
rules: 'selectRequired',
},
{
component: 'Select',
fieldName: 'treeNameColumnId',
label: '名称字段',
help: '树节点显示的名称字段,一般是 name',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择名称字段',
options: columns.map((column) => ({
label: column.columnName,
value: column.id,
})),
},
rules: 'selectRequired',
},
];
}
/** 主子表信息 schema */
export function useGenerationInfoSubTableFormSchema(
columns: InfraCodegenApi.CodegenColumn[] = [],
tables: InfraCodegenApi.CodegenTable[] = [],
): VbenFormSchema[] {
return [
{
component: 'Divider',
fieldName: 'subDivider',
label: '',
renderComponentContent: () => {
return {
default: () => ['主子表信息'],
};
},
formItemClass: 'md:col-span-2',
},
{
component: 'Select',
fieldName: 'masterTableId',
label: '关联的主表',
help: '关联主表(父表)的表名, 如system_user',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择',
options: tables.map((table) => ({
label: `${table.tableName}${table.tableComment}`,
value: table.id,
})),
},
rules: 'selectRequired',
},
{
component: 'Select',
fieldName: 'subJoinColumnId',
label: '子表关联的字段',
help: '子表关联的字段, 如user_id',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择',
options: columns.map((column) => ({
label: `${column.columnName}:${column.columnComment}`,
value: column.id,
})),
},
rules: 'selectRequired',
},
{
component: 'RadioGroup',
fieldName: 'subJoinMany',
label: '关联关系',
help: '主表与子表的关联关系',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择',
options: [
{
label: '一对多',
value: true,
},
{
label: '一对一',
value: 'false',
},
],
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'tableName',
label: '表名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入表名称',
},
},
{
fieldName: 'tableComment',
label: '表描述',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入表描述',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraCodegenApi.CodegenTable>(
onActionClick: OnActionClickFn<T>,
getDataSourceConfigName?: (dataSourceConfigId: number) => string | undefined,
): VxeTableGridOptions['columns'] {
return [
{
field: 'dataSourceConfigId',
title: '数据源',
minWidth: 120,
formatter: (row) => getDataSourceConfigName?.(row.cellValue) || '-',
},
{
field: 'tableName',
title: '表名称',
minWidth: 200,
},
{
field: 'tableComment',
title: '表描述',
minWidth: 200,
},
{
field: 'className',
title: '实体',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 300,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'tableName',
nameTitle: '代码生成',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'preview',
text: '预览',
show: hasAccessByCodes(['infra:codegen:preview']),
},
{
code: 'edit',
show: hasAccessByCodes(['infra:codegen:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:codegen:delete']),
},
{
code: 'sync',
text: '同步',
show: hasAccessByCodes(['infra:codegen:update']),
},
{
code: 'generate',
text: '生成代码',
show: hasAccessByCodes(['infra:codegen:download']),
},
],
},
},
];
}
/** 代码生成表格列定义 */
export function useCodegenColumnTableColumns(): VxeTableGridOptions['columns'] {
return [
{ field: 'columnName', title: '字段列名', minWidth: 130 },
{
field: 'columnComment',
title: '字段描述',
minWidth: 100,
slots: { default: 'columnComment' },
},
{ field: 'dataType', title: '物理类型', minWidth: 100 },
{
field: 'javaType',
title: 'Java 类型',
minWidth: 130,
slots: { default: 'javaType' },
params: {
options: [
{ label: 'Long', value: 'Long' },
{ label: 'String', value: 'String' },
{ label: 'Integer', value: 'Integer' },
{ label: 'Double', value: 'Double' },
{ label: 'BigDecimal', value: 'BigDecimal' },
{ label: 'LocalDateTime', value: 'LocalDateTime' },
{ label: 'Boolean', value: 'Boolean' },
],
},
},
{
field: 'javaField',
title: 'Java 属性',
minWidth: 100,
slots: { default: 'javaField' },
},
{
field: 'createOperation',
title: '插入',
width: 40,
slots: { default: 'createOperation' },
},
{
field: 'updateOperation',
title: '编辑',
width: 40,
slots: { default: 'updateOperation' },
},
{
field: 'listOperationResult',
title: '列表',
width: 40,
slots: { default: 'listOperationResult' },
},
{
field: 'listOperation',
title: '查询',
width: 40,
slots: { default: 'listOperation' },
},
{
field: 'listOperationCondition',
title: '查询方式',
minWidth: 100,
slots: { default: 'listOperationCondition' },
params: {
options: [
{ label: '=', value: '=' },
{ label: '!=', value: '!=' },
{ label: '>', value: '>' },
{ label: '>=', value: '>=' },
{ label: '<', value: '<' },
{ label: '<=', value: '<=' },
{ label: 'LIKE', value: 'LIKE' },
{ label: 'BETWEEN', value: 'BETWEEN' },
],
},
},
{
field: 'nullable',
title: '允许空',
width: 60,
slots: { default: 'nullable' },
},
{
field: 'htmlType',
title: '显示类型',
width: 130,
slots: { default: 'htmlType' },
params: {
options: [
{ label: '文本框', value: 'input' },
{ label: '文本域', value: 'textarea' },
{ label: '下拉框', value: 'select' },
{ label: '单选框', value: 'radio' },
{ label: '复选框', value: 'checkbox' },
{ label: '日期控件', value: 'datetime' },
{ label: '图片上传', value: 'imageUpload' },
{ label: '文件上传', value: 'fileUpload' },
{ label: '富文本控件', value: 'editor' },
],
},
},
{
field: 'dictType',
title: '字典类型',
width: 120,
slots: { default: 'dictType' },
},
{
field: 'example',
title: '示例',
minWidth: 100,
slots: { default: 'example' },
},
];
}

View File

@ -0,0 +1,170 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { ref, unref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { NButton, NSteps } from 'naive-ui';
import { message } from '#/adapter/naive';
import { getCodegenTable, updateCodegenTable } from '#/api/infra/codegen';
import { $t } from '#/locales';
import BasicInfo from '../modules/basic-info.vue';
import ColumnInfo from '../modules/column-info.vue';
import GenerationInfo from '../modules/generation-info.vue';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const currentStep = ref(0);
const formData = ref<InfraCodegenApi.CodegenDetail>({
table: {} as InfraCodegenApi.CodegenTable,
columns: [],
});
/** 表单引用 */
const basicInfoRef = ref<InstanceType<typeof BasicInfo>>();
const columnInfoRef = ref<InstanceType<typeof ColumnInfo>>();
const generateInfoRef = ref<InstanceType<typeof GenerationInfo>>();
/** 获取详情数据 */
const getDetail = async () => {
const id = route.query.id as any;
if (!id) {
return;
}
loading.value = true;
try {
formData.value = await getCodegenTable(id);
} finally {
loading.value = false;
}
};
/** 提交表单 */
const submitForm = async () => {
//
const basicInfoValid = await basicInfoRef.value?.validate();
if (!basicInfoValid) {
message.warn('保存失败,原因:基本信息表单校验失败请检查!!!');
return;
}
const generateInfoValid = await generateInfoRef.value?.validate();
if (!generateInfoValid) {
message.warn('保存失败,原因:生成信息表单校验失败请检查!!!');
return;
}
//
const hideLoading = message.loading({
content: $t('ui.actionMessage.updating'),
duration: 0,
key: 'action_process_msg',
});
try {
//
const basicInfo = await basicInfoRef.value?.getValues();
const columns = columnInfoRef.value?.getData() || unref(formData).columns;
const generateInfo = await generateInfoRef.value?.getValues();
await updateCodegenTable({
table: { ...unref(formData).table, ...basicInfo, ...generateInfo },
columns,
});
//
message.success($t('ui.actionMessage.operationSuccess'));
close();
} catch (error) {
console.error('保存失败', error);
} finally {
hideLoading();
}
};
const tabs = useTabs();
/** 返回列表 */
const close = () => {
tabs.closeCurrentTab();
router.push('/infra/codegen');
};
/** 下一步 */
const nextStep = async () => {
currentStep.value += 1;
};
/** 上一步 */
const prevStep = () => {
if (currentStep.value > 0) {
currentStep.value -= 1;
}
};
/** 步骤配置 */
const steps = [
{
title: '基本信息',
},
{
title: '字段信息',
},
{
title: '生成信息',
},
];
//
getDetail();
</script>
<template>
<Page auto-content-height v-loading="loading">
<div
class="flex h-[95%] flex-col rounded-md bg-white p-4 dark:bg-[#1f1f1f] dark:text-gray-300"
>
<NSteps
type="navigation"
v-model:current="currentStep"
class="mb-8 rounded shadow-sm dark:bg-[#141414]"
>
<Steps.Step
v-for="(step, index) in steps"
:key="index"
:title="step.title"
/>
</NSteps>
<div class="flex-1 overflow-auto py-4">
<!-- 根据当前步骤显示对应的组件 -->
<BasicInfo
v-show="currentStep === 0"
ref="basicInfoRef"
:table="formData.table"
/>
<ColumnInfo
v-show="currentStep === 1"
ref="columnInfoRef"
:columns="formData.columns"
/>
<GenerationInfo
v-show="currentStep === 2"
ref="generateInfoRef"
:table="formData.table"
:columns="formData.columns"
/>
</div>
<div class="mt-4 flex justify-end space-x-2">
<NButton v-show="currentStep > 0" @click="prevStep"></NButton>
<NButton v-show="currentStep < steps.length - 1" @click="nextStep">
下一步
</NButton>
<NButton type="primary" :loading="loading" @click="submitForm">
保存
</NButton>
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,232 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraCodegenApi } from '#/api/infra/codegen';
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCodegenTable,
downloadCodegen,
getCodegenTablePage,
syncCodegenFromDB,
} from '#/api/infra/codegen';
import { getDataSourceConfigList } from '#/api/infra/data-source-config';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import ImportTable from './modules/import-table.vue';
import PreviewCode from './modules/preview-code.vue';
const router = useRouter();
const dataSourceConfigList = ref<InfraDataSourceConfigApi.DataSourceConfig[]>(
[],
);
/** 获取数据源名称 */
const getDataSourceConfigName = (dataSourceConfigId: number) => {
return dataSourceConfigList.value.find(
(item) => item.id === dataSourceConfigId,
)?.name;
};
const [ImportModal, importModalApi] = useVbenModal({
connectedComponent: ImportTable,
destroyOnClose: true,
});
const [PreviewModal, previewModalApi] = useVbenModal({
connectedComponent: PreviewCode,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导入表格 */
function onImport() {
importModalApi.open();
}
/** 预览代码 */
function onPreview(row: InfraCodegenApi.CodegenTable) {
previewModalApi.setData(row).open();
}
/** 编辑表格 */
function onEdit(row: InfraCodegenApi.CodegenTable) {
router.push({ name: 'InfraCodegenEdit', query: { id: row.id } });
}
/** 删除代码生成配置 */
async function onDelete(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.tableName]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteCodegenTable(row.id);
message.success($t('ui.actionMessage.deleteSuccess', [row.tableName]));
onRefresh();
} finally {
hideLoading();
}
}
/** 同步数据库 */
async function onSync(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.updating', [row.tableName]),
duration: 0,
key: 'action_process_msg',
});
try {
await syncCodegenFromDB(row.id);
message.success($t('ui.actionMessage.updateSuccess', [row.tableName]));
onRefresh();
} finally {
hideLoading();
}
}
/** 生成代码 */
async function onGenerate(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: '正在生成代码...',
duration: 0,
key: 'action_process_msg',
});
try {
const res = await downloadCodegen(row.id);
const blob = new Blob([res], { type: 'application/zip' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `codegen-${row.className}.zip`;
link.click();
window.URL.revokeObjectURL(url);
message.success('代码生成成功');
} finally {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraCodegenApi.CodegenTable>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
case 'generate': {
onGenerate(row);
break;
}
case 'preview': {
onPreview(row);
break;
}
case 'sync': {
onSync(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick, getDataSourceConfigName),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCodegenTablePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraCodegenApi.CodegenTable>,
});
/** 获取数据源配置列表 */
async function initDataSourceConfig() {
try {
dataSourceConfigList.value = await getDataSourceConfigList();
} catch (error) {
console.error('获取数据源配置失败', error);
}
}
/** 初始化 */
initDataSourceConfig();
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="代码生成(单表)"
url="https://doc.iocoder.cn/new-feature/"
/>
<DocAlert
title="代码生成(树表)"
url="https://doc.iocoder.cn/new-feature/tree/"
/>
<DocAlert
title="代码生成(主子表)"
url="https://doc.iocoder.cn/new-feature/master-sub/"
/>
<DocAlert title="单元测试" url="https://doc.iocoder.cn/unit-test/" />
</template>
<ImportModal @success="onRefresh" />
<PreviewModal />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onImport"
v-access:code="['infra:codegen:create']"
>
<Plus class="size-5" />
导入
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,45 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { watch } from 'vue';
import { useVbenForm } from '#/adapter/form';
import { useBasicInfoFormSchema } from '../data';
const props = defineProps<{
table: InfraCodegenApi.CodegenTable;
}>();
/** 表单实例 */
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-4', //
schema: useBasicInfoFormSchema(),
layout: 'horizontal',
showDefaultActions: false,
});
/** 动态更新表单值 */
watch(
() => props.table,
(val: any) => {
if (!val) {
return;
}
formApi.setValues(val);
},
{ immediate: true },
);
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => {
const { valid } = await formApi.validate();
return valid;
},
getValues: formApi.getValues,
});
</script>
<template>
<Form />
</template>

View File

@ -0,0 +1,161 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import type { SystemDictTypeApi } from '#/api/system/dict/type';
import { nextTick, onMounted, ref, watch } from 'vue';
import { NCheckbox, NInput, NSelect } from 'naive-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSimpleDictTypeList } from '#/api/system/dict/type';
import { useCodegenColumnTableColumns } from '../data';
const props = defineProps<{
columns?: InfraCodegenApi.CodegenColumn[];
}>();
/** 表格配置 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useCodegenColumnTableColumns(),
border: true,
showOverflow: true,
autoResize: true,
keepSource: true,
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
/** 监听外部传入的列数据 */
watch(
() => props.columns,
async (columns) => {
if (!columns) {
return;
}
await nextTick();
gridApi.grid?.loadData(columns);
},
{
immediate: true,
},
);
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): InfraCodegenApi.CodegenColumn[] => gridApi.grid.getData(),
});
/** 初始化 */
const dictTypeOptions = ref<SystemDictTypeApi.DictType[]>([]); //
onMounted(async () => {
dictTypeOptions.value = await getSimpleDictTypeList();
});
</script>
<template>
<Grid>
<!-- 字段描述 -->
<template #columnComment="{ row }">
<NInput v-model:value="row.columnComment" />
</template>
<!-- Java 类型 -->
<template #javaType="{ row, column }">
<NSelect v-model:value="row.javaType" style="width: 100%">
<NSelect.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</NSelect.Option>
</NSelect>
</template>
<!-- Java 属性 -->
<template #javaField="{ row }">
<NInput v-model:value="row.javaField" />
</template>
<!-- 插入 -->
<template #createOperation="{ row }">
<NCheckbox v-model:checked="row.createOperation" />
</template>
<!-- 编辑 -->
<template #updateOperation="{ row }">
<NCheckbox v-model:checked="row.updateOperation" />
</template>
<!-- 列表 -->
<template #listOperationResult="{ row }">
<NCheckbox v-model:checked="row.listOperationResult" />
</template>
<!-- 查询 -->
<template #listOperation="{ row }">
<NCheckbox v-model:checked="row.listOperation" />
</template>
<!-- 查询方式 -->
<template #listOperationCondition="{ row, column }">
<NSelect v-model:value="row.listOperationCondition" class="w-full">
<NSelect.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</NSelect.Option>
</NSelect>
</template>
<!-- 允许空 -->
<template #nullable="{ row }">
<NCheckbox v-model:checked="row.nullable" />
</template>
<!-- 显示类型 -->
<template #htmlType="{ row, column }">
<NSelect v-model:value="row.htmlType" class="w-full">
<NSelect.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</NSelect.Option>
</NSelect>
</template>
<!-- 字典类型 -->
<template #dictType="{ row }">
<NSelect
v-model:value="row.dictType"
class="w-full"
allow-clear
show-search
>
>
<NSelect.Option
v-for="option in dictTypeOptions"
:key="option.type"
:value="option.type"
>
{{ option.name }}
</NSelect.Option>
</NSelect>
</template>
<!-- 示例 -->
<template #example="{ row }">
<NInput v-model:value="row.example" />
</template>
</Grid>
</template>

View File

@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { computed, ref, watch } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { getCodegenTableList } from '#/api/infra/codegen';
import { InfraCodegenTemplateTypeEnum } from '#/utils';
import {
useGenerationInfoBaseFormSchema,
useGenerationInfoSubTableFormSchema,
useGenerationInfoTreeFormSchema,
} from '../data';
const props = defineProps<{
columns?: InfraCodegenApi.CodegenColumn[];
table?: InfraCodegenApi.CodegenTable;
}>();
const tables = ref<InfraCodegenApi.CodegenTable[]>([]);
/** 计算当前模板类型 */
const currentTemplateType = ref<number>();
const isTreeTable = computed(
() => currentTemplateType.value === InfraCodegenTemplateTypeEnum.TREE,
);
const isSubTable = computed(
() => currentTemplateType.value === InfraCodegenTemplateTypeEnum.SUB,
);
/** 基础表单实例 */
const [BaseForm, baseFormApi] = useVbenForm({
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-4', //
layout: 'horizontal',
showDefaultActions: false,
schema: useGenerationInfoBaseFormSchema(),
handleValuesChange: (values) => {
//
if (
values.templateType !== undefined &&
values.templateType !== currentTemplateType.value
) {
currentTemplateType.value = values.templateType;
}
},
});
/** 树表信息表单实例 */
const [TreeForm, treeFormApi] = useVbenForm({
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-4', //
layout: 'horizontal',
showDefaultActions: false,
schema: [],
});
/** 主子表信息表单实例 */
const [SubForm, subFormApi] = useVbenForm({
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-4', //
layout: 'horizontal',
showDefaultActions: false,
schema: [],
});
/** 更新树表信息表单 schema */
function updateTreeSchema(): void {
treeFormApi.setState({
schema: useGenerationInfoTreeFormSchema(props.columns),
});
}
/** 更新主子表信息表单 schema */
function updateSubSchema(): void {
subFormApi.setState({
schema: useGenerationInfoSubTableFormSchema(props.columns, tables.value),
});
}
/** 获取合并的表单值 */
async function getAllFormValues(): Promise<Record<string, any>> {
//
const baseValues = await baseFormApi.getValues();
//
let extraValues = {};
if (isTreeTable.value) {
extraValues = await treeFormApi.getValues();
} else if (isSubTable.value) {
extraValues = await subFormApi.getValues();
}
//
return { ...baseValues, ...extraValues };
}
/** 验证所有表单 */
async function validateAllForms() {
//
const { valid: baseFormValid } = await baseFormApi.validate();
//
let extraValid = true;
if (isTreeTable.value) {
const { valid: treeFormValid } = await treeFormApi.validate();
extraValid = treeFormValid;
} else if (isSubTable.value) {
const { valid: subFormValid } = await subFormApi.validate();
extraValid = subFormValid;
}
return baseFormValid && extraValid;
}
/** 设置表单值 */
function setAllFormValues(values: Record<string, any>): void {
if (!values) {
return;
}
//
currentTemplateType.value = values.templateType;
//
baseFormApi.setValues(values);
//
if (isTreeTable.value) {
treeFormApi.setValues(values);
} else if (isSubTable.value) {
subFormApi.setValues(values);
}
}
/** 监听表格数据变化 */
watch(
() => props.table,
async (val) => {
if (!val || isEmpty(val)) {
return;
}
const table = val as InfraCodegenApi.CodegenTable;
// schema
updateTreeSchema();
//
setAllFormValues(table);
//
const dataSourceConfigId = table.dataSourceConfigId;
if (dataSourceConfigId === undefined) {
return;
}
tables.value = await getCodegenTableList(dataSourceConfigId);
// schema
updateSubSchema();
},
{ immediate: true },
);
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: validateAllForms,
getValues: getAllFormValues,
});
</script>
<template>
<div>
<!-- 基础表单 -->
<BaseForm />
<!-- 树表信息表单 -->
<TreeForm v-if="isTreeTable" />
<!-- 主子表信息表单 -->
<SubForm v-if="isSubTable" />
</div>
</template>

View File

@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { createCodegenList, getSchemaTableList } from '#/api/infra/codegen';
import { $t } from '#/locales';
import {
useImportTableColumns,
useImportTableFormSchema,
} from '#/views/infra/codegen/data';
/** 定义组件事件 */
const emit = defineEmits<{
(e: 'success'): void;
}>();
const formData = reactive<InfraCodegenApi.CodegenCreateListReqVO>({
dataSourceConfigId: 0,
tableNames: [], //
});
/** 表格实例 */
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useImportTableFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useImportTableColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (formValues.dataSourceConfigId === undefined) {
return [];
}
formData.dataSourceConfigId = formValues.dataSourceConfigId;
return await getSchemaTableList({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'name',
},
toolbarConfig: {
enabled: false,
},
checkboxConfig: {
highlight: true,
range: true,
},
pagerConfig: {
enabled: false,
},
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
gridEvents: {
checkboxChange: ({
records,
}: {
records: InfraCodegenApi.DatabaseTable[];
}) => {
formData.tableNames = records.map((item) => item.name);
},
},
});
/** 模态框实例 */
const [Modal, modalApi] = useVbenModal({
title: '导入表',
class: 'w-1/2',
async onConfirm() {
modalApi.lock();
// 1.1
if (formData?.dataSourceConfigId === undefined) {
message.error('请选择数据源');
return;
}
// 1.2
if (formData.tableNames.length === 0) {
message.error('请选择需要导入的表');
return;
}
// 2.
const hideLoading = message.loading({
content: '导入中...',
duration: 0,
key: 'import_loading',
});
try {
await createCodegenList(formData);
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
hideLoading();
modalApi.unlock();
}
},
});
</script>
<template>
<Modal>
<Grid />
</Modal>
</template>

View File

@ -0,0 +1,372 @@
<script lang="ts" setup>
// TODO @vben2.0 CodeEditor
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Copy } from '@vben/icons';
import { useClipboard } from '@vueuse/core';
import hljs from 'highlight.js/lib/core';
import java from 'highlight.js/lib/languages/java';
import javascript from 'highlight.js/lib/languages/javascript';
import sql from 'highlight.js/lib/languages/sql';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import { NButton, NTabs, NTree } from 'naive-ui';
import { message } from '#/adapter/naive';
import { previewCodegen } from '#/api/infra/codegen';
/** 注册代码高亮语言 */
hljs.registerLanguage('java', java);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('html', xml);
hljs.registerLanguage('vue', xml);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('typescript', typescript);
/** 文件树类型 */
interface FileNode {
key: string;
title: string;
parentKey: string;
isLeaf?: boolean;
children?: FileNode[];
}
/** 组件状态 */
const loading = ref(false);
const fileTree = ref<FileNode[]>([]);
const previewFiles = ref<InfraCodegenApi.CodegenPreview[]>([]);
const activeKey = ref<string>('');
/** 代码地图 */
const codeMap = ref<Map<string, string>>(new Map<string, string>());
const setCodeMap = (key: string, lang: string, code: string) => {
// Java
const trimmedCode = code.trimStart();
//
if (codeMap.value.has(key)) {
return;
}
try {
const highlightedCode = hljs.highlight(trimmedCode, {
language: lang,
}).value;
codeMap.value.set(key, highlightedCode);
} catch {
codeMap.value.set(key, trimmedCode);
}
};
const removeCodeMapKey = (targetKey: any) => {
//
if (codeMap.value.size === 1) {
return;
}
if (codeMap.value.has(targetKey)) {
codeMap.value.delete(targetKey);
}
};
/** 复制代码 */
const copyCode = async () => {
const { copy } = useClipboard();
const file = previewFiles.value.find(
(item) => item.filePath === activeKey.value,
);
if (file) {
await copy(file.code);
message.success('复制成功');
}
};
/** 文件节点点击事件 */
const handleNodeClick = (_: any[], e: any) => {
if (!e.node.isLeaf) return;
activeKey.value = e.node.key;
const file = previewFiles.value.find((item) => {
const list = activeKey.value.split('.');
// -
if (list.length > 2) {
const lang = list.pop();
return item.filePath === `${list.join('/')}.${lang}`;
}
return item.filePath === activeKey.value;
});
if (!file) return;
const lang = file.filePath.split('.').pop() || '';
setCodeMap(activeKey.value, lang, file.code);
};
/** 处理文件树 */
const handleFiles = (data: InfraCodegenApi.CodegenPreview[]): FileNode[] => {
const exists: Record<string, boolean> = {};
const files: FileNode[] = [];
//
for (const item of data) {
const paths = item.filePath.split('/');
let cursor = 0;
let fullPath = '';
while (cursor < paths.length) {
const path = paths[cursor] || '';
const oldFullPath = fullPath;
// Java
if (path === 'java' && cursor + 1 < paths.length) {
fullPath = fullPath ? `${fullPath}/${path}` : path;
cursor++;
//
let packagePath = '';
while (cursor < paths.length) {
const nextPath = paths[cursor] || '';
if (
[
'controller',
'convert',
'dal',
'dataobject',
'enums',
'mysql',
'service',
'vo',
].includes(nextPath)
) {
break;
}
packagePath = packagePath ? `${packagePath}.${nextPath}` : nextPath;
cursor++;
}
if (packagePath) {
const newFullPath = `${fullPath}/${packagePath}`;
if (!exists[newFullPath]) {
exists[newFullPath] = true;
files.push({
key: newFullPath,
title: packagePath,
parentKey: oldFullPath || '/',
isLeaf: cursor === paths.length,
});
}
fullPath = newFullPath;
}
continue;
}
//
fullPath = fullPath ? `${fullPath}/${path}` : path;
if (!exists[fullPath]) {
exists[fullPath] = true;
files.push({
key: fullPath,
title: path,
parentKey: oldFullPath || '/',
isLeaf: cursor === paths.length - 1,
});
}
cursor++;
}
}
/** 构建树形结构 */
const buildTree = (parentKey: string): FileNode[] => {
return files
.filter((file) => file.parentKey === parentKey)
.map((file) => ({
...file,
children: buildTree(file.key),
}));
};
return buildTree('/');
};
/** 模态框实例 */
const [Modal, modalApi] = useVbenModal({
footer: false,
fullscreen: true,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
//
codeMap.value.clear();
return;
}
const row = modalApi.getData<InfraCodegenApi.CodegenTable>();
if (!row) return;
//
loading.value = true;
try {
const data = await previewCodegen(row.id);
previewFiles.value = data;
//
fileTree.value = handleFiles(data);
if (data.length > 0) {
activeKey.value = data[0]?.filePath || '';
const lang = activeKey.value.split('.').pop() || '';
const code = data[0]?.code || '';
setCodeMap(activeKey.value, lang, code);
}
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal title="代码预览">
<div class="flex h-full" v-loading="loading">
<!-- 文件树 -->
<div
class="h-full w-1/3 overflow-auto border-r border-gray-200 pr-4 dark:border-gray-700"
>
<NTree
v-if="fileTree.length > 0"
default-expand-all
v-model:active-key="activeKey"
@select="handleNodeClick"
:tree-data="fileTree"
/>
</div>
<!-- 代码预览 -->
<div class="h-full w-2/3 overflow-auto pl-4">
<NTabs
v-model:active-key="activeKey"
hide-add
type="editable-card"
@edit="removeCodeMapKey"
>
<NTabPane
v-for="key in codeMap.keys()"
:key="key"
:tab="key.split('/').pop()"
>
<div
class="h-full rounded-md bg-gray-50 !p-0 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<code
v-html="codeMap.get(activeKey)"
class="code-highlight"
></code>
</div>
</NTabPane>
<template #rightExtra>
<NButton type="primary" ghost @click="copyCode" :icon="h(Copy)">
复制代码
</NButton>
</template>
</NTabs>
</div>
</div>
</Modal>
</template>
<style scoped>
/* stylelint-disable selector-class-pattern */
/* 代码高亮样式 - 支持暗黑模式 */
:deep(.code-highlight) {
display: block;
white-space: pre;
background: transparent;
}
/* 关键字 */
:deep(.hljs-keyword) {
@apply text-purple-600 dark:text-purple-400;
}
/* 字符串 */
:deep(.hljs-string) {
@apply text-green-600 dark:text-green-400;
}
/* 注释 */
:deep(.hljs-comment) {
@apply text-gray-500 dark:text-gray-400;
}
/* 函数 */
:deep(.hljs-function) {
@apply text-blue-600 dark:text-blue-400;
}
/* 数字 */
:deep(.hljs-number) {
@apply text-orange-600 dark:text-orange-400;
}
/* 类 */
:deep(.hljs-class) {
@apply text-yellow-600 dark:text-yellow-400;
}
/* 标题/函数名 */
:deep(.hljs-title) {
@apply font-bold text-blue-600 dark:text-blue-400;
}
/* 参数 */
:deep(.hljs-params) {
@apply text-gray-700 dark:text-gray-300;
}
/* 内置对象 */
:deep(.hljs-built_in) {
@apply text-teal-600 dark:text-teal-400;
}
/* HTML标签 */
:deep(.hljs-tag) {
@apply text-blue-600 dark:text-blue-400;
}
/* 属性 */
:deep(.hljs-attribute),
:deep(.hljs-attr) {
@apply text-green-600 dark:text-green-400;
}
/* 字面量 */
:deep(.hljs-literal) {
@apply text-purple-600 dark:text-purple-400;
}
/* 元信息 */
:deep(.hljs-meta) {
@apply text-gray-500 dark:text-gray-400;
}
/* 选择器标签 */
:deep(.hljs-selector-tag) {
@apply text-blue-600 dark:text-blue-400;
}
/* XML/HTML名称 */
:deep(.hljs-name) {
@apply text-blue-600 dark:text-blue-400;
}
/* 变量 */
:deep(.hljs-variable) {
@apply text-orange-600 dark:text-orange-400;
}
/* 属性 */
:deep(.hljs-property) {
@apply text-red-600 dark:text-red-400;
}
/* stylelint-enable selector-class-pattern */
</style>

View File

@ -0,0 +1,209 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraConfigApi } from '#/api/infra/config';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'category',
label: '参数分类',
component: 'Input',
componentProps: {
placeholder: '请输入参数分类',
},
rules: 'required',
},
{
fieldName: 'name',
label: '参数名称',
component: 'Input',
componentProps: {
placeholder: '请输入参数名称',
},
rules: 'required',
},
{
fieldName: 'key',
label: '参数键名',
component: 'Input',
componentProps: {
placeholder: '请输入参数键名',
},
rules: 'required',
},
{
fieldName: 'value',
label: '参数键值',
component: 'Input',
componentProps: {
placeholder: '请输入参数键值',
},
rules: 'required',
},
{
fieldName: 'visible',
label: '是否可见',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
defaultValue: true,
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '参数名称',
component: 'Input',
componentProps: {
placeholder: '请输入参数名称',
clearable: true,
},
},
{
fieldName: 'key',
label: '参数键名',
component: 'Input',
componentProps: {
placeholder: '请输入参数键名',
clearable: true,
},
},
{
fieldName: 'type',
label: '系统内置',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_CONFIG_TYPE, 'number'),
placeholder: '请选择系统内置',
allowClear: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraConfigApi.Config>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '参数主键',
minWidth: 100,
},
{
field: 'category',
title: '参数分类',
minWidth: 120,
},
{
field: 'name',
title: '参数名称',
minWidth: 200,
},
{
field: 'key',
title: '参数键名',
minWidth: 200,
},
{
field: 'value',
title: '参数键值',
minWidth: 150,
},
{
field: 'visible',
title: '是否可见',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'type',
title: '系统内置',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_CONFIG_TYPE },
},
},
{
field: 'remark',
title: '备注',
minWidth: 150,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '参数',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:config:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:config:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,134 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraConfigApi } from '#/api/infra/config';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteConfig, exportConfig, getConfigPage } from '#/api/infra/config';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportConfig(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '参数配置.xls', source: data });
}
/** 创建参数 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑参数 */
function onEdit(row: InfraConfigApi.Config) {
formModalApi.setData(row).open();
}
/** 删除参数 */
async function onDelete(row: InfraConfigApi.Config) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteConfig(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraConfigApi.Config>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getConfigPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraConfigApi.Config>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['infra:config:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['参数']) }}
</NButton>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:config:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { InfraConfigApi } from '#/api/infra/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createConfig, getConfig, updateConfig } from '#/api/infra/config';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<InfraConfigApi.Config>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['参数'])
: $t('ui.actionTitle.create', ['参数']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as InfraConfigApi.Config;
try {
await (formData.value?.id ? updateConfig(data) : createConfig(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<InfraConfigApi.Config>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getConfig(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,119 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { useAccess } from '@vben/access';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '数据源名称',
component: 'Input',
componentProps: {
placeholder: '请输入数据源名称',
},
rules: 'required',
},
{
fieldName: 'url',
label: '数据源连接',
component: 'Input',
componentProps: {
placeholder: '请输入数据源连接',
},
rules: 'required',
},
{
fieldName: 'username',
label: '用户名',
component: 'Input',
componentProps: {
placeholder: '请输入用户名',
},
rules: 'required',
},
{
fieldName: 'password',
label: '密码',
component: 'Input',
componentProps: {
placeholder: '请输入密码',
type: 'password',
},
rules: 'required',
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraDataSourceConfigApi.DataSourceConfig>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '主键编号',
minWidth: 100,
},
{
field: 'name',
title: '数据源名称',
minWidth: 150,
},
{
field: 'url',
title: '数据源连接',
minWidth: 300,
},
{
field: 'username',
title: '用户名',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '数据源',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:data-source-config:update']),
disabled: (row: any) => row.id === 0,
},
{
code: 'delete',
show: hasAccessByCodes(['infra:data-source-config:delete']),
disabled: (row: any) => row.id === 0,
},
],
},
},
];
}

View File

@ -0,0 +1,123 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { onMounted } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDataSourceConfig,
getDataSourceConfigList,
} from '#/api/infra/data-source-config';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 创建数据源 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑数据源 */
function onEdit(row: InfraDataSourceConfigApi.DataSourceConfig) {
formModalApi.setData(row).open();
}
/** 删除数据源 */
async function onDelete(row: InfraDataSourceConfigApi.DataSourceConfig) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDataSourceConfig(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
await handleLoadData();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraDataSourceConfigApi.DataSourceConfig>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: getDataSourceConfigList,
},
},
} as VxeTableGridOptions<InfraDataSourceConfigApi.DataSourceConfig>,
});
/** 加载数据 */
async function handleLoadData() {
await gridApi.query();
}
/** 刷新表格 */
async function onRefresh() {
await handleLoadData();
}
/** 初始化 */
onMounted(() => {
handleLoadData();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['infra:data-source-config:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['数据源']) }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createDataSourceConfig,
getDataSourceConfig,
updateDataSourceConfig,
} from '#/api/infra/data-source-config';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<InfraDataSourceConfigApi.DataSourceConfig>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['数据源'])
: $t('ui.actionTitle.create', ['数据源']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as InfraDataSourceConfigApi.DataSourceConfig;
try {
await (formData.value?.id
? updateDataSourceConfig(data)
: createDataSourceConfig(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<InfraDataSourceConfigApi.DataSourceConfig>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDataSourceConfig(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,38 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { getConfigKey } from '#/api/infra/config';
import { DocAlert } from '#/components/doc-alert';
import { IFrame } from '#/components/iframe';
const loading = ref(true); //
const src = ref(`${import.meta.env.VITE_BASE_URL}/druid/index.html`);
/** 初始化 */
onMounted(async () => {
try {
const data = await getConfigKey('url.druid');
if (data && data.length > 0) {
src.value = data;
}
} finally {
loading.value = false;
}
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="数据库 MyBatis" url="https://doc.iocoder.cn/mybatis/" />
<DocAlert
title="多数据源(读写分离)"
url="https://doc.iocoder.cn/dynamic-datasource/"
/>
</template>
<IFrame v-if="!loading" v-loading="loading" :src="src" />
</Page>
</template>

View File

@ -0,0 +1,140 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraFileApi } from '#/api/infra/file';
import { useAccess } from '@vben/access';
import { getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 表单的字段 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'file',
label: '文件上传',
component: 'Upload',
componentProps: {
placeholder: '请选择要上传的文件',
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'path',
label: '文件路径',
component: 'Input',
componentProps: {
placeholder: '请输入文件路径',
clearable: true,
},
},
{
fieldName: 'type',
label: '文件类型',
component: 'Input',
componentProps: {
placeholder: '请输入文件类型',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraFileApi.File>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '文件名',
minWidth: 150,
},
{
field: 'path',
title: '文件路径',
minWidth: 200,
showOverflow: true,
},
{
field: 'url',
title: 'URL',
minWidth: 200,
showOverflow: true,
},
{
field: 'size',
title: '文件大小',
minWidth: 80,
formatter: ({ cellValue }) => {
// TODO @芋艿:后续优化下
if (!cellValue) return '0 B';
const unitArr = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const index = Math.floor(Math.log(cellValue) / Math.log(1024));
const size = cellValue / 1024 ** index;
const formattedSize = size.toFixed(2);
return `${formattedSize} ${unitArr[index]}`;
},
},
{
field: 'type',
title: '文件类型',
minWidth: 120,
},
{
field: 'file-content',
title: '文件内容',
minWidth: 120,
slots: {
default: 'file-content',
},
},
{
field: 'createTime',
title: '上传时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 160,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '文件',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'copyUrl',
text: '复制链接',
},
{
code: 'delete',
show: hasAccessByCodes(['infra:file:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,148 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraFileApi } from '#/api/infra/file';
import { Page, useVbenModal } from '@vben/common-ui';
import { Upload } from '@vben/icons';
import { openWindow } from '@vben/utils';
import { useClipboard } from '@vueuse/core';
import { NButton, NImage } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteFile, getFilePage } from '#/api/infra/file';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 上传文件 */
function onUpload() {
formModalApi.setData(null).open();
}
/** 复制链接到剪贴板 */
const { copy } = useClipboard({ legacy: true });
async function onCopyUrl(row: InfraFileApi.File) {
if (!row.url) {
message.error('文件 URL 为空');
return;
}
try {
await copy(row.url);
message.success('复制成功');
} catch {
message.error('复制失败');
}
}
/** 打开 URL */
function openUrl(url?: string) {
if (url) {
openWindow(url);
}
}
/** 删除文件 */
async function onDelete(row: InfraFileApi.File) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name || row.path]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteFile(row.id as number);
message.success(
$t('ui.actionMessage.deleteSuccess', [row.name || row.path]),
);
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({ code, row }: OnActionClickParams<InfraFileApi.File>) {
switch (code) {
case 'copyUrl': {
onCopyUrl(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getFilePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraFileApi.File>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton type="primary" @click="onUpload">
<Upload class="size-5" />
上传图片
</NButton>
</template>
<template #file-content="{ row }">
<NImage v-if="row.type && row.type.includes('image')" :src="row.url" />
<NButton
v-else-if="row.type && row.type.includes('pdf')"
type="link"
@click="() => openUrl(row.url)"
>
预览
</NButton>
<NButton v-else type="link" @click="() => openUrl(row.url)">
下载
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { UploadFileInfo } from 'naive-ui';
import { useVbenModal } from '@vben/common-ui';
import { NUpload } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { useUpload } from '#/components/upload/use-upload';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
hideLabel: true,
},
layout: 'horizontal',
schema: useFormSchema().map((item) => ({ ...item, label: '' })), // label
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = await formApi.getValues();
try {
await useUpload().httpRequest(data.file);
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
});
/** 上传前 */
function beforeUpload(file: UploadFileInfo) {
formApi.setFieldValue('file', file);
return false;
}
</script>
<template>
<Modal title="上传图片">
<Form class="mx-4">
<template #file>
<div class="w-full">
<!-- 上传区域 -->
<NUpload.Dragger
name="file"
:max-count="1"
accept=".jpg,.png,.gif,.webp"
:before-upload="beforeUpload"
list-type="picture-card"
>
<p class="ant-upload-drag-icon">
<span class="icon-[ant-design--inbox-outlined] text-2xl"></span>
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">
支持 .jpg.png.gif.webp 格式图片文件
</p>
</NUpload.Dragger>
</div>
</template>
</Form>
</Modal>
</template>

View File

@ -0,0 +1,347 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraFileConfigApi } from '#/api/infra/file-config';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '配置名',
component: 'Input',
componentProps: {
placeholder: '请输入配置名',
},
rules: 'required',
},
{
fieldName: 'storage',
label: '存储器',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_FILE_STORAGE, 'number'),
placeholder: '请选择存储器',
},
rules: 'required',
dependencies: {
triggerFields: ['id'],
show: (formValues) => !formValues.id,
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
// DB / Local / FTP / SFTP
{
fieldName: 'config.basePath',
label: '基础路径',
component: 'Input',
componentProps: {
placeholder: '请输入基础路径',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) =>
formValues.storage >= 10 && formValues.storage <= 12,
},
},
{
fieldName: 'config.host',
label: '主机地址',
component: 'Input',
componentProps: {
placeholder: '请输入主机地址',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) =>
formValues.storage >= 11 && formValues.storage <= 12,
},
},
{
fieldName: 'config.port',
label: '主机端口',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入主机端口',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) =>
formValues.storage >= 11 && formValues.storage <= 12,
},
},
{
fieldName: 'config.username',
label: '用户名',
component: 'Input',
componentProps: {
placeholder: '请输入用户名',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) =>
formValues.storage >= 11 && formValues.storage <= 12,
},
},
{
fieldName: 'config.password',
label: '密码',
component: 'Input',
componentProps: {
placeholder: '请输入密码',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) =>
formValues.storage >= 11 && formValues.storage <= 12,
},
},
{
fieldName: 'config.mode',
label: '连接模式',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '主动模式', value: 'Active' },
{ label: '被动模式', value: 'Passive' },
],
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) => formValues.storage === 11,
},
},
// S3
{
fieldName: 'config.endpoint',
label: '节点地址',
component: 'Input',
componentProps: {
placeholder: '请输入节点地址',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) => formValues.storage === 20,
},
},
{
fieldName: 'config.bucket',
label: '存储 bucket',
component: 'Input',
componentProps: {
placeholder: '请输入 bucket',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) => formValues.storage === 20,
},
},
{
fieldName: 'config.accessKey',
label: 'accessKey',
component: 'Input',
componentProps: {
placeholder: '请输入 accessKey',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) => formValues.storage === 20,
},
},
{
fieldName: 'config.accessSecret',
label: 'accessSecret',
component: 'Input',
componentProps: {
placeholder: '请输入 accessSecret',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) => formValues.storage === 20,
},
},
{
fieldName: 'config.enablePathStyleAccess',
label: '是否 Path Style',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '启用', value: true },
{ label: '禁用', value: false },
],
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) => formValues.storage === 20,
},
defaultValue: false,
},
// 通用
{
fieldName: 'config.domain',
label: '自定义域名',
component: 'Input',
componentProps: {
placeholder: '请输入自定义域名',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) => !!formValues.storage,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '配置名',
component: 'Input',
componentProps: {
placeholder: '请输入配置名',
clearable: true,
},
},
{
fieldName: 'storage',
label: '存储器',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_FILE_STORAGE, 'number'),
placeholder: '请选择存储器',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraFileConfigApi.FileConfig>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
width: 100,
},
{
field: 'name',
title: '配置名',
minWidth: 120,
},
{
field: 'storage',
title: '存储器',
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_FILE_STORAGE },
},
},
{
field: 'remark',
title: '备注',
minWidth: 150,
},
{
field: 'master',
title: '主配置',
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 280,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '文件配置',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:file-config:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:file-config:delete']),
},
{
code: 'master',
text: '主配置',
disabled: (row: any) => row.master,
show: (_row: any) => hasAccessByCodes(['infra:file-config:update']),
},
{
code: 'test',
text: '测试',
},
],
},
},
];
}

View File

@ -0,0 +1,174 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraFileConfigApi } from '#/api/infra/file-config';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { openWindow } from '@vben/utils';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteFileConfig,
getFileConfigPage,
testFileConfig,
updateFileConfigMaster,
} from '#/api/infra/file-config';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建文件配置 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑文件配置 */
function onEdit(row: InfraFileConfigApi.FileConfig) {
formModalApi.setData(row).open();
}
/** 设为主配置 */
async function onMaster(row: InfraFileConfigApi.FileConfig) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.updating', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await updateFileConfigMaster(row.id as number);
message.success($t('ui.actionMessage.operationSuccess'));
onRefresh();
} catch {
hideLoading();
}
}
/** 测试文件配置 */
async function onTest(row: InfraFileConfigApi.FileConfig) {
const hideLoading = message.loading({
content: '测试上传中...',
duration: 0,
key: 'action_process_msg',
});
try {
const response = await testFileConfig(row.id as number);
hideLoading();
// 访
confirm({
title: '测试上传成功',
content: '是否要访问该文件?',
confirmText: '访问',
cancelText: '取消',
}).then(() => {
openWindow(response);
});
} catch {
hideLoading();
}
}
/** 删除文件配置 */
async function onDelete(row: InfraFileConfigApi.FileConfig) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteFileConfig(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraFileConfigApi.FileConfig>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
case 'master': {
onMaster(row);
break;
}
case 'test': {
onTest(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getFileConfigPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraFileConfigApi.FileConfig>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['infra:file-config:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['文件配置']) }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { InfraFileConfigApi } from '#/api/infra/file-config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createFileConfig,
getFileConfig,
updateFileConfig,
} from '#/api/infra/file-config';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<InfraFileConfigApi.FileConfig>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['文件配置'])
: $t('ui.actionTitle.create', ['文件配置']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as InfraFileConfigApi.FileConfig;
try {
await (formData.value?.id
? updateFileConfig(data)
: createFileConfig(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<InfraFileConfigApi.FileConfig>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getFileConfig(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,221 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraJobApi } from '#/api/infra/job';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions, InfraJobStatusEnum } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '任务名称',
component: 'Input',
componentProps: {
placeholder: '请输入任务名称',
},
rules: 'required',
},
{
fieldName: 'handlerName',
label: '处理器的名字',
component: 'Input',
componentProps: {
placeholder: '请输入处理器的名字',
// readonly: ({ values }) => !!values.id,
},
rules: 'required',
// TODO @芋艿:在修改场景下,禁止调整
},
{
fieldName: 'handlerParam',
label: '处理器的参数',
component: 'Input',
componentProps: {
placeholder: '请输入处理器的参数',
},
},
{
fieldName: 'cronExpression',
label: 'CRON 表达式',
component: 'Input',
componentProps: {
placeholder: '请输入 CRON 表达式',
},
rules: 'required',
// TODO @芋艿:未来支持动态的 CRON 表达式选择
},
{
fieldName: 'retryCount',
label: '重试次数',
component: 'InputNumber',
componentProps: {
placeholder: '请输入重试次数。设置为 0 时,不进行重试',
min: 0,
},
rules: 'required',
},
{
fieldName: 'retryInterval',
label: '重试间隔',
component: 'InputNumber',
componentProps: {
placeholder: '请输入重试间隔,单位:毫秒。设置为 0 时,无需间隔',
min: 0,
},
rules: 'required',
},
{
fieldName: 'monitorTimeout',
label: '监控超时时间',
component: 'InputNumber',
componentProps: {
placeholder: '请输入监控超时时间,单位:毫秒',
min: 0,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '任务名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入任务名称',
},
},
{
fieldName: 'status',
label: '任务状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_JOB_STATUS, 'number'),
allowClear: true,
placeholder: '请选择任务状态',
},
},
{
fieldName: 'handlerName',
label: '处理器的名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入处理器的名字',
},
},
];
}
/** 表格列配置 */
export function useGridColumns<T = InfraJobApi.Job>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '任务编号',
minWidth: 80,
},
{
field: 'name',
title: '任务名称',
minWidth: 120,
},
{
field: 'status',
title: '任务状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_JOB_STATUS },
},
},
{
field: 'handlerName',
title: '处理器的名字',
minWidth: 180,
},
{
field: 'handlerParam',
title: '处理器的参数',
minWidth: 140,
},
{
field: 'cronExpression',
title: 'CRON 表达式',
minWidth: 120,
},
{
field: 'operation',
title: '操作',
width: 280,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '任务',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:job:update']),
},
{
code: 'update-status',
text: '开启',
show: (row: any) =>
hasAccessByCodes(['infra:job:update']) &&
row.status === InfraJobStatusEnum.STOP,
},
{
code: 'update-status',
text: '暂停',
show: (row: any) =>
hasAccessByCodes(['infra:job:update']) &&
row.status === InfraJobStatusEnum.NORMAL,
},
{
code: 'trigger',
text: '执行',
show: hasAccessByCodes(['infra:job:trigger']),
},
// TODO @芋艿:增加一个“更多”选项
{
code: 'detail',
text: '详细',
show: hasAccessByCodes(['infra:job:query']),
},
{
code: 'log',
text: '日志',
show: hasAccessByCodes(['infra:job:query']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:job:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,224 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraJobApi } from '#/api/infra/job';
import { useRouter } from 'vue-router';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { Download, History, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteJob,
exportJob,
getJobPage,
runJob,
updateJobStatus,
} from '#/api/infra/job';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { InfraJobStatusEnum } from '#/utils';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
import Form from './modules/form.vue';
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportJob(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '定时任务.xls', source: data });
}
/** 创建任务 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑任务 */
function onEdit(row: InfraJobApi.Job) {
formModalApi.setData(row).open();
}
/** 查看任务详情 */
function onDetail(row: InfraJobApi.Job) {
detailModalApi.setData({ id: row.id }).open();
}
/** 更新任务状态 */
async function onUpdateStatus(row: InfraJobApi.Job) {
const status =
row.status === InfraJobStatusEnum.STOP
? InfraJobStatusEnum.NORMAL
: InfraJobStatusEnum.STOP;
const statusText = status === InfraJobStatusEnum.NORMAL ? '启用' : '停用';
confirm({
content: `确定${statusText} ${row.name} 吗?`,
}).then(async () => {
await updateJobStatus(row.id as number, status);
//
message.success($t('ui.actionMessage.operationSuccess'));
onRefresh();
});
}
/** 执行一次任务 */
async function onTrigger(row: InfraJobApi.Job) {
confirm({
content: `确定执行一次 ${row.name} 吗?`,
}).then(async () => {
await runJob(row.id as number);
message.success($t('ui.actionMessage.operationSuccess'));
});
}
/** 跳转到任务日志 */
function onLog(row?: InfraJobApi.Job) {
push({
name: 'InfraJobLog',
query: row?.id ? { id: row.id } : {},
});
}
/** 删除任务 */
async function onDelete(row: InfraJobApi.Job) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteJob(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({ code, row }: OnActionClickParams<InfraJobApi.Job>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'detail': {
onDetail(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
case 'log': {
onLog(row);
break;
}
case 'trigger': {
onTrigger(row);
break;
}
case 'update-status': {
onUpdateStatus(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getJobPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraJobApi.Job>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="定时任务" url="https://doc.iocoder.cn/job/" />
<DocAlert title="异步任务" url="https://doc.iocoder.cn/async-task/" />
<DocAlert title="消息队列" url="https://doc.iocoder.cn/message-queue/" />
</template>
<FormModal @success="onRefresh" />
<DetailModal />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['infra:job:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['任务']) }}
</NButton>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:job:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
<NButton
type="primary"
class="ml-2"
@click="onLog(undefined)"
v-access:code="['infra:job:query']"
>
<History class="size-5" />
执行日志
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,145 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraJobLogApi } from '#/api/infra/job-log';
import { useAccess } from '@vben/access';
import { formatDateTime } from '@vben/utils';
import dayjs from 'dayjs';
import { DICT_TYPE, getDictOptions } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'handlerName',
label: '处理器的名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入处理器的名字',
},
},
{
fieldName: 'beginTime',
label: '开始执行时间',
component: 'DatePicker',
componentProps: {
allowClear: true,
placeholder: '选择开始执行时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
showTime: {
format: 'HH:mm:ss',
defaultValue: dayjs('00:00:00', 'HH:mm:ss'),
},
},
},
{
fieldName: 'endTime',
label: '结束执行时间',
component: 'DatePicker',
componentProps: {
allowClear: true,
placeholder: '选择结束执行时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
showTime: {
format: 'HH:mm:ss',
defaultValue: dayjs('23:59:59', 'HH:mm:ss'),
},
},
},
{
fieldName: 'status',
label: '任务状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_JOB_LOG_STATUS, 'number'),
allowClear: true,
placeholder: '请选择任务状态',
},
},
];
}
/** 表格列配置 */
export function useGridColumns<T = InfraJobLogApi.JobLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 80,
},
{
field: 'jobId',
title: '任务编号',
minWidth: 80,
},
{
field: 'handlerName',
title: '处理器的名字',
minWidth: 180,
},
{
field: 'handlerParam',
title: '处理器的参数',
minWidth: 140,
},
{
field: 'executeIndex',
title: '第几次执行',
minWidth: 100,
},
{
field: 'beginTime',
title: '执行时间',
minWidth: 280,
formatter: ({ row }) => {
return `${formatDateTime(row.beginTime)} ~ ${formatDateTime(row.endTime)}`;
},
},
{
field: 'duration',
title: '执行时长',
minWidth: 120,
formatter: ({ row }) => {
return `${row.duration} 毫秒`;
},
},
{
field: 'status',
title: '任务状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_JOB_LOG_STATUS },
},
},
{
field: 'operation',
title: '操作',
width: 80,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详细',
show: hasAccessByCodes(['infra:job:query']),
},
],
},
},
];
}

View File

@ -0,0 +1,112 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraJobLogApi } from '#/api/infra/job-log';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportJobLog, getJobLogPage } from '#/api/infra/job-log';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const { query } = useRoute();
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 导出表格 */
async function onExport() {
const data = await exportJobLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '任务日志.xls', source: data });
}
/** 查看日志详情 */
function onDetail(row: InfraJobLogApi.JobLog) {
detailModalApi.setData({ id: row.id }).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraJobLogApi.JobLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
}
}
// schemajobId
const formSchema = useGridFormSchema();
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: formSchema,
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getJobLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
jobId: query.id,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraJobLogApi.JobLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="定时任务" url="https://doc.iocoder.cn/job/" />
<DocAlert title="异步任务" url="https://doc.iocoder.cn/async-task/" />
<DocAlert title="消息队列" url="https://doc.iocoder.cn/message-queue/" />
</template>
<DetailModal />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:job:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { InfraJobLogApi } from '#/api/infra/job-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { NDescriptions, NDescriptionsItem } from 'naive-ui';
import { getJobLog } from '#/api/infra/job-log';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<InfraJobLogApi.JobLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<{ id: number }>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
formData.value = await getJobLog(data.id);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<NDescriptions
:column="1"
bordered
size="middle"
class="mx-4"
:label-style="{ width: '140px' }"
>
<NDescriptionsItem label="日志编号">
{{ formData?.id }}
</NDescriptionsItem>
<NDescriptionsItem label="任务编号">
{{ formData?.jobId }}
</NDescriptionsItem>
<NDescriptionsItem label="处理器的名字">
{{ formData?.handlerName }}
</NDescriptionsItem>
<NDescriptionsItem label="处理器的参数">
{{ formData?.handlerParam }}
</NDescriptionsItem>
<NDescriptionsItem label="第几次执行">
{{ formData?.executeIndex }}
</NDescriptionsItem>
<NDescriptionsItem label="执行时间">
{{ formData?.beginTime ? formatDateTime(formData.beginTime) : '' }} ~
{{ formData?.endTime ? formatDateTime(formData.endTime) : '' }}
</NDescriptionsItem>
<NDescriptionsItem label="执行时长">
{{ formData?.duration ? `${formData.duration} 毫秒` : '' }}
</NDescriptionsItem>
<NDescriptionsItem label="任务状态">
<DictTag
:type="DICT_TYPE.INFRA_JOB_LOG_STATUS"
:value="formData?.status"
/>
</NDescriptionsItem>
<NDescriptionsItem label="执行结果">
{{ formData?.result }}
</NDescriptionsItem>
</NDescriptions>
</Modal>
</template>

View File

@ -0,0 +1,106 @@
<script lang="ts" setup>
import type { InfraJobApi } from '#/api/infra/job';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import {
NDescriptions,
NDescriptionsItem,
NTimeline,
NTimelineItem,
} from 'naive-ui';
import { getJob, getJobNextTimes } from '#/api/infra/job';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<InfraJobApi.Job>(); //
const nextTimes = ref<Date[]>([]); //
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<{ id: number }>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
formData.value = await getJob(data.id);
//
nextTimes.value = await getJobNextTimes(data.id);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="任务详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<NDescriptions
:column="1"
bordered
size="middle"
class="mx-4"
:label-style="{ width: '140px' }"
>
<NDescriptionsItem label="任务编号">
{{ formData?.id }}
</NDescriptionsItem>
<NDescriptionsItem label="任务名称">
{{ formData?.name }}
</NDescriptionsItem>
<NDescriptionsItem label="任务状态">
<DictTag :type="DICT_TYPE.INFRA_JOB_STATUS" :value="formData?.status" />
</NDescriptionsItem>
<NDescriptionsItem label="处理器的名字">
{{ formData?.handlerName }}
</NDescriptionsItem>
<NDescriptionsItem label="处理器的参数">
{{ formData?.handlerParam }}
</NDescriptionsItem>
<NDescriptionsItem label="Cron 表达式">
{{ formData?.cronExpression }}
</NDescriptionsItem>
<NDescriptionsItem label="重试次数">
{{ formData?.retryCount }}
</NDescriptionsItem>
<NDescriptionsItem label="重试间隔">
{{
formData?.retryInterval ? `${formData.retryInterval} 毫秒` : '无间隔'
}}
</NDescriptionsItem>
<NDescriptionsItem label="监控超时时间">
{{
formData?.monitorTimeout && formData.monitorTimeout > 0
? `${formData.monitorTimeout} 毫秒`
: '未开启'
}}
</NDescriptionsItem>
<NDescriptionsItem label="后续执行时间">
<NTimeline class="h-[180px]">
<NTimelineItem
v-for="(nextTime, index) in nextTimes"
:key="index"
color="blue"
>
{{ index + 1 }} {{ formatDateTime(nextTime.toString()) }}
</NTimelineItem>
</NTimeline>
</NDescriptionsItem>
</NDescriptions>
</Modal>
</template>

View File

@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { InfraJobApi } from '#/api/infra/job';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createJob, getJob, updateJob } from '#/api/infra/job';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<InfraJobApi.Job>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['任务'])
: $t('ui.actionTitle.create', ['任务']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as InfraJobApi.Job;
try {
await (formData.value?.id ? updateJob(data) : createJob(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<InfraJobApi.Job>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getJob(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,54 @@
<script lang="ts" setup>
import type { InfraRedisApi } from '#/api/infra/redis';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { NCard } from 'naive-ui';
import { getRedisMonitorInfo } from '#/api/infra/redis';
import { DocAlert } from '#/components/doc-alert';
import Commands from './modules/commands.vue';
import Info from './modules/info.vue';
import Memory from './modules/memory.vue';
const redisData = ref<InfraRedisApi.RedisMonitorInfo>();
/** 统一加载 Redis 数据 */
const loadRedisData = async () => {
try {
redisData.value = await getRedisMonitorInfo();
} catch (error) {
console.error('加载 Redis 数据失败', error);
}
};
onMounted(() => {
loadRedisData();
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="Redis 缓存" url="https://doc.iocoder.cn/redis-cache/" />
<DocAlert title="本地缓存" url="https://doc.iocoder.cn/local-cache/" />
</template>
<NCard class="mt-5" title="Redis 概览">
<Info :redis-data="redisData" />
</NCard>
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
<NCard title="内存使用">
<Memory :redis-data="redisData" />
</NCard>
<NCard title="命令统计">
<Commands :redis-data="redisData" />
</NCard>
</div>
</Page>
</template>

View File

@ -0,0 +1,103 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { InfraRedisApi } from '#/api/infra/redis';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
redisData?: InfraRedisApi.RedisMonitorInfo;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 渲染命令统计图表 */
const renderCommandStats = () => {
if (!props.redisData?.commandStats) {
return;
}
//
const commandStats = [] as any[];
const nameList = [] as string[];
props.redisData.commandStats.forEach((row) => {
commandStats.push({
name: row.command,
value: row.calls,
});
nameList.push(row.command);
});
//
renderEcharts({
title: {
text: '命令统计',
left: 'center',
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 30,
top: 10,
bottom: 20,
data: nameList,
textStyle: {
color: '#a1a1a1',
},
},
series: [
{
name: '命令',
type: 'pie',
radius: [20, 120],
center: ['40%', '60%'],
data: commandStats,
roseType: 'radius',
label: {
show: true,
},
emphasis: {
label: {
show: true,
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
});
};
/** 监听数据变化,重新渲染图表 */
watch(
() => props.redisData,
(newVal) => {
if (newVal) {
renderCommandStats();
}
},
{ deep: true },
);
onMounted(() => {
if (props.redisData) {
renderCommandStats();
}
});
</script>
<template>
<div>
<EchartsUI ref="chartRef" height="420px" />
</div>
</template>

View File

@ -0,0 +1,60 @@
<script lang="ts" setup>
import type { InfraRedisApi } from '#/api/infra/redis';
import { NDescriptions, NDescriptionsItem } from 'naive-ui';
defineProps<{
redisData?: InfraRedisApi.RedisMonitorInfo;
}>();
</script>
<template>
<NDescriptions
:column="6"
bordered
size="middle"
:label-style="{ width: '138px' }"
>
<NDescriptionsItem label="Redis 版本">
{{ redisData?.info?.redis_version }}
</NDescriptionsItem>
<NDescriptionsItem label="运行模式">
{{ redisData?.info?.redis_mode === 'standalone' ? '单机' : '集群' }}
</NDescriptionsItem>
<NDescriptionsItem label="端口">
{{ redisData?.info?.tcp_port }}
</NDescriptionsItem>
<NDescriptionsItem label="客户端数">
{{ redisData?.info?.connected_clients }}
</NDescriptionsItem>
<NDescriptionsItem label="运行时间(天)">
{{ redisData?.info?.uptime_in_days }}
</NDescriptionsItem>
<NDescriptionsItem label="使用内存">
{{ redisData?.info?.used_memory_human }}
</NDescriptionsItem>
<NDescriptionsItem label="使用 CPU">
{{
redisData?.info
? parseFloat(redisData?.info?.used_cpu_user_children).toFixed(2)
: ''
}}
</NDescriptionsItem>
<NDescriptionsItem label="内存配置">
{{ redisData?.info?.maxmemory_human }}
</NDescriptionsItem>
<NDescriptionsItem label="AOF 是否开启">
{{ redisData?.info?.aof_enabled === '0' ? '否' : '是' }}
</NDescriptionsItem>
<NDescriptionsItem label="RDB 是否成功">
{{ redisData?.info?.rdb_last_bgsave_status }}
</NDescriptionsItem>
<NDescriptionsItem label="Key 数量">
{{ redisData?.dbSize }}
</NDescriptionsItem>
<NDescriptionsItem label="网络入口/出口">
{{ redisData?.info?.instantaneous_input_kbps }}kps /
{{ redisData?.info?.instantaneous_output_kbps }}kps
</NDescriptionsItem>
</NDescriptions>
</template>

View File

@ -0,0 +1,137 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { InfraRedisApi } from '#/api/infra/redis';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
redisData?: InfraRedisApi.RedisMonitorInfo;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 解析内存值,移除单位,转为数字 */
const parseMemoryValue = (memStr: string | undefined): number => {
if (!memStr) {
return 0;
}
try {
// "1.2M" 1.2
const str = String(memStr); //
const match = str.match(/^([\d.]+)/);
return match ? Number.parseFloat(match[1] as string) : 0;
} catch {
return 0;
}
};
/** 渲染内存使用图表 */
const renderMemoryChart = () => {
if (!props.redisData?.info) {
return;
}
//
const usedMemory = props.redisData.info.used_memory_human || '0';
const memoryValue = parseMemoryValue(usedMemory);
//
renderEcharts({
title: {
text: '内存使用情况',
left: 'center',
},
tooltip: {
formatter: `{b} <br/>{a} : ${usedMemory}`,
},
series: [
{
name: '峰值',
type: 'gauge',
min: 0,
max: 100,
splitNumber: 10,
color: '#F5C74E',
radius: '85%',
center: ['50%', '50%'],
startAngle: 225,
endAngle: -45,
axisLine: {
lineStyle: {
color: [
[0.2, '#7FFF00'],
[0.8, '#00FFFF'],
[1, '#FF0000'],
],
width: 10,
},
},
axisTick: {
length: 5,
lineStyle: {
color: '#76D9D7',
},
},
splitLine: {
length: 20,
lineStyle: {
color: '#76D9D7',
},
},
axisLabel: {
color: '#76D9D7',
distance: 15,
fontSize: 15,
},
pointer: {
width: 7,
show: true,
},
detail: {
show: true,
offsetCenter: [0, '50%'],
color: 'auto',
fontSize: 30,
formatter: usedMemory,
},
progress: {
show: true,
},
data: [
{
value: memoryValue,
name: '内存消耗',
},
],
},
],
});
};
/** 监听数据变化,重新渲染图表 */
watch(
() => props.redisData,
(newVal) => {
if (newVal) {
renderMemoryChart();
}
},
{ deep: true },
);
onMounted(() => {
if (props.redisData) {
renderMemoryChart();
}
});
</script>
<template>
<div>
<EchartsUI ref="chartRef" height="420px" />
</div>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { getConfigKey } from '#/api/infra/config';
import { DocAlert } from '#/components/doc-alert';
import { IFrame } from '#/components/iframe';
const loading = ref(true); //
const src = ref(`${import.meta.env.VITE_BASE_URL}/admin/applications`);
/** 初始化 */
onMounted(async () => {
try {
// 访 404
// 1boot https://doc.iocoder.cn/server-monitor/
// 2cloud https://cloud.iocoder.cn/server-monitor/
const data = await getConfigKey('url.spring-boot-admin');
if (data && data.length > 0) {
src.value = data;
}
} finally {
loading.value = false;
}
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="服务监控" url="https://doc.iocoder.cn/server-monitor/" />
</template>
<IFrame v-if="!loading" v-loading="loading" :src="src" />
</Page>
</template>

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { getConfigKey } from '#/api/infra/config';
import { DocAlert } from '#/components/doc-alert';
import { IFrame } from '#/components/iframe';
const loading = ref(true); //
const src = ref('http://skywalking.shop.iocoder.cn');
/** 初始化 */
onMounted(async () => {
try {
const data = await getConfigKey('url.skywalking');
if (data && data.length > 0) {
src.value = data;
}
} finally {
loading.value = false;
}
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="服务监控" url="https://doc.iocoder.cn/server-monitor/" />
</template>
<IFrame v-if="!loading" v-loading="loading" :src="src" />
</Page>
</template>

View File

@ -0,0 +1,35 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { getConfigKey } from '#/api/infra/config';
import { DocAlert } from '#/components/doc-alert';
import { IFrame } from '#/components/iframe';
const loading = ref(true); //
const src = ref(`${import.meta.env.VITE_BASE_URL}/doc.html`); // Knife4j UI
// const src = ref(import.meta.env.VITE_BASE_URL + '/swagger-ui') // Swagger UI
/** 初始化 */
onMounted(async () => {
try {
const data = await getConfigKey('url.swagger');
if (data && data.length > 0) {
src.value = data;
}
} finally {
loading.value = false;
}
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="接口文档" url="https://doc.iocoder.cn/api-doc/" />
</template>
<IFrame v-if="!loading" :src="src" />
</Page>
</template>

View File

@ -0,0 +1,322 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { computed, onMounted, ref, watchEffect } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { formatDate } from '@vben/utils';
import { useWebSocket } from '@vueuse/core';
import {
NAvatar,
NBadge,
NButton,
NCard,
NDivider,
NEmpty,
NInput,
NSelect,
NSelectOption,
NTag,
} from 'naive-ui';
import { message } from '#/adapters/message';
import { getSimpleUserList } from '#/api/system/user';
import { DocAlert } from '#/components/doc-alert';
const accessStore = useAccessStore();
const refreshToken = accessStore.refreshToken as string;
const server = ref(
`${`${import.meta.env.VITE_BASE_URL}/infra/ws`.replace(
'http',
'ws',
)}?token=${refreshToken}`, // 使用 refreshToken而不使用 accessToken 方法的原因WebSocket 无法方便的刷新访问令牌
); // WebSocket
const getIsOpen = computed(() => status.value === 'OPEN'); // WebSocket
const getTagColor = computed(() => (getIsOpen.value ? 'success' : 'red')); // WebSocket
const getStatusText = computed(() => (getIsOpen.value ? '已连接' : '未连接')); //
/** 发起 WebSocket 连接 */
const { status, data, send, close, open } = useWebSocket(server.value, {
autoReconnect: true,
heartbeat: true,
});
/** 监听接收到的数据 */
const messageList = ref(
[] as { text: string; time: number; type?: string; userId?: string }[],
); //
const messageReverseList = computed(() => [...messageList.value].reverse());
watchEffect(() => {
if (!data.value) {
return;
}
try {
// 1.
if (data.value === 'pong') {
// state.recordList.push({
// text: '',
// time: new Date().getTime()
// })
return;
}
// 2.1 type
const jsonMessage = JSON.parse(data.value);
const type = jsonMessage.type;
const content = JSON.parse(jsonMessage.content);
if (!type) {
message.error(`未知的消息类型:${data.value}`);
return;
}
// 2.2 demo-message-receive
if (type === 'demo-message-receive') {
const single = content.single;
messageList.value.push({
text: content.text,
time: Date.now(),
type: single ? 'single' : 'group',
userId: content.fromUserId,
});
return;
}
// 2.3 notice-push
if (type === 'notice-push') {
messageList.value.push({
text: content.title,
time: Date.now(),
type: 'system',
});
return;
}
message.error(`未处理消息:${data.value}`);
} catch (error) {
message.error(`处理消息发生异常:${data.value}`);
console.error(error);
}
});
/** 发送消息 */
const sendText = ref(''); //
const sendUserId = ref(''); //
const handlerSend = () => {
if (!sendText.value.trim()) {
message.warning('消息内容不能为空');
return;
}
// 1.1 JSON message
const messageContent = JSON.stringify({
text: sendText.value,
toUserId: sendUserId.value,
});
// 1.2 JSON
const jsonMessage = JSON.stringify({
type: 'demo-message-send',
content: messageContent,
});
// 2.
send(jsonMessage);
sendText.value = '';
};
/** 切换 websocket 连接状态 */
const toggleConnectStatus = () => {
if (getIsOpen.value) {
close();
} else {
open();
}
};
/** 获取消息类型的徽标颜色 */
const getMessageBadgeColor = (type?: string) => {
switch (type) {
case 'group': {
return 'green';
}
case 'single': {
return 'blue';
}
case 'system': {
return 'red';
}
default: {
return 'default';
}
}
};
/** 获取消息类型的文本 */
const getMessageTypeText = (type?: string) => {
switch (type) {
case 'group': {
return '群发';
}
case 'single': {
return '单发';
}
case 'system': {
return '系统';
}
default: {
return '未知';
}
}
};
/** 初始化 */
const userList = ref<SystemUserApi.User[]>([]); //
onMounted(async () => {
userList.value = await getSimpleUserList();
});
</script>
<template>
<Page>
<template #doc>
<DocAlert
title="WebSocket 实时通信"
url="https://doc.iocoder.cn/websocket/"
/>
</template>
<div class="mt-4 flex flex-col gap-4 md:flex-row">
<!-- 左侧建立连接发送消息 -->
<NCard :bordered="false" class="w-full md:w-1/2">
<template #title>
<div class="flex items-center">
<NBadge :status="getIsOpen ? 'success' : 'error'" />
<span class="ml-2 text-lg font-medium">连接管理</span>
</div>
</template>
<div class="mb-4 flex items-center rounded-lg p-3">
<span class="mr-4 font-medium">连接状态:</span>
<NTag :color="getTagColor" class="px-3 py-1">
{{ getStatusText }}
</NTag>
</div>
<div class="mb-6 flex space-x-2">
<NInput
v-model:value="server"
disabled
class="rounded-md"
size="large"
>
<template #addonBefore>
<span class="text-gray-600">服务地址</span>
</template>
</NInput>
<NButton
:type="getIsOpen ? 'default' : 'primary'"
:danger="getIsOpen"
size="large"
class="flex-shrink-0"
@click="toggleConnectStatus"
>
{{ getIsOpen ? '关闭连接' : '开启连接' }}
</NButton>
</div>
<NDivider>
<span class="text-gray-500">消息发送</span>
</NDivider>
<NSelect
v-model:value="sendUserId"
class="mb-3 w-full"
size="large"
placeholder="请选择接收人"
:disabled="!getIsOpen"
>
<NSelectOption key="" value="" label="所有人">
<div class="flex items-center">
<NAvatar size="small" class="mr-2"></NAvatar>
<span>所有人</span>
</div>
</NSelectOption>
<NSelectOption
v-for="user in userList"
:key="user.id"
:value="user.id"
:label="user.nickname"
>
<div class="flex items-center">
<NAvatar size="small" class="mr-2">
{{ user.nickname.slice(0, 1) }}
</NAvatar>
<span>{{ user.nickname }}</span>
</div>
</NSelectOption>
</NSelect>
<NInput
type="textarea"
v-model:value="sendText"
:auto-size="{ minRows: 3, maxRows: 6 }"
:disabled="!getIsOpen"
class="border-1 rounded-lg"
allow-clear
placeholder="请输入你要发送的消息..."
/>
<NButton
:disabled="!getIsOpen"
block
class="mt-4"
type="primary"
size="large"
@click="handlerSend"
>
<template #icon>
<span class="i-ant-design:send-outlined mr-1"></span>
</template>
发送消息
</NButton>
</NCard>
<!-- 右侧消息记录 -->
<NCard :bordered="false" class="w-full md:w-1/2">
<template #title>
<div class="flex items-center">
<span class="i-ant-design:message-outlined mr-2 text-lg"></span>
<span class="text-lg font-medium">消息记录</span>
<NTag v-if="messageList.length > 0" class="ml-2">
{{ messageList.length }}
</NTag>
</div>
</template>
<div class="h-96 overflow-auto rounded-lg p-2">
<NEmpty v-if="messageList.length === 0" description="暂无消息记录" />
<div v-else class="space-y-3">
<div
v-for="msg in messageReverseList"
:key="msg.time"
class="rounded-lg p-3 shadow-sm"
>
<div class="mb-1 flex items-center justify-between">
<div class="flex items-center">
<NBadge :color="getMessageBadgeColor(msg.type)" />
<span class="ml-1 font-medium text-gray-600">
{{ getMessageTypeText(msg.type) }}
</span>
<span v-if="msg.userId" class="ml-2 text-gray-500">
用户 ID: {{ msg.userId }}
</span>
</div>
<span class="text-xs text-gray-400">
{{ formatDate(msg.time) }}
</span>
</div>
<div class="mt-2 break-words text-gray-800">
{{ msg.text }}
</div>
</div>
</div>
</div>
</NCard>
</div>
</Page>
</template>

View File

@ -0,0 +1,46 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemAreaApi } from '#/api/system/area';
/** 查询 IP 的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'ip',
label: 'IP 地址',
component: 'Input',
componentProps: {
placeholder: '请输入 IP 地址',
},
rules: 'required',
},
{
fieldName: 'result',
label: '地址',
component: 'Input',
componentProps: {
placeholder: '展示查询 IP 结果',
readonly: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<SystemAreaApi.Area>['columns'] {
return [
{
field: 'id',
title: '地区编码',
minWidth: 120,
align: 'left',
fixed: 'left',
treeNode: true,
},
{
field: 'name',
title: '地区名称',
minWidth: 200,
},
];
}

View File

@ -0,0 +1,76 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { Page, useVbenModal } from '@vben/common-ui';
import { Search } from '@vben/icons';
import { NButton } from 'naive-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAreaTree } from '#/api/system/area';
import { DocAlert } from '#/components/doc-alert';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 查询 IP */
function onQueryIp() {
formModalApi.setData(null).open();
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async () => {
return await getAreaTree();
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
},
treeConfig: {
rowField: 'id',
reserve: true,
},
} as VxeTableGridOptions,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="地区 & IP" url="https://doc.iocoder.cn/area-and-ip/" />
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton type="primary" @click="onQueryIp">
<Search class="size-5" />
IP 查询
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,49 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { getAreaByIp } from '#/api/system/area';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const [Form, { setFieldValue, validate, getValues }] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = await getValues();
try {
const result = await getAreaByIp(data.ip);
//
await setFieldValue('result', result);
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal title="IP 查询">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,192 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import { useAccess } from '@vben/access';
import { handleTree } from '@vben/utils';
import { z } from '#/adapter/form';
import { getDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'parentId',
label: '上级部门',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getDeptList();
data.unshift({
id: 0,
name: '顶级部门',
});
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级部门',
treeDefaultExpandAll: true,
},
rules: 'selectRequired',
},
{
fieldName: 'name',
label: '部门名称',
component: 'Input',
componentProps: {
placeholder: '请输入部门名称',
},
rules: 'required',
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入显示顺序',
},
rules: 'required',
},
{
fieldName: 'leaderUserId',
label: '负责人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择负责人',
allowClear: true,
},
rules: z.number().optional(),
},
{
fieldName: 'phone',
label: '联系电话',
component: 'Input',
componentProps: {
maxLength: 11,
placeholder: '请输入联系电话',
},
rules: 'mobileRequired',
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
},
rules: z.string().email('请输入正确的邮箱地址').optional(),
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的字段 */
export function useGridColumns(
onActionClick?: OnActionClickFn<SystemDeptApi.Dept>,
getLeaderName?: (userId: number) => string | undefined,
): VxeTableGridOptions<SystemDeptApi.Dept>['columns'] {
return [
{
field: 'name',
title: '部门名称',
minWidth: 150,
align: 'left',
fixed: 'left',
treeNode: true,
},
{
field: 'leaderUserId',
title: '负责人',
minWidth: 150,
formatter: (row) => {
return getLeaderName?.(row.cellValue) || '-';
},
},
{
field: 'sort',
title: '显示顺序',
minWidth: 100,
},
{
field: 'status',
title: '部门状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'right',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '部门',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
text: '新增下级',
show: hasAccessByCodes(['system:dept:create']),
},
{
code: 'edit',
show: hasAccessByCodes(['system:dept:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:dept:delete']),
disabled: (row: SystemDeptApi.Dept) => {
return !!(row.children && row.children.length > 0);
},
},
],
},
},
];
}

View File

@ -0,0 +1,154 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemUserApi } from '#/api/system/user';
import { onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDept, getDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const userList = ref<SystemUserApi.User[]>([]);
/** 获取负责人名称 */
const getLeaderName = (userId: number) => {
return userList.value.find((user) => user.id === userId)?.nickname;
};
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 切换树形展开/收缩状态 */
const isExpanded = ref(true);
function toggleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid.setAllTreeExpand(isExpanded.value);
}
/** 创建部门 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 添加下级部门 */
function onAppend(row: SystemDeptApi.Dept) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 编辑部门 */
function onEdit(row: SystemDeptApi.Dept) {
formModalApi.setData(row).open();
}
/** 删除部门 */
async function onDelete(row: SystemDeptApi.Dept) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDept(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({ code, row }: OnActionClickParams<SystemDeptApi.Dept>) {
switch (code) {
case 'append': {
onAppend(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(onActionClick, getLeaderName),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getDeptList();
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
reserve: true,
},
} as VxeTableGridOptions,
});
/** 初始化 */
onMounted(async () => {
userList.value = await getSimpleUserList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:dept:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['部门']) }}
</NButton>
<NButton class="ml-2" @click="toggleExpand">
{{ isExpanded ? '收缩' : '展开' }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createDept, getDept, updateDept } from '#/api/system/dept';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemDeptApi.Dept>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['部门'])
: $t('ui.actionTitle.create', ['部门']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as SystemDeptApi.Dept;
try {
await (formData.value?.id ? updateDept(data) : createDept(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
let data = modalApi.getData<SystemDeptApi.Dept>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDept(data.id);
} finally {
modalApi.unlock();
}
}
// values
formData.value = data;
await formApi.setValues(formData.value);
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,388 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemDictDataApi } from '#/api/system/dict/data';
import type { SystemDictTypeApi } from '#/api/system/dict/type';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import { getSimpleDictTypeList } from '#/api/system/dict/type';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
const { hasAccessByCodes } = useAccess();
// ============================== 字典类型 ==============================
/** 类型新增/修改的表单 */
export function useTypeFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '字典名称',
component: 'Input',
componentProps: {
placeholder: '请输入字典名称',
},
rules: 'required',
},
{
fieldName: 'type',
label: '字典类型',
component: 'Input',
componentProps: (values) => {
return {
placeholder: '请输入字典类型',
disabled: !!values.id,
};
},
rules: 'required',
dependencies: {
triggerFields: [''],
},
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 类型列表的搜索表单 */
export function useTypeGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '字典名称',
component: 'Input',
componentProps: {
placeholder: '请输入字典名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
clearable: true,
},
},
];
}
/** 类型列表的字段 */
export function useTypeGridColumns<T = SystemDictTypeApi.DictType>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '字典编号',
minWidth: 100,
},
{
field: 'name',
title: '字典名称',
minWidth: 200,
},
{
field: 'type',
title: '字典类型',
minWidth: 220,
},
{
field: 'status',
title: '状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'remark',
title: '备注',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
minWidth: 120,
title: '操作',
field: 'operation',
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'type',
nameTitle: '字典类型',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:dict:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:dict:delete']),
},
],
},
},
];
}
// ============================== 字典数据 ==============================
// TODO @芋艿:后续针对 antd增加
/**
*
*/
const colorOptions = [
{ value: '', label: '无' },
{ value: 'processing', label: '主要' },
{ value: 'success', label: '成功' },
{ value: 'default', label: '默认' },
{ value: 'warning', label: '警告' },
{ value: 'error', label: '危险' },
{ value: 'pink', label: 'pink' },
{ value: 'red', label: 'red' },
{ value: 'orange', label: 'orange' },
{ value: 'green', label: 'green' },
{ value: 'cyan', label: 'cyan' },
{ value: 'blue', label: 'blue' },
{ value: 'purple', label: 'purple' },
];
/** 数据新增/修改的表单 */
export function useDataFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'dictType',
label: '字典类型',
component: 'ApiSelect',
componentProps: (values) => {
return {
api: getSimpleDictTypeList,
placeholder: '请输入字典类型',
labelField: 'name',
valueField: 'type',
disabled: !!values.id,
};
},
rules: 'required',
dependencies: {
triggerFields: [''],
},
},
{
fieldName: 'label',
label: '数据标签',
component: 'Input',
componentProps: {
placeholder: '请输入数据标签',
},
rules: 'required',
},
{
fieldName: 'value',
label: '数据键值',
component: 'Input',
componentProps: {
placeholder: '请输入数据键值',
},
rules: 'required',
},
{
fieldName: 'sort',
label: '显示排序',
component: 'InputNumber',
componentProps: {
placeholder: '请输入显示排序',
},
rules: 'required',
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'colorType',
label: '颜色类型',
component: 'Select',
componentProps: {
options: colorOptions,
placeholder: '请选择颜色类型',
},
},
{
fieldName: 'cssClass',
label: 'CSS Class',
component: 'Input',
componentProps: {
placeholder: '请输入 CSS Class',
},
help: '输入 hex 模式的颜色, 例如 #108ee9',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 字典数据列表搜索表单 */
export function useDataGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'label',
label: '字典标签',
component: 'Input',
componentProps: {
placeholder: '请输入字典标签',
clearable: true,
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
clearable: true,
},
},
];
}
/**
*
*/
export function useDataGridColumns<T = SystemDictDataApi.DictData>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '字典编码',
minWidth: 100,
},
{
field: 'label',
title: '字典标签',
minWidth: 180,
},
{
field: 'value',
title: '字典键值',
minWidth: 100,
},
{
field: 'sort',
title: '字典排序',
minWidth: 100,
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'colorType',
title: '颜色类型',
minWidth: 120,
},
{
field: 'cssClass',
title: 'CSS Class',
minWidth: 120,
},
{
title: '创建时间',
field: 'createTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
minWidth: 120,
title: '操作',
field: 'operation',
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'label',
nameTitle: '字典数据',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:dict:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:dict:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,35 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { DocAlert } from '#/components/doc-alert';
import DataGrid from './modules/data-grid.vue';
import TypeGrid from './modules/type-grid.vue';
const searchDictType = ref<string>(); //
function onDictTypeSelect(dictType: string) {
searchDictType.value = dictType;
}
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="字典管理" url="https://doc.iocoder.cn/system-dict/" />
</template>
<div class="flex h-full">
<!-- 左侧字典类型列表 -->
<div class="w-1/2 pr-3">
<TypeGrid @select="onDictTypeSelect" />
</div>
<!-- 右侧字典数据列表 -->
<div class="w-1/2">
<DataGrid :dict-type="searchDictType" />
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,97 @@
<script lang="ts" setup>
import type { SystemDictDataApi } from '#/api/system/dict/data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createDictData,
getDictData,
updateDictData,
} from '#/api/system/dict/data';
import { $t } from '#/locales';
import { useDataFormSchema } from '../data';
defineOptions({ name: 'SystemDictDataForm' });
const emit = defineEmits(['success']);
const formData = ref<SystemDictDataApi.DictData>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['字典数据'])
: $t('ui.actionTitle.create', ['字典数据']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useDataFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as SystemDictDataApi.DictData;
try {
await (formData.value?.id ? updateDictData(data) : createDictData(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<
SystemDictDataApi.DictData | { dictType?: string }
>();
// ID
if (data && 'id' in data && data.id) {
modalApi.lock();
try {
formData.value = await getDictData(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
} else if (data && 'dictType' in data && data.dictType) {
// dictType
await formApi.setValues({
dictType: data.dictType,
});
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,161 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemDictDataApi } from '#/api/system/dict/data';
import { watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDictData,
exportDictData,
getDictDataPage,
} from '#/api/system/dict/data';
import { $t } from '#/locales';
import { useDataGridColumns, useDataGridFormSchema } from '../data';
import DataForm from './data-form.vue';
const props = defineProps({
dictType: {
type: String,
default: undefined,
},
});
const [DataFormModal, dataFormModalApi] = useVbenModal({
connectedComponent: DataForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportDictData(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '字典数据.xls', source: data });
}
/** 创建字典数据 */
function onCreate() {
dataFormModalApi.setData({ dictType: props.dictType }).open();
}
/** 编辑字典数据 */
function onEdit(row: any) {
dataFormModalApi.setData(row).open();
}
/** 删除字典数据 */
async function onDelete(row: any) {
const hideLoading = message.loading({
content: $t('common.processing'),
duration: 0,
key: 'process_message',
});
try {
await deleteDictData(row.id);
message.success({
content: $t('common.operationSuccess'),
key: 'process_message',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 表格操作按钮回调 */
function onActionClick({ code, row }: OnActionClickParams) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useDataGridFormSchema(),
},
gridOptions: {
columns: useDataGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDictDataPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
dictType: props.dictType,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemDictDataApi.DictData>,
});
/** 监听 dictType 变化,重新查询 */
watch(
() => props.dictType,
() => {
if (props.dictType) {
onRefresh();
}
},
);
</script>
<template>
<div class="flex h-full flex-col">
<DataFormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:dict:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['字典数据']) }}
</NButton>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['system:dict:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</div>
</template>

View File

@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { SystemDictTypeApi } from '#/api/system/dict/type';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createDictType,
getDictType,
updateDictType,
} from '#/api/system/dict/type';
import { $t } from '#/locales';
import { useTypeFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemDictTypeApi.DictType>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['字典类型'])
: $t('ui.actionTitle.create', ['字典类型']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useTypeFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as SystemDictTypeApi.DictType;
try {
await (formData.value?.id ? updateDictType(data) : createDictType(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemDictTypeApi.DictType>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDictType(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,156 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeGridListeners,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemDictTypeApi } from '#/api/system/dict/type';
import { useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDictType,
exportDictType,
getDictTypePage,
} from '#/api/system/dict/type';
import { $t } from '#/locales';
import { useTypeGridColumns, useTypeGridFormSchema } from '../data';
import TypeForm from './type-form.vue';
const emit = defineEmits(['select']);
const [TypeFormModal, typeFormModalApi] = useVbenModal({
connectedComponent: TypeForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportDictType(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '字典类型.xls', source: data });
}
/** 创建字典类型 */
function onCreate() {
typeFormModalApi.setData(null).open();
}
/** 编辑字典类型 */
function onEdit(row: any) {
typeFormModalApi.setData(row).open();
}
/** 删除字典类型 */
async function onDelete(row: SystemDictTypeApi.DictType) {
const hideLoading = message.loading({
content: $t('common.processing'),
duration: 0,
key: 'process_message',
});
try {
await deleteDictType(row.id as number);
message.success({
content: $t('common.operationSuccess'),
key: 'process_message',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 表格操作按钮回调 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemDictTypeApi.DictType>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
/** 表格事件 */
const gridEvents: VxeGridListeners<SystemDictTypeApi.DictType> = {
cellClick: ({ row }) => {
emit('select', row.type);
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useTypeGridFormSchema(),
},
gridOptions: {
columns: useTypeGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDictTypePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemDictTypeApi.DictType>,
gridEvents,
});
</script>
<template>
<div class="h-full">
<TypeFormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:dict:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['字典类型']) }}
</NButton>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['system:dict:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</div>
</template>

View File

@ -0,0 +1,116 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemLoginLogApi } from '#/api/system/login-log';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'username',
label: '用户名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入用户名称',
},
},
{
fieldName: 'userIp',
label: '登录地址',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入登录地址',
},
},
{
fieldName: 'createTime',
label: '登录时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemLoginLogApi.LoginLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 100,
},
{
field: 'logType',
title: '操作类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_LOGIN_TYPE },
},
},
{
field: 'username',
title: '用户名称',
minWidth: 180,
},
{
field: 'userIp',
title: '登录地址',
minWidth: 180,
},
{
field: 'userAgent',
title: '浏览器',
minWidth: 200,
},
{
field: 'result',
title: '登录结果',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_LOGIN_RESULT },
},
},
{
field: 'createTime',
title: '登录日期',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 120,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'username',
nameTitle: '登录日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:login-log:query']),
},
],
},
},
];
}

View File

@ -0,0 +1,107 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemLoginLogApi } from '#/api/system/login-log';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportLoginLog, getLoginLogPage } from '#/api/system/login-log';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportLoginLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '登录日志.xls', source: data });
}
/** 查看登录日志详情 */
function onDetail(row: SystemLoginLogApi.LoginLog) {
detailModalApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemLoginLogApi.LoginLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getLoginLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemLoginLogApi.LoginLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="系统日志" url="https://doc.iocoder.cn/system-log/" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['system:login-log:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,80 @@
<script lang="ts" setup>
import type { SystemLoginLogApi } from '#/api/system/login-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { NDescriptions, NDescriptionsItem } from 'naive-ui';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<SystemLoginLogApi.LoginLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemLoginLogApi.LoginLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="登录日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<NDescriptions
bordered
:column="1"
size="middle"
class="mx-4"
:label-style="{ width: '110px' }"
>
<NDescriptionsItem label="日志编号">
{{ formData?.id }}
</NDescriptionsItem>
<NDescriptionsItem label="操作类型">
<DictTag
:type="DICT_TYPE.SYSTEM_LOGIN_TYPE"
:value="formData?.logType"
/>
</NDescriptionsItem>
<NDescriptionsItem label="用户名称">
{{ formData?.username }}
</NDescriptionsItem>
<NDescriptionsItem label="登录地址">
{{ formData?.userIp }}
</NDescriptionsItem>
<NDescriptionsItem label="浏览器">
{{ formData?.userAgent }}
</NDescriptionsItem>
<NDescriptionsItem label="登录结果">
<DictTag
:type="DICT_TYPE.SYSTEM_LOGIN_RESULT"
:value="formData?.result"
/>
</NDescriptionsItem>
<NDescriptionsItem label="登录日期">
{{ formatDateTime(formData?.createTime || '') }}
</NDescriptionsItem>
</NDescriptions>
</Modal>
</template>

View File

@ -0,0 +1,207 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMailAccountApi } from '#/api/system/mail/account';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import { DICT_TYPE, getDictOptions } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'mail',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
},
rules: 'required',
},
{
fieldName: 'username',
label: '用户名',
component: 'Input',
componentProps: {
placeholder: '请输入用户名',
},
rules: 'required',
},
{
fieldName: 'password',
label: '密码',
component: 'InputPassword',
componentProps: {
placeholder: '请输入密码',
},
rules: 'required',
},
{
fieldName: 'host',
label: 'SMTP 服务器域名',
component: 'Input',
componentProps: {
placeholder: '请输入 SMTP 服务器域名',
},
rules: 'required',
},
{
fieldName: 'port',
label: 'SMTP 服务器端口',
component: 'InputNumber',
componentProps: {
placeholder: '请输入 SMTP 服务器端口',
min: 0,
max: 65_535,
},
rules: 'required',
},
{
fieldName: 'sslEnable',
label: '是否开启 SSL',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.boolean().default(true),
},
{
fieldName: 'starttlsEnable',
label: '是否开启 STARTTLS',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.boolean().default(false),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'mail',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
clearable: true,
},
},
{
fieldName: 'username',
label: '用户名',
component: 'Input',
componentProps: {
placeholder: '请输入用户名',
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemMailAccountApi.MailAccount>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'mail',
title: '邮箱',
minWidth: 160,
},
{
field: 'username',
title: '用户名',
minWidth: 160,
},
{
field: 'host',
title: 'SMTP 服务器域名',
minWidth: 150,
},
{
field: 'port',
title: 'SMTP 服务器端口',
minWidth: 130,
},
{
field: 'sslEnable',
title: '是否开启 SSL',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'starttlsEnable',
title: '是否开启 STARTTLS',
minWidth: 145,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'mail',
nameTitle: '邮箱账号',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:mail-account:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:mail-account:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,127 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemMailAccountApi } from '#/api/system/mail/account';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteMailAccount,
getMailAccountPage,
} from '#/api/system/mail/account';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建邮箱账号 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑邮箱账号 */
function onEdit(row: SystemMailAccountApi.MailAccount) {
formModalApi.setData(row).open();
}
/** 删除邮箱账号 */
async function onDelete(row: SystemMailAccountApi.MailAccount) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.mail]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteMailAccount(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.mail]));
onRefresh();
} finally {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemMailAccountApi.MailAccount>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMailAccountPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemMailAccountApi.MailAccount>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="邮件配置" url="https://doc.iocoder.cn/mail" />
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:mail-account:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['邮箱账号']) }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { SystemMailAccountApi } from '#/api/system/mail/account';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createMailAccount,
getMailAccount,
updateMailAccount,
} from '#/api/system/mail/account';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemMailAccountApi.MailAccount>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['邮箱账号'])
: $t('ui.actionTitle.create', ['邮箱账号']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as SystemMailAccountApi.MailAccount;
try {
await (formData.value?.id
? updateMailAccount(data)
: createMailAccount(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemMailAccountApi.MailAccount>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getMailAccount(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,150 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMailLogApi } from '#/api/system/mail/log';
import { useAccess } from '@vben/access';
import { getSimpleMailAccountList } from '#/api/system/mail/account';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'sendTime',
label: '发送时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
allowClear: true,
placeholder: '请选择用户类型',
},
},
{
fieldName: 'sendStatus',
label: '发送状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_MAIL_SEND_STATUS, 'number'),
allowClear: true,
placeholder: '请选择发送状态',
},
},
{
fieldName: 'accountId',
label: '邮箱账号',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleMailAccountList(),
labelField: 'mail',
valueField: 'id',
allowClear: true,
placeholder: '请选择邮箱账号',
},
},
{
fieldName: 'templateId',
label: '模板编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入模板编号',
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemMailLogApi.MailLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'sendTime',
title: '发送时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'toMail',
title: '收件邮箱',
minWidth: 160,
},
{
field: 'templateTitle',
title: '邮件标题',
minWidth: 120,
},
{
field: 'templateContent',
title: '邮件内容',
minWidth: 300,
},
{
field: 'fromMail',
title: '发送邮箱',
minWidth: 120,
},
{
field: 'sendStatus',
title: '发送状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_MAIL_SEND_STATUS },
},
},
{
field: 'templateCode',
title: '模板编码',
minWidth: 120,
},
{
field: 'operation',
title: '操作',
minWidth: 80,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'toMail',
nameTitle: '邮件日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '查看',
show: hasAccessByCodes(['system:mail-log:query']),
},
],
},
},
];
}

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemMailLogApi } from '#/api/system/mail/log';
import { Page, useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getMailLogPage } from '#/api/system/mail/log';
import { DocAlert } from '#/components/doc-alert';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 查看邮件日志 */
function onDetail(row: SystemMailLogApi.MailLog) {
detailModalApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemMailLogApi.MailLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMailLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemMailLogApi.MailLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="邮件配置" url="https://doc.iocoder.cn/mail" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools> </template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,93 @@
<script lang="ts" setup>
import type { SystemMailLogApi } from '#/api/system/mail/log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { NDescriptions, NDescriptionsItem } from 'naive-ui';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<SystemMailLogApi.MailLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemMailLogApi.MailLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="邮件日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<div class="p-4">
<NDescriptions :column="2" bordered :label-style="{ width: '140px' }">
<NDescriptionsItem label="编号">{{ formData?.id }}</NDescriptionsItem>
<NDescriptionsItem label="创建时间">
{{ formatDateTime(formData?.createTime || '') }}
</NDescriptionsItem>
<NDescriptionsItem label="收件邮箱">
{{ formData?.toMail }}
</NDescriptionsItem>
<NDescriptionsItem label="发送邮箱">
{{ formData?.fromMail }}
</NDescriptionsItem>
<NDescriptionsItem label="用户编号">
{{ formData?.userId }}
</NDescriptionsItem>
<NDescriptionsItem label="用户类型">
{{ formData?.userType }}
</NDescriptionsItem>
<NDescriptionsItem label="模板编号">
{{ formData?.templateId }}
</NDescriptionsItem>
<NDescriptionsItem label="模板编码">
{{ formData?.templateCode }}
</NDescriptionsItem>
<NDescriptionsItem label="邮件标题" :span="2">
{{ formData?.templateTitle }}
</NDescriptionsItem>
<NDescriptionsItem label="邮件内容" :span="2">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="formData?.templateContent"></div>
</NDescriptionsItem>
<NDescriptionsItem label="发送状态">
<DictTag
:type="DICT_TYPE.SYSTEM_MAIL_SEND_STATUS"
:value="formData?.sendStatus"
/>
</NDescriptionsItem>
<NDescriptionsItem label="发送时间">
{{ formatDateTime(formData?.sendTime || '') }}
</NDescriptionsItem>
<NDescriptionsItem label="发送消息编号">
{{ formData?.sendMessageId }}
</NDescriptionsItem>
<NDescriptionsItem label="发送异常">
{{ formData?.sendException }}
</NDescriptionsItem>
</NDescriptions>
</div>
</Modal>
</template>

View File

@ -0,0 +1,278 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMailTemplateApi } from '#/api/system/mail/template';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import { getSimpleMailAccountList } from '#/api/system/mail/account';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
} from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
placeholder: '请输入模板名称',
},
rules: 'required',
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
placeholder: '请输入模板编码',
},
rules: 'required',
},
{
fieldName: 'accountId',
label: '邮箱账号',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleMailAccountList(),
labelField: 'mail',
valueField: 'id',
placeholder: '请选择邮箱账号',
},
rules: 'required',
},
{
fieldName: 'nickname',
label: '发送人名称',
component: 'Input',
componentProps: {
placeholder: '请输入发送人名称',
},
},
{
fieldName: 'title',
label: '模板标题',
component: 'Input',
componentProps: {
placeholder: '请输入模板标题',
},
rules: 'required',
},
{
fieldName: 'content',
label: '模板内容',
component: 'Textarea',
componentProps: {
placeholder: '请输入模板内容',
height: 300,
},
rules: 'required',
},
{
fieldName: 'status',
label: '开启状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 发送邮件表单 */
export function useSendMailFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'templateParams',
label: '模板参数',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'content',
label: '模板内容',
component: 'Textarea',
componentProps: {
disabled: true,
},
},
{
fieldName: 'mail',
label: '收件邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入收件邮箱',
},
rules: z.string().email('请输入正确的邮箱'),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'status',
label: '开启状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
allowClear: true,
placeholder: '请选择开启状态',
},
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入模板编码',
},
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入模板名称',
},
},
{
fieldName: 'accountId',
label: '邮箱账号',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleMailAccountList(),
labelField: 'mail',
valueField: 'id',
allowClear: true,
placeholder: '请选择邮箱账号',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemMailTemplateApi.MailTemplate>(
onActionClick: OnActionClickFn<T>,
getAccountMail?: (accountId: number) => string | undefined,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'code',
title: '模板编码',
minWidth: 120,
},
{
field: 'name',
title: '模板名称',
minWidth: 120,
},
{
field: 'title',
title: '模板标题',
minWidth: 120,
},
{
field: 'accountId',
title: '邮箱账号',
minWidth: 120,
formatter: (row) => getAccountMail?.(row.cellValue) || '-',
},
{
field: 'nickname',
title: '发送人名称',
minWidth: 120,
},
{
field: 'status',
title: '开启状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 150,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '邮件模板',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:mail-template:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:mail-template:delete']),
},
{
code: 'send',
text: '测试',
show: hasAccessByCodes(['system:mail-template:send-mail']),
},
],
},
},
];
}

View File

@ -0,0 +1,159 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemMailAccountApi } from '#/api/system/mail/account';
import type { SystemMailTemplateApi } from '#/api/system/mail/template';
import { onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSimpleMailAccountList } from '#/api/system/mail/account';
import {
deleteMailTemplate,
getMailTemplatePage,
} from '#/api/system/mail/template';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import SendForm from './modules/send-form.vue';
const accountList = ref<SystemMailAccountApi.MailAccount[]>([]);
/** 获取邮箱账号 */
const getAccountMail = (accountId: number) => {
return accountList.value.find((account) => account.id === accountId)?.mail;
};
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [SendModal, sendModalApi] = useVbenModal({
connectedComponent: SendForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建邮件模板 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑邮件模板 */
function onEdit(row: SystemMailTemplateApi.MailTemplate) {
formModalApi.setData(row).open();
}
/** 发送测试邮件 */
function onSend(row: SystemMailTemplateApi.MailTemplate) {
sendModalApi.setData(row).open();
}
/** 删除邮件模板 */
async function onDelete(row: SystemMailTemplateApi.MailTemplate) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteMailTemplate(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemMailTemplateApi.MailTemplate>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
case 'send': {
onSend(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick, getAccountMail),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMailTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemMailTemplateApi.MailTemplate>,
});
/** 初始化 */
onMounted(async () => {
accountList.value = await getSimpleMailAccountList();
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="邮件配置" url="https://doc.iocoder.cn/mail" />
</template>
<FormModal @success="onRefresh" />
<SendModal />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:mail-template:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['邮件模板']) }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { SystemMailTemplateApi } from '#/api/system/mail/template';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createMailTemplate,
getMailTemplate,
updateMailTemplate,
} from '#/api/system/mail/template';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemMailTemplateApi.MailTemplate>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['邮件模板'])
: $t('ui.actionTitle.create', ['邮件模板']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as SystemMailTemplateApi.MailTemplate;
try {
await (formData.value?.id
? updateMailTemplate(data)
: createMailTemplate(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemMailTemplateApi.MailTemplate>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getMailTemplate(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,108 @@
<script lang="ts" setup>
import type { SystemMailTemplateApi } from '#/api/system/mail/template';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { sendMail } from '#/api/system/mail/template';
import { useSendMailFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemMailTemplateApi.MailTemplate>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const values = await formApi.getValues();
const paramsObj: Record<string, string> = {};
if (formData.value?.params) {
formData.value.params.forEach((param: string) => {
paramsObj[param] = values[`param_${param}`];
});
}
const data: SystemMailTemplateApi.MailSendReqVO = {
mail: values.mail,
templateCode: formData.value?.code || '',
templateParams: paramsObj,
};
//
try {
await sendMail(data);
//
await modalApi.close();
emit('success');
message.success('邮件发送成功');
} catch (error) {
console.error('发送邮件失败', error);
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemMailTemplateApi.MailTemplate>();
if (!data) {
return;
}
formData.value = data;
// form schema
const schema = buildFormSchema();
formApi.setState({ schema });
// values
await formApi.setValues({
content: data.content,
});
},
});
/** 动态构建表单 schema */
const buildFormSchema = () => {
const schema = useSendMailFormSchema();
if (formData.value?.params) {
formData.value.params?.forEach((param: string) => {
schema.push({
fieldName: `param_${param}`,
label: `参数 ${param}`,
component: 'Input',
componentProps: {
placeholder: `请输入参数 ${param}`,
},
rules: 'required',
});
});
}
return schema;
};
</script>
<template>
<Modal title="测试发送邮件">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,355 @@
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMenuApi } from '#/api/system/menu';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { IconifyIcon } from '@vben/icons';
import { handleTree, isHttpUrl } from '@vben/utils';
import { z } from '#/adapter/form';
import { getMenuList } from '#/api/system/menu';
import { $t } from '#/locales';
import { componentKeys } from '#/router/routes';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
SystemMenuTypeEnum,
} from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'parentId',
label: '上级菜单',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getMenuList();
data.unshift({
id: 0,
name: '顶级部门',
} as SystemMenuApi.Menu);
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级菜单',
filterTreeNode(input: string, node: Recordable<any>) {
if (!input || input.length === 0) {
return true;
}
const name: string = node.label ?? '';
if (!name) return false;
return name.includes(input) || $t(name).includes(input);
},
showSearch: true,
treeDefaultExpandedKeys: [0],
},
rules: 'selectRequired',
renderComponentContent() {
return {
title({ label, icon }: { icon: string; label: string }) {
const components = [];
if (!label) return '';
if (icon) {
components.push(h(IconifyIcon, { class: 'size-4', icon }));
}
components.push(h('span', { class: '' }, $t(label || '')));
return h('div', { class: 'flex items-center gap-1' }, components);
},
};
},
},
{
fieldName: 'name',
label: '菜单名称',
component: 'Input',
componentProps: {
placeholder: '请输入菜单名称',
},
rules: 'required',
},
{
fieldName: 'type',
label: '菜单类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_MENU_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(SystemMenuTypeEnum.DIR),
},
{
fieldName: 'icon',
label: '菜单图标',
component: 'IconPicker',
componentProps: {
placeholder: '请选择菜单图标',
prefix: 'carbon',
},
rules: 'required',
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.DIR, SystemMenuTypeEnum.MENU].includes(
values.type,
);
},
},
},
{
fieldName: 'path',
label: '路由地址',
component: 'Input',
componentProps: {
placeholder: '请输入路由地址',
},
rules: z.string(),
help: '访问的路由地址,如:`user`。如需外网地址时,则以 `http(s)://` 开头',
dependencies: {
triggerFields: ['type', 'parentId'],
show: (values) => {
return [SystemMenuTypeEnum.DIR, SystemMenuTypeEnum.MENU].includes(
values.type,
);
},
rules: (values) => {
const schema = z.string().min(1, '路由地址不能为空');
if (isHttpUrl(values.path)) {
return schema;
}
if (values.parentId === 0) {
return schema.refine(
(path) => path.charAt(0) === '/',
'路径必须以 / 开头',
);
}
return schema.refine(
(path) => path.charAt(0) !== '/',
'路径不能以 / 开头',
);
},
},
},
{
fieldName: 'component',
label: '组件地址',
component: 'Input',
componentProps: {
placeholder: '请输入组件地址',
},
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.MENU].includes(values.type);
},
},
},
{
fieldName: 'componentName',
label: '组件名称',
component: 'AutoComplete',
componentProps: {
allowClear: true,
filterOption(input: string, option: { value: string }) {
return option.value.toLowerCase().includes(input.toLowerCase());
},
placeholder: '请选择组件名称',
options: componentKeys.map((v) => ({ value: v })),
},
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.MENU].includes(values.type);
},
},
},
{
fieldName: 'permission',
label: '权限标识',
component: 'Input',
componentProps: {
placeholder: '请输入菜单描述',
},
dependencies: {
show: (values) => {
return [SystemMenuTypeEnum.BUTTON, SystemMenuTypeEnum.MENU].includes(
values.type,
);
},
triggerFields: ['type'],
},
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入显示顺序',
},
rules: 'required',
},
{
fieldName: 'status',
label: '菜单状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'alwaysShow',
label: '总是显示',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '总是', value: true },
{ label: '不是', value: false },
],
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
defaultValue: true,
help: '选择不是时,当该菜单只有一个子菜单时,不展示自己,直接展示子菜单',
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.MENU].includes(values.type);
},
},
},
{
fieldName: 'keepAlive',
label: '缓存状态',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '缓存', value: true },
{ label: '不缓存', value: false },
],
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
defaultValue: true,
help: '选择缓存时,则会被 `keep-alive` 缓存,必须填写「组件名称」字段',
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.MENU].includes(values.type);
},
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onActionClick: OnActionClickFn<SystemMenuApi.Menu>,
): VxeTableGridOptions<SystemMenuApi.Menu>['columns'] {
return [
{
field: 'name',
title: '菜单名称',
minWidth: 250,
align: 'left',
fixed: 'left',
slots: { default: 'name' },
treeNode: true,
},
{
field: 'type',
title: '菜单类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_MENU_TYPE },
},
},
{
field: 'sort',
title: '显示排序',
minWidth: 100,
},
{
field: 'permission',
title: '权限标识',
minWidth: 200,
},
{
field: 'path',
title: '组件路径',
minWidth: 200,
},
{
field: 'componentName',
minWidth: 200,
title: '组件名称',
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'operation',
title: '操作',
align: 'right',
minWidth: 200,
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'name',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
text: '新增下级',
show: hasAccessByCodes(['system:menu:create']),
},
{
code: 'edit',
show: hasAccessByCodes(['system:menu:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:menu:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,167 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemMenuApi } from '#/api/system/menu';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon, Plus } from '@vben/icons';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMenu, getMenuList } from '#/api/system/menu';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { SystemMenuTypeEnum } from '#/utils';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建菜单 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 添加下级菜单 */
function onAppend(row: SystemMenuApi.Menu) {
formModalApi.setData({ pid: row.id }).open();
}
/** 编辑菜单 */
function onEdit(row: SystemMenuApi.Menu) {
formModalApi.setData(row).open();
}
/** 删除菜单 */
async function onDelete(row: SystemMenuApi.Menu) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteMenu(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({ code, row }: OnActionClickParams<SystemMenuApi.Menu>) {
switch (code) {
case 'append': {
onAppend(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
/** 切换树形展开/收缩状态 */
const isExpanded = ref(false);
function toggleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid.setAllTreeExpand(isExpanded.value);
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getMenuList();
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
reserve: true,
},
} as VxeTableGridOptions,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="功能权限"
url="https://doc.iocoder.cn/resource-permission"
/>
<DocAlert title="菜单路由" url="https://doc.iocoder.cn/vue3/route/" />
</template>
<FormModal @success="onRefresh" />
<Grid>
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:menu:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['菜单']) }}
</NButton>
<NButton class="ml-2" @click="toggleExpand">
{{ isExpanded ? '收缩' : '展开' }}
</NButton>
</template>
<template #name="{ row }">
<div class="flex w-full items-center gap-1">
<div class="size-5 flex-shrink-0">
<IconifyIcon
v-if="row.type === SystemMenuTypeEnum.BUTTON"
icon="carbon:square-outline"
class="size-full"
/>
<IconifyIcon
v-else-if="row.icon"
:icon="row.icon || 'carbon:circle-dash'"
class="size-full"
/>
</div>
<span class="flex-auto">{{ $t(row.name) }}</span>
<div class="items-center justify-end"></div>
</div>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { SystemMenuApi } from '#/api/system/menu';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createMenu, getMenu, updateMenu } from '#/api/system/menu';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemMenuApi.Menu>();
const getTitle = computed(() =>
formData.value?.id
? $t('ui.actionTitle.edit', ['菜单'])
: $t('ui.actionTitle.create', ['菜单']),
);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as SystemMenuApi.Menu;
try {
await (formData.value?.id ? updateMenu(data) : createMenu(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
let data = modalApi.getData<SystemMenuApi.Menu>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getMenu(data.id as number);
} finally {
modalApi.unlock();
}
}
// values
formData.value = data;
await formApi.setValues(formData.value);
},
});
</script>
<template>
<Modal class="w-[40%]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,163 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNoticeApi } from '#/api/system/notice';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'title',
label: '公告标题',
component: 'Input',
rules: 'required',
},
{
fieldName: 'type',
label: '公告类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_NOTICE_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
{
fieldName: 'content',
label: '公告内容',
component: 'RichTextarea',
rules: 'required',
},
{
fieldName: 'status',
label: '公告状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'title',
label: '公告标题',
component: 'Input',
componentProps: {
placeholder: '请输入公告标题',
allowClear: true,
},
},
{
fieldName: 'status',
label: '公告状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择公告状态',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemNoticeApi.Notice>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '公告编号',
minWidth: 100,
},
{
field: 'title',
title: '公告标题',
minWidth: 200,
},
{
field: 'type',
title: '公告类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_NOTICE_TYPE },
},
},
{
field: 'status',
title: '公告状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 180,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'title',
nameTitle: '公告',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:notice:update']),
},
{
code: 'push',
text: '推送',
show: hasAccessByCodes(['system:notice:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:notice:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,139 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemNoticeApi } from '#/api/system/notice';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteNotice, getNoticePage, pushNotice } from '#/api/system/notice';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建公告 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑公告 */
function onEdit(row: SystemNoticeApi.Notice) {
formModalApi.setData(row).open();
}
/** 删除公告 */
async function onDelete(row: SystemNoticeApi.Notice) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.title]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteNotice(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.title]));
onRefresh();
} catch {
hideLoading();
}
}
/** 推送公告 */
async function onPush(row: SystemNoticeApi.Notice) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.processing', ['推送']),
duration: 0,
key: 'action_process_msg',
});
try {
await pushNotice(row.id as number);
message.success($t('ui.actionMessage.operationSuccess'));
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemNoticeApi.Notice>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
case 'push': {
onPush(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getNoticePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemNoticeApi.Notice>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:notice:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['公告']) }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { SystemNoticeApi } from '#/api/system/notice';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createNotice, getNotice, updateNotice } from '#/api/system/notice';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemNoticeApi.Notice>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['公告'])
: $t('ui.actionTitle.create', ['公告']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as SystemNoticeApi.Notice;
try {
await (formData.value?.id ? updateNotice(data) : createNotice(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemNoticeApi.Notice>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getNotice(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/2">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,171 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
placeholder: '请选择用户类型',
},
},
{
fieldName: 'templateCode',
label: '模板编码',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入模板编码',
},
},
{
fieldName: 'templateType',
label: '模版类型',
component: 'Select',
componentProps: {
options: getDictOptions(
DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
'number',
),
allowClear: true,
placeholder: '请选择模版类型',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemNotifyMessageApi.NotifyMessage>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'userType',
title: '用户类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'userId',
title: '用户编号',
minWidth: 100,
},
{
field: 'templateCode',
title: '模板编码',
minWidth: 120,
},
{
field: 'templateNickname',
title: '发送人名称',
minWidth: 180,
},
{
field: 'templateContent',
title: '模版内容',
minWidth: 200,
},
{
field: 'templateParams',
title: '模版参数',
minWidth: 180,
formatter: ({ cellValue }) => {
try {
return JSON.stringify(cellValue);
} catch {
return '';
}
},
},
{
field: 'templateType',
title: '模版类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE },
},
},
{
field: 'readStatus',
title: '是否已读',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'readTime',
title: '阅读时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 180,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '站内信',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:notify-message:query']),
},
],
},
},
];
}

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { Page, useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getNotifyMessagePage } from '#/api/system/notify/message';
import { DocAlert } from '#/components/doc-alert';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 查看站内信详情 */
function onDetail(row: SystemNotifyMessageApi.NotifyMessage) {
detailModalApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemNotifyMessageApi.NotifyMessage>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getNotifyMessagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemNotifyMessageApi.NotifyMessage>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="短信配置" url="https://doc.iocoder.cn/sms/" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="" />
</Page>
</template>

View File

@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { NDescriptions, NDescriptionsItem } from 'naive-ui';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<SystemNotifyMessageApi.NotifyMessage>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemNotifyMessageApi.NotifyMessage>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="站内信详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<NDescriptions bordered :column="1" size="middle" class="mx-4">
<NDescriptionsItem label="编号">{{ formData?.id }}</NDescriptionsItem>
<NDescriptionsItem label="用户类型">
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
</NDescriptionsItem>
<NDescriptionsItem label="用户编号">
{{ formData?.userId }}
</NDescriptionsItem>
<NDescriptionsItem label="模版编号">
{{ formData?.templateId }}
</NDescriptionsItem>
<NDescriptionsItem label="模板编码">
{{ formData?.templateCode }}
</NDescriptionsItem>
<NDescriptionsItem label="发送人名称">
{{ formData?.templateNickname }}
</NDescriptionsItem>
<NDescriptionsItem label="模版内容">
{{ formData?.templateContent }}
</NDescriptionsItem>
<NDescriptionsItem label="模版参数">
{{ formData?.templateParams }}
</NDescriptionsItem>
<NDescriptionsItem label="模版类型">
<DictTag
:type="DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE"
:value="formData?.templateType"
/>
</NDescriptionsItem>
<NDescriptionsItem label="是否已读">
<DictTag
:type="DICT_TYPE.INFRA_BOOLEAN_STRING"
:value="formData?.readStatus"
/>
</NDescriptionsItem>
<NDescriptionsItem label="阅读时间">
{{ formatDateTime(formData?.readTime || '') }}
</NDescriptionsItem>
<NDescriptionsItem label="创建时间">
{{ formatDateTime(formData?.createTime || '') }}
</NDescriptionsItem>
</NDescriptions>
</Modal>
</template>

View File

@ -0,0 +1,157 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'readStatus',
label: '是否已读',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
allowClear: true,
placeholder: '请选择是否已读',
},
},
{
fieldName: 'createTime',
label: '发送时间',
component: 'RangePicker',
componentProps: {
allowClear: true,
...getRangePickerDefaultProps(),
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemNotifyMessageApi.NotifyMessage>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
title: '',
width: 40,
type: 'checkbox',
},
{
field: 'templateNickname',
title: '发送人',
minWidth: 180,
},
{
field: 'createTime',
title: '发送时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'templateType',
title: '类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE },
},
},
{
field: 'templateContent',
title: '消息内容',
minWidth: 300,
},
{
field: 'readStatus',
title: '是否已读',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'readTime',
title: '阅读时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 180,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '站内信',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '查看',
show: (row: any) => row.readStatus,
},
{
code: 'read',
text: '已读',
show: (row: any) => !row.readStatus,
},
],
},
},
];
}
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'templateNickname',
label: '发送人',
},
{
field: 'createTime',
label: '发送时间',
content: (data) => formatDateTime(data?.createTime) as string,
},
{
field: 'templateType',
label: '消息类型',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
value: data?.templateType,
}),
},
{
field: 'readStatus',
label: '是否已读',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.INFRA_BOOLEAN_STRING,
value: data?.readStatus,
}),
},
{
field: 'readTime',
label: '阅读时间',
content: (data) => formatDateTime(data?.readTime) as string,
},
{
field: 'templateContent',
label: '消息内容',
},
];
}

View File

@ -0,0 +1,165 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { Page, useVbenModal } from '@vben/common-ui';
import { MdiCheckboxMarkedCircleOutline } from '@vben/icons';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getMyNotifyMessagePage,
updateAllNotifyMessageRead,
updateNotifyMessageRead,
} from '#/api/system/notify/message';
import { DocAlert } from '#/components/doc-alert';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 查看站内信详情 */
function onDetail(row: SystemNotifyMessageApi.NotifyMessage) {
detailModalApi.setData(row).open();
}
/** 标记一条站内信已读 */
async function onRead(row: SystemNotifyMessageApi.NotifyMessage) {
message.loading({
content: '正在标记已读...',
duration: 0,
key: 'action_process_msg',
});
//
await updateNotifyMessageRead([row.id]);
//
message.success('标记已读成功');
onRefresh();
//
onDetail(row);
}
/** 标记选中的站内信为已读 */
async function onMarkRead() {
const rows = gridApi.grid.getCheckboxRecords();
if (!rows || rows.length === 0) {
message.warning('请选择需要标记的站内信');
return;
}
const ids = rows.map((row: SystemNotifyMessageApi.NotifyMessage) => row.id);
message.loading({
content: '正在标记已读...',
duration: 0,
key: 'action_process_msg',
});
//
await updateNotifyMessageRead(ids);
//
message.success('标记已读成功');
await gridApi.grid.setAllCheckboxRow(false);
onRefresh();
}
/** 标记所有站内信为已读 */
async function onMarkAllRead() {
message.loading({
content: '正在标记全部已读...',
duration: 0,
key: 'action_process_msg',
});
//
await updateAllNotifyMessageRead();
//
message.success('全部标记已读成功');
await gridApi.grid.setAllCheckboxRow(false);
onRefresh();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemNotifyMessageApi.NotifyMessage>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
case 'read': {
onRead(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMyNotifyMessagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
checkboxConfig: {
checkMethod: (params: { row: SystemNotifyMessageApi.NotifyMessage }) =>
!params.row.readStatus,
highlight: true,
},
} as VxeTableGridOptions<SystemNotifyMessageApi.NotifyMessage>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="站内信配置" url="https://doc.iocoder.cn/notify/" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton type="primary" @click="onMarkRead">
<MdiCheckboxMarkedCircleOutline />
标记已读
</NButton>
<NButton type="primary" class="ml-2" @click="onMarkAllRead">
<MdiCheckboxMarkedCircleOutline />
全部已读
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const [Description, descApi] = useDescription({
componentProps: {
bordered: true,
column: 1,
size: 'middle',
class: 'mx-4',
},
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
//
const data = modalApi.getData<SystemNotifyMessageApi.NotifyMessage>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
descApi.setState({ data });
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="消息详情"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Description />
</Modal>
</template>

View File

@ -0,0 +1,321 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNotifyTemplateApi } from '#/api/system/notify/template';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
UserTypeEnum,
} from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
placeholder: '请输入模板名称',
},
rules: 'required',
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
placeholder: '请输入模板编码',
},
rules: 'required',
},
{
fieldName: 'nickname',
label: '发送人名称',
component: 'Input',
componentProps: {
placeholder: '请输入发送人名称',
},
rules: 'required',
},
{
fieldName: 'content',
label: '模板内容',
component: 'Textarea',
componentProps: {
placeholder: '请输入模板内容',
},
rules: 'required',
},
{
fieldName: 'type',
label: '模板类型',
component: 'Select',
componentProps: {
options: getDictOptions(
DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
'number',
),
placeholder: '请选择模板类型',
},
rules: 'required',
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入模板名称',
},
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入模板编码',
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
allowClear: true,
placeholder: '请选择状态',
},
},
{
fieldName: 'type',
label: '模板类型',
component: 'Select',
componentProps: {
options: getDictOptions(
DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
'number',
),
allowClear: true,
placeholder: '请选择模板类型',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 发送站内信表单 */
export function useSendNotifyFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'content',
label: '模板内容',
component: 'Textarea',
componentProps: {
disabled: true,
},
},
{
fieldName: 'templateCode',
label: '模板编码',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
},
rules: z.number().default(UserTypeEnum.MEMBER),
},
{
fieldName: 'userId',
label: '接收人ID',
component: 'Input',
componentProps: {
placeholder: '请输入用户编号',
},
dependencies: {
show(values) {
return values.userType === UserTypeEnum.MEMBER;
},
triggerFields: ['userType'],
},
rules: 'required',
},
{
fieldName: 'userId',
label: '接收人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择接收人',
},
dependencies: {
show(values) {
return values.userType === UserTypeEnum.ADMIN;
},
triggerFields: ['userType'],
},
rules: 'required',
},
{
fieldName: 'templateParams',
label: '模板参数',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemNotifyTemplateApi.NotifyTemplate>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'name',
title: '模板名称',
minWidth: 120,
},
{
field: 'code',
title: '模板编码',
minWidth: 120,
},
{
field: 'nickname',
title: '发送人名称',
minWidth: 120,
},
{
field: 'content',
title: '模板内容',
minWidth: 200,
},
{
field: 'type',
title: '模板类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE },
},
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'remark',
title: '备注',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 180,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '站内信模板',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:notify-template:update']),
},
{
code: 'send',
text: '测试',
show: hasAccessByCodes(['system:notify-template:send-notify']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:notify-template:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,161 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemNotifyTemplateApi } from '#/api/system/notify/template';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteNotifyTemplate,
exportNotifyTemplate,
getNotifyTemplatePage,
} from '#/api/system/notify/template';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import SendForm from './modules/send-form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [SendModal, sendModalApi] = useVbenModal({
connectedComponent: SendForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportNotifyTemplate(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '站内信模板.xls', source: data });
}
/** 创建站内信模板 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑站内信模板 */
function onEdit(row: SystemNotifyTemplateApi.NotifyTemplate) {
formModalApi.setData(row).open();
}
/** 发送测试站内信 */
function onSend(row: SystemNotifyTemplateApi.NotifyTemplate) {
sendModalApi.setData(row).open();
}
/** 删除站内信模板 */
async function onDelete(row: SystemNotifyTemplateApi.NotifyTemplate) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteNotifyTemplate(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemNotifyTemplateApi.NotifyTemplate>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
case 'send': {
onSend(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getNotifyTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemNotifyTemplateApi.NotifyTemplate>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="短信配置" url="https://doc.iocoder.cn/sms/" />
</template>
<FormModal @success="onRefresh" />
<SendModal />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:notify-template:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['站内信模板']) }}
</NButton>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['system:notify-template:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { SystemNotifyTemplateApi } from '#/api/system/notify/template';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createNotifyTemplate,
getNotifyTemplate,
updateNotifyTemplate,
} from '#/api/system/notify/template';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemNotifyTemplateApi.NotifyTemplate>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['站内信模板'])
: $t('ui.actionTitle.create', ['站内信模板']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as SystemNotifyTemplateApi.NotifyTemplate;
try {
await (formData.value?.id
? updateNotifyTemplate(data)
: createNotifyTemplate(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemNotifyTemplateApi.NotifyTemplate>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getNotifyTemplate(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,111 @@
<script lang="ts" setup>
import type { SystemNotifyTemplateApi } from '#/api/system/notify/template';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { sendNotify } from '#/api/system/notify/template';
import { $t } from '#/locales';
import { useSendNotifyFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemNotifyTemplateApi.NotifyTemplate>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const values = await formApi.getValues();
const paramsObj: Record<string, string> = {};
if (formData.value?.params) {
formData.value.params.forEach((param) => {
paramsObj[param] = values[`param_${param}`];
});
}
const data: SystemNotifyTemplateApi.NotifySendReqVO = {
userId: values.userId,
userType: values.userType,
templateCode: formData.value?.code || '',
templateParams: paramsObj,
};
//
try {
await sendNotify(data);
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error('发送站内信失败', error);
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemNotifyTemplateApi.NotifyTemplate>();
if (!data || !data.id) {
return;
}
formData.value = data;
// form schema
const schema = buildFormSchema();
formApi.setState({ schema });
// values
await formApi.setValues({
content: data.content,
templateCode: data.code,
});
},
});
/** 动态构建表单 schema */
const buildFormSchema = () => {
const schema = useSendNotifyFormSchema();
if (formData.value?.params) {
formData.value.params.forEach((param: string) => {
schema.push({
fieldName: `param_${param}`,
label: `参数 ${param}`,
component: 'Input',
rules: 'required',
componentProps: {
placeholder: `请输入参数 ${param}`,
},
});
});
}
return schema;
};
</script>
<template>
<Modal title="测试发送站内信">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,279 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemOAuth2ClientApi } from '#/api/system/oauth2/client';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'clientId',
label: '客户端编号',
component: 'Input',
componentProps: {
placeholder: '请输入客户端编号',
},
rules: 'required',
},
{
fieldName: 'secret',
label: '客户端密钥',
component: 'Input',
componentProps: {
placeholder: '请输入客户端密钥',
},
rules: 'required',
},
{
fieldName: 'name',
label: '应用名',
component: 'Input',
componentProps: {
placeholder: '请输入应用名',
},
rules: 'required',
},
{
fieldName: 'logo',
label: '应用图标',
component: 'ImageUpload',
componentProps: {
limit: 1,
},
rules: 'required',
},
{
fieldName: 'description',
label: '应用描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入应用描述',
},
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'accessTokenValiditySeconds',
label: '访问令牌的有效期',
component: 'InputNumber',
componentProps: {
placeholder: '请输入访问令牌的有效期,单位:秒',
min: 0,
controlsPosition: 'right',
},
rules: 'required',
},
{
fieldName: 'refreshTokenValiditySeconds',
label: '刷新令牌的有效期',
component: 'InputNumber',
componentProps: {
placeholder: '请输入刷新令牌的有效期,单位:秒',
min: 0,
controlsPosition: 'right',
},
rules: 'required',
},
{
fieldName: 'authorizedGrantTypes',
label: '授权类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_OAUTH2_GRANT_TYPE),
mode: 'multiple',
placeholder: '请输入授权类型',
},
rules: 'required',
},
{
fieldName: 'scopes',
label: '授权范围',
component: 'Select',
componentProps: {
placeholder: '请输入授权范围',
mode: 'tags',
},
},
{
fieldName: 'autoApproveScopes',
label: '自动授权范围',
component: 'Select',
componentProps: {
placeholder: '请输入自动授权范围',
mode: 'multiple',
// TODO @芋艿:根据权限,自动授权范围
},
},
{
fieldName: 'redirectUris',
label: '可重定向的 URI 地址',
component: 'Select',
componentProps: {
placeholder: '请输入可重定向的 URI 地址',
mode: 'tags',
},
rules: 'required',
},
{
fieldName: 'authorities',
label: '权限',
component: 'Select',
componentProps: {
placeholder: '请输入权限',
mode: 'tags',
},
},
{
fieldName: 'resourceIds',
label: '资源',
component: 'Select',
componentProps: {
mode: 'tags',
placeholder: '请输入资源',
},
},
{
fieldName: 'additionalInformation',
label: '附加信息',
component: 'Textarea',
componentProps: {
placeholder: '请输入附加信息JSON 格式数据',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '应用名',
component: 'Input',
componentProps: {
placeholder: '请输入应用名',
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
allowClear: true,
placeholder: '请输入状态',
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemOAuth2ClientApi.OAuth2Client>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'clientId',
title: '客户端编号',
minWidth: 200,
},
{
field: 'secret',
title: '客户端密钥',
minWidth: 120,
},
{
field: 'name',
title: '应用名',
minWidth: 300,
},
{
field: 'logo',
title: '应用图标',
minWidth: 80,
cellRender: {
name: 'CellImage',
},
},
{
field: 'status',
title: '状态',
minWidth: 80,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'accessTokenValiditySeconds',
title: '访问令牌的有效期',
minWidth: 130,
formatter: ({ cellValue }) => `${cellValue}`,
},
{
field: 'refreshTokenValiditySeconds',
title: '刷新令牌的有效期',
minWidth: 130,
formatter: ({ cellValue }) => `${cellValue}`,
},
{
field: 'authorizedGrantTypes',
title: '授权类型',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: 'OAuth2 客户端',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:oauth2-client:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:oauth2-client:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,131 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemOAuth2ClientApi } from '#/api/system/oauth2/client';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteOAuth2Client,
getOAuth2ClientPage,
} from '#/api/system/oauth2/client';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 OAuth2 客户端 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑 OAuth2 客户端 */
function onEdit(row: SystemOAuth2ClientApi.OAuth2Client) {
formModalApi.setData(row).open();
}
/** 删除 OAuth2 客户端 */
async function onDelete(row: SystemOAuth2ClientApi.OAuth2Client) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteOAuth2Client(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemOAuth2ClientApi.OAuth2Client>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOAuth2ClientPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemOAuth2ClientApi.OAuth2Client>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="OAuth 2.0SSO 单点登录)"
url="https://doc.iocoder.cn/oauth2/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="OAuth2 ">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:oauth2-client:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [' OAuth2.0 客户端']) }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { SystemOAuth2ClientApi } from '#/api/system/oauth2/client';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createOAuth2Client,
getOAuth2Client,
updateOAuth2Client,
} from '#/api/system/oauth2/client';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemOAuth2ClientApi.OAuth2Client>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [' OAuth2.0 客户端'])
: $t('ui.actionTitle.create', [' OAuth2.0 客户端']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as SystemOAuth2ClientApi.OAuth2Client;
try {
await (formData.value?.id
? updateOAuth2Client(data)
: createOAuth2Client(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemOAuth2ClientApi.OAuth2Client>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getOAuth2Client(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,111 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemOAuth2TokenApi } from '#/api/system/oauth2/token';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
placeholder: '请选择用户类型',
},
},
{
fieldName: 'clientId',
label: '客户端编号',
component: 'Input',
componentProps: {
placeholder: '请输入客户端编号',
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemOAuth2TokenApi.OAuth2Token>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'accessToken',
title: '访问令牌',
minWidth: 300,
},
{
field: 'refreshToken',
title: '刷新令牌',
minWidth: 300,
},
{
field: 'userId',
title: '用户编号',
minWidth: 100,
},
{
field: 'userType',
title: '用户类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'clientId',
title: '客户端编号',
minWidth: 120,
},
{
field: 'expiresTime',
title: '过期时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 100,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'accessToken',
nameTitle: 'OAuth2 令牌',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'delete',
text: '强退',
show: hasAccessByCodes(['system:oauth2-token:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,96 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemOAuth2TokenApi } from '#/api/system/oauth2/token';
import { Page } from '@vben/common-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteOAuth2Token,
getOAuth2TokenPage,
} from '#/api/system/oauth2/token';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 删除 OAuth2 令牌 */
async function onDelete(row: SystemOAuth2TokenApi.OAuth2Token) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', ['令牌']),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteOAuth2Token(row.accessToken);
message.success($t('ui.actionMessage.operationSuccess'));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemOAuth2TokenApi.OAuth2Token>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOAuth2TokenPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemOAuth2TokenApi.OAuth2Token>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="OAuth 2.0SSO 单点登录)"
url="https://doc.iocoder.cn/oauth2/"
/>
</template>
<Grid table-title="" />
</Page>
</template>

View File

@ -0,0 +1,146 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { useAccess } from '@vben/access';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '操作人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
fieldNames: {
label: 'nickname',
value: 'id',
},
allowClear: true,
placeholder: '请选择操作人员',
},
},
{
fieldName: 'type',
label: '操作模块',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入操作模块',
},
},
{
fieldName: 'subType',
label: '操作名',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入操作名',
},
},
{
fieldName: 'action',
label: '操作内容',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入操作内容',
},
},
{
fieldName: 'createTime',
label: '操作时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
{
fieldName: 'bizId',
label: '业务编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入业务编号',
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemOperateLogApi.OperateLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 100,
},
{
field: 'userName',
title: '操作人',
minWidth: 120,
},
{
field: 'type',
title: '操作模块',
minWidth: 120,
},
{
field: 'subType',
title: '操作名',
minWidth: 160,
},
{
field: 'action',
title: '操作内容',
minWidth: 200,
},
{
field: 'createTime',
title: '操作时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'bizId',
title: '业务编号',
minWidth: 120,
},
{
field: 'userIp',
title: '操作IP',
minWidth: 120,
},
{
field: 'operation',
title: '操作',
minWidth: 120,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'action',
nameTitle: '操作日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详情',
show: hasAccessByCodes(['system:operate-log:query']),
},
],
},
},
];
}

View File

@ -0,0 +1,107 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportOperateLog, getOperateLogPage } from '#/api/system/operate-log';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportOperateLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '操作日志.xls', source: data });
}
/** 查看操作日志详情 */
function onDetail(row: SystemOperateLogApi.OperateLog) {
detailModalApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<SystemOperateLogApi.OperateLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOperateLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemOperateLogApi.OperateLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="系统日志" url="https://doc.iocoder.cn/system-log/" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['system:operate-log:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { NDescriptions, NDescriptionsItem } from 'naive-ui';
const formData = ref<SystemOperateLogApi.OperateLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemOperateLogApi.OperateLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="操作日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<NDescriptions
bordered
:column="1"
size="middle"
class="mx-4"
:label-style="{ width: '110px' }"
>
<NDescriptionsItem label="日志编号">
{{ formData?.id }}
</NDescriptionsItem>
<NDescriptionsItem label="链路追踪" v-if="formData?.traceId">
{{ formData?.traceId }}
</NDescriptionsItem>
<NDescriptionsItem label="操作人编号">
{{ formData?.userId }}
</NDescriptionsItem>
<NDescriptionsItem label="操作人名字">
{{ formData?.userName }}
</NDescriptionsItem>
<NDescriptionsItem label="操作人IP">
{{ formData?.userIp }}
</NDescriptionsItem>
<NDescriptionsItem label="操作人UA">
{{ formData?.userAgent }}
</NDescriptionsItem>
<NDescriptionsItem label="操作模块">
{{ formData?.type }}
</NDescriptionsItem>
<NDescriptionsItem label="操作名">
{{ formData?.subType }}
</NDescriptionsItem>
<NDescriptionsItem label="操作内容">
{{ formData?.action }}
</NDescriptionsItem>
<NDescriptionsItem v-if="formData?.extra" label="操作拓展参数">
{{ formData?.extra }}
</NDescriptionsItem>
<NDescriptionsItem label="请求URL">
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
</NDescriptionsItem>
<NDescriptionsItem label="操作时间">
{{ formatDateTime(formData?.createTime || '') }}
</NDescriptionsItem>
<NDescriptionsItem label="业务编号">
{{ formData?.bizId }}
</NDescriptionsItem>
</NDescriptions>
</Modal>
</template>

View File

@ -0,0 +1,159 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemPostApi } from '#/api/system/post';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
label: '岗位名称',
rules: 'required',
},
{
component: 'Input',
fieldName: 'code',
label: '岗位编码',
rules: 'required',
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
min: 0,
},
rules: 'required',
},
{
fieldName: 'status',
label: '岗位状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '岗位备注',
component: 'Textarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '岗位名称',
component: 'Input',
},
{
fieldName: 'code',
label: '岗位编码',
component: 'Input',
},
{
fieldName: 'status',
label: '岗位状态',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemPostApi.Post>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '岗位编号',
minWidth: 200,
},
{
field: 'name',
title: '岗位名称',
minWidth: 200,
},
{
field: 'code',
title: '岗位编码',
minWidth: 200,
},
{
field: 'sort',
title: '显示顺序',
minWidth: 100,
},
{
field: 'remark',
title: '岗位备注',
minWidth: 200,
},
{
field: 'status',
title: '岗位状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '岗位',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:post:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:post:delete']),
},
],
},
},
];
}

View File

@ -0,0 +1,133 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemPostApi } from '#/api/system/post';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deletePost, exportPost, getPostPage } from '#/api/system/post';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportPost(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '岗位.xls', source: data });
}
/** 创建岗位 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑岗位 */
function onEdit(row: SystemPostApi.Post) {
formModalApi.setData(row).open();
}
/** 删除岗位 */
async function onDelete(row: SystemPostApi.Post) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deletePost(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({ code, row }: OnActionClickParams<SystemPostApi.Post>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPostPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemPostApi.Post>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:post:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['岗位']) }}
</NButton>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['system:post:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { SystemPostApi } from '#/api/system/post';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createPost, getPost, updatePost } from '#/api/system/post';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemPostApi.Post>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['岗位'])
: $t('ui.actionTitle.create', ['岗位']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as SystemPostApi.Post;
try {
await (formData.value?.id ? updatePost(data) : createPost(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemPostApi.Post>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getPost(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[40%]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,283 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api/system/role';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
SystemDataScopeEnum,
} from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'code',
label: '角色标识',
component: 'Input',
rules: 'required',
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入显示顺序',
},
rules: 'required',
},
{
fieldName: 'status',
label: '角色状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '角色备注',
component: 'Textarea',
},
];
}
/** 分配数据权限的表单 */
export function useAssignDataPermissionFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
component: 'Input',
fieldName: 'code',
label: '角色标识',
componentProps: {
disabled: true,
},
},
{
component: 'Select',
fieldName: 'dataScope',
label: '权限范围',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_DATA_SCOPE, 'number'),
},
},
{
fieldName: 'dataScopeDeptIds',
label: '部门范围',
component: 'Input',
formItemClass: 'items-start',
dependencies: {
triggerFields: ['dataScope'],
show: (values) => {
return values.dataScope === SystemDataScopeEnum.DEPT_CUSTOM;
},
},
},
];
}
/** 分配菜单的表单 */
export function useAssignMenuFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
fieldName: 'code',
label: '角色标识',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
fieldName: 'menuIds',
label: '菜单权限',
component: 'Input',
formItemClass: 'items-start',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
},
{
fieldName: 'code',
label: '角色标识',
component: 'Input',
},
{
fieldName: 'status',
label: '角色状态',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = SystemRoleApi.Role>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '角色编号',
minWidth: 200,
},
{
field: 'name',
title: '角色名称',
minWidth: 200,
},
{
field: 'type',
title: '角色类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_ROLE_TYPE },
},
},
{
field: 'code',
title: '角色标识',
minWidth: 200,
},
{
field: 'sort',
title: '显示顺序',
minWidth: 100,
},
{
field: 'remark',
title: '角色备注',
minWidth: 100,
},
{
field: 'status',
title: '角色状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 240,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '角色',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['system:role:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['system:role:delete']),
},
{
code: 'assign-data-permission',
text: '数据权限',
show: hasAccessByCodes([
'system:permission:assign-role-data-scope',
]),
},
{
code: 'assign-menu',
text: '菜单权限',
show: hasAccessByCodes(['system:permission:assign-role-menu']),
},
],
},
},
];
}

View File

@ -0,0 +1,175 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api/system/role';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton } from 'naive-ui';
import { message } from '#/adapter/naive';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteRole, exportRole, getRolePage } from '#/api/system/role';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import AssignDataPermissionForm from './modules/assign-data-permission-form.vue';
import AssignMenuForm from './modules/assign-menu-form.vue';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [AssignDataPermissionFormModel, assignDataPermissionFormApi] =
useVbenModal({
connectedComponent: AssignDataPermissionForm,
destroyOnClose: true,
});
const [AssignMenuFormModel, assignMenuFormApi] = useVbenModal({
connectedComponent: AssignMenuForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportRole(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '角色.xls', source: data });
}
/** 编辑角色 */
function onEdit(row: SystemRoleApi.Role) {
formModalApi.setData(row).open();
}
/** 创建角色 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 删除角色 */
async function onDelete(row: SystemRoleApi.Role) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteRole(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 分配角色的数据权限 */
function onAssignDataPermission(row: SystemRoleApi.Role) {
assignDataPermissionFormApi.setData(row).open();
}
/** 分配角色的菜单权限 */
function onAssignMenu(row: SystemRoleApi.Role) {
assignMenuFormApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({ code, row }: OnActionClickParams<SystemRoleApi.Role>) {
switch (code) {
case 'assign-data-permission': {
onAssignDataPermission(row);
break;
}
case 'assign-menu': {
onAssignMenu(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getRolePage({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<SystemRoleApi.Role>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="功能权限"
url="https://doc.iocoder.cn/resource-permission"
/>
<DocAlert title="数据权限" url="https://doc.iocoder.cn/data-permission" />
</template>
<FormModal @success="onRefresh" />
<AssignDataPermissionFormModel @success="onRefresh" />
<AssignMenuFormModel @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<NButton
type="primary"
@click="onCreate"
v-access:code="['system:role:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['角色']) }}
</NButton>
<NButton
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['system:role:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</NButton>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,169 @@
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemRoleApi } from '#/api/system/role';
import { ref } from 'vue';
import { useVbenModal, VbenTree } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { NCheckbox } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { getDeptList } from '#/api/system/dept';
import { assignRoleDataScope } from '#/api/system/permission';
import { getRole } from '#/api/system/role';
import { $t } from '#/locales';
import { SystemDataScopeEnum } from '#/utils';
import { useAssignDataPermissionFormSchema } from '../data';
const emit = defineEmits(['success']);
const deptTree = ref<SystemDeptApi.Dept[]>([]); //
const deptLoading = ref(false); //
const isAllSelected = ref(false); //
const isExpanded = ref(false); //
const isCheckStrictly = ref(true); //
const expandedKeys = ref<number[]>([]); //
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useAssignDataPermissionFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
const data = await formApi.getValues();
try {
await assignRoleDataScope({
roleId: data.id,
dataScope: data.dataScope,
dataScopeDeptIds:
data.dataScope === SystemDataScopeEnum.DEPT_CUSTOM
? data.dataScopeDeptIds
: undefined,
});
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
const data = modalApi.getData<SystemRoleApi.Role>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
await formApi.setValues(await getRole(data.id as number));
//
await loadDeptTree();
toggleExpandAll();
} finally {
modalApi.unlock();
}
},
});
/** 加载部门树 */
async function loadDeptTree() {
deptLoading.value = true;
try {
const data = await getDeptList();
deptTree.value = handleTree(data) as SystemDeptApi.Dept[];
} finally {
deptLoading.value = false;
}
}
/** 全选/全不选 */
function toggleSelectAll() {
isAllSelected.value = !isAllSelected.value;
if (isAllSelected.value) {
const allIds = getAllNodeIds(deptTree.value);
formApi.setFieldValue('dataScopeDeptIds', allIds);
} else {
formApi.setFieldValue('dataScopeDeptIds', []);
}
}
/** 展开/折叠所有节点 */
function toggleExpandAll() {
isExpanded.value = !isExpanded.value;
// ID
expandedKeys.value = isExpanded.value ? getAllNodeIds(deptTree.value) : [];
}
/** 切换父子联动 */
function toggleCheckStrictly() {
isCheckStrictly.value = !isCheckStrictly.value;
}
/** 递归获取所有节点 ID */
function getAllNodeIds(nodes: any[], ids: number[] = []): number[] {
nodes.forEach((node: any) => {
ids.push(node.id);
if (node.children && node.children.length > 0) {
getAllNodeIds(node.children, ids);
}
});
return ids;
}
</script>
<template>
<Modal title="数据权限" class="w-[40%]">
<Form class="mx-4">
<template #dataScopeDeptIds="slotProps">
<!-- <Spin :spinning="deptLoading"> -->
<!-- TODO @芋艿可优化使用 antd tree原因是更原生 -->
<VbenTree
:tree-data="deptTree"
multiple
bordered
:expanded="expandedKeys"
v-bind="slotProps"
value-field="id"
label-field="name"
:auto-check-parent="false"
:check-strictly="!isCheckStrictly"
/>
<!-- </Spin> -->
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<NCheckbox :checked="isAllSelected" @change="toggleSelectAll">
全选
</NCheckbox>
<NCheckbox :checked="isExpanded" @change="toggleExpandAll">
全部展开
</NCheckbox>
<NCheckbox :checked="isCheckStrictly" @change="toggleCheckStrictly">
父子联动
</NCheckbox>
</div>
</template>
</Modal>
</template>

View File

@ -0,0 +1,155 @@
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemRoleApi } from '#/api/system/role';
import { ref } from 'vue';
import { useVbenModal, VbenTree } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { NCheckbox } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { getMenuList } from '#/api/system/menu';
import { assignRoleMenu, getRoleMenuList } from '#/api/system/permission';
import { $t } from '#/locales';
import { useAssignMenuFormSchema } from '../data';
const emit = defineEmits(['success']);
const menuTree = ref<SystemDeptApi.Dept[]>([]); //
const menuLoading = ref(false); //
const isAllSelected = ref(false); //
const isExpanded = ref(false); //
const expandedKeys = ref<number[]>([]); //
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useAssignMenuFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = await formApi.getValues();
try {
await assignRoleMenu({
roleId: data.id,
menuIds: data.menuIds,
});
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
const data = modalApi.getData<SystemRoleApi.Role>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
await formApi.setValues(data);
//
const menuIds = await getRoleMenuList(data.id as number);
await formApi.setFieldValue('menuIds', menuIds);
//
await loadMenuTree();
} finally {
modalApi.unlock();
}
},
});
/** 加载菜单树 */
async function loadMenuTree() {
menuLoading.value = true;
try {
const data = await getMenuList();
menuTree.value = handleTree(data) as SystemDeptApi.Dept[];
} finally {
menuLoading.value = false;
}
}
/** 全选/全不选 */
function toggleSelectAll() {
isAllSelected.value = !isAllSelected.value;
if (isAllSelected.value) {
const allIds = getAllNodeIds(menuTree.value);
formApi.setFieldValue('menuIds', allIds);
} else {
formApi.setFieldValue('menuIds', []);
}
}
/** 展开/折叠所有节点 */
function toggleExpandAll() {
isExpanded.value = !isExpanded.value;
// ID
expandedKeys.value = isExpanded.value ? getAllNodeIds(menuTree.value) : [];
}
/** 递归获取所有节点 ID */
function getAllNodeIds(nodes: any[], ids: number[] = []): number[] {
nodes.forEach((node: any) => {
ids.push(node.id);
if (node.children && node.children.length > 0) {
getAllNodeIds(node.children, ids);
}
});
return ids;
}
</script>
<template>
<Modal title="数据权限" class="w-[40%]">
<Form class="mx-4">
<template #menuIds="slotProps">
<!-- <Spin :spinning="menuLoading" class="w-full"> -->
<!-- TODO @芋艿可优化使用 antd tree原因是更原生 -->
<VbenTree
:tree-data="menuTree"
multiple
bordered
:expanded="expandedKeys"
v-bind="slotProps"
value-field="id"
label-field="name"
/>
<!-- </Spin> -->
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<NCheckbox :checked="isAllSelected" @change="toggleSelectAll">
全选
</NCheckbox>
<NCheckbox :checked="isExpanded" @change="toggleExpandAll">
全部展开
</NCheckbox>
</div>
</template>
</Modal>
</template>

View File

@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { SystemRoleApi } from '#/api/system/role';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createRole, getRole, updateRole } from '#/api/system/role';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemRoleApi.Role>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['角色'])
: $t('ui.actionTitle.create', ['角色']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as SystemRoleApi.Role;
try {
await (formData.value?.id ? updateRole(data) : createRole(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<SystemRoleApi.Role>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getRole(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

Some files were not shown because too many files have changed in this diff Show More