From 3e6755642801a21c6a9e6c2c38129d0ad650dab9 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 22 Sep 2026 12:45:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20task-4=20tasks=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E8=BF=81=E7=A7=BB=EF=BC=88=E4=BB=BB=E5=8A=A1=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E5=88=97=E8=A1=A8/=E8=AF=A6=E6=83=85/attempts/drafts?= =?UTF-8?q?=EF=BC=8CTabs=20=E4=BF=A1=E6=81=AF=E7=BB=84=E7=BB=87=EF=BC=8C?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E4=B8=8E=E7=AD=9B=E9=80=89=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=BD=92=E6=A1=A3=E7=89=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/attempts/$id/index.tsx | 71 +++++++- web/src/pages/drafts/$id/index.tsx | 227 ++++++++++++++++++++++- web/src/pages/tasks/$id/index.tsx | 258 ++++++++++++++++++++++++++- web/src/pages/tasks/index.tsx | 143 ++++++++++++++- 4 files changed, 691 insertions(+), 8 deletions(-) diff --git a/web/src/pages/attempts/$id/index.tsx b/web/src/pages/attempts/$id/index.tsx index 8eba50c..6ec6e99 100644 --- a/web/src/pages/attempts/$id/index.tsx +++ b/web/src/pages/attempts/$id/index.tsx @@ -1,4 +1,71 @@ -// TODO: 从 web.archived 迁移 attempts/$id +// Attempt 详情:语义对齐 web.archived TracePages.jsx 的 AttemptDetail。 +import { useEffect, useState } from 'react'; +import { history, useParams } from '@umijs/max'; +import { Alert, Button, Card, Descriptions, Space, Typography } from 'antd'; +import { getOne } from '@/services/api'; +import { dateTime } from '@/utils/helpers'; + export default function Page() { - return null; + const { id = '' } = useParams<{ id: string }>(); + const [attempt, setAttempt] = useState(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setPending(true); + setError(null); + getOne('attempts', id) + .then((result) => { + if (!cancelled) setAttempt(result); + }) + .catch((loadError) => { + if (!cancelled) setError(loadError); + }) + .finally(() => { + if (!cancelled) setPending(false); + }); + return () => { + cancelled = true; + }; + }, [id]); + + if (pending) return ; + if (error) return ; + if (!attempt) return null; + + return ( +
+
+ + Attempt 详情 + + + {attempt.id} + +
+ + + + {attempt.outcome || '执行中'} + {dateTime(attempt.started_at)} + + {`${attempt.browser_env_alias || '未记录'} · ${attempt.network_exit_id || '未记录'} · ${attempt.binding_version || '未记录'}`} + + + + + {attempt.browser_env_alias ? ( + + ) : null} + {attempt.network_exit_id ? ( + + ) : null} + + + +
+ ); } diff --git a/web/src/pages/drafts/$id/index.tsx b/web/src/pages/drafts/$id/index.tsx index 2e87659..eee5e7a 100644 --- a/web/src/pages/drafts/$id/index.tsx +++ b/web/src/pages/drafts/$id/index.tsx @@ -1,4 +1,227 @@ -// TODO: 从 web.archived 迁移 drafts/$id +// 草稿核对:语义对齐 web.archived DraftPage.jsx(只读快照 / 版本链 / 显式核对与入队 / 关联任务)。 +import { useCallback, useEffect, useState } from 'react'; +import { history, useParams } from '@umijs/max'; +import { + Alert, + Button, + Card, + Checkbox, + Descriptions, + Modal, + Space, + Tag, + Typography, + message, +} from 'antd'; +import { confirmDraft, enqueueConfirmation, getOne, getList } from '@/services/api'; +import { conflictMessage } from '@/utils/helpers'; + +// 与账号 readiness 同构的草稿侧判定:只依赖绑定健康与调度状态。 +function draftReadiness(draft: any, binding: any, bindingsError = false) { + if (draft.account.authorization_status !== 'authorized') return { label: '授权已撤销', ready: false }; + if (bindingsError) return { label: '环境状态未知', ready: false }; + if (!binding) return { label: '未绑定运行环境', ready: false }; + if (binding.network_exit_id && binding.network_exit_health !== 'healthy') return { label: '固定出口不健康', ready: false }; + const schedule = binding.schedule_block_reason || ''; + return { label: schedule ? `不可调度:${schedule}` : '可调度', ready: !schedule }; +} + export default function Page() { - return null; + const { id = '' } = useParams<{ id: string }>(); + const [draft, setDraft] = useState(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [binding, setBinding] = useState(null); + const [browsersError, setBrowsersError] = useState(null); + const [reviewed, setReviewed] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(async () => { + setPending(true); + setError(null); + try { + const result = await getOne('drafts', id); + setDraft(result); + try { + const browsersResult = await getList({ resource: 'browsers' }); + setBrowsersError(null); + setBinding((browsersResult.data ?? []).find((item: any) => item.account_id === result.account_id) ?? null); + } catch (loadBindingError) { + setBrowsersError(loadBindingError); + setBinding(undefined); + } + } catch (loadError) { + setError(loadError); + } finally { + setPending(false); + } + }, [id]); + + useEffect(() => { + load(); + }, [load]); + + if (pending) return ; + if (error || !draft) return ; + + const versions = draft.versions || []; + const confirmations = draft.confirmations || []; + const tasks = draft.tasks || []; + const latestVersion = versions[0]?.version || draft.version; + const isLatest = draft.version === latestVersion; + const confirmation = confirmations.find( + (item: any) => item.account_version === draft.account.version && item.draft_version === draft.version, + ); + const readiness = draftReadiness(draft, binding, Boolean(browsersError)); + const canConfirm = isLatest && Boolean(binding) && !browsersError; + const canEnqueue = Boolean(confirmation) && isLatest && readiness.ready; + + async function confirmSnapshot() { + setBusy(true); + try { + await confirmDraft(draft.id, draft.account.version, draft.version); + await load(); + setConfirmOpen(false); + messageApi.success('确认快照已保存,可回溯账号、草稿与当前固定资源版本。'); + } catch (reason) { + setConfirmOpen(false); + messageApi.error(conflictMessage(reason)); + await load(); + } finally { + setBusy(false); + } + } + + async function enqueue() { + if (!confirmation) return; + setBusy(true); + try { + const task = await enqueueConfirmation(confirmation.id); + await load(); + messageApi.success(`任务已排队:${task.id}。重复提交会返回同一任务。`); + } catch (reason) { + messageApi.error(conflictMessage(reason)); + await load(); + } finally { + setBusy(false); + } + } + + return ( +
+ {contextHolder} +
+
+ 草稿核对 + + {draft.account.platform_account_key} · 草稿版本 {draft.version} + +
+ +
+ {browsersError ? ( + + ) : null} + {!isLatest ? ( + history.push(`/drafts/${versions[0]?.id}`)}> + 打开最新版本 + + } + /> + ) : null} + + + {draft.content} + + + + + {draft.account.version} + {draft.version} + {browsersError ? '状态未知' : binding?.name || '未绑定'} + {browsersError ? '状态未知' : binding?.network_exit_id || '当前机器直连'} + + {readiness.label} + + + + + {versions.map((item: any) => + item.id === draft.id ? ( + + 版本 {item.version}(当前快照) + + ) : ( + + ), + )} + + + + + setReviewed(e.target.checked)}> + 我已核对当前账号、草稿内容、运行环境和网络出口 + + + + + + {confirmation ? ( + + + 账号版本 {confirmation.account_version} · 草稿版本 {confirmation.draft_version} + + + ) : ( + 保存有效确认后才可加入队列。 + )} + + + + {tasks.length === 0 ? ( + 尚未入队。 + ) : ( + + {tasks.map((task: any) => ( + + {task.id} + 任务状态:{task.state} + + ))} + + )} + + + { + if (!busy) setConfirmOpen(false); + }} + onOk={confirmSnapshot} + confirmLoading={busy} + okText={busy ? '确认中…' : '保存确认'} + cancelText="返回核对" + > +

+ 将保存账号版本 {draft.account.version}、草稿版本 {draft.version}、运行环境 {binding?.alias} 与网络出口{' '} + {binding?.network_exit_id || '当前机器直连'} 的只读确认快照。版本变化后必须重新确认。 +

+
+
+ ); } diff --git a/web/src/pages/tasks/$id/index.tsx b/web/src/pages/tasks/$id/index.tsx index 037b9a8..ead848f 100644 --- a/web/src/pages/tasks/$id/index.tsx +++ b/web/src/pages/tasks/$id/index.tsx @@ -1,4 +1,258 @@ -// TODO: 从 web.archived 迁移 tasks/$id +// 任务详情:语义对齐 web.archived TasksPage.jsx 的 TaskDetail。 +// 四卡改用 Tabs(当前状态 / 确认快照 / 执行关联 / 唯一安全动作)——antd 默认组件的信息组织方式。 +import { useCallback, useEffect, useState } from 'react'; +import { history, useParams } from '@umijs/max'; +import { Alert, Button, Card, Descriptions, Select, Space, Tabs, Tag, Typography, message } from 'antd'; +import { getOne, taskAction } from '@/services/api'; +import { dateTime } from '@/utils/helpers'; + +const stateText: Record = { + queued: '已排队', + executing: '执行中', + succeeded: '已成功', + failed: '失败', + needs_confirmation: '需要人工确认', + policy_hold: '策略暂停', + cancelled: '已取消', +}; +const stateColor: Record = { + queued: 'default', + executing: 'processing', + succeeded: 'success', + failed: 'error', + needs_confirmation: 'warning', + policy_hold: 'warning', + cancelled: 'default', +}; + +const holdText: Record = { + 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 holdLabel = (reason?: string) => (reason ? holdText[reason] || `未知停止原因:${reason}` : '无'); + +function actionErrorText(error: any): string { + 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 default function Page() { - return null; + const { id = '' } = useParams<{ id: string }>(); + const [task, setTask] = useState(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [verification, setVerification] = useState('not_executed'); + const [busy, setBusy] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(async () => { + setPending(true); + setError(null); + try { + const result = await getOne('tasks', id); + setTask(result); + } catch (loadError) { + setError(loadError); + } finally { + setPending(false); + } + }, [id]); + + useEffect(() => { + load(); + }, [load]); + + async function runAction(action: 'verify' | 'resume' | 'finish', data?: unknown) { + setBusy(true); + try { + await taskAction(task.id, action, data); + messageApi.success( + action === 'verify' + ? '人工核验已记录;现在只能按该结论恢复或结束。' + : action === 'resume' + ? '任务已恢复排队。' + : '任务已按核验结论结束。', + ); + await load(); + } catch (reason) { + messageApi.error(actionErrorText(reason)); + } finally { + setBusy(false); + } + } + + if (pending) return ; + if (error || !task) return ; + + const confirmation = task.confirmation || {}; + const unknownHold = task.hold_reason && !holdText[task.hold_reason]; + + const items = [ + { + key: 'status', + label: '当前状态', + children: ( + + {stateText[task.state] || `未知状态:${task.state}`} + + {holdLabel(task.hold_reason)} + {task.verification_result || '尚未记录'} + + + ), + }, + { + key: 'confirmation', + label: '确认快照', + children: ( + + + + {confirmation.id || '未记录'} · v{confirmation.version || '—'} + + {`账号版本 ${confirmation.account_version || '—'} · 草稿版本 ${confirmation.draft_version || '—'}`} + + {`${confirmation.browser_env_alias || '未记录'} · ${confirmation.network_exit_id || '未记录'} · 绑定版本:${confirmation.binding_version || '未记录'}`} + + + + + + + + ), + }, + { + key: 'execution', + label: '执行关联', + children: ( + + + {task.browser_env_alias || '未记录'} + {task.network_exit_id || '未记录'} + + {task.runtime_instance_id || '未记录'} · {task.binding_version || '未记录'} + + + + {task.browser_env_alias ? ( + + ) : null} + {task.network_exit_id ? ( + + ) : null} + + + ), + }, + { + key: 'action', + label: '唯一安全动作', + children: ( + + {task.readiness_reason ? : null} + {task.allowed_action === 'verify' ? ( + <> + + setAccountFilter(event.target.value)} + allowClear + /> +