feat: 完善 ele vxe-table 适配,完善文件和图片上传组件、字典组件、文档组件迁移,完善租户和租户套餐管理页面
parent
0155198f4e
commit
ca6d36b6e2
|
|
@ -21,6 +21,8 @@ import { $t } from '@vben/locales';
|
||||||
|
|
||||||
import { ElNotification } from 'element-plus';
|
import { ElNotification } from 'element-plus';
|
||||||
|
|
||||||
|
import { FileUpload, ImageUpload } from '#/components/upload';
|
||||||
|
|
||||||
const ElButton = defineAsyncComponent(() =>
|
const ElButton = defineAsyncComponent(() =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
import('element-plus/es/components/button/index'),
|
import('element-plus/es/components/button/index'),
|
||||||
|
|
@ -167,7 +169,9 @@ export type ComponentType =
|
||||||
| 'CheckboxGroup'
|
| 'CheckboxGroup'
|
||||||
| 'DatePicker'
|
| 'DatePicker'
|
||||||
| 'Divider'
|
| 'Divider'
|
||||||
|
| 'FileUpload'
|
||||||
| 'IconPicker'
|
| 'IconPicker'
|
||||||
|
| 'ImageUpload'
|
||||||
| 'Input'
|
| 'Input'
|
||||||
| 'InputNumber'
|
| 'InputNumber'
|
||||||
| 'RadioGroup'
|
| 'RadioGroup'
|
||||||
|
|
@ -315,6 +319,8 @@ async function initComponentAdapter() {
|
||||||
},
|
},
|
||||||
TreeSelect: withDefaultPlaceholder(ElTreeSelect, 'select'),
|
TreeSelect: withDefaultPlaceholder(ElTreeSelect, 'select'),
|
||||||
Upload: ElUpload,
|
Upload: ElUpload,
|
||||||
|
FileUpload,
|
||||||
|
ImageUpload,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将组件注册到全局共享状态中
|
// 将组件注册到全局共享状态中
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,20 @@
|
||||||
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
import { h } from 'vue';
|
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';
|
import { useVbenForm } from './form';
|
||||||
|
|
||||||
|
|
@ -20,6 +32,17 @@ setupVbenVxeTable({
|
||||||
// 全局禁用vxe-table的表单配置,使用formOptions
|
// 全局禁用vxe-table的表单配置,使用formOptions
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
import: false, // 是否导入
|
||||||
|
export: false, // 是否导出
|
||||||
|
refresh: true, // 是否刷新
|
||||||
|
print: false, // 是否打印
|
||||||
|
zoom: true, // 是否缩放
|
||||||
|
custom: true, // 是否自定义配置
|
||||||
|
},
|
||||||
|
customConfig: {
|
||||||
|
mode: 'modal',
|
||||||
|
},
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
autoLoad: true,
|
autoLoad: true,
|
||||||
response: {
|
response: {
|
||||||
|
|
@ -30,6 +53,12 @@ setupVbenVxeTable({
|
||||||
showActiveMsg: true,
|
showActiveMsg: true,
|
||||||
showResponseMsg: false,
|
showResponseMsg: false,
|
||||||
},
|
},
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
sortConfig: {
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
round: true,
|
round: true,
|
||||||
showOverflow: true,
|
showOverflow: true,
|
||||||
size: 'small',
|
size: 'small',
|
||||||
|
|
@ -57,12 +86,209 @@ setupVbenVxeTable({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
|
// 表格配置项可以用 cellRender: { name: 'CellDict', props:{dictType: ''} },
|
||||||
// vxeUI.formats.add
|
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,
|
useVbenForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
export { useVbenVxeGrid };
|
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';
|
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