[codex] improve proxy stability, routing, and site subscription visibility (#194)
* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy
This commit is contained in:
@@ -1917,6 +1917,52 @@ body {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.site-balance-cell {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.site-balance-inline {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.site-balance-inline.align-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.site-balance-primary {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.site-balance-divider {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.site-balance-subscription {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.35;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: normal;
|
||||
text-wrap: pretty;
|
||||
cursor: help;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.site-balance-subscription:hover,
|
||||
.site-balance-subscription:focus-visible {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.sites-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
+143
-14
@@ -23,6 +23,16 @@ import {
|
||||
type SiteForm,
|
||||
} from './helpers/sitesEditor.js';
|
||||
|
||||
type SiteSubscriptionSummary = {
|
||||
activeCount: number;
|
||||
totalUsedUsd: number;
|
||||
totalMonthlyLimitUsd?: number | null;
|
||||
totalRemainingUsd?: number | null;
|
||||
nextExpiresAt?: string | null;
|
||||
planNames?: string[];
|
||||
updatedAt?: number | null;
|
||||
};
|
||||
|
||||
type SiteRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -36,6 +46,7 @@ type SiteRow = {
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
totalBalance?: number;
|
||||
subscriptionSummary?: SiteSubscriptionSummary | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
@@ -43,6 +54,105 @@ function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean {
|
||||
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
|
||||
}
|
||||
|
||||
function formatUsd(value?: number | null): string {
|
||||
return `$${(value || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatSubscriptionDate(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isFinite(parsed)) return value;
|
||||
return new Date(parsed).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatRemainingDuration(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const targetMs = Date.parse(value);
|
||||
if (!Number.isFinite(targetMs)) return null;
|
||||
const deltaMs = targetMs - Date.now();
|
||||
if (deltaMs <= 0) return '已到期';
|
||||
|
||||
const minuteMs = 60 * 1000;
|
||||
const hourMs = 60 * minuteMs;
|
||||
const dayMs = 24 * hourMs;
|
||||
if (deltaMs >= dayMs) return `剩余${Math.ceil(deltaMs / dayMs)}天`;
|
||||
if (deltaMs >= hourMs) return `剩余${Math.ceil(deltaMs / hourMs)}小时`;
|
||||
if (deltaMs >= minuteMs) return `剩余${Math.ceil(deltaMs / minuteMs)}分钟`;
|
||||
return `剩余${Math.max(1, Math.ceil(deltaMs / 1000))}秒`;
|
||||
}
|
||||
|
||||
function buildSubscriptionInlineValue(summary?: SiteSubscriptionSummary | null): string | null {
|
||||
if (!summary) return null;
|
||||
const remainingValue = typeof summary.totalRemainingUsd === 'number' && Number.isFinite(summary.totalRemainingUsd)
|
||||
? formatUsd(summary.totalRemainingUsd)
|
||||
: '--';
|
||||
const usedValue = formatUsd(summary.totalUsedUsd);
|
||||
const remainingDuration = formatRemainingDuration(summary.nextExpiresAt);
|
||||
const remainingSuffix = remainingDuration ? `(${remainingDuration})` : '';
|
||||
if (usedValue === '$0.00' && remainingValue === '--' && !remainingSuffix) return null;
|
||||
return `${remainingValue}${remainingSuffix}`;
|
||||
}
|
||||
|
||||
function buildSubscriptionTooltip(summary?: SiteSubscriptionSummary | null): string | null {
|
||||
if (!summary) return null;
|
||||
const parts: string[] = [];
|
||||
if (summary.activeCount > 0) parts.push(`生效订阅 ${summary.activeCount} 个`);
|
||||
|
||||
const planNames = Array.isArray(summary.planNames)
|
||||
? summary.planNames.filter((item) => typeof item === 'string' && item.trim())
|
||||
: [];
|
||||
if (planNames.length > 0) parts.push(`套餐 ${planNames.join(' / ')}`);
|
||||
|
||||
if (typeof summary.totalRemainingUsd === 'number' && Number.isFinite(summary.totalRemainingUsd)) {
|
||||
parts.push(`订阅余额 ${formatUsd(summary.totalRemainingUsd)}`);
|
||||
}
|
||||
parts.push(`已用 ${formatUsd(summary.totalUsedUsd)}`);
|
||||
|
||||
if (typeof summary.totalMonthlyLimitUsd === 'number' && Number.isFinite(summary.totalMonthlyLimitUsd)) {
|
||||
parts.push(`总额度 ${formatUsd(summary.totalMonthlyLimitUsd)}`);
|
||||
}
|
||||
|
||||
const remainingDuration = formatRemainingDuration(summary.nextExpiresAt);
|
||||
if (remainingDuration) parts.push(remainingDuration);
|
||||
|
||||
if (summary.nextExpiresAt) parts.push(`到期 ${formatSubscriptionDate(summary.nextExpiresAt)}`);
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
function SiteBalanceDisplay(props: {
|
||||
balance?: number | null;
|
||||
summary?: SiteSubscriptionSummary | null;
|
||||
align?: 'start' | 'end';
|
||||
}) {
|
||||
const { balance, summary, align = 'start' } = props;
|
||||
const walletBalanceText = formatUsd(balance);
|
||||
const subscriptionValue = buildSubscriptionInlineValue(summary);
|
||||
const tooltip = buildSubscriptionTooltip(summary);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`site-balance-inline ${align === 'end' ? 'align-end' : ''}`.trim()}
|
||||
>
|
||||
<span className="site-balance-primary">{walletBalanceText}</span>
|
||||
{subscriptionValue ? (
|
||||
<>
|
||||
<span className="site-balance-divider">/</span>
|
||||
<span
|
||||
className="site-balance-subscription"
|
||||
data-tooltip={tooltip || undefined}
|
||||
data-tooltip-align={align === 'end' ? 'end' : 'start'}
|
||||
data-tooltip-side="top"
|
||||
tabIndex={tooltip ? 0 : undefined}
|
||||
>
|
||||
{subscriptionValue}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const platformColors: Record<string, string> = {
|
||||
'new-api': 'badge-info',
|
||||
'one-api': 'badge-success',
|
||||
@@ -873,7 +983,16 @@ export default function Sites() {
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<MobileField label="余额" value={`$${(site.totalBalance || 0).toFixed(2)}`} />
|
||||
<MobileField
|
||||
label="余额"
|
||||
value={(
|
||||
<SiteBalanceDisplay
|
||||
balance={site.totalBalance}
|
||||
summary={site.subscriptionSummary}
|
||||
align="end"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
@@ -1017,17 +1136,24 @@ export default function Sites() {
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--color-text-primary)',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{site.name}
|
||||
</a>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--color-text-primary)',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{site.name}
|
||||
</a>
|
||||
{hasConfiguredCustomHeaders(site.customHeaders) ? (
|
||||
<span className="badge badge-info" style={{ fontSize: 11 }}>
|
||||
自定义头
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||
{site.externalCheckinUrl ? (
|
||||
@@ -1048,8 +1174,11 @@ export default function Sites() {
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
${(site.totalBalance || 0).toFixed(2)}
|
||||
<td className="site-balance-cell">
|
||||
<SiteBalanceDisplay
|
||||
balance={site.totalBalance}
|
||||
summary={site.subscriptionSummary}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
||||
|
||||
@@ -88,6 +88,24 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
|
||||
advancedOpen: false,
|
||||
};
|
||||
|
||||
function normalizeRouteRoutingStrategyValue(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
|
||||
if (value === 'round_robin' || value === 'stable_first') return value;
|
||||
return 'weighted';
|
||||
}
|
||||
|
||||
function getRouteRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
|
||||
const strategy = normalizeRouteRoutingStrategyValue(value);
|
||||
if (strategy === 'round_robin') return tr('轮询');
|
||||
if (strategy === 'stable_first') return tr('稳定优先');
|
||||
return tr('权重随机');
|
||||
}
|
||||
|
||||
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
|
||||
if (value === 'round_robin') return '已切换为轮询策略';
|
||||
if (value === 'stable_first') return '已切换为稳定优先策略';
|
||||
return '已切换为权重随机策略';
|
||||
}
|
||||
|
||||
export default function TokenRoutes() {
|
||||
const navigate = useNavigate();
|
||||
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
||||
@@ -421,7 +439,7 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
const handleRoutingStrategyChange = async (route: RouteSummaryRow, routingStrategy: RouteRoutingStrategy) => {
|
||||
const currentStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
||||
const currentStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy);
|
||||
if (routingStrategy === currentStrategy) return;
|
||||
|
||||
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: true }));
|
||||
@@ -432,7 +450,7 @@ export default function TokenRoutes() {
|
||||
)));
|
||||
try {
|
||||
await api.updateRoute(route.id, { routingStrategy });
|
||||
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
|
||||
toast.success(getRouteRoutingStrategySuccessMessage(routingStrategy));
|
||||
} catch (e: any) {
|
||||
setRouteSummaries((prev) => prev.map((item) => (
|
||||
item.id === route.id
|
||||
@@ -1186,7 +1204,7 @@ export default function TokenRoutes() {
|
||||
>
|
||||
<MobileField label="模型" value={route.modelPattern} />
|
||||
<MobileField label="通道" value={route.channelCount} />
|
||||
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : (route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机'))} />
|
||||
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : getRouteRoutingStrategyLabel(route.routingStrategy)} />
|
||||
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
|
||||
@@ -72,6 +72,18 @@ type RouteCardProps = {
|
||||
onToggleSourceGroup: (groupKey: string) => void;
|
||||
};
|
||||
|
||||
function normalizeRoutingStrategy(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
|
||||
if (value === 'round_robin' || value === 'stable_first') return value;
|
||||
return 'weighted';
|
||||
}
|
||||
|
||||
function getRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
|
||||
const strategy = normalizeRoutingStrategy(value);
|
||||
if (strategy === 'round_robin') return tr('轮询');
|
||||
if (strategy === 'stable_first') return tr('稳定优先');
|
||||
return tr('权重随机');
|
||||
}
|
||||
|
||||
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
|
||||
const presence = useAnimatedVisibility(open, 220);
|
||||
if (!presence.shouldRender) return null;
|
||||
@@ -120,7 +132,7 @@ function RouteCardInner({
|
||||
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
||||
const channelManagementDisabled = explicitGroupRoute;
|
||||
const title = resolveRouteTitle(route);
|
||||
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
||||
const routingStrategy = normalizeRoutingStrategy(route.routingStrategy);
|
||||
const routingStrategyOptions = [
|
||||
{
|
||||
value: 'weighted',
|
||||
@@ -132,6 +144,11 @@ function RouteCardInner({
|
||||
label: tr('轮询'),
|
||||
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
|
||||
},
|
||||
{
|
||||
value: 'stable_first',
|
||||
label: tr('稳定优先'),
|
||||
description: tr('按优先级优先选择当前最稳、最快、成功率更高的通道,不做随机分流'),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -228,7 +245,7 @@ function RouteCardInner({
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
|
||||
{getRoutingStrategyLabel(routingStrategy)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { BrandInfo } from '../../components/BrandIcon.js';
|
||||
export type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||
export type RouteSortDir = 'asc' | 'desc';
|
||||
export type GroupFilter = null | '__all__' | number;
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
|
||||
export type RouteRowKind = 'persisted' | 'zero_channel';
|
||||
export type RouteMode = 'pattern' | 'explicit_group';
|
||||
|
||||
|
||||
@@ -127,4 +127,53 @@ describe('TokenRoutes routing strategy updates', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports switching to stable_first and keeps the optimistic label when refresh fails', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandButton = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||
));
|
||||
await act(async () => {
|
||||
expandButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const stableFirstOption = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
&& collectText(node).includes('稳定优先')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
stableFirstOption.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { routingStrategy: 'stable_first' });
|
||||
|
||||
const strategyTrigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
&& collectText(node).includes('稳定优先')
|
||||
));
|
||||
expect(collectText(strategyTrigger)).toContain('稳定优先');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user