forked from datawhale/whale-town-end
feat:添加日志功能
This commit is contained in:
@@ -8,7 +8,11 @@ export function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const selectedKey = location.pathname.startsWith('/users') ? 'users' : 'users';
|
||||
const selectedKey = location.pathname.startsWith('/logs')
|
||||
? 'logs'
|
||||
: location.pathname.startsWith('/users')
|
||||
? 'users'
|
||||
: 'users';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
@@ -28,6 +32,11 @@ export function AdminLayout() {
|
||||
label: '用户管理',
|
||||
onClick: () => navigate('/users'),
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: '运行日志',
|
||||
onClick: () => navigate('/logs'),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AdminLayout } from './AdminLayout';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { UsersPage } from '../pages/UsersPage';
|
||||
import { LogsPage } from '../pages/LogsPage';
|
||||
import { isAuthed } from '../lib/adminAuth';
|
||||
|
||||
export function App() {
|
||||
@@ -17,6 +18,7 @@ export function App() {
|
||||
>
|
||||
<Route index element={<Navigate to="/users" replace />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to={isAuthed() ? '/users' : '/login'} replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -11,6 +11,27 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function parseFilenameFromContentDisposition(contentDisposition: string | null): string | null {
|
||||
if (!contentDisposition) return null;
|
||||
|
||||
// Prefer RFC 5987 filename*=UTF-8''...
|
||||
const filenameStarMatch = contentDisposition.match(/filename\*=(?:UTF-8''|utf-8'')([^;]+)/);
|
||||
if (filenameStarMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(filenameStarMatch[1].trim().replace(/^"|"$/g, ''));
|
||||
} catch {
|
||||
return filenameStarMatch[1].trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
const filenameMatch = contentDisposition.match(/filename=([^;]+)/);
|
||||
if (filenameMatch?.[1]) {
|
||||
return filenameMatch[1].trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getToken();
|
||||
|
||||
@@ -44,6 +65,48 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function requestDownload(path: string, init?: RequestInit): Promise<{ blob: Blob; filename: string }>
|
||||
{
|
||||
const token = getToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(init?.headers as any),
|
||||
};
|
||||
|
||||
// Do NOT force Content-Type for downloads (GET binary)
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
// Try to extract message from JSON-ish body
|
||||
let message = `请求失败: ${res.status}`;
|
||||
try {
|
||||
const maybeJson = JSON.parse(text || '{}');
|
||||
message = maybeJson?.message || message;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new ApiError(message, res.status);
|
||||
}
|
||||
|
||||
const filename =
|
||||
parseFilenameFromContentDisposition(res.headers.get('content-disposition')) || 'logs.tar.gz';
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
adminLogin: (identifier: string, password: string) =>
|
||||
request<any>('/admin/auth/login', {
|
||||
@@ -59,4 +122,9 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
}),
|
||||
|
||||
getRuntimeLogs: (lines = 200) =>
|
||||
request<any>(`/admin/logs/runtime?lines=${encodeURIComponent(lines)}`),
|
||||
|
||||
downloadLogsArchive: () => requestDownload('/admin/logs/archive'),
|
||||
};
|
||||
|
||||
106
client/src/pages/LogsPage.tsx
Normal file
106
client/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, InputNumber, Space, Typography } from 'antd';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
|
||||
export function LogsPage() {
|
||||
const [lines, setLines] = useState<number>(200);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadLoading, setDownloadLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<string>('');
|
||||
const [updatedAt, setUpdatedAt] = useState<string>('');
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
|
||||
const logText = useMemo(() => logLines.join('\n'), [logLines]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getRuntimeLogs(lines);
|
||||
if (!res?.success) {
|
||||
setError(res?.message || '运行日志获取失败');
|
||||
return;
|
||||
}
|
||||
setFile(res?.data?.file || '');
|
||||
setUpdatedAt(res?.data?.updated_at || '');
|
||||
setLogLines(Array.isArray(res?.data?.lines) ? res.data.lines : []);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : '运行日志获取失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const downloadArchive = async () => {
|
||||
setDownloadLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { blob, filename } = await api.downloadLogsArchive();
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || 'logs.tar.gz';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : '日志下载失败');
|
||||
}
|
||||
} finally {
|
||||
setDownloadLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{error ? <Alert type="error" message={error} /> : null}
|
||||
|
||||
<Card
|
||||
title="运行日志"
|
||||
extra={
|
||||
<Space>
|
||||
<span>行数</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={2000}
|
||||
value={lines}
|
||||
onChange={(v) => setLines(typeof v === 'number' ? v : 200)}
|
||||
/>
|
||||
<Button onClick={() => void load()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => void downloadArchive()} loading={downloadLoading}>
|
||||
下载日志压缩包
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
<Typography.Text type="secondary">
|
||||
{file ? `文件:${file}` : '文件:-'}
|
||||
{updatedAt ? ` 更新时间:${updatedAt}` : ''}
|
||||
</Typography.Text>
|
||||
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{logText || '暂无日志'}</pre>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user