feat: add rust browser terminal prototype
This commit is contained in:
45
app/Cargo.toml
Normal file
45
app/Cargo.toml
Normal 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
3
app/build.rs
Normal 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
1
app/src/api/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod project;
|
||||
490
app/src/api/project.rs
Normal file
490
app/src/api/project.rs
Normal 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
39
app/src/app.rs
Normal 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
346
app/src/client_icon.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
16
app/src/components/layout/breadcrumb.rs
Normal file
16
app/src/components/layout/breadcrumb.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
140
app/src/components/layout/header.rs
Normal file
140
app/src/components/layout/header.rs
Normal 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()
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
7
app/src/components/layout/mod.rs
Normal file
7
app/src/components/layout/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod breadcrumb;
|
||||
mod header;
|
||||
mod shell;
|
||||
mod sidebar;
|
||||
|
||||
pub use breadcrumb::Breadcrumb;
|
||||
pub use shell::AdminShell;
|
||||
25
app/src/components/layout/shell.rs
Normal file
25
app/src/components/layout/shell.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
184
app/src/components/layout/sidebar.rs
Normal file
184
app/src/components/layout/sidebar.rs
Normal 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(¤t_path.get(), item)
|
||||
});
|
||||
let child_active = Memo::new(move |_| {
|
||||
is_child_route_active(¤t_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(¤t_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(¤t_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>
|
||||
}
|
||||
}
|
||||
1
app/src/components/mod.rs
Normal file
1
app/src/components/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod layout;
|
||||
19
app/src/lib.rs
Normal file
19
app/src/lib.rs
Normal 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
142
app/src/menu_items.rs
Normal 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
2
app/src/pages/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod project_list;
|
||||
pub mod terminal;
|
||||
1175
app/src/pages/project_list.rs
Normal file
1175
app/src/pages/project_list.rs
Normal file
File diff suppressed because it is too large
Load Diff
16
app/src/pages/terminal.rs
Normal file
16
app/src/pages/terminal.rs
Normal 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
36
app/src/shell.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
662
app/src/terminal/component.rs
Normal file
662
app/src/terminal/component.rs
Normal 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
312
app/src/terminal/core.rs
Normal 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
6
app/src/terminal/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod component;
|
||||
pub mod core;
|
||||
pub mod protocol;
|
||||
pub mod scrollback;
|
||||
|
||||
pub use component::TerminalPanel;
|
||||
31
app/src/terminal/protocol.rs
Normal file
31
app/src/terminal/protocol.rs
Normal 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,
|
||||
}
|
||||
126
app/src/terminal/scrollback.rs
Normal file
126
app/src/terminal/scrollback.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user