Compare commits

..

No commits in common. "main" and "agent/docker/20e0ad2a" have entirely different histories.

66 changed files with 153 additions and 2489 deletions

View File

@ -1,54 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
quality:
name: Node 质量门禁
runs-on: docker
timeout-minutes: 15
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 配置 Node.js
uses: actions/setup-node@v4
with:
node-version: 22.18.0
cache: npm
- name: 安装锁定依赖
run: npm ci
- name: TypeScript 类型检查
run: npm run typecheck
- name: 单元与组件测试
run: npm test
- name: 生产构建
run: npm run build
- name: 高危依赖审计
run: npm audit --audit-level=high
docker:
name: Docker 构建与健康检查
runs-on: docker
timeout-minutes: 15
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 构建 Web 镜像
run: docker compose build web
- name: 启动 Web 服务
run: docker compose up --no-build --detach web
- name: 等待健康检查通过
run: ./scripts/check-web-health.sh
- name: 输出故障诊断
if: failure()
run: docker compose ps && docker compose logs --no-color web
- name: 清理容器
if: always()
run: docker compose down --volumes --remove-orphans

View File

@ -2,12 +2,6 @@
Gaza 是一个协议优先、跨平台的多 Teamserver 桌面工作台。当前仓库包含可运行的首个项目骨架,重点验证 Teamserver 工作区和标签页隔离模型。
## 标准协议模型
`src/protocol` 定义 GUI 与兼容 Teamserver 之间的首版强类型模型,包括 Teamserver、Project、Session、Capability、Task、Event、Artifact 和 Audit。该模块不包含网络请求、认证或私有 C2 适配。
`ServerId`、`ProjectId`、`SessionId` 等采用品牌类型,避免在编译期误用不同层级 ID枚举允许未来服务端新增值解析器保留未知枚举和扩展字段。迁移现有界面数据时可从 `src/protocol/fixtures` 引入最小演示 fixture。
## 技术栈
- Tauri 2桌面容器
@ -27,11 +21,8 @@ npm run tauri dev # 桌面开发模式
无需本机 Node.js 的前端验证:
```bash
docker compose build test build
docker compose run --rm test
docker compose run --rm build
docker compose run --rm test npm run typecheck
docker compose run --rm test npm audit --audit-level=high
```
## Docker 启动 Web 测试服务
@ -47,7 +38,6 @@ docker compose up --build -d
```bash
docker compose ps
docker compose logs -f web
./scripts/check-web-health.sh
```
停止服务:
@ -74,33 +64,8 @@ WEB_BIND_ADDRESS=0.0.0.0 WEB_PORT=1420 docker compose up --build -d
## 常用命令
```bash
npm ci
npm run typecheck
npm test
npm run build
npm audit --audit-level=high
npm run tauri build
```
## 持续集成与本地复现
Forgejo 工作流 [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) 在推送到 `main` 和拉取请求时运行两个独立作业:
- Node 质量门禁依次执行锁定依赖安装、TypeScript 类型检查、测试、生产构建和高危依赖审计。
- Docker 门禁构建 `web` 镜像,按 Compose 的最小权限配置启动服务,并等待镜像内置健康检查通过。
本机装有 Node.js 22.18.0 时,可使用“常用命令”中的前五条命令逐步复现 Node 作业。仅安装 Docker 时,可完整复现 CI
```bash
docker compose build test build
docker compose run --rm test npm run typecheck
docker compose run --rm test
docker compose run --rm build
docker compose run --rm test npm audit --audit-level=high
docker compose build web
docker compose up --no-build -d web
./scripts/check-web-health.sh
docker compose down --volumes --remove-orphans
```
依赖审计会在发现高危或严重漏洞时失败网络或软件源不可用同样视为审计失败不静默跳过。Docker 服务仍以非 root 用户运行,并保留只读根文件系统与 `no-new-privileges` 限制。

11
package-lock.json generated
View File

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

View File

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

View File

@ -1,32 +0,0 @@
#!/bin/sh
set -eu
max_attempts="${WEB_HEALTH_ATTEMPTS:-30}"
attempt=1
while [ "$attempt" -le "$max_attempts" ]; do
container_id="$(docker compose ps --quiet web)"
if [ -z "$container_id" ]; then
echo "Web 容器未运行" >&2
exit 1
fi
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container_id")"
case "$status" in
healthy)
echo "Web 容器健康检查通过"
exit 0
;;
unhealthy|missing)
echo "Web 容器健康状态异常:$status" >&2
exit 1
;;
esac
echo "等待 Web 容器健康检查($attempt/$max_attempts,当前:$status"
attempt=$((attempt + 1))
sleep 2
done
echo "Web 容器未在预期时间内进入 healthy 状态" >&2
exit 1

View File

@ -3,24 +3,6 @@ import { describe, expect, it } from "vitest";
import { App } from "./App";
describe("Teamserver 工作区", () => {
it("从 Rail 添加并保存内存 Teamserver 配置", async () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "添加 Teamserver" }));
const dialog = screen.getByRole("dialog", { name: "添加 Teamserver" });
fireEvent.change(screen.getByLabelText("名称"), { target: { value: "Delta Lab" } });
fireEvent.change(screen.getByLabelText("地址"), { target: { value: "https://delta.example" } });
fireEvent.click(screen.getByRole("button", { name: "保存到内存" }));
expect(await screen.findByRole("button", { name: "切换到 Delta Lab" })).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "添加 Teamserver" })).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("尚未建立真实连接");
expect(dialog).not.toBeInTheDocument();
});
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();
@ -39,49 +21,4 @@ describe("Teamserver 工作区", () => {
expect(screen.getByText("NW-042")).toBeInTheDocument();
expect(screen.getByText("10.42.8.17")).toBeInTheDocument();
});
it("从 Session 表格打开上下文锁定的详情标签", () => {
render(<App />);
fireEvent.doubleClick(screen.getByRole("row", { name: /NW-038/ }));
expect(screen.getAllByRole("tab", { name: "Session Overview" }).find((tab) => tab.getAttribute("aria-selected") === "true")).toBeDefined();
expect(screen.getByLabelText("锁定的操作上下文")).toHaveTextContent("Northwind / NW-038 / Session Overview");
expect(screen.getByRole("heading", { name: "NW-038" })).toBeInTheDocument();
});
it("通过拆分后的侧边栏进入主线新增的连接中心", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "连接中心" }));
expect(screen.getByRole("heading", { name: "连接中心" })).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,35 +1,108 @@
import { useEffect, useReducer, useState, type ReactNode } from "react";
import { blankConnection, ConnectionCenter, ConnectionEditor, MemoryMockConnectionAdapter } from "./features/connections";
import { ArtifactsPage, AuditPage, EventsPage, OverviewPage, SessionDetailPage, TasksPage } from "./features/workbench";
import { createSessionTableViewState, SessionQueryIntent, SessionTable } from "./SessionTable";
import { demoSessions } from "./protocol/fixtures";
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
import { toSessionRow } from "./sessionPresentation";
import { WorkspaceLayout } from "./shell";
import { Dialog, Toast } from "./ui";
import { createInitialWorkspaceState, selectActiveTab, selectActiveWorkspace, workspaceReducer } from "./workspace";
import { useMemo, useState } from "react";
import { initialWorkspaces, selectWorkspace } from "./workspace";
const fixtureNow = new Date("2026-08-03T19:12:02Z");
const sessions = demoSessions.map((session, index) => ({ ...toSessionRow(session, fixtureNow), capabilities: index === 0 ? ["session.processes", "session.files"] : [] }));
const connectionAdapter = new MemoryMockConnectionAdapter();
const sessions = [
{ id: "NW-042", status: "ONLINE", host: "ws-fin-042", user: "nora.chen", os: "Windows 11", address: "10.42.8.17", seen: "14s ago", tags: ["finance", "priority"] },
{ id: "NW-038", status: "ONLINE", host: "srv-app-03", user: "svc.deploy", os: "Ubuntu 24.04", address: "10.42.3.8", seen: "51s ago", tags: ["server"] },
{ id: "NW-031", status: "IDLE", host: "mac-design-07", user: "alex.k", os: "macOS 15.6", address: "10.42.12.27", seen: "8m ago", tags: ["design"] },
{ id: "NW-019", status: "OFFLINE", host: "ws-ops-019", user: "operator", os: "Windows 10", address: "10.42.6.91", seen: "2h ago", tags: ["legacy"] },
];
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>>({});
const [addOpen, setAddOpen] = useState(false);
const [connectionAddresses, setConnectionAddresses] = useState<string[]>([]);
const [notice, setNotice] = useState("");
const server = selectActiveWorkspace(workspace); const activeTab = selectActiveTab(server);
const tableState = { ...(tableStates[server.id] ?? createSessionTableViewState()), filter: server.filter };
const navigate = (page: string) => {
const title = page === "Sessions" ? "Sessions" : page;
dispatch({ type: "navigate", serverId: server.id, project: server.activeProject, navigation: page });
dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.activeProject.toLowerCase()}-${page.toLowerCase().replaceAll(" ", "-")}`, title, context: { serverId: server.id, project: server.activeProject, session: "Project" } } });
};
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)); }} onOpenSession={(session) => dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.id}-${activeTab.context.project}-${session.id}-overview`, title: "Session Overview", context: { serverId: server.id, project: activeTab.context.project, session: session.id } } })} onOpenOperation={(session, operation) => dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.id}-${activeTab.context.project}-${session.id}-${operation}`, title: operation === "processes" ? "进程" : operation === "files" ? "文件" : "打开操作", context: { serverId: server.id, project: activeTab.context.project, session: session.id }, dirty: operation === "operation" } })} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} /><output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output></>;
const pages: Record<string, ReactNode> = { Overview: <OverviewPage />, Sessions: sessionsPage, "Session Overview": <SessionDetailPage sessionId={activeTab.context.session} />, Tasks: <TasksPage />, "Task Output": <TasksPage />, Events: <EventsPage />, Artifacts: <ArtifactsPage />, Audit: <AuditPage />, "连接中心": <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> };
const operationBoundary = ["进程", "文件", "打开操作"].includes(activeTab.title) ? <section className="operation-boundary"><h2>{activeTab.title}</h2><p>{activeTab.context.serverId} / {activeTab.context.project} / {activeTab.context.session}</p><p className="demo-boundary">Mock Teamserver adapter </p></section> : undefined;
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 })} onAddServer={() => setAddOpen(true)} 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] ?? operationBoundary ?? <OverviewPage />}</article></WorkspaceLayout><Dialog open={addOpen} onOpenChange={setAddOpen} title="添加 Teamserver"><ConnectionEditor initial={blankConnection()} existingAddresses={connectionAddresses} developmentMode={import.meta.env.DEV} onCancel={() => setAddOpen(false)} onSave={async (config) => { await connectionAdapter.save(config); setConnectionAddresses((current) => [...current, config.address.trim()]); const tab = { id: `${config.id}-connections`, title: "连接中心", context: { serverId: config.id, project: "尚未选择 Project", session: "Project" } }; dispatch({ type: "addServer", server: { id: config.id, name: config.name, shortName: config.name.trim().slice(0, 2).toUpperCase(), connection: "DEGRADED", unread: 0, activeProject: "尚未选择 Project", navigation: "连接中心", filter: "", activeTabId: tab.id, tabs: [tab] } }); setAddOpen(false); setNotice("连接配置已创建,尚未建立真实连接。"); }} /></Dialog>{notice && <Toast tone="info" onDismiss={() => setNotice("")}>{notice}</Toast>}</>;
const [activeServerId, setActiveServerId] = useState(initialWorkspaces[0].id);
const [activeTabs, setActiveTabs] = useState<Record<string, string>>(
Object.fromEntries(initialWorkspaces.map((server) => [server.id, server.activeTabId])),
);
const server = useMemo(
() => selectWorkspace(initialWorkspaces, activeServerId) ?? initialWorkspaces[0],
[activeServerId],
);
const activeTab = server.tabs.find((tab) => tab.id === activeTabs[server.id]) ?? server.tabs[0];
return (
<main className="shell">
<aside className="server-rail" aria-label="Teamserver 列表">
<div className="brand">G</div>
{initialWorkspaces.map((item) => (
<button
className={`server-button ${item.id === server.id ? "active" : ""}`}
key={item.id}
onClick={() => setActiveServerId(item.id)}
aria-label={`切换到 ${item.name}`}
title={item.name}
>
{item.shortName}
<span className={`presence ${item.connection.toLowerCase()}`} />
{item.unread > 0 && <span className="badge">{item.unread}</span>}
</button>
))}
<button className="server-button add" aria-label="添加 Teamserver">+</button>
</aside>
<aside className="project-sidebar">
<header>
<p className="eyebrow">TEAMSERVER</p>
<h1>{server.name}</h1>
<span className={`connection ${server.connection.toLowerCase()}`}>{server.connection}</span>
</header>
<label className="search">
<span></span>
<input aria-label="搜索" placeholder="搜索" />
<kbd> K</kbd>
</label>
<nav aria-label="项目导航">
<a> <strong>{server.unread}</strong></a>
<p className="nav-heading"> · {server.activeProject}</p>
{['Overview', 'Sessions', 'Tasks', 'Events', 'Artifacts', 'Audit'].map((item) => (
<a className={item === activeTab.title ? "selected" : ""} key={item}>{item}</a>
))}
</nav>
<p className="memory-note"> · 退</p>
</aside>
<section className="workspace">
<div className="tabbar" role="tablist" aria-label={`${server.name} 标签`}>
{server.tabs.map((tab) => (
<button
role="tab"
aria-selected={tab.id === activeTab.id}
className={tab.id === activeTab.id ? "active" : ""}
key={tab.id}
onClick={() => setActiveTabs((tabs) => ({ ...tabs, [server.id]: tab.id }))}
title={`${tab.project} / ${tab.session}`}
>
{tab.running && <span className="running" />}{tab.title}<span className="close">×</span>
</button>
))}
</div>
<div className="contextbar" aria-label="锁定的操作上下文">
<span className="context-lock"> LOCKED</span>{server.name} <i>/</i> {activeTab.project} <i>/</i> <b>{activeTab.session}</b> <i>/</i> {activeTab.title}
</div>
<article className="content">
<div className="content-heading">
<div><h2>{activeTab.title}</h2><p>{activeTab.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…" /></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>
</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>
</main>
);
}

View File

@ -1,90 +0,0 @@
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"], capabilities: ["session.processes", "session.files"] },
{ id: "S-2", status: "OFFLINE", host: "beta", user: "two", os: "Windows", address: "10.0.0.2", seen: "2m", tags: ["b"] },
];
function Harness({ onIntent = () => undefined, onOpenSession, onOpenOperation }: { onIntent?: (intent: SessionQueryIntent) => void; onOpenSession?: (session: SessionRow) => void; onOpenOperation?: (session: SessionRow, operation: "processes" | "files" | "operation") => 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} onOpenSession={onOpenSession} onOpenOperation={onOpenOperation} />;
}
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(second).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("已选择 1 项")).toBeInTheDocument();
});
it("采用 Swing 风格的单选、修饰键多选与范围选择", () => {
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 });
expect(screen.getByText("已选择 2 项")).toBeInTheDocument();
expect(screen.getByText("12 / 200")).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /选择 (当前页|S-)/ })).not.toBeInTheDocument();
expect(first).toHaveClass("session-data-row");
});
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();
});
it("双击或按 Enter 打开当前 Session", () => {
const onOpenSession = vi.fn();
render(<Harness onOpenSession={onOpenSession} />);
const first = screen.getByRole("row", { name: /S-1/ });
fireEvent.doubleClick(first);
first.focus();
fireEvent.keyDown(first, { key: "Enter" });
expect(onOpenSession).toHaveBeenNthCalledWith(1, rows[0]);
expect(onOpenSession).toHaveBeenNthCalledWith(2, rows[0]);
});
it("右键按桌面语义选择并按 Capability 显示操作", () => {
const onOpenOperation = vi.fn(); render(<Harness onOpenOperation={onOpenOperation} />);
fireEvent.contextMenu(screen.getByRole("row", { name: /S-1/ }), { clientX: 20, clientY: 20 });
expect(screen.getByRole("row", { name: /S-1/ })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("menuitem", { name: "查看进程" }));
expect(onOpenOperation).toHaveBeenCalledWith(rows[0], "processes");
fireEvent.contextMenu(screen.getByRole("row", { name: /S-2/ }));
expect(screen.queryByRole("menuitem", { name: "查看进程" })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "浏览文件" })).not.toBeInTheDocument();
});
it("右键菜单支持方向键、Home、End 与 Escape 焦点恢复", () => {
render(<Harness />); const row = screen.getByRole("row", { name: /S-1/ }); row.focus(); fireEvent.contextMenu(row);
const menu = screen.getByRole("menu"); fireEvent.keyDown(menu, { key: "End" }); expect(screen.getByRole("menuitem", { name: "复制 Session ID" })).toHaveFocus();
fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(row).toHaveFocus();
});
});

View File

@ -1,175 +0,0 @@
import { CSSProperties, KeyboardEvent, MouseEvent, useEffect, useRef, useState } 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[];
capabilities?: 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[];
selectionAnchorId?: 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;
onOpenSession?: (session: SessionRow) => void;
onOpenOperation?: (session: SessionRow, operation: "processes" | "files" | "operation") => 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";
}
type SessionMenuState = { session: SessionRow; x: number; y: number; returnFocus: HTMLElement | null };
export function SessionTable({ rows, state, capabilities, page, onStateChange, onQueryIntent, onOpenSession, onOpenOperation }: SessionTableProps) {
const rowRefs = useRef(new Map<string, HTMLDivElement>());
const menuRef = useRef<HTMLDivElement>(null);
const [menu, setMenu] = useState<SessionMenuState>();
const selected = new Set(state.selectedSessionIds);
const visible = new Set(state.visibleColumns);
useEffect(() => { if (!menu) return; menuRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus(); const close = (event: globalThis.KeyboardEvent | PointerEvent) => { if (event.type === "keydown" && (event as globalThis.KeyboardEvent).key !== "Escape") return; if (event.type === "pointerdown" && menuRef.current?.contains(event.target as Node)) return; setMenu(undefined); menu.returnFocus?.focus(); }; document.addEventListener("keydown", close); document.addEventListener("pointerdown", close); return () => { document.removeEventListener("keydown", close); document.removeEventListener("pointerdown", close); }; }, [menu]);
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 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 openMenu = (event: MouseEvent<HTMLDivElement>, index: number, session: SessionRow) => { event.preventDefault(); if (!selected.has(session.id)) selectRow(session.id, index, { additive: false, range: false }); setMenu({ session, x: Math.min(event.clientX, window.innerWidth - 230), y: Math.min(event.clientY, window.innerHeight - 270), returnFocus: event.currentTarget }); };
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 === "Enter" && event.target === event.currentTarget) {
event.preventDefault();
const session = rows[index]; if (session) onOpenSession?.(session);
}
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();
selectRow(id, index, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey });
}
};
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"><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">
{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-data-row ${session.id === state.focusedSessionId ? "focused" : ""}`} role="row" aria-selected={selected.has(session.id)} aria-label={`${session.id},双击或按 Enter 打开详情`} 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)} onContextMenu={(event) => openMenu(event, index, session)} onDoubleClick={() => onOpenSession?.(session)} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}>
{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>}
{visible.has("os") && <span role="cell">{session.os}</span>}
{visible.has("address") && <span role="cell" className="mono muted">{session.address}</span>}
{visible.has("seen") && <span role="cell" className="mono muted">{session.seen}</span>}
{visible.has("tags") && <span role="cell" className="tags">{session.tags.map((tag) => <small key={tag}>{tag}</small>)}</span>}
<span role="cell"><button className="row-action" aria-label={`${session.id} 快速操作`}></button></span>
</div>)}
</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>{menu && <div ref={menuRef} className="session-context-menu" role="menu" aria-label={`${menu.session.id} 操作`} style={{ left: menu.x, top: menu.y }} onKeyDown={(event) => { const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')]; const current = items.indexOf(document.activeElement as HTMLButtonElement); const index = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : event.key === "ArrowDown" ? (current + 1) % items.length : event.key === "ArrowUp" ? (current - 1 + items.length) % items.length : -1; if (index >= 0) { event.preventDefault(); items[index]?.focus(); } }}>
<button role="menuitem" onClick={() => { onOpenSession?.(menu.session); setMenu(undefined); }}> Overview</button>
{menu.session.capabilities?.includes("session.processes") ? <button role="menuitem" onClick={() => { onOpenOperation?.(menu.session, "processes"); setMenu(undefined); }}></button> : null}
{menu.session.capabilities?.includes("session.files") ? <button role="menuitem" onClick={() => { onOpenOperation?.(menu.session, "files"); setMenu(undefined); }}></button> : null}
<button role="menuitem" onClick={() => { onOpenOperation?.(menu.session, "operation"); setMenu(undefined); }}></button>
<button role="menuitem" title="需要 Teamserver 分组 adapter" disabled></button>
<button role="menuitem" onClick={() => { void navigator.clipboard?.writeText(menu.session.id); setMenu(undefined); }}> Session ID</button>
</div>}
</>
);
}

View File

@ -1,15 +0,0 @@
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

@ -1,20 +0,0 @@
.activity-status { display:inline-flex; align-items:center; gap:6px; font-size:12px; white-space:nowrap }
.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--succeeded { color:#277a4b }.activity-status--failed,.activity-status--cancelled { color:#a23d3d }
.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 var(--border); background:#15191f; text-align:left; color:inherit }
.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-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-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:#0f1115; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace }
.activity-danger { color:#a12323; border-color:#cf7777 }
.activity-dialog-backdrop { position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:#080a0dcc }.activity-dialog { width:min(520px,calc(100vw - 32px));box-sizing:border-box;background:#171b21;border:1px solid #454e5a;border-radius:6px;padding:20px;box-shadow:0 18px 60px #0008 }.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-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 #5d4b29;border-radius:5px;background:#292316;color:#e5c777 }
.activity-detail button,.activity-dialog button,.activity-output button,.activity-events button,.activity-banner button{font:inherit;cursor:pointer;border:1px solid var(--border);border-radius:4px;padding:5px 9px;background:#1b2027;color:var(--text)}.activity-detail button:disabled,.activity-dialog button:disabled,.activity-output button:disabled,.activity-events 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 (prefers-reduced-motion:reduce) { .activity-event--new { animation:none } }

View File

@ -1,35 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { CapabilityId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model";
import { CancelTaskDialog, ReconnectBanner, TaskOutput } from "./components";
const task: Task = {
server_id: "server-a" as ServerId, project_id: "project-a" as ProjectId, session_id: "session-a" as SessionId,
task_id: "task-a" as TaskId, capability_id: "operate" as CapabilityId, state: "RUNNING", cancellable: true,
created_at: "2026-01-01T00:00:00Z" as Timestamp, updated_at: "2026-01-01T00:00:01Z" as Timestamp,
};
describe("activity components", () => {
it("取消确认展示固定上下文并要求显式确认", () => {
const confirm = vi.fn();
render(<CancelTaskDialog task={task} open onConfirm={confirm} onClose={vi.fn()} />);
expect(screen.getByText("server-a")).toBeInTheDocument();
expect(screen.getByText("project-a")).toBeInTheDocument();
expect(screen.getByText("session-a")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "确认取消" }));
expect(confirm).toHaveBeenCalledWith(task);
});
it("输出支持暂停和恢复自动跟随且显示脱敏状态", () => {
const changed = vi.fn();
render(<TaskOutput follow entries={[{ id: "1", task_id: task.task_id, timestamp: task.updated_at, text: "token=[已隐藏]", redacted: true }]} onFollowChange={changed} onCopy={vi.fn()} />);
expect(screen.getAllByText(/已脱敏/).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "暂停跟随" }));
expect(changed).toHaveBeenCalledWith(false);
});
it("恢复缺口使用告警语义呈现", () => {
render(<ReconnectBanner phase="streaming" gap={{ message: "检测到事件缺口" }} />);
expect(screen.getByRole("alert")).toHaveTextContent("检测到事件缺口");
});
});

View File

@ -1,63 +0,0 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import type { Event, Task } from "../../protocol/model";
import { canCancelTask, taskDuration } from "./state";
import type { EventFiltersValue, ResumeGap, StreamPhase, TaskOutputEntry } from "./types";
import "./activity.css";
const taskLabels: Readonly<Record<string, string>> = {
PENDING: "等待中", RUNNING: "运行中", SUCCEEDED: "已成功", FAILED: "失败", CANCELLED: "已取消",
};
export function TaskStatus({ state }: { readonly state: Task["state"] }) {
return <span className={`activity-status activity-status--${state.toLowerCase()}`}><span aria-hidden="true" className="activity-status__mark" />{taskLabels[state] ?? state}</span>;
}
export function TaskList({ tasks, selectedTaskId, onSelect, loading = false }: { readonly tasks: readonly Task[]; readonly selectedTaskId?: string; readonly onSelect?: (task: Task) => void; readonly loading?: boolean }) {
if (loading) return <div className="activity-empty" role="status"></div>;
if (!tasks.length) return <div className="activity-empty"></div>;
return <div className="activity-list" aria-label="任务列表">{tasks.map((task) => <button key={task.task_id} className="activity-list__row" aria-pressed={task.task_id === selectedTaskId} onClick={() => onSelect?.(task)}><span>{task.capability_id}</span><TaskStatus state={task.state} /><time>{taskDuration(task)}</time></button>)}</div>;
}
export function TaskTimeline({ task }: { readonly task: Task }) {
return <ol className="activity-timeline"><li><strong></strong><time>{task.created_at}</time></li>{task.state !== "PENDING" && <li><strong>{taskLabels[task.state] ?? task.state}</strong><time>{task.updated_at}</time></li>}</ol>;
}
export function TaskOutput({ entries, follow, onFollowChange, onCopy }: { readonly entries: readonly TaskOutputEntry[]; readonly follow: boolean; readonly onFollowChange?: (follow: boolean) => void; readonly onCopy?: (text: string) => void }) {
const outputRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (follow && outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight; }, [entries, follow]);
const text = entries.map((entry) => entry.text).join("\n");
return <section className="activity-output" aria-label="任务输出"><header><span>{entries.some((entry) => entry.redacted) ? "已脱敏输出" : "标准输出"}</span><button onClick={() => (onCopy ? onCopy(text) : void navigator.clipboard?.writeText(text))}></button><button aria-pressed={follow} onClick={() => onFollowChange?.(!follow)}>{follow ? "暂停跟随" : "继续跟随"}</button></header><div ref={outputRef} className="activity-output__body" tabIndex={0}>{entries.length ? entries.map((entry) => <div key={entry.id}><time>{entry.timestamp}</time> {entry.text}{entry.redacted && <span className="activity-redacted"> </span>}</div>) : <span></span>}</div></section>;
}
export function TaskDetail({ task, outputs = [], followOutput = true, onFollowOutputChange, onCancel }: { readonly task?: Task; readonly outputs?: readonly TaskOutputEntry[]; readonly followOutput?: boolean; readonly onFollowOutputChange?: (follow: boolean) => void; readonly onCancel?: (task: Task) => void }) {
if (!task) return <div className="activity-empty"></div>;
return <article className="activity-detail"><header><div><h2>{task.capability_id}</h2><small>{task.task_id}</small></div><TaskStatus state={task.state} /></header><dl><dt></dt><dd>{taskDuration(task)}</dd><dt></dt><dd>{task.server_id} / {task.project_id} / {task.session_id}</dd></dl>{task.result !== undefined && <pre className="activity-result">{JSON.stringify(task.result, null, 2)}</pre>}<TaskTimeline task={task} /><TaskOutput entries={outputs} follow={followOutput} onFollowChange={onFollowOutputChange} /><button className="activity-danger" disabled={!canCancelTask(task)} onClick={() => onCancel?.(task)}></button></article>;
}
export function CancelTaskDialog({ task, open, busy = false, error, onConfirm, onClose }: { readonly task?: Task; readonly open: boolean; readonly busy?: boolean; readonly error?: string; readonly onConfirm: (task: Task) => void; readonly onClose: () => void }) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => { if (open) cancelRef.current?.focus(); }, [open]);
if (!open || !task) return null;
return <div className="activity-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><div role="dialog" aria-modal="true" aria-labelledby="cancel-title" className="activity-dialog" onKeyDown={(event) => { if (event.key === "Escape") onClose(); }}><h2 id="cancel-title"></h2><p></p><dl><dt>Teamserver</dt><dd>{task.server_id}</dd><dt>Project</dt><dd>{task.project_id}</dd><dt>Session</dt><dd>{task.session_id}</dd><dt>Task</dt><dd>{task.task_id}</dd></dl>{error && <p role="alert">{error}</p>}<footer><button ref={cancelRef} onClick={onClose}></button><button className="activity-danger" disabled={busy || !canCancelTask(task)} onClick={() => onConfirm(task)}>{busy ? "正在取消…" : "确认取消"}</button></footer></div></div>;
}
export function EventFilters({ value, knownTypes, onChange }: { readonly value: EventFiltersValue; readonly knownTypes: readonly string[]; readonly onChange: (value: EventFiltersValue) => void }) {
return <form className="activity-filters" onSubmit={(event: FormEvent) => event.preventDefault()}><label><select multiple value={[...value.types]} onChange={(event) => onChange({ ...value, types: Array.from(event.currentTarget.selectedOptions, (option) => option.value) })}>{knownTypes.map((type) => <option key={type}>{type}</option>)}</select></label><label><input type="datetime-local" onChange={(event) => onChange({ ...value, from: event.currentTarget.value ? `${event.currentTarget.value}:00.000Z` as EventFiltersValue["from"] : undefined })} /></label><button type="button" onClick={() => onChange({ types: [] })}></button></form>;
}
export function EventStream({ events, follow, onFollowChange, onSelect, loading = false }: { readonly events: readonly Event[]; readonly follow: boolean; readonly onFollowChange?: (follow: boolean) => void; readonly onSelect?: (event: Event) => void; readonly loading?: boolean }) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (follow && endRef.current?.parentElement) endRef.current.parentElement.scrollTop = endRef.current.parentElement.scrollHeight; }, [events, follow]);
return <section className="activity-events"><header><strong></strong><button aria-pressed={follow} onClick={() => onFollowChange?.(!follow)}>{follow ? "暂停跟随" : "继续跟随"}</button></header>{loading ? <div className="activity-empty"></div> : !events.length ? <div className="activity-empty"></div> : <div className="activity-events__feed">{events.map((event, index) => <button className={index === events.length - 1 ? "activity-event activity-event--new" : "activity-event"} key={event.event_id} onClick={() => onSelect?.(event)}><time>{event.timestamp}</time><strong>{event.type}</strong><span> {event.sequence ?? "—"} · {event.cursor}</span></button>)}<div ref={endRef} /></div>}</section>;
}
export function EventDetail({ event }: { readonly event?: Event }) {
if (!event) return <div className="activity-empty"></div>;
return <article className="activity-detail"><header><h2>{event.type}</h2><time>{event.timestamp}</time></header><dl><dt> ID</dt><dd>{event.event_id}</dd><dt> / </dt><dd>{event.sequence ?? "—"} / {event.cursor}</dd><dt></dt><dd>{event.context.server_id} / {event.context.project_id ?? "—"} / {event.context.session_id ?? "—"}</dd></dl><pre className="activity-result">{JSON.stringify(event.payload, null, 2)}</pre></article>;
}
export function ReconnectBanner({ phase, gap, onReconnect }: { readonly phase: StreamPhase; readonly gap?: ResumeGap; readonly onReconnect?: () => void }) {
if (["idle", "streaming", "closed"].includes(phase) && !gap) return null;
const messages: Readonly<Record<string, string>> = { loading: "正在建立事件流…", degraded: "事件流已降级,任务仍在服务端继续运行。", reconnecting: "连接中断,正在从最近游标恢复…", recovery_failed: "无法恢复事件流,请重新同步。" };
return <div role={phase === "recovery_failed" || gap ? "alert" : "status"} className="activity-banner"><span>{gap?.message ?? messages[phase] ?? phase}</span>{(phase === "recovery_failed" || phase === "degraded") && <button onClick={onReconnect}></button>}</div>;
}

View File

@ -1,4 +0,0 @@
export * from "./types";
export * from "./state";
export * from "./memoryAdapter";
export * from "./components";

View File

@ -1,30 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { CapabilityId, Cursor, Event, EventId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model";
import { MemoryActivityAdapter } from "./memoryAdapter";
const scope = { server_id: "s" as ServerId, project_id: "p" as ProjectId, session_id: "x" as SessionId };
const event: Event = { event_id: "e" as EventId, type: "TASK_UPDATED", timestamp: "2026-01-01T00:00:00Z" as Timestamp, cursor: "current" as Cursor, context: scope, payload: {} };
const task: Task = { ...scope, task_id: "t" as TaskId, capability_id: "c" as CapabilityId, state: "RUNNING", cancellable: true, created_at: event.timestamp, updated_at: event.timestamp };
describe("MemoryActivityAdapter", () => {
it("从有效游标之后恢复标准事件", async () => {
const received = vi.fn();
new MemoryActivityAdapter([event]).subscribe(scope, undefined, received, vi.fn());
await Promise.resolve();
expect(received).toHaveBeenCalledWith(event);
});
it("游标失效时报告缺口但继续恢复", async () => {
const phase = vi.fn();
new MemoryActivityAdapter([event]).subscribe(scope, "expired" as Cursor, vi.fn(), phase);
await Promise.resolve();
expect(phase).toHaveBeenCalledWith("streaming", expect.objectContaining({ requested_cursor: "expired", resumed_cursor: "current" }));
});
it("断开订阅不会取消任务,取消必须显式调用", async () => {
const adapter = new MemoryActivityAdapter([event], [task]);
adapter.subscribe(scope, undefined, vi.fn(), vi.fn()).close();
await expect(adapter.cancelTask(task)).resolves.toMatchObject({ state: "CANCELLED", cancellable: false });
await expect(adapter.cancelTask({ ...task, state: "SUCCEEDED" })).rejects.toThrow("不可取消");
});
});

View File

@ -1,34 +0,0 @@
import type { Cursor, Event, Task, Timestamp } from "../../protocol/model";
import type { ActivityAdapter, ActivityScope, ActivitySubscription, ResumeGap, StreamPhase } from "./types";
export class MemoryActivityAdapter implements ActivityAdapter {
readonly #events: readonly Event[];
readonly #tasks = new Map<string, Task>();
constructor(events: readonly Event[] = [], tasks: readonly Task[] = []) {
this.#events = events;
tasks.forEach((task) => this.#tasks.set(task.task_id, task));
}
subscribe(scope: ActivityScope, cursor: Cursor | undefined, onEvent: (event: Event) => void, onPhase: (phase: StreamPhase, gap?: ResumeGap) => void): ActivitySubscription {
let closed = false;
const scoped = this.#events.filter((event) => event.context.server_id === scope.server_id && event.context.project_id === scope.project_id);
const cursorIndex = cursor ? scoped.findIndex((event) => event.cursor === cursor) : -1;
const gap = cursor && cursorIndex < 0 ? { requested_cursor: cursor, resumed_cursor: scoped[0]?.cursor, message: "事件游标已失效,期间事件可能缺失。" } : undefined;
queueMicrotask(() => {
if (closed) return;
onPhase("streaming", gap);
scoped.slice(cursorIndex + 1).forEach((event) => { if (!closed) onEvent(event); });
});
return { close: () => { closed = true; } };
}
async cancelTask(task: Task): Promise<Task> {
if (!task.cancellable || ["SUCCEEDED", "FAILED", "CANCELLED"].includes(task.state)) {
throw new Error("该任务当前不可取消");
}
const cancelled = { ...task, state: "CANCELLED" as const, cancellable: false, updated_at: new Date().toISOString() as Timestamp };
this.#tasks.set(task.task_id, cancelled);
return cancelled;
}
}

View File

@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import type { CapabilityId, Cursor, EventId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model";
import { activityReducer, canCancelTask, filterEvents, initialActivityState, isTaskTransitionAllowed } from "./state";
const ids = { server_id: "server-1" as ServerId, project_id: "project-1" as ProjectId, session_id: "session-1" as SessionId };
const task = (state: Task["state"], cancellable = true): Task => ({ ...ids, task_id: "task-1" as TaskId, capability_id: "shell" as CapabilityId, state, cancellable, created_at: "2026-01-01T00:00:00Z" as Timestamp, updated_at: "2026-01-01T00:00:01Z" as Timestamp });
const event = (id: string, cursor: string, type = "TASK_UPDATED") => ({ event_id: id as EventId, type, timestamp: "2026-01-01T00:00:00Z" as Timestamp, cursor: cursor as Cursor, sequence: 1, context: ids, payload: {} });
describe("activity state", () => {
it("仅接受合法任务状态转换并保护终态", () => {
expect(isTaskTransitionAllowed("PENDING", "RUNNING")).toBe(true);
expect(isTaskTransitionAllowed("SUCCEEDED", "RUNNING")).toBe(false);
const finished = activityReducer(initialActivityState, { type: "task_received", task: task("SUCCEEDED") });
expect(activityReducer(finished, { type: "task_received", task: task("RUNNING") })).toBe(finished);
});
it("只允许可取消的非终态任务取消", () => {
expect(canCancelTask(task("RUNNING"))).toBe(true);
expect(canCancelTask(task("RUNNING", false))).toBe(false);
expect(canCancelTask(task("FAILED"))).toBe(false);
});
it("记录最新事件游标、去重并保留恢复缺口", () => {
const once = activityReducer(initialActivityState, { type: "event_received", event: event("e1", "c1") });
expect(activityReducer(once, { type: "event_received", event: event("e1", "c1") }).events).toHaveLength(1);
const resumed = activityReducer(once, { type: "resume_completed", cursor: "c3" as Cursor, gap: { requested_cursor: "c1" as Cursor, resumed_cursor: "c3" as Cursor, message: "缺口" } });
expect(resumed).toMatchObject({ stream_phase: "streaming", last_cursor: "c3", gap: { message: "缺口" } });
});
it("按类型和固定上下文筛选事件", () => {
expect(filterEvents([event("e1", "c1"), event("e2", "c2", "TASK_OUTPUT")], { types: ["TASK_OUTPUT"], project_id: ids.project_id })).toHaveLength(1);
});
});

View File

@ -1,66 +0,0 @@
import type { Event, Task, TaskState } from "../../protocol/model";
import type { ActivityAction, ActivityState, EventFiltersValue } from "./types";
export const initialActivityState: ActivityState = {
tasks: [], outputs: {}, events: [], stream_phase: "idle",
follow_events: true, follow_output: true,
};
export const terminalTaskStates = new Set<string>(["SUCCEEDED", "FAILED", "CANCELLED"]);
export function canCancelTask(task: Task): boolean {
return task.cancellable && !terminalTaskStates.has(task.state);
}
export function isTaskTransitionAllowed(from: TaskState, to: TaskState): boolean {
if (from === to) return true;
if (terminalTaskStates.has(from)) return false;
const allowed: Readonly<Record<string, readonly string[]>> = {
PENDING: ["RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"],
RUNNING: ["SUCCEEDED", "FAILED", "CANCELLED"],
};
return allowed[from]?.includes(to) ?? true;
}
export function activityReducer(state: ActivityState, action: ActivityAction): ActivityState {
switch (action.type) {
case "task_received": {
const previous = state.tasks.find((task) => task.task_id === action.task.task_id);
if (previous && !isTaskTransitionAllowed(previous.state, action.task.state)) return state;
return { ...state, tasks: [...state.tasks.filter((task) => task.task_id !== action.task.task_id), action.task] };
}
case "task_output_received": {
const key = action.output.task_id as string;
return { ...state, outputs: { ...state.outputs, [key]: [...(state.outputs[key] ?? []), action.output] } };
}
case "event_received":
if (state.events.some((event) => event.event_id === action.event.event_id)) return state;
return { ...state, events: [...state.events, action.event], last_cursor: action.event.cursor };
case "stream_phase_changed":
return { ...state, stream_phase: action.phase };
case "resume_completed":
return { ...state, stream_phase: "streaming", last_cursor: action.cursor, gap: action.gap };
case "select_task": return { ...state, selected_task_id: action.task_id };
case "select_event": return { ...state, selected_event_id: action.event_id };
case "set_follow_events": return { ...state, follow_events: action.enabled };
case "set_follow_output": return { ...state, follow_output: action.enabled };
}
}
export function filterEvents(events: readonly Event[], filters: EventFiltersValue): readonly Event[] {
return events.filter((event) =>
(!filters.types.length || filters.types.includes(event.type)) &&
(!filters.project_id || event.context.project_id === filters.project_id) &&
(!filters.session_id || event.context.session_id === filters.session_id) &&
(!filters.from || event.timestamp >= filters.from) &&
(!filters.to || event.timestamp <= filters.to)
);
}
export function taskDuration(task: Task, now = Date.now()): string {
const start = Date.parse(task.created_at);
const end = terminalTaskStates.has(task.state) ? Date.parse(task.updated_at) : now;
if (!Number.isFinite(start) || !Number.isFinite(end)) return "未知";
const seconds = Math.max(0, Math.floor((end - start) / 1000));
return seconds < 60 ? `${seconds}` : `${Math.floor(seconds / 60)}${seconds % 60}`;
}

View File

@ -1,74 +0,0 @@
import type {
Cursor, Event, EventContext, EventId, ProjectId, ServerId, SessionId,
Task, TaskId, Timestamp,
} from "../../protocol/model";
export interface TaskOutputEntry {
readonly id: string;
readonly task_id: TaskId;
readonly timestamp: Timestamp;
readonly text: string;
readonly redacted: boolean;
}
export interface EventFiltersValue {
readonly types: readonly string[];
readonly project_id?: ProjectId;
readonly session_id?: SessionId;
readonly from?: Timestamp;
readonly to?: Timestamp;
}
export type StreamPhase = "idle" | "loading" | "streaming" | "degraded" | "reconnecting" | "recovery_failed" | "closed";
export interface ResumeGap {
readonly requested_cursor?: Cursor;
readonly resumed_cursor?: Cursor;
readonly message: string;
}
export interface ActivityState {
readonly tasks: readonly Task[];
readonly outputs: Readonly<Record<string, readonly TaskOutputEntry[]>>;
readonly events: readonly Event[];
readonly selected_task_id?: TaskId;
readonly selected_event_id?: EventId;
readonly stream_phase: StreamPhase;
readonly last_cursor?: Cursor;
readonly gap?: ResumeGap;
readonly follow_events: boolean;
readonly follow_output: boolean;
}
export type ActivityAction =
| { readonly type: "task_received"; readonly task: Task }
| { readonly type: "task_output_received"; readonly output: TaskOutputEntry }
| { readonly type: "event_received"; readonly event: Event }
| { readonly type: "stream_phase_changed"; readonly phase: StreamPhase }
| { readonly type: "resume_completed"; readonly cursor: Cursor; readonly gap?: ResumeGap }
| { readonly type: "select_task"; readonly task_id?: TaskId }
| { readonly type: "select_event"; readonly event_id?: EventId }
| { readonly type: "set_follow_events"; readonly enabled: boolean }
| { readonly type: "set_follow_output"; readonly enabled: boolean };
export interface ActivityScope {
readonly server_id: ServerId;
readonly project_id: ProjectId;
readonly session_id?: SessionId;
}
export interface ActivitySubscription {
close(): void;
}
export interface ActivityAdapter {
subscribe(scope: ActivityScope, cursor: Cursor | undefined, onEvent: (event: Event) => void, onPhase: (phase: StreamPhase, gap?: ResumeGap) => void): ActivitySubscription;
cancelTask(task: Task): Promise<Task>;
}
export function activityEvent(input: {
event_id: EventId; cursor: Cursor; timestamp: Timestamp; sequence: number;
type: Event["type"]; context: EventContext; payload: unknown;
}): Event {
return input;
}

View File

@ -1,23 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ConnectionCenter, ConnectionEditor } from "./components";
import { MemoryMockConnectionAdapter } from "./mockAdapter";
describe("连接中心组件", () => {
it("表单拒绝无效配置,秘密字段使用 password 类型", () => {
render(<ConnectionEditor onSave={() => undefined} onCancel={() => undefined} />);
expect(screen.getByLabelText("认证秘密")).toHaveAttribute("type", "password");
fireEvent.click(screen.getByRole("button", { name: "保存到内存" }));
expect(screen.getByRole("alert")).toHaveTextContent("配置错误");
});
it("通过 mock adapter 完成创建并明确未建立网络连接", async () => {
render(<ConnectionCenter adapter={new MemoryMockConnectionAdapter()} />);
fireEvent.click(await screen.findByRole("button", { name: " 新建连接" }));
fireEvent.change(screen.getByLabelText("名称"), { target: { value: "Atlas" } });
fireEvent.change(screen.getByLabelText("地址"), { target: { value: "https://atlas.example" } });
fireEvent.click(screen.getByRole("button", { name: "保存到内存" }));
await waitFor(() => expect(screen.getByText("Atlas")).toBeInTheDocument());
expect(screen.getByText(/尚未建立网络连接/)).toBeInTheDocument();
});
});

View File

@ -1,112 +0,0 @@
import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
import { Button } from "../../ui";
import { ConnectionAdapter } from "./mockAdapter";
import { blankConnection, ConnectionConfig, ConnectionRecord, connectionStates, createConnectionId, DiagnosticCategory, DiagnosticResult, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
const field = (config: ConnectionConfig, onChange: (next: ConnectionConfig) => void) =>
<K extends keyof ConnectionConfig>(key: K, value: ConnectionConfig[K]) => onChange({ ...config, [key]: value });
function Section({ title, children }: { title: string; children: ReactNode }) {
return <fieldset className="connection-section"><legend>{title}</legend>{children}</fieldset>;
}
export function TlsSettings({ config, onChange, developmentMode }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void; developmentMode: boolean }) {
const set = (value: Partial<ConnectionConfig["tls"]>) => onChange({ ...config, tls: { ...config.tls, ...value } });
return <Section title="TLS / mTLS">
<label className="check"><input type="checkbox" checked={config.tls.enabled} onChange={(e) => set({ enabled: e.target.checked })} /> TLS</label>
<label> CA<textarea value={config.tls.customCa} onChange={(e) => set({ customCa: e.target.value })} placeholder="PEM 内容(仅保存在内存)" /></label>
<label><textarea value={config.tls.clientCertificate} onChange={(e) => set({ clientCertificate: e.target.value })} /></label>
<label><input type="password" autoComplete="off" value={config.tls.clientKey} onChange={(e) => set({ clientKey: e.target.value })} /></label>
<label><input value={config.tls.fingerprint} onChange={(e) => set({ fingerprint: e.target.value })} placeholder="sha256:…" /></label>
<label>SNI<input value={config.tls.sni} onChange={(e) => set({ sni: e.target.value })} /></label>
{developmentMode && <label className="check danger"><input type="checkbox" checked={config.tls.skipVerification} onChange={(e) => set({ skipVerification: e.target.checked })} /></label>}
</Section>;
}
export function AuthSettings({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
const set = (value: Partial<ConnectionConfig["auth"]>) => onChange({ ...config, auth: { ...config.auth, ...value } });
return <Section title="认证">
<label><select value={config.auth.method} onChange={(e) => set({ method: e.target.value as ConnectionConfig["auth"]["method"] })}><option value="token">Token</option><option value="password"> / </option><option value="mtls"> mTLS</option></select></label>
{config.auth.method === "password" && <label><input value={config.auth.username} onChange={(e) => set({ username: e.target.value })} /></label>}
{config.auth.method !== "mtls" && <label><input aria-label="认证秘密" type="password" autoComplete="new-password" value={config.auth.secret} onChange={(e) => set({ secret: e.target.value })} placeholder={maskedSecret(config.auth.secret)} /></label>}
</Section>;
}
export function ProxySettings({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
const set = (value: Partial<ConnectionConfig["proxy"]>) => onChange({ ...config, proxy: { ...config.proxy, ...value } });
return <Section title="代理">
<label className="check"><input type="checkbox" checked={config.proxy.enabled} onChange={(e) => set({ enabled: e.target.checked })} />使</label>
{config.proxy.enabled && <><label> URL<input value={config.proxy.url} onChange={(e) => set({ url: e.target.value })} placeholder="http://proxy.local:8080" /></label><label><input value={config.proxy.username} onChange={(e) => set({ username: e.target.value })} /></label><label><input type="password" autoComplete="new-password" value={config.proxy.password} onChange={(e) => set({ password: e.target.value })} /></label></>}
</Section>;
}
export function ReconnectPolicy({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
const set = (value: Partial<ConnectionConfig["reconnect"]>) => onChange({ ...config, reconnect: { ...config.reconnect, ...value } });
return <Section title="重连策略">
<label className="check"><input type="checkbox" checked={config.reconnect.enabled} onChange={(e) => set({ enabled: e.target.checked })} />退</label>
<label>退<input type="number" value={config.reconnect.initialSeconds} onChange={(e) => set({ initialSeconds: Number(e.target.value) })} /></label>
<label>退<input type="number" value={config.reconnect.maxSeconds} onChange={(e) => set({ maxSeconds: Number(e.target.value) })} /></label>
<label><input type="number" step="0.1" value={config.reconnect.multiplier} onChange={(e) => set({ multiplier: Number(e.target.value) })} /></label>
<label>01<input type="number" step="0.1" value={config.reconnect.jitter} onChange={(e) => set({ jitter: Number(e.target.value) })} /></label>
</Section>;
}
export function ConnectionEditor({ initial, developmentMode = false, existingAddresses = [], onSave, onCancel }: { initial?: ConnectionConfig; developmentMode?: boolean; existingAddresses?: string[]; onSave: (config: ConnectionConfig) => void | Promise<void>; onCancel: () => void }) {
const [config, setConfig] = useState(() => structuredClone(initial ?? blankConnection()));
const [submitted, setSubmitted] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const errors = useMemo(() => { const next = validateConnection(config, developmentMode); if (existingAddresses.some((address) => address === config.address.trim() && config.address.trim())) next.address = "该 Teamserver 地址已存在"; return next; }, [config, developmentMode, existingAddresses]);
const set = field(config, setConfig);
return <form className="connection-editor" onSubmit={async (e) => { e.preventDefault(); setSubmitted(true); setSaveError(""); if (Object.keys(errors).length) return; setSaving(true); try { await onSave(config); } catch (error) { setSaveError(error instanceof Error ? error.message : "保存失败,请重试"); } finally { setSaving(false); } }}>
<div className="editor-grid"><Section title="基本设置">
<label><input value={config.name} onChange={(e) => set("name", e.target.value)} />{submitted && errors.name && <small className="field-error">{errors.name}</small>}</label>
<label><select value={config.transport} onChange={(e) => set("transport", e.target.value as ConnectionConfig["transport"])}><option value="grpc">gRPC</option><option value="websocket">WebSocket</option></select></label>
<label><input value={config.address} onChange={(e) => set("address", e.target.value)} placeholder={config.transport === "grpc" ? "https://teamserver.example" : "wss://teamserver.example/events"} />{submitted && errors.address && <small className="field-error">{errors.address}</small>}</label>
<label><input type="number" value={config.timeoutSeconds} onChange={(e) => set("timeoutSeconds", Number(e.target.value))} /></label>
<label><input type="number" value={config.heartbeatSeconds} onChange={(e) => set("heartbeatSeconds", Number(e.target.value))} /></label>
</Section><TlsSettings config={config} onChange={setConfig} developmentMode={developmentMode} /><AuthSettings config={config} onChange={setConfig} /><ProxySettings config={config} onChange={setConfig} /><ReconnectPolicy config={config} onChange={setConfig} /></div>
{submitted && Object.keys(errors).length > 0 && <p role="alert" className="form-error"> {Object.keys(errors).length} </p>}{saveError && <p role="alert" className="form-error">{saveError}</p>}
<footer><Button type="button" onClick={onCancel} disabled={saving}></Button><Button intent="primary" loading={saving} type="submit"></Button></footer>
</form>;
}
export function ConnectionList({ records, selectedId, onSelect, onCreate, onCopy, onExport, onDelete }: { records: ConnectionRecord[]; selectedId?: string; onSelect: (id: string) => void; onCreate: () => void; onCopy: (record: ConnectionRecord) => void; onExport: (record: ConnectionRecord) => void; onDelete: (record: ConnectionRecord) => void }) {
return <section className="connection-list" aria-label="连接配置列表"><header><div><h2></h2><p>{records.length} · Mock adapter</p></div><button className="primary" onClick={onCreate}> </button></header>
<div className="state-legend" aria-label="连接状态机">{connectionStates.map((state) => <span key={state}>{state}</span>)}</div>
{records.length === 0 ? <div className="empty-state"></div> : records.map((record) => <article key={record.config.id} className={selectedId === record.config.id ? "selected" : ""} onClick={() => onSelect(record.config.id)}>
<div><strong>{record.config.name}</strong><code>{record.config.address}</code></div><span className={`connection-state ${record.state.toLowerCase()}`}>{record.state}</span>
<div className="record-actions"><button onClick={(e) => { e.stopPropagation(); onCopy(record); }}></button><button onClick={(e) => { e.stopPropagation(); onExport(record); }}></button><button className="danger-button" onClick={(e) => { e.stopPropagation(); onDelete(record); }}></button></div>
</article>)}</section>;
}
const categories: DiagnosticCategory[] = ["network", "tls", "authentication", "version", "permission", "rate_limit", "timeout", "message_format", "server"];
export function ConnectionDiagnostics({ record, adapter }: { record: ConnectionRecord; adapter: ConnectionAdapter }) {
const [results, setResults] = useState<DiagnosticResult[]>([]);
return <section className="diagnostics"><h3></h3><p> mock </p><button className="secondary" onClick={async () => setResults(await Promise.all(categories.map((category) => adapter.diagnose(record.config.id, category))))}></button><div>{results.map((result) => <output key={result.category}><b>{result.category}</b><span>{result.summary}</span></output>)}</div></section>;
}
export function DeleteConfirmation({ record, onConfirm, onCancel }: { record: ConnectionRecord; onConfirm: () => void; onCancel: () => void }) {
return <div className="modal-backdrop" role="presentation"><section role="dialog" aria-modal="true" aria-labelledby="delete-title" className="connection-dialog"><h3 id="delete-title">{record.config.name}</h3><p> mock Teamserver</p><footer><button className="secondary" onClick={onCancel}></button><button className="danger-button" onClick={onConfirm}></button></footer></section></div>;
}
export function ConnectionCenter({ adapter, developmentMode = false }: { adapter: ConnectionAdapter; developmentMode?: boolean }) {
const [records, setRecords] = useState<ConnectionRecord[]>([]);
const [editing, setEditing] = useState<ConnectionConfig>();
const [selected, setSelected] = useState<string>();
const [deleting, setDeleting] = useState<ConnectionRecord>();
const [importText, setImportText] = useState("");
const [notice, setNotice] = useState("");
useEffect(() => { void adapter.list().then(setRecords); }, [adapter]);
const refresh = () => adapter.list().then(setRecords);
const selectedRecord = records.find((item) => item.config.id === selected);
const importPreview = () => { try { setEditing(parseImportPreview(importText)); setNotice("导入预览已加载;秘密字段已清空,保存前请确认。"); } catch (error) { setNotice(error instanceof Error ? error.message : "导入失败"); } };
const exportRecord = (record: ConnectionRecord) => { const safe = JSON.stringify(toSafeExport(record.config), null, 2); setNotice(`安全导出预览(不含 Token、密码和客户端私钥\n${safe}`); };
if (editing) return <ConnectionEditor initial={editing} developmentMode={developmentMode} onCancel={() => setEditing(undefined)} onSave={async (config) => { await adapter.save(config); await refresh(); setSelected(config.id); setEditing(undefined); setNotice("配置已保存到内存 mock adapter尚未建立网络连接。"); }} />;
return <div className="connection-center"><ConnectionList records={records} selectedId={selected} onSelect={setSelected} onCreate={() => setEditing(blankConnection())} onCopy={(record) => setEditing({ ...structuredClone(record.config), id: createConnectionId(), name: `${record.config.name} 副本`, auth: { ...record.config.auth, secret: "" }, proxy: { ...record.config.proxy, password: "" }, tls: { ...record.config.tls, clientKey: "" } })} onExport={exportRecord} onDelete={setDeleting} />
<section className="import-panel"><h3></h3><textarea aria-label="导入 JSON" value={importText} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setImportText(e.target.value)} placeholder="粘贴由本客户端安全导出的 JSON" /><button className="secondary" onClick={importPreview}></button></section>
{selectedRecord && <ConnectionDiagnostics record={selectedRecord} adapter={adapter} />}
{notice && <pre className="connection-notice" aria-live="polite">{notice}</pre>}
{deleting && <DeleteConfirmation record={deleting} onCancel={() => setDeleting(undefined)} onConfirm={async () => { await adapter.remove(deleting.config.id); await refresh(); setDeleting(undefined); if (selected === deleting.config.id) setSelected(undefined); }} />}
</div>;
}

View File

@ -1,3 +0,0 @@
export * from "./model";
export * from "./mockAdapter";
export * from "./components";

View File

@ -1,29 +0,0 @@
import { ConnectionConfig, ConnectionRecord, DiagnosticCategory, DiagnosticResult } from "./model";
export interface ConnectionAdapter {
readonly kind: "mock";
list(): Promise<ConnectionRecord[]>;
save(config: ConnectionConfig): Promise<ConnectionRecord>;
remove(id: string): Promise<void>;
diagnose(id: string, category: DiagnosticCategory): Promise<DiagnosticResult>;
}
/** 仅用于安全的内存交互演示;不会发起网络请求,也不会持久化秘密。 */
export class MemoryMockConnectionAdapter implements ConnectionAdapter {
readonly kind = "mock" as const;
private records: ConnectionRecord[];
constructor(seed: ConnectionRecord[] = []) { this.records = structuredClone(seed); }
async list() { return structuredClone(this.records); }
async save(config: ConnectionConfig) {
const record = { config: structuredClone(config), state: "DISCONNECTED" as const };
const index = this.records.findIndex((item) => item.config.id === config.id);
if (index === -1) this.records.push(record); else this.records[index] = record;
return structuredClone(record);
}
async remove(id: string) { this.records = this.records.filter((item) => item.config.id !== id); }
async diagnose(id: string, category: DiagnosticCategory) {
if (!this.records.some((item) => item.config.id === id)) throw new Error("连接不存在");
return { category, ok: true, summary: `Mock 检查:${category} 配置结构有效(未连接真实服务器)` };
}
}

View File

@ -1,46 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { blankConnection, connectionStates, createConnectionId, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
function validConfig() {
const config = blankConnection();
config.name = "Atlas";
config.address = "https://atlas.example";
return config;
}
describe("连接配置安全边界", () => {
it("缺少 crypto.randomUUID 时仍可生成 RFC 4122 格式 ID", () => {
vi.stubGlobal("crypto", { getRandomValues: (bytes: Uint8Array) => { bytes.fill(7); return bytes; } });
expect(createConnectionId()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
vi.unstubAllGlobals();
});
it("验证地址、URL 凭据及开发模式 TLS 约束", () => {
const config = validConfig();
config.address = "https://user:password@atlas.example";
config.tls.skipVerification = true;
expect(validateConnection(config)).toMatchObject({ address: "地址中不得包含凭据", skipVerification: "仅开发模式允许跳过证书校验" });
expect(validateConnection({ ...config, address: "https://atlas.example" }, true)).toEqual({});
});
it("安全导出排除全部秘密并在导入时生成新 ID", () => {
const config = validConfig();
config.auth.secret = "token-value";
config.proxy.password = "proxy-password";
config.tls.clientKey = "private-key";
const json = JSON.stringify(toSafeExport(config));
expect(json).not.toContain("token-value");
expect(json).not.toContain("proxy-password");
expect(json).not.toContain("private-key");
const imported = parseImportPreview(json);
expect(imported.id).not.toBe(config.id);
expect(imported.auth.secret).toBe("");
expect(imported.proxy.password).toBe("");
expect(imported.tls.clientKey).toBe("");
});
it("覆盖完整状态机且脱敏函数不回显原秘密", () => {
expect(connectionStates).toHaveLength(11);
expect(connectionStates).toContain("AUTH_EXPIRED");
expect(maskedSecret("sensitive")).toBe("••••••••");
});
});

View File

@ -1,112 +0,0 @@
export const connectionStates = [
"DISCONNECTED", "CONNECTING", "TLS_HANDSHAKE", "AUTHENTICATING", "NEGOTIATING",
"SYNCHRONIZING", "CONNECTED", "DEGRADED", "RECONNECTING", "AUTH_EXPIRED", "FAILED",
] as const;
export type ConnectionState = (typeof connectionStates)[number];
export type Transport = "grpc" | "websocket";
export type AuthMethod = "token" | "password" | "mtls";
export interface ConnectionConfig {
id: string;
name: string;
address: string;
transport: Transport;
tls: {
enabled: boolean;
customCa: string;
clientCertificate: string;
clientKey: string;
fingerprint: string;
sni: string;
skipVerification: boolean;
};
auth: { method: AuthMethod; username: string; secret: string };
proxy: { enabled: boolean; url: string; username: string; password: string };
timeoutSeconds: number;
heartbeatSeconds: number;
reconnect: { enabled: boolean; initialSeconds: number; maxSeconds: number; multiplier: number; jitter: number };
}
export interface ConnectionRecord { config: ConnectionConfig; state: ConnectionState; }
export type DiagnosticCategory = "network" | "tls" | "authentication" | "version" | "permission" | "rate_limit" | "timeout" | "message_format" | "server";
export interface DiagnosticResult { category: DiagnosticCategory; ok: boolean; summary: string; }
/** 连接配置 ID 不承载安全语义;兼容缺少 randomUUID 的旧 WebView 与非安全 HTTP 上下文。 */
export function createConnectionId(): string {
const webCrypto = globalThis.crypto;
if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
const bytes = new Uint8Array(16);
if (typeof webCrypto?.getRandomValues === "function") webCrypto.getRandomValues(bytes);
else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const value = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
}
export const blankConnection = (): ConnectionConfig => ({
id: createConnectionId(), name: "", address: "", transport: "grpc",
tls: { enabled: true, customCa: "", clientCertificate: "", clientKey: "", fingerprint: "", sni: "", skipVerification: false },
auth: { method: "token", username: "", secret: "" },
proxy: { enabled: false, url: "", username: "", password: "" },
timeoutSeconds: 15, heartbeatSeconds: 30,
reconnect: { enabled: true, initialSeconds: 1, maxSeconds: 30, multiplier: 2, jitter: 0.2 },
});
export function validateConnection(config: ConnectionConfig, developmentMode = false): Record<string, string> {
const errors: Record<string, string> = {};
if (!config.name.trim()) errors.name = "请输入连接名称";
try {
const url = new URL(config.address);
const allowed = config.transport === "grpc" ? ["https:", "http:"] : ["wss:", "ws:"];
if (!allowed.includes(url.protocol)) errors.address = `传输 ${config.transport} 不支持 ${url.protocol}`;
if (url.username || url.password) errors.address = "地址中不得包含凭据";
} catch { errors.address = "请输入包含协议的有效地址"; }
if (config.tls.skipVerification && !developmentMode) errors.skipVerification = "仅开发模式允许跳过证书校验";
if (config.tls.fingerprint && !/^sha256:[a-f0-9]{64}$/i.test(config.tls.fingerprint)) errors.fingerprint = "指纹格式应为 sha256: 后接 64 位十六进制字符";
if (config.timeoutSeconds < 1 || config.timeoutSeconds > 300) errors.timeoutSeconds = "超时范围为 1300 秒";
if (config.heartbeatSeconds < 5 || config.heartbeatSeconds > 3600) errors.heartbeatSeconds = "心跳范围为 53600 秒";
if (config.reconnect.initialSeconds > config.reconnect.maxSeconds) errors.reconnect = "初始退避不能大于最大退避";
if (config.reconnect.jitter < 0 || config.reconnect.jitter > 1) errors.jitter = "抖动范围为 01";
if (config.proxy.enabled) {
try {
const proxy = new URL(config.proxy.url);
if (proxy.username || proxy.password) errors.proxy = "代理 URL 中不得包含凭据";
} catch { errors.proxy = "请输入有效代理 URL"; }
}
return errors;
}
export type SafeConnectionExport = Omit<ConnectionConfig, "auth" | "proxy" | "tls"> & {
auth: Omit<ConnectionConfig["auth"], "secret">;
proxy: Omit<ConnectionConfig["proxy"], "password">;
tls: Omit<ConnectionConfig["tls"], "clientKey">;
exportVersion: 1;
};
export function toSafeExport(config: ConnectionConfig): SafeConnectionExport {
const { secret: _secret, ...auth } = config.auth;
const { password: _password, ...proxy } = config.proxy;
const { clientKey: _clientKey, ...tls } = config.tls;
return { ...config, auth, proxy, tls, exportVersion: 1 };
}
export function parseImportPreview(raw: string): ConnectionConfig {
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("导入内容必须是 JSON 对象");
const input = parsed as Partial<SafeConnectionExport>;
if (input.exportVersion !== 1) throw new Error("不支持的导出版本");
const base = blankConnection();
const imported: ConnectionConfig = {
...base, ...input, id: createConnectionId(),
tls: { ...base.tls, ...input.tls, clientKey: "" },
auth: { ...base.auth, ...input.auth, secret: "" },
proxy: { ...base.proxy, ...input.proxy, password: "" },
};
if (Object.keys(validateConnection(imported)).length) throw new Error("导入配置未通过安全校验");
return imported;
}
export function maskedSecret(secret: string): string { return secret ? "••••••••" : "未设置"; }

View File

@ -1,103 +0,0 @@
import { ReactNode, useEffect, useState } from "react";
export type LoadState = "ready" | "loading" | "empty" | "error";
export interface ArtifactRecord { id: string; name: string; size: number; mimeType: string; sha256: string; createdAt: string; }
export interface ArtifactContent { blob: Blob; }
export interface ArtifactExportTarget { path: string; overwrite: boolean; }
export interface ArtifactSaveAdapter { save(artifact: ArtifactRecord, content: Blob, target: ArtifactExportTarget): Promise<void>; }
export interface ArtifactContentAdapter { load(id: string): Promise<ArtifactContent>; }
export const DEFAULT_PREVIEW_LIMIT = 5 * 1024 * 1024;
const TEXT_MIMES = new Set(["application/json", "application/xml", "application/yaml"]);
export function previewKind(mime: string): "text" | "image" | "unsupported" {
if (mime.startsWith("text/") || TEXT_MIMES.has(mime)) return "text";
if (mime.startsWith("image/") && mime !== "image/svg+xml") return "image";
return "unsupported";
}
export function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 ** 2).toFixed(1)} MB`;
}
function StatePanel({ state, error }: { state: LoadState; error?: string }) {
if (state === "loading") return <p role="status"></p>;
if (state === "empty") return <p role="status"></p>;
if (state === "error") return <p role="alert">{error ?? "未知错误"}</p>;
return null;
}
export function ArtifactList({ artifacts, state = "ready", error, selectedId, onSelect }: { artifacts: ArtifactRecord[]; state?: LoadState; error?: string; selectedId?: string; onSelect: (artifact: ArtifactRecord) => void }) {
if (state !== "ready") return <StatePanel state={state} error={error} />;
if (!artifacts.length) return <StatePanel state="empty" />;
return <section aria-label="Artifact 列表" className="records-table">{artifacts.map((item) => <button key={item.id} aria-pressed={selectedId === item.id} onClick={() => onSelect(item)}><span>{item.name}</span><small>{item.mimeType} · {formatBytes(item.size)}</small></button>)}</section>;
}
export function ArtifactMetadata({ artifact }: { artifact: ArtifactRecord }) {
return <dl aria-label="Artifact 元数据"><dt></dt><dd>{artifact.name}</dd><dt></dt><dd>{artifact.mimeType}</dd><dt></dt><dd>{formatBytes(artifact.size)}</dd><dt>SHA-256</dt><dd className="mono">{artifact.sha256}</dd><dt></dt><dd>{artifact.createdAt}</dd></dl>;
}
export function ArtifactPreview({ artifact, content, state = "ready", error, maxBytes = DEFAULT_PREVIEW_LIMIT }: { artifact: ArtifactRecord; content?: ArtifactContent; state?: LoadState; error?: string; maxBytes?: number }) {
const kind = previewKind(artifact.mimeType);
const [imageUrl, setImageUrl] = useState<string>();
useEffect(() => {
if (!content || kind !== "image") { setImageUrl(undefined); return; }
const url = URL.createObjectURL(content.blob);
setImageUrl(url);
return () => URL.revokeObjectURL(url);
}, [content, kind]);
if (state !== "ready") return <StatePanel state={state} error={error} />;
if (artifact.size > maxBytes) return <p role="status"> {formatBytes(maxBytes)}使</p>;
if (kind === "unsupported") return <p role="status"></p>;
if (!content) return <p role="status"></p>;
if (kind === "image") {
return imageUrl ? <img src={imageUrl} alt={`${artifact.name} 预览`} /> : <p role="status"></p>;
}
return <TextBlob blob={content.blob} />;
}
function TextBlob({ blob }: { blob: Blob }) {
const [text, setText] = useState<string>();
const [failed, setFailed] = useState(false);
useEffect(() => { let active = true; setText(undefined); setFailed(false); blob.text().then((value) => active && setText(value), () => active && setFailed(true)); return () => { active = false; }; }, [blob]);
if (failed) return <p role="alert"></p>;
return text === undefined ? <p role="status"></p> : <pre>{text}</pre>;
}
export function ExportArtifactDialog({ artifact, content, adapter, onClose }: { artifact: ArtifactRecord; content: Blob; adapter: ArtifactSaveAdapter; onClose: () => void }) {
const [path, setPath] = useState(artifact.name);
const [overwrite, setOverwrite] = useState(false);
const [status, setStatus] = useState<"idle" | "saving" | "error">("idle");
const save = async () => { setStatus("saving"); try { await adapter.save(artifact, content, { path, overwrite }); onClose(); } catch { setStatus("error"); } };
return <section role="dialog" aria-modal="true" aria-labelledby="export-title"><h2 id="export-title"> Artifact</h2><p></p><ArtifactMetadata artifact={artifact} /><label><input aria-label="目标路径" value={path} onChange={(e) => setPath(e.currentTarget.value)} /></label><label><input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.currentTarget.checked)} /></label>{status === "error" && <p role="alert"></p>}<button onClick={onClose}></button><button disabled={!path.trim() || status === "saving"} onClick={save}></button></section>;
}
const SECRET_PATTERN = /(token|cookie|password|passwd|secret|api[_-]?key|private[_-]?key|authorization)/i;
export interface RedactedValue { value: unknown; redacted: boolean; }
export function redactSecrets(value: unknown, key = ""): unknown {
if (SECRET_PATTERN.test(key)) return "[已脱敏]";
if (Array.isArray(value)) return value.map((item) => redactSecrets(item));
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([childKey, child]) => [childKey, redactSecrets(child, childKey)]));
return value;
}
export function ParameterSummary({ parameters, allowReveal = false, onReveal }: { parameters: Record<string, unknown>; allowReveal?: boolean; onReveal?: () => void }) {
const [revealed, setRevealed] = useState(false);
const shown = revealed ? parameters : redactSecrets(parameters);
return <div><pre aria-label="参数摘要">{JSON.stringify(shown, null, 2)}</pre>{allowReveal && !revealed && <button onClick={() => { setRevealed(true); onReveal?.(); }}></button>}</div>;
}
export interface AuditRecord { id: string; actor: string; target: string; operation: string; parameters: Record<string, unknown>; result: string; requestId: string; timestamp: string; }
export type AuditQueryIntent = { type: "filter"; field: "actor" | "target" | "operation" | "result"; value: string } | { type: "page"; cursor?: string; direction: "previous" | "next" };
export function RequestIdLink({ requestId, onOpen }: { requestId: string; onOpen?: (requestId: string) => void }) { return onOpen ? <button className="mono" onClick={() => onOpen(requestId)}>{requestId}</button> : <span className="mono">{requestId}</span>; }
export function AuditTable({ records, state = "ready", error, nextCursor, previousCursor, onQueryIntent, onSelect }: { records: AuditRecord[]; state?: LoadState; error?: string; nextCursor?: string; previousCursor?: string; onQueryIntent: (intent: AuditQueryIntent) => void; onSelect: (record: AuditRecord) => void }) {
if (state !== "ready") return <StatePanel state={state} error={error} />;
return <section><label><input aria-label="操作筛选" onChange={(e) => onQueryIntent({ type: "filter", field: "operation", value: e.currentTarget.value })} /></label>{records.length ? <table><thead><tr><th></th><th></th><th></th><th></th><th></th><th>Request ID</th></tr></thead><tbody>{records.map((record) => <tr key={record.id} onClick={() => onSelect(record)}><td>{record.timestamp}</td><td>{record.actor}</td><td>{record.target}</td><td>{record.operation}</td><td>{record.result}</td><td><RequestIdLink requestId={record.requestId} /></td></tr>)}</tbody></table> : <StatePanel state="empty" />}<footer><button disabled={!previousCursor} onClick={() => onQueryIntent({ type: "page", direction: "previous", cursor: previousCursor })}></button><button disabled={!nextCursor} onClick={() => onQueryIntent({ type: "page", direction: "next", cursor: nextCursor })}></button></footer></section>;
}
export function AuditDetail({ record, allowReveal, onReveal }: { record: AuditRecord; allowReveal?: boolean; onReveal?: () => void }) { return <article><h2></h2><dl><dt></dt><dd>{record.actor}</dd><dt></dt><dd>{record.target}</dd><dt></dt><dd>{record.operation}</dd><dt></dt><dd>{record.result}</dd><dt>Request ID</dt><dd><RequestIdLink requestId={record.requestId} /></dd><dt></dt><dd>{record.timestamp}</dd></dl><ParameterSummary parameters={record.parameters} allowReveal={allowReveal} onReveal={onReveal} /></article>; }
export function MemoryOnlyArtifactController({ adapter, artifact, children }: { adapter: ArtifactContentAdapter; artifact: ArtifactRecord; children: (state: { content?: ArtifactContent; state: LoadState; load: () => Promise<void> }) => ReactNode }) {
const [content, setContent] = useState<ArtifactContent>(); const [state, setState] = useState<LoadState>("empty");
const load = async () => { setState("loading"); try { setContent(await adapter.load(artifact.id)); setState("ready"); } catch { setState("error"); } };
return <>{children({ content, state, load })}</>;
}

View File

@ -1,44 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ArtifactPreview, AuditTable, ExportArtifactDialog, ParameterSummary, previewKind, redactSecrets, type ArtifactRecord, type AuditRecord } from ".";
const artifact: ArtifactRecord = { id: "a1", name: "report.txt", size: 12, mimeType: "text/plain", sha256: "abc123", createdAt: "2026-08-03T10:00:00Z" };
const audit: AuditRecord = { id: "e1", actor: "analyst", target: "session/s1", operation: "artifact.export", parameters: { path: "/safe", token: "secret", nested: { password: "pw" } }, result: "success", requestId: "req-1", timestamp: "2026-08-03T10:00:00Z" };
describe("Artifact records", () => {
it("限制危险 MIME 和过大内容的内嵌预览", () => {
expect(previewKind("image/svg+xml")).toBe("unsupported");
const { rerender } = render(<ArtifactPreview artifact={{ ...artifact, mimeType: "application/x-executable" }} />);
expect(screen.getByText(/不支持安全内嵌/)).toBeInTheDocument();
rerender(<ArtifactPreview artifact={{ ...artifact, size: 11 }} maxBytes={10} />);
expect(screen.getByText(/文件过大/)).toBeInTheDocument();
});
it("只有用户确认后才调用保存 adapter并携带覆盖意图", async () => {
const save = vi.fn().mockResolvedValue(undefined); const close = vi.fn();
render(<ExportArtifactDialog artifact={artifact} content={new Blob(["hello"])} adapter={{ save }} onClose={close} />);
expect(save).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("目标路径"), { target: { value: "/exports/report.txt" } });
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: "确认导出" }));
await waitFor(() => expect(save).toHaveBeenCalledWith(artifact, expect.any(Blob), { path: "/exports/report.txt", overwrite: true }));
});
});
describe("Audit records", () => {
it("递归脱敏常见秘密字段,原始值需要显式动作", () => {
expect(redactSecrets(audit.parameters)).toEqual({ path: "/safe", token: "[已脱敏]", nested: { password: "[已脱敏]" } });
const reveal = vi.fn(); render(<ParameterSummary parameters={audit.parameters} allowReveal onReveal={reveal} />);
expect(screen.queryByText(/"secret"/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /显示原始敏感值/ }));
expect(screen.getByLabelText("参数摘要")).toHaveTextContent("secret"); expect(reveal).toHaveBeenCalledOnce();
});
it("筛选和游标分页只发出服务端查询意图", () => {
const intent = vi.fn(); render(<AuditTable records={[audit]} nextCursor="next-2" onQueryIntent={intent} onSelect={() => undefined} />);
fireEvent.change(screen.getByLabelText("操作筛选"), { target: { value: "export" } });
fireEvent.click(screen.getByRole("button", { name: "下一页" }));
expect(intent).toHaveBeenNthCalledWith(1, { type: "filter", field: "operation", value: "export" });
expect(intent).toHaveBeenNthCalledWith(2, { type: "page", direction: "next", cursor: "next-2" });
});
});

View File

@ -1,6 +0,0 @@
import type { Capability, CapabilityId } from "../../protocol/model";
export function CapabilityPanel({ declared, catalog, onSelect }: { declared: readonly CapabilityId[]; catalog: readonly Capability[]; onSelect?: (capability: Capability) => void }) {
const visible = catalog.filter((capability) => declared.includes(capability.capability_id));
return <section aria-labelledby="capabilities"><h3 id="capabilities">Capability</h3>{visible.length === 0 ? <p className="empty-state"></p> : <ul className="card-list">{visible.map((capability) => <li key={capability.capability_id}><div><strong>{capability.name}</strong><small>{capability.description ?? capability.capability_id}</small></div>{onSelect && <button onClick={() => onSelect(capability)}></button>}</li>)}</ul>}</section>;
}

View File

@ -1,11 +0,0 @@
import { useState } from "react";
import type { GroupId, ProjectId, SessionGroup } from "../../protocol/model";
import type { GroupCommandIntent, SessionSelection } from "./types";
function flatten(groups: readonly SessionGroup[]): SessionGroup[] { return groups.flatMap((group) => [group, ...flatten(group.children ?? [])]); }
export function GroupAssignment({ projectId, groups, selection, onIntent }: { projectId: ProjectId; groups: readonly SessionGroup[]; selection: SessionSelection; onIntent: (intent: GroupCommandIntent) => void }) {
const [groupId, setGroupId] = useState<GroupId | "">("");
const count = selection.kind === "explicit" ? selection.sessionIds.length : selection.estimatedCount;
return <section><h3> Session</h3><p>{count === undefined ? "服务端查询范围(数量待服务端确认)" : `${count} 个 Session`}</p><label><select aria-label="目标分组" value={groupId} onChange={(event) => setGroupId(event.currentTarget.value as GroupId | "")}><option value=""></option>{flatten(groups).map((group) => <option value={group.group_id} key={group.group_id}>{group.name} ({group.online_count}/{group.total_count})</option>)}</select></label><button onClick={() => onIntent({ type: "assign-sessions", projectId, selection, groupId: groupId || undefined })}></button></section>;
}

View File

@ -1,15 +0,0 @@
import { useState } from "react";
import type { GroupId, SessionGroup } from "../../protocol/model";
export function GroupTree({ groups, selectedGroupId, onSelect }: { groups: readonly SessionGroup[]; selectedGroupId?: GroupId; onSelect: (groupId?: GroupId) => void }) {
const [collapsed, setCollapsed] = useState<Set<GroupId>>(new Set());
const render = (group: SessionGroup, depth: number) => {
const hasChildren = Boolean(group.children?.length);
const isCollapsed = collapsed.has(group.group_id);
return <li key={group.group_id}><div className={selectedGroupId === group.group_id ? "selected" : ""} style={{ paddingLeft: depth * 16 }}>
<button aria-label={`${isCollapsed ? "展开" : "折叠"} ${group.name}`} disabled={!hasChildren} onClick={() => setCollapsed((current) => { const next = new Set(current); next.has(group.group_id) ? next.delete(group.group_id) : next.add(group.group_id); return next; })}>{hasChildren ? isCollapsed ? "▸" : "▾" : "·"}</button>
<button onClick={() => onSelect(group.group_id)}>{group.name}</button><span>{group.online_count} / {group.total_count}</span>
</div>{hasChildren && !isCollapsed && <ul>{group.children?.map((child) => render(child, depth + 1))}</ul>}</li>;
};
return <nav className="group-tree" aria-label="Session 分组"><button className={!selectedGroupId ? "selected" : ""} onClick={() => onSelect(undefined)}></button><ul>{groups.map((group) => render(group, 0))}</ul></nav>;
}

View File

@ -1,9 +0,0 @@
import type { OperationIntent } from "./types";
function redact(key: string, value: unknown): unknown { return /password|secret|token|cookie|credential/i.test(key) ? "••••••" : value; }
export function OperationConfirmation({ intent, pending, onConfirm, onCancel }: { intent: OperationIntent; pending?: boolean; onConfirm: (intent: OperationIntent) => void; onCancel: () => void }) {
const { context, selection } = intent;
const target = selection.kind === "explicit" ? `${selection.sessionIds.length} 个明确 Session` : selection.estimatedCount === undefined ? "服务端查询范围(数量待确认)" : `${selection.estimatedCount} 个查询结果`;
return <section className="operation-confirmation" aria-labelledby="confirm-operation"><h3 id="confirm-operation"></h3><p className="context-lock"> LOCKED CONTEXT</p><dl className="detail-grid"><div><dt>Teamserver</dt><dd>{context.serverName} ({context.serverId})</dd></div><div><dt>Project</dt><dd>{context.projectName} ({context.projectId})</dd></div><div><dt>Operation</dt><dd>{intent.capabilityId}</dd></div><div><dt></dt><dd>{target}</dd></div></dl><h4></h4><pre>{JSON.stringify(Object.fromEntries(Object.entries(intent.parameters).map(([key, value]) => [key, redact(key, value)])), null, 2)}</pre><div><button disabled={pending} onClick={onCancel}></button><button className="primary" disabled={pending} onClick={() => onConfirm(intent)}>{pending ? "提交中…" : "确认提交"}</button></div></section>;
}

View File

@ -1,28 +0,0 @@
import { FormEvent, useMemo, useState } from "react";
import type { Capability } from "../../protocol/model";
interface SchemaProperty { type?: unknown; title?: unknown; description?: unknown; enum?: unknown; default?: unknown; minimum?: unknown; maximum?: unknown; maxLength?: unknown; }
function properties(schema: Capability["input_schema"]): Record<string, SchemaProperty> {
const value = schema.properties;
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, SchemaProperty> : {};
}
export function OperationForm({ capability, disabled, onSubmit }: { capability: Capability; disabled?: boolean; onSubmit: (parameters: Readonly<Record<string, unknown>>) => void }) {
const fields = useMemo(() => properties(capability.input_schema), [capability]);
const required = new Set(Array.isArray(capability.input_schema.required) ? capability.input_schema.required.filter((value): value is string => typeof value === "string") : []);
const [values, setValues] = useState<Record<string, unknown>>(() => Object.fromEntries(Object.entries(fields).filter(([, field]) => field.default !== undefined).map(([name, field]) => [name, field.default])));
const unsupported = Object.entries(fields).filter(([, field]) => !Array.isArray(field.enum) && ![undefined, "string", "boolean", "number", "integer"].includes(field.type as string | undefined)).map(([name]) => name);
const submit = (event: FormEvent) => { event.preventDefault(); if (unsupported.length === 0) onSubmit(values); };
const controls = Object.entries(fields).map(([name, field]) => {
const label = typeof field.title === "string" ? field.title : name;
const common = { id: `operation-${name}`, disabled, required: required.has(name) };
const enumValues = Array.isArray(field.enum) && field.enum.every((item) => ["string", "number"].includes(typeof item)) ? field.enum as readonly (string | number)[] : undefined;
if (enumValues) return <select {...common} value={String(values[name] ?? "")} onChange={(e) => setValues({ ...values, [name]: enumValues.find((item) => String(item) === e.currentTarget.value) })}><option value=""></option>{enumValues.map((item) => <option key={String(item)} value={String(item)}>{String(item)}</option>)}</select>;
if (field.type === "boolean") return <input {...common} type="checkbox" checked={Boolean(values[name])} onChange={(e) => setValues({ ...values, [name]: e.currentTarget.checked })} />;
if (field.type === "number" || field.type === "integer") return <input {...common} type="number" min={typeof field.minimum === "number" ? field.minimum : undefined} max={typeof field.maximum === "number" ? field.maximum : undefined} value={String(values[name] ?? "")} onChange={(e) => setValues({ ...values, [name]: e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value) })} />;
if (field.type === "string" || field.type === undefined) return <input {...common} type="text" maxLength={typeof field.maxLength === "number" ? field.maxLength : undefined} value={String(values[name] ?? "")} onChange={(e) => setValues({ ...values, [name]: e.currentTarget.value })} />;
return <p role="alert"> {name} 使 Schema </p>;
});
return <form className="operation-form" onSubmit={submit}><h3>{capability.name}</h3>{Object.keys(fields).length === 0 && <p className="empty-state"></p>}{Object.entries(fields).map(([name, field], index) => <label key={name} htmlFor={`operation-${name}`}><span>{typeof field.title === "string" ? field.title : name}{required.has(name) ? " *" : ""}</span>{controls[index]}{typeof field.description === "string" && <small>{field.description}</small>}</label>)}<button className="primary" disabled={disabled || unsupported.length > 0} type="submit"></button></form>;
}

View File

@ -1,5 +0,0 @@
import type { Event } from "../../protocol/model";
export function RecentEvents({ events }: { events: readonly Event[] }) {
return <section><h3></h3>{events.length === 0 ? <p className="empty-state"></p> : <ol className="event-list">{events.map((event) => <li key={event.event_id}><strong>{event.type}</strong><time>{event.timestamp}</time></li>)}</ol>}</section>;
}

View File

@ -1,8 +0,0 @@
import type { Task, TaskId } from "../../protocol/model";
import type { AsyncViewState } from "./types";
export function RunningTasks({ tasks, state = "ready", onCancel }: { tasks: readonly Task[]; state?: AsyncViewState; onCancel?: (taskId: TaskId) => void }) {
if (state === "loading") return <section><h3></h3><p role="status"></p></section>;
if (state === "error") return <section><h3></h3><p role="alert"></p></section>;
return <section><h3></h3>{tasks.length === 0 ? <p className="empty-state"></p> : <ul className="card-list">{tasks.map((task) => <li key={task.task_id}><div><strong>{task.capability_id}</strong><small>{task.state} · {task.updated_at}</small></div>{task.cancellable && onCancel && <button onClick={() => onCancel(task.task_id)}></button>}</li>)}</ul>}</section>;
}

View File

@ -1,19 +0,0 @@
import type { Session } from "../../protocol/model";
const standardKeys = new Set(["server_id", "project_id", "session_id", "name", "state", "hostname", "username", "os", "architecture", "source_address", "first_seen_at", "last_active_at", "tags", "capabilities"]);
function display(value: unknown): string {
if (value === null || value === undefined || value === "") return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
export function SessionIdentity({ session }: { session: Session }) {
const fields = [
["Session ID", session.session_id], ["状态", session.state], ["主机", session.hostname],
["用户", session.username], ["系统 / 架构", [session.os, session.architecture].filter(Boolean).join(" / ")],
["来源地址", session.source_address], ["首次出现", session.first_seen_at], ["最后活动", session.last_active_at],
] as const;
const extensions = Object.entries(session).filter(([key]) => !standardKeys.has(key));
return <section aria-labelledby="session-identity"><h3 id="session-identity"></h3><dl className="detail-grid">{fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{display(value)}</dd></div>)}</dl>{extensions.length > 0 && <details><summary> ({extensions.length})</summary><dl className="detail-grid">{extensions.map(([key, value]) => <div key={key}><dt>{key}</dt><dd>{display(value)}</dd></div>)}</dl></details>}</section>;
}

View File

@ -1,3 +0,0 @@
export function SessionNotes({ notes }: { notes?: string }) {
return <section><h3></h3><p className={notes ? "" : "empty-state"}>{notes || "暂无备注"}</p></section>;
}

View File

@ -1,13 +0,0 @@
import type { Session, TaskId } from "../../protocol/model";
import { CapabilityPanel } from "./CapabilityPanel";
import { RecentEvents } from "./RecentEvents";
import { RunningTasks } from "./RunningTasks";
import { SessionIdentity } from "./SessionIdentity";
import { SessionNotes } from "./SessionNotes";
import { TagEditor } from "./TagEditor";
import type { SessionOverviewData } from "./types";
export function SessionOverview({ session, data, onTagsChange, onCancelTask }: { session: Session; data: SessionOverviewData; onTagsChange?: (tags: readonly string[]) => void; onCancelTask?: (taskId: TaskId) => void }) {
const running = data.tasks.filter((task) => task.state === "PENDING" || task.state === "RUNNING");
return <div className="session-overview"><header><div><p className="eyebrow">SESSION OVERVIEW</p><h2>{session.name}</h2></div><span className={`connection ${session.state.toLowerCase()}`}>{session.state}</span></header><div className="overview-grid"><SessionIdentity session={session} /><CapabilityPanel declared={session.capabilities} catalog={data.capabilities} /><RunningTasks tasks={running} onCancel={onCancelTask} /><RecentEvents events={data.events} /><section><h3>Artifact</h3>{data.artifacts.length === 0 ? <p className="empty-state"> Artifact</p> : <ul className="card-list">{data.artifacts.map((artifact) => <li key={artifact.artifact_id}><div><strong>{artifact.name}</strong><small>{artifact.media_type} · {artifact.size_bytes} bytes</small></div></li>)}</ul>}</section><SessionNotes notes={data.notes} /><TagEditor tags={session.tags} disabled={!onTagsChange} onChange={(tags) => onTagsChange?.(tags)} /></div></div>;
}

View File

@ -1,7 +0,0 @@
import { useState } from "react";
export function TagEditor({ tags, disabled, onChange }: { tags: readonly string[]; disabled?: boolean; onChange: (tags: readonly string[]) => void }) {
const [value, setValue] = useState("");
const add = () => { const tag = value.trim(); if (tag && !tags.includes(tag)) onChange([...tags, tag]); setValue(""); };
return <section><h3></h3><div className="tag-editor">{tags.map((tag) => <button disabled={disabled} aria-label={`移除标签 ${tag}`} key={tag} onClick={() => onChange(tags.filter((item) => item !== tag))}>{tag} ×</button>)}<input disabled={disabled} aria-label="新标签" value={value} onChange={(event) => setValue(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); add(); } }} /><button disabled={disabled || !value.trim()} onClick={add}></button></div></section>;
}

View File

@ -1,12 +0,0 @@
export * from "./types";
export * from "./SessionOverview";
export * from "./SessionIdentity";
export * from "./CapabilityPanel";
export * from "./RunningTasks";
export * from "./RecentEvents";
export * from "./SessionNotes";
export * from "./TagEditor";
export * from "./GroupTree";
export * from "./GroupAssignment";
export * from "./OperationForm";
export * from "./OperationConfirmation";

View File

@ -1,65 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Capability, CapabilityId, GroupId, ProjectId, ServerId, SessionGroup, SessionId } from "../../protocol/model";
import { CapabilityPanel } from "./CapabilityPanel";
import { GroupAssignment } from "./GroupAssignment";
import { OperationConfirmation } from "./OperationConfirmation";
import { OperationForm } from "./OperationForm";
import type { OperationIntent } from "./types";
const serverId = "server-1" as ServerId;
const projectId = "project-1" as ProjectId;
const sessionId = "session-1" as SessionId;
const capabilityId = "shell.safe" as CapabilityId;
const capability: Capability = {
server_id: serverId, project_id: projectId, capability_id: capabilityId,
name: "安全操作", supports_batch: true,
input_schema: { type: "object", required: ["path"], properties: { path: { type: "string", title: "路径", maxLength: 20 }, retries: { type: "integer", minimum: 0, maximum: 3 }, mode: { type: "string", enum: ["read", "list"] }, audit: { type: "boolean" } } },
};
describe("Session capability 操作", () => {
it("只显示 Session 已声明的 capability", () => {
render(<CapabilityPanel declared={[capabilityId]} catalog={[capability, { ...capability, capability_id: "hidden" as CapabilityId, name: "未声明操作" }]} />);
expect(screen.getByText("安全操作")).toBeInTheDocument();
expect(screen.queryByText("未声明操作")).not.toBeInTheDocument();
});
it("从受约束 Schema 生成类型化参数", () => {
const submit = vi.fn();
render(<OperationForm capability={capability} onSubmit={submit} />);
fireEvent.change(screen.getByLabelText("路径 *"), { target: { value: "/tmp/report" } });
fireEvent.change(screen.getByLabelText("retries"), { target: { value: "2" } });
fireEvent.change(screen.getByLabelText("mode"), { target: { value: "list" } });
fireEvent.click(screen.getByLabelText("audit"));
fireEvent.click(screen.getByRole("button", { name: "检查并确认" }));
expect(submit).toHaveBeenCalledWith({ path: "/tmp/report", retries: 2, mode: "list", audit: true });
});
it("拒绝渲染可执行或复合的未知 Schema 类型", () => {
render(<OperationForm capability={{ ...capability, input_schema: { properties: { payload: { type: "object" } } } }} onSubmit={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent("不支持");
expect(screen.getByRole("button", { name: "检查并确认" })).toBeDisabled();
});
});
describe("上下文与分组命令", () => {
it("确认页展示锁定上下文、目标数量并脱敏参数", () => {
const intent: OperationIntent = { type: "submit-operation", capabilityId, context: { serverId, serverName: "Red", projectId, projectName: "Alpha", sessionIds: [sessionId] }, selection: { kind: "explicit", sessionIds: [sessionId] }, parameters: { path: "/tmp", access_token: "never-show" } };
render(<OperationConfirmation intent={intent} onConfirm={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText(/Red \(server-1\)/)).toBeInTheDocument();
expect(screen.getByText(/Alpha \(project-1\)/)).toBeInTheDocument();
expect(screen.getByText("1 个明确 Session")).toBeInTheDocument();
expect(screen.queryByText(/never-show/)).not.toBeInTheDocument();
expect(screen.getByText(/••••••/)).toBeInTheDocument();
});
it("跨页选择保持服务端 query 范围,不推算本地 ID", () => {
const onIntent = vi.fn();
const groups: SessionGroup[] = [{ server_id: serverId, project_id: projectId, group_id: "group-1" as GroupId, name: "生产", total_count: 120, online_count: 80 }];
render(<GroupAssignment projectId={projectId} groups={groups} selection={{ kind: "query", queryId: "query-abc" }} onIntent={onIntent} />);
expect(screen.getByText(/数量待服务端确认/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("目标分组"), { target: { value: "group-1" } });
fireEvent.click(screen.getByRole("button", { name: "生成移动命令" }));
expect(onIntent).toHaveBeenCalledWith({ type: "assign-sessions", projectId, selection: { kind: "query", queryId: "query-abc" }, groupId: "group-1" });
});
});

View File

@ -1,45 +0,0 @@
import type {
Artifact,
Capability,
CapabilityId,
Event,
GroupId,
ProjectId,
ServerId,
SessionId,
Task,
} from "../../protocol/model";
export type AsyncViewState = "loading" | "empty" | "ready" | "pending" | "success" | "error" | "cancelled";
export interface LockedOperationContext {
readonly serverId: ServerId;
readonly serverName: string;
readonly projectId: ProjectId;
readonly projectName: string;
readonly sessionIds: readonly SessionId[];
}
export type SessionSelection =
| { readonly kind: "explicit"; readonly sessionIds: readonly SessionId[] }
| { readonly kind: "query"; readonly queryId: string; readonly estimatedCount?: number };
export interface OperationIntent {
readonly type: "submit-operation";
readonly context: LockedOperationContext;
readonly capabilityId: CapabilityId;
readonly selection: SessionSelection;
readonly parameters: Readonly<Record<string, unknown>>;
}
export type GroupCommandIntent =
| { readonly type: "assign-sessions"; readonly projectId: ProjectId; readonly selection: SessionSelection; readonly groupId?: GroupId }
| { readonly type: "move-group"; readonly projectId: ProjectId; readonly groupId: GroupId; readonly parentGroupId?: GroupId };
export interface SessionOverviewData {
readonly tasks: readonly Task[];
readonly events: readonly Event[];
readonly artifacts: readonly Artifact[];
readonly capabilities: readonly Capability[];
readonly notes?: string;
}

View File

@ -1,43 +0,0 @@
import { useState } from "react";
import type { Event, Task } from "../../protocol/model";
import { demoSession, demoSessions } 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({ sessionId }: { sessionId?: string }) {
const session = demoSessions.find((item) => item.session_id === sessionId) ?? demoSession;
const data = { capabilities: [], tasks: [task], events: [event], artifacts: [], notes: "服务端演示备注", tags: demoSession.tags } as unknown as SessionOverviewData;
return <><Heading title="Session Overview" detail={`${session.name} · 标准协议 Session 详情`} /><SessionOverview session={session} 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

@ -2,8 +2,6 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
import "./styles/tokens.css";
import "./ui/ui.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@ -1,43 +0,0 @@
import type { CapabilityId, ProjectId, ServerId, Session, SessionId, Timestamp } from "../model";
const serverId = "atlas" as ServerId;
const projectId = "northwind" as ProjectId;
const overview = "session.overview" as CapabilityId;
interface DemoSessionInput {
readonly session_id: string;
readonly name: string;
readonly state: Session["state"];
readonly hostname?: string;
readonly username?: string;
readonly os?: string;
readonly architecture?: string;
readonly source_address?: string;
readonly first_seen_at: string;
readonly last_active_at: string;
readonly tags: readonly string[];
}
function session(
value: DemoSessionInput,
): Session {
return {
...value,
server_id: serverId,
project_id: projectId,
session_id: value.session_id as SessionId,
first_seen_at: value.first_seen_at as Timestamp,
last_active_at: value.last_active_at as Timestamp,
capabilities: [overview],
};
}
/** 界面只通过展示适配器消费的标准协议 Session fixture。 */
export const demoSessions: readonly Session[] = [
session({ session_id: "NW-042", name: "NW-042", state: "ONLINE", hostname: "ws-fin-042", username: "nora.chen", os: "Windows 11", architecture: "amd64", source_address: "10.42.8.17", first_seen_at: "2026-08-01T08:00:00Z", last_active_at: "2026-08-03T19:11:48Z", tags: ["finance", "priority"] }),
session({ session_id: "NW-038", name: "NW-038", state: "ONLINE", hostname: "srv-app-03", username: "svc.deploy", os: "Ubuntu 24.04", architecture: "amd64", source_address: "10.42.3.8", first_seen_at: "2026-07-28T10:30:00Z", last_active_at: "2026-08-03T19:11:11Z", tags: ["server"] }),
session({ session_id: "NW-031", name: "NW-031", state: "DORMANT", hostname: "mac-design-07", username: "alex.k", os: "macOS 15.6", architecture: "arm64", source_address: "10.42.12.27", first_seen_at: "2026-07-22T09:15:00Z", last_active_at: "2026-08-03T19:04:00Z", tags: ["design"] }),
session({ session_id: "NW-019", name: "NW-019", state: "OFFLINE", hostname: "ws-ops-019", username: "operator", os: "Windows 10", architecture: "amd64", source_address: "10.42.6.91", first_seen_at: "2026-07-10T14:00:00Z", last_active_at: "2026-08-03T17:12:00Z", tags: ["legacy"] }),
];
export const demoSession = demoSessions[0];

View File

@ -1,177 +0,0 @@
/** 协议对象允许保留服务端新增字段GUI 不应因未知字段而拒绝整个对象。 */
export interface Extensible {
readonly [extension: string]: unknown;
}
declare const brand: unique symbol;
export type Brand<Value, Name extends string> = Value & { readonly [brand]: Name };
export type ServerId = Brand<string, "ServerId">;
export type ProjectId = Brand<string, "ProjectId">;
export type SessionId = Brand<string, "SessionId">;
export type TaskId = Brand<string, "TaskId">;
export type EventId = Brand<string, "EventId">;
export type ArtifactId = Brand<string, "ArtifactId">;
export type AuditId = Brand<string, "AuditId">;
export type CapabilityId = Brand<string, "CapabilityId">;
export type GroupId = Brand<string, "GroupId">;
export type Timestamp = Brand<string, "Timestamp">;
export type Cursor = Brand<string, "Cursor">;
/** 已知值保留自动补全,同时允许服务端返回未来新增值。 */
export type ForwardCompatible<Known extends string> = Known | (string & {});
export interface ProtocolVersion extends Extensible {
readonly major: number;
readonly minor: number;
readonly patch: number;
}
export interface ProtocolEnvelope extends Extensible {
readonly protocol_version: ProtocolVersion;
readonly request_id?: string;
}
export interface ServerContext extends Extensible {
readonly server_id: ServerId;
}
export interface ProjectContext extends ServerContext {
readonly project_id: ProjectId;
}
export interface SessionContext extends ProjectContext {
readonly session_id: SessionId;
}
export interface PageRequest extends Extensible {
readonly cursor?: Cursor;
readonly page_size?: number;
}
export interface PageInfo extends Extensible {
readonly next_cursor?: Cursor;
readonly has_more: boolean;
}
export interface Page<T> extends Extensible {
readonly items: readonly T[];
readonly page_info: PageInfo;
}
export type ConnectionState = ForwardCompatible<
| "DISCONNECTED" | "CONNECTING" | "TLS_HANDSHAKE" | "AUTHENTICATING"
| "NEGOTIATING" | "SYNCHRONIZING" | "CONNECTED" | "DEGRADED"
| "RECONNECTING" | "AUTH_EXPIRED" | "FAILED"
>;
export interface Teamserver extends ServerContext, Extensible {
readonly name: string;
readonly protocol_version: ProtocolVersion;
readonly connection_state: ConnectionState;
readonly capabilities: readonly CapabilityId[];
}
export interface Project extends ProjectContext, Extensible {
readonly name: string;
readonly description?: string;
readonly capabilities: readonly CapabilityId[];
}
export type SessionState = ForwardCompatible<"ONLINE" | "OFFLINE" | "DORMANT" | "LOST">;
export interface Session extends SessionContext, Extensible {
readonly name: string;
readonly state: SessionState;
readonly hostname?: string;
readonly username?: string;
readonly os?: string;
readonly architecture?: string;
readonly source_address?: string;
readonly first_seen_at: Timestamp;
readonly last_active_at: Timestamp;
readonly tags: readonly string[];
readonly capabilities: readonly CapabilityId[];
}
export interface SessionGroup extends ProjectContext, Extensible {
readonly group_id: GroupId;
readonly parent_group_id?: GroupId;
readonly name: string;
readonly total_count: number;
readonly online_count: number;
readonly children?: readonly SessionGroup[];
}
export type JsonSchema = Readonly<Record<string, unknown>>;
export interface Capability extends ProjectContext, Extensible {
readonly capability_id: CapabilityId;
readonly name: string;
readonly description?: string;
readonly input_schema: JsonSchema;
readonly output_schema?: JsonSchema;
readonly supports_batch: boolean;
}
export type TaskState = ForwardCompatible<
"PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED"
>;
export interface Task extends SessionContext, Extensible {
readonly task_id: TaskId;
readonly capability_id: CapabilityId;
readonly state: TaskState;
readonly cancellable: boolean;
readonly created_at: Timestamp;
readonly updated_at: Timestamp;
readonly result?: unknown;
readonly error?: ProtocolError;
}
export interface EventContext extends ServerContext, Extensible {
readonly project_id?: ProjectId;
readonly session_id?: SessionId;
readonly task_id?: TaskId;
}
export interface Event extends Extensible {
readonly event_id: EventId;
readonly type: ForwardCompatible<"SESSION_UPDATED" | "TASK_UPDATED" | "TASK_OUTPUT" | "ARTIFACT_CREATED">;
readonly timestamp: Timestamp;
readonly cursor: Cursor;
readonly sequence?: number;
readonly context: EventContext;
readonly payload: unknown;
}
export interface Artifact extends SessionContext, Extensible {
readonly artifact_id: ArtifactId;
readonly task_id?: TaskId;
readonly name: string;
readonly media_type: string;
readonly size_bytes: number;
readonly sha256?: string;
readonly created_at: Timestamp;
}
export interface AuditRecord extends ProjectContext, Extensible {
readonly audit_id: AuditId;
readonly actor: string;
readonly action: string;
readonly target: Readonly<Record<string, string>>;
readonly parameter_summary?: Readonly<Record<string, unknown>>;
readonly outcome: ForwardCompatible<"SUCCEEDED" | "FAILED" | "DENIED">;
readonly request_id: string;
readonly timestamp: Timestamp;
}
export interface ProtocolError extends Extensible {
readonly code: ForwardCompatible<
"NETWORK" | "TLS" | "AUTHENTICATION" | "INCOMPATIBLE_VERSION" |
"PERMISSION_DENIED" | "RATE_LIMITED" | "TIMEOUT" | "INVALID_MESSAGE" | "INTERNAL"
>;
readonly message: string;
readonly retryable: boolean;
readonly details?: Readonly<Record<string, unknown>>;
}

View File

@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { demoSession } from "./fixtures";
import { isProtocolVersion, isSession, parseSession, ProtocolParseError } from "./parser";
describe("协议解析", () => {
it("接受完整 Session 并保留未知枚举和扩展字段", () => {
const futureSession = { ...demoSession, state: "SUSPENDED_BY_POLICY", vendor_status: 42 };
const parsed = parseSession(futureSession);
expect(parsed.state).toBe("SUSPENDED_BY_POLICY");
expect(parsed.vendor_status).toBe(42);
});
it("拒绝缺少 project_id 的跨上下文 Session", () => {
const { project_id: _projectId, ...invalid } = demoSession;
expect(isSession(invalid)).toBe(false);
expect(() => parseSession(invalid)).toThrow(ProtocolParseError);
});
it("协议版本必须由非负整数构成", () => {
expect(isProtocolVersion({ major: 1, minor: 0, patch: 0, future: true })).toBe(true);
expect(isProtocolVersion({ major: 1, minor: -1, patch: 0 })).toBe(false);
});
});

View File

@ -1,49 +0,0 @@
import type { ProjectContext, ProtocolVersion, Session, SessionContext } from "./model";
export class ProtocolParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ProtocolParseError";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasString(value: Record<string, unknown>, key: string): boolean {
return typeof value[key] === "string" && value[key] !== "";
}
export function isProtocolVersion(value: unknown): value is ProtocolVersion {
return isRecord(value) && ["major", "minor", "patch"].every(
(key) => Number.isInteger(value[key]) && (value[key] as number) >= 0,
);
}
export function isProjectContext(value: unknown): value is ProjectContext {
return isRecord(value) && hasString(value, "server_id") && hasString(value, "project_id");
}
export function isSessionContext(value: unknown): value is SessionContext {
return isProjectContext(value) && hasString(value, "session_id");
}
export function isSession(value: unknown): value is Session {
if (!isRecord(value) || !isSessionContext(value)) return false;
return hasString(value, "name")
&& hasString(value, "state")
&& hasString(value, "first_seen_at")
&& hasString(value, "last_active_at")
&& Array.isArray(value.tags)
&& value.tags.every((tag) => typeof tag === "string")
&& Array.isArray(value.capabilities)
&& value.capabilities.every((capability) => typeof capability === "string");
}
export function parseSession(value: unknown): Session {
if (!isSession(value)) {
throw new ProtocolParseError("无效的 Session缺少上下文字段或标准字段类型错误");
}
return value;
}

View File

@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import { demoSessions } from "./protocol/fixtures";
import { toSessionRow } from "./sessionPresentation";
describe("toSessionRow", () => {
it("将协议 Session 转换为稳定的表格展示值", () => {
expect(toSessionRow(demoSessions[0], new Date("2026-08-03T19:12:02Z"))).toEqual({
id: "NW-042",
status: "ONLINE",
host: "ws-fin-042",
user: "nora.chen",
os: "Windows 11 / amd64",
address: "10.42.8.17",
seen: "14s ago",
tags: ["finance", "priority"],
});
});
});

View File

@ -1,24 +0,0 @@
import type { Session } from "./protocol/model";
import type { SessionRow } from "./SessionTable";
/** 将服务端权威模型转换为表格展示值,不在 UI 复制业务模型。 */
export function toSessionRow(session: Session, now: Date = new Date()): SessionRow {
return {
id: session.session_id,
status: session.state,
host: session.hostname ?? session.name,
user: session.username ?? "—",
os: [session.os, session.architecture].filter(Boolean).join(" / ") || "未知",
address: session.source_address ?? "—",
seen: formatRelativeTime(session.last_active_at, now),
tags: [...session.tags],
};
}
function formatRelativeTime(timestamp: string, now: Date): string {
const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - Date.parse(timestamp)) / 1000));
if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`;
if (elapsedSeconds < 3600) return `${Math.floor(elapsedSeconds / 60)}m ago`;
if (elapsedSeconds < 86400) return `${Math.floor(elapsedSeconds / 3600)}h ago`;
return `${Math.floor(elapsedSeconds / 86400)}d ago`;
}

View File

@ -1,13 +0,0 @@
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("");
});
});

View File

@ -1,15 +0,0 @@
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

@ -1,3 +0,0 @@
import { Icon } from "../ui";
import type { OperationTab } from "../workspace";
export function ContextBar({ serverName, tab }: { serverName: string; tab: OperationTab }) { return <div className="contextbar" aria-label="锁定的操作上下文"><span className="context-lock"><Icon name="lock" size={12} /> LOCKED</span>{serverName} <i>/</i> {tab.context.project} <i>/</i> <b>{tab.context.session}</b> <i>/</i> {tab.title}</div>; }

View File

@ -1,17 +0,0 @@
import { Icon, IconButton } from "../ui";
import type { OperationTab } from "../workspace";
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, onTogglePin }: OperationTabsProps) {
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-label={tab.title} aria-selected={tab.id === activeTabId} tabIndex={tab.id === activeTabId ? 0 : -1} className={`operation-tab ${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.dirty && <span className="dirty" aria-label="有未提交更改"></span>}{tab.pinned && <span aria-label="已固定"></span>}{tab.title}</span>
</button>)}</div><div className="tab-actions-strip" aria-label="标签内操作">{tabs.map((tab) => <div className={`tab-actions ${tab.id === activeTabId ? "active" : ""}`} key={tab.id}>
{onTogglePin && <IconButton className="tab-action pin" label={`${tab.pinned ? "取消固定" : "固定"} ${tab.title}`} onClick={() => onTogglePin(tab.id)}></IconButton>}
{onClose && !tab.pinned && <IconButton className="tab-action close" label={`关闭 ${tab.title}`} onClick={() => onClose(tab.id)}><Icon name="close" size={12} /></IconButton>}
</div>)}</div></div>;
}

View File

@ -1,8 +0,0 @@
import { Icon, Input, StatusIndicator } from "../ui";
import type { TeamserverWorkspace } from "../workspace";
const navigation = ["Overview", "Sessions", "Tasks", "Events", "Artifacts", "Audit"];
export interface ProjectSidebarProps { server: TeamserverWorkspace; activePage: string; onNavigate?: (page: string) => void; }
export function ProjectSidebar({ server, activePage, onNavigate }: ProjectSidebarProps) {
return <aside className="project-sidebar"><header><p className="eyebrow">TEAMSERVER</p><h1>{server.name}</h1><StatusIndicator status={server.connection === "CONNECTED" ? "online" : "warning"} label={server.connection} /></header><Input className="sidebar-search" aria-label="搜索" placeholder="搜索" leading={<Icon name="search" />} /><nav aria-label="项目导航"><button> <strong>{server.unread}</strong></button><button className={activePage === "连接中心" ? "selected" : ""} onClick={() => onNavigate?.("连接中心")}></button><p className="nav-heading"> · {server.activeProject}</p>{navigation.map((item) => <button className={item === activePage ? "selected" : ""} key={item} onClick={() => onNavigate?.(item)}>{item}</button>)}</nav><p className="memory-note"> · 退</p></aside>;
}

View File

@ -1,3 +0,0 @@
import { StatusIndicator } from "../ui";
import type { ConnectionState } from "../workspace";
export function StatusBar({ connection }: { connection: ConnectionState }) { return <footer className="statusbar"><StatusIndicator status={connection === "CONNECTED" ? "online" : "warning"} label={connection} /><span> operator@example</span><span> 38ms</span><span> v1.0</span><span></span><span className="status-spacer" /><span></span></footer>; }

View File

@ -1,7 +0,0 @@
import { Badge, Icon, IconButton, StatusIndicator, Tooltip } from "../ui";
import type { TeamserverWorkspace } from "../workspace";
export interface TeamserverRailProps { servers: TeamserverWorkspace[]; activeServerId: string; onSwitch: (serverId: string) => void; onAdd?: () => void; }
export function TeamserverRail({ servers, activeServerId, onSwitch, onAdd }: TeamserverRailProps) {
return <aside className="server-rail" aria-label="Teamserver 列表"><div className="brand">G</div>{servers.map((server) => <Tooltip content={`${server.name} · ${server.connection}`} key={server.id}><button className={`server-button ${server.id === activeServerId ? "active" : ""}`} onClick={() => onSwitch(server.id)} aria-label={`切换到 ${server.name}`} aria-current={server.id === activeServerId ? "true" : undefined}><span>{server.shortName}</span><StatusIndicator compact status={server.connection === "CONNECTED" ? "online" : "warning"} label={server.connection} />{server.unread > 0 && <Badge tone="danger">{server.unread}</Badge>}</button></Tooltip>)}<IconButton className="server-button add" label="添加 Teamserver" onClick={onAdd}><Icon name="add" /></IconButton></aside>;
}

View File

@ -1,10 +0,0 @@
import { ReactNode } from "react";
import type { OperationTab, TeamserverWorkspace } from "../workspace";
import { ContextBar } from "./ContextBar";
import { OperationTabs } from "./OperationTabs";
import { ProjectSidebar } from "./ProjectSidebar";
import { StatusBar } from "./StatusBar";
import { TeamserverRail } from "./TeamserverRail";
export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onAddServer?: () => 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, onAddServer, onActivateTab, onNavigate, onCloseTab, onTogglePin }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} onAdd={onAddServer} /><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

@ -1,6 +0,0 @@
export * from "./ContextBar";
export * from "./OperationTabs";
export * from "./ProjectSidebar";
export * from "./StatusBar";
export * from "./TeamserverRail";
export * from "./WorkspaceLayout";

View File

@ -6,20 +6,21 @@
--online: #38b57a; --warning: #d9a441; --error: #e05b63; --info: #4c8dda;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 0; min-height: 100vh; overflow: auto; background: var(--app); }
button, input, select, textarea { font: inherit; color: inherit; }
body { margin: 0; min-width: 900px; min-height: 100vh; background: var(--app); }
button, input { font: inherit; color: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, [tabindex]:focus-visible { outline: none; box-shadow: var(--focus-ring); position: relative; z-index: 1; }
.shell { display: grid; grid-template-columns: 52px 260px minmax(600px, 1fr); min-width: 912px; min-height: 100vh; background: var(--app); }
button:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--info); outline-offset: -2px; }
.shell { display: grid; grid-template-columns: 52px 260px 1fr; min-height: 100vh; background: var(--app); }
.server-rail { background: #101318; border-right: 1px solid var(--border); padding: 10px 7px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.brand { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3a414c; border-radius: 5px; color: var(--text); background: #1b2027; font: 700 14px ui-monospace, "JetBrains Mono", monospace; margin-bottom: 9px; }
.server-button { position: relative; width: 36px; height: 36px; display:grid; place-items:center; border: 1px solid transparent; border-radius: 5px; background: #1b2027; color: #a9b0bc; font: 600 10px ui-monospace, "JetBrains Mono", monospace; transition: background 100ms ease, border-color 100ms ease; }
.server-button { position: relative; width: 36px; height: 36px; border: 1px solid transparent; border-radius: 5px; background: #1b2027; color: #a9b0bc; font: 600 10px ui-monospace, "JetBrains Mono", monospace; transition: background 100ms ease, border-color 100ms ease; }
.server-button:hover { background: var(--hover); border-color: #343c47; color: var(--text); }
.server-button.active { background: var(--selected); border-color: #3c4959; color: var(--text); }
.server-button.active::before { content: ""; position: absolute; width: 2px; height: 22px; background: var(--info); left: -8px; top: 6px; border-radius: 1px; }
.server-button.add { color: var(--muted); font-size: 18px; background: transparent; border: 1px dashed #3a414c; }
.server-button .ui-status { position:absolute;right:-3px;bottom:-3px;background:#101318;border-radius:50%;padding:2px; }
.server-button .ui-badge { position:absolute;right:-7px;top:-7px; }
.presence { position: absolute; width: 8px; height: 8px; border: 2px solid #101318; border-radius: 50%; right: -3px; bottom: -3px; background: var(--online); }
.presence.degraded { background: var(--warning); }
.badge { position: absolute; min-width: 16px; height: 16px; padding: 0 4px; line-height: 16px; background: var(--error); color: white; border-radius: 8px; right: -7px; top: -7px; font: 600 9px ui-monospace, monospace; }
.project-sidebar { background: var(--nav); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 18px 12px 9px; }
.project-sidebar header { padding: 0 7px 13px; }
.eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .1em; margin: 0 0 6px; }
@ -28,27 +29,28 @@ h1 { font-size: 14px; line-height: 20px; margin: 0 0 8px; font-weight: 600; }
.connection::before { content: "✓"; margin-right: 4px; }
.connection.degraded { color: #e5bb67; background: #322a1d; border-color: #5a4727; }
.connection.degraded::before { content: "!"; }
.table-toolbar label { display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 4px; background: #111419; color: var(--muted); }
.sidebar-search { margin: 8px 1px 17px; }
.table-toolbar input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; }
.search, .table-toolbar label { display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 4px; background: #111419; color: var(--muted); }
.search { margin: 8px 1px 17px; padding: 6px 8px; }
.search input, .table-toolbar input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; }
kbd { border: 1px solid #343b45; border-radius: 3px; padding: 1px 4px; font-size: 9px; white-space: nowrap; }
nav { display: flex; flex-direction: column; gap: 2px; }
nav button { display: flex; justify-content: space-between; padding: 6px 9px; border:0; border-radius: 4px; background:transparent; color: #aab1bd; font-size: 12px; text-align:left; transition: background 100ms ease; }
nav button:hover { background: var(--hover); }
nav button.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); }
nav a { display: flex; justify-content: space-between; padding: 6px 9px; border-radius: 4px; color: #aab1bd; font-size: 12px; transition: background 100ms ease; }
nav a:hover { background: var(--hover); }
nav a.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); }
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; }
.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); }
.tabbar-wrap { display:flex; min-width:0; background:#111419; border-bottom:1px solid var(--border); }.tabbar { display:flex; flex:1; overflow-x:auto; overflow-y:hidden; scrollbar-width:thin; }
.operation-tab{position:relative;display:flex;flex:0 0 min(190px,22vw);min-width:132px;height:38px;border-right:1px solid var(--border);background:#111419}.operation-tab.active{background:var(--content);box-shadow:inset 0 2px var(--info)}.operation-tab>button[role=tab]{min-width:0;flex:1;border:0;border-radius:0;background:transparent;padding:0 5px 0 11px;color:var(--muted);white-space:nowrap;overflow:hidden}.operation-tab:hover{background:var(--hover)}.operation-tab.active>button[role=tab]{color:var(--text)}.operation-tab .tab-action{visibility:hidden;opacity:.55;flex:0 0 25px;width:25px;min-height:0;border:0;border-radius:0;background:transparent;padding:0}.operation-tab:hover .tab-action,.operation-tab.active .tab-action,.operation-tab .tab-action:focus-visible{visibility:visible}.operation-tab .tab-action:hover{opacity:1;background:#ffffff0b}.tab-label{display:flex;align-items:center;gap:6px;overflow:hidden;text-overflow:ellipsis}.dirty{font-size:8px;color:var(--text)}
.running { display: inline-block; width: 6px; height: 6px; border:1px solid currentColor; border-radius: 2px; background: var(--warning); margin-right: 7px; }
.operation-tab[role=tab]{align-items:center;padding-left:11px;color:var(--muted)}.operation-tab[role=tab].active{color:var(--text)}.operation-tab[role=tab]>.tab-label{flex:1;min-width:0}
.tabbar-wrap{position:relative}.tabbar>button.operation-tab{position:relative;display:flex;align-items:center;flex:0 0 min(190px,22vw);min-width:132px;height:38px;border:0;border-right:1px solid var(--border);border-radius:0;background:#111419;padding:0 55px 0 11px;color:var(--muted);white-space:nowrap;overflow:hidden}.tabbar>button.operation-tab.active{background:var(--content);color:var(--text);box-shadow:inset 0 2px var(--info)}.tab-actions-strip{position:absolute;inset:0 auto 0 0;display:flex;pointer-events:none}.tab-actions{display:flex;justify-content:flex-end;align-items:center;flex:0 0 min(190px,22vw);min-width:132px;padding-right:3px}.tab-actions .tab-action{pointer-events:auto;visibility:hidden;opacity:.6}.tab-actions.active .tab-action,.tab-actions:hover .tab-action,.tab-actions .tab-action:focus-visible{visibility:visible}.tab-actions .tab-action:hover{opacity:1}
.tabbar { display: flex; background: #111419; border-bottom: 1px solid var(--border); 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:hover { background: var(--hover); color: #cbd0d8; }
.tabbar button.active { background: var(--content); color: var(--text); box-shadow: inset 0 2px var(--info); }
.close { color: #69717d; margin-left: 15px; }
.running { display: inline-block; width: 6px; height: 6px; 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 i { color: #515967; font-style: normal; }
.contextbar b { color: #cdd2da; font: 500 11px ui-monospace, "JetBrains Mono", monospace; }
.context-lock { display:inline-flex;align-items:center;gap:4px;color: #77a9e4; font: 650 9px ui-monospace, monospace; border-right: 1px solid var(--border); padding-right: 10px; margin-right: 2px; }
.context-lock { color: #77a9e4; font: 650 9px ui-monospace, monospace; border-right: 1px solid var(--border); padding-right: 10px; margin-right: 2px; }
.content { overflow: auto; padding: 22px 24px 30px; background: var(--content); }
.content-heading { display: flex; align-items: center; justify-content: space-between; padding-bottom: 18px; }
.content-heading h2 { font-size: 20px; line-height: 27px; margin: 0; font-weight: 600; letter-spacing: -.01em; }
@ -61,23 +63,13 @@ 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 { --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; }
.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; }
.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); }
.session-data-row { user-select: none; -webkit-user-select: none; }
.session-context-menu{position:fixed;z-index:40;width:220px;padding:5px;border:1px solid #414956;background:#171b21;box-shadow:0 12px 34px #000a}.session-context-menu button{display:block;width:100%;height:29px;border:0;border-radius:2px;padding:0 9px;background:transparent;color:var(--text);text-align:left}.session-context-menu button:hover:not(:disabled),.session-context-menu button:focus-visible{background:var(--selected)}.session-context-menu button:disabled{color:#68717e;cursor:not-allowed}
.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; }
@ -91,65 +83,8 @@ 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 { overflow-x:auto;white-space:nowrap; }.statusbar .ui-status { color: #75c99c; }
.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 220px minmax(600px,1fr); min-width:872px; } .content { padding-inline: 16px; } }
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto!important;transition-duration:0.01ms!important;animation-duration:0.01ms!important;animation-iteration-count:1!important; } }
@media (forced-colors: active) { button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:2px solid Highlight;outline-offset:2px;box-shadow:none; } }
.session-overview > header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
.session-overview h2, .session-overview h3 { margin: 0; }
.overview-grid { display: grid; grid-template-columns: repeat(2, minmax(280px, 1fr)); gap: 12px; }
.overview-grid > section, .operation-form, .operation-confirmation, .group-tree { border: 1px solid var(--border); border-radius: 5px; background: #15191f; padding: 14px; }
.overview-grid h3, .operation-form h3, .operation-confirmation h3 { font-size: 12px; margin-bottom: 12px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(120px, 1fr)); gap: 10px; margin: 0; }
.detail-grid dt { color: var(--muted); font-size: 10px; }.detail-grid dd { margin: 3px 0 0; overflow-wrap: anywhere; }
.card-list, .event-list, .group-tree ul { list-style: none; padding: 0; margin: 0; }.card-list li, .event-list li { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }.card-list small, .event-list time { display: block; color: var(--muted); font-size: 10px; margin-top: 3px; }
.empty-state { color: var(--muted); }.tag-editor { display: flex; flex-wrap: wrap; gap: 6px; }.tag-editor input { min-width: 100px; background: #111419; border: 1px solid var(--border); }
.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; }
@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-list { grid-row: span 3; }
.connection-list > header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.connection-list h2, .connection-list p, .diagnostics h3, .diagnostics p, .import-panel h3 { margin: 0; }
.connection-list p, .diagnostics p { color: var(--muted); margin-top: 4px; }
.state-legend { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 12px; }
.state-legend span { padding: 2px 5px; border: 1px solid var(--border); border-radius: 3px; color: #7f8998; font: 8px ui-monospace, monospace; }
.connection-list article { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 13px; border: 1px solid var(--border); background: #15191f; margin-bottom: 7px; border-radius: 5px; }
.connection-list article.selected { border-color: #4777ac; background: #1c2631; }
.connection-list article strong, .connection-list article code { display: block; }
.connection-list article code { color: var(--muted); margin-top: 4px; }
.connection-state { align-self: start; padding: 3px 6px; border: 1px solid #48515e; border-radius: 3px; font: 9px ui-monospace, monospace; }
.connection-state.connected { color: #82d5aa; border-color: #28523e; }
.record-actions { grid-column: 1 / -1; display: flex; gap: 6px; justify-content: flex-end; }
.record-actions button, .danger-button { border: 1px solid var(--border); border-radius: 4px; padding: 5px 8px; background: #1b2027; }
.danger-button { color: #f18b92; border-color: #6a353a; background: #301d21; }
.connection-list .empty-state, .import-panel, .diagnostics, .connection-notice { padding: 16px; border: 1px solid var(--border); background: #15191f; border-radius: 5px; color: var(--muted); }
.import-panel textarea { width: 100%; min-height: 130px; margin: 10px 0; }
.diagnostics > button { margin: 10px 0; }
.diagnostics output { display: grid; grid-template-columns: 110px 1fr; padding: 6px 0; border-top: 1px solid var(--border); font-size: 10px; }
.diagnostics output b { color: #87b5e8; font-family: ui-monospace, monospace; }
.connection-notice { white-space: pre-wrap; max-height: 260px; overflow: auto; font-size: 10px; }
.connection-editor { max-width: 1100px; }
.editor-grid { display: grid; grid-template-columns: repeat(2, minmax(300px, 1fr)); gap: 12px; align-items: start; }
.connection-section { border: 1px solid var(--border); background: #15191f; border-radius: 5px; padding: 14px; display: grid; gap: 10px; }
.connection-section legend { padding: 0 6px; color: #cbd3de; font-weight: 600; }
.connection-section label { display: grid; gap: 5px; color: #9da6b4; font-size: 11px; }
.connection-section input, .connection-section select, .connection-section textarea, .import-panel textarea { border: 1px solid #353d48; border-radius: 4px; background: #101318; padding: 7px 8px; outline: 0; }
.connection-section textarea { min-height: 65px; resize: vertical; }
.connection-section .check { display: flex; align-items: center; gap: 7px; }
.connection-section .danger { color: #e8b36e; padding: 8px; border: 1px solid #65512f; background: #2b251a; }
.field-error, .form-error { color: #f1888f; }
.connection-editor > footer, .connection-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; background: #080a0dcc; }
.connection-dialog { width: 410px; padding: 20px; border: 1px solid #454e5a; border-radius: 6px; background: #171b21; box-shadow: 0 15px 50px #000a; }
@media (max-width: 1150px) { .connection-center, .editor-grid { grid-template-columns: 1fr; } .connection-list { grid-row: auto; } }
@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 (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }

View File

@ -1,10 +0,0 @@
:root {
--color-app: #0f1115; --color-nav: #14171c; --color-content: #181c22; --color-hover: #20252d;
--color-selected: #27313d; --color-border: #2a3039; --color-text: #e7eaf0; --color-muted: #929aa8;
--color-online: #38b57a; --color-warning: #d9a441; --color-danger: #e05b63; --color-info: #4c8dda;
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px;
--font-xs: 9px; --font-sm: 11px; --font-md: 13px; --font-lg: 20px;
--line-sm: 1.3; --line-md: 1.5; --radius-sm: 4px; --radius-md: 6px;
--border-subtle: 1px solid var(--color-border); --z-popover: 30; --z-dialog: 50;
--duration-fast: 100ms; --duration-normal: 160ms; --focus-ring: 0 0 0 2px #0f1115, 0 0 0 4px #73b7ff;
}

View File

@ -1,121 +0,0 @@
import {
ButtonHTMLAttributes,
HTMLAttributes,
InputHTMLAttributes,
ReactNode,
RefObject,
SelectHTMLAttributes,
useEffect,
useId,
useRef,
useState,
} from "react";
export type Intent = "neutral" | "primary" | "danger";
export type StatusTone = "online" | "warning" | "offline" | "info";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
intent?: Intent;
loading?: boolean;
}
export function Button({ intent = "neutral", loading = false, disabled, className = "", children, ...props }: ButtonProps) {
return <button className={`ui-button ${intent} ${className}`} disabled={disabled || loading} aria-busy={loading || undefined} {...props}>{loading && <span className="ui-spinner" aria-hidden="true" />}{children}</button>;
}
export interface IconButtonProps extends ButtonProps { label: string; }
export function IconButton({ label, children, ...props }: IconButtonProps) {
return <Button className={`ui-icon-button ${props.className ?? ""}`} aria-label={label} title={props.title ?? label} {...props}>{children}</Button>;
}
export function Icon({ name, size = 14 }: { name: "add" | "search" | "close" | "lock" | "more" | "columns" | "check" | "warning"; size?: number }) {
const paths = {
add: <path d="M12 5v14M5 12h14" />,
search: <><circle cx="10.5" cy="10.5" r="5.5" /><path d="m15 15 4 4" /></>,
close: <path d="m7 7 10 10M17 7 7 17" />,
lock: <><rect x="6" y="10" width="12" height="9" rx="2" /><path d="M8.5 10V7.5a3.5 3.5 0 0 1 7 0V10" /></>,
more: <><circle cx="6" cy="12" r="1" fill="currentColor" /><circle cx="12" cy="12" r="1" fill="currentColor" /><circle cx="18" cy="12" r="1" fill="currentColor" /></>,
columns: <><rect x="4" y="5" width="16" height="14" rx="1" /><path d="M10 5v14m4-14v14" /></>,
check: <path d="m5 12 4 4L19 6" />,
warning: <><path d="M12 4 21 20H3Z" /><path d="M12 9v5m0 3v.1" /></>,
};
return <svg className="ui-icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
}
export function Badge({ children, tone = "neutral", className = "" }: { children: ReactNode; tone?: StatusTone | "neutral" | "danger"; className?: string }) {
return <span className={`ui-badge ${tone} ${className}`}>{children}</span>;
}
export function StatusIndicator({ status, label, compact = false }: { status: StatusTone; label: string; compact?: boolean }) {
const mark = status === "online" ? <Icon name="check" /> : status === "warning" ? <Icon name="warning" /> : <span aria-hidden="true"></span>;
return <span className={`ui-status ${status} ${compact ? "compact" : ""}`}><span className="ui-status-mark">{mark}</span>{!compact && <span>{label}</span>}<span className="sr-only">{compact ? label : ""}</span></span>;
}
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; leading?: ReactNode; }
export function Input({ label, leading, className = "", ...props }: InputProps) {
const id = useId();
return <label className={`ui-field ${className}`}>{label && <span className="ui-field-label">{label}</span>}<span className="ui-input-wrap">{leading}<input id={id} {...props} /></span></label>;
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { label?: string; options: { value: string; label: string }[]; }
export function Select({ label, options, className = "", ...props }: SelectProps) {
return <label className={`ui-field ${className}`}>{label && <span className="ui-field-label">{label}</span>}<select {...props}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
}
export function Checkbox({ label, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: ReactNode }) {
return <label className="ui-checkbox"><input type="checkbox" {...props} /><span>{label}</span></label>;
}
export interface TabsProps { label: string; tabs: { id: string; label: ReactNode; title?: string }[]; activeId: string; onChange: (id: string) => void; }
export function Tabs({ label, tabs, activeId, onChange }: TabsProps) {
return <div className="ui-tabs" role="tablist" aria-label={label}>{tabs.map((tab) => <button role="tab" aria-selected={tab.id === activeId} className={tab.id === activeId ? "active" : ""} key={tab.id} title={tab.title} onClick={() => onChange(tab.id)}>{tab.label}</button>)}</div>;
}
export function Tooltip({ content, children }: { content: ReactNode; children: ReactNode }) {
return <span className="ui-tooltip">{children}<span role="tooltip">{content}</span></span>;
}
export interface OverlayProps { open: boolean; onOpenChange: (open: boolean) => void; children: ReactNode; }
function useDismiss(open: boolean, onOpenChange: (open: boolean) => void, container: RefObject<HTMLElement | null>) {
const returnFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
returnFocus.current = document.activeElement as HTMLElement;
const escape = (event: KeyboardEvent) => event.key === "Escape" && onOpenChange(false);
const outside = (event: PointerEvent) => !container.current?.contains(event.target as Node) && onOpenChange(false);
document.addEventListener("keydown", escape);
document.addEventListener("pointerdown", outside);
return () => { document.removeEventListener("keydown", escape); document.removeEventListener("pointerdown", outside); returnFocus.current?.focus(); };
}, [open, onOpenChange, container]);
}
export function Popover({ open, onOpenChange, children }: OverlayProps) {
const ref = useRef<HTMLDivElement>(null);
useDismiss(open, onOpenChange, ref);
return open ? <div className="ui-popover" ref={ref} role="dialog">{children}</div> : null;
}
export function Dialog({ open, onOpenChange, title, children }: OverlayProps & { title: string }) {
const ref = useRef<HTMLDivElement>(null);
const titleId = useId();
useDismiss(open, onOpenChange, ref);
useEffect(() => { if (open) ref.current?.querySelector<HTMLElement>("button, input, select, [tabindex]")?.focus(); }, [open]);
if (!open) return null;
return <div className="ui-dialog-backdrop" onPointerDown={(event) => event.target === event.currentTarget && onOpenChange(false)}><div className="ui-dialog" ref={ref} role="dialog" aria-modal="true" aria-labelledby={titleId}><header><h2 id={titleId}>{title}</h2><IconButton label="关闭对话框" onClick={() => onOpenChange(false)}><Icon name="close" /></IconButton></header>{children}</div></div>;
}
export function EmptyState({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
return <div className="ui-empty"><strong>{title}</strong>{description && <p>{description}</p>}{action}</div>;
}
export function Skeleton({ width = "100%", className = "" }: { width?: string | number; className?: string }) { return <span className={`ui-skeleton ${className}`} style={{ width }} aria-hidden="true" />; }
export function Toast({ tone = "info", children, onDismiss }: { tone?: StatusTone | "danger"; children: ReactNode; onDismiss?: () => void }) {
return <div className={`ui-toast ${tone}`} role={tone === "danger" ? "alert" : "status"}>{children}{onDismiss && <IconButton label="关闭通知" onClick={onDismiss}><Icon name="close" /></IconButton>}</div>;
}
export function DataToolbar({ children, className = "", ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={`ui-data-toolbar ${className}`} role="toolbar" {...props}>{children}</div>; }
export function DemoOverlays() {
const [open, setOpen] = useState(false);
return <><Button onClick={() => setOpen(true)}></Button><Dialog open={open} onOpenChange={setOpen} title="示例"></Dialog></>;
}

View File

@ -1,8 +0,0 @@
.ui-button { min-height: 28px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border:var(--border-subtle); border-radius:var(--radius-sm); padding:5px 9px; color:var(--color-text); background:#1b2027; font-size:var(--font-sm); }
.ui-button:hover:not(:disabled) { background:var(--color-hover); }.ui-button:active:not(:disabled){background:#252c35}.ui-button:disabled{cursor:not-allowed;opacity:.45}.ui-button.primary{background:#326ba9;border-color:#457dbd}.ui-button.primary:hover:not(:disabled){background:#3d78b8}.ui-button.danger{color:#ffdadd;background:#6c292f;border-color:#a2434b}.ui-icon-button{width:28px;padding:0}.ui-icon{flex:none}.ui-spinner{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:ui-spin .7s linear infinite}
.ui-badge{display:inline-flex;min-width:16px;height:16px;align-items:center;justify-content:center;padding:0 4px;border-radius:8px;background:#343b45;font:600 var(--font-xs) ui-monospace}.ui-badge.danger{background:var(--color-danger);color:white}
.ui-status{display:inline-flex;align-items:center;gap:5px;color:var(--color-muted);font:600 var(--font-xs) ui-monospace}.ui-status-mark{width:15px;height:15px;display:grid;place-items:center;border:1px solid currentColor;border-radius:3px}.ui-status.online{color:#82d5aa}.ui-status.warning{color:#e5bb67}.ui-status.offline{color:#a4abb5}.ui-status.compact .ui-status-mark{width:10px;height:10px;border-radius:50%}.ui-status.compact svg{display:none}
.ui-field{display:grid;gap:4px}.ui-field-label{color:var(--color-muted);font-size:var(--font-sm)}.ui-input-wrap,.ui-field select{display:flex;align-items:center;gap:7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#111419;padding:6px 8px}.ui-field input{width:100%;min-width:0;border:0;outline:0;background:transparent}.ui-field select{color:var(--color-text)}.ui-checkbox{display:flex;align-items:center;gap:7px}.ui-tabs{display:flex;overflow:auto}.ui-tabs>button{flex:0 0 auto}.ui-tooltip{position:relative;display:inline-flex}.ui-tooltip [role=tooltip]{position:absolute;left:50%;bottom:calc(100% + 7px);z-index:var(--z-popover);transform:translateX(-50%);visibility:hidden;opacity:0;white-space:nowrap;padding:5px 7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#080a0d;color:var(--color-text);transition:opacity var(--duration-fast)}.ui-tooltip:hover [role=tooltip],.ui-tooltip:focus-within [role=tooltip]{visibility:visible;opacity:1}
.ui-popover{position:absolute;z-index:var(--z-popover);padding:10px;border:1px solid #3a424e;border-radius:var(--radius-md);background:#15191f;box-shadow:0 8px 24px #090b0ecc}.ui-dialog-backdrop{position:fixed;inset:0;z-index:var(--z-dialog);display:grid;place-items:center;background:#06080bb8}.ui-dialog{width:min(480px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;border:1px solid #3a424e;border-radius:var(--radius-md);background:var(--color-content);box-shadow:0 20px 60px #000a;padding:16px}.ui-dialog>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.ui-dialog h2{margin:0;font-size:16px}.ui-empty{display:grid;justify-items:center;gap:8px;padding:40px;color:var(--color-muted);text-align:center}.ui-empty p{margin:0}.ui-skeleton{display:inline-block;height:12px;border-radius:3px;background:linear-gradient(90deg,#222831,#343c47,#222831);background-size:200% 100%;animation:ui-shimmer 1.4s ease infinite}.ui-toast{position:fixed;right:16px;bottom:36px;z-index:var(--z-popover);display:flex;align-items:center;gap:12px;max-width:360px;padding:10px 12px;border:var(--border-subtle);border-left:3px solid var(--color-info);border-radius:var(--radius-sm);background:#171b21;box-shadow:0 8px 24px #0008}.ui-toast.danger{border-left-color:var(--color-danger)}.ui-data-toolbar{display:flex;align-items:center;gap:6px}
@keyframes ui-spin{to{transform:rotate(360deg)}}@keyframes ui-shimmer{to{background-position:-200% 0}}
@media(prefers-reduced-motion:reduce){.ui-spinner,.ui-skeleton{animation:none}.ui-button:active{transform:none}}

View File

@ -1,13 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { Button, Dialog, Popover, StatusIndicator } from ".";
function DialogFixture() { const [open, setOpen] = useState(false); return <><button onClick={() => setOpen(true)}></button><Dialog open={open} onOpenChange={setOpen} title="确认操作"><Button></Button></Dialog></>; }
function PopoverFixture() { const [open, setOpen] = useState(true); return <div><Popover open={open} onOpenChange={setOpen}><button></button></Popover><button></button></div>; }
describe("设计系统关键交互", () => {
it("Dialog 支持 Escape 关闭并将焦点归还触发器", () => { render(<DialogFixture />); const trigger = screen.getByRole("button", { name: "触发器" }); trigger.focus(); fireEvent.click(trigger); expect(screen.getByRole("dialog")).toBeInTheDocument(); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); });
it("Popover 点击外部后关闭", () => { render(<PopoverFixture />); expect(screen.getByRole("dialog")).toBeInTheDocument(); fireEvent.pointerDown(screen.getByRole("button", { name: "外部" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); });
it("加载态会禁用按钮,状态同时提供文本", () => { render(<><Button loading></Button><StatusIndicator status="warning" label="DEGRADED" /></>); expect(screen.getByRole("button", { name: "保存" })).toBeDisabled(); expect(screen.getByText("DEGRADED")).toBeInTheDocument(); });
});

1
src/vite-env.d.ts vendored
View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -1,68 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createInitialWorkspaceState,
selectActiveTab,
selectActiveWorkspace,
type WorkspaceAction,
workspaceReducer,
} from "./workspace";
describe("workspaceReducer", () => {
it("隔离每个 Teamserver 的导航、筛选与未读状态", () => {
const initial = createInitialWorkspaceState();
const actions: WorkspaceAction[] = [
{ type: "navigate", serverId: "atlas", project: "Orchid", navigation: "Audit" },
{ type: "setFilter", serverId: "atlas", filter: "status:online" },
{ type: "setUnread", serverId: "atlas", unread: 0 },
];
const changed = actions.reduce(workspaceReducer, initial);
expect(changed.servers.atlas).toMatchObject({ activeProject: "Orchid", navigation: "Audit", filter: "status:online", unread: 0 });
expect(changed.servers.ember).toMatchObject({ activeProject: "Sandbox", navigation: "Events", filter: "", unread: 5 });
});
it("切回 Teamserver 时恢复各自的激活标签", () => {
let state = createInitialWorkspaceState();
state = workspaceReducer(state, { type: "activateTab", serverId: "atlas", tabId: "nw-042" });
state = workspaceReducer(state, { type: "switchServer", serverId: "ember" });
state = workspaceReducer(state, { type: "activateTab", serverId: "ember", tabId: "sb-107" });
state = workspaceReducer(state, { type: "switchServer", serverId: "atlas" });
expect(selectActiveTab(selectActiveWorkspace(state)).id).toBe("nw-042");
expect(state.servers.ember.activeTabId).toBe("sb-107");
});
it("导航与重复 tab ID 均不能重定向已创建标签的上下文", () => {
let state = createInitialWorkspaceState();
const originalContext = state.servers.atlas.tabs[1].context;
state = workspaceReducer(state, { type: "navigate", serverId: "atlas", project: "Orchid", navigation: "Sessions" });
state = workspaceReducer(state, {
type: "openTab",
serverId: "atlas",
tab: { id: "nw-042", title: "被拒绝的替换", context: { serverId: "atlas", project: "Orchid", session: "OR-999" } },
});
expect(state.servers.atlas.tabs[1].context).toEqual(originalContext);
expect(state.servers.atlas.tabs[1].title).toBe("Session Overview");
});
it("退出等价于丢弃内存状态,新实例不恢复业务状态", () => {
const changed = workspaceReducer(createInitialWorkspaceState(), { type: "setFilter", serverId: "atlas", filter: "secret" });
const restarted = createInitialWorkspaceState();
expect(changed.servers.atlas.filter).toBe("secret");
expect(restarted.servers.atlas.filter).toBe("");
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

@ -1,51 +1,25 @@
export type ConnectionState = "CONNECTED" | "DEGRADED" | "RECONNECTING";
export interface TabContext {
readonly serverId: string;
readonly project: string;
readonly session: string;
}
export interface OperationTab {
readonly id: string;
readonly context: TabContext;
readonly title: string;
readonly running?: boolean;
readonly pinned?: boolean;
readonly dirty?: boolean;
id: string;
project: string;
session: string;
title: string;
running?: boolean;
}
export interface TeamserverWorkspace {
readonly id: string;
readonly name: string;
readonly shortName: string;
readonly connection: ConnectionState;
readonly tabs: readonly OperationTab[];
activeTabId: string;
activeProject: string;
navigation: string;
filter: string;
id: string;
name: string;
shortName: string;
connection: ConnectionState;
unread: number;
activeProject: string;
activeTabId: string;
tabs: OperationTab[];
}
export interface WorkspaceState {
activeServerId: string;
servers: Record<string, TeamserverWorkspace>;
serverOrder: readonly string[];
}
export type WorkspaceAction =
| { type: "switchServer"; serverId: string }
| { type: "activateTab"; serverId: string; tabId: string }
| { type: "navigate"; serverId: string; project: string; navigation: string }
| { type: "setFilter"; serverId: string; filter: string }
| { type: "setUnread"; serverId: string; unread: number }
| { type: "openTab"; serverId: string; tab: OperationTab }
| { type: "addServer"; server: TeamserverWorkspace }
| { type: "closeTab"; serverId: string; tabId: string }
| { type: "togglePinTab"; serverId: string; tabId: string };
const seedWorkspaces: readonly TeamserverWorkspace[] = [
export const initialWorkspaces: TeamserverWorkspace[] = [
{
id: "atlas",
name: "Atlas Teamserver",
@ -53,13 +27,11 @@ const seedWorkspaces: readonly TeamserverWorkspace[] = [
connection: "CONNECTED",
unread: 2,
activeProject: "Northwind",
navigation: "Sessions",
filter: "",
activeTabId: "nw-sessions",
tabs: [
{ id: "nw-sessions", context: { serverId: "atlas", project: "Northwind", session: "All sessions" }, title: "Sessions" },
{ id: "nw-042", context: { serverId: "atlas", project: "Northwind", session: "NW-042" }, title: "Session Overview" },
{ id: "or-audit", context: { serverId: "atlas", project: "Orchid", session: "Project" }, title: "Audit", running: true },
{ id: "nw-sessions", project: "Northwind", session: "All sessions", title: "Sessions" },
{ id: "nw-042", project: "Northwind", session: "NW-042", title: "Session Overview" },
{ id: "or-audit", project: "Orchid", session: "Project", title: "Audit", running: true },
],
},
{
@ -69,98 +41,17 @@ const seedWorkspaces: readonly TeamserverWorkspace[] = [
connection: "DEGRADED",
unread: 5,
activeProject: "Sandbox",
navigation: "Events",
filter: "",
activeTabId: "sb-events",
tabs: [
{ id: "sb-events", context: { serverId: "ember", project: "Sandbox", session: "Project" }, title: "Events" },
{ id: "sb-107", context: { serverId: "ember", project: "Sandbox", session: "SB-107" }, title: "Task Output", running: true },
{ id: "sb-events", project: "Sandbox", session: "Project", title: "Events" },
{ id: "sb-107", project: "Sandbox", session: "SB-107", title: "Task Output", running: true },
],
},
];
function cloneServer(server: TeamserverWorkspace): TeamserverWorkspace {
return {
...server,
tabs: server.tabs.map((tab) => ({ ...tab, context: { ...tab.context } })),
};
}
/** 每次进程启动都从种子数据创建全新状态,不读取或写入任何持久化存储。 */
export function createInitialWorkspaceState(): WorkspaceState {
return {
activeServerId: seedWorkspaces[0].id,
serverOrder: seedWorkspaces.map(({ id }) => id),
servers: Object.fromEntries(seedWorkspaces.map((server) => [server.id, cloneServer(server)])),
};
}
function updateServer(
state: WorkspaceState,
serverId: string,
update: (server: TeamserverWorkspace) => TeamserverWorkspace,
): WorkspaceState {
const server = state.servers[serverId];
if (!server) return state;
const nextServer = update(server);
if (nextServer === server) return state;
return { ...state, servers: { ...state.servers, [serverId]: nextServer } };
}
export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState {
switch (action.type) {
case "addServer":
if (state.servers[action.server.id]) return state;
return { ...state, activeServerId: action.server.id, serverOrder: [...state.serverOrder, action.server.id], servers: { ...state.servers, [action.server.id]: action.server } };
case "switchServer":
return state.servers[action.serverId] && action.serverId !== state.activeServerId
? { ...state, activeServerId: action.serverId }
: state;
case "activateTab":
return updateServer(state, action.serverId, (server) =>
server.tabs.some(({ id }) => id === action.tabId) && server.activeTabId !== action.tabId
? { ...server, activeTabId: action.tabId }
: server,
);
case "navigate":
return updateServer(state, action.serverId, (server) => ({
...server,
activeProject: action.project,
navigation: action.navigation,
}));
case "setFilter":
return updateServer(state, action.serverId, (server) => ({ ...server, filter: action.filter }));
case "setUnread":
return updateServer(state, action.serverId, (server) => ({ ...server, unread: Math.max(0, action.unread) }));
case "openTab":
return updateServer(state, action.serverId, (server) => {
if (action.tab.context.serverId !== action.serverId) return server;
const existing = server.tabs.find(({ id }) => id === action.tab.id);
// 相同 ID 只能重新激活,不能借导航或重复创建改写其锁定上下文。
if (existing) return { ...server, activeTabId: existing.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),
}));
}
}
export function selectActiveWorkspace(state: WorkspaceState): TeamserverWorkspace {
return state.servers[state.activeServerId] ?? state.servers[state.serverOrder[0]];
}
export function selectActiveTab(server: TeamserverWorkspace): OperationTab {
return server.tabs.find(({ id }) => id === server.activeTabId) ?? server.tabs[0];
export function selectWorkspace(
workspaces: TeamserverWorkspace[],
workspaceId: string,
): TeamserverWorkspace | undefined {
return workspaces.find(({ id }) => id === workspaceId);
}