feat: cut over rust web main shell
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
//! SSR (Server-Side Rendering) 模块
|
||||
//!
|
||||
//! 提供 Leptos 0.8 的 SSR 渲染基础设施。
|
||||
//! 使用 `RenderHtml::to_html_with_buf` 将 Leptos view 渲染为 HTML 字符串。
|
||||
|
||||
pub mod pages;
|
||||
pub mod styles;
|
||||
|
||||
pub use styles::MNOTE_CSS;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::tachys::view::Position;
|
||||
|
||||
/// 将任何实现 `RenderHtml` 的视图渲染为 HTML 字符串 (SSR)
|
||||
///
|
||||
/// 使用 Leptos 0.8 的 `RenderHtml::to_html_with_buf` 方法,
|
||||
/// 通过 `Position::default()` (即 FirstChild) 初始化位置状态。
|
||||
pub fn render_view(view: impl RenderHtml) -> String {
|
||||
let mut buf = String::new();
|
||||
let mut position = Position::default();
|
||||
RenderHtml::to_html_with_buf(
|
||||
view,
|
||||
&mut buf,
|
||||
&mut position,
|
||||
true, // escape — 对 HTML 特殊字符进行转义
|
||||
false, // mark_branches — 不标记分支注释
|
||||
vec![], // extra_attrs — 无需额外属性
|
||||
);
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use leptos::view;
|
||||
|
||||
#[test]
|
||||
fn render_view_produces_non_empty_html() {
|
||||
let html = render_view(view! { <h1>"Hello SSR"</h1> });
|
||||
assert!(!html.is_empty());
|
||||
assert!(html.contains("Hello SSR"));
|
||||
assert!(html.contains("<h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_view_escapes_html_special_chars() {
|
||||
let html = render_view(view! { <p>"<script>alert('xss')</script>"</p> });
|
||||
assert!(html.contains("<"));
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_view_renders_nested_elements() {
|
||||
let html = render_view(view! {
|
||||
<main>
|
||||
<header><h1>"Title"</h1></header>
|
||||
<section><p>"Content"</p></section>
|
||||
</main>
|
||||
});
|
||||
assert!(html.contains("<main>"));
|
||||
assert!(html.contains("<header>"));
|
||||
assert!(html.contains("<h1>Title</h1>"));
|
||||
assert!(html.contains("<section>"));
|
||||
assert!(html.contains("<p>Content</p>"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
//! MNOTE CSS 样式常量
|
||||
//!
|
||||
//! 为所有 Leptos SSR 页面提供 wolai / Notion 风格样式,
|
||||
//! 匹配 3100 (Next.js) 前端的 wolai 设计系统。
|
||||
|
||||
/// MNOTE 完整 CSS 样式字符串
|
||||
///
|
||||
/// Wolai / Notion 风格:白色背景、近黑正文、浅色侧栏、极简边框。
|
||||
/// 覆盖所有页面组件使用的 class 和 id。
|
||||
pub const MNOTE_CSS: &str = r##"
|
||||
/* ===== 基础重置 ===== */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ===== Wolai 设计变量(来自设计稿) ===== */
|
||||
:root {
|
||||
/* 背景色 */
|
||||
--color-basic-50: #F7F7F5;
|
||||
--color-basic-75: #F0F0EE;
|
||||
--color-basic-100: #EBEBEB;
|
||||
--color-basic-200: #E3E3E0;
|
||||
--color-basic-300: #D4D4D1;
|
||||
--color-basic-400: #C4C4C1;
|
||||
--color-basic-500: #A9A9A6;
|
||||
--color-basic-600: #9B9A97;
|
||||
--color-basic-700: #6D6D69;
|
||||
--color-basic-800: #504F4B;
|
||||
--color-basic-900: #37352F;
|
||||
|
||||
/* wolai 语义变量 */
|
||||
--wolai-bg: #FFFFFF;
|
||||
--wolai-bg-sidebar: var(--color-basic-50);
|
||||
--wolai-bg-hover: rgba(55, 53, 47, 0.08);
|
||||
--wolai-bg-active: rgba(55, 53, 47, 0.12);
|
||||
--wolai-text-primary: var(--color-basic-900);
|
||||
--wolai-text-secondary: var(--color-basic-600);
|
||||
--wolai-border: #E9E9E8;
|
||||
--wolai-brand: #22A559;
|
||||
--wolai-accent: #2563eb;
|
||||
--wolai-accent-hover: #1d4ed8;
|
||||
--wolai-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Helvetica, Arial, sans-serif;
|
||||
--wolai-radius: 4px;
|
||||
}
|
||||
|
||||
/* ===== 基础排版 ===== */
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--wolai-font-sans);
|
||||
color: var(--wolai-text-primary);
|
||||
background: var(--wolai-bg);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--wolai-accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--wolai-accent-hover);
|
||||
}
|
||||
|
||||
/* ===== 滚动条(悬停时显示) ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ===== 页面布局:侧栏 + 内容 ===== */
|
||||
.mnote-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--wolai-bg);
|
||||
}
|
||||
|
||||
.wolai-workspace-shell {
|
||||
--wolai-bg-sidebar: #F7F7F6;
|
||||
--wolai-bg-selected: #F8E6E7;
|
||||
--wolai-accent-red: #E0525B;
|
||||
}
|
||||
|
||||
.wolai-sidebar {
|
||||
width: 288px;
|
||||
background: var(--wolai-bg-sidebar);
|
||||
}
|
||||
|
||||
.wolai-sidebar-header {
|
||||
height: 52px;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.wolai-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: #D6545D;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-sidebar-header .sidebar-workspace-name {
|
||||
max-width: 190px;
|
||||
color: #202124;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.wolai-sidebar-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 12px 18px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a {
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: #4B4B4B;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a .nav-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wolai-sidebar-section {
|
||||
padding: 4px 8px 14px;
|
||||
}
|
||||
|
||||
.wolai-section-title {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #B5B5B2;
|
||||
font-size: 14px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.wolai-section-icon {
|
||||
color: #FFB33F;
|
||||
}
|
||||
|
||||
.wolai-section-caret {
|
||||
color: #B5B5B2;
|
||||
}
|
||||
|
||||
.wolai-section-add {
|
||||
margin-left: auto;
|
||||
font-size: 22px;
|
||||
color: #B5B5B2;
|
||||
}
|
||||
|
||||
.wolai-page-row {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
color: #535353;
|
||||
font-size: 17px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wolai-page-row:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
.wolai-muted-row {
|
||||
color: #747471;
|
||||
}
|
||||
|
||||
.wolai-active-row {
|
||||
background: var(--wolai-bg-selected);
|
||||
color: var(--wolai-accent-red);
|
||||
}
|
||||
|
||||
.wolai-row-icon {
|
||||
width: 22px;
|
||||
color: #111;
|
||||
text-align: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-sidebar-footer {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-top: 1px solid var(--wolai-border);
|
||||
background: #FBFBFA;
|
||||
}
|
||||
|
||||
.wolai-footer-entry {
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #6D6D69;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.wolai-footer-entry + .wolai-footer-entry {
|
||||
border-left: 1px solid var(--wolai-border);
|
||||
}
|
||||
|
||||
.wolai-topbar {
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 18px;
|
||||
color: #222;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.wolai-topbar-left,
|
||||
.wolai-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wolai-topbar-actions {
|
||||
color: #222;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.wolai-menu-icon {
|
||||
color: #4A4A4A;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.wolai-home-icon {
|
||||
color: #000;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wolai-floating-actions {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.wolai-floating-button {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 1px solid #ECECF1;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 侧栏导航 */
|
||||
.mnote-sidebar {
|
||||
width: 288px;
|
||||
flex-shrink: 0;
|
||||
background: var(--wolai-bg-sidebar);
|
||||
border-right: 1px solid var(--wolai-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-header {
|
||||
padding: 14px 16px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mnote-sidebar-brand {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--wolai-text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-brand:hover {
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--wolai-radius);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--wolai-text-primary);
|
||||
text-decoration: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a.active {
|
||||
background: var(--wolai-bg-active);
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a .nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 工作区名称 */
|
||||
.sidebar-workspace-name {
|
||||
font-size: 12px;
|
||||
color: var(--wolai-text-secondary);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 侧栏页面树区 */
|
||||
.sidebar-tree-section {
|
||||
border-top: 1px solid var(--wolai-border);
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.sidebar-tree-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-tree {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-root {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-node {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--wolai-radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--wolai-text-primary);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"] {
|
||||
background: var(--wolai-bg-active);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"] .tree-link-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--wolai-text-secondary);
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-spacer {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-link {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
color: var(--wolai-text-primary);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-link-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row:hover .tree-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--wolai-text-secondary);
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-action:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-children {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-children--collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 树行缩进 — 通过嵌套深度自动偏移 */
|
||||
.sidebar-tree .tree-node .tree-children .tree-row {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-node .tree-children .tree-children .tree-row {
|
||||
padding-left: 48px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-row {
|
||||
padding-left: 68px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-children .tree-row {
|
||||
padding-left: 88px;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.mnote-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wolai-bg);
|
||||
}
|
||||
|
||||
.mnote-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ===== 首页 ===== */
|
||||
.mnote-home {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 72px 64px;
|
||||
}
|
||||
|
||||
.mnote-home-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 22px;
|
||||
color: #000;
|
||||
font-size: 92px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mnote-home h1 {
|
||||
text-align: center;
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 34px;
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
.mnote-home p {
|
||||
font-size: 16px;
|
||||
color: var(--wolai-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.mnote-home-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mnote-home-links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
border-radius: var(--wolai-radius);
|
||||
font-size: 21px;
|
||||
font-weight: 650;
|
||||
color: var(--wolai-text-primary);
|
||||
text-decoration: none;
|
||||
transition: background-color 80ms ease;
|
||||
}
|
||||
|
||||
.mnote-home-links a:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.mnote-home-links a::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.mnote-home-links a span {
|
||||
width: 26px;
|
||||
text-align: center;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ===== 文档壳 ===== */
|
||||
.document-shell {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.document-shell-header {
|
||||
padding: 48px 0 0;
|
||||
margin: 0 auto 8px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.document-shell-header h1 {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--wolai-text-primary);
|
||||
word-break: break-word;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
#mnote-editor-island {
|
||||
margin: 0 15px;
|
||||
max-width: 760px;
|
||||
width: calc(100% - 30px);
|
||||
min-height: 300px;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
#mnote-editor-island .bn-editor {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== 搜索壳 ===== */
|
||||
#mnote-search-shell {
|
||||
max-width: 800px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 48px 64px;
|
||||
}
|
||||
|
||||
#mnote-search-shell header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
#mnote-search-shell h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--wolai-text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.search-meta,
|
||||
.search-query {
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#mnote-search-island {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ===== 思维导图壳 ===== */
|
||||
#mnote-mindmap-shell {
|
||||
max-width: 1000px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 48px 64px;
|
||||
}
|
||||
|
||||
#mnote-mindmap-shell header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
#mnote-mindmap-shell h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
#mnote-mindmap-island {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* ===== 认证页面 ===== */
|
||||
.mnote-auth {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wolai-bg);
|
||||
}
|
||||
|
||||
.mnote-auth-card {
|
||||
text-align: center;
|
||||
padding: 48px 32px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.mnote-auth-card h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--wolai-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mnote-auth-card p {
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== ProseMirror 兼容 ===== */
|
||||
.ProseMirror {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.mnote-sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mnote-home {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.mnote-home h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.document-shell-header {
|
||||
padding: 24px 0 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.document-shell-header h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
#mnote-editor-island {
|
||||
margin: 0 24px;
|
||||
width: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
#mnote-search-shell,
|
||||
#mnote-mindmap-shell {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
#mnote-search-shell h1,
|
||||
#mnote-mindmap-shell h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mnote-sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--wolai-border);
|
||||
}
|
||||
|
||||
.mnote-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mnote-home {
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.mnote-home h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.document-shell-header {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
.document-shell-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#mnote-editor-island {
|
||||
margin: 0 16px;
|
||||
width: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
"##;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_core_selectors() {
|
||||
// Wolai 设计系统 CSS 变量
|
||||
assert!(MNOTE_CSS.contains("--wolai-text-primary"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-text-secondary"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-border"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-bg-sidebar"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-accent"));
|
||||
|
||||
// 布局结构
|
||||
assert!(MNOTE_CSS.contains(".mnote-shell"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-main"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_shell_ids() {
|
||||
assert!(MNOTE_CSS.contains(".document-shell"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-search-shell"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-mindmap-shell"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-editor-island"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-search-island"));
|
||||
assert!(MNOTE_CSS.contains("#mnote-mindmap-island"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_responsive_breakpoints() {
|
||||
assert!(MNOTE_CSS.contains("@media (max-width: 768px)"));
|
||||
assert!(MNOTE_CSS.contains("@media (max-width: 480px)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_scrollbar_styles() {
|
||||
assert!(MNOTE_CSS.contains("::-webkit-scrollbar"));
|
||||
assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_prosemirror_styles() {
|
||||
assert!(MNOTE_CSS.contains(".ProseMirror"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_is_reasonably_sized() {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// 最多 20000 字符避免过于臃肿
|
||||
assert!(MNOTE_CSS.len() < 20000);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user