news 2026/9/19 10:30:16

从0到1构建桌面端轻量CRM系统:Electron+React+SQLite实战拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从0到1构建桌面端轻量CRM系统:Electron+React+SQLite实战拆解

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表——客户主表

字段名类型说明
idTEXT客户ID,UUID
nameTEXT客户姓名或企业名称
phoneTEXT联系电话
wechatTEXT微信号
companyTEXT公司名称
sourceTEXT线索来源:官网、转介绍、展会、广告
levelINTEGER客户等级:1-5,5为最高
statusTEXT状态:new、following、negotiating、won、lost
owner_idTEXT负责人ID
remarkTEXT备注信息
created_atTEXT创建时间
updated_atTEXT更新时间

客户状态的设计,我建议不要做得太复杂。最开始我设计了八个状态,结果实际用的时候销售根本不知道选哪个,后来精简到五个,反而是最舒服的状态。owner_id字段一定要建索引,因为客户列表页最常用的筛选条件就是“我负责的客户”。

follow_ups表——跟进记录表

字段名类型说明
idTEXT记录ID
customer_idTEXT关联客户ID
user_idTEXT跟进人ID
typeTEXT跟进方式:call、wechat、visit、other
contentTEXT跟进内容
next_actionTEXT下一步计划
next_timeTEXT下次跟进时间
statusINTEGER是否已完成,0/1
created_atTEXT创建时间

这张表是整个系统的精华所在。每个客户都能拉出一条时间线,从第一次接触到最近的沟通内容一目了然。next_time字段为任务模块提供数据基础,用定时器扫描这个字段能自动生成待办提醒。

tasks表——任务待办表

字段名类型说明
idTEXT任务ID
customer_idTEXT关联客户ID
titleTEXT任务标题
descTEXT任务描述
due_timeTEXT截止时间
priorityINTEGER优先级:1-3
statusTEXT状态:pending、done、cancelled
created_atTEXT创建时间

messages表——内部消息表

字段名类型说明
idTEXT消息ID
from_userTEXT发送人
to_userTEXT接收人
typeTEXT消息类型:text、system
contentTEXT消息内容
related_customerTEXT关联客户ID,可为空
readINTEGER是否已读
created_atTEXT发送时间

内部通信表加了一个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_idcustomer_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.jsonpostinstall脚本里加上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没收到”的情况,排查路径是这样的:

  1. 确认WebSocket连接是否存在:在客户端Console里打印ws.readyState,1表示连接正常;
  2. 确认消息是否入库:检查messages表里有没有记录,如果没有,问题在发送端;
  3. 确认消息是否广播:在notifyUser函数里加日志,看接收人ID是否匹配;
  4. 确认接收人是否有系统通知权限: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人”这种数据会有成就感,会更愿意主动使用系统。这本身也是一种数据驱动业务迭代的方式。系统上线后的第一个月,养成每天早上把前一天的统计数据截图发到团队群里的习惯,大家看到数据在增长,使用积极性自然就上来了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 10:29:48

DNESP32P4 USB Slave实现Modbus从站读SD卡

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 10:26:02

ESP32接入百度智能云语音识别:从硬件到API完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 10:25:50

2026年可删的5个npm包:原生Node.js替代方案全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 10:25:40

Homebrew 可视化工具 BrewUI:从 CLI 到 TUI 的开发实践

"你还在一个个执行brew outdated && brew upgrade&#xff1f;"同事那天看着我终端里滚动的日志&#xff0c;随口问了一句。我当时正盯着十几条更新记录&#xff0c;单线程地敲键盘&#xff0c;说实话也有点烦了。命令行当然强大&#xff0c;但包管理这件事本…

作者头像 李华
网站建设 2026/9/19 10:21:43

纵横交叉算法优化BP神经网络的电力负荷预测与Matlab实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华