Files
creator-hub/web/src/AuditPage.jsx
T
rogee 7ecad93821 refactor(web): migrate frontend to React 19 + Refine v5 + shadcn/ui stack
技术栈切换(按 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 通过。
2026-09-02 13:40:34 +08:00

137 lines
7.5 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}