From 52275ab11bb438d0828e853488b2f885e32a97e2 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 3 Aug 2026 19:53:21 +0000 Subject: [PATCH] =?UTF-8?q?=E7=A6=81=E7=94=A8=E5=8E=9F=E7=94=9F=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E5=B9=B6=E6=94=B9=E8=BF=9B=E8=A1=8C?= =?UTF-8?q?=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- src/App.test.tsx | 6 ++++++ src/App.tsx | 3 ++- src/SessionTable.test.tsx | 16 +++++++++++++--- src/SessionTable.tsx | 36 ++++++++++++++++++++++-------------- src/styles.css | 3 ++- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index c1c8c40..d2551e9 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -3,6 +3,12 @@ import { describe, expect, it } from "vitest"; import { App } from "./App"; describe("Teamserver 工作区", () => { + it("禁用桌面应用内容区域的原生右键菜单", () => { + render(); + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + expect(document.dispatchEvent(event)).toBe(false); + expect(event.defaultPrevented).toBe(true); + }); it("切换服务器时只显示对应服务器标签,并恢复各自激活标签", () => { render(); expect(screen.getByRole("tab", { name: /Session Overview/ })).toBeInTheDocument(); diff --git a/src/App.tsx b/src/App.tsx index 1418402..69df922 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useReducer, useState, type ReactNode } from "react"; +import { useEffect, 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,6 +13,7 @@ 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>({}); diff --git a/src/SessionTable.test.tsx b/src/SessionTable.test.tsx index 570571b..f4821ca 100644 --- a/src/SessionTable.test.tsx +++ b/src/SessionTable.test.tsx @@ -22,15 +22,25 @@ describe("SessionTable", () => { const second = screen.getByRole("row", { name: /S-2/ }); expect(second).toHaveFocus(); fireEvent.keyDown(second, { key: " " }); - expect(screen.getByRole("checkbox", { name: "选择 S-2" })).toBeChecked(); + expect(second).toHaveAttribute("aria-selected", "true"); expect(screen.getByText("已选择 1 项")).toBeInTheDocument(); }); - it("支持当前页多选,不声称选择服务端未加载行", () => { + it("采用 Swing 风格的单选、修饰键多选与范围选择", () => { render(); - fireEvent.click(screen.getByRole("checkbox", { name: "选择当前页全部 Session" })); + 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 }); expect(screen.getByText("已选择 2 项")).toBeInTheDocument(); expect(screen.getByText("1–2 / 200")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: /选择 (当前页|S-)/ })).not.toBeInTheDocument(); }); it("筛选和排序产生显式查询意图", () => { diff --git a/src/SessionTable.tsx b/src/SessionTable.tsx index 17e0512..025c4f4 100644 --- a/src/SessionTable.tsx +++ b/src/SessionTable.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, KeyboardEvent, useRef } from "react"; +import { CSSProperties, KeyboardEvent, MouseEvent, useRef } from "react"; export type SessionStatus = "ONLINE" | "IDLE" | "OFFLINE" | (string & {}); @@ -20,6 +20,7 @@ export interface SessionTableViewState { filter: string; focusedSessionId?: string; selectedSessionIds: string[]; + selectionAnchorId?: string; sort?: { column: SessionColumnId; direction: SortDirection }; visibleColumns: SessionColumnId[]; } @@ -67,12 +68,25 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o const rowRefs = useRef(new Map()); const selected = new Set(state.selectedSessionIds); const visible = new Set(state.visibleColumns); - const allSelected = rows.length > 0 && rows.every(({ id }) => selected.has(id)); + 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 updateSelection = (id: string, checked: boolean) => { - const next = new Set(selected); - checked ? next.add(id) : next.delete(id); - onStateChange({ ...state, selectedSessionIds: [...next] }); + const handleRowClick = (event: MouseEvent, 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 focusRow = (index: number) => { @@ -90,7 +104,7 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o } if (event.key === " " && event.target === event.currentTarget) { event.preventDefault(); - updateSelection(id, !selected.has(id)); + selectRow(id, index, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey }); } }; @@ -118,19 +132,13 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
`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] }); - }} /> {sessionColumns.filter(({ id }) => visible.has(id)).map((column) => { 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)} /> +
{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 })} onClick={(event) => handleRowClick(event, index, session.id)} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}> {visible.has("status") &&