feat: 完善历史任务页与删除能力
This commit is contained in:
@@ -285,4 +285,61 @@ describe("API server", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("deletes a task together with its report snapshots", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const createdTask = await createTask(app, "Xbox Series X");
|
||||
|
||||
const candidatesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${createdTask.taskId}/candidates`
|
||||
});
|
||||
const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${createdTask.taskId}/confirm`,
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
platform: "tmall",
|
||||
candidateIds: [firstCandidateId]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/tasks/${createdTask.taskId}`
|
||||
});
|
||||
|
||||
expect(deleteResponse.statusCode).toBe(204);
|
||||
|
||||
const taskResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${createdTask.taskId}`
|
||||
});
|
||||
expect(taskResponse.statusCode).toBe(404);
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${createdTask.taskId}/report`
|
||||
});
|
||||
expect(reportResponse.statusCode).toBe(404);
|
||||
|
||||
const historyResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/history"
|
||||
});
|
||||
expect(
|
||||
historyResponse
|
||||
.json()
|
||||
.tasks.some((task: { taskId: string }) => task.taskId === createdTask.taskId)
|
||||
).toBe(false);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,19 @@ export function createServer() {
|
||||
return { task };
|
||||
});
|
||||
|
||||
app.delete<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId", async (request, reply) => {
|
||||
try {
|
||||
store.deleteTask(request.params.taskId);
|
||||
reply.code(204);
|
||||
return null;
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId/candidates", async (request, reply) => {
|
||||
|
||||
@@ -311,6 +311,14 @@ export class InMemoryTaskStore {
|
||||
return reports[reports.length - 1];
|
||||
}
|
||||
|
||||
deleteTask(taskId: string): void {
|
||||
this.requireTask(taskId);
|
||||
this.tasks.delete(taskId);
|
||||
this.reports.delete(taskId);
|
||||
this.reportFingerprints.delete(taskId);
|
||||
this.executionScenarios.delete(taskId);
|
||||
}
|
||||
|
||||
private runSearch(task: TaskRecord): void {
|
||||
task.taskStage = "search";
|
||||
|
||||
|
||||
+388
-43
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
platformCatalogMap,
|
||||
taskStatuses,
|
||||
type CandidateRecord,
|
||||
type PlatformId
|
||||
type PlatformId,
|
||||
type PlatformStatus,
|
||||
type TaskRecord,
|
||||
type TaskStatus
|
||||
} from "@cross-ai/domain";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
@@ -20,6 +24,7 @@ import { TaskSpine } from "./components/TaskSpine";
|
||||
import {
|
||||
confirmTask,
|
||||
createTask,
|
||||
deleteTask,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getTask,
|
||||
@@ -52,6 +57,67 @@ function formatPlatformNames(platforms: PlatformId[]) {
|
||||
return platforms.map((platform) => platformCatalogMap[platform].label).join("、");
|
||||
}
|
||||
|
||||
function isRetryablePlatformStatus(
|
||||
status: PlatformStatus
|
||||
): status is Extract<PlatformStatus, "SearchBlocked" | "Blocked" | "Failed"> {
|
||||
return status === "SearchBlocked" || status === "Blocked" || status === "Failed";
|
||||
}
|
||||
|
||||
function getTaskDestination(
|
||||
taskId: string,
|
||||
taskStatus: TaskStatus,
|
||||
hasReport: boolean,
|
||||
version?: number
|
||||
) {
|
||||
if (hasReport) {
|
||||
const query = version ? `?version=${version}` : "";
|
||||
return `/tasks/${taskId}/report${query}`;
|
||||
}
|
||||
|
||||
if (taskStatus === "AwaitingConfirmation") {
|
||||
return `/tasks/${taskId}/confirm`;
|
||||
}
|
||||
|
||||
return `/tasks/${taskId}/run`;
|
||||
}
|
||||
|
||||
function getTaskPrimaryLabel(taskStatus: TaskStatus, hasReport: boolean) {
|
||||
if (hasReport) {
|
||||
return "查看报告";
|
||||
}
|
||||
|
||||
if (taskStatus === "AwaitingConfirmation") {
|
||||
return "继续确认";
|
||||
}
|
||||
|
||||
return "查看任务";
|
||||
}
|
||||
|
||||
function getNoReportSummary(task: TaskRecord) {
|
||||
switch (task.taskStatus) {
|
||||
case "NoSelection":
|
||||
return "本轮未确认任何商品链接,任务在确认阶段收口,未生成报告。";
|
||||
case "AwaitingConfirmation":
|
||||
return "候选召回已完成,当前仍在等待人工确认,报告尚不可用。";
|
||||
case "Searching":
|
||||
case "Running":
|
||||
return "任务仍在处理中,报告会在可发布结果形成后生成。";
|
||||
case "Blocked":
|
||||
case "Failed": {
|
||||
const platformReasons = task.platformRuns
|
||||
.filter(
|
||||
(run) =>
|
||||
run.status === "SearchBlocked" || run.status === "Blocked" || run.status === "Failed"
|
||||
)
|
||||
.map((run) => `${platformCatalogMap[run.platform].label}:${run.reason ?? "当前平台未完成执行。"}`);
|
||||
|
||||
return platformReasons[0] ?? "当前任务没有形成可发布报告。";
|
||||
}
|
||||
default:
|
||||
return "当前任务暂无可发布报告。";
|
||||
}
|
||||
}
|
||||
|
||||
function NewTaskPage() {
|
||||
const navigate = useNavigate();
|
||||
const readinessQuery = useQuery({
|
||||
@@ -583,56 +649,335 @@ function ReportPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPage() {
|
||||
export function HistoryPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["history"],
|
||||
queryFn: getHistoryTasks
|
||||
});
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | TaskStatus>("all");
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | undefined>(undefined);
|
||||
const [deleteArmed, setDeleteArmed] = useState(false);
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
const tasks = historyQuery.data?.tasks ?? [];
|
||||
return tasks.filter((task) => {
|
||||
const matchesSearch =
|
||||
searchText.trim().length === 0 ||
|
||||
task.query.toLowerCase().includes(searchText.trim().toLowerCase());
|
||||
const matchesStatus = statusFilter === "all" || task.taskStatus === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [historyQuery.data?.tasks, searchText, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredTasks.length === 0) {
|
||||
setSelectedTaskId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstTask] = filteredTasks;
|
||||
|
||||
if (
|
||||
firstTask &&
|
||||
(!selectedTaskId || !filteredTasks.some((task) => task.taskId === selectedTaskId))
|
||||
) {
|
||||
setSelectedTaskId(firstTask.taskId);
|
||||
}
|
||||
}, [filteredTasks, selectedTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedVersion(undefined);
|
||||
setDeleteArmed(false);
|
||||
}, [selectedTaskId]);
|
||||
|
||||
const selectedHistoryTask = filteredTasks.find((task) => task.taskId === selectedTaskId);
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", selectedTaskId],
|
||||
queryFn: () => getTask(selectedTaskId ?? ""),
|
||||
enabled: Boolean(selectedTaskId)
|
||||
});
|
||||
const selectedTask = taskQuery.data?.task;
|
||||
const effectiveVersion =
|
||||
selectedVersion ??
|
||||
selectedTask?.defaultReportVersion ??
|
||||
selectedTask?.reportVersions[selectedTask.reportVersions.length - 1];
|
||||
const reportQuery = useQuery({
|
||||
queryKey: ["report", selectedTaskId, effectiveVersion ?? "latest"],
|
||||
queryFn: () => getTaskReport(selectedTaskId ?? "", effectiveVersion),
|
||||
enabled: Boolean(selectedTaskId && selectedHistoryTask?.hasReport && selectedTask)
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (platform: PlatformId) => retryTaskPlatform(selectedTaskId ?? "", platform),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["history"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["task", selectedTaskId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["report", selectedTaskId] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (taskId: string) => deleteTask(taskId),
|
||||
onSuccess: async (_, taskId) => {
|
||||
setDeleteArmed(false);
|
||||
setSelectedTaskId((current) => (current === taskId ? null : current));
|
||||
queryClient.removeQueries({ queryKey: ["task", taskId] });
|
||||
queryClient.removeQueries({ queryKey: ["report", taskId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["history"] });
|
||||
}
|
||||
});
|
||||
|
||||
const retryablePlatforms =
|
||||
selectedTask?.platformRuns
|
||||
.filter((run) => isRetryablePlatformStatus(run.status))
|
||||
.map((run) => run.platform) ?? [];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel">
|
||||
<p className="eyebrow">History</p>
|
||||
<h2>任务账本</h2>
|
||||
<div className="stack">
|
||||
{historyQuery.data?.tasks.map((task) => (
|
||||
<article key={task.taskId} className="history-card">
|
||||
<div className="history-card__topline">
|
||||
<div>
|
||||
<strong>{task.query}</strong>
|
||||
<p>{new Date(task.updatedAt).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
<TaskStatusPill status={task.taskStatus} />
|
||||
</div>
|
||||
<div className="history-card__actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={
|
||||
task.hasReport
|
||||
? `/tasks/${task.taskId}/report`
|
||||
: `/tasks/${task.taskId}/run`
|
||||
}
|
||||
<section className="history-layout">
|
||||
<article className="page-panel history-panel">
|
||||
<div className="history-panel__header">
|
||||
<div>
|
||||
<p className="eyebrow">History</p>
|
||||
<h2>任务账本</h2>
|
||||
</div>
|
||||
<span className="inline-note">按状态筛选、回看版本、继续处理阻塞任务。</span>
|
||||
</div>
|
||||
<div className="history-toolbar">
|
||||
<label className="field">
|
||||
<span>搜索任务</span>
|
||||
<input
|
||||
placeholder="按商品关键词搜索"
|
||||
type="search"
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>任务状态</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "all" | TaskStatus)}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
{taskStatuses
|
||||
.filter((status) => status !== "Draft")
|
||||
.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="stack">
|
||||
{filteredTasks.length > 0 ? (
|
||||
filteredTasks.map((task) => (
|
||||
<button
|
||||
key={task.taskId}
|
||||
className={`history-card history-card--selectable ${
|
||||
selectedTaskId === task.taskId ? "history-card--selected" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedTaskId(task.taskId)}
|
||||
type="button"
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<span>No report</span>
|
||||
)}
|
||||
<div className="history-card__topline">
|
||||
<div>
|
||||
<strong>{task.query}</strong>
|
||||
<p>{new Date(task.updatedAt).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
<TaskStatusPill status={task.taskStatus} />
|
||||
</div>
|
||||
<div className="history-card__actions">
|
||||
<span>
|
||||
{getTaskPrimaryLabel(task.taskStatus, task.hasReport)}
|
||||
</span>
|
||||
{task.blockedPlatforms.length > 0 || task.failedPlatforms.length > 0 ? (
|
||||
<span>
|
||||
待处理:
|
||||
{formatPlatformNames([
|
||||
...task.blockedPlatforms,
|
||||
...task.failedPlatforms
|
||||
])}
|
||||
</span>
|
||||
) : null}
|
||||
{task.defaultReportVersion ? (
|
||||
<span>默认版本 v{task.defaultReportVersion}</span>
|
||||
) : (
|
||||
<span>No report</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
{historyQuery.data?.tasks?.length
|
||||
? "当前筛选条件下没有匹配任务。"
|
||||
: "还没有任务,先创建第一条分析任务。"}
|
||||
</div>
|
||||
</article>
|
||||
)) ?? <div className="empty-state">还没有任务,先创建第一条分析任务。</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="page-panel history-preview">
|
||||
<p className="eyebrow">Task Preview</p>
|
||||
{!selectedTaskId ? (
|
||||
<div className="empty-state">从左侧选择一条任务,查看版本、状态和后续动作。</div>
|
||||
) : taskQuery.isLoading || !selectedTask ? (
|
||||
<div className="empty-state">正在加载任务详情...</div>
|
||||
) : (
|
||||
<div className="stack">
|
||||
<div className="history-preview__header">
|
||||
<div>
|
||||
<h3>{selectedTask.query}</h3>
|
||||
<p>{new Date(selectedTask.updatedAt).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
<TaskStatusPill status={selectedTask.taskStatus} />
|
||||
</div>
|
||||
|
||||
<div className="history-preview__section">
|
||||
<p className="eyebrow">Quick Actions</p>
|
||||
<div className="panel-actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={getTaskDestination(
|
||||
selectedTask.taskId,
|
||||
selectedTask.taskStatus,
|
||||
selectedTask.reportVersions.length > 0,
|
||||
effectiveVersion
|
||||
)}
|
||||
>
|
||||
{getTaskPrimaryLabel(
|
||||
selectedTask.taskStatus,
|
||||
selectedTask.reportVersions.length > 0
|
||||
)}
|
||||
</a>
|
||||
{retryablePlatforms.map((platform) => (
|
||||
<button
|
||||
key={platform}
|
||||
className="ghost-button"
|
||||
disabled={
|
||||
retryMutation.isPending && retryMutation.variables === platform
|
||||
}
|
||||
onClick={() => retryMutation.mutate(platform)}
|
||||
type="button"
|
||||
>
|
||||
重试 {platformCatalogMap[platform].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTask.reportVersions.length > 0 ? (
|
||||
<div className="history-preview__section">
|
||||
<div className="history-preview__topline">
|
||||
<div>
|
||||
<p className="eyebrow">Report Snapshot</p>
|
||||
<h4>
|
||||
{reportQuery.data?.report.summary.headline ?? "正在加载当前报告摘要..."}
|
||||
</h4>
|
||||
</div>
|
||||
{selectedTask.reportVersions.length > 1 ? (
|
||||
<label className="field field--inline history-version-switcher">
|
||||
<span>版本</span>
|
||||
<select
|
||||
value={String(effectiveVersion ?? selectedTask.reportVersions[0])}
|
||||
onChange={(event) => setSelectedVersion(Number(event.target.value))}
|
||||
>
|
||||
{selectedTask.reportVersions.map((reportVersion) => (
|
||||
<option key={reportVersion} value={reportVersion}>
|
||||
v{reportVersion}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{reportQuery.data ? (
|
||||
<>
|
||||
<ul className="list">
|
||||
{reportQuery.data.report.summary.key_points.map((point) => (
|
||||
<li key={point}>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="history-preview__chips">
|
||||
<span className="inline-note">
|
||||
平台数 {reportQuery.data.report.product_snapshot.platform_count}
|
||||
</span>
|
||||
<span className="inline-note">
|
||||
链接数 {reportQuery.data.report.product_snapshot.selected_link_count}
|
||||
</span>
|
||||
<span className="inline-note">
|
||||
评论样本 {reportQuery.data.report.product_snapshot.review_sample_count}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">正在加载报告摘要...</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="history-preview__section">
|
||||
<p className="eyebrow">No Report</p>
|
||||
<p>{getNoReportSummary(selectedTask)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="history-preview__section">
|
||||
<p className="eyebrow">Platform Coverage</p>
|
||||
<div className="history-preview__platforms">
|
||||
{selectedTask.platformRuns.map((run) => (
|
||||
<div key={run.platform} className="history-platform-row">
|
||||
<div className="history-platform-row__topline">
|
||||
<PlatformIdentity platform={run.platform} />
|
||||
<PlatformStatusPill status={run.status} />
|
||||
</div>
|
||||
<p>{run.reason ?? "当前平台没有额外异常说明。"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="history-preview__section history-preview__section--danger">
|
||||
<p className="eyebrow">Danger Zone</p>
|
||||
<p>删除后会移除任务记录以及已生成的报告快照。</p>
|
||||
<div className="panel-actions">
|
||||
{!deleteArmed ? (
|
||||
<button
|
||||
className="ghost-button ghost-button--danger"
|
||||
onClick={() => setDeleteArmed(true)}
|
||||
type="button"
|
||||
>
|
||||
删除任务
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="primary-button primary-button--danger"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(selectedTask.taskId)}
|
||||
type="button"
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteArmed(false)}
|
||||
type="button"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
preparePlatform: vi.fn(),
|
||||
retryTaskPlatform: vi.fn()
|
||||
}));
|
||||
|
||||
import { HistoryPage } from "./App";
|
||||
import {
|
||||
deleteTask,
|
||||
getHistoryTasks,
|
||||
getTask,
|
||||
getTaskReport,
|
||||
retryTaskPlatform
|
||||
} from "./lib/api";
|
||||
|
||||
function renderWithProviders(node: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{node}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("HistoryPage", () => {
|
||||
const reportTaskId = "task-report";
|
||||
const noReportTaskId = "task-no-report";
|
||||
let historyTasks: Array<{
|
||||
taskId: string;
|
||||
query: string;
|
||||
taskStatus: "Completed" | "NoSelection";
|
||||
updatedAt: string;
|
||||
hasReport: boolean;
|
||||
defaultReportVersion?: number;
|
||||
failedPlatforms: Array<"tmall" | "jd">;
|
||||
blockedPlatforms: Array<"tmall" | "jd">;
|
||||
}>;
|
||||
let taskDetails: Record<string, any>;
|
||||
|
||||
beforeEach(() => {
|
||||
historyTasks = [
|
||||
{
|
||||
taskId: reportTaskId,
|
||||
query: "Nintendo Switch 2",
|
||||
taskStatus: "Completed",
|
||||
updatedAt: "2026-04-02T12:00:00.000Z",
|
||||
hasReport: true,
|
||||
defaultReportVersion: 2,
|
||||
failedPlatforms: [],
|
||||
blockedPlatforms: []
|
||||
},
|
||||
{
|
||||
taskId: noReportTaskId,
|
||||
query: "DJI Pocket 3",
|
||||
taskStatus: "NoSelection",
|
||||
updatedAt: "2026-04-02T11:30:00.000Z",
|
||||
hasReport: false,
|
||||
failedPlatforms: [],
|
||||
blockedPlatforms: []
|
||||
}
|
||||
];
|
||||
taskDetails = {
|
||||
[reportTaskId]: {
|
||||
task: {
|
||||
taskId: reportTaskId,
|
||||
query: "Nintendo Switch 2",
|
||||
createdAt: "2026-04-02T11:40:00.000Z",
|
||||
updatedAt: "2026-04-02T12:00:00.000Z",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500,
|
||||
taskStatus: "Completed",
|
||||
taskStage: "publish",
|
||||
platformRuns: [
|
||||
{
|
||||
platform: "tmall",
|
||||
searchRequirement: "recommended",
|
||||
status: "Completed",
|
||||
candidateCount: 3,
|
||||
selectedCandidateIds: ["tmall-1"],
|
||||
lastUpdatedAt: "2026-04-02T11:58:00.000Z"
|
||||
},
|
||||
{
|
||||
platform: "jd",
|
||||
searchRequirement: "required",
|
||||
status: "Completed",
|
||||
candidateCount: 3,
|
||||
selectedCandidateIds: ["jd-1"],
|
||||
lastUpdatedAt: "2026-04-02T11:58:00.000Z"
|
||||
}
|
||||
],
|
||||
platformCandidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
},
|
||||
events: [],
|
||||
reportVersions: [1, 2],
|
||||
defaultReportVersion: 2,
|
||||
latestSuccessfulReportVersion: 2
|
||||
}
|
||||
},
|
||||
[noReportTaskId]: {
|
||||
task: {
|
||||
taskId: noReportTaskId,
|
||||
query: "DJI Pocket 3",
|
||||
createdAt: "2026-04-02T11:00:00.000Z",
|
||||
updatedAt: "2026-04-02T11:30:00.000Z",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500,
|
||||
taskStatus: "NoSelection",
|
||||
taskStage: "confirmation",
|
||||
platformRuns: [
|
||||
{
|
||||
platform: "tmall",
|
||||
searchRequirement: "recommended",
|
||||
status: "Skipped",
|
||||
candidateCount: 3,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: "2026-04-02T11:20:00.000Z"
|
||||
},
|
||||
{
|
||||
platform: "jd",
|
||||
searchRequirement: "required",
|
||||
status: "SearchBlocked",
|
||||
reason: "需要先完成会话准备,否则系统会标记为 SearchBlocked。",
|
||||
candidateCount: 0,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: "2026-04-02T11:20:00.000Z"
|
||||
}
|
||||
],
|
||||
platformCandidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
},
|
||||
events: [],
|
||||
reportVersions: []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
vi.mocked(getHistoryTasks).mockImplementation(async () => ({
|
||||
tasks: historyTasks
|
||||
}));
|
||||
vi.mocked(getTask).mockImplementation(async (taskId: string) => taskDetails[taskId]);
|
||||
vi.mocked(getTaskReport).mockResolvedValue({
|
||||
report: {
|
||||
report_id: "report-2",
|
||||
report_version: 2,
|
||||
task_id: reportTaskId,
|
||||
generated_at: "2026-04-02T12:00:00.000Z",
|
||||
task_status: "Completed",
|
||||
summary: {
|
||||
headline: "双平台卖点已经收敛。",
|
||||
key_points: ["两端价格带接近,评论样本覆盖完整。"],
|
||||
limitations: ["当前仍为模拟采集样本。"]
|
||||
},
|
||||
product_snapshot: {
|
||||
query: "Nintendo Switch 2",
|
||||
normalized_product_name: "Nintendo Switch 2",
|
||||
platform_count: 2,
|
||||
selected_link_count: 2,
|
||||
review_sample_count: 180,
|
||||
analysis_time_range: {
|
||||
start: "2026-04-02T11:40:00.000Z",
|
||||
end: "2026-04-02T12:00:00.000Z"
|
||||
}
|
||||
},
|
||||
platform_insights: [],
|
||||
cross_platform_insights: [],
|
||||
recommendations: [],
|
||||
evidence_index: [],
|
||||
quality_flags: {
|
||||
sample_insufficient: false,
|
||||
partial_platform_failure: false,
|
||||
blocked_platforms: [],
|
||||
failed_platforms: []
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(deleteTask).mockResolvedValue(undefined);
|
||||
vi.mocked(retryTaskPlatform).mockResolvedValue({ task: taskDetails[reportTaskId].task });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("filters history tasks and updates the preview panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<HistoryPage />);
|
||||
|
||||
expect(await screen.findByText("Nintendo Switch 2")).toBeInTheDocument();
|
||||
expect(await screen.findByText("双平台卖点已经收敛。")).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("任务状态"), "NoSelection");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Nintendo Switch 2")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText("本轮未确认任何商品链接,任务在确认阶段收口,未生成报告。")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requires a second confirmation before deleting a task", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(deleteTask).mockImplementation(async (taskId: string) => {
|
||||
historyTasks = historyTasks.filter((task) => task.taskId !== taskId);
|
||||
delete taskDetails[taskId];
|
||||
});
|
||||
|
||||
renderWithProviders(<HistoryPage />);
|
||||
|
||||
expect(await screen.findByText("Nintendo Switch 2")).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "删除任务" }));
|
||||
await user.click(screen.getByRole("button", { name: "确认删除" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteTask).toHaveBeenCalledWith(reportTaskId);
|
||||
expect(screen.queryByText("Nintendo Switch 2")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText("本轮未确认任何商品链接,任务在确认阶段收口,未生成报告。")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,10 @@ a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button--danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -195,6 +199,12 @@ a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-button--danger {
|
||||
border-color: rgba(182, 62, 47, 0.18);
|
||||
background: rgba(182, 62, 47, 0.08);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.primary-button--link {
|
||||
width: fit-content;
|
||||
}
|
||||
@@ -433,6 +443,96 @@ a {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.history-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.history-panel,
|
||||
.history-preview {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.history-panel__header,
|
||||
.history-preview__header,
|
||||
.history-preview__topline,
|
||||
.history-platform-row__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.history-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(200px, 0.7fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.history-card--selectable {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-card--selectable strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-card--selectable p,
|
||||
.history-platform-row p,
|
||||
.history-preview__header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.history-card--selected {
|
||||
border-color: rgba(20, 108, 110, 0.24);
|
||||
background: var(--brand-primary-soft);
|
||||
}
|
||||
|
||||
.history-card--selected .status-pill {
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.history-preview__section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.history-preview__section--danger {
|
||||
border-top-color: rgba(182, 62, 47, 0.18);
|
||||
}
|
||||
|
||||
.history-preview__section h4,
|
||||
.history-preview__header h3 {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.history-preview__chips,
|
||||
.history-preview__platforms {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.history-platform-row {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.history-version-switcher {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.sticky-actions {
|
||||
position: sticky;
|
||||
bottom: 24px;
|
||||
@@ -487,6 +587,8 @@ a {
|
||||
.hero-panel,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
.history-layout,
|
||||
.history-toolbar,
|
||||
.field-grid,
|
||||
.metrics-grid,
|
||||
.session-placeholder {
|
||||
|
||||
Reference in New Issue
Block a user