2026-04-17 23:36:24 +08:00
|
|
|
use crate::app::AppState;
|
|
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
use crate::routes::command_support::{
|
|
|
|
|
build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex,
|
|
|
|
|
read_optional_non_empty,
|
|
|
|
|
};
|
|
|
|
|
use crate::routes::query_support::{
|
2026-04-18 05:43:49 +08:00
|
|
|
resolve_effective_workspace_id,
|
|
|
|
|
};
|
|
|
|
|
use crate::routes::snapshot_support::{
|
|
|
|
|
load_projection_snapshot, ProjectionSnapshotSpec,
|
2026-04-17 23:36:24 +08:00
|
|
|
};
|
|
|
|
|
use axum::extract::{Extension, Query, State};
|
|
|
|
|
use axum::http::{header, HeaderValue, StatusCode};
|
|
|
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
|
|
|
use axum::Json;
|
|
|
|
|
use bridge_runtime::RuntimeCommandEnvelopeWire;
|
|
|
|
|
use core_protocol::KernelProjectionKind;
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
|
|
|
|
static TREE_DOCUMENT_COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeShellQuery {
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
pub root_node_id: Option<String>,
|
|
|
|
|
pub depth: Option<u32>,
|
|
|
|
|
pub active_document_id: Option<String>,
|
|
|
|
|
pub actor_id: Option<String>,
|
|
|
|
|
pub channel: Option<String>,
|
|
|
|
|
pub host: Option<String>,
|
|
|
|
|
pub mode: Option<String>,
|
|
|
|
|
pub allow_root_pick: Option<String>,
|
|
|
|
|
pub exclude_ids: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeCommandEnvelope {
|
|
|
|
|
pub action: String,
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
pub document_id: Option<String>,
|
|
|
|
|
pub parent_id: Option<String>,
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
pub access_scope: Option<String>,
|
|
|
|
|
pub content: Option<Value>,
|
|
|
|
|
pub sort_order: Option<i64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum TreeCommandRequest {
|
|
|
|
|
Create {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
parent_id: Option<String>,
|
|
|
|
|
title: String,
|
|
|
|
|
access_scope: Option<String>,
|
|
|
|
|
content: Option<Value>,
|
|
|
|
|
},
|
|
|
|
|
Rename {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
title: String,
|
|
|
|
|
},
|
|
|
|
|
Move {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
parent_id: Option<String>,
|
|
|
|
|
sort_order: i64,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_html(input: &str) -> String {
|
|
|
|
|
input
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
.replace('\'', "'")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_inline_json(input: &str) -> String {
|
|
|
|
|
input
|
|
|
|
|
.replace('&', "\\u0026")
|
|
|
|
|
.replace('<', "\\u003c")
|
|
|
|
|
.replace('>', "\\u003e")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_title(value: Option<String>) -> String {
|
|
|
|
|
let trimmed = value.unwrap_or_default().trim().to_string();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
"无标题".into()
|
|
|
|
|
} else {
|
|
|
|
|
trimmed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_channel(value: Option<String>) -> String {
|
|
|
|
|
let trimmed = value.unwrap_or_default().trim().to_string();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
"mnote-tree-shell-v1".into()
|
|
|
|
|
} else {
|
|
|
|
|
trimmed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_tree_mode(value: Option<&str>) -> &'static str {
|
|
|
|
|
match value.unwrap_or_default().trim() {
|
|
|
|
|
"picker" => "picker",
|
|
|
|
|
"filetree" => "filetree",
|
|
|
|
|
_ => "page",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
|
|
|
|
|
match value.unwrap_or_default().trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
"1" | "true" | "yes" | "on" => true,
|
|
|
|
|
"0" | "false" | "no" | "off" => false,
|
|
|
|
|
_ => default,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_exclude_ids(value: Option<&str>) -> Vec<String> {
|
|
|
|
|
value
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.split(',')
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|item| !item.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn override_actor_context(context: &RequestContext, actor_id: Option<&str>) -> RequestContext {
|
|
|
|
|
let mut next = context.clone();
|
|
|
|
|
let actor_id = actor_id
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned);
|
|
|
|
|
if let Some(actor_id) = actor_id {
|
|
|
|
|
next.auth.actor_id = actor_id;
|
|
|
|
|
next.auth.actor_type = "user".into();
|
|
|
|
|
}
|
|
|
|
|
next
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn generate_tree_document_id() -> String {
|
|
|
|
|
let millis = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_millis();
|
|
|
|
|
let counter = TREE_DOCUMENT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
format!("tree_{millis}_{counter}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_tree_shell_html(
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
root_node_id: Option<&str>,
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
channel: &str,
|
|
|
|
|
host: Option<&str>,
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
projection: &Value,
|
|
|
|
|
mode: &str,
|
|
|
|
|
allow_root_pick: bool,
|
|
|
|
|
exclude_ids: &[String],
|
|
|
|
|
dataset: &Value,
|
|
|
|
|
) -> String {
|
|
|
|
|
let app_state = json!({
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
"rootNodeId": root_node_id,
|
|
|
|
|
"activeDocumentId": active_document_id,
|
|
|
|
|
"actorId": context.auth.actor_id,
|
|
|
|
|
"channel": channel,
|
|
|
|
|
"host": host,
|
|
|
|
|
"mode": mode,
|
|
|
|
|
"allowRootPick": allow_root_pick,
|
|
|
|
|
"excludeIds": exclude_ids,
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
"commandPath": "/api/tree/commands",
|
|
|
|
|
"items": projection.get("items").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
"mediaAssets": dataset.get("media_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
"mindmapAssets": dataset.get("mindmap_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
"tableAssets": dataset.get("table_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
"mindmapAssetChildren": dataset.get("mindmap_asset_children").cloned().unwrap_or_else(|| json!({})),
|
|
|
|
|
});
|
|
|
|
|
let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());
|
|
|
|
|
let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into());
|
|
|
|
|
let root_label = root_node_id.unwrap_or("workspace_root");
|
|
|
|
|
let active_label = active_document_id.unwrap_or("未指定");
|
|
|
|
|
let template = r##"<!doctype html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
<title>mnote Tree Shell</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
color-scheme: light;
|
|
|
|
|
--bg: #f3efe7;
|
|
|
|
|
--panel: rgba(255, 252, 247, 0.96);
|
|
|
|
|
--panel-strong: #ffffff;
|
|
|
|
|
--ink: #18222f;
|
|
|
|
|
--muted: #64748b;
|
|
|
|
|
--line: rgba(148, 163, 184, 0.28);
|
|
|
|
|
--accent: #0f6c84;
|
|
|
|
|
--accent-soft: rgba(15, 108, 132, 0.12);
|
|
|
|
|
--danger: #b42318;
|
|
|
|
|
}
|
|
|
|
|
* {
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-family: "Noto Sans CJK SC", "Source Han Sans SC", sans-serif;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
background:
|
|
|
|
|
radial-gradient(circle at top left, rgba(15, 108, 132, 0.18), transparent 32%),
|
|
|
|
|
linear-gradient(180deg, #fbf8f3 0%, var(--bg) 100%);
|
|
|
|
|
}
|
|
|
|
|
main {
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-rows: auto auto minmax(0, 1fr);
|
|
|
|
|
gap: 16px;
|
|
|
|
|
padding: 18px;
|
|
|
|
|
}
|
|
|
|
|
.hero,
|
|
|
|
|
.status-card,
|
|
|
|
|
.tree-card {
|
|
|
|
|
background: var(--panel);
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
border-radius: 22px;
|
|
|
|
|
box-shadow: 0 18px 40px rgba(24, 34, 47, 0.08);
|
|
|
|
|
}
|
|
|
|
|
.hero {
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
.eyebrow {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
letter-spacing: 0.12em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
h1 {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: clamp(24px, 4vw, 34px);
|
|
|
|
|
line-height: 1.05;
|
|
|
|
|
}
|
|
|
|
|
.summary {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
line-height: 1.55;
|
|
|
|
|
}
|
|
|
|
|
.meta-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.meta-pill {
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.78);
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
|
|
|
}
|
|
|
|
|
.meta-pill strong {
|
|
|
|
|
display: block;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.status-card {
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.toolbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button,
|
|
|
|
|
.tree-action,
|
|
|
|
|
.tree-toggle,
|
|
|
|
|
.tree-link {
|
|
|
|
|
font: inherit;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 10px 14px;
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: #fff;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button:hover {
|
|
|
|
|
filter: brightness(0.97);
|
|
|
|
|
}
|
|
|
|
|
.toolbar button:disabled {
|
|
|
|
|
opacity: 0.55;
|
|
|
|
|
cursor: progress;
|
|
|
|
|
}
|
|
|
|
|
.status-text {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.status-text[data-tone="error"] {
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
}
|
|
|
|
|
.tree-card {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
padding: 16px 18px 12px;
|
|
|
|
|
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.tree-card-title {
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-meta {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.tree-scroll {
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
padding: 14px 12px 18px;
|
|
|
|
|
}
|
|
|
|
|
.tree-root,
|
|
|
|
|
.tree-children {
|
|
|
|
|
list-style: none;
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-children {
|
|
|
|
|
margin-left: 22px;
|
|
|
|
|
padding-left: 14px;
|
|
|
|
|
border-left: 1px dashed rgba(15, 108, 132, 0.2);
|
|
|
|
|
}
|
|
|
|
|
.tree-node + .tree-node {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
background: var(--panel-strong);
|
|
|
|
|
padding: 8px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-focused="true"] {
|
|
|
|
|
border-color: rgba(15, 108, 132, 0.48);
|
|
|
|
|
box-shadow: inset 0 0 0 1px rgba(15, 108, 132, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-active="true"] {
|
|
|
|
|
border-color: rgba(15, 108, 132, 0.32);
|
|
|
|
|
box-shadow: inset 0 0 0 1px rgba(15, 108, 132, 0.12);
|
|
|
|
|
background: linear-gradient(180deg, rgba(15, 108, 132, 0.08), rgba(255, 255, 255, 0.96));
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-selected="true"] {
|
|
|
|
|
background: rgba(15, 108, 132, 0.1);
|
|
|
|
|
border-color: rgba(15, 108, 132, 0.24);
|
|
|
|
|
}
|
|
|
|
|
.tree-toggle,
|
|
|
|
|
.tree-action {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
}
|
|
|
|
|
.tree-toggle:hover,
|
|
|
|
|
.tree-action:hover {
|
|
|
|
|
background: rgba(15, 108, 132, 0.08);
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
.tree-toggle[disabled],
|
|
|
|
|
.tree-action[disabled] {
|
|
|
|
|
opacity: 0.4;
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
}
|
|
|
|
|
.tree-spacer {
|
|
|
|
|
width: 34px;
|
|
|
|
|
height: 30px;
|
|
|
|
|
}
|
|
|
|
|
.tree-link {
|
|
|
|
|
border: 0;
|
|
|
|
|
background: transparent;
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-link-title {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.tree-link-meta {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
min-width: 22px;
|
|
|
|
|
height: 22px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: rgba(15, 108, 132, 0.08);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
.tree-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
transition: opacity 140ms ease;
|
|
|
|
|
}
|
|
|
|
|
.tree-row:hover .tree-actions,
|
|
|
|
|
.tree-row[data-focused="true"] .tree-actions,
|
|
|
|
|
.tree-row:focus-within .tree-actions {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
}
|
|
|
|
|
.tree-link:focus-visible,
|
|
|
|
|
.tree-toggle:focus-visible,
|
|
|
|
|
.tree-action:focus-visible,
|
|
|
|
|
.tree-row:focus-visible {
|
|
|
|
|
outline: 2px solid rgba(15, 108, 132, 0.35);
|
|
|
|
|
outline-offset: 2px;
|
|
|
|
|
}
|
|
|
|
|
.tree-empty {
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
border: 1px dashed rgba(148, 163, 184, 0.36);
|
|
|
|
|
padding: 24px 18px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
background: rgba(255, 255, 255, 0.64);
|
|
|
|
|
}
|
|
|
|
|
.tree-card-footer {
|
|
|
|
|
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
padding: 12px 16px 16px;
|
|
|
|
|
}
|
|
|
|
|
details {
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
|
|
|
|
background: rgba(255, 255, 255, 0.72);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
summary {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
list-style: none;
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
summary::-webkit-details-marker {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
pre {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0 14px 14px;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
line-height: 1.55;
|
|
|
|
|
color: #243447;
|
|
|
|
|
}
|
|
|
|
|
/* 说明:这里用覆盖式样式把原本的开发态卡片壳压成真正的侧栏树视图,
|
|
|
|
|
避免继续保留 hero/status/debug 大面板。 */
|
|
|
|
|
body {
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
}
|
|
|
|
|
main {
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
|
|
|
gap: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
.hero,
|
|
|
|
|
.status-card,
|
|
|
|
|
.tree-card-footer,
|
|
|
|
|
details,
|
|
|
|
|
summary,
|
|
|
|
|
pre {
|
|
|
|
|
display: none !important;
|
|
|
|
|
}
|
|
|
|
|
.tree-card {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-header {
|
|
|
|
|
padding: 8px 10px 6px;
|
|
|
|
|
border-bottom: 1px solid rgba(31, 35, 40, 0.08);
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
min-height: 40px;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-title {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-meta {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
.toolbar {
|
|
|
|
|
gap: 6px;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button {
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button:hover {
|
|
|
|
|
background: rgba(15, 23, 42, 0.05);
|
|
|
|
|
filter: none;
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
}
|
|
|
|
|
.tree-scroll {
|
|
|
|
|
padding: 6px 4px 12px;
|
|
|
|
|
}
|
|
|
|
|
.tree-children {
|
|
|
|
|
margin-left: 18px;
|
|
|
|
|
padding-left: 10px;
|
|
|
|
|
border-left: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-node + .tree-node {
|
|
|
|
|
margin-top: 1px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row {
|
|
|
|
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
min-height: 26px;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
border: 0;
|
|
|
|
|
background: transparent;
|
|
|
|
|
padding: 1px 4px;
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
.tree-row:hover {
|
|
|
|
|
background: rgba(15, 23, 42, 0.04);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-focused="true"] {
|
|
|
|
|
border-color: transparent;
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
background: rgba(37, 99, 235, 0.08);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-active="true"],
|
|
|
|
|
.tree-row[data-selected="true"] {
|
|
|
|
|
border-color: transparent;
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
background: rgba(37, 99, 235, 0.12);
|
|
|
|
|
}
|
|
|
|
|
.tree-toggle,
|
|
|
|
|
.tree-action {
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
width: 20px;
|
|
|
|
|
height: 20px;
|
|
|
|
|
padding: 0;
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
.tree-spacer {
|
|
|
|
|
width: 16px;
|
|
|
|
|
height: 16px;
|
|
|
|
|
}
|
|
|
|
|
.tree-link {
|
|
|
|
|
padding: 2px 4px;
|
|
|
|
|
gap: 0;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-link-title {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
line-height: 1.35;
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
}
|
|
|
|
|
.tree-link-meta {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge {
|
|
|
|
|
min-width: 16px;
|
|
|
|
|
width: 16px;
|
|
|
|
|
height: 16px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge svg,
|
|
|
|
|
.tree-action svg {
|
|
|
|
|
width: 14px;
|
|
|
|
|
height: 14px;
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="page"] {
|
|
|
|
|
color: #2563eb;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="index"] {
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="mindmap"] {
|
|
|
|
|
color: #7c3aed;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="table"] {
|
|
|
|
|
color: #b45309;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="file"] {
|
|
|
|
|
color: #64748b;
|
|
|
|
|
}
|
|
|
|
|
.tree-actions {
|
|
|
|
|
gap: 2px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
flex-wrap: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.tree-action {
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
}
|
|
|
|
|
.tree-action:hover {
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
background: rgba(15, 23, 42, 0.06);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-shell-mode="page"] .tree-link {
|
|
|
|
|
padding-right: 42px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-shell-mode="page"] .tree-actions {
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 6px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-shell-mode="filetree"] .tree-link {
|
|
|
|
|
padding-right: 30px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-shell-mode="filetree"] .tree-actions {
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 6px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
}
|
|
|
|
|
.tree-empty {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
padding: 16px 10px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
background: transparent;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 860px) {
|
|
|
|
|
main {
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-row {
|
|
|
|
|
grid-template-columns: auto auto minmax(0, 1fr);
|
|
|
|
|
}
|
|
|
|
|
.tree-actions {
|
|
|
|
|
position: static;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
padding-left: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main>
|
|
|
|
|
<section class="tree-card">
|
|
|
|
|
<div class="tree-card-header">
|
|
|
|
|
<div class="tree-card-title" id="tree-shell-title">Sidebar / Page Tree</div>
|
|
|
|
|
<div class="tree-card-meta" id="tree-shell-summary">workspace=__WORKSPACE_ID__ · root=__ROOT_LABEL__ · active=__ACTIVE_LABEL__</div>
|
|
|
|
|
<div class="toolbar" id="tree-shell-toolbar">
|
|
|
|
|
<button id="tree-create-root" data-testid="tree-create-root" type="button">新建</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tree-scroll" id="tree-shell-app"></div>
|
|
|
|
|
</section>
|
|
|
|
|
<div class="status-text" id="tree-shell-status" data-tone="normal" hidden>Tree shell 已加载</div>
|
|
|
|
|
<div class="status-text" id="tree-shell-last-action" data-tone="normal" hidden></div>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<script id="tree-shell-state" type="application/json">__APP_STATE__</script>
|
|
|
|
|
<script>
|
|
|
|
|
(() => {
|
|
|
|
|
const stateElement = document.getElementById("tree-shell-state");
|
|
|
|
|
const appElement = document.getElementById("tree-shell-app");
|
|
|
|
|
const statusElement = document.getElementById("tree-shell-status");
|
|
|
|
|
const lastActionElement = document.getElementById("tree-shell-last-action");
|
|
|
|
|
const createRootButton = document.getElementById("tree-create-root");
|
|
|
|
|
if (!stateElement || !appElement || !statusElement || !lastActionElement || !createRootButton) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parseState = () => {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(stateElement.textContent || "{}");
|
|
|
|
|
} catch {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const state = parseState();
|
|
|
|
|
const channel =
|
|
|
|
|
typeof state.channel === "string" && state.channel.trim()
|
|
|
|
|
? state.channel.trim()
|
|
|
|
|
: "mnote-tree-shell-v1";
|
|
|
|
|
const workspaceId =
|
|
|
|
|
typeof state.workspaceId === "string" && state.workspaceId.trim()
|
|
|
|
|
? state.workspaceId.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const actorId =
|
|
|
|
|
typeof state.actorId === "string" && state.actorId.trim()
|
|
|
|
|
? state.actorId.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const activeDocumentId =
|
|
|
|
|
typeof state.activeDocumentId === "string" && state.activeDocumentId.trim()
|
|
|
|
|
? state.activeDocumentId.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const mode = (() => {
|
|
|
|
|
const rawMode =
|
|
|
|
|
typeof state.mode === "string" ? state.mode.trim() : "";
|
|
|
|
|
if (rawMode === "picker") return "picker";
|
|
|
|
|
if (rawMode === "filetree") return "filetree";
|
|
|
|
|
return "page";
|
|
|
|
|
})();
|
|
|
|
|
const allowRootPick = state.allowRootPick === true;
|
|
|
|
|
const excludedIds = new Set(
|
|
|
|
|
Array.isArray(state.excludeIds)
|
|
|
|
|
? state.excludeIds
|
|
|
|
|
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
: [],
|
|
|
|
|
);
|
|
|
|
|
const commandPath =
|
|
|
|
|
typeof state.commandPath === "string" && state.commandPath.trim()
|
|
|
|
|
? state.commandPath.trim()
|
|
|
|
|
: "/api/tree/commands";
|
|
|
|
|
const mediaAssets = Array.isArray(state.mediaAssets) ? state.mediaAssets : [];
|
|
|
|
|
const mindmapAssets = Array.isArray(state.mindmapAssets) ? state.mindmapAssets : [];
|
|
|
|
|
const tableAssets = Array.isArray(state.tableAssets) ? state.tableAssets : [];
|
|
|
|
|
const mindmapAssetChildren =
|
|
|
|
|
state.mindmapAssetChildren && typeof state.mindmapAssetChildren === "object"
|
|
|
|
|
? state.mindmapAssetChildren
|
|
|
|
|
: {};
|
|
|
|
|
const titleElement = document.getElementById("tree-shell-title");
|
|
|
|
|
const summaryElement = document.getElementById("tree-shell-summary");
|
|
|
|
|
const toolbarElement = document.getElementById("tree-shell-toolbar");
|
|
|
|
|
const targetOrigin = (() => {
|
|
|
|
|
try {
|
|
|
|
|
if (!document.referrer) return "*";
|
|
|
|
|
return new URL(document.referrer).origin || "*";
|
|
|
|
|
} catch {
|
|
|
|
|
return "*";
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
const normalizeText = (value, fallback = "") => {
|
|
|
|
|
if (typeof value !== "string") return fallback;
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed || fallback;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeParent = (value) => {
|
|
|
|
|
const normalized = normalizeText(value);
|
|
|
|
|
return normalized || null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeNumber = (value, fallback = Number.MAX_SAFE_INTEGER) => {
|
|
|
|
|
return Number.isFinite(value) ? Number(value) : fallback;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizedItems = Array.isArray(state.items)
|
|
|
|
|
? state.items
|
|
|
|
|
.map((item) => ({
|
|
|
|
|
nodeId: normalizeText(item?.nodeId),
|
|
|
|
|
parentNodeId: normalizeParent(item?.parentNodeId),
|
|
|
|
|
title: normalizeText(item?.title, "无标题"),
|
|
|
|
|
childCount: normalizeNumber(item?.childCount, 0),
|
|
|
|
|
position: normalizeNumber(item?.position),
|
|
|
|
|
expandedByDefault: item?.expandedByDefault !== false,
|
|
|
|
|
}))
|
|
|
|
|
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const itemById = new Map(normalizedItems.map((item) => [item.nodeId, item]));
|
|
|
|
|
const childrenByParentId = new Map();
|
|
|
|
|
const roots = [];
|
|
|
|
|
const assetsByDocId = new Map();
|
|
|
|
|
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
|
|
|
|
|
const documentId = normalizeText(asset?.document_id);
|
|
|
|
|
const assetId = normalizeText(asset?.id);
|
|
|
|
|
if (!documentId || !assetId) return;
|
|
|
|
|
const bucket = assetsByDocId.get(documentId) || [];
|
|
|
|
|
bucket.push({
|
|
|
|
|
id: assetId,
|
|
|
|
|
documentId,
|
|
|
|
|
assetType: normalizeText(asset?.asset_type, "file"),
|
|
|
|
|
fileName: normalizeText(asset?.file_name, "附件"),
|
|
|
|
|
storagePath: normalizeText(asset?.storage_path),
|
|
|
|
|
});
|
|
|
|
|
assetsByDocId.set(documentId, bucket);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const compareItems = (left, right) => {
|
|
|
|
|
const byPosition = left.position - right.position;
|
|
|
|
|
if (byPosition !== 0) return byPosition;
|
|
|
|
|
return left.title.localeCompare(right.title, "zh-CN");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
normalizedItems.forEach((item) => {
|
|
|
|
|
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
|
|
|
|
|
if (!parentId) {
|
|
|
|
|
roots.push(item);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const bucket = childrenByParentId.get(parentId) || [];
|
|
|
|
|
bucket.push(item);
|
|
|
|
|
childrenByParentId.set(parentId, bucket);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
roots.sort(compareItems);
|
|
|
|
|
childrenByParentId.forEach((bucket) => bucket.sort(compareItems));
|
|
|
|
|
|
|
|
|
|
const expanded = new Set(
|
|
|
|
|
normalizedItems
|
|
|
|
|
.filter((item) => item.childCount > 0 && item.expandedByDefault)
|
|
|
|
|
.map((item) => item.nodeId),
|
|
|
|
|
);
|
|
|
|
|
let focusedNodeId =
|
|
|
|
|
activeDocumentId && itemById.has(activeDocumentId)
|
|
|
|
|
? activeDocumentId
|
|
|
|
|
: roots[0]?.nodeId || "";
|
|
|
|
|
let selectedFileTreeRowIds = new Set(activeDocumentId ? [`doc:${activeDocumentId}`, `index:${activeDocumentId}`] : []);
|
|
|
|
|
|
|
|
|
|
let activeCursor = itemById.get(activeDocumentId) || null;
|
|
|
|
|
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
|
|
|
|
expanded.add(activeCursor.parentNodeId);
|
|
|
|
|
activeCursor = itemById.get(activeCursor.parentNodeId) || null;
|
|
|
|
|
}
|
|
|
|
|
assetsByDocId.forEach((_, documentId) => {
|
|
|
|
|
if (itemById.has(documentId)) {
|
|
|
|
|
expanded.add(documentId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (titleElement) {
|
|
|
|
|
titleElement.textContent =
|
|
|
|
|
mode === "picker"
|
|
|
|
|
? "页面选择"
|
|
|
|
|
: mode === "filetree"
|
|
|
|
|
? "资源管理器"
|
|
|
|
|
: "页面树";
|
|
|
|
|
}
|
|
|
|
|
if (summaryElement) {
|
|
|
|
|
summaryElement.textContent =
|
|
|
|
|
mode === "picker"
|
|
|
|
|
? "这个页面复用统一 projection 协议,以轻量选择器模式承载 move / embed picker。当前阶段只负责树浏览与目标选择,不承接命令写链。"
|
|
|
|
|
: mode === "filetree"
|
|
|
|
|
? "这个页面以文件树模式消费 Rust 侧 projection,并承载页面、index 与附件浏览。当前阶段仍是过渡验证壳,但 filetree 协议与渲染分支必须保持闭环。"
|
|
|
|
|
: "这个页面直接消费 Rust 侧 projection,并通过统一 command route 回写树操作。当前阶段先交付最小可交互壳,用于主 Sidebar 页面树切流与真实网页验证。";
|
|
|
|
|
}
|
|
|
|
|
if (toolbarElement && mode === "picker") {
|
|
|
|
|
toolbarElement.style.display = "none";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let busy = false;
|
|
|
|
|
|
|
|
|
|
const setBusy = (nextBusy) => {
|
|
|
|
|
busy = nextBusy;
|
|
|
|
|
createRootButton.disabled = nextBusy;
|
|
|
|
|
appElement.querySelectorAll("button").forEach((button) => {
|
|
|
|
|
button.disabled = nextBusy;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setStatus = (message, tone = "normal") => {
|
|
|
|
|
statusElement.textContent = message;
|
|
|
|
|
statusElement.dataset.tone = tone;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setLastAction = (message, tone = "normal") => {
|
|
|
|
|
lastActionElement.textContent = message;
|
|
|
|
|
lastActionElement.dataset.tone = tone;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const focusRowElement = (nodeId) => {
|
|
|
|
|
if (!nodeId) return;
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
const row = appElement.querySelector(`.tree-row[data-node-id="${nodeId}"]`);
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
row.focus({ preventScroll: true });
|
|
|
|
|
row.scrollIntoView({ block: "nearest" });
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const postToHost = (type, extra = {}) => {
|
|
|
|
|
if (window.parent === window) return;
|
|
|
|
|
const payload = Object.assign({ channel, type }, extra);
|
|
|
|
|
window.parent.postMessage(payload, targetOrigin);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const scheduleRefresh = () => {
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
window.location.reload();
|
|
|
|
|
}, 80);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readErrorMessage = async (response) => {
|
|
|
|
|
const text = await response.text();
|
|
|
|
|
try {
|
|
|
|
|
const payload = text ? JSON.parse(text) : null;
|
|
|
|
|
const fromMessage =
|
|
|
|
|
payload && typeof payload.message === "string" ? payload.message :
|
|
|
|
|
payload && typeof payload.error === "string" ? payload.error :
|
|
|
|
|
payload && payload.result && typeof payload.result.message === "string" ? payload.result.message :
|
|
|
|
|
"";
|
|
|
|
|
if (fromMessage) return fromMessage;
|
|
|
|
|
} catch {
|
|
|
|
|
// 忽略 JSON 解析失败,继续返回文本片段
|
|
|
|
|
}
|
|
|
|
|
return text ? text.slice(0, 180) : "树命令执行失败";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ICONS = {
|
|
|
|
|
add: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<path d="M8 3.2v9.6M3.2 8h9.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
more: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
|
|
|
<circle cx="4" cy="8" r="1.2"/>
|
|
|
|
|
<circle cx="8" cy="8" r="1.2"/>
|
|
|
|
|
<circle cx="12" cy="8" r="1.2"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
page: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
|
|
|
|
<path d="M9.2 2.8v2.8H12" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
index: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
|
|
|
|
<path d="M6 7h4M6 9h4M6 11h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
mindmap: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<circle cx="8" cy="8" r="1.6" fill="currentColor"/>
|
|
|
|
|
<circle cx="4" cy="4.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
|
|
|
|
|
<circle cx="12" cy="4.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
|
|
|
|
|
<circle cx="12" cy="11.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
|
|
|
|
|
<path d="M6.8 7 4.9 5.4M9.2 7l1.9-1.6M9.1 9l2 1.6" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
table: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<rect x="3.2" y="3.2" width="9.6" height="9.6" rx="1.4" stroke="currentColor" stroke-width="1.2"/>
|
|
|
|
|
<path d="M3.8 6.6h8.4M6.6 3.8v8.4M9.4 3.8v8.4" stroke="currentColor" stroke-width="1.1"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
file: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
|
|
|
|
<path d="M6 8.2h4M6 10.4h2.8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sendCommand = async (payload) => {
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setStatus("正在提交树命令…");
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(commandPath, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-mnote-workspace-id": workspaceId,
|
|
|
|
|
"x-mnote-source-channel": "mnote_web_tree_shell",
|
|
|
|
|
"x-mnote-source-client": "mnote-web",
|
|
|
|
|
...(actorId
|
|
|
|
|
? {
|
|
|
|
|
"x-mnote-actor-id": actorId,
|
|
|
|
|
"x-mnote-actor-type": "user",
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(await readErrorMessage(response));
|
|
|
|
|
}
|
|
|
|
|
const data = await response.json().catch(() => null);
|
|
|
|
|
if (!data || data.ok !== true || !data.result) {
|
|
|
|
|
throw new Error("tree command 返回了无效响应");
|
|
|
|
|
}
|
|
|
|
|
return data.result;
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const promptTitle = (message, defaultValue) => {
|
|
|
|
|
const value = window.prompt(message, defaultValue || "无标题");
|
|
|
|
|
if (value === null) return null;
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed || "无标题";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getSiblings = (parentId) => {
|
|
|
|
|
if (!parentId) return roots.slice();
|
|
|
|
|
return (childrenByParentId.get(parentId) || []).slice();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleExpand = (nodeId) => {
|
|
|
|
|
if (expanded.has(nodeId)) expanded.delete(nodeId);
|
|
|
|
|
else expanded.add(nodeId);
|
|
|
|
|
renderTree();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getVisiblePageItems = () => {
|
|
|
|
|
const visible = [];
|
|
|
|
|
const walk = (entries) => {
|
|
|
|
|
entries.forEach((item) => {
|
|
|
|
|
visible.push(item);
|
|
|
|
|
if (item.childCount > 0 && expanded.has(item.nodeId)) {
|
|
|
|
|
walk(getSiblings(item.nodeId));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
walk(roots);
|
|
|
|
|
return visible;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const focusNode = (nodeId) => {
|
|
|
|
|
if (!nodeId || !itemById.has(nodeId)) return;
|
|
|
|
|
focusedNodeId = nodeId;
|
|
|
|
|
renderTree();
|
|
|
|
|
focusRowElement(nodeId);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openFileTreeContextMenu = ({
|
|
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId,
|
|
|
|
|
rowKind,
|
|
|
|
|
clientX,
|
|
|
|
|
clientY,
|
|
|
|
|
}) => {
|
|
|
|
|
setLastAction(
|
|
|
|
|
assetId
|
|
|
|
|
? `已打开资源 ${assetId} 的更多操作`
|
|
|
|
|
: `已打开文件树节点 ${documentId || rowId || "unknown"} 的更多操作`,
|
|
|
|
|
);
|
|
|
|
|
postToHost("tree.filetree.context-menu", {
|
|
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId,
|
|
|
|
|
rowKind,
|
|
|
|
|
payload: {
|
|
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId,
|
|
|
|
|
rowKind,
|
|
|
|
|
x: clientX,
|
|
|
|
|
y: clientY,
|
|
|
|
|
},
|
|
|
|
|
x: clientX,
|
|
|
|
|
y: clientY,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openContextMenu = (nodeId, clientX, clientY) => {
|
|
|
|
|
if (!nodeId || mode !== "page") return;
|
|
|
|
|
setLastAction(`已打开页面 ${nodeId} 的上下文菜单`);
|
|
|
|
|
postToHost("tree.page.context-menu", {
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
target: { documentId: nodeId },
|
|
|
|
|
payload: { documentId: nodeId, x: clientX, y: clientY },
|
|
|
|
|
x: clientX,
|
|
|
|
|
y: clientY,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getElementCenter = (element) => {
|
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
|
return {
|
|
|
|
|
x: rect.left + rect.width / 2,
|
|
|
|
|
y: rect.top + rect.height / 2,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRowKeyDown = (event, item) => {
|
|
|
|
|
if (mode !== "page") return;
|
|
|
|
|
const visible = getVisiblePageItems();
|
|
|
|
|
const currentIndex = visible.findIndex((entry) => entry.nodeId === item.nodeId);
|
|
|
|
|
if (event.key === "ArrowDown") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const next = visible[currentIndex + 1];
|
|
|
|
|
if (next) focusNode(next.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "ArrowUp") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const previous = visible[currentIndex - 1];
|
|
|
|
|
if (previous) focusNode(previous.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "ArrowRight") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (item.childCount > 0 && !expanded.has(item.nodeId)) {
|
|
|
|
|
expanded.add(item.nodeId);
|
|
|
|
|
renderTree();
|
|
|
|
|
focusRowElement(item.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const firstChild = getSiblings(item.nodeId)[0];
|
|
|
|
|
if (firstChild) {
|
|
|
|
|
focusNode(firstChild.nodeId);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "ArrowLeft") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (item.childCount > 0 && expanded.has(item.nodeId)) {
|
|
|
|
|
expanded.delete(item.nodeId);
|
|
|
|
|
renderTree();
|
|
|
|
|
focusRowElement(item.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (item.parentNodeId && itemById.has(item.parentNodeId)) {
|
|
|
|
|
focusNode(item.parentNodeId);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
handleNavigate(item.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "F2") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void handleRename(item.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
event.key === "ContextMenu" ||
|
|
|
|
|
(event.shiftKey && event.key === "F10")
|
|
|
|
|
) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
|
|
|
openContextMenu(
|
|
|
|
|
item.nodeId,
|
|
|
|
|
rect.left + Math.min(rect.width - 12, 28),
|
|
|
|
|
rect.top + Math.min(rect.height - 12, 18),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleNavigate = (nodeId) => {
|
|
|
|
|
if (!nodeId) return;
|
|
|
|
|
if (mode === "picker") {
|
|
|
|
|
setLastAction(`已选择页面 ${nodeId}`);
|
|
|
|
|
postToHost("tree.pick", {
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
target: { documentId: nodeId },
|
|
|
|
|
payload: { documentId: nodeId },
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLastAction(`准备打开页面 ${nodeId}`);
|
|
|
|
|
postToHost("tree.navigate", {
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
target: { documentId: nodeId },
|
|
|
|
|
payload: { documentId: nodeId },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCreate = async (parentId) => {
|
|
|
|
|
const title = "无标题";
|
|
|
|
|
try {
|
|
|
|
|
const result = await sendCommand({
|
|
|
|
|
action: "create",
|
|
|
|
|
workspaceId,
|
|
|
|
|
parentId,
|
|
|
|
|
title,
|
|
|
|
|
accessScope: "private",
|
|
|
|
|
content: [],
|
|
|
|
|
});
|
|
|
|
|
const documentId =
|
|
|
|
|
typeof result.documentId === "string" && result.documentId.trim()
|
|
|
|
|
? result.documentId.trim()
|
|
|
|
|
: "";
|
|
|
|
|
setStatus("创建页面成功");
|
|
|
|
|
setLastAction(parentId ? "已创建无标题子页面" : "已创建无标题页面");
|
|
|
|
|
postToHost("tree.node.created", {
|
|
|
|
|
documentId,
|
|
|
|
|
target: { documentId },
|
|
|
|
|
payload: { documentId },
|
|
|
|
|
});
|
|
|
|
|
if (documentId) {
|
|
|
|
|
postToHost("tree.navigate", {
|
|
|
|
|
documentId,
|
|
|
|
|
target: { documentId },
|
|
|
|
|
payload: { documentId },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
scheduleRefresh();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : "创建页面失败";
|
|
|
|
|
setStatus(message, "error");
|
|
|
|
|
setLastAction("创建页面失败", "error");
|
|
|
|
|
window.alert(message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRename = async (nodeId) => {
|
|
|
|
|
const item = itemById.get(nodeId);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
const title = promptTitle("输入新的页面标题", item.title);
|
|
|
|
|
if (title === null) return;
|
|
|
|
|
try {
|
|
|
|
|
const result = await sendCommand({
|
|
|
|
|
action: "rename",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
title,
|
|
|
|
|
});
|
|
|
|
|
const documentId =
|
|
|
|
|
typeof result.documentId === "string" && result.documentId.trim()
|
|
|
|
|
? result.documentId.trim()
|
|
|
|
|
: nodeId;
|
|
|
|
|
setStatus("重命名成功");
|
|
|
|
|
setLastAction(`页面已重命名为 ${title}`);
|
|
|
|
|
postToHost("tree.node.renamed", {
|
|
|
|
|
documentId,
|
|
|
|
|
target: { documentId },
|
|
|
|
|
payload: { documentId },
|
|
|
|
|
});
|
|
|
|
|
scheduleRefresh();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : "重命名失败";
|
|
|
|
|
setStatus(message, "error");
|
|
|
|
|
setLastAction("重命名失败", "error");
|
|
|
|
|
window.alert(message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMove = async (nodeId, delta) => {
|
|
|
|
|
const item = itemById.get(nodeId);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
const siblings = getSiblings(item.parentNodeId);
|
|
|
|
|
const currentIndex = siblings.findIndex((entry) => entry.nodeId === nodeId);
|
|
|
|
|
if (currentIndex === -1) return;
|
|
|
|
|
const nextIndex = currentIndex + delta;
|
|
|
|
|
if (nextIndex < 0 || nextIndex >= siblings.length) return;
|
|
|
|
|
try {
|
|
|
|
|
const result = await sendCommand({
|
|
|
|
|
action: "move",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
parentId: item.parentNodeId,
|
|
|
|
|
sortOrder: nextIndex,
|
|
|
|
|
});
|
|
|
|
|
const documentId =
|
|
|
|
|
typeof result.documentId === "string" && result.documentId.trim()
|
|
|
|
|
? result.documentId.trim()
|
|
|
|
|
: nodeId;
|
|
|
|
|
setStatus("移动页面成功");
|
|
|
|
|
setLastAction(delta < 0 ? "页面已上移" : "页面已下移");
|
|
|
|
|
postToHost("tree.subtree.moved", {
|
|
|
|
|
documentId,
|
|
|
|
|
target: { documentId },
|
|
|
|
|
payload: { documentId },
|
|
|
|
|
});
|
|
|
|
|
scheduleRefresh();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : "移动页面失败";
|
|
|
|
|
setStatus(message, "error");
|
|
|
|
|
setLastAction("移动页面失败", "error");
|
|
|
|
|
window.alert(message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createKindBadge = (kind) => {
|
|
|
|
|
const badge = document.createElement("span");
|
|
|
|
|
badge.className = "tree-kind-badge";
|
|
|
|
|
badge.dataset.kind = kind;
|
|
|
|
|
badge.innerHTML =
|
|
|
|
|
kind === "mindmap"
|
|
|
|
|
? ICONS.mindmap
|
|
|
|
|
: kind === "table"
|
|
|
|
|
? ICONS.table
|
|
|
|
|
: kind === "index"
|
|
|
|
|
? ICONS.index
|
|
|
|
|
: kind === "page"
|
|
|
|
|
? ICONS.page
|
|
|
|
|
: ICONS.file;
|
|
|
|
|
return badge;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createActionButton = (icon, testId, title, onClick, disabled) => {
|
|
|
|
|
const button = document.createElement("button");
|
|
|
|
|
button.type = "button";
|
|
|
|
|
button.className = "tree-action";
|
|
|
|
|
button.dataset.testid = testId;
|
|
|
|
|
button.title = title;
|
|
|
|
|
button.setAttribute("aria-label", title);
|
|
|
|
|
button.innerHTML = icon;
|
|
|
|
|
button.disabled = disabled || busy;
|
|
|
|
|
button.addEventListener("click", (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
onClick(button);
|
|
|
|
|
});
|
|
|
|
|
return button;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderNode = (item) => {
|
|
|
|
|
const hasChildren = item.childCount > 0;
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className = "tree-row";
|
|
|
|
|
row.dataset.active = String(item.nodeId === activeDocumentId);
|
|
|
|
|
row.dataset.focused = String(item.nodeId === focusedNodeId);
|
|
|
|
|
row.dataset.nodeId = item.nodeId;
|
|
|
|
|
row.dataset.shellMode = mode;
|
|
|
|
|
row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1;
|
|
|
|
|
row.setAttribute("role", "treeitem");
|
|
|
|
|
row.setAttribute("aria-level", String(item.depth + 1));
|
|
|
|
|
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
|
|
|
|
|
row.addEventListener("focus", () => {
|
|
|
|
|
if (focusedNodeId !== item.nodeId) {
|
|
|
|
|
focusedNodeId = item.nodeId;
|
|
|
|
|
renderTree();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("keydown", (event) => handleRowKeyDown(event, item));
|
|
|
|
|
row.addEventListener("contextmenu", (event) => {
|
|
|
|
|
if (mode !== "page") return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
openContextMenu(item.nodeId, event.clientX, event.clientY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (hasChildren) {
|
|
|
|
|
const toggleButton = document.createElement("button");
|
|
|
|
|
toggleButton.type = "button";
|
|
|
|
|
toggleButton.className = "tree-toggle";
|
|
|
|
|
toggleButton.setAttribute("data-testid", "tree-node-toggle");
|
|
|
|
|
toggleButton.setAttribute(
|
|
|
|
|
"aria-label",
|
|
|
|
|
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
|
|
|
|
|
);
|
|
|
|
|
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
|
|
|
|
toggleButton.addEventListener("click", (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
toggleExpand(item.nodeId);
|
|
|
|
|
});
|
|
|
|
|
row.appendChild(toggleButton);
|
|
|
|
|
} else {
|
|
|
|
|
const spacer = document.createElement("div");
|
|
|
|
|
spacer.className = "tree-spacer";
|
|
|
|
|
row.appendChild(spacer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
row.appendChild(createKindBadge("page"));
|
|
|
|
|
|
|
|
|
|
const linkButton = document.createElement("button");
|
|
|
|
|
linkButton.type = "button";
|
|
|
|
|
linkButton.className = "tree-link";
|
|
|
|
|
linkButton.setAttribute("data-testid", "tree-node-open");
|
|
|
|
|
linkButton.setAttribute("aria-label", `打开 ${item.title}`);
|
|
|
|
|
linkButton.addEventListener("click", () => handleNavigate(item.nodeId));
|
|
|
|
|
|
|
|
|
|
const titleElement = document.createElement("span");
|
|
|
|
|
titleElement.className = "tree-link-title";
|
|
|
|
|
titleElement.textContent = item.title;
|
|
|
|
|
linkButton.appendChild(titleElement);
|
|
|
|
|
|
|
|
|
|
const metaElement = document.createElement("span");
|
|
|
|
|
metaElement.className = "tree-link-meta";
|
|
|
|
|
metaElement.textContent = `${item.nodeId} · ${item.childCount} 个子页面`;
|
|
|
|
|
linkButton.appendChild(metaElement);
|
|
|
|
|
row.appendChild(linkButton);
|
|
|
|
|
|
|
|
|
|
const actions = document.createElement("div");
|
|
|
|
|
actions.className = "tree-actions";
|
|
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.add,
|
|
|
|
|
"tree-action-create",
|
|
|
|
|
`在 ${item.title} 下新建子页面`,
|
|
|
|
|
() => handleCreate(item.nodeId),
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
if (mode === "page") {
|
|
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.more,
|
|
|
|
|
"tree-action-menu",
|
|
|
|
|
`打开 ${item.title} 的更多操作`,
|
|
|
|
|
(button) => {
|
|
|
|
|
const center = getElementCenter(button);
|
|
|
|
|
openContextMenu(
|
|
|
|
|
item.nodeId,
|
|
|
|
|
center.x,
|
|
|
|
|
center.y,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
row.appendChild(actions);
|
|
|
|
|
|
|
|
|
|
const nodeElement = document.createElement("li");
|
|
|
|
|
nodeElement.className = "tree-node";
|
|
|
|
|
nodeElement.dataset.nodeId = item.nodeId;
|
|
|
|
|
nodeElement.appendChild(row);
|
|
|
|
|
|
|
|
|
|
if (hasChildren && expanded.has(item.nodeId)) {
|
|
|
|
|
const children = getSiblings(item.nodeId);
|
|
|
|
|
if (children.length > 0) {
|
|
|
|
|
const childrenList = document.createElement("ul");
|
|
|
|
|
childrenList.className = "tree-children";
|
|
|
|
|
children.forEach((child) => {
|
|
|
|
|
childrenList.appendChild(renderNode(child));
|
|
|
|
|
});
|
|
|
|
|
nodeElement.appendChild(childrenList);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nodeElement;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderFileTree = () => {
|
|
|
|
|
appElement.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
const fileRoot = document.createElement("div");
|
|
|
|
|
fileRoot.className = "tree-root";
|
|
|
|
|
fileRoot.setAttribute("role", "tree");
|
|
|
|
|
|
|
|
|
|
const appendAssetRow = (container, asset, depth) => {
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className = "tree-row";
|
|
|
|
|
row.style.marginLeft = `${depth * 22}px`;
|
|
|
|
|
row.setAttribute("data-testid", "filetree-asset-row");
|
|
|
|
|
row.dataset.assetId = asset.id;
|
|
|
|
|
row.dataset.rowId = `asset:${asset.id}`;
|
|
|
|
|
row.dataset.rowKind = "asset";
|
|
|
|
|
row.dataset.shellMode = "filetree";
|
|
|
|
|
row.dataset.selected = String(selectedFileTreeRowIds.has(`asset:${asset.id}`));
|
|
|
|
|
row.tabIndex = 0;
|
|
|
|
|
row.setAttribute("role", "treeitem");
|
|
|
|
|
row.setAttribute("aria-level", String(depth + 1));
|
|
|
|
|
row.addEventListener("click", () => {
|
|
|
|
|
selectedFileTreeRowIds = new Set([`asset:${asset.id}`]);
|
|
|
|
|
renderTree();
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("contextmenu", (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
selectedFileTreeRowIds = new Set([`asset:${asset.id}`]);
|
|
|
|
|
renderTree();
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId: asset.documentId,
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
rowId: `asset:${asset.id}`,
|
|
|
|
|
rowKind: "asset",
|
|
|
|
|
clientX: event.clientX,
|
|
|
|
|
clientY: event.clientY,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const spacer = document.createElement("div");
|
|
|
|
|
spacer.className = "tree-spacer";
|
|
|
|
|
row.appendChild(spacer);
|
|
|
|
|
row.appendChild(
|
|
|
|
|
createKindBadge(
|
|
|
|
|
asset.assetType === "mindmap"
|
|
|
|
|
? "mindmap"
|
|
|
|
|
: asset.assetType === "luckysheet"
|
|
|
|
|
? "table"
|
|
|
|
|
: "file",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const button = document.createElement("button");
|
|
|
|
|
button.type = "button";
|
|
|
|
|
button.className = "tree-link";
|
|
|
|
|
button.setAttribute("data-testid", "filetree-asset-open");
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
setLastAction(`准备打开附件 ${asset.id}`);
|
|
|
|
|
postToHost("tree.asset.open", {
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
documentId: asset.documentId,
|
|
|
|
|
target: { documentId: asset.documentId },
|
|
|
|
|
payload: { documentId: asset.documentId, assetId: asset.id },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
const title = document.createElement("span");
|
|
|
|
|
title.className = "tree-link-title";
|
|
|
|
|
title.textContent = asset.fileName;
|
|
|
|
|
const meta = document.createElement("span");
|
|
|
|
|
meta.className = "tree-link-meta";
|
|
|
|
|
meta.textContent = `${asset.assetType} · ${asset.id}`;
|
|
|
|
|
button.appendChild(title);
|
|
|
|
|
button.appendChild(meta);
|
|
|
|
|
row.appendChild(button);
|
|
|
|
|
const actions = document.createElement("div");
|
|
|
|
|
actions.className = "tree-actions";
|
|
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.more,
|
|
|
|
|
"filetree-action-menu",
|
|
|
|
|
`打开 ${asset.fileName} 的更多操作`,
|
|
|
|
|
(button) => {
|
|
|
|
|
const center = getElementCenter(button);
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId: asset.documentId,
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
rowId: `asset:${asset.id}`,
|
|
|
|
|
rowKind: "asset",
|
|
|
|
|
clientX: center.x,
|
|
|
|
|
clientY: center.y,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
row.appendChild(actions);
|
|
|
|
|
container.appendChild(row);
|
|
|
|
|
|
|
|
|
|
const childIds = Array.isArray(mindmapAssetChildren[asset.id]) ? mindmapAssetChildren[asset.id] : [];
|
|
|
|
|
if (childIds.length === 0) return;
|
|
|
|
|
childIds.forEach((childId) => {
|
|
|
|
|
const child = mediaAssets.find((entry) => normalizeText(entry?.id) === childId);
|
|
|
|
|
if (!child) return;
|
|
|
|
|
appendAssetRow(
|
|
|
|
|
container,
|
|
|
|
|
{
|
|
|
|
|
id: normalizeText(child?.id),
|
|
|
|
|
documentId: normalizeText(child?.document_id),
|
|
|
|
|
assetType: normalizeText(child?.asset_type, "file"),
|
|
|
|
|
fileName: normalizeText(child?.file_name, "附件"),
|
|
|
|
|
storagePath: normalizeText(child?.storage_path),
|
|
|
|
|
},
|
|
|
|
|
depth + 1,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const appendDoc = (item, depth) => {
|
|
|
|
|
const wrapper = document.createElement("div");
|
|
|
|
|
wrapper.className = "tree-node";
|
|
|
|
|
wrapper.dataset.nodeId = item.nodeId;
|
|
|
|
|
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className = "tree-row";
|
|
|
|
|
row.style.marginLeft = `${depth * 22}px`;
|
|
|
|
|
row.dataset.active = String(item.nodeId === activeDocumentId);
|
|
|
|
|
row.setAttribute("data-testid", "filetree-doc-row");
|
|
|
|
|
row.dataset.rowId = `doc:${item.nodeId}`;
|
|
|
|
|
row.dataset.rowKind = "doc";
|
|
|
|
|
row.dataset.shellMode = "filetree";
|
|
|
|
|
row.dataset.selected = String(selectedFileTreeRowIds.has(`doc:${item.nodeId}`));
|
|
|
|
|
row.tabIndex = 0;
|
|
|
|
|
row.setAttribute("role", "treeitem");
|
|
|
|
|
row.setAttribute("aria-level", String(depth + 1));
|
|
|
|
|
const children = getSiblings(item.nodeId);
|
|
|
|
|
const assets = assetsByDocId.get(item.nodeId) || [];
|
|
|
|
|
const hasBranches = children.length > 0 || assets.length > 0;
|
|
|
|
|
row.setAttribute("aria-expanded", hasBranches ? String(expanded.has(item.nodeId)) : "false");
|
|
|
|
|
row.addEventListener("click", () => {
|
|
|
|
|
selectedFileTreeRowIds = new Set([`doc:${item.nodeId}`]);
|
|
|
|
|
renderTree();
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("contextmenu", (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
selectedFileTreeRowIds = new Set([`doc:${item.nodeId}`]);
|
|
|
|
|
renderTree();
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId: item.nodeId,
|
|
|
|
|
rowId: `doc:${item.nodeId}`,
|
|
|
|
|
rowKind: "doc",
|
|
|
|
|
clientX: event.clientX,
|
|
|
|
|
clientY: event.clientY,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (hasBranches) {
|
|
|
|
|
const toggleButton = document.createElement("button");
|
|
|
|
|
toggleButton.type = "button";
|
|
|
|
|
toggleButton.className = "tree-toggle";
|
|
|
|
|
toggleButton.setAttribute("aria-label", `${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`);
|
|
|
|
|
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
|
|
|
|
toggleButton.addEventListener("click", (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
toggleExpand(item.nodeId);
|
|
|
|
|
});
|
|
|
|
|
row.appendChild(toggleButton);
|
|
|
|
|
} else {
|
|
|
|
|
const spacer = document.createElement("div");
|
|
|
|
|
spacer.className = "tree-spacer";
|
|
|
|
|
row.appendChild(spacer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
row.appendChild(createKindBadge("page"));
|
|
|
|
|
const linkButton = document.createElement("button");
|
|
|
|
|
linkButton.type = "button";
|
|
|
|
|
linkButton.className = "tree-link";
|
|
|
|
|
linkButton.setAttribute("data-testid", "filetree-doc-open");
|
|
|
|
|
linkButton.addEventListener("click", () => handleNavigate(item.nodeId));
|
|
|
|
|
const title = document.createElement("span");
|
|
|
|
|
title.className = "tree-link-title";
|
|
|
|
|
title.textContent = item.title;
|
|
|
|
|
const meta = document.createElement("span");
|
|
|
|
|
meta.className = "tree-link-meta";
|
|
|
|
|
meta.textContent = `${item.nodeId} · 页面`;
|
|
|
|
|
linkButton.appendChild(title);
|
|
|
|
|
linkButton.appendChild(meta);
|
|
|
|
|
row.appendChild(linkButton);
|
|
|
|
|
const actions = document.createElement("div");
|
|
|
|
|
actions.className = "tree-actions";
|
|
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.more,
|
|
|
|
|
"filetree-action-menu",
|
|
|
|
|
`打开 ${item.title} 的更多操作`,
|
|
|
|
|
(button) => {
|
|
|
|
|
const center = getElementCenter(button);
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId: item.nodeId,
|
|
|
|
|
rowId: `doc:${item.nodeId}`,
|
|
|
|
|
rowKind: "doc",
|
|
|
|
|
clientX: center.x,
|
|
|
|
|
clientY: center.y,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
row.appendChild(actions);
|
|
|
|
|
wrapper.appendChild(row);
|
|
|
|
|
|
|
|
|
|
const indexRow = document.createElement("div");
|
|
|
|
|
indexRow.className = "tree-row";
|
|
|
|
|
indexRow.style.marginLeft = `${(depth + 1) * 22}px`;
|
|
|
|
|
indexRow.setAttribute("data-testid", "filetree-index-row");
|
|
|
|
|
indexRow.dataset.rowId = `index:${item.nodeId}`;
|
|
|
|
|
indexRow.dataset.rowKind = "index";
|
|
|
|
|
indexRow.dataset.shellMode = "filetree";
|
|
|
|
|
indexRow.dataset.selected = String(selectedFileTreeRowIds.has(`index:${item.nodeId}`));
|
|
|
|
|
indexRow.tabIndex = 0;
|
|
|
|
|
indexRow.setAttribute("role", "treeitem");
|
|
|
|
|
indexRow.setAttribute("aria-level", String(depth + 2));
|
|
|
|
|
indexRow.addEventListener("click", () => {
|
|
|
|
|
selectedFileTreeRowIds = new Set([`index:${item.nodeId}`]);
|
|
|
|
|
renderTree();
|
|
|
|
|
});
|
|
|
|
|
indexRow.addEventListener("contextmenu", (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
selectedFileTreeRowIds = new Set([`index:${item.nodeId}`]);
|
|
|
|
|
renderTree();
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId: item.nodeId,
|
|
|
|
|
rowId: `index:${item.nodeId}`,
|
|
|
|
|
rowKind: "index",
|
|
|
|
|
clientX: event.clientX,
|
|
|
|
|
clientY: event.clientY,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
const indexSpacer = document.createElement("div");
|
|
|
|
|
indexSpacer.className = "tree-spacer";
|
|
|
|
|
indexRow.appendChild(indexSpacer);
|
|
|
|
|
indexRow.appendChild(createKindBadge("index"));
|
|
|
|
|
const indexLink = document.createElement("button");
|
|
|
|
|
indexLink.type = "button";
|
|
|
|
|
indexLink.className = "tree-link";
|
|
|
|
|
indexLink.addEventListener("click", () => handleNavigate(item.nodeId));
|
|
|
|
|
const indexTitle = document.createElement("span");
|
|
|
|
|
indexTitle.className = "tree-link-title";
|
|
|
|
|
indexTitle.textContent = "index.md";
|
|
|
|
|
const indexMeta = document.createElement("span");
|
|
|
|
|
indexMeta.className = "tree-link-meta";
|
|
|
|
|
indexMeta.textContent = "页面正文";
|
|
|
|
|
indexLink.appendChild(indexTitle);
|
|
|
|
|
indexLink.appendChild(indexMeta);
|
|
|
|
|
indexRow.appendChild(indexLink);
|
|
|
|
|
const indexActions = document.createElement("div");
|
|
|
|
|
indexActions.className = "tree-actions";
|
|
|
|
|
indexActions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.more,
|
|
|
|
|
"filetree-action-menu",
|
|
|
|
|
`打开 ${item.title} 正文的更多操作`,
|
|
|
|
|
(button) => {
|
|
|
|
|
const center = getElementCenter(button);
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId: item.nodeId,
|
|
|
|
|
rowId: `index:${item.nodeId}`,
|
|
|
|
|
rowKind: "index",
|
|
|
|
|
clientX: center.x,
|
|
|
|
|
clientY: center.y,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
indexRow.appendChild(indexActions);
|
|
|
|
|
if (expanded.has(item.nodeId) || !hasBranches) {
|
|
|
|
|
wrapper.appendChild(indexRow);
|
|
|
|
|
assets.forEach((asset) => appendAssetRow(wrapper, asset, depth + 1));
|
|
|
|
|
children.forEach((child) => appendDoc(child, depth + 1));
|
|
|
|
|
}
|
|
|
|
|
fileRoot.appendChild(wrapper);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (roots.length === 0) {
|
|
|
|
|
const empty = document.createElement("div");
|
|
|
|
|
empty.className = "tree-empty";
|
|
|
|
|
empty.textContent = "当前 file tree 没有可渲染的页面。";
|
|
|
|
|
appElement.appendChild(empty);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
roots.forEach((item) => appendDoc(item, 0));
|
|
|
|
|
appElement.appendChild(fileRoot);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderTree = () => {
|
|
|
|
|
if (mode === "filetree") {
|
|
|
|
|
renderFileTree();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
appElement.innerHTML = "";
|
|
|
|
|
if (mode === "picker" && allowRootPick) {
|
|
|
|
|
const rootButton = document.createElement("button");
|
|
|
|
|
rootButton.type = "button";
|
|
|
|
|
rootButton.className = "tree-row";
|
|
|
|
|
rootButton.setAttribute("data-testid", "tree-picker-root");
|
|
|
|
|
rootButton.addEventListener("click", () => {
|
|
|
|
|
setLastAction("已选择根目录");
|
|
|
|
|
postToHost("tree.pick.root", {
|
|
|
|
|
documentId: null,
|
|
|
|
|
target: { documentId: null },
|
|
|
|
|
payload: { documentId: null },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const spacer = document.createElement("div");
|
|
|
|
|
spacer.className = "tree-spacer";
|
|
|
|
|
rootButton.appendChild(spacer);
|
|
|
|
|
|
|
|
|
|
const label = document.createElement("div");
|
|
|
|
|
label.className = "tree-link";
|
|
|
|
|
const title = document.createElement("span");
|
|
|
|
|
title.className = "tree-link-title";
|
|
|
|
|
title.textContent = "根目录";
|
|
|
|
|
const meta = document.createElement("span");
|
|
|
|
|
meta.className = "tree-link-meta";
|
|
|
|
|
meta.textContent = "选择工作空间根目录";
|
|
|
|
|
label.appendChild(title);
|
|
|
|
|
label.appendChild(meta);
|
|
|
|
|
rootButton.appendChild(label);
|
|
|
|
|
appElement.appendChild(rootButton);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (roots.length === 0) {
|
|
|
|
|
const empty = document.createElement("div");
|
|
|
|
|
empty.className = "tree-empty";
|
|
|
|
|
empty.textContent =
|
|
|
|
|
mode === "picker"
|
|
|
|
|
? "当前 projection 没有可选择的页面。"
|
|
|
|
|
: "当前 projection 没有可渲染的页面,点击上方按钮先创建一个根页面。";
|
|
|
|
|
appElement.appendChild(empty);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const list = document.createElement("ul");
|
|
|
|
|
list.className = "tree-root";
|
|
|
|
|
list.setAttribute("role", "tree");
|
|
|
|
|
roots.forEach((item) => {
|
|
|
|
|
list.appendChild(renderNode(item));
|
|
|
|
|
});
|
|
|
|
|
appElement.appendChild(list);
|
|
|
|
|
if (mode === "page" && focusedNodeId) {
|
|
|
|
|
focusRowElement(focusedNodeId);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
createRootButton.addEventListener("click", () => {
|
|
|
|
|
if (mode === "picker") return;
|
|
|
|
|
void handleCreate(null);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const emitReady = () => {
|
|
|
|
|
postToHost("tree.ready", {
|
|
|
|
|
workspaceId,
|
|
|
|
|
payload: { workspaceId },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
renderTree();
|
|
|
|
|
setStatus(
|
|
|
|
|
mode === "picker"
|
|
|
|
|
? "Tree picker 已就绪,可以展开目录并选择目标页面。"
|
|
|
|
|
: mode === "filetree"
|
|
|
|
|
? "File tree shell 已就绪,可以打开页面与附件。"
|
|
|
|
|
: "Tree shell 已就绪,可以进行展开、创建、重命名和排序操作。",
|
|
|
|
|
);
|
|
|
|
|
setLastAction("已向宿主发送 ready 消息。");
|
|
|
|
|
emitReady();
|
|
|
|
|
window.setTimeout(emitReady, 300);
|
|
|
|
|
window.setTimeout(emitReady, 1200);
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"##;
|
|
|
|
|
|
|
|
|
|
template
|
|
|
|
|
.replace("__WORKSPACE_ID__", &escape_html(workspace_id))
|
|
|
|
|
.replace("__ROOT_LABEL__", &escape_html(root_label))
|
|
|
|
|
.replace("__ACTIVE_LABEL__", &escape_html(active_label))
|
|
|
|
|
.replace("__PROJECTION_JSON__", &escape_html(&projection_json))
|
|
|
|
|
.replace("__APP_STATE__", &escape_inline_json(&app_state_json))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
|
|
|
|
(
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
Json(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
"result": result,
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn tree_shell(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<TreeShellQuery>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
let effective_context = override_actor_context(&context, query.actor_id.as_deref());
|
|
|
|
|
let effective_workspace_id =
|
|
|
|
|
resolve_effective_workspace_id(&effective_context, query.workspace_id.as_deref(), true)?
|
|
|
|
|
.expect("workspace_required 已确保存在");
|
|
|
|
|
let mode = normalize_tree_mode(query.mode.as_deref());
|
|
|
|
|
let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false);
|
|
|
|
|
let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref());
|
2026-04-18 05:43:49 +08:00
|
|
|
let snapshot = load_projection_snapshot(
|
2026-04-17 23:36:24 +08:00
|
|
|
state.config(),
|
|
|
|
|
&effective_context,
|
2026-04-18 05:43:49 +08:00
|
|
|
&ProjectionSnapshotSpec {
|
|
|
|
|
workspace_id: &effective_workspace_id,
|
|
|
|
|
root_node_id: query.root_node_id.as_deref(),
|
|
|
|
|
depth: query.depth,
|
|
|
|
|
projection: if mode == "filetree" {
|
|
|
|
|
KernelProjectionKind::FileTree
|
|
|
|
|
} else {
|
|
|
|
|
KernelProjectionKind::PageTree
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
let html = build_tree_shell_html(
|
|
|
|
|
&effective_workspace_id,
|
|
|
|
|
query.root_node_id.as_deref(),
|
|
|
|
|
query.active_document_id.as_deref(),
|
|
|
|
|
&normalize_channel(query.channel),
|
|
|
|
|
query.host.as_deref(),
|
|
|
|
|
&effective_context,
|
2026-04-18 05:43:49 +08:00
|
|
|
&snapshot.projection,
|
2026-04-17 23:36:24 +08:00
|
|
|
mode,
|
|
|
|
|
allow_root_pick,
|
|
|
|
|
&exclude_ids,
|
2026-04-18 05:43:49 +08:00
|
|
|
&snapshot.dataset,
|
2026-04-17 23:36:24 +08:00
|
|
|
);
|
|
|
|
|
let mut response = Html(html).into_response();
|
|
|
|
|
response
|
|
|
|
|
.headers_mut()
|
|
|
|
|
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn create_command_wire(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
request: TreeCommandRequest,
|
|
|
|
|
) -> Result<RuntimeCommandEnvelopeWire, WebError> {
|
|
|
|
|
match request {
|
|
|
|
|
TreeCommandRequest::Create {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
title,
|
|
|
|
|
access_scope,
|
|
|
|
|
content,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
let parent_id = read_optional_non_empty(parent_id);
|
|
|
|
|
let access_scope = read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "documents.create".into(),
|
|
|
|
|
command_id: format!("tree_create_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
|
|
|
|
source: bridge_runtime::RuntimeSourceWire {
|
|
|
|
|
channel: context.source.channel.clone(),
|
|
|
|
|
client: context.source.client.clone(),
|
|
|
|
|
},
|
|
|
|
|
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
|
|
|
|
payload: json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
"parentId": parent_id,
|
|
|
|
|
"title": title,
|
|
|
|
|
"accessScope": access_scope,
|
|
|
|
|
"content": content.unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
}),
|
|
|
|
|
reason: Some("tree-shell create".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
TreeCommandRequest::Rename {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
title,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "documents.title.update".into(),
|
|
|
|
|
command_id: format!("tree_rename_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
|
|
|
|
source: bridge_runtime::RuntimeSourceWire {
|
|
|
|
|
channel: context.source.channel.clone(),
|
|
|
|
|
client: context.source.client.clone(),
|
|
|
|
|
},
|
|
|
|
|
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
|
|
|
|
payload: json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"title": title,
|
|
|
|
|
}),
|
|
|
|
|
reason: Some("tree-shell rename".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
TreeCommandRequest::Move {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
sort_order,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
let sort_order = ensure_sort_order(sort_order, context)?;
|
|
|
|
|
let parent_id = read_optional_non_empty(parent_id);
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "documents.move".into(),
|
|
|
|
|
command_id: format!("tree_move_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
|
|
|
|
source: bridge_runtime::RuntimeSourceWire {
|
|
|
|
|
channel: context.source.channel.clone(),
|
|
|
|
|
client: context.source.client.clone(),
|
|
|
|
|
},
|
|
|
|
|
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
|
|
|
|
|
payload: json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"parentId": parent_id,
|
|
|
|
|
"sortOrder": sort_order,
|
|
|
|
|
}),
|
|
|
|
|
reason: Some("tree-shell move".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn tree_command(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
body: String,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let raw_request: TreeCommandEnvelope = serde_json::from_str(&body).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"tree_command_invalid_json",
|
|
|
|
|
format!("tree command 请求体非法: {error}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header("x-error-phase", "tree_command_decode")
|
|
|
|
|
})?;
|
|
|
|
|
let request = match raw_request.action.trim() {
|
|
|
|
|
"create" => TreeCommandRequest::Create {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: read_optional_non_empty(raw_request.document_id)
|
|
|
|
|
.unwrap_or_else(generate_tree_document_id),
|
|
|
|
|
parent_id: raw_request.parent_id,
|
|
|
|
|
title: normalize_title(raw_request.title),
|
|
|
|
|
access_scope: raw_request.access_scope,
|
|
|
|
|
content: raw_request.content,
|
|
|
|
|
},
|
|
|
|
|
"rename" => TreeCommandRequest::Rename {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: raw_request.document_id.unwrap_or_default(),
|
|
|
|
|
title: normalize_title(raw_request.title),
|
|
|
|
|
},
|
|
|
|
|
"move" => TreeCommandRequest::Move {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: raw_request.document_id.unwrap_or_default(),
|
|
|
|
|
parent_id: raw_request.parent_id,
|
|
|
|
|
sort_order: raw_request.sort_order.unwrap_or(-1),
|
|
|
|
|
},
|
|
|
|
|
other => {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"tree_command_validation",
|
|
|
|
|
format!("不支持的 tree action: {other}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header("x-error-phase", "tree_command_validate"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let requested_workspace_id = match &request {
|
|
|
|
|
TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(),
|
|
|
|
|
TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(),
|
|
|
|
|
TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(),
|
|
|
|
|
};
|
|
|
|
|
let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) =
|
|
|
|
|
match &request {
|
|
|
|
|
TreeCommandRequest::Create {
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
title,
|
|
|
|
|
..
|
|
|
|
|
} => (
|
|
|
|
|
"create",
|
|
|
|
|
document_id.clone(),
|
|
|
|
|
parent_id.clone(),
|
|
|
|
|
Some(title.clone()),
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
TreeCommandRequest::Rename {
|
|
|
|
|
document_id,
|
|
|
|
|
title,
|
|
|
|
|
..
|
|
|
|
|
} => (
|
|
|
|
|
"rename",
|
|
|
|
|
document_id.clone(),
|
|
|
|
|
None,
|
|
|
|
|
Some(title.clone()),
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
TreeCommandRequest::Move {
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
sort_order,
|
|
|
|
|
..
|
|
|
|
|
} => (
|
|
|
|
|
"move",
|
|
|
|
|
document_id.clone(),
|
|
|
|
|
parent_id.clone(),
|
|
|
|
|
None,
|
|
|
|
|
Some(*sort_order),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
let effective_workspace_id =
|
|
|
|
|
resolve_effective_workspace_id(&context, requested_workspace_id, true)?
|
|
|
|
|
.expect("workspace_required 已确保存在");
|
|
|
|
|
let command_wire = create_command_wire(&context, &effective_workspace_id, request)?;
|
|
|
|
|
let execution = execute_runtime_command_via_convex(
|
|
|
|
|
state.config(),
|
|
|
|
|
&context,
|
|
|
|
|
Some(&effective_workspace_id),
|
|
|
|
|
command_wire,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
let response_document_id = execution
|
|
|
|
|
.get("id")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.unwrap_or(requested_document_id);
|
|
|
|
|
|
|
|
|
|
Ok(json_response(
|
|
|
|
|
&context,
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": effective_workspace_id,
|
|
|
|
|
"action": action,
|
|
|
|
|
"documentId": response_document_id,
|
|
|
|
|
"parentId": requested_parent_id,
|
|
|
|
|
"title": requested_title,
|
|
|
|
|
"sortOrder": requested_sort_order,
|
|
|
|
|
"updatedAt": execution.get("updated_at").cloned().unwrap_or(Value::Null),
|
|
|
|
|
"execution": execution,
|
|
|
|
|
}),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use crate::app::{build_app, AppConfig, AppState};
|
|
|
|
|
use axum::body::Body;
|
|
|
|
|
use axum::http::{Request, StatusCode};
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
|
|
|
|
fn app() -> axum::Router {
|
|
|
|
|
build_app(AppState::new(AppConfig {
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
convex_url: None,
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
allow_dev_fixtures: true,
|
|
|
|
|
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]}}"#.into()),
|
|
|
|
|
mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"}}"#.into()),
|
|
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_returns_interactive_html_document() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&activeDocumentId=page_child&channel=test-shell")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let content_type = response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("content-type")
|
|
|
|
|
.and_then(|value| value.to_str().ok())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
assert!(content_type.contains("text/html"));
|
|
|
|
|
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("tree-create-root"));
|
|
|
|
|
assert!(html.contains("tree.ready"));
|
|
|
|
|
assert!(html.contains("test-shell"));
|
|
|
|
|
assert!(html.contains("tree-action-menu"));
|
|
|
|
|
assert!(html.contains("tree.page.context-menu"));
|
|
|
|
|
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
|
|
|
|
|
assert!(html.contains("setAttribute(\"aria-level\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_picker_mode_hides_command_toolbar_and_supports_root_pick() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/tree?workspaceId=ws_demo&mode=picker&allowRootPick=1&excludeIds=page_child")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("\"mode\":\"picker\""));
|
|
|
|
|
assert!(html.contains("\"allowRootPick\":true"));
|
|
|
|
|
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
|
|
|
|
|
assert!(html.contains("tree.pick.root"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_filetree_mode_embeds_asset_state() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/tree?workspaceId=ws_demo&mode=filetree")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("filetree-doc-row"));
|
|
|
|
|
assert!(html.contains("\"mediaAssets\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_create_generates_document_id_when_missing() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"create","workspaceId":"ws_demo","parentId":"page_root","title":"新页面","accessScope":"private","content":[]}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["result"]["action"], Value::String("create".into()));
|
|
|
|
|
assert_eq!(payload["result"]["documentId"], Value::String("page_new".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_rejects_negative_sort_order() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":-1}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_rename_defaults_blank_title_to_untitled() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":" "}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["result"]["title"], Value::String("无标题".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_move_returns_structured_payload() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":1}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["result"]["action"], Value::String("move".into()));
|
|
|
|
|
assert_eq!(payload["result"]["documentId"], Value::String("page_child".into()));
|
|
|
|
|
}
|
|
|
|
|
}
|