- Register Prometheus ChannelCollector in GetMetrics() sync.Once block - Add MetricsMiddleware to /v1 and /v1beta relay router groups when metrics enabled - Remove public_name field from channel creation form and validation - Restore InvitationCard in topup page - Extract fetchSearchPage helper in useModelsData to eliminate ~50 lines of duplication; fix setSearching with finally block; use Axios params for URL encoding; fix missing vendor_counts update during paginated search - Fix vendor ordering in HomePricingFilters to follow vendorsMap sort_order; fix O(n²) Array.includes to use Set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
| @@ -98,6 +98,7 @@ func GetMetrics() *Metrics { | |||
| return | |||
| } | |||
| globalMetrics = NewMetrics(prometheus.DefaultRegisterer) | |||
| prometheus.MustRegister(NewChannelCollector()) | |||
| }) | |||
| return globalMetrics | |||
| } | |||
| @@ -79,6 +79,9 @@ func SetRelayRouter(router *gin.Engine) { | |||
| relayV1Router := router.Group("/v1") | |||
| relayV1Router.Use(middleware.SystemPerformanceCheck()) | |||
| relayV1Router.Use(middleware.TokenAuth()) | |||
| if metrics.IsEnabled() { | |||
| relayV1Router.Use(metrics.MetricsMiddleware(metrics.GetMetrics())) | |||
| } | |||
| relayV1Router.Use(middleware.ModelRequestRateLimit()) | |||
| relayV1Router.Use(middleware.RelayCaptureMiddleware()) | |||
| { | |||
| @@ -165,6 +168,9 @@ func SetRelayRouter(router *gin.Engine) { | |||
| relayGeminiRouter := router.Group("/v1beta") | |||
| relayGeminiRouter.Use(middleware.SystemPerformanceCheck()) | |||
| relayGeminiRouter.Use(middleware.TokenAuth()) | |||
| if metrics.IsEnabled() { | |||
| relayGeminiRouter.Use(metrics.MetricsMiddleware(metrics.GetMetrics())) | |||
| } | |||
| relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) | |||
| relayGeminiRouter.Use(middleware.RelayCaptureMiddleware()) | |||
| relayGeminiRouter.Use(middleware.Distribute()) | |||
| @@ -1 +0,0 @@ | |||
| var NewApiLoginWidget=function(s){"use strict";async function i({apiUrl:r,username:t,password:a}){if(!r)throw new Error("NewApiLoginWidget: apiUrl is required");if(!t||!t.trim())return{success:!1,message:"请输入用户名"};if(!a)return{success:!1,message:"请输入密码"};const n=r.replace(/\/+$/,"")+"/api/user/login";try{const e=await(await fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify({username:t.trim(),password:a})})).json();return e.success?e.data&&e.data.require_2fa?{success:!1,message:"此账户启用了两步验证,暂不支持通过 Widget 登录"}:{success:!0,data:e.data}:{success:!1,message:e.message||"登录失败"}}catch{return{success:!1,message:"网络错误,请检查网络连接"}}}return window.NewApiLoginWidget={login:i},s.login=i,Object.defineProperty(s,Symbol.toStringTag,{value:"Module"}),s}({}); | |||
| @@ -1361,8 +1361,8 @@ const EditChannelModal = (props) => { | |||
| } | |||
| delete localInputs.vertex_files; | |||
| if (!isEdit && (!localInputs.name || !localInputs.public_name || !localInputs.key)) { | |||
| showInfo(t('请填写渠道名称、对外名称和渠道密钥!')); | |||
| if (!isEdit && (!localInputs.name || !localInputs.key)) { | |||
| showInfo(t('请填写渠道名称和渠道密钥!')); | |||
| return; | |||
| } | |||
| if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) { | |||
| @@ -2014,16 +2014,6 @@ const EditChannelModal = (props) => { | |||
| autoComplete='new-password' | |||
| /> | |||
| <Form.Input | |||
| field='public_name' | |||
| label={t('对外名称')} | |||
| placeholder={t('用户看到的渠道名称,如「标准通道」「高速通道」')} | |||
| rules={!isEdit ? [{ required: true, message: t('请填写对外名称') }] : []} | |||
| showClear | |||
| onChange={(value) => handleInputChange('public_name', value)} | |||
| autoComplete='new-password' | |||
| /> | |||
| {inputs.type === 33 && ( | |||
| <> | |||
| <Form.Select | |||
| @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import React, { useState, useEffect } from 'react'; | |||
| import { Card, Avatar, Typography, Table, Tag, Spin, Banner, Tooltip } from '@douyinfe/semi-ui'; | |||
| import { Card, Avatar, Typography, Table, Tag, Spin, Tooltip } from '@douyinfe/semi-ui'; | |||
| import { IconServer, IconCopy, IconArrowDown } from '@douyinfe/semi-icons'; | |||
| import { API, copy, showSuccess, getCurrencyConfig } from '../../../../../helpers'; | |||
| @@ -132,9 +132,6 @@ const ChannelPricingCard = ({ | |||
| ID: {record.channelId} <IconCopy size='tiny' style={{ marginLeft: 2, verticalAlign: 'middle' }} /> | |||
| </Tag> | |||
| </Tooltip> | |||
| <Tag color='cyan' size='small' shape='circle'> | |||
| {text} | |||
| </Tag> | |||
| {record.channelTags.map((tag, idx) => ( | |||
| <Tag | |||
| key={idx} | |||
| @@ -291,11 +288,6 @@ const ChannelPricingCard = ({ | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <Banner | |||
| type='info' | |||
| description={t('在 API Key 后添加 ":通道ID" 可指定使用特定通道,如:sk-xxxx:1')} | |||
| className='mb-4' | |||
| /> | |||
| <Table | |||
| dataSource={tableData} | |||
| columns={columns} | |||
| @@ -890,14 +890,14 @@ const TopUp = () => { | |||
| allSubscriptions={allSubscriptions} | |||
| reloadSubscriptionSelf={getSubscriptionSelf} | |||
| /> | |||
| {/* <InvitationCard | |||
| <InvitationCard | |||
| t={t} | |||
| userState={userState} | |||
| renderQuota={renderQuota} | |||
| setOpenTransfer={setOpenTransfer} | |||
| affLink={affLink} | |||
| handleAffLinkClick={handleAffLinkClick} | |||
| /> */} | |||
| /> | |||
| </div> | |||
| </div> | |||
| ); | |||
| @@ -35,6 +35,9 @@ export const useModelsData = () => { | |||
| const [searching, setSearching] = useState(false); | |||
| const [modelCount, setModelCount] = useState(0); | |||
| // snapshot of the last *submitted* search — distinct from live form values, so pagination stays correct if the user edits the form without re-submitting | |||
| const [activeSearch, setActiveSearch] = useState({ keyword: '', vendor: '' }); | |||
| // Modal states | |||
| const [showEdit, setShowEdit] = useState(false); | |||
| const [editingModel, setEditingModel] = useState({ | |||
| @@ -250,25 +253,16 @@ export const useModelsData = () => { | |||
| } | |||
| }; | |||
| // Search models with keyword and vendor | |||
| const searchModels = async () => { | |||
| const { searchKeyword = '', searchVendor = '' } = getFormValues(); | |||
| if (searchKeyword === '' && searchVendor === '') { | |||
| // If keyword is blank, load models instead | |||
| await loadModels(1, pageSize); | |||
| return; | |||
| } | |||
| const fetchSearchPage = async (keyword, vendor, page, size) => { | |||
| setSearching(true); | |||
| try { | |||
| const res = await API.get( | |||
| `/api/models/search?keyword=${searchKeyword}&vendor=${searchVendor}&p=1&page_size=${pageSize}`, | |||
| ); | |||
| const res = await API.get('/api/models/search', { | |||
| params: { keyword, vendor, p: page, page_size: size }, | |||
| }); | |||
| const { success, message, data } = res.data; | |||
| if (success) { | |||
| const newPageData = extractItems(data); | |||
| setActivePage(data.page || 1); | |||
| setActivePage(data.page || page); | |||
| setModelCount(data.total || newPageData.length); | |||
| setModelFormat(newPageData); | |||
| if (data.vendor_counts) { | |||
| @@ -278,16 +272,36 @@ export const useModelsData = () => { | |||
| ); | |||
| setVendorCounts({ ...data.vendor_counts, all: sumAll }); | |||
| } | |||
| } else { | |||
| showError(message); | |||
| setModels([]); | |||
| return true; | |||
| } | |||
| showError(message); | |||
| setModels([]); | |||
| return false; | |||
| } catch (error) { | |||
| console.error(error); | |||
| showError(t('搜索模型失败')); | |||
| setModels([]); | |||
| return false; | |||
| } finally { | |||
| setSearching(false); | |||
| } | |||
| }; | |||
| // Search models with keyword and vendor | |||
| const searchModels = async () => { | |||
| const { searchKeyword = '', searchVendor = '' } = getFormValues(); | |||
| if (searchKeyword === '' && searchVendor === '') { | |||
| // If keyword is blank, load models instead and clear search state | |||
| setActiveSearch({ keyword: '', vendor: '' }); | |||
| await loadModels(1, pageSize); | |||
| return; | |||
| } | |||
| const ok = await fetchSearchPage(searchKeyword, searchVendor, 1, pageSize); | |||
| if (ok) { | |||
| setActiveSearch({ keyword: searchKeyword, vendor: searchVendor }); | |||
| } | |||
| setSearching(false); | |||
| }; | |||
| // Manage model (enable/disable/delete) | |||
| @@ -328,13 +342,18 @@ export const useModelsData = () => { | |||
| }; | |||
| // Handle page change | |||
| const handlePageChange = (page) => { | |||
| const handlePageChange = async (page) => { | |||
| setActivePage(page); | |||
| loadModels(page, pageSize, activeVendorKey); | |||
| if (activeSearch.keyword || activeSearch.vendor) { | |||
| await fetchSearchPage(activeSearch.keyword, activeSearch.vendor, page, pageSize); | |||
| } else { | |||
| loadModels(page, pageSize, activeVendorKey); | |||
| } | |||
| }; | |||
| // Reload models when activeVendorKey changes | |||
| useEffect(() => { | |||
| setActiveSearch({ keyword: '', vendor: '' }); | |||
| loadModels(1, pageSize, activeVendorKey); | |||
| }, [activeVendorKey]); | |||
| @@ -342,7 +361,11 @@ export const useModelsData = () => { | |||
| const handlePageSizeChange = async (size) => { | |||
| setPageSize(size); | |||
| setActivePage(1); | |||
| await loadModels(1, size, activeVendorKey); | |||
| if (activeSearch.keyword || activeSearch.vendor) { | |||
| await fetchSearchPage(activeSearch.keyword, activeSearch.vendor, 1, size); | |||
| } else { | |||
| await loadModels(1, size, activeVendorKey); | |||
| } | |||
| }; | |||
| // Handle row click and styling | |||
| @@ -77,12 +77,12 @@ const HomePricingFilters = ({ t }) => { | |||
| const vendorItems = useMemo(() => { | |||
| const models = pricingData.models; | |||
| const vendorSet = new Set(); | |||
| const vendorIcons = new Map(); | |||
| const vendorNamesInModels = new Set(); | |||
| let hasUnknown = false; | |||
| (models || []).forEach((m) => { | |||
| if (m.vendor_name) { | |||
| vendorSet.add(m.vendor_name); | |||
| vendorNamesInModels.add(m.vendor_name); | |||
| if (m.vendor_icon && !vendorIcons.has(m.vendor_name)) { | |||
| vendorIcons.set(m.vendor_name, m.vendor_icon); | |||
| } | |||
| @@ -90,10 +90,20 @@ const HomePricingFilters = ({ t }) => { | |||
| hasUnknown = true; | |||
| } | |||
| }); | |||
| const vendors = Array.from(vendorSet).sort(); | |||
| // 按后端 vendorsMap 的 sort_order 顺序排列 | |||
| const vMap = pricingData.vendorsMap || {}; | |||
| const orderedVendors = Object.values(vMap) | |||
| .filter((v) => vendorNamesInModels.has(v.name)) | |||
| .map((v) => v.name); | |||
| // fallback: vendors with models that are absent from vendorsMap (e.g. not yet registered) | |||
| const orderedVendorSet = new Set(orderedVendors); | |||
| vendorNamesInModels.forEach((name) => { | |||
| if (!orderedVendorSet.has(name)) { | |||
| orderedVendors.push(name); | |||
| } | |||
| }); | |||
| const getVendorCount = () => { | |||
| // 计算供应商数量(包含未知供应商) | |||
| const count = vendorSet.size + (hasUnknown ? 1 : 0); | |||
| const count = vendorNamesInModels.size + (hasUnknown ? 1 : 0); | |||
| return count; | |||
| }; | |||
| const getModelCount = (v) => { | |||
| @@ -104,7 +114,7 @@ const HomePricingFilters = ({ t }) => { | |||
| const items = [ | |||
| { value: 'all', label: t('全部供应商'), tagCount: getVendorCount(), disabled: (models || []).length === 0 }, | |||
| ]; | |||
| vendors.forEach((v) => { | |||
| orderedVendors.forEach((v) => { | |||
| const count = getModelCount(v); | |||
| items.push({ | |||
| value: v, | |||
| @@ -123,7 +133,7 @@ const HomePricingFilters = ({ t }) => { | |||
| }); | |||
| } | |||
| return items; | |||
| }, [pricingData.models, vendorModels, t]); | |||
| }, [pricingData.models, pricingData.vendorsMap, vendorModels, t]); | |||
| const typeItems = useMemo(() => { | |||
| const models = pricingData.models; | |||