技术栈切换(按 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 通过。
213 lines
12 KiB
React
213 lines
12 KiB
React
import { useState } from 'react'
|
||
import { Link, useNavigate, useParams } from 'react-router'
|
||
import { useDataProvider, useList, useOne } from '@refinedev/core'
|
||
import {
|
||
Alert, Button, Card, CardContent, DetailList, Field, PageHeader, PageState, Select, StatusPill,
|
||
} from './lib/ui.jsx'
|
||
import { useTitle } from './lib/hooks.js'
|
||
|
||
const stateTone = { queued: 'neutral', executing: 'info', succeeded: 'success', failed: 'danger', needs_confirmation: 'warning', policy_hold: 'warning', cancelled: 'neutral' }
|
||
const stateText = { queued: '已排队', executing: '执行中', succeeded: '已成功', failed: '失败', needs_confirmation: '需要人工确认', policy_hold: '策略暂停', cancelled: '已取消' }
|
||
const holdText = {
|
||
account_paused: '账号已暂停', account_revoked: '账号授权已撤销', binding_missing: '运行环境绑定缺失',
|
||
environment_missing: '原运行环境不可用', exit_missing: '固定出口缺失', exit_unhealthy: '固定出口失败或发生漂移',
|
||
runtime_stop_pending: '停止结果未知,等待人工核验', runtime_missing: '原运行实例不可用', runtime_lease_expired: '运行实例租约已过期',
|
||
execution_lease_expired: '执行租约过期,结果未知', task_result_uncertain: '执行结果不确定', task_policy_hold: '执行器请求策略暂停',
|
||
account_version_changed: '账号版本已变化', draft_version_changed: '草稿版本已变化', confirmation_missing: '确认快照缺失',
|
||
confirmation_version_changed: '确认版本已变化', binding_version_changed: '环境或出口版本已变化', legacy_confirmation_required: '历史任务结果未知,等待人工核验',
|
||
}
|
||
const stateLabel = state => stateText[state] || `未知状态:${state}`
|
||
const holdLabel = reason => reason ? holdText[reason] || `未知停止原因:${reason}` : '无'
|
||
|
||
function actionErrorText(error) {
|
||
const reason = error?.body?.reason_code
|
||
if (error?.status === 409) return `当前版本仍不允许恢复(409):${holdLabel(reason)}`
|
||
if (error?.status === 503) return `原资源尚未恢复(503):${holdLabel(reason)}`
|
||
return error?.message || '操作失败'
|
||
}
|
||
|
||
function TaskCard({ task }) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||
<Link to={`/tasks/${task.id}`} className="anywhere font-semibold text-primary hover:underline">{task.id}</Link>
|
||
<StatusPill tone={stateTone[task.state] || 'neutral'} label={stateLabel(task.state)} />
|
||
</div>
|
||
<div className="text-sm"><span className="text-muted">账号:</span><Link to={`/accounts/${task.account_id}`} className="anywhere text-primary hover:underline">{task.account_id}</Link></div>
|
||
<div className="text-sm"><span className="text-muted">确认:</span><span className="anywhere">{task.confirmation_id || '未确认'}</span></div>
|
||
<div className="anywhere text-xs text-muted">{holdLabel(task.hold_reason)}</div>
|
||
<p className="text-xs text-muted">{new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')}</p>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
export function TaskList() {
|
||
const [accountFilter, setAccountFilter] = useState('')
|
||
const [stateFilter, setStateFilter] = useState('')
|
||
const { result, query } = useList({
|
||
resource: 'tasks',
|
||
filters: [
|
||
{ field: 'account_id', value: accountFilter },
|
||
{ field: 'state', value: stateFilter },
|
||
],
|
||
})
|
||
const error = query.error
|
||
const isPending = query.isPending
|
||
const tasks = result.data ?? []
|
||
useTitle('CreatorHub · 任务中心')
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader icon="ri-task-line" title="任务中心" description="逐条处理暂停、版本变化与未知执行结果;不会自动重试或更换出口" />
|
||
<div className="mb-5 flex flex-col gap-3 sm:flex-row">
|
||
<Field id="task-account-filter" label="按账号筛选" className="sm:w-72">
|
||
<input id="task-account-filter" value={accountFilter} onChange={event => setAccountFilter(event.target.value)} placeholder="账号 ID" className="h-9 w-full rounded-md border border-[#c4ccd9] bg-white px-3 text-sm focus:border-primary focus:outline-none" />
|
||
</Field>
|
||
<Field id="task-state-filter" label="按状态筛选" className="sm:w-56">
|
||
<Select id="task-state-filter" value={stateFilter} onChange={event => setStateFilter(event.target.value)}
|
||
options={Object.entries(stateText).map(([value, label]) => ({ value, label }))} placeholder="全部状态" emptyOption={{ value: '', label: '全部状态' }} />
|
||
</Field>
|
||
</div>
|
||
{error ? <Alert variant="destructive" className="mb-4">{error.message}</Alert> : null}
|
||
<PageState pending={isPending} error={error} empty={tasks.length === 0} emptyText={accountFilter || stateFilter ? '没有符合筛选条件的任务' : '尚无任务'}>
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{tasks.map(task => <TaskCard key={task.id} task={task} />)}
|
||
</div>
|
||
</PageState>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function TaskDetail() {
|
||
const { id } = useParams()
|
||
const navigate = useNavigate()
|
||
const dataProvider = useDataProvider()('default')
|
||
const [verification, setVerification] = useState('not_executed')
|
||
const [busy, setBusy] = useState(false)
|
||
const [notice, setNotice] = useState(null)
|
||
const { result, error, isPending, query } = useOne({ resource: 'tasks', id, queryOptions: { retry: false } })
|
||
const task = result
|
||
useTitle('CreatorHub · 任务详情')
|
||
|
||
if (isPending) return <PageState pending />
|
||
if (error || !task) return <Alert variant="destructive">{error?.message || '任务不存在'}</Alert>
|
||
|
||
async function runAction(action, data) {
|
||
setBusy(true)
|
||
setNotice(null)
|
||
try {
|
||
await dataProvider.taskAction(task.id, action, data)
|
||
setNotice({ variant: 'success', text: action === 'verify' ? '人工核验已记录;现在只能按该结论恢复或结束。' : action === 'resume' ? '任务已恢复排队。' : '任务已按核验结论结束。' })
|
||
await query.refetch()
|
||
} catch (reason) {
|
||
setNotice({ variant: 'destructive', text: actionErrorText(reason) })
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const confirmation = task.confirmation || {}
|
||
const unknownHold = task.hold_reason && !holdText[task.hold_reason]
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="任务详情" description={<span className="anywhere">{task.id}</span>}>
|
||
<Button icon="ri-arrow-left-line" onClick={() => navigate('/tasks')}>返回任务中心</Button>
|
||
</PageHeader>
|
||
{notice ? <Alert variant={notice.variant} className="mb-4">{notice.text}</Alert> : null}
|
||
{unknownHold ? <Alert variant="warning" className="mb-4">后端返回了未知停止原因“{task.hold_reason}”;为避免不安全操作,本页不提供恢复动作。</Alert> : null}
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<Card>
|
||
<CardContent className="space-y-3">
|
||
<h2 className="text-base font-semibold">当前状态</h2>
|
||
<StatusPill tone={stateTone[task.state] || 'neutral'} label={stateLabel(task.state)} />
|
||
<DetailList rows={[
|
||
['停止原因', <span className="anywhere">{holdLabel(task.hold_reason)}</span>],
|
||
['人工核验', task.verification_result || '尚未记录'],
|
||
]} />
|
||
<div>
|
||
<Button size="sm" as={Link} to={`/audit?task_id=${encodeURIComponent(task.id)}`}>查看关联审计</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="space-y-3">
|
||
<h2 className="text-base font-semibold">确认快照</h2>
|
||
<DetailList rows={[
|
||
['确认', <span className="anywhere">{confirmation.id || '未记录'} · v{confirmation.version || '—'}</span>],
|
||
['版本', <>账号版本 {confirmation.account_version || '—'} · 草稿版本 {confirmation.draft_version || '—'}</>],
|
||
['环境与出口', <span className="anywhere">{confirmation.browser_env_alias || '未记录'} · {confirmation.network_exit_id || '未记录'} · 绑定版本:{confirmation.binding_version || '未记录'}</span>],
|
||
]} />
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" as={Link} to={`/accounts/${task.account_id}`}>查看账号</Button>
|
||
<Button size="sm" as={Link} to={`/drafts/${task.draft_id}`}>查看草稿</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="space-y-3">
|
||
<h2 className="text-base font-semibold">执行关联</h2>
|
||
<DetailList rows={[
|
||
['环境', <span className="anywhere">{task.browser_env_alias || '未记录'}</span>],
|
||
['固定出口', <span className="anywhere">{task.network_exit_id || '未记录'}</span>],
|
||
['运行实例 / 绑定版本', <span className="anywhere">{task.runtime_instance_id || '未记录'} · {task.binding_version || '未记录'}</span>],
|
||
]} />
|
||
<div className="flex flex-wrap gap-2">
|
||
{task.browser_env_alias ? <Button size="sm" as={Link} to={`/browsers/${encodeURIComponent(task.browser_env_alias)}`}>查看原环境</Button> : null}
|
||
{task.network_exit_id ? <Button size="sm" as={Link} to={`/network-exits/${encodeURIComponent(task.network_exit_id)}`}>查看原出口</Button> : null}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="space-y-3">
|
||
<h2 className="text-base font-semibold">唯一安全动作</h2>
|
||
{task.readiness_reason ? <Alert variant="warning">当前仍被阻断:{holdLabel(task.readiness_reason)}。修复原资源并刷新后,才会开放恢复。</Alert> : null}
|
||
{task.allowed_action === 'verify' ? (
|
||
<>
|
||
<Alert variant="warning">先在原环境和原出口核验真实结果。选择“未执行”不会立即重试,仍需下一步显式恢复。</Alert>
|
||
<Field id="task-verification" label="人工核验结论">
|
||
<Select id="task-verification" value={verification} onChange={event => setVerification(event.target.value)} options={[
|
||
{ value: 'not_executed', label: '确认未执行,可评估恢复' },
|
||
{ value: 'succeeded', label: '确认已成功' },
|
||
{ value: 'failed', label: '确认已失败' },
|
||
]} />
|
||
</Field>
|
||
<Button variant="primary" disabled={busy} onClick={() => runAction('verify', { result: verification })}>记录人工核验</Button>
|
||
</>
|
||
) : null}
|
||
{task.allowed_action === 'resume' ? <Button variant="primary" disabled={busy} onClick={() => runAction('resume')}>按核验结论恢复排队</Button> : null}
|
||
{task.allowed_action === 'finish' ? <Button variant="primary" disabled={busy} onClick={() => runAction('finish')}>按核验结论结束任务</Button> : null}
|
||
{task.allowed_action === 'reconfirm' ? (
|
||
<>
|
||
<Alert variant="warning">版本已变化,旧确认不可继续使用。</Alert>
|
||
<Button variant="primary" as={Link} to={`/drafts/${task.draft_id}`}>重新核对并确认草稿</Button>
|
||
</>
|
||
) : null}
|
||
{task.allowed_action ? null : <p className="text-sm text-muted">当前没有可安全执行的动作。</p>}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
<section className="mt-6">
|
||
<h2 className="mb-3 text-lg font-bold">执行尝试</h2>
|
||
{task.attempts?.length ? (
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{task.attempts.map(attempt => (
|
||
<Card key={attempt.id}>
|
||
<CardContent>
|
||
<Link to={`/attempts/${encodeURIComponent(attempt.id)}`} className="anywhere font-semibold text-primary hover:underline">{attempt.id}</Link>
|
||
<p className="mt-1.5 text-sm">{attempt.outcome || '执行中'} · {new Date(attempt.started_at).toLocaleString('zh-CN')}</p>
|
||
<p className="anywhere mt-1 text-sm">脱敏证据:{attempt.evidence?.mock_outcome || '无可公开证据'}</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<Card><CardContent><p className="text-sm text-muted">尚未产生执行尝试。</p></CardContent></Card>
|
||
)}
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|