3.7 项目实战——VibeNote V3.0 专业 UI 界面与交互体验升级

0 阅读21分钟

模块三:产品设计与前端实战 | 第07讲:项目实战——VibeNote V3.0 专业 UI 界面与交互体验升级

目标:在 V2.0(Markdown + AI 工具栏)基础上升级到 V3.0shadcn/ui 专业布局(侧栏 + 主工作区)、暗色模式响应式(移动 Sheet + Tabs,桌面双栏)、并保持与模块二 AI Route 兼容。
:Next.js 14 App Router、React、TypeScript、Tailwind CSS、shadcn/ui、next-themes


一、V3.0 的「产品一句话」

桌面:左侧笔记列表,右侧「编辑 + 预览」双栏;顶区工具栏承载保存与 AI。
移动:侧栏进 Sheet;编辑与预览用 Tabs 切换,避免挤压。
通用:暗色模式可切换;Loading/Empty/Error 不缺失;AI 仍走服务端路由。

把 V3.0 当成一次「产品级整容」而不是「换个皮肤」:整容的目标是让用户更愿意完成主路径,而不是让你截图更好看。若你做完 V3 发现指标没变化,通常不是 UI 无效,而是你验证的假设与改动点不对齐——请回到第01~03讲把赌注写清楚。

本讲代码量显著变多,这是刻意的:Vibe Coding 的进阶门槛从「写出代码」迁移到「组织代码」。你会发现,真正耗时的是边界与约束,不是语法。为了让边界可见,本讲在代码之外提供了大段「为什么」:它们与模块三的 PRD/UX/组件化/响应式一一对应,建议你按顺序对照阅读,而不是跳跃复制。


二、架构总览(Mermaid)

flowchart TB
  subgraph Client["Client Components"]
    L["AppSidebar / Sheet"]
    T["NoteToolbar"]
    E["NoteEditorPanel"]
    P["MarkdownPreview"]
    AI["AIWritingToolbar (V2)"]
  end
  subgraph Server["Server / API"]
    R["app/api/ai/writing"]
  end
  L --> E
  T --> AI
  AI --> R
  E --> P

三、路由与数据流(Mermaid)

sequenceDiagram
  participant U as 用户
  participant Page as /notes/[id]
  participant Shell as EditorShell
  participant API as /api/ai/writing
  U->>Page: 打开笔记
  Page->>Shell: props(title, body)
  U->>Shell: 编辑 Markdown
  Shell-->>U: 预览更新
  U->>API: AI 摘要/扩写/润色
  API-->>U: 流式文本

这张时序图刻意把「打开笔记」与「AI 调用」分开:前者应尽量快且稳定,后者允许慢但必须可恢复。实际实现中,你可以在工具栏层统一处理 busy 状态,避免编辑器与工具栏各做一套 loading 造成 UI 分裂。若你未来接入流式输出,时序图会在「API-->>U」阶段变成多次增量返回,但用户心智仍是同一条:我在等模型,我可以中止或重试


四、依赖与 shadcn 组件(在项目根目录执行)

pnpm add next-themes lucide-react class-variance-authority clsx tailwind-merge
pnpm add react-markdown remark-gfm
pnpm add ai @ai-sdk/openai zod
pnpm add -D @tailwindcss/typography

# shadcn/ui(按 CLI 交互选择 style;以下组件名为常见集合)
npx shadcn@latest add button sheet scroll-area separator tabs tooltip sonner alert alert-dialog dropdown-menu

tailwind.config.ts 增加 @tailwindcss/typography 插件;globals.css 使用 shadcn 生成的 CSS 变量主题(安装主题时会提示)。

安装过程中若 CLI 询问样式变量与基础色,请优先选择与笔记应用匹配的中性色盘;不要为了「好看」选择过高饱和主题,否则长文阅读会疲劳。安装完成后务必跑一次 pnpm dev 验证 Button 是否正常渲染,再进入本讲的大段粘贴——否则你会在最后一刻才发现 Tailwind 内容路径或 PostCSS 配置有问题。


五、components/theme-provider.tsx

// components/theme-provider.tsx
"use client";

import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";

export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

六、app/layout.tsx(包裹 ThemeProvider + Toaster)

// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";

export const metadata: Metadata = {
  title: "VibeNote",
  description: "Vibe Coding 智能笔记",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN" suppressHydrationWarning>
      <body className="min-h-dvh bg-background text-foreground antialiased">
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
          <Toaster richColors closeButton />
        </ThemeProvider>
      </body>
    </html>
  );
}

七、components/mode-toggle.tsx(暗色切换)

// components/mode-toggle.tsx
"use client";

import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export function ModeToggle() {
  const { setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon" className="relative" aria-label="切换主题">
          <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>浅色</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>深色</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>跟随系统</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

七补、components/editor-shell.tsx(布局壳:与第05讲一致)

若你已在第05讲创建该文件可跳过。此处完整给出,确保本讲可独立粘贴运行。

// components/editor-shell.tsx
import type { ReactNode } from "react";
import { Separator } from "@/components/ui/separator";

export function EditorShell(props: {
  sidebar: ReactNode;
  main: ReactNode;
  toolbar?: ReactNode;
}) {
  return (
    <div className="flex h-dvh w-full min-h-0 flex-col md:flex-row">
      <aside className="hidden h-full w-72 shrink-0 border-r md:block">{props.sidebar}</aside>
      <div className="flex min-h-0 min-w-0 flex-1 flex-col">
        {props.toolbar}
        {props.toolbar ? <Separator /> : null}
        <div className="min-h-0 flex-1 overflow-hidden">{props.main}</div>
      </div>
    </div>
  );
}

说明h-dvh 解决移动端视口高度抖动的一部分问题;min-h-0 再次强调,避免 flex 子项把滚动搞崩。这个壳越「笨」,你越不容易在迭代里意外破坏布局。


八、components/app-sidebar.tsx(桌面侧栏 + 移动入口由 Sheet 包裹)

// components/app-sidebar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";

export type NoteItem = { id: string; title: string; updatedAt: string };

export function AppSidebar(props: {
  notes: NoteItem[];
  onCreate?: () => void;
}) {
  const pathname = usePathname();

  return (
    <div className="flex h-full flex-col gap-2 p-3">
      <div className="flex items-center justify-between gap-2">
        <div className="text-sm font-semibold">笔记</div>
        <Button size="sm" onClick={props.onCreate}>
          新建
        </Button>
      </div>
      <ScrollArea className="min-h-0 flex-1 pr-2">
        <div className="space-y-1">
          {props.notes.map((n) => {
            const active = pathname?.includes(n.id);
            return (
              <Link
                key={n.id}
                href={`/notes/${n.id}`}
                className={cn(
                  "block rounded-md px-2 py-2 text-sm transition-colors",
                  active
                    ? "bg-muted font-medium"
                    : "hover:bg-muted/60"
                )}
              >
                <div className="truncate">{n.title || "(无标题)"}</div>
                <div className="text-xs text-muted-foreground">{n.updatedAt}</div>
              </Link>
            );
          })}
        </div>
      </ScrollArea>
    </div>
  );
}

九、components/mobile-sidebar.tsx(Sheet 包侧栏)

// components/mobile-sidebar.tsx
"use client";

import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { AppSidebar, type NoteItem } from "@/components/app-sidebar";

export function MobileSidebar(props: {
  notes: NoteItem[];
  onCreate?: () => void;
}) {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button variant="outline" size="icon" className="md:hidden" aria-label="打开笔记列表">
          <Menu className="h-4 w-4" />
        </Button>
      </SheetTrigger>
      <SheetContent side="left" className="w-80 p-0">
        <AppSidebar notes={props.notes} onCreate={props.onCreate} />
      </SheetContent>
    </Sheet>
  );
}

十、components/note-editor-panel.tsx(响应式:Tabs / Split)

// components/note-editor-panel.tsx
"use client";

import { useDeferredValue, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";

export function NoteEditorPanel(props: {
  body: string;
  onBodyChange: (next: string) => void;
  className?: string;
}) {
  const deferred = useDeferredValue(props.body);
  const markdown = useMemo(() => deferred, [deferred]);
  const [tab, setTab] = useState<"edit" | "preview">("edit");

  const preview = (
    <div
      className={cn(
        "prose prose-neutral dark:prose-invert max-w-none min-h-[50vh] rounded-md border bg-card p-4",
        "overflow-auto"
      )}
    >
      <ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown>
    </div>
  );

  const editor = (
    <Textarea
      id="vibenote-md"
      value={props.body}
      onChange={(e) => props.onBodyChange(e.target.value)}
      className="min-h-[50vh] resize-y font-mono text-sm"
      spellCheck={false}
    />
  );

  return (
    <div className={cn("min-h-0 flex-1", props.className)}>
      {/* 小屏:Tabs */}
      <div className="md:hidden">
        <Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
          <TabsList className="w-full">
            <TabsTrigger className="flex-1" value="edit">
              编辑
            </TabsTrigger>
            <TabsTrigger className="flex-1" value="preview">
              预览
            </TabsTrigger>
          </TabsList>
          <TabsContent value="edit" className="mt-3">
            {editor}
          </TabsContent>
          <TabsContent value="preview" className="mt-3">
            {preview}
          </TabsContent>
        </Tabs>
      </div>

      {/* 桌面:双栏 */}
      <div className="hidden min-h-0 flex-1 gap-4 md:grid md:grid-cols-2">
        <div className="min-h-0">{editor}</div>
        <div className="min-h-0 overflow-auto">{preview}</div>
      </div>
    </div>
  );
}

需要 npx shadcn@latest add textarea


十一、接入模块二 AIWritingToolbar 的薄封装

保持 V2 的 AIWritingToolbar/api/ai/writing 不变,只在 V3 布局中引用。示例 components/note-workspace.tsx

// components/note-workspace.tsx
"use client";

import { useState } from "react";
import { Separator } from "@/components/ui/separator";
import { EditorShell } from "@/components/editor-shell";
import { AppSidebar, type NoteItem } from "@/components/app-sidebar";
import { MobileSidebar } from "@/components/mobile-sidebar";
import { ModeToggle } from "@/components/mode-toggle";
import { NoteEditorPanel } from "@/components/note-editor-panel";
import { AIWritingToolbar } from "@/components/AIWritingToolbar";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";

export function NoteWorkspace(props: {
  noteId: string;
  title: string;
  initialBody: string;
  notes: NoteItem[];
}) {
  const [body, setBody] = useState(props.initialBody);

  return (
    <EditorShell
      sidebar={
        <div className="hidden h-full md:block">
          <AppSidebar
            notes={props.notes}
            onCreate={() => toast.message("TODO: 创建笔记路由")}
          />
        </div>
      }
      toolbar={
        <div className="flex flex-wrap items-center gap-2 border-b bg-background/80 px-3 py-2 backdrop-blur">
          <div className="flex items-center gap-2 md:hidden">
            <MobileSidebar
              notes={props.notes}
              onCreate={() => toast.message("TODO: 创建笔记路由")}
            />
          </div>
          <div className="min-w-0 flex-1 truncate text-sm font-medium">{props.title}</div>
          <AIWritingToolbar
            title={props.title}
            getContent={() => body}
            getSelection={() => {
              const ta = document.getElementById("vibenote-md") as HTMLTextAreaElement | null;
              if (!ta) return "";
              return body.slice(ta.selectionStart, ta.selectionEnd);
            }}
            onSummarize={(text) => toast.message("摘要", { description: text.slice(0, 140) })}
            onReplaceBody={(next) => setBody(next)}
          />
          <Button size="sm" variant="secondary" onClick={() => toast.success("已保存(示例)")}>
            保存
          </Button>
          <ModeToggle />
        </div>
      }
      main={
        <div className="flex min-h-0 flex-1 flex-col gap-3 p-3">
          <NoteEditorPanel
            body={body}
            onBodyChange={setBody}
          />
          <Separator />
          <p className="text-xs text-muted-foreground">
            提示:`Textarea` 已设置 `id="vibenote-md"` 以兼容模块二 `AIWritingToolbar` 的选区读取逻辑。
          </p>
        </div>
      }
    />
  );
}

十二、app/notes/[id]/page.tsx(示例页面)

// app/notes/[id]/page.tsx
import { NoteWorkspace } from "@/components/note-workspace";

export default function Page({ params }: { params: { id: string } }) {
  const notes = [
    { id: "demo-1", title: "会议纪要", updatedAt: "今天" },
    { id: params.id, title: "当前笔记", updatedAt: "刚刚" },
  ];

  return (
    <main className="h-dvh">
      <NoteWorkspace
        noteId={params.id}
        title="VibeNote V3.0 示例"
        initialBody={"# Hello V3\n\n- item\n"}
        notes={notes}
      />
    </main>
  );
}

真实项目请替换为 V1 数据层 getNote / listNotes


十三、AGENTS.md 增补(V3)

## VibeNote V3 UI
- 布局组件:`EditorShell` / `NoteWorkspace`;不要随意把 fetch 写进 shell。
- 响应式:md 以下 `Sheet` + `Tabs`;md 以上双栏。
- 主题:只用语义 token;客户端主题用 `next-themes`- AI:继续只走 `app/api/ai/writing`;禁止客户端密钥。

十三补、为什么 V3 要把「布局」从页面里抽成 EditorShell

在 V2 里,编辑器页面常常把所有东西写进一个 page.tsx:列表、工具栏、编辑器、预览全堆一起。短期很快,长期很痛:你只要改一次工具栏布局,就可能误伤预览区滚动。V3 的策略是:把稳定结构固化成壳(shell),把易变内容(列表数据、当前笔记、AI 状态)留在组合层。EditorShell 只做三件事:给侧栏区域、给工具栏区域、给主区域——它不关心笔记内容是什么。这样你在第05讲学到的组件边界,会在实战里真正产生复利。


十三补 2、与 PRD 的对照:V3 实现了哪些「Must」

回到第03讲 PRD 思路,本实战默认以下 Must 已落地:桌面双栏编辑预览;移动 Tabs;侧栏导航;暗色模式;AI 工具栏接入;基础无障碍(图标按钮 aria-label、Sheet 触发器标签)。Won't 仍然包括:协同编辑、离线冲突合并、复杂权限。若你的赌注不同,请改 PRD 后再改代码,不要反过来。


十三补 3、NoteEditorPanel 的设计取舍:为什么预览用 prose

@tailwindcss/typographyprose 会提供合理的标题、列表、代码块样式,让 Markdown 预览「像阅读文章」而不是「像裸 HTML」。代价是它可能与你全局主题略有张力,需要通过 prose-neutral / dark:prose-invert 调和。若你更偏好极简预览,也可以缩小 prose 范围,但不要在没设计系统的情况下手写一堆 h1/h2 样式——那样 AI 很容易生成不一致的排版。


十三补 4、滚动为什么仍然是本讲「隐藏主角」

你已经在上两讲听过 min-h-0。在 V3 里,EditorShell 的主区域与 NoteEditorPanel 内部都必须坚持:外层控制高度预算,内层承担滚动。否则会出现「页面跟着抖、光标跟着跳」的经典移动端问题。若你观察到滚动条出现在错误层级,优先检查:是否缺少 min-h-0、是否把 h-screen 写死、是否在 flex 子项上缺少 overflow-auto


十三补 5、暗色模式:为什么一定要 suppressHydrationWarning

next-themes 在服务端与客户端初次渲染可能对 class 不一致,React 会警告 hydration mismatch。layout.tsxhtml 上设置 suppressHydrationWarning 是常见解法。不要把它当成「压制错误」的偷懒:它是主题系统在 SSR 框架下的已知契约。同时要确保你不要同时在别处强行写死浅色背景,否则暗色切换会出现「半黑半白」。


十三补 6、与模块二 AI Route 的兼容策略:尽量「少改服务端,多改壳」

V3 的核心升级在前端壳层。服务端 app/api/ai/writing/route.ts 应尽量保持稳定,让你的回归测试成本最低。若你必须改 prompt 或字段,请走 PRD 版本化与 ADR,不要悄悄改。前端侧,AIWritingToolbar 若仍读取 #vibenote-md,你就要保证编辑器始终存在该 id——本讲已在 Textarea 固化。


十三补 7、保存按钮为什么是 toast 示例:别停在 demo

讲义里保存用 toast.success 是刻意留白:它逼迫你自己把「保存」接回真实数据源,并思考三态:saving、success、error。你可以把保存逻辑放进 useMutation 或 Server Action(后续模块),但 UX 必须遵循第04讲:失败可重试、成功可感知、过程不可重复提交。


十三补 8、移动端侧栏:为什么用 Sheet 而不是永久抽屉

永久抽屉会侵占写作空间,而笔记应用的主任务是写作。Sheet 提供「按需出现」的导航体验,更符合移动优先。若你未来做平板横屏,可以考虑 md 以上固定侧栏、md 以下 Sheet 的组合——本讲示例已按此思路拆分:md:blockmd:hidden 成对出现,减少条件渲染遗漏。


十三补 9、NoteWorkspace 为什么是 client component

因为内部需要 useState 管理 body,并承载 AI 工具栏与主题切换等客户端交互。不要把「整个 app 都 client」当作结论:page.tsx 仍可 server,把数据预取后作为 props 传入 NoteWorkspace(示例简化为硬编码,便于你复制运行)。这也是 Next.js 14 常见的「壳 client、数据 server」组合。


十三补 10、样式一致性:如何避免 AI 在 V3 里引入「第二套按钮」

在提示词里明确:所有按钮用 shadcn Button 变体;禁止原生 button 除非有特殊理由。否则你会得到一半 ghost、一半 default、一半自定义 class 的混乱界面。混乱不只是丑,更会让用户无法预测点击后果——这是 UX 问题。


十三补 11、从「能跑」到「能演示」:建议你录一条 60 秒演示视频脚本

  1. 桌面打开笔记,展示侧栏切换。
  2. 编辑 Markdown,预览实时更新。
  3. 切换到手机宽度,展示 Sheet 与 Tabs。
  4. 切换暗色模式。
  5. 触发一次 AI 摘要并展示错误重试(可故意断网)。

这条脚本同时也是你的回归清单。


十三补 12、常见故障排查表(三连跪时按顺序查)

  1. ThemeProvider 是否包裹?Toaster 是否在 client 树可用?
  2. lib/utilscn 是否存在(shadcn 依赖)?
  3. Textarea / Tabs / Sheet 组件是否已 add?
  4. Tailwind content glob 是否覆盖 components
  5. AI 路由环境变量是否仍在服务端?

十三补 13、性能建议:大文档下的预览策略

若笔记正文极长,预览可以限制最大渲染字数或对代码块折叠(后续增强)。当前示例用 useDeferredValue 降低输入阻塞,这是模块二方法的延续。不要在输入事件里做重计算;更不要把整个 AST 解析放在主线程的每个 keypress 上。


十三补 14、无障碍补刀:Sheet 打开后的焦点管理

Radix Sheet 一般会处理焦点陷阱,但如果你在 Sheet 内放自动聚焦输入框,注意不要被移动端软键盘策略干扰。若你发现焦点异常,优先检查是否有自定义 pointer-events 或错误嵌套 Dialog


十三补 15、与课程「Vibe Coding 方法论」对齐:Workflow 怎么跑本讲

推荐 PR 切分:PR-A 主题与 layout;PR-B shell + sidebar;PR-C editor panel + workspace;PR-D 接入 AI 工具栏与联调。每段都可独立回滚,符合模块二的分段合并策略。


十三补 16、你完成 V3 后最值得写的复盘问题

「用户有没有更理解 VibeNote 是什么?」如果答案是不确定,说明 UI 升级没有服务第01讲的验证目标——你只是更漂亮了。请回到指标与主路径,继续迭代。


十三补 17、扩展思考题(加分)

  1. 如何把侧栏选中态与 URL 更深度绑定(active 样式、可分享)?
  2. 如果要在 V3 加「全文搜索」,你会放 toolbar 还是 sidebar?为什么?
  3. 如何把 NoteWorkspace 拆成 server wrapper + client core 以降低 client bundle?

十三补 18、结语:V3 不是终点,是「可展示的增长基点」

当你能拿着 V3 去给人演示,你获得的不是虚荣,而是更快的外部反馈。把反馈接回第01讲假设登记簿,你就完成了一次完整的产品循环。


十三补 19、从「代码粘贴」到「工程合并」:你需要改动的文件清单(Checklist)

  1. app/layout.tsx:ThemeProvider + Toaster
  2. components/theme-provider.tsx
  3. components/mode-toggle.tsx
  4. components/editor-shell.tsx(来自第05讲;若你仓库没有请复制)
  5. components/app-sidebar.tsx
  6. components/mobile-sidebar.tsx
  7. components/note-editor-panel.tsx
  8. components/note-workspace.tsx
  9. app/notes/[id]/page.tsx
  10. components/AIWritingToolbar.tsx + app/api/ai/writing/route.ts(沿用模块二)

把清单写进 PR,你的合并过程会像流水线,而不是像探险。


十三补 20、lib/utils.tscn:shadcn 的地基

几乎所有 shadcn 组件都依赖 cn。若你新建项目,确保 lib/utils.ts 存在且 tsconfig paths 配置 @/*。很多「组件编译不过」其实不是组件问题,而是别名与工具函数缺失。


十三补 21、TypeScript:本讲示例的类型边界

示例里 params 在 Next 15 可能变为 Promise,需要按你项目版本调整。教学上我们使用 Next 14 常见写法:以你本地 pnpm build 报错为准,让 AI 按报错修订。不要把讲义当不可变真理,要把讲义当可执行起点。


十三补 22、从视觉到信任:V3 如何提升「像产品」的感觉

用户判断是不是玩具,往往来自细节集合:是否有一致间距、是否有像样子的空状态、是否有可靠错误提示、是否有清晰导航。V3 的价值是把 V2 的「能力」包装成「可信任的形态」。这直接影响第01讲验证阶段用户愿不愿意继续试用。


十三补 23、与 sonner 的配合:toast 不是万能,但不要滥用

toast 适合轻反馈;阻断型错误用 AlertAlertDialog。V3 工具栏区域空间紧张,优先 toast,但若保存失败涉及数据风险,应升级为更明确的面板提示。


十三补 24、下一步你可以做的 V3.1(不在本讲 Must)

  • 摘要写入 excerpt 并显示在侧栏列表
  • 同步滚动(编辑/预览)
  • 命令面板(cmd+k)快速跳转笔记
  • 键盘快捷键切换 Tabs

把它们写进 Won't 或 Roadmap,避免你边做 V3 边失焦。


十三补 25、与团队协作:如何让其他人快速接手 V3

给仓库加 docs/vibenote-ui.md:一页说明布局结构、关键组件、主题策略、响应式策略。人类同事会感谢,AI 也会感谢——因为它的上下文会更稳定。


十三补 26、从「实现完成」到「演示完成」:你还差什么?

实现完成只解决编译;演示完成要解决「别人看得懂」。准备三句话介绍:VibeNote 帮谁解决什么问题;V3 相对 V2 改变了什么;下一步验证什么指标。你会惊讶这三句话对融资、求职、获客同样有用。


十三补 27、暗色模式下的 Markdown 代码块

技术笔记常有代码块,prose 下代码块背景与边框需要你在 globals.css 微调(可选)。若代码块对比不足,优先调 pre 的背景色,而不是整体提高对比导致正文刺眼。


十三补 28、把本讲映射到学习路径:你现在已经具备什么能力

你能把 PRD → 组件树 → 响应式策略 → shadcn 落地 → AI 功能接入 串起来,这就是 AI-native 全栈产品开发的「前端脊梁」。下一模块把这些能力接到更硬的工程与上线环节,你的作品就能从演示走向真实用户。


十三补 29、最后的狠话:别复制代码而不跑

本讲代码量多,但价值在运行。请至少跑到:切换暗色、缩放窗口、走一次 AI。只复制不运行,你会把 V3 变成静态艺术品。


十三补 30、附:最小 lib/utils.ts(若你尚未初始化)

// lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

十三补 31、与第04讲对齐:本讲仍需要你补齐的三态

讲义为了可读性,没有在侧栏列表里展开完整三态组件(第04讲已给 NoteListStates)。在真实项目中,请把列表数据请求的三态接回侧栏:否则移动端 Sheet 打开后若加载失败,用户只看到空白——这比桌面端更糟,因为 Sheet 的「临时感」会放大焦虑。


十三补 32、与第05讲对齐:不要把 NoteWorkspace 继续膨胀

NoteWorkspace 已经同时承担:布局组合、body 状态、工具栏聚合。再往下加「搜索/标签/设置」前,先评估是否应拆 features/note 目录。膨胀不是错,无边界膨胀才是错。


十三补 33、与第06讲对齐:Tabs 的默认 tab 策略

小屏默认落在「编辑」而不是「预览」,因为笔记应用的首要任务是输入。若你默认预览,用户会误以为不可编辑。这个默认值很小,但决定体验气质。


十三补 34、从「截图」到「录屏」:为什么录屏更能暴露 UX 问题

截图往往截取最好看的帧,录屏会暴露滚动、跳转、键盘、错误恢复。模块三的学习建议:每个里程碑录一条 30~60 秒无剪辑录屏,你会快速看到自己的笨拙之处——这比用户骂你便宜。


十三补 35、与 V1/V2 数据层合并的现实顺序

建议顺序:先让 UI 在 mock 数据跑通 → 再接入真实 listNotes/getNote → 再处理缓存与乐观更新。不要第一步就把数据层与 UI 同时改,否则你无法定位问题来自哪一层。


十三补 36、AI 提示词:如何让 AI 按本讲落地而不跑偏

把下面这段作为提示词前缀:

目标:实现 VibeNote V3 布局(Next14 App Router)。
栈:TS + Tailwind + shadcn/ui + next-themes。
必须:EditorShell;md 以下 Sheet+Tabs;md 以上侧栏+双栏;暗色切换;Textarea id=vibenote-md。
禁止:第二套 UI 库;客户端 OPENAI key;在 shell 内 fetch。
验收:pnpm build 通过;桌面/移动手动走查主路径。

十三补 37、与安全的再次强调:V3 不改模型边界

V3 再漂亮也不能把密钥搬到浏览器。若你为了「调试方便」临时这么做,请把它当作事故种子,立即回滚。


十三补 38、从「完成」到「可讲」:用一页 README 讲清 V3

README 写:如何启动、如何配置环境变量、V3 相对 V2 的差异截图或 GIF、已知限制(Won't)。这会让你在两周后还记得自己做了什么——也是给 AI 的上下文资产。


十三补 39、与课程节奏:模块三结束意味着什么

意味着你已经能把「想法」推进到「可演示的专业界面」,并且具备把需求写成规格、把规格落成组件与响应式策略的能力。下一模块通常进入「能上线」:数据、鉴权、部署、监控。那时你会感谢自己在模块三把 UI 债务压住。


十三补 40、最后一张对照表:模块三讲次 → V3 中的落点

讲次在 V3 的落点
第01讲 验证你改动是否服务假设?演示脚本是否验证主路径?
第02讲 MVPWon't 是否继续拒绝范围蔓延?
第03讲 PRD是否把布局/响应式/主题写入规格?
第04讲 UX三态、toast、错误恢复是否齐全?
第05讲 组件化Shell/Sidebar/Panel/Workspace 边界是否清晰?
第06讲 响应式Tabs/Sheet/Split 是否按断点切换?
第07讲 实战本文件

十四、验收清单(DoD)

  • 桌面:侧栏 + 双栏编辑预览可用
  • 移动:Sheet 打开列表;Tabs 切换编辑/预览
  • 暗色:切换成功且对比可读
  • AI:三按钮仍可走通(沿用 V2)
  • pnpm lint && pnpm build 通过
  • 真机或模拟器宽度下走通主路径(至少一次)
  • Textarea 存在 id="vibenote-md" 且扩写选区可用

十五、思考题

  1. 你会把「保存」从 toast 示例接入真实 mutation 吗?注意哪些 UX 点?
  2. 双栏滚动与同步滚动,你会如何选择 MVP 边界?
  3. 如果 AI 流式输出要接回 V3 UI,你会把 loading 状态放在工具栏还是编辑器?
  4. 你认为侧栏列表最重要的信息是哪两项(除标题外)?如何影响布局?
  5. 如果要在不增加组件复杂度的情况下做「笔记搜索」,你会优先改交互还是改信息架构?
  6. V3 完成后,你会用哪三个指标验证「升级值得」?
  7. 你会如何把本讲文件中的示例 notes 数据替换为你的真实数据源,同时保持 UI 不变?

十六、模块三收官与下一模块预告

模块三把 验证 → PRD → UI/UX → 组件化 → 响应式 → 实战 串成闭环。你应当能自信地对外说:我不是只会让 AI 写代码,我还能定义边界、状态与验收,并把复杂界面拆到可维护的结构里。下一模块将进入更硬的工程主题(API 契约、鉴权、数据校验分层、部署与观测等——以课程总目录为准),你会带着 V3.0 可演示界面继续推进「能上线」。记住:上线不是终点,可观测、可回滚、可迭代才是工程化的开始。

当你进入下一模块,建议你随身携带两份文档:一份是最新 PRD,一份是本讲 DoD 勾选记录。它们会让你在面对后端与运维问题时,仍然记得产品主路径不能断。


十七、架构补充图(组件边界)

graph LR
  Shell[EditorShell] --> Side[Sidebar Area]
  Shell --> Tool[Toolbar Area]
  Shell --> Main[Main Area]
  Main --> Panel[NoteEditorPanel]
  Tool --> AI[AIWritingToolbar]

参考:模块二 13-模块二-第07讲.md(V2.0);课程原文 课程内容/4.x;shadcn/ui 官方文档。

结语:V3.0 是一次「把方法编译成界面」的练习。你完成的不是更多代码,而是更可复用的协作接口:对未来的人类同事、对未来的 AI、对未来的你自己,都更友好。现在,去运行它,把它演示给一个真实用户,然后把反馈写进你的假设登记簿——模块三到此,才算真正结束。

下一模块预告(示例):全栈工程化与上线——把笔记从「本地演示」推进到「可持续运营」:API 契约、鉴权、校验分层、部署与环境配置、日志与监控、回归与发布流程。你会用 V3 的界面承载真实数据与真实流量。到那一步,你会真正理解:前端的专业度,最终体现在用户是否愿意把你的产品留在主屏幕上;这也是 VibeNote 作为「智能笔记」最重要的虚荣指标之一。

动手建议:合并完本讲代码后,给自己设一个 24 小时截止的「演示截止」:不管还剩多少不完美,先演示一次。你会用真实反馈换来下一模块的正确优先级。