12 Commits
Author SHA1 Message Date
Masir01 1c9bf446dd Merge remote-tracking branch 'origin/dev-ma' 2026-08-16 19:52:31 +08:00
Masir01 404e142929 refactor(frontend): 对齐 app.js 与模板渲染逻辑 2026-08-16 19:51:58 +08:00
Masir01 1dd1cf9bc2 feat(freight-sfa-server): 初始化货代SFA服务端项目 2026-08-16 19:28:07 +08:00
Masir01 9faa2dd78a chore: 删除误创建的 console.log 文件 2026-08-16 19:25:50 +08:00
Masir01 e378e7d18a chore(freight-sfa-server): 移除临时冒烟测试脚本与测试数据文件 2026-08-16 19:25:42 +08:00
Masir01 64ca351c82 fix(freight-sfa-server): 修复静态文件服务路径遍历问题 2026-08-16 19:25:33 +08:00
Masir01 a9cbb20889 feat(freight-sfa-server): 新增货代SFA服务端及前端页面 2026-08-16 19:11:31 +08:00
alice 3d02548743 fix(侧栏): 修复移动端遮罩锁死并优化今日任务展示
- 移动端侧栏遮罩改为透明可穿透,避免打开抽屉后主内容无法操作
- 侧栏首页看板项支持拖拽排序,顺序持久化到 localStorage
- 重构今日任务区域:按客户聚合、分类配色、增加已查看勾选标记
- 新增备忘录日历功能:可按日期查看、添加、勾选待办,支持年月滚轮切换
- 等级分类仪表盘改为按等级多卡片聚合展示
- 清理顶部 S 图标与无用 CSS
2026-08-16 19:09:04 +08:00
alice f11c84911a chore: add .gitignore 2026-08-16 17:09:26 +08:00
Masir01 46c4c86808 chore: 删除读书笔记工作台项目文件 2026-08-16 16:54:28 +08:00
alice 9e3a3c1b4e chore: 更新 .DS_Store 文件 2026-08-16 16:39:10 +08:00
alice b6167d4ee4 refactor: 优化代码结构 2026-08-16 16:29:06 +08:00
54 changed files with 11252 additions and 2 deletions
Vendored
BIN
View File
Binary file not shown.
+54
View File
@@ -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 项结构检查全部通过。
+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)。
+57
View File
@@ -0,0 +1,57 @@
# 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 项任务完成,交付用户
<<<<<<< HEAD
- 用户反馈后去掉 freight-sfa.html 顶栏的 S 图标(header-logo 内 SVG),仅保留标题文字
- 继续去掉顶栏最右侧用户头像中的字母 S(移除整个 #userAvatar 元素,JS 无引用)
- 用户反馈「侧边栏点不动」:根因是 init() 中 bindSidebar() 排在无 try-catch 的 loadSampleData() 之后,若数据加载抛错则事件不绑定。修复:将 bindSidebar/bindHeader/bindModals 提前到 init 最前面执行,loadState/loadSampleData/renderAll 全部包 try-catch,并校验 state 数组类型;bindSidebar 的 showPage 也加 try-catch 保证高亮/关抽屉不中断。侧边栏导航从此保证始终可点。
- 续:再次去掉顶栏右侧 #userAvatar(已删除元素)并清理无用 CSS `.user-avatar`header-logo 容器保留,favicon/PWA manifest 中的 "S" 未触及。
- 侧栏重构:把"首页看板"内 9 项包进 `#sideSortable` 容器,每项加 `draggable="true"` + `⋮⋮` 拖动手柄;新增 `bindSidebarSortable` 实现拖拽排序,按顺序持久化到 `localStorage[wb_freight_sfa_v1_side_order]`;dragstart 仅当指针在手柄上才响应,点文字/图标不进入拖拽、点击导航照常。"客户全量"→"所有客户""A类重点"→"等级分类",其 route 不变。
- 等级分类仪表盘改造:`renderVIPView` 重写为按 `state.options.grade` 分级(默认 A/B/C)多块聚合卡片(每级一张:客户数/占比/待跟进/累计货量),下方表格按分级分组、加分组头、点击"跳到表筛选"自动设置 `cf-grade` 并跳转到"所有客户"页(cf-grade=该等级)。空数据时显示引导。无客户时显示"请先在客户档案表新增"。
- 侧栏图标顺序:用户反馈图标要在最左侧,且点文字不可复制。把所有 15 个侧栏项的文字包进 <span class="side-label">CSS order 设为 `.ic=1, .side-label=2(flex:1 占满剩余), .drag-handle=3, .badge=4`,视觉顺序:🔷图标 → 文字 → ⋮⋮拖手柄 → 数字徽章。side-label 加 user-select:-webkit-/-moz-/-ms-/-none 全套禁止文本复制。
- 移动端侧栏 bug 修复:原本 `.drawer-mask` 是 rgba(0,0,0,.4) 深色全屏遮罩(pointer-events 默认 auto),页面变窄(≤1024px)打开左侧栏时整个画面变暗且被遮罩锁死无法点主内容。改为 `background:transparent + pointer-events:none`(透明可穿透,主画面不再变暗、可正常操作);同时移动端 `.sidebar``z-index:60` 保证抽屉浮于顶层。关闭方式不变:点导航项自动关、点菜单按钮 toggle 关(遮罩透明后点击遮罩外的 onclick 已无意义,靠按钮/导航项关闭)。
- 备忘录功能:state.memos=[{id,date('YYYY-MM-DD'),text,done,created}];在 today-bar 下方加 memo-bar (含输入框+日期选择器);首页 charts 后插入"备忘录日历"卡片(当月日历、每天显示"待 N / 完 N" pill、可翻上月/下月/今天、点击日期同步切换 today-bar 输入框日期)。函数: memoInit/memoAdd/memoToggle/memoDel/memoRenderBar/renderMemoCalendar/memoNavMonth/memoJumpDate/memoPills。已挂到 init 末尾与 renderDashboard 末尾同步刷新。
- 备忘录增强:①勾选完成时触发 `fireConfetti()` 彩带粒子动画(70 片随机颜色,translate+rotate+淡出);打勾后 `.memo-item.done` 只 opacity:.5 变暗、去掉了 text-decoration:line-through(不再加删除线)。②日历标题右侧改为"数字滚轮":年月各显示大号数字 `2026年 08月`,上下 ▲▼ 微调(memoWheelYear/memoWheelMonth),鼠标在导航区滚轮滑动切换月(shift+滚轮切年),旁边红色"今天"按钮 memoGoToday() 一键回到今天并高亮选中。函数 memoEnsureMonth/memoWheelYear/memoWheelMonth/memoGoToday/fireConfetti。滚轮绑定在 .memo-cal-nav 上(onwheel+e.preventDefault)。memoToggle 现返回新 done 状态供 UI 判断是否撒彩带。
- 备忘录日历微调:①把"年月合并"的滚轮拆成 .memo-wheels 两个独立 .memo-wheel —— 年轮(2026年) 与 月轮(08月) 各自 ▲▼ 微调;滚轮事件绑定到每个独立 wheel 上(hover 年轮滑动切年,hover 月轮滑动切月)。②`memoGoToday` 移除 scrollIntoView、改为展开 memoShowDetail(ds) 让用户立即看到今天待办。③日历网格下方新增 #memoDetail 详情面板,点击日期展开当天备忘录列表(含复选框/时间/删除)、再次点击同一日期收起(toggle 行为);另提供 ✕ 按钮手动收起。memoShowDetail / memoHideDetail / memoDetailIn 动画。
- 备忘录去重+解耦:①顶部"我的备忘录"去掉日期选择器(memoDate input),固定只显示今天(label 直接写"我的备忘录 · 今天")。②日历详情面板 #memoDetail 新增添加输入框 + "添加"按钮(回车也可 memoDetailAdd),可直接给任意日期加待办;勾选(memoToggleUI)/删除(memoDelUI) 与顶部共享 state.memos,两边数据即时同步。③引入 window._memoActiveDate 记录日历当前操作日期,memoSelectedDate()/memoDetailDate() 返回它(默认今天);顶部 memoRenderBar(todayStr()) 固定今天,详情 refreshMemoDetail() 用 active date。④memoShowDetail 改为生成头部/列表/输入框三块结构并调 refreshMemoDetail 填充;memoJumpDate/memoGoToday 不再依赖 memoDate 输入框。
- 今天要处理排版重排:HTML 把 todayTasks 从 today-row 内移到外(在 quick-stats 之后),CSS 改为 flex-direction:column.today-tasks+ .today-group/.today-group-head(含彩色 dot + 种类 emoji + 数量徽章)/.today-group-list(任务卡片堆叠)/ .today-empty/.today-tasks-empty。JS renderDashboard 不再按 alert 排序 + slice(0,6) 拍平,而是按 groups.followup/etd/overdue 分桶,渲染顺序固定"待跟进 → 今日开船 → 逾期回款",每个分组最多展示 5 条 + 「还有 N 条…」。同时 quick-stats 数字来源改为 groups.*.length(统一语义)。SI截关临近归入 etd 组。
- 【修正】"今天要处理"排版:之前误做成按种类分组的任务卡片,用户只要"头部一行(今天要处理+2待跟进+0今日开船+1逾期回款) + 下方直接列客户名"。改为:HTML 中 todayTasks 移到 today-row(label+quick-stats)之后;JS 用 custMap{name->{kinds:Set}} 去重收集今天要处理的客户,下方每行显示"客户名 + 类别标签(待跟进/今日开船/逾期回款 tag) + 查看按钮"alert 红色。quick-stats 改为按客户去重计数(followupCount/etdCount/overdueCount 用 Set)。CSS .today-tasks 改纵向 flex,加 .cust-name/.cust-kinds。
- 【修正】今天要处理顶/底数据源统一:之前顶部 quick-stats 用了独立的过滤循环统计客户数,下方 custMap 又用了略有不同的过滤条件,导致顶部"待跟进=0"但下方显示 2 个待跟进客户的不一致。改为:所有客户先收进 custMap(统一的过滤逻辑:逾期回款=回款状态逾期;待跟进=下次跟进==今天或<今天;今日开船=ETD==今天+SI截关临近),顶部三个数字 followupCount/etdCount/overdueCount 用 Object.values(custMap) 反向按 kinds 计数 Set.size。这样顶部数字与下方 row 数量必然一致。
- 【修正】今天要处理客户行配色协调:之前行用 c.alert 决定红/白底,与 followup/etd 标签色不对齐(如美西收货人:行红色但标签蓝色"待跟进")。改为:以"主类别"决定行配色(优先级 overdue > etd > followup),.today-task.row-followup(浅蓝)/.row-etd(浅橙)/.row-overdue(浅红) 三套独立样式;标签用 .tag.tag-followup/tag-etd/tag-overdue 颜色与行同色系。客户列表也按主类别排序,让同类颜色邻接。移除 .today-task.alert 旧规则。
- 今天要处理客户行加"已查看"勾选标记:新增 state.seenToday{客户名:时间戳}(本地 UI 记忆,绝不修改数据库状态;真正完成任务仍须在数据库表标记)。渲染时每行前加 .cust-seen 勾选框(data-name 存客户名,onclick=toggleSeenBy(this)),已勾选则行加 .seen(打勾、整体灰度+淡化 0.4、轻微去饱和)。再次点击可取消。存到现有 wb_freight_sfa_v1。toggleSeenBy 里 delete/add + saveState + renderDashboard 刷新。
=======
## freight-sfa 前后端分离重构(已完成,19 项冒烟测试通过)
- 交付:后端 freight-sfa-serverGo1.26+modernc.org/sqlite 纯Go免CGO+golang-jwt/v5+ 前端 html/ 静态目录(单二进制内嵌服务)
- 前端拆分 6 文件:index.html(应用壳+登录页)、style.css(修正源文件 ::root 笔误为 :root)、js/schemas.jsSCHEMAS+DEFAULT_OPTIONS+COLORS+工具函数 uid/esc/toast/confirmDialog/setSyncState/closeModal/setActive)、js/api.jsfetch+JWT 持久化 localStorageregister/login/me/CRUD/options/dashboard/backup401 调 __forceLogin)、js/app.js(渲染逻辑迁移,loadAll 并行拉 4 类 records+options+mesaveState 全部替换为 REST),js/main.js(登录/注册页装配+boot+bindSidebar/bindHeader/bindModals+logout+saveOwnerPref+injectPWA
- 后端契约:register 返回扁平 userInfo 无 token(首个自动 admin,后续需 admin token);login 返回 userInfo+tokenme 返回 userInforecords 响应 {id,kind,owner_id,data,created_at,updated_at}create 返回 201;财务字段 admin 可见 user 不可见(后端 sanitizeFinancial
- 关键修复:Go http.FileServer 在本机对 /index.html 返回 301 ./(疑似 http.Dir 相对路径/环境异常),改为自实现 serveStaticpath.Clean + filepath.Abs + strings.HasPrefix 防目录遍历 + http.ServeContent + mime.TypeByExtension)后一切正常
- 踩坑:cmd 中 set PORT=18999 尾随空格会把端口设成 "18999 "node -e 内联代码在 cmd 双引号下 `=>` 被吞需写文件执行;node v24 fetch+process.exit 崩溃需 setTimeout 自然退出;cmd `del` 在此环境被包装成 PowerShell DeleteFile 且运行中 exe 占用删除失败需先 taskkill
- 验证:node --check 四 JS 全过;go build 0 错误;19 项冒烟测试(静态 8+auth 2+CRUD 5+options/dashboard/401)全 PASS;浏览器预览登录页正常
- 遗留:html 目录空 data/ 与 freight-sfa-server.exe 由 .gitignore 忽略(data/、*.db、*.exe、*.log);前端保存逻辑为"保存后 persist 该记录"非 debounce 全量提交
## freight-sfa 前端模板同步后端 app.js2026-08-16 完成)
- 权威模板:freight-sfa-workspace/freight-sfa.html(单文件 localStorage 版)→ 后端 freight-sfa-server/html/js/app.js 逐函数对齐
- 变更 18 处(全部为 app.js):新增 COLORS 常量;renderAll 顺序对齐;updateBadges 增加 badge-channels(客户来源渠道 Set 去重);renderDashboard KPI 口径对齐(bookings 排除 已完成/异常、询价按 已报价、week-etd 未来7天、pending-pay 合计、今日任务含逾期回款/今日跟进/逾期跟进/SI截关);drawFollowupChart 全状态柱状图;drawBookingPie 环形图;renderFunnel 卡片样式+ABC tagfilterClients 参数化;renderCustomerTable 整体重绘 9 列;renderVIPView 统计卡+表格;renderChannels 真实渠道聚合;renderBookingsKanban 三组列+逾期高亮;renderPayments KPI+逾期排前;renderLoss 统计+柱状图;renderRisk 统计卡;4 列表(客户/询价/订舱/跟进)列结构对齐模板
- 未改:schemas.js/api.js/main.js/index.html/style.css/Go 后端(已验证契约一致)
- 验证:node --check 四 JS 全过;go build 0 错误;重启冒烟测试(临时 DB 端口 18999)注册/登录/4 类 CRUD/list/dashboard/options/backup/静态页全 PASS;临时文件与测试进程已清理
- 坑:cmd 中 `set T=xxx && curl -H "Bearer %T%"` 同行的 %T% 在解析期展开为空,需把 token 直接内联(延迟展开才生效)
- 收尾清理:app.js submitForm 中两行无效 forEachchip-input 收集残留)已删除(buildChipInput input 事件已实时写回,无需再收集),linter 仅剩模板自带 buildContactRow(p,i) 未用参数 HINT
## freight-sfa 前后端分离重构方案(已出方案,待用户确认选型)
- 现状:freight-sfa.html 单文件 1330 行/145KBlocalStorage 存储(wb_freight_sfa_v1),SCHEMAS 声明式中文 key 字段模型,前端假权限(sw-finance 开关),全量 renderAll 重绘
- 目标架构:浏览器 SPA + Go 后端(复用 cnc-sales-backend 模式:Go1.26 + modernc.org/sqlite 纯Go免CGO + golang-jwt/v5,分层 api/auth/database/models/middleware
- 关键决策:数据表固定列 id/created_at/updated_at/owner + data_json 整存业务字段(保留中文 key),前端 SCHEMAS 几乎不改,只把 saveState 换 API 调用
- 权限:角色 admin/sales/finance/viewer,财务字段后端响应剔除 + audit_logs 审计
- 前端两方案:A) Vue3+Vite+Pinia(推荐,SCHEMAS 抽 JSON 双端共享);B) 原生 ES Module 拆分(零依赖低成本)
- 迁移三步走:localStorage JSON 导出→/api/import 入库→后端先行 curl 验证→前端 API 对接
- 待用户拍板:是否多用户 auth、财务权限真隔离、是否保留 PWA 离线、部署形态(单机局域网 vs Docker/云)
>>>>>>> dev-ma
+21
View File
@@ -0,0 +1,21 @@
# 项目记忆
## 货代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
- 侧栏"首页看板"区可拖拽排序(拖手柄 ⋮⋮),顺序持久化到 `wb_freight_sfa_v1_side_order`
- 等级分类(侧栏项,对应路由 `view-vip` 实际渲染按 ABC 多级汇总):顶部多卡片(每级一张:客户数/占比/待跟进/累计货量),下方分组表格(含"跳到表筛选"快捷跳到 `view-customers-table`
## 技术要点(避坑)
- import_html.py 的 token 必须用 `--token-stdin`stdin 传入),环境变量无效
- 资料库 select/multi_select 字段创建时 options 不能为空,空字段需放占位选项
- JS 对象字面量中含 `/``(``)` 的中文键名必须加引号(曾因此报 SyntaxError
- read_file/grep 看到的"五角星 path d=..."可能截断一部分(如 `M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z` 中含 `L7 14.14 2 9.27`),做精确替换前要先 `od -c``awk` 看真实字节再写 old_str
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
.DS_Store
-2
View File
@@ -1,2 +0,0 @@
# WorkBuddy
+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" });
}
})());
});
@@ -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" });
}
})());
});
+12
View File
@@ -0,0 +1,12 @@
# 编译产物
freight-sfa-server.exe
*.exe
# 运行时数据
data/
*.db
# 临时文件与日志
*.log
*.err
*.tmp
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
module freight-sfa-server
go 1.25.0
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=
+249
View File
@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#6B5385">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="货代SFA">
<meta name="mobile-web-app-capable" content="yes">
<meta name="format-detection" content="telephone=no">
<title>客户管理工作台(货代版)</title>
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%236B5385'/><text x='50%25' y='52%25' text-anchor='middle' fill='%23F5E9D7' font-size='38' font-family='sans-serif' dominant-baseline='middle' font-weight='700'>S</text></svg>">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ===== 登录 / 注册页 ===== -->
<div class="login-page" id="loginPage">
<div class="login-bg"></div>
<div class="login-card">
<div class="login-logo">
<svg viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#F5E9D7"/><text x="50%" y="52%" text-anchor="middle" fill="#54416A" font-size="38" font-family="sans-serif" dominant-baseline="middle" font-weight="700">S</text></svg>
</div>
<h1 class="login-title">货代 SFA 工作台</h1>
<p class="login-sub">客户 · 商机 · 订舱 · 跟进 全流程管理</p>
<div class="login-tabs">
<button class="login-tab active" data-tab="login">登 录</button>
<button class="login-tab" data-tab="register">注 册</button>
</div>
<div class="login-field">
<label>用户名</label>
<input id="loginUser" autocomplete="username" placeholder="请输入用户名">
</div>
<div class="login-field">
<label>密码</label>
<input id="loginPass" type="password" autocomplete="current-password" placeholder="请输入密码">
</div>
<div class="login-field" id="loginNameField" style="display:none">
<label>显示名称</label>
<input id="loginName" autocomplete="nickname" placeholder="团队内展示的姓名">
</div>
<div class="login-hint" id="loginHint">首个注册账号自动成为管理员</div>
<button class="login-btn" id="loginSubmit">登 录</button>
<div class="login-foot">数据存储于服务端 · 团队共享一套账套</div>
</div>
</div>
<div class="app" id="app" style="display:none">
<header class="header">
<button class="menu-toggle" id="menuToggle" aria-label="菜单">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="header-logo">
<svg viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#F5E9D7"/><text x="50%" y="52%" text-anchor="middle" fill="#54416A" font-size="38" font-family="sans-serif" dominant-baseline="middle" font-weight="700">S</text></svg>
<div class="header-title">客户管理工作台 <small>· 货代销售版</small></div>
</div>
<div class="header-search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="globalSearch" type="search" placeholder="搜索客户/商机/订舱号/单号...">
</div>
<div class="header-spacer"></div>
<div class="header-actions">
<div class="sync-indicator" id="syncIndicator" title="服务端连接状态">
<span class="sync-dot" id="syncDot"></span><span id="syncLabel">服务端已连接</span>
</div>
<button class="icon-btn" id="btnQuickAdd" title="快速新建" aria-label="快速新建">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<div class="user-avatar" id="userAvatar">S</div>
</div>
</header>
<aside class="sidebar" id="sidebar">
<div class="side-section">
<div class="side-section-title">首页看板</div>
<div class="side-item" data-route="dashboard"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>综合看板</div>
<div class="side-item" data-route="view-funnel"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46"/></svg>销售漏斗 <span class="badge" id="badge-funnel">0</span></div>
<div class="side-item" data-route="view-customers-table"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h18v4H3zM3 10h18v4H3zM3 17h18v4H3z"/></svg>客户全量</div>
<div class="side-item" data-route="view-vip"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>A类重点 <span class="badge" id="badge-vip">0</span></div>
<div class="side-item" data-route="view-channels"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>渠道产出 <span class="badge" id="badge-channels">0</span></div>
<div class="side-item" data-route="view-bookings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>订舱跟踪 <span class="badge alert-badge" id="badge-bookings-alert">0</span></div>
<div class="side-item" data-route="view-payments"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>回款预警 <span class="badge alert-badge" id="badge-payment">0</span></div>
<div class="side-item" data-route="view-loss"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>丢单复盘 <span class="badge" id="badge-loss">0</span></div>
<div class="side-item" data-route="view-risk"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>高风险客户 <span class="badge alert-badge" id="badge-risk">0</span></div>
</div>
<div class="side-section">
<div class="side-section-title">数据库表</div>
<div class="side-item" data-route="table-clients"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>客户档案 <span class="badge" id="count-clients">0</span></div>
<div class="side-item" data-route="table-inquiries"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>商机询价 <span class="badge" id="count-inquiries">0</span></div>
<div class="side-item" data-route="table-bookings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 17h18M3 12h18M3 7h18"/></svg>订舱执行 <span class="badge" id="count-bookings">0</span></div>
<div class="side-item" data-route="table-followups"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>跟进记录 <span class="badge" id="count-followups">0</span></div>
</div>
<div class="side-divider"></div>
<div class="side-section">
<div class="side-section-title">数据管理</div>
<div class="side-item" data-route="stats"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>数据统计</div>
<div class="side-item" data-route="settings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>个人中心</div>
</div>
<div class="side-footer-card">
<h4>已沉淀数据</h4>
<div class="count" id="footTotal">0</div>
<div style="font-size:11px;color:var(--ink-mute);margin-top:2px">条客户/商机/订舱/跟进记录</div>
</div>
</aside>
<main class="main" id="main">
<section class="page active" id="page-dashboard">
<div class="page-head">
<div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>综合看板</h1><p>今日核心指标 · 待办逾期一屏掌握</p></div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btnRefresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>刷新</button>
<button class="btn btn-primary btn-sm" onclick="openForm('client')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>新建客户</button>
</div>
</div>
<div class="today-bar" id="todayBar">
<div class="label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>今天要处理</div>
<div class="today-tasks" id="todayTasks"></div>
<div class="quick-stats">
<div><b id="qs-today-followups">0</b><span>待跟进</span></div>
<div><b id="qs-today-etd">0</b><span>今日开船</span></div>
<div><b id="qs-overdue-payment">0</b><span>逾期回款</span></div>
</div>
</div>
<div class="grid grid-4" style="margin-bottom:18px">
<div class="card stat-card"><div class="kpi-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="card-title">客户总数</div><div class="num" id="kpi-clients">0</div><div class="delta">A级核心 <span id="kpi-vip-count" style="font-weight:700;margin-left:4px">0</span></div></div>
<div class="card stat-card"><div class="kpi-icon success"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div><div class="card-title">商机询价中</div><div class="num" id="kpi-inquiries">0</div><div class="delta">已报价 <span id="kpi-quoted" style="font-weight:700;margin-left:4px">0</span></div></div>
<div class="card stat-card"><div class="kpi-icon warning"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg></div><div class="card-title">订舱执行中</div><div class="num" id="kpi-bookings">0</div><div class="delta">本周开船 <span id="kpi-week-etd" style="font-weight:700;margin-left:4px">0</span></div></div>
<div class="card stat-card"><div class="kpi-icon cream"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg></div><div class="card-title">待回款金额</div><div class="num" id="kpi-pending-pay">¥0</div><div class="delta down">逾期 <span id="kpi-overdue-pay" style="font-weight:700;margin-left:4px">¥0</span></div></div>
</div>
<div class="charts">
<div class="chart-box"><h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>客户跟进状态分布</h4><svg viewBox="0 0 360 220" id="chart-followup" preserveAspectRatio="xMidYMid meet" style="flex:1;width:100%;height:100%"></svg></div>
<div class="chart-box"><h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="6" width="18" height="13" rx="2"/></svg>订舱状态饼图</h4><svg viewBox="0 0 360 220" id="chart-booking" preserveAspectRatio="xMidYMid meet" style="flex:1;width:100%;height:100%"></svg></div>
</div>
<div class="grid grid-2">
<div class="card"><div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>本周开船 · 需重点盯货</div><div id="week-etd-list" style="margin-top:10px;display:flex;flex-direction:column;gap:8px"></div></div>
<div class="card"><div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/></svg>逾期回款清单</div><div id="overdue-list" style="margin-top:10px;display:flex;flex-direction:column;gap:8px"></div></div>
</div>
</section>
<section class="page" id="page-view-funnel">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46"/></svg>销售漏斗看板</h1><p>按客户跟进状态分组 · 拖拽变更状态</p></div></div>
<div class="kanban cols-5" id="funnel-kanban"></div>
</section>
<section class="page" id="page-view-customers-table">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h18v4H3zM3 10h18v4H3zM3 17h18v4H3z"/></svg>客户分类检索</h1><p>按等级·渠道·供应商多条件筛选汇总</p></div><div class="page-actions"><button class="btn btn-secondary btn-sm" onclick="exportCSV('clients')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>导出Excel</button></div></div>
<div class="filter-bar" id="clientFilter">
<input type="search" id="cf-q" placeholder="搜索客户名称..." oninput="renderCustomerTable()">
<select id="cf-type" onchange="renderCustomerTable()"><option value="">全部客户类型</option></select>
<select id="cf-grade" onchange="renderCustomerTable()"><option value="">全部ABC分级</option></select>
<select id="cf-status" onchange="renderCustomerTable()"><option value="">全部跟进状态</option></select>
<select id="cf-source" onchange="renderCustomerTable()"><option value="">全部来源渠道</option></select>
<div class="filter-spacer"></div><span style="font-size:12px;color:var(--ink-mute)"><b id="cf-count">0</b></span>
</div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="clientTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-view-vip">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>A类重点客户仪表盘</h1><p>仅A级核心 · 优先维护</p></div></div>
<div class="grid grid-3" id="vipDashboard" style="margin-bottom:16px"></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="vipTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-view-channels">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>渠道产出统计</h1><p>按获客渠道 · 复盘渠道价值</p></div></div>
<div id="channelSummary" class="grid grid-4" style="margin-bottom:16px"></div>
<div class="chart-box" style="height:340px;margin-bottom:16px"><h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>各渠道客户产出 · 优质客户占比</h4><svg viewBox="0 0 760 320" id="chart-channel" preserveAspectRatio="xMidYMid meet" style="flex:1;width:100%;height:100%"></svg></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="channelTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-view-bookings">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>订舱业务跟踪看板</h1><p>每票货 · 庄家/DDP/船期 全流程跟踪</p></div></div>
<div class="kanban cols-3" id="bookings-kanban"></div>
</section>
<section class="page" id="page-view-payments">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>回款预警仪表盘</h1><p>筛选未回款·逾期订单 · 管控坏账</p></div></div>
<div class="grid grid-4" id="paymentKPI" style="margin-bottom:16px"></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="paymentTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-view-loss">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>丢单原因统计</h1><p>优化报价·供应商·服务短板</p></div></div>
<div id="lossSummary" class="grid grid-3" style="margin-bottom:16px"></div>
<div class="chart-box" style="height:320px;margin-bottom:16px"><h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="12" width="4" height="9"/><rect x="10" y="6" width="4" height="15"/><rect x="17" y="3" width="4" height="18"/></svg>丢单原因分布</h4><svg viewBox="0 0 760 280" id="chart-loss" preserveAspectRatio="xMidYMid meet" style="flex:1;width:100%;height:100%"></svg></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="lossTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-view-risk">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>高风险客户视图</h1><p>统一管控账期·纠纷·欠款风险</p></div></div>
<div id="riskSummary" class="grid grid-3" style="margin-bottom:16px"></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="riskTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-table-clients">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>客户档案表</h1><p>主表 · 通用+按类型展开+财务保密</p></div><div class="page-actions"><button class="btn btn-secondary btn-sm" onclick="openOptionEditor()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>选项管理</button><button class="btn btn-secondary btn-sm" onclick="exportCSV('clients')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>导出</button><button class="btn btn-primary btn-sm" onclick="openForm('client')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>新建客户</button></div></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="clientListTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-table-inquiries">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>商机询价表</h1><p>询价 → 多版本报价 → 状态流转</p></div><div class="page-actions"><button class="btn btn-secondary btn-sm" onclick="exportCSV('inquiries')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>导出</button><button class="btn btn-primary btn-sm" onclick="openForm('inquiry')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>新建询价</button></div></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="inquiryListTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-table-bookings">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 17h18M3 12h18M3 7h18"/></svg>订舱执行记录表</h1><p>本票实际执行供应商·优先级最高</p></div><div class="page-actions"><button class="btn btn-secondary btn-sm" onclick="exportCSV('bookings')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>导出</button><button class="btn btn-primary btn-sm" onclick="openForm('booking')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>新建订舱</button></div></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="bookingListTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-table-followups">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>跟进记录表</h1><p>沟通·改单·催款全留痕</p></div><div class="page-actions"><button class="btn btn-secondary btn-sm" onclick="exportCSV('followups')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>导出</button><button class="btn btn-primary btn-sm" onclick="openForm('followup')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>新建跟进</button></div></div>
<div class="table-wrap"><div class="table-scroll"><table class="data" id="followupListTable"><thead></thead><tbody></tbody></table></div></div>
</section>
<section class="page" id="page-stats">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>数据统计分析</h1><p>渠道·客户价值·业务健康度</p></div></div>
<div class="grid grid-4" style="margin-bottom:18px" id="statsKPI"></div>
<div class="charts">
<div class="chart-box"><h4>客户等级分布</h4><svg viewBox="0 0 360 220" id="chart-grade" preserveAspectRatio="xMidYMid meet" style="flex:1;width:100%;height:100%"></svg></div>
<div class="chart-box"><h4>客户类型分布</h4><svg viewBox="0 0 360 220" id="chart-type" preserveAspectRatio="xMidYMid meet" style="flex:1;width:100%;height:100%"></svg></div>
</div>
</section>
<section class="page" id="page-settings">
<div class="page-head"><div><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>个人中心 · 数据管理</h1><p>账号 / 备份导入 / 清空 / 桌面挂载</p></div></div>
<div class="profile-card"><div class="avatar">S</div><div class="info"><h3 id="profile-name">货代销售管理员</h3><p>专业货代SFA · 客户/询价/订舱/跟进全闭环</p></div><div class="stats"><div class="item"><div class="num" id="profile-clients">0</div><div class="lbl">客户</div></div><div class="item"><div class="num" id="profile-inq">0</div><div class="lbl">商机</div></div><div class="item"><div class="num" id="profile-bookings">0</div><div class="lbl">订舱</div></div><div class="item"><div class="num" id="profile-fl">0</div><div class="lbl">跟进</div></div></div></div>
<div class="settings-card"><h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6m11-7h-6m-6 0H1"/></svg>我的账号</h3><div class="settings-row"><div><div class="label">当前用户</div><div class="desc" id="acct-desc">-</div></div><button class="btn btn-secondary btn-sm" onclick="logout()">退出登录</button></div></div>
<div class="settings-card"><h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>数据备份</h3><div class="settings-row"><div><div class="label">导出全部数据 (JSON)</div><div class="desc">客户/商机/订舱/跟进记录+自定义字段选项</div></div><button class="btn btn-secondary btn-sm" onclick="exportBackup()">立即导出</button></div><div class="settings-row"><div><div class="label">从备份恢复</div><div class="desc">将覆盖现有数据,请先导出当前数据</div></div><label class="btn btn-secondary btn-sm" style="cursor:pointer">选择文件<input type="file" accept=".json" onchange="importBackup(event)" style="display:none"></label></div><div class="settings-row"><div><div class="label">清空全部数据</div><div class="desc">⚠️ 不可恢复,请确认已备份</div></div><button class="btn btn-danger btn-sm" onclick="clearAllData()">清空</button></div></div>
<div class="settings-card"><h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>桌面挂载</h3><div class="settings-row"><div><div class="label">添加到手机主屏幕</div><div class="desc">iPhone Safari·分享→添加到主屏幕 · Android Chrome·菜单→添加到主屏幕</div></div><span style="font-size:12px;color:var(--ink-mute)">已支持 PWA</span></div></div>
<div class="settings-card"><h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6m11-7h-6m-6 0H1"/></svg>显示偏好</h3><div class="settings-row"><div><div class="label">财务字段权限隔离</div><div class="desc">内部底价/约定账期 仅管理员可见(服务端按角色过滤)</div></div><div class="switch on" id="sw-finance" onclick="this.classList.toggle('on')"></div></div><div class="settings-row"><div><div class="label">默认销售姓名</div><div class="desc">录入客户/商机时自动带入</div></div><input type="text" id="ownerPref" value="SALES-Self" onchange="saveOwnerPref(this.value)" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px"></div></div>
<div class="settings-card"><h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>关于本工作台</h3><div class="settings-row"><div class="label">版本</div><div>v1.0.0 · 货代销售SFA</div></div><div class="settings-row"><div class="label">配套数据库</div><div>4 张 · 客户档案·商机询价·订舱执行·跟进记录</div></div><div class="settings-row"><div class="label">存储</div><div>服务端 SQLite · 团队共享账套</div></div></div>
</section>
</main>
</div>
<div class="drawer-mask" id="drawerMask" onclick="document.getElementById('sidebar').classList.remove('open');this.classList.remove('open')"></div>
<div class="modal-mask" id="modal-form"><div class="modal"><div class="modal-head"><h2 id="form-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>新建</h2><button class="modal-close" onclick="closeModal('modal-form')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="modal-body" id="form-body"></div><div class="modal-foot"><div class="left"><span style="font-size:11px;color:var(--ink-mute)">水印: <span id="wm-info">未保存</span></span></div><div class="right"><button class="btn btn-secondary" onclick="closeModal('modal-form')">取消</button><button class="btn btn-primary" id="form-submit">保存</button></div></div></div></div>
<div class="modal-mask" id="modal-detail"><div class="modal"><div class="modal-head"><h2 id="detail-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/></svg>详情</h2><button class="modal-close" onclick="closeModal('modal-detail')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="modal-body" id="detail-body"></div><div class="modal-foot"><div class="left"><button class="btn btn-danger btn-sm" id="detail-delete">删除</button><button class="btn btn-secondary btn-sm" id="detail-related">查看关联</button></div><div class="right"><button class="btn btn-secondary" onclick="closeModal('modal-detail')">关闭</button><button class="btn btn-primary" id="detail-edit">编辑</button></div></div></div></div>
<div class="option-editor" id="optionEditor"><div class="option-editor-head"><h3>字段选项管理</h3><button class="modal-close" onclick="document.getElementById('optionEditor').classList.remove('open')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="option-editor-body" id="optionEditorBody"></div><div class="option-editor-foot"><button class="btn btn-primary" style="width:100%" onclick="saveOptionEditor()">保存选项</button></div></div>
<div class="confirm-mask" id="confirmMask"><div class="confirm-box"><h3 id="cf-title">确认</h3><p id="cf-msg"></p><div class="confirm-actions"><button class="btn btn-secondary btn-sm" onclick="cfCancel()">取消</button><button class="btn btn-danger btn-sm" id="cf-ok">确认</button></div></div></div>
<div class="toast" id="toast"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg><span id="toastText">OK</span></div>
<script src="js/schemas.js"></script>
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script src="js/main.js"></script>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
/* ===== SFA · REST API =====
* 服务端返回的 record 结构: {id, kind, owner_id, data, created_at, updated_at}
* 本层负责: JWT 持久化 / 401 处理 / 业务字段(data)与前端扁平对象互转
*/
const API={
TOKEN_KEY:'wb_freight_sfa_token',
USER_KEY:'wb_freight_sfa_user',
_token:localStorage.getItem('wb_freight_sfa_token')||'',
_user:null,
get token(){return this._token},
get user(){return this._user},
get isAdmin(){return !!(this._user&&this._user.role==='admin')},
setToken(t){this._token=t;if(t)localStorage.setItem(this.TOKEN_KEY,t);else localStorage.removeItem(this.TOKEN_KEY)},
setUser(u){this._user=u;if(u)localStorage.setItem(this.USER_KEY,JSON.stringify(u));else localStorage.removeItem(this.USER_KEY)},
loadUser(){try{this._user=JSON.parse(localStorage.getItem(this.USER_KEY)||'null')}catch(e){this._user=null}return this._user},
clear(){this._token='';this._user=null;localStorage.removeItem(this.TOKEN_KEY);localStorage.removeItem(this.USER_KEY)},
/* 核心请求 */
async request(method,path,body){
const headers={'Content-Type':'application/json'};
if(this._token)headers['Authorization']='Bearer '+this._token;
let res;
try{
res=await fetch(path,{method,headers,body:body!==undefined?JSON.stringify(body):undefined});
}catch(e){
toast('无法连接服务端','danger');throw new Error('NETWORK');
}
if(res.status===401){
this.clear();
// 跳转登录页(由 main.js 注册)
if(window.__forceLogin)window.__forceLogin();
const err=new Error('登录已过期,请重新登录');err.status=401;throw err;
}
if(res.status===403){
const err=new Error('无权限执行此操作');err.status=403;throw err;
}
let data=null;
try{data=await res.json()}catch(e){data=null}
if(!res.ok){
const msg=(data&&data.error)||('请求失败('+res.status+')');
const err=new Error(msg);err.status=res.status;err.data=data;throw err;
}
return data;
},
/* ---- 认证 ---- */
async register(username,password,displayName){
const d=await this.request('POST','/api/auth/register',{username,password,display_name:displayName});
if(d.token){this.setToken(d.token);this.setUser(d);}
return d;
},
async login(username,password){
const d=await this.request('POST','/api/auth/login',{username,password});
if(d.token){this.setToken(d.token);this.setUser(d);}
return d;
},
async me(){
const d=await this.request('GET','/api/auth/me');
this.setUser(d);
return d;
},
/* ---- CRUD ----
* data: 前端扁平对象(含业务字段,不含 id/kind/owner_id/时间戳)
* 返回: 扁平对象 {id, ...业务字段, 系统创建时间?, ...}
*/
flatten(r){const o={id:r.id};if(r.data&&typeof r.data==='object')Object.assign(o,r.data);return o},
async list(kind){
const arr=await this.request('GET','/api/records/'+kind);
return arr.map(r=>this.flatten(r));
},
async create(kind,data){
const body={...data};delete body.id;
const r=await this.request('POST','/api/records/'+kind,body);
return this.flatten(r);
},
async update(kind,id,data){
const body={...data};delete body.id;
const r=await this.request('PUT','/api/records/'+kind+'/'+id,body);
return this.flatten(r);
},
async del(kind,id){return this.request('DELETE','/api/records/'+kind+'/'+id)},
/* ---- 选项 ---- */
async getOptions(){return this.request('GET','/api/options')},
async putOptions(opts){return this.request('PUT','/api/options',opts)},
/* ---- 看板 / 备份 / 审计 ---- */
async dashboard(){return this.request('GET','/api/dashboard')},
async backupExport(){return this.request('GET','/api/backup')},
async backupImport(payload){return this.request('POST','/api/backup',payload)},
async auditLogs(){return this.request('GET','/api/audit-logs')},
};
API.loadUser();
File diff suppressed because one or more lines are too long
+148
View File
@@ -0,0 +1,148 @@
/* ===== 货代 SFA 工作台 · 引导 / 登录 / 事件绑定 =====
* 数据交互经由 js/api.js REST 封装;
* 本文件负责: 登录/注册页装配应用引导侧栏/头部事件绑定401 全局跳转
*/
const OWNER_KEY='wb_freight_sfa_owner';
/* ---------- 登录页 ---------- */
function setHint(msg,type){
const h=document.getElementById('loginHint');
h.textContent=msg||'';h.className='login-hint '+(type||'');
}
function showLogin(msg){
document.getElementById('loginPage').style.display='flex';
document.getElementById('app').style.display='none';
if(msg)setHint(msg,msg==='info'?'info':'error');
}
function showApp(){
document.getElementById('loginPage').style.display='none';
document.getElementById('app').style.display='block';
}
function switchLoginTab(tab){
document.querySelectorAll('.login-tab').forEach(t=>t.classList.toggle('active',t.dataset.tab===tab));
document.getElementById('loginNameField').style.display=tab==='register'?'flex':'none';
document.getElementById('loginSubmit').textContent=tab==='register'?'注 册':'登 录';
setHint(tab==='register'?'首个注册账号自动成为管理员':'','');
}
async function submitLogin(){
const u=document.getElementById('loginUser').value.trim();
const p=document.getElementById('loginPass').value;
if(!u||!p){setHint('请输入用户名和密码','error');return}
const btn=document.getElementById('loginSubmit');
btn.classList.add('loading');btn.disabled=true;setHint('','info');
try{
await API.login(u,p);
boot();
}catch(e){setHint(e.message||'登录失败','error')}
finally{btn.classList.remove('loading');btn.disabled=false}
}
async function submitRegister(){
const n=document.getElementById('loginName').value.trim();
const u=document.getElementById('loginUser').value.trim();
const p=document.getElementById('loginPass').value;
if(!n||!u||!p){setHint('请填写完整信息','error');return}
if(p.length<6){setHint('密码至少 6 位','error');return}
const btn=document.getElementById('loginSubmit');
btn.classList.add('loading');btn.disabled=true;setHint('','info');
try{
// 注册接口返回用户信息(不含 token), 注册成功后自动登录
await API.register(u,p,n);
await API.login(u,p);
boot();
}catch(e){setHint(e.message||'注册失败','error')}
finally{btn.classList.remove('loading');btn.disabled=false}
}
/* ---------- 登出 / 偏好 ---------- */
function logout(){API.clear();showLogin('已退出登录');}
function saveOwnerPref(v){
state.owner=v||'SALES-Self';
localStorage.setItem(OWNER_KEY,state.owner);
toast('默认负责人已更新','success');
}
/* ---------- 事件绑定 ---------- */
function bindSidebar(){
document.querySelectorAll('.side-item[data-route]').forEach(it=>{
it.onclick=()=>{const r=it.dataset.route;showPage(r);setActive(r);closeSidebar()};
});
}
function bindHeader(){
const menuToggle=document.getElementById('menuToggle');
const drawerMask=document.getElementById('drawerMask');
const btnQuickAdd=document.getElementById('btnQuickAdd');
const btnRefresh=document.getElementById('btnRefresh');
const globalSearch=document.getElementById('globalSearch');
if(menuToggle)menuToggle.onclick=()=>{
document.getElementById('sidebar').classList.toggle('open');
if(drawerMask)drawerMask.classList.toggle('open');
};
if(btnQuickAdd)btnQuickAdd.onclick=()=>openForm('client');
if(btnRefresh)btnRefresh.onclick=()=>{loadAll();toast('已刷新','success')};
if(globalSearch){
let t;
globalSearch.oninput=()=>{
clearTimeout(t);
t=setTimeout(()=>{state.filter.search=globalSearch.value.trim().toLowerCase();renderAll()},250);
};
}
}
function bindModals(){
['modal-form','modal-detail'].forEach(id=>{
const el=document.getElementById(id);
if(el)el.addEventListener('click',e=>{if(e.target.id===id)closeModal(id)});
});
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeModal();closeSidebar()}});
}
function closeSidebar(){
const s=document.getElementById('sidebar');if(s)s.classList.remove('open');
const m=document.getElementById('drawerMask');if(m)m.classList.remove('open');
}
/* ---------- 引导 ---------- */
function injectPWA(){
if(document.querySelector('link[rel="manifest"]'))return;
const l=document.createElement('link');l.rel='manifest';l.href='manifest.webmanifest';
document.head.appendChild(l);
}
async function boot(){
showApp();
const u=API.user||API.loadUser();
if(u&&u.display_name){
const ch=u.display_name.charAt(0).toUpperCase();
document.getElementById('userAvatar').textContent=ch;
const av=document.querySelector('.profile-card .avatar');if(av)av.textContent=ch;
}
const pref=document.getElementById('ownerPref');if(pref)pref.value=state.owner;
bindSidebar();bindHeader();bindModals();
injectPWA();
await loadAll();
showPage('dashboard');
setInterval(updateBadges,60000);
}
/* ---------- 401 全局钩子(由 api.js 调用) ---------- */
window.__forceLogin=function(msg){API.clear();showLogin(msg||'登录已过期,请重新登录');};
/* ---------- 启动 ---------- */
async function init(){
// 登录页事件
document.querySelectorAll('.login-tab').forEach(t=>{t.onclick=()=>switchLoginTab(t.dataset.tab)});
const btn=document.getElementById('loginSubmit');
btn.onclick=()=>{
const active=document.querySelector('.login-tab.active');
(active&&active.dataset.tab==='register')?submitRegister():submitLogin();
};
['loginUser','loginPass','loginName'].forEach(id=>{
document.getElementById(id).addEventListener('keydown',e=>{if(e.key==='Enter')btn.click()});
});
if(!API.token){showLogin();return}
try{
await API.me();
boot();
}catch(e){
if(e&&e.status===401)showLogin('登录已过期,请重新登录');
else showLogin(e.message||'无法连接服务端');
}
}
document.addEventListener('DOMContentLoaded',init);
+128
View File
@@ -0,0 +1,128 @@
/* ===== 货代 SFA 工作台 · 数据模型与工具 ===== */
const COLORS={primary:'#6B5385',deep:'#54416A',warm:'#E8D8C0',cream:'#F5E9D7',sage:'#D4DCCB',rose:'#E6CFCB',blue:'#C9D6E2',danger:'#C04848',warning:'#D4944A',success:'#5C9175'};
const SCHEMAS={
client:{title:'客户档案',
groups:[
{title:'通用基础',fields:[
{k:'客户名称',type:'text',required:true},{k:'公司抬头',type:'text'},
{k:'客户类型',type:'select',optionsKey:'clientType'},{k:'ABC分级',type:'select',optionsKey:'grade'},
{k:'跟进状态',type:'select',optionsKey:'flowStatus'},{k:'来源渠道',type:'mselect',optionsKey:'source'},
{k:'客户风险标签',type:'mselect',optionsKey:'risk'},{k:'自定义标签',type:'mselect',optionsKey:'customTags'},
]},
{title:'同行货代专属',condKey:'客户类型',condValue:'同行货代',fields:[
{k:'主营航线',type:'text'},{k:'合作船公司',type:'text'},{k:'月柜量',type:'text'},
{k:'是否庄家',type:'select',optionsKey:'yesno'},{k:'合作账期',type:'text'},
]},
{title:'国内直客专属',condKey:'客户类型',condValue:'国内直客(工厂/外贸)',fields:[
{k:'主营航线',type:'text'},{k:'常用起运港',type:'text'},{k:'主营出口产品',type:'text'},
{k:'月出货柜量',type:'text'},{k:'报关/拖车需求',type:'text'},
]},
{title:'国外收货人专属',condKey:'客户类型',condValue:'国外收货人',fields:[
{k:'常用目的港',type:'text'},{k:'是否指定货',type:'select',optionsKey:'yesno'},
{k:'海外收货地址',type:'textarea'},{k:'常用贸易条款',type:'select',optionsKey:'incoterm'},
]},
{title:'国外海外代理专属',condKey:'客户类型',condValue:'国外海外代理',fields:[
{k:'常用目的港',type:'text'},{k:'合作模式',type:'text'},{k:'目的港服务优势',type:'textarea'},{k:'代理结算账期',type:'text'},
]},
{title:'供应商专属',condKey:'客户类型',condValue:'供应商',fields:[
{k:'合作模式',type:'text'},{k:'供应商服务范围',type:'textarea'},{k:'代理结算账期',type:'text'},
]},
{title:'默认供应商',fields:[
{k:'起运港庄家(默认)',type:'mselect',optionsKey:'peijiaList'},
{k:'目的港DDP(默认)',type:'mselect',optionsKey:'ddpList'},
]},
{title:'联系人',fields:[{k:'联系人列表',type:'contacts'}]},
{title:'内部财务保密(仅负责人可见)',permission:true,fields:[
{k:'内部底价备注',type:'textarea'},{k:'约定账期天数',type:'number'},
{k:'累计合作货量',type:'number'},{k:'业务禁忌备注',type:'textarea'},
]},
]},
inquiry:{title:'商机询价',groups:[
{title:'基础询价',fields:[
{k:'商机名称',type:'text',required:true},{k:'关联客户',type:'clientPicker',required:true},
{k:'船期',type:'date'},{k:'运输方式',type:'select',optionsKey:'transport'},
{k:'柜型',type:'select',optionsKey:'containerType'},{k:'柜量',type:'number'},
{k:'起运港',type:'text'},{k:'目的港',type:'text'},{k:'目标船公司',type:'text'},
]},
{title:'意向供应商',fields:[
{k:'起运港庄家(意向)',type:'mselect',optionsKey:'peijiaList'},
{k:'目的港DDP(意向)',type:'mselect',optionsKey:'ddpList'},
]},
{title:'业务&财务',fields:[
{k:'业务流程状态',type:'select',optionsKey:'inquiryStatus'},
{k:'丢单原因',type:'select',optionsKey:'lossReason',showIf:'业务流程状态:丢单'},
{k:'多版本报价记录',type:'textarea'},{k:'业务注意事项',type:'textarea'},
]},
{title:'附件',fields:[{k:'附件',type:'text',placeholder:'文件名逗号分隔(预留)'}]},
]},
booking:{title:'订舱执行',groups:[
{title:'基础业务',fields:[
{k:'订舱单号',type:'text',required:true},{k:'关联客户',type:'clientPicker',required:true},
{k:'关联商机',type:'inquiryPicker'},{k:'MBL主单号',type:'text'},{k:'HBL分单号',type:'text'},
{k:'起运港',type:'text'},{k:'目的港',type:'text'},
{k:'柜型',type:'select',optionsKey:'containerType'},{k:'柜量',type:'number'},
]},
{title:'本票实际执行供应商(优先级最高)',fields:[
{k:'起运港庄家(本票)',type:'mselect',optionsKey:'peijiaList'},
{k:'目的港DDP(本票)',type:'mselect',optionsKey:'ddpList'},
]},
{title:'核心时间节点',fields:[
{k:'SI截关时间',type:'datetime'},{k:'ETD开船时间',type:'date'},{k:'ETA预计到港时间',type:'date'},
]},
{title:'业务&财务',fields:[
{k:'订舱跟进状态',type:'select',optionsKey:'bookingStatus'},
{k:'应收运费金额',type:'currency'},{k:'结算币种',type:'select',optionsKey:'currency'},
{k:'回款状态',type:'select',optionsKey:'paymentStatus'},{k:'逾期提醒',type:'checkbox'},
{k:'业务标注',type:'textarea'},
]},
]},
followup:{title:'跟进记录',groups:[
{title:'关联对象',fields:[
{k:'跟进主题',type:'text',required:true},{k:'关联客户',type:'clientPicker',required:true},
{k:'关联商机',type:'inquiryPicker'},{k:'关联订舱',type:'bookingPicker'},
]},
{title:'跟进内容',fields:[
{k:'跟进方式',type:'select',optionsKey:'followupMethod'},{k:'本次沟通内容',type:'textarea',required:true},
{k:'跟进结果',type:'select',optionsKey:'followupResult'},{k:'下次跟进时间',type:'datetime'},{k:'业务备注',type:'textarea'},
]},
]}
};
const DEFAULT_OPTIONS={
clientType:['同行货代','国内直客(工厂/外贸)','国外收货人','国外海外代理','供应商'],
grade:['A级核心','B级重要','C级普通'],
flowStatus:['待联系','初次沟通','询价中','报价跟进','谈判中','已合作','维护期','流失'],
source:['陌拜','展会','网站询盘','老客介绍','LinkedIn','微信群','阿里国际站','Made-in-China','其他'],
risk:['账期超长','付款拖延','价格敏感','纠纷历史','资金紧张','船期变动频繁'],
customTags:['VIP','战略合作'],
yesno:['是','否'],
incoterm:['FOB','CIF','CFR','DDP','DDU','EXW','DAP'],
transport:['直达','中转'],
containerType:['20GP','40GP','40HQ','45HQ','20RF','40RF','特种柜','散货'],
inquiryStatus:['询价中','已报价','确认订舱','实单出货','丢单'],
lossReason:['价格偏高','船期不合适','竞争对手胜出','客户暂缓','目的港服务不佳','客户选择直签'],
bookingStatus:['待订舱','已订舱','已截关','已开船','已到港','已清关','已派送','已完成','异常'],
currency:['CNY','USD','EUR','HKD'],
paymentStatus:['待回款','已开票','部分回款','已回款','逾期'],
followupMethod:['电话','微信','邮件','面谈','视频会议','其他'],
followupResult:['继续跟进','待报价','待确认订舱','已完成','客户暂缓','丢单'],
peijiaList:['+1庄家','待补充'],
ddpList:['+1DDP','待补充'],
};
/* ===== 工具函数 ===== */
function uid(p){return p+'_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6)}
function esc(s){if(s==null)return'';return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function todayStr(d=new Date()){const z=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+z(d.getMonth()+1)+'-'+z(d.getDate())}
function nowStr(d=new Date()){const z=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+z(d.getMonth()+1)+'-'+z(d.getDate())+' '+z(d.getHours())+':'+z(d.getMinutes())}
function fmtDate(s){if(!s)return'-';return s.replace('T',' ').slice(0,16)}
function fmtMoney(n){if(n==null||n==='')return'¥0';return'¥'+Number(n).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}
function toast(msg,type='info'){const t=document.getElementById('toast');t.className='toast '+type;document.getElementById('toastText').textContent=msg;t.classList.add('show');clearTimeout(window._toastT);window._toastT=setTimeout(()=>t.classList.remove('show'),2200)}
function confirmDialog(title,msg,onOk){document.getElementById('cf-title').textContent=title;document.getElementById('cf-msg').textContent=msg;document.getElementById('confirmMask').classList.add('show');document.getElementById('cf-ok').onclick=()=>{cfCancel();try{onOk&&onOk()}catch(e){console.error(e)}}}
function cfCancel(){document.getElementById('confirmMask').classList.remove('show')}
function setSyncState(s){const el=document.getElementById('syncLabel');if(el)el.textContent=s}
function closeModal(id){document.getElementById(id).classList.remove('active')}
function setActive(route){document.querySelectorAll('.side-item').forEach(x=>{if(x.dataset.route===route)x.classList.add('active');else x.classList.remove('active')})}
@@ -0,0 +1,17 @@
{
"name": "客户管理工作台(货代版)",
"short_name": "货代SFA",
"description": "货代销售SFA工作台:客户/商机/订舱/跟进全流程管理",
"start_url": "./",
"display": "standalone",
"background_color": "#F5F1EA",
"theme_color": "#6B5385",
"icons": [
{
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%236B5385'/%3E%3Ctext x='50%25' y='52%25' text-anchor='middle' fill='%23F5E9D7' font-size='38' font-family='sans-serif' dominant-baseline='middle' font-weight='700'%3ES%3C/text%3E%3C/svg%3E",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+349
View File
@@ -0,0 +1,349 @@
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:14px;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",Roboto,sans-serif;color:var(--ink);background:var(--bg);line-height:1.5;overflow-x:hidden;-webkit-font-smoothing:antialiased}
button,input,select,textarea{font-family:inherit;font-size:inherit;color:inherit;outline:none}
button{cursor:pointer;border:none;background:none}
input,textarea,select{-webkit-appearance:none;appearance:none}
a{color:inherit;text-decoration:none}
img,svg{display:block;max-width:100%}
ul,ol{list-style:none}
:root{
--primary:#6B5385;--primary-deep:#54416A;--primary-light:#A89BC9;--primary-soft:#E8E1EE;
--accent-warm:#E8D8C0;--accent-cream:#F5E9D7;--accent-rose:#E6CFCB;--accent-blue:#C9D6E2;--accent-sage:#D4DCCB;
--bg:#FBF8F3;--bg-soft:#F4EEE6;--card:#FFFFFF;
--ink:#2D2640;--ink-soft:#6D6584;--ink-mute:#A096AE;
--border:#E8E1EE;--border-soft:#F0EAF5;
--danger:#C04848;--danger-soft:#F8E4E4;
--success:#5C9175;--success-soft:#E4F0E8;
--warning:#D4944A;--warning-soft:#FAEBD8;
--info:#5C7A91;--info-soft:#E1E9F0;
--shadow-sm:0 1px 2px rgba(45,38,64,.04),0 1px 4px rgba(45,38,64,.04);
--shadow:0 4px 12px rgba(45,38,64,.06),0 2px 4px rgba(45,38,64,.04);
--shadow-lg:0 16px 40px rgba(45,38,64,.10),0 4px 12px rgba(45,38,64,.05);
--radius:14px;--radius-sm:10px;--radius-xs:6px;
--sidebar-w:248px;--header-h:64px;
--safe-bottom:env(safe-area-inset-bottom,0px);
}
.app{display:grid;grid-template-columns:var(--sidebar-w) 1fr;grid-template-rows:var(--header-h) 1fr;grid-template-areas:"sidebar header" "sidebar main";min-height:100vh;min-height:100dvh}
.header{grid-area:header;background:linear-gradient(135deg,var(--primary) 0%,var(--primary-deep) 100%);color:#F5E9D7;display:flex;align-items:center;padding:0 24px;gap:16px;box-shadow:0 2px 12px rgba(75,57,103,.18);position:sticky;top:0;z-index:30}
.header-logo{display:flex;align-items:center;gap:12px;min-width:0}
.header-logo svg{width:38px;height:38px;flex-shrink:0}
.header-title{font-size:17px;font-weight:700;letter-spacing:.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.header-title small{font-size:11px;font-weight:400;opacity:.7;margin-left:6px}
.header-search{flex:1;max-width:380px;position:relative}
.header-search input{width:100%;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.18);border-radius:24px;padding:9px 16px 9px 40px;color:#fff;font-size:13px;transition:all .2s}
.header-search input::placeholder{color:rgba(255,255,255,.55)}
.header-search input:focus{background:rgba(255,255,255,.22);border-color:rgba(255,255,255,.4)}
.header-search svg{position:absolute;left:14px;top:50%;transform:translateY(-50%);opacity:.7}
.header-spacer{flex:1}
.header-actions{display:flex;align-items:center;gap:8px}
.sync-indicator{display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.14);padding:7px 14px;border-radius:20px;font-size:12px;border:1px solid rgba(255,255,255,.18)}
.sync-dot{width:8px;height:8px;border-radius:50%;background:#5C9175;box-shadow:0 0 0 3px rgba(92,145,117,.3);animation:pulse 2s infinite}
@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.2)}}
.icon-btn{width:38px;height:38px;border-radius:10px;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;color:#fff;transition:background .15s}
.icon-btn:hover{background:rgba(255,255,255,.22)}
.icon-btn svg{width:18px;height:18px}
.user-avatar{width:38px;height:38px;border-radius:50%;background:linear-gradient(135deg,#F5E9D7,#E8D8C0);color:var(--primary-deep);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;border:2px solid rgba(255,255,255,.3)}
.menu-toggle{display:none;width:38px;height:38px;border-radius:10px;background:rgba(255,255,255,.12);align-items:center;justify-content:center;color:#fff}
.menu-toggle svg{width:20px;height:20px}
.sidebar{grid-area:sidebar;background:#fff;border-right:1px solid var(--border-soft);overflow-y:auto;padding:18px 12px 100px;position:sticky;top:0;height:100vh;z-index:25}
.sidebar::-webkit-scrollbar{width:6px}
.sidebar::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.side-section{margin-bottom:18px}
.side-section-title{font-size:10px;font-weight:700;letter-spacing:1.2px;color:var(--ink-mute);text-transform:uppercase;padding:0 14px 8px}
.side-item{display:flex;align-items:center;gap:12px;padding:9px 14px;border-radius:var(--radius-sm);color:var(--ink-soft);font-size:13px;font-weight:500;cursor:pointer;margin-bottom:2px;transition:all .15s;position:relative}
.side-item:hover{background:var(--primary-soft);color:var(--primary)}
.side-item.active{background:linear-gradient(135deg,var(--primary) 0%,var(--primary-deep) 100%);color:#fff;box-shadow:0 4px 10px rgba(107,83,133,.25)}
.side-item.active::before{content:'';position:absolute;left:-12px;top:50%;transform:translateY(-50%);width:3px;height:18px;background:var(--primary-deep);border-radius:0 2px 2px 0}
.side-item .ic{width:18px;height:18px;flex-shrink:0}
.side-item .badge{margin-left:auto;background:var(--accent-cream);color:var(--primary-deep);padding:1px 7px;border-radius:10px;font-size:10px;font-weight:700}
.side-item.active .badge{background:rgba(255,255,255,.25);color:#fff}
.side-item.alert-badge .badge{background:var(--danger);color:#fff}
.side-divider{height:1px;background:var(--border-soft);margin:14px 12px}
.side-footer-card{margin:16px 12px 0;padding:14px;background:linear-gradient(135deg,var(--accent-cream) 0%,#EDDFCC 100%);border-radius:12px;font-size:12px;color:var(--ink-soft)}
.side-footer-card h4{font-size:13px;font-weight:700;color:var(--primary-deep);margin-bottom:4px}
.side-footer-card .count{font-size:20px;font-weight:700;color:var(--primary-deep);margin-top:6px}
.main{grid-area:main;padding:20px 24px 32px;overflow-x:hidden;max-width:100%}
.page{display:none}
.page.active{display:block;animation:fadeIn .25s ease}
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.page-head{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.page-head h1{font-size:22px;font-weight:700;color:var(--ink);margin-bottom:4px;display:flex;align-items:center;gap:10px}
.page-head h1 svg{width:24px;height:24px;color:var(--primary)}
.page-head p{color:var(--ink-soft);font-size:13px}
.page-actions{display:flex;gap:8px;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 16px;border-radius:var(--radius-sm);font-size:13px;font-weight:600;transition:all .15s;border:1px solid transparent;min-height:36px}
.btn svg{width:14px;height:14px}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-deep);box-shadow:0 4px 12px rgba(107,83,133,.25)}
.btn-secondary{background:#fff;border-color:var(--border);color:var(--ink-soft)}
.btn-secondary:hover{border-color:var(--primary);color:var(--primary)}
.btn-danger{background:var(--danger-soft);color:var(--danger)}
.btn-danger:hover{background:#F5CECE}
.btn-ghost{background:transparent;color:var(--ink-soft)}
.btn-ghost:hover{background:var(--primary-soft);color:var(--primary)}
.btn-sm{padding:5px 12px;font-size:12px;min-height:30px}
.btn-icon{width:36px;height:36px;padding:0;border-radius:var(--radius-sm);background:var(--bg-soft);color:var(--ink-soft);min-height:auto}
.btn-icon:hover{background:var(--primary-soft);color:var(--primary)}
.today-bar{background:linear-gradient(135deg,var(--accent-cream) 0%,#EDDFCC 100%);border-radius:var(--radius);padding:18px 22px;margin-bottom:20px;border:1px solid rgba(212,148,74,.15);display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.today-bar .label{font-size:13px;font-weight:600;color:var(--primary-deep);display:flex;align-items:center;gap:6px}
.today-bar .label svg{width:16px;height:16px}
.today-tasks{display:flex;gap:10px;flex-wrap:wrap;flex:1}
.today-task{background:#fff;border-radius:20px;padding:6px 14px;font-size:12px;font-weight:500;color:var(--ink-soft);box-shadow:var(--shadow-sm);display:flex;align-items:center;gap:8px;max-width:280px}
.today-task.alert{background:var(--danger-soft);color:var(--danger);font-weight:600}
.today-task.alert::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--danger)}
.today-task button{margin-left:auto;color:var(--primary);font-size:11px;font-weight:700}
.today-task button:hover{color:var(--primary-deep)}
.today-bar .quick-stats{display:flex;gap:18px;font-size:12px;color:var(--ink-soft)}
.today-bar .quick-stats b{font-size:18px;color:var(--primary-deep);font-weight:700;display:block;margin-bottom:2px}
.grid{display:grid;gap:16px}
.grid-2{grid-template-columns:repeat(2,1fr)}
.grid-3{grid-template-columns:repeat(3,1fr)}
.grid-4{grid-template-columns:repeat(4,1fr)}
.card{background:var(--card);border-radius:var(--radius);padding:18px;box-shadow:var(--shadow-sm);border:1px solid var(--border-soft);transition:all .15s}
.card:hover{box-shadow:var(--shadow);border-color:var(--border)}
.card-title{font-size:13px;font-weight:600;color:var(--ink-soft);margin-bottom:8px;display:flex;align-items:center;gap:6px}
.card-title svg{width:14px;height:14px;color:var(--primary)}
.stat-card{position:relative;overflow:hidden}
.stat-card .num{font-size:32px;font-weight:700;color:var(--primary-deep);letter-spacing:-1px;line-height:1.1;margin:6px 0 4px}
.stat-card .delta{font-size:11px;color:var(--success);display:flex;align-items:center;gap:4px}
.stat-card .delta.down{color:var(--danger)}
.kpi-icon{width:38px;height:38px;border-radius:10px;background:var(--primary-soft);color:var(--primary);display:flex;align-items:center;justify-content:center;margin-bottom:8px}
.kpi-icon svg{width:18px;height:18px}
.kpi-icon.cream{background:var(--accent-cream);color:#9C7A45}
.kpi-icon.danger{background:var(--danger-soft);color:var(--danger)}
.kpi-icon.success{background:var(--success-soft);color:var(--success)}
.kpi-icon.warning{background:var(--warning-soft);color:var(--warning)}
.kpi-icon.info{background:var(--info-soft);color:var(--info)}
.filter-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;background:#fff;padding:12px 16px;border-radius:var(--radius);box-shadow:var(--shadow-sm);margin-bottom:16px;border:1px solid var(--border-soft)}
.filter-bar input[type=text],.filter-bar input[type=search],.filter-bar select{padding:7px 12px;border-radius:var(--radius-sm);border:1px solid var(--border);background:#fff;font-size:13px;min-width:140px}
.filter-bar input:focus,.filter-bar select:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(107,83,133,.1)}
.filter-bar label{font-size:12px;color:var(--ink-soft);font-weight:600;margin-right:4px}
.filter-spacer{flex:1}
.table-wrap{background:#fff;border-radius:var(--radius);box-shadow:var(--shadow-sm);overflow:hidden;border:1px solid var(--border-soft)}
.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
table.data{width:100%;border-collapse:collapse;font-size:13px;min-width:780px}
table.data thead th{background:var(--bg-soft);color:var(--ink-soft);font-weight:600;padding:11px 12px;text-align:left;border-bottom:1px solid var(--border-soft);font-size:12px;white-space:nowrap;position:sticky;top:0}
table.data tbody tr{border-bottom:1px solid var(--border-soft);transition:background .1s}
table.data tbody tr:hover{background:var(--primary-soft)}
table.data tbody tr:last-child{border-bottom:none}
table.data td{padding:12px;vertical-align:middle}
.tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;margin:2px 2px 2px 0;background:var(--primary-soft);color:var(--primary-deep)}
.tag.warn{background:var(--warning-soft);color:#8B5E1B}
.tag.success{background:var(--success-soft);color:var(--success)}
.tag.danger{background:var(--danger-soft);color:var(--danger)}
.tag.info{background:var(--info-soft);color:var(--info)}
.tag.muted{background:var(--bg-soft);color:var(--ink-mute)}
.row-action{color:var(--ink-mute);padding:4px;border-radius:6px;background:transparent;transition:all .15s}
.row-action:hover{background:var(--primary-soft);color:var(--primary)}
.row-action.danger:hover{background:var(--danger-soft);color:var(--danger)}
.row-actions{display:flex;gap:4px}
.cell-primary{font-weight:600;color:var(--ink)}
.cell-meta{font-size:11px;color:var(--ink-mute);margin-top:2px}
.cell-tags{margin-top:4px}
.kanban{display:grid;gap:14px;overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:8px}
.kanban.cols-5{grid-template-columns:repeat(5,minmax(240px,1fr))}
.kanban.cols-3{grid-template-columns:repeat(3,minmax(240px,1fr))}
.kanban-col{background:#fff;border-radius:var(--radius);padding:14px;border:1px solid var(--border-soft);box-shadow:var(--shadow-sm);min-height:200px;display:flex;flex-direction:column}
.kanban-col-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:10px;border-bottom:2px solid var(--primary-soft)}
.kanban-col-head .title{font-size:13px;font-weight:700;color:var(--ink);display:flex;align-items:center;gap:8px}
.kanban-col-head .dot{width:8px;height:8px;border-radius:50%}
.kanban-col-head .count{font-size:11px;font-weight:600;color:var(--ink-soft);background:var(--bg-soft);padding:2px 8px;border-radius:10px}
.kanban-card{background:#fff;border-radius:var(--radius-sm);padding:11px 12px;border:1px solid var(--border-soft);margin-bottom:8px;cursor:grab;transition:all .15s}
.kanban-card:hover{box-shadow:var(--shadow);border-color:var(--primary-light);transform:translateY(-1px)}
.kanban-card.dragging{opacity:.5;cursor:grabbing}
.kanban-card .name{font-size:13px;font-weight:600;color:var(--ink);margin-bottom:4px}
.kanban-card .meta{font-size:11px;color:var(--ink-mute);margin-bottom:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.kanban-card .meta .amount{color:var(--primary-deep);font-weight:700}
.kanban-card.alert{border-color:#F5CECE;background:var(--danger-soft)}
.kanban-card.alert .name::before{content:'⚠ ';color:var(--danger)}
.modal-mask{position:fixed;inset:0;background:rgba(45,38,64,.5);backdrop-filter:blur(4px);z-index:100;display:none;align-items:center;justify-content:center;padding:16px;animation:fadeIn .2s}
.modal-mask.active{display:flex}
.modal{background:#fff;border-radius:var(--radius);max-width:760px;width:100%;max-height:90vh;max-height:90dvh;display:flex;flex-direction:column;box-shadow:var(--shadow-lg);animation:slideUp .25s ease}
@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:none;opacity:1}}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--border-soft)}
.modal-head h2{font-size:17px;font-weight:700;color:var(--ink);display:flex;align-items:center;gap:8px}
.modal-head h2 svg{width:20px;height:20px;color:var(--primary)}
.modal-close{width:32px;height:32px;border-radius:8px;background:var(--bg-soft);color:var(--ink-soft);display:flex;align-items:center;justify-content:center;transition:all .15s}
.modal-close:hover{background:var(--danger-soft);color:var(--danger)}
.modal-close svg{width:16px;height:16px}
.modal-body{flex:1;overflow-y:auto;padding:18px 22px}
.modal-foot{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:14px 22px;border-top:1px solid var(--border-soft);background:var(--bg-soft);border-radius:0 0 var(--radius) var(--radius)}
.modal-foot .left{display:flex;gap:8px}
.modal-foot .right{display:flex;gap:8px}
.form-section{margin-bottom:18px}
.form-section-title{font-size:11px;font-weight:700;letter-spacing:.5px;color:var(--primary);text-transform:uppercase;margin-bottom:10px;padding-bottom:5px;border-bottom:1px dashed var(--border);display:flex;align-items:center;gap:6px}
.form-grid{display:grid;gap:12px}
.form-grid.cols-2{grid-template-columns:1fr 1fr}
.form-grid.cols-3{grid-template-columns:repeat(3,1fr)}
.form-field{display:flex;flex-direction:column;gap:4px}
.form-field label{font-size:12px;font-weight:600;color:var(--ink-soft)}
.form-field label .required{color:var(--danger);margin-left:2px}
.form-field input,.form-field textarea,.form-field select{padding:8px 11px;border:1px solid var(--border);border-radius:var(--radius-xs);background:#fff;font-size:14px;transition:all .15s;min-height:36px}
.form-field input:focus,.form-field textarea:focus,.form-field select:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(107,83,133,.1)}
.form-field textarea{resize:vertical;min-height:72px;line-height:1.5}
.form-field.full{grid-column:1/-1}
.form-field.permissions{background:var(--warning-soft);padding:10px 12px;border-radius:var(--radius-xs);border-left:3px solid var(--warning);margin-top:6px}
.form-field.permissions label{color:#8B5E1B}
.chip-input{display:flex;flex-wrap:wrap;gap:6px;padding:6px 8px;border:1px solid var(--border);border-radius:var(--radius-xs);background:#fff;min-height:36px;cursor:text}
.chip-input:empty::before{content:attr(data-placeholder);color:var(--ink-mute);font-size:13px;padding:6px 4px}
.chip-input:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(107,83,133,.1)}
.chip-input input{flex:1;min-width:100px;border:none;padding:4px;font-size:13px;background:transparent}
.chip{display:inline-flex;align-items:center;gap:5px;background:var(--primary-soft);color:var(--primary-deep);padding:3px 8px;border-radius:10px;font-size:12px;font-weight:600}
.chip.warn{background:var(--warning-soft);color:#8B5E1B}
.chip.danger{background:var(--danger-soft);color:var(--danger)}
.chip.success{background:var(--success-soft);color:var(--success)}
.chip .x{cursor:pointer;opacity:.6;font-weight:400;width:14px;height:14px;display:flex;align-items:center;justify-content:center;border-radius:50%}
.chip .x:hover{background:rgba(0,0,0,.1);opacity:1}
.chip.addui{background:#fff;border:1px dashed var(--primary);color:var(--primary);cursor:pointer}
.chip.addui:hover{background:var(--primary-soft)}
.contact-row{display:grid;grid-template-columns:1fr 1fr 1.4fr 1.2fr auto;gap:8px;align-items:start;margin-bottom:8px}
.contact-row input,.contact-row select{padding:7px 9px;border:1px solid var(--border);border-radius:var(--radius-xs);background:#fff;font-size:13px}
.option-editor{position:fixed;right:0;top:0;bottom:0;width:360px;max-width:90vw;background:#fff;box-shadow:-8px 0 24px rgba(0,0,0,.12);z-index:110;display:none;flex-direction:column;animation:slideLeft .25s ease}
@keyframes slideLeft{from{transform:translateX(100%)}to{transform:none}}
.option-editor.open{display:flex}
.option-editor-head{padding:18px 20px;border-bottom:1px solid var(--border-soft);display:flex;justify-content:space-between;align-items:center}
.option-editor-head h3{font-size:15px;font-weight:700;color:var(--ink)}
.option-editor-body{flex:1;overflow-y:auto;padding:18px 20px}
.option-editor-foot{padding:14px 20px;border-top:1px solid var(--border-soft);background:var(--bg-soft)}
.opt-group{margin-bottom:18px}
.opt-group h4{font-size:12px;font-weight:700;color:var(--primary);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}
.opt-list{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.opt-list .chip{font-size:12px}
.opt-add{display:flex;gap:6px}
.opt-add input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:var(--radius-xs);font-size:13px}
.detail-section{margin-bottom:16px}
.detail-section h4{font-size:12px;font-weight:700;color:var(--primary);margin-bottom:8px;display:flex;align-items:center;gap:6px;letter-spacing:.4px;text-transform:uppercase}
.detail-section h4 svg{width:14px;height:14px}
.detail-row{display:grid;grid-template-columns:140px 1fr;gap:12px;padding:6px 0;font-size:13px;border-bottom:1px dashed var(--border-soft)}
.detail-row .k{color:var(--ink-mute);font-weight:500}
.detail-row .v{color:var(--ink)}
.watermark-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;padding:10px 12px;background:var(--bg-soft);border-radius:var(--radius-xs);font-size:11px}
.watermark-row .wm-item .label{color:var(--ink-mute);font-weight:600;margin-bottom:2px}
.watermark-row .wm-item .val{color:var(--ink);font-weight:600}
.watermark-row .wm-item.sys .val{color:var(--primary)}
.toast{position:fixed;bottom:32px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--ink);color:#fff;padding:10px 18px;border-radius:24px;font-size:13px;box-shadow:var(--shadow-lg);z-index:200;opacity:0;transition:all .25s;display:flex;align-items:center;gap:8px;pointer-events:none}
.toast.show{transform:translateX(-50%) translateY(0);opacity:1}
.toast.success{background:var(--success)}
.toast.danger{background:var(--danger)}
.toast.warning{background:var(--warning)}
.confirm-mask{position:fixed;inset:0;background:rgba(45,38,64,.55);backdrop-filter:blur(4px);z-index:150;display:none;align-items:center;justify-content:center;padding:16px}
.confirm-mask.show{display:flex;animation:fadeIn .2s}
.confirm-box{background:#fff;border-radius:var(--radius);padding:22px;max-width:380px;width:100%;box-shadow:var(--shadow-lg);animation:slideUp .25s ease}
.confirm-box h3{font-size:16px;font-weight:700;margin-bottom:10px;color:var(--ink)}
.confirm-box p{font-size:13px;color:var(--ink-soft);line-height:1.6;margin-bottom:18px}
.confirm-actions{display:flex;justify-content:flex-end;gap:8px}
.empty{padding:36px 18px;text-align:center;color:var(--ink-mute);font-size:13px}
.empty svg{width:48px;height:48px;margin:0 auto 10px;opacity:.4;display:block}
.empty h4{font-size:14px;color:var(--ink-soft);margin-bottom:4px;font-weight:600}
.profile-card{background:linear-gradient(135deg,var(--primary) 0%,var(--primary-deep) 100%);color:#fff;border-radius:var(--radius);padding:24px;margin-bottom:18px;display:flex;align-items:center;gap:18px}
.profile-card .avatar{width:64px;height:64px;border-radius:50%;background:linear-gradient(135deg,#F5E9D7,#E8D8C0);color:var(--primary-deep);display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;border:3px solid rgba(255,255,255,.3)}
.profile-card .info h3{font-size:18px;font-weight:700;margin-bottom:4px}
.profile-card .info p{font-size:12px;opacity:.85}
.profile-card .stats{display:flex;gap:18px;margin-left:auto}
.profile-card .stats .item{text-align:center}
.profile-card .stats .item .num{font-size:22px;font-weight:700;line-height:1.1}
.profile-card .stats .item .lbl{font-size:10px;opacity:.8;letter-spacing:.4px}
.settings-card{background:#fff;border-radius:var(--radius);padding:18px;margin-bottom:14px;border:1px solid var(--border-soft)}
.settings-card h3{font-size:14px;font-weight:700;color:var(--ink);margin-bottom:12px;display:flex;align-items:center;gap:8px}
.settings-card h3 svg{width:16px;height:16px;color:var(--primary)}
.settings-row{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px dashed var(--border-soft);font-size:13px;gap:14px}
.settings-row:last-child{border-bottom:none}
.settings-row>div:first-child{flex:1;min-width:0}
.settings-row .label{color:var(--ink-soft);font-weight:500}
.settings-row .desc{font-size:11px;color:var(--ink-mute);margin-top:2px}
.charts{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin-bottom:14px}
.chart-box{background:#fff;border-radius:var(--radius);padding:16px;border:1px solid var(--border-soft);height:280px;display:flex;flex-direction:column}
.chart-box h4{font-size:13px;font-weight:700;color:var(--ink);margin-bottom:10px;display:flex;align-items:center;gap:6px}
.chart-box h4 svg{width:14px;height:14px;color:var(--primary)}
.switch{position:relative;display:inline-block;width:42px;height:24px;background:var(--border);border-radius:12px;cursor:pointer;transition:background .15s;flex-shrink:0}
.switch.on{background:var(--primary)}
.switch::after{content:'';position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;transition:transform .15s;box-shadow:0 1px 3px rgba(0,0,0,.2)}
.switch.on::after{transform:translateX(18px)}
.drawer-mask{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:50}
.drawer-mask.open{display:block;animation:fadeIn .2s}
@media (max-width:1024px){
.sidebar{position:fixed;left:-260px;transition:left .25s;box-shadow:var(--shadow-lg)}
.sidebar.open{left:0}
:root{--sidebar-w:0px}
.app{grid-template-columns:1fr;grid-template-areas:"header" "main"}
.menu-toggle{display:flex}
.grid-4{grid-template-columns:repeat(2,1fr)}
.grid-3{grid-template-columns:1fr}
.header-search{max-width:200px}
}
@media (max-width:768px){
.grid-4,.grid-3,.grid-2{grid-template-columns:1fr}
.main{padding:14px 14px 80px}
.header{padding:0 14px;gap:10px}
.header-search{display:none}
.header-title{font-size:15px}
.form-grid.cols-2,.form-grid.cols-3{grid-template-columns:1fr}
.contact-row{grid-template-columns:1fr;gap:6px}
.modal{max-width:100vw;height:100vh;height:100dvh;border-radius:0;max-height:none}
.modal-body{padding:14px}
.page-head h1{font-size:18px}
.today-bar{flex-direction:column;align-items:flex-start}
table.data{min-width:680px}
.charts{grid-template-columns:1fr}
.profile-card{flex-direction:column;text-align:center}
.profile-card .stats{margin-left:0}
.modal-foot{flex-wrap:wrap}
.modal-foot .left,.modal-foot .right{width:100%;justify-content:flex-end}
.kanban.cols-5,.kanban.cols-3{grid-template-columns:repeat(2,minmax(200px,1fr))}
.detail-row{grid-template-columns:1fr;gap:4px}
}
@media (max-width:480px){
.kanban.cols-5,.kanban.cols-3{grid-template-columns:1fr}
.header-actions .sync-indicator span{display:none}
.header-actions .sync-indicator{padding:7px 10px}
}
/* ===== 登录 / 注册页 ===== */
.login-page{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px;background:linear-gradient(135deg,var(--primary) 0%,#3D2F55 100%);overflow:auto}
.login-bg{position:absolute;inset:0;background:radial-gradient(circle at 20% 20%,rgba(245,233,215,.12) 0,transparent 40%),radial-gradient(circle at 80% 80%,rgba(200,214,226,.12) 0,transparent 40%);pointer-events:none}
.login-card{position:relative;width:100%;max-width:400px;background:var(--card);border-radius:20px;box-shadow:var(--shadow-lg);padding:34px 32px 26px;animation:slideUp .35s ease}
.login-logo{width:64px;height:64px;margin:0 auto 14px;display:flex;align-items:center;justify-content:center}
.login-logo svg{width:64px;height:64px}
.login-title{text-align:center;font-size:22px;font-weight:700;color:var(--ink);letter-spacing:.5px}
.login-sub{text-align:center;font-size:12px;color:var(--ink-mute);margin:6px 0 22px}
.login-tabs{display:flex;background:var(--bg-soft);border-radius:12px;padding:4px;margin-bottom:18px}
.login-tab{flex:1;padding:9px 0;border-radius:9px;font-size:13px;font-weight:600;color:var(--ink-soft);transition:all .15s}
.login-tab.active{background:#fff;color:var(--primary);box-shadow:var(--shadow-sm)}
.login-field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}
.login-field label{font-size:12px;font-weight:600;color:var(--ink-soft)}
.login-field input{padding:11px 14px;border:1px solid var(--border);border-radius:10px;font-size:14px;background:#fff;transition:border .15s,box-shadow .15s}
.login-field input:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(107,83,133,.12)}
.login-hint{font-size:11px;color:var(--ink-mute);text-align:center;margin:4px 0 14px;min-height:14px}
.login-hint.info{color:var(--info)}
.login-hint.error{color:var(--danger)}
.login-btn{width:100%;padding:12px 0;border-radius:10px;background:linear-gradient(135deg,var(--primary) 0%,var(--primary-deep) 100%);color:#fff;font-size:15px;font-weight:700;letter-spacing:4px;transition:all .2s;box-shadow:0 6px 16px rgba(107,83,133,.3)}
.login-btn:hover{transform:translateY(-1px);box-shadow:0 8px 20px rgba(107,83,133,.4)}
.login-btn:active{transform:none}
.login-btn.loading{opacity:.7;pointer-events:none}
.login-foot{text-align:center;font-size:11px;color:var(--ink-mute);margin-top:16px}
@@ -0,0 +1,130 @@
package api
import (
"database/sql"
"errors"
"net/http"
"strings"
"freight-sfa-server/internal/auth"
)
type registerReq struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
}
type loginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
type userInfo struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Token string `json:"token,omitempty"`
}
// register 注册。首个注册用户自动成为 admin;后续注册仅限 admin 调用。
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"
} else {
// 后续注册必须由管理员调用(本路由未挂中间件,手动校验 Bearer token
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
writeErr(w, http.StatusForbidden, "仅管理员可创建用户")
return
}
claims, err := auth.ParseToken(strings.TrimPrefix(h, "Bearer "))
if err != nil || claims["role"] != "admin" {
writeErr(w, http.StatusForbidden, "仅管理员可创建用户")
return
}
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "密码加密失败")
return
}
displayName := 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 {
writeErr(w, http.StatusConflict, "用户名已存在")
return
}
id, _ := res.LastInsertId()
writeJSON(w, http.StatusCreated, userInfo{ID: id, Username: req.Username, DisplayName: displayName, Role: role})
}
// login 登录,返回 JWT
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
}
var (
id int64
hash, disp string
role string
)
err := s.db.QueryRow(`SELECT id, password_hash, display_name, role FROM users WHERE username=?`,
strings.TrimSpace(req.Username)).Scan(&id, &hash, &disp, &role)
if errors.Is(err, sql.ErrNoRows) || !auth.CheckPassword(hash, req.Password) {
writeErr(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "登录失败")
return
}
token, err := auth.GenerateToken(id, req.Username, role)
if err != nil {
writeErr(w, http.StatusInternalServerError, "令牌生成失败")
return
}
writeJSON(w, http.StatusOK, userInfo{ID: id, Username: req.Username, DisplayName: disp, Role: role, Token: token})
}
// me 返回当前用户信息
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
uid := auth.UserID(r)
var disp, role string
if err := s.db.QueryRow(`SELECT display_name, role FROM users WHERE id=?`, uid).
Scan(&disp, &role); err != nil {
writeErr(w, http.StatusUnauthorized, "用户不存在")
return
}
writeJSON(w, http.StatusOK, userInfo{ID: uid, Username: auth.Username(r), DisplayName: disp, Role: role})
}
+160
View File
@@ -0,0 +1,160 @@
package api
import (
"encoding/json"
"net/http"
"freight-sfa-server/internal/auth"
)
// backupPayload 备份结构,与前端 exportBackup 的 JSON 保持一致
type backupPayload struct {
App string `json:"app"`
Version string `json:"version"`
Exported string `json:"exported"`
Clients []map[string]any `json:"clients"`
Inquiries []map[string]any `json:"inquiries"`
Bookings []map[string]any `json:"bookings"`
Followups []map[string]any `json:"followups"`
Options map[string][]string `json:"options"`
Owner string `json:"owner"`
Extra map[string]json.RawMessage `json:"extra,omitempty"`
}
// exportBackup 导出全量数据(团队共享账套)
func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
clients, _ := s.loadAllRecords("client")
inquiries, _ := s.loadAllRecords("inquiry")
bookings, _ := s.loadAllRecords("booking")
followups, _ := s.loadAllRecords("followup")
opts, _ := s.loadOptions()
p := backupPayload{
App: "freight-sfa",
Version: "1.0.0",
Exported: nowStr(),
Clients: clients,
Inquiries: inquiries,
Bookings: bookings,
Followups: followups,
Options: opts,
Owner: auth.Username(r),
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) loadOptions() (map[string][]string, error) {
var raw string
err := s.db.QueryRow(`SELECT data_json FROM options WHERE id=1`).Scan(&raw)
if err != nil {
return defaultOptions, nil
}
m := map[string][]string{}
if json.Unmarshal([]byte(raw), &m) != nil {
return defaultOptions, nil
}
for k, v := range defaultOptions {
if _, ok := m[k]; !ok || m[k] == nil {
m[k] = v
}
}
return m, nil
}
// importBackup 导入备份(全量覆盖团队数据)
func (s *Server) importBackup(w http.ResponseWriter, r *http.Request) {
var p backupPayload
if err := decodeJSON(r, &p); err != nil {
writeErr(w, http.StatusBadRequest, "备份格式错误")
return
}
tx, err := s.db.Begin()
if err != nil {
writeErr(w, http.StatusInternalServerError, "事务启动失败")
return
}
defer tx.Rollback()
now := nowStr()
clearAndInsert := func(kind string, list []map[string]any) error {
if _, err := tx.Exec(`UPDATE records SET deleted_at=? WHERE kind=?`, now, kind); err != nil {
return err
}
for _, m := range list {
raw, err := json.Marshal(m)
if err != nil {
return err
}
id := genID(kind)
uid := auth.UserID(r)
if _, err := tx.Exec(`INSERT INTO records (id, kind, owner_id, data_json, created_at, updated_at)
VALUES (?,?,?,?,?,?)`, id, kind, uid, string(raw), now, now); err != nil {
return err
}
}
return nil
}
if err := clearAndInsert("client", p.Clients); err != nil {
writeErr(w, http.StatusInternalServerError, "导入客户失败")
return
}
if err := clearAndInsert("inquiry", p.Inquiries); err != nil {
writeErr(w, http.StatusInternalServerError, "导入商机失败")
return
}
if err := clearAndInsert("booking", p.Bookings); err != nil {
writeErr(w, http.StatusInternalServerError, "导入订舱失败")
return
}
if err := clearAndInsert("followup", p.Followups); err != nil {
writeErr(w, http.StatusInternalServerError, "导入跟进失败")
return
}
if p.Options != nil {
raw, _ := json.Marshal(p.Options)
if _, err := tx.Exec(`UPDATE options SET data_json=?, updated_at=? WHERE id=1`, string(raw), now); err != nil {
return
}
}
if err := tx.Commit(); err != nil {
writeErr(w, http.StatusInternalServerError, "导入提交失败")
return
}
s.auditLog(r, "import", "backup", "", "导入备份")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "counts": map[string]int{
"clients": len(p.Clients), "inquiries": len(p.Inquiries),
"bookings": len(p.Bookings), "followups": len(p.Followups),
}})
}
// listAuditLogs 审计日志(仅 admin 可见)
func (s *Server) listAuditLogs(w http.ResponseWriter, r *http.Request) {
if auth.Role(r) != "admin" {
writeErr(w, http.StatusForbidden, "仅管理员可查看审计日志")
return
}
rows, err := s.db.Query(`SELECT id, user_id, username, action, entity, entity_id, detail, created_at
FROM audit_logs ORDER BY id DESC LIMIT 200`)
if err != nil {
writeErr(w, http.StatusInternalServerError, "查询失败")
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id int64
var uid int64
var user, action, entity, entityID, detail, created string
if err := rows.Scan(&id, &uid, &user, &action, &entity, &entityID, &detail, &created); err != nil {
writeErr(w, http.StatusInternalServerError, "数据读取失败")
return
}
out = append(out, map[string]any{
"id": id, "user_id": uid, "username": user, "action": action,
"entity": entity, "entity_id": entityID, "detail": detail, "created_at": created,
})
}
writeJSON(w, http.StatusOK, out)
}
@@ -0,0 +1,184 @@
package api
import (
"encoding/json"
"net/http"
"time"
)
// loadAllRecords 读取指定 kind 的全部记录(团队共享账套,不剔除财务字段,聚合在本地计算)
func (s *Server) loadAllRecords(kind string) ([]map[string]any, error) {
rows, err := s.db.Query(`SELECT data_json FROM records WHERE kind=? AND deleted_at IS NULL`,
kind)
if err != nil {
return nil, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var m map[string]any
if json.Unmarshal([]byte(raw), &m) == nil {
out = append(out, m)
}
}
return out, rows.Err()
}
func asFloat(v any) float64 {
switch t := v.(type) {
case float64:
return t
case json.Number:
f, _ := t.Float64()
return f
default:
return 0
}
}
func inSlice[T comparable](v T, list []T) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// dashboard 看板聚合:KPI + 今日任务(前端其余视图用四表数据本地聚合)
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
clients, _ := s.loadAllRecords("client")
inquiries, _ := s.loadAllRecords("inquiry")
bookings, _ := s.loadAllRecords("booking")
followups, _ := s.loadAllRecords("followup")
kpi := map[string]any{
"clients": len(clients),
"inquiries": countBy(inquiries, "业务流程状态", "询价中"),
"quoted": countBy(inquiries, "业务流程状态", "已报价"),
"bookings": len(filterBookings(bookings, false)),
"week_etd": countWeekETD(bookings),
"pending_pay": sumMoney(filterByIn(bookings, "回款状态", []string{"待回款", "已开票", "部分回款", "逾期"})),
"overdue_pay": sumMoney(filterBy(bookings, "回款状态", "逾期")),
}
kpi["vip_count"] = countBy(clients, "ABC分级", "A级核心")
// 今日任务
now := time.Now()
today := now.Format("2006-01-02")
twoDays := now.Add(2 * 86400 * time.Second)
type taskItem struct {
Alert bool `json:"alert"`
Txt string `json:"txt"`
Route string `json:"route"`
}
tasks := []taskItem{}
for _, b := range bookings {
if b["回款状态"] == "逾期" {
tasks = append(tasks, taskItem{Alert: true, Txt: "逾期回款 · " + str(b["订舱单号"]) + " · " + str(b["关联客户"]), Route: "view-payments"})
}
if s := str(b["SI截关时间"]); s != "" {
if t, err := time.Parse("2006-01-02 15:04", s); err == nil {
if !t.Before(now) && !t.After(twoDays) && !inSlice(str(b["订舱跟进状态"]), []string{"已完成", "异常"}) {
tasks = append(tasks, taskItem{Alert: true, Txt: "SI截关临近 · " + str(b["订舱单号"]) + " · " + s, Route: "view-bookings"})
}
}
}
}
for _, f := range followups {
s := str(f["下次跟进时间"])
if len(s) >= 10 {
d := s[:10]
if d == today {
tasks = append(tasks, taskItem{Alert: false, Txt: "今日跟进 · " + str(f["关联客户"]) + " · " + str(f["跟进主题"]), Route: "table-followups"})
}
if d < today {
tasks = append(tasks, taskItem{Alert: true, Txt: "逾期跟进 · " + str(f["关联客户"]) + " · " + str(f["跟进主题"]), Route: "table-followups"})
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"kpi": kpi, "tasks": tasks})
}
// ---- 聚合工具 ----
func str(v any) string {
switch t := v.(type) {
case string:
return t
default:
return ""
}
}
func countBy(list []map[string]any, key, val string) int {
n := 0
for _, m := range list {
if str(m[key]) == val {
n++
}
}
return n
}
func countWeekETD(bookings []map[string]any) int {
now := time.Now()
in7 := now.Add(7 * 86400 * time.Second)
n := 0
for _, b := range bookings {
s := str(b["ETD开船时间"])
if s == "" {
continue
}
if t, err := time.Parse("2006-01-02", s); err == nil {
if !t.Before(now) && !t.After(in7) {
n++
}
}
}
return n
}
func filterBy(list []map[string]any, key, val string) []map[string]any {
out := []map[string]any{}
for _, m := range list {
if str(m[key]) == val {
out = append(out, m)
}
}
return out
}
func filterByIn(list []map[string]any, key string, vals []string) []map[string]any {
out := []map[string]any{}
for _, m := range list {
if inSlice(str(m[key]), vals) {
out = append(out, m)
}
}
return out
}
func filterBookings(bookings []map[string]any, done bool) []map[string]any {
out := []map[string]any{}
for _, b := range bookings {
if inSlice(str(b["订舱跟进状态"]), []string{"已完成", "异常"}) == done {
out = append(out, b)
}
}
return out
}
func sumMoney(list []map[string]any) float64 {
total := 0.0
for _, m := range list {
total += asFloat(m["应收运费金额"])
}
return total
}
@@ -0,0 +1,73 @@
package api
import (
"encoding/json"
"net/http"
"freight-sfa-server/internal/auth"
)
// 默认选项字典(与前端 DEFAULT_OPTIONS 保持一致)
var defaultOptions = map[string][]string{
"clientType": {"同行货代", "国内直客(工厂/外贸)", "国外收货人", "国外海外代理", "供应商"},
"grade": {"A级核心", "B级重要", "C级普通"},
"flowStatus": {"待联系", "初次沟通", "询价中", "报价跟进", "谈判中", "已合作", "维护期", "流失"},
"source": {"陌拜", "展会", "网站询盘", "老客介绍", "LinkedIn", "微信群", "阿里国际站", "Made-in-China", "其他"},
"risk": {"账期超长", "付款拖延", "价格敏感", "纠纷历史", "资金紧张", "船期变动频繁"},
"customTags": {"VIP", "战略合作"},
"yesno": {"是", "否"},
"incoterm": {"FOB", "CIF", "CFR", "DDP", "DDU", "EXW", "DAP"},
"transport": {"直达", "中转"},
"containerType": {"20GP", "40GP", "40HQ", "45HQ", "20RF", "40RF", "特种柜", "散货"},
"inquiryStatus": {"询价中", "已报价", "确认订舱", "实单出货", "丢单"},
"lossReason": {"价格偏高", "船期不合适", "竞争对手胜出", "客户暂缓", "目的港服务不佳", "客户选择直签"},
"bookingStatus": {"待订舱", "已订舱", "已截关", "已开船", "已到港", "已清关", "已派送", "已完成", "异常"},
"currency": {"CNY", "USD", "EUR", "HKD"},
"paymentStatus": {"待回款", "已开票", "部分回款", "已回款", "逾期"},
"followupMethod": {"电话", "微信", "邮件", "面谈", "视频会议", "其他"},
"followupResult": {"继续跟进", "待报价", "待确认订舱", "已完成", "客户暂缓", "丢单"},
"peijiaList": {"+1庄家", "待补充"},
"ddpList": {"+1DDP", "待补充"},
}
func (s *Server) getOptions(w http.ResponseWriter, r *http.Request) {
var raw string
err := s.db.QueryRow(`SELECT data_json FROM options WHERE id=1`).Scan(&raw)
if err != nil {
// 未初始化:返回默认并落库
rawBytes, _ := json.Marshal(defaultOptions)
_, _ = s.db.Exec(`INSERT OR IGNORE INTO options (id, data_json, updated_at) VALUES (1,?,?)`,
string(rawBytes), nowStr())
raw = string(rawBytes)
}
var m map[string][]string
if json.Unmarshal([]byte(raw), &m) != nil {
m = defaultOptions
}
// 补全缺失的键
for k, v := range defaultOptions {
if _, ok := m[k]; !ok || m[k] == nil {
m[k] = v
}
}
writeJSON(w, http.StatusOK, m)
}
func (s *Server) updateOptions(w http.ResponseWriter, r *http.Request) {
if auth.Role(r) != "admin" {
writeErr(w, http.StatusForbidden, "仅管理员可修改选项")
return
}
var m map[string][]string
if err := decodeJSON(r, &m); err != nil {
writeErr(w, http.StatusBadRequest, "请求格式错误")
return
}
raw, _ := json.Marshal(m)
if _, err := s.db.Exec(`UPDATE options SET data_json=?, updated_at=? WHERE id=1`, string(raw), nowStr()); err != nil {
writeErr(w, http.StatusInternalServerError, "保存失败")
return
}
s.auditLog(r, "update", "options", "", "")
writeJSON(w, http.StatusOK, m)
}
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"freight-sfa-server/internal/auth"
)
// 允许的实体 kind,防止 SQL 注入
var allowedKinds = map[string]bool{
"client": true,
"inquiry": true,
"booking": true,
"followup": true,
}
// 财务保密字段(前端 SCHEMAS 中 permission:true 的组),仅 admin 可见
var financialFields = []string{"内部底价备注", "约定账期天数", "累计合作货量", "业务禁忌备注"}
type record struct {
ID string `json:"id"`
Kind string `json:"kind"`
OwnerID int64 `json:"owner_id"`
Data json.RawMessage `json:"data"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// sanitizeFinancial 按角色剔除财务保密字段(不可信前端权限,必须后端过滤)
func sanitizeFinancial(role string, raw json.RawMessage) json.RawMessage {
if role == "admin" {
return raw
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return raw
}
changed := false
for _, f := range financialFields {
if _, ok := m[f]; ok {
delete(m, f)
changed = true
}
}
if !changed {
return raw
}
out, _ := json.Marshal(m)
return out
}
func (s *Server) listRecords(w http.ResponseWriter, r *http.Request) {
role := auth.Role(r)
kind := pathID(r, "kind")
if !allowedKinds[kind] {
writeErr(w, http.StatusBadRequest, "未知数据类型")
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
// 团队共享账套:所有登录用户可见同一套数据
query := `SELECT id, kind, owner_id, data_json, created_at, updated_at FROM records
WHERE kind=? AND deleted_at IS NULL`
args := []any{kind}
if q != "" {
query += ` AND data_json LIKE ?`
args = append(args, "%"+q+"%")
}
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 := []record{}
for rows.Next() {
rec := record{}
var raw string
if err := rows.Scan(&rec.ID, &rec.Kind, &rec.OwnerID, &raw, &rec.CreatedAt, &rec.UpdatedAt); err != nil {
writeErr(w, http.StatusInternalServerError, "数据读取失败")
return
}
rec.Data = sanitizeFinancial(role, json.RawMessage(raw))
out = append(out, rec)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "数据读取失败")
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) loadRecord(role, kind, id string) (*record, error) {
rec := &record{}
var raw string
err := s.db.QueryRow(`SELECT id, kind, owner_id, data_json, created_at, updated_at FROM records
WHERE kind=? AND id=? AND deleted_at IS NULL`,
kind, id).Scan(&rec.ID, &rec.Kind, &rec.OwnerID, &raw, &rec.CreatedAt, &rec.UpdatedAt)
if err != nil {
return nil, err
}
rec.Data = sanitizeFinancial(role, json.RawMessage(raw))
return rec, nil
}
func (s *Server) getRecord(w http.ResponseWriter, r *http.Request) {
role := auth.Role(r)
kind := pathID(r, "kind")
if !allowedKinds[kind] {
writeErr(w, http.StatusBadRequest, "未知数据类型")
return
}
id := pathID(r, "id")
rec, err := s.loadRecord(role, kind, id)
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "记录不存在")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "查询失败")
return
}
writeJSON(w, http.StatusOK, rec)
}
func (s *Server) createRecord(w http.ResponseWriter, r *http.Request) {
uid := auth.UserID(r)
kind := pathID(r, "kind")
if !allowedKinds[kind] {
writeErr(w, http.StatusBadRequest, "未知数据类型")
return
}
var data map[string]any
if err := decodeJSON(r, &data); err != nil {
writeErr(w, http.StatusBadRequest, "请求格式错误")
return
}
raw, err := json.Marshal(data)
if err != nil {
writeErr(w, http.StatusInternalServerError, "数据编码失败")
return
}
id := genID(kind)
now := nowStr()
if _, err := s.db.Exec(`INSERT INTO records (id, kind, owner_id, data_json, created_at, updated_at)
VALUES (?,?,?,?,?,?)`, id, kind, uid, string(raw), now, now); err != nil {
writeErr(w, http.StatusInternalServerError, "保存失败")
return
}
s.auditLog(r, "create", kind, id, truncateDetail(string(raw)))
rec, _ := s.loadRecord(auth.Role(r), kind, id)
writeJSON(w, http.StatusCreated, rec)
}
func (s *Server) updateRecord(w http.ResponseWriter, r *http.Request) {
kind := pathID(r, "kind")
if !allowedKinds[kind] {
writeErr(w, http.StatusBadRequest, "未知数据类型")
return
}
id := pathID(r, "id")
var exists int
err := s.db.QueryRow(`SELECT 1 FROM records WHERE kind=? AND id=? AND deleted_at IS NULL`,
kind, id).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "记录不存在")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "查询失败")
return
}
var data map[string]any
if err := decodeJSON(r, &data); err != nil {
writeErr(w, http.StatusBadRequest, "请求格式错误")
return
}
raw, err := json.Marshal(data)
if err != nil {
writeErr(w, http.StatusInternalServerError, "数据编码失败")
return
}
if _, err := s.db.Exec(`UPDATE records SET data_json=?, updated_at=? WHERE kind=? AND id=?`,
string(raw), nowStr(), kind, id); err != nil {
writeErr(w, http.StatusInternalServerError, "保存失败")
return
}
s.auditLog(r, "update", kind, id, truncateDetail(string(raw)))
rec, _ := s.loadRecord(auth.Role(r), kind, id)
writeJSON(w, http.StatusOK, rec)
}
func (s *Server) deleteRecord(w http.ResponseWriter, r *http.Request) {
kind := pathID(r, "kind")
if !allowedKinds[kind] {
writeErr(w, http.StatusBadRequest, "未知数据类型")
return
}
id := pathID(r, "id")
res, err := s.db.Exec(`UPDATE records SET deleted_at=? WHERE kind=? AND id=? AND deleted_at IS NULL`,
nowStr(), kind, id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "删除失败")
return
}
if n, _ := res.RowsAffected(); n == 0 {
writeErr(w, http.StatusNotFound, "记录不存在")
return
}
s.auditLog(r, "delete", kind, id, "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func truncateDetail(s string) string {
if len(s) > 500 {
return s[:500] + "..."
}
return s
}
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"log"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"freight-sfa-server/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)))
// ---- 通用业务记录(client/inquiry/booking/followup ----
s.mux.Handle("GET /api/records/{kind}", auth.Middleware(http.HandlerFunc(s.listRecords)))
s.mux.Handle("POST /api/records/{kind}", auth.Middleware(http.HandlerFunc(s.createRecord)))
s.mux.Handle("GET /api/records/{kind}/{id}", auth.Middleware(http.HandlerFunc(s.getRecord)))
s.mux.Handle("PUT /api/records/{kind}/{id}", auth.Middleware(http.HandlerFunc(s.updateRecord)))
s.mux.Handle("DELETE /api/records/{kind}/{id}", auth.Middleware(http.HandlerFunc(s.deleteRecord)))
// ---- 选项字典 ----
s.mux.Handle("GET /api/options", auth.Middleware(http.HandlerFunc(s.getOptions)))
s.mux.Handle("PUT /api/options", auth.Middleware(http.HandlerFunc(s.updateOptions)))
// ---- 看板聚合 ----
s.mux.Handle("GET /api/dashboard", auth.Middleware(http.HandlerFunc(s.dashboard)))
// ---- 备份/恢复 ----
s.mux.Handle("GET /api/backup", auth.Middleware(http.HandlerFunc(s.exportBackup)))
s.mux.Handle("POST /api/backup", auth.Middleware(http.HandlerFunc(s.importBackup)))
// ---- 审计日志 ----
s.mux.Handle("GET /api/audit-logs", auth.Middleware(http.HandlerFunc(s.listAuditLogs)))
// ---- 静态资源(前端) ----
mime.AddExtensionType(".webmanifest", "application/manifest+json")
mime.AddExtensionType(".js", "text/javascript")
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/index.html", http.StatusFound)
return
}
serveStatic(w, r, staticDir)
})
}
// serveStatic 提供 html 目录下的静态文件(含路径遍历防护)
func serveStatic(w http.ResponseWriter, r *http.Request, staticDir string) {
rel := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
full := filepath.Join(staticDir, filepath.FromSlash(rel))
base, err := filepath.Abs(staticDir)
if err != nil {
http.NotFound(w, r)
return
}
abs, err := filepath.Abs(full)
if err != nil {
http.NotFound(w, r)
return
}
// 规范化后必须仍在 staticDir 内,防目录遍历
if abs != base && !strings.HasPrefix(abs, base+string(filepath.Separator)) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
st, err := os.Stat(abs)
if err != nil || st.IsDir() {
http.NotFound(w, r)
return
}
f, err := os.Open(abs)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
if ctype := mime.TypeByExtension(strings.ToLower(filepath.Ext(abs))); ctype != "" {
w.Header().Set("Content-Type", ctype)
}
http.ServeContent(w, r, filepath.Base(abs), st.ModTime(), f)
}
// ---- 通用工具 ----
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)
dec.DisallowUnknownFields()
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(p string) string {
var sb strings.Builder
sb.WriteString(p)
sb.WriteString("_")
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))
}
// auditLog 记录审计日志
func (s *Server) auditLog(r *http.Request, action, entity, entityID, detail string) {
_, err := s.db.Exec(`INSERT INTO audit_logs (user_id, username, action, entity, entity_id, detail, created_at)
VALUES (?,?,?,?,?,?,?)`,
auth.UserID(r), auth.Username(r), action, entity, entityID, detail, nowStr())
if err != nil {
log.Printf("写入审计日志失败: %v", err)
}
}
+134
View File
@@ -0,0 +1,134 @@
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"
keyUsername ctxKey = "username"
keyRole ctxKey = "role"
)
// secret 返回 JWT 签名密钥,可用环境变量 JWT_SECRET 覆盖(生产必须设置)
func secret() []byte {
if s := os.Getenv("JWT_SECRET"); s != "" {
return []byte(s)
}
return []byte("freight-sfa-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, username, role string) (string, error) {
claims := jwt.MapClaims{
"sub": strconv.FormatInt(userID, 10),
"user": username,
"role": role,
"iat": time.Now().Unix(),
"exp": time.Now().Add(72 * time.Hour).Unix(),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret())
}
// ParseToken 解析并校验 JWT,返回声明(供非中间件路由手动校验)
func ParseToken(raw string) (map[string]string, error) {
token, err := jwt.ParseWithClaims(raw, &jwt.MapClaims{},
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 {
return nil, errors.New("invalid token")
}
claims, ok := token.Claims.(*jwt.MapClaims)
if !ok {
return nil, errors.New("invalid claims")
}
out := map[string]string{}
if sub, ok := (*claims)["sub"].(string); ok {
out["sub"] = sub
}
if u, ok := (*claims)["user"].(string); ok {
out["user"] = u
}
if r, ok := (*claims)["role"].(string); ok {
out["role"] = r
}
return out, nil
}
// 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
}
claims, err := ParseToken(strings.TrimPrefix(h, "Bearer "))
if err != nil {
writeErr(w, http.StatusUnauthorized, "登录已过期,请重新登录")
return
}
uid, err := strconv.ParseInt(claims["sub"], 10, 64)
if err != nil || uid <= 0 {
writeErr(w, http.StatusUnauthorized, "无效凭证")
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, keyUserID, uid)
ctx = context.WithValue(ctx, keyUsername, claims["user"])
ctx = context.WithValue(ctx, keyRole, claims["role"])
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
}
// Username 从请求上下文取当前用户名
func Username(r *http.Request) string {
v, _ := r.Context().Value(keyUsername).(string)
return v
}
// Role 从请求上下文取当前用户角色
func Role(r *http.Request) string {
v, _ := r.Context().Value(keyRole).(string)
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,74 @@
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 'admin',
created_at TEXT NOT NULL DEFAULT ''
);
-- 通用业务记录表kind 区分 client/inquiry/booking/followup
-- data_json 整存中文 key 业务字段保持前端 SCHEMAS 结构不变
CREATE TABLE IF NOT EXISTS records (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
owner_id INTEGER NOT NULL REFERENCES users(id),
data_json 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_records_kind_owner ON records(kind, owner_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_records_kind ON records(kind, deleted_at);
-- 选项字典单行id 固定为 1
CREATE TABLE IF NOT EXISTS options (
id INTEGER PRIMARY KEY CHECK (id = 1),
data_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL DEFAULT '',
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
}

Some files were not shown because too many files have changed in this diff Show More