diff --git a/package-lock.json b/package-lock.json index cc82c3c..006866b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", + "axe-core": "^4.10.3", "jsdom": "26.1.0", "typescript": "5.9.2", "vite": "7.3.6", @@ -1918,6 +1919,16 @@ "node": ">=12" } }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", diff --git a/package.json b/package.json index 03e9dcf..3ea5e6f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", + "axe-core": "4.10.3", "jsdom": "26.1.0", "typescript": "5.9.2", "vite": "7.3.6", diff --git a/src/App.test.tsx b/src/App.test.tsx index 74528a3..c1c8c40 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -28,4 +28,34 @@ describe("Teamserver 工作区", () => { expect(screen.getByRole("heading", { name: "连接中心" })).toBeInTheDocument(); expect(screen.queryByLabelText("Session 表格")).not.toBeInTheDocument(); }); + + it.each(["Tasks", "Events", "Artifacts", "Audit"])("导航到 %s 并创建锁定 Operation Tab", (page) => { + render(); + fireEvent.click(screen.getByRole("button", { name: page })); + expect(screen.getAllByRole("tab", { name: new RegExp(page) }).find((tab) => tab.getAttribute("aria-selected") === "true")).toBeDefined(); + expect(screen.getByLabelText("锁定的操作上下文")).toHaveTextContent(`Northwind / Project / ${page}`); + expect(screen.getByText("演示数据 · 内存 adapter · 未连接真实 Teamserver")).toBeInTheDocument(); + }); + + it("支持键盘切换、固定与关闭标签", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Tasks" })); + const tasksTab = screen.getByRole("tab", { name: "Tasks" }); + fireEvent.keyDown(tasksTab.closest('[role="tablist"]')!, { key: "ArrowLeft" }); + expect(tasksTab).toHaveAttribute("aria-selected", "false"); + fireEvent.click(tasksTab); + fireEvent.click(screen.getByRole("button", { name: "固定 Tasks" })); + expect(screen.queryByRole("button", { name: "关闭 Tasks" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "取消固定 Tasks" })); + fireEvent.click(screen.getByRole("button", { name: "关闭 Tasks" })); + expect(screen.queryByRole("tab", { name: "Tasks" })).not.toBeInTheDocument(); + }); + + it("取消危险任务前显示固定目标和 mock 边界", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Tasks" })); + fireEvent.click(screen.getByRole("button", { name: "取消任务" })); + expect(screen.getByRole("alertdialog", { name: "取消任务确认" })).toHaveTextContent("Atlas / Northwind / NW-042"); + expect(screen.getByRole("alertdialog")).toHaveTextContent("不会发送真实请求"); + }); }); diff --git a/src/App.tsx b/src/App.tsx index 6b5ba6d..1418402 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,12 @@ -import { useReducer, useState } from "react"; +import { useReducer, useState, type ReactNode } from "react"; import { ConnectionCenter, MemoryMockConnectionAdapter } from "./features/connections"; -import { SessionQueryIntent, SessionTable } from "./SessionTable"; +import { ArtifactsPage, AuditPage, EventsPage, OverviewPage, SessionDetailPage, TasksPage } from "./features/workbench"; +import { createSessionTableViewState, SessionQueryIntent, SessionTable } from "./SessionTable"; import { demoSessions } from "./protocol/fixtures"; import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState"; import { toSessionRow } from "./sessionPresentation"; import { WorkspaceLayout } from "./shell"; -import { Button, Icon } from "./ui"; -import { - createInitialWorkspaceState, - selectActiveTab, - selectActiveWorkspace, - workspaceReducer, -} from "./workspace"; +import { createInitialWorkspaceState, selectActiveTab, selectActiveWorkspace, workspaceReducer } from "./workspace"; const fixtureNow = new Date("2026-08-03T19:12:02Z"); const sessions = demoSessions.map((session) => toSessionRow(session, fixtureNow)); @@ -21,26 +16,14 @@ export function App() { const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState); const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder)); const [lastQueryIntent, setLastQueryIntent] = useState>({}); - const [view, setView] = useState<"sessions" | "connections">("sessions"); - const server = selectActiveWorkspace(workspace); - const activeTab = selectActiveTab(server); - const tableState = { ...tableStates[server.id], filter: server.filter }; - - return ( - workspace.servers[id])} server={server} activeTab={activeTab} activePage={view === "connections" ? "连接中心" : activeTab.title} onNavigate={(page) => setView(page === "连接中心" ? "connections" : "sessions")} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })}> -
- {view === "connections" ? : <> -
-

{activeTab.title}

{activeTab.context.project} 中的 248 个 Session · 4 个实时更新

-
-
- { - if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter }); - setTableStates((current) => updateSessionTableState(current, server.id, state)); - }} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} /> - {lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"} - } -
-
- ); + const server = selectActiveWorkspace(workspace); const activeTab = selectActiveTab(server); + const tableState = { ...(tableStates[server.id] ?? createSessionTableViewState()), filter: server.filter }; + const navigate = (page: string) => { + const title = page === "Sessions" ? "Sessions" : page; + dispatch({ type: "navigate", serverId: server.id, project: server.activeProject, navigation: page }); + dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.activeProject.toLowerCase()}-${page.toLowerCase().replaceAll(" ", "-")}`, title, context: { serverId: server.id, project: server.activeProject, session: "Project" } } }); + }; + const sessionsPage = <>

Sessions

{activeTab.context.project} 中的 248 个 Session · 4 个实时更新

演示数据 · 内存 adapter · 未连接真实 Teamserver

{ if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter }); setTableStates((current) => updateSessionTableState(current, server.id, state)); }} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}; + const pages: Record = { Overview: , Sessions: sessionsPage, "Session Overview": , Tasks: , "Task Output": , Events: , Artifacts: , Audit: , "连接中心": }; + return workspace.servers[id])} server={server} activeTab={activeTab} activePage={server.navigation} onNavigate={navigate} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })} onCloseTab={(tabId) => dispatch({ type: "closeTab", serverId: server.id, tabId })} onTogglePin={(tabId) => dispatch({ type: "togglePinTab", serverId: server.id, tabId })}>
{pages[activeTab.title] ?? }
; } diff --git a/src/SessionTable.tsx b/src/SessionTable.tsx index 3944ae6..17e0512 100644 --- a/src/SessionTable.tsx +++ b/src/SessionTable.tsx @@ -116,9 +116,9 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o }} />{column.label})}} -
`var(--column-${id})`).join(" ") } as CSSProperties}> +
`var(--column-${id})`).join(" ") } as CSSProperties}>
- { + { const next = new Set(selected); rows.forEach(({ id }) => event.currentTarget.checked ? next.add(id) : next.delete(id)); onStateChange({ ...state, selectedSessionIds: [...next] }); @@ -127,20 +127,20 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o const sortable = column.sortable && capabilities.sortableColumns.includes(column.id); return {sortable ? : column.label}; })} - +
- {rows.map((session, index) =>
{ if (element) rowRefs.current.set(session.id, element); else rowRefs.current.delete(session.id); }} onFocus={() => state.focusedSessionId !== session.id && onStateChange({ ...state, focusedSessionId: session.id })} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}> - updateSelection(session.id, event.currentTarget.checked)} /> - {visible.has("status") &&