Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19deecef18 |
@@ -1,54 +0,0 @@
|
||||
# 2026-08-14 工作日志
|
||||
|
||||
## 读书笔记工作台搭建
|
||||
|
||||
为用户搭建了一个单文件 HTML 读书笔记工作台(reading-notes-workspace.html)。
|
||||
|
||||
**用户需求**:5 个 Tab(纸质书/电子书/视频转文字/日记/英语)+ 首页看板,冷白底褐色渐变视觉,全内联 SVG 图标,响应式三端布局,localStorage 即时存储,导出导入备份。
|
||||
|
||||
**技术方案**:
|
||||
- 单文件 HTML,所有 CSS/JS/SVG 内联,零外部依赖
|
||||
- 资料库 skill 加载失败,采用本地 localStorage 方案
|
||||
- localStorage key 前缀 `wb_reading_`
|
||||
- 响应式:PC 左侧边栏 + 多栏,平板顶部导航,手机底部 Tab + 单列
|
||||
- 语音输入:Web Speech API(Chrome/Edge 支持)
|
||||
- 拍照上传:FileReader + Canvas 压缩到 800px
|
||||
- 水印:时间自动 + 位置 Geolocation API
|
||||
- 彩带动效:纯 CSS/JS confetti
|
||||
- 图表/图标:全部内联 SVG,无 emoji
|
||||
- 预置示例数据 5 条(含 1 条逾期书籍)
|
||||
|
||||
**文件位置**:/Users/alice/WorkBuddy/2026-08-14-17-50-08/reading-notes-workspace.html
|
||||
**文件大小**:约 76 KB
|
||||
|
||||
## 第二轮迭代修改(18:52)
|
||||
|
||||
按用户要求完成 4 项修改:
|
||||
|
||||
1. **备忘录板块**:首页"今天要处理"新增备忘录(输入框+回车添加),勾选完成自动记录 completedAt 并触发彩带;已完成备忘自动同步显示在日记模块(带"备忘"标签和完成时间);支持取消勾选和删除。存储 key `wb_reading_memo`。
|
||||
2. **书籍合并**:纸质书/电子书两个 Tab 合并为单一"书籍" Tab;新建书籍弹窗可选类型(纸质书/电子书,复用 mood-option 选择器样式);纸质书类型保留拍照/水印/阅读目标/读完功能,电子书类型保留章节摘录;数据统一存 `wb_reading_books`(含 type 字段),旧 paper/ebook 数据自动迁移(migrateBooks + 导入兼容)。
|
||||
3. **英语扩展**:新建可选"单词"(音标/释义/例句)或"口语句子"(句子/翻译/使用场景,支持语音输入),渲染带类型标签;掌握按钮对两种类型都触发彩带。
|
||||
4. **视觉改版**:冷白底 #f2f7f4 + 薄荷绿渐变 #7ed8b4→#2a9d78,全部根变量与硬编码褐色(阴影/封面/遮罩/彩带/心情色)替换。
|
||||
|
||||
导航从 6 项变为 5 项(首页/书籍/视频/日记/英语),手机底部 Tab 布局仍适配。文件 1000 行 / 81.3KB,JS 语法与 23 项结构检查全部通过。
|
||||
|
||||
## 第三轮迭代修改(19:30)
|
||||
|
||||
按用户要求完成 4 项修改:
|
||||
|
||||
1. **移除"清空全部数据"**:首页底部仅保留"清空示例数据",删除 clearAllData 函数和对应按钮,防止误删。
|
||||
2. **分类汇总竖排布局**:summary-grid 从横向网格改为 flex-direction:column 竖排,卡片改为横排(图标+信息),最大宽度 280px,左侧分布。
|
||||
3. **全局编辑模式**:
|
||||
- 新增 state.editMode / state.selectedIds 状态
|
||||
- 长按内容(触摸 600ms / 鼠标长按)进入编辑模式
|
||||
- 编辑模式:每项显示选中状态(蓝色边框+背景),编辑栏显示已选数/全选/删除选中/退出
|
||||
- 点击已有内容可直接编辑(editBook/editVideoNote/editDiary/editEnglish 四个编辑函数)
|
||||
- 各模块底部新增"编辑模式"按钮入口
|
||||
- deleteSelected 统一处理 books/video/diary/english/booknotes 五种类别
|
||||
4. **书籍笔记详情页增强**:
|
||||
- 原"删除"按钮改为"编辑笔记"按钮(进入多选模式)+ "编辑书籍"按钮(编辑书名/作者/类型/目标日期/状态)
|
||||
- 排序选择:最新优先 / 最早优先 / 按章节(电子书),按章节时自动分组显示章节标题
|
||||
- 每条笔记新增"追加感悟"按钮,支持文字+语音输入,多条感悟独立显示带时间戳
|
||||
- 笔记数据结构新增 reflections[] 数组(兼容旧 reflection 字段自动迁移)
|
||||
|
||||
文件增至 1269 行 / 99.6KB,JS 语法与 17 项结构检查全部通过。
|
||||
@@ -1,18 +0,0 @@
|
||||
# 2026-08-15
|
||||
|
||||
## CNC 销售工作台前端分析(2026-08-15-13-49-49/)
|
||||
- 单文件 PWA:`cnc-sales-workbench.html`(原生 JS/CSS,无构建无依赖)+ `manifest.webmanifest` + `sw.js`(离线缓存)。
|
||||
- 存储:`localStorage["wb_cncsales_state"]`,SCHEMA=1;顶层 `{settings, ui, drafts, customers[], tasks[], notes[]}`。
|
||||
- 业务常量:阶段 s1~s8(信息收集→售后维护)、等级 A/B/C、优先级 P0/P1/P2、状态 todo/doing/done、重复 daily/weekly/monthly、资料分类 5 类(报价模板/技术沟通要点/谈判话术/合同注意事项/其他)、联系人角色 老板/技术/采购/其他。
|
||||
- 6 大模块:客户档案、销售流程看板、任务待办、数据看板、资料库、数据中心(JSON/Excel/CSV 导入导出,merge 幂等合并)。
|
||||
- ID 生成:`id` + 时间戳36进制 + 随机串;导入用自然键哈希保证幂等。
|
||||
|
||||
## 后端搭建(cnc-sales-backend/,技术选型:Go + SQLite)
|
||||
- 用户选定 **Go + SQLite**(modernc.org/sqlite 纯 Go 驱动,无 CGO)。
|
||||
- 项目结构:`main.go`(入口,PORT/DB_PATH/STATIC_DIR/JWT_SECRET 环境变量)+ `internal/{database,models,auth,api}`。
|
||||
- 数据库:users/customers/contacts/follow_ups/tasks/notes/audit_log 共 7 表;业务表软删除(deleted_at)+ owner_id 数据隔离;contacts/follow_ups 外键级联。
|
||||
- API(JWT 鉴权,72h):auth 注册/登录/me(首个用户自动 admin);customers CRUD+筛选+联系人/跟进子接口;tasks CRUD+toggle(完成时按 repeat 自动生成下一周期任务);notes CRUD;聚合 pipeline/stages、stats/overview、today;backup 导出导入(upsert,兼容前端备份格式)。
|
||||
- 静态托管:static/ 复制了前端 3 个文件;根路径重定向:**不可用 `GET /{$}` + `GET /` 组合(实测 html 404)**,必须用 `HandleFunc("/")` 分流:`r.URL.Path == "/"` 时 302 到 `/cnc-sales-workbench.html`,否则交给 `http.FileServer`。
|
||||
- 环境:Go 1.26.6 在 `/usr/local/go/bin`(不在 PATH,需 `export PATH=$PATH:/usr/local/go/bin`);`GOPROXY=https://goproxy.cn,direct` 才能拉依赖(默认代理超时)。
|
||||
- 已完成端到端冒烟测试(注册/登录/CRUD/重复任务/聚合/备份/401/静态页全部通过);测试数据已清理。
|
||||
- 二进制:`bin/cnc-server`,运行于 http://localhost:8080。前端尚未接入后端(仍 localStorage 单机模式),下一步可选做前端登录页 + 全量同步层(方案 A)。
|
||||
@@ -1,57 +0,0 @@
|
||||
# 2026-08-16 工作日志
|
||||
|
||||
- 修复 freight-sfa.html 三处 JS 语法错误:`{cf-type:...}` 未引号键、`常用起运港:['起运港庄家(默认)']:[]` 畸形字段、`报关/拖车需求` 与 `起运港庄家(意向)`/`目的港DDP(意向)` 未引号键
|
||||
- node --check 全量校验通过;onclick 函数与 getElementById ID 交叉检查全部匹配
|
||||
- 部署 HTML 至资料库在线页面(绑定 4 张数据库),URL: https://www.workbuddy.cn/space/d/slZoYoQY3Jp8VuRAmizFem,HTTP 200 验证通过
|
||||
- 全部 6 项任务完成,交付用户
|
||||
<<<<<<< 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-server(Go1.26+modernc.org/sqlite 纯Go免CGO+golang-jwt/v5)+ 前端 html/ 静态目录(单二进制内嵌服务)
|
||||
- 前端拆分 6 文件:index.html(应用壳+登录页)、style.css(修正源文件 ::root 笔误为 :root)、js/schemas.js(SCHEMAS+DEFAULT_OPTIONS+COLORS+工具函数 uid/esc/toast/confirmDialog/setSyncState/closeModal/setActive)、js/api.js(fetch+JWT 持久化 localStorage,register/login/me/CRUD/options/dashboard/backup,401 调 __forceLogin)、js/app.js(渲染逻辑迁移,loadAll 并行拉 4 类 records+options+me,saveState 全部替换为 REST),js/main.js(登录/注册页装配+boot+bindSidebar/bindHeader/bindModals+logout+saveOwnerPref+injectPWA)
|
||||
- 后端契约:register 返回扁平 userInfo 无 token(首个自动 admin,后续需 admin token);login 返回 userInfo+token;me 返回 userInfo;records 响应 {id,kind,owner_id,data,created_at,updated_at};create 返回 201;财务字段 admin 可见 user 不可见(后端 sanitizeFinancial)
|
||||
- 关键修复:Go http.FileServer 在本机对 /index.html 返回 301 ./(疑似 http.Dir 相对路径/环境异常),改为自实现 serveStatic(path.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.js(2026-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 tag;filterClients 参数化;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 中两行无效 forEach(chip-input 收集残留)已删除(buildChipInput input 事件已实时写回,无需再收集),linter 仅剩模板自带 buildContactRow(p,i) 未用参数 HINT
|
||||
|
||||
## freight-sfa 前后端分离重构方案(已出方案,待用户确认选型)
|
||||
|
||||
- 现状:freight-sfa.html 单文件 1330 行/145KB,localStorage 存储(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
|
||||
@@ -1,21 +0,0 @@
|
||||
# 项目记忆
|
||||
|
||||
## 货代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
|
||||
@@ -1,2 +0,0 @@
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
@@ -1,12 +0,0 @@
|
||||
# 二进制
|
||||
bin/
|
||||
|
||||
# 数据库与运行时数据
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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` 做好备份。
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
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=
|
||||
@@ -1,174 +0,0 @@
|
||||
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"),
|
||||
})
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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})
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
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})
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
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})
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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})
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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) + `}`))
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/* 数控销售工作台 离线缓存 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" });
|
||||
}
|
||||
})());
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/* 数控销售工作台 离线缓存 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" });
|
||||
}
|
||||
})());
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
# 编译产物
|
||||
freight-sfa-server.exe
|
||||
*.exe
|
||||
|
||||
# 运行时数据
|
||||
data/
|
||||
*.db
|
||||
|
||||
# 临时文件与日志
|
||||
*.log
|
||||
*.err
|
||||
*.tmp
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,21 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
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=
|
||||
@@ -1,249 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,94 +0,0 @@
|
||||
/* ===== 货代 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
@@ -1,148 +0,0 @@
|
||||
/* ===== 货代 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);
|
||||
@@ -1,128 +0,0 @@
|
||||
/* ===== 货代 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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')})}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
*,*::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}
|
||||
@@ -1,130 +0,0 @@
|
||||
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})
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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) + `}`))
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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
Reference in New Issue
Block a user