FormManagement.tsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918
  1. import React, { useState, useEffect, useMemo } from 'react';
  2. import { useNavigate, useLocation } from 'react-router-dom';
  3. import { Plus, Edit2, Trash2, Copy, Eye, X, Search, RotateCcw } from 'lucide-react';
  4. import { Button, Space, Table as AntdTable, Modal, message, Input, Form as AntdForm, Select, DatePicker, TimePicker, InputNumber, Switch, Radio, Checkbox, Card, Alert, Cascader, Upload, Tooltip } from 'antd';
  5. import { UploadOutlined } from '@ant-design/icons';
  6. import type { ColumnsType } from 'antd/es/table';
  7. import { toast } from 'sonner';
  8. import * as FormApi from '../api/bpm/form';
  9. import { DICT_TYPE, getDictLabel } from '../utils/dict';
  10. import { dateFormatter } from '../utils/formatTime';
  11. import { setConfAndFields2, FormCreateData } from '../utils/formCreate';
  12. import { useTranslation } from 'react-i18next';
  13. import FormUploadField from './FormUploadField';
  14. export default function FormManagement() {
  15. const { t } = useTranslation();
  16. const navigate = useNavigate();
  17. const location = useLocation();
  18. // 调试信息
  19. useEffect(() => {
  20. console.log('FormManagement 组件已加载');
  21. }, []);
  22. const [loading, setLoading] = useState(true);
  23. const [list, setList] = useState<FormApi.FormVO[]>([]);
  24. const [total, setTotal] = useState(0);
  25. const [queryParams, setQueryParams] = useState<FormApi.FormPageParams>({
  26. pageNo: 1,
  27. pageSize: 10,
  28. name: '',
  29. });
  30. const [searchName, setSearchName] = useState('');
  31. const [detailForm] = AntdForm.useForm();
  32. const [formStatusUpdating, setFormStatusUpdating] = useState<Record<number, boolean>>({});
  33. // 详情弹窗
  34. const [detailVisible, setDetailVisible] = useState(false);
  35. const [detailData, setDetailData] = useState<FormCreateData>({
  36. rule: [],
  37. option: {}
  38. });
  39. const defaultFormConfig = {
  40. name: '',
  41. labelPosition: 'right',
  42. formSize: 'middle',
  43. labelSuffix: '',
  44. labelWidth: 100,
  45. layoutColumns: 1 as 1 | 2 | 3,
  46. hideRequiredMark: false,
  47. showValidationError: true,
  48. inlineValidation: false,
  49. showSubmitButton: false,
  50. showResetButton: false,
  51. };
  52. // 合并表单配置:兼容 option.formConfig 或 option 即 formConfig 两种后端结构
  53. const getMergedFormConfig = () => ({
  54. ...defaultFormConfig,
  55. ...(detailData.option?.formConfig || detailData.option || {}),
  56. });
  57. // 渲染字段预览(支持嵌套结构)
  58. const renderFieldPreview = (field: any, parentSpanStyle?: React.CSSProperties): React.ReactNode => {
  59. const formConfig = getMergedFormConfig();
  60. const layoutColumns = formConfig.layoutColumns || 1;
  61. const spanStyle = parentSpanStyle || (layoutColumns > 1 ? { gridColumn: `span ${Math.min(layoutColumns, field.span || 1)}` } : undefined);
  62. const wrapperStyle: React.CSSProperties = spanStyle || {};
  63. // 处理容器类型(card 和 grid)
  64. if (field.type === 'card') {
  65. const children = field.children || [];
  66. // 优先使用 label(字段名称),如果没有则使用 cardTitle,最后使用默认值
  67. const cardTitle = field.label || field.cardTitle || t('common.cardContainer');
  68. return (
  69. <div key={field.id} style={spanStyle} className="mb-4">
  70. <Card title={cardTitle} className="w-full">
  71. <div className="space-y-4">
  72. {children.map((child: any) => renderFieldPreview(child))}
  73. </div>
  74. </Card>
  75. </div>
  76. );
  77. }
  78. if (field.type === 'grid') {
  79. const gridColumns = field.gridColumns || 2;
  80. const children = field.children || [];
  81. return (
  82. <div key={field.id} style={spanStyle} className="mb-4">
  83. <div
  84. style={{
  85. display: 'grid',
  86. gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
  87. gap: '12px',
  88. rowGap: '16px',
  89. }}
  90. >
  91. {children.map((child: any) => {
  92. const childSpanStyle = gridColumns > 1
  93. ? { gridColumn: `span ${Math.min(gridColumns, child.span || 1)}` }
  94. : undefined;
  95. return (
  96. <div key={child.id} style={childSpanStyle}>
  97. {renderFieldPreview(child)}
  98. </div>
  99. );
  100. })}
  101. </div>
  102. </div>
  103. );
  104. }
  105. // 处理 alert 类型
  106. if (field.type === 'alert') {
  107. const themeClass =
  108. field.alertTheme === 'dark'
  109. ? 'bg-gray-800 text-white border-gray-700'
  110. : '';
  111. return (
  112. <div key={field.id} className="mb-4" style={spanStyle}>
  113. <Alert
  114. message={field.alertTitle || field.label}
  115. description={field.alertDescription}
  116. type={field.alertType || 'info'}
  117. showIcon={field.alertShowIcon !== false}
  118. closable={field.alertClosable}
  119. closeText={field.alertCloseText}
  120. className={`${field.alertCentered ? 'text-center' : ''} ${themeClass}`}
  121. banner
  122. style={field.style}
  123. />
  124. {field.hint && <div className="text-xs text-gray-400 mt-1">{field.hint}</div>}
  125. </div>
  126. );
  127. }
  128. // 与表单设计器一致:每字段 labelCol/wrapperCol
  129. const getItemLayout = (f: any) => {
  130. const isTop = formConfig.labelPosition === 'top';
  131. const w = f.labelWidth ?? formConfig.labelWidth ?? 100;
  132. const effective = (typeof w === 'number' && w > 0) ? w : 100;
  133. return {
  134. labelCol: isTop ? undefined : { flex: `${effective}px`, style: { minWidth: `${effective}px`, textAlign: formConfig.labelPosition === 'right' ? 'right' : 'left' } },
  135. wrapperCol: isTop ? undefined : { flex: 'auto', style: { minWidth: 0 } },
  136. };
  137. };
  138. const itemLayout = getItemLayout(field);
  139. // 处理普通字段
  140. switch (field.type) {
  141. case 'textarea':
  142. return (
  143. <div key={field.id} style={wrapperStyle}>
  144. <AntdForm.Item
  145. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  146. name={field.name || field.field}
  147. required={field.required && !formConfig.hideRequiredMark}
  148. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseInput') }] : []}
  149. help={field.hint}
  150. labelCol={itemLayout.labelCol}
  151. wrapperCol={itemLayout.wrapperCol}
  152. >
  153. <Input.TextArea
  154. placeholder={typeof field.placeholder === 'string' ? field.placeholder : t('common.pleaseInput')}
  155. rows={4}
  156. allowClear={field.showClear}
  157. maxLength={field.maxLength}
  158. readOnly={field.readOnly}
  159. disabled={field.disabled}
  160. size={field.size || 'middle'}
  161. />
  162. </AntdForm.Item>
  163. </div>
  164. );
  165. case 'password':
  166. return (
  167. <div key={field.id} style={wrapperStyle}>
  168. <AntdForm.Item
  169. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  170. name={field.name || field.field}
  171. required={field.required && !formConfig.hideRequiredMark}
  172. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseInput') }] : []}
  173. help={field.hint}
  174. labelCol={itemLayout.labelCol}
  175. wrapperCol={itemLayout.wrapperCol}
  176. >
  177. <Input.Password
  178. placeholder={typeof field.placeholder === 'string' ? field.placeholder : t('common.pleaseInput')}
  179. allowClear={field.showClear}
  180. maxLength={field.maxLength}
  181. readOnly={field.readOnly}
  182. disabled={field.disabled}
  183. size={field.size || 'middle'}
  184. />
  185. </AntdForm.Item>
  186. </div>
  187. );
  188. case 'number':
  189. return (
  190. <div key={field.id} style={wrapperStyle}>
  191. <AntdForm.Item
  192. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  193. name={field.name || field.field}
  194. required={field.required && !formConfig.hideRequiredMark}
  195. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseInput') }] : []}
  196. help={field.hint}
  197. labelCol={itemLayout.labelCol}
  198. wrapperCol={itemLayout.wrapperCol}
  199. >
  200. <InputNumber
  201. style={{ width: '100%' }}
  202. placeholder={typeof field.placeholder === 'string' ? field.placeholder : t('common.pleaseInput')}
  203. max={field.maxLength}
  204. readOnly={field.readOnly}
  205. disabled={field.disabled}
  206. size={field.size || 'middle'}
  207. />
  208. </AntdForm.Item>
  209. </div>
  210. );
  211. case 'select':
  212. return (
  213. <div key={field.id} style={wrapperStyle}>
  214. <AntdForm.Item
  215. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  216. name={field.name || field.field}
  217. required={field.required && !formConfig.hideRequiredMark}
  218. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelect') }] : []}
  219. help={field.hint}
  220. labelCol={itemLayout.labelCol}
  221. wrapperCol={itemLayout.wrapperCol}
  222. >
  223. <Select
  224. placeholder={typeof field.placeholder === 'string' ? field.placeholder : t('common.pleaseSelect')}
  225. allowClear={field.showClear}
  226. disabled={field.disabled}
  227. size={field.size || 'middle'}
  228. >
  229. {(field.options || []).map((opt: any, idx: number) => (
  230. <Select.Option key={idx} value={opt.value}>{opt.label}</Select.Option>
  231. ))}
  232. </Select>
  233. </AntdForm.Item>
  234. </div>
  235. );
  236. case 'date':
  237. return (
  238. <div key={field.id} style={wrapperStyle}>
  239. <AntdForm.Item
  240. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  241. name={field.name || field.field}
  242. required={field.required && !formConfig.hideRequiredMark}
  243. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelectDate') }] : []}
  244. help={field.hint}
  245. labelCol={itemLayout.labelCol}
  246. wrapperCol={itemLayout.wrapperCol}
  247. >
  248. <DatePicker
  249. className="w-full"
  250. placeholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}
  251. allowClear={field.showClear}
  252. disabled={field.disabled}
  253. size={field.size || 'middle'}
  254. />
  255. </AntdForm.Item>
  256. </div>
  257. );
  258. case 'daterange':
  259. return (
  260. <div key={field.id} style={wrapperStyle}>
  261. <AntdForm.Item
  262. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  263. name={field.name || field.field}
  264. required={field.required && !formConfig.hideRequiredMark}
  265. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelectDateRange') }] : []}
  266. help={field.hint}
  267. labelCol={itemLayout.labelCol}
  268. wrapperCol={itemLayout.wrapperCol}
  269. >
  270. <DatePicker.RangePicker
  271. className="w-full"
  272. placeholder={Array.isArray(field.placeholder) ? field.placeholder as [string, string] : [t('common.startDate'), t('common.endDate')]}
  273. allowClear={field.showClear}
  274. disabled={field.disabled}
  275. size={field.size || 'middle'}
  276. />
  277. </AntdForm.Item>
  278. </div>
  279. );
  280. case 'datetime':
  281. return (
  282. <div key={field.id} style={wrapperStyle}>
  283. <AntdForm.Item
  284. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  285. name={field.name || field.field}
  286. required={field.required && !formConfig.hideRequiredMark}
  287. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelectDateTime') }] : []}
  288. help={field.hint}
  289. labelCol={itemLayout.labelCol}
  290. wrapperCol={itemLayout.wrapperCol}
  291. >
  292. <DatePicker
  293. className="w-full"
  294. placeholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}
  295. allowClear={field.showClear}
  296. disabled={field.disabled}
  297. size={field.size || 'middle'}
  298. showTime={{ format: 'HH:mm:ss' }}
  299. format="YYYY-MM-DD HH:mm:ss"
  300. />
  301. </AntdForm.Item>
  302. </div>
  303. );
  304. case 'timepicker':
  305. return (
  306. <div key={field.id} style={wrapperStyle}>
  307. <AntdForm.Item
  308. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  309. name={field.name || field.field}
  310. required={field.required && !formConfig.hideRequiredMark}
  311. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelectTime') }] : []}
  312. help={field.hint}
  313. labelCol={itemLayout.labelCol}
  314. wrapperCol={itemLayout.wrapperCol}
  315. >
  316. <TimePicker
  317. className="w-full"
  318. placeholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}
  319. allowClear={field.showClear}
  320. disabled={field.disabled}
  321. size={field.size || 'middle'}
  322. format="HH:mm:ss"
  323. />
  324. </AntdForm.Item>
  325. </div>
  326. );
  327. case 'switch':
  328. return (
  329. <div key={field.id} style={wrapperStyle}>
  330. <AntdForm.Item
  331. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  332. name={field.name || field.field}
  333. required={field.required && !formConfig.hideRequiredMark}
  334. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelect') }] : []}
  335. help={field.hint}
  336. valuePropName="checked"
  337. labelCol={itemLayout.labelCol}
  338. wrapperCol={itemLayout.wrapperCol}
  339. >
  340. <Switch disabled={field.disabled} />
  341. </AntdForm.Item>
  342. </div>
  343. );
  344. case 'radio':
  345. return (
  346. <div key={field.id} style={wrapperStyle}>
  347. <AntdForm.Item
  348. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  349. name={field.name || field.field}
  350. required={field.required && !formConfig.hideRequiredMark}
  351. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelect') }] : []}
  352. help={field.hint}
  353. labelCol={itemLayout.labelCol}
  354. wrapperCol={itemLayout.wrapperCol}
  355. >
  356. <Radio.Group disabled={field.disabled}>
  357. {(field.options || []).map((opt: any, idx: number) => (
  358. <Radio key={idx} value={opt.value}>{opt.label}</Radio>
  359. ))}
  360. </Radio.Group>
  361. </AntdForm.Item>
  362. </div>
  363. );
  364. case 'checkbox':
  365. return (
  366. <div key={field.id} style={wrapperStyle}>
  367. <AntdForm.Item
  368. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  369. name={field.name || field.field}
  370. required={field.required && !formConfig.hideRequiredMark}
  371. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelect') }] : []}
  372. help={field.hint}
  373. labelCol={itemLayout.labelCol}
  374. wrapperCol={itemLayout.wrapperCol}
  375. >
  376. <Checkbox.Group disabled={field.disabled}>
  377. {(field.options || []).map((opt: any, idx: number) => (
  378. <Checkbox key={idx} value={opt.value}>{opt.label}</Checkbox>
  379. ))}
  380. </Checkbox.Group>
  381. </AntdForm.Item>
  382. </div>
  383. );
  384. case 'cascader':
  385. return (
  386. <div key={field.id} style={wrapperStyle}>
  387. <AntdForm.Item
  388. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  389. name={field.name || field.field}
  390. required={field.required && !formConfig.hideRequiredMark}
  391. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseSelect') }] : []}
  392. help={field.hint}
  393. labelCol={itemLayout.labelCol}
  394. wrapperCol={itemLayout.wrapperCol}
  395. >
  396. <Cascader
  397. placeholder={typeof field.placeholder === 'string' ? field.placeholder : t('common.pleaseSelect')}
  398. options={field.cascaderOptions || []}
  399. className="w-full"
  400. allowClear={field.showClear}
  401. disabled={field.disabled}
  402. size={field.size || 'middle'}
  403. />
  404. </AntdForm.Item>
  405. </div>
  406. );
  407. case 'upload':
  408. return (
  409. <div key={field.id} style={wrapperStyle}>
  410. <AntdForm.Item
  411. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  412. name={field.name || field.field}
  413. required={field.required && !formConfig.hideRequiredMark}
  414. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.upload') }] : []}
  415. help={field.hint}
  416. labelCol={itemLayout.labelCol}
  417. wrapperCol={itemLayout.wrapperCol}
  418. >
  419. <FormUploadField
  420. uploadType={field.uploadType}
  421. maxCount={field.maxCount}
  422. accept={field.accept}
  423. disabled={field.disabled}
  424. />
  425. </AntdForm.Item>
  426. </div>
  427. );
  428. default:
  429. return (
  430. <div key={field.id} style={wrapperStyle}>
  431. <AntdForm.Item
  432. label={(field.label || field.title || '') + (formConfig.labelSuffix || '')}
  433. name={field.name || field.field}
  434. required={field.required && !formConfig.hideRequiredMark}
  435. rules={field.required ? [{ required: true, message: field.requiredMessage || t('common.pleaseInput') }] : []}
  436. help={field.hint}
  437. labelCol={itemLayout.labelCol}
  438. wrapperCol={itemLayout.wrapperCol}
  439. >
  440. <Input
  441. type={field.inputType || 'text'}
  442. placeholder={typeof field.placeholder === 'string' ? field.placeholder : t('common.pleaseInput')}
  443. allowClear={field.showClear}
  444. maxLength={field.maxLength}
  445. readOnly={field.readOnly}
  446. disabled={field.disabled}
  447. size={field.size || 'middle'}
  448. />
  449. </AntdForm.Item>
  450. </div>
  451. );
  452. }
  453. };
  454. /** 查询列表 */
  455. const getList = async (params?: FormApi.FormPageParams) => {
  456. const searchParams = params || queryParams;
  457. setLoading(true);
  458. try {
  459. console.log('FormManagement: 开始获取表单列表', searchParams);
  460. const data = await FormApi.getFormPage(searchParams);
  461. console.log('FormManagement: 获取到数据', data);
  462. setList(data.list || []);
  463. setTotal(data.total || 0);
  464. } catch (error: any) {
  465. console.error('FormManagement: 获取表单列表失败', error);
  466. toast.error(error.message || t('form.fetchFormListFailed'));
  467. // 确保即使失败也显示空列表
  468. setList([]);
  469. setTotal(0);
  470. } finally {
  471. setLoading(false);
  472. }
  473. };
  474. // 处理表单状态切换
  475. const handleFormStatusChanged = async (record: FormApi.FormVO, newStatus: number) => {
  476. if (!record.id) {
  477. message.error('表单ID不存在');
  478. return;
  479. }
  480. // 设置该记录为更新中状态
  481. setFormStatusUpdating(prev => ({ ...prev, [record.id!]: true }));
  482. try {
  483. await FormApi.updateFormStatus({
  484. id: record.id,
  485. status: newStatus,
  486. });
  487. // 更新本地列表中的状态
  488. setList(prev =>
  489. prev.map(item =>
  490. item.id === record.id ? { ...item, status: newStatus } : item
  491. )
  492. );
  493. message.success(newStatus === 0 ? '已开启' : '已关闭');
  494. } catch (error: any) {
  495. console.error('更新表单状态失败:', error);
  496. message.error(error?.message || '更新状态失败');
  497. } finally {
  498. // 清除更新中状态
  499. setFormStatusUpdating(prev => {
  500. const newState = { ...prev };
  501. delete newState[record.id!];
  502. return newState;
  503. });
  504. }
  505. };
  506. useEffect(() => {
  507. getList();
  508. // eslint-disable-next-line react-hooks/exhaustive-deps
  509. }, [queryParams.pageNo, queryParams.pageSize, queryParams.name]);
  510. // 监听路径变化,当从表单编辑器返回时自动刷新列表
  511. useEffect(() => {
  512. // 当路径包含 /form 时,刷新列表以确保显示最新数据
  513. if (location.pathname.includes('/form')) {
  514. getList();
  515. }
  516. // eslint-disable-next-line react-hooks/exhaustive-deps
  517. }, [location.pathname]);
  518. /** 添加/修改操作 */
  519. const openForm = (type: string, id?: number) => {
  520. const query: Record<string, string> = { type };
  521. if (id !== undefined && (typeof id === 'number' || typeof id === 'string')) {
  522. query.id = String(id);
  523. }
  524. // 记录来源菜单:用于从表单设计器返回时恢复菜单到「隔离作业 > 表单管理」
  525. sessionStorage.setItem(
  526. 'formDesignerSource',
  527. JSON.stringify({ menu: 'isolationWork', subMenu: 'formManagement' })
  528. );
  529. // 跳转到表单编辑器(这里需要根据实际路由配置)
  530. navigate(`/bpm/form/editor?${new URLSearchParams(query).toString()}`);
  531. };
  532. /** 删除按钮操作 */
  533. const handleDelete = async (id: number) => {
  534. Modal.confirm({
  535. title: t('form.confirmDeleteForm'),
  536. content: t('form.confirmDeleteFormText'),
  537. okText: t('common.confirm'),
  538. cancelText: t('common.cancel'),
  539. onOk: async () => {
  540. try {
  541. await FormApi.deleteForm(id);
  542. toast.success(t('form.deleteFormSuccess'));
  543. await getList();
  544. } catch (error: any) {
  545. toast.error(error.message || t('form.deleteFormFailed'));
  546. }
  547. }
  548. });
  549. };
  550. /** 详情操作 */
  551. const openDetail = async (rowId: number) => {
  552. try {
  553. const data = await FormApi.getForm(rowId);
  554. setConfAndFields2(setDetailData, data.conf, data.fields);
  555. setDetailVisible(true);
  556. } catch (error: any) {
  557. toast.error(error.message || t('form.fetchFormDetailFailed'));
  558. }
  559. };
  560. // 搜索
  561. const handleSearch = () => {
  562. setQueryParams({
  563. ...queryParams,
  564. pageNo: 1,
  565. name: searchName.trim() || undefined,
  566. });
  567. };
  568. // 重置
  569. const handleReset = () => {
  570. setSearchName('');
  571. setQueryParams({
  572. ...queryParams,
  573. pageNo: 1,
  574. name: undefined,
  575. });
  576. };
  577. // 表格列配置
  578. const columns: ColumnsType<FormApi.FormVO> = [
  579. {
  580. title: t('form.formId'),
  581. dataIndex: 'id',
  582. width: 90,
  583. align: 'center',
  584. },
  585. {
  586. title: t('form.formName'),
  587. dataIndex: 'name',
  588. width: 220,
  589. align: 'center',
  590. render: (name: string, record: FormApi.FormVO) => {
  591. if (!name || name === '-') return <span>{name || '-'}</span>;
  592. return (
  593. <span
  594. className="text-blue-600 cursor-pointer hover:text-blue-800 hover:underline"
  595. onClick={(e) => {
  596. e.stopPropagation();
  597. openDetail(record.id!);
  598. }}
  599. >
  600. {name}
  601. </span>
  602. );
  603. },
  604. },
  605. {
  606. title: t('form.formStatus'),
  607. dataIndex: 'status',
  608. width: 110,
  609. align: 'center',
  610. render: (status: number, record: FormApi.FormVO) => {
  611. const isChecked = Number(status) === 0; // 0 是开启
  612. const isUpdating = formStatusUpdating[record.id!] || false;
  613. return (
  614. <Switch
  615. checked={isChecked}
  616. onChange={(checked) => {
  617. if (!isUpdating) {
  618. const newStatus = checked ? 0 : 1; // 0 是开启,1 是关闭
  619. handleFormStatusChanged(record, newStatus);
  620. }
  621. }}
  622. disabled={isUpdating}
  623. loading={isUpdating}
  624. style={{
  625. ...(isChecked ? {
  626. backgroundColor: '#52c41a',
  627. } : {})
  628. }}
  629. className={isChecked ? 'ant-switch-checked-green' : ''}
  630. />
  631. );
  632. },
  633. },
  634. {
  635. title: t('form.formRemark'),
  636. dataIndex: 'remark',
  637. width: 260,
  638. align: 'center',
  639. render: (text: string) => {
  640. const remarkText = text || '-';
  641. const maxLength = 20;
  642. const shouldTruncate = remarkText.length > maxLength;
  643. const displayText = shouldTruncate ? remarkText.slice(0, maxLength) + '...' : remarkText;
  644. return (
  645. <Tooltip placement="topLeft" title={remarkText}>
  646. <span>{displayText}</span>
  647. </Tooltip>
  648. );
  649. },
  650. },
  651. {
  652. title: t('form.formCreateTime'),
  653. dataIndex: 'createTime',
  654. width: 180,
  655. align: 'center',
  656. render: (text: string) => dateFormatter(text),
  657. },
  658. {
  659. title: t('form.formOperation'),
  660. width: 260,
  661. align: 'center',
  662. fixed: 'right',
  663. render: (_: any, record: FormApi.FormVO) => (
  664. <Space size="small">
  665. <Button
  666. type="link"
  667. size="small"
  668. icon={<Copy className="w-4 h-4" style={{ color: '#000000' }} />}
  669. onClick={() => openForm('copy', record.id)}
  670. style={{ color: '#000000' }}
  671. className="transition-colors"
  672. onMouseEnter={(e) => {
  673. e.currentTarget.style.color = '#1677ff';
  674. e.currentTarget.style.textDecoration = 'underline';
  675. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #1677ff');
  676. }}
  677. onMouseLeave={(e) => {
  678. e.currentTarget.style.color = '#000000';
  679. e.currentTarget.style.textDecoration = 'none';
  680. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #000000');
  681. }}
  682. >
  683. {t('form.copy')}
  684. </Button>
  685. <Button
  686. type="link"
  687. size="small"
  688. icon={<Edit2 className="w-4 h-4" style={{ color: '#000000' }} />}
  689. onClick={() => openForm('update', record.id)}
  690. style={{ color: '#000000' }}
  691. className="transition-colors"
  692. onMouseEnter={(e) => {
  693. e.currentTarget.style.color = '#1677ff';
  694. e.currentTarget.style.textDecoration = 'underline';
  695. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #1677ff');
  696. }}
  697. onMouseLeave={(e) => {
  698. e.currentTarget.style.color = '#000000';
  699. e.currentTarget.style.textDecoration = 'none';
  700. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #000000');
  701. }}
  702. >
  703. {t('common.edit')}
  704. </Button>
  705. <Button
  706. type="link"
  707. size="small"
  708. icon={<Eye className="w-4 h-4" style={{ color: '#000000' }} />}
  709. onClick={() => openDetail(record.id!)}
  710. style={{ color: '#000000' }}
  711. className="transition-colors"
  712. onMouseEnter={(e) => {
  713. e.currentTarget.style.color = '#1677ff';
  714. e.currentTarget.style.textDecoration = 'underline';
  715. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #1677ff');
  716. }}
  717. onMouseLeave={(e) => {
  718. e.currentTarget.style.color = '#000000';
  719. e.currentTarget.style.textDecoration = 'none';
  720. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #000000');
  721. }}
  722. >
  723. {t('form.preview')}
  724. </Button>
  725. <Button
  726. type="link"
  727. size="small"
  728. icon={<Trash2 className="w-4 h-4" style={{ color: '#000000' }} />}
  729. onClick={() => handleDelete(record.id!)}
  730. style={{ color: '#000000' }}
  731. className="transition-colors"
  732. onMouseEnter={(e) => {
  733. e.currentTarget.style.color = '#1677ff';
  734. e.currentTarget.style.textDecoration = 'underline';
  735. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #1677ff');
  736. }}
  737. onMouseLeave={(e) => {
  738. e.currentTarget.style.color = '#000000';
  739. e.currentTarget.style.textDecoration = 'none';
  740. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #000000');
  741. }}
  742. >
  743. {t('common.delete')}
  744. </Button>
  745. </Space>
  746. ),
  747. },
  748. ];
  749. // 计算分页数据
  750. const totalPages = Math.ceil(total / queryParams.pageSize) || 1;
  751. return (
  752. <div className="space-y-6">
  753. {/* 操作栏和表格容器 */}
  754. <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm overflow-hidden">
  755. {/* 查询与操作栏 */}
  756. <div className="p-4 lg:p-5 border-b border-gray-200/50">
  757. <div className="flex flex-wrap items-center justify-between gap-3">
  758. <div className="flex items-center gap-3">
  759. <span className="text-sm text-gray-700">{t('form.formName')}:</span>
  760. <Input
  761. value={searchName}
  762. onChange={(e) => setSearchName(e.target.value)}
  763. placeholder={t('form.formNamePlaceholder')}
  764. allowClear
  765. style={{ width: 240 }}
  766. />
  767. </div>
  768. <Space size="small">
  769. <Button type="primary" icon={<Search className="w-4 h-4" />} onClick={handleSearch}>
  770. {t('common.search')}
  771. </Button>
  772. <Button icon={<RotateCcw className="w-4 h-4" />} onClick={handleReset}>
  773. {t('common.reset')}
  774. </Button>
  775. <Button
  776. type="primary"
  777. icon={<Plus className="w-4 h-4" />}
  778. onClick={() => openForm('create')}
  779. >
  780. {t('form.addForm')}
  781. </Button>
  782. </Space>
  783. </div>
  784. </div>
  785. {/* 表格容器 */}
  786. <div className="overflow-hidden min-w-0">
  787. <AntdTable
  788. loading={loading}
  789. columns={columns}
  790. dataSource={list}
  791. rowKey="id"
  792. pagination={false}
  793. scroll={{ x: 'max-content' }}
  794. />
  795. </div>
  796. </div>
  797. {/* 分页 */}
  798. {total > 0 && (
  799. <div className="bg-white rounded-lg border border-gray-200 px-6 py-4">
  800. <div className="flex items-center justify-between">
  801. <div className="text-sm text-gray-600">
  802. {t('form.totalRecords')} <span className="text-blue-600 font-medium">{total}</span> {t('form.recordsUnit')}
  803. </div>
  804. <div className="flex gap-2">
  805. <Button
  806. onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo - 1 })}
  807. disabled={queryParams.pageNo <= 1}
  808. >
  809. {t('common.prevPage')}
  810. </Button>
  811. <span className="px-4 py-2 text-sm text-gray-600 flex items-center">
  812. {queryParams.pageNo} / {totalPages}
  813. </span>
  814. <Button
  815. onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo + 1 })}
  816. disabled={queryParams.pageNo >= totalPages}
  817. >
  818. {t('common.nextPage')}
  819. </Button>
  820. </div>
  821. </div>
  822. </div>
  823. )}
  824. {/* 表单详情的弹窗 */}
  825. <Modal
  826. title={t('form.formDetail')}
  827. open={detailVisible}
  828. onCancel={() => setDetailVisible(false)}
  829. footer={[
  830. <div key="tip" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '12px', width: '100%' }}>
  831. <span style={{ color: '#ff4d4f', fontSize: '14px' }}>{t('form.formPreviewTip')}</span>
  832. <Button
  833. key="submit"
  834. type="primary"
  835. onClick={() => {
  836. // 详情预览提交按钮不执行任何操作,也不关闭弹窗
  837. return;
  838. }}
  839. >
  840. {t('common.submit')}
  841. </Button>
  842. </div>,
  843. ]}
  844. width={960}
  845. >
  846. <div className="p-2">
  847. {(() => {
  848. const formConfig = getMergedFormConfig();
  849. const layoutColumns = formConfig.layoutColumns || 1;
  850. const gridStyle = layoutColumns > 1 ? {
  851. display: 'grid',
  852. gridTemplateColumns: `repeat(${layoutColumns}, minmax(0, 1fr))`,
  853. gap: '12px',
  854. rowGap: '16px',
  855. } : undefined;
  856. const isHorizontal = formConfig.labelPosition !== 'top';
  857. const effectiveLabelWidth = Math.max(0, Number(formConfig.labelWidth) || defaultFormConfig.labelWidth);
  858. return (
  859. <>
  860. <style>{`
  861. .form-preview-grid .ant-form-item { margin-bottom: 12px; }
  862. .form-preview-grid .ant-form-item-control { min-width: 0; }
  863. `}</style>
  864. <AntdForm
  865. form={detailForm}
  866. layout={formConfig.labelPosition === 'top' ? 'vertical' : 'horizontal'}
  867. size={formConfig.formSize === 'default' ? 'middle' : formConfig.formSize}
  868. requiredMark={formConfig.hideRequiredMark ? false : undefined}
  869. wrapperCol={formConfig.labelPosition === 'top' ? undefined : { flex: 'auto', style: { minWidth: 0 } }}
  870. >
  871. <div
  872. style={gridStyle}
  873. className={layoutColumns === 1 ? 'space-y-4' : 'form-preview-grid'}
  874. >
  875. {(detailData.rule || []).map((field: any) => renderFieldPreview(field))}
  876. </div>
  877. </AntdForm>
  878. </>
  879. );
  880. })()}
  881. </div>
  882. </Modal>
  883. </div>
  884. );
  885. }