JSONEditor.js 20 KB

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