- 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 | return | ||||
| } | } | ||||
| globalMetrics = NewMetrics(prometheus.DefaultRegisterer) | globalMetrics = NewMetrics(prometheus.DefaultRegisterer) | ||||
| prometheus.MustRegister(NewChannelCollector()) | |||||
| }) | }) | ||||
| return globalMetrics | return globalMetrics | ||||
| } | } | ||||
| @@ -79,6 +79,9 @@ func SetRelayRouter(router *gin.Engine) { | |||||
| relayV1Router := router.Group("/v1") | relayV1Router := router.Group("/v1") | ||||
| relayV1Router.Use(middleware.SystemPerformanceCheck()) | relayV1Router.Use(middleware.SystemPerformanceCheck()) | ||||
| relayV1Router.Use(middleware.TokenAuth()) | relayV1Router.Use(middleware.TokenAuth()) | ||||
| if metrics.IsEnabled() { | |||||
| relayV1Router.Use(metrics.MetricsMiddleware(metrics.GetMetrics())) | |||||
| } | |||||
| relayV1Router.Use(middleware.ModelRequestRateLimit()) | relayV1Router.Use(middleware.ModelRequestRateLimit()) | ||||
| relayV1Router.Use(middleware.RelayCaptureMiddleware()) | relayV1Router.Use(middleware.RelayCaptureMiddleware()) | ||||
| { | { | ||||
| @@ -165,6 +168,9 @@ func SetRelayRouter(router *gin.Engine) { | |||||
| relayGeminiRouter := router.Group("/v1beta") | relayGeminiRouter := router.Group("/v1beta") | ||||
| relayGeminiRouter.Use(middleware.SystemPerformanceCheck()) | relayGeminiRouter.Use(middleware.SystemPerformanceCheck()) | ||||
| relayGeminiRouter.Use(middleware.TokenAuth()) | relayGeminiRouter.Use(middleware.TokenAuth()) | ||||
| if metrics.IsEnabled() { | |||||
| relayGeminiRouter.Use(metrics.MetricsMiddleware(metrics.GetMetrics())) | |||||
| } | |||||
| relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) | relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) | ||||
| relayGeminiRouter.Use(middleware.RelayCaptureMiddleware()) | relayGeminiRouter.Use(middleware.RelayCaptureMiddleware()) | ||||
| relayGeminiRouter.Use(middleware.Distribute()) | 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; | delete localInputs.vertex_files; | ||||
| if (!isEdit && (!localInputs.name || !localInputs.public_name || !localInputs.key)) { | |||||
| showInfo(t('请填写渠道名称、对外名称和渠道密钥!')); | |||||
| if (!isEdit && (!localInputs.name || !localInputs.key)) { | |||||
| showInfo(t('请填写渠道名称和渠道密钥!')); | |||||
| return; | return; | ||||
| } | } | ||||
| if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) { | if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) { | ||||
| @@ -2014,16 +2014,6 @@ const EditChannelModal = (props) => { | |||||
| autoComplete='new-password' | 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 && ( | {inputs.type === 33 && ( | ||||
| <> | <> | ||||
| <Form.Select | <Form.Select | ||||
| @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com | |||||
| */ | */ | ||||
| import React, { useState, useEffect } from 'react'; | 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 { IconServer, IconCopy, IconArrowDown } from '@douyinfe/semi-icons'; | ||||
| import { API, copy, showSuccess, getCurrencyConfig } from '../../../../../helpers'; | import { API, copy, showSuccess, getCurrencyConfig } from '../../../../../helpers'; | ||||
| @@ -132,9 +132,6 @@ const ChannelPricingCard = ({ | |||||
| ID: {record.channelId} <IconCopy size='tiny' style={{ marginLeft: 2, verticalAlign: 'middle' }} /> | ID: {record.channelId} <IconCopy size='tiny' style={{ marginLeft: 2, verticalAlign: 'middle' }} /> | ||||
| </Tag> | </Tag> | ||||
| </Tooltip> | </Tooltip> | ||||
| <Tag color='cyan' size='small' shape='circle'> | |||||
| {text} | |||||
| </Tag> | |||||
| {record.channelTags.map((tag, idx) => ( | {record.channelTags.map((tag, idx) => ( | ||||
| <Tag | <Tag | ||||
| key={idx} | key={idx} | ||||
| @@ -291,11 +288,6 @@ const ChannelPricingCard = ({ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <Banner | |||||
| type='info' | |||||
| description={t('在 API Key 后添加 ":通道ID" 可指定使用特定通道,如:sk-xxxx:1')} | |||||
| className='mb-4' | |||||
| /> | |||||
| <Table | <Table | ||||
| dataSource={tableData} | dataSource={tableData} | ||||
| columns={columns} | columns={columns} | ||||
| @@ -890,14 +890,14 @@ const TopUp = () => { | |||||
| allSubscriptions={allSubscriptions} | allSubscriptions={allSubscriptions} | ||||
| reloadSubscriptionSelf={getSubscriptionSelf} | reloadSubscriptionSelf={getSubscriptionSelf} | ||||
| /> | /> | ||||
| {/* <InvitationCard | |||||
| <InvitationCard | |||||
| t={t} | t={t} | ||||
| userState={userState} | userState={userState} | ||||
| renderQuota={renderQuota} | renderQuota={renderQuota} | ||||
| setOpenTransfer={setOpenTransfer} | setOpenTransfer={setOpenTransfer} | ||||
| affLink={affLink} | affLink={affLink} | ||||
| handleAffLinkClick={handleAffLinkClick} | handleAffLinkClick={handleAffLinkClick} | ||||
| /> */} | |||||
| /> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| ); | ); | ||||
| @@ -35,6 +35,9 @@ export const useModelsData = () => { | |||||
| const [searching, setSearching] = useState(false); | const [searching, setSearching] = useState(false); | ||||
| const [modelCount, setModelCount] = useState(0); | 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 | // Modal states | ||||
| const [showEdit, setShowEdit] = useState(false); | const [showEdit, setShowEdit] = useState(false); | ||||
| const [editingModel, setEditingModel] = useState({ | 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); | setSearching(true); | ||||
| try { | 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; | const { success, message, data } = res.data; | ||||
| if (success) { | if (success) { | ||||
| const newPageData = extractItems(data); | const newPageData = extractItems(data); | ||||
| setActivePage(data.page || 1); | |||||
| setActivePage(data.page || page); | |||||
| setModelCount(data.total || newPageData.length); | setModelCount(data.total || newPageData.length); | ||||
| setModelFormat(newPageData); | setModelFormat(newPageData); | ||||
| if (data.vendor_counts) { | if (data.vendor_counts) { | ||||
| @@ -278,16 +272,36 @@ export const useModelsData = () => { | |||||
| ); | ); | ||||
| setVendorCounts({ ...data.vendor_counts, all: sumAll }); | setVendorCounts({ ...data.vendor_counts, all: sumAll }); | ||||
| } | } | ||||
| } else { | |||||
| showError(message); | |||||
| setModels([]); | |||||
| return true; | |||||
| } | } | ||||
| showError(message); | |||||
| setModels([]); | |||||
| return false; | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error(error); | console.error(error); | ||||
| showError(t('搜索模型失败')); | showError(t('搜索模型失败')); | ||||
| setModels([]); | 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) | // Manage model (enable/disable/delete) | ||||
| @@ -328,13 +342,18 @@ export const useModelsData = () => { | |||||
| }; | }; | ||||
| // Handle page change | // Handle page change | ||||
| const handlePageChange = (page) => { | |||||
| const handlePageChange = async (page) => { | |||||
| setActivePage(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 | // Reload models when activeVendorKey changes | ||||
| useEffect(() => { | useEffect(() => { | ||||
| setActiveSearch({ keyword: '', vendor: '' }); | |||||
| loadModels(1, pageSize, activeVendorKey); | loadModels(1, pageSize, activeVendorKey); | ||||
| }, [activeVendorKey]); | }, [activeVendorKey]); | ||||
| @@ -342,7 +361,11 @@ export const useModelsData = () => { | |||||
| const handlePageSizeChange = async (size) => { | const handlePageSizeChange = async (size) => { | ||||
| setPageSize(size); | setPageSize(size); | ||||
| setActivePage(1); | 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 | // Handle row click and styling | ||||
| @@ -77,12 +77,12 @@ const HomePricingFilters = ({ t }) => { | |||||
| const vendorItems = useMemo(() => { | const vendorItems = useMemo(() => { | ||||
| const models = pricingData.models; | const models = pricingData.models; | ||||
| const vendorSet = new Set(); | |||||
| const vendorIcons = new Map(); | const vendorIcons = new Map(); | ||||
| const vendorNamesInModels = new Set(); | |||||
| let hasUnknown = false; | let hasUnknown = false; | ||||
| (models || []).forEach((m) => { | (models || []).forEach((m) => { | ||||
| if (m.vendor_name) { | if (m.vendor_name) { | ||||
| vendorSet.add(m.vendor_name); | |||||
| vendorNamesInModels.add(m.vendor_name); | |||||
| if (m.vendor_icon && !vendorIcons.has(m.vendor_name)) { | if (m.vendor_icon && !vendorIcons.has(m.vendor_name)) { | ||||
| vendorIcons.set(m.vendor_name, m.vendor_icon); | vendorIcons.set(m.vendor_name, m.vendor_icon); | ||||
| } | } | ||||
| @@ -90,10 +90,20 @@ const HomePricingFilters = ({ t }) => { | |||||
| hasUnknown = true; | 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 getVendorCount = () => { | ||||
| // 计算供应商数量(包含未知供应商) | |||||
| const count = vendorSet.size + (hasUnknown ? 1 : 0); | |||||
| const count = vendorNamesInModels.size + (hasUnknown ? 1 : 0); | |||||
| return count; | return count; | ||||
| }; | }; | ||||
| const getModelCount = (v) => { | const getModelCount = (v) => { | ||||
| @@ -104,7 +114,7 @@ const HomePricingFilters = ({ t }) => { | |||||
| const items = [ | const items = [ | ||||
| { value: 'all', label: t('全部供应商'), tagCount: getVendorCount(), disabled: (models || []).length === 0 }, | { value: 'all', label: t('全部供应商'), tagCount: getVendorCount(), disabled: (models || []).length === 0 }, | ||||
| ]; | ]; | ||||
| vendors.forEach((v) => { | |||||
| orderedVendors.forEach((v) => { | |||||
| const count = getModelCount(v); | const count = getModelCount(v); | ||||
| items.push({ | items.push({ | ||||
| value: v, | value: v, | ||||
| @@ -123,7 +133,7 @@ const HomePricingFilters = ({ t }) => { | |||||
| }); | }); | ||||
| } | } | ||||
| return items; | return items; | ||||
| }, [pricingData.models, vendorModels, t]); | |||||
| }, [pricingData.models, pricingData.vendorsMap, vendorModels, t]); | |||||
| const typeItems = useMemo(() => { | const typeItems = useMemo(() => { | ||||
| const models = pricingData.models; | const models = pricingData.models; | ||||