- Why
- Needed to separate help text from action buttons in JSONEditor for better layout and UX.
- Models table should robustly render both new object-based endpoint mappings and legacy arrays.
- Columns should re-render when vendor map changes.
- Minor import cleanups for consistency.
- What
- JSONEditor.js
- Added optional prop extraFooter to render content below the extraText divider.
- Kept extraText rendered via Divider; extraFooter appears on the next line for clear separation.
- EditModelModal.jsx
- Moved endpoint group buttons from extraText into extraFooter to display under the helper text.
- Kept merge-logic: group items are merged into current endpoints JSON with key override semantics.
- Consolidated lucide-react imports into a single line.
- ModelsColumnDefs.js
- Made endpoint renderer resilient:
- Supports object-based JSON (keys as endpoint types) and legacy array format.
- Displays keys/items as tags and limits the number shown; uses stringToColor for visual consistency.
- Consolidated Semi UI imports into a single line.
- ModelsTable.jsx
- Fixed columns memoization dependency to include vendorMap, ensuring re-render when vendor data changes.
- Notes
- Backward-compatible: extraFooter is additive; existing JSONEditor usage remains unchanged.
- No API changes to backend.
- No linter errors introduced.
- Files touched
- web/src/components/common/ui/JSONEditor.js
- web/src/components/table/models/modals/EditModelModal.jsx
- web/src/components/table/models/ModelsColumnDefs.js
- web/src/components/table/models/ModelsTable.jsx
- Impact
- Clearer UI for endpoint editing (buttons now below helper text).
- Correct endpoints display for object-based mappings in models list.
- More reliable reactivity when vendor data updates.
653 lines
19 KiB
JavaScript
653 lines
19 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import {
|
||
Button,
|
||
Form,
|
||
Typography,
|
||
Banner,
|
||
Tabs,
|
||
TabPane,
|
||
Card,
|
||
Input,
|
||
InputNumber,
|
||
Switch,
|
||
TextArea,
|
||
Row,
|
||
Col,
|
||
Divider,
|
||
} from '@douyinfe/semi-ui';
|
||
import {
|
||
IconCode,
|
||
IconPlus,
|
||
IconDelete,
|
||
IconRefresh,
|
||
} from '@douyinfe/semi-icons';
|
||
|
||
const { Text } = Typography;
|
||
|
||
const JSONEditor = ({
|
||
value = '',
|
||
onChange,
|
||
field,
|
||
label,
|
||
placeholder,
|
||
extraText,
|
||
extraFooter,
|
||
showClear = true,
|
||
template,
|
||
templateLabel,
|
||
editorType = 'keyValue',
|
||
rules = [],
|
||
formApi = null,
|
||
...props
|
||
}) => {
|
||
const { t } = useTranslation();
|
||
|
||
// 初始化JSON数据
|
||
const [jsonData, setJsonData] = useState(() => {
|
||
// 初始化时解析JSON数据
|
||
if (typeof value === 'string' && value.trim()) {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return parsed;
|
||
} catch (error) {
|
||
return {};
|
||
}
|
||
}
|
||
if (typeof value === 'object' && value !== null) {
|
||
return value;
|
||
}
|
||
return {};
|
||
});
|
||
|
||
// 根据键数量决定默认编辑模式
|
||
const [editMode, setEditMode] = useState(() => {
|
||
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
|
||
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) {
|
||
// JSON无效时默认显示手动编辑模式
|
||
return 'manual';
|
||
}
|
||
}
|
||
return 'visual';
|
||
});
|
||
const [jsonError, setJsonError] = useState('');
|
||
|
||
// 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
|
||
useEffect(() => {
|
||
try {
|
||
let parsed = {};
|
||
if (typeof value === 'string' && value.trim()) {
|
||
parsed = JSON.parse(value);
|
||
} else if (typeof value === 'object' && value !== null) {
|
||
parsed = value;
|
||
}
|
||
setJsonData(parsed);
|
||
setJsonError('');
|
||
} catch (error) {
|
||
console.log('JSON解析失败:', error.message);
|
||
setJsonError(error.message);
|
||
// JSON格式错误时不更新jsonData
|
||
}
|
||
}, [value]);
|
||
|
||
// 处理可视化编辑的数据变化
|
||
const handleVisualChange = useCallback((newData) => {
|
||
setJsonData(newData);
|
||
setJsonError('');
|
||
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
||
|
||
// 通过formApi设置值(如果提供的话)
|
||
if (formApi && field) {
|
||
formApi.setValue(field, jsonString);
|
||
}
|
||
|
||
onChange?.(jsonString);
|
||
}, [onChange, formApi, field]);
|
||
|
||
// 处理手动编辑的数据变化
|
||
const handleManualChange = useCallback((newValue) => {
|
||
onChange?.(newValue);
|
||
// 验证JSON格式
|
||
if (newValue && newValue.trim()) {
|
||
try {
|
||
const parsed = JSON.parse(newValue);
|
||
setJsonError('');
|
||
// 预先准备可视化数据,但不立即应用
|
||
// 这样切换到可视化模式时数据已经准备好了
|
||
} catch (error) {
|
||
setJsonError(error.message);
|
||
}
|
||
} else {
|
||
setJsonError('');
|
||
}
|
||
}, [onChange]);
|
||
|
||
// 切换编辑模式
|
||
const toggleEditMode = useCallback(() => {
|
||
if (editMode === 'visual') {
|
||
// 从可视化模式切换到手动模式
|
||
setEditMode('manual');
|
||
} else {
|
||
// 从手动模式切换到可视化模式,需要验证JSON
|
||
try {
|
||
let parsed = {};
|
||
if (typeof value === 'string' && value.trim()) {
|
||
parsed = JSON.parse(value);
|
||
} else if (typeof value === 'object' && value !== null) {
|
||
parsed = value;
|
||
}
|
||
setJsonData(parsed);
|
||
setJsonError('');
|
||
setEditMode('visual');
|
||
} catch (error) {
|
||
setJsonError(error.message);
|
||
// JSON格式错误时不切换模式
|
||
return;
|
||
}
|
||
}
|
||
}, [editMode, value]);
|
||
|
||
// 添加键值对
|
||
const addKeyValue = useCallback(() => {
|
||
const newData = { ...jsonData };
|
||
const keys = Object.keys(newData);
|
||
let counter = 1;
|
||
let newKey = `field_${counter}`;
|
||
while (newData.hasOwnProperty(newKey)) {
|
||
counter += 1;
|
||
newKey = `field_${counter}`;
|
||
}
|
||
newData[newKey] = '';
|
||
handleVisualChange(newData);
|
||
}, [jsonData, handleVisualChange]);
|
||
|
||
// 删除键值对
|
||
const removeKeyValue = useCallback((keyToRemove) => {
|
||
const newData = { ...jsonData };
|
||
delete newData[keyToRemove];
|
||
handleVisualChange(newData);
|
||
}, [jsonData, handleVisualChange]);
|
||
|
||
// 更新键名
|
||
const updateKey = useCallback((oldKey, newKey) => {
|
||
if (oldKey === newKey || !newKey) return;
|
||
const newData = {};
|
||
Object.entries(jsonData).forEach(([k, v]) => {
|
||
if (k === oldKey) {
|
||
newData[newKey] = v;
|
||
} else {
|
||
newData[k] = v;
|
||
}
|
||
});
|
||
handleVisualChange(newData);
|
||
}, [jsonData, handleVisualChange]);
|
||
|
||
// 更新值
|
||
const updateValue = useCallback((key, newValue) => {
|
||
const newData = { ...jsonData };
|
||
newData[key] = newValue;
|
||
handleVisualChange(newData);
|
||
}, [jsonData, handleVisualChange]);
|
||
|
||
// 填入模板
|
||
const fillTemplate = useCallback(() => {
|
||
if (template) {
|
||
const templateString = JSON.stringify(template, null, 2);
|
||
|
||
// 通过formApi设置值(如果提供的话)
|
||
if (formApi && field) {
|
||
formApi.setValue(field, templateString);
|
||
}
|
||
|
||
// 无论哪种模式都要更新值
|
||
onChange?.(templateString);
|
||
|
||
// 如果是可视化模式,同时更新jsonData
|
||
if (editMode === 'visual') {
|
||
setJsonData(template);
|
||
}
|
||
|
||
// 清除错误状态
|
||
setJsonError('');
|
||
}
|
||
}, [template, onChange, editMode, formApi, field]);
|
||
|
||
// 渲染键值对编辑器
|
||
const renderKeyValueEditor = () => {
|
||
if (typeof jsonData !== 'object' || jsonData === null) {
|
||
return (
|
||
<div className="text-center py-6 px-4">
|
||
<div className="text-gray-400 mb-2">
|
||
<IconCode size={32} />
|
||
</div>
|
||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||
{t('无效的JSON数据,请检查格式')}
|
||
</Text>
|
||
</div>
|
||
);
|
||
}
|
||
const entries = Object.entries(jsonData);
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
{entries.length === 0 && (
|
||
<div className="text-center py-6 px-4">
|
||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||
{t('暂无数据,点击下方按钮添加键值对')}
|
||
</Text>
|
||
</div>
|
||
)}
|
||
|
||
{entries.map(([key, value], index) => (
|
||
<Row key={index} gutter={8} align="middle">
|
||
<Col span={6}>
|
||
<Input
|
||
placeholder={t('键名')}
|
||
value={key}
|
||
onChange={(newKey) => updateKey(key, newKey)}
|
||
/>
|
||
</Col>
|
||
<Col span={16}>
|
||
{renderValueInput(key, value)}
|
||
</Col>
|
||
<Col span={2}>
|
||
<Button
|
||
icon={<IconDelete />}
|
||
type="danger"
|
||
theme="borderless"
|
||
onClick={() => removeKeyValue(key)}
|
||
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 flattenObject = useCallback((parentKey) => {
|
||
const newData = { ...jsonData };
|
||
let primitive = '';
|
||
const obj = newData[parentKey];
|
||
if (obj && typeof obj === 'object') {
|
||
const firstKey = Object.keys(obj)[0];
|
||
if (firstKey !== undefined) {
|
||
const firstVal = obj[firstKey];
|
||
if (typeof firstVal !== 'object') primitive = firstVal;
|
||
}
|
||
}
|
||
newData[parentKey] = primitive;
|
||
handleVisualChange(newData);
|
||
}, [jsonData, handleVisualChange]);
|
||
|
||
const addNestedObject = useCallback((parentKey) => {
|
||
const newData = { ...jsonData };
|
||
if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) {
|
||
newData[parentKey] = {};
|
||
}
|
||
const existingKeys = Object.keys(newData[parentKey]);
|
||
let counter = 1;
|
||
let newKey = `field_${counter}`;
|
||
while (newData[parentKey].hasOwnProperty(newKey)) {
|
||
counter += 1;
|
||
newKey = `field_${counter}`;
|
||
}
|
||
newData[parentKey][newKey] = '';
|
||
handleVisualChange(newData);
|
||
}, [jsonData, handleVisualChange]);
|
||
|
||
// 渲染参数值输入控件(支持嵌套)
|
||
const renderValueInput = (key, value) => {
|
||
const valueType = typeof value;
|
||
|
||
if (valueType === 'boolean') {
|
||
return (
|
||
<div className="flex items-center">
|
||
<Switch
|
||
checked={value}
|
||
onChange={(newValue) => updateValue(key, newValue)}
|
||
/>
|
||
<Text type="tertiary" className="ml-2">
|
||
{value ? t('true') : t('false')}
|
||
</Text>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (valueType === 'number') {
|
||
return (
|
||
<InputNumber
|
||
value={value}
|
||
onChange={(newValue) => updateValue(key, newValue)}
|
||
style={{ width: '100%' }}
|
||
step={key === 'temperature' ? 0.1 : 1}
|
||
precision={key === 'temperature' ? 2 : 0}
|
||
placeholder={t('输入数字')}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (valueType === 'object' && value !== null) {
|
||
// 渲染嵌套对象
|
||
const entries = Object.entries(value);
|
||
return (
|
||
<Card className="!rounded-2xl">
|
||
{entries.length === 0 && (
|
||
<Text type="tertiary" className="text-gray-500 text-xs">
|
||
{t('空对象,点击下方加号添加字段')}
|
||
</Text>
|
||
)}
|
||
|
||
{entries.map(([nestedKey, nestedValue], index) => (
|
||
<Row key={index} gutter={4} align="middle" className="mb-1">
|
||
<Col span={8}>
|
||
<Input
|
||
size="small"
|
||
placeholder={t('键名')}
|
||
value={nestedKey}
|
||
onChange={(newKey) => {
|
||
const newData = { ...jsonData };
|
||
const oldValue = newData[key][nestedKey];
|
||
delete newData[key][nestedKey];
|
||
newData[key][newKey] = oldValue;
|
||
handleVisualChange(newData);
|
||
}}
|
||
/>
|
||
</Col>
|
||
<Col span={14}>
|
||
{typeof nestedValue === 'object' && nestedValue !== null ? (
|
||
<TextArea
|
||
size="small"
|
||
rows={2}
|
||
value={JSON.stringify(nestedValue, null, 2)}
|
||
onChange={(txt) => {
|
||
try {
|
||
const obj = txt.trim() ? JSON.parse(txt) : {};
|
||
const newData = { ...jsonData };
|
||
newData[key][nestedKey] = obj;
|
||
handleVisualChange(newData);
|
||
} catch {
|
||
// ignore parse error
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<Input
|
||
size="small"
|
||
placeholder={t('值')}
|
||
value={String(nestedValue)}
|
||
onChange={(newValue) => {
|
||
const newData = { ...jsonData };
|
||
let convertedValue = newValue;
|
||
if (newValue === 'true') convertedValue = true;
|
||
else if (newValue === 'false') convertedValue = false;
|
||
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
||
convertedValue = Number(newValue);
|
||
}
|
||
newData[key][nestedKey] = convertedValue;
|
||
handleVisualChange(newData);
|
||
}}
|
||
/>
|
||
)}
|
||
</Col>
|
||
<Col span={2}>
|
||
<Button
|
||
size="small"
|
||
icon={<IconDelete />}
|
||
type="danger"
|
||
theme="borderless"
|
||
onClick={() => {
|
||
const newData = { ...jsonData };
|
||
delete newData[key][nestedKey];
|
||
handleVisualChange(newData);
|
||
}}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
))}
|
||
|
||
<div className="flex justify-center mt-1 gap-2">
|
||
<Button
|
||
size="small"
|
||
icon={<IconPlus />}
|
||
type="tertiary"
|
||
onClick={() => addNestedObject(key)}
|
||
>
|
||
{t('添加字段')}
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
icon={<IconRefresh />}
|
||
type="tertiary"
|
||
onClick={() => flattenObject(key)}
|
||
>
|
||
{t('转换为值')}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// 字符串或其他原始类型
|
||
return (
|
||
<div className="flex items-center gap-1">
|
||
<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 !== '' && newValue !== '0') {
|
||
convertedValue = Number(newValue);
|
||
}
|
||
updateValue(key, convertedValue);
|
||
}}
|
||
/>
|
||
<Button
|
||
icon={<IconPlus />}
|
||
type="tertiary"
|
||
onClick={() => {
|
||
// 将当前值转换为对象
|
||
const newData = { ...jsonData };
|
||
newData[key] = { '1': value };
|
||
handleVisualChange(newData);
|
||
}}
|
||
title={t('转换为对象')}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 渲染区域编辑器(特殊格式)
|
||
const renderRegionEditor = () => {
|
||
const entries = Object.entries(jsonData);
|
||
const defaultEntry = entries.find(([key]) => key === 'default');
|
||
const modelEntries = entries.filter(([key]) => key !== 'default');
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{/* 默认区域 */}
|
||
<Form.Slot label={t('默认区域')}>
|
||
<Input
|
||
placeholder={t('默认区域,如: us-central1')}
|
||
value={defaultEntry ? defaultEntry[1] : ''}
|
||
onChange={(value) => updateValue('default', value)}
|
||
/>
|
||
</Form.Slot>
|
||
|
||
{/* 模型专用区域 */}
|
||
<Form.Slot label={t('模型专用区域')}>
|
||
<div>
|
||
{modelEntries.map(([modelName, region], index) => (
|
||
<Row key={index} gutter={8} align="middle" className="mb-2">
|
||
<Col span={10}>
|
||
<Input
|
||
placeholder={t('模型名称')}
|
||
value={modelName}
|
||
onChange={(newKey) => updateKey(modelName, newKey)}
|
||
/>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Input
|
||
placeholder={t('区域')}
|
||
value={region}
|
||
onChange={(newValue) => updateValue(modelName, newValue)}
|
||
/>
|
||
</Col>
|
||
<Col span={2}>
|
||
<Button
|
||
icon={<IconDelete />}
|
||
type="danger"
|
||
theme="borderless"
|
||
onClick={() => removeKeyValue(modelName)}
|
||
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={value}
|
||
onChange={handleManualChange}
|
||
showClear={showClear}
|
||
rows={Math.max(8, value ? value.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'>
|
||
{extraText}
|
||
</Divider>
|
||
)}
|
||
{extraFooter && (
|
||
<div className="mt-1">
|
||
{extraFooter}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</Form.Slot>
|
||
);
|
||
};
|
||
|
||
export default JSONEditor;
|