Compare commits

..

3 Commits

Author SHA1 Message Date
dev
af0b0e450b Merge pull request '升级高密度 Session 表格交互' (#4) from agent/docker/91241ae6 into main
Reviewed-on: #4
2026-08-03 19:11:17 +00:00
Ubuntu
9ad4182b2b 合并 main 并解决工作区状态冲突
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 19:10:30 +00:00
Ubuntu
49c859ff55 升级高密度 Session 表格交互
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 19:06:37 +00:00
6 changed files with 252 additions and 21 deletions

View File

@ -1,4 +1,6 @@
import { useReducer } from "react";
import { useReducer, useState } from "react";
import { SessionQueryIntent, SessionTable } from "./SessionTable";
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
import {
createInitialWorkspaceState,
selectActiveTab,
@ -15,8 +17,11 @@ const sessions = [
export function App() {
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder));
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});
const server = selectActiveWorkspace(workspace);
const activeTab = selectActiveTab(server);
const tableState = { ...tableStates[server.id], filter: server.filter };
return (
<main className="shell">
@ -82,23 +87,11 @@ export function App() {
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 248 Session · 4 </p></div>
<div className="heading-actions"><button className="secondary"></button><button className="primary"> </button></div>
</div>
<div className="table-toolbar">
<label><span></span><input aria-label="筛选 Session" placeholder="筛选 Session…" value={server.filter} onChange={(event) => dispatch({ type: "setFilter", serverId: server.id, filter: event.target.value })} /></label>
<button></button><button></button><button> </button>
<span className="toolbar-spacer" /><button aria-label="调整列"> </button><button aria-label="更多操作"></button>
</div>
<section className="session-table" aria-label="Session 表格">
<div className="table-row table-head" role="row"><span><input type="checkbox" aria-label="选择全部" /></span><span>STATUS </span><span>SESSION ID</span><span>HOST / USER</span><span>OS</span><span>SOURCE ADDRESS</span><span>LAST SEEN </span><span>TAGS</span><span /></div>
{sessions.map((session) => <div className={`table-row ${session.id === "NW-042" ? "focused" : ""}`} role="row" key={session.id} tabIndex={0}>
<span><input type="checkbox" aria-label={`选择 ${session.id}`} /></span>
<span><em className={`status-mark ${session.status.toLowerCase()}`} />{session.status}</span>
<strong className="mono">{session.id}</strong>
<span><b>{session.host}</b><small>{session.user}</small></span>
<span>{session.os}</span><span className="mono muted">{session.address}</span><span className="mono muted">{session.seen}</span>
<span className="tags">{session.tags.map((tag) => <small key={tag}>{tag}</small>)}</span><button className="row-action" aria-label={`${session.id} 快速操作`}></button>
</div>)}
<footer><span> 0 </span><span>150 / 248</span><button></button><button></button></footer>
</section>
<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>
<footer className="statusbar"><span> {server.connection}</span><span> operator@example</span><span> 38ms</span><span> v1.0</span><span> </span><span className="status-spacer" /></footer>
</section>

53
src/SessionTable.test.tsx Normal file
View File

@ -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 <SessionTable rows={rows} state={state} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 2, total: 200, hasPrevious: false, hasNext: true }} onStateChange={setState} onQueryIntent={onIntent} />;
}
describe("SessionTable", () => {
it("使用方向键移动行焦点,并用空格选择", () => {
render(<Harness />);
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(<Harness />);
fireEvent.click(screen.getByRole("checkbox", { name: "选择当前页全部 Session" }));
expect(screen.getByText("已选择 2 项")).toBeInTheDocument();
expect(screen.getByText("12 / 200")).toBeInTheDocument();
});
it("筛选和排序产生显式查询意图", () => {
const onIntent = vi.fn();
render(<Harness onIntent={onIntent} />);
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(<Harness />);
fireEvent.click(screen.getByText("☷ 列"));
fireEvent.click(screen.getByRole("checkbox", { name: "来源地址" }));
expect(screen.queryByText("10.0.0.1")).not.toBeInTheDocument();
});
});

147
src/SessionTable.tsx Normal file
View File

@ -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<string, HTMLDivElement>());
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<HTMLDivElement>, 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 (
<>
<div className="table-toolbar">
<label><span></span><input aria-label="筛选 Session" disabled={!capabilities.filtering} value={state.filter} placeholder={capabilities.filtering ? "筛选 Session…" : "服务端未声明筛选能力"} onChange={(event) => {
const filter = event.currentTarget.value;
onStateChange({ ...state, filter });
onQueryIntent({ type: "filter", value: filter });
}} /></label>
<button></button><button></button><button> </button>
<span className="toolbar-spacer" />
{capabilities.columnVisibility && <details className="column-picker"><summary aria-label="调整列"> </summary><fieldset><legend></legend>{sessionColumns.map((column) => <label key={column.id}><input type="checkbox" checked={visible.has(column.id)} onChange={(event) => {
const next = event.currentTarget.checked ? [...state.visibleColumns, column.id] : state.visibleColumns.filter((id) => id !== column.id);
onStateChange({ ...state, visibleColumns: next });
}} />{column.label}</label>)}</fieldset></details>}
<button aria-label="更多操作"></button>
</div>
<section className="session-table" aria-label="Session 表格" style={{ "--session-columns": state.visibleColumns.map((id) => `var(--column-${id})`).join(" ") } as CSSProperties}>
<div className="table-row table-head" role="row">
<span><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 />
</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)}>
<span><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("id") && <strong className="mono">{session.id}</strong>}
{visible.has("identity") && <span><b>{session.host}</b><small>{session.user}</small></span>}
{visible.has("os") && <span>{session.os}</span>}
{visible.has("address") && <span className="mono muted">{session.address}</span>}
{visible.has("seen") && <span className="mono muted">{session.seen}</span>}
{visible.has("tags") && <span className="tags">{session.tags.map((tag) => <small key={tag}>{tag}</small>)}</span>}
<button className="row-action" aria-label={`${session.id} 快速操作`}></button>
</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>
</>
);
}

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
describe("Teamserver Session 表格视图状态", () => {
it("在进程内按 Teamserver 隔离并恢复视图", () => {
const initial = initializeSessionTableStates(["atlas", "ember"]);
const next = updateSessionTableState(initial, "atlas", { ...initial.atlas, filter: "finance", selectedSessionIds: ["S-1"] });
expect(next.atlas.filter).toBe("finance");
expect(next.atlas.selectedSessionIds).toEqual(["S-1"]);
expect(next.ember.filter).toBe("");
expect(initial.atlas.filter).toBe("");
});
});

15
src/sessionTableState.ts Normal file
View File

@ -0,0 +1,15 @@
import { createSessionTableViewState, SessionTableViewState } from "./SessionTable";
export type SessionTableStateByServer = Record<string, SessionTableViewState>;
export function initializeSessionTableStates(serverIds: readonly string[]): SessionTableStateByServer {
return Object.fromEntries(serverIds.map((serverId) => [serverId, createSessionTableViewState()]));
}
export function updateSessionTableState(
states: SessionTableStateByServer,
serverId: string,
state: SessionTableViewState,
): SessionTableStateByServer {
return { ...states, [serverId]: state };
}

View File

@ -63,13 +63,20 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.table-toolbar { height: 40px; display: flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-bottom: 0; padding: 5px 7px; background: #15191f; }
.table-toolbar label { width: 220px; height: 28px; padding: 0 8px; }
.table-toolbar button { height: 28px; padding: 0 8px; color: #aeb5c0; }
.column-picker { position: relative; }
.column-picker summary { display: flex; align-items: center; height: 28px; padding: 0 8px; border: 1px solid var(--border); border-radius: 4px; background: #1b2027; color: #aeb5c0; cursor: pointer; list-style: none; }
.column-picker summary::-webkit-details-marker { display: none; }
.column-picker fieldset { position: absolute; z-index: 5; right: 0; top: 31px; width: 170px; margin: 0; padding: 9px; border: 1px solid #3a424e; border-radius: 4px; background: #15191f; box-shadow: 0 8px 24px #090b0eaa; }
.column-picker legend { color: var(--muted); font-size: 10px; }
.column-picker fieldset label { width: auto; height: 26px; padding: 0; border: 0; background: transparent; color: #cbd0d8; }
.toolbar-spacer, .status-spacer { flex: 1; }
.session-table { min-width: 880px; border: 1px solid var(--border); background: #15191f; }
.table-row { display: grid; grid-template-columns: 34px 92px 104px minmax(150px, 1.3fr) 120px 130px 92px minmax(120px, 1fr) 34px; min-height: 36px; border-bottom: 1px solid #252b33; align-items: stretch; font-size: 12px; color: #cbd0d8; }
.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: 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-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; }
.table-row small { display: block; color: #7f8895; font-size: 10px; }
.mono { font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; font-weight: 500; color: #9ec7f4; }
@ -83,8 +90,11 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.session-table > footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; height: 34px; padding: 0 8px; color: #7f8895; font-size: 10px; }
.session-table > footer span:first-child { margin-right: auto; }
.session-table > footer button { width: 24px; height: 22px; border: 1px solid var(--border); background: #1b2027; border-radius: 3px; color: var(--muted); }
.session-table > footer button:disabled { cursor: not-allowed; opacity: .45; }
.query-intent { display: block; margin-top: 7px; color: #737c89; font: 9px ui-monospace, monospace; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.statusbar { display: flex; gap: 20px; align-items: center; padding: 0 10px; border-top: 1px solid #2b323c; color: #78818d; background: #111419; font-size: 9px; }
.statusbar span:first-child { color: #75c99c; }
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } .table-row { grid-template-columns: 34px 84px 96px minmax(140px, 1fr) 110px 120px 84px minmax(110px, 1fr) 34px; } }
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } }
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }