235 lines
14 KiB
React
235 lines
14 KiB
React
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>
|
||
)
|
||
}
|