Compare commits
No commits in common. "main" and "agent/docker/20e0ad2a" have entirely different histories.
main
...
agent/dock
@ -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
|
||||
35
README.md
35
README.md
@ -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
11
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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("不会发送真实请求");
|
||||
});
|
||||
});
|
||||
|
||||
135
src/App.tsx
135
src/App.tsx
@ -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>1–50 / 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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("1–2 / 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();
|
||||
});
|
||||
});
|
||||
@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
@ -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 } }
|
||||
@ -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("检测到事件缺口");
|
||||
});
|
||||
});
|
||||
@ -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>;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export * from "./types";
|
||||
export * from "./state";
|
||||
export * from "./memoryAdapter";
|
||||
export * from "./components";
|
||||
@ -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("不可取消");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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} 秒`;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>抖动(0–1)<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>;
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from "./model";
|
||||
export * from "./mockAdapter";
|
||||
export * from "./components";
|
||||
@ -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} 配置结构有效(未连接真实服务器)` };
|
||||
}
|
||||
}
|
||||
@ -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("••••••••");
|
||||
});
|
||||
});
|
||||
@ -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 = "超时范围为 1–300 秒";
|
||||
if (config.heartbeatSeconds < 5 || config.heartbeatSeconds > 3600) errors.heartbeatSeconds = "心跳范围为 5–3600 秒";
|
||||
if (config.reconnect.initialSeconds > config.reconnect.maxSeconds) errors.reconnect = "初始退避不能大于最大退避";
|
||||
if (config.reconnect.jitter < 0 || config.reconnect.jitter > 1) errors.jitter = "抖动范围为 0–1";
|
||||
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 ? "••••••••" : "未设置"; }
|
||||
@ -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 })}</>;
|
||||
}
|
||||
@ -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" });
|
||||
});
|
||||
});
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export function SessionNotes({ notes }: { notes?: string }) {
|
||||
return <section><h3>服务端备注</h3><p className={notes ? "" : "empty-state"}>{notes || "暂无备注"}</p></section>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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";
|
||||
@ -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" });
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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></>;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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];
|
||||
@ -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>>;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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`;
|
||||
}
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
@ -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>; }
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>; }
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>; }
|
||||
@ -1,6 +0,0 @@
|
||||
export * from "./ContextBar";
|
||||
export * from "./OperationTabs";
|
||||
export * from "./ProjectSidebar";
|
||||
export * from "./StatusBar";
|
||||
export * from "./TeamserverRail";
|
||||
export * from "./WorkspaceLayout";
|
||||
117
src/styles.css
117
src/styles.css
@ -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; } }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
121
src/ui/index.tsx
121
src/ui/index.tsx
@ -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></>;
|
||||
}
|
||||
@ -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}}
|
||||
@ -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
1
src/vite-env.d.ts
vendored
@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
155
src/workspace.ts
155
src/workspace.ts
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user