| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useState, useEffect, useCallback, useMemo } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { |
| Button, |
| Form, |
| Typography, |
| Banner, |
| Tabs, |
| TabPane, |
| Card, |
| Input, |
| InputNumber, |
| Switch, |
| TextArea, |
| Row, |
| Col, |
| Divider, |
| Tooltip, |
| } from '@douyinfe/semi-ui'; |
| import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons'; |
|
|
| const { Text } = Typography; |
|
|
| |
| const generateUniqueId = (() => { |
| let counter = 0; |
| return () => `kv_${counter++}`; |
| })(); |
|
|
| const JSONEditor = ({ |
| value = '', |
| onChange, |
| field, |
| label, |
| placeholder, |
| extraText, |
| extraFooter, |
| showClear = true, |
| template, |
| templateLabel, |
| editorType = 'keyValue', |
| rules = [], |
| formApi = null, |
| ...props |
| }) => { |
| const { t } = useTranslation(); |
|
|
| |
| const objectToKeyValueArray = useCallback((obj, prevPairs = []) => { |
| if (!obj || typeof obj !== 'object') return []; |
|
|
| const entries = Object.entries(obj); |
| return entries.map(([key, value], index) => { |
| |
| const prev = prevPairs[index]; |
| const shouldReuseId = prev && prev.key === key; |
| return { |
| id: shouldReuseId ? prev.id : generateUniqueId(), |
| key, |
| value, |
| }; |
| }); |
| }, []); |
|
|
| |
| const keyValueArrayToObject = useCallback((arr) => { |
| const result = {}; |
| arr.forEach((item) => { |
| if (item.key) { |
| result[item.key] = item.value; |
| } |
| }); |
| return result; |
| }, []); |
|
|
| |
| const [keyValuePairs, setKeyValuePairs] = useState(() => { |
| if (typeof value === 'string' && value.trim()) { |
| try { |
| const parsed = JSON.parse(value); |
| return objectToKeyValueArray(parsed); |
| } catch (error) { |
| return []; |
| } |
| } |
| if (typeof value === 'object' && value !== null) { |
| return objectToKeyValueArray(value); |
| } |
| return []; |
| }); |
|
|
| |
| const [manualText, setManualText] = useState(() => { |
| if (typeof value === 'string') return value; |
| if (value && typeof value === 'object') |
| return JSON.stringify(value, null, 2); |
| return ''; |
| }); |
|
|
| |
| const [editMode, setEditMode] = useState(() => { |
| if (typeof value === 'string' && value.trim()) { |
| try { |
| const parsed = JSON.parse(value); |
| const keyCount = Object.keys(parsed).length; |
| return keyCount > 10 ? 'manual' : 'visual'; |
| } catch (error) { |
| return 'manual'; |
| } |
| } |
| return 'visual'; |
| }); |
|
|
| const [jsonError, setJsonError] = useState(''); |
|
|
| |
| const duplicateKeys = useMemo(() => { |
| const keyCount = {}; |
| const duplicates = new Set(); |
|
|
| keyValuePairs.forEach((pair) => { |
| if (pair.key) { |
| keyCount[pair.key] = (keyCount[pair.key] || 0) + 1; |
| if (keyCount[pair.key] > 1) { |
| duplicates.add(pair.key); |
| } |
| } |
| }); |
|
|
| return duplicates; |
| }, [keyValuePairs]); |
|
|
| |
| useEffect(() => { |
| try { |
| let parsed = {}; |
| if (typeof value === 'string' && value.trim()) { |
| parsed = JSON.parse(value); |
| } else if (typeof value === 'object' && value !== null) { |
| parsed = value; |
| } |
|
|
| |
| const currentObj = keyValueArrayToObject(keyValuePairs); |
| if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) { |
| setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); |
| } |
| setJsonError(''); |
| } catch (error) { |
| console.log('JSON解析失败:', error.message); |
| setJsonError(error.message); |
| } |
| }, [value]); |
|
|
| |
| useEffect(() => { |
| if (editMode !== 'manual') { |
| if (typeof value === 'string') setManualText(value); |
| else if (value && typeof value === 'object') |
| setManualText(JSON.stringify(value, null, 2)); |
| else setManualText(''); |
| } |
| }, [value, editMode]); |
|
|
| |
| const handleVisualChange = useCallback( |
| (newPairs) => { |
| setKeyValuePairs(newPairs); |
| const jsonObject = keyValueArrayToObject(newPairs); |
| const jsonString = |
| Object.keys(jsonObject).length === 0 |
| ? '' |
| : JSON.stringify(jsonObject, null, 2); |
|
|
| setJsonError(''); |
|
|
| |
| if (formApi && field) { |
| formApi.setValue(field, jsonString); |
| } |
|
|
| onChange?.(jsonString); |
| }, |
| [onChange, formApi, field, keyValueArrayToObject], |
| ); |
|
|
| |
| const handleManualChange = useCallback( |
| (newValue) => { |
| setManualText(newValue); |
| if (newValue && newValue.trim()) { |
| try { |
| const parsed = JSON.parse(newValue); |
| setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); |
| setJsonError(''); |
| onChange?.(newValue); |
| } catch (error) { |
| setJsonError(error.message); |
| } |
| } else { |
| setKeyValuePairs([]); |
| setJsonError(''); |
| onChange?.(''); |
| } |
| }, |
| [onChange, objectToKeyValueArray, keyValuePairs], |
| ); |
|
|
| |
| const toggleEditMode = useCallback(() => { |
| if (editMode === 'visual') { |
| const jsonObject = keyValueArrayToObject(keyValuePairs); |
| setManualText( |
| Object.keys(jsonObject).length === 0 |
| ? '' |
| : JSON.stringify(jsonObject, null, 2), |
| ); |
| setEditMode('manual'); |
| } else { |
| try { |
| let parsed = {}; |
| if (manualText && manualText.trim()) { |
| parsed = JSON.parse(manualText); |
| } else if (typeof value === 'string' && value.trim()) { |
| parsed = JSON.parse(value); |
| } else if (typeof value === 'object' && value !== null) { |
| parsed = value; |
| } |
| setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); |
| setJsonError(''); |
| setEditMode('visual'); |
| } catch (error) { |
| setJsonError(error.message); |
| return; |
| } |
| } |
| }, [ |
| editMode, |
| value, |
| manualText, |
| keyValuePairs, |
| keyValueArrayToObject, |
| objectToKeyValueArray, |
| ]); |
|
|
| |
| const addKeyValue = useCallback(() => { |
| const newPairs = [...keyValuePairs]; |
| const existingKeys = newPairs.map((p) => p.key); |
| let counter = 1; |
| let newKey = `field_${counter}`; |
| while (existingKeys.includes(newKey)) { |
| counter += 1; |
| newKey = `field_${counter}`; |
| } |
| newPairs.push({ |
| id: generateUniqueId(), |
| key: newKey, |
| value: '', |
| }); |
| handleVisualChange(newPairs); |
| }, [keyValuePairs, handleVisualChange]); |
|
|
| |
| const removeKeyValue = useCallback( |
| (id) => { |
| const newPairs = keyValuePairs.filter((pair) => pair.id !== id); |
| handleVisualChange(newPairs); |
| }, |
| [keyValuePairs, handleVisualChange], |
| ); |
|
|
| |
| const updateKey = useCallback( |
| (id, newKey) => { |
| const newPairs = keyValuePairs.map((pair) => |
| pair.id === id ? { ...pair, key: newKey } : pair, |
| ); |
| handleVisualChange(newPairs); |
| }, |
| [keyValuePairs, handleVisualChange], |
| ); |
|
|
| |
| const updateValue = useCallback( |
| (id, newValue) => { |
| const newPairs = keyValuePairs.map((pair) => |
| pair.id === id ? { ...pair, value: newValue } : pair, |
| ); |
| handleVisualChange(newPairs); |
| }, |
| [keyValuePairs, handleVisualChange], |
| ); |
|
|
| |
| const fillTemplate = useCallback(() => { |
| if (template) { |
| const templateString = JSON.stringify(template, null, 2); |
|
|
| if (formApi && field) { |
| formApi.setValue(field, templateString); |
| } |
|
|
| setManualText(templateString); |
| setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs)); |
| onChange?.(templateString); |
| setJsonError(''); |
| } |
| }, [ |
| template, |
| onChange, |
| formApi, |
| field, |
| objectToKeyValueArray, |
| keyValuePairs, |
| ]); |
|
|
| |
| const renderValueInput = (pairId, value) => { |
| const valueType = typeof value; |
|
|
| if (valueType === 'boolean') { |
| return ( |
| <div className='flex items-center'> |
| <Switch |
| checked={value} |
| onChange={(newValue) => updateValue(pairId, newValue)} |
| /> |
| <Text type='tertiary' className='ml-2'> |
| {value ? t('true') : t('false')} |
| </Text> |
| </div> |
| ); |
| } |
|
|
| if (valueType === 'number') { |
| return ( |
| <InputNumber |
| value={value} |
| onChange={(newValue) => updateValue(pairId, newValue)} |
| style={{ width: '100%' }} |
| placeholder={t('输入数字')} |
| /> |
| ); |
| } |
|
|
| if (valueType === 'object' && value !== null) { |
| |
| return ( |
| <TextArea |
| rows={2} |
| value={JSON.stringify(value, null, 2)} |
| onChange={(txt) => { |
| try { |
| const obj = txt.trim() ? JSON.parse(txt) : {}; |
| updateValue(pairId, obj); |
| } catch { |
| // 忽略解析错误 |
| } |
| }} |
| placeholder={t('输入JSON对象')} |
| /> |
| ); |
| } |
|
|
| |
| return ( |
| <Input |
| placeholder={t('参数值')} |
| value={String(value)} |
| onChange={(newValue) => { |
| let convertedValue = newValue; |
| if (newValue === 'true') convertedValue = true; |
| else if (newValue === 'false') convertedValue = false; |
| else if (!isNaN(newValue) && newValue !== '') { |
| const num = Number(newValue); |
| // 检查是否为整数 |
| if (Number.isInteger(num)) { |
| convertedValue = num; |
| } |
| } |
| updateValue(pairId, convertedValue); |
| }} |
| /> |
| ); |
| }; |
|
|
| |
| const renderKeyValueEditor = () => { |
| return ( |
| <div className='space-y-1'> |
| {/* 重复键警告 */} |
| {duplicateKeys.size > 0 && ( |
| <Banner |
| type='warning' |
| icon={<IconAlertTriangle />} |
| description={ |
| <div> |
| <Text strong>{t('存在重复的键名:')}</Text> |
| <Text>{Array.from(duplicateKeys).join(', ')}</Text> |
| <br /> |
| <Text type='tertiary' size='small'> |
| {t('注意:JSON中重复的键只会保留最后一个同名键的值')} |
| </Text> |
| </div> |
| } |
| className='mb-3' |
| /> |
| )} |
| |
| {keyValuePairs.length === 0 && ( |
| <div className='text-center py-6 px-4'> |
| <Text type='tertiary' className='text-gray-500 text-sm'> |
| {t('暂无数据,点击下方按钮添加键值对')} |
| </Text> |
| </div> |
| )} |
| |
| {keyValuePairs.map((pair, index) => { |
| const isDuplicate = duplicateKeys.has(pair.key); |
| const isLastDuplicate = |
| isDuplicate && |
| keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key); |
| |
| return ( |
| <Row key={pair.id} gutter={8} align='middle'> |
| <Col span={10}> |
| <div className='relative'> |
| <Input |
| placeholder={t('键名')} |
| value={pair.key} |
| onChange={(newKey) => updateKey(pair.id, newKey)} |
| status={isDuplicate ? 'warning' : undefined} |
| /> |
| {isDuplicate && ( |
| <Tooltip |
| content={ |
| isLastDuplicate |
| ? t('这是重复键中的最后一个,其值将被使用') |
| : t('重复的键名,此值将被后面的同名键覆盖') |
| } |
| > |
| <IconAlertTriangle |
| className='absolute right-2 top-1/2 transform -translate-y-1/2' |
| style={{ |
| color: isLastDuplicate ? '#ff7d00' : '#faad14', |
| fontSize: '14px', |
| }} |
| /> |
| </Tooltip> |
| )} |
| </div> |
| </Col> |
| <Col span={12}>{renderValueInput(pair.id, pair.value)}</Col> |
| <Col span={2}> |
| <Button |
| icon={<IconDelete />} |
| type='danger' |
| theme='borderless' |
| onClick={() => removeKeyValue(pair.id)} |
| style={{ width: '100%' }} |
| /> |
| </Col> |
| </Row> |
| ); |
| })} |
| |
| <div className='mt-2 flex justify-center'> |
| <Button |
| icon={<IconPlus />} |
| type='primary' |
| theme='outline' |
| onClick={addKeyValue} |
| > |
| {t('添加键值对')} |
| </Button> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| |
| const renderRegionEditor = () => { |
| const defaultPair = keyValuePairs.find((pair) => pair.key === 'default'); |
| const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default'); |
|
|
| return ( |
| <div className='space-y-2'> |
| {/* 重复键警告 */} |
| {duplicateKeys.size > 0 && ( |
| <Banner |
| type='warning' |
| icon={<IconAlertTriangle />} |
| description={ |
| <div> |
| <Text strong>{t('存在重复的键名:')}</Text> |
| <Text>{Array.from(duplicateKeys).join(', ')}</Text> |
| <br /> |
| <Text type='tertiary' size='small'> |
| {t('注意:JSON中重复的键只会保留最后一个同名键的值')} |
| </Text> |
| </div> |
| } |
| className='mb-3' |
| /> |
| )} |
| |
| {/* 默认区域 */} |
| <Form.Slot label={t('默认区域')}> |
| <Input |
| placeholder={t('默认区域,如: us-central1')} |
| value={defaultPair ? defaultPair.value : ''} |
| onChange={(value) => { |
| if (defaultPair) { |
| updateValue(defaultPair.id, value); |
| } else { |
| const newPairs = [ |
| ...keyValuePairs, |
| { |
| id: generateUniqueId(), |
| key: 'default', |
| value: value, |
| }, |
| ]; |
| handleVisualChange(newPairs); |
| } |
| }} |
| /> |
| </Form.Slot> |
| |
| {/* 模型专用区域 */} |
| <Form.Slot label={t('模型专用区域')}> |
| <div> |
| {modelPairs.map((pair) => { |
| const isDuplicate = duplicateKeys.has(pair.key); |
| return ( |
| <Row key={pair.id} gutter={8} align='middle' className='mb-2'> |
| <Col span={10}> |
| <div className='relative'> |
| <Input |
| placeholder={t('模型名称')} |
| value={pair.key} |
| onChange={(newKey) => updateKey(pair.id, newKey)} |
| status={isDuplicate ? 'warning' : undefined} |
| /> |
| {isDuplicate && ( |
| <Tooltip content={t('重复的键名')}> |
| <IconAlertTriangle |
| className='absolute right-2 top-1/2 transform -translate-y-1/2' |
| style={{ color: '#faad14', fontSize: '14px' }} |
| /> |
| </Tooltip> |
| )} |
| </div> |
| </Col> |
| <Col span={12}> |
| <Input |
| placeholder={t('区域')} |
| value={pair.value} |
| onChange={(newValue) => updateValue(pair.id, newValue)} |
| /> |
| </Col> |
| <Col span={2}> |
| <Button |
| icon={<IconDelete />} |
| type='danger' |
| theme='borderless' |
| onClick={() => removeKeyValue(pair.id)} |
| style={{ width: '100%' }} |
| /> |
| </Col> |
| </Row> |
| ); |
| })} |
| |
| <div className='mt-2 flex justify-center'> |
| <Button |
| icon={<IconPlus />} |
| onClick={addKeyValue} |
| type='primary' |
| theme='outline' |
| > |
| {t('添加模型区域')} |
| </Button> |
| </div> |
| </div> |
| </Form.Slot> |
| </div> |
| ); |
| }; |
|
|
| |
| const renderVisualEditor = () => { |
| switch (editorType) { |
| case 'region': |
| return renderRegionEditor(); |
| case 'object': |
| case 'keyValue': |
| default: |
| return renderKeyValueEditor(); |
| } |
| }; |
|
|
| const hasJsonError = jsonError && jsonError.trim() !== ''; |
|
|
| return ( |
| <Form.Slot label={label}> |
| <Card |
| header={ |
| <div className='flex justify-between items-center'> |
| <Tabs |
| type='slash' |
| activeKey={editMode} |
| onChange={(key) => { |
| if (key === 'manual' && editMode === 'visual') { |
| setEditMode('manual'); |
| } else if (key === 'visual' && editMode === 'manual') { |
| toggleEditMode(); |
| } |
| }} |
| > |
| <TabPane tab={t('可视化')} itemKey='visual' /> |
| <TabPane tab={t('手动编辑')} itemKey='manual' /> |
| </Tabs> |
| |
| {template && templateLabel && ( |
| <Button type='tertiary' onClick={fillTemplate} size='small'> |
| {templateLabel} |
| </Button> |
| )} |
| </div> |
| } |
| headerStyle={{ padding: '12px 16px' }} |
| bodyStyle={{ padding: '16px' }} |
| className='!rounded-2xl' |
| > |
| {/* JSON错误提示 */} |
| {hasJsonError && ( |
| <Banner |
| type='danger' |
| description={`JSON 格式错误: ${jsonError}`} |
| className='mb-3' |
| /> |
| )} |
| |
| {/* 编辑器内容 */} |
| {editMode === 'visual' ? ( |
| <div> |
| {renderVisualEditor()} |
| {/* 隐藏的Form字段用于验证和数据绑定 */} |
| <Form.Input |
| field={field} |
| value={value} |
| rules={rules} |
| style={{ display: 'none' }} |
| noLabel={true} |
| {...props} |
| /> |
| </div> |
| ) : ( |
| <div> |
| <TextArea |
| placeholder={placeholder} |
| value={manualText} |
| onChange={handleManualChange} |
| showClear={showClear} |
| rows={Math.max(8, manualText ? manualText.split('\n').length : 8)} |
| /> |
| {/* 隐藏的Form字段用于验证和数据绑定 */} |
| <Form.Input |
| field={field} |
| value={value} |
| rules={rules} |
| style={{ display: 'none' }} |
| noLabel={true} |
| {...props} |
| /> |
| </div> |
| )} |
| |
| {/* 额外文本显示在卡片底部 */} |
| {extraText && ( |
| <Divider margin='12px' align='center'> |
| <Text type='tertiary' size='small'> |
| {extraText} |
| </Text> |
| </Divider> |
| )} |
| {extraFooter && <div className='mt-1'>{extraFooter}</div>} |
| </Card> |
| </Form.Slot> |
| ); |
| }; |
|
|
| export default JSONEditor; |
|
|