Compare commits
8 Commits
fbcc342c5f
...
12ce632de7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ce632de7 | ||
| 7e8290f6b8 | |||
| fa61c39bd8 | |||
|
|
6e422f6e41 | ||
|
|
938ecf86a0 | ||
|
|
1a32791922 | ||
|
|
70f69b0683 | ||
|
|
1748d2d66a |
@ -1,6 +1,5 @@
|
|||||||
|
.git
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-tests
|
src-tauri/target
|
||||||
.git
|
*.log
|
||||||
.env
|
|
||||||
npm-debug.log
|
|
||||||
|
|||||||
54
.forgejo/workflows/ci.yml
Normal file
54
.forgejo/workflows/ci.yml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
22
Dockerfile
22
Dockerfile
@ -1,23 +1,17 @@
|
|||||||
FROM node:22.18.0-alpine3.22 AS dependencies
|
FROM node:22.18.0-alpine3.22 AS dependencies
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --ignore-scripts
|
RUN npm ci
|
||||||
|
|
||||||
FROM dependencies AS test
|
FROM dependencies AS test
|
||||||
COPY tsconfig.json tsconfig.build.json ./
|
COPY . .
|
||||||
COPY src ./src
|
|
||||||
COPY test ./test
|
|
||||||
CMD ["npm", "test"]
|
CMD ["npm", "test"]
|
||||||
|
|
||||||
FROM dependencies AS build
|
FROM dependencies AS build
|
||||||
COPY tsconfig.json tsconfig.build.json ./
|
COPY . .
|
||||||
COPY src ./src
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22.18.0-alpine3.22 AS production
|
FROM nginxinc/nginx-unprivileged:1.29.1-alpine3.22 AS preview
|
||||||
WORKDIR /app
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
ENV NODE_ENV=production
|
EXPOSE 8080
|
||||||
COPY package.json ./
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
|
||||||
COPY --from=build /app/dist ./dist
|
|
||||||
USER node
|
|
||||||
CMD ["node", "--input-type=module", "--eval", "import('@gaza/protocol')"]
|
|
||||||
|
|||||||
108
README.md
108
README.md
@ -1,20 +1,106 @@
|
|||||||
# gaza
|
# Gaza
|
||||||
|
|
||||||
GUI 与兼容 Teamserver 之间的首版强类型 TypeScript 协议模型。本包只定义标准对象与运行时解析边界,不包含网络请求、认证或私有 C2 适配。
|
Gaza 是一个协议优先、跨平台的多 Teamserver 桌面工作台。当前仓库包含可运行的首个项目骨架,重点验证 Teamserver 工作区和标签页隔离模型。
|
||||||
|
|
||||||
## 使用
|
## 标准协议模型
|
||||||
|
|
||||||
```ts
|
`src/protocol` 定义 GUI 与兼容 Teamserver 之间的首版强类型模型,包括 Teamserver、Project、Session、Capability、Task、Event、Artifact 和 Audit。该模块不包含网络请求、认证或私有 C2 适配。
|
||||||
import { parseSession, type Session } from "@gaza/protocol";
|
|
||||||
|
|
||||||
const session: Session = parseSession(teamserverPayload);
|
`ServerId`、`ProjectId`、`SessionId` 等采用品牌类型,避免在编译期误用不同层级 ID;枚举允许未来服务端新增值,解析器保留未知枚举和扩展字段。迁移现有界面数据时可从 `src/protocol/fixtures` 引入最小演示 fixture。
|
||||||
```
|
|
||||||
|
|
||||||
`ServerId`、`ProjectId`、`SessionId` 等采用品牌类型,避免在编译期误用不同层级 ID。枚举使用前向兼容字符串表达,解析器保留未知枚举和扩展字段。
|
## 技术栈
|
||||||
|
|
||||||
## 验证
|
- Tauri 2(桌面容器)
|
||||||
|
- React 19 + TypeScript + Vite(界面)
|
||||||
|
- Vitest + Testing Library(单元与组件测试)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
本机安装 Node.js 22 与 Rust stable 后:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm test
|
npm ci
|
||||||
docker compose build production
|
npm run dev # 浏览器开发模式
|
||||||
|
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 测试服务
|
||||||
|
|
||||||
|
构建镜像并在后台启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 <http://127.0.0.1:1420>。查看状态和日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f web
|
||||||
|
./scripts/check-web-health.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
停止服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
默认只监听本机回环地址。需要让局域网测试设备访问时,显式设置监听地址和端口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEB_BIND_ADDRESS=0.0.0.0 WEB_PORT=1420 docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
开放到局域网前请确认主机防火墙和测试网络范围;Web 预览不包含认证能力。
|
||||||
|
|
||||||
|
## 当前范围
|
||||||
|
|
||||||
|
- 展示多 Teamserver 连接状态和独立工作区。
|
||||||
|
- 切换 Teamserver 时隔离标签、项目导航和激活状态。
|
||||||
|
- 所有演示业务状态仅保存在进程内存,不写入本地存储。
|
||||||
|
- 提供 Tauri 最小权限配置;后续连接、协议与加密 SQLite 能力按独立迭代接入。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```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` 限制。
|
||||||
|
|||||||
27
compose.yaml
27
compose.yaml
@ -1,35 +1,28 @@
|
|||||||
services:
|
services:
|
||||||
test:
|
test:
|
||||||
image: gaza-protocol-test:local
|
profiles: ["tools"]
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: test
|
target: test
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
- /app/dist
|
|
||||||
- /app/dist-tests
|
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: gaza-protocol-build:local
|
profiles: ["tools"]
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: build
|
target: build
|
||||||
command: ["npm", "run", "build"]
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: preview
|
||||||
|
ports:
|
||||||
|
- "${WEB_BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-1420}:8080"
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
- /app/dist
|
- /var/cache/nginx
|
||||||
security_opt:
|
- /var/run
|
||||||
- no-new-privileges:true
|
|
||||||
|
|
||||||
production:
|
|
||||||
image: gaza-protocol:local
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: production
|
|
||||||
read_only: true
|
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#151515" />
|
||||||
|
<title>Gaza</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3250
package-lock.json
generated
3250
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@ -1,28 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "@gaza/protocol",
|
"name": "gaza",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./fixtures": {
|
|
||||||
"types": "./dist/fixtures/index.d.ts",
|
|
||||||
"import": "./dist/fixtures/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"check": "tsc -p tsconfig.json --noEmit",
|
"build": "tsc -b && vite build",
|
||||||
"test": "npm run build && tsc -p tsconfig.json && node --test dist-tests/test/**/*.test.js"
|
"typecheck": "tsc -b --pretty false",
|
||||||
|
"test": "vitest run",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "2.8.0",
|
||||||
|
"react": "19.1.1",
|
||||||
|
"react-dom": "19.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "22.17.2",
|
"@tauri-apps/cli": "2.8.4",
|
||||||
"typescript": "5.9.2"
|
"@testing-library/jest-dom": "6.8.0",
|
||||||
|
"@testing-library/react": "16.3.0",
|
||||||
|
"@types/react": "19.1.10",
|
||||||
|
"@types/react-dom": "19.1.7",
|
||||||
|
"@vitejs/plugin-react": "5.0.2",
|
||||||
|
"jsdom": "26.1.0",
|
||||||
|
"typescript": "5.9.2",
|
||||||
|
"vite": "7.3.6",
|
||||||
|
"vitest": "3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
scripts/check-web-health.sh
Executable file
32
scripts/check-web-health.sh
Executable file
@ -0,0 +1,32 @@
|
|||||||
|
#!/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
|
||||||
18
src-tauri/Cargo.toml
Normal file
18
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "gaza"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "多 Teamserver 桌面工作台"
|
||||||
|
authors = ["Gaza contributors"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "gaza_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.4.1", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2.8.5", features = [] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
7
src-tauri/capabilities/default.json
Normal file
7
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "主窗口的最小权限",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
6
src-tauri/src/lib.rs
Normal file
6
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("启动 Gaza 桌面客户端失败");
|
||||||
|
}
|
||||||
3
src-tauri/src/main.rs
Normal file
3
src-tauri/src/main.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
gaza_lib::run();
|
||||||
|
}
|
||||||
17
src-tauri/tauri.conf.json
Normal file
17
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Gaza",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "red.gaza.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [{ "title": "Gaza", "width": 1280, "height": 800, "minWidth": 900, "minHeight": 600 }],
|
||||||
|
"security": { "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" }
|
||||||
|
},
|
||||||
|
"bundle": { "active": true, "targets": "all" }
|
||||||
|
}
|
||||||
24
src/App.test.tsx
Normal file
24
src/App.test.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
describe("Teamserver 工作区", () => {
|
||||||
|
it("切换服务器时只显示对应服务器标签,并恢复各自激活标签", () => {
|
||||||
|
render(<App />);
|
||||||
|
expect(screen.getByRole("tab", { name: /Session Overview/ })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /Session Overview/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "切换到 Ember Lab" }));
|
||||||
|
expect(screen.queryByRole("tab", { name: /Session Overview/ })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: /Task Output/ })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "切换到 Atlas Teamserver" }));
|
||||||
|
expect(screen.getByRole("tab", { name: /Session Overview/ })).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("显示锁定上下文和高密度 Session 表格", () => {
|
||||||
|
render(<App />);
|
||||||
|
expect(screen.getByLabelText("锁定的操作上下文")).toHaveTextContent("Atlas Teamserver / Northwind / All sessions / Sessions");
|
||||||
|
expect(screen.getByLabelText("Session 表格")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("NW-042")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("10.42.8.17")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
107
src/App.tsx
Normal file
107
src/App.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useReducer } from "react";
|
||||||
|
import {
|
||||||
|
createInitialWorkspaceState,
|
||||||
|
selectActiveTab,
|
||||||
|
selectActiveWorkspace,
|
||||||
|
workspaceReducer,
|
||||||
|
} from "./workspace";
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
|
||||||
|
const server = selectActiveWorkspace(workspace);
|
||||||
|
const activeTab = selectActiveTab(server);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<aside className="server-rail" aria-label="Teamserver 列表">
|
||||||
|
<div className="brand">G</div>
|
||||||
|
{workspace.serverOrder.map((serverId) => workspace.servers[serverId]).map((item) => (
|
||||||
|
<button
|
||||||
|
className={`server-button ${item.id === server.id ? "active" : ""}`}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => dispatch({ type: "switchServer", serverId: 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={() => dispatch({ type: "activateTab", serverId: server.id, tabId: tab.id })}
|
||||||
|
title={`${tab.context.project} / ${tab.context.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.context.project} <i>/</i> <b>{activeTab.context.session}</b> <i>/</i> {activeTab.title}
|
||||||
|
</div>
|
||||||
|
<article className="content">
|
||||||
|
<div className="content-heading">
|
||||||
|
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 中的 248 个 Session · 4 个实时更新</p></div>
|
||||||
|
<div className="heading-actions"><button className="secondary">导出视图</button><button className="primary">+ 新建操作</button></div>
|
||||||
|
</div>
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<label><span>⌕</span><input aria-label="筛选 Session" placeholder="筛选 Session…" value={server.filter} onChange={(event) => dispatch({ type: "setFilter", serverId: server.id, filter: event.target.value })} /></label>
|
||||||
|
<button>状态:全部⌄</button><button>标签⌄</button><button>+ 筛选</button>
|
||||||
|
<span className="toolbar-spacer" /><button aria-label="调整列">☷ 列</button><button aria-label="更多操作">•••</button>
|
||||||
|
</div>
|
||||||
|
<section className="session-table" aria-label="Session 表格">
|
||||||
|
<div className="table-row table-head" role="row"><span><input type="checkbox" aria-label="选择全部" /></span><span>STATUS ↕</span><span>SESSION ID</span><span>HOST / USER</span><span>OS</span><span>SOURCE ADDRESS</span><span>LAST SEEN ↓</span><span>TAGS</span><span /></div>
|
||||||
|
{sessions.map((session) => <div className={`table-row ${session.id === "NW-042" ? "focused" : ""}`} role="row" key={session.id} tabIndex={0}>
|
||||||
|
<span><input type="checkbox" aria-label={`选择 ${session.id}`} /></span>
|
||||||
|
<span><em className={`status-mark ${session.status.toLowerCase()}`} />{session.status}</span>
|
||||||
|
<strong className="mono">{session.id}</strong>
|
||||||
|
<span><b>{session.host}</b><small>{session.user}</small></span>
|
||||||
|
<span>{session.os}</span><span className="mono muted">{session.address}</span><span className="mono muted">{session.seen}</span>
|
||||||
|
<span className="tags">{session.tags.map((tag) => <small key={tag}>{tag}</small>)}</span><button className="row-action" aria-label={`${session.id} 快速操作`}>•••</button>
|
||||||
|
</div>)}
|
||||||
|
<footer><span>已选择 0 项</span><span>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,2 +0,0 @@
|
|||||||
export * from "./protocol.js";
|
|
||||||
export * from "./parser.js";
|
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import type { Session } from "../protocol.js";
|
import type { Session } from "../model";
|
||||||
|
|
||||||
/** UI 迁移期可直接使用的最小 Session fixture。 */
|
/** UI 迁移期可直接使用的最小 Session fixture。 */
|
||||||
export const demoSession = {
|
export const demoSession = {
|
||||||
23
src/protocol/parser.test.ts
Normal file
23
src/protocol/parser.test.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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,4 +1,4 @@
|
|||||||
import type { ProjectContext, ProtocolVersion, Session, SessionContext } from "./protocol.js";
|
import type { ProjectContext, ProtocolVersion, Session, SessionContext } from "./model";
|
||||||
|
|
||||||
export class ProtocolParseError extends Error {
|
export class ProtocolParseError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
90
src/styles.css
Normal file
90
src/styles.css
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #e7eaf0; background: #0f1115; font-synthesis: none; font-size: 13px;
|
||||||
|
--app: #0f1115; --nav: #14171c; --content: #181c22; --hover: #20252d;
|
||||||
|
--selected: #27313d; --border: #2a3039; --text: #e7eaf0; --muted: #929aa8;
|
||||||
|
--online: #38b57a; --warning: #d9a441; --error: #e05b63; --info: #4c8dda;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
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, [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; 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; }
|
||||||
|
.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; }
|
||||||
|
h1 { font-size: 14px; line-height: 20px; margin: 0 0 8px; font-weight: 600; }
|
||||||
|
.connection { display: inline-flex; font: 600 9px ui-monospace, monospace; color: #82d5aa; background: #193128; border: 1px solid #28523e; border-radius: 4px; padding: 2px 5px; }
|
||||||
|
.connection::before { content: "✓"; margin-right: 4px; }
|
||||||
|
.connection.degraded { color: #e5bb67; background: #322a1d; border-color: #5a4727; }
|
||||||
|
.connection.degraded::before { content: "!"; }
|
||||||
|
.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 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 { 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 { 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; }
|
||||||
|
.content-heading p { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
|
||||||
|
.heading-actions { display: flex; gap: 7px; }
|
||||||
|
.primary, .secondary, .table-toolbar button { border: 1px solid var(--border); border-radius: 4px; padding: 6px 9px; font-size: 11px; background: #1b2027; }
|
||||||
|
.primary { border-color: #457dbd; background: #326ba9; color: #fff; }
|
||||||
|
.primary:hover { background: #3d78b8; }
|
||||||
|
.secondary:hover, .table-toolbar button:hover { background: var(--hover); }
|
||||||
|
.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; }
|
||||||
|
.toolbar-spacer, .status-spacer { flex: 1; }
|
||||||
|
.session-table { min-width: 880px; border: 1px solid var(--border); background: #15191f; }
|
||||||
|
.table-row { display: grid; grid-template-columns: 34px 92px 104px minmax(150px, 1.3fr) 120px 130px 92px minmax(120px, 1fr) 34px; min-height: 36px; border-bottom: 1px solid #252b33; align-items: stretch; font-size: 12px; color: #cbd0d8; }
|
||||||
|
.table-row > span, .table-row > strong, .table-row > button { display: flex; align-items: center; padding: 0 9px; min-width: 0; border-right: 1px solid #222831; }
|
||||||
|
.table-row:hover { background: var(--hover); }
|
||||||
|
.table-row.focused { background: #202a35; box-shadow: inset 2px 0 var(--info); }
|
||||||
|
.table-head { position: sticky; top: -22px; z-index: 2; min-height: 30px; background: #111419; color: #78818e; font-size: 9px; font-weight: 650; letter-spacing: .04em; }
|
||||||
|
.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; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.status-mark { width: 7px; height: 7px; margin-right: 7px; border-radius: 2px; background: var(--online); }
|
||||||
|
.status-mark.idle { background: var(--warning); }
|
||||||
|
.status-mark.offline { background: #69717d; box-shadow: inset 0 0 0 1px #89919c; }
|
||||||
|
.tags { gap: 4px; }
|
||||||
|
.tags small { display: inline; padding: 2px 5px; border: 1px solid #343c47; border-radius: 3px; background: #1d2229; color: #a8b0bb; }
|
||||||
|
.row-action { border: 0; background: transparent; color: #79818d; }
|
||||||
|
.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); }
|
||||||
|
.statusbar { display: flex; gap: 20px; align-items: center; padding: 0 10px; border-top: 1px solid #2b323c; color: #78818d; background: #111419; font-size: 9px; }
|
||||||
|
.statusbar span:first-child { color: #75c99c; }
|
||||||
|
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
||||||
|
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } .table-row { grid-template-columns: 34px 84px 96px minmax(140px, 1fr) 110px 120px 84px minmax(110px, 1fr) 34px; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }
|
||||||
5
src/test/setup.ts
Normal file
5
src/test/setup.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
57
src/workspace.test.ts
Normal file
57
src/workspace.test.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
144
src/workspace.ts
Normal file
144
src/workspace.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
const seedWorkspaces: readonly TeamserverWorkspace[] = [
|
||||||
|
{
|
||||||
|
id: "atlas",
|
||||||
|
name: "Atlas Teamserver",
|
||||||
|
shortName: "AT",
|
||||||
|
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: "ember",
|
||||||
|
name: "Ember Lab",
|
||||||
|
shortName: "EL",
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 "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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { demoSession } from "../src/fixtures/index.js";
|
|
||||||
import { isProtocolVersion, isSession, parseSession, ProtocolParseError } from "../src/index.js";
|
|
||||||
|
|
||||||
test("接受完整 Session 并保留未知枚举和扩展字段", () => {
|
|
||||||
const futureSession = { ...demoSession, state: "SUSPENDED_BY_POLICY", vendor_status: 42 };
|
|
||||||
const parsed = parseSession(futureSession);
|
|
||||||
assert.equal(parsed.state, "SUSPENDED_BY_POLICY");
|
|
||||||
assert.equal(parsed.vendor_status, 42);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("拒绝缺少 project_id 的跨上下文 Session", () => {
|
|
||||||
const { project_id: _projectId, ...invalid } = demoSession;
|
|
||||||
assert.equal(isSession(invalid), false);
|
|
||||||
assert.throws(() => parseSession(invalid), ProtocolParseError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("协议版本必须由非负整数构成", () => {
|
|
||||||
assert.equal(isProtocolVersion({ major: 1, minor: 0, patch: 0, future: true }), true);
|
|
||||||
assert.equal(isProtocolVersion({ major: 1, minor: -1, patch: 0 }), false);
|
|
||||||
});
|
|
||||||
20
tsconfig.app.json
Normal file
20
tsconfig.app.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"exclude": ["test"]
|
|
||||||
}
|
|
||||||
@ -1,16 +1,4 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"files": [],
|
||||||
"target": "ES2022",
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"exactOptionalPropertyTypes": true,
|
|
||||||
"declaration": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"rootDir": ".",
|
|
||||||
"outDir": "dist-tests",
|
|
||||||
"skipLibCheck": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: { port: 1420, strictPort: true },
|
||||||
|
envPrefix: ["VITE_", "TAURI_ENV_"],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "./src/test/setup.ts",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user