feat: 完善历史任务页与删除能力
This commit is contained in:
parent
99718c94fd
commit
29cea8b0aa
@ -285,4 +285,61 @@ describe("API server", () => {
|
|||||||
|
|
||||||
await app.close();
|
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 };
|
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<{
|
app.get<{
|
||||||
Params: { taskId: string };
|
Params: { taskId: string };
|
||||||
}>("/api/tasks/:taskId/candidates", async (request, reply) => {
|
}>("/api/tasks/:taskId/candidates", async (request, reply) => {
|
||||||
|
|||||||
@ -311,6 +311,14 @@ export class InMemoryTaskStore {
|
|||||||
return reports[reports.length - 1];
|
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 {
|
private runSearch(task: TaskRecord): void {
|
||||||
task.taskStage = "search";
|
task.taskStage = "search";
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
platformCatalogMap,
|
platformCatalogMap,
|
||||||
|
taskStatuses,
|
||||||
type CandidateRecord,
|
type CandidateRecord,
|
||||||
type PlatformId
|
type PlatformId,
|
||||||
|
type PlatformStatus,
|
||||||
|
type TaskRecord,
|
||||||
|
type TaskStatus
|
||||||
} from "@cross-ai/domain";
|
} from "@cross-ai/domain";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
@ -20,6 +24,7 @@ import { TaskSpine } from "./components/TaskSpine";
|
|||||||
import {
|
import {
|
||||||
confirmTask,
|
confirmTask,
|
||||||
createTask,
|
createTask,
|
||||||
|
deleteTask,
|
||||||
getHistoryTasks,
|
getHistoryTasks,
|
||||||
getPlatformReadiness,
|
getPlatformReadiness,
|
||||||
getTask,
|
getTask,
|
||||||
@ -52,6 +57,67 @@ function formatPlatformNames(platforms: PlatformId[]) {
|
|||||||
return platforms.map((platform) => platformCatalogMap[platform].label).join("、");
|
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() {
|
function NewTaskPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const readinessQuery = useQuery({
|
const readinessQuery = useQuery({
|
||||||
@ -583,20 +649,142 @@ function ReportPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryPage() {
|
export function HistoryPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const historyQuery = useQuery({
|
const historyQuery = useQuery({
|
||||||
queryKey: ["history"],
|
queryKey: ["history"],
|
||||||
queryFn: getHistoryTasks
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<section className="page-panel">
|
<section className="history-layout">
|
||||||
|
<article className="page-panel history-panel">
|
||||||
|
<div className="history-panel__header">
|
||||||
|
<div>
|
||||||
<p className="eyebrow">History</p>
|
<p className="eyebrow">History</p>
|
||||||
<h2>任务账本</h2>
|
<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">
|
<div className="stack">
|
||||||
{historyQuery.data?.tasks.map((task) => (
|
{filteredTasks.length > 0 ? (
|
||||||
<article key={task.taskId} className="history-card">
|
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"
|
||||||
|
>
|
||||||
<div className="history-card__topline">
|
<div className="history-card__topline">
|
||||||
<div>
|
<div>
|
||||||
<strong>{task.query}</strong>
|
<strong>{task.query}</strong>
|
||||||
@ -605,16 +793,9 @@ function HistoryPage() {
|
|||||||
<TaskStatusPill status={task.taskStatus} />
|
<TaskStatusPill status={task.taskStatus} />
|
||||||
</div>
|
</div>
|
||||||
<div className="history-card__actions">
|
<div className="history-card__actions">
|
||||||
<a
|
<span>
|
||||||
className="text-link"
|
{getTaskPrimaryLabel(task.taskStatus, task.hasReport)}
|
||||||
href={
|
</span>
|
||||||
task.hasReport
|
|
||||||
? `/tasks/${task.taskId}/report`
|
|
||||||
: `/tasks/${task.taskId}/run`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{task.hasReport ? "查看报告" : "查看任务"}
|
|
||||||
</a>
|
|
||||||
{task.blockedPlatforms.length > 0 || task.failedPlatforms.length > 0 ? (
|
{task.blockedPlatforms.length > 0 || task.failedPlatforms.length > 0 ? (
|
||||||
<span>
|
<span>
|
||||||
待处理:
|
待处理:
|
||||||
@ -630,9 +811,173 @@ function HistoryPage() {
|
|||||||
<span>No report</span>
|
<span>No report</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
)) ?? <div className="empty-state">还没有任务,先创建第一条分析任务。</div>}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">
|
||||||
|
{historyQuery.data?.tasks?.length
|
||||||
|
? "当前筛选条件下没有匹配任务。"
|
||||||
|
: "还没有任务,先创建第一条分析任务。"}
|
||||||
</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>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
249
apps/web/src/HistoryPage.test.tsx
Normal file
249
apps/web/src/HistoryPage.test.tsx
Normal file
@ -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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary-button--danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.ghost-button {
|
.ghost-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -195,6 +199,12 @@ a {
|
|||||||
cursor: pointer;
|
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 {
|
.primary-button--link {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
@ -433,6 +443,96 @@ a {
|
|||||||
margin-top: 16px;
|
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 {
|
.sticky-actions {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
@ -487,6 +587,8 @@ a {
|
|||||||
.hero-panel,
|
.hero-panel,
|
||||||
.page-grid,
|
.page-grid,
|
||||||
.report-grid,
|
.report-grid,
|
||||||
|
.history-layout,
|
||||||
|
.history-toolbar,
|
||||||
.field-grid,
|
.field-grid,
|
||||||
.metrics-grid,
|
.metrics-grid,
|
||||||
.session-placeholder {
|
.session-placeholder {
|
||||||
|
|||||||
133
docs/tasks.md
133
docs/tasks.md
@ -1,7 +1,7 @@
|
|||||||
# 跨平台商品聚合与 AI 分析开发任务清单
|
# 跨平台商品聚合与 AI 分析开发任务清单
|
||||||
|
|
||||||
- 文档状态:Draft
|
- 文档状态:Draft
|
||||||
- 版本:v0.2
|
- 版本:v0.3
|
||||||
- 更新时间:2026-04-02
|
- 更新时间:2026-04-02
|
||||||
- 依据文档:
|
- 依据文档:
|
||||||
- `docs/PRD.md`
|
- `docs/PRD.md`
|
||||||
@ -9,6 +9,7 @@
|
|||||||
- `docs/DevelopmentPlan.md`
|
- `docs/DevelopmentPlan.md`
|
||||||
- `docs/UIDesign.md`
|
- `docs/UIDesign.md`
|
||||||
- `docs/tdd.md`
|
- `docs/tdd.md`
|
||||||
|
- 当前进度快照基于:2026-04-02 仓库实现、`git status`、`npm run test` 与 `npm run typecheck`
|
||||||
|
|
||||||
## 1. 文档目标
|
## 1. 文档目标
|
||||||
|
|
||||||
@ -34,6 +35,11 @@
|
|||||||
| `阻塞` | 受外部依赖、平台策略或设计未决问题阻塞 |
|
| `阻塞` | 受外部依赖、平台策略或设计未决问题阻塞 |
|
||||||
| `已完成` | 已满足本文定义的产出、验收和测试门禁 |
|
| `已完成` | 已满足本文定义的产出、验收和测试门禁 |
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 本文已在阶段任务与横向任务表中维护 `当前状态` 列。
|
||||||
|
- `进行中` 表示已有骨架、样板或 mock 实现,但尚未满足该任务定义的完整产出、验收标准或测试门禁。
|
||||||
|
|
||||||
### 2.2 编号约定
|
### 2.2 编号约定
|
||||||
|
|
||||||
本文使用 `S0` 到 `S5` 表示开发阶段编号,对应 `DevelopmentPlan.md` 中的 `Phase 0` 到 `Phase 5`;不等同于产品优先级里的 `P0 / P1`。
|
本文使用 `S0` 到 `S5` 表示开发阶段编号,对应 `DevelopmentPlan.md` 中的 `Phase 0` 到 `Phase 5`;不等同于产品优先级里的 `P0 / P1`。
|
||||||
@ -90,98 +96,109 @@
|
|||||||
| `S4` | 第 10-11 周 | 标准化、三级聚合、AI 报告、历史任务、版本管理 | `M5` |
|
| `S4` | 第 10-11 周 | 标准化、三级聚合、AI 报告、历史任务、版本管理 | `M5` |
|
||||||
| `S5` | 第 12 周 | 稳定性、性能、试运行、发布准备 | `M6` |
|
| `S5` | 第 12 周 | 稳定性、性能、试运行、发布准备 | `M6` |
|
||||||
|
|
||||||
|
### 5.1 当前阶段进度快照
|
||||||
|
|
||||||
|
| 阶段 | 当前状态 | 当前判断 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `S0` | `进行中` | 工程骨架已搭好,但能力矩阵、fixture/HAR、PoC 验证与 `strategy_attempts` 口径未落地 |
|
||||||
|
| `S1` | `进行中` | 共享领域模型、报告 Schema、核心路由已落地;持久化、队列、真实 `SSE`、完整会话中心仍未完成 |
|
||||||
|
| `S2` | `进行中` | 候选确认与最小闭环可演示,但搜索/详情/评论/标准化仍以 mock 为主 |
|
||||||
|
| `S3` | `进行中` | 恢复页、双平台工作台、`PartialCompleted` 与平台级重试已具备雏形;`L2` 模板刷新和双平台回归包未开始 |
|
||||||
|
| `S4` | `进行中` | 报告版本规则、历史任务页、版本切换、删除入口已落地;完整聚合、真正 AI 报告、留存清理与审计未完成 |
|
||||||
|
| `S5` | `未开始` | 稳定性、性能、UAT、部署与发布准备尚未进入实施 |
|
||||||
|
|
||||||
## 6. 阶段任务清单
|
## 6. 阶段任务清单
|
||||||
|
|
||||||
### 6.1 `S0` 双平台可行性勘探与方案冻结
|
### 6.1 `S0` 双平台可行性勘探与方案冻结
|
||||||
|
|
||||||
阶段目标:在正式编码前,验证双平台非浏览器主路径可行、服务端受控浏览器可接管、能力矩阵和工程方案可落地。
|
阶段目标:在正式编码前,验证双平台非浏览器主路径可行、服务端受控浏览器可接管、能力矩阵和工程方案可落地。
|
||||||
|
|
||||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `S0-01` | 冻结双平台能力矩阵 | 产品、平台、自动化 | `PRD`、`FeatureSummary`、`DevelopmentPlan` | 天猫/京东 `search/detail/reviews/login` 能力矩阵,`search_requirement`,默认路径与降级路径 | 每个平台都明确 `L0/L1/L2/L3` 路径、登录依赖、阻塞分类、可接受兜底成本 | fixture 解析测试、能力矩阵契约测试 |
|
| `S0-01` | `未开始` | 冻结双平台能力矩阵 | 产品、平台、自动化 | `PRD`、`FeatureSummary`、`DevelopmentPlan` | 天猫/京东 `search/detail/reviews/login` 能力矩阵,`search_requirement`,默认路径与降级路径 | 每个平台都明确 `L0/L1/L2/L3` 路径、登录依赖、阻塞分类、可接受兜底成本 | fixture 解析测试、能力矩阵契约测试 |
|
||||||
| `S0-02` | 产出双平台首批 fixture 与 HAR 样本 | 平台、自动化、QA | `S0-01` | 搜索、详情、评论、阻塞场景样本;最小 HAR 回放样本 | 双平台至少覆盖“正常返回、无结果、需登录、抓取失败、样本不足”五类样本 | HAR 回放测试、fixture 冒烟测试 |
|
| `S0-02` | `未开始` | 产出双平台首批 fixture 与 HAR 样本 | 平台、自动化、QA | `S0-01` | 搜索、详情、评论、阻塞场景样本;最小 HAR 回放样本 | 双平台至少覆盖“正常返回、无结果、需登录、抓取失败、样本不足”五类样本 | HAR 回放测试、fixture 冒烟测试 |
|
||||||
| `S0-03` | 验证服务端受控浏览器与会话快照 PoC | 自动化、后端 | `UIDesign`、`DevelopmentPlan` | 远程浏览器接管 PoC、会话快照保存与恢复 PoC | 用户可完成一次远程登录,系统可加密保存并复用会话,完成后可回跳来源页 | 浏览器接管冒烟测试、会话快照读写测试 |
|
| `S0-03` | `进行中` | 验证服务端受控浏览器与会话快照 PoC | 自动化、后端 | `UIDesign`、`DevelopmentPlan` | 远程浏览器接管 PoC、会话快照保存与恢复 PoC | 用户可完成一次远程登录,系统可加密保存并复用会话,完成后可回跳来源页 | 浏览器接管冒烟测试、会话快照读写测试 |
|
||||||
| `S0-04` | 验证至少一个平台的非浏览器主路径 PoC | 平台、后端 | `S0-01`、`S0-02` | 至少一个平台的 `search/detail/reviews` 非浏览器主路径 PoC | 至少一个平台满足“搜索成功率 >= 80%、详情字段完整率 >= 85%、50 条评论耗时 <= 90s” | PoC 性能基准测试、路径降级测试 |
|
| `S0-04` | `未开始` | 验证至少一个平台的非浏览器主路径 PoC | 平台、后端 | `S0-01`、`S0-02` | 至少一个平台的 `search/detail/reviews` 非浏览器主路径 PoC | 至少一个平台满足“搜索成功率 >= 80%、详情字段完整率 >= 85%、50 条评论耗时 <= 90s” | PoC 性能基准测试、路径降级测试 |
|
||||||
| `S0-05` | 搭建 Monorepo 与基础工程骨架 | 后端、前端 | `DevelopmentPlan` | `pnpm workspace + Turborepo`、应用目录、共享包目录、统一脚本 | `apps/`、`packages/` 结构落地;`dev/build/test/lint` 可运行 | 构建冒烟测试、CI 骨架校验 |
|
| `S0-05` | `已完成` | 搭建 Monorepo 与基础工程骨架 | 后端、前端 | `DevelopmentPlan` | `pnpm workspace + Turborepo`、应用目录、共享包目录、统一脚本 | `apps/`、`packages/` 结构落地;`dev/build/test/lint` 可运行 | 构建冒烟测试、CI 骨架校验 |
|
||||||
| `S0-06` | 冻结 Phase 0 量化评分表、`strategy_attempts` 记录格式与进入开发门槛 | 产品、QA、平台 | `S0-03`、`S0-04` | 量化评分表、`strategy_attempts` 最小记录格式、是否进入正式开发的结论 | 明确 `M1` 是否通过;若未通过,记录阻塞点和改造方向;PoC 路由选择、结果和耗时统计口径已冻结 | Phase 0 结果审计清单、`strategy_attempts` 契约测试 |
|
| `S0-06` | `未开始` | 冻结 Phase 0 量化评分表、`strategy_attempts` 记录格式与进入开发门槛 | 产品、QA、平台 | `S0-03`、`S0-04` | 量化评分表、`strategy_attempts` 最小记录格式、是否进入正式开发的结论 | 明确 `M1` 是否通过;若未通过,记录阻塞点和改造方向;PoC 路由选择、结果和耗时统计口径已冻结 | Phase 0 结果审计清单、`strategy_attempts` 契约测试 |
|
||||||
|
|
||||||
### 6.2 `S1` 基础骨架与任务系统
|
### 6.2 `S1` 基础骨架与任务系统
|
||||||
|
|
||||||
阶段目标:搭起可测的系统骨架,固化状态模型、会话中心、任务创建与执行框架。
|
阶段目标:搭起可测的系统骨架,固化状态模型、会话中心、任务创建与执行框架。
|
||||||
|
|
||||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `S1-01` | 共享领域模型与枚举包落地 | 后端、数据AI | `S0-05` | `task_status`、`task_stage`、`platform_status`、`execution_status`、报告 Schema 包 | Web、API、Worker 共用同一份枚举与类型定义;`NoSelection`、`PartialCompleted` 正式入模 | 枚举契约测试、报告 Schema 基础测试 |
|
| `S1-01` | `已完成` | 共享领域模型与枚举包落地 | 后端、数据AI | `S0-05` | `task_status`、`task_stage`、`platform_status`、`execution_status`、报告 Schema 包 | Web、API、Worker 共用同一份枚举与类型定义;`NoSelection`、`PartialCompleted` 正式入模 | 枚举契约测试、报告 Schema 基础测试 |
|
||||||
| `S1-02` | 数据库、事件日志与对象存储模型落地 | 后端 | `S1-01` | `tasks`、`platform_runs`、`selected_links`、`task_events`、`strategy_attempts`、`raw_*`、`normalized_*`、`report_snapshots`、`evidence_index`、`session_states`、`artifact_refs` 等表结构与迁移 | 表结构覆盖 P0 全流程;事件、策略尝试和对象存储引用可关联任务与平台执行记录 | 数据迁移测试、实体约束测试、事件表契约测试 |
|
| `S1-02` | `未开始` | 数据库、事件日志与对象存储模型落地 | 后端 | `S1-01` | `tasks`、`platform_runs`、`selected_links`、`task_events`、`strategy_attempts`、`raw_*`、`normalized_*`、`report_snapshots`、`evidence_index`、`session_states`、`artifact_refs` 等表结构与迁移 | 表结构覆盖 P0 全流程;事件、策略尝试和对象存储引用可关联任务与平台执行记录 | 数据迁移测试、实体约束测试、事件表契约测试 |
|
||||||
| `S1-03` | 任务编排、事件持久化与状态机骨架落地 | 后端 | `S1-01`、`S1-02` | BullMQ 队列、任务状态机、`task_events` 记录、平台并发控制、重试约束 | 可支持 `Draft -> Searching -> AwaitingConfirmation` 主路径;每次阶段/状态变更写入事件日志;`SearchBlocked` 不拖死整任务 | 状态机转移测试、事件持久化测试、队列载荷测试 |
|
| `S1-03` | `进行中` | 任务编排、事件持久化与状态机骨架落地 | 后端 | `S1-01`、`S1-02` | BullMQ 队列、任务状态机、`task_events` 记录、平台并发控制、重试约束 | 可支持 `Draft -> Searching -> AwaitingConfirmation` 主路径;每次阶段/状态变更写入事件日志;`SearchBlocked` 不拖死整任务 | 状态机转移测试、事件持久化测试、队列载荷测试 |
|
||||||
| `S1-04` | API / BFF、平台就绪摘要与 `SSE` 基础接口落地 | 后端 | `S1-03` | 任务创建、任务详情、候选查询、历史查询、平台 readiness 摘要、会话入口、实时事件流接口 | 前端可查询任务、平台就绪状态、订阅事件并读取平台状态 | REST 契约测试、`SSE` 事件契约测试、platform readiness 契约测试 |
|
| `S1-04` | `进行中` | API / BFF、平台就绪摘要与 `SSE` 基础接口落地 | 后端 | `S1-03` | 任务创建、任务详情、候选查询、历史查询、平台 readiness 摘要、会话入口、实时事件流接口 | 前端可查询任务、平台就绪状态、订阅事件并读取平台状态 | REST 契约测试、`SSE` 事件契约测试、platform readiness 契约测试 |
|
||||||
| `S1-05` | Web 工作台基础壳层与核心路由落地 | 前端、设计 | `UIDesign`、`S1-04` | 左侧导航、任务上下文头部、`TaskSpine`、基础页面路由 | 可访问 `/tasks/new`、`/tasks/:id/confirm`、`/tasks/:id/run`、`/history`、`/sessions/:platform/prepare`;基础布局与状态占位正确 | 页面路由测试、共享组件快照测试 |
|
| `S1-05` | `已完成` | Web 工作台基础壳层与核心路由落地 | 前端、设计 | `UIDesign`、`S1-04` | 左侧导航、任务上下文头部、`TaskSpine`、基础页面路由 | 可访问 `/tasks/new`、`/tasks/:id/confirm`、`/tasks/:id/run`、`/history`、`/sessions/:platform/prepare`;基础布局与状态占位正确 | 页面路由测试、共享组件快照测试 |
|
||||||
| `S1-06` | 会话中心 v1 与全局会话准备后端入口落地 | 后端、自动化、前端 | `S0-03`、`S1-02`、`S1-04` | 会话保存、过期时间、手动清理、`/sessions/:platform/prepare` 入口、来源页回跳协议 | 支持加密存储、24 小时有效期、按平台查看与清理会话;完成准备后可返回来源页并刷新状态 | 会话保存/过期/清理测试、prepare 回跳测试 |
|
| `S1-06` | `进行中` | 会话中心 v1 与全局会话准备后端入口落地 | 后端、自动化、前端 | `S0-03`、`S1-02`、`S1-04` | 会话保存、过期时间、手动清理、`/sessions/:platform/prepare` 入口、来源页回跳协议 | 支持加密存储、24 小时有效期、按平台查看与清理会话;完成准备后可返回来源页并刷新状态 | 会话保存/过期/清理测试、prepare 回跳测试 |
|
||||||
| `S1-07` | 新建任务页与全局会话准备入口落地 | 前端、后端、设计 | `UIDesign`、`S1-04`、`S1-05`、`S1-06` | `/tasks/new`、`Hero Composer`、`Sampling Config`、`Platform Readiness Panel`、`Recent Tasks Mini List`、全局会话准备入口 | 支持自然语言输入;默认 `per_link_limit = 100`、`task_total_limit = 500` 且允许按规则调整;正确展示 `required/recommended` 平台提示与创建前会话预热;创建成功后进入确认页 | 新建任务页交互测试、输入校验测试、prepare 入口回跳测试 |
|
| `S1-07` | `进行中` | 新建任务页与全局会话准备入口落地 | 前端、后端、设计 | `UIDesign`、`S1-04`、`S1-05`、`S1-06` | `/tasks/new`、`Hero Composer`、`Sampling Config`、`Platform Readiness Panel`、`Recent Tasks Mini List`、全局会话准备入口 | 支持自然语言输入;默认 `per_link_limit = 100`、`task_total_limit = 500` 且允许按规则调整;正确展示 `required/recommended` 平台提示与创建前会话预热;创建成功后进入确认页 | 新建任务页交互测试、输入校验测试、prepare 入口回跳测试 |
|
||||||
| `S1-08` | TDD 与 CI 基础链路落地 | QA、前端、后端 | `S0-05` | `Vitest`、`Playwright`、Schema 校验、`lint/build/test` 流水线 | 提交前与 PR 阶段的最小测试链路可运行 | CI 冒烟测试、空场景回归测试 |
|
| `S1-08` | `进行中` | TDD 与 CI 基础链路落地 | QA、前端、后端 | `S0-05` | `Vitest`、`Playwright`、Schema 校验、`lint/build/test` 流水线 | 提交前与 PR 阶段的最小测试链路可运行 | CI 冒烟测试、空场景回归测试 |
|
||||||
|
|
||||||
### 6.3 `S2` 单平台 API 优先闭环
|
### 6.3 `S2` 单平台 API 优先闭环
|
||||||
|
|
||||||
阶段目标:先在一个平台跑通完整闭环,验证搜索、确认、详情、评论、标准化与最小报告链路。
|
阶段目标:先在一个平台跑通完整闭环,验证搜索、确认、详情、评论、标准化与最小报告链路。
|
||||||
|
|
||||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `S2-01` | 首个平台预检查与搜索适配器落地 | 平台、后端 | `S0-04`、`S1-03`、`S1-04` | 单平台 `precheck/search` 适配器、候选标准化结果、搜索阶段 `strategy_attempts` 记录 | 返回候选、`NoResult` 或 `SearchBlocked` 三类明确结果;`required/recommended` 平台搜索前行为符合既定口径;搜索路径选择与失败分类可回溯 | 搜索 fixture 测试、预检查规则测试、路由选择测试 |
|
| `S2-01` | `进行中` | 首个平台预检查与搜索适配器落地 | 平台、后端 | `S0-04`、`S1-03`、`S1-04` | 单平台 `precheck/search` 适配器、候选标准化结果、搜索阶段 `strategy_attempts` 记录 | 返回候选、`NoResult` 或 `SearchBlocked` 三类明确结果;`required/recommended` 平台搜索前行为符合既定口径;搜索路径选择与失败分类可回溯 | 搜索 fixture 测试、预检查规则测试、路由选择测试 |
|
||||||
| `S2-02` | 候选确认页与确认 API 落地 | 前端、后端、设计 | `S1-05`、`S2-01` | `/tasks/:taskId/confirm` 页、确认提交接口、`SelectionBasket` | 支持单选、多选、跳过、零确认收口;进入 `NoSelection` 时不生成报告 | 候选确认 E2E、`NoSelection` 终态测试 |
|
| `S2-02` | `已完成` | 候选确认页与确认 API 落地 | 前端、后端、设计 | `S1-05`、`S2-01` | `/tasks/:taskId/confirm` 页、确认提交接口、`SelectionBasket` | 支持单选、多选、跳过、零确认收口;进入 `NoSelection` 时不生成报告 | 候选确认 E2E、`NoSelection` 终态测试 |
|
||||||
| `S2-03` | 单平台商品详情抓取链路落地 | 平台、后端 | `S2-01` | 详情采集器、原始字段留存、对象存储引用、详情阶段 `strategy_attempts` 记录 | 商品标题、价格、规格、店铺、评分、销量、抓取时间可抓取并回溯;详情抓取路径与失败分类可追溯 | 详情解析测试、原始字段留存测试、详情路由测试 |
|
| `S2-03` | `未开始` | 单平台商品详情抓取链路落地 | 平台、后端 | `S2-01` | 详情采集器、原始字段留存、对象存储引用、详情阶段 `strategy_attempts` 记录 | 商品标题、价格、规格、店铺、评分、销量、抓取时间可抓取并回溯;详情抓取路径与失败分类可追溯 | 详情解析测试、原始字段留存测试、详情路由测试 |
|
||||||
| `S2-04` | 单平台评论采集与抽样链路落地 | 平台、后端 | `S2-03` | 评论采集器、三桶抽样、去重与样本不足标记、评论阶段 `strategy_attempts` 记录 | 满足 `40/30/30` 规则;评论不足时正确打 `sample_insufficient` 标记;评论抓取路径与失败分类可追溯 | 评论抽样测试、去重测试、样本不足测试、评论路由测试 |
|
| `S2-04` | `未开始` | 单平台评论采集与抽样链路落地 | 平台、后端 | `S2-03` | 评论采集器、三桶抽样、去重与样本不足标记、评论阶段 `strategy_attempts` 记录 | 满足 `40/30/30` 规则;评论不足时正确打 `sample_insufficient` 标记;评论抓取路径与失败分类可追溯 | 评论抽样测试、去重测试、样本不足测试、评论路由测试 |
|
||||||
| `S2-05` | 标准化 v1 与最小报告快照落地 | 数据AI、后端 | `S2-03`、`S2-04` | 商品/评论标准化、最小 `report_snapshot`、最小 `evidence_index` | 可生成单平台结构化摘要;关键字段统一到既定口径 | 标准化测试、最小报告 Schema 测试 |
|
| `S2-05` | `进行中` | 标准化 v1 与最小报告快照落地 | 数据AI、后端 | `S2-03`、`S2-04` | 商品/评论标准化、最小 `report_snapshot`、最小 `evidence_index` | 可生成单平台结构化摘要;关键字段统一到既定口径 | 标准化测试、最小报告 Schema 测试 |
|
||||||
| `S2-06` | 单平台执行页闭环与回归包落地 | 前端、后端、QA | `S1-07`、`S2-02`、`S2-05` | 执行页单平台闭环、首条端到端回归包 | 单平台可从新建任务走到 `Completed`;`NoSelection` 可独立收口;关键路由尝试与事件日志可用于回放问题 | 单平台 E2E、`SSE` 更新测试、可访问性基础测试 |
|
| `S2-06` | `进行中` | 单平台执行页闭环与回归包落地 | 前端、后端、QA | `S1-07`、`S2-02`、`S2-05` | 执行页单平台闭环、首条端到端回归包 | 单平台可从新建任务走到 `Completed`;`NoSelection` 可独立收口;关键路由尝试与事件日志可用于回放问题 | 单平台 E2E、`SSE` 更新测试、可访问性基础测试 |
|
||||||
|
|
||||||
### 6.4 `S3` 双平台模板刷新与恢复体系
|
### 6.4 `S3` 双平台模板刷新与恢复体系
|
||||||
|
|
||||||
阶段目标:扩展到双平台,补齐阻塞恢复、模板刷新、部分成功与平台级重试。
|
阶段目标:扩展到双平台,补齐阻塞恢复、模板刷新、部分成功与平台级重试。
|
||||||
|
|
||||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `S3-01` | 第二平台 `precheck/search/detail/reviews` 适配器落地 | 平台、后端 | `S2-05` | 双平台搜索、详情、评论适配器 | 双平台都能返回候选、无结果或阻塞原因,且至少有一条详情与评论抓取路径可用 | 双平台 fixture 测试、适配器回放测试 |
|
| `S3-01` | `进行中` | 第二平台 `precheck/search/detail/reviews` 适配器落地 | 平台、后端 | `S2-05` | 双平台搜索、详情、评论适配器 | 双平台都能返回候选、无结果或阻塞原因,且至少有一条详情与评论抓取路径可用 | 双平台 fixture 测试、适配器回放测试 |
|
||||||
| `S3-02` | 模板刷新与 `L2` 路径落地 | 自动化、平台 | `S0-03`、`S3-01` | 模板刷新、动态参数补齐、策略切换逻辑 | 请求模板失效时可转入 `L2`,成功后回到 HTTP 主路径 | 路由降级测试、模板刷新冒烟测试 |
|
| `S3-02` | `未开始` | 模板刷新与 `L2` 路径落地 | 自动化、平台 | `S0-03`、`S3-01` | 模板刷新、动态参数补齐、策略切换逻辑 | 请求模板失效时可转入 `L2`,成功后回到 HTTP 主路径 | 路由降级测试、模板刷新冒烟测试 |
|
||||||
| `S3-03` | 阻塞恢复与 `L3 Browser Recovery` 落地 | 自动化、前端、后端 | `S1-06`、`S3-02` | `/tasks/:taskId/recovery/:platform`、恢复流程、恢复后回跳 | `SearchBlocked`、`Blocked` 平台可发起恢复;恢复成功后任务继续 | 阻塞恢复 E2E、会话恢复回跳测试 |
|
| `S3-03` | `进行中` | 阻塞恢复与 `L3 Browser Recovery` 落地 | 自动化、前端、后端 | `S1-06`、`S3-02` | `/tasks/:taskId/recovery/:platform`、恢复流程、恢复后回跳 | `SearchBlocked`、`Blocked` 平台可发起恢复;恢复成功后任务继续 | 阻塞恢复 E2E、会话恢复回跳测试 |
|
||||||
| `S3-04` | 双平台候选确认与执行控制台落地 | 前端、设计、后端 | `S3-01`、`S3-03` | 双平台 `Candidate Board`、`PlatformRunPanel`、`Live Event Feed` | 候选页、执行页能并列展示平台状态;新事件到达不打断当前阅读 | 双平台页面交互测试、`SSE` 并发更新测试 |
|
| `S3-04` | `进行中` | 双平台候选确认与执行控制台落地 | 前端、设计、后端 | `S3-01`、`S3-03` | 双平台 `Candidate Board`、`PlatformRunPanel`、`Live Event Feed` | 候选页、执行页能并列展示平台状态;新事件到达不打断当前阅读 | 双平台页面交互测试、`SSE` 并发更新测试 |
|
||||||
| `S3-05` | `PartialCompleted`、`Blocked`、`Failed` 汇总规则落地 | 后端、数据AI | `S3-01`、`S3-03` | 任务汇总逻辑、平台级重试入口、`RetryablePlatformPicker` | 一个平台成功、一个平台阻塞时进入 `PartialCompleted`;已确认平台全部失败时进入 `Failed`;`NoResult` / `Skipped` 不误算为 `Completed` 或 `Failed`;仅失败/阻塞平台可重试 | 状态汇总测试、平台级重试范围测试、`NoResult/Skipped` 汇总测试 |
|
| `S3-05` | `已完成` | `PartialCompleted`、`Blocked`、`Failed` 汇总规则落地 | 后端、数据AI | `S3-01`、`S3-03` | 任务汇总逻辑、平台级重试入口、`RetryablePlatformPicker` | 一个平台成功、一个平台阻塞时进入 `PartialCompleted`;已确认平台全部失败时进入 `Failed`;`NoResult` / `Skipped` 不误算为 `Completed` 或 `Failed`;仅失败/阻塞平台可重试 | 状态汇总测试、平台级重试范围测试、`NoResult/Skipped` 汇总测试 |
|
||||||
| `S3-06` | 双平台主回归包落地 | QA、前端、后端 | `S3-05` | 双平台回归包与阶段验收记录 | 覆盖“一个 `SearchBlocked`、一个成功”“一个 `NoResult`、一个成功”“一个成功、一个 `Blocked`”“已确认平台全部失败”四类主场景 | 双平台 E2E、回归报告 |
|
| `S3-06` | `未开始` | 双平台主回归包落地 | QA、前端、后端 | `S3-05` | 双平台回归包与阶段验收记录 | 覆盖“一个 `SearchBlocked`、一个成功”“一个 `NoResult`、一个成功”“一个成功、一个 `Blocked`”“已确认平台全部失败”四类主场景 | 双平台 E2E、回归报告 |
|
||||||
|
|
||||||
### 6.5 `S4` 标准化、三级聚合、AI 报告与历史任务
|
### 6.5 `S4` 标准化、三级聚合、AI 报告与历史任务
|
||||||
|
|
||||||
阶段目标:完成报告产品化交付,包括标准化、聚合、证据索引、历史版本、留存与删除。
|
阶段目标:完成报告产品化交付,包括标准化、聚合、证据索引、历史版本、留存与删除。
|
||||||
|
|
||||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `S4-01` | 完整标准化与三级聚合落地 | 数据AI、后端 | `S3-01`、`S3-05` | 商品/评论标准化、链接级/平台级/跨平台级聚合视图 | 平台内多链接不强制合并;跨平台默认以平台级为主视角 | 标准化全量测试、三级聚合测试 |
|
| `S4-01` | `进行中` | 完整标准化与三级聚合落地 | 数据AI、后端 | `S3-01`、`S3-05` | 商品/评论标准化、链接级/平台级/跨平台级聚合视图 | 平台内多链接不强制合并;跨平台默认以平台级为主视角 | 标准化全量测试、三级聚合测试 |
|
||||||
| `S4-02` | AI 结构化报告生成与版本规则落地 | 数据AI、后端 | `S4-01`、`S1-01` | `summary`、`product_snapshot`、`platform_insights`、`cross_platform_insights`、`recommendations`、`evidence_index`、`quality_flags`、`report_version` 生成规则 | 报告仅允许 `Completed` / `PartialCompleted`;强结论必须带证据;同一 `task_id` 下 `report_version` 从 `1` 递增且结果未变化不生成新版本 | 完整报告 Schema 测试、证据约束测试、版本规则测试 |
|
| `S4-02` | `进行中` | AI 结构化报告生成与版本规则落地 | 数据AI、后端 | `S4-01`、`S1-01` | `summary`、`product_snapshot`、`platform_insights`、`cross_platform_insights`、`recommendations`、`evidence_index`、`quality_flags`、`report_version` 生成规则 | 报告仅允许 `Completed` / `PartialCompleted`;强结论必须带证据;同一 `task_id` 下 `report_version` 从 `1` 递增且结果未变化不生成新版本 | 完整报告 Schema 测试、证据约束测试、版本规则测试 |
|
||||||
| `S4-03` | 报告页、证据抽屉与质量标记落地 | 前端、设计 | `S4-02`、`UIDesign` | `/tasks/:taskId/report`、`InsightCard`、`EvidenceDrawer`、`QualityFlagPanel` | 先展示摘要,再支持证据下钻;异常平台使用 `execution_status` 展示 | 报告页组件测试、证据抽屉测试、a11y 测试 |
|
| `S4-03` | `进行中` | 报告页、证据抽屉与质量标记落地 | 前端、设计 | `S4-02`、`UIDesign` | `/tasks/:taskId/report`、`InsightCard`、`EvidenceDrawer`、`QualityFlagPanel` | 先展示摘要,再支持证据下钻;异常平台使用 `execution_status` 展示 | 报告页组件测试、证据抽屉测试、a11y 测试 |
|
||||||
| `S4-04` | 历史任务页、版本切换与删除入口落地 | 前端、后端 | `S4-02`、`S4-03` | `/history`、`VersionSwitcher`、搜索/筛选、删除确认、平台级重试入口、无报告任务展示 | 同一任务可切换 `report_version`;`NoSelection` / `Failed` 任务不显示为空白;历史页支持筛选、回看、删除和平台级重试入口 | 历史任务测试、版本切换测试、删除交互测试 |
|
| `S4-04` | `已完成` | 历史任务页、版本切换与删除入口落地 | 前端、后端 | `S4-02`、`S4-03` | `/history`、`VersionSwitcher`、搜索/筛选、删除确认、平台级重试入口、无报告任务展示 | 同一任务可切换 `report_version`;`NoSelection` / `Failed` 任务不显示为空白;历史页支持筛选、回看、删除和平台级重试入口 | 历史任务测试、版本切换测试、删除交互测试 |
|
||||||
| `S4-05` | 留存、删除 API 与联动清理链路落地 | 后端、QA | `S1-02`、`S4-04` | 30/90 天清理作业、任务级删除 API、对象存储联动清理、残留审计 | 原始数据 30 天、标准化与报告 90 天;用户删除后关联数据与产物一致清理;前台删除动作与后台清理结果一致 | 留存作业测试、删除 API 契约测试、删除联动测试 |
|
| `S4-05` | `进行中` | 留存、删除 API 与联动清理链路落地 | 后端、QA | `S1-02`、`S4-04` | 30/90 天清理作业、任务级删除 API、对象存储联动清理、残留审计 | 原始数据 30 天、标准化与报告 90 天;用户删除后关联数据与产物一致清理;前台删除动作与后台清理结果一致 | 留存作业测试、删除 API 契约测试、删除联动测试 |
|
||||||
| `S4-06` | 完整可观测性与审计日志落地 | 后端、自动化、数据AI | `S1-02`、`S3-02`、`S4-02` | `strategy_attempts` 聚合查询、`platform_run_metrics`、`report_metrics`、`retention_metrics`、AI 请求摘要与恢复审计日志 | 能支持策略命中率、浏览器占比、重试效果、留存清理与报告质量统计;关键动作具备审计追溯能力 | 指标完整性测试、审计日志测试 |
|
| `S4-06` | `未开始` | 完整可观测性与审计日志落地 | 后端、自动化、数据AI | `S1-02`、`S3-02`、`S4-02` | `strategy_attempts` 聚合查询、`platform_run_metrics`、`report_metrics`、`retention_metrics`、AI 请求摘要与恢复审计日志 | 能支持策略命中率、浏览器占比、重试效果、留存清理与报告质量统计;关键动作具备审计追溯能力 | 指标完整性测试、审计日志测试 |
|
||||||
|
|
||||||
### 6.6 `S5` 稳定性、性能、试运行与发布准备
|
### 6.6 `S5` 稳定性、性能、试运行与发布准备
|
||||||
|
|
||||||
阶段目标:把系统从“能跑”推进到“可试运行、可排障、可热修”。
|
阶段目标:把系统从“能跑”推进到“可试运行、可排障、可热修”。
|
||||||
|
|
||||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `S5-01` | 平台级定向重试稳定化 | 后端、平台、自动化 | `S3-05`、`S4-02`、`S4-04` | 失败/阻塞平台定向重试、重试幂等保护、结果差异检测接入既有版本规则 | 仅 `SearchBlocked`、`Blocked`、`Failed` 可重试;已成功平台不被误重跑;结果变化时沿用既定版本规则生成新版本 | 重试规则测试、重试范围测试、幂等性测试 |
|
| `S5-01` | `进行中` | 平台级定向重试稳定化 | 后端、平台、自动化 | `S3-05`、`S4-02`、`S4-04` | 失败/阻塞平台定向重试、重试幂等保护、结果差异检测接入既有版本规则 | 仅 `SearchBlocked`、`Blocked`、`Failed` 可重试;已成功平台不被误重跑;结果变化时沿用既定版本规则生成新版本 | 重试规则测试、重试范围测试、幂等性测试 |
|
||||||
| `S5-02` | 性能与成本优化 | 后端、平台、数据AI | `S4-06` | 限流、并发、评论分页、缓存、浏览器占比优化 | 任务时长 `P50 <= 20 分钟`;全量浏览器兜底占比 `<= 30%` | 性能基准测试、指标对账测试 |
|
| `S5-02` | `未开始` | 性能与成本优化 | 后端、平台、数据AI | `S4-06` | 限流、并发、评论分页、缓存、浏览器占比优化 | 任务时长 `P50 <= 20 分钟`;全量浏览器兜底占比 `<= 30%` | 性能基准测试、指标对账测试 |
|
||||||
| `S5-03` | UAT 与试运行任务集执行 | 产品、QA、前端、后端 | `S5-01`、`S5-02` | UAT 用例、试运行记录、问题清单 | 达到“报告可用于决策 >= 4/5”“报告采纳率 >= 70%” | 三条主链路 E2E、人工验收记录 |
|
| `S5-03` | `未开始` | UAT 与试运行任务集执行 | 产品、QA、前端、后端 | `S5-01`、`S5-02` | UAT 用例、试运行记录、问题清单 | 达到“报告可用于决策 >= 4/5”“报告采纳率 >= 70%” | 三条主链路 E2E、人工验收记录 |
|
||||||
| `S5-04` | 部署、值守、排障与热修手册落地 | 后端、自动化、QA | `S4-06` | 部署说明、回滚策略、值守流程、热修策略 | 形成内部受控环境发布包;明确按平台、按能力热修方式 | 预发布冒烟测试、回滚演练 |
|
| `S5-04` | `未开始` | 部署、值守、排障与热修手册落地 | 后端、自动化、QA | `S4-06` | 部署说明、回滚策略、值守流程、热修策略 | 形成内部受控环境发布包;明确按平台、按能力热修方式 | 预发布冒烟测试、回滚演练 |
|
||||||
| `S5-05` | 最终验收与文档同步收口 | 产品、设计、前端、后端、QA | `S5-03`、`S5-04` | P0 验收清单、偏差记录、必要文档回写 | 所有 P0 验收项通过;实现偏差已同步回上游文档 | P0 总体验收清单、文档一致性检查 |
|
| `S5-05` | `未开始` | 最终验收与文档同步收口 | 产品、设计、前端、后端、QA | `S5-03`、`S5-04` | P0 验收清单、偏差记录、必要文档回写 | 所有 P0 验收项通过;实现偏差已同步回上游文档 | P0 总体验收清单、文档一致性检查 |
|
||||||
|
|
||||||
## 7. 横向持续任务
|
## 7. 横向持续任务
|
||||||
|
|
||||||
以下任务不属于单一阶段,应从 `S0` 持续到 `S5`:
|
以下任务不属于单一阶段,应从 `S0` 持续到 `S5`:
|
||||||
|
|
||||||
| 编号 | 任务 | 责任角色 | 执行时机 | 产出与要求 |
|
| 编号 | 当前状态 | 任务 | 责任角色 | 执行时机 | 产出与要求 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `X-01` | 上下游文档变更同步 | 产品、设计、前端、后端 | 任何上游文档修改后 | 检查 `PRD`、`FeatureSummary`、`DevelopmentPlan`、`UIDesign`、`tdd`、`tasks` 是否失效,必要时同步修订 |
|
| `X-01` | `进行中` | 上下游文档变更同步 | 产品、设计、前端、后端 | 任何上游文档修改后 | 检查 `PRD`、`FeatureSummary`、`DevelopmentPlan`、`UIDesign`、`tdd`、`tasks` 是否失效,必要时同步修订 |
|
||||||
| `X-02` | 安全与合规检查 | 后端、自动化、QA | 每阶段出口前 | 确认不保存账号密码、会话加密、日志脱敏、仅抓取有权访问的数据 |
|
| `X-02` | `未开始` | 安全与合规检查 | 后端、自动化、QA | 每阶段出口前 | 确认不保存账号密码、会话加密、日志脱敏、仅抓取有权访问的数据 |
|
||||||
| `X-03` | 测试资产维护 | QA、平台、自动化 | 每新增平台能力或异常样本时 | 补齐 fixture、HAR、UI 状态快照、报告快照样本 |
|
| `X-03` | `进行中` | 测试资产维护 | QA、平台、自动化 | 每新增平台能力或异常样本时 | 补齐 fixture、HAR、UI 状态快照、报告快照样本 |
|
||||||
| `X-04` | 设计一致性与可访问性检查 | 设计、前端、QA | 每个页面进入联调前 | 对照 `UIDesign.md` 检查状态语义、组件一致性、`WCAG AA`、`aria-live` |
|
| `X-04` | `进行中` | 设计一致性与可访问性检查 | 设计、前端、QA | 每个页面进入联调前 | 对照 `UIDesign.md` 检查状态语义、组件一致性、`WCAG AA`、`aria-live` |
|
||||||
| `X-05` | 观测指标复盘 | 产品、后端、平台、QA | 每阶段结束时 | 复盘 `strategy_attempts`、平台成功率、浏览器兜底占比、报告质量与重试效果 |
|
| `X-05` | `未开始` | 观测指标复盘 | 产品、后端、平台、QA | 每阶段结束时 | 复盘 `strategy_attempts`、平台成功率、浏览器兜底占比、报告质量与重试效果 |
|
||||||
|
|
||||||
## 8. P0 验收映射
|
## 8. P0 验收映射
|
||||||
|
|
||||||
@ -217,4 +234,4 @@
|
|||||||
|
|
||||||
## 10. 一句话结论
|
## 10. 一句话结论
|
||||||
|
|
||||||
本轮开发任务应严格围绕 P0 闭环展开:先冻结双平台能力矩阵和测试资产,再搭骨架与单平台闭环,再扩双平台与阻塞恢复,最后补齐结构化报告、历史版本、留存删除与试运行;任何偏离这条主线的需求,都不应进入当前开发排期。
|
本轮开发任务应严格围绕 P0 闭环展开:先冻结双平台能力矩阵和测试资产,再搭骨架与单平台闭环,再扩双平台与阻塞恢复,最后补齐结构化报告、历史版本、留存删除与试运行;任何偏离这条主线的需求,都不应进入当前开发排期。当前仓库已完成的关键节点为 `S0-05`、`S1-01`、`S1-05`、`S2-02`、`S3-05`、`S4-04`,其余任务按上表继续推进。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user