Files
creator-hub/web/src/TasksPage.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

213 lines
12 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 { 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>
)
}