feat: 补齐会话中心 v1 与任务创建准备流

This commit is contained in:
2026-04-02 18:48:21 +08:00
parent 29cea8b0aa
commit 145f958663
9 changed files with 1462 additions and 59 deletions
+181 -24
View File
@@ -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>
+2
View File
@@ -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(),
+158
View File
@@ -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
View File
@@ -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;