basic.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. <script lang="ts" setup>
  2. import type { UploadFile } from 'ant-design-vue';
  3. import { h, ref, toRaw } from 'vue';
  4. import { Page } from '@vben/common-ui';
  5. import { useDebounceFn } from '@vueuse/core';
  6. import { Button, Card, message, Spin, Tag } from 'ant-design-vue';
  7. import dayjs from 'dayjs';
  8. import { useVbenForm, z } from '#/adapter/form';
  9. import { getAllMenusApi } from '#/api';
  10. import { upload_file } from '#/api/examples/upload';
  11. import { $t } from '#/locales';
  12. import DocButton from '../doc-button.vue';
  13. const keyword = ref('');
  14. const fetching = ref(false);
  15. // 模拟远程获取数据
  16. function fetchRemoteOptions({ keyword = '选项' }: Record<string, any>) {
  17. fetching.value = true;
  18. return new Promise((resolve) => {
  19. setTimeout(() => {
  20. const options = Array.from({ length: 10 }).map((_, index) => ({
  21. label: `${keyword}-${index}`,
  22. value: `${keyword}-${index}`,
  23. }));
  24. resolve(options);
  25. fetching.value = false;
  26. }, 1000);
  27. });
  28. }
  29. const [BaseForm, baseFormApi] = useVbenForm({
  30. // 所有表单项共用,可单独在表单内覆盖
  31. commonConfig: {
  32. // 在label后显示一个冒号
  33. colon: true,
  34. // 所有表单项
  35. componentProps: {
  36. class: 'w-full',
  37. },
  38. },
  39. fieldMappingTime: [['rangePicker', ['startTime', 'endTime'], 'YYYY-MM-DD']],
  40. // 提交函数
  41. handleSubmit: onSubmit,
  42. handleValuesChange(_values, fieldsChanged) {
  43. message.info(`表单以下字段发生变化:${fieldsChanged.join(',')}`);
  44. },
  45. // 垂直布局,label和input在不同行,值为vertical
  46. // 水平布局,label和input在同一行
  47. layout: 'horizontal',
  48. schema: [
  49. {
  50. // 组件需要在 #/adapter.ts内注册,并加上类型
  51. component: 'Input',
  52. // 对应组件的参数
  53. componentProps: {
  54. placeholder: '请输入用户名',
  55. },
  56. // 字段名
  57. fieldName: 'username',
  58. // 界面显示的label
  59. label: '字符串',
  60. rules: 'required',
  61. },
  62. {
  63. component: 'Input',
  64. fieldName: 'desc',
  65. // 界面显示的description
  66. description: '这是表单描述',
  67. label: '字符串(带描述)',
  68. },
  69. {
  70. // 组件需要在 #/adapter.ts内注册,并加上类型
  71. component: 'ApiSelect',
  72. // 对应组件的参数
  73. componentProps: {
  74. // 菜单接口转options格式
  75. afterFetch: (data: { name: string; path: string }[]) => {
  76. return data.map((item: any) => ({
  77. label: item.name,
  78. value: item.path,
  79. }));
  80. },
  81. // 菜单接口
  82. api: getAllMenusApi,
  83. autoSelect: 'first',
  84. },
  85. // 字段名
  86. fieldName: 'api',
  87. // 界面显示的label
  88. label: 'ApiSelect',
  89. },
  90. {
  91. component: 'ApiSelect',
  92. // 对应组件的参数
  93. componentProps: () => {
  94. return {
  95. api: fetchRemoteOptions,
  96. // 禁止本地过滤
  97. filterOption: false,
  98. // 如果正在获取数据,使用插槽显示一个loading
  99. notFoundContent: fetching.value ? undefined : null,
  100. // 搜索词变化时记录下来, 使用useDebounceFn防抖。
  101. onSearch: useDebounceFn((value: string) => {
  102. keyword.value = value;
  103. }, 300),
  104. // 远程搜索参数。当搜索词变化时,params也会更新
  105. params: {
  106. keyword: keyword.value || undefined,
  107. },
  108. showSearch: true,
  109. };
  110. },
  111. // 字段名
  112. fieldName: 'remoteSearch',
  113. // 界面显示的label
  114. label: '远程搜索',
  115. renderComponentContent: () => {
  116. return {
  117. notFoundContent: fetching.value ? h(Spin) : undefined,
  118. };
  119. },
  120. rules: 'selectRequired',
  121. },
  122. {
  123. component: 'ApiTreeSelect',
  124. // 对应组件的参数
  125. componentProps: {
  126. // 菜单接口
  127. api: getAllMenusApi,
  128. // 菜单接口转options格式
  129. labelField: 'name',
  130. valueField: 'path',
  131. childrenField: 'children',
  132. },
  133. // 字段名
  134. fieldName: 'apiTree',
  135. // 界面显示的label
  136. label: 'ApiTreeSelect',
  137. },
  138. {
  139. component: 'InputPassword',
  140. componentProps: {
  141. placeholder: '请输入密码',
  142. },
  143. fieldName: 'password',
  144. label: '密码',
  145. },
  146. {
  147. component: 'InputNumber',
  148. componentProps: {
  149. placeholder: '请输入',
  150. },
  151. fieldName: 'number',
  152. label: '数字(带后缀)',
  153. suffix: () => '¥',
  154. },
  155. {
  156. component: 'IconPicker',
  157. fieldName: 'icon',
  158. label: '图标',
  159. },
  160. {
  161. colon: false,
  162. component: 'Select',
  163. componentProps: {
  164. allowClear: true,
  165. filterOption: true,
  166. options: [
  167. {
  168. label: '选项1',
  169. value: '1',
  170. },
  171. {
  172. label: '选项2',
  173. value: '2',
  174. },
  175. ],
  176. placeholder: '请选择',
  177. showSearch: true,
  178. },
  179. fieldName: 'options',
  180. label: () => h(Tag, { color: 'warning' }, () => '😎自定义:'),
  181. },
  182. {
  183. component: 'RadioGroup',
  184. componentProps: {
  185. options: [
  186. {
  187. label: '选项1',
  188. value: '1',
  189. },
  190. {
  191. label: '选项2',
  192. value: '2',
  193. },
  194. ],
  195. },
  196. fieldName: 'radioGroup',
  197. label: '单选组',
  198. },
  199. {
  200. component: 'Radio',
  201. fieldName: 'radio',
  202. label: '',
  203. renderComponentContent: () => {
  204. return {
  205. default: () => ['Radio'],
  206. };
  207. },
  208. },
  209. {
  210. component: 'CheckboxGroup',
  211. componentProps: {
  212. name: 'cname',
  213. options: [
  214. {
  215. label: '选项1',
  216. value: '1',
  217. },
  218. {
  219. label: '选项2',
  220. value: '2',
  221. },
  222. ],
  223. },
  224. fieldName: 'checkboxGroup',
  225. label: '多选组',
  226. },
  227. {
  228. component: 'Checkbox',
  229. fieldName: 'checkbox',
  230. label: '',
  231. renderComponentContent: () => {
  232. return {
  233. default: () => ['我已阅读并同意'],
  234. };
  235. },
  236. rules: z
  237. .boolean()
  238. .refine((v) => v, { message: '为什么不同意?勾上它!' }),
  239. },
  240. {
  241. component: 'Mentions',
  242. componentProps: {
  243. options: [
  244. {
  245. label: 'afc163',
  246. value: 'afc163',
  247. },
  248. {
  249. label: 'zombieJ',
  250. value: 'zombieJ',
  251. },
  252. ],
  253. placeholder: '请输入',
  254. },
  255. fieldName: 'mentions',
  256. label: '提及',
  257. },
  258. {
  259. component: 'Rate',
  260. fieldName: 'rate',
  261. label: '评分',
  262. },
  263. {
  264. component: 'Switch',
  265. componentProps: {
  266. class: 'w-auto',
  267. },
  268. fieldName: 'switch',
  269. help: () =>
  270. ['这是一个多行帮助信息', '第二行', '第三行'].map((v) => h('p', v)),
  271. label: '开关',
  272. },
  273. {
  274. component: 'DatePicker',
  275. fieldName: 'datePicker',
  276. label: '日期选择框',
  277. },
  278. {
  279. component: 'RangePicker',
  280. fieldName: 'rangePicker',
  281. label: '范围选择器',
  282. },
  283. {
  284. component: 'TimePicker',
  285. fieldName: 'timePicker',
  286. label: '时间选择框',
  287. },
  288. {
  289. component: 'TreeSelect',
  290. componentProps: {
  291. allowClear: true,
  292. placeholder: '请选择',
  293. showSearch: true,
  294. treeData: [
  295. {
  296. label: 'root 1',
  297. value: 'root 1',
  298. children: [
  299. {
  300. label: 'parent 1',
  301. value: 'parent 1',
  302. children: [
  303. {
  304. label: 'parent 1-0',
  305. value: 'parent 1-0',
  306. children: [
  307. {
  308. label: 'my leaf',
  309. value: 'leaf1',
  310. },
  311. {
  312. label: 'your leaf',
  313. value: 'leaf2',
  314. },
  315. ],
  316. },
  317. {
  318. label: 'parent 1-1',
  319. value: 'parent 1-1',
  320. },
  321. ],
  322. },
  323. {
  324. label: 'parent 2',
  325. value: 'parent 2',
  326. },
  327. ],
  328. },
  329. ],
  330. treeNodeFilterProp: 'label',
  331. },
  332. fieldName: 'treeSelect',
  333. label: '树选择',
  334. },
  335. {
  336. component: 'Upload',
  337. componentProps: {
  338. // 更多属性见:https://ant.design/components/upload-cn
  339. accept: '.png,.jpg,.jpeg',
  340. // 自动携带认证信息
  341. customRequest: upload_file,
  342. disabled: false,
  343. maxCount: 3,
  344. // 单位:MB
  345. maxSize: 2,
  346. multiple: false,
  347. showUploadList: true,
  348. // 上传列表的内建样式,支持四种基本样式 text, picture, picture-card 和 picture-circle
  349. listType: 'picture-card',
  350. draggable: true, // 启用拖拽排序
  351. // onChange事件已被重写,如需自定义请在此基础上扩展
  352. handleChange: ({ file }: { file: UploadFile }) => {
  353. const { name, status } = file;
  354. if (status === 'done') {
  355. message.success(`${name} ${$t('examples.form.upload-success')}`);
  356. } else if (status === 'error') {
  357. message.error(`${name} ${$t('examples.form.upload-fail')}`);
  358. }
  359. },
  360. onDragSort: (oldIndex: number, newIndex: number) => {
  361. console.info(`图片从 ${oldIndex} 移动到 ${newIndex}`);
  362. },
  363. },
  364. fieldName: 'files',
  365. label: $t('examples.form.file'),
  366. renderComponentContent: () => {
  367. return {
  368. default: () => $t('examples.form.upload-image'),
  369. };
  370. },
  371. rules: 'selectRequired',
  372. },
  373. {
  374. component: 'Upload',
  375. componentProps: {
  376. accept: '.png,.jpg,.jpeg',
  377. customRequest: upload_file,
  378. maxCount: 1,
  379. maxSize: 2,
  380. listType: 'picture-card',
  381. // 是否启用图片裁剪(多选或者非图片不唤起裁剪框)
  382. crop: true,
  383. // 裁剪比例
  384. aspectRatio: '1:1',
  385. },
  386. fieldName: 'cropImage',
  387. label: $t('examples.form.crop-image'),
  388. renderComponentContent: () => {
  389. return {
  390. default: () => $t('examples.form.upload-image'),
  391. };
  392. },
  393. rules: 'selectRequired',
  394. },
  395. ],
  396. // 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个
  397. wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
  398. });
  399. function onSubmit(values: Record<string, any>) {
  400. const files = toRaw(values.files) as UploadFile[];
  401. const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[];
  402. const doneFiles = files.filter((file) => file.status === 'done');
  403. const failedFiles = files.filter((file) => file.status !== 'done');
  404. const doneCrop = cropImage.filter((file) => file.status === 'done');
  405. const failedCrop = cropImage.filter((file) => file.status !== 'done');
  406. const msg = [
  407. ...doneFiles.map((file) => file.response?.url || file.url),
  408. ...failedFiles.map((file) => file.name),
  409. ].join(', ');
  410. const msgCrop = [
  411. ...doneCrop.map((file) => file.response?.url || file.url),
  412. ...failedCrop.map((file) => file.name),
  413. ].join(', ');
  414. if (failedFiles.length === 0) {
  415. message.success({
  416. content: `${$t('examples.form.upload-urls')}: ${msg}`,
  417. });
  418. } else {
  419. message.error({
  420. content: `${$t('examples.form.upload-error')}: ${msg}`,
  421. });
  422. return;
  423. }
  424. if (doneCrop.length > 0 && failedCrop.length === 0) {
  425. message.success({
  426. content: `${$t('examples.form.upload-urls')}: ${msgCrop}`,
  427. });
  428. } else if (failedCrop.length > 0) {
  429. message.error({
  430. content: `${$t('examples.form.upload-error')}: ${msgCrop}`,
  431. });
  432. return;
  433. }
  434. // 如果需要可提交前替换为需要的urls
  435. values.files = doneFiles.map((file) => file.response?.url || file.url);
  436. values.cropImage = doneCrop.map((file) => file.response?.url || file.url);
  437. message.success({
  438. content: `form values: ${JSON.stringify(values)}`,
  439. });
  440. }
  441. function handleSetFormValue() {
  442. /**
  443. * 设置表单值(多个)
  444. */
  445. baseFormApi.setValues({
  446. checkboxGroup: ['1'],
  447. datePicker: dayjs('2022-01-01'),
  448. files: [
  449. {
  450. name: 'example.png',
  451. status: 'done',
  452. uid: '-1',
  453. url: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
  454. },
  455. ],
  456. mentions: '@afc163',
  457. number: 3,
  458. options: '1',
  459. password: '2',
  460. radioGroup: '1',
  461. rangePicker: [dayjs('2022-01-01'), dayjs('2022-01-02')],
  462. rate: 3,
  463. switch: true,
  464. timePicker: dayjs('2022-01-01 12:00:00'),
  465. treeSelect: 'leaf1',
  466. username: '1',
  467. });
  468. // 设置单个表单值
  469. baseFormApi.setFieldValue('checkbox', true);
  470. }
  471. </script>
  472. <template>
  473. <Page
  474. content-class="flex flex-col gap-4"
  475. description="表单组件基础示例,请注意,该页面用到的参数代码会添加一些简单注释,方便理解,请仔细查看。"
  476. title="表单组件"
  477. >
  478. <template #description>
  479. <div class="text-muted-foreground">
  480. <p>
  481. 表单组件基础示例,请注意,该页面用到的参数代码会添加一些简单注释,方便理解,请仔细查看。
  482. </p>
  483. </div>
  484. </template>
  485. <template #extra>
  486. <DocButton class="mb-2" path="/components/common-ui/vben-form" />
  487. </template>
  488. <Card title="基础示例">
  489. <template #extra>
  490. <Button type="primary" @click="handleSetFormValue">设置表单值</Button>
  491. </template>
  492. <BaseForm />
  493. </Card>
  494. </Page>
  495. </template>