fix: 补齐前端实测与恢复重试链路
This commit is contained in:
+181
-53
@@ -3,7 +3,7 @@ import {
|
||||
type CandidateRecord,
|
||||
type PlatformId
|
||||
} from "@cross-ai/domain";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
getTask,
|
||||
getTaskCandidates,
|
||||
getTaskReport,
|
||||
preparePlatform
|
||||
preparePlatform,
|
||||
retryTaskPlatform
|
||||
} from "./lib/api";
|
||||
|
||||
function Layout(props: { children: React.ReactNode }) {
|
||||
@@ -47,6 +48,10 @@ function Layout(props: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatPlatformNames(platforms: PlatformId[]) {
|
||||
return platforms.map((platform) => platformCatalogMap[platform].label).join("、");
|
||||
}
|
||||
|
||||
function NewTaskPage() {
|
||||
const navigate = useNavigate();
|
||||
const readinessQuery = useQuery({
|
||||
@@ -100,8 +105,8 @@ function NewTaskPage() {
|
||||
<label className="field">
|
||||
<span>单链接评论上限</span>
|
||||
<input
|
||||
min={10}
|
||||
max={200}
|
||||
min={10}
|
||||
type="number"
|
||||
value={perLinkLimit}
|
||||
onChange={(event) => setPerLinkLimit(Number(event.target.value))}
|
||||
@@ -110,8 +115,8 @@ function NewTaskPage() {
|
||||
<label className="field">
|
||||
<span>任务评论总上限</span>
|
||||
<input
|
||||
min={50}
|
||||
max={500}
|
||||
min={50}
|
||||
type="number"
|
||||
value={taskTotalLimit}
|
||||
onChange={(event) => setTaskTotalLimit(Number(event.target.value))}
|
||||
@@ -248,64 +253,65 @@ function ConfirmPage() {
|
||||
<TaskContextHeader task={task} />
|
||||
<TaskSpine current="confirmation" />
|
||||
<section className="page-grid">
|
||||
{(Object.keys(candidates) as PlatformId[]).map((platform) => (
|
||||
<article key={platform} className="page-panel">
|
||||
<div className="candidate-section__header">
|
||||
<div>
|
||||
<p className="eyebrow">Candidate Board</p>
|
||||
<h3>{platformCatalogMap[platform].label}</h3>
|
||||
</div>
|
||||
<PlatformStatusPill
|
||||
status={
|
||||
task.platformRuns.find((run) => run.platform === platform)?.status ??
|
||||
"Pending"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{(Object.keys(candidates) as PlatformId[]).map((platform) => {
|
||||
const platformRun = task.platformRuns.find((run) => run.platform === platform);
|
||||
|
||||
{candidates[platform].length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
{task.platformRuns.find((run) => run.platform === platform)?.reason ??
|
||||
"当前没有候选结果。"}
|
||||
</p>
|
||||
return (
|
||||
<article key={platform} className="page-panel">
|
||||
<div className="candidate-section__header">
|
||||
<div>
|
||||
<p className="eyebrow">Candidate Board</p>
|
||||
<h3>{platformCatalogMap[platform].label}</h3>
|
||||
</div>
|
||||
<PlatformStatusPill status={platformRun?.status ?? "Pending"} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack">
|
||||
{candidates[platform].map((candidate) => (
|
||||
<CandidateCard
|
||||
key={candidate.candidateId}
|
||||
candidate={candidate}
|
||||
checked={Boolean(selected[candidate.candidateId])}
|
||||
onToggle={() =>
|
||||
setSelected((current) => ({
|
||||
...current,
|
||||
[candidate.candidateId]: !current[candidate.candidateId]
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
|
||||
{candidates[platform].length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
||||
{platformRun?.status === "SearchBlocked" ? (
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`}
|
||||
>
|
||||
处理阻塞并重试
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack">
|
||||
{candidates[platform].map((candidate) => (
|
||||
<CandidateCard
|
||||
key={candidate.candidateId}
|
||||
candidate={candidate}
|
||||
checked={Boolean(selected[candidate.candidateId])}
|
||||
onToggle={() =>
|
||||
setSelected((current) => ({
|
||||
...current,
|
||||
[candidate.candidateId]: !current[candidate.candidateId]
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<div className="sticky-actions">
|
||||
<div>
|
||||
<p className="eyebrow">Selection Basket</p>
|
||||
<strong>
|
||||
已选{" "}
|
||||
{groupedSelections.reduce(
|
||||
(sum, group) => sum + group.candidateIds.length,
|
||||
0
|
||||
)}{" "}
|
||||
个链接
|
||||
{groupedSelections.reduce((sum, group) => sum + group.candidateIds.length, 0)} 个链接
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={confirmMutation.isPending}
|
||||
onClick={() => confirmMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
提交确认
|
||||
</button>
|
||||
@@ -315,11 +321,22 @@ function ConfirmPage() {
|
||||
}
|
||||
|
||||
function RunPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { taskId = "" } = useParams();
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["task", taskId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["history"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["report", taskId] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
if (!taskQuery.data) {
|
||||
return (
|
||||
@@ -347,6 +364,28 @@ function RunPage() {
|
||||
<PlatformStatusPill status={run.status} />
|
||||
</div>
|
||||
<p>{run.reason ?? "当前平台已进入主线处理。"}</p>
|
||||
<div className="panel-actions">
|
||||
{run.status === "SearchBlocked" || run.status === "Blocked" ? (
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/tasks/${taskId}/recovery/${run.platform}?from=/tasks/${taskId}/run`}
|
||||
>
|
||||
进入恢复并重试
|
||||
</a>
|
||||
) : null}
|
||||
{run.status === "Failed" ? (
|
||||
<button
|
||||
className="ghost-button"
|
||||
disabled={
|
||||
retryMutation.isPending && retryMutation.variables === run.platform
|
||||
}
|
||||
onClick={() => retryMutation.mutate(run.platform)}
|
||||
type="button"
|
||||
>
|
||||
重试平台
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -385,6 +424,7 @@ function MetricCard(props: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
function ReportPage() {
|
||||
const navigate = useNavigate();
|
||||
const { taskId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const version = searchParams.get("version");
|
||||
@@ -407,6 +447,10 @@ function ReportPage() {
|
||||
|
||||
const task = taskQuery.data.task;
|
||||
const report = reportQuery.data.report;
|
||||
const currentVersion =
|
||||
version && Number.isFinite(Number(version))
|
||||
? Number(version)
|
||||
: task.defaultReportVersion ?? report.report_version;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -431,6 +475,23 @@ function ReportPage() {
|
||||
/>
|
||||
<MetricCard label="报告版本" value={`v${report.report_version}`} />
|
||||
</div>
|
||||
{task.reportVersions.length > 1 ? (
|
||||
<label className="field field--inline">
|
||||
<span>版本切换</span>
|
||||
<select
|
||||
value={String(currentVersion)}
|
||||
onChange={(event) =>
|
||||
navigate(`/tasks/${taskId}/report?version=${event.target.value}`)
|
||||
}
|
||||
>
|
||||
{task.reportVersions.map((reportVersion) => (
|
||||
<option key={reportVersion} value={reportVersion}>
|
||||
v{reportVersion}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="stack">
|
||||
{report.summary.key_points.map((point) => (
|
||||
<p key={point} className="inline-note">
|
||||
@@ -455,6 +516,14 @@ function ReportPage() {
|
||||
.join("、")
|
||||
: "无"}
|
||||
</li>
|
||||
<li>
|
||||
失败平台:
|
||||
{report.quality_flags.failed_platforms.length > 0
|
||||
? report.quality_flags.failed_platforms
|
||||
.map((platform) => platformCatalogMap[platform].label)
|
||||
.join("、")
|
||||
: "无"}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
@@ -546,6 +615,15 @@ function HistoryPage() {
|
||||
>
|
||||
{task.hasReport ? "查看报告" : "查看任务"}
|
||||
</a>
|
||||
{task.blockedPlatforms.length > 0 || task.failedPlatforms.length > 0 ? (
|
||||
<span>
|
||||
待处理:
|
||||
{formatPlatformNames([
|
||||
...task.blockedPlatforms,
|
||||
...task.failedPlatforms
|
||||
])}
|
||||
</span>
|
||||
) : null}
|
||||
{task.defaultReportVersion ? (
|
||||
<span>默认版本 v{task.defaultReportVersion}</span>
|
||||
) : (
|
||||
@@ -562,12 +640,14 @@ function HistoryPage() {
|
||||
|
||||
function SessionPreparePage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { platform = "tmall" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from") ?? "/tasks/new";
|
||||
const prepareMutation = useMutation({
|
||||
mutationFn: () => preparePlatform(platform as PlatformId),
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["platform-readiness"] });
|
||||
navigate(from);
|
||||
}
|
||||
});
|
||||
@@ -582,10 +662,8 @@ function SessionPreparePage() {
|
||||
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
||||
<div className="session-placeholder__sidebar">
|
||||
<strong>当前模式:prepare</strong>
|
||||
<p>
|
||||
本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。
|
||||
</p>
|
||||
<button className="primary-button" onClick={() => prepareMutation.mutate()}>
|
||||
<p>本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。</p>
|
||||
<button className="primary-button" onClick={() => prepareMutation.mutate()} type="button">
|
||||
标记预热完成
|
||||
</button>
|
||||
</div>
|
||||
@@ -595,6 +673,55 @@ function SessionPreparePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function RecoveryPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { taskId = "", platform = "tmall" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from") ?? `/tasks/${taskId}/run`;
|
||||
const recoveryMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await preparePlatform(platform as PlatformId);
|
||||
return retryTaskPlatform(taskId, platform as PlatformId);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["task", taskId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["history"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["report", taskId] })
|
||||
]);
|
||||
navigate(from);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel session-panel">
|
||||
<p className="eyebrow">Recovery Console</p>
|
||||
<h2>{platformCatalogMap[platform as PlatformId].label} 阻塞恢复</h2>
|
||||
<p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p>
|
||||
<div className="session-placeholder">
|
||||
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
||||
<div className="session-placeholder__sidebar">
|
||||
<strong>当前模式:recovery</strong>
|
||||
<p>本轮会先标记会话恢复完成,再对当前平台发起定向重试。</p>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={recoveryMutation.isPending}
|
||||
onClick={() => recoveryMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
标记恢复完成并重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -602,6 +729,7 @@ export function App() {
|
||||
<Route element={<NewTaskPage />} path="/tasks/new" />
|
||||
<Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" />
|
||||
<Route element={<RunPage />} path="/tasks/:taskId/run" />
|
||||
<Route element={<RecoveryPage />} path="/tasks/:taskId/recovery/:platform" />
|
||||
<Route element={<ReportPage />} path="/tasks/:taskId/report" />
|
||||
<Route element={<HistoryPage />} path="/history" />
|
||||
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
|
||||
|
||||
+29
-1
@@ -128,7 +128,8 @@ a {
|
||||
}
|
||||
|
||||
.field textarea,
|
||||
.field input {
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
@@ -138,6 +139,11 @@ a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.field--inline {
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
@@ -174,6 +180,21 @@ a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(20, 108, 110, 0.18);
|
||||
background: rgba(20, 108, 110, 0.08);
|
||||
color: var(--brand-primary);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button--link {
|
||||
width: fit-content;
|
||||
}
|
||||
@@ -218,6 +239,13 @@ a {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-context-header {
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user