GAZ-12: 整合全部功能并完成全页面精细化验收 #11

Merged
dev merged 1 commits from agent/docker/86e8574c into main 2026-08-03 19:43:38 +00:00
13 changed files with 174 additions and 62 deletions

11
package-lock.json generated
View File

@ -19,6 +19,7 @@
"@types/react": "19.1.10", "@types/react": "19.1.10",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2", "@vitejs/plugin-react": "5.0.2",
"axe-core": "^4.10.3",
"jsdom": "26.1.0", "jsdom": "26.1.0",
"typescript": "5.9.2", "typescript": "5.9.2",
"vite": "7.3.6", "vite": "7.3.6",
@ -1918,6 +1919,16 @@
"node": ">=12" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.11.12", "version": "2.11.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",

View File

@ -22,6 +22,7 @@
"@types/react": "19.1.10", "@types/react": "19.1.10",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2", "@vitejs/plugin-react": "5.0.2",
"axe-core": "4.10.3",
"jsdom": "26.1.0", "jsdom": "26.1.0",
"typescript": "5.9.2", "typescript": "5.9.2",
"vite": "7.3.6", "vite": "7.3.6",

View File

@ -28,4 +28,34 @@ describe("Teamserver 工作区", () => {
expect(screen.getByRole("heading", { name: "连接中心" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "连接中心" })).toBeInTheDocument();
expect(screen.queryByLabelText("Session 表格")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Session 表格")).not.toBeInTheDocument();
}); });
it.each(["Tasks", "Events", "Artifacts", "Audit"])("导航到 %s 并创建锁定 Operation Tab", (page) => {
render(<App />);
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(<App />);
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(<App />);
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("不会发送真实请求");
});
}); });

View File

@ -1,17 +1,12 @@
import { useReducer, useState } from "react"; import { useReducer, useState, type ReactNode } from "react";
import { ConnectionCenter, MemoryMockConnectionAdapter } from "./features/connections"; 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 { demoSessions } from "./protocol/fixtures";
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState"; import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
import { toSessionRow } from "./sessionPresentation"; import { toSessionRow } from "./sessionPresentation";
import { WorkspaceLayout } from "./shell"; 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 fixtureNow = new Date("2026-08-03T19:12:02Z");
const sessions = demoSessions.map((session) => toSessionRow(session, fixtureNow)); const sessions = demoSessions.map((session) => toSessionRow(session, fixtureNow));
@ -21,26 +16,14 @@ export function App() {
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState); const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder)); const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder));
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({}); const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});
const [view, setView] = useState<"sessions" | "connections">("sessions"); const server = selectActiveWorkspace(workspace); const activeTab = selectActiveTab(server);
const server = selectActiveWorkspace(workspace); const tableState = { ...(tableStates[server.id] ?? createSessionTableViewState()), filter: server.filter };
const activeTab = selectActiveTab(server); const navigate = (page: string) => {
const tableState = { ...tableStates[server.id], filter: server.filter }; const title = page === "Sessions" ? "Sessions" : page;
dispatch({ type: "navigate", serverId: server.id, project: server.activeProject, navigation: page });
return ( dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.activeProject.toLowerCase()}-${page.toLowerCase().replaceAll(" ", "-")}`, title, context: { serverId: server.id, project: server.activeProject, session: "Project" } } });
<WorkspaceLayout servers={workspace.serverOrder.map((id) => 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 })}> };
<article className="content"> const sessionsPage = <><div className="content-heading"><div><h2>Sessions</h2><p>{activeTab.context.project} 248 Session · 4 </p></div></div><p className="demo-boundary" role="status"> · adapter · Teamserver</p><SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => { 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 }))} /><output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output></>;
{view === "connections" ? <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> : <> const pages: Record<string, ReactNode> = { Overview: <OverviewPage />, Sessions: sessionsPage, "Session Overview": <SessionDetailPage />, Tasks: <TasksPage />, "Task Output": <TasksPage />, Events: <EventsPage />, Artifacts: <ArtifactsPage />, Audit: <AuditPage />, "连接中心": <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> };
<div className="content-heading"> return <WorkspaceLayout servers={workspace.serverOrder.map((id) => 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 })}><article className="content" aria-label={`${activeTab.title} 页面`}>{pages[activeTab.title] ?? <OverviewPage />}</article></WorkspaceLayout>;
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 248 Session · 4 </p></div>
<div className="heading-actions"><Button></Button><Button intent="primary"><Icon name="add" /></Button></div>
</div>
<SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => {
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 }))} />
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
</>}
</article>
</WorkspaceLayout>
);
} }

View File

@ -116,9 +116,9 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
}} />{column.label}</label>)}</fieldset></details>} }} />{column.label}</label>)}</fieldset></details>}
<button aria-label="更多操作"></button> <button aria-label="更多操作"></button>
</div> </div>
<section className="session-table" aria-label="Session 表格" style={{ "--session-columns": state.visibleColumns.map((id) => `var(--column-${id})`).join(" ") } as CSSProperties}> <section className="session-table"><div role="table" aria-label="Session 表格" style={{ "--session-columns": state.visibleColumns.map((id) => `var(--column-${id})`).join(" ") } as CSSProperties}><div role="rowgroup">
<div className="table-row table-head" role="row"> <div className="table-row table-head" role="row">
<span><input type="checkbox" aria-label="选择当前页全部 Session" checked={allSelected} onChange={(event) => { <span role="columnheader"><input type="checkbox" aria-label="选择当前页全部 Session" checked={allSelected} onChange={(event) => {
const next = new Set(selected); const next = new Set(selected);
rows.forEach(({ id }) => event.currentTarget.checked ? next.add(id) : next.delete(id)); rows.forEach(({ id }) => event.currentTarget.checked ? next.add(id) : next.delete(id));
onStateChange({ ...state, selectedSessionIds: [...next] }); 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); const sortable = column.sortable && capabilities.sortableColumns.includes(column.id);
return <span role="columnheader" aria-sort={sortable ? state.sort?.column === column.id ? state.sort.direction : "none" : undefined} key={column.id}>{sortable ? <button className="sort-button" onClick={() => sort(column.id)}>{column.label}<span aria-hidden="true">{state.sort?.column === column.id ? state.sort.direction === "ascending" ? " ↑" : " ↓" : " ↕"}</span></button> : column.label}</span>; return <span role="columnheader" aria-sort={sortable ? state.sort?.column === column.id ? state.sort.direction : "none" : undefined} key={column.id}>{sortable ? <button className="sort-button" onClick={() => sort(column.id)}>{column.label}<span aria-hidden="true">{state.sort?.column === column.id ? state.sort.direction === "ascending" ? " ↑" : " ↓" : " ↕"}</span></button> : column.label}</span>;
})} })}
<span /> <span role="columnheader" />
</div> </div>
{rows.map((session, index) => <div className={`table-row ${session.id === state.focusedSessionId ? "focused" : ""}`} role="row" aria-selected={selected.has(session.id)} key={session.id} tabIndex={session.id === state.focusedSessionId || (!state.focusedSessionId && index === 0) ? 0 : -1} ref={(element) => { 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)}> </div><div role="rowgroup">{rows.map((session, index) => <div className={`table-row ${session.id === state.focusedSessionId ? "focused" : ""}`} role="row" aria-selected={selected.has(session.id)} key={session.id} tabIndex={session.id === state.focusedSessionId || (!state.focusedSessionId && index === 0) ? 0 : -1} ref={(element) => { 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)}>
<span><input type="checkbox" aria-label={`选择 ${session.id}`} checked={selected.has(session.id)} onChange={(event) => updateSelection(session.id, event.currentTarget.checked)} /></span> <span role="cell"><input type="checkbox" aria-label={`选择 ${session.id}`} checked={selected.has(session.id)} onChange={(event) => updateSelection(session.id, event.currentTarget.checked)} /></span>
{visible.has("status") && <span><em className={`status-mark ${session.status.toLowerCase()}`} aria-hidden="true" /><span className="sr-only"></span>{session.status}</span>} {visible.has("status") && <span role="cell"><em className={`status-mark ${session.status.toLowerCase()}`} aria-hidden="true" /><span className="sr-only"></span>{session.status}</span>}
{visible.has("id") && <strong className="mono">{session.id}</strong>} {visible.has("id") && <strong role="cell" className="mono">{session.id}</strong>}
{visible.has("identity") && <span><b>{session.host}</b><small>{session.user}</small></span>} {visible.has("identity") && <span role="cell"><b>{session.host}</b><small>{session.user}</small></span>}
{visible.has("os") && <span>{session.os}</span>} {visible.has("os") && <span role="cell">{session.os}</span>}
{visible.has("address") && <span className="mono muted">{session.address}</span>} {visible.has("address") && <span role="cell" className="mono muted">{session.address}</span>}
{visible.has("seen") && <span className="mono muted">{session.seen}</span>} {visible.has("seen") && <span role="cell" className="mono muted">{session.seen}</span>}
{visible.has("tags") && <span className="tags">{session.tags.map((tag) => <small key={tag}>{tag}</small>)}</span>} {visible.has("tags") && <span role="cell" className="tags">{session.tags.map((tag) => <small key={tag}>{tag}</small>)}</span>}
<button className="row-action" aria-label={`${session.id} 快速操作`}></button> <span role="cell"><button className="row-action" aria-label={`${session.id} 快速操作`}></button></span>
</div>)} </div>)}
<footer><span aria-live="polite"> {state.selectedSessionIds.length} </span><span>{page.start}{page.end} / {page.total}</span><button aria-label="上一页" disabled={!capabilities.pagination || !page.hasPrevious} onClick={() => onQueryIntent({ type: "page", direction: "previous" })}></button><button aria-label="下一页" disabled={!capabilities.pagination || !page.hasNext} onClick={() => onQueryIntent({ type: "page", direction: "next" })}></button></footer> </div></div><footer><span aria-live="polite"> {state.selectedSessionIds.length} </span><span>{page.start}{page.end} / {page.total}</span><button aria-label="上一页" disabled={!capabilities.pagination || !page.hasPrevious} onClick={() => onQueryIntent({ type: "page", direction: "previous" })}></button><button aria-label="下一页" disabled={!capabilities.pagination || !page.hasNext} onClick={() => onQueryIntent({ type: "page", direction: "next" })}></button></footer>
</section> </section>
</> </>
); );

View File

@ -0,0 +1,15 @@
import { render } from "@testing-library/react";
import axe from "axe-core";
import { describe, expect, it } from "vitest";
import { App } from "./App";
describe("自动化可访问性扫描", () => {
it("应用壳层无可自动确认的 WCAG A/AA 违规", async () => {
const { container } = render(<App />);
const result = await axe.run(container, {
runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] },
rules: { "color-contrast": { enabled: false } },
});
expect(result.violations.map(({ id, nodes }) => ({ id, targets: nodes.map(({ target }) => target) }))).toEqual([]);
});
});

View File

@ -2,18 +2,18 @@
.activity-status__mark { width:8px; height:8px; border:2px solid currentColor; border-radius:50% } .activity-status__mark { width:8px; height:8px; border:2px solid currentColor; border-radius:50% }
.activity-status--running .activity-status__mark { border-radius:2px } .activity-status--running .activity-status__mark { border-radius:2px }
.activity-status--succeeded { color:#277a4b }.activity-status--failed,.activity-status--cancelled { color:#a23d3d } .activity-status--succeeded { color:#277a4b }.activity-status--failed,.activity-status--cancelled { color:#a23d3d }
.activity-list { display:grid; border:1px solid #d9dde5; border-radius:8px; overflow:hidden } .activity-list { display:grid; border:1px solid var(--border); border-radius:5px; overflow:hidden }
.activity-list__row { min-height:38px; display:grid; grid-template-columns:minmax(150px,1fr) 100px 90px; align-items:center; gap:12px; padding:0 12px; border:0; border-bottom:1px solid #e6e8ed; background:#fff; text-align:left; color:inherit } .activity-list__row { min-height:38px; display:grid; grid-template-columns:minmax(150px,1fr) 100px 90px; align-items:center; gap:12px; padding:0 12px; border:0; border-bottom:1px solid var(--border); background:#15191f; text-align:left; color:inherit }
.activity-list__row:hover,.activity-list__row[aria-pressed="true"] { background:#f1f4f9 } .activity-list__row:hover,.activity-list__row[aria-pressed="true"] { background:var(--selected) }
.activity-empty { padding:24px; color:#667085; text-align:center; border:1px dashed #cfd4dc; border-radius:8px } .activity-empty { padding:24px; color:#667085; text-align:center; border:1px dashed #cfd4dc; border-radius:8px }
.activity-detail { display:grid; gap:16px }.activity-detail>header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start }.activity-detail h2 { margin:0;font-size:16px }.activity-detail dl,.activity-dialog dl { display:grid; grid-template-columns:120px 1fr; margin:0; gap:8px 12px }.activity-detail dt,.activity-dialog dt { color:#667085 }.activity-detail dd,.activity-dialog dd { margin:0; overflow-wrap:anywhere } .activity-detail { display:grid; gap:16px }.activity-detail>header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start }.activity-detail h2 { margin:0;font-size:16px }.activity-detail dl,.activity-dialog dl { display:grid; grid-template-columns:120px 1fr; margin:0; gap:8px 12px }.activity-detail dt,.activity-dialog dt { color:#667085 }.activity-detail dd,.activity-dialog dd { margin:0; overflow-wrap:anywhere }
.activity-timeline { display:flex; padding:0; list-style:none; gap:8px }.activity-timeline li { display:grid; border-left:3px solid #8793a8; padding-left:10px; min-width:130px }.activity-timeline time { color:#667085;font-size:11px } .activity-timeline { display:flex; padding:0; list-style:none; gap:8px }.activity-timeline li { display:grid; border-left:3px solid #8793a8; padding-left:10px; min-width:130px }.activity-timeline time { color:#667085;font-size:11px }
.activity-output { border:1px solid #cfd4dc; border-radius:8px; overflow:hidden }.activity-output header,.activity-events>header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#f5f6f8 }.activity-output header span,.activity-events header strong { margin-right:auto }.activity-output__body { box-sizing:border-box; min-height:120px; max-height:300px; overflow:auto; padding:10px; background:#17191d; color:#e7eaf0; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace }.activity-redacted { color:#ffce73 } .activity-output { border:1px solid #cfd4dc; border-radius:8px; overflow:hidden }.activity-output header,.activity-events>header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#0f1115 }.activity-output header span,.activity-events header strong { margin-right:auto }.activity-output__body { box-sizing:border-box; min-height:120px; max-height:300px; overflow:auto; padding:10px; background:#17191d; color:#e7eaf0; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace }.activity-redacted { color:#ffce73 }
.activity-result { max-height:220px; overflow:auto; margin:0; padding:12px; background:#f5f6f8; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace } .activity-result { max-height:220px; overflow:auto; margin:0; padding:12px; background:#0f1115; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace }
.activity-danger { color:#a12323; border-color:#cf7777 } .activity-danger { color:#a12323; border-color:#cf7777 }
.activity-dialog-backdrop { position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:#1118 }.activity-dialog { width:min(520px,calc(100vw - 32px));box-sizing:border-box;background:white;border-radius:10px;padding:20px;box-shadow:0 18px 60px #0004 }.activity-dialog footer { display:flex;justify-content:flex-end;gap:8px;margin-top:20px } .activity-dialog-backdrop { position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:#1118 }.activity-dialog { width:min(520px,calc(100vw - 32px));box-sizing:border-box;background:white;border-radius:10px;padding:20px;box-shadow:0 18px 60px #0004 }.activity-dialog footer { display:flex;justify-content:flex-end;gap:8px;margin-top:20px }
.activity-filters { display:flex;align-items:end;gap:12px;flex-wrap:wrap }.activity-filters label { display:grid;gap:4px;font-size:12px }.activity-filters select { min-height:58px } .activity-filters { display:flex;align-items:end;gap:12px;flex-wrap:wrap }.activity-filters label { display:grid;gap:4px;font-size:12px }.activity-filters select { min-height:58px }
.activity-events { min-height:200px;border:1px solid #d9dde5;border-radius:8px;overflow:hidden }.activity-events__feed { max-height:420px;overflow:auto }.activity-event { width:100%;display:grid;grid-template-columns:175px 1fr 210px;gap:12px;text-align:left;padding:9px 12px;background:#fff;border:0;border-bottom:1px solid #e6e8ed;color:inherit }.activity-event:hover { background:#f5f7fa }.activity-event span { color:#667085 }.activity-event--new { background:#edf5ff } .activity-events { min-height:200px;border:1px solid var(--border);border-radius:5px;overflow:hidden }.activity-events__feed{max-height:420px;overflow:auto}.activity-event{width:100%;display:grid;grid-template-columns:175px 1fr 210px;gap:12px;text-align:left;padding:9px 12px;background:#15191f;border:0;border-bottom:1px solid var(--border);color:inherit}.activity-event:hover,.activity-event--new{background:var(--selected)}.activity-event span{color:var(--muted)}
.activity-banner { display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border:1px solid #e2b75e;border-radius:7px;background:#fff8e5;color:#654b14 } .activity-banner { display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border:1px solid #e2b75e;border-radius:7px;background:#fff8e5;color:#654b14 }
button,input,select { font:inherit }button { cursor:pointer;border:1px solid #bbc2ce;border-radius:5px;padding:5px 9px;background:#fff }button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:3px solid #477ee8;outline-offset:2px }button:disabled { opacity:.5;cursor:not-allowed } button,input,select { font:inherit }button { cursor:pointer;border:1px solid #bbc2ce;border-radius:5px;padding:5px 9px;background:#fff }button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:3px solid #477ee8;outline-offset:2px }button:disabled { opacity:.5;cursor:not-allowed }
@media (max-width:700px) { .activity-list,.activity-events { overflow-x:auto }.activity-list__row,.activity-event { min-width:620px } } @media (max-width:700px) { .activity-list,.activity-events { overflow-x:auto }.activity-list__row,.activity-event { min-width:620px } }

View File

@ -0,0 +1,42 @@
import { useState } from "react";
import type { Event, Task } from "../../protocol/model";
import { demoSession } from "../../protocol/fixtures";
import { EventDetail, EventFilters, EventStream, TaskDetail, TaskList, type EventFiltersValue, type TaskOutputEntry } from "../activity";
import { ArtifactList, ArtifactMetadata, ArtifactPreview, AuditDetail, AuditTable, type ArtifactRecord, type AuditRecord } from "../records";
import { SessionOverview, type SessionOverviewData } from "../sessions";
export const DEMO_BOUNDARY = "演示数据 · 内存 adapter · 未连接真实 Teamserver";
const task = { task_id: "task-842", capability_id: "session.collect", state: "RUNNING", cancellable: true, created_at: "2026-08-03T19:10:00Z", updated_at: "2026-08-03T19:12:00Z", server_id: "atlas", project_id: "northwind", session_id: "NW-042" } as Task;
const outputs = [{ id: "out-1", timestamp: "2026-08-03T19:12:01Z", text: "已接收任务,等待 Teamserver 输出", redacted: false }] as unknown as TaskOutputEntry[];
const event = { event_id: "evt-1208", type: "TASK_UPDATED", timestamp: "2026-08-03T19:12:00Z", cursor: "cursor-1208", sequence: 1208, context: { server_id: "atlas", project_id: "northwind", session_id: "NW-042", task_id: "task-842" }, payload: { state: "RUNNING" } } as Event;
const artifact: ArtifactRecord = { id: "artifact-12", name: "collection-summary.pdf", size: 39, mimeType: "application/pdf", sha256: "d35f8c7a…8e2c", createdAt: "2026-08-03 19:12 UTC" };
const audit: AuditRecord = { id: "audit-32", actor: "operator@example.test", target: "Northwind / NW-042", operation: "SubmitTask", parameters: { capability: "session.collect", token: "demo-secret" }, result: "SUCCEEDED", requestId: "req-4be9", timestamp: "2026-08-03 19:10 UTC" };
function Heading({ title, detail }: { title: string; detail: string }) { return <><div className="content-heading"><div><h2>{title}</h2><p>{detail}</p></div></div><p className="demo-boundary" role="status">{DEMO_BOUNDARY}</p></>; }
export function OverviewPage() { return <><Heading title="Project Overview" detail="Northwind 项目健康与活动摘要" /><div className="overview-grid"><section><h3></h3><p>CONNECTED · 42 ms · Protocol 1.0</p></section><section><h3></h3><p>248 Sessions · 7 Tasks · 2 </p></section></div></>; }
export function SessionDetailPage() {
const data = { capabilities: [], tasks: [task], events: [event], artifacts: [], notes: "服务端演示备注", tags: demoSession.tags } as unknown as SessionOverviewData;
return <><Heading title="Session Overview" detail="标准协议 Session 详情" /><SessionOverview session={demoSession} data={data} /></>;
}
export function TasksPage() {
const [selected, setSelected] = useState<Task>(task); const [cancel, setCancel] = useState(false);
return <><Heading title="Tasks" detail="异步任务、输出和服务端状态" /><div className="split-view"><TaskList tasks={[task]} selectedTaskId={selected.task_id} onSelect={setSelected} /><TaskDetail task={selected} outputs={outputs} onCancel={() => setCancel(true)} /></div>{cancel && <div className="inline-alert" role="alertdialog" aria-label="取消任务确认"><strong> task-842</strong><p>Atlas / Northwind / NW-042</p><button onClick={() => setCancel(false)}></button><button className="danger-button" onClick={() => setCancel(false)}></button></div>}</>;
}
export function EventsPage() {
const [filters, setFilters] = useState<EventFiltersValue>({ types: [] }); const [selected, setSelected] = useState<Event>();
return <><Heading title="Events" detail="支持游标恢复的实时事件流" /><EventFilters value={filters} knownTypes={["TASK_UPDATED", "SESSION_UPDATED"]} onChange={setFilters} /><div className="split-view"><EventStream events={[event]} follow onSelect={setSelected} /><EventDetail event={selected} /></div></>;
}
export function ArtifactsPage() {
const [selected, setSelected] = useState<ArtifactRecord>(artifact);
return <><Heading title="Artifacts" detail="内容仅在内存预览,明确导出才写盘" /><div className="split-view"><ArtifactList artifacts={[artifact]} selectedId={selected.id} onSelect={setSelected} /><section className="record-detail"><ArtifactMetadata artifact={selected} /><ArtifactPreview artifact={selected} content={{ blob: new Blob(["Demo artifact content; no server data."]) }} /></section></div></>;
}
export function AuditPage() {
const [selected, setSelected] = useState<AuditRecord>();
return <><Heading title="Audit" detail="Teamserver 权威审计记录" /><div className="split-view"><AuditTable records={[audit]} nextCursor="next" onQueryIntent={() => undefined} onSelect={setSelected} />{selected ? <AuditDetail record={selected} /> : <div className="empty-state"></div>}</div></>;
}

View File

@ -1,7 +1,8 @@
import { Icon } from "../ui"; import { Icon } from "../ui";
import type { OperationTab } from "../workspace"; import type { OperationTab } from "../workspace";
export interface OperationTabsProps { serverName: string; tabs: readonly OperationTab[]; activeTabId: string; onActivate: (tabId: string) => void; onClose?: (tabId: string) => void; } export interface OperationTabsProps { serverName: string; tabs: readonly OperationTab[]; activeTabId: string; onActivate: (tabId: string) => void; onClose?: (tabId: string) => void; onTogglePin?: (tabId: string) => void; }
export function OperationTabs({ serverName, tabs, activeTabId, onActivate, onClose }: OperationTabsProps) { export function OperationTabs({ serverName, tabs, activeTabId, onActivate, onClose, onTogglePin }: OperationTabsProps) {
return <div className="tabbar" role="tablist" aria-label={`${serverName} 标签`}>{tabs.map((tab) => <button role="tab" aria-selected={tab.id === activeTabId} className={tab.id === activeTabId ? "active" : ""} key={tab.id} onClick={() => onActivate(tab.id)} title={`${tab.context.project} / ${tab.context.session}`}><span className="tab-label">{tab.running && <span className="running" aria-label="任务运行中" />}{tab.title}</span>{onClose && <span className="close" role="button" aria-label={`关闭 ${tab.title}`} tabIndex={0} onClick={(event) => { event.stopPropagation(); onClose(tab.id); }}><Icon name="close" size={12} /></span>}</button>)}</div>; const active = tabs.find(({ id }) => id === activeTabId) ?? tabs[0];
return <div className="tabbar-wrap"><div className="tabbar" role="tablist" aria-label={`${serverName} 标签`} onKeyDown={(event) => { const current = tabs.findIndex(({ id }) => id === activeTabId); if (event.key === "ArrowRight") onActivate(tabs[(current + 1) % tabs.length].id); if (event.key === "ArrowLeft") onActivate(tabs[(current - 1 + tabs.length) % tabs.length].id); }}>{tabs.map((tab) => <button role="tab" aria-selected={tab.id === activeTabId} tabIndex={tab.id === activeTabId ? 0 : -1} className={tab.id === activeTabId ? "active" : ""} key={tab.id} onClick={() => onActivate(tab.id)} title={`${tab.context.project} / ${tab.context.session}`}><span className="tab-label">{tab.running && <span className="running" aria-label="任务运行中" />}{tab.pinned && <span aria-label="已固定"> </span>}{tab.title}</span></button>)}</div><div className="tab-controls" aria-label="当前标签操作">{onTogglePin && <button aria-label={`${active.pinned ? "取消固定" : "固定"} ${active.title}`} onClick={() => onTogglePin(active.id)}></button>}{onClose && !active.pinned && <button aria-label={`关闭 ${active.title}`} onClick={() => onClose(active.id)}><Icon name="close" size={12} /></button>}</div></div>;
} }

View File

@ -6,5 +6,5 @@ import { ProjectSidebar } from "./ProjectSidebar";
import { StatusBar } from "./StatusBar"; import { StatusBar } from "./StatusBar";
import { TeamserverRail } from "./TeamserverRail"; import { TeamserverRail } from "./TeamserverRail";
export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; onNavigate?: (page: string) => void; } export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; onNavigate?: (page: string) => void; onCloseTab?: (id: string) => void; onTogglePin?: (id: string) => void; }
export function WorkspaceLayout({ servers, server, activeTab, activePage = activeTab.title, children, onSwitchServer, onActivateTab, onNavigate }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} /><ProjectSidebar server={server} activePage={activePage} onNavigate={onNavigate} /><section className="workspace"><OperationTabs serverName={server.name} tabs={server.tabs} activeTabId={activeTab.id} onActivate={onActivateTab} /><ContextBar serverName={server.name} tab={activeTab} />{children}<StatusBar connection={server.connection} /></section></main>; } export function WorkspaceLayout({ servers, server, activeTab, activePage = activeTab.title, children, onSwitchServer, onActivateTab, onNavigate, onCloseTab, onTogglePin }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} /><ProjectSidebar server={server} activePage={activePage} onNavigate={onNavigate} /><section className="workspace"><OperationTabs serverName={server.name} tabs={server.tabs} activeTabId={activeTab.id} onActivate={onActivateTab} onClose={onCloseTab} onTogglePin={onTogglePin} /><ContextBar serverName={server.name} tab={activeTab} />{children}<StatusBar connection={server.connection} /></section></main>; }

View File

@ -40,11 +40,8 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.nav-heading { margin: 18px 9px 5px; font-size: 10px; color: #737c89; font-weight: 650; text-transform: uppercase; letter-spacing: .08em; } .nav-heading { margin: 18px 9px 5px; font-size: 10px; color: #737c89; font-weight: 650; text-transform: uppercase; letter-spacing: .08em; }
.memory-note { margin-top: auto; padding: 9px; border-top: 1px solid var(--border); font-size: 10px; color: #727b88; } .memory-note { margin-top: auto; padding: 9px; border-top: 1px solid var(--border); font-size: 10px; color: #727b88; }
.workspace { display: grid; grid-template-rows: 38px 42px 1fr 24px; min-width: 0; height: 100vh; background: var(--content); } .workspace { display: grid; grid-template-rows: 38px 42px 1fr 24px; min-width: 0; height: 100vh; background: var(--content); }
.tabbar { display: flex; background: #111419; border-bottom: 1px solid var(--border); overflow: hidden; } .tabbar-wrap { display:flex; min-width:0; background:#111419; border-bottom:1px solid var(--border); }.tabbar { display:flex; flex:1; overflow:hidden; }
.tabbar button { min-width: 132px; max-width: 190px; border: 0; border-right: 1px solid var(--border); background: transparent; padding: 0 11px; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background 100ms ease; } .tabbar > button { min-width:132px;max-width:190px;border:0;border-right:1px solid var(--border);background:transparent;padding:0 11px;font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden }.tabbar > button:hover{background:var(--hover)}.tabbar > button.active{background:var(--content);color:var(--text);box-shadow:inset 0 2px var(--info)}.tab-controls{display:flex;border-left:1px solid var(--border)}.tab-controls button{width:30px;border:0;background:transparent;color:#8b94a2;padding:0}.tab-controls button:hover{background:var(--hover)}.tab-label{overflow:hidden;text-overflow:ellipsis}
.tabbar button:hover { background: var(--hover); color: #cbd0d8; }
.tabbar button.active { background: var(--content); color: var(--text); box-shadow: inset 0 2px var(--info); }
.tabbar button { display:flex;align-items:center;justify-content:space-between;gap:8px; }.tab-label{overflow:hidden;text-overflow:ellipsis}.close { color: #69717d; display:inline-flex; }
.running { display: inline-block; width: 6px; height: 6px; border:1px solid currentColor; border-radius: 2px; background: var(--warning); margin-right: 7px; } .running { display: inline-block; width: 6px; height: 6px; border:1px solid currentColor; border-radius: 2px; background: var(--warning); margin-right: 7px; }
.contextbar { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 16px; font-size: 11px; color: var(--muted); background: #171b21; } .contextbar { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 16px; font-size: 11px; color: var(--muted); background: #171b21; }
.contextbar i { color: #515967; font-style: normal; } .contextbar i { color: #515967; font-style: normal; }
@ -110,6 +107,10 @@ input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
.group-tree li > div { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; }.group-tree button, .operation-form button, .operation-confirmation button { border: 1px solid var(--border); background: #1b2027; border-radius: 4px; padding: 5px 8px; }.group-tree .selected { background: var(--selected); } .group-tree li > div { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; }.group-tree button, .operation-form button, .operation-confirmation button { border: 1px solid var(--border); background: #1b2027; border-radius: 4px; padding: 5px 8px; }.group-tree .selected { background: var(--selected); }
.operation-form { display: grid; gap: 12px; }.operation-form label { display: grid; gap: 5px; }.operation-form input, .operation-form select, select { background: #111419; border: 1px solid var(--border); border-radius: 4px; padding: 7px; }.operation-form small { color: var(--muted); }.operation-confirmation pre { max-height: 220px; overflow: auto; background: #0f1115; padding: 10px; }.operation-confirmation > div:last-child { display: flex; justify-content: flex-end; gap: 7px; } .operation-form { display: grid; gap: 12px; }.operation-form label { display: grid; gap: 5px; }.operation-form input, .operation-form select, select { background: #111419; border: 1px solid var(--border); border-radius: 4px; padding: 7px; }.operation-form small { color: var(--muted); }.operation-confirmation pre { max-height: 220px; overflow: auto; background: #0f1115; padding: 10px; }.operation-confirmation > div:last-child { display: flex; justify-content: flex-end; gap: 7px; }
@media (max-width: 1050px) { .overview-grid { grid-template-columns: 1fr; } } @media (max-width: 1050px) { .overview-grid { grid-template-columns: 1fr; } }
.demo-boundary { display:inline-block; margin:0 0 12px; padding:4px 7px; color:#d9b15f; border:1px solid #5d4b29; background:#292316; border-radius:4px; font:10px ui-monospace,monospace; }
.split-view { display:grid; grid-template-columns:minmax(320px,.9fr) minmax(360px,1.1fr); gap:14px; margin-top:14px; }.split-view>*{min-width:0}
.record-detail,.inline-alert { border:1px solid var(--border); border-radius:5px; background:#15191f; padding:14px; }.record-detail dl{display:grid;grid-template-columns:100px 1fr;gap:7px;margin:0 0 14px}.record-detail dd{margin:0;overflow-wrap:anywhere}.record-detail pre{max-height:300px;overflow:auto;background:#0f1115;padding:10px}.inline-alert{position:fixed;z-index:30;inset:auto 24px 40px auto;max-width:420px;box-shadow:0 12px 36px #000a}.inline-alert button{margin-right:7px}
@media (max-width:900px){.split-view{grid-template-columns:1fr}}
.connection-center { max-width: 1120px; display: grid; grid-template-columns: minmax(520px, 1fr) 330px; gap: 14px; } .connection-center { max-width: 1120px; display: grid; grid-template-columns: minmax(520px, 1fr) 330px; gap: 14px; }
.connection-list { grid-row: span 3; } .connection-list { grid-row: span 3; }

View File

@ -54,4 +54,15 @@ describe("workspaceReducer", () => {
expect(restarted.servers.atlas.filter).toBe(""); expect(restarted.servers.atlas.filter).toBe("");
expect(restarted).not.toBe(changed); expect(restarted).not.toBe(changed);
}); });
it("固定标签不可关闭,取消固定后关闭并恢复相邻标签", () => {
let state = createInitialWorkspaceState();
state = workspaceReducer(state, { type: "activateTab", serverId: "atlas", tabId: "nw-042" });
state = workspaceReducer(state, { type: "togglePinTab", serverId: "atlas", tabId: "nw-042" });
expect(workspaceReducer(state, { type: "closeTab", serverId: "atlas", tabId: "nw-042" })).toBe(state);
state = workspaceReducer(state, { type: "togglePinTab", serverId: "atlas", tabId: "nw-042" });
state = workspaceReducer(state, { type: "closeTab", serverId: "atlas", tabId: "nw-042" });
expect(state.servers.atlas.tabs.some(({ id }) => id === "nw-042")).toBe(false);
expect(state.servers.atlas.activeTabId).toBe("nw-sessions");
});
}); });

View File

@ -11,6 +11,7 @@ export interface OperationTab {
readonly context: TabContext; readonly context: TabContext;
readonly title: string; readonly title: string;
readonly running?: boolean; readonly running?: boolean;
readonly pinned?: boolean;
} }
export interface TeamserverWorkspace { export interface TeamserverWorkspace {
@ -38,7 +39,9 @@ export type WorkspaceAction =
| { type: "navigate"; serverId: string; project: string; navigation: string } | { type: "navigate"; serverId: string; project: string; navigation: string }
| { type: "setFilter"; serverId: string; filter: string } | { type: "setFilter"; serverId: string; filter: string }
| { type: "setUnread"; serverId: string; unread: number } | { type: "setUnread"; serverId: string; unread: number }
| { type: "openTab"; serverId: string; tab: OperationTab }; | { type: "openTab"; serverId: string; tab: OperationTab }
| { type: "closeTab"; serverId: string; tabId: string }
| { type: "togglePinTab"; serverId: string; tabId: string };
const seedWorkspaces: readonly TeamserverWorkspace[] = [ const seedWorkspaces: readonly TeamserverWorkspace[] = [
{ {
@ -132,6 +135,20 @@ export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction)
if (existing) return { ...server, activeTabId: existing.id }; if (existing) return { ...server, activeTabId: existing.id };
return { ...server, tabs: [...server.tabs, action.tab], activeTabId: action.tab.id }; return { ...server, tabs: [...server.tabs, action.tab], activeTabId: action.tab.id };
}); });
case "closeTab":
return updateServer(state, action.serverId, (server) => {
const target = server.tabs.find(({ id }) => id === action.tabId);
if (!target || target.pinned || server.tabs.length === 1) return server;
const index = server.tabs.findIndex(({ id }) => id === action.tabId);
const tabs = server.tabs.filter(({ id }) => id !== action.tabId);
const activeTabId = server.activeTabId === action.tabId ? tabs[Math.max(0, index - 1)].id : server.activeTabId;
return { ...server, tabs, activeTabId };
});
case "togglePinTab":
return updateServer(state, action.serverId, (server) => ({
...server,
tabs: server.tabs.map((tab) => tab.id === action.tabId ? { ...tab, pinned: !tab.pinned } : tab),
}));
} }
} }