- Create SwipeableContent component for sidebar swipe on non-tab pages - Add swipe-to-close sidebar from overlay - Make swipe work from entire page (ignoring interactive elements) - Show title and divider on desktop when tab bar is at bottom - Hide title/divider only on mobile for bottom position 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
168 lines
6.1 KiB
TypeScript
168 lines
6.1 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from '../../contexts/LanguageContext';
|
|
import { useSidebar } from '../../contexts/SidebarContext';
|
|
import TabsScroller from '../../components/TabsScroller';
|
|
import { SwipeableContent } from '../../components/SwipeableContent';
|
|
import { auditAPI } from '../../api/client';
|
|
import type { AuditLogItem } from '../../api/client';
|
|
import '../../styles/AdminAudit.css';
|
|
|
|
export default function AuditLogs() {
|
|
const { t } = useTranslation();
|
|
const { toggleMobileMenu } = useSidebar();
|
|
|
|
const [items, setItems] = useState<AuditLogItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
const [page, setPage] = useState(1);
|
|
const pageSize = 50;
|
|
|
|
const [username, setUsername] = useState('');
|
|
const [action, setAction] = useState('');
|
|
const [resourceType, setResourceType] = useState('');
|
|
const [status, setStatus] = useState('');
|
|
|
|
const params = useMemo(() => {
|
|
const p: Record<string, unknown> = { page, page_size: pageSize };
|
|
if (username.trim()) p.username = username.trim();
|
|
if (action.trim()) p.action = action.trim();
|
|
if (resourceType.trim()) p.resource_type = resourceType.trim();
|
|
if (status.trim()) p.status = status.trim();
|
|
return p;
|
|
}, [page, pageSize, username, action, resourceType, status]);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const data = await auditAPI.list(params);
|
|
setItems(data.items || []);
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.detail || t.common.error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [params]);
|
|
|
|
const resetFilters = () => {
|
|
setUsername('');
|
|
setAction('');
|
|
setResourceType('');
|
|
setStatus('');
|
|
setPage(1);
|
|
};
|
|
|
|
return (
|
|
<SwipeableContent className="main-content admin-audit-root">
|
|
<div className="page-tabs-container">
|
|
<TabsScroller className="page-tabs-slider">
|
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
|
<span className="material-symbols-outlined">menu</span>
|
|
</button>
|
|
<div className="page-title-section">
|
|
<span className="page-title-text">{t.auditPage.title}</span>
|
|
</div>
|
|
</TabsScroller>
|
|
</div>
|
|
|
|
<div className="page-content">
|
|
<div className="audit-filters">
|
|
<input
|
|
className="audit-input"
|
|
placeholder={t.auditPage.username}
|
|
value={username}
|
|
onChange={(e) => { setUsername(e.target.value); setPage(1); }}
|
|
/>
|
|
<input
|
|
className="audit-input"
|
|
placeholder={t.auditPage.action}
|
|
value={action}
|
|
onChange={(e) => { setAction(e.target.value); setPage(1); }}
|
|
/>
|
|
<input
|
|
className="audit-input"
|
|
placeholder={t.auditPage.resourceType}
|
|
value={resourceType}
|
|
onChange={(e) => { setResourceType(e.target.value); setPage(1); }}
|
|
/>
|
|
<select
|
|
className="audit-input"
|
|
value={status}
|
|
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
|
|
>
|
|
<option value="">{t.auditPage.anyStatus}</option>
|
|
<option value="success">{t.auditPage.statusSuccess}</option>
|
|
<option value="failure">{t.auditPage.statusFailure}</option>
|
|
<option value="pending">{t.auditPage.statusPending}</option>
|
|
<option value="error">{t.auditPage.statusError}</option>
|
|
</select>
|
|
<button className="audit-reset-btn" onClick={resetFilters} disabled={loading}>
|
|
{t.common.reset}
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="error-message">{error}</div>}
|
|
|
|
{loading ? (
|
|
<div className="loading">{t.common.loading}</div>
|
|
) : items.length === 0 ? (
|
|
<div className="audit-empty">{t.auditPage.empty}</div>
|
|
) : (
|
|
<div className="audit-table-card">
|
|
<table className="audit-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{t.auditPage.time}</th>
|
|
<th>{t.auditPage.user}</th>
|
|
<th>{t.auditPage.action}</th>
|
|
<th>{t.auditPage.resource}</th>
|
|
<th>{t.auditPage.status}</th>
|
|
<th>{t.auditPage.ip}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((log) => (
|
|
<tr key={log.id}>
|
|
<td className="mono">{new Date(log.created_at).toLocaleString()}</td>
|
|
<td>{log.username || '—'}</td>
|
|
<td className="mono">{log.action}</td>
|
|
<td className="mono">{log.resource_type || '—'}{log.resource_id ? `:${log.resource_id}` : ''}</td>
|
|
<td>
|
|
<span className={`badge ${
|
|
log.status === 'success' ? 'badge-success' :
|
|
log.status === 'failure' ? 'badge-muted' :
|
|
log.status === 'error' ? 'badge-error' :
|
|
log.status === 'pending' ? 'badge-warning' :
|
|
'badge-neutral'
|
|
}`}>{log.status}</span>
|
|
</td>
|
|
<td className="mono">{log.ip_address || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="audit-pagination">
|
|
<button className="btn-link" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>
|
|
{t.auditPage.prev}
|
|
</button>
|
|
<span className="audit-page-indicator">
|
|
{t.auditPage.page} {page}
|
|
</span>
|
|
<button className="btn-link" onClick={() => setPage((p) => p + 1)} disabled={loading || items.length < pageSize}>
|
|
{t.auditPage.next}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</SwipeableContent>
|
|
);
|
|
}
|