feat: add rust browser terminal prototype

This commit is contained in:
zhangheng
2026-06-08 16:34:13 +08:00
commit fd056ce502
43 changed files with 10312 additions and 0 deletions

13
.cargo/config.toml Normal file
View File

@@ -0,0 +1,13 @@
[target.wasm32-unknown-unknown]
rustflags = [
"--cfg",
"getrandom_backend=\"wasm_js\"",
"--cfg",
"erase_components",
]
[target.x86_64-unknown-linux-gnu]
rustflags = [
"--cfg",
"erase_components",
]

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target/
.env.development
node_modules

3798
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

74
Cargo.toml Normal file
View File

@@ -0,0 +1,74 @@
[workspace]
resolver = "2"
members = ["app", "server", "app_config"]
[workspace.dependencies]
app_config = { path = "app_config" }
icons = { path = "../ui/crates/icons", features = ["leptos"] }
registry = { path = "../ui/app_crates/registry" }
leptos_ui = { path = "../ui/crates/leptos_ui" }
tw_merge = { path = "../ui/crates/tw_merge/tw_merge", features = ["variant"] }
tw_merge_variants = { path = "../ui/crates/tw_merge/tw_merge_variants" }
axum = { version = "0.8", features = ["ws"] }
console_error_panic_hook = "0.1"
console_log = "1"
futures-util = "0.3"
leptos = { version = "0.8", features = ["nightly"] }
leptos_axum = "0.8"
leptos_meta = "0.8"
leptos_router = { version = "0.8", features = ["nightly"] }
log = "0.4"
portable-pty = "0.9"
redis = { version = "0.32", features = ["tokio-comp"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
strum = { version = "0.27", features = ["derive"] }
time = { version = "0.3", features = ["serde", "wasm-bindgen"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
validator = { version = "0.20", features = ["derive"] }
vt100 = "0.15"
heck = "0.5"
js-sys = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", default-features = false, features = [
"BinaryType",
"CloseEvent",
"console",
"Document",
"DomRectReadOnly",
"Element",
"ErrorEvent",
"Event",
"EventTarget",
"HtmlElement",
"KeyboardEvent",
"Location",
"MessageEvent",
"ResizeObserver",
"ResizeObserverEntry",
"WebSocket",
"Window",
] }
[[workspace.metadata.leptos]]
name = "base_path_demo"
bin-package = "server"
lib-package = "app"
site-root = "target/site"
site-pkg-dir = "pkg"
tailwind-input-file = "style/tailwind.css"
assets-dir = "public"
site-addr = "127.0.0.1:3100"
reload-port = 3101
browserquery = "defaults"
bin-features = []
bin-default-features = false
lib-features = ["hydrate"]
lib-default-features = false

17
README.md Normal file
View File

@@ -0,0 +1,17 @@
# BASE_PATH Demo
Minimal Leptos SSR project that reuses a few components from `ui` to validate `SiteConfig::BASE_PATH`.
The public app path is `/rustui`, while Leptos SSR routes are still declared as `/`, `/check`, and `/deep/nested`. The server strips `/rustui` before route matching, and the client router uses `/rustui` as its base.
```bash
cargo leptos watch
```
Then open:
- `http://127.0.0.1:3100/rustui`
- `http://127.0.0.1:3100/rustui/check`
- `http://127.0.0.1:3100/rustui/deep/nested`
Change `app_config/src/lib.rs` to test another base path.

View File

@@ -0,0 +1,568 @@
# Rust Browser Terminal Development Guide
本指南用于在 `base-path-demo` 中实现一套 Rust 终端能力:
- 后端Axum + `portable-pty` 创建真实伪终端,负责启动 shell、读写 PTY、处理 resize。
- 前端Rust/WASM + Leptos 实现浏览器终端组件,负责输入、渲染、滚屏、选区和终端状态。
- 沉淀:先在业务项目中跑通,再拆成可复用 Rust 终端组件库。
## 1. 核心判断
浏览器终端必须拆成两层:
```text
Browser Rust/WASM Terminal Component
<-> WebSocket protocol
Axum terminal session service
<-> portable-pty master
Shell process: bash/zsh/fish/vim/top
```
浏览器里的 WASM 不能直接创建系统 PTY因为浏览器沙箱不能启动本机 shell。PTY 必须在 Rust native 后端或桌面端创建。浏览器终端组件只做终端仿真、渲染和用户输入采集。
`portable-pty` 是 Rust crate提供跨平台 PTY API。它适合放在后端适配层不应该放进浏览器 WASM 组件。
## 2. 目标架构
建议分四层沉淀。
```text
base-path-demo/
app/
src/
pages/terminal.rs
terminal/
mod.rs
component.rs
core.rs
dom_renderer.rs
keyboard.rs
protocol.rs
server/
src/
terminal/
mod.rs
pty_session.rs
ws.rs
main.rs
```
后续抽库时再拆成 workspace crates
```text
crates/
terminal-core/ # no web-sys, no axum; parser/buffer/cursor/style
terminal-leptos/ # Leptos component and browser renderer
terminal-protocol/ # shared client/server message types
terminal-pty-server/ # portable-pty + Axum integration
```
第一阶段可以先在 `app/src/terminal``server/src/terminal` 内实现,等 API 稳定后再抽 crate。这样比一开始拆库更快也能减少早期重构成本。
## 3. 依赖规划
当前 workspace 已有 `axum = "0.8"`,但 WebSocket 模块需要启用 `ws` feature。
建议先调整 `base-path-demo/Cargo.toml`
```toml
[workspace.dependencies]
axum = { version = "0.8", features = ["ws"] }
futures-util = "0.3"
portable-pty = "0.9"
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
vt100 = "0.16"
```
`server/Cargo.toml` 建议增加:
```toml
[dependencies]
futures-util.workspace = true
portable-pty.workspace = true
```
`app/Cargo.toml` 建议增加:
```toml
[dependencies]
vt100.workspace = true
```
前端需要扩展 `web-sys` features
```toml
web-sys = { version = "0.3", default-features = false, features = [
"BinaryType",
"Blob",
"CloseEvent",
"Document",
"Element",
"ErrorEvent",
"Event",
"EventTarget",
"HtmlElement",
"KeyboardEvent",
"MessageEvent",
"Url",
"WebSocket",
"Window",
] }
```
说明:
- `portable-pty` 只用于 native server。
- `vt100` 可先用于 WASM 端解析字节流并维护屏幕状态,后续再替换为自研 `terminal-core`
- `futures-util` 用于 Axum WebSocket split。
## 4. WebSocket 协议
先用简单、可调试的协议,不要过早优化。
浏览器到服务端:
```rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientTerminalMessage {
Input { data: String },
Resize {
cols: u16,
rows: u16,
pixel_width: u16,
pixel_height: u16,
},
Ping,
}
```
服务端到浏览器:
```rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerTerminalMessage {
Output { data: String },
Exit { code: Option<i32> },
Error { message: String },
Pong,
}
```
MVP 可以先用 JSON 文本传输。稳定后再优化为:
- 控制消息走 JSON text frame。
- PTY output/input 走 binary frame。
- 大输出使用批处理,避免每个字节触发一次 render。
## 5. 后端实现计划
### 5.1 路由
`server/src/main.rs` 中新增 WebSocket route。
因为当前项目有 `SiteConfig::BASE_PATH = "/rustui"` 的场景,建议同时支持:
```text
/terminal/ws
/rustui/terminal/ws
```
挂载位置建议放在 `leptos_router` 外层,避免被 SSR fallback 吃掉。
目标形态:
```rust
let terminal_routes = Router::new()
.route("/terminal/ws", axum::routing::get(terminal_ws_handler));
let app = if SiteConfig::BASE_PATH.is_empty() {
terminal_routes.merge(leptos_router)
} else {
Router::new()
.route("/rustui/api/{*fn_name}", axum::routing::post(handle_server_fns))
.route("/api/{*fn_name}", axum::routing::post(handle_server_fns))
.merge(terminal_routes.clone())
.nest(SiteConfig::BASE_PATH, terminal_routes.merge(leptos_router))
};
```
### 5.2 PTY session
新增 `server/src/terminal/pty_session.rs`
职责:
- 创建 PTY。
- 启动 shell。
- 拿到 master reader/writer。
- 支持 resize。
- 在断开连接时终止 child。
核心流程:
```rust
use portable_pty::{CommandBuilder, PtySize, PtySystem, native_pty_system};
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows,
cols,
pixel_width,
pixel_height,
})?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "bash".to_owned());
let child = pair.slave.spawn_command(CommandBuilder::new(shell))?;
let reader = pair.master.try_clone_reader()?;
let writer = pair.master.take_writer()?;
```
注意:
- `portable-pty` 的 reader/writer 是阻塞 IO放进 `tokio::task::spawn_blocking` 或专用线程,不要直接阻塞 async runtime。
- 每个 WebSocket 连接对应一个 PTY session。
- socket close、read error、child exit 时要清理 session。
- 初版只允许启动固定 shell不允许前端传任意命令。
### 5.3 WebSocket 桥接
新增 `server/src/terminal/ws.rs`
需要两个方向的任务:
```text
PTY reader thread -> mpsc -> WebSocket sender
WebSocket receiver -> PTY writer
```
建议结构:
```rust
pub async fn terminal_ws_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(handle_terminal_socket)
}
async fn handle_terminal_socket(socket: WebSocket) {
let session = PtySession::spawn(default_size)?;
let (mut ws_sender, mut ws_receiver) = socket.split();
// Task A: PTY output -> WS output.
// Task B: WS input/resize -> PTY.
// On either side ending, abort/cleanup the other side.
}
```
MVP 验收标准:
- 打开页面能看到 shell prompt。
- 输入 `pwd` 能看到输出。
- 输入 `top``vim` 不会把 UI 卡死。
- 浏览器断开后后端 child 被终止。
## 6. 前端实现计划
### 6.1 页面接入
新增:
```text
app/src/pages/terminal.rs
app/src/terminal/mod.rs
```
修改:
- `app/src/pages/mod.rs` 暴露 terminal page。
- `app/src/app.rs` 增加 `/terminal` route。
- `app/src/menu_items.rs` 增加导航项。
建议页面名:
```rust
#[component]
pub fn TerminalPage() -> impl IntoView {
view! {
<AdminShell>
<TerminalPanel />
</AdminShell>
}
}
```
### 6.2 TerminalPanel
新增 `app/src/terminal/component.rs`
职责:
- 建立 WebSocket。
- 捕获键盘输入。
- 调用 terminal core 处理服务端 output。
- 渲染 terminal screen。
- 根据容器尺寸计算 cols/rows并发送 resize。
MVP 可以先使用 DOM 渲染:
```text
<div class="terminal-root" tabindex="0">
<For each=screen_lines>
<div class="terminal-row">
<span style=cell_style>...</span>
</div>
</For>
</div>
```
后续再切 Canvas/WebGL。
### 6.3 输入处理
新增 `app/src/terminal/keyboard.rs`
先支持基础输入:
```text
Enter -> "\r"
Backspace -> "\x7f"
Tab -> "\t"
ArrowUp -> "\x1b[A"
ArrowDown -> "\x1b[B"
ArrowRight -> "\x1b[C"
ArrowLeft -> "\x1b[D"
Ctrl+C -> "\x03"
Ctrl+D -> "\x04"
普通字符 -> event.key
```
后续增强:
- Alt/meta 组合键。
- bracketed paste。
- IME composition。
- 鼠标协议。
- Kitty keyboard protocol。
### 6.4 终端 core
MVP 不建议立即手写完整 ANSI/VT parser。先用 `vt100::Parser` 做 core
```rust
pub struct TerminalCore {
parser: vt100::Parser,
}
impl TerminalCore {
pub fn new(rows: u16, cols: u16) -> Self;
pub fn process(&mut self, bytes: &[u8]);
pub fn resize(&mut self, rows: u16, cols: u16);
pub fn snapshot(&self) -> TerminalSnapshot;
}
```
`TerminalSnapshot` 是组件渲染所需的稳定数据结构:
```rust
pub struct TerminalSnapshot {
pub rows: Vec<TerminalRow>,
pub cursor: TerminalCursor,
pub title: Option<String>,
}
pub struct TerminalRow {
pub cells: Vec<TerminalCell>,
}
pub struct TerminalCell {
pub text: String,
pub fg: TerminalColor,
pub bg: TerminalColor,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub inverse: bool,
}
```
后续自研 core 时,可以逐步替换:
- `terminal-core::parser`
- `terminal-core::buffer`
- `terminal-core::screen`
- `terminal-core::style`
- `terminal-core::unicode_width`
## 7. 渲染策略
### MVP: DOM renderer
优点:
- 容易实现。
- 容易调试。
- 选区、复制、无障碍更自然。
缺点:
- 大量输出时性能一般。
- 每个 cell 都渲染 DOM 会很重。
建议 MVP 不要每个 cell 一个 span而是按 style run 合并:
```text
row = [
run(style A, "hello "),
run(style B, "world"),
]
```
### 第二阶段: Canvas renderer
优点:
- 性能好。
- 更接近 xterm.js 的渲染模型。
缺点:
- 选区、光标、字体测量需要自己做。
- IME 和可访问性需要额外 DOM 辅助层。
推荐路线:先 DOM等 parser/protocol/session 稳定后再做 Canvas。
## 8. 安全边界
浏览器终端是高风险能力,必须先定边界。
MVP 安全策略:
- 默认只在 development 环境启用。
- WebSocket route 必须鉴权,至少先做本地开发开关。
- 不允许前端指定任意启动命令。
- shell 工作目录固定在项目目录或安全 sandbox 目录。
- 限制并发 session 数。
- 限制空闲时间,超时自动 kill child。
- 记录 session start/stop/error 日志,但不要记录全部输入输出,避免泄露 secret。
后续生产策略:
- 接入用户身份和 RBAC。
- 每个 session 用独立低权限系统用户或容器隔离。
- 支持 allowlist 命令模式。
- 限制环境变量透传。
- 添加审计和资源配额。
## 9. 里程碑
### M0: 文档与目录准备
- 新增本指南。
- 明确 crate/模块命名。
- 确认 Axum route 如何兼容 `/rustui` base path。
验收:团队能按本文拆任务。
### M1: 后端 PTY + WebSocket
- 启用 `axum/ws`
- 实现 `PtySession`
- 实现 `/terminal/ws`
- 支持 input/output/resize。
- 断连自动清理 child。
验收:用浏览器或 WebSocket client 连接后,可以和 shell 交互。
### M2: Leptos DOM 终端 MVP
- 新增 `/terminal` 页面。
- 建立 WebSocket。
- 捕获基础键盘输入。
-`vt100` 解析 output。
- DOM 渲染 rows/runs。
- 支持 resize。
验收:浏览器页面能执行 `pwd``ls``clear``top`,基础 ANSI 颜色可见。
### M3: 组件化与体验
- 抽出 `TerminalCore``TerminalPanel``TerminalTransport`
- 增加滚屏 scrollback。
- 增加复制、粘贴、选区。
- 增加连接状态、重连、错误提示。
- 增加主题 token。
验收:组件可在其他 Leptos 页面复用。
### M4: 抽 Rust 组件库
-`app/src/terminal` 抽出 `terminal-core`
- 从页面组件抽出 `terminal-leptos`
- 从消息类型抽出 `terminal-protocol`
- 从 server module 抽出 `terminal-pty-server`
验收:`base-path-demo` 只依赖这些 crates不再持有核心实现。
### M5: 自研 parser/buffer
- 保留 `vt100` 作为参考测试 oracle。
- 实现自研 escape parser。
- 实现 buffer、cursor、SGR style、scroll region。
- 对比 `vt100` snapshot 做回归测试。
验收:常见 shell、git、vim、top、cargo 输出行为稳定。
## 10. 测试策略
后端测试:
- `PtySession` 创建和清理。
- resize 后 PTY size 更新。
- WebSocket 收到 input 后能产生 output。
- socket 断开后 child 退出。
前端 core 测试:
- 普通文本换行。
- ANSI 颜色。
- cursor movement。
- clear screen。
- resize reflow。
集成测试:
- 启动 `cargo leptos watch` 或测试 server。
- 打开 `/rustui/terminal`
- 输入 `echo hello`
- 断言页面出现 `hello`
## 11. 开发注意事项
- 不要在 async task 中直接阻塞读取 PTY。
- 不要把 `portable-pty` 编译到 WASM 目标。
- 不要一开始支持“前端传 command”这会扩大安全面。
- 不要先做 Canvas先把协议和 core 跑稳。
- 不要把每个 cell 都渲染成独立 DOM 节点,优先按 style run 合并。
- 不要把 xterm.js 当成逐行移植对象只参考它的边界parser、buffer、renderer、input、addon。
## 12. 推荐第一批改动清单
建议按这个顺序提交:
1. `Cargo.toml` 增加 `axum/ws``portable-pty``futures-util``vt100`
2. `server/src/terminal/pty_session.rs` 实现 PTY session。
3. `server/src/terminal/ws.rs` 实现 WebSocket handler。
4. `server/src/main.rs``/terminal/ws``/rustui/terminal/ws`
5. `app/src/terminal/protocol.rs` 定义共享消息。
6. `app/src/terminal/core.rs` 包装 `vt100::Parser`
7. `app/src/terminal/component.rs` 实现 DOM 终端。
8. `app/src/pages/terminal.rs` 接入页面。
9. `app/src/app.rs``app/src/menu_items.rs` 接入路由和导航。
10. 增加基础测试和手动验收记录。
## 13. 参考资料
- `portable-pty`: Rust 跨平台 PTY crate用于后端创建和控制伪终端。
- `axum::extract::ws`: Axum WebSocket 支持,需要启用 `ws` feature。
- `vt100`: Rust terminal byte stream parser可作为 MVP parser 和后续自研 core 的对照实现。
- `xterm.js`: 参考模块边界,不逐行移植。

281
TERMINAL_HANDOFF.md Normal file
View File

@@ -0,0 +1,281 @@
# Terminal Handoff
本文档用于把 `base-path-demo` 当前浏览器终端实现的状态、已验证结论、未解决问题和下一步建议整理清楚,方便后续继续开发。
## 1. 当前目标
项目目标不是简单接一个现成 JS 终端,而是在 `base-path-demo` 中逐步沉淀一套 Rust 终端能力:
- 后端:`Axum + portable-pty`
- 前端:`Leptos + Rust/WASM`
- 长期方向:抽出可复用 Rust 终端组件库
当前已经完成:
- PTY 启动与 shell 会话
- WebSocket 双向通信
- Rust/WASM 终端渲染 MVP
- ANSI 样式分段渲染
- 宽高测量与 PTY resize
- 基础 scrollback/history 浏览能力
当前仍未稳定完成:
- 长历史滚动后的视口一致性
- 从 history 模式返回 live 模式时的可靠定位
- 超长输出下的 scrollback / DOM / parser 三者完全一致
## 2. 当前实现位置
### 后端
- `server/src/terminal/pty_session.rs`
- `server/src/terminal/ws.rs`
- `server/src/main.rs`
### 前端
- `app/src/terminal/core.rs`
- `app/src/terminal/component.rs`
- `app/src/terminal/scrollback.rs`
- `app/src/terminal/protocol.rs`
- `app/src/pages/terminal.rs`
- `style/tailwind.css`
## 3. 当前前端结构
当前前端已经从“组件里直接推导滚动”改成三层:
1. `TerminalCore`
- 基于 `vt100::Parser`
- 负责终端字节流解析
- 负责从 parser 生成 `TerminalSnapshot`
- 暴露 `snapshot_live()``snapshot_for_top_row()`
2. `ScrollbackModel`
- 位于 `app/src/terminal/scrollback.rs`
- 负责管理:
- `live/history` 状态
- `top_row`
- `ScrollMetrics`
- `scrollTop <-> top_row` 映射
- spacer 渲染计划
3. `TerminalPanel`
- 负责 DOM 事件和 WebSocket
- 把滚动、输入、resize 转换为 model 更新
- 再根据 model 请求 `TerminalCore` 产出 snapshot
这是一次结构性重构不再依赖前面几轮“scrollback_offset / scrollTop / spacer”互相修补的逻辑。
## 4. 已确认有效的部分
这些能力在当前实现里基本是成立的:
- WebSocket 通信链路稳定
- shell 启动和 PTY 读写稳定
- 普通命令行输入输出稳定
- `btm` 这种全屏 TUI 在 live 模式下效果已经明显优于最初版本
- 宽度和高度测量相比初版更合理
- 终端样式、ANSI 颜色和 cursor 基本可用
## 5. 当前未解决问题
用户最新反馈:滚动相关问题仍然存在。
典型症状:
1. 滚动到高处后,再滚回底部,视图仍可能错位。
2. 认为已经回到底部,但看不到当前 prompt 或新输出。
3. 某些情况下顶部会出现大块空白。
4. history 模式和 live 模式切换仍然不完全符合真实终端体验。
## 6. 为什么当前结构仍可能不稳
虽然已经抽出了 `ScrollbackModel`,但当前方案本质上仍然依赖一个前提:
> 用 DOM 滚动条 + spacer 高度,去模拟“完整终端历史缓冲区”。
这条路的优势是实现快,但仍有天然风险:
### 6.1 `vt100` 只给出“当前视口”
`vt100` 当前更适合表达:
- 当前可见 screen
- 当前 scrollback offset
它不是为浏览器里的“无限历史虚拟滚动列表”直接设计的 UI 数据结构。
也就是说,浏览器里看到的“长历史列表”不是 parser 原生给出来的,而是我们通过 spacer + 视口切换模拟出来的。
### 6.2 DOM 滚动不是终端滚动
浏览器滚动条是像素系统,终端历史是行系统。
即使我们做了:
- 行高量化
- near-bottom 阈值
- top row model
- 程序滚动忽略回流
仍然可能在以下场景出现边界问题:
- resize 后 scrollHeight 改变
- 某些行因字符宽度或字体 fallback 产生高度/宽度偏差
- cursor / prompt / wrapped line 在 parser 里和 DOM 里对应关系不完全一致
### 6.3 spacer 模型仍然是“UI 侧估计”
现在的 spacer 高度来自:
- `scrollback_rows`
- `viewport_rows`
- `top_row`
但这仍然是假设“每一逻辑行高度固定、所有历史行都可用同一高度表达”。
对于普通命令输出通常够用,但当用户期待系统终端级别的一致性时,仍可能暴露问题。
## 7. 建议的下一步方向
不建议继续围绕当前 spacer 方案做大量微调。
### 推荐方向 A显式历史行缓冲
最推荐。
思路:
1. 在前端维护一份显式的“历史行缓冲”。
2. 每次收到 PTY 输出时,不只保留当前 snapshot还要把可确认滚出屏幕的行纳入历史缓冲。
3. 浏览器滚动条直接绑定:
- `history_rows`
- `visible_rows`
4. 当前视图渲染为:
- 历史区真实行
- 当前 screen 行
这样浏览器滚动就不再依赖 spacer 去“伪装总高度”,而是真正拥有一份历史数据模型。
更具体地说,应当抽成类似:
```text
terminal-core
parser
screen
scrollback_store
viewport_state
terminal-leptos
renderer
dom scroll adapter
input adapter
```
建议引入一个新的模型,例如:
```rust
pub struct TerminalBufferModel {
pub history_rows: Vec<RenderedRow>,
pub screen_rows: Vec<RenderedRow>,
pub viewport_rows: usize,
pub mode: ViewMode,
}
```
其中:
- `history_rows` 是真实可滚动历史
- `screen_rows` 是当前 parser screen
- `ViewMode` 表示 live/history
然后滚动行为只是在这份显式数据上切片不再通过“scrollback offset + spacer”来推导。
### 推荐方向 B虚拟列表化
如果历史很多,后续要避免一次性渲染全部历史行。
那就基于显式历史行缓冲再做:
- 虚拟滚动窗口
- 仅渲染当前可见行切片
- 上下填充高度由真实行数计算
这个方向适合在方向 A 稳定后再做。
### 不推荐继续投入的方向
除非只是临时验证,否则不建议继续在下面这些点上做大量时间投入:
- 单纯继续调 `scrollTop -> topRow` 的换算公式
- 继续加更多“忽略下一次 scroll 事件”之类的回流保护
- 继续依赖 spacer 修正 live/history 错位
这些都只能减轻问题,不太可能从结构上彻底解决。
## 8. 一个更稳的实施顺序
建议下一位接手的人按这个顺序推进:
1. 先保留当前 PTY / WS / 输入输出链路,不动后端。
2. 在前端新增显式历史缓冲层,不急着删除现有 `ScrollbackModel`
3. 把当前渲染改成:
- `history_rows + screen_rows`
- 不再通过 `snapshot.scrollback_offset` 去制造大块 spacer
4. 先让滚动逻辑只在显式数据上成立。
5. 确认:
- 滚到顶部没有空白
- 滚回底部一定能恢复 prompt
- history 模式继续输入一定回 live
6. 再考虑是否保留 `vt100` 的 scrollback offset 接口,还是让它只负责 screen而历史完全交给自有 buffer model。
## 9. 当前代码是否值得保留
值得保留的部分:
- `portable-pty` 后端
- Axum WebSocket 协议与会话层
- `TerminalCore` 的 ANSI 分段渲染能力
- 宽高测量和 PTY resize
- `ScrollbackModel` 里关于 live/history 的概念划分
可以重做的部分:
- 终端历史滚动的底层表现方式
- spacer 方案本身
- DOM scroll 与 parser scrollback 的耦合方式
## 10. 建议的 handoff 结论
当前项目已经证明:
- Rust 后端 PTY 是可行的
- Rust/WASM 终端组件是可行的
- `btm` 和普通 shell 已经能跑
但要让它真正成为一个稳定的 Rust 终端组件库下一步不应继续在“spacer + 当前视口推导”上做小修小补,而应转向:
> 显式历史缓冲模型 + 真实终端行数据结构 + 可选虚拟滚动
这会更接近真正终端组件库内部的设计,也更利于后续抽成 crate。
## 11. 当前验证命令
建议继续用下面这些命令回归:
- `pwd`
- `ls`
- `cat <long-file>`
- `btm`
- `top` / `htop`(如果环境可用)
- 连续回车制造长历史
重点检查:
- 滚到顶部是否有空白
- 滚回底部是否恢复 prompt
- history 模式输入后是否回到 live
- resize 后历史视图是否稳定

45
app/Cargo.toml Normal file
View File

@@ -0,0 +1,45 @@
[package]
name = "app"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
app_config.workspace = true
icons.workspace = true
registry.workspace = true
axum = { workspace = true, optional = true }
console_error_panic_hook.workspace = true
console_log.workspace = true
leptos.workspace = true
leptos_axum = { workspace = true, optional = true }
leptos_meta.workspace = true
leptos_router.workspace = true
log.workspace = true
redis = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
vt100.workspace = true
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true
web-sys.workspace = true
dotenvy = { version = "0.15", optional = true }
[features]
default = []
hydrate = ["leptos/hydrate"]
ssr = [
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:axum",
"dep:leptos_axum",
"dep:redis",
"dep:reqwest",
"dep:dotenvy",
"registry/ssr",
]

3
app/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
println!("cargo:rustc-env=LEPTOS_OUTPUT_NAME=base_path_demo");
}

1
app/src/api/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod project;

490
app/src/api/project.rs Normal file
View File

@@ -0,0 +1,490 @@
use leptos::prelude::*;
use leptos::server_fn::codec::{Json, PostUrl};
use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
const DEFAULT_LABEL_API_BASE_URL: &str = "https://label.softtest.cowarobot.com";
#[cfg(feature = "ssr")]
const PROJECT_LIST_PATH: &str = "/api/v1/label_server/project/list";
#[cfg(feature = "ssr")]
const SESSION_COOKIE_NAME: &str = "session";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ProjectListRequest {
pub page_number: i64,
pub page_size: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<Vec<i64>>,
}
impl Default for ProjectListRequest {
fn default() -> Self {
Self {
page_number: 1,
page_size: 15,
project_name: None,
project_type: None,
owner: None,
status: Some(vec![
100, 191, 199, 200, 201, 202, 203, 400, 500, 580, 600, 700,
]),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProjectListResponse {
#[serde(default)]
pub project_list: Vec<ProjectData>,
#[serde(default)]
pub total_pages: i64,
#[serde(default)]
pub total_items: i64,
#[serde(default)]
pub source: ProjectDataSource,
#[serde(default)]
pub message: Option<String>,
}
impl ProjectListResponse {
pub fn fallback(message: impl Into<String>) -> Self {
let message = compact_message(message.into());
Self {
source: ProjectDataSource::Fallback,
message: (!message.is_empty()).then_some(message),
..Self::default()
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProjectDataSource {
Upstream,
#[default]
Fallback,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProjectData {
pub id: i64,
#[serde(default)]
pub name: String,
#[serde(default)]
pub status: i64,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub create_user: Option<String>,
#[serde(default)]
pub project_type: Option<String>,
#[serde(default)]
pub label_schema_name: Option<String>,
#[serde(default)]
pub label_schema_version: Option<String>,
#[serde(default)]
pub data_size: Option<i64>,
#[serde(default)]
pub labeled_scale: Option<f64>,
#[serde(default)]
pub reviewed_scale: Option<f64>,
#[serde(default)]
pub is_collect: bool,
#[serde(default)]
pub is_embedding: bool,
#[serde(default)]
pub is_terminate: bool,
#[serde(default)]
pub embedding_status: Option<i64>,
#[serde(default)]
pub remarks: Option<String>,
#[serde(default)]
pub create_at: Option<String>,
#[serde(default)]
pub update_at: Option<String>,
}
#[server(FetchProjectList, "/rustui/api", input = PostUrl, output = Json)]
pub async fn fetch_project_list(
request: ProjectListRequest,
) -> Result<ProjectListResponse, ServerFnError> {
fetch_project_list_impl(request).await
}
#[cfg(not(feature = "ssr"))]
#[allow(dead_code)]
async fn fetch_project_list_impl(
_request: ProjectListRequest,
) -> Result<ProjectListResponse, ServerFnError> {
Ok(ProjectListResponse::fallback(
"fetch_project_list should run on the server",
))
}
#[cfg(feature = "ssr")]
async fn fetch_project_list_impl(
request: ProjectListRequest,
) -> Result<ProjectListResponse, ServerFnError> {
use axum::http::HeaderMap;
use leptos_axum::extract;
let headers: HeaderMap = match extract().await {
Ok(headers) => headers,
Err(err) => {
let message = format!("读取请求头失败: {err}");
log::warn!("{message}");
return Ok(ProjectListResponse::fallback(message));
}
};
let tokens = match resolve_tokens(&headers).await {
Ok(tokens) if tokens.access_token.is_some() || tokens.refresh_token.is_some() => tokens,
Ok(_) => {
let message = "未获取到项目列表 token";
log::warn!("{message}");
return Ok(ProjectListResponse::fallback(message));
}
Err(error) => {
let message = server_fn_error_message(&error);
log::warn!("获取项目列表 token 失败: {message}");
return Ok(ProjectListResponse::fallback(message));
}
};
let base_url = label_api_base_url();
let url = format!("{base_url}{PROJECT_LIST_PATH}");
let client = reqwest::Client::new();
let mut upstream_request = client
.post(url)
.header("content-type", "application/json")
.json(&request);
if let Some(access_token) = tokens.access_token.as_deref() {
upstream_request = upstream_request.header("Token", access_token);
}
if let Some(refresh_token) = tokens.refresh_token.as_deref() {
upstream_request = upstream_request.header("Refresh-Token", refresh_token);
}
let response = match upstream_request.send().await {
Ok(response) => response,
Err(err) => {
let message = format!("请求项目列表服务失败: {err}");
log::warn!("{message}");
return Ok(ProjectListResponse::fallback(message));
}
};
let status = response.status();
let text = match response.text().await {
Ok(text) => text,
Err(err) => {
let message = format!("读取项目列表响应失败: {err}");
log::warn!("{message}");
return Ok(ProjectListResponse::fallback(message));
}
};
if !status.is_success() {
let detail = response_message_from_text(&text).unwrap_or_else(|| compact_message(text));
let message = if detail.is_empty() {
format!("项目列表服务返回异常: HTTP {status}")
} else {
format!("项目列表服务返回异常: HTTP {status}, {detail}")
};
log::warn!("{message}");
return Ok(ProjectListResponse::fallback(message));
}
let mut payload = match parse_project_list_payload(&text) {
Ok(payload) => payload,
Err(error) => {
let message = server_fn_error_message(&error);
log::warn!("解析项目列表响应失败: {message}");
return Ok(ProjectListResponse::fallback(message));
}
};
payload.source = ProjectDataSource::Upstream;
Ok(payload)
}
#[cfg(feature = "ssr")]
#[derive(Debug, Default)]
struct TokenPair {
access_token: Option<String>,
refresh_token: Option<String>,
}
#[cfg(feature = "ssr")]
async fn resolve_tokens(headers: &axum::http::HeaderMap) -> Result<TokenPair, ServerFnError> {
let header_tokens = TokenPair {
access_token: read_header(headers, "token")
.or_else(|| read_header(headers, "authorization").and_then(strip_bearer_prefix)),
refresh_token: read_header(headers, "refresh-token"),
};
if header_tokens.access_token.is_some() || header_tokens.refresh_token.is_some() {
return Ok(header_tokens);
}
let env_tokens = TokenPair {
access_token: std::env::var("LABEL_ACCESS_TOKEN").ok(),
refresh_token: std::env::var("LABEL_REFRESH_TOKEN").ok(),
};
if env_tokens.access_token.is_some() || env_tokens.refresh_token.is_some() {
return Ok(env_tokens);
}
let Some(session_key) = session_key_from_headers(headers) else {
return Err(ServerFnError::new(
"未获取到 session cookie无法获取项目列表 token",
));
};
let Some(session_data) = load_session_from_redis(&session_key).await? else {
return Err(ServerFnError::new("Redis 中未获取到 session 用户信息"));
};
Ok(TokenPair {
access_token: session_data
.get("access_token")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
refresh_token: session_data
.get("refresh_token")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
}
#[cfg(feature = "ssr")]
fn read_header(headers: &axum::http::HeaderMap, name: &'static str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(feature = "ssr")]
fn strip_bearer_prefix(value: String) -> Option<String> {
let token = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
.unwrap_or(&value)
.trim()
.to_owned();
(!token.is_empty()).then_some(token)
}
#[cfg(feature = "ssr")]
fn parse_project_list_payload(text: &str) -> Result<ProjectListResponse, ServerFnError> {
let value: serde_json::Value = serde_json::from_str(text)
.map_err(|err| ServerFnError::new(format!("解析项目列表响应失败: {err}; {text}")))?;
if let Some(code) = value.get("code").and_then(|value| value.as_i64()) {
if code != 200 {
let message =
response_message(&value).unwrap_or_else(|| "项目列表服务返回业务异常".to_owned());
return Err(ServerFnError::new(format!("{message}: code {code}")));
}
if let Some(data) = value.get("data") {
return serde_json::from_value::<ProjectListResponse>(data.clone()).map_err(|err| {
ServerFnError::new(format!("解析项目列表 data 字段失败: {err}; {text}"))
});
}
}
serde_json::from_value::<ProjectListResponse>(value)
.map_err(|err| ServerFnError::new(format!("解析项目列表响应失败: {err}; {text}")))
}
#[cfg(feature = "ssr")]
fn response_message(value: &serde_json::Value) -> Option<String> {
value
.get("message")
.or_else(|| value.get("msg"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(feature = "ssr")]
fn response_message_from_text(text: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(text)
.ok()
.and_then(|value| response_message(&value))
}
#[cfg(feature = "ssr")]
fn session_key_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
let cookie_header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
cookie_header.split(';').find_map(|pair| {
let (name, value) = pair.trim().split_once('=')?;
if name == SESSION_COOKIE_NAME {
Some(normalize_session_cookie_value(value))
} else {
None
}
})
}
#[cfg(feature = "ssr")]
fn normalize_session_cookie_value(value: &str) -> String {
let decoded = percent_decode(value);
serde_json::from_str::<String>(&decoded).unwrap_or(decoded)
}
#[cfg(feature = "ssr")]
fn percent_decode(value: &str) -> String {
let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& index + 2 < bytes.len()
&& let (Some(high), Some(low)) =
(hex_value(bytes[index + 1]), hex_value(bytes[index + 2]))
{
output.push((high << 4) | low);
index += 3;
} else if bytes[index] == b'+' {
output.push(b' ');
index += 1;
} else {
output.push(bytes[index]);
index += 1;
}
}
String::from_utf8(output).unwrap_or_else(|_| value.to_owned())
}
#[cfg(feature = "ssr")]
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[cfg(feature = "ssr")]
async fn load_session_from_redis(
session_key: &str,
) -> Result<Option<serde_json::Value>, ServerFnError> {
let redis_url = redis_url();
let redis_target = redis_target_label();
let client = redis::Client::open(redis_url.as_str())
.map_err(|err| ServerFnError::new(format!("创建 Redis 客户端失败: {err}")))?;
let mut connection = client
.get_multiplexed_async_connection()
.await
.map_err(|err| ServerFnError::new(format!("连接 Redis 失败 ({redis_target}): {err}")))?;
let value: Option<String> = redis::AsyncCommands::get(&mut connection, session_key)
.await
.map_err(|err| ServerFnError::new(format!("读取 Redis session 失败: {err}")))?;
value
.map(|raw| {
serde_json::from_str::<serde_json::Value>(&raw)
.map_err(|err| ServerFnError::new(format!("解析 Redis session 失败: {err}")))
})
.transpose()
}
#[cfg(feature = "ssr")]
fn redis_url() -> String {
if let Ok(url) = std::env::var("REDIS_URL").or_else(|_| std::env::var("NEXT_PUBLIC_REDIS_DSN"))
{
return url;
}
let host = std::env::var("NEXT_PUBLIC_REDIS_URL").unwrap_or_else(|_| "127.0.0.1".to_owned());
let port = std::env::var("NEXT_PUBLIC_REDIS_PORT").unwrap_or_else(|_| "6379".to_owned());
let username = std::env::var("NEXT_PUBLIC_REDIS_USERNAME").unwrap_or_default();
let password = std::env::var("NEXT_PUBLIC_REDIS_PASSWORD").unwrap_or_default();
let db = std::env::var("NEXT_PUBLIC_REDIS_DB").unwrap_or_else(|_| "0".to_owned());
if username.is_empty() && password.is_empty() {
format!("redis://{host}:{port}/{db}")
} else if username.is_empty() {
format!("redis://:{password}@{host}:{port}/{db}")
} else {
format!("redis://{username}:{password}@{host}:{port}/{db}")
}
}
#[cfg(feature = "ssr")]
fn redis_target_label() -> String {
let host = std::env::var("NEXT_PUBLIC_REDIS_URL").unwrap_or_else(|_| "127.0.0.1".to_owned());
let port = std::env::var("NEXT_PUBLIC_REDIS_PORT").unwrap_or_else(|_| "6379".to_owned());
let username = std::env::var("NEXT_PUBLIC_REDIS_USERNAME").unwrap_or_default();
let db = std::env::var("NEXT_PUBLIC_REDIS_DB").unwrap_or_else(|_| "0".to_owned());
if username.is_empty() {
format!("{host}:{port}/{db}")
} else {
format!("{host}:{port}/{db} as {username}")
}
}
#[cfg(feature = "ssr")]
fn label_api_base_url() -> String {
if let Ok(url) = std::env::var("BASE_LABEL_API") {
return trim_trailing_slash(url);
}
let env = std::env::var("NEXT_PUBLIC_ENV").unwrap_or_default();
let url = match env.as_str() {
"production" => "http://172.16.103.224:9110",
"staging" => "http://172.16.115.128:9110",
_ => DEFAULT_LABEL_API_BASE_URL,
};
trim_trailing_slash(url.to_owned())
}
#[cfg(feature = "ssr")]
fn trim_trailing_slash(mut value: String) -> String {
while value.ends_with('/') {
value.pop();
}
value
}
fn compact_message(value: String) -> String {
const MAX_CHARS: usize = 240;
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let total_chars = normalized.chars().count();
if total_chars <= MAX_CHARS {
normalized
} else {
let truncated = normalized.chars().take(MAX_CHARS).collect::<String>();
format!("{truncated}...")
}
}
#[cfg(feature = "ssr")]
fn server_fn_error_message(error: &ServerFnError) -> String {
match error {
ServerFnError::ServerError(message) => compact_message(message.clone()),
_ => compact_message(error.to_string()),
}
}

39
app/src/app.rs Normal file
View File

@@ -0,0 +1,39 @@
use app_config::SiteConfig;
use leptos::prelude::*;
use leptos_meta::{Title, provide_meta_context};
use leptos_router::components::{Route, Router, Routes};
use leptos_router::{StaticSegment, path};
use crate::pages::project_list::{
CategoryPage, CostPage, DailyPaperPage, EmployeePage, NotFound, OrganizationPage,
ProjectAllPage, ProjectAuditPage, WorkloadPage,
};
use crate::pages::terminal::TerminalPage;
#[cfg(target_arch = "wasm32")]
const ROUTER_BASE: &str = SiteConfig::BASE_PATH;
#[cfg(not(target_arch = "wasm32"))]
const ROUTER_BASE: &str = "";
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Title text=SiteConfig::TITLE />
<Router base=ROUTER_BASE>
<Routes fallback=NotFound>
<Route path=StaticSegment("") view=EmployeePage />
<Route path=StaticSegment("employee") view=EmployeePage />
<Route path=path!("/team/organization") view=OrganizationPage />
<Route path=path!("/team/dailypaper") view=DailyPaperPage />
<Route path=path!("/team/cost") view=CostPage />
<Route path=path!("/team/workload") view=WorkloadPage />
<Route path=StaticSegment("category") view=CategoryPage />
<Route path=path!("/project/all") view=ProjectAllPage />
<Route path=path!("/project/audit") view=ProjectAuditPage />
<Route path=path!("/terminal") view=TerminalPage />
</Routes>
</Router>
}
}

346
app/src/client_icon.rs Normal file
View File

@@ -0,0 +1,346 @@
use leptos::prelude::*;
#[component]
fn TablerOutlineIcon(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class=class
aria-hidden="true"
>
{children()}
</svg>
}
}
#[component]
fn DashboardIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M5 4h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1" />
<path d="M5 16h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1" />
<path d="M15 12h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1" />
<path d="M15 4h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1" />
</TablerOutlineIcon>
}
}
#[component]
fn EmployeeManagementIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M10 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1" />
<path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M17 10h2a2 2 0 0 1 2 2v1" />
<path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M3 13v-1a2 2 0 0 1 2 -2h2" />
</TablerOutlineIcon>
}
}
#[component]
fn ImageAnnotationIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M15 8h.01" />
<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12" />
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" />
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" />
</TablerOutlineIcon>
}
}
#[component]
fn LidarAnnotationIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M6 17.6l-2 -1.1v-2.5" />
<path d="M4 10v-2.5l2 -1.1" />
<path d="M10 4.1l2 -1.1l2 1.1" />
<path d="M18 6.4l2 1.1v2.5" />
<path d="M20 14v2.5l-2 1.12" />
<path d="M14 19.9l-2 1.1l-2 -1.1" />
<path d="M12 12l2 -1.1" />
<path d="M18 8.6l2 -1.1" />
<path d="M12 12l0 2.5" />
<path d="M12 18.5l0 2.5" />
<path d="M12 12l-2 -1.12" />
<path d="M6 8.6l-2 -1.1" />
</TablerOutlineIcon>
}
}
#[component]
fn ManagementCenterIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065" />
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</TablerOutlineIcon>
}
}
#[component]
fn MyProjectsIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2" />
</TablerOutlineIcon>
}
}
#[component]
fn PersonalCenterIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
</TablerOutlineIcon>
}
}
#[component]
fn ProjectCategoryIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M14 4h6v6h-6l0 -6" />
<path d="M4 14h6v6h-6l0 -6" />
<path d="M14 17a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M4 7a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
</TablerOutlineIcon>
}
}
#[component]
fn ProjectManagementIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M9 3h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
<path d="M17 16v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2" />
</TablerOutlineIcon>
}
}
#[component]
fn ReportIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
<path d="M9 9l1 0" />
<path d="M9 13l6 0" />
<path d="M9 17l6 0" />
</TablerOutlineIcon>
}
}
#[component]
fn WorkloadIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2" />
<path d="M9 5a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2" />
<path d="M10 14l4 0" />
<path d="M12 12l0 4" />
</TablerOutlineIcon>
}
}
#[component]
fn TeamManagementIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M10 5a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M6 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M10 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M18 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M2 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M14 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 17l2 -3" />
<path d="M9 10l2 -3" />
<path d="M13 7l2 3" />
<path d="M17 14l2 3" />
<path d="M15 14l-2 3" />
<path d="M9 14l2 3" />
</TablerOutlineIcon>
}
}
#[component]
fn VideoAnnotationIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4" />
<path d="M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8" />
</TablerOutlineIcon>
}
}
#[component]
fn LockIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6" />
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
<path d="M8 11v-4a4 4 0 1 1 8 0v4" />
</TablerOutlineIcon>
}
}
#[component]
fn LogoutIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
<path d="M9 12h12l-3 -3" />
<path d="M18 15l3 -3" />
</TablerOutlineIcon>
}
}
#[component]
fn ChevronDownIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M6 9l6 6l6 -6" />
</TablerOutlineIcon>
}
}
#[component]
fn ChevronLeftIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M15 6l-6 6l6 6" />
</TablerOutlineIcon>
}
}
#[component]
fn ChevronRightIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M9 6l6 6l-6 6" />
</TablerOutlineIcon>
}
}
#[component]
fn EditIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1" />
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z" />
<path d="M16 5l3 3" />
</TablerOutlineIcon>
}
}
#[component]
fn UploadIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
<path d="M12 17v-6" />
<path d="M9.5 13.5l2.5 -2.5l2.5 2.5" />
</TablerOutlineIcon>
}
}
#[component]
fn InfoCircleIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! {
<TablerOutlineIcon class=class>
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 9h.01" />
<path d="M11 12h1v4h1" />
</TablerOutlineIcon>
}
}
#[component]
fn SystemIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! { <ManagementCenterIcon class=class /> }
}
#[component]
fn PersonalMenuIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! { <PersonalCenterIcon class=class /> }
}
#[component]
fn TaskMenuIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! { <DashboardIcon class=class /> }
}
#[component]
fn AuthMenuIcon(#[prop(into, optional)] class: String) -> impl IntoView {
view! { <ReportIcon class=class /> }
}
#[component]
pub fn ClientIcon(
icon: Option<&'static str>,
#[prop(into, optional)] class: String,
) -> impl IntoView {
match icon {
Some("DashboardIcon") => view! { <DashboardIcon class=class.clone() /> }.into_any(),
Some("EmployeeManagementIcon") => {
view! { <EmployeeManagementIcon class=class.clone() /> }.into_any()
}
Some("ImageAnnotationIcon") => {
view! { <ImageAnnotationIcon class=class.clone() /> }.into_any()
}
Some("LidarAnnotationIcon") => {
view! { <LidarAnnotationIcon class=class.clone() /> }.into_any()
}
Some("ManagementCenterIcon") => {
view! { <ManagementCenterIcon class=class.clone() /> }.into_any()
}
Some("MyProjectsIcon") => view! { <MyProjectsIcon class=class.clone() /> }.into_any(),
Some("PersonalCenterIcon") => {
view! { <PersonalCenterIcon class=class.clone() /> }.into_any()
}
Some("ProjectCategoryIcon") => {
view! { <ProjectCategoryIcon class=class.clone() /> }.into_any()
}
Some("ProjectManagementIcon") => {
view! { <ProjectManagementIcon class=class.clone() /> }.into_any()
}
Some("ReportIcon") => view! { <ReportIcon class=class.clone() /> }.into_any(),
Some("WorkloadIcon") => view! { <WorkloadIcon class=class.clone() /> }.into_any(),
Some("TeamManagementIcon") => {
view! { <TeamManagementIcon class=class.clone() /> }.into_any()
}
Some("VideoAnnotationIcon") => {
view! { <VideoAnnotationIcon class=class.clone() /> }.into_any()
}
Some("LockIcon") => view! { <LockIcon class=class.clone() /> }.into_any(),
Some("LogoutIcon") => view! { <LogoutIcon class=class.clone() /> }.into_any(),
Some("ChevronDownIcon") => view! { <ChevronDownIcon class=class.clone() /> }.into_any(),
Some("ChevronLeftIcon") => view! { <ChevronLeftIcon class=class.clone() /> }.into_any(),
Some("ChevronRightIcon") => view! { <ChevronRightIcon class=class.clone() /> }.into_any(),
Some("EditIcon") => view! { <EditIcon class=class.clone() /> }.into_any(),
Some("UploadIcon") => view! { <UploadIcon class=class.clone() /> }.into_any(),
Some("InfoCircleIcon") => view! { <InfoCircleIcon class=class.clone() /> }.into_any(),
Some("SystemIcon") => view! { <SystemIcon class=class.clone() /> }.into_any(),
Some("PersonalMenuIcon") => view! { <PersonalMenuIcon class=class.clone() /> }.into_any(),
Some("TaskMenuIcon") => view! { <TaskMenuIcon class=class.clone() /> }.into_any(),
Some("AuthMenuIcon") => view! { <AuthMenuIcon class=class /> }.into_any(),
_ => {
let _: () = view! { <></> };
().into_any()
}
}
}

View File

@@ -0,0 +1,16 @@
use leptos::prelude::*;
use crate::client_icon::ClientIcon;
#[component]
pub fn Breadcrumb(current: &'static str) -> impl IntoView {
view! {
<div class="cowa-breadcrumb">
<span>"数据平台"</span>
<ClientIcon icon=Some("ChevronRightIcon") class="cowa-breadcrumb-icon".to_owned() />
<span>"管理中心"</span>
<ClientIcon icon=Some("ChevronRightIcon") class="cowa-breadcrumb-icon".to_owned() />
<strong>{current}</strong>
</div>
}
}

View File

@@ -0,0 +1,140 @@
use app_config::SiteConfig;
use leptos::prelude::*;
use crate::client_icon::ClientIcon;
use crate::menu_items::{COMPONENT_LIST, MenuItem};
fn header_menu_href(item: MenuItem) -> String {
match item.url {
"person" => "/person/dashboard".to_owned(),
"rustui" => SiteConfig::with_base_path("/employee"),
_ => format!("/{}", item.url),
}
}
#[component]
pub(super) fn Header() -> impl IntoView {
let logo_src = SiteConfig::with_base_path("/header.svg");
view! {
<header class="cowa-topbar">
<div class="cowa-brand">
<img class="cowa-brand-logo" src=logo_src alt="酷哇标注平台" />
<span>"酷哇标注平台"</span>
</div>
<div class="cowa-topbar-main">
<div class="cowa-module-tabs">
<For
each=move || COMPONENT_LIST.iter().copied()
key=|item| item.url
children=move |item| {
let href = header_menu_href(item);
let is_active = item.url == "rustui";
view! {
<a
class="cowa-module-tab"
class=("cowa-module-tab-active", is_active)
href=href
>
<ClientIcon icon=item.icon class="cowa-module-tab-icon".to_owned() />
<span>{item.title}</span>
</a>
}
}
/>
</div>
<HeaderUserMenu />
</div>
</header>
}
}
#[component]
fn HeaderUserMenu() -> impl IntoView {
let is_password_modal_open = RwSignal::new(false);
view! {
<div class="cowa-user-menu">
<button
class="cowa-user-trigger"
type="button"
aria-haspopup="menu"
aria-label="用户菜单"
>
<span class="cowa-avatar">""</span>
<span class="cowa-user-meta">
<span class="cowa-user-name">"张俊峰"</span>
<span class="cowa-user-city">"上海市"</span>
</span>
<ClientIcon icon=Some("ChevronDownIcon") class="cowa-user-chevron".to_owned() />
</button>
<div class="cowa-user-dropdown" role="menu">
<div class="cowa-user-dropdown-label">"用户信息"</div>
<div class="cowa-user-dropdown-info">"张俊峰"</div>
<div class="cowa-user-dropdown-divider" />
<div class="cowa-user-dropdown-label">"操作"</div>
<button
class="cowa-user-dropdown-item"
type="button"
role="menuitem"
on:click=move |_| is_password_modal_open.set(true)
>
<ClientIcon icon=Some("LockIcon") class="cowa-user-dropdown-icon".to_owned() />
<span>"修改密码"</span>
</button>
<a class="cowa-user-dropdown-item" href="/login" role="menuitem">
<ClientIcon icon=Some("LogoutIcon") class="cowa-user-dropdown-icon".to_owned() />
<span>"退出登录"</span>
</a>
</div>
</div>
{move || {
if is_password_modal_open.get() {
view! {
<div class="cowa-modal-backdrop" role="presentation">
<div class="cowa-password-modal" role="dialog" aria-modal="true" aria-labelledby="password-modal-title">
<h2 id="password-modal-title">"修改密码"</h2>
<p>"请输入当前密码,并再次确认新密码后提交修改。"</p>
<form
class="cowa-password-form"
on:submit=move |event| {
event.prevent_default();
is_password_modal_open.set(false);
}
>
<label>
<span>"当前密码"</span>
<input type="password" placeholder="请输入当前密码" />
</label>
<label>
<span>"新密码"</span>
<input type="password" placeholder="请输入新密码" />
</label>
<label>
<span>"确认新密码"</span>
<input type="password" placeholder="请再次输入新密码" />
</label>
<div class="cowa-password-actions">
<button
class="cowa-password-cancel"
type="button"
on:click=move |_| is_password_modal_open.set(false)
>
"取消"
</button>
<button class="cowa-password-submit" type="submit">"确认修改"</button>
</div>
</form>
</div>
</div>
}.into_any()
} else {
let _: () = view! { <></> };
().into_any()
}
}}
}
}

View File

@@ -0,0 +1,7 @@
mod breadcrumb;
mod header;
mod shell;
mod sidebar;
pub use breadcrumb::Breadcrumb;
pub use shell::AdminShell;

View File

@@ -0,0 +1,25 @@
use leptos::prelude::*;
use super::header::Header;
use super::sidebar::Sidebar;
#[component]
pub fn AdminShell(children: Children) -> impl IntoView {
let is_sidebar_open = RwSignal::new(true);
view! {
<div
class="cowa-shell"
class=("cowa-shell-collapsed", move || !is_sidebar_open.get())
>
<Header />
<Sidebar is_open=is_sidebar_open />
<main class="cowa-main">
<div class="cowa-content">
{children()}
</div>
</main>
</div>
}
}

View File

@@ -0,0 +1,184 @@
use app_config::SiteConfig;
use leptos::prelude::*;
use leptos_router::hooks::use_location;
use crate::client_icon::ClientIcon;
use crate::menu_items::{MenuItem, RUSTUI_NAV_ITEMS};
fn is_route_active(current_path: &str, item: MenuItem) -> bool {
if item.url == "/employee" && (current_path == "/" || current_path == SiteConfig::BASE_PATH) {
return true;
}
if item.url == "/" {
return current_path == "/" || current_path == SiteConfig::BASE_PATH;
}
let path = item.url.strip_prefix('/').unwrap_or(item.url);
current_path.ends_with(item.url) || current_path.ends_with(path)
}
fn is_child_route_active(current_path: &str, item: MenuItem) -> bool {
item.items
.iter()
.any(|sub_item| is_route_active(current_path, *sub_item))
}
#[component]
pub(super) fn Sidebar(is_open: RwSignal<bool>) -> impl IntoView {
let location = use_location();
let current_path = Memo::new(move |_| location.pathname.get());
view! {
<aside class="cowa-sidebar">
<nav class="cowa-nav" aria-label="RustUI 导航">
<For
each=move || RUSTUI_NAV_ITEMS.iter().copied()
key=|item| item.title
children=move |item| {
let href = SiteConfig::with_base_path(item.url);
let has_children = !item.items.is_empty();
let active = Memo::new(move |_| {
!has_children && is_route_active(&current_path.get(), item)
});
let child_active = Memo::new(move |_| {
is_child_route_active(&current_path.get(), item)
});
let expanded = RwSignal::new(child_active.get_untracked());
view! {
<div class="cowa-nav-group">
{if has_children {
view! {
<button
class="cowa-nav-item"
class=("cowa-nav-item-has-active-child", move || child_active.get())
type="button"
title=item.title
aria-label=item.title
aria-expanded=move || expanded.get().to_string()
on:click=move |_| expanded.update(|open| *open = !*open)
>
<ClientIcon icon=item.icon class="cowa-nav-icon".to_owned() />
<span class="cowa-nav-label">{item.title}</span>
{move || {
if is_open.get() {
view! {
<ClientIcon
icon=Some("ChevronDownIcon")
class="cowa-nav-caret".to_owned()
/>
}.into_any()
} else {
let _: () = view! { <></> };
().into_any()
}
}}
</button>
}.into_any()
} else {
view! {
<a
class="cowa-nav-item"
class=("cowa-nav-item-active", move || active.get())
href=href
title=item.title
aria-label=item.title
>
<ClientIcon icon=item.icon class="cowa-nav-icon".to_owned() />
<span class="cowa-nav-label">{item.title}</span>
</a>
}.into_any()
}}
{move || {
if has_children && is_open.get() && expanded.get() {
view! {
<div class="cowa-nav-sublist">
<For
each=move || item.items.iter().copied()
key=|sub| sub.title
children=move |sub| {
let sub_href = SiteConfig::with_base_path(sub.url);
let sub_active = Memo::new(move |_| {
is_route_active(&current_path.get(), sub)
});
view! {
<a
class="cowa-nav-subitem"
class=("cowa-nav-subitem-active", move || sub_active.get())
href=sub_href
>
{sub.title}
</a>
}
}
/>
</div>
}.into_any()
} else {
let _: () = view! { <></> };
().into_any()
}
}}
{move || {
if has_children {
view! {
<div class="cowa-nav-flyout" role="menu">
<div class="cowa-nav-flyout-title">{item.title}</div>
<For
each=move || item.items.iter().copied()
key=|sub| sub.title
children=move |sub| {
let sub_href = SiteConfig::with_base_path(sub.url);
let sub_active = Memo::new(move |_| {
is_route_active(&current_path.get(), sub)
});
view! {
<a
class="cowa-nav-flyout-item"
class=("cowa-nav-flyout-item-active", move || sub_active.get())
href=sub_href
role="menuitem"
>
{sub.title}
</a>
}
}
/>
</div>
}.into_any()
} else {
let _: () = view! { <></> };
().into_any()
}
}}
</div>
}
}
/>
</nav>
<div class="cowa-sidebar-footer">
<button
class="cowa-sidebar-toggle"
type="button"
aria-label=move || if is_open.get() { "收起菜单" } else { "展开菜单" }
on:click=move |_| is_open.update(|open| *open = !*open)
>
{move || {
if is_open.get() {
view! {
<ClientIcon icon=Some("ChevronLeftIcon") class="cowa-sidebar-toggle-icon".to_owned() />
}.into_any()
} else {
view! {
<ClientIcon icon=Some("ChevronRightIcon") class="cowa-sidebar-toggle-icon".to_owned() />
}.into_any()
}
}}
</button>
</div>
</aside>
}
}

View File

@@ -0,0 +1 @@
pub mod layout;

19
app/src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
#![recursion_limit = "256"]
pub mod api;
pub mod app;
pub mod client_icon;
pub mod components;
pub mod menu_items;
pub mod pages;
pub mod shell;
pub mod terminal;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
console_error_panic_hook::set_once();
_ = console_log::init_with_level(log::Level::Debug);
leptos::mount::hydrate_body(app::App);
}

142
app/src/menu_items.rs Normal file
View File

@@ -0,0 +1,142 @@
#[derive(Clone, Copy)]
pub struct MenuItem {
pub url: &'static str,
pub title: &'static str,
pub icon: Option<&'static str>,
pub items: &'static [MenuItem],
}
pub const PERSONAL_CENTER_ITEMS: &[MenuItem] = &[
MenuItem {
title: "个人看板",
url: "dashboard",
icon: Some("DashboardIcon"),
items: &[],
},
MenuItem {
title: "我的日报",
url: "report",
icon: Some("ReportIcon"),
items: &[],
},
MenuItem {
title: "工时列表",
url: "workload",
icon: Some("WorkloadIcon"),
items: &[],
},
];
pub const COMPONENT_LIST: &[MenuItem] = &[
MenuItem {
title: "个人中心",
url: "person",
icon: Some("PersonalCenterIcon"),
items: PERSONAL_CENTER_ITEMS,
},
MenuItem {
title: "图片标注",
url: "image",
icon: Some("ImageAnnotationIcon"),
items: &[],
},
MenuItem {
title: "视频标注",
url: "video",
icon: Some("VideoAnnotationIcon"),
items: &[],
},
MenuItem {
title: "点云标注",
url: "lidar",
icon: Some("LidarAnnotationIcon"),
items: &[],
},
MenuItem {
title: "管理中心",
url: "mgt",
icon: Some("ManagementCenterIcon"),
items: &[],
},
MenuItem {
title: "Rustui",
url: "rustui",
icon: Some("ManagementCenterIcon"),
items: &[],
},
];
pub const TEAM_MANAGEMENT_ITEMS: &[MenuItem] = &[
MenuItem {
title: "组织管理",
url: "/team/organization",
icon: None,
items: &[],
},
MenuItem {
title: "团队日报",
url: "/team/dailypaper",
icon: None,
items: &[],
},
MenuItem {
title: "团队成本",
url: "/team/cost",
icon: None,
items: &[],
},
MenuItem {
title: "团队工时",
url: "/team/workload",
icon: None,
items: &[],
},
];
pub const PROJECT_MANAGEMENT_ITEMS: &[MenuItem] = &[
MenuItem {
title: "全部项目",
url: "/project/all",
icon: None,
items: &[],
},
MenuItem {
title: "项目审核",
url: "/project/audit",
icon: None,
items: &[],
},
];
pub const RUSTUI_NAV_ITEMS: &[MenuItem] = &[
MenuItem {
title: "员工管理",
url: "/employee",
icon: Some("EmployeeManagementIcon"),
items: &[],
},
MenuItem {
title: "团队管理",
url: "/team/organization",
icon: Some("TeamManagementIcon"),
items: TEAM_MANAGEMENT_ITEMS,
},
MenuItem {
title: "标注方案",
url: "/category",
icon: Some("ProjectCategoryIcon"),
items: &[],
},
MenuItem {
title: "项目管理",
url: "/project/all",
icon: Some("ProjectManagementIcon"),
items: PROJECT_MANAGEMENT_ITEMS,
},
MenuItem {
title: "浏览器终端",
url: "/terminal",
icon: Some("ManagementCenterIcon"),
items: &[],
},
];

2
app/src/pages/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod project_list;
pub mod terminal;

File diff suppressed because it is too large Load Diff

16
app/src/pages/terminal.rs Normal file
View File

@@ -0,0 +1,16 @@
use leptos::prelude::*;
use crate::components::layout::{AdminShell, Breadcrumb};
use crate::terminal::TerminalPanel;
#[component]
pub fn TerminalPage() -> impl IntoView {
view! {
<AdminShell>
<Breadcrumb current="浏览器终端" />
<section class="cowa-work-surface terminal-page-shell">
<TerminalPanel />
</section>
</AdminShell>
}
}

36
app/src/shell.rs Normal file
View File

@@ -0,0 +1,36 @@
use app_config::SiteConfig;
use leptos::prelude::*;
use leptos_meta::MetaTags;
use crate::app::App;
pub fn shell(options: LeptosOptions) -> impl IntoView {
let css_href = SiteConfig::with_base_path("/pkg/base_path_demo.css");
let js_href = SiteConfig::with_base_path("/pkg/base_path_demo.js");
let wasm_href = SiteConfig::with_base_path("/pkg/base_path_demo.wasm");
let hydration_script = format!(
r#"import("{js_href}").then((mod) => {{
mod.default({{ module_or_path: "{wasm_href}" }}).then(() => mod.hydrate());
}});"#
);
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content=SiteConfig::DESCRIPTION />
<link rel="stylesheet" href=css_href />
<link rel="modulepreload" href=js_href />
<link rel="preload" href=wasm_href r#as="fetch" r#type="application/wasm" crossorigin="anonymous" />
<AutoReload options=options.clone() />
<script type="module" inner_html=hydration_script></script>
<MetaTags />
</head>
<body>
<App />
</body>
</html>
}
}

View File

@@ -0,0 +1,662 @@
use std::cell::RefCell;
use std::rc::Rc;
use app_config::SiteConfig;
use leptos::html;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use web_sys::js_sys::{Array, Function};
use web_sys::{
BinaryType, ErrorEvent, Event, HtmlElement, KeyboardEvent, MessageEvent,
ResizeObserver, ResizeObserverEntry, WebSocket,
};
use super::core::{
DEFAULT_COLS, DEFAULT_ROWS, TerminalCore, TerminalRow, TerminalSegment,
TerminalSnapshot,
};
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
use super::scrollback::{ScrollMetrics, ScrollbackModel};
const DEFAULT_CELL_WIDTH: f64 = 8.0;
const DEFAULT_CELL_HEIGHT: f64 = 18.0;
const MIN_COLS: u16 = 40;
const MIN_ROWS: u16 = 12;
const MEASURE_SAMPLE_TEXT: &str =
"WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
const SCROLL_LINE_HEIGHT: f64 = 18.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct TerminalViewport {
cols: u16,
rows: u16,
pixel_width: u16,
pixel_height: u16,
}
impl Default for TerminalViewport {
fn default() -> Self {
Self {
cols: DEFAULT_COLS,
rows: DEFAULT_ROWS,
pixel_width: (f64::from(DEFAULT_COLS) * DEFAULT_CELL_WIDTH).round()
as u16,
pixel_height: (f64::from(DEFAULT_ROWS) * DEFAULT_CELL_HEIGHT)
.round() as u16,
}
}
}
#[component]
pub fn TerminalPanel() -> impl IntoView {
let terminal_ref = NodeRef::<html::Div>::new();
let measure_ref = NodeRef::<html::Span>::new();
let snapshot = RwSignal::new(TerminalSnapshot::default());
let connection_status = RwSignal::new("Connecting".to_owned());
let ws_signal = RwSignal::new(None::<WebSocket>);
let current_viewport = RwSignal::new(TerminalViewport::default());
let output_updates = RwSignal::new(0_u64);
let scrollback_model = RwSignal::new(ScrollbackModel::default());
let ignore_next_scroll = RwSignal::new(false);
let terminal_core = Rc::new(RefCell::new(TerminalCore::default()));
Effect::new({
let terminal_core = terminal_core.clone();
move |_| {
let url = websocket_url();
let Ok(socket) = WebSocket::new(&url) else {
connection_status.set("Failed to connect".to_owned());
return;
};
socket.set_binary_type(BinaryType::Arraybuffer);
let socket = Rc::new(socket);
let open_socket = socket.clone();
let open_status = connection_status;
let open_ws = ws_signal;
let open_viewport = current_viewport;
let open_ref = terminal_ref;
let open_measure = measure_ref;
let open_snapshot = snapshot;
let open_model = scrollback_model;
let open_ignore_scroll = ignore_next_scroll;
let open_core = terminal_core.clone();
let on_open = Closure::<dyn Fn(Event)>::new(move |_| {
open_status.set("Connected".to_owned());
open_ws.set(Some((*open_socket).clone()));
let viewport = measure_terminal_viewport(open_ref, open_measure);
open_viewport.set(viewport);
{
let mut core = open_core.borrow_mut();
resize_terminal_core(&mut core, viewport);
let mut model = open_model.get_untracked();
model.enter_live(metrics_from_core(viewport, &mut core));
open_model.set(model);
let next_snapshot = snapshot_for_model(&mut core, model, viewport);
open_snapshot.set(next_snapshot.clone());
sync_scroll_to_model(
open_ref,
next_snapshot,
model,
viewport,
open_ignore_scroll,
);
}
send_resize_message(&open_socket, viewport);
focus_terminal(open_ref);
});
socket.set_onopen(Some(on_open.as_ref().unchecked_ref()));
on_open.forget();
let message_snapshot = snapshot;
let message_core = terminal_core.clone();
let message_status = connection_status;
let message_ref = terminal_ref;
let message_updates = output_updates;
let message_viewport = current_viewport;
let message_model = scrollback_model;
let message_ignore_scroll = ignore_next_scroll;
let on_message =
Closure::<dyn Fn(MessageEvent)>::new(move |event: MessageEvent| {
let Some(text) = event.data().as_string() else {
return;
};
let Ok(message) =
serde_json::from_str::<ServerTerminalMessage>(&text)
else {
message_status.set("Protocol error".to_owned());
return;
};
match message {
ServerTerminalMessage::Output { data } => {
let viewport = message_viewport.get_untracked();
let mut core = message_core.borrow_mut();
core.process(data.as_bytes());
let mut model = message_model.get_untracked();
model.normalize(metrics_from_core(viewport, &mut core));
message_model.set(model);
let next_snapshot =
snapshot_for_model(&mut core, model, viewport);
message_snapshot.set(next_snapshot.clone());
message_updates.update(|count| *count += 1);
if model.is_live() {
sync_scroll_to_model(
message_ref,
next_snapshot,
model,
viewport,
message_ignore_scroll,
);
}
focus_terminal(message_ref);
}
ServerTerminalMessage::Exit { code } => {
message_status.set(match code {
Some(code) => format!("Exited ({code})"),
None => "Exited".to_owned(),
});
}
ServerTerminalMessage::Error { message } => {
message_status.set(format!("Error: {message}"));
}
ServerTerminalMessage::Pong => {}
}
});
socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
on_message.forget();
let close_status = connection_status;
let close_ws = ws_signal;
let on_close = Closure::<dyn Fn(Event)>::new(move |_| {
close_status.set("Disconnected".to_owned());
close_ws.set(None);
});
socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
on_close.forget();
let error_status = connection_status;
let on_error = Closure::<dyn Fn(ErrorEvent)>::new(move |_| {
error_status.set("Connection error".to_owned());
});
socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
on_error.forget();
}
});
Effect::new({
let terminal_core = terminal_core.clone();
move |_| {
let Some(element) = terminal_ref.get() else {
return;
};
let Some(socket) = ws_signal.get() else {
return;
};
let resize_socket = socket.clone();
let resize_viewport = current_viewport;
let resize_ref = terminal_ref;
let resize_measure = measure_ref;
let resize_snapshot = snapshot;
let resize_model = scrollback_model;
let resize_ignore_scroll = ignore_next_scroll;
let resize_core = terminal_core.clone();
let callback = Closure::<dyn FnMut(Array, ResizeObserver)>::new(
move |entries: Array, _observer: ResizeObserver| {
let Some(entry) = entries
.get(0)
.dyn_into::<ResizeObserverEntry>()
.ok()
else {
return;
};
let rect = entry.content_rect();
let viewport = measure_terminal_viewport_from_rect(
rect.width(),
rect.height(),
resize_measure,
);
if viewport != resize_viewport.get_untracked() {
resize_viewport.set(viewport);
let mut core = resize_core.borrow_mut();
resize_terminal_core(&mut core, viewport);
let mut model = resize_model.get_untracked();
model.normalize(metrics_from_core(viewport, &mut core));
resize_model.set(model);
let next_snapshot =
snapshot_for_model(&mut core, model, viewport);
resize_snapshot.set(next_snapshot.clone());
sync_scroll_to_model(
resize_ref,
next_snapshot,
model,
viewport,
resize_ignore_scroll,
);
send_resize_message(&resize_socket, viewport);
}
focus_terminal(resize_ref);
},
);
let Ok(observer) = ResizeObserver::new(
callback.as_ref().unchecked_ref::<Function>(),
) else {
return;
};
observer.observe(&element);
callback.forget();
std::mem::forget(observer);
}
});
let handle_keydown = {
let terminal_core = terminal_core.clone();
let keydown_viewport = current_viewport;
let keydown_model = scrollback_model;
let keydown_ignore_scroll = ignore_next_scroll;
move |event: KeyboardEvent| {
let Some(socket) = ws_signal.get_untracked() else {
return;
};
let Some(data) = map_key_to_terminal_input(&event) else {
return;
};
let model = keydown_model.get_untracked();
if !model.is_live() {
let viewport = keydown_viewport.get_untracked();
let mut core = terminal_core.borrow_mut();
let mut next_model = model;
next_model.enter_live(metrics_from_core(viewport, &mut core));
keydown_model.set(next_model);
let live_snapshot =
snapshot_for_model(&mut core, next_model, viewport);
snapshot.set(live_snapshot.clone());
sync_scroll_to_model(
terminal_ref,
live_snapshot,
next_model,
viewport,
keydown_ignore_scroll,
);
}
event.prevent_default();
send_terminal_message(&socket, &ClientTerminalMessage::Input { data });
}
};
let handle_scroll = {
let terminal_core = terminal_core.clone();
let scroll_viewport = current_viewport;
let scroll_model = scrollback_model;
let scroll_ignore_next = ignore_next_scroll;
move |_| {
if scroll_ignore_next.get_untracked() {
scroll_ignore_next.set(false);
return;
}
let Some(element) = terminal_ref.get_untracked() else {
return;
};
let viewport = scroll_viewport.get_untracked();
let mut core = terminal_core.borrow_mut();
let metrics = metrics_from_core(viewport, &mut core);
let mut model = scroll_model.get_untracked();
model.apply_scroll_top(
element.scroll_top(),
element.scroll_height(),
element.client_height(),
SCROLL_LINE_HEIGHT,
metrics,
);
model.normalize(metrics);
scroll_model.set(model);
let next_snapshot = snapshot_for_model(&mut core, model, viewport);
snapshot.set(next_snapshot.clone());
sync_scroll_to_model(
terminal_ref,
next_snapshot,
model,
viewport,
scroll_ignore_next,
);
}
};
let handle_click = move |_| {
focus_terminal(terminal_ref);
};
view! {
<section class="terminal-shell-card">
<div class="terminal-header">
<div>
<h2 class="terminal-title">"Rust Browser Terminal"</h2>
<p class="terminal-subtitle">
"Axum + portable-pty backend, Leptos + Rust/WASM client."
</p>
</div>
<div class="terminal-status-stack">
<span class="terminal-badge">{move || connection_status.get()}</span>
<span class="terminal-metrics">
{move || {
let viewport = current_viewport.get();
let buffer = snapshot.get();
let model = scrollback_model.get();
format!(
"view {} x {} / buffer {} x {} / scroll {} of {} / mode {} / {} updates",
viewport.cols,
viewport.rows,
buffer.terminal_cols,
buffer.terminal_rows,
buffer.scrollback_offset,
buffer.max_scrollback,
if model.is_live() { "live" } else { "history" },
output_updates.get()
)
}}
</span>
</div>
</div>
<div
node_ref=terminal_ref
class="terminal-screen"
tabindex="0"
on:click=handle_click
on:keydown=handle_keydown
on:scroll=handle_scroll
>
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
{MEASURE_SAMPLE_TEXT}
</span>
<div class="terminal-grid">
{move || render_terminal_view(snapshot.get())}
</div>
</div>
</section>
}
}
fn metrics_from_core(
viewport: TerminalViewport,
core: &mut TerminalCore,
) -> ScrollMetrics {
let snapshot = core.snapshot_live();
ScrollMetrics {
viewport_rows: usize::from(viewport.rows),
scrollback_rows: snapshot.max_scrollback,
}
}
fn snapshot_for_model(
core: &mut TerminalCore,
model: ScrollbackModel,
viewport: TerminalViewport,
) -> TerminalSnapshot {
let metrics = metrics_from_core(viewport, core);
if model.is_live() {
core.snapshot_live()
} else {
core.snapshot_for_top_row(model.top_row().min(metrics.max_top_row()))
}
}
fn render_terminal_view(snapshot: TerminalSnapshot) -> impl IntoView {
let metrics = ScrollMetrics {
viewport_rows: usize::from(snapshot.terminal_rows),
scrollback_rows: snapshot.max_scrollback,
};
let mut model = ScrollbackModel::default();
if snapshot.scrollback_offset == 0 {
model.enter_live(metrics);
} else {
let top_row = metrics
.max_top_row()
.saturating_sub(snapshot.scrollback_offset);
model.apply_scroll_top(
(top_row as f64 * SCROLL_LINE_HEIGHT).round() as i32,
(metrics.total_rows() as f64 * SCROLL_LINE_HEIGHT).round() as i32,
(metrics.viewport_rows as f64 * SCROLL_LINE_HEIGHT).round() as i32,
SCROLL_LINE_HEIGHT,
metrics,
);
}
let plan = model.render_plan(metrics);
view! {
<div
class="terminal-scroll-spacer"
style=format!("height:{}px;", plan.top_spacer_rows as f64 * SCROLL_LINE_HEIGHT)
aria-hidden="true"
></div>
{snapshot
.rows
.into_iter()
.map(render_terminal_row)
.collect_view()}
<div
class="terminal-scroll-spacer"
style=format!("height:{}px;", plan.bottom_spacer_rows as f64 * SCROLL_LINE_HEIGHT)
aria-hidden="true"
></div>
}
}
fn render_terminal_row(row: TerminalRow) -> impl IntoView {
view! {
<div class="terminal-row">
{row
.segments
.into_iter()
.map(render_terminal_segment)
.collect_view()}
</div>
}
}
fn render_terminal_segment(segment: TerminalSegment) -> impl IntoView {
let style = format!(
"color:{};background:{};font-weight:{};font-style:{};text-decoration:{};",
segment.style.color,
segment.style.background,
if segment.style.bold { "700" } else { "400" },
if segment.style.italic { "italic" } else { "normal" },
if segment.style.underline { "underline" } else { "none" },
);
view! {
<span
class="terminal-segment"
class:terminal-cursor=segment.is_cursor
style=style
>
{segment.text}
</span>
}
}
fn websocket_url() -> String {
let window = window();
let location = window.location();
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_owned());
let host = location.host().unwrap_or_else(|_| "127.0.0.1:3100".to_owned());
let scheme = if protocol == "https:" { "wss" } else { "ws" };
format!("{scheme}://{host}{}/terminal/ws", SiteConfig::BASE_PATH)
}
fn send_terminal_message(socket: &WebSocket, message: &ClientTerminalMessage) {
if let Ok(payload) = serde_json::to_string(message) {
let _ = socket.send_with_str(&payload);
}
}
fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) {
send_terminal_message(
socket,
&ClientTerminalMessage::Resize {
cols: viewport.cols,
rows: viewport.rows,
pixel_width: viewport.pixel_width,
pixel_height: viewport.pixel_height,
},
);
}
fn resize_terminal_core(core: &mut TerminalCore, viewport: TerminalViewport) {
core.resize(viewport.rows, viewport.cols);
}
fn measure_terminal_viewport(
terminal_ref: NodeRef<html::Div>,
measure_ref: NodeRef<html::Span>,
) -> TerminalViewport {
let Some(element) = terminal_ref.get_untracked() else {
return TerminalViewport::default();
};
let Some(element) = element.dyn_ref::<HtmlElement>() else {
return TerminalViewport::default();
};
measure_terminal_viewport_from_rect(
f64::from(element.client_width().max(0)),
f64::from(element.client_height().max(0)),
measure_ref,
)
}
fn measure_terminal_viewport_from_rect(
width: f64,
height: f64,
measure_ref: NodeRef<html::Span>,
) -> TerminalViewport {
let (cell_width, cell_height) = measure_cell_size(measure_ref);
let safe_width = (width - 2.0).max(cell_width);
let safe_height = (height - 2.0).max(cell_height);
let cols = ((safe_width / cell_width).floor().max(1.0) as u16).max(MIN_COLS);
let rows = ((safe_height / cell_height).floor().max(1.0) as u16).max(MIN_ROWS);
TerminalViewport {
cols,
rows,
pixel_width: safe_width.floor().max(1.0) as u16,
pixel_height: safe_height.floor().max(1.0) as u16,
}
}
fn measure_cell_size(measure_ref: NodeRef<html::Span>) -> (f64, f64) {
let Some(element) = measure_ref.get_untracked() else {
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
};
let Some(element) = element.dyn_ref::<HtmlElement>() else {
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
};
let width = f64::from(element.offset_width().max(0));
let height = f64::from(element.offset_height().max(0));
if width <= 0.0 || height <= 0.0 {
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
}
(
width / (MEASURE_SAMPLE_TEXT.chars().count() as f64),
height,
)
}
fn focus_terminal(terminal_ref: NodeRef<html::Div>) {
if let Some(element) = terminal_ref.get_untracked() {
let _ = element.focus();
}
}
fn sync_scroll_to_model(
terminal_ref: NodeRef<html::Div>,
snapshot: TerminalSnapshot,
model: ScrollbackModel,
viewport: TerminalViewport,
ignore_next_scroll: RwSignal<bool>,
) {
let callback = Closure::<dyn FnMut()>::wrap(Box::new(move || {
let Some(element) = terminal_ref.get_untracked() else {
return;
};
let metrics = ScrollMetrics {
viewport_rows: usize::from(viewport.rows),
scrollback_rows: snapshot.max_scrollback,
};
let scroll_top = model.target_scroll_top(
element.scroll_height(),
element.client_height(),
SCROLL_LINE_HEIGHT,
metrics,
);
ignore_next_scroll.set(true);
element.set_scroll_top(scroll_top);
}));
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
0,
);
callback.forget();
}
fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option<String> {
let key = event.key();
if event.ctrl_key() && !event.alt_key() {
return match key.as_str() {
"c" | "C" => Some("\u{3}".to_owned()),
"d" | "D" => Some("\u{4}".to_owned()),
"l" | "L" => Some("\u{c}".to_owned()),
_ => None,
};
}
match key.as_str() {
"Enter" => Some("\r".to_owned()),
"Backspace" => Some("\u{7f}".to_owned()),
"Tab" => Some("\t".to_owned()),
"ArrowUp" => Some("\u{1b}[A".to_owned()),
"ArrowDown" => Some("\u{1b}[B".to_owned()),
"ArrowRight" => Some("\u{1b}[C".to_owned()),
"ArrowLeft" => Some("\u{1b}[D".to_owned()),
"Escape" => Some("\u{1b}".to_owned()),
_ if key.chars().count() == 1 && !event.meta_key() => Some(key),
_ => None,
}
}

312
app/src/terminal/core.rs Normal file
View File

@@ -0,0 +1,312 @@
use vt100::{Color, Parser};
pub const DEFAULT_ROWS: u16 = 24;
pub const DEFAULT_COLS: u16 = 80;
const DEFAULT_SCROLLBACK: usize = 2_000;
pub struct TerminalCore {
parser: Parser,
}
impl TerminalCore {
pub fn new(rows: u16, cols: u16) -> Self {
Self {
parser: Parser::new(rows, cols, DEFAULT_SCROLLBACK),
}
}
pub fn process(&mut self, bytes: &[u8]) {
self.parser.process(bytes);
}
pub fn resize(&mut self, rows: u16, cols: u16) {
self.parser.set_size(rows, cols);
}
pub fn set_scrollback(&mut self, rows: usize) {
self.parser.set_scrollback(rows);
}
pub fn snapshot(&mut self) -> TerminalSnapshot {
self.snapshot_live()
}
pub fn snapshot_live(&mut self) -> TerminalSnapshot {
self.snapshot_for_top_row_internal(None)
}
pub fn snapshot_for_top_row(&mut self, top_row: usize) -> TerminalSnapshot {
self.snapshot_for_top_row_internal(Some(top_row))
}
fn snapshot_for_top_row_internal(
&mut self,
requested_top_row: Option<usize>,
) -> TerminalSnapshot {
self.parser.set_scrollback(usize::MAX);
let max_scrollback = self.parser.screen().scrollback();
let resolved_top_row =
requested_top_row.unwrap_or(max_scrollback).min(max_scrollback);
let current_scrollback = max_scrollback.saturating_sub(resolved_top_row);
self.parser.set_scrollback(current_scrollback);
let screen = self.parser.screen();
let (rows, cols) = screen.size();
let (cursor_row, cursor_col) = screen.cursor_position();
let hide_cursor = screen.hide_cursor();
let mut rendered_rows = Vec::with_capacity(usize::from(rows));
for row in 0..rows {
let mut segments = Vec::new();
let mut current_style: Option<TerminalStyle> = None;
let mut current_cursor = false;
let mut current_text = String::new();
for col in 0..cols {
let Some(cell) = screen.cell(row, col) else {
continue;
};
if cell.is_wide_continuation() {
continue;
}
let is_cursor =
!hide_cursor && cursor_row == row && cursor_col == col;
let style = style_from_cell(cell);
let text = if cell.has_contents() {
cell.contents()
} else {
" ".to_owned()
};
if current_style.as_ref() == Some(&style)
&& current_cursor == is_cursor
{
current_text.push_str(&text);
} else {
flush_segment(
&mut segments,
&mut current_style,
&mut current_text,
current_cursor,
);
current_style = Some(style);
current_cursor = is_cursor;
current_text = text;
}
}
if !hide_cursor && cursor_row == row && cursor_col >= cols {
flush_segment(
&mut segments,
&mut current_style,
&mut current_text,
current_cursor,
);
segments.push(TerminalSegment {
text: " ".to_owned(),
style: TerminalStyle::default(),
is_cursor: true,
});
} else {
flush_segment(
&mut segments,
&mut current_style,
&mut current_text,
current_cursor,
);
}
if segments.is_empty() {
segments.push(TerminalSegment {
text: " ".to_owned(),
style: TerminalStyle::default(),
is_cursor: false,
});
}
rendered_rows.push(TerminalRow { segments });
}
TerminalSnapshot {
rows: rendered_rows,
title: screen.title().to_owned(),
cursor_row,
cursor_col,
terminal_rows: rows,
terminal_cols: cols,
hide_cursor,
scrollback_offset: current_scrollback,
max_scrollback,
alternate_screen: screen.alternate_screen(),
}
}
}
impl Default for TerminalCore {
fn default() -> Self {
Self::new(DEFAULT_ROWS, DEFAULT_COLS)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TerminalSnapshot {
pub rows: Vec<TerminalRow>,
pub title: String,
pub cursor_row: u16,
pub cursor_col: u16,
pub terminal_rows: u16,
pub terminal_cols: u16,
pub hide_cursor: bool,
pub scrollback_offset: usize,
pub max_scrollback: usize,
pub alternate_screen: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TerminalRow {
pub segments: Vec<TerminalSegment>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalSegment {
pub text: String,
pub style: TerminalStyle,
pub is_cursor: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalStyle {
pub color: String,
pub background: String,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl Default for TerminalStyle {
fn default() -> Self {
Self {
color: "#dbeafe".to_owned(),
background: "transparent".to_owned(),
bold: false,
italic: false,
underline: false,
}
}
}
fn flush_segment(
segments: &mut Vec<TerminalSegment>,
current_style: &mut Option<TerminalStyle>,
current_text: &mut String,
is_cursor: bool,
) {
if current_text.is_empty() {
return;
}
let style = current_style.clone().unwrap_or_default();
let text = std::mem::take(current_text);
segments.push(TerminalSegment {
text,
style,
is_cursor,
});
}
fn style_from_cell(cell: &vt100::Cell) -> TerminalStyle {
let color = if cell.inverse() {
resolve_background(cell.bgcolor(), false)
} else {
resolve_foreground(cell.fgcolor())
};
let background = if cell.inverse() {
resolve_foreground(cell.fgcolor())
} else {
resolve_background(cell.bgcolor(), true)
};
TerminalStyle {
color,
background,
bold: cell.bold(),
italic: cell.italic(),
underline: cell.underline(),
}
}
fn resolve_foreground(color: Color) -> String {
match color {
Color::Default => "#dbeafe".to_owned(),
Color::Idx(index) => indexed_color(index),
Color::Rgb(red, green, blue) => {
format!("#{red:02x}{green:02x}{blue:02x}")
}
}
}
fn resolve_background(color: Color, transparent_default: bool) -> String {
match color {
Color::Default => {
if transparent_default {
"transparent".to_owned()
} else {
"#0f172a".to_owned()
}
}
Color::Idx(index) => indexed_color(index),
Color::Rgb(red, green, blue) => {
format!("#{red:02x}{green:02x}{blue:02x}")
}
}
}
fn indexed_color(index: u8) -> String {
const ANSI_16: [&str; 16] = [
"#1e293b",
"#ef4444",
"#22c55e",
"#eab308",
"#3b82f6",
"#d946ef",
"#06b6d4",
"#e2e8f0",
"#475569",
"#f87171",
"#4ade80",
"#fde047",
"#60a5fa",
"#e879f9",
"#67e8f9",
"#f8fafc",
];
match index {
0..=15 => ANSI_16[usize::from(index)].to_owned(),
16..=231 => {
let base = index - 16;
let red = base / 36;
let green = (base % 36) / 6;
let blue = base % 6;
let convert = |value: u8| -> u8 {
if value == 0 {
0
} else {
value.saturating_mul(40).saturating_add(55)
}
};
format!(
"#{:02x}{:02x}{:02x}",
convert(red),
convert(green),
convert(blue)
)
}
232..=255 => {
let shade = 8_u8.saturating_add((index - 232).saturating_mul(10));
format!("#{shade:02x}{shade:02x}{shade:02x}")
}
}
}

6
app/src/terminal/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod component;
pub mod core;
pub mod protocol;
pub mod scrollback;
pub use component::TerminalPanel;

View File

@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientTerminalMessage {
Input {
data: String,
},
Resize {
cols: u16,
rows: u16,
pixel_width: u16,
pixel_height: u16,
},
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerTerminalMessage {
Output {
data: String,
},
Exit {
code: Option<i32>,
},
Error {
message: String,
},
Pong,
}

View File

@@ -0,0 +1,126 @@
const LIVE_SCROLL_THRESHOLD_PX: i32 = 24;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScrollMetrics {
pub viewport_rows: usize,
pub scrollback_rows: usize,
}
impl ScrollMetrics {
pub fn total_rows(self) -> usize {
self.scrollback_rows.saturating_add(self.viewport_rows)
}
pub fn max_top_row(self) -> usize {
self.total_rows().saturating_sub(self.viewport_rows)
}
pub fn max_scrollback_offset(self) -> usize {
self.scrollback_rows
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScrollRenderPlan {
pub top_spacer_rows: usize,
pub bottom_spacer_rows: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScrollbackModel {
live: bool,
top_row: usize,
}
impl Default for ScrollbackModel {
fn default() -> Self {
Self {
live: true,
top_row: 0,
}
}
}
impl ScrollbackModel {
pub fn is_live(self) -> bool {
self.live
}
pub fn top_row(self) -> usize {
self.top_row
}
pub fn enter_live(&mut self, metrics: ScrollMetrics) {
self.live = true;
self.top_row = metrics.max_top_row();
}
pub fn normalize(&mut self, metrics: ScrollMetrics) {
if self.live {
self.top_row = metrics.max_top_row();
} else {
self.top_row = self.top_row.min(metrics.max_top_row());
}
}
pub fn apply_scroll_top(
&mut self,
scroll_top: i32,
scroll_height: i32,
client_height: i32,
line_height_px: f64,
metrics: ScrollMetrics,
) {
let distance_from_bottom =
(scroll_height - client_height - scroll_top).max(0);
if distance_from_bottom <= LIVE_SCROLL_THRESHOLD_PX {
self.enter_live(metrics);
return;
}
self.live = false;
self.top_row = scroll_top_to_row(scroll_top, line_height_px)
.min(metrics.max_top_row());
}
pub fn target_scroll_top(
self,
scroll_height: i32,
client_height: i32,
line_height_px: f64,
metrics: ScrollMetrics,
) -> i32 {
if self.live {
return (scroll_height - client_height).max(0);
}
let max_scroll_top = (scroll_height - client_height).max(0);
row_to_scroll_top(self.top_row.min(metrics.max_top_row()), line_height_px)
.min(max_scroll_top)
}
pub fn current_scrollback_offset(self, metrics: ScrollMetrics) -> usize {
metrics.max_top_row().saturating_sub(self.top_row)
}
pub fn render_plan(self, metrics: ScrollMetrics) -> ScrollRenderPlan {
let top_spacer_rows = self.top_row.min(metrics.max_top_row());
let bottom_spacer_rows = metrics
.total_rows()
.saturating_sub(top_spacer_rows)
.saturating_sub(metrics.viewport_rows);
ScrollRenderPlan {
top_spacer_rows,
bottom_spacer_rows,
}
}
}
pub fn scroll_top_to_row(scroll_top: i32, line_height_px: f64) -> usize {
((f64::from(scroll_top.max(0)) / line_height_px.max(1.0)).round()) as usize
}
pub fn row_to_scroll_top(row: usize, line_height_px: f64) -> i32 {
((row as f64) * line_height_px).round() as i32
}

6
app_config/Cargo.toml Normal file
View File

@@ -0,0 +1,6 @@
[package]
name = "app_config"
version = "0.1.0"
edition = "2024"
[dependencies]

18
app_config/src/lib.rs Normal file
View File

@@ -0,0 +1,18 @@
pub struct SiteConfig;
impl SiteConfig {
pub const TITLE: &str = "BASE_PATH Demo";
pub const DESCRIPTION: &str = "A small Leptos SSR app for validating sub-path deployment.";
pub const BASE_URL: &str = "http://127.0.0.1:3100";
pub const BASE_PATH: &str = "/rustui";
pub fn with_base_path(path: &str) -> String {
if Self::BASE_PATH.is_empty() {
path.to_owned()
} else if path == "/" {
Self::BASE_PATH.to_owned()
} else {
format!("{}{}", Self::BASE_PATH, path)
}
}
}

7
package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"private": true,
"devDependencies": {
"@tailwindcss/cli": "4.3.0",
"tailwindcss": "4.3.0"
}
}

26
public/header.svg Normal file
View File

@@ -0,0 +1,26 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.72131 7.97323L7.77543 10.3299L16.0003 5.54456L24.2738 10.3582L28.3342 8.00521L16.0003 0.829102L3.72131 7.97323Z" fill="url(#paint0_linear_300_8367)"/>
<path d="M13.8691 13.9176L16.0001 12.6777L18.1442 13.9252L21.9243 11.7157L16.0001 8.2689L10.0962 11.7039L13.8691 13.9176Z" fill="url(#paint1_linear_300_8367)"/>
<path d="M25.2219 21.6404V12.0299L29.2742 9.68164V23.998L16.9482 31.1695V26.4541L25.2219 21.6404Z" fill="#1874FF"/>
<path d="M19.0916 18.0737V15.6176L19.092 15.6323L22.8805 13.4055V20.2782L16.9482 23.7297V19.3208L19.0916 18.0737Z" fill="#1874FF"/>
<path d="M15.0518 26.4536L6.77814 21.6399V11.9728L2.72583 9.61719V23.9975L15.0518 31.169V26.4536Z" fill="url(#paint2_linear_300_8367)"/>
<path d="M9.11923 13.3376V20.2777L15.0518 23.7293V19.3205L12.9081 18.0732V15.55L9.11923 13.3376Z" fill="url(#paint3_linear_300_8367)"/>
<defs>
<linearGradient id="paint0_linear_300_8367" x1="14.104" y1="4.62169" x2="19.7265" y2="13.0418" gradientUnits="userSpaceOnUse">
<stop stop-color="#06C3FF"/>
<stop offset="1" stop-color="#0B60F3"/>
</linearGradient>
<linearGradient id="paint1_linear_300_8367" x1="14.104" y1="4.62169" x2="19.7265" y2="13.0418" gradientUnits="userSpaceOnUse">
<stop stop-color="#06C3FF"/>
<stop offset="1" stop-color="#0B60F3"/>
</linearGradient>
<linearGradient id="paint2_linear_300_8367" x1="10.311" y1="15.5246" x2="10.7851" y2="28.7987" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF8F90"/>
<stop offset="1" stop-color="#F62F3F"/>
</linearGradient>
<linearGradient id="paint3_linear_300_8367" x1="10.311" y1="15.5246" x2="10.7851" y2="28.7987" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF8F90"/>
<stop offset="1" stop-color="#F62F3F"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.6 MiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
targets = ["wasm32-unknown-unknown"]

20
server/Cargo.toml Normal file
View File

@@ -0,0 +1,20 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
app = { path = "../app", default-features = false, features = ["ssr"] }
app_config.workspace = true
axum.workspace = true
futures-util.workspace = true
leptos = { workspace = true, features = ["ssr"] }
leptos_axum.workspace = true
portable-pty.workspace = true
serde_json.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
dotenvy = "0.15"

93
server/src/main.rs Normal file
View File

@@ -0,0 +1,93 @@
use app::app::App;
use app::shell::shell;
use app_config::SiteConfig;
use axum::Router;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_axum::{LeptosRoutes, generate_route_list, handle_server_fns};
mod terminal;
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
load_env_files();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env().add_directive("info".parse()?),
)
.init();
let conf = get_configuration(None).map_err(|err| {
tracing::error!("Failed to get Leptos configuration: {err:?}");
err
})?;
let addr = conf.leptos_options.site_addr;
let leptos_options = conf.leptos_options;
let routes = generate_route_list(App);
let leptos_router = Router::new()
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
let terminal_routes = Router::new().route(
"/terminal/ws",
axum::routing::get(terminal::ws::terminal_ws_handler),
);
let app = if SiteConfig::BASE_PATH.is_empty() {
terminal_routes.merge(leptos_router)
} else {
Router::new()
.route(
"/rustui/api/{*fn_name}",
axum::routing::post(handle_server_fns),
)
.route("/api/{*fn_name}", axum::routing::post(handle_server_fns))
.merge(terminal_routes.clone())
.nest(SiteConfig::BASE_PATH, terminal_routes.merge(leptos_router))
};
log!(
"BASE_PATH demo listening on http://{addr}{}",
SiteConfig::BASE_PATH
);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app.into_make_service()).await?;
Ok(())
}
fn load_env_files() {
for filename in [".env", ".env.local"] {
let _ = dotenvy::from_filename(filename);
}
let env = std::env::var("NEXT_PUBLIC_ENV").unwrap_or_else(|_| "development".to_owned());
for filename in [format!(".env.{env}"), format!(".env.{env}.local")] {
let _ = dotenvy::from_filename(&filename);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_path_gets_base_path() {
assert_eq!(SiteConfig::with_base_path("/"), "/rustui");
}
#[test]
fn nested_path_gets_base_path() {
assert_eq!(
SiteConfig::with_base_path("/deep/nested"),
"/rustui/deep/nested"
);
}
}

View File

@@ -0,0 +1,2 @@
pub mod pty_session;
pub mod ws;

View File

@@ -0,0 +1,149 @@
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use portable_pty::{
ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system,
};
use tokio::sync::mpsc;
#[derive(Clone, Copy, Debug)]
pub struct TerminalSize {
pub cols: u16,
pub rows: u16,
pub pixel_width: u16,
pub pixel_height: u16,
}
impl Default for TerminalSize {
fn default() -> Self {
Self {
cols: 80,
rows: 24,
pixel_width: 720,
pixel_height: 432,
}
}
}
impl TerminalSize {
fn into_pty_size(self) -> PtySize {
PtySize {
cols: self.cols,
rows: self.rows,
pixel_width: self.pixel_width,
pixel_height: self.pixel_height,
}
}
}
pub struct PtySession {
master: Box<dyn MasterPty + Send>,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
killer: Arc<Mutex<Box<dyn ChildKiller + Send + Sync>>>,
}
impl PtySession {
pub fn spawn(
size: TerminalSize,
) -> Result<(Self, mpsc::UnboundedReceiver<Vec<u8>>), String> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(size.into_pty_size())
.map_err(|error| format!("failed to open pty: {error}"))?;
let shell = std::env::var("SHELL")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "bash".to_owned());
let command = CommandBuilder::new(shell);
let child = pair
.slave
.spawn_command(command)
.map_err(|error| format!("failed to spawn shell: {error}"))?;
drop(pair.slave);
let mut reader = pair
.master
.try_clone_reader()
.map_err(|error| format!("failed to clone pty reader: {error}"))?;
let writer = pair
.master
.take_writer()
.map_err(|error| format!("failed to take pty writer: {error}"))?;
let killer = child.clone_killer();
let (tx, rx) = mpsc::unbounded_channel();
let child_handle = Arc::new(Mutex::new(child));
let child_for_thread = child_handle.clone();
std::thread::spawn(move || {
let mut buffer = [0_u8; 4096];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
if tx.send(buffer[..read].to_vec()).is_err() {
break;
}
}
Err(error) => {
let _ = tx.send(
format!("\r\n[terminal read error] {error}\r\n").into_bytes(),
);
break;
}
}
}
let exit_code = child_for_thread
.lock()
.ok()
.and_then(|mut child| child.wait().ok())
.map(|status| status.exit_code() as i32);
let payload = serde_json::to_vec(&app::terminal::protocol::ServerTerminalMessage::Exit {
code: exit_code,
})
.unwrap_or_else(|_| {
br#"{"type":"exit","code":null}"#.to_vec()
});
let _ = tx.send(payload);
});
Ok((
Self {
master: pair.master,
writer: Arc::new(Mutex::new(writer)),
killer: Arc::new(Mutex::new(killer)),
},
rx,
))
}
pub fn resize(&self, size: TerminalSize) -> Result<(), String> {
self.master
.resize(size.into_pty_size())
.map_err(|error| format!("failed to resize pty: {error}"))
}
pub fn write(&self, data: &str) -> Result<(), String> {
let mut writer = self
.writer
.lock()
.map_err(|_| "failed to lock pty writer".to_owned())?;
writer
.write_all(data.as_bytes())
.map_err(|error| format!("failed to write to pty: {error}"))?;
writer
.flush()
.map_err(|error| format!("failed to flush pty writer: {error}"))
}
pub fn kill(&self) {
if let Ok(mut killer) = self.killer.lock() {
let _ = killer.kill();
}
}
}

103
server/src/terminal/ws.rs Normal file
View File

@@ -0,0 +1,103 @@
use app::terminal::protocol::{ClientTerminalMessage, ServerTerminalMessage};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::Response;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use super::pty_session::{PtySession, TerminalSize};
pub async fn terminal_ws_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(handle_terminal_socket)
}
async fn handle_terminal_socket(socket: WebSocket) {
let Ok((session, mut output_rx)) = PtySession::spawn(TerminalSize::default()) else {
return;
};
let (mut sender, mut receiver) = socket.split();
let (server_tx, mut server_rx) = mpsc::unbounded_channel::<ServerTerminalMessage>();
let output_forwarder = {
let server_tx = server_tx.clone();
tokio::spawn(async move {
while let Some(chunk) = output_rx.recv().await {
if let Ok(message) = serde_json::from_slice::<ServerTerminalMessage>(&chunk) {
if server_tx.send(message).is_err() {
break;
}
continue;
}
let payload = String::from_utf8_lossy(&chunk).into_owned();
if server_tx
.send(ServerTerminalMessage::Output { data: payload })
.is_err()
{
break;
}
}
})
};
let send_task = tokio::spawn(async move {
while let Some(message) = server_rx.recv().await {
let Ok(payload) = serde_json::to_string(&message) else {
continue;
};
if sender.send(Message::Text(payload.into())).await.is_err() {
break;
}
}
});
while let Some(Ok(message)) = receiver.next().await {
match message {
Message::Text(text) => {
let Ok(client_message) =
serde_json::from_str::<ClientTerminalMessage>(text.as_str())
else {
let _ = server_tx.send(ServerTerminalMessage::Error {
message: "invalid terminal message".to_owned(),
});
continue;
};
match client_message {
ClientTerminalMessage::Input { data } => {
if let Err(error) = session.write(&data) {
let _ = server_tx
.send(ServerTerminalMessage::Error { message: error });
}
}
ClientTerminalMessage::Resize {
cols,
rows,
pixel_width,
pixel_height,
} => {
if let Err(error) = session.resize(TerminalSize {
cols,
rows,
pixel_width,
pixel_height,
}) {
let _ = server_tx
.send(ServerTerminalMessage::Error { message: error });
}
}
ClientTerminalMessage::Ping => {
let _ = server_tx.send(ServerTerminalMessage::Pong);
}
}
}
Message::Close(_) => break,
_ => {}
}
}
session.kill();
output_forwarder.abort();
send_task.abort();
}

1296
style/tailwind.css Normal file

File diff suppressed because it is too large Load Diff