refactor: 优化代码结构
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# 2026-08-15
|
||||
|
||||
## CNC 销售工作台前端分析(2026-08-15-13-49-49/)
|
||||
- 单文件 PWA:`cnc-sales-workbench.html`(原生 JS/CSS,无构建无依赖)+ `manifest.webmanifest` + `sw.js`(离线缓存)。
|
||||
- 存储:`localStorage["wb_cncsales_state"]`,SCHEMA=1;顶层 `{settings, ui, drafts, customers[], tasks[], notes[]}`。
|
||||
- 业务常量:阶段 s1~s8(信息收集→售后维护)、等级 A/B/C、优先级 P0/P1/P2、状态 todo/doing/done、重复 daily/weekly/monthly、资料分类 5 类(报价模板/技术沟通要点/谈判话术/合同注意事项/其他)、联系人角色 老板/技术/采购/其他。
|
||||
- 6 大模块:客户档案、销售流程看板、任务待办、数据看板、资料库、数据中心(JSON/Excel/CSV 导入导出,merge 幂等合并)。
|
||||
- ID 生成:`id` + 时间戳36进制 + 随机串;导入用自然键哈希保证幂等。
|
||||
|
||||
## 后端搭建(cnc-sales-backend/,技术选型:Go + SQLite)
|
||||
- 用户选定 **Go + SQLite**(modernc.org/sqlite 纯 Go 驱动,无 CGO)。
|
||||
- 项目结构:`main.go`(入口,PORT/DB_PATH/STATIC_DIR/JWT_SECRET 环境变量)+ `internal/{database,models,auth,api}`。
|
||||
- 数据库:users/customers/contacts/follow_ups/tasks/notes/audit_log 共 7 表;业务表软删除(deleted_at)+ owner_id 数据隔离;contacts/follow_ups 外键级联。
|
||||
- API(JWT 鉴权,72h):auth 注册/登录/me(首个用户自动 admin);customers CRUD+筛选+联系人/跟进子接口;tasks CRUD+toggle(完成时按 repeat 自动生成下一周期任务);notes CRUD;聚合 pipeline/stages、stats/overview、today;backup 导出导入(upsert,兼容前端备份格式)。
|
||||
- 静态托管:static/ 复制了前端 3 个文件;根路径重定向:**不可用 `GET /{$}` + `GET /` 组合(实测 html 404)**,必须用 `HandleFunc("/")` 分流:`r.URL.Path == "/"` 时 302 到 `/cnc-sales-workbench.html`,否则交给 `http.FileServer`。
|
||||
- 环境:Go 1.26.6 在 `/usr/local/go/bin`(不在 PATH,需 `export PATH=$PATH:/usr/local/go/bin`);`GOPROXY=https://goproxy.cn,direct` 才能拉依赖(默认代理超时)。
|
||||
- 已完成端到端冒烟测试(注册/登录/CRUD/重复任务/聚合/备份/401/静态页全部通过);测试数据已清理。
|
||||
- 二进制:`bin/cnc-server`,运行于 http://localhost:8080。前端尚未接入后端(仍 localStorage 单机模式),下一步可选做前端登录页 + 全量同步层(方案 A)。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 2026-08-14 工作日志
|
||||
|
||||
## 读书笔记工作台搭建
|
||||
|
||||
为用户搭建了一个单文件 HTML 读书笔记工作台(reading-notes-workspace.html)。
|
||||
|
||||
**用户需求**:5 个 Tab(纸质书/电子书/视频转文字/日记/英语)+ 首页看板,冷白底褐色渐变视觉,全内联 SVG 图标,响应式三端布局,localStorage 即时存储,导出导入备份。
|
||||
|
||||
**技术方案**:
|
||||
- 单文件 HTML,所有 CSS/JS/SVG 内联,零外部依赖
|
||||
- 资料库 skill 加载失败,采用本地 localStorage 方案
|
||||
- localStorage key 前缀 `wb_reading_`
|
||||
- 响应式:PC 左侧边栏 + 多栏,平板顶部导航,手机底部 Tab + 单列
|
||||
- 语音输入:Web Speech API(Chrome/Edge 支持)
|
||||
- 拍照上传:FileReader + Canvas 压缩到 800px
|
||||
- 水印:时间自动 + 位置 Geolocation API
|
||||
- 彩带动效:纯 CSS/JS confetti
|
||||
- 图表/图标:全部内联 SVG,无 emoji
|
||||
- 预置示例数据 5 条(含 1 条逾期书籍)
|
||||
|
||||
**文件位置**:/Users/alice/WorkBuddy/2026-08-14-17-50-08/reading-notes-workspace.html
|
||||
**文件大小**:约 76 KB
|
||||
|
||||
## 第二轮迭代修改(18:52)
|
||||
|
||||
按用户要求完成 4 项修改:
|
||||
|
||||
1. **备忘录板块**:首页"今天要处理"新增备忘录(输入框+回车添加),勾选完成自动记录 completedAt 并触发彩带;已完成备忘自动同步显示在日记模块(带"备忘"标签和完成时间);支持取消勾选和删除。存储 key `wb_reading_memo`。
|
||||
2. **书籍合并**:纸质书/电子书两个 Tab 合并为单一"书籍" Tab;新建书籍弹窗可选类型(纸质书/电子书,复用 mood-option 选择器样式);纸质书类型保留拍照/水印/阅读目标/读完功能,电子书类型保留章节摘录;数据统一存 `wb_reading_books`(含 type 字段),旧 paper/ebook 数据自动迁移(migrateBooks + 导入兼容)。
|
||||
3. **英语扩展**:新建可选"单词"(音标/释义/例句)或"口语句子"(句子/翻译/使用场景,支持语音输入),渲染带类型标签;掌握按钮对两种类型都触发彩带。
|
||||
4. **视觉改版**:冷白底 #f2f7f4 + 薄荷绿渐变 #7ed8b4→#2a9d78,全部根变量与硬编码褐色(阴影/封面/遮罩/彩带/心情色)替换。
|
||||
|
||||
导航从 6 项变为 5 项(首页/书籍/视频/日记/英语),手机底部 Tab 布局仍适配。文件 1000 行 / 81.3KB,JS 语法与 23 项结构检查全部通过。
|
||||
|
||||
## 第三轮迭代修改(19:30)
|
||||
|
||||
按用户要求完成 4 项修改:
|
||||
|
||||
1. **移除"清空全部数据"**:首页底部仅保留"清空示例数据",删除 clearAllData 函数和对应按钮,防止误删。
|
||||
2. **分类汇总竖排布局**:summary-grid 从横向网格改为 flex-direction:column 竖排,卡片改为横排(图标+信息),最大宽度 280px,左侧分布。
|
||||
3. **全局编辑模式**:
|
||||
- 新增 state.editMode / state.selectedIds 状态
|
||||
- 长按内容(触摸 600ms / 鼠标长按)进入编辑模式
|
||||
- 编辑模式:每项显示选中状态(蓝色边框+背景),编辑栏显示已选数/全选/删除选中/退出
|
||||
- 点击已有内容可直接编辑(editBook/editVideoNote/editDiary/editEnglish 四个编辑函数)
|
||||
- 各模块底部新增"编辑模式"按钮入口
|
||||
- deleteSelected 统一处理 books/video/diary/english/booknotes 五种类别
|
||||
4. **书籍笔记详情页增强**:
|
||||
- 原"删除"按钮改为"编辑笔记"按钮(进入多选模式)+ "编辑书籍"按钮(编辑书名/作者/类型/目标日期/状态)
|
||||
- 排序选择:最新优先 / 最早优先 / 按章节(电子书),按章节时自动分组显示章节标题
|
||||
- 每条笔记新增"追加感悟"按钮,支持文字+语音输入,多条感悟独立显示带时间戳
|
||||
- 笔记数据结构新增 reflections[] 数组(兼容旧 reflection 字段自动迁移)
|
||||
|
||||
文件增至 1269 行 / 99.6KB,JS 语法与 17 项结构检查全部通过。
|
||||
@@ -0,0 +1,50 @@
|
||||
# 读书笔记工作台 - 项目概述
|
||||
|
||||
## 完成内容
|
||||
|
||||
为用户搭建了一个单文件 HTML 读书笔记工作台,完全离线可用,无任何外部依赖。
|
||||
历经三轮迭代:
|
||||
1. 初版搭建(5 Tab + 首页看板)
|
||||
2. 备忘录、书籍合并、英语双语类型、薄荷绿视觉
|
||||
3. 编辑模式、长按多选、分类汇总竖排、笔记排序与追加感悟
|
||||
|
||||
## 工作台结构
|
||||
|
||||
### 5 个页面
|
||||
1. **首页**:关键字搜索(跨全部分类)、按时间排序、**分类汇总竖排布局**(左侧垂直分布)、"今天要处理"区域(逾期书籍提醒 + 今日日记 + 备忘录板块)
|
||||
2. **书籍**:新建书籍时可选类型(纸质书/电子书);纸质书保留拍照笔记、语音、水印、阅读目标、读完标记;电子书保留章节摘录;笔记支持**三种排序**(最新/最早/按章节)和**随时追加感悟**
|
||||
3. **视频转文字**:记录视频链接和提取文字、感悟、水印
|
||||
4. **日记**:日期、心情选择器、地点、内容;已完成的备忘录自动同步显示
|
||||
5. **英语**:新建可选单词或口语句子,掌握标记触发彩带
|
||||
|
||||
### 编辑模式(第三轮新增)
|
||||
- **长按**任意内容项进入编辑模式(触摸 600ms / 鼠标长按)
|
||||
- 编辑模式:选中项蓝色高亮,编辑栏显示已选数/全选/删除选中/退出
|
||||
- **点击**任意已有内容可直接编辑(书籍信息、视频笔记、日记、英语单词/句子)
|
||||
- 各模块底部有"编辑模式"按钮入口
|
||||
|
||||
### 备忘录功能
|
||||
- 输入框回车或"添加"按钮新建事项
|
||||
- 勾选完成 → 自动记录完成时间 → 触发彩带 → 同步到日记模块
|
||||
- **可取消勾选**防止误触;可删除
|
||||
|
||||
### 书籍笔记增强(第三轮新增)
|
||||
- 原"删除"按钮改为"编辑笔记"(多选删除)+ "编辑书籍"(修改书名/作者/类型/状态)
|
||||
- 排序选择:最新优先 / 最早优先 / 按章节(电子书自动分组)
|
||||
- 每条笔记新增"追加感悟"按钮,支持文字+语音,多条感悟独立显示
|
||||
|
||||
### 技术特性
|
||||
- **单文件 HTML**:所有 CSS/JS/SVG 全内联,99.6KB,断网可用
|
||||
- **视觉**:冷白底 #f2f7f4 + 薄荷绿渐变 #7ed8b4 → #2a9d78
|
||||
- **响应式三端**:PC 左侧边栏 + 多栏,平板顶部导航,手机底部 5 Tab + 单列
|
||||
- **数据存储**:localStorage 即时保存,key 前缀 `wb_reading_`
|
||||
- **导出导入**:JSON 备份/恢复
|
||||
- **彩带动效**:目标达成时触发
|
||||
- **语音输入**:Web Speech API(Chrome/Edge)
|
||||
- **拍照上传**:自动压缩到 800px
|
||||
- **水印**:时间自动 + 位置 Geolocation
|
||||
- **图标**:全部内联 SVG,无 emoji
|
||||
- **安全**:已移除"清空全部数据"功能,防止误删
|
||||
|
||||
## 文件位置
|
||||
`/Users/alice/WorkBuddy/2026-08-14-17-50-08/reading-notes-workspace.html`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 项关键特性存在性自检全过
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
})());
|
||||
});
|
||||
Vendored
BIN
Binary file not shown.
@@ -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 项任务完成,交付用户
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
# 二进制
|
||||
bin/
|
||||
|
||||
# 数据库与运行时数据
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
@@ -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 <token>`。
|
||||
|
||||
### 认证(公开)
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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` 做好备份。
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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, today)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
if t, ok := scanTask(rows); ok {
|
||||
overdue = append(overdue, *t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dueToday := []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=?`,
|
||||
uid, today)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
if t, ok := scanTask(rows); ok {
|
||||
dueToday = append(dueToday, *t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dueFollowUps := []models.Customer{}
|
||||
rows, err = s.db.Query(`SELECT `+customerCols+` FROM customers
|
||||
WHERE owner_id=? AND deleted_at IS NULL AND next_follow_date!='' AND next_follow_date<=?`,
|
||||
uid, today)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
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 {
|
||||
dueFollowUps = append(dueFollowUps, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"today": today,
|
||||
"overdueTasks": overdue,
|
||||
"dueTodayTasks": dueToday,
|
||||
"dueFollowUps": dueFollowUps,
|
||||
})
|
||||
}
|
||||
|
||||
func scanTask(rows *sql.Rows) (*models.Task, bool) {
|
||||
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 {
|
||||
return nil, false
|
||||
}
|
||||
if customerID.Valid && customerID.String != "" {
|
||||
t.CustomerID = &customerID.String
|
||||
}
|
||||
if repeat.Valid && repeat.String != "" {
|
||||
t.Repeat = &repeat.String
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// pipelineStages 销售流程 8 阶段看板数据
|
||||
func (s *Server) pipelineStages(w http.ResponseWriter, r *http.Request) {
|
||||
uid := auth.UserID(r)
|
||||
rows, err := s.db.Query("SELECT stage, company FROM customers WHERE owner_id=? AND deleted_at IS NULL", uid)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stageCompanies := map[string][]string{}
|
||||
for rows.Next() {
|
||||
var stage, company string
|
||||
if err := rows.Scan(&stage, &company); err != nil {
|
||||
continue
|
||||
}
|
||||
stageCompanies[stage] = append(stageCompanies[stage], company)
|
||||
}
|
||||
|
||||
// 固定顺序输出 s1~s8
|
||||
keys := make([]string, 0, len(models.StageNames))
|
||||
for k := range models.StageNames {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
out := []map[string]any{}
|
||||
for _, k := range keys {
|
||||
out = append(out, map[string]any{
|
||||
"stage": k,
|
||||
"name": models.StageNames[k],
|
||||
"count": len(stageCompanies[k]),
|
||||
"companies": stageCompanies[k],
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// statsOverview 数据看板统计
|
||||
func (s *Server) statsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
uid := auth.UserID(r)
|
||||
|
||||
type kv struct {
|
||||
Key string `json:"key"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
groupBy := func(table, col string) []kv {
|
||||
rows, err := s.db.Query("SELECT "+col+", COUNT(*) FROM "+table+" WHERE owner_id=? AND deleted_at IS NULL GROUP BY "+col, uid)
|
||||
if err != nil {
|
||||
return []kv{}
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []kv{}
|
||||
for rows.Next() {
|
||||
var k kv
|
||||
if rows.Scan(&k.Key, &k.Count) == nil {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var totalCustomers, totalTasks, totalNotes, recentAdded int
|
||||
_ = s.db.QueryRow("SELECT COUNT(*) FROM customers WHERE owner_id=? AND deleted_at IS NULL", uid).Scan(&totalCustomers)
|
||||
_ = s.db.QueryRow("SELECT COUNT(*) FROM tasks WHERE owner_id=? AND deleted_at IS NULL", uid).Scan(&totalTasks)
|
||||
_ = s.db.QueryRow("SELECT COUNT(*) FROM notes WHERE owner_id=? AND deleted_at IS NULL", uid).Scan(&totalNotes)
|
||||
|
||||
weekAgo := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
|
||||
_ = s.db.QueryRow("SELECT COUNT(*) FROM customers WHERE owner_id=? AND deleted_at IS NULL AND created_at>=?",
|
||||
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"),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) + `}`))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
})());
|
||||
});
|
||||
Reference in New Issue
Block a user