ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Electron的桌面CRM系统开发实践:SQLite存储与通信集成

基于Electron的桌面CRM系统开发实践:SQLite存储与通信集成 1. 项目概述为什么要做一个桌面端CRM1.1 核心需求解析DeskcommCRM这个名字拆开看很有意思Desk桌面 comm通信 CRM客户关系管理一句话概括就是“跑在桌面上的、带通信能力的客户关系管理系统”。我最初的想法没那么宏大只是团队里一直在用网页版CRM越用越别扭——每天打开浏览器、登录系统、切标签页配合企微和邮件客户端来回切来切去一天下来光在窗口切换上就得浪费不少时间。于是我想着自己写一套轻量级的桌面CRM把客户信息管理、跟进记录、通信记录整合到一个窗口里不用频繁切换工具。用下来之后收益确实明显——客户资料的完整度提升了、跟进的及时性改善了、跨成员的协作效率也比之前高了不少。这篇文章就把整个项目的核心设计、踩坑过程和技术选型思路完整分享一下给同样在考虑自建CRM或者想优化客户管理流程的朋友做个参考。这套系统适合几类人一是中小型销售团队或服务团队买大型CRM太重、太贵散装Excel又不够用二是个人开发者或独立业务者想自己掌控数据、不想把客户数据存在第三方平台三是对数据安全、客户信息自主可控有要求的团队。它解决的核心问题就三个客户信息统一沉淀、跟进过程不丢失、通信记录自动关联客户。1.2 为什么选桌面端而不是纯Web端先说结论不是Web端不好而是桌面端在特定使用场景下确实有不可替代的优势。Web端CRM的痛点我列一下每次访问需要登录认证、网络不稳定时操作会卡顿、浏览器标签一多就找不到窗口、做系统间数据拷贝时经常被安全策略拦、本地文件与系统之间传数据很麻烦。桌面端天然规避了这些问题——数据在内网走、本地缓存、秒级启动操作体验更接近原生软件。我采用的方案是Electron React SQLite。Electron负责提供跨平台桌面容器React负责界面渲染SQLite做本地数据存储。有人会说“Electron内存占用大”这话没毛病但放到实际场景里——办公电脑普遍16G内存起步——多占两三百MB换来的开发效率和生态资源我觉得这个账是划算的。后续如果要团队化部署可以通过局域网共享数据库文件或者在业务层加一个同步服务实现多人协作。2. 核心设计拆解DeskcommCRM的整体架构与数据模型2.1 模块划分客户管理、跟进记录、通信集成三大核心模块系统整体分三层界面层、业务层、数据层。界面层就是用户操作的各种视图——客户列表、客户详情、跟进时间线、报表看板业务层处理客户增删改查、跟进计划提醒、通信记录的自动关联数据层负责本地SQLite存储和数据库文件备份。模块细化下来是客户信息模块管理公司名、联系人、电话、邮箱、地址、行业分类、客户等级、来源渠道等基础信息。跟进管理模块每次联系客户都留下一笔记录支持文字备注、下次跟进时间设置、跟进方式标记。通信集成模块通过邮件协议和电话API把企业邮箱、电话的通话记录集成到系统里自动关联到对应客户。数据看板模块统计本周跟进数量、待跟进客户数、客户转化率、各阶段客户分布。这几个模块有清晰的数据流客户主表作为核心跟进记录表、通信记录表都以客户ID为外键关联。简单的说就是——“所有动作都围绕客户这个对象展开所有记录都能回溯”。2.2 数据表设计与字段规划直接给一张核心数据表结构我有在项目里实测过按这个结构来基本不用大改表名字段类型说明customersidINTEGER 主键自增客户IDcompany_nameTEXT公司名称必填contact_nameTEXT联系人姓名phoneTEXT联系电话支持手机/座机emailTEXT联系人邮箱industryTEXT所属行业sourceTEXT客户来源官网/转介绍/线下活动levelTEXT客户等级A/B/CstatusINTEGER状态1新客户/2跟进中/3已成交/4已流失remarkTEXT备注信息followupsidINTEGER 主键自增跟进记录IDcustomer_idINTEGER 外键关联客户IDcontentTEXT跟进内容follow_methodTEXT跟进方式电话/邮件/面谈/微信next_follow_dateDATE下次跟进日期created_atDATETIME记录创建时间communicationsidINTEGER 主键自增通信记录IDcustomer_idINTEGER 外键关联客户IDdirectionTEXT呼出/呼入contentTEXT通信内容摘要邮件正文/通话纪要related_typeTEXTmail/callhappened_atDATETIME通信发生时间有一点要特别提醒customer_id索引一定要建否则随着数据量增长关联查询会越来越慢。在SQLite中建索引的语句很简单CREATE INDEX idx_followups_customer ON followups(customer_id); CREATE INDEX idx_communications_customer ON communications(customer_id);2.3 状态机设计客户生命周期管理客户不是静态的它会有从“新客户”到“成交客户”或“流失客户”的流转过程。我在系统里设计了一个简单的状态机新客户刚录入系统还没有任何跟进。跟进中已进行过至少一次有效沟通但尚未成交。已成交完成交易或合作。已流失长期无响应、已明确无需求或主动放弃。这个状态机的价值在于——看板模块可以基于状态做聚合统计比如本周新增多少新客户、有多少从跟进中转为已成交销售管理者一眼就能看出团队的转化效率。状态流转的触发条件尽量简单跟进时选择“本次沟通已明确合作意向”则状态自动更新为跟进中选择“客户已打款/已签合同”则变为已成交。也可以在管理后台手动调整状态保留一定的灵活性。3. 实操过程从零搭建一个可用的DeskcommCRM3.1 开发环境准备与依赖安装开始之前先把环境装齐我用的是以下组合操作系统Windows 11项目主测平台程序本身做了跨平台兼容macOS/Linux也能跑。Node.js18.16.0以上版本建议用LTS版。包管理器npm也可以用yarn或pnpm看个人习惯。代码编辑器VS Code装ESLint插件、Prettier插件。初始化项目并安装核心依赖# 创建项目目录 mkdir deskcomm-crm cd deskcomm-crm # 初始化package.json npm init -y # 安装Electron和React相关依赖 npm install --save-dev electron electron-forge/cli npm install react react-dom npm install better-sqlite3 npm install electron-store npm install date-fns npm install axios其中better-sqlite3是我用得最顺手的SQLite库它是同步API不需要像sqlite3那样写回调或await性能也更好。electron-store用来保存应用配置比如窗口大小、主题偏好、数据目录路径非常简单实用。3.2 主进程与渲染进程的基本框架搭建Electron应用分主进程和渲染进程。主进程负责创建窗口、管理生命周期、访问Node.js能力渲染进程就是界面跑React。两者通过IPC进程间通信协作。主进程的基本代码// main.js const { app, BrowserWindow, ipcMain } require(electron); const path require(path); function createWindow() { const win new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false, } }); win.loadFile(dist/index.html); } app.whenReady().then(() { createWindow(); app.on(activate, () { if (BrowserWindow.getAllWindows().length 0) createWindow(); }); }); app.on(window-all-closed, () { if (process.platform ! darwin) app.quit(); });注意contextIsolation: true和nodeIntegration: false这两个配置——这是Electron应用的安全底线。渲染进程不能直接访问Node.js环境只能通过preload脚本暴露的接口来调用主进程能力。preload脚本// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(api, { getCustomers: () ipcRenderer.invoke(customers:getAll), createCustomer: (data) ipcRenderer.invoke(customers:create, data), updateCustomer: (id, data) ipcRenderer.invoke(customers:update, id, data), deleteCustomer: (id) ipcRenderer.invoke(customers:delete, id), });3.3 数据持久化模块的实现我封装了一个数据库工具类所有数据操作都走这个类统一处理错误和日志// db.js const Database require(better-sqlite3); const path require(path); const { app } require(electron); const fs require(fs); const dataDir app.getPath(userData); const dbPath path.join(dataDir, deskcomm.db); // 确保数据目录存在 if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } const db new Database(dbPath); // 启用外键约束SQLite默认关闭必须手动开启 db.pragma(foreign_keys ON); // 创建表结构 function initDatabase() { db.exec( CREATE TABLE IF NOT EXISTS customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, company_name TEXT NOT NULL, contact_name TEXT, phone TEXT, email TEXT, industry TEXT, source TEXT, level TEXT DEFAULT B, status INTEGER DEFAULT 1, remark TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS followups ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, content TEXT, follow_method TEXT, next_follow_date DATE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS communications ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, direction TEXT, content TEXT, related_type TEXT, happened_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE ); ); } module.exports { db, initDatabase, dbPath };这里有个坑必须讲SQLite的foreign_keys默认是关闭的。如果你不执行db.pragma(foreign_keys ON)外键约束形同虚设删除客户时关联记录不会被级联删除数据就会变成孤儿数据。3.4 通信集成模块关联邮件与电话记录DeskcommCRM的核心亮点是通信集成。我的做法是用IMAP协议轮询企业邮箱同时通过电话服务提供方的API拉取通话记录两者解析后统一写入communications表。以邮件集成举例使用了imapflow库// mail-sync.js const { ImapFlow } require(imapflow); const client new ImapFlow({ host: imap.example.com, port: 993, secure: true, auth: { user: salesexample.com, pass: your-password-here }, logger: false }); async function syncMessages() { await client.connect(); const lock await client.getMailboxLock(INBOX); try { // 只取最近24小时的新邮件 const since new Date(Date.now() - 24 * 60 * 60 * 1000); const messages await client.fetch(SINCE since.toISOString().split(T)[0], { envelope: true, source: true, uid: true }); for await (const message of messages) { // 从邮件头解析发件人邮箱 const sender message.envelope.from[0].address; // 根据邮箱地址匹配客户 const customer db.prepare( SELECT id FROM customers WHERE email ? ).get(sender); if (customer) { db.prepare( INSERT INTO communications (customer_id, direction, content, related_type) VALUES (?, in, ?, mail) ).run(customer.id, message.envelope.subject || ); } } } finally { lock.release(); await client.logout(); } }整个流程拉取邮件 → 解析发件人 → 匹配客户 → 写入通信记录。这样做的价值在于当你打开某个客户详情页时所有历史邮件和通话记录都自动汇总在底部不需要去邮箱里翻。3.5 React界面客户列表、详情页、跟进时间线界面部分的核心组件有四个客户列表、客户详情、跟进表单、数据看板。客户列表页最常用的操作是搜索和筛选。我实现了一个轻量级模糊搜索——按公司名、联系人名、电话进行匹配// CustomerList.jsx 核心逻辑 const handleSearch useCallback((keyword) { const lower keyword.toLowerCase(); const filtered customers.filter(c c.company_name.toLowerCase().includes(lower) || c.contact_name?.toLowerCase().includes(lower) || c.phone?.includes(keyword) ); setFilteredCustomers(filtered); }, [customers]);客户详情页布局是左中右三栏左栏展示客户基本信息和等级标签中间展示跟进时间线按时间倒序排列每次跟进记录右栏是通信记录和操作快捷入口新建跟进、修改状态、删除客户。跟进表单是一个浮层弹窗字段包括跟进方式、跟进内容、下次跟进日期。提交之后写入followups表同时刷新时间线组件。这个交互我测下来业务人员反馈很好——三步就能完成一次完整的跟进记录比在网页系统里找按钮快得多。4. 开发过程中踩过的坑与排查技巧4.1 better-sqlite3在Electron中的原生模块编译问题这是最容易踩的坑也是提issue最多的坑。better-sqlite3是C原生模块直接npm install后放到Electron里跑十有八九会报版本不兼容的错误。原因是Node.js和Electron使用的是不同的V8引擎版本原生模块必须针对Electron版本重新编译。解决办法是用electron-rebuildnpm install --save-dev electron/rebuild # 重新编译原生模块适配Electron npx electron-rebuild -f -w better-sqlite3如果你用的是electron-forge或electron-builder这类打包工具它们通常会在打包前自动rebuild。开发过程中手动执行一次即可解决报错。4.2 SQLite数据库文件锁与多窗口写入冲突本地SQLite适合单进程读取但如果开了多个窗口或者后台有多个任务同时写入会出现SQLITE_BUSY错误。排查后发现Electron主进程本身是单线程的但多个异步任务在时序上可能交叉访问数据库。稳妥做法是给所有数据库写操作加一个队列保证同一时刻只有一个写入任务// WriteQueue.js class WriteQueue { constructor() { this.queue Promise.resolve(); } enqueue(task) { const result this.queue.then(() task()); // 防止队列链中的错误阻塞后续任务 this.queue result.catch(err { console.error(Task failed:, err); }); return result; } } const queue new WriteQueue(); // 使用方式 queue.enqueue(() { const stmt db.prepare(INSERT INTO customers (company_name) VALUES (?)); stmt.run(新客户A); });4.3 通信记录时间格式的坑我在做邮件同步时发现IMAP协议返回的日期格式是Wed, 21 May 2025 14:30:00 0800这种RFC822格式而SQLite的日期函数存的是YYYY-MM-DD HH:MM:SS。直接存字符串会导致排序错乱。处理办法是用date-fns库统一转换const { format } require(date-fns); const { parseRFC2822 } require(date-fns); const parsedDate parseRFC2822(rawDate); const formatted format(parsedDate, yyyy-MM-dd HH:mm:ss);4.4 数据丢失风险与自动备份方案本地应用最大的风险是硬盘坏了或者误删文件。我加了三个层面的保护层面一每次启动时自动备份数据库文件到备份目录。只保留最近7份备份避免磁盘被撑爆const fs require(fs); const path require(path); function backupDatabase() { const dbPath path.join(app.getPath(userData), deskcomm.db); const backupDir path.join(app.getPath(userData), backups); if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir); const stamp new Date().toISOString().replace(/[:.]/g, -); const backupPath path.join(backupDir, deskcomm_${stamp}.db); fs.copyFileSync(dbPath, backupPath); // 删除7天前的备份 const files fs.readdirSync(backupDir).filter(f f.endsWith(.db)); if (files.length 7) { files.sort().slice(0, files.length - 7).forEach(f { fs.unlinkSync(path.join(backupDir, f)); }); } }层面二开启WAL模式。SQLite的WALWrite-Ahead Logging模式在崩溃恢复和并发读写的稳定性上比默认的DELETE模式好很多db.pragma(journal_mode WAL);层面三导出CSV留底。我还在设置页里加了一个“导出全部客户数据为CSV”的按钮方便把数据拿到Excel里做二次分析同时也算一道保险。4.5 渲染进程白屏问题开发中途出现过一次白屏排查下来是dist/index.html路径错误。Electron开发模式下用loadURL(http://localhost:3000)加载Vite/Webpack的dev server打包后要改成loadFile(dist/index.html)。这两个路径切换最容易出问题。我建议在代码里做环境判断const isDev !app.isPackaged; if (isDev) { win.loadURL(http://localhost:3000); } else { win.loadFile(path.join(__dirname, dist/index.html)); }5. 常见问题排查速查表把开发和使用中最常遇到的十类问题整理成一张表方便快速定位问题现象可能原因解决方案安装依赖后启动报“NODE_MODULE_VERSION不匹配”原生模块未适配Electron版本执行npx electron-rebuild -f -w better-sqlite3删除客户后跟进记录/通信记录仍然存在外键约束未启用连接数据库后执行PRAGMA foreign_keys ON查询客户列表越来越慢未建索引或数据量过大为customer_id字段创建索引对大表定期执行VACUUM通信记录日期显示错乱时间格式未统一使用date-fns统一转成YYYY-MM-DD HH:MM:SS格式邮件同步后通信记录重复未做消息去重为communications表增加message_id字段并建唯一索引应用关闭后数据库文件损坏非正常退出开启WAL模式并在退出时手动执行db.close()打包后的应用体积过大Electron打包包含全部依赖使用electron-builder的asar压缩把devDependencies排除掉打开客户详情页卡顿渲染了过多记录用分页加载每次只取30条记录系统休眠后SQLite操作报错连接超时在app.on(resume)事件里重新连接数据库多窗口同时写库报SQLITE_BUSY写入冲突引入WriteQueue保证串行写入还有两个细节值得单独说关于消息去重。邮件同步如果每次全量拉取同一封邮件会被重复插入。我通过添加message_id字段做唯一索引来解决CREATE UNIQUE INDEX idx_communications_msg ON communications(message_id) WHERE message_id IS NOT NULL;后续同步时使用INSERT OR IGNORE重复消息直接跳过。关于备份策略。自动备份别做得太激进——每启动一次就备份一份一个月下来磁盘会被堆满。保留7份再加一个每月强制导出的策略踩过坑之后发现这个方案最省心。6. 数据安全设计多维度保障客户信息不泄露6.1 静态加密与脱敏存储做CRM绕不开数据安全客户的联系电话、邮箱、地址都属于敏感信息。我在DeskcommCRM里加了一层加密逻辑核心字段在写入SQLite之前先用AES-256-GCM加密读取时再解密。实现并不复杂用Node.js内置的crypto模块即可const crypto require(crypto); // 密钥从electron-store读取首次启动时生成 const algorithm aes-256-gcm; const key crypto.randomBytes(32); function encrypt(text) { const iv crypto.randomBytes(12); const cipher crypto.createCipheriv(algorithm, key, iv); const encrypted Buffer.concat([cipher.update(text, utf8), cipher.final()]); const tag cipher.getAuthTag(); return { iv: iv.toString(hex), tag: tag.toString(hex), data: encrypted.toString(hex) }; } function decrypt(encryptedObj) { const decipher crypto.createDecipheriv( algorithm, key, Buffer.from(encryptedObj.iv, hex) ); decipher.setAuthTag(Buffer.from(encryptedObj.tag, hex)); const decrypted Buffer.concat([ decipher.update(Buffer.from(encryptedObj.data, hex)), decipher.final() ]); return decrypted.toString(utf8); }这样即使数据库文件被拷走没有密钥也无法直接读取真实电话号码和邮箱地址。密钥本身再通过safeStorageElectron提供的安全存储接口加密后落盘等于上了双锁。6.2 自动清理与日志审计合规的角度讲客户数据不能无限期保留。我在设置项里加了“数据保留策略”选项提供三个档位180天、365天、永久。到了设定的时间系统会提示用户确认清理长期未跟进的客户及其关联记录。这个过程会在审计日志里留下记录——什么时候删了哪些客户、操作人是谁、操作结果如何避免误操作导致不可恢复。7. 数据看板与业务分析模块7.1 看板指标设计CRM不能只有录入和查询还要能辅助决策。DeskcommCRM里我做了四个核心指标卡片本周新客户数本周新增的客户数量对比上周增长率。待跟进客户数所有状态为“跟进中”的客户数量。本周跟进次数所有团队成员的跟进记录总数。转化率已成交客户 / 全部客户的比例。这些指标用一条SQL就能从数据库里聚合出来-- 本周新客户数 SELECT COUNT(*) FROM customers WHERE created_at datetime(now, -7 days); -- 待跟进客户数 SELECT COUNT(*) FROM customers WHERE status 2; -- 本周跟进次数 SELECT COUNT(*) FROM followups WHERE created_at datetime(now, -7 days); -- 转化率 SELECT ROUND(100.0 * SUM(CASE WHEN status 3 THEN 1 ELSE 0 END) / COUNT(*), 2) AS conversion_rate FROM customers;7.2 看板的实际使用方式看板的价值不在华丽而在直观。我还加了一个“明日待跟进”清单每天早上启动应用时会弹出一个通知列出所有明天计划跟进的客户直接从客户详情页点“去跟进”就能切入记录。真实用下来这个简单的提醒机制对跟进及时率的提升非常明显——之前团队经常忘了某客户约好了周二回电现在系统自动提醒基本没有再发生过。8. 扩展思路与后续计划8.1 多人协作与数据同步目前的版本是单机使用。后续如果要多人协作有两个方向一是通过局域网共享数据库文件。SQLite支持网络文件共享但并发写入的稳定性不好说只适合一到两个人同时使用的小场景。二是加一个同步服务层。每个客户端本地继续用SQLite在业务层引入同步逻辑客户记录带一个updated_at时间戳增量同步时比对时间戳和记录版本号。这个方法复杂度可控也能解决“多端离线可用”的问题。8.2 更深入的通信集成目前邮件支持的是IMAP轮询后续可以换成IMAP IDLE长连接做到邮件到达即触发同步不再等定时轮询。电话这块可以接入运营商API把通话录音文件关联到客户记录里。再加上自动语音转文字通话纪要进一步生成待办事项整个闭环就完整了。8.3 客户分群与智能提醒通过给客户打标签比如“高意向”、“已报价”、“需回访”配合规则引擎做自动化提醒。例如客户进入“已报价”状态后三天内没有跟进自动推送一条提醒给负责人。这是把客户成功方法论固化到系统里的过程。从上到下把DeskcommCRM从项目缘起、技术选型、核心实现到踩坑总结完整梳理了一遍。实际开发下来最大的感受是做垂直场景的桌面工具不必追求大而全把两个核心流程客户信息管理、通信记录关联打磨到极致工具自然会被团队接受。如果你也在做一个类似的桌面工具建议先把数据模型设计清楚、基础功能跑通再逐步加集成和看板别一上来就铺太大摊子。这套架构设计有值得借鉴的地方可以按你的业务场景改一版直接用起来。
返回列表