index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. /**
  2. * 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
  3. * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
  4. */
  5. /* eslint-disable vue/one-component-per-file */
  6. import type {
  7. UploadChangeParam,
  8. UploadFile,
  9. UploadProps,
  10. } from 'ant-design-vue';
  11. import type { Component, Ref } from 'vue';
  12. import type { BaseFormComponentType } from '@vben/common-ui';
  13. import type { Sortable } from '@vben/hooks';
  14. import type { Recordable } from '@vben/types';
  15. import {
  16. computed,
  17. defineAsyncComponent,
  18. defineComponent,
  19. h,
  20. nextTick,
  21. onMounted,
  22. onUnmounted,
  23. ref,
  24. render,
  25. unref,
  26. watch,
  27. } from 'vue';
  28. import {
  29. ApiComponent,
  30. globalShareState,
  31. IconPicker,
  32. VCropper,
  33. } from '@vben/common-ui';
  34. import { useSortable } from '@vben/hooks';
  35. import { IconifyIcon } from '@vben/icons';
  36. import { $t } from '@vben/locales';
  37. import { isEmpty } from '@vben/utils';
  38. import { message, Modal, notification } from 'ant-design-vue';
  39. const AutoComplete = defineAsyncComponent(
  40. () => import('ant-design-vue/es/auto-complete'),
  41. );
  42. const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
  43. const Checkbox = defineAsyncComponent(
  44. () => import('ant-design-vue/es/checkbox'),
  45. );
  46. const CheckboxGroup = defineAsyncComponent(() =>
  47. import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
  48. );
  49. const DatePicker = defineAsyncComponent(
  50. () => import('ant-design-vue/es/date-picker'),
  51. );
  52. const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
  53. const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
  54. const InputNumber = defineAsyncComponent(
  55. () => import('ant-design-vue/es/input-number'),
  56. );
  57. const InputPassword = defineAsyncComponent(() =>
  58. import('ant-design-vue/es/input').then((res) => res.InputPassword),
  59. );
  60. const Mentions = defineAsyncComponent(
  61. () => import('ant-design-vue/es/mentions'),
  62. );
  63. const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
  64. const RadioGroup = defineAsyncComponent(() =>
  65. import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
  66. );
  67. const RangePicker = defineAsyncComponent(() =>
  68. import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
  69. );
  70. const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
  71. const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
  72. const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
  73. const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
  74. const Textarea = defineAsyncComponent(() =>
  75. import('ant-design-vue/es/input').then((res) => res.Textarea),
  76. );
  77. const TimePicker = defineAsyncComponent(
  78. () => import('ant-design-vue/es/time-picker'),
  79. );
  80. const TreeSelect = defineAsyncComponent(
  81. () => import('ant-design-vue/es/tree-select'),
  82. );
  83. const Cascader = defineAsyncComponent(
  84. () => import('ant-design-vue/es/cascader'),
  85. );
  86. const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
  87. const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
  88. const PreviewGroup = defineAsyncComponent(() =>
  89. import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
  90. );
  91. const withDefaultPlaceholder = <T extends Component>(
  92. component: T,
  93. type: 'input' | 'select',
  94. componentProps: Recordable<any> = {},
  95. ) => {
  96. return defineComponent({
  97. name: component.name,
  98. inheritAttrs: false,
  99. setup: (props: any, { attrs, expose, slots }) => {
  100. const placeholder =
  101. props?.placeholder ||
  102. attrs?.placeholder ||
  103. $t(`ui.placeholder.${type}`);
  104. // 透传组件暴露的方法
  105. const innerRef = ref();
  106. expose(
  107. new Proxy(
  108. {},
  109. {
  110. get: (_target, key) => innerRef.value?.[key],
  111. has: (_target, key) => key in (innerRef.value || {}),
  112. },
  113. ),
  114. );
  115. return () =>
  116. h(
  117. component,
  118. { ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
  119. slots,
  120. );
  121. },
  122. });
  123. };
  124. const IMAGE_EXTENSIONS = new Set([
  125. 'bmp',
  126. 'gif',
  127. 'jpeg',
  128. 'jpg',
  129. 'png',
  130. 'svg',
  131. 'webp',
  132. ]);
  133. /**
  134. * 检查是否为图片文件
  135. */
  136. function isImageFile(file: UploadFile): boolean {
  137. if (file.url) {
  138. try {
  139. const pathname = new URL(file.url, 'http://localhost').pathname;
  140. const ext = pathname.split('.').pop()?.toLowerCase();
  141. return ext ? IMAGE_EXTENSIONS.has(ext) : false;
  142. } catch {
  143. const ext = file.url?.split('.').pop()?.toLowerCase();
  144. return ext ? IMAGE_EXTENSIONS.has(ext) : false;
  145. }
  146. }
  147. if (!file.type) {
  148. const ext = file.name?.split('.').pop()?.toLowerCase();
  149. return ext ? IMAGE_EXTENSIONS.has(ext) : false;
  150. }
  151. return file.type.startsWith('image/');
  152. }
  153. /**
  154. * 创建默认的上传按钮插槽
  155. */
  156. function createDefaultUploadSlots(listType: string, placeholder: string) {
  157. if (listType === 'picture-card') {
  158. return { default: () => placeholder };
  159. }
  160. return {
  161. default: () =>
  162. h(
  163. Button,
  164. {
  165. icon: h(IconifyIcon, {
  166. icon: 'ant-design:upload-outlined',
  167. class: 'mb-1 size-4',
  168. }),
  169. },
  170. () => placeholder,
  171. ),
  172. };
  173. }
  174. /**
  175. * 获取文件的 Base64
  176. */
  177. function getBase64(file: File): Promise<string> {
  178. return new Promise((resolve, reject) => {
  179. const reader = new FileReader();
  180. reader.readAsDataURL(file);
  181. reader.addEventListener('load', () => resolve(reader.result as string));
  182. reader.addEventListener('error', reject);
  183. });
  184. }
  185. /**
  186. * 预览图片
  187. */
  188. async function previewImage(
  189. file: UploadFile,
  190. visible: Ref<boolean>,
  191. fileList: Ref<UploadProps['fileList']>,
  192. ) {
  193. // 非图片文件直接打开链接
  194. if (!isImageFile(file)) {
  195. const url = file.url || file.preview;
  196. if (url) {
  197. window.open(url, '_blank');
  198. } else {
  199. message.error($t('ui.formRules.previewWarning'));
  200. }
  201. return;
  202. }
  203. const [ImageComponent, PreviewGroupComponent] = await Promise.all([
  204. Image,
  205. PreviewGroup,
  206. ]);
  207. // 过滤图片文件并生成预览
  208. const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
  209. for (const imgFile of imageFiles) {
  210. if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
  211. imgFile.preview = await getBase64(imgFile.originFileObj);
  212. }
  213. }
  214. const container = document.createElement('div');
  215. document.body.append(container);
  216. let isUnmounted = false;
  217. const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
  218. const PreviewWrapper = {
  219. setup() {
  220. return () => {
  221. if (isUnmounted) return null;
  222. return h(
  223. PreviewGroupComponent,
  224. {
  225. class: 'hidden',
  226. preview: {
  227. visible: visible.value,
  228. current: currentIndex,
  229. onVisibleChange: (value: boolean) => {
  230. visible.value = value;
  231. if (!value) {
  232. setTimeout(() => {
  233. if (!isUnmounted && container) {
  234. isUnmounted = true;
  235. render(null, container);
  236. container.remove();
  237. }
  238. }, 300);
  239. }
  240. },
  241. },
  242. },
  243. () =>
  244. imageFiles.map((imgFile) =>
  245. h(ImageComponent, {
  246. key: imgFile.uid,
  247. src: imgFile.url || imgFile.preview,
  248. }),
  249. ),
  250. );
  251. };
  252. },
  253. };
  254. render(h(PreviewWrapper), container);
  255. }
  256. /**
  257. * 图片裁剪操作
  258. */
  259. function cropImage(file: File, aspectRatio: string | undefined) {
  260. return new Promise<Blob | string | undefined>((resolve, reject) => {
  261. const container = document.createElement('div');
  262. document.body.append(container);
  263. let isUnmounted = false;
  264. let objectUrl: null | string = null;
  265. const open = ref<boolean>(true);
  266. const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
  267. const closeModal = () => {
  268. open.value = false;
  269. setTimeout(() => {
  270. if (!isUnmounted && container) {
  271. if (objectUrl) {
  272. URL.revokeObjectURL(objectUrl);
  273. }
  274. isUnmounted = true;
  275. render(null, container);
  276. container.remove();
  277. }
  278. }, 300);
  279. };
  280. const CropperWrapper = {
  281. setup() {
  282. return () => {
  283. if (isUnmounted) return null;
  284. if (!objectUrl) {
  285. objectUrl = URL.createObjectURL(file);
  286. }
  287. return h(
  288. Modal,
  289. {
  290. open: open.value,
  291. title: h('div', {}, [
  292. $t('ui.crop.title'),
  293. h(
  294. 'span',
  295. {
  296. class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
  297. },
  298. $t('ui.crop.titleTip', [aspectRatio]),
  299. ),
  300. ]),
  301. centered: true,
  302. width: 548,
  303. keyboard: false,
  304. maskClosable: false,
  305. closable: false,
  306. cancelText: $t('common.cancel'),
  307. okText: $t('ui.crop.confirm'),
  308. destroyOnClose: true,
  309. onOk: async () => {
  310. const cropper = cropperRef.value;
  311. if (!cropper) {
  312. reject(new Error('Cropper not found'));
  313. closeModal();
  314. return;
  315. }
  316. try {
  317. const dataUrl = await cropper.getCropImage();
  318. if (dataUrl) {
  319. resolve(dataUrl);
  320. } else {
  321. reject(new Error($t('ui.crop.errorTip')));
  322. }
  323. } catch {
  324. reject(new Error($t('ui.crop.errorTip')));
  325. } finally {
  326. closeModal();
  327. }
  328. },
  329. onCancel() {
  330. resolve('');
  331. closeModal();
  332. },
  333. },
  334. () =>
  335. h(VCropper, {
  336. ref: (ref: any) => (cropperRef.value = ref),
  337. img: objectUrl as string,
  338. aspectRatio,
  339. }),
  340. );
  341. };
  342. },
  343. };
  344. render(h(CropperWrapper), container);
  345. });
  346. }
  347. /**
  348. * 带预览功能的上传组件
  349. */
  350. const withPreviewUpload = () => {
  351. return defineComponent({
  352. name: Upload.name,
  353. emits: ['update:modelValue'],
  354. setup(
  355. props: any,
  356. { attrs, slots, emit }: { attrs: any; emit: any; slots: any },
  357. ) {
  358. const previewVisible = ref<boolean>(false);
  359. const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
  360. const listType = attrs?.listType || attrs?.['list-type'] || 'text';
  361. const fileList = ref<UploadProps['fileList']>(
  362. attrs?.fileList || attrs?.['file-list'] || [],
  363. );
  364. const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
  365. const aspectRatio = computed(
  366. () => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
  367. );
  368. const handleBeforeUpload = async (
  369. file: UploadFile,
  370. originFileList: Array<File>,
  371. ) => {
  372. // 文件大小限制
  373. if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
  374. message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
  375. file.status = 'removed';
  376. return false;
  377. }
  378. // 图片裁剪处理
  379. if (
  380. attrs.crop &&
  381. !attrs.multiple &&
  382. originFileList[0] &&
  383. isImageFile(file)
  384. ) {
  385. file.status = 'removed';
  386. const blob = await cropImage(originFileList[0], aspectRatio.value);
  387. if (!blob) {
  388. throw new Error($t('ui.crop.errorTip'));
  389. }
  390. return blob;
  391. }
  392. return attrs.beforeUpload?.(file) ?? true;
  393. };
  394. const handleChange = (event: UploadChangeParam) => {
  395. try {
  396. attrs.handleChange?.(event);
  397. attrs.onHandleChange?.(event);
  398. } catch (error) {
  399. console.error(error);
  400. }
  401. fileList.value = event.fileList.filter(
  402. (file) => file.status !== 'removed',
  403. );
  404. emit(
  405. 'update:modelValue',
  406. event.fileList?.length ? fileList.value : undefined,
  407. );
  408. };
  409. const handlePreview = async (file: UploadFile) => {
  410. previewVisible.value = true;
  411. await previewImage(file, previewVisible, fileList);
  412. };
  413. const renderUploadButton = () => {
  414. if (attrs.disabled) return null;
  415. return isEmpty(slots)
  416. ? createDefaultUploadSlots(listType, placeholder)
  417. : slots;
  418. };
  419. // 拖拽排序
  420. const draggable = computed(
  421. () => (attrs.draggable ?? false) && !attrs.disabled,
  422. );
  423. const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
  424. const sortableInstance = ref<null | Sortable>(null);
  425. const styleId = `upload-drag-style-${uploadId}`;
  426. function injectDragStyle() {
  427. if (!document.querySelector(`[id="${styleId}"]`)) {
  428. const style = document.createElement('style');
  429. style.id = styleId;
  430. style.textContent = `
  431. [data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
  432. [data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
  433. `;
  434. document.head.append(style);
  435. }
  436. }
  437. function removeDragStyle() {
  438. document.querySelector(`[id="${styleId}"]`)?.remove();
  439. }
  440. async function initSortable(retryCount = 0) {
  441. if (!draggable.value) return;
  442. injectDragStyle();
  443. await nextTick();
  444. await new Promise((resolve) => setTimeout(resolve, 100));
  445. const container = document.querySelector(
  446. `[data-upload-id="${uploadId}"] .ant-upload-list`,
  447. ) as HTMLElement;
  448. if (!container) {
  449. if (retryCount < 5) {
  450. setTimeout(() => initSortable(retryCount + 1), 200);
  451. }
  452. return;
  453. }
  454. const { initializeSortable } = useSortable(container, {
  455. animation: 300,
  456. delay: 400,
  457. delayOnTouchOnly: true,
  458. filter:
  459. '.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
  460. onEnd: (evt) => {
  461. const { oldIndex, newIndex } = evt;
  462. if (
  463. oldIndex === undefined ||
  464. newIndex === undefined ||
  465. oldIndex === newIndex
  466. ) {
  467. return;
  468. }
  469. const list = [...(fileList.value || [])];
  470. const [movedItem] = list.splice(oldIndex, 1);
  471. if (movedItem) {
  472. list.splice(newIndex, 0, movedItem);
  473. fileList.value = list;
  474. }
  475. attrs.onDragSort?.(oldIndex, newIndex);
  476. emit('update:modelValue', fileList.value);
  477. },
  478. });
  479. sortableInstance.value = await initializeSortable();
  480. }
  481. // 监听表单值变化
  482. watch(
  483. () => attrs.modelValue,
  484. (res) => {
  485. fileList.value = res;
  486. },
  487. );
  488. onMounted(initSortable);
  489. onUnmounted(() => {
  490. sortableInstance.value?.destroy();
  491. removeDragStyle();
  492. });
  493. return () =>
  494. h(
  495. 'div',
  496. { 'data-upload-id': uploadId, class: 'w-full' },
  497. h(
  498. Upload,
  499. {
  500. ...props,
  501. ...attrs,
  502. fileList: fileList.value,
  503. beforeUpload: handleBeforeUpload,
  504. onChange: handleChange,
  505. onPreview: handlePreview,
  506. },
  507. renderUploadButton() as any,
  508. ),
  509. );
  510. },
  511. });
  512. };
  513. // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
  514. export type ComponentType =
  515. | 'ApiCascader'
  516. | 'ApiSelect'
  517. | 'ApiTreeSelect'
  518. | 'AutoComplete'
  519. | 'Cascader'
  520. | 'Checkbox'
  521. | 'CheckboxGroup'
  522. | 'DatePicker'
  523. | 'DefaultButton'
  524. | 'Divider'
  525. | 'IconPicker'
  526. | 'Input'
  527. | 'InputNumber'
  528. | 'InputPassword'
  529. | 'Mentions'
  530. | 'PrimaryButton'
  531. | 'Radio'
  532. | 'RadioGroup'
  533. | 'RangePicker'
  534. | 'Rate'
  535. | 'Select'
  536. | 'Space'
  537. | 'Switch'
  538. | 'Textarea'
  539. | 'TimePicker'
  540. | 'TreeSelect'
  541. | 'Upload'
  542. | BaseFormComponentType;
  543. async function initComponentAdapter() {
  544. const components: Partial<Record<ComponentType, Component>> = {
  545. // 如果你的组件体积比较大,可以使用异步加载
  546. // Button: () =>
  547. // import('xxx').then((res) => res.Button),
  548. ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
  549. component: Cascader,
  550. fieldNames: { label: 'label', value: 'value', children: 'children' },
  551. loadingSlot: 'suffixIcon',
  552. modelPropName: 'value',
  553. visibleEvent: 'onVisibleChange',
  554. }),
  555. ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
  556. component: Select,
  557. loadingSlot: 'suffixIcon',
  558. modelPropName: 'value',
  559. visibleEvent: 'onVisibleChange',
  560. }),
  561. ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
  562. component: TreeSelect,
  563. fieldNames: { label: 'label', value: 'value', children: 'children' },
  564. loadingSlot: 'suffixIcon',
  565. modelPropName: 'value',
  566. optionsPropName: 'treeData',
  567. visibleEvent: 'onVisibleChange',
  568. }),
  569. AutoComplete,
  570. Cascader,
  571. Checkbox,
  572. CheckboxGroup,
  573. DatePicker,
  574. // 自定义默认按钮
  575. DefaultButton: (props, { attrs, slots }) => {
  576. return h(Button, { ...props, attrs, type: 'default' }, slots);
  577. },
  578. Divider,
  579. IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
  580. iconSlot: 'addonAfter',
  581. inputComponent: Input,
  582. modelValueProp: 'value',
  583. }),
  584. Input: withDefaultPlaceholder(Input, 'input'),
  585. InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
  586. InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
  587. Mentions: withDefaultPlaceholder(Mentions, 'input'),
  588. // 自定义主要按钮
  589. PrimaryButton: (props, { attrs, slots }) => {
  590. return h(Button, { ...props, attrs, type: 'primary' }, slots);
  591. },
  592. Radio,
  593. RadioGroup,
  594. RangePicker,
  595. Rate,
  596. Select: withDefaultPlaceholder(Select, 'select'),
  597. Space,
  598. Switch,
  599. Textarea: withDefaultPlaceholder(Textarea, 'input'),
  600. TimePicker,
  601. TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
  602. Upload: withPreviewUpload(),
  603. };
  604. // 将组件注册到全局共享状态中
  605. globalShareState.setComponents(components);
  606. // 定义全局共享状态中的消息提示
  607. globalShareState.defineMessage({
  608. // 复制成功消息提示
  609. copyPreferencesSuccess: (title, content) => {
  610. notification.success({
  611. description: content,
  612. message: title,
  613. placement: 'bottomRight',
  614. });
  615. },
  616. });
  617. }
  618. export { initComponentAdapter };