Merge branch 'dev' of https://gitee.com/yudaocode/yudao-ui-admin-vben into dev
commit
2f1813af41
|
@ -3,6 +3,7 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace BpmFormApi {
|
||||
// TODO @siye:注释加一个。。嘿嘿
|
||||
export interface FormVO {
|
||||
id?: number | undefined;
|
||||
name: string;
|
||||
|
@ -11,7 +12,6 @@ export namespace BpmFormApi {
|
|||
status: number;
|
||||
remark: string;
|
||||
createTime: string;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,6 @@ const routes: RouteRecordRaw[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: 'process-instance/detail',
|
||||
component: () => import('#/views/bpm/processInstance/detail/index.vue'),
|
||||
|
@ -47,8 +46,6 @@ const routes: RouteRecordRaw[] = [
|
|||
};
|
||||
},
|
||||
},
|
||||
|
||||
/** 编辑流程表单 */
|
||||
{
|
||||
path: '/bpm/manager/form/edit',
|
||||
name: 'BpmFormEditor',
|
||||
|
|
|
@ -91,20 +91,17 @@ export function useGridColumns<T = BpmFormApi.FormVO>(
|
|||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'remark',
|
||||
title: '备注',
|
||||
minWidth: 200,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
{
|
||||
field: 'operation',
|
||||
title: '操作',
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
// TODO @siye:editor 要不要放到独立的目录?form/designer ?
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
@ -16,6 +17,7 @@ import Form from './modules/form.vue';
|
|||
|
||||
defineOptions({ name: 'BpmFormEditor' });
|
||||
|
||||
// TODO @siye:这里有 lint 告警
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Props {
|
||||
|
@ -119,6 +121,7 @@ function handleSave() {
|
|||
.open();
|
||||
}
|
||||
|
||||
// TODO @siye:一些必要的注释,稍微写写哈。保持风格的一致性;
|
||||
function onBack() {
|
||||
router.push({
|
||||
path: '/bpm/manager/form',
|
||||
|
|
|
@ -179,6 +179,7 @@ watch(
|
|||
</template>
|
||||
|
||||
<!-- 摘要 -->
|
||||
<!-- TODO @siye:这个是不是不应该有呀? -->
|
||||
<template #slot-summary="{ row }">
|
||||
<div
|
||||
class="flex flex-col py-2"
|
||||
|
|
|
@ -39,6 +39,7 @@ const [Form, formApi] = useVbenForm({
|
|||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
// TODO @siye:建议和别的模块,也稍微加点类似的注释哈。= = 阅读总是会有点层次感;
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
|
@ -49,6 +50,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||
data.conf = encodeConf(designerComponent);
|
||||
data.fields = encodeFields(designerComponent);
|
||||
|
||||
// TODO @siye:这个是不是不用抽方法呀,直接写逻辑就完事啦。
|
||||
const saveForm = async () => {
|
||||
if (!formData.value?.id) {
|
||||
return createForm(data);
|
||||
|
@ -74,6 +76,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||
return;
|
||||
}
|
||||
|
||||
// TODO @siye:建议和别的模块,也稍微加点类似的注释哈。= = 阅读总是会有点层次感;
|
||||
const data = modalApi.getData<any>();
|
||||
if (!data) return;
|
||||
|
||||
|
|
|
@ -43,11 +43,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
});
|
||||
},
|
||||
querySuccess: (params) => {
|
||||
// TODO @siye:getLeaderName?: (userId: number) => string | undefined, 参考这个哈。
|
||||
const { list } = params.response;
|
||||
const userMap = new Map(
|
||||
userList.value.map((user) => [user.id, user.nickname]),
|
||||
);
|
||||
|
||||
list.forEach(
|
||||
(item: BpmUserGroupApi.UserGroupVO & { nicknames?: string }) => {
|
||||
item.nicknames = item.userIds
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { createIconifyIcon } from '@vben/icons';
|
||||
|
||||
// bpm 图标
|
||||
// TODO @siye:可以新建出一个 bpm 目录哇?icons/bpm;
|
||||
const SvgBpmRunningIcon = createIconifyIcon('svg:bpm-running');
|
||||
const SvgBpmApproveIcon = createIconifyIcon('svg:bpm-approve');
|
||||
const SvgBpmRejectIcon = createIconifyIcon('svg:bpm-reject');
|
||||
|
|
|
@ -20,7 +20,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
allowClear: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '抄送时间',
|
||||
|
@ -51,26 +50,22 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
default: 'slot-summary',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'startUser.nickname',
|
||||
title: '流程发起人',
|
||||
minWidth: 120,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'processInstanceStartTime',
|
||||
title: '流程发起时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
{
|
||||
field: 'activityName',
|
||||
title: '抄送节点',
|
||||
minWidth: 180,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'createUser.nickname',
|
||||
title: '抄送人',
|
||||
|
@ -84,14 +79,12 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
title: '抄送意见',
|
||||
minWidth: 180,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '抄送时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
{
|
||||
field: 'operation',
|
||||
title: '操作',
|
||||
|
|
|
@ -63,11 +63,11 @@ function onActionClick({
|
|||
|
||||
/** 办理任务 */
|
||||
function onDetail(row: BpmProcessInstanceApi.ProcessInstanceVO) {
|
||||
// TODO @siye:row 的类型是不是不对哈?需要改成 copyvo 么?
|
||||
const query = {
|
||||
id: row.processInstanceId,
|
||||
...(row.activityId && { activityId: row.activityId }),
|
||||
};
|
||||
|
||||
router.push({
|
||||
name: 'BpmProcessInstanceDetail',
|
||||
query,
|
||||
|
@ -82,10 +82,12 @@ function onRefresh() {
|
|||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- TODO @siye:应该用 <template #doc>,这样高度可以被用进去哈 -->
|
||||
<DocAlert
|
||||
title="审批转办、委派、抄送"
|
||||
url="https://doc.iocoder.cn/bpm/task-delegation-and-cc/"
|
||||
/>
|
||||
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="抄送任务">
|
||||
<!-- 摘要 -->
|
||||
|
@ -102,7 +104,6 @@ function onRefresh() {
|
|||
</div>
|
||||
<div v-else>-</div>
|
||||
</template>
|
||||
|
||||
<!-- 抄送人 -->
|
||||
<template #slot-createUser="{ row }">
|
||||
<span class="text-gray-500">
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
getRangePickerDefaultProps,
|
||||
} from '#/utils';
|
||||
|
||||
// TODO @siye:这个要去掉么?没用到
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
|
@ -35,7 +36,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
allowClear: true,
|
||||
},
|
||||
},
|
||||
// 流程分类
|
||||
{
|
||||
fieldName: 'category',
|
||||
label: '流程分类',
|
||||
|
@ -48,7 +48,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
valueField: 'code',
|
||||
},
|
||||
},
|
||||
// 流程状态
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '流程状态',
|
||||
|
@ -93,7 +92,6 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
default: 'slot-summary',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'processInstance.startUser.nickname',
|
||||
title: '发起人',
|
||||
|
@ -110,21 +108,18 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
title: '当前任务',
|
||||
minWidth: 180,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '任务开始时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
{
|
||||
field: 'endTime',
|
||||
title: '任务结束时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
{
|
||||
field: 'status',
|
||||
title: '审批状态',
|
||||
|
@ -134,14 +129,11 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
props: { type: DICT_TYPE.BPM_TASK_STATUS },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'reason',
|
||||
title: '审批建议',
|
||||
minWidth: 180,
|
||||
},
|
||||
|
||||
// 耗时
|
||||
{
|
||||
field: 'durationInMillis',
|
||||
title: '耗时',
|
||||
|
@ -150,19 +142,16 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
return `${formatPast2(row.durationInMillis)}`;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'processInstanceId',
|
||||
title: '流程编号',
|
||||
minWidth: 280,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'id',
|
||||
title: '任务编号',
|
||||
minWidth: 280,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'operation',
|
||||
title: '操作',
|
||||
|
|
|
@ -16,7 +16,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
allowClear: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
|
@ -47,7 +46,6 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
default: 'slot-summary',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'processInstance.startUser.nickname',
|
||||
title: '发起人',
|
||||
|
@ -64,28 +62,23 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
title: '当前任务',
|
||||
minWidth: 180,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '任务开始时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
{
|
||||
field: 'endTime',
|
||||
title: '任务结束时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
// 审批人
|
||||
{
|
||||
field: 'assigneeUser.nickname',
|
||||
title: '审批人',
|
||||
minWidth: 180,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'status',
|
||||
title: '审批状态',
|
||||
|
@ -95,14 +88,11 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
props: { type: DICT_TYPE.BPM_TASK_STATUS },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'reason',
|
||||
title: '审批建议',
|
||||
minWidth: 180,
|
||||
},
|
||||
|
||||
// 耗时
|
||||
{
|
||||
field: 'durationInMillis',
|
||||
title: '耗时',
|
||||
|
@ -111,19 +101,16 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
return `${formatPast2(row.durationInMillis)}`;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'processInstanceId',
|
||||
title: '流程编号',
|
||||
minWidth: 280,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'id',
|
||||
title: '任务编号',
|
||||
minWidth: 280,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'operation',
|
||||
title: '操作',
|
||||
|
|
|
@ -64,6 +64,7 @@ function onHistory(row: BpmTaskApi.TaskVO) {
|
|||
router.push({
|
||||
name: 'BpmProcessInstanceDetail',
|
||||
query: {
|
||||
// TODO @siye:数据类型,会爆红哈;
|
||||
id: row.processInstance.id,
|
||||
},
|
||||
});
|
||||
|
@ -78,9 +79,11 @@ function onRefresh() {
|
|||
<template>
|
||||
<Page auto-content-height>
|
||||
<DocAlert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
|
||||
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="流程任务">
|
||||
<!-- 摘要 -->
|
||||
<!-- TODO siye:这个要不要,也放到 data.ts 处理掉? -->
|
||||
<template #slot-summary="{ row }">
|
||||
<div
|
||||
class="flex flex-col py-2"
|
||||
|
|
|
@ -30,7 +30,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
allowClear: true,
|
||||
},
|
||||
},
|
||||
// 流程分类
|
||||
{
|
||||
fieldName: 'category',
|
||||
label: '流程分类',
|
||||
|
@ -43,7 +42,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
valueField: 'code',
|
||||
},
|
||||
},
|
||||
// 流程状态
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '流程状态',
|
||||
|
@ -57,7 +55,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
allowClear: true,
|
||||
},
|
||||
},
|
||||
// 发起时间
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '发起时间',
|
||||
|
@ -88,45 +85,38 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
|
|||
default: 'slot-summary',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: 'processInstance.startUser.nickname',
|
||||
title: '发起人',
|
||||
minWidth: 120,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '发起时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
{
|
||||
field: 'name',
|
||||
title: '当前任务',
|
||||
minWidth: 180,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '任务时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
|
||||
{
|
||||
field: 'processInstanceId',
|
||||
title: '流程编号',
|
||||
minWidth: 280,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'id',
|
||||
title: '任务编号',
|
||||
minWidth: 280,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'operation',
|
||||
title: '操作',
|
||||
|
|
|
@ -92,6 +92,7 @@ function onRefresh() {
|
|||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="待办任务">
|
||||
<!-- 摘要 -->
|
||||
<!-- TODO siye:这个要不要,也放到 data.ts 处理掉? -->
|
||||
<template #slot-summary="{ row }">
|
||||
<div
|
||||
class="flex flex-col py-2"
|
||||
|
|
|
@ -50,6 +50,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||
|
||||
/** 上传前 */
|
||||
function beforeUpload(file: FileType) {
|
||||
// TODO @puhui999:研究下,看看怎么类似 antd 可以前端直传哈;通过配置切换;
|
||||
formApi.setFieldValue('file', file);
|
||||
return false;
|
||||
}
|
||||
|
@ -61,6 +62,7 @@ function beforeUpload(file: FileType) {
|
|||
<template #file>
|
||||
<div class="w-full">
|
||||
<!-- 上传区域 -->
|
||||
<!-- TODO @puhui999:1)上传图片,用不了;2)底部有点遮挡 -->
|
||||
<Upload.Dragger
|
||||
name="file"
|
||||
:max-count="1"
|
||||
|
|
|
@ -26,6 +26,8 @@
|
|||
"#/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@form-create/designer": "^3.2.6",
|
||||
"@form-create/element-ui": "^3.2.11",
|
||||
"@tinymce/tinymce-vue": "catalog:",
|
||||
"@vben/access": "workspace:*",
|
||||
"@vben/common-ui": "workspace:*",
|
||||
|
|
|
@ -8,6 +8,9 @@ import type { ComponentType } from './component';
|
|||
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
/** 手机号正则表达式(中国) */
|
||||
const MOBILE_REGEX = /(?:0|86|\+86)?1[3-9]\d{9}/;
|
||||
|
||||
setupVbenForm<ComponentType>({
|
||||
config: {
|
||||
modelPropNameMap: {
|
||||
|
@ -16,18 +19,39 @@ setupVbenForm<ComponentType>({
|
|||
},
|
||||
},
|
||||
defineRules: {
|
||||
// 输入项目必填国际化适配
|
||||
required: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
return $t('ui.formRules.required', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// 选择项目必填国际化适配
|
||||
selectRequired: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null) {
|
||||
return $t('ui.formRules.selectRequired', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// 手机号非必填
|
||||
mobile: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
return true;
|
||||
} else if (!MOBILE_REGEX.test(value)) {
|
||||
return $t('ui.formRules.mobile', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// 手机号必填
|
||||
mobileRequired: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
return $t('ui.formRules.required', [ctx.label]);
|
||||
}
|
||||
if (!MOBILE_REGEX.test(value)) {
|
||||
return $t('ui.formRules.mobile', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import { useTitle } from '@vueuse/core';
|
|||
import { ElLoading } from 'element-plus';
|
||||
|
||||
import { $t, setupI18n } from '#/locales';
|
||||
import { setupFormCreate } from '#/plugins/form-create';
|
||||
|
||||
import { initComponentAdapter } from './adapter/component';
|
||||
import App from './app.vue';
|
||||
|
@ -58,6 +59,9 @@ async function bootstrap(namespace: string) {
|
|||
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||
app.use(MotionPlugin);
|
||||
|
||||
// formCreate
|
||||
setupFormCreate(app);
|
||||
|
||||
// 动态更新标题
|
||||
watchEffect(() => {
|
||||
if (preferences.app.dynamicTitle) {
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { CropperAvatarProps } from './typing';
|
||||
|
||||
import { computed, ref, unref, watch, watchEffect } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
import cropperModal from './cropper-modal.vue';
|
||||
|
||||
defineOptions({ name: 'CropperAvatar' });
|
||||
|
||||
const props = withDefaults(defineProps<CropperAvatarProps>(), {
|
||||
width: 200,
|
||||
value: '',
|
||||
showBtn: true,
|
||||
btnProps: () => ({}),
|
||||
btnText: '',
|
||||
uploadApi: () => Promise.resolve(),
|
||||
size: 5,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value', 'change']);
|
||||
|
||||
const sourceValue = ref(props.value || '');
|
||||
const prefixCls = 'cropper-avatar';
|
||||
const [CropperModal, modalApi] = useVbenModal({
|
||||
connectedComponent: cropperModal,
|
||||
});
|
||||
|
||||
const getClass = computed(() => [prefixCls]);
|
||||
|
||||
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`);
|
||||
|
||||
const getIconWidth = computed(
|
||||
() => `${Number.parseInt(`${props.width}`.replace(/px/, '')) / 2}px`,
|
||||
);
|
||||
|
||||
const getStyle = computed((): CSSProperties => ({ width: unref(getWidth) }));
|
||||
|
||||
const getImageWrapperStyle = computed(
|
||||
(): CSSProperties => ({ height: unref(getWidth), width: unref(getWidth) }),
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
sourceValue.value = props.value || '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => sourceValue.value,
|
||||
(v: string) => {
|
||||
emit('update:value', v);
|
||||
},
|
||||
);
|
||||
|
||||
function handleUploadSuccess({ data, source }: any) {
|
||||
sourceValue.value = source;
|
||||
emit('change', { data, source });
|
||||
ElMessage.success($t('ui.cropper.uploadSuccess'));
|
||||
}
|
||||
|
||||
const closeModal = () => modalApi.close();
|
||||
const openModal = () => modalApi.open();
|
||||
|
||||
defineExpose({
|
||||
closeModal,
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="getClass" :style="getStyle">
|
||||
<div
|
||||
:class="`${prefixCls}-image-wrapper`"
|
||||
:style="getImageWrapperStyle"
|
||||
@click="openModal"
|
||||
>
|
||||
<div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
|
||||
<span
|
||||
:style="{
|
||||
...getImageWrapperStyle,
|
||||
width: `${getIconWidth}`,
|
||||
height: `${getIconWidth}`,
|
||||
lineHeight: `${getIconWidth}`,
|
||||
}"
|
||||
class="icon-[ant-design--cloud-upload-outlined] text-[#d6d6d6]"
|
||||
></span>
|
||||
</div>
|
||||
<img v-if="sourceValue" :src="sourceValue" alt="avatar" />
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="showBtn"
|
||||
:class="`${prefixCls}-upload-btn`"
|
||||
@click="openModal"
|
||||
v-bind="btnProps"
|
||||
>
|
||||
{{ btnText ? btnText : $t('ui.cropper.selectImage') }}
|
||||
</ElButton>
|
||||
|
||||
<CropperModal
|
||||
:size="size"
|
||||
:src="sourceValue"
|
||||
:upload-api="uploadApi"
|
||||
@upload-success="handleUploadSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cropper-avatar {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
|
||||
&-image-wrapper {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
cursor: pointer;
|
||||
background: rgb(0 0 0 / 40%);
|
||||
border: inherit;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s;
|
||||
|
||||
::v-deep(svg) {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask:hover {
|
||||
opacity: 40;
|
||||
}
|
||||
|
||||
&-upload-btn {
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,371 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CropendResult, CropperModalProps, CropperType } from './typing';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
import { dataURLtoBlob, isFunction } from '@vben/utils';
|
||||
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElMessage,
|
||||
ElSpace,
|
||||
ElTooltip,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
import CropperImage from './cropper.vue';
|
||||
|
||||
defineOptions({ name: 'CropperModal' });
|
||||
|
||||
const props = withDefaults(defineProps<CropperModalProps>(), {
|
||||
circled: true,
|
||||
size: 0,
|
||||
src: '',
|
||||
uploadApi: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['uploadSuccess', 'uploadError', 'register']);
|
||||
|
||||
let filename = '';
|
||||
const src = ref(props.src || '');
|
||||
const previewSource = ref('');
|
||||
const cropper = ref<CropperType>();
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
|
||||
const prefixCls = 'cropper-am';
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
onConfirm: handleOk,
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
// 打开时,进行 loading 加载。后续 CropperImage 组件加载完毕,会自动关闭 loading(通过 handleReady)
|
||||
modalLoading(true);
|
||||
} else {
|
||||
// 关闭时,清空右侧预览
|
||||
previewSource.value = '';
|
||||
modalLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function modalLoading(loading: boolean) {
|
||||
modalApi.setState({ confirmLoading: loading, loading });
|
||||
}
|
||||
|
||||
// Block upload
|
||||
function handleBeforeUpload(file: File) {
|
||||
if (props.size > 0 && file.size > 1024 * 1024 * props.size) {
|
||||
emit('uploadError', { msg: $t('ui.cropper.imageTooBig') });
|
||||
return false;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
src.value = '';
|
||||
previewSource.value = '';
|
||||
reader.addEventListener('load', (e) => {
|
||||
src.value = (e.target?.result as string) ?? '';
|
||||
filename = file.name;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleCropend({ imgBase64 }: CropendResult) {
|
||||
previewSource.value = imgBase64;
|
||||
}
|
||||
|
||||
function handleReady(cropperInstance: CropperType) {
|
||||
cropper.value = cropperInstance;
|
||||
// 画布加载完毕 关闭 loading
|
||||
modalLoading(false);
|
||||
}
|
||||
|
||||
function handlerToolbar(event: string, arg?: number) {
|
||||
if (event === 'scaleX') {
|
||||
scaleX = arg = scaleX === -1 ? 1 : -1;
|
||||
}
|
||||
if (event === 'scaleY') {
|
||||
scaleY = arg = scaleY === -1 ? 1 : -1;
|
||||
}
|
||||
(cropper?.value as any)?.[event]?.(arg);
|
||||
}
|
||||
|
||||
async function handleOk() {
|
||||
const uploadApi = props.uploadApi;
|
||||
if (uploadApi && isFunction(uploadApi)) {
|
||||
if (!previewSource.value) {
|
||||
ElMessage.warning('未选择图片');
|
||||
return;
|
||||
}
|
||||
const blob = dataURLtoBlob(previewSource.value);
|
||||
try {
|
||||
modalLoading(true);
|
||||
const url = await uploadApi({ file: blob, filename, name: 'file' });
|
||||
emit('uploadSuccess', { data: url, source: previewSource.value });
|
||||
await modalApi.close();
|
||||
} finally {
|
||||
modalLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-bind="$attrs"
|
||||
:confirm-text="$t('ui.cropper.okText')"
|
||||
:fullscreen-button="false"
|
||||
:title="$t('ui.cropper.modalTitle')"
|
||||
class="w-[800px]"
|
||||
>
|
||||
<div :class="prefixCls">
|
||||
<div :class="`${prefixCls}-left`" class="w-full">
|
||||
<div :class="`${prefixCls}-cropper`">
|
||||
<CropperImage
|
||||
v-if="src"
|
||||
:circled="circled"
|
||||
:src="src"
|
||||
height="300px"
|
||||
@cropend="handleCropend"
|
||||
@ready="handleReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="`${prefixCls}-toolbar`">
|
||||
<ElUpload
|
||||
:before-upload="handleBeforeUpload"
|
||||
:file-list="[]"
|
||||
accept="image/*"
|
||||
>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.selectImage')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton size="small" type="primary">
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--upload-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</ElUpload>
|
||||
<ElSpace>
|
||||
<ElTooltip :content="$t('ui.cropper.btn_reset')" placement="bottom">
|
||||
<ElButton
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('reset')"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--reload-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_rotate_left')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('rotate', -45)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span
|
||||
class="icon-[ant-design--rotate-left-outlined]"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_rotate_right')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('rotate', 45)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span
|
||||
class="icon-[ant-design--rotate-right-outlined]"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_scale_x')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('scaleX')"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[vaadin--arrows-long-h]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_scale_y')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('scaleY')"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[vaadin--arrows-long-v]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_zoom_in')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('zoom', 0.1)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--zoom-in-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('ui.cropper.btn_zoom_out')"
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton
|
||||
:disabled="!src"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('zoom', -0.1)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--zoom-out-outlined]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</ElSpace>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="`${prefixCls}-right`">
|
||||
<div :class="`${prefixCls}-preview`">
|
||||
<img
|
||||
v-if="previewSource"
|
||||
:alt="$t('ui.cropper.preview')"
|
||||
:src="previewSource"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="previewSource">
|
||||
<div :class="`${prefixCls}-group`">
|
||||
<ElAvatar :src="previewSource" size="large" />
|
||||
<ElAvatar :size="48" :src="previewSource" />
|
||||
<ElAvatar :size="64" :src="previewSource" />
|
||||
<ElAvatar :size="80" :src="previewSource" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.cropper-am {
|
||||
display: flex;
|
||||
|
||||
&-left,
|
||||
&-right {
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
&-left {
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
&-right {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
&-cropper {
|
||||
height: 300px;
|
||||
background: #eee;
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
);
|
||||
background-position:
|
||||
0 0,
|
||||
12px 12px;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
&-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,173 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { CropperProps } from './typing';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref, unref, useAttrs } from 'vue';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import Cropper from 'cropperjs';
|
||||
|
||||
import { defaultOptions } from './typing';
|
||||
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
|
||||
defineOptions({ name: 'CropperImage' });
|
||||
|
||||
const props = withDefaults(defineProps<CropperProps>(), {
|
||||
src: '',
|
||||
alt: '',
|
||||
circled: false,
|
||||
realTimePreview: true,
|
||||
height: '360px',
|
||||
crossorigin: undefined,
|
||||
imageStyle: () => ({}),
|
||||
options: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['cropend', 'ready', 'cropendError']);
|
||||
const attrs = useAttrs();
|
||||
|
||||
type ElRef<T extends HTMLElement = HTMLDivElement> = null | T;
|
||||
const imgElRef = ref<ElRef<HTMLImageElement>>();
|
||||
const cropper = ref<Cropper | null>();
|
||||
const isReady = ref(false);
|
||||
|
||||
const prefixCls = 'cropper-image';
|
||||
const debounceRealTimeCropped = useDebounceFn(realTimeCropped, 80);
|
||||
|
||||
const getImageStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: props.height,
|
||||
maxWidth: '100%',
|
||||
...props.imageStyle,
|
||||
};
|
||||
});
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
attrs.class,
|
||||
{
|
||||
[`${prefixCls}--circled`]: props.circled,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const getWrapperStyle = computed((): CSSProperties => {
|
||||
return { height: `${`${props.height}`.replace(/px/, '')}px` };
|
||||
});
|
||||
|
||||
onMounted(init);
|
||||
|
||||
onUnmounted(() => {
|
||||
cropper.value?.destroy();
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const imgEl = unref(imgElRef);
|
||||
if (!imgEl) {
|
||||
return;
|
||||
}
|
||||
cropper.value = new Cropper(imgEl, {
|
||||
...defaultOptions,
|
||||
ready: () => {
|
||||
isReady.value = true;
|
||||
realTimeCropped();
|
||||
emit('ready', cropper.value);
|
||||
},
|
||||
crop() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
zoom() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
cropmove() {
|
||||
debounceRealTimeCropped();
|
||||
},
|
||||
...props.options,
|
||||
});
|
||||
}
|
||||
|
||||
// Real-time display preview
|
||||
function realTimeCropped() {
|
||||
props.realTimePreview && cropped();
|
||||
}
|
||||
|
||||
// event: return base64 and width and height information after cropping
|
||||
function cropped() {
|
||||
if (!cropper.value) {
|
||||
return;
|
||||
}
|
||||
const imgInfo = cropper.value.getData();
|
||||
const canvas = props.circled
|
||||
? getRoundedCanvas()
|
||||
: cropper.value.getCroppedCanvas();
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
const fileReader: FileReader = new FileReader();
|
||||
fileReader.readAsDataURL(blob);
|
||||
fileReader.onloadend = (e) => {
|
||||
emit('cropend', {
|
||||
imgBase64: e.target?.result ?? '',
|
||||
imgInfo,
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line unicorn/prefer-add-event-listener
|
||||
fileReader.onerror = () => {
|
||||
emit('cropendError');
|
||||
};
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
// Get a circular picture canvas
|
||||
function getRoundedCanvas() {
|
||||
const sourceCanvas = cropper.value!.getCroppedCanvas();
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
const width = sourceCanvas.width;
|
||||
const height = sourceCanvas.height;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height);
|
||||
context.globalCompositeOperation = 'destination-in';
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
width / 2,
|
||||
height / 2,
|
||||
Math.min(width, height) / 2,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
true,
|
||||
);
|
||||
context.fill();
|
||||
return canvas;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="getClass" :style="getWrapperStyle">
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imgElRef"
|
||||
:alt="alt"
|
||||
:crossorigin="crossorigin"
|
||||
:src="src"
|
||||
:style="getImageStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.cropper-image {
|
||||
&--circled {
|
||||
.cropper-view-box,
|
||||
.cropper-face {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,3 @@
|
|||
export { default as CropperAvatar } from './cropper-avatar.vue';
|
||||
export { default as CropperImage } from './cropper.vue';
|
||||
export type { CropperType } from './typing';
|
|
@ -0,0 +1,68 @@
|
|||
import type Cropper from 'cropperjs';
|
||||
import type { ButtonProps } from 'element-plus';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
export interface apiFunParams {
|
||||
file: Blob;
|
||||
filename: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CropendResult {
|
||||
imgBase64: string;
|
||||
imgInfo: Cropper.Data;
|
||||
}
|
||||
|
||||
export interface CropperProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
circled?: boolean;
|
||||
realTimePreview?: boolean;
|
||||
height?: number | string;
|
||||
crossorigin?: '' | 'anonymous' | 'use-credentials' | undefined;
|
||||
imageStyle?: CSSProperties;
|
||||
options?: Cropper.Options;
|
||||
}
|
||||
|
||||
export interface CropperAvatarProps {
|
||||
width?: number | string;
|
||||
value?: string;
|
||||
showBtn?: boolean;
|
||||
btnProps?: ButtonProps;
|
||||
btnText?: string;
|
||||
uploadApi?: (params: apiFunParams) => Promise<any>;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface CropperModalProps {
|
||||
circled?: boolean;
|
||||
uploadApi?: (params: apiFunParams) => Promise<any>;
|
||||
src?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const defaultOptions: Cropper.Options = {
|
||||
aspectRatio: 1,
|
||||
zoomable: true,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
autoCrop: true,
|
||||
background: true,
|
||||
highlight: true,
|
||||
center: true,
|
||||
responsive: true,
|
||||
restore: true,
|
||||
checkCrossOrigin: true,
|
||||
checkOrientation: true,
|
||||
scalable: true,
|
||||
modal: true,
|
||||
guides: true,
|
||||
movable: true,
|
||||
rotatable: true,
|
||||
};
|
||||
|
||||
export type { Cropper as CropperType };
|
|
@ -0,0 +1,78 @@
|
|||
<!-- 数据字典 Select 选择器 -->
|
||||
<script lang="ts" setup>
|
||||
import type { DictSelectProps } from '../typing';
|
||||
|
||||
import { computed, useAttrs } from 'vue';
|
||||
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElOption,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getDictObj, getIntDictOptions, getStrDictOptions } from '#/utils';
|
||||
|
||||
defineOptions({ name: 'DictSelect' });
|
||||
|
||||
const props = withDefaults(defineProps<DictSelectProps>(), {
|
||||
valueType: 'str',
|
||||
selectType: 'select',
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
// 获得字典配置
|
||||
// TODO @dhb:可以使用 getDictOptions 替代么?
|
||||
const getDictOptions = computed(() => {
|
||||
switch (props.valueType) {
|
||||
case 'bool': {
|
||||
return getDictObj(props.dictType, 'bool');
|
||||
}
|
||||
case 'int': {
|
||||
return getIntDictOptions(props.dictType);
|
||||
}
|
||||
case 'str': {
|
||||
return getStrDictOptions(props.dictType);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect v-if="selectType === 'select'" class="w-1/1" v-bind="attrs">
|
||||
<ElOption
|
||||
v-for="(dict, index) in getDictOptions"
|
||||
:key="index"
|
||||
:value="dict.value"
|
||||
:label="dict.label"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElRadioGroup v-if="selectType === 'radio'" class="w-1/1" v-bind="attrs">
|
||||
<ElRadio
|
||||
v-for="(dict, index) in getDictOptions"
|
||||
:key="index"
|
||||
:label="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<ElCheckboxGroup
|
||||
v-if="selectType === 'checkbox'"
|
||||
class="w-1/1"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<ElCheckbox
|
||||
v-for="(dict, index) in getDictOptions"
|
||||
:key="index"
|
||||
:label="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</template>
|
|
@ -0,0 +1,288 @@
|
|||
import type { ApiSelectProps } from '#/components/form-create/typing';
|
||||
|
||||
import { defineComponent, onMounted, ref, useAttrs } from 'vue';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElOption,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export const useApiSelect = (option: ApiSelectProps) => {
|
||||
return defineComponent({
|
||||
name: option.name,
|
||||
props: {
|
||||
// 选项标签
|
||||
labelField: {
|
||||
type: String,
|
||||
default: () => option.labelField ?? 'label',
|
||||
},
|
||||
// 选项的值
|
||||
valueField: {
|
||||
type: String,
|
||||
default: () => option.valueField ?? 'value',
|
||||
},
|
||||
// api 接口
|
||||
url: {
|
||||
type: String,
|
||||
default: () => option.url ?? '',
|
||||
},
|
||||
// 请求类型
|
||||
method: {
|
||||
type: String,
|
||||
default: 'GET',
|
||||
},
|
||||
// 选项解析函数
|
||||
parseFunc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 请求参数
|
||||
data: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 选择器类型,下拉框 select、多选框 checkbox、单选框 radio
|
||||
selectType: {
|
||||
type: String,
|
||||
default: 'select',
|
||||
},
|
||||
// 是否多选
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否远程搜索
|
||||
remote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 远程搜索时携带的参数
|
||||
remoteField: {
|
||||
type: String,
|
||||
default: 'label',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const attrs = useAttrs();
|
||||
const options = ref<any[]>([]); // 下拉数据
|
||||
const loading = ref(false); // 是否正在从远程获取数据
|
||||
const queryParam = ref<any>(); // 当前输入的值
|
||||
const getOptions = async () => {
|
||||
options.value = [];
|
||||
// 接口选择器
|
||||
if (isEmpty(props.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (props.method) {
|
||||
case 'GET': {
|
||||
let url: string = props.url;
|
||||
if (props.remote && queryParam.value !== undefined) {
|
||||
url = url.includes('?')
|
||||
? `${url}&${props.remoteField}=${queryParam.value}`
|
||||
: `${url}?${props.remoteField}=${queryParam.value}`;
|
||||
}
|
||||
parseOptions(await requestClient.get(url));
|
||||
break;
|
||||
}
|
||||
case 'POST': {
|
||||
const data: any = JSON.parse(props.data);
|
||||
if (props.remote) {
|
||||
data[props.remoteField] = queryParam.value;
|
||||
}
|
||||
parseOptions(await requestClient.post(props.url, data));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function parseOptions(data: any) {
|
||||
// 情况一:如果有自定义解析函数优先使用自定义解析
|
||||
if (!isEmpty(props.parseFunc)) {
|
||||
options.value = parseFunc()?.(data);
|
||||
return;
|
||||
}
|
||||
// 情况二:返回的直接是一个列表
|
||||
if (Array.isArray(data)) {
|
||||
parseOptions0(data);
|
||||
return;
|
||||
}
|
||||
// 情况二:返回的是分页数据,尝试读取 list
|
||||
data = data.list;
|
||||
if (!!data && Array.isArray(data)) {
|
||||
parseOptions0(data);
|
||||
return;
|
||||
}
|
||||
// 情况三:不是 yudao-vue-pro 标准返回
|
||||
console.warn(
|
||||
`接口[${props.url}] 返回结果不是 yudao-vue-pro 标准返回建议采用自定义解析函数处理`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseOptions0(data: any[]) {
|
||||
if (Array.isArray(data)) {
|
||||
options.value = data.map((item: any) => ({
|
||||
label: parseExpression(item, props.labelField),
|
||||
value: parseExpression(item, props.valueField),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
console.warn(`接口[${props.url}] 返回结果不是一个数组`);
|
||||
}
|
||||
|
||||
function parseFunc() {
|
||||
let parse: any = null;
|
||||
if (props.parseFunc) {
|
||||
// 解析字符串函数
|
||||
// eslint-disable-next-line no-new-func
|
||||
parse = new Function(`return ${props.parseFunc}`)();
|
||||
}
|
||||
return parse;
|
||||
}
|
||||
|
||||
function parseExpression(data: any, template: string) {
|
||||
// 检测是否使用了表达式
|
||||
if (!template.includes('${')) {
|
||||
return data[template];
|
||||
}
|
||||
// 正则表达式匹配模板字符串中的 ${...}
|
||||
const pattern = /\$\{([^}]*)\}/g;
|
||||
// 使用replace函数配合正则表达式和回调函数来进行替换
|
||||
return template.replaceAll(pattern, (_, expr) => {
|
||||
// expr 是匹配到的 ${} 内的表达式(这里是属性名),从 data 中获取对应的值
|
||||
const result = data[expr.trim()]; // 去除前后空白,以防用户输入带空格的属性名
|
||||
if (!result) {
|
||||
console.warn(
|
||||
`接口选择器选项模版[${template}][${expr.trim()}] 解析值失败结果为[${result}], 请检查属性名称是否存在于接口返回值中,存在则忽略此条!!!`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
const remoteMethod = async (query: any) => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
queryParam.value = query;
|
||||
await getOptions();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await getOptions();
|
||||
});
|
||||
|
||||
const buildSelect = () => {
|
||||
if (props.multiple) {
|
||||
// fix:多写此步是为了解决 multiple 属性问题
|
||||
return (
|
||||
<ElSelect
|
||||
class="w-1/1"
|
||||
loading={loading.value}
|
||||
multiple
|
||||
{...attrs}
|
||||
// 远程搜索
|
||||
filterable={props.remote}
|
||||
remote={props.remote}
|
||||
{...(props.remote && { remoteMethod })}
|
||||
>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<ElOption key={index} label={item.label} value={item.value} />
|
||||
),
|
||||
)}
|
||||
</ElSelect>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ElSelect
|
||||
class="w-1/1"
|
||||
loading={loading.value}
|
||||
{...attrs}
|
||||
// 远程搜索
|
||||
filterable={props.remote}
|
||||
remote={props.remote}
|
||||
{...(props.remote && { remoteMethod })}
|
||||
>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<ElOption key={index} label={item.label} value={item.value} />
|
||||
),
|
||||
)}
|
||||
</ElSelect>
|
||||
);
|
||||
};
|
||||
const buildCheckbox = () => {
|
||||
if (isEmpty(options.value)) {
|
||||
options.value = [
|
||||
{ label: '选项1', value: '选项1' },
|
||||
{ label: '选项2', value: '选项2' },
|
||||
];
|
||||
}
|
||||
return (
|
||||
<ElCheckboxGroup class="w-1/1" {...attrs}>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<ElCheckbox key={index} label={item.value}>
|
||||
{item.label}
|
||||
</ElCheckbox>
|
||||
),
|
||||
)}
|
||||
</ElCheckboxGroup>
|
||||
);
|
||||
};
|
||||
const buildRadio = () => {
|
||||
if (isEmpty(options.value)) {
|
||||
options.value = [
|
||||
{ label: '选项1', value: '选项1' },
|
||||
{ label: '选项2', value: '选项2' },
|
||||
];
|
||||
}
|
||||
return (
|
||||
<ElRadioGroup class="w-1/1" {...attrs}>
|
||||
{options.value.map(
|
||||
(item: { label: any; value: any }, index: any) => (
|
||||
<ElRadio key={index} label={item.value}>
|
||||
{item.label}
|
||||
</ElRadio>
|
||||
),
|
||||
)}
|
||||
</ElRadioGroup>
|
||||
);
|
||||
};
|
||||
return () => (
|
||||
<>
|
||||
{(() => {
|
||||
switch (props.selectType) {
|
||||
case 'checkbox': {
|
||||
return buildCheckbox();
|
||||
}
|
||||
case 'radio': {
|
||||
return buildRadio();
|
||||
}
|
||||
case 'select': {
|
||||
return buildSelect();
|
||||
}
|
||||
default: {
|
||||
return buildSelect();
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
import { defineComponent } from 'vue';
|
||||
|
||||
import ImageUpload from '#/components/upload/image-upload.vue';
|
||||
|
||||
export const useImagesUpload = () => {
|
||||
return defineComponent({
|
||||
name: 'ImagesUpload',
|
||||
props: {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
maxNumber: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
// TODO: @dhb52 其实还是靠 props 默认参数起作用,没能从 formCreate 传递
|
||||
return (props: { maxNumber?: number; multiple?: boolean }) => (
|
||||
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,182 @@
|
|||
import type { Ref } from 'vue';
|
||||
|
||||
import type { Menu } from '#/components/form-create/typing';
|
||||
|
||||
import { nextTick, onMounted } from 'vue';
|
||||
|
||||
import { apiSelectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
import {
|
||||
useDictSelectRule,
|
||||
useEditorRule,
|
||||
useSelectRule,
|
||||
useUploadFileRule,
|
||||
useUploadImageRule,
|
||||
useUploadImagesRule,
|
||||
} from './rules';
|
||||
|
||||
export function makeRequiredRule() {
|
||||
return {
|
||||
type: 'Required',
|
||||
field: 'formCreate$required',
|
||||
title: '是否必填',
|
||||
};
|
||||
}
|
||||
|
||||
export const localeProps = (
|
||||
t: (msg: string) => any,
|
||||
prefix: string,
|
||||
rules: any[],
|
||||
) => {
|
||||
return rules.map((rule: { field: string; title: any }) => {
|
||||
if (rule.field === 'formCreate$required') {
|
||||
rule.title = t('props.required') || rule.title;
|
||||
} else if (rule.field && rule.field !== '_optionType') {
|
||||
rule.title = t(`components.${prefix}.${rule.field}`) || rule.title;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析表单组件的 field, title 等字段(递归,如果组件包含子组件)
|
||||
*
|
||||
* @param rule 组件的生成规则 https://www.form-create.com/v3/guide/rule
|
||||
* @param fields 解析后表单组件字段
|
||||
* @param parentTitle 如果是子表单,子表单的标题,默认为空
|
||||
*/
|
||||
export const parseFormFields = (
|
||||
rule: Record<string, any>,
|
||||
fields: Array<Record<string, any>> = [],
|
||||
parentTitle: string = '',
|
||||
) => {
|
||||
const { type, field, $required, title: tempTitle, children } = rule;
|
||||
if (field && tempTitle) {
|
||||
let title = tempTitle;
|
||||
if (parentTitle) {
|
||||
title = `${parentTitle}.${tempTitle}`;
|
||||
}
|
||||
let required = false;
|
||||
if ($required) {
|
||||
required = true;
|
||||
}
|
||||
fields.push({
|
||||
field,
|
||||
title,
|
||||
type,
|
||||
required,
|
||||
});
|
||||
// TODO 子表单 需要处理子表单字段
|
||||
// if (type === 'group' && rule.props?.rule && Array.isArray(rule.props.rule)) {
|
||||
// // 解析子表单的字段
|
||||
// rule.props.rule.forEach((item) => {
|
||||
// parseFields(item, fieldsPermission, title)
|
||||
// })
|
||||
// }
|
||||
}
|
||||
if (children && Array.isArray(children)) {
|
||||
children.forEach((rule) => {
|
||||
parseFormFields(rule, fields);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单设计器增强 hook
|
||||
* 新增
|
||||
* - 文件上传
|
||||
* - 单图上传
|
||||
* - 多图上传
|
||||
* - 字典选择器
|
||||
* - 用户选择器
|
||||
* - 部门选择器
|
||||
* - 富文本
|
||||
*/
|
||||
export const useFormCreateDesigner = async (designer: Ref) => {
|
||||
const editorRule = useEditorRule();
|
||||
const uploadFileRule = useUploadFileRule();
|
||||
const uploadImageRule = useUploadImageRule();
|
||||
const uploadImagesRule = useUploadImagesRule();
|
||||
|
||||
/**
|
||||
* 构建表单组件
|
||||
*/
|
||||
const buildFormComponents = () => {
|
||||
// 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代
|
||||
designer.value?.removeMenuItem('upload');
|
||||
// 移除自带的富文本组件规则,使用 editorRule 替代
|
||||
designer.value?.removeMenuItem('fc-editor');
|
||||
const components = [
|
||||
editorRule,
|
||||
uploadFileRule,
|
||||
uploadImageRule,
|
||||
uploadImagesRule,
|
||||
];
|
||||
components.forEach((component) => {
|
||||
// 插入组件规则
|
||||
designer.value?.addComponent(component);
|
||||
// 插入拖拽按钮到 `main` 分类下
|
||||
designer.value?.appendMenuItem('main', {
|
||||
icon: component.icon,
|
||||
name: component.name,
|
||||
label: component.label,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const userSelectRule = useSelectRule({
|
||||
name: 'UserSelect',
|
||||
label: '用户选择器',
|
||||
icon: 'icon-eye',
|
||||
});
|
||||
const deptSelectRule = useSelectRule({
|
||||
name: 'DeptSelect',
|
||||
label: '部门选择器',
|
||||
icon: 'icon-tree',
|
||||
});
|
||||
const dictSelectRule = useDictSelectRule();
|
||||
const apiSelectRule0 = useSelectRule({
|
||||
name: 'ApiSelect',
|
||||
label: '接口选择器',
|
||||
icon: 'icon-json',
|
||||
props: [...apiSelectRule],
|
||||
event: ['click', 'change', 'visibleChange', 'clear', 'blur', 'focus'],
|
||||
});
|
||||
|
||||
/**
|
||||
* 构建系统字段菜单
|
||||
*/
|
||||
const buildSystemMenu = () => {
|
||||
// 移除自带的下拉选择器组件,使用 currencySelectRule 替代
|
||||
// designer.value?.removeMenuItem('select')
|
||||
// designer.value?.removeMenuItem('radio')
|
||||
// designer.value?.removeMenuItem('checkbox')
|
||||
const components = [
|
||||
userSelectRule,
|
||||
deptSelectRule,
|
||||
dictSelectRule,
|
||||
apiSelectRule0,
|
||||
];
|
||||
const menu: Menu = {
|
||||
name: 'system',
|
||||
title: '系统字段',
|
||||
list: components.map((component) => {
|
||||
// 插入组件规则
|
||||
designer.value?.addComponent(component);
|
||||
// 插入拖拽按钮到 `system` 分类下
|
||||
return {
|
||||
icon: component.icon,
|
||||
name: component.name,
|
||||
label: component.label,
|
||||
};
|
||||
}),
|
||||
};
|
||||
designer.value?.addMenu(menu);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
buildFormComponents();
|
||||
buildSystemMenu();
|
||||
});
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export { useApiSelect } from './components/use-api-select';
|
||||
|
||||
export { useFormCreateDesigner } from './helpers';
|
|
@ -0,0 +1,182 @@
|
|||
/* eslint-disable no-template-curly-in-string */
|
||||
const selectRule = [
|
||||
{
|
||||
type: 'select',
|
||||
field: 'selectType',
|
||||
title: '选择器类型',
|
||||
value: 'select',
|
||||
options: [
|
||||
{ label: '下拉框', value: 'select' },
|
||||
{ label: '单选框', value: 'radio' },
|
||||
{ label: '多选框', value: 'checkbox' },
|
||||
],
|
||||
// 参考 https://www.form-create.com/v3/guide/control 组件联动,单选框和多选框不需要多选属性
|
||||
control: [
|
||||
{
|
||||
value: 'select',
|
||||
condition: '==',
|
||||
method: 'hidden',
|
||||
rule: [
|
||||
'multiple',
|
||||
'clearable',
|
||||
'collapseTags',
|
||||
'multipleLimit',
|
||||
'allowCreate',
|
||||
'filterable',
|
||||
'noMatchText',
|
||||
'remote',
|
||||
'remoteMethod',
|
||||
'reserveKeyword',
|
||||
'defaultFirstOption',
|
||||
'automaticDropdown',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'filterable',
|
||||
title: '是否可搜索',
|
||||
},
|
||||
{ type: 'switch', field: 'multiple', title: '是否多选' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否禁用',
|
||||
},
|
||||
{ type: 'switch', field: 'clearable', title: '是否可以清空选项' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'collapseTags',
|
||||
title: '多选时是否将选中值按文字的形式展示',
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'multipleLimit',
|
||||
title: '多选时用户最多可以选择的项目数,为 0 则不限制',
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'autocomplete',
|
||||
title: 'autocomplete 属性',
|
||||
},
|
||||
{ type: 'input', field: 'placeholder', title: '占位符' },
|
||||
{ type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' },
|
||||
{
|
||||
type: 'input',
|
||||
field: 'noMatchText',
|
||||
title: '搜索条件无匹配时显示的文字',
|
||||
},
|
||||
{ type: 'input', field: 'noDataText', title: '选项为空时显示的文字' },
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'reserveKeyword',
|
||||
title: '多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'defaultFirstOption',
|
||||
title: '在输入框按下回车,选择第一个匹配项',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'popperAppendToBody',
|
||||
title: '是否将弹出框插入至 body 元素',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'automaticDropdown',
|
||||
title: '对于不可搜索的 Select,是否在输入框获得焦点后自动弹出选项菜单',
|
||||
},
|
||||
];
|
||||
|
||||
const apiSelectRule = [
|
||||
{
|
||||
type: 'input',
|
||||
field: 'url',
|
||||
title: 'url 地址',
|
||||
props: {
|
||||
placeholder: '/system/user/simple-list',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'method',
|
||||
title: '请求类型',
|
||||
value: 'GET',
|
||||
options: [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
],
|
||||
control: [
|
||||
{
|
||||
value: 'GET',
|
||||
condition: '!=',
|
||||
method: 'hidden',
|
||||
rule: [
|
||||
{
|
||||
type: 'input',
|
||||
field: 'data',
|
||||
title: '请求参数 JSON 格式',
|
||||
props: {
|
||||
autosize: true,
|
||||
type: 'textarea',
|
||||
placeholder: '{"type": 1}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'labelField',
|
||||
title: 'label 属性',
|
||||
info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}',
|
||||
props: {
|
||||
placeholder: 'nickname',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'valueField',
|
||||
title: 'value 属性',
|
||||
info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}',
|
||||
props: {
|
||||
placeholder: 'id',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'parseFunc',
|
||||
title: '选项解析函数',
|
||||
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
|
||||
(data: any)=>{ label: string; value: any }[]`,
|
||||
props: {
|
||||
autosize: true,
|
||||
rows: { minRows: 2, maxRows: 6 },
|
||||
type: 'textarea',
|
||||
placeholder: `
|
||||
function (data) {
|
||||
console.log(data)
|
||||
return data.list.map(item=> ({label: item.nickname,value: item.id}))
|
||||
}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'remote',
|
||||
info: '是否可搜索',
|
||||
title: '其中的选项是否从服务器远程加载',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'remoteField',
|
||||
title: '请求参数',
|
||||
info: '远程请求时请求携带的参数名称,如:name',
|
||||
},
|
||||
];
|
||||
|
||||
export { apiSelectRule, selectRule };
|
|
@ -0,0 +1,6 @@
|
|||
export { useDictSelectRule } from './use-dict-select';
|
||||
export { useEditorRule } from './use-editor-rule';
|
||||
export { useSelectRule } from './use-select-rule';
|
||||
export { useUploadFileRule } from './use-upload-file-rule';
|
||||
export { useUploadImageRule } from './use-upload-image-rule';
|
||||
export { useUploadImagesRule } from './use-upload-images-rule';
|
|
@ -0,0 +1,69 @@
|
|||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { buildUUID, cloneDeep } from '@vben/utils';
|
||||
|
||||
import * as DictDataApi from '#/api/system/dict/type';
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
import { selectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
/**
|
||||
* 字典选择器规则,如果规则使用到动态数据则需要单独配置不能使用 useSelectRule
|
||||
*/
|
||||
export const useDictSelectRule = () => {
|
||||
const label = '字典选择器';
|
||||
const name = 'DictSelect';
|
||||
const rules = cloneDeep(selectRule);
|
||||
const dictOptions = ref<{ label: string; value: string }[]>([]); // 字典类型下拉数据
|
||||
onMounted(async () => {
|
||||
const data = await DictDataApi.getSimpleDictTypeList();
|
||||
if (!data || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
dictOptions.value =
|
||||
data?.map((item: DictDataApi.SystemDictTypeApi.DictType) => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
})) ?? [];
|
||||
});
|
||||
return {
|
||||
icon: 'icon-descriptions',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'select',
|
||||
field: 'dictType',
|
||||
title: '字典类型',
|
||||
value: '',
|
||||
options: dictOptions.value,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'valueType',
|
||||
title: '字典值类型',
|
||||
value: 'str',
|
||||
options: [
|
||||
{ label: '数字', value: 'int' },
|
||||
{ label: '字符串', value: 'str' },
|
||||
{ label: '布尔值', value: 'bool' },
|
||||
],
|
||||
},
|
||||
...rules,
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export const useEditorRule = () => {
|
||||
const label = '富文本';
|
||||
const name = 'Tinymce';
|
||||
return {
|
||||
icon: 'icon-editor',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'input',
|
||||
field: 'height',
|
||||
title: '高度',
|
||||
},
|
||||
{ type: 'switch', field: 'readonly', title: '是否只读' },
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,45 @@
|
|||
import type { SelectRuleOption } from '#/components/form-create/typing';
|
||||
|
||||
import { buildUUID, cloneDeep } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
import { selectRule } from '#/components/form-create/rules/data';
|
||||
|
||||
/**
|
||||
* 通用选择器规则 hook
|
||||
*
|
||||
* @param option 规则配置
|
||||
*/
|
||||
export const useSelectRule = (option: SelectRuleOption) => {
|
||||
const label = option.label;
|
||||
const name = option.name;
|
||||
const rules = cloneDeep(selectRule);
|
||||
return {
|
||||
icon: option.icon,
|
||||
label,
|
||||
name,
|
||||
event: option.event,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
if (!option.props) {
|
||||
option.props = [];
|
||||
}
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
...option.props,
|
||||
...rules,
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,84 @@
|
|||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export const useUploadFileRule = () => {
|
||||
const label = '文件上传';
|
||||
const name = 'FileUpload';
|
||||
return {
|
||||
icon: 'icon-upload',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'select',
|
||||
field: 'fileType',
|
||||
title: '文件类型',
|
||||
value: ['doc', 'xls', 'ppt', 'txt', 'pdf'],
|
||||
options: [
|
||||
{ label: 'doc', value: 'doc' },
|
||||
{ label: 'xls', value: 'xls' },
|
||||
{ label: 'ppt', value: 'ppt' },
|
||||
{ label: 'txt', value: 'txt' },
|
||||
{ label: 'pdf', value: 'pdf' },
|
||||
],
|
||||
props: {
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'autoUpload',
|
||||
title: '是否在选取文件后立即进行上传',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'drag',
|
||||
title: '拖拽上传',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'isShowTip',
|
||||
title: '是否显示提示',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'fileSize',
|
||||
title: '大小限制(MB)',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'limit',
|
||||
title: '数量限制',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否禁用',
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,93 @@
|
|||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export const useUploadImageRule = () => {
|
||||
const label = '单图上传';
|
||||
const name = 'ImageUpload';
|
||||
return {
|
||||
icon: 'icon-image',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'drag',
|
||||
title: '拖拽上传',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'fileType',
|
||||
title: '图片类型限制',
|
||||
value: ['image/jpeg', 'image/png', 'image/gif'],
|
||||
options: [
|
||||
{ label: 'image/apng', value: 'image/apng' },
|
||||
{ label: 'image/bmp', value: 'image/bmp' },
|
||||
{ label: 'image/gif', value: 'image/gif' },
|
||||
{ label: 'image/jpeg', value: 'image/jpeg' },
|
||||
{ label: 'image/pjpeg', value: 'image/pjpeg' },
|
||||
{ label: 'image/svg+xml', value: 'image/svg+xml' },
|
||||
{ label: 'image/tiff', value: 'image/tiff' },
|
||||
{ label: 'image/webp', value: 'image/webp' },
|
||||
{ label: 'image/x-icon', value: 'image/x-icon' },
|
||||
],
|
||||
props: {
|
||||
multiple: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'fileSize',
|
||||
title: '大小限制(MB)',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'height',
|
||||
title: '组件高度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'width',
|
||||
title: '组件宽度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'borderradius',
|
||||
title: '组件边框圆角',
|
||||
value: '8px',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否显示删除按钮',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'showBtnText',
|
||||
title: '是否显示按钮文字',
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,89 @@
|
|||
import { buildUUID } from '@vben/utils';
|
||||
|
||||
import {
|
||||
localeProps,
|
||||
makeRequiredRule,
|
||||
} from '#/components/form-create/helpers';
|
||||
|
||||
export const useUploadImagesRule = () => {
|
||||
const label = '多图上传';
|
||||
const name = 'ImagesUpload';
|
||||
return {
|
||||
icon: 'icon-image',
|
||||
label,
|
||||
name,
|
||||
rule() {
|
||||
return {
|
||||
type: name,
|
||||
field: buildUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
return localeProps(t, `${name}.props`, [
|
||||
makeRequiredRule(),
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'drag',
|
||||
title: '拖拽上传',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
field: 'fileType',
|
||||
title: '图片类型限制',
|
||||
value: ['image/jpeg', 'image/png', 'image/gif'],
|
||||
options: [
|
||||
{ label: 'image/apng', value: 'image/apng' },
|
||||
{ label: 'image/bmp', value: 'image/bmp' },
|
||||
{ label: 'image/gif', value: 'image/gif' },
|
||||
{ label: 'image/jpeg', value: 'image/jpeg' },
|
||||
{ label: 'image/pjpeg', value: 'image/pjpeg' },
|
||||
{ label: 'image/svg+xml', value: 'image/svg+xml' },
|
||||
{ label: 'image/tiff', value: 'image/tiff' },
|
||||
{ label: 'image/webp', value: 'image/webp' },
|
||||
{ label: 'image/x-icon', value: 'image/x-icon' },
|
||||
],
|
||||
props: {
|
||||
multiple: true,
|
||||
maxNumber: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'fileSize',
|
||||
title: '大小限制(MB)',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'limit',
|
||||
title: '数量限制',
|
||||
value: 5,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'height',
|
||||
title: '组件高度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'width',
|
||||
title: '组件宽度',
|
||||
value: '150px',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'borderradius',
|
||||
title: '组件边框圆角',
|
||||
value: '8px',
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
import type { Rule } from '@form-create/element-ui'; // 左侧拖拽按钮
|
||||
|
||||
/** 数据字典 Select 选择器组件 Props 类型 */
|
||||
export interface DictSelectProps {
|
||||
dictType: string; // 字典类型
|
||||
valueType?: 'bool' | 'int' | 'str'; // 字典值类型 TODO @芋艿:'boolean' | 'number' | 'string';需要和 vue3 一起统一!
|
||||
selectType?: 'checkbox' | 'radio' | 'select'; // 选择器类型,下拉框 select、多选框 checkbox、单选框 radio
|
||||
formCreateInject?: any;
|
||||
}
|
||||
|
||||
/** 左侧拖拽按钮 */
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** 左侧拖拽按钮分类 */
|
||||
export interface Menu {
|
||||
title: string;
|
||||
name: string;
|
||||
list: MenuItem[];
|
||||
}
|
||||
|
||||
export type MenuList = Array<Menu>;
|
||||
|
||||
// TODO @dhb52:MenuList、Menu、MenuItem、DragRule 这几个,是不是没用到呀?
|
||||
// 拖拽组件的规则
|
||||
export interface DragRule {
|
||||
icon: string;
|
||||
name: string;
|
||||
label: string;
|
||||
children?: string;
|
||||
inside?: true;
|
||||
drag?: string | true;
|
||||
dragBtn?: false;
|
||||
mask?: false;
|
||||
|
||||
rule(): Rule;
|
||||
|
||||
props(v: any, v1: any): Rule[];
|
||||
}
|
||||
|
||||
/** 通用 API 下拉组件 Props 类型 */
|
||||
export interface ApiSelectProps {
|
||||
name: string; // 组件名称
|
||||
labelField?: string; // 选项标签
|
||||
valueField?: string; // 选项的值
|
||||
url?: string; // url 接口
|
||||
isDict?: boolean; // 是否字典选择器
|
||||
}
|
||||
|
||||
/** 选择组件规则配置类型 */
|
||||
export interface SelectRuleOption {
|
||||
label: string; // label 名称
|
||||
name: string; // 组件名称
|
||||
icon: string; // 组件图标
|
||||
props?: any[]; // 组件规则
|
||||
event?: any[]; // 事件配置
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
interface IFrameProps {
|
||||
/** iframe 的源地址 */
|
||||
src: string;
|
||||
}
|
||||
|
||||
const props = defineProps<IFrameProps>();
|
||||
|
||||
const loading = ref(true);
|
||||
const height = ref('');
|
||||
const frameRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function init() {
|
||||
height.value = `${document.documentElement.clientHeight - 94.5}px`;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
init();
|
||||
}, 300);
|
||||
});
|
||||
// TODO @芋艿:优化:未来使用 vben 自带的内链实现
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" :style="`height:${height}`">
|
||||
<iframe
|
||||
ref="frameRef"
|
||||
:src="props.src"
|
||||
style="width: 100%; height: 100%"
|
||||
frameborder="no"
|
||||
scrolling="auto"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1 @@
|
|||
export { default as IFrame } from './iframe.vue';
|
|
@ -1,12 +1,17 @@
|
|||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
import { AuthenticationLoginExpiredModal, useVbenModal } from '@vben/common-ui';
|
||||
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
|
||||
import {
|
||||
AntdProfileOutlined,
|
||||
BookOpenText,
|
||||
CircleHelp,
|
||||
MdiGithub,
|
||||
} from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
|
@ -15,52 +20,43 @@ import {
|
|||
} from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
import { formatDateTime, openWindow } from '@vben/utils';
|
||||
|
||||
import {
|
||||
getUnreadNotifyMessageCount,
|
||||
getUnreadNotifyMessageList,
|
||||
updateAllNotifyMessageRead,
|
||||
updateNotifyMessageRead,
|
||||
} from '#/api/system/notify/message';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
|
||||
const notifications = ref<NotificationItem[]>([
|
||||
{
|
||||
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
|
||||
date: '3小时前',
|
||||
isRead: true,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '收到了 14 份新周报',
|
||||
},
|
||||
{
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '刚刚',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '朱偏右 回复了你',
|
||||
},
|
||||
{
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '2024-01-01',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '曲丽丽 评论了你',
|
||||
},
|
||||
{
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '代办提醒',
|
||||
},
|
||||
]);
|
||||
import Help from './components/help.vue';
|
||||
import TenantDropdown from './components/tenant-dropdown.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
const notifications = ref<NotificationItem[]>([]);
|
||||
const unreadCount = ref(0);
|
||||
const showDot = computed(() => unreadCount.value > 0);
|
||||
|
||||
const [HelpModal, helpModalApi] = useVbenModal({
|
||||
connectedComponent: Help,
|
||||
});
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
handler: () => {
|
||||
router.push({ name: 'Profile' });
|
||||
},
|
||||
icon: AntdProfileOutlined,
|
||||
text: $t('ui.widgets.profile'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_DOC_URL, {
|
||||
|
@ -81,9 +77,7 @@ const menus = computed(() => [
|
|||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(`${VBEN_GITHUB_URL}/issues`, {
|
||||
target: '_blank',
|
||||
});
|
||||
helpModalApi.open();
|
||||
},
|
||||
icon: CircleHelp,
|
||||
text: $t('ui.widgets.qa'),
|
||||
|
@ -98,19 +92,83 @@ async function handleLogout() {
|
|||
await authStore.logout(false);
|
||||
}
|
||||
|
||||
function handleNoticeClear() {
|
||||
/** 获得未读消息数 */
|
||||
async function handleNotificationGetUnreadCount() {
|
||||
unreadCount.value = await getUnreadNotifyMessageCount();
|
||||
}
|
||||
|
||||
/** 获得消息列表 */
|
||||
async function handleNotificationGetList() {
|
||||
const list = await getUnreadNotifyMessageList();
|
||||
notifications.value = list.map((item) => ({
|
||||
avatar: preferences.app.defaultAvatar,
|
||||
date: formatDateTime(item.createTime) as string,
|
||||
isRead: false,
|
||||
id: item.id,
|
||||
message: item.templateContent,
|
||||
title: item.templateNickname,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 跳转我的站内信 */
|
||||
function handleNotificationViewAll() {
|
||||
router.push({
|
||||
name: 'MyNotifyMessage',
|
||||
});
|
||||
}
|
||||
|
||||
/** 标记所有已读 */
|
||||
async function handleNotificationMakeAll() {
|
||||
await updateAllNotifyMessageRead();
|
||||
unreadCount.value = 0;
|
||||
notifications.value = [];
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
notifications.value.forEach((item) => (item.isRead = true));
|
||||
/** 清空通知 */
|
||||
async function handleNotificationClear() {
|
||||
handleNotificationMakeAll();
|
||||
}
|
||||
|
||||
/** 标记单个已读 */
|
||||
async function handleNotificationRead(item: NotificationItem) {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
await updateNotifyMessageRead([item.id]);
|
||||
await handleNotificationGetUnreadCount();
|
||||
notifications.value = notifications.value.filter((n) => n.id !== item.id);
|
||||
}
|
||||
|
||||
/** 处理通知打开 */
|
||||
function handleNotificationOpen(open: boolean) {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
handleNotificationGetList();
|
||||
handleNotificationGetUnreadCount();
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
onMounted(() => {
|
||||
// 首次加载未读数量
|
||||
handleNotificationGetUnreadCount();
|
||||
// 轮询刷新未读数量
|
||||
setInterval(
|
||||
() => {
|
||||
if (userStore.userInfo) {
|
||||
handleNotificationGetUnreadCount();
|
||||
}
|
||||
},
|
||||
1000 * 60 * 2,
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => preferences.app.watermark,
|
||||
async (enable) => {
|
||||
if (enable) {
|
||||
await updateWatermark({
|
||||
content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
|
||||
content: `${userStore.userInfo?.id} - ${userStore.userInfo?.nickname}`,
|
||||
});
|
||||
} else {
|
||||
destroyWatermark();
|
||||
|
@ -128,9 +186,9 @@ watch(
|
|||
<UserDropdown
|
||||
:avatar
|
||||
:menus
|
||||
:text="userStore.userInfo?.realName"
|
||||
description="ann.vben@gmail.com"
|
||||
tag-text="Pro"
|
||||
:text="userStore.userInfo?.nickname"
|
||||
:description="userStore.userInfo?.email"
|
||||
:tag-text="userStore.userInfo?.username"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</template>
|
||||
|
@ -138,10 +196,16 @@ watch(
|
|||
<Notification
|
||||
:dot="showDot"
|
||||
:notifications="notifications"
|
||||
@clear="handleNoticeClear"
|
||||
@make-all="handleMakeAll"
|
||||
@clear="handleNotificationClear"
|
||||
@make-all="handleNotificationMakeAll"
|
||||
@view-all="handleNotificationViewAll"
|
||||
@open="handleNotificationOpen"
|
||||
@read="handleNotificationRead"
|
||||
/>
|
||||
</template>
|
||||
<template #header-right-1>
|
||||
<TenantDropdown class="w-30 mr-2" />
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
v-model:open="accessStore.loginExpired"
|
||||
|
@ -154,4 +218,5 @@ watch(
|
|||
<LockScreen :avatar @to-login="handleLogout" />
|
||||
</template>
|
||||
</BasicLayout>
|
||||
<HelpModal />
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts" setup>
|
||||
// TODO @xingyu:这个有可能 3 端复用么?想着是把 layouts 下的 components 没有这个目录哈;
|
||||
import { useVbenModal, VbenButton, VbenButtonGroup } from '@vben/common-ui';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ElImage, ElTag } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
draggable: true,
|
||||
overlayBlur: 5,
|
||||
footer: false,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[40%]" :title="$t('ui.widgets.qa')">
|
||||
<div class="mt-2 flex flex-col">
|
||||
<div class="mt-2 flex flex-row">
|
||||
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
|
||||
<p class="p-2">项目地址:</p>
|
||||
<VbenButton
|
||||
variant="link"
|
||||
@click="
|
||||
openWindow('https://gitee.com/yudaocode/yudao-ui-admin-vben')
|
||||
"
|
||||
>
|
||||
Gitee
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
variant="link"
|
||||
@click="
|
||||
openWindow('https://github.com/yudaocode/yudao-ui-admin-vben')
|
||||
"
|
||||
>
|
||||
Github
|
||||
</VbenButton>
|
||||
</VbenButtonGroup>
|
||||
|
||||
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
|
||||
<p class="p-2">issues:</p>
|
||||
<VbenButton
|
||||
variant="link"
|
||||
@click="
|
||||
openWindow(
|
||||
'https://gitee.com/yudaocode/yudao-ui-admin-vben/issues',
|
||||
)
|
||||
"
|
||||
>
|
||||
Gitee
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
variant="link"
|
||||
@click="
|
||||
openWindow(
|
||||
'https://github.com/yudaocode/yudao-ui-admin-vben/issues',
|
||||
)
|
||||
"
|
||||
>
|
||||
Github
|
||||
</VbenButton>
|
||||
</VbenButtonGroup>
|
||||
|
||||
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
|
||||
<p class="p-2">开发文档:</p>
|
||||
<VbenButton
|
||||
variant="link"
|
||||
@click="openWindow('https://doc.iocoder.cn/quick-start/')"
|
||||
>
|
||||
项目文档
|
||||
</VbenButton>
|
||||
<VbenButton variant="link" @click="openWindow('https://antdv.com/')">
|
||||
antdv 文档
|
||||
</VbenButton>
|
||||
</VbenButtonGroup>
|
||||
</div>
|
||||
<p class="mt-2 flex justify-center">
|
||||
<span>
|
||||
<ElImage src="/wx-xingyu.png" alt="数舵科技" />
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-2 flex justify-center pt-4 text-sm italic">
|
||||
本项目采用<ElTag type="primary">MIT</ElTag>开源协议,个人与企业可100%
|
||||
免费使用。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
|
@ -0,0 +1,67 @@
|
|||
<script lang="ts" setup>
|
||||
// TODO @puhui999:下拉选择的宽度不对哈;
|
||||
import type { SystemTenantApi } from '#/api/system/tenant';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { isTenantEnable, useTabs } from '@vben/hooks';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { ElMessage, ElOption, ElSelect } from 'element-plus';
|
||||
|
||||
import { getSimpleTenantList } from '#/api/system/tenant';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const { closeOtherTabs, refreshTab } = useTabs();
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
const tenantEnable = isTenantEnable();
|
||||
|
||||
const value = ref<number>(accessStore.visitTenantId ?? 0); // 当前访问的租户 ID
|
||||
const tenants = ref<SystemTenantApi.Tenant[]>([]); // 租户列表
|
||||
|
||||
// TODO @xingyu:这个有可能 3 端复用么?
|
||||
async function handleChange(id: number) {
|
||||
if (id === null) return;
|
||||
|
||||
// 设置访问租户 ID
|
||||
accessStore.setVisitTenantId(id);
|
||||
// 关闭其他标签页,只保留当前页
|
||||
await closeOtherTabs();
|
||||
// 刷新当前页面
|
||||
await refreshTab();
|
||||
// 提示切换成功
|
||||
const tenant = tenants.value.find((item) => item.id === id);
|
||||
if (tenant) {
|
||||
ElMessage.success(`切换当前租户为: ${tenant.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!tenantEnable) {
|
||||
return;
|
||||
}
|
||||
tenants.value = await getSimpleTenantList();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="tenantEnable && hasAccessByCodes(['system:tenant:visit'])">
|
||||
<ElSelect
|
||||
v-model="value"
|
||||
:placeholder="$t('page.tenant.placeholder')"
|
||||
clearable
|
||||
class="w-40"
|
||||
@change="handleChange"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in tenants"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id || 0"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,103 @@
|
|||
import type { App, Component } from 'vue';
|
||||
|
||||
import FcDesigner from '@form-create/designer';
|
||||
import formCreate from '@form-create/element-ui';
|
||||
import install from '@form-create/element-ui/auto-import';
|
||||
// 👇使用 form-create 需额外全局引入 element plus 组件
|
||||
import {
|
||||
ElAlert,
|
||||
ElAside,
|
||||
ElBadge,
|
||||
ElCard,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElContainer,
|
||||
ElDivider,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElFooter,
|
||||
ElHeader,
|
||||
ElMain,
|
||||
ElMenu,
|
||||
ElMenuItem,
|
||||
ElMessage,
|
||||
ElPopconfirm,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
ElTag,
|
||||
ElText,
|
||||
ElTransfer,
|
||||
} from 'element-plus';
|
||||
|
||||
// ======================= 自定义组件 =======================
|
||||
import { useApiSelect } from '#/components/form-create';
|
||||
import DictSelect from '#/components/form-create/components/dict-select.vue';
|
||||
import { useImagesUpload } from '#/components/form-create/components/use-images-upload';
|
||||
import { Tinymce } from '#/components/tinymce';
|
||||
import { FileUpload, ImageUpload } from '#/components/upload';
|
||||
|
||||
const UserSelect = useApiSelect({
|
||||
name: 'UserSelect',
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
url: '/system/user/simple-list',
|
||||
});
|
||||
const DeptSelect = useApiSelect({
|
||||
name: 'DeptSelect',
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
url: '/system/dept/simple-list',
|
||||
});
|
||||
const ApiSelect = useApiSelect({
|
||||
name: 'ApiSelect',
|
||||
});
|
||||
const ImagesUpload = useImagesUpload();
|
||||
|
||||
const components = [
|
||||
ImageUpload,
|
||||
ImagesUpload,
|
||||
FileUpload,
|
||||
Tinymce,
|
||||
DictSelect,
|
||||
UserSelect,
|
||||
DeptSelect,
|
||||
ApiSelect,
|
||||
ElAlert,
|
||||
ElTransfer,
|
||||
ElAside,
|
||||
ElContainer,
|
||||
ElDivider,
|
||||
ElHeader,
|
||||
ElMain,
|
||||
ElPopconfirm,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
ElDropdown,
|
||||
ElDropdownMenu,
|
||||
ElDropdownItem,
|
||||
ElBadge,
|
||||
ElTag,
|
||||
ElText,
|
||||
ElMenu,
|
||||
ElMenuItem,
|
||||
ElFooter,
|
||||
ElMessage,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElCard,
|
||||
];
|
||||
|
||||
// 参考 http://www.form-create.com/v3/element-ui/auto-import.html 文档
|
||||
export const setupFormCreate = (app: App<Element>) => {
|
||||
components.forEach((component) => {
|
||||
app.component(component.name as string, component as Component);
|
||||
});
|
||||
formCreate.use(install);
|
||||
app.use(formCreate);
|
||||
app.use(FcDesigner);
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/infra/job/job-log',
|
||||
component: () => import('#/views/infra/job/logger/index.vue'),
|
||||
name: 'InfraJobLog',
|
||||
meta: {
|
||||
title: '调度日志',
|
||||
icon: 'ant-design:history-outlined',
|
||||
activePath: '/infra/job',
|
||||
keepAlive: false,
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/codegen',
|
||||
name: 'CodegenEdit',
|
||||
meta: {
|
||||
title: '代码生成',
|
||||
icon: 'ic:baseline-view-in-ar',
|
||||
keepAlive: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/codegen/edit',
|
||||
name: 'InfraCodegenEdit',
|
||||
component: () => import('#/views/infra/codegen/edit/index.vue'),
|
||||
meta: {
|
||||
title: '修改生成配置',
|
||||
activeMenu: '/infra/codegen',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
|
@ -0,0 +1,16 @@
|
|||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/system/notify-message',
|
||||
component: () => import('#/views/system/notify/my/index.vue'),
|
||||
name: 'MyNotifyMessage',
|
||||
meta: {
|
||||
title: '我的站内信',
|
||||
icon: 'ant-design:message-filled',
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
|
@ -1,7 +1,65 @@
|
|||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import type { SystemUserProfileApi } from '#/api/system/user/profile';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { ElCard, ElTabPane, ElTabs } from 'element-plus';
|
||||
|
||||
import { getUserProfile } from '#/api/system/user/profile';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import BaseInfo from './modules/base-info.vue';
|
||||
import ProfileUser from './modules/profile-user.vue';
|
||||
import ResetPwd from './modules/reset-pwd.vue';
|
||||
import UserSocial from './modules/user-social.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const activeName = ref('basicInfo');
|
||||
|
||||
/** 加载个人信息 */
|
||||
const profile = ref<SystemUserProfileApi.UserProfileRespVO>();
|
||||
async function loadProfile() {
|
||||
profile.value = await getUserProfile();
|
||||
}
|
||||
|
||||
/** 刷新个人信息 */
|
||||
async function refreshProfile() {
|
||||
// 加载个人信息
|
||||
await loadProfile();
|
||||
|
||||
// 更新 store
|
||||
await authStore.fetchUserInfo();
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(loadProfile);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
<Page auto-content-height>
|
||||
<div class="flex">
|
||||
<!-- 左侧 个人信息 -->
|
||||
<ElCard class="w-2/5" title="个人信息">
|
||||
<ProfileUser :profile="profile" @success="refreshProfile" />
|
||||
</ElCard>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<!-- 右侧 标签页 -->
|
||||
<ElCard class="ml-3 w-3/5">
|
||||
<ElTabs v-model="activeName" class="-mt-4">
|
||||
<ElTabPane name="basicInfo" label="基本设置">
|
||||
<BaseInfo :profile="profile" @success="refreshProfile" />
|
||||
</ElTabPane>
|
||||
<ElTabPane name="resetPwd" label="密码设置">
|
||||
<ResetPwd />
|
||||
</ElTabPane>
|
||||
<ElTabPane name="userSocial" label="社交绑定" force-render>
|
||||
<UserSocial @update:active-name="activeName = $event" />
|
||||
</ElTabPane>
|
||||
<!-- TODO @芋艿:在线设备 -->
|
||||
</ElTabs>
|
||||
</ElCard>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { SystemUserProfileApi } from '#/api/system/user/profile';
|
||||
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
import { updateUserProfile } from '#/api/system/user/profile';
|
||||
import { DICT_TYPE, getDictOptions } from '#/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
profile?: SystemUserProfileApi.UserProfileRespVO;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
// TODO @puhui999:展示貌似不太对;应该是左右,不是上下哈
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
labelWidth: 70,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
label: '用户昵称',
|
||||
fieldName: 'nickname',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入用户昵称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
label: '用户手机',
|
||||
fieldName: 'mobile',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入用户手机',
|
||||
},
|
||||
rules: z.string(),
|
||||
},
|
||||
{
|
||||
label: '用户邮箱',
|
||||
fieldName: 'email',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入用户邮箱',
|
||||
},
|
||||
rules: z.string().email('请输入正确的邮箱'),
|
||||
},
|
||||
{
|
||||
label: '用户性别',
|
||||
fieldName: 'sex',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number(),
|
||||
},
|
||||
],
|
||||
resetButtonOptions: {
|
||||
show: false,
|
||||
},
|
||||
submitButtonOptions: {
|
||||
content: '更新信息',
|
||||
},
|
||||
handleSubmit,
|
||||
});
|
||||
|
||||
async function handleSubmit(values: Recordable<any>) {
|
||||
try {
|
||||
formApi.setLoading(true);
|
||||
// 提交表单
|
||||
await updateUserProfile(values as SystemUserProfileApi.UpdateProfileReqVO);
|
||||
// 关闭并提示
|
||||
emit('success');
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
formApi.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听 profile 变化 */
|
||||
watch(
|
||||
() => props.profile,
|
||||
(newProfile) => {
|
||||
if (newProfile) {
|
||||
formApi.setValues(newProfile);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-16px md:w-full lg:w-1/2 2xl:w-2/5">
|
||||
<Form />
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,145 @@
|
|||
<script setup lang="ts">
|
||||
import type { SystemUserProfileApi } from '#/api/system/user/profile';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import { ElDescriptions, ElDescriptionsItem, ElTooltip } from 'element-plus';
|
||||
|
||||
import { updateUserProfile } from '#/api/system/user/profile';
|
||||
import { CropperAvatar } from '#/components/cropper';
|
||||
import { useUpload } from '#/components/upload/use-upload';
|
||||
|
||||
const props = defineProps<{
|
||||
profile?: SystemUserProfileApi.UserProfileRespVO;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const avatar = computed(
|
||||
() => props.profile?.avatar || preferences.app.defaultAvatar,
|
||||
);
|
||||
|
||||
// TODO @puhui999:头像上传没跑通
|
||||
async function handelUpload({
|
||||
file,
|
||||
filename,
|
||||
}: {
|
||||
file: Blob;
|
||||
filename: string;
|
||||
}) {
|
||||
// 1. 上传头像,获取 URL
|
||||
const { httpRequest } = useUpload();
|
||||
// 将 Blob 转换为 File
|
||||
const fileObj = new File([file], filename, { type: file.type });
|
||||
const avatar = await httpRequest(fileObj);
|
||||
// 2. 更新用户头像
|
||||
await updateUserProfile({ avatar });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="profile">
|
||||
<div class="flex flex-col items-center">
|
||||
<ElTooltip content="点击上传头像">
|
||||
<CropperAvatar
|
||||
:show-btn="false"
|
||||
:upload-api="handelUpload"
|
||||
:value="avatar"
|
||||
:width="120"
|
||||
@change="emit('success')"
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<ElDescriptions :column="2">
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
|
||||
用户账号
|
||||
</div>
|
||||
</template>
|
||||
{{ profile.username }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon
|
||||
icon="ant-design:user-switch-outlined"
|
||||
class="mr-1"
|
||||
/>
|
||||
所属角色
|
||||
</div>
|
||||
</template>
|
||||
{{ profile.roles.map((role) => role.name).join(',') }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="ant-design:phone-outlined" class="mr-1" />
|
||||
手机号码
|
||||
</div>
|
||||
</template>
|
||||
{{ profile.mobile }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="ant-design:mail-outlined" class="mr-1" />
|
||||
用户邮箱
|
||||
</div>
|
||||
</template>
|
||||
{{ profile.email }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="ant-design:team-outlined" class="mr-1" />
|
||||
所属部门
|
||||
</div>
|
||||
</template>
|
||||
{{ profile.dept?.name }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon
|
||||
icon="ant-design:usergroup-add-outlined"
|
||||
class="mr-1"
|
||||
/>
|
||||
所属岗位
|
||||
</div>
|
||||
</template>
|
||||
{{ profile.posts.map((post) => post.name).join(',') }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon
|
||||
icon="ant-design:clock-circle-outlined"
|
||||
class="mr-1"
|
||||
/>
|
||||
创建时间
|
||||
</div>
|
||||
</template>
|
||||
{{ formatDateTime(profile.createTime) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="ant-design:login-outlined" class="mr-1" />
|
||||
登录时间
|
||||
</div>
|
||||
</template>
|
||||
{{ formatDateTime(profile.loginDate) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
import { updateUserPassword } from '#/api/system/user/profile';
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
labelWidth: 70,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'oldPassword',
|
||||
label: '旧密码',
|
||||
rules: z
|
||||
.string({ message: '请输入密码' })
|
||||
.min(5, '密码长度不能少于 5 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符'),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
return z
|
||||
.string({ message: '请输入新密码' })
|
||||
.min(5, '密码长度不能少于 5 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符')
|
||||
.refine(
|
||||
(value) => value !== values.oldPassword,
|
||||
'新旧密码不能相同',
|
||||
);
|
||||
},
|
||||
triggerFields: ['newPassword', 'oldPassword'],
|
||||
},
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('authentication.confirmPassword'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
return z
|
||||
.string({ message: '请输入确认密码' })
|
||||
.min(5, '密码长度不能少于 5 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符')
|
||||
.refine(
|
||||
(value) => value === values.newPassword,
|
||||
'新密码和确认密码不一致',
|
||||
);
|
||||
},
|
||||
triggerFields: ['newPassword', 'confirmPassword'],
|
||||
},
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
rules: 'required',
|
||||
},
|
||||
],
|
||||
resetButtonOptions: {
|
||||
show: false,
|
||||
},
|
||||
submitButtonOptions: {
|
||||
content: '修改密码',
|
||||
},
|
||||
handleSubmit,
|
||||
});
|
||||
|
||||
async function handleSubmit(values: Recordable<any>) {
|
||||
try {
|
||||
formApi.setLoading(true);
|
||||
// 提交表单
|
||||
await updateUserPassword({
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
formApi.setLoading(false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-[16px] md:w-full lg:w-1/2 2xl:w-2/5">
|
||||
<Form />
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,215 @@
|
|||
<script setup lang="tsx">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemSocialUserApi } from '#/api/system/social/user';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { confirm } from '@vben/common-ui';
|
||||
|
||||
import { ElButton, ElCard, ElImage, ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { socialAuthRedirect } from '#/api/core/auth';
|
||||
import {
|
||||
getBindSocialUserList,
|
||||
socialBind,
|
||||
socialUnbind,
|
||||
} from '#/api/system/social/user';
|
||||
import { $t } from '#/locales';
|
||||
import { DICT_TYPE, getDictLabel, SystemUserSocialTypeEnum } from '#/utils';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:activeName', v: string): void;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
/** 已经绑定的平台 */
|
||||
const bindList = ref<SystemSocialUserApi.SocialUser[]>([]);
|
||||
const allBindList = computed<any[]>(() => {
|
||||
return Object.values(SystemUserSocialTypeEnum).map((social) => {
|
||||
const socialUser = bindList.value.find((item) => item.type === social.type);
|
||||
return {
|
||||
...social,
|
||||
socialUser,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'type',
|
||||
title: '绑定平台',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'openid',
|
||||
title: '标识',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
title: '昵称',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: '操作',
|
||||
minWidth: 80,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }: { row: SystemSocialUserApi.SocialUser }) => {
|
||||
return (
|
||||
<ElButton onClick={() => onUnbind(row)} type="text">
|
||||
解绑
|
||||
</ElButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
minHeight: 0,
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
bindList.value = await getBindSocialUserList();
|
||||
return bindList.value;
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
|
||||
});
|
||||
|
||||
/** 解绑账号 */
|
||||
function onUnbind(row: SystemSocialUserApi.SocialUser) {
|
||||
confirm({
|
||||
content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]平台的[${row.openid}]账号吗?`,
|
||||
}).then(async () => {
|
||||
await socialUnbind({ type: row.type, openid: row.openid });
|
||||
// 提示成功
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
await gridApi.reload();
|
||||
});
|
||||
}
|
||||
|
||||
/** 绑定账号(跳转授权页面) */
|
||||
async function onBind(bind: any) {
|
||||
const type = bind.type;
|
||||
if (type <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 计算 redirectUri
|
||||
// tricky: type 需要先 encode 一次,否则钉钉回调会丢失。配合 getUrlValue() 使用
|
||||
const redirectUri = `${location.origin}/profile?${encodeURIComponent(`type=${type}`)}`;
|
||||
|
||||
// 进行跳转
|
||||
window.location.href = await socialAuthRedirect(type, redirectUri);
|
||||
} catch (error) {
|
||||
console.error('社交绑定处理失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听路由变化,处理社交绑定回调 */
|
||||
async function bindSocial() {
|
||||
// 社交绑定
|
||||
const type = Number(getUrlValue('type'));
|
||||
const code = route.query.code as string;
|
||||
const state = route.query.state as string;
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
await socialBind({ type, code, state });
|
||||
// 提示成功
|
||||
ElMessage.success('绑定成功');
|
||||
emit('update:activeName', 'userSocial');
|
||||
await gridApi.reload();
|
||||
// 清理 URL 参数,避免刷新重复触发
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
}
|
||||
|
||||
// TODO @芋艿:后续搞到 util 里;
|
||||
// 双层 encode 需要在回调后进行 decode
|
||||
function getUrlValue(key: string): string {
|
||||
const url = new URL(decodeURIComponent(location.href));
|
||||
return url.searchParams.get(key) ?? '';
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
bindSocial();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<Grid />
|
||||
|
||||
<div class="pb-3">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-2 px-2 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3"
|
||||
>
|
||||
<ElCard v-for="item in allBindList" :key="item.type" class="!mb-2">
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<!-- TODO @puhui999:图片大小不太对 -->
|
||||
<ElImage
|
||||
:src="item.img"
|
||||
:width="40"
|
||||
:height="40"
|
||||
:alt="item.title"
|
||||
:preview="false"
|
||||
/>
|
||||
<div class="flex flex-1 items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h4
|
||||
class="mb-[4px] text-[14px] text-black/85 dark:text-white/85"
|
||||
>
|
||||
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
|
||||
</h4>
|
||||
<span class="text-black/45 dark:text-white/45">
|
||||
<template v-if="item.socialUser">
|
||||
{{ item.socialUser?.nickname || item.socialUser?.openid }}
|
||||
</template>
|
||||
<template v-else>
|
||||
绑定{{
|
||||
getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type)
|
||||
}}账号
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<ElButton
|
||||
:disabled="!!item.socialUser"
|
||||
size="small"
|
||||
type="text"
|
||||
@click="onBind(item)"
|
||||
>
|
||||
{{ item.socialUser ? '已绑定' : '绑定' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,174 @@
|
|||
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',
|
||||
// TODO @puhui999:时间范围不太对。结束时间不是 23:59:59 这种哈
|
||||
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']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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 { ElButton } from 'element-plus';
|
||||
|
||||
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>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="ml-2"
|
||||
@click="onExport"
|
||||
v-access:code="['infra:api-access-log:export']"
|
||||
>
|
||||
<Download class="size-5" />
|
||||
{{ $t('ui.actionTitle.export') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -0,0 +1,107 @@
|
|||
<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 { ElDescriptions, ElDescriptionsItem } from 'element-plus';
|
||||
|
||||
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>
|
||||
<!-- TODO @puhui999:这个后续会做类似 antd 的 description 组件么? -->
|
||||
<Modal
|
||||
title="API 访问日志详情"
|
||||
class="w-1/2"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<ElDescriptions
|
||||
border
|
||||
:column="1"
|
||||
size="default"
|
||||
class="mx-4"
|
||||
label-width="110px"
|
||||
>
|
||||
<ElDescriptionsItem label="日志编号">
|
||||
{{ formData?.id }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="链路追踪">
|
||||
{{ formData?.traceId }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="应用名">
|
||||
{{ formData?.applicationName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="用户信息">
|
||||
{{ formData?.userId }}
|
||||
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="用户IP">
|
||||
{{ formData?.userIp }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="用户UA">
|
||||
{{ formData?.userAgent }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="请求信息">
|
||||
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="请求参数">
|
||||
{{ formData?.requestParams }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="请求结果">
|
||||
{{ formData?.responseBody }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="请求时间">
|
||||
{{ formatDateTime(formData?.beginTime || '') }} ~
|
||||
{{ formatDateTime(formData?.endTime || '') }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="请求耗时">
|
||||
{{ formData?.duration }} ms
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作结果">
|
||||
<div v-if="formData?.resultCode === 0">正常</div>
|
||||
<div v-else-if="formData && formData?.resultCode > 0">
|
||||
失败 | {{ formData?.resultCode }} | {{ formData?.resultMsg }}
|
||||
</div>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作模块">
|
||||
{{ formData?.operateModule }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作名">
|
||||
{{ formData?.operateName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作类型">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.INFRA_OPERATE_TYPE"
|
||||
:value="formData?.operateType"
|
||||
/>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</Modal>
|
||||
</template>
|
|
@ -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'])
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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 { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
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);
|
||||
// 关闭并提示
|
||||
ElMessage.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>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="ml-2"
|
||||
@click="onExport"
|
||||
v-access:code="['infra:api-error-log:export']"
|
||||
>
|
||||
<Download class="size-5" />
|
||||
{{ $t('ui.actionTitle.export') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -0,0 +1,104 @@
|
|||
<script lang="ts" setup>
|
||||
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import { ElDescriptions, ElDescriptionsItem, ElInput } from 'element-plus';
|
||||
|
||||
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"
|
||||
>
|
||||
<ElDescriptions
|
||||
border
|
||||
:column="1"
|
||||
size="default"
|
||||
class="mx-4"
|
||||
label-width="110px"
|
||||
>
|
||||
<ElDescriptionsItem label="日志编号">
|
||||
{{ formData?.id }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="链路追踪">
|
||||
{{ formData?.traceId }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="应用名">
|
||||
{{ formData?.applicationName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="用户编号">
|
||||
{{ formData?.userId }}
|
||||
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="用户IP">
|
||||
{{ formData?.userIp }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="用户UA">
|
||||
{{ formData?.userAgent }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="请求信息">
|
||||
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="请求参数">
|
||||
{{ formData?.requestParams }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="异常时间">
|
||||
{{ formatDateTime(formData?.exceptionTime || '') }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="异常名">
|
||||
{{ formData?.exceptionName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="formData?.exceptionStackTrace" label="异常堆栈">
|
||||
<ElInput
|
||||
type="textarea"
|
||||
:model-value="formData?.exceptionStackTrace"
|
||||
:autosize="{ maxRows: 20 }"
|
||||
readonly
|
||||
/>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="处理状态">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS"
|
||||
:value="formData?.processStatus"
|
||||
/>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="formData?.processUserId" label="处理人">
|
||||
{{ formData?.processUserId }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="formData?.processTime" label="处理时间">
|
||||
{{ formatDateTime(formData?.processTime || '') }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</Modal>
|
||||
</template>
|
|
@ -0,0 +1,183 @@
|
|||
<!-- eslint-disable no-useless-escape -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, unref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { isString } from '@vben/utils';
|
||||
|
||||
import FcDesigner from '@form-create/designer';
|
||||
import formCreate from '@form-create/element-ui';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { ElButton, ElMessage } from 'element-plus';
|
||||
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(); // 表单设计器
|
||||
|
||||
// 表单设计器配置
|
||||
// TODO @puhui999:表单选择器,版本号从 package.json 拿到全局哈
|
||||
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 - 生成 JSON;1 - 生成 Options;2 - 生成组件
|
||||
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)) {
|
||||
ElMessage.success('复制成功');
|
||||
}
|
||||
} else {
|
||||
ElMessage.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 || ' ';
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
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>
|
||||
<ElButton size="small" type="primary" plain @click="showJson">
|
||||
生成JSON
|
||||
</ElButton>
|
||||
<ElButton size="small" type="primary" plain @click="showOption">
|
||||
生成Options
|
||||
</ElButton>
|
||||
<ElButton size="small" type="primary" plain @click="showTemplate">
|
||||
生成组件
|
||||
</ElButton>
|
||||
</template>
|
||||
</FcDesigner>
|
||||
|
||||
<!-- 弹窗:表单预览 -->
|
||||
<Modal :title="dialogTitle" :footer="false" :fullscreen-button="false">
|
||||
<div>
|
||||
<ElButton style="float: right" @click="copy(formData)"> 复制 </ElButton>
|
||||
<div>
|
||||
<pre><code v-dompurify-html="highlightedCode(formData)" class="hljs"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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 { ElButton, ElLoading, ElMessage, ElStep, ElSteps } from 'element-plus';
|
||||
|
||||
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) {
|
||||
ElMessage.warning('保存失败,原因:基本信息表单校验失败请检查!!!');
|
||||
return;
|
||||
}
|
||||
const generateInfoValid = await generateInfoRef.value?.validate();
|
||||
if (!generateInfoValid) {
|
||||
ElMessage.warning('保存失败,原因:生成信息表单校验失败请检查!!!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.updating'),
|
||||
fullscreen: true,
|
||||
});
|
||||
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,
|
||||
});
|
||||
// 关闭并提示
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
close();
|
||||
} catch (error) {
|
||||
console.error('保存失败', error);
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
};
|
||||
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"
|
||||
>
|
||||
<ElSteps
|
||||
:active="currentStep"
|
||||
class="mb-8 rounded shadow-sm dark:bg-[#141414]"
|
||||
simple
|
||||
>
|
||||
<ElStep
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
:title="step.title"
|
||||
/>
|
||||
</ElSteps>
|
||||
|
||||
<div class="flex-1 overflow-auto py-4">
|
||||
<!-- TODO @puhui999:顶部的导航,应该可以点击哈; -->
|
||||
<!-- 根据当前步骤显示对应的组件 -->
|
||||
<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">
|
||||
<ElButton v-show="currentStep > 0" @click="prevStep">上一步</ElButton>
|
||||
<ElButton v-show="currentStep < steps.length - 1" @click="nextStep">
|
||||
下一步
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="loading" @click="submitForm">
|
||||
保存
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
|
@ -0,0 +1,228 @@
|
|||
<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 { ElButton, ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
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 loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', [row.tableName]),
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await deleteCodegenTable(row.id);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.tableName]));
|
||||
onRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步数据库 */
|
||||
async function onSync(row: InfraCodegenApi.CodegenTable) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.updating', [row.tableName]),
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await syncCodegenFromDB(row.id);
|
||||
ElMessage.success($t('ui.actionMessage.updateSuccess', [row.tableName]));
|
||||
onRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成代码 */
|
||||
async function onGenerate(row: InfraCodegenApi.CodegenTable) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '正在生成代码...',
|
||||
fullscreen: true,
|
||||
});
|
||||
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);
|
||||
ElMessage.success('代码生成成功');
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格操作按钮的回调函数 */
|
||||
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>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="onImport"
|
||||
v-access:code="['infra:codegen:create']"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
导入
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -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>
|
|
@ -0,0 +1,151 @@
|
|||
<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 { ElCheckbox, ElInput, ElOption, ElSelect } from 'element-plus';
|
||||
|
||||
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 }">
|
||||
<ElInput v-model="row.columnComment" />
|
||||
</template>
|
||||
|
||||
<!-- Java 类型 -->
|
||||
<template #javaType="{ row, column }">
|
||||
<ElSelect v-model="row.javaType" style="width: 100%">
|
||||
<ElOption
|
||||
v-for="option in column.params.options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
<!-- Java 属性 -->
|
||||
<template #javaField="{ row }">
|
||||
<ElInput v-model="row.javaField" />
|
||||
</template>
|
||||
|
||||
<!-- 插入 -->
|
||||
<template #createOperation="{ row }">
|
||||
<ElCheckbox v-model="row.createOperation" />
|
||||
</template>
|
||||
<!-- 编辑 -->
|
||||
<template #updateOperation="{ row }">
|
||||
<ElCheckbox v-model="row.updateOperation" />
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<template #listOperationResult="{ row }">
|
||||
<ElCheckbox v-model="row.listOperationResult" />
|
||||
</template>
|
||||
<!-- 查询 -->
|
||||
<template #listOperation="{ row }">
|
||||
<ElCheckbox v-model="row.listOperation" />
|
||||
</template>
|
||||
|
||||
<!-- 查询方式 -->
|
||||
<template #listOperationCondition="{ row, column }">
|
||||
<ElSelect v-model="row.listOperationCondition" class="w-full">
|
||||
<ElOption
|
||||
v-for="option in column.params.options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<!-- 允许空 -->
|
||||
<template #nullable="{ row }">
|
||||
<ElCheckbox v-model="row.nullable" />
|
||||
</template>
|
||||
|
||||
<!-- 显示类型 -->
|
||||
<template #htmlType="{ row, column }">
|
||||
<ElSelect v-model="row.htmlType" class="w-full">
|
||||
<ElOption
|
||||
v-for="option in column.params.options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<!-- 字典类型 -->
|
||||
<template #dictType="{ row }">
|
||||
<ElSelect v-model="row.dictType" class="w-full" clearable filterable>
|
||||
<ElOption
|
||||
v-for="option in dictTypeOptions"
|
||||
:key="option.type"
|
||||
:label="option.name"
|
||||
:value="option.type"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<!-- 示例 -->
|
||||
<template #example="{ row }">
|
||||
<ElInput v-model="row.example" />
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
|
@ -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>
|
|
@ -0,0 +1,119 @@
|
|||
<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 { ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
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) {
|
||||
ElMessage.error('请选择数据源');
|
||||
return;
|
||||
}
|
||||
// 1.2 校验是否选择了表
|
||||
if (formData.tableNames.length === 0) {
|
||||
ElMessage.error('请选择需要导入的表');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 提交请求
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '导入中...',
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await createCodegenList(formData);
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<Grid />
|
||||
</Modal>
|
||||
</template>
|
|
@ -0,0 +1,388 @@
|
|||
<script lang="ts" setup>
|
||||
import type { TabPaneName } from 'element-plus';
|
||||
|
||||
// TODO @芋艿:待定,vben2.0 有 CodeEditor,不确定官方后续会不会迁移!!!
|
||||
import type { InfraCodegenApi } from '#/api/infra/codegen';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Copy } from '@vben/icons';
|
||||
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { ElMessage, ElTabPane, ElTabs, ElTree } from 'element-plus';
|
||||
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 handleTabsEdit = (
|
||||
targetName: TabPaneName | undefined,
|
||||
action: 'add' | 'remove',
|
||||
) => {
|
||||
switch (action) {
|
||||
case 'add': {
|
||||
// el-tab 原生添加的位置用来放复制图标了,所以添加就是复制
|
||||
copyCode();
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
removeCodeMapKey(targetName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 复制代码 */
|
||||
const copyCode = async () => {
|
||||
const { copy } = useClipboard();
|
||||
const file = previewFiles.value.find(
|
||||
(item) => item.filePath === activeKey.value,
|
||||
);
|
||||
if (file) {
|
||||
await copy(file.code);
|
||||
ElMessage.success('复制成功');
|
||||
}
|
||||
};
|
||||
|
||||
/** 文件节点点击事件 */
|
||||
const handleNodeClick = (node: FileNode) => {
|
||||
if (!node.isLeaf) return;
|
||||
|
||||
activeKey.value = 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"
|
||||
>
|
||||
<ElTree
|
||||
v-if="fileTree.length > 0"
|
||||
:data="fileTree"
|
||||
:props="{
|
||||
label: 'title',
|
||||
children: 'children',
|
||||
isLeaf: 'isLeaf',
|
||||
}"
|
||||
node-key="key"
|
||||
default-expand-all
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</div>
|
||||
<!-- 代码预览 -->
|
||||
<div class="h-full w-2/3 overflow-auto pl-4">
|
||||
<ElTabs v-model="activeKey" type="card" editable @edit="handleTabsEdit">
|
||||
<ElTabPane
|
||||
v-for="key in codeMap.keys()"
|
||||
:key="key"
|
||||
:label="key.split('/').pop()"
|
||||
:name="key"
|
||||
>
|
||||
<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>
|
||||
</ElTabPane>
|
||||
<template #add-icon>
|
||||
<Copy />
|
||||
</template>
|
||||
</ElTabs>
|
||||
</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>
|
|
@ -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']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
<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 { ElButton, ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
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 loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await deleteConfig(row.id as number);
|
||||
// TODO @puhui999:close 是不是放在 finally 里面更好?
|
||||
loadingInstance.close();
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
onRefresh();
|
||||
} catch {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格操作按钮的回调函数 */
|
||||
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>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
v-access:code="['infra:config:create']"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', ['参数']) }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="ml-2"
|
||||
@click="onExport"
|
||||
v-access:code="['infra:config:export']"
|
||||
>
|
||||
<Download class="size-5" />
|
||||
{{ $t('ui.actionTitle.export') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -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 { ElMessage } from 'element-plus';
|
||||
|
||||
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');
|
||||
ElMessage.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>
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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 { ElButton, ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
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 loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await deleteDataSourceConfig(row.id as number);
|
||||
loadingInstance.close();
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
await handleLoadData();
|
||||
} catch {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格操作按钮的回调函数 */
|
||||
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>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
v-access:code="['infra:data-source-config:create']"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', ['数据源']) }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -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 { ElMessage } from 'element-plus';
|
||||
|
||||
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');
|
||||
ElMessage.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>
|
|
@ -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>
|
|
@ -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']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { InfraFileApi } from '#/api/infra/file';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { Upload } from '@vben/icons';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { ElButton, ElImage, ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
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) {
|
||||
ElMessage.error('文件 URL 为空');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copy(row.url);
|
||||
ElMessage.success('复制成功');
|
||||
} catch {
|
||||
ElMessage.error('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开 URL */
|
||||
function openUrl(url?: string) {
|
||||
if (url) {
|
||||
openWindow(url);
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除文件 */
|
||||
async function onDelete(row: InfraFileApi.File) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', [row.name || row.path]),
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await deleteFile(row.id as number);
|
||||
loadingInstance.close();
|
||||
ElMessage.success(
|
||||
$t('ui.actionMessage.deleteSuccess', [row.name || row.path]),
|
||||
);
|
||||
onRefresh();
|
||||
} catch {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格操作按钮的回调函数 */
|
||||
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>
|
||||
<ElButton type="primary" @click="onUpload">
|
||||
<Upload class="size-5" />
|
||||
上传图片
|
||||
</ElButton>
|
||||
</template>
|
||||
<template #file-content="{ row }">
|
||||
<ElImage v-if="row.type && row.type.includes('image')" :src="row.url" />
|
||||
<ElButton
|
||||
v-else-if="row.type && row.type.includes('pdf')"
|
||||
type="primary"
|
||||
link
|
||||
@click="() => openUrl(row.url)"
|
||||
>
|
||||
预览
|
||||
</ElButton>
|
||||
<ElButton v-else type="primary" link @click="() => openUrl(row.url)">
|
||||
下载
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts" setup>
|
||||
import type { UploadRawFile } from 'element-plus';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ElMessage, ElUpload } from 'element-plus';
|
||||
|
||||
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');
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 上传前 */
|
||||
function beforeUpload(file: UploadRawFile) {
|
||||
formApi.setFieldValue('file', file);
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="上传图片">
|
||||
<Form class="mx-4">
|
||||
<template #file>
|
||||
<div class="w-full">
|
||||
<!-- 上传区域 -->
|
||||
<ElUpload
|
||||
class="upload-demo"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".jpg,.png,.gif,.webp"
|
||||
:before-upload="beforeUpload"
|
||||
list-type="picture-card"
|
||||
>
|
||||
<div class="el-upload__text">
|
||||
<p>
|
||||
<i class="el-icon-upload text-2xl"></i>
|
||||
</p>
|
||||
<p>点击或拖拽文件到此区域上传</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
支持 .jpg、.png、.gif、.webp 格式图片文件
|
||||
</p>
|
||||
</div>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</template>
|
||||
</Form>
|
||||
</Modal>
|
||||
</template>
|
|
@ -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: '测试',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
<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 { ElButton, ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
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 loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.updating', [row.name]),
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await updateFileConfigMaster(row.id as number);
|
||||
loadingInstance.close();
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
onRefresh();
|
||||
} catch {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试文件配置 */
|
||||
async function onTest(row: InfraFileConfigApi.FileConfig) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '测试上传中...',
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
const response = await testFileConfig(row.id as number);
|
||||
loadingInstance.close();
|
||||
// 确认是否访问该文件
|
||||
confirm({
|
||||
title: '测试上传成功',
|
||||
content: '是否要访问该文件?',
|
||||
confirmText: '访问',
|
||||
cancelText: '取消',
|
||||
}).then(() => {
|
||||
openWindow(response);
|
||||
});
|
||||
} catch {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除文件配置 */
|
||||
async function onDelete(row: InfraFileConfigApi.FileConfig) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await deleteFileConfig(row.id as number);
|
||||
loadingInstance.close();
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
onRefresh();
|
||||
} catch {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格操作按钮的回调函数 */
|
||||
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>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
v-access:code="['infra:file-config:create']"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', ['文件配置']) }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -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 { ElMessage } from 'element-plus';
|
||||
|
||||
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');
|
||||
ElMessage.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>
|
|
@ -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']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -0,0 +1,222 @@
|
|||
<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 { ElButton, ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
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);
|
||||
// 提示成功
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
onRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行一次任务 */
|
||||
async function onTrigger(row: InfraJobApi.Job) {
|
||||
confirm({
|
||||
content: `确定执行一次 ${row.name} 吗?`,
|
||||
}).then(async () => {
|
||||
await runJob(row.id as number);
|
||||
ElMessage.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 loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
fullscreen: true,
|
||||
});
|
||||
try {
|
||||
await deleteJob(row.id as number);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
onRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格操作按钮的回调函数 */
|
||||
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>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
v-access:code="['infra:job:create']"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', ['任务']) }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="ml-2"
|
||||
@click="onExport"
|
||||
v-access:code="['infra:job:export']"
|
||||
>
|
||||
<Download class="size-5" />
|
||||
{{ $t('ui.actionTitle.export') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="ml-2"
|
||||
@click="onLog(undefined)"
|
||||
v-access:code="['infra:job:query']"
|
||||
>
|
||||
<History class="size-5" />
|
||||
执行日志
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -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']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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 { ElButton } from 'element-plus';
|
||||
|
||||
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>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="ml-2"
|
||||
@click="onExport"
|
||||
v-access:code="['infra:job:export']"
|
||||
>
|
||||
<Download class="size-5" />
|
||||
{{ $t('ui.actionTitle.export') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -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 { ElDescriptions, ElDescriptionsItem } from 'element-plus';
|
||||
|
||||
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"
|
||||
>
|
||||
<ElDescriptions
|
||||
:column="1"
|
||||
border
|
||||
size="default"
|
||||
class="mx-4"
|
||||
:label-width="140"
|
||||
>
|
||||
<ElDescriptionsItem label="日志编号">
|
||||
{{ formData?.id }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="任务编号">
|
||||
{{ formData?.jobId }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="处理器的名字">
|
||||
{{ formData?.handlerName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="处理器的参数">
|
||||
{{ formData?.handlerParam }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="第几次执行">
|
||||
{{ formData?.executeIndex }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="执行时间">
|
||||
{{ formData?.beginTime ? formatDateTime(formData.beginTime) : '' }} ~
|
||||
{{ formData?.endTime ? formatDateTime(formData.endTime) : '' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="执行时长">
|
||||
{{ formData?.duration ? `${formData.duration} 毫秒` : '' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="任务状态">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.INFRA_JOB_LOG_STATUS"
|
||||
:value="formData?.status"
|
||||
/>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="执行结果">
|
||||
{{ formData?.result }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</Modal>
|
||||
</template>
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts" setup>
|
||||
import type { InfraJobApi } from '#/api/infra/job';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import {
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElTimeline,
|
||||
ElTimelineItem,
|
||||
} from 'element-plus';
|
||||
|
||||
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"
|
||||
>
|
||||
<ElDescriptions
|
||||
:column="1"
|
||||
border
|
||||
size="default"
|
||||
class="mx-4"
|
||||
:label-width="140"
|
||||
>
|
||||
<ElDescriptionsItem label="任务编号">
|
||||
{{ formData?.id }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="任务名称">
|
||||
{{ formData?.name }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="任务状态">
|
||||
<DictTag :type="DICT_TYPE.INFRA_JOB_STATUS" :value="formData?.status" />
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="处理器的名字">
|
||||
{{ formData?.handlerName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="处理器的参数">
|
||||
{{ formData?.handlerParam }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="Cron 表达式">
|
||||
{{ formData?.cronExpression }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="重试次数">
|
||||
{{ formData?.retryCount }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="重试间隔">
|
||||
{{
|
||||
formData?.retryInterval ? `${formData.retryInterval} 毫秒` : '无间隔'
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="监控超时时间">
|
||||
{{
|
||||
formData?.monitorTimeout && formData.monitorTimeout > 0
|
||||
? `${formData.monitorTimeout} 毫秒`
|
||||
: '未开启'
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="后续执行时间">
|
||||
<ElTimeline class="h-[180px]">
|
||||
<ElTimelineItem
|
||||
v-for="(nextTime, index) in nextTimes"
|
||||
:key="index"
|
||||
type="primary"
|
||||
>
|
||||
第 {{ index + 1 }} 次:{{ formatDateTime(nextTime.toString()) }}
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</Modal>
|
||||
</template>
|
|
@ -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 { ElMessage } from 'element-plus';
|
||||
|
||||
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');
|
||||
ElMessage.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>
|
|
@ -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 { ElCard } from 'element-plus';
|
||||
|
||||
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>
|
||||
|
||||
<ElCard header="Redis 概览">
|
||||
<Info :redis-data="redisData" />
|
||||
</ElCard>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<ElCard header="内存使用">
|
||||
<Memory :redis-data="redisData" />
|
||||
</ElCard>
|
||||
|
||||
<ElCard header="命令统计">
|
||||
<Commands :redis-data="redisData" />
|
||||
</ElCard>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
|
@ -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>
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts" setup>
|
||||
import type { InfraRedisApi } from '#/api/infra/redis';
|
||||
|
||||
import { ElDescriptions, ElDescriptionsItem } from 'element-plus';
|
||||
|
||||
defineProps<{
|
||||
redisData?: InfraRedisApi.RedisMonitorInfo;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDescriptions :column="6" border size="default" :label-width="138">
|
||||
<ElDescriptionsItem label="Redis 版本">
|
||||
{{ redisData?.info?.redis_version }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="运行模式">
|
||||
{{ redisData?.info?.redis_mode === 'standalone' ? '单机' : '集群' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="端口">
|
||||
{{ redisData?.info?.tcp_port }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="客户端数">
|
||||
{{ redisData?.info?.connected_clients }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="运行时间(天)">
|
||||
{{ redisData?.info?.uptime_in_days }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="使用内存">
|
||||
{{ redisData?.info?.used_memory_human }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="使用 CPU">
|
||||
{{
|
||||
redisData?.info
|
||||
? parseFloat(redisData?.info?.used_cpu_user_children).toFixed(2)
|
||||
: ''
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="内存配置">
|
||||
{{ redisData?.info?.maxmemory_human }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="AOF 是否开启">
|
||||
{{ redisData?.info?.aof_enabled === '0' ? '否' : '是' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="RDB 是否成功">
|
||||
{{ redisData?.info?.rdb_last_bgsave_status }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="Key 数量">
|
||||
{{ redisData?.dbSize }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="网络入口/出口">
|
||||
{{ redisData?.info?.instantaneous_input_kbps }}kps /
|
||||
{{ redisData?.info?.instantaneous_output_kbps }}kps
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</template>
|
|
@ -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>
|
|
@ -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 问题:
|
||||
// 1)boot 参考 https://doc.iocoder.cn/server-monitor/ 解决;
|
||||
// 2)cloud 参考 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,322 @@
|
|||
<script lang="ts" setup>
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { computed, onMounted, ref, watchEffect } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
import { formatDate } from '@vben/utils';
|
||||
|
||||
import { useWebSocket } from '@vueuse/core';
|
||||
import {
|
||||
ElAvatar,
|
||||
ElBadge,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDivider,
|
||||
ElEmpty,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
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' : 'danger')); // 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) {
|
||||
ElMessage.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;
|
||||
}
|
||||
ElMessage.error(`未处理消息:${data.value}`);
|
||||
} catch (error) {
|
||||
ElMessage.error(`处理消息发生异常:${data.value}`);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** 发送消息 */
|
||||
const sendText = ref(''); // 发送内容
|
||||
const sendUserId = ref(''); // 发送人
|
||||
const handlerSend = () => {
|
||||
if (!sendText.value.trim()) {
|
||||
ElMessage.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 getMessageBadgeType = (type?: string) => {
|
||||
switch (type) {
|
||||
case 'group': {
|
||||
return 'success';
|
||||
}
|
||||
case 'single': {
|
||||
return 'primary';
|
||||
}
|
||||
case 'system': {
|
||||
return 'danger';
|
||||
}
|
||||
default: {
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取消息类型的文本 */
|
||||
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">
|
||||
<!-- 左侧:建立连接、发送消息 -->
|
||||
<ElCard shadow="never" class="w-full md:w-1/2">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<div v-if="getIsOpen">
|
||||
<ElBadge type="success" is-dot />
|
||||
</div>
|
||||
<div v-else>
|
||||
<ElBadge type="danger" is-dot />
|
||||
</div>
|
||||
<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>
|
||||
<ElTag :type="getTagColor">{{ getStatusText }}</ElTag>
|
||||
</div>
|
||||
<div class="mb-6 flex space-x-2">
|
||||
<ElInput v-model="server" disabled class="rounded-md" size="default">
|
||||
<template #prepend>
|
||||
<span class="text-gray-600">服务地址</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElButton
|
||||
:type="getIsOpen ? 'danger' : 'primary'"
|
||||
size="default"
|
||||
class="flex-shrink-0"
|
||||
@click="toggleConnectStatus"
|
||||
>
|
||||
{{ getIsOpen ? '关闭连接' : '开启连接' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElDivider>
|
||||
<span class="text-gray-500">消息发送</span>
|
||||
</ElDivider>
|
||||
|
||||
<ElSelect
|
||||
v-model="sendUserId"
|
||||
class="mb-3 w-full"
|
||||
size="default"
|
||||
placeholder="请选择接收人"
|
||||
:disabled="!getIsOpen"
|
||||
>
|
||||
<!-- TODO @puhui999:所有人,选择不上 -->
|
||||
<ElOption key="" value="" label="所有人">
|
||||
<div class="flex items-center">
|
||||
<ElAvatar size="small">全</ElAvatar>
|
||||
<span class="ml-2">所有人</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
<ElOption
|
||||
v-for="user in userList"
|
||||
:key="user.id"
|
||||
:value="user.id || 0"
|
||||
:label="user.nickname"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<ElAvatar size="small">
|
||||
{{ user.nickname.slice(0, 1) }}
|
||||
</ElAvatar>
|
||||
<span class="ml-2">{{ user.nickname }}</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
|
||||
<ElInput
|
||||
v-model="sendText"
|
||||
:rows="3"
|
||||
type="textarea"
|
||||
:disabled="!getIsOpen"
|
||||
class="border-1 rounded-lg"
|
||||
clearable
|
||||
placeholder="请输入你要发送的消息..."
|
||||
/>
|
||||
|
||||
<ElButton
|
||||
:disabled="!getIsOpen"
|
||||
class="mt-4"
|
||||
type="primary"
|
||||
size="default"
|
||||
style="width: 100%"
|
||||
@click="handlerSend"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="i-ant-design:send-outlined mr-1"></span>
|
||||
</template>
|
||||
发送消息
|
||||
</ElButton>
|
||||
</ElCard>
|
||||
|
||||
<!-- 右侧:消息记录 -->
|
||||
<ElCard shadow="never" class="w-full md:w-1/2">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<span class="i-ant-design:message-outlined mr-2 text-lg"></span>
|
||||
<span class="text-lg font-medium">消息记录</span>
|
||||
<ElTag v-if="messageList.length > 0" class="ml-2">
|
||||
{{ messageList.length }} 条
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="h-96 overflow-auto rounded-lg p-2">
|
||||
<ElEmpty 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">
|
||||
<div>
|
||||
<ElBadge :type="getMessageBadgeType(msg.type)" dot />
|
||||
</div>
|
||||
<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>
|
||||
</ElCard>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
|
@ -3,6 +3,7 @@ import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
|||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { $t } from '@vben/locales';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
|
@ -132,31 +133,50 @@ export function useResetPasswordFormSchema(): VbenFormSchema[] {
|
|||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
component: 'InputPassword',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
return z
|
||||
.string({ message: '请输入新密码' })
|
||||
.min(5, '密码长度不能少于 5 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符')
|
||||
.refine(
|
||||
(value) => value !== values.oldPassword,
|
||||
'新旧密码不能相同',
|
||||
);
|
||||
},
|
||||
triggerFields: ['newPassword', 'oldPassword'],
|
||||
},
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
component: 'InputPassword',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请再次输入新密码',
|
||||
passwordStrength: true,
|
||||
placeholder: $t('authentication.confirmPassword'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values: Record<string, any>) {
|
||||
const { newPassword } = values;
|
||||
rules(values) {
|
||||
return z
|
||||
.string()
|
||||
.nonempty('确认密码不能为空')
|
||||
.refine((value) => value === newPassword, '两次输入的密码不一致');
|
||||
.string({ message: '请输入确认密码' })
|
||||
.min(5, '密码长度不能少于 5 个字符')
|
||||
.max(20, '密码长度不能超过 20 个字符')
|
||||
.refine(
|
||||
(value) => value === values.newPassword,
|
||||
'新密码和确认密码不一致',
|
||||
);
|
||||
},
|
||||
triggerFields: ['newPassword'],
|
||||
triggerFields: ['newPassword', 'confirmPassword'],
|
||||
},
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -57,7 +57,6 @@ const [Modal, modalApi] = useVbenModal({
|
|||
await formApi.setValues(data);
|
||||
},
|
||||
});
|
||||
// TODO @puhui999:重置密码展示不对;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
1820
pnpm-lock.yaml
1820
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue