diff --git a/src/App.tsx b/src/App.tsx index 7925cb3..761d4d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ import { useMemo, useState } from "react"; +import { SessionQueryIntent, SessionTable } from "./SessionTable"; +import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState"; import { initialWorkspaces, selectWorkspace } from "./workspace"; const sessions = [ @@ -13,11 +15,14 @@ export function App() { const [activeTabs, setActiveTabs] = useState>( Object.fromEntries(initialWorkspaces.map((server) => [server.id, server.activeTabId])), ); + const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(initialWorkspaces.map(({ id }) => id))); + const [lastQueryIntent, setLastQueryIntent] = useState>({}); const server = useMemo( () => selectWorkspace(initialWorkspaces, activeServerId) ?? initialWorkspaces[0], [activeServerId], ); const activeTab = server.tabs.find((tab) => tab.id === activeTabs[server.id]) ?? server.tabs[0]; + const tableState = tableStates[server.id]; return (
@@ -83,23 +88,8 @@ export function App() {

{activeTab.title}

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

-
- - - -
-
-
STATUS ↕SESSION IDHOST / USEROSSOURCE ADDRESSLAST SEEN ↓TAGS
- {sessions.map((session) =>
- - {session.status} - {session.id} - {session.host}{session.user} - {session.os}{session.address}{session.seen} - {session.tags.map((tag) => {tag})} -
)} -
已选择 0 项1–50 / 248
-
+ setTableStates((current) => updateSessionTableState(current, server.id, state))} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} /> + {lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}
● {server.connection}身份 operator@example延迟 38ms协议 v1.0事件流 ✓ 已同步内存工作区
diff --git a/src/SessionTable.test.tsx b/src/SessionTable.test.tsx new file mode 100644 index 0000000..570571b --- /dev/null +++ b/src/SessionTable.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createSessionTableViewState, SessionQueryIntent, SessionRow, SessionTable } from "./SessionTable"; + +const rows: SessionRow[] = [ + { id: "S-1", status: "ONLINE", host: "alpha", user: "one", os: "Linux", address: "10.0.0.1", seen: "1m", tags: ["a"] }, + { id: "S-2", status: "OFFLINE", host: "beta", user: "two", os: "Windows", address: "10.0.0.2", seen: "2m", tags: ["b"] }, +]; + +function Harness({ onIntent = () => undefined }: { onIntent?: (intent: SessionQueryIntent) => void }) { + const [state, setState] = useState(createSessionTableViewState); + return ; +} + +describe("SessionTable", () => { + it("使用方向键移动行焦点,并用空格选择", () => { + render(); + const first = screen.getByRole("row", { name: /S-1/ }); + first.focus(); + fireEvent.keyDown(first, { key: "ArrowDown" }); + const second = screen.getByRole("row", { name: /S-2/ }); + expect(second).toHaveFocus(); + fireEvent.keyDown(second, { key: " " }); + expect(screen.getByRole("checkbox", { name: "选择 S-2" })).toBeChecked(); + expect(screen.getByText("已选择 1 项")).toBeInTheDocument(); + }); + + it("支持当前页多选,不声称选择服务端未加载行", () => { + render(); + fireEvent.click(screen.getByRole("checkbox", { name: "选择当前页全部 Session" })); + expect(screen.getByText("已选择 2 项")).toBeInTheDocument(); + expect(screen.getByText("1–2 / 200")).toBeInTheDocument(); + }); + + it("筛选和排序产生显式查询意图", () => { + const onIntent = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox", { name: "筛选 Session" }), { target: { value: "alpha" } }); + fireEvent.click(screen.getByRole("button", { name: /最后活动/ })); + fireEvent.click(screen.getByRole("button", { name: /最后活动/ })); + expect(onIntent).toHaveBeenNthCalledWith(1, { type: "filter", value: "alpha" }); + expect(onIntent).toHaveBeenNthCalledWith(2, { type: "sort", column: "seen", direction: "ascending" }); + expect(onIntent).toHaveBeenNthCalledWith(3, { type: "sort", column: "seen", direction: "descending" }); + }); + + it("可以隐藏列", () => { + render(); + fireEvent.click(screen.getByText("☷ 列")); + fireEvent.click(screen.getByRole("checkbox", { name: "来源地址" })); + expect(screen.queryByText("10.0.0.1")).not.toBeInTheDocument(); + }); +}); diff --git a/src/SessionTable.tsx b/src/SessionTable.tsx new file mode 100644 index 0000000..3944ae6 --- /dev/null +++ b/src/SessionTable.tsx @@ -0,0 +1,147 @@ +import { CSSProperties, KeyboardEvent, useRef } from "react"; + +export type SessionStatus = "ONLINE" | "IDLE" | "OFFLINE" | (string & {}); + +export interface SessionRow { + id: string; + status: SessionStatus; + host: string; + user: string; + os: string; + address: string; + seen: string; + tags: string[]; +} + +export type SessionColumnId = "status" | "id" | "identity" | "os" | "address" | "seen" | "tags"; +export type SortDirection = "ascending" | "descending"; + +export interface SessionTableViewState { + filter: string; + focusedSessionId?: string; + selectedSessionIds: string[]; + sort?: { column: SessionColumnId; direction: SortDirection }; + visibleColumns: SessionColumnId[]; +} + +export type SessionQueryIntent = + | { type: "filter"; value: string } + | { type: "sort"; column: SessionColumnId; direction: SortDirection } + | { type: "page"; direction: "previous" | "next" }; + +export interface SessionTableProps { + rows: SessionRow[]; + state: SessionTableViewState; + capabilities: SessionTableCapabilities; + page: { start: number; end: number; total: number; hasPrevious: boolean; hasNext: boolean }; + onStateChange: (state: SessionTableViewState) => void; + onQueryIntent: (intent: SessionQueryIntent) => void; +} + +export interface SessionTableCapabilities { + filtering: boolean; + sortableColumns: SessionColumnId[]; + columnVisibility: boolean; + pagination: boolean; +} + +export const sessionColumns: { id: SessionColumnId; label: string; sortable?: boolean }[] = [ + { id: "status", label: "状态", sortable: true }, + { id: "id", label: "Session ID", sortable: true }, + { id: "identity", label: "主机 / 用户" }, + { id: "os", label: "OS" }, + { id: "address", label: "来源地址" }, + { id: "seen", label: "最后活动", sortable: true }, + { id: "tags", label: "标签" }, +]; + +export function createSessionTableViewState(): SessionTableViewState { + return { filter: "", selectedSessionIds: [], visibleColumns: sessionColumns.map(({ id }) => id) }; +} + +function nextSort(state: SessionTableViewState, column: SessionColumnId): SortDirection { + return state.sort?.column === column && state.sort.direction === "ascending" ? "descending" : "ascending"; +} + +export function SessionTable({ rows, state, capabilities, page, onStateChange, onQueryIntent }: SessionTableProps) { + 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 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) => { + const row = rows[Math.max(0, Math.min(rows.length - 1, index))]; + if (!row) return; + onStateChange({ ...state, focusedSessionId: row.id }); + rowRefs.current.get(row.id)?.focus(); + }; + + const handleRowKeyDown = (event: KeyboardEvent, index: number, id: string) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") { + event.preventDefault(); + const target = event.key === "Home" ? 0 : event.key === "End" ? rows.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1); + focusRow(target); + } + if (event.key === " " && event.target === event.currentTarget) { + event.preventDefault(); + updateSelection(id, !selected.has(id)); + } + }; + + const sort = (column: SessionColumnId) => { + const direction = nextSort(state, column); + onStateChange({ ...state, sort: { column, direction } }); + onQueryIntent({ type: "sort", column, direction }); + }; + + return ( + <> +
+ + + + {capabilities.columnVisibility &&
☷ 列
显示列{sessionColumns.map((column) => )}
} + +
+
`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)} /> + {visible.has("status") &&