Compare commits

..

No commits in common. "2d23a31c28a18b9c9972bc7491d12ce4c62ab170" and "64af3823cb19b2dada69782a586f54fbd1dfb831" have entirely different histories.

5 changed files with 19 additions and 45 deletions

View File

@ -3,12 +3,6 @@ import { describe, expect, it } from "vitest";
import { App } from "./App";
describe("Teamserver 工作区", () => {
it("禁用桌面应用内容区域的原生右键菜单", () => {
render(<App />);
const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
expect(document.dispatchEvent(event)).toBe(false);
expect(event.defaultPrevented).toBe(true);
});
it("切换服务器时只显示对应服务器标签,并恢复各自激活标签", () => {
render(<App />);
expect(screen.getByRole("tab", { name: /Session Overview/ })).toBeInTheDocument();

View File

@ -1,4 +1,4 @@
import { useEffect, useReducer, useState, type ReactNode } from "react";
import { useReducer, useState, type ReactNode } from "react";
import { ConnectionCenter, MemoryMockConnectionAdapter } from "./features/connections";
import { ArtifactsPage, AuditPage, EventsPage, OverviewPage, SessionDetailPage, TasksPage } from "./features/workbench";
import { createSessionTableViewState, SessionQueryIntent, SessionTable } from "./SessionTable";
@ -13,7 +13,6 @@ const sessions = demoSessions.map((session) => toSessionRow(session, fixtureNow)
const connectionAdapter = new MemoryMockConnectionAdapter();
export function App() {
useEffect(() => { const disableNativeMenu = (event: globalThis.MouseEvent) => event.preventDefault(); document.addEventListener("contextmenu", disableNativeMenu); return () => document.removeEventListener("contextmenu", disableNativeMenu); }, []);
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder));
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});

View File

@ -22,25 +22,15 @@ describe("SessionTable", () => {
const second = screen.getByRole("row", { name: /S-2/ });
expect(second).toHaveFocus();
fireEvent.keyDown(second, { key: " " });
expect(second).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("checkbox", { name: "选择 S-2" })).toBeChecked();
expect(screen.getByText("已选择 1 项")).toBeInTheDocument();
});
it("采用 Swing 风格的单选、修饰键多选与范围选择", () => {
it("支持当前页多选,不声称选择服务端未加载行", () => {
render(<Harness />);
const first = screen.getByRole("row", { name: /S-1/ });
const second = screen.getByRole("row", { name: /S-2/ });
fireEvent.click(first);
expect(first).toHaveAttribute("aria-selected", "true");
fireEvent.click(second, { ctrlKey: true });
expect(screen.getByText("已选择 2 项")).toBeInTheDocument();
fireEvent.click(first);
expect(first).toHaveAttribute("aria-selected", "true");
expect(second).toHaveAttribute("aria-selected", "false");
fireEvent.click(second, { shiftKey: true });
fireEvent.click(screen.getByRole("checkbox", { name: "选择当前页全部 Session" }));
expect(screen.getByText("已选择 2 项")).toBeInTheDocument();
expect(screen.getByText("12 / 200")).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /选择 (当前页|S-)/ })).not.toBeInTheDocument();
});
it("筛选和排序产生显式查询意图", () => {

View File

@ -1,4 +1,4 @@
import { CSSProperties, KeyboardEvent, MouseEvent, useRef } from "react";
import { CSSProperties, KeyboardEvent, useRef } from "react";
export type SessionStatus = "ONLINE" | "IDLE" | "OFFLINE" | (string & {});
@ -20,7 +20,6 @@ export interface SessionTableViewState {
filter: string;
focusedSessionId?: string;
selectedSessionIds: string[];
selectionAnchorId?: string;
sort?: { column: SessionColumnId; direction: SortDirection };
visibleColumns: SessionColumnId[];
}
@ -68,25 +67,12 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
const rowRefs = useRef(new Map<string, HTMLDivElement>());
const selected = new Set(state.selectedSessionIds);
const visible = new Set(state.visibleColumns);
const selectRow = (id: string, index: number, modifiers: { additive: boolean; range: boolean }) => {
if (modifiers.range) {
const anchorIndex = Math.max(0, rows.findIndex((row) => row.id === (state.selectionAnchorId ?? id)));
const [start, end] = anchorIndex <= index ? [anchorIndex, index] : [index, anchorIndex];
const range = rows.slice(start, end + 1).map((row) => row.id);
onStateChange({ ...state, focusedSessionId: id, selectedSessionIds: modifiers.additive ? [...new Set([...selected, ...range])] : range });
return;
}
if (modifiers.additive) {
const next = new Set(selected); next.has(id) ? next.delete(id) : next.add(id);
onStateChange({ ...state, focusedSessionId: id, selectionAnchorId: id, selectedSessionIds: [...next] });
return;
}
onStateChange({ ...state, focusedSessionId: id, selectionAnchorId: id, selectedSessionIds: [id] });
};
const allSelected = rows.length > 0 && rows.every(({ id }) => selected.has(id));
const handleRowClick = (event: MouseEvent<HTMLDivElement>, index: number, id: string) => {
if ((event.target as HTMLElement).closest("button, input, select, a")) return;
selectRow(id, index, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey });
const updateSelection = (id: string, checked: boolean) => {
const next = new Set(selected);
checked ? next.add(id) : next.delete(id);
onStateChange({ ...state, selectedSessionIds: [...next] });
};
const focusRow = (index: number) => {
@ -104,7 +90,7 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
}
if (event.key === " " && event.target === event.currentTarget) {
event.preventDefault();
selectRow(id, index, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey });
updateSelection(id, !selected.has(id));
}
};
@ -132,13 +118,19 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
</div>
<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">
<span role="columnheader"><input type="checkbox" aria-label="选择当前页全部 Session" checked={allSelected} onChange={(event) => {
const next = new Set(selected);
rows.forEach(({ id }) => event.currentTarget.checked ? next.add(id) : next.delete(id));
onStateChange({ ...state, selectedSessionIds: [...next] });
}} /></span>
{sessionColumns.filter(({ id }) => visible.has(id)).map((column) => {
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>;
})}
<span role="columnheader" />
</div>
</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 })} onClick={(event) => handleRowClick(event, index, 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 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 role="cell"><em className={`status-mark ${session.status.toLowerCase()}`} aria-hidden="true" /><span className="sr-only"></span>{session.status}</span>}
{visible.has("id") && <strong role="cell" className="mono">{session.id}</strong>}
{visible.has("identity") && <span role="cell"><b>{session.host}</b><small>{session.user}</small></span>}

View File

@ -67,11 +67,10 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.column-picker fieldset label { width: auto; height: 26px; padding: 0; border: 0; background: transparent; color: #cbd0d8; }
.toolbar-spacer, .status-spacer { flex: 1; }
.session-table { --column-status: 92px; --column-id: 104px; --column-identity: minmax(150px, 1.3fr); --column-os: 120px; --column-address: 130px; --column-seen: 92px; --column-tags: minmax(120px, 1fr); min-width: 880px; border: 1px solid var(--border); background: #15191f; }
.table-row { display: grid; grid-template-columns: var(--session-columns) 34px; min-height: 36px; border-bottom: 1px solid #252b33; align-items: stretch; font-size: 12px; color: #cbd0d8; }
.table-row { display: grid; grid-template-columns: 34px var(--session-columns) 34px; min-height: 36px; border-bottom: 1px solid #252b33; align-items: stretch; font-size: 12px; color: #cbd0d8; }
.table-row > span, .table-row > strong, .table-row > button { display: flex; align-items: center; padding: 0 9px; min-width: 0; border-right: 1px solid #222831; }
.table-row:hover { background: var(--hover); }
.table-row.focused { background: #202a35; box-shadow: inset 2px 0 var(--info); }
.table-row[aria-selected="true"] { background: #243346; box-shadow: inset 2px 0 var(--info); }
.table-head { position: sticky; top: -22px; z-index: 2; min-height: 30px; background: #111419; color: #78818e; font-size: 9px; font-weight: 650; letter-spacing: .04em; }
.sort-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; text-transform: uppercase; }
.table-row b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; color: #dde1e7; }