feat: 收口 mindmap Phase 6 Leptos UI shell
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -51,9 +51,11 @@ pub use kernel::{
|
||||
WorkspaceSourceCapability, WorkspaceSourceKind,
|
||||
};
|
||||
pub use mindmap::{
|
||||
MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
|
||||
MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner,
|
||||
MindmapTreeNode,
|
||||
MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities,
|
||||
MindmapKernelCommand, MindmapKernelEdge, MindmapKernelNode, MindmapKernelProjection,
|
||||
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapProjection,
|
||||
MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner, MindmapProjectionSource,
|
||||
MindmapSummary, MindmapTreeNode,
|
||||
};
|
||||
pub use page_aggregate::{
|
||||
PageAggregateProjection, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout,
|
||||
@@ -363,4 +365,190 @@ mod tests {
|
||||
assert_eq!(projection.owner, MindmapProjectionOwner::RustKernel);
|
||||
assert!(matches!(command, MindmapCommand::RenameNode { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_kernel_and_adapter_projection_serialize_as_camel_case() {
|
||||
let kernel_projection = MindmapKernelProjection {
|
||||
schema: "mnote.mindmap.kernel_projection.v1".into(),
|
||||
document_id: "doc_1".into(),
|
||||
mindmap_id: "mind_1".into(),
|
||||
root_node_id: "root".into(),
|
||||
revision: 7,
|
||||
nodes: vec![
|
||||
MindmapKernelNode {
|
||||
node_id: "root".into(),
|
||||
parent_id: None,
|
||||
text: "KMIND".into(),
|
||||
order: 0,
|
||||
collapsed: false,
|
||||
style: Default::default(),
|
||||
refs: Default::default(),
|
||||
},
|
||||
MindmapKernelNode {
|
||||
node_id: "child_1".into(),
|
||||
parent_id: Some("root".into()),
|
||||
text: "二级节点".into(),
|
||||
order: 1,
|
||||
collapsed: false,
|
||||
style: Default::default(),
|
||||
refs: Default::default(),
|
||||
},
|
||||
],
|
||||
edges: vec![MindmapKernelEdge {
|
||||
edge_id: "root->child_1".into(),
|
||||
from_node_id: "root".into(),
|
||||
to_node_id: "child_1".into(),
|
||||
edge_kind: "tree".into(),
|
||||
}],
|
||||
summaries: vec![MindmapSummary {
|
||||
summary_id: "summary_1".into(),
|
||||
parent_node_id: "child_1".into(),
|
||||
node_ids: vec!["child_1".into()],
|
||||
text: "概要".into(),
|
||||
style: Default::default(),
|
||||
}],
|
||||
associative_lines: vec![MindmapAssociativeLine {
|
||||
line_id: "line_1".into(),
|
||||
from_node_id: "root".into(),
|
||||
to_node_id: "child_1".into(),
|
||||
text: Some("关联".into()),
|
||||
style: Default::default(),
|
||||
}],
|
||||
layout: serde_json::json!({ "type": "logicalStructure" }),
|
||||
theme: serde_json::json!({ "template": "classic" }),
|
||||
view: serde_json::json!({ "zoom": 1.0 }),
|
||||
capabilities: MindmapKernelCapabilities {
|
||||
can_edit: true,
|
||||
can_edit_text: true,
|
||||
can_insert_child: true,
|
||||
can_insert_sibling_after: true,
|
||||
can_delete_node: true,
|
||||
can_move_node: true,
|
||||
can_patch_view: true,
|
||||
can_set_layout: true,
|
||||
can_set_theme: true,
|
||||
},
|
||||
source: MindmapProjectionSource::Kernel,
|
||||
};
|
||||
|
||||
let adapter_projection = MindmapAdapterProjection {
|
||||
schema: "mnote.mindmap.simple_mind_map_scene.v1".into(),
|
||||
runtime: "simple-mind-map".into(),
|
||||
root: serde_json::json!({
|
||||
"data": { "uid": "root", "text": "KMIND" },
|
||||
"children": []
|
||||
}),
|
||||
layout: serde_json::json!("logicalStructure"),
|
||||
theme: serde_json::json!("classic"),
|
||||
theme_config: serde_json::json!({}),
|
||||
view: serde_json::json!({ "scale": 1.0 }),
|
||||
config: serde_json::json!({}),
|
||||
compat_payload: serde_json::json!({}),
|
||||
kernel_revision: 7,
|
||||
};
|
||||
|
||||
let kernel_value =
|
||||
serde_json::to_value(kernel_projection).expect("kernel projection should serialize");
|
||||
let adapter_value =
|
||||
serde_json::to_value(adapter_projection).expect("adapter projection should serialize");
|
||||
|
||||
assert_eq!(kernel_value["documentId"], serde_json::json!("doc_1"));
|
||||
assert_eq!(kernel_value["mindmapId"], serde_json::json!("mind_1"));
|
||||
assert_eq!(kernel_value["rootNodeId"], serde_json::json!("root"));
|
||||
assert_eq!(
|
||||
kernel_value["nodes"][1]["parentId"],
|
||||
serde_json::json!("root")
|
||||
);
|
||||
assert_eq!(
|
||||
kernel_value["associativeLines"][0]["fromNodeId"],
|
||||
serde_json::json!("root")
|
||||
);
|
||||
assert_eq!(
|
||||
kernel_value["capabilities"]["canEditText"],
|
||||
serde_json::json!(true)
|
||||
);
|
||||
assert_eq!(kernel_value["source"], serde_json::json!("kernel"));
|
||||
|
||||
assert_eq!(
|
||||
adapter_value["runtime"],
|
||||
serde_json::json!("simple-mind-map")
|
||||
);
|
||||
assert_eq!(adapter_value["themeConfig"], serde_json::json!({}));
|
||||
assert_eq!(adapter_value["compatPayload"], serde_json::json!({}));
|
||||
assert_eq!(adapter_value["kernelRevision"], serde_json::json!(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_kernel_command_contract_covers_minimum_write_surface() {
|
||||
let commands = vec![
|
||||
MindmapKernelCommand::UpdateText {
|
||||
mindmap_id: "mind_1".into(),
|
||||
node_id: "root".into(),
|
||||
text: "新标题".into(),
|
||||
},
|
||||
MindmapKernelCommand::InsertChild {
|
||||
mindmap_id: "mind_1".into(),
|
||||
parent_node_id: "root".into(),
|
||||
node: MindmapNodeInput {
|
||||
uid: Some("child_1".into()),
|
||||
text: "子节点".into(),
|
||||
hyperlink: None,
|
||||
note: None,
|
||||
refs: None,
|
||||
},
|
||||
},
|
||||
MindmapKernelCommand::InsertSiblingAfter {
|
||||
mindmap_id: "mind_1".into(),
|
||||
target_node_id: "child_1".into(),
|
||||
node: MindmapNodeInput {
|
||||
uid: Some("child_2".into()),
|
||||
text: "同级节点".into(),
|
||||
hyperlink: None,
|
||||
note: None,
|
||||
refs: None,
|
||||
},
|
||||
},
|
||||
MindmapKernelCommand::DeleteNode {
|
||||
mindmap_id: "mind_1".into(),
|
||||
node_id: "child_2".into(),
|
||||
},
|
||||
MindmapKernelCommand::MoveNode {
|
||||
mindmap_id: "mind_1".into(),
|
||||
node_id: "child_1".into(),
|
||||
new_parent_node_id: "root".into(),
|
||||
order: Some(1),
|
||||
},
|
||||
MindmapKernelCommand::PatchView {
|
||||
mindmap_id: "mind_1".into(),
|
||||
patch: serde_json::json!({ "scale": 1.2 }),
|
||||
},
|
||||
MindmapKernelCommand::SetLayout {
|
||||
mindmap_id: "mind_1".into(),
|
||||
layout: serde_json::json!({ "type": "mindMap" }),
|
||||
},
|
||||
MindmapKernelCommand::SetTheme {
|
||||
mindmap_id: "mind_1".into(),
|
||||
theme: serde_json::json!({ "template": "classic" }),
|
||||
theme_config: serde_json::json!({ "lineColor": "#f59e0b" }),
|
||||
},
|
||||
];
|
||||
|
||||
let encoded = serde_json::to_value(&commands).expect("commands should serialize");
|
||||
|
||||
assert_eq!(encoded[0]["type"], serde_json::json!("updateText"));
|
||||
assert_eq!(encoded[1]["type"], serde_json::json!("insertChild"));
|
||||
assert_eq!(encoded[2]["type"], serde_json::json!("insertSiblingAfter"));
|
||||
assert_eq!(encoded[3]["type"], serde_json::json!("deleteNode"));
|
||||
assert_eq!(encoded[4]["type"], serde_json::json!("moveNode"));
|
||||
assert_eq!(encoded[5]["type"], serde_json::json!("patchView"));
|
||||
assert_eq!(encoded[6]["type"], serde_json::json!("setLayout"));
|
||||
assert_eq!(encoded[7]["type"], serde_json::json!("setTheme"));
|
||||
assert_eq!(encoded[0]["mindmapId"], serde_json::json!("mind_1"));
|
||||
assert_eq!(encoded[1]["parentNodeId"], serde_json::json!("root"));
|
||||
assert_eq!(encoded[4]["newParentNodeId"], serde_json::json!("root"));
|
||||
assert_eq!(
|
||||
encoded[7]["themeConfig"]["lineColor"],
|
||||
serde_json::json!("#f59e0b")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,189 @@ pub enum MindmapProjectionOwner {
|
||||
CompatBlob,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum MindmapProjectionSource {
|
||||
Kernel,
|
||||
CompatBlob,
|
||||
AdapterCache,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapKernelProjection {
|
||||
pub schema: String,
|
||||
pub document_id: String,
|
||||
pub mindmap_id: String,
|
||||
pub root_node_id: String,
|
||||
pub revision: u64,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<MindmapKernelNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<MindmapKernelEdge>,
|
||||
#[serde(default)]
|
||||
pub summaries: Vec<MindmapSummary>,
|
||||
#[serde(default)]
|
||||
pub associative_lines: Vec<MindmapAssociativeLine>,
|
||||
#[serde(default)]
|
||||
pub layout: Value,
|
||||
#[serde(default)]
|
||||
pub theme: Value,
|
||||
#[serde(default)]
|
||||
pub view: Value,
|
||||
pub capabilities: MindmapKernelCapabilities,
|
||||
pub source: MindmapProjectionSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapKernelNode {
|
||||
pub node_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub order: i64,
|
||||
#[serde(default)]
|
||||
pub collapsed: bool,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub style: BTreeMap<String, Value>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub refs: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapKernelEdge {
|
||||
pub edge_id: String,
|
||||
pub from_node_id: String,
|
||||
pub to_node_id: String,
|
||||
pub edge_kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapSummary {
|
||||
pub summary_id: String,
|
||||
pub parent_node_id: String,
|
||||
#[serde(default)]
|
||||
pub node_ids: Vec<String>,
|
||||
pub text: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub style: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapAssociativeLine {
|
||||
pub line_id: String,
|
||||
pub from_node_id: String,
|
||||
pub to_node_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub style: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapKernelCapabilities {
|
||||
pub can_edit: bool,
|
||||
pub can_edit_text: bool,
|
||||
pub can_insert_child: bool,
|
||||
pub can_insert_sibling_after: bool,
|
||||
pub can_delete_node: bool,
|
||||
pub can_move_node: bool,
|
||||
pub can_patch_view: bool,
|
||||
pub can_set_layout: bool,
|
||||
pub can_set_theme: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapAdapterProjection {
|
||||
pub schema: String,
|
||||
pub runtime: String,
|
||||
pub root: Value,
|
||||
#[serde(default)]
|
||||
pub layout: Value,
|
||||
#[serde(default)]
|
||||
pub theme: Value,
|
||||
#[serde(default)]
|
||||
pub theme_config: Value,
|
||||
#[serde(default)]
|
||||
pub view: Value,
|
||||
#[serde(default)]
|
||||
pub config: Value,
|
||||
#[serde(default)]
|
||||
pub compat_payload: Value,
|
||||
pub kernel_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum MindmapKernelCommand {
|
||||
UpdateText {
|
||||
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
|
||||
mindmap_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
text: String,
|
||||
},
|
||||
InsertChild {
|
||||
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
|
||||
mindmap_id: String,
|
||||
#[serde(rename = "parentNodeId", alias = "parent_node_id", alias = "parentId")]
|
||||
parent_node_id: String,
|
||||
node: MindmapNodeInput,
|
||||
},
|
||||
InsertSiblingAfter {
|
||||
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
|
||||
mindmap_id: String,
|
||||
#[serde(rename = "targetNodeId", alias = "target_node_id")]
|
||||
target_node_id: String,
|
||||
node: MindmapNodeInput,
|
||||
},
|
||||
DeleteNode {
|
||||
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
|
||||
mindmap_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
},
|
||||
MoveNode {
|
||||
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
|
||||
mindmap_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
#[serde(
|
||||
rename = "newParentNodeId",
|
||||
alias = "new_parent_node_id",
|
||||
alias = "newParentId"
|
||||
)]
|
||||
new_parent_node_id: String,
|
||||
#[serde(rename = "order", alias = "sortOrder", alias = "sort_order")]
|
||||
order: Option<i64>,
|
||||
},
|
||||
PatchView {
|
||||
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
|
||||
mindmap_id: String,
|
||||
patch: Value,
|
||||
},
|
||||
SetLayout {
|
||||
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
|
||||
mindmap_id: String,
|
||||
layout: Value,
|
||||
},
|
||||
SetTheme {
|
||||
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
|
||||
mindmap_id: String,
|
||||
theme: Value,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "themeConfig", alias = "theme_config")]
|
||||
theme_config: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum MindmapCommand {
|
||||
@@ -136,6 +319,8 @@ pub struct MindmapTreeNode {
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
/// 兼容 DTO:对应 simple-mind-map / KMind 风格树节点。
|
||||
/// 长期事实源应使用 `MindmapKernelProjection` 与 kernel command。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapNodeInput {
|
||||
@@ -150,6 +335,8 @@ pub struct MindmapNodeInput {
|
||||
pub refs: Option<Vec<MindmapNodeRef>>,
|
||||
}
|
||||
|
||||
/// 兼容 DTO:用于历史 AI 工具和旧 blob 操作。
|
||||
/// 新的 leptos-mindmap 写面应优先使用 `MindmapKernelCommand`。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum MindmapOp {
|
||||
|
||||
@@ -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(¶ms);
|
||||
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(¤t, &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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
+7
-1
@@ -38,12 +38,18 @@ export class IntoUnderlyingSource {
|
||||
|
||||
export function mount(container: Element, options: any): number;
|
||||
|
||||
export function mount_mindmap_shell(container: Element, options: any): number;
|
||||
|
||||
export function unmount(mount_id: number): void;
|
||||
|
||||
export function unmount_mindmap_shell(mount_id: number): void;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly mount_mindmap_shell: (a: any, b: any) => [number, number, number];
|
||||
readonly unmount_mindmap_shell: (a: number) => [number, number];
|
||||
readonly mount: (a: any, b: any) => [number, number, number];
|
||||
readonly unmount: (a: number) => [number, number];
|
||||
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
@@ -61,7 +67,7 @@ export interface InitOutput {
|
||||
readonly intounderlyingsource_cancel: (a: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
|
||||
|
||||
+38
-11
@@ -165,6 +165,19 @@ export function mount(container, options) {
|
||||
return ret[0] >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} container
|
||||
* @param {any} options
|
||||
* @returns {number}
|
||||
*/
|
||||
export function mount_mindmap_shell(container, options) {
|
||||
const ret = wasm.mount_mindmap_shell(container, options);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return ret[0] >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} mount_id
|
||||
*/
|
||||
@@ -174,6 +187,16 @@ export function unmount(mount_id) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} mount_id
|
||||
*/
|
||||
export function unmount_mindmap_shell(mount_id) {
|
||||
const ret = wasm.unmount_mindmap_shell(mount_id);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
@@ -812,6 +835,10 @@ function __wbg_get_imports() {
|
||||
const ret = arg0.next;
|
||||
return ret;
|
||||
},
|
||||
__wbg_now_a9b7df1cbee90986: function() {
|
||||
const ret = Date.now();
|
||||
return ret;
|
||||
},
|
||||
__wbg_ok_f7783a2e6ac7fe17: function(arg0) {
|
||||
const ret = arg0.ok;
|
||||
return ret;
|
||||
@@ -1233,42 +1260,42 @@ function __wbg_get_imports() {
|
||||
}
|
||||
}, arguments); },
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1440, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1585, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1721, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1824, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1812, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1915, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1638, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1741, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1723, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1826, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1637, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1740, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1659, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1762, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1722, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1825, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
|
||||
return ret;
|
||||
},
|
||||
@@ -1330,8 +1357,8 @@ function wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg
|
||||
return ret !== 0;
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) {
|
||||
|
||||
BIN
Binary file not shown.
Vendored
+3
-1
@@ -1,6 +1,8 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const mount_mindmap_shell: (a: any, b: any) => [number, number, number];
|
||||
export const unmount_mindmap_shell: (a: number) => [number, number];
|
||||
export const mount: (a: any, b: any) => [number, number, number];
|
||||
export const unmount: (a: number) => [number, number];
|
||||
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
@@ -18,7 +20,7 @@ export const intounderlyingsource_pull: (a: number, b: any) => any;
|
||||
export const intounderlyingsource_cancel: (a: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
|
||||
|
||||
+1520
-1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user