Files
creator-hub/web/src/features/operations/TasksPage.jsx
T

235 lines
14 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 '../../shared/router.jsx'
import { useDataProvider, useList, useOne } from '../../shared/hooks/dataHooks.js'
import { Alert } from '../../components/ui/alert'
import { Button } from '../../components/ui/button'
import { Card, CardContent } from '../../components/ui/card'
import { Input } from '../../components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'
import { DetailList, Field, PageHeader, PageState, StatusPill } from '../../shared/ui/ui.jsx'
import { useTitle } from '../../shared/hooks/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 || '操作失败'
}
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" />
</Field>
<Field id="task-state-filter" label="按状态筛选" className="sm:w-56">
<Select value={stateFilter || "__all__"} onValueChange={value => setStateFilter(value === "__all__" ? "" : value)}>
<SelectTrigger id="task-state-filter">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部状态</SelectItem>
{Object.entries(stateText).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</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 ? '没有符合筛选条件的任务' : '尚无任务'}>
<Table>
<TableHeader>
<TableRow>
<TableHead>任务</TableHead>
<TableHead>账号</TableHead>
<TableHead>确认</TableHead>
<TableHead>状态</TableHead>
<TableHead>停止原因</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map(task => (
<TableRow key={task.id}>
<TableCell><Link to={`/tasks/${task.id}`} className="anywhere font-semibold text-primary hover:underline">{task.id}</Link></TableCell>
<TableCell><Link to={`/accounts/${task.account_id}`} className="anywhere text-primary hover:underline">{task.account_id}</Link></TableCell>
<TableCell className="anywhere">{task.confirmation_id || '未确认'}</TableCell>
<TableCell><StatusPill tone={stateTone[task.state] || 'neutral'} label={stateLabel(task.state)} /></TableCell>
<TableCell className="anywhere text-sm text-muted-foreground">{holdLabel(task.hold_reason)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')}</TableCell>
<TableCell className="text-right"><Button size="sm" variant="outline" asChild><Link to={`/tasks/${task.id}`}>查看</Link></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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 onClick={() => navigate('/tasks')}>
<i className="ri-arrow-left-line" aria-hidden="true" />
返回任务中心
</Button>
</PageHeader>
{notice ? <Alert variant={notice.variant === "destructive" ? "destructive" : "default"} className="mb-4">{notice.text}</Alert> : null}
{unknownHold ? <Alert variant="default" 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 || '尚未记录'],
]} />
</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" asChild><Link to={`/accounts/${task.account_id}`}>查看账号</Link></Button>
<Button size="sm" asChild><Link to={`/drafts/${task.draft_id}`}>查看草稿</Link></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" asChild><Link to={`/browsers/${encodeURIComponent(task.browser_env_alias)}`}>查看原环境</Link></Button> : null}
{task.network_exit_id ? <Button size="sm" asChild><Link to={`/network-exits/${encodeURIComponent(task.network_exit_id)}`}>查看原出口</Link></Button> : null}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3">
<h2 className="text-base font-semibold">唯一安全动作</h2>
{task.readiness_reason ? <Alert variant="default">当前仍被阻断:{holdLabel(task.readiness_reason)}。修复原资源并刷新后,才会开放恢复。</Alert> : null}
{task.allowed_action === 'verify' ? (
<>
<Alert variant="default">先在原环境和原出口核验真实结果。选择“未执行”不会立即重试,仍需下一步显式恢复。</Alert>
<Field id="task-verification" label="人工核验结论">
<Select value={verification} onValueChange={setVerification}>
<SelectTrigger id="task-verification"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="not_executed">确认未执行,可评估恢复</SelectItem>
<SelectItem value="succeeded">确认已成功</SelectItem>
<SelectItem value="failed">确认已失败</SelectItem>
</SelectContent>
</Select>
</Field>
<Button variant="default" disabled={busy} onClick={() => runAction('verify', { result: verification })}>记录人工核验</Button>
</>
) : null}
{task.allowed_action === 'resume' ? <Button variant="default" disabled={busy} onClick={() => runAction('resume')}>按核验结论恢复排队</Button> : null}
{task.allowed_action === 'finish' ? <Button variant="default" disabled={busy} onClick={() => runAction('finish')}>按核验结论结束任务</Button> : null}
{task.allowed_action === 'reconfirm' ? (
<>
<Alert variant="default">版本已变化,旧确认不可继续使用。</Alert>
<Button variant="default" asChild><Link to={`/drafts/${task.draft_id}`}>重新核对并确认草稿</Link></Button>
</>
) : null}
{task.allowed_action ? null : <p className="text-sm text-muted-foreground">当前没有可安全执行的动作。</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-foreground">尚未产生执行尝试。</p></CardContent></Card>
)}
</section>
</div>
)
}