| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718 |
- /*
- Copyright (C) 2025 QuantumNous
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
- For commercial licensing, please contact support@quantumnous.com
- */
- 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;
- // 唯一 ID 生成器,确保在组件生命周期内稳定且递增
- 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,
- renderStringValueSuffix,
- ...props
- }) => {
- const { t } = useTranslation();
- // 将对象转换为键值对数组(包含唯一ID)
- const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
- if (!obj || typeof obj !== 'object') return [];
- const entries = Object.entries(obj);
- return entries.map(([key, value], index) => {
- // 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
- 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]);
- // 数据同步 - 当value变化时更新键值对数组
- 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]);
- // 外部 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('');
- // 通过formApi设置值
- 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, pairKey, 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) {
- // 简化嵌套对象的处理,使用TextArea
- 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)}
- suffix={renderStringValueSuffix?.({ pairId, pairKey, 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.key, 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;
|