1. 为什么我要自己动手做一个游戏下载管理器平时折腾单机游戏的朋友大概率都听过 FitGirl 这个名号她的高压版资源以体积小、安装稳定著称但真正让人头疼的从来不是资源本身而是找资源—核对版本—下载—解压—校验这一整套流程。浏览器里开一堆标签页下载列表里躺着十几个分卷压缩包过两天再回来看自己都忘了哪个是哪个版本、哪个补丁装没装。这种体验做一次两次还行当成日常操作就纯属折磨自己了。我最初的想法很简单能不能有一个本地的小工具把 FitGirl 相关的资源信息聚合起来让我在一个界面里完成浏览、筛选、记录下载状态、管理本地游戏库这几件事。市面上的下载管理器要么太重要么不支持自定义数据源要么界面丑到不想打开。既然自己写代码是吃饭的本事那就干脆用 Electron 搭一个专属的游戏管家。这篇文章要聊的就是这个项目的完整搭建思路和实操过程。核心关键词是FitGirl、Electron、JavaScript、HTML、CSS——用 Electron 做桌面壳JavaScript 负责逻辑HTML 和 CSS 负责界面。整套东西不需要什么高深的架构一个下午就能跑起来5 分钟能搭出可用的骨架剩下的时间都花在打磨细节上。适合有一定前端基础、想入门 Electron 桌面开发、同时又是个游戏爱好者的朋友。哪怕你只是想学 Electron 的主渲染进程通信这个项目也是个非常好的练手载体因为它涉及窗口管理、IPC 通信、本地文件读写、数据持久化这些典型场景。我先把结论摆在这儿这个项目的技术难度不高难点在于想清楚要管理什么数据以及怎么把界面做得自己愿意天天用。下面我会从整体设计、核心细节、实操实现、踩坑排查四个维度把这个游戏管家从零到一讲透。2. 整体架构设计与技术选型思路2.1 为什么是 Electron 而不是纯网页很多人第一反应是既然界面用 HTML/CSS/JS那直接做个网页不就行了我一开始也这么想但很快就发现纯网页方案有几个绕不过去的坎。第一是本地文件系统的访问。游戏管家需要扫描本地游戏目录、读取已下载的分卷文件、记录安装路径这些操作在浏览器沙箱里基本做不了就算用 File System Access API 也限制重重用户每次打开都要重新授权目录体验很差。Electron 的主进程直接跑在 Node.js 环境里fs模块随便用读写本地文件跟喝水一样自然。第二是数据持久化。我需要把游戏库信息、下载记录、配置项存到本地浏览器只能用 localStorage 或者 IndexedDB数据量一大就捉襟见肘而且清个缓存就全没了。Electron 里我可以直接写 JSON 文件到用户数据目录稳定可靠还能手动备份。第三是系统集成。比如调用系统默认浏览器打开资源页、在文件管理器里定位到某个游戏目录、托盘图标常驻后台这些都是桌面应用才有的能力。Electron 提供的shell.openExternal、shell.showItemInFolder这些 API 用起来非常顺手。所以选 Electron 不是因为它时髦而是这个场景下它确实是成本最低、能力最全的方案。用一句话概括我要的是一个能碰本地文件的桌面程序而不是一个被沙箱捆住手脚的网页。2.2 主进程与渲染进程的职责划分Electron 的进程模型是这个项目里最需要想清楚的地方。简单说主进程main process是管家的大脑负责创建窗口、访问文件系统、处理系统级操作渲染进程renderer process是管家的脸面就是那个用 HTML/CSS 画出来的界面负责展示和交互。这里有个关键原则渲染进程不要直接碰 Node.js 的 API。虽然早期 Electron 允许在渲染进程里开nodeIntegration但那等于把整个文件系统暴露给网页代码一旦界面里加载了不可信内容就是灾难。正确做法是关掉nodeIntegration、开启contextIsolation通过预加载脚本preload用contextBridge暴露有限的、经过封装的接口给渲染进程。我踩过的坑就在这儿一开始图省事直接在渲染进程里require(fs)本地跑没问题后来想加个从网络加载封面图的功能立刻意识到这等于给自己埋雷。于是老老实实重构把所有文件操作都挪到主进程渲染进程只通过 IPC 发消息请求数据。这个重构花了我小半天但后面加功能时顺畅多了。主进程和渲染进程之间靠 IPC进程间通信打交道具体分两种模式ipcRenderer.invoke配ipcMain.handle是请求—响应模式适合给我游戏列表这种一问一答ipcRenderer.send配ipcMain.on是单向通知模式适合我要开始下载了这种不需要返回值的场景。搞懂这两组 APIElectron 的通信就掌握八成了。2.3 界面技术栈的取舍界面这块我纠结过要不要上 Vue 或 React。热词里也有人问Electron 主渲染进程 IPC 通信和 Vue 有关系吗——答案是没关系IPC 是 Electron 的机制跟你用什么前端框架完全解耦。框架只影响渲染进程内部怎么组织界面不影响进程间怎么通信。考虑到这个项目界面复杂度中等主要是列表、卡片、表单这几类元素我最终选择了原生 HTML CSS JavaScript没有引入框架。理由有三一是启动快没有构建步骤改完代码刷新就见效二是依赖少不用担心框架版本升级带来的连锁反应三是这个项目本身就是个学习载体用原生写法能把 DOM 操作、事件绑定这些基础打扎实。当然如果你已经熟悉 Vue用 Vue 也完全没问题electron-vite这类脚手架能帮你把 Vue 项目打包进 Electron。但对于一个5 分钟搭骨架的目标来说原生方案的上手门槛最低。CSS 方面我用的是原生 CSS 加 CSS 变量做主题管理没有上 Sass 或 Tailwind因为界面元素不多手写完全够用还能顺便练练 Flexbox 和 Grid 布局。3. 核心细节解析与实操要点3.1 项目目录结构怎么规划一个清晰的目录结构能让后续开发少走很多弯路。我的项目结构是这样的fitgirl-manager/ ├── package.json ├── main.js // 主进程入口 ├── preload.js // 预加载脚本暴露安全接口 ├── src/ │ ├── index.html // 主界面 │ ├── styles/ │ │ ├── main.css // 全局样式 │ │ └── theme.css // 主题变量 │ └── scripts/ │ ├── renderer.js // 渲染进程主逻辑 │ ├── store.js // 前端状态管理 │ └── utils.js // 工具函数 └── data/ └── library.json // 游戏库数据运行时生成这个结构的关键在于主进程代码和渲染进程代码物理隔离。main.js和preload.js在根目录src目录下的东西全部是给渲染进程用的。这样一眼就能看出哪些代码跑在哪个进程里避免误用 API。data/library.json是运行时生成的不纳入版本管理。我把它放在用户数据目录而不是项目目录这样打包后也能正常读写。获取用户数据目录用app.getPath(userData)这是 Electron 提供的标准方法跨平台都能拿到正确的路径。3.2 预加载脚本安全通信的桥梁预加载脚本是整个项目安全性的核心。它运行在渲染进程的上下文里但拥有访问 Node.js API 的权限是主进程和渲染进程之间的翻译官。我用contextBridge暴露了一组精心设计的接口// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(gameAPI, { // 获取游戏库列表 getLibrary: () ipcRenderer.invoke(library:get), // 添加游戏 addGame: (game) ipcRenderer.invoke(library:add, game), // 更新游戏状态 updateGame: (id, patch) ipcRenderer.invoke(library:update, { id, patch }), // 删除游戏 removeGame: (id) ipcRenderer.invoke(library:remove, id), // 在文件管理器中定位 revealInFolder: (path) ipcRenderer.invoke(shell:reveal, path), // 用默认浏览器打开链接 openExternal: (url) ipcRenderer.invoke(shell:open, url) });注意这里暴露的接口都是语义化的业务方法而不是把ipcRenderer整个丢出去。如果直接暴露ipcRenderer渲染进程就能往任意频道发消息等于把安全门又打开了。只暴露具体方法渲染进程能做的事就被严格限定在这几个操作里这是最小权限原则的体现。提示contextBridge暴露的对象里不要放函数引用之外的复杂对象尤其是带有原型链的对象跨上下文传递时会被序列化方法会丢失。传数据就传纯 JSON 结构。3.3 数据模型设计游戏库到底存什么数据模型是想清楚要管理什么的落地。我反复调整过几版最终定下来的字段是这样的字段名类型说明idstring唯一标识用时间戳加随机数生成titlestring游戏名称versionstring版本号比如 v1.2.3sizestring压缩包体积比如 45.2 GBstatusstring状态待下载/下载中/已下载/已安装sourceUrlstring资源页面地址localPathstring本地安装路径tagsarray标签比如动作开放世界notestring备注记录补丁、DLC 情况createdAtnumber创建时间戳updatedAtnumber更新时间戳status字段是整个管家的灵魂。我用一个状态机来管理它的流转待下载 → 下载中 → 已下载 → 已安装每个状态对应界面上不同的颜色标识。这样打开界面一眼就能看出哪些游戏还没下、哪些下完了没装。tags用数组存方便后续做筛选。note字段看起来不起眼但实际用起来非常关键——高压版游戏经常有各种补丁和 DLC 需要单独处理不记下来过两天就忘了。我吃过这个亏某个游戏的汉化补丁装了一半回头完全想不起来装到哪一步了。3.4 界面布局的核心思路界面我采用的是经典的左侧导航 右侧内容区布局。左侧是状态筛选全部、待下载、下载中、已下载、已安装和标签云右侧是游戏卡片网格。这种布局信息密度高操作路径短符合管家的定位。CSS 上用 Grid 做卡片网格用 Flexbox 做导航栏和卡片内部布局。这里分享一个实用技巧卡片网格用grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))这样窗口拉宽拉窄时卡片会自动调整列数不用写任何媒体查询。这个写法我用了好几年做响应式网格几乎百试百灵。主题色我用 CSS 变量管理定义在:root里:root { --bg-primary: #1a1a1e; --bg-card: #26262c; --text-primary: #e8e8ea; --text-secondary: #9a9aa2; --accent: #6c5ce7; --status-pending: #fdcb6e; --status-downloading: #74b9ff; --status-downloaded: #55efc4; --status-installed: #a29bfe; }深色主题是游戏工具的标配长时间盯着不累眼。状态色用不同的色相区分配合文字标签色盲用户也能靠文字识别状态这是无障碍设计的基本要求。4. 实操过程与核心环节实现4.1 从零初始化项目第一步是初始化 npm 项目并安装 Electron。打开终端执行mkdir fitgirl-manager cd fitgirl-manager npm init -y npm install --save-dev electron安装完成后修改package.json把入口指向主进程文件并加上启动脚本{ name: fitgirl-manager, version: 1.0.0, main: main.js, scripts: { start: electron . }, devDependencies: { electron: ^28.0.0 } }这里有个细节Electron 的版本号建议用当前稳定版不要盲目追最新。新版本偶尔会有 API 变动对于学习项目来说稳定比新潮重要。安装过程如果卡在下载二进制包可以配置镜像源这是国内开发者的常规操作具体方法搜一下就有不展开。4.2 主进程窗口创建与 IPC 处理主进程的核心工作是创建窗口和注册 IPC 处理器。先看窗口创建// main.js const { app, BrowserWindow, ipcMain, shell } require(electron); const path require(path); const fs require(fs); let mainWindow; function createWindow() { mainWindow new BrowserWindow({ width: 1200, height: 800, minWidth: 900, minHeight: 600, backgroundColor: #1a1a1e, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false, sandbox: false } }); mainWindow.loadFile(path.join(__dirname, src, index.html)); } app.whenReady().then(createWindow); app.on(window-all-closed, () { if (process.platform ! darwin) app.quit(); });webPreferences里那三个配置是安全底线contextIsolation: true隔离上下文nodeIntegration: false禁止渲染进程直接用 Nodesandbox: false是因为预加载脚本里要用require如果不需要可以设为 true 更安全。backgroundColor设成和主题一致的深色能避免窗口打开瞬间的白屏闪烁这个细节很影响第一印象。接下来是数据读写。我把游戏库存在用户数据目录下的library.jsonfunction getLibraryPath() { return path.join(app.getPath(userData), library.json); } function readLibrary() { const filePath getLibraryPath(); if (!fs.existsSync(filePath)) return []; try { const raw fs.readFileSync(filePath, utf-8); return JSON.parse(raw); } catch (err) { console.error(读取游戏库失败:, err); return []; } } function writeLibrary(data) { const filePath getLibraryPath(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), utf-8); }注意readLibrary里的 try-catch。JSON 文件如果被手动改坏了JSON.parse会直接抛异常导致整个应用崩溃。加上容错后最坏情况就是返回空数组用户重新添加即可不至于打不开程序。这种防御性编程在桌面应用里特别重要因为用户能直接访问到数据文件。然后是 IPC 处理器的注册ipcMain.handle(library:get, () { return readLibrary(); }); ipcMain.handle(library:add, (event, game) { const library readLibrary(); const newGame { ...game, id: Date.now().toString(36) Math.random().toString(36).slice(2, 8), createdAt: Date.now(), updatedAt: Date.now() }; library.push(newGame); writeLibrary(library); return newGame; }); ipcMain.handle(library:update, (event, { id, patch }) { const library readLibrary(); const index library.findIndex(g g.id id); if (index -1) return null; library[index] { ...library[index], ...patch, updatedAt: Date.now() }; writeLibrary(library); return library[index]; }); ipcMain.handle(library:remove, (event, id) { const library readLibrary().filter(g g.id ! id); writeLibrary(library); return true; });ID 生成用Date.now().toString(36)加随机串既保证唯一性又比 UUID 短看着清爽。每次更新都刷新updatedAt方便后续按最近操作排序。系统集成部分也很简单ipcMain.handle(shell:reveal, (event, targetPath) { shell.showItemInFolder(targetPath); return true; }); ipcMain.handle(shell:open, (event, url) { shell.openExternal(url); return true; });shell.openExternal打开外部链接时一定要校验 URL 协议只允许 http 和 https防止有人构造file://或更危险的协议。这是安全审查时的常见问题虽然个人项目风险低但养成习惯没坏处。4.3 渲染进程界面渲染与交互逻辑渲染进程的代码围绕数据驱动界面来写。核心思路是所有界面元素都由library数组渲染出来数据一变就重新渲染不手动操作 DOM 的增删改。// renderer.js let library []; let currentFilter all; let currentTag null; async function init() { library await window.gameAPI.getLibrary(); render(); bindEvents(); } function render() { const filtered filterLibrary(); renderCards(filtered); renderStats(); } function filterLibrary() { return library.filter(game { const statusMatch currentFilter all || game.status currentFilter; const tagMatch !currentTag || (game.tags || []).includes(currentTag); return statusMatch tagMatch; }); }filterLibrary把状态筛选和标签筛选组合起来逻辑清晰。render函数是纯函数式的——给定数据就产出界面不依赖外部状态这样调试起来特别容易。卡片渲染用模板字符串拼 HTML然后一次性塞进容器function renderCards(games) { const container document.getElementById(card-grid); if (games.length 0) { container.innerHTML div classempty-state这里空空如也点右上角添加一个吧/div; return; } container.innerHTML games.map(game div classgame-card>function escapeHtml(str) { if (!str) return ; return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); }事件绑定用事件委托把监听器挂在容器上而不是每个按钮上function bindEvents() { document.getElementById(card-grid).addEventListener(click, async (e) { const btn e.target.closest([data-action]); if (!btn) return; const card btn.closest(.game-card); const id card.dataset.id; const action btn.dataset.action; if (action delete) { if (!confirm(确定要删除这条记录吗)) return; await window.gameAPI.removeGame(id); library library.filter(g g.id ! id); render(); } else if (action folder) { const game library.find(g g.id id); if (game game.localPath) { await window.gameAPI.revealInFolder(game.localPath); } } else if (action edit) { openEditDialog(id); } }); }事件委托的好处是卡片重新渲染后不需要重新绑定事件因为监听器在父容器上新生成的按钮自动生效。这个技巧在动态列表场景下能省掉大量重复代码也是面试常考点。4.4 添加与编辑表单的实现添加游戏用一个模态框。HTML 结构放在index.html里默认隐藏需要时显示div classmodal-overlay idmodal-overlay hidden div classmodal h2 idmodal-title添加游戏/h2 form idgame-form label游戏名称 input typetext nametitle required/label label版本号 input typetext nameversion placeholder如 v1.2.3/label label体积 input typetext namesize placeholder如 45.2 GB/label label状态 select namestatus option valuepending待下载/option option valuedownloading下载中/option option valuedownloaded已下载/option option valueinstalled已安装/option /select /label label标签 input typetext nametags placeholder用逗号分隔/label label本地路径 input typetext namelocalPath/label label备注 textarea namenote rows3/textarea/label div classmodal-actions button typebutton idbtn-cancel取消/button button typesubmit保存/button /div /form /div /div表单提交时收集数据标签字段按逗号切分成数组document.getElementById(game-form).addEventListener(submit, async (e) { e.preventDefault(); const formData new FormData(e.target); const data Object.fromEntries(formData.entries()); data.tags data.tags ? data.tags.split(,).map(s s.trim()).filter(Boolean) : []; if (editingId) { const updated await window.gameAPI.updateGame(editingId, data); const index library.findIndex(g g.id editingId); if (index ! -1) library[index] updated; } else { const newGame await window.gameAPI.addGame(data); library.push(newGame); } closeModal(); render(); });Object.fromEntries(formData.entries())这一行能把整个表单一次性转成对象比逐个querySelector取值优雅太多。这是现代 JavaScript 的实用技巧值得记住。4.5 状态统计与视觉反馈界面顶部我放了一个统计条显示各状态的游戏数量function renderStats() { const stats { all: library.length, pending: library.filter(g g.status pending).length, downloading: library.filter(g g.status downloading).length, downloaded: library.filter(g g.status downloaded).length, installed: library.filter(g g.status installed).length }; Object.entries(stats).forEach(([key, count]) { const el document.querySelector([data-stat${key}]); if (el) el.textContent count; }); }状态徽章的颜色通过 CSS 类名控制status-pending、status-downloading这些类对应前面定义的 CSS 变量。视觉反馈做得好用户扫一眼就知道整体进度这是管家类工具的核心价值。5. 常见问题与排查技巧实录5.1 IPC 通信报错的典型原因新手用 Electron 最容易卡在 IPC 上报错信息往往很模糊。我整理了几种最常见的情况报错现象可能原因解决方法ipcRenderer is not defined渲染进程直接用了 ipcRenderer通过 preload 的 contextBridge 暴露No handler registered for xxx主进程没注册对应 handle检查ipcMain.handle的频道名拼写返回值为 undefinedhandle 里没 return每个 handle 都要有返回值传参丢失方法传了带函数的对象只传纯 JSON 数据contextBridge暴露后拿不到预加载路径写错检查preload路径是否绝对路径其中频道名拼写不一致是最隐蔽的坑。主进程写library:get渲染进程写library:getAll程序不报错就是拿不到数据。我的做法是把频道名抽成常量两边引用同一份定义从根上杜绝拼写问题。5.2 数据文件损坏与并发写入前面提到过 JSON 解析失败的问题这里再补充一个更隐蔽的场景并发写入。如果用户快速连续点击保存两次writeFileSync可能交错执行导致文件内容不完整。虽然writeFileSync是同步的单次调用不会被打断但读—改—写这个组合操作在多处触发时仍可能出问题。我的解决方案是所有写操作都走主进程串行化。因为主进程是单线程的只要把读—改—写封装在一个 handle 里完成就不会有并发问题。渲染进程永远只发我要更新某条记录的请求不自己维护数据副本做写入。这个设计原则叫单一数据源能避免大量数据不一致的 bug。另外建议加一个写入前备份的机制function writeLibrary(data) { const filePath getLibraryPath(); const backupPath filePath .bak; if (fs.existsSync(filePath)) { fs.copyFileSync(filePath, backupPath); } fs.writeFileSync(filePath, JSON.stringify(data, null, 2), utf-8); }多一个.bak文件关键时刻能救命。我有次手动改数据文件改错了格式就是靠备份恢复的。5.3 界面卡顿与渲染性能游戏数量少的时候界面很流畅但如果你像我一样囤了几百个游戏每次render()都重建整个列表就会明显卡顿。优化思路有两个一是虚拟滚动只渲染可视区域内的卡片。这个实现起来稍复杂适合游戏数量超过 500 的场景。二是分页或懒加载先渲染前 50 个滚动到底部再加载更多。对于个人使用几百个游戏用分页就够了没必要上虚拟滚动。还有一个容易忽略的点图片资源。如果给每个游戏配封面图几百张图同时加载会拖慢界面。我的做法是封面图懒加载用IntersectionObserver监听卡片进入视口再设置src。这个 API 用起来很简单const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; observer.unobserve(img); } }); });5.4 打包发布时的注意事项开发时npm start跑得好好的打包后打不开是常见问题。几个排查方向第一路径问题。开发时用__dirname拼路径没问题打包后文件被塞进 asar 归档路径结构会变。加载 HTML 用path.join(__dirname, src, index.html)一般没问题但如果用了相对路径./src/index.html就可能失效。第二用户数据目录。打包后app.getPath(userData)指向的目录和开发时不同这是正常的但如果你在代码里硬编码了开发时的路径就会出问题。永远用app.getPath动态获取。第三原生依赖。如果项目里用了需要编译的原生模块打包时要针对目标平台重新编译。纯 JavaScript 项目没这个问题这也是我坚持不引入复杂依赖的原因之一。打包工具我用的是electron-builder配置写在package.json的build字段里。Windows 平台生成 NSIS 安装包Mac 平台生成 dmg配置项不多官方文档看一遍就能上手。5.5 独家避坑心得分享几个文档里不会写、但实际开发中很关键的经验。第一开发时打开 DevTools 但别依赖它。mainWindow.webContents.openDevTools()在开发时很方便但记得在打包前注释掉或者用环境变量控制。我见过有人打包后忘了关用户一打开程序就弹出调试面板非常尴尬。第二主进程的异常要捕获。主进程一旦抛出未捕获异常整个应用直接崩溃。所有涉及文件操作、网络请求的地方都要 try-catch并且给用户一个友好的错误提示而不是让程序默默消失。第三窗口状态要记忆。用户调整了窗口大小和位置下次打开应该恢复。这个功能实现起来不难在close事件里把mainWindow.getBounds()存起来启动时读出来设置即可。但体验提升很明显是专业感的体现。第四快捷键要支持。至少支持CtrlN新建、CtrlF搜索、Esc关闭弹窗这几个。用globalShortcut或者渲染进程的keydown监听都行。桌面应用没有快捷键用户会觉得别扭。第五数据导入导出。给用户一个导出游戏库为 JSON 的按钮换电脑时能迁移数据。这个功能实现成本极低但用户粘性提升很大——数据在谁手里用户就离不开谁。6. 后续可以怎么扩展这个管家骨架搭起来之后能加的东西其实很多。我列几个自己正在做或打算做的方向给有兴趣的朋友参考。自动扫描本地目录。让用户指定一个游戏安装根目录程序递归扫描子目录识别出可能的游戏文件夹自动匹配到游戏库里。这个功能用 Node.js 的fs.readdir递归实现难点在于怎么判断一个目录是不是游戏——可以靠特征文件比如.exe主程序、steam_api.dll之类来识别。下载进度监控。如果下载工具支持输出日志或者有 API可以读取下载进度实时更新游戏状态。这个需要针对具体的下载工具做适配通用性差一些但做出来体验很好。标签体系与智能筛选。现在的标签是手动输入的可以升级成预设标签加自定义标签的组合再配合搜索框做模糊匹配。搜索用简单的includes就够数据量不大不需要上全文索引。主题切换。现在只有深色主题加一套浅色主题不难CSS 变量都定义好了切换时改:root上的类名即可。甚至可以支持跟随系统主题用nativeTheme模块监听系统变化。数据统计面板。统计一下游戏库的总体积、各状态占比、最近添加的游戏用简单的柱状图或环形图展示。图表可以用纯 CSS 画也可以用轻量的图表库看个人喜好。这个项目最大的价值不在于功能多全而在于它是一个完全属于你自己的工具。市面上的软件再强大也不如自己写的顺手——因为需求是你自己的改起来也是你自己说了算。我从最初的一个想法到能日常使用前后花了大概三个周末中间踩的坑基本都写在这篇文章里了。如果你也想动手做一个建议先从最小可用版本开始能添加、能列表、能改状态这三件事跑通剩下的都是锦上添花。
