feat: 补齐会话中心 v1 与任务创建准备流
This commit is contained in:
+181
-24
@@ -4,6 +4,8 @@ import {
|
||||
type CandidateRecord,
|
||||
type PlatformId,
|
||||
type PlatformStatus,
|
||||
type SessionReadinessRecord,
|
||||
type SessionStateRecord,
|
||||
type TaskRecord,
|
||||
type TaskStatus
|
||||
} from "@cross-ai/domain";
|
||||
@@ -22,11 +24,13 @@ import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./componen
|
||||
import { TaskContextHeader } from "./components/TaskContextHeader";
|
||||
import { TaskSpine } from "./components/TaskSpine";
|
||||
import {
|
||||
clearPlatformSession,
|
||||
confirmTask,
|
||||
createTask,
|
||||
deleteTask,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getPlatformSession,
|
||||
getTask,
|
||||
getTaskCandidates,
|
||||
getTaskReport,
|
||||
@@ -118,12 +122,65 @@ function getNoReportSummary(task: TaskRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
function NewTaskPage() {
|
||||
function formatTimestamp(timestamp?: string) {
|
||||
if (!timestamp) {
|
||||
return "暂无";
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function getSearchRequirementLabel(
|
||||
searchRequirement: SessionReadinessRecord["searchRequirement"]
|
||||
) {
|
||||
switch (searchRequirement) {
|
||||
case "required":
|
||||
return "需准备会话";
|
||||
case "recommended":
|
||||
return "建议预热";
|
||||
default:
|
||||
return "无需会话";
|
||||
}
|
||||
}
|
||||
|
||||
function getReadinessSummary(readiness: SessionReadinessRecord) {
|
||||
if (readiness.status === "ready") {
|
||||
return readiness.expiresAt
|
||||
? `当前工作区已有可复用会话,有效至 ${formatTimestamp(readiness.expiresAt)}。`
|
||||
: readiness.reason;
|
||||
}
|
||||
|
||||
if (readiness.status === "expired") {
|
||||
return `最近一次会话已过期,上次准备时间为 ${formatTimestamp(readiness.lastPreparedAt)}。`;
|
||||
}
|
||||
|
||||
return readiness.reason;
|
||||
}
|
||||
|
||||
function getSessionSnapshotSummary(session: SessionStateRecord) {
|
||||
if (session.status === "ready") {
|
||||
return session.expiresAt
|
||||
? `已保存加密快照,可复用至 ${formatTimestamp(session.expiresAt)}。`
|
||||
: "已保存加密快照,可用于当前工作区复用。";
|
||||
}
|
||||
|
||||
if (session.status === "expired") {
|
||||
return "最近一次会话已过期,需重新完成会话准备。";
|
||||
}
|
||||
|
||||
return "当前还没有可复用会话快照。";
|
||||
}
|
||||
|
||||
export function NewTaskPage() {
|
||||
const navigate = useNavigate();
|
||||
const readinessQuery = useQuery({
|
||||
queryKey: ["platform-readiness"],
|
||||
queryFn: getPlatformReadiness
|
||||
});
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["history"],
|
||||
queryFn: getHistoryTasks
|
||||
});
|
||||
const [query, setQuery] = useState("");
|
||||
const [perLinkLimit, setPerLinkLimit] = useState(100);
|
||||
const [taskTotalLimit, setTaskTotalLimit] = useState(500);
|
||||
@@ -134,6 +191,7 @@ function NewTaskPage() {
|
||||
navigate(`/tasks/${task.taskId}/confirm`);
|
||||
}
|
||||
});
|
||||
const recentTasks = (historyQuery.data?.tasks ?? []).slice(0, 4);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -209,26 +267,72 @@ function NewTaskPage() {
|
||||
status={platform.ready ? "Completed" : "SearchBlocked"}
|
||||
/>
|
||||
</div>
|
||||
<p>{platformCatalogMap[platform.platform].description}</p>
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
|
||||
>
|
||||
进入会话准备
|
||||
</a>
|
||||
<p className="readiness-card__caption">
|
||||
搜索要求:{getSearchRequirementLabel(platform.searchRequirement)}
|
||||
</p>
|
||||
<p>{getReadinessSummary(platform)}</p>
|
||||
<div className="readiness-card__meta">
|
||||
<span className="inline-note inline-note--subtle">
|
||||
最近准备:{formatTimestamp(platform.lastPreparedAt)}
|
||||
</span>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
{platform.expiresAt
|
||||
? `有效至 ${formatTimestamp(platform.expiresAt)}`
|
||||
: "当前无有效期中的会话"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
|
||||
>
|
||||
进入会话准备
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-panel">
|
||||
<p className="eyebrow">Scope Reminder</p>
|
||||
<h3>P0 当前不做什么</h3>
|
||||
<ul className="list">
|
||||
<li>不做自动绕过风控。</li>
|
||||
<li>不做无人工确认的同款判断。</li>
|
||||
<li>当前工作台只覆盖天猫、京东。</li>
|
||||
</ul>
|
||||
<div className="stack">
|
||||
<div className="page-panel">
|
||||
<p className="eyebrow">Recent Tasks</p>
|
||||
<h3>最近任务捷径</h3>
|
||||
<div className="stack stack--dense">
|
||||
{recentTasks.length > 0 ? (
|
||||
recentTasks.map((task) => (
|
||||
<a
|
||||
key={task.taskId}
|
||||
className="mini-task-link"
|
||||
href={getTaskDestination(
|
||||
task.taskId,
|
||||
task.taskStatus,
|
||||
task.hasReport,
|
||||
task.defaultReportVersion
|
||||
)}
|
||||
>
|
||||
<div className="mini-task-link__topline">
|
||||
<strong>{task.query}</strong>
|
||||
<TaskStatusPill status={task.taskStatus} />
|
||||
</div>
|
||||
<p>{new Date(task.updatedAt).toLocaleString("zh-CN")}</p>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">还没有历史任务,先创建第一条分析任务。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-panel">
|
||||
<p className="eyebrow">Scope Reminder</p>
|
||||
<h3>P0 当前不做什么</h3>
|
||||
<ul className="list">
|
||||
<li>不做自动绕过风控。</li>
|
||||
<li>不做无人工确认的同款判断。</li>
|
||||
<li>当前工作台只覆盖天猫、京东。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
@@ -983,34 +1087,87 @@ export function HistoryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionPreparePage() {
|
||||
export function SessionPreparePage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { platform = "tmall" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from") ?? "/tasks/new";
|
||||
const platformId = platform as PlatformId;
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: ["session", platformId],
|
||||
queryFn: () => getPlatformSession(platformId)
|
||||
});
|
||||
const prepareMutation = useMutation({
|
||||
mutationFn: () => preparePlatform(platform as PlatformId),
|
||||
mutationFn: () => preparePlatform(platformId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["platform-readiness"] });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["session", platformId] })
|
||||
]);
|
||||
navigate(from);
|
||||
}
|
||||
});
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => clearPlatformSession(platformId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["session", platformId] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
const session = sessionQuery.data?.session;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel session-panel">
|
||||
<p className="eyebrow">Session Console</p>
|
||||
<h2>{platformCatalogMap[platform as PlatformId].label} 会话准备</h2>
|
||||
<p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p>
|
||||
<h2>{platformCatalogMap[platformId].label} 会话准备</h2>
|
||||
<p>{platformCatalogMap[platformId].recoveryHint}</p>
|
||||
<div className="session-placeholder">
|
||||
<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()} type="button">
|
||||
标记预热完成
|
||||
</button>
|
||||
<div className="session-details">
|
||||
<div className="session-details__row">
|
||||
<span>当前状态</span>
|
||||
<PlatformStatusPill status={session?.ready ? "Completed" : "SearchBlocked"} />
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>搜索要求</span>
|
||||
<strong>{getSearchRequirementLabel(platformCatalogMap[platformId].searchRequirement)}</strong>
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>会话快照</span>
|
||||
<strong>{session?.encryptedSnapshotAvailable ? "已加密保存" : "尚未生成"}</strong>
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>有效期</span>
|
||||
<strong>{session?.expiresAt ? formatTimestamp(session.expiresAt) : "暂无"}</strong>
|
||||
</div>
|
||||
<p>{session ? getSessionSnapshotSummary(session) : "正在读取当前会话状态..."}</p>
|
||||
<p className="session-return-target">完成后将返回:{from}</p>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={prepareMutation.isPending}
|
||||
onClick={() => prepareMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
标记预热完成
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
disabled={clearMutation.isPending || !session?.encryptedSnapshotAvailable}
|
||||
onClick={() => clearMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
清理当前会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,11 +6,13 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
clearPlatformSession: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
clearPlatformSession: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
preparePlatform: vi.fn(),
|
||||
retryTaskPlatform: vi.fn()
|
||||
}));
|
||||
|
||||
import { NewTaskPage, SessionPreparePage } from "./App";
|
||||
import {
|
||||
clearPlatformSession,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getPlatformSession,
|
||||
preparePlatform
|
||||
} from "./lib/api";
|
||||
|
||||
function renderWithProviders(node: ReactNode, initialEntries?: string[]) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{initialEntries ? (
|
||||
<MemoryRouter initialEntries={initialEntries}>{node}</MemoryRouter>
|
||||
) : (
|
||||
<MemoryRouter>{node}</MemoryRouter>
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("task composer and session console", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getPlatformReadiness).mockResolvedValue({
|
||||
platforms: [
|
||||
{
|
||||
platform: "tmall",
|
||||
ready: true,
|
||||
status: "ready",
|
||||
searchRequirement: "recommended",
|
||||
reason: "当前工作区存在可复用会话,创建任务时会再次校验。",
|
||||
lastPreparedAt: "2026-04-02T12:00:00.000Z",
|
||||
expiresAt: "2026-04-03T12:00:00.000Z"
|
||||
},
|
||||
{
|
||||
platform: "jd",
|
||||
ready: false,
|
||||
status: "missing",
|
||||
searchRequirement: "required",
|
||||
reason: "需要先完成会话准备,否则系统会标记为 SearchBlocked。"
|
||||
}
|
||||
]
|
||||
} as any);
|
||||
vi.mocked(getHistoryTasks).mockResolvedValue({
|
||||
tasks: [
|
||||
{
|
||||
taskId: "task-1",
|
||||
query: "Nintendo Switch 2",
|
||||
taskStatus: "Completed",
|
||||
updatedAt: "2026-04-02T12:00:00.000Z",
|
||||
hasReport: true,
|
||||
defaultReportVersion: 2,
|
||||
failedPlatforms: [],
|
||||
blockedPlatforms: []
|
||||
},
|
||||
{
|
||||
taskId: "task-2",
|
||||
query: "DJI Pocket 3",
|
||||
taskStatus: "AwaitingConfirmation",
|
||||
updatedAt: "2026-04-02T11:30:00.000Z",
|
||||
hasReport: false,
|
||||
failedPlatforms: [],
|
||||
blockedPlatforms: ["jd"]
|
||||
}
|
||||
]
|
||||
} as any);
|
||||
vi.mocked(getPlatformSession).mockResolvedValue({
|
||||
session: {
|
||||
platform: "jd",
|
||||
ready: true,
|
||||
status: "ready",
|
||||
searchRequirement: "required",
|
||||
scope: "workspace",
|
||||
ttlHours: 24,
|
||||
lastPreparedAt: "2026-04-02T10:00:00.000Z",
|
||||
expiresAt: "2026-04-03T10:00:00.000Z",
|
||||
encryptedSnapshotAvailable: true,
|
||||
cipherLabel: "mock-aes-gcm-v1"
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(clearPlatformSession).mockResolvedValue(undefined);
|
||||
vi.mocked(preparePlatform).mockResolvedValue({
|
||||
platform: "jd",
|
||||
session_ready: true,
|
||||
status: "ready",
|
||||
last_prepared_at: "2026-04-02T10:00:00.000Z",
|
||||
expires_at: "2026-04-03T10:00:00.000Z",
|
||||
encrypted_snapshot_available: true
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows readiness details and recent task shortcuts on the new task page", async () => {
|
||||
renderWithProviders(<NewTaskPage />);
|
||||
|
||||
expect(await screen.findByText("最近任务捷径")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Nintendo Switch 2")).toBeInTheDocument();
|
||||
expect(await screen.findByText("DJI Pocket 3")).toBeInTheDocument();
|
||||
expect(await screen.findByText("搜索要求:建议预热")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/当前工作区已有可复用会话,有效至/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows session details and allows clearing the current session", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
|
||||
</Routes>,
|
||||
["/sessions/jd/prepare?from=/tasks/new"]
|
||||
);
|
||||
|
||||
expect(await screen.findByText("已加密保存")).toBeInTheDocument();
|
||||
expect(screen.getByText(/完成后将返回:\/tasks\/new/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "清理当前会话" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearPlatformSession).toHaveBeenCalledWith("jd");
|
||||
});
|
||||
});
|
||||
});
|
||||
+66
-2
@@ -220,7 +220,8 @@ a {
|
||||
.candidate-card,
|
||||
.metric-card,
|
||||
.insight-card,
|
||||
.evidence-card {
|
||||
.evidence-card,
|
||||
.mini-task-link {
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
border-radius: 18px;
|
||||
background: var(--bg-elevated);
|
||||
@@ -231,7 +232,8 @@ a {
|
||||
.history-card,
|
||||
.metric-card,
|
||||
.insight-card,
|
||||
.evidence-card {
|
||||
.evidence-card,
|
||||
.mini-task-link {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -256,6 +258,37 @@ a {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.readiness-card__caption {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.readiness-card__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mini-task-link {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.mini-task-link__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mini-task-link p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-context-header {
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
@@ -424,6 +457,12 @@ a {
|
||||
background: rgba(20, 108, 110, 0.08);
|
||||
}
|
||||
|
||||
.inline-note--subtle {
|
||||
padding: 8px 10px;
|
||||
background: rgba(31, 42, 48, 0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -568,6 +607,31 @@ a {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-details {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(31, 42, 48, 0.04);
|
||||
}
|
||||
|
||||
.session-details p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-details__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-return-target {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
|
||||
Reference in New Issue
Block a user