mirror of
https://github.com/cita-777/metapi.git
synced 2026-11-02 13:22:57 +08:00
351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { api } from '../api.js';
|
|
import { useToast } from '../components/Toast.js';
|
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
|
import ModernSelect from '../components/ModernSelect.js';
|
|
import { tr } from '../i18n.js';
|
|
|
|
type ProgramEvent = {
|
|
id: number;
|
|
type: string;
|
|
title: string;
|
|
message?: string | null;
|
|
level: 'info' | 'warning' | 'error';
|
|
read: boolean;
|
|
relatedId?: number | null;
|
|
relatedType?: string | null;
|
|
createdAt?: string | null;
|
|
};
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
const TYPE_OPTIONS = [
|
|
{ value: '', label: '全部类型' },
|
|
{ value: 'checkin', label: '签到' },
|
|
{ value: 'balance', label: '余额' },
|
|
{ value: 'token', label: '令牌' },
|
|
{ value: 'proxy', label: '代理' },
|
|
{ value: 'status', label: '状态' },
|
|
{ value: 'site_notice', label: '站点公告' },
|
|
];
|
|
|
|
function levelLabel(level: string) {
|
|
if (level === 'error') return { label: '错误', cls: 'badge-error' };
|
|
if (level === 'warning') return { label: '警告', cls: 'badge-warning' };
|
|
return { label: '信息', cls: 'badge-info' };
|
|
}
|
|
|
|
function eventStatusLabel(row: ProgramEvent) {
|
|
const text = `${row.title || ''} ${row.message || ''}`.toLowerCase();
|
|
|
|
const parseCount = (pattern: RegExp): number | undefined => {
|
|
const match = text.match(pattern);
|
|
if (!match?.[1]) return undefined;
|
|
const value = Number.parseInt(match[1], 10);
|
|
return Number.isFinite(value) ? value : undefined;
|
|
};
|
|
|
|
const summary = {
|
|
success: parseCount(/成功[^\d]{0,6}(\d+)/i) ?? parseCount(/success(?:ful)?[^\d]{0,6}(\d+)/i),
|
|
skipped: parseCount(/跳过[^\d]{0,6}(\d+)/i) ?? parseCount(/skipped?[^\d]{0,6}(\d+)/i),
|
|
failed: parseCount(/失败[^\d]{0,6}(\d+)/i) ?? parseCount(/failed[^\d]{0,6}(\d+)/i),
|
|
};
|
|
|
|
if (summary.failed !== undefined || summary.success !== undefined || summary.skipped !== undefined) {
|
|
if ((summary.failed ?? 0) > 0) {
|
|
return { label: '失败', cls: 'badge-error' };
|
|
}
|
|
if ((summary.success ?? 0) > 0) {
|
|
return { label: '成功', cls: 'badge-success' };
|
|
}
|
|
if ((summary.skipped ?? 0) > 0) {
|
|
return { label: '跳过', cls: 'badge-warning' };
|
|
}
|
|
return { label: '成功', cls: 'badge-success' };
|
|
}
|
|
|
|
if (text.includes('失败') || text.includes('failed') || text.includes('error')) {
|
|
return { label: '失败', cls: 'badge-error' };
|
|
}
|
|
if (text.includes('跳过') || text.includes('skipped')) {
|
|
return { label: '跳过', cls: 'badge-warning' };
|
|
}
|
|
if (text.includes('进行中') || text.includes('已开始') || text.includes('running') || text.includes('pending')) {
|
|
return { label: '进行中', cls: 'badge-info' };
|
|
}
|
|
if (text.includes('成功') || text.includes('已完成') || text.includes('completed') || text.includes('finished')) {
|
|
return { label: '成功', cls: 'badge-success' };
|
|
}
|
|
|
|
if (row.level === 'error') return { label: '异常', cls: 'badge-error' };
|
|
if (row.level === 'warning') return { label: '警告', cls: 'badge-warning' };
|
|
return { label: '信息', cls: 'badge-info' };
|
|
}
|
|
|
|
export default function ProgramLogs() {
|
|
const [events, setEvents] = useState<ProgramEvent[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [filterType, setFilterType] = useState('');
|
|
const [onlyUnread, setOnlyUnread] = useState(false);
|
|
const [offset, setOffset] = useState(0);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [markingAll, setMarkingAll] = useState(false);
|
|
const [clearing, setClearing] = useState(false);
|
|
const [rowLoading, setRowLoading] = useState<Record<number, boolean>>({});
|
|
const toast = useToast();
|
|
|
|
const load = async (silent = false, append = false) => {
|
|
if (append) setLoadingMore(true);
|
|
else if (silent) setRefreshing(true);
|
|
else setLoading(true);
|
|
|
|
try {
|
|
const nextOffset = append ? offset : 0;
|
|
const params = new URLSearchParams();
|
|
params.set('limit', String(PAGE_SIZE));
|
|
params.set('offset', String(nextOffset));
|
|
if (filterType) params.set('type', filterType);
|
|
if (onlyUnread) params.set('read', 'false');
|
|
const rows = await api.getEvents(params.toString());
|
|
const safeRows = Array.isArray(rows) ? rows : [];
|
|
setEvents((prev) => (append ? [...prev, ...safeRows] : safeRows));
|
|
const loaded = append ? nextOffset + safeRows.length : safeRows.length;
|
|
setOffset(loaded);
|
|
setHasMore(safeRows.length >= PAGE_SIZE);
|
|
} catch (e: any) {
|
|
toast.error(e.message || '加载程序日志失败');
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [filterType, onlyUnread]);
|
|
|
|
const visibleRows = useMemo(() => events, [events]);
|
|
|
|
const withRowLoading = async (id: number, fn: () => Promise<void>) => {
|
|
setRowLoading((prev) => ({ ...prev, [id]: true }));
|
|
try {
|
|
await fn();
|
|
} finally {
|
|
setRowLoading((prev) => ({ ...prev, [id]: false }));
|
|
}
|
|
};
|
|
|
|
const markOneRead = async (id: number) => {
|
|
await withRowLoading(id, async () => {
|
|
await api.markEventRead(id);
|
|
setEvents((prev) => {
|
|
if (onlyUnread) return prev.filter((item) => item.id !== id);
|
|
return prev.map((item) => (item.id === id ? { ...item, read: true } : item));
|
|
});
|
|
});
|
|
};
|
|
|
|
const markAllRead = async () => {
|
|
setMarkingAll(true);
|
|
try {
|
|
await api.markAllEventsRead();
|
|
if (onlyUnread) setEvents([]);
|
|
else setEvents((prev) => prev.map((item) => ({ ...item, read: true })));
|
|
toast.success('已标记全部为已读');
|
|
} catch (e: any) {
|
|
toast.error(e.message || '标记失败');
|
|
} finally {
|
|
setMarkingAll(false);
|
|
}
|
|
};
|
|
|
|
const clearAll = async () => {
|
|
setClearing(true);
|
|
try {
|
|
await api.clearEvents();
|
|
setEvents([]);
|
|
setOffset(0);
|
|
setHasMore(false);
|
|
toast.success('日志已清空');
|
|
} catch (e: any) {
|
|
toast.error(e.message || '清空失败');
|
|
} finally {
|
|
setClearing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="animate-fade-in">
|
|
<div className="page-header">
|
|
<h2 className="page-title">{tr('程序日志')}</h2>
|
|
<div className="page-actions">
|
|
<button
|
|
onClick={() => load(true)}
|
|
disabled={refreshing}
|
|
className="btn btn-ghost"
|
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
|
>
|
|
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
|
|
</button>
|
|
<button
|
|
onClick={markAllRead}
|
|
disabled={markingAll}
|
|
className="btn btn-ghost"
|
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
|
>
|
|
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
|
|
</button>
|
|
<button
|
|
onClick={clearAll}
|
|
disabled={clearing}
|
|
className="btn btn-link btn-link-danger"
|
|
>
|
|
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
|
|
<div style={{ minWidth: 170 }}>
|
|
<ModernSelect
|
|
size="sm"
|
|
value={filterType}
|
|
onChange={(nextValue) => setFilterType(nextValue)}
|
|
options={TYPE_OPTIONS.map((item) => ({
|
|
value: item.value,
|
|
label: item.label,
|
|
}))}
|
|
placeholder="全部类型"
|
|
/>
|
|
</div>
|
|
|
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={onlyUnread}
|
|
onChange={(e) => {
|
|
setOffset(0);
|
|
setHasMore(true);
|
|
setOnlyUnread(e.target.checked);
|
|
}}
|
|
/>
|
|
仅看未读
|
|
</label>
|
|
|
|
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
|
共 {visibleRows.length} 条
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card" style={{ overflowX: 'auto' }}>
|
|
{loading ? (
|
|
<div style={{ padding: 20 }}>
|
|
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
|
|
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
|
|
<div className="skeleton" style={{ width: '100%', height: 34 }} />
|
|
</div>
|
|
) : visibleRows.length > 0 ? (
|
|
<table className="data-table program-logs-table">
|
|
<colgroup>
|
|
<col style={{ width: 170 }} />
|
|
<col style={{ width: 90 }} />
|
|
<col style={{ width: 90 }} />
|
|
<col style={{ width: 260 }} />
|
|
<col />
|
|
<col style={{ width: 110 }} />
|
|
<col style={{ width: 140 }} />
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th>时间</th>
|
|
<th>类型</th>
|
|
<th>级别</th>
|
|
<th>标题</th>
|
|
<th>内容</th>
|
|
<th>状态</th>
|
|
<th style={{ textAlign: 'right' }}>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleRows.map((row, idx) => {
|
|
const level = levelLabel(row.level || 'info');
|
|
const eventStatus = eventStatusLabel(row);
|
|
return (
|
|
<tr key={row.id} className={`animate-slide-up stagger-${Math.min(idx + 1, 5)}`}>
|
|
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
|
{formatDateTimeLocal(row.createdAt)}
|
|
</td>
|
|
<td>
|
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
|
{row.type || '-'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>
|
|
{level.label}
|
|
</span>
|
|
</td>
|
|
<td className="program-logs-title-cell">
|
|
{row.title || '-'}
|
|
</td>
|
|
<td className="program-logs-content-cell">
|
|
{row.message || '-'}
|
|
</td>
|
|
<td>
|
|
<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>
|
|
{eventStatus.label}
|
|
</span>
|
|
</td>
|
|
<td style={{ textAlign: 'right' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
|
{row.read ? (
|
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
|
|
) : (
|
|
<span className="badge badge-warning" style={{ fontSize: 11 }}>未读</span>
|
|
)}
|
|
{!row.read && (
|
|
<button
|
|
onClick={() => markOneRead(row.id)}
|
|
disabled={!!rowLoading[row.id]}
|
|
className="btn btn-link btn-link-primary"
|
|
>
|
|
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
<div className="empty-state">
|
|
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<div className="empty-state-title">暂无日志</div>
|
|
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!loading && visibleRows.length > 0 && hasMore && (
|
|
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center' }}>
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => load(false, true)}
|
|
disabled={loadingMore}
|
|
style={{ border: '1px solid var(--color-border)', padding: '8px 16px' }}
|
|
>
|
|
{loadingMore ? <><span className="spinner spinner-sm" /> 加载中...</> : '加载更多'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|