基于React与Vite的AI编码计划文件可视化阅读器开发实践
1. 项目概述:AI编码助手计划文件的专属阅读器
如果你和我一样,深度依赖Claude Code、Cursor这类AI编码助手来辅助开发,那你一定对它们生成的Markdown格式“实施计划”文件又爱又恨。爱的是,这些计划文件逻辑清晰,将复杂的开发任务拆解成一步步可执行的指令,是绝佳的“开发蓝图”。恨的是,查看它们的方式太原始了——要么在终端里用cat命令,要么在简陋的文本编辑器里翻找,格式混乱,代码高亮缺失,一旦计划文件变长,想快速定位某个技术细节简直是一场噩梦。
Plan Viewer就是为了解决这个痛点而生的。它是一个基于现代Web技术栈(React + TypeScript + Tailwind CSS)构建的本地桌面级Web应用,专门用来浏览、搜索和阅读AI编码助手生成的Markdown计划文件。你可以把它理解为一个为.md计划文件量身定做的“IDE”或“阅读器”,它默认无缝对接Claude Code的计划目录(~/.claude/plans/),同时也支持打开任意包含.md文件的文件夹,无论是来自Cursor、Codex还是其他任何能生成Markdown计划的工具。
这个工具的核心价值在于,它将散落在文件系统各处的、格式原始的文本文件,变成了一个结构清晰、交互友好、功能强大的可视化知识库。对于需要频繁回顾、对比或基于多个AI计划进行开发的工程师来说,它能极大提升信息获取和处理的效率。接下来,我将从设计思路、核心功能实现、技术栈选型到实际部署踩坑经验,为你完整拆解这个项目。
2. 核心设计思路与架构解析
2.1 需求定位与核心问题拆解
在动手之前,我首先明确了Plan Viewer要解决的几个核心问题:
- 文件发现与管理混乱:AI计划文件通常散落在特定的隐藏目录(如
~/.claude/plans/),用户需要手动导航、记忆路径。不同AI工具(Claude, Cursor)的路径还不一样,管理起来非常麻烦。 - 阅读体验糟糕:原生Markdown在终端或基础编辑器中缺乏代码高亮、标题导航、响应式布局,长文档阅读如同“开盲盒”。
- 状态跟踪缺失:一个计划是“待实施”、“进行中”还是“已完成”?原生文件系统无法标记这种状态,导致项目进度管理困难。
- 信息检索低效:想从几十个历史计划中找到某个特定功能的实现思路?只能靠
grep或肉眼扫描,效率极低。 - 实时性不足:AI在生成或更新计划时,用户需要手动刷新文件夹才能看到变化,无法实现“所见即所得”的同步。
基于这些问题,我确定了产品的核心形态:一个本地优先、实时同步、状态可追踪、阅读体验增强的Markdown文件管理器。它不应该是一个云端服务,因为计划文件可能包含敏感的代码或架构信息;它应该轻量、快速,像一个增强版的本地文件浏览器。
2.2 技术架构选型背后的思考
为什么选择这样的技术栈?每一个选择背后都有具体的工程考量。
前端框架:React 19这是当前生态最成熟、社区最活跃的选择。React的函数组件和Hooks模式非常适合构建这种以状态驱动视图的应用。例如,计划列表、当前选中文件、大纲导航、主题模式,这些都是典型的状态,用React管理起来非常直观。选择19版本是为了利用其最新的并发特性(如useHook)为未来的性能优化留出空间,尽管当前项目尚未深度使用。
构建工具链:Vite+这是一个关键决策。我没有选择Create React App或Next.js,而是选择了作者自研的Vite+(vpCLI)。原因有三:一是极致的开发体验,Vite的冷启动和HMR速度无可匹敌;二是Vite+集成了Vitest(测试)、Oxlint(linting)、Oxfmt(格式化),提供了开箱即用的、高度集成且高性能的工具链,避免了繁琐的配置;三是它支持后端API插件的开发,这对于我们需要在本地提供文件系统API的服务端逻辑至关重要。
样式方案:Tailwind CSS v4 + shadcn/ui样式方面,我追求的是极高的开发效率和一致的设计系统。Tailwind CSS的实用类(Utility-First)理念允许我快速构建UI,而无需在CSS文件和组件间反复切换。选择v4版本是为了使用其新的oklch色彩系统,它能提供更广的色域和更精确的色彩控制。在此基础上,我引入了shadcn/ui。它不是一个传统的组件库,而是一套基于Radix UI原始组件、用Tailwind CSS样式化的代码模板。这意味着我获得了一整套美观、无障碍、交互逻辑健全的组件(如按钮、对话框、下拉菜单),同时拥有100%的代码所有权,可以完全根据项目需求进行定制,避免了传统UI库的捆绑和样式冲突问题。
状态与数据流:TanStack Query + Context API对于异步数据(读取文件列表、获取文件内容),我使用了TanStack Query(原React Query)。它完美地处理了缓存、后台刷新、错误重试等复杂逻辑。例如,实现“每5秒轮询文件变化”这个功能,用TanStack Query只需要一个简单的refetchInterval配置,远比手动设置setInterval和清理副作用要优雅可靠。对于全局的、简单的状态(如当前打开的文件夹句柄),我使用了React的Context API,它足够轻量且易于理解。
核心功能实现:自定义Vite插件这是项目的技术核心。浏览器环境出于安全限制,无法直接访问用户的本地文件系统(除了通过<input type=”file”>)。为了读取~/.claude/plans/这样的特定目录,我们必须提供一个本地服务。我选择开发一个Vite插件,它在开发服务器和预览服务器中注入了一个简单的REST API(如GET /api/plans)。这个插件运行在Node.js环境中,因此可以自由使用fs模块读取文件。前端通过TanStack Query调用这个本地API,从而安全地获取文件数据。这种架构将敏感的文件系统操作隔离在后端,前端保持纯净,也便于未来扩展(例如,添加文件监控fs.watch以实现更实时的更新)。
3. 核心功能模块深度实现
3.1 文件系统桥接与多文件夹管理
这是应用的基础。目标是从前端“无感”地读取本地目录。
实现方案:自定义Vite插件 (plansApiPlugin)我在src/server/目录下创建了一个Vite插件。它的主要作用是在Vite的开发服务器上注册一组API路由。
// vite.config.ts 中的配置 import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { plansApiPlugin } from './src/server/plans-api-plugin'; export default defineConfig({ plugins: [ react(), plansApiPlugin({ plansDir: '~/.claude/plans', // 默认目录,可配置 }), ], });插件内部大致逻辑如下:
- 配置一个中间件,拦截对
/api/*路径的请求。 - 对于
GET /api/plans请求,使用Node.js的fs.readdir和fs.stat递归扫描配置的plansDir目录,过滤出.md文件,并返回文件列表(包含名称、路径、大小、修改时间)。 - 对于
GET /api/plan?path=xxx请求,使用fs.readFile读取指定路径的Markdown文件内容。 - 为了支持“打开任意文件夹”,我利用了现代浏览器的File System Access API。当用户点击“Open Folder”按钮时,前端调用
window.showDirectoryPicker(),获取用户授权后得到一个目录句柄。这个句柄无法直接发送给后端,所以我们将它存储在React Context中。后续前端需要读取文件时,通过这个句柄使用directoryHandle.getFile()等API直接操作,完全在前端完成,无需后端介入。这实现了对任意文件夹的安全访问。
注意:File System Access API目前仅在Chromium系浏览器(Chrome, Edge)中得到较好支持。在Firefox或Safari中,我们降级使用传统的
<input type=”file” webkitdirectory>来选择文件夹,但其功能受限(例如,无法递归读取)。在UI上需要对用户进行适当的提示。
多文件夹状态管理用户可能同时打开“Claude计划目录”和“某个项目里的计划文件夹”。我用一个FolderContext来管理这些“数据源”。每个数据源包含类型(“default” 或 “user-picked”)、名称、句柄(或路径)、以及该文件夹下的计划列表。侧边栏的“可折叠区域”就是基于这个上下文动态生成的。
3.2 增强型Markdown渲染引擎
仅仅显示原始文本是远远不够的。目标是实现媲美GitHub的阅读体验。
技术选型:react-markdown + rehype-highlight我没有使用重量级的Markdown编辑器框架(如CodeMirror、Monaco),因为它们过于复杂且不适合纯阅读场景。react-markdown是一个轻量、灵活的库,它将Markdown字符串解析成React组件树,允许我对每一个元素(如h1,code,table)进行自定义渲染。
import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import 'highlight.js/styles/github-dark.css'; // 引入代码高亮主题 const MarkdownRenderer = ({ content }) => { return ( <ReactMarkdown rehypePlugins={[rehypeHighlight]} // 使用rehype-highlight处理代码块 components={{ // 自定义组件渲染,例如为所有标题添加锚点ID h1: ({node, ...props}) => <h1 id={generateId(props.children)} {...props} />, code: ({node, inline, className, children, ...props}) => { const language = className?.replace('language-', ''); return inline ? (<code className="bg-gray-100 px-1 rounded" {...props}>{children}</code>) : (<SyntaxHighlighter language={language} PreTag="div" {...props}>{children}</SyntaxHighlighter>); } }} > {content} </ReactMarkdown> ); };关键细节处理:
- 代码高亮:通过
rehype-highlight插件配合highlight.js库实现。需要导入具体的语言定义包和CSS主题文件。我选择了github-dark和github-light主题以匹配应用的整体设计。 - 表格与任务列表:
react-markdown默认支持GFM(GitHub Flavored Markdown)语法,表格会自动添加Tailwind CSS的样式类来实现边框和斑马纹。任务列表- [x]会被渲染成带复选框的列表。 - 安全性与性能:默认情况下,
react-markdown会忽略HTML标签和危险脚本,这提高了安全性。对于超长文档,我使用了虚拟滚动或分块渲染的优化策略(例如,只渲染视口及附近的内容),以防止一次性渲染巨大DOM节点导致页面卡顿。
3.3 交互式大纲(Outline Panel)与滚动侦测
这是提升长文档导航体验的灵魂功能。
实现步骤:
- 标题提取:在Markdown内容加载后,我需要从中解析出所有层级的标题(
#~######)。我写了一个extractHeadings函数,它使用一个简单的正则表达式(如/^(#{1,6})\s+(.+)$/gm)来匹配行首的标题。同时,我会清理标题文本中的内联Markdown格式(如删除**粗体**的符号),并为每个标题生成一个唯一的锚点ID(通常是对标题文本进行小写化和连字符化,如implementation-details)。 - 大纲渲染:将提取出的标题数组渲染成一个嵌套的列表结构。使用不同的左边距或字体大小来视觉化地表现层级(H1, H2, H3...)。
- 滚动侦测(Scroll Spy):这是最具交互性的部分。我需要知道用户当前阅读到了哪个章节。
- 方法:使用
Intersection Observer API。我为页面中每个标题元素(h1~h6)都设置了一个观察器。 - 阈值:通常将
threshold设置为[0, 0.1, 0.2, ..., 1]的一个数组,或简单设置为[0, 0.25, 0.5, 0.75, 1],以更精确地追踪元素进入视口的比例。 - 判断逻辑:当多个标题同时出现在视口中时,我选择交叉比例最大的那个作为“当前活跃标题”。这比单纯选择第一个进入视口的标题更符合阅读直觉。
- 性能:为所有标题创建Observer可能会有性能开销。一个优化点是使用一个单一的Observer,并观察一个包裹所有内容的容器,然后通过计算容器内各标题的位置来手动判断,但这更复杂。在当前场景下,标题数量通常有限,直接使用多个Observer是简单有效的。
- 方法:使用
- 平滑滚动与大纲高亮:当用户点击大纲中的某个标题时,使用
element.scrollIntoView({ behavior: 'smooth' })进行滚动。同时,当滚动侦测到活跃标题变化时,在大纲组件中高亮对应的项,并自动展开其父级折叠项,确保当前标题可见。
3.4 计划状态管理(完成/未完成)
这是一个典型的“客户端状态持久化”需求。
数据结构设计:我在前端维护一个Map或一个对象,键是计划文件的唯一标识(如完整路径),值是一个包含isImplemented布尔值和其他元数据的对象。
interface PlanStatus { filePath: string; isImplemented: boolean; updatedAt: number; }持久化方案:使用浏览器的localStorage。每次状态变更(用户点击复选框)时,同步更新内存中的状态Map和localStorage。应用初始化时,从localStorage读取状态并恢复。
UI与交互:
- 在侧边栏的计划列表项上,鼠标悬停时显示一个复选框,点击即可切换状态。
- 在计划内容页面的顶部标题栏,也有一个显眼的复选框。
- 已完成的计划在侧边栏会被移动到一个可折叠的“已完成”分区内,并与未完成计划视觉区分(例如,降低透明度、添加删除线图标)。
- 状态切换时有平滑的动画反馈(如复选框勾选动画、项目移动的过渡效果),增强用户体验。
实操心得:直接使用
localStorage简单快捷,但存在局限性:状态仅存在于当前浏览器。如果用户在多台设备或不同浏览器间切换,状态无法同步。对于更高级的需求,可以考虑集成IndexedDB存储更多数据,或开发一个简单的后端服务配合账户系统进行云同步。但作为v1.0,localStorage是完全合理的选择。
4. 开发、构建与部署实战
4.1 开发环境搭建与启动
项目使用vp(Vite+ CLI)作为统一的命令入口。这极大地简化了工作流。
# 1. 克隆项目 git clone https://github.com/eltonvs/plan-viewer.git cd plan-viewer # 2. 安装依赖 # 使用 vp install 而不是 npm install 或 yarn vp install # 这个命令背后会调用 pnpm install (项目默认使用 pnpm) # 3. 启动开发服务器 vp dev执行vp dev后,Vite会启动开发服务器,通常在本地的http://localhost:5173。这里有一个关键点:我们自定义的plansApiPlugin也会在这个开发服务器中生效。这意味着前端发往/api/plans的请求会被插件中间件正确拦截和处理,从而读取到本地的计划文件。
常见问题排查:
- 端口占用:如果5173端口被占用,Vite会自动尝试下一个端口。检查终端输出即可。
- 依赖安装失败:确保你的Node.js版本在20以上,并且网络通畅。可以尝试使用
npm config set registry https://registry.npmmirror.com切换为国内镜像源,再运行vp install。 - 插件API不工作:检查
vite.config.ts中插件配置的路径是否正确。确保你尝试访问的默认计划目录(如~/.claude/plans/)真实存在。~在Node.js中需要被解析为绝对路径,插件内部需要使用path.resolve(os.homedir(), ‘.claude/plans’)来处理。
4.2 生产构建与优化
开发完成后,需要构建出用于部署的静态文件。
# 执行生产构建 vp build这个命令会触发Vite的构建流程:
- TypeScript编译:将TSX/TS文件编译为JavaScript。
- 依赖打包:使用Rollup将项目代码和第三方库打包成少数几个优化的chunk文件(如
index-xxxxxx.js,vendor-xxxxxx.js)。 - CSS优化:Tailwind CSS会被扫描所有用到的类,生成一个最小化的CSS文件。未使用的样式会被自动剔除(PurgeCSS)。
- 资源处理:图片等资源会被压缩并哈希化。
- 插件处理:我们的
plansApiPlugin在生产构建中需要被特殊处理。因为生产环境是一个静态站点,没有Node.js服务器。插件在构建时通常会被忽略或配置为不注入API路由。这意味着生产构建的版本将无法直接通过文件系统API读取默认的~/.claude/plans目录。
生产部署的两种模式:
- 纯静态托管(受限模式):将
dist目录部署到Netlify、Vercel、GitHub Pages等静态托管平台。此时,只有通过浏览器File System Access API“打开文件夹”的功能可用。适合查看任意项目文件夹中的计划,但无法直接访问系统默认路径。 - 本地服务模式(完整功能):为了使用完整功能(包括读取默认路径),你需要本地运行一个服务。
# 首先构建 vp build # 然后使用Vite预览模式启动一个本地静态服务器,这个服务器同样可以运行我们的API插件 vp previewvp preview命令会启动一个服务于dist目录的静态文件服务器,并且同样会加载Vite配置中的插件。因此,在预览模式下,API功能是完整的。你可以将构建好的dist文件夹拷贝到任何有Node.js环境的机器上,运行vp preview来获得完整功能的本地应用。
重要提示:这是架构上的一个关键设计取舍。为了安全(浏览器沙箱限制)和部署灵活性(可静态托管),我们牺牲了生产版本中“直接读取特定系统路径”的能力。用户手册中必须明确说明这一点。
4.3 配置与自定义
项目的主要配置集中在vite.config.ts文件中。
// 最常用的自定义:修改默认计划目录 import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { plansApiPlugin } from './src/server/plans-api-plugin'; export default defineConfig({ plugins: [ react(), plansApiPlugin({ // 修改为你自己的Claude Code计划路径,或Cursor等其他AI工具的路径 plansDir: '/Users/yourname/.cursor/plans', // 可以配置轮询间隔(毫秒) pollInterval: 5000, }), ], // 其他Vite配置,如设置基础路径(base path)用于部署到子目录 // base: '/plan-viewer/', });如果你想支持更多AI工具的默认路径,可以在插件内部维护一个路径映射表,或者允许接收一个路径数组。
5. 常见问题、排查技巧与扩展思路
5.1 开发与调试问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
启动vp dev后页面空白,控制台报错 | 1. 端口冲突 2. 依赖未正确安装 3. TypeScript编译错误 | 1. 查看终端输出,确认实际端口,或使用vp dev --port 3000指定端口。2. 删除 node_modules和pnpm-lock.yaml/package-lock.json,重新运行vp install。3. 运行 vp check查看TS错误详情并修复。 |
| 侧边栏看不到任何计划文件 | 1. 默认目录不存在或为空 2. API插件未正确工作 3. 文件读取权限问题 | 1. 确认~/.claude/plans/目录存在且包含.md文件。2. 打开浏览器开发者工具“网络”标签,查看 /api/plans请求是否成功返回数据。失败则检查插件逻辑和Vite配置。3. 在Mac/Linux上,检查Node.js进程是否有权读取该目录。 |
| 代码块没有语法高亮 | 1.highlight.js主题CSS未引入2. 语言未正确检测 | 1. 检查main.tsx或入口组件是否导入了CSS文件,如import ‘highlight.js/styles/github-dark.css’。2. 检查Markdown代码块是否标注了语言(如 ```javascript)。 rehype-highlight依赖此标注。 |
| 大纲(Outline)不显示或无法点击 | 1. 标题提取正则表达式有误 2. Intersection Observer未正确设置3. 锚点ID生成冲突 | 1. 调试extractHeadings函数,输入测试Markdown看输出是否正确。2. 在开发者工具中检查标题元素是否有 id,以及Observer回调是否被触发。3. 确保ID生成算法能处理重复标题(可在末尾添加索引)。 |
| “打开文件夹”按钮在Safari/Firefox中无效 | 浏览器兼容性问题 | File System Access API兼容性有限。需检测浏览器支持情况,并给出友好提示,或降级使用<input directory>。可以在按钮旁添加一个浏览器图标提示。 |
| 生产构建后功能缺失 | 生产构建未包含API服务器 | 记住:纯静态部署仅支持“打开文件夹”功能。如需完整功能,必须在目标机器上通过vp preview运行。在项目README中明确说明此区别。 |
5.2 性能优化实践
- 虚拟滚动:当单个计划文件极其巨大(数万行)时,一次性渲染所有Markdown会导致DOM节点过多,页面滚动卡顿。解决方案是实现虚拟滚动:只渲染视口及其上下一定范围内的内容。可以使用
react-virtualized或@tanstack/react-virtual这类库。对于大纲列表,如果文件有成百上千个标题,同样需要考虑虚拟化。 - 文件监听与智能轮询:当前每5秒轮询一次文件系统,对于SSD来说开销很小,但并非最优。更高效的方式是使用Node.js的
fs.watchAPI(在插件后端)。当目录内容发生变化时,通过WebSocket或Server-Sent Events (SSE) 主动向前端推送更新通知,前端再发起请求。这实现了真正的实时同步,且资源消耗更低。 - 图片与资源懒加载:如果Markdown中包含大量图片,可以使用原生
loading=”lazy”属性或Intersection Observer实现图片懒加载,减少页面初始加载时间。 - 代码分割:使用React.lazy和Suspense对大纲组件、Markdown渲染器等非首屏必需的组件进行动态导入,拆分代码包,加快应用初始加载速度。
5.3 项目扩展思路
Plan Viewer作为一个基础平台,有很多值得扩展的方向:
- 计划对比视图:并排显示两个计划文件,高亮显示差异(类似代码Diff)。这对于分析AI在不同时间或针对同一任务生成的不同方案非常有价值。
- 计划搜索与标签:不仅搜索文件名,还能对计划文件内容进行全文检索。允许用户为计划打上自定义标签(如“#数据库设计”、“#前端组件”),方便分类过滤。
- 导出与分享:支持将单个计划或带渲染样式的计划导出为PDF、HTML或PNG图片,便于分享或纳入项目文档。
- AI集成:提供一个侧边栏聊天窗口,允许用户针对当前打开的计划文件向本地运行的Ollama(或其他本地LLM)提问,例如“请解释第三步的架构设计”或“将这个计划转换成Jira任务”。
- 项目模板生成:对于某些标准的AI计划(如“创建一个React组件”),可以开发一个功能,一键将计划中的代码块和文件结构提取出来,在真实文件系统中生成项目骨架。
这个项目的开发过程让我深刻体会到,一个成功的工具型产品,往往不是技术最复杂的,而是最能精准击中用户痒点、并用优雅简洁的技术方案将其实现的。Plan Viewer没有使用任何高深莫测的技术,但它通过合理的架构和用心的细节设计,切实地提升了一类特定场景下的工作效率。如果你也在频繁使用AI编码助手,不妨尝试一下它,或者基于这个思路,打造更适合自己工作流的工具。
