Electron+SQLite构建本地优先桌面CRM:从需求到落地的完整实践

Electron+SQLite构建本地优先桌面CRM:从需求到落地的完整实践 做销售和客户管理最痛苦的不是没客户而是明明聊过、记录过转头就找不到了。我最初被这个问题折磨到不行的时候在个人电脑上维护着一套 Excel 客户台账后来又换成在线表格再后来发现连在线表格都会被“谁改的、什么时候改的、上一次聊到哪”折磨疯。DeskcommCRM 就是在这段被逼疯的经历里出生的——一个跑在桌面上的客户关系管理CRM工具本地数据库、无云依赖、打开就能用目标是把客户、联系人、跟进记录和待办任务收拢到一个界面上让销售和客服不用每天“考古”。这篇文章写给两类人看一类是正在被客户资料散落问题困扰的小团队负责人或独立销售想弄清楚一个本地优先的 CRM 到底能解决什么另一类是打算自研内部工具的开发者想看看 Electron SQLite 这条技术路线在真实业务里会遇到什么、怎么绕坑。我会把 DeskcommCRM 从需求、选型、核心模块、数据安全到开发期排错的全过程摊开来讲包括那些文档里不会写的细节。1. 从“客户在 Excel 里躺尸”到“我想自己写一个桌面 CRM”1.1 客户资料散落三处我每天都在“考古”你可能有这样的经验客户 A 上周说“下周打款”但具体是周几、打多少、走什么流程只有翻微信聊天记录才能找到客户 B 的市场负责人换人了你们重新做产品介绍时才发现上一个销售留下的联系方式早就过期客户 C 追了三个月中间聊过什么、报价多少、卡在哪个环节关于这个客户的历史脉络需要从三四个文件里拼接出来。我所在的是一个不到十人的业务团队规模不大但客户类型杂、跟进周期长。最早用 Excel 管客户每个人一个文件偶尔合并一次合并时就产生大量冲突和丢数据后来切到在线表格多人协作是方便了可一旦有人退出、断网、误删根本没法追溯。更要命的是客户和联系人可以“存”但跟进过程没有地方“记”时间一长谁也想不起来上次聊到哪儿。那时候我意识到我们缺的不是一个表格而是一个能沉淀“销售过程”的工具。这个工具不需要很强的权限体系不需要十几种跟单报表甚至不需要手机端但它必须做到客户信息不能散跟进痕迹不能丢待办不能忘。1.2 市面上的 CRM 为什么让我这样的五人小团队望而却步我也认真调研过市面上各种 CRM结论是大而全的平台功能确实强大但对这种小团队来说成本和学习曲线都很不友好。先说付费成本。很多 SaaS CRM 按坐席按月收费坐席一多一年下来是一笔不小的开销。再加上初期配置、字段定制、流程搭建没有专人投入基本跑不起来。再说使用成本。功能堆得越多界面越复杂销售本来时间就紧强迫他们在系统里填十几个字段第二周就没人用了。数据安全也是我比较在意的一点客户资料是团队的命脉把它放在云端本地没有完整副本万一服务商出问题或被误删后果不可控。我知道会有人说“用开源 CRM 自己部署”但这又引出了另一个问题我们需要的是一个能开机即用的桌面工具而不是一套需要维护数据库和 Web 服务的系统。于是我动了自研的念头做一个像 Excel 一样容易上手、像数据库一样可靠的本地客户管理软件。DeskcommCRM 这个名字就是“Desk Communication CRM”核心场景非常明确桌面端、沟通记录、客户关系。1.3 DeskcommCRM 要解决的最小闭环是什么项目立项时我把第一版目标收敛成一个最小闭环客户建档 → 添加联系人 → 记录跟进 → 创建任务 → 到期提醒 → 再次跟进。对这个闭环之外的功能比如 BI 报表、销售预测、复杂审批流第一版全部砍掉。在功能边界上我也做了明确取舍。不做云同步因为第一版只有几个人用通过备份文件即可完成数据迁移不做复杂权限只通过 owner 字段区分客户归属不做网页端因为本地桌面应用启动快、离线可用更适合销售日常操作。这个“小”反而是它能跑下去的关键——开发量小维护成本低团队成员学习成本几乎为零打开软件第一眼就知道该干嘛。2. 选型为什么是 Electron SQLite而不是 Web 系统或大型数据库2.1 桌面优先客户数据留在自己电脑上的安全感很多做内部工具的人第一反应是“做个网页系统”但 DeskcommCRM 从第一天就决定做桌面应用这个决定不是拍脑袋而是基于业务场景。第一销售人员的办公环境经常在会议室、客户现场或通勤路上网络不稳定是常态。网页系统断网就白屏桌面应用却能保持本地数据完整这在使用体验上是天壤之别。第二客户数据敏感团队负责人希望数据默认保存在本地硬盘而不是某台服务器上少一层云端传输就少一堆安全和合规问题。第三桌面应用可以利用系统级能力比如原生桌面通知、文件系统访问、系统托盘这些都是浏览器页面能做到但限制很多的。如果非要类比网页 CRM 像是把贵重物品放在公共储物柜里平台负责保管但你也交出钥匙本地桌面 CRM 则更像自己家的保险柜钥匙在自己手里随时打开、随时合上没有网络依赖。2.2 技术栈清单与每项选择的真实理由DeskcommCRM 的主要技术栈如下表所示每一项都是我对比过后才定的模块选型选择理由桌面框架Electron 28生态成熟团队熟悉 Web 技术栈打包和更新方案比较完整界面层React 18 TypeScript 5组件化开发效率高类型系统能在编译期挡掉一堆低级错误渲染进程Vite开发热更新快打包产物体积比 Webpack 方案小本地数据库SQLitebetter-sqlite3单文件数据库零服务部署性能足够事务能力可靠数据库加密SQLCipherSQLite 本身不加密必须引入加密层保护客户数据IPC 通信contextBridge ipcRenderer/ipcMain符合 Electron 安全模型渲染进程不直接接触 Node导入导出ExcelJS对 xlsx 格式支持好能处理单元格格式和样式打包分发electron-builder支持多平台安装包能处理 native 模块重构这里有一个很多人问过的问题为什么不直接在主进程里用 TypeORM 或 Sequelize 操作 SQLite我的回答是这条路径看起来优雅实际上反而绕。SQLite 场景下 SQL 并不复杂五个核心表、几十个字段直接用 prepared statements 足够清晰还能减少一层 ORM 的隐式行为带来的排错成本。better-sqlite3 虽然名字听起来小众但它的同步 API 在 Electron 主进程里非常好用查询性能比某些异步驱动还高不必为“同步会卡 UI”担忧因为数据库操作的逻辑本来就在主进程的独立职责里。2.3 数据模型用五张表撑起客户生命周期DeskcommCRM 的数据模型第一版只设计了五张核心表这五张表基本覆盖了一个客户从线索到成交再到长期维护的全过程。CREATE TABLE IF NOT EXISTS customer ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, company TEXT, phone TEXT, email TEXT, source TEXT, status TEXT NOT NULL DEFAULT lead, owner TEXT, last_follow_time TEXT, remark TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS contact ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL REFERENCES customer(id), name TEXT NOT NULL, title TEXT, phone TEXT, wechat TEXT, email TEXT, is_primary INTEGER DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS follow_up ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL REFERENCES customer(id), contact_id INTEGER, type TEXT NOT NULL, content TEXT, next_action TEXT, next_time TEXT, created_by TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS task ( id INTEGER PRIMARY KEY AUTOINCREMENT, related_type TEXT NOT NULL, related_id INTEGER, title TEXT NOT NULL, due_time TEXT, remind_time TEXT, done INTEGER NOT NULL DEFAULT 0, done_at TEXT, notified_at TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS import_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT, total_rows INTEGER, success_rows INTEGER, failed_rows INTEGER, started_at TEXT, finished_at TEXT );customer 表记录客户主体信息status 字段用字符串枚举取值 lead / active / paying / churn 四档不用数字枚举的原因是想让数据直接可读排查问题时一眼能看懂。contact 表一个客户可以挂多个联系人用 is_primary 标出主联系人。follow_up 表是这套系统的灵魂所有电话、拜访、微信、邮件沟通都沉淀在这里并把“下一步做什么、什么时候做”也一并写入。task 表负责待办和提醒它和 follow_up 是松耦合任务可以由跟进记录生成也可以独立创建。数据库在首次启动时通过一段建表脚本初始化并且在用户数据目录下自动创建索引。索引策略也值得说一句不要见列就建索引我只给 owner、customer_id、due_time、remind_time 这几个高频查询条件建了索引实际查询计划基本保持在毫秒级。2.4 进程隔离与数据访问边界Electron 桌面应用天然有主进程和渲染进程两个世界很多人一开始会图方便把 nodeIntegration 打开让渲染进程直接 require 数据库模块这个做法短期爽长期非常危险。DeskcommCRM 的做法是数据库文件只在主进程中打开渲染进程通过 contextBridge 暴露的 white-list API 访问数据。渲染进程无法通过 devtools 执行任意数据库操作即使某个页面被注入恶意脚本攻击面也被限制在预定义的 IPC 接口里不会直接拿到数据库句柄。说白了渲染进程是前台接待员主进程才是保险库管理员接待员只能通过窗口传话不能自己进保险库。这个结构也带来一个实际好处数据访问规则都收敛在主进程的 service 层后面要加字段级权限、操作审计、日志埋点只要改主进程逻辑不需要动界面。如果一开始就把数据库操作摊在渲染进程里后续每一次权限升级都是一场大改。3. 四个关键功能让我每天打开 DeskcommCRM 而不是 Excel3.1 客户 360 度视图一次查询串起所有业务动作客户管理的核心体验是“打开一个客户一切都在眼前”。我把详情页设计成三栏布局左侧是客户列表中间是客户基本信息右侧是联系人和跟进时间线。这个界面的数据来自一个组合查询而不是多次往返数据库拼装。SELECT c.*, (SELECT COUNT(*) FROM follow_up f WHERE f.customer_id c.id) AS follow_up_count, (SELECT COUNT(*) FROM task t WHERE t.related_type customer AND t.related_id c.id AND t.done 0) AS pending_task_count FROM customer c WHERE c.id ?拿到客户主体后应用层再并行查询联系人列表、跟进记录、待办任务一次性组装成渲染层需要的结构。这里有个小技巧不要在 SQL 里做太多应用逻辑把复杂关联放到服务层用数组和 Map 合并代码更好测试也不容易因为索引的问题写出慢查询。画面上我特意把“上次跟进时间”放到客户列表的第三列这样销售扫一眼列表就能判断哪些客户已经冷掉了。这个字段每次写入跟进记录时都会自动更新不需要手动维护。3.2 跟进时间线用事务保证“记录”与“更新”不脱节跟进记录最容易出的问题就是“客户信息改了跟进记录里的老数据也被覆盖了”。所以设计上我把每次跟进作为一条不可变的时间线事件处理只有 content 允许被本人修改其他字段如创建人、客户快照都原样保留。新增一条跟进时需要同时做两件事插入 follow_up 记录并更新 customer 的 last_follow_time。这两步必须是一个原子操作否则会出现“跟进记录了但客户列表里的时间没更新”这种隐蔽 bug。better-sqlite3 的事务写法很直接const createFollowUp db.transaction((input: FollowUpInput) { const info db.prepare( INSERT INTO follow_up ( customer_id, contact_id, type, content, next_action, next_time, created_by, created_at ) VALUES ( customer_id, contact_id, type, content, next_action, next_time, created_by, created_at ) ).run(input); db.prepare( UPDATE customer SET last_follow_time created_at, updated_at created_at WHERE id customer_id ).run(input); return info.lastInsertRowid; }); const newId createFollowUp({ customer_id: 1, contact_id: 3, type: phone, content: 确认报价客户说要和合伙人商量, next_action: 周一上午电话跟进, next_time: 2025-06-10T09:00:00, created_by: zhang, created_at: new Date().toISOString() });事务函数要求是同步函数所以在 Electron 主进程里用非常顺手。不要把数据库事务里嵌套异步操作如 IPC 通知渲染进程否则事务可能会被挂起这是我后面踩坑时发现的在第五章详细讲。3.3 任务提醒和桌面通知谁也别想再漏掉一个待办任务的本质是“下一步动作”。跟进记录一旦填了 next_action 和 next_time系统会自动生成一条关联客户的任务这种从跟进里派生任务的设计比让销售单独去“新建待办”更符合习惯。桌面通知用 Electron 的 Notification 模块实现主进程每分钟扫描一次任务表把所有 remind_time 已到、未完成、还没通知过的任务查出来批量推通知。setInterval(() { const now new Date(); const tasks db.prepare( SELECT id, title, due_time FROM task WHERE remind_time ? AND done 0 AND notified_at IS NULL ).all(now.toISOString()); for (const task of tasks) { new Notification({ title: DeskcommCRM 跟进提醒, body: ${task.title}截止 ${task.due_time} }).show(); db.prepare(UPDATE task SET notified_at ? WHERE id ?).run(now.toISOString(), task.id); } }, 60 * 1000);这里两个细节值得注意。一是 notified_at 字段它的作用是幂等避免每次启动扫描都重复弹同样的通知二是系统休眠时 setInterval 不执行我监听了主进程 powerMonitor 的 resume 事件唤醒后立即补扫一次把休眠期间错过的提醒补上这样就不会出现“午休回来发现一堆该处理的任务静悄悄过去了”。3.4 Excel 导入导出迁移成本决定工具生死工具做得再好如果客户数据搬不进去一切都是白搭。DeskcommCRM 的导入功能是我花时间打磨最多的模块之一原因很简单销售手里的历史数据格式五花八门有人表头写了“客户名”有人写“单位”有人直接没有表头手机号一列被 Excel 自动变成科学计数法日期字段变成一串数字这些脏数据不处理导入就是一场灾难。导入流程分成三步读取文件、逐行校验、批量写入。ExcelJS 可以指定单元格类型但我更建议读取时统一按“原始值”处理再做格式归一化。手机号被 Excel 转成科学计数法的问题在导出模板时就提前防范把 phone 列设置为文本格式导入时再做一个兜底把数字型手机号转回字符串function parsePhone(value: any): string | null { if (value null || value undefined) return null; if (typeof value number) { return String(Math.round(value)); // 防止科学计数法 } const str String(value).trim(); if (/^\d{6,15}$/.test(str)) return str; return null; }导入采用事务批量插入每 200 条提交一批单批失败不会影响前面已提交的数据。校验不通过的行不会直接丢弃而是收集到错误列表中在界面上展示每一行失败的原因用户改完 CSV 可以继续导。这个“错误行回显”功能当时加了两个晚上但实际使用中它把迁移的挫败感降低了一大截。导出功能则相对简单按客户列表生成 xlsx列宽、冻结首行、状态字段用中文标签销售拿出去做汇报或者导出后二次编辑体验和 Excel 原生一致。4. 上班前必须做完的功课数据库加密与自动备份4.1 给 SQLite 上锁SQLCipher 接入与密钥管理SQLite 默认的数据库文件是明文存储任何能拿到文件的人都能用工具打开读数据。客户资料一旦落到移动硬盘、备份盘或被带走的旧电脑上等于裸奔。所以 DeskcommCRM 从一开始就决定接入 SQLCipher 做透明加密。集成方式上我用的是 better-sqlite3-multiple-ciphers它可以直接用 better-sqlite3 的 API只是打开数据库时需要先设置密钥const Database require(better-sqlite3-multiple-ciphers); const db new Database(dbPath); db.pragma(key deskcomm-secret-key);密钥管理是关键。不能把密钥硬编码在源码里更不能随安装包分发。我的做法是使用 Electron 的 safeStorage 模块在应用首次启动时随机生成一个数据库密钥用 safeStorage.encryptString 加密后存入用户本地的配置文件每次启动时通过 safeStorage.decryptString 解出密钥再打开数据库。这样密钥以系统级加密的方式存在当前用户环境里即使有人复制走了配置文件换了机器也无法解密。这个方案不需要额外引入 keytar 之类的原生依赖安全级别也足够。接入加密后要留意一点SQLCipher 对每条 SQL 都有加解密开销但对单机 CRM 这种查询规模来说体验上完全无感。真正有感知的是备份文件它也是密文反而更让人放心。4.2 自动备份做不成多云容灾但能对抗手滑单机应用最大的风险不是黑客而是手滑和硬盘损坏。我给 DeskcommCRM 设计了一套很轻量的自动备份机制应用启动时、关闭前、每天中午十二点这三个时间点自动备份数据库文件到 userData 目录下的 backups 文件夹文件名带时间戳只保留最近 14 份。better-sqlite3 提供了 backup 接口可以安全热备份不用停机async function backupDatabase(): Promisestring { const backupDir path.join(app.getPath(userData), backups); await fs.mkdir(backupDir, { recursive: true }); const fileName deskcomm_backup_${new Date().toISOString().replace(/[:.]/g, -)}.db; const target path.join(backupDir, fileName); await db.backup(target); // 清理超过14天的备份 const files await fs.readdir(backupDir); const sorted files.filter(f f.endsWith(.db)).sort(); for (let i 0; i sorted.length - 14; i) { await fs.unlink(path.join(backupDir, sorted[i])); } return target; }我建议小团队也把“备份恢复演练”变成必做项不要等到硬盘报废才去翻备份文件。我们有一次为了测试恢复流程特意把备份文件拷到另一台全新电脑上输入同样的主密钥成功打开确认所有客户和跟进记录都完好后才相信这套方案真的可靠。4.3 加密导出跨电脑迁移的稳妥姿势当销售换电脑、或者需要在另一台笔记本上继续跟单时DeskcommCRM 提供一个“导出加密归档”功能。它的本质是把当前数据库文件以密文方式复制到用户指定的目录比如移动硬盘或网盘私有空间。因为数据库文件本身就是 SQLCipher 加密的所以直接复制就可以作为归档不需要额外压缩。导入侧只需要在目标电脑的 DeskcommCRM 里导入这个加密库文件系统会用同一个主密钥解封。这里有一个限制要提前说清楚如果两台电脑的系统用户不同safeStorage 解出的密钥可能不一致所以归档导入时会先要求用户输入当前主密钥避免跨机器解不开的情况。这个交互虽然多了一步但换来的是明明白白的安全边界。5. 开发期踩过的坑四条让我加班到深夜的报错信息5.1 database is lockedSQLite 并发没有想象中那么“宽容”第一次压测时我快速连续点击保存跟进、创建任务、修改客户界面卡了几秒后控制台抛出一行 SQLITE_BUSY: database is locked。刚看到时很困惑因为 better-sqlite3 不是同步 API 吗同步代码怎么会锁库排查下来问题出在两个地方。第一我在一个事务里调用了 webContents.send 通知渲染进程刷新这个操作会引入异步边界事务迟迟无法提交锁被无限期持有。第二多个 IPC handler 可能同时进入写路径虽然同步代码在单线程内不会真正并行但逻辑上先进入的写事务还没结束后进入的写请求就会碰到锁。解决方案有两层。第一层是给 SQLite 加上 WAL 模式并调大 busy_timeoutdb.pragma(journal_mode WAL); db.pragma(busy_timeout 5000);WAL 模式显著提高读写并发能力busy_timeout 让访问冲突时等待而不是立即报错。第二层是在主进程内实现一个简单的串行写队列所有写操作通过队列排队执行从源头上避免写事务交叉let writeQueue Promise.resolve(); function enqueueWriteT(fn: () T): PromiseT { const next writeQueue.then(() fn()); writeQueue next.then(() {}, () {}); return next; }队列方案虽然丢了一点“并发”但单个桌面应用的写吞吐没那么高换来的是稳定性和可预测性。这个原则也推荐给所有用 better-sqlite3 的 Electron 项目宁可排队不要锁竞争。5.2 白屏问题contextBridge 配置错的连锁反应DeskcommCRM 在开发环境跑得好好的一打包出来就白屏。查了整整一天发现是 preload 配合 contextBridge 的问题。我最初的 Electron 窗口配置是 nodeIntegration: falsecontextIsolation: true这本身是安全默认值但 preload 脚本里我用 require 引入了 ipcRenderer 并挂到 contextBridge却忘了把 sandbox 设为 false。Electron 20 之后的 sandbox 默认开启sandbox 模式下 preload 脚本的 require 能力受限导致 contextBridge 暴露 API 失败渲染进程拿不到任何数据页面自然白屏。修正后窗口配置长这样new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, preload.js), nodeIntegration: false, contextIsolation: true, sandbox: false } });这里要强调sandbox: false 不是放弃安全而是因为 preload 需要 require Electron 与 Node 的部分 API这在 sandbox 环境下不被允许。如果完全不想降低沙箱等级就需要把 preload 打包成单文件去除 require 动态引入但维护成本更高。对内部工具来说我认为 sandbox: false 配合 contextIsolation: true 已经是一个可接受的折中。5.3 中文用户名路径下的原生模块崩溃有一位同事的 Windows 系统用户名是中文装完 DeskcommCRM 后数据库一直打不开排查时发现 better-sqlite3 的原生 .node 模块在加载时抛出了找不到模块的错误。原因是打包后原生模块被放在 app.asar.unpacked而 Electron 在加载这些模块时会经过使用系统临时目录的解压过程当系统临时目录路径含中文和空格时某些 Windows 版本下的加载逻辑会出问题。解决的组合拳是electron-builder 配置里主动声明 asarUnpack 把 native 模块释放到目录外同时让数据库文件路径使用固定英文子目录。这里我不建议用户去改系统用户名而是要求安装时不要选择中文路径安装包内部把数据目录规范化为 app.getPath(userData) 下的 deskcomm-data 英文目录这个目录在绝大多数情况下是英文的即使用户名是中文Electron 也会在应用层做路径处理。实际项目里给同事换成纯英文安装路径后问题消失。5.4 自动更新没有签名的 Windows 应用怎么发版给团队分发桌面应用最麻烦的不是做安装包而是后续的版本更新。我用 electron-updater 配了自动更新发布源用的私有服务器流程基本跑通。但在 Windows 上没有代码签名证书时SmartScreen 会拦截安装包团队成员第一次安装需要多点几步“仍要运行”体验很受影响。我的取舍是第一版不花钱买证书提供两种更新通道一是自动更新通道用于小版本二是手动下载完整安装包用于大版本等团队规模扩大、对外分发需求明确后再采购证书。另外我把每次发布都做成自动化脚本由打包脚本生成 latest.yml 并上传到发布服务器客户端下次启动时自动比对版本号并提示更新。这个链路虽然算不上复杂但极大减少了“你在用旧版怪不得那个 bug 还在”的沟通成本。6. 一年之后回头看DeskcommCRM 到底值不值得6.1 团队使用前后的真实效率对比DeskcommCRM 上线一年后我做了一次粗粒度的统计。先说每天都要用到的高频动作查一个客户的历史跟进以前靠微信聊天记录搜索加 Excel 定位平均要三四分钟现在进入客户详情页十秒内全部看到每周跟进任务的漏处理数从之前每周三到五个降到现在基本为零因为桌面通知会在该打电话的时候直接弹出来新同事接手一个存量客户以前需要找老销售口头交接加翻聊天记录至少半天现在打开客户的时间线十天前的沟通、报价、卡点和下一步计划都写着呢十分钟就能进入状态。这些都是很朴素的数据没有夸张的“业绩翻倍”但团队感受到最明显的变化是客户资产不再只存在于某几个人的记忆里它变成了一个可持续翻阅、可交接、可追溯的过程记录。对一个销售驱动的团队来说这个转变比任何花哨的数据看板都重要。6.2 如果再给我一次机会这三件事会提前做第一件是数据字段的扩展性设计。第一版把 phone、email、company 都做成了固定字段后来想加“所在地区”“客户等级”“来源渠道”时经历了两次表结构变更。如果一开始在 customer 表里预留一个 json 扩展字段后期就从容很多。第二件是操作日志。项目上线几个月后有人问“客户状态是谁改的”我只能说查不到。虽然我们有 owner 字段但没有把状态修改历史记录下来。后来补了操作日志表但这个字段本应该从第一版就存在因为权限和审计这类能力后面补永远比一开始设计贵得多。第三件是备份恢复的用户界面。现在的备份是自动完成的但恢复入口藏得比较深团队里有人换电脑时总要找我帮忙。如果一开始就把“备份”“恢复”“迁移”三个操作做成显眼的一级功能整个数据迁移动线会流畅很多。6.3 接下来的路局域网协作与更细粒度的权限DeskcommCRM 目前的定位是单机桌面工具但团队已经有七八个人各自的数据无法实时共享这成了新的瓶颈。我的计划是在保持“本地优先”的前提下增加可选的局域网协作模式主数据库架在其中一台常开电脑上其他客户端通过局域网连接离线时继续用本地副本网络恢复后再做增量合并。这个方案不需要云服务器数据仍然在团队可掌控的范围内但会引入不少分布式一致性的复杂度。权限方面下一步要按角色区分只读、编辑和管理员至少让“客户归属人”和“团队负责人”的能力边界清晰起来。这条路还很长但 DeskcommCRM 的架构给这些扩展留了空间。对我来说亲手做出来的东西解决了一个真实的业务问题并且在持续演进这就是做这个项目最大的回报。