JSONEditor.jsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useEffect, useCallback, useMemo } from 'react';
  16. import { useTranslation } from 'react-i18next';
  17. import {
  18. Button,
  19. Form,
  20. Typography,
  21. Banner,
  22. Tabs,
  23. TabPane,
  24. Card,
  25. Input,
  26. InputNumber,
  27. Switch,
  28. TextArea,
  29. Row,
  30. Col,
  31. Divider,
  32. Tooltip,
  33. } from '@douyinfe/semi-ui';
  34. import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
  35. const { Text } = Typography;
  36. // 唯一 ID 生成器,确保在组件生命周期内稳定且递增
  37. const generateUniqueId = (() => {
  38. let counter = 0;
  39. return () => `kv_${counter++}`;
  40. })();
  41. const JSONEditor = ({
  42. value = '',
  43. onChange,
  44. field,
  45. label,
  46. placeholder,
  47. extraText,
  48. extraFooter,
  49. showClear = true,
  50. template,
  51. templateLabel,
  52. editorType = 'keyValue',
  53. rules = [],
  54. formApi = null,
  55. ...props
  56. }) => {
  57. const { t } = useTranslation();
  58. // 将对象转换为键值对数组(包含唯一ID)
  59. const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
  60. if (!obj || typeof obj !== 'object') return [];
  61. const entries = Object.entries(obj);
  62. return entries.map(([key, value], index) => {
  63. // 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
  64. const prev = prevPairs[index];
  65. const shouldReuseId = prev && prev.key === key;
  66. return {
  67. id: shouldReuseId ? prev.id : generateUniqueId(),
  68. key,
  69. value,
  70. };
  71. });
  72. }, []);
  73. // 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
  74. const keyValueArrayToObject = useCallback((arr) => {
  75. const result = {};
  76. arr.forEach((item) => {
  77. if (item.key) {
  78. result[item.key] = item.value;
  79. }
  80. });
  81. return result;
  82. }, []);
  83. // 初始化键值对数组
  84. const [keyValuePairs, setKeyValuePairs] = useState(() => {
  85. if (typeof value === 'string' && value.trim()) {
  86. try {
  87. const parsed = JSON.parse(value);
  88. return objectToKeyValueArray(parsed);
  89. } catch (error) {
  90. return [];
  91. }
  92. }
  93. if (typeof value === 'object' && value !== null) {
  94. return objectToKeyValueArray(value);
  95. }
  96. return [];
  97. });
  98. // 手动模式下的本地文本缓冲
  99. const [manualText, setManualText] = useState(() => {
  100. if (typeof value === 'string') return value;
  101. if (value && typeof value === 'object')
  102. return JSON.stringify(value, null, 2);
  103. return '';
  104. });
  105. // 根据键数量决定默认编辑模式
  106. const [editMode, setEditMode] = useState(() => {
  107. if (typeof value === 'string' && value.trim()) {
  108. try {
  109. const parsed = JSON.parse(value);
  110. const keyCount = Object.keys(parsed).length;
  111. return keyCount > 10 ? 'manual' : 'visual';
  112. } catch (error) {
  113. return 'manual';
  114. }
  115. }
  116. return 'visual';
  117. });
  118. const [jsonError, setJsonError] = useState('');
  119. // 计算重复的键
  120. const duplicateKeys = useMemo(() => {
  121. const keyCount = {};
  122. const duplicates = new Set();
  123. keyValuePairs.forEach((pair) => {
  124. if (pair.key) {
  125. keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
  126. if (keyCount[pair.key] > 1) {
  127. duplicates.add(pair.key);
  128. }
  129. }
  130. });
  131. return duplicates;
  132. }, [keyValuePairs]);
  133. // 数据同步 - 当value变化时更新键值对数组
  134. useEffect(() => {
  135. try {
  136. let parsed = {};
  137. if (typeof value === 'string' && value.trim()) {
  138. parsed = JSON.parse(value);
  139. } else if (typeof value === 'object' && value !== null) {
  140. parsed = value;
  141. }
  142. // 只在外部值真正改变时更新,避免循环更新
  143. const currentObj = keyValueArrayToObject(keyValuePairs);
  144. if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
  145. setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
  146. }
  147. setJsonError('');
  148. } catch (error) {
  149. console.log('JSON解析失败:', error.message);
  150. setJsonError(error.message);
  151. }
  152. }, [value]);
  153. // 外部 value 变化时,若不在手动模式,则同步手动文本
  154. useEffect(() => {
  155. if (editMode !== 'manual') {
  156. if (typeof value === 'string') setManualText(value);
  157. else if (value && typeof value === 'object')
  158. setManualText(JSON.stringify(value, null, 2));
  159. else setManualText('');
  160. }
  161. }, [value, editMode]);
  162. // 处理可视化编辑的数据变化
  163. const handleVisualChange = useCallback(
  164. (newPairs) => {
  165. setKeyValuePairs(newPairs);
  166. const jsonObject = keyValueArrayToObject(newPairs);
  167. const jsonString =
  168. Object.keys(jsonObject).length === 0
  169. ? ''
  170. : JSON.stringify(jsonObject, null, 2);
  171. setJsonError('');
  172. // 通过formApi设置值
  173. if (formApi && field) {
  174. formApi.setValue(field, jsonString);
  175. }
  176. onChange?.(jsonString);
  177. },
  178. [onChange, formApi, field, keyValueArrayToObject],
  179. );
  180. // 处理手动编辑的数据变化
  181. const handleManualChange = useCallback(
  182. (newValue) => {
  183. setManualText(newValue);
  184. if (newValue && newValue.trim()) {
  185. try {
  186. const parsed = JSON.parse(newValue);
  187. setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
  188. setJsonError('');
  189. onChange?.(newValue);
  190. } catch (error) {
  191. setJsonError(error.message);
  192. }
  193. } else {
  194. setKeyValuePairs([]);
  195. setJsonError('');
  196. onChange?.('');
  197. }
  198. },
  199. [onChange, objectToKeyValueArray, keyValuePairs],
  200. );
  201. // 切换编辑模式
  202. const toggleEditMode = useCallback(() => {
  203. if (editMode === 'visual') {
  204. const jsonObject = keyValueArrayToObject(keyValuePairs);
  205. setManualText(
  206. Object.keys(jsonObject).length === 0
  207. ? ''
  208. : JSON.stringify(jsonObject, null, 2),
  209. );
  210. setEditMode('manual');
  211. } else {
  212. try {
  213. let parsed = {};
  214. if (manualText && manualText.trim()) {
  215. parsed = JSON.parse(manualText);
  216. } else if (typeof value === 'string' && value.trim()) {
  217. parsed = JSON.parse(value);
  218. } else if (typeof value === 'object' && value !== null) {
  219. parsed = value;
  220. }
  221. setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
  222. setJsonError('');
  223. setEditMode('visual');
  224. } catch (error) {
  225. setJsonError(error.message);
  226. return;
  227. }
  228. }
  229. }, [
  230. editMode,
  231. value,
  232. manualText,
  233. keyValuePairs,
  234. keyValueArrayToObject,
  235. objectToKeyValueArray,
  236. ]);
  237. // 添加键值对
  238. const addKeyValue = useCallback(() => {
  239. const newPairs = [...keyValuePairs];
  240. const existingKeys = newPairs.map((p) => p.key);
  241. let counter = 1;
  242. let newKey = `field_${counter}`;
  243. while (existingKeys.includes(newKey)) {
  244. counter += 1;
  245. newKey = `field_${counter}`;
  246. }
  247. newPairs.push({
  248. id: generateUniqueId(),
  249. key: newKey,
  250. value: '',
  251. });
  252. handleVisualChange(newPairs);
  253. }, [keyValuePairs, handleVisualChange]);
  254. // 删除键值对
  255. const removeKeyValue = useCallback(
  256. (id) => {
  257. const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
  258. handleVisualChange(newPairs);
  259. },
  260. [keyValuePairs, handleVisualChange],
  261. );
  262. // 更新键名
  263. const updateKey = useCallback(
  264. (id, newKey) => {
  265. const newPairs = keyValuePairs.map((pair) =>
  266. pair.id === id ? { ...pair, key: newKey } : pair,
  267. );
  268. handleVisualChange(newPairs);
  269. },
  270. [keyValuePairs, handleVisualChange],
  271. );
  272. // 更新值
  273. const updateValue = useCallback(
  274. (id, newValue) => {
  275. const newPairs = keyValuePairs.map((pair) =>
  276. pair.id === id ? { ...pair, value: newValue } : pair,
  277. );
  278. handleVisualChange(newPairs);
  279. },
  280. [keyValuePairs, handleVisualChange],
  281. );
  282. // 填入模板
  283. const fillTemplate = useCallback(() => {
  284. if (template) {
  285. const templateString = JSON.stringify(template, null, 2);
  286. if (formApi && field) {
  287. formApi.setValue(field, templateString);
  288. }
  289. setManualText(templateString);
  290. setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
  291. onChange?.(templateString);
  292. setJsonError('');
  293. }
  294. }, [
  295. template,
  296. onChange,
  297. formApi,
  298. field,
  299. objectToKeyValueArray,
  300. keyValuePairs,
  301. ]);
  302. // 渲染值输入控件(支持嵌套)
  303. const renderValueInput = (pairId, value) => {
  304. const valueType = typeof value;
  305. if (valueType === 'boolean') {
  306. return (
  307. <div className='flex items-center'>
  308. <Switch
  309. checked={value}
  310. onChange={(newValue) => updateValue(pairId, newValue)}
  311. />
  312. <Text type='tertiary' className='ml-2'>
  313. {value ? t('true') : t('false')}
  314. </Text>
  315. </div>
  316. );
  317. }
  318. if (valueType === 'number') {
  319. return (
  320. <InputNumber
  321. value={value}
  322. onChange={(newValue) => updateValue(pairId, newValue)}
  323. style={{ width: '100%' }}
  324. placeholder={t('输入数字')}
  325. />
  326. );
  327. }
  328. if (valueType === 'object' && value !== null) {
  329. // 简化嵌套对象的处理,使用TextArea
  330. return (
  331. <TextArea
  332. rows={2}
  333. value={JSON.stringify(value, null, 2)}
  334. onChange={(txt) => {
  335. try {
  336. const obj = txt.trim() ? JSON.parse(txt) : {};
  337. updateValue(pairId, obj);
  338. } catch {
  339. // 忽略解析错误
  340. }
  341. }}
  342. placeholder={t('输入JSON对象')}
  343. />
  344. );
  345. }
  346. // 字符串或其他原始类型
  347. return (
  348. <Input
  349. placeholder={t('参数值')}
  350. value={String(value)}
  351. onChange={(newValue) => {
  352. let convertedValue = newValue;
  353. if (newValue === 'true') convertedValue = true;
  354. else if (newValue === 'false') convertedValue = false;
  355. else if (!isNaN(newValue) && newValue !== '') {
  356. const num = Number(newValue);
  357. // 检查是否为整数
  358. if (Number.isInteger(num)) {
  359. convertedValue = num;
  360. }
  361. }
  362. updateValue(pairId, convertedValue);
  363. }}
  364. />
  365. );
  366. };
  367. // 渲染键值对编辑器
  368. const renderKeyValueEditor = () => {
  369. return (
  370. <div className='space-y-1'>
  371. {/* 重复键警告 */}
  372. {duplicateKeys.size > 0 && (
  373. <Banner
  374. type='warning'
  375. icon={<IconAlertTriangle />}
  376. description={
  377. <div>
  378. <Text strong>{t('存在重复的键名:')}</Text>
  379. <Text>{Array.from(duplicateKeys).join(', ')}</Text>
  380. <br />
  381. <Text type='tertiary' size='small'>
  382. {t('注意:JSON中重复的键只会保留最后一个同名键的值')}
  383. </Text>
  384. </div>
  385. }
  386. className='mb-3'
  387. />
  388. )}
  389. {keyValuePairs.length === 0 && (
  390. <div className='text-center py-6 px-4'>
  391. <Text type='tertiary' className='text-gray-500 text-sm'>
  392. {t('暂无数据,点击下方按钮添加键值对')}
  393. </Text>
  394. </div>
  395. )}
  396. {keyValuePairs.map((pair, index) => {
  397. const isDuplicate = duplicateKeys.has(pair.key);
  398. const isLastDuplicate =
  399. isDuplicate &&
  400. keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
  401. return (
  402. <Row key={pair.id} gutter={8} align='middle'>
  403. <Col span={10}>
  404. <div className='relative'>
  405. <Input
  406. placeholder={t('键名')}
  407. value={pair.key}
  408. onChange={(newKey) => updateKey(pair.id, newKey)}
  409. status={isDuplicate ? 'warning' : undefined}
  410. />
  411. {isDuplicate && (
  412. <Tooltip
  413. content={
  414. isLastDuplicate
  415. ? t('这是重复键中的最后一个,其值将被使用')
  416. : t('重复的键名,此值将被后面的同名键覆盖')
  417. }
  418. >
  419. <IconAlertTriangle
  420. className='absolute right-2 top-1/2 transform -translate-y-1/2'
  421. style={{
  422. color: isLastDuplicate ? '#ff7d00' : '#faad14',
  423. fontSize: '14px',
  424. }}
  425. />
  426. </Tooltip>
  427. )}
  428. </div>
  429. </Col>
  430. <Col span={12}>{renderValueInput(pair.id, pair.value)}</Col>
  431. <Col span={2}>
  432. <Button
  433. icon={<IconDelete />}
  434. type='danger'
  435. theme='borderless'
  436. onClick={() => removeKeyValue(pair.id)}
  437. style={{ width: '100%' }}
  438. />
  439. </Col>
  440. </Row>
  441. );
  442. })}
  443. <div className='mt-2 flex justify-center'>
  444. <Button
  445. icon={<IconPlus />}
  446. type='primary'
  447. theme='outline'
  448. onClick={addKeyValue}
  449. >
  450. {t('添加键值对')}
  451. </Button>
  452. </div>
  453. </div>
  454. );
  455. };
  456. // 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
  457. const renderRegionEditor = () => {
  458. const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
  459. const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
  460. return (
  461. <div className='space-y-2'>
  462. {/* 重复键警告 */}
  463. {duplicateKeys.size > 0 && (
  464. <Banner
  465. type='warning'
  466. icon={<IconAlertTriangle />}
  467. description={
  468. <div>
  469. <Text strong>{t('存在重复的键名:')}</Text>
  470. <Text>{Array.from(duplicateKeys).join(', ')}</Text>
  471. <br />
  472. <Text type='tertiary' size='small'>
  473. {t('注意:JSON中重复的键只会保留最后一个同名键的值')}
  474. </Text>
  475. </div>
  476. }
  477. className='mb-3'
  478. />
  479. )}
  480. {/* 默认区域 */}
  481. <Form.Slot label={t('默认区域')}>
  482. <Input
  483. placeholder={t('默认区域,如: us-central1')}
  484. value={defaultPair ? defaultPair.value : ''}
  485. onChange={(value) => {
  486. if (defaultPair) {
  487. updateValue(defaultPair.id, value);
  488. } else {
  489. const newPairs = [
  490. ...keyValuePairs,
  491. {
  492. id: generateUniqueId(),
  493. key: 'default',
  494. value: value,
  495. },
  496. ];
  497. handleVisualChange(newPairs);
  498. }
  499. }}
  500. />
  501. </Form.Slot>
  502. {/* 模型专用区域 */}
  503. <Form.Slot label={t('模型专用区域')}>
  504. <div>
  505. {modelPairs.map((pair) => {
  506. const isDuplicate = duplicateKeys.has(pair.key);
  507. return (
  508. <Row key={pair.id} gutter={8} align='middle' className='mb-2'>
  509. <Col span={10}>
  510. <div className='relative'>
  511. <Input
  512. placeholder={t('模型名称')}
  513. value={pair.key}
  514. onChange={(newKey) => updateKey(pair.id, newKey)}
  515. status={isDuplicate ? 'warning' : undefined}
  516. />
  517. {isDuplicate && (
  518. <Tooltip content={t('重复的键名')}>
  519. <IconAlertTriangle
  520. className='absolute right-2 top-1/2 transform -translate-y-1/2'
  521. style={{ color: '#faad14', fontSize: '14px' }}
  522. />
  523. </Tooltip>
  524. )}
  525. </div>
  526. </Col>
  527. <Col span={12}>
  528. <Input
  529. placeholder={t('区域')}
  530. value={pair.value}
  531. onChange={(newValue) => updateValue(pair.id, newValue)}
  532. />
  533. </Col>
  534. <Col span={2}>
  535. <Button
  536. icon={<IconDelete />}
  537. type='danger'
  538. theme='borderless'
  539. onClick={() => removeKeyValue(pair.id)}
  540. style={{ width: '100%' }}
  541. />
  542. </Col>
  543. </Row>
  544. );
  545. })}
  546. <div className='mt-2 flex justify-center'>
  547. <Button
  548. icon={<IconPlus />}
  549. onClick={addKeyValue}
  550. type='primary'
  551. theme='outline'
  552. >
  553. {t('添加模型区域')}
  554. </Button>
  555. </div>
  556. </div>
  557. </Form.Slot>
  558. </div>
  559. );
  560. };
  561. // 渲染可视化编辑器
  562. const renderVisualEditor = () => {
  563. switch (editorType) {
  564. case 'region':
  565. return renderRegionEditor();
  566. case 'object':
  567. case 'keyValue':
  568. default:
  569. return renderKeyValueEditor();
  570. }
  571. };
  572. const hasJsonError = jsonError && jsonError.trim() !== '';
  573. return (
  574. <Form.Slot label={label}>
  575. <Card
  576. header={
  577. <div className='flex justify-between items-center'>
  578. <Tabs
  579. type='slash'
  580. activeKey={editMode}
  581. onChange={(key) => {
  582. if (key === 'manual' && editMode === 'visual') {
  583. setEditMode('manual');
  584. } else if (key === 'visual' && editMode === 'manual') {
  585. toggleEditMode();
  586. }
  587. }}
  588. >
  589. <TabPane tab={t('可视化')} itemKey='visual' />
  590. <TabPane tab={t('手动编辑')} itemKey='manual' />
  591. </Tabs>
  592. {template && templateLabel && (
  593. <Button type='tertiary' onClick={fillTemplate} size='small'>
  594. {templateLabel}
  595. </Button>
  596. )}
  597. </div>
  598. }
  599. headerStyle={{ padding: '12px 16px' }}
  600. bodyStyle={{ padding: '16px' }}
  601. className='!rounded-2xl'
  602. >
  603. {/* JSON错误提示 */}
  604. {hasJsonError && (
  605. <Banner
  606. type='danger'
  607. description={`JSON 格式错误: ${jsonError}`}
  608. className='mb-3'
  609. />
  610. )}
  611. {/* 编辑器内容 */}
  612. {editMode === 'visual' ? (
  613. <div>
  614. {renderVisualEditor()}
  615. {/* 隐藏的Form字段用于验证和数据绑定 */}
  616. <Form.Input
  617. field={field}
  618. value={value}
  619. rules={rules}
  620. style={{ display: 'none' }}
  621. noLabel={true}
  622. {...props}
  623. />
  624. </div>
  625. ) : (
  626. <div>
  627. <TextArea
  628. placeholder={placeholder}
  629. value={manualText}
  630. onChange={handleManualChange}
  631. showClear={showClear}
  632. rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
  633. />
  634. {/* 隐藏的Form字段用于验证和数据绑定 */}
  635. <Form.Input
  636. field={field}
  637. value={value}
  638. rules={rules}
  639. style={{ display: 'none' }}
  640. noLabel={true}
  641. {...props}
  642. />
  643. </div>
  644. )}
  645. {/* 额外文本显示在卡片底部 */}
  646. {extraText && (
  647. <Divider margin='12px' align='center'>
  648. <Text type='tertiary' size='small'>
  649. {extraText}
  650. </Text>
  651. </Divider>
  652. )}
  653. {extraFooter && <div className='mt-1'>{extraFooter}</div>}
  654. </Card>
  655. </Form.Slot>
  656. );
  657. };
  658. export default JSONEditor;