feat(quota): refine quota actions and usage metrics layout
This commit is contained in:
1 parent
3c5114ebbb
commit
d4e49a240c
14 files changed
+230
-37
No files matched your search
@@ -97,15 +97,46 @@
|
||||
min-width: 0;
|
||||
padding: 2px 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 7px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
font-weight: 550;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dense quota cells follow the compact overview treatment: quiet labels and a
|
||||
single visual weight for the remaining amount. */
|
||||
.credential-quota-dense .credential-quota-label span {
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, var(--bg-card));
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.credential-quota-dense .credential-quota-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 10%, var(--bg-card));
|
||||
}
|
||||
.credential-quota-dense .credential-quota-track i {
|
||||
border-radius: inherit;
|
||||
background: var(--quota-color, var(--success-color));
|
||||
}
|
||||
.credential-quota-dense .credential-quota-row.high { --quota-color: #20bf62; }
|
||||
.credential-quota-dense .credential-quota-row.medium { --quota-color: #e6a523; }
|
||||
.credential-quota-dense .credential-quota-row.low { --quota-color: #e25555; }
|
||||
.credential-quota-dense .credential-quota-row small {
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.credential-quota-dense .credential-quota-empty {
|
||||
margin: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.credential-quota-dense .credential-quota-label strong {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
.auth-file-usage-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px 10px;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-file-usage-metric {
|
||||
@@ -22,8 +23,8 @@
|
||||
.auth-file-usage-metric svg {
|
||||
box-sizing: content-box;
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 5px;
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
}
|
||||
@@ -34,6 +35,11 @@
|
||||
background: color-mix(in srgb, var(--success-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.auth-file-usage-metric.rate svg {
|
||||
color: #8b5cf6;
|
||||
background: color-mix(in srgb, #8b5cf6 10%, transparent);
|
||||
}
|
||||
|
||||
.auth-file-usage-metric.failure svg {
|
||||
color: var(--failure-badge-text);
|
||||
background: color-mix(in srgb, var(--failure-badge-text) 10%, transparent);
|
||||
@@ -48,10 +54,7 @@
|
||||
}
|
||||
|
||||
.auth-file-usage-scope {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-meta);
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container auth-file-usage (max-width: 130px) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, Check, Send, X } from 'lucide-react';
|
||||
import { Check, Percent, Send, X } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { authFileRequestStats } from '../services/authFileRequests';
|
||||
import { formatUsageNumber } from '../services/usageNumber';
|
||||
@@ -16,7 +16,7 @@ export function AuthFileUsageSummary({ file }: { file: Record<string, unknown> }
|
||||
const rateText = rate === null ? '—' : formatNumber(rate, { style: 'percent', maximumFractionDigits: 1 });
|
||||
const metrics = [
|
||||
{ key: 'total', Icon: Send, value: countText(total), label: `${t('usage.stat.requests')}: ${exactCount(total)}` },
|
||||
{ key: 'rate', Icon: Activity, value: rateText, label: t('authFiles.requests.rate', { rate: rateText }) },
|
||||
{ key: 'rate', Icon: Percent, value: rateText, label: t('authFiles.requests.rate', { rate: rateText }) },
|
||||
{ key: 'success', Icon: Check, value: countText(success), label: t('authFiles.requests.success', { count: exactCount(success) }) },
|
||||
{ key: 'failure', Icon: X, value: countText(failure), label: t('authFiles.requests.failure', { count: exactCount(failure) }) },
|
||||
];
|
||||
@@ -31,9 +31,6 @@ export function AuthFileUsageSummary({ file }: { file: Record<string, unknown> }
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="auth-file-usage-scope" title={t('authFiles.requests.totalsHint')}>
|
||||
{t('authFiles.usage.runtimeTotals')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export const authFileListZhCN = {
|
||||
'authFiles.list.next': '下一页',
|
||||
'authFiles.usage.runtimeTotals': '运行累计',
|
||||
'authFiles.usage.title': '累计用量',
|
||||
'authFiles.toolbar.refreshList': '刷新列表',
|
||||
'authFiles.toolbar.refreshQuota': '刷新额度',
|
||||
} as const;
|
||||
|
||||
export const authFileListEn: Record<keyof typeof authFileListZhCN, string> = {
|
||||
@@ -48,6 +50,8 @@ export const authFileListEn: Record<keyof typeof authFileListZhCN, string> = {
|
||||
'authFiles.list.next': 'Next',
|
||||
'authFiles.usage.runtimeTotals': 'Core session totals',
|
||||
'authFiles.usage.title': 'Cumulative usage',
|
||||
'authFiles.toolbar.refreshList': 'Refresh list',
|
||||
'authFiles.toolbar.refreshQuota': 'Refresh quota',
|
||||
};
|
||||
|
||||
export const authFileListJa: Record<keyof typeof authFileListZhCN, string> = {
|
||||
@@ -74,4 +78,6 @@ export const authFileListJa: Record<keyof typeof authFileListZhCN, string> = {
|
||||
'authFiles.list.next': '次へ',
|
||||
'authFiles.usage.runtimeTotals': 'コア稼働中の累計',
|
||||
'authFiles.usage.title': '累計使用量',
|
||||
'authFiles.toolbar.refreshList': '一覧を更新',
|
||||
'authFiles.toolbar.refreshQuota': 'クォータを更新',
|
||||
};
|
||||
+2
-2
@@ -449,8 +449,8 @@ export const jaOverrides = {
|
||||
'quota.queryableCredentials.other': '照会可能な認証情報 {count} 件',
|
||||
'quota.credentials.one': '認証情報 {count} 件',
|
||||
'quota.credentials.other': '認証情報 {count} 件',
|
||||
'quota.readList': '一覧を読み込む',
|
||||
'quota.refreshAll': 'すべて更新',
|
||||
'quota.readList': '一覧を更新',
|
||||
'quota.refreshAll': 'クォータを更新',
|
||||
'quota.loadingFiles': '認証ファイルを読み込んでいます',
|
||||
'quota.empty.title': '照会可能なクォータがありません',
|
||||
'quota.empty.description': 'まず認証ファイルにクォータ照会対応の認証情報を追加してください。',
|
||||
|
||||
@@ -436,8 +436,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'quota.queryableCredentials.other': '{count} queryable credentials',
|
||||
'quota.credentials.one': '{count} credential',
|
||||
'quota.credentials.other': '{count} credentials',
|
||||
'quota.readList': 'Load List',
|
||||
'quota.refreshAll': 'Refresh All',
|
||||
'quota.readList': 'Refresh List',
|
||||
'quota.refreshAll': 'Refresh Quota',
|
||||
'quota.loadingFiles': 'Loading authentication files',
|
||||
'quota.empty.title': 'No quota data available',
|
||||
'quota.empty.description': 'Add a credential that supports quota queries under Auth Files first.',
|
||||
|
||||
@@ -435,8 +435,8 @@ export const zhCN = {
|
||||
'quota.queryableCredentials.other': '{count} 个可查询凭据',
|
||||
'quota.credentials.one': '{count} 个凭据',
|
||||
'quota.credentials.other': '{count} 个凭据',
|
||||
'quota.readList': '读取列表',
|
||||
'quota.refreshAll': '刷新全部',
|
||||
'quota.readList': '刷新列表',
|
||||
'quota.refreshAll': '刷新额度',
|
||||
'quota.loadingFiles': '读取凭证文件中',
|
||||
'quota.empty.title': '暂无可查询配额',
|
||||
'quota.empty.description': '先在凭证文件中添加支持配额查询的凭据。',
|
||||
|
||||
+29
-10
@@ -379,6 +379,9 @@ let agentViewStateCache: Record<'full' | 'embedded', Partial<Record<AgentClientI
|
||||
};
|
||||
|
||||
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
|
||||
// Keep the last successful data across page visits; revalidate it in the background.
|
||||
let agentStatusesCache: AgentConfigStatus[] | null = null;
|
||||
const agentModelsCache: Partial<Record<AgentClientId, ModelOption[]>> = {};
|
||||
const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1';
|
||||
const AGENT_LAUNCH_DIRECTORY_HISTORY_KEY = 'cpa-gui.agent-launch-directory-history.v1';
|
||||
|
||||
@@ -834,8 +837,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
const setConfigurationNotice = (configurationNotice: string) => updateViewState({ configurationNotice });
|
||||
const setClearNotice = (clearNotice: string) => updateViewState({ clearNotice });
|
||||
const setLaunchError = (launchError: string) => updateViewState({ launchError });
|
||||
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
const [statuses, setStatuses] = useState<AgentConfigStatus[]>(() => agentStatusesCache ?? []);
|
||||
const [modelData, setModelData] = useState(() => ({
|
||||
client: selected, models: agentModelsCache[selected] ?? [],
|
||||
}));
|
||||
const models = modelData.client === selected ? modelData.models : agentModelsCache[selected] ?? [];
|
||||
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
|
||||
readAgentModelSelections,
|
||||
);
|
||||
@@ -851,7 +857,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
const [claudeCustomMappingByClient, setClaudeCustomMappingByClientState] = useState(
|
||||
() => ({ ...claudeCustomMappingCache }),
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(() => agentStatusesCache === null);
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
'backup' | 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'native-oauth' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | 'restart-deepseek' | null
|
||||
@@ -890,6 +896,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
);
|
||||
const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState<PiProviderUpdateStatus | null>(null);
|
||||
const modelRequestRef = useRef(0);
|
||||
const statusRequestRef = useRef(0);
|
||||
const piUpdateRequestRef = useRef(0);
|
||||
const claudeModelMappingsDirtyRef = useRef(claudeModelMappingsDirtyCache);
|
||||
const launchDirectoryDialogRef = useDialogFocusTrap<HTMLElement>({
|
||||
@@ -947,28 +954,40 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
}, []);
|
||||
|
||||
const loadStatuses = useCallback(async (forceRefresh = false) => {
|
||||
const requestId = ++statusRequestRef.current;
|
||||
const command = forceRefresh
|
||||
? 'refresh_agent_config_statuses'
|
||||
: 'get_agent_config_statuses';
|
||||
const nextStatuses = await invoke<AgentConfigStatus[]>(command);
|
||||
if (requestId !== statusRequestRef.current) return;
|
||||
setStatuses(nextStatuses);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (statuses.length) agentStatusesCache = statuses;
|
||||
}, [statuses]);
|
||||
|
||||
useEffect(() => () => {
|
||||
// Responses from a previous visit must not overwrite the shared cache.
|
||||
modelRequestRef.current += 1;
|
||||
statusRequestRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const loadDeepSeekHarnessProcessStatus = useCallback(async () => {
|
||||
const status = await invoke<DeepSeekHarnessProcessStatus>('get_deepseek_harness_process_status');
|
||||
setDeepSeekHarnessProcessStatus(status);
|
||||
}, []);
|
||||
|
||||
const loadModels = useCallback(async (client: AgentClientId, preferredModel = '') => {
|
||||
const loadModels = useCallback(async (client: AgentClientId, preferredModel = '', background = false) => {
|
||||
const requestId = modelRequestRef.current + 1;
|
||||
modelRequestRef.current = requestId;
|
||||
setModelLoading(true);
|
||||
setModelLoading(!background || agentModelsCache[client] === undefined);
|
||||
setModelError('');
|
||||
setModels([]);
|
||||
try {
|
||||
const nextModels = await invoke<ModelOption[]>('get_agent_models', { client });
|
||||
if (modelRequestRef.current !== requestId) return;
|
||||
setModels(nextModels);
|
||||
agentModelsCache[client] = nextModels;
|
||||
setModelData({ client, models: nextModels });
|
||||
setModelSelectionError('');
|
||||
setModelByClient((current) => {
|
||||
const next = {
|
||||
@@ -998,7 +1017,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
}, [loadStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setLoading(agentStatusesCache === null);
|
||||
setDetectionError('');
|
||||
void loadStatuses()
|
||||
.catch((requestError) => setDetectionError(String(requestError)))
|
||||
@@ -1009,7 +1028,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
if (loading) return;
|
||||
const status = statuses.find((status) => status.id === selected);
|
||||
const preferredModel = status?.currentModel ?? '';
|
||||
void loadModels(selected, preferredModel);
|
||||
void loadModels(selected, preferredModel, true);
|
||||
}, [loadModels, loading, selected]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1021,7 +1040,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
void loadStatuses().catch((requestError) => {
|
||||
if (!disposed) setDetectionError(String(requestError));
|
||||
});
|
||||
void loadModels(selected);
|
||||
void loadModels(selected, '', true);
|
||||
}).then((unlisten) => {
|
||||
if (disposed) unlisten();
|
||||
else stop = unlisten;
|
||||
|
||||
@@ -72,8 +72,10 @@
|
||||
.auth-list-recent .auth-file-requests-timeline { justify-content: center; gap: 5px; font-size: 10px; }
|
||||
.auth-list-recent .auth-file-requests-unavailable { font-size: 10px; line-height: 1.4; text-align: center; color: var(--auth-muted); }
|
||||
.auth-list-usage .auth-file-usage { color: var(--auth-text); }
|
||||
.auth-list-usage .auth-file-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 8px; }
|
||||
.auth-list-usage .auth-file-usage-metrics { grid-template-columns: max-content max-content; grid-template-rows: repeat(2, 20px); gap: 0 18px; justify-content: center; align-content: center; }
|
||||
.auth-list-usage .auth-file-usage-metric { gap: 5px; }
|
||||
.auth-list-usage .auth-file-usage-metric { min-height: 0; height: 20px; line-height: 1; }
|
||||
.auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; }
|
||||
.auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; border-radius: 4px; }
|
||||
.auth-list-usage .auth-file-usage-metric.total svg { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 9%, var(--bg-card)); }
|
||||
.auth-list-usage .auth-file-usage-metric strong { font-size: 12.75px; line-height: 1.2; font-weight: 750; }
|
||||
@@ -195,3 +197,46 @@
|
||||
.auth-files-heading-actions { margin-left: auto; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .auth-list-switch > span { transition: none; } }
|
||||
|
||||
/* Keep the quota column airy without the heavy framed inset used previously. */
|
||||
.auth-files-page .auth-list-quota {
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.auth-files-page .auth-list-quota .credential-quota,
|
||||
.auth-files-page .auth-list-quota .credential-quota.credential-quota-compact,
|
||||
.auth-files-page .auth-list-quota .credential-quota.credential-quota-dense {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.auth-files-page .credential-quota-dense {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
}
|
||||
.auth-files-page .credential-quota-dense .credential-quota-rows {
|
||||
width: 100%;
|
||||
gap: 10px 18px;
|
||||
}
|
||||
.auth-files-page .credential-quota-dense .credential-quota-row {
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-files-page .credential-quota-dense .credential-quota-row small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.auth-files-page .auth-file-card {
|
||||
border-color: #edf0f4;
|
||||
box-shadow: 0 1px 2px rgb(35 48 65 / 3%);
|
||||
}
|
||||
.auth-files-page .auth-file-card:hover {
|
||||
border-color: color-mix(in srgb, var(--auth-accent) 26%, #edf0f4);
|
||||
box-shadow: 0 4px 14px rgb(35 83 160 / 7%);
|
||||
}
|
||||
@@ -138,6 +138,7 @@ export function AuthFileManagementPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled' | 'runtime'>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [quotaRefreshing, setQuotaRefreshing] = useState(false);
|
||||
const [cooldownResetting, setCooldownResetting] = useState<Set<string>>(() => new Set());
|
||||
const [error, setError] = useState('');
|
||||
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
|
||||
@@ -246,6 +247,21 @@ export function AuthFileManagementPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const refreshAllQuotas = async () => {
|
||||
if (quotaRefreshing || busy || loading) return;
|
||||
const queryable = files.filter((file) => !readBoolean(file, 'disabled') && quotaProviderForFile(file));
|
||||
if (!queryable.length) return;
|
||||
setQuotaRefreshing(true);
|
||||
setError('');
|
||||
try {
|
||||
for (let index = 0; index < queryable.length; index += 4) {
|
||||
await Promise.all(queryable.slice(index, index + 4).map((file) => refreshQuota(file)));
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setQuotaRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeOauthModels = () => {
|
||||
if (oauthModelSaveRef.current) return;
|
||||
oauthModelRequestRef.current += 1;
|
||||
@@ -461,7 +477,10 @@ export function AuthFileManagementPage() {
|
||||
}}><Settings2 size={15} />{t('authFiles.models.toolbarButton')}</button>
|
||||
<button type="button" className="secondary-button compact-button auth-toolbar-quiet" disabled={busy} onClick={() => void openAuthFilesDirectory()}><FolderOpen size={15} />{t('authFiles.openDirectory')}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />{t('common.refresh')}
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />{t('authFiles.toolbar.refreshList')}
|
||||
</button>
|
||||
<button type="button" className="secondary-button compact-button auth-toolbar-quiet" onClick={() => void refreshAllQuotas()} disabled={loading || busy || quotaRefreshing || !files.some((file) => !readBoolean(file, 'disabled') && quotaProviderForFile(file))}>
|
||||
<RefreshCw size={16} className={quotaRefreshing ? 'spin' : ''} />{t('authFiles.toolbar.refreshQuota')}
|
||||
</button>
|
||||
<button type="button" className="primary-button compact-button" onClick={() => fileInputRef.current?.click()} disabled={busy}>
|
||||
<Import size={16} />{t('authFiles.import')}
|
||||
|
||||
+11
-3
@@ -66,26 +66,34 @@
|
||||
.quota-page .quota-status-badge.ready { background: #e8f8ee; color: #149447; }
|
||||
.quota-page .quota-status-badge.error { background: #fff0ee; color: #d94b42; }
|
||||
.quota-page .quota-row-list { grid-column: 4; gap: 9px; min-width: 0; }
|
||||
.quota-page .quota-row-list { grid-row: 1 / span 2; align-self: center; }
|
||||
.quota-page .real-quota-row { gap: 5px; }
|
||||
.quota-page .real-quota-row > div:first-child { font-size: var(--font-size-meta); }
|
||||
.quota-page .real-quota-track { height: 7px; background: #edf0f4; }
|
||||
.quota-page .real-quota-track span { background: #20bf62; }
|
||||
.quota-page .real-quota-row small { font-size: 11px; }
|
||||
.quota-page .quota-card-actions { grid-column: 5; grid-row: 1; justify-content: flex-end; align-self: center; flex-wrap: wrap; }
|
||||
.quota-page .quota-reset-footer { grid-column: 5; grid-row: 2; justify-self: end; align-self: end; margin: 0; min-height: 30px; padding: 5px 10px; border-radius: 7px; color: #347df4; border-color: #d8e5fb; background: #f3f7ff; }
|
||||
.quota-page .quota-reset-footer:hover:not(:disabled) { border-color: #9dbcf4; background: #eaf2ff; }
|
||||
.quota-page .quota-card-actions .icon-button { width: 30px; height: 30px; border-radius: 7px; background: #eef4ff; color: #347df4; }
|
||||
.quota-page .quota-card-message, .quota-page .quota-card-error, .quota-page .quota-action-feedback, .quota-page .quota-reset-credit-summary { grid-column: 1 / -1; }
|
||||
.quota-page .quota-card-message, .quota-page .quota-card-error, .quota-page .quota-action-feedback { grid-column: 1 / -1; }
|
||||
.quota-page .quota-reset-credit-summary { grid-column: 1 / 4; grid-row: 2; align-self: end; }
|
||||
.quota-page .quota-card-message { min-height: 36px; }
|
||||
.quota-page .quota-card-error { min-height: 36px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.quota-page .quota-table-head { display: none; }
|
||||
.quota-page .real-quota-card { grid-template-columns: minmax(180px, 1fr) minmax(100px, .7fr) minmax(100px, .7fr) 38px; }
|
||||
.quota-page .quota-row-list { grid-column: 1 / -1; }
|
||||
.quota-page .quota-row-list { grid-column: 1 / -1; grid-row: auto; }
|
||||
.quota-page .quota-reset-credit-summary { grid-column: 1 / -1; grid-row: auto; }
|
||||
.quota-page .quota-card-actions { grid-column: 4; }
|
||||
.quota-page .quota-reset-footer { grid-column: 4; grid-row: 2; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.quota-page .real-quota-card { grid-template-columns: 1fr auto; padding: 13px; }
|
||||
.quota-page .quota-plan, .quota-page .quota-status { grid-column: 1 / -1; }
|
||||
.quota-page .quota-card-actions { grid-column: 2; grid-row: 1; }
|
||||
.quota-page .quota-row-list { grid-column: 1 / -1; }
|
||||
.quota-page .quota-reset-footer { grid-column: 2; grid-row: 2; }
|
||||
.quota-page .quota-row-list { grid-column: 1 / -1; grid-row: auto; }
|
||||
.quota-page .quota-reset-credit-summary { grid-column: 1 / -1; grid-row: auto; }
|
||||
}
|
||||
@@ -201,7 +201,6 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
|
||||
<div className="quota-plan"><strong>{quota.plan || '—'}</strong><span>{quota.subscriptionActiveUntil ? formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) : ''}</span></div>
|
||||
<div className="quota-status"><span className={quota.status === 'success' ? 'quota-status-badge ready' : quota.status === 'error' ? 'quota-status-badge error' : 'quota-status-badge'}>{quota.status === 'success' ? t('common.enabled') : quota.status === 'loading' ? t('quota.querying') : quota.status === 'error' ? t('common.unavailable') : t('quota.notFetched')}</span><small>{t('authFiles.settings.priority')} 0</small></div>
|
||||
<div className="quota-card-actions">
|
||||
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
|
||||
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')} aria-label={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,6 +227,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
|
||||
<small>{[row.detail, reset].filter(Boolean).join(' · ')}</small>
|
||||
</div>;
|
||||
})}</div> : null}
|
||||
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button quota-reset-footer" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Run Vite on port 1421, then node tests/agent-loading-ui.cjs.
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
await page.goto('http://127.0.0.1:1421/tests/fixtures/agent-backups.html?reset-selections');
|
||||
const picker = page.locator('.agent-model-trigger');
|
||||
await page.waitForFunction(() => document.querySelector('.agent-model-trigger strong')?.textContent === 'gpt-one');
|
||||
const rows = await page.locator('.agent-list-items').innerText();
|
||||
await page.evaluate(() => {
|
||||
window.fixtureDeferredCommands = ['get_agent_config_statuses', 'get_agent_models'];
|
||||
window.fixtureRemount();
|
||||
});
|
||||
await page.waitForFunction(() => window.fixturePendingCommands?.length === 2);
|
||||
// Both requests are still pending: the previous data is already interactive.
|
||||
assert.equal(await page.locator('.agent-list-items').innerText(), rows);
|
||||
assert.equal(await picker.locator('strong').textContent(), 'gpt-one');
|
||||
assert.equal(await page.getByRole('button', { name: '重新检测', exact: true }).isEnabled(), true);
|
||||
await picker.click();
|
||||
await page.getByRole('option', { name: 'gpt-two' }).click();
|
||||
await page.evaluate(() => {
|
||||
window.fixtureDeferredCommands = [];
|
||||
window.fixturePendingCommands.splice(0).forEach(item => item.resolve());
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector('.agent-model-trigger strong')?.textContent === 'gpt-two');
|
||||
|
||||
// Failed background refreshes preserve usable data and surface their errors.
|
||||
await page.evaluate(() => {
|
||||
window.fixtureFailedCommands = ['get_agent_config_statuses', 'get_agent_models'];
|
||||
window.fixtureRemount();
|
||||
});
|
||||
await page.getByText(/模拟后台刷新失败/).first().waitFor();
|
||||
assert.equal(await picker.locator('strong').textContent(), 'gpt-two');
|
||||
assert.equal(await page.locator('.agent-list-items').innerText(), rows);
|
||||
|
||||
// Explicit detection still runs, with a busy indicator and retained rows.
|
||||
await page.evaluate(() => {
|
||||
window.fixtureFailedCommands = [];
|
||||
window.fixtureDeferredCommands = ['refresh_agent_config_statuses'];
|
||||
});
|
||||
const refresh = page.getByRole('button', { name: '重新检测', exact: true });
|
||||
await refresh.click();
|
||||
await page.waitForFunction(() => window.fixturePendingCommands?.length === 1);
|
||||
assert.equal(await refresh.isDisabled(), true);
|
||||
assert.equal(await page.locator('.agent-list-items').innerText(), rows);
|
||||
await page.evaluate(() => {
|
||||
window.fixtureDeferredCommands = [];
|
||||
window.fixturePendingCommands.splice(0).forEach(item => item.resolve());
|
||||
});
|
||||
await page.waitForFunction(() => !document.querySelector('.agent-client-list-heading button').disabled);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: cached page return, nonblocking revalidation, preserved selection, failed refresh and explicit detection');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
Vendored
+6
@@ -34,6 +34,12 @@ let embedded=params.has('embedded');
|
||||
(window as any).fixtureSessionIds=Array.from({length:61},(_,index)=>`session-${index+1}`);
|
||||
mockIPC(async (cmd,args:any) => {
|
||||
calls.push({cmd,args});
|
||||
if((window as any).fixtureDeferredCommands?.includes(cmd)) {
|
||||
await new Promise<void>(resolve=>{
|
||||
((window as any).fixturePendingCommands??=[]).push({cmd,resolve});
|
||||
});
|
||||
}
|
||||
if((window as any).fixtureFailedCommands?.includes(cmd))throw new Error('模拟后台刷新失败');
|
||||
if(cmd==='plugin:event|listen') return 1;
|
||||
if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null;
|
||||
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') {
|
||||
|
||||
Reference in new issue
Block a user