From d4e49a240c57985dbbb49a17f8b44a28ac896401 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 4 Oct 2026 19:56:45 +0800 Subject: [PATCH] feat(quota): refine quota actions and usage metrics layout --- src/components/AuthFileQuotaPanel.css | 37 ++++++++++++++-- src/components/AuthFileUsageSummary.css | 17 ++++--- src/components/AuthFileUsageSummary.tsx | 7 +-- src/i18n/authFileList.ts | 6 +++ src/i18n/ja.ts | 4 +- src/i18n/locales/en.ts | 4 +- src/i18n/locales/zh-CN.ts | 4 +- src/pages/AgentsPage.tsx | 39 +++++++++++----- src/pages/AuthFileManagementPage.css | 47 +++++++++++++++++++- src/pages/AuthFileManagementPage.tsx | 21 ++++++++- src/pages/QuotaPage.css | 14 ++++-- src/pages/QuotaPage.tsx | 2 +- tests/agent-loading-ui.cjs | 59 +++++++++++++++++++++++++ tests/fixtures/agent-backups.tsx | 6 +++ 14 files changed, 230 insertions(+), 37 deletions(-) create mode 100644 tests/agent-loading-ui.cjs diff --git a/src/components/AuthFileQuotaPanel.css b/src/components/AuthFileQuotaPanel.css index d5b67f7..b163f83 100644 --- a/src/components/AuthFileQuotaPanel.css +++ b/src/components/AuthFileQuotaPanel.css @@ -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; diff --git a/src/components/AuthFileUsageSummary.css b/src/components/AuthFileUsageSummary.css index 8e84ed6..90f7b70 100644 --- a/src/components/AuthFileUsageSummary.css +++ b/src/components/AuthFileUsageSummary.css @@ -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) { diff --git a/src/components/AuthFileUsageSummary.tsx b/src/components/AuthFileUsageSummary.tsx index 8d10e9d..c363553 100644 --- a/src/components/AuthFileUsageSummary.tsx +++ b/src/components/AuthFileUsageSummary.tsx @@ -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 } 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 } ))} - - {t('authFiles.usage.runtimeTotals')} - ); } diff --git a/src/i18n/authFileList.ts b/src/i18n/authFileList.ts index 7050c26..82e6fbf 100644 --- a/src/i18n/authFileList.ts +++ b/src/i18n/authFileList.ts @@ -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 = { @@ -48,6 +50,8 @@ export const authFileListEn: Record = { '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 = { @@ -74,4 +78,6 @@ export const authFileListJa: Record = { 'authFiles.list.next': '次へ', 'authFiles.usage.runtimeTotals': 'コア稼働中の累計', 'authFiles.usage.title': '累計使用量', + 'authFiles.toolbar.refreshList': '一覧を更新', + 'authFiles.toolbar.refreshQuota': 'クォータを更新', }; diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index ca1b8a1..68e11c2 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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': 'まず認証ファイルにクォータ照会対応の認証情報を追加してください。', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7fdc9ed..4d66f61 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -436,8 +436,8 @@ export const en: Record = { '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.', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 853e875..0a652f8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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': '先在凭证文件中添加支持配额查询的凭据。', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index d3ebbe0..a84b0d2 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -379,6 +379,9 @@ let agentViewStateCache: Record<'full' | 'embedded', Partial> = {}; 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([]); - const [models, setModels] = useState([]); + const [statuses, setStatuses] = useState(() => agentStatusesCache ?? []); + const [modelData, setModelData] = useState(() => ({ + client: selected, models: agentModelsCache[selected] ?? [], + })); + const models = modelData.client === selected ? modelData.models : agentModelsCache[selected] ?? []; const [modelByClient, setModelByClient] = useState>>( 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(null); const modelRequestRef = useRef(0); + const statusRequestRef = useRef(0); const piUpdateRequestRef = useRef(0); const claudeModelMappingsDirtyRef = useRef(claudeModelMappingsDirtyCache); const launchDirectoryDialogRef = useDialogFocusTrap({ @@ -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(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('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('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; diff --git a/src/pages/AuthFileManagementPage.css b/src/pages/AuthFileManagementPage.css index 3b24ad7..7699699 100644 --- a/src/pages/AuthFileManagementPage.css +++ b/src/pages/AuthFileManagementPage.css @@ -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%); +} diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index f73da7b..e2c2229 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -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>(() => 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() { }}>{t('authFiles.models.toolbarButton')} + : null} @@ -228,6 +227,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; {[row.detail, reset].filter(Boolean).join(' · ')} ; })} : null} + {onReset && (quota.resetCredits ?? 0) > 0 ? : null} ); } diff --git a/tests/agent-loading-ui.cjs b/tests/agent-loading-ui.cjs new file mode 100644 index 0000000..b8503aa --- /dev/null +++ b/tests/agent-loading-ui.cjs @@ -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); }); diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index 1fb806d..ffb96e3 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -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(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') {