diff --git a/docs/screenshots/en/1.png b/docs/screenshots/en/1.png index 2714bd3..0a7639f 100644 Binary files a/docs/screenshots/en/1.png and b/docs/screenshots/en/1.png differ diff --git a/docs/screenshots/en/2.png b/docs/screenshots/en/2.png index cfd91ac..828f5b9 100644 Binary files a/docs/screenshots/en/2.png and b/docs/screenshots/en/2.png differ diff --git a/docs/screenshots/en/3.png b/docs/screenshots/en/3.png index 8a3fc73..90c89ae 100644 Binary files a/docs/screenshots/en/3.png and b/docs/screenshots/en/3.png differ diff --git a/docs/screenshots/en/4.png b/docs/screenshots/en/4.png index ea6db27..ef7be23 100644 Binary files a/docs/screenshots/en/4.png and b/docs/screenshots/en/4.png differ diff --git a/docs/screenshots/en/5.png b/docs/screenshots/en/5.png index c8c64ad..121c2fc 100644 Binary files a/docs/screenshots/en/5.png and b/docs/screenshots/en/5.png differ diff --git a/docs/screenshots/jp/1.png b/docs/screenshots/jp/1.png index c4266d4..7bc09f0 100644 Binary files a/docs/screenshots/jp/1.png and b/docs/screenshots/jp/1.png differ diff --git a/docs/screenshots/jp/2.png b/docs/screenshots/jp/2.png index be67f6a..46d0d1c 100644 Binary files a/docs/screenshots/jp/2.png and b/docs/screenshots/jp/2.png differ diff --git a/docs/screenshots/jp/3.png b/docs/screenshots/jp/3.png index f2bca32..f36fbc4 100644 Binary files a/docs/screenshots/jp/3.png and b/docs/screenshots/jp/3.png differ diff --git a/docs/screenshots/jp/4.png b/docs/screenshots/jp/4.png index 84a7cc3..e2c5fa3 100644 Binary files a/docs/screenshots/jp/4.png and b/docs/screenshots/jp/4.png differ diff --git a/docs/screenshots/jp/5.png b/docs/screenshots/jp/5.png index 025dc21..67ff852 100644 Binary files a/docs/screenshots/jp/5.png and b/docs/screenshots/jp/5.png differ diff --git a/docs/screenshots/zh-CN/1.png b/docs/screenshots/zh-CN/1.png index 3c6ca2f..edd8821 100644 Binary files a/docs/screenshots/zh-CN/1.png and b/docs/screenshots/zh-CN/1.png differ diff --git a/docs/screenshots/zh-CN/2.png b/docs/screenshots/zh-CN/2.png index 6ca8e55..deddb7a 100644 Binary files a/docs/screenshots/zh-CN/2.png and b/docs/screenshots/zh-CN/2.png differ diff --git a/docs/screenshots/zh-CN/3.png b/docs/screenshots/zh-CN/3.png index afa4680..f8166b1 100644 Binary files a/docs/screenshots/zh-CN/3.png and b/docs/screenshots/zh-CN/3.png differ diff --git a/docs/screenshots/zh-CN/4.png b/docs/screenshots/zh-CN/4.png index ce67f0e..c5cd069 100644 Binary files a/docs/screenshots/zh-CN/4.png and b/docs/screenshots/zh-CN/4.png differ diff --git a/docs/screenshots/zh-CN/5.png b/docs/screenshots/zh-CN/5.png index 30a889d..ae22fae 100644 Binary files a/docs/screenshots/zh-CN/5.png and b/docs/screenshots/zh-CN/5.png differ diff --git a/scripts/capture-readme-screenshots.cjs b/scripts/capture-readme-screenshots.cjs index a189386..165ad32 100644 --- a/scripts/capture-readme-screenshots.cjs +++ b/scripts/capture-readme-screenshots.cjs @@ -44,7 +44,7 @@ const pages = [ await response.text(); } const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined); - browser = await chromium.launch(channel ? { channel, headless: true } : { headless: true }); + browser = await chromium.launch(channel ? { channel, headless: true, args: ['--no-proxy-server'] } : { headless: true, args: ['--no-proxy-server'] }); for (const locale of locales) { const context = await browser.newContext({ diff --git a/src/App.tsx b/src/App.tsx index 32953ab..28c0d19 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { MessageNotice } from './appNotice'; -import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { @@ -242,12 +242,23 @@ function AppContent() { options[next]?.focus(); }; - const select = (pageId: PageId) => { + const select = useCallback((pageId: PageId) => { if (!canOpenAppPage(pageId, coreReady)) { return; } setActive(pageId); - }; + }, [coreReady]); + + useEffect(() => { + const handleNavigate = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail) { + select(customEvent.detail); + } + }; + window.addEventListener('app:navigate', handleNavigate); + return () => window.removeEventListener('app:navigate', handleNavigate); + }, [select]); const openContact = async () => { try { diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3052237..f2cf7b7 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -351,7 +351,7 @@ export const jaOverrides = { 'easyMode.overview.coreTitle': '使い方', 'easyMode.overview.providerTitle': 'モデルを接続', 'easyMode.overview.agentTitle': 'エージェントを接続', - 'easyMode.setup.providerDescription': 'OAuth で自分のアカウントにログインして利用枠を使うことも、API 接続で任意のプラットフォームに接続することもできます。', + 'easyMode.setup.providerDescription': 'ブラウザのOAuth認証によるサブスクリプション接続、またはサードパーティAPIプロバイダーの設定に対応しています。', 'easyMode.oauth.title': 'OAuth ログイン', 'easyMode.oauth.description': 'ブラウザー認証で自分のアカウントにログインし、アカウントの利用枠を使います。', 'easyMode.oauth.nextHint': 'ログインが完了したら、ページ上部の「次へ」をクリックして続行します。', @@ -507,13 +507,16 @@ export const jaOverrides = { 'usage.stat.successMetaTitle': '成功 {success} 件、失敗 {failed} 件、キャンセル {canceled} 件', 'usage.stat.averageLatency': '平均レイテンシ', 'usage.stat.tps': 'TPS', - 'usage.stat.performanceMeta': '有効サンプル {samples} 件 · RPM {rpm}', + 'usage.stat.performanceMeta': '{samples} 件 · RPM {rpm}', 'usage.stat.performanceMetaTitle': 'TPS {tps}(総レイテンシで加重)、有効サンプル {samples} 件、RPM {rpm}、平均レイテンシ {latency} ms', 'usage.stat.cacheHitRate': 'キャッシュヒット率', - 'usage.stat.cacheHitMeta': 'キャッシュ読取 {hit} · 入力 {input}', + 'usage.stat.cacheHitMeta': '読取 {hit} · 入力 {input}', 'usage.stat.cacheHitMetaTitle': 'キャッシュヒット率 {rate}%、読取 {hit} Token、入力 {input} Token', 'usage.stat.estimatedCost': '推定コスト', 'usage.stat.costMeta': '料金カバー率 {priced} / {total} 件', + 'usage.stat.costNote': 'モデル単価で推定', + 'usage.stat.costIncluded': '{count} 件を計上', + 'usage.stat.costExcluded': '{count} 件は未計上', 'usage.stat.costMetaTitle': 'モデル料金で {priced} / {total} 件を推定、未計算 {unpriced} 件', 'usage.pricing.coverage': '{priced} / {total} 件を計算済み · 利用可能なモデル料金 {saved} 件', 'usage.pricing.search': 'モデルを検索', @@ -1555,6 +1558,10 @@ export const jaOverrides = { 'kernel.update.openGithub': 'GitHub の最新リリースページを開く', 'kernel.overview.coreVersion': 'コアバージョン', 'kernel.control.title': '実行制御', + 'kernel.quick.agentsDesc': 'クライアントエージェントとモデルルーティングを設定', + 'kernel.quick.apiDesc': 'マルチアップストリームモデルとキーを管理', + 'kernel.quick.oauthDesc': 'アカウントのワンクリック認証と資格情報管理', + 'kernel.quick.usageDesc': 'リアルタイムのリクエストログとToken消費統計を表示', 'kernel.control.installStatus': 'インストール状態', 'kernel.control.installed': 'インストール済み', 'kernel.control.runStatus': '実行状態', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 62dc510..f82a337 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -300,7 +300,7 @@ export const en: Record = { 'easyMode.overview.coreTitle': 'How to use', 'easyMode.overview.providerTitle': 'Connect a model', 'easyMode.overview.agentTitle': 'Connect an agent', - 'easyMode.setup.providerDescription': 'You can use OAuth to sign in to your own account and use its quota, or use API Access to connect to any platform.', + 'easyMode.setup.providerDescription': 'Connect via browser OAuth for subscription accounts, or configure third-party API providers.', 'easyMode.oauth.title': 'OAuth Sign-In', 'easyMode.oauth.description': 'Sign in to your own account through browser authorization and use its quota.', 'easyMode.oauth.nextHint': 'After signing in, click “Next” at the top of the page to continue.', @@ -494,13 +494,16 @@ export const en: Record = { 'usage.stat.successMetaTitle': '{success} succeeded, {failed} failed, {canceled} canceled', 'usage.stat.averageLatency': 'Average Latency', 'usage.stat.tps': 'TPS', - 'usage.stat.performanceMeta': '{samples} samples · RPM {rpm}', + 'usage.stat.performanceMeta': '{samples} samples · RPM {rpm} · {latency} ms', 'usage.stat.performanceMetaTitle': 'TPS {tps} (weighted by total latency), {samples} valid samples, RPM {rpm}, average latency {latency} ms', 'usage.stat.cacheHitRate': 'Cache Hit Rate', - 'usage.stat.cacheHitMeta': 'Cache read {hit} · Input {input}', + 'usage.stat.cacheHitMeta': 'Read {hit} · Input {input}', 'usage.stat.cacheHitMetaTitle': 'Cache hit rate {rate}%, cache read {hit} tokens, input {input} tokens', 'usage.stat.estimatedCost': 'Estimated Cost', 'usage.stat.costMeta': 'Price coverage: {priced} / {total} requests', + 'usage.stat.costNote': 'Estimated from model prices', + 'usage.stat.costIncluded': '{count} included', + 'usage.stat.costExcluded': '{count} excluded', 'usage.stat.costMetaTitle': '{priced} of {total} requests estimated from model prices; {unpriced} unpriced', 'usage.pricing.coverage': '{priced} / {total} requests priced · {saved} model prices available', 'usage.pricing.search': 'Search model', @@ -1555,6 +1558,10 @@ export const en: Record = { 'kernel.update.openGithub': 'Open the latest release on GitHub', 'kernel.overview.coreVersion': 'Core Version', 'kernel.control.title': 'Runtime Control', + 'kernel.quick.agentsDesc': 'Configure client agents and model routing', + 'kernel.quick.apiDesc': 'Manage upstream models and access credentials', + 'kernel.quick.oauthDesc': 'One-click account authorization & credentials', + 'kernel.quick.usageDesc': 'View real-time request logs and token metrics', 'kernel.control.installStatus': 'Installation Status', 'kernel.control.installed': 'Installed', 'kernel.control.runStatus': 'Runtime Status', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c58df9f..786f7f8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -299,7 +299,7 @@ export const zhCN = { 'easyMode.overview.coreTitle': '使用指导', 'easyMode.overview.providerTitle': '接入模型', 'easyMode.overview.agentTitle': '接入智能体', - 'easyMode.setup.providerDescription': '可选择两种方式接入\n1.OAuth登录自己的订阅账号,使用自己账号的额度\n2.API URL的方式接入', + 'easyMode.setup.providerDescription': '支持通过浏览器 OAuth 授权登录官方订阅账号,或配置第三方大模型平台的 API 接入。', 'easyMode.oauth.title': 'OAuth 登录', 'easyMode.oauth.description': '通过浏览器授权登录自己账号,并使用账号的额度。', 'easyMode.oauth.nextHint': '账号登录完成后,点击页面上方的“下一步”继续。', @@ -493,13 +493,16 @@ export const zhCN = { 'usage.stat.successMetaTitle': '成功 {success} 次,失败 {failed} 次,取消 {canceled} 次', 'usage.stat.averageLatency': '平均延迟', 'usage.stat.tps': 'TPS', - 'usage.stat.performanceMeta': '{samples} 条有效样本 · RPM {rpm}', + 'usage.stat.performanceMeta': '{samples} 条样本 · RPM {rpm}', 'usage.stat.performanceMetaTitle': 'TPS {tps}(按总延迟加权),{samples} 条有效样本,RPM {rpm},平均延迟 {latency} ms', 'usage.stat.cacheHitRate': '缓存命中率', - 'usage.stat.cacheHitMeta': '缓存读取 {hit} · 输入 {input}', + 'usage.stat.cacheHitMeta': '读取 {hit} · 输入 {input}', 'usage.stat.cacheHitMetaTitle': '缓存命中率 {rate}%,缓存读取 {hit} Token,输入 {input} Token', 'usage.stat.estimatedCost': '预估成本', 'usage.stat.costMeta': '价格覆盖 {priced} / {total} 次请求', + 'usage.stat.costNote': '按模型单价估算', + 'usage.stat.costIncluded': '已计入 {count} 次', + 'usage.stat.costExcluded': '未计入 {count} 次', 'usage.stat.costMetaTitle': '已按模型价格估算 {priced} / {total} 次请求,未计价 {unpriced} 次', 'usage.pricing.coverage': '已计价 {priced} / {total} 次请求 · 可用 {saved} 个模型价格', 'usage.pricing.search': '搜索模型', @@ -1554,6 +1557,10 @@ export const zhCN = { 'kernel.update.openGithub': '打开 GitHub 最新版本页面', 'kernel.overview.coreVersion': '内核版本', 'kernel.control.title': '运行控制', + 'kernel.quick.agentsDesc': '配置 Claude Code、Codex 等客户端代理', + 'kernel.quick.apiDesc': '管理 OpenAI、Claude、Gemini 多渠道接入', + 'kernel.quick.oauthDesc': '官方账号一键授权登录与凭证管理', + 'kernel.quick.usageDesc': '查看实时请求日志与 Token 消耗统计', 'kernel.control.installStatus': '安装状态', 'kernel.control.installed': '已安装', 'kernel.control.runStatus': '运行状态', diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index 69b936f..f3d71d9 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -3,10 +3,14 @@ import { getVersion } from '@tauri-apps/api/app'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { + Bot, Check, Copy, Eye, EyeOff, + History, + LogIn, + Network, } from 'lucide-react'; import { type CoreStatus, useCoreRuntime } from '../coreRuntime'; import openaiIcon from '../assets/icons/openai-light.svg'; @@ -240,18 +244,20 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
{t('kernel.control.pid')}
-
{coreStatus?.processId || t('kernel.control.noPid')}
+
{coreStatus?.processId || t('kernel.control.noPid')}
{t('kernel.overview.coreVersion')}
- {currentVersion - || (coreInstalled ? t('common.unavailable') : t('kernel.status.notInstalled'))} + + {currentVersion + || (coreInstalled ? t('common.unavailable') : t('kernel.status.notInstalled'))} +
{t('kernel.overview.appVersion')}
-
{currentAppVersion}
+
{currentAppVersion}
@@ -380,6 +386,61 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { +
+ + + + +
); } diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index ebbf7d5..7f46897 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -24,7 +24,7 @@ import { getCurrentLocale, useI18n } from '../i18n'; import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice'; import type { MessageKey } from '../i18n/resources'; import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics'; -import { formatUsageNumber } from '../services/usageNumber'; +import { formatDuration, formatUsageNumber } from '../services/usageNumber'; import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation'; import { useDialogFocusTrap } from '../components/useDialogFocusTrap'; import { @@ -266,6 +266,7 @@ const rangeQuery = (range: UsageRange, customStart: string, customEnd: string): }; const compactNumber = (value: number) => formatUsageNumber(value, getCurrentLocale()); +const compactDuration = (value: number) => formatDuration(value, getCurrentLocale()); const formatStorageBytes = (value: number) => { const bytes = Number.isFinite(value) ? Math.max(0, value) : 0; @@ -1056,6 +1057,8 @@ function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pi const { t } = useI18n(); const cards = [ { + key: 'requests', + tone: 'requests', label: t('usage.stat.requests'), value: compactNumber(overview.totalRequests), meta: t('usage.stat.requestMeta', { @@ -1069,8 +1072,15 @@ function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pi failed: compactNumber(overview.failureCount), canceled: compactNumber(overview.canceledCount), }), + chips: [ + { text: `${t('usage.result.success')} ${compactNumber(overview.successCount)}`, tone: 'ok' }, + { text: `${t('usage.result.failed')} ${compactNumber(overview.failureCount)}`, tone: 'bad' }, + { text: `${t('usage.result.canceled')} ${compactNumber(overview.canceledCount)}`, tone: 'warn' }, + ], }, { + key: 'speed', + tone: 'speed', label: t('usage.stat.tps'), value: overview.tpsSampleCount > 0 ? overview.tps.toFixed(1) : '—', meta: t('usage.stat.performanceMeta', { @@ -1084,8 +1094,14 @@ function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pi rpm: overview.rpm.toFixed(2), latency: Math.round(overview.averageLatencyMs), }), + chips: [ + { text: `RPM ${overview.rpm.toFixed(2)}`, tone: 'neutral' }, + { text: compactDuration(overview.averageLatencyMs), tone: 'neutral' }, + ], }, { + key: 'tokens', + tone: 'tokens', label: t('usage.stat.tokens'), value: compactNumber(overview.totalTokens), meta: t('usage.stat.tokenMeta', { @@ -1098,10 +1114,17 @@ function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pi reasoning: compactNumber(overview.reasoningTokens), cache: compactNumber(overview.cacheReadTokens), }), + chips: [ + { text: `${t('usage.token.input')} ${compactNumber(overview.inputTokens)}`, tone: 'neutral' }, + { text: `${t('usage.token.output')} ${compactNumber(overview.outputTokens)}`, tone: 'ok' }, + ], }, { + key: 'success', + tone: 'success', label: t('usage.stat.successRate'), value: `${overview.successRate.toFixed(1)}%`, + meter: overview.successRate, meta: t('usage.stat.successMeta', { success: compactNumber(overview.successCount), failed: compactNumber(overview.failureCount), @@ -1111,10 +1134,17 @@ function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pi failed: compactNumber(overview.failureCount), canceled: compactNumber(overview.canceledCount), }), + chips: [ + { text: `${t('usage.result.success')} ${compactNumber(overview.successCount)}`, tone: 'ok' }, + { text: `${t('usage.result.failed')} ${compactNumber(overview.failureCount)}`, tone: 'bad' }, + ], }, { + key: 'cache', + tone: 'cache', label: t('usage.stat.cacheHitRate'), value: `${(overview.cacheHitRate * 100).toFixed(1)}%`, + meter: overview.cacheHitRate * 100, meta: t('usage.stat.cacheHitMeta', { hit: compactNumber(overview.cacheReadTokens), input: compactNumber(overview.inputTokens), @@ -1124,8 +1154,14 @@ function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pi hit: compactNumber(overview.cacheReadTokens), input: compactNumber(overview.inputTokens), }), + chips: [ + { text: `${t('usage.token.cacheRead')} ${compactNumber(overview.cacheReadTokens)}`, tone: 'warn' }, + { text: `${t('usage.token.input')} ${compactNumber(overview.inputTokens)}`, tone: 'neutral' }, + ], }, { + key: 'cost', + tone: 'cost', label: t('usage.stat.estimatedCost'), value: formatUsd(overview.estimatedCost), meta: t('usage.stat.costMeta', { @@ -1137,16 +1173,43 @@ function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pi total: compactNumber(overview.totalRequests), unpriced: compactNumber(Math.max(overview.totalRequests - overview.pricedRequests, 0)), }), + chips: [ + { text: t('usage.stat.costNote'), tone: 'note' }, + { + text: t('usage.stat.costIncluded', { + count: compactNumber(overview.pricedRequests), + }), + tone: 'ok', + }, + ...(overview.totalRequests > overview.pricedRequests + ? [{ + text: t('usage.stat.costExcluded', { + count: compactNumber(Math.max(overview.totalRequests - overview.pricedRequests, 0)), + }), + tone: 'warn', + }] + : []), + ], }, ]; return (
- {cards.map(({ label, value, meta, metaTitle }) => ( -
+ {cards.map(({ key, tone, label, value, meter, meta, metaTitle, chips }) => ( +
{label} {value} + {typeof meter === 'number' ? ( +
))}
@@ -1954,13 +2017,13 @@ function UsageEventCell({ case 'latency': return ( - {compactNumber(record.latency_ms)} ms + {compactDuration(record.latency_ms)} ); case 'ttft': return ( - {record.ttft_ms == null ? '—' : `${compactNumber(record.ttft_ms)} ms`} + {record.ttft_ms == null ? '—' : compactDuration(record.ttft_ms)} ); case 'speed': { diff --git a/src/services/usageNumber.ts b/src/services/usageNumber.ts index 15ac5fb..69eb714 100644 --- a/src/services/usageNumber.ts +++ b/src/services/usageNumber.ts @@ -1,16 +1,33 @@ +const THOUSAND = 1_000; const MILLION = 1_000_000; const BILLION = 1_000_000_000; export const formatUsageNumber = (value: number, locale: string) => { const amount = Number.isFinite(value) ? value : 0; const absolute = Math.abs(amount); + const roundedThousands = Math.round((absolute / THOUSAND) * 10) / 10; const roundedMillions = Math.round((absolute / MILLION) * 10) / 10; const useBillions = absolute >= BILLION || roundedMillions >= 1_000; - const divisor = useBillions ? BILLION : absolute >= MILLION ? MILLION : 1; - const unit = useBillions ? 'B' : absolute >= MILLION ? 'M' : ''; + const useMillions = !useBillions && (absolute >= MILLION || roundedThousands >= 1_000); + const useThousands = !useBillions && !useMillions && absolute >= THOUSAND; + const divisor = useBillions ? BILLION : useMillions ? MILLION : useThousands ? THOUSAND : 1; + const unit = useBillions ? "B" : useMillions ? "M" : useThousands ? "K" : ""; const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: divisor === 1 ? 0 : 1, }).format(amount / divisor); return `${formatted}${unit}`; }; + +export const formatDuration = (value: number, locale: string) => { + const milliseconds = Number.isFinite(value) ? Math.max(0, value) : 0; + if (milliseconds < 1_000) { + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(milliseconds)} ms`; + } + const seconds = milliseconds / 1_000; + const maximumFractionDigits = seconds >= 10 ? 1 : 2; + return `${new Intl.NumberFormat(locale, { + maximumFractionDigits, + minimumFractionDigits: seconds >= 10 ? 0 : 1, + }).format(seconds)} s`; +}; diff --git a/src/styles.css b/src/styles.css index c7e29ae..d22fcd2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -106,7 +106,7 @@ --theme-f8f6f1: #f8f6f1; --theme-faf8f2: #faf8f2; --theme-faf8f4: #faf8f4; - --theme-faf9f5: #faf9f5; + --theme-faf9f5: #ffffff; --theme-fdf1ee: #fdf1ee; --theme-fff: #fff; --theme-fff1ee: #fff1ee; @@ -115,24 +115,24 @@ --theme-fffdf8: #fffdf8; --theme-ffffff: #ffffff; - --bg-secondary: #f6f7f5; + --bg-secondary: #ffffff; --bg-primary: #ffffff; - --bg-tertiary: #eff3f0; - --bg-hover: #f6f8f6; - --bg-quinary: #f9faf9; + --bg-tertiary: #f8fafc; + --bg-hover: #f1f5f9; + --bg-quinary: #f8fafc; --bg-card: #ffffff; --bg-error-light: #fff1ee; - --text-primary: #202521; - --text-secondary: #46524a; - --text-tertiary: #566259; - --text-quaternary: #5f6a62; + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #64748b; + --text-quaternary: #94a3b8; --text-muted: var(--text-tertiary); --text-placeholder: var(--text-quaternary); --text-disabled: #7d877f; - --border-color: #e2e8e3; - --border-secondary: #edf1ee; - --border-primary: #cfd7d0; - --border-hover: #c0c8c1; + --border-color: #e2e8f0; + --border-secondary: #f1f5f9; + --border-primary: #cbd5e1; + --border-hover: #94a3b8; --primary-color: #0f766e; --primary-hover: #0b5d57; --primary-active: #094c47; @@ -154,7 +154,7 @@ --count-badge-text: var(--text-primary); --ui-accent: #0f766e; --ui-accent-strong: #0b5d57; - --ui-accent-soft: #d9f0ec; + --ui-accent-soft: #f0fdfa; --ui-focus-ring: rgba(15, 118, 110, 0.28); --ui-accent-shadow: rgba(15, 118, 110, 0.18); --ui-panel-shadow: 0 8px 24px rgba(32, 42, 36, 0.07); @@ -332,7 +332,7 @@ :root { --ui-accent: #0f766e; --ui-accent-strong: #0b5d57; - --ui-accent-soft: #d9f0ec; + --ui-accent-soft: #f0fdfa; --ui-focus-ring: rgba(15, 118, 110, 0.28); --ui-accent-shadow: rgba(15, 118, 110, 0.18); --ui-panel-shadow: 0 8px 24px rgba(32, 42, 36, 0.07); @@ -372,7 +372,7 @@ --theme-f8f6f1: #f9faf9; --theme-faf8f2: #fbfcfb; --theme-faf8f4: #fbfcfb; - --theme-faf9f5: #f6f7f5; + --theme-faf9f5: #ffffff; --theme-fff: #ffffff; --theme-fffdf8: #ffffff; --theme-ffffff: #ffffff; @@ -8618,7 +8618,13 @@ textarea:focus-visible { } .usage-stat-card-meta { - display: none; + display: flex; + flex-wrap: wrap; + gap: 4px; + color: var(--theme-8a847c); + font-size: var(--font-size-caption); + font-variant-numeric: tabular-nums; + line-height: 1.35; } /* Trend Panel */ diff --git a/src/styles/ui-refinements.css b/src/styles/ui-refinements.css index bea7cce..d6896f8 100644 --- a/src/styles/ui-refinements.css +++ b/src/styles/ui-refinements.css @@ -1,3 +1,143 @@ +/* ========================================================================== + EasyCLIProxyAPI UI Refinements & Modern White Theme Design System + ========================================================================== */ + +/* 1. Global Light Theme Surface & Contrast Variables */ +:root { + --bg-secondary: #ffffff; + --bg-primary: #ffffff; + --bg-card: #ffffff; + --bg-tertiary: #f8fafc; + --bg-hover: #f1f5f9; + --bg-quinary: #f8fafc; + --border-color: #e2e8f0; + --border-secondary: #f1f5f9; + --border-primary: #cbd5e1; + --border-hover: #94a3b8; + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #64748b; + --text-quaternary: #94a3b8; + --theme-faf9f5: #ffffff; + --theme-faf8f4: #ffffff; + --theme-faf8f2: #ffffff; + --theme-fffdf8: #ffffff; + --theme-f8f5ee: #f8fafc; + --theme-f6f2ec: #f8fafc; + --theme-f4f1ea: #f8fafc; + --theme-eeeae2: #e2e8f0; + --theme-eeece6: #f1f5f9; + --theme-e8e5dc: #f1f5f9; + --theme-e8e4dc: #f1f5f9; + --theme-e3e1db: #e2e8f0; + --theme-d5d2cb: #cbd5e1; + --theme-c7c2b8: #94a3b8; + --theme-7a746c: #64748b; + --theme-5f5a53: #64748b; + --theme-4f4a43: #475569; + --theme-433f39: #334155; + --theme-39352f: #334155; + --theme-342f2a: #1e293b; + --theme-2d2a26: #0f172a; + --ui-accent: #0f766e; + --ui-accent-strong: #0b5d57; + --ui-accent-soft: #f0fdfa; + --ui-panel-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.05), 0 1px 2px -1px rgba(15, 23, 42, 0.03); + --ui-card-hover-shadow: 0 4px 14px -2px rgba(15, 23, 42, 0.08), 0 2px 6px -2px rgba(15, 23, 42, 0.04); +} + +/* 2. Pure White Surface Application */ +body, html, #root { + background: var(--bg-secondary) !important; +} + +.app-shell, +.app-shell.app-shell-easy-mode, +.workspace, +.content { + background: var(--bg-secondary); +} + +/* 3. Refined Panels & Cards on Pure White */ +.panel { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-card); + box-shadow: var(--ui-panel-shadow); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.panel:hover { + border-color: var(--border-primary); +} + +/* 4. Refined Sidebar Navigation */ +.sidebar { + background: var(--bg-primary); + border-right: 1px solid var(--border-color); +} + +.nav-section button { + border-radius: 8px; + margin-bottom: 2px; + font-weight: 500; + color: var(--text-secondary); + transition: background 0.15s ease, color 0.15s ease; +} + +.nav-section button:hover:not(:disabled) { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.nav-section button.active { + background: var(--ui-accent-soft); + color: var(--ui-accent); + font-weight: 600; + box-shadow: inset 3px 0 0 0 var(--ui-accent); +} + +/* Sidebar Easy Mode Button */ +.sidebar-easy-entry { + border: 1px solid #ccfbf1 !important; + background: #f0fdfa !important; + color: #0f766e !important; + border-radius: 8px; + font-weight: 600; + box-shadow: 0 1px 2px rgba(15, 118, 110, 0.06); + transition: all 0.15s ease; +} + +.sidebar-easy-entry:hover { + background: #ccfbf1 !important; + border-color: #99f6e4 !important; + color: #0b5d57 !important; +} + +/* Sidebar Theme Selector (Clean Segmented Control) */ +.sidebar-theme-selector { + background: var(--bg-hover); + border-radius: 8px; + padding: 3px; + border: 1px solid var(--border-color); +} + +.sidebar-theme-selector button { + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: var(--text-tertiary); + transition: all 0.15s ease; +} + +.sidebar-theme-selector button.active { + background: var(--bg-primary); + color: var(--text-primary); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + font-weight: 600; +} + +/* 5. Home Page Layout & Quick Navigation */ .home-page-header, .config-page-header, .version-page-header { @@ -10,7 +150,7 @@ @container workspace (min-width: 840px) { .home-page { - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto auto; grid-auto-rows: unset; } @@ -18,8 +158,252 @@ .home-page > .client-api-panel { grid-row: 2; } + + .home-page > .home-quick-actions { + grid-row: 3; + grid-column: 1 / -1; + } } +/* Home Page Runtime Control & Detail Grid */ +.control-panel .panel-detail-grid { + display: flex; + flex-direction: column; + gap: 0; + margin: 8px 0; +} + +.control-panel .panel-detail-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--border-secondary); +} + +.control-panel .panel-detail-row:last-child { + border-bottom: none; +} + +.control-panel .panel-detail-row dt { + color: var(--text-secondary); + font-size: 13.5px; +} + +.control-panel .panel-detail-row dd { + color: var(--text-primary); + font-weight: 500; + font-size: 13.5px; +} + +.control-panel .panel-detail-row dd code, +.control-panel .panel-detail-row dd span.mono-tag { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 2px 8px; + font-family: var(--font-mono); + font-size: 13px; +} + +/* Home Page API Access Panel */ +.client-api-panel .client-api-key-actions { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 6px 12px; +} + +.client-api-card { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px 14px; + transition: all 0.15s ease; +} + +.client-api-card:hover { + background: #ffffff; + border-color: var(--border-primary); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); +} + +.client-api-card-heading { + width: 144px; + flex-basis: 144px; +} + +.client-api-card-heading .client-api-format-tag, +.client-api-card-heading span { + font-size: var(--font-size-caption); +} + +.client-api-value-row code { + background: #ffffff; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: text; +} + +/* Home Quick Actions Grid */ +.home-quick-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; + margin-top: 8px; +} + +.home-quick-card { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 18px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-card); + text-align: left; + cursor: pointer; + box-shadow: var(--ui-panel-shadow); + transition: all 0.2s ease; +} + +.home-quick-card:hover { + border-color: var(--ui-accent); + background: #ffffff; + transform: translateY(-2px); + box-shadow: var(--ui-card-hover-shadow); +} + +.home-quick-icon { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--ui-accent-soft); + color: var(--ui-accent); + flex: none; +} + +.home-quick-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.home-quick-info strong { + color: var(--text-primary); + font-size: 14.5px; + font-weight: 600; +} + +.home-quick-info p { + margin: 0; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + word-break: break-word; +} + +/* 6. Tabs & Segmented Controls (Unified Modern Style) */ +.agent-subpage-tabs, +.oauth-subpage-tabs, +.config-subpage-tabs, +.usage-tabs { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-hover); + padding: 3px; + display: inline-flex; + gap: 2px; +} + +.agent-subpage-tabs button, +.oauth-subpage-tabs button, +.config-subpage-tabs button, +.usage-tabs button { + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-tertiary); + font-weight: 500; + font-size: 13.5px; + padding: 6px 14px; + transition: all 0.15s ease; +} + +.agent-subpage-tabs button:hover, +.oauth-subpage-tabs button:hover, +.config-subpage-tabs button:hover, +.usage-tabs button:hover { + color: var(--text-primary); +} + +.agent-subpage-tabs button.active, +.oauth-subpage-tabs button.active, +.config-subpage-tabs button.active, +.usage-tabs button.active { + background: var(--bg-primary); + color: var(--text-primary); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + font-weight: 600; +} + +/* 7. OAuth Management Page Polish */ +.oauth-management-page .management-header h1 { + display: none; +} + +.oauth-management-page .management-header { + min-height: 40px; + justify-content: flex-end; + margin-bottom: 4px; +} + +.oauth-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: 16px; +} + +.oauth-card { + padding: 22px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-card); + box-shadow: var(--ui-panel-shadow); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.oauth-card:hover { + border-color: var(--border-primary); + box-shadow: var(--ui-card-hover-shadow); + transform: translateY(-1px); +} + +.oauth-card .provider-logo { + border-radius: 10px; + border: 1px solid var(--border-secondary); + padding: 4px; + background: var(--bg-tertiary); +} + +.oauth-hint { + color: var(--text-tertiary); + font-size: 12.5px; + line-height: 1.5; +} + +/* 8. Usage Records Page Polish */ .usage-page-navigation { display: flex; min-width: 0; @@ -60,20 +444,190 @@ outline-offset: -2px; } -.client-api-card-heading { - width: 144px; - flex-basis: 144px; +.usage-stat-card { + --usage-tone: var(--ui-accent); + position: relative; + gap: 7px; + overflow: hidden; + padding: 13px 14px 12px 16px; + border-color: color-mix(in srgb, var(--usage-tone) 24%, var(--border-color)); + border-radius: 12px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--usage-tone) 13%, #fff) 0%, #fff 68%); + box-shadow: var(--ui-panel-shadow); } -.client-api-card-heading .client-api-format-tag, -.client-api-card-heading span { - font-size: var(--font-size-caption); +.usage-stat-card::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 4px; + background: var(--usage-tone); + content: ""; } -.client-api-value-row code { - cursor: text; +.usage-stat-card.tone-requests { --usage-tone: #0f766e; } +.usage-stat-card.tone-speed { --usage-tone: #2563eb; } +.usage-stat-card.tone-tokens { --usage-tone: #7c3aed; } +.usage-stat-card.tone-success { --usage-tone: #059669; } +.usage-stat-card.tone-cache { --usage-tone: #d97706; } +.usage-stat-card.tone-cost { --usage-tone: #0f766e; } + +.usage-stat-card:hover { + border-color: color-mix(in srgb, var(--usage-tone) 48%, var(--border-color)); + box-shadow: var(--ui-card-hover-shadow); } +.usage-stat-card-label { + color: var(--usage-tone); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.usage-stat-card-value { + color: var(--text-primary); + font-size: 28px; + font-weight: 750; + letter-spacing: -0.035em; + line-height: 1.05; +} + +.usage-stat-meter { + display: block; + height: 6px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--usage-tone) 16%, #e2e8f0); +} + +.usage-stat-meter > span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--usage-tone); +} + +.usage-stat-card-meta { + display: flex; + min-height: 0; + flex-wrap: wrap; + gap: 4px; + overflow: visible; + color: var(--text-secondary); + white-space: normal; + -webkit-line-clamp: unset; + -webkit-box-orient: unset; +} + +.usage-stat-chip { + display: inline-flex; + max-width: 100%; + align-items: center; + padding: 2px 7px; + border-radius: 999px; + background: #f1f5f9; + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + line-height: 1.4; +} + +.usage-stat-chip.tone-ok { + background: #ecfdf5; + color: #047857; +} + +.usage-stat-chip.tone-bad { + background: #fef2f2; + color: #b91c1c; +} + +.usage-stat-chip.tone-warn { + background: #fffbeb; + color: #b45309; +} + +.usage-stat-chip.tone-note { + width: 100%; + padding: 0; + border-radius: 0; + background: transparent; + color: var(--text-tertiary); + font-weight: 600; +} + +.usage-filter-panel, +.usage-trend-panel, +.usage-health-panel, +.usage-category-panel, +.usage-events-panel, +.usage-collector-state, +.usage-refresh-btn { + border-color: var(--border-color); + background: var(--bg-card); + box-shadow: var(--ui-panel-shadow); +} + +.usage-filter-panel { + padding: 12px 14px; +} + +.usage-filter-label { + color: var(--text-tertiary); +} + +.usage-filter-item select, +.usage-filter-item input { + border-color: var(--border-primary); + border-radius: 8px; + background: var(--bg-card); + color: var(--text-primary); +} + +.usage-filter-item select:focus, +.usage-filter-item input:focus { + border-color: var(--ui-accent); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12); +} + +.usage-section-heading strong { + color: var(--text-primary); +} + +.usage-section-heading span, +.usage-token-count, +.usage-token-pct, +.usage-trend-legend-item { + color: var(--text-tertiary); +} + +.usage-token-name, +.usage-collector-state strong { + color: var(--text-primary); +} + +.usage-token-bar-track { + height: 7px; + background: var(--bg-hover); +} + +.usage-metrics-grid .metric-card { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 16px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); +} + +.metric-card strong { + color: var(--text-primary); + font-size: 26px; + font-weight: 700; +} + +/* 9. Easy Mode / Simple Mode Polish */ .simple-mode-step-status-item { width: 100%; background: transparent; @@ -83,7 +637,7 @@ .simple-mode-step-status-item:not(:disabled):hover { border-color: color-mix(in srgb, var(--ui-accent) 32%, var(--theme-e3e1db)); - background: var(--theme-f8f5ee); + background: var(--bg-tertiary); } .simple-mode-step-status-item:disabled { @@ -95,6 +649,66 @@ opacity: 1; } +/* 10. Status Badges & Pills */ +.state-pill.success { + background: #f0fdf4 !important; + color: #15803d !important; + border: 1px solid #bbf7d0 !important; + font-weight: 500; +} + +.state-pill.error { + background: #fff1f2 !important; + color: #be123c !important; + border: 1px solid #fecdd3 !important; + font-weight: 500; +} + +.state-pill.warn { + background: #fffbeb !important; + color: #b45309 !important; + border: 1px solid #fde68a !important; + font-weight: 500; +} + +/* 11. Buttons Polish */ +.primary-button { + border-radius: 8px; + font-weight: 500; + transition: all 0.15s ease; +} + +.secondary-button { + background: #ffffff; + border: 1px solid var(--border-color); + color: var(--text-secondary); + border-radius: 8px; + font-weight: 500; + transition: all 0.15s ease; +} + +.secondary-button:hover:not(:disabled) { + background: var(--bg-tertiary); + border-color: var(--border-primary); + color: var(--text-primary); +} + +.danger-button { + background: #fff1f2; + border: 1px solid #fecdd3; + color: #e11d48; + border-radius: 8px; + font-weight: 500; + transition: all 0.15s ease; +} + +.danger-button:hover:not(:disabled) { + background: #e11d48; + border-color: #e11d48; + color: #ffffff; +} + +/* Responsive Shell (Preserved from existing accessibility rules) */ @media (max-width: 900px) { .app-shell:not(.app-shell-easy-mode) { grid-template-columns: minmax(0, 1fr); @@ -236,3 +850,81 @@ gap: 8px; } } + +/* Dark Theme Adjustments for Refined Components */ +:root[data-theme='dark'] .usage-stat-card { + background: + linear-gradient(180deg, color-mix(in srgb, var(--usage-tone) 24%, var(--bg-card)) 0%, var(--bg-card) 72%); + border-color: color-mix(in srgb, var(--usage-tone) 42%, var(--border-color)); +} + +:root[data-theme='dark'] .usage-stat-chip { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +:root[data-theme='dark'] .usage-stat-chip.tone-ok { + background: rgba(16, 185, 129, 0.16); + color: #6ee7b7; +} + +:root[data-theme='dark'] .usage-stat-chip.tone-bad { + background: rgba(239, 68, 68, 0.16); + color: #fca5a5; +} + +:root[data-theme='dark'] .usage-stat-chip.tone-warn { + background: rgba(245, 158, 11, 0.16); + color: #fcd34d; +} + +:root[data-theme='dark'] .usage-stat-card, +:root[data-theme='dark'] .usage-filter-panel, +:root[data-theme='dark'] .usage-trend-panel, +:root[data-theme='dark'] .usage-health-panel, +:root[data-theme='dark'] .usage-category-panel, +:root[data-theme='dark'] .usage-events-panel, +:root[data-theme='dark'] .usage-collector-state, +:root[data-theme='dark'] .usage-refresh-btn { + background: var(--bg-card); + border-color: var(--border-color); + box-shadow: none; +} + +:root[data-theme='dark'] .usage-filter-item select, +:root[data-theme='dark'] .usage-filter-item input { + background: var(--bg-tertiary); + border-color: var(--border-primary); + color: var(--text-primary); +} + +:root[data-theme='dark'] .usage-token-bar-track { + background: var(--bg-hover); +} + +:root[data-theme='dark'] .sidebar-easy-entry { + background: rgba(15, 118, 110, 0.18) !important; + border-color: rgba(15, 118, 110, 0.35) !important; + color: #2dd4bf !important; +} + +:root[data-theme='dark'] .sidebar-easy-entry:hover { + background: rgba(15, 118, 110, 0.28) !important; + border-color: rgba(15, 118, 110, 0.5) !important; + color: #5eead4 !important; +} + +:root[data-theme='dark'] .home-quick-card { + background: var(--bg-card); + border-color: var(--border-color); +} + +:root[data-theme='dark'] .home-quick-card:hover { + border-color: #0d9488; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +:root[data-theme='dark'] .home-quick-icon { + background: rgba(15, 118, 110, 0.2); + color: #2dd4bf; +} diff --git a/src/theme.ts b/src/theme.ts index 0f1c68a..c4ff665 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -8,7 +8,7 @@ export type { AppTheme, ThemePreference } from './themeController'; const STORAGE_KEY = 'easy-cli-proxy-api.theme'; const WINDOW_BACKGROUND: Record = { - light: '#f6f7f5', + light: '#ffffff', dark: '#0b0d11', }; diff --git a/tests/app-shell-accessibility-ui.cjs b/tests/app-shell-accessibility-ui.cjs index b1cd2b5..bf804e0 100644 --- a/tests/app-shell-accessibility-ui.cjs +++ b/tests/app-shell-accessibility-ui.cjs @@ -3,20 +3,24 @@ const assert = require('node:assert/strict'); const path = require('node:path'); (async () => { - const { createServer } = await import('vite'); - const react = (await import('@vitejs/plugin-react')).default; - const root = path.resolve(__dirname, '..'); - const server = await createServer({ configFile: false, root, plugins: [react()], logLevel: 'error', server: { host: '127.0.0.1', port: 1421, strictPort: false } }); - let browser; + let server; + let base = 'http://127.0.0.1:1420/'; try { + const response = await fetch(base); + if (!response.ok) throw new Error(String(response.status)); + } catch { + const { createServer } = await import('vite'); + const react = (await import('@vitejs/plugin-react')).default; + const root = path.resolve(__dirname, '..'); + server = await createServer({ configFile: false, root, plugins: [react()], logLevel: 'error', server: { host: '127.0.0.1', port: 1421, strictPort: false } }); await server.listen(); const address = server.httpServer.address(); - const base = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 1421}/`; - const response = await fetch(base); - assert.equal(response.ok, true, `Vite server failed at ${base}`); - await response.text(); + base = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 1421}/`; + } + let browser; + try { const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined); - browser = await chromium.launch(channel ? { channel, headless: true } : { headless: true }); + browser = await chromium.launch(channel ? { channel, headless: true, args: ['--no-proxy-server'] } : { headless: true, args: ['--no-proxy-server'] }); const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); await page.goto(`${base}?mock=running`, { waitUntil: 'commit' }); await page.locator('.app-shell').waitFor(); @@ -68,7 +72,7 @@ const path = require('node:path'); console.log('PASS: page hierarchy, tabs, contextual filters, modal focus and compact shell.'); } finally { if (browser) await browser.close(); - await server.close(); + if (server) await server.close(); } })().catch((error) => { console.error(error); diff --git a/tests/usage-layout-ui.cjs b/tests/usage-layout-ui.cjs index 1f92d7a..3e1cb0a 100644 --- a/tests/usage-layout-ui.cjs +++ b/tests/usage-layout-ui.cjs @@ -26,7 +26,7 @@ const base = 'http://127.0.0.1:1421'; assert.equal(statInfo1213.cardCount, 6, 'There are 6 stat cards'); assert.equal(statInfo1213.cardRows, 1, 'At 1213px width, all 6 cards fit into a single row'); - assert.equal(statInfo1213.metaCount, 0, 'Stat cards have no meta subtext displayed'); + assert.equal(statInfo1213.metaCount, 6, 'Each stat card shows its supporting detail'); assert.ok(!statInfo1213.tpsValue.includes('TPS'), `TPS value should not contain TPS unit: ${statInfo1213.tpsValue}`); assert.ok(/^\d+(\.\d+)?$/.test(statInfo1213.tpsValue), `TPS value should be numeric: ${statInfo1213.tpsValue}`); diff --git a/tests/usageNumber.test.ts b/tests/usageNumber.test.ts index 9f34b47..4b50a8a 100644 --- a/tests/usageNumber.test.ts +++ b/tests/usageNumber.test.ts @@ -1,12 +1,24 @@ import { describe, expect, test } from 'bun:test'; -import { formatUsageNumber } from '../src/services/usageNumber'; +import { formatDuration, formatUsageNumber } from '../src/services/usageNumber'; describe('使用统计数量单位', () => { - test('百万以下保持普通数字格式', () => { - expect(formatUsageNumber(999_999, 'en-US')).toBe('999,999'); + test('按数量级自动切换 K、M、B', () => { + expect(formatUsageNumber(999, 'en-US')).toBe('999'); + expect(formatUsageNumber(1_000, 'en-US')).toBe('1K'); + expect(formatUsageNumber(132_000, 'en-US')).toBe('132K'); + expect(formatUsageNumber(486_500, 'en-US')).toBe('486.5K'); + expect(formatUsageNumber(999_949, 'en-US')).toBe('999.9K'); + expect(formatUsageNumber(999_950, 'en-US')).toBe('1M'); expect(formatUsageNumber(Number.NaN, 'en-US')).toBe('0'); }); + test('延迟在毫秒和秒之间自动切换', () => { + expect(formatDuration(842, 'en-US')).toBe('842 ms'); + expect(formatDuration(1_284, 'en-US')).toBe('1.28 s'); + expect(formatDuration(12_840, 'en-US')).toBe('12.8 s'); + expect(formatDuration(Number.NaN, 'en-US')).toBe('0 ms'); + }); + test('百万级使用 M 并在达到阈值时自动切换为 B', () => { expect(formatUsageNumber(1_000_000, 'en-US')).toBe('1M'); expect(formatUsageNumber(12_340_000, 'en-US')).toBe('12.3M');