refactor: 优化代码结构

This commit is contained in:
alice
2026-08-16 16:29:06 +08:00
commit b6167d4ee4
31 changed files with 7713 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+18
View File
@@ -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 外键级联。
- APIJWT 鉴权,72h):auth 注册/登录/me(首个用户自动 admin);customers CRUD+筛选+联系人/跟进子接口;tasks CRUD+toggle(完成时按 repeat 自动生成下一周期任务);notes CRUD;聚合 pipeline/stages、stats/overview、todaybackup 导出导入(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 APIChrome/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 项结构检查全部通过。
+50
View File
@@ -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 APIChrome/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
+25
View File
@@ -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"
}
]
}
+22
View File
@@ -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" });
}
})());
});
BIN
View File
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/slZoYoQY3Jp8VuRAmizFemHTTP 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
+12
View File
@@ -0,0 +1,12 @@
# 二进制
bin/
# 数据库与运行时数据
data/*.db
data/*.db-*
# 系统文件
.DS_Store
# 日志
*.log
+96
View File
@@ -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` 做好备份。
+21
View File
@@ -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
)
+54
View File
@@ -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=
+174
View File
@@ -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})
}
+210
View File
@@ -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})
}
+352
View File
@@ -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})
}
+114
View File
@@ -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})
}
+123
View File
@@ -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))
}
+275
View File
@@ -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
}
+110
View File
@@ -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
}
+155
View File
@@ -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"`
}
+36
View File
@@ -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"
}
]
}
+22
View File
@@ -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" });
}
})());
});