form.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. <script lang="ts" setup>
  2. import type { ChangeEvent } from 'ant-design-vue/es/_util/EventInterface';
  3. import type { Recordable } from '@vben/types';
  4. import type { VbenFormSchema } from '#/adapter/form';
  5. import { computed, h, ref } from 'vue';
  6. import { useVbenDrawer } from '@vben/common-ui';
  7. import { IconifyIcon } from '@vben/icons';
  8. import { $te } from '@vben/locales';
  9. import { getPopupContainer } from '@vben/utils';
  10. import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
  11. import { useVbenForm, z } from '#/adapter/form';
  12. import {
  13. createMenu,
  14. getMenuList,
  15. isMenuNameExists,
  16. isMenuPathExists,
  17. SystemMenuApi,
  18. updateMenu,
  19. } from '#/api/system/menu';
  20. import { $t } from '#/locales';
  21. import { componentKeys } from '#/router/routes';
  22. import { getMenuTypeOptions } from '../data';
  23. const emit = defineEmits<{
  24. success: [];
  25. }>();
  26. const formData = ref<SystemMenuApi.SystemMenu>();
  27. const loading = ref(false);
  28. const titleSuffix = ref<string>();
  29. const schema: VbenFormSchema[] = [
  30. {
  31. component: 'RadioGroup',
  32. componentProps: {
  33. buttonStyle: 'solid',
  34. options: getMenuTypeOptions(),
  35. optionType: 'button',
  36. },
  37. defaultValue: 'menu',
  38. fieldName: 'type',
  39. formItemClass: 'col-span-2 md:col-span-2',
  40. label: $t('system.menu.type'),
  41. },
  42. {
  43. component: 'Input',
  44. fieldName: 'name',
  45. label: $t('system.menu.menuName'),
  46. rules: z
  47. .string()
  48. .min(2, $t('ui.formRules.minLength', [$t('system.menu.menuName'), 2]))
  49. .max(30, $t('ui.formRules.maxLength', [$t('system.menu.menuName'), 30]))
  50. .refine(
  51. async (value: string) => {
  52. return !(await isMenuNameExists(value, formData.value?.id));
  53. },
  54. (value) => ({
  55. message: $t('ui.formRules.alreadyExists', [
  56. $t('system.menu.menuName'),
  57. value,
  58. ]),
  59. }),
  60. ),
  61. },
  62. {
  63. component: 'ApiTreeSelect',
  64. componentProps: {
  65. api: getMenuList,
  66. class: 'w-full',
  67. filterTreeNode(input: string, node: Recordable<any>) {
  68. if (!input || input.length === 0) {
  69. return true;
  70. }
  71. const title: string = node.meta?.title ?? '';
  72. if (!title) return false;
  73. return title.includes(input) || $t(title).includes(input);
  74. },
  75. getPopupContainer,
  76. labelField: 'meta.title',
  77. showSearch: true,
  78. treeDefaultExpandAll: true,
  79. valueField: 'id',
  80. childrenField: 'children',
  81. },
  82. fieldName: 'pid',
  83. label: $t('system.menu.parent'),
  84. renderComponentContent() {
  85. return {
  86. title({ label, meta }: { label: string; meta: Recordable<any> }) {
  87. const coms = [];
  88. if (!label) return '';
  89. if (meta?.icon) {
  90. coms.push(h(IconifyIcon, { class: 'size-4', icon: meta.icon }));
  91. }
  92. coms.push(h('span', { class: '' }, $t(label || '')));
  93. return h('div', { class: 'flex items-center gap-1' }, coms);
  94. },
  95. };
  96. },
  97. },
  98. {
  99. component: 'Input',
  100. componentProps() {
  101. // 不需要处理多语言时就无需这么做
  102. return {
  103. addonAfter: titleSuffix.value,
  104. onChange({ target: { value } }: ChangeEvent) {
  105. titleSuffix.value = value && $te(value) ? $t(value) : undefined;
  106. },
  107. };
  108. },
  109. fieldName: 'meta.title',
  110. label: $t('system.menu.menuTitle'),
  111. rules: 'required',
  112. },
  113. {
  114. component: 'Input',
  115. dependencies: {
  116. show: (values) => {
  117. return ['catalog', 'embedded', 'menu'].includes(values.type);
  118. },
  119. triggerFields: ['type'],
  120. },
  121. fieldName: 'path',
  122. label: $t('system.menu.path'),
  123. rules: z
  124. .string()
  125. .min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
  126. .max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
  127. .refine(
  128. (value: string) => {
  129. return value.startsWith('/');
  130. },
  131. $t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
  132. )
  133. .refine(
  134. async (value: string) => {
  135. return !(await isMenuPathExists(value, formData.value?.id));
  136. },
  137. (value) => ({
  138. message: $t('ui.formRules.alreadyExists', [
  139. $t('system.menu.path'),
  140. value,
  141. ]),
  142. }),
  143. ),
  144. },
  145. {
  146. component: 'Input',
  147. dependencies: {
  148. show: (values) => {
  149. return ['embedded', 'menu'].includes(values.type);
  150. },
  151. triggerFields: ['type'],
  152. },
  153. fieldName: 'activePath',
  154. help: $t('system.menu.activePathHelp'),
  155. label: $t('system.menu.activePath'),
  156. rules: z
  157. .string()
  158. .min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
  159. .max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
  160. .refine(
  161. (value: string) => {
  162. return value.startsWith('/');
  163. },
  164. $t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
  165. )
  166. .refine(async (value: string) => {
  167. return await isMenuPathExists(value, formData.value?.id);
  168. }, $t('system.menu.activePathMustExist'))
  169. .optional(),
  170. },
  171. {
  172. component: 'IconPicker',
  173. componentProps: {
  174. prefix: 'carbon',
  175. },
  176. dependencies: {
  177. show: (values) => {
  178. return ['catalog', 'embedded', 'link', 'menu'].includes(values.type);
  179. },
  180. triggerFields: ['type'],
  181. },
  182. fieldName: 'meta.icon',
  183. label: $t('system.menu.icon'),
  184. },
  185. {
  186. component: 'IconPicker',
  187. componentProps: {
  188. prefix: 'carbon',
  189. },
  190. dependencies: {
  191. show: (values) => {
  192. return ['catalog', 'embedded', 'menu'].includes(values.type);
  193. },
  194. triggerFields: ['type'],
  195. },
  196. fieldName: 'meta.activeIcon',
  197. label: $t('system.menu.activeIcon'),
  198. },
  199. {
  200. component: 'AutoComplete',
  201. componentProps: {
  202. allowClear: true,
  203. class: 'w-full',
  204. filterOption(input: string, option: { value: string }) {
  205. return option.value.toLowerCase().includes(input.toLowerCase());
  206. },
  207. options: componentKeys.map((v) => ({ value: v })),
  208. },
  209. dependencies: {
  210. rules: (values) => {
  211. return values.type === 'menu' ? 'required' : null;
  212. },
  213. show: (values) => {
  214. return values.type === 'menu';
  215. },
  216. triggerFields: ['type'],
  217. },
  218. fieldName: 'component',
  219. label: $t('system.menu.component'),
  220. },
  221. {
  222. component: 'Input',
  223. dependencies: {
  224. show: (values) => {
  225. return ['embedded', 'link'].includes(values.type);
  226. },
  227. triggerFields: ['type'],
  228. },
  229. fieldName: 'linkSrc',
  230. label: $t('system.menu.linkSrc'),
  231. rules: z.string().url($t('ui.formRules.invalidURL')),
  232. },
  233. {
  234. component: 'Input',
  235. dependencies: {
  236. rules: (values) => {
  237. return values.type === 'button' ? 'required' : null;
  238. },
  239. show: (values) => {
  240. return ['button', 'catalog', 'embedded', 'menu'].includes(values.type);
  241. },
  242. triggerFields: ['type'],
  243. },
  244. fieldName: 'authCode',
  245. label: $t('system.menu.authCode'),
  246. },
  247. {
  248. component: 'RadioGroup',
  249. componentProps: {
  250. buttonStyle: 'solid',
  251. options: [
  252. { label: $t('common.enabled'), value: 1 },
  253. { label: $t('common.disabled'), value: 0 },
  254. ],
  255. optionType: 'button',
  256. },
  257. defaultValue: 1,
  258. fieldName: 'status',
  259. label: $t('system.menu.status'),
  260. },
  261. {
  262. component: 'Select',
  263. componentProps: {
  264. allowClear: true,
  265. class: 'w-full',
  266. options: [
  267. { label: $t('system.menu.badgeType.dot'), value: 'dot' },
  268. { label: $t('system.menu.badgeType.normal'), value: 'normal' },
  269. ],
  270. },
  271. dependencies: {
  272. show: (values) => {
  273. return values.type !== 'button';
  274. },
  275. triggerFields: ['type'],
  276. },
  277. fieldName: 'meta.badgeType',
  278. label: $t('system.menu.badgeType.title'),
  279. },
  280. {
  281. component: 'Input',
  282. componentProps: (values) => {
  283. return {
  284. allowClear: true,
  285. class: 'w-full',
  286. disabled: values.meta?.badgeType !== 'normal',
  287. };
  288. },
  289. dependencies: {
  290. show: (values) => {
  291. return values.type !== 'button';
  292. },
  293. triggerFields: ['type'],
  294. },
  295. fieldName: 'meta.badge',
  296. label: $t('system.menu.badge'),
  297. },
  298. {
  299. component: 'Select',
  300. componentProps: {
  301. allowClear: true,
  302. class: 'w-full',
  303. options: SystemMenuApi.BadgeVariants.map((v) => ({
  304. label: v,
  305. value: v,
  306. })),
  307. },
  308. dependencies: {
  309. show: (values) => {
  310. return values.type !== 'button';
  311. },
  312. triggerFields: ['type'],
  313. },
  314. fieldName: 'meta.badgeVariants',
  315. label: $t('system.menu.badgeVariants'),
  316. },
  317. {
  318. component: 'Divider',
  319. dependencies: {
  320. show: (values) => {
  321. return !['button', 'link'].includes(values.type);
  322. },
  323. triggerFields: ['type'],
  324. },
  325. fieldName: 'divider1',
  326. formItemClass: 'col-span-2 md:col-span-2 pb-0',
  327. hideLabel: true,
  328. renderComponentContent() {
  329. return {
  330. default: () => $t('system.menu.advancedSettings'),
  331. };
  332. },
  333. },
  334. {
  335. component: 'Checkbox',
  336. dependencies: {
  337. show: (values) => {
  338. return ['menu'].includes(values.type);
  339. },
  340. triggerFields: ['type'],
  341. },
  342. fieldName: 'meta.keepAlive',
  343. renderComponentContent() {
  344. return {
  345. default: () => $t('system.menu.keepAlive'),
  346. };
  347. },
  348. },
  349. {
  350. component: 'Checkbox',
  351. dependencies: {
  352. show: (values) => {
  353. return ['embedded', 'menu'].includes(values.type);
  354. },
  355. triggerFields: ['type'],
  356. },
  357. fieldName: 'meta.affixTab',
  358. renderComponentContent() {
  359. return {
  360. default: () => $t('system.menu.affixTab'),
  361. };
  362. },
  363. },
  364. {
  365. component: 'Checkbox',
  366. dependencies: {
  367. show: (values) => {
  368. return !['button'].includes(values.type);
  369. },
  370. triggerFields: ['type'],
  371. },
  372. fieldName: 'meta.hideInMenu',
  373. renderComponentContent() {
  374. return {
  375. default: () => $t('system.menu.hideInMenu'),
  376. };
  377. },
  378. },
  379. {
  380. component: 'Checkbox',
  381. dependencies: {
  382. show: (values) => {
  383. return ['catalog', 'menu'].includes(values.type);
  384. },
  385. triggerFields: ['type'],
  386. },
  387. fieldName: 'meta.hideChildrenInMenu',
  388. renderComponentContent() {
  389. return {
  390. default: () => $t('system.menu.hideChildrenInMenu'),
  391. };
  392. },
  393. },
  394. {
  395. component: 'Checkbox',
  396. dependencies: {
  397. show: (values) => {
  398. return !['button', 'link'].includes(values.type);
  399. },
  400. triggerFields: ['type'],
  401. },
  402. fieldName: 'meta.hideInBreadcrumb',
  403. renderComponentContent() {
  404. return {
  405. default: () => $t('system.menu.hideInBreadcrumb'),
  406. };
  407. },
  408. },
  409. {
  410. component: 'Checkbox',
  411. dependencies: {
  412. show: (values) => {
  413. return !['button', 'link'].includes(values.type);
  414. },
  415. triggerFields: ['type'],
  416. },
  417. fieldName: 'meta.hideInTab',
  418. renderComponentContent() {
  419. return {
  420. default: () => $t('system.menu.hideInTab'),
  421. };
  422. },
  423. },
  424. ];
  425. const breakpoints = useBreakpoints(breakpointsTailwind);
  426. const isHorizontal = computed(() => breakpoints.greaterOrEqual('md').value);
  427. const [Form, formApi] = useVbenForm({
  428. commonConfig: {
  429. colon: true,
  430. formItemClass: 'col-span-2 md:col-span-1',
  431. },
  432. schema,
  433. showDefaultActions: false,
  434. wrapperClass: 'grid-cols-2 gap-x-4',
  435. });
  436. const [Drawer, drawerApi] = useVbenDrawer({
  437. onBeforeClose() {
  438. if (loading.value) return false;
  439. },
  440. onConfirm: onSubmit,
  441. onOpenChange(isOpen) {
  442. if (isOpen) {
  443. const data = drawerApi.getData<SystemMenuApi.SystemMenu>();
  444. if (data?.type === 'link') {
  445. data.linkSrc = data.meta?.link;
  446. } else if (data?.type === 'embedded') {
  447. data.linkSrc = data.meta?.iframeSrc;
  448. }
  449. if (data) {
  450. formData.value = data;
  451. formApi.setValues(formData.value);
  452. titleSuffix.value = formData.value.meta?.title
  453. ? $t(formData.value.meta.title)
  454. : '';
  455. } else {
  456. formApi.resetForm();
  457. titleSuffix.value = '';
  458. }
  459. }
  460. },
  461. });
  462. async function onSubmit() {
  463. const { valid } = await formApi.validate();
  464. if (valid) {
  465. loading.value = true;
  466. drawerApi.setState({
  467. closeOnClickModal: false,
  468. closeOnPressEscape: false,
  469. confirmLoading: true,
  470. loading: true,
  471. });
  472. const data =
  473. await formApi.getValues<
  474. Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>
  475. >();
  476. if (data.type === 'link') {
  477. data.meta = { ...data.meta, link: data.linkSrc };
  478. } else if (data.type === 'embedded') {
  479. data.meta = { ...data.meta, iframeSrc: data.linkSrc };
  480. }
  481. delete data.linkSrc;
  482. try {
  483. await (formData.value?.id
  484. ? updateMenu(formData.value.id, data)
  485. : createMenu(data));
  486. drawerApi.close();
  487. emit('success');
  488. } finally {
  489. loading.value = false;
  490. drawerApi.setState({
  491. closeOnClickModal: true,
  492. closeOnPressEscape: true,
  493. confirmLoading: false,
  494. loading: false,
  495. });
  496. }
  497. }
  498. }
  499. const getDrawerTitle = computed(() =>
  500. formData.value?.id
  501. ? $t('ui.actionTitle.edit', [$t('system.menu.name')])
  502. : $t('ui.actionTitle.create', [$t('system.menu.name')]),
  503. );
  504. </script>
  505. <template>
  506. <Drawer class="w-full max-w-[800px]" :title="getDrawerTitle">
  507. <Form class="mx-4" :layout="isHorizontal ? 'horizontal' : 'vertical'" />
  508. </Drawer>
  509. </template>