mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-01 15:34:05 +08:00
feat(quota): replace soonest recovery logic with urgent recovery emphasis
This commit is contained in:
@@ -72,15 +72,6 @@ button.quotaMessageAction {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Compact host: same contract class, tighter gutter. */
|
||||
.quotaRowSoon {
|
||||
padding: 3px 6px;
|
||||
margin: 0 -6px;
|
||||
border-left: 2px solid var(--warning-border);
|
||||
border-radius: $radius-sm;
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -20,17 +20,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The limit that governs what this credential can do next.
|
||||
Deliberately no transition: the emphasis moves to another row the moment a
|
||||
window resets, and cross-fading a target that jumps reads as flicker. */
|
||||
.quotaRowSoon {
|
||||
padding: 4px 8px;
|
||||
margin: 0 -8px;
|
||||
border-left: 2px solid var(--warning-border);
|
||||
border-radius: $radius-sm;
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AntigravityQuotaState, AntigravityQuotaSubscription } from '@/types';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { collectQuotaRowInstants, pickSoonestRowId } from '../../resetSchedule';
|
||||
import { collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps } from '../../types';
|
||||
import { getNextAntigravityCountdownUpdateDelay } from './countdown';
|
||||
|
||||
@@ -146,9 +146,9 @@ export function AntigravityQuotaBody({ quota, classes }: QuotaBodyProps<Antigrav
|
||||
}, [resetTimestamps, serverTimeOffsetMs]);
|
||||
|
||||
// Ranked against this provider's own server-corrected clock rather than the
|
||||
// shared one, so the highlight and the countdown beside it always agree.
|
||||
// shared one, so the final-hour warning and the countdown always agree.
|
||||
const soonestRowId = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('antigravity', quota), nowMs),
|
||||
() => pickUrgentRowId(collectQuotaRowInstants('antigravity', quota), nowMs),
|
||||
[quota, nowMs]
|
||||
);
|
||||
|
||||
@@ -208,12 +208,7 @@ export function AntigravityQuotaBody({ quota, classes }: QuotaBodyProps<Antigrav
|
||||
const soon = bucket.id === soonestRowId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bucket.id}
|
||||
className={
|
||||
soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow
|
||||
}
|
||||
>
|
||||
<div key={bucket.id} className={classes.quotaRow}>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
<span className={classes.quotaModel} title={bucketDescription}>
|
||||
{bucketLabel}
|
||||
|
||||
@@ -9,14 +9,14 @@ import { buildResetDisplay } from '@/utils/quota';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { QuotaResetLabel } from '../../components/QuotaResetLabel';
|
||||
import { collectQuotaRowInstants, pickSoonestRowId } from '../../resetSchedule';
|
||||
import { collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps } from '../../types';
|
||||
|
||||
export function ClaudeQuotaBody({ quota, classes }: QuotaBodyProps<ClaudeQuotaState>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const now = useNow();
|
||||
const soonestRowId = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('claude', quota), now),
|
||||
() => pickUrgentRowId(collectQuotaRowInstants('claude', quota), now),
|
||||
[quota, now]
|
||||
);
|
||||
const windows = quota.windows ?? [];
|
||||
@@ -61,7 +61,7 @@ export function ClaudeQuotaBody({ quota, classes }: QuotaBodyProps<ClaudeQuotaSt
|
||||
return (
|
||||
<div
|
||||
key={window.id}
|
||||
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
|
||||
className={classes.quotaRow}
|
||||
title={soon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { formatDateTimeValue } from '@/utils/format';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { QuotaResetLabel } from '../../components/QuotaResetLabel';
|
||||
import { collectQuotaRowInstants, pickSoonestRowId, resetCreditRowId } from '../../resetSchedule';
|
||||
import { collectQuotaRowInstants, pickUrgentRowId, resetCreditRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps, QuotaClassMap } from '../../types';
|
||||
|
||||
const getPlanValueClass = (planType: string | null, classes: QuotaClassMap): string => {
|
||||
@@ -35,10 +35,10 @@ export function CodexQuotaBody({ quota, classes }: QuotaBodyProps<CodexQuotaStat
|
||||
const { t, i18n } = useTranslation();
|
||||
const now = useNow();
|
||||
const locale = i18n.resolvedLanguage;
|
||||
// Windows and reset credits compete for the same emphasis: a credit expiring
|
||||
// tonight matters more than a weekly window resetting on Friday.
|
||||
// Windows and reset credits compete for the same emphasis, but only during
|
||||
// the final hour before the reset or expiry.
|
||||
const soonestRowId = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('codex', quota), now),
|
||||
() => pickUrgentRowId(collectQuotaRowInstants('codex', quota), now),
|
||||
[quota, now]
|
||||
);
|
||||
const windows = quota.windows ?? [];
|
||||
@@ -171,7 +171,7 @@ export function CodexQuotaBody({ quota, classes }: QuotaBodyProps<CodexQuotaStat
|
||||
return (
|
||||
<div
|
||||
key={window.id}
|
||||
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
|
||||
className={classes.quotaRow}
|
||||
title={soon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { KimiQuotaState } from '@/types';
|
||||
import { formatKimiResetHint } from '@/utils/quota';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { collectQuotaRowInstants, pickSoonestRowId } from '../../resetSchedule';
|
||||
import { collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps } from '../../types';
|
||||
|
||||
export function KimiQuotaBody({ quota, classes }: QuotaBodyProps<KimiQuotaState>) {
|
||||
@@ -16,7 +16,7 @@ export function KimiQuotaBody({ quota, classes }: QuotaBodyProps<KimiQuotaState>
|
||||
// Ahead of the early return below — hooks cannot be conditional.
|
||||
const now = useNow();
|
||||
const soonestRowId = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('kimi', quota), now),
|
||||
() => pickUrgentRowId(collectQuotaRowInstants('kimi', quota), now),
|
||||
[quota, now]
|
||||
);
|
||||
const rows = quota.rows ?? [];
|
||||
@@ -46,7 +46,7 @@ export function KimiQuotaBody({ quota, classes }: QuotaBodyProps<KimiQuotaState>
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
|
||||
className={classes.quotaRow}
|
||||
title={soon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { buildResetDisplay, formatQuotaResetTime, parseIsoToMs } from '@/utils/q
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { QuotaResetLabel } from '../../components/QuotaResetLabel';
|
||||
import { XAI_WEEKLY_ROW_ID, collectQuotaRowInstants, pickSoonestRowId } from '../../resetSchedule';
|
||||
import { XAI_WEEKLY_ROW_ID, collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps } from '../../types';
|
||||
|
||||
const formatUsdFromCents = (cents: number | null): string => {
|
||||
@@ -71,7 +71,7 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
|
||||
// Only the weekly limit is a quota window; the monthly figure is a billing
|
||||
// cycle, so it is never the row that "recovers first".
|
||||
const weeklySoon = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('xai', quota), now) === XAI_WEEKLY_ROW_ID,
|
||||
() => pickUrgentRowId(collectQuotaRowInstants('xai', quota), now) === XAI_WEEKLY_ROW_ID,
|
||||
[quota, now]
|
||||
);
|
||||
const billing = quota.billing;
|
||||
@@ -149,7 +149,7 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
|
||||
)}
|
||||
{hasWeeklyData && (
|
||||
<div
|
||||
className={weeklySoon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
|
||||
className={classes.quotaRow}
|
||||
title={weeklySoon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import { parseIsoToMs } from '@/utils/quota';
|
||||
import { HOUR_MS } from '@/utils/time/durations';
|
||||
import type { QuotaProviderType } from './providers/types';
|
||||
|
||||
export interface QuotaRowInstant {
|
||||
@@ -156,6 +157,23 @@ export function pickSoonestRowId(
|
||||
return best?.rowId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recovery row whose countdown should receive warning emphasis.
|
||||
*
|
||||
* A nearest reset that is still hours or days away is informational, not
|
||||
* urgent. Only the final hour is highlighted, and exactly one hour remaining
|
||||
* is deliberately excluded to match the "less than one hour" UI rule.
|
||||
*/
|
||||
export function pickUrgentRowId(
|
||||
instants: readonly QuotaRowInstant[],
|
||||
nowMs: number
|
||||
): string | null {
|
||||
return pickSoonestRowId(
|
||||
instants.filter((instant) => instant.atMs - nowMs > 0 && instant.atMs - nowMs < HOUR_MS),
|
||||
nowMs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soonest upcoming recovery instant for a whole credential — the sort key for
|
||||
* "soonest recovery first". Null when nothing is loaded or nothing is pending.
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
export interface QuotaClassMap {
|
||||
// 额度行(五个提供商共用)
|
||||
quotaRow: string;
|
||||
/** Applied to the row that recovers first on this credential. */
|
||||
quotaRowSoon: string;
|
||||
quotaRowHeader: string;
|
||||
quotaModel: string;
|
||||
quotaMeta: string;
|
||||
@@ -52,7 +50,6 @@ export interface QuotaClassMap {
|
||||
|
||||
export const QUOTA_CLASS_KEYS: readonly (keyof QuotaClassMap)[] = [
|
||||
'quotaRow',
|
||||
'quotaRowSoon',
|
||||
'quotaRowHeader',
|
||||
'quotaModel',
|
||||
'quotaMeta',
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('CodexQuotaBody', () => {
|
||||
expect(markup).toMatch(/11 days/);
|
||||
});
|
||||
|
||||
test('highlights the credit when it expires before every window resets', () => {
|
||||
test('highlights a credit expiring within the final hour', () => {
|
||||
const creditFirst: CodexQuotaState = {
|
||||
...quota,
|
||||
windows: [{ ...quota.windows[0], resetAtMs: now + 5 * DAY_MS }],
|
||||
@@ -83,7 +83,7 @@ describe('CodexQuotaBody', () => {
|
||||
id: 'credit-1',
|
||||
status: 'available',
|
||||
grantedAt: new Date(now - DAY_MS).toISOString(),
|
||||
expiresAt: new Date(now + 2 * HOUR_MS).toISOString(),
|
||||
expiresAt: new Date(now + 30 * 60_000).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -95,13 +95,24 @@ describe('CodexQuotaBody', () => {
|
||||
expect(markup).not.toContain('quotaRowSoon');
|
||||
});
|
||||
|
||||
test('highlights the window when it resets before any credit expires', () => {
|
||||
test('does not emphasize a reset countdown more than one hour away', () => {
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota, classes }));
|
||||
|
||||
expect(markup).toContain('quotaRowSoon');
|
||||
expect(markup).not.toContain('quotaRowSoon');
|
||||
expect(markup).not.toContain('quotaResetRelativeSoon');
|
||||
expect(markup).not.toContain('codexResetCreditRowSoon');
|
||||
});
|
||||
|
||||
test('emphasizes a reset countdown within the final hour', () => {
|
||||
const urgent: CodexQuotaState = {
|
||||
...quota,
|
||||
windows: [{ ...quota.windows[0], resetAtMs: now + 30 * 60_000 }],
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota: urgent, classes }));
|
||||
|
||||
expect(markup).toContain('quotaResetRelativeSoon');
|
||||
});
|
||||
|
||||
test('highlights nothing once every instant is in the past', () => {
|
||||
const stale: CodexQuotaState = {
|
||||
...quota,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
collectQuotaRowInstants,
|
||||
nextRecoveryMs,
|
||||
pickSoonestRowId,
|
||||
pickUrgentRowId,
|
||||
resetCreditRowId,
|
||||
} from '@/features/quota/resetSchedule';
|
||||
import { DAY_MS, HOUR_MS } from '@/utils/time/durations';
|
||||
@@ -169,6 +170,30 @@ describe('pickSoonestRowId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickUrgentRowId', () => {
|
||||
test('highlights only the nearest reset strictly inside the final hour', () => {
|
||||
const instants = [
|
||||
{ rowId: 'later-urgent', atMs: NOW + 45 * 60_000, kind: 'window' as const },
|
||||
{ rowId: 'nearest-urgent', atMs: NOW + 30 * 60_000, kind: 'window' as const },
|
||||
{ rowId: 'past', atMs: NOW - 1, kind: 'window' as const },
|
||||
];
|
||||
|
||||
expect(pickUrgentRowId(instants, NOW)).toBe('nearest-urgent');
|
||||
});
|
||||
|
||||
test('does not highlight at exactly one hour or beyond', () => {
|
||||
expect(
|
||||
pickUrgentRowId(
|
||||
[
|
||||
{ rowId: 'exactly-one-hour', atMs: NOW + HOUR_MS, kind: 'window' },
|
||||
{ rowId: 'later', atMs: NOW + 2 * HOUR_MS, kind: 'window' },
|
||||
],
|
||||
NOW
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetCreditRowId', () => {
|
||||
test('prefers the credit id', () => {
|
||||
expect(resetCreditRowId({ id: 'credit-a', expiresAt: 'x' }, 3)).toBe('credit-a');
|
||||
|
||||
Reference in New Issue
Block a user