JSONEditor.jsx 21 KB

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