Compare commits
160 Commits
1.2.3-back
...
1.3.1-back
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4dc7543bb6 | ||
![]() |
d8e7945f9f | ||
![]() |
2fd1fdcb32 | ||
![]() |
44ba945a12 | ||
![]() |
2680101872 | ||
![]() |
1c2e27613c | ||
![]() |
3e7a2336b0 | ||
![]() |
022d5182d7 | ||
![]() |
329a176a5c | ||
![]() |
41962ef380 | ||
![]() |
9003df713c | ||
![]() |
ebb4738be7 | ||
![]() |
ad7c33a7d6 | ||
![]() |
a114335a56 | ||
![]() |
9379093a4f | ||
![]() |
c9014d0338 | ||
![]() |
b8ec8edb38 | ||
![]() |
ed26dca64e | ||
![]() |
08c6496e24 | ||
![]() |
a8c5df38e9 | ||
![]() |
5b9f647cfd | ||
![]() |
ae6bf6ee53 | ||
![]() |
77894d5df4 | ||
![]() |
ba8f36a2c0 | ||
![]() |
133abe9ded | ||
![]() |
ef390ae636 | ||
![]() |
6d2f4e8486 | ||
![]() |
c4962aaf85 | ||
![]() |
f7128b099e | ||
![]() |
5510b6dea4 | ||
![]() |
98f658d46f | ||
![]() |
e307db2f3d | ||
![]() |
e6dab8300d | ||
![]() |
eb9f278e7f | ||
![]() |
34e5812de9 | ||
![]() |
07587c0faf | ||
![]() |
88316d7498 | ||
![]() |
53e02d46c2 | ||
![]() |
5e1de6fc79 | ||
![]() |
7463df053a | ||
![]() |
1286b52135 | ||
![]() |
92fe406ae9 | ||
![]() |
5b72d9b79d | ||
![]() |
b97fe47afd | ||
![]() |
4f2354b53a | ||
![]() |
8f9006c96d | ||
![]() |
71e8d12b70 | ||
![]() |
ae5d45763f | ||
![]() |
3f037f146b | ||
![]() |
63db3ba143 | ||
![]() |
17ca1ef1b2 | ||
![]() |
203c2edf63 | ||
![]() |
09e0721db7 | ||
![]() |
3c2a52057e | ||
![]() |
06dd17eac3 | ||
![]() |
0e3eb887da | ||
![]() |
1b2ded7421 | ||
![]() |
7bc36d2b84 | ||
![]() |
21fb9c8c99 | ||
![]() |
84d6559f25 | ||
![]() |
78c16c1016 | ||
![]() |
7fc284a609 | ||
![]() |
29c062a4a8 | ||
![]() |
e1aa1f7636 | ||
![]() |
eae24d8d83 | ||
![]() |
88b3208afb | ||
![]() |
26587ac09a | ||
![]() |
b4d038e22f | ||
![]() |
d216fdca44 | ||
![]() |
384c5d7dbb | ||
![]() |
63630b17c1 | ||
![]() |
a2ed3fa48b | ||
![]() |
104039cdfb | ||
![]() |
16f033aa8f | ||
![]() |
0c0988404e | ||
![]() |
1a16c520a1 | ||
![]() |
e52fec291a | ||
![]() |
aeed0fd48e | ||
![]() |
542407dcd6 | ||
![]() |
123f234971 | ||
![]() |
2ca39dfdbb | ||
![]() |
b0ad08dbbc | ||
![]() |
3600603016 | ||
![]() |
116fe39b8d | ||
![]() |
cde1a85394 | ||
![]() |
fe53c33821 | ||
![]() |
bac71a30f0 | ||
![]() |
ec82510f49 | ||
![]() |
f1c4ed1412 | ||
![]() |
36683dd218 | ||
![]() |
2577ba5500 | ||
![]() |
b5150b5863 | ||
![]() |
5d47026908 | ||
![]() |
63e13069ea | ||
![]() |
56e7e840b3 | ||
![]() |
2d58a2172d | ||
![]() |
c623604ea9 | ||
![]() |
75d7a607f5 | ||
![]() |
354ff7ecf6 | ||
![]() |
7933da8f66 | ||
![]() |
38d39d5e3d | ||
![]() |
ecf518bb02 | ||
![]() |
98e3a4a34c | ||
![]() |
362bc84cfb | ||
![]() |
b67be83a19 | ||
![]() |
af118cef71 | ||
![]() |
bc2beefa7e | ||
![]() |
44ad2c5f8d | ||
![]() |
1d9f1be004 | ||
![]() |
44138f578f | ||
![]() |
13951a0caf | ||
![]() |
a84a713eaa | ||
![]() |
0e3abc2b53 | ||
![]() |
a96be3db98 | ||
![]() |
308853cce1 | ||
![]() |
0a19ec3122 | ||
![]() |
ebc571e13f | ||
![]() |
2b7713323e | ||
![]() |
d6f239c564 | ||
![]() |
166e9a0e82 | ||
![]() |
c0a5942c2a | ||
![]() |
bc9e3a50e1 | ||
![]() |
06ccad9db0 | ||
![]() |
18722ce434 | ||
![]() |
a0feeb1966 | ||
![]() |
825c2837ea | ||
![]() |
23ff03d40c | ||
![]() |
755a30583f | ||
![]() |
a302fdf119 | ||
![]() |
df6341f0b8 | ||
![]() |
062e999f35 | ||
![]() |
6c4d15136f | ||
![]() |
f16afe657e | ||
![]() |
b9843c6faf | ||
![]() |
621f79e7d8 | ||
![]() |
467d337515 | ||
![]() |
dbc0b7e4a9 | ||
![]() |
aa2907323f | ||
![]() |
3655cae900 | ||
![]() |
60c398df39 | ||
![]() |
69222807a4 | ||
![]() |
b3e2d758f6 | ||
![]() |
96d2bc52e9 | ||
![]() |
f4a88efb0f | ||
![]() |
b78b599a06 | ||
![]() |
ffcc21975e | ||
![]() |
dd57e3c9ae | ||
![]() |
8c1cd617ad | ||
![]() |
e91e4e0eea | ||
![]() |
c2b5f6497d | ||
![]() |
456f0e1112 | ||
![]() |
e5fa32bbae | ||
![]() |
024087b9b2 | ||
![]() |
c0476613d7 | ||
![]() |
3c2d325d8c | ||
![]() |
a77bb8e68d | ||
![]() |
870cd86393 | ||
![]() |
f5fada20e6 | ||
![]() |
0b650367f3 | ||
![]() |
1616a06bfd |
14
.vscode/settings.json
vendored
14
.vscode/settings.json
vendored
@@ -224,10 +224,20 @@
|
||||
"commentTranslate.multiLineMerge": true,
|
||||
"vue.server.hybridMode": true,
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"cSpell.words": ["tinymce", "vditor"],
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.linkedEditing": true, // 自动同步更改html标签,
|
||||
"vscodeCustomCodeColor.highlightValue": "v-access", // v-access显示的颜色
|
||||
"vscodeCustomCodeColor.highlightValueColor": "#CCFFFF",
|
||||
"oxc.enable": false
|
||||
"oxc.enable": false,
|
||||
"cSpell.words": [
|
||||
"archiver",
|
||||
"axios",
|
||||
"dotenv",
|
||||
"isequal",
|
||||
"jspm",
|
||||
"napi",
|
||||
"nolebase",
|
||||
"rollup",
|
||||
"vitest"
|
||||
]
|
||||
}
|
||||
|
75
CHANGELOG.md
75
CHANGELOG.md
@@ -1,3 +1,78 @@
|
||||
# 1.3.1
|
||||
|
||||
**REFACTOR**
|
||||
|
||||
- 所有Modal/Drawer表单关闭前会进行表单数据对比来弹出提示框
|
||||
- 字典项颜色选择从`原生input type=color`改为`vue3-colorpicker`组件
|
||||
- 全局Header: ClientID 更改大小写 [spring的问题导致](https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IC0BDS)
|
||||
|
||||
**BUG FIX**
|
||||
|
||||
- getVxePopupContainer逻辑调整 解决表格固定高度展开不全的问题
|
||||
|
||||
**FEATURES**
|
||||
|
||||
- 字典渲染支持loading(length为0情况)
|
||||
|
||||
**OTHERS**
|
||||
|
||||
- useForm的组件改为异步导入(官方更新) bootstrap.js体积从2M降到600K 首屏加载速度提升
|
||||
|
||||
# 1.3.0
|
||||
|
||||
注意: 如果你使用老版本的`文件上传`/`图片上传` 可暂时使用
|
||||
|
||||
- `component: 'ImageUploadOld'`
|
||||
- `component: 'FileUploadOld'`
|
||||
|
||||
代替 **建议替换为新版本**
|
||||
|
||||
大致变动:
|
||||
|
||||
- `accept string[] -> string`
|
||||
- `resultField 已经移除 统一使用ossId`
|
||||
- `maxNumber -> maxCount`
|
||||
|
||||
具体参数查看: `apps/web-antd/src/components/upload/src/props.d.ts`
|
||||
|
||||
不再推荐使用useDescription, 这个版本会标记为@deprecated, 下个次版本将会移除
|
||||
|
||||
框架所有使用useDescription组件的会替换为原生(TODO)
|
||||
|
||||
**REFACTOR**
|
||||
|
||||
- **文件上传/图片上传重构(破坏性更新 不兼容之前的api)**
|
||||
- **文件上传/图片上传不再支持url用法 强制使用ossId**
|
||||
- TableSwitch组件重构
|
||||
- 管理员租户切换不再返回首页 直接刷新当前页(除特殊页面外会回到首页)
|
||||
- 租户切换Select增加loading
|
||||
- ~~modalLoading/drawerLoading改为调用内部的lock/unlock方法~~ 有待商榷暂时按老版本逻辑不变
|
||||
- 登录验证码 增加loading
|
||||
- DictEnum使用const代替enum
|
||||
- TinyMCE组件重构 移除冗余代码/功能 增加loading
|
||||
|
||||
**ALPHA功能**
|
||||
|
||||
- 弹窗表单数据更改关闭时的提示框(可能最终不会加入) 测试页面: 参数管理
|
||||
|
||||
**BUG FIX**
|
||||
|
||||
- 重新登录 字典会unknown的情况[详细分析](https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IBY27D)
|
||||
- 测试菜单 请假申请 选中删除 需要根据状态判断
|
||||
- 修复文件/图片在Safari中无法上传 file-type库与Safari不兼容导致
|
||||
- 头像裁剪 图片加载失败一直处于loading无法上传
|
||||
- 头像裁剪 私有桶会拼接timestamp参数导致sign计算异常无法上传 感谢cropperjs作者 https://github.com/fengyuanchen/cropperjs/issues/1230
|
||||
- 租户选择下拉框会跟随body滚动(将下拉框样式的默认absolute改为fixed)
|
||||
|
||||
**OTHER**
|
||||
|
||||
- 字典管理 字典类型 表格选中行增加bold效果
|
||||
- 全局圆角修改 与antd保持一致
|
||||
- vditor(Markdown)升级3.10.9
|
||||
- 老版本的文件/图片上传将于下个版本移除
|
||||
- useDescription将于下个版本移除
|
||||
- getVxePopupContainer与新版Vxe不兼容 先返回body(会导致滚动不跟随)后续版本再优化
|
||||
|
||||
# 1.2.3
|
||||
|
||||
**BUG FIX**
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/web-antd",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.0",
|
||||
"homepage": "https://vben.pro",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
@@ -54,7 +54,8 @@
|
||||
"tinymce": "^7.3.0",
|
||||
"unplugin-vue-components": "^0.27.3",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
"vue-router": "catalog:",
|
||||
"vue3-colorpicker": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
|
@@ -8,42 +8,84 @@ import type { Component } from 'vue';
|
||||
import type { BaseFormComponentType } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, defineComponent, getCurrentInstance, h, ref } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
getCurrentInstance,
|
||||
h,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Input,
|
||||
InputNumber,
|
||||
InputPassword,
|
||||
Mentions,
|
||||
notification,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
RangePicker,
|
||||
Rate,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Textarea,
|
||||
TimePicker,
|
||||
TreeSelect,
|
||||
Upload,
|
||||
} from 'ant-design-vue';
|
||||
import { notification } from 'ant-design-vue';
|
||||
|
||||
import { Tinymce as RichTextarea } from '#/components/tinymce';
|
||||
import { FileUpload, ImageUpload } from '#/components/upload';
|
||||
import { FileUploadOld, ImageUploadOld } from '#/components/upload-old';
|
||||
|
||||
const RichTextarea = defineAsyncComponent(() =>
|
||||
import('#/components/tinymce/index').then((res) => res.Tinymce),
|
||||
);
|
||||
|
||||
const FileUpload = defineAsyncComponent(() =>
|
||||
import('#/components/upload').then((res) => res.FileUpload),
|
||||
);
|
||||
|
||||
const ImageUpload = defineAsyncComponent(() =>
|
||||
import('#/components/upload').then((res) => res.ImageUpload),
|
||||
);
|
||||
|
||||
const AutoComplete = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/auto-complete'),
|
||||
);
|
||||
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
|
||||
const Checkbox = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/checkbox'),
|
||||
);
|
||||
const CheckboxGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
|
||||
);
|
||||
const DatePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/date-picker'),
|
||||
);
|
||||
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
|
||||
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
|
||||
const InputNumber = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/input-number'),
|
||||
);
|
||||
const InputPassword = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.InputPassword),
|
||||
);
|
||||
const Mentions = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/mentions'),
|
||||
);
|
||||
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
|
||||
const RadioGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
|
||||
);
|
||||
const RangePicker = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
|
||||
);
|
||||
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
|
||||
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
|
||||
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
|
||||
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
|
||||
const Textarea = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.Textarea),
|
||||
);
|
||||
const TimePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/time-picker'),
|
||||
);
|
||||
const TreeSelect = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/tree-select'),
|
||||
);
|
||||
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
|
||||
|
||||
const withDefaultPlaceholder = <T extends Component>(
|
||||
component: T,
|
||||
type: 'input' | 'select',
|
||||
componentProps: Recordable<any> = {},
|
||||
) => {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
@@ -74,7 +116,13 @@ const withDefaultPlaceholder = <T extends Component>(
|
||||
return () =>
|
||||
h(
|
||||
component,
|
||||
{ ...props, ...attrs, placeholder: placeholder.value, ref: innerRef },
|
||||
{
|
||||
...componentProps,
|
||||
placeholder: placeholder.value,
|
||||
...props,
|
||||
...attrs,
|
||||
ref: innerRef,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
@@ -92,8 +140,10 @@ export type ComponentType =
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'FileUpload'
|
||||
| 'FileUploadOld'
|
||||
| 'IconPicker'
|
||||
| 'ImageUpload'
|
||||
| 'ImageUploadOld'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
| 'InputPassword'
|
||||
@@ -118,38 +168,20 @@ async function initComponentAdapter() {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
// Button: () =>
|
||||
// import('xxx').then((res) => res.Button),
|
||||
ApiSelect: (props, { attrs, slots }) => {
|
||||
return h(
|
||||
ApiComponent,
|
||||
{
|
||||
placeholder: $t('ui.placeholder.select'),
|
||||
...props,
|
||||
...attrs,
|
||||
component: Select,
|
||||
loadingSlot: 'suffixIcon',
|
||||
visibleEvent: 'onDropdownVisibleChange',
|
||||
modelPropName: 'value',
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
ApiTreeSelect: (props, { attrs, slots }) => {
|
||||
return h(
|
||||
ApiComponent,
|
||||
{
|
||||
placeholder: $t('ui.placeholder.select'),
|
||||
...props,
|
||||
...attrs,
|
||||
component: TreeSelect,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
optionsPropName: 'treeData',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Select,
|
||||
loadingSlot: 'suffixIcon',
|
||||
visibleEvent: 'onDropdownVisibleChange',
|
||||
modelPropName: 'value',
|
||||
}),
|
||||
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: TreeSelect,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
optionsPropName: 'treeData',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
AutoComplete,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
@@ -159,19 +191,11 @@ async function initComponentAdapter() {
|
||||
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
||||
},
|
||||
Divider,
|
||||
IconPicker: (props, { attrs, slots }) => {
|
||||
return h(
|
||||
IconPicker,
|
||||
{
|
||||
iconSlot: 'addonAfter',
|
||||
inputComponent: Input,
|
||||
modelValueProp: 'value',
|
||||
...props,
|
||||
...attrs,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
|
||||
iconSlot: 'addonAfter',
|
||||
inputComponent: Input,
|
||||
modelValueProp: 'value',
|
||||
}),
|
||||
Input: withDefaultPlaceholder(Input, 'input'),
|
||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
|
||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||
@@ -194,6 +218,8 @@ async function initComponentAdapter() {
|
||||
ImageUpload,
|
||||
FileUpload,
|
||||
RichTextarea,
|
||||
ImageUploadOld,
|
||||
FileUploadOld,
|
||||
};
|
||||
|
||||
// 将组件注册到全局共享状态中
|
||||
|
@@ -7,22 +7,6 @@ import { requestClient } from '#/api/request';
|
||||
*/
|
||||
export type AxiosProgressEvent = AxiosRequestConfig['onUploadProgress'];
|
||||
|
||||
/**
|
||||
* 通过单文件上传接口
|
||||
* @param file 上传的文件
|
||||
* @param onUploadProgress 上传进度事件 非必传
|
||||
* @returns 上传结果
|
||||
*/
|
||||
export function uploadApi(
|
||||
file: Blob | File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) {
|
||||
return requestClient.upload(
|
||||
'/resource/oss/upload',
|
||||
{ file },
|
||||
{ onUploadProgress, timeout: 60_000 },
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 默认上传结果
|
||||
*/
|
||||
@@ -31,3 +15,33 @@ export interface UploadResult {
|
||||
fileName: string;
|
||||
ossId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过单文件上传接口
|
||||
* @param file 上传的文件
|
||||
* @param options 一些配置项
|
||||
* @param options.onUploadProgress 上传进度事件
|
||||
* @param options.signal 上传取消信号
|
||||
* @param options.otherData 其他请求参数 后端拓展可能会用到
|
||||
* @returns 上传结果
|
||||
*/
|
||||
export function uploadApi(
|
||||
file: Blob | File,
|
||||
options?: {
|
||||
onUploadProgress?: AxiosProgressEvent;
|
||||
otherData?: Record<string, any>;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) {
|
||||
const { onUploadProgress, signal, otherData = {} } = options ?? {};
|
||||
return requestClient.upload<UploadResult>(
|
||||
'/resource/oss/upload',
|
||||
{ file, ...otherData },
|
||||
{ onUploadProgress, signal, timeout: 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传api type
|
||||
*/
|
||||
export type UploadApi = typeof uploadApi;
|
||||
|
@@ -3,14 +3,14 @@ import { requestClient } from './request';
|
||||
/**
|
||||
* @description: contentType
|
||||
*/
|
||||
export enum ContentTypeEnum {
|
||||
export const ContentTypeEnum = {
|
||||
// form-data upload
|
||||
FORM_DATA = 'multipart/form-data;charset=UTF-8',
|
||||
FORM_DATA: 'multipart/form-data;charset=UTF-8',
|
||||
// form-data qs
|
||||
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
FORM_URLENCODED: 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
// json
|
||||
JSON = 'application/json;charset=UTF-8',
|
||||
}
|
||||
JSON: 'application/json;charset=UTF-8',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 通用下载接口 封装一层
|
||||
|
@@ -9,4 +9,5 @@ export interface LoginLog {
|
||||
os: string;
|
||||
msg: string;
|
||||
loginTime: string;
|
||||
clientKey: string;
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ export interface OperationLog {
|
||||
operId: string;
|
||||
tenantId: string;
|
||||
title: string;
|
||||
businessType: number;
|
||||
businessType: string;
|
||||
businessTypes?: any;
|
||||
method: string;
|
||||
requestMethod: string;
|
||||
@@ -14,7 +14,7 @@ export interface OperationLog {
|
||||
operLocation: string;
|
||||
operParam: string;
|
||||
jsonResult: string;
|
||||
status: number;
|
||||
status: string;
|
||||
errorMsg: string;
|
||||
operTime: string;
|
||||
costTime: number;
|
||||
|
@@ -93,9 +93,12 @@ function createRequestClient(baseURL: string) {
|
||||
const language = preferences.app.locale.replace('-', '_');
|
||||
config.headers['Accept-Language'] = language;
|
||||
config.headers['Content-Language'] = language;
|
||||
// 添加全局clientId
|
||||
config.headers.clientId = clientId;
|
||||
|
||||
/**
|
||||
* 添加全局clientId
|
||||
* 关于header的clientId被错误绑定到实体类
|
||||
* https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IC0BDS
|
||||
*/
|
||||
config.headers.ClientID = clientId;
|
||||
/**
|
||||
* 格式化get/delete参数
|
||||
* 如果包含自定义的paramsSerializer则不走此逻辑
|
||||
@@ -225,7 +228,7 @@ function createRequestClient(baseURL: string) {
|
||||
case 401: {
|
||||
// 已经在登出过程中 不再执行
|
||||
if (isLogoutProcessing) {
|
||||
return;
|
||||
throw new Error(timeoutMsg);
|
||||
}
|
||||
isLogoutProcessing = true;
|
||||
const _msg = $t('http.loginTimeout');
|
||||
@@ -235,7 +238,7 @@ function createRequestClient(baseURL: string) {
|
||||
isLogoutProcessing = false;
|
||||
});
|
||||
// 不再执行下面逻辑
|
||||
return;
|
||||
throw new Error(_msg);
|
||||
}
|
||||
default: {
|
||||
if (msg) {
|
||||
|
@@ -59,7 +59,11 @@ export function clientUpdate(data: Partial<Client>) {
|
||||
* @param data 状态
|
||||
*/
|
||||
export function clientChangeStatus(data: any) {
|
||||
return requestClient.putWithMsg<void>(Api.clientChangeStatus, data);
|
||||
const requestData = {
|
||||
clientId: data.clientId,
|
||||
status: data.status,
|
||||
};
|
||||
return requestClient.putWithMsg<void>(Api.clientChangeStatus, requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -37,5 +37,9 @@ export function ossConfigRemove(ossConfigIds: IDS) {
|
||||
|
||||
// 更改OSS配置的状态
|
||||
export function ossConfigChangeStatus(data: any) {
|
||||
return requestClient.putWithMsg(Api.ossConfigChangeStatus, data);
|
||||
const requestData = {
|
||||
ossConfigId: data.ossConfigId,
|
||||
status: data.status,
|
||||
};
|
||||
return requestClient.putWithMsg(Api.ossConfigChangeStatus, requestData);
|
||||
}
|
||||
|
@@ -29,7 +29,7 @@ export function ossList(params?: PageQuery) {
|
||||
* @param ossIds id数组
|
||||
* @returns 信息数组
|
||||
*/
|
||||
export function ossInfo(ossIds: IDS) {
|
||||
export function ossInfo(ossIds: ID | IDS) {
|
||||
return requestClient.get<OssFile[]>(`${Api.ossInfo}/${ossIds}`);
|
||||
}
|
||||
|
||||
|
@@ -72,7 +72,11 @@ export function roleUpdate(data: Partial<Role>) {
|
||||
* @returns void
|
||||
*/
|
||||
export function roleChangeStatus(data: Partial<Role>) {
|
||||
return requestClient.putWithMsg<void>(Api.roleChangeStatus, data);
|
||||
const requestData = {
|
||||
roleId: data.roleId,
|
||||
status: data.status,
|
||||
};
|
||||
return requestClient.putWithMsg<void>(Api.roleChangeStatus, requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -74,7 +74,11 @@ export function packageUpdate(data: Partial<TenantPackage>) {
|
||||
* @returns void
|
||||
*/
|
||||
export function packageChangeStatus(data: Partial<TenantPackage>) {
|
||||
return requestClient.putWithMsg<void>(Api.packageChangeStatus, data);
|
||||
const packageId = {
|
||||
packageId: data.packageId,
|
||||
status: data.status,
|
||||
};
|
||||
return requestClient.putWithMsg<void>(Api.packageChangeStatus, packageId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -67,7 +67,12 @@ export function tenantUpdate(data: Partial<Tenant>) {
|
||||
* @returns void
|
||||
*/
|
||||
export function tenantStatusChange(data: Partial<Tenant>) {
|
||||
return requestClient.putWithMsg(Api.tenantStatus, data);
|
||||
const requestData = {
|
||||
id: data.id,
|
||||
tenantId: data.tenantId,
|
||||
status: data.status,
|
||||
};
|
||||
return requestClient.putWithMsg(Api.tenantStatus, requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -110,7 +110,11 @@ export function userUpdate(data: Partial<User>) {
|
||||
* @returns void
|
||||
*/
|
||||
export function userStatusChange(data: Partial<User>) {
|
||||
return requestClient.putWithMsg<void>(Api.userStatusChange, data);
|
||||
const requestData = {
|
||||
userId: data.userId,
|
||||
status: data.status,
|
||||
};
|
||||
return requestClient.putWithMsg<void>(Api.userStatusChange, requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
|
1
apps/web-antd/src/api/system/user/model.d.ts
vendored
1
apps/web-antd/src/api/system/user/model.d.ts
vendored
@@ -66,6 +66,7 @@ export interface User {
|
||||
roleIds?: string[];
|
||||
postIds?: number[];
|
||||
roleId: string;
|
||||
deptName: string;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
|
@@ -1,8 +1,7 @@
|
||||
import { createApp, watchEffect } from 'vue';
|
||||
|
||||
import { registerAccessDirective } from '@vben/access';
|
||||
import { initTippy, registerLoadingDirective } from '@vben/common-ui';
|
||||
import { MotionPlugin } from '@vben/plugins/motion';
|
||||
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { initStores } from '@vben/stores';
|
||||
import '@vben/styles';
|
||||
@@ -50,12 +49,14 @@ async function bootstrap(namespace: string) {
|
||||
registerAccessDirective(app);
|
||||
|
||||
// 初始化 tippy
|
||||
const { initTippy } = await import('@vben/common-ui/es/tippy');
|
||||
initTippy(app);
|
||||
|
||||
// 配置路由及路由守卫
|
||||
app.use(router);
|
||||
|
||||
// 配置Motion插件
|
||||
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||
app.use(MotionPlugin);
|
||||
|
||||
// 动态更新标题
|
||||
|
@@ -84,6 +84,10 @@ function handleReady(cropperInstance: Cropper) {
|
||||
modalLoading(false);
|
||||
}
|
||||
|
||||
function handleReadyError() {
|
||||
modalLoading(false);
|
||||
}
|
||||
|
||||
function handlerToolbar(event: string, arg?: number) {
|
||||
if (event === 'scaleX') {
|
||||
scaleX = arg = scaleX === -1 ? 1 : -1;
|
||||
@@ -128,9 +132,11 @@ async function handleOk() {
|
||||
v-if="src"
|
||||
:circled="circled"
|
||||
:src="src"
|
||||
crossorigin="anonymous"
|
||||
height="300px"
|
||||
@cropend="handleCropend"
|
||||
@ready="handleReady"
|
||||
@ready-error="handleReadyError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +328,8 @@ async function handleOk() {
|
||||
&-cropper {
|
||||
height: 300px;
|
||||
background: #eee;
|
||||
background-image: linear-gradient(
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
|
@@ -26,14 +26,16 @@ const props = defineProps({
|
||||
src: { required: true, type: String },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['cropend', 'ready', 'cropendError']);
|
||||
const emit = defineEmits(['cropend', 'ready', 'cropendError', 'readyError']);
|
||||
|
||||
const defaultOptions: Options = {
|
||||
aspectRatio: 1,
|
||||
autoCrop: true,
|
||||
background: true,
|
||||
center: true,
|
||||
checkCrossOrigin: true,
|
||||
// 需要设置为false 否则会自动拼接timestamp 导致私有桶sign错误
|
||||
// 需要配合img crossorigin='anonymous'使用(默认已经做了处理)
|
||||
checkCrossOrigin: false,
|
||||
checkOrientation: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
@@ -94,6 +96,15 @@ async function init() {
|
||||
if (!imgEl) {
|
||||
return;
|
||||
}
|
||||
// 判断是否为正常访问的图片
|
||||
try {
|
||||
const resp = await fetch(props.src);
|
||||
if (resp.status !== 200) {
|
||||
emit('readyError');
|
||||
}
|
||||
} catch {
|
||||
emit('readyError');
|
||||
}
|
||||
cropper.value = new Cropper(imgEl, {
|
||||
...defaultOptions,
|
||||
crop() {
|
||||
|
@@ -34,6 +34,9 @@ const props = {
|
||||
useCollapse: { default: true, type: Boolean },
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated 使用antd原生组件替代 下个版本将会移除
|
||||
*/
|
||||
export default defineComponent({
|
||||
emits: ['register'],
|
||||
// eslint-disable-next-line vue/order-in-components
|
||||
|
@@ -6,6 +6,9 @@ import type {
|
||||
|
||||
import { getCurrentInstance, ref, unref } from 'vue';
|
||||
|
||||
/**
|
||||
* @deprecated 使用antd原生组件替代 下个版本将会移除
|
||||
*/
|
||||
export function useDescription(
|
||||
props?: Partial<DescriptionProps>,
|
||||
): UseDescReturnType {
|
||||
|
@@ -4,7 +4,7 @@ import type { DictData } from '#/api/system/dict/dict-data-model';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
import { Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { tagTypes } from './data';
|
||||
|
||||
@@ -41,12 +41,22 @@ const label = computed<number | string>(() => {
|
||||
});
|
||||
|
||||
const tagComponent = computed(() => (color.value ? Tag : 'div'));
|
||||
|
||||
const loading = computed(() => {
|
||||
return props.dicts?.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<component :is="tagComponent" :class="cssClass" :color="color">
|
||||
<component
|
||||
v-if="!loading"
|
||||
:is="tagComponent"
|
||||
:class="cssClass"
|
||||
:color="color"
|
||||
>
|
||||
{{ label }}
|
||||
</component>
|
||||
<Spin v-else :spinning="true" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -1,84 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Switch } from 'ant-design-vue';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { Modal, Switch } from 'ant-design-vue';
|
||||
import { isFunction } from 'lodash-es';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableSwitch',
|
||||
components: {
|
||||
Switch,
|
||||
},
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [Boolean, String, Number],
|
||||
default: false,
|
||||
},
|
||||
checkedText: {
|
||||
type: String,
|
||||
default: '启用',
|
||||
},
|
||||
unCheckedText: {
|
||||
type: String,
|
||||
default: '禁用',
|
||||
},
|
||||
// 使用严格相等判断 类型要正确
|
||||
checkedValue: {
|
||||
type: [Boolean, String, Number],
|
||||
default: '0',
|
||||
},
|
||||
unCheckedValue: {
|
||||
type: [Boolean, String, Number],
|
||||
default: '1',
|
||||
},
|
||||
api: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
reload: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
type CheckedType = boolean | number | string;
|
||||
async function onChange(checked: CheckedType, e: Event) {
|
||||
// 阻止事件冒泡 否则会跟行选中冲突
|
||||
e.stopPropagation();
|
||||
const { checkedValue, unCheckedValue } = props;
|
||||
// 原本的状态
|
||||
const lastStatus =
|
||||
checked === checkedValue ? unCheckedValue : checkedValue;
|
||||
// 切换状态
|
||||
emit('update:modelValue', checked);
|
||||
const { api, reload } = props;
|
||||
type CheckedType = boolean | number | string;
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 选中的文本
|
||||
* @default i18n 启用
|
||||
*/
|
||||
checkedText?: string;
|
||||
/**
|
||||
* 未选中的文本
|
||||
* @default i18n 禁用
|
||||
*/
|
||||
unCheckedText?: string;
|
||||
checkedValue?: CheckedType;
|
||||
unCheckedValue?: CheckedType;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 需要自己在内部处理更新的逻辑 因为status已经双向绑定了 可以直接获取
|
||||
*/
|
||||
api: () => PromiseLike<void>;
|
||||
/**
|
||||
* 更新前是否弹窗确认
|
||||
* @default false
|
||||
*/
|
||||
confirm?: boolean;
|
||||
/**
|
||||
* 对应的提示内容
|
||||
* @param checked 选中的值(更新后的值)
|
||||
* @default string '确认要更新状态吗?'
|
||||
*/
|
||||
confirmText?: (checked: CheckedType) => string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
checkedText: undefined,
|
||||
unCheckedText: undefined,
|
||||
checkedValue: '0',
|
||||
unCheckedValue: '1',
|
||||
confirm: false,
|
||||
confirmText: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ reload: [] }>();
|
||||
|
||||
// 修改为computed 支持语言切换
|
||||
const checkedTextComputed = computed(() => {
|
||||
return props.checkedText ?? $t('pages.common.enable');
|
||||
});
|
||||
|
||||
const unCheckedTextComputed = computed(() => {
|
||||
return props.unCheckedText ?? $t('pages.common.disable');
|
||||
});
|
||||
|
||||
const currentChecked = defineModel<CheckedType>('value', {
|
||||
default: false,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
function confirmUpdate(checked: CheckedType, lastStatus: CheckedType) {
|
||||
const content = isFunction(props.confirmText)
|
||||
? props.confirmText(checked)
|
||||
: `确认要更新状态吗?`;
|
||||
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content,
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const { api } = props;
|
||||
isFunction(api) && (await api());
|
||||
isFunction(reload) && (await reload());
|
||||
emit('reload');
|
||||
} catch {
|
||||
emit('update:modelValue', lastStatus);
|
||||
currentChecked.value = lastStatus;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
currentChecked.value = lastStatus;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChange(checked: CheckedType, e: Event) {
|
||||
// 阻止事件冒泡 否则会跟行选中冲突
|
||||
e.stopPropagation();
|
||||
const { checkedValue, unCheckedValue } = props;
|
||||
// 原本的状态
|
||||
const lastStatus = checked === checkedValue ? unCheckedValue : checkedValue;
|
||||
// 切换状态
|
||||
currentChecked.value = checked;
|
||||
const { api } = props;
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
if (props.confirm) {
|
||||
confirmUpdate(checked, lastStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
onChange,
|
||||
};
|
||||
},
|
||||
});
|
||||
isFunction(api) && (await api());
|
||||
emit('reload');
|
||||
} catch {
|
||||
currentChecked.value = lastStatus;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch
|
||||
v-bind="$attrs"
|
||||
:checked="modelValue"
|
||||
:checked-children="checkedText"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:checked="currentChecked"
|
||||
:checked-children="checkedTextComputed"
|
||||
:checked-value="checkedValue"
|
||||
:un-checked-children="unCheckedText"
|
||||
:un-checked-children="unCheckedTextComputed"
|
||||
:un-checked-value="unCheckedValue"
|
||||
@change="onChange"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { MessageType } from 'ant-design-vue/es/message';
|
||||
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select';
|
||||
|
||||
import type { TenantOption } from '#/api';
|
||||
|
||||
import { computed, onMounted, ref, unref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed, onMounted, ref, shallowRef, unref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { DEFAULT_HOME_PATH } from '@vben/constants';
|
||||
import { useTabs } from '@vben/hooks';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message, Select } from 'ant-design-vue';
|
||||
import { message, Select, Spin } from 'ant-design-vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { tenantDynamicClear, tenantDynamicToggle } from '#/api/system/tenant';
|
||||
@@ -35,24 +35,39 @@ const showToggle = computed<boolean>(() => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// 没有超级管理员权限 不会调用接口
|
||||
if (!hasAccessByRoles(['superadmin'])) {
|
||||
return;
|
||||
}
|
||||
await initTenant();
|
||||
});
|
||||
|
||||
const { closeAllTabs } = useTabs();
|
||||
const router = useRouter();
|
||||
function close(checked: boolean) {
|
||||
const route = useRoute();
|
||||
const { closeOtherTabs, refreshTab, closeAllTabs } = useTabs();
|
||||
|
||||
async function close(checked: boolean) {
|
||||
// store设置状态
|
||||
setChecked(checked);
|
||||
// 需要关闭全部标签页
|
||||
closeAllTabs();
|
||||
// 切换完加载首页
|
||||
router.push(DEFAULT_HOME_PATH);
|
||||
|
||||
/**
|
||||
* 切换租户需要回到首页的页面 一般为带id的页面
|
||||
* 其他则直接刷新页面
|
||||
*/
|
||||
if (route.meta.requireHomeRedirect) {
|
||||
await closeAllTabs();
|
||||
} else {
|
||||
// 先关闭再刷新 这里不用Promise.all()
|
||||
await closeOtherTabs();
|
||||
await refreshTab();
|
||||
}
|
||||
}
|
||||
|
||||
const dictStore = useDictStore();
|
||||
// 用于清理上一条message
|
||||
const messageInstance = shallowRef<MessageType | null>();
|
||||
// loading加载中效果
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 选中租户的处理
|
||||
* @param tenantId tenantId
|
||||
@@ -63,23 +78,46 @@ const onSelected: SelectHandler = async (tenantId: string, option: any) => {
|
||||
// createMessage.info('选择一致');
|
||||
return;
|
||||
}
|
||||
await tenantDynamicToggle(tenantId);
|
||||
lastSelected.value = tenantId;
|
||||
message.success(
|
||||
`${$t('component.tenantToggle.switch')} ${option.companyName}`,
|
||||
);
|
||||
close(true);
|
||||
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
|
||||
setTimeout(() => dictStore.resetCache());
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await tenantDynamicToggle(tenantId);
|
||||
lastSelected.value = tenantId;
|
||||
|
||||
// 关闭之前的message 只保留一条
|
||||
messageInstance.value?.();
|
||||
messageInstance.value = message.success(
|
||||
`${$t('component.tenantToggle.switch')} ${option.companyName}`,
|
||||
);
|
||||
|
||||
close(true);
|
||||
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
|
||||
setTimeout(() => dictStore.resetCache());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
async function onDeselect() {
|
||||
await tenantDynamicClear();
|
||||
message.success($t('component.tenantToggle.reset'));
|
||||
lastSelected.value = '';
|
||||
close(false);
|
||||
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
|
||||
setTimeout(() => dictStore.resetCache());
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await tenantDynamicClear();
|
||||
// 关闭之前的message 只保留一条
|
||||
messageInstance.value?.();
|
||||
messageInstance.value = message.success($t('component.tenantToggle.reset'));
|
||||
|
||||
lastSelected.value = '';
|
||||
close(false);
|
||||
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
|
||||
setTimeout(() => dictStore.resetCache());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,16 +134,22 @@ function filterOption(input: string, option: TenantOption) {
|
||||
<div v-if="showToggle" class="mr-[8px] hidden md:block">
|
||||
<Select
|
||||
v-model:value="selected"
|
||||
:disabled="loading"
|
||||
:field-names="{ label: 'companyName', value: 'tenantId' }"
|
||||
:filter-option="filterOption"
|
||||
:options="tenantList"
|
||||
:placeholder="$t('component.tenantToggle.placeholder')"
|
||||
:dropdown-style="{ position: 'fixed', zIndex: 1024 }"
|
||||
allow-clear
|
||||
class="w-60"
|
||||
show-search
|
||||
@deselect="onDeselect"
|
||||
@select="onSelected"
|
||||
/>
|
||||
>
|
||||
<template v-if="loading" #suffixIcon>
|
||||
<Spin size="small" spinning />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@@ -1,80 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import type { IPropTypes } from '@tinymce/tinymce-vue/lib/cjs/main/ts/components/EditorPropTypes';
|
||||
import type { Editor as EditorType } from 'tinymce/tinymce';
|
||||
|
||||
import type { PropType } from 'vue';
|
||||
import type { AxiosProgressEvent, UploadResult } from '#/api';
|
||||
|
||||
import type { UploadResult } from '#/api/core/upload';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
ref,
|
||||
unref,
|
||||
useAttrs,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { computed, nextTick, ref, shallowRef, useAttrs, watch } from 'vue';
|
||||
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { buildShortUUID } from '@vben/utils';
|
||||
|
||||
import Editor from '@tinymce/tinymce-vue';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import { Spin } from 'ant-design-vue';
|
||||
import { camelCase } from 'lodash-es';
|
||||
|
||||
import { uploadApi } from '#/api/core/upload';
|
||||
|
||||
import { bindHandlers } from './helper';
|
||||
import ImgUpload from './img-upload.vue';
|
||||
import { uploadApi } from '#/api';
|
||||
import {
|
||||
plugins as defaultPlugins,
|
||||
toolbar as defaultToolbar,
|
||||
} from './tinymce';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
height: {
|
||||
default: 400,
|
||||
required: false,
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
},
|
||||
options: {
|
||||
default: () => ({}),
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
type: Object as PropType<Partial<InitOptions>>,
|
||||
},
|
||||
|
||||
plugins: {
|
||||
default: defaultPlugins,
|
||||
type: String,
|
||||
},
|
||||
showImageUpload: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
toolbar: {
|
||||
default: defaultToolbar,
|
||||
type: String,
|
||||
},
|
||||
width: {
|
||||
default: 'auto',
|
||||
required: false,
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
} from '#/components/tinymce/src/tinymce';
|
||||
|
||||
type InitOptions = IPropTypes['init'];
|
||||
|
||||
/**
|
||||
* 外部使用 v-model 绑定值
|
||||
*/
|
||||
const modelValue = defineModel('modelValue', { default: '', type: String });
|
||||
interface Props {
|
||||
height?: number | string;
|
||||
options?: Partial<InitOptions>;
|
||||
plugins?: string;
|
||||
toolbar?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'Tinymce',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 400,
|
||||
options: () => ({}),
|
||||
plugins: defaultPlugins,
|
||||
toolbar: defaultToolbar,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
mounted: [];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* https://www.jianshu.com/p/59a9c3802443
|
||||
* 使用自托管方案(本地)代替cdn 没有key的限制
|
||||
@@ -82,21 +52,13 @@ const modelValue = defineModel('modelValue', { default: '', type: String });
|
||||
*/
|
||||
const tinymceScriptSrc = `${import.meta.env.VITE_BASE}tinymce/tinymce.min.js`;
|
||||
|
||||
const attrs = useAttrs();
|
||||
const editorRef = ref<EditorType>();
|
||||
const fullscreen = ref(false);
|
||||
const tinymceId = ref<string>(buildShortUUID('tiny-vue'));
|
||||
const elRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const containerWidth = computed(() => {
|
||||
const width = props.width;
|
||||
if (isNumber(width)) {
|
||||
return `${width}px`;
|
||||
}
|
||||
return width;
|
||||
const content = defineModel<string>('modelValue', {
|
||||
default: '',
|
||||
});
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const editorRef = shallowRef<EditorType | null>(null);
|
||||
|
||||
const { isDark, locale } = usePreferences();
|
||||
const skinName = computed(() => {
|
||||
return isDark.value ? 'oxide-dark' : 'oxide';
|
||||
});
|
||||
@@ -105,30 +67,6 @@ const contentCss = computed(() => {
|
||||
return isDark.value ? 'dark' : 'default';
|
||||
});
|
||||
|
||||
/**
|
||||
* 通过v-if来挂载/卸载组件
|
||||
* 来完成主题切换/语言切换
|
||||
*/
|
||||
const init = ref(true);
|
||||
watch(
|
||||
() => [preferences.theme.mode, preferences.app.locale],
|
||||
() => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
destroy();
|
||||
init.value = false;
|
||||
// 放在下一次tick来切换
|
||||
// 需要先加载组件 也就是v-if为true 然后需要拿到editorRef 必须放在setTimeout(相当于onMounted)
|
||||
nextTick(() => {
|
||||
init.value = true;
|
||||
setTimeout(() => {
|
||||
setEditorMode();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* tinymce支持 en zh_CN
|
||||
*/
|
||||
@@ -140,6 +78,26 @@ const langName = computed(() => {
|
||||
return 'zh_CN';
|
||||
});
|
||||
|
||||
/**
|
||||
* 通过v-if来挂载/卸载组件来完成主题切换切换
|
||||
* 语言切换也需要监听 不监听在切换时候会显示原始<textarea>样式
|
||||
*/
|
||||
const init = ref(true);
|
||||
watch([isDark, locale], async () => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
// 相当于手动unmounted清理 非常重要
|
||||
editorRef.value.destroy();
|
||||
init.value = false;
|
||||
// 放在下一次tick来切换
|
||||
// 需要先加载组件 也就是v-if为true 然后需要拿到editorRef 必须放在setTimeout(相当于onMounted)
|
||||
await nextTick();
|
||||
init.value = true;
|
||||
});
|
||||
|
||||
// 加载完毕前显示spin
|
||||
const loading = ref(true);
|
||||
const initOptions = computed((): InitOptions => {
|
||||
const { height, options, plugins, toolbar } = props;
|
||||
return {
|
||||
@@ -163,6 +121,7 @@ const initOptions = computed((): InitOptions => {
|
||||
* images_upload_handler启用时为上传
|
||||
*/
|
||||
paste_data_images: true,
|
||||
images_file_types: 'jpeg,jpg,png,gif,bmp,webp',
|
||||
plugins,
|
||||
quickbars_selection_toolbar:
|
||||
'bold italic | quicklink h2 h3 blockquote quickimage quicktable',
|
||||
@@ -175,12 +134,18 @@ const initOptions = computed((): InitOptions => {
|
||||
* @param blobInfo
|
||||
* 大坑 不要调用这两个函数 success failure:
|
||||
* 使用resolve/reject代替
|
||||
* (PS: 新版已经没有success failure)
|
||||
*/
|
||||
images_upload_handler: (blobInfo) => {
|
||||
images_upload_handler: (blobInfo, progress) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = blobInfo.blob();
|
||||
// const filename = blobInfo.filename();
|
||||
uploadApi(file)
|
||||
// 进度条事件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
progress(percent);
|
||||
};
|
||||
uploadApi(file, { onUploadProgress: progressEvent })
|
||||
.then((response) => {
|
||||
const { url } = response as unknown as UploadResult;
|
||||
console.log('tinymce上传图片:', url);
|
||||
@@ -188,168 +153,51 @@ const initOptions = computed((): InitOptions => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('tinymce上传图片失败:', error);
|
||||
reject(error.message);
|
||||
// eslint-disable-next-line prefer-promise-reject-errors
|
||||
reject({ message: error.message, remove: true });
|
||||
});
|
||||
});
|
||||
},
|
||||
setup: (editor) => {
|
||||
editorRef.value = editor;
|
||||
editor.on('init', (e) => initSetup(e));
|
||||
editor.on('init', () => {
|
||||
emit('mounted');
|
||||
loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
/**
|
||||
* 监听options.readonly
|
||||
* 获取透传的事件 通过v-on绑定
|
||||
* 可绑定的事件 https://www.tiny.cloud/docs/tinymce/latest/vue-ref/#event-binding
|
||||
*/
|
||||
watch(
|
||||
() => props.options,
|
||||
(options) => {
|
||||
const getDisabled = options && Reflect.get(options, 'readonly');
|
||||
const editor = unref(editorRef);
|
||||
if (editor) {
|
||||
editor.mode.set(getDisabled ? 'readonly' : 'design');
|
||||
const events = computed(() => {
|
||||
const onEvents: Record<string, any> = {};
|
||||
for (const key in attrs) {
|
||||
if (key.startsWith('on')) {
|
||||
const eventKey = camelCase(key.split('on')[1]!);
|
||||
onEvents[eventKey] = attrs[key];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!initOptions.value.inline) {
|
||||
tinymceId.value = buildShortUUID('tiny-vue');
|
||||
}
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
initEditor();
|
||||
setEditorMode();
|
||||
}, 30);
|
||||
});
|
||||
return onEvents;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
setEditorMode();
|
||||
});
|
||||
|
||||
function setEditorMode() {
|
||||
const editor = unref(editorRef);
|
||||
if (editor) {
|
||||
const mode = props.options.readonly ? 'readonly' : 'design';
|
||||
editor.mode.set(mode);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
const editor = unref(editorRef);
|
||||
editor?.destroy();
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
const el = unref(elRef);
|
||||
if (el) {
|
||||
el.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
function initSetup(e: any) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const value = modelValue.value || '';
|
||||
|
||||
editor.setContent(value);
|
||||
bindModelHandlers(editor);
|
||||
bindHandlers(e, attrs, unref(editorRef));
|
||||
}
|
||||
|
||||
function setValue(editor: Record<string, any>, val?: string, prevVal?: string) {
|
||||
if (
|
||||
editor &&
|
||||
typeof val === 'string' &&
|
||||
val !== prevVal &&
|
||||
val !== editor.getContent({ format: attrs.outputFormat })
|
||||
) {
|
||||
editor.setContent(val);
|
||||
}
|
||||
}
|
||||
|
||||
function bindModelHandlers(editor: any) {
|
||||
const modelEvents = attrs.modelEvents ?? null;
|
||||
const normalizedEvents = Array.isArray(modelEvents)
|
||||
? modelEvents.join(' ')
|
||||
: modelEvents;
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(val, prevVal) => {
|
||||
setValue(editor, val, prevVal);
|
||||
},
|
||||
);
|
||||
|
||||
editor.on(normalizedEvents || 'change keyup undo redo', () => {
|
||||
const content = editor.getContent({ format: attrs.outputFormat });
|
||||
emit('change', content);
|
||||
});
|
||||
|
||||
editor.on('FullscreenStateChanged', (e: any) => {
|
||||
fullscreen.value = e.state;
|
||||
});
|
||||
}
|
||||
|
||||
const disabled = computed(() => props.options.readonly ?? false);
|
||||
|
||||
function getUploadingImgName(name: string) {
|
||||
return `[uploading:${name}]`;
|
||||
}
|
||||
|
||||
function handleImageUploading(name: string) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
editor.execCommand('mceInsertContent', false, getUploadingImgName(name));
|
||||
const content = editor?.getContent() ?? '';
|
||||
setValue(editor, content);
|
||||
}
|
||||
|
||||
function handleDone(name: string, url: string) {
|
||||
const editor = unref(editorRef);
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const content = editor?.getContent() ?? '';
|
||||
const val =
|
||||
content?.replace(getUploadingImgName(name), `<img src="${url}"/>`) ?? '';
|
||||
setValue(editor, val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ width: containerWidth }" class="app-tinymce">
|
||||
<ImgUpload
|
||||
v-if="showImageUpload"
|
||||
v-show="editorRef"
|
||||
:disabled="disabled"
|
||||
:fullscreen="fullscreen"
|
||||
@done="handleDone"
|
||||
@uploading="handleImageUploading"
|
||||
/>
|
||||
<Editor
|
||||
v-if="!initOptions.inline && init"
|
||||
v-model="modelValue"
|
||||
:init="initOptions"
|
||||
:style="{ visibility: 'hidden', zIndex: 3000 }"
|
||||
:tinymce-script-src="tinymceScriptSrc"
|
||||
license-key="gpl"
|
||||
/>
|
||||
<slot v-else></slot>
|
||||
<div class="app-tinymce">
|
||||
<Spin :spinning="loading">
|
||||
<Editor
|
||||
v-if="init"
|
||||
v-model="content"
|
||||
:init="initOptions"
|
||||
:tinymce-script-src="tinymceScriptSrc"
|
||||
:disabled="disabled"
|
||||
license-key="gpl"
|
||||
v-on="events"
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -362,23 +210,13 @@ function handleDone(name: string, url: string) {
|
||||
/** 该样式默认为1300的zIndex */
|
||||
z-index: 2025;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/**
|
||||
隐藏右上角upgrade按钮
|
||||
*/
|
||||
:deep(.tox-promotion) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.app-tinymce {
|
||||
position: relative;
|
||||
line-height: normal;
|
||||
|
||||
:deep(.textarea) {
|
||||
z-index: -1;
|
||||
visibility: hidden;
|
||||
/**
|
||||
隐藏右上角upgrade按钮
|
||||
*/
|
||||
.tox-promotion {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,85 +0,0 @@
|
||||
const validEvents = new Set([
|
||||
'onActivate',
|
||||
'onAddUndo',
|
||||
'onBeforeAddUndo',
|
||||
'onBeforeExecCommand',
|
||||
'onBeforeGetContent',
|
||||
'onBeforePaste',
|
||||
'onBeforeRenderUI',
|
||||
'onBeforeSetContent',
|
||||
'onBlur',
|
||||
'onChange',
|
||||
'onClearUndos',
|
||||
'onClick',
|
||||
'onContextMenu',
|
||||
'onCopy',
|
||||
'onCut',
|
||||
'onDblclick',
|
||||
'onDeactivate',
|
||||
'onDirty',
|
||||
'onDrag',
|
||||
'onDragDrop',
|
||||
'onDragEnd',
|
||||
'onDragGesture',
|
||||
'onDragOver',
|
||||
'onDrop',
|
||||
'onExecCommand',
|
||||
'onFocus',
|
||||
'onFocusIn',
|
||||
'onFocusOut',
|
||||
'onGetContent',
|
||||
'onHide',
|
||||
'onInit',
|
||||
'onKeyDown',
|
||||
'onKeyPress',
|
||||
'onKeyUp',
|
||||
'onLoadContent',
|
||||
'onMouseDown',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave',
|
||||
'onMouseMove',
|
||||
'onMouseOut',
|
||||
'onMouseOver',
|
||||
'onMouseUp',
|
||||
'onNodeChange',
|
||||
'onObjectResized',
|
||||
'onObjectResizeStart',
|
||||
'onObjectSelected',
|
||||
'onPaste',
|
||||
'onPostProcess',
|
||||
'onPostRender',
|
||||
'onPreProcess',
|
||||
'onProgressState',
|
||||
'onRedo',
|
||||
'onRemove',
|
||||
'onReset',
|
||||
'onSaveContent',
|
||||
'onSelectionChange',
|
||||
'onSetAttrib',
|
||||
'onSetContent',
|
||||
'onShow',
|
||||
'onSubmit',
|
||||
'onUndo',
|
||||
'onVisualAid',
|
||||
]);
|
||||
|
||||
const isValidKey = (key: string) => validEvents.has(key);
|
||||
|
||||
export const bindHandlers = (
|
||||
initEvent: Event,
|
||||
listeners: any,
|
||||
editor: any,
|
||||
): void => {
|
||||
Object.keys(listeners)
|
||||
.filter((element) => isValidKey(element))
|
||||
.forEach((key: string) => {
|
||||
const handler = listeners[key];
|
||||
if (typeof handler === 'function') {
|
||||
if (key === 'onInit') {
|
||||
handler(initEvent, editor);
|
||||
} else {
|
||||
editor.on(key.slice(2), (e: any) => handler(e, editor));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
@@ -1,115 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'TinymceImageUpload' });
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
fullscreen: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['uploading', 'done', 'error']);
|
||||
|
||||
let uploading = false;
|
||||
|
||||
const { apiURL, clientId } = useAppConfig(
|
||||
import.meta.env,
|
||||
import.meta.env.PROD,
|
||||
);
|
||||
const accessStore = useAccessStore();
|
||||
const uploadUrl = `${apiURL}/resource/oss/upload`;
|
||||
// 使用upload组件只能这样上传
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessStore.accessToken}`,
|
||||
clientId,
|
||||
};
|
||||
|
||||
const getButtonProps = computed(() => {
|
||||
const { disabled } = props;
|
||||
return {
|
||||
disabled,
|
||||
};
|
||||
});
|
||||
|
||||
function handleChange(info: Record<string, any>) {
|
||||
const file = info.file;
|
||||
const status = file?.status;
|
||||
// const url = file?.response?.data.url;
|
||||
const name = file?.name;
|
||||
|
||||
switch (status) {
|
||||
case 'done': {
|
||||
// http 200会走到这里 需要再次判断
|
||||
const { response } = file;
|
||||
const { code, data, msg = '服务器错误' } = response;
|
||||
if (code === 200) {
|
||||
const { url } = data;
|
||||
emit('done', name, url);
|
||||
} else {
|
||||
message.error(msg);
|
||||
}
|
||||
// emit('done', name, url);
|
||||
uploading = false;
|
||||
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
emit('error');
|
||||
uploading = false;
|
||||
|
||||
break;
|
||||
}
|
||||
case 'uploading': {
|
||||
if (!uploading) {
|
||||
emit('uploading', name);
|
||||
uploading = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :class="[{ fullscreen }]" class="tinymce-image-upload">
|
||||
<Upload
|
||||
:action="uploadUrl"
|
||||
:headers="headers"
|
||||
:show-upload-list="false"
|
||||
accept=".jpg,.jpeg,.gif,.png,.webp"
|
||||
multiple
|
||||
name="file"
|
||||
@change="handleChange"
|
||||
>
|
||||
<!-- 这里要改成i18n -->
|
||||
<a-button type="primary" v-bind="{ ...getButtonProps }">
|
||||
图片上传
|
||||
</a-button>
|
||||
</Upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tinymce-image-upload {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 10px;
|
||||
z-index: 20;
|
||||
|
||||
&.fullscreen {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -317,7 +317,7 @@ function getKeys(records: MenuPermissionOption[], addCurrent: boolean) {
|
||||
function getCheckedKeys() {
|
||||
// 节点关联
|
||||
if (association.value) {
|
||||
const records = tableApi?.grid?.getCheckboxRecords?.() ?? [];
|
||||
const records = tableApi?.grid?.getCheckboxRecords?.(true) ?? [];
|
||||
// 子节点
|
||||
const nodeKeys = getKeys(records, true);
|
||||
// 所有父节点
|
||||
@@ -329,7 +329,7 @@ function getCheckedKeys() {
|
||||
// 节点独立
|
||||
|
||||
// 勾选的行
|
||||
const records = tableApi?.grid?.getCheckboxRecords?.() ?? [];
|
||||
const records = tableApi?.grid?.getCheckboxRecords?.(true) ?? [];
|
||||
// 全部数据 用于获取permissions
|
||||
const allRecords = tableApi?.grid?.getData?.() ?? [];
|
||||
// 表格已经选中的行ids
|
||||
|
8
apps/web-antd/src/components/upload-old/index.ts
Normal file
8
apps/web-antd/src/components/upload-old/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @description: 旧版文件上传组件 使用FileUpload代替
|
||||
*/
|
||||
export { default as FileUploadOld } from './src/file-upload.vue';
|
||||
/**
|
||||
* @description: 旧版图片上传组件 使用ImageUpload代替
|
||||
*/
|
||||
export { default as ImageUploadOld } from './src/image-upload.vue';
|
240
apps/web-antd/src/components/upload-old/src/file-upload.vue
Normal file
240
apps/web-antd/src/components/upload-old/src/file-upload.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
||||
|
||||
import type { AxiosProgressEvent, UploadApi } from '#/api';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { isArray, isFunction, isObject, isString } from 'lodash-es';
|
||||
|
||||
import { uploadApi } from '#/api';
|
||||
|
||||
import { checkFileType } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* 建议使用拓展名(不带.)
|
||||
* 或者文件头 image/png等(测试判断不准确) 不支持image/*类似的写法
|
||||
* 需自行改造 ./helper/checkFileType方法
|
||||
*/
|
||||
accept?: string[];
|
||||
api?: UploadApi;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
// 返回的字段 默认url
|
||||
resultField?: 'fileName' | 'ossId' | 'url' | string;
|
||||
/**
|
||||
* 是否显示下面的描述
|
||||
*/
|
||||
showDescription?: boolean;
|
||||
value?: string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
disabled: false,
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => [],
|
||||
multiple: false,
|
||||
api: () => uploadApi,
|
||||
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<UploadProps['fileList']>([]);
|
||||
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 (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: 'done',
|
||||
url: item,
|
||||
};
|
||||
} else if (item && isObject(item)) {
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}) as UploadProps['fileList'];
|
||||
}
|
||||
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 = await checkFileType(file, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('component.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
}
|
||||
return (isAct && !isLt) || Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
async function customRequest(info: UploadRequestOption<any>) {
|
||||
const { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
console.warn('upload api must exist and be a function');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 进度条事件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
info.onProgress!({ percent });
|
||||
};
|
||||
const res = await api?.(info.file as File, {
|
||||
onUploadProgress: progressEvent,
|
||||
});
|
||||
/**
|
||||
* 由getValue处理 传对象过去
|
||||
* 直接传string(id)会被转为Number
|
||||
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
|
||||
*/
|
||||
info.onSuccess!(res);
|
||||
message.success($t('component.upload.uploadSuccess'));
|
||||
// 获取
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('change', value);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
info.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?.[props.resultField];
|
||||
}
|
||||
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
|
||||
if (item?.url) {
|
||||
return item.url;
|
||||
}
|
||||
// 注意这里取的key为 url
|
||||
return item?.response?.url;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Upload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:max-count="maxNumber"
|
||||
:multiple="multiple"
|
||||
list-type="text"
|
||||
:progress="{ showInfo: true }"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div v-if="fileList && fileList.length < maxNumber">
|
||||
<a-button>
|
||||
<UploadOutlined />
|
||||
{{ $t('component.upload.upload') }}
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="showDescription" class="mt-2 flex flex-wrap items-center">
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
</div>
|
||||
</Upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.ant-upload-select-picture-card i {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card .ant-upload-text {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
51
apps/web-antd/src/components/upload-old/src/helper.ts
Normal file
51
apps/web-antd/src/components/upload-old/src/helper.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { fileTypeFromBlob } from '@vben/utils';
|
||||
|
||||
/**
|
||||
* 不支持txt文件 @see https://github.com/sindresorhus/file-type/issues/55
|
||||
* 需要自行修改
|
||||
* @param file file对象
|
||||
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
||||
* @returns 是否通过文件类型校验
|
||||
*/
|
||||
export async function checkFileType(file: File, accepts: string[]) {
|
||||
if (!accepts || accepts?.length === 0) {
|
||||
return true;
|
||||
}
|
||||
console.log(file);
|
||||
const fileType = await fileTypeFromBlob(file);
|
||||
if (!fileType) {
|
||||
console.error('无法获取文件类型');
|
||||
return false;
|
||||
}
|
||||
console.log('文件类型', fileType);
|
||||
// 是否文件拓展名/文件头任意有一个匹配
|
||||
return accepts.includes(fileType.ext) || accepts.includes(fileType.mime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认图片类型
|
||||
*/
|
||||
export const defaultImageAccept = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
/**
|
||||
* 判断文件类型是否符合要求
|
||||
* @param file file对象
|
||||
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
||||
* @returns 是否通过文件类型校验
|
||||
*/
|
||||
export async function checkImageFileType(file: File, accepts: string[]) {
|
||||
// 空的accepts 使用默认规则
|
||||
if (!accepts || accepts.length === 0) {
|
||||
accepts = defaultImageAccept;
|
||||
}
|
||||
const fileType = await fileTypeFromBlob(file);
|
||||
if (!fileType) {
|
||||
console.error('无法获取文件类型');
|
||||
return false;
|
||||
}
|
||||
console.log('文件类型', fileType);
|
||||
// 是否文件拓展名/文件头任意有一个匹配
|
||||
if (accepts.includes(fileType.ext) || accepts.includes(fileType.mime)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
323
apps/web-antd/src/components/upload-old/src/image-upload.vue
Normal file
323
apps/web-antd/src/components/upload-old/src/image-upload.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
||||
|
||||
import type { AxiosProgressEvent, UploadApi } from '#/api';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { message, Modal, Upload } from 'ant-design-vue';
|
||||
import { isArray, isFunction, isObject, isString, uniqueId } from 'lodash-es';
|
||||
|
||||
import { uploadApi } from '#/api';
|
||||
import { ossInfo } from '#/api/system/oss';
|
||||
|
||||
import { checkImageFileType, defaultImageAccept } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
||||
*/
|
||||
accept?: string[];
|
||||
api?: UploadApi;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
listType?: ListType;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
// 返回的字段 默认url
|
||||
resultField?: 'fileName' | 'ossId' | 'url';
|
||||
/**
|
||||
* 是否显示下面的描述
|
||||
*/
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
disabled: false,
|
||||
listType: 'picture-card',
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => defaultImageAccept,
|
||||
multiple: false,
|
||||
api: () => uploadApi,
|
||||
resultField: 'url',
|
||||
showDescription: true,
|
||||
},
|
||||
);
|
||||
const emit = defineEmits(['change', 'update:value', 'delete']);
|
||||
type ListType = 'picture' | 'picture-card' | 'text';
|
||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
});
|
||||
const previewOpen = ref<boolean>(false);
|
||||
const previewImage = ref<string>('');
|
||||
const previewTitle = ref<string>('');
|
||||
|
||||
const fileList = ref<UploadProps['fileList']>([]);
|
||||
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) {
|
||||
const _fileList: string[] = [];
|
||||
if (isString(v)) {
|
||||
_fileList.push(v);
|
||||
}
|
||||
if (isArray(v)) {
|
||||
_fileList.push(...v);
|
||||
}
|
||||
// 直接赋值 可能为string | string[]
|
||||
value = v;
|
||||
const withUrlList: UploadProps['fileList'] = [];
|
||||
for (const item of _fileList) {
|
||||
// ossId情况
|
||||
if (props.resultField === 'ossId') {
|
||||
const resp = await ossInfo([item]);
|
||||
if (item && isString(item)) {
|
||||
withUrlList.push({
|
||||
uid: item, // ossId作为uid 方便getValue获取
|
||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
||||
status: 'done',
|
||||
url: resp?.[0]?.url,
|
||||
});
|
||||
} else if (item && isObject(item)) {
|
||||
withUrlList.push({
|
||||
...(item as any),
|
||||
uid: item,
|
||||
url: resp?.[0]?.url,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 非ossId情况
|
||||
if (item && isString(item)) {
|
||||
withUrlList.push({
|
||||
uid: uniqueId(),
|
||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
||||
status: 'done',
|
||||
url: item,
|
||||
});
|
||||
} else if (item && isObject(item)) {
|
||||
withUrlList.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
fileList.value = withUrlList;
|
||||
}
|
||||
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 && !file.preview) {
|
||||
file.preview = await getBase64<string>(file.originFileObj!);
|
||||
}
|
||||
previewImage.value = file.url || file.preview || '';
|
||||
previewOpen.value = true;
|
||||
previewTitle.value =
|
||||
file.name ||
|
||||
previewImage.value.slice(
|
||||
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
|
||||
);
|
||||
};
|
||||
|
||||
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 handleCancel = () => {
|
||||
previewOpen.value = false;
|
||||
previewTitle.value = '';
|
||||
};
|
||||
|
||||
const beforeUpload = async (file: File) => {
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = await checkImageFileType(file, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('component.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
}
|
||||
return (isAct && !isLt) || Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
async function customRequest(info: UploadRequestOption<any>) {
|
||||
const { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
console.warn('upload api must exist and be a function');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 进度条事件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
info.onProgress!({ percent });
|
||||
};
|
||||
const res = await api?.(info.file as File, {
|
||||
onUploadProgress: progressEvent,
|
||||
});
|
||||
/**
|
||||
* 由getValue处理 传对象过去
|
||||
* 直接传string(id)会被转为Number
|
||||
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
|
||||
*/
|
||||
info.onSuccess!(res);
|
||||
message.success($t('component.upload.uploadSuccess'));
|
||||
// 获取
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('change', value);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
info.onError!(error);
|
||||
}
|
||||
}
|
||||
|
||||
function getValue() {
|
||||
console.log(fileList.value);
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === UploadResultStatus.DONE)
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response?.[props.resultField];
|
||||
}
|
||||
// ossId兼容 uid为ossId直接返回
|
||||
if (props.resultField === 'ossId' && item.uid) {
|
||||
return item.uid;
|
||||
}
|
||||
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
|
||||
if (item?.url) {
|
||||
return item.url;
|
||||
}
|
||||
// 注意这里取的key为 url
|
||||
return item?.response?.url;
|
||||
});
|
||||
// 只有一张图片 默认绑定string而非string[]
|
||||
if (props.maxNumber === 1 && list.length === 1) {
|
||||
return list[0];
|
||||
}
|
||||
// 只有一张图片 && 删除图片时 可自行修改
|
||||
if (props.maxNumber === 1 && list.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return list;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Upload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:list-type="listType"
|
||||
:max-count="maxNumber"
|
||||
:multiple="multiple"
|
||||
:progress="{ showInfo: true }"
|
||||
@preview="handlePreview"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div v-if="fileList && fileList.length < maxNumber">
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px">{{ $t('component.upload.upload') }}</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<div
|
||||
v-if="showDescription"
|
||||
class="mt-2 flex flex-wrap items-center text-[14px]"
|
||||
>
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
</div>
|
||||
<Modal
|
||||
:footer="null"
|
||||
:open="previewOpen"
|
||||
:title="previewTitle"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<img :src="previewImage" alt="" style="width: 100%" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.ant-upload-select-picture-card i {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card .ant-upload-text {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
@@ -1,243 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
||||
<!--
|
||||
不再支持url 统一使用ossId
|
||||
去除使用`file-type`库进行文件类型检测 在Safari无法使用
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import type { UploadListType } from 'ant-design-vue/es/upload/interface';
|
||||
|
||||
import type { AxiosResponse } from '@vben/request';
|
||||
import type { BaseUploadProps, UploadEmits } from './props';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
import { $t, I18nT } from '@vben/locales';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { isArray, isFunction, isObject, isString } from 'lodash-es';
|
||||
import { InboxOutlined, UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { Upload } from 'ant-design-vue';
|
||||
|
||||
import { uploadApi } from '#/api';
|
||||
|
||||
import { checkFileType } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUploadType } from './use-upload';
|
||||
import { defaultFileAcceptExts, defaultFilePreview } from './helper';
|
||||
import { useUpload } from './hook';
|
||||
|
||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
||||
interface FileUploadProps extends BaseUploadProps {
|
||||
/**
|
||||
* 同antdv的listType 但是排除picture-card
|
||||
* 文件上传不适合用picture-card显示
|
||||
* @default text
|
||||
*/
|
||||
listType?: Exclude<UploadListType, 'picture-card'>;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* 建议使用拓展名(不带.)
|
||||
* 或者文件头 image/png等(测试判断不准确) 不支持image/*类似的写法
|
||||
* 需自行改造 ./helper/checkFileType方法
|
||||
*/
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: Blob | File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
// 返回的字段 默认url
|
||||
resultField?: 'fileName' | 'ossId' | 'url' | string;
|
||||
/**
|
||||
* 是否显示下面的描述
|
||||
*/
|
||||
showDescription?: boolean;
|
||||
value?: string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
disabled: false,
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => [],
|
||||
multiple: false,
|
||||
api: uploadApi,
|
||||
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 props = withDefaults(defineProps<FileUploadProps>(), {
|
||||
api: () => uploadApi,
|
||||
removeOnError: true,
|
||||
showSuccessMsg: true,
|
||||
removeConfirm: false,
|
||||
accept: defaultFileAcceptExts.join(','),
|
||||
data: () => undefined,
|
||||
maxCount: 1,
|
||||
maxSize: 5,
|
||||
disabled: false,
|
||||
helpMessage: true,
|
||||
preview: defaultFilePreview,
|
||||
enableDragUpload: false,
|
||||
directory: false,
|
||||
abortOnUnmounted: true,
|
||||
listType: 'text',
|
||||
});
|
||||
|
||||
const fileList = ref<UploadProps['fileList']>([]);
|
||||
const isLtMsg = ref<boolean>(true);
|
||||
const isActMsg = ref<boolean>(true);
|
||||
const isFirstRender = ref<boolean>(true);
|
||||
const emit = defineEmits<UploadEmits>();
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(v) => {
|
||||
if (isInnerOperate.value) {
|
||||
isInnerOperate.value = false;
|
||||
return;
|
||||
}
|
||||
let value: string[] = [];
|
||||
if (v) {
|
||||
if (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: 'done',
|
||||
url: item,
|
||||
};
|
||||
} else if (item && isObject(item)) {
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}) as UploadProps['fileList'];
|
||||
}
|
||||
if (!isFirstRender.value) {
|
||||
emit('change', value);
|
||||
isFirstRender.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
/** 返回不同的上传组件 */
|
||||
const CurrentUploadComponent = computed(() => {
|
||||
if (props.enableDragUpload) {
|
||||
return Upload.Dragger;
|
||||
}
|
||||
return Upload;
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
// 双向绑定 ossId
|
||||
const ossIdList = defineModel<string | string[]>('value', {
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const beforeUpload = async (file: File) => {
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = await checkFileType(file, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('component.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
}
|
||||
return (isAct && !isLt) || Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
async function customRequest(info: UploadRequestOption<any>) {
|
||||
const { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
console.warn('upload api must exist and be a function');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 进度条事件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
info.onProgress!({ percent });
|
||||
};
|
||||
const res = await api?.(info.file as File, progressEvent);
|
||||
/**
|
||||
* 由getValue处理 传对象过去
|
||||
* 直接传string(id)会被转为Number
|
||||
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
|
||||
*/
|
||||
info.onSuccess!(res);
|
||||
message.success($t('component.upload.uploadSuccess'));
|
||||
// 获取
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('change', value);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
info.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?.[props.resultField];
|
||||
}
|
||||
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
|
||||
if (item?.url) {
|
||||
return item.url;
|
||||
}
|
||||
// 注意这里取的key为 url
|
||||
return item?.response?.url;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
const {
|
||||
customRequest,
|
||||
acceptStr,
|
||||
handleChange,
|
||||
handleRemove,
|
||||
beforeUpload,
|
||||
innerFileList,
|
||||
} = useUpload(props, emit, ossIdList, 'file');
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Upload.Dragger只会影响样式
|
||||
使用普通Upload也是支持拖拽上传的
|
||||
-->
|
||||
<template>
|
||||
<div>
|
||||
<Upload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
<CurrentUploadComponent
|
||||
v-model:file-list="innerFileList"
|
||||
:accept="accept"
|
||||
:list-type="listType"
|
||||
:disabled="disabled"
|
||||
:directory="directory"
|
||||
:max-count="maxCount"
|
||||
:progress="{ showInfo: true }"
|
||||
:multiple="multiple"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:max-count="maxNumber"
|
||||
:multiple="multiple"
|
||||
list-type="text"
|
||||
:progress="{ showInfo: true }"
|
||||
@preview="preview"
|
||||
@change="handleChange"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div v-if="fileList && fileList.length < maxNumber">
|
||||
<a-button>
|
||||
<div v-if="!enableDragUpload && innerFileList?.length < maxCount">
|
||||
<a-button :disabled="disabled">
|
||||
<UploadOutlined />
|
||||
{{ $t('component.upload.upload') }}
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="showDescription" class="mt-2 flex flex-wrap items-center">
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
<div v-if="enableDragUpload && innerFileList?.length < maxCount">
|
||||
<p class="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p class="ant-upload-text">
|
||||
{{ $t('component.upload.clickOrDrag') }}
|
||||
</p>
|
||||
</div>
|
||||
</Upload>
|
||||
</CurrentUploadComponent>
|
||||
<slot name="helpMessage" v-bind="{ maxCount, disabled, maxSize, accept }">
|
||||
<I18nT
|
||||
v-if="helpMessage"
|
||||
scope="global"
|
||||
keypath="component.upload.uploadHelpMessage"
|
||||
tag="div"
|
||||
class="mt-2"
|
||||
:class="{ 'upload-text__disabled': disabled }"
|
||||
>
|
||||
<template #size>
|
||||
<span
|
||||
class="text-primary mx-1 font-medium"
|
||||
:class="{ 'upload-text__disabled': disabled }"
|
||||
>
|
||||
{{ maxSize }}MB
|
||||
</span>
|
||||
</template>
|
||||
<template #ext>
|
||||
<span
|
||||
class="text-primary mx-1 font-medium"
|
||||
:class="{ 'upload-text__disabled': disabled }"
|
||||
>
|
||||
{{ acceptStr }}
|
||||
</span>
|
||||
</template>
|
||||
</I18nT>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.ant-upload-select-picture-card i {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
}
|
||||
<style lang="scss">
|
||||
// 禁用的样式和antd保持一致
|
||||
.upload-text__disabled {
|
||||
color: rgb(50 54 57 / 25%);
|
||||
cursor: not-allowed;
|
||||
|
||||
.ant-upload-select-picture-card .ant-upload-text {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
&:where(.dark, .dark *) {
|
||||
color: rgb(242 242 242 / 25%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,51 +1,28 @@
|
||||
import { fileTypeFromBlob } from '@vben/utils';
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
/**
|
||||
* 不支持txt文件 @see https://github.com/sindresorhus/file-type/issues/55
|
||||
* 需要自行修改
|
||||
* @param file file对象
|
||||
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
||||
* @returns 是否通过文件类型校验
|
||||
* 默认支持上传的图片文件类型
|
||||
*/
|
||||
export async function checkFileType(file: File, accepts: string[]) {
|
||||
if (!accepts || accepts?.length === 0) {
|
||||
return true;
|
||||
}
|
||||
console.log(file);
|
||||
const fileType = await fileTypeFromBlob(file);
|
||||
if (!fileType) {
|
||||
console.error('无法获取文件类型');
|
||||
return false;
|
||||
}
|
||||
console.log('文件类型', fileType);
|
||||
// 是否文件拓展名/文件头任意有一个匹配
|
||||
return accepts.includes(fileType.ext) || accepts.includes(fileType.mime);
|
||||
}
|
||||
export const defaultImageAcceptExts = [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
];
|
||||
|
||||
/**
|
||||
* 默认图片类型
|
||||
* 默认支持上传的文件类型
|
||||
*/
|
||||
export const defaultImageAccept = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
export const defaultFileAcceptExts = ['.xlsx', '.csv', '.docx', '.pdf'];
|
||||
|
||||
/**
|
||||
* 判断文件类型是否符合要求
|
||||
* @param file file对象
|
||||
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
||||
* @returns 是否通过文件类型校验
|
||||
* 文件(非图片)的默认预览逻辑
|
||||
* 默认: window.open打开 交给浏览器接管
|
||||
* @param file file
|
||||
*/
|
||||
export async function checkImageFileType(file: File, accepts: string[]) {
|
||||
// 空的accepts 使用默认规则
|
||||
if (!accepts || accepts.length === 0) {
|
||||
accepts = defaultImageAccept;
|
||||
export function defaultFilePreview(file: UploadFile) {
|
||||
if (file?.url) {
|
||||
window.open(file.url);
|
||||
}
|
||||
const fileType = await fileTypeFromBlob(file);
|
||||
if (!fileType) {
|
||||
console.error('无法获取文件类型');
|
||||
return false;
|
||||
}
|
||||
console.log('文件类型', fileType);
|
||||
// 是否文件拓展名/文件头任意有一个匹配
|
||||
if (accepts.includes(fileType.ext) || accepts.includes(fileType.mime)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
372
apps/web-antd/src/components/upload/src/hook.ts
Normal file
372
apps/web-antd/src/components/upload/src/hook.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import type { UploadChangeParam, UploadFile } from 'ant-design-vue';
|
||||
import type { FileType } from 'ant-design-vue/es/upload/interface';
|
||||
import type {
|
||||
RcFile,
|
||||
UploadRequestOption,
|
||||
} from 'ant-design-vue/es/vc-upload/interface';
|
||||
|
||||
import type { ModelRef } from 'vue';
|
||||
|
||||
import type {
|
||||
BaseUploadProps,
|
||||
CustomGetter,
|
||||
UploadEmits,
|
||||
UploadType,
|
||||
} from './props';
|
||||
|
||||
import type { AxiosProgressEvent, UploadResult } from '#/api';
|
||||
import type { OssFile } from '#/api/system/oss/model';
|
||||
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { isFunction, isString } from 'lodash-es';
|
||||
|
||||
import { ossInfo } from '#/api/system/oss';
|
||||
|
||||
/**
|
||||
* 图片预览hook
|
||||
* @returns 预览
|
||||
*/
|
||||
export function useImagePreview() {
|
||||
/**
|
||||
* 获取base64字符串
|
||||
* @param file 文件
|
||||
* @returns base64字符串
|
||||
*/
|
||||
function getBase64(file: File) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.addEventListener('load', () => resolve(reader.result));
|
||||
reader.addEventListener('error', (error) => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
// Modal可见
|
||||
const previewVisible = ref(false);
|
||||
// 预览的图片 url/base64
|
||||
const previewImage = ref('');
|
||||
// 预览的图片名称
|
||||
const previewTitle = ref('');
|
||||
|
||||
function handleCancel() {
|
||||
previewVisible.value = false;
|
||||
previewTitle.value = '';
|
||||
}
|
||||
|
||||
async function handlePreview(file: UploadFile) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
// 文件预览 取base64
|
||||
if (!file.url && !file.preview && file.originFileObj) {
|
||||
file.preview = (await getBase64(file.originFileObj)) as string;
|
||||
}
|
||||
// 这里不可能为空
|
||||
const url = file.url ?? '';
|
||||
previewImage.value = url || file.preview || '';
|
||||
previewVisible.value = true;
|
||||
previewTitle.value =
|
||||
file.name || url.slice(Math.max(0, url.lastIndexOf('/') + 1));
|
||||
}
|
||||
|
||||
return {
|
||||
previewVisible,
|
||||
previewImage,
|
||||
previewTitle,
|
||||
handleCancel,
|
||||
handlePreview,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传和文件上传的通用hook
|
||||
* @param props 组件props
|
||||
* @param emit 事件
|
||||
* @param bindValue 双向绑定的idList
|
||||
* @param uploadType 区分是文件还是图片上传
|
||||
* @returns hook
|
||||
*/
|
||||
export function useUpload(
|
||||
props: Readonly<BaseUploadProps>,
|
||||
emit: UploadEmits,
|
||||
bindValue: ModelRef<string | string[]>,
|
||||
uploadType: UploadType,
|
||||
) {
|
||||
// 组件内部维护fileList
|
||||
const innerFileList = ref<UploadFile[]>([]);
|
||||
|
||||
const acceptStr = computed(() => {
|
||||
// string类型
|
||||
if (isString(props.acceptFormat)) {
|
||||
return props.acceptFormat;
|
||||
}
|
||||
// 函数类型
|
||||
if (isFunction(props.acceptFormat)) {
|
||||
return props.acceptFormat(props.accept!);
|
||||
}
|
||||
// 默认 会对拓展名做处理
|
||||
return props.accept
|
||||
?.split(',')
|
||||
.map((item) => {
|
||||
if (item.startsWith('.')) {
|
||||
return item.slice(1);
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.join(', ');
|
||||
});
|
||||
|
||||
/**
|
||||
* 自定义文件显示名称 需要区分不同的接口
|
||||
* @param cb callback
|
||||
* @returns 文件名
|
||||
*/
|
||||
function transformFilename(cb: Parameters<CustomGetter<string>>[0]) {
|
||||
if (isFunction(props.customFilename)) {
|
||||
return props.customFilename(cb);
|
||||
}
|
||||
// info接口
|
||||
if (cb.type === 'info') {
|
||||
return cb.response.originalName;
|
||||
}
|
||||
// 上传接口
|
||||
return cb.response.fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义缩略图 需要区分不同的接口
|
||||
* @param cb callback
|
||||
* @returns 缩略图地址
|
||||
*/
|
||||
function transformThumbUrl(cb: Parameters<CustomGetter<undefined>>[0]) {
|
||||
if (isFunction(props.customThumbUrl)) {
|
||||
return props.customThumbUrl(cb);
|
||||
}
|
||||
// image 默认返回图片链接
|
||||
if (uploadType === 'image') {
|
||||
// info接口
|
||||
if (cb.type === 'info') {
|
||||
return cb.response.url;
|
||||
}
|
||||
// 上传接口
|
||||
return cb.response.url;
|
||||
}
|
||||
// 文件默认返回空 走antd默认的预览图逻辑
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function handleChange(info: UploadChangeParam) {
|
||||
/**
|
||||
* 移除当前文件
|
||||
* @param currentFile 当前文件
|
||||
* @param currentFileList 当前所有文件list
|
||||
*/
|
||||
function removeCurrentFile(
|
||||
currentFile: UploadChangeParam['file'],
|
||||
currentFileList: UploadChangeParam['fileList'],
|
||||
) {
|
||||
if (props.removeOnError) {
|
||||
currentFileList.splice(currentFileList.indexOf(currentFile), 1);
|
||||
} else {
|
||||
currentFile.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
const { file: currentFile, fileList } = info;
|
||||
|
||||
switch (currentFile.status) {
|
||||
// 上传成功 只是判断httpStatus 200 需要手动判断业务code
|
||||
case 'done': {
|
||||
if (!currentFile.response) {
|
||||
return;
|
||||
}
|
||||
// 获取返回结果 为customRequest的reslove参数
|
||||
// 只有success才会走到这里
|
||||
const { ossId, url } = currentFile.response as UploadResult;
|
||||
currentFile.url = url;
|
||||
currentFile.uid = ossId;
|
||||
|
||||
const cb = {
|
||||
type: 'upload',
|
||||
response: currentFile.response as UploadResult,
|
||||
} as const;
|
||||
|
||||
currentFile.fileName = transformFilename(cb);
|
||||
currentFile.name = transformFilename(cb);
|
||||
currentFile.thumbUrl = transformThumbUrl(cb);
|
||||
|
||||
// ossID添加 单个文件会被当做string
|
||||
if (props.maxCount === 1) {
|
||||
bindValue.value = ossId;
|
||||
} else {
|
||||
// 给默认值
|
||||
if (!Array.isArray(bindValue.value)) {
|
||||
bindValue.value = [];
|
||||
}
|
||||
// 直接使用.value无法触发useForm的更新(原生是正常的) 需要修改地址
|
||||
bindValue.value = [...bindValue.value, ossId];
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 上传失败 网络原因导致httpStatus 不等于200
|
||||
case 'error': {
|
||||
removeCurrentFile(currentFile, fileList);
|
||||
}
|
||||
}
|
||||
emit('change', info);
|
||||
}
|
||||
|
||||
function handleRemove(currentFile: UploadFile) {
|
||||
function remove() {
|
||||
// fileList会自行处理删除 这里只需要处理ossId
|
||||
if (props.maxCount === 1) {
|
||||
bindValue.value = '';
|
||||
} else {
|
||||
(bindValue.value as string[]).splice(
|
||||
bindValue.value.indexOf(currentFile.uid),
|
||||
1,
|
||||
);
|
||||
}
|
||||
// 触发remove事件
|
||||
emit('remove', currentFile);
|
||||
}
|
||||
|
||||
if (!props.removeConfirm) {
|
||||
remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: $t('pages.common.tip'),
|
||||
content: $t('component.upload.confirmDelete', [currentFile.name]),
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk() {
|
||||
resolve(true);
|
||||
remove();
|
||||
},
|
||||
onCancel() {
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前检测文件大小
|
||||
* 拖拽时候前置会有浏览器自身的accept校验 校验失败不会执行此方法
|
||||
* @param file file
|
||||
* @returns file | false
|
||||
*/
|
||||
function beforeUpload(file: FileType) {
|
||||
const isLtMax = file.size / 1024 / 1024 < props.maxSize!;
|
||||
if (!isLtMax) {
|
||||
message.error($t('component.upload.maxSize', [props.maxSize]));
|
||||
return false;
|
||||
}
|
||||
// 大坑 Safari不支持file-type库 去除文件类型的校验
|
||||
return file;
|
||||
}
|
||||
|
||||
const uploadAbort = new AbortController();
|
||||
/**
|
||||
* 自定义上传实现
|
||||
* @param info
|
||||
*/
|
||||
async function customRequest(info: UploadRequestOption<any>) {
|
||||
const { api } = props;
|
||||
if (!isFunction(api)) {
|
||||
console.warn('upload api must exist and be a function');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 进度条事件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
info.onProgress!({ percent });
|
||||
};
|
||||
const res = await api(info.file as File, {
|
||||
onUploadProgress: progressEvent,
|
||||
signal: uploadAbort.signal,
|
||||
otherData: props?.data,
|
||||
});
|
||||
info.onSuccess!(res);
|
||||
if (props.showSuccessMsg) {
|
||||
message.success($t('component.upload.uploadSuccess'));
|
||||
}
|
||||
emit('success', info.file as RcFile, res);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
info.onError!(error);
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
props.abortOnUnmounted && uploadAbort.abort();
|
||||
});
|
||||
|
||||
/**
|
||||
* 这里默认只监听list地址变化 即重新赋值才会触发watch
|
||||
* immediate用于初始化触发
|
||||
*/
|
||||
watch(
|
||||
() => bindValue.value,
|
||||
async (value) => {
|
||||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
const resp = await ossInfo(value);
|
||||
function transformFile(info: OssFile) {
|
||||
const cb = { type: 'info', response: info } as const;
|
||||
|
||||
const fileitem: UploadFile = {
|
||||
uid: info.ossId,
|
||||
name: transformFilename(cb),
|
||||
fileName: transformFilename(cb),
|
||||
url: info.url,
|
||||
thumbUrl: transformThumbUrl(cb),
|
||||
status: 'done',
|
||||
};
|
||||
return fileitem;
|
||||
}
|
||||
const transformOptions = resp.map((item) => transformFile(item));
|
||||
innerFileList.value = transformOptions;
|
||||
// 单文件 丢弃策略
|
||||
if (props.maxCount === 1 && resp.length === 0 && !props.keepMissingId) {
|
||||
bindValue.value = '';
|
||||
return;
|
||||
}
|
||||
// 多文件
|
||||
// 单文件查到了也会走这里的逻辑 filter会报错 需要maxCount判断处理
|
||||
if (
|
||||
resp.length !== value.length &&
|
||||
!props.keepMissingId &&
|
||||
props.maxCount !== 1
|
||||
) {
|
||||
// 给默认值
|
||||
if (!Array.isArray(bindValue.value)) {
|
||||
bindValue.value = [];
|
||||
}
|
||||
bindValue.value = bindValue.value.filter((ossId) =>
|
||||
resp.map((res) => res.ossId).includes(ossId),
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
handleChange,
|
||||
handleRemove,
|
||||
beforeUpload,
|
||||
customRequest,
|
||||
innerFileList,
|
||||
acceptStr,
|
||||
};
|
||||
}
|
@@ -1,326 +1,190 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
||||
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
|
||||
<!--
|
||||
不再支持url 统一使用ossId
|
||||
去除使用`file-type`库进行文件类型检测 在Safari无法使用
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
UploadFile,
|
||||
UploadListType,
|
||||
} from 'ant-design-vue/es/upload/interface';
|
||||
|
||||
import type { AxiosResponse } from '@vben/request';
|
||||
import type { BaseUploadProps, UploadEmits } from './props';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api';
|
||||
import { $t, I18nT } from '@vben/locales';
|
||||
|
||||
import { ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { message, Modal, Upload } from 'ant-design-vue';
|
||||
import { isArray, isFunction, isObject, isString, uniqueId } from 'lodash-es';
|
||||
import { PlusOutlined, UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { Image, ImagePreviewGroup, Upload } from 'ant-design-vue';
|
||||
import { isFunction } from 'lodash-es';
|
||||
|
||||
import { uploadApi } from '#/api';
|
||||
import { ossInfo } from '#/api/system/oss';
|
||||
|
||||
import { checkImageFileType, defaultImageAccept } from './helper';
|
||||
import { UploadResultStatus } from './typing';
|
||||
import { useUploadType } from './use-upload';
|
||||
import { defaultImageAcceptExts } from './helper';
|
||||
import { useImagePreview, useUpload } from './hook';
|
||||
|
||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
||||
interface ImageUploadProps extends BaseUploadProps {
|
||||
/**
|
||||
* 同antdv的listType
|
||||
* @default picture-card
|
||||
*/
|
||||
listType?: UploadListType;
|
||||
/**
|
||||
* 使用list-type: picture-card时 是否显示动画
|
||||
* 会有一个`弹跳`的效果 默认关闭
|
||||
* @default false
|
||||
*/
|
||||
withAnimation?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
|
||||
*/
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: Blob | File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
listType?: ListType;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
// 返回的字段 默认url
|
||||
resultField?: 'fileName' | 'ossId' | 'url';
|
||||
/**
|
||||
* 是否显示下面的描述
|
||||
*/
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
disabled: false,
|
||||
listType: 'picture-card',
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => defaultImageAccept,
|
||||
multiple: false,
|
||||
api: uploadApi,
|
||||
resultField: 'url',
|
||||
showDescription: true,
|
||||
},
|
||||
);
|
||||
const emit = defineEmits(['change', 'update:value', 'delete']);
|
||||
type ListType = 'picture' | 'picture-card' | 'text';
|
||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
const props = withDefaults(defineProps<ImageUploadProps>(), {
|
||||
api: () => uploadApi,
|
||||
removeOnError: true,
|
||||
showSuccessMsg: true,
|
||||
removeConfirm: false,
|
||||
accept: defaultImageAcceptExts.join(','),
|
||||
data: () => undefined,
|
||||
maxCount: 1,
|
||||
maxSize: 5,
|
||||
disabled: false,
|
||||
listType: 'picture-card',
|
||||
helpMessage: true,
|
||||
enableDragUpload: false,
|
||||
abortOnUnmounted: true,
|
||||
withAnimation: false,
|
||||
});
|
||||
const previewOpen = ref<boolean>(false);
|
||||
const previewImage = ref<string>('');
|
||||
const previewTitle = ref<string>('');
|
||||
|
||||
const fileList = ref<UploadProps['fileList']>([]);
|
||||
const isLtMsg = ref<boolean>(true);
|
||||
const isActMsg = ref<boolean>(true);
|
||||
const isFirstRender = ref<boolean>(true);
|
||||
const emit = defineEmits<UploadEmits>();
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
async (v) => {
|
||||
if (isInnerOperate.value) {
|
||||
isInnerOperate.value = false;
|
||||
return;
|
||||
}
|
||||
let value: string | string[] = [];
|
||||
if (v) {
|
||||
const _fileList: string[] = [];
|
||||
if (isString(v)) {
|
||||
_fileList.push(v);
|
||||
}
|
||||
if (isArray(v)) {
|
||||
_fileList.push(...v);
|
||||
}
|
||||
// 直接赋值 可能为string | string[]
|
||||
value = v;
|
||||
const withUrlList: UploadProps['fileList'] = [];
|
||||
for (const item of _fileList) {
|
||||
// ossId情况
|
||||
if (props.resultField === 'ossId') {
|
||||
const resp = await ossInfo([item]);
|
||||
if (item && isString(item)) {
|
||||
withUrlList.push({
|
||||
uid: item, // ossId作为uid 方便getValue获取
|
||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
||||
status: 'done',
|
||||
url: resp?.[0]?.url,
|
||||
});
|
||||
} else if (item && isObject(item)) {
|
||||
withUrlList.push({
|
||||
...(item as any),
|
||||
uid: item,
|
||||
url: resp?.[0]?.url,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 非ossId情况
|
||||
if (item && isString(item)) {
|
||||
withUrlList.push({
|
||||
uid: uniqueId(),
|
||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
||||
status: 'done',
|
||||
url: item,
|
||||
});
|
||||
} else if (item && isObject(item)) {
|
||||
withUrlList.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
fileList.value = withUrlList;
|
||||
}
|
||||
if (!isFirstRender.value) {
|
||||
emit('change', value);
|
||||
isFirstRender.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
// 双向绑定 ossId
|
||||
const ossIdList = defineModel<string | string[]>('value', {
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
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 {
|
||||
acceptStr,
|
||||
handleChange,
|
||||
handleRemove,
|
||||
beforeUpload,
|
||||
innerFileList,
|
||||
customRequest,
|
||||
} = useUpload(props, emit, ossIdList, 'image');
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
if (!file.url && !file.preview) {
|
||||
file.preview = await getBase64<string>(file.originFileObj!);
|
||||
const { previewVisible, previewImage, handleCancel, handlePreview } =
|
||||
useImagePreview();
|
||||
|
||||
function currentPreview(file: UploadFile) {
|
||||
// 有自定义预览逻辑走自定义
|
||||
if (isFunction(props.preview)) {
|
||||
return props.preview(file);
|
||||
}
|
||||
previewImage.value = file.url || file.preview || '';
|
||||
previewOpen.value = true;
|
||||
previewTitle.value =
|
||||
file.name ||
|
||||
previewImage.value.slice(
|
||||
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
|
||||
);
|
||||
};
|
||||
|
||||
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 handleCancel = () => {
|
||||
previewOpen.value = false;
|
||||
previewTitle.value = '';
|
||||
};
|
||||
|
||||
const beforeUpload = async (file: File) => {
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = await checkImageFileType(file, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('component.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
}
|
||||
return (isAct && !isLt) || Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
async function customRequest(info: UploadRequestOption<any>) {
|
||||
const { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
console.warn('upload api must exist and be a function');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 进度条事件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
info.onProgress!({ percent });
|
||||
};
|
||||
const res = await api?.(info.file as File, progressEvent);
|
||||
/**
|
||||
* 由getValue处理 传对象过去
|
||||
* 直接传string(id)会被转为Number
|
||||
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
|
||||
*/
|
||||
info.onSuccess!(res);
|
||||
message.success($t('component.upload.uploadSuccess'));
|
||||
// 获取
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('change', value);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
info.onError!(error);
|
||||
}
|
||||
}
|
||||
|
||||
function getValue() {
|
||||
console.log(fileList.value);
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === UploadResultStatus.DONE)
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response?.[props.resultField];
|
||||
}
|
||||
// ossId兼容 uid为ossId直接返回
|
||||
if (props.resultField === 'ossId' && item.uid) {
|
||||
return item.uid;
|
||||
}
|
||||
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
|
||||
if (item?.url) {
|
||||
return item.url;
|
||||
}
|
||||
// 注意这里取的key为 url
|
||||
return item?.response?.url;
|
||||
});
|
||||
// 只有一张图片 默认绑定string而非string[]
|
||||
if (props.maxNumber === 1 && list.length === 1) {
|
||||
return list[0];
|
||||
}
|
||||
// 只有一张图片 && 删除图片时 可自行修改
|
||||
if (props.maxNumber === 1 && list.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return list;
|
||||
// 否则走默认预览
|
||||
return handlePreview(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Upload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
v-model:file-list="innerFileList"
|
||||
:class="{ 'upload-animation__disabled': !withAnimation }"
|
||||
:list-type="listType"
|
||||
:accept="accept"
|
||||
:disabled="disabled"
|
||||
:directory="directory"
|
||||
:max-count="maxCount"
|
||||
:progress="{ showInfo: true }"
|
||||
:multiple="multiple"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:list-type="listType"
|
||||
:max-count="maxNumber"
|
||||
:multiple="multiple"
|
||||
:progress="{ showInfo: true }"
|
||||
@preview="handlePreview"
|
||||
@preview="currentPreview"
|
||||
@change="handleChange"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div v-if="fileList && fileList.length < maxNumber">
|
||||
<div
|
||||
v-if="innerFileList?.length < maxCount && listType === 'picture-card'"
|
||||
>
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px">{{ $t('component.upload.upload') }}</div>
|
||||
<div class="mt-[8px]">{{ $t('component.upload.upload') }}</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="innerFileList?.length < maxCount && listType !== 'picture-card'"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<UploadOutlined />
|
||||
{{ $t('component.upload.upload') }}
|
||||
</a-button>
|
||||
</Upload>
|
||||
<div
|
||||
v-if="showDescription"
|
||||
class="mt-2 flex flex-wrap items-center text-[14px]"
|
||||
<slot name="helpMessage" v-bind="{ maxCount, disabled, maxSize, accept }">
|
||||
<I18nT
|
||||
v-if="helpMessage"
|
||||
scope="global"
|
||||
keypath="component.upload.uploadHelpMessage"
|
||||
tag="div"
|
||||
:class="{
|
||||
'upload-text__disabled': disabled,
|
||||
'mt-2': listType !== 'picture-card',
|
||||
}"
|
||||
>
|
||||
<template #size>
|
||||
<span
|
||||
class="text-primary mx-1 font-medium"
|
||||
:class="{ 'upload-text__disabled': disabled }"
|
||||
>
|
||||
{{ maxSize }}MB
|
||||
</span>
|
||||
</template>
|
||||
<template #ext>
|
||||
<span
|
||||
class="text-primary mx-1 font-medium"
|
||||
:class="{ 'upload-text__disabled': disabled }"
|
||||
>
|
||||
{{ acceptStr }}
|
||||
</span>
|
||||
</template>
|
||||
</I18nT>
|
||||
</slot>
|
||||
|
||||
<ImagePreviewGroup
|
||||
:preview="{
|
||||
visible: previewVisible,
|
||||
onVisibleChange: handleCancel,
|
||||
}"
|
||||
>
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
</div>
|
||||
<Modal
|
||||
:footer="null"
|
||||
:open="previewOpen"
|
||||
:title="previewTitle"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<img :src="previewImage" alt="" style="width: 100%" />
|
||||
</Modal>
|
||||
<Image class="hidden" :src="previewImage" />
|
||||
</ImagePreviewGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.ant-upload-select-picture-card i {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
<style lang="scss">
|
||||
.ant-upload-select-picture-card {
|
||||
i {
|
||||
@apply text-[32px] text-[#999];
|
||||
}
|
||||
|
||||
.ant-upload-text {
|
||||
@apply mt-[8px] text-[#666];
|
||||
}
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card .ant-upload-text {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
.ant-upload-list-picture-card {
|
||||
.ant-upload-list-item::before {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用的样式和antd保持一致
|
||||
.upload-text__disabled {
|
||||
color: rgb(50 54 57 / 25%);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:where(.dark, .dark *) {
|
||||
color: rgb(242 242 242 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
// list-type: picture-card动画效果关闭样式
|
||||
.upload-animation__disabled {
|
||||
.ant-upload-animate-inline {
|
||||
animation-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
26
apps/web-antd/src/components/upload/src/note.md
Normal file
26
apps/web-antd/src/components/upload/src/note.md
Normal file
@@ -0,0 +1,26 @@
|
||||
Safari在执行到beforeUpload方法
|
||||
|
||||
有两种情况
|
||||
|
||||
1. 不继续执行 也无法上传(没有调用上传)
|
||||
2. 报错
|
||||
|
||||
Unhandled Promise Rejection: TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController
|
||||
|
||||
https://github.com/oven-sh/bun/issues/12908#issuecomment-2490151231
|
||||
|
||||
刚开始以为是异步的问题 由于`file-type`调用了异步方法 调试也是在这里没有后续打印了
|
||||
|
||||
使用别的异步代码测试结果是正常上传的
|
||||
|
||||
```js
|
||||
return new Promise<FileType>((resolve) =>
|
||||
setTimeout(() => resolve(file), 2000),
|
||||
);
|
||||
```
|
||||
|
||||
根本原因在于`file-typ`库的`fileTypeFromBlob`方法不支持Safari 去掉可以正常上传
|
||||
|
||||
safari不支持`ReadableStreamBYOBReader`api
|
||||
|
||||
详见: https://github.com/sindresorhus/file-type/issues/690
|
122
apps/web-antd/src/components/upload/src/props.d.ts
vendored
Normal file
122
apps/web-antd/src/components/upload/src/props.d.ts
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
import type { RcFile } from 'ant-design-vue/es/vc-upload/interface';
|
||||
|
||||
import type { UploadApi, UploadResult } from '#/api';
|
||||
import type { OssFile } from '#/api/system/oss/model';
|
||||
|
||||
import { UploadChangeParam } from 'ant-design-vue';
|
||||
|
||||
export type UploadType = 'file' | 'image';
|
||||
|
||||
/**
|
||||
* 自定义返回文件名/缩略图使用 泛型控制返回是否必填
|
||||
* type 为不同的接口返回值 需要自行if判断
|
||||
*/
|
||||
export type CustomGetter<T extends string | undefined> = (
|
||||
cb:
|
||||
| { response: OssFile; type: 'info' }
|
||||
| { response: UploadResult; type: 'upload' },
|
||||
) => T extends undefined ? string | undefined : string;
|
||||
|
||||
export interface BaseUploadProps {
|
||||
/**
|
||||
* 上传接口
|
||||
*/
|
||||
api?: UploadApi;
|
||||
/**
|
||||
* 文件上传失败 是否从展示列表中删除
|
||||
* @default true
|
||||
*/
|
||||
removeOnError?: boolean;
|
||||
/**
|
||||
* 上传成功 是否展示提示信息
|
||||
* @default true
|
||||
*/
|
||||
showSuccessMsg?: boolean;
|
||||
/**
|
||||
* 删除文件前是否需要确认
|
||||
* @default false
|
||||
*/
|
||||
removeConfirm?: boolean;
|
||||
/**
|
||||
* 同antdv参数
|
||||
*/
|
||||
accept?: string;
|
||||
/**
|
||||
* 你可能使用的是application/pdf这种mime类型, 但是这样用户可能看不懂, 在这里自定义逻辑
|
||||
* @default 原始accept
|
||||
*/
|
||||
acceptFormat?: ((accept: string) => string) | string;
|
||||
/**
|
||||
* 附带的请求参数
|
||||
*/
|
||||
data?: any;
|
||||
/**
|
||||
* 最大上传图片数量
|
||||
* maxCount为1时 会被绑定为string而非string[]
|
||||
* @default 1
|
||||
*/
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 文件最大 单位M
|
||||
* @default 5
|
||||
*/
|
||||
maxSize?: number;
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 是否显示文案 请上传不超过...
|
||||
* @default true
|
||||
*/
|
||||
helpMessage?: boolean;
|
||||
/**
|
||||
* 是否支持多选文件,ie10+ 支持。开启后按住 ctrl 可选择多个文件。
|
||||
* @default false
|
||||
*/
|
||||
multiple?: boolean;
|
||||
/**
|
||||
* 是否支持上传文件夹
|
||||
* @default false
|
||||
*/
|
||||
directory?: boolean;
|
||||
/**
|
||||
* 是否支持拖拽上传
|
||||
* @default false
|
||||
*/
|
||||
enableDragUpload?: boolean;
|
||||
/**
|
||||
* 当ossId查询不到文件信息时 比如被删除了
|
||||
* 是否保留列表对应的ossId 默认不保留
|
||||
* @default false
|
||||
*/
|
||||
keepMissingId?: boolean;
|
||||
/**
|
||||
* 自定义文件/图片预览逻辑 比如: 你可以改为下载
|
||||
* 图片上传默认为预览
|
||||
* 文件上传默认为window.open
|
||||
* @param file file
|
||||
*/
|
||||
preview?: (file: UploadFile) => Promise<void> | void;
|
||||
/**
|
||||
* 是否在组件Unmounted时取消上传
|
||||
* @default true
|
||||
*/
|
||||
abortOnUnmounted?: boolean;
|
||||
/**
|
||||
* 自定义文件名 需要区分两个接口的返回值
|
||||
*/
|
||||
customFilename?: CustomGetter<string>;
|
||||
/**
|
||||
* 自定义缩略图 需要区分两个接口的返回值
|
||||
*/
|
||||
customThumbUrl?: CustomGetter<undefined>;
|
||||
}
|
||||
|
||||
export interface UploadEmits {
|
||||
(e: 'success', file: RcFile, response: UploadResult): void;
|
||||
(e: 'remove', file: UploadFile): void;
|
||||
(e: 'change', info: UploadChangeParam): void;
|
||||
}
|
@@ -116,7 +116,7 @@ watch(
|
||||
async (enable) => {
|
||||
if (enable) {
|
||||
await updateWatermark({
|
||||
content: `${userStore.userInfo?.username}`,
|
||||
content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
|
||||
});
|
||||
} else {
|
||||
destroyWatermark();
|
||||
|
@@ -50,6 +50,10 @@
|
||||
"uploadError": "Upload failed",
|
||||
"uploading": "Uploading",
|
||||
"uploadWait": "Please wait for the file upload to finish",
|
||||
"reUploadFailed": "Re-upload failed files"
|
||||
"reUploadFailed": "Re-upload failed files",
|
||||
"uploadHelpMessage": "Please upload a file in {ext} format that does not exceed {size} .",
|
||||
"unknownFileType": "Unknown file type, unable to upload",
|
||||
"confirmDelete": "Confirm file deletion {0}?",
|
||||
"clickOrDrag": "Click or drag file to this area to upload"
|
||||
}
|
||||
}
|
||||
|
@@ -18,6 +18,10 @@
|
||||
"refresh": "Refresh",
|
||||
"generate": "Generate",
|
||||
"downloadLoading": "Downloading... Please wait.",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"tip": "Tip",
|
||||
"enable": "On",
|
||||
"disable": "Off",
|
||||
"beforeCloseTip": "You have unsaved changes. Are you sure you want to exit?"
|
||||
}
|
||||
}
|
||||
|
@@ -50,6 +50,10 @@
|
||||
"uploadError": "上传失败",
|
||||
"uploading": "上传中",
|
||||
"uploadWait": "请等待文件上传结束后操作",
|
||||
"reUploadFailed": "重新上传失败文件"
|
||||
"reUploadFailed": "重新上传失败文件",
|
||||
"uploadHelpMessage": "请上传不超过{size}的{ext}格式文件",
|
||||
"unknownFileType": "未知的文件类型, 无法上传",
|
||||
"confirmDelete": "确认删除文件 {0}?",
|
||||
"clickOrDrag": "点击或拖动文件到这个区域上传"
|
||||
}
|
||||
}
|
||||
|
@@ -18,6 +18,10 @@
|
||||
"refresh": "刷新",
|
||||
"generate": "生成",
|
||||
"downloadLoading": "下载中, 请稍后...",
|
||||
"preview": "预览"
|
||||
"preview": "预览",
|
||||
"tip": "提示",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"beforeCloseTip": "您有未保存的更改,确认要退出吗?"
|
||||
}
|
||||
}
|
||||
|
@@ -46,6 +46,11 @@ export const overridesPreferences = defineOverridesPreferences({
|
||||
* 浅色sidebar
|
||||
*/
|
||||
semiDarkSidebar: false,
|
||||
/**
|
||||
* 圆角大小 换算比例为1.6px = 0.1radius
|
||||
* 这里为6px 与antd保持一致
|
||||
*/
|
||||
radius: '0.375',
|
||||
},
|
||||
/**
|
||||
* !!! 更改配置后请清空浏览器缓存
|
||||
|
@@ -2,10 +2,10 @@ import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
|
||||
|
||||
import { AuthPageLayout, BasicLayout } from '#/layouts';
|
||||
import { $t } from '#/locales';
|
||||
import Login from '#/views/_core/authentication/login.vue';
|
||||
|
||||
const BasicLayout = () => import('#/layouts/basic.vue');
|
||||
const AuthPageLayout = () => import('#/layouts/auth.vue');
|
||||
/** 全局404页面 */
|
||||
const fallbackNotFoundRoute: RouteRecordRaw = {
|
||||
component: () => import('#/views/_core/fallback/not-found.vue'),
|
||||
@@ -58,7 +58,7 @@ const coreRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
name: 'Login',
|
||||
path: 'login',
|
||||
component: Login,
|
||||
component: () => import('#/views/_core/authentication/login.vue'),
|
||||
meta: {
|
||||
title: $t('page.auth.login'),
|
||||
},
|
||||
|
@@ -12,6 +12,7 @@ const localRoutes: RouteRecordStringComponent[] = [
|
||||
icon: 'mingcute:profile-line',
|
||||
title: $t('ui.widgets.profile'),
|
||||
hideInMenu: true,
|
||||
requireHomeRedirect: true,
|
||||
},
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
@@ -23,6 +24,7 @@ const localRoutes: RouteRecordStringComponent[] = [
|
||||
icon: 'ant-design:setting-outlined',
|
||||
title: 'oss配置',
|
||||
hideInMenu: true,
|
||||
requireHomeRedirect: true,
|
||||
},
|
||||
name: 'OssConfig',
|
||||
path: '/system/oss-config',
|
||||
@@ -34,6 +36,7 @@ const localRoutes: RouteRecordStringComponent[] = [
|
||||
icon: 'tabler:code',
|
||||
title: '生成配置',
|
||||
hideInMenu: true,
|
||||
requireHomeRedirect: true,
|
||||
},
|
||||
name: 'GenConfig',
|
||||
path: '/code-gen/edit/:tableId',
|
||||
@@ -45,6 +48,7 @@ const localRoutes: RouteRecordStringComponent[] = [
|
||||
icon: 'eos-icons:role-binding-outlined',
|
||||
title: '分配角色',
|
||||
hideInMenu: true,
|
||||
requireHomeRedirect: true,
|
||||
},
|
||||
name: 'RoleAssign',
|
||||
path: '/system/role-assign/:roleId',
|
||||
@@ -56,6 +60,7 @@ const localRoutes: RouteRecordStringComponent[] = [
|
||||
icon: 'fluent-mdl2:flow',
|
||||
title: '流程设计',
|
||||
hideInMenu: true,
|
||||
requireHomeRedirect: true,
|
||||
},
|
||||
name: 'WorkflowDesigner',
|
||||
path: '/workflow/designer',
|
||||
@@ -70,6 +75,7 @@ const localRoutes: RouteRecordStringComponent[] = [
|
||||
title: '请假申请',
|
||||
activePath: '/demo/leave',
|
||||
hideInMenu: true,
|
||||
requireHomeRedirect: true,
|
||||
},
|
||||
name: 'WorkflowLeaveIndex',
|
||||
path: '/workflow/leaveEdit/index',
|
||||
@@ -120,6 +126,18 @@ export const localMenuList: RouteRecordStringComponent[] = [
|
||||
title: $t('demos.vben.document'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'V5UpdateLog',
|
||||
path: '/changelog',
|
||||
component: '/演示使用自行删除/changelog/index',
|
||||
meta: {
|
||||
icon: 'lucide:book-open-text',
|
||||
keepAlive: true,
|
||||
title: '更新记录',
|
||||
badge: '1.3.0',
|
||||
badgeVariants: '#CC0033',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@@ -59,7 +59,11 @@ export const useDictStore = defineStore('app-dict', () => {
|
||||
}
|
||||
|
||||
function resetCache() {
|
||||
dictRequestCache.clear();
|
||||
dictOptionsMap.clear();
|
||||
/**
|
||||
* 不需要清空dictRequestCache 每次请求成功/失败都清空key
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -27,6 +27,10 @@ function fetchAndCacheDictData<T>(
|
||||
// 内部处理了push的逻辑 这里不用push
|
||||
setDictInfo(dictName, resp, formatNumber);
|
||||
})
|
||||
.catch(() => {
|
||||
// 401时 移除字典缓存 下次登录重新获取
|
||||
dictRequestCache.delete(dictName);
|
||||
})
|
||||
.finally(() => {
|
||||
// 移除请求状态缓存
|
||||
/**
|
||||
|
126
apps/web-antd/src/utils/popup.ts
Normal file
126
apps/web-antd/src/utils/popup.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { ExtendedFormApi } from '@vben/common-ui';
|
||||
import type { MaybePromise } from '@vben/types';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { isFunction } from 'lodash-es';
|
||||
|
||||
interface BeforeCloseDiffProps {
|
||||
/**
|
||||
* 初始化值如何获取
|
||||
* @returns Promise<string>
|
||||
*/
|
||||
initializedGetter: () => MaybePromise<string>;
|
||||
/**
|
||||
* 当前值如何获取
|
||||
* @returns Promise<string>
|
||||
*/
|
||||
currentGetter: () => MaybePromise<string>;
|
||||
/**
|
||||
* 自定义比较函数
|
||||
* @param init 初始值
|
||||
* @param current 当前值
|
||||
* @returns boolean
|
||||
*/
|
||||
compare?: (init: string, current: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于Drawer/Modal使用 判断表单是否有变动来决定是否弹窗提示
|
||||
* @param props props
|
||||
* @returns hook
|
||||
*/
|
||||
export function useBeforeCloseDiff(props: BeforeCloseDiffProps) {
|
||||
const { initializedGetter, currentGetter, compare } = props;
|
||||
/**
|
||||
* 记录初始值 json
|
||||
*/
|
||||
const initialized = ref<string>('');
|
||||
/**
|
||||
* 是否已经初始化了 通过这个值判断是否需要进行对比 为false直接关闭 不弹窗
|
||||
*/
|
||||
const isInitialized = ref(false);
|
||||
|
||||
/**
|
||||
* 标记是否已经完成初始化 后续需要进行对比
|
||||
* @param data 自定义初始化数据 可选
|
||||
*/
|
||||
async function markInitialized(data?: string) {
|
||||
initialized.value = data || (await initializedGetter());
|
||||
isInitialized.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置初始化状态 需要在closed前调用 或者打开窗口时
|
||||
*/
|
||||
function resetInitialized() {
|
||||
initialized.value = '';
|
||||
isInitialized.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供给useVbenForm/useVbenDrawer使用
|
||||
* @returns 是否允许关闭
|
||||
*/
|
||||
async function onBeforeClose(): Promise<boolean> {
|
||||
// 如果还未初始化,直接允许关闭
|
||||
if (!isInitialized.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前表单数据
|
||||
const current = await currentGetter();
|
||||
// 自定义比较的情况
|
||||
if (isFunction(compare) && compare(initialized.value, current)) {
|
||||
return true;
|
||||
} else {
|
||||
// 如果数据没有变化,直接允许关闭
|
||||
if (current === initialized.value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 数据有变化,显示确认对话框
|
||||
return new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: $t('pages.common.tip'),
|
||||
content: $t('pages.common.beforeCloseTip'),
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: $t('common.cancel'),
|
||||
okText: $t('common.confirm'),
|
||||
onOk: () => {
|
||||
resolve(true);
|
||||
isInitialized.value = false;
|
||||
},
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to compare data:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onBeforeClose,
|
||||
markInitialized,
|
||||
resetInitialized,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 给useVbenForm使用的 封装函数
|
||||
* @param formApi 表单实例
|
||||
* @returns getter
|
||||
*/
|
||||
export function defaultFormValueGetter(formApi: ExtendedFormApi) {
|
||||
return async () => {
|
||||
const v = await formApi.getValues();
|
||||
return JSON.stringify(v);
|
||||
};
|
||||
}
|
@@ -116,7 +116,7 @@ export function renderHttpMethodTag(type: string) {
|
||||
return <Tag color={color}>{title}</Tag>;
|
||||
}
|
||||
|
||||
export function renderDictTag(value: string, dicts: DictData[]) {
|
||||
export function renderDictTag(value: number | string, dicts: DictData[]) {
|
||||
return <DictTag dicts={dicts} value={value}></DictTag>;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function renderDictTags(
|
||||
* @param dictName dictName
|
||||
* @returns tag
|
||||
*/
|
||||
export function renderDict(value: string, dictName: string) {
|
||||
export function renderDict(value: number | string, dictName: string) {
|
||||
const dictInfo = getDictOptions(dictName);
|
||||
return renderDictTag(value, dictInfo);
|
||||
}
|
||||
|
@@ -30,13 +30,23 @@ const captchaInfo = ref<CaptchaResponse>({
|
||||
img: '',
|
||||
uuid: '',
|
||||
});
|
||||
// 验证码loading
|
||||
const captchaLoading = ref(false);
|
||||
|
||||
async function loadCaptcha() {
|
||||
const resp = await captchaImage();
|
||||
if (resp.captchaEnabled) {
|
||||
resp.img = `data:image/png;base64,${resp.img}`;
|
||||
try {
|
||||
captchaLoading.value = true;
|
||||
|
||||
const resp = await captchaImage();
|
||||
if (resp.captchaEnabled) {
|
||||
resp.img = `data:image/png;base64,${resp.img}`;
|
||||
}
|
||||
captchaInfo.value = resp;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
captchaLoading.value = false;
|
||||
}
|
||||
captchaInfo.value = resp;
|
||||
}
|
||||
|
||||
const tenantInfo = ref<TenantResp>({
|
||||
@@ -116,6 +126,7 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
class: 'focus:border-primary',
|
||||
onCaptchaClick: loadCaptcha,
|
||||
placeholder: $t('authentication.code'),
|
||||
loading: captchaLoading.value,
|
||||
},
|
||||
dependencies: {
|
||||
if: () => captchaInfo.value.captchaEnabled,
|
||||
|
@@ -15,10 +15,10 @@ import {
|
||||
} from '@vben/icons';
|
||||
|
||||
import AnalyticsTrends from './analytics-trends.vue';
|
||||
import AnalyticsVisits from './analytics-visits.vue';
|
||||
import AnalyticsVisitsData from './analytics-visits-data.vue';
|
||||
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
|
||||
import AnalyticsVisitsSource from './analytics-visits-source.vue';
|
||||
import AnalyticsVisits from './analytics-visits.vue';
|
||||
|
||||
const overviewItems: AnalysisOverviewItem[] = [
|
||||
{
|
||||
|
@@ -1,96 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { RedisInfo } from '#/api/monitor/cache';
|
||||
import type { DescItem } from '#/components/description';
|
||||
|
||||
import { onMounted, watch } from 'vue';
|
||||
|
||||
import { Description, useDescription } from '#/components/description';
|
||||
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
|
||||
|
||||
interface IRedisInfo extends RedisInfo {
|
||||
dbSize: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: IRedisInfo }>();
|
||||
|
||||
const descSchemas: DescItem[] = [
|
||||
{ field: 'redis_version', label: 'redis版本' },
|
||||
{
|
||||
field: 'redis_mode',
|
||||
label: 'redis模式',
|
||||
render(value) {
|
||||
return value === 'standalone' ? '单机模式' : '集群模式';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'tcp_port',
|
||||
label: 'tcp端口',
|
||||
},
|
||||
{
|
||||
field: 'connected_clients',
|
||||
label: '客户端数',
|
||||
},
|
||||
{
|
||||
field: 'uptime_in_days',
|
||||
label: '运行时间',
|
||||
render(value) {
|
||||
return `${value}天`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'used_memory_human',
|
||||
label: '使用内存',
|
||||
},
|
||||
{
|
||||
field: 'used_cpu_user_children',
|
||||
label: '使用CPU',
|
||||
render(value) {
|
||||
return Number.parseFloat(value).toFixed(2);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'maxmemory_human',
|
||||
label: '内存配置',
|
||||
},
|
||||
{
|
||||
field: 'aof_enabled',
|
||||
label: 'AOF是否开启',
|
||||
render(value) {
|
||||
return value === '0' ? '否' : '是';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'rdb_last_bgsave_status',
|
||||
label: 'RDB是否成功',
|
||||
},
|
||||
{
|
||||
field: 'dbSize',
|
||||
label: 'Key数量',
|
||||
},
|
||||
{
|
||||
field: 'instantaneous_input_kbps',
|
||||
label: '网络入口/出口',
|
||||
render(_, data) {
|
||||
const { instantaneous_input_kbps, instantaneous_output_kbps } = data;
|
||||
return `${instantaneous_input_kbps}kps/${instantaneous_output_kbps}kps`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [registerDescription, { setDescProps }] = useDescription({
|
||||
column: { lg: 4, md: 3, sm: 1, xl: 4, xs: 1 },
|
||||
schema: descSchemas,
|
||||
});
|
||||
|
||||
onMounted(() => setDescProps({ data: props.data }));
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(data) => {
|
||||
setDescProps({ data });
|
||||
},
|
||||
);
|
||||
defineProps<{ data: IRedisInfo }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Description @register="registerDescription" />
|
||||
<Descriptions
|
||||
bordered
|
||||
:column="{ lg: 4, md: 3, sm: 1, xl: 4, xs: 1 }"
|
||||
size="small"
|
||||
>
|
||||
<DescriptionsItem label="redis版本">
|
||||
{{ data.redis_version }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="redis模式">
|
||||
{{ data.redis_mode === 'standalone' ? '单机模式' : '集群模式' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="tcp端口">
|
||||
{{ data.tcp_port }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="客户端数">
|
||||
{{ data.connected_clients }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="运行时间">
|
||||
{{ data.uptime_in_days }} 天
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="使用内存">
|
||||
{{ data.used_memory_human }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="使用CPU">
|
||||
{{ Number.parseFloat(data?.used_cpu_user_children ?? '0').toFixed(2) }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="内存配置">
|
||||
{{ data.maxmemory_human }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="AOF是否开启">
|
||||
{{ data.aof_enabled === '0' ? '否' : '是' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="RDB是否成功">
|
||||
{{ data.rdb_last_bgsave_status }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="key数量">
|
||||
{{ data.dbSize }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="网络入口/出口">
|
||||
{{
|
||||
`${data.instantaneous_input_kbps}kps/${data.instantaneous_output_kbps}kps`
|
||||
}}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</template>
|
||||
|
@@ -2,7 +2,6 @@ import type { VNode } from 'vue';
|
||||
|
||||
import type { FormSchemaGetter } from '#/adapter/form';
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { DescItem } from '#/components/description';
|
||||
|
||||
import { DictEnum } from '@vben/constants';
|
||||
|
||||
@@ -107,62 +106,3 @@ export const columns: VxeGridProps['columns'] = [
|
||||
width: 150,
|
||||
},
|
||||
];
|
||||
|
||||
export const modalSchema: () => DescItem[] = () => [
|
||||
{
|
||||
field: 'status',
|
||||
label: '登录状态',
|
||||
labelMinWidth: 80,
|
||||
render(value) {
|
||||
return renderDict(value, DictEnum.SYS_COMMON_STATUS);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'clientKey',
|
||||
label: '登录平台',
|
||||
render(value) {
|
||||
if (value) {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'ipaddr',
|
||||
label: '账号信息',
|
||||
render(_, data) {
|
||||
const { ipaddr, loginLocation, userName } = data;
|
||||
return `账号: ${userName} / ${ipaddr} / ${loginLocation}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'loginTime',
|
||||
label: '登录时间',
|
||||
},
|
||||
{
|
||||
field: 'msg',
|
||||
label: '登录信息',
|
||||
render(_, data: any) {
|
||||
const { msg, status } = data;
|
||||
return (
|
||||
<span class={['font-bold', status === '0' ? '' : 'text-red-500']}>
|
||||
{msg}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'os',
|
||||
label: '登录设备',
|
||||
render(value) {
|
||||
return renderOsIcon(value);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'browser',
|
||||
label: '浏览器',
|
||||
render(value) {
|
||||
return renderBrowserIcon(value);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@@ -1,22 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { LoginLog } from '#/api/monitor/logininfo/model';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { DictEnum } from '@vben/constants';
|
||||
|
||||
import { Description, useDescription } from '#/components/description';
|
||||
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
|
||||
|
||||
import { modalSchema } from './data';
|
||||
|
||||
const [registerDescription, { setDescProps }] = useDescription({
|
||||
column: 1,
|
||||
schema: modalSchema(),
|
||||
});
|
||||
import { renderBrowserIcon, renderDict, renderOsIcon } from '#/utils/render';
|
||||
|
||||
const loginInfo = ref<LoginLog>();
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
onOpenChange: (isOpen) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
const data = modalApi.getData();
|
||||
setDescProps({ data }, true);
|
||||
const record = modalApi.getData() as LoginLog;
|
||||
loginInfo.value = record;
|
||||
},
|
||||
onClosed() {
|
||||
loginInfo.value = undefined;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -28,6 +32,37 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
class="w-[550px]"
|
||||
title="登录日志"
|
||||
>
|
||||
<Description @register="registerDescription" />
|
||||
<Descriptions v-if="loginInfo" size="small" :column="1" bordered>
|
||||
<DescriptionsItem label="登录状态">
|
||||
<component
|
||||
:is="renderDict(loginInfo.status, DictEnum.SYS_COMMON_STATUS)"
|
||||
/>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="登录平台">
|
||||
{{ loginInfo.clientKey.toLowerCase() }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="账号信息">
|
||||
{{
|
||||
`账号: ${loginInfo.userName} / ${loginInfo.ipaddr} / ${loginInfo.loginLocation}`
|
||||
}}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="登录时间">
|
||||
{{ loginInfo.loginTime }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="登录信息">
|
||||
<span
|
||||
class="font-semibold"
|
||||
:class="{ 'text-red-500': loginInfo.status !== '0' }"
|
||||
>
|
||||
{{ loginInfo.msg }}
|
||||
</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="登录设备">
|
||||
<component :is="renderOsIcon(loginInfo.os)" />
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="浏览器">
|
||||
<component :is="renderBrowserIcon(loginInfo.browser)" />
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
@@ -1,17 +1,10 @@
|
||||
import type { FormSchemaGetter } from '#/adapter/form';
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { DescItem } from '#/components/description';
|
||||
|
||||
import { DictEnum } from '@vben/constants';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import { getDictOptions } from '#/utils/dict';
|
||||
import {
|
||||
renderDict,
|
||||
renderHttpMethodTag,
|
||||
renderJsonPreview,
|
||||
} from '#/utils/render';
|
||||
import { renderDict } from '#/utils/render';
|
||||
|
||||
export const querySchema: FormSchemaGetter = () => [
|
||||
{
|
||||
@@ -96,104 +89,3 @@ export const columns: VxeGridProps['columns'] = [
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
|
||||
export const descSchema: DescItem[] = [
|
||||
{
|
||||
field: 'operId',
|
||||
label: '日志编号',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '操作结果',
|
||||
render(value) {
|
||||
return renderDict(value, DictEnum.SYS_COMMON_STATUS);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'title',
|
||||
label: '操作模块',
|
||||
labelMinWidth: 80,
|
||||
render(value, { businessType }) {
|
||||
const operType = renderDict(businessType, DictEnum.SYS_OPER_TYPE);
|
||||
return (
|
||||
<div class="flex items-center">
|
||||
<Tag>{value}</Tag>
|
||||
{operType}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'operIp',
|
||||
label: '操作信息',
|
||||
render(_, data) {
|
||||
return `账号: ${data.operName} / ${data.deptName} / ${data.operIp} / ${data.operLocation}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'operUrl',
|
||||
label: '请求信息',
|
||||
render(_, data) {
|
||||
const { operUrl, requestMethod } = data;
|
||||
const methodTag = renderHttpMethodTag(requestMethod);
|
||||
return (
|
||||
<span>
|
||||
{methodTag} {operUrl}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'errorMsg',
|
||||
label: '异常信息',
|
||||
render(value) {
|
||||
return <span class="font-bold text-red-600">{value}</span>;
|
||||
},
|
||||
show: (data) => {
|
||||
return data && data.errorMsg !== '';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'method',
|
||||
label: '方法',
|
||||
},
|
||||
/**
|
||||
* 默认word-break: break-word;会导致json预览样式异常
|
||||
*/
|
||||
{
|
||||
field: 'operParam',
|
||||
label: '请求参数',
|
||||
render(value) {
|
||||
return (
|
||||
<div class="max-h-[300px] w-full overflow-y-auto">
|
||||
{renderJsonPreview(value)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'jsonResult',
|
||||
label: '响应参数',
|
||||
render(value) {
|
||||
return (
|
||||
<div class="max-h-[300px] w-full overflow-y-auto">
|
||||
{renderJsonPreview(value)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
show(data) {
|
||||
return data && data.jsonResult;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'costTime',
|
||||
label: '耗时',
|
||||
render(value) {
|
||||
return `${value} ms`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'operTime',
|
||||
label: '操作时间',
|
||||
},
|
||||
];
|
||||
|
@@ -1,32 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import type { OperationLog } from '#/api/monitor/operlog/model';
|
||||
|
||||
import { computed, shallowRef } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { DictEnum } from '@vben/constants';
|
||||
|
||||
import { Description, useDescription } from '#/components/description';
|
||||
import { Descriptions, DescriptionsItem, Tag } from 'ant-design-vue';
|
||||
|
||||
import { descSchema } from './data';
|
||||
import {
|
||||
renderDict,
|
||||
renderHttpMethodTag,
|
||||
renderJsonPreview,
|
||||
} from '#/utils/render';
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onOpenChange: handleOpenChange,
|
||||
onClosed() {
|
||||
currentLog.value = null;
|
||||
},
|
||||
});
|
||||
|
||||
const [registerDescription, { setDescProps }] = useDescription({
|
||||
column: 1,
|
||||
schema: descSchema,
|
||||
});
|
||||
|
||||
const currentLog = shallowRef<null | OperationLog>(null);
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
const { record } = drawerApi.getData() as { record: OperationLog };
|
||||
setDescProps({ data: record }, true);
|
||||
currentLog.value = record;
|
||||
}
|
||||
|
||||
const actionInfo = computed(() => {
|
||||
if (!currentLog.value) {
|
||||
return '-';
|
||||
}
|
||||
const data = currentLog.value;
|
||||
return `账号: ${data.operName} / ${data.deptName} / ${data.operIp} / ${data.operLocation}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :footer="false" class="w-[600px]" title="查看日志">
|
||||
<Description @register="registerDescription" />
|
||||
<Descriptions v-if="currentLog" size="small" bordered :column="1">
|
||||
<DescriptionsItem label="日志编号" :label-style="{ minWidth: '120px' }">
|
||||
{{ currentLog.operId }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="操作结果">
|
||||
<component
|
||||
:is="renderDict(currentLog.status, DictEnum.SYS_COMMON_STATUS)"
|
||||
/>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="操作模块">
|
||||
<div class="flex items-center">
|
||||
<Tag>{{ currentLog.title }}</Tag>
|
||||
<component
|
||||
:is="renderDict(currentLog.businessType, DictEnum.SYS_OPER_TYPE)"
|
||||
/>
|
||||
</div>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="操作信息">
|
||||
{{ actionInfo }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="请求信息">
|
||||
<component :is="renderHttpMethodTag(currentLog.requestMethod)" />
|
||||
{{ currentLog.operUrl }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem v-if="currentLog.errorMsg" label="异常信息">
|
||||
<span class="font-semibold text-red-600">
|
||||
{{ currentLog.errorMsg }}
|
||||
</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="方法">
|
||||
{{ currentLog.method }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="请求参数">
|
||||
<div class="max-h-[300px] overflow-y-auto">
|
||||
<component :is="renderJsonPreview(currentLog.operParam)" />
|
||||
</div>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem v-if="currentLog.jsonResult" label="响应参数">
|
||||
<div class="max-h-[300px] overflow-y-auto">
|
||||
<component :is="renderJsonPreview(currentLog.jsonResult)" />
|
||||
</div>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="请求耗时">
|
||||
{{ `${currentLog.costTime} ms` }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="操作时间">
|
||||
{{ `${currentLog.operTime}` }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
@@ -7,6 +7,7 @@ import { cloneDeep } from '@vben/utils';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { clientAdd, clientInfo, clientUpdate } from '#/api/system/client';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
import SecretInput from './secret-input.vue';
|
||||
@@ -55,6 +56,13 @@ function setupForm(update: boolean) {
|
||||
]);
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
// 提取生成状态字段Schema的函数
|
||||
const getStatusSchema = (disabled: boolean) => [
|
||||
{
|
||||
@@ -64,13 +72,15 @@ const getStatusSchema = (disabled: boolean) => [
|
||||
];
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
drawerApi.drawerLoading(true);
|
||||
|
||||
const { id } = drawerApi.getData() as { id?: number | string };
|
||||
isUpdate.value = !!id;
|
||||
// 初始化
|
||||
@@ -84,36 +94,39 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
// 新增模式: 确保状态字段可用
|
||||
formApi.updateSchema(getStatusSchema(false));
|
||||
}
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
drawerApi.drawerLoading(true);
|
||||
drawerApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? clientUpdate(data) : clientAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
drawerApi.drawerLoading(false);
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
|
||||
<BasicDrawer :title="title" class="w-[600px]">
|
||||
<BasicForm>
|
||||
<template #clientSecret="slotProps">
|
||||
<SecretInput v-bind="slotProps" :disabled="isUpdate" />
|
||||
|
@@ -144,10 +144,10 @@ const { hasAccessByCodes } = useAccess();
|
||||
<!-- pc不允许禁用 禁用了直接登录不了 应该设置disabled -->
|
||||
<!-- 登录提示: 认证权限类型已禁用 -->
|
||||
<TableSwitch
|
||||
v-model="row.status"
|
||||
v-model:value="row.status"
|
||||
:api="() => clientChangeStatus(row)"
|
||||
:disabled="row.id === 1 || !hasAccessByCodes(['system:client:edit'])"
|
||||
:reload="() => tableApi.query()"
|
||||
@reload="tableApi.query()"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
|
@@ -7,6 +7,7 @@ import { cloneDeep } from '@vben/utils';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { configAdd, configInfo, configUpdate } from '#/api/system/config';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { modalSchema } from './data';
|
||||
|
||||
@@ -25,9 +26,17 @@ const [BasicForm, formApi] = useVbenForm({
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
onOpenChange: async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
@@ -42,6 +51,7 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
const record = await configInfo(id);
|
||||
await formApi.setValues(record);
|
||||
}
|
||||
await markInitialized();
|
||||
|
||||
modalApi.modalLoading(false);
|
||||
},
|
||||
@@ -49,30 +59,31 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
modalApi.modalLoading(true);
|
||||
modalApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? configUpdate(data) : configAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.modalLoading(false);
|
||||
modalApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
modalApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicModal :close-on-click-modal="false" :title="title" class="w-[550px]">
|
||||
<BasicModal :title="title" class="w-[550px]">
|
||||
<BasicForm />
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
@@ -16,6 +16,7 @@ import {
|
||||
deptUpdate,
|
||||
} from '#/api/system/dept';
|
||||
import { listUserByDeptId } from '#/api/system/user';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
|
||||
@@ -107,8 +108,16 @@ async function setLeaderOptions() {
|
||||
]);
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
@@ -130,6 +139,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
await (update && id ? initDeptUsers(id) : setLeaderOptions());
|
||||
/** 部门选择 下拉框 */
|
||||
await initDeptSelect(id);
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
@@ -137,30 +147,31 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
drawerApi.drawerLoading(true);
|
||||
drawerApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? deptUpdate(data) : deptAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
drawerApi.drawerLoading(false);
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
|
||||
<BasicDrawer :title="title" class="w-[600px]">
|
||||
<BasicForm />
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
dictDetailInfo,
|
||||
} from '#/api/system/dict/dict-data';
|
||||
import { tagTypes } from '#/components/dict';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
import TagStylePicker from './tag-style-picker.vue';
|
||||
@@ -57,8 +58,16 @@ function setupSelectType(listClass: string) {
|
||||
selectType.value = isDefault ? 'default' : 'custom';
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
@@ -75,6 +84,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
setupSelectType(record.listClass);
|
||||
await formApi.setValues(record);
|
||||
}
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
@@ -82,7 +92,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
drawerApi.drawerLoading(true);
|
||||
drawerApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
@@ -93,19 +103,20 @@ async function handleConfirm() {
|
||||
data.listClass = '';
|
||||
}
|
||||
await (isUpdate.value ? dictDataUpdate(data) : dictDataAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
drawerApi.drawerLoading(false);
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
selectType.value = 'default';
|
||||
resetInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +128,7 @@ async function handleDeSelect() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
|
||||
<BasicDrawer :title="title" class="w-[600px]">
|
||||
<BasicForm>
|
||||
<template #listClass="slotProps">
|
||||
<TagStylePicker
|
||||
|
@@ -1,14 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { RadioChangeEvent } from 'ant-design-vue';
|
||||
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Input, RadioGroup, Select } from 'ant-design-vue';
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import { RadioGroup, Select } from 'ant-design-vue';
|
||||
import { ColorPicker } from 'vue3-colorpicker';
|
||||
|
||||
import { tagSelectOptions } from '#/components/dict';
|
||||
|
||||
import 'vue3-colorpicker/style.css';
|
||||
|
||||
/**
|
||||
* 需要禁止透传
|
||||
* 不禁止会有奇怪的bug 会绑定到selectType上
|
||||
@@ -32,23 +35,26 @@ const computedOptions = computed(
|
||||
|
||||
type SelectType = (typeof options)[number]['value'];
|
||||
|
||||
const selectType = defineModel('selectType', {
|
||||
const selectType = defineModel<SelectType>('selectType', {
|
||||
default: 'default',
|
||||
type: String as PropType<SelectType>,
|
||||
});
|
||||
|
||||
/**
|
||||
* color必须为hex颜色或者undefined
|
||||
*/
|
||||
const color = defineModel('value', {
|
||||
const color = defineModel<string | undefined>('value', {
|
||||
default: undefined,
|
||||
type: String as PropType<string | undefined>,
|
||||
});
|
||||
|
||||
function handleSelectTypeChange(e: RadioChangeEvent) {
|
||||
// 必须给默认hex颜色 不能为空字符串
|
||||
color.value = e.target.value === 'custom' ? '#000000' : undefined;
|
||||
color.value = e.target.value === 'custom' ? '#1677ff' : undefined;
|
||||
}
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const theme = computed(() => {
|
||||
return isDark.value ? 'black' : 'white';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,15 +75,12 @@ function handleSelectTypeChange(e: RadioChangeEvent) {
|
||||
placeholder="请选择标签样式"
|
||||
@deselect="$emit('deselect')"
|
||||
/>
|
||||
<Input
|
||||
<ColorPicker
|
||||
v-if="selectType === 'custom'"
|
||||
v-model:value="color"
|
||||
class="flex-1"
|
||||
disabled
|
||||
>
|
||||
<template #addonAfter>
|
||||
<input v-model="color" class="rounded-lg" type="color" />
|
||||
</template>
|
||||
</Input>
|
||||
disable-alpha
|
||||
format="hex"
|
||||
v-model:pure-color="color"
|
||||
:theme="theme"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -71,7 +71,6 @@ export const modalSchema: FormSchemaGetter = () => [
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'remark',
|
||||
formItemClass: 'items-start',
|
||||
label: '备注',
|
||||
},
|
||||
];
|
||||
|
@@ -11,6 +11,7 @@ import {
|
||||
dictTypeInfo,
|
||||
dictTypeUpdate,
|
||||
} from '#/api/system/dict/dict-type';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { modalSchema } from './data';
|
||||
|
||||
@@ -22,6 +23,7 @@ const title = computed(() => {
|
||||
});
|
||||
|
||||
const [BasicForm, formApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
commonConfig: {
|
||||
labelWidth: 100,
|
||||
},
|
||||
@@ -29,51 +31,63 @@ const [BasicForm, formApi] = useVbenForm({
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
onOpenChange: async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
modalApi.modalLoading(true);
|
||||
|
||||
const { id } = modalApi.getData() as { id?: number | string };
|
||||
isUpdate.value = !!id;
|
||||
if (isUpdate.value && id) {
|
||||
const record = await dictTypeInfo(id);
|
||||
await formApi.setValues(record);
|
||||
}
|
||||
await markInitialized();
|
||||
|
||||
modalApi.modalLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
modalApi.modalLoading(true);
|
||||
modalApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? dictTypeUpdate(data) : dictTypeAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.modalLoading(false);
|
||||
modalApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
modalApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicModal :close-on-click-modal="false" :title="title">
|
||||
<BasicModal :title="title">
|
||||
<BasicForm />
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
@@ -61,8 +61,11 @@ const gridOptions: VxeGridProps = {
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'dictId',
|
||||
// 高亮当前行
|
||||
isCurrent: true,
|
||||
},
|
||||
id: 'system-dict-type-index',
|
||||
rowClassName: 'hover:cursor-pointer',
|
||||
};
|
||||
|
||||
const lastDictType = ref('');
|
||||
@@ -193,3 +196,14 @@ function handleDownloadExcel() {
|
||||
<DictTypeModal @reload="tableApi.query()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
div#dict-type {
|
||||
.vxe-body--row {
|
||||
&.row--current {
|
||||
// 选中行bold
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { menuAdd, menuInfo, menuList, menuUpdate } from '#/api/system/menu';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
|
||||
@@ -88,14 +89,23 @@ async function setupMenuSelect() {
|
||||
]);
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
drawerApi.drawerLoading(true);
|
||||
|
||||
const { id, update } = drawerApi.getData() as ModalProps;
|
||||
isUpdate.value = update;
|
||||
|
||||
@@ -108,36 +118,39 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
await formApi.setValues(record);
|
||||
}
|
||||
}
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
drawerApi.drawerLoading(true);
|
||||
drawerApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? menuUpdate(data) : menuAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
drawerApi.drawerLoading(false);
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
|
||||
<BasicDrawer :title="title" class="w-[600px]">
|
||||
<BasicForm />
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
@@ -18,6 +18,7 @@ import { pick } from 'lodash-es';
|
||||
import { noticeAdd, noticeInfo, noticeUpdate } from '#/api/system/notice';
|
||||
import { Tinymce } from '#/components/tinymce';
|
||||
import { getDictOptions } from '#/utils/dict';
|
||||
import { useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
const emit = defineEmits<{ reload: [] }>();
|
||||
|
||||
@@ -74,17 +75,29 @@ const { validate, validateInfos, resetFields } = Form.useForm(
|
||||
formRules,
|
||||
);
|
||||
|
||||
function customFormValueGetter() {
|
||||
return JSON.stringify(formData.value);
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: customFormValueGetter,
|
||||
currentGetter: customFormValueGetter,
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
class: 'w-[800px]',
|
||||
fullscreenButton: true,
|
||||
closeOnClickModal: false,
|
||||
onClosed: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
onOpenChange: async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
modalApi.modalLoading(true);
|
||||
|
||||
const { id } = modalApi.getData() as { id?: number | string };
|
||||
isUpdate.value = !!id;
|
||||
if (isUpdate.value && id) {
|
||||
@@ -93,30 +106,33 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
const filterRecord = pick(record, Object.keys(defaultValues));
|
||||
formData.value = filterRecord;
|
||||
}
|
||||
await markInitialized();
|
||||
|
||||
modalApi.modalLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
modalApi.modalLoading(true);
|
||||
modalApi.lock(true);
|
||||
await validate();
|
||||
// 可能会做数据处理 使用cloneDeep深拷贝
|
||||
const data = cloneDeep(formData.value);
|
||||
await (isUpdate.value ? noticeUpdate(data) : noticeAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.modalLoading(false);
|
||||
modalApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
modalApi.close();
|
||||
async function handleClosed() {
|
||||
formData.value = defaultValues;
|
||||
resetFields();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@@ -128,10 +128,10 @@ const { hasAccessByCodes } = useAccess();
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<TableSwitch
|
||||
v-model="row.status"
|
||||
v-model:value="row.status"
|
||||
:api="() => ossConfigChangeStatus(row)"
|
||||
:disabled="!hasAccessByCodes(['system:ossConfig:edit'])"
|
||||
:reload="() => tableApi.query()"
|
||||
@reload="tableApi.query()"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
ossConfigInfo,
|
||||
ossConfigUpdate,
|
||||
} from '#/api/system/oss-config';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
|
||||
@@ -33,27 +34,38 @@ const [BasicForm, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-3',
|
||||
});
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
drawerApi.drawerLoading(true);
|
||||
|
||||
const { id } = drawerApi.getData() as { id?: number | string };
|
||||
isUpdate.value = !!id;
|
||||
if (isUpdate.value && id) {
|
||||
const record = await ossConfigInfo(id);
|
||||
await formApi.setValues(record);
|
||||
}
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
drawerApi.drawerLoading(true);
|
||||
drawerApi.lock(true);
|
||||
/**
|
||||
* 这里解构出来的values只能获取到自定义校验参数的值
|
||||
* 需要自行调用formApi.getValues()获取表单值
|
||||
@@ -64,23 +76,24 @@ async function handleConfirm() {
|
||||
}
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? ossConfigUpdate(data) : ossConfigAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
drawerApi.drawerLoading(false);
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[650px]">
|
||||
<BasicDrawer :title="title" class="w-[650px]">
|
||||
<BasicForm>
|
||||
<template #tip>
|
||||
<div class="ml-7 w-full">
|
||||
|
@@ -1,10 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Alert } from 'ant-design-vue';
|
||||
|
||||
import { FileUpload } from '#/components/upload';
|
||||
|
||||
const emit = defineEmits<{ reload: [] }>();
|
||||
@@ -23,13 +21,6 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const accept = ref(['xlsx', 'word', 'pdf']);
|
||||
const maxNumber = ref(3);
|
||||
|
||||
const message = computed(() => {
|
||||
return `支持 [${accept.value.join(', ')}] 格式,最多上传 ${maxNumber.value} 个文件`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,13 +31,7 @@ const message = computed(() => {
|
||||
title="文件上传"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Alert :message="message" show-icon type="info">aaa</Alert>
|
||||
<FileUpload
|
||||
v-model:value="fileList"
|
||||
:accept="accept"
|
||||
:max-number="maxNumber"
|
||||
:max-size="5"
|
||||
/>
|
||||
<FileUpload v-model:value="fileList" :enable-drag-upload="true" />
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
@@ -1,10 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Alert } from 'ant-design-vue';
|
||||
|
||||
import { ImageUpload } from '#/components/upload';
|
||||
|
||||
const emit = defineEmits<{ reload: [] }>();
|
||||
@@ -23,13 +21,6 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const accept = ref(['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
||||
const maxNumber = ref(3);
|
||||
|
||||
const message = computed(() => {
|
||||
return `支持 [${accept.value.join(', ')}] 格式,最多上传 ${maxNumber.value} 张图片`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,12 +31,7 @@ const message = computed(() => {
|
||||
title="图片上传"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Alert :message="message" show-icon type="info">aaa</Alert>
|
||||
<ImageUpload
|
||||
v-model:value="fileList"
|
||||
:accept="accept"
|
||||
:max-number="maxNumber"
|
||||
/>
|
||||
<ImageUpload v-model:value="fileList" />
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
@@ -83,9 +83,14 @@ const gridOptions: VxeGridProps = {
|
||||
},
|
||||
},
|
||||
},
|
||||
headerCellConfig: {
|
||||
height: 44,
|
||||
},
|
||||
cellConfig: {
|
||||
height: 65,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'ossId',
|
||||
height: 65,
|
||||
},
|
||||
sortConfig: {
|
||||
// 远程排序
|
||||
|
@@ -96,7 +96,7 @@ async function handleCancel() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
|
||||
<BasicDrawer :title="title" class="w-[600px]">
|
||||
<BasicForm />
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
@@ -177,14 +177,14 @@ function handleAssignRole(record: Role) {
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<TableSwitch
|
||||
v-model="row.status"
|
||||
v-model:value="row.status"
|
||||
:api="() => roleChangeStatus(row)"
|
||||
:disabled="
|
||||
row.roleId === 1 ||
|
||||
row.roleKey === 'admin' ||
|
||||
!hasAccessByCodes(['system:role:edit'])
|
||||
"
|
||||
:reload="() => tableApi.query()"
|
||||
@reload="tableApi.query()"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
@@ -215,10 +215,7 @@ function handleAssignRole(record: Role) {
|
||||
</ghost-button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<Dropdown
|
||||
:get-popup-container="getVxePopupContainer"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Dropdown placement="bottomRight">
|
||||
<template #overlay>
|
||||
<Menu>
|
||||
<MenuItem key="1" @click="handleAuthEdit(row)">
|
||||
|
@@ -9,6 +9,7 @@ import { cloneDeep } from '@vben/utils';
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { roleDataScope, roleDeptTree, roleInfo } from '#/api/system/role';
|
||||
import { TreeSelectPanel } from '#/components/tree';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { authModalSchemas } from './data';
|
||||
|
||||
@@ -33,9 +34,25 @@ async function setupDeptTree(id: number | string) {
|
||||
deptTree.value = resp.depts;
|
||||
}
|
||||
|
||||
async function customFormValueGetter() {
|
||||
const v = await defaultFormValueGetter(formApi)();
|
||||
// 获取勾选信息
|
||||
const menuIds = deptSelectRef.value?.[0]?.getCheckedKeys() ?? [];
|
||||
const mixStr = v + menuIds.join(',');
|
||||
return mixStr;
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: customFormValueGetter,
|
||||
currentGetter: customFormValueGetter,
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onCancel: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
onOpenChange: async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
@@ -48,6 +65,7 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
setupDeptTree(id);
|
||||
const record = await roleInfo(id);
|
||||
await formApi.setValues(record);
|
||||
markInitialized();
|
||||
|
||||
modalApi.modalLoading(false);
|
||||
},
|
||||
@@ -60,7 +78,7 @@ const deptSelectRef = ref();
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
modalApi.modalLoading(true);
|
||||
modalApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
@@ -75,18 +93,19 @@ async function handleConfirm() {
|
||||
data.deptIds = [];
|
||||
}
|
||||
await roleDataScope(data);
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.modalLoading(false);
|
||||
modalApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
modalApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,11 +118,7 @@ function handleCheckStrictlyChange(value: boolean) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicModal
|
||||
:close-on-click-modal="false"
|
||||
class="min-h-[600px] w-[550px]"
|
||||
title="分配权限"
|
||||
>
|
||||
<BasicModal class="min-h-[600px] w-[550px]" title="分配权限">
|
||||
<BasicForm>
|
||||
<template #deptIds="slotProps">
|
||||
<TreeSelectPanel
|
||||
|
@@ -1,3 +1,6 @@
|
||||
<!--
|
||||
TODO: 这个页面要优化逻辑
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import type { MenuOption } from '#/api/system/menu/model';
|
||||
|
||||
@@ -11,6 +14,7 @@ import { useVbenForm } from '#/adapter/form';
|
||||
import { menuTreeSelect, roleMenuTreeSelect } from '#/api/system/menu';
|
||||
import { roleAdd, roleInfo, roleUpdate } from '#/api/system/role';
|
||||
import { MenuSelectTable } from '#/components/tree';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
|
||||
@@ -62,14 +66,31 @@ async function setupMenuTree(id?: number | string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function customFormValueGetter() {
|
||||
const v = await defaultFormValueGetter(formApi)();
|
||||
// 获取勾选信息
|
||||
const menuIds = menuSelectRef.value?.getCheckedKeys?.() ?? [];
|
||||
const mixStr = v + menuIds.join(',');
|
||||
return mixStr;
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: customFormValueGetter,
|
||||
currentGetter: customFormValueGetter,
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
drawerApi.drawerLoading(true);
|
||||
|
||||
const { id } = drawerApi.getData() as { id?: number | string };
|
||||
isUpdate.value = !!id;
|
||||
|
||||
@@ -79,6 +100,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
}
|
||||
// init菜单 注意顺序要放在赋值record之后 内部watch会依赖record
|
||||
await setupMenuTree(id);
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
@@ -87,7 +109,8 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
const menuSelectRef = ref<InstanceType<typeof MenuSelectTable>>();
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
drawerApi.drawerLoading(true);
|
||||
drawerApi.lock(true);
|
||||
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
@@ -99,17 +122,18 @@ async function handleConfirm() {
|
||||
data.menuIds = menuIds;
|
||||
await (isUpdate.value ? roleUpdate(data) : roleAdd(data));
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
resetInitialized();
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
drawerApi.drawerLoading(false);
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,7 +146,7 @@ function handleMenuCheckStrictlyChange(value: boolean) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[800px]">
|
||||
<BasicDrawer :title="title" class="w-[800px]">
|
||||
<BasicForm>
|
||||
<template #menuIds="slotProps">
|
||||
<div class="h-[600px] w-full">
|
||||
|
@@ -183,10 +183,10 @@ function handleSyncTenantDict() {
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<TableSwitch
|
||||
v-model="row.status"
|
||||
v-model:value="row.status"
|
||||
:api="() => tenantStatusChange(row)"
|
||||
:disabled="row.id === 1 || !hasAccessByCodes(['system:tenant:edit'])"
|
||||
:reload="() => tableApi.query()"
|
||||
@reload="tableApi.query()"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
|
@@ -9,6 +9,7 @@ import { useVbenForm } from '#/adapter/form';
|
||||
import { tenantAdd, tenantInfo, tenantUpdate } from '#/api/system/tenant';
|
||||
import { packageSelectList } from '#/api/system/tenant-package';
|
||||
import { useTenantStore } from '#/store/tenant';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
|
||||
@@ -51,22 +52,33 @@ async function setupPackageSelect() {
|
||||
]);
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
drawerApi.drawerLoading(true);
|
||||
|
||||
const { id } = drawerApi.getData() as { id?: number | string };
|
||||
isUpdate.value = !!id;
|
||||
// 初始化
|
||||
await setupPackageSelect();
|
||||
|
||||
if (isUpdate.value && id) {
|
||||
const record = await tenantInfo(id);
|
||||
await formApi.setValues(record);
|
||||
}
|
||||
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'packageId',
|
||||
@@ -75,6 +87,8 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
},
|
||||
},
|
||||
]);
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
});
|
||||
@@ -82,32 +96,33 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
const tenantStore = useTenantStore();
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
drawerApi.drawerLoading(true);
|
||||
drawerApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? tenantUpdate(data) : tenantAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
drawerApi.close();
|
||||
// 重新加载租户信息
|
||||
tenantStore.initTenant();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
drawerApi.drawerLoading(false);
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
|
||||
<BasicDrawer :title="title" class="w-[600px]">
|
||||
<BasicForm />
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
@@ -65,12 +65,6 @@ export const drawerSchema: FormSchemaGetter = () => [
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'remark',
|
||||
formItemClass: 'items-start',
|
||||
label: '备注',
|
||||
},
|
||||
];
|
||||
|
||||
// 租户管理 不可分配 只有superadmin有权限操作 分配了也没用
|
||||
export const excludeIds = [
|
||||
6, 121, 122, 1606, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1615,
|
||||
];
|
||||
|
@@ -154,10 +154,10 @@ const isSuperAdmin = computed(() => {
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<TableSwitch
|
||||
v-model="row.status"
|
||||
v-model:value="row.status"
|
||||
:api="() => packageChangeStatus(row)"
|
||||
:disabled="!hasAccessByCodes(['system:tenantPackage:edit'])"
|
||||
:reload="() => tableApi.query()"
|
||||
@reload="tableApi.query()"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
|
@@ -17,6 +17,7 @@ import {
|
||||
packageUpdate,
|
||||
} from '#/api/system/tenant-package';
|
||||
import { MenuSelectTable } from '#/components/tree';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
|
||||
@@ -65,8 +66,24 @@ async function setupMenuTree(id?: number | string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function customFormValueGetter() {
|
||||
const v = await defaultFormValueGetter(formApi)();
|
||||
// 获取勾选信息
|
||||
const menuIds = menuSelectRef.value?.getCheckedKeys?.() ?? [];
|
||||
const mixStr = v + menuIds.join(',');
|
||||
return mixStr;
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: customFormValueGetter,
|
||||
currentGetter: customFormValueGetter,
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
@@ -84,6 +101,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
}
|
||||
// init菜单 注意顺序要放在赋值record之后 内部watch会依赖record
|
||||
await setupMenuTree(id);
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
@@ -103,8 +121,9 @@ async function handleConfirm() {
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
data.menuIds = menuIds;
|
||||
await (isUpdate.value ? packageUpdate(data) : packageAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
@@ -112,9 +131,9 @@ async function handleConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +146,7 @@ function handleMenuCheckStrictlyChange(value: boolean) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[800px]">
|
||||
<BasicDrawer :title="title" class="w-[800px]">
|
||||
<BasicForm>
|
||||
<template #menuIds="slotProps">
|
||||
<div class="h-[600px] w-full">
|
||||
|
@@ -1,44 +0,0 @@
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { Menu } from '#/api/system/menu/model';
|
||||
|
||||
import { computed, defineComponent } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TreeItem',
|
||||
props: {
|
||||
data: {
|
||||
required: true,
|
||||
type: Object as PropType<Menu>,
|
||||
},
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
expose();
|
||||
|
||||
interface TagProp {
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const menuTagProp = computed<TagProp>(() => {
|
||||
// 正则判断是否为链接
|
||||
if (/^https?:\/\/[^\s/$.?#].\S*$/i.test(props.data.path)) {
|
||||
return { color: 'pink', text: '外链' };
|
||||
}
|
||||
const type = props.data.menuType;
|
||||
if (type === 'M') return { color: 'green', text: '目录' };
|
||||
if (type === 'C') return { color: 'blue', text: '菜单' };
|
||||
if (type === 'F') return { color: '', text: '按钮' };
|
||||
return { color: 'error', text: '未知' };
|
||||
});
|
||||
|
||||
return () => (
|
||||
<div class="flex gap-[6px]">
|
||||
<span>{props.data.menuName}</span>
|
||||
<Tag color={menuTagProp.value.color}>{menuTagProp.value.text}</Tag>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
@@ -64,7 +64,7 @@ const formOptions: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
handleReset: async () => {
|
||||
selectDeptId.value = [];
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
|
||||
const { formApi, reload } = tableApi;
|
||||
await formApi.resetForm();
|
||||
const formValues = formApi.form.values;
|
||||
@@ -113,9 +113,14 @@ const gridOptions: VxeGridProps = {
|
||||
},
|
||||
},
|
||||
},
|
||||
headerCellConfig: {
|
||||
height: 44,
|
||||
},
|
||||
cellConfig: {
|
||||
height: 48,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'userId',
|
||||
height: 48,
|
||||
},
|
||||
id: 'system-user-index',
|
||||
};
|
||||
@@ -231,12 +236,12 @@ const { hasAccessByCodes } = useAccess();
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<TableSwitch
|
||||
v-model="row.status"
|
||||
v-model:value="row.status"
|
||||
:api="() => userStatusChange(row)"
|
||||
:disabled="
|
||||
row.userId === 1 || !hasAccessByCodes(['system:user:edit'])
|
||||
"
|
||||
:reload="() => tableApi.query()"
|
||||
@reload="() => tableApi.query()"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
@@ -263,10 +268,7 @@ const { hasAccessByCodes } = useAccess();
|
||||
</ghost-button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<Dropdown
|
||||
:get-popup-container="getVxePopupContainer"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Dropdown placement="bottomRight">
|
||||
<template #overlay>
|
||||
<Menu>
|
||||
<MenuItem key="1" @click="handleUserInfo(row)">
|
||||
|
@@ -1,129 +0,0 @@
|
||||
import type { DescItem } from '#/components/description';
|
||||
|
||||
import { DictEnum } from '@vben/constants';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import { renderDict } from '#/utils/render';
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
function renderTags(list: string[]) {
|
||||
return (
|
||||
<div class="flex flex-row flex-wrap gap-0.5">
|
||||
{list.map((item) => (
|
||||
<Tag key={item}>{item}</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const descSchema: DescItem[] = [
|
||||
{
|
||||
field: 'userId',
|
||||
label: '用户ID',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '用户状态',
|
||||
render(value) {
|
||||
return renderDict(value, DictEnum.SYS_NORMAL_DISABLE);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'nickName',
|
||||
label: '用户信息',
|
||||
render(_, data) {
|
||||
const { deptName = '暂无部门信息', nickName, userName } = data;
|
||||
// 为了兼容新版本和旧版本
|
||||
let currentDept = deptName;
|
||||
if (data.dept && data.dept.deptName) {
|
||||
currentDept = data.dept.deptName;
|
||||
}
|
||||
return `${userName} / ${nickName} / ${currentDept}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'phonenumber',
|
||||
label: '手机号',
|
||||
render(value) {
|
||||
return value || '未设置手机号码';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
label: '邮箱',
|
||||
render(value) {
|
||||
return value || '未设置邮箱地址';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'postNames',
|
||||
label: '岗位',
|
||||
render(value) {
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return '暂无信息';
|
||||
}
|
||||
return renderTags(value);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'roleNames',
|
||||
label: '权限',
|
||||
render(value) {
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return '暂无信息';
|
||||
}
|
||||
return renderTags(value);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
label: '创建时间',
|
||||
},
|
||||
{
|
||||
field: 'loginIp',
|
||||
label: '上次登录IP',
|
||||
render(value) {
|
||||
return value || <span class="text-orange-500">从未登录过</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'loginDate',
|
||||
label: '上次登录时间',
|
||||
render(value) {
|
||||
if (!value) {
|
||||
return <span class="text-orange-500">从未登录过</span>;
|
||||
}
|
||||
// 默认en显示
|
||||
dayjs.locale('zh-cn');
|
||||
// 计算相差秒数
|
||||
const diffSeconds = dayjs().diff(dayjs(value), 'second');
|
||||
/**
|
||||
* 转为时间显示(x月 x天)
|
||||
* https://dayjs.fenxianglu.cn/category/duration.html#%E4%BA%BA%E6%80%A7%E5%8C%96
|
||||
*
|
||||
*/
|
||||
const diffText = dayjs.duration(diffSeconds, 'seconds').humanize();
|
||||
return (
|
||||
<div class="flex gap-2">
|
||||
{value}
|
||||
<Tag bordered={false} color="cyan">
|
||||
{diffText}前
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'remark',
|
||||
label: '备注',
|
||||
render(value) {
|
||||
return value || '无';
|
||||
},
|
||||
},
|
||||
];
|
@@ -18,6 +18,7 @@ import {
|
||||
userAdd,
|
||||
userUpdate,
|
||||
} from '#/api/system/user';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
import { authScopeOptions } from '#/views/system/role/data';
|
||||
|
||||
import { drawerSchema } from './data';
|
||||
@@ -134,8 +135,16 @@ async function loadDefaultPassword(update: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
@@ -149,6 +158,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
return null;
|
||||
}
|
||||
drawerApi.drawerLoading(true);
|
||||
|
||||
const { id } = drawerApi.getData() as { id?: number | string };
|
||||
isUpdate.value = !!id;
|
||||
/** update时 禁用用户名修改 不显示密码框 */
|
||||
@@ -186,10 +196,11 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
fieldName: 'postIds',
|
||||
},
|
||||
]);
|
||||
// 部门选择 && 初始密码
|
||||
await Promise.all([setupDeptSelect(), loadDefaultPassword(isUpdate.value)]);
|
||||
|
||||
// 部门选择、初始密码及用户相关操作并行处理
|
||||
const promises = [setupDeptSelect(), loadDefaultPassword(isUpdate.value)];
|
||||
if (user) {
|
||||
await Promise.all([
|
||||
promises.push(
|
||||
// 添加基础信息
|
||||
formApi.setValues(user),
|
||||
// 添加角色和岗位
|
||||
@@ -197,38 +208,43 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
|
||||
formApi.setFieldValue('roleIds', roleIds),
|
||||
// 更新时不会触发onSelect 需要手动调用
|
||||
setupPostOptions(user.deptId),
|
||||
]);
|
||||
);
|
||||
}
|
||||
// 并行处理 重构后会带来10-50ms的优化
|
||||
await Promise.all(promises);
|
||||
await markInitialized();
|
||||
|
||||
drawerApi.drawerLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
drawerApi.drawerLoading(true);
|
||||
drawerApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? userUpdate(data) : userAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
drawerApi.drawerLoading(false);
|
||||
drawerApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
drawerApi.close();
|
||||
await formApi.resetForm();
|
||||
async function handleClosed() {
|
||||
formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
|
||||
<BasicDrawer :title="title" class="w-[600px]">
|
||||
<BasicForm />
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
@@ -1,25 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { User } from '#/api/system/user/model';
|
||||
|
||||
import { computed, shallowRef } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { DictEnum } from '@vben/constants';
|
||||
|
||||
import { Descriptions, DescriptionsItem, Tag } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import { findUserInfo } from '#/api/system/user';
|
||||
import { Description, useDescription } from '#/components/description';
|
||||
import { renderDict } from '#/utils/render';
|
||||
|
||||
import { descSchema } from './info';
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
onOpenChange: handleOpenChange,
|
||||
onClosed() {
|
||||
currentUser.value = null;
|
||||
},
|
||||
});
|
||||
|
||||
const [registerDescription, { setDescProps }] = useDescription({
|
||||
column: 1,
|
||||
labelStyle: {
|
||||
minWidth: '150px',
|
||||
width: '150px',
|
||||
},
|
||||
schema: descSchema,
|
||||
});
|
||||
interface UserWithNames extends User {
|
||||
postNames: string[];
|
||||
roleNames: string[];
|
||||
}
|
||||
const currentUser = shallowRef<null | UserWithNames>(null);
|
||||
|
||||
async function handleOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
@@ -41,22 +50,103 @@ async function handleOpenChange(open: boolean) {
|
||||
.filter((item) => roleIds.includes(item.roleId))
|
||||
.map((item) => item.roleName);
|
||||
|
||||
interface UserWithNames extends User {
|
||||
postNames: string[];
|
||||
roleNames: string[];
|
||||
}
|
||||
(user as UserWithNames).postNames = postNames;
|
||||
(user as UserWithNames).roleNames = roleNames;
|
||||
|
||||
// 赋值
|
||||
setDescProps({ data: user });
|
||||
currentUser.value = user as UserWithNames;
|
||||
|
||||
modalApi.modalLoading(false);
|
||||
}
|
||||
|
||||
const mixInfo = computed(() => {
|
||||
if (!currentUser.value) {
|
||||
return '-';
|
||||
}
|
||||
const { deptName, nickName, userName } = currentUser.value;
|
||||
return `${userName} / ${nickName} / ${deptName ?? '-'}`;
|
||||
});
|
||||
|
||||
const diffLoginTime = computed(() => {
|
||||
if (!currentUser.value) {
|
||||
return '-';
|
||||
}
|
||||
const { loginDate } = currentUser.value;
|
||||
// 默认en显示
|
||||
dayjs.locale('zh-cn');
|
||||
// 计算相差秒数
|
||||
const diffSeconds = dayjs().diff(dayjs(loginDate), 'second');
|
||||
/**
|
||||
* 转为时间显示(x月 x天)
|
||||
* https://dayjs.fenxianglu.cn/category/duration.html#%E4%BA%BA%E6%80%A7%E5%8C%96
|
||||
*
|
||||
*/
|
||||
const diffText = dayjs.duration(diffSeconds, 'seconds').humanize();
|
||||
return diffText;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicModal :footer="false" :fullscreen-button="false" title="用户信息">
|
||||
<Description @register="registerDescription" />
|
||||
<Descriptions v-if="currentUser" size="small" :column="1" bordered>
|
||||
<DescriptionsItem label="userId">
|
||||
{{ currentUser.userId }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="用户状态">
|
||||
<component
|
||||
:is="renderDict(currentUser.status, DictEnum.SYS_NORMAL_DISABLE)"
|
||||
/>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="用户信息">
|
||||
{{ mixInfo }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="手机号">
|
||||
{{ currentUser.phonenumber || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="邮箱">
|
||||
{{ currentUser.email || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="岗位">
|
||||
<div
|
||||
v-if="currentUser.postNames.length > 0"
|
||||
class="flex flex-wrap gap-0.5"
|
||||
>
|
||||
<Tag v-for="item in currentUser.postNames" :key="item">
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="权限">
|
||||
<div
|
||||
v-if="currentUser.roleNames.length > 0"
|
||||
class="flex flex-wrap gap-0.5"
|
||||
>
|
||||
<Tag v-for="item in currentUser.roleNames" :key="item">
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="创建时间">
|
||||
{{ currentUser.createTime }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="上次登录IP">
|
||||
{{ currentUser.loginIp ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="上次登录时间">
|
||||
<span>{{ currentUser.loginDate ?? '-' }}</span>
|
||||
<Tag
|
||||
class="ml-2"
|
||||
v-if="diffLoginTime"
|
||||
:bordered="false"
|
||||
color="processing"
|
||||
>
|
||||
{{ diffLoginTime }}前
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="备注">
|
||||
{{ currentUser.remark ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
@@ -1,38 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { ResetPwdParam, User } from '#/api/system/user/model';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal, z } from '@vben/common-ui';
|
||||
|
||||
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { userResetPassword } from '#/api/system/user';
|
||||
import { Description, useDescription } from '#/components/description';
|
||||
|
||||
const emit = defineEmits<{ reload: [] }>();
|
||||
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
onCancel: handleCancel,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleSubmit,
|
||||
onOpenChange: handleOpenChange,
|
||||
});
|
||||
|
||||
const [registerDescription, { setDescProps }] = useDescription({
|
||||
column: 1,
|
||||
schema: [
|
||||
{
|
||||
field: 'userId',
|
||||
label: '用户ID',
|
||||
},
|
||||
{
|
||||
field: 'userName',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
field: 'nickName',
|
||||
label: '昵称',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [BasicForm, formApi] = useVbenForm({
|
||||
schema: [
|
||||
{
|
||||
@@ -64,13 +49,18 @@ const [BasicForm, formApi] = useVbenForm({
|
||||
},
|
||||
});
|
||||
|
||||
const currentUser = ref<null | User>(null);
|
||||
async function handleOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
modalApi.modalLoading(true);
|
||||
|
||||
const { record } = modalApi.getData() as { record: User };
|
||||
setDescProps({ data: record }, true);
|
||||
currentUser.value = record;
|
||||
await formApi.setValues({ userId: record.userId });
|
||||
|
||||
modalApi.modalLoading(false);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -83,7 +73,7 @@ async function handleSubmit() {
|
||||
const data = await formApi.getValues();
|
||||
await userResetPassword(data as ResetPwdParam);
|
||||
emit('reload');
|
||||
handleCancel();
|
||||
handleClosed();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
@@ -91,9 +81,10 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
async function handleClosed() {
|
||||
modalApi.close();
|
||||
await formApi.resetForm();
|
||||
currentUser.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -104,7 +95,17 @@ async function handleCancel() {
|
||||
title="重置密码"
|
||||
>
|
||||
<div class="flex flex-col gap-[12px]">
|
||||
<Description @register="registerDescription" />
|
||||
<Descriptions v-if="currentUser" size="small" :column="1" bordered>
|
||||
<DescriptionsItem label="用户ID">
|
||||
{{ currentUser.userId }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="用户名">
|
||||
{{ currentUser.userName }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="昵称">
|
||||
{{ currentUser.nickName }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
<BasicForm />
|
||||
</div>
|
||||
</BasicModal>
|
||||
|
@@ -17,6 +17,7 @@ import {
|
||||
categoryList,
|
||||
categoryUpdate,
|
||||
} from '#/api/workflow/category';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { modalSchema } from './data';
|
||||
|
||||
@@ -65,9 +66,17 @@ async function setupCategorySelect() {
|
||||
]);
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicModal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onClosed: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
onOpenChange: async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
@@ -89,6 +98,7 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
await formApi.setValues({ parentId });
|
||||
}
|
||||
await setupCategorySelect();
|
||||
await markInitialized();
|
||||
|
||||
modalApi.modalLoading(false);
|
||||
},
|
||||
@@ -96,7 +106,7 @@ const [BasicModal, modalApi] = useVbenModal({
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
modalApi.modalLoading(true);
|
||||
modalApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
@@ -104,27 +114,24 @@ async function handleConfirm() {
|
||||
// getValues获取为一个readonly的对象 需要修改必须先深拷贝一次
|
||||
const data = cloneDeep(await formApi.getValues());
|
||||
await (isUpdate.value ? categoryUpdate(data) : categoryAdd(data));
|
||||
resetInitialized();
|
||||
emit('reload');
|
||||
await handleCancel();
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.modalLoading(false);
|
||||
modalApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
modalApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicModal
|
||||
:close-on-click-modal="false"
|
||||
:title="title"
|
||||
class="min-h-[500px]"
|
||||
>
|
||||
<BasicModal :title="title" class="min-h-[500px]">
|
||||
<BasicForm />
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
@@ -24,6 +24,10 @@ export const columns: VxeGridProps['columns'] = [
|
||||
field: 'orderNum',
|
||||
title: '排序',
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
},
|
||||
{
|
||||
field: 'action',
|
||||
fixed: 'right',
|
||||
|
@@ -87,20 +87,9 @@ const [BasicForm, formApi] = useVbenForm({
|
||||
fieldName: 'attachment',
|
||||
component: 'FileUpload',
|
||||
componentProps: {
|
||||
resultField: 'ossId',
|
||||
maxNumber: 10,
|
||||
maxCount: 10,
|
||||
maxSize: 20,
|
||||
accept: [
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'doc',
|
||||
'docx',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'ppt',
|
||||
'pdf',
|
||||
],
|
||||
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
|
||||
},
|
||||
defaultValue: [],
|
||||
label: '附件上传',
|
||||
|
@@ -59,20 +59,9 @@ const [BasicForm, formApi] = useVbenForm({
|
||||
fieldName: 'attachment',
|
||||
component: 'FileUpload',
|
||||
componentProps: {
|
||||
resultField: 'ossId',
|
||||
maxNumber: 10,
|
||||
maxCount: 10,
|
||||
maxSize: 20,
|
||||
accept: [
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'doc',
|
||||
'docx',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'ppt',
|
||||
'pdf',
|
||||
],
|
||||
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
|
||||
},
|
||||
defaultValue: [],
|
||||
label: '附件上传',
|
||||
|
@@ -54,20 +54,9 @@ const [BasicForm, formApi] = useVbenForm({
|
||||
fieldName: 'attachment',
|
||||
component: 'FileUpload',
|
||||
componentProps: {
|
||||
resultField: 'ossId',
|
||||
maxNumber: 10,
|
||||
maxCount: 10,
|
||||
maxSize: 20,
|
||||
accept: [
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'doc',
|
||||
'docx',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'ppt',
|
||||
'pdf',
|
||||
],
|
||||
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
|
||||
},
|
||||
defaultValue: [],
|
||||
label: '附件上传',
|
||||
|
@@ -37,8 +37,8 @@ const gridOptions: VxeGridProps = {
|
||||
highlight: true,
|
||||
// 翻页时保留选中状态
|
||||
reserve: true,
|
||||
// 点击行选中
|
||||
// trigger: 'row',
|
||||
// 选中 需要根据状态判断
|
||||
checkMethod: ({ row }) => ['back', 'cancel', 'draft'].includes(row.status),
|
||||
},
|
||||
columns,
|
||||
height: 'auto',
|
||||
|
@@ -93,9 +93,14 @@ const gridOptions: VxeGridProps = {
|
||||
},
|
||||
},
|
||||
},
|
||||
headerCellConfig: {
|
||||
height: 44,
|
||||
},
|
||||
cellConfig: {
|
||||
height: 100,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
height: 100,
|
||||
},
|
||||
id: 'workflow-definition-index',
|
||||
};
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
workflowDefinitionInfo,
|
||||
workflowDefinitionUpdate,
|
||||
} from '#/api/workflow/definition';
|
||||
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
|
||||
|
||||
import { modalSchema } from './data';
|
||||
|
||||
@@ -65,8 +66,16 @@ async function setupCategorySelect() {
|
||||
]);
|
||||
}
|
||||
|
||||
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
|
||||
{
|
||||
initializedGetter: defaultFormValueGetter(formApi),
|
||||
currentGetter: defaultFormValueGetter(formApi),
|
||||
},
|
||||
);
|
||||
|
||||
const [BasicDrawer, modalApi] = useVbenModal({
|
||||
onCancel: handleCancel,
|
||||
onBeforeClose,
|
||||
onCancel: handleClosed,
|
||||
onConfirm: handleConfirm,
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
@@ -83,6 +92,7 @@ const [BasicDrawer, modalApi] = useVbenModal({
|
||||
const record = await workflowDefinitionInfo(id);
|
||||
await formApi.setValues(record);
|
||||
}
|
||||
await markInitialized();
|
||||
|
||||
modalApi.modalLoading(false);
|
||||
},
|
||||
@@ -90,7 +100,7 @@ const [BasicDrawer, modalApi] = useVbenModal({
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
modalApi.modalLoading(true);
|
||||
modalApi.lock(true);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
@@ -103,27 +113,23 @@ async function handleConfirm() {
|
||||
await workflowDefinitionAdd(data);
|
||||
emit('reload', 'add');
|
||||
}
|
||||
await handleCancel();
|
||||
resetInitialized();
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.modalLoading(false);
|
||||
modalApi.lock(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
modalApi.close();
|
||||
async function handleClosed() {
|
||||
await formApi.resetForm();
|
||||
resetInitialized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicDrawer
|
||||
:close-on-click-modal="false"
|
||||
:fullscreen-button="false"
|
||||
:title="title"
|
||||
class="w-[550px]"
|
||||
>
|
||||
<BasicDrawer :fullscreen-button="false" :title="title" class="w-[550px]">
|
||||
<div class="min-h-[400px]">
|
||||
<BasicForm />
|
||||
</div>
|
||||
|
@@ -41,7 +41,7 @@ const formOptions: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
handleReset: async () => {
|
||||
selectedCode.value = [];
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
|
||||
const { formApi, reload } = tableApi;
|
||||
await formApi.resetForm();
|
||||
const formValues = formApi.form.values;
|
||||
@@ -68,7 +68,7 @@ async function handleTypeChange(e: RadioChangeEvent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
|
||||
await tableApi.reload();
|
||||
}
|
||||
|
||||
@@ -103,9 +103,14 @@ const gridOptions: VxeGridProps = {
|
||||
},
|
||||
},
|
||||
},
|
||||
headerCellConfig: {
|
||||
height: 44,
|
||||
},
|
||||
cellConfig: {
|
||||
height: 66,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
height: 66,
|
||||
},
|
||||
id: 'workflow-definition-index',
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user