Compare commits

...

10 Commits

23 changed files with 942 additions and 91 deletions

View File

@@ -15,6 +15,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -36,27 +39,25 @@ jobs:
mv index.html management.html
ls -lh management.html
- name: Generate release notes
run: |
set -euo pipefail
current_tag="${GITHUB_REF_NAME}"
previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
if [ -n "${previous_tag}" ]; then
range="${previous_tag}..${current_tag}"
else
range="${current_tag}"
fi
: > release-notes.md
git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: dist/management.html
body: |
## CLI Proxy API Management Center - ${{ github.ref_name }}
### Download and Usage
1. Download the `management.html` file
2. Open it directly in your browser
3. All assets (CSS, JavaScript, images) are bundled into this single file
### Features
- Single file, no external dependencies required
- Complete management interface for CLI Proxy API
- Support for local and remote connections
- Multi-language support (Chinese/English)
- Dark/Light theme support
---
🤖 Generated with GitHub Actions
body_path: release-notes.md
draft: false
prerelease: false
env:

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ dist-ssr
*.local
# Editor directories and files
settings.local.json
.vscode/*
!.vscode/extensions.json
.idea

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
@@ -11,6 +12,7 @@ import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
import { NotificationContainer } from '@/components/common/NotificationContainer';
import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
@@ -20,6 +22,9 @@ function App() {
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const restoreSession = useAuthStore((state) => state.restoreSession);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const [showSplash, setShowSplash] = useState(true);
useEffect(() => {
initializeTheme();
@@ -31,6 +36,15 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅用于首屏同步 i18n 语言
const handleSplashFinish = useCallback(() => {
setShowSplash(false);
}, []);
// 仅在已认证时显示闪屏
if (showSplash && isAuthenticated) {
return <SplashScreen onFinish={handleSplashFinish} duration={1500} />;
}
return (
<HashRouter>
<NotificationContainer />
@@ -44,7 +58,8 @@ function App() {
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/settings" replace />} />
<Route index element={<DashboardPage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
<Route path="ai-providers" element={<AiProvidersPage />} />
@@ -54,7 +69,7 @@ function App() {
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="system" element={<SystemPage />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</HashRouter>

View File

@@ -0,0 +1,106 @@
@use 'sass:color';
@use '../../styles/variables.scss' as *;
.splash-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
opacity: 1;
transition: opacity 0.4s ease-out;
&.fade-out {
opacity: 0;
pointer-events: none;
}
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-md;
animation: splash-enter 0.6s ease-out;
}
@keyframes splash-enter {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.splash-logo {
height: 80px;
width: auto;
border-radius: $radius-lg;
box-shadow: $shadow-lg;
animation: splash-logo-pulse 1.5s ease-in-out infinite;
}
@keyframes splash-logo-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.splash-title {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.5px;
}
.splash-subtitle {
font-size: 16px;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
margin-top: -8px;
}
.splash-loader {
width: 120px;
height: 3px;
background: var(--border-color);
border-radius: $radius-full;
overflow: hidden;
margin-top: $spacing-md;
}
.splash-loader-bar {
width: 100%;
height: 100%;
background: var(--primary-color);
border-radius: $radius-full;
animation: splash-loading 1.2s ease-in-out infinite;
transform-origin: left;
}
@keyframes splash-loading {
0% {
transform: scaleX(0);
}
50% {
transform: scaleX(1);
transform-origin: left;
}
50.01% {
transform-origin: right;
}
100% {
transform: scaleX(0);
transform-origin: right;
}
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import './SplashScreen.scss';
interface SplashScreenProps {
onFinish: () => void;
duration?: number;
}
export function SplashScreen({ onFinish, duration = 1500 }: SplashScreenProps) {
const [fadeOut, setFadeOut] = useState(false);
useEffect(() => {
const fadeTimer = setTimeout(() => {
setFadeOut(true);
}, duration - 400);
const finishTimer = setTimeout(() => {
onFinish();
}, duration);
return () => {
clearTimeout(fadeTimer);
clearTimeout(finishTimer);
};
}, [duration, onFinish]);
return (
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
<div className="splash-content">
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
<h1 className="splash-title">CLI Proxy API</h1>
<p className="splash-subtitle">Management Center</p>
<div className="splash-loader">
<div className="splash-loader-bar" />
</div>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
IconFileText,
IconInfo,
IconKey,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
@@ -18,6 +19,7 @@ import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, u
import { versionApi } from '@/services/api';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />,
@@ -230,6 +232,7 @@ export function MainLayout() {
: 'muted';
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },

View File

@@ -1,7 +1,13 @@
export function LoadingSpinner({ size = 20 }: { size?: number }) {
export function LoadingSpinner({
size = 20,
className = ''
}: {
size?: number;
className?: string;
}) {
return (
<div
className="loading-spinner"
className={`loading-spinner${className ? ` ${className}` : ''}`}
style={{ width: size, height: size, borderWidth: size / 7 }}
role="status"
aria-live="polite"

View File

@@ -303,3 +303,14 @@ export function IconCode({ size = 20, ...props }: IconProps) {
</svg>
);
}
export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" />
</svg>
);
}

View File

@@ -81,6 +81,7 @@
"status": "Connection Status:"
},
"nav": {
"dashboard": "Dashboard",
"basic_settings": "Basic Settings",
"api_keys": "API Keys",
"ai_providers": "AI Providers",
@@ -91,6 +92,27 @@
"logs": "Logs Viewer",
"system_info": "Management Center Info"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Welcome to CLI Proxy API Management Center",
"openai_providers": "OpenAI Providers",
"quick_actions": "Quick Actions",
"current_config": "Current Configuration",
"management_keys": "Management Keys",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth Credentials",
"usage_overview": "Usage Overview",
"total_requests": "Total Requests",
"total_tokens": "Total Tokens",
"rpm_30min": "RPM (30min)",
"tpm_30min": "TPM (30min)",
"models_used": "Models Used",
"no_usage_data": "No usage data available",
"view_detailed_usage": "View Detailed Stats",
"edit_settings": "Edit Settings",
"available_models": "Available Models",
"available_models_desc": "Total models from all providers"
},
"basic_settings": {
"title": "Basic Settings",
"debug_title": "Debug Mode",
@@ -200,8 +222,6 @@
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
"ampcode_clear_upstream_api_key": "Clear official key",
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
"ampcode_restrict_management_label": "Restrict Amp management routes to localhost",
"ampcode_restrict_management_hint": "When enabled, Amp management routes (/api/auth, /api/user, /api/threads, etc.) only accept 127.0.0.1/::1 (recommended).",
"ampcode_force_model_mappings_label": "Force model mappings",
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
"ampcode_model_mappings_label": "Model mappings (from → to)",

View File

@@ -81,6 +81,7 @@
"status": "连接状态:"
},
"nav": {
"dashboard": "仪表盘",
"basic_settings": "基础设置",
"api_keys": "API 密钥",
"ai_providers": "AI 提供商",
@@ -91,6 +92,27 @@
"logs": "日志查看",
"system_info": "中心信息"
},
"dashboard": {
"title": "仪表盘",
"subtitle": "欢迎使用 CLI Proxy API 管理中心",
"openai_providers": "OpenAI 提供商",
"quick_actions": "快捷操作",
"current_config": "当前配置",
"management_keys": "管理密钥",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth 凭证",
"usage_overview": "使用概览",
"total_requests": "总请求数",
"total_tokens": "总 Token 数",
"rpm_30min": "RPM (30分钟)",
"tpm_30min": "TPM (30分钟)",
"models_used": "使用模型数",
"no_usage_data": "暂无使用数据",
"view_detailed_usage": "查看详细统计",
"edit_settings": "编辑设置",
"available_models": "可用模型",
"available_models_desc": "所有提供商的模型总数"
},
"basic_settings": {
"title": "基础设置",
"debug_title": "调试模式",
@@ -200,8 +222,6 @@
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
"ampcode_clear_upstream_api_key": "清除官方密钥",
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API keyAmp官方",
"ampcode_restrict_management_label": "仅允许本机访问 Amp 管理路由",
"ampcode_restrict_management_hint": "开启后,/api/auth、/api/user、/api/threads 等 Amp 管理路由仅允许 127.0.0.1/::1 访问(推荐)。",
"ampcode_force_model_mappings_label": "强制应用模型映射",
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
"ampcode_model_mappings_label": "模型映射 (from → to)",

View File

@@ -404,8 +404,17 @@
}
.excludedModelTag {
background: rgba(251, 191, 36, 0.2);
border-color: rgba(251, 191, 36, 0.4);
background: rgba(251, 191, 36, 0.22);
border-color: rgba(251, 191, 36, 0.55);
color: #fde68a;
.modelName {
color: #fde68a;
}
}
.excludedModelsLabel {
color: #fde68a;
}
.apiKeyEntryCard {

View File

@@ -49,7 +49,6 @@ interface OpenAIFormState {
interface AmpcodeFormState {
upstreamUrl: string;
upstreamApiKey: string;
restrictManagementToLocalhost: boolean;
forceModelMappings: boolean;
mappingEntries: ModelEntry[];
}
@@ -174,7 +173,6 @@ const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[]
const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
upstreamUrl: ampcode?.upstreamUrl ?? '',
upstreamApiKey: '',
restrictManagementToLocalhost: ampcode?.restrictManagementToLocalhost ?? true,
forceModelMappings: ampcode?.forceModelMappings ?? false,
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
});
@@ -701,9 +699,6 @@ export function AiProvidersPage() {
await ampcodeApi.clearUpstreamUrl();
}
await ampcodeApi.updateRestrictManagementToLocalhost(
ampcodeForm.restrictManagementToLocalhost
);
await ampcodeApi.updateForceModelMappings(ampcodeForm.forceModelMappings);
if (ampcodeLoaded || ampcodeMappingsDirty) {
@@ -720,12 +715,18 @@ export function AiProvidersPage() {
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = {
...previous,
upstreamUrl: upstreamUrl || undefined,
restrictManagementToLocalhost: ampcodeForm.restrictManagementToLocalhost,
forceModelMappings: ampcodeForm.forceModelMappings,
};
if (previous.upstreamApiKey) {
next.upstreamApiKey = previous.upstreamApiKey;
}
if (Array.isArray(previous.modelMappings)) {
next.modelMappings = previous.modelMappings;
}
if (overrideKey) {
next.upstreamApiKey = overrideKey;
}
@@ -1505,16 +1506,6 @@ export function AiProvidersPage() {
: t('common.not_set')}
</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.ampcode_restrict_management_label')}:
</span>
<span className={styles.fieldValue}>
{(config?.ampcode?.restrictManagementToLocalhost ?? true)
? t('common.yes')
: t('common.no')}
</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.ampcode_force_model_mappings_label')}:
@@ -1739,18 +1730,6 @@ export function AiProvidersPage() {
</Button>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_restrict_management_label')}
checked={ampcodeForm.restrictManagementToLocalhost}
onChange={(value) =>
setAmpcodeForm((prev) => ({ ...prev, restrictManagementToLocalhost: value }))
}
disabled={ampcodeModalLoading || ampcodeSaving}
/>
<div className="hint">{t('ai_providers.ampcode_restrict_management_hint')}</div>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')}
@@ -1897,19 +1876,21 @@ export function AiProvidersPage() {
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={providerForm.modelEntries}
onChange={(entries) =>
setProviderForm((prev) => ({ ...prev, modelEntries: entries }))
}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={saving}
/>
</div>
{modal?.type === 'claude' && (
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={providerForm.modelEntries}
onChange={(entries) =>
setProviderForm((prev) => ({ ...prev, modelEntries: entries }))
}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={saving}
/>
</div>
)}
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea

View File

@@ -0,0 +1,320 @@
@use 'sass:color';
@use '../styles/variables.scss' as *;
.dashboard {
display: flex;
flex-direction: column;
gap: $spacing-lg;
max-width: 1000px;
margin: 0 auto;
}
.header {
margin-bottom: $spacing-sm;
}
.title {
font-size: 26px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
line-height: 1.4;
}
.subtitle {
font-size: 15px;
color: var(--text-secondary);
margin: $spacing-xs 0 0 0;
}
.connectionCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: $spacing-md $spacing-lg;
flex-wrap: wrap;
}
.connectionStatus {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.statusDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: $gray-400;
&.connected {
background: $success-color;
box-shadow: 0 0 8px rgba($success-color, 0.5);
}
&.connecting {
background: $warning-color;
animation: pulse 1s ease-in-out infinite;
}
&.disconnected {
background: $error-color;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.statusText {
font-weight: 600;
color: var(--text-primary);
}
.connectionInfo {
display: flex;
align-items: center;
gap: $spacing-md;
flex-wrap: wrap;
}
.serverUrl {
font-family: $font-mono;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-primary);
padding: 4px 10px;
border-radius: $radius-md;
border: 1px solid var(--border-color);
}
.serverVersion {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
background: rgba($primary-color, 0.1);
padding: 4px 10px;
border-radius: $radius-full;
}
.buildDate {
font-size: 12px;
color: var(--text-secondary);
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $spacing-md;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.statCard {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-lg;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
text-decoration: none;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
}
.statIcon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: $radius-md;
background: var(--bg-secondary);
color: var(--primary-color);
}
.statContent {
display: flex;
flex-direction: column;
gap: 2px;
}
.statValue {
font-size: 24px;
font-weight: 800;
color: var(--text-primary);
}
.statLabel {
font-size: 13px;
color: var(--text-secondary);
}
.statSublabel {
font-size: 11px;
color: var(--text-secondary);
opacity: 0.8;
margin-top: 2px;
}
.section {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.sectionTitle {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.actionsGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm;
a {
text-decoration: none;
}
}
.actionButton {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
// Button 内部的 span 需要 flex 对齐图标和文字
> span {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
}
svg {
flex-shrink: 0;
}
}
.configGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-sm;
}
.configItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
padding: $spacing-sm $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
}
.configLabel {
font-size: 13px;
color: var(--text-secondary);
}
.configValue {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
&.enabled {
color: $success-color;
}
&.disabled {
color: var(--text-secondary);
}
}
.configValueMono {
font-size: 12px;
font-family: $font-mono;
color: var(--text-secondary);
word-break: break-all;
}
.configItemFull {
grid-column: 1 / -1;
}
// Usage stats section
.usageGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: $spacing-sm;
}
.usageCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
text-align: center;
}
.usageValue {
font-size: 22px;
font-weight: 800;
color: var(--primary-color);
}
.usageLabel {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.usageLoading,
.usageEmpty {
padding: $spacing-lg;
text-align: center;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
}
.viewMoreLink {
display: inline-flex;
align-items: center;
font-size: 13px;
color: var(--primary-color);
text-decoration: none;
margin-top: $spacing-xs;
&:hover {
text-decoration: underline;
}
}

321
src/pages/DashboardPage.tsx Normal file
View File

@@ -0,0 +1,321 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
IconKey,
IconBot,
IconFileText,
IconSatellite
} from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useModelsStore } from '@/stores';
import { apiKeysApi, providersApi, authFilesApi } from '@/services/api';
import styles from './DashboardPage.module.scss';
interface QuickStat {
label: string;
value: number | string;
icon: React.ReactNode;
path: string;
loading?: boolean;
sublabel?: string;
}
interface ProviderStats {
gemini: number | null;
codex: number | null;
claude: number | null;
openai: number | null;
}
export function DashboardPage() {
const { t, i18n } = useTranslation();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const serverVersion = useAuthStore((state) => state.serverVersion);
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
const apiBase = useAuthStore((state) => state.apiBase);
const config = useConfigStore((state) => state.config);
const models = useModelsStore((state) => state.models);
const modelsLoading = useModelsStore((state) => state.loading);
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
const [stats, setStats] = useState<{
apiKeys: number | null;
authFiles: number | null;
}>({
apiKeys: null,
authFiles: null
});
const [providerStats, setProviderStats] = useState<ProviderStats>({
gemini: null,
codex: null,
claude: null,
openai: null
});
const [loading, setLoading] = useState(true);
const apiKeysCache = useRef<string[]>([]);
useEffect(() => {
apiKeysCache.current = [];
}, [apiBase, config?.apiKeys]);
const normalizeApiKeyList = (input: any): string[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const keys: string[] = [];
input.forEach((item) => {
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
const trimmed = String(value || '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
keys.push(trimmed);
});
return keys;
};
const resolveApiKeysForModels = useCallback(async () => {
if (apiKeysCache.current.length) {
return apiKeysCache.current;
}
const configKeys = normalizeApiKeyList(config?.apiKeys);
if (configKeys.length) {
apiKeysCache.current = configKeys;
return configKeys;
}
try {
const list = await apiKeysApi.list();
const normalized = normalizeApiKeyList(list);
if (normalized.length) {
apiKeysCache.current = normalized;
}
return normalized;
} catch {
return [];
}
}, [config?.apiKeys]);
const fetchModels = useCallback(async () => {
if (connectionStatus !== 'connected' || !apiBase) {
return;
}
try {
const apiKeys = await resolveApiKeysForModels();
const primaryKey = apiKeys[0];
await fetchModelsFromStore(apiBase, primaryKey);
} catch {
// Ignore model fetch errors on dashboard
}
}, [connectionStatus, apiBase, resolveApiKeysForModels, fetchModelsFromStore]);
useEffect(() => {
const fetchStats = async () => {
setLoading(true);
try {
const [keysRes, filesRes, geminiRes, codexRes, claudeRes, openaiRes] = await Promise.allSettled([
apiKeysApi.list(),
authFilesApi.list(),
providersApi.getGeminiKeys(),
providersApi.getCodexConfigs(),
providersApi.getClaudeConfigs(),
providersApi.getOpenAIProviders()
]);
setStats({
apiKeys: keysRes.status === 'fulfilled' ? keysRes.value.length : null,
authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null
});
setProviderStats({
gemini: geminiRes.status === 'fulfilled' ? geminiRes.value.length : null,
codex: codexRes.status === 'fulfilled' ? codexRes.value.length : null,
claude: claudeRes.status === 'fulfilled' ? claudeRes.value.length : null,
openai: openaiRes.status === 'fulfilled' ? openaiRes.value.length : null
});
} finally {
setLoading(false);
}
};
if (connectionStatus === 'connected') {
fetchStats();
fetchModels();
} else {
setLoading(false);
}
}, [connectionStatus, fetchModels]);
// Calculate total provider keys only when all provider stats are available.
const providerStatsReady =
providerStats.gemini !== null &&
providerStats.codex !== null &&
providerStats.claude !== null &&
providerStats.openai !== null;
const hasProviderStats =
providerStats.gemini !== null ||
providerStats.codex !== null ||
providerStats.claude !== null ||
providerStats.openai !== null;
const totalProviderKeys = providerStatsReady
? (providerStats.gemini ?? 0) +
(providerStats.codex ?? 0) +
(providerStats.claude ?? 0) +
(providerStats.openai ?? 0)
: 0;
const quickStats: QuickStat[] = [
{
label: t('nav.api_keys'),
value: stats.apiKeys ?? '-',
icon: <IconKey size={24} />,
path: '/api-keys',
loading: loading && stats.apiKeys === null,
sublabel: t('dashboard.management_keys')
},
{
label: t('nav.ai_providers'),
value: loading ? '-' : providerStatsReady ? totalProviderKeys : '-',
icon: <IconBot size={24} />,
path: '/ai-providers',
loading: loading,
sublabel: hasProviderStats
? t('dashboard.provider_keys_detail', {
gemini: providerStats.gemini ?? '-',
codex: providerStats.codex ?? '-',
claude: providerStats.claude ?? '-',
openai: providerStats.openai ?? '-'
})
: undefined
},
{
label: t('nav.auth_files'),
value: stats.authFiles ?? '-',
icon: <IconFileText size={24} />,
path: '/auth-files',
loading: loading && stats.authFiles === null,
sublabel: t('dashboard.oauth_credentials')
},
{
label: t('dashboard.available_models'),
value: modelsLoading ? '-' : models.length,
icon: <IconSatellite size={24} />,
path: '/system',
loading: modelsLoading,
sublabel: t('dashboard.available_models_desc')
}
];
return (
<div className={styles.dashboard}>
<div className={styles.header}>
<h1 className={styles.title}>{t('dashboard.title')}</h1>
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
</div>
<div className={styles.connectionCard}>
<div className={styles.connectionStatus}>
<span
className={`${styles.statusDot} ${
connectionStatus === 'connected'
? styles.connected
: connectionStatus === 'connecting'
? styles.connecting
: styles.disconnected
}`}
/>
<span className={styles.statusText}>
{t(
connectionStatus === 'connected'
? 'common.connected'
: connectionStatus === 'connecting'
? 'common.connecting'
: 'common.disconnected'
)}
</span>
</div>
<div className={styles.connectionInfo}>
<span className={styles.serverUrl}>{apiBase || '-'}</span>
{serverVersion && <span className={styles.serverVersion}>v{serverVersion}</span>}
{serverBuildDate && (
<span className={styles.buildDate}>
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
</span>
)}
</div>
</div>
<div className={styles.statsGrid}>
{quickStats.map((stat) => (
<Link key={stat.path} to={stat.path} className={styles.statCard}>
<div className={styles.statIcon}>{stat.icon}</div>
<div className={styles.statContent}>
<span className={styles.statValue}>{stat.loading ? '...' : stat.value}</span>
<span className={styles.statLabel}>{stat.label}</span>
{stat.sublabel && !stat.loading && (
<span className={styles.statSublabel}>{stat.sublabel}</span>
)}
</div>
</Link>
))}
</div>
{config && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>{t('dashboard.current_config')}</h2>
<div className={styles.configGrid}>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.debug_enable')}</span>
<span className={`${styles.configValue} ${config.debug ? styles.enabled : styles.disabled}`}>
{config.debug ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.usage_statistics_enable')}</span>
<span className={`${styles.configValue} ${config.usageStatisticsEnabled ? styles.enabled : styles.disabled}`}>
{config.usageStatisticsEnabled ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.logging_to_file_enable')}</span>
<span className={`${styles.configValue} ${config.loggingToFile ? styles.enabled : styles.disabled}`}>
{config.loggingToFile ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.request_log_enable')}</span>
<span className={`${styles.configValue} ${config.requestLog ? styles.enabled : styles.disabled}`}>
{config.requestLog ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.retry_count_label')}</span>
<span className={styles.configValue}>{config.requestRetry ?? 0}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.ws_auth_enable')}</span>
<span className={`${styles.configValue} ${config.wsAuth ? styles.enabled : styles.disabled}`}>
{config.wsAuth ? t('common.yes') : t('common.no')}
</span>
</div>
{config.proxyUrl && (
<div className={`${styles.configItem} ${styles.configItemFull}`}>
<span className={styles.configLabel}>{t('basic_settings.proxy_url_label')}</span>
<span className={styles.configValueMono}>{config.proxyUrl}</span>
</div>
)}
</div>
<Link to="/settings" className={styles.viewMoreLink}>
{t('dashboard.edit_settings')}
</Link>
</div>
)}
</div>
);
}

View File

@@ -281,7 +281,7 @@ export function OAuthPage() {
{t('auth_login.oauth_callback_button')}
</Button>
</div>
{state.callbackStatus === 'success' && (
{state.callbackStatus === 'success' && state.status === 'waiting' && (
<div className="status-badge success" style={{ marginTop: 8 }}>
{t('auth_login.oauth_callback_status_success')}
</div>

View File

@@ -66,11 +66,12 @@
border: 1px solid var(--border-color);
background: var(--bg-primary);
box-shadow: var(--shadow-lg);
}
:global(.loading-spinner) {
border-color: rgba(59, 130, 246, 0.25);
border-top-color: var(--primary-color);
}
.loadingOverlaySpinner {
border-color: rgba(59, 130, 246, 0.25);
border-top-color: var(--primary-color);
box-shadow: 0 0 10px rgba(59, 130, 246, 0.25);
}
.loadingOverlayText {

View File

@@ -520,7 +520,7 @@ export function UsagePage() {
{loading && !usage && (
<div className={styles.loadingOverlay} aria-busy="true">
<div className={styles.loadingOverlayContent}>
<LoadingSpinner size={28} />
<LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
</div>
</div>

View File

@@ -18,9 +18,6 @@ export const ampcodeApi = {
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
updateRestrictManagementToLocalhost: (enabled: boolean) =>
apiClient.put('/ampcode/restrict-management-to-localhost', { value: enabled }),
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
const data = await apiClient.get('/ampcode/model-mappings');
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;

View File

@@ -82,6 +82,10 @@ class ApiClient {
(config) => {
// 设置 baseURL
config.baseURL = this.apiBase;
if (config.url) {
// Normalize deprecated Gemini endpoint to the current path.
config.url = config.url.replace(/\/generative-language-api-key\b/g, '/gemini-api-key');
}
// 添加认证头
if (this.managementKey) {

View File

@@ -3,6 +3,7 @@
*/
import { apiClient } from './client';
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
export interface LogsQuery {
after?: number;
@@ -25,14 +26,17 @@ export interface ErrorLogsResponse {
}
export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> => apiClient.get('/logs', { params }),
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
clearLogs: () => apiClient.delete('/logs'),
fetchErrorLogs: (): Promise<ErrorLogsResponse> => apiClient.get('/request-error-logs'),
fetchErrorLogs: (): Promise<ErrorLogsResponse> =>
apiClient.get('/request-error-logs', { timeout: LOGS_TIMEOUT_MS }),
downloadErrorLog: (filename: string) =>
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
};

View File

@@ -205,15 +205,6 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
const restrictManagementToLocalhost = normalizeBoolean(
source['restrict-management-to-localhost'] ??
source.restrictManagementToLocalhost ??
source['restrict_management_to_localhost']
);
if (restrictManagementToLocalhost !== undefined) {
config.restrictManagementToLocalhost = restrictManagementToLocalhost;
}
const forceModelMappings = normalizeBoolean(
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
);

View File

@@ -10,7 +10,6 @@ export interface AmpcodeModelMapping {
export interface AmpcodeConfig {
upstreamUrl?: string;
upstreamApiKey?: string;
restrictManagementToLocalhost?: boolean;
modelMappings?: AmpcodeModelMapping[];
forceModelMappings?: boolean;
}

View File

@@ -18,6 +18,7 @@ export const LOG_REFRESH_DELAY_MS = 500;
// 日志相关
export const MAX_LOG_LINES = 2000;
export const LOG_FETCH_LIMIT = 2500;
export const LOGS_TIMEOUT_MS = 60 * 1000;
// 认证文件分页
export const DEFAULT_AUTH_FILES_PAGE_SIZE = 20;