模块三:产品设计与前端实战 | 第07讲:项目实战——VibeNote V3.0 专业 UI 界面与交互体验升级
目标:在 V2.0(Markdown + AI 工具栏)基础上升级到 V3.0:shadcn/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/typography 的 prose 会提供合理的标题、列表、代码块样式,让 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.tsx 在 html 上设置 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:block 与 md: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 秒演示视频脚本
- 桌面打开笔记,展示侧栏切换。
- 编辑 Markdown,预览实时更新。
- 切换到手机宽度,展示 Sheet 与 Tabs。
- 切换暗色模式。
- 触发一次 AI 摘要并展示错误重试(可故意断网)。
这条脚本同时也是你的回归清单。
十三补 12、常见故障排查表(三连跪时按顺序查)
ThemeProvider是否包裹?Toaster是否在 client 树可用?lib/utils的cn是否存在(shadcn 依赖)?Textarea/Tabs/Sheet组件是否已 add?- Tailwind
contentglob 是否覆盖components? - 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、扩展思考题(加分)
- 如何把侧栏选中态与 URL 更深度绑定(active 样式、可分享)?
- 如果要在 V3 加「全文搜索」,你会放 toolbar 还是 sidebar?为什么?
- 如何把
NoteWorkspace拆成 server wrapper + client core 以降低 client bundle?
十三补 18、结语:V3 不是终点,是「可展示的增长基点」
当你能拿着 V3 去给人演示,你获得的不是虚荣,而是更快的外部反馈。把反馈接回第01讲假设登记簿,你就完成了一次完整的产品循环。
十三补 19、从「代码粘贴」到「工程合并」:你需要改动的文件清单(Checklist)
app/layout.tsx:ThemeProvider + Toastercomponents/theme-provider.tsxcomponents/mode-toggle.tsxcomponents/editor-shell.tsx(来自第05讲;若你仓库没有请复制)components/app-sidebar.tsxcomponents/mobile-sidebar.tsxcomponents/note-editor-panel.tsxcomponents/note-workspace.tsxapp/notes/[id]/page.tsxcomponents/AIWritingToolbar.tsx+app/api/ai/writing/route.ts(沿用模块二)
把清单写进 PR,你的合并过程会像流水线,而不是像探险。
十三补 20、lib/utils.ts 与 cn: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 适合轻反馈;阻断型错误用 Alert 或 AlertDialog。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讲 MVP | Won'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"且扩写选区可用
十五、思考题
- 你会把「保存」从 toast 示例接入真实 mutation 吗?注意哪些 UX 点?
- 双栏滚动与同步滚动,你会如何选择 MVP 边界?
- 如果 AI 流式输出要接回 V3 UI,你会把 loading 状态放在工具栏还是编辑器?
- 你认为侧栏列表最重要的信息是哪两项(除标题外)?如何影响布局?
- 如果要在不增加组件复杂度的情况下做「笔记搜索」,你会优先改交互还是改信息架构?
- V3 完成后,你会用哪三个指标验证「升级值得」?
- 你会如何把本讲文件中的示例
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 小时截止的「演示截止」:不管还剩多少不完美,先演示一次。你会用真实反馈换来下一模块的正确优先级。