style(ui): unify light mode with pure white background across app shell and usage views

- Set default light background color to pure white across html, body, and app-shell
- Replace grayish background, diffuse card shadows, and translucent pills in usage records with pure white surfaces and clean borders
- Ensure usage tables and subpage panels render consistently as pure white documents
- Update UI accessibility regression tests to assert pure white surfaces
This commit is contained in:
lzt404 committed 2026-10-05 16:10:37 +08:00
1 parent f4bba22817
commit b1fe595f0b
5 files changed
+212 -257

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@
: window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = theme === 'dark' ? '#0b0d11' : '#f6f7f5';
document.documentElement.style.backgroundColor = theme === 'dark' ? '#0b0d11' : '#ffffff';
})();
</script>
<title>EasyCLIProxyAPI</title>
+106 -126
View File
@@ -6,7 +6,6 @@ import {
Activity,
Database,
Gauge,
ListFilter,
Pencil,
RefreshCw,
Trash2,
@@ -248,7 +247,6 @@ export function UsageRecordsPage() {
const [source, setSource] = useState('');
const [apiKeyHash, setApiKeyHash] = useState('');
const [result, setResult] = useState('all');
const [advancedFiltersOpen, setAdvancedFiltersOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [status, setStatus] = useState<CollectorStatus | null>(null);
@@ -447,7 +445,6 @@ export function UsageRecordsPage() {
};
const collectorTone = status?.state === 'error' ? 'error' : status?.state === 'collecting' ? 'success' : '';
const activeAdvancedFilterCount = [model, provider, source, apiKeyHash].filter(Boolean).length;
const hasCurrentSnapshot = loadedScopeKey === scopeKey;
const showInitialLoading =
activeTab !== 'data-management' &&
@@ -471,135 +468,118 @@ export function UsageRecordsPage() {
const filterPanel = (
<section className="panel usage-filter-panel">
<div className="usage-filter-header">
<span className="usage-filter-title">{t('usage.filter.title')}</span>
{activeAdvancedFilterCount > 0 ? (
<span className="usage-filter-active">{t('usage.filter.active', { count: activeAdvancedFilterCount })}</span>
) : null}
</div>
<div className="usage-filter-row">
<div className="usage-filter-group usage-filter-primary">
<div className="usage-filter-group">
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.filter.timeRange')}</span>
<span className="sr-only">{t('usage.filter.timeRange')}</span>
<select
value={range}
onChange={(event) => {
setRange(event.currentTarget.value as UsageRange);
setPage(1);
}}
aria-label={t('usage.filter.timeRange')}
>
<option value="4h">{t('usage.range.4h')}</option>
<option value="24h">{t('usage.range.24h')}</option>
<option value="today">{t('usage.range.today')}</option>
<option value="7d">{t('usage.range.7d')}</option>
<option value="30d">{t('usage.range.30d')}</option>
<option value="all">{t('usage.range.all')}</option>
<option value="custom">{t('usage.range.custom')}</option>
</select>
</label>
value={range}
onChange={(event) => {
setRange(event.currentTarget.value as UsageRange);
setPage(1);
}}
aria-label={t('usage.filter.timeRange')}
>
<option value="4h">{t('usage.range.4h')}</option>
<option value="24h">{t('usage.range.24h')}</option>
<option value="today">{t('usage.range.today')}</option>
<option value="7d">{t('usage.range.7d')}</option>
<option value="30d">{t('usage.range.30d')}</option>
<option value="all">{t('usage.range.all')}</option>
<option value="custom">{t('usage.range.custom')}</option>
</select>
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.filter.result')}</span>
<select
value={result}
onChange={(event) => changeFilter(setResult, event.currentTarget.value)}
aria-label={t('usage.filter.result')}
>
<option value="all">{t('usage.filter.allResults')}</option>
<option value="success">{t('usage.result.success')}</option>
<option value="failed">{t('usage.result.failed')}</option>
<option value="canceled">{t('usage.result.canceled')}</option>
</select>
</label>
</div>
<button
type="button"
className={`usage-filter-toggle${advancedFiltersOpen ? ' is-open' : ''}`}
aria-expanded={advancedFiltersOpen}
onClick={() => setAdvancedFiltersOpen((open) => !open)}
>
<ListFilter size={15} aria-hidden="true" />
<span>{advancedFiltersOpen ? t('usage.filter.hideMore') : t('usage.filter.more')}</span>
{activeAdvancedFilterCount > 0 ? <b>{activeAdvancedFilterCount}</b> : null}
</button>
<label className="usage-filter-item">
<span className="sr-only">{t('usage.filter.model')}</span>
<select
value={model}
onChange={(event) => changeFilter(setModel, event.currentTarget.value)}
aria-label={t('usage.filter.model')}
>
<option value="">{t('usage.filter.allModels')}</option>
{filterOptions(optionsAnalysis.models).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('usage.column.provider')}</span>
<select
value={provider}
onChange={(event) => changeFilter(setProvider, event.currentTarget.value)}
aria-label={t('usage.column.provider')}
>
<option value="">{t('usage.filter.allProviders')}</option>
{filterOptions(optionsAnalysis.providers).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('usage.filter.source')}</span>
<select
value={source}
onChange={(event) => changeFilter(setSource, event.currentTarget.value)}
aria-label={t('usage.filter.source')}
>
<option value="">{t('usage.filter.allSources')}</option>
{filterOptions(optionsAnalysis.sources).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('apiAccess.field.key')}</span>
<select
value={apiKeyHash}
onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)}
aria-label={t('apiAccess.field.key')}
>
<option value="">{t('usage.filter.allKeys')}</option>
{filterOptions(optionsAnalysis.apiKeys).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="sr-only">{t('usage.filter.result')}</span>
<select
value={result}
onChange={(event) => changeFilter(setResult, event.currentTarget.value)}
aria-label={t('usage.filter.result')}
>
<option value="all">{t('usage.filter.allResults')}</option>
<option value="success">{t('usage.result.success')}</option>
<option value="failed">{t('usage.result.failed')}</option>
<option value="canceled">{t('usage.result.canceled')}</option>
</select>
</label>
</div>
</div>
{advancedFiltersOpen ? (
<div className="usage-filter-advanced">
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.filter.model')}</span>
<select
value={model}
onChange={(event) => changeFilter(setModel, event.currentTarget.value)}
aria-label={t('usage.filter.model')}
>
<option value="">{t('usage.filter.allModels')}</option>
{filterOptions(optionsAnalysis.models).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.column.provider')}</span>
<select
value={provider}
onChange={(event) => changeFilter(setProvider, event.currentTarget.value)}
aria-label={t('usage.column.provider')}
>
<option value="">{t('usage.filter.allProviders')}</option>
{filterOptions(optionsAnalysis.providers).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">{t('usage.filter.source')}</span>
<select
value={source}
onChange={(event) => changeFilter(setSource, event.currentTarget.value)}
aria-label={t('usage.filter.source')}
>
<option value="">{t('usage.filter.allSources')}</option>
{filterOptions(optionsAnalysis.sources).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">{t('apiAccess.field.key')}</span>
<select
value={apiKeyHash}
onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)}
aria-label={t('apiAccess.field.key')}
>
<option value="">{t('usage.filter.allKeys')}</option>
{filterOptions(optionsAnalysis.apiKeys).map((item) => (
<option value={item.key} key={item.key}>{item.label}</option>
))}
</select>
</label>
</div>
) : null}
{range === 'custom' ? (
<div className="usage-custom-range">
<input
type="datetime-local"
value={customStart}
onChange={(event) => setCustomStart(event.currentTarget.value)}
aria-label={t('usage.filter.startTime')}
/>
<span>{t('usage.filter.to')}</span>
<input
type="datetime-local"
value={customEnd}
onChange={(event) => setCustomEnd(event.currentTarget.value)}
aria-label={t('usage.filter.endTime')}
/>
</div>
) : null}
</section>
{range === 'custom' ? (
<div className="usage-custom-range">
<input
type="datetime-local"
value={customStart}
onChange={(event) => setCustomStart(event.currentTarget.value)}
aria-label={t('usage.filter.startTime')}
/>
<span>{t('usage.filter.to')}</span>
<input
type="datetime-local"
value={customEnd}
onChange={(event) => setCustomEnd(event.currentTarget.value)}
aria-label={t('usage.filter.endTime')}
/>
</div>
) : null}
</section>
);
return (
+13 -125
View File
@@ -98,104 +98,28 @@
background: var(--usage-page-hover);
}
/* Usage records: keep the common path quiet, and move diagnostic filters out of the way. */
.usage-records-page .usage-filter-panel {
gap: 8px;
}
.usage-records-page .usage-filter-header {
/* Keep every usage filter visible. Each select sizes to its option text. */
.usage-records-page .usage-filter-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 22px;
}
.usage-records-page .usage-filter-title {
color: var(--text-primary);
font-size: var(--font-size-caption);
font-weight: 650;
}
.usage-records-page .usage-filter-active {
padding: 2px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--ui-accent) 10%, transparent);
color: var(--ui-accent-strong);
font-size: 11px;
font-weight: 600;
}
.usage-records-page .usage-filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.usage-records-page .usage-filter-group.usage-filter-primary {
display: flex;
flex: 0 1 auto;
min-width: 0;
flex-wrap: nowrap;
grid-template-columns: none;
}
.usage-records-page .usage-filter-primary .usage-filter-item {
flex: 0 1 230px;
min-width: 170px;
}
.usage-records-page .usage-filter-toggle {
display: inline-flex;
.usage-records-page .usage-filter-item {
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 11px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-primary);
color: var(--text-secondary);
font: inherit;
font-size: var(--font-size-caption);
font-weight: 600;
cursor: pointer;
transition: border-color .15s ease, background .15s ease, color .15s ease;
}
.usage-records-page .usage-filter-toggle:hover,
.usage-records-page .usage-filter-toggle.is-open {
border-color: var(--ui-accent);
background: color-mix(in srgb, var(--ui-accent) 7%, var(--bg-primary));
color: var(--ui-accent-strong);
}
.usage-records-page .usage-filter-toggle b {
display: inline-grid;
min-width: 18px;
height: 18px;
place-items: center;
padding: 0 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--ui-accent) 14%, transparent);
font-size: 10px;
}
.usage-records-page .usage-filter-advanced {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.usage-records-page .usage-filter-advanced .usage-filter-item {
width: max-content;
max-width: 100%;
min-width: 0;
padding: 0 10px 0 12px;
}
.usage-records-page .usage-custom-range {
padding-top: 8px;
.usage-records-page .usage-filter-item select {
width: max-content;
max-width: 100%;
min-width: 0;
field-sizing: content;
padding: 0 20px 0 0;
}
/* The default event view is intentionally compact; secondary diagnostics remain available via tooltips and Columns. */
@@ -231,39 +155,3 @@
background: color-mix(in srgb, var(--usage-log-surface) 97%, var(--ui-accent));
}
@media (max-width: 760px) {
.usage-records-page .usage-filter-row {
align-items: stretch;
flex-direction: column;
}
.usage-records-page .usage-filter-group.usage-filter-primary {
width: 100%;
flex-wrap: wrap;
}
.usage-records-page .usage-filter-primary .usage-filter-item {
flex: 1 1 0;
min-width: 0;
}
.usage-records-page .usage-filter-toggle {
width: 100%;
}
.usage-records-page .usage-filter-advanced {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.usage-records-page .usage-filter-group.usage-filter-primary,
.usage-records-page .usage-filter-advanced {
grid-template-columns: 1fr;
flex-direction: column;
}
.usage-records-page .usage-filter-advanced {
display: grid;
}
}
+72 -5
View File
@@ -1,4 +1,18 @@
/* Light-mode workspace surfaces. Keep the sidebar and semantic status colors separate. */
:root:not([data-theme='dark']) {
--theme-faf9f5: #fff;
--bg-secondary: #fff;
}
:root:not([data-theme='dark']),
:root:not([data-theme='dark']) body,
:root:not([data-theme='dark']) #root,
:root:not([data-theme='dark']) .app-shell,
:root:not([data-theme='dark']) .workspace,
:root:not([data-theme='dark']) .workspace .content {
background: #fff;
}
:root:not([data-theme='dark']) :is(.workspace, .config-dialog, dialog) {
--bg-primary: #fff;
--bg-secondary: #fff;
@@ -27,11 +41,6 @@
--text-quaternary: #526174;
}
:root:not([data-theme='dark']) .workspace,
:root:not([data-theme='dark']) .workspace .content {
background: #fff;
}
:root:not([data-theme='dark']) .workspace :is(
.agent-model-section, .agent-auth-segmented, .routing-segmented,
.plugin-tabs, .usage-analysis-metric,
@@ -114,6 +123,64 @@
--usage-log-hover: #fff;
}
/* The usage views and outer panels should read as clean white documents in light mode.
Remove neutral zebra fills, gray collector pills, and diffuse card shadows while
retaining borders and semantic colors for structure. */
:root:not([data-theme='dark']) .workspace :is(
.usage-records-page,
.usage-tab-panel,
.usage-filter-panel,
.usage-request-log,
.usage-events-footer
) {
background: #fff;
}
:root:not([data-theme='dark']) .workspace .usage-records-page :is(
.usage-trend-panel,
.usage-health-panel,
.usage-events-panel
) {
background: #fff;
border: 1px solid var(--border-color);
box-shadow: none;
}
:root:not([data-theme='dark']) .workspace .usage-records-page :is(
.usage-filter-item,
.usage-filter-toggle
) {
background: #fff;
border-color: var(--border-color);
}
:root:not([data-theme='dark']) .workspace .usage-records-page
.usage-request-log .usage-events-table tbody tr td,
:root:not([data-theme='dark']) .workspace .usage-records-page
.usage-request-log .usage-events-table tbody tr:nth-child(even) td,
:root:not([data-theme='dark']) .workspace .usage-records-page
.usage-request-log .usage-events-table tbody tr:hover td {
background: #fff !important;
}
:root:not([data-theme='dark']) .workspace .usage-records-page
.usage-pricing-table :is(th, td) {
background: #fff;
}
:root:not([data-theme='dark']) .workspace .usage-records-page
.usage-pricing-table tbody tr:hover td {
background: #fff !important;
}
:root:not([data-theme='dark']) .workspace .usage-records-page
.usage-collector-state,
:root:not([data-theme='dark']) .workspace .usage-records-page
.usage-refresh-btn {
background: #fff;
border: 1px solid var(--border-color);
}
/* Empty tracks still need a visible outline after neutral surfaces become white. */
:root:not([data-theme='dark']) .workspace .usage-model-bar-track {
box-shadow: inset 0 0 0 1px var(--border-color);
+20
View File
@@ -36,6 +36,26 @@ const path = require('node:path');
await tabs.first().focus();
await page.keyboard.press('ArrowRight');
assert.equal(await tabs.nth(1).getAttribute('aria-selected'), 'true');
await page.getByRole('tab', { name: /请求明细/ }).click();
await page.locator('.usage-events-table tbody tr').first().waitFor();
const usageSurfaces = await page.locator('.usage-records-page .usage-events-table tbody tr').first().evaluate(row => {
const cell = row.querySelector('td');
const page = row.closest('.usage-records-page');
const status = page?.querySelector('.usage-collector-state');
return { row: cell ? getComputedStyle(cell).backgroundColor : '', status: status ? getComputedStyle(status).backgroundColor : '' };
});
assert.equal(usageSurfaces.row, 'rgb(255, 255, 255)', 'light usage rows stay pure white');
assert.equal(usageSurfaces.status, 'rgb(255, 255, 255)', 'light usage status stays pure white');
const shellSurfaces = await page.evaluate(() => {
const shell = document.querySelector('.app-shell');
const content = document.querySelector('.content');
return {
shell: shell ? getComputedStyle(shell).backgroundColor : '',
content: content ? getComputedStyle(content).backgroundColor : '',
};
});
assert.equal(shellSurfaces.shell, 'rgb(255, 255, 255)', 'light app shell stays pure white');
assert.equal(shellSurfaces.content, 'rgb(255, 255, 255)', 'light content stays pure white');
await page.getByRole('tab', { name: /数据管理/ }).click();
assert.equal(await page.locator('.usage-filter-panel').count(), 0);