init project

This commit is contained in:
caiyuchao
2025-05-16 14:52:30 +08:00
commit 1d6f7521c4
1496 changed files with 134863 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 { Button } from 'ant-design-vue';
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>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:api-access-log:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</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 { Descriptions } from 'ant-design-vue';
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"
>
<Descriptions
bordered
:column="1"
size="middle"
class="mx-4"
:label-style="{ width: '110px' }"
>
<Descriptions.Item label="日志编号">
{{ formData?.id }}
</Descriptions.Item>
<Descriptions.Item label="链路追踪">
{{ formData?.traceId }}
</Descriptions.Item>
<Descriptions.Item label="应用名">
{{ formData?.applicationName }}
</Descriptions.Item>
<Descriptions.Item label="用户信息">
{{ formData?.userId }}
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
</Descriptions.Item>
<Descriptions.Item label="用户IP">
{{ formData?.userIp }}
</Descriptions.Item>
<Descriptions.Item label="用户UA">
{{ formData?.userAgent }}
</Descriptions.Item>
<Descriptions.Item label="请求信息">
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
</Descriptions.Item>
<Descriptions.Item label="请求参数">
{{ formData?.requestParams }}
</Descriptions.Item>
<Descriptions.Item label="请求结果">
{{ formData?.responseBody }}
</Descriptions.Item>
<Descriptions.Item label="请求时间">
{{ formatDateTime(formData?.beginTime || '') }} ~
{{ formatDateTime(formData?.endTime || '') }}
</Descriptions.Item>
<Descriptions.Item label="请求耗时">
{{ formData?.duration }} ms
</Descriptions.Item>
<Descriptions.Item label="操作结果">
<div v-if="formData?.resultCode === 0">正常</div>
<div v-else-if="formData && formData?.resultCode > 0">
失败 | {{ formData?.resultCode }} | {{ formData?.resultMsg }}
</div>
</Descriptions.Item>
<Descriptions.Item label="操作模块">
{{ formData?.operateModule }}
</Descriptions.Item>
<Descriptions.Item label="操作名">
{{ formData?.operateName }}
</Descriptions.Item>
<Descriptions.Item label="操作类型">
<DictTag
:type="DICT_TYPE.INFRA_OPERATE_TYPE"
:value="formData?.operateType"
/>
</Descriptions.Item>
</Descriptions>
</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,132 @@
<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 { Button, message } from 'ant-design-vue';
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>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:api-error-log:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,103 @@
<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 { Descriptions, Input } from 'ant-design-vue';
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"
>
<Descriptions
bordered
:column="1"
size="middle"
class="mx-4"
:label-style="{ width: '110px' }"
>
<Descriptions.Item label="日志编号">
{{ formData?.id }}
</Descriptions.Item>
<Descriptions.Item label="链路追踪">
{{ formData?.traceId }}
</Descriptions.Item>
<Descriptions.Item label="应用名">
{{ formData?.applicationName }}
</Descriptions.Item>
<Descriptions.Item label="用户编号">
{{ formData?.userId }}
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
</Descriptions.Item>
<Descriptions.Item label="用户IP">
{{ formData?.userIp }}
</Descriptions.Item>
<Descriptions.Item label="用户UA">
{{ formData?.userAgent }}
</Descriptions.Item>
<Descriptions.Item label="请求信息">
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
</Descriptions.Item>
<Descriptions.Item label="请求参数">
{{ formData?.requestParams }}
</Descriptions.Item>
<Descriptions.Item label="异常时间">
{{ formatDateTime(formData?.exceptionTime || '') }}
</Descriptions.Item>
<Descriptions.Item label="异常名">
{{ formData?.exceptionName }}
</Descriptions.Item>
<Descriptions.Item v-if="formData?.exceptionStackTrace" label="异常堆栈">
<Input.TextArea
:value="formData?.exceptionStackTrace"
:auto-size="{ maxRows: 20 }"
readonly
/>
</Descriptions.Item>
<Descriptions.Item label="处理状态">
<DictTag
:type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS"
:value="formData?.processStatus"
/>
</Descriptions.Item>
<Descriptions.Item v-if="formData?.processUserId" label="处理人">
{{ formData?.processUserId }}
</Descriptions.Item>
<Descriptions.Item v-if="formData?.processTime" label="处理时间">
{{ formatDateTime(formData?.processTime || '') }}
</Descriptions.Item>
</Descriptions>
</Modal>
</template>

View File

@@ -0,0 +1,182 @@
<!-- 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 { Button, message } from 'ant-design-vue';
import hljs from 'highlight.js';
import xml from 'highlight.js/lib/languages/java';
import json from 'highlight.js/lib/languages/json';
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>
<Button size="small" type="primary" ghost @click="showJson">
生成JSON
</Button>
<Button size="small" type="primary" ghost @click="showOption">
生成Options
</Button>
<Button size="small" type="primary" ghost @click="showTemplate">
生成组件
</Button>
</template>
</FcDesigner>
<!-- 弹窗表单预览 -->
<Modal :title="dialogTitle" :footer="false" :fullscreen-button="false">
<div>
<Button style="float: right" @click="copy(formData)"> 复制 </Button>
<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,169 @@
<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 { Button, message, Steps } from 'ant-design-vue';
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"
>
<Steps
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"
/>
</Steps>
<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">
<Button v-show="currentStep > 0" @click="prevStep">上一步</Button>
<Button v-show="currentStep < steps.length - 1" @click="nextStep">
下一步
</Button>
<Button type="primary" :loading="loading" @click="submitForm">
保存
</Button>
</div>
</div>
</Page>
</template>

View File

@@ -0,0 +1,231 @@
<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 { Button, message } from 'ant-design-vue';
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>
<Button
type="primary"
@click="onImport"
v-access:code="['infra:codegen:create']"
>
<Plus class="size-5" />
导入
</Button>
</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,160 @@
<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 { Checkbox, Input, Select } from 'ant-design-vue';
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 }">
<Input v-model:value="row.columnComment" />
</template>
<!-- Java 类型 -->
<template #javaType="{ row, column }">
<Select v-model:value="row.javaType" style="width: 100%">
<Select.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</Select.Option>
</Select>
</template>
<!-- Java 属性 -->
<template #javaField="{ row }">
<Input v-model:value="row.javaField" />
</template>
<!-- 插入 -->
<template #createOperation="{ row }">
<Checkbox v-model:checked="row.createOperation" />
</template>
<!-- 编辑 -->
<template #updateOperation="{ row }">
<Checkbox v-model:checked="row.updateOperation" />
</template>
<!-- 列表 -->
<template #listOperationResult="{ row }">
<Checkbox v-model:checked="row.listOperationResult" />
</template>
<!-- 查询 -->
<template #listOperation="{ row }">
<Checkbox v-model:checked="row.listOperation" />
</template>
<!-- 查询方式 -->
<template #listOperationCondition="{ row, column }">
<Select v-model:value="row.listOperationCondition" class="w-full">
<Select.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</Select.Option>
</Select>
</template>
<!-- 允许空 -->
<template #nullable="{ row }">
<Checkbox v-model:checked="row.nullable" />
</template>
<!-- 显示类型 -->
<template #htmlType="{ row, column }">
<Select v-model:value="row.htmlType" class="w-full">
<Select.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</Select.Option>
</Select>
</template>
<!-- 字典类型 -->
<template #dictType="{ row }">
<Select
v-model:value="row.dictType"
class="w-full"
allow-clear
show-search
>
<Select.Option
v-for="option in dictTypeOptions"
:key="option.type"
:value="option.type"
>
{{ option.name }}
</Select.Option>
</Select>
</template>
<!-- 示例 -->
<template #example="{ row }">
<Input 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 'ant-design-vue';
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,371 @@
<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 { Button, DirectoryTree, message, Tabs } from 'ant-design-vue';
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 { 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"
>
<DirectoryTree
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">
<Tabs
v-model:active-key="activeKey"
hide-add
type="editable-card"
@edit="removeCodeMapKey"
>
<Tabs.TabPane
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>
</Tabs.TabPane>
<template #rightExtra>
<Button type="primary" ghost @click="copyCode" :icon="h(Copy)">
复制代码
</Button>
</template>
</Tabs>
</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,135 @@
<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 { Button, message } from 'ant-design-vue';
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>
<Button
type="primary"
@click="onCreate"
v-access:code="['infra:config:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['参数']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:config:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { InfraConfigApi } from '#/api/infra/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
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,124 @@
<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 { Button, message } from 'ant-design-vue';
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>
<Button
type="primary"
@click="onCreate"
v-access:code="['infra:data-source-config:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['数据源']) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,89 @@
<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 { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
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,176 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { useAccess } from '@vben/access';
import { 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: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
rules: 'required',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
fieldName: 'birthday',
label: '出生年',
rules: 'required',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'description',
label: '简介',
rules: 'required',
component: 'RichTextarea',
},
{
fieldName: 'avatar',
label: '头像',
component: 'ImageUpload',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
placeholder: '请选择性别',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onActionClick?: OnActionClickFn<Demo01ContactApi.Demo01Contact>,
): VxeTableGridOptions<Demo01ContactApi.Demo01Contact>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'sex',
title: '性别',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_USER_SEX },
},
},
{
field: 'birthday',
title: '出生年',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'description',
title: '简介',
minWidth: 120,
},
{
field: 'avatar',
title: '头像',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
// TODO @puhui999headerAlign 要使用 headerAlign: 'center' 么?看着现在分成了 align 和 headerAlign 两种
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '示例联系人',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:demo01-contact:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:demo01-contact:delete']),
},
],
},
},
];
}

View File

@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { h } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo01Contact,
exportDemo01Contact,
getDemo01ContactPage,
} from '#/api/infra/demo/demo01';
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({}).open();
}
/** 编辑示例联系人 */
function onEdit(row: Demo01ContactApi.Demo01Contact) {
formModalApi.setData(row).open();
}
/** 删除示例联系人 */
async function onDelete(row: Demo01ContactApi.Demo01Contact) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo01Contact(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
onRefresh();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
const data = await exportDemo01Contact(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '示例联系人.xls', source: data });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo01ContactApi.Demo01Contact>) {
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',
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDemo01ContactPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<Demo01ContactApi.Demo01Contact>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="示例联系人列表">
<template #toolbar-tools>
<Button
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo01-contact:create']"
>
{{ $t('ui.actionTitle.create', ['示例联系人']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:demo01-contact:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDemo01Contact,
getDemo01Contact,
updateDemo01Contact,
} from '#/api/infra/demo/demo01';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo01ContactApi.Demo01Contact>();
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 Demo01ContactApi.Demo01Contact;
try {
await (formData.value?.id
? updateDemo01Contact(data)
: createDemo01Contact(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success({
content: $t('ui.actionMessage.operationSuccess'),
key: 'action_process_msg',
});
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
let data = modalApi.getData<Demo01ContactApi.Demo01Contact>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo01Contact(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,154 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { useAccess } from '@vben/access';
import { handleTree } from '@vben/utils';
import { getDemo02CategoryList } from '#/api/infra/demo/demo02';
import { getRangePickerDefaultProps } 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 getDemo02CategoryList({});
data.unshift({
id: 0,
name: '顶级示例分类',
});
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级示例分类',
treeDefaultExpandAll: true,
},
rules: 'selectRequired',
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'parentId',
label: '父级编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入父级编号',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onActionClick?: OnActionClickFn<Demo02CategoryApi.Demo02Category>,
): VxeTableGridOptions<Demo02CategoryApi.Demo02Category>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
treeNode: true,
},
{
field: 'parentId',
title: '父级编号',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '示例分类',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
text: '新增下级',
show: hasAccessByCodes(['infra:demo02-category:create']),
},
{
code: 'edit',
show: hasAccessByCodes(['infra:demo02-category:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:demo02-category:delete']),
disabled: (row: Demo02CategoryApi.Demo02Category) => {
return !!(row.children && row.children.length > 0);
},
},
],
},
},
];
}

View File

@@ -0,0 +1,167 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { h, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo02Category,
exportDemo02Category,
getDemo02CategoryList,
} from '#/api/infra/demo/demo02';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 切换树形展开/收缩状态 */
const isExpanded = ref(true);
function toggleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid.setAllTreeExpand(isExpanded.value);
}
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportDemo02Category(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '示例分类.xls', source: data });
}
/** 创建示例分类 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑示例分类 */
function onEdit(row: Demo02CategoryApi.Demo02Category) {
formModalApi.setData(row).open();
}
/** 新增下级示例分类 */
function onAppend(row: Demo02CategoryApi.Demo02Category) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 删除示例分类 */
async function onDelete(row: Demo02CategoryApi.Demo02Category) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo02Category(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo02CategoryApi.Demo02Category>) {
switch (code) {
case 'append': {
onAppend(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',
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
reserve: true,
},
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues) => {
return await getDemo02CategoryList(formValues);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<Demo02CategoryApi.Demo02Category>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="示例分类列表">
<template #toolbar-tools>
<Button @click="toggleExpand" class="mr-2">
{{ isExpanded ? '收缩' : '展开' }}
</Button>
<Button
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo02-category:create']"
>
{{ $t('ui.actionTitle.create', ['示例分类']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:demo02-category:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,100 @@
<script lang="ts" setup>
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDemo02Category,
getDemo02Category,
updateDemo02Category,
} from '#/api/infra/demo/demo02';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo02CategoryApi.Demo02Category>();
const parentId = ref<number>(); // 新增下级时的父级 ID
const getTitle = computed(() => {
if (formData.value?.id) {
return $t('ui.actionTitle.edit', ['示例分类']);
}
return parentId.value
? $t('ui.actionTitle.create', ['下级示例分类'])
: $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 Demo02CategoryApi.Demo02Category;
try {
await (formData.value?.id
? updateDemo02Category(data)
: createDemo02Category(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<Demo02CategoryApi.Demo02Category>();
if (!data) {
return;
}
if (data.id) {
// 编辑
modalApi.lock();
try {
data = await getDemo02Category(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,448 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { useAccess } from '@vben/access';
import { 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: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
rules: 'required',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
fieldName: 'birthday',
label: '出生日期',
rules: 'required',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'description',
label: '简介',
rules: 'required',
component: 'RichTextarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
placeholder: '请选择性别',
},
},
{
fieldName: 'description',
label: '简介',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入简介',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onActionClick?: OnActionClickFn<Demo03StudentApi.Demo03Student>,
): VxeTableGridOptions<Demo03StudentApi.Demo03Student>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'sex',
title: '性别',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_USER_SEX },
},
},
{
field: 'birthday',
title: '出生日期',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'description',
title: '简介',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '学生',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:demo03-student:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:demo03-student:delete']),
},
],
},
},
];
}
// ==================== 子表(学生课程) ====================
/** 新增/修改的表单 */
export function useDemo03CourseFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'score',
label: '分数',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入分数',
},
},
];
}
/** 列表的搜索表单 */
export function useDemo03CourseGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'studentId',
label: '学生编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入学生编号',
},
},
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'score',
label: '分数',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入分数',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useDemo03CourseGridColumns(
onActionClick?: OnActionClickFn<Demo03StudentApi.Demo03Course>,
): VxeTableGridOptions<Demo03StudentApi.Demo03Course>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'studentId',
title: '学生编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'score',
title: '分数',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '学生课程',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:demo03-student:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:demo03-student:delete']),
},
],
},
},
];
}
// ==================== 子表(学生班级) ====================
/** 新增/修改的表单 */
export function useDemo03GradeFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'teacher',
label: '班主任',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入班主任',
},
},
];
}
/** 列表的搜索表单 */
export function useDemo03GradeGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'studentId',
label: '学生编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入学生编号',
},
},
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'teacher',
label: '班主任',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入班主任',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useDemo03GradeGridColumns(
onActionClick?: OnActionClickFn<Demo03StudentApi.Demo03Grade>,
): VxeTableGridOptions<Demo03StudentApi.Demo03Grade>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'studentId',
title: '学生编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'teacher',
title: '班主任',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '学生班级',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:demo03-student:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:demo03-student:delete']),
},
],
},
},
];
}

View File

@@ -0,0 +1,169 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { h, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Demo03CourseList from './modules/demo03-course-list.vue';
import Demo03GradeList from './modules/demo03-grade-list.vue';
import Form from './modules/form.vue';
/** 子表的列表 */
const subTabsName = ref('demo03Course');
const selectDemo03Student = ref<Demo03StudentApi.Demo03Student>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建学生 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 编辑学生 */
function onEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function onDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Student(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
onRefresh();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
const data = await exportDemo03Student(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo03StudentApi.Demo03Student>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: '600px',
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDemo03StudentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
isCurrent: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Student>,
gridEvents: {
cellClick: ({ row }: { row: Demo03StudentApi.Demo03Student }) => {
selectDemo03Student.value = row;
},
},
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<div>
<Grid table-title="学生列表">
<template #toolbar-tools>
<Button
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:demo03-student:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName" class="mt-2">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseList :student-id="selectDemo03Student?.id" />
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeList :student-id="selectDemo03Student?.id" />
</Tabs.TabPane>
</Tabs>
</div>
</Page>
</template>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDemo03Course,
getDemo03Course,
updateDemo03Course,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { useDemo03CourseFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Course>();
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: useDemo03CourseFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo03StudentApi.Demo03Course;
data.studentId = formData.value?.studentId;
try {
await (formData.value?.id
? updateDemo03Course(data)
: createDemo03Course(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<Demo03StudentApi.Demo03Course>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Course(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,153 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { h, nextTick, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Course,
getDemo03CoursePage,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import {
useDemo03CourseGridColumns,
useDemo03CourseGridFormSchema,
} from '../data';
import Demo03CourseForm from './demo03-course-form.vue';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03CourseForm,
destroyOnClose: true,
});
/** 创建学生课程 */
function onCreate() {
if (!props.studentId) {
message.warning('请先选择一个学生!');
return;
}
formModalApi.setData({ studentId: props.studentId }).open();
}
/** 编辑学生课程 */
function onEdit(row: Demo03StudentApi.Demo03Course) {
formModalApi.setData(row).open();
}
/** 删除学生课程 */
async function onDelete(row: Demo03StudentApi.Demo03Course) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Course(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo03StudentApi.Demo03Course>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useDemo03CourseGridFormSchema(),
},
gridOptions: {
columns: useDemo03CourseGridColumns(onActionClick),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (!props.studentId) {
return [];
}
return await getDemo03CoursePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
studentId: props.studentId,
...formValues,
});
},
},
},
pagerConfig: {
enabled: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
height: '600px',
rowConfig: {
keyField: 'id',
isHover: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Course>,
});
/** 刷新表格 */
const onRefresh = async () => {
await gridApi.query();
};
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await onRefresh();
},
{ immediate: true },
);
</script>
<template>
<FormModal @success="onRefresh" />
<Grid table-title="学生课程列表">
<template #toolbar-tools>
<Button
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDemo03Grade,
getDemo03Grade,
updateDemo03Grade,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { useDemo03GradeFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Grade>();
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: useDemo03GradeFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo03StudentApi.Demo03Grade;
data.studentId = formData.value?.studentId;
try {
await (formData.value?.id
? updateDemo03Grade(data)
: createDemo03Grade(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<Demo03StudentApi.Demo03Grade>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Grade(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,153 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { h, nextTick, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Grade,
getDemo03GradePage,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import {
useDemo03GradeGridColumns,
useDemo03GradeGridFormSchema,
} from '../data';
import Demo03GradeForm from './demo03-grade-form.vue';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03GradeForm,
destroyOnClose: true,
});
/** 创建学生班级 */
function onCreate() {
if (!props.studentId) {
message.warning('请先选择一个学生!');
return;
}
formModalApi.setData({ studentId: props.studentId }).open();
}
/** 编辑学生班级 */
function onEdit(row: Demo03StudentApi.Demo03Grade) {
formModalApi.setData(row).open();
}
/** 删除学生班级 */
async function onDelete(row: Demo03StudentApi.Demo03Grade) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Grade(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo03StudentApi.Demo03Grade>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useDemo03GradeGridFormSchema(),
},
gridOptions: {
columns: useDemo03GradeGridColumns(onActionClick),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (!props.studentId) {
return [];
}
return await getDemo03GradePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
studentId: props.studentId,
...formValues,
});
},
},
},
pagerConfig: {
enabled: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
height: '600px',
rowConfig: {
keyField: 'id',
isHover: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Grade>,
});
/** 刷新表格 */
const onRefresh = async () => {
await gridApi.query();
};
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await onRefresh();
},
{ immediate: true },
);
</script>
<template>
<FormModal @success="onRefresh" />
<Grid table-title="学生班级列表">
<template #toolbar-tools>
<Button
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生班级']) }}
</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Student>();
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 Demo03StudentApi.Demo03Student;
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(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<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(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,318 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { useAccess } from '@vben/access';
import { 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: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
rules: 'required',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
fieldName: 'birthday',
label: '出生日期',
rules: 'required',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'description',
label: '简介',
rules: 'required',
component: 'RichTextarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
placeholder: '请选择性别',
},
},
{
fieldName: 'description',
label: '简介',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入简介',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onActionClick?: OnActionClickFn<Demo03StudentApi.Demo03Student>,
): VxeTableGridOptions<Demo03StudentApi.Demo03Student>['columns'] {
return [
{ type: 'expand', width: 80, slots: { content: 'expand_content' } },
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'sex',
title: '性别',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_USER_SEX },
},
},
{
field: 'birthday',
title: '出生日期',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'description',
title: '简介',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '学生',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:demo03-student:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:demo03-student:delete']),
},
],
},
},
];
}
// ==================== 子表(学生课程) ====================
/** 新增/修改列表的字段 */
export function useDemo03CourseGridEditColumns(
onActionClick?: OnActionClickFn<Demo03StudentApi.Demo03Course>,
): VxeTableGridOptions<Demo03StudentApi.Demo03Course>['columns'] {
return [
{
field: 'name',
title: '名字',
minWidth: 120,
slots: { default: 'name' },
},
{
field: 'score',
title: '分数',
minWidth: 120,
slots: { default: 'score' },
},
{
field: 'operation',
title: '操作',
minWidth: 60,
align: 'center',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '学生',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'delete',
show: hasAccessByCodes(['infra:demo03-student:delete']),
},
],
},
},
];
}
/** 列表的字段 */
export function useDemo03CourseGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Course>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'studentId',
title: '学生编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'score',
title: '分数',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
];
}
// ==================== 子表(学生班级) ====================
/** 新增/修改的表单 */
export function useDemo03GradeFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'teacher',
label: '班主任',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入班主任',
},
},
];
}
/** 列表的字段 */
export function useDemo03GradeGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Grade>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'studentId',
title: '学生编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'teacher',
title: '班主任',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
];
}

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { h, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/inner';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Demo03CourseList from './modules/demo03-course-list.vue';
import Demo03GradeList from './modules/demo03-grade-list.vue';
import Form from './modules/form.vue';
/** 子表的列表 */
const subTabsName = ref('demo03Course');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.reload();
}
/** 创建学生 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 编辑学生 */
function onEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function onDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Student(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
onRefresh();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
const data = await exportDemo03Student(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo03StudentApi.Demo03Student>) {
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',
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDemo03StudentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Student>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="学生列表">
<template #expand_content="{ row }">
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName" class="mx-8">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseList :student-id="row?.id" />
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeList :student-id="row?.id" />
</Tabs.TabPane>
</Tabs>
</template>
<template #toolbar-tools>
<Button
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:demo03-student:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,113 @@
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { h, nextTick, watch } from 'vue';
import { Plus } from '@vben/icons';
import { Button, Input } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/inner';
import { $t } from '#/locales';
import { useDemo03CourseGridEditColumns } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo03StudentApi.Demo03Course>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDemo03CourseGridEditColumns(onActionClick),
border: true,
showOverflow: true,
autoResize: true,
keepSource: true,
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
/** 添加学生课程 */
const onAdd = async () => {
await gridApi.grid.insertAt({} as Demo03StudentApi.Demo03Course, -1);
};
/** 删除学生课程 */
const onDelete = async (row: Demo03StudentApi.Demo03Course) => {
await gridApi.grid.remove(row);
};
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): Demo03StudentApi.Demo03Course[] => {
const data = gridApi.grid.getData() as Demo03StudentApi.Demo03Course[];
const removeRecords =
gridApi.grid.getRemoveRecords() as Demo03StudentApi.Demo03Course[];
const insertRecords =
gridApi.grid.getInsertRecords() as Demo03StudentApi.Demo03Course[];
return data
.filter((row) => !removeRecords.some((removed) => removed.id === row.id))
.concat(insertRecords.map((row: any) => ({ ...row, id: undefined })));
},
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await gridApi.grid.loadData(
await getDemo03CourseListByStudentId(props.studentId!),
);
},
{ immediate: true },
);
</script>
<template>
<Grid class="mx-4">
<template #name="{ row }">
<Input v-model:value="row.name" />
</template>
<template #score="{ row }">
<Input v-model:value="row.score" />
</template>
</Grid>
<div class="-mt-4 flex justify-center">
<Button
:icon="h(Plus)"
type="primary"
ghost
@click="onAdd"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { nextTick, watch } from 'vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/inner';
import { useDemo03CourseGridColumns } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDemo03CourseGridColumns(),
height: 'auto',
rowConfig: {
keyField: 'id',
isHover: true,
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Student>,
});
/** 刷新表格 */
const onRefresh = async () => {
await gridApi.grid.loadData(
await getDemo03CourseListByStudentId(props.studentId!),
);
};
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await onRefresh();
},
{ immediate: true },
);
</script>
<template>
<Grid table-title="学生课程列表" />
</template>

View File

@@ -0,0 +1,44 @@
<script lang="ts" setup>
import { nextTick, watch } from 'vue';
import { useVbenForm } from '#/adapter/form';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/inner';
import { useDemo03GradeFormSchema } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Form, formApi] = useVbenForm({
layout: 'horizontal',
schema: useDemo03GradeFormSchema(),
showDefaultActions: false,
});
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => {
const { valid } = await formApi.validate();
return valid;
},
getValues: formApi.getValues,
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await formApi.setValues(await getDemo03GradeByStudentId(props.studentId!));
},
{ immediate: true },
);
</script>
<template>
<Form class="mx-4" />
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { nextTick, watch } from 'vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/inner';
import { useDemo03GradeGridColumns } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDemo03GradeGridColumns(),
height: 'auto',
rowConfig: {
keyField: 'id',
isHover: true,
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Student>,
});
/** 刷新表格 */
const onRefresh = async () => {
await gridApi.grid.loadData([
await getDemo03GradeByStudentId(props.studentId!),
]);
};
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await onRefresh();
},
{ immediate: true },
);
</script>
<template>
<Grid table-title="学生班级列表" />
</template>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/inner';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import Demo03CourseForm from './demo03-course-form.vue';
import Demo03GradeForm from './demo03-grade-form.vue';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Student>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 子表的表单 */
const subTabsName = ref('demo03Course');
const demo03CourseFormRef = ref<InstanceType<typeof Demo03CourseForm>>();
const demo03GradeFormRef = ref<InstanceType<typeof Demo03GradeForm>>();
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;
}
// 校验子表单
const demo03GradeValid = await demo03GradeFormRef.value?.validate();
if (!demo03GradeValid) {
subTabsName.value = 'demo03Grade';
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo03StudentApi.Demo03Student;
// 拼接子表的数据
data.demo03courses = demo03CourseFormRef.value?.getData();
data.demo03grade = await demo03GradeFormRef.value?.getValues();
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(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<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
await formApi.setValues(formData.value);
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseForm
ref="demo03CourseFormRef"
:student-id="formData?.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData?.id" />
</Tabs.TabPane>
</Tabs>
</Modal>
</template>

View File

@@ -0,0 +1,253 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { useAccess } from '@vben/access';
import { 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: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
rules: 'required',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
fieldName: 'birthday',
label: '出生日期',
rules: 'required',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'description',
label: '简介',
rules: 'required',
component: 'RichTextarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
placeholder: '请选择性别',
},
},
{
fieldName: 'description',
label: '简介',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入简介',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onActionClick?: OnActionClickFn<Demo03StudentApi.Demo03Student>,
): VxeTableGridOptions<Demo03StudentApi.Demo03Student>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'sex',
title: '性别',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_USER_SEX },
},
},
{
field: 'birthday',
title: '出生日期',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'description',
title: '简介',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '学生',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:demo03-student:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:demo03-student:delete']),
},
],
},
},
];
}
// ==================== 子表(学生课程) ====================
/** 新增/修改列表的字段 */
export function useDemo03CourseGridEditColumns(
onActionClick?: OnActionClickFn<Demo03StudentApi.Demo03Course>,
): VxeTableGridOptions<Demo03StudentApi.Demo03Course>['columns'] {
return [
{
field: 'name',
title: '名字',
minWidth: 120,
slots: { default: 'name' },
},
{
field: 'score',
title: '分数',
minWidth: 120,
slots: { default: 'score' },
},
{
field: 'operation',
title: '操作',
minWidth: 60,
align: 'center',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '学生',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'delete',
show: hasAccessByCodes(['infra:demo03-student:delete']),
},
],
},
},
];
}
// ==================== 子表(学生班级) ====================
/** 新增/修改的表单 */
export function useDemo03GradeFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'teacher',
label: '班主任',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入班主任',
},
},
];
}

View File

@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { h } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/normal';
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({}).open();
}
/** 编辑学生 */
function onEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function onDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Student(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
onRefresh();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
const data = await exportDemo03Student(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo03StudentApi.Demo03Student>) {
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',
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDemo03StudentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Student>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="学生列表">
<template #toolbar-tools>
<Button
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:demo03-student:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,113 @@
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { h, nextTick, watch } from 'vue';
import { Plus } from '@vben/icons';
import { Button, Input } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
import { useDemo03CourseGridEditColumns } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo03StudentApi.Demo03Course>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDemo03CourseGridEditColumns(onActionClick),
border: true,
showOverflow: true,
autoResize: true,
keepSource: true,
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
/** 添加学生课程 */
const onAdd = async () => {
await gridApi.grid.insertAt({} as Demo03StudentApi.Demo03Course, -1);
};
/** 删除学生课程 */
const onDelete = async (row: Demo03StudentApi.Demo03Course) => {
await gridApi.grid.remove(row);
};
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): Demo03StudentApi.Demo03Course[] => {
const data = gridApi.grid.getData() as Demo03StudentApi.Demo03Course[];
const removeRecords =
gridApi.grid.getRemoveRecords() as Demo03StudentApi.Demo03Course[];
const insertRecords =
gridApi.grid.getInsertRecords() as Demo03StudentApi.Demo03Course[];
return data
.filter((row) => !removeRecords.some((removed) => removed.id === row.id))
.concat(insertRecords.map((row: any) => ({ ...row, id: undefined })));
},
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await gridApi.grid.loadData(
await getDemo03CourseListByStudentId(props.studentId!),
);
},
{ immediate: true },
);
</script>
<template>
<Grid class="mx-4">
<template #name="{ row }">
<Input v-model:value="row.name" />
</template>
<template #score="{ row }">
<Input v-model:value="row.score" />
</template>
</Grid>
<div class="-mt-4 flex justify-center">
<Button
:icon="h(Plus)"
type="primary"
ghost
@click="onAdd"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script lang="ts" setup>
import { nextTick, watch } from 'vue';
import { useVbenForm } from '#/adapter/form';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/normal';
import { useDemo03GradeFormSchema } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Form, formApi] = useVbenForm({
layout: 'horizontal',
schema: useDemo03GradeFormSchema(),
showDefaultActions: false,
});
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => {
const { valid } = await formApi.validate();
return valid;
},
getValues: formApi.getValues,
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await formApi.setValues(await getDemo03GradeByStudentId(props.studentId!));
},
{ immediate: true },
);
</script>
<template>
<Form class="mx-4" />
</template>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import Demo03CourseForm from './demo03-course-form.vue';
import Demo03GradeForm from './demo03-grade-form.vue';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Student>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 子表的表单 */
const subTabsName = ref('demo03Course');
const demo03CourseFormRef = ref<InstanceType<typeof Demo03CourseForm>>();
const demo03GradeFormRef = ref<InstanceType<typeof Demo03GradeForm>>();
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;
}
// 校验子表单
const demo03GradeValid = await demo03GradeFormRef.value?.validate();
if (!demo03GradeValid) {
subTabsName.value = 'demo03Grade';
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo03StudentApi.Demo03Student;
// 拼接子表的数据
data.demo03courses = demo03CourseFormRef.value?.getData();
data.demo03grade = await demo03GradeFormRef.value?.getValues();
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(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<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
await formApi.setValues(formData.value);
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseForm
ref="demo03CourseFormRef"
:student-id="formData?.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData?.id" />
</Tabs.TabPane>
</Tabs>
</Modal>
</template>

View File

@@ -0,0 +1,264 @@
<script lang="ts" setup>
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { h, onMounted, reactive, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
} from '@vben/utils';
import {
Button,
Form,
Input,
message,
Pagination,
RangePicker,
Select,
} from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo01Contact,
exportDemo01Contact,
getDemo01ContactPage,
} from '#/api/infra/demo/demo01';
import { ContentWrap } from '#/components/content-wrap';
import { DictTag } from '#/components/dict-tag';
import { TableToolbar } from '#/components/table-toolbar';
import { useTableToolbar } from '#/hooks';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
import Demo01ContactForm from './modules/form.vue';
const loading = ref(true); // 列表的加载中
const list = ref<Demo01ContactApi.Demo01Contact[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
sex: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
const data = await getDemo01ContactPage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo01ContactForm,
destroyOnClose: true,
});
/** 创建示例联系人 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 编辑示例联系人 */
function onEdit(row: Demo01ContactApi.Demo01Contact) {
formModalApi.setData(row).open();
}
/** 删除示例联系人 */
async function onDelete(row: Demo01ContactApi.Demo01Contact) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo01Contact(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
await getList();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
try {
exportLoading.value = true;
const data = await exportDemo01Contact(queryParams);
downloadFileFromBlobPart({ fileName: '示例联系人.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model:value="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<!-- TODO @puhui999貌似性别的宽度不对并且选择后会变哈 -->
<Form.Item label="性别" name="sex">
<Select
v-model:value="queryParams.sex"
placeholder="请选择性别"
allow-clear
class="w-full"
>
<!-- TODO @puhui999要不咱还是把 getIntDictOptions 还是搞出来总归方便点~ -->
<Select.Option
v-for="dict in getDictOptions(
DICT_TYPE.SYSTEM_USER_SEX,
'number',
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<RangePicker
v-model:value="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" type="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="示例联系人">
<template #extra>
<TableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo01-contact:create']"
>
{{ $t('ui.actionTitle.create', ['示例联系人']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
:loading="exportLoading"
@click="onExport"
v-access:code="['infra:demo01-contact:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</TableToolbar>
</template>
<VxeTable ref="tableRef" :data="list" show-overflow :loading="loading">
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="sex" title="性别" align="center">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</VxeColumn>
<VxeColumn field="birthday" title="出生年" align="center">
<template #default="{ row }">
{{ formatDateTime(row.birthday) }}
</template>
</VxeColumn>
<VxeColumn field="description" title="简介" align="center" />
<VxeColumn field="avatar" title="头像" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
@click="onEdit(row as any)"
v-access:code="['infra:demo01-contact:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
type="link"
class="ml-2"
@click="onDelete(row as any)"
v-access:code="['infra:demo01-contact:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,144 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
DatePicker,
Form,
Input,
message,
Radio,
RadioGroup,
} from 'ant-design-vue';
import {
createDemo01Contact,
getDemo01Contact,
updateDemo01Contact,
} from '#/api/infra/demo/demo01';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { ImageUpload } from '#/components/upload';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions } from '#/utils';
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo01ContactApi.Demo01Contact>>({
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
avatar: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
birthday: [{ required: true, message: '出生年不能为空', trigger: 'blur' }],
description: [{ required: true, message: '简介不能为空', trigger: 'blur' }],
};
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['示例联系人'])
: $t('ui.actionTitle.create', ['示例联系人']);
});
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
avatar: undefined,
};
formRef.value?.resetFields();
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo01ContactApi.Demo01Contact;
try {
await (formData.value?.id
? updateDemo01Contact(data)
: createDemo01Contact(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo01ContactApi.Demo01Contact>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo01Contact(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="性别" name="sex">
<RadioGroup v-model:value="formData.sex">
<Radio
v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number')"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item label="出生年" name="birthday">
<DatePicker
v-model:value="formData.birthday"
value-format="x"
placeholder="选择出生年"
/>
</Form.Item>
<Form.Item label="简介" name="description">
<RichTextarea v-model="formData.description" height="500px" />
</Form.Item>
<Form.Item label="头像" name="avatar">
<ImageUpload v-model:value="formData.avatar" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,255 @@
<script lang="ts" setup>
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { h, onMounted, reactive, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
isEmpty,
} from '@vben/utils';
import { Button, Form, Input, message, RangePicker } from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo02Category,
exportDemo02Category,
getDemo02CategoryList,
} from '#/api/infra/demo/demo02';
import { ContentWrap } from '#/components/content-wrap';
import { TableToolbar } from '#/components/table-toolbar';
import { useTableToolbar } from '#/hooks';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo02CategoryForm from './modules/form.vue';
const loading = ref(true); // 列表的加载中
const list = ref<any[]>([]); // 树列表的数据
const queryParams = reactive({
name: undefined,
parentId: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
list.value = await getDemo02CategoryList(params);
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo02CategoryForm,
destroyOnClose: true,
});
/** 创建示例分类 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 编辑示例分类 */
function onEdit(row: Demo02CategoryApi.Demo02Category) {
formModalApi.setData(row).open();
}
/** 新增下级示例分类 */
function onAppend(row: Demo02CategoryApi.Demo02Category) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 删除示例分类 */
async function onDelete(row: Demo02CategoryApi.Demo02Category) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo02Category(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
await getList();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
try {
exportLoading.value = true;
const data = await exportDemo02Category(queryParams);
downloadFileFromBlobPart({ fileName: '示例分类.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 切换树形展开/收缩状态 */
const isExpanded = ref(true);
function toggleExpand() {
isExpanded.value = !isExpanded.value;
tableRef.value?.setAllTreeExpand(isExpanded.value);
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model:value="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="父级编号" name="parentId">
<Input
v-model:value="queryParams.parentId"
placeholder="请输入父级编号"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<RangePicker
v-model:value="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" type="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="示例分类">
<template #extra>
<TableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button @click="toggleExpand" class="mr-2">
{{ isExpanded ? '收缩' : '展开' }}
</Button>
<Button
class="ml-2"
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo02-category:create']"
>
{{ $t('ui.actionTitle.create', ['示例分类']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
:loading="exportLoading"
@click="onExport"
v-access:code="['infra:demo02-category:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</TableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
:tree-config="{
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
reserve: true,
}"
show-overflow
:loading="loading"
>
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" tree-node />
<VxeColumn field="parentId" title="父级编号" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
@click="onAppend(row as any)"
v-access:code="['infra:demo02-category:create']"
>
新增下级
</Button>
<Button
size="small"
type="link"
@click="onEdit(row as any)"
v-access:code="['infra:demo02-category:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
type="link"
danger
class="ml-2"
:disabled="!isEmpty(row?.children)"
@click="onDelete(row as any)"
v-access:code="['infra:demo02-category:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,132 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { Form, Input, message, TreeSelect } from 'ant-design-vue';
import {
createDemo02Category,
getDemo02Category,
getDemo02CategoryList,
updateDemo02Category,
} from '#/api/infra/demo/demo02';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo02CategoryApi.Demo02Category>>({
id: undefined,
name: undefined,
parentId: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
parentId: [{ required: true, message: '父级编号不能为空', trigger: 'blur' }],
};
const demo02CategoryTree = ref<any[]>([]); // 树形结构
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['示例分类'])
: $t('ui.actionTitle.create', ['示例分类']);
});
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
parentId: undefined,
};
formRef.value?.resetFields();
};
/** 获得示例分类树 */
const getDemo02CategoryTree = async () => {
demo02CategoryTree.value = [];
const data = await getDemo02CategoryList({});
data.unshift({
id: 0,
name: '顶级示例分类',
});
demo02CategoryTree.value = handleTree(data);
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo02CategoryApi.Demo02Category;
try {
await (formData.value?.id
? updateDemo02Category(data)
: createDemo02Category(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo02CategoryApi.Demo02Category>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo02Category(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
// 加载树数据
await getDemo02CategoryTree();
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="父级编号" name="parentId">
<TreeSelect
v-model:value="formData.parentId"
:tree-data="demo02CategoryTree"
:field-names="{
label: 'name',
value: 'id',
children: 'children',
}"
checkable
tree-default-expand-all
placeholder="请选择父级编号"
/>
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,311 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { h, onMounted, reactive, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
} from '@vben/utils';
import {
Button,
DatePicker,
Form,
Input,
message,
Pagination,
RangePicker,
Select,
Tabs,
} from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/erp';
import { ContentWrap } from '#/components/content-wrap';
import { DictTag } from '#/components/dict-tag';
import { TableToolbar } from '#/components/table-toolbar';
import { useTableToolbar } from '#/hooks';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
import Demo03CourseList from './modules/demo03-course-list.vue';
import Demo03GradeList from './modules/demo03-grade-list.vue';
import Demo03StudentForm from './modules/form.vue';
/** 子表的列表 */
const subTabsName = ref('demo03Course');
const selectDemo03Student = ref<Demo03StudentApi.Demo03Student>();
async function onCellClick({ row }: { row: Demo03StudentApi.Demo03Student }) {
selectDemo03Student.value = row;
}
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Student[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.birthday && Array.isArray(params.birthday)) {
params.birthday = (params.birthday as string[]).join(',');
}
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
const data = await getDemo03StudentPage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03StudentForm,
destroyOnClose: true,
});
/** 创建学生 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 编辑学生 */
function onEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function onDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Student(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
await getList();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
try {
exportLoading.value = true;
const data = await exportDemo03Student(queryParams);
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model:value="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="性别" name="sex">
<Select
v-model:value="queryParams.sex"
placeholder="请选择性别"
allow-clear
class="w-full"
>
<Select.Option
v-for="dict in getDictOptions(
DICT_TYPE.SYSTEM_USER_SEX,
'number',
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model:value="queryParams.birthday"
value-format="YYYY-MM-DD"
placeholder="选择出生日期"
allow-clear
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<RangePicker
v-model:value="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" type="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<TableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
:loading="exportLoading"
@click="onExport"
v-access:code="['infra:demo03-student:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</TableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
@cell-click="onCellClick"
:row-config="{
keyField: 'id',
isHover: true,
isCurrent: true,
}"
show-overflow
:loading="loading"
>
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="sex" title="性别" align="center">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</VxeColumn>
<VxeColumn field="birthday" title="出生日期" align="center">
<template #default="{ row }">
{{ formatDateTime(row.birthday) }}
</template>
</VxeColumn>
<VxeColumn field="description" title="简介" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
@click="onEdit(row as any)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
type="link"
danger
class="ml-2"
@click="onDelete(row as any)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
<ContentWrap>
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseList :student-id="selectDemo03Student?.id" />
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeList :student-id="selectDemo03Student?.id" />
</Tabs.TabPane>
</Tabs>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, Input, message } from 'ant-design-vue';
import {
createDemo03Course,
getDemo03Course,
updateDemo03Course,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生课程'])
: $t('ui.actionTitle.create', ['学生课程']);
});
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Course>>({
id: undefined,
studentId: undefined,
name: undefined,
score: undefined,
});
const rules: Record<string, Rule[]> = {
studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
score: [{ required: true, message: '分数不能为空', trigger: 'blur' }],
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Course;
try {
await (formData.value?.id
? updateDemo03Course(data)
: createDemo03Course(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Course>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Course(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
},
});
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
studentId: undefined,
name: undefined,
score: undefined,
};
formRef.value?.resetFields();
};
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="学生编号" name="studentId">
<Input
v-model:value="formData.studentId"
placeholder="请输入学生编号"
/>
</Form.Item>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="分数" name="score">
<Input v-model:value="formData.score" placeholder="请输入分数" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,250 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { h, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { cloneDeep, formatDateTime } from '@vben/utils';
import {
Button,
Form,
Input,
message,
Pagination,
RangePicker,
} from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Course,
getDemo03CoursePage,
} from '#/api/infra/demo/demo03/erp';
import { ContentWrap } from '#/components/content-wrap';
import { TableToolbar } from '#/components/table-toolbar';
import { useTableToolbar } from '#/hooks';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo03CourseForm from './demo03-course-form.vue';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03CourseForm,
destroyOnClose: true,
});
/** 创建学生课程 */
function onCreate() {
if (!props.studentId) {
message.warning('请先选择一个学生!');
return;
}
formModalApi.setData({ studentId: props.studentId }).open();
}
/** 编辑学生课程 */
function onEdit(row: Demo03StudentApi.Demo03Course) {
formModalApi.setData(row).open();
}
/** 删除学生课程 */
async function onDelete(row: Demo03StudentApi.Demo03Course) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Course(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
getList();
} catch {
hideLoading();
}
}
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Course[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryFormRef = ref(); // 搜索的表单
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
studentId: undefined,
name: undefined,
score: undefined,
createTime: undefined,
});
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
if (!props.studentId) {
return [];
}
const params = cloneDeep(queryParams) as any;
if (params.birthday && Array.isArray(params.birthday)) {
params.birthday = (params.birthday as string[]).join(',');
}
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
params.studentId = props.studentId;
const data = await getDemo03CoursePage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await getList();
},
{ immediate: true },
);
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<FormModal @success="getList" />
<div class="h-[600px]">
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="学生编号" name="studentId">
<Input
v-model:value="queryParams.studentId"
placeholder="请输入学生编号"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="名字" name="name">
<Input
v-model:value="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="分数" name="score">
<Input
v-model:value="queryParams.score"
placeholder="请输入分数"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<RangePicker
v-model:value="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" type="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<TableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
</TableToolbar>
</template>
<VxeTable ref="tableRef" :data="list" show-overflow :loading="loading">
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="studentId" title="学生编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="score" title="分数" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
@click="onEdit(row as any)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
type="link"
danger
class="ml-2"
@click="onDelete(row as any)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, Input, message } from 'ant-design-vue';
import {
createDemo03Grade,
getDemo03Grade,
updateDemo03Grade,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生班级'])
: $t('ui.actionTitle.create', ['学生班级']);
});
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Grade>>({
id: undefined,
studentId: undefined,
name: undefined,
teacher: undefined,
});
const rules: Record<string, Rule[]> = {
studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }],
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Grade;
try {
await (formData.value?.id
? updateDemo03Grade(data)
: createDemo03Grade(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Grade>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Grade(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
},
});
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
studentId: undefined,
name: undefined,
teacher: undefined,
};
formRef.value?.resetFields();
};
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="学生编号" name="studentId">
<Input
v-model:value="formData.studentId"
placeholder="请输入学生编号"
/>
</Form.Item>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="班主任" name="teacher">
<Input v-model:value="formData.teacher" placeholder="请输入班主任" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,250 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { h, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { cloneDeep, formatDateTime } from '@vben/utils';
import {
Button,
Form,
Input,
message,
Pagination,
RangePicker,
} from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Grade,
getDemo03GradePage,
} from '#/api/infra/demo/demo03/erp';
import { ContentWrap } from '#/components/content-wrap';
import { TableToolbar } from '#/components/table-toolbar';
import { useTableToolbar } from '#/hooks';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo03GradeForm from './demo03-grade-form.vue';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03GradeForm,
destroyOnClose: true,
});
/** 创建学生班级 */
function onCreate() {
if (!props.studentId) {
message.warning('请先选择一个学生!');
return;
}
formModalApi.setData({ studentId: props.studentId }).open();
}
/** 编辑学生班级 */
function onEdit(row: Demo03StudentApi.Demo03Grade) {
formModalApi.setData(row).open();
}
/** 删除学生班级 */
async function onDelete(row: Demo03StudentApi.Demo03Grade) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Grade(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
getList();
} catch {
hideLoading();
}
}
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Grade[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryFormRef = ref(); // 搜索的表单
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
studentId: undefined,
name: undefined,
teacher: undefined,
createTime: undefined,
});
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
if (!props.studentId) {
return [];
}
const params = cloneDeep(queryParams) as any;
if (params.birthday && Array.isArray(params.birthday)) {
params.birthday = (params.birthday as string[]).join(',');
}
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
params.studentId = props.studentId;
const data = await getDemo03GradePage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await getList();
},
{ immediate: true },
);
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<FormModal @success="getList" />
<div class="h-[600px]">
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="学生编号" name="studentId">
<Input
v-model:value="queryParams.studentId"
placeholder="请输入学生编号"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="名字" name="name">
<Input
v-model:value="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="班主任" name="teacher">
<Input
v-model:value="queryParams.teacher"
placeholder="请输入班主任"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<RangePicker
v-model:value="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" type="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<TableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
</TableToolbar>
</template>
<VxeTable ref="tableRef" :data="list" show-overflow :loading="loading">
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="studentId" title="学生编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="teacher" title="班主任" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
@click="onEdit(row as any)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
type="link"
danger
class="ml-2"
@click="onDelete(row as any)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</div>
</template>

View File

@@ -0,0 +1,138 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
DatePicker,
Form,
Input,
message,
Radio,
RadioGroup,
} from 'ant-design-vue';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/erp';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions } from '#/utils';
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Student>>({
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
description: [{ required: true, message: '简介不能为空', trigger: 'blur' }],
};
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
};
formRef.value?.resetFields();
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Student;
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="性别" name="sex">
<RadioGroup v-model:value="formData.sex">
<Radio
v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number')"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model:value="formData.birthday"
value-format="x"
placeholder="选择出生日期"
/>
</Form.Item>
<Form.Item label="简介" name="description">
<RichTextarea v-model="formData.description" height="500px" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,298 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { h, onMounted, reactive, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
} from '@vben/utils';
import {
Button,
DatePicker,
Form,
Input,
message,
Pagination,
RangePicker,
Select,
Tabs,
} from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/normal';
import { ContentWrap } from '#/components/content-wrap';
import { DictTag } from '#/components/dict-tag';
import { TableToolbar } from '#/components/table-toolbar';
import { useTableToolbar } from '#/hooks';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
import Demo03CourseList from './modules/demo03-course-list.vue';
import Demo03GradeList from './modules/demo03-grade-list.vue';
import Demo03StudentForm from './modules/form.vue';
/** 子表的列表 */
const subTabsName = ref('demo03Course');
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Student[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.birthday && Array.isArray(params.birthday)) {
params.birthday = (params.birthday as string[]).join(',');
}
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
const data = await getDemo03StudentPage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03StudentForm,
destroyOnClose: true,
});
/** 创建学生 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 编辑学生 */
function onEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function onDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Student(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
await getList();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
try {
exportLoading.value = true;
const data = await exportDemo03Student(queryParams);
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model:value="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="性别" name="sex">
<Select
v-model:value="queryParams.sex"
placeholder="请选择性别"
allow-clear
class="w-full"
>
<Select.Option
v-for="dict in getDictOptions(
DICT_TYPE.SYSTEM_USER_SEX,
'number',
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model:value="queryParams.birthday"
value-format="YYYY-MM-DD"
placeholder="选择出生日期"
allow-clear
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<RangePicker
v-model:value="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" type="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<TableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
:loading="exportLoading"
@click="onExport"
v-access:code="['infra:demo03-student:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</TableToolbar>
</template>
<VxeTable ref="tableRef" :data="list" show-overflow :loading="loading">
<!-- 子表的列表 -->
<VxeColumn type="expand" width="60">
<template #content="{ row }">
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName" class="mx-8">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseList :student-id="row?.id" />
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeList :student-id="row?.id" />
</Tabs.TabPane>
</Tabs>
</template>
</VxeColumn>
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="sex" title="性别" align="center">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</VxeColumn>
<VxeColumn field="birthday" title="出生日期" align="center">
<template #default="{ row }">
{{ formatDateTime(row.birthday) }}
</template>
</VxeColumn>
<VxeColumn field="description" title="简介" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
@click="onEdit(row as any)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
type="link"
danger
class="ml-2"
@click="onDelete(row as any)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { h, ref, watch } from 'vue';
import { Plus } from '@vben/icons';
import { Button, Input } from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const list = ref<Demo03StudentApi.Demo03Course[]>([]); // 列表的数据
const tableRef = ref<VxeTableInstance>();
/** 添加学生课程 */
const onAdd = async () => {
await tableRef.value?.insertAt({} as Demo03StudentApi.Demo03Course, -1);
};
/** 删除学生课程 */
const onDelete = async (row: Demo03StudentApi.Demo03Course) => {
await tableRef.value?.remove(row);
};
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): Demo03StudentApi.Demo03Course[] => {
const data = list.value as Demo03StudentApi.Demo03Course[];
const removeRecords =
tableRef.value?.getRemoveRecords() as Demo03StudentApi.Demo03Course[];
const insertRecords =
tableRef.value?.getInsertRecords() as Demo03StudentApi.Demo03Course[];
return data
.filter((row) => !removeRecords.some((removed) => removed.id === row.id))
?.concat(insertRecords.map((row: any) => ({ ...row, id: undefined })));
},
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
list.value = await getDemo03CourseListByStudentId(props.studentId!);
},
{ immediate: true },
);
</script>
<template>
<VxeTable ref="tableRef" :data="list" show-overflow class="mx-4">
<VxeColumn field="name" title="名字" align="center">
<template #default="{ row }">
<Input v-model:value="row.name" />
</template>
</VxeColumn>
<VxeColumn field="score" title="分数" align="center">
<template #default="{ row }">
<Input v-model:value="row.score" />
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
danger
@click="onDelete(row as any)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<div class="mt-4 flex justify-center">
<Button
:icon="h(Plus)"
type="primary"
ghost
@click="onAdd"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, ref, watch } from 'vue';
import { formatDateTime } from '@vben/utils';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/normal';
import { ContentWrap } from '#/components/content-wrap';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Course[]>([]); // 列表的数据
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
if (!props.studentId) {
return [];
}
list.value = await getDemo03CourseListByStudentId(props.studentId!);
} finally {
loading.value = false;
}
};
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await getList();
},
{ immediate: true },
);
</script>
<template>
<ContentWrap title="学生课程列表">
<VxeTable :data="list" show-overflow :loading="loading">
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="studentId" title="学生编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="score" title="分数" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
</VxeTable>
</ContentWrap>
</template>

View File

@@ -0,0 +1,67 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, ref, watch } from 'vue';
import { Form, Input } from 'ant-design-vue';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/normal';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Grade>>({
id: undefined,
studentId: undefined,
name: undefined,
teacher: undefined,
});
const rules: Record<string, Rule[]> = {
studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }],
};
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => await formRef.value?.validate(),
getValues: () => formData.value,
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
formData.value = await getDemo03GradeByStudentId(props.studentId!);
},
{ immediate: true },
);
</script>
<template>
<Form
ref="formRef"
class="mx-4"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="学生编号" name="studentId">
<Input v-model:value="formData.studentId" placeholder="请输入学生编号" />
</Form.Item>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="班主任" name="teacher">
<Input v-model:value="formData.teacher" placeholder="请输入班主任" />
</Form.Item>
</Form>
</template>

View File

@@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, ref, watch } from 'vue';
import { formatDateTime } from '@vben/utils';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/normal';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Grade[]>([]); // 列表的数据
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
if (!props.studentId) {
return [];
}
list.value = [await getDemo03GradeByStudentId(props.studentId!)];
} finally {
loading.value = false;
}
};
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await getList();
},
{ immediate: true },
);
</script>
<template>
<ContentWrap title="学生班级列表">
<VxeTable :data="list" show-overflow :loading="loading">
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="studentId" title="学生编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="teacher" title="班主任" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
</VxeTable>
</ContentWrap>
</template>

View File

@@ -0,0 +1,170 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
DatePicker,
Form,
Input,
message,
Radio,
RadioGroup,
Tabs,
} from 'ant-design-vue';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/normal';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions } from '#/utils';
import Demo03CourseForm from './demo03-course-form.vue';
import Demo03GradeForm from './demo03-grade-form.vue';
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Student>>({
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
description: [{ required: true, message: '简介不能为空', trigger: 'blur' }],
};
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 子表的表单 */
const subTabsName = ref('demo03Course');
const demo03CourseFormRef = ref<InstanceType<typeof Demo03CourseForm>>();
const demo03GradeFormRef = ref<InstanceType<typeof Demo03GradeForm>>();
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
};
formRef.value?.resetFields();
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
// 校验子表单
try {
await demo03GradeFormRef.value?.validate();
} catch {
subTabsName.value = 'demo03Grade';
return;
}
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Student;
// 拼接子表的数据
data.demo03courses = demo03CourseFormRef.value?.getData();
data.demo03grade =
demo03GradeFormRef.value?.getValues() as Demo03StudentApi.Demo03Grade;
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="性别" name="sex">
<RadioGroup v-model:value="formData.sex">
<Radio
v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number')"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model:value="formData.birthday"
value-format="x"
placeholder="选择出生日期"
/>
</Form.Item>
<Form.Item label="简介" name="description">
<RichTextarea v-model="formData.description" height="500px" />
</Form.Item>
</Form>
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseForm
ref="demo03CourseFormRef"
:student-id="formData?.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData?.id" />
</Tabs.TabPane>
</Tabs>
</Modal>
</template>

View File

@@ -0,0 +1,278 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { h, onMounted, reactive, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
} from '@vben/utils';
import {
Button,
DatePicker,
Form,
Input,
message,
Pagination,
RangePicker,
Select,
} from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/normal';
import { ContentWrap } from '#/components/content-wrap';
import { DictTag } from '#/components/dict-tag';
import { TableToolbar } from '#/components/table-toolbar';
import { useTableToolbar } from '#/hooks';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
import Demo03StudentForm from './modules/form.vue';
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Student[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.birthday && Array.isArray(params.birthday)) {
params.birthday = (params.birthday as string[]).join(',');
}
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
const data = await getDemo03StudentPage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03StudentForm,
destroyOnClose: true,
});
/** 创建学生 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 编辑学生 */
function onEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function onDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo03Student(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
await getList();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
try {
exportLoading.value = true;
const data = await exportDemo03Student(queryParams);
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model:value="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="性别" name="sex">
<Select
v-model:value="queryParams.sex"
placeholder="请选择性别"
allow-clear
class="w-full"
>
<Select.Option
v-for="dict in getDictOptions(
DICT_TYPE.SYSTEM_USER_SEX,
'number',
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model:value="queryParams.birthday"
value-format="YYYY-MM-DD"
placeholder="选择出生日期"
allow-clear
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<RangePicker
v-model:value="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" type="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<TableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
:loading="exportLoading"
@click="onExport"
v-access:code="['infra:demo03-student:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</TableToolbar>
</template>
<VxeTable ref="tableRef" :data="list" show-overflow :loading="loading">
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="sex" title="性别" align="center">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</VxeColumn>
<VxeColumn field="birthday" title="出生日期" align="center">
<template #default="{ row }">
{{ formatDateTime(row.birthday) }}
</template>
</VxeColumn>
<VxeColumn field="description" title="简介" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
@click="onEdit(row as any)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
type="link"
danger
class="ml-2"
@click="onDelete(row as any)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { h, ref, watch } from 'vue';
import { Plus } from '@vben/icons';
import { Button, Input } from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const list = ref<Demo03StudentApi.Demo03Course[]>([]); // 列表的数据
const tableRef = ref<VxeTableInstance>();
/** 添加学生课程 */
const onAdd = async () => {
await tableRef.value?.insertAt({} as Demo03StudentApi.Demo03Course, -1);
};
/** 删除学生课程 */
const onDelete = async (row: Demo03StudentApi.Demo03Course) => {
await tableRef.value?.remove(row);
};
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): Demo03StudentApi.Demo03Course[] => {
const data = list.value as Demo03StudentApi.Demo03Course[];
const removeRecords =
tableRef.value?.getRemoveRecords() as Demo03StudentApi.Demo03Course[];
const insertRecords =
tableRef.value?.getInsertRecords() as Demo03StudentApi.Demo03Course[];
return data
.filter((row) => !removeRecords.some((removed) => removed.id === row.id))
?.concat(insertRecords.map((row: any) => ({ ...row, id: undefined })));
},
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
list.value = await getDemo03CourseListByStudentId(props.studentId!);
},
{ immediate: true },
);
</script>
<template>
<VxeTable ref="tableRef" :data="list" show-overflow class="mx-4">
<VxeColumn field="name" title="名字" align="center">
<template #default="{ row }">
<Input v-model:value="row.name" />
</template>
</VxeColumn>
<VxeColumn field="score" title="分数" align="center">
<template #default="{ row }">
<Input v-model:value="row.score" />
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
type="link"
danger
@click="onDelete(row as any)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<div class="mt-4 flex justify-center">
<Button
:icon="h(Plus)"
type="primary"
ghost
@click="onAdd"
v-access:code="['infra:demo03-student:create']"
>
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, ref, watch } from 'vue';
import { Form, Input } from 'ant-design-vue';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/normal';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Grade>>({
id: undefined,
studentId: undefined,
name: undefined,
teacher: undefined,
});
const rules: Record<string, Rule[]> = {
studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }],
};
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => await formRef.value?.validate(),
getValues: () => formData.value,
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
formData.value = await getDemo03GradeByStudentId(props.studentId!);
},
{ immediate: true },
);
</script>
<template>
<Form
ref="formRef"
class="mx-4"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="学生编号" name="studentId">
<Input v-model:value="formData.studentId" placeholder="请输入学生编号" />
</Form.Item>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="班主任" name="teacher">
<Input v-model:value="formData.teacher" placeholder="请输入班主任" />
</Form.Item>
</Form>
</template>

View File

@@ -0,0 +1,169 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
DatePicker,
Form,
Input,
message,
Radio,
RadioGroup,
Tabs,
} from 'ant-design-vue';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/normal';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions } from '#/utils';
import Demo03CourseForm from './demo03-course-form.vue';
import Demo03GradeForm from './demo03-grade-form.vue';
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Student>>({
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
description: [{ required: true, message: '简介不能为空', trigger: 'blur' }],
};
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 子表的表单 */
const subTabsName = ref('demo03Course');
const demo03CourseFormRef = ref<InstanceType<typeof Demo03CourseForm>>();
const demo03GradeFormRef = ref<InstanceType<typeof Demo03GradeForm>>();
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
};
formRef.value?.resetFields();
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
// 校验子表单
try {
await demo03GradeFormRef.value?.validate();
} catch {
subTabsName.value = 'demo03Grade';
return;
}
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Student;
// 拼接子表的数据
data.demo03courses = demo03CourseFormRef.value?.getData();
data.demo03grade = demo03GradeFormRef.value?.getValues();
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model:value="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="性别" name="sex">
<RadioGroup v-model:value="formData.sex">
<Radio
v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number')"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model:value="formData.birthday"
value-format="x"
placeholder="选择出生日期"
/>
</Form.Item>
<Form.Item label="简介" name="description">
<RichTextarea v-model="formData.description" height="500px" />
</Form.Item>
</Form>
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseForm
ref="demo03CourseFormRef"
:student-id="formData?.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData?.id" />
</Tabs.TabPane>
</Tabs>
</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,147 @@
<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 { Button, Image, message } from 'ant-design-vue';
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>
<Button type="primary" @click="onUpload">
<Upload class="size-5" />
上传图片
</Button>
</template>
<template #file-content="{ row }">
<Image v-if="row.type && row.type.includes('image')" :src="row.url" />
<Button
v-else-if="row.type && row.type.includes('pdf')"
type="link"
@click="() => openUrl(row.url)"
>
预览
</Button>
<Button v-else type="link" @click="() => openUrl(row.url)">
下载
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { useVbenModal } from '@vben/common-ui';
import { message, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
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: FileType) {
// TODO @puhui999研究下看看怎么类似 antd 可以前端直传哈;通过配置切换;
formApi.setFieldValue('file', file);
return false;
}
</script>
<template>
<Modal title="上传图片">
<Form class="mx-4">
<template #file>
<div class="w-full">
<!-- 上传区域 -->
<!-- TODO @puhui9991上传图片用不了2底部有点遮挡 -->
<Upload.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>
</Upload.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,173 @@
<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 { Button, message } from 'ant-design-vue';
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>
<Button
type="primary"
@click="onCreate"
v-access:code="['infra:file-config:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['文件配置']) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { InfraFileConfigApi } from '#/api/infra/file-config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
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,223 @@
<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 { Button, message } from 'ant-design-vue';
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>
<Button
type="primary"
@click="onCreate"
v-access:code="['infra:job:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['任务']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:job:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onLog(undefined)"
v-access:code="['infra:job:query']"
>
<History class="size-5" />
执行日志
</Button>
</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 { Button } from 'ant-design-vue';
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;
}
}
}
// 获取表单schema并设置默认jobId
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>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:job:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</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 { Descriptions } from 'ant-design-vue';
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"
>
<Descriptions
:column="1"
bordered
size="middle"
class="mx-4"
:label-style="{ width: '140px' }"
>
<Descriptions.Item label="日志编号">
{{ formData?.id }}
</Descriptions.Item>
<Descriptions.Item label="任务编号">
{{ formData?.jobId }}
</Descriptions.Item>
<Descriptions.Item label="处理器的名字">
{{ formData?.handlerName }}
</Descriptions.Item>
<Descriptions.Item label="处理器的参数">
{{ formData?.handlerParam }}
</Descriptions.Item>
<Descriptions.Item label="第几次执行">
{{ formData?.executeIndex }}
</Descriptions.Item>
<Descriptions.Item label="执行时间">
{{ formData?.beginTime ? formatDateTime(formData.beginTime) : '' }} ~
{{ formData?.endTime ? formatDateTime(formData.endTime) : '' }}
</Descriptions.Item>
<Descriptions.Item label="执行时长">
{{ formData?.duration ? `${formData.duration} 毫秒` : '' }}
</Descriptions.Item>
<Descriptions.Item label="任务状态">
<DictTag
:type="DICT_TYPE.INFRA_JOB_LOG_STATUS"
:value="formData?.status"
/>
</Descriptions.Item>
<Descriptions.Item label="执行结果">
{{ formData?.result }}
</Descriptions.Item>
</Descriptions>
</Modal>
</template>

View File

@@ -0,0 +1,101 @@
<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 { Descriptions, Timeline } from 'ant-design-vue';
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"
>
<Descriptions
:column="1"
bordered
size="middle"
class="mx-4"
:label-style="{ width: '140px' }"
>
<Descriptions.Item label="任务编号">
{{ formData?.id }}
</Descriptions.Item>
<Descriptions.Item label="任务名称">
{{ formData?.name }}
</Descriptions.Item>
<Descriptions.Item label="任务状态">
<DictTag :type="DICT_TYPE.INFRA_JOB_STATUS" :value="formData?.status" />
</Descriptions.Item>
<Descriptions.Item label="处理器的名字">
{{ formData?.handlerName }}
</Descriptions.Item>
<Descriptions.Item label="处理器的参数">
{{ formData?.handlerParam }}
</Descriptions.Item>
<Descriptions.Item label="Cron 表达式">
{{ formData?.cronExpression }}
</Descriptions.Item>
<Descriptions.Item label="重试次数">
{{ formData?.retryCount }}
</Descriptions.Item>
<Descriptions.Item label="重试间隔">
{{
formData?.retryInterval ? `${formData.retryInterval} 毫秒` : '无间隔'
}}
</Descriptions.Item>
<Descriptions.Item label="监控超时时间">
{{
formData?.monitorTimeout && formData.monitorTimeout > 0
? `${formData.monitorTimeout} 毫秒`
: '未开启'
}}
</Descriptions.Item>
<Descriptions.Item label="后续执行时间">
<Timeline class="h-[180px]">
<Timeline.Item
v-for="(nextTime, index) in nextTimes"
:key="index"
color="blue"
>
{{ index + 1 }} {{ formatDateTime(nextTime.toString()) }}
</Timeline.Item>
</Timeline>
</Descriptions.Item>
</Descriptions>
</Modal>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { InfraJobApi } from '#/api/infra/job';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
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 { Card } from 'ant-design-vue';
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>
<Card class="mt-5" title="Redis 概览">
<Info :redis-data="redisData" />
</Card>
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
<Card title="内存使用">
<Memory :redis-data="redisData" />
</Card>
<Card title="命令统计">
<Commands :redis-data="redisData" />
</Card>
</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 { Descriptions } from 'ant-design-vue';
defineProps<{
redisData?: InfraRedisApi.RedisMonitorInfo;
}>();
</script>
<template>
<Descriptions
:column="6"
bordered
size="middle"
:label-style="{ width: '138px' }"
>
<Descriptions.Item label="Redis 版本">
{{ redisData?.info?.redis_version }}
</Descriptions.Item>
<Descriptions.Item label="运行模式">
{{ redisData?.info?.redis_mode === 'standalone' ? '单机' : '集群' }}
</Descriptions.Item>
<Descriptions.Item label="端口">
{{ redisData?.info?.tcp_port }}
</Descriptions.Item>
<Descriptions.Item label="客户端数">
{{ redisData?.info?.connected_clients }}
</Descriptions.Item>
<Descriptions.Item label="运行时间(天)">
{{ redisData?.info?.uptime_in_days }}
</Descriptions.Item>
<Descriptions.Item label="使用内存">
{{ redisData?.info?.used_memory_human }}
</Descriptions.Item>
<Descriptions.Item label="使用 CPU">
{{
redisData?.info
? parseFloat(redisData?.info?.used_cpu_user_children).toFixed(2)
: ''
}}
</Descriptions.Item>
<Descriptions.Item label="内存配置">
{{ redisData?.info?.maxmemory_human }}
</Descriptions.Item>
<Descriptions.Item label="AOF 是否开启">
{{ redisData?.info?.aof_enabled === '0' ? '否' : '是' }}
</Descriptions.Item>
<Descriptions.Item label="RDB 是否成功">
{{ redisData?.info?.rdb_last_bgsave_status }}
</Descriptions.Item>
<Descriptions.Item label="Key 数量">
{{ redisData?.dbSize }}
</Descriptions.Item>
<Descriptions.Item label="网络入口/出口">
{{ redisData?.info?.instantaneous_input_kbps }}kps /
{{ redisData?.info?.instantaneous_output_kbps }}kps
</Descriptions.Item>
</Descriptions>
</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,318 @@
<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 {
Avatar,
Badge,
Button,
Card,
Divider,
Empty,
Input,
message,
Select,
Tag,
} from 'ant-design-vue';
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">
<!-- 左侧建立连接发送消息 -->
<Card :bordered="false" class="w-full md:w-1/2">
<template #title>
<div class="flex items-center">
<Badge :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>
<Tag :color="getTagColor" class="px-3 py-1">{{ getStatusText }}</Tag>
</div>
<div class="mb-6 flex space-x-2">
<Input
v-model:value="server"
disabled
class="rounded-md"
size="large"
>
<template #addonBefore>
<span class="text-gray-600">服务地址</span>
</template>
</Input>
<Button
:type="getIsOpen ? 'default' : 'primary'"
:danger="getIsOpen"
size="large"
class="flex-shrink-0"
@click="toggleConnectStatus"
>
{{ getIsOpen ? '关闭连接' : '开启连接' }}
</Button>
</div>
<Divider>
<span class="text-gray-500">消息发送</span>
</Divider>
<Select
v-model:value="sendUserId"
class="mb-3 w-full"
size="large"
placeholder="请选择接收人"
:disabled="!getIsOpen"
>
<Select.Option key="" value="" label="所有人">
<div class="flex items-center">
<Avatar size="small" class="mr-2"></Avatar>
<span>所有人</span>
</div>
</Select.Option>
<Select.Option
v-for="user in userList"
:key="user.id"
:value="user.id"
:label="user.nickname"
>
<div class="flex items-center">
<Avatar size="small" class="mr-2">
{{ user.nickname.slice(0, 1) }}
</Avatar>
<span>{{ user.nickname }}</span>
</div>
</Select.Option>
</Select>
<Input.TextArea
v-model:value="sendText"
:auto-size="{ minRows: 3, maxRows: 6 }"
:disabled="!getIsOpen"
class="border-1 rounded-lg"
allow-clear
placeholder="请输入你要发送的消息..."
/>
<Button
: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>
发送消息
</Button>
</Card>
<!-- 右侧消息记录 -->
<Card :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>
<Tag v-if="messageList.length > 0" class="ml-2">
{{ messageList.length }}
</Tag>
</div>
</template>
<div class="h-96 overflow-auto rounded-lg p-2">
<Empty 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">
<Badge :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>
</Card>
</div>
</Page>
</template>