diff --git a/constant/context_key.go b/constant/context_key.go index 32dd9617..26ff1738 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -40,4 +40,6 @@ const ( ContextKeyUserGroup ContextKey = "user_group" ContextKeyUsingGroup ContextKey = "group" ContextKeyUserName ContextKey = "username" + + ContextKeySystemPromptOverride ContextKey = "system_prompt_override" ) diff --git a/controller/midjourney.go b/controller/midjourney.go index 02ad708f..30a5a09a 100644 --- a/controller/midjourney.go +++ b/controller/midjourney.go @@ -145,6 +145,22 @@ func UpdateMidjourneyTaskBulk() { buttonStr, _ := json.Marshal(responseItem.Buttons) task.Buttons = string(buttonStr) } + // 映射 VideoUrl + task.VideoUrl = responseItem.VideoUrl + + // 映射 VideoUrls - 将数组序列化为 JSON 字符串 + if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 { + videoUrlsStr, err := json.Marshal(responseItem.VideoUrls) + if err != nil { + common.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err)) + task.VideoUrls = "[]" // 失败时设置为空数组 + } else { + task.VideoUrls = string(videoUrlsStr) + } + } else { + task.VideoUrls = "" // 空值时清空字段 + } + shouldReturnQuota := false if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") { common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason) @@ -208,6 +224,20 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) if oldTask.Progress != "100%" && newTask.FailReason != "" { return true } + // 检查 VideoUrl 是否需要更新 + if oldTask.VideoUrl != newTask.VideoUrl { + return true + } + // 检查 VideoUrls 是否需要更新 + if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 { + newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls) + if oldTask.VideoUrls != string(newVideoUrlsStr) { + return true + } + } else if oldTask.VideoUrls != "" { + // 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空) + return true + } return false } diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 47f8bf95..1c697048 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -6,4 +6,5 @@ type ChannelSettings struct { Proxy string `json:"proxy"` PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` + SystemPromptOverride bool `json:"system_prompt_override,omitempty"` } diff --git a/dto/openai_request.go b/dto/openai_request.go index fcd47d07..f33b2c1e 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -78,6 +78,8 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string { if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") { return "developer" } + } else if strings.HasPrefix(r.Model, "gpt-5") { + return "developer" } return "system" } diff --git a/middleware/distributor.go b/middleware/distributor.go index e8abcbe9..dea30abf 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -267,6 +267,8 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode common.SetContextKey(c, constant.ContextKeyChannelKey, key) common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL()) + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, false) + // TODO: api_version统一 switch channel.Type { case constant.ChannelTypeAzure: diff --git a/model/main.go b/model/main.go index b93f01a2..49ccc56f 100644 --- a/model/main.go +++ b/model/main.go @@ -64,6 +64,22 @@ var DB *gorm.DB var LOG_DB *gorm.DB +// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors +func dropIndexIfExists(tableName string, indexName string) { + if !common.UsingMySQL { + return + } + var count int64 + // Check index existence via information_schema + err := DB.Raw( + "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", + tableName, indexName, + ).Scan(&count).Error + if err == nil && count > 0 { + _ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error + } +} + func createRootAccountIfNeed() error { var user User //if user.Status != common.UserStatusEnabled { @@ -235,6 +251,9 @@ func InitLogDB() (err error) { } func migrateDB() error { + // 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录 + dropIndexIfExists("models", "uk_model_name") + dropIndexIfExists("vendors", "uk_vendor_name") if !common.UsingPostgreSQL { return migrateDBFast() } @@ -264,6 +283,10 @@ func migrateDB() error { } func migrateDBFast() error { + // 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录 + dropIndexIfExists("models", "uk_model_name") + dropIndexIfExists("vendors", "uk_vendor_name") + var wg sync.WaitGroup migrations := []struct { diff --git a/model/model_meta.go b/model/model_meta.go index 5ccd80c5..53b00f28 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -36,7 +36,7 @@ type BoundChannel struct { type Model struct { Id int `json:"id"` - ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"` + ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"` Description string `json:"description,omitempty" gorm:"type:text"` Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` VendorID int `json:"vendor_id,omitempty" gorm:"index"` @@ -44,7 +44,7 @@ type Model struct { Status int `json:"status" gorm:"default:1"` CreatedTime int64 `json:"created_time" gorm:"bigint"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"` BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` diff --git a/model/vendor_meta.go b/model/vendor_meta.go index fd316156..b96b1d5c 100644 --- a/model/vendor_meta.go +++ b/model/vendor_meta.go @@ -14,13 +14,13 @@ import ( type Vendor struct { Id int `json:"id"` - Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"` + Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"` Description string `json:"description,omitempty" gorm:"type:text"` Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` Status int `json:"status" gorm:"default:1"` CreatedTime int64 `json:"created_time" gorm:"bigint"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"` } // Insert 创建新的供应商记录 diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index d5cbe692..3c5bd99b 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -119,6 +119,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { action = "batchEmbedContents" } return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil + return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", info.BaseUrl, version, info.UpstreamModelName), nil } action := "generateContent" @@ -163,29 +164,35 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela if len(inputs) == 0 { return nil, errors.New("input is empty") } - - // only process the first input - geminiRequest := dto.GeminiEmbeddingRequest{ - Content: dto.GeminiChatContent{ - Parts: []dto.GeminiPart{ - { - Text: inputs[0], + // process all inputs + geminiRequests := make([]map[string]interface{}, 0, len(inputs)) + for _, input := range inputs { + geminiRequest := map[string]interface{}{ + "model": fmt.Sprintf("models/%s", info.UpstreamModelName), + "content": dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ + { + Text: input, + }, }, }, - }, - } - - // set specific parameters for different models - // https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent - switch info.UpstreamModelName { - case "text-embedding-004": - // except embedding-001 supports setting `OutputDimensionality` - if request.Dimensions > 0 { - geminiRequest.OutputDimensionality = request.Dimensions } + + // set specific parameters for different models + // https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent + switch info.UpstreamModelName { + case "text-embedding-004", "gemini-embedding-exp-03-07", "gemini-embedding-001": + // Only newer models introduced after 2024 support OutputDimensionality + if request.Dimensions > 0 { + geminiRequest["outputDimensionality"] = request.Dimensions + } + } + geminiRequests = append(geminiRequests, geminiRequest) } - return geminiRequest, nil + return map[string]interface{}{ + "requests": geminiRequests, + }, nil } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 25a2c412..24b42942 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1071,7 +1071,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - var geminiResponse dto.GeminiEmbeddingResponse + var geminiResponse dto.GeminiBatchEmbeddingResponse if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } @@ -1079,14 +1079,16 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h // convert to openai format response openAIResponse := dto.OpenAIEmbeddingResponse{ Object: "list", - Data: []dto.OpenAIEmbeddingResponseItem{ - { - Object: "embedding", - Embedding: geminiResponse.Embedding.Values, - Index: 0, - }, - }, - Model: info.UpstreamModelName, + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)), + Model: info.UpstreamModelName, + } + + for i, embedding := range geminiResponse.Embeddings { + openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: "embedding", + Embedding: embedding.Values, + Index: i, + }) } // calculate usage diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go index 83070fe5..a83e30e6 100644 --- a/relay/channel/zhipu_4v/adaptor.go +++ b/relay/channel/zhipu_4v/adaptor.go @@ -54,8 +54,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) - token := getZhipuToken(info.ApiKey) - req.Set("Authorization", token) + req.Set("Authorization", "Bearer "+info.ApiKey) return nil } diff --git a/relay/channel/zhipu_4v/relay-zhipu_v4.go b/relay/channel/zhipu_4v/relay-zhipu_v4.go index 98a852f5..cb8adfe4 100644 --- a/relay/channel/zhipu_4v/relay-zhipu_v4.go +++ b/relay/channel/zhipu_4v/relay-zhipu_v4.go @@ -1,69 +1,10 @@ package zhipu_4v import ( - "github.com/golang-jwt/jwt" - "one-api/common" "one-api/dto" "strings" - "sync" - "time" ) -// https://open.bigmodel.cn/doc/api#chatglm_std -// chatglm_std, chatglm_lite -// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke -// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke - -var zhipuTokens sync.Map -var expSeconds int64 = 24 * 3600 - -func getZhipuToken(apikey string) string { - data, ok := zhipuTokens.Load(apikey) - if ok { - tokenData := data.(tokenData) - if time.Now().Before(tokenData.ExpiryTime) { - return tokenData.Token - } - } - - split := strings.Split(apikey, ".") - if len(split) != 2 { - common.SysError("invalid zhipu key: " + apikey) - return "" - } - - id := split[0] - secret := split[1] - - expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6 - expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second) - - timestamp := time.Now().UnixNano() / 1e6 - - payload := jwt.MapClaims{ - "api_key": id, - "exp": expMillis, - "timestamp": timestamp, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) - - token.Header["alg"] = "HS256" - token.Header["sign_type"] = "SIGN" - - tokenString, err := token.SignedString([]byte(secret)) - if err != nil { - return "" - } - - zhipuTokens.Store(apikey, tokenData{ - Token: tokenString, - ExpiryTime: expiryTime, - }) - - return tokenString -} - func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { messages := make([]dto.Message, 0, len(request.Messages)) for _, message := range request.Messages { diff --git a/relay/relay-text.go b/relay/relay-text.go index f175dbfb..50d574f3 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -140,10 +140,10 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) } }() - includeUsage := false + includeUsage := true // 判断用户是否需要返回使用情况 - if textRequest.StreamOptions != nil && textRequest.StreamOptions.IncludeUsage { - includeUsage = true + if textRequest.StreamOptions != nil { + includeUsage = textRequest.StreamOptions.IncludeUsage } // 如果不支持StreamOptions,将StreamOptions设置为nil @@ -158,9 +158,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } } - if includeUsage { - relayInfo.ShouldIncludeUsage = true - } + relayInfo.ShouldIncludeUsage = includeUsage adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { @@ -201,6 +199,26 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { Content: relayInfo.ChannelSetting.SystemPrompt, } request.Messages = append([]dto.Message{systemMessage}, request.Messages...) + } else if relayInfo.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + // 如果有系统提示,且允许覆盖,则拼接到前面 + for i, message := range request.Messages { + if message.Role == request.GetSystemRoleName() { + if message.IsStringContent() { + request.Messages[i].SetStringContent(relayInfo.ChannelSetting.SystemPrompt + "\n" + message.StringContent()) + } else { + contents := message.ParseContent() + contents = append([]dto.MediaContent{ + { + Type: dto.ContentTypeText, + Text: relayInfo.ChannelSetting.SystemPrompt, + }, + }, contents...) + request.Messages[i].Content = contents + } + break + } + } } } diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 020a2ba9..0dae9a03 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -28,6 +28,12 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m other["is_model_mapped"] = true other["upstream_model_name"] = relayInfo.UpstreamModelName } + + isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride) + if isSystemPromptOverwritten { + other["is_system_prompt_overwritten"] = true + } + adminInfo := make(map[string]interface{}) adminInfo["use_channel"] = ctx.GetStringSlice("use_channel") isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey) diff --git a/web/src/components/common/ui/JSONEditor.js b/web/src/components/common/ui/JSONEditor.js index f4f5eee9..4acbe270 100644 --- a/web/src/components/common/ui/JSONEditor.js +++ b/web/src/components/common/ui/JSONEditor.js @@ -1,4 +1,23 @@ -import React, { useState, useEffect, useCallback } from 'react'; +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, @@ -15,16 +34,22 @@ import { Row, Col, Divider, + Tooltip, } from '@douyinfe/semi-ui'; import { - IconCode, IconPlus, IconDelete, - IconRefresh, + IconAlertTriangle, } from '@douyinfe/semi-icons'; const { Text } = Typography; +// 唯一 ID 生成器,确保在组件生命周期内稳定且递增 +const generateUniqueId = (() => { + let counter = 0; + return () => `kv_${counter++}`; +})(); + const JSONEditor = ({ value = '', onChange, @@ -43,24 +68,51 @@ const JSONEditor = ({ }) => { const { t } = useTranslation(); - // 初始化JSON数据 - const [jsonData, setJsonData] = useState(() => { - // 初始化时解析JSON数据 + // 将对象转换为键值对数组(包含唯一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 parsed; + return objectToKeyValueArray(parsed); } catch (error) { - return {}; + return []; } } if (typeof value === 'object' && value !== null) { - return value; + return objectToKeyValueArray(value); } - return {}; + return []; }); - // 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置 + // 手动模式下的本地文本缓冲 const [manualText, setManualText] = useState(() => { if (typeof value === 'string') return value; if (value && typeof value === 'object') return JSON.stringify(value, null, 2); @@ -69,22 +121,38 @@ const JSONEditor = ({ // 根据键数量决定默认编辑模式 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有效) + // 计算重复的键 + 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 = {}; @@ -93,16 +161,20 @@ const JSONEditor = ({ } else if (typeof value === 'object' && value !== null) { parsed = value; } - setJsonData(parsed); + + // 只在外部值真正改变时更新,避免循环更新 + 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); - // JSON格式错误时不更新jsonData } }, [value]); - // 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入 + // 外部 value 变化时,若不在手动模式,则同步手动文本 useEffect(() => { if (editMode !== 'manual') { if (typeof value === 'string') setManualText(value); @@ -112,45 +184,47 @@ const JSONEditor = ({ }, [value, editMode]); // 处理可视化编辑的数据变化 - const handleVisualChange = useCallback((newData) => { - setJsonData(newData); - setJsonError(''); - const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2); + const handleVisualChange = useCallback((newPairs) => { + setKeyValuePairs(newPairs); + const jsonObject = keyValueArrayToObject(newPairs); + const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2); - // 通过formApi设置值(如果提供的话) + setJsonError(''); + + // 通过formApi设置值 if (formApi && field) { formApi.setValue(field, jsonString); } onChange?.(jsonString); - }, [onChange, formApi, field]); + }, [onChange, formApi, field, keyValueArrayToObject]); - // 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游) + // 处理手动编辑的数据变化 const handleManualChange = useCallback((newValue) => { setManualText(newValue); if (newValue && newValue.trim()) { try { - JSON.parse(newValue); + const parsed = JSON.parse(newValue); + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); setJsonError(''); onChange?.(newValue); } catch (error) { setJsonError(error.message); - // 无效 JSON 时不回传,避免外部值把输入重置 } } else { + setKeyValuePairs([]); setJsonError(''); onChange?.(''); } - }, [onChange]); + }, [onChange, objectToKeyValueArray, keyValuePairs]); // 切换编辑模式 const toggleEditMode = useCallback(() => { if (editMode === 'visual') { - // 从可视化模式切换到手动模式 - setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2)); + const jsonObject = keyValueArrayToObject(keyValuePairs); + setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2)); setEditMode('manual'); } else { - // 从手动模式切换到可视化模式,需要验证JSON try { let parsed = {}; if (manualText && manualText.trim()) { @@ -160,98 +234,166 @@ const JSONEditor = ({ } else if (typeof value === 'object' && value !== null) { parsed = value; } - setJsonData(parsed); + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); setJsonError(''); setEditMode('visual'); } catch (error) { setJsonError(error.message); - // JSON格式错误时不切换模式 return; } } - }, [editMode, value, manualText, jsonData]); + }, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]); // 添加键值对 const addKeyValue = useCallback(() => { - const newData = { ...jsonData }; - const keys = Object.keys(newData); + const newPairs = [...keyValuePairs]; + const existingKeys = newPairs.map(p => p.key); let counter = 1; let newKey = `field_${counter}`; - while (newData.hasOwnProperty(newKey)) { + while (existingKeys.includes(newKey)) { counter += 1; newKey = `field_${counter}`; } - newData[newKey] = ''; - handleVisualChange(newData); - }, [jsonData, handleVisualChange]); + newPairs.push({ + id: generateUniqueId(), + key: newKey, + value: '' + }); + handleVisualChange(newPairs); + }, [keyValuePairs, handleVisualChange]); // 删除键值对 - const removeKeyValue = useCallback((keyToRemove) => { - const newData = { ...jsonData }; - delete newData[keyToRemove]; - handleVisualChange(newData); - }, [jsonData, handleVisualChange]); + const removeKeyValue = useCallback((id) => { + const newPairs = keyValuePairs.filter(pair => pair.id !== id); + handleVisualChange(newPairs); + }, [keyValuePairs, 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 updateKey = useCallback((id, newKey) => { + const newPairs = keyValuePairs.map(pair => + pair.id === id ? { ...pair, key: newKey } : pair + ); + handleVisualChange(newPairs); + }, [keyValuePairs, handleVisualChange]); // 更新值 - const updateValue = useCallback((key, newValue) => { - const newData = { ...jsonData }; - newData[key] = newValue; - handleVisualChange(newData); - }, [jsonData, 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); - // 通过formApi设置值(如果提供的话) if (formApi && field) { formApi.setValue(field, templateString); } - // 同步内部与外部值,避免出现杂字符 setManualText(templateString); - setJsonData(template); + setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs)); onChange?.(templateString); - - // 清除错误状态 setJsonError(''); } - }, [template, onChange, editMode, formApi, field]); + }, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]); - // 渲染键值对编辑器 - const renderKeyValueEditor = () => { - if (typeof jsonData !== 'object' || jsonData === null) { + // 渲染值输入控件(支持嵌套) + const renderValueInput = (pairId, value) => { + const valueType = typeof value; + + if (valueType === 'boolean') { return ( -
-
- -
- - {t('无效的JSON数据,请检查格式')} +
+ updateValue(pairId, newValue)} + /> + + {value ? t('true') : t('false')}
); } - const entries = Object.entries(jsonData); + if (valueType === 'number') { + return ( + updateValue(pairId, newValue)} + style={{ width: '100%' }} + placeholder={t('输入数字')} + /> + ); + } + + if (valueType === 'object' && value !== null) { + // 简化嵌套对象的处理,使用TextArea + return ( +