feat: 收口 mindmap Phase 6 Leptos UI shell

This commit is contained in:
lix-2026
2026-05-11 12:27:40 +08:00
parent b7dddd2a66
commit b5eb27fabc
51 changed files with 8669 additions and 1313 deletions
@@ -0,0 +1,207 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex;
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
apply_mindmap_kernel_commands_to_value, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapQueryParams {
pub view: Option<String>,
pub query_name: Option<String>,
pub root_node_id: Option<String>,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapCommandRequest {
pub command_name: Option<String>,
#[serde(default)]
pub commands: Vec<Value>,
pub projection_revision: Option<u64>,
pub workspace_id: Option<String>,
}
fn response_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_MINDMAP_TRANSPORT.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mindmap-api"));
}
headers
}
fn resolve_query_name(params: &MindmapQueryParams) -> &'static str {
if params.query_name.as_deref() == Some("mindmap.simple_mind_map_scene.get")
|| params.view.as_deref() == Some("simple_mind_map_scene")
{
return "mindmap.simple_mind_map_scene.get";
}
if params.query_name.as_deref() == Some("mindmap.kernel_projection.get")
|| params.view.as_deref() == Some("kernel_projection")
{
return "mindmap.kernel_projection.get";
}
"mindmap.projection.get"
}
pub async fn get_mindmap(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path((document_id, mindmap_id)): Path<(String, String)>,
Query(params): Query<MindmapQueryParams>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = document_id.trim();
let mindmap_id = mindmap_id.trim();
if document_id.is_empty() || mindmap_id.is_empty() {
return Err(WebError::bad_request_code(
"mindmap_id_required",
"缺少有效 documentId 或 mindmapId",
)
.with_context(&context));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?;
let query_name = resolve_query_name(&params);
let result = execute_runtime_query_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: query_name.into(),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"rootNodeId": params.root_node_id,
"workspaceId": effective_workspace_id.clone(),
}),
},
)
.await?;
Ok((StatusCode::OK, response_headers(), Json(result)))
}
pub async fn apply_mindmap_command(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path((document_id, mindmap_id)): Path<(String, String)>,
Json(body): Json<MindmapCommandRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = document_id.trim();
let mindmap_id = mindmap_id.trim();
if document_id.is_empty() || mindmap_id.is_empty() {
return Err(WebError::bad_request_code(
"mindmap_id_required",
"缺少有效 documentId 或 mindmapId",
)
.with_context(&context));
}
if body.command_name.as_deref() != Some("mindmap.command.apply") {
return Err(WebError::bad_request_code(
"mindmap_command_required",
"仅支持 mindmap.command.apply",
)
.with_context(&context));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
let current = fetch_query_data_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "mindmaps.get".into(),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"workspaceId": effective_workspace_id,
}),
},
)
.await?;
let applied = apply_mindmap_kernel_commands_to_value(&current, &body.commands)
.map_err(|error| WebError::bad_request(error.message).with_context(&context))?;
if !applied.errors.is_empty() {
return Err(WebError::bad_request_code(
"mindmap_command_failed",
applied.errors.join("; "),
)
.with_context(&context)
.with_header("x-error-phase", "mindmap_command_apply"));
}
let command = RuntimeCommandEnvelopeWire {
name: "mindmaps.put".into(),
command_id: format!("mindmap_put_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
page_id: Some(document_id.to_string()),
block_id: Some(mindmap_id.to_string()),
}),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"workspaceId": effective_workspace_id,
"data": applied.data,
"createOnly": false,
}),
preflight_data: None,
reason: Some("mnote-web mindmap command apply via kernel projection".into()),
refs: vec!["task166-mindmap-phase6-block-smoke".into()],
dry_run: false,
validate_only: false,
};
let result = execute_runtime_command_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
Ok((
StatusCode::OK,
response_headers(),
Json(json!({
"ok": true,
"commandName": "mindmap.command.apply",
"applied": applied.applied,
"errors": applied.errors,
"projectionRevision": body.projection_revision,
"result": result,
})),
))
}
@@ -20,15 +20,16 @@ pub async fn mindmap_object_shell(
"documentId": doc_id,
"mindmapId": mindmap_id,
"projection": {
"schema": "mnote.mindmap_projection.v1",
"source": "rust-kernel",
"schema": "mnote.mindmap.simple_mind_map_scene.v1",
"runtime": "simple-mind-map",
"source": "compat-blob",
"owner": "rust-kernel",
"queryName": "mindmap.projection.get"
"queryName": "mindmap.simple_mind_map_scene.get"
},
"island": {
"kind": "react_mindmap_runtime",
"kind": "leptos_mindmap_adapter",
"mountId": "mnote-mindmap-island",
"runtimeRole": "renderer_adapter",
"runtimeRole": "simple_mind_map_adapter",
"commandName": "mindmap.command.apply"
},
"requestId": context.trace.request_id,
@@ -115,6 +116,47 @@ mod tests {
}))
}
fn app_with_mindmap_fixture() -> 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(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
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(
serde_json::json!({
"mindmaps:get": {
"ok": true,
"source": "compat-blob",
"revision": 7,
"data": {
"data": {"uid": "root", "text": "KMIND"},
"children": [
{"data": {"uid": "topic", "text": "二级节点"}, "children": []}
]
},
"meta": {
"document_id": "doc_1",
"mindmap_id": "mind_1"
}
}
})
.to_string(),
),
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn mindmap_shell_returns_rust_object_shell_contract() {
let response = app()
@@ -147,9 +189,42 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("mnote.mindmap_shell.v1"));
assert!(html.contains("data-react-island=\"mindmap_runtime\""));
assert!(html.contains("mindmap.projection.get"));
assert!(html.contains("data-leptos-mindmap-island=\"standalone\""));
assert!(html.contains("mindmap.simple_mind_map_scene.get"));
assert!(html.contains("mindmap.command.apply"));
assert!(!html.contains("react_mindmap_runtime"));
assert!(!html.contains("next-app-router"));
}
#[tokio::test]
async fn mindmap_api_returns_same_adapter_contract_for_standalone_and_block() {
let response = app_with_mindmap_fixture()
.oneshot(
Request::builder()
.uri("/api/mindmap/doc_1/mind_1?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("adapter projection json");
assert_eq!(
payload["schema"],
serde_json::json!("mnote.mindmap.simple_mind_map_scene.v1")
);
assert_eq!(payload["runtime"], serde_json::json!("simple-mind-map"));
assert_eq!(payload["root"]["data"]["uid"], serde_json::json!("root"));
assert_eq!(payload["root"]["data"]["text"], serde_json::json!("KMIND"));
assert_eq!(payload["kernelRevision"], serde_json::json!(7));
assert_eq!(
payload["compatPayload"]["source"],
serde_json::json!("compat-blob")
);
}
}
+5
View File
@@ -11,6 +11,7 @@ mod local_folder_source;
mod local_folder_events;
mod local_markdown_parser;
mod media;
mod mindmap_api;
mod mindmap_shell;
mod onlyoffice;
mod query_support;
@@ -42,6 +43,10 @@ pub fn build_router(state: AppState) -> Router {
"/mindmap/{doc_id}/{mindmap_id}",
get(mindmap_shell::mindmap_object_shell),
)
.route(
"/api/mindmap/{doc_id}/{mindmap_id}",
get(mindmap_api::get_mindmap).post(mindmap_api::apply_mindmap_command),
)
.route(
"/documents/{document_id}",
get(web_shell::document_page_shell),
@@ -11,7 +11,7 @@ use leptos::prelude::*;
/// 渲染思维导图的 SSR 壳结构:
/// - `<main id="mnote-mindmap-shell" data-object-shell="mindmap">`
/// - 思维导图标题区域
/// - React 运行时挂载占位区
/// - leptos-mindmap / simple-mind-map adapter 挂载占位区
#[component]
pub fn MindmapPage(
/// 文档 ID
@@ -30,7 +30,13 @@ pub fn MindmapPage(
<header>
<h1>{"思维导图"}</h1>
</header>
<section id="mnote-mindmap-island" data-react-island="mindmap_runtime"></section>
<section
id="mnote-mindmap-island"
data-leptos-mindmap-island="standalone"
data-runtime="simple-mind-map"
data-projection-query="mindmap.simple_mind_map_scene.get"
data-command-name="mindmap.command.apply"
></section>
</main>
</PageLayout>
}