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