mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 10:40:50 +08:00
feat: initialize new React application structure with TypeScript, ESLint, and Prettier configurations, while removing legacy files and adding new components and pages for enhanced functionality
This commit is contained in:
20
src/components/common/NotificationContainer.tsx
Normal file
20
src/components/common/NotificationContainer.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
export function NotificationContainer() {
|
||||
const { notifications, removeNotification } = useNotificationStore();
|
||||
|
||||
if (!notifications.length) return null;
|
||||
|
||||
return (
|
||||
<div className="notification-container">
|
||||
{notifications.map((notification) => (
|
||||
<div key={notification.id} className={`notification ${notification.type}`}>
|
||||
<div className="message">{notification.message}</div>
|
||||
<button className="close-btn" onClick={() => removeNotification(notification.id)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
src/components/layout/MainLayout.tsx
Normal file
225
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { versionApi } from '@/services/api';
|
||||
import { isLocalhost } from '@/utils/connection';
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
const cleaned = version.trim().replace(/^v/i, '');
|
||||
if (!cleaned) return null;
|
||||
const parts = cleaned
|
||||
.split(/[^0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.filter(Number.isFinite);
|
||||
return parts.length ? parts : null;
|
||||
};
|
||||
|
||||
const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
const latestParts = parseVersionSegments(latest);
|
||||
const currentParts = parseVersionSegments(current);
|
||||
if (!latestParts || !currentParts) return null;
|
||||
const length = Math.max(latestParts.length, currentParts.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = latestParts[i] || 0;
|
||||
const c = currentParts[i] || 0;
|
||||
if (l > c) return 1;
|
||||
if (l < c) return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export function MainLayout() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showNotification } = useNotificationStore();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const checkAuth = useAuthStore((state) => state.checkAuth);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const toggleTheme = useThemeStore((state) => state.toggleTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [checkingConnection, setCheckingConnection] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
|
||||
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
// ignore initial failure; login flow会提示
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
const statusClass =
|
||||
connectionStatus === 'connected'
|
||||
? 'success'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'warning'
|
||||
: connectionStatus === 'error'
|
||||
? 'error'
|
||||
: 'muted';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/settings', label: t('nav.basic_settings') },
|
||||
{ path: '/api-keys', label: t('nav.api_keys') },
|
||||
{ path: '/ai-providers', label: t('nav.ai_providers') },
|
||||
{ path: '/auth-files', label: t('nav.auth_files') },
|
||||
...(isLocal ? [{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }) }] : []),
|
||||
{ path: '/usage', label: t('nav.usage_stats') },
|
||||
{ path: '/config', label: t('nav.config_management') },
|
||||
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs') }] : []),
|
||||
{ path: '/system', label: t('nav.system_info') }
|
||||
];
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
clearCache();
|
||||
try {
|
||||
await fetchConfig(undefined, true);
|
||||
showNotification(t('notification.data_refreshed'), 'success');
|
||||
} catch (error: any) {
|
||||
showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckConnection = async () => {
|
||||
setCheckingConnection(true);
|
||||
try {
|
||||
const ok = await checkAuth();
|
||||
if (ok) {
|
||||
showNotification(t('common.connected_status'), 'success');
|
||||
} else {
|
||||
showNotification(t('common.disconnected_status'), 'warning');
|
||||
}
|
||||
} catch (error: any) {
|
||||
showNotification(`${t('notification.login_failed')}: ${error?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setCheckingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionCheck = async () => {
|
||||
setCheckingVersion(true);
|
||||
try {
|
||||
const data = await versionApi.checkLatest();
|
||||
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const comparison = compareVersions(latest, serverVersion);
|
||||
|
||||
if (!latest) {
|
||||
showNotification(t('system_info.version_check_error'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison === null) {
|
||||
showNotification(t('system_info.version_current_missing'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0) {
|
||||
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
|
||||
} else {
|
||||
showNotification(t('system_info.version_is_latest'), 'success');
|
||||
}
|
||||
} catch (error: any) {
|
||||
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setCheckingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : ''}`}>
|
||||
<div className="brand">{t('title.abbr')}</div>
|
||||
<div className="nav-section">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="content">
|
||||
<header className="main-header">
|
||||
<div className="left">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}>
|
||||
☰
|
||||
</Button>
|
||||
<div className="connection">
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
{t(
|
||||
connectionStatus === 'connected'
|
||||
? 'common.connected_status'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'common.connecting_status'
|
||||
: 'common.disconnected_status'
|
||||
)}
|
||||
</span>
|
||||
<span className="base">{apiBase || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Button variant="secondary" size="sm" onClick={handleCheckConnection} loading={checkingConnection}>
|
||||
{t('header.check_connection')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleRefreshAll}>
|
||||
{t('header.refresh_all')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleVersionCheck} loading={checkingVersion}>
|
||||
{t('system_info.version_check_button')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={toggleLanguage}>
|
||||
{t('language.switch')}: {language === 'zh-CN' ? t('language.chinese') : t('language.english')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={toggleTheme}>
|
||||
{t('theme.switch')}: {theme === 'dark' ? t('theme.dark') : t('theme.light')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleLogout}>
|
||||
{t('header.logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="footer">
|
||||
<span>
|
||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span>
|
||||
{t('footer.build_date')}:{' '}
|
||||
{serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/ui/Button.tsx
Normal file
39
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'md' | 'sm';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
disabled,
|
||||
...rest
|
||||
}: PropsWithChildren<ButtonProps>) {
|
||||
const classes = [
|
||||
'btn',
|
||||
`btn-${variant}`,
|
||||
size === 'sm' ? 'btn-sm' : '',
|
||||
fullWidth ? 'btn-full' : '',
|
||||
className
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<button className={classes} disabled={disabled || loading} {...rest}>
|
||||
{loading && <span className="loading-spinner" aria-hidden="true" />}
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
20
src/components/ui/Card.tsx
Normal file
20
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: ReactNode;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ title, extra, children }: PropsWithChildren<CardProps>) {
|
||||
return (
|
||||
<div className="card">
|
||||
{(title || extra) && (
|
||||
<div className="card-header">
|
||||
<div className="title">{title}</div>
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/EmptyState.tsx
Normal file
24
src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-content">
|
||||
<div className="empty-icon" aria-hidden="true">
|
||||
◦
|
||||
</div>
|
||||
<div>
|
||||
<div className="empty-title">{title}</div>
|
||||
{description && <div className="empty-desc">{description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{action && <div className="empty-action">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/ui/HeaderInputList.tsx
Normal file
74
src/components/ui/HeaderInputList.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Button } from './Button';
|
||||
import type { HeaderEntry } from '@/utils/headers';
|
||||
|
||||
interface HeaderInputListProps {
|
||||
entries: HeaderEntry[];
|
||||
onChange: (entries: HeaderEntry[]) => void;
|
||||
addLabel: string;
|
||||
disabled?: boolean;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
}
|
||||
|
||||
export function HeaderInputList({
|
||||
entries,
|
||||
onChange,
|
||||
addLabel,
|
||||
disabled = false,
|
||||
keyPlaceholder = 'X-Custom-Header',
|
||||
valuePlaceholder = 'value'
|
||||
}: HeaderInputListProps) {
|
||||
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
||||
|
||||
const updateEntry = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
onChange([...currentEntries, { key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
const next = currentEntries.filter((_, idx) => idx !== index);
|
||||
onChange(next.length ? next : [{ key: '', value: '' }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="header-input-list">
|
||||
{currentEntries.map((entry, index) => (
|
||||
<Fragment key={index}>
|
||||
<div className="header-input-row">
|
||||
<input
|
||||
className="input"
|
||||
placeholder={keyPlaceholder}
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEntry(index, 'key', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="header-separator">:</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={valuePlaceholder}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, 'value', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={disabled || currentEntries.length <= 1}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/Input.tsx
Normal file
26
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
export function Input({ label, hint, error, rightElement, className = '', ...rest }: InputProps) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
{label && <label>{label}</label>}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input className={`input ${className}`.trim()} {...rest} />
|
||||
{rightElement && (
|
||||
<div style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
{rightElement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hint && <div className="hint">{hint}</div>}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/components/ui/LoadingSpinner.tsx
Normal file
10
src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export function LoadingSpinner({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{ width: size, height: size, borderWidth: size / 7 }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
src/components/ui/Modal.tsx
Normal file
34
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
title?: ReactNode;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
if (!open) return null;
|
||||
|
||||
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleMaskClick}>
|
||||
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/ToggleSwitch.tsx
Normal file
24
src/components/ui/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ToggleSwitch({ checked, onChange, label, disabled = false }: ToggleSwitchProps) {
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
||||
<span className="track">
|
||||
<span className="thumb" />
|
||||
</span>
|
||||
{label && <span className="label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user