技术栈切换(按 AGENTS.md 批准栈):react-admin/MUI → Refine v5 + shadcn/ui
(Tailwind CSS v4),Vite 8 + React 19 + react-router v7 hash 路由。
- 全部 9 个页面重写:AccountsPage/BrowsersPage(BrowserList/CreatePage/Detail)/
BrowserImagesPage/GatewaysPage/NetworkExitsPage/TasksPage/TracePages/
DraftPage/AuditPage;自建 lib/ui.jsx 组件库与 Layout(侧导航 + Outlet)
- 简单创建/编辑改 modal(账号/网关/镜像/出口),环境创建走独立页
/browsers/new;列表按信息密度选表格或移动卡片双形态
- 图标统一 RemixIcon;领域动作保持显式动词(start/stop/upgrade/recycle
等,不伪装成 CRUD update)
- LoginPage:Basic 凭证存 localStorage;401 清凭证并跳 #/login;
AuthGate 改为渲染期读 localStorage(react-router v7 的 pushState 不触发
hashchange,事件订阅会在导航后拿到过期状态导致登录后卡死)
- dataProvider 适配 Refine v5 契约:所有方法收单个参数对象
({resource, pagination, filters, meta, variables}),create/update/
delete/deleteOne 语义对齐
- Modal 关闭时焦点还给触发元素;Select 保持原生 button/combobox 语义
- e2e 补登录前置;账号创建流程同步 modal 交互
验证:npm test 65/65 通过;覆盖率 71.5% stmts / 76.3% lines(门槛 65%);
vite build 成功;playwright e2e 9/9 通过。
137 lines
7.5 KiB
React
137 lines
7.5 KiB
React
import { useEffect, useState } from 'react'
|
||
import { Link, useSearchParams } from 'react-router'
|
||
import { useList } from '@refinedev/core'
|
||
import { Alert, Button, PageHeader, PageState, Table } from './lib/ui.jsx'
|
||
import { useTitle } from './lib/hooks.js'
|
||
|
||
const startOfDay = value => (value ? new Date(`${value}T00:00:00`).toISOString() : '')
|
||
const endOfDay = value => (value ? new Date(`${value}T23:59:59.999`).toISOString() : '')
|
||
|
||
const csvColumns = ['id', 'event_type', 'account_id', 'task_id', 'attempt_id', 'browser_env_alias', 'network_exit_id', 'binding_version', 'reason_code', 'outcome', 'created_at']
|
||
const csvCell = value => `"${String(value ?? '').replaceAll('"', '""')}"`
|
||
|
||
// 导出仅含关联字段的脱敏白名单,不带 details。
|
||
export function auditCSV(events) {
|
||
return [csvColumns, ...events.map(event => csvColumns.map(column => event[column]))]
|
||
.map(row => row.map(csvCell).join(','))
|
||
.join('\n')
|
||
}
|
||
|
||
function downloadCSV(events) {
|
||
const url = URL.createObjectURL(new Blob([auditCSV(events)], { type: 'text/csv;charset=utf-8' }))
|
||
const anchor = document.createElement('a')
|
||
anchor.href = url
|
||
anchor.download = 'creatorhub-audit.csv'
|
||
anchor.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
const filterInput = 'h-9 w-full rounded-md border border-[#c4ccd9] bg-white px-3 text-sm focus:border-primary focus:outline-none'
|
||
|
||
function FilterField({ id, label, ...props }) {
|
||
return (
|
||
<div className="min-w-0 space-y-1">
|
||
<label htmlFor={id} className="block text-sm font-medium text-ink">{label}</label>
|
||
<input id={id} {...props} className={filterInput} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function AuditPage() {
|
||
const [searchParams] = useSearchParams()
|
||
const [account, setAccount] = useState(searchParams.get('account_id') || '')
|
||
const [task, setTask] = useState(searchParams.get('task_id') || '')
|
||
const [attempt, setAttempt] = useState(searchParams.get('attempt_id') || '')
|
||
const [env, setEnv] = useState(searchParams.get('browser_env_alias') || '')
|
||
const [exit, setExit] = useState(searchParams.get('network_exit_id') || '')
|
||
const [eventType, setEventType] = useState('')
|
||
const [from, setFrom] = useState('')
|
||
const [to, setTo] = useState('')
|
||
const [page, setPage] = useState(1)
|
||
const filters = [
|
||
{ field: 'account_id', value: account },
|
||
{ field: 'task_id', value: task },
|
||
{ field: 'attempt_id', value: attempt },
|
||
{ field: 'browser_env_alias', value: env },
|
||
{ field: 'network_exit_id', value: exit },
|
||
{ field: 'event_type', value: eventType },
|
||
{ field: 'from', value: startOfDay(from) },
|
||
{ field: 'to', value: endOfDay(to) },
|
||
]
|
||
const { result, query } = useList({ resource: 'audit', pagination: { currentPage: page, pageSize: 25 }, filters, queryOptions: { retry: false } })
|
||
const error = query.error
|
||
const isPending = query.isPending
|
||
const events = result.data ?? []
|
||
const total = result.total ?? 0
|
||
useTitle('CreatorHub · 审计记录')
|
||
useEffect(() => { setPage(1) }, [account, task, attempt, env, exit, eventType, from, to])
|
||
const hasFilters = !!(account || task || attempt || env || exit || eventType || from || to)
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader icon="ri-history-line" title="审计记录" description="追加式事件回溯;页面不提供编辑或删除">
|
||
<Button icon="ri-download-2-line" disabled={!events.length} onClick={() => downloadCSV(events)}>导出当前页(脱敏)</Button>
|
||
</PageHeader>
|
||
<div className="mb-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||
<FilterField id="audit-account" label="账号" value={account} onChange={event => setAccount(event.target.value)} />
|
||
<FilterField id="audit-task" label="任务" value={task} onChange={event => setTask(event.target.value)} />
|
||
<FilterField id="audit-attempt" label="Attempt" value={attempt} onChange={event => setAttempt(event.target.value)} />
|
||
<FilterField id="audit-env" label="环境" value={env} onChange={event => setEnv(event.target.value)} />
|
||
<FilterField id="audit-exit" label="出口" value={exit} onChange={event => setExit(event.target.value)} />
|
||
<FilterField id="audit-event" label="事件" value={eventType} onChange={event => setEventType(event.target.value)} />
|
||
<FilterField id="audit-from" label="开始日期" type="date" value={from} onChange={event => setFrom(event.target.value)} />
|
||
<FilterField id="audit-to" label="结束日期" type="date" value={to} onChange={event => setTo(event.target.value)} />
|
||
</div>
|
||
{hasFilters ? (
|
||
<div className="mb-4">
|
||
<Button size="sm" onClick={() => { setAccount(''); setTask(''); setAttempt(''); setEnv(''); setExit(''); setEventType(''); setFrom(''); setTo('') }}>清除筛选</Button>
|
||
</div>
|
||
) : null}
|
||
{error ? <Alert variant="destructive" className="mb-4">{error.message}</Alert> : null}
|
||
<PageState pending={isPending} error={error} empty={events.length === 0} emptyText={hasFilters ? '没有符合筛选条件的审计事件' : '尚无审计事件'}>
|
||
<Table
|
||
columns={[
|
||
{ header: '事件', width: '20%', render: event => (
|
||
<div>
|
||
<p className="anywhere font-semibold">{event.event_type}</p>
|
||
<p className="text-xs text-muted">#{event.id}</p>
|
||
</div>
|
||
) },
|
||
{ header: '关联', width: '27%', render: event => (
|
||
<div className="space-y-0.5 text-sm">
|
||
{event.account_id ? <p className="anywhere">账号:<Link to={`/accounts/${event.account_id}`} className="text-primary hover:underline">{event.account_id}</Link></p> : null}
|
||
{event.task_id ? <p className="anywhere">任务:<Link to={`/tasks/${event.task_id}`} className="text-primary hover:underline">{event.task_id}</Link></p> : null}
|
||
{event.attempt_id ? <p className="anywhere">Attempt:<Link to={`/attempts/${event.attempt_id}`} className="text-primary hover:underline">{event.attempt_id}</Link></p> : null}
|
||
</div>
|
||
) },
|
||
{ header: '环境 / 出口', width: '24%', render: event => (
|
||
<div>
|
||
<p className="anywhere text-sm">
|
||
{event.browser_env_alias ? <Link to={`/browsers/${encodeURIComponent(event.browser_env_alias)}`} className="text-primary hover:underline">{event.browser_env_alias}</Link> : '—'}
|
||
{' / '}
|
||
{event.network_exit_id ? <Link to={`/network-exits/${encodeURIComponent(event.network_exit_id)}`} className="text-primary hover:underline">{event.network_exit_id}</Link> : '—'}
|
||
</p>
|
||
<p className="text-xs text-muted">绑定版本 {event.binding_version || '—'}</p>
|
||
</div>
|
||
) },
|
||
{ header: '原因 / 时间', width: '29%', render: event => (
|
||
<div>
|
||
<p className="anywhere text-sm">{event.reason_code || event.outcome || '—'}</p>
|
||
<p className="text-xs text-muted">{new Date(event.created_at).toLocaleString('zh-CN')}</p>
|
||
</div>
|
||
) },
|
||
]}
|
||
rows={events} rowKey={event => event.id} minWidth="1000px"
|
||
/>
|
||
</PageState>
|
||
{total > 25 ? (
|
||
<div className="mt-4 flex items-center justify-end gap-4">
|
||
<Button size="sm" disabled={page === 1} onClick={() => setPage(current => current - 1)}>上一页</Button>
|
||
<p className="text-sm text-muted">第 {page} / {Math.ceil(total / 25)} 页</p>
|
||
<Button size="sm" disabled={page * 25 >= total} onClick={() => setPage(current => current + 1)}>下一页</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|