From 1f111a163abc0828cdd712139251fc5070cc46a7 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 1 Sep 2025 23:43:39 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ratio-sync,=20ui):=20add=20bui?= =?UTF-8?q?lt=E2=80=91in=20=E2=80=9COfficial=20Ratio=20Preset=E2=80=9D=20a?= =?UTF-8?q?nd=20harden=20upstream=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (controller/ratio_sync.go): - Add built‑in official upstream to GetSyncableChannels (ID: -100, BaseURL: https://basellm.github.io) - Support absolute endpoint URLs; otherwise join BaseURL + endpoint (defaults to /api/ratio_config) - Harden HTTP client: - IPv4‑first with IPv6 fallback for github.io - Add ResponseHeaderTimeout - 3 attempts with exponential backoff (200/400/800ms) - Validate Content-Type and limit response body to 10MB (safe decode via io.LimitReader) - Robust parsing: support type1 ratio_config map and type2 pricing list - Use net.SplitHostPort for host parsing - Use float tolerance in differences comparison to avoid false mismatches - Remove unused code (tryDirect) and improve warnings Frontend: - UpstreamRatioSync.jsx: auto-assign official endpoint to /llm-metadata/api/newapi/ratio_config-v1-base.json - ChannelSelectorModal.jsx: - Pin the official source at the top of the list - Show a green “官方” tag next to the status - Refactor status renderer to accept the full record Notes: - Backward compatible; no API surface changes - Official ratio_config reference: https://basellm.github.io/llm-metadata/api/newapi/ratio_config-v1-base.json --- controller/ratio_sync.go | 91 ++++- web/src/App.jsx | 5 +- .../layout/HeaderBar/Navigation.jsx | 8 +- web/src/components/layout/SiderBar.jsx | 284 ++++++++-------- .../settings/ChannelSelectorModal.jsx | 70 ++-- .../components/settings/PersonalSetting.jsx | 4 - .../personal/cards/NotificationSettings.jsx | 317 +++++++++++------- .../table/channels/ChannelsColumnDefs.jsx | 13 +- web/src/hooks/common/useHeaderBar.js | 2 +- web/src/hooks/common/useNavigation.js | 115 ++++--- web/src/hooks/common/useSidebar.js | 51 +-- web/src/hooks/common/useUserPermissions.js | 39 ++- .../Operation/SettingsHeaderNavModules.jsx | 204 ++++++----- .../Operation/SettingsSidebarModulesAdmin.jsx | 242 ++++++++----- .../Personal/SettingsSidebarModulesUser.jsx | 251 ++++++++------ .../Setting/Ratio/ModelRationNotSetEditor.jsx | 4 +- .../Ratio/ModelSettingsVisualEditor.jsx | 4 +- .../pages/Setting/Ratio/UpstreamRatioSync.jsx | 13 +- 18 files changed, 1023 insertions(+), 694 deletions(-) diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index 6fba0aac3..7a481c476 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "net" "net/http" "one-api/logger" "strings" @@ -21,8 +23,26 @@ const ( defaultTimeoutSeconds = 10 defaultEndpoint = "/api/ratio_config" maxConcurrentFetches = 8 + maxRatioConfigBytes = 10 << 20 // 10MB + floatEpsilon = 1e-9 ) +func nearlyEqual(a, b float64) bool { + if a > b { + return a-b < floatEpsilon + } + return b-a < floatEpsilon +} + +func valuesEqual(a, b interface{}) bool { + af, aok := a.(float64) + bf, bok := b.(float64) + if aok && bok { + return nearlyEqual(af, bf) + } + return a == b +} + var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} type upstreamResult struct { @@ -87,7 +107,23 @@ func FetchUpstreamRatios(c *gin.Context) { sem := make(chan struct{}, maxConcurrentFetches) - client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}} + dialer := &net.Dialer{Timeout: 10 * time.Second} + transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second} + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + // 对 github.io 优先尝试 IPv4,失败则回退 IPv6 + if strings.HasSuffix(host, "github.io") { + if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { + return conn, nil + } + return dialer.DialContext(ctx, "tcp6", addr) + } + return dialer.DialContext(ctx, network, addr) + } + client := &http.Client{Transport: transport} for _, chn := range upstreams { wg.Add(1) @@ -98,12 +134,17 @@ func FetchUpstreamRatios(c *gin.Context) { defer func() { <-sem }() endpoint := chItem.Endpoint - if endpoint == "" { - endpoint = defaultEndpoint - } else if !strings.HasPrefix(endpoint, "/") { - endpoint = "/" + endpoint + var fullURL string + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + fullURL = endpoint + } else { + if endpoint == "" { + endpoint = defaultEndpoint + } else if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + fullURL = chItem.BaseURL + endpoint } - fullURL := chItem.BaseURL + endpoint uniqueName := chItem.Name if chItem.ID != 0 { @@ -120,10 +161,19 @@ func FetchUpstreamRatios(c *gin.Context) { return } - resp, err := client.Do(httpReq) - if err != nil { - logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) - ch <- upstreamResult{Name: uniqueName, Err: err.Error()} + // 简单重试:最多 3 次,指数退避 + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + resp, lastErr = client.Do(httpReq) + if lastErr == nil { + break + } + time.Sleep(time.Duration(200*(1< data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price // type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式 @@ -141,7 +197,7 @@ func FetchUpstreamRatios(c *gin.Context) { Message string `json:"message"` } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + if err := json.NewDecoder(limited).Decode(&body); err != nil { logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) ch <- upstreamResult{Name: uniqueName, Err: err.Error()} return @@ -152,6 +208,8 @@ func FetchUpstreamRatios(c *gin.Context) { return } + // 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容) + // 尝试按 type1 解析 var type1Data map[string]any if err := json.Unmarshal(body.Data, &type1Data); err == nil { @@ -357,9 +415,9 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { upstreamValue = val hasUpstreamValue = true - if localValue != nil && localValue != val { + if localValue != nil && !valuesEqual(localValue, val) { hasDifference = true - } else if localValue == val { + } else if valuesEqual(localValue, val) { upstreamValue = "same" } } @@ -466,6 +524,13 @@ func GetSyncableChannels(c *gin.Context) { } } + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: -100, + Name: "官方倍率预设", + BaseURL: "https://basellm.github.io", + Status: 1, + }) + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", diff --git a/web/src/App.jsx b/web/src/App.jsx index cb9245244..635742f91 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -279,7 +279,10 @@ function App() { element={ pricingRequireAuth ? ( - } key={location.pathname}> + } + key={location.pathname} + > diff --git a/web/src/components/layout/HeaderBar/Navigation.jsx b/web/src/components/layout/HeaderBar/Navigation.jsx index b15df662f..3a5e3a3bd 100644 --- a/web/src/components/layout/HeaderBar/Navigation.jsx +++ b/web/src/components/layout/HeaderBar/Navigation.jsx @@ -21,7 +21,13 @@ import React from 'react'; import { Link } from 'react-router-dom'; import SkeletonWrapper from './SkeletonWrapper'; -const Navigation = ({ mainNavLinks, isMobile, isLoading, userState, pricingRequireAuth }) => { +const Navigation = ({ + mainNavLinks, + isMobile, + isLoading, + userState, + pricingRequireAuth, +}) => { const renderNavLinks = () => { const baseClasses = 'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out'; diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx index 7f61a2411..37e55d76c 100644 --- a/web/src/components/layout/SiderBar.jsx +++ b/web/src/components/layout/SiderBar.jsx @@ -50,7 +50,11 @@ const routerMap = { const SiderBar = ({ onNavigate = () => {} }) => { const { t } = useTranslation(); const [collapsed, toggleCollapsed] = useSidebarCollapsed(); - const { isModuleVisible, hasSectionVisibleModules, loading: sidebarLoading } = useSidebar(); + const { + isModuleVisible, + hasSectionVisibleModules, + loading: sidebarLoading, + } = useSidebar(); const [selectedKeys, setSelectedKeys] = useState(['home']); const [chatItems, setChatItems] = useState([]); @@ -58,160 +62,148 @@ const SiderBar = ({ onNavigate = () => {} }) => { const location = useLocation(); const [routerMapState, setRouterMapState] = useState(routerMap); - const workspaceItems = useMemo( - () => { - const items = [ - { - text: t('数据看板'), - itemKey: 'detail', - to: '/detail', - className: - localStorage.getItem('enable_data_export') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('令牌管理'), - itemKey: 'token', - to: '/token', - }, - { - text: t('使用日志'), - itemKey: 'log', - to: '/log', - }, - { - text: t('绘图日志'), - itemKey: 'midjourney', - to: '/midjourney', - className: - localStorage.getItem('enable_drawing') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('任务日志'), - itemKey: 'task', - to: '/task', - className: - localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', - }, - ]; + const workspaceItems = useMemo(() => { + const items = [ + { + text: t('数据看板'), + itemKey: 'detail', + to: '/detail', + className: + localStorage.getItem('enable_data_export') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('令牌管理'), + itemKey: 'token', + to: '/token', + }, + { + text: t('使用日志'), + itemKey: 'log', + to: '/log', + }, + { + text: t('绘图日志'), + itemKey: 'midjourney', + to: '/midjourney', + className: + localStorage.getItem('enable_drawing') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('任务日志'), + itemKey: 'task', + to: '/task', + className: + localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', + }, + ]; - // 根据配置过滤项目 - const filteredItems = items.filter(item => { - const configVisible = isModuleVisible('console', item.itemKey); - return configVisible; - }); + // 根据配置过滤项目 + const filteredItems = items.filter((item) => { + const configVisible = isModuleVisible('console', item.itemKey); + return configVisible; + }); - return filteredItems; - }, - [ - localStorage.getItem('enable_data_export'), - localStorage.getItem('enable_drawing'), - localStorage.getItem('enable_task'), - t, - isModuleVisible, - ], - ); + return filteredItems; + }, [ + localStorage.getItem('enable_data_export'), + localStorage.getItem('enable_drawing'), + localStorage.getItem('enable_task'), + t, + isModuleVisible, + ]); - const financeItems = useMemo( - () => { - const items = [ - { - text: t('钱包管理'), - itemKey: 'topup', - to: '/topup', - }, - { - text: t('个人设置'), - itemKey: 'personal', - to: '/personal', - }, - ]; + const financeItems = useMemo(() => { + const items = [ + { + text: t('钱包管理'), + itemKey: 'topup', + to: '/topup', + }, + { + text: t('个人设置'), + itemKey: 'personal', + to: '/personal', + }, + ]; - // 根据配置过滤项目 - const filteredItems = items.filter(item => { - const configVisible = isModuleVisible('personal', item.itemKey); - return configVisible; - }); + // 根据配置过滤项目 + const filteredItems = items.filter((item) => { + const configVisible = isModuleVisible('personal', item.itemKey); + return configVisible; + }); - return filteredItems; - }, - [t, isModuleVisible], - ); + return filteredItems; + }, [t, isModuleVisible]); - const adminItems = useMemo( - () => { - const items = [ - { - text: t('渠道管理'), - itemKey: 'channel', - to: '/channel', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('模型管理'), - itemKey: 'models', - to: '/console/models', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('兑换码管理'), - itemKey: 'redemption', - to: '/redemption', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('用户管理'), - itemKey: 'user', - to: '/user', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('系统设置'), - itemKey: 'setting', - to: '/setting', - className: isRoot() ? '' : 'tableHiddle', - }, - ]; + const adminItems = useMemo(() => { + const items = [ + { + text: t('渠道管理'), + itemKey: 'channel', + to: '/channel', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('模型管理'), + itemKey: 'models', + to: '/console/models', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('兑换码管理'), + itemKey: 'redemption', + to: '/redemption', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('用户管理'), + itemKey: 'user', + to: '/user', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('系统设置'), + itemKey: 'setting', + to: '/setting', + className: isRoot() ? '' : 'tableHiddle', + }, + ]; - // 根据配置过滤项目 - const filteredItems = items.filter(item => { - const configVisible = isModuleVisible('admin', item.itemKey); - return configVisible; - }); + // 根据配置过滤项目 + const filteredItems = items.filter((item) => { + const configVisible = isModuleVisible('admin', item.itemKey); + return configVisible; + }); - return filteredItems; - }, - [isAdmin(), isRoot(), t, isModuleVisible], - ); + return filteredItems; + }, [isAdmin(), isRoot(), t, isModuleVisible]); - const chatMenuItems = useMemo( - () => { - const items = [ - { - text: t('操练场'), - itemKey: 'playground', - to: '/playground', - }, - { - text: t('聊天'), - itemKey: 'chat', - items: chatItems, - }, - ]; + const chatMenuItems = useMemo(() => { + const items = [ + { + text: t('操练场'), + itemKey: 'playground', + to: '/playground', + }, + { + text: t('聊天'), + itemKey: 'chat', + items: chatItems, + }, + ]; - // 根据配置过滤项目 - const filteredItems = items.filter(item => { - const configVisible = isModuleVisible('chat', item.itemKey); - return configVisible; - }); + // 根据配置过滤项目 + const filteredItems = items.filter((item) => { + const configVisible = isModuleVisible('chat', item.itemKey); + return configVisible; + }); - return filteredItems; - }, - [chatItems, t, isModuleVisible], - ); + return filteredItems; + }, [chatItems, t, isModuleVisible]); // 更新路由映射,添加聊天路由 const updateRouterMapWithChats = (chats) => { @@ -426,7 +418,9 @@ const SiderBar = ({ onNavigate = () => {} }) => { {/* 聊天区域 */} {hasSectionVisibleModules('chat') && (
- {!collapsed &&
{t('聊天')}
} + {!collapsed && ( +
{t('聊天')}
+ )} {chatMenuItems.map((item) => renderSubItem(item))}
)} diff --git a/web/src/components/settings/ChannelSelectorModal.jsx b/web/src/components/settings/ChannelSelectorModal.jsx index b151151e6..757b0e2f3 100644 --- a/web/src/components/settings/ChannelSelectorModal.jsx +++ b/web/src/components/settings/ChannelSelectorModal.jsx @@ -34,7 +34,6 @@ import { Tag, } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; -import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react'; const ChannelSelectorModal = forwardRef( ( @@ -65,6 +64,18 @@ const ChannelSelectorModal = forwardRef( }, })); + // 官方渠道识别 + const isOfficialChannel = (record) => { + const id = record?.key ?? record?.value ?? record?._originalData?.id; + const base = record?._originalData?.base_url || ''; + const name = record?.label || ''; + return ( + id === -100 || + base === 'https://basellm.github.io' || + name === '官方倍率预设' + ); + }; + useEffect(() => { if (!allChannels) return; @@ -77,7 +88,13 @@ const ChannelSelectorModal = forwardRef( }) : allChannels; - setFilteredData(matched); + const sorted = [...matched].sort((a, b) => { + const wa = isOfficialChannel(a) ? 0 : 1; + const wb = isOfficialChannel(b) ? 0 : 1; + return wa - wb; + }); + + setFilteredData(sorted); }, [allChannels, searchText]); const total = filteredData.length; @@ -143,45 +160,49 @@ const ChannelSelectorModal = forwardRef( ); }; - const renderStatusCell = (status) => { + const renderStatusCell = (record) => { + const status = record?._originalData?.status || 0; + const official = isOfficialChannel(record); + let statusTag = null; switch (status) { case 1: - return ( - } - > + statusTag = ( + {t('已启用')} ); + break; case 2: - return ( - }> + statusTag = ( + {t('已禁用')} ); + break; case 3: - return ( - } - > + statusTag = ( + {t('自动禁用')} ); + break; default: - return ( - } - > + statusTag = ( + {t('未知状态')} ); } + return ( +
+ {statusTag} + {official && ( + + {t('官方')} + + )} +
+ ); }; const renderNameCell = (text) => ( @@ -207,8 +228,7 @@ const ChannelSelectorModal = forwardRef( { title: t('状态'), dataIndex: '_originalData.status', - render: (_, record) => - renderStatusCell(record._originalData?.status || 0), + render: (_, record) => renderStatusCell(record), }, { title: t('同步接口'), diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 422cf0e88..3ba8dcfd3 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -38,8 +38,6 @@ const PersonalSetting = () => { let navigate = useNavigate(); const { t } = useTranslation(); - - const [inputs, setInputs] = useState({ wechat_verification_code: '', email_verification_code: '', @@ -335,8 +333,6 @@ const PersonalSetting = () => { saveNotificationSettings={saveNotificationSettings} /> - - diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index c7a31bd52..0b097eaff 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -34,7 +34,12 @@ import { } from '@douyinfe/semi-ui'; import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons'; import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react'; -import { renderQuotaWithPrompt, API, showSuccess, showError } from '../../../../helpers'; +import { + renderQuotaWithPrompt, + API, + showSuccess, + showError, +} from '../../../../helpers'; import CodeViewer from '../../../playground/CodeViewer'; import { StatusContext } from '../../../../context/Status'; import { UserContext } from '../../../../context/User'; @@ -57,7 +62,7 @@ const NotificationSettings = ({ chat: { enabled: true, playground: true, - chat: true + chat: true, }, console: { enabled: true, @@ -65,12 +70,12 @@ const NotificationSettings = ({ token: true, log: true, midjourney: true, - task: true + task: true, }, personal: { enabled: true, topup: true, - personal: true + personal: true, }, admin: { enabled: true, @@ -78,8 +83,8 @@ const NotificationSettings = ({ models: true, redemption: true, user: true, - setting: true - } + setting: true, + }, }); const [adminConfig, setAdminConfig] = useState(null); @@ -99,8 +104,8 @@ const NotificationSettings = ({ ...sidebarModulesUser, [sectionKey]: { ...sidebarModulesUser[sectionKey], - enabled: checked - } + enabled: checked, + }, }; setSidebarModulesUser(newModules); }; @@ -112,8 +117,8 @@ const NotificationSettings = ({ ...sidebarModulesUser, [sectionKey]: { ...sidebarModulesUser[sectionKey], - [moduleKey]: checked - } + [moduleKey]: checked, + }, }; setSidebarModulesUser(newModules); }; @@ -123,7 +128,7 @@ const NotificationSettings = ({ setSidebarLoading(true); try { const res = await API.put('/api/user/self', { - sidebar_modules: JSON.stringify(sidebarModulesUser) + sidebar_modules: JSON.stringify(sidebarModulesUser), }); if (res.data.success) { showSuccess(t('侧边栏设置保存成功')); @@ -139,9 +144,23 @@ const NotificationSettings = ({ const resetSidebarModules = () => { const defaultConfig = { chat: { enabled: true, playground: true, chat: true }, - console: { enabled: true, detail: true, token: true, log: true, midjourney: true, task: true }, + console: { + enabled: true, + detail: true, + token: true, + log: true, + midjourney: true, + task: true, + }, personal: { enabled: true, topup: true, personal: true }, - admin: { enabled: true, channel: true, models: true, redemption: true, user: true, setting: true } + admin: { + enabled: true, + channel: true, + models: true, + redemption: true, + user: true, + setting: true, + }, }; setSidebarModulesUser(defaultConfig); }; @@ -187,7 +206,9 @@ const NotificationSettings = ({ if (!adminConfig) return true; if (moduleKey) { - return adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]; + return ( + adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey] + ); } else { return adminConfig[sectionKey]?.enabled; } @@ -200,9 +221,13 @@ const NotificationSettings = ({ title: t('聊天区域'), description: t('操练场和聊天功能'), modules: [ - { key: 'playground', title: t('操练场'), description: t('AI模型测试环境') }, - { key: 'chat', title: t('聊天'), description: t('聊天会话管理') } - ] + { + key: 'playground', + title: t('操练场'), + description: t('AI模型测试环境'), + }, + { key: 'chat', title: t('聊天'), description: t('聊天会话管理') }, + ], }, { key: 'console', @@ -212,9 +237,13 @@ const NotificationSettings = ({ { key: 'detail', title: t('数据看板'), description: t('系统数据统计') }, { key: 'token', title: t('令牌管理'), description: t('API令牌管理') }, { key: 'log', title: t('使用日志'), description: t('API使用记录') }, - { key: 'midjourney', title: t('绘图日志'), description: t('绘图任务记录') }, - { key: 'task', title: t('任务日志'), description: t('系统任务记录') } - ] + { + key: 'midjourney', + title: t('绘图日志'), + description: t('绘图任务记录'), + }, + { key: 'task', title: t('任务日志'), description: t('系统任务记录') }, + ], }, { key: 'personal', @@ -222,8 +251,12 @@ const NotificationSettings = ({ description: t('用户个人功能'), modules: [ { key: 'topup', title: t('钱包管理'), description: t('余额充值管理') }, - { key: 'personal', title: t('个人设置'), description: t('个人信息设置') } - ] + { + key: 'personal', + title: t('个人设置'), + description: t('个人信息设置'), + }, + ], }, // 管理员区域:根据后端权限控制显示 { @@ -233,23 +266,35 @@ const NotificationSettings = ({ modules: [ { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') }, { key: 'models', title: t('模型管理'), description: t('AI模型配置') }, - { key: 'redemption', title: t('兑换码管理'), description: t('兑换码生成管理') }, + { + key: 'redemption', + title: t('兑换码管理'), + description: t('兑换码生成管理'), + }, { key: 'user', title: t('用户管理'), description: t('用户账户管理') }, - { key: 'setting', title: t('系统设置'), description: t('系统参数配置') } - ] - } - ].filter(section => { - // 使用后端权限验证替代前端角色判断 - return isSidebarSectionAllowed(section.key); - }).map(section => ({ - ...section, - modules: section.modules.filter(module => - isSidebarModuleAllowed(section.key, module.key) - ) - })).filter(section => - // 过滤掉没有可用模块的区域 - section.modules.length > 0 && isAllowedByAdmin(section.key) - ); + { + key: 'setting', + title: t('系统设置'), + description: t('系统参数配置'), + }, + ], + }, + ] + .filter((section) => { + // 使用后端权限验证替代前端角色判断 + return isSidebarSectionAllowed(section.key); + }) + .map((section) => ({ + ...section, + modules: section.modules.filter((module) => + isSidebarModuleAllowed(section.key, module.key), + ), + })) + .filter( + (section) => + // 过滤掉没有可用模块的区域 + section.modules.length > 0 && isAllowedByAdmin(section.key), + ); // 表单提交 const handleSubmit = () => { @@ -491,7 +536,9 @@ const NotificationSettings = ({ handleFormChange('barkUrl', val)} prefix={} extraText={t( @@ -500,8 +547,7 @@ const NotificationSettings = ({ showClear rules={[ { - required: - notificationSettings.warningType === 'bark', + required: notificationSettings.warningType === 'bark', message: t('请输入Bark推送URL'), }, { @@ -516,16 +562,23 @@ const NotificationSettings = ({ {t('模板示例')}
- https://api.day.app/yourkey/{'{{title}}'}/{'{{content}}'}?sound=alarm&group=quota + https://api.day.app/yourkey/{'{{title}}'}/ + {'{{content}}'}?sound=alarm&group=quota
-
{'title'}: {t('通知标题')}
-
{'content'}: {t('通知内容')}
+
+ • {'title'}: {t('通知标题')} +
+
+ • {'content'}: {t('通知内容')} +
- {t('更多参数请参考')}{' '} - + {t('更多参数请参考')} + {' '} + @@ -603,27 +656,25 @@ const NotificationSettings = ({
{t('您可以个性化设置侧边栏的要显示功能')}
- {/* 边栏设置功能区域容器 */}
- {sectionConfigs.map((section) => (
{/* 区域标题和总开关 */} @@ -632,80 +683,102 @@ const NotificationSettings = ({ style={{ backgroundColor: 'var(--semi-color-fill-0)', border: '1px solid var(--semi-color-border-light)', - borderColor: 'var(--semi-color-fill-1)' + borderColor: 'var(--semi-color-fill-1)', }} > -
-
- {section.title} -
- - {section.description} - -
- -
- - {/* 功能模块网格 */} - - {section.modules - .filter(module => isAllowedByAdmin(section.key, module.key)) - .map((module) => ( - - +
+ {section.title} +
+ -
-
-
- {module.title} + {section.description} + +
+ +
+ + {/* 功能模块网格 */} + + {section.modules + .filter((module) => + isAllowedByAdmin(section.key, module.key), + ) + .map((module) => ( + + +
+
+
+ {module.title} +
+ + {module.description} + +
+
+ +
- - {module.description} - -
-
- -
-
- - - ))} - + + + ))} +
))} -
{/* 关闭边栏设置功能区域容器 */} +
{' '} + {/* 关闭边栏设置功能区域容器 */} )} diff --git a/web/src/components/table/channels/ChannelsColumnDefs.jsx b/web/src/components/table/channels/ChannelsColumnDefs.jsx index 56f745c20..5b505baed 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/web/src/components/table/channels/ChannelsColumnDefs.jsx @@ -231,11 +231,14 @@ export const getChannelsColumns = ({ theme='outline' onClick={(e) => { e.stopPropagation(); - navigator.clipboard.writeText(record.remark).then(() => { - showSuccess(t('复制成功')); - }).catch(() => { - showError(t('复制失败')); - }); + navigator.clipboard + .writeText(record.remark) + .then(() => { + showSuccess(t('复制成功')); + }) + .catch(() => { + showError(t('复制失败')); + }); }} > {t('复制')} diff --git a/web/src/hooks/common/useHeaderBar.js b/web/src/hooks/common/useHeaderBar.js index 9f95a9b9a..3458a1d16 100644 --- a/web/src/hooks/common/useHeaderBar.js +++ b/web/src/hooks/common/useHeaderBar.js @@ -64,7 +64,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { if (typeof modules.pricing === 'boolean') { modules.pricing = { enabled: modules.pricing, - requireAuth: false // 默认不需要登录鉴权 + requireAuth: false, // 默认不需要登录鉴权 }; } diff --git a/web/src/hooks/common/useNavigation.js b/web/src/hooks/common/useNavigation.js index 43d9024ea..f7e61a203 100644 --- a/web/src/hooks/common/useNavigation.js +++ b/web/src/hooks/common/useNavigation.js @@ -20,67 +20,66 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react'; export const useNavigation = (t, docsLink, headerNavModules) => { - const mainNavLinks = useMemo( - () => { - // 默认配置,如果没有传入配置则显示所有模块 - const defaultModules = { - home: true, - console: true, - pricing: true, - docs: true, - about: true, - }; + const mainNavLinks = useMemo(() => { + // 默认配置,如果没有传入配置则显示所有模块 + const defaultModules = { + home: true, + console: true, + pricing: true, + docs: true, + about: true, + }; - // 使用传入的配置或默认配置 - const modules = headerNavModules || defaultModules; + // 使用传入的配置或默认配置 + const modules = headerNavModules || defaultModules; - const allLinks = [ - { - text: t('首页'), - itemKey: 'home', - to: '/', - }, - { - text: t('控制台'), - itemKey: 'console', - to: '/console', - }, - { - text: t('模型广场'), - itemKey: 'pricing', - to: '/pricing', - }, - ...(docsLink - ? [ - { - text: t('文档'), - itemKey: 'docs', - isExternal: true, - externalLink: docsLink, - }, - ] - : []), - { - text: t('关于'), - itemKey: 'about', - to: '/about', - }, - ]; + const allLinks = [ + { + text: t('首页'), + itemKey: 'home', + to: '/', + }, + { + text: t('控制台'), + itemKey: 'console', + to: '/console', + }, + { + text: t('模型广场'), + itemKey: 'pricing', + to: '/pricing', + }, + ...(docsLink + ? [ + { + text: t('文档'), + itemKey: 'docs', + isExternal: true, + externalLink: docsLink, + }, + ] + : []), + { + text: t('关于'), + itemKey: 'about', + to: '/about', + }, + ]; - // 根据配置过滤导航链接 - return allLinks.filter(link => { - if (link.itemKey === 'docs') { - return docsLink && modules.docs; - } - if (link.itemKey === 'pricing') { - // 支持新的pricing配置格式 - return typeof modules.pricing === 'object' ? modules.pricing.enabled : modules.pricing; - } - return modules[link.itemKey] === true; - }); - }, - [t, docsLink, headerNavModules], - ); + // 根据配置过滤导航链接 + return allLinks.filter((link) => { + if (link.itemKey === 'docs') { + return docsLink && modules.docs; + } + if (link.itemKey === 'pricing') { + // 支持新的pricing配置格式 + return typeof modules.pricing === 'object' + ? modules.pricing.enabled + : modules.pricing; + } + return modules[link.itemKey] === true; + }); + }, [t, docsLink, headerNavModules]); return { mainNavLinks, diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 0a695bbd4..5dce44f9e 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -31,7 +31,7 @@ export const useSidebar = () => { chat: { enabled: true, playground: true, - chat: true + chat: true, }, console: { enabled: true, @@ -39,12 +39,12 @@ export const useSidebar = () => { token: true, log: true, midjourney: true, - task: true + task: true, }, personal: { enabled: true, topup: true, - personal: true + personal: true, }, admin: { enabled: true, @@ -52,8 +52,8 @@ export const useSidebar = () => { models: true, redemption: true, user: true, - setting: true - } + setting: true, + }, }; // 获取管理员配置 @@ -87,12 +87,15 @@ export const useSidebar = () => { // 当用户没有配置时,生成一个基于管理员配置的默认用户配置 // 这样可以确保权限控制正确生效 const defaultUserConfig = {}; - Object.keys(adminConfig).forEach(sectionKey => { + Object.keys(adminConfig).forEach((sectionKey) => { if (adminConfig[sectionKey]?.enabled) { defaultUserConfig[sectionKey] = { enabled: true }; // 为每个管理员允许的模块设置默认值为true - Object.keys(adminConfig[sectionKey]).forEach(moduleKey => { - if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) { + Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => { + if ( + moduleKey !== 'enabled' && + adminConfig[sectionKey][moduleKey] + ) { defaultUserConfig[sectionKey][moduleKey] = true; } }); @@ -103,10 +106,10 @@ export const useSidebar = () => { } catch (error) { // 出错时也生成默认配置,而不是设置为空对象 const defaultUserConfig = {}; - Object.keys(adminConfig).forEach(sectionKey => { + Object.keys(adminConfig).forEach((sectionKey) => { if (adminConfig[sectionKey]?.enabled) { defaultUserConfig[sectionKey] = { enabled: true }; - Object.keys(adminConfig[sectionKey]).forEach(moduleKey => { + Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => { if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) { defaultUserConfig[sectionKey][moduleKey] = true; } @@ -149,7 +152,7 @@ export const useSidebar = () => { } // 遍历所有区域 - Object.keys(adminConfig).forEach(sectionKey => { + Object.keys(adminConfig).forEach((sectionKey) => { const adminSection = adminConfig[sectionKey]; const userSection = userConfig[sectionKey]; @@ -161,18 +164,21 @@ export const useSidebar = () => { // 区域级别:用户可以选择隐藏管理员允许的区域 // 当userSection存在时检查enabled状态,否则默认为true - const sectionEnabled = userSection ? (userSection.enabled !== false) : true; + const sectionEnabled = userSection ? userSection.enabled !== false : true; result[sectionKey] = { enabled: sectionEnabled }; // 功能级别:只有管理员和用户都允许的功能才显示 - Object.keys(adminSection).forEach(moduleKey => { + Object.keys(adminSection).forEach((moduleKey) => { if (moduleKey === 'enabled') return; const adminAllowed = adminSection[moduleKey]; // 当userSection存在时检查模块状态,否则默认为true - const userAllowed = userSection ? (userSection[moduleKey] !== false) : true; + const userAllowed = userSection + ? userSection[moduleKey] !== false + : true; - result[sectionKey][moduleKey] = adminAllowed && userAllowed && sectionEnabled; + result[sectionKey][moduleKey] = + adminAllowed && userAllowed && sectionEnabled; }); }); @@ -192,9 +198,9 @@ export const useSidebar = () => { const hasSectionVisibleModules = (sectionKey) => { const section = finalConfig[sectionKey]; if (!section?.enabled) return false; - - return Object.keys(section).some(key => - key !== 'enabled' && section[key] === true + + return Object.keys(section).some( + (key) => key !== 'enabled' && section[key] === true, ); }; @@ -202,9 +208,10 @@ export const useSidebar = () => { const getVisibleModules = (sectionKey) => { const section = finalConfig[sectionKey]; if (!section?.enabled) return []; - - return Object.keys(section) - .filter(key => key !== 'enabled' && section[key] === true); + + return Object.keys(section).filter( + (key) => key !== 'enabled' && section[key] === true, + ); }; return { @@ -215,6 +222,6 @@ export const useSidebar = () => { isModuleVisible, hasSectionVisibleModules, getVisibleModules, - refreshUserConfig + refreshUserConfig, }; }; diff --git a/web/src/hooks/common/useUserPermissions.js b/web/src/hooks/common/useUserPermissions.js index 743594353..8d57f972e 100644 --- a/web/src/hooks/common/useUserPermissions.js +++ b/web/src/hooks/common/useUserPermissions.js @@ -1,3 +1,21 @@ +/* +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 { useState, useEffect } from 'react'; import { API } from '../../helpers'; @@ -52,22 +70,22 @@ export const useUserPermissions = () => { const isSidebarModuleAllowed = (sectionKey, moduleKey) => { if (!permissions?.sidebar_modules) return true; const sectionPerms = permissions.sidebar_modules[sectionKey]; - + // 如果整个区域被禁用 if (sectionPerms === false) return false; - + // 如果区域存在但模块被禁用 if (sectionPerms && sectionPerms[moduleKey] === false) return false; - + return true; }; // 获取允许的边栏区域列表 const getAllowedSidebarSections = () => { if (!permissions?.sidebar_modules) return []; - - return Object.keys(permissions.sidebar_modules).filter(sectionKey => - isSidebarSectionAllowed(sectionKey) + + return Object.keys(permissions.sidebar_modules).filter((sectionKey) => + isSidebarSectionAllowed(sectionKey), ); }; @@ -75,12 +93,13 @@ export const useUserPermissions = () => { const getAllowedSidebarModules = (sectionKey) => { if (!permissions?.sidebar_modules) return []; const sectionPerms = permissions.sidebar_modules[sectionKey]; - + if (sectionPerms === false) return []; if (!sectionPerms || typeof sectionPerms !== 'object') return []; - - return Object.keys(sectionPerms).filter(moduleKey => - moduleKey !== 'enabled' && sectionPerms[moduleKey] === true + + return Object.keys(sectionPerms).filter( + (moduleKey) => + moduleKey !== 'enabled' && sectionPerms[moduleKey] === true, ); }; diff --git a/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx b/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx index 623accefb..37dabf546 100644 --- a/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx +++ b/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx @@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useContext } from 'react'; -import { Button, Card, Col, Form, Row, Switch, Typography } from '@douyinfe/semi-ui'; +import { + Button, + Card, + Col, + Form, + Row, + Switch, + Typography, +} from '@douyinfe/semi-ui'; import { API, showError, showSuccess } from '../../../helpers'; import { useTranslation } from 'react-i18next'; import { StatusContext } from '../../../context/Status'; @@ -29,14 +37,14 @@ export default function SettingsHeaderNavModules(props) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [statusState, statusDispatch] = useContext(StatusContext); - + // 顶栏模块管理状态 const [headerNavModules, setHeaderNavModules] = useState({ home: true, console: true, pricing: { enabled: true, - requireAuth: false // 默认不需要登录鉴权 + requireAuth: false, // 默认不需要登录鉴权 }, docs: true, about: true, @@ -50,7 +58,7 @@ export default function SettingsHeaderNavModules(props) { // 对于pricing模块,只更新enabled属性 newModules[moduleKey] = { ...newModules[moduleKey], - enabled: checked + enabled: checked, }; } else { newModules[moduleKey] = checked; @@ -64,7 +72,7 @@ export default function SettingsHeaderNavModules(props) { const newModules = { ...headerNavModules }; newModules.pricing = { ...newModules.pricing, - requireAuth: checked + requireAuth: checked, }; setHeaderNavModules(newModules); } @@ -76,7 +84,7 @@ export default function SettingsHeaderNavModules(props) { console: true, pricing: { enabled: true, - requireAuth: false + requireAuth: false, }, docs: true, about: true, @@ -102,8 +110,8 @@ export default function SettingsHeaderNavModules(props) { type: 'set', payload: { ...statusState.status, - HeaderNavModules: JSON.stringify(headerNavModules) - } + HeaderNavModules: JSON.stringify(headerNavModules), + }, }); // 刷新父组件状态 @@ -130,7 +138,7 @@ export default function SettingsHeaderNavModules(props) { if (typeof modules.pricing === 'boolean') { modules.pricing = { enabled: modules.pricing, - requireAuth: false // 默认不需要登录鉴权 + requireAuth: false, // 默认不需要登录鉴权 }; } @@ -142,7 +150,7 @@ export default function SettingsHeaderNavModules(props) { console: true, pricing: { enabled: true, - requireAuth: false + requireAuth: false, }, docs: true, about: true, @@ -157,35 +165,37 @@ export default function SettingsHeaderNavModules(props) { { key: 'home', title: t('首页'), - description: t('用户主页,展示系统信息') + description: t('用户主页,展示系统信息'), }, { key: 'console', title: t('控制台'), - description: t('用户控制面板,管理账户') + description: t('用户控制面板,管理账户'), }, { key: 'pricing', title: t('模型广场'), description: t('模型定价,需要登录访问'), - hasSubConfig: true // 标识该模块有子配置 + hasSubConfig: true, // 标识该模块有子配置 }, { key: 'docs', title: t('文档'), - description: t('系统文档和帮助信息') + description: t('系统文档和帮助信息'), }, { key: 'about', title: t('关于'), - description: t('关于系统的详细信息') - } + description: t('关于系统的详细信息'), + }, ]; return ( - - + {moduleConfigs.map((module) => ( @@ -195,34 +205,38 @@ export default function SettingsHeaderNavModules(props) { border: '1px solid var(--semi-color-border)', transition: 'all 0.2s ease', background: 'var(--semi-color-bg-1)', - minHeight: '80px' + minHeight: '80px', }} bodyStyle={{ padding: '16px' }} hoverable > -
+
-
+
{module.title}
{module.description} @@ -230,78 +244,94 @@ export default function SettingsHeaderNavModules(props) {
{/* 为模型广场添加权限控制子开关 */} - {module.key === 'pricing' && (module.key === 'pricing' ? headerNavModules[module.key]?.enabled : headerNavModules[module.key]) && ( -
-
-
-
- {t('需要登录访问')} + {module.key === 'pricing' && + (module.key === 'pricing' + ? headerNavModules[module.key]?.enabled + : headerNavModules[module.key]) && ( +
+
+
+
+ {t('需要登录访问')} +
+ + {t('开启后未登录用户无法访问模型广场')} + +
+
+
- - {t('开启后未登录用户无法访问模型广场')} - -
-
-
-
- )} + )} ))} - -
+
- + {t('您可以个性化设置侧边栏的要显示功能')}
- {sectionConfigs.map((section) => ( -
- {/* 区域标题和总开关 */} -
-
-
- {section.title} -
- - {section.description} - + {sectionConfigs.map((section) => ( +
+ {/* 区域标题和总开关 */} +
+
+
+ {section.title}
- + + {section.description} +
- - {/* 功能模块网格 */} - - {section.modules.map((module) => ( - - -
-
-
- {module.title} -
- - {module.description} - -
-
- -
-
-
- - ))} -
+
- ))} + + {/* 功能模块网格 */} + + {section.modules.map((module) => ( + + +
+
+
+ {module.title} +
+ + {module.description} + +
+
+ +
+
+
+ + ))} +
+
+ ))} {/* 底部按钮 */}
diff --git a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx index 9394ae83d..5d6dd154f 100644 --- a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx +++ b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx @@ -130,9 +130,7 @@ export default function ModelRatioNotSetEditor(props) { // 在 return 语句之前,先处理过滤和分页逻辑 const filteredModels = models.filter((model) => - searchText - ? model.name.includes(searchText) - : true, + searchText ? model.name.includes(searchText) : true, ); // 然后基于过滤后的数据计算分页数据 diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx index e291e132b..b5ad3e58d 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx @@ -99,9 +99,7 @@ export default function ModelSettingsVisualEditor(props) { // 在 return 语句之前,先处理过滤和分页逻辑 const filteredModels = models.filter((model) => { - const keywordMatch = searchText - ? model.name.includes(searchText) - : true; + const keywordMatch = searchText ? model.name.includes(searchText) : true; const conflictMatch = conflictOnly ? model.hasConflict : true; return keywordMatch && conflictMatch; }); diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx index 0f483717b..86efcce02 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx @@ -151,8 +151,17 @@ export default function UpstreamRatioSync(props) { setChannelEndpoints((prev) => { const merged = { ...prev }; transferData.forEach((channel) => { - if (!merged[channel.key]) { - merged[channel.key] = DEFAULT_ENDPOINT; + const id = channel.key; + const base = channel._originalData?.base_url || ''; + const name = channel.label || ''; + const isOfficial = + id === -100 || + base === 'https://basellm.github.io' || + name === '官方倍率预设'; + if (!merged[id]) { + merged[id] = isOfficial + ? '/llm-metadata/api/newapi/ratio_config-v1-base.json' + : DEFAULT_ENDPOINT; } }); return merged;