Files
new-api-hunter/web/src/components/common/ui/JSONEditor.js
t0ng7u 2da0e1a542 ♻️ refactor(model): replace gorm.io/datatypes with JSONValue for PrefillGroup.Items; fix JSON scan across drivers
- Why:
  - Avoid introducing `gorm.io/datatypes` for a single field.
  - Align with existing pattern (`ChannelInfo`, `Properties`) using `Scanner`/`Valuer`.
  - Fix runtime error when drivers return JSON as string.

- What:
  - Introduced `JSONValue` (based on `json.RawMessage`) implementing `sql.Scanner` and `driver.Valuer`, with `MarshalJSON`/`UnmarshalJSON` to preserve raw JSON in API.
  - Updated `PrefillGroup.Items` to use `JSONValue` with `gorm:"type:json"`.
  - Localized comments in `model/prefill_group.go` to Chinese.

- Impact:
  - Resolves “unsupported Scan, storing driver.Value type string into type *json.RawMessage”.
  - Works with MySQL/Postgres/SQLite whether JSON is returned as `[]byte` or `string`.
  - API and DB schema remain unchanged; no `go.mod` changes; lints pass.

Files changed:
- model/prefill_group.go
2025-08-08 04:09:53 +08:00

653 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'>
<Text type="tertiary" size="small">{extraText}</Text>
</Divider>
)}
{extraFooter && (
<div className="mt-1">
{extraFooter}
</div>
)}
</Card>
</Form.Slot>
);
};
export default JSONEditor;