feat: 完善 ele vxe-table 适配,完善文件和图片上传组件、字典组件、文档组件迁移,完善租户和租户套餐管理页面
parent
0155198f4e
commit
ca6d36b6e2
|
@ -21,6 +21,8 @@ import { $t } from '@vben/locales';
|
|||
|
||||
import { ElNotification } from 'element-plus';
|
||||
|
||||
import { FileUpload, ImageUpload } from '#/components/upload';
|
||||
|
||||
const ElButton = defineAsyncComponent(() =>
|
||||
Promise.all([
|
||||
import('element-plus/es/components/button/index'),
|
||||
|
@ -167,7 +169,9 @@ export type ComponentType =
|
|||
| 'CheckboxGroup'
|
||||
| 'DatePicker'
|
||||
| 'Divider'
|
||||
| 'FileUpload'
|
||||
| 'IconPicker'
|
||||
| 'ImageUpload'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
| 'RadioGroup'
|
||||
|
@ -315,6 +319,8 @@ async function initComponentAdapter() {
|
|||
},
|
||||
TreeSelect: withDefaultPlaceholder(ElTreeSelect, 'select'),
|
||||
Upload: ElUpload,
|
||||
FileUpload,
|
||||
ImageUpload,
|
||||
};
|
||||
|
||||
// 将组件注册到全局共享状态中
|
||||
|
|
|
@ -1,8 +1,20 @@
|
|||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $te } from '@vben/locales';
|
||||
import {
|
||||
AsyncComponents,
|
||||
setupVbenVxeTable,
|
||||
useVbenVxeGrid,
|
||||
} from '@vben/plugins/vxe-table';
|
||||
import { isFunction, isString } from '@vben/utils';
|
||||
|
||||
import { ElButton, ElImage } from 'element-plus';
|
||||
import { ElButton, ElImage, ElPopconfirm, ElSwitch } from 'element-plus';
|
||||
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useVbenForm } from './form';
|
||||
|
||||
|
@ -20,6 +32,17 @@ setupVbenVxeTable({
|
|||
// 全局禁用vxe-table的表单配置,使用formOptions
|
||||
enabled: false,
|
||||
},
|
||||
toolbarConfig: {
|
||||
import: false, // 是否导入
|
||||
export: false, // 是否导出
|
||||
refresh: true, // 是否刷新
|
||||
print: false, // 是否打印
|
||||
zoom: true, // 是否缩放
|
||||
custom: true, // 是否自定义配置
|
||||
},
|
||||
customConfig: {
|
||||
mode: 'modal',
|
||||
},
|
||||
proxyConfig: {
|
||||
autoLoad: true,
|
||||
response: {
|
||||
|
@ -30,6 +53,12 @@ setupVbenVxeTable({
|
|||
showActiveMsg: true,
|
||||
showResponseMsg: false,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
sortConfig: {
|
||||
multiple: true,
|
||||
},
|
||||
round: true,
|
||||
showOverflow: true,
|
||||
size: 'small',
|
||||
|
@ -57,12 +86,209 @@ setupVbenVxeTable({
|
|||
},
|
||||
});
|
||||
|
||||
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
|
||||
// vxeUI.formats.add
|
||||
// 表格配置项可以用 cellRender: { name: 'CellDict', props:{dictType: ''} },
|
||||
vxeUI.renderer.add('CellDict', {
|
||||
renderTableDefault(renderOpts, params) {
|
||||
const { props } = renderOpts;
|
||||
const { column, row } = params;
|
||||
if (!props) {
|
||||
return '';
|
||||
}
|
||||
// 使用 DictTag 组件替代原来的实现
|
||||
return h(DictTag, {
|
||||
type: props.type,
|
||||
value: row[column.field]?.toString(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 表格配置项可以用 cellRender: { name: 'CellSwitch', props: { beforeChange: () => {} } },
|
||||
vxeUI.renderer.add('CellSwitch', {
|
||||
renderTableDefault({ attrs, props }, { column, row }) {
|
||||
const loadingKey = `__loading_${column.field}`;
|
||||
const finallyProps = {
|
||||
activeText: $t('common.enabled'),
|
||||
inactiveText: $t('common.disabled'),
|
||||
activeValue: 1,
|
||||
inactiveValue: 0,
|
||||
...props,
|
||||
modelValue: row[column.field],
|
||||
loading: row[loadingKey] ?? false,
|
||||
'onUpdate:modelValue': onChange,
|
||||
};
|
||||
|
||||
async function onChange(newVal: any) {
|
||||
row[loadingKey] = true;
|
||||
try {
|
||||
const result = await attrs?.beforeChange?.(newVal, row);
|
||||
if (result !== false) {
|
||||
row[column.field] = newVal;
|
||||
}
|
||||
} finally {
|
||||
row[loadingKey] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return h(ElSwitch, finallyProps);
|
||||
},
|
||||
});
|
||||
|
||||
// 注册表格的操作按钮渲染器 cellRender: { name: 'CellOperation', options: ['edit', 'delete'] }
|
||||
vxeUI.renderer.add('CellOperation', {
|
||||
renderTableDefault({ attrs, options, props }, { column, row }) {
|
||||
const defaultProps = { size: 'small', type: 'primary', ...props };
|
||||
let align = 'end';
|
||||
switch (column.align) {
|
||||
case 'center': {
|
||||
align = 'center';
|
||||
break;
|
||||
}
|
||||
case 'left': {
|
||||
align = 'start';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
align = 'end';
|
||||
break;
|
||||
}
|
||||
}
|
||||
const presets: Recordable<Recordable<any>> = {
|
||||
delete: {
|
||||
type: 'danger',
|
||||
text: $t('common.delete'),
|
||||
},
|
||||
edit: {
|
||||
text: $t('common.edit'),
|
||||
},
|
||||
};
|
||||
const operations: Array<Recordable<any>> = (
|
||||
options || ['edit', 'delete']
|
||||
)
|
||||
.map((opt) => {
|
||||
if (isString(opt)) {
|
||||
return presets[opt]
|
||||
? { code: opt, ...presets[opt], ...defaultProps }
|
||||
: {
|
||||
code: opt,
|
||||
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
|
||||
...defaultProps,
|
||||
};
|
||||
} else {
|
||||
return { ...defaultProps, ...presets[opt.code], ...opt };
|
||||
}
|
||||
})
|
||||
.map((opt) => {
|
||||
const optBtn: Recordable<any> = {};
|
||||
Object.keys(opt).forEach((key) => {
|
||||
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
|
||||
});
|
||||
return optBtn;
|
||||
})
|
||||
.filter((opt) => opt.show !== false);
|
||||
|
||||
function renderBtn(opt: Recordable<any>, listen = true) {
|
||||
return h(
|
||||
ElButton,
|
||||
{
|
||||
...props,
|
||||
...opt,
|
||||
icon: undefined,
|
||||
onClick: listen
|
||||
? () =>
|
||||
attrs?.onClick?.({
|
||||
code: opt.code,
|
||||
row,
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
const content = [];
|
||||
if (opt.icon) {
|
||||
content.push(
|
||||
h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
|
||||
);
|
||||
}
|
||||
content.push(opt.text);
|
||||
return content;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function renderConfirm(opt: Recordable<any>) {
|
||||
return h(
|
||||
ElPopconfirm,
|
||||
{
|
||||
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
|
||||
width: 'auto',
|
||||
'popper-class': 'popper-top-left',
|
||||
onConfirm: () => {
|
||||
attrs?.onClick?.({
|
||||
code: opt.code,
|
||||
row,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
reference: () => renderBtn({ ...opt }, false),
|
||||
default: () =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'truncate' },
|
||||
$t('ui.actionMessage.deleteConfirm', [
|
||||
row[attrs?.nameField || 'name'],
|
||||
]),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const btns = operations.map((opt) =>
|
||||
opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
|
||||
);
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex table-operations',
|
||||
style: { justifyContent: align },
|
||||
},
|
||||
btns,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 添加数量格式化,例如金额
|
||||
vxeUI.formats.add('formatAmount', {
|
||||
cellFormatMethod({ cellValue }, digits = 2) {
|
||||
if (cellValue === null || cellValue === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (isString(cellValue)) {
|
||||
cellValue = Number.parseFloat(cellValue);
|
||||
}
|
||||
// 如果非 number,则直接返回空串
|
||||
if (Number.isNaN(cellValue)) {
|
||||
return '';
|
||||
}
|
||||
return cellValue.toFixed(digits);
|
||||
},
|
||||
});
|
||||
},
|
||||
useVbenForm,
|
||||
});
|
||||
|
||||
export { useVbenVxeGrid };
|
||||
|
||||
const [VxeTable, VxeColumn, VxeToolbar] = AsyncComponents;
|
||||
export { VxeColumn, VxeTable, VxeToolbar };
|
||||
|
||||
// 导出操作按钮的回调函数类型
|
||||
export type OnActionClickParams<T = Recordable<any>> = {
|
||||
code: string;
|
||||
row: T;
|
||||
};
|
||||
export type OnActionClickFn<T = Recordable<any>> = (
|
||||
params: OnActionClickParams<T>,
|
||||
) => void;
|
||||
export type * from '@vben/plugins/vxe-table';
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
// import { isHexColor } from '@/utils/color' // TODO @芋艿:【可优化】增加 cssClass 的处理 https://gitee.com/yudaocode/yudao-ui-admin-vben/blob/v2.4.1/src/components/DictTag/src/DictTag.vue#L60
|
||||
import { getDictObj } from '#/utils';
|
||||
|
||||
interface DictTagProps {
|
||||
/**
|
||||
* 字典类型
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* 字典值
|
||||
*/
|
||||
value: any;
|
||||
/**
|
||||
* 图标
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<DictTagProps>();
|
||||
|
||||
/** 获取字典标签 */
|
||||
const dictTag = computed(() => {
|
||||
const defaultDict = {
|
||||
label: '',
|
||||
colorType: 'primary',
|
||||
};
|
||||
// 校验参数有效性
|
||||
if (!props.type || props.value === undefined || props.value === null) {
|
||||
return defaultDict;
|
||||
}
|
||||
|
||||
// 获取字典对象
|
||||
const dict = getDictObj(props.type, String(props.value));
|
||||
if (!dict) {
|
||||
return defaultDict;
|
||||
}
|
||||
|
||||
// 处理颜色类型
|
||||
let colorType = dict.colorType;
|
||||
switch (colorType) {
|
||||
case 'danger': {
|
||||
colorType = 'danger';
|
||||
break;
|
||||
}
|
||||
case 'info': {
|
||||
colorType = 'info';
|
||||
break;
|
||||
}
|
||||
case 'primary': {
|
||||
colorType = 'primary';
|
||||
break;
|
||||
}
|
||||
case 'success': {
|
||||
colorType = 'success';
|
||||
break;
|
||||
}
|
||||
case 'warning': {
|
||||
colorType = 'warning';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (!colorType) {
|
||||
colorType = 'primary';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: dict.label || '',
|
||||
colorType,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTag v-if="dictTag" :type="dictTag.colorType">
|
||||
{{ dictTag.label }}
|
||||
</ElTag>
|
||||
</template>
|
|
@ -0,0 +1 @@
|
|||
export { default as DictTag } from './dict-tag.vue';
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts" setup>
|
||||
import { isDocAlertEnable } from '@vben/hooks';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ElAlert, ElLink } from 'element-plus';
|
||||
|
||||
export interface DocAlertProps {
|
||||
/**
|
||||
* 文档标题
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* 文档 URL 地址
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
const props = defineProps<DocAlertProps>();
|
||||
|
||||
/** 跳转 URL 链接 */
|
||||
const goToUrl = () => {
|
||||
openWindow(props.url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElAlert
|
||||
v-if="isDocAlertEnable()"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-2 rounded"
|
||||
>
|
||||
<ElLink type="primary" @click="goToUrl">
|
||||
【{{ title }}】文档地址:{{ url }}
|
||||
</ElLink>
|
||||
</ElAlert>
|
||||
</template>
|
|
@ -0,0 +1 @@
|
|||
export { default as DocAlert } from './doc-alert.vue';
|
|
@ -0,0 +1,256 @@
|
|||
<script lang="ts" setup>
|
||||
import type {
|
||||
UploadFile,
|
||||
UploadProgressEvent,
|
||||
UploadRequestOptions,
|
||||
} from 'element-plus';
|
||||
|
||||
import type { AxiosResponse } from '@vben/request';
|
||||
|
||||
import type { CustomUploadFile } from './typing';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { CloudUpload } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isFunction, isObject, isString } from '@vben/utils';
|
||||
|
||||
import { ElButton, ElMessage, ElUpload } from 'element-plus';
|
||||
|
||||
import { checkFileType } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// 根据后缀,或者其他
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
// 上传的目录
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
resultField?: string;
|
||||
// 是否显示下面的描述
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
directory: undefined,
|
||||
disabled: false,
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => [],
|
||||
multiple: false,
|
||||
api: undefined,
|
||||
resultField: '',
|
||||
showDescription: false,
|
||||
},
|
||||
);
|
||||
const emit = defineEmits(['change', 'update:value', 'delete']);
|
||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
});
|
||||
|
||||
const fileList = ref<CustomUploadFile[]>([]);
|
||||
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
|
||||
const isActMsg = ref<boolean>(true); // 文件类型错误提示
|
||||
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(v) => {
|
||||
if (isInnerOperate.value) {
|
||||
isInnerOperate.value = false;
|
||||
return;
|
||||
}
|
||||
let value: string[] = [];
|
||||
if (v) {
|
||||
if (Array.isArray(v)) {
|
||||
value = v;
|
||||
} else {
|
||||
value.push(v);
|
||||
}
|
||||
fileList.value = value
|
||||
.map((item, i) => {
|
||||
if (item && isString(item)) {
|
||||
return {
|
||||
uid: -i,
|
||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
||||
status: UploadResultStatus.DONE,
|
||||
url: item,
|
||||
} as CustomUploadFile;
|
||||
} else if (item && isObject(item)) {
|
||||
const file = item as Record<string, any>;
|
||||
return {
|
||||
uid: file.uid || -i,
|
||||
name: file.name || '',
|
||||
status: UploadResultStatus.DONE,
|
||||
url: file.url,
|
||||
response: file.response,
|
||||
percentage: file.percentage,
|
||||
size: file.size,
|
||||
} as CustomUploadFile;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as CustomUploadFile[];
|
||||
}
|
||||
if (!isFirstRender.value) {
|
||||
emit('change', value);
|
||||
isFirstRender.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
const handleRemove = async (file: UploadFile) => {
|
||||
if (fileList.value) {
|
||||
const index = fileList.value.findIndex((item) => item.uid === file.uid);
|
||||
index !== -1 && fileList.value.splice(index, 1);
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('change', value);
|
||||
emit('delete', file);
|
||||
}
|
||||
};
|
||||
|
||||
const beforeUpload = async (file: File) => {
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = checkFileType(file, accept);
|
||||
if (!isAct) {
|
||||
ElMessage.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
ElMessage.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
async function customRequest(options: UploadRequestOptions) {
|
||||
let { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
api = useUpload(props.directory).httpRequest;
|
||||
}
|
||||
try {
|
||||
// 上传文件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
const progressEvent: UploadProgressEvent = {
|
||||
percent,
|
||||
total: e.total || 0,
|
||||
loaded: e.loaded || 0,
|
||||
lengthComputable: true,
|
||||
target: e.target as EventTarget,
|
||||
bubbles: false,
|
||||
cancelBubble: false,
|
||||
cancelable: false,
|
||||
composed: false,
|
||||
currentTarget: e.target as EventTarget,
|
||||
defaultPrevented: false,
|
||||
eventPhase: 0,
|
||||
isTrusted: true,
|
||||
returnValue: true,
|
||||
srcElement: e.target as EventTarget,
|
||||
timeStamp: Date.now(),
|
||||
type: 'progress',
|
||||
composedPath: () => [],
|
||||
initEvent: () => {},
|
||||
preventDefault: () => {},
|
||||
stopImmediatePropagation: () => {},
|
||||
stopPropagation: () => {},
|
||||
};
|
||||
options.onProgress!(progressEvent);
|
||||
};
|
||||
const res = await api?.(options.file, progressEvent);
|
||||
options.onSuccess!(res);
|
||||
ElMessage.success($t('ui.upload.uploadSuccess'));
|
||||
|
||||
// 更新文件
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('change', value);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
options.onError!(error);
|
||||
}
|
||||
}
|
||||
|
||||
function getValue() {
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === UploadResultStatus.DONE)
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response;
|
||||
}
|
||||
return item?.url || item?.response?.url || item?.response;
|
||||
});
|
||||
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
|
||||
if (props.maxNumber === 1) {
|
||||
return list.length > 0 ? list[0] : '';
|
||||
}
|
||||
return list;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElUpload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:limit="maxNumber"
|
||||
:multiple="multiple"
|
||||
list-type="text"
|
||||
:on-remove="handleRemove"
|
||||
>
|
||||
<div v-if="fileList && fileList.length < maxNumber">
|
||||
<ElButton>
|
||||
<CloudUpload />
|
||||
{{ $t('ui.upload.upload') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div v-if="showDescription" class="mt-2 text-xs text-gray-500">
|
||||
{{ getStringAccept }}
|
||||
</div>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,20 @@
|
|||
export function checkFileType(file: File, accepts: string[]) {
|
||||
if (!accepts || accepts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const newTypes = accepts.join('|');
|
||||
const reg = new RegExp(`${String.raw`\.(` + newTypes})$`, 'i');
|
||||
return reg.test(file.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认图片类型
|
||||
*/
|
||||
export const defaultImageAccepts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
export function checkImgType(
|
||||
file: File,
|
||||
accepts: string[] = defaultImageAccepts,
|
||||
) {
|
||||
return checkFileType(file, accepts);
|
||||
}
|
|
@ -0,0 +1,265 @@
|
|||
<script lang="ts" setup>
|
||||
import type {
|
||||
UploadFile,
|
||||
UploadProgressEvent,
|
||||
UploadRequestOptions,
|
||||
} from 'element-plus';
|
||||
|
||||
import type { AxiosResponse } from '@vben/request';
|
||||
|
||||
import type { UploadListType } from './typing';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { CloudUpload } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isFunction, isObject, isString } from '@vben/utils';
|
||||
|
||||
import { ElMessage, ElUpload } from 'element-plus';
|
||||
|
||||
import { checkImgType, defaultImageAccepts } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// 根据后缀,或者其他
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
// 上传的目录
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
listType?: UploadListType;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
resultField?: string;
|
||||
// 是否显示下面的描述
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
directory: undefined,
|
||||
disabled: false,
|
||||
listType: 'picture-card',
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => defaultImageAccepts,
|
||||
multiple: false,
|
||||
api: undefined,
|
||||
resultField: '',
|
||||
showDescription: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits(['change', 'update:value', 'delete']);
|
||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
});
|
||||
|
||||
const fileList = ref<UploadFile[]>([]);
|
||||
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
|
||||
const isActMsg = ref<boolean>(true); // 文件类型错误提示
|
||||
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
async (v) => {
|
||||
if (isInnerOperate.value) {
|
||||
isInnerOperate.value = false;
|
||||
return;
|
||||
}
|
||||
let value: string | string[] = [];
|
||||
if (v) {
|
||||
if (Array.isArray(v)) {
|
||||
value = v;
|
||||
} else {
|
||||
value.push(v);
|
||||
}
|
||||
fileList.value = value
|
||||
.map((item, i) => {
|
||||
if (item && isString(item)) {
|
||||
return {
|
||||
uid: -i,
|
||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
||||
status: UploadResultStatus.DONE,
|
||||
url: item,
|
||||
} as UploadFile;
|
||||
} else if (item && isObject(item)) {
|
||||
const file = item as Record<string, any>;
|
||||
return {
|
||||
uid: file.uid || -i,
|
||||
name: file.name || '',
|
||||
status: UploadResultStatus.DONE,
|
||||
url: file.url,
|
||||
} as UploadFile;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as UploadFile[];
|
||||
}
|
||||
if (!isFirstRender.value) {
|
||||
emit('change', value);
|
||||
isFirstRender.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.addEventListener('load', () => {
|
||||
resolve(reader.result as T);
|
||||
});
|
||||
reader.addEventListener('error', (error) => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
if (!file.url) {
|
||||
const preview = await getBase64<string>(file.raw!);
|
||||
window.open(preview || '');
|
||||
return;
|
||||
}
|
||||
window.open(file.url);
|
||||
};
|
||||
|
||||
const handleRemove = async (file: UploadFile) => {
|
||||
if (fileList.value) {
|
||||
const index = fileList.value.findIndex((item) => item.uid === file.uid);
|
||||
index !== -1 && fileList.value.splice(index, 1);
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('change', value);
|
||||
emit('delete', file);
|
||||
}
|
||||
};
|
||||
|
||||
const beforeUpload = async (file: File) => {
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = checkImgType(file, accept);
|
||||
if (!isAct) {
|
||||
ElMessage.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
ElMessage.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
async function customRequest(options: UploadRequestOptions) {
|
||||
let { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
api = useUpload(props.directory).httpRequest;
|
||||
}
|
||||
try {
|
||||
// 上传文件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
options.onProgress!({
|
||||
percent,
|
||||
total: e.total || 0,
|
||||
loaded: e.loaded || 0,
|
||||
lengthComputable: true,
|
||||
} as unknown as UploadProgressEvent);
|
||||
};
|
||||
const res = await api?.(options.file, progressEvent);
|
||||
options.onSuccess!(res);
|
||||
ElMessage.success($t('ui.upload.uploadSuccess'));
|
||||
|
||||
// 更新文件
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('change', value);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
options.onError!(error);
|
||||
}
|
||||
}
|
||||
|
||||
function getValue() {
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === UploadResultStatus.DONE)
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response;
|
||||
}
|
||||
return item?.url || item?.response?.url || item?.response;
|
||||
});
|
||||
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
|
||||
if (props.maxNumber === 1) {
|
||||
return list.length > 0 ? list[0] : '';
|
||||
}
|
||||
return list;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElUpload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:list-type="listType"
|
||||
:limit="maxNumber"
|
||||
:multiple="multiple"
|
||||
:on-preview="handlePreview"
|
||||
:on-remove="handleRemove"
|
||||
>
|
||||
<div
|
||||
v-if="fileList && fileList.length < maxNumber"
|
||||
class="flex flex-col items-center justify-center"
|
||||
>
|
||||
<CloudUpload />
|
||||
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div>
|
||||
</div>
|
||||
</ElUpload>
|
||||
<div v-if="showDescription" class="mt-2 text-xs text-gray-500">
|
||||
{{ getStringAccept }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.ant-upload-select-picture-card {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,2 @@
|
|||
export { default as FileUpload } from './file-upload.vue';
|
||||
export { default as ImageUpload } from './image-upload.vue';
|
|
@ -0,0 +1,46 @@
|
|||
import type { UploadStatus } from 'element-plus';
|
||||
|
||||
export type UploadListType = 'picture' | 'picture-card' | 'text';
|
||||
|
||||
export type UploadStatus = 'error' | 'removed' | 'success' | 'uploading';
|
||||
|
||||
export enum UploadResultStatus {
|
||||
DONE = 'success',
|
||||
ERROR = 'error',
|
||||
REMOVED = 'removed',
|
||||
SUCCESS = 'success',
|
||||
UPLOADING = 'uploading',
|
||||
}
|
||||
|
||||
export interface CustomUploadFile {
|
||||
uid: number;
|
||||
name: string;
|
||||
status: UploadStatus;
|
||||
url?: string;
|
||||
response?: any;
|
||||
percentage?: number;
|
||||
size?: number;
|
||||
raw?: File;
|
||||
}
|
||||
|
||||
export function convertToUploadStatus(
|
||||
status: UploadResultStatus,
|
||||
): UploadStatus {
|
||||
switch (status) {
|
||||
case UploadResultStatus.DONE: {
|
||||
return 'success';
|
||||
}
|
||||
case UploadResultStatus.ERROR: {
|
||||
return 'error';
|
||||
}
|
||||
case UploadResultStatus.REMOVED: {
|
||||
return 'removed';
|
||||
}
|
||||
case UploadResultStatus.UPLOADING: {
|
||||
return 'uploading';
|
||||
}
|
||||
default: {
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
import type { Ref } from 'vue';
|
||||
|
||||
import type { AxiosProgressEvent, InfraFileApi } from '#/api/infra/file';
|
||||
|
||||
import { computed, unref } from 'vue';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
// import CryptoJS from 'crypto-js';
|
||||
import { createFile, getFilePresignedUrl, uploadFile } from '#/api/infra/file';
|
||||
import { baseRequestClient } from '#/api/request';
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
/**
|
||||
* 上传类型
|
||||
*/
|
||||
enum UPLOAD_TYPE {
|
||||
// 客户端直接上传(只支持S3服务)
|
||||
CLIENT = 'client',
|
||||
// 客户端发送到后端上传
|
||||
SERVER = 'server',
|
||||
}
|
||||
|
||||
export function useUploadType({
|
||||
acceptRef,
|
||||
helpTextRef,
|
||||
maxNumberRef,
|
||||
maxSizeRef,
|
||||
}: {
|
||||
acceptRef: Ref<string[]>;
|
||||
helpTextRef: Ref<string>;
|
||||
maxNumberRef: Ref<number>;
|
||||
maxSizeRef: Ref<number>;
|
||||
}) {
|
||||
// 文件类型限制
|
||||
const getAccept = computed(() => {
|
||||
const accept = unref(acceptRef);
|
||||
if (accept && accept.length > 0) {
|
||||
return accept;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const getStringAccept = computed(() => {
|
||||
return unref(getAccept)
|
||||
.map((item) => {
|
||||
return item.indexOf('/') > 0 || item.startsWith('.')
|
||||
? item
|
||||
: `.${item}`;
|
||||
})
|
||||
.join(',');
|
||||
});
|
||||
|
||||
// 支持jpg、jpeg、png格式,不超过2M,最多可选择10张图片,。
|
||||
const getHelpText = computed(() => {
|
||||
const helpText = unref(helpTextRef);
|
||||
if (helpText) {
|
||||
return helpText;
|
||||
}
|
||||
const helpTexts: string[] = [];
|
||||
|
||||
const accept = unref(acceptRef);
|
||||
if (accept.length > 0) {
|
||||
helpTexts.push($t('ui.upload.accept', [accept.join(',')]));
|
||||
}
|
||||
|
||||
const maxSize = unref(maxSizeRef);
|
||||
if (maxSize) {
|
||||
helpTexts.push($t('ui.upload.maxSize', [maxSize]));
|
||||
}
|
||||
|
||||
const maxNumber = unref(maxNumberRef);
|
||||
if (maxNumber && maxNumber !== Infinity) {
|
||||
helpTexts.push($t('ui.upload.maxNumber', [maxNumber]));
|
||||
}
|
||||
return helpTexts.join(',');
|
||||
});
|
||||
return { getAccept, getStringAccept, getHelpText };
|
||||
}
|
||||
|
||||
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构
|
||||
export const useUpload = (directory?: string) => {
|
||||
// 后端上传地址
|
||||
const uploadUrl = getUploadUrl();
|
||||
// 是否使用前端直连上传
|
||||
const isClientUpload =
|
||||
UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
|
||||
// 重写ElUpload上传方法
|
||||
const httpRequest = async (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => {
|
||||
// 模式一:前端上传
|
||||
if (isClientUpload) {
|
||||
// 1.1 生成文件名称
|
||||
const fileName = await generateFileName(file);
|
||||
// 1.2 获取文件预签名地址
|
||||
const presignedInfo = await getFilePresignedUrl(fileName, directory);
|
||||
// 1.3 上传文件
|
||||
return baseRequestClient
|
||||
.put(presignedInfo.uploadUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
// 1.4. 记录文件信息到后端(异步)
|
||||
createFile0(presignedInfo, file);
|
||||
// 通知成功,数据格式保持与后端上传的返回结果一致
|
||||
return { url: presignedInfo.url };
|
||||
});
|
||||
} else {
|
||||
// 模式二:后端上传
|
||||
return uploadFile({ file, directory }, onUploadProgress);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
httpRequest,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获得上传 URL
|
||||
*/
|
||||
export const getUploadUrl = (): string => {
|
||||
return `${apiURL}/infra/file/upload`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建文件信息
|
||||
*
|
||||
* @param vo 文件预签名信息
|
||||
* @param file 文件
|
||||
*/
|
||||
function createFile0(vo: InfraFileApi.FilePresignedUrlRespVO, file: File) {
|
||||
const fileVO = {
|
||||
configId: vo.configId,
|
||||
url: vo.url,
|
||||
path: vo.path,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
};
|
||||
createFile(fileVO);
|
||||
return fileVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件名称
|
||||
*
|
||||
* @param file 要上传的文件
|
||||
*/
|
||||
async function generateFileName(file: File) {
|
||||
return file.name;
|
||||
}
|
|
@ -0,0 +1,255 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemTenantApi } from '#/api/system/tenant';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getTenantPackageList } from '#/api/system/tenant-package';
|
||||
import {
|
||||
CommonStatusEnum,
|
||||
DICT_TYPE,
|
||||
getDictOptions,
|
||||
getRangePickerDefaultProps,
|
||||
} from '#/utils';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '租户名称',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'packageId',
|
||||
label: '租户套餐',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: () => getTenantPackageList(),
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择租户套餐',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'contactName',
|
||||
label: '联系人',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'contactMobile',
|
||||
label: '联系手机',
|
||||
component: 'Input',
|
||||
rules: 'mobile',
|
||||
},
|
||||
{
|
||||
label: '用户名称',
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
show: (values) => !values.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '用户密码',
|
||||
fieldName: 'password',
|
||||
component: 'InputPassword',
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
show: (values) => !values.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '账号额度',
|
||||
fieldName: 'accountCount',
|
||||
component: 'InputNumber',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
label: '过期时间',
|
||||
fieldName: 'expireTime',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'x',
|
||||
placeholder: '请选择过期时间',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
label: '绑定域名',
|
||||
fieldName: 'website',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '租户状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '租户名',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'contactName',
|
||||
label: '联系人',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'contactMobile',
|
||||
label: '联系手机',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns<T = SystemTenantApi.Tenant>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
getPackageName?: (packageId: number) => string | undefined,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: '租户编号',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '租户名',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'packageId',
|
||||
title: '租户套餐',
|
||||
minWidth: 180,
|
||||
formatter: (row: { cellValue: number }) => {
|
||||
return getPackageName?.(row.cellValue) || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'contactName',
|
||||
title: '联系人',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'contactMobile',
|
||||
title: '联系手机',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'accountCount',
|
||||
title: '账号额度',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'expireTime',
|
||||
title: '过期时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'website',
|
||||
title: '绑定域名',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '租户状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: '操作',
|
||||
minWidth: 130,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
nameTitle: '租户',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'edit',
|
||||
show: hasAccessByCodes(['system:tenant:update']),
|
||||
},
|
||||
{
|
||||
code: 'delete',
|
||||
show: hasAccessByCodes(['system:tenant:delete']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -0,0 +1,160 @@
|
|||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SystemTenantApi } from '#/api/system/tenant';
|
||||
import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
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 { deleteTenant, exportTenant, getTenantPage } from '#/api/system/tenant';
|
||||
import { getTenantPackageList } from '#/api/system/tenant-package';
|
||||
import { DocAlert } from '#/components/doc-alert';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const tenantPackageList = ref<SystemTenantPackageApi.TenantPackage[]>([]);
|
||||
|
||||
/** 获取套餐名称 */
|
||||
const getPackageName = (packageId: number) => {
|
||||
if (packageId === 0) {
|
||||
return '系统租户';
|
||||
}
|
||||
return tenantPackageList.value.find((pkg) => pkg.id === packageId)?.name;
|
||||
};
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 导出表格 */
|
||||
async function onExport() {
|
||||
const data = await exportTenant(await gridApi.formApi.getValues());
|
||||
downloadFileFromBlobPart({ fileName: '租户.xls', source: data });
|
||||
}
|
||||
|
||||
/** 创建租户 */
|
||||
function onCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑租户 */
|
||||
function onEdit(row: SystemTenantApi.Tenant) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除租户 */
|
||||
async function onDelete(row: SystemTenantApi.Tenant) {
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
});
|
||||
try {
|
||||
await deleteTenant(row.id as number);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
onRefresh();
|
||||
} catch {
|
||||
// 错误处理
|
||||
} finally {
|
||||
loading.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格操作按钮的回调函数 */
|
||||
function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<SystemTenantApi.Tenant>) {
|
||||
switch (code) {
|
||||
case 'delete': {
|
||||
onDelete(row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(onActionClick, getPackageName),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getTenantPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemTenantApi.Tenant>,
|
||||
});
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
tenantPackageList.value = await getTenantPackageList();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert title="SaaS 多租户" url="https://doc.iocoder.cn/saas-tenant/" />
|
||||
</template>
|
||||
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="租户列表">
|
||||
<template #toolbar-tools>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
v-access:code="['system:tenant:create']"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', ['租户']) }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="ml-2"
|
||||
@click="onExport"
|
||||
v-access:code="['system:tenant:export']"
|
||||
>
|
||||
<Download class="size-5" />
|
||||
{{ $t('ui.actionTitle.export') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -0,0 +1,81 @@
|
|||
<script lang="ts" setup>
|
||||
import type { SystemTenantApi } from '#/api/system/tenant';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createTenant, getTenant, updateTenant } from '#/api/system/tenant';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<SystemTenantApi.Tenant>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value
|
||||
? $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 SystemTenantApi.Tenant;
|
||||
try {
|
||||
await (formData.value ? updateTenant(data) : createTenant(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<SystemTenantApi.Tenant>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getTenant(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,154 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import {
|
||||
CommonStatusEnum,
|
||||
DICT_TYPE,
|
||||
getDictOptions,
|
||||
getRangePickerDefaultProps,
|
||||
} from '#/utils';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '套餐名称',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'menuIds',
|
||||
label: '菜单权限',
|
||||
component: 'Input',
|
||||
formItemClass: 'items-start',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '套餐名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入套餐名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
allowClear: true,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns<T = SystemTenantPackageApi.TenantPackage>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: '套餐编号',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '套餐名称',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'remark',
|
||||
title: '备注',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: '操作',
|
||||
minWidth: 130,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
nameTitle: '套餐',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'edit',
|
||||
show: hasAccessByCodes(['system:tenant-package:update']),
|
||||
},
|
||||
{
|
||||
code: 'delete',
|
||||
show: hasAccessByCodes(['system:tenant-package:delete']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteTenantPackage,
|
||||
getTenantPackagePage,
|
||||
} from '#/api/system/tenant-package';
|
||||
import { DocAlert } from '#/components/doc-alert';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建租户套餐 */
|
||||
function onCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑租户套餐 */
|
||||
function onEdit(row: SystemTenantPackageApi.TenantPackage) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除租户套餐 */
|
||||
async function onDelete(row: SystemTenantPackageApi.TenantPackage) {
|
||||
const loadingInstance = ElMessage({
|
||||
message: $t('ui.actionMessage.deleting', [row.name]),
|
||||
type: 'info',
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteTenantPackage(row.id as number);
|
||||
loadingInstance.close();
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
onRefresh();
|
||||
} catch {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格操作按钮的回调函数 */
|
||||
function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<SystemTenantPackageApi.TenantPackage>) {
|
||||
switch (code) {
|
||||
case 'delete': {
|
||||
onDelete(row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
// TODO @芋艿:时间筛选,后续处理;
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getTenantPackagePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemTenantPackageApi.TenantPackage>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert title="SaaS 多租户" url="https://doc.iocoder.cn/saas-tenant/" />
|
||||
</template>
|
||||
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="租户套餐列表">
|
||||
<template #toolbar-tools>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="onCreate"
|
||||
v-access:code="['system:tenant-package:create']"
|
||||
>
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', ['套餐']) }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
|
@ -0,0 +1,162 @@
|
|||
<script lang="ts" setup>
|
||||
import type { SystemDeptApi } from '#/api/system/dept';
|
||||
import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal, VbenTree } from '@vben/common-ui';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { ElCheckbox, ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getMenuList } from '#/api/system/menu';
|
||||
import {
|
||||
createTenantPackage,
|
||||
getTenantPackage,
|
||||
updateTenantPackage,
|
||||
} from '#/api/system/tenant-package';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<SystemTenantPackageApi.TenantPackage>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value
|
||||
? $t('ui.actionTitle.edit', ['套餐'])
|
||||
: $t('ui.actionTitle.create', ['套餐']);
|
||||
});
|
||||
const menuTree = ref<SystemDeptApi.Dept[]>([]); // 菜单树
|
||||
const menuLoading = ref(false); // 加载菜单列表
|
||||
const isAllSelected = ref(false); // 全选状态
|
||||
const isExpanded = ref(false); // 展开状态
|
||||
const expandedKeys = ref<number[]>([]); // 展开的节点
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: 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 SystemTenantPackageApi.TenantPackage;
|
||||
try {
|
||||
await (formData.value
|
||||
? updateTenantPackage(data)
|
||||
: createTenantPackage(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;
|
||||
}
|
||||
// 加载菜单列表
|
||||
await loadMenuTree();
|
||||
// 加载数据
|
||||
const data = modalApi.getData<SystemTenantPackageApi.TenantPackage>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getTenantPackage(data.id as number);
|
||||
await formApi.setValues(data);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 加载菜单树 */
|
||||
async function loadMenuTree() {
|
||||
menuLoading.value = true;
|
||||
try {
|
||||
const data = await getMenuList();
|
||||
menuTree.value = handleTree(data) as SystemDeptApi.Dept[];
|
||||
} finally {
|
||||
menuLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 全选/全不选 */
|
||||
function toggleSelectAll() {
|
||||
isAllSelected.value = !isAllSelected.value;
|
||||
if (isAllSelected.value) {
|
||||
const allIds = getAllNodeIds(menuTree.value);
|
||||
formApi.setFieldValue('menuIds', allIds);
|
||||
} else {
|
||||
formApi.setFieldValue('menuIds', []);
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开/折叠所有节点 */
|
||||
function toggleExpandAll() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
expandedKeys.value = isExpanded.value ? getAllNodeIds(menuTree.value) : [];
|
||||
}
|
||||
|
||||
/** 递归获取所有节点 ID */
|
||||
function getAllNodeIds(nodes: any[], ids: number[] = []): number[] {
|
||||
nodes.forEach((node: any) => {
|
||||
ids.push(node.id);
|
||||
if (node.children && node.children.length > 0) {
|
||||
getAllNodeIds(node.children, ids);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-[40%]">
|
||||
<Form class="mx-6">
|
||||
<template #menuIds="slotProps">
|
||||
<!-- TODO @芋艿:可优化,使用 antd 的 tree?原因是,更原生 -->
|
||||
<VbenTree
|
||||
class="max-h-[400px] overflow-y-auto"
|
||||
:loading="menuLoading"
|
||||
:tree-data="menuTree"
|
||||
multiple
|
||||
bordered
|
||||
:expanded="expandedKeys"
|
||||
v-bind="slotProps"
|
||||
value-field="id"
|
||||
label-field="name"
|
||||
/>
|
||||
</template>
|
||||
</Form>
|
||||
<template #prepend-footer>
|
||||
<div class="flex flex-auto items-center">
|
||||
<ElCheckbox :model-value="isAllSelected" @change="toggleSelectAll">
|
||||
全选
|
||||
</ElCheckbox>
|
||||
<ElCheckbox :model-value="isExpanded" @change="toggleExpandAll">
|
||||
全部展开
|
||||
</ElCheckbox>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
Loading…
Reference in New Issue