Files
mnote/design/stage1.md
T
2025-11-23 10:55:04 +08:00

11 KiB
Raw Permalink Blame History

# 阶段 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;
  }
}
// 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. 整体布局组件(严格两栏 + 面包屑 + 底部栏)

// 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 精确复刻)

// 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 执行):

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

// 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>
  );
}
// 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 让 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. 面包屑 + 底部工具栏(精确像素级克隆)

// src/components/Breadcrumb.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