From dbced40e0180a878749b15e0061e8c6014cc2ffe Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 18 Jul 2025 16:54:16 +0800 Subject: [PATCH 01/78] feat: add kling video text2Video when image is empty --- relay/channel/task/kling/adaptor.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index afa39201..75f6cad8 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -143,6 +143,9 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel if err != nil { return nil, err } + if body.Image == "" && body.ImageTail == "" { + c.Set("action", constant.TaskActionTextGenerate) + } data, err := json.Marshal(body) if err != nil { return nil, err From 5520bf4dbeabfb7852c0d9df0f73f9c8be423271 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 11 Aug 2025 15:33:55 +0800 Subject: [PATCH 02/78] feat: init default vendor --- model/pricing.go | 7 +++- model/pricing_default.go | 89 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 model/pricing_default.go diff --git a/model/pricing.go b/model/pricing.go index 0936d298..643ae588 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -155,9 +155,12 @@ func updatePricing() { vendorMap[vendors[i].Id] = &vendors[i] } + // 初始化默认供应商映射 + initDefaultVendorMapping(metaMap, vendorMap, enableAbilities) + // 构建对前端友好的供应商列表 - vendorsList = make([]PricingVendor, 0, len(vendors)) - for _, v := range vendors { + vendorsList = make([]PricingVendor, 0, len(vendorMap)) + for _, v := range vendorMap { vendorsList = append(vendorsList, PricingVendor{ ID: v.Id, Name: v.Name, diff --git a/model/pricing_default.go b/model/pricing_default.go new file mode 100644 index 00000000..912bbe25 --- /dev/null +++ b/model/pricing_default.go @@ -0,0 +1,89 @@ +package model + +import ( + "strings" +) + +// 简化的供应商映射规则 +var defaultVendorRules = map[string]string{ + "gpt": "OpenAI", + "dall-e": "OpenAI", + "whisper": "OpenAI", + "o1": "OpenAI", + "o3": "OpenAI", + "claude": "Anthropic", + "gemini": "Google", + "moonshot": "Moonshot", + "kimi": "Moonshot", + "chatglm": "智谱", + "glm-": "智谱", + "qwen": "阿里巴巴", + "deepseek": "DeepSeek", + "abab": "MiniMax", + "ernie": "百度", + "spark": "讯飞", + "hunyuan": "腾讯", + "command": "Cohere", + "@cf/": "Cloudflare", + "360": "360", + "yi": "零一万物", + "jina": "Jina", + "mistral": "Mistral", + "grok": "xAI", + "llama": "Meta", + "doubao": "字节跳动", + "kling": "快手", + "jimeng": "即梦", + "vidu": "Vidu", +} + +// initDefaultVendorMapping 简化的默认供应商映射 +func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) { + for _, ability := range enableAbilities { + modelName := ability.Model + if _, exists := metaMap[modelName]; exists { + continue + } + + // 匹配供应商 + vendorID := 0 + modelLower := strings.ToLower(modelName) + for pattern, vendorName := range defaultVendorRules { + if strings.Contains(modelLower, pattern) { + vendorID = getOrCreateVendor(vendorName, vendorMap) + break + } + } + + // 创建模型元数据 + metaMap[modelName] = &Model{ + ModelName: modelName, + VendorID: vendorID, + Status: 1, + NameRule: NameRuleExact, + } + } +} + +// 查找或创建供应商 +func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int { + // 查找现有供应商 + for id, vendor := range vendorMap { + if vendor.Name == vendorName { + return id + } + } + + // 创建新供应商 + newVendor := &Vendor{ + Name: vendorName, + Status: 1, + } + + if err := newVendor.Insert(); err != nil { + return 0 + } + + vendorMap[newVendor.Id] = newVendor + return newVendor.Id +} From c2e49f2b2a2adda3db9332f9caf4cc1262e8efb7 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Wed, 13 Aug 2025 09:57:06 +0800 Subject: [PATCH 03/78] fix(adaptor): missing first text delta while convert OpenAI to Claude --- service/convert.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/service/convert.go b/service/convert.go index ea219c4f..b232ca39 100644 --- a/service/convert.go +++ b/service/convert.go @@ -248,9 +248,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon }, }) claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Type: "content_block_delta", + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{ - Type: "text", + Type: "text_delta", Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()), }, }) From c94a74c3989fc028f6e684ee31cb7e9157c2f05b Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:35:31 +0800 Subject: [PATCH 04/78] feat: adapt Volcengine adaptor for deepseek3.1 model with thinking suffix --- relay/channel/volcengine/adaptor.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index b46cb952..0af019da 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -2,6 +2,7 @@ package volcengine import ( "bytes" + "encoding/json" "errors" "fmt" "io" @@ -214,6 +215,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if request == nil { return nil, errors.New("request is nil") } + // 适配 方舟deepseek混合模型 的 thinking 后缀 + if strings.HasSuffix(info.UpstreamModelName, "-thinking") && strings.HasPrefix(info.UpstreamModelName, "deepseek") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + request.Model = info.UpstreamModelName + request.THINKING = json.RawMessage(`{"type": "enabled"}`) + } return request, nil } From 6033d4318e2e45843e978d5abbcbfce6f3d91d76 Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Mon, 25 Aug 2025 14:45:48 +0800 Subject: [PATCH 05/78] =?UTF-8?q?feat(channel):=20=E6=B7=BB=E5=8A=A02FA?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=90=8E=E6=9F=A5=E7=9C=8B=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增接口通过2FA验证后获取渠道密钥 - 统一实现2FA验证码和备用码的验证逻辑 - 记录用户查看密钥的操作日志 - 编辑渠道弹窗新增查看密钥按钮,触发2FA验证模态框 - 使用TwoFactorAuthModal进行验证码输入及验证 - 验证成功后弹出渠道密钥展示窗口 - 对渠道编辑模态框的状态进行了统一重置优化 - 添加相关国际化文案支持密钥查看功能 --- controller/channel.go | 79 ++++++ router/api-router.go | 1 + .../common/modals/TwoFactorAuthModal.jsx | 128 ++++++++++ .../common/ui/ChannelKeyDisplay.jsx | 224 ++++++++++++++++++ .../channels/modals/EditChannelModal.jsx | 200 ++++++++++++++-- web/src/i18n/locales/en.json | 22 +- 6 files changed, 635 insertions(+), 19 deletions(-) create mode 100644 web/src/components/common/modals/TwoFactorAuthModal.jsx create mode 100644 web/src/components/common/ui/ChannelKeyDisplay.jsx diff --git a/controller/channel.go b/controller/channel.go index 020a3327..70be91d4 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -380,6 +380,85 @@ func GetChannel(c *gin.Context) { return } +// GetChannelKey 验证2FA后获取渠道密钥 +func GetChannelKey(c *gin.Context) { + type GetChannelKeyRequest struct { + Code string `json:"code" binding:"required"` + } + + var req GetChannelKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, fmt.Errorf("参数错误: %v", err)) + return + } + + userId := c.GetInt("id") + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) + return + } + + // 获取2FA记录并验证 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err)) + return + } + + if twoFA == nil || !twoFA.IsEnabled { + common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥")) + return + } + + // 统一的2FA验证逻辑 + if !validateTwoFactorAuth(twoFA, req.Code) { + common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) + return + } + + // 获取渠道信息(包含密钥) + channel, err := model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) + return + } + + if channel == nil { + common.ApiError(c, fmt.Errorf("渠道不存在")) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) + + // 统一的成功响应格式 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "验证成功", + "data": map[string]interface{}{ + "key": channel.Key, + }, + }) +} + +// validateTwoFactorAuth 统一的2FA验证函数 +func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool { + // 尝试验证TOTP + if cleanCode, err := common.ValidateNumericCode(code); err == nil { + if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid { + return true + } + } + + // 尝试验证备用码 + if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid { + return true + } + + return false +} + // validateChannel 通用的渠道校验函数 func validateChannel(channel *model.Channel, isAdd bool) error { // 校验 channel settings diff --git a/router/api-router.go b/router/api-router.go index be721b05..b3d4fe08 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/:id", controller.GetChannel) + channelRoute.POST("/:id/key", controller.GetChannelKey) channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx new file mode 100644 index 00000000..a3884d98 --- /dev/null +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -0,0 +1,128 @@ +/* +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 from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui'; + +/** + * 可复用的两步验证模态框组件 + * @param {Object} props + * @param {boolean} props.visible - 是否显示模态框 + * @param {string} props.code - 验证码值 + * @param {boolean} props.loading - 是否正在验证 + * @param {Function} props.onCodeChange - 验证码变化回调 + * @param {Function} props.onVerify - 验证回调 + * @param {Function} props.onCancel - 取消回调 + * @param {string} props.title - 模态框标题 + * @param {string} props.description - 验证描述文本 + * @param {string} props.placeholder - 输入框占位文本 + */ +const TwoFactorAuthModal = ({ + visible, + code, + loading, + onCodeChange, + onVerify, + onCancel, + title, + description, + placeholder +}) => { + const { t } = useTranslation(); + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && code) { + onVerify(); + } + }; + + return ( + +
+ + + +
+ {title || t('安全验证')} + + } + visible={visible} + onCancel={onCancel} + footer={ + <> + + + + } + width={500} + style={{ maxWidth: '90vw' }} + > +
+ {/* 安全提示 */} +
+
+ + + +
+ + {t('安全验证')} + + + {description || t('为了保护账户安全,请验证您的两步验证码。')} + +
+
+
+ + {/* 验证码输入 */} +
+ + {t('验证身份')} + + + + {t('支持6位TOTP验证码或8位备用码')} + +
+
+
+ ); +}; + +export default TwoFactorAuthModal; \ No newline at end of file diff --git a/web/src/components/common/ui/ChannelKeyDisplay.jsx b/web/src/components/common/ui/ChannelKeyDisplay.jsx new file mode 100644 index 00000000..8c6e79ef --- /dev/null +++ b/web/src/components/common/ui/ChannelKeyDisplay.jsx @@ -0,0 +1,224 @@ +/* +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 from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui'; +import { copy, showSuccess } from '../../../helpers'; + +/** + * 解析密钥数据,支持多种格式 + * @param {string} keyData - 密钥数据 + * @param {Function} t - 翻译函数 + * @returns {Array} 解析后的密钥数组 + */ +const parseChannelKeys = (keyData, t) => { + if (!keyData) return []; + + const trimmed = keyData.trim(); + + // 检查是否是JSON数组格式(如Vertex AI) + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map((item, index) => ({ + id: index, + content: typeof item === 'string' ? item : JSON.stringify(item, null, 2), + type: typeof item === 'string' ? 'text' : 'json', + label: `${t('密钥')} ${index + 1}` + })); + } + } catch (e) { + // 如果解析失败,按普通文本处理 + console.warn('Failed to parse JSON keys:', e); + } + } + + // 检查是否是多行密钥(按换行符分割) + const lines = trimmed.split('\n').filter(line => line.trim()); + if (lines.length > 1) { + return lines.map((line, index) => ({ + id: index, + content: line.trim(), + type: 'text', + label: `${t('密钥')} ${index + 1}` + })); + } + + // 单个密钥 + return [{ + id: 0, + content: trimmed, + type: trimmed.startsWith('{') ? 'json' : 'text', + label: t('密钥') + }]; +}; + +/** + * 可复用的密钥显示组件 + * @param {Object} props + * @param {string} props.keyData - 密钥数据 + * @param {boolean} props.showSuccessIcon - 是否显示成功图标 + * @param {string} props.successText - 成功文本 + * @param {boolean} props.showWarning - 是否显示安全警告 + * @param {string} props.warningText - 警告文本 + */ +const ChannelKeyDisplay = ({ + keyData, + showSuccessIcon = true, + successText, + showWarning = true, + warningText +}) => { + const { t } = useTranslation(); + + const parsedKeys = parseChannelKeys(keyData, t); + const isMultipleKeys = parsedKeys.length > 1; + + const handleCopyAll = () => { + copy(keyData); + showSuccess(t('所有密钥已复制到剪贴板')); + }; + + const handleCopyKey = (content) => { + copy(content); + showSuccess(t('密钥已复制到剪贴板')); + }; + + return ( +
+ {/* 成功状态 */} + {showSuccessIcon && ( +
+ + + + + {successText || t('验证成功')} + +
+ )} + + {/* 密钥内容 */} +
+
+ + {isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')} + + {isMultipleKeys && ( +
+ + {t('共 {{count}} 个密钥', { count: parsedKeys.length })} + + +
+ )} +
+ +
+ {parsedKeys.map((keyItem) => ( + +
+
+ + {keyItem.label} + +
+ {keyItem.type === 'json' && ( + {t('JSON')} + )} + +
+
+ +
+ + {keyItem.content} + +
+ + {keyItem.type === 'json' && ( + + {t('JSON格式密钥,请确保格式正确')} + + )} +
+
+ ))} +
+ + {isMultipleKeys && ( +
+ + + + + {t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')} + +
+ )} +
+ + {/* 安全警告 */} + {showWarning && ( +
+
+ + + +
+ + {t('安全提醒')} + + + {warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')} + +
+
+
+ )} +
+ ); +}; + +export default ChannelKeyDisplay; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index de84da1e..78c8197f 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -45,10 +45,13 @@ import { Row, Col, Highlight, + Input, } from '@douyinfe/semi-ui'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import JSONEditor from '../../../common/ui/JSONEditor'; +import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal'; +import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; import { IconSave, IconClose, @@ -158,6 +161,44 @@ const EditChannelModal = (props) => { const [channelSearchValue, setChannelSearchValue] = useState(''); const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式 const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) + + // 2FA验证查看密钥相关状态 + const [twoFAState, setTwoFAState] = useState({ + showModal: false, + code: '', + loading: false, + showKey: false, + keyData: '' + }); + + // 专门的2FA验证状态(用于TwoFactorAuthModal) + const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false); + const [verifyCode, setVerifyCode] = useState(''); + const [verifyLoading, setVerifyLoading] = useState(false); + + // 2FA状态更新辅助函数 + const updateTwoFAState = (updates) => { + setTwoFAState(prev => ({ ...prev, ...updates })); + }; + + // 重置2FA状态 + const resetTwoFAState = () => { + setTwoFAState({ + showModal: false, + code: '', + loading: false, + showKey: false, + keyData: '' + }); + }; + + // 重置2FA验证状态 + const reset2FAVerifyState = () => { + setShow2FAVerifyModal(false); + setVerifyCode(''); + setVerifyLoading(false); + }; + // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -500,6 +541,42 @@ const EditChannelModal = (props) => { } }; + // 使用TwoFactorAuthModal的验证函数 + const handleVerify2FA = async () => { + if (!verifyCode) { + showError(t('请输入验证码或备用码')); + return; + } + + setVerifyLoading(true); + try { + const res = await API.post(`/api/channel/${channelId}/key`, { + code: verifyCode + }); + if (res.data.success) { + // 验证成功,显示密钥 + updateTwoFAState({ + showModal: true, + showKey: true, + keyData: res.data.data.key + }); + reset2FAVerifyState(); + showSuccess(t('验证成功')); + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('获取密钥失败')); + } finally { + setVerifyLoading(false); + } + }; + + // 显示2FA验证模态框 - 使用TwoFactorAuthModal + const handleShow2FAModal = () => { + setShow2FAVerifyModal(true); + }; + useEffect(() => { const modelMap = new Map(); @@ -576,27 +653,37 @@ const EditChannelModal = (props) => { // 重置手动输入模式状态 setUseManualInput(false); } else { - formApiRef.current?.reset(); - // 重置渠道设置状态 - setChannelSettings({ - force_format: false, - thinking_to_content: false, - proxy: '', - pass_through_body_enabled: false, - system_prompt: '', - system_prompt_override: false, - }); - // 重置密钥模式状态 - setKeyMode('append'); - // 清空表单中的key_mode字段 - if (formApiRef.current) { - formApiRef.current.setValue('key_mode', undefined); - } - // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 - setInputs(getInitValues()); + // 统一的模态框关闭重置逻辑 + resetModalState(); } }, [props.visible, channelId]); + // 统一的模态框重置函数 + const resetModalState = () => { + formApiRef.current?.reset(); + // 重置渠道设置状态 + setChannelSettings({ + force_format: false, + thinking_to_content: false, + proxy: '', + pass_through_body_enabled: false, + system_prompt: '', + system_prompt_override: false, + }); + // 重置密钥模式状态 + setKeyMode('append'); + // 清空表单中的key_mode字段 + if (formApiRef.current) { + formApiRef.current.setValue('key_mode', undefined); + } + // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 + setInputs(getInitValues()); + // 重置2FA状态 + resetTwoFAState(); + // 重置2FA验证状态 + reset2FAVerifyState(); + }; + const handleVertexUploadChange = ({ fileList }) => { vertexErroredNames.current.clear(); (async () => { @@ -1080,6 +1167,16 @@ const EditChannelModal = (props) => { {t('追加模式:新密钥将添加到现有密钥列表的末尾')} )} + {isEdit && ( + + )} {batchExtra} } @@ -1154,6 +1251,16 @@ const EditChannelModal = (props) => { {t('追加模式:新密钥将添加到现有密钥列表的末尾')} )} + {isEdit && ( + + )} {batchExtra} } @@ -1194,6 +1301,16 @@ const EditChannelModal = (props) => { {t('追加模式:新密钥将添加到现有密钥列表的末尾')} )} + {isEdit && ( + + )} {batchExtra} } @@ -1846,6 +1963,53 @@ const EditChannelModal = (props) => { onVisibleChange={(visible) => setIsModalOpenurl(visible)} /> + {/* 使用TwoFactorAuthModal组件进行2FA验证 */} + + + {/* 使用ChannelKeyDisplay组件显示密钥 */} + +
+ + + +
+ {t('渠道密钥信息')} + + } + visible={twoFAState.showModal && twoFAState.showKey} + onCancel={resetTwoFAState} + footer={ + + } + width={700} + style={{ maxWidth: '90vw' }} + > + +
+ Date: Mon, 25 Aug 2025 15:20:04 +0800 Subject: [PATCH 06/78] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E9=80=9F=E7=8E=87=E9=99=90=E5=88=B6=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- router/api-router.go | 2 +- web/src/components/common/modals/TwoFactorAuthModal.jsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/router/api-router.go b/router/api-router.go index b3d4fe08..f8d87d64 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -114,7 +114,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/:id", controller.GetChannel) - channelRoute.POST("/:id/key", controller.GetChannelKey) + channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), controller.GetChannelKey) channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx index a3884d98..b2271968 100644 --- a/web/src/components/common/modals/TwoFactorAuthModal.jsx +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -47,8 +47,8 @@ const TwoFactorAuthModal = ({ }) => { const { t } = useTranslation(); - const handleKeyPress = (e) => { - if (e.key === 'Enter' && code) { + const handleKeyDown = (e) => { + if (e.key === 'Enter' && code && !loading) { onVerify(); } }; @@ -75,7 +75,7 @@ const TwoFactorAuthModal = ({ - - {
{/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
- +
{/* 可滚动的内容区域 */} diff --git a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx index fe842fe3..78cea080 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx @@ -35,6 +35,16 @@ const PricingTopSection = memo(({ filteredModels, loading, searchValue, + showWithRecharge, + setShowWithRecharge, + currency, + setCurrency, + showRatio, + setShowRatio, + viewMode, + setViewMode, + tokenUnit, + setTokenUnit, t }) => { const [showFilterModal, setShowFilterModal] = useState(false); @@ -53,6 +63,16 @@ const PricingTopSection = memo(({ isMobile={isMobile} searchValue={searchValue} setShowFilterModal={setShowFilterModal} + showWithRecharge={showWithRecharge} + setShowWithRecharge={setShowWithRecharge} + currency={currency} + setCurrency={setCurrency} + showRatio={showRatio} + setShowRatio={setShowRatio} + viewMode={viewMode} + setViewMode={setViewMode} + tokenUnit={tokenUnit} + setTokenUnit={setTokenUnit} t={t} />
@@ -78,6 +98,16 @@ const PricingTopSection = memo(({ isMobile={isMobile} searchValue={searchValue} setShowFilterModal={setShowFilterModal} + showWithRecharge={showWithRecharge} + setShowWithRecharge={setShowWithRecharge} + currency={currency} + setCurrency={setCurrency} + showRatio={showRatio} + setShowRatio={setShowRatio} + viewMode={viewMode} + setViewMode={setViewMode} + tokenUnit={tokenUnit} + setTokenUnit={setTokenUnit} /> )} diff --git a/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx index 2f02bd2b..718b94bb 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx @@ -126,7 +126,17 @@ const PricingVendorIntro = memo(({ handleCompositionEnd, isMobile = false, searchValue = '', - setShowFilterModal + setShowFilterModal, + showWithRecharge, + setShowWithRecharge, + currency, + setCurrency, + showRatio, + setShowRatio, + viewMode, + setViewMode, + tokenUnit, + setTokenUnit }) => { const [currentOffset, setCurrentOffset] = useState(0); const [descModalVisible, setDescModalVisible] = useState(false); @@ -239,9 +249,19 @@ const PricingVendorIntro = memo(({ isMobile={isMobile} searchValue={searchValue} setShowFilterModal={setShowFilterModal} + showWithRecharge={showWithRecharge} + setShowWithRecharge={setShowWithRecharge} + currency={currency} + setCurrency={setCurrency} + showRatio={showRatio} + setShowRatio={setShowRatio} + viewMode={viewMode} + setViewMode={setViewMode} + tokenUnit={tokenUnit} + setTokenUnit={setTokenUnit} t={t} /> - ), [selectedRowKeys, copyText, handleChange, handleCompositionStart, handleCompositionEnd, isMobile, searchValue, setShowFilterModal, t]); + ), [selectedRowKeys, copyText, handleChange, handleCompositionStart, handleCompositionEnd, isMobile, searchValue, setShowFilterModal, showWithRecharge, setShowWithRecharge, currency, setCurrency, showRatio, setShowRatio, viewMode, setViewMode, tokenUnit, setTokenUnit, t]); const renderHeaderCard = useCallback(({ title, count, description, rightContent, primaryDarkerChannel }) => ( { const handleCopyClick = useCallback(() => { @@ -42,6 +52,14 @@ const SearchActions = memo(({ setShowFilterModal?.(true); }, [setShowFilterModal]); + const handleViewModeToggle = useCallback(() => { + setViewMode?.(viewMode === 'table' ? 'card' : 'table'); + }, [viewMode, setViewMode]); + + const handleTokenUnitToggle = useCallback(() => { + setTokenUnit?.(tokenUnit === 'K' ? 'M' : 'K'); + }, [tokenUnit, setTokenUnit]); + return (
@@ -67,6 +85,63 @@ const SearchActions = memo(({ {t('复制')} + {!isMobile && ( + <> + + + {/* 充值价格显示开关 */} +
+ {t('充值价格显示')} + +
+ + {/* 货币单位选择 */} + {showWithRecharge && ( + - + {t('支持6位TOTP验证码或8位备用码')}
@@ -126,4 +143,4 @@ const TwoFactorAuthModal = ({ ); }; -export default TwoFactorAuthModal; \ No newline at end of file +export default TwoFactorAuthModal; diff --git a/web/src/components/common/ui/CardPro.jsx b/web/src/components/common/ui/CardPro.jsx index 3e124722..2c95f97c 100644 --- a/web/src/components/common/ui/CardPro.jsx +++ b/web/src/components/common/ui/CardPro.jsx @@ -27,15 +27,15 @@ const { Text } = Typography; /** * CardPro 高级卡片组件 - * + * * 布局分为6个区域: * 1. 统计信息区域 (statsArea) - * 2. 描述信息区域 (descriptionArea) + * 2. 描述信息区域 (descriptionArea) * 3. 类型切换/标签区域 (tabsArea) * 4. 操作按钮区域 (actionsArea) * 5. 搜索表单区域 (searchArea) * 6. 分页区域 (paginationArea) - 固定在卡片底部 - * + * * 支持三种布局类型: * - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单 * - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单 @@ -71,47 +71,38 @@ const CardPro = ({ const hasMobileHideableContent = actionsArea || searchArea; const renderHeader = () => { - const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea; + const hasContent = + statsArea || descriptionArea || tabsArea || actionsArea || searchArea; if (!hasContent) return null; return ( -
+
{/* 统计信息区域 - 用于type2 */} - {type === 'type2' && statsArea && ( - <> - {statsArea} - - )} + {type === 'type2' && statsArea && <>{statsArea}} {/* 描述信息区域 - 用于type1和type3 */} {(type === 'type1' || type === 'type3') && descriptionArea && ( - <> - {descriptionArea} - + <>{descriptionArea} )} {/* 第一个分隔线 - 在描述信息或统计信息后面 */} {((type === 'type1' || type === 'type3') && descriptionArea) || - (type === 'type2' && statsArea) ? ( - + (type === 'type2' && statsArea) ? ( + ) : null} {/* 类型切换/标签区域 - 主要用于type3 */} - {type === 'type3' && tabsArea && ( - <> - {tabsArea} - - )} + {type === 'type3' && tabsArea && <>{tabsArea}} {/* 移动端操作切换按钮 */} {isMobile && hasMobileHideableContent && ( <> -
+
); @@ -214,4 +197,4 @@ CardPro.propTypes = { t: PropTypes.func, }; -export default CardPro; \ No newline at end of file +export default CardPro; diff --git a/web/src/components/common/ui/CardTable.jsx b/web/src/components/common/ui/CardTable.jsx index f7f443db..8a331d07 100644 --- a/web/src/components/common/ui/CardTable.jsx +++ b/web/src/components/common/ui/CardTable.jsx @@ -19,7 +19,15 @@ For commercial licensing, please contact support@quantumnous.com import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@douyinfe/semi-ui'; +import { + Table, + Card, + Skeleton, + Pagination, + Empty, + Button, + Collapsible, +} from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; import PropTypes from 'prop-types'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; @@ -27,7 +35,7 @@ import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTi /** * CardTable 响应式表格组件 - * + * * 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。 * 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。 */ @@ -75,18 +83,22 @@ const CardTable = ({ const renderSkeletonCard = (key) => { const placeholder = ( -
+
{visibleCols.map((col, idx) => { if (!col.title) { return ( -
+
); } return ( -
+
+ ); }; return ( -
+
{[1, 2, 3].map((i) => renderSkeletonCard(i))}
); @@ -127,9 +139,12 @@ const CardTable = ({ (!tableProps.rowExpandable || tableProps.rowExpandable(record)); return ( - + {columns.map((col, colIdx) => { - if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) { + if ( + tableProps?.visibleColumns && + !tableProps.visibleColumns[col.key] + ) { return null; } @@ -140,7 +155,7 @@ const CardTable = ({ if (!title) { return ( -
+
{cellContent}
); @@ -149,14 +164,16 @@ const CardTable = ({ return (
- + {title} -
- {cellContent !== undefined && cellContent !== null ? cellContent : '-'} +
+ {cellContent !== undefined && cellContent !== null + ? cellContent + : '-'}
); @@ -177,7 +194,7 @@ const CardTable = ({ {showDetails ? t('收起') : t('详情')} -
+
{tableProps.expandedRowRender(record, index)}
@@ -190,19 +207,23 @@ const CardTable = ({ if (isEmpty) { if (tableProps.empty) return tableProps.empty; return ( -
- +
+
); } return ( -
+
{dataSource.map((record, index) => ( - + ))} {!hidePagination && tableProps.pagination && dataSource.length > 0 && ( -
+
)} @@ -218,4 +239,4 @@ CardTable.propTypes = { hidePagination: PropTypes.bool, }; -export default CardTable; \ No newline at end of file +export default CardTable; diff --git a/web/src/components/common/ui/ChannelKeyDisplay.jsx b/web/src/components/common/ui/ChannelKeyDisplay.jsx index 8c6e79ef..79aa3eec 100644 --- a/web/src/components/common/ui/ChannelKeyDisplay.jsx +++ b/web/src/components/common/ui/ChannelKeyDisplay.jsx @@ -30,9 +30,9 @@ import { copy, showSuccess } from '../../../helpers'; */ const parseChannelKeys = (keyData, t) => { if (!keyData) return []; - + const trimmed = keyData.trim(); - + // 检查是否是JSON数组格式(如Vertex AI) if (trimmed.startsWith('[')) { try { @@ -40,9 +40,10 @@ const parseChannelKeys = (keyData, t) => { if (Array.isArray(parsed)) { return parsed.map((item, index) => ({ id: index, - content: typeof item === 'string' ? item : JSON.stringify(item, null, 2), + content: + typeof item === 'string' ? item : JSON.stringify(item, null, 2), type: typeof item === 'string' ? 'text' : 'json', - label: `${t('密钥')} ${index + 1}` + label: `${t('密钥')} ${index + 1}`, })); } } catch (e) { @@ -50,25 +51,27 @@ const parseChannelKeys = (keyData, t) => { console.warn('Failed to parse JSON keys:', e); } } - + // 检查是否是多行密钥(按换行符分割) - const lines = trimmed.split('\n').filter(line => line.trim()); + const lines = trimmed.split('\n').filter((line) => line.trim()); if (lines.length > 1) { return lines.map((line, index) => ({ id: index, content: line.trim(), type: 'text', - label: `${t('密钥')} ${index + 1}` + label: `${t('密钥')} ${index + 1}`, })); } - + // 单个密钥 - return [{ - id: 0, - content: trimmed, - type: trimmed.startsWith('{') ? 'json' : 'text', - label: t('密钥') - }]; + return [ + { + id: 0, + content: trimmed, + type: trimmed.startsWith('{') ? 'json' : 'text', + label: t('密钥'), + }, + ]; }; /** @@ -85,7 +88,7 @@ const ChannelKeyDisplay = ({ showSuccessIcon = true, successText, showWarning = true, - warningText + warningText, }) => { const { t } = useTranslation(); @@ -103,34 +106,42 @@ const ChannelKeyDisplay = ({ }; return ( -
+
{/* 成功状态 */} {showSuccessIcon && ( -
- - +
+ + - + {successText || t('验证成功')}
)} {/* 密钥内容 */} -
-
+
+
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')} {isMultipleKeys && ( -
- +
+ {t('共 {{count}} 个密钥', { count: parsedKeys.length })}
)}
- -
+ +
{parsedKeys.map((keyItem) => ( - -
-
- + +
+
+ {keyItem.label} -
+
{keyItem.type === 'json' && ( - {t('JSON')} + + {t('JSON')} + )}
- -
+ +
{keyItem.content}
- + {keyItem.type === 'json' && ( - + {t('JSON格式密钥,请确保格式正确')} )} @@ -186,14 +214,28 @@ const ChannelKeyDisplay = ({ ))}
- + {isMultipleKeys && ( -
- - - +
+ + + - {t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')} + {t( + '检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。', + )}
)} @@ -201,17 +243,31 @@ const ChannelKeyDisplay = ({ {/* 安全警告 */} {showWarning && ( -
-
- - +
+
+ +
- + {t('安全提醒')} - - {warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')} + + {warningText || + t( + '请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。', + )}
@@ -221,4 +277,4 @@ const ChannelKeyDisplay = ({ ); }; -export default ChannelKeyDisplay; \ No newline at end of file +export default ChannelKeyDisplay; diff --git a/web/src/components/common/ui/CompactModeToggle.jsx b/web/src/components/common/ui/CompactModeToggle.jsx index 631156ee..40da0abc 100644 --- a/web/src/components/common/ui/CompactModeToggle.jsx +++ b/web/src/components/common/ui/CompactModeToggle.jsx @@ -65,4 +65,4 @@ CompactModeToggle.propTypes = { className: PropTypes.string, }; -export default CompactModeToggle; \ No newline at end of file +export default CompactModeToggle; diff --git a/web/src/components/common/ui/JSONEditor.jsx b/web/src/components/common/ui/JSONEditor.jsx index 4acbe270..7acdc2e3 100644 --- a/web/src/components/common/ui/JSONEditor.jsx +++ b/web/src/components/common/ui/JSONEditor.jsx @@ -36,11 +36,7 @@ import { Divider, Tooltip, } from '@douyinfe/semi-ui'; -import { - IconPlus, - IconDelete, - IconAlertTriangle, -} from '@douyinfe/semi-icons'; +import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons'; const { Text } = Typography; @@ -88,7 +84,7 @@ const JSONEditor = ({ // 将键值对数组转换为对象(重复键时后面的会覆盖前面的) const keyValueArrayToObject = useCallback((arr) => { const result = {}; - arr.forEach(item => { + arr.forEach((item) => { if (item.key) { result[item.key] = item.value; } @@ -115,7 +111,8 @@ const JSONEditor = ({ // 手动模式下的本地文本缓冲 const [manualText, setManualText] = useState(() => { if (typeof value === 'string') return value; - if (value && typeof value === 'object') return JSON.stringify(value, null, 2); + if (value && typeof value === 'object') + return JSON.stringify(value, null, 2); return ''; }); @@ -140,7 +137,7 @@ const JSONEditor = ({ const keyCount = {}; const duplicates = new Set(); - keyValuePairs.forEach(pair => { + keyValuePairs.forEach((pair) => { if (pair.key) { keyCount[pair.key] = (keyCount[pair.key] || 0) + 1; if (keyCount[pair.key] > 1) { @@ -178,51 +175,65 @@ const JSONEditor = ({ useEffect(() => { if (editMode !== 'manual') { if (typeof value === 'string') setManualText(value); - else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2)); + 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); + const handleVisualChange = useCallback( + (newPairs) => { + setKeyValuePairs(newPairs); + const jsonObject = keyValueArrayToObject(newPairs); + const jsonString = + Object.keys(jsonObject).length === 0 + ? '' + : JSON.stringify(jsonObject, null, 2); - setJsonError(''); + setJsonError(''); - // 通过formApi设置值 - if (formApi && field) { - formApi.setValue(field, jsonString); - } + // 通过formApi设置值 + if (formApi && field) { + formApi.setValue(field, jsonString); + } - onChange?.(jsonString); - }, [onChange, formApi, field, keyValueArrayToObject]); + 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)); + 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?.(newValue); - } catch (error) { - setJsonError(error.message); + onChange?.(''); } - } else { - setKeyValuePairs([]); - setJsonError(''); - onChange?.(''); - } - }, [onChange, objectToKeyValueArray, keyValuePairs]); + }, + [onChange, objectToKeyValueArray, keyValuePairs], + ); // 切换编辑模式 const toggleEditMode = useCallback(() => { if (editMode === 'visual') { const jsonObject = keyValueArrayToObject(keyValuePairs); - setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2)); + setManualText( + Object.keys(jsonObject).length === 0 + ? '' + : JSON.stringify(jsonObject, null, 2), + ); setEditMode('manual'); } else { try { @@ -242,12 +253,19 @@ const JSONEditor = ({ return; } } - }, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]); + }, [ + editMode, + value, + manualText, + keyValuePairs, + keyValueArrayToObject, + objectToKeyValueArray, + ]); // 添加键值对 const addKeyValue = useCallback(() => { const newPairs = [...keyValuePairs]; - const existingKeys = newPairs.map(p => p.key); + const existingKeys = newPairs.map((p) => p.key); let counter = 1; let newKey = `field_${counter}`; while (existingKeys.includes(newKey)) { @@ -257,32 +275,41 @@ const JSONEditor = ({ newPairs.push({ id: generateUniqueId(), key: newKey, - value: '' + value: '', }); handleVisualChange(newPairs); }, [keyValuePairs, handleVisualChange]); // 删除键值对 - const removeKeyValue = useCallback((id) => { - const newPairs = keyValuePairs.filter(pair => pair.id !== id); - 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 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 updateValue = useCallback( + (id, newValue) => { + const newPairs = keyValuePairs.map((pair) => + pair.id === id ? { ...pair, value: newValue } : pair, + ); + handleVisualChange(newPairs); + }, + [keyValuePairs, handleVisualChange], + ); // 填入模板 const fillTemplate = useCallback(() => { @@ -298,7 +325,14 @@ const JSONEditor = ({ onChange?.(templateString); setJsonError(''); } - }, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]); + }, [ + template, + onChange, + formApi, + field, + objectToKeyValueArray, + keyValuePairs, + ]); // 渲染值输入控件(支持嵌套) const renderValueInput = (pairId, value) => { @@ -306,12 +340,12 @@ const JSONEditor = ({ if (valueType === 'boolean') { return ( -
+
updateValue(pairId, newValue)} /> - + {value ? t('true') : t('false')}
@@ -373,29 +407,29 @@ const JSONEditor = ({ // 渲染键值对编辑器 const renderKeyValueEditor = () => { return ( -
+
{/* 重复键警告 */} {duplicateKeys.size > 0 && ( } description={
{t('存在重复的键名:')} {Array.from(duplicateKeys).join(', ')}
- + {t('注意:JSON中重复的键只会保留最后一个同名键的值')}
} - className="mb-3" + className='mb-3' /> )} {keyValuePairs.length === 0 && ( -
- +
+ {t('暂无数据,点击下方按钮添加键值对')}
@@ -403,13 +437,14 @@ const JSONEditor = ({ {keyValuePairs.map((pair, index) => { const isDuplicate = duplicateKeys.has(pair.key); - const isLastDuplicate = isDuplicate && - keyValuePairs.slice(index + 1).every(p => p.key !== pair.key); + const isLastDuplicate = + isDuplicate && + keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key); return ( - + -
+
)}
- - {renderValueInput(pair.id, pair.value)} - + {renderValueInput(pair.id, pair.value)} @@ -590,9 +626,9 @@ const JSONEditor = ({ +
{ if (key === 'manual' && editMode === 'visual') { @@ -602,16 +638,12 @@ const JSONEditor = ({ } }} > - - + + {template && templateLabel && ( - )} @@ -619,14 +651,14 @@ const JSONEditor = ({ } headerStyle={{ padding: '12px 16px' }} bodyStyle={{ padding: '16px' }} - className="!rounded-2xl" + className='!rounded-2xl' > {/* JSON错误提示 */} {hasJsonError && ( )} @@ -668,17 +700,15 @@ const JSONEditor = ({ {/* 额外文本显示在卡片底部 */} {extraText && ( - {extraText} + + {extraText} + )} - {extraFooter && ( -
- {extraFooter} -
- )} + {extraFooter &&
{extraFooter}
} ); }; -export default JSONEditor; \ No newline at end of file +export default JSONEditor; diff --git a/web/src/components/common/ui/Loading.jsx b/web/src/components/common/ui/Loading.jsx index 60f94748..a2fc6f8e 100644 --- a/web/src/components/common/ui/Loading.jsx +++ b/web/src/components/common/ui/Loading.jsx @@ -21,13 +21,9 @@ import React from 'react'; import { Spin } from '@douyinfe/semi-ui'; const Loading = ({ size = 'small' }) => { - return ( -
- +
+
); }; diff --git a/web/src/components/common/ui/RenderUtils.jsx b/web/src/components/common/ui/RenderUtils.jsx index 26a72e16..3411649c 100644 --- a/web/src/components/common/ui/RenderUtils.jsx +++ b/web/src/components/common/ui/RenderUtils.jsx @@ -57,4 +57,4 @@ export const renderDescription = (text, maxWidth = 200) => { {text || '-'} ); -}; \ No newline at end of file +}; diff --git a/web/src/components/common/ui/ScrollableContainer.jsx b/web/src/components/common/ui/ScrollableContainer.jsx index 4ddda7d8..441c8c03 100644 --- a/web/src/components/common/ui/ScrollableContainer.jsx +++ b/web/src/components/common/ui/ScrollableContainer.jsx @@ -24,197 +24,219 @@ import React, { useCallback, useMemo, useImperativeHandle, - forwardRef + forwardRef, } from 'react'; /** * ScrollableContainer 可滚动容器组件 - * + * * 提供自动检测滚动状态和显示渐变指示器的功能 * 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器 - * + * */ -const ScrollableContainer = forwardRef(({ - children, - maxHeight = '24rem', - className = '', - contentClassName = '', - fadeIndicatorClassName = '', - checkInterval = 100, - scrollThreshold = 5, - debounceDelay = 16, // ~60fps - onScroll, - onScrollStateChange, - ...props -}, ref) => { - const scrollRef = useRef(null); - const containerRef = useRef(null); - const debounceTimerRef = useRef(null); - const resizeObserverRef = useRef(null); - const onScrollStateChangeRef = useRef(onScrollStateChange); - const onScrollRef = useRef(onScroll); - - const [showScrollHint, setShowScrollHint] = useState(false); - - useEffect(() => { - onScrollStateChangeRef.current = onScrollStateChange; - }, [onScrollStateChange]); - - useEffect(() => { - onScrollRef.current = onScroll; - }, [onScroll]); - - const debounce = useCallback((func, delay) => { - return (...args) => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - debounceTimerRef.current = setTimeout(() => func(...args), delay); - }; - }, []); - - const checkScrollable = useCallback(() => { - if (!scrollRef.current) return; - - const element = scrollRef.current; - const isScrollable = element.scrollHeight > element.clientHeight; - const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold; - const shouldShowHint = isScrollable && !isAtBottom; - - setShowScrollHint(shouldShowHint); - - if (onScrollStateChangeRef.current) { - onScrollStateChangeRef.current({ - isScrollable, - isAtBottom, - showScrollHint: shouldShowHint, - scrollTop: element.scrollTop, - scrollHeight: element.scrollHeight, - clientHeight: element.clientHeight - }); - } - }, [scrollThreshold]); - - const debouncedCheckScrollable = useMemo(() => - debounce(checkScrollable, debounceDelay), - [debounce, checkScrollable, debounceDelay] - ); - - const handleScroll = useCallback((e) => { - debouncedCheckScrollable(); - if (onScrollRef.current) { - onScrollRef.current(e); - } - }, [debouncedCheckScrollable]); - - useImperativeHandle(ref, () => ({ - checkScrollable: () => { - checkScrollable(); +const ScrollableContainer = forwardRef( + ( + { + children, + maxHeight = '24rem', + className = '', + contentClassName = '', + fadeIndicatorClassName = '', + checkInterval = 100, + scrollThreshold = 5, + debounceDelay = 16, // ~60fps + onScroll, + onScrollStateChange, + ...props }, - scrollToTop: () => { - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, - scrollToBottom: () => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, - getScrollInfo: () => { - if (!scrollRef.current) return null; - const element = scrollRef.current; - return { - scrollTop: element.scrollTop, - scrollHeight: element.scrollHeight, - clientHeight: element.clientHeight, - isScrollable: element.scrollHeight > element.clientHeight, - isAtBottom: element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold + ref, + ) => { + const scrollRef = useRef(null); + const containerRef = useRef(null); + const debounceTimerRef = useRef(null); + const resizeObserverRef = useRef(null); + const onScrollStateChangeRef = useRef(onScrollStateChange); + const onScrollRef = useRef(onScroll); + + const [showScrollHint, setShowScrollHint] = useState(false); + + useEffect(() => { + onScrollStateChangeRef.current = onScrollStateChange; + }, [onScrollStateChange]); + + useEffect(() => { + onScrollRef.current = onScroll; + }, [onScroll]); + + const debounce = useCallback((func, delay) => { + return (...args) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => func(...args), delay); }; - } - }), [checkScrollable, scrollThreshold]); + }, []); - useEffect(() => { - const timer = setTimeout(() => { - checkScrollable(); - }, checkInterval); - return () => clearTimeout(timer); - }, [checkScrollable, checkInterval]); + const checkScrollable = useCallback(() => { + if (!scrollRef.current) return; - useEffect(() => { - if (!scrollRef.current) return; + const element = scrollRef.current; + const isScrollable = element.scrollHeight > element.clientHeight; + const isAtBottom = + element.scrollTop + element.clientHeight >= + element.scrollHeight - scrollThreshold; + const shouldShowHint = isScrollable && !isAtBottom; - if (typeof ResizeObserver === 'undefined') { - if (typeof MutationObserver !== 'undefined') { - const observer = new MutationObserver(() => { - debouncedCheckScrollable(); + setShowScrollHint(shouldShowHint); + + if (onScrollStateChangeRef.current) { + onScrollStateChangeRef.current({ + isScrollable, + isAtBottom, + showScrollHint: shouldShowHint, + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight, }); - - observer.observe(scrollRef.current, { - childList: true, - subtree: true, - attributes: true, - characterData: true - }); - - return () => observer.disconnect(); } - return; - } + }, [scrollThreshold]); - resizeObserverRef.current = new ResizeObserver((entries) => { - for (const entry of entries) { + const debouncedCheckScrollable = useMemo( + () => debounce(checkScrollable, debounceDelay), + [debounce, checkScrollable, debounceDelay], + ); + + const handleScroll = useCallback( + (e) => { debouncedCheckScrollable(); + if (onScrollRef.current) { + onScrollRef.current(e); + } + }, + [debouncedCheckScrollable], + ); + + useImperativeHandle( + ref, + () => ({ + checkScrollable: () => { + checkScrollable(); + }, + scrollToTop: () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, + scrollToBottom: () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, + getScrollInfo: () => { + if (!scrollRef.current) return null; + const element = scrollRef.current; + return { + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight, + isScrollable: element.scrollHeight > element.clientHeight, + isAtBottom: + element.scrollTop + element.clientHeight >= + element.scrollHeight - scrollThreshold, + }; + }, + }), + [checkScrollable, scrollThreshold], + ); + + useEffect(() => { + const timer = setTimeout(() => { + checkScrollable(); + }, checkInterval); + return () => clearTimeout(timer); + }, [checkScrollable, checkInterval]); + + useEffect(() => { + if (!scrollRef.current) return; + + if (typeof ResizeObserver === 'undefined') { + if (typeof MutationObserver !== 'undefined') { + const observer = new MutationObserver(() => { + debouncedCheckScrollable(); + }); + + observer.observe(scrollRef.current, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + + return () => observer.disconnect(); + } + return; } - }); - resizeObserverRef.current.observe(scrollRef.current); + resizeObserverRef.current = new ResizeObserver((entries) => { + for (const entry of entries) { + debouncedCheckScrollable(); + } + }); - return () => { - if (resizeObserverRef.current) { - resizeObserverRef.current.disconnect(); - } - }; - }, [debouncedCheckScrollable]); + resizeObserverRef.current.observe(scrollRef.current); - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - }; - }, []); + return () => { + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + } + }; + }, [debouncedCheckScrollable]); - const containerStyle = useMemo(() => ({ - maxHeight - }), [maxHeight]); + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); - const fadeIndicatorStyle = useMemo(() => ({ - opacity: showScrollHint ? 1 : 0 - }), [showScrollHint]); + const containerStyle = useMemo( + () => ({ + maxHeight, + }), + [maxHeight], + ); - return ( -
+ const fadeIndicatorStyle = useMemo( + () => ({ + opacity: showScrollHint ? 1 : 0, + }), + [showScrollHint], + ); + + return (
- {children} +
+ {children} +
+
-
-
- ); -}); + ); + }, +); ScrollableContainer.displayName = 'ScrollableContainer'; -export default ScrollableContainer; \ No newline at end of file +export default ScrollableContainer; diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index 2dd8f657..3fe24908 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -20,7 +20,17 @@ For commercial licensing, please contact support@quantumnous.com import React, { useState, useRef, useEffect } from 'react'; import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; import { useContainerWidth } from '../../../hooks/common/useContainerWidth'; -import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton, Tooltip } from '@douyinfe/semi-ui'; +import { + Divider, + Button, + Tag, + Row, + Col, + Collapsible, + Checkbox, + Skeleton, + Tooltip, +} from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; /** @@ -47,7 +57,7 @@ const SelectableButtonGroup = ({ collapsible = true, collapseHeight = 200, withCheckbox = false, - loading = false + loading = false, }) => { const [isOpen, setIsOpen] = useState(false); const [skeletonCount] = useState(12); @@ -64,15 +74,13 @@ const SelectableButtonGroup = ({ }, [text, containerWidth]); const textElement = ( - + {text} ); return isOverflowing ? ( - - {textElement} - + {textElement} ) : ( textElement ); @@ -80,10 +88,10 @@ const SelectableButtonGroup = ({ // 基于容器宽度计算响应式列数和标签显示策略 const getResponsiveConfig = () => { - if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签 - if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签 - if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签 - return { columns: 3, showTags: true }; // 最宽:3列+标签 + if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签 + if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签 + if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签 + return { columns: 3, showTags: true }; // 最宽:3列+标签 }; const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig(); @@ -102,9 +110,9 @@ const SelectableButtonGroup = ({ const maskStyle = isOpen ? {} : { - WebkitMaskImage: - 'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)', - }; + WebkitMaskImage: + 'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)', + }; const toggle = () => { setIsOpen(!isOpen); @@ -127,25 +135,23 @@ const SelectableButtonGroup = ({ }; const renderSkeletonButtons = () => { - const placeholder = ( {Array.from({ length: skeletonCount }).map((_, index) => ( - -
+ +
{withCheckbox && ( )} @@ -153,7 +159,7 @@ const SelectableButtonGroup = ({ active style={{ width: `${60 + (index % 3) * 20}px`, - height: 14 + height: 14, }} />
@@ -167,26 +173,29 @@ const SelectableButtonGroup = ({ ); }; - const contentElement = showSkeleton ? renderSkeletonButtons() : ( + const contentElement = showSkeleton ? ( + renderSkeletonButtons() + ) : ( {items.map((item) => { - const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0); + const isDisabled = + item.disabled || + (typeof item.tagCount === 'number' && item.tagCount === 0); const isActive = Array.isArray(activeValue) ? activeValue.includes(item.value) : activeValue === item.value; if (withCheckbox) { return ( - + @@ -210,23 +226,27 @@ const SelectableButtonGroup = ({ } return ( - + @@ -237,9 +257,12 @@ const SelectableButtonGroup = ({ ); return ( -
+
{title && ( - + {showSkeleton ? ( ) : ( @@ -249,23 +272,30 @@ const SelectableButtonGroup = ({ )} {needCollapse && !showSkeleton ? (
- + {contentElement} {isOpen ? null : (
- + {t('展开更多')}
)} {isOpen && ( -
- +
+ {t('收起')}
)} @@ -277,4 +307,4 @@ const SelectableButtonGroup = ({ ); }; -export default SelectableButtonGroup; \ No newline at end of file +export default SelectableButtonGroup; diff --git a/web/src/components/dashboard/AnnouncementsPanel.jsx b/web/src/components/dashboard/AnnouncementsPanel.jsx index e24f8da2..c62850b3 100644 --- a/web/src/components/dashboard/AnnouncementsPanel.jsx +++ b/web/src/components/dashboard/AnnouncementsPanel.jsx @@ -21,7 +21,10 @@ import React from 'react'; import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui'; import { Bell } from 'lucide-react'; import { marked } from 'marked'; -import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations'; +import { + IllustrationConstruction, + IllustrationConstructionDark, +} from '@douyinfe/semi-illustrations'; import ScrollableContainer from '../common/ui/ScrollableContainer'; const AnnouncementsPanel = ({ @@ -29,36 +32,43 @@ const AnnouncementsPanel = ({ announcementLegendData, CARD_PROPS, ILLUSTRATION_SIZE, - t + t, }) => { return ( -
+
+
{t('系统公告')} - + {t('显示最新20条')}
{/* 图例 */} -
+
{announcementLegendData.map((legend, index) => ( -
+
- {legend.label} + {legend.label}
))}
@@ -66,9 +76,9 @@ const AnnouncementsPanel = ({ } bodyStyle={{ padding: 0 }} > - + {announcementData.length > 0 ? ( - + {announcementData.map((item, idx) => { const htmlExtra = item.extra ? marked.parse(item.extra) : ''; return ( @@ -76,16 +86,20 @@ const AnnouncementsPanel = ({ key={idx} type={item.type || 'default'} time={`${item.relative ? item.relative + ' ' : ''}${item.time}`} - extra={item.extra ? ( -
- ) : null} + extra={ + item.extra ? ( +
+ ) : null + } >
@@ -93,10 +107,12 @@ const AnnouncementsPanel = ({ })} ) : ( -
+
} - darkModeImage={} + darkModeImage={ + + } title={t('暂无系统公告')} description={t('请联系管理员在系统设置中配置公告信息')} /> @@ -107,4 +123,4 @@ const AnnouncementsPanel = ({ ); }; -export default AnnouncementsPanel; \ No newline at end of file +export default AnnouncementsPanel; diff --git a/web/src/components/dashboard/ApiInfoPanel.jsx b/web/src/components/dashboard/ApiInfoPanel.jsx index 5da250e6..63b6def4 100644 --- a/web/src/components/dashboard/ApiInfoPanel.jsx +++ b/web/src/components/dashboard/ApiInfoPanel.jsx @@ -20,7 +20,10 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui'; import { Server, Gauge, ExternalLink } from 'lucide-react'; -import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations'; +import { + IllustrationConstruction, + IllustrationConstructionDark, +} from '@douyinfe/semi-illustrations'; import ScrollableContainer from '../common/ui/ScrollableContainer'; const ApiInfoPanel = ({ @@ -30,12 +33,12 @@ const ApiInfoPanel = ({ CARD_PROPS, FLEX_CENTER_GAP2, ILLUSTRATION_SIZE, - t + t, }) => { return ( @@ -44,66 +47,65 @@ const ApiInfoPanel = ({ } bodyStyle={{ padding: 0 }} > - + {apiInfoData.length > 0 ? ( apiInfoData.map((api) => ( -
-
- +
+
+ {api.route.substring(0, 2)}
-
-
- +
+
+ {api.route} -
+
} - size="small" - color="white" + size='small' + color='white' shape='circle' onClick={() => handleSpeedTest(api.url)} - className="cursor-pointer hover:opacity-80 text-xs" + className='cursor-pointer hover:opacity-80 text-xs' > {t('测速')} } - size="small" - color="white" + size='small' + color='white' shape='circle' - onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')} - className="cursor-pointer hover:opacity-80 text-xs" + onClick={() => + window.open(api.url, '_blank', 'noopener,noreferrer') + } + className='cursor-pointer hover:opacity-80 text-xs' > {t('跳转')}
handleCopyUrl(api.url)} > {api.url}
-
- {api.description} -
+
{api.description}
)) ) : ( -
+
} - darkModeImage={} + darkModeImage={ + + } title={t('暂无API信息')} description={t('请联系管理员在系统设置中配置API信息')} /> @@ -114,4 +116,4 @@ const ApiInfoPanel = ({ ); }; -export default ApiInfoPanel; \ No newline at end of file +export default ApiInfoPanel; diff --git a/web/src/components/dashboard/ChartsPanel.jsx b/web/src/components/dashboard/ChartsPanel.jsx index 595e2e02..dc1684a2 100644 --- a/web/src/components/dashboard/ChartsPanel.jsx +++ b/web/src/components/dashboard/ChartsPanel.jsx @@ -23,7 +23,7 @@ import { PieChart } from 'lucide-react'; import { IconHistogram, IconPulse, - IconPieChart2Stroked + IconPieChart2Stroked, } from '@douyinfe/semi-icons'; import { VChart } from '@visactor/react-vchart'; @@ -38,80 +38,80 @@ const ChartsPanel = ({ CHART_CONFIG, FLEX_CENTER_GAP2, hasApiInfoPanel, - t + t, }) => { return ( +
{t('模型数据分析')}
- - - {t('消耗分布')} - - } itemKey="1" /> - - - {t('消耗趋势')} - - } itemKey="2" /> - - - {t('调用次数分布')} - - } itemKey="3" /> - - - {t('调用次数排行')} - - } itemKey="4" /> + + + {t('消耗分布')} + + } + itemKey='1' + /> + + + {t('消耗趋势')} + + } + itemKey='2' + /> + + + {t('调用次数分布')} + + } + itemKey='3' + /> + + + {t('调用次数排行')} + + } + itemKey='4' + />
} bodyStyle={{ padding: 0 }} > -
+
{activeChartTab === '1' && ( - + )} {activeChartTab === '2' && ( - + )} {activeChartTab === '3' && ( - + )} {activeChartTab === '4' && ( - + )}
); }; -export default ChartsPanel; \ No newline at end of file +export default ChartsPanel; diff --git a/web/src/components/dashboard/DashboardHeader.jsx b/web/src/components/dashboard/DashboardHeader.jsx index e0be5d85..c2867e90 100644 --- a/web/src/components/dashboard/DashboardHeader.jsx +++ b/web/src/components/dashboard/DashboardHeader.jsx @@ -27,19 +27,19 @@ const DashboardHeader = ({ showSearchModal, refresh, loading, - t + t, }) => { - const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full"; + const ICON_BUTTON_CLASS = 'text-white hover:bg-opacity-80 !rounded-full'; return ( -
+

{getGreeting}

-
+
); } else { const showRegisterButton = !isSelfUseMode; - const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5"; + const commonSizingAndLayoutClass = + 'flex items-center justify-center !py-[10px] !px-1.5'; - const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors"; + const loginButtonSpecificStyling = + '!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors'; let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`; let registerButtonClasses = `${commonSizingAndLayoutClass}`; - const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5"; - const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5"; + const loginButtonTextSpanClass = + '!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5'; + const registerButtonTextSpanClass = '!text-xs !text-white !p-1.5'; if (showRegisterButton) { if (isMobile) { - loginButtonClasses += " !rounded-full"; + loginButtonClasses += ' !rounded-full'; } else { - loginButtonClasses += " !rounded-l-full !rounded-r-none"; + loginButtonClasses += ' !rounded-l-full !rounded-r-none'; } - registerButtonClasses += " !rounded-r-full !rounded-l-none"; + registerButtonClasses += ' !rounded-r-full !rounded-l-none'; } else { - loginButtonClasses += " !rounded-full"; + loginButtonClasses += ' !rounded-full'; } return ( -
- +
+ {showRegisterButton && ( -
- +
+
diff --git a/web/src/components/layout/HeaderBar/index.jsx b/web/src/components/layout/HeaderBar/index.jsx index 0a0e8954..db104de4 100644 --- a/web/src/components/layout/HeaderBar/index.jsx +++ b/web/src/components/layout/HeaderBar/index.jsx @@ -63,7 +63,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const { mainNavLinks } = useNavigation(t, docsLink); return ( -
+
{ unreadKeys={getUnreadKeys()} /> -
-
-
+
+
+
{ +const NoticeModal = ({ + visible, + onClose, + isMobile, + defaultTab = 'inApp', + unreadKeys = [], +}) => { const { t } = useTranslation(); const [noticeContent, setNoticeContent] = useState(''); const [loading, setLoading] = useState(false); @@ -38,23 +54,25 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]); - const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`; + const getKeyForItem = (item) => + `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`; const processedAnnouncements = useMemo(() => { - return (announcements || []).slice(0, 20).map(item => { + return (announcements || []).slice(0, 20).map((item) => { const pubDate = item?.publishDate ? new Date(item.publishDate) : null; - const absoluteTime = pubDate && !isNaN(pubDate.getTime()) - ? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}` - : (item?.publishDate || ''); - return ({ + const absoluteTime = + pubDate && !isNaN(pubDate.getTime()) + ? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}` + : item?.publishDate || ''; + return { key: getKeyForItem(item), type: item.type || 'default', time: absoluteTime, content: item.content, extra: item.extra, relative: getRelativeTime(item.publishDate), - isUnread: unreadSet.has(getKeyForItem(item)) - }); + isUnread: unreadSet.has(getKeyForItem(item)), + }; }); }, [announcements, unreadSet]); @@ -100,15 +118,23 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK const renderMarkdownNotice = () => { if (loading) { - return
; + return ( +
+ +
+ ); } if (!noticeContent) { return ( -
+
} - darkModeImage={} + image={ + + } + darkModeImage={ + + } description={t('暂无公告')} />
@@ -118,7 +144,7 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK return (
); }; @@ -126,10 +152,14 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK const renderAnnouncementTimeline = () => { if (processedAnnouncements.length === 0) { return ( -
+
} - darkModeImage={} + image={ + + } + darkModeImage={ + + } description={t('暂无系统公告')} />
@@ -137,8 +167,8 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK } return ( -
- +
+ {processedAnnouncements.map((item, idx) => { const htmlContent = marked.parse(item.content || ''); const htmlExtra = item.extra ? marked.parse(item.extra) : ''; @@ -147,12 +177,14 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK key={idx} type={item.type} time={`${item.relative ? item.relative + ' ' : ''}${item.time}`} - extra={item.extra ? ( -
- ) : null} + extra={ + item.extra ? ( +
+ ) : null + } className={item.isUnread ? '' : ''} >
@@ -179,26 +211,40 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK return ( +
{t('系统公告')} - - {t('通知')}} itemKey='inApp' /> - {t('系统公告')}} itemKey='system' /> + + + {t('通知')} + + } + itemKey='inApp' + /> + + {t('系统公告')} + + } + itemKey='system' + />
} visible={visible} onCancel={onClose} - footer={( -
- - + footer={ +
+ +
- )} + } size={isMobile ? 'full-width' : 'large'} > {renderBody()} @@ -206,4 +252,4 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK ); }; -export default NoticeModal; \ No newline at end of file +export default NoticeModal; diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index dd508068..72df89eb 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -27,7 +27,13 @@ import React, { useContext, useEffect, useState } from 'react'; import { useIsMobile } from '../../hooks/common/useIsMobile'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed'; import { useTranslation } from 'react-i18next'; -import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers'; +import { + API, + getLogo, + getSystemName, + showError, + setStatusData, +} from '../../helpers'; import { UserContext } from '../../context/User'; import { StatusContext } from '../../context/Status'; import { useLocation } from 'react-router-dom'; @@ -42,9 +48,12 @@ const PageLayout = () => { const { i18n } = useTranslation(); const location = useLocation(); - const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing'; + const shouldHideFooter = + location.pathname.startsWith('/console') || + location.pathname === '/pricing'; - const shouldInnerPadding = location.pathname.includes('/console') && + const shouldInnerPadding = + location.pathname.includes('/console') && !location.pathname.startsWith('/console/chat') && location.pathname !== '/console/playground'; @@ -120,7 +129,10 @@ const PageLayout = () => { zIndex: 100, }} > - setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} /> + setDrawerOpen((prev) => !prev)} + drawerOpen={drawerOpen} + />
{ width: 'var(--sidebar-current-width)', }} > - { if (isMobile) setDrawerOpen(false); }} /> + { + if (isMobile) setDrawerOpen(false); + }} + /> )} { const location = useLocation(); useEffect(() => { - if (statusState?.status?.setup === false && location.pathname !== '/setup') { + if ( + statusState?.status?.setup === false && + location.pathname !== '/setup' + ) { window.location.href = '/setup'; } }, [statusState?.status?.setup, location.pathname]); @@ -34,4 +37,4 @@ const SetupCheck = ({ children }) => { return children; }; -export default SetupCheck; \ No newline at end of file +export default SetupCheck; diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx index 86c48002..44dd0a78 100644 --- a/web/src/components/layout/SiderBar.jsx +++ b/web/src/components/layout/SiderBar.jsx @@ -23,17 +23,9 @@ import { useTranslation } from 'react-i18next'; import { getLucideIcon } from '../../helpers/render'; import { ChevronLeft } from 'lucide-react'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed'; -import { - isAdmin, - isRoot, - showError -} from '../../helpers'; +import { isAdmin, isRoot, showError } from '../../helpers'; -import { - Nav, - Divider, - Button, -} from '@douyinfe/semi-ui'; +import { Nav, Divider, Button } from '@douyinfe/semi-ui'; const routerMap = { home: '/', @@ -54,7 +46,7 @@ const routerMap = { personal: '/console/personal', }; -const SiderBar = ({ onNavigate = () => { } }) => { +const SiderBar = ({ onNavigate = () => {} }) => { const { t } = useTranslation(); const [collapsed, toggleCollapsed] = useSidebarCollapsed(); @@ -275,14 +267,17 @@ const SiderBar = ({ onNavigate = () => { } }) => { key={item.itemKey} itemKey={item.itemKey} text={ -
- +
+ {item.text}
} icon={ -
+
{getLucideIcon(item.itemKey, isSelected)}
} @@ -302,14 +297,17 @@ const SiderBar = ({ onNavigate = () => { } }) => { key={item.itemKey} itemKey={item.itemKey} text={ -
- +
+ {item.text}
} icon={ -
+
{getLucideIcon(item.itemKey, isSelected)}
} @@ -323,7 +321,10 @@ const SiderBar = ({ onNavigate = () => { } }) => { key={subItem.itemKey} itemKey={subItem.itemKey} text={ - + {subItem.text} } @@ -339,18 +340,18 @@ const SiderBar = ({ onNavigate = () => { } }) => { return (
{/* 底部折叠按钮 */} -
+
diff --git a/web/src/components/playground/ChatArea.jsx b/web/src/components/playground/ChatArea.jsx index b6303112..2c65731f 100644 --- a/web/src/components/playground/ChatArea.jsx +++ b/web/src/components/playground/ChatArea.jsx @@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { - Card, - Chat, - Typography, - Button, -} from '@douyinfe/semi-ui'; -import { - MessageSquare, - Eye, - EyeOff, -} from 'lucide-react'; +import { Card, Chat, Typography, Button } from '@douyinfe/semi-ui'; +import { MessageSquare, Eye, EyeOff } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import CustomInputRender from './CustomInputRender'; @@ -57,37 +48,43 @@ const ChatArea = ({ return ( {/* 聊天头部 */} {styleState.isMobile ? ( -
+
) : ( -
-
-
-
- +
+
+
+
+
- + {t('AI 对话')} - + {inputs.model || t('选择模型开始对话')}
-
+
@@ -97,7 +94,7 @@ const ChatArea = ({ )} {/* 聊天内容区域 */} -
+
@@ -129,4 +126,4 @@ const ChatArea = ({ ); }; -export default ChatArea; \ No newline at end of file +export default ChatArea; diff --git a/web/src/components/playground/CodeViewer.jsx b/web/src/components/playground/CodeViewer.jsx index 0e0d0bf5..9d8ae453 100644 --- a/web/src/components/playground/CodeViewer.jsx +++ b/web/src/components/playground/CodeViewer.jsx @@ -102,15 +102,17 @@ const highlightJson = (str) => { color = '#569cd6'; } return `${match}`; - } + }, ); }; const isJsonLike = (content, language) => { if (language === 'json') return true; const trimmed = content.trim(); - return (trimmed.startsWith('{') && trimmed.endsWith('}')) || - (trimmed.startsWith('[') && trimmed.endsWith(']')); + return ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ); }; const formatContent = (content) => { @@ -148,7 +150,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => { const contentMetrics = useMemo(() => { const length = formattedContent.length; const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH; - const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER; + const isVeryLarge = + length > + PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * + PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER; return { length, isLarge, isVeryLarge }; }, [formattedContent.length]); @@ -156,8 +161,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => { if (!contentMetrics.isLarge || isExpanded) { return formattedContent; } - return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) + - '\n\n// ... 内容被截断以提升性能 ...'; + return ( + formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) + + '\n\n// ... 内容被截断以提升性能 ...' + ); }, [formattedContent, contentMetrics.isLarge, isExpanded]); const highlightedContent = useMemo(() => { @@ -174,9 +181,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => { const handleCopy = useCallback(async () => { try { - const textToCopy = typeof content === 'object' && content !== null - ? JSON.stringify(content, null, 2) - : content; + const textToCopy = + typeof content === 'object' && content !== null + ? JSON.stringify(content, null, 2) + : content; const success = await copy(textToCopy); setCopied(true); @@ -205,11 +213,12 @@ const CodeViewer = ({ content, title, language = 'json' }) => { }, [isExpanded, contentMetrics.isVeryLarge]); if (!content) { - const placeholderText = { - preview: t('正在构造请求体预览...'), - request: t('暂无请求数据'), - response: t('暂无响应数据') - }[title] || t('暂无数据'); + const placeholderText = + { + preview: t('正在构造请求体预览...'), + request: t('暂无请求数据'), + response: t('暂无响应数据'), + }[title] || t('暂无数据'); return (
@@ -222,7 +231,7 @@ const CodeViewer = ({ content, title, language = 'json' }) => { const contentPadding = contentMetrics.isLarge ? '52px' : '16px'; return ( -
+
{/* 性能警告 */} {contentMetrics.isLarge && (
@@ -250,8 +259,8 @@ const CodeViewer = ({ content, title, language = 'json' }) => { @@ -329,4 +354,4 @@ const CodeViewer = ({ content, title, language = 'json' }) => { ); }; -export default CodeViewer; \ No newline at end of file +export default CodeViewer; diff --git a/web/src/components/playground/ConfigManager.jsx b/web/src/components/playground/ConfigManager.jsx index 753d1138..7eaa35b8 100644 --- a/web/src/components/playground/ConfigManager.jsx +++ b/web/src/components/playground/ConfigManager.jsx @@ -18,21 +18,16 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useRef } from 'react'; -import { - Button, - Typography, - Toast, - Modal, - Dropdown, -} from '@douyinfe/semi-ui'; -import { - Download, - Upload, - RotateCcw, - Settings2, -} from 'lucide-react'; +import { Button, Typography, Toast, Modal, Dropdown } from '@douyinfe/semi-ui'; +import { Download, Upload, RotateCcw, Settings2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage'; +import { + exportConfig, + importConfig, + clearConfig, + hasStoredConfig, + getConfigTimestamp, +} from './configStorage'; const ConfigManager = ({ currentConfig, @@ -51,7 +46,10 @@ const ConfigManager = ({ ...currentConfig, timestamp: new Date().toISOString(), }; - localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp)); + localStorage.setItem( + 'playground_config', + JSON.stringify(configWithTimestamp), + ); exportConfig(currentConfig, messages); Toast.success({ @@ -104,7 +102,9 @@ const ConfigManager = ({ const handleReset = () => { Modal.confirm({ title: t('重置配置'), - content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'), + content: t( + '将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?', + ), okText: t('确定重置'), cancelText: t('取消'), okButtonProps: { @@ -114,7 +114,9 @@ const ConfigManager = ({ // 询问是否同时重置消息 Modal.confirm({ title: t('重置选项'), - content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'), + content: t( + '是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。', + ), okText: t('同时重置消息'), cancelText: t('仅重置配置'), okButtonProps: { @@ -159,7 +161,7 @@ const ConfigManager = ({ name: 'export', onClick: handleExport, children: ( -
+
{t('导出配置')}
@@ -170,7 +172,7 @@ const ConfigManager = ({ name: 'import', onClick: handleImportClick, children: ( -
+
{t('导入配置')}
@@ -184,7 +186,7 @@ const ConfigManager = ({ name: 'reset', onClick: handleReset, children: ( -
+
{t('重置配置')}
@@ -197,24 +199,24 @@ const ConfigManager = ({ return ( <>
{/* 导出和导入按钮 */} -
+
@@ -267,8 +269,8 @@ const ConfigManager = ({ @@ -276,4 +278,4 @@ const ConfigManager = ({ ); }; -export default ConfigManager; \ No newline at end of file +export default ConfigManager; diff --git a/web/src/components/playground/CustomInputRender.jsx b/web/src/components/playground/CustomInputRender.jsx index 2191cb16..464cfa3b 100644 --- a/web/src/components/playground/CustomInputRender.jsx +++ b/web/src/components/playground/CustomInputRender.jsx @@ -21,23 +21,24 @@ import React from 'react'; const CustomInputRender = (props) => { const { detailProps } = props; - const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps; + const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = + detailProps; // 清空按钮 const styledClearNode = clearContextNode ? React.cloneElement(clearContextNode, { - className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`, - style: { - ...clearContextNode.props.style, - width: '32px', - height: '32px', - minWidth: '32px', - padding: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - } - }) + className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`, + style: { + ...clearContextNode.props.style, + width: '32px', + height: '32px', + minWidth: '32px', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + }) : null; // 发送按钮 @@ -52,21 +53,19 @@ const CustomInputRender = (props) => { display: 'flex', alignItems: 'center', justifyContent: 'center', - } + }, }); return ( -
+
{/* 清空对话按钮 - 左边 */} {styledClearNode} -
- {inputNode} -
+
{inputNode}
{/* 发送按钮 - 右边 */} {styledSendNode}
@@ -74,4 +73,4 @@ const CustomInputRender = (props) => { ); }; -export default CustomInputRender; \ No newline at end of file +export default CustomInputRender; diff --git a/web/src/components/playground/CustomRequestEditor.jsx b/web/src/components/playground/CustomRequestEditor.jsx index e411d9e7..26b3ff50 100644 --- a/web/src/components/playground/CustomRequestEditor.jsx +++ b/web/src/components/playground/CustomRequestEditor.jsx @@ -25,13 +25,7 @@ import { Switch, Banner, } from '@douyinfe/semi-ui'; -import { - Code, - Edit, - Check, - X, - AlertTriangle, -} from 'lucide-react'; +import { Code, Edit, Check, X, AlertTriangle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; const CustomRequestEditor = ({ @@ -48,12 +42,22 @@ const CustomRequestEditor = ({ // 当切换到自定义模式时,用默认payload初始化 useEffect(() => { - if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) { - const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : ''; + if ( + customRequestMode && + (!customRequestBody || customRequestBody.trim() === '') + ) { + const defaultJson = defaultPayload + ? JSON.stringify(defaultPayload, null, 2) + : ''; setLocalValue(defaultJson); onCustomRequestBodyChange(defaultJson); } - }, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]); + }, [ + customRequestMode, + defaultPayload, + customRequestBody, + onCustomRequestBodyChange, + ]); // 同步外部传入的customRequestBody到本地状态 useEffect(() => { @@ -113,21 +117,21 @@ const CustomRequestEditor = ({ }; return ( -
+
{/* 自定义模式开关 */} -
-
- - +
+
+ + 自定义请求体模式
@@ -135,43 +139,43 @@ const CustomRequestEditor = ({ <> {/* 提示信息 */} } - className="!rounded-lg" + className='!rounded-lg' closeIcon={null} /> {/* JSON编辑器 */}
-
- +
+ 请求体 JSON -
+
{isValid ? ( -
+
- + 格式正确
) : ( -
+
- + 格式错误
)} @@ -191,12 +195,12 @@ const CustomRequestEditor = ({ /> {!isValid && errorMessage && ( - + {errorMessage} )} - + 请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。
@@ -206,4 +210,4 @@ const CustomRequestEditor = ({ ); }; -export default CustomRequestEditor; \ No newline at end of file +export default CustomRequestEditor; diff --git a/web/src/components/playground/DebugPanel.jsx b/web/src/components/playground/DebugPanel.jsx index 24158c2b..d931ff61 100644 --- a/web/src/components/playground/DebugPanel.jsx +++ b/web/src/components/playground/DebugPanel.jsx @@ -26,14 +26,7 @@ import { Button, Dropdown, } from '@douyinfe/semi-ui'; -import { - Code, - Zap, - Clock, - X, - Eye, - Send, -} from 'lucide-react'; +import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import CodeViewer from './CodeViewer'; @@ -76,7 +69,7 @@ const DebugPanel = ({ - {items.map(item => { + {items.map((item) => { return ( -
-
-
- +
+
+
+
- + {t('调试信息')}
@@ -127,75 +120,84 @@ const DebugPanel = ({
-
+
- - - {t('预览请求体')} - {customRequestMode && ( - - 自定义 - - )} -
- } itemKey="preview"> + + + {t('预览请求体')} + {customRequestMode && ( + + 自定义 + + )} +
+ } + itemKey='preview' + > - - - {t('实际请求体')} -
- } itemKey="request"> + + + {t('实际请求体')} +
+ } + itemKey='request' + > - - - {t('响应')} -
- } itemKey="response"> + + + {t('响应')} +
+ } + itemKey='response' + >
-
+
{(debugData.timestamp || debugData.previewTimestamp) && ( -
- - +
+ + {activeKey === 'preview' && debugData.previewTimestamp ? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}` : debugData.timestamp @@ -209,4 +211,4 @@ const DebugPanel = ({ ); }; -export default DebugPanel; \ No newline at end of file +export default DebugPanel; diff --git a/web/src/components/playground/FloatingButtons.jsx b/web/src/components/playground/FloatingButtons.jsx index 87a3b0b5..3d024df4 100644 --- a/web/src/components/playground/FloatingButtons.jsx +++ b/web/src/components/playground/FloatingButtons.jsx @@ -19,11 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Button } from '@douyinfe/semi-ui'; -import { - Settings, - Eye, - EyeOff, -} from 'lucide-react'; +import { Settings, Eye, EyeOff } from 'lucide-react'; const FloatingButtons = ({ styleState, @@ -55,7 +51,7 @@ const FloatingButtons = ({ onClick={onToggleSettings} theme='solid' type='primary' - className="lg:hidden" + className='lg:hidden' /> )} @@ -64,8 +60,8 @@ const FloatingButtons = ({
{!imageEnabled ? ( - - {disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'} + + {disabled + ? '图片功能在自定义请求体模式下不可用' + : '启用后可添加图片URL进行多模态对话'} ) : imageUrls.length === 0 ? ( - - {disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'} + + {disabled + ? '图片功能在自定义请求体模式下不可用' + : '点击 + 按钮添加图片URL进行多模态对话'} ) : ( - - 已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''} + + 已添加 {imageUrls.length} 张图片 + {disabled ? ' (自定义模式下不可用)' : ''} )} -
+
{imageUrls.map((url, index) => ( -
-
+
+
handleUpdateImageUrl(index, value)} - className="!rounded-lg" - size="small" + className='!rounded-lg' + size='small' prefix={} disabled={!imageEnabled || disabled} />
@@ -129,4 +137,4 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab ); }; -export default ImageUrlInput; \ No newline at end of file +export default ImageUrlInput; diff --git a/web/src/components/playground/MessageActions.jsx b/web/src/components/playground/MessageActions.jsx index 64775ae5..09370036 100644 --- a/web/src/components/playground/MessageActions.jsx +++ b/web/src/components/playground/MessageActions.jsx @@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { - Button, - Tooltip, -} from '@douyinfe/semi-ui'; -import { - RefreshCw, - Copy, - Trash2, - UserCheck, - Edit, -} from 'lucide-react'; +import { Button, Tooltip } from '@douyinfe/semi-ui'; +import { RefreshCw, Copy, Trash2, UserCheck, Edit } from 'lucide-react'; import { useTranslation } from 'react-i18next'; const MessageActions = ({ @@ -40,23 +31,32 @@ const MessageActions = ({ onRoleToggle, onMessageEdit, isAnyMessageGenerating = false, - isEditing = false + isEditing = false, }) => { const { t } = useTranslation(); - const isLoading = message.status === 'loading' || message.status === 'incomplete'; + const isLoading = + message.status === 'loading' || message.status === 'incomplete'; const shouldDisableActions = isAnyMessageGenerating || isEditing; - const canToggleRole = message.role === 'assistant' || message.role === 'system'; - const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing; + const canToggleRole = + message.role === 'assistant' || message.role === 'system'; + const canEdit = + !isLoading && + message.content && + typeof onMessageEdit === 'function' && + !isEditing; return ( -
+
{!isLoading && ( - +