fix: channel affinity (#2799)

* fix: channel affinity log styles

* fix: Issue with incorrect data storage when switching key sources

* feat: support not retrying after a single rule configuration fails

* fix: render channel affinity tooltip as multiline content

* feat: channel affinity cache hit

* fix: prevent ChannelAffinityUsageCacheModal infinite loading and hide data before fetch

* chore: format backend with gofmt and frontend with prettier/eslint autofix
This commit is contained in:
Seefs
2026-02-02 14:37:31 +08:00
committed by GitHub
parent 80a609b7c6
commit f244a9e661
61 changed files with 2012 additions and 1004 deletions

View File

@@ -109,7 +109,9 @@ const renderType = (type, record = {}, t) => {
<Tooltip
content={
<div className='max-w-xs'>
<div className='text-xs text-gray-600'>{t('来源于 IO.NET 部署')}</div>
<div className='text-xs text-gray-600'>
{t('来源于 IO.NET 部署')}
</div>
{ionetMeta?.deployment_id && (
<div className='text-xs text-gray-500 mt-1'>
{t('部署 ID')}: {ionetMeta.deployment_id}

View File

@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Button, Space, Typography, Input, Banner } from '@douyinfe/semi-ui';
import {
Modal,
Button,
Space,
Typography,
Input,
Banner,
} from '@douyinfe/semi-ui';
import { API, copy, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
@@ -33,14 +40,21 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
const startOAuth = async () => {
setLoading(true);
try {
const res = await API.post('/api/channel/codex/oauth/start', {}, { skipErrorHandler: true });
const res = await API.post(
'/api/channel/codex/oauth/start',
{},
{ skipErrorHandler: true },
);
if (!res?.data?.success) {
console.error('Codex OAuth start failed:', res?.data?.message);
throw new Error(t('启动授权失败'));
}
const url = res?.data?.data?.authorize_url || '';
if (!url) {
console.error('Codex OAuth start response missing authorize_url:', res?.data);
console.error(
'Codex OAuth start response missing authorize_url:',
res?.data,
);
throw new Error(t('响应缺少授权链接'));
}
setAuthorizeUrl(url);
@@ -106,7 +120,12 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
<Button theme='borderless' onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button theme='solid' type='primary' onClick={completeOAuth} loading={loading}>
<Button
theme='solid'
type='primary'
onClick={completeOAuth}
loading={loading}
>
{t('生成并填入')}
</Button>
</Space>
@@ -141,7 +160,9 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
/>
<Text type='tertiary' size='small'>
{t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON包含 access_token / refresh_token / account_id。')}
{t(
'说明:生成结果是可直接粘贴到渠道密钥里的 JSON包含 access_token / refresh_token / account_id。',
)}
</Text>
</Space>
</Modal>

View File

@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Modal, Button, Progress, Tag, Typography, Spin } from '@douyinfe/semi-ui';
import {
Modal,
Button,
Progress,
Tag,
Typography,
Spin,
} from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers';
const { Text } = Typography;
@@ -134,7 +141,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
</Text>
<div className='flex items-center gap-2'>
{statusTag}
<Button size='small' type='tertiary' theme='borderless' onClick={onRefresh}>
<Button
size='small'
type='tertiary'
theme='borderless'
onClick={onRefresh}
>
{tt('刷新')}
</Button>
</div>
@@ -243,7 +255,12 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
<div className='flex flex-col gap-3'>
<Text type='danger'>{tt('获取用量失败')}</Text>
<div className='flex justify-end'>
<Button size='small' type='primary' theme='outline' onClick={fetchUsage}>
<Button
size='small'
type='primary'
theme='outline'
onClick={fetchUsage}
>
{tt('刷新')}
</Button>
</div>

View File

@@ -2000,171 +2000,180 @@ const EditChannelModal = (props) => {
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
disabled={isIonetLocked}
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
)}
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 57 ? (
<>
<Form.TextArea
field='key'
label={
isEdit
? t('密钥(编辑模式下,保存的密钥不会显示)')
: t('密钥')
}
placeholder={t(
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
)}
rules={
isEdit
? []
: [{ required: true, message: t('请输入密钥') }]
}
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
disabled={isIonetLocked}
extraText={
<div className='flex flex-col gap-2'>
<Text type='tertiary' size='small'>
{t(
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
)}
</Text>
{batchExtra}
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 57 ? (
<>
<Form.TextArea
field='key'
label={
isEdit
? t('密钥(编辑模式下,保存的密钥不会显示)')
: t('密钥')
}
placeholder={t(
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
)}
rules={
isEdit
? []
: [
{
required: true,
message: t('请输入密钥'),
},
]
}
autoComplete='new-password'
onChange={(value) =>
handleInputChange('key', value)
}
disabled={isIonetLocked}
extraText={
<div className='flex flex-col gap-2'>
<Text type='tertiary' size='small'>
{t(
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
)}
</Text>
<Space wrap spacing='tight'>
<Space wrap spacing='tight'>
<Button
size='small'
type='primary'
theme='outline'
onClick={() =>
setCodexOAuthModalVisible(true)
}
disabled={isIonetLocked}
>
{t('Codex 授权')}
</Button>
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleRefreshCodexCredential}
loading={codexCredentialRefreshing}
disabled={isIonetLocked}
>
{t('刷新凭证')}
</Button>
)}
<Button
size='small'
type='primary'
theme='outline'
onClick={() => formatJsonField('key')}
disabled={isIonetLocked}
>
{t('格式化')}
</Button>
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
disabled={isIonetLocked}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</Space>
</div>
}
autosize
showClear
/>
<CodexOAuthModal
visible={codexOAuthModalVisible}
onCancel={() => setCodexOAuthModalVisible(false)}
onSuccess={handleCodexOAuthGenerated}
/>
</>
) : inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type='primary'
theme='outline'
onClick={() =>
setCodexOAuthModalVisible(true)
type={
!useManualInput ? 'primary' : 'tertiary'
}
disabled={isIonetLocked}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('Codex 授权')}
{t('文件上传')}
</Button>
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleRefreshCodexCredential}
loading={codexCredentialRefreshing}
disabled={isIonetLocked}
>
{t('刷新凭证')}
</Button>
)}
<Button
size='small'
type='primary'
theme='outline'
onClick={() => formatJsonField('key')}
disabled={isIonetLocked}
type={
useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('格式化')}
{t('手动输入')}
</Button>
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
disabled={isIonetLocked}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</Space>
</div>
}
autosize
showClear
/>
<CodexOAuthModal
visible={codexOAuthModalVisible}
onCancel={() => setCodexOAuthModalVisible(false)}
onSuccess={handleCodexOAuthGenerated}
/>
</>
) : inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type={
!useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('文件上传')}
</Button>
<Button
size='small'
type={useManualInput ? 'primary' : 'tertiary'}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('手动输入')}
</Button>
</Space>
</div>
)}
)}
{batch && (
<Banner

View File

@@ -533,7 +533,11 @@ const EditTagModal = (props) => {
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
<Avatar
size='small'
color='orange'
className='mr-2 shadow-md'
>
<IconSetting size={16} />
</Avatar>
<div>
@@ -549,9 +553,7 @@ const EditTagModal = (props) => {
field='param_override'
label={t('参数覆盖')}
placeholder={
t(
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
) +
t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
'\n' +
t('旧格式(直接覆盖):') +
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +

View File

@@ -104,7 +104,9 @@ const ModelSelectModal = ({
}, [normalizedRedirectModels, normalizedSelectedSet]);
const filteredModels = models.filter((m) =>
String(m || '').toLowerCase().includes(keyword.toLowerCase()),
String(m || '')
.toLowerCase()
.includes(keyword.toLowerCase()),
);
// 分类模型:新获取的模型和已有模型

View File

@@ -30,7 +30,7 @@ const ConfirmationDialog = ({
type = 'danger',
deployment,
t,
loading = false
loading = false,
}) => {
const [confirmText, setConfirmText] = useState('');
@@ -66,17 +66,17 @@ const ConfirmationDialog = ({
okButtonProps={{
disabled: !isConfirmed,
type: type === 'danger' ? 'danger' : 'primary',
loading
loading,
}}
width={480}
>
<div className="space-y-4">
<Text type="danger" strong>
<div className='space-y-4'>
<Text type='danger' strong>
{t('此操作具有风险,请确认要继续执行')}
</Text>
<Text>
{t('请输入部署名称以完成二次确认')}
<Text code className="ml-1">
<Text code className='ml-1'>
{requiredText || t('未知部署')}
</Text>
</Text>
@@ -87,7 +87,7 @@ const ConfirmationDialog = ({
autoFocus
/>
{!isConfirmed && confirmText && (
<Text type="danger" size="small">
<Text type='danger' size='small'>
{t('部署名称不匹配,请检查后重新输入')}
</Text>
)}

View File

@@ -130,9 +130,7 @@ const ExtendDurationModal = ({
? details.locations
.map((location) =>
Number(
location?.id ??
location?.location_id ??
location?.locationId,
location?.id ?? location?.location_id ?? location?.locationId,
),
)
.filter((id) => Number.isInteger(id) && id > 0)
@@ -181,9 +179,7 @@ const ExtendDurationModal = ({
} else {
const message = response.data.message || '';
setPriceEstimation(null);
setPriceError(
t('价格计算失败') + (message ? `: ${message}` : ''),
);
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
}
} catch (error) {
if (costRequestIdRef.current !== requestId) {
@@ -192,9 +188,7 @@ const ExtendDurationModal = ({
const message = error?.response?.data?.message || error.message || '';
setPriceEstimation(null);
setPriceError(
t('价格计算失败') + (message ? `: ${message}` : ''),
);
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
} finally {
if (costRequestIdRef.current === requestId) {
setCostLoading(false);
@@ -269,11 +263,8 @@ const ExtendDurationModal = ({
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
const priceData = priceEstimation || {};
const breakdown =
priceData.price_breakdown || priceData.PriceBreakdown || {};
const currencyLabel = (
priceData.currency || priceData.Currency || 'USDC'
)
const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
.toString()
.toUpperCase();
@@ -316,7 +307,10 @@ const ExtendDurationModal = ({
confirmLoading={loading}
okButtonProps={{
disabled:
!deployment?.id || detailsLoading || !durationHours || durationHours < 1,
!deployment?.id ||
detailsLoading ||
!durationHours ||
durationHours < 1,
}}
width={600}
className='extend-duration-modal'
@@ -357,9 +351,7 @@ const ExtendDurationModal = ({
<p>
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
</p>
<p>
{t('延长操作一旦确认无法撤销,费用将立即扣除。')}
</p>
<p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p>
</div>
}
/>
@@ -370,7 +362,9 @@ const ExtendDurationModal = ({
onValueChange={(values) => {
if (values.duration_hours !== undefined) {
const numericValue = Number(values.duration_hours);
setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
setDurationHours(
Number.isFinite(numericValue) ? numericValue : 0,
);
}
}}
>

View File

@@ -378,7 +378,12 @@ const EditTokenModal = (props) => {
/>
)}
</Col>
<Col span={24} style={{ display: values.group === 'auto' ? 'block' : 'none' }}>
<Col
span={24}
style={{
display: values.group === 'auto' ? 'block' : 'none',
}}
>
<Form.Switch
field='cross_group_retry'
label={t('跨分组重试')}
@@ -561,7 +566,9 @@ const EditTokenModal = (props) => {
placeholder={t('允许的IP一行一个不填写则不限制')}
autosize
rows={1}
extraText={t('请勿过度信任此功能IP可能被伪造请配合nginx和cdn等网关使用')}
extraText={t(
'请勿过度信任此功能IP可能被伪造请配合nginx和cdn等网关使用',
)}
showClear
style={{ width: '100%' }}
/>

View File

@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import {
Avatar,
Button,
Space,
Tag,
Tooltip,
@@ -71,6 +72,34 @@ function formatRatio(ratio) {
return String(ratio);
}
function buildChannelAffinityTooltip(affinity, t) {
if (!affinity) {
return null;
}
const keySource = affinity.key_source || '-';
const keyPath = affinity.key_path || affinity.key_key || '-';
const keyHint = affinity.key_hint || '';
const keyFp = affinity.key_fp ? `#${affinity.key_fp}` : '';
const keyText = `${keySource}:${keyPath}${keyFp}`;
const lines = [
t('渠道亲和性'),
`${t('规则')}${affinity.rule_name || '-'}`,
`${t('分组')}${affinity.selected_group || '-'}`,
`${t('Key')}${keyText}`,
...(keyHint ? [`${t('Key 摘要')}${keyHint}`] : []),
];
return (
<div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
{lines.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
);
}
// Render functions
function renderType(type, t) {
switch (type) {
@@ -250,6 +279,7 @@ export const getLogsColumns = ({
COLUMN_KEYS,
copyText,
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
}) => {
return [
@@ -532,42 +562,39 @@ export const getLogsColumns = ({
return isAdminUser ? (
<Space>
<div>{content}</div>
{affinity ? (
<Tooltip
content={
<div style={{ lineHeight: 1.6 }}>
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
<div>
<Typography.Text type='secondary'>
{t('规则')}{affinity.rule_name || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type='secondary'>
{t('分组')}{affinity.selected_group || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type='secondary'>
{t('Key')}
{(affinity.key_source || '-') +
':' +
(affinity.key_path || affinity.key_key || '-') +
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
</Typography.Text>
{affinity ? (
<Tooltip
content={
<div>
{buildChannelAffinityTooltip(affinity, t)}
<div style={{ marginTop: 6 }}>
<Button
theme='borderless'
size='small'
onClick={(e) => {
e.stopPropagation();
openChannelAffinityUsageCacheModal?.(affinity);
}}
>
{t('查看详情')}
</Button>
</div>
</div>
}
>
<span>
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
<span className='channel-affinity-tag-content'>
<IconStarStroked style={{ fontSize: 13 }} />
{t('优选')}
</span>
</Tag>
</span>
</Tooltip>
</div>
}
>
<span>
<Tag
className='channel-affinity-tag'
color='cyan'
shape='circle'
>
<span className='channel-affinity-tag-content'>
<IconStarStroked style={{ fontSize: 13 }} />
{t('优选')}
</span>
</Tag>
</span>
</Tooltip>
) : null}
</Space>
) : (

View File

@@ -40,6 +40,7 @@ const LogsTable = (logsData) => {
handlePageSizeChange,
copyText,
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
hasExpandableRows,
isAdminUser,
t,
@@ -53,9 +54,17 @@ const LogsTable = (logsData) => {
COLUMN_KEYS,
copyText,
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
});
}, [t, COLUMN_KEYS, copyText, showUserInfoFunc, isAdminUser]);
}, [
t,
COLUMN_KEYS,
copyText,
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {

View File

@@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions';
import LogsFilters from './UsageLogsFilters';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import UserInfoModal from './modals/UserInfoModal';
import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
@@ -37,6 +38,7 @@ const LogsPage = () => {
{/* Modals */}
<ColumnSelectorModal {...logsData} />
<UserInfoModal {...logsData} />
<ChannelAffinityUsageCacheModal {...logsData} />
{/* Main Content */}
<CardPro

View File

@@ -0,0 +1,200 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Modal, Descriptions, Spin, Typography } from '@douyinfe/semi-ui';
import { API, showError, timestamp2string } from '../../../../helpers';
const { Text } = Typography;
function formatRate(hit, total) {
if (!total || total <= 0) return '-';
const r = (Number(hit || 0) / Number(total || 0)) * 100;
if (!Number.isFinite(r)) return '-';
return `${r.toFixed(2)}%`;
}
function formatTokenRate(n, d) {
const nn = Number(n || 0);
const dd = Number(d || 0);
if (!dd || dd <= 0) return '-';
const r = (nn / dd) * 100;
if (!Number.isFinite(r)) return '-';
return `${r.toFixed(2)}%`;
}
const ChannelAffinityUsageCacheModal = ({
t,
showChannelAffinityUsageCacheModal,
setShowChannelAffinityUsageCacheModal,
channelAffinityUsageCacheTarget,
}) => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState(null);
const requestSeqRef = useRef(0);
const params = useMemo(() => {
const x = channelAffinityUsageCacheTarget || {};
return {
rule_name: (x.rule_name || '').trim(),
using_group: (x.using_group || '').trim(),
key_hint: (x.key_hint || '').trim(),
key_fp: (x.key_fp || '').trim(),
};
}, [channelAffinityUsageCacheTarget]);
useEffect(() => {
if (!showChannelAffinityUsageCacheModal) {
requestSeqRef.current += 1; // invalidate inflight request
setLoading(false);
setStats(null);
return;
}
if (!params.rule_name || !params.key_fp) {
setLoading(false);
setStats(null);
return;
}
const reqSeq = (requestSeqRef.current += 1);
setStats(null);
setLoading(true);
(async () => {
try {
const res = await API.get('/api/log/channel_affinity_usage_cache', {
params,
disableDuplicate: true,
});
if (reqSeq !== requestSeqRef.current) return;
const { success, message, data } = res.data || {};
if (!success) {
setStats(null);
showError(t(message || '请求失败'));
return;
}
setStats(data || {});
} catch (e) {
if (reqSeq !== requestSeqRef.current) return;
setStats(null);
showError(t('请求失败'));
} finally {
if (reqSeq !== requestSeqRef.current) return;
setLoading(false);
}
})();
}, [
showChannelAffinityUsageCacheModal,
params.rule_name,
params.using_group,
params.key_hint,
params.key_fp,
t,
]);
const rows = useMemo(() => {
const s = stats || {};
const hit = Number(s.hit || 0);
const total = Number(s.total || 0);
const windowSeconds = Number(s.window_seconds || 0);
const lastSeenAt = Number(s.last_seen_at || 0);
const promptTokens = Number(s.prompt_tokens || 0);
const completionTokens = Number(s.completion_tokens || 0);
const totalTokens = Number(s.total_tokens || 0);
const cachedTokens = Number(s.cached_tokens || 0);
const promptCacheHitTokens = Number(s.prompt_cache_hit_tokens || 0);
return [
{ key: t('规则'), value: s.rule_name || params.rule_name || '-' },
{ key: t('分组'), value: s.using_group || params.using_group || '-' },
{
key: t('Key 摘要'),
value: params.key_hint || '-',
},
{
key: t('Key 指纹'),
value: s.key_fp || params.key_fp || '-',
},
{ key: t('TTL'), value: windowSeconds > 0 ? windowSeconds : '-' },
{
key: t('命中率'),
value: `${hit}/${total} (${formatRate(hit, total)})`,
},
{
key: t('Prompt tokens'),
value: promptTokens,
},
{
key: t('Cached tokens'),
value: `${cachedTokens} (${formatTokenRate(cachedTokens, promptTokens)})`,
},
{
key: t('Prompt cache hit tokens'),
value: promptCacheHitTokens,
},
{
key: t('Completion tokens'),
value: completionTokens,
},
{
key: t('Total tokens'),
value: totalTokens,
},
{
key: t('最近一次'),
value: lastSeenAt > 0 ? timestamp2string(lastSeenAt) : '-',
},
];
}, [stats, params, t]);
return (
<Modal
title={t('渠道亲和性:上游缓存命中')}
visible={showChannelAffinityUsageCacheModal}
onCancel={() => setShowChannelAffinityUsageCacheModal(false)}
footer={null}
centered
closable
maskClosable
width={640}
>
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 12 }}>
<Text type='tertiary' size='small'>
{t(
'命中判定usage 中存在 cached tokens例如 cached_tokens/prompt_cache_hit_tokens即视为命中。',
)}
</Text>
</div>
<Spin spinning={loading} tip={t('加载中...')}>
{stats ? (
<Descriptions data={rows} />
) : (
<div style={{ padding: '24px 0' }}>
<Text type='tertiary' size='small'>
{loading ? t('加载中...') : t('暂无数据')}
</Text>
</div>
)}
</Spin>
</div>
</Modal>
);
};
export default ChannelAffinityUsageCacheModal;