fix: align rust web stitch sidebar tabs

This commit is contained in:
lix-2026
2026-04-29 16:23:49 +08:00
parent 33ddda9dfd
commit 6ddc95412c
9 changed files with 874 additions and 24 deletions
@@ -0,0 +1,45 @@
# 3-11 Rust Web 3000 Stitch UI Parity Checklist v1
> 目标:以 `design/design/stitch/260429` 的 Stitch 产物为视觉基准,把 3000 的 Rust Web 页面推进到更接近目标 UI,同时保留真实页面树、文件树、页面新建和 `leptos-tiptap` runtime。
## 输入基准
- [x] 读取 Stitch 设计系统:`DESIGN.md`
- [x] 读取 Stitch 静态结构:`code.html`
- [x] 查看目标截图:`screen.png`
- [x] 对比当前 3000 截图:`/mnt/Data1T/mnote/tmp/image copy 33.png`
## 约束
- [x] 不直接整包复制 Stitch HTML,避免绕开 Rust projection / tree shell / editor island。
- [x] 不恢复 Next.js 为主入口,3000 继续由 `mnote-web` 承载。
- [x] 页面树、文件树、新建页面、正文编辑继续使用当前已接入的运行链路。
- [x] 不引入 Tailwind CDN、Google Fonts、远程图片作为运行依赖。
## 实施项
- [x] 侧栏外壳对齐 Stitch:240px 左栏、浅灰无硬边框、工作区头像/标题、快捷操作区、底部入口。
- [x] 侧栏内容对齐 Stitch:星标置顶、我的页面、Explorer tab 同屏呈现,并保留真实页面树和文件树 DOM/testid。
- [x] 页面树/文件树行样式对齐 Stitch:12px 层级缩进、14px 文本、soft-bleed hover、当前页选中态。
- [x] 顶栏对齐 Stitch:44px 高度、左侧面包屑、右侧 Public/收藏/历史/AI/搜索/更多工具。
- [x] 文档画布对齐 Stitch:居中 880px 内容列、80px 顶部留白、页面图标、标题、元信息、分割线。
- [x] `leptos-tiptap` 外壳对齐 Stitch:编辑器跟随文档列宽、正文 16px/1.6、空态提示不遮挡真实编辑器。
- [x] 浮动按钮对齐 Stitch:右下帮助按钮 + 绿色 AI 圆形按钮。
- [x] 首页 active page 链路不退化:根页仍能看到真实 sidebar,并可打开当前页。
- [x] 验证 Rust 单测和关键 smoke,确保树与编辑器 runtime 未断开。
## 验收
- [x] `cargo test -p mnote-web` 通过。
- [x] 至少一个文档 shell smoke 通过,确认 `wolai-sidebar`、页面树、文件树、`leptos_tiptap_island` 仍存在。
- [x] 如 3000 服务可用,使用浏览器截图或 DOM 检查确认首屏侧栏、顶栏、文档画布接近 Stitch 目标。
## 验证记录
- [x] `cargo test -p mnote-web`126 个单测通过,doc tests 4 个 ignored。
- [x] `node scripts/task119-rust-web-wolai-visual-regression-smoke.js`:通过,截图 `test-results/task119-rust-web-wolai-visual-regression-root.png`
- [x] `node scripts/task120-rust-web-tree-integration-smoke.js`:通过。
- [x] `node scripts/task121-rust-web-editor-island-hydration-smoke.js`:通过,确认 hydrate、输入、保存、reload 读回。
- [x] `node scripts/task122-rust-web-create-page-ui-smoke.js`:通过,确认侧栏 `+` 新建页面并进入文档页。
- [x] 手动 Playwright 截图:`test-results/task123-stitch-document-parity.png`,确认文档页头、真实页面树/文件树、编辑器正文列对齐。
- [x] 修复 `我的页面 / Explorer` 切换:文件树不再作为第二个独立 Explorer section 渲染,点击 Explorer tab 后切换到文件树 panel。
@@ -37,11 +37,21 @@ pub fn DocumentPage(
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "null")
.is_some();
let workspace_label = workspace_name
.clone()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "个人空间".to_string());
view! {
<PageLayout current_nav="documents" 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()} topbar_title={title.clone()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()}>
<header class="document-shell-header">
<div class="document-page-icon" aria-hidden="true">""</div>
<h1>{title}</h1>
<div class="document-shell-meta" aria-label="页面元信息">
<span><span aria-hidden="true">""</span>{workspace_label}</span>
<span><span aria-hidden="true">""</span>"已同步"</span>
</div>
</header>
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
<section
+6 -1
View File
@@ -43,9 +43,14 @@ pub fn HomePage(
<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()} topbar_title={active_page_title.clone()}>
{move || if has_active_page {
view! {
<main class="document-shell" data-root-active-page-id={active_page_id.clone()} data-editor-host="leptos_tiptap_island">
<main class="document-shell document-shell--workspace-entry" data-root-active-page-id={active_page_id.clone()} data-editor-host="leptos_tiptap_island">
<header class="document-shell-header">
<div class="document-page-icon" aria-hidden="true">""</div>
<h1>{active_page_title.clone()}</h1>
<div class="document-shell-meta" aria-label="页面元信息">
<span><span aria-hidden="true">""</span>"个人空间"</span>
<span><span aria-hidden="true">""</span>"工作区首页"</span>
</div>
</header>
<section class="mnote-workspace-active-state" data-testid="mnote-workspace-active-page-state">
<a class="mnote-workspace-active-link" href={active_href.clone()}>"打开当前页面"</a>
+44 -13
View File
@@ -51,7 +51,32 @@ const SIDEBAR_TREE_JS: &str = r##"
window.location.href = '/documents/' + encodeURIComponent(result.documentId) + '?workspaceId=' + encodeURIComponent(nextWorkspaceId);
}
function switchSidebarTreeTab(trigger) {
var mode = (trigger.getAttribute('data-mnote-sidebar-tree-tab') || 'page').trim();
var shell = trigger.closest('[data-testid="wolai-sidebar-page-tree-shell"]');
if (!shell) return;
var tabs = shell.querySelectorAll('[data-mnote-sidebar-tree-tab]');
for (var i = 0; i < tabs.length; i++) {
var isActive = tabs[i].getAttribute('data-mnote-sidebar-tree-tab') === mode;
tabs[i].setAttribute('aria-selected', isActive ? 'true' : 'false');
tabs[i].classList.toggle('wolai-sidebar-tab-active', isActive);
tabs[i].classList.toggle('wolai-sidebar-tab-muted', !isActive);
}
var panels = shell.querySelectorAll('[data-mnote-sidebar-tree-panel]');
for (var j = 0; j < panels.length; j++) {
var isCurrentPanel = panels[j].getAttribute('data-mnote-sidebar-tree-panel') === mode;
panels[j].hidden = !isCurrentPanel;
}
}
document.addEventListener('click', function(e) {
var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]');
if (tabTrigger) {
e.preventDefault();
switchSidebarTreeTab(tabTrigger);
return;
}
var createTrigger = closestAction(e.target, '[data-mnote-action="create-page"]');
if (createTrigger) {
e.preventDefault();
@@ -184,10 +209,10 @@ pub fn PageLayout(
</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="/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="/help" 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>
@@ -195,23 +220,29 @@ pub fn PageLayout(
</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>{topbar_title}</span></div>
<div class="wolai-topbar-left">
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏">""</button>
<nav class="wolai-breadcrumb" aria-label="页面路径">
<span class="wolai-breadcrumb-root">"The Digital Atelier"</span>
<span class="wolai-breadcrumb-separator" aria-hidden="true">"/"</span>
<span class="wolai-breadcrumb-current"><span class="wolai-home-icon" aria-hidden="true">""</span>{topbar_title}</span>
</nav>
</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>
<span class="wolai-public-pill"><span aria-hidden="true">""</span>"Public"</span>
<button type="button" class="wolai-icon-button" title="收藏" aria-label="收藏">""</button>
<button type="button" class="wolai-icon-button" title="历史" aria-label="历史">""</button>
<button type="button" class="wolai-icon-button wolai-ai-inline" title="AI" aria-label="AI">""</button>
<button type="button" class="wolai-icon-button" title="搜索" aria-label="搜索">""</button>
<button type="button" class="wolai-icon-button" title="更多" aria-label="更多">""</button>
</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>
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button wolai-floating-button--help" aria-label="帮助">"?"</button>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手">""</button>
</div>
</div>
</div>
+700 -1
View File
@@ -865,6 +865,705 @@ a:hover {
padding-bottom: 16px;
}
}
/* ===== Stitch 260429 UI parity overrides ===== */
:root {
--atelier-surface: #FAF9F9;
--atelier-sidebar: #F4F3F3;
--atelier-sidebar-hover: #ECEBE9;
--atelier-sidebar-active: #E3E2E2;
--atelier-document: #FFFFFF;
--atelier-text: #1B1C1C;
--atelier-text-muted: #8A8782;
--atelier-text-soft: #B7B4AF;
--atelier-outline: #EAEAEA;
--atelier-primary: #006E28;
--atelier-primary-container: #31AA4D;
--atelier-secondary: #2367F6;
--atelier-shadow: 0 16px 32px rgba(27, 28, 28, 0.04);
}
html,
body {
background: var(--atelier-document);
}
body {
color: var(--atelier-text);
font-family: Inter, var(--wolai-font-sans);
letter-spacing: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.mnote-shell,
.wolai-workspace-shell {
background: var(--atelier-document);
}
.mnote-sidebar,
.wolai-sidebar {
width: 240px;
background: var(--atelier-sidebar);
border-right: 0;
box-shadow: inset -1px 0 0 rgba(27, 28, 28, 0.06);
overflow-x: hidden;
}
.mnote-sidebar-header,
.wolai-sidebar-header {
height: 68px;
padding: 16px 12px 8px;
gap: 10px;
}
.wolai-avatar {
width: 32px;
height: 32px;
border-radius: 5px;
background: #D6534D;
font-size: 17px;
font-weight: 650;
}
.wolai-sidebar-header .sidebar-workspace-name,
.sidebar-workspace-name {
max-width: 160px;
color: var(--atelier-text);
font-size: 17px;
font-weight: 700;
line-height: 1.1;
}
.wolai-sidebar-chevron {
color: #55514C;
font-size: 17px;
}
.wolai-quick-actions {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
padding: 8px 16px 26px;
}
.wolai-quick-actions a,
.mnote-sidebar-nav a {
border-radius: 4px;
}
.wolai-quick-actions a {
height: 28px;
color: #3F3D39;
font-size: 19px;
}
.wolai-quick-actions a:hover {
background: rgba(27, 28, 28, 0.07);
}
.wolai-sidebar-body {
padding-bottom: 0;
}
.wolai-sidebar-section {
padding: 0 8px 18px;
}
.wolai-section-title,
.wolai-sidebar-tabs {
height: 30px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
color: var(--atelier-text-muted);
font-size: 14px;
font-weight: 500;
}
.wolai-section-icon {
color: #FF9F1A;
}
.wolai-section-caret {
color: var(--atelier-text-soft);
font-size: 13px;
}
.wolai-sidebar-tabs {
gap: 16px;
margin-bottom: 6px;
}
.wolai-sidebar-tab {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--atelier-text-muted);
font: inherit;
line-height: 1;
white-space: nowrap;
cursor: pointer;
}
.wolai-sidebar-tab:hover {
color: var(--atelier-text);
}
.wolai-sidebar-tab-active {
color: var(--atelier-text);
border-bottom: 2px solid var(--atelier-primary-container);
}
.wolai-sidebar-tab-muted {
color: #9B9892;
}
.wolai-sidebar-tab-panels {
min-height: 0;
}
.wolai-sidebar-tab-panel[hidden] {
display: none !important;
}
.wolai-folder-icon {
color: #A8A49E;
font-size: 15px;
}
.wolai-section-add {
margin-left: auto;
width: 22px;
height: 22px;
border-radius: 4px;
color: var(--atelier-text-soft);
font-size: 20px;
}
.wolai-section-add:hover {
background: var(--atelier-sidebar-hover);
color: var(--atelier-text);
}
.wolai-page-row {
min-height: 30px;
gap: 7px;
margin: 1px 4px;
padding: 0 8px 0 12px;
border-radius: 4px;
color: #2D2E2E;
font-size: 14px;
line-height: 1.2;
}
.wolai-page-row:hover {
background: var(--atelier-sidebar-hover);
}
.wolai-page-row[data-active="true"],
.wolai-active-row {
background: var(--atelier-sidebar-active);
color: var(--atelier-text);
}
.wolai-row-caret {
width: 12px;
flex: 0 0 12px;
color: #B8B5AF;
font-size: 16px;
}
.wolai-row-icon {
width: 20px;
color: #111111;
font-size: 16px;
}
.wolai-row-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-tree-section {
border-top: 0;
margin-top: 0;
padding-top: 0;
}
.sidebar-tree {
padding: 0;
}
.sidebar-tree .tree-empty,
.wolai-sidebar-empty {
padding: 6px 12px;
color: var(--atelier-text-soft);
font-size: 13px;
}
.sidebar-tree .tree-row {
height: 29px;
gap: 5px;
margin: 1px 4px;
padding: 0 8px;
border-radius: 4px;
color: #2D2E2E;
font-size: 14px;
}
.sidebar-tree .tree-row:hover {
background: var(--atelier-sidebar-hover);
}
.sidebar-tree .tree-row[data-active="true"],
.sidebar-tree .tree-row[data-selected="true"] {
background: var(--atelier-sidebar-active);
color: var(--atelier-text);
}
.sidebar-tree .tree-toggle,
.sidebar-tree .tree-spacer {
width: 16px;
height: 22px;
color: #B8B5AF;
font-size: 13px;
}
.sidebar-tree .tree-toggle:hover {
background: rgba(27, 28, 28, 0.06);
}
.sidebar-tree .tree-kind-badge {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 20px;
color: #111111;
font-size: 15px;
}
.sidebar-tree .tree-kind-badge::before {
content: "□";
}
.sidebar-tree .tree-kind-badge[data-kind="page"]::before {
content: "⌂";
}
.sidebar-tree[data-tree-shell-mode="filetree"] .tree-kind-badge[data-kind="page"]::before,
.sidebar-tree .tree-kind-badge[data-kind="document"]::before {
content: "◇";
}
.sidebar-tree .tree-kind-badge[data-kind="index"]::before {
content: "▤";
color: var(--atelier-secondary);
}
.sidebar-tree .tree-kind-badge[data-kind="folder"]::before {
content: "▱";
color: #D08A1F;
}
.sidebar-tree .tree-link {
padding: 0 2px;
color: inherit;
font-size: 14px;
}
.sidebar-tree .tree-link-title {
font-weight: 400;
}
.sidebar-tree .tree-row[data-active="true"] .tree-link-title {
font-weight: 500;
}
.sidebar-tree .tree-actions {
gap: 1px;
}
.sidebar-tree .tree-action {
width: 20px;
height: 20px;
color: #9A968F;
border-radius: 4px;
}
.sidebar-tree .tree-action:hover {
background: rgba(27, 28, 28, 0.07);
color: var(--atelier-text);
}
.sidebar-tree .tree-node .tree-children .tree-row {
padding-left: 20px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-row {
padding-left: 32px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-row {
padding-left: 44px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-children .tree-row {
padding-left: 56px;
}
.wolai-file-tree-section {
padding-top: 2px;
}
.wolai-explorer-title {
margin-bottom: 4px;
}
.wolai-sidebar-footer {
border-top: 1px solid rgba(27, 28, 28, 0.08);
background: var(--atelier-sidebar);
}
.wolai-footer-entry {
height: 44px;
gap: 8px;
color: #55514C;
font-size: 14px;
}
.wolai-footer-entry + .wolai-footer-entry {
border-left: 1px solid rgba(27, 28, 28, 0.08);
}
.mnote-main,
.mnote-content {
background: var(--atelier-document);
}
.wolai-topbar {
height: 44px;
padding: 0 20px 0 22px;
background: rgba(255, 255, 255, 0.92);
color: #5A5A5A;
font-size: 14px;
line-height: 1;
backdrop-filter: blur(16px);
}
.wolai-topbar-left,
.wolai-topbar-actions {
gap: 10px;
}
.wolai-breadcrumb {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
color: #6D6A65;
}
.wolai-breadcrumb-root {
white-space: nowrap;
}
.wolai-breadcrumb-separator {
color: #D0CDC8;
}
.wolai-breadcrumb-current {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
color: var(--atelier-text);
font-weight: 500;
}
.wolai-home-icon {
color: #111111;
font-size: 16px;
}
.wolai-icon-button {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #5A5A5A;
font: inherit;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.wolai-icon-button:hover {
background: #F4F3F3;
color: var(--atelier-text);
}
.wolai-menu-button {
color: #3D3B37;
font-size: 21px;
}
.wolai-public-pill {
height: 26px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 11px;
border: 1px solid #D6F2DC;
border-radius: 999px;
background: #ECFCEF;
color: #087B31;
font-size: 12px;
font-weight: 600;
}
.wolai-public-pill span {
color: var(--atelier-primary-container);
font-size: 12px;
}
.wolai-ai-inline {
color: var(--atelier-primary-container);
}
.document-shell {
max-width: 880px;
min-height: calc(100vh - 44px);
padding: 78px 48px 160px;
margin: 0 auto;
}
.document-shell-header {
max-width: none;
padding: 0;
margin: 0 0 28px;
}
.document-page-icon {
height: 82px;
display: flex;
align-items: center;
color: #111111;
font-size: 72px;
line-height: 1;
margin-bottom: 12px;
}
.document-shell-header h1 {
padding: 0;
margin: 0 0 14px;
color: var(--atelier-text);
font-size: 42px;
font-weight: 700;
line-height: 1.12;
letter-spacing: 0;
text-align: left;
}
.document-shell-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
padding-bottom: 28px;
border-bottom: 1px solid #F0EFED;
color: #A19D97;
font-size: 14px;
line-height: 1.4;
}
.document-shell-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
}
#mnote-editor-island {
width: 100%;
max-width: none;
min-height: 360px;
margin: 0;
padding: 0 0 48px;
}
#mnote-leptos-tiptap-island-editor-root {
min-height: 320px;
}
#mnote-leptos-tiptap-island-editor-root .editor-surface {
border: 0 !important;
box-shadow: none !important;
background: transparent !important;
padding: 0 !important;
}
#mnote-leptos-tiptap-island-editor-root .ProseMirror,
.ProseMirror {
color: var(--atelier-text);
font-size: 16px;
line-height: 1.65;
outline: none;
}
#mnote-leptos-tiptap-island-editor-root .ProseMirror {
width: 100% !important;
min-height: 280px;
margin: 0 !important;
padding: 0 !important;
}
#mnote-leptos-tiptap-island-editor-root .ProseMirror p {
margin: 0 0 24px;
}
#mnote-leptos-tiptap-island-editor-root .ProseMirror h1,
#mnote-leptos-tiptap-island-editor-root .ProseMirror h2,
#mnote-leptos-tiptap-island-editor-root .ProseMirror h3 {
line-height: 1.2;
letter-spacing: 0;
}
.mnote-workspace-active-state {
padding-top: 10px;
}
.mnote-workspace-active-link {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
color: var(--atelier-text);
font-size: 15px;
text-decoration: underline;
text-decoration-color: #D7D4CE;
text-underline-offset: 5px;
}
.mnote-workspace-active-link::before {
content: "▤";
color: #A19D97;
text-decoration: none;
}
.wolai-floating-actions {
right: 24px;
bottom: 24px;
gap: 10px;
z-index: 50;
}
.wolai-floating-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
cursor: pointer;
}
.wolai-floating-button--help {
width: 40px;
height: 40px;
border: 1px solid rgba(27, 28, 28, 0.1);
background: rgba(255, 255, 255, 0.86);
color: #5A5A5A;
box-shadow: 0 10px 24px rgba(27, 28, 28, 0.08);
backdrop-filter: blur(16px);
font-size: 18px;
}
.wolai-floating-button--ai {
width: 52px;
height: 52px;
border: 0;
background: linear-gradient(135deg, var(--atelier-primary), var(--atelier-primary-container));
color: #FFFFFF;
box-shadow: 0 16px 32px rgba(0, 110, 40, 0.22);
font-size: 23px;
}
@media (max-width: 768px) {
.mnote-sidebar,
.wolai-sidebar {
width: 220px;
}
.wolai-topbar {
padding: 0 12px;
}
.wolai-public-pill,
.wolai-breadcrumb-root,
.wolai-breadcrumb-separator {
display: none;
}
.document-shell {
padding: 48px 28px 120px;
}
.document-page-icon {
height: 60px;
font-size: 54px;
}
.document-shell-header h1 {
font-size: 32px;
}
}
@media (max-width: 480px) {
.mnote-shell {
flex-direction: column;
}
.mnote-sidebar,
.wolai-sidebar {
width: 100%;
max-height: 42vh;
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
box-shadow: none;
}
.document-shell {
padding: 36px 20px 112px;
}
.document-shell-header h1 {
font-size: 28px;
}
.wolai-topbar-actions {
gap: 2px;
}
}
"##;
#[cfg(test)]
@@ -919,6 +1618,6 @@ mod tests {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 最多 20000 字符避免过于臃肿
assert!(MNOTE_CSS.len() < 20000);
assert!(MNOTE_CSS.len() < 32000);
}
}
+39 -8
View File
@@ -102,13 +102,13 @@ pub fn build_workspace_shell_projection(
id: "trash".into(),
label: "垃圾箱".into(),
href: "/trash".into(),
icon: "🗑".into(),
icon: "".into(),
},
WorkspaceShellEntry {
id: "templates".into(),
label: "模板中心".into(),
href: "/templates".into(),
icon: "📦".into(),
icon: "".into(),
},
],
}
@@ -323,6 +323,35 @@ mod tests {
assert!(html.contains("data-mnote-action=\"create-page\""));
}
#[test]
fn workspace_shell_sidebar_html_uses_single_tabbed_tree_host() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": [
{ "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 }
]
});
let projection = build_workspace_shell_projection(
&dataset,
"ws_demo",
Some("doc_root"),
"开发用户 的工作区",
);
let html = render_workspace_shell_sidebar_html(
&projection,
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
);
assert!(html.contains(r#"data-mnote-sidebar-tree-tab="page""#));
assert!(html.contains(r#"data-mnote-sidebar-tree-tab="filetree""#));
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="page""#));
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree" hidden"#));
assert!(html.contains(r#"id="sidebar-tree-root""#));
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
assert!(!html.contains("wolai-file-tree-section"));
}
#[test]
fn workspace_shell_sidebar_html_outputs_empty_state() {
let dataset = json!({
@@ -368,16 +397,18 @@ pub fn render_workspace_shell_sidebar_html(
)
})
.unwrap_or_default();
let file_tree_panel = file_tree_html
let file_tree_content = file_tree_html
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|html| {
format!(
r#"<section class="wolai-sidebar-section wolai-file-tree-section" aria-label="文件树" data-testid="wolai-sidebar-file-tree-section"><div class="wolai-section-title"><span>文件树</span><span class="wolai-section-caret">⌄</span></div><div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}">{html}</div></div></section>"#,
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}">{html}</div></div>"#,
escape_html(&projection.workspace_id),
)
})
.unwrap_or_default();
.unwrap_or_else(|| {
r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-filetree-empty-state">暂无文件或页面</div>"#.to_string()
});
let empty_state = r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-empty-state">暂无页面</div>"#;
let my_pages = if !tree_html.is_empty() {
tree_html
@@ -401,7 +432,7 @@ pub fn render_workspace_shell_sidebar_html(
.join("");
format!(
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div>{my_pages}</section>{file_tree_panel}<div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" aria-controls="wolai-sidebar-file-tree-panel"><span class="wolai-folder-icon">▱</span>Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" hidden>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
escape_html(&projection.workspace_id),
)
}
@@ -411,7 +442,7 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
let depth_style = if item.depth == 0 {
String::new()
} else {
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 18)
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 12)
};
let parent_attr = item
.parent_id
@@ -424,7 +455,7 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
""
};
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-caret" aria-hidden="true"></span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
escape_html(&item.href),
escape_html(&item.id),
item.depth,
@@ -55,6 +55,11 @@ async function main() {
const topbar = page.getByTestId("wolai-topbar");
const floatingAi = page.getByTestId("wolai-floating-ai");
const floatingHelp = page.getByTestId("wolai-floating-help");
const sidebarTreeTabs = page.getByTestId("wolai-sidebar-tree-tabs");
const pageTreePanel = page.locator('[data-mnote-sidebar-tree-panel="page"]').first();
const fileTreePanel = page.locator('[data-mnote-sidebar-tree-panel="filetree"]').first();
const pageTreeTab = page.locator('[data-mnote-sidebar-tree-tab="page"]').first();
const fileTreeTab = page.locator('[data-mnote-sidebar-tree-tab="filetree"]').first();
const content = page.locator(".mnote-content").first();
const staticHomeLinks = page.locator(".mnote-home-links");
const documentShell = page.locator(".document-shell").first();
@@ -67,6 +72,7 @@ async function main() {
const sidebarBox = await boundingBox(sidebar, "左侧栏");
const topbarBox = await boundingBox(topbar, "顶栏");
await boundingBox(sidebarTreeTabs, "页面树/文件树切换栏");
const contentBox = await boundingBox(content, "主内容区");
const primaryContentBox = hasDocumentShell
? await boundingBox(documentShell, "active page shell")
@@ -84,7 +90,30 @@ async function main() {
assert(floatingHelpBox.x > sidebarBox.width, "帮助浮动按钮不应落入左侧栏");
assert(!boxesIntersect(floatingAiBox, primaryContentBox), "AI 浮动按钮不应遮挡主内容");
assert(!boxesIntersect(floatingHelpBox, primaryContentBox), "帮助浮动按钮不应遮挡主内容");
assert(floatingHelpBox.y > floatingAiBox.y, "帮助按钮应位于 AI 按钮下方");
assert(floatingHelpBox.y < floatingAiBox.y, "帮助按钮应位于 AI 按钮上方,匹配 Stitch 目标");
assert.equal(await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').count(), 1, "Explorer 只能作为一个切换 tab 出现");
assert.equal(await page.locator('[data-testid="wolai-sidebar-file-tree-section"]').count(), 0, "文件树不应再渲染成第二个独立 Explorer section");
assert.equal(await pageTreeTab.getAttribute("aria-selected"), "true", "默认应选中我的页面 tab");
assert.equal(await fileTreeTab.getAttribute("aria-selected"), "false", "默认不应选中 Explorer tab");
assert.equal(await pageTreePanel.evaluate((node) => node.hidden), false, "默认应显示页面树 panel");
assert.equal(await fileTreePanel.evaluate((node) => node.hidden), true, "默认应隐藏文件树 panel");
await fileTreeTab.click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const filePanel = document.querySelector('[data-mnote-sidebar-tree-panel="filetree"]');
const pagePanel = document.querySelector('[data-mnote-sidebar-tree-panel="page"]');
return filePanel instanceof HTMLElement && pagePanel instanceof HTMLElement && !filePanel.hidden && pagePanel.hidden;
}, null, { timeout: UI_TIMEOUT_MS });
await boundingBox(fileTreePanel, "Explorer 文件树 panel");
assert.equal(await fileTreeTab.getAttribute("aria-selected"), "true", "点击后应选中 Explorer tab");
await pageTreeTab.click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const filePanel = document.querySelector('[data-mnote-sidebar-tree-panel="filetree"]');
const pagePanel = document.querySelector('[data-mnote-sidebar-tree-panel="page"]');
return filePanel instanceof HTMLElement && pagePanel instanceof HTMLElement && filePanel.hidden && !pagePanel.hidden;
}, null, { timeout: UI_TIMEOUT_MS });
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB