feat: unify page layout with consistent page titles and structure

- Add page title (h1) to all main pages for consistent hierarchy
  - Wrap page content in container/content div structure
  - Handle 404 error for unsupported OAuth excluded models API
  - Add cache price input field in usage page model pricing
  - Add upgrade required i18n messages for older CPA versions
  - Import mixins in page-level SCSS modules
This commit is contained in:
Supra4E8C
2025-12-14 20:05:59 +08:00
parent 9d648e3404
commit 09c17c03b9
15 changed files with 316 additions and 210 deletions

View File

@@ -370,7 +370,10 @@
"load_failed": "Failed to load exclusion list", "load_failed": "Failed to load exclusion list",
"provider_required": "Please enter a provider first", "provider_required": "Please enter a provider first",
"scope_all": "Scope: All providers", "scope_all": "Scope: All providers",
"scope_provider": "Scope: {provider}" "scope_provider": "Scope: {provider}",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
}, },
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",

View File

@@ -380,7 +380,10 @@
"load_failed": "加载排除列表失败", "load_failed": "加载排除列表失败",
"provider_required": "请先填写提供商名称", "provider_required": "请先填写提供商名称",
"scope_all": "当前范围:全局(显示所有提供商)", "scope_all": "当前范围:全局(显示所有提供商)",
"scope_provider": "当前范围:{{provider}}" "scope_provider": "当前范围:{{provider}}",
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
}, },
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",

View File

@@ -1072,7 +1072,9 @@ export function AiProvidersPage() {
}; };
return ( return (
<div className="stack"> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.content}>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
<Card <Card
@@ -1973,5 +1975,6 @@ export function AiProvidersPage() {
)} )}
</Modal> </Modal>
</div> </div>
</div>
); );
} }

View File

@@ -1,3 +1,5 @@
@use '../styles/mixins' as *;
.container { .container {
width: 100%; width: 100%;
} }

View File

@@ -9,6 +9,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { apiKeysApi } from '@/services/api'; import { apiKeysApi } from '@/services/api';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() { export function ApiKeysPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -138,6 +139,9 @@ export function ApiKeysPage() {
); );
return ( return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('api_keys.title')}</h1>
<Card title={t('api_keys.proxy_auth_title')} extra={actionButtons}> <Card title={t('api_keys.proxy_auth_title')} extra={actionButtons}>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
@@ -213,5 +217,6 @@ export function ApiKeysPage() {
/> />
</Modal> </Modal>
</Card> </Card>
</div>
); );
} }

View File

@@ -7,6 +7,25 @@
gap: $spacing-lg; gap: $spacing-lg;
} }
.pageHeader {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.description {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.headerActions { .headerActions {
display: flex; display: flex;
gap: $spacing-sm; gap: $spacing-sm;
@@ -472,4 +491,3 @@
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -150,11 +150,13 @@ export function AuthFilesPage() {
// OAuth 排除模型相关 // OAuth 排除模型相关
const [excluded, setExcluded] = useState<Record<string, string[]>>({}); const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
const [excludedModalOpen, setExcludedModalOpen] = useState(false); const [excludedModalOpen, setExcludedModalOpen] = useState(false);
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' }); const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
const [savingExcluded, setSavingExcluded] = useState(false); const [savingExcluded, setSavingExcluded] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const excludedUnsupportedRef = useRef(false);
const disableControls = connectionStatus !== 'connected'; const disableControls = connectionStatus !== 'connected';
@@ -199,11 +201,27 @@ export function AuthFilesPage() {
const loadExcluded = useCallback(async () => { const loadExcluded = useCallback(async () => {
try { try {
const res = await authFilesApi.getOauthExcludedModels(); const res = await authFilesApi.getOauthExcludedModels();
excludedUnsupportedRef.current = false;
setExcluded(res || {}); setExcluded(res || {});
} catch { setExcludedError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setExcluded({});
setExcludedError('unsupported');
if (!excludedUnsupportedRef.current) {
excludedUnsupportedRef.current = true;
showNotification(t('oauth_excluded.upgrade_required'), 'warning');
}
return;
}
// 静默失败 // 静默失败
} }
}, []); }, [showNotification, t]);
useEffect(() => { useEffect(() => {
loadFiles(); loadFiles();
@@ -651,8 +669,13 @@ export function AuthFilesPage() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('auth_files.title')}</h1>
<p className={styles.description}>{t('auth_files.description')}</p>
</div>
<Card <Card
title={t('auth_files.title')} title={t('auth_files.title_section')}
extra={ extra={
<div className={styles.headerActions}> <div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={() => { loadFiles(); loadKeyStats(); }} disabled={loading}> <Button variant="secondary" size="sm" onClick={() => { loadFiles(); loadKeyStats(); }} disabled={loading}>
@@ -770,12 +793,21 @@ export function AuthFilesPage() {
<Card <Card
title={t('oauth_excluded.title')} title={t('oauth_excluded.title')}
extra={ extra={
<Button size="sm" onClick={() => openExcludedModal()} disabled={disableControls}> <Button
size="sm"
onClick={() => openExcludedModal()}
disabled={disableControls || excludedError === 'unsupported'}
>
{t('oauth_excluded.add')} {t('oauth_excluded.add')}
</Button> </Button>
} }
> >
{Object.keys(excluded).length === 0 ? ( {excludedError === 'unsupported' ? (
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
description={t('oauth_excluded.upgrade_required_desc')}
/>
) : Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} /> <EmptyState title={t('oauth_excluded.list_empty_all')} />
) : ( ) : (
<div className={styles.excludedList}> <div className={styles.excludedList}>

View File

@@ -1,3 +1,5 @@
@use '../styles/mixins' as *;
.container { .container {
width: 100%; width: 100%;
} }

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { useNotificationStore, useAuthStore } from '@/stores'; import { useNotificationStore, useAuthStore } from '@/stores';
import { logsApi } from '@/services/api/logs'; import { logsApi } from '@/services/api/logs';
import styles from './LogsPage.module.scss';
interface ErrorLogItem { interface ErrorLogItem {
name: string; name: string;
@@ -197,9 +198,11 @@ export function LogsPage() {
const logsText = logLines.join('\n'); const logsText = logLines.join('\n');
return ( return (
<div className="stack"> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.content}>
<Card <Card
title={t('logs.title')} title={t('logs.log_content')}
extra={ extra={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" size="sm" onClick={() => loadLogs(false)} disabled={loading}> <Button variant="secondary" size="sm" onClick={() => loadLogs(false)} disabled={loading}>
@@ -265,5 +268,6 @@ export function LogsPage() {
)} )}
</Card> </Card>
</div> </div>
</div>
); );
} }

View File

@@ -1,3 +1,5 @@
@use '../styles/mixins' as *;
.container { .container {
width: 100%; width: 100%;
} }

View File

@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/Input';
import { useNotificationStore } from '@/stores'; import { useNotificationStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { isLocalhost } from '@/utils/connection'; import { isLocalhost } from '@/utils/connection';
import styles from './OAuthPage.module.scss';
interface ProviderState { interface ProviderState {
url?: string; url?: string;
@@ -157,7 +158,10 @@ export function OAuthPage() {
}; };
return ( return (
<div className="stack"> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
<div className={styles.content}>
{PROVIDERS.map((provider) => { {PROVIDERS.map((provider) => {
const state = states[provider.id] || {}; const state = states[provider.id] || {};
// 非本地访问时禁用所有 OAuth 登录方式 // 非本地访问时禁用所有 OAuth 登录方式
@@ -282,5 +286,6 @@ export function OAuthPage() {
)} )}
</Card> </Card>
</div> </div>
</div>
); );
} }

View File

@@ -1,3 +1,5 @@
@use '../../styles/mixins' as *;
.container { .container {
width: 100%; width: 100%;
} }

View File

@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { configApi } from '@/services/api'; import { configApi } from '@/services/api';
import type { Config } from '@/types'; import type { Config } from '@/types';
import styles from './Settings/Settings.module.scss';
type PendingKey = type PendingKey =
| 'debug' | 'debug'
@@ -168,8 +169,11 @@ export function SettingsPage() {
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false; const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
return ( return (
<div className="grid cols-2"> <div className={styles.container}>
<Card title={t('basic_settings.title')}> <h1 className={styles.pageTitle}>{t('basic_settings.title')}</h1>
<div className={styles.grid}>
<Card>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch <ToggleSwitch
@@ -327,5 +331,6 @@ export function SettingsPage() {
</div> </div>
</Card> </Card>
</div> </div>
</div>
); );
} }

View File

@@ -115,9 +115,11 @@ export function SystemPage() {
}, [auth.connectionStatus, auth.apiBase]); }, [auth.connectionStatus, auth.apiBase]);
return ( return (
<div className="stack"> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
<div className={styles.content}>
<Card <Card
title={t('system_info.title')} title={t('system_info.connection_status_title')}
extra={ extra={
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}> <Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
{t('common.refresh')} {t('common.refresh')}
@@ -187,5 +189,6 @@ export function SystemPage() {
)} )}
</Card> </Card>
</div> </div>
</div>
); );
} }

View File

@@ -72,6 +72,7 @@ export function UsagePage() {
const [selectedModel, setSelectedModel] = useState(''); const [selectedModel, setSelectedModel] = useState('');
const [promptPrice, setPromptPrice] = useState(''); const [promptPrice, setPromptPrice] = useState('');
const [completionPrice, setCompletionPrice] = useState(''); const [completionPrice, setCompletionPrice] = useState('');
const [cachePrice, setCachePrice] = useState('');
// Expanded sections // Expanded sections
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set()); const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
@@ -340,12 +341,14 @@ export function UsagePage() {
if (!selectedModel) return; if (!selectedModel) return;
const prompt = parseFloat(promptPrice) || 0; const prompt = parseFloat(promptPrice) || 0;
const completion = parseFloat(completionPrice) || 0; const completion = parseFloat(completionPrice) || 0;
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion } }; const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
setModelPrices(newPrices); setModelPrices(newPrices);
saveModelPrices(newPrices); saveModelPrices(newPrices);
setSelectedModel(''); setSelectedModel('');
setPromptPrice(''); setPromptPrice('');
setCompletionPrice(''); setCompletionPrice('');
setCachePrice('');
}; };
// Handle model price delete // Handle model price delete
@@ -362,6 +365,7 @@ export function UsagePage() {
setSelectedModel(model); setSelectedModel(model);
setPromptPrice(price?.prompt?.toString() || ''); setPromptPrice(price?.prompt?.toString() || '');
setCompletionPrice(price?.completion?.toString() || ''); setCompletionPrice(price?.completion?.toString() || '');
setCachePrice(price?.cache?.toString() || '');
}; };
// Toggle API expansion // Toggle API expansion
@@ -771,9 +775,11 @@ export function UsagePage() {
if (price) { if (price) {
setPromptPrice(price.prompt.toString()); setPromptPrice(price.prompt.toString());
setCompletionPrice(price.completion.toString()); setCompletionPrice(price.completion.toString());
setCachePrice(price.cache.toString());
} else { } else {
setPromptPrice(''); setPromptPrice('');
setCompletionPrice(''); setCompletionPrice('');
setCachePrice('');
} }
}} }}
className={styles.select} className={styles.select}
@@ -804,6 +810,16 @@ export function UsagePage() {
step="0.0001" step="0.0001"
/> />
</div> </div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
<Input
type="number"
value={cachePrice}
onChange={(e) => setCachePrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<Button <Button
variant="primary" variant="primary"
onClick={handleSavePrice} onClick={handleSavePrice}
@@ -826,6 +842,7 @@ export function UsagePage() {
<div className={styles.priceMeta}> <div className={styles.priceMeta}>
<span>{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M</span> <span>{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M</span>
<span>{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M</span> <span>{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M</span>
<span>{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M</span>
</div> </div>
</div> </div>
<div className={styles.priceActions}> <div className={styles.priceActions}>