feat: cut over rust web main shell

This commit is contained in:
lix-2026
2026-04-29 12:24:44 +08:00
parent 7965c6c107
commit 048fe28a4d
97 changed files with 9396 additions and 1263 deletions
@@ -0,0 +1,21 @@
//! MNOTE 登录页面组件
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 登录页面
///
/// 当 legacy compat 关闭时由 `auth_entry` 路由使用。
#[component]
pub fn AuthPage() -> impl IntoView {
view! {
<PageLayout current_nav="home">
<section class="mnote-auth">
<h1>MNOTE </h1>
<p>
"此页面由 mnote-web gateway 提供。"
</p>
</section>
</PageLayout>
}
}
@@ -0,0 +1,38 @@
//! MNOTE 文档页面组件 (SSR)
//!
//! 提供文档编辑器的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use leptos::prelude::*;
use crate::ssr::pages::layout::PageLayout;
/// MNOTE 文档页面
///
/// 渲染文档编辑器的 SSR 壳结构:
/// - `<PageLayout current_nav="documents">`
/// - 标题区域
/// - 页面聚合快照标记
/// - 编辑器占位区
#[component]
pub fn DocumentPage(
/// 文档标题
title: String,
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island">
<header class="document-shell-header">
<h1>{title}</h1>
</header>
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island"></section>
</main>
</PageLayout>
}
}
@@ -0,0 +1,36 @@
//! MNOTE 首页组件(Wolai 风格)
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 首页。
#[component]
pub fn HomePage(
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}>
<div class="mnote-home">
<div class="mnote-home-icon" aria-hidden="true">""</div>
<h1>"个人"</h1>
<div class="mnote-home-links">
<a href="/documents/backup"><span>""</span>"正版软件备份"</a>
<a href="/documents/growth"><span>""</span>"个人发展"</a>
<a href="/documents/notes"><span>""</span>"杂记"</a>
<a href="/documents/passwords"><span>""</span>"密码"</a>
<a href="/documents/wolai-guide"><span>""</span>"Wolai 教程"</a>
<a href="/documents/health"><span>""</span>"个人健康"</a>
<a href="/documents/software"><span>""</span>"软件开发"</a>
</div>
</div>
</PageLayout>
}
}
@@ -0,0 +1,148 @@
//! MNOTE 通用页面布局组件(Wolai / Notion 风格)
use leptos::prelude::*;
const SIDEBAR_TREE_JS: &str = r##"
<script>
(function(){
var tree = document.getElementById('sidebar-tree-root');
if (!tree) return;
// 高亮当前页
var currentPath = window.location.pathname;
var match = currentPath.match(/^\/documents\/([^\/]+)/);
if (match) {
var activeId = match[1];
var links = tree.querySelectorAll('[data-rust-action="open"]');
for (var i = 0; i < links.length; i++) {
if (links[i].getAttribute('data-node-id') === activeId) {
links[i].closest('.tree-row').setAttribute('data-active', 'true');
}
}
}
// 展开/折叠
tree.addEventListener('click', function(e) {
var btn = e.target.closest('[data-rust-action]');
if (!btn) return;
var nodeId = btn.getAttribute('data-node-id');
var action = btn.getAttribute('data-rust-action');
if (action === 'toggle') {
var row = btn.closest('.tree-row');
if (!row) return;
var li = row.parentElement;
var children = li.querySelector(':scope > .tree-children');
if (children) {
children.classList.toggle('tree-children--collapsed');
var isCollapsed = children.classList.contains('tree-children--collapsed');
row.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
btn.textContent = isCollapsed ? '▸' : '▾';
}
e.preventDefault();
} else if (action === 'open') {
window.location.href = '/documents/' + encodeURIComponent(nodeId);
e.preventDefault();
}
});
})();
</script>
"##;
/// MNOTE Wolai 风格页面布局
///
/// 包含左侧栏 + 内容区的双栏布局。
/// 侧栏显示品牌、导航链接和可选的页面树。
///
/// # 用法
///
/// ```ignore
/// view! {
/// <PageLayout current_nav="home" sidebar_tree_html={None}>
/// <section>...</section>
/// </PageLayout>
/// }
/// ```
#[component]
pub fn PageLayout(
children: Children,
current_nav: &'static str,
/// 侧栏页面树 HTML(可选),由路由 handler 渲染
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选),显示在侧栏顶部
#[prop(optional)]
workspace_name: Option<String>,
/// workspace shell 侧栏 sections HTML(可选),由 projection 渲染
#[prop(optional)]
workspace_sidebar_html: Option<String>,
) -> impl IntoView {
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
let ws_name = workspace_name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "开发用户 的空间".to_string());
let sidebar_sections_html = workspace_sidebar_html
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
let dataset = serde_json::json!({
"workspaces": [{ "id": "default", "name": ws_name.clone() }],
"documents": []
});
let projection = crate::workspace_shell::build_workspace_shell_projection(
&dataset,
"default",
None,
&ws_name,
);
crate::workspace_shell::render_workspace_shell_sidebar_html(
&projection,
Some(sidebar_tree_html.as_str()),
)
});
view! {
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
<aside class="mnote-sidebar wolai-sidebar" data-testid="wolai-sidebar">
<div class="mnote-sidebar-header wolai-sidebar-header" data-testid="wolai-workspace-identity">
<a href="/" class="mnote-sidebar-brand wolai-avatar" aria-label="工作区首页">"L"</a>
<span class="sidebar-workspace-name">{ws_name}</span>
<span class="wolai-sidebar-chevron" aria-hidden="true">""</span>
</div>
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
<a href="/search" class:active={current_nav == "search"} title="搜索"><span class="nav-icon">""</span></a>
<a href="/graph" title="关系图"><span class="nav-icon">""</span></a>
<a href="/actions" title="快捷动作"><span class="nav-icon">""</span></a>
<a href="/inbox" title="收纳"><span class="nav-icon">""</span></a>
<a href="/files" title="文件"><span class="nav-icon">""</span></a>
<a href="/more" title="更多"><span class="nav-icon">""</span></a>
</nav>
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
</aside>
<div class="mnote-main">
<header class="wolai-topbar" data-testid="wolai-topbar">
<div class="wolai-topbar-left"><span class="wolai-menu-icon">""</span><span class="wolai-home-icon">""</span><span>{"个人"}</span></div>
<div class="wolai-topbar-actions" aria-label="页面操作">
<span title="收藏">""</span>
<span title="演示">""</span>
<span title="评论">""</span>
<span title="关系">""</span>
<span title="成员">"♙+"</span>
<span title="历史">""</span>
<span title="更多">""</span>
</div>
</header>
<article class="mnote-content">
{children()}
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button">"AI"</button>
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button">"?"</button>
</div>
</div>
</div>
}
}
@@ -0,0 +1,37 @@
//! MNOTE 思维导图页面组件 (SSR)
//!
//! 提供思维导图页面的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 思维导图页面
///
/// 渲染思维导图的 SSR 壳结构:
/// - `<main id="mnote-mindmap-shell" data-object-shell="mindmap">`
/// - 思维导图标题区域
/// - React 运行时挂载占位区
#[component]
pub fn MindmapPage(
/// 文档 ID
document_id: String,
/// 思维导图 ID
mindmap_id: String,
) -> impl IntoView {
view! {
<PageLayout current_nav="documents">
<main
id="mnote-mindmap-shell"
data-object-shell="mindmap"
data-document-id={document_id}
data-mindmap-id={mindmap_id}
>
<header>
<h1>{"思维导图"}</h1>
</header>
<section id="mnote-mindmap-island" data-react-island="mindmap_runtime"></section>
</main>
</PageLayout>
}
}
@@ -0,0 +1,8 @@
//! SSR 页面组件
pub mod auth;
pub mod document;
pub mod home;
pub mod layout;
pub mod mindmap;
pub mod search;
@@ -0,0 +1,80 @@
//! MNOTE 搜索页面组件 (SSR)
//!
//! 提供搜索页面的服务器端渲染壳。
//! 由 `<body>` 外层包装、搜索契约 JSON 嵌入脚本由路由 handler 处理。
use leptos::prelude::*;
use crate::ssr::pages::layout::PageLayout;
/// MNOTE 搜索页面
///
/// 渲染搜索页面的 SSR 壳结构:
/// - `<main id="mnote-search-shell" data-island-host="react_search_palette">`
/// - 搜索标题区域
/// - 搜索岛占位区
#[component]
pub fn SearchPage(
/// 工作区 ID
#[prop(into)]
workspace_id: String,
/// 搜索查询
#[prop(into)]
search_query: String,
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="search" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
<main id="mnote-search-shell" data-island-host="react_search_palette">
<header class="search-header">
<h1>{"搜索"}</h1>
<p class="search-meta">
{format!("工作区: {}", workspace_id)}
</p>
<p class="search-query">
{if search_query.is_empty() {
"请输入关键词".to_string()
} else {
format!("查询: {}", search_query)
}}
</p>
</header>
<section id="mnote-search-island" data-react-island="search_palette"></section>
</main>
</PageLayout>
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ssr::render_view;
#[test]
fn search_page_renders_correct_structure() {
let html = render_view(view! {
<SearchPage workspace_id="ws_demo" search_query="Rust" />
});
assert!(html.contains("mnote-search-shell"));
assert!(html.contains("data-island-host=\"react_search_palette\""));
assert!(html.contains("mnote-search-island"));
assert!(html.contains("data-react-island=\"search_palette\""));
assert!(html.contains("搜索"));
}
#[test]
fn search_page_contains_layout() {
let html = render_view(view! {
<SearchPage workspace_id="ws_demo" search_query="" />
});
assert!(html.contains("mnote-shell"));
assert!(html.contains("mnote-sidebar"));
assert!(html.contains("mnote-sidebar-brand"));
assert!(html.contains("mnote-sidebar-nav"));
assert!(html.contains("mnote-content"));
}
}