mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +08:00
- 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
274 lines
8.8 KiB
TypeScript
274 lines
8.8 KiB
TypeScript
import { useEffect, useState, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
import { useNotificationStore, useAuthStore } from '@/stores';
|
|
import { logsApi } from '@/services/api/logs';
|
|
import styles from './LogsPage.module.scss';
|
|
|
|
interface ErrorLogItem {
|
|
name: string;
|
|
size?: number;
|
|
modified?: number;
|
|
}
|
|
|
|
// 限制显示的最大日志行数,防止渲染过多导致卡死
|
|
const MAX_DISPLAY_LINES = 500;
|
|
|
|
export function LogsPage() {
|
|
const { t } = useTranslation();
|
|
const { showNotification } = useNotificationStore();
|
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
|
|
|
const [logLines, setLogLines] = useState<string[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
|
const [loadingErrors, setLoadingErrors] = useState(false);
|
|
|
|
const logViewerRef = useRef<HTMLPreElement | null>(null);
|
|
const pendingScrollToBottomRef = useRef(false);
|
|
|
|
// 保存最新时间戳用于增量获取
|
|
const latestTimestampRef = useRef<number>(0);
|
|
|
|
const disableControls = connectionStatus !== 'connected';
|
|
|
|
const isNearBottom = (node: HTMLPreElement | null) => {
|
|
if (!node) return true;
|
|
const threshold = 24;
|
|
return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
|
|
};
|
|
|
|
const scrollToBottom = () => {
|
|
const node = logViewerRef.current;
|
|
if (!node) return;
|
|
node.scrollTop = node.scrollHeight;
|
|
};
|
|
|
|
const isWarningLine = (line: string) => /\bwarn(?:ing)?\b/i.test(line) || line.includes('警告');
|
|
|
|
const loadLogs = async (incremental = false) => {
|
|
if (connectionStatus !== 'connected') {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!incremental) {
|
|
setLoading(true);
|
|
}
|
|
setError('');
|
|
|
|
try {
|
|
pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current);
|
|
|
|
const params = incremental && latestTimestampRef.current > 0
|
|
? { after: latestTimestampRef.current }
|
|
: {};
|
|
const data = await logsApi.fetchLogs(params);
|
|
|
|
// 更新时间戳
|
|
if (data['latest-timestamp']) {
|
|
latestTimestampRef.current = data['latest-timestamp'];
|
|
}
|
|
|
|
const newLines = Array.isArray(data.lines) ? data.lines : [];
|
|
|
|
if (incremental && newLines.length > 0) {
|
|
// 增量更新:追加新日志并限制总行数
|
|
setLogLines(prev => {
|
|
const combined = [...prev, ...newLines];
|
|
return combined.slice(-MAX_DISPLAY_LINES);
|
|
});
|
|
} else if (!incremental) {
|
|
// 全量加载:只取最后 MAX_DISPLAY_LINES 行
|
|
setLogLines(newLines.slice(-MAX_DISPLAY_LINES));
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Failed to load logs:', err);
|
|
if (!incremental) {
|
|
setError(err?.message || t('logs.load_error'));
|
|
}
|
|
} finally {
|
|
if (!incremental) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const clearLogs = async () => {
|
|
if (!window.confirm(t('logs.clear_confirm'))) return;
|
|
try {
|
|
await logsApi.clearLogs();
|
|
setLogLines([]);
|
|
latestTimestampRef.current = 0;
|
|
showNotification(t('logs.clear_success'), 'success');
|
|
} catch (err: any) {
|
|
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
|
}
|
|
};
|
|
|
|
const downloadLogs = () => {
|
|
const text = logLines.join('\n');
|
|
const blob = new Blob([text], { type: 'text/plain' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'logs.txt';
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
showNotification(t('logs.download_success'), 'success');
|
|
};
|
|
|
|
const loadErrorLogs = async () => {
|
|
if (connectionStatus !== 'connected') {
|
|
setLoadingErrors(false);
|
|
return;
|
|
}
|
|
|
|
setLoadingErrors(true);
|
|
try {
|
|
const res = await logsApi.fetchErrorLogs();
|
|
// API 返回 { files: [...] }
|
|
const files = (res as any)?.files;
|
|
const list: ErrorLogItem[] = Array.isArray(files)
|
|
? files.map((f: any) => ({
|
|
name: f.name,
|
|
size: f.size,
|
|
modified: f.modified
|
|
}))
|
|
: [];
|
|
setErrorLogs(list);
|
|
} catch (err: any) {
|
|
console.error('Failed to load error logs:', err);
|
|
// 静默失败,不影响主日志显示
|
|
setErrorLogs([]);
|
|
} finally {
|
|
setLoadingErrors(false);
|
|
}
|
|
};
|
|
|
|
const downloadErrorLog = async (name: string) => {
|
|
try {
|
|
const response = await logsApi.downloadErrorLog(name);
|
|
const blob = new Blob([response.data], { type: 'text/plain' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
showNotification(t('logs.error_log_download_success'), 'success');
|
|
} catch (err: any) {
|
|
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (connectionStatus === 'connected') {
|
|
latestTimestampRef.current = 0;
|
|
loadLogs(false);
|
|
loadErrorLogs();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [connectionStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!autoRefresh || connectionStatus !== 'connected') {
|
|
return;
|
|
}
|
|
const id = window.setInterval(() => {
|
|
loadLogs(true);
|
|
}, 8000);
|
|
return () => window.clearInterval(id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [autoRefresh, connectionStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingScrollToBottomRef.current) return;
|
|
if (loading) return;
|
|
if (!logViewerRef.current) return;
|
|
|
|
scrollToBottom();
|
|
pendingScrollToBottomRef.current = false;
|
|
}, [loading, logLines]);
|
|
|
|
const logsText = logLines.join('\n');
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
|
<div className={styles.content}>
|
|
<Card
|
|
title={t('logs.log_content')}
|
|
extra={
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
<Button variant="secondary" size="sm" onClick={() => loadLogs(false)} disabled={loading}>
|
|
{t('logs.refresh_button')}
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => setAutoRefresh((v) => !v)}>
|
|
{t('logs.auto_refresh')}: {autoRefresh ? t('common.yes') : t('common.no')}
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={downloadLogs} disabled={logLines.length === 0}>
|
|
{t('logs.download_button')}
|
|
</Button>
|
|
<Button variant="danger" size="sm" onClick={clearLogs} disabled={disableControls}>
|
|
{t('logs.clear_button')}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
{error && <div className="error-box">{error}</div>}
|
|
{loading ? (
|
|
<div className="hint">{t('logs.loading')}</div>
|
|
) : logsText ? (
|
|
<pre ref={logViewerRef} className="log-viewer log-viewer-lines">
|
|
{logLines.map((line, index) => (
|
|
<span key={index} className={`log-line${isWarningLine(line) ? ' log-line-warning' : ''}`}>
|
|
{line}
|
|
</span>
|
|
))}
|
|
</pre>
|
|
) : (
|
|
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
|
|
)}
|
|
</Card>
|
|
|
|
<Card
|
|
title={t('logs.error_logs_modal_title')}
|
|
extra={
|
|
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
|
|
{t('common.refresh')}
|
|
</Button>
|
|
}
|
|
>
|
|
{errorLogs.length === 0 ? (
|
|
<div className="hint">{t('logs.error_logs_empty')}</div>
|
|
) : (
|
|
<div className="item-list">
|
|
{errorLogs.map((item) => (
|
|
<div key={item.name} className="item-row">
|
|
<div className="item-meta">
|
|
<div className="item-title">{item.name}</div>
|
|
<div className="item-subtitle">
|
|
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
|
|
{item.modified ? new Date(item.modified).toLocaleString() : ''}
|
|
</div>
|
|
</div>
|
|
<div className="item-actions">
|
|
<Button variant="secondary" size="sm" onClick={() => downloadErrorLog(item.name)}>
|
|
{t('logs.error_logs_download')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|