From b6167d4ee496b3bc57cd1c52791c63b68d491b0c Mon Sep 17 00:00:00 2001 From: alice <1301185173@qq.com> Date: Sun, 16 Aug 2026 16:29:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 10244 bytes .codebuddy/memory/2026-08-15.md | 18 + .../.workbuddy/memory/2026-08-14.md | 54 + 2026-08-14-17-50-08/overview.md | 50 + .../reading-notes-workspace.html | 1269 +++++++++++++++ .../.workbuddy/memory/2026-08-15.md | 19 + 2026-08-15-13-49-49/cnc-sales-workbench.html | 1427 +++++++++++++++++ 2026-08-15-13-49-49/manifest.webmanifest | 25 + 2026-08-15-13-49-49/sw.js | 22 + 2026-08-16-15-51-02/.DS_Store | Bin 0 -> 8196 bytes .../.workbuddy/memory/2026-08-16.md | 6 + .../.workbuddy/memory/MEMORY.md | 18 + .../freight-sfa-workspace/freight-sfa.html | 1330 +++++++++++++++ cnc-sales-backend/.gitignore | 12 + cnc-sales-backend/README.md | 96 ++ cnc-sales-backend/go.mod | 21 + cnc-sales-backend/go.sum | 54 + cnc-sales-backend/internal/api/aggregate.go | 174 ++ .../internal/api/auth_handlers.go | 152 ++ cnc-sales-backend/internal/api/backup.go | 210 +++ cnc-sales-backend/internal/api/customers.go | 352 ++++ cnc-sales-backend/internal/api/notes.go | 114 ++ cnc-sales-backend/internal/api/router.go | 123 ++ cnc-sales-backend/internal/api/tasks.go | 275 ++++ cnc-sales-backend/internal/auth/auth.go | 110 ++ .../internal/database/database.go | 117 ++ cnc-sales-backend/internal/models/models.go | 155 ++ cnc-sales-backend/main.go | 36 + .../static/cnc-sales-workbench.html | 1427 +++++++++++++++++ cnc-sales-backend/static/manifest.webmanifest | 25 + cnc-sales-backend/static/sw.js | 22 + 31 files changed, 7713 insertions(+) create mode 100644 .DS_Store create mode 100644 .codebuddy/memory/2026-08-15.md create mode 100644 2026-08-14-17-50-08/.workbuddy/memory/2026-08-14.md create mode 100644 2026-08-14-17-50-08/overview.md create mode 100644 2026-08-14-17-50-08/reading-notes-workspace.html create mode 100644 2026-08-15-13-49-49/.workbuddy/memory/2026-08-15.md create mode 100644 2026-08-15-13-49-49/cnc-sales-workbench.html create mode 100644 2026-08-15-13-49-49/manifest.webmanifest create mode 100644 2026-08-15-13-49-49/sw.js create mode 100644 2026-08-16-15-51-02/.DS_Store create mode 100644 2026-08-16-15-51-02/.workbuddy/memory/2026-08-16.md create mode 100644 2026-08-16-15-51-02/.workbuddy/memory/MEMORY.md create mode 100644 2026-08-16-15-51-02/freight-sfa-workspace/freight-sfa.html create mode 100644 cnc-sales-backend/.gitignore create mode 100644 cnc-sales-backend/README.md create mode 100644 cnc-sales-backend/go.mod create mode 100644 cnc-sales-backend/go.sum create mode 100644 cnc-sales-backend/internal/api/aggregate.go create mode 100644 cnc-sales-backend/internal/api/auth_handlers.go create mode 100644 cnc-sales-backend/internal/api/backup.go create mode 100644 cnc-sales-backend/internal/api/customers.go create mode 100644 cnc-sales-backend/internal/api/notes.go create mode 100644 cnc-sales-backend/internal/api/router.go create mode 100644 cnc-sales-backend/internal/api/tasks.go create mode 100644 cnc-sales-backend/internal/auth/auth.go create mode 100644 cnc-sales-backend/internal/database/database.go create mode 100644 cnc-sales-backend/internal/models/models.go create mode 100644 cnc-sales-backend/main.go create mode 100644 cnc-sales-backend/static/cnc-sales-workbench.html create mode 100644 cnc-sales-backend/static/manifest.webmanifest create mode 100644 cnc-sales-backend/static/sw.js diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bca461b15380d58c242ca42e2e2f1d7c8dd26b6c GIT binary patch literal 10244 zcmeHMU2GIp6u#fIzziMe6p9q=goQ%*$rk!cTYk*a9|Z&oY)gMAEW0}+9hlCPo!u5F zjg5&fAQ+!C{`;hn$b$hTzG$K%`lx7vF+LDA{ygA=FDeh7J9oB`wj?!C#5gy(_uPB# zJ@?E#bG|uy?lQ*EoHME!i!jDys+?*nHB%Hm&+DWj1ssJaNS?7uR>c~rt!DL;R)>tx z0-*&$3xpO3Ef8AZUa$bgY+mFdLBlq*Kxl!`0^=6o--ifQPGbQb6V(57P~)cnL<@=S z7wS_U;LAh;8Vl%{puQ;WDY6Fyq6kt92yvQ^@a05f0UZ-mh%*RrhCnld3zga@l za-O-@q`j;mvz2!Ax-C-41xqKK9?FHe3zsZkRaw)pDb}`a=bmfDTFJcmrK;L%8K#r9 z9|nq#Zq10&u3aPCbdQ&uzHQIc6f9KEg39ayG~U* zj#`ePdHfMbw6;#wJMCo3v^7H!dK#Nly=zF=Uwb%UZZ1{z?zB5FKu7cf-j-%vKgiYY zNP5}!LDQi}x*F(ykmh>5{$$2%A9l=4z}-$<|CJJ-3$+9LMawwIijdX0~*nUX0&4u_9Bix z^uxjtcsP!e7{Mt#j?;JoXK@Zs;|08gm+=Z-$D6o_OLzCHA)+#jnXcuUFw#S(hvIM47TmWmvSQ7;y2j00#$8nSbl#2s3=SU=x8cY~ zgzqsEy!o2IsJbMN--%%DBq*83WsYcVmRP+^T`m)pv>9T!Qe7<*h_snv7%g8b6Kplf zAF9>mwF-e#D-gqId4nQr5Q7csCWT@2+4`3^Hk?(WQ@5DhQkVFaw2H;>2#|QMv5DwnPWxRv;@d2*jqW}?K5gvZTPZNB6=j}d@U#9QlL-CAbyUt;f z;E0(D89ZF^i{?tI)znf5GrY8&a$TE?l&~~&s8%wMC7I1kqLnN~{WLpDBZnEOVJ`p@ zyPOBo7x{=bOD>WR#3^KnZ&Y(Kv!9PTX!}&YpG@teK$g%-TU;lbwCMBb3sCr=C4F7w zQ|@rHn{y1$myL@FS&@~5RyHzH*H|Bo)YjFHMk6ClHC53_UG4hOQAJ)*v2Nqu#8G>| zaZl4ps38B@m?OsH?HTG# + + + + +读书笔记工作台 + + + +
+ + + + + + + +
+ +
+ + +
+
+ + +
+
+
+ + + +
+ + + + + +
+ +
+ + + + + + \ No newline at end of file diff --git a/2026-08-15-13-49-49/.workbuddy/memory/2026-08-15.md b/2026-08-15-13-49-49/.workbuddy/memory/2026-08-15.md new file mode 100644 index 0000000..b2a1e59 --- /dev/null +++ b/2026-08-15-13-49-49/.workbuddy/memory/2026-08-15.md @@ -0,0 +1,19 @@ +# 2026-08-15 工作记录 + +## 数控机床B端销售个人工作台(单文件 PWA) +- 交付文件:`/Users/alice/WorkBuddy/2026-08-15-13-49-49/cnc-sales-workbench.html`(单文件,localStorage 存储,无需后端/登录) +- 配套 PWA 文件:`manifest.webmanifest`、`sw.js`(仅在 http/https 托管时启用离线+桌面安装;file:// 双击也可用) +- 6 大模块:客户档案 / 销售流程(8阶段) / 任务待办(逾期标红) / 数据看板 / 资料库 / 数据中心 +- 数据兼容(迭代规则):导出 JSON 含 schemaVersion;导入 normalize 时对缺 id 记录用自然键生成确定性 id(`c_`+hash(公司名) 等),使 merge 幂等、replace 全量恢复,旧版备份可导入 +- 导入导出:JSON 全量、Excel(xlsx,自写 zip 无需外部库)、CSV 客户导入;已单元验证 xlsx 生成有效、JSON 导入幂等、Excel 单元格 round-trip 结构正确 +- 修复的 bug:Excel 导入阶段映射曾把合法 s3 等 id 错误折叠为 s1(已修正) +- 验证方式:Node 提取纯函数单测 + python http.server 实测三文件均 200 + +## 视觉/UX 迭代(第二轮需求) +- 视觉:冷白底 `#f5f6f8` + 褐色渐变 `--pri-grad:linear-gradient(135deg,#a9743e,#6e4421)`,深色主题褐色系同步调整;manifest 主题色同步改为 `#8a5a2b` +- 彩带动效:Canvas 粒子系统 `confetti()`,触发点 = 任务标记完成 + 销售阶段推进到 s6(交付)/s8(回款);无外部库 +- 图标全内联 SVG 去 emoji(含顶部/侧栏导航、看板、任务勾选、管线箭头、空状态等),Node 扫描 0 个 emoji +- 三档响应式:≤768 手机底部 4 Tab(客户/流程/任务/更多)+单列;≤1024 平板顶部横向导航;>1024 桌面左侧固定边栏+多栏 +- 即时保存:修复关键 bug——旧 `loadState` 要求 `o.data` 包装而 saveState 存顶层,导致刷新丢数据;现兼容顶层/导出两种格式;UI 状态(S.ui: 当前页/筛选/今日折叠)与表单草稿(S.drafts,防抖400ms)均即时写入 localStorage +- 资源全内联:manifest 改为 data URI 内联,无任何外部 CDN/字体/库;sw.js 仅在 http(s) 下注册,file:// 双击可用 +- 验证:JS 语法 OK(65915 字符)、持久化 round-trip/顶层加载/导出格式加载/merge 幂等 4 项测试全过、16 项关键特性存在性自检全过 diff --git a/2026-08-15-13-49-49/cnc-sales-workbench.html b/2026-08-15-13-49-49/cnc-sales-workbench.html new file mode 100644 index 0000000..397cd4f --- /dev/null +++ b/2026-08-15-13-49-49/cnc-sales-workbench.html @@ -0,0 +1,1427 @@ + + + + + + + + + + + + +数控销售工作台 + + + +
+ + + +
+ + +
+
+

客户档案

+
管理你的制造工厂客户
+
+
+ +
+ +
+
+
+ + + + +
+ + +
+
+
已自动保存
+ + + + + diff --git a/2026-08-15-13-49-49/manifest.webmanifest b/2026-08-15-13-49-49/manifest.webmanifest new file mode 100644 index 0000000..626385d --- /dev/null +++ b/2026-08-15-13-49-49/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "数控销售工作台", + "short_name": "数控销售台", + "start_url": ".", + "scope": ".", + "display": "standalone", + "orientation": "any", + "background_color": "#f5f6f8", + "theme_color": "#8a5a2b", + "description": "数控机床B端销售客户管理与任务工作台(本地单文件应用)", + "icons": [ + { + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='192' height='192'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23a9743e'/%3E%3Cstop offset='1' stop-color='%236e4421'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='192' height='192' rx='40' fill='url(%23g)'/%3E%3Ctext x='96' y='130' font-size='96' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'%3EC%3C/text%3E%3C/svg%3E", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='512' height='512'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23a9743e'/%3E%3Cstop offset='1' stop-color='%236e4421'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='512' height='512' rx='110' fill='url(%23g)'/%3E%3Ctext x='256' y='342' font-size='256' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'%3EC%3C/text%3E%3C/svg%3E", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/2026-08-15-13-49-49/sw.js b/2026-08-15-13-49-49/sw.js new file mode 100644 index 0000000..fd8c52f --- /dev/null +++ b/2026-08-15-13-49-49/sw.js @@ -0,0 +1,22 @@ +/* 数控销售工作台 离线缓存 Service Worker + 作用:托管(http/https)时缓存本应用,支持断网使用与“添加到桌面/安装为应用”。 + 单文件 HTML 本身即可独立运行,此 SW 仅用于离线加速与桌面安装。 */ +const CACHE = "cnc-wb-v1"; +self.addEventListener("install", (e) => { self.skipWaiting(); }); +self.addEventListener("activate", (e) => { e.waitUntil(self.clients.claim()); }); +self.addEventListener("fetch", (e) => { + if (e.request.method !== "GET") return; + const url = new URL(e.request.url); + if (url.origin !== location.origin) return; // 仅缓存同源资源 + e.respondWith((async () => { + const cache = await caches.open(CACHE); + const cached = await cache.match(e.request); + try { + const res = await fetch(e.request); + if (res && res.status === 200) cache.put(e.request, res.clone()); + return res; + } catch (_) { + return cached || new Response("离线且本地无缓存", { status: 503, statusText: "Offline" }); + } + })()); +}); diff --git a/2026-08-16-15-51-02/.DS_Store b/2026-08-16-15-51-02/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..238313ce9e0826997f7b4205e26f797f87a27565 GIT binary patch literal 8196 zcmeHMU2GIp6uxKL!W}xmv=k^zaks2cgG;(?Dp){V`XhprmTpV8EwJ6)8R*FDOqrS8 zMGK~yXjFnfPZIwUUNnm0iy=HttEw45aQcDP-_%JC~Vs(SU#@REi2)?EQC7?Ya2goRK$y(x& z{nKU#iU5HCfdGL3fdGL3fqw%6cxSUhXSnzIXix_T1PI)e2(a@Zj1QA>Uruo89UWMC zDFDiHRC@*6m85p4vpoY{)o{w50o_!sVw;sWRmZbU@Bu1PF(ucIk2l9!qT%&TN2B5KrdV@xG`z00 z<>*n7E{-&A>`fgsa;EhpUnKYk1(`o((Z^=DV%5nS8C(JLG7F##r_j!UDod7t`nm*^ zIImKguOtVDhNZnJxw?RebdR~ZWjebx+d-L<7dnXCwyb6L*cuxj$XZ4|XF4glDw{U4 zc_Zy=TMQ$iKd7N@p_8>O!|1Ubow*rIy4qovjZGU$4mp-nO=R{iZFQ+jsU}TqsvnOEuDxfr9Sn8AIzY>Z&_Bn0B(7 zsp{r|A>2CC)%WX~EmpxRVxO7LX%}mkEw5JUlv`%9qH=^L^BMh6UUwhh@2FQIqI{C> zQSk$ISt|3WdLv4`(m?4j)3@6i)?6*EQKF)prhN{sgS%*ynnc+V`wn7!anlxQ9hFtF z-$-XPgR5Glja0r*94PQT;L6QXoK`_pOlGZI4%g#wb-mI-=|1-69cjny9@EUyXWFW4 zqw)nh(703bjG%z;X%fjU?TQCJI&5QA1|gLdeIdmsUOU@z>0`@sPh3Q&Y2 z@F+Y6kHZu26g&qf;CXlfPQvSO3f_d%a0cFobMOIt2%o_v_!7Q?@8KH!1V6(s@P|MJ zQJ5#x39E$FLW9sGY!JGHZlOomCG-lzf-2+$TX6Uagc6bW`782;a0DB(W2Ap{32)t) zk)f^kZrd*H_|Gu%@&d-eMKz0;EDf(()7Wz7U2WIHlm~VxmjB8Tj~0{2VX0Tf8?`L|@0|Jf|I+k81_%TQ{0|X;@{UAD8!l|7wg$xJB(AAP286gT%rccKk!39aQ|z;{XgZa@J)CB{|SUK&Da0{ literal 0 HcmV?d00001 diff --git a/2026-08-16-15-51-02/.workbuddy/memory/2026-08-16.md b/2026-08-16-15-51-02/.workbuddy/memory/2026-08-16.md new file mode 100644 index 0000000..65f8e92 --- /dev/null +++ b/2026-08-16-15-51-02/.workbuddy/memory/2026-08-16.md @@ -0,0 +1,6 @@ +# 2026-08-16 工作日志 + +- 修复 freight-sfa.html 三处 JS 语法错误:`{cf-type:...}` 未引号键、`常用起运港:['起运港庄家(默认)']:[]` 畸形字段、`报关/拖车需求` 与 `起运港庄家(意向)`/`目的港DDP(意向)` 未引号键 +- node --check 全量校验通过;onclick 函数与 getElementById ID 交叉检查全部匹配 +- 部署 HTML 至资料库在线页面(绑定 4 张数据库),URL: https://www.workbuddy.cn/space/d/slZoYoQY3Jp8VuRAmizFem,HTTP 200 验证通过 +- 全部 6 项任务完成,交付用户 diff --git a/2026-08-16-15-51-02/.workbuddy/memory/MEMORY.md b/2026-08-16-15-51-02/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..02ed915 --- /dev/null +++ b/2026-08-16-15-51-02/.workbuddy/memory/MEMORY.md @@ -0,0 +1,18 @@ +# 项目记忆 + +## 货代SFA销售管理工作台(2026-08-16 完成) + +- 主交付物:`freight-sfa-workspace/freight-sfa.html`(单文件 HTML,~145KB,紫/奶油配色) +- 在线地址:https://www.workbuddy.cn/space/d/slZoYoQY3Jp8VuRAmizFem (node_block_id: slZoYoQY3Jp8VuRAmizFem) +- 资料库 4 张数据库 ID: + - 客户档案表 v1CxFYvBOQo9AfaV2olcWR + - 商机询价表 JNmQ5vBqHijZGlR3ptxtRL + - 订舱执行记录表 Heqsy4vjPAJ6Vrxq9N5nzv + - 跟进记录表 JUfrTdkSYRL7bweIYBAbjK +- 功能:4表CRUD+8看板+跨表联动+双时间水印+三级供应商逻辑+内部字段权限隔离+选项自定义+CSV/JSON备份+PWA +- 数据本地存储 localStorage key 前缀:wb_freight_sfa_v1 + +## 技术要点(避坑) +- import_html.py 的 token 必须用 `--token-stdin`(stdin 传入),环境变量无效 +- 资料库 select/multi_select 字段创建时 options 不能为空,空字段需放占位选项 +- JS 对象字面量中含 `/`、`(`、`)` 的中文键名必须加引号(曾因此报 SyntaxError) diff --git a/2026-08-16-15-51-02/freight-sfa-workspace/freight-sfa.html b/2026-08-16-15-51-02/freight-sfa-workspace/freight-sfa.html new file mode 100644 index 0000000..f151726 --- /dev/null +++ b/2026-08-16-15-51-02/freight-sfa-workspace/freight-sfa.html @@ -0,0 +1,1330 @@ + + + + + + + + + + + +客户管理工作台(货代版) + + + + +
+
+ + + +
+
+
+ 本地存储·已就绪 +
+ +
S
+
+
+ + + +
+
+
+

综合看板

今日核心指标 · 待办逾期一屏掌握

+
+ + +
+
+
+
今天要处理
+
+
+
0待跟进
+
0今日开船
+
0逾期回款
+
+
+
+
客户总数
0
A级核心 0
+
商机询价中
0
已报价 0
+
订舱执行中
0
本周开船 0
+
待回款金额
¥0
逾期 ¥0
+
+
+

客户跟进状态分布

+

订舱状态饼图

+
+
+
本周开船 · 需重点盯货
+
逾期回款清单
+
+
+ +
+

销售漏斗看板

按客户跟进状态分组 · 拖拽变更状态

+
+
+ +
+

客户分类检索

按等级·渠道·供应商多条件筛选汇总

+
+ + + + + +
0 +
+
+
+ +
+

A类重点客户仪表盘

仅A级核心 · 优先维护

+
+
+
+ +
+

渠道产出统计

按获客渠道 · 复盘渠道价值

+
+

各渠道客户产出 · 优质客户占比

+
+
+ +
+

订舱业务跟踪看板

每票货 · 庄家/DDP/船期 全流程跟踪

+
+
+ +
+

回款预警仪表盘

筛选未回款·逾期订单 · 管控坏账

+
+
+
+ +
+

丢单原因统计

优化报价·供应商·服务短板

+
+

丢单原因分布

+
+
+ +
+

高风险客户视图

统一管控账期·纠纷·欠款风险

+
+
+
+ +
+

客户档案表

主表 · 通用+按类型展开+财务保密

+
+
+ +
+

商机询价表

询价 → 多版本报价 → 状态流转

+
+
+ +
+

订舱执行记录表

本票实际执行供应商·优先级最高

+
+
+ +
+

跟进记录表

沟通·改单·催款全留痕

+
+
+ +
+

数据统计分析

渠道·客户价值·业务健康度

+
+
+

客户等级分布

+

客户类型分布

+
+
+ +
+

个人中心 · 数据管理

备份/导入/清空/桌面挂载

+
S

货代销售管理员

专业货代SFA · 客户/询价/订舱/跟进全闭环

0
客户
0
商机
0
订舱
0
跟进
+

数据备份

导出全部数据 (JSON)
客户/商机/订舱/跟进记录+自定义字段选项
从备份恢复
将覆盖现有数据,请先导出当前数据
清空全部数据
⚠️ 不可恢复,请确认已备份
+

桌面挂载

添加到手机主屏幕
iPhone Safari·分享→添加到主屏幕 · Android Chrome·菜单→添加到主屏幕
已支持 PWA
+

显示偏好

财务字段权限隔离
内部底价/约定账期仅负责人可见
默认销售姓名
录入客户/商机时自动带入
+

关于本工作台

版本
v1.0.0 · 货代销售SFA
配套数据库
4 张 · 客户档案·商机询价·订舱执行·跟进记录
存储
本地浏览器 localStorage + 可导出 JSON
+
+
+
+ +
+ + +

字段选项管理

+

确认

+
OK
+ + + + \ No newline at end of file diff --git a/cnc-sales-backend/.gitignore b/cnc-sales-backend/.gitignore new file mode 100644 index 0000000..04d7a3e --- /dev/null +++ b/cnc-sales-backend/.gitignore @@ -0,0 +1,12 @@ +# 二进制 +bin/ + +# 数据库与运行时数据 +data/*.db +data/*.db-* + +# 系统文件 +.DS_Store + +# 日志 +*.log diff --git a/cnc-sales-backend/README.md b/cnc-sales-backend/README.md new file mode 100644 index 0000000..62ffea5 --- /dev/null +++ b/cnc-sales-backend/README.md @@ -0,0 +1,96 @@ +# CNC 销售工作台 — 后端服务 + +为单文件 PWA 前端(`cnc-sales-workbench.html`)提供的 Go + SQLite 后端: +统一认证、多用户数据隔离、客户/任务/资料在线 CRUD、聚合看板与备份导入导出。 + +## 技术栈 + +- Go 1.22+(标准库 `net/http` 路由,无 Web 框架) +- SQLite(`modernc.org/sqlite` 纯 Go 驱动,无 CGO,单文件数据库) +- JWT 认证(`golang-jwt/jwt/v5`)+ bcrypt 密码哈希 + +## 快速开始 + +```bash +cd cnc-sales-backend +go build -o bin/cnc-server . +./bin/cnc-server +# 启动后访问 http://localhost:8080/(自动跳转到工作台页面) +``` + +环境变量(均有默认值): + +| 变量 | 默认值 | 说明 | +|---|---|---| +| `PORT` | `8080` | 监听端口 | +| `DB_PATH` | `data/cnc.db` | SQLite 数据文件路径 | +| `STATIC_DIR` | `static` | 前端静态资源目录 | +| `JWT_SECRET` | 开发默认值 | **生产必须设置**,用于 JWT 签名 | + +## API 一览 + +所有业务接口需携带请求头 `Authorization: Bearer `。 + +### 认证(公开) +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/api/auth/register` | 注册(首个用户自动为 admin) | +| POST | `/api/auth/login` | 登录,返回 `{token, user}` | +| GET | `/api/auth/me` | 当前用户信息 | + +### 客户档案 +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/customers` | 列表,支持 `stage/level/industry/q` 筛选 | +| POST | `/api/customers` | 新建(company 必填) | +| GET | `/api/customers/{id}` | 详情(含联系人、跟进记录) | +| PUT | `/api/customers/{id}` | 全量更新(子表全量重建) | +| DELETE | `/api/customers/{id}` | 软删除,解除关联任务引用 | +| POST | `/api/customers/{id}/contacts` | 添加联系人 | +| DELETE | `/api/customers/{id}/contacts/{cid}` | 删除联系人 | +| POST | `/api/customers/{id}/followups` | 添加跟进记录 | +| DELETE | `/api/customers/{id}/followups/{fid}` | 删除跟进记录 | + +### 任务 +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/tasks` | 列表,支持 `status/priority` 筛选 | +| POST | `/api/tasks` | 新建(title 必填) | +| PUT | `/api/tasks/{id}` | 更新 | +| DELETE | `/api/tasks/{id}` | 软删除 | +| POST | `/api/tasks/{id}/toggle` | 完成/取消;完成且带 `repeat` 时自动生成下一周期任务 | + +### 资料库 +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/notes` | 列表,支持 `cat` 筛选 | +| POST | `/api/notes` | 新建(title 必填) | +| PUT | `/api/notes/{id}` | 更新 | +| DELETE | `/api/notes/{id}` | 软删除 | + +### 聚合与备份 +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/pipeline/stages` | 8 阶段看板数据(按阶段聚合公司名) | +| GET | `/api/stats/overview` | 统计:客户/任务/资料总数、等级与阶段分布、7 日新增 | +| GET | `/api/today` | 今日待处理:逾期任务/今日到期/跟进到期客户 | +| GET | `/api/backup` | 全量导出 JSON(兼容前端备份格式) | +| POST | `/api/backup` | 全量导入(按 id upsert,归属当前用户) | + +## 数据模型 + +客户字段与前端 `normalizeCustomer` 对齐:`id/company/industry/address/contacts/level/stage/machineModel/budget/workpieceNeeds/painPoints/followUps/nextFollowDate/source/remark/createdAt/updatedAt`。 + +业务常量(服务端校验)与前端一致:阶段 `s1~s8`、等级 `A/B/C`、优先级 `P0/P1/P2`、状态 `todo/doing/done`、重复 `daily/weekly/monthly`、资料分类 5 类、联系人角色 4 类。ID 生成规则与前端兼容(`id` + 时间戳 36 进制 + 随机串)。 + +所有业务表按 `owner_id` 隔离数据,删除采用软删除(`deleted_at`),联系人/跟进记录通过外键级联。 + +## 前端接入 + +- 静态资源目录 `static/` 托管了前端的 `cnc-sales-workbench.html`、`manifest.webmanifest`、`sw.js`。 +- 前端当前仍是 localStorage 单机模式;接入后端可走方案 A(全量同步):前端 `loadState/saveState` 对接 `GET/PUT /api/state`,并新增登录页调用 `/api/auth/*`。实体级 REST API 已全部就绪,后续可平滑迁移到增量同步。 + +## 部署注意 + +- `sw.js`(Service Worker)仅在 http(s) 协议下生效,线上部署需 HTTPS。 +- 生产环境务必设置 `JWT_SECRET`,并给数据文件 `data/cnc.db` 做好备份。 diff --git a/cnc-sales-backend/go.mod b/cnc-sales-backend/go.mod new file mode 100644 index 0000000..88fe941 --- /dev/null +++ b/cnc-sales-backend/go.mod @@ -0,0 +1,21 @@ +module cnc-sales-backend + +go 1.26.6 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.55.0 + modernc.org/sqlite v1.56.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/cnc-sales-backend/go.sum b/cnc-sales-backend/go.sum new file mode 100644 index 0000000..c353977 --- /dev/null +++ b/cnc-sales-backend/go.sum @@ -0,0 +1,54 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/cnc-sales-backend/internal/api/aggregate.go b/cnc-sales-backend/internal/api/aggregate.go new file mode 100644 index 0000000..953b12e --- /dev/null +++ b/cnc-sales-backend/internal/api/aggregate.go @@ -0,0 +1,174 @@ +package api + +import ( + "database/sql" + "net/http" + "sort" + "time" + + "cnc-sales-backend/internal/auth" + "cnc-sales-backend/internal/models" +) + +// todayPanel 今日待处理聚合:逾期任务 / 今日到期任务 / 跟进到期客户 +func (s *Server) todayPanel(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + today := todayStr() + + overdue := []models.Task{} + rows, err := s.db.Query(`SELECT `+taskCols+` FROM tasks t + LEFT JOIN customers c ON c.id=t.customer_id + WHERE t.owner_id=? AND t.deleted_at IS NULL AND t.status!='done' AND t.due_date!='' AND t.due_date=?", + uid, weekAgo).Scan(&recentAdded) + + // 阶段分布补全名称 + stageDist := groupBy("customers", "stage") + for i := range stageDist { + if name, ok := models.StageNames[stageDist[i].Key]; ok { + stageDist[i].Key = name + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "totalCustomers": totalCustomers, + "totalTasks": totalTasks, + "totalNotes": totalNotes, + "recentAdded7d": recentAdded, + "levelDistribution": groupBy("customers", "level"), + "stageDistribution": stageDist, + "taskStatus": groupBy("tasks", "status"), + }) +} diff --git a/cnc-sales-backend/internal/api/auth_handlers.go b/cnc-sales-backend/internal/api/auth_handlers.go new file mode 100644 index 0000000..2c40ee2 --- /dev/null +++ b/cnc-sales-backend/internal/api/auth_handlers.go @@ -0,0 +1,152 @@ +package api + +import ( + "database/sql" + "errors" + "net/http" + "strings" + + "cnc-sales-backend/internal/auth" + "cnc-sales-backend/internal/models" +) + +type registerReq struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"displayName"` +} + +type loginReq struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type authResp struct { + Token string `json:"token"` + User models.User `json:"user"` +} + +// register 注册(首个注册用户自动成为管理员) +func (s *Server) register(w http.ResponseWriter, r *http.Request) { + var req registerReq + if err := decodeJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + req.Username = strings.TrimSpace(req.Username) + if len(req.Username) < 2 || len(req.Username) > 32 { + writeErr(w, http.StatusBadRequest, "用户名长度需为 2~32 个字符") + return + } + if len(req.Password) < 6 { + writeErr(w, http.StatusBadRequest, "密码长度至少 6 位") + return + } + + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { + writeErr(w, http.StatusInternalServerError, "服务内部错误") + return + } + role := "user" + if count == 0 { + role = "admin" + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + writeErr(w, http.StatusInternalServerError, "密码处理失败") + return + } + + displayName := strings.TrimSpace(req.DisplayName) + if displayName == "" { + displayName = req.Username + } + + res, err := s.db.Exec( + "INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)", + req.Username, hash, displayName, role, nowStr()) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + writeErr(w, http.StatusConflict, "用户名已存在") + return + } + writeErr(w, http.StatusInternalServerError, "注册失败") + return + } + id, _ := res.LastInsertId() + + token, err := auth.GenerateToken(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "签发凭证失败") + return + } + + writeJSON(w, http.StatusCreated, authResp{ + Token: token, + User: models.User{ID: id, Username: req.Username, DisplayName: displayName, Role: role, CreatedAt: nowStr()}, + }) +} + +// login 登录 +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + var req loginReq + if err := decodeJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + req.Username = strings.TrimSpace(req.Username) + + var ( + id int64 + passwordHash string + displayName string + role string + createdAt string + ) + err := s.db.QueryRow( + "SELECT id, password_hash, display_name, role, created_at FROM users WHERE username=?", + req.Username).Scan(&id, &passwordHash, &displayName, &role, &createdAt) + if errors.Is(err, sql.ErrNoRows) || (err == nil && !auth.CheckPassword(passwordHash, req.Password)) { + writeErr(w, http.StatusUnauthorized, "用户名或密码错误") + return + } + if err != nil { + writeErr(w, http.StatusInternalServerError, "服务内部错误") + return + } + + token, err := auth.GenerateToken(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "签发凭证失败") + return + } + writeJSON(w, http.StatusOK, authResp{ + Token: token, + User: models.User{ID: id, Username: req.Username, DisplayName: displayName, Role: role, CreatedAt: createdAt}, + }) +} + +// me 返回当前登录用户信息 +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + var ( + username string + displayName string + role string + createdAt string + ) + err := s.db.QueryRow( + "SELECT username, display_name, role, created_at FROM users WHERE id=?", + uid).Scan(&username, &displayName, &role, &createdAt) + if errors.Is(err, sql.ErrNoRows) { + writeErr(w, http.StatusUnauthorized, "用户不存在") + return + } + if err != nil { + writeErr(w, http.StatusInternalServerError, "服务内部错误") + return + } + writeJSON(w, http.StatusOK, models.User{ID: uid, Username: username, DisplayName: displayName, Role: role, CreatedAt: createdAt}) +} diff --git a/cnc-sales-backend/internal/api/backup.go b/cnc-sales-backend/internal/api/backup.go new file mode 100644 index 0000000..233d52e --- /dev/null +++ b/cnc-sales-backend/internal/api/backup.go @@ -0,0 +1,210 @@ +package api + +import ( + "net/http" + + "cnc-sales-backend/internal/auth" + "cnc-sales-backend/internal/models" +) + +// exportBackup 全量导出(与前端数据中心 JSON 备份格式兼容) +func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + + // 客户 + customers := []models.Customer{} + rows, err := s.db.Query("SELECT "+customerCols+" FROM customers WHERE owner_id=? AND deleted_at IS NULL ORDER BY created_at", uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导出失败") + return + } + for rows.Next() { + c := models.Customer{} + if err := rows.Scan(&c.ID, &c.Company, &c.Industry, &c.Address, &c.Level, &c.Stage, + &c.MachineModel, &c.Budget, &c.WorkpieceNeeds, &c.PainPoints, + &c.NextFollowDate, &c.Source, &c.Remark, &c.CreatedAt, &c.UpdatedAt); err != nil { + continue + } + c.Contacts, _ = s.loadContacts(c.ID) + c.FollowUps, _ = s.loadFollowUps(c.ID) + customers = append(customers, c) + } + rows.Close() + + // 任务 + tasks := []models.Task{} + rows, err = s.db.Query("SELECT "+taskCols+" FROM tasks t LEFT JOIN customers c ON c.id=t.customer_id WHERE t.owner_id=? AND t.deleted_at IS NULL", uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导出失败") + return + } + for rows.Next() { + if t, ok := scanTask(rows); ok { + tasks = append(tasks, *t) + } + } + rows.Close() + + // 资料 + notes := []models.Note{} + rows, err = s.db.Query("SELECT id, cat, title, content, updated_at FROM notes WHERE owner_id=? AND deleted_at IS NULL", uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导出失败") + return + } + for rows.Next() { + var n models.Note + if err := rows.Scan(&n.ID, &n.Cat, &n.Title, &n.Content, &n.UpdatedAt); err == nil { + notes = append(notes, n) + } + } + rows.Close() + + writeJSON(w, http.StatusOK, models.Backup{ + SchemaVersion: 1, + ExportedAt: nowStr(), + Customers: customers, + Tasks: tasks, + Notes: notes, + }) +} + +// importBackup 全量导入(按 id upsert,数据归属当前用户) +func (s *Server) importBackup(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + var b models.Backup + if err := decodeJSON(r, &b); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + + tx, err := s.db.Begin() + if err != nil { + writeErr(w, http.StatusInternalServerError, "导入失败") + return + } + defer tx.Rollback() + + imported := map[string]int{"customers": 0, "tasks": 0, "notes": 0} + + for _, c := range b.Customers { + c.Level = models.NormalizeLevel(c.Level) + c.Stage = models.NormalizeStage(c.Stage) + if c.ID == "" { + c.ID = genID() + } + if c.CreatedAt == "" { + c.CreatedAt = nowStr() + } + if c.UpdatedAt == "" { + c.UpdatedAt = nowStr() + } + res, err := tx.Exec(`UPDATE customers SET company=?, industry=?, address=?, level=?, stage=?, + machine_model=?, budget=?, workpiece_needs=?, pain_points=?, next_follow_date=?, + source=?, remark=?, updated_at=? WHERE id=? AND owner_id=?`, + c.Company, c.Industry, c.Address, c.Level, c.Stage, c.MachineModel, c.Budget, + c.WorkpieceNeeds, c.PainPoints, c.NextFollowDate, c.Source, c.Remark, c.UpdatedAt, c.ID, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导入客户失败") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + _, err = tx.Exec(`INSERT INTO customers (id, owner_id, company, industry, address, level, stage, + machine_model, budget, workpiece_needs, pain_points, next_follow_date, source, remark, + created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + c.ID, uid, c.Company, c.Industry, c.Address, c.Level, c.Stage, c.MachineModel, + c.Budget, c.WorkpieceNeeds, c.PainPoints, c.NextFollowDate, c.Source, c.Remark, + c.CreatedAt, c.UpdatedAt) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导入客户失败") + return + } + } + if _, err := tx.Exec("DELETE FROM contacts WHERE customer_id=?", c.ID); err != nil { + writeErr(w, http.StatusInternalServerError, "导入失败") + return + } + for _, ct := range c.Contacts { + if _, err := tx.Exec("INSERT INTO contacts (customer_id, name, role, phone) VALUES (?,?,?,?)", + c.ID, ct.Name, ct.Role, ct.Phone); err != nil { + writeErr(w, http.StatusInternalServerError, "导入失败") + return + } + } + if _, err := tx.Exec("DELETE FROM follow_ups WHERE customer_id=?", c.ID); err != nil { + writeErr(w, http.StatusInternalServerError, "导入失败") + return + } + for _, f := range c.FollowUps { + if _, err := tx.Exec("INSERT INTO follow_ups (customer_id, time, content) VALUES (?,?,?)", + c.ID, f.Time, f.Content); err != nil { + writeErr(w, http.StatusInternalServerError, "导入失败") + return + } + } + imported["customers"]++ + } + + for _, t := range b.Tasks { + t.Priority = models.NormalizePriority(t.Priority) + t.Status = models.NormalizeStatus(t.Status) + t.Repeat = models.NormalizeRepeat(deref(t.Repeat)) + if t.ID == "" { + t.ID = genID() + } + if t.CreatedAt == "" { + t.CreatedAt = nowStr() + } + res, err := tx.Exec(`UPDATE tasks SET title=?, customer_id=?, due_date=?, priority=?, status=?, + repeat=? WHERE id=? AND owner_id=?`, + t.Title, nullOrEmpty(t.CustomerID), t.DueDate, t.Priority, t.Status, + nullOrEmpty(t.Repeat), t.ID, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导入任务失败") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + _, err = tx.Exec(`INSERT INTO tasks (id, owner_id, title, customer_id, due_date, priority, + status, repeat, created_at) VALUES (?,?,?,?,?,?,?,?,?)`, + t.ID, uid, t.Title, nullOrEmpty(t.CustomerID), t.DueDate, t.Priority, t.Status, + nullOrEmpty(t.Repeat), t.CreatedAt) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导入任务失败") + return + } + } + imported["tasks"]++ + } + + for _, n := range b.Notes { + n.Cat = models.NormalizeNoteCat(n.Cat) + if n.ID == "" { + n.ID = genID() + } + if n.UpdatedAt == "" { + n.UpdatedAt = nowStr() + } + res, err := tx.Exec("UPDATE notes SET cat=?, title=?, content=?, updated_at=? WHERE id=? AND owner_id=?", + n.Cat, n.Title, n.Content, n.UpdatedAt, n.ID, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导入资料失败") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + _, err = tx.Exec("INSERT INTO notes (id, owner_id, cat, title, content, updated_at) VALUES (?,?,?,?,?,?)", + n.ID, uid, n.Cat, n.Title, n.Content, n.UpdatedAt) + if err != nil { + writeErr(w, http.StatusInternalServerError, "导入资料失败") + return + } + } + imported["notes"]++ + } + + if err := tx.Commit(); err != nil { + writeErr(w, http.StatusInternalServerError, "导入提交失败") + return + } + writeJSON(w, http.StatusOK, map[string]any{"imported": imported}) +} diff --git a/cnc-sales-backend/internal/api/customers.go b/cnc-sales-backend/internal/api/customers.go new file mode 100644 index 0000000..9f45291 --- /dev/null +++ b/cnc-sales-backend/internal/api/customers.go @@ -0,0 +1,352 @@ +package api + +import ( + "database/sql" + "errors" + "net/http" + "strings" + + "cnc-sales-backend/internal/auth" + "cnc-sales-backend/internal/models" +) + +const customerCols = `id, company, industry, address, level, stage, machine_model, + budget, workpiece_needs, pain_points, next_follow_date, source, remark, created_at, updated_at` + +// loadCustomer 读取单个客户(含联系人、跟进记录),并校验归属 +func (s *Server) loadCustomer(uid int64, id string) (*models.Customer, error) { + c := &models.Customer{} + err := s.db.QueryRow(`SELECT `+customerCols+` FROM customers + WHERE owner_id=? AND id=? AND deleted_at IS NULL`, uid, id). + Scan(&c.ID, &c.Company, &c.Industry, &c.Address, &c.Level, &c.Stage, + &c.MachineModel, &c.Budget, &c.WorkpieceNeeds, &c.PainPoints, + &c.NextFollowDate, &c.Source, &c.Remark, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + return nil, err + } + c.Contacts, _ = s.loadContacts(id) + c.FollowUps, _ = s.loadFollowUps(id) + return c, nil +} + +func (s *Server) loadContacts(customerID string) ([]models.Contact, error) { + rows, err := s.db.Query("SELECT id, name, role, phone FROM contacts WHERE customer_id=? ORDER BY id", customerID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.Contact{} + for rows.Next() { + var c models.Contact + if err := rows.Scan(&c.ID, &c.Name, &c.Role, &c.Phone); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (s *Server) loadFollowUps(customerID string) ([]models.FollowUp, error) { + rows, err := s.db.Query("SELECT id, time, content FROM follow_ups WHERE customer_id=? ORDER BY id", customerID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.FollowUp{} + for rows.Next() { + var f models.FollowUp + if err := rows.Scan(&f.ID, &f.Time, &f.Content); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +// listCustomers 客户列表(支持 stage / level / industry / q 筛选) +func (s *Server) listCustomers(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + q := strings.TrimSpace(r.URL.Query().Get("q")) + + query := `SELECT ` + customerCols + ` FROM customers + WHERE owner_id=? AND deleted_at IS NULL` + args := []any{uid} + for _, f := range []struct{ key, col string }{ + {"stage", "stage"}, {"level", "level"}, {"industry", "industry"}, + } { + if v := r.URL.Query().Get(f.key); v != "" { + query += " AND " + f.col + "=?" + args = append(args, v) + } + } + if q != "" { + query += ` AND (company LIKE ? OR industry LIKE ? OR address LIKE ? OR remark LIKE ? OR id IN + (SELECT customer_id FROM contacts WHERE name LIKE ?))` + like := "%" + q + "%" + args = append(args, like, like, like, like, like) + } + query += " ORDER BY updated_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + writeErr(w, http.StatusInternalServerError, "查询失败") + return + } + defer rows.Close() + + out := []models.Customer{} + for rows.Next() { + c := models.Customer{} + if err := rows.Scan(&c.ID, &c.Company, &c.Industry, &c.Address, &c.Level, &c.Stage, + &c.MachineModel, &c.Budget, &c.WorkpieceNeeds, &c.PainPoints, + &c.NextFollowDate, &c.Source, &c.Remark, &c.CreatedAt, &c.UpdatedAt); err != nil { + writeErr(w, http.StatusInternalServerError, "数据读取失败") + return + } + c.Contacts, _ = s.loadContacts(c.ID) + c.FollowUps, _ = s.loadFollowUps(c.ID) + out = append(out, c) + } + if err := rows.Err(); err != nil { + writeErr(w, http.StatusInternalServerError, "数据读取失败") + return + } + writeJSON(w, http.StatusOK, out) +} + +// createCustomer 新建客户 +func (s *Server) createCustomer(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + var c models.Customer + if err := decodeJSON(r, &c); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + if strings.TrimSpace(c.Company) == "" { + writeErr(w, http.StatusBadRequest, "公司名称必填") + return + } + if c.ID == "" { + c.ID = genID() + } + c.Level = models.NormalizeLevel(c.Level) + c.Stage = models.NormalizeStage(c.Stage) + now := nowStr() + if c.CreatedAt == "" { + c.CreatedAt = now + } + if c.UpdatedAt == "" { + c.UpdatedAt = now + } + + if err := s.saveCustomerTx(uid, &c, false); err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + full, err := s.loadCustomer(uid, c.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "读取失败") + return + } + writeJSON(w, http.StatusCreated, full) +} + +// getCustomer 客户详情 +func (s *Server) getCustomer(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + id := pathID(r, "id") + c, err := s.loadCustomer(uid, id) + if errors.Is(err, sql.ErrNoRows) { + writeErr(w, http.StatusNotFound, "客户不存在") + return + } + if err != nil { + writeErr(w, http.StatusInternalServerError, "查询失败") + return + } + writeJSON(w, http.StatusOK, c) +} + +// updateCustomer 全量更新客户(联系人/跟进记录采用全量重建策略) +func (s *Server) updateCustomer(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + id := pathID(r, "id") + if !s.ownsCustomer(uid, id) { + writeErr(w, http.StatusNotFound, "客户不存在") + return + } + var c models.Customer + if err := decodeJSON(r, &c); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + if strings.TrimSpace(c.Company) == "" { + writeErr(w, http.StatusBadRequest, "公司名称必填") + return + } + c.ID = id + c.Level = models.NormalizeLevel(c.Level) + c.Stage = models.NormalizeStage(c.Stage) + c.UpdatedAt = nowStr() + + if err := s.saveCustomerTx(uid, &c, true); err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + full, err := s.loadCustomer(uid, id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "读取失败") + return + } + writeJSON(w, http.StatusOK, full) +} + +// deleteCustomer 软删除客户,并解除其关联任务的客户引用 +func (s *Server) deleteCustomer(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + id := pathID(r, "id") + res, err := s.db.Exec("UPDATE customers SET deleted_at=? WHERE id=? AND owner_id=? AND deleted_at IS NULL", + nowStr(), id, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "删除失败") + return + } + if n, _ := res.RowsAffected(); n == 0 { + writeErr(w, http.StatusNotFound, "客户不存在") + return + } + _, _ = s.db.Exec("UPDATE tasks SET customer_id=NULL WHERE customer_id=? AND owner_id=?", id, uid) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// saveCustomerTx 事务内插入或更新客户及子表 +func (s *Server) saveCustomerTx(uid int64, c *models.Customer, isUpdate bool) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if isUpdate { + _, err = tx.Exec(`UPDATE customers SET company=?, industry=?, address=?, level=?, stage=?, + machine_model=?, budget=?, workpiece_needs=?, pain_points=?, next_follow_date=?, + source=?, remark=?, updated_at=? WHERE id=? AND owner_id=?`, + c.Company, c.Industry, c.Address, c.Level, c.Stage, c.MachineModel, c.Budget, + c.WorkpieceNeeds, c.PainPoints, c.NextFollowDate, c.Source, c.Remark, c.UpdatedAt, c.ID, uid) + } else { + _, err = tx.Exec(`INSERT INTO customers (id, owner_id, company, industry, address, level, stage, + machine_model, budget, workpiece_needs, pain_points, next_follow_date, source, remark, + created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + c.ID, uid, c.Company, c.Industry, c.Address, c.Level, c.Stage, c.MachineModel, + c.Budget, c.WorkpieceNeeds, c.PainPoints, c.NextFollowDate, c.Source, c.Remark, + c.CreatedAt, c.UpdatedAt) + } + if err != nil { + return err + } + + // 全量重建子表 + if _, err := tx.Exec("DELETE FROM contacts WHERE customer_id=?", c.ID); err != nil { + return err + } + for _, ct := range c.Contacts { + if _, err := tx.Exec("INSERT INTO contacts (customer_id, name, role, phone) VALUES (?,?,?,?)", + c.ID, ct.Name, ct.Role, ct.Phone); err != nil { + return err + } + } + if _, err := tx.Exec("DELETE FROM follow_ups WHERE customer_id=?", c.ID); err != nil { + return err + } + for _, f := range c.FollowUps { + if _, err := tx.Exec("INSERT INTO follow_ups (customer_id, time, content) VALUES (?,?,?)", + c.ID, f.Time, f.Content); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Server) ownsCustomer(uid int64, id string) bool { + var one int + err := s.db.QueryRow("SELECT 1 FROM customers WHERE owner_id=? AND id=? AND deleted_at IS NULL", uid, id).Scan(&one) + return err == nil +} + +// addContact 添加联系人 +func (s *Server) addContact(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + cid := pathID(r, "id") + if !s.ownsCustomer(uid, cid) { + writeErr(w, http.StatusNotFound, "客户不存在") + return + } + var c models.Contact + if err := decodeJSON(r, &c); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + res, err := s.db.Exec("INSERT INTO contacts (customer_id, name, role, phone) VALUES (?,?,?,?)", + cid, c.Name, c.Role, c.Phone) + if err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + c.ID, _ = res.LastInsertId() + writeJSON(w, http.StatusCreated, c) +} + +// deleteContact 删除联系人 +func (s *Server) deleteContact(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + cid := pathID(r, "id") + contactID := pathID(r, "cid") + if !s.ownsCustomer(uid, cid) { + writeErr(w, http.StatusNotFound, "客户不存在") + return + } + if _, err := s.db.Exec("DELETE FROM contacts WHERE id=? AND customer_id=?", contactID, cid); err != nil { + writeErr(w, http.StatusInternalServerError, "删除失败") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// addFollowUp 添加跟进记录 +func (s *Server) addFollowUp(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + cid := pathID(r, "id") + if !s.ownsCustomer(uid, cid) { + writeErr(w, http.StatusNotFound, "客户不存在") + return + } + var f models.FollowUp + if err := decodeJSON(r, &f); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + res, err := s.db.Exec("INSERT INTO follow_ups (customer_id, time, content) VALUES (?,?,?)", + cid, f.Time, f.Content) + if err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + f.ID, _ = res.LastInsertId() + writeJSON(w, http.StatusCreated, f) +} + +// deleteFollowUp 删除跟进记录 +func (s *Server) deleteFollowUp(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + cid := pathID(r, "id") + fid := pathID(r, "fid") + if !s.ownsCustomer(uid, cid) { + writeErr(w, http.StatusNotFound, "客户不存在") + return + } + if _, err := s.db.Exec("DELETE FROM follow_ups WHERE id=? AND customer_id=?", fid, cid); err != nil { + writeErr(w, http.StatusInternalServerError, "删除失败") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/cnc-sales-backend/internal/api/notes.go b/cnc-sales-backend/internal/api/notes.go new file mode 100644 index 0000000..dc48a1f --- /dev/null +++ b/cnc-sales-backend/internal/api/notes.go @@ -0,0 +1,114 @@ +package api + +import ( + "net/http" + "strings" + + "cnc-sales-backend/internal/auth" + "cnc-sales-backend/internal/models" +) + +// listNotes 资料列表(支持 cat 筛选) +func (s *Server) listNotes(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + query := "SELECT id, cat, title, content, updated_at FROM notes WHERE owner_id=? AND deleted_at IS NULL" + args := []any{uid} + if v := r.URL.Query().Get("cat"); v != "" { + query += " AND cat=?" + args = append(args, models.NormalizeNoteCat(v)) + } + query += " ORDER BY updated_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + writeErr(w, http.StatusInternalServerError, "查询失败") + return + } + defer rows.Close() + + out := []models.Note{} + for rows.Next() { + var n models.Note + if err := rows.Scan(&n.ID, &n.Cat, &n.Title, &n.Content, &n.UpdatedAt); err != nil { + writeErr(w, http.StatusInternalServerError, "数据读取失败") + return + } + out = append(out, n) + } + if err := rows.Err(); err != nil { + writeErr(w, http.StatusInternalServerError, "数据读取失败") + return + } + writeJSON(w, http.StatusOK, out) +} + +// createNote 新建资料 +func (s *Server) createNote(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + var n models.Note + if err := decodeJSON(r, &n); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + if strings.TrimSpace(n.Title) == "" { + writeErr(w, http.StatusBadRequest, "资料标题必填") + return + } + if n.ID == "" { + n.ID = genID() + } + n.Cat = models.NormalizeNoteCat(n.Cat) + if n.UpdatedAt == "" { + n.UpdatedAt = nowStr() + } + _, err := s.db.Exec("INSERT INTO notes (id, owner_id, cat, title, content, updated_at) VALUES (?,?,?,?,?,?)", + n.ID, uid, n.Cat, n.Title, n.Content, n.UpdatedAt) + if err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + writeJSON(w, http.StatusCreated, n) +} + +// updateNote 更新资料 +func (s *Server) updateNote(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + id := pathID(r, "id") + var n models.Note + if err := decodeJSON(r, &n); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + if strings.TrimSpace(n.Title) == "" { + writeErr(w, http.StatusBadRequest, "资料标题必填") + return + } + res, err := s.db.Exec("UPDATE notes SET cat=?, title=?, content=?, updated_at=? WHERE id=? AND owner_id=? AND deleted_at IS NULL", + models.NormalizeNoteCat(n.Cat), n.Title, n.Content, nowStr(), id, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + if n, _ := res.RowsAffected(); n == 0 { + writeErr(w, http.StatusNotFound, "资料不存在") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// deleteNote 软删除资料 +func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + id := pathID(r, "id") + res, err := s.db.Exec("UPDATE notes SET deleted_at=? WHERE id=? AND owner_id=? AND deleted_at IS NULL", + nowStr(), id, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "删除失败") + return + } + if n, _ := res.RowsAffected(); n == 0 { + writeErr(w, http.StatusNotFound, "资料不存在") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/cnc-sales-backend/internal/api/router.go b/cnc-sales-backend/internal/api/router.go new file mode 100644 index 0000000..30e808b --- /dev/null +++ b/cnc-sales-backend/internal/api/router.go @@ -0,0 +1,123 @@ +package api + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "log" + "mime" + "net/http" + "strconv" + "strings" + "time" + + "cnc-sales-backend/internal/auth" +) + +// Server API 服务 +type Server struct { + db *sql.DB + mux *http.ServeMux +} + +// New 组装路由与中间件,返回 http.Handler +func New(db *sql.DB, staticDir string) http.Handler { + s := &Server{db: db, mux: http.NewServeMux()} + s.routes(staticDir) + return s.mux +} + +func (s *Server) routes(staticDir string) { + // ---- 认证(公开) ---- + s.mux.HandleFunc("POST /api/auth/register", s.register) + s.mux.HandleFunc("POST /api/auth/login", s.login) + + // ---- 认证(JWT 保护) ---- + s.mux.Handle("GET /api/auth/me", auth.Middleware(http.HandlerFunc(s.me))) + + // ---- 客户档案 ---- + s.mux.Handle("GET /api/customers", auth.Middleware(http.HandlerFunc(s.listCustomers))) + s.mux.Handle("POST /api/customers", auth.Middleware(http.HandlerFunc(s.createCustomer))) + s.mux.Handle("GET /api/customers/{id}", auth.Middleware(http.HandlerFunc(s.getCustomer))) + s.mux.Handle("PUT /api/customers/{id}", auth.Middleware(http.HandlerFunc(s.updateCustomer))) + s.mux.Handle("DELETE /api/customers/{id}", auth.Middleware(http.HandlerFunc(s.deleteCustomer))) + s.mux.Handle("POST /api/customers/{id}/contacts", auth.Middleware(http.HandlerFunc(s.addContact))) + s.mux.Handle("DELETE /api/customers/{id}/contacts/{cid}", auth.Middleware(http.HandlerFunc(s.deleteContact))) + s.mux.Handle("POST /api/customers/{id}/followups", auth.Middleware(http.HandlerFunc(s.addFollowUp))) + s.mux.Handle("DELETE /api/customers/{id}/followups/{fid}", auth.Middleware(http.HandlerFunc(s.deleteFollowUp))) + + // ---- 任务 ---- + s.mux.Handle("GET /api/tasks", auth.Middleware(http.HandlerFunc(s.listTasks))) + s.mux.Handle("POST /api/tasks", auth.Middleware(http.HandlerFunc(s.createTask))) + s.mux.Handle("PUT /api/tasks/{id}", auth.Middleware(http.HandlerFunc(s.updateTask))) + s.mux.Handle("DELETE /api/tasks/{id}", auth.Middleware(http.HandlerFunc(s.deleteTask))) + s.mux.Handle("POST /api/tasks/{id}/toggle", auth.Middleware(http.HandlerFunc(s.toggleTask))) + + // ---- 资料库 ---- + s.mux.Handle("GET /api/notes", auth.Middleware(http.HandlerFunc(s.listNotes))) + s.mux.Handle("POST /api/notes", auth.Middleware(http.HandlerFunc(s.createNote))) + s.mux.Handle("PUT /api/notes/{id}", auth.Middleware(http.HandlerFunc(s.updateNote))) + s.mux.Handle("DELETE /api/notes/{id}", auth.Middleware(http.HandlerFunc(s.deleteNote))) + + // ---- 聚合视图 ---- + s.mux.Handle("GET /api/pipeline/stages", auth.Middleware(http.HandlerFunc(s.pipelineStages))) + s.mux.Handle("GET /api/stats/overview", auth.Middleware(http.HandlerFunc(s.statsOverview))) + s.mux.Handle("GET /api/today", auth.Middleware(http.HandlerFunc(s.todayPanel))) + + // ---- 备份 ---- + s.mux.Handle("GET /api/backup", auth.Middleware(http.HandlerFunc(s.exportBackup))) + s.mux.Handle("POST /api/backup", auth.Middleware(http.HandlerFunc(s.importBackup))) + + // ---- 静态资源(PWA 前端) ---- + mime.AddExtensionType(".webmanifest", "application/manifest+json") + mime.AddExtensionType(".js", "text/javascript") + + // 根路径重定向到工作台页面,其余路径交给静态文件服务 + fileServer := http.FileServer(http.Dir(staticDir)) + s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + http.Redirect(w, r, "/cnc-sales-workbench.html", http.StatusFound) + return + } + fileServer.ServeHTTP(w, r) + }) +} + +// ---- 通用工具 ---- + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("JSON 序列化失败: %v", err) + } +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +func decodeJSON(r *http.Request, v any) error { + dec := json.NewDecoder(r.Body) + return dec.Decode(v) +} + +func nowStr() string { return time.Now().Format("2006-01-02 15:04") } +func todayStr() string { return time.Now().Format("2006-01-02") } + +// genID 生成与前端 uid() 兼容的 ID(前缀 + 时间戳36进制 + 随机数) +func genID() string { + var sb strings.Builder + sb.WriteString("id") + sb.WriteString(strconv.FormatInt(time.Now().UnixMilli(), 36)) + b := make([]byte, 4) + if _, err := rand.Read(b); err == nil { + sb.WriteString(hex.EncodeToString(b)) + } + return sb.String() +} + +func pathID(r *http.Request, key string) string { + return strings.TrimSpace(r.PathValue(key)) +} diff --git a/cnc-sales-backend/internal/api/tasks.go b/cnc-sales-backend/internal/api/tasks.go new file mode 100644 index 0000000..bd86d36 --- /dev/null +++ b/cnc-sales-backend/internal/api/tasks.go @@ -0,0 +1,275 @@ +package api + +import ( + "database/sql" + "net/http" + "strings" + "time" + + "cnc-sales-backend/internal/auth" + "cnc-sales-backend/internal/models" +) + +const taskCols = `t.id, t.title, t.customer_id, t.due_date, t.priority, t.status, + t.repeat, t.created_at, COALESCE(c.company,'')` + +func (s *Server) loadTask(uid int64, id string) (*models.Task, error) { + t := &models.Task{} + var customerID, repeat sql.NullString + err := s.db.QueryRow(`SELECT `+taskCols+` FROM tasks t + LEFT JOIN customers c ON c.id=t.customer_id + WHERE t.owner_id=? AND t.id=? AND t.deleted_at IS NULL`, uid, id). + Scan(&t.ID, &t.Title, &customerID, &t.DueDate, &t.Priority, &t.Status, + &repeat, &t.CreatedAt, &t.CustomerName) + if err != nil { + return nil, err + } + if customerID.Valid && customerID.String != "" { + t.CustomerID = &customerID.String + } + if repeat.Valid && repeat.String != "" { + t.Repeat = &repeat.String + } + return t, nil +} + +// listTasks 任务列表(支持 status / priority 筛选) +func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + query := `SELECT ` + taskCols + ` FROM tasks t + LEFT JOIN customers c ON c.id=t.customer_id + WHERE t.owner_id=? AND t.deleted_at IS NULL` + args := []any{uid} + if v := r.URL.Query().Get("status"); v != "" { + query += " AND t.status=?" + args = append(args, models.NormalizeStatus(v)) + } + if v := r.URL.Query().Get("priority"); v != "" { + query += " AND t.priority=?" + args = append(args, models.NormalizePriority(v)) + } + query += " ORDER BY CASE t.status WHEN 'done' THEN 1 ELSE 0 END, t.due_date, t.created_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + writeErr(w, http.StatusInternalServerError, "查询失败") + return + } + defer rows.Close() + + out := []models.Task{} + for rows.Next() { + t := models.Task{} + var customerID, repeat sql.NullString + if err := rows.Scan(&t.ID, &t.Title, &customerID, &t.DueDate, &t.Priority, &t.Status, + &repeat, &t.CreatedAt, &t.CustomerName); err != nil { + writeErr(w, http.StatusInternalServerError, "数据读取失败") + return + } + if customerID.Valid && customerID.String != "" { + t.CustomerID = &customerID.String + } + if repeat.Valid && repeat.String != "" { + t.Repeat = &repeat.String + } + out = append(out, t) + } + if err := rows.Err(); err != nil { + writeErr(w, http.StatusInternalServerError, "数据读取失败") + return + } + writeJSON(w, http.StatusOK, out) +} + +// createTask 新建任务 +func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + var t models.Task + if err := decodeJSON(r, &t); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + if strings.TrimSpace(t.Title) == "" { + writeErr(w, http.StatusBadRequest, "任务标题必填") + return + } + if t.ID == "" { + t.ID = genID() + } + t.Priority = models.NormalizePriority(t.Priority) + t.Status = models.NormalizeStatus(t.Status) + t.Repeat = models.NormalizeRepeat(deref(t.Repeat)) + if t.CreatedAt == "" { + t.CreatedAt = nowStr() + } + + customerID := nullOrEmpty(t.CustomerID) + _, err := s.db.Exec(`INSERT INTO tasks (id, owner_id, title, customer_id, due_date, priority, + status, repeat, created_at) VALUES (?,?,?,?,?,?,?,?,?)`, + t.ID, uid, t.Title, customerID, t.DueDate, t.Priority, t.Status, nullOrEmpty(t.Repeat), t.CreatedAt) + if err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + full, err := s.loadTask(uid, t.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "读取失败") + return + } + writeJSON(w, http.StatusCreated, full) +} + +// updateTask 更新任务 +func (s *Server) updateTask(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + id := pathID(r, "id") + if _, err := s.loadTask(uid, id); err != nil { + writeErr(w, http.StatusNotFound, "任务不存在") + return + } + var t models.Task + if err := decodeJSON(r, &t); err != nil { + writeErr(w, http.StatusBadRequest, "请求格式错误") + return + } + if strings.TrimSpace(t.Title) == "" { + writeErr(w, http.StatusBadRequest, "任务标题必填") + return + } + t.Priority = models.NormalizePriority(t.Priority) + t.Status = models.NormalizeStatus(t.Status) + t.Repeat = models.NormalizeRepeat(deref(t.Repeat)) + + completedAt := any(nil) + if t.Status == "done" { + completedAt = nowStr() + } + _, err := s.db.Exec(`UPDATE tasks SET title=?, customer_id=?, due_date=?, priority=?, status=?, + repeat=?, completed_at=? WHERE id=? AND owner_id=?`, + t.Title, nullOrEmpty(t.CustomerID), t.DueDate, t.Priority, t.Status, + nullOrEmpty(t.Repeat), completedAt, id, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + full, err := s.loadTask(uid, id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "读取失败") + return + } + writeJSON(w, http.StatusOK, full) +} + +// deleteTask 软删除任务 +func (s *Server) deleteTask(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + id := pathID(r, "id") + res, err := s.db.Exec("UPDATE tasks SET deleted_at=? WHERE id=? AND owner_id=? AND deleted_at IS NULL", + nowStr(), id, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "删除失败") + return + } + if n, _ := res.RowsAffected(); n == 0 { + writeErr(w, http.StatusNotFound, "任务不存在") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// toggleTask 完成/取消任务;完成时若带重复规则则自动生成下一周期任务 +func (s *Server) toggleTask(w http.ResponseWriter, r *http.Request) { + uid := auth.UserID(r) + id := pathID(r, "id") + cur, err := s.loadTask(uid, id) + if err != nil { + writeErr(w, http.StatusNotFound, "任务不存在") + return + } + + var next *models.Task + if cur.Status == "done" { + // 取消完成 + _, err = s.db.Exec("UPDATE tasks SET status='todo', completed_at=NULL WHERE id=? AND owner_id=?", id, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + } else { + // 标记完成 + _, err = s.db.Exec("UPDATE tasks SET status='done', completed_at=? WHERE id=? AND owner_id=?", nowStr(), id, uid) + if err != nil { + writeErr(w, http.StatusInternalServerError, "保存失败") + return + } + if cur.Repeat != nil { + next = s.genRepeatTask(uid, cur) + } + } + + full, err := s.loadTask(uid, id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "读取失败") + return + } + if next == nil { + writeJSON(w, http.StatusOK, full) + return + } + writeJSON(w, http.StatusOK, map[string]any{"task": full, "next": next}) +} + +// genRepeatTask 按重复规则生成下一周期任务 +func (s *Server) genRepeatTask(uid int64, cur *models.Task) *models.Task { + base := time.Now() + if cur.DueDate != "" { + if d, err := time.Parse("2006-01-02", cur.DueDate); err == nil { + base = d + } + } + switch *cur.Repeat { + case "daily": + base = base.AddDate(0, 0, 1) + case "weekly": + base = base.AddDate(0, 0, 7) + case "monthly": + base = base.AddDate(0, 1, 0) + } + nt := &models.Task{ + ID: genID(), + Title: cur.Title, + CustomerID: cur.CustomerID, + DueDate: base.Format("2006-01-02"), + Priority: cur.Priority, + Status: "todo", + Repeat: cur.Repeat, + CreatedAt: nowStr(), + } + _, err := s.db.Exec(`INSERT INTO tasks (id, owner_id, title, customer_id, due_date, priority, + status, repeat, created_at) VALUES (?,?,?,?,?,?,?,?,?)`, + nt.ID, uid, nt.Title, nullOrEmpty(nt.CustomerID), nt.DueDate, nt.Priority, nt.Status, + nullOrEmpty(nt.Repeat), nt.CreatedAt) + if err != nil { + return nil + } + if full, err := s.loadTask(uid, nt.ID); err == nil { + return full + } + return nt +} + +// nullOrEmpty 空指针/空串转 NULL +func nullOrEmpty[T string](v *T) any { + if v == nil || strings.TrimSpace(string(*v)) == "" { + return nil + } + return *v +} + +func deref[T any](v *T) T { + var zero T + if v == nil { + return zero + } + return *v +} diff --git a/cnc-sales-backend/internal/auth/auth.go b/cnc-sales-backend/internal/auth/auth.go new file mode 100644 index 0000000..c7f4b7c --- /dev/null +++ b/cnc-sales-backend/internal/auth/auth.go @@ -0,0 +1,110 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type ctxKey string + +const ( + keyUserID ctxKey = "userID" + keyIsAdmin ctxKey = "isAdmin" +) + +// secret 返回 JWT 签名密钥,可用环境变量 JWT_SECRET 覆盖(生产必须设置) +func secret() []byte { + if s := os.Getenv("JWT_SECRET"); s != "" { + return []byte(s) + } + return []byte("dev-secret-change-me") +} + +// HashPassword 生成 bcrypt 密码哈希 +func HashPassword(pw string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + return string(b), err +} + +// CheckPassword 校验密码 +func CheckPassword(hash, pw string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil +} + +// GenerateToken 签发 72 小时有效的 JWT +func GenerateToken(userID int64) (string, error) { + claims := jwt.RegisteredClaims{ + Subject: strconv.FormatInt(userID, 10), + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(72 * time.Hour)), + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret()) +} + +// Middleware JWT 鉴权中间件 +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, "Bearer ") { + writeErr(w, http.StatusUnauthorized, "未登录") + return + } + token, err := jwt.ParseWithClaims(strings.TrimPrefix(h, "Bearer "), &jwt.RegisteredClaims{}, + func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return secret(), nil + }) + if err != nil || !token.Valid { + writeErr(w, http.StatusUnauthorized, "登录已过期,请重新登录") + return + } + claims, ok := token.Claims.(*jwt.RegisteredClaims) + if !ok { + writeErr(w, http.StatusUnauthorized, "无效凭证") + return + } + uid, err := strconv.ParseInt(claims.Subject, 10, 64) + if err != nil || uid <= 0 { + writeErr(w, http.StatusUnauthorized, "无效凭证") + return + } + ctx := context.WithValue(r.Context(), keyUserID, uid) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// UserID 从请求上下文取当前用户 ID +func UserID(r *http.Request) int64 { + if v, ok := r.Context().Value(keyUserID).(int64); ok { + return v + } + return 0 +} + +// WithAdmin 标记当前用户为管理员(供业务层判断) +func WithAdmin(r *http.Request, isAdmin bool) *http.Request { + ctx := context.WithValue(r.Context(), keyIsAdmin, isAdmin) + return r.WithContext(ctx) +} + +// IsAdmin 判断当前用户是否为管理员 +func IsAdmin(r *http.Request) bool { + v, _ := r.Context().Value(keyIsAdmin).(bool) + return v +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"error":` + strconv.Quote(msg) + `}`)) +} diff --git a/cnc-sales-backend/internal/database/database.go b/cnc-sales-backend/internal/database/database.go new file mode 100644 index 0000000..2a867ea --- /dev/null +++ b/cnc-sales-backend/internal/database/database.go @@ -0,0 +1,117 @@ +package database + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" // 纯 Go SQLite 驱动,无需 CGO +) + +const schema = ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS customers ( + id TEXT PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id), + company TEXT NOT NULL DEFAULT '', + industry TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + level TEXT NOT NULL DEFAULT 'C', + stage TEXT NOT NULL DEFAULT 's1', + machine_model TEXT NOT NULL DEFAULT '', + budget TEXT NOT NULL DEFAULT '', + workpiece_needs TEXT NOT NULL DEFAULT '', + pain_points TEXT NOT NULL DEFAULT '', + next_follow_date TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + deleted_at TEXT DEFAULT NULL +); +CREATE INDEX IF NOT EXISTS idx_customers_owner ON customers(owner_id, deleted_at); +CREATE INDEX IF NOT EXISTS idx_customers_stage ON customers(owner_id, stage); + +CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_contacts_customer ON contacts(customer_id); + +CREATE TABLE IF NOT EXISTS follow_ups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + time TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_followups_customer ON follow_ups(customer_id); + +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + customer_id TEXT REFERENCES customers(id), + due_date TEXT NOT NULL DEFAULT '', + priority TEXT NOT NULL DEFAULT 'P2', + status TEXT NOT NULL DEFAULT 'todo', + repeat TEXT DEFAULT NULL, + completed_at TEXT DEFAULT NULL, + created_at TEXT NOT NULL DEFAULT '', + deleted_at TEXT DEFAULT NULL +); +CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_id, deleted_at); + +CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id), + cat TEXT NOT NULL DEFAULT '其他', + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + deleted_at TEXT DEFAULT NULL +); +CREATE INDEX IF NOT EXISTS idx_notes_owner ON notes(owner_id, deleted_at); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + action TEXT NOT NULL, + entity TEXT NOT NULL DEFAULT '', + entity_id TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '' +); +` + +// Open 打开(必要时创建)SQLite 数据库并执行建表 +func Open(path string) (*sql.DB, error) { + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("创建数据目录失败: %w", err) + } + } + dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("打开数据库失败: %w", err) + } + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + if _, err := db.Exec(schema); err != nil { + return nil, fmt.Errorf("初始化表结构失败: %w", err) + } + return db, nil +} diff --git a/cnc-sales-backend/internal/models/models.go b/cnc-sales-backend/internal/models/models.go new file mode 100644 index 0000000..4742ed8 --- /dev/null +++ b/cnc-sales-backend/internal/models/models.go @@ -0,0 +1,155 @@ +package models + +// 数据模型与业务常量。 +// 所有常量与前端 cnc-sales-workbench.html 中的定义保持一致。 + +// StageNames 销售阶段:s1~s8 +var StageNames = map[string]string{ + "s1": "信息收集", "s2": "需求调研", "s3": "方案定制", "s4": "打样验证", + "s5": "商务谈判", "s6": "合同签订", "s7": "交付安装调试", "s8": "售后维护", +} + +var ( + Levels = []string{"A", "B", "C"} + Priorities = []string{"P0", "P1", "P2"} + TaskStatuses = []string{"todo", "doing", "done"} + Repeats = []string{"daily", "weekly", "monthly"} + NoteCats = []string{"报价模板", "技术沟通要点", "谈判话术", "合同注意事项", "其他"} + ContactRoles = []string{"老板", "技术", "采购", "其他"} +) + +// Contains 判断切片是否包含某值 +func Contains(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +// NormalizeStage 规范化销售阶段,非法值回退到 s1 +func NormalizeStage(s string) string { + if _, ok := StageNames[s]; ok { + return s + } + return "s1" +} + +// NormalizeLevel 规范化客户等级,非法值回退到 C +func NormalizeLevel(s string) string { + if Contains(Levels, s) { + return s + } + return "C" +} + +// NormalizePriority 规范化任务优先级,非法值回退到 P2 +func NormalizePriority(s string) string { + if Contains(Priorities, s) { + return s + } + return "P2" +} + +// NormalizeStatus 规范化任务状态,非法值回退到 todo +func NormalizeStatus(s string) string { + if Contains(TaskStatuses, s) { + return s + } + return "todo" +} + +// NormalizeRepeat 规范化重复规则,非法值返回 nil +func NormalizeRepeat(s string) *string { + if Contains(Repeats, s) { + return &s + } + return nil +} + +// NormalizeNoteCat 规范化资料分类,非法值回退到"其他" +func NormalizeNoteCat(s string) string { + if Contains(NoteCats, s) { + return s + } + return "其他" +} + +// ---- 数据结构 ---- + +// User 用户 +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + CreatedAt string `json:"createdAt"` +} + +// Contact 联系人 +type Contact struct { + ID int64 `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Phone string `json:"phone"` +} + +// FollowUp 跟进记录 +type FollowUp struct { + ID int64 `json:"id"` + Time string `json:"time"` + Content string `json:"content"` +} + +// Customer 客户档案(字段与前端 normalizeCustomer 对齐) +type Customer struct { + ID string `json:"id"` + Company string `json:"company"` + Industry string `json:"industry"` + Address string `json:"address"` + Contacts []Contact `json:"contacts"` + Level string `json:"level"` + Stage string `json:"stage"` + MachineModel string `json:"machineModel"` + Budget string `json:"budget"` + WorkpieceNeeds string `json:"workpieceNeeds"` + PainPoints string `json:"painPoints"` + FollowUps []FollowUp `json:"followUps"` + NextFollowDate string `json:"nextFollowDate"` + Source string `json:"source"` + Remark string `json:"remark"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// Task 任务 +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + CustomerID *string `json:"customerId"` + CustomerName string `json:"customerName,omitempty"` + DueDate string `json:"dueDate"` + Priority string `json:"priority"` + Status string `json:"status"` + Repeat *string `json:"repeat"` + CreatedAt string `json:"createdAt"` +} + +// Note 资料 +type Note struct { + ID string `json:"id"` + Cat string `json:"cat"` + Title string `json:"title"` + Content string `json:"content"` + UpdatedAt string `json:"updatedAt"` +} + +// Backup 全量备份(与前端数据中心 JSON 导出格式对齐) +type Backup struct { + SchemaVersion int `json:"schemaVersion"` + ExportedAt string `json:"exportedAt"` + Customers []Customer `json:"customers"` + Tasks []Task `json:"tasks"` + Notes []Note `json:"notes"` +} diff --git a/cnc-sales-backend/main.go b/cnc-sales-backend/main.go new file mode 100644 index 0000000..407ce6d --- /dev/null +++ b/cnc-sales-backend/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "log" + "net/http" + "os" + + "cnc-sales-backend/internal/api" + "cnc-sales-backend/internal/database" +) + +func main() { + port := getenv("PORT", "8080") + dbPath := getenv("DB_PATH", "data/cnc.db") + staticDir := getenv("STATIC_DIR", "static") + + db, err := database.Open(dbPath) + if err != nil { + log.Fatalf("数据库初始化失败: %v", err) + } + defer db.Close() + + handler := api.New(db, staticDir) + addr := ":" + port + log.Printf("CNC 销售工作台后端已启动: http://localhost%s", addr) + if err := http.ListenAndServe(addr, handler); err != nil { + log.Fatal(err) + } +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/cnc-sales-backend/static/cnc-sales-workbench.html b/cnc-sales-backend/static/cnc-sales-workbench.html new file mode 100644 index 0000000..397cd4f --- /dev/null +++ b/cnc-sales-backend/static/cnc-sales-workbench.html @@ -0,0 +1,1427 @@ + + + + + + + + + + + + +数控销售工作台 + + + +
+ + + +
+ + +
+
+

客户档案

+
管理你的制造工厂客户
+
+
+ +
+ +
+
+
+ + + + +
+ + +
+
+
已自动保存
+ + + + + diff --git a/cnc-sales-backend/static/manifest.webmanifest b/cnc-sales-backend/static/manifest.webmanifest new file mode 100644 index 0000000..626385d --- /dev/null +++ b/cnc-sales-backend/static/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "数控销售工作台", + "short_name": "数控销售台", + "start_url": ".", + "scope": ".", + "display": "standalone", + "orientation": "any", + "background_color": "#f5f6f8", + "theme_color": "#8a5a2b", + "description": "数控机床B端销售客户管理与任务工作台(本地单文件应用)", + "icons": [ + { + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='192' height='192'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23a9743e'/%3E%3Cstop offset='1' stop-color='%236e4421'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='192' height='192' rx='40' fill='url(%23g)'/%3E%3Ctext x='96' y='130' font-size='96' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'%3EC%3C/text%3E%3C/svg%3E", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='512' height='512'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23a9743e'/%3E%3Cstop offset='1' stop-color='%236e4421'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='512' height='512' rx='110' fill='url(%23g)'/%3E%3Ctext x='256' y='342' font-size='256' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'%3EC%3C/text%3E%3C/svg%3E", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/cnc-sales-backend/static/sw.js b/cnc-sales-backend/static/sw.js new file mode 100644 index 0000000..fd8c52f --- /dev/null +++ b/cnc-sales-backend/static/sw.js @@ -0,0 +1,22 @@ +/* 数控销售工作台 离线缓存 Service Worker + 作用:托管(http/https)时缓存本应用,支持断网使用与“添加到桌面/安装为应用”。 + 单文件 HTML 本身即可独立运行,此 SW 仅用于离线加速与桌面安装。 */ +const CACHE = "cnc-wb-v1"; +self.addEventListener("install", (e) => { self.skipWaiting(); }); +self.addEventListener("activate", (e) => { e.waitUntil(self.clients.claim()); }); +self.addEventListener("fetch", (e) => { + if (e.request.method !== "GET") return; + const url = new URL(e.request.url); + if (url.origin !== location.origin) return; // 仅缓存同源资源 + e.respondWith((async () => { + const cache = await caches.open(CACHE); + const cached = await cache.match(e.request); + try { + const res = await fetch(e.request); + if (res && res.status === 200) cache.put(e.request, res.clone()); + return res; + } catch (_) { + return cached || new Response("离线且本地无缓存", { status: 503, statusText: "Offline" }); + } + })()); +});