|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|