Files
mnote/design/stage1.md
T

323 lines
11 KiB
Markdown
Raw Permalink Normal View History

2025-11-23 10:55:04 +08:00
```markdown
# 阶段 1Wolai 核心页面树 + BlockNote 编辑器(最大程度克隆 Wolai UI 与交互)
**目标**4-6 天内(AI 助手单人 8-12 小时)完成一个视觉与交互 95% 接近真实 Wolai(光模式)的核心编辑体验。
**验收标准**(可直接写 Cypress 测试):
- 左侧 240px 树状侧边栏无限嵌套、可拖拽排序/嵌套、实时同步
- 主编辑区 BlockNote 块编辑器(斜杠命令、Tab 缩进、拖拽柄、子页面块渲染为蓝色链接)
- 顶部面包屑完整(≡ 展开侧边栏 + 路径跳转 + 星标/分享/更多)
- 底部固定 AI 工具栏(发送/魔力中心/?/AI 按钮)
- 暗黑模式自动适配、<768px 侧边栏折叠、首屏加载 <1.2s
- 所有颜色/圆角/间距/字体 100% 按 ui_react.md 规范(Inter 字体、#2563eb 主蓝、hover #f5f5f5
**本阶段只做阶段 1 内容**,不提前实现思维导图、OCR、AI 聊天侧边栏。
## 1. 颜色与全局样式(必须先做,Wolai 克隆关键)
```ts
// src/app/globals.css (在现有 Tailwind 基础上追加)
@import url('https://rsms.me/inter/inter.css');
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.wolai-hover {
@apply hover:bg-gray-50 transition-colors duration-150;
}
.wolai-selected {
@apply bg-blue-50 border-l-2 border-blue-500;
}
.wolai-blue {
@apply text-[#2563eb] hover:underline cursor-pointer;
}
}
```
```ts
// tailwind.config.ts 追加 Wolai 精确色值
theme: {
extend: {
colors: {
wolai: {
blue: '#2563eb',
hover: '#f5f5f5',
border: '#eeeeee',
text: '#333333',
muted: '#666666',
light: '#999999',
},
},
borderRadius: {
wolai: '4px',
},
spacing: {
wolai: '8px',
},
},
},
```
## 2. 整体布局组件(严格两栏 + 面包屑 + 底部栏)
```tsx
// src/app/(app)/layout.tsx
import { Sidebar } from '@/components/sidebar/Sidebar';
import { Breadcrumb } from '@/components/Breadcrumb';
import { BottomToolbar } from '@/components/BottomToolbar';
import { MobileSidebarTrigger } from '@/components/MobileSidebarTrigger';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen bg-white dark:bg-black">
{/* 左侧侧边栏 - 响应式折叠 */}
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
{/* 顶部面包屑 40px */}
<header className="h-10 border-b border-[#eeeeee] flex items-center px-4 gap-4">
<MobileSidebarTrigger />
<Breadcrumb />
</header>
{/* 主内容区 */}
<main className="flex-1 overflow-hidden">{children}</main>
{/* 底部固定工具栏 */}
<BottomToolbar />
</div>
</div>
);
}
```
## 3. 递归树状侧边栏(240px 精确复刻)
```tsx
// src/components/sidebar/Sidebar.tsx
'use client';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase/client';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; // 推荐比 react-dnd 更轻量
interface PageNode {
id: string;
title: string;
children: PageNode[];
hasChildren: boolean;
}
export function Sidebar() {
const [pages, setPages] = useState<PageNode[]>([]);
const [openIds, setOpenIds] = useState<Set<string>>(new Set());
// 递归 CTE 查询所有层级(一次性拉取用户全部页面树)
const fetchTree = async () => {
const { data } = await supabase.rpc('get_documents_tree', { userId);
setPages(data || []);
};
// 拖拽结束 → 调用 Supabase function 更新 parent_id 和排序
const onDragEnd = async (result: any) => { /* 实现 parent_id 与顺序更新 */ };
return (
<div className="w-60 border-r border-[#eeeeee] flex flex-col">
{/* 搜索区 */}
<div className="p-2">
<input className="w-full px-2 py-1 text-sm border-b border-gray-300 focus:border-[#2563eb] outline-none" placeholder="搜索..." />
</div>
{/* 树 */}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sidebar">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex-1 overflow-y-auto">
{pages.map((page, index) => (
<SidebarItem key={page.id} page={page} index={index} depth={0} openIds={openIds} setOpenIds={setOpenIds} />
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
);
}
// SidebarItem 递归组件(缩进 16px> 展开箭头,hover 显示拖拽柄)
const SidebarItem = ({ page, index, depth }: { page: PageNode; index: number; depth: number }) => {
const hasChildren = page.children.length > 0;
return (
<Draggable draggableId={page.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className="flex items-center gap-1 py-0.5 pl-[calc(var(--depth)*16px + 8px)] wolai-hover"
>
{/* 展开箭头 + 拖拽柄(hover 显示) */}
<div className="w-5 opacity-0 group-hover:opacity-100" {...provided.dragHandleProps}>⋮⋮</div>
{hasChildren && <button>></button>}
<div className="flex-1 text-sm text-[#333] truncate">{page.title || '未命名'}</div>
</div>
)}
</Draggable>
);
};
```
> Supabase RPC 函数(SQL Editor 执行):
```sql
create or replace function get_documents_tree(user_id uuid)
returns table (id uuid, title text, parent_id uuid, depth integer, path text[])
language sql as $$
with recursive tree as (
select d.id, d.title, d.parent_id, 0 as depth, array[d.id] as path
from documents d
where d.parent_id is null and d.user_id = user_id
union all
select d.id, d.title, d.parent_id, t.depth + 1, t.path || d.id
from documents d
join tree t on d.parent_id = t.id
where d.user_id = user_id
)
select id, title, parent_id, depth
from tree
order by path;
$$;
```
## 4. BlockNote 编辑器页面(/documents/[id]/page.tsx
```tsx
// src/app/(app)/documents/[id]/page.tsx
import { BlockNoteEditor } from '@/components/editor/BlockNoteEditor';
import { Skeleton } from '@/components/ui/skeleton';
export default async function DocumentPage({ params }: { params: { id: string } }) {
const supabase = createSupabaseServer();
const { data: doc } = await supabase
.from('documents')
.select('title, content')
.eq('id', params.id)
.single();
if (!doc) return <div>404</div>;
return (
<div className="flex flex-col h-full">
{/* 标题 */}
<h1 className="text-3xl font-bold px-12 pt-8 pb-2">{doc.title}</h1>
{/* BlockNote 编辑器(高度占满) */}
<div className="flex-1 overflow-y-auto px-12">
<BlockNoteEditor documentId={params.id} initialContent={doc.content} />
</div>
</div>
);
}
```
```tsx
// src/components/editor/BlockNoteEditor.tsx (核心克隆点)
'use client';
import { BlockNoteView, useCreateBlockNote } from '@blocknote/react';
import { useEffect, useRef } from 'react';
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
export function BlockNoteEditor({ documentId, initialContent }: { documentId: string; initialContent: any }) {
const editor = useCreateBlockNote({
initialContent,
// 自定义斜杠命令菜单样式与 Wolai 一致(圆角 4px,浅灰背景)
slashMenuTheme: { item: 'px-3 py-1.5 text-sm wolai-hover' },
});
// 实时协作(Hocuspocus + Supabase Realtime
useEffect(() => {
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/realtime/v1`,
name: `document.${documentId}`,
token: 'your-supabase-anon-key', // 实际用 JWT
});
// 绑定 BlockNote → Yjs
// ... 官方示例代码
// 防抖 800ms 保存到 Supabase documents.content
const debounceSave = debounce(async (content) => {
await fetch('/api/documents/save', {
method: 'POST',
body: JSON.stringify({ id: documentId, content }),
});
}, 800);
editor.onEditorContentChange(() => debounceSave(editor.document));
}, []);
return (
<BlockNoteView
editor={editor}
theme="light"
className="wolai-editor" // 自定义 CSS 实现 Wolai 精确间距
/>
);
}
```
```css
/* 自定义 CSS 让 BlockNote 100% Wolai 风格 */
.wolai-editor {
--bn-colors-editor-text: #333333;
--bn-colors-editor-background: #ffffff;
--bn-radii: 4px;
--bn-spacing: 8px;
.bn-block-outer { padding: 2px 8px; }
.bn-block-group:hover .bn-drag-handle { opacity: 1; } /* 左侧拖拽柄 hover 显示 */
.bn-inline-content a { color: #2563eb; text-decoration: underline; }
}
```
## 5. 面包屑 + 底部工具栏(精确像素级克隆)
```tsx
// src/components/Breadcrumb.tsx (路径链接可点,≡ 展开侧边栏)
```
```tsx
// src/components/BottomToolbar.tsx
<div className="fixed bottom-0 left-60 right-0 h-12 bg-white border-t border-[#eeeeee] flex items-center justify-end px-8 gap-4">
<button className="text-sm text-gray-500">发送</button>
<button className="w-8 h-8 rounded-full bg-[#2563eb] text-white flex items-center justify-center">AI</button>
<button className="text-gray-500">?</button>
</div>
```
## 6. 交给 AI 编码助手的完整 Prompt(直接复制)
```
你现在是一个顶级 Next.js + BlockNote 专家。仓库已完成阶段0Next.js15 App Router + Supabase + shadcn + 数据库表)。
请严格按照 ui_react.md 文档实现 Wolai 克隆:
1. 实现递归侧边栏 Sidebar(宽度 240px,缩进 16px,拖拽用 @hello-pangea/dndhover 显示拖拽柄,支持无限嵌套,颜色 #333/#666/#999
2. 实现 /documents/[id]/page.tsx 使用 BlockNoteView,标题 24px 粗体,内容区 px-12,块间距 8px,左侧拖拽柄 hover 显示,子页面块渲染为蓝色可点链接
3. 实现顶部面包屑(高度 40px,≡ 触发侧边栏,路径链接可跳转,右侧星标/分享/更多)
4. 实现固定底部工具栏(AI 按钮蓝色圆形 32px)
5. 所有圆角 4px、间距 8px 基准、Inter 字体、hover #f5f5f5、选中左侧蓝边
6. 实时协作用 @hocuspocus/provider + Supabase Realtime800ms 防抖保存到 documents.content JSONB
7. 移动端 <768px 侧边栏隐藏,用 ≡ 按钮展开 Drawer
8. 完全使用 Server Components + Server Actions,首屏 skeleton 加载
请输出完整代码文件列表与代码,不要省略任何细节。
```
**阶段 1 到此结束**。执行完后运行 `pnpm dev` 应该看到一个几乎一模一样的 Wolai 界面(除思维导图外),拖拽、嵌套、实时保存全部正常。
下一步再做阶段 2(斜杠命令增强 + 嵌入块 + 模板)。存为 `PHASE_1_WOLAI_CORE_UI.md`
```