feat: implement Claude reset grants functionality with demo and tests

This commit is contained in:
Supra4E8C
2026-10-01 22:16:26 +08:00
parent d554bb0983
commit e5fb14c876
13 changed files with 504 additions and 167 deletions
+25
View File
@@ -0,0 +1,25 @@
# Original-app Claude reset demo
Open `http://localhost:5179/demo/claude-reset.html#/quota` with Vite running on port 5179
(`bun run dev -- --host 127.0.0.1 --port 5179`).
This entry loads the real `src/main.tsx`, App, MainLayout and quota route. There is
no demo UI, toolbar or stylesheet. Four synthetic Claude accounts populate the
normal quota cards and timeline:
- `alex.max`: usable grant; confirmation clears usage and spends one reset.
- `design.team`: exhausted grant.
- `sam.free`: ineligible account.
- `retry.max`: first claim has an unknown outcome; retry must reuse its request ID.
Use the existing “储备重置次数” controls. Reload the page to reset all fixtures.
Other providers are intentionally empty. This is a quota-focused fixture, not a
complete mock of every route in the app; unsupported requests fail closed.
Before any application import, the HTML replaces local/session storage with
memory-only stores and blocks fetch/XHR. CSP permits only the local Vite socket.
The entry installs an Axios adapter before loading the API client. All mock
credentials use `example.invalid`; no real management key is supplied. Both the
initial quota cache and refresh actions use the real quota parser and API modules.
Regression check: `bun test demo/claude-reset-mocks.test.ts`.
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test';
import axios from 'axios';
import { demoFiles, installDemoTransport } from './claude-reset-mocks';
describe('original-app demo transport', () => {
test('lists only synthetic accounts and rejects unimplemented requests', async () => {
const client = axios.create({ adapter: installDemoTransport() });
expect((await client.get('/credentials')).data.files).toEqual(demoFiles);
await expect(client.get('https://example.invalid/real-api')).rejects.toThrow('blocked');
await expect(client.delete('/credentials')).rejects.toThrow('blocked');
});
test('unknown outcome requires the same request identity; refresh reflects a single spend', async () => {
const client = axios.create({ adapter: installDemoTransport() });
const call = async (path: string, data?: string) =>
(
await client.post('/requests/api-call', {
authIndex: 'demo-ambiguous',
method: data ? 'POST' : 'GET',
url: `https://api.anthropic.com${path}`,
data,
})
).data.body;
const path = '/api/organizations/00000000-0000-4000-8000-000000000001/reset_rate_limits';
const claim = JSON.stringify({ grant_id: 'demo_grant', request_id: 'same-request' });
await expect(call(path, claim)).rejects.toThrow('unknown outcome');
await expect(
call(path, JSON.stringify({ grant_id: 'demo_grant', request_id: 'different' }))
).rejects.toThrow('identity changed');
expect(await call(path, claim)).toEqual({ result: 'reset' });
expect(await call(path, claim)).toEqual({ result: 'already_used' });
const usage = await call('/api/oauth/usage');
expect(usage.five_hour.utilization).toBe(0);
expect(usage.cedar_ember.grants[0].resets_left).toBe(1);
});
});
+154
View File
@@ -0,0 +1,154 @@
import type { AxiosAdapter } from 'axios';
import type { AuthFileItem } from '@/types';
import type { ApiCallRequest } from '@/services/api/apiCall';
type Scenario = 'available' | 'exhausted' | 'ineligible' | 'ambiguous';
const accounts: { email: string; scenario: Scenario }[] = [
{ email: 'alex.max@example.invalid', scenario: 'available' },
{ email: 'design.team@example.invalid', scenario: 'exhausted' },
{ email: 'sam.free@example.invalid', scenario: 'ineligible' },
{ email: 'retry.max@example.invalid', scenario: 'ambiguous' },
];
export const demoFiles: AuthFileItem[] = accounts.map(({ email, scenario }, index) => ({
name: `claude-${email}.json`,
email,
type: 'claude',
provider: 'claude',
authIndex: `demo-${scenario}`,
status: 'active',
disabled: false,
size: 1824 + index * 128,
}));
const isoAfter = (hours: number) => new Date(Date.now() + hours * 3600000).toISOString();
const organization = '00000000-0000-4000-8000-000000000001';
/** In-memory, fail-closed Axios transport. No request is forwarded to an adapter. */
export function installDemoTransport(): AxiosAdapter {
const states = new Map(
accounts.map(({ scenario }) => [
`demo-${scenario}`,
{
scenario,
left: scenario === 'exhausted' ? 0 : 2,
reset: false,
pending: '',
completed: new Set<string>(),
},
])
);
return async (config) => {
await new Promise((resolve) => setTimeout(resolve, 120));
const method = config.method?.toUpperCase();
const path = config.url;
let data: unknown;
if (method === 'GET' && path === '/config') {
data = {};
} else if (method === 'GET' && path === '/credentials') {
data = { files: demoFiles, total: demoFiles.length };
} else if (method === 'GET' && path === '/plugins') {
data = { plugins: [] };
} else if (method === 'POST' && path === '/requests/api-call') {
const payload = (
typeof config.data === 'string' ? JSON.parse(config.data) : config.data
) as ApiCallRequest;
const state = states.get(payload.authIndex ?? '');
if (!state) throw new Error('Demo: unknown account');
const url = new URL(payload.url);
if (url.origin !== 'https://api.anthropic.com') throw new Error('Demo: blocked upstream');
let body: unknown;
if (payload.method === 'GET' && url.pathname === '/api/oauth/profile') {
body = {
organization: {
uuid: organization,
organization_type: state.scenario === 'exhausted' ? 'claude_team' : 'claude_pro',
subscription_status: 'active',
},
account: { has_claude_max: state.scenario !== 'ineligible', has_claude_pro: false },
};
} else if (payload.method === 'GET' && url.pathname === '/api/oauth/usage') {
body = {
five_hour: {
utilization: state.reset ? 0 : state.scenario === 'ineligible' ? 38 : 100,
resets_at: isoAfter(2.5),
},
seven_day: {
utilization: state.reset ? 0 : state.scenario === 'exhausted' ? 96 : 72,
resets_at: isoAfter(96),
},
seven_day_sonnet: { utilization: state.reset ? 0 : 43, resets_at: isoAfter(96) },
extra_usage: {
is_enabled: state.scenario === 'available',
monthly_limit: 5000,
used_credits: 1250,
utilization: 25,
},
cedar_ember: {
eligible: state.scenario !== 'ineligible',
ineligible_reason: state.scenario === 'ineligible' ? 'tier' : null,
next_grant_id: 'demo_grant',
at_limit: !state.reset && state.scenario !== 'ineligible',
grants:
state.scenario === 'ineligible'
? []
: [
...(state.scenario === 'available'
? [
{
id: 'a_backup',
label: '备用重置',
resets_total: 1,
resets_left: 1,
usable_now: true,
use_requires_limit: true,
},
]
: []),
{
id: 'demo_grant',
label: 'Claude Max · 每周储备重置',
resets_total: 3,
resets_left: state.left,
ends_at: isoAfter(168),
clears: ['five_hour', 'seven_day'],
usable_now: true,
use_requires_limit: true,
},
],
},
};
} else if (
payload.method === 'POST' &&
url.pathname === `/api/organizations/${organization}/reset_rate_limits`
) {
const claim = JSON.parse(payload.data ?? '{}') as {
grant_id?: string;
request_id?: string;
};
if (claim.grant_id !== 'demo_grant' || !claim.request_id)
throw new Error('Demo: invalid claim');
if (state.completed.has(claim.request_id)) {
body = { result: 'already_used' };
} else if (state.scenario === 'ineligible' || state.left <= 0 || state.reset) {
throw new Error('Demo: unavailable grant');
} else {
if (state.scenario === 'ambiguous' && !state.pending) {
state.pending = claim.request_id;
throw new Error('Demo: simulated unknown outcome');
}
if (state.pending && state.pending !== claim.request_id)
throw new Error('Demo: retry identity changed');
state.completed.add(claim.request_id);
state.left -= 1;
state.reset = true;
body = { result: 'reset' };
}
} else {
throw new Error(`Demo: unsupported upstream route ${payload.method} ${url.pathname}`);
}
data = { status_code: 200, header: {}, body };
} else {
throw new Error(`Demo: blocked management route ${method} ${path}`);
}
return { data, status: 200, statusText: 'OK', headers: {}, config };
};
}
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLI Proxy API Management Center · Local Demo</title>
<link rel="icon" href="data:," />
<!-- Only Vite's local development socket may use a live connection. -->
<meta
http-equiv="Content-Security-Policy"
content="connect-src ws://127.0.0.1:5179 ws://localhost:5179; form-action 'none'"
/>
<script>
// Isolate persisted authentication before any application module is evaluated.
for (const name of ['localStorage', 'sessionStorage']) {
const values = new Map();
Object.defineProperty(window, name, {
value: {
getItem: (key) => values.get(String(key)) ?? null,
setItem: (key, value) => values.set(String(key), String(value)),
removeItem: (key) => values.delete(String(key)),
clear: () => values.clear(),
key: (index) => [...values.keys()][index] ?? null,
get length() {
return values.size;
},
},
});
}
// Application API calls are mocked; fail closed if another transport is used.
window.fetch = async () => {
throw new Error('Preview: network transport blocked');
};
XMLHttpRequest.prototype.send = function () {
throw new Error('Preview: network transport blocked');
};
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/demo/claude-reset.tsx"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
// Install the transport before importing any application module (including apiClient).
import axios from 'axios';
import { installDemoTransport, demoFiles } from './claude-reset-mocks';
axios.defaults.adapter = installDemoTransport();
async function start() {
const [
{ useAuthStore },
{ useThemeStore },
{ useLanguageStore },
{ useQuotaStore },
{ CLAUDE_CONFIG },
{ default: i18n },
] = await Promise.all([
import('@/stores/useAuthStore'),
import('@/stores/useThemeStore'),
import('@/stores/useLanguageStore'),
import('@/stores/useQuotaStore'),
import('@/features/quota/providers/claude/data'),
import('@/i18n'),
]);
useAuthStore.setState({
isAuthenticated: true,
connectionStatus: 'connected',
apiBase: 'http://demo.invalid',
managementKey: '',
rememberPassword: false,
serverVersion: 'v8.0.0-demo',
supportsPlugin: false,
});
useLanguageStore.getState().setLanguage('zh-CN');
useThemeStore.getState().setTheme('light');
// Populate the original quota cache through the same parser and mock transport
// used by the page's refresh actions, rather than replacing any UI component.
for (const file of demoFiles) {
const data = await CLAUDE_CONFIG.fetchQuota(file, i18n.t);
useQuotaStore.getState().setClaudeQuota((previous) => ({
...previous,
[file.name]: CLAUDE_CONFIG.buildSuccessState(data),
}));
}
if (!location.hash) location.hash = '/quota';
await import('@/main');
}
void start();
+34 -7
View File
@@ -22,7 +22,7 @@ import {
import { bindQuotaClasses } from '../types';
import { QUOTA_ADAPTERS, type QuotaCardState } from '../providers';
import { isQuotaRefreshDisabled, type QuotaFileEntry } from '../logic';
import { ClaudeResetGrants } from '../providers/claude/ClaudeResetGrants';
import { useClaudeResetGrants } from '../providers/claude/ClaudeResetGrants';
import bodyStyles from './QuotaBody.module.scss';
import styles from './QuotaCard.module.scss';
@@ -66,6 +66,13 @@ export function QuotaCard(props: QuotaCardProps) {
const status = quota?.status ?? 'idle';
const loading = status === 'loading';
const claudeReset = useClaudeResetGrants(
file,
entry.type === 'claude' && status !== 'idle',
!canRefresh || loading || resetting,
quota,
onRefresh
);
const iconSrc = getAuthFileIcon(entry.type, resolvedTheme);
const typeLabel = getTypeLabel(t, entry.type);
const errorMessage = resolveQuotaErrorMessage(
@@ -106,6 +113,21 @@ export function QuotaCard(props: QuotaCardProps) {
</header>
<div className={styles.body}>
{entry.type === 'claude' && status === 'success' && (
<>
<div className={quotaClasses.codexPlan}>
<span className={quotaClasses.codexPlanItem}>
<span className={quotaClasses.codexPlanLabel}>{t('claude_reset.remaining')}</span>
<span className={quotaClasses.codexPlanValue}>{claudeReset.count ?? '--'}</span>
</span>
</div>
{claudeReset.message && (
<div role="status" className={quotaClasses.codexResetCreditsError}>
{t(`claude_reset.${claudeReset.message}`)}
</div>
)}
</>
)}
{status === 'idle' ? (
<button
type="button"
@@ -140,11 +162,16 @@ export function QuotaCard(props: QuotaCardProps) {
{status !== 'idle' && (
<footer className={styles.actionRow}>
{entry.type === 'claude' && (
<ClaudeResetGrants
file={file}
disabled={!canRefresh || loading || resetting}
onRefresh={onRefresh}
/>
<button
type="button"
className={styles.actionPill}
disabled={claudeReset.blocked}
onClick={claudeReset.confirm}
title={t(`claude_reset.${claudeReset.buttonLabel}`)}
>
<IconRefreshCw size={13} className={claudeReset.busy ? styles.spinning : undefined} />
{t(`claude_reset.${claudeReset.buttonLabel}`)}
</button>
)}
{showReset && (
<button
@@ -162,7 +189,7 @@ export function QuotaCard(props: QuotaCardProps) {
type="button"
className={styles.actionPill}
onClick={onRefresh}
disabled={isQuotaRefreshDisabled(canRefresh, loading, resetting)}
disabled={isQuotaRefreshDisabled(canRefresh, loading, resetting || claudeReset.busy)}
title={t('auth_files.quota_refresh_hint')}
>
<IconRefreshCw size={13} className={loading ? styles.spinning : undefined} />
@@ -1,183 +1,121 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { useNow } from '@/hooks/useNow';
import { useAuthStore } from '@/stores/useAuthStore';
import { useNotificationStore } from '@/stores';
import { apiClient } from '@/services/api/client';
import {
anthropicResetGrantBlocker,
readClaudeResetGrants,
type AnthropicResetGrantStatus,
} from '@/services/api/claudeResetGrants';
import type { AuthFileItem } from '@/types';
import { normalizeAuthIndex } from '@/utils/quota';
import { resetGrantOperations, RETRY_WINDOW_MS } from './resetGrantOperations';
import { selectResetGrant } from './selectResetGrant';
export function ClaudeResetGrants({
file,
disabled: externallyDisabled,
onRefresh,
}: {
file: AuthFileItem;
disabled: boolean;
onRefresh: () => void;
}) {
/** Card-owned reads; the session-scoped journal owns spending and ambiguous retries. */
export function useClaudeResetGrants(
file: AuthFileItem,
enabled: boolean,
disabled: boolean,
refreshToken: unknown,
onRefresh: () => void
) {
const { t } = useTranslation();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [session] = useState(() => apiClient.getConnectionRevision());
const sessionActive =
connectionStatus === 'connected' && session === apiClient.getConnectionRevision();
const disabled = externallyDisabled || !sessionActive;
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const showNotification = useNotificationStore((state) => state.showNotification);
const now = useNow();
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
const [open, setOpen] = useState(false);
const key = JSON.stringify([file.name, authIndex]);
const [status, setStatus] = useState<AnthropicResetGrantStatus | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [reload, setReload] = useState(0);
const lock = useRef(false);
const generation = useRef(0);
useEffect(
() => () => {
useEffect(() => {
const version = ++generation.current;
setStatus(null);
if (!enabled || disabled || !sessionActive || !authIndex) return;
let cancelled = false;
const current = () =>
!cancelled && version === generation.current && session === apiClient.getConnectionRevision();
void readClaudeResetGrants(authIndex).then(
(result) => {
if (current()) {
setStatus(result);
setMessage('');
}
},
() => {
if (current()) setMessage('read_error');
}
);
return () => {
cancelled = true;
generation.current += 1;
},
[authIndex, file.name]
);
const key = JSON.stringify([file.name, authIndex]);
};
}, [authIndex, key, enabled, disabled, sessionActive, session, refreshToken, reload]);
const operation = resetGrantOperations.inspect(key);
const pending = operation && !operation.code ? operation : undefined;
const expired = pending && now - pending.createdAt >= RETRY_WINDOW_MS;
const load = async () => {
if (!authIndex || disabled || lock.current) return;
lock.current = true;
setBusy(true);
setOpen(true);
setStatus(null);
setSelected(null);
setMessage('');
const revision = apiClient.getConnectionRevision();
const expired = Boolean(pending && now - pending.createdAt >= RETRY_WINDOW_MS);
const selected = pending?.grantId ?? (status ? selectResetGrant(status, now)?.id : undefined);
const blocked = disabled || !sessionActive || !authIndex || busy || expired || !selected;
const confirm = () => {
if (blocked || lock.current || !selected || !authIndex) return;
const version = generation.current;
const current = () =>
revision === apiClient.getConnectionRevision() && version === generation.current;
try {
const result = await readClaudeResetGrants(authIndex);
if (current()) setStatus(result);
} catch {
if (current()) setMessage('read_error');
} finally {
lock.current = false;
if (current()) setBusy(false);
}
};
const consume = async () => {
if (!authIndex || !selected || disabled || lock.current) return;
lock.current = true;
setBusy(true);
setMessage('');
const revision = apiClient.getConnectionRevision();
const version = generation.current;
const current = () =>
revision === apiClient.getConnectionRevision() && version === generation.current;
try {
const answer = await resetGrantOperations.run(key, authIndex, selected);
if (!current()) return;
setMessage(answer.unresolved ? 'unknown' : answer.code);
setSelected(null);
setStatus(null);
onRefresh();
} catch {
if (current()) {
const unresolved = resetGrantOperations.inspect(key);
setMessage(unresolved && !unresolved.code ? 'unknown' : 'blocked');
setSelected(null);
}
} finally {
lock.current = false;
if (current()) setBusy(false);
}
};
return (
<>
<Button
size="sm"
variant="secondary"
disabled={disabled || !authIndex || busy}
onClick={() => void load()}
>
{t('claude_reset.title')}
</Button>
<Modal
open={open && sessionActive}
title={t('claude_reset.title')}
onClose={() => setOpen(false)}
closeDisabled={busy}
footer={
selected ? (
<Button disabled={disabled || busy} loading={busy} onClick={() => void consume()}>
{t('claude_reset.confirm')}
</Button>
) : undefined
session === apiClient.getConnectionRevision() && version === generation.current;
showConfirmation({
title: t('claude_reset.title'),
message: t(pending ? 'claude_reset.retry_confirm' : 'claude_reset.confirm_text', {
name: file.name,
}),
confirmText: t(pending ? 'claude_reset.retry' : 'claude_reset.confirm'),
variant: 'primary',
onConfirm: async () => {
if (!current() || lock.current || useAuthStore.getState().connectionStatus !== 'connected')
return;
lock.current = true;
setBusy(true);
try {
const answer = await resetGrantOperations.run(key, authIndex, selected);
if (!current()) return;
showNotification(
t(`claude_reset.${answer.unresolved ? 'unknown' : answer.code}`),
!answer.unresolved && (answer.code === 'reset' || answer.code === 'already_used')
? 'success'
: 'error'
);
} catch {
if (!current()) return;
const unresolved = resetGrantOperations.inspect(key);
showNotification(
t(`claude_reset.${unresolved && !unresolved.code ? 'unknown' : 'blocked'}`),
'error'
);
} finally {
lock.current = false;
// A concurrent page-wide refresh can invalidate this read generation.
// Release the local lock regardless, but never refresh a replacement account.
setBusy(false);
setReload((value) => value + 1);
if (current()) onRefresh();
}
>
<p>{t('claude_reset.scope')}</p>
<div role="status" aria-live="polite">
{busy && <p>{t('claude_reset.loading')}</p>}
{message && <p>{t(`claude_reset.${message}`)}</p>}
</div>
{pending ? (
<>
<p>{t(expired ? 'claude_reset.expired' : 'claude_reset.unknown')}</p>
<Button
variant="secondary"
disabled={Boolean(expired) || busy || disabled}
onClick={() => setSelected(pending.grantId)}
>
{t('claude_reset.retry')}
</Button>
</>
) : (
status && (
<>
{!status.eligible && <p>{t('claude_reset.ineligible')}</p>}
{status.grants.length === 0 && <p>{t('claude_reset.empty')}</p>}
{status.grants.map((grant) => (
<div key={grant.id}>
<p>
{grant.label || grant.id} —{' '}
{t('claude_reset.count', { left: grant.resetsLeft, total: grant.resetsTotal })}
</p>
{grant.endsAt && (
<p>
{t('claude_reset.ends', { date: new Date(grant.endsAt).toLocaleString() })}
</p>
)}
<Button
variant="secondary"
size="sm"
disabled={
busy || disabled || Boolean(anthropicResetGrantBlocker(status, grant.id))
}
onClick={() => setSelected(grant.id)}
>
{t('claude_reset.use')}
</Button>
</div>
))}
</>
)
)}
{selected && (
<p>
{t('claude_reset.confirm_text', {
grant: status?.grants.find((grant) => grant.id === selected)?.label || selected,
})}
</p>
)}
</Modal>
</>
);
},
});
};
return {
count: status?.grants.reduce((sum, grant) => sum + grant.resetsLeft, 0) ?? null,
busy,
blocked,
confirm,
message: pending ? (expired ? 'expired' : 'unknown') : message,
buttonLabel: pending ? 'retry' : 'use',
};
}
@@ -0,0 +1,19 @@
import {
anthropicResetGrantBlocker,
type AnthropicResetGrantStatus,
} from '@/services/api/claudeResetGrants';
/** Prefer the upstream recommendation; otherwise use stable ID ordering. */
export function selectResetGrant(status: AnthropicResetGrantStatus, now: number) {
if (status.cooldownUntil && Date.parse(status.cooldownUntil) > now) return undefined;
const usable = status.grants.filter(
(grant) =>
!anthropicResetGrantBlocker(status, grant.id) &&
(!grant.startsAt || Date.parse(grant.startsAt) <= now) &&
(!grant.endsAt || Date.parse(grant.endsAt) > now)
);
return (
usable.find((grant) => grant.id === status.nextGrantId) ??
usable.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0]
);
}
+4 -2
View File
@@ -2201,6 +2201,8 @@
}
},
"claude_reset": {
"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",
"scope": "Uses a limited reset grant to clear eligible Claude usage windows. Only confirm when you want to spend a reset. Recovery is limited to this tab/session; do not reload or use another tab while an outcome is unknown.",
"loading": "Loading…",
@@ -2212,9 +2214,9 @@
"empty": "No banked reset grants.",
"count": "{{left}} / {{total}} resets remaining",
"ends": "Expires: {{date}}",
"use": "Use reset",
"use": "Reset limits",
"confirm": "Confirm reset",
"confirm_text": "Spend one reset from “{{grant}}”? An unknown claim will be retried with its original request ID.",
"confirm_text": "Spend one reset to clear eligible usage limits for “{{name}}”? This uses a limited reset grant.",
"reset": "Usage limits reset.",
"already_used": "This grant was already used.",
"not_limited": "No reset: the account is not currently limited.",
+4 -2
View File
@@ -2195,6 +2195,8 @@
}
},
"claude_reset": {
"retry_confirm": "Повторить сброс «{{name}}» с неизвестным результатом и исходным ID запроса? Сброс мог быть потрачен. Повторите в течение 10 минут; не перезагружайте страницу и не создавайте запрос в другой вкладке.",
"remaining": "Осталось сбросов",
"title": "Запас сбросов",
"scope": "Использует ограниченное право на сброс лимитов Claude. Подтверждайте только если хотите потратить сброс. Восстановление доступно только в этой вкладке и сессии; при неизвестном результате не перезагружайте страницу и не используйте другую вкладку.",
"loading": "Загрузка…",
@@ -2206,9 +2208,9 @@
"empty": "Нет доступных запасных сбросов.",
"count": "Осталось сбросов: {{left}} / {{total}}",
"ends": "Истекает: {{date}}",
"use": "Использовать сброс",
"use": "Сбросить лимиты",
"confirm": "Подтвердить сброс",
"confirm_text": "Потратить один сброс «{{grant}}»? Запрос с неизвестным результатом будет повторён с исходным ID.",
"confirm_text": "Потратить один сброс для снятия доступных лимитов «{{name}}»? Число сбросов ограничено.",
"reset": "Лимиты использования сброшены.",
"already_used": "Это право уже использовано.",
"not_limited": "Сброс не выполнен: лимит ещё не достигнут.",
+4 -2
View File
@@ -2199,6 +2199,8 @@
}
},
"claude_reset": {
"retry_confirm": "使用原请求 ID 重试“{{name}}”结果未知的重置?可能已消耗一次重置。请在 10 分钟内重试;请勿重新加载页面或在其他标签页发起新请求。",
"remaining": "剩余重置次数",
"title": "储备重置次数",
"scope": "使用有限的重置权益清除符合条件的 Claude 用量窗口。仅在确定要消耗一次重置时确认。恢复仅限当前标签页和会话;结果未知时,请勿刷新页面或使用其他标签页。",
"loading": "加载中…",
@@ -2210,9 +2212,9 @@
"empty": "没有储备重置权益。",
"count": "剩余 {{left}} / {{total}} 次重置",
"ends": "到期:{{date}}",
"use": "使用重置",
"use": "重置额度",
"confirm": "确认重置",
"confirm_text": "消耗“{{grant}}”的一次重置?结果未知的请求将使用原请求 ID 重试。",
"confirm_text": "确定消耗一次重置,清除“{{name}}”符合条件的用量限制?重置次数有限。",
"reset": "已重置用量限制。",
"already_used": "该权益已使用。",
"not_limited": "未重置:账户当前未达到限制。",
+4 -2
View File
@@ -2225,6 +2225,8 @@
}
},
"claude_reset": {
"retry_confirm": "使用原請求 ID 重試「{{name}}」結果未知的重置?可能已消耗一次重置。請在 10 分鐘內重試;請勿重新載入頁面或在其他分頁發起新請求。",
"remaining": "剩餘重置次數",
"title": "儲備重置次數",
"scope": "使用有限的重置權益清除符合條件的 Claude 用量視窗。僅在確定要消耗一次重置時確認。復原僅限目前分頁和工作階段;結果未知時,請勿重新載入頁面或使用其他分頁。",
"loading": "載入中…",
@@ -2236,9 +2238,9 @@
"empty": "沒有儲備重置權益。",
"count": "剩餘 {{left}} / {{total}} 次重置",
"ends": "到期:{{date}}",
"use": "使用重置",
"use": "重置額度",
"confirm": "確認重置",
"confirm_text": "消耗「{{grant}}」的一次重置?結果未知的請求將使用原請求 ID 重試。",
"confirm_text": "確定消耗一次重置,清除「{{name}}」符合條件的用量限制?重置次數有限。",
"reset": "已重置用量限制。",
"already_used": "該權益已使用。",
"not_limited": "未重置:帳戶目前未達到限制。",
+41 -1
View File
@@ -1,3 +1,5 @@
import { selectResetGrant } from '../src/features/quota/providers/claude/selectResetGrant';
import { afterEach, describe, expect, test } from 'bun:test';
import {
anthropicResetGrantBlocker,
@@ -299,7 +301,9 @@ test('all grant messages and confirmation are translated in four locales', async
);
for (const locale of locales) {
expect(Object.keys(locale).sort()).toEqual(Object.keys(locales[0]).sort());
expect(locale.confirm_text).toContain('{{grant}}');
expect(locale.confirm_text).toContain('{{name}}');
expect(locale.retry_confirm).toContain('{{name}}');
expect(typeof locale.remaining).toBe('string');
expect(locale.count).toContain('{{left}}');
for (const key of [
...ANTHROPIC_RESET_RESULTS,
@@ -312,3 +316,39 @@ test('all grant messages and confirmation are translated in four locales', async
}
}
});
test('card selection prefers usable recommendation and has deterministic fallback', () => {
const base = status();
const a = { ...base.grants[0], id: 'a' };
const b = { ...a, id: 'b' };
const multiple = { ...base, grants: [b, a], nextGrantId: 'b' };
expect(selectResetGrant(multiple, 0)?.id).toBe('b');
expect(selectResetGrant({ ...multiple, nextGrantId: null }, 0)?.id).toBe('a');
expect(selectResetGrant({ ...multiple, grants: [a, { ...b, paused: true }] }, 0)?.id).toBe('a');
expect(selectResetGrant({ ...multiple, eligible: false }, 0)).toBeUndefined();
expect(selectResetGrant({ ...multiple, atLimit: false }, 0)).toBeUndefined();
expect(
selectResetGrant({ ...multiple, cooldownUntil: new Date(1000).toISOString() }, 0)
).toBeUndefined();
for (const changed of [
{ resetsLeft: 0 },
{ usableNow: false },
{ paused: true },
{ startsAt: new Date(1000).toISOString() },
{ endsAt: new Date(0).toISOString() },
]) {
expect(selectResetGrant({ ...base, grants: [{ ...a, ...changed }] }, 0)).toBeUndefined();
}
});
test('Claude card uses Codex count and action styles and shared confirmation, not a grant dialog', async () => {
const card = await Bun.file('src/features/quota/components/QuotaCard.tsx').text();
const hook = await Bun.file('src/features/quota/providers/claude/ClaudeResetGrants.tsx').text();
expect(card).toContain('quotaClasses.codexPlanValue}>{claudeReset.count');
expect(card).toContain('disabled={claudeReset.blocked}');
expect(card).toContain('onClick={claudeReset.confirm}');
expect(hook).toContain('showConfirmation({');
expect(hook).toContain("pending ? 'claude_reset.retry_confirm'");
expect(hook).not.toContain('<Modal');
expect(hook).not.toContain('status.grants.map');
});