1. 项目背景与需求定位
1.1 为什么还要再做一套CRM
先说个背景。市面上CRM系统已经多到让人眼花缭乱,Salesforce、HubSpot、纷享销客、销售易,随便拎一个出来都是大厂背景、功能齐全。但真到一线业务团队用起来,你会发现一个尴尬的事实:系统是买回来了,客户数据也录进去了,可销售还是习惯用Excel管理自己的跟单进度,客服还是把客户问题记在微信聊天记录里。问题出在哪?不是CRM不好用,是通用CRM离实际业务场景太远了。
我做的这个DeskcommCRM,定位很明确:不是要做另一个大而全的CRM,而是做一个打通“桌面办公+内部沟通+客户管理”的轻量级系统。从名字就能看出来,Desk代表桌面端,comm是communication,指内部沟通与消息协作。这套系统解决的核心痛点,是小团队里销售、客服、运营三个人各管一摊、信息割裂的问题。它让每个业务人员在自己的桌面上,就能完成客户建档、跟进记录、任务提醒、消息沟通这几件核心事,不需要频繁切换系统。
1.2 这个项目适合谁参考
如果你正在发愁以下这些问题,那这个项目的设计思路和实施方案值得你认真看一下:
- 团队人数在5到50人之间,业务链条不长,但客户跟进全靠个人记忆,没有任何沉淀;
- 已经用过某些SaaS CRM,但觉得功能太复杂,配置成本太高,团队懒得用;
- 想自己开发一套内部客户管理系统,但不知道从哪下手,尤其是消息与业务数据怎么打通;
- 有桌面端应用开发需求,想找一个“Electron + 前端框架 + 轻量后端”的可参考组合。
我在这篇文章里,会把整个项目的完整设计思路、模块拆分、数据库结构、核心代码实现,以及实际开发中踩过的坑,全部整理出来。不管你是产品经理、前端开发者还是全栈工程师,都能从里面找到可以直接拿来用的部分。
1.3 项目最终交付了什么
简单交代一下做完之后的成果。DeskcommCRM采用桌面客户端模式,支持Windows和macOS双平台启动,拥有客户信息管理、跟进记录时间线、任务待办提醒、内部消息沟通、销售数据看板五大核心模块。整套系统支持局域网内独立部署,也可以单机运行。数据通过SQLite落本地,消息通过WebSocket实时推送,不依赖外部云服务。
说白了,就是一套能装在自己电脑上、数据掌握在手里的轻量CRM。接下来我会从思路、选型、实现、排错几个维度,把整个项目的过程完整拆给你看。
2. 架构设计与技术选型
2.1 为什么选了桌面端而不是Web端
做这个项目之前,我先把产品形态想了一遍。Web端CRM是主流形态,但有个绕不开的问题:使用体验受浏览器制约,通知提醒做不到系统级,消息推送延迟高,而且用完还得记得打开网页。对销售和客服这类每天长时间对着电脑的岗位来说,一个常驻桌面、能弹系统通知、消息实时到达的客户端,体验要好得多。
所以我决定做桌面端。当时考虑了三套方案:
- Electron + React:生态最成熟,社区资源多,调试方便,缺点是打包体积大,内存占用偏高;
- Tauri + Vue:打包体积小,性能好,但当时WebView兼容性还需要多处理一层,团队不熟悉Rust;
- Qt / C++:性能最佳,但开发效率低,界面做不出Web级的效果。
最后选了Electron + React + TypeScript的组合。原因很实际:团队前端技术栈最熟,Electron的生态系统能解决桌面端绝大部分场景问题,而且后续如果要加功能,前端资源好找。
2.2 后端与数据存储怎么定
后端没有单独拆一个服务出来,而是让Electron主进程承担了部分后端职责,业务层逻辑放在渲染进程,数据层放在主进程,两者通过IPC通信。这样做有一个明显好处:整个应用可以单机跑起来,不依赖部署服务器。
数据存储选型上有过一番纠结。最初考虑用MySQL,但仔细想了想,这个场景下根本没必要。客户管理系统单机版,数据量最多也就是几万条客户记录、几十万条跟进记录,SQLite完全能扛住。而且SQLite是文件型数据库,备份就是复制一个文件,对非技术背景的销售主管来说,这是最友好的数据管理方式。于是定了:数据层用SQLite,通过better-sqlite3这个同步API的库来操作。虽然是桌面应用,但客户管理系统的并发量非常低,完全不需要异步数据库操作来撑性能。
消息推送这一层,用Node.js的ws库做WebSocket服务,内置在Electron主进程里。同局域网内的客户端可以直连。单机使用时,主进程和渲染进程直接走内存事件;多人使用时,通过WebSocket广播消息。
2.3 整体目录结构设计
项目代码组织上,我采用了前后端分离但同仓管理的结构。前端部分分成主进程和渲染进程两块,共用TypeScript类型定义。核心目录结构如下:
deskcomm-crm/ ├── electron/ │ ├── main.ts // 主进程入口 │ ├── db.ts // SQLite数据库封装 │ ├── socket.ts // WebSocket服务 │ ├── ipc.ts // IPC通信处理 │ └── preload.ts // 预加载脚本 ├── src/ │ ├── pages/ │ │ ├── Dashboard/ // 数据看板 │ │ ├── Customers/ // 客户管理 │ │ ├── Tasks/ // 任务待办 │ │ ├── Messages/ // 内部通信 │ │ └── Statistics/ // 统计报表 │ ├── components/ // 通用组件 │ ├── store/ // 状态管理 │ └── utils/ // 工具函数 ├── shared/ │ └── types.ts // 共享类型定义 └── package.json这个结构看起来普通,但实际开发中它帮了大忙。主进程和渲染进程的代码严格分离,避免Electron里最常见的主进程引用DOM对象、渲染进程直接操作Node API这类混乱。shared/types.ts让两端的对象结构保持一致,改一个字段类型,两端同时报错,从类型层面就堵住了很多低级bug。
3. 核心功能模块与数据库设计
3.1 五大模块各自解决什么问题
DeskcommCRM的功能设计,完全围绕一线业务人员的真实操作流展开,没有为了堆功能而做功能。我在设计之初,先列了一个业务人员的日常路径:
- 拿到一个线索 -> 在系统里建档
- 联系客户 -> 记录通话/微信沟通内容
- 有下一步动作 -> 创建跟进任务,设置提醒
- 需要同事配合 -> 发起内部消息沟通
- 定期看数据 -> 打开统计看板,看转化情况
顺着这条路径,每个环节对应一个模块,就成了最终的五大模块。客户管理负责建档和查档,跟进记录为每个客户生成一条时间线,任务待办负责提醒和跟进计划,内部通信做协作,数据看板管分析。
3.2 数据库表结构详细拆解
数据库是这套系统的地基。我设计了六张核心表,下面把每张表的关键字段和设计意图都说清楚。
customers表——客户主表
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | TEXT | 客户ID,UUID |
| name | TEXT | 客户姓名或企业名称 |
| phone | TEXT | 联系电话 |
| TEXT | 微信号 | |
| company | TEXT | 公司名称 |
| source | TEXT | 线索来源:官网、转介绍、展会、广告 |
| level | INTEGER | 客户等级:1-5,5为最高 |
| status | TEXT | 状态:new、following、negotiating、won、lost |
| owner_id | TEXT | 负责人ID |
| remark | TEXT | 备注信息 |
| created_at | TEXT | 创建时间 |
| updated_at | TEXT | 更新时间 |
客户状态的设计,我建议不要做得太复杂。最开始我设计了八个状态,结果实际用的时候销售根本不知道选哪个,后来精简到五个,反而是最舒服的状态。owner_id字段一定要建索引,因为客户列表页最常用的筛选条件就是“我负责的客户”。
follow_ups表——跟进记录表
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | TEXT | 记录ID |
| customer_id | TEXT | 关联客户ID |
| user_id | TEXT | 跟进人ID |
| type | TEXT | 跟进方式:call、wechat、visit、other |
| content | TEXT | 跟进内容 |
| next_action | TEXT | 下一步计划 |
| next_time | TEXT | 下次跟进时间 |
| status | INTEGER | 是否已完成,0/1 |
| created_at | TEXT | 创建时间 |
这张表是整个系统的精华所在。每个客户都能拉出一条时间线,从第一次接触到最近的沟通内容一目了然。next_time字段为任务模块提供数据基础,用定时器扫描这个字段能自动生成待办提醒。
tasks表——任务待办表
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | TEXT | 任务ID |
| customer_id | TEXT | 关联客户ID |
| title | TEXT | 任务标题 |
| desc | TEXT | 任务描述 |
| due_time | TEXT | 截止时间 |
| priority | INTEGER | 优先级:1-3 |
| status | TEXT | 状态:pending、done、cancelled |
| created_at | TEXT | 创建时间 |
messages表——内部消息表
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | TEXT | 消息ID |
| from_user | TEXT | 发送人 |
| to_user | TEXT | 接收人 |
| type | TEXT | 消息类型:text、system |
| content | TEXT | 消息内容 |
| related_customer | TEXT | 关联客户ID,可为空 |
| read | INTEGER | 是否已读 |
| created_at | TEXT | 发送时间 |
内部通信表加了一个related_customer字段,这是和其他聊天工具有显著区别的地方。业务人员在聊客户情况时,可以把消息直接关联到某个客户,系统会自动在客户的跟进时间线里同步一条记录。这样客户信息、沟通记录、协作消息就彻底打通了。
users表与stats_cache表
users表字段就是id、name、avatar、role、created_at,比较简单。值得一提的stats_cache表,用于存储客户状态统计的缓存数据。因为统计看板需要频繁计算客户各状态的数量、本周新增客户数、跟进次数等指标,每次都去全表扫描会很慢,所以用一个缓存表定时更新,展示时直接读缓存。
3.3 数据库连接与初始化实现
数据库这一块的代码,用了better-sqlite3的同步API,整体写起来非常顺。下面是数据库初始化和建表的完整代码:
// electron/db.ts import Database from 'better-sqlite3'; import { app } from 'electron'; import path from 'path'; import fs from 'fs'; const dbPath = path.join(app.getPath('userData'), 'deskcomm.db'); if (!fs.existsSync(dbPath)) { fs.writeFileSync(dbPath, ''); } const db = new Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS customers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, phone TEXT, wechat TEXT, company TEXT, source TEXT DEFAULT 'other', level INTEGER DEFAULT 3, status TEXT DEFAULT 'new', owner_id TEXT, remark TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')), updated_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS follow_ups ( id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, user_id TEXT, type TEXT DEFAULT 'call', content TEXT, next_action TEXT, next_time TEXT, status INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_customers_owner ON customers(owner_id); CREATE INDEX IF NOT EXISTS idx_followups_customer ON follow_ups(customer_id); CREATE INDEX IF NOT EXISTS idx_followups_nexttime ON follow_ups(next_time); `);建索引这块值得多说一句。起初我并没在意索引,数据量到了两万条左右,打开客户列表明显感觉到卡顿。后来加上owner_id和customer_id的索引后,查询速度直接从几百毫秒降到了十几毫秒。索引对SQLite这种嵌入式数据库的查询性能提升是决定性的,千万别省。
3.4 主进程IPC通信设计
既然选了Electron,主进程和渲染进程的通信是绕不开的一环。我统一用ipcMain.handle来做请求响应式的通信,渲染进程通过ipcRenderer.invoke调用。这样代码结构最清晰。
// electron/ipc.ts import { ipcMain } from 'electron'; import { db } from './db'; import { v4 as uuidv4 } from 'uuid'; export function registerIpcHandlers() { // 客户管理相关 ipcMain.handle('customer:create', (event, data) => { const id = uuidv4(); db.prepare(` INSERT INTO customers (id, name, phone, wechat, company, source, level, status, owner_id, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, data.name, data.phone || '', data.wechat || '', data.company || '', data.source || 'other', data.level || 3, data.status || 'new', data.ownerId || '', data.remark || ''); return { success: true, id }; }); ipcMain.handle('customer:list', (event, query) => { const { keyword = '', status = '', ownerId = '' } = query; let sql = `SELECT * FROM customers WHERE 1=1`; const params: string[] = []; if (keyword) { sql += ` AND (name LIKE ? OR phone LIKE ? OR company LIKE ?)`; const kw = `%${keyword}%`; params.push(kw, kw, kw); } if (status) { sql += ` AND status = ?`; params.push(status); } if (ownerId) { sql += ` AND owner_id = ?`; params.push(ownerId); } sql += ` ORDER BY updated_at DESC LIMIT 100`; return db.prepare(sql).all(...params); }); ipcMain.handle('customer:update', (event, data) => { const fields = Object.keys(data).filter(k => k !== 'id').map(k => `${k} = ?`); const values = Object.keys(data).filter(k => k !== 'id').map(k => data[k]); db.prepare(` UPDATE customers SET ${fields.join(', ')}, updated_at = datetime('now', 'localtime') WHERE id = ? `).run(...values, data.id); return { success: true }; }); ipcMain.handle('customer:delete', (event, id) => { db.prepare('DELETE FROM customers WHERE id = ?').run(id); return { success: true }; }); // 跟进记录相关 ipcMain.handle('followup:list', (event, customerId) => { return db.prepare('SELECT * FROM follow_ups WHERE customer_id = ? ORDER BY created_at DESC').all(customerId); }); ipcMain.handle('followup:create', (event, data) => { const id = uuidv4(); db.prepare(` INSERT INTO follow_ups (id, customer_id, user_id, type, content, next_action, next_time, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run(id, data.customerId, data.userId, data.type, data.content, data.nextAction || '', data.nextTime || '', 0); return { success: true, id }; }); }这套IPCHandler有一个好处:数据库操作全部收敛在主进程,渲染进程不直接触碰Node API。从安全角度看,即使渲染进程被攻击,攻击面也不会直接落在数据库上。从架构角度看,以后要把单机版升级成服务器版,只需要把IPC Handler替换成HTTP API调用,前端代码几乎不用动。
4. 实操过程与核心实现
4.1 前端页面的组件化设计
渲染进程的UI组件,我选用了React + Ant Design这套组合。选Ant Design的原因很直接:它的表格、表单、时间线、通知提醒组件是现成的,做管理系统效率非常高。这里不展开所有的UI细节,重点讲几个核心场景的实现思路。
客户列表页是整个系统用得最频繁的页面。在设计上,我采用左侧列表+右侧抽屉详情的方式。左侧是客户表格,展示姓名、状态、等级、负责人、最近跟进时间;点击某一条记录后,右侧滑出抽屉,展示客户详情、跟进时间线、待办任务。
// src/pages/Customers/index.tsx 关键逻辑 const [customers, setCustomers] = useState<Customer[]>([]); const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null); const [keyword, setKeyword] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const loadCustomers = async () => { const result = await window.api.customer.list({ keyword, status: statusFilter, ownerId: currentUser.id }); setCustomers(result); }; useEffect(() => { const timer = setTimeout(loadCustomers, 300); return () => clearTimeout(timer); }, [keyword, statusFilter]);搜索这里做了一个300毫秒的防抖,避免每次按键都触发一次数据库查询。实际使用中,输入关键词后顿一下再出结果,比边输边查要流畅得多。
客户详情抽屉里最核心的是跟进时间线组件。用Ant Design的Timeline组件,拉取该客户的所有跟进记录,按时间倒序排列,每条记录显示跟进方式、内容、下一步计划。
<Timeline items={followUps.map(f => ({ color: f.type === 'call' ? 'blue' : f.type === 'wechat' ? 'green' : 'gray', children: ( <div> <div className="follow-header"> <span>{followTypeMap[f.type]}</span> <span>{f.created_at}</span> <span>{f.user_name}</span> </div> <div className="follow-content">{f.content}</div> {f.next_action && ( <div className="follow-next"> 下一步:{f.next_action}({f.next_time}) </div> )} {f.status === 0 && f.next_time && ( <Button size="small" onClick={() => completeFollowUp(f.id)}>标记完成</Button> )} </div> ) }))} />这一段代码看着普通,但用起来特别顺手。销售打开客户详情,整个跟进历史一眼就能看完。特别是“下一步动作+时间”这个信息,直接驱动了任务模块的提醒逻辑。
4.2 消息模块的实时通信实现
消息模块用的是WebSocket。Electron主进程里启动一个WebSocket服务,渲染进程通过ws客户端连接。由于是在本机跑的,连接地址直接写ws://localhost:端口号。
// electron/socket.ts import { WebSocketServer } from 'ws'; import { db } from './db'; export function startSocketServer() { const wss = new WebSocketServer({ port: 9789 }); wss.on('connection', (ws, req) => { const userAgent = req.headers['user-agent'] || ''; // 从URL参数中解析用户ID const params = new URL(req.url, 'http://localhost').searchParams; const userId = params.get('userId'); ws.userId = userId; // 用户上线,推送未读消息 const unreadMessages = db.prepare(` SELECT * FROM messages WHERE to_user = ? AND read = 0 ORDER BY created_at ASC `).all(userId); ws.send(JSON.stringify({ type: 'unread_messages', data: unreadMessages })); }); wss.on('message', (data) => { // 广播消息给对应接收人 }); } // 推送新消息给指定用户 export function notifyUser(userId: string, message: any) { wss.clients.forEach(client => { if (client.userId === userId && client.readyState === 1) { client.send(JSON.stringify({ type: 'new_message', data: message })); } }); }在双人聊天场景下,发消息的流程是这样的:发送人调用saveMessage把消息存入数据库,然后通过notifyUser推送给接收人。接收人收到WebSocket消息后,更新本地状态并弹出系统级通知。如果接收人离线,消息落在数据库里,上线时通过unread_messages一次性拉取。
这里有一个实用的经验:WebSocket端口号不能写死,否则多开客户端会冲突。最终实现是让主进程先找一个可用端口,再通过环境变量传给渲染进程。
4.3 任务提醒的定时扫描机制
任务提醒不能靠用户自己去翻任务列表,必须主动弹通知。这部分的实现逻辑:主进程启动一个定时器,每30秒扫描一次follow_ups表,找出next_time在当前时间前后5分钟内的记录,且状态为0(未完成),然后触发系统通知。
// electron/scheduler.ts export function startScheduler() { setInterval(() => { const now = new Date(); const fiveMinutesLater = new Date(now.getTime() + 5 * 60 * 1000); const dueTasks = db.prepare(` SELECT f.*, c.name as customer_name FROM follow_ups f JOIN customers c ON c.id = f.customer_id WHERE f.status = 0 AND f.next_time IS NOT NULL AND f.next_time != '' AND f.next_time BETWEEN ? AND ? `).all( formatDateTime(now), formatDateTime(fiveMinutesLater) ); dueTasks.forEach(task => { notifyUser(task.user_id, { type: 'task_reminder', title: '待跟进提醒', body: `${task.customer_name}的跟进时间到了` }); }); }, 30 * 1000); }定时扫描的机制,比我一开始想的“精确到秒的定时任务”要可靠得多。真实场景下,用户设定的下次跟进时间一般是“下午3点”“明天上午”这类的模糊时间,5分钟的窗口足够覆盖了。而且如果程序当时没在运行,漏掉了某个提醒,用户下次打开系统后,还能在任务列表里看到这个未完成任务,不会完全丢失。
4.4 数据看板的SQL统计实现
数据看板是老板最关心的模块。设计上我做了三个指标卡片加一个趋势图:客户总数、本月新增客户数、本月跟进次数、销售漏斗转化率。统计逻辑直接写在SQL里,比在JS层做聚合要高效得多。
ipcMain.handle('stats:dashboard', () => { const totalCustomers = db.prepare('SELECT COUNT(*) as count FROM customers').get().count; const thisMonthStart = formatDateTime(new Date(new Date().getFullYear(), new Date().getMonth(), 1)); const newCustomersThisMonth = db.prepare( 'SELECT COUNT(*) as count FROM customers WHERE created_at >= ?' ).get(thisMonthStart).count; const followUpsThisMonth = db.prepare( 'SELECT COUNT(*) as count FROM follow_ups WHERE created_at >= ?' ).get(thisMonthStart).count; const statusCounts = db.prepare( 'SELECT status, COUNT(*) as count FROM customers GROUP BY status' ).all(); // 最近7天新增客户趋势 const last7Days = []; for (let i = 6; i >= 0; i--) { const day = new Date(); day.setDate(day.getDate() - i); const dayStart = formatDateTime(new Date(day.getFullYear(), day.getMonth(), day.getDate())); const dayEnd = formatDateTime(new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1)); const count = db.prepare( 'SELECT COUNT(*) as count FROM customers WHERE created_at >= ? AND created_at < ?' ).get(dayStart, dayEnd).count; last7Days.push({ date: day.toISOString().split('T')[0], count }); } return { totalCustomers, newCustomersThisMonth, followUpsThisMonth, statusCounts, last7Days }; });SQL批量统计还有一个好处,就是一次IPC调用返回整个看板所需的数据,不会有多次往返调用的延迟。看板页面在useEffect里调用一次,数据全回来了。
4.5 数据备份与恢复
SQLite的数据备份很简单,就是文件拷贝。我在设置页面做了个“备份数据”按钮,点击后把deskcomm.db文件复制到用户选择的目录。恢复数据时,关闭数据库连接,把备份文件复制回原位置,再重启数据库连接。
// 备份数据 ipcMain.handle('backup:create', async (event, backupPath) => { const sourcePath = dbPath; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const destPath = path.join(backupPath, `deskcomm-backup-${timestamp}.db`); await fsPromises.copyFile(sourcePath, destPath); return { success: true, path: destPath }; });这样一份数据备份方案,对非技术用户来说几乎没有学习成本。我甚至建议管理员每周手动备份一次,反正就是点几下的事。
5. 常见问题与排查技巧
5.1 开发中踩过的典型坑
第一个坑:better-sqlite3在Electron中编译失败。这是因为better-sqlite3是原生模块,需要针对Electron的Node版本重新编译。解决办法是安装后运行electron-rebuild:
npm install @electron/rebuild --save-dev npx electron-rebuild -f -w better-sqlite3如果还有问题,检查Node版本和Electron版本是否兼容,最好锁定Node版本在项目.nvmrc里。这个问题在Windows环境特别常见,选项是提前在package.json的postinstall脚本里加上rebuild命令,让每次安装依赖后自动重编译。
第二个坑:WebSocket端口被占用。本地联调时,多开几个客户端实例,端口就冲突了。解决方法是动态找可用端口,而不是硬编码。
// 用net模块找一个可用端口 import net from 'net'; export function getAvailablePort(): Promise<number> { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, () => { const { port } = server.address() as net.AddressInfo; server.close(() => resolve(port)); }); }); }第三个坑:SQLite的WAL模式在打包后失效。PRAGMA journal_mode = WAL设置后,在某些环境重启后会被重置。解决方式是在每次启动时都执行一次这个PRAGMA命令,保证WAL模式始终生效。这能显著提升并发读写性能,尤其是主进程频繁写入消息记录的时候。
5.2 消息不同步问题排查
消息模块偶尔出现“A发了消息,B没收到”的情况,排查路径是这样的:
- 确认WebSocket连接是否存在:在客户端Console里打印
ws.readyState,1表示连接正常; - 确认消息是否入库:检查messages表里有没有记录,如果没有,问题在发送端;
- 确认消息是否广播:在
notifyUser函数里加日志,看接收人ID是否匹配; - 确认接收人是否有系统通知权限:Electron的
Notification在部分系统上需要用户手动授权。
最常见的问题其实出在第四步。Windows 10及以上的某个版本更新后,系统通知权限默认关闭,Electron应用怎么弹都弹不出来。解决办法是在设置页面加一个“打开系统通知设置”的引导按钮。
5.3 打包与分发时遇到的问题
Electron应用打包用electron-builder。踩过的坑集中在安装包体积和Windows平台安装权限上。
安装包体积可以从默认的150MB压缩到80MB左右,方法是把用不到的Chromium组件剔除。在electron-builder.yml里配置:
files: - dist/** - electron/** - package.json - "!node_modules/**/*.map" - "!node_modules/**/*.d.ts"Windows平台安装时,如果不想弹出UAC权限提示,可以在nsis配置里设置perMachine: false,安装到当前用户目录就行。当然,如果企业内网有多用户共用的需求,管理员权限还是需要的。
5.4 常见问题速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 客户列表打开慢 | 缺少索引 | 给owner_id、customer_id建索引 |
| 消息发不出去 | WebSocket连接断开 | 检查端口、重新连接 |
| 系统通知不弹 | 系统通知权限未开启 | 引导用户开启通知权限 |
| 数据库无法写入 | 文件被占用 | 关闭其他进程,解锁数据库文件 |
| 打包后白屏 | 资源路径问题 | 检查webPreferences的preload路径是否为绝对路径 |
| 内存占用过高 | 渲染进程未释放 | 检查是否有定时器未清理、事件监听未移除 |
5.5 性能调优的几点心得
数据量到一定规模后,几个优化点帮助很大。第一,SQLite开启WAL模式,读和写互不阻塞。第二,所有列表查询都加LIMIT,客户列表最多加载100条,按关键词搜索时用户很少翻到100条以后。第三,定时器任务不要用多个setInterval,统一用一个调度器管理所有扫描任务,避免多个定时器互相干扰CPU。第四,React端用useMemo缓存列表数据和统计结果,避免无关状态变化导致重复渲染。
性能优化不要一开始就做,先用最简单能跑的方式实现,等数据量上来了,用Profiler看看到底卡在哪,再有针对性地优化。我之前做了一个多月的性能优化,最后发现80%的收益来自加索引和限制返回行数这两个操作。
6. 扩展思路与个人体会
6.1 后续可以怎么扩展
DeskcommCRM目前是一套单机版加局域网通信的轻量系统,扩展方向其实很多。比如加上数据导入导出功能,支持从Excel批量导入客户名单,这对销售团队从Excel迁移到系统有巨大价值。再比如加上客户分群标签,给客户打上“高意向”“价格敏感”“竞品对比中”这些标签,后续可以按标签做精细化的跟进策略。还可以加上操作日志功能,记录每个用户的数据操作记录,提升数据安全性。
还有一个很值得做的方向:移动端配合。销售外出拜访客户时,不可能打开电脑查客户资料。做一个轻量级的移动端H5页面,挂在内网或云服务器上,销售只需要输入客户手机号,就能快速看到该客户的历史跟进记录。这样桌面端做深度录入,移动端做快速查询,覆盖的场景就全了。
6.2 关于开发流程的一些体会
做完这个项目,最大的体会是:做内部工具系统,需求比技术更重要。技术选型、代码架构这些东西相对成熟,真正决定系统好不好用的,是你对业务场景的理解程度。我在开发初期花了大量时间去和销售、客服人员聊天,了解他们最常用的操作是什么、最讨厌的操作是什么、哪些步骤希望少一点点击、哪些数据希望看得更直观。这些调研结果直接决定了模块设计、页面布局和交互逻辑。
比如客户列表页,最初设计里有“编辑”“删除”两个按钮在每个操作项后面。实际使用后才发现,删除操作极少被用到,反而误触风险很大。后来我删掉列表上的删除按钮,把删除功能挪到详情页底部,用明显的警示色提示。这个小改动,让好几个销售同事避免了误删客户数据的麻烦。
另外一点是,数据结构的可扩展性一定要提前想一步。比如客户表里的source字段,当时只设计了官网、转介绍、展会、广告四种取值,后来运营想要增加“内容营销”这个渠道,直接就能在系统设置里配置,不用改代码、不用改表结构。这属于基本功,但很多人开发内部系统时会忽略这一点,导致后期需求一变就要大改数据结构。
如果你也想做一个类似的小系统,我的建议是:不要一上来就选型技术栈,先花一周时间把流程画清楚。画清楚每个角色点开这个系统会做什么、想看什么、会在哪里卡住,然后拿着流程图去跟业务人员确认。确认完再动手写代码,你会发现后面的开发顺得多了。
最后分享一个小技巧:开发这类工具型系统时,在页面上显示每个列表的数据量统计。用户看到“本周新增客户12人”这种数据会有成就感,会更愿意主动使用系统。这本身也是一种数据驱动业务迭代的方式。系统上线后的第一个月,养成每天早上把前一天的统计数据截图发到团队群里的习惯,大家看到数据在增长,使用积极性自然就上来了。