fix(quota): prevent overflow with responsive credential cards
- Remove the redundant page title and table headings - Adapt card columns to the available content width - Compact quota details and prevent controls from overlapping - Add layout regression coverage for 11 viewport configurations Fixes #342
This commit is contained in:
1 parent
d4e49a240c
commit
a79140b8d2
5 files changed
+124
-81
No files matched your search
+43
-76
@@ -1,99 +1,66 @@
|
||||
.quota-page .quota-group-heading {
|
||||
justify-content: flex-start;
|
||||
min-height: 32px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.quota-page .quota-provider-title {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.quota-page .quota-group-list { gap: 18px; }
|
||||
.quota-page .quota-provider-group { gap: 8px; }
|
||||
.quota-page .quota-group-heading { justify-content: flex-start; min-height: 24px; padding: 0 2px; }
|
||||
.quota-page .quota-provider-title { min-width: 0; flex-wrap: wrap; gap: 8px; }
|
||||
.quota-page .quota-group-heading h2 { font-size: var(--font-size-label); }
|
||||
.quota-page .quota-group-heading .provider-logo { width: 18px; height: 18px; }
|
||||
.quota-page .quota-provider-count {
|
||||
padding: 3px 8px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.4;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Dense account list inspired by the quota overview reference. */
|
||||
.quota-page .quota-group-list { gap: 14px; }
|
||||
.quota-page .quota-table-head {
|
||||
/* Keep each card compact; available content width determines the column count. */
|
||||
.quota-page .real-quota-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.35fr) minmax(100px, .65fr) minmax(110px, .7fr) minmax(280px, 2fr) minmax(112px, .7fr);
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 18px 6px;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-meta);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.quota-page .quota-provider-group { gap: 7px; }
|
||||
.quota-page .quota-group-heading { min-height: 24px; padding: 0 3px; }
|
||||
.quota-page .quota-group-heading h2 { font-size: var(--font-size-label); color: var(--theme-7a746c); }
|
||||
.quota-page .quota-group-heading .provider-logo { width: 18px; height: 18px; }
|
||||
.quota-page .quota-provider-count { padding: 2px 7px; font-size: 11px; }
|
||||
.quota-page .real-quota-grid { display: grid; gap: 8px; }
|
||||
.quota-page .real-quota-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.35fr) minmax(100px, .65fr) minmax(110px, .7fr) minmax(280px, 2fr) minmax(112px, .7fr);
|
||||
align-items: center;
|
||||
gap: 14px 18px;
|
||||
min-height: 104px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9px;
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 1px 2px rgb(40 45 55 / 3%);
|
||||
}
|
||||
.quota-page .real-quota-card:hover { border-color: #b7c9ed; box-shadow: 0 3px 12px rgb(55 95 180 / 8%); }
|
||||
.quota-page .real-quota-card-header { display: contents; }
|
||||
.quota-page .quota-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.quota-page .quota-identity > div { display: grid; gap: 4px; min-width: 0; }
|
||||
.quota-page .quota-account-icon { width: 30px; height: 30px; flex: 0 0 auto; }
|
||||
.quota-page .real-quota-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.quota-page .real-quota-card-header .quota-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.quota-page .quota-identity > div { display: grid; gap: 3px; min-width: 0; }
|
||||
.quota-page .quota-account-icon { width: 26px; height: 26px; flex: 0 0 auto; }
|
||||
.quota-page .real-quota-card-header strong { color: var(--theme-2d2a26); font-size: var(--font-size-body); }
|
||||
.quota-page .real-quota-card-header span { font-size: var(--font-size-meta); }
|
||||
.quota-page .quota-plan, .quota-page .quota-status { display: grid; gap: 4px; min-width: 0; }
|
||||
.quota-page .quota-plan strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.quota-page .quota-plan span, .quota-page .quota-status small { color: var(--theme-7a746c); font-size: var(--font-size-meta); }
|
||||
.quota-page .quota-status-badge { width: fit-content; padding: 3px 8px; border-radius: 6px; background: var(--theme-f4f1ea); color: var(--theme-7a746c); font-size: var(--font-size-meta); font-weight: var(--font-weight-semibold); }
|
||||
.quota-page .quota-plan { grid-column: 1; grid-row: 2; }
|
||||
.quota-page .quota-status { grid-column: 2; grid-row: 2; justify-items: end; }
|
||||
.quota-page .quota-plan, .quota-page .quota-status { display: grid; gap: 3px; min-width: 0; }
|
||||
.quota-page .quota-plan span, .quota-page .quota-status small { color: var(--text-tertiary); font-size: var(--font-size-meta); }
|
||||
.quota-page .quota-status-badge { width: fit-content; padding: 3px 7px; border-radius: 6px; background: var(--theme-f4f1ea); color: var(--theme-7a746c); font-size: var(--font-size-meta); font-weight: var(--font-weight-semibold); }
|
||||
.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 .quota-card-actions { grid-column: 2; grid-row: 1; justify-content: flex-end; }
|
||||
.quota-page .quota-card-actions .icon-button { width: 30px; height: 30px; border-radius: 7px; background: #eef4ff; color: #347df4; }
|
||||
.quota-page .quota-row-list { order: 1; gap: 10px; min-width: 0; }
|
||||
.quota-page .real-quota-row { gap: 4px; min-width: 0; overflow-wrap: anywhere; }
|
||||
.quota-page .real-quota-row > div:first-child { font-size: var(--font-size-meta); align-items: flex-start; }
|
||||
.quota-page .real-quota-row > div:first-child strong { flex-shrink: 0; }
|
||||
.quota-page .real-quota-track { height: 6px; 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-credit-summary { order: 2; gap: 5px 10px; padding: 8px; }
|
||||
.quota-page .quota-reset-credit-summary span { white-space: normal; overflow-wrap: anywhere; }
|
||||
.quota-page .quota-reset-footer { order: 3; align-self: flex-end; margin: auto 0 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 { 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; 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-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; }
|
||||
}
|
||||
.quota-page .quota-card-message, .quota-page .quota-card-error { min-height: 36px; flex-wrap: wrap; }
|
||||
@@ -143,11 +143,8 @@ export function QuotaPage() {
|
||||
}, [files, quotas]);
|
||||
|
||||
return (
|
||||
<section className="page management-page quota-page">
|
||||
<section className="page management-page quota-page" aria-label={t('quota.title')}>
|
||||
{confirmationDialog}
|
||||
<header className="management-header">
|
||||
<div><h1>{t('quota.title')}</h1></div>
|
||||
</header>
|
||||
{error ? <MessageNotice message={error} /> : null}
|
||||
<OAuthPageToolbar
|
||||
icon={<Gauge size={18} />}
|
||||
@@ -166,7 +163,6 @@ export function QuotaPage() {
|
||||
<div className="management-empty"><AlertCircle size={24} /><strong>{t('quota.empty.title')}</strong><span>{t('quota.empty.description')}</span></div>
|
||||
) : (
|
||||
<div className="quota-group-list">
|
||||
<div className="quota-table-head" aria-hidden="true"><span>{t('authFiles.list.credential')}</span><span>{t('authFiles.list.plan')}</span><span>{t('authFiles.list.status')}</span><span>{t('authFiles.list.quota')}</span><span>{t('authFiles.list.actions')}</span></div>
|
||||
{grouped.map(([provider, items]) => (
|
||||
<section className="quota-provider-group" key={provider}>
|
||||
<div className="quota-group-heading">
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<html><body><div id="root"></div><script type="module" src="/tests/fixtures/quota-layout.tsx"></script></body></html>
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { QuotaCard } from '../../src/pages/QuotaPage';
|
||||
import { quotaRowsFor } from '../../src/services/quotaService';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', 'en');
|
||||
const rows = quotaRowsFor('kimi', {
|
||||
limits: [{ window: { duration: 5, timeUnit: 'TIME_UNIT_HOUR' }, detail: { limit: 100, remaining: 80 } }],
|
||||
usage: { limit: 100, remaining: 60 },
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider>
|
||||
<main className="content" style={{ width: 1100, padding: 0 }}>
|
||||
<section className="page quota-page">
|
||||
<div className="real-quota-grid">
|
||||
{[1, 2, 3, 4, 5, 6].map((id) => <QuotaCard key={id} file={{ name: `kimi-${id}-long-credential-name@example.com.json`, provider: 'kimi' }} quota={{ status: 'success', rows }} onRefresh={() => {}} />)}
|
||||
</div>
|
||||
<div className="real-quota-grid">
|
||||
<QuotaCard file={{ name: 'codex.json', provider: 'codex' }} quota={{ status: 'success', rows, resetCredits: 2, resetCreditsApplicable: 2, plan: 'team', subscriptionActiveUntil: '2030-10-17T13:58:00Z', resetCreditsEarliestExpiry: '2030-10-23T05:10:00Z' }} onRefresh={() => {}} onReset={() => {}} />
|
||||
<QuotaCard file={{ name: 'failed.json', provider: 'kimi' }} quota={{ status: 'error', rows: [], error: 'Quota request failed' }} onRefresh={() => {}} />
|
||||
<QuotaCard file={{ name: 'loading.json', provider: 'kimi' }} quota={{ status: 'loading', rows: [] }} onRefresh={() => {}} />
|
||||
<QuotaCard file={{ name: 'idle.json', provider: 'kimi' }} quota={{ status: 'idle', rows: [] }} onRefresh={() => {}} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
// Run with the Vite dev server at QUOTA_TEST_BASE (default: http://127.0.0.1:1421).
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const base = process.env.QUOTA_TEST_BASE || 'http://127.0.0.1:1421';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
|
||||
await page.goto(`${base}/tests/fixtures/quota-layout.html`);
|
||||
await page.locator('.real-quota-card').first().waitFor();
|
||||
for (const [viewport, width, columns] of [[1920, 1600, 5], [1440, 1100, 3], [1440, 1000, 3], [1440, 924, 3], [1440, 923, 2], [1100, 800, 2], [1100, 612, 2], [1100, 611, 1], [640, 560, 1], [375, 320, 1], [320, 260, 1]]) {
|
||||
await page.setViewportSize({ width: viewport, height: 1000 });
|
||||
await page.locator('.content').evaluate((el, width) => { el.style.width = `${width}px`; }, width);
|
||||
const layout = await page.evaluate(() => {
|
||||
const cards = [...document.querySelectorAll('.real-quota-card')];
|
||||
const rects = cards.map(card => card.getBoundingClientRect());
|
||||
const issues = [];
|
||||
for (const [index, card] of cards.entries()) {
|
||||
const outer = rects[index];
|
||||
const elements = [...card.querySelectorAll('.quota-identity, .quota-plan, .quota-status, .quota-row-list, .quota-card-actions, .quota-reset-footer, .quota-reset-credit-summary, .quota-card-message, .quota-card-error')];
|
||||
for (const el of elements) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.left < outer.left - 1 || r.right > outer.right + 1 || r.bottom > outer.bottom + 1) issues.push(`${index}: ${el.className} outside card`);
|
||||
}
|
||||
for (let i = 0; i < elements.length; i++) for (let j = i + 1; j < elements.length; j++) {
|
||||
const a = elements[i].getBoundingClientRect(), b = elements[j].getBoundingClientRect();
|
||||
if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1) issues.push(`${index}: ${elements[i].className} overlaps ${elements[j].className}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
issues,
|
||||
columns: rects.slice(0, 6).filter(r => Math.abs(r.top - rects[0].top) < 1).length,
|
||||
quotaTexts: cards.slice(0, 2).map(card => card.querySelector('.quota-row-list').textContent),
|
||||
};
|
||||
});
|
||||
const context = `viewport=${viewport}, content=${width}`;
|
||||
assert.equal(layout.columns, columns, `${context}: column count adapts to available width`);
|
||||
assert.deepEqual(layout.issues, [], context);
|
||||
for (const text of layout.quotaTexts) {
|
||||
assert.ok(text.includes('80%') && text.includes('60%'), `${context}: both Kimi windows remain rendered`);
|
||||
}
|
||||
}
|
||||
console.log('Quota layout: 11 viewport/content combinations passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user