Просмотр исходного кода

feat: add channel metrics collector, fix search pagination and vendor sort order

- 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
fengsilin 2 недель назад
Родитель
Сommit
3cf84775f2
8 измененных файлов: 73 добавлений и 52 удалений
  1. +1
    -0
      common/metrics/metrics.go
  2. +6
    -0
      router/relay-router.go
  3. +0
    -1
      web/dist/static/login-widget.js
  4. +2
    -12
      web/src/components/table/channels/modals/EditChannelModal.jsx
  5. +1
    -9
      web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
  6. +2
    -2
      web/src/components/topup/index.jsx
  7. +44
    -21
      web/src/hooks/models/useModelsData.jsx
  8. +17
    -7
      web/src/pages/Home/HomePricingFilters.jsx

+ 1
- 0
common/metrics/metrics.go Просмотреть файл

@@ -98,6 +98,7 @@ func GetMetrics() *Metrics {
return
}
globalMetrics = NewMetrics(prometheus.DefaultRegisterer)
prometheus.MustRegister(NewChannelCollector())
})
return globalMetrics
}


+ 6
- 0
router/relay-router.go Просмотреть файл

@@ -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())


+ 0
- 1
web/dist/static/login-widget.js Просмотреть файл

@@ -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}({});

+ 2
- 12
web/src/components/table/channels/modals/EditChannelModal.jsx Просмотреть файл

@@ -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


+ 1
- 9
web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx Просмотреть файл

@@ -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}


+ 2
- 2
web/src/components/topup/index.jsx Просмотреть файл

@@ -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>
);


+ 44
- 21
web/src/hooks/models/useModelsData.jsx Просмотреть файл

@@ -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


+ 17
- 7
web/src/pages/Home/HomePricingFilters.jsx Просмотреть файл

@@ -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;


Загрузка…
Отмена
Сохранить