mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
feat(claude): add ClaudeResetGrantDetails component and related tests; update styles and translations
This commit is contained in:
@@ -259,8 +259,9 @@
|
||||
|
||||
.codexResetCreditLabel {
|
||||
color: var(--text-tertiary);
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.codexResetCreditTime {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { bindQuotaClasses } from '../types';
|
||||
import { QUOTA_ADAPTERS, type QuotaCardState } from '../providers';
|
||||
import { isQuotaRefreshDisabled, type QuotaFileEntry } from '../logic';
|
||||
import { useClaudeResetGrants } from '../providers/claude/ClaudeResetGrants';
|
||||
import { ClaudeResetGrantDetails } from '../providers/claude/ClaudeResetGrantDetails';
|
||||
import bodyStyles from './QuotaBody.module.scss';
|
||||
import styles from './QuotaCard.module.scss';
|
||||
|
||||
@@ -121,6 +122,7 @@ export function QuotaCard(props: QuotaCardProps) {
|
||||
<span className={quotaClasses.codexPlanValue}>{claudeReset.count ?? '--'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ClaudeResetGrantDetails grants={claudeReset.grants} classes={quotaClasses} />
|
||||
{claudeReset.message && (
|
||||
<div role="status" className={quotaClasses.codexResetCreditsError}>
|
||||
{t(`claude_reset.${claudeReset.message}`)}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import type { AnthropicResetGrant } from '@/services/api/claudeResetGrants';
|
||||
import { buildResetDisplay, parseIsoToMs } from '@/utils/quota';
|
||||
import { resolveTimeZoneLabel } from '@/utils/time/timezone';
|
||||
import { QuotaResetLabel } from '../../components/QuotaResetLabel';
|
||||
import type { QuotaClassMap } from '../../types';
|
||||
|
||||
interface ClaudeResetGrantDetailsProps {
|
||||
grants: readonly AnthropicResetGrant[];
|
||||
classes: QuotaClassMap;
|
||||
}
|
||||
|
||||
/** Match Codex's reset-credit details without treating a multi-reset grant as one credit. */
|
||||
export function ClaudeResetGrantDetails({ grants, classes }: ClaudeResetGrantDetailsProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const now = useNow();
|
||||
const remaining = grants
|
||||
.filter((grant) => grant.resetsLeft > 0)
|
||||
.sort((a, b) => (parseIsoToMs(a.endsAt) ?? Infinity) - (parseIsoToMs(b.endsAt) ?? Infinity));
|
||||
|
||||
if (remaining.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={classes.codexResetCredits}>
|
||||
<div className={classes.codexResetCreditsTitle}>
|
||||
{t('claude_reset.expiry_title', { timezone: resolveTimeZoneLabel() })}
|
||||
</div>
|
||||
{remaining.map((grant, index) => {
|
||||
const display = buildResetDisplay(
|
||||
null,
|
||||
parseIsoToMs(grant.endsAt),
|
||||
now,
|
||||
i18n.resolvedLanguage
|
||||
);
|
||||
return (
|
||||
<div key={grant.id} className={classes.codexResetCreditRow}>
|
||||
<span className={classes.codexResetCreditLabel}>
|
||||
{grant.label || t('claude_reset.grant_number', { index: index + 1 })}
|
||||
{' · '}
|
||||
{t('claude_reset.count', { left: grant.resetsLeft, total: grant.resetsTotal })}
|
||||
</span>
|
||||
<span className={classes.codexResetCreditTime}>
|
||||
{display ? (
|
||||
<QuotaResetLabel display={display} classes={classes} />
|
||||
) : (
|
||||
<span className={classes.quotaReset}>{t('claude_reset.expiry_unknown')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export function useClaudeResetGrants(
|
||||
};
|
||||
return {
|
||||
count: status?.grants.reduce((sum, grant) => sum + grant.resetsLeft, 0) ?? null,
|
||||
grants: status?.grants ?? [],
|
||||
busy,
|
||||
blocked,
|
||||
confirm,
|
||||
|
||||
@@ -2205,6 +2205,9 @@
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"expiry_title": "Manual reset expiry ({{timezone}})",
|
||||
"grant_number": "Grant {{index}}",
|
||||
"expiry_unknown": "Expiry unknown",
|
||||
"retry_confirm": "Retry the unknown reset for “{{name}}” with its original request ID? A reset may already have been spent. Retry within 10 minutes; do not reload or start another claim in another tab.",
|
||||
"remaining": "Resets remaining",
|
||||
"title": "Banked resets",
|
||||
|
||||
@@ -2199,6 +2199,9 @@
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"expiry_title": "Срок действия ручных сбросов ({{timezone}})",
|
||||
"grant_number": "Пакет {{index}}",
|
||||
"expiry_unknown": "Срок действия неизвестен",
|
||||
"retry_confirm": "Повторить сброс «{{name}}» с неизвестным результатом и исходным ID запроса? Сброс мог быть потрачен. Повторите в течение 10 минут; не перезагружайте страницу и не создавайте запрос в другой вкладке.",
|
||||
"remaining": "Осталось сбросов",
|
||||
"title": "Запас сбросов",
|
||||
|
||||
@@ -2210,6 +2210,9 @@
|
||||
"remaining": "Còn lại {{percent}}%"
|
||||
},
|
||||
"claude_reset": {
|
||||
"expiry_title": "Hết hạn đặt lại thủ công ({{timezone}})",
|
||||
"grant_number": "Gói {{index}}",
|
||||
"expiry_unknown": "Chưa rõ thời gian hết hạn",
|
||||
"retry_confirm": "Thử lại lượt đặt lại chưa rõ kết quả cho “{{name}}” với ID yêu cầu ban đầu? Một lượt đặt lại có thể đã được tiêu tốn. Hãy thử lại trong vòng 10 phút; không tải lại trang hoặc bắt đầu nhận ở tab khác.",
|
||||
"remaining": "Lượt đặt lại còn lại",
|
||||
"title": "Lượt đặt lại dự trữ",
|
||||
|
||||
@@ -2204,6 +2204,9 @@
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"expiry_title": "主动重置过期时间({{timezone}})",
|
||||
"grant_number": "第 {{index}} 批",
|
||||
"expiry_unknown": "到期时间未知",
|
||||
"retry_confirm": "使用原请求 ID 重试“{{name}}”结果未知的重置?可能已消耗一次重置。请在 10 分钟内重试;请勿重新加载页面或在其他标签页发起新请求。",
|
||||
"remaining": "剩余重置次数",
|
||||
"title": "储备重置次数",
|
||||
|
||||
@@ -2230,6 +2230,9 @@
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"expiry_title": "主動重置過期時間({{timezone}})",
|
||||
"grant_number": "第 {{index}} 批",
|
||||
"expiry_unknown": "到期時間未知",
|
||||
"retry_confirm": "使用原請求 ID 重試「{{name}}」結果未知的重置?可能已消耗一次重置。請在 10 分鐘內重試;請勿重新載入頁面或在其他分頁發起新請求。",
|
||||
"remaining": "剩餘重置次數",
|
||||
"title": "儲備重置次數",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import i18n from '@/i18n';
|
||||
import { ClaudeResetGrantDetails } from '@/features/quota/providers/claude/ClaudeResetGrantDetails';
|
||||
import { bindQuotaClasses, QUOTA_CLASS_KEYS } from '@/features/quota/types';
|
||||
import type { AnthropicResetGrant } from '@/services/api/claudeResetGrants';
|
||||
import { formatInstantShort } from '@/utils/quota';
|
||||
import { resolveTimeZoneLabel } from '@/utils/time/timezone';
|
||||
|
||||
const classes = bindQuotaClasses(
|
||||
Object.fromEntries(QUOTA_CLASS_KEYS.map((key) => [key, key])),
|
||||
'test-host'
|
||||
);
|
||||
const expiry = Date.now() + 11 * 86400_000;
|
||||
const grant = (overrides: Partial<AnthropicResetGrant> = {}): AnthropicResetGrant => ({
|
||||
id: 'launch',
|
||||
label: 'Launch',
|
||||
resetsTotal: 3,
|
||||
resetsLeft: 2,
|
||||
startsAt: null,
|
||||
endsAt: new Date(expiry).toISOString(),
|
||||
clears: ['five_hour'],
|
||||
paused: false,
|
||||
usableNow: true,
|
||||
useRequiresLimit: true,
|
||||
percentUsed: {},
|
||||
...overrides,
|
||||
});
|
||||
const render = (grants: AnthropicResetGrant[]) =>
|
||||
renderToStaticMarkup(createElement(ClaudeResetGrantDetails, { grants, classes }));
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
describe('Claude reset grant details', () => {
|
||||
test('uses Codex detail classes, timezone, remaining count and live relative date', () => {
|
||||
const markup = render([grant()]);
|
||||
expect(markup).toContain('class="codexResetCredits"');
|
||||
expect(markup).toContain('class="codexResetCreditRow"');
|
||||
expect(markup).toContain(resolveTimeZoneLabel());
|
||||
expect(markup).toContain('Launch · 2 / 3 resets remaining');
|
||||
expect(markup).toContain(formatInstantShort(expiry));
|
||||
expect(markup).toContain('class="quotaResetRelative"');
|
||||
expect(markup).toContain('in 11 days');
|
||||
});
|
||||
|
||||
test('hides empty and spent grants', () => {
|
||||
expect(render([])).toBe('');
|
||||
expect(render([grant({ resetsLeft: 0 })])).toBe('');
|
||||
expect(render([grant(), grant({ id: 'spent', label: 'Spent', resetsLeft: 0 })])).not.toContain(
|
||||
'Spent'
|
||||
);
|
||||
});
|
||||
|
||||
test('sorts by deadline without mutating input and keeps unknown dates last', () => {
|
||||
const grants = [
|
||||
grant({ id: 'unknown', label: 'Unknown', endsAt: null }),
|
||||
grant({ id: 'later', label: 'Later', endsAt: new Date(expiry + 86400_000).toISOString() }),
|
||||
grant({ id: 'first', label: 'First' }),
|
||||
];
|
||||
const markup = render(grants);
|
||||
expect(markup.indexOf('First')).toBeLessThan(markup.indexOf('Later'));
|
||||
expect(markup.indexOf('Later')).toBeLessThan(markup.indexOf('Unknown'));
|
||||
expect(markup).toContain('Expiry unknown');
|
||||
expect(grants.map((item) => item.id)).toEqual(['unknown', 'later', 'first']);
|
||||
});
|
||||
|
||||
test('keeps paused and expired unspent grants visible without implying usability', () => {
|
||||
const markup = render([
|
||||
grant({ paused: true, usableNow: false, endsAt: '2020-01-01T00:00:00Z' }),
|
||||
]);
|
||||
expect(markup).toContain(formatInstantShort(Date.parse('2020-01-01T00:00:00Z')));
|
||||
expect(markup).not.toContain('<button');
|
||||
});
|
||||
|
||||
test.each(['en', 'zh-CN', 'zh-TW', 'ru', 'vi'])(
|
||||
'translates fallback labels in %s',
|
||||
async (lng) => {
|
||||
await i18n.changeLanguage(lng);
|
||||
try {
|
||||
const markup = render([grant({ label: '', endsAt: null })]);
|
||||
expect(markup).toContain(i18n.t('claude_reset.grant_number', { index: 1 }));
|
||||
expect(markup).toContain(i18n.t('claude_reset.expiry_unknown'));
|
||||
expect(markup).not.toContain('claude_reset.');
|
||||
} finally {
|
||||
await i18n.changeLanguage('en');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user