21 Commits
Author SHA1 Message Date
DrsakuraandClaude Opus 5 3380dbc9d7 chore(release): v1.1.0
本次内容:
  fix  客户联系人新增 / 跟进编辑补归属校验(两处越权,已实测利用成功再封堵)
  feat 订单支出台账 —— 快递费、吊卡费等杂费挂到订单上,带跨币种汇率快照
  feat 产品速查浮层 Ctrl/⌘+J —— 报价时就地查采购价、起订量、箱规

三处 package.json 一起改:lib/updater.js 的 currentVersion() 按
「仓库根 → backend」的顺序找版本号,只改一处的话,部署形态变了版本号就不对。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-22 15:23:21 +08:00
DrsakuraandClaude Opus 5 226949fa53 feat(sku): 产品速查浮层(Ctrl/⌘+J)
报价报到一半想确认某个货的采购价、起订量、箱规时用。跟已有的两个东西都
不是一件事:

  ⌘K 全局搜索  是「找到后跳过去」—— 一跳走,正在写的报价就断了
  SkuPicker     是「挑一个塞进表单」—— 要点产品组再点货号,两层
  速查          是「就地看数」—— 不跳转、不改任何东西,Esc 回到刚才的地方

· 新接口 GET /api/sku/quick:一次把采购价、起订量、供应商、规格、箱规、
  阶梯价全带回来,不返回 path(它不是用来跳转的)。
· 货号精确/前缀命中排最前。报价时多半直接敲货号,让它跟在一堆品名模糊
  匹配后面就等于没做速查。
· 箱规拼成「40×30×25cm / 12.5kg / 装 24」一句话 —— 报价要的是这一句,
  不是四个分开的数字。
· 点货号或价格可复制;clipboard API 在非 HTTPS 内网下不可用,退回
  execCommand。
· 没占 ⌘K,因为那已经是全局搜索了,抢掉会打断老习惯。
· 页脚写死「价格是采购价,不是报价」防手滑。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-22 15:23:10 +08:00
DrsakuraandClaude Opus 5 f32a2daaef feat(order): 订单支出台账(快递费/吊卡费等杂费)
订单上原本只有「卖给客户多少钱」,而一张单真实花出去的还有寄样品的快递费、
印吊卡的费用、商检报关拖车。不记下来就算不出这单赚没赚 —— 小单子几百块
杂费能把毛利吃掉一截。

· 新表 order_expenses,一张订单挂多笔:类型/金额/币种/日期/收款方/单号/
  已付未付/备注。单独成表而不是往 orders 加列,因为杂费是多笔不定项的,
  寄三次样品就是三条快递费,各有单号;塞进 orders 只能存一个合计,事后
  对不出「这 800 块是哪来的」。

· 跨币种强制填汇率并存快照。杂费基本是人民币、订单常是美元;不存快照的话
  汇率一变历史订单毛利跟着变,账永远对不上。同币种强制按 1,防止前端传个
  7.2 把美元支出又乘一遍。

· 新权限点 order.expense,不复用 order.edit。掏钱寄样品的是销售业务员,
  而销售业务组只有 order.view —— 挂在 order.edit 上要么他记不了账,要么
  为了让他记账得把「改订单金额」一起给他。启动时 syncNewPermissions()
  会自动把这个新点补给四个默认组。

· 订单列表加「杂费」列(合计 + 未付)。SUM 外层再 ROUND 一次:不加会漏出
  348.15999999999997 这种浮点尾巴直接显示在页面上。

· 金额逐行取两位再相加,保证明细几行加起来等于合计。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-22 15:22:56 +08:00
DrsakuraandClaude Opus 5 3e9fd2dc97 fix(customer): 联系人新增与跟进编辑补上归属校验
两个端点都只有 requirePermission('customer.edit'),而那只说明「这个人有权
编辑客户」,没说明「有权编辑**这一条**客户」。实测一个 sales_rep 账号:

  GET   /api/customers/4            → 404(读是隔离的)
  POST  /api/customers/4/contacts   → 201(写进去了)
  PATCH /api/customers/4/follow-ups/12 → 200
        把「客户确认 3000 套订单」改成了「客户取消了订单」

也就是说一个销售能往看不见的客户里插联系人(还能把自己设成主联系人),
还能改写另一个销售客户的跟进历史。

两处都有对照组:紧挨着的 PATCH /contacts 和 POST /follow-ups 一直有这道
检查,所以是漏了而不是设计如此。补上后重放两个利用都是 403,归属人自己
仍然 201/200。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-22 15:22:15 +08:00
Drsakura 691f315341 docs(readme): 功能模块与开发计划对齐代码现状
功能模块:按应用实际的导航结构重写,补上此前完全没提的模块——
系统管理(用户/权限组/系统日志三级授权与审计)、合同清洗中心、
客户公海、采购单管理、报价单与询盘管理、工作台的汇率计算器、
时区时钟与 AI 助手。

开发计划:订正大规模过期状态。Phase 2(SKU 迁移、阶梯价格、合同解析、
产品图片)与 Phase 3 主体均已实现,原文误标为全部未完成;Phase 4 补标
已完成的订单状态管理,保留未实现的订单备注与 PDF 导出;Phase 5 标出
已完成的 AI 智能分析与客户公海线索转化,保留未做的 GoodJob 集成与
数据源适配。商机管道视图注明 deals 表已建但前端界面待实现。
2026-09-18 11:05:05 +08:00
Drsakura 13d67c804f feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
2026-09-17 14:53:02 +08:00
Drsakura 436519e0a6 feat(embed): 合同导入与工作台融成一体(主题/字体/配色/滚动全对齐)
深色外壳里嵌一张米白纸、外加两层滚动条,是「没浑然一体」的根源:

- 宿主把当前主题下的设计令牌 postMessage 给嵌入页(跨源读不到对方的
  CSS 变量),两边共用同一套配色/字体/圆角;切主题即时跟随,不用刷新
  (src 里的 theme 只作首帧初值,避免换主题触发 iframe 重载白闪)
- 收起嵌入页里重复的部件:自带侧栏、顶栏指标条(货号总数/供应商/待核对)、
  右下角「问一句」悬浮球(宿主已有小皮)
- 只留一层滚动:路由区固定为视口高度,滚动交给 iframe 内部
- 输入框沿用宿主的「无方框」规则:去掉描边与焦点光环,改底色区分
- 页面头去掉「新窗口打开」(打开的是裸 SKU 页),刷新改成图标按钮
- 令牌名对不上时会告警,免得嵌入页悄悄回落到旧配色
2026-09-11 12:53:22 +08:00
Drsakura 453f3b1583 fix(header): 顶栏搜索框补 type="text"(最后一个裸 input,避免再掉进浏览器默认样式) 2026-09-11 11:48:07 +08:00
Drsakura fa4dd6482b feat(ui): 助手正名「小皮」+ 全站输入框去掉描边方框
助手改名
- 「小屁」→「小皮」(AI 浮窗标题/按钮/占位文案/工作台引导语 + 注释)

输入框一律无方框(靠底色区分,聚焦换成 primary 底色)
- 根因一:全局选择器写成 input[type="text"] 列表,AI 浮窗输入框没写 type
  完全没吃到样式,渲染成浏览器默认的 2px 灰直角框(就是「丑出天际」那个),
  顺手补上 input:not([type]) 兜底 + 给该输入框补 type="text"
- 根因二:聚焦时同时叠了 input[type] 的 1px 描边 + 3px 光环,以及
  :focus-visible 的 2px 外描边(鼠标点文本框也会命中),一共三层方框
- 覆盖范围:全局 form 控件、顶栏搜索、汇率金额、备忘录添加行、
  日历添加日程、跳转浮层年月输入框、设置里 API Key / 下拉
2026-09-11 11:45:20 +08:00
Drsakura 850abe9327 feat(calendar): 跳转浮层年月改为直接输入,回车快速跳转
- 点开 🗓 后的浮层标题由「弹出拨盘」改为输入框:前 4 位是年、后 1~2 位是月,
  只填 4 位则沿用当前月;回车跳转并关闭编辑态,Esc 取消且不关浮层
- 非数字自动过滤、上限 6 位;月越界或年份越界标红提示且保留焦点
- 进入编辑态时把「2026年9月」换成「202609」并全选,两种触发(鼠标按下/聚焦)都稳
- 工具条上的年月文字仍保留 iOS 拨盘入口
2026-09-11 11:22:04 +08:00
Drsakura 0453a49e4e feat(calendar): 年月拨盘(iOS 闹钟式滚轮)替换工具条的月份文字
- 新增 components/MonthYearWheel.jsx:年、月两列滚轮
  · scroll-snap 吸附 + 指针按住拖动 + 鼠标滚轮/触摸板,停稳 140ms 后落值
  · 滚动过程中实时算居中行,中间行放大加粗高亮、相邻行缩小淡出(像苹果拨盘)
  · 点某一行直接跳到该行;「回到今天」一键回当月
  · 年份范围默认前后 12/20 年,两侧夹在视口内,下方放不下自动翻到上方
- 两个入口:
  · 工具条上的「2026年9月」由 <strong> 换成按钮,点击弹出拨盘,再点一次收起
  · 日期跳转浮层里的标题同样可点:点击后浮层收起、同位弹出拨盘
- 拨盘选完年月,主日历与跳转浮层同步过去

修掉一个竞态:两列在同一帧内先后停稳时,后一次会读到渲染期闭包里的旧值,
把前一次的结果覆盖(滚到 2028年12月 会退回 2026年12月)。
改为组件内用 ref 保存最新年月、每次落值先更新 ref 再回调。

顺带修正跳转浮层标题多出来的一个右花括号。
2026-09-11 11:06:36 +08:00
Drsakura 4c3ec995b6 feat(workspace): 日历再瘦身 + 右侧面板等高填满 + 日期跳转按钮 + 小屁浮窗化
日历
- 单元格 72→60px,日号 19→17px、字号 11→10px,事件条与周标同步收紧,
  标题区 padding 与提示文案一起压小
- 工具条右边的「月」按钮删掉,换成日历小图标按钮:悬浮/展开变蓝(primary 实底),
  点击弹出迷你月历(自带年月切换 + 回到今天),点某天即跳月并选中该日

右侧面板
- 「今日 to do」与「待办事项」改成按列高 58/42 平分,列表自身 flex 撑满并可滚动;
  之前待办被挤到底部只剩一条空文案,现在两块都填满整列
- 跳转浮层 z-index 82,压过小屁浮窗(78);设置遮罩提到 90,保证仍是最高层

AI 助手 → 小屁
- 全屏遮罩 + 右侧整条抽屉删掉,改成右上角 348px 宽浮动小窗:
  可拖动(位置记 localStorage,越界自动夹回视口)、可最小化成小条、双击标题栏切换
- App.jsx 不再因为小屁打开而 inert 主界面(只有设置才是模态),
  所以可以一边让小屁干活一边自己点系统:实测开着窗导航、选日期、填备忘录都正常
- 文案全部改名小屁,顶栏按钮、面板标题、工作台摘要里的引导语一并更新

顺手
- 顶栏搜索的 ⌘K 提示块加 nowrap,1024 宽度下不再折成两行
2026-09-10 23:56:52 +08:00
Drsakura b9d6296591 feat(workspace): 日历瘦身 + 右侧「今日 to do / 选中日待办」双面板
日历
- 行高 92→72px,行程条只留 2 条(超出折成 +N 并压缩成小 chip),标题区与周标同步收窄
- 选中态重做:去掉了原来生硬的 2px 内描边,改成单元格内嵌圆角环 + 淡入缩放动画;
  「今天」改为日号描边圆环,与「选中」的实心日号区分开
- 侧栏宽度不足时(≤1200px)自动改为上下排布

右侧新增两块
- 「今日 to do」:备忘录性质。底部常驻加号输入框,回车即建;建完前面出现勾选框,
  勾选后划线变淡并沉到已完成组末尾(输入框始终在最后一行);可取消勾选回到未完成组;
  悬浮出现删除按钮;列表过长可滚动,勾选后自动滚进视野
- 「待办事项」:显示日历上选中那天的日程与节日(未选中时默认今天),
  带优先级点与说明,右上角「新建」直接为该日添加日程
- 原左栏重复的 [TASK QUEUE] 面板移到这里后删除,避免同名面板出现两次

后端
- todos 增加 list 列(schedule 日历日程 / memo 备忘录待办),老库启动时自动补列
- GET /api/todos 支持 list=memo;POST 支持 list,memo 不写日期
- 新增 PATCH /api/todos/:id(勾选完成/取消、改标题等)与 DELETE /api/todos/:id
2026-09-10 23:35:20 +08:00
Drsakura 33dc71f8c3 feat(calendar): 工作日历选中态 + 再次单击添加日程 + 日程/节日悬浮详情
- 单击日期选中(主题色底 + 内描边环),再次单击同一日期弹出「添加日程」浮层
- 浮层支持标题 / 优先级 / 备注,保存后写库并即时刷新日历与工作台待办
- 悬浮日程或节日显示详情小窗:日程含日期/优先级/状态/客户/备注,
  节日含外贸视角的具体说明(对出货、订舱、客户响应的影响)
- 节日补充春节(2025–2035 逐年查表)、感恩节与黑色星期五
- 超过 3 项时折叠为 +N,悬浮查看剩余条目
- 后端新增 GET /api/todos(按日期范围)与 POST /api/todos
- 浮层用 fixed 定位 + 视口翻转/夹取,渲染后按真实高度校正一次;
  滚动跟随锚点;Esc 与点击外部关闭;日期格支持键盘 Enter/Space
2026-09-10 23:16:29 +08:00
Drsakura 02e985c011 fix(suppliers): 主营类目去重,消除 React 重复 key 警告
splitCats 未去重,源数据里同一类目重复登记时(如「游标卡尺;游标卡尺」)
会渲染出两个 key 相同的 chip。React 会因此报
'Encountered two children with the same key',且该行为官方标注为 unsupported。
在 splitCats 出口处按原顺序去重,既消警告也避免卡片上出现重复标签。

由无头浏览器全站巡检发现(供应商页原本有 22 条此类警告)。
2026-09-10 17:52:56 +08:00
Drsakura 46a963e23d feat(header): 顶栏时钟可切时区 + 汇率计算器;工作台移除运行日期
1) 工作台
   - 删掉欢迎区右侧的「运行日期」卡片,含 luxury.css 里对应样式与窄屏残留

2) 顶栏时钟(仿小满 CRM)
   - 新增 TimeZoneClock:时钟图标 +「东八区:北京,上海 ⌄」+ 大号时间(20px 等宽,
     tabular-nums 保证秒位不跳动),下方小字带日期
   - 点整块展开时区列表,23 个时区由西向东排列,每行右侧实时显示该地时间与 UTC 偏移
   - 时区用 IANA 名(Europe/London 等)而非写死偏移,夏令时自动正确;
     打开列表会自动滚到当前选中项;选择记 localStorage('happy.clockZone')
   - 新增 src/timezones.js 集中时区表与格式化工具

3) 顶栏汇率计算器
   - 新增 FxCalculator:计算器图标点开浮层,默认 USD→CNY
   - 左右两侧各带可搜索的币种下拉(币种来自接口返回的全部币种,常用币种排前),
     中间一键互换;任一框输入另一框实时反算;显示主汇率与倒数汇率
   - 换币种时锁住所改那一侧的数字(与 Google 换算一致)
   - 金额按数量级自动取精度(USD→VND 与 CNY→USD 都不出现一堆无意义的零)
   - 新增 src/currencies.js(币种中文名 + 排序 + 交叉汇率与格式化)

4) 后端汇率接口
   - 新增 /api/fx/rates:以 USD 为基准返回全量汇率,前端据此算任意币种交叉汇率
   - 主源 open.er-api.com(166 币种),备源 frankfurter.app(欧洲央行);
     内存 + 落盘缓存 1 小时,并发请求只发一次网络
   - 网络不可用时依次回退「上次成功的数据」→「内置离线汇率表」,
     并在响应里用 source/warning 标注,前端明确提示数据来源

5) 顶栏排布
   - 时钟变宽后中等屏会溢出,新增 ≤1440px 断点收紧留白/品牌区/搜索框;
     搜索框允许收缩;1280/1100/980/700 各档实测无横向溢出
   - 移动端(≤680px)汇率浮层改用固定定位铺满两侧,避免左侧被裁

6) .gitignore
   - backend/data/ 整体忽略(trade.db、fx-cache.json 等全是运行时产物)
2026-09-10 17:44:36 +08:00
Drsakura 1edf5ac995 feat(nav): 侧栏高对比度 + 顶栏已打开页面标签 + 折叠态只留日夜切换 + 换明月图标
1) 侧边栏对比度
   - 新增 --sb-* 专属色板(固定深色底,不随日夜主题翻转),文字 #c3d7e3、
     hover 纯白、选中纯白 + 品牌色强调条 + 内描边,参考 GoodJob 的 Command Deck
   - 原过渡曲线(--transition-fast/base)保持不变
   - luxury.css 里把侧栏刷回浅底的覆盖一并改为同一套色板

2) 顶栏「已打开页面」标签
   - 新增 src/navigation.js:NAV_GROUPS 与 PAGE_LABELS 单点维护,App 与顶栏共用
   - GlobalHeader 用 useLocation 自动登记访问过的页面,工作台恒定置顶且不可关闭,
     其余按最近使用排序,可单独关闭,最多 10 个,落 localStorage(happy.openTabs)
   - 关掉当前标签会退到剩下的最近一个,没有则回工作台

3) 折叠态页脚
   - .sidebar.collapsed .settings-btn 隐藏,76px 宽度下只留一个日夜切换按钮

4) 明月图标
   - MoonIcon → MoonStarsIcon,造型取自 igoutu.cn/icon/101343/moon-and-stars,
     重绘为矢量(月牙 = 两段圆弧相减,两枚凹边圆角四角星),走 currentColor 随主题变色
   - 源图存 design-refs/(已忽略,不入库)

5) 顶栏排布修正
   - 标签条加入后头部变挤,收窄品牌区/搜索框,按钮加 nowrap 防止文字折行
   - ≤1280px 隐藏标签条
2026-09-10 17:25:38 +08:00
Drsakura d650ff9c4d refactor(layout): 顶栏横跨整宽并压在侧边栏之上,侧栏去标题、按钮移到工作台右侧
- App.jsx: 新增 .app-shell / .app-body 两层结构,GlobalHeader 提到侧边栏之前;
  inert 上移到 .app-shell,行为不变
- 侧边栏删除带 happy出口通 的 .sidebar-header(logo 与名字统一交给顶栏,且更大)
- 折叠/展开按钮从侧栏标题栏移到「工作台」右侧(.sidebar-top),收起时自动落到工作台下方
- 新增 --header-height 变量(80px,≤680px 时 62px),顶栏 min-height 与侧栏 sticky top/
  height、route-content min-height 全部据此计算
- 移除已失效的 .sidebar-header 规则(含 ≤900px 媒体查询)
2026-09-10 17:04:05 +08:00
Drsakura 47f6214056 feat: 品牌标识用云+数据库矢量图标(favicon + 页眉品牌标)
- Icons.jsx 新增 CloudDbIcon:照 icons8-云 重绘为矢量,左云随主题色、右圆柱用品牌蓝
- GlobalHeader 品牌区前置该图标,原「系统在线」绿点改为挂在其右下角
- index.html 增加 rel=icon / mask-icon 指向 public/favicon.svg
- luxury.css 补品牌样式,并让 :not() 选择器排除 .brand-mark 避免样式误伤
2026-09-10 16:58:52 +08:00
Drsakura 5cff29702b chore: 忽略设计参考素材(icons8 厂/云 等),仅本地保留不入库 2026-09-10 16:47:28 +08:00
Drsakura 250d2505d5 fix: tag 回退时更新链接使用原始标签名(v1.0.0,而非清洗后的 1.0.0) 2026-09-10 16:26:08 +08:00
89 changed files with 22234 additions and 1037 deletions
+7 -3
View File
@@ -12,9 +12,9 @@ build/
!.env.example
# ---------- 数据库与运行时数据 ----------
backend/data/*.db
backend/data/*.db-*
backend/data/*.sqlite*
# backend/data/ 放的是运行时产物(trade.db、汇率缓存 fx-cache.json 等),
# 全是本机生成的数据,一律不入库;新环境部署时由服务自动创建。
backend/data/
backend/uploads/
# ---------- 日志 ----------
@@ -33,3 +33,7 @@ Thumbs.db
# ---------- 临时 ----------
tmp/
*.tmp
# ---------- 设计参考素材(仅本地参考,一律不入库) ----------
icons8-*
design-refs/
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+66 -28
View File
@@ -4,24 +4,38 @@
## 功能模块
### 工作台
- 工作日历:日程安排、节日标注、今日待办
- 汇率计算器与时区时钟
- AI 助手(可接入自有模型)
### 业务管理
- **工作台** - 数据汇总、日程提醒、节日显示
- **供应商价格管理** - 继承 SKU Manager 完整功能
- **产品管理** - SKU/SPU 两层管理、包装参数、阶梯价格
- **PI合同管理** - 合同解析、价格历史追踪
- **询盘管理** - 询盘登记、明细与跟进
- **客户报价单管理** - 报价单编制、英文大写金额
- **PI合同管理** - PI 合同台账、收款条件
- **订单管理** - 订单跟踪与状态管理
### 客户管理
- **客户管理** - 客户信息、联系人、跟进记录
- **新客开发** - 自动获客、AI 智能分析
- **商机管理** - 销售管道、成交预测
- **订单管理** - 订单跟踪、备注跟进
- **我的客户** - 客户信息、联系人、跟进记录
- **新客开发** - 线索登记、AI 智能分析
- **客户公海** - 未分配客户的领取与分配
### 采购管理
- **采购单管理** - 采购单登记与跟踪
- **产品管理** - SKU/SPU 两层管理、包装参数、阶梯价格、产品图片
- **供应商管理** - 供应商档案与采购价
- **合同清洗中心** - 合同文件导入、解析与校对
### 系统管理
- **用户管理** - 账号、角色与启停用
- **权限组管理** - 角色 → 权限组 → 权限点三级授权
- **系统日志** - 登录、改密与数据变更审计
## 技术栈
### 后端
- Node.js + Express
- SQLite + better-sqlite3-multiple-ciphers
- 加密存储(SQLCipher)
### 前端
- React 18
@@ -70,7 +84,7 @@ npm run dev
## 项目结构
```
trade-management-system/
happy-export/
├── backend/ # 后端
│ ├── db/ # 数据库
│ │ ├── schema.sql # 数据库结构
@@ -96,48 +110,72 @@ trade-management-system/
└── README.md
```
## 数据库
## 数据库与配置
使用 SQLite + SQLCipher 加密存储:
- 数据库文件: `backend/data/trade.db`
- 自动初始化表结构
SQLite 单文件存储:
- 数据库文件:`backend/data/trade.db`
- 首次启动时自动建表
- 支持备份和迁移
- 数据目录与上传文件已在 `.gitignore` 中排除,不会入库
> **已知限制:数据库当前未加密。** 驱动选用的是 `better-sqlite3-multiple-ciphers`
> (本身具备 SQLCipher 能力),但代码尚未设置加密密钥,`trade.db` 以明文存放。
> 部署到共享主机或不可信环境前,请自行启用磁盘加密。
后端可识别的环境变量(全部有代码默认值,不设置也能跑):
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `DATA_DIR` | `backend/data` | 数据库与汇率缓存所在目录 |
| `PORT` | `4300` | 后端监听端口 |
| `CONTRACT_RETENTION_DAYS` | `7` | 合同导入原文件的保留天数 |
| `HAPPY_UPDATE_REPO` | `Drsakura/happy-export` | 在线更新检查所用的 GitHub 仓库 |
> 注意:后端**没有引入 dotenv**,不会自动读取 `.env` 文件。
> 请通过系统环境变量、启动脚本或容器编排来设置上述变量。
## 开发计划
Phase 1 至 Phase 3 已完成;Phase 4、Phase 5 部分完成,未完成项保留为待办。
### Phase 1:基础框架 ✅
- [x] 项目结构搭建
- [x] 数据库设计
- [x] 前后端基础框架
- [x] 工作台页面
### Phase 2:供应商与产品管理
- [ ] 迁移 SKU Manager 功能
- [ ] 阶梯价格管理
- [ ] 合同解析引擎
- [ ] 产品图片管理
### Phase 2:供应商与产品管理 ✅
- [x] 迁移 SKU Manager 功能
- [x] 阶梯价格管理
- [x] 合同解析引擎
- [x] 产品图片管理
### Phase 3:客户 CRM
- [ ] 客户详细页面
- [ ] 联系人管理
- [ ] 商机管道视图
- [ ] 跟进活动时间线
- [x] 客户详细页面
- [x] 联系人管理
- [x] 跟进活动时间线
- [ ] 商机管道视图(`deals` 表已建,前端界面待实现)
### Phase 4:订单管理
- [ ] 订单创建和编辑
- [ ] 订单状态流转
- [x] 订单创建和编辑
- [x] 订单状态管理
- [ ] 订单备注系统
- [ ] PDF 导出
### Phase 5:自动获客
- [x] AI 智能分析
- [x] 线索转化(客户公海领取)
- [ ] 集成 GoodJob 获客引擎
- [ ] 数据源适配
- [ ] AI 智能分析
- [ ] 线索转化
## 许可证
私有项目
本项目采用 [GNU Affero 通用公共许可证 v3.0](LICENSE)(AGPL-3.0)。
你可以自由使用、修改和分发,但需要遵守 AGPL-3.0 的核心义务:
**如果你修改后的版本通过网络对外提供服务,必须向使用者公开你的完整源代码**,
并以同样的许可证授权。完整的条款见 [LICENSE](LICENSE)。
## 作者
-1
View File
@@ -1 +0,0 @@
+346 -1
View File
@@ -16,9 +16,25 @@ const db = new Database(DB_PATH);
// 启用外键约束
db.pragma('foreign_keys = ON');
// 性能优化
/* ---------------------------------------------------------------------------
* 并发与性能配置 —— 多人共用这套库的命脉,全部显式声明,不依赖驱动默认值。
*
* journal_mode=WAL 读写互不阻塞。这是多用户能同时用的前提,
* 否则一个写操作会锁住全库所有的读。
* synchronous=NORMAL 配合 WAL 的平衡点:断电最多丢最后一个事务,
* 但不会损坏数据库文件。
* busy_timeout=5000 写冲突时排队等 5 秒再报错,而不是立刻抛 SQLITE_BUSY。
* ⚠️ 这一项此前一直是 5000,但那是 better-sqlite3 的
* 驱动默认值——一旦换驱动、或哪天默认值变了,
* 「多人同时保存」就会开始直接报错。必须显式写死。
* 单次写事务只需 1–5ms,5 秒足够排完几十人的队列。
*
* mmap_size 刻意不设:本库走 SQLCipher 加密路径,内存映射在加密库上
* 历史有兼容性坑,而库只有几 MB,收益远小于风险。
* --------------------------------------------------------------------------- */
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('busy_timeout = 5000');
// 初始化数据库结构
function initDatabase() {
@@ -36,6 +52,230 @@ if (!tableExists) {
initDatabase();
}
// ---------------------------------------------------------------------------
// 轻量迁移:schema.sql 只对新库生效,老库缺列在这里补。
// todos.list —— 'schedule' 日历日程 / 'memo' 今日待办备忘录(备忘录不带日期)
// ---------------------------------------------------------------------------
function ensureColumns() {
const todoColumns = db.pragma('table_info(todos)').map(column => column.name);
if (!todoColumns.includes('list')) {
db.exec("ALTER TABLE todos ADD COLUMN list TEXT NOT NULL DEFAULT 'schedule'");
console.log('✓ 迁移:todos.list 列已补齐');
}
// 老数据库不会重新执行 schema.sql,这里只补新增表和新增列,不改动已有数据。
db.exec(`
CREATE TABLE IF NOT EXISTS inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_no TEXT NOT NULL UNIQUE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
inquiry_date TEXT NOT NULL,
title TEXT, status TEXT NOT NULL DEFAULT 'draft', currency TEXT DEFAULT 'USD',
source TEXT, notes TEXT, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS inquiry_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_id INTEGER NOT NULL REFERENCES inquiries(id), sku TEXT NOT NULL,
product_name TEXT, supplier_id INTEGER REFERENCES suppliers(id), supplier_name TEXT,
purchase_price REAL NOT NULL DEFAULT 0, currency TEXT NOT NULL DEFAULT 'CNY',
exchange_rate REAL NOT NULL DEFAULT 1, markup_rate REAL NOT NULL DEFAULT 0,
markup_factor REAL NOT NULL DEFAULT 1, exw REAL NOT NULL DEFAULT 0,
quantity INTEGER NOT NULL DEFAULT 1, packaging_snapshot TEXT, product_snapshot TEXT,
quoted_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id), activity_type TEXT NOT NULL,
content TEXT NOT NULL, next_action TEXT, next_action_date TEXT,
contact_id INTEGER REFERENCES contacts(id), inquiry_id INTEGER REFERENCES inquiries(id),
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS product_packaging (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE REFERENCES products(sku), inner_pack_qty INTEGER, carton_qty INTEGER,
carton_length REAL, carton_width REAL, carton_height REAL, carton_weight REAL,
unit_length REAL, unit_width REAL, unit_height REAL, unit_weight REAL,
packaging_material TEXT, notes TEXT, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_no TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'queued',
file_count INTEGER NOT NULL DEFAULT 0, source_files TEXT,
parse_method TEXT NOT NULL DEFAULT 'rules', rule_version TEXT NOT NULL DEFAULT 'mvp-v1',
notes TEXT, created_at TEXT NOT NULL, updated_at TEXT, completed_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES contract_import_jobs(id), filename TEXT NOT NULL,
row_no INTEGER, raw_text TEXT, sku TEXT, product_name TEXT, purchase_price REAL,
currency TEXT, quantity INTEGER, status TEXT NOT NULL DEFAULT 'pending_review',
review_note TEXT, reviewed_by INTEGER REFERENCES users(id), reviewed_at TEXT,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_inquiries_customer ON inquiries(customer_id);
CREATE INDEX IF NOT EXISTS idx_inquiries_status_date ON inquiries(status, inquiry_date);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_inquiry ON inquiry_items(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_sku ON inquiry_items(sku);
CREATE INDEX IF NOT EXISTS idx_follow_up_customer_date ON follow_up_records(customer_id, next_action_date);
CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku);
CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status);
/* 组织与权限组(多用户阶段):老库补表。organizations 必须建在 users 补列之前,
因为下面会给 users 加带外键的 org_id 列。 */
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, code TEXT, description TEXT,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, description TEXT, is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
permission_key TEXT NOT NULL, UNIQUE (group_id, permission_key)
);
CREATE TABLE IF NOT EXISTS role_permission_groups (
role TEXT PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES permission_groups(id), updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_pg_permissions_group ON permission_group_permissions(group_id);
/* 产品目录本地化(SKU 模块并入后):小推车 + 二进制存储。老库补表。
图片与合同原件单独成表,而不是给 product_images / contracts 加 BLOB 列 ——
那两张表都用 SELECT * 查,二进制挂在同一行会让每次列表查询都把整张图读进内存。 */
CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE, qty INTEGER, note TEXT,
sort INTEGER DEFAULT 0, added_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS product_image_files (
image_id INTEGER PRIMARY KEY, bytes BLOB NOT NULL, size INTEGER, stored_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_files (
contract_id INTEGER PRIMARY KEY, filename TEXT, mime TEXT, size INTEGER,
sha256 TEXT, bytes BLOB NOT NULL, stored_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_cart_items_sort ON cart_items(sort);
/* 订单支出(业务员记的杂费:快递费、吊卡费、商检、报关、拖车、打样…)。
单独一张表而不是往 orders 加几列 —— 一笔订单的杂费是多笔不定项的,
寄三次样品就是三条快递费,各自有单号和日期;塞进 orders 只能存一个合计,
事后对不出「这 800 块是哪来的」。
rate_to_order 是「记账当时」的汇率快照:快递费是人民币、订单可能是美元,
不存快照的话汇率一变,历史订单的毛利会跟着变,账永远对不上。 */
CREATE TABLE IF NOT EXISTS order_expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
category TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
rate_to_order REAL NOT NULL DEFAULT 1,
spent_at TEXT,
payee TEXT,
supplier_id INTEGER REFERENCES suppliers(id),
voucher_no TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
note TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_order_expenses_order ON order_expenses(order_id);
CREATE INDEX IF NOT EXISTS idx_order_expenses_date ON order_expenses(spent_at);
`);
const columnMigrations = {
inquiries: [
['inquiry_no', "TEXT NOT NULL DEFAULT ''"], ['customer_id', 'INTEGER'], ['inquiry_date', "TEXT NOT NULL DEFAULT ''"],
['title', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'draft'"], ['currency', "TEXT DEFAULT 'USD'"], ['source', 'TEXT'],
['notes', 'TEXT'], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
inquiry_items: [
['inquiry_id', 'INTEGER'], ['sku', "TEXT NOT NULL DEFAULT ''"], ['product_name', 'TEXT'], ['supplier_id', 'INTEGER'],
['supplier_name', 'TEXT'], ['purchase_price', 'REAL NOT NULL DEFAULT 0'], ['currency', "TEXT NOT NULL DEFAULT 'CNY'"],
['exchange_rate', 'REAL NOT NULL DEFAULT 1'], ['markup_rate', 'REAL NOT NULL DEFAULT 0'], ['markup_factor', 'REAL NOT NULL DEFAULT 1'],
['exw', 'REAL NOT NULL DEFAULT 0'], ['quantity', 'INTEGER NOT NULL DEFAULT 1'], ['packaging_snapshot', 'TEXT'],
['product_snapshot', 'TEXT'], ['quoted_at', "TEXT NOT NULL DEFAULT ''"], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
follow_up_records: [
['customer_id', 'INTEGER'], ['activity_type', "TEXT NOT NULL DEFAULT 'note'"], ['content', "TEXT NOT NULL DEFAULT ''"],
['next_action', 'TEXT'], ['next_action_date', 'TEXT'], ['contact_id', 'INTEGER'], ['inquiry_id', 'INTEGER'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
product_packaging: [
['sku', "TEXT NOT NULL DEFAULT ''"], ['inner_pack_qty', 'INTEGER'], ['carton_qty', 'INTEGER'], ['carton_length', 'REAL'],
['carton_width', 'REAL'], ['carton_height', 'REAL'], ['carton_weight', 'REAL'], ['unit_length', 'REAL'], ['unit_width', 'REAL'],
['unit_height', 'REAL'], ['unit_weight', 'REAL'], ['packaging_material', 'TEXT'], ['notes', 'TEXT'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
contract_import_jobs: [
['job_no', "TEXT NOT NULL DEFAULT ''"], ['status', "TEXT NOT NULL DEFAULT 'queued'"], ['file_count', 'INTEGER NOT NULL DEFAULT 0'],
['source_files', 'TEXT'], ['parse_method', "TEXT NOT NULL DEFAULT 'rules'"], ['rule_version', "TEXT NOT NULL DEFAULT 'mvp-v1'"],
// column_map 存本次认列结果(字段→列号/表头/得分):解析不对时要能自证认了哪几列
['column_map', 'TEXT'], ['parse_source', "TEXT DEFAULT 'rules'"],
['notes', 'TEXT'], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'], ['completed_at', 'TEXT']
],
contract_import_items: [
['job_id', 'INTEGER'], ['filename', "TEXT NOT NULL DEFAULT ''"], ['row_no', 'INTEGER'], ['raw_text', 'TEXT'], ['sku', 'TEXT'],
['product_name', 'TEXT'], ['spec', 'TEXT'], ['purchase_price', 'REAL'], ['currency', 'TEXT'], ['quantity', 'INTEGER'],
['moq', 'INTEGER'], ['parse_source', "TEXT DEFAULT 'rules'"],
['status', "TEXT NOT NULL DEFAULT 'pending_review'"], ['review_note', 'TEXT'], ['reviewed_by', 'INTEGER'], ['reviewed_at', 'TEXT'],
// 入库留痕:committed_at 记入库时间,product_sku 记实际落库货号(撞号会是 -COPY2)
['committed_at', 'TEXT'], ['product_sku', 'TEXT'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
// 报价单明细的成本口径快照(老库补列)
quotation_items: [
['purchase_price', 'REAL'], ['exchange_rate', 'REAL'], ['markup_rate', 'REAL']
],
// 报价单条款结构化字段(老库补列)。payment_terms / delivery_terms 继续存可读文本。
customer_quotations: [
['pi_no', 'TEXT'], ['payment_method', 'TEXT'], ['deposit_percent', 'REAL'], ['balance_percent', 'REAL'],
['delivery_method', 'TEXT'], ['delivery_port', 'TEXT'], ['validity_days', 'INTEGER'], ['internal_notes', 'TEXT']
],
// 个人资料与组织归属(老库补列)
users: [
['position', 'TEXT'], ['phone', 'TEXT'], ['address', 'TEXT'], ['avatar', 'TEXT'],
['org_id', 'INTEGER'], ['updated_at', 'TEXT']
],
// 产品目录本地化(SKU 模块并入):上传者 / 归属组织 / 撞号副本来源,以及调价人(老库补列)
products: [
['uploader', 'TEXT'], ['org_id', 'INTEGER'], ['duplicated_from', 'TEXT']
],
price_history: [
['changed_by', 'TEXT']
],
// 客户成交状态(新客开发页按 pending / won / lost 分栏)+ 归属与公海(老库补列)
customers: [
['deal_status', "TEXT DEFAULT 'pending'"], ['org_id', 'INTEGER'],
// created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕
['created_by', 'INTEGER'],
// pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领)
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT']
]
};
for (const [table, columns] of Object.entries(columnMigrations)) {
const existing = new Set(db.pragma(`table_info(${table})`).map(column => column.name));
for (const [name, definition] of columns) {
if (!existing.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
}
/* settings 是键值表,权限点自动补齐的台账记在这里;老库万一没有也兜一下 */
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)');
/* 依赖「补列之后才存在」的索引,放在这里建 */
db.exec('CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id)');
/* 客户列表现在是「按归属过滤」,owner_id / pool_status 都得有索引 */
db.exec('CREATE INDEX IF NOT EXISTS idx_customers_owner ON customers(owner_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_customers_pool ON customers(pool_status)');
}
ensureColumns();
// 创建默认管理员账号(如果不存在)
function createDefaultAdmin() {
const crypto = require('crypto');
@@ -61,4 +301,109 @@ function createDefaultAdmin() {
createDefaultAdmin();
// ---------------------------------------------------------------------------
// 组织与权限组的开箱数据:只在首次运行写入,之后一律以库里的为准。
// 老库(已有 admin 用户)也会走这里补默认组织与默认权限组。
// ---------------------------------------------------------------------------
function seedAccessControl() {
const now = new Date().toISOString();
const { DEFAULT_PERMISSION_GROUPS } = require('../lib/permissions');
/* 默认组织:让存量用户立刻有归属,「同组织」口径从一开始就成立 */
let org = db.prepare('SELECT id FROM organizations ORDER BY id LIMIT 1').get();
if (!org) {
const result = db.prepare('INSERT INTO organizations (name, code, description, created_at) VALUES (?, ?, ?, ?)')
.run('happy出口通', 'HAPPY', '默认组织 —— 团队共享的产品/客户资源以此为单位', now);
org = { id: result.lastInsertRowid };
console.log('✓ 默认组织已创建:happy出口通');
}
db.prepare('UPDATE users SET org_id = ?, updated_at = ? WHERE org_id IS NULL').run(org.id, now);
/* 默认权限组 + 角色指派 */
const groupCount = db.prepare('SELECT COUNT(*) AS c FROM permission_groups').get().c;
if (groupCount === 0) {
const insertGroup = db.prepare('INSERT INTO permission_groups (name, description, is_system, created_at) VALUES (?, ?, ?, ?)');
const insertPermission = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
const assignRole = db.prepare('INSERT OR REPLACE INTO role_permission_groups (role, group_id, updated_at) VALUES (?, ?, ?)');
for (const group of DEFAULT_PERMISSION_GROUPS) {
const id = insertGroup.run(group.name, group.description, group.is_system, now).lastInsertRowid;
for (const key of group.permissions) insertPermission.run(id, key);
for (const role of group.roles) assignRole.run(role, id, now);
}
console.log('✓ 默认权限组与角色指派已创建');
}
syncNewPermissions();
adoptOrphanCustomers(org.id, now);
}
/**
* 新增权限点的自动补齐。
*
* 权限组只在「首次建库」时按 DEFAULT_PERMISSION_GROUPS 灌一遍,之后库里的勾选状态
* 就是唯一事实 —— 所以后续版本新增的权限点,老库永远不会自动拿到(典型症状:
* 升级后某个功能对所有人都是「没有权限」)。这里把「全库都不认识的 key」
* 按默认配置补进对应组,补过就记在 settings 台账里,之后绝不再碰:
* Wayne 手工取消勾选的动作不会被这段代码在下次启动时倒回去。
*/
function syncNewPermissions() {
const { DEFAULT_PERMISSION_GROUPS, PERMISSION_KEYS } = require('../lib/permissions');
const LEDGER = 'perm_autogrant';
let granted = [];
try {
granted = JSON.parse(db.prepare('SELECT value FROM settings WHERE key = ?').get(LEDGER)?.value || '[]');
} catch {
granted = [];
}
const grantedSet = new Set(Array.isArray(granted) ? granted : []);
const known = new Set(
db.prepare('SELECT DISTINCT permission_key FROM permission_group_permissions').all()
.map(row => row.permission_key)
);
/* 本版本新增的 = 代码里有、但全库任何组都没拥有过、也没被自动补过 */
const fresh = PERMISSION_KEYS.filter(key => !known.has(key) && !grantedSet.has(key));
if (!fresh.length) return;
const groupByName = db.prepare('SELECT id FROM permission_groups WHERE name = ?');
const insert = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
const added = [];
for (const group of DEFAULT_PERMISSION_GROUPS) {
const row = groupByName.get(group.name);
if (!row) continue;
for (const key of group.permissions) {
if (!fresh.includes(key)) continue;
insert.run(row.id, key);
added.push(`${group.name}:${key}`);
}
}
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run(LEDGER, JSON.stringify([...grantedSet, ...fresh]));
if (added.length) console.log(`✓ 新增权限点已补齐:${added.join(', ')}`);
}
/**
* 存量客户归属回填。
*
* 「谁建的谁看得见」这条一旦生效,老库里 owner_id 为空的客户就会变成
* 「除了最高级别管理账号谁都看不见」—— 升个级把老板的客户弄丢是不可接受的。
* 所以统一认给最早的系统管理员,org 也一并补上。
*/
function adoptOrphanCustomers(orgId, now) {
const orphans = db.prepare('SELECT COUNT(*) AS c FROM customers WHERE owner_id IS NULL').get().c;
if (!orphans) return;
const owner = db.prepare("SELECT id, org_id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
if (!owner) return;
db.prepare(`UPDATE customers
SET owner_id = ?,
created_by = COALESCE(created_by, ?),
org_id = COALESCE(org_id, ?),
updated_at = COALESCE(updated_at, ?)
WHERE owner_id IS NULL`).run(owner.id, owner.id, owner.org_id || orgId, now);
console.log(`✓ 存量客户归属回填:${orphans} 条 → 用户 #${owner.id}`);
}
seedAccessControl();
module.exports = db;
+263
View File
@@ -51,6 +51,11 @@ CREATE TABLE IF NOT EXISTS products (
last_updated TEXT,
source_contract TEXT,
confidence TEXT DEFAULT 'high',
-- 上传者 / 归属组织 / 撞号副本来源:产品库是组织共享的,谁都能传自己那一版,
-- 「谁传的、复制自谁」必须可追,见 lib/skuCatalog.js 的 insertItem。
uploader TEXT,
org_id INTEGER,
duplicated_from TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
@@ -110,6 +115,8 @@ CREATE TABLE IF NOT EXISTS price_history (
supplier_id INTEGER REFERENCES suppliers(id),
source_contract TEXT,
confidence TEXT,
-- 谁改的价:报价单里的采购价要能追到出处,和 insertItem 的手工留痕同源
changed_by TEXT,
changed_at TEXT NOT NULL
);
@@ -142,6 +149,39 @@ CREATE TABLE IF NOT EXISTS product_images (
created_at TEXT NOT NULL
);
/* 小推车:产品页里挑出来的货号暂存区,出报价单前的中转台。
全局唯一一份(不按用户分车)—— 这是当前口径,改成分人分车要同时动前端。 */
CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
qty INTEGER,
note TEXT,
sort INTEGER DEFAULT 0,
added_at TEXT NOT NULL
);
/* ---------------------------------------------------------------------------
* 二进制存储:图片与合同原件存进库,不摊在磁盘上。
* 单独成表而不是给 product_images / contracts 加 BLOB 列 —— 那两张表都用
* SELECT * 查,把二进制挂在同一行会让每次列表查询都把整张图读进内存。
* --------------------------------------------------------------------------- */
CREATE TABLE IF NOT EXISTS product_image_files (
image_id INTEGER PRIMARY KEY REFERENCES product_images(id),
bytes BLOB NOT NULL,
size INTEGER,
stored_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_files (
contract_id INTEGER PRIMARY KEY REFERENCES contracts(id),
filename TEXT,
mime TEXT,
size INTEGER,
sha256 TEXT,
bytes BLOB NOT NULL,
stored_at TEXT
);
-- ================== 客户管理(CRM) ==================
CREATE TABLE IF NOT EXISTS customers (
@@ -156,7 +196,17 @@ CREATE TABLE IF NOT EXISTS customers (
email TEXT,
rating TEXT,
status TEXT DEFAULT 'active',
/* 开发结果:pending 未成交 / won 已成交 / lost 已流失(新客开发页按此分栏) */
deal_status TEXT DEFAULT 'pending',
/* 归属:谁建的归谁,默认只有归属人看得见;创建人主动丢进公海才对同组织开放 */
owner_id INTEGER REFERENCES users(id),
/* created_by 单独记「谁建的」—— 公海被领取后 owner_id 会换人,创建人这一笔仍要留痕 */
created_by INTEGER REFERENCES users(id),
org_id INTEGER REFERENCES organizations(id),
/* pool_status:owned 名下有主 / public 已丢入公海(同组织可见、可领取) */
pool_status TEXT DEFAULT 'owned',
pool_at TEXT,
pool_note TEXT,
source TEXT,
tags TEXT,
notes TEXT,
@@ -225,6 +275,7 @@ CREATE TABLE IF NOT EXISTS todos (
due_date TEXT,
priority TEXT DEFAULT 'medium',
status TEXT DEFAULT 'pending',
list TEXT NOT NULL DEFAULT 'schedule',
customer_id INTEGER REFERENCES customers(id),
deal_id INTEGER REFERENCES deals(id),
order_id INTEGER REFERENCES orders(id),
@@ -274,10 +325,20 @@ CREATE TABLE IF NOT EXISTS customer_quotations (
quote_date TEXT NOT NULL,
valid_until TEXT,
currency TEXT DEFAULT 'USD',
-- 条款结构化字段:payment_terms / delivery_terms 仍保留可读文本,供老页面直接展示
pi_no TEXT,
payment_method TEXT,
deposit_percent REAL,
balance_percent REAL,
delivery_method TEXT,
delivery_port TEXT,
validity_days INTEGER,
payment_terms TEXT,
delivery_terms TEXT,
status TEXT DEFAULT 'draft',
notes TEXT,
-- 系统备注:只给内部看,报价单对外不显示
internal_notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT
@@ -294,6 +355,10 @@ CREATE TABLE IF NOT EXISTS quotation_items (
unit_price REAL NOT NULL,
total_price REAL NOT NULL,
currency TEXT DEFAULT 'USD',
-- 成本口径快照:报价单发出去之后,供应商再调价也不能让这张单的历史毛利变样
purchase_price REAL,
exchange_rate REAL,
markup_rate REAL,
notes TEXT,
created_at TEXT NOT NULL
);
@@ -338,20 +403,63 @@ CREATE TABLE IF NOT EXISTS prospect_leads (
-- ================== 系统管理 ==================
-- 组织(团队):产品/客户的归属单位,「同组织的人均可参考」以它为边界。
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT,
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
email TEXT,
/* role = 权限角色(董事长 / 总经理 / 销售经理 / 销售业务员 …),也是「角色 → 权限组」映射的键 */
role TEXT DEFAULT 'user',
/* position = 对外展示的职位称谓,纯文字,与角色解耦 */
position TEXT,
phone TEXT,
address TEXT,
avatar TEXT,
org_id INTEGER REFERENCES organizations(id),
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT,
last_login_at TEXT,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
-- 权限组:一套命名的权限范围。角色被指派进某个组,即获得该组的全部权限点。
CREATE TABLE IF NOT EXISTS permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT
);
-- 权限组 → 权限点(permission_key 由后端 PERMISSIONS 清单定义)
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
permission_key TEXT NOT NULL,
UNIQUE (group_id, permission_key)
);
-- 角色 → 权限组(一个角色同时只属于一个组,改指派即换组)
CREATE TABLE IF NOT EXISTS role_permission_groups (
role TEXT PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
@@ -400,3 +508,158 @@ CREATE INDEX IF NOT EXISTS idx_prospect_leads_run ON prospect_leads(run_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at);
-- ================== 询盘与报价主链路 ==================
CREATE TABLE IF NOT EXISTS inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_no TEXT NOT NULL UNIQUE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
inquiry_date TEXT NOT NULL,
title TEXT,
status TEXT NOT NULL DEFAULT 'draft',
currency TEXT DEFAULT 'USD',
source TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS inquiry_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_id INTEGER NOT NULL REFERENCES inquiries(id),
sku TEXT NOT NULL,
product_name TEXT,
supplier_id INTEGER REFERENCES suppliers(id),
supplier_name TEXT,
purchase_price REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
exchange_rate REAL NOT NULL DEFAULT 1,
markup_rate REAL NOT NULL DEFAULT 0,
markup_factor REAL NOT NULL DEFAULT 1,
exw REAL NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
packaging_snapshot TEXT,
product_snapshot TEXT,
quoted_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id),
activity_type TEXT NOT NULL,
content TEXT NOT NULL,
next_action TEXT,
next_action_date TEXT,
contact_id INTEGER REFERENCES contacts(id),
inquiry_id INTEGER REFERENCES inquiries(id),
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS product_packaging (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE REFERENCES products(sku),
inner_pack_qty INTEGER,
carton_qty INTEGER,
carton_length REAL,
carton_width REAL,
carton_height REAL,
carton_weight REAL,
unit_length REAL,
unit_width REAL,
unit_height REAL,
unit_weight REAL,
packaging_material TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_no TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'queued',
file_count INTEGER NOT NULL DEFAULT 0,
source_files TEXT,
parse_method TEXT NOT NULL DEFAULT 'rules',
rule_version TEXT NOT NULL DEFAULT 'mvp-v1',
-- column_map:本次认列结果(字段 → 列号/表头/得分),出问题时唯一能自证的现场
column_map TEXT,
-- parse_source:这份结果是怎么来的 —— rules(字符规则)/ ai(模型解析)
parse_source TEXT DEFAULT 'rules',
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT,
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES contract_import_jobs(id),
filename TEXT NOT NULL,
row_no INTEGER,
raw_text TEXT,
sku TEXT,
product_name TEXT,
spec TEXT,
purchase_price REAL,
currency TEXT,
quantity INTEGER,
moq INTEGER,
status TEXT NOT NULL DEFAULT 'pending_review',
review_note TEXT,
parse_source TEXT DEFAULT 'rules',
reviewed_by INTEGER REFERENCES users(id),
reviewed_at TEXT,
-- committed_at / product_sku:入库留痕 —— 什么时候进的库、进成了哪个货号
-- (撞号时实际落库的是 -COPY2,光看 sku 列看不出来)
committed_at TEXT,
product_sku TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_inquiries_customer ON inquiries(customer_id);
CREATE INDEX IF NOT EXISTS idx_inquiries_status_date ON inquiries(status, inquiry_date);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_inquiry ON inquiry_items(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_sku ON inquiry_items(sku);
CREATE INDEX IF NOT EXISTS idx_follow_up_customer_date ON follow_up_records(customer_id, next_action_date);
CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku);
CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status);
-- ---------------------------------------------------------------------------
-- 订单支出(业务员自己记的杂费:快递费、吊卡费、商检、报关、拖车、打样…)
--
-- 为什么单独一张表而不是往 orders 加几列:一笔订单的杂费是**多笔、不定项**的,
-- 寄三次样品就是三条快递费,各自有单号和日期。塞进 orders 只能存一个合计,
-- 事后对不出「这 800 块是哪来的」。
--
-- 币种是关键:快递费、吊卡费都是人民币,订单却可能是美元。所以每笔支出带自己的
-- currency,外加一个 rate_to_order —— **下单当时的汇率快照**。不存快照的话,
-- 三个月后汇率变了,历史订单的毛利会跟着变,账就永远对不上。
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS order_expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
category TEXT NOT NULL, -- 快递费 / 吊卡费 / 商检费 / 报关费 / 拖车费 / 打样费 / 唛头印刷 / 其他
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
-- 支出币种 → 订单币种的汇率快照;同币种为 1
rate_to_order REAL NOT NULL DEFAULT 1,
spent_at TEXT, -- 发生日期 YYYY-MM-DD
payee TEXT, -- 收款方:顺丰 / 某印刷厂
supplier_id INTEGER REFERENCES suppliers(id),
voucher_no TEXT, -- 快递单号 / 发票号,事后核对用
is_paid INTEGER NOT NULL DEFAULT 0,
note TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_order_expenses_order ON order_expenses(order_id);
CREATE INDEX IF NOT EXISTS idx_order_expenses_date ON order_expenses(spent_at);
+195
View File
@@ -0,0 +1,195 @@
/**
* 常用大模型接口预设(单一事实来源)。
*
* 口径(Wayne 2026-09-15):
* - 别人部署这套系统后,应该「选一个预设 → 填 key → 测通 → 拉模型 → 分配用途」四步搞定,
* 而不是让他自己去查 base_url 和兼容模式;
* - 预设只是**便利清单**,不是白名单:所有字段都能手改(自定义预设就是空模板);
* - 模型清单同理:能拉就拉真实的,拉不到就用这里的推荐值兜底。
*
* mode 的含义(= 界面上的「接口兼容模式」):
* openai 走 OpenAI 风格 `POST {base}/chat/completions` + `GET {base}/models`
* anthropic 走 Claude Messages API `POST {base}/messages` + `GET {base}/models`
*/
/**
* 两种协议的 Base URL 写法**不一样**,界面上的示例要分别给(Wayne 2026-09-15):
* 本系统是「Base URL + 固定后缀」的拼法,所以用户填的东西必须写到能接后缀的那一层:
* openai → {base}/chat/completions、{base}/models
* anthropic → {base}/messages、{base}/models
* 最容易踩的坑是照抄 Anthropic SDK 文档里的 base_url —— SDK 会自己补 /v1,本系统不补。
*/
const MODES = [
{
value: 'openai',
label: 'OpenAI 兼容',
hint: '大多数国内厂商与本地推理服务都兼容这套路由',
base_placeholder: 'https://api.deepseek.com/v1',
base_suffix: '/chat/completions',
/* 界面上的示例说明:写清楚「填到哪一层」+「结尾长什么样因厂商而异」 */
base_hint: '只填到服务根路径,系统会在后面自动拼 /chat/completions 与 /models。'
+ '结尾不一定是 /v1 —— DeepSeek、Kimi 是 /v1,火山方舟是 /api/v3,智谱是 /api/paas/v4,照服务商文档填即可。',
base_examples: [
{ label: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
{ label: '火山方舟', url: 'https://ark.cn-beijing.volces.com/api/v3' }
]
},
{
value: 'anthropic',
label: 'Anthropic Messages',
hint: 'Claude 原生协议的 /messages 路由',
base_placeholder: 'https://api.anthropic.com/v1',
base_suffix: '/messages',
/* ⚠️ 这条是本轮要讲清楚的区别:SDK 文档的 base_url 少了 /v1,直接抄会 404 */
base_hint: '填的这一层后面会拼 /messages(不是 /chat/completions),所以必须带上 /v1:'
+ '官方是 https://api.anthropic.com/v1,Kimi 是 https://api.moonshot.cn/anthropic/v1,'
+ '智谱是 https://open.bigmodel.cn/api/anthropic/v1。'
+ '注意各家 SDK 文档里的 base_url 通常写到 /anthropic 为止(由 SDK 补 /v1),本系统不补,别直接照抄。',
base_examples: [
{ label: 'Claude 官方', url: 'https://api.anthropic.com/v1' },
{ label: 'Kimi', url: 'https://api.moonshot.cn/anthropic/v1' },
{ label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/anthropic/v1' }
]
}
]
/** 模型用途:一个用途 = 系统里一处需要智能能力的地方,各自可挂不同模型 */
const PURPOSES = [
{ key: 'assistant_chat', label: '智能助手对话', hint: '右上角小皮的问答与指令理解', vision: false },
{ key: 'contract_parse', label: '合同智能解析', hint: '合同清洗中心抽取品名、价格、账期等字段', vision: false },
{ key: 'ocr', label: '图片 / 扫描件识别', hint: 'PDF 扫描件与图片资料,需要多模态模型', vision: true },
{ key: 'customer_profile', label: '客户画像分析', hint: '辅助判断客户跟进优先级', vision: false },
{ key: 'quote_suggest', label: '报价建议', hint: '基于历史成交价给出报价参考', vision: false }
]
const PRESETS = [
{
key: 'openai',
label: 'OpenAI',
base_url: 'https://api.openai.com/v1',
mode: 'openai',
key_hint: 'sk-...',
docs: 'https://platform.openai.com/api-keys',
note: '官方直连,国内网络通常需要自备代理',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o4-mini']
},
{
key: 'anthropic',
label: 'Anthropic Claude',
base_url: 'https://api.anthropic.com/v1',
mode: 'anthropic',
key_hint: 'sk-ant-...',
docs: 'https://console.anthropic.com/settings/keys',
note: '长文档与合同条款理解表现好',
models: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-3-5-haiku-latest']
},
{
/* 和下面的 Kimi(OpenAI 兼容)是两个不同的地址,放一起就是为了让对比一眼可见 */
key: 'moonshot-anthropic',
label: '月之暗面 Kimi · Anthropic 协议',
base_url: 'https://api.moonshot.cn/anthropic/v1',
mode: 'anthropic',
key_hint: 'sk-...',
docs: 'https://platform.moonshot.cn/console/api-keys',
note: '同一个 Kimi key,但地址与下面那条「Kimi」不一样:这条是 /anthropic/v1(Claude 协议),'
+ '那条是 /v1(OpenAI 协议),填错会 404',
models: ['kimi-k2-0905-preview', 'moonshot-v1-128k']
},
{
key: 'zhipu-anthropic',
label: '智谱 GLM · Anthropic 协议',
base_url: 'https://open.bigmodel.cn/api/anthropic/v1',
mode: 'anthropic',
key_hint: 'xxxxx.xxxxx',
docs: 'https://open.bigmodel.cn/usercenter/apikeys',
note: '同一家的 Anthropic 兼容入口是 /api/anthropic/v1,OpenAI 兼容是 /api/paas/v4',
models: ['glm-4-plus', 'glm-4-air']
},
{
key: 'deepseek',
label: 'DeepSeek 深度求索',
base_url: 'https://api.deepseek.com/v1',
mode: 'openai',
key_hint: 'sk-...',
docs: 'https://platform.deepseek.com/api_keys',
note: '性价比高,中文业务理解稳定',
models: ['deepseek-chat', 'deepseek-reasoner']
},
{
key: 'moonshot',
label: '月之暗面 Kimi',
base_url: 'https://api.moonshot.cn/v1',
mode: 'openai',
key_hint: 'sk-...',
docs: 'https://platform.moonshot.cn/console/api-keys',
note: '长上下文,适合整份合同通读',
models: ['kimi-k2-0905-preview', 'moonshot-v1-32k', 'moonshot-v1-128k']
},
{
key: 'qwen',
label: '阿里通义千问(DashScope 兼容模式)',
base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
mode: 'openai',
key_hint: 'sk-...',
docs: 'https://bailian.console.aliyun.com/',
note: 'qwen-vl 系列可用于图片识别',
models: ['qwen-max', 'qwen-plus', 'qwen-turbo', 'qwen-vl-max']
},
{
key: 'zhipu',
label: '智谱 GLM',
base_url: 'https://open.bigmodel.cn/api/paas/v4',
mode: 'openai',
key_hint: 'xxxxx.xxxxx',
docs: 'https://open.bigmodel.cn/usercenter/apikeys',
note: 'glm-4v 支持图片输入',
models: ['glm-4-plus', 'glm-4-air', 'glm-4-flash', 'glm-4v-plus']
},
{
key: 'doubao',
label: '火山方舟(豆包)',
base_url: 'https://ark.cn-beijing.volces.com/api/v3',
mode: 'openai',
key_hint: '火山方舟 API Key',
docs: 'https://console.volcengine.com/ark',
note: '模型名要填方舟上的「推理接入点 ID」(ep-开头),或直接填模型名',
models: []
},
{
key: 'openrouter',
label: 'OpenRouter(聚合网关)',
base_url: 'https://openrouter.ai/api/v1',
mode: 'openai',
key_hint: 'sk-or-...',
docs: 'https://openrouter.ai/keys',
note: '一个 key 打通多家模型,适合先试后定',
models: []
},
{
key: 'ollama',
label: '本地 Ollama',
base_url: 'http://127.0.0.1:11434/v1',
mode: 'openai',
key_hint: '本地服务通常留空',
docs: 'https://ollama.com/download',
note: '完全离线,数据不出本机;密钥可留空',
needs_key: false,
models: ['qwen2.5:7b', 'qwen2.5:14b', 'llama3.1:8b', 'deepseek-r1:7b']
},
{
key: 'custom',
label: '自定义 / 私有部署',
base_url: '',
mode: 'openai',
key_hint: '按你的网关要求填写',
docs: '',
note: '任何兼容 OpenAI 或 Anthropic 协议的网关都可以接',
models: []
}
]
const PRESET_MAP = new Map(PRESETS.map(item => [item.key, item]));
const PURPOSE_KEYS = PURPOSES.map(item => item.key);
module.exports = { MODES, PRESETS, PRESET_MAP, PURPOSES, PURPOSE_KEYS };
+487
View File
@@ -0,0 +1,487 @@
/**
* 多模型接入:模型服务商的存取、连通性测试、模型清单拉取与按用途调用。
*
* 存储(settings 键值表,跟权限点台账同一张表):
* ai.providers JSON 数组,一家服务商一条
* ai.assignments JSON 对象,用途 → { provider_id, model }
*
* 安全口径:
* - api_key 明文存在本机 SQLite 里,**永远不出现在任何 HTTP 响应中**(一律打码);
* - 上游返回的错误文本会先过一遍脱敏,避免把 key 回显给浏览器。
*
* 这里只依赖 Node 内置 fetch(Node 18+),不引第三方 SDK —— 各家协议就两种,
* 引 SDK 反而会让「换个私有网关」变得麻烦。
*/
const db = require('../db/db');
const { PRESET_MAP, PURPOSE_KEYS } = require('./aiPresets');
const KEY_PROVIDERS = 'ai.providers';
const KEY_ASSIGNMENTS = 'ai.assignments';
const TEST_TIMEOUT_MS = 12000;
const CHAT_TIMEOUT_MS = 90000;
/* ------------------------------------------------------------------ 存取 */
function readJson(key, fallback) {
try {
const raw = db.prepare('SELECT value FROM settings WHERE key = ?').get(key)?.value;
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return parsed ?? fallback;
} catch {
return fallback;
}
}
function writeJson(key, value) {
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, JSON.stringify(value));
}
function listProviders() {
const list = readJson(KEY_PROVIDERS, []);
return Array.isArray(list) ? list : [];
}
function saveProviders(list) {
writeJson(KEY_PROVIDERS, list);
}
function getProvider(id) {
return listProviders().find(item => item.id === String(id)) || null;
}
function listAssignments() {
const map = readJson(KEY_ASSIGNMENTS, {});
return map && typeof map === 'object' && !Array.isArray(map) ? map : {};
}
function saveAssignments(map) {
writeJson(KEY_ASSIGNMENTS, map);
}
/* ------------------------------------------------------------------ 打码 */
function maskKey(key) {
const value = String(key || '');
if (!value) return '';
if (value.length <= 8) return '••••';
return `${value.slice(0, 4)}••••${value.slice(-4)}`;
}
/** 把上游报错里的密钥抹掉再往外抛 —— 有些网关会把请求头原样回显 */
function sanitize(text, key) {
let out = String(text || '');
if (key) out = out.split(String(key)).join('••••');
return out.slice(0, 600);
}
/** 对外输出:密钥只给「有没有」和「长什么样」,绝不给原值 */
function publicProvider(provider) {
const preset = PRESET_MAP.get(provider.preset) || null;
return {
id: provider.id,
name: provider.name,
preset: provider.preset,
preset_label: preset?.label || '自定义',
base_url: provider.base_url,
mode: provider.mode,
models: Array.isArray(provider.models) ? provider.models : [],
enabled: provider.enabled !== false,
note: provider.note || '',
has_key: Boolean(provider.api_key),
key_masked: maskKey(provider.api_key),
last_test: provider.last_test || null,
created_at: provider.created_at || null,
updated_at: provider.updated_at || null
};
}
/* ------------------------------------------------------------------ 写入 */
function badRequest(message) {
return Object.assign(new Error(message), { statusCode: 400 });
}
function normalizeBaseUrl(input) {
const value = String(input || '').trim().replace(/\/+$/, '');
if (!value) throw badRequest('接口地址不能为空');
let url;
try {
url = new URL(value);
} catch {
throw badRequest('接口地址格式不正确,需要以 http:// 或 https:// 开头');
}
if (!/^https?:$/.test(url.protocol)) throw badRequest('接口地址只支持 http 与 https');
return value;
}
function normalizeMode(input) {
const value = String(input || 'openai').trim();
if (!['openai', 'anthropic'].includes(value)) throw badRequest('接口兼容模式只能是 openai 或 anthropic');
return value;
}
function normalizeModels(input) {
if (input === undefined) return undefined;
if (!Array.isArray(input)) throw badRequest('模型清单格式不正确');
return [...new Set(input.map(item => String(item).trim()).filter(Boolean))].slice(0, 200);
}
/**
* 新建 / 更新一家服务商。
* api_key 的三种输入:
* 省略或传 undefined → 保持原值(编辑时前端不回传明文)
* 传空串 → 清空密钥(本地 Ollama 这类不需要密钥)
* 传新值 → 覆盖
*/
function upsertProvider(input, id) {
const list = listProviders();
const now = new Date().toISOString();
const existing = id ? list.find(item => item.id === String(id)) : null;
if (id && !existing) throw badRequest('该模型接入不存在');
const presetKey = String(input.preset || existing?.preset || 'custom').trim();
const preset = PRESET_MAP.get(presetKey) || null;
const mode = normalizeMode(input.mode || existing?.mode || preset?.mode || 'openai');
const baseUrl = input.base_url === undefined && !existing
? normalizeBaseUrl(preset?.base_url)
: normalizeBaseUrl(input.base_url === undefined ? existing.base_url : input.base_url);
const name = String(input.name ?? existing?.name ?? preset?.label ?? '未命名接入').trim();
if (!name) throw badRequest('接入名称不能为空');
if (name.length > 40) throw badRequest('接入名称不能超过 40 个字');
let apiKey = existing?.api_key || '';
if (input.api_key !== undefined) {
const value = String(input.api_key ?? '').trim();
/* 前端拿到的是打码值,万一被原样回传就当没改 */
apiKey = value.includes('••••') ? apiKey : value;
}
const models = normalizeModels(input.models);
const next = {
id: existing?.id || `p_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
name,
preset: preset?.key || 'custom',
base_url: baseUrl,
mode,
api_key: apiKey,
models: models === undefined ? (existing?.models || preset?.models || []) : models,
enabled: input.enabled === undefined ? (existing?.enabled !== false) : Boolean(input.enabled),
note: String(input.note ?? existing?.note ?? '').trim().slice(0, 200),
last_test: existing?.last_test || null,
created_at: existing?.created_at || now,
updated_at: now
};
const nextList = existing
? list.map(item => (item.id === existing.id ? next : item))
: [...list, next];
saveProviders(nextList);
return next;
}
function removeProvider(id) {
const list = listProviders();
const hit = list.find(item => item.id === String(id));
if (!hit) throw badRequest('该模型接入不存在');
saveProviders(list.filter(item => item.id !== hit.id));
/* 引用它的用途分配一并清掉,避免留下指向空气的配置 */
const assignments = listAssignments();
let touched = 0;
for (const purpose of Object.keys(assignments)) {
if (assignments[purpose]?.provider_id === hit.id) {
delete assignments[purpose];
touched += 1;
}
}
if (touched) saveAssignments(assignments);
return { removed: publicProvider(hit), cleared_assignments: touched };
}
function rememberTest(id, result) {
const list = listProviders();
const next = list.map(item => (item.id === String(id)
? { ...item, last_test: result, updated_at: new Date().toISOString() }
: item));
saveProviders(next);
}
/* ------------------------------------------------------------------ 调用 */
/** 统一的 HTTP 出口:超时、错误文本脱敏、JSON 解析兜底都在这里 */
async function requestJson(url, { method = 'POST', headers = {}, body, timeoutMs = TEST_TIMEOUT_MS, apiKey = '' } = {}) {
const started = Date.now();
let response;
try {
response = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs)
});
} catch (error) {
const reason = error?.name === 'TimeoutError'
? `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
: `无法连接:${sanitize(error?.cause?.code || error?.message || '网络错误', apiKey)}`;
return { ok: false, status: 0, latencyMs: Date.now() - started, error: reason };
}
const latencyMs = Date.now() - started;
const text = await response.text().catch(() => '');
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
const upstream = data?.error?.message || data?.message || data?.error || text || `HTTP ${response.status}`;
return {
ok: false,
status: response.status,
latencyMs,
error: sanitize(typeof upstream === 'string' ? upstream : JSON.stringify(upstream), apiKey),
data
};
}
return { ok: true, status: response.status, latencyMs, data, text };
}
function buildHeaders(provider, extra = {}) {
const headers = { 'content-type': 'application/json', ...extra };
const key = provider.api_key;
if (provider.mode === 'anthropic') {
headers['anthropic-version'] = '2023-06-01';
if (key) headers['x-api-key'] = key;
} else if (key) {
headers.authorization = `Bearer ${key}`;
}
return headers;
}
/** 探测用的最小对话请求:一条 user 消息、只要 1 个 token */
function probeBody(provider, model) {
if (provider.mode === 'anthropic') {
return { model, max_tokens: 16, messages: [{ role: 'user', content: 'ping' }] };
}
return { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 16, temperature: 0, stream: false };
}
function chatPath(provider) {
return provider.mode === 'anthropic'
? `${provider.base_url}/messages`
: `${provider.base_url}/chat/completions`;
}
/** 从各家返回体里把正文抠出来 */
function extractChatText(provider, data) {
if (!data) return '';
if (provider.mode === 'anthropic') {
const blocks = Array.isArray(data.content) ? data.content : [];
return blocks.filter(block => block?.type === 'text').map(block => block.text).join('').trim();
}
const message = data.choices?.[0]?.message;
if (!message) return '';
if (typeof message.content === 'string') return message.content.trim();
/* 有些网关把 content 拆成数组(含 reasoning 之类),这里只取文本段 */
if (Array.isArray(message.content)) {
return message.content.filter(part => part?.type === 'text' || typeof part === 'string')
.map(part => (typeof part === 'string' ? part : part.text)).join('').trim();
}
return '';
}
/**
* 连通性测试。
* 策略:优先发一条最小对话(最能说明「这个 key 能不能真的用」);
* 若该模型不可用再退一步只拉模型清单。
*/
async function testProvider(provider) {
const model = (provider.models || [])[0];
if (!model) {
const models = await fetchModels(provider);
if (!models.ok) return { ok: false, at: new Date().toISOString(), latency_ms: models.latencyMs, detail: models.error, mode: provider.mode };
return {
ok: true,
at: new Date().toISOString(),
latency_ms: models.latencyMs,
detail: `接口可访问,读到 ${models.models.length} 个模型(未配置模型名,跳过对话测试)`,
mode: provider.mode
};
}
const result = await requestJson(chatPath(provider), {
method: 'POST',
headers: buildHeaders(provider),
body: probeBody(provider, model),
timeoutMs: TEST_TIMEOUT_MS,
apiKey: provider.api_key
});
if (!result.ok) {
return {
ok: false,
at: new Date().toISOString(),
latency_ms: result.latencyMs,
detail: `模型 ${model} 调用失败:${result.error}`,
mode: provider.mode,
status: result.status
};
}
const text = extractChatText(provider, result.data);
return {
ok: true,
at: new Date().toISOString(),
latency_ms: result.latencyMs,
detail: `模型 ${model} 响应正常${text ? `(返回 ${text.slice(0, 20)})` : ''}`,
mode: provider.mode,
model
};
}
/** 拉模型清单;协议不支持或没权限时回退到预设里的推荐清单 */
async function fetchModels(provider) {
const result = await requestJson(`${provider.base_url}/models`, {
method: 'GET',
headers: buildHeaders(provider),
timeoutMs: TEST_TIMEOUT_MS,
apiKey: provider.api_key
});
const preset = PRESET_MAP.get(provider.preset);
if (!result.ok) {
const fallback = preset?.models || [];
if (fallback.length) {
return {
ok: true,
source: 'preset',
latencyMs: result.latencyMs,
models: fallback,
detail: `该服务未开放模型列表接口(${result.error}),已回退到预设推荐清单`
};
}
return { ok: false, source: 'none', latencyMs: result.latencyMs, models: [], error: result.error };
}
const rows = Array.isArray(result.data?.data) ? result.data.data
: Array.isArray(result.data?.models) ? result.data.models
: Array.isArray(result.data) ? result.data : [];
const models = [...new Set(rows.map(row => String(row?.id || row?.name || row || '').trim()).filter(Boolean))];
if (models.length) return { ok: true, source: 'api', latencyMs: result.latencyMs, models, detail: `读取到 ${models.length} 个模型` };
const fallback = preset?.models || [];
return {
ok: fallback.length > 0,
source: fallback.length ? 'preset' : 'none',
latencyMs: result.latencyMs,
models: fallback,
detail: fallback.length ? '接口未返回模型清单,已回退到预设推荐' : '接口未返回任何模型',
error: fallback.length ? undefined : '接口未返回任何模型'
};
}
/** 按用途找「用哪家、哪个模型」 */
function resolveAssignment(purpose) {
const assignment = listAssignments()[purpose];
if (!assignment?.provider_id || !assignment?.model) return null;
const provider = getProvider(assignment.provider_id);
if (!provider || provider.enabled === false) return null;
return { provider, model: assignment.model };
}
/**
* 真正的对话调用(按用途自动选模型)。
* 未配置时返回 null,调用方据此回退到本地规则逻辑。
*/
async function chatByPurpose(purpose, messages, options = {}) {
const resolved = resolveAssignment(purpose);
if (!resolved) return null;
const { provider, model } = resolved;
const result = await requestJson(chatPath(provider), {
method: 'POST',
headers: buildHeaders(provider),
body: {
model,
messages: provider.mode === 'anthropic'
? messages.filter(message => message.role !== 'system')
: messages,
...(provider.mode === 'anthropic'
? {
max_tokens: options.maxTokens || 1024,
...(messages.find(message => message.role === 'system')
? { system: messages.find(message => message.role === 'system').content }
: {})
}
: {
max_tokens: options.maxTokens || 1024,
temperature: options.temperature === undefined ? 0.3 : options.temperature,
stream: false
})
},
timeoutMs: options.timeoutMs || CHAT_TIMEOUT_MS,
apiKey: provider.api_key
});
if (!result.ok) {
return {
ok: false,
provider: provider.name,
provider_id: provider.id,
model,
error: result.error,
status: result.status
};
}
return {
ok: true,
provider: provider.name,
provider_id: provider.id,
model,
text: extractChatText(provider, result.data),
latency_ms: result.latencyMs,
usage: result.data?.usage || null
};
}
/** 概览:给界面显示「接了几家、分配了几个用途」 */
function overview() {
const providers = listProviders();
const assignments = listAssignments();
const assigned = PURPOSE_KEYS.filter(key => {
const item = assignments[key];
return item?.provider_id && item?.model && providers.some(p => p.id === item.provider_id && p.enabled !== false);
});
const healthy = providers.filter(p => p.last_test?.ok);
return {
provider_count: providers.length,
enabled_count: providers.filter(p => p.enabled !== false).length,
healthy_count: healthy.length,
assigned_purposes: assigned,
last_test_at: healthy.map(p => p.last_test?.at).sort().pop() || null
};
}
module.exports = {
MASK_PLACEHOLDER: '••••',
listProviders,
getProvider,
publicProvider,
upsertProvider,
removeProvider,
listAssignments,
saveAssignments,
resolveAssignment,
testProvider,
fetchModels,
chatByPurpose,
rememberTest,
maskKey,
overview,
PURPOSE_KEYS
};
+227
View File
@@ -0,0 +1,227 @@
/**
* 鉴权与会话(多用户阶段)。
*
* 设计取舍:
* - 密码哈希沿用 db.js 建默认管理员时的格式 `scrypt$salt$hash`,不引第三方依赖
* (本机没有 bcrypt,也不想去碰原生模块编译),Node 内置 crypto.scryptSync 足够。
* - 会话复用已有的 sessions 表,浏览器侧只放一个 HttpOnly cookie(happy_sid),
* 不把用户信息塞进 token,避免改资料后前端还拿着旧值。
* - 权限一律「角色 → 权限组 → 权限点」三级推导,用户表不直接存权限。
*/
const crypto = require('crypto');
const db = require('../db/db');
const { PERMISSION_KEYS } = require('./permissions');
const COOKIE_NAME = 'happy_sid';
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_FAILED = 5;
const LOCK_MS = 15 * 60 * 1000;
/* ---------------------------------------------------------------- 密码 */
function hashPassword(plain) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(String(plain), salt, 64).toString('hex');
return `scrypt$${salt}$${hash}`;
}
function verifyPassword(plain, stored) {
const parts = String(stored || '').split('$');
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
const [, salt, expected] = parts;
let actual;
try {
actual = crypto.scryptSync(String(plain), salt, 64).toString('hex');
} catch {
return false;
}
const a = Buffer.from(actual, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/* ---------------------------------------------------------------- cookie */
function parseCookies(header) {
const out = {};
if (!header) return out;
for (const piece of String(header).split(';')) {
const index = piece.indexOf('=');
if (index < 0) continue;
const key = piece.slice(0, index).trim();
if (!key) continue;
try {
out[key] = decodeURIComponent(piece.slice(index + 1).trim());
} catch {
out[key] = piece.slice(index + 1).trim();
}
}
return out;
}
function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) {
res.append('Set-Cookie', [
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`
].join('; '));
}
function clearSessionCookie(res) {
res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
function clientIp(req) {
const raw = req?.headers?.['x-forwarded-for'] || req?.socket?.remoteAddress || '';
return String(raw).split(',')[0].trim().replace(/^::ffff:/, '') || null;
}
/* ---------------------------------------------------------------- 会话 */
function createSession(userId, req) {
const now = new Date();
const id = crypto.randomBytes(32).toString('hex');
db.prepare(`INSERT INTO sessions (id, user_id, created_at, last_seen_at, expires_at, user_agent, ip)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
id, userId, now.toISOString(), now.toISOString(),
new Date(now.getTime() + SESSION_TTL_MS).toISOString(),
String(req?.headers?.['user-agent'] || '').slice(0, 200), clientIp(req)
);
/* 顺手清掉这把用户的过期会话,避免 sessions 表无限增长 */
db.prepare('DELETE FROM sessions WHERE user_id = ? AND expires_at < ?').run(userId, now.toISOString());
return id;
}
function destroySession(sessionId) {
if (sessionId) db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
}
/** 从请求里解出当前用户(含 session id),未登录返回 null */
function sessionUser(req) {
const sessionId = parseCookies(req?.headers?.cookie)[COOKIE_NAME];
if (!sessionId) return null;
const row = db.prepare(`
SELECT u.*, s.id AS sid, s.expires_at AS session_expires_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id = ?
`).get(sessionId);
if (!row) return null;
if (new Date(row.session_expires_at).getTime() < Date.now()) {
destroySession(sessionId);
return null;
}
if (!row.is_active) return null;
return row;
}
function touchSession(sessionId) {
/* 每小时落一次 last_seen_at 就够,避免每个请求都写库 */
try {
db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ? AND last_seen_at < ?')
.run(new Date().toISOString(), sessionId, new Date(Date.now() - 3600 * 1000).toISOString());
} catch { /* 非关键路径 */ }
}
/* ---------------------------------------------------------------- 权限 */
function permissionsOfRole(role) {
if (!role) return [];
if (role === 'admin') return PERMISSION_KEYS.slice();
return db.prepare(`
SELECT p.permission_key AS key
FROM role_permission_groups r
JOIN permission_group_permissions p ON p.group_id = r.group_id
WHERE r.role = ?
`).all(role).map(row => row.key);
}
function permissionGroupOfRole(role) {
if (!role) return null;
return db.prepare(`
SELECT g.id, g.name, g.description
FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id
WHERE r.role = ?
`).get(role) || null;
}
/** 输出给前端的用户对象:绝不带 password_hash */
function publicUser(row) {
if (!row) return null;
const org = row.org_id
? db.prepare('SELECT id, name, code FROM organizations WHERE id = ?').get(row.org_id)
: null;
return {
id: row.id,
username: row.username,
display_name: row.display_name || row.username,
email: row.email || '',
role: row.role || '',
position: row.position || '',
phone: row.phone || '',
address: row.address || '',
avatar: row.avatar || '',
is_active: !!row.is_active,
created_at: row.created_at,
last_login_at: row.last_login_at,
org: org || null,
permission_group: permissionGroupOfRole(row.role),
permissions: permissionsOfRole(row.role)
};
}
/* ---------------------------------------------------------------- 中间件 */
function attachUser(req, res, next) {
const user = sessionUser(req);
req.user = user;
if (user) touchSession(user.sid);
next();
}
function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: '请先登录' });
next();
}
function requirePermission(key) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: '请先登录' });
const granted = req.user.role === 'admin' || permissionsOfRole(req.user.role).includes(key);
if (!granted) return res.status(403).json({ error: '当前账号没有该操作权限' });
next();
};
}
/* ---------------------------------------------------------------- 审计 */
function audit({ username, action, detail, req, ok = 1 }) {
try {
db.prepare(`INSERT INTO audit_log (at, username, action, detail, ip, user_agent, ok)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
new Date().toISOString(), username || null, action, detail || null,
clientIp(req), String(req?.headers?.['user-agent'] || '').slice(0, 200), ok ? 1 : 0
);
} catch { /* 审计是旁路,失败不能影响业务 */ }
}
module.exports = {
COOKIE_NAME,
SESSION_TTL_MS,
MAX_FAILED,
LOCK_MS,
hashPassword,
verifyPassword,
parseCookies,
setSessionCookie,
clearSessionCookie,
clientIp,
createSession,
destroySession,
sessionUser,
permissionsOfRole,
permissionGroupOfRole,
publicUser,
attachUser,
requireAuth,
requirePermission,
audit
};
+71
View File
@@ -0,0 +1,71 @@
/**
* 系统标识与组织名称(单一事实来源)。
*
* 口径(Wayne 2026-09-15 定,别再改回去):
* - **系统名是作者固定署名,不可修改**:登录页、顶栏、浏览器标题一律显示
* SYSTEM.name,且**不跟随组织名** —— 这套系统会被别人拿去部署,
* 把牌子固定在作者名下是刻意保留的来源标识(引流)。
* - **组织名(公司名)可以改**:它只是业务字段,决定「同组织」的资源边界、
* 以及权限组/用户下拉里的显示;改组织名**不会**影响界面上的系统名。
* - 之前那套 `settings['brand.name'] / brand.subtitle` 覆盖已作废,
* 历史遗留的这两个键即使还在库里也不再被读取。
*/
const db = require('../db/db');
/** 系统标识:作者固定署名,界面一律用它;不读 settings,不跟随组织名 */
const SYSTEM = {
name: 'happy出口通',
subtitle: 'Ai Export System'
};
/** 第一个组织视为「主组织」:开箱数据只有一个,组织名以它为准 */
function primaryOrg() {
try {
return db.prepare('SELECT id, name, code, description FROM organizations ORDER BY id LIMIT 1').get() || null;
} catch {
return null;
}
}
/**
* 给前端用的标识信息。
* 故意做得轻:登录页、顶栏、浏览器标题都会调它。
* name / subtitle 恒为固定值;org_* 只是顺带给设置页展示「当前公司名」。
*/
function getBranding() {
const org = primaryOrg();
return {
/* 界面牌子:固定 */
name: SYSTEM.name,
subtitle: SYSTEM.subtitle,
/* 前端据此把「系统标识」渲染成只读、不提供输入框 */
system_locked: true,
/* 公司/组织名单独给出:设置页要能看见它当前是什么(这个是可改的) */
org_name: org?.name || '',
org_id: org?.id ?? null
};
}
/** 改组织名(顺带把 code / 描述也支持了,组织与品牌设置页要用) */
function updateOrg(id, { name, code, description } = {}) {
const row = db.prepare('SELECT * FROM organizations WHERE id = ?').get(Number(id));
if (!row) throw Object.assign(new Error('组织不存在'), { statusCode: 400 });
const nextName = name === undefined ? row.name : String(name || '').trim();
if (!nextName) throw Object.assign(new Error('组织名称不能为空'), { statusCode: 400 });
if (nextName.length > 40) throw Object.assign(new Error('组织名称不能超过 40 个字'), { statusCode: 400 });
const clash = db.prepare('SELECT id FROM organizations WHERE name = ? AND id <> ?').get(nextName, row.id);
if (clash) throw Object.assign(new Error('已存在同名组织'), { statusCode: 400 });
db.prepare('UPDATE organizations SET name = ?, code = ?, description = ? WHERE id = ?').run(
nextName,
code === undefined ? row.code : (String(code || '').trim() || null),
description === undefined ? row.description : (String(description || '').trim() || null),
row.id
);
return db.prepare('SELECT id, name, code, description FROM organizations WHERE id = ?').get(row.id);
}
module.exports = { SYSTEM, getBranding, updateOrg, primaryOrg };
+165
View File
@@ -0,0 +1,165 @@
/**
* 合同原件的磁盘留存 + 到期清理。
*
* 为什么放磁盘而不是继续用 `contract_files` 的 BLOB:
* 留原件的唯一目的是「复核那几天能回去核对一眼」,7 天后就要删掉省 VPS 空间。
* 而 SQLite 删掉 BLOB 后空间并不会还给系统(要 VACUUM 整库重写才还),
* 与「省空间」的目标正好相反。磁盘文件删掉就是删掉。
*
* 目录结构:<DATA_DIR>/contracts/<job_no>/<清洗过的原文件名>
* —— 用 job_no 分目录,清理时按目录整删即可,不需要额外记路径(也就不需要加字段)。
*
* ⚠️ 安全:目录名与文件名都来自用户,必须防越权路径。
* job_no 由服务端生成(CIMP-数字-数字),文件名统一过 sanitizeFileName,
* 拼好之后再校验 resolved 路径确实落在 contracts 根目录之内。
*/
const fs = require('fs');
const path = require('path');
const RETENTION_DAYS = Number(process.env.CONTRACT_RETENTION_DAYS || 7);
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
const ROOT = path.join(DATA_DIR, 'contracts');
/** 清洗文件名:去掉路径分隔与控制字符,压掉长度,保留中文 */
function sanitizeFileName(name) {
const base = path.basename(String(name || '')).replace(/[\u0000-\u001f\u007f]/g, '').trim();
const safe = base.replace(/[\\/:*?"<>|]/g, '_').replace(/^\.+/, '');
const trimmed = safe.slice(0, 120) || 'contract';
return trimmed;
}
/** 防越权:任何对外暴露的路径都要过这一关 */
function resolveInside(jobNo, fileName) {
const dir = path.join(ROOT, String(jobNo || '').replace(/[^\w-]/g, ''));
const target = path.join(dir, sanitizeFileName(fileName));
const rootResolved = path.resolve(ROOT);
const targetResolved = path.resolve(target);
if (targetResolved !== rootResolved && !targetResolved.startsWith(rootResolved + path.sep)) return null;
return targetResolved;
}
/**
* 保存一份原件。失败不抛 —— 原件只是辅助材料,解析结果才是主产物,
* 不能因为磁盘满了就把整次导入判失败。
*/
function saveOriginal(jobNo, file) {
try {
if (!jobNo || !file?.buffer?.length) return null;
const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, ''));
fs.mkdirSync(dir, { recursive: true });
// 同名文件加序号后缀,避免后来者覆盖前一个
const wanted = sanitizeFileName(file.originalname);
let target = path.join(dir, wanted);
let seq = 1;
const ext = path.extname(wanted);
const stem = path.basename(wanted, ext);
while (fs.existsSync(target)) {
seq += 1;
target = path.join(dir, `${stem}-${seq}${ext}`);
}
fs.writeFileSync(target, file.buffer);
return { name: path.basename(target), size: file.buffer.length };
} catch {
return null;
}
}
/** 列出某个任务还留着的原件 */
function listOriginals(jobNo) {
try {
const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, ''));
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).map(name => {
const stat = fs.statSync(path.join(dir, name));
return { name, size: stat.size, stored_at: stat.mtime.toISOString() };
});
} catch {
return [];
}
}
/** 取某一份原件(下载用)。不存在返回 null —— 界面据此显示「已过期清理」。 */
function readOriginal(jobNo, fileName) {
const target = resolveInside(jobNo, fileName);
if (!target) return null;
try {
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return null;
return { path: target, name: path.basename(target), buffer: fs.readFileSync(target) };
} catch {
return null;
}
}
/**
* 清理过期原件。
*
* 到期判定用**任务创建时间**(库里查得到的权威时间),不用目录 mtime ——
* mtime 会被「同 job 再传一次文件」刷新,导致该清的清不掉。
* 库里查不到对应任务的孤儿目录,按目录 mtime 兜底删。
*/
function purgeExpired(db, retentionDays = RETENTION_DAYS) {
const removed = [];
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
let dirs = [];
try {
if (!fs.existsSync(ROOT)) return { removed, scanned: 0 };
dirs = fs.readdirSync(ROOT).filter(name => {
try { return fs.statSync(path.join(ROOT, name)).isDirectory(); } catch { return false; }
});
} catch {
return { removed, scanned: 0 };
}
for (const jobNo of dirs) {
const dir = path.join(ROOT, jobNo);
let expired = false;
try {
const job = db.prepare('SELECT created_at FROM contract_import_jobs WHERE job_no = ?').get(jobNo);
const stamp = job?.created_at ? Date.parse(job.created_at) : fs.statSync(dir).mtimeMs;
expired = Number.isFinite(stamp) && stamp < cutoffMs;
} catch {
expired = false;
}
if (!expired) continue;
try {
fs.rmSync(dir, { recursive: true, force: true });
removed.push(jobNo);
} catch {
/* 删不掉(占着句柄等)下一轮再试,不影响服务 */
}
}
return { removed, scanned: dirs.length };
}
/**
* 启动时清一次 + 之后每 6 小时一次。
* 用 unref() 让定时器不阻止进程退出(否则 nodemon 重启会挂着)。
*/
function startPurgeSchedule(db, logger = console) {
const run = () => {
try {
const { removed } = purgeExpired(db);
if (removed.length) logger.log(`[contracts] 已清理 ${removed.length} 个到期的合同原件(保留 ${RETENTION_DAYS} 天)`);
} catch (error) {
logger.error?.('[contracts] 清理合同原件失败:', error.message);
}
};
run();
const timer = setInterval(run, 6 * 60 * 60 * 1000);
timer.unref?.();
return timer;
}
module.exports = {
RETENTION_DAYS,
ROOT,
sanitizeFileName,
saveOriginal,
listOriginals,
readOriginal,
purgeExpired,
startPurgeSchedule
};
+609
View File
@@ -0,0 +1,609 @@
/**
* 通用表格解析引擎:把「供应商合同 / 报价单」这类文件认成结构化的行。
*
* 设计目标(三条,缺一不可):
*
* 1. **按字符认列,不按行列位置。**
* 规则只描述「这一列的标题大概长什么样」(关键词 / 正则),不写死「第 3 列是价格」。
* 同一份表加一列、调一下顺序,识别结果不变。
*
* 2. **只采纳「标题对得上、且值确实是数值」的列。**
* 数值类字段(采购价 / 数量 / 起订量)在认列之后还要过一道**列级体检**:
* 这一列的非空值里能解析成数字的比例达标,才允许采纳。
* 这一条是「通用」的关键 —— 它挡住的不是错别字,而是「品名里带个『价』字」
* 这类只看标题一定会踩的坑。
*
* 3. **通用、不绑定行业。**
* 默认真名词典按「语义」组织(中/英 + 常见缩写),词典里**不允许出现任何行业专有词**
* (五金、灯具、化工……一概不写)。换一个行业只是换个文件,不是改代码。
* 词典本身也写成**数据**:可在 `settings['contract.rules']` 或
* `DATA_DIR/contract-rules.json` 里增删关键词、排除词、阈值,覆盖默认值。
*
* 表头不在第一行也能认:标题行 / 公司抬头 / 日期这些常常占掉前几行,
* 所以会**在前若干行里挑「规则命中最多」的那一行当表头**,而不是无脑取第 1 行。
*/
const path = require('path');
const fs = require('fs');
const db = require('../db/db');
let XLSX = null;
try {
// 读 xlsx/xls 用;放在 try 里,没装也不至于整个服务起不来
XLSX = require('xlsx');
} catch {
XLSX = null;
}
const SETTINGS_KEY = 'contract.rules';
const DEFAULT_OPTIONS = {
/** 表头最多往下找几行 */
header_search_rows: 12,
/** 认定「这行是表头」至少要有几个字段命中 */
header_min_hits: 2,
/** 认列的最低分(低于此分视为没认出来) */
min_score: 45,
/** 数值列体检:能解析成数字的比例下限 */
min_numeric_ratio: 0.6,
/** 数值列体检最多抽几行 */
numeric_sample: 40
};
/* ------------------------------------------------------------------ 词典 */
/**
* 默认规则。
*
* 约定:
* type text / number —— number 的列要过数值体检
* keys 命中即得分的关键词(中英混排,不分大小写)
* exclude 硬排除:命中就直接判定「这一列不是这个字段」
* regex 额外的正则特征(写字符串,用 /.../i 形式)
*
* ⚠️ 这里的词必须是**通用词**。加词前先问:换个行业还成立吗?
*/
const DEFAULT_FIELDS = {
sku: {
label: '货号',
type: 'text',
keys: [
'sku', 'item code', 'item no', 'item number', 'product code', 'product no', 'product number',
'part no', 'part number', 'model no', 'model number', 'article no', 'article number',
'style no', 'style number', 'catalog no', 'cat no', 'ref no', 'reference',
'code', 'model', 'article', 'product id', 'item id',
'货号', '产品编号', '产品编码', '产品货号', '商品编号', '物料编号', '物料编码', '物料号',
'型号', '型号规格', '编号', '款号', '图号', '零件号', '存货编码', '存货编号'
],
exclude: ['数量', '价', 'price', 'cost', 'amount', '名称', 'name', 'qty', 'quantity']
},
product_name: {
label: '品名',
type: 'text',
keys: [
'product name', 'item name', 'goods name', 'commodity', 'product description',
'item description', 'description', 'product', 'item', 'title', 'goods',
'品名', '产品名称', '商品名称', '货物名称', '产品描述', '商品描述',
'品名规格', '名称', '描述', '品名及规格', '产品'
],
exclude: ['货号', '编号', '编码', 'code', 'no.', '数量', '价', 'price', 'amount', 'qty', '规格型号']
},
spec: {
label: '规格',
type: 'text',
keys: [
'spec', 'specification', 'specifications', 'size', 'dimension', 'dimensions',
'material', 'model spec', 'type',
'规格', '规格型号', '尺寸', '材质', '参数', '规格参数', '规格描述'
],
exclude: ['货号', '编号', '价', 'price', '数量', 'qty', 'amount', '名称']
},
purchase_price: {
label: '采购价',
type: 'number',
keys: [
'purchase price', 'buying price', 'cost price', 'factory price', 'unit price',
'net price', 'exw price', 'fob price', 'ex works', 'exw', 'fob', 'cost', 'price',
'采购价', '采购单价', '采购价格', '供货价', '供货单价', '出厂价', '出厂单价',
'成本价', '成本单价', '未税价', '不含税价', '含税价', '工厂价', '底价', '单价', '价格', '报价'
],
exclude: [
'总价', '合计', '金额', '总额', '小计', 'amount', 'total', 'sum',
'售价', '零售', '建议零售', 'retail', 'msrp', 'selling', 'sell price',
'折扣', 'discount', '毛利', '利润', 'profit', 'margin', '汇率', 'rate', '税点', '运费', 'freight'
]
},
currency: {
label: '币种',
type: 'text',
keys: ['currency', 'cur', 'unit', '币种', '货币', '币别', '结算币种', '单价币种', '计价币种'],
exclude: ['数量', 'qty', '价', 'price', '货号', '编号']
},
quantity: {
label: '数量',
type: 'number',
keys: [
'quantity', 'qty', 'order qty', 'order quantity', 'purchase qty', 'purchase quantity',
'ordered qty', 'amount of order', 'pcs', 'sets', 'qty(pcs)',
'数量', '订购量', '订购数量', '采购数量', '采购量', '订货量', '订货数量', '数量(pcs)', '件数', '个数'
],
exclude: ['价', 'price', 'cost', 'amount', '金额', '总', '合计', 'moq', '起订', 'min', '最少']
},
moq: {
label: '起订量',
type: 'number',
keys: [
'moq', 'minimum order quantity', 'min order qty', 'min order', 'minimum qty', 'min qty',
'起订量', '最小起订量', '起订数量', '最少订量', '最小订量', '最小起订'
],
exclude: ['价', 'price', 'cost', 'amount', '金额']
}
};
/* -------------------------------------------------------------- 配置读取 */
function readJsonFile(file) {
try {
if (!fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return null;
}
}
/** 用户覆盖文件放哪:与数据库同目录,便于随库一起备份 */
function overrideFile() {
const dir = process.env.DATA_DIR || path.join(__dirname, '../data');
return path.join(dir, 'contract-rules.json');
}
function readOverride() {
/* 优先级:DATA_DIR/contract-rules.json(真正意义的「配置文件」) > settings 表。
前者适合开源用户直接改文件、连界面都不进;后者留给以后的界面化编辑。 */
const fromFile = readJsonFile(overrideFile());
if (fromFile && typeof fromFile === 'object') return { source: 'file', data: fromFile };
try {
const raw = db.prepare('SELECT value FROM settings WHERE key = ?').get(SETTINGS_KEY)?.value;
if (raw) return { source: 'settings', data: JSON.parse(raw) };
} catch {
/* 表还没建或值是脏的,当没有覆盖处理 */
}
return { source: null, data: {} };
}
/**
* 合并默认规则与用户覆盖。
* 覆盖写法(只写要改的部分即可):
* {
* "fields": { "purchase_price": { "add": ["供货价"], "exclude": ["吊牌价"] } },
* "options": { "min_numeric_ratio": 0.5 },
* "disable": ["spec"]
* }
*/
function loadRules() {
const { source, data } = readOverride();
const options = { ...DEFAULT_OPTIONS, ...(data.options || {}) };
const disabled = new Set(Array.isArray(data.disable) ? data.disable : []);
const fields = {};
for (const [key, def] of Object.entries(DEFAULT_FIELDS)) {
if (disabled.has(key)) continue;
const patch = data.fields?.[key] || {};
const keys = [...def.keys, ...(Array.isArray(patch.add) ? patch.add : [])]
.filter(item => !(Array.isArray(patch.remove) && patch.remove.includes(item)));
const exclude = [...def.exclude, ...(Array.isArray(patch.exclude) ? patch.exclude : [])];
fields[key] = {
...def,
keys,
exclude,
// type 允许被覆盖(有人想把「规格」当数值列 тоже可以)
type: patch.type || def.type,
regex: Array.isArray(patch.regex) ? patch.regex : []
};
}
return { fields, options, override_source: source };
}
/* ------------------------------------------------------------ 文本与数字 */
/** 全角 → 半角、统一括号、去零宽字符。中文表格里这三样最常见。 */
function normalizeText(value) {
return String(value ?? '')
.replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
.replace(/\u3000/g, ' ')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim();
}
/** 表头归一:小写、去空白、去尾部标点,让「采购价 (元)」和「采购价」能对上 */
function normalizeHeader(value) {
return normalizeText(value)
.toLowerCase()
.replace(/\s+/g, '')
.replace(/[::、,,.。**·\-—]+$/g, '')
.replace(/[((].*?[))]$/g, ''); // 去掉尾部的单位括号
}
const hasCJK = text => /[\u4E00-\u9FFF]/.test(text);
/**
* 把单元格里的值转成数字。
* 处理:全角数字、千分位(含欧洲写法 1.234,56)、币种符号、单位后缀、
* 括号负数(会计写法)、前后空白与不可见字符。
* 返回 null 表示「这不是一个数字」——调用方据此做列级体检。
*/
function toNumber(value) {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
let text = normalizeText(value).replace(/\s/g, '');
if (!text) return null;
// 括号负数:(12.5) → -12.5
let negative = false;
if (/^\(.*\)$/.test(text)) {
negative = true;
text = text.slice(1, -1);
}
// 欧洲写法:1.234,56 —— 逗号是小数点、点是千分位。必须先判,否则会被当千分位剥掉
if (/^\d{1,3}(\.\d{3})+(,\d+)?$/.test(text)) {
text = text.replace(/\./g, '').replace(',', '.');
} else {
text = text.replace(/,/g, '');
}
// 剥掉一切非数字字符(币种符号、单位、文字都在这步掉)
text = text.replace(/[^0-9.\-+]/g, '');
if (!text) return null;
/* 符号只允许出现在最前面。
⚠️ 不能「把 - 全省掉」—— 那样「12-15」这种区间会被拼成 1215,
比留空更糟:它会当成真价继续往产品库里走。区间一律判为非数字。 */
let sign = 1;
if (/^[-+]/.test(text)) {
if (text[0] === '-') sign = -1;
text = text.slice(1);
}
if (/[-+]/.test(text)) return null;
if (!/^\d*\.?\d+$/.test(text)) return null;
const num = Number(text) * sign * (negative ? -1 : 1);
return Number.isFinite(num) ? num : null;
}
/** 从整行的内容里猜币种 —— 没有币种列时用 */
const CURRENCY_HINTS = [
[/¥|¥|人民币|rmb|cny|元/i, 'CNY'],
[/\$|usd|美元|美金/i, 'USD'],
[/€|eur|欧元/i, 'EUR'],
[/£|gbp|英镑/i, 'GBP'],
[/jpy|日元|円/i, 'JPY'],
[/krw|韩元|₩/i, 'KRW'],
[/aud|澳元/i, 'AUD'],
[/cad|加元/i, 'CAD'],
[/hkd|港币|港元/i, 'HKD'],
[/inr|卢比/i, 'INR'],
[/vnd|越南盾|đ/i, 'VND'],
[/thb|泰铢|฿/i, 'THB']
];
function guessCurrency(...texts) {
const joined = texts.map(item => String(item ?? '')).join(' ');
if (!joined.trim()) return null;
for (const [pattern, code] of CURRENCY_HINTS) {
if (pattern.test(joined)) return code;
}
// 三字母代码(USD / CNY…)单独兜一次,避免被上面的贪婪匹配漏掉
const code = joined.match(/\b([A-Z]{3})\b/);
return code ? code[1] : null;
}
/* ---------------------------------------------------------------- 列匹配 */
/** 单个「表头 × 字段」打分。返回 0 = 不匹配;exclude 命中直接 0。 */
function scoreHeader(header, field) {
const head = normalizeHeader(header);
if (!head) return 0;
for (const bad of field.exclude) {
const token = normalizeHeader(bad);
if (token && head.includes(token)) return 0;
}
let best = 0;
for (const rawKey of field.keys) {
const key = normalizeHeader(rawKey);
if (!key) continue;
if (head === key) return 100;
if (hasCJK(key)) {
// 中文:包含即可,但关键词越长越可信(「采购单价」远比「价」可信)
if (head.includes(key)) best = Math.max(best, 50 + key.length * 8);
} else {
// 拉丁:短词(no / moq / ref…)必须落在词边界上,否则 notes 会被当成 no
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const boundary = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`);
if (boundary.test(head)) best = Math.max(best, 55 + Math.min(key.length, 12) * 3);
}
}
for (const pattern of field.regex) {
try {
const [source, flags] = pattern.split(/\/([a-z]*)$/);
if (new RegExp(source.replace(/^\//, ''), flags || 'i').test(header)) best = Math.max(best, 85);
} catch {
/* 用户写的正则不合法就当没写,不能让一份配置文件把整条解析链路打挂 */
}
}
return best;
}
/**
* 认列:把「表头行」映射成「字段 → 列号」。
*
* 两步走:
* ① 每个数值字段先过**列级体检** —— 候选列的数字占比不达标直接淘汰(拦「品名带价字」这类误判)
* ② 所有候选按分排序,贪心分配:字段没被占、列也没被占才落座
* (贪心而不是「每个字段各取最高分」,是为了避免两列抢同一个字段时出现重复占用)
*/
function matchColumns(headers, dataRows, rules) {
const { fields, options } = rules;
const candidates = [];
const rejected = [];
for (const [fieldKey, field] of Object.entries(fields)) {
for (let col = 0; col < headers.length; col += 1) {
const score = scoreHeader(headers[col], field);
if (score < options.min_score) continue;
if (field.type === 'number') {
const sample = dataRows
.map(row => row[col])
.filter(value => String(value ?? '').trim() !== '')
.slice(0, options.numeric_sample);
if (!sample.length) {
rejected.push({ field: fieldKey, col, header: headers[col], reason: '整列为空' });
continue;
}
const ok = sample.filter(value => toNumber(value) !== null).length;
const ratio = ok / sample.length;
if (ratio < options.min_numeric_ratio) {
rejected.push({
field: fieldKey, col, header: headers[col],
reason: `数值占比 ${Math.round(ratio * 100)}% 低于 ${Math.round(options.min_numeric_ratio * 100)}%`
});
continue;
}
}
candidates.push({ field: fieldKey, col, header: String(headers[col] ?? '').trim(), score });
}
}
candidates.sort((a, b) => b.score - a.score || a.col - b.col);
const usedField = new Set();
const usedCol = new Set();
const map = {};
for (const item of candidates) {
if (usedField.has(item.field) || usedCol.has(item.col)) continue;
usedField.add(item.field);
usedCol.add(item.col);
map[item.field] = item;
}
return { map, rejected };
}
/**
* 挑表头行。
* 真实文件里表头常常不在第 1 行(上面还有公司抬头、报价日期、说明……),
* 所以在前若干行里找「字段命中最多」的那一行,而不是无脑取第 1 行。
*/
function detectHeaderRow(matrix, rules) {
const limit = Math.min(matrix.length, rules.options.header_search_rows);
let best = { index: 0, hits: 0, matched: null };
for (let i = 0; i < limit; i += 1) {
const row = matrix[i] || [];
const { map } = matchColumns(row, matrix.slice(i + 1), rules);
const hits = Object.keys(map).length;
// 命中更多者胜;打平取靠前的(抬头行通常在上面)
if (hits > best.hits) best = { index: i, hits, matched: map };
}
return best;
}
/* ---------------------------------------------------------------- 读文件 */
/** CSV / TSV / TXT:按首行含不含 Tab 决定分隔符(沿用原有口径) */
function readDelimited(buffer, filename) {
const text = buffer.toString('utf8').replace(/^\uFEFF/, '');
const lines = text.split(/\r?\n/);
const delimiter = /\.tsv$/i.test(filename) || (lines[0] || '').includes('\t') ? '\t'
: (lines[0] || '').includes(';') && !(lines[0] || '').includes(',') ? ';'
: ',';
return lines.map(line => splitDelimited(line, delimiter));
}
/** 按分隔符切一行,但引号里的分隔符不算("A,B" 是一格) */
function splitDelimited(line, delimiter) {
const cells = [];
let current = '';
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (ch === '"') {
if (quoted && line[i + 1] === '"') { current += '"'; i += 1; } else quoted = !quoted;
} else if (ch === delimiter && !quoted) {
cells.push(current);
current = '';
} else {
current += ch;
}
}
cells.push(current);
return cells.map(cell => cell.trim());
}
/** xlsx / xls:交给 SheetJS;多 sheet 时取「非空单元格最多」的那个(通常就是明细表) */
function readWorkbook(buffer) {
if (!XLSX) throw new Error('未安装 xlsx 解析库,无法读取 Excel 文件');
const book = XLSX.read(buffer, { type: 'buffer' });
let best = { matrix: [], cells: -1, sheet: null };
for (const name of book.SheetNames || []) {
const sheet = book.Sheets[name];
if (!sheet) continue;
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '', blankrows: true });
const cells = matrix.reduce((sum, row) => sum + row.filter(cell => String(cell ?? '').trim() !== '').length, 0);
if (cells > best.cells) best = { matrix, cells, sheet: name };
}
return best;
}
function parseMatrixToRows(matrix, headerIndex, columnMap, fallbackCurrency = null) {
const fields = Object.keys(columnMap);
const rows = [];
for (let i = headerIndex + 1; i < matrix.length; i += 1) {
const raw = matrix[i] || [];
const pick = field => {
const hit = columnMap[field];
if (!hit) return null;
const value = normalizeText(raw[hit.col]);
return value === '' ? null : value;
};
const row = {
// row_no 用「文件里的真实行号」(1 起),方便人回去对着原文件找
row_no: i + 1,
raw_text: raw.map(cell => String(cell ?? '').trim()).filter(Boolean).join(' | ').slice(0, 500),
sku: pick('sku'),
product_name: pick('product_name'),
spec: pick('spec'),
currency: pick('currency'),
quantity: toNumber(pick('quantity')),
moq: toNumber(pick('moq')),
purchase_price: toNumber(pick('purchase_price'))
};
/* 币种三级兜底:本行的币种列(上面 pick 过了) → 本行里的符号(¥ / USD / 元)
→ 整份文件的线索(表头常写「单价(元)」「Unit Price(USD)」)。
只按单行猜会出现「同一列有的行有币种、有的行没有」这种不一致。 */
if (!row.currency) row.currency = guessCurrency(raw.join(' ')) || fallbackCurrency;
// 整行该抓的全空 —— 大概率是空行或小计行,丢掉
const meaningful = [row.sku, row.product_name, row.spec, row.purchase_price, row.quantity, row.moq]
.some(value => value !== null && value !== undefined && value !== '');
if (meaningful) rows.push(row);
}
return { rows, fields };
}
/**
* 解析一个上传文件 → { rows, headers, header_row, column_map, rejected, notes }
* 这是对外的唯一入口。
*/
function parseUpload(file, rules = loadRules()) {
const filename = String(file.originalname || '');
const isExcel = /\.(xlsx|xlsm|xls)$/i.test(filename);
let matrix;
let sheetName = null;
if (isExcel) {
const book = readWorkbook(file.buffer);
matrix = book.matrix;
sheetName = book.sheet;
} else {
matrix = readDelimited(file.buffer, filename);
}
// 去掉整行全空的(xlsx 里尾部常有大量空行)
matrix = matrix.filter(row => row.some(cell => String(cell ?? '').trim() !== ''));
if (!matrix.length) {
return { rows: [], headers: [], header_row: null, column_map: {}, rejected: [], notes: '文件里没有读到任何内容' };
}
const header = detectHeaderRow(matrix, rules);
const headers = matrix[header.index] || [];
const { map, rejected } = matchColumns(headers, matrix.slice(header.index + 1), rules);
/* 文件级币种线索:表头 + 表头以上的抬头行(「报价单(美元)」「单价(元)」都在这)
—— 没有币种列时靠它,保证整列币种一致 */
const titleRows = matrix.slice(0, header.index).flat();
const fileCurrency = guessCurrency(headers.join(' '), titleRows.join(' '));
const { rows } = parseMatrixToRows(matrix, header.index, map, fileCurrency);
const notes = rows.length
? `表头在第 ${header.index + 1} 行,认出 ${Object.keys(map).length} 个字段${sheetName ? `(工作表 ${sheetName})` : ''}`
: '表头行没有认出任何字段,建议人工补录或用 AI 解析';
return {
rows,
headers: headers.map(cell => String(cell ?? '').trim()),
header_row: header.index + 1,
column_map: map,
rejected,
currency: fileCurrency,
notes
};
}
/** 读成二维数组(xlsx / csv / tsv / txt 统一入口),AI 解析与规则解析共用 */
function readMatrix(buffer, filename) {
const isExcel = /\.(xlsx|xlsm|xls)$/i.test(String(filename || ''));
const matrix = isExcel ? readWorkbook(buffer).matrix : readDelimited(buffer, filename);
return matrix.filter(row => row.some(cell => String(cell ?? '').trim() !== ''));
}
/** 二维数组 → CSV 文本,喂给模型用 */
function matrixToText(matrix, { maxRows = 200, maxChars = 12000 } = {}) {
const lines = [];
let chars = 0;
for (const row of matrix) {
const line = row.map(cell => {
const text = String(cell ?? '').replace(/\s+/g, ' ').trim();
return /[",]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}).join(',').replace(/(?:,)+$/, '');
if (!line.trim()) continue;
if (lines.length >= maxRows || chars + line.length > maxChars) {
lines.push('…(余下内容已截断)');
break;
}
lines.push(line);
chars += line.length;
}
return lines.join('\n');
}
/**
* 把上传的原件转成「给模型看的文本」。
* ⚠️ 只做格式转换,不做任何猜测 —— 猜是模型的事,转换是我们的事。
*/
function describeForAi(buffer, filename, options) {
const matrix = readMatrix(buffer, filename);
return { text: matrixToText(matrix, options), row_count: matrix.length };
}
/** 给界面用:把认列结果翻译成人能读的一段话 */
function describeMatch(columnMap, rejected = []) {
const picked = Object.entries(columnMap)
.map(([field, hit]) => `${DEFAULT_FIELDS[field]?.label || field}←「${hit.header}」(${hit.score}分)`)
.join(',');
const miss = rejected.length
? `;被否掉:${rejected.map(item => `${item.header}(${item.reason})`).join(',')}`
: '';
return (picked || '没有认出任一字段') + miss;
}
module.exports = {
DEFAULT_FIELDS,
DEFAULT_OPTIONS,
loadRules,
overrideFile,
parseUpload,
readMatrix,
matrixToText,
describeForAi,
matchColumns,
detectHeaderRow,
describeMatch,
toNumber,
normalizeHeader,
guessCurrency,
scoreHeader
};
+91
View File
@@ -0,0 +1,91 @@
/**
* 客户归属与可见性口径(唯一事实来源)。
*
* Wayne 2026-09-14 定的规矩:
* ① 客户有「所属」:谁建的归谁,默认只有归属人自己看得见;
* ② 创建人主动丢进公海(pool_status = 'public')后,同组织成员都能看见、都能领取;
* ③ 其余情况只有拿到 `customer.view_all` 的最高级别管理账号能查看。
*
* 「能看」与「能改」是两档:
* 公海里的客户同组织成员都能看见(不然没法判断值不值得领),
* 但只有归属人或管理账号能改 —— 别人想改得先「领取」。
*
* ⚠️ 注意 req.user 是 users 表的原始行(attachUser 直接塞的是 sessionUser 的结果),
* 上面**没有** permissions 字段,权限必须用 auth.permissionsOfRole(role) 现推。
*/
const auth = require('./auth');
/** 越过归属看全部客户的权限点 */
const VIEW_ALL = 'customer.view_all';
/** 是否属于「最高级别系统管理账号」 */
function seesAll(user) {
if (!user || !user.role) return false;
return auth.permissionsOfRole(user.role).includes(VIEW_ALL);
}
/**
* 列表可见性 → 可直接拼进 WHERE 的条件数组 + 参数。
*
* scope:
* 'mine' 我的客户(默认)—— owner_id = 我
* 'all' 全部客户 —— 需 customer.view_all,无权限时退回 'mine' 而不是报错
* 'pool' 客户公海 —— pool_status = 'public',且限定本组织
*
* 调用方拼 SQL 时,customers 表必须别名为 **c**。
*/
function scopeFilter(user, scope = 'mine') {
const conditions = [];
const params = [];
if (scope === 'pool') {
conditions.push("c.pool_status = 'public'");
if (user && user.org_id) {
/* 公海是「组织(团队)」的资源,只放本组织的进来。
存量没 org_id 的老数据不设门槛,免得刚升级就凭空少一批。 */
conditions.push('(c.org_id IS NULL OR c.org_id = ?)');
params.push(user.org_id);
}
return { conditions, params };
}
if (scope === 'all' && seesAll(user)) {
return { conditions, params };
}
/* 「我的客户」= 归我 + 还没交出去。
丢进公海后这条就从我的列表里移走,只留在「客户公海」——
否则用户会疑惑「我都丢出去了怎么还在我这儿」。 */
conditions.push('c.owner_id = ?');
conditions.push("COALESCE(c.pool_status, 'owned') <> 'public'");
params.push(user ? user.id : -1);
return { conditions, params };
}
/** 公海是「组织(团队)」的资源:两边都记了组织且不一致,就是外人 */
function inSameOrg(user, customer) {
const mine = user?.org_id;
const theirs = customer?.org_id;
/* 存量数据可能没组织,不设门槛 —— 与 scopeFilter 里 pool 的写法保持一致 */
if (!mine || !theirs) return true;
return Number(mine) === Number(theirs);
}
/** 能不能看这条客户(详情页、跟进时间线) */
function canView(user, customer) {
if (!customer) return false;
if (seesAll(user)) return true;
if (user && Number(customer.owner_id) === Number(user.id)) return true;
/* 公海客户同组织可见;别组织的公海客户连详情都不该看到 */
if (customer.pool_status === 'public' && inSameOrg(user, customer)) return true;
return false;
}
/** 能不能改这条客户(基本信息、联系人、跟进记录) */
function canEdit(user, customer) {
if (!customer) return false;
if (seesAll(user)) return true;
return !!user && Number(customer.owner_id) === Number(user.id);
}
module.exports = { VIEW_ALL, seesAll, scopeFilter, inSameOrg, canView, canEdit };
+218
View File
@@ -0,0 +1,218 @@
/**
* happy 出口通 —— 汇率取数
*
* 目标:给顶栏的汇率计算器提供**任意两种货币之间**的换算汇率。
*
* 设计要点:
* 1. 所有汇率统一以 USD 为基准存取(rates[X] = 1 USD 值多少 X),
* 任意币种对 (from → to) 用 rates[to] / rates[from] 即可算出交叉汇率。
* 2. 两个公开数据源按顺序尝试,任一成功即用:
* - open.er-api.com 免费无需 key,约 160+ 币种,带更新日期
* - frankfurter.app 欧洲央行数据,约 30 币种,权威但覆盖面小
* 3. 结果写内存 + 落盘(backend/data/fx-cache.json,该目录已 gitignore)。
* 重启后仍能立刻给出上次的汇率,不必等网络。
* 4. 网络彻底不可用时回退内置离线汇率表,并在响应里标注 source='offline',
* 让前端能明确提示「这是离线参考值」。宁可给一个标清楚来源的估算,
* 也不要让计算器变成一块空白。
*/
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, '..', 'data');
const CACHE_FILE = path.join(DATA_DIR, 'fx-cache.json');
/** 内存缓存的保鲜期:1 小时后下次访问会去拉新数据。 */
const TTL_MS = 60 * 60 * 1000;
/** 单个数据源的超时。多个源是并行竞速的,所以这基本等于整体最坏耗时。 */
const TIMEOUT_MS = 5000;
const USER_AGENT = 'happy-export-fx';
/* ------------------------------ 数据源 ------------------------------ */
/**
* 每个数据源负责把自家返回体归一成 { rates, updatedAt }。
* rates 必须以 USD 为基准,键为大写三位币种码。
*/
const PROVIDERS = [
{
id: 'open.er-api.com',
label: 'ExchangeRate-API',
url: 'https://open.er-api.com/v6/latest/USD',
pick(json) {
if (!json || json.result === 'error' || !json.rates) return null;
const unix = Number(json.time_last_update_unix);
return {
rates: json.rates,
// 这个源会给到精确到秒的更新时间;没有就退回 next_update 或当前时间
updatedAt: Number.isFinite(unix) && unix > 0
? new Date(unix * 1000).toISOString()
: new Date().toISOString()
};
}
},
{
id: 'frankfurter.app',
label: 'Frankfurter(欧洲央行)',
url: 'https://api.frankfurter.app/latest?from=USD',
pick(json) {
if (!json || !json.rates) return null;
return {
// 这个源不返回基准货币自身,补上 USD: 1
rates: { USD: 1, ...json.rates },
updatedAt: json.date ? new Date(`${json.date}T00:00:00Z`).toISOString() : new Date().toISOString()
};
}
}
];
/**
* 离线兜底汇率(1 USD 值多少目标币)。
* 仅在两个数据源都拿不到、且本地也没有任何历史缓存时启用。
* 数值是写这份代码时的市场中间价量级,只适合粗估,响应里会明确标注。
*/
const OFFLINE_RATES = {
USD: 1,
CNY: 7.10, HKD: 7.80, TWD: 32.2, JPY: 152, KRW: 1360, SGD: 1.34,
EUR: 0.92, GBP: 0.78, CHF: 0.88, SEK: 10.5, NOK: 10.7, DKK: 6.88,
PLN: 3.95, CZK: 23.0, TRY: 34.0, RUB: 92.0,
AUD: 1.51, NZD: 1.64, CAD: 1.37, MXN: 18.5, BRL: 5.55, ARS: 950, CLP: 930,
INR: 83.5, PKR: 278, BDT: 118, LKR: 300, NPR: 134, VND: 24800,
THB: 34.5, MYR: 4.45, IDR: 15600, PHP: 56.5, KHR: 4100, MMK: 2100,
AED: 3.6725, SAR: 3.75, QAR: 3.64, KWD: 0.307, OMR: 0.385, ILS: 3.72,
EGP: 48.5, ZAR: 18.2, NGN: 1600, KES: 129, MAD: 9.85, TZS: 2700,
UAH: 41.0, RON: 4.58, HUF: 360, BGN: 1.80, ISK: 138, GEL: 2.72
};
/* ------------------------------ 工具 ------------------------------ */
/** 只保留「键是 3 位字母、值是正数」的条目,顺手统一成大写。 */
function sanitizeRates(raw) {
const out = {};
if (!raw || typeof raw !== 'object') return out;
for (const [key, value] of Object.entries(raw)) {
const code = String(key).trim().toUpperCase();
const num = Number(value);
if (/^[A-Z]{3}$/.test(code) && Number.isFinite(num) && num > 0) out[code] = num;
}
out.USD = 1;
return out;
}
function readDiskCache() {
try {
const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
const rates = sanitizeRates(parsed && parsed.rates);
if (Object.keys(rates).length > 1) {
return {
rates,
updatedAt: parsed.updatedAt || null,
fetchedAt: Number(parsed.fetchedAt) || 0,
source: parsed.source || 'cache'
};
}
} catch {
/* 没有缓存或文件损坏都走正常取数流程 */
}
return null;
}
function writeDiskCache(payload) {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(payload, null, 2), 'utf8');
} catch {
/* 写不进去不影响本次换算,只是下次重启享受不到缓存 */
}
}
/** 带超时的 GET,返回解析后的 JSON 对象或 null。 */
async function fetchJson(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
signal: controller.signal
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/* ------------------------------ 主流程 ------------------------------ */
let memory = readDiskCache();
/** 同一个进程里并发调用只发一次网络请求。 */
let inflight = null;
/**
* 刷新汇率表。
*
* 所有数据源**并行竞速**,第一个拿到完整数据的胜出。
* 早先这里是串行 for 循环,一次网络抖动要等 8s × 2 = 16s 才回退,
* 用户看到的是「刷新失败 + 3 天前的旧数据」——其实另一个源可能早就成功了。
* 并行之后最坏情况只等一个超时周期。
*/
async function refresh() {
const attempts = PROVIDERS.map(async provider => {
const json = await fetchJson(provider.url);
const picked = json && provider.pick(json);
if (!picked) throw new Error(`${provider.id} 无数据`);
const rates = sanitizeRates(picked.rates);
if (Object.keys(rates).length < 10) throw new Error(`${provider.id} 数据不完整`);
return { provider, rates, updatedAt: picked.updatedAt };
});
let winner;
try {
winner = await Promise.any(attempts);
} catch {
return null; // 所有源都失败,交给 getRates 走缓存 / 离线兜底
}
const payload = {
rates: winner.rates,
updatedAt: winner.updatedAt,
fetchedAt: Date.now(),
source: winner.provider.id,
sourceLabel: winner.provider.label
};
memory = payload;
writeDiskCache(payload);
return payload;
}
/**
* 取汇率表。永远不抛异常。
* @param {boolean} force 为 true 时忽略缓存有效期,强制刷新
*/
async function getRates(force = false) {
const fresh = memory && Date.now() - memory.fetchedAt < TTL_MS;
if (fresh && !force) return { ...memory, stale: false, cached: true };
if (!inflight) {
inflight = refresh().finally(() => { inflight = null; });
}
const refreshed = await inflight;
if (refreshed) return { ...refreshed, stale: false, cached: false };
// 网络失败:有旧数据就先用旧数据,但要标出来
if (memory) return { ...memory, stale: true, cached: true, warning: '汇率刷新失败,显示的是上次成功获取的数据' };
return {
rates: OFFLINE_RATES,
updatedAt: null,
fetchedAt: Date.now(),
source: 'offline',
sourceLabel: '内置离线汇率',
stale: true,
cached: false,
warning: '当前无法连接汇率服务,显示的是内置离线参考值'
};
}
module.exports = { getRates, PROVIDERS, OFFLINE_RATES };
+127
View File
@@ -0,0 +1,127 @@
/**
* 系统权限点清单(单一事实来源)。
*
* 设计口径(Wayne 2026-09-14 定):
* - 「角色」是职位显示(董事长 / 总经理 / 销售经理 / 销售业务员 …),本身不带权限;
* - 权限全部收敛到「权限组」:一个组勾选一批权限点(即下面的 key);
* - 把角色指派进某个组(role_permission_groups),该角色下的所有用户即获得这组权限。
*
* 命名约定:`模块.动作`。前端菜单过滤、路由守卫、后端 requirePermission 都用同一批 key。
*/
const PERMISSIONS = [
/* 工作台 */
{ key: 'dashboard.view', label: '查看工作台', group: '工作台' },
/* 业务管理 */
{ key: 'inquiry.view', label: '查看询盘管理', group: '业务管理' },
{ key: 'inquiry.edit', label: '编辑询盘管理', group: '业务管理' },
{ key: 'quote.view', label: '查看客户报价单', group: '业务管理' },
{ key: 'quote.edit', label: '编辑客户报价单', group: '业务管理' },
{ key: 'contract.view', label: '查看 PI 合同', group: '业务管理' },
{ key: 'contract.edit', label: '编辑 PI 合同', group: '业务管理' },
{ key: 'purchase.view', label: '查看采购单', group: '业务管理' },
{ key: 'purchase.edit', label: '编辑采购单', group: '业务管理' },
{ key: 'order.view', label: '查看订单管理', group: '业务管理' },
{ key: 'order.edit', label: '编辑订单管理', group: '业务管理' },
/* 订单支出(快递费/吊卡费这类杂费)单独成点,不搭在 order.edit 上 ——
掏钱寄样品的是销售业务员,而业务组只有 order.view。若把记账挂在 order.edit 上,
要么业务员记不了账,要么为了让他记账得把「改订单金额」的权限一起给他。 */
{ key: 'order.expense', label: '记录订单支出(快递/吊卡等杂费)', group: '业务管理' },
/* 客户管理 */
{ key: 'customer.view', label: '查看我的客户', group: '客户管理' },
{ key: 'customer.edit', label: '编辑我的客户', group: '客户管理' },
/* 客户默认「谁建的谁看得见」,丢进公海才对同组织开放。
拿到这个点才越过归属看全部客户 —— 按 Wayne 的口径只给最高级别的系统管理组。 */
{ key: 'customer.view_all', label: '查看全部客户(含他人归属)', group: '客户管理' },
{ key: 'prospect.view', label: '新客开发', group: '客户管理' },
{ key: 'pool.view', label: '客户公海', group: '客户管理' },
{ key: 'pool.claim', label: '客户公海领取', group: '客户管理' },
/* 采购管理 */
{ key: 'product.view', label: '查看产品管理', group: '采购管理' },
{ key: 'product.edit', label: '编辑产品管理', group: '采购管理' },
{ key: 'supplier.view', label: '查看供应商管理', group: '采购管理' },
{ key: 'supplier.edit', label: '编辑供应商管理', group: '采购管理' },
{ key: 'cleanup.view', label: '合同清洗中心', group: '采购管理' },
/* 系统管理 */
{ key: 'org.manage', label: '组织管理', group: '系统管理' },
/* 组织与品牌(改公司名 / 组织代码 / 描述)单独成点,与「组织管理」解耦 ——
Wayne 2026-09-15 的口径:这块**只允许高管改**,所以它得能单独勾给「公司高管」组,
而不是搭在系统管理权限上。前端设置页分类、PATCH /api/organizations/:id 与
PUT /api/branding 都认这一个点。 */
{ key: 'branding.manage', label: '组织与品牌(公司信息)', group: '系统管理' },
{ key: 'user.manage', label: '用户管理', group: '系统管理' },
{ key: 'perm.manage', label: '权限组管理', group: '系统管理' },
{ key: 'ai.manage', label: '智能配置(大模型接入与用途分配)', group: '系统管理' },
{ key: 'audit.view', label: '审计日志', group: '系统管理' }
]
const PERMISSION_KEYS = PERMISSIONS.map(item => item.key)
/* 角色清单:Wayne 列举的四个业务角色 + 系统管理员。权限组管理页的「角色指派」用它做候选。 */
const ROLES = [
{ value: 'admin', label: '系统管理员' },
{ value: 'chairman', label: '董事长' },
{ value: 'general_manager', label: '总经理' },
{ value: 'sales_manager', label: '销售经理' },
{ value: 'sales_rep', label: '销售业务员' }
]
/* 开箱默认权限组。仅在首次建库/首次运行时写入,之后以库里的为准。 */
const DEFAULT_PERMISSION_GROUPS = [
{
name: '超级管理员组',
description: '全部权限,含组织、用户与权限组管理',
is_system: 1,
permissions: PERMISSION_KEYS,
/* 只挂 admin。董事长 / 总经理改走下面的「公司高管」组 ——
一个角色同时只能属于一个组(role_permission_groups.role 是主键),
让他们留在超管组就等于「高管 = 系统管理员」,与 Wayne 2026-09-15 的分权口径冲突。 */
roles: ['admin']
},
{
name: '销售管理组',
description: '业务全流程可读可写,可维护客户、产品与供应商,无系统管理权限',
is_system: 0,
/* 注意:`customer.view_all` 必须显式排除 —— 它不含 .manage 后缀,
否则会被这个过滤器顺带带上,销售经理就能看到全公司的客户了。
`branding.manage` 反过来说:它**靠** .manage 后缀被自动排除,
销售经理永远拿不到改公司信息的权限(Wayne 口径:只有高管能改)。 */
permissions: PERMISSION_KEYS.filter(key =>
!key.endsWith('.manage') && key !== 'audit.view' && key !== 'customer.view_all'),
roles: ['sales_manager']
},
{
name: '销售业务组',
description: '基础业务操作:维护自己名下的客户与询盘报价,产品只读',
is_system: 0,
permissions: [
'dashboard.view',
'inquiry.view', 'inquiry.edit',
'quote.view', 'quote.edit',
'contract.view',
'purchase.view',
'order.view', 'order.expense',
'customer.view', 'customer.edit',
'prospect.view', 'pool.view', 'pool.claim',
'product.view', 'supplier.view', 'cleanup.view'
],
roles: ['sales_rep']
},
{
name: '公司高管',
description: '董事长 / 总经理:除用户、权限组、智能配置与系统日志外的全部权限,含改公司信息',
is_system: 0,
/* 与线上库(2026-09-15)一致:全量权限里摘掉四个「系统管理员专属」的点。
user.manage / perm.manage 是账号治理权,ai.manage 会看到所有模型密钥,
audit.view 能看到全公司的操作轨迹 —— Wayne 口径是「系统日志仅系统管理员可见」。 */
permissions: PERMISSION_KEYS.filter(key =>
!['user.manage', 'perm.manage', 'ai.manage', 'audit.view'].includes(key)),
roles: ['chairman', 'general_manager']
}
]
module.exports = { PERMISSIONS, PERMISSION_KEYS, ROLES, DEFAULT_PERMISSION_GROUPS }
+297
View File
@@ -0,0 +1,297 @@
/**
* 产品目录(SKU 模块)的本地数据层。
*
* 这块原先是独立的 sku-manager 服务(3300),happy 通过 /api/sku 代理访问它。
* 合并后数据直接落在 trade.db:少一个进程、少一个端口,开源部署只跑 4300 + 5300。
*
* 表结构沿用 sku-manager 那套(products / product_groups / suppliers / item_attributes /
* product_images / price_history / cart_items),列名保持一致 —— 迁移是按原 id 整表搬过来的,
* 所以这里的外键关系与历史数据完全对得上,不需要任何 id 映射。
*/
const crypto = require('crypto');
const db = require('../db/db');
/* --------------------------------- 规范化 --------------------------------- */
/**
* 货号规范化:去掉空格、连字符、下划线、斜杠、点,再转大写。
* 合同里的「GD-100F」和产品库里的「GD100F」必须归到同一条。
*/
function normalizeSku(raw) {
if (!raw) return null;
return String(raw).toUpperCase().replace(/[\s\-_./]/g, '').trim();
}
/**
* 产品归组键:同一单品的不同尺寸归为一个产品。
* 只剥「尺寸/规格」类字样(8x10、200mm、1/2"、6寸),件数(6件套)保留 ——
* 套装件数不同是不同的产品。
*/
function groupKey(name) {
if (!name) return null;
let s = String(name).replace(/\s+/g, '');
s = s.replace(/\d+(\.\d+)?[x×*]\d+(\.\d+)?(mm|cm)?/gi, '');
s = s.replace(/\d+\/\d+(["″”寸]|英寸)?/g, '');
s = s.replace(/\d+(\.\d+)?(mm|cm|寸|英寸|inch|in|"|″|”|米)/gi, '');
s = s.replace(/[-—–()()]/g, '');
const key = s.toUpperCase();
return key || String(name).trim().toUpperCase();
}
/* ------------------------------ 二进制存储 ------------------------------ */
const MIME_BY_EXT = {
'.pdf': 'application/pdf',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp'
};
function mimeFor(name) {
const i = String(name).lastIndexOf('.');
return (i >= 0 && MIME_BY_EXT[String(name).slice(i).toLowerCase()]) || 'application/octet-stream';
}
const syncImageStmt = db.prepare(
`INSERT INTO product_image_files (image_id, bytes, size, stored_at)
VALUES (@image_id, @bytes, @size, @stored_at)
ON CONFLICT(image_id) DO UPDATE SET bytes = excluded.bytes, size = excluded.size, stored_at = excluded.stored_at`
);
const getImageStmt = db.prepare('SELECT bytes, size FROM product_image_files WHERE image_id = ?');
const delImageStmt = db.prepare('DELETE FROM product_image_files WHERE image_id = ?');
const storeImage = (imageId, buf) =>
syncImageStmt.run({ image_id: imageId, bytes: buf, size: buf.length, stored_at: new Date().toISOString() });
const getImage = (imageId) => getImageStmt.get(imageId);
const deleteImage = (imageId) => delImageStmt.run(imageId);
/** 批量删图(批量删产品时用),避免逐条 delete。 */
function deleteImagesFor(imageIds) {
if (!imageIds.length) return;
db.prepare(`DELETE FROM product_image_files WHERE image_id IN (${imageIds.map(() => '?').join(',')})`)
.run(...imageIds);
}
/* ------------------------------- 合同原件 ------------------------------- */
const putContractStmt = db.prepare(
`INSERT INTO contract_files (contract_id, filename, mime, size, sha256, bytes, stored_at)
VALUES (@contract_id, @filename, @mime, @size, @sha256, @bytes, @stored_at)
ON CONFLICT(contract_id) DO UPDATE SET
filename = excluded.filename, mime = excluded.mime, size = excluded.size,
sha256 = excluded.sha256, bytes = excluded.bytes, stored_at = excluded.stored_at`
);
/** 把一份合同原件收进库。返回 null 表示没收到(不算致命,解析结果才是主产物)。 */
function storeContractFile(contractId, bytes, displayName) {
if (!bytes || !bytes.length) return null;
const filename = displayName || `contract-${contractId}`;
putContractStmt.run({
contract_id: contractId,
filename,
mime: mimeFor(filename),
size: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
bytes,
stored_at: new Date().toISOString()
});
return { size: bytes.length };
}
const getContractFile = (contractId) =>
db.prepare('SELECT filename, mime, size, bytes FROM contract_files WHERE contract_id = ?').get(contractId);
/* --------------------------------- 参数 --------------------------------- */
const getItemAttrs = db.prepare(
'SELECT id, name, value, sort FROM item_attributes WHERE sku = ? ORDER BY sort, id'
);
const getGroupAttrs = db.prepare(
'SELECT id, name, value, sort FROM item_attributes WHERE group_id = ? AND sku IS NULL ORDER BY sort, id'
);
/** 整组替换参数(货号级或产品级)。传入 [{name, value}],空名跳过。 */
function replaceAttrs({ sku = null, groupId = null }, attrs) {
if (!Array.isArray(attrs)) return;
db.transaction(() => {
if (sku) db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku);
else db.prepare('DELETE FROM item_attributes WHERE group_id = ? AND sku IS NULL').run(groupId);
const ins = db.prepare(
'INSERT INTO item_attributes (group_id, sku, name, value, sort) VALUES (@group_id, @sku, @name, @value, @sort)'
);
attrs.forEach((a, i) => {
const name = String(a?.name || '').trim();
if (!name) return;
ins.run({ group_id: sku ? null : groupId, sku, name, value: String(a?.value ?? '').trim() || null, sort: i });
});
})();
}
/** 产品组变空后清理掉,避免列表里留下没有货号的壳(有图片的保留)。 */
function cleanupGroupIfEmpty(groupId) {
if (!groupId) return;
const hasItems = db.prepare('SELECT 1 FROM products WHERE group_id = ? LIMIT 1').get(groupId);
const hasImages = db.prepare('SELECT 1 FROM product_images WHERE group_id = ? LIMIT 1').get(groupId);
if (!hasItems && !hasImages) {
db.prepare('DELETE FROM item_attributes WHERE group_id = ?').run(groupId);
db.prepare('DELETE FROM product_groups WHERE id = ?').run(groupId);
}
}
/* --------------------------------- 货号 --------------------------------- */
function validatePriceMoq(b) {
const price = b.price === '' || b.price == null ? null : Number(b.price);
if (price !== null && (!Number.isFinite(price) || price < 0)) return { error: '采购价必须是非负数字' };
const moq = b.moq === '' || b.moq == null ? null : Number(b.moq);
if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return { error: '起订量必须是非负数字' };
return { price, moq };
}
/**
* 新增货号。
*
* 货号重复**不当错误** —— 产品库是组织共享的,谁都能上传自己那一版:
* 撞号时落成兄弟副本 `<SKU>-COPY2 / -COPY3 …`,display_sku 加「-副本N」,
* 并用 duplicated_from 记住它复制自哪个货号,界面上标出来,避免两条同名货号看着像脏数据。
* 无论是否重复都记 uploader / org_id;手工价同时写 changed_by,保证「谁传的、谁改的价」可追。
*/
function insertItem(b, groupId, supplierId, actor = null, actorOrg = null, options = {}) {
const displaySku = String(b.sku || '').trim();
const base = normalizeSku(displaySku);
if (!base) return { error: '货号不能为空' };
const v = validatePriceMoq(b);
if (v.error) return v;
const taken = (candidate) => !!db.prepare('SELECT sku FROM products WHERE sku = ?').get(candidate);
let sku = base;
let display = displaySku;
let duplicatedFrom = null;
if (taken(base)) {
duplicatedFrom = base;
let n = 2;
while (taken(`${base}-COPY${n}`)) n += 1;
sku = `${base}-COPY${n}`;
display = `${displaySku}-副本${n}`;
}
const now = new Date().toISOString();
/* options 只在「带来源的批量导入」里传(目前是合同清洗中心的入库):
sourceContract —— 价格出处,只记合同**文件名**(Wayne 2026-09-16:不记路径、不记任务号)
parseMethod —— 'manual' | 'rules' | 'ai',用来区分这条货号是手录还是合同带进来的
currency —— 新建时按合同的币种落库;不传仍然是 CNY(产品库原有口径)
⚠️ 更新已有货号时**不动 currency** —— 汇率口径牵一发动全身,不在这里顺手改。 */
const sourceContract = options.sourceContract || null;
const parseMethod = options.parseMethod || 'manual';
const currency = String(options.currency || 'CNY').toUpperCase();
db.transaction(() => {
db.prepare(
`INSERT INTO products (sku, display_sku, name, spec, price, currency, moq, description,
supplier_id, contract_id, group_id, parse_method,
last_updated, source_contract, confidence,
uploader, org_id, duplicated_from, created_at, updated_at)
VALUES (@sku, @display_sku, @name, @spec, @price, @currency, @moq, @description,
@supplier_id, NULL, @group_id, @parse_method, @now, @source_contract, 'high',
@uploader, @org_id, @duplicated_from, @now, @now)`
).run({
sku,
display_sku: display,
name: b.name?.trim() || null,
spec: b.spec?.trim() || null,
price: v.price,
moq: v.moq,
currency,
description: b.description?.trim() || null,
supplier_id: supplierId,
group_id: groupId,
parse_method: parseMethod,
source_contract: sourceContract,
now,
uploader: actor,
org_id: actorOrg,
duplicated_from: duplicatedFrom
});
/* 手工价格同样留痕,保证「每个价格都能追到出处」这条不破功。
带来源时(合同入库)把合同文件名当出处写进去,手工录入才写「手工录入」。 */
if (v.price !== null) {
db.prepare(
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
supplier_id, source_contract, confidence, changed_at, changed_by)
VALUES (@sku, NULL, @price, NULL, @moq, @currency, @supplier_id, @source_contract, 'high', @now, @changed_by)`
).run({
sku,
price: v.price,
moq: v.moq,
currency,
supplier_id: supplierId,
source_contract: sourceContract || '手工录入',
now,
changed_by: actor
});
}
})();
if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes);
return { sku, displaySku: display, duplicatedFrom };
}
/* ------------------------------- 产品解析 ------------------------------- */
/**
* 解析货号,返回统一的 { product, packaging } 视图。
*
* 合并前这里要先打 SKU 服务、失败再回退本地镜像(镜像只兜底,绝不拿过期价去报价)。
* 现在数据就在本库,直接查即可 —— 单表主键命中,比原来走一趟 HTTP 快得多。
* packaging 始终取自本地 product_packaging(询盘/报价用的包装快照来源)。
*/
function resolveProduct(sku) {
const raw = String(sku || '').trim();
if (!raw) throw new Error('SKU 不能为空');
const key = normalizeSku(raw);
const row = db.prepare(
`SELECT p.*, s.name AS supplier_name FROM products p
LEFT JOIN suppliers s ON s.id = p.supplier_id WHERE p.sku = ?`
).get(key);
if (!row) throw new Error(`产品 SKU 不存在:${raw}`);
const product = {
...row,
sku: row.sku,
display_sku: row.display_sku || row.sku,
name: row.name || row.name_en || row.sku,
name_en: row.name_en || null,
price: row.price,
currency: row.currency || 'CNY',
moq: row.moq === undefined ? null : row.moq
};
const packaging = db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(row.sku) || null;
return { product, packaging };
}
module.exports = {
normalizeSku,
groupKey,
mimeFor,
storeImage,
getImage,
deleteImage,
deleteImagesFor,
storeContractFile,
getContractFile,
getItemAttrs,
getGroupAttrs,
replaceAttrs,
cleanupGroupIfEmpty,
validatePriceMoq,
insertItem,
resolveProduct
};
+7 -7
View File
@@ -161,10 +161,9 @@ async function checkUpdate(repoInput) {
const list = Array.isArray(tags.data) ? tags.data : [];
const versions = list
.map((t) => normalizeTag(t && t.name))
.filter(Boolean)
.sort(compareVersions)
.reverse();
.map((t) => ({ raw: String((t && t.name) || ''), version: normalizeTag(t && t.name) }))
.filter((t) => t.version)
.sort((a, b) => compareVersions(b.version, a.version)); // 降序
if (!versions.length) {
return {
@@ -177,18 +176,19 @@ async function checkUpdate(repoInput) {
};
}
const latest = versions[0];
const [{ version: latest, raw }] = versions;
return {
...base,
ok: true,
source: 'tag',
latest,
tag: latest,
// 原始 tag 名留着:链接要用它,用清洗过的版本号会指向不存在的路径
tag: raw || latest,
hasUpdate: compareVersions(latest, current) > 0,
title: '',
notes: '',
publishedAt: null,
url: `https://github.com/${repo}/releases/tag/${latest}`
url: `https://github.com/${repo}/releases/tag/${encodeURIComponent(raw || latest)}`
};
}
+14 -1
View File
@@ -12,7 +12,8 @@
"better-sqlite3-multiple-ciphers": "^13.0.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"nodemon": "^3.0.1"
@@ -1420,6 +1421,18 @@
"node": ">= 0.8"
}
},
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+8 -3
View File
@@ -1,20 +1,25 @@
{
"name": "trade-management-backend",
"version": "1.0.0",
"version": "1.1.0",
"description": "外贸供应链管理系统 - 后端",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": ["trade", "crm", "supply-chain"],
"keywords": [
"trade",
"crm",
"supply-chain"
],
"author": "",
"license": "ISC",
"dependencies": {
"better-sqlite3-multiple-ciphers": "^13.0.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"nodemon": "^3.0.1"
+464
View File
@@ -0,0 +1,464 @@
/**
* 组织与权限组(多用户阶段的权限中枢)。
* 挂载点:/api
*
* 口径(Wayne 2026-09-14 定):
* - 角色只是职位标签,本身不带权限;
* - 权限组 = 一批权限点;把角色指派进某个组,该角色下所有用户即获得这组权限。
* 所以「角色 → 组」是唯一的授权入口,permission_group_permissions 是唯一的权限范围来源。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const branding = require('../lib/branding');
const { PERMISSIONS, PERMISSION_KEYS, ROLES } = require('../lib/permissions');
const router = express.Router();
const ROLE_VALUES = ROLES.map(item => item.value);
const KEY_SET = new Set(PERMISSION_KEYS);
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/必须|不能为空|不存在|无效|格式|已存在|不可|请先/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/** 校验并归一化权限点数组 */
function normalizePermissions(input) {
if (input === undefined) return undefined;
if (!Array.isArray(input)) throw badRequest('权限范围格式不正确');
const list = [...new Set(input.map(key => String(key).trim()).filter(Boolean))];
const unknown = list.filter(key => !KEY_SET.has(key));
if (unknown.length) throw badRequest(`未知的权限点:${unknown.join('、')}`);
return list;
}
/** 校验并归一化角色数组 */
function normalizeRoles(input) {
if (input === undefined) return undefined;
if (!Array.isArray(input)) throw badRequest('角色指派格式不正确');
const list = [...new Set(input.map(role => String(role).trim()).filter(Boolean))];
const unknown = list.filter(role => !ROLE_VALUES.includes(role));
if (unknown.length) throw badRequest(`未知的角色:${unknown.join('、')}`);
return list;
}
function groupDetail(row) {
const permissions = db.prepare('SELECT permission_key FROM permission_group_permissions WHERE group_id = ?')
.all(row.id).map(item => item.permission_key);
const roles = db.prepare('SELECT role FROM role_permission_groups WHERE group_id = ?')
.all(row.id).map(item => item.role);
return {
id: row.id,
name: row.name,
description: row.description || '',
is_system: !!row.is_system,
permissions,
roles,
created_at: row.created_at,
updated_at: row.updated_at
};
}
/** 写入某个组的权限范围(整组替换) */
function replacePermissions(groupId, permissions) {
db.prepare('DELETE FROM permission_group_permissions WHERE group_id = ?').run(groupId);
const insert = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
for (const key of permissions) insert.run(groupId, key);
}
/**
* 写入某个组的角色指派。
* 先把原本指向本组、但不在新名单里的角色解绑(role 表主键唯一,只能改指处),
* 再把新名单里的角色指到本组。
*/
function replaceRoles(groupId, roles, nowIso) {
const current = db.prepare('SELECT role FROM role_permission_groups WHERE group_id = ?').all(groupId).map(row => row.role);
const keep = new Set(roles);
for (const role of current) {
if (!keep.has(role)) db.prepare('DELETE FROM role_permission_groups WHERE role = ?').run(role);
}
const assign = db.prepare('INSERT OR REPLACE INTO role_permission_groups (role, group_id, updated_at) VALUES (?, ?, ?)');
for (const role of roles) assign.run(role, groupId, nowIso);
}
/* ------------------------------------------------------------ 权限清单 */
/** GET /api/permissions —— 权限点清单 + 可选角色(权限组管理页的左栏) */
router.get('/permissions', auth.requireAuth, (req, res) => {
try {
const groups = [];
for (const item of PERMISSIONS) {
let bucket = groups.find(group => group.title === item.group);
if (!bucket) { bucket = { title: item.group, items: [] }; groups.push(bucket); }
bucket.items.push({ key: item.key, label: item.label });
}
res.json({ permissions: PERMISSIONS, groups, roles: ROLES });
} catch (error) {
fail(res, error);
}
});
/** GET /api/roles —— 角色与各自所在的权限组(指派一览) */
router.get('/roles', auth.requireAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT r.role, r.group_id, g.name AS group_name
FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id
`).all();
const map = new Map(rows.map(row => [row.role, row]));
res.json({
roles: ROLES.map(item => ({
...item,
group_id: map.get(item.value)?.group_id || null,
group_name: map.get(item.value)?.group_name || null
}))
});
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 权限组 */
router.get('/permission-groups', auth.requirePermission('perm.manage'), (req, res) => {
try {
const rows = db.prepare('SELECT * FROM permission_groups ORDER BY is_system DESC, id ASC').all();
res.json({ groups: rows.map(groupDetail) });
} catch (error) {
fail(res, error);
}
});
router.post('/permission-groups', auth.requirePermission('perm.manage'), (req, res) => {
try {
const name = String(req.body?.name || '').trim();
if (!name) throw badRequest('权限组名称不能为空');
if (db.prepare('SELECT id FROM permission_groups WHERE name = ?').get(name)) throw badRequest('该权限组名称已存在');
const permissions = normalizePermissions(req.body?.permissions) || [];
const roles = normalizeRoles(req.body?.roles) || [];
const now = new Date().toISOString();
const result = db.prepare('INSERT INTO permission_groups (name, description, is_system, created_at) VALUES (?, ?, 0, ?)')
.run(name, String(req.body?.description || '').trim() || null, now);
replacePermissions(result.lastInsertRowid, permissions);
replaceRoles(result.lastInsertRowid, roles, now);
auth.audit({ username: req.user.username, action: 'permission_group_create', detail: `新建权限组「${name}」`, req });
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ group: groupDetail(row), message: '权限组已创建' });
} catch (error) {
fail(res, error);
}
});
router.patch('/permission-groups/:id', auth.requirePermission('perm.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
if (!row) throw badRequest('权限组不存在');
const now = new Date().toISOString();
const fields = [];
const values = [];
if (req.body?.name !== undefined) {
const name = String(req.body.name || '').trim();
if (!name) throw badRequest('权限组名称不能为空');
const clash = db.prepare('SELECT id FROM permission_groups WHERE name = ? AND id != ?').get(name, id);
if (clash) throw badRequest('该权限组名称已存在');
fields.push('name = ?'); values.push(name);
}
if (req.body?.description !== undefined) {
fields.push('description = ?'); values.push(String(req.body.description || '').trim() || null);
}
if (fields.length) {
fields.push('updated_at = ?'); values.push(now); values.push(id);
db.prepare(`UPDATE permission_groups SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
const permissions = normalizePermissions(req.body?.permissions);
if (permissions) replacePermissions(id, permissions);
const roles = normalizeRoles(req.body?.roles);
if (roles) replaceRoles(id, roles, now);
auth.audit({ username: req.user.username, action: 'permission_group_update', detail: `更新权限组「${row.name}」`, req });
const updated = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
res.json({ group: groupDetail(updated), message: '权限组已保存' });
} catch (error) {
fail(res, error);
}
});
router.delete('/permission-groups/:id', auth.requirePermission('perm.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
if (!row) throw badRequest('权限组不存在');
if (row.is_system) throw badRequest('系统内置权限组不可删除');
const occupied = db.prepare('SELECT COUNT(*) AS c FROM role_permission_groups WHERE group_id = ?').get(id).c;
if (occupied > 0) throw badRequest('该权限组下仍指派有角色,请先把角色改派到其它组');
db.prepare('DELETE FROM permission_group_permissions WHERE group_id = ?').run(id);
db.prepare('DELETE FROM permission_groups WHERE id = ?').run(id);
auth.audit({ username: req.user.username, action: 'permission_group_delete', detail: `删除权限组「${row.name}」`, req });
res.json({ ok: true, message: '权限组已删除' });
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 组织 */
router.get('/organizations', auth.requireAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.org_id = o.id) AS member_count
FROM organizations o ORDER BY o.id ASC
`).all();
res.json({ organizations: rows });
} catch (error) {
fail(res, error);
}
});
router.post('/organizations', auth.requirePermission('org.manage'), (req, res) => {
try {
const name = String(req.body?.name || '').trim();
if (!name) throw badRequest('组织名称不能为空');
if (db.prepare('SELECT id FROM organizations WHERE name = ?').get(name)) throw badRequest('该组织名称已存在');
const now = new Date().toISOString();
const result = db.prepare('INSERT INTO organizations (name, code, description, created_at) VALUES (?, ?, ?, ?)')
.run(name, String(req.body?.code || '').trim() || null, String(req.body?.description || '').trim() || null, now);
auth.audit({ username: req.user.username, action: 'organization_create', detail: `新建组织「${name}」`, req });
res.status(201).json({ organization: db.prepare('SELECT * FROM organizations WHERE id = ?').get(result.lastInsertRowid) });
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 组织改名与系统标识 */
/**
* PATCH /api/organizations/:id —— 改组织名/代码/描述(branding.manage)
* 组织名 = 部署方自己的公司名,可自由改;它只作业务字段用(资源边界 + 下拉显示),
* **不会**影响界面上的系统名(系统名固定,见 lib/branding.js)。
*
* ⚠️ 权限点从 `org.manage` 换成 `branding.manage`(Wayne 2026-09-15):
* 这块**只允许高管改**,所以拆成独立权限点,只勾给「公司高管」组 + 超级管理员组,
* 销售经理/业务员永远拿不到。
*/
router.patch('/organizations/:id', auth.requirePermission('branding.manage'), (req, res) => {
try {
const before = db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(Number(req.params.id));
if (!before) throw badRequest('组织不存在');
const organization = branding.updateOrg(req.params.id, req.body || {});
auth.audit({
username: req.user.username,
action: 'organization_update',
detail: `组织「${before.name}」→「${organization.name}」`,
req
});
res.json({ organization });
} catch (error) {
fail(res, error);
}
});
/** GET /api/branding —— 当前系统标识 + 公司名(登录即可读,设置页要用) */
router.get('/branding', auth.requireAuth, (req, res) => {
try {
res.json(branding.getBranding());
} catch (error) {
fail(res, error);
}
});
/**
* PUT /api/branding —— 已停用:系统名是作者固定署名,任何角色都改不了。
* 保留这条路由是为了给调用方一个明确的 403 说明,而不是含糊的 404。
* 想换公司门面的,请改组织名(PATCH /api/organizations/:id)。
*/
router.put('/branding', auth.requirePermission('branding.manage'), (req, res) => {
res.status(403).json({
error: `「${branding.SYSTEM.name}」是作者固定署名,不可修改;如需换成贵公司的名字,请修改组织名称。`
});
});
/* ------------------------------------------------------------ 用户 */
/** 用户行 → 输出对象(绝不带 password_hash),附带角色/权限组/组织的可读名 */
function userDetail(row) {
const roleMeta = ROLES.find(item => item.value === row.role);
const group = db.prepare(`
SELECT g.id, g.name FROM role_permission_groups r
JOIN permission_groups g ON g.id = r.group_id WHERE r.role = ?
`).get(row.role || '') || null;
const org = row.org_id
? db.prepare('SELECT id, name, code FROM organizations WHERE id = ?').get(row.org_id)
: null;
return {
id: row.id,
username: row.username,
display_name: row.display_name || row.username,
email: row.email || '',
phone: row.phone || '',
role: row.role || '',
role_label: roleMeta?.label || row.role || '—',
position: row.position || '',
org_id: row.org_id || null,
org_name: org?.name || '',
permission_group_name: group?.name || '',
is_active: !!row.is_active,
created_at: row.created_at,
last_login_at: row.last_login_at
};
}
function normalizeRole(value) {
const role = String(value || '').trim();
if (!ROLE_VALUES.includes(role)) throw badRequest(`角色不正确(可选:${ROLE_VALUES.join('、')})`);
return role;
}
function normalizeOrgId(value) {
if (value === null || value === undefined || value === '') return null;
const id = Number(value);
const org = db.prepare('SELECT id FROM organizations WHERE id = ?').get(id);
if (!org) throw badRequest('所属组织不存在');
return id;
}
/** GET /api/users —— 用户列表(user.manage) */
router.get('/users', auth.requirePermission('user.manage'), (req, res) => {
try {
const rows = db.prepare('SELECT * FROM users ORDER BY id ASC').all();
res.json({ users: rows.map(userDetail) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/users —— 新建用户(user.manage) */
router.post('/users', auth.requirePermission('user.manage'), (req, res) => {
try {
const username = String(req.body?.username || '').trim();
if (!username) throw badRequest('登录账号不能为空');
if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) {
throw badRequest('账号只能是 2–32 位字母 / 数字 / 下划线 / 点 / 横线');
}
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) throw badRequest('该账号已存在');
const password = String(req.body?.password || '');
if (password.length < 6) throw badRequest('密码至少 6 位');
const role = normalizeRole(req.body?.role);
const orgId = normalizeOrgId(req.body?.org_id);
const now = new Date().toISOString();
const result = db.prepare(`
INSERT INTO users (username, password_hash, display_name, email, phone, role, position, org_id, is_active, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
`).run(
username,
auth.hashPassword(password),
String(req.body?.display_name || '').trim() || null,
String(req.body?.email || '').trim() || null,
String(req.body?.phone || '').trim() || null,
role,
String(req.body?.position || '').trim() || null,
orgId,
now
);
auth.audit({ username: req.user.username, action: 'user_create', detail: `新建用户「${username}」(${role})`, req });
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ user: userDetail(row), message: '用户已创建' });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/users/:id —— 编辑用户 / 重置密码 / 停用(user.manage) */
router.patch('/users/:id', auth.requirePermission('user.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!row) throw badRequest('用户不存在');
const body = req.body || {};
const isSelf = row.id === req.user.id;
/* 自己的账号:不许改角色(防止把自己锁在门外)、不许停用 */
if (isSelf && body.role !== undefined && String(body.role) !== row.role) {
throw badRequest('不能修改自己的角色');
}
if (isSelf && body.is_active !== undefined && !body.is_active) {
throw badRequest('不能停用自己的账号');
}
const fields = [];
const values = [];
if (body.display_name !== undefined) {
fields.push('display_name = ?'); values.push(String(body.display_name || '').trim() || null);
}
if (body.email !== undefined) {
fields.push('email = ?'); values.push(String(body.email || '').trim() || null);
}
if (body.phone !== undefined) {
fields.push('phone = ?'); values.push(String(body.phone || '').trim() || null);
}
if (body.position !== undefined) {
fields.push('position = ?'); values.push(String(body.position || '').trim() || null);
}
if (body.org_id !== undefined) {
fields.push('org_id = ?'); values.push(normalizeOrgId(body.org_id));
}
if (body.role !== undefined) {
const role = normalizeRole(body.role);
if (row.role === 'admin' && role !== 'admin') {
/* 防止把最后一个系统管理员改成普通角色 */
const adminCount = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND is_active = 1").get().c;
if (adminCount <= 1) throw badRequest('系统至少要保留一名可用的系统管理员');
}
fields.push('role = ?'); values.push(role);
}
if (body.is_active !== undefined) {
fields.push('is_active = ?'); values.push(body.is_active ? 1 : 0);
}
if (body.password !== undefined) {
const password = String(body.password || '');
if (password.length < 6) throw badRequest('密码至少 6 位');
fields.push('password_hash = ?'); values.push(auth.hashPassword(password));
fields.push('failed_count = ?'); values.push(0);
fields.push('locked_until = ?'); values.push(null);
}
if (!fields.length) throw badRequest('没有要更新的内容');
const now = new Date().toISOString();
fields.push('updated_at = ?'); values.push(now); values.push(id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
/* 改密/停用后踢掉该用户除当前会话外的所有会话(当前管理员自己的会话只在做自改密码时保留) */
if (body.password !== undefined || body.is_active === false) {
const keep = isSelf ? req.user.sid : null;
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(id, keep || '');
}
const summary = [
body.password !== undefined ? '重置密码' : null,
body.is_active !== undefined ? (body.is_active ? '启用' : '停用') : null,
body.role !== undefined && String(body.role) !== row.role ? `角色改为 ${body.role}` : null
].filter(Boolean).join('、') || '更新资料';
auth.audit({ username: req.user.username, action: 'user_update', detail: `${summary}:「${row.username}」`, req });
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
res.json({ user: userDetail(updated), message: '用户已保存' });
} catch (error) {
fail(res, error);
}
});
module.exports = router;
+212
View File
@@ -0,0 +1,212 @@
/**
* 智能配置:多模型接入、连通性测试、模型清单、用途分配。
* 挂载点:/api/ai
*
* 权限:
* - 接入配置的读与写都要 `ai.manage`(配置里有 base_url 与密钥打码,属系统管理面);
* - `POST /api/ai/chat` 只要登录 —— 它是给所有用户用的对话能力,不是管理动作。
*
* 多模型并行(Wayne 2026-09-15):
* 可以同时接多家、每家多个模型,然后按「用途」把不同模型派给不同活 ——
* 合同解析用长文本强的,图片识别用多模态的,日常对话用便宜的。
*/
const express = require('express');
const auth = require('../lib/auth');
const ai = require('../lib/aiProviders');
const { MODES, PRESETS, PURPOSES, PURPOSE_KEYS } = require('../lib/aiPresets');
const router = express.Router();
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/不能为空|不正确|不存在|无效|格式|只能|超过/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/** GET /api/ai/config —— 一次拿全:预设、兼容模式、用途、已接入的服务商、用途分配 */
router.get('/config', auth.requirePermission('ai.manage'), (req, res) => {
try {
res.json({
presets: PRESETS,
modes: MODES,
purposes: PURPOSES,
providers: ai.listProviders().map(ai.publicProvider),
assignments: ai.listAssignments(),
overview: ai.overview()
});
} catch (error) {
fail(res, error);
}
});
/** GET /api/ai/status —— 轻量状态:小皮这类普通用户界面用(不含任何配置细节) */
router.get('/status', auth.requireAuth, (req, res) => {
try {
const resolved = ai.resolveAssignment('assistant_chat');
res.json({
configured: Boolean(resolved),
assistant: resolved ? { provider: resolved.provider.name, model: resolved.model } : null
});
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers —— 新增一家模型服务 */
router.post('/providers', auth.requirePermission('ai.manage'), (req, res) => {
try {
const provider = ai.upsertProvider(req.body || {});
auth.audit({
username: req.user.username,
action: 'ai_provider_create',
detail: `接入模型服务「${provider.name}」(${provider.base_url} · ${provider.mode})`,
req
});
res.status(201).json({ provider: ai.publicProvider(provider) });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/ai/providers/:id —— 改配置(密钥不回传,省略即保持原值) */
router.patch('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => {
try {
const provider = ai.upsertProvider(req.body || {}, req.params.id);
auth.audit({
username: req.user.username,
action: 'ai_provider_update',
detail: `更新接入「${provider.name}」:${provider.enabled === false ? '停用' : '启用'} · 模型 ${provider.models.length} 个`,
req
});
res.json({ provider: ai.publicProvider(provider) });
} catch (error) {
fail(res, error);
}
});
/** DELETE /api/ai/providers/:id —— 移除接入(顺手清掉指向它的用途分配) */
router.delete('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => {
try {
const result = ai.removeProvider(req.params.id);
auth.audit({
username: req.user.username,
action: 'ai_provider_delete',
detail: `移除接入「${result.removed.name}」${result.cleared_assignments ? `(同时清掉 ${result.cleared_assignments} 处用途分配)` : ''}`,
req
});
res.json(result);
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers/:id/test —— 连通性测试(真发请求) */
router.post('/providers/:id/test', auth.requirePermission('ai.manage'), async (req, res) => {
try {
const provider = ai.getProvider(req.params.id);
if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 });
const result = await ai.testProvider(provider);
ai.rememberTest(provider.id, {
ok: result.ok,
at: result.at,
latency_ms: result.latency_ms,
detail: result.detail
});
auth.audit({
username: req.user.username,
action: 'ai_provider_test',
detail: `连通测试「${provider.name}」:${result.ok ? '通过' : '失败'} · ${result.detail}`,
req,
ok: result.ok ? 1 : 0
});
res.json({ result, provider: ai.publicProvider(ai.getProvider(provider.id)) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers/:id/models —— 拉取可用模型(save=1 时顺便存进该接入) */
router.post('/providers/:id/models', auth.requirePermission('ai.manage'), async (req, res) => {
try {
const provider = ai.getProvider(req.params.id);
if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 });
const result = await ai.fetchModels(provider);
if (!result.ok && !result.models.length) {
return res.status(400).json({ error: result.error || '未能读取模型清单' });
}
let saved = null;
if (String(req.body?.save || '') === '1' && result.models.length) {
saved = ai.publicProvider(ai.upsertProvider({ models: result.models }, provider.id));
}
res.json({ models: result.models, source: result.source, detail: result.detail, provider: saved });
} catch (error) {
fail(res, error);
}
});
/**
* PUT /api/ai/assignments —— 保存「用途 → 模型」分配。
* 传 { assignments: { purpose: { provider_id, model } | null } },整体替换。
*/
router.put('/assignments', auth.requirePermission('ai.manage'), (req, res) => {
try {
const input = req.body?.assignments;
if (input === undefined || input === null || typeof input !== 'object' || Array.isArray(input)) {
throw Object.assign(new Error('分配内容格式不正确'), { statusCode: 400 });
}
const providers = ai.listProviders();
const next = {};
for (const [purpose, value] of Object.entries(input)) {
if (!PURPOSE_KEYS.includes(purpose)) {
throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 });
}
if (value === null || value === undefined || value === '') continue;
const provider = providers.find(item => item.id === String(value.provider_id));
if (!provider) throw Object.assign(new Error(`用途「${purpose}」指向的模型接入不存在`), { statusCode: 400 });
const model = String(value.model || '').trim();
if (!model) continue;
next[purpose] = { provider_id: provider.id, model };
}
ai.saveAssignments(next);
auth.audit({
username: req.user.username,
action: 'ai_assignment_update',
detail: `用途分配:${Object.entries(next).map(([k, v]) => `${k}→${v.model}`).join(',') || '(全部清空)'}`,
req
});
res.json({ assignments: next, overview: ai.overview() });
} catch (error) {
fail(res, error);
}
});
/**
* POST /api/ai/chat —— 按用途发起一次真实对话(试跑 / 小皮内部使用)。
* body: { prompt | messages, purpose, temperature }
*/
router.post('/chat', auth.requireAuth, async (req, res) => {
try {
const purpose = String(req.body?.purpose || 'assistant_chat');
if (!PURPOSE_KEYS.includes(purpose)) {
throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 });
}
const messages = Array.isArray(req.body?.messages) && req.body.messages.length
? req.body.messages.map(item => ({ role: item.role === 'assistant' ? 'assistant' : 'user', content: String(item.content || '') }))
: [{ role: 'user', content: String(req.body?.prompt || '').trim() }];
if (!messages[0].content) throw Object.assign(new Error('请输入内容'), { statusCode: 400 });
const result = await ai.chatByPurpose(purpose, messages, {
temperature: req.body?.temperature
});
if (!result) {
return res.status(409).json({ error: '还没有为这个用途分配模型,请先在「设置 → 智能配置」里接入并分配' });
}
if (!result.ok) return res.status(502).json({ error: `${result.provider} 调用失败:${result.error}`, ...result });
res.json(result);
} catch (error) {
fail(res, error);
}
});
module.exports = router;
+182
View File
@@ -0,0 +1,182 @@
/**
* 账号与个人资料。
* 挂载点:/api(路由内部自带 /auth/* 与 /me* 前缀)
*
* 免登录白名单在 server.js 的 /api 登录门里声明(health / auth\/ / system\/),
* 所以这里 /auth/login、/auth/logout、/auth/me 都可以在没有会话时访问。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const { ROLES } = require('../lib/permissions');
const router = express.Router();
const ROLE_VALUES = ROLES.map(item => item.value);
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/必须|不能为空|不正确|不存在|不一致|格式|至少|已停用|已锁定/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/* ------------------------------------------------------------ 登录相关 */
/** POST /api/auth/login */
router.post('/auth/login', (req, res) => {
try {
const username = String(req.body?.username || '').trim();
const password = String(req.body?.password || '');
if (!username) throw badRequest('请输入用户名');
if (!password) throw badRequest('请输入密码');
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) {
auth.audit({ username, action: 'login_failed', detail: '用户名不存在', req, ok: 0 });
throw badRequest('用户名或密码不正确');
}
if (user.locked_until && new Date(user.locked_until).getTime() > Date.now()) {
const minutes = Math.max(1, Math.ceil((new Date(user.locked_until).getTime() - Date.now()) / 60000));
auth.audit({ username, action: 'login_blocked', detail: '账号锁定中', req, ok: 0 });
throw badRequest(`账号已锁定,请 ${minutes} 分钟后再试`);
}
if (!user.is_active) {
auth.audit({ username, action: 'login_blocked', detail: '账号已停用', req, ok: 0 });
throw badRequest('账号已停用,请联系管理员');
}
if (!auth.verifyPassword(password, user.password_hash)) {
const failed = Number(user.failed_count || 0) + 1;
const lockedUntil = failed >= auth.MAX_FAILED
? new Date(Date.now() + auth.LOCK_MS).toISOString()
: null;
db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?').run(failed, lockedUntil, user.id);
auth.audit({ username, action: 'login_failed', detail: `密码错误(第 ${failed} 次)`, req, ok: 0 });
throw badRequest(lockedUntil
? `密码连续错误 ${failed} 次,账号已锁定 15 分钟`
: '用户名或密码不正确');
}
const now = new Date().toISOString();
db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL, last_login_at = ? WHERE id = ?').run(now, user.id);
const sessionId = auth.createSession(user.id, req);
auth.setSessionCookie(res, sessionId);
auth.audit({ username: user.username, action: 'login', detail: '登录成功', req });
res.json({ user: auth.publicUser({ ...user, last_login_at: now }) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/auth/logout */
router.post('/auth/logout', (req, res) => {
try {
if (req.user) {
auth.destroySession(req.user.sid);
auth.audit({ username: req.user.username, action: 'logout', detail: '退出登录', req });
}
auth.clearSessionCookie(res);
res.json({ ok: true });
} catch (error) {
fail(res, error);
}
});
/** GET /api/auth/me —— 前端启动时用它判断是否已登录 */
router.get('/auth/me', (req, res) => {
if (!req.user) return res.status(401).json({ error: '请先登录' });
res.json({ user: auth.publicUser(req.user) });
});
/* ------------------------------------------------------------ 个人资料 */
const PROFILE_TEXT_FIELDS = [
['display_name', '显示名'],
['email', '联系邮箱'],
['phone', '电话'],
['address', '地址'],
['position', '职位'],
['avatar', '头像']
];
/** PATCH /api/me —— 保存个人资料 */
router.patch('/me', (req, res) => {
try {
if (!req.user) return res.status(401).json({ error: '请先登录' });
const fields = [];
const values = [];
for (const [key, label] of PROFILE_TEXT_FIELDS) {
if (req.body?.[key] === undefined) continue;
const value = String(req.body[key] ?? '').trim();
if (key === 'display_name' && !value) throw badRequest('显示名不能为空');
if (key === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw badRequest('联系邮箱格式不正确');
}
fields.push(`${key} = ?`);
values.push(value || null);
}
if (req.body?.role !== undefined) {
const role = String(req.body.role || '').trim();
if (!ROLE_VALUES.includes(role)) throw badRequest('角色取值不在允许范围内');
fields.push('role = ?');
values.push(role);
}
if (req.body?.org_id !== undefined) {
const orgId = Number(req.body.org_id);
if (!Number.isInteger(orgId) || !db.prepare('SELECT id FROM organizations WHERE id = ?').get(orgId)) {
throw badRequest('所属组织不存在');
}
fields.push('org_id = ?');
values.push(orgId);
}
if (!fields.length) throw badRequest('没有需要保存的内容');
fields.push('updated_at = ?');
values.push(new Date().toISOString());
values.push(req.user.id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
auth.audit({ username: updated.username, action: 'profile_update', detail: '更新个人资料', req });
res.json({ user: auth.publicUser(updated), message: '个人资料已保存' });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/me/password —— 改密码:三段校验全过才落库 */
router.patch('/me/password', (req, res) => {
try {
if (!req.user) return res.status(401).json({ error: '请先登录' });
const current = String(req.body?.current_password || '');
const next = String(req.body?.new_password || '');
const confirm = String(req.body?.confirm_password || '');
if (!current) throw badRequest('请输入当前密码');
if (!next) throw badRequest('请输入新密码');
if (next.length < 6) throw badRequest('新密码至少 6 位');
if (next !== confirm) throw badRequest('两次输入的新密码不一致');
if (next === current) throw badRequest('新密码不能与当前密码相同');
if (!auth.verifyPassword(current, req.user.password_hash)) {
auth.audit({ username: req.user.username, action: 'password_change_failed', detail: '当前密码不正确', req, ok: 0 });
throw badRequest('当前密码不正确');
}
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
.run(auth.hashPassword(next), new Date().toISOString(), req.user.id);
/* 保住当前会话,踢掉其它设备上已登录的会话 */
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(req.user.id, req.user.sid);
auth.audit({ username: req.user.username, action: 'password_change', detail: '修改密码成功', req });
res.json({ ok: true, message: '密码已修改' });
} catch (error) {
fail(res, error);
}
});
module.exports = router;
+431
View File
@@ -0,0 +1,431 @@
/**
* 合同清洗中心:解析规则、AI 兜底解析、批量入库、原件下载。
* 挂载点:/api/contracts(挂在登录门之后,见 server.js)
*
* 与 server.js 里那三个基础接口(上传 / 任务列表 / 单条复核)的分工:
* 那边只管「把文件变成待复核的行」,这边管「行怎么认出来的、怎么进产品库」。
*
* 权限口径:仍然沿用 `cleanup.view` —— 系统里目前没有 `cleanup.edit` 这个点,
* 复核与入库都属于「合同清洗」这一个能力块。等以后要拆细粒度再补点。
*/
const express = require('express');
const path = require('path');
const db = require('../db/db');
const auth = require('../lib/auth');
const catalog = require('../lib/skuCatalog');
const parser = require('../lib/contractParser');
const files = require('../lib/contractFiles');
const aiProviders = require('../lib/aiProviders');
const router = express.Router();
const nowIso = () => new Date().toISOString();
function badRequest(message) {
return Object.assign(new Error(message), { statusCode: 400 });
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode || (/不存在|无效|格式|不能为空|请先|没有|已过期/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
const getJob = id => db.prepare('SELECT * FROM contract_import_jobs WHERE id = ?').get(Number(id));
/* ============================== 解析规则 ============================== */
/**
* 当前生效的规则(内置默认 + 用户覆盖)。
* 写这个接口是为了让「通用性」可验证:用户能一眼看到系统到底认哪些词,
* 再照着 JSON 覆盖文件去加自己行业的说法。
*/
router.get('/parse-rules', (req, res) => {
try {
const rules = parser.loadRules();
res.json({
options: rules.options,
fields: Object.entries(rules.fields).map(([key, field]) => ({
key,
label: field.label,
type: field.type,
keys: field.keys,
exclude: field.exclude,
regex: field.regex || []
})),
override_source: rules.override_source,
override_file: parser.overrideFile,
hint: '覆盖文件(可选):在数据目录放 contract-rules.json,或在设置表 settings["contract.rules"] 写同样的 JSON。'
});
} catch (error) { fail(res, error); }
});
/** 保存覆盖(存 settings;文件形式的覆盖优先级更高,见 contractParser.readOverride) */
router.put('/parse-rules', auth.requirePermission('cleanup.view'), (req, res) => {
try {
const body = req.body;
if (!body || typeof body !== 'object' || Array.isArray(body)) throw badRequest('规则必须是 JSON 对象');
const allowed = ['fields', 'options', 'disable'];
if (!Object.keys(body).some(key => allowed.includes(key))) {
throw badRequest(`规则里至少要有一项:${allowed.join(' / ')}`);
}
if (body.fields && (typeof body.fields !== 'object' || Array.isArray(body.fields))) throw badRequest('fields 必须是对象');
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run('contract.rules', JSON.stringify(body));
auth.audit({ username: req.user?.username, action: 'contract_rules_update', detail: '更新合同解析规则覆盖', req });
res.json({ ok: true, rules: parser.loadRules(), message: '规则已保存,下次解析立即生效' });
} catch (error) { fail(res, error); }
});
/* ============================== AI 解析 ============================== */
/** 从模型返回里把 JSON 数组抠出来:容忍 ```json 代码块、前后废话 */
function extractJsonArray(text) {
let body = String(text || '').trim();
body = body.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
const start = body.indexOf('[');
const end = body.lastIndexOf(']');
if (start < 0 || end <= start) return null;
try {
const parsed = JSON.parse(body.slice(start, end + 1));
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
const AI_FIELDS = ['sku', 'product_name', 'spec', 'purchase_price', 'currency', 'quantity', 'moq'];
const AI_SYSTEM_PROMPT = [
'你是外贸采购数据抽取引擎。用户会给你一张供应商合同/报价单里的表格(CSV 文本,可能由 Excel 转出,列的顺序与命名都不固定)。',
'请把每一行商品抽成 JSON。',
'',
'硬性要求:',
'1) 只输出一个 JSON 数组,不要任何解释、不要 markdown 代码块、不要多余字段。',
'2) purchase_price 必须是数值(采购价/供货价/出厂价/单价/EXW/FOB 都算);',
' 金额合计、总价、小计、税额、运费、建议零售价一律**不要**当采购价。',
'3) quantity 是数量,moq 是最小起订量,两者不要混。',
'4) currency 用 ISO 三字码(CNY/USD/EUR…),没有线索就 null。',
`5) 每个对象只允许这些键:${AI_FIELDS.join(', ')}。`,
'6) 抽不到的字段填 null,**不要编造**。',
'7) 忽略小计/合计/备注/页脚/公司抬头这类非商品行。'
].join('\n');
/**
* 用模型重解析一个任务。
*
* 为什么用「原件」而不是「已解析的行」:规则解析失败时(正是需要 AI 的场景),
* 队列里可能只有一行占位记录,把那些行喂给模型等于让它猜一份残缺的表。
* 所以优先读 7 天内还留着的原件;原件过期了才退回用队列里的原文。
*/
router.post('/import-jobs/:id/ai-parse', auth.requirePermission('cleanup.view'), async (req, res) => {
try {
const job = getJob(req.params.id);
if (!job) throw badRequest('导入任务不存在');
const resolved = aiProviders.resolveAssignment('contract_parse');
if (!resolved) {
throw badRequest('还没有给「合同智能解析」分配模型:请到 设置 → 智能配置 里,把某个接入分配给这个用途');
}
// 拼素材:优先原件,其次队列里的 raw_text
let sourceText = '';
let sourceLabel = '';
let sourceNames = [];
try { sourceNames = JSON.parse(job.source_files || '[]'); } catch { sourceNames = []; }
for (const name of sourceNames) {
const original = files.readOriginal(job.job_no, name);
if (!original) continue;
const described = parser.describeForAi(original.buffer, original.name, { maxRows: 120, maxChars: 9000 });
if (!described.text) continue;
sourceText += `\n### 文件:${original.name}\n${described.text}\n`;
sourceLabel = original.name;
if (sourceText.length > 16000) break;
}
if (!sourceText) {
const rows = db.prepare(
'SELECT row_no, raw_text FROM contract_import_items WHERE job_id = ? AND raw_text IS NOT NULL ORDER BY row_no LIMIT 120'
).all(job.id);
if (rows.length >= 2) {
sourceText = rows.map(row => row.raw_text).join('\n');
sourceLabel = '队列内已解析的原文';
}
}
if (!sourceText.trim()) {
throw badRequest('拿不到可解析的内容:原件已过保留期(7 天)被清理,请把文件重新上传一次');
}
const answer = await aiProviders.chatByPurpose('contract_parse', [
{ role: 'system', content: AI_SYSTEM_PROMPT },
{ role: 'user', content: `请抽取下面表格里的商品行:\n\n${sourceText}` }
], { maxTokens: 4000, temperature: 0 });
if (!answer) throw badRequest('没有可用的模型接入');
if (!answer.ok) throw Object.assign(new Error(`模型调用失败:${answer.error}`), { statusCode: 502 });
const list = extractJsonArray(answer.text);
if (!list || !list.length) throw badRequest('模型没有返回可用的 JSON 数组,建议稍后重试或改用手工补录');
const items = list.map((row, index) => ({
sku: row?.sku ? String(row.sku).trim().slice(0, 120) : null,
product_name: row?.product_name ? String(row.product_name).trim().slice(0, 200) : null,
spec: row?.spec ? String(row.spec).trim().slice(0, 200) : null,
purchase_price: parser.toNumber(row?.purchase_price),
currency: row?.currency ? String(row.currency).trim().toUpperCase().slice(0, 8) : null,
quantity: parser.toNumber(row?.quantity),
moq: parser.toNumber(row?.moq),
row_no: index + 1
})).filter(row => AI_FIELDS.some(key => row[key] !== null));
if (!items.length) throw badRequest('模型返回的内容里没有可用的字段,建议改用手工补录');
const now = nowIso();
let replaced = 0;
db.transaction(() => {
/* 只清掉「上一次机器解析出来的、还没被人处理过」的行。
已经复核过/已入库的行必须留着 —— 用户可能在 AI 之前已经手工修过几条。 */
const stale = db.prepare(
`SELECT COUNT(*) AS count FROM contract_import_items
WHERE job_id = ? AND status = 'pending_review' AND committed_at IS NULL
AND COALESCE(parse_source, 'rules') = 'rules'`
).get(job.id).count;
db.prepare(
`DELETE FROM contract_import_items
WHERE job_id = ? AND status = 'pending_review' AND committed_at IS NULL
AND COALESCE(parse_source, 'rules') = 'rules'`
).run(job.id);
replaced = stale;
const ins = db.prepare(
`INSERT INTO contract_import_items (job_id, filename, row_no, raw_text, sku, product_name, spec,
purchase_price, currency, quantity, moq, status, review_note,
parse_source, created_at, updated_at)
VALUES (@job_id, @filename, @row_no, @raw_text, @sku, @product_name, @spec,
@purchase_price, @currency, @quantity, @moq, 'pending_review', @review_note,
'ai', @now, @now)`
);
for (const item of items) {
ins.run({
job_id: job.id,
filename: sourceLabel || (sourceNames[0] || 'AI 解析'),
row_no: item.row_no,
raw_text: JSON.stringify(item).slice(0, 500),
sku: item.sku,
product_name: item.product_name,
spec: item.spec,
purchase_price: item.purchase_price,
currency: item.currency,
quantity: item.quantity,
moq: item.moq,
review_note: 'AI 解析结果,提交前请复核',
now
});
}
db.prepare(`UPDATE contract_import_jobs SET status = 'pending_review', parse_source = 'ai',
notes = ?, updated_at = ? WHERE id = ?`)
.run(`AI 解析:${answer.provider} / ${answer.model} · 抽出 ${items.length} 行`, now, job.id);
})();
auth.audit({
username: req.user?.username,
action: 'contract_ai_parse',
detail: `任务 ${job.job_no} 用 AI 解析出 ${items.length} 行(替换未复核的规则行 ${replaced} 条)`,
req
});
res.json({
ok: true,
job: getJob(job.id),
extracted: items.length,
replaced,
provider: answer.provider,
model: answer.model,
message: `AI 解析出 ${items.length} 行,已进入待复核队列`
});
} catch (error) { fail(res, error); }
});
/* ============================== 入库(批量) ============================== */
/**
* 把勾选的明细写进产品库。
*
* 口径(Wayne 2026-09-16 定):
* - 已存在的货号 → 更新采购价(并写一条价格历史,来源记合同文件名)
* - 库里没有的货号 → 新建(沿用产品库原有的「撞号落 -COPY2」规则)
* - 入库即视为复核通过,同时补上 reviewed_by / committed_at —— 这两项以前是空的
*
* 逐条一个事务:一次勾几百条时不能让写锁一直握着(同上传那里的取舍)。
*/
router.post('/import-items/commit', auth.requirePermission('cleanup.view'), (req, res) => {
try {
const rawIds = Array.isArray(req.body?.ids) ? req.body.ids : [];
const ids = [...new Set(rawIds.map(Number).filter(Number.isInteger))].slice(0, 2000);
if (!ids.length) throw badRequest('请先勾选要入库的明细');
const actor = req.user?.username || null;
const orgId = req.user?.org_id ?? null;
const now = nowIso();
const summary = { updated: 0, created: 0, skipped: [], jobs: new Set(), contracts: new Set() };
for (const id of ids) {
const item = db.prepare('SELECT * FROM contract_import_items WHERE id = ?').get(id);
if (!item) { summary.skipped.push({ id, reason: '明细不存在' }); continue; }
if (item.committed_at) {
summary.skipped.push({ id, sku: item.product_sku, reason: '之前已经入库过了' });
continue;
}
const skuText = String(item.sku || '').trim();
if (!skuText) { summary.skipped.push({ id, reason: '没有货号,无法入库' }); continue; }
const price = parser.toNumber(item.purchase_price);
const moq = parser.toNumber(item.moq);
const key = catalog.normalizeSku(skuText);
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(key);
try {
let productSku = key;
if (existing) {
if (price === null) { summary.skipped.push({ id, sku: existing.display_sku || key, reason: '没有采购价,未更新' }); continue; }
db.transaction(() => {
/* 只补空字段,不覆盖已有的人工命名 —— 导入是来「补价」的,不是来改名的 */
db.prepare(
`UPDATE products SET
price = @price,
moq = COALESCE(@moq, moq),
name = COALESCE(NULLIF(name, ''), @name),
spec = COALESCE(NULLIF(spec, ''), @spec),
source_contract = @source_contract,
parse_method = @parse_method,
confidence = 'high',
last_updated = @now, updated_at = @now
WHERE sku = @sku`
).run({
sku: key,
price,
moq,
name: item.product_name?.trim() || null,
spec: item.spec?.trim() || null,
source_contract: item.filename,
parse_method: item.parse_source === 'ai' ? 'ai' : 'rules',
now
});
db.prepare(
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
supplier_id, source_contract, confidence, changed_at, changed_by)
VALUES (@sku, @old_price, @price, @old_moq, @moq, @currency, @supplier_id, @source, 'high', @now, @actor)`
).run({
sku: key,
old_price: existing.price,
price,
old_moq: existing.moq,
moq,
currency: existing.currency || 'CNY',
supplier_id: existing.supplier_id ?? null,
// 价格历史里只记合同文件名(不记路径、不记任务号)
source: item.filename,
now,
actor
});
})();
summary.updated += 1;
} else {
const created = catalog.insertItem(
{ sku: skuText, name: item.product_name, spec: item.spec, price, moq },
null, null, actor, orgId,
{
sourceContract: item.filename,
parseMethod: item.parse_source === 'ai' ? 'ai' : 'rules',
currency: item.currency || 'CNY'
}
);
if (created?.error) { summary.skipped.push({ id, reason: created.error }); continue; }
productSku = created.sku;
summary.created += 1;
}
db.prepare(
`UPDATE contract_import_items
SET status = 'approved', reviewed_by = ?, reviewed_at = ?,
updated_at = ?, committed_at = ?, product_sku = ?
WHERE id = ?`
).run(req.user?.id ?? null, now, now, now, productSku, id);
summary.jobs.add(item.job_id);
summary.contracts.add(item.filename);
} catch (error) {
summary.skipped.push({ id, reason: error.message });
}
}
// 任务的「待复核」可能清空了,状态跟着走(和单条复核同一套派生规则)
for (const jobId of summary.jobs) {
const remaining = db.prepare(
`SELECT COUNT(*) AS count FROM contract_import_items WHERE job_id = ? AND status = 'pending_review'`
).get(jobId).count;
db.prepare('UPDATE contract_import_jobs SET status = ?, updated_at = ?, completed_at = ? WHERE id = ?')
.run(remaining ? 'pending_review' : 'reviewed', now, remaining ? null : now, jobId);
}
auth.audit({
username: actor,
action: 'contract_commit',
detail: `合同入库:更新 ${summary.updated} 条、新建 ${summary.created} 条、跳过 ${summary.skipped.length} 条`
+ (summary.contracts.size ? `(来源:${[...summary.contracts].join('、')})` : ''),
req
});
res.json({
ok: true,
updated: summary.updated,
created: summary.created,
skipped: summary.skipped,
message: `入库完成:更新 ${summary.updated} 条、新建 ${summary.created} 条`
+ (summary.skipped.length ? `、跳过 ${summary.skipped.length} 条` : '')
});
} catch (error) { fail(res, error); }
});
/* ============================== 原件(7 天) ============================== */
router.get('/import-jobs/:id/files', (req, res) => {
try {
const job = getJob(req.params.id);
if (!job) throw badRequest('导入任务不存在');
const list = files.listOriginals(job.job_no);
const created = Date.parse(job.created_at);
const expires = Number.isFinite(created)
? new Date(created + files.RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
: null;
res.json({
files: list,
retention_days: files.RETENTION_DAYS,
expires_at: expires,
expired: list.length === 0 && Number.isFinite(created) && Date.now() > created + files.RETENTION_DAYS * 24 * 60 * 60 * 1000
});
} catch (error) { fail(res, error); }
});
/** 下载一份原件。过期清理后返回 410,让界面能说清「原件已清理」而不是报一个坏链接。 */
router.get('/import-jobs/:id/file', (req, res) => {
try {
const job = getJob(req.params.id);
if (!job) throw badRequest('导入任务不存在');
const name = String(req.query.name || '').trim();
if (!name) throw badRequest('缺少文件名参数 name');
const file = files.readOriginal(job.job_no, name);
if (!file) {
return res.status(410).json({
error: `原件已过保留期(${files.RETENTION_DAYS} 天)被自动清理,解析结果不受影响`
});
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader(
'Content-Disposition',
`attachment; filename="contract"; filename*=UTF-8''${encodeURIComponent(path.basename(file.name))}`
);
res.end(file.buffer);
} catch (error) { fail(res, error); }
});
module.exports = router;
+31
View File
@@ -0,0 +1,31 @@
/**
* 汇率接口。
* 挂载点:/api/fx
*/
const express = require('express');
const fx = require('../lib/fx');
const router = express.Router();
/**
* GET /api/fx/rates?force=1
*
* 返回以 USD 为基准的完整汇率表:
* { ok, base: 'USD', rates: { USD:1, CNY:7.1, ... }, updatedAt, source, sourceLabel,
* stale, cached, warning? }
*
* 前端拿到后自行做交叉汇率:amount * rates[to] / rates[from]。
* 一次请求覆盖所有币种,切货币不再发请求。
*/
router.get('/rates', async (req, res) => {
try {
const force = String(req.query.force || '') === '1';
const data = await fx.getRates(force);
res.json({ ok: true, base: 'USD', count: Object.keys(data.rates).length, ...data });
} catch (error) {
// getRates 内部已兜底,走到这里属于意料之外
res.status(500).json({ ok: false, error: error.message });
}
});
module.exports = router;
+210
View File
@@ -0,0 +1,210 @@
/**
* 系统日志(审计轨迹)—— 读取 audit_log 并提供筛选、统计与导出。
*
* 为什么要有这一页:Wayne 2026-09-15 参照 GoodJob 的系统日志页提出 —— 登录、改密、
* 用户/权限组/组织变更、客户归属流转、商机状态、AI 接入变更都会写 audit_log,
* 但这些记录此前只能在库里看见,界面上没有任何入口。
*
* ⚠️ 挂载点刻意**不放在 /api/system/ 下** —— 那个前缀在免登录白名单里
* (`/^\/api\/(health|auth\/|system\/)/`),放在那儿等于把全公司的操作日志公开。
*
* 权限:`audit.view`,按 Wayne 的口径**只授给系统管理员组**(见 lib/permissions.js)。
* 界面入口同理:没这个点的人,设置里根本看不到「系统日志」这一项。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const router = express.Router();
/* ---------------------------------------------------------------------------
* 分类:按 action 前缀归组。新增 action 时如果这里没覆盖,会落到「系统」,
* 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。
* ------------------------------------------------------------------------- */
const CATEGORIES = [
{ key: 'auth', label: '认证', match: /^(login|logout|password_|profile_)/ },
{ key: 'user', label: '用户', match: /^user_/ },
{ key: 'permission', label: '权限', match: /^permission_group_/ },
{ key: 'org', label: '组织', match: /^(organization_|branding_)/ },
{ key: 'customer', label: '客户', match: /^(customer_|pool_|prospect_)/ },
{ key: 'contract', label: '合同', match: /^contract_/ },
{ key: 'ai', label: '智能', match: /^ai_/ }
]
const CATEGORY_LABELS = { system: '系统', ...Object.fromEntries(CATEGORIES.map(c => [c.key, c.label])) }
/* 动作的中文名。查不到就显示原始 action —— 便于新动作上线时立刻可读,不用等改代码。 */
const ACTION_LABELS = {
login: '登录成功',
login_failed: '登录失败',
login_blocked: '登录被拦截',
logout: '退出登录',
profile_update: '更新个人资料',
password_change: '修改密码',
password_change_failed: '修改密码失败',
user_create: '新建用户',
user_update: '更新用户',
permission_group_create: '新建权限组',
permission_group_update: '更新权限组',
permission_group_delete: '删除权限组',
organization_create: '新建组织',
organization_update: '更新组织信息',
branding_update: '更新系统标识',
customer_create: '新建客户',
customer_to_pool: '客户丢入公海',
customer_claim: '公海领取客户',
prospect_deal_status: '商机状态变更',
ai_provider_create: '新增模型接入',
ai_provider_update: '更新模型接入',
ai_provider_delete: '移除模型接入',
ai_provider_test: '测试模型连通性',
ai_assignment_update: '调整模型用途分配',
contract_import: '导入合同',
contract_commit: '合同入库',
contract_ai_parse: '合同 AI 解析',
contract_rules_update: '更新合同解析规则'
}
function categoryOf(action = '') {
const hit = CATEGORIES.find(c => c.match.test(action))
return hit ? hit.key : 'system'
}
/* 等级:成功 / 信息 / 警告 / 错误。登录失败算「警告」——它通常是输错密码,
而不是系统故障,混进错误里会把真正的故障淹掉。 */
const WRITE_ACTION = /_(create|update|delete|change|claim)$/
function levelOf(row) {
if (row.ok) {
if (WRITE_ACTION.test(row.action) || row.action === 'customer_to_pool') return 'success'
return 'info'
}
return /^(login_failed|login_blocked)$/.test(row.action) ? 'warning' : 'error'
}
const LEVEL_LABELS = { info: '信息', success: '成功', warning: '警告', error: '错误' }
/* ---------------------------------------------------------------------------
* 过滤条件只在这里拼一次:列表、计数、统计、导出共用,避免四处走散。
* 全部走占位参数,不拼字符串。
* ------------------------------------------------------------------------- */
function buildFilter(query) {
const where = []
const params = []
const level = String(query.level || '').trim()
const category = String(query.category || '').trim()
const keyword = String(query.q || '').trim()
if (keyword) {
where.push('(username LIKE ? OR action LIKE ? OR detail LIKE ? OR ip LIKE ?)')
const like = `%${keyword}%`
params.push(like, like, like, like)
}
/* 分类与等级是「从 action / ok 算出来的」,SQL 里没有这两列,
所以先选出候选行再在内存里筛 —— 日志量级(万行内)完全够用。 */
const rows = db.prepare(
`SELECT id, at, username, action, detail, ip, user_agent, ok FROM audit_log
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY id DESC LIMIT 20000`
).all(...params)
const decorated = rows.map(row => ({
...row,
ok: row.ok ? 1 : 0,
category: categoryOf(row.action),
level: levelOf(row),
action_label: ACTION_LABELS[row.action] || row.action
}))
return decorated.filter(row =>
(!level || row.level === level) && (!category || row.category === category))
}
function dayStartIso() {
const d = new Date()
d.setHours(0, 0, 0, 0)
return d.toISOString()
}
function statsOf(rows) {
/* 统计永远基于「当前筛选结果」,这样切了分类/等级之后四个数字才对得上列表。 */
const today = dayStartIso()
return {
total: rows.length,
today: rows.filter(r => r.at >= today).length,
warning: rows.filter(r => r.level === 'warning').length,
error: rows.filter(r => r.level === 'error').length
}
}
function csvCell(value) {
const text = value === null || value === undefined ? '' : String(value)
return `"${text.replace(/"/g, '""')}"`
}
/** GET /api/logs —— 分页列表(带统计与可选分类) */
router.get('/logs', auth.requirePermission('audit.view'), (req, res) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
const offset = Math.max(Number(req.query.offset) || 0, 0)
const all = buildFilter(req.query)
const categoryCounts = {}
for (const row of all) categoryCounts[row.category] = (categoryCounts[row.category] || 0) + 1
res.json({
items: all.slice(offset, offset + limit),
total: all.length,
limit,
offset,
stats: statsOf(all),
categories: Object.entries(CATEGORY_LABELS)
.map(([key, label]) => ({ key, label, count: categoryCounts[key] || 0 }))
.filter(item => item.count > 0),
levels: Object.entries(LEVEL_LABELS).map(([key, label]) => ({
key, label, count: all.filter(r => r.level === key).length
}))
})
} catch (error) {
res.status(500).json({ error: '读取日志失败:' + error.message })
}
});
/** GET /api/logs/export —— 导出当前筛选结果为 CSV(带 BOM,Excel 直接打开不乱码) */
router.get('/logs/export', auth.requirePermission('audit.view'), (req, res) => {
try {
const rows = buildFilter(req.query).slice(0, 5000)
const header = ['时间', '等级', '分类', '操作人', '动作', '说明', 'IP', '结果']
const lines = [header.map(csvCell).join(',')]
for (const row of rows) {
lines.push([
row.at,
LEVEL_LABELS[row.level] || row.level,
CATEGORY_LABELS[row.category] || row.category,
row.username || '—',
row.action_label,
row.detail || '',
row.ip || '',
row.ok ? '成功' : '失败'
].map(csvCell).join(','))
}
auth.audit({
username: req.user.username,
action: 'audit_export',
detail: `导出系统日志 ${rows.length} 条`,
req
})
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="system-logs-${stamp}.csv"`)
res.send('\uFEFF' + lines.join('\r\n'))
} catch (error) {
res.status(500).json({ error: '导出日志失败:' + error.message })
}
});
module.exports = router;
+113
View File
@@ -0,0 +1,113 @@
/**
* 新客开发的「开发结果管理」。
* 挂载点:/api/prospects
*
* 口径:新客开发页只做两件事 —— ① 用线索池找到新客户;② 管理开发结果。
* 开发结果就落在 customers.deal_status 上:pending 未成交 / won 已成交 / lost 已流失。
* 这样「我的客户」和「新客开发」看到的是同一批客户,不会出现两套数据打架。
*
* 归属口径(Wayne 2026-09-14):客户「谁建的谁看得见」,所以本页也按归属过滤,
* 否则业务员在「新客开发」里能直接看到并标记同事的客户。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const access = require('../lib/customerAccess');
const router = express.Router();
const DEAL_STATUSES = ['pending', 'won', 'lost'];
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode || (/不存在|无效|格式|不能为空/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
function countBy(status, user) {
const { conditions, params } = access.scopeFilter(user, 'all');
const where = ["COALESCE(c.deal_status, 'pending') = ?", ...conditions];
return db.prepare(`SELECT COUNT(*) AS c FROM customers c WHERE ${where.join(' AND ')}`)
.get(status, ...params).c;
}
/** GET /api/prospects?deal_status=won|lost|pending&q= */
router.get('/', auth.requirePermission('prospect.view'), (req, res) => {
try {
const status = String(req.query.deal_status || '').trim();
const keyword = String(req.query.q || '').trim();
/* 归属过滤与本页自己的筛选条件合并 —— 归属条件务必放进来,它是硬边界 */
const scope = access.scopeFilter(req.user, 'all');
const conditions = [...scope.conditions];
const params = [...scope.params];
if (DEAL_STATUSES.includes(status)) {
conditions.push("COALESCE(c.deal_status, 'pending') = ?");
params.push(status);
}
if (keyword) {
conditions.push('(c.company LIKE ? OR c.company_en LIKE ? OR c.country LIKE ? OR c.email LIKE ?)');
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
const items = db.prepare(`
SELECT c.*,
u.display_name AS owner_name,
(SELECT COUNT(*) FROM contacts ct WHERE ct.customer_id = c.id) AS contact_count,
(SELECT COUNT(*) FROM customer_quotations q WHERE q.customer_id = c.id) AS quote_count,
(SELECT MAX(f.created_at) FROM follow_up_records f WHERE f.customer_id = c.id) AS last_follow_at
FROM customers c
LEFT JOIN users u ON c.owner_id = u.id
${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}
ORDER BY COALESCE(c.updated_at, c.created_at) DESC
LIMIT 300
`).all(...params);
res.json({
items,
stats: {
pending: countBy('pending', req.user),
won: countBy('won', req.user),
lost: countBy('lost', req.user)
}
});
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/prospects/:id —— 标记成交状态 */
router.patch('/:id', auth.requirePermission('prospect.view'), (req, res) => {
try {
const id = Number(req.params.id);
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
if (!customer) throw badRequest('客户不存在');
/* 标记成交 = 改客户数据,别人的客户不能改 */
if (!access.canEdit(req.user, customer)) {
const error = new Error('这条客户不在你名下,请先到客户公海领取');
error.statusCode = 403;
throw error;
}
const status = String(req.body?.deal_status || '').trim();
if (!DEAL_STATUSES.includes(status)) throw badRequest('成交状态取值无效');
db.prepare('UPDATE customers SET deal_status = ?, updated_at = ? WHERE id = ?')
.run(status, new Date().toISOString(), id);
auth.audit({
username: req.user.username,
action: 'prospect_deal_status',
detail: `客户「${customer.company}」标记为 ${status}`,
req
});
res.json({ ok: true, item: db.prepare('SELECT * FROM customers WHERE id = ?').get(id) });
} catch (error) {
fail(res, error);
}
});
module.exports = router;
+721
View File
@@ -0,0 +1,721 @@
/**
* 产品目录(SKU)接口 —— 本地实现。
*
* 这些路径以前是 4300 原样转发给 sku-manager(3300)的,现在直接落在本库执行。
* **路径一个字没改**(/api/sku/… 与 /product-images/…),所以前端零改动。
*
* 两处刻意偏离原实现,都是因为 happy 这边多了一张 product_packaging 表:
* · 删除货号 / 批量删产品时,连带清掉它的包装参数 —— product_packaging.sku 有外键
* 指向 products(sku),不清就删不动;产品都没了,包装参数留着也没意义。
* · 其余照搬 sku-manager 的口径,包括「调价历史一律保留备查」「撞号落副本不报错」。
*/
const express = require('express');
const multer = require('multer');
const path = require('path');
const db = require('../db/db');
const catalog = require('../lib/skuCatalog');
const {
groupKey, normalizeSku, getItemAttrs, getGroupAttrs, replaceAttrs,
cleanupGroupIfEmpty, insertItem, getContractFile
} = catalog;
/* ------------------------------ 上传(图片) ------------------------------ */
const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
/* 直接进内存 → 立刻写进库里,磁盘上不留明文副本(合约与产品图都属于机密数据) */
const imageUpload = multer({
storage: multer.memoryStorage(),
defParamCharset: 'utf8',
fileFilter: (req, file, cb) => {
if (!ALLOWED_IMAGE_EXT.has(path.extname(file.originalname).toLowerCase())) return cb(null, false);
cb(null, true);
},
limits: { files: 20, fileSize: 15 * 1024 * 1024 }
});
/* -------------------------------- 公共辅助 -------------------------------- */
/** 当前操作者。产品库要留「谁传的、谁改的价」,登录用户就是唯一来源。 */
const actorOf = (req) => req.user?.display_name || req.user?.username || null;
const actorOrgOf = (req) => req.user?.org_id ?? null;
const fail = (res, error, statusCode) =>
res.status(statusCode || error?.statusCode || 500).json({ error: error?.message || '请求处理失败' });
const api = express.Router();
const images = express.Router();
/* =============================== 查询与检索 =============================== */
/**
* 产品级搜索:一行一个产品,聚合价格区间/货号数;命中的货号单独列出。
*
* 用 LIKE '%kw%' 全表扫 —— 前导通配符**用不上 B-tree 索引**,加索引也没用。
* 当前规模(516 组 / 1574 货号)端到端 P50 才 8ms 出头,远不到要优化的地步。
* 真要等到十万级货号再考虑 FTS5 虚拟表,而不是加索引。
*/
api.get('/search', (req, res) => {
const q = (req.query.q || '').trim();
const supplierId = req.query.supplier_id;
const onlyLow = req.query.only_low === '1';
const where = [];
const params = {};
if (q) {
where.push(`g.id IN (
SELECT DISTINCT g2.id FROM product_groups g2
LEFT JOIN products p2 ON p2.group_id = g2.id
LEFT JOIN item_attributes a2 ON (a2.group_id = g2.id OR a2.sku = p2.sku)
WHERE g2.name LIKE @q OR g2.brand LIKE @q OR g2.category LIKE @q OR g2.description LIKE @q
OR p2.sku LIKE @q OR p2.display_sku LIKE @q OR p2.name LIKE @q
OR p2.spec LIKE @q OR p2.description LIKE @q
OR a2.name LIKE @q OR a2.value LIKE @q
)`);
params.q = `%${q}%`;
}
if (supplierId === 'none') {
where.push('g.supplier_id IS NULL');
} else if (supplierId) {
where.push('g.supplier_id = @supplier_id');
params.supplier_id = Number(supplierId);
}
if (onlyLow) {
where.push(`EXISTS (SELECT 1 FROM products pl WHERE pl.group_id = g.id AND pl.confidence = 'low')`);
}
const groups = db.prepare(
`SELECT g.id, g.name, g.brand, g.category, g.supplier_id,
s.name AS supplier_name, s.short_name AS supplier_short,
(SELECT COUNT(*) FROM products p WHERE p.group_id = g.id) AS item_count,
(SELECT MIN(p.price) FROM products p WHERE p.group_id = g.id) AS price_min,
(SELECT MAX(p.price) FROM products p WHERE p.group_id = g.id) AS price_max,
(SELECT COUNT(*) FROM products p WHERE p.group_id = g.id AND p.confidence = 'low') AS low_count,
(SELECT MAX(p.last_updated) FROM products p WHERE p.group_id = g.id) AS last_updated,
(SELECT filename FROM product_images i WHERE i.group_id = g.id
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS thumb,
(SELECT COUNT(*) FROM product_images i WHERE i.group_id = g.id) AS image_count
FROM product_groups g
LEFT JOIN suppliers s ON s.id = g.supplier_id
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY last_updated DESC LIMIT 300`
).all(params);
// 搜索词直接命中了哪些货号 —— 前端高亮用
if (q && groups.length) {
const ids = groups.map((g) => g.id);
const hits = db.prepare(
`SELECT p.group_id, p.sku, p.display_sku FROM products p
WHERE p.group_id IN (${ids.map(() => '?').join(',')})
AND (p.sku LIKE ? OR p.display_sku LIKE ? OR p.spec LIKE ? OR p.description LIKE ?
OR p.sku IN (SELECT a.sku FROM item_attributes a WHERE a.sku IS NOT NULL AND (a.name LIKE ? OR a.value LIKE ?)))`
).all(...ids, params.q, params.q, params.q, params.q, params.q, params.q);
const byGroup = {};
for (const h of hits) (byGroup[h.group_id] = byGroup[h.group_id] || []).push({ sku: h.sku, display_sku: h.display_sku });
for (const g of groups) g.matched_items = byGroup[g.id] || [];
}
res.json(groups);
});
/** 产品详情:产品 + 全部货号(含各自参数)+ 产品级参数 + 图片。 */
api.get('/group/:id', (req, res) => {
const id = Number(req.params.id);
const group = db.prepare(
`SELECT g.*, s.name AS supplier_name FROM product_groups g
LEFT JOIN suppliers s ON s.id = g.supplier_id WHERE g.id = ?`
).get(id);
if (!group) return res.status(404).json({ error: 'not found' });
const items = db.prepare(
`SELECT p.*, c.terms AS contract_terms,
(SELECT pi.filename FROM product_images pi
WHERE pi.group_id = p.group_id ORDER BY pi.is_primary DESC, pi.id ASC LIMIT 1) AS thumb
FROM products p
LEFT JOIN contracts c ON c.id = p.contract_id
WHERE p.group_id = ? ORDER BY p.display_sku`
).all(id);
for (const it of items) it.attributes = getItemAttrs.all(it.sku);
res.json({
group,
groupAttributes: getGroupAttrs.all(id),
items,
images: db.prepare('SELECT * FROM product_images WHERE group_id = ? ORDER BY is_primary DESC, id ASC').all(id)
});
});
/** 编辑产品(名称/品牌/分类/供应商/说明/产品级参数)。 */
api.put('/group/:id', (req, res) => {
const id = Number(req.params.id);
const g = db.prepare('SELECT * FROM product_groups WHERE id = ?').get(id);
if (!g) return res.status(404).json({ error: 'not found' });
const b = req.body || {};
const name = b.name?.trim();
if (!name) return res.status(400).json({ error: '产品名称不能为空' });
db.prepare(
`UPDATE product_groups SET name=@name, group_key=@group_key, brand=@brand,
category=@category, description=@description, supplier_id=@supplier_id, updated_at=@now WHERE id=@id`
).run({
id,
name,
group_key: groupKey(name),
brand: b.brand?.trim() || null,
category: b.category?.trim() || null,
description: b.description?.trim() || null,
supplier_id: b.supplier_id ? Number(b.supplier_id) : null,
now: new Date().toISOString()
});
if (Array.isArray(b.attributes)) replaceAttrs({ groupId: id }, b.attributes);
res.json({ ok: true });
});
/* ================================ 货号读写 ================================ */
/** 新建产品(必须带第一个货号)。 */
api.post('/groups', (req, res) => {
const b = req.body || {};
const name = b.name?.trim();
if (!name) return res.status(400).json({ error: '产品名称不能为空' });
if (!b.item || !String(b.item.sku || '').trim()) return res.status(400).json({ error: '至少需要一个货号' });
const supplierId = b.supplier_id ? Number(b.supplier_id) : null;
const groupId = db.prepare(
`INSERT INTO product_groups (name, group_key, brand, category, description, supplier_id, created_at)
VALUES (@name, @group_key, @brand, @category, @description, @supplier_id, @created_at)`
).run({
name,
group_key: groupKey(name),
brand: b.brand?.trim() || null,
category: b.category?.trim() || null,
description: b.description?.trim() || null,
supplier_id: supplierId,
created_at: new Date().toISOString()
}).lastInsertRowid;
const r = insertItem({ ...b.item, name }, groupId, supplierId, actorOf(req), actorOrgOf(req));
if (r.error) {
cleanupGroupIfEmpty(groupId);
return res.status(400).json(r);
}
res.json({
group_id: groupId,
sku: r.sku,
display_sku: r.displaySku,
duplicated_from: r.duplicatedFrom || null,
uploader: actorOf(req)
});
});
/** 给已有产品加货号。 */
api.post('/group/:id/items', (req, res) => {
const id = Number(req.params.id);
const g = db.prepare('SELECT * FROM product_groups WHERE id = ?').get(id);
if (!g) return res.status(404).json({ error: 'not found' });
const r = insertItem({ ...req.body, name: req.body?.name ?? g.name }, id, g.supplier_id, actorOf(req), actorOrgOf(req));
if (r.error) return res.status(400).json(r);
res.json({
sku: r.sku,
display_sku: r.displaySku,
duplicated_from: r.duplicatedFrom || null,
uploader: actorOf(req)
});
});
/** 货号详情(编辑器用):含参数与调价历史。 */
api.get('/product/:sku', (req, res) => {
const sku = req.params.sku.toUpperCase();
const product = db.prepare(
`SELECT p.*, s.name AS supplier_name, c.terms AS contract_terms
FROM products p
LEFT JOIN suppliers s ON s.id = p.supplier_id
LEFT JOIN contracts c ON c.id = p.contract_id
WHERE p.sku = ?`
).get(sku);
if (!product) return res.status(404).json({ error: 'not found' });
product.attributes = getItemAttrs.all(sku);
const history = db.prepare(
`SELECT h.*, s.name AS supplier_name FROM price_history h
LEFT JOIN suppliers s ON s.id = h.supplier_id
WHERE h.sku = ? ORDER BY h.changed_at DESC LIMIT 50`
).all(sku);
res.json({ product, history });
});
/** 手工修正货号 —— 表头千奇百怪,总有识别不准需要人工改的。 */
api.put('/product/:sku', (req, res) => {
const sku = req.params.sku.toUpperCase();
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(sku);
if (!existing) return res.status(404).json({ error: 'not found' });
const b = req.body || {};
const moq = b.moq === '' || b.moq === null || b.moq === undefined ? null : Number(b.moq);
if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return res.status(400).json({ error: '起订量必须是非负数字' });
/* 价格只有在没有合同来源时才允许手改 —— 合同解析出来的价格必须保持可追溯,
否则下次导入同一份合同又被覆盖,反而让人以为改丢了。 */
const priceEditable = !existing.contract_id;
let price = existing.price;
if (priceEditable && b.price !== undefined) {
price = b.price === '' || b.price === null ? null : Number(b.price);
if (price !== null && (!Number.isFinite(price) || price < 0)) {
return res.status(400).json({ error: '采购价必须是非负数字' });
}
}
// 移动到其他产品 / 独立成新产品
let targetGroup = existing.group_id;
const oldGroup = existing.group_id;
if (b.group_id === 'new') {
targetGroup = db.prepare(
`INSERT INTO product_groups (name, group_key, supplier_id, created_at)
VALUES (@name, @group_key, @supplier_id, @created_at)`
).run({
name: b.name?.trim() || existing.name || existing.display_sku || sku,
group_key: 'SKU:' + sku,
supplier_id: existing.supplier_id ?? null,
created_at: new Date().toISOString()
}).lastInsertRowid;
} else if (b.group_id !== undefined && b.group_id !== null && b.group_id !== '') {
const g = db.prepare('SELECT id FROM product_groups WHERE id = ?').get(Number(b.group_id));
if (!g) return res.status(400).json({ error: '目标产品不存在' });
targetGroup = g.id;
}
const now = new Date().toISOString();
db.transaction(() => {
db.prepare(
`UPDATE products SET
name = @name, spec = @spec, description = @description,
price = @price, moq = @moq, group_id = @group_id,
last_updated = @now, updated_at = @now
WHERE sku = @sku`
).run({
sku,
name: b.name?.trim() || null,
spec: b.spec?.trim() || null,
description: b.description?.trim() || null,
price,
moq,
group_id: targetGroup,
now
});
if (priceEditable && (price !== existing.price || moq !== existing.moq)) {
db.prepare(
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
supplier_id, source_contract, confidence, changed_at, changed_by)
VALUES (@sku, @old_price, @new_price, @old_moq, @new_moq, 'CNY',
@supplier_id, '手工修改', 'high', @now, @changed_by)`
).run({
sku,
old_price: existing.price,
new_price: price,
old_moq: existing.moq,
new_moq: moq,
supplier_id: existing.supplier_id ?? null,
now,
changed_by: actorOf(req)
});
}
})();
if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes);
if (targetGroup !== oldGroup) cleanupGroupIfEmpty(oldGroup);
res.json({ ok: true, priceEditable, group_id: targetGroup });
});
/** 删除货号(调价历史保留备查;所在产品变空则一并清理)。 */
api.delete('/product/:sku', (req, res) => {
const sku = req.params.sku.toUpperCase();
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(sku);
if (!existing) return res.status(404).json({ error: 'not found' });
db.transaction(() => {
db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku);
db.prepare('DELETE FROM cart_items WHERE sku = ?').run(sku);
// happy 特有:包装参数挂在外键上,产品没了必须先摘掉,否则删不动
db.prepare('DELETE FROM product_packaging WHERE sku = ?').run(sku);
db.prepare('DELETE FROM products WHERE sku = ?').run(sku);
})();
cleanupGroupIfEmpty(existing.group_id);
res.json({ ok: true });
});
/** 批量删除产品(整个 SPU 连同下挂货号)。 */
api.post('/groups/delete', (req, res) => {
const ids = (Array.isArray(req.body?.ids) ? req.body.ids : [])
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0);
if (!ids.length) return res.status(400).json({ error: '没有选中任何产品' });
const ph = ids.map(() => '?').join(',');
const imgIds = db.prepare(`SELECT id FROM product_images WHERE group_id IN (${ph})`).all(...ids).map((r) => r.id);
let groups = 0;
let items = 0;
db.transaction(() => {
// 图片二进制跟着一起删,否则库里会积一堆没人引用的 BLOB,越攒越大
catalog.deleteImagesFor(imgIds);
const skus = db.prepare(`SELECT sku FROM products WHERE group_id IN (${ph})`).all(...ids);
items = skus.length;
if (skus.length) {
const sph = skus.map(() => '?').join(',');
const args = skus.map((r) => r.sku);
db.prepare(`DELETE FROM item_attributes WHERE sku IN (${sph})`).run(...args);
db.prepare(`DELETE FROM cart_items WHERE sku IN (${sph})`).run(...args);
db.prepare(`DELETE FROM product_packaging WHERE sku IN (${sph})`).run(...args);
db.prepare(`DELETE FROM products WHERE sku IN (${sph})`).run(...args);
}
db.prepare(`DELETE FROM item_attributes WHERE group_id IN (${ph})`).run(...ids);
db.prepare(`DELETE FROM product_images WHERE group_id IN (${ph})`).run(...ids);
groups = db.prepare(`DELETE FROM product_groups WHERE id IN (${ph})`).run(...ids).changes;
})();
res.json({ ok: true, groups, items });
});
/* ================================= 小推车 ================================= */
const CART_COLUMNS = `
c.sku, c.qty, c.note, c.sort, c.added_at,
p.display_sku, p.spec, p.price, p.currency, p.moq,
p.description, p.confidence, p.last_updated,
g.id AS group_id, g.name AS product_name, g.brand, g.category,
s.name AS supplier_name, s.short_name AS supplier_short,
(SELECT filename FROM product_images i WHERE i.group_id = g.id
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS image`;
const cartRows = () => db.prepare(
`SELECT ${CART_COLUMNS}
FROM cart_items c
JOIN products p ON p.sku = c.sku
LEFT JOIN product_groups g ON g.id = p.group_id
LEFT JOIN suppliers s ON s.id = p.supplier_id
ORDER BY c.sort, c.id`
).all();
api.get('/cart', (req, res) => res.json(cartRows()));
/**
* 加入小推车。可以直接给货号,也可以给产品 id(该产品下所有货号一起进)。
* 已在车里的不动 —— 重复点「加入」不该把用户排好的顺序打乱。
*/
api.post('/cart', (req, res) => {
const skus = (Array.isArray(req.body?.skus) ? req.body.skus : []).map((s) => String(s).toUpperCase());
const groupIds = (Array.isArray(req.body?.group_ids) ? req.body.group_ids : [])
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0);
if (groupIds.length) {
const ph = groupIds.map(() => '?').join(',');
for (const r of db.prepare(`SELECT sku FROM products WHERE group_id IN (${ph}) ORDER BY display_sku`).all(...groupIds)) {
skus.push(r.sku);
}
}
if (!skus.length) return res.status(400).json({ error: '没有可加入的货号' });
const now = new Date().toISOString();
let added = 0;
db.transaction(() => {
let sort = db.prepare('SELECT COALESCE(MAX(sort), -1) AS m FROM cart_items').get().m;
const exists = db.prepare('SELECT 1 FROM cart_items WHERE sku = ?');
const real = db.prepare('SELECT 1 FROM products WHERE sku = ?');
const ins = db.prepare('INSERT INTO cart_items (sku, sort, added_at) VALUES (@sku, @sort, @added_at)');
for (const sku of skus) {
if (exists.get(sku) || !real.get(sku)) continue;
ins.run({ sku, sort: ++sort, added_at: now });
added += 1;
}
})();
res.json({ ok: true, added, rows: cartRows() });
});
api.patch('/cart/:sku', (req, res) => {
const sku = req.params.sku.toUpperCase();
if (!db.prepare('SELECT 1 FROM cart_items WHERE sku = ?').get(sku)) return res.status(404).json({ error: 'not found' });
const b = req.body || {};
if ('qty' in b) {
// 空/0 表示「没定数量」,出报价单时按未填处理,不要落成 0
const n = Number(b.qty);
db.prepare('UPDATE cart_items SET qty = ? WHERE sku = ?').run(Number.isFinite(n) && n > 0 ? Math.round(n) : null, sku);
}
if ('note' in b) {
db.prepare('UPDATE cart_items SET note = ? WHERE sku = ?').run(String(b.note || '').trim() || null, sku);
}
res.json({ ok: true, rows: cartRows() });
});
api.delete('/cart/:sku', (req, res) => {
db.prepare('DELETE FROM cart_items WHERE sku = ?').run(req.params.sku.toUpperCase());
res.json({ ok: true, rows: cartRows() });
});
api.post('/cart/clear', (req, res) => {
db.prepare('DELETE FROM cart_items').run();
res.json({ ok: true, rows: [] });
});
/** 拖拽排序落库。只认车里已有的货号,顺序按传入数组重排。 */
api.put('/cart/order', (req, res) => {
const skus = (Array.isArray(req.body?.skus) ? req.body.skus : []).map((s) => String(s).toUpperCase());
db.transaction(() => {
const upd = db.prepare('UPDATE cart_items SET sort = ? WHERE sku = ?');
skus.forEach((sku, i) => upd.run(i, sku));
})();
res.json({ ok: true, rows: cartRows() });
});
/* =============================== 参数与图片 =============================== */
/** 参数名自动补全 —— 输过一次的参数名,下次直接选。 */
api.get('/attr-names', (req, res) => {
const q = (req.query.q || '').trim();
const rows = q
? db.prepare('SELECT DISTINCT name FROM item_attributes WHERE name LIKE ? ORDER BY name LIMIT 30').all(`%${q}%`)
: db.prepare('SELECT DISTINCT name FROM item_attributes ORDER BY name LIMIT 30').all();
res.json(rows.map((r) => r.name));
});
/**
* 批量取货号缩略图。
*
* 「凡是出现货号的地方最前面都要有缩略图」,那些位置(询盘明细、报价明细、合同待复核、
* 选品器)手里只有货号、没有图片字段 —— 逐个调 /api/sku/product/:sku 既慢又拿不到图,
* 所以给一个一次问一批的入口。
*
* 图片挂在**产品组**上(product_images.group_id),先按货号找到组再取该组主图。
* 大小写不敏感(客户端传来的货号大小写不一定规范)。单次上限 500,防止被塞超大数组。
*/
api.get('/thumbs', (req, res) => {
const skus = [...new Set(String(req.query.skus || '').split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 500);
if (!skus.length) return res.json({ thumbs: {} });
const rows = db.prepare(
`SELECT p.sku,
(SELECT pi.filename FROM product_images pi
WHERE pi.group_id = p.group_id AND pi.group_id IS NOT NULL
ORDER BY pi.is_primary DESC, pi.id ASC LIMIT 1) AS thumb
FROM products p
WHERE p.sku IN (${skus.map(() => '?').join(',')}) COLLATE NOCASE`
).all(...skus);
const thumbs = {};
for (const row of rows) if (row.thumb) thumbs[row.sku] = row.thumb;
res.json({ thumbs });
});
api.post('/group/:id/images', imageUpload.array('images', 20), (req, res) => {
const id = Number(req.params.id);
if (!db.prepare('SELECT id FROM product_groups WHERE id = ?').get(id)) return res.status(404).json({ error: 'not found' });
if (!req.files || !req.files.length) {
return res.status(400).json({ error: '没有收到图片(只支持 jpg/png/webp/gif/bmp)' });
}
const existing = db.prepare('SELECT COUNT(*) AS n FROM product_images WHERE group_id = ?').get(id).n;
const insert = db.prepare(
`INSERT INTO product_images (sku, group_id, filename, original_name, is_primary, created_at)
VALUES ('', @group_id, @filename, @original_name, @is_primary, @created_at)`
);
db.transaction(() => {
req.files.forEach((f, i) => {
const ext = path.extname(f.originalname).toLowerCase();
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
const info = insert.run({
group_id: id,
filename,
original_name: f.originalname,
is_primary: existing === 0 && i === 0 ? 1 : 0,
created_at: new Date().toISOString()
});
// 二进制直接进库;文件本来就在内存里,磁盘上不留明文副本
catalog.storeImage(info.lastInsertRowid, f.buffer);
});
})();
res.json({
images: db.prepare('SELECT * FROM product_images WHERE group_id = ? ORDER BY is_primary DESC, id ASC').all(id)
});
});
api.post('/images/:id/primary', (req, res) => {
const img = db.prepare('SELECT * FROM product_images WHERE id = ?').get(Number(req.params.id));
if (!img) return res.status(404).json({ error: 'not found' });
db.transaction(() => {
db.prepare('UPDATE product_images SET is_primary = 0 WHERE group_id = ?').run(img.group_id);
db.prepare('UPDATE product_images SET is_primary = 1 WHERE id = ?').run(img.id);
})();
res.json({ ok: true });
});
api.delete('/images/:id', (req, res) => {
const img = db.prepare('SELECT * FROM product_images WHERE id = ?').get(Number(req.params.id));
if (!img) return res.status(404).json({ error: 'not found' });
db.prepare('DELETE FROM product_images WHERE id = ?').run(img.id);
// 删掉记录后把封面顺延给剩下的第一张
if (img.is_primary) {
const next = db.prepare('SELECT id FROM product_images WHERE group_id = ? ORDER BY id ASC LIMIT 1').get(img.group_id);
if (next) db.prepare('UPDATE product_images SET is_primary = 1 WHERE id = ?').run(next.id);
}
catalog.deleteImage(img.id);
res.json({ ok: true });
});
/** 下载合同原件(原件只存在库里,磁盘上没有)。 */
api.get('/contracts/:id/file', (req, res) => {
const f = getContractFile(Number(req.params.id));
if (!f) return res.status(404).json({ error: '库里没有这份合同的原件' });
res.setHeader('Content-Type', f.mime || 'application/octet-stream');
res.setHeader(
'Content-Disposition',
`attachment; filename="contract"; filename*=UTF-8''${encodeURIComponent(f.filename)}`
);
res.end(f.bytes);
});
/* ================================== 统计 ================================== */
api.get('/stats', (req, res) => {
res.json(db.prepare(
`SELECT
(SELECT COUNT(*) FROM product_groups) AS total_groups,
(SELECT COUNT(*) FROM products) AS total_skus,
(SELECT COUNT(*) FROM products WHERE confidence = 'low') AS low_confidence,
(SELECT COUNT(*) FROM suppliers) AS total_suppliers,
(SELECT COUNT(*) FROM contracts) AS contracts_processed`
).get());
});
/* ================================= 供应商 ================================= */
api.get('/suppliers', (req, res) => {
res.json(db.prepare(
`SELECT s.*,
(SELECT COUNT(*) FROM product_groups g WHERE g.supplier_id = s.id) AS group_count,
(SELECT COUNT(*) FROM products p WHERE p.supplier_id = s.id) AS sku_count
FROM suppliers s ORDER BY s.name`
).all());
});
/** 没归属供应商的产品数 —— 手工建的货常常一时定不下供应商。 */
api.get('/suppliers/unassigned/count', (req, res) => {
res.json(db.prepare('SELECT COUNT(*) AS count FROM product_groups WHERE supplier_id IS NULL').get());
});
api.get('/suppliers/:id', (req, res) => {
const id = Number(req.params.id);
const supplier = db.prepare('SELECT * FROM suppliers WHERE id = ?').get(id);
if (!supplier) return res.status(404).json({ error: 'not found' });
res.json({
supplier,
products: db.prepare(
`SELECT p.*, g.name AS group_name,
(SELECT filename FROM product_images i WHERE i.group_id = p.group_id
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS thumb
FROM products p LEFT JOIN product_groups g ON g.id = p.group_id
WHERE p.supplier_id = ? ORDER BY p.last_updated DESC LIMIT 500`
).all(id),
contracts: db.prepare('SELECT * FROM contracts WHERE supplier_id = ? ORDER BY processed_at DESC LIMIT 50').all(id)
});
});
const SUPPLIER_FIELDS = [
'name', 'short_name', 'contact_person', 'phone',
'email', 'address', 'website', 'payment_terms', 'main_categories', 'notes'
];
const supplierPayload = (body) => {
const out = {};
for (const f of SUPPLIER_FIELDS) out[f] = body?.[f] ?? null;
return out;
};
api.post('/suppliers', (req, res) => {
const data = supplierPayload(req.body);
if (!data.name) return res.status(400).json({ error: '供应商名称不能为空' });
try {
const now = new Date().toISOString();
const info = db.prepare(
`INSERT INTO suppliers (name, short_name, contact_person, phone, email, address, website,
payment_terms, main_categories, notes, created_at, updated_at)
VALUES (@name, @short_name, @contact_person, @phone, @email, @address, @website,
@payment_terms, @main_categories, @notes, @created_at, @updated_at)`
).run({ ...data, created_at: now, updated_at: now });
res.json({ id: info.lastInsertRowid });
} catch (err) {
if (String(err.message).includes('UNIQUE')) return res.status(409).json({ error: '该供应商名称已存在' });
fail(res, err);
}
});
api.put('/suppliers/:id', (req, res) => {
const data = supplierPayload(req.body);
if (!data.name) return res.status(400).json({ error: '供应商名称不能为空' });
try {
db.prepare(
`UPDATE suppliers SET name=@name, short_name=@short_name, contact_person=@contact_person,
phone=@phone, email=@email, address=@address, website=@website,
payment_terms=@payment_terms, main_categories=@main_categories, notes=@notes,
updated_at=@updated_at WHERE id=@id`
).run({ ...data, id: Number(req.params.id), updated_at: new Date().toISOString() });
res.json({ ok: true });
} catch (err) {
if (String(err.message).includes('UNIQUE')) return res.status(409).json({ error: '该供应商名称已存在' });
fail(res, err);
}
});
api.delete('/suppliers/:id', (req, res) => {
const id = Number(req.params.id);
// 产品保留原价,只是变成「未指派供应商」,而不是跟着被删
db.transaction(() => {
db.prepare('UPDATE products SET supplier_id = NULL WHERE supplier_id = ?').run(id);
db.prepare('UPDATE product_groups SET supplier_id = NULL WHERE supplier_id = ?').run(id);
db.prepare('DELETE FROM suppliers WHERE id = ?').run(id);
})();
res.json({ ok: true });
});
/* ============================== 产品图片文件 ============================== */
/**
* /product-images/<文件名>。
*
* 图片存在库里(product_image_files),按文件名去查而不是拼磁盘路径 ——
* 顺带也就没有路径穿越这回事了。
*
* 这里要求登录:产品图属于商业数据,原先挂在裸 static 上等于「猜到文件名就能拿图」。
* 页面里的 <img> 是同源请求,会带上会话 cookie,所以登录后照常显示。
*/
images.get('/:filename', (req, res) => {
if (!req.user) return res.status(401).end();
const name = req.params.filename;
const img = db.prepare('SELECT id FROM product_images WHERE filename = ?').get(name);
if (!img) return res.status(404).end();
const blob = catalog.getImage(img.id);
if (!blob) return res.status(404).end();
res.setHeader('Content-Type', catalog.mimeFor(name));
// 文件名带时间戳+随机串,内容不会变,可以让浏览器长缓存。
// private:机密数据,不许中间代理留副本。
res.setHeader('Cache-Control', 'private, max-age=604800');
res.end(blob.bytes);
});
module.exports = { api, images };
+14
View File
@@ -4,6 +4,7 @@
*/
const express = require('express');
const updater = require('../lib/updater');
const branding = require('../lib/branding');
const router = express.Router();
@@ -45,4 +46,17 @@ router.get('/check-update', async (req, res) => {
}
});
/**
* GET /api/system/branding
* 品牌与组织名。公开接口 —— 登录页在拿到会话之前就得知道系统叫什么。
* 只回显示用的名字,不含任何业务数据。
*/
router.get('/branding', (req, res) => {
try {
res.json(branding.getBranding());
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+1527 -72
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -40,7 +40,7 @@
1. **安装依赖并启动**
```bash
cd D:\wayne\Personal\trade-management-system
cd D:\wayne\Personal\happy-export
# 安装后端依赖
cd backend
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>happy出口通</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="mask-icon" href="/favicon.svg" color="#2aa7dc" />
<script>
try {
const theme = localStorage.getItem('tms-theme') === 'light' ? 'light' : 'dark';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trade-management-frontend",
"version": "1.0.0",
"version": "1.1.0",
"description": "外贸供应链管理系统 - 前端",
"type": "module",
"scripts": {
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- happy出口通 品牌标识:云 + 数据库圆柱(矢量绘制,非位图素材) -->
<g transform="translate(-0.1 5.8) scale(0.54)" stroke="#7c93a4" stroke-width="3.7">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>
</g>
<g transform="translate(12 5.1) scale(0.54)" stroke="#2aa7dc" stroke-width="3.7">
<ellipse cx="12" cy="5.5" rx="8.5" ry="3.2"/>
<path d="M3.5 5.5V19.5A8.5 3.2 0 0 0 20.5 19.5V5.5"/>
<path d="M3.5 12.5A8.5 3.2 0 0 0 20.5 12.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 659 B

+298 -91
View File
@@ -1,120 +1,243 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, NavLink, Navigate } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import EmbeddedSku from './pages/EmbeddedSku'
import ProductManagement from './pages/ProductManagement'
import SupplierManagement from './pages/SupplierManagement'
import CustomerManagement from './pages/CustomerManagement'
import OrderManagement from './pages/OrderManagement'
import ProspectManagement from './pages/ProspectManagement'
import QuoteManagement from './pages/QuoteManagement'
import PiContractManagement from './pages/PiContractManagement'
import PurchaseOrderManagement from './pages/PurchaseOrderManagement'
import { BrowserRouter as Router, Routes, Route, NavLink, Navigate, useNavigate, useLocation } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import Settings from './components/Settings'
import GlobalHeader from './components/GlobalHeader'
import AIAssistant from './components/AIAssistant'
import QuickLookup from './components/QuickLookup'
import { readPreference } from './preferences'
import { AuthProvider, useAuth, hasPermission } from './auth'
import { loadBranding, useBranding, setPageTitle } from './branding'
import { NAV_GROUPS, ROUTE_PERMISSIONS, HOME_PATH, PAGE_LABELS } from './navigation'
import { getUnsavedGuard, isGuardDirty, clearUnsavedGuard } from './unsavedGuard'
import {
DashboardIcon,
FactoryIcon,
PackageIcon,
UsersIcon,
TargetIcon,
ClipboardIcon,
DownloadIcon,
QuoteIcon,
ContractIcon,
PurchaseIcon,
ChevronDownIcon,
TuneIcon,
SunIcon,
MoonIcon,
MoonStarsIcon,
SidebarLayoutIcon
} from './components/Icons'
/* 侧边栏分组配置:工作台独立置顶,不归属任何分组 */
const NAV_GROUPS = [
{
id: 'business',
title: '业务管理',
items: [
{ to: '/quotes', label: '客户报价单管理', icon: QuoteIcon },
{ to: '/pi-contracts', label: 'PI合同管理', icon: ContractIcon },
{ to: '/purchase-orders', label: '采购单管理', icon: PurchaseIcon }
]
},
{
id: 'customer',
title: '客户管理',
items: [
{ to: '/customers', label: '客户管理', icon: UsersIcon },
{ to: '/prospects', label: '新客开发', icon: TargetIcon },
{ to: '/orders', label: '订单管理', icon: ClipboardIcon }
]
},
{
id: 'procurement',
title: '采购管理',
items: [
{ to: '/products', label: '产品管理', icon: PackageIcon },
{ to: '/suppliers', label: '供应商管理', icon: FactoryIcon },
{ to: '/procurement/import', label: '合同导入', icon: DownloadIcon }
]
/* ---------------------------------------------------------------------------
* 路由级代码分割
*
* 改造前:18 个页面全是静态 import,Vite 把整站打进一个 index.js(约 530 KB)。
* 打开任何一页,浏览器都得先把整站的代码下载并解析完才看得到东西 ——
* 本地感觉不出来,上云走公网就是一两秒白屏。
*
* 改成 React.lazy 后每页一个独立 chunk,首屏只加载当前页那一份。
* 下面几个刻意保持静态 import,不拆,理由各自不同:
* · LoginPage —— 未登录时它本身就是首屏,拆了反而多一次往返
* · GlobalHeader / Settings / AIAssistant —— 属于外壳,首屏就要用
* --------------------------------------------------------------------------- */
const Dashboard = React.lazy(() => import('./pages/Dashboard'))
const ProductManagement = React.lazy(() => import('./pages/ProductManagement'))
const SupplierManagement = React.lazy(() => import('./pages/SupplierManagement'))
const CustomerManagement = React.lazy(() => import('./pages/CustomerManagement'))
const CustomerDetail = React.lazy(() => import('./pages/CustomerDetail'))
const InquiryManagement = React.lazy(() => import('./pages/InquiryManagement'))
const InquiryDetail = React.lazy(() => import('./pages/InquiryDetail'))
const ContractCleanup = React.lazy(() => import('./pages/ContractCleanup'))
const OrderManagement = React.lazy(() => import('./pages/OrderManagement'))
const ProspectManagement = React.lazy(() => import('./pages/ProspectManagement'))
const QuoteManagement = React.lazy(() => import('./pages/QuoteManagement'))
const QuoteEditor = React.lazy(() => import('./pages/QuoteEditor'))
const PiContractManagement = React.lazy(() => import('./pages/PiContractManagement'))
const PurchaseOrderManagement = React.lazy(() => import('./pages/PurchaseOrderManagement'))
const CustomerPool = React.lazy(() => import('./pages/CustomerPool'))
const ProfilePage = React.lazy(() => import('./pages/ProfilePage'))
const UserManagement = React.lazy(() => import('./pages/UserManagement'))
const PermissionGroups = React.lazy(() => import('./pages/PermissionGroups'))
const SystemLogs = React.lazy(() => import('./pages/SystemLogs'))
/* -------------------------------------------------------------- 启动与兜底 */
function BootScreen() {
const brand = useBranding()
return (
<div className="app-boot">
<span className="app-boot-mark" />
<p>正在载入 {brand.name}…</p>
</div>
)
}
/* 详情页路径不在导航表里,给它们一批前缀标题,免得浏览器标签只剩系统名 */
const DETAIL_TITLES = [
['/customers/', '客户详情'],
['/inquiries/', '询盘详情'],
['/products/', '产品详情'],
['/suppliers/', '供应商详情']
]
function App() {
function pageTitleFor(pathname) {
if (PAGE_LABELS[pathname]) return PAGE_LABELS[pathname]
const hit = DETAIL_TITLES.find(([prefix]) => pathname.startsWith(prefix))
return hit ? hit[1] : ''
}
/** 路由懒加载时的过渡态:不白屏,给一条顶部进度线 + 一句提示 */
function RouteFallback() {
return (
<div className="route-fallback" role="status" aria-live="polite">
<div className="route-fallback-bar" />
<p>正在载入…</p>
</div>
)
}
function NoPermission() {
return (
<div className="page-container">
<div className="panel">
<div className="panel-title">无访问权限</div>
<p className="muted-note">
当前角色所属的权限组没有该模块的查看权限。请让管理员在「权限组管理」里把该角色指派到有权限的组。
</p>
</div>
</div>
)
}
/** 路由级权限守卫:与侧栏用的是同一份 ROUTE_PERMISSIONS,不会两处不一致 */
function Guarded({ permission, children }) {
const { user } = useAuth()
if (permission && !hasPermission(user, permission)) return <NoPermission />
return children
}
/* -------------------------------------------------------------- 主外壳 */
function Shell({ theme, onThemeChange }) {
const { user } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [collapsed, setCollapsed] = React.useState(false)
const [collapsedGroups, setCollapsedGroups] = React.useState([])
const [settingsOpen, setSettingsOpen] = React.useState(false)
const [assistantOpen, setAssistantOpen] = React.useState(false)
const [theme, setTheme] = React.useState(() => readPreference('tms-theme', ['dark', 'light'], 'dark'))
const [pendingNav, setPendingNav] = React.useState(null)
const [guardBusy, setGuardBusy] = React.useState(false)
const [reducedMotion, setReducedMotion] = React.useState(
() => readPreference('tms-motion', ['full', 'reduced'], 'full') === 'reduced'
)
const [reducedMotion, setReducedMotion] = React.useState(() => readPreference('tms-motion', ['full', 'reduced'], 'full') === 'reduced')
const closeSettings = React.useCallback(() => setSettingsOpen(false), [])
const closeAssistant = React.useCallback(() => setAssistantOpen(false), [])
React.useEffect(() => {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
try { localStorage.setItem('tms-theme', theme) } catch { /* Session-only when storage is unavailable. */ }
}, [theme])
React.useEffect(() => {
document.documentElement.dataset.motion = reducedMotion ? 'reduced' : 'full'
try { localStorage.setItem('tms-motion', reducedMotion ? 'reduced' : 'full') } catch { /* Session-only. */ }
}, [reducedMotion])
/* 浏览器标签标题跟着当前页走 —— 页面组件内部也会设一次(同值),
这里兜住那些没自己设标题的页面,避免标签一直停在上一页的名字。 */
React.useEffect(() => {
setPageTitle(pageTitleFor(location.pathname))
}, [location.pathname])
/* ------------------------------------------------------------------
未保存改动拦截
编辑页(个人资料)挂载时会在 unsavedGuard 里登记「我脏了吗 / 帮我存 / 帮我丢弃」。
两道防线:
1) 所有应用内跳转都走 guardedNavigate,跳之前先问一句;
2) 浏览器后退/前进这类不走我们的导航,靠监听 location 变化把它弹回来。
------------------------------------------------------------------ */
const guardedNavigate = React.useCallback((path) => {
if (!path || path === location.pathname) return
if (isGuardDirty()) { setPendingNav(path); return }
navigate(path)
}, [navigate, location.pathname])
const lastPathRef = React.useRef(location.pathname)
React.useEffect(() => {
const previous = lastPathRef.current
lastPathRef.current = location.pathname
/* 只在「刚刚离开编辑页」且「它还有未保存改动」时弹回来 */
if (previous === location.pathname || previous !== '/profile') return
if (!isGuardDirty()) return
setPendingNav(location.pathname)
navigate('/profile', { replace: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
React.useEffect(() => {
const handler = event => {
if (!isGuardDirty()) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [])
const resolvePending = async (action) => {
const target = pendingNav
const guard = getUnsavedGuard()
setPendingNav(null)
if (action === 'cancel') return
if (action === 'save') {
setGuardBusy(true)
try {
await guard?.save()
} catch {
setGuardBusy(false)
return
}
setGuardBusy(false)
} else {
guard?.discard()
}
clearUnsavedGuard()
if (target) navigate(target)
}
const navClass = ({ isActive }) => `nav-item${isActive ? ' active' : ''}`
/* 工作台额外带一个类,供「展开/收起导航按钮挂在它右侧」定位 */
const homeNavClass = ({ isActive }) => `nav-item nav-item-home${isActive ? ' active' : ''}`
const toggleGroup = React.useCallback((id) => {
setCollapsedGroups(current => current.includes(id) ? current.filter(x => x !== id) : [...current, id])
}, [])
/* 侧栏只显示当前权限组可见的菜单;整组都不可见就整组隐藏 */
const visibleGroups = React.useMemo(
() => NAV_GROUPS
.map(group => ({ ...group, items: group.items.filter(item => hasPermission(user, item.permission)) }))
.filter(group => group.items.length > 0),
[user]
)
const guard = (path, element) => <Guarded permission={ROUTE_PERMISSIONS[path]}>{element}</Guarded>
return (
<Router>
<div className="app-container">
<aside inert={settingsOpen || assistantOpen ? "" : undefined} className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
<h1>{collapsed ? 'HET' : 'happy出口通'}</h1>
{/* 只有设置是模态;「小皮」是浮窗,不能再 inert 主界面,否则没法边聊边操作 */}
<div className="app-shell" inert={settingsOpen ? "" : undefined}>
{/* 顶栏横跨整宽,压在侧边栏之上 */}
<GlobalHeader onOpenAssistant={() => setAssistantOpen(true)} onNavigate={guardedNavigate} />
<div className="app-body">
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
{/* 工作台置顶;展开/收起导航按钮挂在它右侧 */}
<div className="sidebar-top">
<NavLink aria-label="工作台" title="工作台" to="/" end className={homeNavClass}>
<span className="icon"><DashboardIcon size={18} /></span>
{!collapsed && <span>工作台</span>}
</NavLink>
<button
onClick={() => setCollapsed(!collapsed)}
className="collapse-btn"
aria-label={collapsed ? '展开导航' : '收起导航'}
title={collapsed ? '展开导航' : '收起导航'}
>
<SidebarLayoutIcon size={20} />
<SidebarLayoutIcon size={18} />
</button>
</div>
<nav className="sidebar-nav">
{/* 工作台:独立置顶,不属于任何分组 */}
<NavLink aria-label="工作台" title="工作台" to="/" end className={navClass}>
<span className="icon"><DashboardIcon size={18} /></span>
{!collapsed && <span>工作台</span>}
</NavLink>
{NAV_GROUPS.map(group => {
{visibleGroups.map(group => {
/* 侧栏整体收起时忽略分组的折叠状态,保证图标仍可点 */
const open = collapsed || !collapsedGroups.includes(group.id)
return (
@@ -137,7 +260,14 @@ function App() {
{open && group.items.map(item => {
const ItemIcon = item.icon
return (
<NavLink key={item.to} aria-label={item.label} title={item.label} to={item.to} className={navClass}>
<NavLink
key={item.to}
aria-label={item.label}
title={item.label}
to={item.to}
className={navClass}
onClick={event => { event.preventDefault(); guardedNavigate(item.to) }}
>
<span className="icon"><ItemIcon size={18} /></span>
{!collapsed && <span>{item.label}</span>}
</NavLink>
@@ -149,14 +279,14 @@ function App() {
</nav>
<div className="sidebar-footer">
<div className="user-info">管理员</div>
{/* 用户信息只在右上角 header 显示,此处不再重复;只留日夜切换与设置 */}
<button
className="theme-toggle-btn"
onClick={() => setTheme(current => current === 'light' ? 'dark' : 'light')}
onClick={() => onThemeChange(theme === 'light' ? 'dark' : 'light')}
title={theme === 'light' ? '切换至夜间模式' : '切换至日间模式'}
aria-label={theme === 'light' ? '切换至夜间模式' : '切换至日间模式'}
>
{theme === 'light' ? <MoonIcon size={18} /> : <SunIcon size={18} />}
{theme === 'light' ? <MoonStarsIcon size={20} /> : <SunIcon size={20} />}
</button>
<button className="settings-btn" onClick={() => setSettingsOpen(true)} title="设置" aria-label="设置">
<TuneIcon size={18} />
@@ -164,35 +294,112 @@ function App() {
</div>
</aside>
<main inert={settingsOpen || assistantOpen ? "" : undefined} className="main-content">
<GlobalHeader onOpenAssistant={() => setAssistantOpen(true)} />
<main className="main-content">
<div className="route-content">
{/* 页面 chunk 加载期间显示过渡态,避免白屏 */}
<React.Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/quotes" element={<QuoteManagement />} />
<Route path="/pi-contracts" element={<PiContractManagement />} />
<Route path="/purchase-orders" element={<PurchaseOrderManagement />} />
<Route path="/products" element={<ProductManagement />} />
<Route path="/suppliers" element={<SupplierManagement />} />
<Route path="/procurement/import" element={<EmbeddedSku view="import" />} />
<Route path="/customers" element={<CustomerManagement />} />
<Route path="/orders" element={<OrderManagement />} />
<Route path="/prospects" element={<ProspectManagement />} />
<Route path="/inquiries" element={guard('/inquiries', <InquiryManagement />)} />
<Route path="/inquiries/:id" element={guard('/inquiries', <InquiryDetail />)} />
<Route path="/quotes" element={guard('/quotes', <QuoteManagement />)} />
<Route path="/quotes/new" element={guard('/quotes/new', <QuoteEditor />)} />
<Route path="/pi-contracts" element={guard('/pi-contracts', <PiContractManagement />)} />
<Route path="/purchase-orders" element={guard('/purchase-orders', <PurchaseOrderManagement />)} />
<Route path="/orders" element={guard('/orders', <OrderManagement />)} />
<Route path="/products" element={guard('/products', <ProductManagement />)} />
<Route path="/suppliers" element={guard('/suppliers', <SupplierManagement />)} />
<Route path="/contract-cleanup" element={guard('/contract-cleanup', <ContractCleanup />)} />
<Route path="/customers" element={guard('/customers', <CustomerManagement />)} />
<Route path="/customers/:id" element={guard('/customers', <CustomerDetail />)} />
<Route path="/prospects" element={guard('/prospects', <ProspectManagement />)} />
<Route path="/customer-pool" element={guard('/customer-pool', <CustomerPool />)} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/users" element={guard('/users', <UserManagement />)} />
<Route path="/permission-groups" element={guard('/permission-groups', <PermissionGroups />)} />
<Route path="/logs" element={guard('/logs', <SystemLogs />)} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</React.Suspense>
</div>
</main>
</div>
</div>
{/* 未保存改动的离开确认:保存并离开 / 放弃修改 / 取消 */}
{pendingNav && (
<div className="modal-overlay" onClick={() => resolvePending('cancel')}>
<div className="modal-content modal-content-sm" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>个人资料还没保存</h2>
</div>
<div className="modal-body">
<p>你修改了个人资料但还没有保存。要保存后再离开吗?</p>
<p className="muted-note">选「放弃修改」会丢掉刚才的改动,恢复成保存前的样子。</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => resolvePending('cancel')}>取消</button>
<button type="button" className="btn btn-secondary" onClick={() => resolvePending('discard')}>放弃修改</button>
<button type="button" className="btn btn-primary" onClick={() => resolvePending('save')} disabled={guardBusy}>
{guardBusy ? '保存中…' : '保存并离开'}
</button>
</div>
</div>
</div>
)}
<AIAssistant isOpen={assistantOpen} onClose={closeAssistant} />
<Settings isOpen={settingsOpen} onClose={closeSettings} theme={theme} onThemeChange={setTheme} reducedMotion={reducedMotion} onReducedMotionChange={setReducedMotion} />
{/* 产品速查(Ctrl/⌘+J):自己管开关,挂在外壳里就能在任何页面唤出 */}
<QuickLookup />
<Settings
isOpen={settingsOpen}
onClose={closeSettings}
theme={theme}
onThemeChange={onThemeChange}
reducedMotion={reducedMotion}
onReducedMotionChange={setReducedMotion}
/>
</div>
)
}
/* -------------------------------------------------------------- 分流 */
function AppRoutes({ theme, onThemeChange }) {
const { user, loading } = useAuth()
if (loading) return <BootScreen />
if (!user) {
/* 未登录时任何路径都落到登录页 */
return (
<Routes>
<Route path="*" element={<LoginPage />} />
</Routes>
)
}
return <Shell theme={theme} onThemeChange={onThemeChange} />
}
function App() {
const [theme, setTheme] = React.useState(() => readPreference('tms-theme', ['dark', 'light'], 'dark'))
/* 品牌信息(系统显示名/副标题)启动就拉一次:登录页与顶栏都要用,且改完要即时生效。
它是公开接口,未登录也能拿到;失败就用兜底名,不影响进系统。 */
React.useEffect(() => { loadBranding() }, [])
React.useEffect(() => {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
try { localStorage.setItem('tms-theme', theme) } catch { /* Session-only when storage is unavailable. */ }
}, [theme])
return (
<AuthProvider>
<Router>
<AppRoutes theme={theme} onThemeChange={setTheme} />
</Router>
</AuthProvider>
)
}
export default App
+142
View File
@@ -0,0 +1,142 @@
/**
* 金额英文大写(外贸单证用)
*
* 目标格式与信用证 / 发票上的写法一致:
* 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND THIRTY-SIX CENTS ONLY
* 1000 CNY → SAY CHINESE YUAN ONE THOUSAND ONLY
* 0.5 EUR → SAY EUROS ZERO AND FIFTY CENTS ONLY
*
* 几条实务约定(不是「随便翻译一下」):
* 1. 整句以 SAY 开头、以 ONLY 结尾,这是单证上的固定套路。
* 2. 百位与十位之间用 AND(英式写法),如 TWO HUNDRED AND FIFTY-FIVE。
* 「组」与「组」之间不重复加 AND,避免 ONE THOUSAND AND TWO HUNDRED AND ... 这种啰嗦写法。
* 3. 货币名固定用复数形式(US DOLLARS / EUROS),因为这里是货币名称而非计量词,
* 单证上不写 ONE US DOLLAR。
* 4. 日元等无小数币种不带辅币段;小数位按币种配置取。
*/
/* 0-19 单独记,20 以上由十位 + 个位拼 */
const ONES = [
'', 'ONE', 'TWO', 'THREE', 'FOUR', 'FIVE', 'SIX', 'SEVEN', 'EIGHT', 'NINE',
'TEN', 'ELEVEN', 'TWELVE', 'THIRTEEN', 'FOURTEEN', 'FIFTEEN', 'SIXTEEN',
'SEVENTEEN', 'EIGHTEEN', 'NINETEEN'
]
const TENS = ['', '', 'TWENTY', 'THIRTY', 'FORTY', 'FIFTY', 'SIXTY', 'SEVENTY', 'EIGHTY', 'NINETY']
const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION']
/**
* 币种配置。
* head —— 大写句里跟在 SAY 后面的货币名(固定复数)
* centsHead —— 辅币名(固定复数)
* decimals —— 该币种的小数位(日元等为 0)
*/
const CURRENCY_WORDS = {
USD: { head: 'US DOLLARS', centsHead: 'CENTS', decimals: 2 },
CNY: { head: 'CHINESE YUAN', centsHead: 'FEN', decimals: 2 },
EUR: { head: 'EUROS', centsHead: 'CENTS', decimals: 2 },
GBP: { head: 'POUNDS STERLING', centsHead: 'PENCE', decimals: 2 },
HKD: { head: 'HONG KONG DOLLARS', centsHead: 'CENTS', decimals: 2 },
JPY: { head: 'JAPANESE YEN', centsHead: 'SEN', decimals: 0 },
AUD: { head: 'AUSTRALIAN DOLLARS', centsHead: 'CENTS', decimals: 2 },
CAD: { head: 'CANADIAN DOLLARS', centsHead: 'CENTS', decimals: 2 },
SGD: { head: 'SINGAPORE DOLLARS', centsHead: 'CENTS', decimals: 2 },
NZD: { head: 'NEW ZEALAND DOLLARS', centsHead: 'CENTS', decimals: 2 },
CHF: { head: 'SWISS FRANCS', centsHead: 'CENTIMES', decimals: 2 },
KRW: { head: 'SOUTH KOREAN WON', centsHead: 'JEON', decimals: 0 },
TWD: { head: 'NEW TAIWAN DOLLARS', centsHead: 'CENTS', decimals: 2 },
MOP: { head: 'MACAO PATACAS', centsHead: 'AVOS', decimals: 2 },
AED: { head: 'UAE DIRHAMS', centsHead: 'FILS', decimals: 2 },
SAR: { head: 'SAUDI RIYALS', centsHead: 'HALALAS', decimals: 2 },
MYR: { head: 'MALAYSIAN RINGGIT', centsHead: 'SEN', decimals: 2 },
THB: { head: 'THAI BAHT', centsHead: 'SATANG', decimals: 2 },
VND: { head: 'VIETNAMESE DONG', centsHead: 'XU', decimals: 0 },
IDR: { head: 'INDONESIAN RUPIAH', centsHead: 'SEN', decimals: 0 },
PHP: { head: 'PHILIPPINE PESOS', centsHead: 'CENTAVOS', decimals: 2 },
INR: { head: 'INDIAN RUPEES', centsHead: 'PAISE', decimals: 2 },
RUB: { head: 'RUSSIAN RUBLES', centsHead: 'KOPECKS', decimals: 2 },
BRL: { head: 'BRAZILIAN REALS', centsHead: 'CENTAVOS', decimals: 2 },
MXN: { head: 'MEXICAN PESOS', centsHead: 'CENTAVOS', decimals: 2 },
ZAR: { head: 'SOUTH AFRICAN RAND', centsHead: 'CENTS', decimals: 2 },
TRY: { head: 'TURKISH LIRA', centsHead: 'KURUS', decimals: 2 },
PLN: { head: 'POLISH ZLOTYS', centsHead: 'GROSZY', decimals: 2 },
SEK: { head: 'SWEDISH KRONOR', centsHead: 'ORE', decimals: 2 },
NOK: { head: 'NORWEGIAN KRONER', centsHead: 'ORE', decimals: 2 },
DKK: { head: 'DANISH KRONER', centsHead: 'ORE', decimals: 2 },
CZK: { head: 'CZECH KORUNAS', centsHead: 'HALERU', decimals: 2 },
HUF: { head: 'HUNGARIAN FORINTS', centsHead: 'FILLER', decimals: 2 },
EGP: { head: 'EGYPTIAN POUNDS', centsHead: 'PIASTRES', decimals: 2 }
}
/** 1–999 转英文。百位后若有非零余数,按英式写法补 AND。 */
function belowThousand(n) {
const parts = []
const hundreds = Math.floor(n / 100)
const rest = n % 100
if (hundreds) parts.push(`${ONES[hundreds]} HUNDRED`)
if (rest) {
if (hundreds) parts.push('AND')
if (rest < 20) parts.push(ONES[rest])
else {
const ten = Math.floor(rest / 10)
const one = rest % 10
parts.push(one ? `${TENS[ten]}-${ONES[one]}` : TENS[ten])
}
}
return parts.join(' ')
}
/** 非负整数转英文,按千分位分组。 */
function integerToWords(value) {
if (!Number.isFinite(value) || value <= 0) return 'ZERO'
const groups = []
let rest = Math.floor(value)
let scale = 0
while (rest > 0) {
const group = rest % 1000
if (group) {
const words = belowThousand(group)
groups.unshift(SCALES[scale] ? `${words} ${SCALES[scale]}` : words)
}
rest = Math.floor(rest / 1000)
scale += 1
}
return groups.join(' ')
}
/** 取币种配置;没登记的币种按 2 位小数 + 代码本身当货币名处理。 */
export function currencyWordsMeta(code) {
const key = String(code || 'USD').toUpperCase()
return CURRENCY_WORDS[key] || { head: key, centsHead: 'CENTS', decimals: 2 }
}
/**
* 金额转英文大写。
* @param {number|string} amount 金额
* @param {string} currency 币种代码
* @returns {string} 形如 "SAY US DOLLARS ... ONLY";输入无法识别时返回空串
*/
export function amountToWords(amount, currency = 'USD') {
const num = typeof amount === 'number' ? amount : Number(String(amount ?? '').replace(/[,\s]/g, ''))
if (!Number.isFinite(num)) return ''
const meta = currencyWordsMeta(currency)
const factor = Math.pow(10, meta.decimals)
/* 先四舍五入到「最小单位」,避免 255.36 这类浮点误差把小数位算成 35.999… */
const total = Math.round(Math.abs(num) * factor)
const major = Math.floor(total / factor)
const minor = total % factor
const parts = [`SAY ${meta.head}`]
if (major > 0 || minor === 0) parts.push(integerToWords(major))
if (minor > 0) {
// 有辅币时,主币为 0 也要读作 ZERO(单证上不会省略)
if (major === 0) parts.push('ZERO')
parts.push('AND')
parts.push(integerToWords(minor))
parts.push(meta.centsHead)
}
parts.push('ONLY')
/* 负数在贸易单证里没有位置,但真出现了也别把符号吞掉 */
return (num < 0 ? 'MINUS ' : '') + parts.join(' ')
}
+103
View File
@@ -0,0 +1,103 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
/**
* 会话上下文:全局唯一的「当前用户是谁」答案来源。
*
* 为什么不把用户信息塞进 localStorage:
* 改了个人资料 / 换了角色之后,前端必须立刻拿到新值。所以只依赖后端的会话 cookie,
* 启动时打一次 /api/auth/me,之后由各处的 setUser / refresh 回写。
*/
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
/* 启动时确定登录态 */
useEffect(() => {
let alive = true
;(async () => {
try {
const { data } = await axios.get('/api/auth/me')
if (alive) setUser(data?.user || null)
} catch {
if (alive) setUser(null)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [])
/* 任何接口返回 401(会话过期 / 被踢),就地清空登录态,界面自然回登录页 */
useEffect(() => {
const id = axios.interceptors.response.use(
response => response,
error => {
const status = error?.response?.status
const url = String(error?.config?.url || '')
const isAuthProbe = url.includes('/api/auth/login') || url.includes('/api/auth/me')
if (status === 401 && !isAuthProbe) setUser(null)
return Promise.reject(error)
}
)
return () => axios.interceptors.response.eject(id)
}, [])
const refresh = useCallback(async () => {
try {
const { data } = await axios.get('/api/auth/me')
setUser(data?.user || null)
return data?.user || null
} catch {
setUser(null)
return null
}
}, [])
const login = useCallback(async (username, password) => {
const { data } = await axios.post('/api/auth/login', { username, password })
setUser(data.user)
return data.user
}, [])
const logout = useCallback(async () => {
try {
await axios.post('/api/auth/logout')
} catch { /* 会话可能已过期,忽略 */ }
setUser(null)
}, [])
const value = useMemo(
() => ({ user, loading, login, logout, refresh, setUser }),
[user, loading, login, logout, refresh]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth 必须在 AuthProvider 内使用')
return context
}
/** 权限判断:系统管理员兜底全通过,其余按角色所属权限组的权限点判断 */
export function hasPermission(user, key) {
if (!user) return false
if (user.role === 'admin') return true
return Array.isArray(user.permissions) && user.permissions.includes(key)
}
/** 角色英文值 → 中文标签 */
export const ROLE_LABELS = {
admin: '系统管理员',
chairman: '董事长',
general_manager: '总经理',
sales_manager: '销售经理',
sales_rep: '销售业务员'
}
export function roleLabel(role) {
return ROLE_LABELS[role] || role || '—'
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
/**
* 系统标识与公司名 —— 全局唯一来源。
*
* 口径(Wayne 2026-09-15 定):
* - **系统名固定不可改**:`name` / `subtitle` 恒为后端给的固定署名,
* 登录页、顶栏、启动页、浏览器标题全用它,也**不跟随组织名**(引流用)。
* - **公司名(组织名)可改**:只在设置页与组织下拉里出现,不影响界面牌子。
*
* 用法:
* 组件里 const brand = useBranding() ← brand.name / brand.subtitle 是只读的
* 设置页标题 setPageTitle('用户管理') ← 页面别自己拼 document.title
*/
const FALLBACK = {
name: 'happy出口通',
subtitle: 'Ai Export System',
system_locked: true,
org_name: '',
org_id: null
}
let state = { ...FALLBACK }
let currentPage = null
let loading = null
const listeners = new Set()
function emit() {
for (const listener of listeners) {
try { listener(state) } catch { /* 某个订阅者出错不该拖垮其他订阅者 */ }
}
}
/** 页面标题统一在这里拼:品牌名变了,标题里的旧名也会跟着更新 */
export function setPageTitle(page) {
currentPage = page || null
document.title = currentPage ? `${currentPage} · ${state.name}` : state.name
}
export function getBranding() {
return state
}
/** 保存成功后立刻套用,不用等下次刷新 */
export function applyBranding(patch) {
state = { ...state, ...(patch || {}) }
setPageTitle(currentPage)
emit()
return state
}
/** 启动时拉一次;失败就用兜底值(后端挂了也不该白屏) */
export function loadBranding() {
if (loading) return loading
loading = axios
.get('/api/system/branding')
.then(({ data }) => applyBranding(data))
.catch(() => state)
return loading
}
export function useBranding() {
const [value, setValue] = useState(state)
useEffect(() => {
setValue(state)
listeners.add(setValue)
loadBranding()
return () => listeners.delete(setValue)
}, [])
return value
}
+122 -22
View File
@@ -1,25 +1,58 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { BotIcon, CloseIcon, SendIcon, SparkIcon } from './Icons'
import { AIBotIcon, CloseIcon, MinusIcon, SendIcon, UserIcon } from './Icons'
const POS_KEY = 'happy.aiPos'
const WIN_WIDTH = 440
const starterMessages = [
{ role: 'assistant', text: '你好,我是你的供应链 AI 助手。可以帮你查看客户、产品、供应商、订单、商机和待办,也可以协助发起系统内的业务操作。' },
{ role: 'assistant', text: '我是小皮,你的外贸业务助理。可以帮你查客户、产品、供应商、订单、商机和待办,也能代你发起系统内的操作。' },
]
function readStoredPos() {
try {
const raw = JSON.parse(localStorage.getItem(POS_KEY) || 'null')
if (raw && Number.isFinite(raw.left) && Number.isFinite(raw.top)) return { left: raw.left, top: raw.top }
} catch { /* 存储不可用时用默认位置 */ }
return null
}
/** 拖动或改窗口大小后,把窗口夹回视口,保证整窗(至少是标题栏)能点到 */
function clampPos(pos, height) {
if (!pos) return pos
const maxLeft = Math.max(8, window.innerWidth - WIN_WIDTH - 8)
const usable = Math.min(height || 320, window.innerHeight - 60)
const maxTop = Math.max(8, window.innerHeight - usable - 8)
return {
left: Math.min(Math.max(8, pos.left), maxLeft),
top: Math.min(Math.max(8, pos.top), maxTop)
}
}
/**
* 小皮 —— 右上角浮动小窗。
* 不是模态:没有遮罩、不 inert 主界面,所以可以一边让它干活一边自己点系统。
*/
function AIAssistant({ isOpen, onClose }) {
const [messages, setMessages] = useState(starterMessages)
const [input, setInput] = useState('')
const [context, setContext] = useState(null)
const [loading, setLoading] = useState(false)
const [minimized, setMinimized] = useState(false)
const [pos, setPos] = useState(readStoredPos)
const inputRef = useRef(null)
const panelRef = useRef(null)
const winRef = useRef(null)
const dragRef = useRef(null)
useEffect(() => {
if (!isOpen) return
inputRef.current?.focus()
const onKeyDown = (event) => {
if (event.key === 'Escape') onClose()
}
if (isOpen && !minimized) inputRef.current?.focus()
}, [isOpen, minimized])
// Esc 只收窗,不做任何全局拦截
useEffect(() => {
if (!isOpen) return undefined
const onKeyDown = (event) => { if (event.key === 'Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isOpen, onClose])
@@ -29,11 +62,41 @@ function AIAssistant({ isOpen, onClose }) {
axios.get('/api/assistant/context').then(response => setContext(response.data)).catch(() => setContext(null))
}, [isOpen])
useEffect(() => {
if (!isOpen || !pos) return undefined
const onResize = () => setPos(current => clampPos(current, winRef.current?.offsetHeight))
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [isOpen, pos])
const onDragStart = (event) => {
if (event.target.closest('button')) return
const rect = winRef.current?.getBoundingClientRect()
if (!rect) return
dragRef.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top }
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* 合成事件或指针已释放 */ }
}
const onDragMove = (event) => {
if (!dragRef.current) return
setPos(clampPos({ left: event.clientX - dragRef.current.dx, top: event.clientY - dragRef.current.dy }, winRef.current?.offsetHeight))
}
const onDragEnd = (event) => {
if (!dragRef.current) return
dragRef.current = null
try { event.currentTarget.releasePointerCapture(event.pointerId) } catch { /* 指针已释放 */ }
const rect = winRef.current?.getBoundingClientRect()
if (rect) {
try { localStorage.setItem(POS_KEY, JSON.stringify({ left: rect.left, top: rect.top })) } catch { /* 仅本次会话 */ }
}
}
const capabilities = useMemo(() => [
'读取全局业务概览与实时统计',
'检索客户、供应商、产品和订单',
'查看商机、待办与近期活动',
'发起已授权的新增、更新和状态操作',
'发起已授权的新增、更新和状态操作'
], [])
if (!isOpen) return null
@@ -56,30 +119,67 @@ function AIAssistant({ isOpen, onClose }) {
}
return (
<div className="assistant-layer" onClick={onClose}>
<aside ref={panelRef} className="ai-assistant-panel" role="dialog" aria-modal="true" aria-labelledby="ai-assistant-title" onClick={event => event.stopPropagation()}>
<div className="ai-panel-header">
<div className="ai-panel-title"><span className="ai-orb"><BotIcon size={20} /></span><div><span className="section-label">[SYSTEM COPILOT]</span><h2 id="ai-assistant-title">AI 助手</h2></div></div>
<button className="ai-close-btn" onClick={onClose} aria-label="关闭 AI 助手"><CloseIcon size={18} /></button>
<aside
ref={winRef}
className={`ai-window${minimized ? ' is-minimized' : ''}`}
style={pos ? { left: pos.left, top: pos.top, right: 'auto' } : undefined}
role="complementary"
aria-labelledby="ai-assistant-title"
>
<div
className="ai-window-bar"
onPointerDown={onDragStart}
onPointerMove={onDragMove}
onPointerUp={onDragEnd}
onPointerCancel={onDragEnd}
onDoubleClick={() => setMinimized(value => !value)}
>
<div className="ai-panel-title">
<span className="ai-orb"><AIBotIcon size={16} /></span>
<div className="ai-panel-heading">
<span className="ai-panel-kicker">BUSINESS COPILOT</span>
<h2 id="ai-assistant-title">小皮</h2>
<span className="ai-panel-subtitle">你的外贸业务助理</span>
</div>
</div>
<div className="ai-window-actions">
{!minimized && <span className="ai-status-label"><span className="ai-live-dot" />在线</span>}
<button
type="button"
className="ai-icon-btn ai-minimize-btn"
onClick={() => setMinimized(value => !value)}
aria-label={minimized ? '展开小皮' : '最小化小皮'}
title={minimized ? '展开' : '最小化(也可以双击标题栏)'}
>
{minimized ? <AIBotIcon size={15} /> : <MinusIcon size={16} />}
</button>
<button type="button" className="ai-close-btn" onClick={onClose} aria-label="关闭小皮" title="关闭小皮">
<CloseIcon size={15} />
<span>关闭</span>
</button>
</div>
</div>
<div className="ai-access-banner"><span className="ai-live-dot" /><div><strong>已连接系统业务层</strong><small>可读取模块数据,敏感写操作会先请求确认</small></div></div>
{!minimized && (
<React.Fragment>
<div className="ai-panel-body">
<div className="ai-capability-card"><div className="ai-capability-heading"><SparkIcon size={15} />我能帮你</div>{capabilities.map(capability => <div className="ai-capability-row" key={capability}><span>✓</span>{capability}</div>)}</div>
<div className="ai-access-banner"><span className="ai-live-dot" /><div><strong>已连接系统业务层</strong><small>可读取模块数据,敏感写操作会先请求确认</small></div></div>
<div className="ai-capability-card"><div className="ai-capability-heading">我能帮你</div>{capabilities.map(capability => <div className="ai-capability-row" key={capability}><span>✓</span>{capability}</div>)}</div>
{context && <div className="ai-context-strip"><span>当前数据快照</span><b>{context.stats?.customers || 0} 客户</b><b>{context.stats?.products || 0} 产品</b><b>{context.stats?.orders || 0} 订单</b></div>}
<div className="ai-message-list">
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <BotIcon size={15} /> : 'W'}</div><div className="ai-message-content"><p>{message.text}</p>{message.meta && <small>{message.meta}</small>}</div></div>)}
{loading && <div className="ai-message assistant"><div className="ai-message-avatar"><BotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <AIBotIcon size={15} /> : <UserIcon size={15} />}</div><div className="ai-message-content"><p>{message.text}</p>{message.meta && <small>{message.meta}</small>}</div></div>)}
{loading && <div className="ai-message assistant"><div className="ai-message-avatar"><AIBotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
</div>
</div>
<div className="ai-suggestion-row"><button onClick={() => sendMessage('汇总今天最重要的事项')}>汇总今天事项</button><button onClick={() => sendMessage('查看系统数据概览')}>查看系统概览</button></div>
<form className="ai-input-row" onSubmit={event => { event.preventDefault(); sendMessage() }}><input ref={inputRef} value={input} onChange={event => setInput(event.target.value)} placeholder="告诉 AI 你要查什么或做什么…" aria-label="输入给 AI 助手的内容" /><button type="submit" disabled={!input.trim() || loading} aria-label="发送"><SendIcon size={18} /></button></form>
<div className="ai-panel-footer">AI 会先说明将要执行的操作,再进行需要写入数据的动作。</div>
<form className="ai-input-row" onSubmit={event => { event.preventDefault(); sendMessage() }}><input type="text" ref={inputRef} value={input} onChange={event => setInput(event.target.value)} placeholder="告诉小皮你要查什么或做什么…" aria-label="输入给小皮的内容" autoComplete="off" /><button type="submit" disabled={!input.trim() || loading} aria-label="发送"><SendIcon size={18} /></button></form>
<div className="ai-panel-footer">小皮会先说明将要执行的操作,再进行需要写入数据的动作。</div>
</React.Fragment>
)}
</aside>
</div>
)
}
export default AIAssistant
@@ -0,0 +1,721 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import {
SparkIcon, PlusIcon, RefreshIcon, CheckIcon, CloseIcon, SaveIcon, SendIcon
} from './Icons'
/**
* 智能配置 —— 多模型接入与用途分配。
*
* 交互主线(Wayne 2026-09-15 定的四步):
* 1. 选一个常用接口预设(自动填地址/兼容模式/推荐模型)
* 2. 填密钥 → 保存(保存后自动跑一次连通性测试)
* 3. 「获取模型」→ 勾选这个接入要用哪些模型
* 4. 到「按用途分配」把不同活派给不同模型(可以同时接多家)
*
* 所有配置都在后端,前端不做任何本地持久化 —— 换台电脑登录照样是这套配置。
*/
const VALUE_SEP = '::'
const encodeChoice = (providerId, model) => (providerId && model ? `${providerId}${VALUE_SEP}${model}` : '')
const decodeChoice = (value) => {
if (!value) return null
const index = value.indexOf(VALUE_SEP)
if (index < 0) return null
return { provider_id: value.slice(0, index), model: value.slice(index + VALUE_SEP.length) }
}
function fmtTime(value) {
if (!value) return ''
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(value)
}
}
/**
* Base URL 的即时体检。
*
* 两种兼容模式的地址写法**不一样**,这里拦最常见的两类填错:
* ① 把完整接口路径也填了进来(/chat/completions、/messages)—— 系统还会再拼一次;
* ② Anthropic 协议漏了 /v1 —— 各家 SDK 文档里的 base_url 是写到 /anthropic 为止的
* (SDK 自己补 /v1),本系统不补,直接照抄必然 404。
*/
function baseUrlAdvice(mode, value) {
const url = String(value || '').trim()
if (!url) return null
const tail = url.replace(/\/+$/, '')
if (/\/(chat\/completions|completions)$/i.test(tail)) {
return { level: 'error', text: '不要带 /chat/completions —— 系统会自动拼上它,填到服务根路径就行。' }
}
if (/\/messages$/i.test(tail)) {
return { level: 'error', text: '不要带 /messages —— 系统会自动拼上它,填到服务根路径就行。' }
}
if (mode === 'anthropic' && !/\/v1$/i.test(tail)) {
return {
level: 'warn',
text: 'Anthropic 协议通常要写到 /v1 结尾(如 …/anthropic/v1)—— 系统是在这一层的后面拼 /messages,'
+ '漏掉 /v1 多半会 404。'
}
}
return null
}
function AIConfigSettings() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [config, setConfig] = useState({
presets: [], modes: [], purposes: [], providers: [], assignments: {}, overview: {}
})
const [editor, setEditor] = useState(null)
const [busy, setBusy] = useState('')
const [modelPanel, setModelPanel] = useState(null)
const [draft, setDraft] = useState({})
const [probe, setProbe] = useState({ prompt: '', reply: '', error: '', busy: false })
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await axios.get('/api/ai/config')
setConfig(data)
const next = {}
for (const [purpose, value] of Object.entries(data.assignments || {})) {
next[purpose] = encodeChoice(value.provider_id, value.model)
}
setDraft(next)
setError('')
} catch (err) {
setError(err?.response?.data?.error || '读取智能配置失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const flash = (text) => {
setNotice(text)
window.setTimeout(() => setNotice(''), 3500)
}
/* 所有启用中的接入 × 它们的模型,供用途分配下拉用 */
const choices = useMemo(() => (config.providers || [])
.filter(provider => provider.enabled)
.map(provider => ({
provider,
models: provider.models || [],
label: `${provider.name}${provider.has_key ? '' : '(未填密钥)'}`
})), [config.providers])
const assignedCount = useMemo(
() => Object.values(draft).filter(Boolean).length,
[draft]
)
/* ---------------------------------------------------------------- 接入 */
const openCreate = (presetKey = '') => {
const preset = (config.presets || []).find(item => item.key === presetKey) || null
setEditor({
mode: 'create',
form: {
preset: preset?.key || 'openai',
name: preset?.label || '',
base_url: preset?.base_url || '',
mode: preset?.mode || 'openai',
api_key: '',
note: '',
enabled: true
},
modelsText: (preset?.models || []).join('\n')
})
}
const openEdit = (provider) => {
setEditor({
mode: 'edit',
id: provider.id,
form: {
preset: provider.preset,
name: provider.name,
base_url: provider.base_url,
mode: provider.mode,
api_key: '',
note: provider.note || '',
enabled: provider.enabled
},
keyMasked: provider.key_masked,
modelsText: (provider.models || []).join('\n')
})
}
const changePreset = (presetKey) => {
const preset = (config.presets || []).find(item => item.key === presetKey)
if (!preset) return
setEditor(current => ({
...current,
form: {
...current.form,
preset: preset.key,
name: preset.label,
base_url: preset.base_url,
mode: preset.mode
},
modelsText: (preset.models || []).join('\n')
}))
}
const saveProvider = async (event) => {
event.preventDefault()
if (!editor) return
setBusy('save-provider')
try {
const payload = {
...editor.form,
models: editor.modelsText.split('\n').map(line => line.trim()).filter(Boolean)
}
if (editor.mode === 'edit' && !payload.api_key) delete payload.api_key
const { data } = editor.mode === 'edit'
? await axios.patch(`/api/ai/providers/${editor.id}`, payload)
: await axios.post('/api/ai/providers', payload)
setEditor(null)
await load()
flash(`「${data.provider.name}」已保存`)
/* 保存完顺手测一次:用户填完 key 最想知道的就是「通不通」 */
await runTest(data.provider.id, { silent: true })
} catch (err) {
setError(err?.response?.data?.error || '保存模型接入失败')
} finally {
setBusy('')
}
}
const runTest = async (id, { silent } = {}) => {
setBusy(`test:${id}`)
try {
const { data } = await axios.post(`/api/ai/providers/${id}/test`)
if (!silent) {
flash(data.result.ok
? `连通正常(${data.result.latency_ms}ms):${data.result.detail}`
: `连通失败:${data.result.detail}`)
}
setConfig(current => ({
...current,
providers: current.providers.map(item => (item.id === id ? data.provider : item))
}))
return data.result
} catch (err) {
const text = err?.response?.data?.error || '测试失败'
if (!silent) setError(text)
return { ok: false, detail: text }
} finally {
setBusy('')
}
}
const fetchModels = async (provider) => {
setBusy(`models:${provider.id}`)
try {
const { data } = await axios.post(`/api/ai/providers/${provider.id}/models`)
/* 勾选默认值:优先沿用之前存过的模型;一个都没对上(换了服务商/换了模型名)就全选,
否则用户会看到一个「获取成功但一个都没勾」的空面板,还得手工点一遍。 */
const previous = (provider.models || []).filter(model => data.models.includes(model))
setModelPanel({
providerId: provider.id,
providerName: provider.name,
detail: data.detail,
source: data.source,
selected: previous.length ? previous : data.models,
models: data.models
})
} catch (err) {
setError(err?.response?.data?.error || '获取模型清单失败')
} finally {
setBusy('')
}
}
const saveModels = async () => {
if (!modelPanel) return
setBusy('save-models')
try {
await axios.patch(`/api/ai/providers/${modelPanel.providerId}`, { models: modelPanel.selected })
setModelPanel(null)
await load()
flash('可用模型已更新')
} catch (err) {
setError(err?.response?.data?.error || '保存模型清单失败')
} finally {
setBusy('')
}
}
const toggleProvider = async (provider) => {
setBusy(`toggle:${provider.id}`)
try {
await axios.patch(`/api/ai/providers/${provider.id}`, { enabled: !provider.enabled })
await load()
flash(`「${provider.name}」已${provider.enabled ? '停用' : '启用'}`)
} catch (err) {
setError(err?.response?.data?.error || '操作失败')
} finally {
setBusy('')
}
}
const removeProvider = async (provider) => {
if (!window.confirm(`移除模型接入「${provider.name}」?\n\n指向它的用途分配会一起清掉。`)) return
setBusy(`remove:${provider.id}`)
try {
await axios.delete(`/api/ai/providers/${provider.id}`)
await load()
flash(`「${provider.name}」已移除`)
} catch (err) {
setError(err?.response?.data?.error || '移除失败')
} finally {
setBusy('')
}
}
/* ---------------------------------------------------------------- 分配 */
const saveAssignments = async () => {
setBusy('assignments')
try {
const assignments = {}
for (const [purpose, value] of Object.entries(draft)) {
const choice = decodeChoice(value)
if (choice) assignments[purpose] = choice
}
const { data } = await axios.put('/api/ai/assignments', { assignments })
setConfig(current => ({ ...current, assignments: data.assignments, overview: data.overview }))
flash('用途分配已保存')
} catch (err) {
setError(err?.response?.data?.error || '保存分配失败')
} finally {
setBusy('')
}
}
const runProbe = async () => {
const prompt = probe.prompt.trim()
if (!prompt) return
setProbe(current => ({ ...current, busy: true, reply: '', error: '' }))
try {
const { data } = await axios.post('/api/ai/chat', { prompt, purpose: 'assistant_chat' })
setProbe(current => ({ ...current, busy: false, reply: data.text || '(模型返回空内容)' }))
} catch (err) {
setProbe(current => ({
...current,
busy: false,
error: err?.response?.data?.error || '试跑失败'
}))
}
}
/* ---------------------------------------------------------------- 渲染 */
if (loading) {
return (
<div className="settings-section">
<h3>智能配置</h3>
<p className="settings-description">正在读取模型接入配置…</p>
</div>
)
}
const { overview = {} } = config
/* 编辑弹窗里跟着「接口兼容模式」走派生值:示例、后缀说明、填错提示 */
const activeMode = editor
? (config.modes || []).find(item => item.value === editor.form.mode) || null
: null
const baseAdvice = editor ? baseUrlAdvice(editor.form.mode, editor.form.base_url) : null
return (
<div className="settings-section">
<h3>智能配置</h3>
<p className="settings-description">
接入一家或多家大模型服务,再把不同用途派给不同模型 —— 合同解析用长文本强的,
图片识别用多模态的,日常对话用便宜的。全部配置存在本机数据库,换电脑登录即生效。
</p>
<div className="ai-overview">
<div><strong>{overview.provider_count || 0}</strong><small>已接入</small></div>
<div><strong>{overview.healthy_count || 0}</strong><small>测试通过</small></div>
<div><strong>{overview.assigned_purposes?.length || 0}/{config.purposes.length}</strong><small>用途已分配</small></div>
<div><strong>{overview.last_test_at ? fmtTime(overview.last_test_at).slice(5, 16) : '—'}</strong><small>最近测试</small></div>
</div>
{notice && <p className="ai-banner is-ok"><CheckIcon size={14} /> {notice}</p>}
{error && <p className="ai-banner is-error"><CloseIcon size={14} /> {error}</p>}
{/* ---------------- 模型接入 ---------------- */}
<div className="settings-group">
<div className="ai-group-head">
<h4>模型接入</h4>
<div className="ai-group-actions">
<button className="btn btn-secondary btn-sm" onClick={load} disabled={Boolean(busy)}>
<RefreshIcon size={14} /> 刷新
</button>
<button className="btn btn-primary btn-sm" onClick={() => openCreate('deepseek')}>
<PlusIcon size={14} /> 新增接入
</button>
</div>
</div>
{config.providers.length === 0
? <p className="help-text">
还没有接入任何模型服务。可以从下面的常用预设开始(DeepSeek / 通义 / Kimi / Claude / 本地 Ollama 等),
也可以完全自定义。不接也能用系统 —— 只是「小皮」会退回到本地规则直答。
</p>
: (
<ul className="ai-provider-list">
{config.providers.map(provider => {
const test = provider.last_test
return (
<li key={provider.id} className={`ai-provider${provider.enabled ? '' : ' is-off'}`}>
<div className="ai-provider-main">
<div className="ai-provider-title">
<strong>{provider.name}</strong>
<span className="ai-tag">{provider.preset_label}</span>
<span className="ai-tag is-mode">{provider.mode === 'anthropic' ? 'Anthropic 协议' : 'OpenAI 兼容'}</span>
{!provider.enabled && <span className="ai-tag is-off">已停用</span>}
</div>
<div className="ai-provider-meta">
<code>{provider.base_url}</code>
<span>密钥 {provider.has_key ? provider.key_masked : '(未填写,本地服务可留空)'}</span>
<span>{provider.models.length} 个可用模型</span>
</div>
{test && (
<div className={`ai-provider-test${test.ok ? ' is-ok' : ' is-error'}`}>
{test.ok ? <CheckIcon size={13} /> : <CloseIcon size={13} />}
<span>{test.detail}</span>
<small>{fmtTime(test.at)} · {test.latency_ms}ms</small>
</div>
)}
</div>
<div className="ai-provider-actions">
<button
className="btn btn-secondary btn-sm"
onClick={() => runTest(provider.id)}
disabled={Boolean(busy)}
>
{busy === `test:${provider.id}` ? '测试中…' : '连通性测试'}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => fetchModels(provider)}
disabled={Boolean(busy)}
>
{busy === `models:${provider.id}` ? '获取中…' : '获取模型'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => openEdit(provider)} disabled={Boolean(busy)}>
编辑
</button>
<button className="btn-link" onClick={() => toggleProvider(provider)} disabled={Boolean(busy)}>
{provider.enabled ? '停用' : '启用'}
</button>
<button className="btn-link is-danger" onClick={() => removeProvider(provider)} disabled={Boolean(busy)}>
移除
</button>
</div>
{modelPanel?.providerId === provider.id && (
<div className="ai-model-panel">
<div className="ai-model-panel-head">
<span>可用模型({modelPanel.models.length})</span>
<small>{modelPanel.detail}{modelPanel.source === 'preset' ? ' · 预设推荐' : ''}</small>
</div>
<div className="ai-model-grid">
{modelPanel.models.map(model => {
const checked = modelPanel.selected.includes(model)
return (
<label key={model} className={`ai-model-item${checked ? ' is-on' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => setModelPanel(current => ({
...current,
selected: checked
? current.selected.filter(item => item !== model)
: [...current.selected, model]
}))}
/>
<span>{model}</span>
</label>
)
})}
</div>
<div className="ai-model-panel-foot">
<button className="btn btn-secondary btn-sm" onClick={() => setModelPanel(null)}>取消</button>
<button className="btn btn-primary btn-sm" onClick={saveModels} disabled={busy === 'save-models'}>
<SaveIcon size={14} /> 保存为可用模型({modelPanel.selected.length})
</button>
</div>
</div>
)}
</li>
)
})}
</ul>
)}
</div>
{/* ---------------- 用途分配 ---------------- */}
<div className="settings-group">
<div className="ai-group-head">
<h4>按用途分配模型</h4>
<div className="ai-group-actions">
<span className="help-text">已分配 {assignedCount}/{config.purposes.length}</span>
<button
className="btn btn-primary btn-sm"
onClick={saveAssignments}
disabled={Boolean(busy) || config.providers.length === 0}
>
<SaveIcon size={14} /> {busy === 'assignments' ? '保存中…' : '保存分配'}
</button>
</div>
</div>
{config.providers.length === 0
? <p className="help-text">先在上面接入至少一家模型服务,这里就能选了。</p>
: (
<table className="data-table ai-assign-table">
<thead>
<tr><th>用途</th><th>说明</th><th>使用的模型</th><th /></tr>
</thead>
<tbody>
{config.purposes.map(purpose => (
<tr key={purpose.key}>
<td>
<strong>{purpose.label}</strong>
{purpose.vision && <span className="ai-tag is-vision">需多模态</span>}
</td>
<td className="ai-assign-hint">{purpose.hint}</td>
<td>
<select
className="form-select"
value={draft[purpose.key] || ''}
onChange={event => setDraft(current => ({ ...current, [purpose.key]: event.target.value }))}
>
<option value="">—— 未分配(走本地规则)——</option>
{choices.map(group => (
<optgroup key={group.provider.id} label={group.label}>
{group.models.length === 0 && (
<option value="" disabled>(该接入还没有可用模型,先点「获取模型」)</option>
)}
{group.models.map(model => (
<option key={model} value={encodeChoice(group.provider.id, model)}>
{model}
</option>
))}
</optgroup>
))}
</select>
</td>
<td>
{draft[purpose.key] && (
<button
className="btn-link"
onClick={() => setDraft(current => ({ ...current, [purpose.key]: '' }))}
>
清空
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ---------------- 试跑 ---------------- */}
<div className="settings-group">
<h4>验证一下</h4>
<p className="help-text">
用「智能助手对话」分配到的模型发一句话,确认配置真的通了。未分配时会提示先去做分配。
</p>
<div className="ai-probe">
<input
type="text"
className="form-input"
placeholder="例如:用一句话介绍我这边的客户情况"
value={probe.prompt}
onChange={event => setProbe(current => ({ ...current, prompt: event.target.value }))}
onKeyDown={event => { if (event.key === 'Enter') runProbe() }}
/>
<button className="btn btn-primary" onClick={runProbe} disabled={probe.busy || !probe.prompt.trim()}>
<SendIcon size={14} /> {probe.busy ? '调用中…' : '试跑'}
</button>
</div>
{probe.reply && <pre className="ai-probe-reply">{probe.reply}</pre>}
{probe.error && <p className="ai-banner is-error"><CloseIcon size={14} /> {probe.error}</p>}
</div>
<p className="appearance-note">
<SparkIcon size={14} /> 密钥以明文保存在本机数据库文件里,请勿把 data/trade.db 外发;
对外接口只会返回打码后的密钥。
</p>
{/* ---------------- 新增 / 编辑弹窗 ---------------- */}
{editor && (
<div className="modal-overlay" onClick={() => !busy && setEditor(null)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>{editor.mode === 'edit' ? `编辑接入「${editor.form.name}」` : '新增模型接入'}</h2>
<button className="close-btn" onClick={() => setEditor(null)} aria-label="关闭">
<CloseIcon size={18} />
</button>
</div>
<form onSubmit={saveProvider}>
<div className="form-group">
<label>常用接口预设</label>
<select
value={editor.form.preset}
onChange={event => changePreset(event.target.value)}
>
{config.presets.map(preset => (
<option key={preset.key} value={preset.key}>{preset.label}</option>
))}
</select>
{(() => {
const preset = config.presets.find(item => item.key === editor.form.preset)
if (!preset?.note) return null
return (
<p className="form-hint">
{preset.note}
{preset.docs && (
<>
{' · '}
<a href={preset.docs} target="_blank" rel="noreferrer">去申请密钥</a>
</>
)}
</p>
)
})()}
</div>
<div className="form-row">
<div className="form-group">
<label>接入名称</label>
<input
type="text"
value={editor.form.name}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, name: event.target.value } }))}
placeholder="例如 DeepSeek 生产"
/>
</div>
<div className="form-group">
<label>接口兼容模式</label>
<select
value={editor.form.mode}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, mode: event.target.value } }))}
>
{config.modes.map(mode => (
<option key={mode.value} value={mode.value}>{mode.label} —— {mode.hint}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label>
接口地址(Base URL)
{activeMode?.base_suffix && <span className="ai-url-suffix">系统会自动拼 {activeMode.base_suffix}</span>}
</label>
<input
type="text"
value={editor.form.base_url}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, base_url: event.target.value } }))}
placeholder={activeMode?.base_placeholder || 'https://api.example.com/v1'}
/>
{activeMode?.base_hint && <p className="form-hint">{activeMode.base_hint}</p>}
{baseAdvice && (
<p className={`form-hint is-${baseAdvice.level}`}>{baseAdvice.text}</p>
)}
{activeMode?.base_examples?.length > 0 && (
<div className="ai-url-examples">
<span className="ai-url-examples-label">{activeMode.label}常见地址(点一下填入):</span>
{activeMode.base_examples.map(item => (
<button
key={item.url}
type="button"
className="ai-url-chip"
title={`填入 ${item.url}`}
onClick={() => setEditor(current => ({
...current,
form: { ...current.form, base_url: item.url }
}))}
>
<em>{item.label}</em>
<code>{item.url}</code>
</button>
))}
</div>
)}
</div>
<div className="form-group">
<label>API Key</label>
<input
type="password"
value={editor.form.api_key}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, api_key: event.target.value } }))}
placeholder={editor.mode === 'edit' && editor.keyMasked
? `已保存:${editor.keyMasked}(留空表示不修改)`
: 'sk-...'}
/>
</div>
<div className="form-group">
<label>可用模型(一行一个;也可以保存后点「获取模型」自动填充)</label>
<textarea
className="ai-models-textarea"
rows={5}
value={editor.modelsText}
onChange={event => setEditor(current => ({ ...current, modelsText: event.target.value }))}
placeholder={'deepseek-chat\ndeepseek-reasoner'}
/>
</div>
<div className="form-group">
<label>备注</label>
<input
type="text"
value={editor.form.note}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, note: event.target.value } }))}
placeholder="例如:给合同解析用"
/>
</div>
<label className="setting-toggle">
<span><strong>启用该接入</strong><small>停用后不会被任何用途调用</small></span>
<input
type="checkbox"
checked={editor.form.enabled}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, enabled: event.target.checked } }))}
/>
<i aria-hidden="true" />
</label>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setEditor(null)} disabled={Boolean(busy)}>
取消
</button>
<button type="submit" className="btn btn-primary" disabled={Boolean(busy)}>
{busy === 'save-provider' ? '保存中…' : '保存并测试连通性'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default AIConfigSettings
+228
View File
@@ -0,0 +1,228 @@
import React from 'react'
/**
* 币种小国旗(纯 SVG 绘制,不使用 Emoji)
*
* 为什么不用 Emoji:🇺🇸 这类「区域指示符」在 Windows 的 Segoe UI Emoji 上
* 根本不渲染成旗子,只会显示成 US 两个字母方块;而且颜色不可控、尺寸不齐。
* 所以这里手绘 13 面旗,统一 viewBox 0 0 24 16,靠 CSS 控制大小与描边。
*
* 关于中国香港 / 中国台湾:香港用香港特别行政区区旗;台湾地区没有经
* 国家认可的区旗,这里用红底白字「台」作为**区划标识**,不是国旗。
*/
/** 五角星:单位坐标,几何中心在原点,外接圆半径 1(内接半径取 0.382) */
const STAR =
'0,-1 0.2246,-0.309 0.9511,-0.309 0.3633,0.118 0.5878,0.809 0,0.382 ' +
'-0.5878,0.809 -0.3633,0.118 -0.9511,-0.309 -0.2246,-0.309'
/** 欧元旗 12 颗星的位置(以 12,8 为中心、半径 5 的圆上均分) */
const EU_STARS = [
[17, 8], [16.33, 10.5], [14.5, 12.33], [12, 13], [9.5, 12.33], [7.67, 10.5],
[7, 8], [7.67, 5.5], [9.5, 3.67], [12, 3], [14.5, 3.67], [16.33, 5.5]
]
/** 简明国旗集:只画能一眼认出来的特征,不追求像素级还原 */
const FLAGS = {
/* 美国:13 道红白条纹 + 蓝色星区 */
USD: (
<>
<rect width="24" height="16" fill="#ffffff" />
<g fill="#b22234">
<rect y="0" width="24" height="1.23" />
<rect y="2.46" width="24" height="1.23" />
<rect y="4.92" width="24" height="1.23" />
<rect y="7.38" width="24" height="1.23" />
<rect y="9.85" width="24" height="1.23" />
<rect y="12.31" width="24" height="1.23" />
<rect y="14.77" width="24" height="1.23" />
</g>
<rect width="9.8" height="8.62" fill="#3c3b6e" />
<g fill="#ffffff">
{[0, 1, 2, 3, 4].map(i => <circle key={`a${i}`} cx={1.4 + i * 1.75} cy="1.4" r="0.48" />)}
{[0, 1, 2, 3].map(i => <circle key={`b${i}`} cx={2.28 + i * 1.75} cy="2.95" r="0.48" />)}
{[0, 1, 2, 3, 4].map(i => <circle key={`c${i}`} cx={1.4 + i * 1.75} cy="4.5" r="0.48" />)}
{[0, 1, 2, 3].map(i => <circle key={`d${i}`} cx={2.28 + i * 1.75} cy="6.05" r="0.48" />)}
{[0, 1, 2].map(i => <circle key={`e${i}`} cx={2.28 + i * 1.75} cy="7.55" r="0.48" />)}
</g>
</>
),
/* 中国:五星红旗 */
CNY: (
<>
<rect width="24" height="16" fill="#de2910" />
<g fill="#ffde00">
<polygon points={STAR} transform="translate(4.4,5.4) scale(2.6)" />
<polygon points={STAR} transform="translate(9,1.9) scale(0.85)" />
<polygon points={STAR} transform="translate(10.9,3.9) scale(0.85)" />
<polygon points={STAR} transform="translate(10.9,6.5) scale(0.85)" />
<polygon points={STAR} transform="translate(9,8.5) scale(0.85)" />
</g>
</>
),
/* 欧元区:蓝底 + 12 颗黄星围成一圈 */
EUR: (
<>
<rect width="24" height="16" fill="#003399" />
<g fill="#ffcc00">
{EU_STARS.map(([x, y], i) => (
<polygon key={i} points={STAR} transform={`translate(${x},${y}) scale(0.92)`} />
))}
</g>
</>
),
/* 中国香港:香港特别行政区区旗(洋紫荆) */
HKD: (
<>
<rect width="24" height="16" fill="#de2910" />
<g fill="#ffffff">
{[0, 72, 144, 216, 288].map(deg => (
<ellipse key={deg} cx="12" cy="5.15" rx="1.15" ry="2.85" transform={`rotate(${deg} 12 8)`} />
))}
</g>
<circle cx="12" cy="8" r="0.95" fill="#de2910" />
</>
),
/* 日本:白底红日 */
JPY: (
<>
<rect width="24" height="16" fill="#ffffff" />
<circle cx="12" cy="8" r="4.3" fill="#bc002d" />
</>
),
/* 俄罗斯:白蓝红三横条 */
RUB: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect y="5.33" width="24" height="5.34" fill="#0039a6" />
<rect y="10.67" width="24" height="5.33" fill="#d52b1e" />
</>
),
/* 马来西亚:红白条纹 + 蓝底黄月黄星 */
MYR: (
<>
<rect width="24" height="16" fill="#ffffff" />
<g fill="#cc0001">
<rect y="0" width="24" height="2.29" />
<rect y="4.57" width="24" height="2.29" />
<rect y="9.14" width="24" height="2.29" />
<rect y="13.71" width="24" height="2.29" />
</g>
<rect width="12" height="9.15" fill="#010066" />
<circle cx="4.7" cy="4.57" r="2.75" fill="#ffcc00" />
<circle cx="5.95" cy="4.57" r="2.35" fill="#010066" />
<polygon points={STAR} transform="translate(8.5,4.57) scale(1.02)" fill="#ffcc00" />
</>
),
/* 新加坡:上红下白 + 白色月与五星 */
SGD: (
<>
<rect width="24" height="8" fill="#ed2939" />
<rect y="8" width="24" height="8" fill="#ffffff" />
<circle cx="5.1" cy="4" r="2.9" fill="#ffffff" />
<circle cx="6.5" cy="4" r="2.5" fill="#ed2939" />
<g fill="#ffffff">
{[[8.6, 6.05], [6.62, 4.65], [7.37, 2.33], [9.83, 2.33], [10.58, 4.65]].map(([x, y], i) => (
<polygon key={i} points={STAR} transform={`translate(${x},${y}) scale(0.58)`} />
))}
</g>
</>
),
/* 中国台湾:台湾地区无国家认可的区旗,用红底白字「台」作区划标识 */
TWD: (
<>
<rect width="24" height="16" fill="#de2910" />
<text x="12" y="12.3" textAnchor="middle" fontSize="11.5" fontWeight="700" fill="#ffffff">台</text>
</>
),
/* 澳大利亚:蓝底 + 米字旗区 + 南十字星 */
AUD: (
<>
<rect width="24" height="16" fill="#00247d" />
<g>
<path d="M0 0 L12 8 M12 0 L0 8" stroke="#ffffff" strokeWidth="1.9" />
<path d="M0 0 L12 8 M12 0 L0 8" stroke="#cf142b" strokeWidth="1" />
<path d="M6 0 V8 M0 4 H12" stroke="#ffffff" strokeWidth="2.8" />
<path d="M6 0 V8 M0 4 H12" stroke="#cf142b" strokeWidth="1.6" />
</g>
<g fill="#ffffff">
<polygon points={STAR} transform="translate(5.6,12.1) scale(1.5)" />
<polygon points={STAR} transform="translate(17.6,4.6) scale(0.72)" />
<polygon points={STAR} transform="translate(20.3,7.7) scale(0.72)" />
<polygon points={STAR} transform="translate(17.7,10.7) scale(0.72)" />
<polygon points={STAR} transform="translate(15,7.7) scale(0.6)" />
<polygon points={STAR} transform="translate(19.2,8.6) scale(0.48)" />
</g>
</>
),
/* 阿联酋:左侧红竖条 + 绿白黑三横条 */
AED: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect width="24" height="5.33" fill="#00732f" />
<rect y="10.67" width="24" height="5.33" fill="#000000" />
<rect width="6" height="16" fill="#ff0000" />
</>
),
/* 沙特:绿底 + 白色清真言与宝刀(简形) */
SAR: (
<>
<rect width="24" height="16" fill="#006c35" />
<g fill="#ffffff">
<rect x="3.4" y="4.1" width="17.2" height="1.5" rx="0.75" />
<rect x="3.4" y="6.5" width="14" height="1.5" rx="0.75" />
<rect x="3.4" y="10.9" width="14.6" height="1.25" rx="0.6" />
<polygon points="18.7,10.25 22.4,11.5 18.7,12.8" />
</g>
</>
),
/* 加拿大:两侧红竖条 + 中间红枫叶 */
CAD: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect width="6" height="16" fill="#d80621" />
<rect x="18" width="6" height="16" fill="#d80621" />
<path
d="M50 5 L58 30 L75 25 L68 45 L90 42 L72 58 L95 72 L62 68 L68 95 L50 78 L32 95 L38 68 L5 72 L28 58 L10 42 L32 45 L25 25 L42 30 Z"
fill="#d80621"
transform="translate(12,8) scale(0.15) translate(-50,-50)"
/>
</>
)
}
/**
* @param {string} code ISO 4217 三字币种代码
* @param {number} width 显示宽度(px),高度按 3:2 自动算
*/
export default function CurrencyFlag({ code, width = 21 }) {
const body = FLAGS[code]
const height = Math.round((width * 16) / 24)
if (!body) {
return <span className="fx-flag is-blank" style={{ width, height }} />
}
return (
<svg
className="fx-flag"
viewBox="0 0 24 16"
width={width}
height={height}
aria-hidden="true"
focusable="false"
>
{body}
</svg>
)
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect } from 'react'
import { CloseIcon } from './Icons'
/**
* 右侧抽屉。产品页原本各自内联了一份同样的结构,这里抽出来给新页面复用,
* 保持「遮罩点击关闭 / Esc 关闭 / 正文独立滚动」三件事的行为一致。
*/
function Drawer({ title, sub, onClose, children, footer, wide = false }) {
useEffect(() => {
const onKeyDown = (event) => { if (event.key === 'Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className={`drawer${wide ? ' drawer-wide' : ''}`} role="dialog" aria-modal="true" aria-label={title}>
<div className="drawer-header">
<div className="drawer-head-main">
<h2 className="drawer-title">{title}</h2>
{sub && <p className="drawer-sub">{sub}</p>}
</div>
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<div className="drawer-body">{children}</div>
{footer && <div className="drawer-footer">{footer}</div>}
</aside>
</>
)
}
export default Drawer
+330
View File
@@ -0,0 +1,330 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { CalculatorIcon, ChevronDownIcon, RefreshIcon, SwapIcon } from './Icons'
import CurrencyFlag from './CurrencyFlag'
import {
buildPrimaryCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
} from '../currencies'
import { amountToWords } from '../amountInWords'
const DEFAULT_FROM = 'USD'
const DEFAULT_TO = 'CNY'
const PAIR_STORAGE_KEY = 'happy.fxPair'
/** 面板开着时的自动刷新间隔(后端本身也有 1 小时缓存,这里只是保证长开着不会太旧)。 */
const AUTO_REFRESH_MS = 30 * 60 * 1000
function readStoredPair() {
try {
const raw = JSON.parse(localStorage.getItem(PAIR_STORAGE_KEY) || 'null')
if (raw && /^[A-Z]{3}$/.test(raw.from) && /^[A-Z]{3}$/.test(raw.to)) return raw
} catch { /* 用默认值 */ }
return { from: DEFAULT_FROM, to: DEFAULT_TO }
}
/* ------------------------------------------------------------------ */
/* 币种选择器:面板内两侧各一个,带搜索 */
/* ------------------------------------------------------------------ */
function CurrencyPicker({ value, options, onChange, align }) {
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const boxRef = useRef(null)
const inputRef = useRef(null)
useEffect(() => {
if (!open) { setKeyword(''); return }
const timer = window.setTimeout(() => inputRef.current?.focus(), 0)
const onPointerDown = event => {
if (!boxRef.current?.contains(event.target)) setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => { window.clearTimeout(timer); document.removeEventListener('mousedown', onPointerDown) }
}, [open])
const filtered = useMemo(() => {
const term = keyword.trim().toUpperCase()
if (!term) return options
const raw = keyword.trim()
return options.filter(item =>
item.code.includes(term)
|| (item.name && item.name.includes(raw))
|| (item.region && item.region.includes(raw))
)
}, [options, keyword])
const labelOf = item => `${item.code} ${item.name}${item.region ? ` · ${item.region}` : ''}`
return (
<div className={`fx-picker ${align}`} ref={boxRef}>
<button
type="button"
className={`fx-picker-btn${open ? ' is-open' : ''}`}
onClick={() => setOpen(v => !v)}
title={currencyLabel(value)}
aria-haspopup="listbox"
aria-expanded={open}
>
<CurrencyFlag code={value} width={20} />
<b>{value}</b>
<span>{currencyLabel(value).slice(4) || '—'}</span>
<ChevronDownIcon size={13} />
</button>
{open && (
<div className="fx-picker-pop">
<input
ref={inputRef}
type="text"
className="fx-picker-search"
value={keyword}
onChange={event => setKeyword(event.target.value)}
placeholder="搜索币种 / 代码"
aria-label="搜索币种"
/>
<div className="fx-picker-list" role="listbox">
{filtered.length === 0
? <div className="fx-picker-empty">没有匹配的币种</div>
: filtered.map(item => (
<button
key={item.code}
type="button"
role="option"
aria-selected={item.code === value}
title={labelOf(item)}
className={`fx-picker-item${item.code === value ? ' active' : ''}`}
onClick={() => { onChange(item.code); setOpen(false) }}
>
<CurrencyFlag code={item.code} width={20} />
<b>{item.code}</b>
<span>{item.name || '—'}</span>
</button>
))}
</div>
</div>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* 汇率计算器 */
/* ------------------------------------------------------------------ */
function FxCalculator() {
const [open, setOpen] = useState(false)
const [status, setStatus] = useState('idle') // idle | loading | ready | error
const [rates, setRates] = useState(null)
const [meta, setMeta] = useState(null)
const [error, setError] = useState('')
const [pair, setPair] = useState(readStoredPair)
/* 只存「用户正在输入的那一侧」的原始文本,另一侧永远是算出来的。
这样不存在两个输入框互相改、改出抖动的可能。 */
const [entry, setEntry] = useState({ value: '1000', side: 'from' })
const [copied, setCopied] = useState(false)
const panelRef = useRef(null)
const loadedRef = useRef(false)
const { from, to } = pair
const loadRates = useCallback(async force => {
setStatus(prev => (prev === 'ready' ? prev : 'loading'))
try {
const response = await axios.get('/api/fx/rates', { params: force ? { force: 1 } : {} })
const data = response.data || {}
if (!data.ok || !data.rates) throw new Error(data.error || '汇率数据不可用')
setRates(data.rates)
setMeta({
source: data.sourceLabel || data.source || '',
updatedAt: data.updatedAt,
stale: !!data.stale,
warning: data.warning || ''
})
setError('')
setStatus('ready')
} catch (err) {
setError(err.response?.data?.error || err.message || '获取汇率失败')
setStatus(prev => (rates ? 'ready' : 'error'))
}
}, [rates])
/* 首次展开才去取数 —— 没打开过的用户不该为这个功能付一次请求。 */
useEffect(() => {
if (!open || loadedRef.current) return
loadedRef.current = true
loadRates(false)
}, [open, loadRates])
useEffect(() => {
if (!open) return
const timer = window.setInterval(() => loadRates(true), AUTO_REFRESH_MS)
return () => window.clearInterval(timer)
}, [open, loadRates])
/* 点面板外或按 Esc 关闭 */
useEffect(() => {
if (!open) return
const onPointerDown = event => {
if (!panelRef.current?.contains(event.target)) setOpen(false)
}
const onKeyDown = event => { if (event.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onPointerDown)
window.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
window.removeEventListener('keydown', onKeyDown)
}
}, [open])
useEffect(() => {
try { localStorage.setItem(PAIR_STORAGE_KEY, JSON.stringify(pair)) } catch { /* 忽略 */ }
}, [pair])
/* 只给主流 13 种币种(见 currencies.js 的 PRIMARY_CURRENCIES),不跟随接口的全量列表 */
const options = useMemo(() => buildPrimaryCurrencyOptions(), [])
const rate = crossRate(rates, from, to) // 1 from = ? to
const inverse = crossRate(rates, to, from) // 1 to = ? from
const num = parseAmount(entry.value)
const fromDisplay = entry.side === 'from'
? entry.value
: (num !== null && rate ? formatAmount(num / rate) : '')
const toDisplay = entry.side === 'to'
? entry.value
: (num !== null && rate ? formatAmount(num * rate) : '')
/* 换币种时锁住「那一侧现在的数字」——符合直觉:1000 美元换成 1000 欧元的输入,
再按新汇率算出右边。 */
const changeFrom = code => {
if (code === from) return
const keep = fromDisplay
setPair(cur => ({ ...cur, from: code }))
setEntry({ value: keep, side: 'from' })
}
const changeTo = code => {
if (code === to) return
const keep = toDisplay
setPair(cur => ({ ...cur, to: code }))
setEntry({ value: keep, side: 'to' })
}
/* 互换:把右边的结果挪到左边,换算关系保持不变 */
const swap = () => {
setPair(cur => ({ from: cur.to, to: cur.from }))
setEntry({ value: toDisplay, side: 'from' })
}
const updatedLabel = meta?.updatedAt
? new Date(meta.updatedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: ''
/* 英文大写金额跟随左侧(from)的金额与币种 —— 单证上写的就是报价币种那一侧 */
const words = useMemo(() => {
const value = parseAmount(fromDisplay)
return value === null ? '' : amountToWords(value, from)
}, [fromDisplay, from])
const copyWords = async () => {
if (!words) return
try {
await navigator.clipboard.writeText(words)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch {
/* 剪贴板不可用(非安全上下文等)时不打断使用,手动选中也能复制 */
}
}
return (
<div className="fx-calc" ref={panelRef}>
<button
type="button"
className={`header-tool-btn${open ? ' is-open' : ''}`}
onClick={() => setOpen(v => !v)}
title="汇率计算器"
aria-label="汇率计算器"
aria-expanded={open}
>
<CalculatorIcon size={19} />
</button>
{open && (
<div className="fx-panel" role="dialog" aria-label="汇率计算器">
<div className="fx-panel-head">
<span className="fx-panel-title"><CalculatorIcon size={16} /> 汇率换算</span>
<button
type="button"
className="fx-refresh"
onClick={() => loadRates(true)}
disabled={status === 'loading'}
title="刷新汇率"
aria-label="刷新汇率"
>
<RefreshIcon size={15} />
</button>
</div>
<div className="fx-panel-body">
<div className="fx-side">
<input
type="text"
inputMode="decimal"
className="fx-amount"
value={fromDisplay}
onChange={event => setEntry({ value: event.target.value, side: 'from' })}
placeholder="0"
aria-label={`金额(${from})`}
/>
<CurrencyPicker value={from} options={options} onChange={changeFrom} align="left" />
</div>
<button type="button" className="fx-swap" onClick={swap} title="互换货币" aria-label="互换货币">
<SwapIcon size={16} />
</button>
<div className="fx-side">
<input
type="text"
inputMode="decimal"
className="fx-amount"
value={toDisplay}
onChange={event => setEntry({ value: event.target.value, side: 'to' })}
placeholder="0"
aria-label={`金额(${to})`}
/>
<CurrencyPicker value={to} options={options} onChange={changeTo} align="right" />
</div>
</div>
<div className="fx-rate-line">
{status === 'loading' && !rates
? '正在获取最新汇率…'
: status === 'error'
? <span className="fx-error">{error}</span>
: rate
? <>1 {from} = <b>{formatRate(rate)}</b> {to}<em>· 1 {to} = {formatRate(inverse)} {from}</em></>
: '—'}
</div>
<div className="fx-words">
<div className="fx-words-head">
<span className="fx-words-label">英文大写金额</span>
{words && (
<button type="button" className="fx-copy" onClick={copyWords}>
{copied ? '已复制' : '复制'}
</button>
)}
</div>
<p className={`fx-words-text${words ? '' : ' is-empty'}`}>
{words || '在左侧输入金额后自动生成'}
</p>
</div>
<div className="fx-panel-foot">
{meta?.warning
? <span className="fx-warn">{meta.warning}</span>
: <span>数据源 {meta?.source || '—'}{updatedLabel ? ` · ${updatedLabel} 更新` : ''}{meta?.stale ? '(已过期)' : ''}</span>}
</div>
</div>
)}
</div>
)
}
export default FxCalculator
+103 -10
View File
@@ -1,22 +1,81 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import React, { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios'
import { BotIcon, ClockIcon, PlusCircleIcon, SearchIcon } from './Icons'
import { BotIcon, CloudDbIcon, PlusCircleIcon, SearchIcon, TrashIcon } from './Icons'
import { useAuth, roleLabel } from '../auth'
import { useBranding } from '../branding'
import TimeZoneClock from './TimeZoneClock'
import FxCalculator from './FxCalculator'
import { HOME_PATH, PAGE_LABELS } from '../navigation'
function GlobalHeader({ onOpenAssistant }) {
const TABS_STORAGE_KEY = 'happy.openTabs'
/* 工作台恒定占第一位,后面最多再挂 4 个页面 */
const MAX_TAB_PAGES = 4
const MAX_TABS = MAX_TAB_PAGES + 1
/* 从本地缓存读取上次打开的页面(只保留仍然存在的路由,工作台恒定在首位) */
function readStoredTabs() {
try {
const raw = JSON.parse(localStorage.getItem(TABS_STORAGE_KEY) || '[]')
const list = Array.isArray(raw) ? raw.filter(path => PAGE_LABELS[path] && path !== HOME_PATH) : []
return [HOME_PATH, ...list.slice(0, MAX_TAB_PAGES)]
} catch { return [HOME_PATH] }
}
function GlobalHeader({ onOpenAssistant, onNavigate }) {
const { user } = useAuth()
const [now, setNow] = useState(new Date())
const [searchOpen, setSearchOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [searching, setSearching] = useState(false)
const [openTabs, setOpenTabs] = useState(readStoredTabs)
const searchRef = useRef(null)
const tabsRef = useRef(null)
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000)
return () => window.clearInterval(timer)
}, [])
/* 访问过的页面自动登记成标签:工作台恒在首位,其余按最近使用排序,新页插在第二位 */
useEffect(() => {
const path = location.pathname
if (!PAGE_LABELS[path]) return
setOpenTabs(current => {
if (current[0] === path) return current
if (current[1] === path) return current
const rest = current.filter(item => item !== path && item !== HOME_PATH)
return [HOME_PATH, path, ...rest].slice(0, MAX_TABS)
})
}, [location.pathname])
useEffect(() => {
try { localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(openTabs)) } catch { /* 存储不可用时仅本次会话有效 */ }
const active = tabsRef.current?.querySelector('.page-tab.active')
active?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [openTabs, location.pathname])
/* 关闭标签:关掉的若是当前页,就退到剩下的最近一个,没有则回工作台 */
const closeTab = (event, path) => {
event.stopPropagation()
const next = openTabs.filter(item => item !== path)
const result = next.length ? next : [HOME_PATH]
setOpenTabs(result)
if (path === location.pathname) {
/* 退到「最近使用过的另一个页面」,全关光了才回工作台 */
navigate(result.find(item => item !== HOME_PATH) || HOME_PATH)
}
}
/* 清空快捷标签:工作台常驻不动,其余一次性摘掉;当前页若被清掉就回工作台 */
const clearTabs = () => {
setOpenTabs([HOME_PATH])
if (location.pathname !== HOME_PATH && PAGE_LABELS[location.pathname]) navigate(HOME_PATH)
}
useEffect(() => {
const keyword = query.trim()
if (!keyword) { setResults([]); setSearching(false); return }
@@ -43,23 +102,57 @@ function GlobalHeader({ onOpenAssistant }) {
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const timeLabel = useMemo(() => now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }), [now])
const dateLabel = useMemo(() => now.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }), [now])
const selectResult = result => { navigate(result.path); setQuery(''); setSearchOpen(false) }
/* 页内所有跳转都走外壳的 guardedNavigate:个人资料有未保存改动时会先弹确认 */
const go = React.useCallback(path => {
if (onNavigate) onNavigate(path)
else navigate(path)
}, [onNavigate, navigate])
const selectResult = result => { go(result.path); setQuery(''); setSearchOpen(false) }
const displayName = user?.display_name || user?.username || '未登录'
const brand = useBranding()
const positionText = user?.position || roleLabel(user?.role)
const avatarText = (displayName || '?').slice(0, 1).toUpperCase()
return (
<header className="global-header">
<div className="global-header-brand"><span className="header-status-dot" /><div><strong>happy出口通</strong><span>EXPORT OPERATIONS CONSOLE</span></div></div>
<div className="global-header-brand"><span className="brand-mark" title={brand.name}><CloudDbIcon size={36} /><span className="header-status-dot" /></span><div><strong>{brand.name}</strong><span>{brand.subtitle}</span></div></div>
<div className={`global-search ${searchOpen ? 'is-open' : ''}`}>
<SearchIcon size={17} />
<input ref={searchRef} aria-label="搜索 everything" value={query} onFocus={() => setSearchOpen(true)} onChange={event => setQuery(event.target.value)} placeholder="搜索 everything" autoComplete="off" />
<input type="text" ref={searchRef} aria-label="搜索 everything" value={query} onFocus={() => setSearchOpen(true)} onChange={event => setQuery(event.target.value)} placeholder="搜索 everything" autoComplete="off" />
<kbd>⌘ K</kbd>
{searchOpen && query.trim() && <div className="global-search-results" role="listbox" aria-label="全局搜索结果">
{searching ? <div className="global-search-status">正在检索系统业务数据…</div> : results.length ? results.map(result => <button key={`${result.type}-${result.id}`} role="option" onMouseDown={event => event.preventDefault()} onClick={() => selectResult(result)}><span className={`search-result-mark ${result.type}`}>{result.typeLabel}</span><span><strong>{result.title}</strong><small>{result.detail}</small></span></button>) : <div className="global-search-status">未找到匹配的数据</div>}
<div className="global-search-footnote">已检索客户、供应商、产品、订单、商机、待办和活动记录</div>
</div>}
</div>
<div className="global-header-actions"><div className="header-clock"><ClockIcon size={16} /><span>{dateLabel}</span><b>{timeLabel}</b></div><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开 AI 助手"><BotIcon size={19} /><span>AI 助手</span><i>β</i></button><button className="header-avatar" title="当前用户">W</button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
{/* 已打开页面:搜索框后的快速跳转标签,工作台常驻且不可关闭 */}
<nav className="page-tabs" ref={tabsRef} aria-label="已打开页面">
{openTabs.map(path => {
const label = PAGE_LABELS[path] || path
const isHome = path === HOME_PATH
const active = location.pathname === path
return (
<div key={path} className={`page-tab${active ? ' active' : ''}`}>
<button type="button" className="page-tab-label" title={label} aria-current={active ? 'page' : undefined} onClick={() => go(path)}>{label}</button>
{!isHome && <button type="button" className="page-tab-close" aria-label={`关闭${label}`} title={`关闭${label}`} onClick={event => closeTab(event, path)}>×</button>}
</div>
)
})}
</nav>
{openTabs.length > 1 && (
<button
type="button"
className="page-tabs-clear"
onClick={clearTabs}
aria-label="清空快捷标签"
title="清空快捷标签(工作台保留)"
>
<TrashIcon size={14} />
</button>
)}
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开小皮"><BotIcon size={19} /><span>小皮</span><i>β</i></button><button type="button" className="header-user" onClick={() => go('/profile')} title={`${displayName} · ${positionText} · 个人资料`} aria-label="个人资料"><span className="header-avatar">{user?.avatar ? <img src={user.avatar} alt="" /> : avatarText}</span><span className="header-user-text"><b>{displayName}</b><small>{positionText}</small></span></button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
</header>
)
}
+212 -2
View File
@@ -25,6 +25,23 @@ export const FactoryIcon = ({ size = 20, className = '' }) => (
</svg>
)
/* 品牌标识:云 + 数据库圆柱(照 icons8-云 重绘为矢量)
左半边云跟随主题色,右半边圆柱固定品牌蓝;坐标用 24 网格,内部 0.48 缩放。 */
export const CloudDbIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
{/* 云:闭合圆头轮廓 */}
<g transform="translate(1 6) scale(0.48)" stroke="currentColor" strokeWidth="3.54">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</g>
{/* 数据库圆柱:顶盖椭圆 + 桶身 + 中间分层线 */}
<g transform="translate(12.4 5.4) scale(0.48)" style={{ stroke: 'var(--primary)' }} strokeWidth="3.54">
<ellipse cx="12" cy="5.5" rx="8.5" ry="3.2" />
<path d="M3.5 5.5V19.5A8.5 3.2 0 0 0 20.5 19.5V5.5" />
<path d="M3.5 12.5A8.5 3.2 0 0 0 20.5 12.5" />
</g>
</svg>
)
export const DownloadIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
@@ -101,6 +118,12 @@ export const CalendarIcon = ({ size = 20, className = '' }) => (
</svg>
)
export const MinusIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
export const BellIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
@@ -131,9 +154,17 @@ export const SunIcon = ({ size = 20, className = '' }) => (
</svg>
)
export const MoonIcon = ({ size = 20, className = '' }) => (
/* 明月(含两颗四角星)——夜间模式切换图标
造型取自 https://igoutu.cn/icon/101343/moon-and-stars(Icons8 iOS Glyph 风格「明月」),
按原图重绘为矢量:月牙 = 大圆挖掉一枚偏向右上的圆(两段圆弧相减),
两枚四角星 = 凹边圆角菱形。全部走 currentColor,可跟随日夜主题换色。 */
export const MoonStarsIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" className={className} aria-hidden="true">
<path d="M12 2.5C6.7 2.5 2.5 6.8 2.5 12s4.2 9.5 9.5 9.5c-3.4-2-5.6-5.6-5.6-9.5S8.6 4.5 12 2.5Z" fill="currentColor" />
<g fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" strokeLinecap="round">
<path d="M9.63 3.09A10 10 0 1 0 20.61 15.77A9.5 9.5 0 0 1 9.63 3.09Z" />
<path d="M14.85 1.1C14.85 1.805 16.495 3.45 17.2 3.45C16.495 3.45 14.85 5.095 14.85 5.8C14.85 5.095 13.205 3.45 12.5 3.45C13.205 3.45 14.85 1.805 14.85 1.1Z" />
<path d="M19.95 5.9C19.95 6.86 22.19 9.1 23.15 9.1C22.19 9.1 19.95 11.34 19.95 12.3C19.95 11.34 17.71 9.1 16.75 9.1C17.71 9.1 19.95 6.86 19.95 5.9Z" />
</g>
</svg>
)
@@ -160,6 +191,23 @@ export const PlusCircleIcon = ({ size = 20, className = '' }) => (
export const BotIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><rect x="4" y="7" width="16" height="12" rx="3" /><path d="M12 3v4M8 12h.01M16 12h.01M8 16h8" /><path d="M2 12h2M20 12h2" /></svg>
)
/* 小皮专属标志 —— 跟 lucide 通用 BotIcon 区分:
* - 头部加 16% 浅填充(视觉重量更足,缩略尺寸下也认得出)
* - 眼睛从「点」改「实心圆」(更可爱)
* - 嘴巴从「横线」改「弧线」(更友善)
* - 去掉 lucide Bot 两侧那两根短横线(视觉上像「耳朵」又不像,反而显得莫名)
* - 头顶触角末端加一颗实心小星点(暗示 AI) */
export const AIBotIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="4" y="9" width="16" height="11" rx="3.5" fill="currentColor" fillOpacity="0.16" />
<path d="M12 5v4" />
<circle cx="12" cy="4.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="9" cy="14" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="14" r="1.2" fill="currentColor" stroke="none" />
<path d="M9.5 17.2c.6.7 4.4.7 5 0" />
</svg>
)
export const SendIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
)
@@ -184,6 +232,25 @@ export const ChevronDownIcon = ({ size = 16, className = '' }) => (
</svg>
)
/* 汇率计算器:机身 + 显示屏 + 按键点阵 */
export const CalculatorIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="4" y="2.5" width="16" height="19" rx="2.6" />
<path d="M8 7.2h8" />
<path d="M8.2 11.6h.01M12 11.6h.01M15.8 11.6h.01M8.2 15h.01M12 15h.01M15.8 15h.01M8.2 18.4h.01M12 18.4h.01M15.8 18.4h.01" strokeWidth="2.4" />
</svg>
)
/* 货币互换:上下两条反向箭头 */
export const SwapIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M4 8.5h15" />
<path d="m15.6 5.2 3.3 3.3-3.3 3.3" />
<path d="M20 15.5H5" />
<path d="m8.4 12.2-3.3 3.3 3.3 3.3" />
</svg>
)
/* 客户报价单 */
export const QuoteIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
@@ -194,6 +261,22 @@ export const QuoteIcon = ({ size = 20, className = '' }) => (
</svg>
)
/* 询盘:来信气泡 + 内含报价行 */
export const InquiryIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M21 12.4a7.6 7.6 0 0 1-7.6 7.6H8.4L3 22.2V8.8A7.6 7.6 0 0 1 10.6 1.2h2.8A7.6 7.6 0 0 1 21 8.8z" />
<path d="M8 8.6h8M8 12h5" />
</svg>
)
/* 合同清洗:漏斗过滤 + 落料 */
export const CleanupIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M3 4.4h18l-7.2 8.4v6.2l-3.6 2.2v-8.4z" />
<path d="M6.6 8.1h10.8" />
</svg>
)
/* PI 合同(形式发票 / 合同文件) */
export const ContractIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
@@ -279,3 +362,130 @@ export const PencilIcon = ({ size = 16, className = '' }) => (
<path d="m14.7 5.5 3.8 3.8" />
</svg>
)
/* 返回 / 左箭头 */
export const ArrowLeftIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M19 12H5" />
<path d="m11 18-6-6 6-6" />
</svg>
)
/* 盾牌 / 权限 */
export const ShieldIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 3l7 3v6c0 4.3-2.9 7.8-7 9-4.1-1.2-7-4.7-7-9V6z" />
<path d="m9 12 2.2 2.2L15.5 10" />
</svg>
)
/* 地球 / 公海 */
export const GlobeIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3.2 9h17.6M3.2 15h17.6" />
<path d="M12 3c2.4 2.5 3.6 5.5 3.6 9s-1.2 6.5-3.6 9c-2.4-2.5-3.6-5.5-3.6-9S9.6 5.5 12 3z" />
</svg>
)
/* 单人 / 个人资料 */
export const UserIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<circle cx="12" cy="8.2" r="3.7" />
<path d="M4.6 20c.6-3.6 3.6-5.6 7.4-5.6s6.8 2 7.4 5.6" />
</svg>
)
/* 钥匙 / 密码 */
export const KeyIcon = ({ size = 18, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<circle cx="8" cy="15" r="4" />
<path d="m11 12 8.5-8.5M17 6l2 2M14.5 8.5l2 2" />
</svg>
)
/* 建筑 / 组织 */
export const BuildingIcon = ({ size = 18, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M4 21V5.2A1.2 1.2 0 0 1 5.2 4h8.6a1.2 1.2 0 0 1 1.2 1.2V21" />
<path d="M15 10h3.8A1.2 1.2 0 0 1 20 11.2V21M2.5 21h19" />
<path d="M7.5 8h4M7.5 12h4M7.5 16h4" />
</svg>
)
/* 保存 / 勾选存盘 */
export const SaveIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M5 3.8h11L20.2 8v12.2A1.8 1.8 0 0 1 18.4 22H5a1.8 1.8 0 0 1-1.8-1.8V5.6A1.8 1.8 0 0 1 5 3.8z" />
<path d="M7.6 3.8v5.4h7.6V3.8M7.6 22v-6.2h8.8V22" />
</svg>
)
/* 登出 */
export const LogoutIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M14.5 4.5H6.8A1.8 1.8 0 0 0 5 6.3v11.4a1.8 1.8 0 0 0 1.8 1.8h7.7" />
<path d="M18 12H10M15.4 8.6 18.8 12l-3.4 3.4" />
</svg>
)
/* 相机 / 换头像 */
export const CameraIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M3.5 8.4h3l1.4-2.2h6.2L15.5 8.4h4A1.5 1.5 0 0 1 21 9.9v8.2a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.1V9.9a1.5 1.5 0 0 1 1.5-1.5z" />
<circle cx="12" cy="13.6" r="3.2" />
</svg>
)
/* 眼睛 / 显示密码 */
export const EyeIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
/* 眼睛(划线)/ 隐藏密码 */
export const EyeOffIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M4 4l16 16" />
<path d="M9.6 5.9A9.6 9.6 0 0 1 12 5.8c6 0 9.5 6.2 9.5 6.2a17 17 0 0 1-2.7 3.5M6.4 8.2A16.8 16.8 0 0 0 2.5 12S6 18.2 12 18.2c1.3 0 2.4-.3 3.4-.7" />
<path d="M10 10.2a3 3 0 0 0 4 4" />
</svg>
)
/* 加号 / 新增 */
export const PlusIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
)
/* 上传 / 上传者 */
export const UploadIcon = ({ size = 14, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 16V4" />
<path d="m7.5 8.5 4.5-4.5 4.5 4.5" />
<path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15" />
</svg>
)
/* 副本 / 复制 */
export const CopyIcon = ({ size = 14, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M15 6.5V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.5" />
</svg>
)
/* 操作人 / 用户痕迹 */
export const FingerprintIcon = ({ size = 14, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 4a8 8 0 0 0-8 8v3" />
<path d="M20 15v-3a8 8 0 0 0-4-6.93" />
<path d="M12 11a1.5 1.5 0 0 0-1.5 1.5V17a3 3 0 0 0 3 3" />
<path d="M16 17v-4.5a4 4 0 0 0-2-3.46" />
<path d="M7 17.5V12a5 5 0 0 1 7.5-4.33" />
</svg>
)
+170
View File
@@ -0,0 +1,170 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
/**
* 年月拨盘(iOS 闹钟式滚轮)
* - 两列各自滚动吸附,停稳后把最终值抛给父级(onChange)
* - 支持鼠标滚轮 / 触摸板 / 按住拖动;点某一行直接跳到该行
* - 外部值变化时反向同步滚动位置(且不会打回环)
*/
const ITEM_HEIGHT = 32 // 与 luxury.css 的 --wheel-item 保持一致
const SETTLE_DELAY = 140
function WheelColumn({ values, value, onSettle, format, ariaLabel }) {
const listRef = useRef(null)
const timerRef = useRef(null)
const dragRef = useRef(null)
const frameRef = useRef(0)
const muteRef = useRef(false)
const [dragging, setDragging] = useState(false)
const index = Math.max(0, values.indexOf(value))
const [centered, setCentered] = useState(index)
// 滚动过程中实时算出居中那一行,用来做 iOS 拨盘那样的放大/淡出
const syncCentered = useCallback(() => {
window.cancelAnimationFrame(frameRef.current)
frameRef.current = window.requestAnimationFrame(() => {
const node = listRef.current
if (!node) return
setCentered(Math.min(values.length - 1, Math.max(0, Math.round(node.scrollTop / ITEM_HEIGHT))))
})
}, [values.length])
const snapTo = useCallback((target, behavior) => {
const node = listRef.current
if (!node) return
if (Math.abs(node.scrollTop - target) < 1) return
muteRef.current = true
node.scrollTo({ top: target, behavior })
window.setTimeout(() => { muteRef.current = false }, behavior === 'smooth' ? 340 : 60)
}, [])
// 外部值变了(含父级纠正)就把滚轮挪到对应行
useLayoutEffect(() => {
snapTo(index * ITEM_HEIGHT, 'auto')
}, [index, snapTo])
// 值变化(含外部纠正)后,让居中高亮跟过来
useLayoutEffect(() => {
setCentered(index)
}, [index])
const settle = useCallback(() => {
const node = listRef.current
if (!node) return
const next = Math.min(values.length - 1, Math.max(0, Math.round(node.scrollTop / ITEM_HEIGHT)))
if (values[next] !== value) onSettle(values[next])
snapTo(next * ITEM_HEIGHT, 'smooth')
}, [onSettle, snapTo, value, values])
const handleScroll = () => {
if (muteRef.current || dragRef.current) return
syncCentered()
window.clearTimeout(timerRef.current)
timerRef.current = window.setTimeout(settle, SETTLE_DELAY)
}
useEffect(() => () => {
window.clearTimeout(timerRef.current)
window.cancelAnimationFrame(frameRef.current)
}, [])
const onPointerDown = (event) => {
const node = listRef.current
if (!node || event.button > 0) return
window.clearTimeout(timerRef.current)
dragRef.current = { startY: event.clientY, startTop: node.scrollTop, moved: false }
setDragging(true)
try { node.setPointerCapture(event.pointerId) } catch { /* 忽略不支持捕获的浏览器 */ }
}
const onPointerMove = (event) => {
if (!dragRef.current) return
const node = listRef.current
if (!node) return
const delta = event.clientY - dragRef.current.startY
if (Math.abs(delta) > 3) dragRef.current.moved = true
node.scrollTop = dragRef.current.startTop - delta
syncCentered()
}
const endDrag = () => {
if (!dragRef.current) return
const moved = dragRef.current.moved
dragRef.current = null
setDragging(false)
if (moved) settle()
}
return (
<div className="ymw-col" role="group" aria-label={ariaLabel}>
<div
className={`ymw-list${dragging ? ' is-dragging' : ''}`}
ref={listRef}
onScroll={handleScroll}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="ymw-pad" aria-hidden="true" />
{values.map((item, position) => {
const distance = Math.abs(position - centered)
return (
<button
type="button"
key={item}
className={`ymw-cell${distance === 0 ? ' is-active' : ''}`}
style={{
transform: `scale(${distance === 0 ? 1 : distance === 1 ? 0.92 : 0.84})`,
opacity: distance === 0 ? 1 : distance === 1 ? 0.7 : 0.38
}}
tabIndex={-1}
onClick={() => onSettle(item)}
>
{format(item)}
</button>
)
})}
<div className="ymw-pad" aria-hidden="true" />
</div>
</div>
)
}
export default function MonthYearWheel({ year, month, onChange, onToday, minYear, maxYear }) {
const years = useMemo(() => {
const list = []
for (let y = minYear; y <= maxYear; y += 1) list.push(y)
return list
}, [minYear, maxYear])
const months = useMemo(() => Array.from({ length: 12 }, (_, index) => index + 1), [])
// 两列可能在同一帧内先后停稳,各自只认自己那一列时不能读渲染期闭包,
// 否则后一次会用旧值覆盖前一次(滚到 2028年12月 会退回 2026年12月)。
const latest = useRef({ year, month })
latest.current = { year, month }
const pickYear = useCallback((nextYear) => {
latest.current = { ...latest.current, year: nextYear }
onChange(latest.current.year, latest.current.month)
}, [onChange])
const pickMonth = useCallback((nextMonth) => {
latest.current = { ...latest.current, month: nextMonth }
onChange(latest.current.year, latest.current.month)
}, [onChange])
return (
<div className="ymw-body">
<div className="ymw-drum">
<span className="ymw-highlight" aria-hidden="true" />
<WheelColumn values={years} value={year} onSettle={pickYear} format={item => `${item}`} ariaLabel="年份" />
<span className="ymw-unit">年</span>
<WheelColumn values={months} value={month} onSettle={pickMonth} format={item => `${item}`} ariaLabel="月份" />
<span className="ymw-unit">月</span>
</div>
<button type="button" className="ymw-today" onClick={onToday}>回到今天</button>
</div>
)
}
+269
View File
@@ -0,0 +1,269 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import Drawer from './Drawer'
import { useAuth, hasPermission } from '../auth'
/**
* 订单支出(业务员记的杂费:快递费、吊卡费、商检、报关…)。
*
* 为什么要有这个:订单上只有「卖给客户多少钱」,而一张单子真实花出去的还有寄样品的
* 快递费、印吊卡的费用、商检报关拖车。不记下来就算不出这单到底赚没赚 —— 小单子
* 几百块杂费能把毛利吃掉一截。
*
* 币种是这里最容易出错的地方:杂费基本都是人民币,订单常常是美元。所以每笔支出
* 带自己的币种,跨币种时**必须填汇率**(不猜,猜出来的毛利没人敢用),
* 后端会把汇率快照存下来,之后汇率再变历史账也不动。
*/
const num = value => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const fmt = (value, currency) => `${currency || ''} ${num(value).toFixed(2)}`.trim()
const today = () => new Date().toISOString().slice(0, 10)
const EMPTY = {
category: '快递费',
amount: '',
currency: 'CNY',
rate_to_order: '',
spent_at: today(),
payee: '',
voucher_no: '',
is_paid: false,
note: ''
}
function OrderExpenses({ order, onClose, onChanged }) {
const { user } = useAuth()
const canWrite = hasPermission(user, 'order.expense')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [form, setForm] = useState(EMPTY)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [editingId, setEditingId] = useState(null)
const orderCurrency = String(order?.currency || 'USD').toUpperCase()
const crossCurrency = String(form.currency || '').toUpperCase() !== orderCurrency
const load = useCallback(async () => {
setLoading(true)
try {
const { data: payload } = await axios.get(`/api/orders/${order.id}/expenses`)
setData(payload)
} catch (err) {
setError(err?.response?.data?.error || '读取支出失败')
} finally {
setLoading(false)
}
}, [order.id])
useEffect(() => { load() }, [load])
const summary = data?.summary
/* 毛利口径写清楚:这里减掉的只是杂费,没减货款采购成本 ——
标签上必须注明,否则会被当成真毛利去报价。 */
const afterExpense = useMemo(
() => (summary ? num(order.total_amount) - num(summary.total) : null),
[summary, order.total_amount]
)
const reset = () => { setForm({ ...EMPTY, currency: 'CNY' }); setEditingId(null); setError('') }
const submit = async event => {
event.preventDefault()
setSaving(true)
setError('')
try {
const body = {
...form,
amount: Number(form.amount),
rate_to_order: crossCurrency ? Number(form.rate_to_order) : 1,
supplier_id: null
}
if (editingId) await axios.patch(`/api/orders/${order.id}/expenses/${editingId}`, body)
else await axios.post(`/api/orders/${order.id}/expenses`, body)
reset()
await load()
onChanged?.()
} catch (err) {
setError(err?.response?.data?.error || '保存失败')
} finally {
setSaving(false)
}
}
const startEdit = row => {
setEditingId(row.id)
setError('')
setForm({
category: row.category,
amount: String(row.amount ?? ''),
currency: row.currency,
rate_to_order: String(row.rate_to_order ?? ''),
spent_at: row.spent_at || today(),
payee: row.payee || '',
voucher_no: row.voucher_no || '',
is_paid: !!row.is_paid,
note: row.note || ''
})
}
const remove = async row => {
if (!window.confirm(`删除这笔「${row.category} ${fmt(row.amount, row.currency)}」?`)) return
try {
await axios.delete(`/api/orders/${order.id}/expenses/${row.id}`)
if (editingId === row.id) reset()
await load()
onChanged?.()
} catch (err) {
setError(err?.response?.data?.error || '删除失败')
}
}
const togglePaid = async row => {
try {
await axios.patch(`/api/orders/${order.id}/expenses/${row.id}`, { is_paid: row.is_paid ? 0 : 1 })
await load()
onChanged?.()
} catch (err) {
setError(err?.response?.data?.error || '保存失败')
}
}
const set = (key, value) => setForm(prev => ({ ...prev, [key]: value }))
return (
<Drawer
wide
title={`订单支出 · ${order.order_no}`}
sub={`订单金额 ${fmt(order.total_amount, orderCurrency)}${order.customer_name ? ` · ${order.customer_name}` : ''}`}
onClose={onClose}
>
{loading ? <p className="oe-hint">读取中…</p> : (
<>
<div className="oe-summary">
<div className="oe-stat">
<span>杂费合计</span>
<strong>{fmt(summary?.total, orderCurrency)}</strong>
</div>
<div className="oe-stat">
<span>其中未付</span>
<strong className={num(summary?.unpaid) > 0 ? 'oe-unpaid' : ''}>{fmt(summary?.unpaid, orderCurrency)}</strong>
</div>
<div className="oe-stat">
<span>订单金额 − 杂费</span>
<strong>{fmt(afterExpense, orderCurrency)}</strong>
<em>未扣货款采购成本</em>
</div>
</div>
{!!summary?.count && (
<div className="oe-bycat">
{Object.entries(summary.byCategory).map(([name, value]) => (
<span key={name}>{name} <b>{num(value).toFixed(2)}</b></span>
))}
</div>
)}
{error && <p className="oe-error">{error}</p>}
{canWrite && (
<form className="oe-form" onSubmit={submit}>
<div className="oe-form-title">{editingId ? '修改这笔支出' : '记一笔支出'}</div>
<div className="oe-grid">
<label>类型
<select value={form.category} onChange={e => set('category', e.target.value)}>
{(data?.categories || []).map(name => <option key={name} value={name}>{name}</option>)}
</select>
</label>
<label>金额
<input type="number" step="0.01" min="0" required value={form.amount}
onChange={e => set('amount', e.target.value)} placeholder="286.50" />
</label>
<label>币种
<select value={form.currency} onChange={e => set('currency', e.target.value)}>
{['CNY', 'USD', 'EUR', 'GBP', 'HKD', 'JPY'].map(c => <option key={c} value={c}>{c}</option>)}
</select>
</label>
{/* 跨币种才出现:同币种硬填汇率只会填错 */}
{crossCurrency && (
<label className="oe-rate">1 {form.currency} = ? {orderCurrency}
<input type="number" step="0.0001" min="0" required value={form.rate_to_order}
onChange={e => set('rate_to_order', e.target.value)} placeholder="0.1395" />
</label>
)}
<label>发生日期
<input type="date" value={form.spent_at} onChange={e => set('spent_at', e.target.value)} />
</label>
<label>收款方
<input type="text" value={form.payee} onChange={e => set('payee', e.target.value)} placeholder="顺丰 / 某印刷厂" />
</label>
<label>单号/发票号
<input type="text" value={form.voucher_no} onChange={e => set('voucher_no', e.target.value)} placeholder="快递单号,事后好核对" />
</label>
<label className="oe-wide">备注
<input type="text" value={form.note} onChange={e => set('note', e.target.value)} placeholder="寄 2 套样品到德国" />
</label>
</div>
<div className="oe-form-foot">
<label className="oe-check">
<input type="checkbox" checked={form.is_paid} onChange={e => set('is_paid', e.target.checked)} />
已付款
</label>
<span className="oe-grow" />
{editingId && <button type="button" className="btn btn-ghost" onClick={reset}>取消</button>}
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : editingId ? '保存修改' : '记上'}
</button>
</div>
</form>
)}
<table className="data-table oe-table">
<thead>
<tr>
<th>日期</th><th>类型</th><th>金额</th>
<th>折 {orderCurrency}</th><th>收款方 / 单号</th><th>状态</th><th />
</tr>
</thead>
<tbody>
{!data?.expenses?.length ? (
<tr><td colSpan="7" className="empty-state">还没记过支出。快递费、吊卡费都记在这儿。</td></tr>
) : data.expenses.map(row => (
<tr key={row.id} className={editingId === row.id ? 'oe-editing' : ''}>
<td>{row.spent_at || '—'}</td>
<td>{row.category}</td>
<td className="oe-num">{fmt(row.amount, row.currency)}</td>
<td className="oe-num">{num(row.amount_in_order_currency).toFixed(2)}</td>
<td>
{row.payee || '—'}
{row.voucher_no && <div className="oe-voucher">{row.voucher_no}</div>}
{row.note && <div className="oe-note">{row.note}</div>}
</td>
<td>
<button type="button" className={`oe-paid ${row.is_paid ? 'is-paid' : ''}`}
onClick={() => canWrite && togglePaid(row)} disabled={!canWrite}
title={canWrite ? '点一下切换付款状态' : ''}>
{row.is_paid ? '已付' : '未付'}
</button>
</td>
<td className="oe-ops">
{canWrite && <>
<button type="button" className="btn-link" onClick={() => startEdit(row)}>改</button>
<button type="button" className="btn-link oe-del" onClick={() => remove(row)}>删</button>
</>}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</Drawer>
)
}
export default OrderExpenses
@@ -0,0 +1,145 @@
import React, { useCallback, useEffect, useState } from 'react'
import axios from 'axios'
import { CheckIcon, CloseIcon, RefreshIcon, SaveIcon, UsersIcon } from './Icons'
/**
* 组织与品牌 —— 只管「公司 / 组织」这块**可改**的业务信息。
*
* 界面上的系统名(登录页、顶栏、浏览器标题)是作者固定署名,由后端
* `lib/branding.js` 提供:设置页不展示、也没有修改入口,改公司名也不会顶掉它。
* (Wayne 明确要求设置页里不要出现系统标识展示与任何解释文字,别再往这加。)
*/
function OrgBrandingSettings() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [orgs, setOrgs] = useState([])
const [orgDraft, setOrgDraft] = useState({})
const [busy, setBusy] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await axios.get('/api/organizations')
const list = data?.organizations || []
setOrgs(list)
const draft = {}
for (const org of list) {
draft[org.id] = { name: org.name || '', code: org.code || '', description: org.description || '' }
}
setOrgDraft(draft)
setError('')
} catch (err) {
setError(err?.response?.data?.error || '读取组织信息失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const flash = (text) => {
setNotice(text)
window.setTimeout(() => setNotice(''), 3500)
}
const saveOrg = async (id) => {
setBusy(`org:${id}`)
try {
const { data } = await axios.patch(`/api/organizations/${id}`, orgDraft[id])
setOrgs(current => current.map(org => (org.id === id ? { ...org, ...data.organization } : org)))
flash(`公司名已改为「${data.organization.name}」`)
} catch (err) {
setError(err?.response?.data?.error || '保存组织信息失败')
} finally {
setBusy('')
}
}
if (loading) {
return (
<div className="settings-section">
<h3>组织与品牌</h3>
<p className="settings-description">正在读取组织信息…</p>
</div>
)
}
return (
<div className="settings-section">
<h3>组织与品牌</h3>
<p className="settings-description">
公司名可以随时改;界面上的系统名由作者固定署名,不随公司名变化。
</p>
{notice && <p className="ai-banner is-ok"><CheckIcon size={14} /> {notice}</p>}
{error && <p className="ai-banner is-error"><CloseIcon size={14} /> {error}</p>}
<div className="settings-group">
<div className="ai-group-head">
<h4>公司 / 组织</h4>
<button className="btn btn-secondary btn-sm" onClick={load} disabled={Boolean(busy)}>
<RefreshIcon size={14} /> 刷新
</button>
</div>
<p className="help-text">
<UsersIcon size={13} /> 同事归属哪个组织,就与同组织成员共享客户公海与产品库。
</p>
{orgs.length === 0 && <p className="help-text">还没有组织记录。</p>}
{orgs.map(org => (
<div className="org-card" key={org.id}>
<div className="form-row">
<div className="form-group">
<label>组织名称</label>
<input
type="text"
value={orgDraft[org.id]?.name ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], name: event.target.value }
}))}
/>
</div>
<div className="form-group">
<label>英文代码</label>
<input
type="text"
value={orgDraft[org.id]?.code ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], code: event.target.value }
}))}
placeholder="例如 HAPPY"
/>
</div>
</div>
<div className="form-group">
<label>描述</label>
<input
type="text"
value={orgDraft[org.id]?.description ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], description: event.target.value }
}))}
placeholder="这行说明会显示在权限组/用户的组织下拉里"
/>
</div>
<div className="org-card-foot">
<span className="help-text">{org.member_count || 0} 名成员</span>
<button
className="btn btn-primary btn-sm"
onClick={() => saveOrg(org.id)}
disabled={Boolean(busy) || orgDraft[org.id]?.name === org.name}
>
<SaveIcon size={14} /> {busy === `org:${org.id}` ? '保存中…' : '保存公司信息'}
</button>
</div>
</div>
))}
</div>
</div>
)
}
export default OrgBrandingSettings
+263
View File
@@ -0,0 +1,263 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import { PackageIcon, RefreshIcon } from './Icons'
/* 常用柜型内容积(m³)。取行业常用的保守值,宁少不溢柜。 */
const CONTAINERS = [
{ key: '20GP', label: '20GP', volume: 28 },
{ key: '40GP', label: '40GP', volume: 58 },
{ key: '40HQ', label: '40HQ', volume: 68 }
]
const FIELDS = [
{ key: 'inner_pack_qty', label: '内盒装量', unit: 'pcs/内盒', group: 'pack' },
{ key: 'carton_qty', label: '外箱装量', unit: 'pcs/箱', group: 'pack' },
{ key: 'carton_length', label: '外箱长', unit: 'cm', group: 'carton' },
{ key: 'carton_width', label: '外箱宽', unit: 'cm', group: 'carton' },
{ key: 'carton_height', label: '外箱高', unit: 'cm', group: 'carton' },
{ key: 'carton_weight', label: '外箱毛重', unit: 'kg', group: 'carton' },
{ key: 'unit_length', label: '单品长', unit: 'cm', group: 'unit' },
{ key: 'unit_width', label: '单品宽', unit: 'cm', group: 'unit' },
{ key: 'unit_height', label: '单品高', unit: 'cm', group: 'unit' },
{ key: 'unit_weight', label: '单品净重', unit: 'kg', group: 'unit' }
]
const EMPTY = FIELDS.reduce((acc, field) => { acc[field.key] = ''; return acc }, { packaging_material: '', notes: '' })
/** 算外箱体积与各柜型装柜量;字段不全就返回 null,不猜。 */
function calcLoading(form) {
const l = Number(form.carton_length)
const w = Number(form.carton_width)
const h = Number(form.carton_height)
const perCarton = Number(form.carton_qty)
if (![l, w, h].every(value => Number.isFinite(value) && value > 0)) return null
const cartonVolume = (l * w * h) / 1000000
if (!(cartonVolume > 0)) return null
const rows = CONTAINERS.map(container => {
const cartons = Math.floor(container.volume / cartonVolume)
return {
...container,
cartons,
pieces: Number.isFinite(perCarton) && perCarton > 0 ? cartons * perCarton : null
}
})
let totalWeight = null
const gross = Number(form.carton_weight)
if (Number.isFinite(gross) && gross > 0) totalWeight = rows.map(row => row.cartons * gross)
return { cartonVolume, rows, totalWeight }
}
/** 把接口返回的对象转成受控表单值(null → 空串,避免 React 受控告警) */
function toForm(data) {
const next = { ...EMPTY }
FIELDS.forEach(field => {
const value = data?.[field.key]
next[field.key] = value === null || value === undefined ? '' : String(value)
})
next.packaging_material = data?.packaging_material || ''
next.notes = data?.notes || ''
return next
}
/**
* 结构化包装信息面板。
* 只负责给「包装」这块数据提供录入与展示,SKU 是否存在由调用方保证
* (后端 PUT 会自己向 SKU 服务核验一次)。
*/
function PackagingPanel({ sku, readOnly = false, onSaved, canEdit }) {
const [form, setForm] = useState(EMPTY)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [available, setAvailable] = useState(true)
const load = useCallback(async () => {
if (!sku) return
setLoading(true)
setError('')
try {
const { data } = await axios.get(`/api/products/${encodeURIComponent(sku)}/packaging`)
setForm(toForm(data))
setAvailable(true)
} catch (loadError) {
setAvailable(false)
setError(loadError?.response?.data?.error || '读取包装信息失败')
} finally {
setLoading(false)
}
}, [sku])
useEffect(() => { load() }, [load])
useEffect(() => { setNotice('') }, [sku])
const loadingPlan = useMemo(() => calcLoading(form), [form])
// 空白输入一律按「未填」处理,不要变成 0 —— 0 和没填在装柜测算里含义完全不同
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
const save = async (event) => {
event.preventDefault()
if (!sku) return
setSaving(true)
setError('')
setNotice('')
const payload = {}
FIELDS.forEach(field => {
const raw = form[field.key]
payload[field.key] = raw === '' ? null : Number(raw)
})
payload.packaging_material = form.packaging_material.trim() || null
payload.notes = form.notes.trim() || null
try {
const { data } = await axios.put(`/api/products/${encodeURIComponent(sku)}/packaging`, payload)
setForm(toForm(data.packaging))
setNotice('包装信息已保存')
if (onSaved) onSaved(data.packaging)
} catch (saveError) {
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
if (loading) return <p className="pkg-loading">读取包装信息…</p>
if (!available) {
return (
<div className="pkg-unavailable">
<p>{error}</p>
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 重试
</button>
</div>
)
}
return (
<form className="pkg-panel" onSubmit={save}>
<div className="pkg-section">
<h4 className="pkg-section-title"><PackageIcon size={14} /> 装箱结构</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'pack').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="1" inputMode="numeric"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">外箱尺寸与重量</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'carton').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="0.1" inputMode="decimal"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">单品尺寸与重量</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'unit').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="0.1" inputMode="decimal"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">包装与备注</h4>
<div className="pkg-grid">
<label className="pkg-field pkg-field-wide">
<span>包装材质</span>
<input
type="text" value={form.packaging_material} disabled={readOnly}
placeholder="例如:彩盒 + 五层瓦楞纸箱"
onChange={event => set('packaging_material', event.target.value)}
/>
</label>
<label className="pkg-field pkg-field-wide">
<span>包装备注</span>
<input
type="text" value={form.notes} disabled={readOnly}
placeholder="唛头要求、客户指定箱规等"
onChange={event => set('notes', event.target.value)}
/>
</label>
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">装柜测算</h4>
{!loadingPlan ? (
<p className="pkg-loading-hint">填好外箱长宽高即可自动推算各柜型装柜量。</p>
) : (
<>
<p className="pkg-volume">
单箱体积 <b>{loadingPlan.cartonVolume.toFixed(4)}</b> m³
</p>
<div className="pkg-container-grid">
{loadingPlan.rows.map((row, index) => (
<div className="pkg-container" key={row.key}>
<span className="pkg-container-name">{row.label}</span>
<span className="pkg-container-main">
<b>{row.cartons.toLocaleString('zh-CN')}</b> 箱
</span>
<span className="pkg-container-sub">
{row.pieces === null ? '装量未知' : `${row.pieces.toLocaleString('zh-CN')} pcs`}
{loadingPlan.totalWeight ? ` · 毛重 ${(loadingPlan.totalWeight[index] / 1000).toFixed(2)} t` : ''}
</span>
</div>
))}
</div>
<p className="pkg-caveat">按柜内容积 28 / 58 / 68 m³ 估算,未计托盘与装载空隙,实际订舱请以货代确认为准。</p>
</>
)}
</div>
{error && <p className="pkg-error">{error}</p>}
{notice && <p className="pkg-notice">{notice}</p>}
{!readOnly && canEdit && (
<div className="pkg-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load} disabled={saving}>
<RefreshIcon size={14} /> 还原
</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? '保存中…' : '保存包装信息'}
</button>
</div>
)}
</form>
)
}
export default PackagingPanel
+187
View File
@@ -0,0 +1,187 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import { SearchIcon, CloseIcon } from './Icons'
/**
* 产品速查。
*
* 跟 ⌘K 全局搜索和 SkuPicker 都不是一件事:
* · ⌘K 是「找到后跳过去」—— 一跳走,正在写的报价就断了;
* · SkuPicker 是「挑一个塞进表单」—— 要点产品组再点货号,两层;
* · 速查是「报价报到一半,想起来要确认一下这个货多少钱、起订多少、一箱装几个」。
* 所以它不跳转、不改任何东西,就是把数摆出来,还能一键复制。
*
* 快捷键 Ctrl/⌘ + J。没占 K 是因为 ⌘K 已经是全局搜索了,抢掉会把老习惯打断。
*/
const money = (value, currency) => {
if (value === null || value === undefined || value === '') return '—'
const num = Number(value)
if (!Number.isFinite(num)) return '—'
return `${currency || ''} ${num.toFixed(2)}`.trim()
}
function QuickLookup() {
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [copied, setCopied] = useState('')
const inputRef = useRef(null)
/* 开关:Ctrl/⌘+J 开,Esc 关。开的时候自动聚焦并全选,
连着查第二个货号时不用先手动删掉上一个。 */
useEffect(() => {
const onKeyDown = event => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'j') {
event.preventDefault()
setOpen(true)
window.setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, 0)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
useEffect(() => {
if (!open) return
const onKeyDown = event => { if (event.key === 'Escape') setOpen(false) }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open])
/* 查询:180ms 防抖,跟全局搜索保持同一个手感 */
useEffect(() => {
const term = keyword.trim()
if (!term) { setResults([]); setError(''); return }
let cancelled = false
const timer = window.setTimeout(async () => {
setLoading(true)
setError('')
try {
const { data } = await axios.get('/api/sku/quick', { params: { q: term } })
if (!cancelled) setResults(data.results || [])
} catch (err) {
if (!cancelled) {
setResults([])
setError(err?.response?.data?.error || '查不动了,确认一下后端还在不在')
}
} finally {
if (!cancelled) setLoading(false)
}
}, 180)
return () => { cancelled = true; window.clearTimeout(timer) }
}, [keyword])
/* 复制:报价时最常做的动作就是把货号或价格抄到别处去。
navigator.clipboard 在非 HTTPS 的内网环境下可能不可用,退回 execCommand。 */
const copy = useCallback(async (text, tag) => {
const value = String(text ?? '')
if (!value) return
try {
await navigator.clipboard.writeText(value)
} catch {
const helper = document.createElement('textarea')
helper.value = value
helper.style.position = 'fixed'
helper.style.opacity = '0'
document.body.appendChild(helper)
helper.select()
try { document.execCommand('copy') } catch { /* 复制不了就算了,不弹错 */ }
helper.remove()
}
setCopied(tag)
window.setTimeout(() => setCopied(current => (current === tag ? '' : current)), 1200)
}, [])
if (!open) return null
return (
<div className="ql-backdrop" onMouseDown={event => { if (event.target === event.currentTarget) setOpen(false) }}>
<div className="ql-panel" role="dialog" aria-label="产品速查">
<div className="ql-head">
<SearchIcon size={17} />
<input
ref={inputRef}
type="text"
value={keyword}
onChange={event => setKeyword(event.target.value)}
placeholder="货号 / 品名 / 规格 —— 报价时查采购价与起订量"
autoComplete="off"
aria-label="产品速查关键词"
/>
<kbd>Esc</kbd>
<button type="button" className="ql-close" onClick={() => setOpen(false)} aria-label="关闭"><CloseIcon size={15} /></button>
</div>
<div className="ql-body">
{!keyword.trim() && (
<div className="ql-hint">
敲货号直接命中;敲品名按采购价从低到高排。<br />
这里<strong>只看不改</strong>,随时 Esc 回到刚才的地方。
</div>
)}
{loading && <div className="ql-hint">正在查…</div>}
{error && <div className="ql-hint ql-error">{error}</div>}
{!loading && !error && keyword.trim() && !results.length && (
<div className="ql-hint">没找到。换个关键词,或者确认这个货号的合同导入了没。</div>
)}
{results.map(item => (
<div className="ql-row" key={item.sku}>
<div className="ql-main">
<div className="ql-sku">
<button type="button" className="ql-copy-sku" title="复制货号" onClick={() => copy(item.displaySku, `sku-${item.sku}`)}>
{item.displaySku}
{copied === `sku-${item.sku}` && <span className="ql-copied">已复制</span>}
</button>
{item.confidence === 'low' && <span className="ql-flag">待核对</span>}
</div>
<div className="ql-name">{item.name || '—'}</div>
{item.nameEn && <div className="ql-sub">{item.nameEn}</div>}
{item.spec && <div className="ql-sub">规格 {item.spec}</div>}
</div>
<div className="ql-numbers">
<button type="button" className="ql-price" title="复制价格" onClick={() => copy(item.price ?? '', `price-${item.sku}`)}>
{money(item.price, item.currency)}
{copied === `price-${item.sku}` && <span className="ql-copied">已复制</span>}
</button>
<div className="ql-meta">
<span>起订 {item.moq ?? '—'}</span>
<span>{item.supplier || '无供应商'}</span>
</div>
{item.packing && (
<div className="ql-meta ql-packing">
{item.packing.cartonQty ? <span>装箱 {item.packing.cartonQty}</span> : null}
{item.packing.cartonSize ? <span>{item.packing.cartonSize}</span> : null}
{item.packing.cartonWeight ? <span>{item.packing.cartonWeight}kg</span> : null}
</div>
)}
{!!item.tiers?.length && (
<div className="ql-tiers">
{item.tiers.map((tier, index) => (
<span key={index}>{tier.moq_from}{tier.moq_to ? `-${tier.moq_to}` : '+'} → {money(tier.price, tier.currency)}</span>
))}
</div>
)}
</div>
</div>
))}
</div>
{!!results.length && (
<div className="ql-foot">
{results.length} 条 · 点货号或价格可复制 · 价格是<strong>采购价</strong>,不是报价
</div>
)}
</div>
</div>
)
}
export default QuickLookup
+260
View File
@@ -0,0 +1,260 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { SearchIcon, ChevronDownIcon, CloseIcon } from './Icons'
/**
* 可搜索下拉(combobox)
*
* 供应商 272 家、产品 300+ 个,原生 <select> 只能一格一格滚。这里把触发区做成
* 一个搜索框:点开就能打字,候选实时收窄,↑↓ 选择、回车确认、Esc 收起。
*
* 实现要点:
* - 菜单用 createPortal 挂到 body + position:fixed —— 抽屉的 .drawer-body 是
* overflow:auto,就地绝对定位会被它裁掉;fixed 还能跟着滚动/缩放重新定位。
* - 触发区沿用全局「无方框」输入框规则:靠底色区分,不做描边与聚焦光环。
* - 展开时按实时位置决定朝上还是朝下弹,空间不够就压 max-height 自身滚动。
*
* props
* value / onChange(value, option)
* options [{ value, label, hint? }],hint 显示在右侧(灰)
* placeholder 未选中时的提示
* searchPlaceholder 展开后的提示
* emptyText 无匹配时的文案
* size 'md'(38px) | 'sm'(32px)
* clearable 选中后是否给出清除按钮
*/
const MENU_MAX = 268
const GAP = 6
function SearchSelect({
value,
onChange,
options = [],
placeholder = '请选择',
searchPlaceholder = '输入关键词搜索…',
emptyText = '没有匹配项',
size = 'md',
disabled = false,
clearable = false,
style,
className = ''
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const [pos, setPos] = useState(null)
const rootRef = useRef(null)
const menuRef = useRef(null)
const inputRef = useRef(null)
const listRef = useRef(null)
const selected = useMemo(
() => options.find((o) => String(o.value) === String(value ?? '')) || null,
[options, value]
)
const filtered = useMemo(() => {
const kw = query.trim().toLowerCase()
if (!kw) return options
return options.filter((o) => `${o.label || ''} ${o.hint || ''}`.toLowerCase().includes(kw))
}, [options, query])
/* 按触发区的实时位置定位菜单:下方空间不够就翻到上面 */
const place = useCallback(() => {
const el = rootRef.current
if (!el) return
const r = el.getBoundingClientRect()
const spaceBelow = window.innerHeight - r.bottom - GAP - 8
const spaceAbove = r.top - GAP - 8
const up = spaceBelow < 180 && spaceAbove > spaceBelow
setPos({
left: r.left,
width: r.width,
top: up ? undefined : r.bottom + GAP,
bottom: up ? window.innerHeight - r.top + GAP : undefined,
maxHeight: Math.max(120, Math.min(MENU_MAX, up ? spaceAbove : spaceBelow))
})
}, [])
const close = useCallback(() => {
setOpen(false)
setQuery('')
}, [])
const openMenu = useCallback(() => {
if (disabled || open) return
place()
setQuery('')
const idx = options.findIndex((o) => String(o.value) === String(value ?? ''))
setActive(idx >= 0 ? idx : 0)
setOpen(true)
}, [disabled, open, options, place, value])
const pick = useCallback((option) => {
onChange?.(option.value, option)
setOpen(false)
setQuery('')
}, [onChange])
/* 展开期间:点外部收起、滚动/缩放时跟住触发区 */
useEffect(() => {
if (!open) return
const onDocDown = (e) => {
if (rootRef.current?.contains(e.target)) return
if (menuRef.current?.contains(e.target)) return
close()
}
const onReflow = () => place()
document.addEventListener('mousedown', onDocDown)
window.addEventListener('resize', onReflow)
window.addEventListener('scroll', onReflow, true)
return () => {
document.removeEventListener('mousedown', onDocDown)
window.removeEventListener('resize', onReflow)
window.removeEventListener('scroll', onReflow, true)
}
}, [open, close, place])
/* 键盘走到看不见的项时,把它滚进视野 */
useEffect(() => {
if (!open) return
const el = listRef.current?.querySelector('.ss-opt.is-active')
el?.scrollIntoView({ block: 'nearest' })
}, [open, active])
const onKeyDown = (e) => {
// Esc 只收候选:必须拦住,否则抽屉/浮层的全局 Esc 会顺手把整层关掉
if (e.key === 'Escape') { e.stopPropagation(); close(); return }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (!open) { openMenu(); return }
if (filtered.length === 0) return
setActive((i) => {
const next = e.key === 'ArrowDown' ? i + 1 : i - 1
return (next + filtered.length) % filtered.length
})
return
}
if (e.key === 'Enter') {
if (!open) return
e.preventDefault()
const option = filtered[active]
if (option) pick(option)
}
}
const showClear = clearable && !disabled && selected
return (
<div
ref={rootRef}
className={`ss-root ss-${size}${open ? ' is-open' : ''}${disabled ? ' is-disabled' : ''}${className ? ` ${className}` : ''}`}
style={style}
onMouseDown={(e) => {
if (disabled) return
if (e.target.closest('.ss-clear')) return
if (inputRef.current && document.activeElement !== inputRef.current) inputRef.current.focus()
if (!open) openMenu()
}}
// mousedown 之外再兜一层 click:真实鼠标两者都会来(第二次是 no-op),
// 但程序化 el.click() / 触摸设备只发 click,不补这层会「点了没反应」
onClick={() => { if (!open) openMenu() }}
>
<span className="ss-icon" aria-hidden="true"><SearchIcon size={size === 'sm' ? 13 : 14} /></span>
<input
ref={inputRef}
type="text"
value={open ? query : (selected?.label || '')}
placeholder={open ? searchPlaceholder : placeholder}
readOnly={!open}
disabled={disabled}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
onFocus={openMenu}
onKeyDown={onKeyDown}
/>
{showClear && (
<button
type="button"
className="ss-clear"
title="清除"
aria-label="清除"
onMouseDown={(e) => e.preventDefault()}
onClick={() => { onChange?.('', null); setQuery(''); inputRef.current?.focus() }}
>
<CloseIcon size={12} />
</button>
)}
<span className={`ss-caret${open ? ' is-up' : ''}`} aria-hidden="true"><ChevronDownIcon size={13} /></span>
{open && pos && createPortal(
<div
ref={menuRef}
className="ss-menu"
role="listbox"
style={{ left: pos.left, width: pos.width, top: pos.top, bottom: pos.bottom, maxHeight: pos.maxHeight }}
>
{filtered.length === 0 ? (
<div className="ss-empty">{emptyText}</div>
) : (
<div ref={listRef}>
{filtered.map((o, i) => (
<div
key={String(o.value)}
role="option"
aria-selected={i === active}
className={`ss-opt${i === active ? ' is-active' : ''}${selected && String(o.value) === String(value ?? '') ? ' is-selected' : ''}`}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => { e.preventDefault(); pick(o) }}
onClick={() => pick(o)}
>
<span className="ss-label">{o.label}</span>
{o.hint != null && o.hint !== '' && <span className="ss-hint">{o.hint}</span>}
</div>
))}
</div>
)}
</div>,
document.body
)}
</div>
)
}
/**
* 列表上方的内联搜索框:用于「列表本身」的即时过滤(供应商详情的供货清单、
* 产品详情里的货号表这类)。和 SearchSelect 同一套外观语言。
*/
export function SearchField({ value, onChange, placeholder = '搜索…', size = 'md', style, className = '' }) {
return (
<div className={`sf-root sf-${size}${className ? ` ${className}` : ''}`} style={style}>
<span className="ss-icon" aria-hidden="true"><SearchIcon size={size === 'sm' ? 13 : 14} /></span>
<input
type="text"
value={value}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => onChange(e.target.value)}
/>
{value !== '' && value != null && (
<button
type="button"
className="ss-clear"
title="清除"
aria-label="清除"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange('')}
>
<CloseIcon size={12} />
</button>
)}
</div>
)
}
export default SearchSelect
+49 -40
View File
@@ -1,10 +1,34 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, Suspense } from 'react'
import axios from 'axios'
import { MoonIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon } from './Icons'
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon, BuildingIcon } from './Icons'
import { useAuth, hasPermission } from '../auth'
import { useBranding } from '../branding'
/* 这两块只有系统管理账号会看,按需加载:设置本身在外壳里是静态引入的,
跟着首屏一起下发等于给所有人多下载十几 KB。 */
const AIConfigSettings = React.lazy(() => import('./AIConfigSettings'))
const OrgBrandingSettings = React.lazy(() => import('./OrgBrandingSettings'))
/* 设置分类。带 permission 的项只对有该权限点的人显示 ——
智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。 */
const CATEGORIES = [
{ key: 'appearance', label: '外观与主题', icon: TuneIcon },
{ key: 'organization', label: '组织与品牌', icon: BuildingIcon, permission: 'branding.manage' },
{ key: 'import-export', label: '数据交换', icon: ExchangeIcon },
{ key: 'ai-config', label: '智能配置', icon: SparkIcon, permission: 'ai.manage' },
{ key: 'system-update', label: '系统与更新', icon: RefreshIcon }
]
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
const { user } = useAuth()
const [activeCategory, setActiveCategory] = useState('appearance')
const visibleCategories = CATEGORIES.filter(item => !item.permission || hasPermission(user, item.permission))
/* 打开着设置时权限被收回(换了角色),当前分类可能已经不可见,退回第一项 */
const current = visibleCategories.some(item => item.key === activeCategory)
? activeCategory
: visibleCategories[0]?.key
const modalRef = useRef(null)
useEffect(() => {
if (!isOpen) return
@@ -39,37 +63,36 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
<aside className="settings-sidebar">
<div className="settings-header">
<div>
<span className="section-label">[CONTROL CENTER]</span>
<h2 id="settings-title">系统设置</h2>
</div>
<button className="close-btn" onClick={onClose} aria-label="关闭设置"><CloseIcon size={18} /></button>
</div>
<nav className="settings-nav" aria-label="设置分类">
<button className={`settings-nav-item ${activeCategory === 'appearance' ? 'active' : ''}`} onClick={() => setActiveCategory('appearance')}>
<span className="settings-nav-icon"><TuneIcon size={16} /></span>
<span>外观与主题</span>
</button>
<button className={`settings-nav-item ${activeCategory === 'import-export' ? 'active' : ''}`} onClick={() => setActiveCategory('import-export')}>
<span className="settings-nav-icon"><ExchangeIcon size={18} /></span>
<span>数据交换</span>
</button>
<button className={`settings-nav-item ${activeCategory === 'ai-config' ? 'active' : ''}`} onClick={() => setActiveCategory('ai-config')}>
<span className="settings-nav-icon"><SparkIcon size={18} /></span>
<span>智能配置</span>
</button>
<button className={`settings-nav-item ${activeCategory === 'system-update' ? 'active' : ''}`} onClick={() => setActiveCategory('system-update')}>
<span className="settings-nav-icon"><RefreshIcon size={18} /></span>
<span>系统与更新</span>
{visibleCategories.map(item => {
const Icon = item.icon
return (
<button
key={item.key}
className={`settings-nav-item ${current === item.key ? 'active' : ''}`}
onClick={() => setActiveCategory(item.key)}
>
<span className="settings-nav-icon"><Icon size={17} /></span>
<span>{item.label}</span>
</button>
)
})}
</nav>
<div className="settings-sidebar-status"><TuneIcon size={15} /><span>专注业务,自在掌控<small>TRADE MANAGEMENT</small></span></div>
</aside>
<section className="settings-content">
{activeCategory === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
{activeCategory === 'import-export' && <ImportExportSettings />}
{activeCategory === 'ai-config' && <AIConfigSettings />}
{activeCategory === 'system-update' && <SystemUpdateSettings />}
<Suspense fallback={<div className="settings-section"><p className="settings-description">正在载入…</p></div>}>
{current === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
{current === 'organization' && <OrgBrandingSettings />}
{current === 'import-export' && <ImportExportSettings />}
{current === 'ai-config' && <AIConfigSettings />}
{current === 'system-update' && <SystemUpdateSettings />}
</Suspense>
</section>
</div>
</div>
@@ -79,13 +102,12 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
return (
<div className="settings-section">
<span className="section-label">[APPEARANCE]</span>
<h3>外观与主题</h3>
<p className="settings-description">选择适合工作环境的显示模式,设置会自动保存在本机。</p>
<div className="theme-selector" role="group" aria-label="界面主题">
<button className={`theme-option ${theme === 'dark' ? 'active' : ''}`} aria-pressed={theme === 'dark'} onClick={() => onThemeChange('dark')}>
<span className="theme-preview theme-preview-dark" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><MoonIcon size={18} /><span className="mini-stats"><i /><i /><i /></span><span className="mini-table"><i /><i /><i /></span></span></span>
<span className="theme-preview theme-preview-dark" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><MoonStarsIcon size={18} /><span className="mini-stats"><i /><i /><i /></span><span className="mini-table"><i /><i /><i /></span></span></span>
<span><strong>夜间控制台</strong><small>深色 · 低光环境</small></span>
<span className="theme-check" aria-hidden="true">{theme === 'dark' && <CheckIcon size={14} />}</span>
</button>
@@ -109,7 +131,6 @@ function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMoti
function ImportExportSettings() {
return (
<div className="settings-section">
<span className="section-label">[DATA EXCHANGE]</span>
<h3>导入 / 导出</h3>
<p className="settings-description">管理业务数据的导入、导出与本地备份。</p>
<div className="settings-group"><h4>导出数据</h4><div className="settings-actions"><button className="btn btn-secondary">导出供应商数据</button><button className="btn btn-secondary">导出产品数据</button><button className="btn btn-secondary">导出客户数据</button><button className="btn btn-secondary">导出订单数据</button></div></div>
@@ -119,19 +140,7 @@ function ImportExportSettings() {
)
}
function AIConfigSettings() {
return (
<div className="settings-section">
<span className="section-label">[INTELLIGENCE]</span>
<h3>智能配置</h3>
<p className="settings-description">配置 AI 功能相关参数与模型。</p>
<div className="settings-group"><h4>API 密钥配置</h4><div className="form-group"><label>OpenAI API Key</label><input type="password" placeholder="sk-..." className="form-input" /></div><div className="form-group"><label>Claude API Key</label><input type="password" placeholder="sk-ant-..." className="form-input" /></div><p className="help-text">当前为配置界面预览,密钥保存和连接测试尚未接入。</p></div>
<div className="settings-group"><h4>AI 功能开关</h4><label className="setting-toggle"><span><strong>启用合同智能解析</strong><small>自动提取合同关键字段</small></span><input type="checkbox" defaultChecked /><i /></label><label className="setting-toggle"><span><strong>启用客户画像分析</strong><small>辅助判断客户跟进优先级</small></span><input type="checkbox" defaultChecked /><i /></label><label className="setting-toggle"><span><strong>启用自动报价建议</strong><small>基于历史价格提供参考</small></span><input type="checkbox" /><i /></label></div>
<div className="settings-group"><h4>模型选择</h4><div className="form-group"><label>OCR 模型</label><select className="form-select"><option>GPT-4 Vision</option><option>Claude 3 Opus</option><option>Claude 3 Sonnet</option></select></div><div className="form-group"><label>文本分析模型</label><select className="form-select"><option>GPT-4</option><option>Claude 3 Opus</option><option>GPT-3.5 Turbo</option></select></div></div>
<div className="settings-actions"><button className="btn btn-primary">保存配置</button><button className="btn btn-secondary">测试连接</button></div>
</div>
)
}
/* 智能配置已拆到 ./AIConfigSettings(多模型接入 + 用途分配)与 ./OrgBrandingSettings(组织与品牌) */
/* ---------------------------------------------------------------------------
系统与更新
@@ -139,6 +148,7 @@ function AIConfigSettings() {
版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。
--------------------------------------------------------------------------- */
function SystemUpdateSettings() {
const brand = useBranding()
const [info, setInfo] = useState(null)
const [repo, setRepo] = useState('')
const [loadingInfo, setLoadingInfo] = useState(true)
@@ -194,7 +204,6 @@ function SystemUpdateSettings() {
return (
<div className="settings-section">
<span className="section-label">[SYSTEM]</span>
<h3>系统与更新</h3>
<p className="settings-description">查看当前版本与运行环境,并从官方仓库检查是否有新版本。</p>
@@ -202,7 +211,7 @@ function SystemUpdateSettings() {
<h4>当前版本</h4>
<div className="setting-info">
<span>
<strong>{info ? `happy 出口通 v${info.version}` : '正在读取版本…'}</strong>
<strong>{info ? `${brand.name} v${info.version}` : '正在读取版本…'}</strong>
<small>{info ? `运行环境 Node ${info.node} · ${info.platform} ${info.arch}` : '读取本机信息中'}</small>
</span>
<span className="preference-badge">正式版</span>
+96
View File
@@ -0,0 +1,96 @@
import React from 'react'
/* ---------------------------------------------------------------------------
* 骨架屏
*
* 为什么要有它:接口回来之前,页面原本只显示一句「正在载入…」。
* 用户看到的是「什么都没有」+ 一行字,主观上就是把整个加载时间都算成了等待。
* 换成与真实内容同构的灰色骨架后,视线先落在结构上,感知到的等待会短很多
* ——这不是玄学,是布局稳定(不会突然跳一下)带来的直接体感差别。
*
* 设计原则:
* · 骨架的行高、列宽必须**贴近真实内容**,否则加载完成时页面会大幅跳动,
* 那比显示一句「正在载入…」还难受。所以宁可少画几行,也不要画得不像。
* · 纯静态 + 透明度呼吸动画,不用渐变扫光(工业风不需要花活,且省 CPU)。
* · 尊重「减少动态效果」设置,开了就静止。
* --------------------------------------------------------------------------- */
/** 骨架基础块。宽高直接给数字(px)或字符串('60%')。 */
export default function Skeleton({ width = '100%', height = 12, radius = 4, className = '', style }) {
return (
<span
className={`skeleton-block ${className}`.trim()}
style={{ width, height, borderRadius: radius, ...style }}
aria-hidden="true"
/>
)
}
/**
* 表格骨架:必须放在 <tbody> 里。
* widths 传每列的相对宽度(数字数组),不传就均分。
* 第一列默认画长一点,因为表格第一列通常是名称/编号。
*/
export function SkeletonTableRows({ rows = 6, widths = null, colSpan = 1 }) {
const cells = widths || [2.2, 1, 1, 1]
const total = cells.reduce((sum, n) => sum + n, 0)
return Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="skeleton-row" aria-hidden="true">
{colSpan > 1 ? (
<td colSpan={colSpan} style={{ padding: '12px 14px' }}>
<span className="skeleton-inline">
{cells.map((weight, cellIndex) => (
<Skeleton
key={cellIndex}
width={`${((weight / total) * 100).toFixed(2)}%`}
height={12}
/>
))}
</span>
</td>
) : (
cells.map((weight, cellIndex) => (
<td key={cellIndex} style={{ padding: '12px 14px' }}>
<Skeleton width={`${Math.min(100, (weight / Math.max(...cells)) * 100).toFixed(0)}%`} height={12} />
</td>
))
)}
</tr>
))
}
/**
* 卡片网格骨架:给工作台那种「一排指标卡」用。
* 数量与真实卡片一致,避免加载完成时整页重排。
*/
export function SkeletonCards({ count = 4, height = 76 }) {
return (
<div className="skeleton-cards" aria-hidden="true">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="skeleton-card">
<Skeleton width="46%" height={11} />
<Skeleton width="68%" height={22} style={{ marginTop: 10 }} />
</div>
))}
</div>
)
}
/**
* 面板骨架:给「一张面板里几行文本」用。
*/
export function SkeletonPanel({ lines = 3, title = true }) {
return (
<div className="skeleton-panel" aria-hidden="true">
{title && <Skeleton width="30%" height={14} style={{ marginBottom: 14 }} />}
{Array.from({ length: lines }).map((_, index) => (
<Skeleton
key={index}
width={index === lines - 1 ? '55%' : '100%'}
height={12}
style={{ marginBottom: 10 }}
/>
))}
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { SearchIcon, PackageIcon, CloseIcon, ChevronDownIcon } from './Icons'
import { SkuThumb, useSkuThumbs } from './SkuThumb'
/**
* SKU 选择器
*
* 数据来自 happy 后端自己的 /api/sku(SKU 模块已并入),不直连端口。搜索返回的是「产品组」,
* 点开产品组再看到具体货号 —— 这是 SKU 服务本来的数据结构,硬拍平成一张 SKU 列表
* 会丢掉归属关系,业务上分不清同一个产品的不同规格。
*
* 同时允许直接输入完整货号回车选中:老手知道自己要哪个货号时不该被逼着点两层。
*/
function SkuPicker({ onPick, onClose, excludeSkus = [] }) {
const [keyword, setKeyword] = useState('')
const [groups, setGroups] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [expanded, setExpanded] = useState(null)
const [itemsCache, setItemsCache] = useState({})
const [itemsLoading, setItemsLoading] = useState(false)
const timerRef = useRef(null)
const excluded = useMemo(() => new Set(excludeSkus.map(sku => String(sku).toUpperCase())), [excludeSkus])
const search = useCallback(async (query) => {
setLoading(true)
setError('')
try {
const { data } = await axios.get('/api/sku/search', { params: { q: query } })
setGroups(Array.isArray(data) ? data : [])
} catch (searchError) {
setGroups([])
setError(searchError?.response?.data?.error || 'SKU 服务暂时不可用,请确认采购模块已启动')
} finally {
setLoading(false)
}
}, [])
// 首次直接拉全量(SKU 服务默认返回前 300 组),输入时再防抖查
useEffect(() => {
search('')
}, [search])
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => search(keyword.trim()), 260)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
}, [keyword, search])
const toggleGroup = async (group) => {
if (expanded === group.id) { setExpanded(null); return }
setExpanded(group.id)
if (itemsCache[group.id]) return
setItemsLoading(true)
try {
const { data } = await axios.get(`/api/sku/group/${group.id}`)
setItemsCache(current => ({ ...current, [group.id]: data.items || [] }))
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取产品货号失败')
} finally {
setItemsLoading(false)
}
}
// 输入框里直接敲完整货号时,优先按货号选中,不去猜产品组
const submitDirect = (event) => {
event.preventDefault()
const value = keyword.trim()
if (!value) return
onPick({ sku: value.toUpperCase(), display_sku: value.toUpperCase(), source: 'manual' })
}
return (
<div className="sku-picker">
<div className="sku-picker-head">
<h3><PackageIcon size={16} /> 选择已有货号</h3>
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭"><CloseIcon size={15} /></button>
</div>
<form className="sku-picker-search" onSubmit={submitDirect}>
<span className="sku-picker-search-icon"><SearchIcon size={15} /></span>
<input
type="text"
autoFocus
value={keyword}
placeholder="搜产品名 / 货号 / 规格,或直接敲完整货号回车"
aria-label="搜索货号"
onChange={event => setKeyword(event.target.value)}
/>
</form>
{error && <p className="sku-picker-error">{error}</p>}
<div className="sku-picker-body">
{loading && !groups.length && <p className="sku-picker-hint">正在读取 SKU 目录…</p>}
{!loading && !error && !groups.length && (
<p className="sku-picker-hint">没有匹配的产品。可以直接敲完整货号回车强制选用。</p>
)}
<ul className="sku-group-list">
{groups.map(group => {
const isOpen = expanded === group.id
const items = itemsCache[group.id] || []
const priceText = group.price_min === null || group.price_min === undefined
? '未报价'
: group.price_min === group.price_max
? `${group.price_min}`
: `${group.price_min} ~ ${group.price_max}`
return (
<li className="sku-group" key={group.id}>
<button
type="button"
className={`sku-group-head${isOpen ? ' is-open' : ''}`}
onClick={() => toggleGroup(group)}
aria-expanded={isOpen}
>
<span className={`sku-group-caret${isOpen ? ' is-open' : ''}`}><ChevronDownIcon size={13} /></span>
<span className="sku-group-name">{group.name || '未命名产品'}</span>
<span className="sku-group-meta">
{group.supplier_short || group.supplier_name || '未指派供应商'}
{' · '}
{group.item_count} 个货号
{' · '}
{priceText}
</span>
</button>
{isOpen && (
<div className="sku-group-items">
{itemsLoading && !items.length && <p className="sku-picker-hint">读取货号中…</p>}
{items.map(item => {
const already = excluded.has(String(item.sku).toUpperCase())
return (
<button
type="button"
key={item.sku}
className={`sku-item${already ? ' is-used' : ''}`}
disabled={already}
onClick={() => onPick(item)}
title={already ? '该货号已在本次询盘中' : `选用 ${item.sku}`}
>
<SkuThumb sku={item.sku} filename={thumbs[item.sku]} size={32} />
<span className="sku-item-code">{item.display_sku || item.sku}</span>
<span className="sku-item-spec">{item.spec || item.name || '—'}</span>
<span className="sku-item-price">
{item.price === null || item.price === undefined
? <em>未报价</em>
: <><b>{item.price}</b><span className="unit"> {item.currency || 'CNY'}</span></>}
</span>
{already && <span className="sku-item-flag">已添加</span>}
</button>
)
})}
{!itemsLoading && !items.length && <p className="sku-picker-hint">这个产品下还没有货号。</p>}
</div>
)}
</li>
)
})}
</ul>
</div>
</div>
)
}
export default SkuPicker
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import axios from 'axios'
import { ImageIcon } from './Icons'
/**
* 货号缩略图 + 鼠标悬浮预览。
*
* 图片来自 happy 后端(4300)的 /product-images/*,同源访问,已无独立 SKU 服务。
* 图片挂在**产品组**上(不是每个货号一张),所以同一个组里的货号会共享同一张主图。
*/
export const thumbSrc = filename => (filename ? `/product-images/${encodeURIComponent(filename)}` : '')
const PREVIEW_WIDTH = 268
const PREVIEW_HEIGHT = 268
/**
* @param {string} sku 货号(用于预览卡上的标注)
* @param {string} filename 图片文件名(不带路径)
* @param {number} size 缩略图边长
* @param {boolean} preview 是否开启悬浮预览
*/
export function SkuThumb({ sku, filename, size = 34, preview = true, className = '' }) {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState(null)
const anchorRef = useRef(null)
const src = thumbSrc(filename)
/* 预览是 fixed 浮层:滚动或改变视口就收起,免得悬在错位的半空中 */
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [open])
const show = () => {
const element = anchorRef.current
if (!element || !src) return
const rect = element.getBoundingClientRect()
let left = rect.right + 14
if (left + PREVIEW_WIDTH > window.innerWidth - 12) {
left = Math.max(12, rect.left - PREVIEW_WIDTH - 14)
}
let top = rect.top + rect.height / 2 - PREVIEW_HEIGHT / 2
top = Math.max(12, Math.min(top, window.innerHeight - PREVIEW_HEIGHT - 12))
setPosition({ left, top })
setOpen(true)
}
return (
<>
<span
ref={anchorRef}
className={`sku-thumb${src ? '' : ' is-empty'}${className ? ' ' + className : ''}`}
style={{ width: size, height: size }}
onMouseEnter={preview && src ? show : undefined}
onMouseLeave={preview ? () => setOpen(false) : undefined}
title={sku ? `货号 ${sku}` : undefined}
>
{src
? <img src={src} alt="" loading="lazy" />
: <ImageIcon size={Math.max(13, Math.round(size * 0.42))} />}
</span>
{open && position && createPortal(
<span className="sku-thumb-preview" style={{ left: position.left, top: position.top }}>
<img src={src} alt="" />
{sku ? <b>{sku}</b> : null}
</span>,
document.body
)}
</>
)
}
/**
* 批量取货号缩略图。
* 只关心「货号 → 文件名」这一层映射,一次请求拿一批,避免每个货号打一次接口。
*/
export function useSkuThumbs(skus) {
const [map, setMap] = useState({})
const key = useMemo(
() => [...new Set((skus || []).filter(Boolean).map(item => String(item)))].sort().join(','),
[skus]
)
useEffect(() => {
if (!key) { setMap({}); return }
let alive = true
axios.get('/api/sku/thumbs', { params: { skus: key } })
.then(({ data }) => { if (alive) setMap(data?.thumbs || {}) })
.catch(() => { if (alive) setMap({}) })
return () => { alive = false }
}, [key])
return map
}
export default SkuThumb
+98
View File
@@ -0,0 +1,98 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDownIcon, ClockIcon } from './Icons'
import {
TIMEZONES, TIMEZONE_STORAGE_KEY, findTimeZone, formatDateIn, formatOffsetIn, formatTimeIn, readStoredTimeZone
} from '../timezones'
/**
* 顶栏时钟:显示所选时区的时间,点一下可以换时区。
*
* 时间由父组件每秒 tick 后以 now 传入 —— 顶栏本来就在跑秒表,
* 这里不再自己起一个 interval,避免同一屏里两个定时器各跳各的。
*/
function TimeZoneClock({ now }) {
const [zoneKey, setZoneKey] = useState(readStoredTimeZone)
const [open, setOpen] = useState(false)
const boxRef = useRef(null)
const listRef = useRef(null)
const zone = findTimeZone(zoneKey)
useEffect(() => {
try { localStorage.setItem(TIMEZONE_STORAGE_KEY, zoneKey) } catch { /* 忽略 */ }
}, [zoneKey])
/* 列表比可视区高,打开时滚到当前选中的那一条,否则要自己翻半天找 */
useEffect(() => {
if (!open) return
const active = listRef.current?.querySelector('.clock-zone-item.active')
active?.scrollIntoView({ block: 'nearest' })
}, [open])
useEffect(() => {
if (!open) return
const onPointerDown = event => {
if (!boxRef.current?.contains(event.target)) setOpen(false)
}
const onKeyDown = event => { if (event.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onPointerDown)
window.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
window.removeEventListener('keydown', onKeyDown)
}
}, [open])
const time = formatTimeIn(now, zone.key)
const date = formatDateIn(now, zone.key)
return (
<div className="header-clock" ref={boxRef}>
<button
type="button"
className={`clock-trigger${open ? ' is-open' : ''}`}
onClick={() => setOpen(v => !v)}
title={`当前时区:${zone.zone}:${zone.cities} · 点击切换`}
aria-haspopup="listbox"
aria-expanded={open}
>
<ClockIcon size={17} />
<span className="clock-zone">{zone.zone}:{zone.cities}</span>
<ChevronDownIcon size={13} className="clock-caret" />
<span className="clock-readout">
<b className="clock-time">{time}</b>
<em className="clock-date">{date}</em>
</span>
</button>
{open && (
<div className="clock-pop" role="listbox" aria-label="选择时区">
<div className="clock-pop-head">客户所在地时间</div>
<div className="clock-pop-list" ref={listRef}>
{TIMEZONES.map(item => {
const active = item.key === zoneKey
return (
<button
key={item.key}
type="button"
role="option"
aria-selected={active}
className={`clock-zone-item${active ? ' active' : ''}`}
onClick={() => { setZoneKey(item.key); setOpen(false) }}
>
<span className="clock-zone-name">{item.zone}:{item.cities}</span>
<span className="clock-zone-right">
<b>{formatTimeIn(now, item.key)}</b>
<em>{formatOffsetIn(now, item.key)}</em>
</span>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
export default TimeZoneClock
+143
View File
@@ -0,0 +1,143 @@
/**
* 币种清单(顶栏汇率计算器用)
*
* 只是「展示用」的名字与排序。真正有哪些币种、汇率多少,全部以
* 后端 /api/fx/rates 返回的 rates 为准 —— 接口给了 160+ 币种,
* 这份清单里没列到的也会照常出现在下拉里(只显示代码)。
*
* ORDER 决定下拉里的先后(外贸常用币种在前),未列出的按字母序排在后面。
*/
export const CURRENCY_NAMES = {
USD: '美元', CNY: '人民币', EUR: '欧元', GBP: '英镑', HKD: '港币', JPY: '日元',
AUD: '澳元', CAD: '加元', SGD: '新加坡元', CHF: '瑞士法郎', NZD: '新西兰元', KRW: '韩元',
AED: '阿联酋迪拉姆', SAR: '沙特里亚尔', RUB: '卢布', INR: '印度卢比', BRL: '巴西雷亚尔',
MXN: '墨西哥比索', THB: '泰铢', VND: '越南盾', MYR: '马来西亚林吉特', IDR: '印尼盾',
PHP: '菲律宾比索', TRY: '土耳其里拉', ZAR: '南非兰特', PLN: '波兰兹罗提',
SEK: '瑞典克朗', NOK: '挪威克朗', DKK: '丹麦克朗', EGP: '埃及镑', NGN: '尼日利亚奈拉',
PKR: '巴基斯坦卢比', BDT: '孟加拉塔卡', KES: '肯尼亚先令', KWD: '科威特第纳尔',
QAR: '卡塔尔里亚尔', OMR: '阿曼里亚尔', BHD: '巴林第纳尔', ILS: '以色列谢克尔',
TWD: '新台币', CLP: '智利比索', COP: '哥伦比亚比索', PEN: '秘鲁索尔', ARS: '阿根廷比索',
UAH: '乌克兰格里夫纳', CZK: '捷克克朗', HUF: '匈牙利福林', RON: '罗马尼亚列伊',
LKR: '斯里兰卡卢比', NPR: '尼泊尔卢比', MMK: '缅甸元', KHR: '柬埔寨瑞尔',
TZS: '坦桑尼亚先令', MAD: '摩洛哥迪拉姆', GHS: '加纳塞地', ETB: '埃塞俄比亚比尔',
UGX: '乌干达先令', ZMW: '赞比亚克瓦查', DZD: '阿尔及利亚第纳尔', TND: '突尼斯第纳尔',
JOD: '约旦第纳尔', IQD: '伊拉克第纳尔', LBP: '黎巴嫩镑', AZN: '阿塞拜疆马纳特',
KZT: '哈萨克斯坦坚戈', UZS: '乌兹别克斯坦苏姆', GEL: '格鲁吉亚拉里', AMD: '亚美尼亚德拉姆',
MNT: '蒙古图格里克', KGS: '吉尔吉斯斯坦索姆', BND: '文莱元', MOP: '澳门元',
ISK: '冰岛克朗', UYU: '乌拉圭比索', BOB: '玻利维亚诺', PYG: '巴拉圭瓜拉尼',
CRC: '哥斯达黎加科朗', GTQ: '危地马拉格查尔', DOP: '多米尼加比索', JMD: '牙买加元',
TTD: '特立尼达和多巴哥元', RSD: '塞尔维亚第纳尔', BGN: '保加利亚列弗',
HRK: '克罗地亚库纳', ALL: '阿尔巴尼亚列克', MKD: '北马其顿第纳尔'
}
/**
* 汇率计算器用的「主流币种」白名单,按 2024 年中国对其出口金额从高到低排。
*
* 排序依据:联合国 COMTRADE / Trading Economics 2024 年中国出口目的地金额。
* 美国 5257 亿 > 中国香港 2910 亿 > 日本 1520 亿 > 俄罗斯 1153 亿 >
* 马来西亚 1015 亿 > 新加坡 791 亿 > 中国台湾 753 亿 > 澳大利亚 708 亿 >
* 阿联酋 656 亿 > 沙特 500 亿 > 加拿大 465 亿。
* 欧元按欧元区合计(德 1071 亿 + 荷 912 亿 + 意 462 亿 + 法 450 亿 + 西 409 亿 …)
* 计入,量级仅次于美国,所以排在美国之后。人民币是本币,按需求紧随美元。
*/
export const PRIMARY_CURRENCIES = [
'USD', 'CNY', 'EUR', 'HKD', 'JPY', 'RUB', 'MYR', 'SGD', 'TWD', 'AUD', 'AED', 'SAR', 'CAD'
]
/** 币种对应的国家 / 地区(中国香港、中国台湾按中国区划标注)。 */
export const CURRENCY_REGIONS = {
USD: '美国', CNY: '中国', EUR: '欧元区', HKD: '中国香港', JPY: '日本',
RUB: '俄罗斯', MYR: '马来西亚', SGD: '新加坡', TWD: '中国台湾', AUD: '澳大利亚',
AED: '阿联酋', SAR: '沙特阿拉伯', CAD: '加拿大'
}
/**
* 汇率计算器的币种下拉:只给主流 13 种,顺序完全由 PRIMARY_CURRENCIES 决定。
* 不用看接口返回的 160+ 币种 —— 这是「常用币种快捷入口」,不是全量币种表。
*/
export function buildPrimaryCurrencyOptions() {
return PRIMARY_CURRENCIES.map(code => ({
code,
name: CURRENCY_NAMES[code] || '',
region: CURRENCY_REGIONS[code] || ''
}))
}
/** 下拉排序权重:外贸结算最常见的排前面。 */
const ORDER = [
'USD', 'CNY', 'EUR', 'GBP', 'HKD', 'JPY', 'AUD', 'CAD', 'SGD', 'CHF', 'NZD', 'KRW',
'AED', 'SAR', 'RUB', 'INR', 'BRL', 'MXN', 'THB', 'VND', 'MYR', 'IDR', 'PHP', 'TRY',
'ZAR', 'PLN', 'SEK', 'NOK', 'DKK', 'TWD', 'EGP', 'NGN', 'PKR', 'BDT', 'KES', 'KWD',
'QAR', 'OMR', 'BHD', 'ILS', 'CLP', 'COP', 'PEN', 'ARS', 'UAH', 'CZK', 'HUF', 'RON'
]
const RANK = new Map(ORDER.map((code, index) => [code, index]))
/**
* 把接口返回的 rates 变成下拉选项,按常用度排序。
* @param {Record<string, number>} rates 以 USD 为基准的汇率表
* @param {string[]} extra 兜底币种(接口没回来时也要有 USD/CNY 可选)
*/
export function buildCurrencyOptions(rates, extra = ['USD', 'CNY']) {
const codes = Object.keys(rates || {})
const merged = Array.from(new Set([...extra, ...codes]))
return merged
.map(code => ({ code, name: CURRENCY_NAMES[code] || '' }))
.sort((a, b) => {
const ra = RANK.has(a.code) ? RANK.get(a.code) : 999
const rb = RANK.has(b.code) ? RANK.get(b.code) : 999
if (ra !== rb) return ra - rb
return a.code.localeCompare(b.code)
})
}
/** "USD 美元" / 没中文名时只显示代码 */
export function currencyLabel(code) {
const name = CURRENCY_NAMES[code]
return name ? `${code} ${name}` : code
}
/**
* 交叉汇率:1 from = ? to
* rates 以 USD 为基准,所以 rates[to] / rates[from] 就是任意两币之间的汇率。
*/
export function crossRate(rates, from, to) {
if (!rates) return null
const rf = Number(rates[from])
const rt = Number(rates[to])
if (!Number.isFinite(rf) || !Number.isFinite(rt) || rf <= 0) return null
return rt / rf
}
/**
* 金额显示:汇率结果的有效位数差别很大
* (1 CNY ≈ 0.00004 USD,1 USD ≈ 25000 VND),
* 固定两位小数会让小额变 0、大额全是没意义的零,所以按数量级取精度。
*/
export function formatAmount(value) {
if (!Number.isFinite(value)) return ''
const abs = Math.abs(value)
let digits
if (abs === 0) digits = 2
else if (abs >= 1000) digits = 2
else if (abs >= 1) digits = 4
else if (abs >= 0.001) digits = 6
else digits = 8
return value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: digits })
}
/** 汇率本身用 6 位有效数字展示:0.000131 这种小汇率也能看清。 */
export function formatRate(value) {
if (!Number.isFinite(value)) return ''
if (value >= 1000) return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
if (value >= 1) return value.toLocaleString('zh-CN', { maximumFractionDigits: 4 })
return value.toPrecision(4)
}
/** 把输入框里的字符串转成数字;空串、半截输入("1.")都返回 null 而不是崩掉。 */
export function parseAmount(text) {
const cleaned = String(text ?? '').replace(/[,\s]/g, '')
if (!cleaned) return null
const num = Number(cleaned)
return Number.isFinite(num) ? num : null
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useRef } from 'react'
/**
* 把「浮层 / 编辑态」接入浏览器历史。
*
* 解决什么问题:Chrome / Edge 的**鼠标侧键(后退键)、Alt + ←、触控板双指横滑、
* 系统的返回手势**走的都是浏览器历史(history.back),不是页面里的按钮。
* 所以只要把浮层状态压进历史,这些操作就天然能"退回上一层",
* 不需要去单独做鼠标按键识别(浏览器也不会把侧键事件交给页面)。
*
* 两个关键设计(改之前务必读懂,否则很容易踩出"按几次返回直接退出应用"):
*
* 1. **关闭动作一律走历史,状态只由 popstate 回写。**
* 不写"点了 X 就 setState(null)",而是"点了 X 就 history.go(-n)",
* 由 popstate 统一把状态改成已关闭。这样不需要在内存里维护"层数栈",
* React 18 StrictMode 的双挂载、快速连点都不会让内存栈和真实历史错位。
*
* 2. **层数用「随组件存活的活计数」,不读历史里的计数器。**
* 历史里可能留着上一次挂载的孤儿条目:用户开着抽屉直接点侧栏跳到别的页面,
* 抽屉会随组件卸载而消失,但压进去的历史条目还在。如果层数从历史里读,
* 下次回到本页就会读到 2 这种脏值,关抽屉时 go(-2) 会多退几条、直接退出应用。
* 所以层数由页面自己按当前 UI 状态算(见 ProductManagement 的 layerCount)。
*
* 配套地,每条历史条目都带上**本次挂载的令牌**(mountId)。
* 读到令牌对不上的条目(上一次挂载留下的孤儿)一律当作"本层已关闭",
* 这样不会冒出幽灵抽屉。
*/
/** 本次挂载的令牌。每次挂载生成一个新的,用来把上一次挂载的孤儿条目认出来 */
export function makeMountId() {
return `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
}
/** 当前历史条目是不是本次挂载压进去的层 */
function isOwnLayer(mountId) {
const s = window.history.state
return !!(s && s.__tmsMount === mountId && typeof s.__tmsDepth === 'number' && s.__tmsDepth > 0)
}
/**
* 打开一层:压一条历史,把「返回后该恢复成什么」存进这条历史本身。
* @param {string} mountId 本次挂载的令牌
* @param {string} key 本层的键名(读回来时用它取值,各层用各自的键就不会串)
* @param {object} snapshot 本层快照,`null` 表示"已关闭"
*/
export function pushLayer(mountId, key, snapshot) {
const cur = window.history.state
const depth = cur && cur.__tmsMount === mountId && typeof cur.__tmsDepth === 'number'
? cur.__tmsDepth
: 0
window.history.pushState({ __tmsMount: mountId, __tmsDepth: depth + 1, [key]: snapshot }, '')
}
/**
* 只退一层 —— 给「返回上一层」按钮用(与鼠标侧键效果完全一致)。
* @returns {boolean} 是否真的走了历史;false 表示当前没有本页的层,调用方自己兜底收起
*/
export function goBackOne(mountId) {
if (!isOwnLayer(mountId)) return false
window.history.back()
return true
}
/**
* 一次退掉 n 层,回到这些层之前那条历史。
* go(-n) 只会触发**一次** popstate(带着最终条目的 state),所以中间那层不会各自响应。
*/
export function popLayers(n) {
if (n > 0) window.history.go(-n)
}
/**
* 监听浏览器返回,把本层状态回写回来。
*
* @param {string} mountId 本次挂载的令牌;读到的条目令牌对不上就当作本层已关闭
* @param {string} key 本层的键名;历史条目里读不到这个键 = 本层已关闭
* @param {function} onState 收到快照(或 null)后恢复 UI 状态
* @param {string} [guardPath] 只在当前路径等于它时才响应。
* 用户开着抽屉直接点侧栏跳到别的页面时,历史里还留着我们的条目;
* 这时往回退不应该在产品页之外乱动抽屉。
*/
export function useLayerRestore(mountId, key, onState, guardPath) {
const onStateRef = useRef(onState)
onStateRef.current = onState
const guardRef = useRef(guardPath ?? window.location.pathname)
if (guardPath) guardRef.current = guardPath
useEffect(() => {
const onPop = () => {
// 已经离开本页:交给路由自己处理,别去动本页的浮层状态
if (window.location.pathname !== guardRef.current) return
const s = window.history.state
const alive = s && s.__tmsMount === mountId && typeof s.__tmsDepth === 'number'
onStateRef.current(alive ? (s[key] ?? null) : null)
}
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [mountId, key])
}
+1
View File
@@ -4,6 +4,7 @@ import App from './App'
import './styles/industrial.css'
import './styles/index.css'
import './styles/luxury.css'
import './styles/stage1.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
+100
View File
@@ -0,0 +1,100 @@
import {
FactoryIcon,
PackageIcon,
UsersIcon,
TargetIcon,
ClipboardIcon,
QuoteIcon,
ContractIcon,
PurchaseIcon,
InquiryIcon,
CleanupIcon,
GlobeIcon,
ShieldIcon,
ActivityIcon
} from './components/Icons'
/* 工作台独立置顶,不属于任何分组 */
export const HOME_PATH = '/'
export const HOME_LABEL = '工作台'
/**
* 侧边栏分组配置。
*
* 顺序即业务发生顺序:询盘 → 报价 → PI,订单作为业务闭环的收口放在业务管理最后;
* 采购单连同品类 / 供应商 / 合同导入统一归到「采购管理」组(2026-09-15 Wayne 调整)。
* 每项带 permission:侧栏按当前用户权限组过滤,路由层做同样的守卫,两处同源。
*/
export const NAV_GROUPS = [
{
id: 'business',
title: '业务管理',
items: [
{ to: '/inquiries', label: '询盘管理', icon: InquiryIcon, permission: 'inquiry.view' },
{ to: '/quotes', label: '客户报价单管理', icon: QuoteIcon, permission: 'quote.view' },
{ to: '/pi-contracts', label: 'PI合同管理', icon: ContractIcon, permission: 'contract.view' },
{ to: '/orders', label: '订单管理', icon: ClipboardIcon, permission: 'order.view' }
]
},
{
id: 'customer',
title: '客户管理',
items: [
{ to: '/customers', label: '我的客户', icon: UsersIcon, permission: 'customer.view' },
{ to: '/prospects', label: '新客开发', icon: TargetIcon, permission: 'prospect.view' },
{ to: '/customer-pool', label: '客户公海', icon: GlobeIcon, permission: 'pool.view' }
]
},
{
id: 'procurement',
title: '采购管理',
items: [
/* 采购单是采购侧的主单据,放在本组最前;下面三项是支撑它的品类 / 供应商 / 合同导入。
2026-09-15 从「业务管理」组挪过来(Wayne:采购单管理放到采购管理目录下)。 */
{ to: '/purchase-orders', label: '采购单管理', icon: PurchaseIcon, permission: 'purchase.view' },
{ to: '/products', label: '产品管理', icon: PackageIcon, permission: 'product.view' },
{ to: '/suppliers', label: '供应商管理', icon: FactoryIcon, permission: 'supplier.view' },
/* 合同清洗中心 = 合同导入与复核队列(上传 → 解析 → 待复核 → 通过/驳回) */
{ to: '/contract-cleanup', label: '合同清洗中心', icon: CleanupIcon, permission: 'cleanup.view' }
]
},
{
id: 'system',
title: '系统管理',
items: [
{ to: '/users', label: '用户管理', icon: UsersIcon, permission: 'user.manage' },
{ to: '/permission-groups', label: '权限组管理', icon: ShieldIcon, permission: 'perm.manage' },
/* 审计轨迹。Wayne 口径:只有系统管理员能看,故认 audit.view(该点只授予超级管理员组)。 */
{ to: '/logs', label: '系统日志', icon: ActivityIcon, permission: 'audit.view' }
]
}
]
/* 路由 → 页面名(顶栏「已打开页面」标签用),由分组自动派生,避免两处维护 */
export const PAGE_LABELS = {
[HOME_PATH]: HOME_LABEL,
...NAV_GROUPS.reduce((acc, group) => {
group.items.forEach(item => { acc[item.to] = item.label })
return acc
}, {}),
'/profile': '个人资料'
}
/* 路由 → 所需权限(路由守卫用)。个人资料页人人可进,故不在表内。 */
export const ROUTE_PERMISSIONS = {
'/inquiries': 'inquiry.view',
'/quotes': 'quote.view',
'/quotes/new': 'quote.edit',
'/pi-contracts': 'contract.view',
'/purchase-orders': 'purchase.view',
'/orders': 'order.view',
'/customers': 'customer.view',
'/prospects': 'prospect.view',
'/customer-pool': 'pool.view',
'/products': 'product.view',
'/suppliers': 'supplier.view',
'/contract-cleanup': 'cleanup.view',
'/users': 'user.manage',
'/permission-groups': 'perm.manage',
'/logs': 'audit.view'
}
+747
View File
@@ -0,0 +1,747 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import {
CleanupIcon, DownloadIcon, RefreshIcon, CheckIcon, CloseIcon, PencilIcon, SparkIcon, ContractIcon
} from '../components/Icons'
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
const JOB_STATUS = {
queued: { label: '排队中', tone: 'mute' },
parsing: { label: '解析中', tone: 'warn' },
pending_review: { label: '待复核', tone: 'warn' },
reviewed: { label: '已复核', tone: 'ok' },
parse_failed: { label: '解析失败', tone: 'danger' }
}
const ITEM_STATUS = {
pending_review: { label: '待复核', tone: 'warn' },
approved: { label: '已通过', tone: 'ok' },
rejected: { label: '已驳回', tone: 'danger' }
}
/** 一条明细的状态显示:入库过的优先显示「已入库」——它比 approved 更具体 */
function itemStatusMeta(item) {
if (item.committed_at) return { label: '已入库', tone: 'ok' }
return ITEM_STATUS[item.status] || { label: item.status, tone: 'mute' }
}
function formatMoment(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function fmtSize(bytes) {
if (!Number.isFinite(bytes)) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
/** 解析结果表头认了哪几列 —— 规则引擎唯一能自证的现场,认错了就看这里 */
function ColumnMapPanel({ raw }) {
const map = useMemo(() => {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
const entries = Object.entries(parsed || {})
return entries.length ? entries : null
} catch {
return null
}
}, [raw])
if (!map) {
return <p className="muted-note">没有认出任一列 —— 可以用「AI 解析」或逐条手工补录。</p>
}
return (
<div className="col-map">
{map.map(([field, hit]) => (
<span className="col-map-item" key={field}>
<b>{field}</b>
<span className="col-map-arrow">←</span>
<span className="col-map-head">{hit.header}</span>
<em>{hit.score} 分</em>
</span>
))}
</div>
)
}
/** 单行复核编辑器:点「修正」后就地变成可编辑,避免为了改一个价格再开一层弹窗 */
function ReviewRow({ item, selected, onToggle, onReviewed, onCommitted, thumbs }) {
const [editing, setEditing] = useState(false)
const [form, setForm] = useState({
sku: item.sku || '',
product_name: item.product_name || '',
spec: item.spec || '',
purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price),
currency: item.currency || '',
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
moq: item.moq === null || item.moq === undefined ? '' : String(item.moq),
review_note: item.review_note || ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setForm({
sku: item.sku || '',
product_name: item.product_name || '',
spec: item.spec || '',
purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price),
currency: item.currency || '',
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
moq: item.moq === null || item.moq === undefined ? '' : String(item.moq),
review_note: item.review_note || ''
})
setEditing(false)
setError('')
}, [item])
const payload = status => ({
status,
review_note: form.review_note.trim() || null,
sku: form.sku.trim() || null,
product_name: form.product_name.trim() || null,
spec: form.spec.trim() || null,
purchase_price: form.purchase_price === '' ? null : Number(form.purchase_price),
currency: form.currency.trim() || null,
quantity: form.quantity === '' ? null : Number(form.quantity),
moq: form.moq === '' ? null : Number(form.moq)
})
/** 只改状态(驳回 / 放回待复核) */
const send = async (status) => {
setSaving(true)
setError('')
try {
await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload(status))
setEditing(false)
onReviewed()
} catch (reviewError) {
setError(reviewError?.response?.data?.error || '操作失败,请稍后重试')
} finally {
setSaving(false)
}
}
/** 保存修正并直接入库:先把改过的字段存回队列,再走入库接口 */
const saveAndCommit = async () => {
setSaving(true)
setError('')
try {
await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload('pending_review'))
await axios.post('/api/contracts/import-items/commit', { ids: [item.id] })
setEditing(false)
onCommitted()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setSaving(false)
}
}
/** 不改任何字段,直接把这一条入库 */
const commitOne = async () => {
setSaving(true)
setError('')
try {
await axios.post('/api/contracts/import-items/commit', { ids: [item.id] })
onCommitted()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setSaving(false)
}
}
const statusMeta = itemStatusMeta(item)
const committed = Boolean(item.committed_at)
const renamed = committed && item.product_sku && item.sku && item.product_sku !== item.sku
if (editing) {
return (
<tr className="import-row is-editing">
<td />
<td className="mono">{item.filename}{item.row_no ? `#${item.row_no}` : ''}</td>
<td>
<input className="cell-input" type="text" value={form.sku} placeholder="货号"
onChange={event => setForm({ ...form, sku: event.target.value })} />
</td>
<td>
<input className="cell-input" type="text" value={form.product_name} placeholder="产品名称"
onChange={event => setForm({ ...form, product_name: event.target.value })} />
</td>
<td>
<input className="cell-input" type="text" value={form.spec} placeholder="规格"
onChange={event => setForm({ ...form, spec: event.target.value })} />
</td>
<td>
<input className="cell-input" type="number" step="0.01" value={form.purchase_price} placeholder="采购价"
onChange={event => setForm({ ...form, purchase_price: event.target.value })} />
</td>
<td>
<input className="cell-input" type="text" value={form.currency} placeholder="币种"
onChange={event => setForm({ ...form, currency: event.target.value })} />
</td>
<td>
<input className="cell-input" type="number" step="1" value={form.quantity} placeholder="数量"
onChange={event => setForm({ ...form, quantity: event.target.value })} />
</td>
<td>
<input className="cell-input" type="number" step="1" value={form.moq} placeholder="起订量"
onChange={event => setForm({ ...form, moq: event.target.value })} />
</td>
<td colSpan={2}>
<input className="cell-input" type="text" value={form.review_note} placeholder="复核备注"
onChange={event => setForm({ ...form, review_note: event.target.value })} />
{error && <p className="form-error">{error}</p>}
</td>
<td className="col-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setEditing(false)} disabled={saving}>取消</button>
<button type="button" className="btn btn-primary btn-sm" onClick={saveAndCommit} disabled={saving}>
{saving ? '入库中…' : '保存并入库'}
</button>
</td>
</tr>
)
}
return (
<tr className={`import-row${item.status === 'rejected' ? ' is-rejected' : ''}${selected ? ' is-picked' : ''}`}>
<td className="col-pick">
<input
className="pick-box" type="checkbox" checked={selected}
onChange={() => onToggle(item.id)}
aria-label={`选择 ${item.sku || item.filename + '#' + item.row_no}`}
/>
</td>
<td className="mono">
<span className="cell-file" title={`${item.filename}${item.row_no ? ` #${item.row_no}` : ''}`}>
{item.filename}{item.row_no ? `#${item.row_no}` : ''}
</span>
</td>
<td className="mono bold">
{item.sku ? (
<span className="sku-cell">
<SkuThumb sku={item.sku} filename={thumbs[item.sku]} size={30} />
{item.sku}
{renamed && <em className="sku-renamed" title="货号已存在,入库时自动落成副本">→ {item.product_sku}</em>}
</span>
) : <span className="muted-note">未识别</span>}
</td>
<td>{item.product_name || <span className="muted-note">未识别</span>}</td>
<td>{item.spec || <span className="muted-note">—</span>}</td>
<td className="num">
{item.purchase_price === null || item.purchase_price === undefined
? <span className="muted-note">—</span>
: item.purchase_price}
</td>
<td>{item.currency || <span className="muted-note">—</span>}</td>
<td className="num">{item.quantity === null || item.quantity === undefined ? '—' : item.quantity}</td>
<td className="num">{item.moq === null || item.moq === undefined ? '—' : item.moq}</td>
<td>
<span className={`pill pill-${statusMeta.tone}`}>{statusMeta.label}</span>
{item.parse_source === 'ai' && <span className="pill pill-mute src-pill">AI</span>}
</td>
<td className="import-note">{item.review_note || <span className="muted-note">—</span>}</td>
<td className="col-actions">
<button type="button" className="icon-btn" title="修正后入库" onClick={() => setEditing(true)}>
<PencilIcon size={14} />
</button>
<button
type="button" className="icon-btn" title="直接入库" disabled={committed || saving}
onClick={commitOne}
>
<CheckIcon size={14} />
</button>
<button
type="button" className="icon-btn" title="驳回" disabled={item.status === 'rejected' || saving}
onClick={() => send('rejected')}
>
<CloseIcon size={14} />
</button>
</td>
</tr>
)
}
function ContractCleanup() {
const [jobs, setJobs] = useState([])
const [activeJobId, setActiveJobId] = useState(null)
const [jobDetail, setJobDetail] = useState(null)
const [loadingJobs, setLoadingJobs] = useState(true)
const [loadingDetail, setLoadingDetail] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [dragging, setDragging] = useState(false)
const [statusFilter, setStatusFilter] = useState('')
const [selected, setSelected] = useState(() => new Set())
const [committing, setCommitting] = useState(false)
const [aiRunning, setAiRunning] = useState(false)
const [commitReport, setCommitReport] = useState(null)
const [originals, setOriginals] = useState(null)
const [showAll, setShowAll] = useState(false)
const fileInputRef = useRef(null)
const headPickRef = useRef(null)
const PAGE_LIMIT = 300
const loadJobs = useCallback(async (preferId) => {
setLoadingJobs(true)
try {
const { data } = await axios.get('/api/contracts/import-jobs')
const rows = Array.isArray(data) ? data : []
setJobs(rows)
setActiveJobId(current => {
if (preferId) return preferId
if (current && rows.some(row => row.id === current)) return current
return rows[0]?.id ?? null
})
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取导入任务失败')
} finally {
setLoadingJobs(false)
}
}, [])
useEffect(() => { loadJobs() }, [loadJobs])
const loadDetail = useCallback(async (jobId) => {
if (!jobId) { setJobDetail(null); return }
setLoadingDetail(true)
try {
const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}`)
setJobDetail(data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取任务明细失败')
setJobDetail(null)
} finally {
setLoadingDetail(false)
}
}, [])
useEffect(() => {
loadDetail(activeJobId)
setSelected(new Set())
setCommitReport(null)
setStatusFilter('')
setShowAll(false)
}, [activeJobId, loadDetail])
/* 原件只在服务器留 7 天,所以「还存在哪些原件」得单独问一次接口 */
const loadOriginals = useCallback(async (jobId) => {
if (!jobId) { setOriginals(null); return }
try {
const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}/files`)
setOriginals(data)
} catch {
setOriginals(null)
}
}, [])
useEffect(() => { loadOriginals(activeJobId) }, [activeJobId, loadOriginals])
const upload = async (fileList) => {
const files = [...(fileList || [])]
if (!files.length) return
setUploading(true)
setError('')
setNotice('')
try {
const form = new FormData()
files.forEach(file => form.append('files', file))
const { data } = await axios.post('/api/contracts/import', form)
const jobId = data?.job?.id
await loadJobs(jobId)
if (jobId) { await loadDetail(jobId); loadOriginals(jobId) }
setNotice(`${files.length} 个文件已解析。${data?.message || ''}`)
} catch (uploadError) {
setError(uploadError?.response?.data?.error || '上传失败,请稍后重试')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const onDrop = (event) => {
event.preventDefault()
setDragging(false)
upload(event.dataTransfer?.files)
}
const refreshAfterReview = useCallback(async () => {
await loadDetail(activeJobId)
// 复核完最后一条,任务状态会从 pending_review 变 reviewed,列表也要跟着刷新
await loadJobs(activeJobId)
}, [activeJobId, loadDetail, loadJobs])
const refreshAfterCommit = useCallback(async () => {
setSelected(new Set())
await loadDetail(activeJobId)
await loadJobs(activeJobId)
}, [activeJobId, loadDetail, loadJobs])
const items = jobDetail?.items || []
const filteredItems = useMemo(
() => (statusFilter ? items.filter(item => item.status === statusFilter) : items),
[items, statusFilter]
)
const shownItems = useMemo(
() => (showAll ? filteredItems : filteredItems.slice(0, PAGE_LIMIT)),
[filteredItems, showAll]
)
const counts = useMemo(() => {
const result = { total: items.length, pending_review: 0, approved: 0, rejected: 0, committed: 0 }
items.forEach(item => {
result[item.status] = (result[item.status] || 0) + 1
if (item.committed_at) result.committed += 1
})
return result
}, [items])
const thumbs = useSkuThumbs(items.map(item => item.sku).filter(Boolean))
const pickableIds = useMemo(
() => filteredItems.filter(item => !item.committed_at).map(item => item.id),
[filteredItems]
)
const allPicked = pickableIds.length > 0 && pickableIds.every(id => selected.has(id))
const somePicked = pickableIds.some(id => selected.has(id))
useEffect(() => {
if (headPickRef.current) headPickRef.current.indeterminate = somePicked && !allPicked
}, [somePicked, allPicked])
const togglePick = (id) => {
setSelected(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const pickAll = () => setSelected(new Set(pickableIds))
const invertPick = () => setSelected(current => {
const next = new Set()
pickableIds.forEach(id => { if (!current.has(id)) next.add(id) })
return next
})
const clearPick = () => setSelected(new Set())
const commitSelected = async () => {
const ids = [...selected]
if (!ids.length) { setError('请先勾选要入库的明细'); return }
if (!window.confirm(`把勾选的 ${ids.length} 条写进产品库?\n\n已存在的货号会更新采购价并记入调价历史,库里没有的货号会新建。`)) return
setCommitting(true)
setError('')
setNotice('')
setCommitReport(null)
try {
const { data } = await axios.post('/api/contracts/import-items/commit', { ids })
setCommitReport(data)
setNotice(data?.message || '入库完成')
await refreshAfterCommit()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setCommitting(false)
}
}
const runAiParse = async () => {
if (!activeJobId) return
if (!window.confirm('用模型重新解析这个任务?\n\n原来是规则解析出来的「待复核」行会被替换掉(已经复核或已入库的行不受影响)。')) return
setAiRunning(true)
setError('')
setNotice('')
try {
const { data } = await axios.post(`/api/contracts/import-jobs/${activeJobId}/ai-parse`)
setNotice(data?.message || 'AI 解析完成')
await refreshAfterCommit()
} catch (aiError) {
setError(aiError?.response?.data?.error || 'AI 解析失败')
} finally {
setAiRunning(false)
}
}
const downloadOriginal = async (name) => {
try {
const response = await axios.get(`/api/contracts/import-jobs/${activeJobId}/file`, {
params: { name }, responseType: 'blob'
})
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = name
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
} catch (downloadError) {
setError('原件下载失败')
}
}
const activeJob = jobDetail?.job
const jobMeta = activeJob ? (JOB_STATUS[activeJob.status] || { label: activeJob.status, tone: 'mute' }) : null
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">合同清洗中心</h1>
<p className="page-sub">
供应商合同批量导入 → 按字符规则认列(不认行列位置)→ 人工审核入库。
规则认不出来的,可以交给 AI 解析。原件只在服务器保留 7 天后自动清理。
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => { loadJobs(activeJobId); loadDetail(activeJobId); loadOriginals(activeJobId) }}>
<RefreshIcon size={14} /> 刷新
</button>
</div>
</div>
<section
className={`drop-zone${dragging ? ' is-dragging' : ''}${uploading ? ' is-busy' : ''}`}
onDragOver={event => { event.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
<span className="drop-zone-icon"><DownloadIcon size={26} /></span>
<div className="drop-zone-text">
<strong>{uploading ? '正在上传并解析…' : '把供应商合同 / 报价单拖到这里'}</strong>
<span>
支持 xlsx / xls / csv / tsv / txt。按标题里的字符特征认列(中英文与常见缩写都认),
表头不在第一行也能识别;认不准的可以改用 AI 解析或人工补录。
</span>
</div>
<button
type="button" className="btn btn-primary btn-sm"
disabled={uploading} onClick={() => fileInputRef.current?.click()}
>
选择文件
</button>
<input
ref={fileInputRef} type="file" multiple hidden
accept=".csv,.tsv,.txt,.xlsx,.xls,.xlsm"
onChange={event => upload(event.target.files)}
/>
</section>
{error && <p className="notice-inline">{error}</p>}
{notice && <p className="notice-inline notice-ok">{notice}</p>}
{commitReport?.skipped?.length > 0 && (
<div className="notice-inline">
<strong>有 {commitReport.skipped.length} 条没入库:</strong>
<ul className="skip-list">
{commitReport.skipped.slice(0, 8).map(row => (
<li key={row.id}>#{row.id}{row.sku ? `(${row.sku})` : ''}:{row.reason}</li>
))}
{commitReport.skipped.length > 8 && <li>…还有 {commitReport.skipped.length - 8} 条</li>}
</ul>
</div>
)}
<div className="cleanup-layout">
<aside className="cleanup-jobs">
<div className="dashboard-panel-header">
<div>
<h2><CleanupIcon size={17} /> 导入任务</h2>
</div>
</div>
{loadingJobs ? (
<p className="muted-note">读取中…</p>
) : jobs.length === 0 ? (
<p className="muted-note">还没有导入过合同。</p>
) : (
<ul className="job-list">
{jobs.map(job => {
const meta = JOB_STATUS[job.status] || { label: job.status, tone: 'mute' }
return (
<li key={job.id}>
<button
type="button"
className={`job-card${activeJobId === job.id ? ' is-active' : ''}`}
onClick={() => setActiveJobId(job.id)}
>
<span className="job-no mono">{job.job_no}</span>
<span className={`pill pill-${meta.tone}`}>{meta.label}</span>
{job.parse_source === 'ai' && <span className="pill pill-mute src-pill">AI</span>}
<span className="job-meta">
{job.file_count} 个文件 · {formatMoment(job.created_at)}
</span>
</button>
</li>
)
})}
</ul>
)}
</aside>
<section className="cleanup-detail dashboard-panel">
{!activeJob ? (
<div className="empty-state">
<p className="empty-state-title">选一个导入任务</p>
<p className="empty-state-hint">左边点一个任务,这里会列出逐条解析结果供复核入库。</p>
</div>
) : loadingDetail ? (
<p className="muted-note">读取明细中…</p>
) : (
<>
<div className="dashboard-panel-header">
<div>
<h2 className="mono">{activeJob.job_no} <span className={`pill pill-${jobMeta.tone}`}>{jobMeta.label}</span></h2>
<p className="panel-sub">
解析方式 {activeJob.parse_source === 'ai' ? 'AI 模型' : '字符规则'} · 规则版本 {activeJob.rule_version} · 创建 {formatMoment(activeJob.created_at)}
</p>
</div>
<div className="chip-filters">
<button
type="button" className={`chip-filter${statusFilter === '' ? ' is-active' : ''}`}
onClick={() => setStatusFilter('')}
>
全部 <b>{counts.total}</b>
</button>
{Object.entries(ITEM_STATUS).map(([value, meta]) => (
counts[value] ? (
<button
type="button" key={value}
className={`chip-filter${statusFilter === value ? ' is-active' : ''}`}
onClick={() => setStatusFilter(value)}
>
{meta.label} <b>{counts[value]}</b>
</button>
) : null
))}
{counts.committed > 0 && (
<span className="chip-filter is-static">已入库 <b>{counts.committed}</b></span>
)}
</div>
</div>
{activeJob.status === 'parse_failed' && activeJob.notes && (
<p className="notice-inline">上一次解析中断在:{activeJob.notes}。可以修正文件表头后重新导入。</p>
)}
<div className="cleanup-toolbar">
<div className="cleanup-toolbar-left">
<button type="button" className="btn btn-secondary btn-sm" onClick={pickAll} disabled={!pickableIds.length}>
全选 <b>{pickableIds.length}</b>
</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={invertPick} disabled={!pickableIds.length}>反选</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={clearPick} disabled={!selected.size}>清空</button>
<span className="muted-note">已勾选 {selected.size} 条</span>
</div>
<div className="cleanup-toolbar-right">
<button type="button" className="btn btn-secondary btn-sm" onClick={runAiParse} disabled={aiRunning}>
<SparkIcon size={14} /> {aiRunning ? 'AI 解析中…' : 'AI 解析'}
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={commitSelected} disabled={committing || !selected.size}>
<CheckIcon size={14} /> {committing ? '入库中…' : `入库选中(${selected.size})`}
</button>
</div>
</div>
<details className="cleanup-meta" open={!counts.total}>
<summary>解析详情(认了哪几列 / 原件留存)</summary>
<div className="cleanup-meta-body">
<div>
<span className="meta-label">本次认列结果</span>
<ColumnMapPanel raw={activeJob.column_map} />
</div>
<div>
<span className="meta-label">原件留存</span>
{originals?.files?.length ? (
<div className="originals">
{originals.files.map(file => (
<button type="button" key={file.name} className="original-chip"
onClick={() => downloadOriginal(file.name)} title="下载原件核对">
<ContractIcon size={13} /> {file.name}
<em>{fmtSize(file.size)}</em>
</button>
))}
<span className="muted-note">
服务器只保留 {originals.retention_days} 天
{originals.expires_at ? `(${new Date(originals.expires_at).toLocaleDateString('zh-CN')} 起自动清理)` : ''}
</span>
</div>
) : (
<p className="muted-note">
{originals?.expired
? `原件已过保留期(${originals.retention_days} 天)被自动清理 —— 解析结果与已入库的价格不受影响。`
: '没有留存的原件。'}
</p>
)}
</div>
</div>
</details>
{counts.pending_review === 0 && counts.total > 0 && (
<p className="notice-inline notice-ok">
这个任务已经处理完毕{counts.committed ? `,其中 ${counts.committed} 条已入库` : ''}。
</p>
)}
{filteredItems.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">
{items.length ? '当前筛选下没有明细' : '这个任务没有解析出任何行'}
</p>
<p className="empty-state-hint">
{items.length
? '换个状态筛选看看。'
: '规则没认出来。可以点上面的「AI 解析」让模型试一次,或点「修正」手工补录。'}
</p>
</div>
) : (
<>
<div className="table-wrap">
<table className="data-table import-table">
<thead>
<tr>
<th className="col-pick">
<input
ref={headPickRef} className="pick-box" type="checkbox"
checked={allPicked} onChange={() => (allPicked ? clearPick() : pickAll())}
aria-label="全选"
/>
</th>
<th>来源</th><th>货号</th><th>产品名称</th><th>规格</th>
<th className="num">采购价</th><th>币种</th><th className="num">数量</th><th className="num">起订量</th>
<th>状态</th><th>复核备注</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{shownItems.map(item => (
<ReviewRow
key={item.id} item={item} thumbs={thumbs}
selected={selected.has(item.id)} onToggle={togglePick}
onReviewed={refreshAfterReview} onCommitted={refreshAfterCommit}
/>
))}
</tbody>
</table>
</div>
{filteredItems.length > shownItems.length && (
<p className="muted-note table-more">
还有 {filteredItems.length - shownItems.length} 条未显示
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowAll(true)}>全部显示</button>
</p>
)}
</>
)}
</>
)}
</section>
</div>
</div>
)
}
export default ContractCleanup
+933
View File
@@ -0,0 +1,933 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import axios from 'axios'
import {
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon
} from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
const ACTIVITY_TYPES = ['邮件', '电话', '微信', 'WhatsApp', '拜访', '样品', '展会', '其他']
/* 联系人表单的空值模板:新增和「清空重置」共用一份,避免字段改动漏掉某处 */
const EMPTY_CONTACT = {
name: '', name_en: '', position: '', department: '',
email: '', phone: '', whatsapp: '', wechat: '', is_primary: false, notes: ''
}
const STATUS_LABELS = { active: '活跃', inactive: '非活跃', lead: '潜在' }
const INQUIRY_STATUS_LABELS = {
draft: '草稿', quoting: '报价中', quoted: '已报价', won: '已成交', lost: '已流失', closed: '已关闭'
}
const ORDER_STATUS_LABELS = {
pending: '待处理', confirmed: '已确认', producing: '生产中', shipped: '已发货',
completed: '已完成', cancelled: '已取消'
}
function todayKey() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
function money(value, currency) {
const number = Number(value)
if (!Number.isFinite(number)) return '—'
return `${number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${currency ? ` ${currency}` : ''}`
}
function formatMoment(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
/** 下次跟进日期的语义化提示 —— 业务上「逾期」和「今天」要一眼能看出来 */
function dueState(dateKey) {
if (!dateKey) return null
const today = todayKey()
if (dateKey < today) return { tone: 'danger', label: '已逾期' }
if (dateKey === today) return { tone: 'warn', label: '今天' }
return { tone: 'ok', label: '待跟进' }
}
/** 快速记录跟进:默认收起成一个输入框,聚焦或输入时展开完整字段 */
function QuickFollowUp({ customer, contacts, inquiries, onSaved, canEdit }) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
activity_type: ACTIVITY_TYPES[0], content: '', next_action: '',
next_action_date: '', contact_id: '', inquiry_id: ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const submit = async (event) => {
event.preventDefault()
const content = form.content.trim()
if (!content) { setError('写点什么吧,至少留下一句跟进内容'); return }
setSaving(true)
setError('')
try {
await axios.post(`/api/customers/${customer.id}/follow-ups`, {
activity_type: form.activity_type,
content,
next_action: form.next_action.trim() || null,
next_action_date: form.next_action_date || null,
contact_id: form.contact_id ? Number(form.contact_id) : null,
inquiry_id: form.inquiry_id ? Number(form.inquiry_id) : null
})
setForm({
activity_type: ACTIVITY_TYPES[0], content: '', next_action: '',
next_action_date: '', contact_id: '', inquiry_id: ''
})
setOpen(false)
onSaved()
} catch (submitError) {
setError(submitError?.response?.data?.error || '记录失败,请稍后重试')
} finally {
setSaving(false)
}
}
return (
<form className={`quick-follow${open ? ' is-open' : ''}`} onSubmit={submit}>
<div className="quick-follow-head">
<span className="quick-follow-icon"><PlusCircleIcon size={15} /></span>
<input
type="text"
value={form.content}
placeholder="快速记录一条跟进,回车保存"
aria-label="快速记录跟进内容"
onFocus={() => setOpen(true)}
onChange={event => setForm({ ...form, content: event.target.value })}
/>
{!open && canEdit && <button type="submit" className="btn btn-primary btn-sm" disabled={saving}>记录</button>}
</div>
{open && (
<div className="quick-follow-body">
<div className="form-row">
<div className="form-group">
<label>跟进方式</label>
<select
value={form.activity_type}
onChange={event => setForm({ ...form, activity_type: event.target.value })}
>
{ACTIVITY_TYPES.map(type => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="form-group">
<label>对接联系人</label>
<select
value={form.contact_id}
onChange={event => setForm({ ...form, contact_id: event.target.value })}
>
<option value="">不指定</option>
{contacts.map(contact => (
<option key={contact.id} value={contact.id}>
{contact.name}{contact.position ? ` · ${contact.position}` : ''}
</option>
))}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>下一次跟进动作</label>
<input
type="text"
value={form.next_action}
placeholder="例如:电话确认包装要求"
onChange={event => setForm({ ...form, next_action: event.target.value })}
/>
</div>
<div className="form-group">
<label>下一次跟进日期</label>
<input
type="date"
value={form.next_action_date}
onChange={event => setForm({ ...form, next_action_date: event.target.value })}
/>
</div>
</div>
{inquiries.length > 0 && (
<div className="form-group">
<label>关联询盘</label>
<select
value={form.inquiry_id}
onChange={event => setForm({ ...form, inquiry_id: event.target.value })}
>
<option value="">不关联</option>
{inquiries.map(inquiry => (
<option key={inquiry.id} value={inquiry.id}>
{inquiry.inquiry_no} · {inquiry.title || '未命名'}
</option>
))}
</select>
</div>
)}
{error && <p className="form-error">{error}</p>}
<div className="quick-follow-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => { setOpen(false); setError('') }}>
收起
</button>
{canEdit && (
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? '保存中…' : '保存跟进'}
</button>
)}
</div>
</div>
)}
{!open && error && <p className="form-error">{error}</p>}
</form>
)
}
/** 编辑一条跟进记录 —— 字段与快速记录保持一致,避免两套表单口径不一 */
function FollowUpEditModal({ record, contacts, inquiries, onClose, onSaved }) {
const [form, setForm] = useState({
activity_type: record.activity_type || ACTIVITY_TYPES[0],
content: record.content || '',
next_action: record.next_action || '',
next_action_date: record.next_action_date ? String(record.next_action_date).slice(0, 10) : '',
contact_id: record.contact_id || '',
inquiry_id: record.inquiry_id || ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const submit = async event => {
event.preventDefault()
const content = form.content.trim()
if (!content) { setError('跟进内容不能为空'); return }
setSaving(true)
setError('')
try {
await axios.patch(`/api/customers/${record.customer_id}/follow-ups/${record.id}`, {
activity_type: form.activity_type,
content,
next_action: form.next_action.trim() || null,
next_action_date: form.next_action_date || null,
contact_id: form.contact_id ? Number(form.contact_id) : null,
inquiry_id: form.inquiry_id ? Number(form.inquiry_id) : null
})
onSaved()
} catch (err) {
setError(err?.response?.data?.error || '保存失败,请稍后重试')
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>编辑跟进记录</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<form onSubmit={submit}>
<div className="modal-body">
{error && <p className="form-error">{error}</p>}
<div className="form-row">
<div className="form-group">
<label>跟进方式</label>
<select value={form.activity_type} onChange={e => setForm({ ...form, activity_type: e.target.value })}>
{ACTIVITY_TYPES.map(type => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="form-group">
<label>对接联系人</label>
<select value={form.contact_id} onChange={e => setForm({ ...form, contact_id: e.target.value })}>
<option value="">不指定</option>
{contacts.map(contact => (
<option key={contact.id} value={contact.id}>
{contact.name}{contact.position ? ` · ${contact.position}` : ''}
</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label>跟进内容 *</label>
<textarea rows="3" value={form.content} onChange={e => setForm({ ...form, content: e.target.value })} />
</div>
<div className="form-row">
<div className="form-group">
<label>下一次跟进动作</label>
<input
type="text" value={form.next_action}
placeholder="例如:电话确认包装要求"
onChange={e => setForm({ ...form, next_action: e.target.value })}
/>
</div>
<div className="form-group">
<label>下一次跟进日期</label>
<input
type="date" value={form.next_action_date}
onChange={e => setForm({ ...form, next_action_date: e.target.value })}
/>
</div>
</div>
{inquiries.length > 0 && (
<div className="form-group">
<label>关联询盘</label>
<select value={form.inquiry_id} onChange={e => setForm({ ...form, inquiry_id: e.target.value })}>
<option value="">不关联</option>
{inquiries.map(inquiry => (
<option key={inquiry.id} value={inquiry.id}>
{inquiry.inquiry_no} · {inquiry.title || '未命名'}
</option>
))}
</select>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
)
}
/** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */
function CustomerProfileModal({ customer, onClose, onSaved }) {
const [form, setForm] = useState({
company: customer.company || '',
company_en: customer.company_en || '',
country: customer.country || '',
industry: customer.industry || '',
website: customer.website || '',
address: customer.address || '',
email: customer.email || '',
phone: customer.phone || '',
notes: customer.notes || '',
status: customer.status === 'inactive' ? 'inactive' : 'active'
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
const submit = async event => {
event.preventDefault()
if (!form.company.trim()) { setError('公司名称不能为空'); return }
setSaving(true)
setError('')
try {
await axios.patch(`/api/customers/${customer.id}`, { ...form, company: form.company.trim() })
onSaved()
} catch (err) {
setError(err?.response?.data?.error || '保存失败,请稍后重试')
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>编辑客户信息</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<form onSubmit={submit}>
<div className="modal-body">
{error && <p className="form-error">{error}</p>}
<div className="form-row">
<div className="form-group">
<label>公司名称 *</label>
<input type="text" value={form.company} onChange={e => set('company', e.target.value)} />
</div>
<div className="form-group">
<label>英文名称</label>
<input type="text" value={form.company_en} onChange={e => set('company_en', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>国家 / 地区</label>
<input type="text" value={form.country} onChange={e => set('country', e.target.value)} />
</div>
<div className="form-group">
<label>行业</label>
<input type="text" value={form.industry} onChange={e => set('industry', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>网站</label>
<input type="text" value={form.website} onChange={e => set('website', e.target.value)} placeholder="https://" />
</div>
<div className="form-group">
<label>地址</label>
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} placeholder="报价单抬头会用到" />
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input type="email" value={form.email} onChange={e => set('email', e.target.value)} />
</div>
<div className="form-group">
<label>电话</label>
<input type="text" value={form.phone} onChange={e => set('phone', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>客户状态</label>
<select value={form.status} onChange={e => set('status', e.target.value)}>
<option value="active">活跃(可正常跟进)</option>
<option value="inactive">非活跃</option>
</select>
</div>
<div className="form-group">
<label>备注</label>
<textarea rows="2" value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
)
}
function CustomerDetail() {
const { id } = useParams()
const { user } = useAuth()
const canEdit = hasPermission(user, 'customer.edit')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [contactOpen, setContactOpen] = useState(false)
/* 为 null = 新增联系人;有值 = 正在编辑这条联系人 */
const [contactEditing, setContactEditing] = useState(null)
const [contactForm, setContactForm] = useState({ ...EMPTY_CONTACT })
const [contactError, setContactError] = useState('')
const [savingContact, setSavingContact] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
/* 正在编辑的跟进记录;null = 没打开 */
const [followUpEditing, setFollowUpEditing] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const response = await axios.get(`/api/customers/${id}`)
setData(response.data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取客户详情失败')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
/* 打开联系人弹窗:不传参数是新增,传了联系人就是编辑 */
const openContact = (contact = null) => {
setContactEditing(contact)
setContactError('')
setContactForm(contact ? {
name: contact.name || '',
name_en: contact.name_en || '',
position: contact.position || '',
department: contact.department || '',
email: contact.email || '',
phone: contact.phone || '',
whatsapp: contact.whatsapp || '',
wechat: contact.wechat || '',
is_primary: !!contact.is_primary,
notes: contact.notes || ''
} : { ...EMPTY_CONTACT })
setContactOpen(true)
}
const closeContact = () => {
setContactOpen(false)
setContactEditing(null)
setContactError('')
setContactForm({ ...EMPTY_CONTACT })
}
const submitContact = async (event) => {
event.preventDefault()
if (!contactForm.name.trim()) { setContactError('联系人姓名不能为空'); return }
setSavingContact(true)
setContactError('')
try {
const payload = { ...contactForm, name: contactForm.name.trim() }
if (contactEditing) await axios.patch(`/api/customers/${id}/contacts/${contactEditing.id}`, payload)
else await axios.post(`/api/customers/${id}/contacts`, payload)
closeContact()
await load()
} catch (submitError) {
setContactError(submitError?.response?.data?.error || (contactEditing ? '保存联系人失败' : '新增联系人失败'))
} finally {
setSavingContact(false)
}
}
const customer = data?.customer
const followups = data?.followups || []
const contacts = data?.contacts || []
const inquiries = data?.inquiries || []
// 下一次跟进 = 还没过期或被跳过的记录里最近的那一条
const nextFollowUp = useMemo(() => {
const dated = followups.filter(record => record.next_action_date)
if (!dated.length) return null
return [...dated].sort((a, b) => String(a.next_action_date).localeCompare(String(b.next_action_date)))
.find(record => record.next_action_date >= todayKey()) || dated[0]
}, [followups])
if (loading) return <div className="loading">加载客户详情…</div>
if (!customer) {
return (
<div className="page-container">
<p className="notice-inline">{error || '客户不存在'}</p>
<Link className="btn btn-secondary btn-sm" to="/customers">返回客户列表</Link>
</div>
)
}
const nextDue = nextFollowUp ? dueState(String(nextFollowUp.next_action_date).slice(0, 10)) : null
return (
<div className="page-container">
<div className="breadcrumb">
<Link to="/customers">客户管理</Link>
<span>/</span>
<span>{customer.company}</span>
</div>
<div className="page-header">
<div>
<h1 className="page-title">{customer.company}</h1>
<p className="page-sub">
{[customer.country, customer.industry].filter(Boolean).join(' · ') || '未填写国家与行业'}
{' · '}
<span className={`pill pill-${customer.status === 'active' ? 'ok' : 'mute'}`}>
{STATUS_LABELS[customer.status] || customer.status || '未标记'}
</span>
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 刷新
</button>
<Link className="btn btn-primary btn-sm" to="/inquiries">
<InquiryIcon size={15} /> 去询盘报价
</Link>
</div>
</div>
{error && <p className="notice-inline">{error}</p>}
<div className="stat-rail inquiry-rail">
<div className="stat-line">
<span className="stat-line-icon"><ClockIcon size={17} /></span>
<span>最近联系</span>
<strong>{customer.last_contact_at ? formatMoment(customer.last_contact_at).slice(0, 16) : '—'}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><TargetIcon size={17} /></span>
<span>下次跟进</span>
<strong>
{nextFollowUp
? <>{String(nextFollowUp.next_action_date).slice(0, 10)} <span className={`pill pill-${nextDue?.tone || 'mute'}`}>{nextDue?.label}</span></>
: '未安排'}
</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
<span>询盘</span><strong>{inquiries.length}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><QuoteIcon size={17} /></span>
<span>报价单</span>
<strong>
{data.quoteOverview?.count || 0}
{Number(data.quoteOverview?.total_amount) > 0 && ` · ${money(data.quoteOverview.total_amount)}`}
</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><ClipboardIcon size={17} /></span>
<span>订单</span>
<strong>
{data.orderOverview?.count || 0}
{Number(data.orderOverview?.total_amount) > 0 && ` · ${money(data.orderOverview.total_amount)}`}
</strong>
</div>
</div>
<div className="detail-grid">
<div className="detail-main">
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><ClockIcon size={17} /> 跟进时间线</h2>
</div>
<span className="panel-period">{followups.length} 条记录</span>
</div>
<QuickFollowUp
customer={customer}
contacts={contacts}
inquiries={inquiries}
onSaved={load}
canEdit={canEdit}
/>
{followups.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">还没有跟进记录</p>
<p className="empty-state-hint">上面那一行就是快速记录入口,写一句话按回车即可。</p>
</div>
) : (
<ol className="timeline">
{followups.map(record => {
const due = dueState(String(record.next_action_date || '').slice(0, 10))
return (
<li className="timeline-item" key={record.id}>
<span className="timeline-dot" />
<div className="timeline-body">
<div className="timeline-head">
<span className="timeline-type">{record.activity_type}</span>
<span className="timeline-time">{formatMoment(record.created_at)}</span>
{canEdit && (
<button
type="button"
className="timeline-edit"
onClick={() => setFollowUpEditing(record)}
title="编辑这条跟进"
aria-label="编辑这条跟进"
>
<PencilIcon size={13} />
</button>
)}
</div>
<p className="timeline-content">{record.content}</p>
{(record.next_action || record.next_action_date) && (
<div className="timeline-next">
<TargetIcon size={13} />
<span>
下次:{record.next_action || '跟进'}
{record.next_action_date ? ` · ${String(record.next_action_date).slice(0, 10)}` : ''}
</span>
{due && <span className={`pill pill-${due.tone}`}>{due.label}</span>}
</div>
)}
{(record.contact_name || record.inquiry_id) && (
<div className="timeline-meta">
{record.contact_name && <span>对接:{record.contact_name}</span>}
{record.inquiry_id && (
<Link to={`/inquiries/${record.inquiry_id}`}>关联询盘 #{record.inquiry_id}</Link>
)}
</div>
)}
</div>
</li>
)
})}
</ol>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><InquiryIcon size={17} /> 关联询盘</h2>
</div>
<Link className="btn btn-secondary btn-sm" to="/inquiries">全部询盘</Link>
</div>
{inquiries.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">还没有询盘</p>
<p className="empty-state-hint">在「询盘管理」里新建一条,把它挂到当前客户名下。</p>
</div>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>询盘号</th><th>标题</th><th>日期</th><th>币种</th><th>状态</th><th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{inquiries.map(inquiry => (
<tr key={inquiry.id}>
<td className="mono bold">{inquiry.inquiry_no}</td>
<td>{inquiry.title || <span className="muted-note">未命名</span>}</td>
<td className="mono">{inquiry.inquiry_date}</td>
<td>{inquiry.currency || '—'}</td>
<td>{INQUIRY_STATUS_LABELS[inquiry.status] || inquiry.status}</td>
<td className="col-actions">
<Link className="btn-link" to={`/inquiries/${inquiry.id}`}>查看报价</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><QuoteIcon size={17} /> 报价单与订单</h2>
</div>
</div>
<div className="split-lists">
<div>
<h4 className="split-title">客户报价单</h4>
{(data.quoteItems || []).length === 0 ? (
<p className="muted-note">还没有正式报价单。</p>
) : (
<ul className="mini-list">
{data.quoteItems.map(quote => (
<li key={quote.id}>
<span className="mono">{quote.quote_no}</span>
<span className="mini-list-sub">{quote.quote_date}</span>
<b>{money(quote.total_amount, quote.currency)}</b>
</li>
))}
</ul>
)}
</div>
<div>
<h4 className="split-title">订单</h4>
{(data.orderItems || []).length === 0 ? (
<p className="muted-note">还没有成交订单。</p>
) : (
<ul className="mini-list">
{data.orderItems.map(order => (
<li key={order.id}>
<span className="mono">{order.order_no}</span>
<span className="mini-list-sub">
{order.order_date} · {ORDER_STATUS_LABELS[order.status] || order.status}
</span>
<b>{money(order.total_amount, order.currency)}</b>
</li>
))}
</ul>
)}
</div>
</div>
</section>
</div>
<aside className="detail-side">
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2>基本信息</h2>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setProfileOpen(true)}>
<PencilIcon size={14} /> 编辑
</button>
)}
</div>
<div className="kv-grid kv-grid-single">
<div className="kv"><span className="kv-label">公司名称</span><span className="kv-value">{customer.company}</span></div>
<div className="kv"><span className="kv-label">国家 / 地区</span><span className={`kv-value${customer.country ? '' : ' is-empty'}`}>{customer.country || '未填写'}</span></div>
<div className="kv"><span className="kv-label">行业</span><span className={`kv-value${customer.industry ? '' : ' is-empty'}`}>{customer.industry || '未填写'}</span></div>
<div className="kv"><span className="kv-label">网站</span><span className="kv-value">
{customer.website
? <a href={customer.website} target="_blank" rel="noopener noreferrer">{customer.website}</a>
: <span className="is-empty">未填写</span>}
</span></div>
<div className="kv"><span className="kv-label">邮箱</span><span className={`kv-value${customer.email ? '' : ' is-empty'}`}>{customer.email || '未填写'}</span></div>
<div className="kv"><span className="kv-label">电话</span><span className={`kv-value${customer.phone ? '' : ' is-empty'}`}>{customer.phone || '未填写'}</span></div>
<div className="kv"><span className="kv-label">负责人</span><span className={`kv-value${customer.owner_name ? '' : ' is-empty'}`}>{customer.owner_name || '未指派'}</span></div>
<div className="kv"><span className="kv-label">建档时间</span><span className="kv-value">{formatMoment(customer.created_at).slice(0, 10)}</span></div>
</div>
{customer.notes && <p className="notes-box">{customer.notes}</p>}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><UsersIcon size={17} /> 联系人</h2>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openContact()}>
<PlusCircleIcon size={14} /> 新增
</button>
)}
</div>
{contacts.length === 0 ? (
<p className="muted-note">还没有联系人。报价和跟进都挂在具体的人身上,建议先补一个。</p>
) : (
<ul className="contact-list">
{contacts.map(contact => (
<li className="contact-card" key={contact.id}>
<div className="contact-head">
<span className="contact-avatar">{(contact.name || '?').slice(0, 1)}</span>
<div>
<div className="contact-name">
{contact.name}
{contact.is_primary ? <span className="pill pill-ok">主要</span> : null}
</div>
<div className="contact-role">
{[contact.position, contact.department].filter(Boolean).join(' · ') || '未填写职位'}
</div>
</div>
{canEdit && (
<button
type="button"
className="contact-edit"
onClick={() => openContact(contact)}
title="编辑联系人"
aria-label={`编辑联系人 ${contact.name}`}
>
<PencilIcon size={13} />
</button>
)}
</div>
<div className="contact-lines">
{contact.email && <span>✉ {contact.email}</span>}
{contact.phone && <span>☎ {contact.phone}</span>}
{contact.whatsapp && <span>WA {contact.whatsapp}</span>}
{contact.wechat && <span>微信 {contact.wechat}</span>}
</div>
</li>
))}
</ul>
)}
</section>
</aside>
</div>
{contactOpen && (
<div className="modal-overlay" onClick={closeContact}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>{contactEditing ? '编辑联系人' : '新增联系人'}</h2>
<button type="button" className="icon-btn" onClick={closeContact} aria-label="关闭">×</button>
</div>
<form onSubmit={submitContact}>
<div className="form-row">
<div className="form-group">
<label>姓名 *</label>
<input
type="text" required value={contactForm.name}
onChange={event => setContactForm({ ...contactForm, name: event.target.value })}
/>
</div>
<div className="form-group">
<label>英文名</label>
<input
type="text" value={contactForm.name_en}
onChange={event => setContactForm({ ...contactForm, name_en: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>职位</label>
<input
type="text" value={contactForm.position}
onChange={event => setContactForm({ ...contactForm, position: event.target.value })}
/>
</div>
<div className="form-group">
<label>部门</label>
<input
type="text" value={contactForm.department}
onChange={event => setContactForm({ ...contactForm, department: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input
type="email" value={contactForm.email}
onChange={event => setContactForm({ ...contactForm, email: event.target.value })}
/>
</div>
<div className="form-group">
<label>电话</label>
<input
type="text" value={contactForm.phone}
onChange={event => setContactForm({ ...contactForm, phone: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>WhatsApp</label>
<input
type="text" value={contactForm.whatsapp}
onChange={event => setContactForm({ ...contactForm, whatsapp: event.target.value })}
/>
</div>
<div className="form-group">
<label>微信</label>
<input
type="text" value={contactForm.wechat}
onChange={event => setContactForm({ ...contactForm, wechat: event.target.value })}
/>
</div>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows={2} value={contactForm.notes}
onChange={event => setContactForm({ ...contactForm, notes: event.target.value })}
/>
</div>
<label className="check-inline">
<input
type="checkbox" checked={contactForm.is_primary}
onChange={event => setContactForm({ ...contactForm, is_primary: event.target.checked })}
/>
设为主要联系人
</label>
{contactError && <p className="form-error">{contactError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={closeContact}>取消</button>
<button type="submit" className="btn btn-primary" disabled={savingContact}>
{savingContact ? '保存中…' : (contactEditing ? '保存修改' : '保存')}
</button>
</div>
</form>
</div>
</div>
)}
{profileOpen && (
<CustomerProfileModal
customer={customer}
onClose={() => setProfileOpen(false)}
onSaved={async () => { setProfileOpen(false); await load() }}
/>
)}
{followUpEditing && (
<FollowUpEditModal
record={followUpEditing}
contacts={contacts}
inquiries={inquiries}
onClose={() => setFollowUpEditing(null)}
onSaved={async () => { setFollowUpEditing(null); await load() }}
/>
)}
</div>
)
}
export default CustomerDetail
+217 -50
View File
@@ -1,10 +1,33 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { PlusCircleIcon, RefreshIcon, SearchIcon, GlobeIcon, UserIcon } from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
const STATUS_LABELS = { active: '活跃', inactive: '非活跃', lead: '潜在' }
/**
* 我的客户。
*
* 归属口径(Wayne 2026-09-14):客户有「所属」,谁建的归谁,默认只有归属人看得见;
* 创建人主动丢进公海后同组织可见可领;其余客户只有拿到 customer.view_all 的
* 最高级别管理账号能看到 —— 所以这里默认只查 scope=mine,
* 有权限的账号多一个「全部客户」看板。
*/
function CustomerManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canViewAll = hasPermission(user, 'customer.view_all')
const canEdit = hasPermission(user, 'customer.edit')
const [scope, setScope] = useState('mine')
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [keyword, setKeyword] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const [formData, setFormData] = useState({
company: '',
country: '',
@@ -15,56 +38,152 @@ function CustomerManagement() {
notes: ''
})
useEffect(() => {
fetchCustomers()
}, [])
const fetchCustomers = async () => {
const fetchCustomers = useCallback(async (nextScope = scope) => {
setError('')
setLoading(true)
try {
const response = await axios.get('/api/customers')
setCustomers(response.data)
} catch (error) {
console.error('获取客户列表失败:', error)
const response = await axios.get('/api/customers', { params: { scope: nextScope } })
setCustomers(Array.isArray(response.data) ? response.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.error || '获取客户列表失败')
setCustomers([])
} finally {
setLoading(false)
}
}
}, [scope])
useEffect(() => { fetchCustomers(scope) }, [scope, fetchCustomers])
const filtered = useMemo(() => {
const term = keyword.trim().toLowerCase()
if (!term) return customers
return customers.filter(customer =>
[customer.company, customer.country, customer.industry, customer.email, customer.phone, customer.owner_name]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(term))
)
}, [customers, keyword])
const handleSubmit = async (e) => {
e.preventDefault()
setSaving(true)
setFormError('')
try {
await axios.post('/api/customers', formData)
const response = await axios.post('/api/customers', formData)
setShowAddModal(false)
setFormData({
company: '',
country: '',
industry: '',
website: '',
email: '',
phone: '',
notes: ''
})
fetchCustomers()
} catch (error) {
console.error('创建客户失败:', error)
alert('创建失败: ' + error.message)
setFormData({ company: '', country: '', industry: '', website: '', email: '', phone: '', notes: '' })
const newId = response.data?.id
await fetchCustomers(scope)
// 新建后直接进详情页 —— 下一步九成是记第一条跟进,不该让用户再自己找
if (newId) navigate(`/customers/${newId}`)
} catch (submitError) {
setFormError(submitError?.response?.data?.error || submitError?.message || '创建失败')
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="loading">加载中...</div>
/* 丢入公海:归属人主动放弃,客户变成组织共享资源,同组织同事可见可领。
乐观更新:先把行从列表拿掉(mine 范围)或打上公海标记(all 范围),
后台失败再放回原位 —— 用户点完立刻看到结果,不用等网络往返。 */
const handleToPool = async (customer) => {
const ok = window.confirm(
`把「${customer.company}」丢进客户公海?\n\n` +
'丢入后它会从「我的客户」移到「客户公海」,同组织的同事都能看到并领取。\n' +
'一旦有同事领取,这条客户就不再归你了。'
)
if (!ok) return
setError('')
const index = customers.findIndex(item => item.id === customer.id)
const rollback = () => setCustomers(prev => {
if (prev.some(item => item.id === customer.id)) {
return prev.map(item => (item.id === customer.id ? customer : item))
}
const next = [...prev]
next.splice(Math.min(Math.max(index, 0), next.length), 0, customer)
return next
})
if (scope === 'mine') {
setCustomers(prev => prev.filter(item => item.id !== customer.id))
} else {
setCustomers(prev => prev.map(item => (item.id === customer.id ? { ...item, pool_status: 'public' } : item)))
}
try {
await axios.post(`/api/customers/${customer.id}/to-pool`)
} catch (err) {
rollback()
setError(err?.response?.data?.error || '丢入公海失败,已恢复原状')
}
}
const openAdd = () => {
setFormError('')
setShowAddModal(true)
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">客户管理</h1>
<button className="btn-primary" onClick={() => setShowAddModal(true)}>
+ 新增客户
<div>
<h1 className="page-title">我的客户</h1>
<p className="page-sub">
客户默认只有创建人(归属人)可见。想让团队一起跟,就把客户丢进「客户公海」,
同组织成员都能看到并领取。
</p>
</div>
<div className="header-actions">
<button className="btn btn-secondary btn-sm" onClick={() => fetchCustomers(scope)}>
<RefreshIcon size={14} /> 刷新
</button>
{canEdit && (
<button className="btn btn-primary btn-sm" onClick={openAdd}>
<PlusCircleIcon size={15} /> 新增客户
</button>
)}
</div>
</div>
<div className="table-container">
<div className="filter-bar">
{canViewAll ? (
<div className="scope-tabs" role="tablist" aria-label="客户范围">
<button
type="button"
role="tab"
aria-selected={scope === 'mine'}
className={`scope-tab${scope === 'mine' ? ' is-active' : ''}`}
onClick={() => setScope('mine')}
>
我的客户
</button>
<button
type="button"
role="tab"
aria-selected={scope === 'all'}
className={`scope-tab${scope === 'all' ? ' is-active' : ''}`}
onClick={() => setScope('all')}
>
全部客户
</button>
</div>
) : (
<span className="scope-hint"><UserIcon size={13} /> 仅显示我名下的客户</span>
)}
<span className="toolbar-search">
<SearchIcon size={15} />
<input
type="search"
value={keyword}
placeholder="搜公司 / 国家 / 行业 / 联系方式"
aria-label="搜索客户"
onChange={event => setKeyword(event.target.value)}
/>
</span>
</div>
{error && <p className="notice-inline">{error}</p>}
<div className="table-container table-wrap">
<table className="data-table">
<thead>
<tr>
@@ -73,25 +192,43 @@ function CustomerManagement() {
<th>行业</th>
<th>网站</th>
<th>联系方式</th>
<th>负责人</th>
<th>归属</th>
<th>状态</th>
<th>操作</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{customers.length === 0 ? (
{loading ? (
/* 骨架屏:列宽贴近真实表格,加载完成时整页不会跳一下 */
<SkeletonTableRows rows={6} widths={[2.4, 1, 1.2, 1.6, 1.8, 1, 1, 1.2]} />
) : filtered.length === 0 ? (
<tr>
<td colSpan="8" className="empty-state">暂无客户数据</td>
<td colSpan="8" className="empty-state">
{customers.length
? '没有匹配的客户'
: scope === 'all'
? '系统里还没有客户'
: '你名下还没有客户。点右上角「新增客户」建档,或到「客户公海」领取同事放出的资源。'}
</td>
</tr>
) : (
customers.map(customer => (
<tr key={customer.id}>
filtered.map(customer => {
const isMine = user && Number(customer.owner_id) === Number(user.id)
const inPool = customer.pool_status === 'public'
const canDrop = (isMine || canViewAll) && !inPool && canEdit
return (
<tr key={customer.id} onClick={() => navigate(`/customers/${customer.id}`)}>
<td><strong>{customer.company}</strong></td>
<td>{customer.country || '-'}</td>
<td>{customer.industry || '-'}</td>
<td>
{customer.website ? (
<a href={customer.website} target="_blank" rel="noopener noreferrer">
<a
href={customer.website}
target="_blank"
rel="noopener noreferrer"
onClick={event => event.stopPropagation()}
>
{customer.website}
</a>
) : '-'}
@@ -100,30 +237,54 @@ function CustomerManagement() {
{customer.email && <div>{customer.email}</div>}
{customer.phone && <div>{customer.phone}</div>}
</td>
<td>{customer.owner_name || '-'}</td>
<td>
<span className={`status-badge status-${customer.status}`}>
{customer.status === 'active' ? '活跃' : '非活跃'}
{inPool ? (
<span className="pill pill-mute" title="已丢入公海,同组织成员可见可领">
<GlobeIcon size={11} /> 公海
</span>
) : (
<span className="owner-cell">
<UserIcon size={11} /> {customer.owner_name || '未指定'}
{isMine && <span className="owner-self">我</span>}
</span>
)}
</td>
<td>
<span className={`pill pill-${customer.status === 'active' ? 'ok' : 'mute'}`}>
{STATUS_LABELS[customer.status] || customer.status || '未标记'}
</span>
</td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
<td className="col-actions">
{canDrop && (
<button
className="btn-link"
title="放弃归属,同组织成员可见并可领取"
onClick={event => { event.stopPropagation(); handleToPool(customer) }}
>
丢入公海
</button>
)}
<button
className="btn-link"
onClick={event => { event.stopPropagation(); navigate(`/customers/${customer.id}`) }}
>
查看详情
</button>
</td>
</tr>
))
)
})
)}
</tbody>
</table>
</div>
{/* 新增客户模态框 */}
{showAddModal && (
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>新增客户</h2>
<button className="modal-close" onClick={() => setShowAddModal(false)}>×</button>
<button className="icon-btn" onClick={() => setShowAddModal(false)} aria-label="关闭">×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
@@ -135,6 +296,9 @@ function CustomerManagement() {
onChange={e => setFormData({ ...formData, company: e.target.value })}
/>
</div>
<p className="form-hint">
新建的客户归属你自己:只有你能看到,除非你把它丢进客户公海。
</p>
<div className="form-row">
<div className="form-group">
<label>国家</label>
@@ -188,13 +352,16 @@ function CustomerManagement() {
onChange={e => setFormData({ ...formData, notes: e.target.value })}
/>
</div>
{formError && <p className="form-error">{formError}</p>}
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={() => setShowAddModal(false)}>
<button type="button" className="btn btn-secondary" onClick={() => setShowAddModal(false)}>
取消
</button>
<button type="submit" className="btn-primary">
保存
{canEdit && (
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存并进入详情'}
</button>
)}
</div>
</form>
</div>
+205
View File
@@ -0,0 +1,205 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { GlobeIcon, RefreshIcon, UserIcon, SearchIcon, CheckIcon } from '../components/Icons'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
import { setPageTitle } from '../branding'
function fmtDate(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
/**
* 客户公海 —— 组织(团队)共享的客户资源池。
*
* 数据就是 customers 里 pool_status = 'public' 的那批:
* 创建人主动把客户丢进来,同组织成员可见、可领取,谁先领归谁。
*/
function CustomerPool() {
const navigate = useNavigate()
const { user } = useAuth()
const canClaim = hasPermission(user, 'pool.claim')
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [keyword, setKeyword] = useState('')
useEffect(() => {
setPageTitle('客户公海')
}, [])
const fetchPool = useCallback(async () => {
setLoading(true)
setError('')
try {
const { data } = await axios.get('/api/customers', { params: { scope: 'pool' } })
setRows(Array.isArray(data) ? data : [])
} catch (loadError) {
setError(loadError?.response?.data?.error || '获取公海客户失败')
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchPool() }, [fetchPool])
/* 领取:乐观更新 —— 先把行从公海拿掉,后台失败再放回原位。
409 = 被同事抢先,回滚后顺手刷一次对齐真实状态。 */
const claim = async (customer) => {
setError('')
setNotice('')
const index = rows.findIndex(row => row.id === customer.id)
setRows(prev => prev.filter(row => row.id !== customer.id))
setNotice(`「${customer.company}」已领取,现在归你名下。`)
try {
await axios.post(`/api/customers/${customer.id}/claim`)
} catch (err) {
setRows(prev => {
if (prev.some(row => row.id === customer.id)) return prev
const next = [...prev]
next.splice(Math.min(Math.max(index, 0), next.length), 0, customer)
return next
})
setNotice('')
setError(err?.response?.data?.error || '领取失败,已恢复原状')
if (err?.response?.status === 409) fetchPool()
}
}
const filtered = keyword.trim()
? rows.filter(row =>
[row.company, row.country, row.industry, row.email, row.created_by_name]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(keyword.trim().toLowerCase()))
)
: rows
return (
<div className="page-container pool-page">
<div className="page-header">
<div>
<h1 className="page-title">客户公海</h1>
<p className="page-subtitle">
与组织(团队)共用的客户资源池:同事主动放出来的客户都躺在下面,同组织成员都能看到,谁先领取就归谁。
</p>
</div>
<div className="header-actions">
<button className="btn btn-secondary btn-sm" onClick={fetchPool}>
<RefreshIcon size={14} /> 刷新
</button>
</div>
</div>
<div className="pool-rules">
<div className="pool-rule-card">
<span className="pool-rule-index">01</span>
<b>主动放出</b>
<p>客户默认只有创建人可见。创建人在「我的客户」里点「丢入公海」,这条就进入团队资源池。</p>
</div>
<div className="pool-rule-card">
<span className="pool-rule-index">02</span>
<b>团队可见</b>
<p>公海客户对同组织成员可见,联系方式与历史跟进都能查,方便快速判断值不值得跟。</p>
</div>
<div className="pool-rule-card">
<span className="pool-rule-index">03</span>
<b>领取跟进</b>
<p>点「领取」即归属到自己名下并进入「我的客户」,同组织其他人不会再领到。</p>
</div>
</div>
<div className="panel">
<div className="panel-title">
<GlobeIcon size={15} /> 公海客户
<span className="panel-count">{rows.length}</span>
</div>
<div className="filter-bar">
<span className="toolbar-search">
<SearchIcon size={15} />
<input
type="search"
value={keyword}
placeholder="搜公司 / 国家 / 行业 / 联系方式"
aria-label="搜索公海客户"
onChange={event => setKeyword(event.target.value)}
/>
</span>
</div>
{notice && <p className="notice-inline notice-ok"><CheckIcon size={13} /> {notice}</p>}
{error && <p className="notice-inline">{error}</p>}
<div className="table-container table-wrap">
<table className="data-table">
<thead>
<tr>
<th>公司名称</th>
<th>国家</th>
<th>行业</th>
<th>联系方式</th>
<th>原归属</th>
<th>丢入时间</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<SkeletonTableRows rows={5} widths={[2.2, 0.8, 1, 1.6, 1, 1, 1]} />
) : filtered.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<GlobeIcon size={28} />
<p style={{ margin: '8px 0 0' }}>
{rows.length ? '没有匹配的公海客户' : '公海现在是空的 —— 同事把客户丢进来后会出现在这里。'}
</p>
</td>
</tr>
) : (
filtered.map(row => (
<tr key={row.id} onClick={() => navigate(`/customers/${row.id}`)}>
<td>
<strong>{row.company}</strong>
{row.pool_note && <div className="muted-note">放出说明:{row.pool_note}</div>}
</td>
<td>{row.country || '-'}</td>
<td>{row.industry || '-'}</td>
<td>
{row.email && <div>{row.email}</div>}
{row.phone && <div>{row.phone}</div>}
</td>
<td>
<span className="owner-cell"><UserIcon size={11} /> {row.owner_name || '未指定'}</span>
{row.created_by_name && <div className="muted-note">创建人:{row.created_by_name}</div>}
</td>
<td className="mono">{fmtDate(row.pool_at)}</td>
<td className="col-actions">
{canClaim ? (
<button
className="btn btn-primary btn-sm"
onClick={event => { event.stopPropagation(); claim(row) }}
>
领取
</button>
) : (
<span className="muted-note">无领取权限</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
export default CustomerPool
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
import React, { useState, useRef } from 'react'
/**
* SKU 功能嵌入页(整合过渡期方案)。
*
* 合并策略是「先嵌后重写」:SKU 的能力先以嵌入方式跑通,
* 之后再逐步重写为原生 React 页面。
*
* 现在嵌入的是独立的 sku-manager 服务(3300);等后端合并(P1/P2)完成后,
* 把 SKU_ORIGIN 换成同源子路径(如 /sku)即可,页面组件本身不用动。
*/
const SKU_ORIGIN = import.meta.env.VITE_SKU_ORIGIN || 'http://127.0.0.1:3300'
const VIEW_META = {
search: {
title: '产品管理',
sub: '按货号、品名、规格、材质或参数检索产品与采购价'
},
suppliers: {
title: '供应商管理',
sub: '供应商档案、联系方式、工厂所在地与主营类目'
},
import: {
title: '合同导入',
sub: '批量导入采购合同,自动解析价格、产品与参数'
}
}
function EmbeddedSku({ view = 'search' }) {
const [loaded, setLoaded] = useState(false)
const frameRef = useRef(null)
const meta = VIEW_META[view] || VIEW_META.search
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}`
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">{meta.title}</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginTop: '6px' }}>
{meta.sub}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.location.reload()}>
刷新
</button>
<a className="btn btn-sm" href={src} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
新窗口打开
</a>
</div>
</div>
<div
style={{
position: 'relative',
height: 'calc(100vh - 210px)',
minHeight: '560px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
background: 'var(--surface)'
}}
>
{!loaded && (
<div className="loading" style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
<div className="spinner"></div>
<span style={{ marginLeft: '12px' }}>正在加载 SKU 模块…</span>
</div>
)}
<iframe
ref={frameRef}
title={meta.title}
src={src}
onLoad={() => setLoaded(true)}
allow="clipboard-write"
style={{
width: '100%',
height: '100%',
border: 0,
display: 'block',
opacity: loaded ? 1 : 0,
transition: 'opacity .18s ease'
}}
/>
</div>
</div>
)
}
export default EmbeddedSku
+601
View File
@@ -0,0 +1,601 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import axios from 'axios'
import Drawer from '../components/Drawer'
import SkuPicker from '../components/SkuPicker'
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
import {
PlusCircleIcon, PencilIcon, TrashIcon, PackageIcon, ClockIcon,
InquiryIcon, DollarIcon, RefreshIcon
} from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
const STATUS_LABELS = {
draft: '草稿',
quoting: '报价中',
quoted: '已报价',
won: '已成交',
lost: '已流失',
closed: '已关闭'
}
const STATUS_TONE = {
draft: 'mute',
quoting: 'warn',
quoted: 'ok',
won: 'ok',
lost: 'danger',
closed: 'mute'
}
const CURRENCIES = ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD']
/**
* 报价合计。
* 口径说明:inquiry_items.currency 存的是「采购币种」,而 EXW 是拿采购价除以汇率算出来的,
* 所以 EXW / 小计 / 合计都落在「报价币种」上,也就是 inquiry.currency。
* 这里不做跨币种换算 —— 一张询盘的报价币种是固定的(汇率那一栏的分母就是它)。
*/
function summarize(items) {
return items.reduce((sum, item) => sum + Number(item.exw || 0) * Number(item.quantity || 0), 0)
}
function money(value, digits = 2) {
const number = Number(value)
if (!Number.isFinite(number)) return '—'
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
}
/** 包装快照读出来做「一行摘要」,让报价行不用展开就知道箱规 */
function packagingBrief(snapshot) {
if (!snapshot) return null
let data = null
try { data = JSON.parse(snapshot) } catch { return null }
if (!data) return null
const parts = []
if (data.carton_qty) parts.push(`${data.carton_qty} pcs/箱`)
if (data.carton_length && data.carton_width && data.carton_height) {
parts.push(`${data.carton_length}×${data.carton_width}×${data.carton_height} cm`)
}
if (data.carton_weight) parts.push(`毛重 ${data.carton_weight} kg`)
if (data.packaging_material) parts.push(data.packaging_material)
return parts.length ? parts : null
}
/**
* 询盘明细编辑器。
* 两种输入方式:
* 自动 —— 填加价率,EXW 由「采购价 ÷ 汇率 ×(1+加价率)」算出(这是报价的标准姿势)
* 手动 —— 直接给 EXW,反算加价率(客户先给目标价时用)
* 两者互斥,切换时保留当前能推导的量,避免把用户已经填好的数字清掉。
*/
function ItemEditor({ inquiry, item, existingSkus = [], onClose, onSaved, canEdit }) {
const isNew = !item
const [skuChoice, setSkuChoice] = useState(isNew ? null : { sku: item.sku, display_sku: item.sku, source: 'existing' })
const [pickerOpen, setPickerOpen] = useState(isNew)
const [mode, setMode] = useState('auto')
const [form, setForm] = useState(() => ({
purchase_price: item ? String(item.purchase_price ?? '') : '',
// 采购币种默认取人民币 —— 供应商报价绝大多数是 CNY,拿询盘的报价币种当默认值会把人带偏
currency: item?.currency || 'CNY',
exchange_rate: item ? String(item.exchange_rate ?? '') : '',
markup_rate: item ? String(((Number(item.markup_rate) || 0) * 100).toFixed(2).replace(/\.?0+$/, '')) : '25',
manual_exw: '',
quantity: item ? String(item.quantity ?? '') : '1'
}))
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const purchasePrice = Number(form.purchase_price)
const exchangeRate = Number(form.exchange_rate)
const markupRate = Number(form.markup_rate) / 100
const manualExw = Number(form.manual_exw)
const base = Number.isFinite(purchasePrice) && Number.isFinite(exchangeRate) && exchangeRate > 0
? purchasePrice / exchangeRate
: null
const preview = useMemo(() => {
if (base === null) return null
if (mode === 'manual') {
if (!Number.isFinite(manualExw) || manualExw <= 0) return null
return { exw: manualExw, markup: manualExw / base - 1 }
}
if (!Number.isFinite(markupRate) || markupRate < -1) return null
return { exw: base * (1 + markupRate), markup: markupRate }
}, [mode, base, manualExw, markupRate])
const pickSku = async (choice) => {
setSkuChoice(choice)
setPickerOpen(false)
setError('')
// 命中 SKU 服务时返回的是完整货号,顺手把采购价/币种带出来,省一次手输
if (choice.source === 'manual') return
setForm(current => ({
...current,
purchase_price: choice.price === null || choice.price === undefined ? current.purchase_price : String(choice.price),
currency: choice.currency || current.currency
}))
}
const submit = async (event) => {
event.preventDefault()
if (!skuChoice?.sku) { setError('请先选择货号'); return }
const quantity = Number(form.quantity)
if (!Number.isInteger(quantity) || quantity <= 0) { setError('数量必须是大于 0 的整数'); return }
if (!Number.isFinite(purchasePrice) || purchasePrice <= 0) { setError('采购价必须是大于 0 的数字'); return }
if (!Number.isFinite(exchangeRate) || exchangeRate <= 0) { setError('汇率必须是大于 0 的数字'); return }
const payload = {
sku: skuChoice.sku,
purchase_price: purchasePrice,
currency: form.currency,
exchange_rate: exchangeRate,
quantity
}
if (mode === 'manual') {
if (!Number.isFinite(manualExw) || manualExw <= 0) { setError('手动 EXW 必须是大于 0 的数字'); return }
payload.manual_exw = manualExw
} else {
payload.markup_rate = markupRate
}
setSaving(true)
setError('')
try {
if (isNew) await axios.post(`/api/inquiries/${inquiry.id}/items`, payload)
else await axios.patch(`/api/inquiry-items/${item.id}`, payload)
onSaved()
} catch (saveError) {
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
const brief = item ? packagingBrief(item.packaging_snapshot) : null
return (
<>
<Drawer
wide
title={isNew ? '添加报价明细' : `编辑明细 · ${item.sku}`}
sub={isNew ? `询盘 ${inquiry?.inquiry_no || ''}` : item.product_name}
onClose={onClose}
footer={(
<>
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
{canEdit && (
<button type="submit" form="inquiry-item-form" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存明细'}
</button>
)}
</>
)}
>
<form id="inquiry-item-form" onSubmit={submit}>
<div className="panel-title">货号</div>
<div className="sku-chosen">
{skuChoice ? (
<>
<div className="sku-chosen-main">
<SkuThumb sku={skuChoice.sku} filename={skuChoice.thumb} size={36} />
<b>{skuChoice.display_sku || skuChoice.sku}</b>
<span>{skuChoice.name || skuChoice.spec || (skuChoice.source === 'manual' ? '手工输入货号' : '')}</span>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPickerOpen(true)}>
更换货号
</button>
)}
</>
) : (
<p className="muted-note">还没有选择货号。</p>
)}
</div>
<div className="panel-title">报价参数</div>
<div className="form-row">
<div className="form-group">
<label>采购价(含税单价)</label>
<input
type="number" min="0" step="0.01" inputMode="decimal"
value={form.purchase_price}
onChange={event => setForm({ ...form, purchase_price: event.target.value })}
/>
</div>
<div className="form-group">
<label>采购币种</label>
<select value={form.currency} onChange={event => setForm({ ...form, currency: event.target.value })}>
{CURRENCIES.map(currency => <option key={currency} value={currency}>{currency}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>汇率(1 {inquiry?.currency || '报价币'} = ? {form.currency})</label>
<input
type="number" min="0" step="0.0001" inputMode="decimal"
value={form.exchange_rate}
placeholder="例如 6.7"
onChange={event => setForm({ ...form, exchange_rate: event.target.value })}
/>
</div>
<div className="form-group">
<label>数量</label>
<input
type="number" min="1" step="1" inputMode="numeric"
value={form.quantity}
onChange={event => setForm({ ...form, quantity: event.target.value })}
/>
</div>
</div>
<div className="panel-title">定价方式</div>
<div className="mode-switch" role="tablist">
<button
type="button" role="tab" aria-selected={mode === 'auto'}
className={mode === 'auto' ? 'is-active' : ''}
onClick={() => setMode('auto')}
>
按加价率算 EXW
</button>
<button
type="button" role="tab" aria-selected={mode === 'manual'}
className={mode === 'manual' ? 'is-active' : ''}
onClick={() => setMode('manual')}
>
手填 EXW 反算加价率
</button>
</div>
<div className="form-row">
{mode === 'auto' ? (
<div className="form-group">
<label>加价率</label>
<span className="pkg-input">
<input
type="number" step="0.1" inputMode="decimal"
value={form.markup_rate}
placeholder="例如 25"
onChange={event => setForm({ ...form, markup_rate: event.target.value })}
/>
<em>%</em>
</span>
</div>
) : (
<div className="form-group">
<label>目标 EXW 单价</label>
<input
type="number" min="0" step="0.01" inputMode="decimal"
value={form.manual_exw}
placeholder="客户给的目标价"
onChange={event => setForm({ ...form, manual_exw: event.target.value })}
/>
</div>
)}
<div className="form-group">
<label>{mode === 'auto' ? '加价系数' : '反算加价系数'}</label>
<div className="readout">
{preview ? (preview.markup + 1).toFixed(4) : '—'}
</div>
</div>
</div>
<div className="exw-preview">
<div className="exw-preview-formula">
EXW = 采购价 ÷ 汇率 ×(1 + 加价率)
</div>
<div className="exw-preview-main">
<span>
EXW 单价
<em className="exw-preview-currency">报价币种 {inquiry?.currency || '—'}</em>
</span>
<strong>{preview ? `${money(preview.exw, 4)} ${inquiry?.currency || ''}` : '—'}</strong>
</div>
<div className="exw-preview-sub">
{base === null ? (
<span>填好采购价和汇率后自动计算。</span>
) : (
<>
<span>采购价折报价币 <b>{money(base, 4)}</b></span>
<span>加价率 <b>{preview ? `${(preview.markup * 100).toFixed(2)}%` : '—'}</b></span>
<span>小计 <b>{preview ? `${money(preview.exw * Number(form.quantity || 0), 2)} ${inquiry?.currency || ''}` : '—'}</b></span>
</>
)}
</div>
</div>
{brief && (
<>
<div className="panel-title">包装(本次报价时的快照)</div>
<ul className="snapshot-list">
{brief.map(text => <li key={text}><PackageIcon size={13} /> {text}</li>)}
</ul>
<p className="muted-note">快照在报价那一刻定格,之后产品包装信息再改也不会动这行报价。</p>
</>
)}
{error && <p className="form-error">{error}</p>}
</form>
</Drawer>
{pickerOpen && (
<div className="modal-overlay" onClick={() => (isNew ? onClose() : setPickerOpen(false))}>
<div className="modal-content sku-picker-modal" onClick={event => event.stopPropagation()}>
<SkuPicker
excludeSkus={existingSkus}
onClose={() => (isNew ? onClose() : setPickerOpen(false))}
onPick={pickSku}
/>
</div>
</div>
)}
</>
)
}
function InquiryDetail() {
const { id } = useParams()
const { user } = useAuth()
const canEdit = hasPermission(user, 'inquiry.edit')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editor, setEditor] = useState(null) // { item } 或 { item: null } 表示新增
const [expandedPackaging, setExpandedPackaging] = useState(null)
/* 明细里的货号只在本地库里,图片得按货号批量问 SKU 服务要 */
const itemThumbs = useSkuThumbs((data?.items || []).map(row => row.sku))
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const response = await axios.get(`/api/inquiries/${id}`)
setData(response.data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取询盘失败')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
const updateStatus = async (status) => {
try {
await axios.patch(`/api/inquiries/${id}`, { status })
await load()
} catch (statusError) {
setError(statusError?.response?.data?.error || '更新状态失败')
}
}
const removeItem = async (item) => {
if (!window.confirm(`删除明细「${item.product_name || item.sku}」?删除后不可恢复。`)) return
try {
await axios.delete(`/api/inquiry-items/${item.id}`)
await load()
} catch (removeError) {
setError(removeError?.response?.data?.error || '删除失败,请稍后重试')
}
}
const inquiry = data?.inquiry
const items = data?.items || []
const totals = useMemo(() => summarize(items), [items])
if (loading) return <div className="loading">加载询盘…</div>
if (!inquiry) {
return (
<div className="page-container">
<p className="notice-inline">{error || '询盘不存在'}</p>
<Link className="btn btn-secondary btn-sm" to="/inquiries">返回询盘列表</Link>
</div>
)
}
return (
<div className="page-container">
<div className="breadcrumb">
<Link to="/inquiries">询盘管理</Link>
<span>/</span>
<span className="mono">{inquiry.inquiry_no}</span>
</div>
<div className="page-header">
<div>
<h1 className="page-title">{inquiry.title || '未命名询盘'}</h1>
<p className="page-sub">
<Link to={`/customers/${inquiry.customer_id}`}>{inquiry.customer_name || '未知客户'}</Link>
{' · '}
{inquiry.inquiry_date}
{' · '}
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
{STATUS_LABELS[inquiry.status] || inquiry.status}
</span>
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 刷新
</button>
{canEdit && (
<button type="button" className="btn btn-primary btn-sm" onClick={() => setEditor({ item: null })}>
<PlusCircleIcon size={15} /> 添加货号
</button>
)}
</div>
</div>
<div className="stat-rail inquiry-rail">
<div className="stat-line">
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
<span>报价明细</span><strong>{items.length}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><PackageIcon size={17} /></span>
<span>总数量</span>
<strong>{items.reduce((sum, item) => sum + Number(item.quantity || 0), 0).toLocaleString('zh-CN')}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><DollarIcon size={17} /></span>
<span>报价合计</span>
<strong>
{items.length ? `${money(totals, 2)} ${inquiry.currency || ''}` : '—'}
</strong>
</div>
</div>
{error && <p className="notice-inline">{error}</p>}
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><DollarIcon size={17} /> 报价明细</h2>
</div>
<div className="header-actions">
{canEdit ? (
<label className="inline-field">
<span>询盘状态</span>
<select value={inquiry.status} onChange={event => updateStatus(event.target.value)}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
) : (
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
{STATUS_LABELS[inquiry.status] || inquiry.status}
</span>
)}
</div>
</div>
{items.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">这次询盘还没有报价行</p>
<p className="empty-state-hint">点右上角「添加货号」,从已有 SKU 里选,或直接敲完整货号。</p>
</div>
) : (
<div className="table-wrap">
<table className="data-table quote-table">
<thead>
<tr>
<th>货号</th>
<th>产品 / 供应商</th>
<th className="num">采购价</th>
<th className="num">汇率</th>
<th className="num">加价率</th>
<th className="num">加价系数</th>
<th className="num">EXW<span className="th-unit">{inquiry.currency}</span></th>
<th className="num">数量</th>
<th className="num">小计<span className="th-unit">{inquiry.currency}</span></th>
<th>包装</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{items.map(item => {
const brief = packagingBrief(item.packaging_snapshot)
const open = expandedPackaging === item.id
const subtotal = Number(item.exw || 0) * Number(item.quantity || 0)
return (
<React.Fragment key={item.id}>
<tr>
<td className="mono bold">
<span className="sku-cell">
<SkuThumb sku={item.sku} filename={itemThumbs[item.sku]} size={30} />
{item.sku}
</span>
</td>
<td>
<div>{item.product_name || '—'}</div>
<div className="cell-sub">{item.supplier_name || '未指派供应商'}</div>
</td>
<td className="num">{money(item.purchase_price, 2)} <span className="unit">{item.currency}</span></td>
<td className="num">{money(item.exchange_rate, 4)}</td>
<td className="num">{(Number(item.markup_rate || 0) * 100).toFixed(2)}%</td>
<td className="num">{money(item.markup_factor, 4)}</td>
<td className="num strong-cell">{money(item.exw, 4)}</td>
<td className="num">{Number(item.quantity || 0).toLocaleString('zh-CN')}</td>
<td className="num">{money(subtotal, 2)}</td>
<td>
{brief ? (
<button
type="button"
className={`packaging-chip${open ? ' is-open' : ''}`}
onClick={() => setExpandedPackaging(open ? null : item.id)}
title="展开包装快照"
>
<PackageIcon size={13} /> {brief[0]}
</button>
) : (
<span className="muted-note">未维护</span>
)}
</td>
<td className="col-actions">
{canEdit && (
<button type="button" className="icon-btn" title="编辑明细" onClick={() => setEditor({ item })}>
<PencilIcon size={14} />
</button>
)}
{canEdit && (
<button type="button" className="icon-btn" title="删除明细" onClick={() => removeItem(item)}>
<TrashIcon size={14} />
</button>
)}
</td>
</tr>
{open && brief && (
<tr className="snapshot-row">
<td colSpan={11}>
<div className="snapshot-detail">
<span className="snapshot-detail-title"><PackageIcon size={13} /> 报价时的包装快照</span>
<ul className="snapshot-list">
{brief.map(text => <li key={text}>{text}</li>)}
</ul>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><ClockIcon size={17} /> 跟进记录</h2>
</div>
<Link className="btn btn-secondary btn-sm" to={`/customers/${inquiry.customer_id}`}>
去客户详情记录跟进
</Link>
</div>
<p className="muted-note">
跟进记录统一挂在客户身上,记录时可以在「关联询盘」里选中本次询盘({inquiry.inquiry_no}),
这样客户时间线和工作台待跟进都能对上。
</p>
</section>
{editor && (
<ItemEditor
inquiry={inquiry}
item={editor.item}
existingSkus={items.filter(row => row.id !== editor.item?.id).map(row => row.sku)}
onClose={() => setEditor(null)}
onSaved={() => { setEditor(null); load() }}
canEdit={canEdit}
/>
)}
</div>
)
}
export default InquiryDetail
+357
View File
@@ -0,0 +1,357 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import axios from 'axios'
import { InquiryIcon, PlusCircleIcon, SearchIcon, RefreshIcon, UsersIcon } from '../components/Icons'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
const STATUS_LABELS = {
draft: '草稿',
quoting: '报价中',
quoted: '已报价',
won: '已成交',
lost: '已流失',
closed: '已关闭'
}
const STATUS_TONE = {
draft: 'mute',
quoting: 'warn',
quoted: 'ok',
won: 'ok',
lost: 'danger',
closed: 'mute'
}
const SOURCES = ['展会', '邮件开发', '老客户复购', '阿里国际站', '客户主动询价', '其他']
function InquiryManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canEdit = hasPermission(user, 'inquiry.edit')
const [inquiries, setInquiries] = useState([])
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({
customer_id: '', title: '', inquiry_date: new Date().toISOString().slice(0, 10),
currency: 'USD', source: SOURCES[0], notes: ''
})
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const [inquiryResponse, customerResponse] = await Promise.all([
axios.get('/api/inquiries'),
axios.get('/api/customers')
])
setInquiries(Array.isArray(inquiryResponse.data) ? inquiryResponse.data : [])
setCustomers(Array.isArray(customerResponse.data) ? customerResponse.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取询盘列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const filtered = useMemo(() => {
const term = keyword.trim().toLowerCase()
return inquiries.filter(inquiry => {
if (statusFilter && inquiry.status !== statusFilter) return false
if (!term) return true
return [inquiry.inquiry_no, inquiry.title, inquiry.customer_name]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(term))
})
}, [inquiries, keyword, statusFilter])
const counts = useMemo(() => {
const result = { total: inquiries.length }
inquiries.forEach(inquiry => { result[inquiry.status] = (result[inquiry.status] || 0) + 1 })
return result
}, [inquiries])
const openAdd = () => {
setForm({
customer_id: customers[0]?.id ? String(customers[0].id) : '',
title: '', inquiry_date: new Date().toISOString().slice(0, 10),
currency: 'USD', source: SOURCES[0], notes: ''
})
setFormError('')
setShowAdd(true)
}
const submit = async (event) => {
event.preventDefault()
if (!form.customer_id) { setFormError('请选择客户'); return }
if (!customers.length) { setFormError('还没有客户,请先到「客户管理」新增一个客户'); return }
setSaving(true)
setFormError('')
try {
const response = await axios.post('/api/inquiries', {
customer_id: Number(form.customer_id),
title: form.title.trim() || null,
inquiry_date: form.inquiry_date,
currency: form.currency,
source: form.source || null,
notes: form.notes.trim() || null
})
const created = response.data?.inquiry
setShowAdd(false)
if (created?.id) navigate(`/inquiries/${created.id}`)
else load()
} catch (submitError) {
setFormError(submitError?.response?.data?.error || '创建询盘失败')
} finally {
setSaving(false)
}
}
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">询盘管理</h1>
<p className="page-sub">客户的第一封询价、绑定的货号、最终的 EXW 报价,都在这里串起来。</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 刷新
</button>
{canEdit && (
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
<PlusCircleIcon size={15} /> 新建询盘
</button>
)}
</div>
</div>
<div className="filter-bar">
<span className="toolbar-search">
<SearchIcon size={15} />
<input
type="search"
value={keyword}
placeholder="搜询盘号 / 标题 / 客户"
aria-label="搜索询盘"
onChange={event => setKeyword(event.target.value)}
/>
</span>
<div className="chip-filters">
<button
type="button"
className={`chip-filter${statusFilter === '' ? ' is-active' : ''}`}
onClick={() => setStatusFilter('')}
>
全部 <b>{counts.total || 0}</b>
</button>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
counts[value] ? (
<button
type="button"
key={value}
className={`chip-filter${statusFilter === value ? ' is-active' : ''}`}
onClick={() => setStatusFilter(value)}
>
{label} <b>{counts[value]}</b>
</button>
) : null
))}
</div>
</div>
{error && <p className="notice-inline">{error}</p>}
{loading ? (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>询盘号</th>
<th>客户</th>
<th>标题</th>
<th>询盘日期</th>
<th className="num">货号数</th>
<th>报价币种</th>
<th>来源</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
<SkeletonTableRows rows={6} widths={[1.2, 1.2, 1.8, 1, 0.7, 0.9, 0.9, 0.9, 1]} />
</tbody>
</table>
</div>
) : filtered.length === 0 ? (
<div className="dashboard-panel">
<div className="empty-state">
<p className="empty-state-title">
{inquiries.length ? '没有符合条件的询盘' : '还没有任何询盘'}
</p>
<p className="empty-state-hint">
{inquiries.length
? '换个关键词或状态试试。'
: '客户来了第一封询价时,先建一条询盘,再往里面挂货号。'}
</p>
{!inquiries.length && canEdit && (
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd} style={{ marginTop: 14 }}>
<PlusCircleIcon size={15} /> 新建询盘
</button>
)}
</div>
</div>
) : (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>询盘号</th>
<th>客户</th>
<th>标题</th>
<th>询盘日期</th>
<th className="num">货号数</th>
<th>报价币种</th>
<th>来源</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{filtered.map(inquiry => (
<tr key={inquiry.id} onClick={() => navigate(`/inquiries/${inquiry.id}`)}>
<td className="mono bold">{inquiry.inquiry_no}</td>
<td>{inquiry.customer_name || '—'}</td>
<td>{inquiry.title || <span className="muted-note">未命名</span>}</td>
<td className="mono">{inquiry.inquiry_date}</td>
<td className="num">{inquiry.item_count ?? 0}</td>
<td>{inquiry.currency || '—'}</td>
<td>{inquiry.source || '—'}</td>
<td>
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
{STATUS_LABELS[inquiry.status] || inquiry.status}
</span>
</td>
<td className="col-actions">
<Link
className="btn-link"
to={`/inquiries/${inquiry.id}`}
onClick={event => event.stopPropagation()}
>
查看报价
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showAdd && (
<div className="modal-overlay" onClick={() => setShowAdd(false)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2><InquiryIcon size={18} /> 新建询盘</h2>
<button type="button" className="icon-btn" onClick={() => setShowAdd(false)} aria-label="关闭">×</button>
</div>
<form onSubmit={submit}>
{customers.length === 0 && (
<p className="notice-inline">
还没有客户档案。先去
<Link to="/customers" onClick={() => setShowAdd(false)}> 客户管理 </Link>
建一个客户,询盘必须挂在客户身上。
</p>
)}
<div className="form-group">
<label>客户 *</label>
<select
value={form.customer_id}
onChange={event => setForm({ ...form, customer_id: event.target.value })}
>
<option value="">请选择客户</option>
{customers.map(customer => (
<option key={customer.id} value={customer.id}>
{customer.company}{customer.country ? ` · ${customer.country}` : ''}
</option>
))}
</select>
</div>
<div className="form-group">
<label>询盘标题</label>
<input
type="text"
maxLength={200}
value={form.title}
placeholder="例如:2026 春季手动工具首轮询价"
onChange={event => setForm({ ...form, title: event.target.value })}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>询盘日期</label>
<input
type="date"
value={form.inquiry_date}
onChange={event => setForm({ ...form, inquiry_date: event.target.value })}
/>
</div>
<div className="form-group">
<label>报价币种</label>
<select
value={form.currency}
onChange={event => setForm({ ...form, currency: event.target.value })}
>
{['USD', 'EUR', 'CNY', 'GBP', 'JPY', 'AUD'].map(currency => (
<option key={currency} value={currency}>{currency}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label>来源渠道</label>
<select value={form.source} onChange={event => setForm({ ...form, source: event.target.value })}>
{SOURCES.map(source => <option key={source} value={source}>{source}</option>)}
</select>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows={3}
maxLength={1000}
value={form.notes}
placeholder="客户提到的交期、认证、目标价等"
onChange={event => setForm({ ...form, notes: event.target.value })}
/>
</div>
{formError && <p className="form-error">{formError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowAdd(false)}>取消</button>
{canEdit && (
<button type="submit" className="btn btn-primary" disabled={saving || !customers.length}>
{saving ? '创建中…' : '创建并去报价'}
</button>
)}
</div>
</form>
</div>
</div>
)}
{!loading && customers.length > 0 && inquiries.length > 0 && (
<p className="muted-note table-foot-note">
<UsersIcon size={13} /> 共 {filtered.length} 条询盘
{filtered.some(inquiry => !inquiry.item_count) && ' · 有询盘还没有报价行,点进去添加货号'}
</p>
)}
</div>
)
}
export default InquiryManagement
+106
View File
@@ -0,0 +1,106 @@
import React, { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { CloudDbIcon, UsersIcon } from '../components/Icons'
import { useAuth } from '../auth'
import { useBranding, setPageTitle } from '../branding'
/**
* 登录页。
*
* 用 replace 跳转:登录成功后不该能用浏览器返回键退回登录页。
* 若是因为「会话过期」被动登出(顶层守卫带过来的 from),登录后回到原来那一页。
*/
function LoginPage() {
const { login } = useAuth()
const brand = useBranding()
const navigate = useNavigate()
const location = useLocation()
const [form, setForm] = useState({ username: '', password: '' })
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const userRef = useRef(null)
useEffect(() => {
setPageTitle('登录')
userRef.current?.focus()
}, [])
const from = location.state?.from && location.state.from !== '/login' ? location.state.from : '/'
const submit = async event => {
event.preventDefault()
if (!form.username.trim()) { setError('请输入用户名'); return }
if (!form.password) { setError('请输入密码'); return }
setBusy(true)
setError('')
try {
await login(form.username.trim(), form.password)
navigate(from, { replace: true })
} catch (submitError) {
setError(submitError?.response?.data?.error || '登录失败,请稍后重试')
} finally {
setBusy(false)
}
}
return (
<div className="login-page">
<div className="login-card">
<aside className="login-brand">
<span className="login-brand-mark"><CloudDbIcon size={44} /></span>
<h1>{brand.name}</h1>
<p className="login-brand-sub">{brand.subtitle}</p>
<ul className="login-brand-points">
<li>询盘 → 报价 → PI → 采购,一条链路跑完</li>
<li>产品与供应商统一目录,团队共享可参考</li>
<li>汇率、英文大写金额、包装参数内置</li>
</ul>
<p className="login-brand-foot">v1 · 第一阶段</p>
</aside>
<form className="login-form" onSubmit={submit}>
<header>
<h2>账号登录</h2>
<p className="login-hint"><UsersIcon size={14} /> 请使用组织分配给你的账号</p>
</header>
<div className="form-group">
<label htmlFor="login-username">用户名</label>
<input
id="login-username"
ref={userRef}
type="text"
autoComplete="username"
value={form.username}
onChange={event => setForm(current => ({ ...current, username: event.target.value }))}
placeholder="请输入用户名"
/>
</div>
<div className="form-group">
<label htmlFor="login-password">密码</label>
<input
id="login-password"
type="password"
autoComplete="current-password"
value={form.password}
onChange={event => setForm(current => ({ ...current, password: event.target.value }))}
placeholder="请输入密码"
/>
</div>
{error && <p className="form-error login-error">{error}</p>}
<button type="submit" className="btn btn-primary login-submit" disabled={busy}>
{busy ? '登录中…' : '登 录'}
</button>
<p className="login-tip">首次使用默认账号 <code>admin</code>,登录后请在「个人资料」里修改密码。</p>
</form>
</div>
<p className="login-copyright">{brand.name} · 外贸供应链管理系统</p>
</div>
)
}
export default LoginPage
+55 -4
View File
@@ -1,9 +1,17 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
import OrderExpenses from '../components/OrderExpenses'
function OrderManagement() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
const { user } = useAuth()
const canEdit = hasPermission(user, 'order.edit')
/* 支出单独一个权限点:掏钱寄样品的是业务员,而业务组没有 order.edit */
const canExpense = hasPermission(user, 'order.expense')
const [expenseOrder, setExpenseOrder] = useState(null)
useEffect(() => {
fetchOrders()
@@ -33,14 +41,39 @@ function OrderManagement() {
}
if (loading) {
return <div className="loading">加载中...</div>
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">订单管理</h1>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>订单号</th>
<th>客户</th>
<th>订单日期</th>
<th>交货日期</th>
<th>金额</th>
<th>杂费</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 1, 0.8, 1]} />
</tbody>
</table>
</div>
</div>
)
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">订单管理</h1>
<button className="btn-primary">+ 新建订单</button>
{canEdit && <button className="btn-primary">+ 新建订单</button>}
</div>
<div className="table-container">
@@ -52,6 +85,7 @@ function OrderManagement() {
<th>订单日期</th>
<th>交货日期</th>
<th>金额</th>
<th>杂费</th>
<th>状态</th>
<th>操作</th>
</tr>
@@ -59,7 +93,7 @@ function OrderManagement() {
<tbody>
{orders.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">暂无订单数据</td>
<td colSpan="8" className="empty-state">暂无订单数据</td>
</tr>
) : (
orders.map(order => (
@@ -77,6 +111,14 @@ function OrderManagement() {
? `${order.currency} ${order.total_amount.toFixed(2)}`
: '-'}
</td>
<td>
{order.expense_count ? (
<button type="button" className="btn-link" onClick={() => setExpenseOrder(order)}>
{order.currency} {Number(order.expense_total || 0).toFixed(2)}
{order.expense_unpaid > 0 && <em className="order-expense-unpaid"> 未付 {Number(order.expense_unpaid).toFixed(2)}</em>}
</button>
) : <span className="text-dim">—</span>}
</td>
<td>
<span className={`status-badge status-${order.status}`}>
{getStatusText(order.status)}
@@ -84,7 +126,8 @@ function OrderManagement() {
</td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
{canExpense && <button type="button" className="btn-link" onClick={() => setExpenseOrder(order)}>支出</button>}
{canEdit && <button className="btn-link">编辑</button>}
</td>
</tr>
))
@@ -92,6 +135,14 @@ function OrderManagement() {
</tbody>
</table>
</div>
{expenseOrder && (
<OrderExpenses
order={expenseOrder}
onClose={() => setExpenseOrder(null)}
onChanged={fetchOrders}
/>
)}
</div>
)
}
+280
View File
@@ -0,0 +1,280 @@
import React, { useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import { ShieldIcon, PlusIcon, TrashIcon, SaveIcon } from '../components/Icons'
import { useAuth } from '../auth'
import { setPageTitle } from '../branding'
/**
* 权限组管理。
*
* 口径:角色(董事长/总经理/销售经理/销售业务员)只是职位标签,本身不带权限。
* 权限组的「权限范围」勾选决定了能访问哪些模块;把角色指派进组,该角色下的用户即生效。
* 一个角色同一时间只能属于一个组 —— 所以角色复选框是互斥语义,勾到别的组会把它移过来。
*/
function PermissionGroups() {
const { refresh } = useAuth()
const [groups, setGroups] = useState([])
const [catalog, setCatalog] = useState({ groups: [], roles: [] })
const [selectedId, setSelectedId] = useState(null)
const [draft, setDraft] = useState(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const load = async (keepId) => {
const [groupRes, catalogRes] = await Promise.all([
axios.get('/api/permission-groups'),
axios.get('/api/permissions')
])
const list = groupRes.data.groups || []
setGroups(list)
setCatalog({ groups: catalogRes.data.groups || [], roles: catalogRes.data.roles || [] })
const target = keepId ?? selectedId
const pick = list.find(item => item.id === target) || list[0] || null
setSelectedId(pick?.id ?? null)
setDraft(pick ? { ...pick, permissions: [...pick.permissions], roles: [...pick.roles] } : null)
return list
}
useEffect(() => {
setPageTitle('权限组管理')
load().catch(() => setNotice({ type: 'error', text: '加载权限组失败' })).finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const roleGroupMap = useMemo(() => {
const map = new Map()
for (const group of groups) {
for (const role of group.roles) map.set(role, group.id)
}
return map
}, [groups])
const select = id => {
const target = groups.find(item => item.id === id)
if (!target) return
setSelectedId(id)
setDraft({ ...target, permissions: [...target.permissions], roles: [...target.roles] })
setNotice(null)
}
const togglePermission = key => {
setDraft(current => {
const has = current.permissions.includes(key)
return {
...current,
permissions: has ? current.permissions.filter(item => item !== key) : [...current.permissions, key]
}
})
}
const toggleModulePermissions = (moduleGroups, checked) => {
const keys = moduleGroups.items.map(item => item.key)
setDraft(current => ({
...current,
permissions: checked
? [...new Set([...current.permissions, ...keys])]
: current.permissions.filter(item => !keys.includes(item))
}))
}
const toggleRole = role => {
setDraft(current => {
const has = current.roles.includes(role)
return { ...current, roles: has ? current.roles.filter(item => item !== role) : [...current.roles, role] }
})
}
const save = async () => {
if (!draft) return
if (!draft.name.trim()) { setNotice({ type: 'error', text: '权限组名称不能为空' }); return }
setBusy(true)
setNotice(null)
try {
const { data } = await axios.patch(`/api/permission-groups/${draft.id}`, {
name: draft.name.trim(),
description: draft.description,
permissions: draft.permissions,
roles: draft.roles
})
await load(data.group.id)
await refresh() /* 可能改到了自己的角色所属组,刷新自身权限 */
setNotice({ type: 'ok', text: '权限组已保存' })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '保存失败' })
} finally {
setBusy(false)
}
}
const createGroup = async () => {
const name = window.prompt('新权限组名称', '新权限组')
if (name === null) return
if (!name.trim()) return
setBusy(true)
try {
const { data } = await axios.post('/api/permission-groups', {
name: name.trim(),
description: '',
permissions: ['dashboard.view'],
roles: []
})
await load(data.group.id)
setNotice({ type: 'ok', text: `权限组「${data.group.name}」已创建` })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '创建失败' })
} finally {
setBusy(false)
}
}
const removeGroup = async () => {
if (!draft) return
if (!window.confirm(`确定删除权限组「${draft.name}」?该操作不可撤销。`)) return
setBusy(true)
try {
await axios.delete(`/api/permission-groups/${draft.id}`)
await load(null)
setNotice({ type: 'ok', text: '权限组已删除' })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '删除失败' })
} finally {
setBusy(false)
}
}
if (loading) return <div className="page-container"><p className="muted-note">正在加载权限组…</p></div>
return (
<div className="page-container perm-page">
<div className="page-header">
<div>
<h1 className="page-title">权限组管理</h1>
<p className="page-subtitle">
角色只是职位标签;把角色放进某个权限组,它就获得该组勾选的权限范围。一个角色同时只能属于一个组。
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={createGroup} disabled={busy}>
<PlusIcon size={15} /> 新建权限组
</button>
</div>
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
<div className="perm-layout">
{/* ---------------- 左:组列表 ---------------- */}
<aside className="perm-list panel">
<div className="panel-title">权限组({groups.length})</div>
{groups.map(group => (
<button
key={group.id}
type="button"
className={`perm-list-item${group.id === selectedId ? ' active' : ''}`}
onClick={() => select(group.id)}
>
<span className="perm-list-name">
<ShieldIcon size={14} /> {group.name}
{group.is_system ? <i className="perm-tag">系统</i> : null}
</span>
<span className="perm-list-meta">
{group.permissions.length} 项权限 · {group.roles.length} 个角色
</span>
</button>
))}
</aside>
{/* ---------------- 右:编辑区 ---------------- */}
<section className="panel perm-editor">
{!draft ? (
<p className="muted-note">左侧选择一个权限组,或新建一个。</p>
) : (
<>
<div className="panel-title">
{draft.name}
{draft.is_system ? <i className="perm-tag">系统内置 · 不可删除</i> : null}
</div>
<div className="perm-basic">
<div className="form-group">
<label>名称</label>
<input type="text" value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} />
</div>
<div className="form-group">
<label>说明</label>
<input type="text" value={draft.description} placeholder="这个组大致管什么" onChange={event => setDraft({ ...draft, description: event.target.value })} />
</div>
</div>
<div className="perm-section-title">权限范围</div>
<div className="perm-modules">
{catalog.groups.map(module => {
const keys = module.items.map(item => item.key)
const checkedCount = keys.filter(key => draft.permissions.includes(key)).length
const allChecked = checkedCount === keys.length
return (
<div className="perm-module" key={module.title}>
<label className="perm-module-head">
<input
type="checkbox"
checked={allChecked}
ref={element => { if (element) element.indeterminate = checkedCount > 0 && !allChecked }}
onChange={event => toggleModulePermissions(module, event.target.checked)}
/>
<b>{module.title}</b>
<span className="perm-module-count">{checkedCount}/{keys.length}</span>
</label>
<div className="perm-module-items">
{module.items.map(item => (
<label className="perm-item" key={item.key}>
<input
type="checkbox"
checked={draft.permissions.includes(item.key)}
onChange={() => togglePermission(item.key)}
/>
<span>{item.label}</span>
</label>
))}
</div>
</div>
)
})}
</div>
<div className="perm-section-title">角色指派(放进本组的角色即获得上面的权限)</div>
<div className="perm-roles">
{catalog.roles.map(role => {
const owner = roleGroupMap.get(role.value)
const here = draft.roles.includes(role.value) || owner === draft.id
const elsewhere = owner && owner !== draft.id
const otherName = elsewhere ? groups.find(group => group.id === owner)?.name : null
return (
<label className={`perm-role${here ? ' checked' : ''}`} key={role.value}>
<input type="checkbox" checked={!!here} onChange={() => toggleRole(role.value)} />
<span>
<b>{role.label}</b>
{otherName ? <small>当前在「{otherName}」,勾选将改派到本组</small> : <small>{here ? '已属于本组' : '未指派'}</small>}
</span>
</label>
)
})}
</div>
<div className="perm-actions">
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
<SaveIcon size={15} /> {busy ? '保存中…' : '保存权限组'}
</button>
{!draft.is_system && (
<button type="button" className="btn btn-danger" onClick={removeGroup} disabled={busy}>
<TrashIcon size={14} /> 删除该组
</button>
)}
</div>
</>
)}
</section>
</div>
</div>
)
}
export default PermissionGroups
+7 -3
View File
@@ -1,11 +1,15 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
/**
* PI 合同管理(Proforma Invoice)
* 数据来源:happy 后端 /api/pi-contracts(P1 后端合并阶段开放)
*/
function PiContractManagement() {
const { user } = useAuth()
const canEdit = hasPermission(user, 'contract.edit')
const [contracts, setContracts] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
@@ -31,7 +35,7 @@ function PiContractManagement() {
<div className="page-container">
<div className="page-header">
<h1 className="page-title">PI 合同管理</h1>
<button className="btn btn-primary">+ 新建 PI</button>
{canEdit && <button className="btn btn-primary">+ 新建 PI</button>}
</div>
<div className="toolbar">
@@ -62,7 +66,7 @@ function PiContractManagement() {
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
@@ -81,7 +85,7 @@ function PiContractManagement() {
<td><span className={`status-badge status-${c.status}`}>{statusText(c.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">生成单据</button>
{canEdit && <button className="btn-link">生成单据</button>}
</td>
</tr>
))
+484 -77
View File
@@ -1,13 +1,23 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
import {
CartIcon,
ImageIcon,
TrashIcon,
PencilIcon,
CloseIcon,
PlusCircleIcon
PlusCircleIcon,
ArrowLeftIcon,
UploadIcon,
CopyIcon,
FingerprintIcon
} from '../components/Icons'
import SearchSelect, { SearchField } from '../components/SearchSelect'
import PackagingPanel from '../components/PackagingPanel'
import { SkuThumb } from '../components/SkuThumb'
import { makeMountId, pushLayer, goBackOne, popLayers, useLayerRestore } from '../hooks/useLayerHistory'
import { useAuth, hasPermission } from '../auth'
/**
* 产品管理(原生 React 版)
@@ -15,11 +25,18 @@ import {
* 由 SKU Manager 的「产品查询」视图迁移而来,功能对齐:
* 检索 / 供应商与待核对筛选 / 反选批量 / 产品详情(货号×参数对比) / 产品与货号编辑
* / 图片管理 / 小推车。数据统一经 /api/sku/* 同源代理,禁止硬编码 SKU 端口。
*
* 两级浮层(产品详情抽屉 → 编辑货号)都挂进了浏览器历史,
* 所以鼠标侧键 / Alt+← / 系统返回手势都能逐级退回,详见 hooks/useLayerHistory.js。
*/
const API = '/api/sku'
const ATTR_LIST_ID = 'sku-attr-names'
/* 两级浮层在历史条目里的键名(各自读取,互不干扰) */
const LAYER_PANEL = 'tmsProductPanel'
const LAYER_GROUP_MODE = 'tmsGroupMode'
/* ------------------------------ 格式化工具 ------------------------------ */
const money = (n) => (n == null ? '—' : Number(n).toFixed(2))
@@ -51,8 +68,9 @@ const thumbSrc = (filename) => `/product-images/${encodeURIComponent(filename)}`
/* ------------------------------ 小部件 ------------------------------ */
/** 关键:图片文件名来自后端,统一走同源代理地址,不拼 3300 */
function Thumb({ filename, count, size = 38 }) {
/** 关键:图片文件名来自后端,统一走同源地址 /product-images/*,不拼端口。
* 悬浮可弹出大图预览(sku 传进去只为预览卡上能标出货号)。 */
function Thumb({ filename, count, size = 38, sku }) {
const style = { width: size, height: size }
if (!filename) {
return (
@@ -61,13 +79,16 @@ function Thumb({ filename, count, size = 38 }) {
</span>
)
}
if (count > 1) {
return (
<span className="thumb-wrap" style={style}>
<img className="thumb" style={style} src={thumbSrc(filename)} alt="" loading="lazy" />
{count > 1 && <span className="thumb-badge">{count}</span>}
<SkuThumb sku={sku} filename={filename} size={size} />
<span className="thumb-badge">{count}</span>
</span>
)
}
return <SkuThumb sku={sku} filename={filename} size={size} />
}
/** 参数编辑器:一组「参数名 / 值」,支持增删 */
function AttrEditor({ attrs, onChange }) {
@@ -110,32 +131,76 @@ function AttrEditor({ attrs, onChange }) {
)
}
/** 供应商下拉:空值 = 未指定 */
/** 主营类目取前两段当副标题,方便在候选里区分同名/近名供应商 */
function categoryHint(text, take = 2) {
const parts = String(text || '').split(/[、,,;;/|\s]+/).filter(Boolean)
return parts.slice(0, take).join(' / ')
}
/** 供应商候选:把「未指定」与真实档案统一成一组下拉项 */
function supplierOptionList(suppliers, noneLabel = '未指定供应商') {
return [
{ value: '', label: noneLabel },
...suppliers.map((s) => ({
value: String(s.id),
label: s.name,
hint: categoryHint(s.main_categories)
}))
]
}
/** 供应商下拉:空值 = 未指定;供应商几百家,必须能打字搜 */
function SupplierSelect({ value, onChange, suppliers, style }) {
const options = useMemo(() => supplierOptionList(suppliers), [suppliers])
return (
<select value={value ?? ''} onChange={onChange} style={style}>
<option value="">未指定供应商</option>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<SearchSelect
value={value ?? ''}
onChange={onChange}
options={options}
placeholder="未指定供应商"
searchPlaceholder="输入供应商名 / 主营类目搜索…"
emptyText="没有匹配的供应商"
clearable
style={style}
/>
)
}
/* ------------------------------ 抽屉外壳 ------------------------------ */
function Drawer({ wide, title, sub, onClose, headerExtra, footer, children }) {
/**
* 通用抽屉。
* onBack 给「有下一层」的场景用,会在标题左侧出现一个返回键
* (与鼠标侧键等价,都走浏览器历史,见 useLayerHistory)。
* onEscape Esc 的行为。有下一层时应该"退一层",没有时才"关抽屉"。
*/
function Drawer({ wide, title, sub, onBack, onEscape, onClose, headerExtra, footer, children }) {
// 用 ref 存回调,键盘监听只挂一次;否则每次重渲染都要重新绑定
const escRef = useRef(onEscape || onClose)
escRef.current = onEscape || onClose
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose() }
const onKey = (e) => { if (e.key === 'Escape') escRef.current() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
}, [])
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className={`drawer${wide ? ' drawer-wide' : ''}`} role="dialog" aria-modal="true">
<div className="drawer-header">
{onBack && (
<button
type="button"
className="drawer-back"
onClick={onBack}
title="返回上一层(鼠标侧键 / Alt + ← 同样可用)"
>
<ArrowLeftIcon size={15} />
<span>返回</span>
</button>
)}
<div className="drawer-head-main">
<h2 className="drawer-title">{title}</h2>
{sub && <p className="drawer-sub">{sub}</p>}
@@ -156,7 +221,7 @@ function Drawer({ wide, title, sub, onClose, headerExtra, footer, children }) {
/* ============================ 产品详情抽屉 ============================ */
function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
function GroupDrawer({ mountId, groupId, highlightSku, suppliers, onClose, onChanged, onModeChange, canEdit }) {
const [data, setData] = useState(null)
const [mode, setMode] = useState({ kind: 'view' })
const [loading, setLoading] = useState(true)
@@ -164,6 +229,31 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
const [lightbox, setLightbox] = useState('')
const [busy, setBusy] = useState(false)
/* 勾选要加入小推车的货号,以及"已经在车里"的货号 —— 后者让勾选框能显示成不可重复添加 */
const [picked, setPicked] = useState(() => new Set())
const [cartSkus, setCartSkus] = useState(() => new Set())
const [cartBusy, setCartBusy] = useState(false)
const [cartNotice, setCartNotice] = useState('')
/* 把当前编辑态报给父级:父级据此算出「本页现在压了几层历史」,
关抽屉时才能精确退回去(层数不能从历史里读,见 hooks/useLayerHistory.js) */
useEffect(() => { onModeChange?.(mode) }, [mode, onModeChange])
/* 编辑态挂进浏览器历史:顶部返回键、鼠标侧键、Alt + ←、系统返回手势走的是同一条路 */
const openMode = useCallback((next) => {
pushLayer(mountId, LAYER_GROUP_MODE, next)
setMode(next)
}, [mountId])
const backOne = useCallback(() => {
// 有本页的层就走历史(状态由 popstate 回写),没有才直接收 —— 兜底,正常不会走到
if (!goBackOne(mountId)) setMode({ kind: 'view' })
}, [mountId])
useLayerRestore(mountId, LAYER_GROUP_MODE, (snapshot) => {
setMode(snapshot && snapshot.kind ? snapshot : { kind: 'view' })
}, '/products')
const load = useCallback(async () => {
setLoading(true)
setError('')
@@ -196,6 +286,75 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
return cols
}, [items])
/* 货号一多(一个产品几十个货号)也要能搜:货号 / 规格 / 参数都能命中 */
const [itemQuery, setItemQuery] = useState('')
const visibleItems = useMemo(() => {
const kw = itemQuery.trim().toLowerCase()
if (!kw) return items
return items.filter((it) => [
it.display_sku,
it.sku,
it.spec,
...(it.attributes || []).map((a) => `${a.name} ${a.value}`)
].filter(Boolean).join(' ').toLowerCase().includes(kw))
}, [items, itemQuery])
/* --------------------------- 货号勾选 / 加入小推车 --------------------------- */
const loadCart = useCallback(async () => {
try {
const { data } = await axios.get(`${API}/cart`)
setCartSkus(new Set((Array.isArray(data) ? data : []).map((r) => r.sku)))
} catch { /* 小推车读不到不影响看货号,勾选时再加也不迟 */ }
}, [])
useEffect(() => { loadCart() }, [loadCart])
const togglePick = (sku) => {
setPicked((prev) => {
const next = new Set(prev)
if (next.has(sku)) next.delete(sku)
else next.add(sku)
return next
})
}
/* 全选 / 反选只作用于「当前筛出来的货号」,否则搜完再全选会勾到看不见的货号 */
const pickableVisible = useMemo(
() => visibleItems.filter((it) => !cartSkus.has(it.sku)),
[visibleItems, cartSkus]
)
const allVisiblePicked = pickableVisible.length > 0 && pickableVisible.every((it) => picked.has(it.sku))
const someVisiblePicked = pickableVisible.some((it) => picked.has(it.sku)) && !allVisiblePicked
const toggleAllVisible = () => {
setPicked((prev) => {
const next = new Set(prev)
if (allVisiblePicked) pickableVisible.forEach((it) => next.delete(it.sku))
else pickableVisible.forEach((it) => next.add(it.sku))
return next
})
}
const addPickedToCart = async () => {
if (!picked.size || cartBusy) return
const skus = [...picked]
setCartBusy(true)
setCartNotice('')
setError('')
try {
const { data } = await axios.post(`${API}/cart`, { skus })
setCartSkus((prev) => new Set([...prev, ...skus]))
setPicked(new Set())
setCartNotice(data.added ? `已加入 ${data.added} 个货号到小推车` : '选中的货号都已经在小推车里了')
onChanged?.()
} catch (e) {
setError(e?.response?.data?.error || `加入小推车失败:${e.message}`)
} finally {
setCartBusy(false)
}
}
const uploadImages = async (e) => {
const files = Array.from(e.target.files || [])
if (!files.length) return
@@ -263,10 +422,12 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
if (mode.kind === 'view') {
headerExtra = (
<button className="btn btn-sm" onClick={() => setMode({ kind: 'editGroup' })}>
canEdit && (
<button className="btn btn-sm" onClick={() => openMode({ kind: 'editGroup' })}>
<PencilIcon size={14} /> 编辑产品
</button>
)
)
body = (
<>
@@ -292,10 +453,44 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
<div className="panel">
<div className="panel-title">货号与参数</div>
{items.length > 1 && (
<div className="panel-search-row">
<SearchField size="sm" value={itemQuery} onChange={setItemQuery} placeholder="搜货号 / 规格 / 参数…" />
{itemQuery !== '' && <span className="secondary">{visibleItems.length} / {items.length} 个货号</span>}
</div>
)}
{cartNotice && <div className="notice-inline notice-ok" style={{ marginBottom: 10 }}>{cartNotice}</div>}
{picked.size > 0 && (
<div className="batch-bar">
<span>已选 <b>{picked.size}</b> 个货号</span>
<button className="btn btn-sm" onClick={() => setPicked(new Set(pickableVisible.map((it) => it.sku)))}>
全选当前 {pickableVisible.length} 个
</button>
<button className="btn btn-sm" onClick={() => setPicked(new Set())}>取消选择</button>
<span style={{ flex: 1 }} />
{canEdit && (
<button className="btn btn-sm btn-primary" onClick={addPickedToCart} disabled={cartBusy}>
<CartIcon size={14} /> {cartBusy ? '加入中…' : '加入小推车'}
</button>
)}
</div>
)}
<div className="table-container" style={{ maxHeight: 380 }}>
<table className="data-table">
<thead>
<tr>
<th style={{ width: 42 }}>
<input
type="checkbox"
checked={allVisiblePicked}
ref={(el) => { if (el) el.indeterminate = someVisiblePicked }}
onChange={toggleAllVisible}
disabled={pickableVisible.length === 0}
title="全选 / 取消全选当前货号"
/>
</th>
<th>货号</th>
<th>规格</th>
<th style={{ textAlign: 'right' }}>采购价</th>
@@ -305,18 +500,51 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr><td colSpan={5 + attrCols.length} className="secondary" style={{ textAlign: 'center' }}>还没有货号</td></tr>
) : items.map((it) => {
{visibleItems.length === 0 ? (
<tr><td colSpan={6 + attrCols.length} className="secondary" style={{ textAlign: 'center' }}>
{items.length === 0 ? '还没有货号' : '没有匹配的货号'}
</td></tr>
) : visibleItems.map((it) => {
const attrMap = {}
for (const a of it.attributes || []) attrMap[a.name] = a.value
const inCart = cartSkus.has(it.sku)
return (
<tr
key={it.sku}
className={highlightSku && it.sku === highlightSku ? 'row-hit' : ''}
onClick={() => setMode({ kind: 'editItem', sku: it.sku })}
onClick={() => openMode({ kind: 'editItem', sku: it.sku })}
>
<td className="mono bold">{it.display_sku || it.sku}</td>
{/* 勾选框只用来挑「加入小推车」,点它不该顺手进编辑页,所以掐掉冒泡 */}
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={inCart || picked.has(it.sku)}
disabled={inCart}
onChange={() => togglePick(it.sku)}
title={inCart ? '已在小推车里' : '勾选后可加入小推车'}
/>
</td>
<td className="mono bold">
<span className="sku-cell">
<SkuThumb sku={it.sku} filename={it.thumb} size={30} />
<span className="sku-cell-text">
{it.display_sku || it.sku}
{it.duplicated_from && (
<span className="pill pill-copy" title={`上传时货号 ${it.duplicated_from} 已存在,系统自动建了这个副本`}>
<CopyIcon size={11} /> 副本
</span>
)}
{inCart && <span className="pill pill-ok">已在车</span>}
</span>
</span>
{/* 谁传的:产品库是组织共享的,来源必须一眼看得见 */}
<span
className={`sku-uploader${it.uploader ? '' : ' is-quiet'}`}
title={it.uploader ? `由 ${it.uploader} 上传` : '这条是留痕功能上线前录入的,没有上传者信息'}
>
<UploadIcon size={11} /> {it.uploader || '来源未记录'}
</span>
</td>
<td className="secondary">{it.spec || '—'}</td>
<td className="number" style={{ textAlign: 'right' }}>
<span className="unit">¥</span>{money(it.price)}
@@ -324,7 +552,7 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
</td>
<td className="number" style={{ textAlign: 'right' }}>{it.moq ?? '—'}</td>
{attrCols.map((c) => <td key={c} className="secondary">{attrMap[c] || '—'}</td>)}
<td><button className="btn btn-sm">编辑</button></td>
<td>{canEdit && <button className="btn btn-sm">编辑</button>}</td>
</tr>
)
})}
@@ -332,9 +560,11 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
</table>
</div>
<div style={{ marginTop: 11 }}>
<button className="btn btn-sm" onClick={() => setMode({ kind: 'newItem' })}>
{canEdit && (
<button className="btn btn-sm" onClick={() => openMode({ kind: 'newItem' })}>
<PlusCircleIcon size={14} /> 新增货号
</button>
)}
</div>
</div>
@@ -350,20 +580,24 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
/>
{img.is_primary === 1 && <span className="gallery-flag">封面</span>}
<div className="gallery-actions">
{img.is_primary !== 1 && (
{img.is_primary !== 1 && canEdit && (
<button className="btn btn-sm" onClick={() => setPrimary(img.id)}>设封面</button>
)}
{canEdit && (
<button className="btn btn-sm btn-danger" onClick={() => removeImage(img.id)}>
<TrashIcon size={13} />
</button>
)}
</div>
</div>
))}
{canEdit && (
<label className="add-image">
<PlusCircleIcon size={18} />
<span>{busy ? '上传中…' : '添加图片'}</span>
<input type="file" accept="image/*" multiple onChange={uploadImages} disabled={busy} />
</label>
)}
</div>
</div>
</>
@@ -376,8 +610,9 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
group={g}
groupAttributes={groupAttributes}
suppliers={suppliers}
onCancel={() => setMode({ kind: 'view' })}
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
onCancel={backOne}
onSaved={async () => { backOne(); await refresh() }}
canEdit={canEdit}
/>
)
} else if (mode.kind === 'newItem') {
@@ -388,8 +623,18 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
groupId={groupId}
group={g}
items={items}
onCancel={() => setMode({ kind: 'view' })}
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
onCancel={backOne}
canEdit={canEdit}
onSaved={async (info) => {
backOne()
await refresh()
/* 撞号时后端不会再报错,而是落成副本 —— 得明说一句,否则用户会以为写错了货号 */
if (info?.duplicated_from) {
setCartNotice(`货号 ${info.duplicated_from} 已存在,已自动建成副本「${info.display_sku}」,并记下上传者`)
} else {
setCartNotice('货号已新增,并记下了上传者')
}
}}
/>
)
} else if (mode.kind === 'editItem') {
@@ -400,16 +645,30 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
sku={mode.sku}
groupId={groupId}
suppliers={suppliers}
onBack={() => setMode({ kind: 'view' })}
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
onBack={backOne}
onSaved={async () => { backOne(); await refresh() }}
onDeleted={async () => { onClose(); onChanged?.() }}
canEdit={canEdit}
/>
)
}
/* 在子层(编辑产品 / 新增货号 / 编辑货号)时,标题左侧出现返回键;
Esc 也退一层,退到最外层再按一次才关抽屉 —— 免得改了一半直接被关掉 */
const inSubLayer = mode.kind !== 'view'
return (
<>
<Drawer wide title={title} sub={sub} onClose={onClose} headerExtra={headerExtra} footer={footer}>
<Drawer
wide
title={title}
sub={sub}
onBack={inSubLayer ? backOne : undefined}
onEscape={inSubLayer ? backOne : onClose}
onClose={onClose}
headerExtra={headerExtra}
footer={footer}
>
{body}
</Drawer>
{lightbox && (
@@ -423,7 +682,7 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
/* ---------------------------- 产品编辑表单 ---------------------------- */
function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved, canEdit }) {
const [form, setForm] = useState({
name: group.name || '',
brand: group.brand || '',
@@ -479,7 +738,7 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
</div>
<div className="form-group">
<label>供应商</label>
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(e) => set('supplier_id', e.target.value)} />
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(v) => set('supplier_id', v)} />
</div>
<div className="form-group">
<label>产品说明</label>
@@ -491,9 +750,11 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
</div>
<div className="form-actions">
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
)}
<button className="btn" onClick={onCancel} disabled={saving}>取消</button>
</div>
</div>
@@ -502,7 +763,7 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
/* ---------------------------- 货号新增表单 ---------------------------- */
function ItemForm({ groupId, group, items, onCancel, onSaved }) {
function ItemForm({ groupId, group, items, onCancel, onSaved, canEdit }) {
const [form, setForm] = useState({ sku: '', spec: '', price: '', moq: '', description: '' })
const [attrs, setAttrs] = useState([])
const [error, setError] = useState('')
@@ -527,8 +788,7 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
description: form.description,
name: group.name,
attributes: attrs.filter((a) => a.name.trim())
})
await onSaved()
}).then(({ data }) => onSaved(data))
} catch (e) {
setError(e?.response?.data?.error || `创建失败:${e.message}`)
} finally {
@@ -568,12 +828,18 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
{items.length > 0 && (
<div className="form-group">
<label>从现有货号复制参数</label>
<select defaultValue="" onChange={(e) => copyFrom(e.target.value)}>
<option value="">不复制</option>
{items.map((it) => (
<option key={it.sku} value={it.sku}>{it.display_sku || it.sku}</option>
))}
</select>
<SearchSelect
value=""
onChange={(v) => { if (v) copyFrom(v) }}
options={items.map((it) => ({
value: it.sku,
label: it.display_sku || it.sku,
hint: it.spec || ''
}))}
placeholder="不复制"
searchPlaceholder="输入货号 / 规格搜索…"
emptyText="没有匹配的货号"
/>
</div>
)}
<div className="form-group">
@@ -582,9 +848,11 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
</div>
<div className="form-actions">
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '创建中…' : '创建'}
</button>
)}
<button className="btn" onClick={onCancel} disabled={saving}>取消</button>
</div>
</div>
@@ -593,7 +861,7 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
/* ---------------------------- 货号编辑器 ---------------------------- */
function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted, canEdit }) {
const [product, setProduct] = useState(null)
const [history, setHistory] = useState([])
const [groupChoices, setGroupChoices] = useState([])
@@ -700,6 +968,18 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
<div className="panel-title">编辑货号 {product.display_sku || product.sku}</div>
{error && <div className="notice-inline">{error}</div>}
{/* 溯源条:产品库是组织共享的,谁传的、是不是撞号生成的副本必须一眼看得见 */}
<div className="provenance">
{product.uploader
? <span className="provenance-item"><UploadIcon size={13} /> 上传者 <b>{product.uploader}</b></span>
: <span className="provenance-item is-quiet" title="这条产品是在留痕功能上线前录入的"><UploadIcon size={13} /> 上传者未记录</span>}
{product.duplicated_from && (
<span className="provenance-item is-copy" title={`上传时货号 ${product.duplicated_from} 已存在,系统自动建了这个副本`}>
<CopyIcon size={13} /> 同货号副本 · 复制自 <b>{product.duplicated_from}</b>
</span>
)}
</div>
<div className="form-row">
<div className="form-group">
<label>采购价{priceEditable ? '' : '(合同锁定)'}</label>
@@ -733,17 +1013,24 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
<div className="form-group">
<label>所属产品</label>
<select value={form.group_id} onChange={(e) => set('group_id', e.target.value)}>
{groupChoices.map((gc) => (
<option key={gc.id} value={gc.id}>
{gc.name}({gc.item_count} 个货号)
</option>
))}
{String(product.group_id) && !groupChoices.some((gc) => String(gc.id) === String(product.group_id)) && (
<option value={product.group_id}>{product.group_name || '当前产品'}</option>
)}
<option value="new">— 独立成新产品 —</option>
</select>
<SearchSelect
value={form.group_id}
onChange={(v) => set('group_id', v)}
options={[
...groupChoices.map((gc) => ({
value: String(gc.id),
label: gc.name,
hint: `${gc.item_count} 个货号`
})),
...(String(product.group_id) && !groupChoices.some((gc) => String(gc.id) === String(product.group_id))
? [{ value: String(product.group_id), label: product.group_name || '当前产品' }]
: []),
{ value: 'new', label: '— 独立成新产品 —' }
]}
placeholder="选择所属产品"
searchPlaceholder="输入产品名搜索…"
emptyText="没有匹配的产品"
/>
</div>
<div className="form-group">
@@ -752,13 +1039,17 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
</div>
<div className="form-actions">
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
)}
<button className="btn" onClick={onBack} disabled={saving}>返回</button>
{canEdit && (
<button className="btn btn-danger" onClick={remove} disabled={saving}>
<TrashIcon size={14} /> 删除货号
</button>
)}
</div>
</div>
@@ -771,6 +1062,13 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
</div>
)}
{/* 包装信息:结构化录入单品/内盒/外箱/重量,并据此推算装柜量。
数据存在 happy 自己的库(product_packaging),与 SKU 服务的价格数据分开维护。 */}
<div className="panel">
<div className="panel-title">包装信息</div>
<PackagingPanel sku={product.sku} canEdit={canEdit} />
</div>
<div className="panel">
<div className="panel-title">调价记录</div>
{history.length === 0 ? (
@@ -792,6 +1090,13 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
{h.source_contract ? ` · ${h.source_contract}` : ''}
{h.supplier_name ? ` · ${h.supplier_name}` : ''}
</div>
{/* 谁改的价:每次调价都要能追到操作人 */}
<div className="timeline-op">
<FingerprintIcon size={12} />
{h.changed_by
? <>操作人 <b>{h.changed_by}</b></>
: <span className="is-quiet">操作人未记录</span>}
</div>
</div>
</div>
))}
@@ -804,7 +1109,7 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
/* ---------------------------- 新增产品抽屉 ---------------------------- */
function NewProductDrawer({ suppliers, onClose, onCreated }) {
function NewProductDrawer({ suppliers, onClose, onCreated, canEdit }) {
const [form, setForm] = useState({
name: '', brand: '', category: '', supplier_id: '', description: '',
sku: '', spec: '', price: '', moq: '', itemDesc: ''
@@ -835,7 +1140,7 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
attributes: attrs.filter((a) => a.name.trim())
}
})
onCreated?.(data.group_id)
onCreated?.(data)
} catch (e) {
setError(e?.response?.data?.error || `创建失败:${e.message}`)
} finally {
@@ -850,9 +1155,11 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
onClose={onClose}
footer={
<>
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '创建中…' : '创建'}
</button>
)}
<button className="btn" onClick={onClose} disabled={saving}>取消</button>
</>
}
@@ -877,7 +1184,7 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
</div>
<div className="form-group">
<label>供应商</label>
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(e) => set('supplier_id', e.target.value)} />
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(v) => set('supplier_id', v)} />
</div>
<div className="form-group">
<label>产品说明</label>
@@ -922,7 +1229,8 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
/* ------------------------------ 小推车抽屉 ------------------------------ */
function CartDrawer({ onClose, onChanged }) {
function CartDrawer({ onClose, onChanged, canEdit, canQuoteEdit }) {
const navigate = useNavigate()
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -991,10 +1299,15 @@ function CartDrawer({ onClose, onChanged }) {
{noPrice > 0 && <span className="secondary" style={{ marginLeft: 10 }}>{noPrice} 个货号库里没有采购价</span>}
</span>
<span style={{ flex: 1 }} />
<button className="btn btn-sm btn-danger" onClick={clear}>清空</button>
<button className="btn btn-primary" onClick={() => window.alert('汇总报价下一步接入:需要先定「从 CRM 选客户 / 自动带出单证字段」的方案,方案定了我就把报价单那步补上。')}>
{canEdit && <button className="btn btn-sm btn-danger" onClick={clear}>清空</button>}
{/* 这里刻意不走 onClose(那会触发 history.go)。
直接切路由,产品页整体卸载、抽屉一并消失即可;
留在历史里的那条层条目靠 mountId 认得出来,回头不会冒出幽灵抽屉。 */}
{canQuoteEdit && (
<button className="btn btn-primary" onClick={() => navigate('/quotes/new')}>
汇总报价 →
</button>
)}
</>
) : null
}
@@ -1035,17 +1348,35 @@ function CartDrawer({ onClose, onChanged }) {
type="number"
min="1"
step="1"
data-cart-qty=""
placeholder={r.moq ? String(r.moq) : ''}
defaultValue={r.qty ?? ''}
onBlur={(e) => {
if (String(r.qty ?? '') !== e.target.value) setQty(r.sku, e.target.value)
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
e.preventDefault()
// 回车 = 存下这一行并跳到下一行的数量框,连续录入不用碰鼠标。
// 焦点一移开,上面的 onBlur 就会把当前值写回去。
const list = e.currentTarget.closest('.cart-list')
const inputs = list ? Array.from(list.querySelectorAll('input[data-cart-qty]')) : []
const next = inputs[inputs.indexOf(e.currentTarget) + 1]
if (next) {
next.focus()
next.select() // 选中已有数字,直接输入即可覆盖
next.scrollIntoView({ block: 'nearest' })
} else {
e.currentTarget.blur() // 最后一行:回车收尾
}
}}
/>
</label>
{canEdit && (
<button className="btn btn-sm btn-icon btn-danger" title="移出小推车" onClick={() => removeRow(r.sku)}>
<CloseIcon size={14} />
</button>
)}
</div>
)
})}
@@ -1063,6 +1394,10 @@ function ProductManagement() {
const [supplierFilter, setSupplierFilter] = useState('')
const [onlyLow, setOnlyLow] = useState(false)
const { user } = useAuth()
const canEdit = hasPermission(user, 'product.edit')
const canQuoteEdit = hasPermission(user, 'quote.edit')
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [notice, setNotice] = useState('')
@@ -1072,8 +1407,60 @@ function ProductManagement() {
const [attrNames, setAttrNames] = useState([])
const [cartCount, setCartCount] = useState(0)
/* 筛选用的供应商候选:多两个「全部 / 未分类」的伪选项 */
const supplierFilterOptions = useMemo(() => [
{ value: '', label: '全部供应商' },
{ value: 'none', label: '未分类(无供应商)' },
...suppliers.map((s) => ({
value: String(s.id),
label: s.name,
hint: `${s.sku_count ?? 0} 个货号${categoryHint(s.main_categories, 1) ? ` · ${categoryHint(s.main_categories, 1)}` : ''}`
}))
], [suppliers])
const [selected, setSelected] = useState(() => new Set())
const [panel, setPanel] = useState(null)
/* 抽屉里的编辑态(由 GroupDrawer 报上来),用来算历史层数 */
const [groupMode, setGroupMode] = useState({ kind: 'view' })
/* 本次挂载的令牌:用来认出上一次挂载残留在历史里的孤儿条目 */
const [mountId] = useState(() => makeMountId())
/* 本页现在压了几层历史:抽屉 1 层,抽屉里的编辑态再加 1 层。
刻意用「当前 UI 状态」算,而不是从 history.state 里读计数器 ——
否则用户开着抽屉点侧栏跳走再回来,会读到上一次挂载留下的脏层数,
关抽屉时多退几条,直接把人退出应用。 */
const layerCount = panel ? (groupMode && groupMode.kind !== 'view' ? 2 : 1) : 0
/* 抽屉也挂进浏览器历史:侧键返回时先收抽屉,而不是直接跳走 */
const openPanel = useCallback((next) => {
pushLayer(mountId, LAYER_PANEL, next)
setGroupMode({ kind: 'view' })
setPanel(next)
}, [mountId])
const closePanel = useCallback(() => {
// 一次退掉本页所有层(抽屉 + 抽屉里的编辑态),回到进入产品页时那条历史
if (layerCount > 0) popLayers(layerCount)
else setPanel(null)
setGroupMode({ kind: 'view' })
}, [layerCount])
useLayerRestore(mountId, LAYER_PANEL, (snapshot) => {
setPanel(snapshot && snapshot.kind ? snapshot : null)
if (!snapshot || !snapshot.kind) setGroupMode({ kind: 'view' })
}, '/products')
/* 挂载时擦掉当前历史条目上「上一次挂载」留下的浮层快照。
场景:开着抽屉直接点侧栏跳走,抽屉随组件卸载消失,但压进历史的条目还在;
回到本页时那条历史就是孤儿,留着会让 history.state 一直显示"抽屉开着",
也让反复按返回时多逛几条无效历史。擦成干净条目即可(那些条目是我们自己 push 的,
不带路由 state,清成 null 不影响 React Router)。 */
useEffect(() => {
const s = window.history.state
if (s && typeof s.__tmsDepth === 'number' && s.__tmsMount !== mountId) {
window.history.replaceState(null, '')
}
}, [mountId])
/* 关键词防抖 250ms */
useEffect(() => {
@@ -1193,12 +1580,16 @@ function ProductManagement() {
<p className="page-sub">按货号、品名、规格或参数检索产品与采购价</p>
</div>
<div className="header-actions">
<button className="btn" onClick={() => setPanel({ kind: 'cart' })}>
{canEdit && (
<button className="btn" onClick={() => openPanel({ kind: 'cart' })}>
<CartIcon size={16} /> 小推车{cartCount > 0 && <span className="cart-badge">{cartCount}</span>}
</button>
<button className="btn btn-primary" onClick={() => setPanel({ kind: 'new' })}>
)}
{canEdit && (
<button className="btn btn-primary" onClick={() => openPanel({ kind: 'new' })}>
<PlusCircleIcon size={16} /> 新增产品
</button>
)}
</div>
</div>
@@ -1212,11 +1603,15 @@ function ProductManagement() {
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<select value={supplierFilter} onChange={(e) => setSupplierFilter(e.target.value)}>
<option value="">全部供应商</option>
<option value="none">未分类(无供应商)</option>
{suppliers.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<SearchSelect
className="toolbar-supplier-filter"
value={supplierFilter}
onChange={(v) => setSupplierFilter(v)}
options={supplierFilterOptions}
placeholder="全部供应商"
searchPlaceholder="输入供应商名 / 主营类目搜索…"
emptyText="没有匹配的供应商"
/>
<label className="check-inline">
<input type="checkbox" checked={onlyLow} onChange={(e) => setOnlyLow(e.target.checked)} />
只看待核对
@@ -1232,8 +1627,8 @@ function ProductManagement() {
<button className="btn btn-sm" onClick={invert}>反选</button>
<button className="btn btn-sm" onClick={clearSel}>取消选择</button>
<span style={{ flex: 1 }} />
<button className="btn btn-sm btn-primary" onClick={addToCart}>加入小推车</button>
<button className="btn btn-sm btn-danger" onClick={deleteSelected}>删除</button>
{canEdit && <button className="btn btn-sm btn-primary" onClick={addToCart}>加入小推车</button>}
{canEdit && <button className="btn btn-sm btn-danger" onClick={deleteSelected}>删除</button>}
</div>
)}
@@ -1282,7 +1677,7 @@ function ProductManagement() {
<tr
key={g.id}
className={picked ? 'row-picked' : ''}
onClick={() => setPanel({ kind: 'group', groupId: g.id, highlightSku: g.matched_items?.[0]?.sku || null })}
onClick={() => openPanel({ kind: 'group', groupId: g.id, highlightSku: g.matched_items?.[0]?.sku || null })}
>
<td onClick={(e) => e.stopPropagation()}>
<input
@@ -1319,24 +1714,36 @@ function ProductManagement() {
{panel?.kind === 'group' && (
<GroupDrawer
mountId={mountId}
groupId={panel.groupId}
highlightSku={panel.highlightSku}
suppliers={suppliers}
onClose={() => setPanel(null)}
onClose={closePanel}
onChanged={afterMutate}
onModeChange={setGroupMode}
canEdit={canEdit}
/>
)}
{panel?.kind === 'new' && (
<NewProductDrawer
suppliers={suppliers}
onClose={() => setPanel(null)}
onCreated={() => { setPanel(null); afterMutate() }}
onClose={closePanel}
canEdit={canEdit}
onCreated={(info) => {
closePanel()
afterMutate()
/* 撞号时后端自动落副本,明确告诉用户新建到手的到底是什么货号 */
if (info?.duplicated_from) {
setNoticeOk(true)
setNotice(`货号 ${info.duplicated_from} 已存在,已自动建成副本「${info.display_sku}」,并记下上传者`)
}
}}
/>
)}
{panel?.kind === 'cart' && (
<CartDrawer onClose={() => { setPanel(null); refreshCart() }} onChanged={refreshCart} />
<CartDrawer onClose={() => { closePanel(); refreshCart() }} onChanged={refreshCart} canEdit={canEdit} canQuoteEdit={canQuoteEdit} />
)}
</div>
)
+345
View File
@@ -0,0 +1,345 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
SaveIcon, KeyIcon, CameraIcon, LogoutIcon, BuildingIcon, ShieldIcon,
EyeIcon, EyeOffIcon, UserIcon
} from '../components/Icons'
import { useAuth, roleLabel } from '../auth'
import { setPageTitle } from '../branding'
import { setUnsavedGuard, clearUnsavedGuard } from '../unsavedGuard'
/* 把用户对象裁成表单结构 */
function toForm(user) {
return {
display_name: user?.display_name || '',
position: user?.position || '',
email: user?.email || '',
phone: user?.phone || '',
address: user?.address || '',
org_id: user?.org?.id ? String(user.org.id) : '',
role: user?.role || 'sales_rep',
avatar: user?.avatar || ''
}
}
/** 头像压到 160×160 的 JPEG,直接存 dataURL —— 本地阶段省掉一套文件接口与存储 */
function compressImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(new Error('读取图片失败'))
reader.onload = () => {
const image = new Image()
image.onerror = () => reject(new Error('这个图片格式浏览器读不了'))
image.onload = () => {
const size = 160
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const scale = Math.max(size / image.width, size / image.height)
const width = image.width * scale
const height = image.height * scale
context.drawImage(image, (size - width) / 2, (size - height) / 2, width, height)
resolve(canvas.toDataURL('image/jpeg', 0.85))
}
image.src = reader.result
}
reader.readAsDataURL(file)
})
}
function ProfilePage() {
const { user, setUser, logout } = useAuth()
const navigate = useNavigate()
const [form, setForm] = useState(() => toForm(user))
const [saved, setSaved] = useState(() => toForm(user))
const [organizations, setOrganizations] = useState([])
const [roles, setRoles] = useState([])
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const fileRef = useRef(null)
const [password, setPassword] = useState({ current: '', next: '', confirm: '' })
const [passwordBusy, setPasswordBusy] = useState(false)
const [passwordNotice, setPasswordNotice] = useState(null)
const [reveal, setReveal] = useState({ current: false, next: false, confirm: false })
const dirty = useMemo(
() => JSON.stringify(form) !== JSON.stringify(saved),
[form, saved]
)
/* guard 要读到最新值,用 ref 兜住(注册只在挂载/卸载时做一次) */
const dirtyRef = useRef(dirty)
const formRef = useRef(form)
const savedRef = useRef(saved)
dirtyRef.current = dirty
formRef.current = form
savedRef.current = saved
useEffect(() => {
setPageTitle('个人资料')
}, [])
useEffect(() => {
let alive = true
;(async () => {
try {
const [orgRes, roleRes] = await Promise.all([
axios.get('/api/organizations'),
axios.get('/api/roles')
])
if (!alive) return
setOrganizations(orgRes.data.organizations || [])
setRoles(roleRes.data.roles || [])
} catch { /* 下拉拉不到不影响主流程 */ }
})()
return () => { alive = false }
}, [])
/* 保存动作抽成 callback,供页面按钮与「离开前保存」共用 */
const submitSave = useCallback(async () => {
const payload = formRef.current
const response = await axios.patch('/api/me', {
display_name: payload.display_name,
position: payload.position,
email: payload.email,
phone: payload.phone,
address: payload.address,
avatar: payload.avatar,
role: payload.role,
org_id: payload.org_id === '' ? undefined : Number(payload.org_id)
})
const updated = response.data.user
setUser(updated)
setSaved(toForm(updated))
return updated
}, [setUser])
/* 让外壳在跳转前能问一句「要不要先保存」 */
useEffect(() => {
setUnsavedGuard({
isDirty: () => dirtyRef.current,
save: () => submitSave(),
discard: () => setForm(savedRef.current)
})
return () => clearUnsavedGuard()
}, [submitSave])
const save = async () => {
if (!form.display_name.trim()) { setNotice({ type: 'error', text: '显示名不能为空' }); return }
setBusy(true)
setNotice(null)
try {
await submitSave()
setNotice({ type: 'ok', text: '个人资料已保存' })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '保存失败' })
} finally {
setBusy(false)
}
}
const pickAvatar = async event => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!/^image\//.test(file.type)) { setNotice({ type: 'error', text: '请选择图片文件' }); return }
try {
const dataUrl = await compressImage(file)
setForm(current => ({ ...current, avatar: dataUrl }))
setNotice({ type: 'info', text: '头像已选择,记得点右上角「保存个人资料」' })
} catch (error) {
setNotice({ type: 'error', text: error.message })
}
}
const submitPassword = async event => {
event.preventDefault()
if (!password.current) { setPasswordNotice({ type: 'error', text: '请输入当前密码' }); return }
if (!password.next) { setPasswordNotice({ type: 'error', text: '请输入新密码' }); return }
if (password.next.length < 6) { setPasswordNotice({ type: 'error', text: '新密码至少 6 位' }); return }
if (password.next !== password.confirm) { setPasswordNotice({ type: 'error', text: '两次输入的新密码不一致' }); return }
setPasswordBusy(true)
setPasswordNotice(null)
try {
await axios.patch('/api/me/password', {
current_password: password.current,
new_password: password.next,
confirm_password: password.confirm
})
setPassword({ current: '', next: '', confirm: '' })
setPasswordNotice({ type: 'ok', text: '密码已修改,其它设备上的登录已失效' })
} catch (error) {
setPasswordNotice({ type: 'error', text: error?.response?.data?.error || '修改密码失败' })
} finally {
setPasswordBusy(false)
}
}
const signOut = async () => {
if (dirty) setForm(saved)
await logout()
navigate('/login', { replace: true })
}
const setField = (key, value) => setForm(current => ({ ...current, [key]: value }))
const currentRole = roles.find(item => item.value === form.role)
const orgName = organizations.find(item => String(item.id) === String(form.org_id))?.name || user?.org?.name || '—'
const avatarText = (form.display_name || user?.username || '?').slice(0, 1).toUpperCase()
return (
<div className="page-container profile-page">
<div className="profile-head">
<div className="profile-head-left">
<span className="profile-avatar">
{form.avatar ? <img src={form.avatar} alt="" /> : avatarText}
</span>
<div className="profile-head-text">
<h1>{form.display_name || user?.username}</h1>
<p>{form.position || roleLabel(form.role)} · {orgName}</p>
</div>
</div>
<div className="profile-head-actions">
{dirty && <span className="profile-dirty-hint">● 有未保存的改动</span>}
<button type="button" className="btn btn-secondary" disabled={!dirty} onClick={() => { setForm(saved); setNotice(null) }}>
放弃修改
</button>
<button type="button" className="btn btn-primary" disabled={!dirty || busy} onClick={save}>
<SaveIcon size={15} /> {busy ? '保存中…' : '保存个人资料'}
</button>
</div>
</div>
{notice && (
<p className={`profile-notice ${notice.type}`}>{notice.text}</p>
)}
<div className="profile-grid">
{/* ---------------- 基本资料 ---------------- */}
<section className="panel">
<div className="panel-title"><UserIcon size={15} /> 基本资料</div>
<div className="profile-avatar-row">
<span className="profile-avatar profile-avatar-sm">
{form.avatar ? <img src={form.avatar} alt="" /> : avatarText}
</span>
<div className="profile-avatar-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => fileRef.current?.click()}>
<CameraIcon size={14} /> 更换头像
</button>
{form.avatar && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setField('avatar', '')}>
移除
</button>
)}
<p className="field-hint">支持 jpg / png,会自动裁成方形并压缩</p>
</div>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={pickAvatar} />
</div>
<div className="profile-fields">
<div className="form-group">
<label>显示名 <i className="required">*</i></label>
<input type="text" value={form.display_name} onChange={event => setField('display_name', event.target.value)} placeholder="在系统里显示的名字" />
</div>
<div className="form-group">
<label>职位</label>
<input type="text" value={form.position} onChange={event => setField('position', event.target.value)} placeholder="如:外贸业务主管" />
</div>
<div className="form-group">
<label>联系邮箱</label>
<input type="email" value={form.email} onChange={event => setField('email', event.target.value)} placeholder="[email protected]" />
</div>
<div className="form-group">
<label>电话</label>
<input type="text" value={form.phone} onChange={event => setField('phone', event.target.value)} placeholder="手机或座机" />
</div>
<div className="form-group">
<label><BuildingIcon size={13} /> 所属组织</label>
<select value={form.org_id} onChange={event => setField('org_id', event.target.value)}>
<option value="">未归属</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}{org.member_count ? `(${org.member_count} 人)` : ''}</option>
))}
</select>
</div>
<div className="form-group">
<label><ShieldIcon size={13} /> 权限角色</label>
<select value={form.role} onChange={event => setField('role', event.target.value)}>
{roles.map(role => (
<option key={role.value} value={role.value}>{role.label}</option>
))}
</select>
<p className="field-hint">
角色本身不带权限,它决定你进入哪个权限组 →
<b> {currentRole?.group_name || '尚未指派权限组'}</b>
</p>
</div>
<div className="form-group profile-field-full">
<label>地址</label>
<textarea rows="2" value={form.address} onChange={event => setField('address', event.target.value)} placeholder="公司或办公地址" />
</div>
</div>
<div className="profile-meta">
<span>账号:<b>{user?.username}</b></span>
<span>权限组:<b>{user?.permission_group?.name || '—'}</b></span>
<span>可访问权限点:<b>{user?.permissions?.length || 0}</b></span>
<span>上次登录:<b>{user?.last_login_at ? new Date(user.last_login_at).toLocaleString('zh-CN') : '—'}</b></span>
</div>
</section>
{/* ---------------- 修改密码 ---------------- */}
<section className="panel">
<div className="panel-title"><KeyIcon size={15} /> 修改密码</div>
<form className="profile-fields profile-fields-single" onSubmit={submitPassword}>
{[
['current', '当前密码', '请输入当前密码'],
['next', '新密码', '至少 6 位'],
['confirm', '再次输入新密码', '与上面保持一致']
].map(([key, label, placeholder]) => (
<div className="form-group" key={key}>
<label>{label}</label>
<div className="password-input">
<input
type={reveal[key] ? 'text' : 'password'}
autoComplete={key === 'current' ? 'current-password' : 'new-password'}
value={password[key]}
onChange={event => setPassword(current => ({ ...current, [key]: event.target.value }))}
placeholder={placeholder}
/>
<button
type="button"
className="password-reveal"
onClick={() => setReveal(current => ({ ...current, [key]: !current[key] }))}
title={reveal[key] ? '隐藏' : '显示'}
aria-label={reveal[key] ? '隐藏密码' : '显示密码'}
>
{reveal[key] ? <EyeOffIcon size={15} /> : <EyeIcon size={15} />}
</button>
</div>
</div>
))}
{passwordNotice && <p className={`profile-notice ${passwordNotice.type}`}>{passwordNotice.text}</p>}
<button type="submit" className="btn btn-primary" disabled={passwordBusy}>
<KeyIcon size={15} /> {passwordBusy ? '提交中…' : '确认修改密码'}
</button>
<p className="field-hint">改密码需要当前密码验证;修改成功后其它设备上的登录会被强制退出。</p>
</form>
<div className="profile-signout">
<button type="button" className="btn btn-secondary" onClick={signOut}>
<LogoutIcon size={15} /> 退出登录
</button>
</div>
</section>
</div>
</div>
)
}
export default ProfilePage
+223 -89
View File
@@ -1,108 +1,242 @@
import React, { useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
TargetIcon, RefreshIcon, UsersIcon, PlusCircleIcon, SearchIcon, ArrowLeftIcon
} from '../components/Icons'
import { SkeletonTableRows } from '../components/Skeleton'
import { setPageTitle } from '../branding'
function ProspectManagement() {
const [formData, setFormData] = useState({
productKeywords: '',
countries: '',
customerTypes: '',
excludeKeywords: '',
resultLimit: 100
})
const TABS = [
{ key: 'pending', label: '未成交', hint: '还在谈的客户' },
{ key: 'won', label: '已成交', hint: '已经下单的客户' },
{ key: 'lost', label: '已流失', hint: '明确放弃或长期无响应的客户' },
{ key: 'all', label: '全部', hint: '不分成败的所有开发客户' }
]
const handleSubmit = async (e) => {
e.preventDefault()
alert('自动获客功能开发中...')
const DEAL_LABELS = { pending: '未成交', won: '已成交', lost: '已流失' }
function formatDate(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10)
return date.toLocaleDateString('zh-CN')
}
function ProspectManagement() {
const navigate = useNavigate()
const [tab, setTab] = useState('pending')
const [keyword, setKeyword] = useState('')
const [items, setItems] = useState([])
const [stats, setStats] = useState({ pending: 0, won: 0, lost: 0 })
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState(null)
const [notice, setNotice] = useState(null)
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await axios.get('/api/prospects', {
params: { deal_status: tab === 'all' ? '' : tab, q: keyword.trim() }
})
setItems(data.items || [])
setStats(data.stats || { pending: 0, won: 0, lost: 0 })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '加载开发客户失败' })
} finally {
setLoading(false)
}
}, [tab, keyword])
useEffect(() => {
setPageTitle('新客开发')
}, [])
useEffect(() => {
const timer = window.setTimeout(load, keyword ? 260 : 0)
return () => window.clearTimeout(timer)
}, [load, keyword])
/* 标记成交/流失:乐观更新 —— 先按新状态改界面(不匹配当前标签页的行直接消失、
顶部计数同步增减),后台失败再把行和计数整体放回原状。 */
const mark = async (item, dealStatus) => {
setBusyId(item.id)
const previous = item.deal_status
const index = items.findIndex(row => row.id === item.id)
const nextStats = { ...stats }
if (previous && nextStats[previous] !== undefined) {
nextStats[previous] = Math.max(0, (nextStats[previous] || 0) - 1)
}
nextStats[dealStatus] = (nextStats[dealStatus] || 0) + 1
setStats(nextStats)
if (tab !== 'all' && dealStatus !== tab) {
setItems(prev => prev.filter(row => row.id !== item.id))
} else {
setItems(prev => prev.map(row => (row.id === item.id ? { ...row, deal_status: dealStatus } : row)))
}
setNotice({ type: 'ok', text: `「${item.company}」已标记为${DEAL_LABELS[dealStatus]}` })
try {
await axios.patch(`/api/prospects/${item.id}`, { deal_status: dealStatus })
} catch (error) {
setStats(stats)
setItems(prev => {
if (prev.some(row => row.id === item.id)) return prev.map(row => (row.id === item.id ? item : row))
const next = [...prev]
next.splice(Math.min(Math.max(index, 0), next.length), 0, item)
return next
})
setNotice({ type: 'error', text: error?.response?.data?.error || '标记失败,已恢复原状态' })
} finally {
setBusyId(null)
}
}
const openCreate = () => navigate('/customers')
return (
<div className="page-container">
<div className="page-container prospect-page">
<div className="page-header">
<h1 className="page-title">新客开发(自动获客)</h1>
<div>
<h1 className="page-title">新客开发</h1>
<p className="page-subtitle">
上半部分是自动获客线索池(待上线);下半部分管理开发结果 —— 谈成的进「已成交」,谈不动的进「已流失」,其余留在「未成交」继续跟。
和「我的客户」同一份数据、同一套归属:只显示你自己名下的客户。
</p>
</div>
<div className="prospect-container">
{/* 搜索条件 */}
<div className="card">
<h2>搜索条件</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>产品关键词</label>
<input
type="text"
placeholder="如:LED灯具, 五金工具"
value={formData.productKeywords}
onChange={e => setFormData({...formData, productKeywords: e.target.value})}
/>
</div>
<div className="form-group">
<label>目标国家</label>
<input
type="text"
placeholder="如:德国, 英国, 美国"
value={formData.countries}
onChange={e => setFormData({...formData, countries: e.target.value})}
/>
</div>
<div className="form-group">
<label>客户类型</label>
<input
type="text"
placeholder="如:经销商, 系统集成商, OEM"
value={formData.customerTypes}
onChange={e => setFormData({...formData, customerTypes: e.target.value})}
/>
</div>
<div className="form-group">
<label>排除关键词</label>
<input
type="text"
placeholder="如:招聘, 二手, 学校"
value={formData.excludeKeywords}
onChange={e => setFormData({...formData, excludeKeywords: e.target.value})}
/>
</div>
<div className="form-group">
<label>结果数量</label>
<input
type="number"
value={formData.resultLimit}
onChange={e => setFormData({...formData, resultLimit: e.target.value})}
/>
</div>
<button type="submit" className="btn-primary">
开始搜索
<button type="button" className="btn btn-secondary" onClick={openCreate}>
<PlusCircleIcon size={15} /> 手工新增客户
</button>
</form>
</div>
{/* 搜索结果 */}
<div className="card">
<h2>搜索结果</h2>
<div className="empty-state">
请配置搜索条件并点击"开始搜索"
</div>
{/* ---------------- 自动获客(线索池,待上线) ---------------- */}
<section className="panel">
<div className="panel-title"><TargetIcon size={15} /> 自动获客线索池</div>
<div className="prospect-pool-note">
<p>
线索池将按「产品关键词 + 目标国家 + 客户类型」从公开数据源(GLEIF、Companies House、搜索引擎)批量抓取,
再由 AI 做去重与意向评分,合格线索一键转成客户。
</p>
<span className="pill pill-warn">待上线</span>
</div>
</section>
{/* ---------------- 开发结果管理 ---------------- */}
<section className="panel">
<div className="panel-title"><UsersIcon size={15} /> 开发结果管理</div>
<div className="prospect-stats">
<button type="button" className={`prospect-stat${tab === 'pending' ? ' active' : ''}`} onClick={() => setTab('pending')}>
<b>{stats.pending}</b>
<span>未成交</span>
</button>
<button type="button" className={`prospect-stat${tab === 'won' ? ' active' : ''}`} onClick={() => setTab('won')}>
<b className="is-won">{stats.won}</b>
<span>已成交</span>
</button>
<button type="button" className={`prospect-stat${tab === 'lost' ? ' active' : ''}`} onClick={() => setTab('lost')}>
<b className="is-lost">{stats.lost}</b>
<span>已流失</span>
</button>
</div>
<div className="info-box">
<h3>功能说明</h3>
<p>自动获客功能将帮助你从多个数据源自动搜索潜在客户:</p>
<ul>
<li>公开公司注册数据(GLEIF、Companies House)</li>
<li>网页搜索引擎</li>
<li>AI 智能解析和评分</li>
<li>自动去重和质量过滤</li>
</ul>
<p><strong>此功能将在后续版本中完善。</strong></p>
<div className="prospect-toolbar">
<div className="prospect-tabs" role="tablist">
{TABS.map(item => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={tab === item.key}
className={`prospect-tab${tab === item.key ? ' active' : ''}`}
title={item.hint}
onClick={() => setTab(item.key)}
>
{item.label}
</button>
))}
</div>
<label className="prospect-search">
<SearchIcon size={14} />
<input
type="text"
value={keyword}
onChange={event => setKeyword(event.target.value)}
placeholder="搜索公司 / 国家 / 邮箱"
aria-label="搜索开发客户"
/>
</label>
<button type="button" className="btn btn-secondary btn-sm" onClick={load} disabled={loading}>
<RefreshIcon size={14} /> 刷新
</button>
</div>
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
<div className="data-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>公司</th>
<th>国家</th>
<th className="num">联系人</th>
<th className="num">报价单</th>
<th>最后跟进</th>
<th>成交状态</th>
<th className="num">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<SkeletonTableRows rows={5} widths={[2.2, 0.8, 0.6, 0.6, 1, 1, 1.4]} />
) : items.length === 0 ? (
<tr><td colSpan="7" className="empty-state">
{keyword ? '没有匹配的客户' : `「${TABS.find(item => item.key === tab)?.label}」下暂无客户`}
</td></tr>
) : items.map(item => (
<tr key={item.id}>
<td>
<strong>{item.company}</strong>
{item.company_en ? <div className="muted-note">{item.company_en}</div> : null}
</td>
<td>{item.country || '—'}</td>
<td className="num">{item.contact_count || 0}</td>
<td className="num">{item.quote_count || 0}</td>
<td>{formatDate(item.last_follow_at)}</td>
<td>
<span className={`pill ${item.deal_status === 'won' ? 'pill-ok' : item.deal_status === 'lost' ? 'pill-danger' : 'pill-mute'}`}>
{DEAL_LABELS[item.deal_status] || '未成交'}
</span>
</td>
<td className="num">
<div className="prospect-actions">
{item.deal_status !== 'won' && (
<button type="button" className="btn btn-secondary btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'won')}>
标记成交
</button>
)}
{item.deal_status !== 'lost' && (
<button type="button" className="btn btn-secondary btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'lost')}>
标记流失
</button>
)}
{item.deal_status !== 'pending' && (
<button type="button" className="btn btn-ghost btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'pending')}>
退回跟进
</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => navigate(`/customers/${item.id}`)}>
<ArrowLeftIcon size={13} /> 查看
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)
}
export default ProspectManagement
@@ -1,11 +1,15 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
/**
* 采购单管理
* 数据来源:happy 后端 /api/purchase-orders(P1 后端合并阶段开放)
*/
function PurchaseOrderManagement() {
const { user } = useAuth()
const canEdit = hasPermission(user, 'purchase.edit')
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
@@ -31,7 +35,7 @@ function PurchaseOrderManagement() {
<div className="page-container">
<div className="page-header">
<h1 className="page-title">采购单管理</h1>
<button className="btn btn-primary">+ 新建采购单</button>
{canEdit && <button className="btn btn-primary">+ 新建采购单</button>}
</div>
<div className="toolbar">
@@ -62,7 +66,7 @@ function PurchaseOrderManagement() {
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
@@ -81,7 +85,7 @@ function PurchaseOrderManagement() {
<td><span className={`status-badge status-${o.status}`}>{statusText(o.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
{canEdit && <button className="btn-link">编辑</button>}
</td>
</tr>
))
+807
View File
@@ -0,0 +1,807 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
import {
ArrowLeftIcon, CartIcon, CheckIcon, CloseIcon, PlusCircleIcon, QuoteIcon, SearchIcon, UsersIcon
} from '../components/Icons'
import { buildCurrencyOptions, crossRate, currencyLabel, formatRate, parseAmount } from '../currencies'
import { SkuThumb } from '../components/SkuThumb'
import { useAuth, hasPermission } from '../auth'
/**
* 汇总报价 —— 把小推车里的货号变成一张正式报价单。
*
* 页面顺序刻意做成「先客户、后货、再条款」:
* 报价单是发给客户的凭证,抬头必须先立住(公司、地址、联系方式),
* 后面所有条款都是围绕这个抬头展开的。
*
* 单价口径与询盘报价完全一致:采购价 ÷ 汇率 × (1 + 加价率),
* 汇率是「1 报价币 = ? 采购币(CNY)」。不要在这里另立一套算法。
*/
const API = '/api'
const SKU_API = '/api/sku'
function today() {
return new Date().toISOString().slice(0, 10)
}
/** 从某个日期键(YYYY-MM-DD)往后推 n 天 */
function addDays(dateKey, days) {
const base = Date.parse(dateKey)
if (!Number.isFinite(base)) return ''
return new Date(base + Number(days) * 86400000).toISOString().slice(0, 10)
}
/* 条款选项 —— 都是外贸报价里最常用的几种,不做成自由填空 */
const PAYMENT_METHODS = ['T/T', 'L/C', 'D/P', 'D/A', 'O/A', 'Western Union', 'PayPal']
const DEPOSIT_OPTIONS = [30, 50, 70, 100]
const DELIVERY_METHODS = ['EXW', 'FOB', 'CIF', 'CFR', 'DDP', 'DAP', 'FCA']
function money(value, digits = 2) {
const num = Number(value)
if (!Number.isFinite(num)) return '—'
return num.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
}
/* ------------------------------------------------------------------ */
/* 客户搜索选择 */
/* ------------------------------------------------------------------ */
function CustomerPicker({ customers, selectedId, onSelect, onCreateNew, canEditCustomer }) {
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const boxRef = useRef(null)
const inputRef = useRef(null)
useEffect(() => {
if (!open) { setKeyword(''); return }
const timer = window.setTimeout(() => inputRef.current?.focus(), 0)
const onPointerDown = event => {
if (!boxRef.current?.contains(event.target)) setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => { window.clearTimeout(timer); document.removeEventListener('mousedown', onPointerDown) }
}, [open])
const filtered = useMemo(() => {
const term = keyword.trim().toLowerCase()
if (!term) return customers.slice(0, 60)
return customers
.filter(c => [c.company, c.company_en, c.country, c.email, c.phone].filter(Boolean).join(' ').toLowerCase().includes(term))
.slice(0, 60)
}, [customers, keyword])
const selected = customers.find(c => c.id === selectedId)
return (
<div className="quote-customer-picker" ref={boxRef}>
<button type="button" className={`quote-customer-btn${open ? ' is-open' : ''}`} onClick={() => setOpen(v => !v)}>
<UsersIcon size={17} />
<span className="quote-customer-btn-text">
{selected
? <><b>{selected.company}</b><em>{[selected.country, selected.email].filter(Boolean).join(' · ') || '未填联系方式'}</em></>
: '选择客户'}
</span>
<span className="quote-customer-btn-hint">搜索 / 更换</span>
</button>
{open && (
<div className="quote-customer-pop">
<div className="quote-customer-search">
<SearchIcon size={15} />
<input
ref={inputRef}
type="text"
value={keyword}
onChange={event => setKeyword(event.target.value)}
placeholder="搜索公司 / 国家 / 邮箱"
aria-label="搜索客户"
/>
</div>
<div className="quote-customer-list">
{filtered.length === 0 ? (
<div className="quote-customer-empty">
<p>{customers.length === 0 ? '还没有客户' : '没有匹配的客户'}</p>
{canEditCustomer && (
<button type="button" className="btn btn-sm btn-primary" onClick={() => { setOpen(false); onCreateNew(keyword.trim()) }}>
<PlusCircleIcon size={14} /> 新建客户
</button>
)}
</div>
) : (
<>
{filtered.map(c => (
<button
type="button"
key={c.id}
className={`quote-customer-item${c.id === selectedId ? ' active' : ''}`}
onClick={() => { onSelect(c.id); setOpen(false) }}
>
<b>{c.company}</b>
<span>{[c.country, c.email, c.phone].filter(Boolean).join(' · ') || '未填联系方式'}</span>
</button>
))}
{canEditCustomer && (
<button type="button" className="quote-customer-new" onClick={() => { setOpen(false); onCreateNew(keyword.trim()) }}>
<PlusCircleIcon size={14} /> 找不到?新建客户
</button>
)}
</>
)}
</div>
</div>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* 客户抬头(自动带出地址与联系方式) */
/* ------------------------------------------------------------------ */
function CustomerCard({ customer, contacts, onChangeCustomer }) {
if (!customer) return null
const primary = contacts?.[0]
const rows = [
['公司名称', customer.company],
['英文名称', customer.company_en],
['国家 / 地区', customer.country],
['地址', customer.address],
['邮箱', customer.email],
['电话', customer.phone],
['联系人', primary ? [primary.name, primary.position].filter(Boolean).join(' · ') : null],
['联系人邮箱', primary?.email],
['联系人电话', primary?.phone]
]
return (
<div className="quote-head">
<div className="quote-head-top">
<div>
<span className="quote-head-company">{customer.company}</span>
{customer.company_en && <span className="quote-head-company-en">{customer.company_en}</span>}
</div>
<div className="quote-head-actions">
{customer.status !== 'active' && <span className="pill pill-warn">未启用</span>}
<button type="button" className="btn btn-sm" onClick={onChangeCustomer}>更换客户</button>
</div>
</div>
<dl className="quote-head-grid">
{rows.map(([label, value]) => (
<div className="quote-head-cell" key={label}>
<dt>{label}</dt>
<dd className={value ? '' : 'is-empty'}>{value || '未填写'}</dd>
</div>
))}
</dl>
<p className="quote-head-note">以上信息取自客户档案,开单时会一并写入报价单抬头。</p>
</div>
)
}
/* ------------------------------------------------------------------ */
/* 新建客户 */
/* ------------------------------------------------------------------ */
function NewCustomerModal({ initialCompany, onClose, onCreated, canEditCustomer }) {
const [form, setForm] = useState({
company: initialCompany || '',
company_en: '',
country: '',
address: '',
email: '',
phone: '',
contact_name: '',
contact_position: '',
notes: ''
})
const [saving, setSaving] = useState('')
const [error, setError] = useState('')
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
/**
* @param {'active'|'inactive'} status
* active = 保存并启用(立刻可作为正式客户继续跟进)
* inactive = 仅保存(先建档,还没确认要不要投入跟进)
*/
const save = async status => {
if (!form.company.trim()) { setError('公司名称不能为空'); return }
setError('')
setSaving(status)
try {
const { data } = await axios.post(`${API}/customers`, {
company: form.company.trim(),
company_en: form.company_en.trim() || null,
country: form.country.trim() || null,
address: form.address.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
notes: form.notes.trim() || null,
source: '报价单',
status
})
/* 填了联系人姓名就顺手建一条主联系人 —— 报价单抬头要用 */
if (form.contact_name.trim()) {
try {
await axios.post(`${API}/customers/${data.id}/contacts`, {
name: form.contact_name.trim(),
position: form.contact_position.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
is_primary: true
})
} catch { /* 联系人建失败不该拦住客户本身 */ }
}
onCreated(data.id)
} catch (err) {
setError(err?.response?.data?.error || err.message)
setSaving('')
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>新建客户</h2>
<button type="button" className="modal-close" onClick={onClose}><CloseIcon size={16} /></button>
</div>
<div className="modal-body">
{error && <div className="notice-inline">{error}</div>}
<div className="form-group">
<label>公司名称 *</label>
<input type="text" value={form.company} onChange={e => set('company', e.target.value)} placeholder="客户公司全称" />
</div>
<div className="form-row">
<div className="form-group">
<label>英文名称</label>
<input type="text" value={form.company_en} onChange={e => set('company_en', e.target.value)} />
</div>
<div className="form-group">
<label>国家 / 地区</label>
<input type="text" value={form.country} onChange={e => set('country', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>地址</label>
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} placeholder="用于报价单抬头" />
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input type="email" value={form.email} onChange={e => set('email', e.target.value)} />
</div>
<div className="form-group">
<label>电话</label>
<input type="text" value={form.phone} onChange={e => set('phone', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>联系人</label>
<input type="text" value={form.contact_name} onChange={e => set('contact_name', e.target.value)} />
</div>
<div className="form-group">
<label>联系人职务</label>
<input type="text" value={form.contact_position} onChange={e => set('contact_position', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>备注</label>
<textarea rows="2" value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onClose}>取消</button>
<span style={{ flex: 1 }} />
{canEditCustomer && (
<button type="button" className="btn" onClick={() => save('inactive')} disabled={!!saving}>
{saving === 'inactive' ? '保存中…' : '仅保存'}
</button>
)}
{canEditCustomer && (
<button type="button" className="btn btn-primary" onClick={() => save('active')} disabled={!!saving}>
{saving === 'active' ? '保存中…' : '保存并启用'}
</button>
)}
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* 主页面 */
/* ------------------------------------------------------------------ */
function QuoteEditor() {
const navigate = useNavigate()
const { user } = useAuth()
const canEditQuote = hasPermission(user, 'quote.edit')
const canEditCustomer = hasPermission(user, 'customer.edit')
const [cart, setCart] = useState([])
const [customers, setCustomers] = useState([])
const [contacts, setContacts] = useState([])
const [customerId, setCustomerId] = useState(null)
const [selectedCustomer, setSelectedCustomer] = useState(null)
const [rates, setRates] = useState(null)
const [loading, setLoading] = useState(true)
const [newCustomerOpen, setNewCustomerOpen] = useState(false)
const [newCustomerSeed, setNewCustomerSeed] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [header, setHeader] = useState({
pi_no: '',
quote_date: today(),
/* 有效期以「距报价日期多少天」为准,截止日期由它推出来 */
validity_days: '30',
currency: 'USD',
exchange_rate: '',
markup_rate: '25',
payment_method: 'T/T',
deposit_percent: '30',
delivery_method: 'FOB',
delivery_port: 'Shanghai',
order_notes: '',
internal_notes: ''
})
const [qtyOverride, setQtyOverride] = useState({})
const setField = (key, value) => setHeader(current => ({ ...current, [key]: value }))
/* ---------- 初始数据:小推车 + 客户 + 汇率 ---------- */
useEffect(() => {
let alive = true
;(async () => {
try {
const [cartRes, customerRes, fxRes] = await Promise.all([
axios.get(`${SKU_API}/cart`),
axios.get(`${API}/customers`),
axios.get(`${API}/fx/rates`).catch(() => null)
])
if (!alive) return
setCart(Array.isArray(cartRes.data) ? cartRes.data : [])
setCustomers(Array.isArray(customerRes.data) ? customerRes.data : [])
if (fxRes?.data?.ok && fxRes.data.rates) setRates(fxRes.data.rates)
} catch (err) {
if (alive) setError(err?.response?.data?.error || `加载数据失败:${err.message}`)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [])
/* ---------- 换了报价币种就把汇率换成实时值 ---------- */
useEffect(() => {
if (!rates || !header.currency) return
const rate = crossRate(rates, header.currency, 'CNY')
if (!rate) return
setHeader(current => ({ ...current, exchange_rate: String(Number(rate.toFixed(4))) }))
}, [rates, header.currency])
/* ---------- 选中客户后拉它的地址 / 联系人 ---------- */
const loadCustomer = useCallback(async id => {
setCustomerId(id)
try {
const { data } = await axios.get(`${API}/customers/${id}`)
setSelectedCustomer(data.customer)
setContacts(data.contacts || [])
} catch (err) {
setError(err?.response?.data?.error || err.message)
}
}, [])
/* ---------- 明细与单价 ---------- */
const priceOf = useCallback(cost => {
const rate = parseAmount(header.exchange_rate)
const markup = parseAmount(header.markup_rate)
const value = Number(cost)
if (!Number.isFinite(value) || value <= 0 || !rate || rate <= 0) return null
const factor = Number.isFinite(markup) ? markup / 100 : 0
return value / rate * (1 + factor)
}, [header.exchange_rate, header.markup_rate])
const lines = useMemo(() => cart.map(row => {
const cost = row.price == null ? null : Number(row.price)
const rawQty = qtyOverride[row.sku] !== undefined ? qtyOverride[row.sku] : row.qty
const qty = Number(rawQty) || 0
const unit = priceOf(cost)
return {
key: row.sku,
sku: row.display_sku || row.sku,
rawSku: row.sku,
/* 小推车接口带主图文件名,直接带到明细里给缩略图用 */
image: row.image || null,
product_name: row.product_name || row.display_sku || row.sku,
spec: row.spec || null,
cost,
qty,
unit,
total: unit != null && qty > 0 ? unit * qty : null
}
}), [cart, priceOf, qtyOverride])
const grandTotal = lines.reduce((sum, line) => sum + (line.total || 0), 0)
const missingQty = lines.filter(line => !line.qty).length
const missingPrice = lines.filter(line => line.unit == null).length
/* 有效期:天数 → 截止日期。天数给人改,日期给人看,提交时两个都带上 */
const validUntil = useMemo(
() => addDays(header.quote_date, header.validity_days || 0),
[header.quote_date, header.validity_days]
)
/* 分期付款:只有 T/T 走「预付 + 尾款」这套,尾款比例自动补足到 100% */
const isTT = header.payment_method === 'T/T'
const depositPercent = Number(header.deposit_percent)
const splitPayment = isTT && Number.isFinite(depositPercent) && depositPercent > 0 && depositPercent <= 100
const balancePercent = splitPayment ? Number((100 - depositPercent).toFixed(4)) : null
const depositAmount = splitPayment ? grandTotal * depositPercent / 100 : null
const balanceAmount = splitPayment ? grandTotal * (balancePercent || 0) / 100 : null
const currencyOptions = useMemo(
() => buildCurrencyOptions(rates, ['USD', 'CNY', 'EUR', 'GBP', 'HKD', 'JPY']),
[rates]
)
const submit = async () => {
setError('')
setNotice('')
if (!customerId) { setError('请先在顶部选择客户'); return }
if (!lines.length) { setError('小推车是空的,先到产品管理里加入货号'); return }
if (missingQty) { setError(`还有 ${missingQty} 行没填数量`); return }
if (missingPrice) { setError('有明细算不出单价,请检查汇率与加价率是否填齐'); return }
setSaving(true)
try {
const { data } = await axios.post(`${API}/quotes`, {
customer_id: customerId,
currency: header.currency,
quote_date: header.quote_date,
valid_until: validUntil || null,
validity_days: Number(header.validity_days) || null,
pi_no: header.pi_no || null,
payment_method: header.payment_method || null,
deposit_percent: splitPayment ? depositPercent : null,
balance_percent: splitPayment ? balancePercent : null,
delivery_method: header.delivery_method || null,
delivery_port: header.delivery_port || null,
notes: header.order_notes || null,
internal_notes: header.internal_notes || null,
status: 'draft',
items: lines.map(line => ({
sku: line.rawSku,
product_name: line.product_name,
spec: line.spec,
qty: line.qty,
unit_price: Number(line.unit.toFixed(4)),
purchase_price: line.cost,
exchange_rate: parseAmount(header.exchange_rate),
markup_rate: (parseAmount(header.markup_rate) || 0) / 100
}))
})
setNotice(`报价单 ${data.quote.quote_no} 已保存,正在跳转到报价单列表…`)
window.setTimeout(() => navigate('/quotes'), 1100)
} catch (err) {
setError(err?.response?.data?.error || err.message)
setSaving(false)
}
}
if (loading) return <div className="page-container"><div className="loading"><span className="spinner" /> 正在读取小推车与客户…</div></div>
return (
<div className="page-container quote-editor">
<div className="page-header quote-editor-header">
<div className="quote-editor-title">
<button type="button" className="btn btn-sm btn-icon" onClick={() => navigate(-1)} title="返回">
<ArrowLeftIcon size={16} />
</button>
<div>
<h1 className="page-title">汇总报价</h1>
</div>
</div>
<div className="quote-editor-actions">
<span className="secondary">{lines.length} 个货号</span>
{canEditQuote && (
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
<CheckIcon size={15} /> {saving ? '保存中…' : '保存报价单'}
</button>
)}
</div>
</div>
{error && <div className="notice-inline">{error}</div>}
{notice && <div className="notice-inline notice-ok">{notice}</div>}
{/* ---------- ① 客户抬头 ---------- */}
<section className="panel">
<div className="panel-title">客户</div>
{selectedCustomer ? (
<CustomerCard
customer={selectedCustomer}
contacts={contacts}
onChangeCustomer={() => { setSelectedCustomer(null); setCustomerId(null); setContacts([]) }}
/>
) : (
<div className="quote-customer-empty-state">
<p>报价单要发给谁?先选一个客户 —— 选中后会自动带出地址与联系方式。</p>
<CustomerPicker
customers={customers}
selectedId={customerId}
onSelect={loadCustomer}
onCreateNew={seed => { setNewCustomerSeed(seed); setNewCustomerOpen(true) }}
canEditCustomer={canEditCustomer}
/>
</div>
)}
</section>
{/* ---------- ② 报价条件 ---------- */}
<section className="panel">
<div className="panel-title">报价条件</div>
<div className="quote-terms-grid">
<div className="form-group">
<label>PI 号</label>
<input
type="text"
value={header.pi_no}
onChange={e => setField('pi_no', e.target.value)}
placeholder="选填,例如 PI-20260914-01"
/>
</div>
<div className="form-group">
<label>报价日期</label>
<input type="date" value={header.quote_date} onChange={e => setField('quote_date', e.target.value)} />
</div>
<div className="form-group">
<label>报价有效期(距报价日期天数)</label>
<input
type="number" min="1" step="1"
value={header.validity_days}
onChange={e => setField('validity_days', e.target.value)}
placeholder="例如 30"
/>
<span className="field-hint">
{validUntil
? `自 ${header.quote_date} 起 ${header.validity_days} 天,有效期至 ${validUntil}`
: '填一个大于 0 的天数'}
</span>
</div>
<div className="form-group">
<label>报价币种</label>
<select value={header.currency} onChange={e => setField('currency', e.target.value)}>
{currencyOptions.map(option => (
<option key={option.code} value={option.code}>{currencyLabel(option.code)}</option>
))}
</select>
</div>
<div className="form-group">
<label>汇率(1 {header.currency} = ? CNY)</label>
<input
type="text"
inputMode="decimal"
value={header.exchange_rate}
onChange={e => setField('exchange_rate', e.target.value)}
placeholder="自动取实时汇率"
/>
</div>
<div className="form-group">
<label>加价率(%)</label>
<input
type="text"
inputMode="decimal"
value={header.markup_rate}
onChange={e => setField('markup_rate', e.target.value)}
placeholder="例如 25"
/>
</div>
<div className="form-group">
<label>付款方式</label>
<select value={header.payment_method} onChange={e => setField('payment_method', e.target.value)}>
{PAYMENT_METHODS.map(method => <option key={method} value={method}>{method}</option>)}
</select>
</div>
<div className="form-group">
<label>交货方式</label>
<div className="field-inline">
<select value={header.delivery_method} onChange={e => setField('delivery_method', e.target.value)}>
{DELIVERY_METHODS.map(method => <option key={method} value={method}>{method}</option>)}
</select>
<input
type="text"
value={header.delivery_port}
onChange={e => setField('delivery_port', e.target.value)}
placeholder="港口 / 地点,例如 Shanghai"
/>
</div>
</div>
</div>
{/* 选 T/T 才展开分期:预付款点一下定比例,尾款百分比与金额自动算出 */}
{isTT && (
<div className="payment-split">
<div className="payment-split-head">
<span>预付款比例</span>
</div>
<div className="payment-split-options">
{DEPOSIT_OPTIONS.map(percent => (
<button
key={percent}
type="button"
className={`payment-split-btn${depositPercent === percent ? ' active' : ''}`}
onClick={() => setField('deposit_percent', String(percent))}
>
{percent}%
</button>
))}
<input
type="number" min="1" max="100" step="1"
className="payment-split-input"
value={header.deposit_percent}
onChange={e => setField('deposit_percent', e.target.value)}
aria-label="自定义预付款比例"
/>
<span className="payment-split-unit">% 自定义</span>
</div>
<div className="payment-split-result">
{splitPayment ? (
<>
<span>预付款 <b>{depositPercent}%</b><em>{money(depositAmount)} {header.currency}</em></span>
<span>尾款 <b>{balancePercent}%</b><em>{money(balanceAmount)} {header.currency}</em></span>
{balancePercent === 0 && <span className="payment-split-note">全额预付,无尾款</span>}
</>
) : (
<span className="quote-warn">预付款比例请填 1–100 之间的数字</span>
)}
</div>
</div>
)}
<p className="quote-formula">
单价 = 采购价 ÷ 汇率 ×(1 + 加价率),与询盘报价同一套口径。
{rates && header.exchange_rate
? ` 当前按 1 ${header.currency} = ${formatRate(parseAmount(header.exchange_rate) || 0)} CNY 换算。`
: ' 汇率取不到时可以手动填。'}
</p>
</section>
{/* ---------- ③ 报价明细 ---------- */}
<section className="panel">
<div className="panel-title">
报价明细
<span className="panel-title-hint">货号来自小推车,数量可在此微调(不会改动小推车)</span>
</div>
{lines.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><CartIcon size={40} /></div>
<div className="empty-state-title">小推车是空的</div>
<div className="empty-state-hint">先到「产品管理」里勾选货号加入小推车,再回来出报价单。</div>
</div>
) : (
<>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>货号</th>
<th>产品 / 规格</th>
<th className="num">采购价</th>
<th className="num">数量</th>
<th className="num">单价</th>
<th className="num">小计</th>
</tr>
</thead>
<tbody>
{lines.map(line => (
<tr key={line.key} className={line.qty ? '' : 'row-pending'}>
<td className="mono bold">
<span className="sku-cell">
<SkuThumb sku={line.sku} filename={line.image} size={30} />
{line.sku}
</span>
</td>
<td>
<div>{line.product_name}</div>
{line.spec && <div className="secondary">{line.spec}</div>}
</td>
<td className="num">
{line.cost == null ? <span className="secondary">无采购价</span> : <>¥{money(line.cost)}</>}
</td>
<td className="num">
<input
type="number"
min="1"
step="1"
className="quote-qty-input"
value={qtyOverride[line.key] ?? line.qty ?? ''}
placeholder="必填"
onChange={event => setQtyOverride(current => ({ ...current, [line.key]: event.target.value }))}
/>
</td>
<td className="num">
{line.unit == null ? <span className="secondary">—</span> : <b>{money(line.unit, 4)}</b>}
</td>
<td className="num">
{line.total == null ? <span className="secondary">—</span> : `${money(line.total)} ${header.currency}`}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="quote-total-bar">
{missingQty > 0 && <span className="quote-warn">还有 {missingQty} 行没填数量</span>}
{missingPrice > 0 && <span className="quote-warn">{missingPrice} 行算不出单价(汇率或加价率未填)</span>}
<span style={{ flex: 1 }} />
<span className="quote-grand-total">
报价合计 <b>{money(grandTotal)} {header.currency}</b>
</span>
</div>
</>
)}
</section>
{/* ---------- ④ 备注:对外 / 对内分开 ---------- */}
<section className="panel">
<div className="panel-title">备注</div>
<div className="quote-notes-grid">
<div className="form-group">
<label>订单备注 <span className="label-badge">会显示在报价单</span></label>
<textarea
rows="3"
className="quote-notes"
value={header.order_notes}
onChange={e => setField('order_notes', e.target.value)}
placeholder="给客户看的补充说明,例如包装要求、交期、唛头。导出时以 Notes: 开头"
/>
{header.order_notes.trim() && (
<span className="field-hint">报价单上呈现为:Notes: {header.order_notes.trim()}</span>
)}
</div>
<div className="form-group">
<label>系统备注 <span className="label-badge label-badge-internal">仅内部可见</span></label>
<textarea
rows="3"
className="quote-notes"
value={header.internal_notes}
onChange={e => setField('internal_notes', e.target.value)}
placeholder="内部备注:成本底线、客户砍价习惯、验厂情况等。不会出现在报价单上"
/>
<span className="field-hint">系统备注只留在系统里,报价单和导出文件都不带它。</span>
</div>
</div>
</section>
<div className="quote-editor-footer">
<button type="button" className="btn" onClick={() => navigate(-1)}>返回</button>
{canEditQuote && (
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
<QuoteIcon size={15} /> {saving ? '保存中…' : '保存报价单'}
</button>
)}
</div>
{newCustomerOpen && (
<NewCustomerModal
initialCompany={newCustomerSeed}
onClose={() => setNewCustomerOpen(false)}
onCreated={async id => {
setNewCustomerOpen(false)
const { data } = await axios.get(`${API}/customers`)
setCustomers(Array.isArray(data) ? data : [])
await loadCustomer(id)
setNotice('客户已创建,抬头信息已带出')
}}
canEditCustomer={canEditCustomer}
/>
)}
</div>
)
}
export default QuoteEditor
+11 -6
View File
@@ -1,12 +1,17 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
/**
* 客户报价单管理
* 数据来源:happy 后端 /api/quotes(P1 后端合并阶段开放)
* 未接入前保持可用的空态,不做假数据。
* 数据来源:happy 后端 /api/quotes
*/
function QuoteManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canEdit = hasPermission(user, 'quote.edit')
const [quotes, setQuotes] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
@@ -32,7 +37,7 @@ function QuoteManagement() {
<div className="page-container">
<div className="page-header">
<h1 className="page-title">客户报价单管理</h1>
<button className="btn btn-primary">+ 新建报价单</button>
{canEdit && <button className="btn btn-primary" onClick={() => navigate('/quotes/new')}>+ 新建报价单</button>}
</div>
<div className="toolbar">
@@ -63,12 +68,12 @@ function QuoteManagement() {
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<div className="empty-state-title">暂无报价单</div>
<div className="empty-state-hint">报价单接口待接入(P1 后端合并阶段开放)</div>
<div className="empty-state-hint">在「产品管理」里把货号加入小推车,点「汇总报价」就能生成第一张。</div>
</td>
</tr>
) : (
@@ -82,7 +87,7 @@ function QuoteManagement() {
<td><span className={`status-badge status-${q.status}`}>{statusText(q.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">转 PI</button>
{canEdit && <button className="btn-link">转 PI</button>}
</td>
</tr>
))
+47 -14
View File
@@ -1,14 +1,18 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { useAuth, hasPermission } from '../auth'
import { GridIcon, ListIcon } from '../components/Icons'
import { SkeletonCards, SkeletonPanel } from '../components/Skeleton'
import { SearchField } from '../components/SearchSelect'
import { SkuThumb } from '../components/SkuThumb'
/**
* 供应商管理(原生 React 版)
*
* 由 SKU Manager 的「供应商管理」视图迁移而来,功能对齐:
* 卡片列表 · 未分类入口 · 新建/编辑 · 删除 · 详情(联系方式 / 供货清单 / 合同记录)。
* 数据经 /api/sku/* 同源代理访问,禁止硬编码 SKU 的独立端口。
* 数据走 /api/sku/* 同源接口(SKU 模块已并入后端),禁止硬编码独立端口。
*/
const API = '/api/sku'
@@ -26,10 +30,13 @@ function fmtDate(iso, withTime = true) {
}
function splitCats(text) {
return String(text || '')
const list = String(text || '')
.split(/[、,,;;/|\s]+/)
.map((t) => t.trim())
.filter(Boolean)
/* 源数据里同一类目可能重复登记(如「游标卡尺;游标卡尺」),
去重后再渲染,顺带避免 React 因 key 重复而发出警告。 */
return [...new Set(list)]
}
/** 从完整地址里抽出省市,卡片上只显示大致方位 */
@@ -103,6 +110,8 @@ function Kv({ label, value, mono }) {
function SupplierManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canEdit = hasPermission(user, 'supplier.edit')
const [mode, setMode] = useState('list') // list | detail | form
const [suppliers, setSuppliers] = useState([])
@@ -122,6 +131,8 @@ function SupplierManagement() {
const [detail, setDetail] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
// 详情页「供货清单」的即时过滤(一家供应商可能供几十上百个货号)
const [productQuery, setProductQuery] = useState('')
const [form, setForm] = useState(EMPTY_FORM)
const [editingId, setEditingId] = useState(null)
@@ -162,6 +173,7 @@ function SupplierManagement() {
setDetailLoading(true)
setDetail(null)
setNotice('')
setProductQuery('')
try {
const { data } = await axios.get(`${API}/suppliers/${id}`)
setDetail(data)
@@ -243,7 +255,7 @@ function SupplierManagement() {
<h1 className="page-title">供应商管理</h1>
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
</div>
<button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>
{canEdit && <button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>}
</div>
{notice && <div className="notice-inline">{notice}</div>}
@@ -282,7 +294,7 @@ function SupplierManagement() {
</div>
{loading ? (
<div className="loading">加载中…</div>
<SkeletonCards count={8} />
) : suppliers.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🏭</div>
@@ -382,6 +394,11 @@ function SupplierManagement() {
const s = detail?.supplier
const products = detail?.products || []
const contracts = detail?.contracts || []
const pk = productQuery.trim().toLowerCase()
const visibleProducts = pk
? products.filter((p) => [p.display_sku, p.sku, p.group_name, p.name, p.spec]
.filter(Boolean).join(' ').toLowerCase().includes(pk))
: products
return (
<div className="page-container">
@@ -398,14 +415,16 @@ function SupplierManagement() {
</div>
<div className="header-actions">
<button className="btn" onClick={() => { setDetail(null); setMode('list') }}>← 供应商列表</button>
{s && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
{s && canEdit && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
</div>
</div>
{notice && <div className="notice-inline">{notice}</div>}
{detailLoading || !s ? (
<div className="loading">加载中…</div>
<div className="card">
<SkeletonPanel lines={4} />
</div>
) : (
<>
<div className="card">
@@ -428,12 +447,26 @@ function SupplierManagement() {
<div className="card">
<div className="card-header">
<h2>供货清单</h2>
<span className="secondary">{products.length} 条</span>
<span className="secondary">
{productQuery.trim() ? `${visibleProducts.length} / ${products.length} 条` : `${products.length} 条`}
</span>
</div>
{products.length === 0 ? (
{products.length > 1 && (
<div className="panel-search-row">
<SearchField
size="sm"
value={productQuery}
onChange={setProductQuery}
placeholder="搜货号 / 所属产品 / 规格…"
/>
</div>
)}
{visibleProducts.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 12px' }}>
<div className="empty-state-hint">
还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。
{products.length === 0
? '还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。'
: '没有匹配的货号,换个关键词试试。'}
</div>
</div>
) : (
@@ -450,12 +483,10 @@ function SupplierManagement() {
</tr>
</thead>
<tbody>
{products.map((p) => (
{visibleProducts.map((p) => (
<tr key={`${p.sku}-${p.group_id ?? ''}`}>
<td>
{p.thumb
? <img className="thumb" src={`/product-images/${p.thumb}`} alt="" loading="lazy" />
: <span className="thumb thumb-empty" />}
<SkuThumb sku={p.sku} filename={p.thumb} size={34} />
</td>
<td className="mono">{p.display_sku || p.sku}</td>
<td>{p.group_name || p.name || '—'}</td>
@@ -575,10 +606,12 @@ function SupplierManagement() {
</div>
<div className="form-actions">
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
{editingId && (
)}
{editingId && canEdit && (
<button
className="btn btn-danger"
onClick={() => removeSupplier(editingId, editingName)}
+257
View File
@@ -0,0 +1,257 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { ActivityIcon, DownloadIcon, RefreshIcon, SearchIcon, ClockIcon, ShieldIcon } from '../components/Icons'
import { setPageTitle } from '../branding'
/* ---------------------------------------------------------------------------
系统日志(审计轨迹)—— 侧栏「系统管理」下的独立页面。
参考 GoodJob 的系统日志页:四格统计 + 等级/分类筛选 + 搜索 + 导出 + 列表 + 展开详情。
数据源 GET /api/logs(后端按 action 前缀归分类、按 ok 归等级)。
入口与路由都认 audit.view —— 按 Wayne 的口径,仅系统管理员可见。
--------------------------------------------------------------------------- */
const LEVELS = [
{ key: '', label: '全部' },
{ key: 'info', label: '信息' },
{ key: 'success', label: '成功' },
{ key: 'warning', label: '警告' },
{ key: 'error', label: '错误' }
]
const PAGE_SIZE = 50
function fmtTime(value) {
if (!value) return ''
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(value)
}
}
function SystemLogs() {
const [level, setLevel] = useState('')
const [category, setCategory] = useState('')
const [keyword, setKeyword] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const [data, setData] = useState(null)
const [openId, setOpenId] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [exporting, setExporting] = useState(false)
const alive = useRef(true)
useEffect(() => { setPageTitle('系统日志') }, [])
/* 搜索框防抖,避免每敲一个字打一次接口 */
useEffect(() => {
const timer = setTimeout(() => { setSearch(keyword.trim()); setOffset(0) }, 300)
return () => clearTimeout(timer)
}, [keyword])
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const { data: payload } = await axios.get('/api/logs', {
params: { level: level || undefined, category: category || undefined, q: search || undefined, limit: PAGE_SIZE, offset }
})
if (!alive.current) return
setData(payload)
} catch (e) {
if (alive.current) setError(e?.response?.data?.error || e.message)
} finally {
if (alive.current) setLoading(false)
}
}, [level, category, search, offset])
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
useEffect(() => { load() }, [load])
const items = data?.items || []
const stats = data?.stats || { total: 0, today: 0, warning: 0, error: 0 }
const total = data?.total || 0
const rangeText = useMemo(() => {
if (!total) return '暂无记录'
const from = offset + 1
const to = Math.min(offset + PAGE_SIZE, total)
return `第 ${from}–${to} 条 / 共 ${total} 条`
}, [offset, total])
const exportCsv = async () => {
setExporting(true)
setError('')
try {
const response = await axios.get('/api/logs/export', {
params: { level: level || undefined, category: category || undefined, q: search || undefined },
responseType: 'blob'
})
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = `system-logs-${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (e) {
setError(e?.response?.data?.error || '导出失败')
} finally {
setExporting(false)
}
}
return (
<div className="page-container logs-page">
<div className="page-header">
<div>
<h1 className="page-title">系统日志</h1>
<p className="page-subtitle">
账号登录与核心业务操作的审计轨迹,用于追溯与排障。仅系统管理员可见。
{total > 0 && ` 共 ${total} 条记录。`}
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
<RefreshIcon size={14} /> {loading ? '刷新中…' : '刷新'}
</button>
<button type="button" className="btn btn-primary" onClick={exportCsv} disabled={exporting || !total}>
<DownloadIcon size={14} /> {exporting ? '导出中…' : '导出 CSV'}
</button>
</div>
</div>
<div className="log-stats">
<div className="log-stat">
<b>{stats.total}</b>
<span>记录</span>
</div>
<div className="log-stat">
<b>{stats.today}</b>
<span>今日</span>
</div>
<div className="log-stat is-warn">
<b>{stats.warning}</b>
<span>警告</span>
</div>
<div className="log-stat is-err">
<b>{stats.error}</b>
<span>错误</span>
</div>
</div>
<div className="panel">
<div className="log-toolbar">
<div className="log-chips" role="tablist" aria-label="按等级筛选">
{LEVELS.map(item => (
<button
key={item.key || 'all'}
type="button"
role="tab"
aria-selected={level === item.key}
className={`log-chip ${level === item.key ? 'is-active' : ''} ${item.key ? 'is-' + item.key : ''}`}
onClick={() => { setLevel(item.key); setOffset(0) }}
>
{item.label}
</button>
))}
</div>
</div>
<div className="log-toolbar">
<div className="log-chips" role="tablist" aria-label="按分类筛选">
<button
type="button"
role="tab"
aria-selected={category === ''}
className={`log-chip ${category === '' ? 'is-active' : ''}`}
onClick={() => { setCategory(''); setOffset(0) }}
>
全部
</button>
{(data?.categories || []).map(item => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={category === item.key}
className={`log-chip ${category === item.key ? 'is-active' : ''}`}
onClick={() => { setCategory(item.key); setOffset(0) }}
>
{item.label}<small>{item.count}</small>
</button>
))}
</div>
</div>
<div className="log-actions">
<label className="log-search">
<SearchIcon size={15} />
<input
type="search"
value={keyword}
placeholder="搜索操作人 / 动作 / 说明 / IP"
onChange={e => setKeyword(e.target.value)}
/>
</label>
</div>
{error && <p className="log-error"><ShieldIcon size={14} /> {error}</p>}
{!loading && !items.length && (
<div className="log-empty">
<ActivityIcon size={22} />
<strong>暂无日志记录</strong>
<small>登录系统或改动用户、权限、客户、商机与模型接入时,操作会自动记录到这里。</small>
</div>
)}
{!!items.length && (
<ul className="log-list">
{items.map(row => (
<li key={row.id} className={`log-row is-${row.level}`}>
<button
type="button"
className="log-row-main"
aria-expanded={openId === row.id}
onClick={() => setOpenId(openId === row.id ? null : row.id)}
>
<span className={`log-dot is-${row.level}`} aria-hidden="true" />
<span className="log-time"><ClockIcon size={12} />{fmtTime(row.at)}</span>
<span className={`log-badge is-${row.level}`}>{row.ok ? '' : '失败 · '}{row.action_label}</span>
<span className="log-detail">{row.detail || '—'}</span>
<span className="log-actor">{row.username || '—'}</span>
<span className="log-cat">{row.category}</span>
</button>
{openId === row.id && (
<dl className="log-more">
<div><dt>动作标识</dt><dd>{row.action}</dd></div>
<div><dt>操作人</dt><dd>{row.username || '—'}</dd></div>
<div><dt>来源 IP</dt><dd>{row.ip || '—'}</dd></div>
<div><dt>结果</dt><dd>{row.ok ? '成功' : '失败'}</dd></div>
<div className="full"><dt>说明</dt><dd>{row.detail || '—'}</dd></div>
<div className="full"><dt>User-Agent</dt><dd>{row.user_agent || '—'}</dd></div>
</dl>
)}
</li>
))}
</ul>
)}
{(total > PAGE_SIZE || offset > 0) && (
<div className="log-pager">
<span>{rangeText}</span>
<div>
<button type="button" className="btn btn-secondary" disabled={offset === 0 || loading} onClick={() => setOffset(Math.max(offset - PAGE_SIZE, 0))}>上一页</button>
<button type="button" className="btn btn-secondary" disabled={offset + PAGE_SIZE >= total || loading} onClick={() => setOffset(offset + PAGE_SIZE)}>下一页</button>
</div>
</div>
)}
</div>
</div>
)
}
export default SystemLogs
+423
View File
@@ -0,0 +1,423 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import { UserIcon, PlusIcon, RefreshIcon, SaveIcon } from '../components/Icons'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth } from '../auth'
import { setPageTitle } from '../branding'
/**
* 用户管理(user.manage)。
*
* 口径(与权限组管理同一套推导):
* - 角色只是职位标签 + 授权键;用户填角色,角色被指派进某个权限组,即获得该组权限。
* - 所以「改用户角色」就是换权限的唯一入口,页面上要把「该角色现在落在哪个组」亮出来,
* 免得管理员改完角色还以为权限没生效。
* - 停用/改密会踢掉该用户已有会话(后端做),管理员自己的会话保留。
*/
const EMPTY_NEW = {
username: '',
display_name: '',
password: '',
role: 'sales_rep',
org_id: '',
position: '',
email: ''
}
function fmtDate(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function UserManagement() {
const { user: me, refresh } = useAuth()
const [users, setUsers] = useState([])
const [roles, setRoles] = useState([])
const [orgs, setOrgs] = useState([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({ ...EMPTY_NEW })
const [createError, setCreateError] = useState('')
const [editing, setEditing] = useState(null)
const [editForm, setEditForm] = useState(null)
const [editError, setEditError] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const [usersRes, rolesRes, orgsRes] = await Promise.all([
axios.get('/api/users'),
axios.get('/api/roles'),
axios.get('/api/organizations')
])
setUsers(usersRes.data.users || [])
setRoles(rolesRes.data.roles || [])
setOrgs(orgsRes.data.organizations || [])
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '加载用户列表失败' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
setPageTitle('用户管理')
load()
}, [load])
const roleLabel = useCallback(value => (
roles.find(item => item.value === value)?.label || value || '—'
), [roles])
const activeCount = useMemo(() => users.filter(item => item.is_active).length, [users])
/* ---------------- 新建 ---------------- */
const submitCreate = async (event) => {
event.preventDefault()
if (!createForm.username.trim() || !createForm.password) {
setCreateError('账号和初始密码必填')
return
}
setBusy(true)
setCreateError('')
try {
const { data } = await axios.post('/api/users', {
...createForm,
org_id: createForm.org_id === '' ? null : Number(createForm.org_id)
})
setNotice({ type: 'ok', text: `用户「${data.user.username}」已创建,可交由对方登录后自行改密` })
setShowCreate(false)
setCreateForm({ ...EMPTY_NEW })
await load()
} catch (error) {
setCreateError(error?.response?.data?.error || '创建失败')
} finally {
setBusy(false)
}
}
/* ---------------- 编辑 ---------------- */
const openEdit = (row) => {
setEditing(row)
setEditError('')
setEditForm({
display_name: row.display_name || '',
position: row.position || '',
email: row.email || '',
phone: row.phone || '',
role: row.role,
org_id: row.org_id ?? ''
})
}
const submitEdit = async (event) => {
event.preventDefault()
if (!editing) return
setBusy(true)
setEditError('')
try {
await axios.patch(`/api/users/${editing.id}`, {
...editForm,
org_id: editForm.org_id === '' ? null : Number(editForm.org_id)
})
setNotice({ type: 'ok', text: `「${editing.username}」的资料已保存` })
setEditing(null)
await load()
if (editing.id === me.id) await refresh() /* 改到自己身上时刷新自身权限显示 */
} catch (error) {
setEditError(error?.response?.data?.error || '保存失败')
} finally {
setBusy(false)
}
}
/* ---------------- 重置密码 / 停用 ---------------- */
const resetPassword = async (row) => {
const password = window.prompt(`给「${row.username}」设置新密码(至少 6 位):`)
if (password === null) return
if (password.length < 6) {
setNotice({ type: 'error', text: '密码至少 6 位,未做修改' })
return
}
setBusy(true)
try {
await axios.patch(`/api/users/${row.id}`, { password })
setNotice({ type: 'ok', text: `「${row.username}」的密码已重置,对方旧登录态已失效` })
await load()
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '重置失败' })
} finally {
setBusy(false)
}
}
const toggleActive = async (row) => {
const next = !row.is_active
const ok = window.confirm(
next
? `启用「${row.username}」?启用后对方即可正常登录。`
: `停用「${row.username}」?停用后对方立即掉线、无法登录,客户等数据保留。`
)
if (!ok) return
setBusy(true)
try {
await axios.patch(`/api/users/${row.id}`, { is_active: next })
setNotice({ type: 'ok', text: `「${row.username}」已${next ? '启用' : '停用'}` })
await load()
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '操作失败' })
} finally {
setBusy(false)
}
}
return (
<div className="page-container user-page">
<div className="page-header">
<div>
<h1 className="page-title">用户管理</h1>
<p className="page-subtitle">
账号的增删改都在这里。角色决定权限(角色 → 权限组的推导见「权限组管理」),
停用会立即把对方踢下线。共 {users.length} 个账号,{activeCount} 个启用中。
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary" onClick={load} disabled={busy}>
<RefreshIcon size={14} /> 刷新
</button>
<button type="button" className="btn btn-primary" onClick={() => { setShowCreate(true); setCreateError('') }} disabled={busy}>
<PlusIcon size={14} /> 新建用户
</button>
</div>
</div>
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>账号 / 姓名</th>
<th>角色</th>
<th>权限组</th>
<th>组织</th>
<th>最近登录</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<SkeletonTableRows rows={4} widths={[1.8, 1, 1, 1, 1, 0.7, 1.6]} />
) : users.length === 0 ? (
<tr><td colSpan="7" className="empty-state">还没有用户</td></tr>
) : users.map(row => {
const isSelf = row.id === me.id
return (
<tr key={row.id} className={row.is_active ? '' : 'row-muted'}>
<td>
<strong>{row.username}</strong>
{isSelf && <span className="owner-self" style={{ marginLeft: 6 }}>我</span>}
<div className="muted-note">
{row.display_name || '—'}
{row.position ? ` · ${row.position}` : ''}
</div>
</td>
<td>
<span className="owner-cell"><UserIcon size={11} /> {roleLabel(row.role)}</span>
</td>
<td>{row.permission_group_name || <span className="muted-note">该角色未指派组</span>}</td>
<td>{row.org_name || <span className="muted-note">未指定</span>}</td>
<td className="mono">{fmtDate(row.last_login_at)}</td>
<td>
<span className={`pill ${row.is_active ? 'pill-ok' : 'pill-mute'}`}>
{row.is_active ? '启用' : '停用'}
</span>
</td>
<td className="col-actions">
<button type="button" className="btn-link" onClick={() => openEdit(row)}>编辑</button>
<button type="button" className="btn-link" disabled={busy} onClick={() => resetPassword(row)}>重置密码</button>
{!isSelf && (
<button type="button" className="btn-link" disabled={busy} onClick={() => toggleActive(row)}>
{row.is_active ? '停用' : '启用'}
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* ---------------- 新建用户 ---------------- */}
{showCreate && (
<div className="modal-overlay" onClick={() => !busy && setShowCreate(false)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>新建用户</h2>
<button type="button" className="icon-btn" onClick={() => setShowCreate(false)} aria-label="关闭">×</button>
</div>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label>登录账号 *</label>
<input
type="text" value={createForm.username} autoFocus
placeholder="字母/数字,如 zhangsan"
onChange={event => setCreateForm({ ...createForm, username: event.target.value })}
/>
</div>
<div className="form-group">
<label>姓名</label>
<input
type="text" value={createForm.display_name}
placeholder="对外显示的名字"
onChange={event => setCreateForm({ ...createForm, display_name: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>初始密码 *</label>
<input
type="text" value={createForm.password}
placeholder="至少 6 位,交给对方后自行修改"
onChange={event => setCreateForm({ ...createForm, password: event.target.value })}
/>
</div>
<div className="form-group">
<label>角色(决定权限)</label>
<select
value={createForm.role}
onChange={event => setCreateForm({ ...createForm, role: event.target.value })}
>
{roles.map(item => (
<option key={item.value} value={item.value}>
{item.label}{item.group_name ? `(${item.group_name})` : '(未指派组)'}
</option>
))}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>所属组织</label>
<select
value={createForm.org_id}
onChange={event => setCreateForm({ ...createForm, org_id: event.target.value })}
>
<option value="">未指定</option>
{orgs.map(org => <option key={org.id} value={String(org.id)}>{org.name}</option>)}
</select>
</div>
<div className="form-group">
<label>职位(对外称谓)</label>
<input
type="text" value={createForm.position}
placeholder="如:外贸业务员"
onChange={event => setCreateForm({ ...createForm, position: event.target.value })}
/>
</div>
</div>
{createError && <p className="form-error">{createError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)} disabled={busy}>取消</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
<SaveIcon size={14} /> {busy ? '创建中…' : '创建用户'}
</button>
</div>
</form>
</div>
</div>
)}
{/* ---------------- 编辑用户 ---------------- */}
{editing && editForm && (
<div className="modal-overlay" onClick={() => !busy && setEditing(null)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>编辑「{editing.username}」</h2>
<button type="button" className="icon-btn" onClick={() => setEditing(null)} aria-label="关闭">×</button>
</div>
<form onSubmit={submitEdit}>
<div className="form-row">
<div className="form-group">
<label>姓名</label>
<input
type="text" value={editForm.display_name}
onChange={event => setEditForm({ ...editForm, display_name: event.target.value })}
/>
</div>
<div className="form-group">
<label>职位</label>
<input
type="text" value={editForm.position}
onChange={event => setEditForm({ ...editForm, position: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>角色(决定权限)</label>
<select
value={editForm.role}
disabled={editing.id === me.id}
title={editing.id === me.id ? '不能修改自己的角色' : ''}
onChange={event => setEditForm({ ...editForm, role: event.target.value })}
>
{roles.map(item => (
<option key={item.value} value={item.value}>
{item.label}{item.group_name ? `(${item.group_name})` : '(未指派组)'}
</option>
))}
</select>
</div>
<div className="form-group">
<label>所属组织</label>
<select
value={editForm.org_id === null ? '' : editForm.org_id}
onChange={event => setEditForm({ ...editForm, org_id: event.target.value })}
>
<option value="">未指定</option>
{orgs.map(org => <option key={org.id} value={String(org.id)}>{org.name}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input
type="text" value={editForm.email}
onChange={event => setEditForm({ ...editForm, email: event.target.value })}
/>
</div>
<div className="form-group">
<label>电话</label>
<input
type="text" value={editForm.phone}
onChange={event => setEditForm({ ...editForm, phone: event.target.value })}
/>
</div>
</div>
{editError && <p className="form-error">{editError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setEditing(null)} disabled={busy}>取消</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
<SaveIcon size={14} /> {busy ? '保存中…' : '保存'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default UserManagement
+109 -72
View File
@@ -2,53 +2,90 @@
应用布局与页面组件
============================================================ */
.app-container { display: flex; min-height: 100vh; background: var(--bg-primary); }
.app-container { min-height: 100vh; background: var(--bg-primary); }
/* 顶栏横跨整宽、压在侧边栏之上;它下方才是「侧边栏 + 主内容」两栏 */
.app-shell { display: flex; flex-direction: column; min-height: 100vh; }
.app-body { display: flex; flex: 1 1 auto; min-height: 0; }
/* 侧边栏:仪表板式工业控制台 */
/* 侧边栏:仪表板式工业控制台
对比度策略参考 GoodJob 的 Command Deck —— 底色固定深色、不随日夜主题反转,
文字亮度拉高,hover 近白、选中近白 + 品牌色强调条,两种主题下都清晰。 */
.sidebar {
/* 侧栏专属色板:不引用随主题翻转的变量,日夜两套主题共用同一份深色底。
⚠️ 这套变量定义在 .sidebar 上,不是 :root —— 要覆盖必须写在同级或更具体的选择器。
2026-09-15:原本是青色系(强调 #7ed7f5、文字 #c3d7e3),跟主区的钢蓝不是同一套色相,
同屏看像两个界面;这里统一到钢蓝,并把文字灰提亮一档。 */
--sb-bg-top: #0a1826;
--sb-bg-bottom: #050c14;
--sb-border: rgba(160, 180, 215, 0.16);
--sb-text: #d5dfec;
--sb-text-dim: #a8b6c9;
--sb-text-strong: #ffffff;
--sb-icon: #a3b2c7;
--sb-hover-bg: #1b2b40;
--sb-active-bg: rgba(132, 181, 255, 0.15);
--sb-active-ring: rgba(132, 181, 255, 0.40);
--sb-accent: #9ec5ff;
position: sticky;
top: 0;
top: var(--header-height);
width: var(--sidebar-width);
height: 100vh;
height: calc(100vh - var(--header-height));
flex: 0 0 var(--sidebar-width);
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #0d2539 0%, #091a2a 100%);
border-right: 1px solid var(--border);
box-shadow: 10px 0 28px rgba(0, 0, 0, 0.2);
background: linear-gradient(180deg, var(--sb-bg-top) 0%, var(--sb-bg-bottom) 100%);
border-right: 1px solid var(--sb-border);
box-shadow: 10px 0 28px rgba(0, 0, 0, 0.28);
transition: width var(--transition-base), flex-basis var(--transition-base);
z-index: 10;
}
.sidebar::before { content: ""; height: 3px; flex: 0 0 3px; background: linear-gradient(90deg, var(--primary-dark), var(--primary-light)); }
.sidebar.collapsed { width: var(--sidebar-collapsed-width); flex-basis: var(--sidebar-collapsed-width); }
.sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 76px; padding: 18px 18px 16px; border-bottom: 1px solid var(--border-light); }
.sidebar-header h1 { margin: 0; overflow: hidden; color: var(--text-primary); font-family: var(--font-display); font-size: 16px; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; }
.collapse-btn { flex: 0 0 30px; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--primary-light); cursor: pointer; font-size: 16px; transition: all var(--transition-fast); }
.collapse-btn:hover { background: var(--primary); color: #041522; border-color: var(--primary); }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 14px 10px; }
/* 首行:工作台(左)+ 展开/收起导航按钮(右) */
.sidebar-top { display: flex; align-items: center; gap: 8px; min-height: 62px; padding: 8px 12px; border-bottom: 1px solid var(--sb-border); }
.sidebar-top .nav-item-home { flex: 1 1 auto; min-width: 0; margin: 0; }
.sidebar-top .collapse-btn { flex: 0 0 auto; }
.sidebar-top .collapse-btn svg { transition: transform var(--transition-base); }
.sidebar.collapsed .sidebar-top { flex-direction: column; gap: 8px; padding: 8px; }
.sidebar.collapsed .sidebar-top .nav-item-home { width: 100%; flex: 0 0 auto; }
.sidebar.collapsed .collapse-btn svg { transform: rotate(180deg); }
.collapse-btn { flex: 0 0 30px; width: 30px; height: 30px; border: 1px solid var(--sb-border); border-radius: var(--radius-sm); background: transparent; color: var(--sb-text); cursor: pointer; font-size: 16px; transition: all var(--transition-fast); }
.collapse-btn:hover { background: var(--sb-accent); color: #04131c; border-color: var(--sb-accent); }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 6px 10px 14px; }
/* 侧栏菜单滚动条:默认系统白条在深色侧栏上很突兀,改成平时隐身、悬停才露出的细条。
侧栏两主题都固定深色,拇指色用白色半透明即可通吃。 */
.sidebar-nav { scrollbar-width: thin; scrollbar-color: transparent transparent; }
.sidebar-nav::-webkit-scrollbar { width: 6px; }
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar-nav::-webkit-scrollbar-thumb { background: transparent; border-radius: 999px; }
.sidebar-nav:hover { scrollbar-color: rgba(255, 255, 255, 0.18) transparent; }
.sidebar-nav:hover::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.18); }
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.32); }
/* 分组标题行:标题 + 右侧折叠按钮 */
.nav-group { display: block; }
.nav-group:first-of-type .nav-group-header { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border-light); }
.nav-group:first-of-type .nav-group-header { margin-top: 6px; padding-top: 0; }
.nav-group-header { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin: 16px 4px 6px; padding: 0 6px; }
.nav-group-title { flex: 1; margin: 0; color: var(--text-tertiary); font-size: 12px; font-weight: 600; letter-spacing: 0.04em; }
.nav-group-toggle { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); }
.nav-group-toggle:hover { color: var(--primary-light); background: var(--surface-hover); border-color: var(--border-light); }
.nav-group-title { flex: 1; margin: 0; color: var(--sb-text-dim); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; }
.nav-group-toggle { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--sb-text-dim); cursor: pointer; transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); }
.nav-group-toggle:hover { color: var(--sb-text-strong); background: var(--sb-hover-bg); border-color: var(--sb-border); }
.nav-group-toggle svg { transition: transform var(--transition-base); }
.nav-group-toggle:not(.open) svg { transform: rotate(-90deg); }
.sidebar.collapsed .nav-group-header { display: none; }
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; min-height: 42px; margin: 3px 0; padding: 10px 12px; border: 1px solid transparent; border-radius: var(--radius-md); color: var(--text-secondary); text-decoration: none; font-size: 14px; font-weight: 600; transition: all var(--transition-fast); }
.nav-item:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); }
.nav-item.active { background: linear-gradient(90deg, rgba(42, 167, 220, 0.22), rgba(42, 167, 220, 0.06)); border-color: var(--primary-border); color: var(--text-primary); box-shadow: var(--shadow-inset); }
.nav-item.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 2px 2px 0; background: var(--primary-light); }
.nav-item .icon { display: flex; align-items: center; justify-content: center; min-width: 20px; color: var(--primary-light); }
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; min-height: 42px; margin: 3px 0; padding: 10px 12px; border: 1px solid transparent; border-radius: var(--radius-md); color: var(--sb-text); text-decoration: none; font-size: 14px; font-weight: 600; transition: all var(--transition-fast); }
.nav-item:hover { background: var(--sb-hover-bg); border-color: transparent; color: var(--sb-text-strong); }
.nav-item.active { background: var(--sb-active-bg); border-color: transparent; color: var(--sb-text-strong); box-shadow: inset 0 0 0 1px var(--sb-active-ring); }
.nav-item.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 2px 2px 0; background: var(--sb-accent); box-shadow: 0 0 10px rgba(158, 197, 255, 0.5); }
.nav-item .icon { display: flex; align-items: center; justify-content: center; min-width: 20px; color: var(--sb-icon); transition: color var(--transition-fast); }
.nav-item:hover .icon, .nav-item.active .icon { color: var(--sb-accent); }
.sidebar.collapsed .nav-item { justify-content: center; padding: 10px; }
.sidebar.collapsed .nav-item span:not(.icon) { display: none; }
.sidebar-footer { display: flex; align-items: center; gap: 10px; padding: 15px 16px; border-top: 1px solid var(--border-light); }
.sidebar-footer .user-info { flex: 1; overflow: hidden; color: var(--text-secondary); font-size: 12px; font-weight: 600; white-space: nowrap; }
.settings-btn { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); }
.settings-btn:hover { color: var(--primary-light); border-color: var(--primary-border); background: var(--primary-bg); }
/* 用户信息只在右上角 header 显示;此处只放日夜切换与设置,右对齐以保持原有位置 */
.sidebar-footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 15px 16px; border-top: 1px solid var(--sb-border); }
.settings-btn { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 1px solid var(--sb-border); border-radius: var(--radius-sm); background: transparent; color: var(--sb-text); cursor: pointer; transition: all var(--transition-fast); }
.settings-btn:hover { color: var(--sb-accent); border-color: var(--sb-active-ring); background: var(--sb-hover-bg); }
.sidebar.collapsed .sidebar-footer { justify-content: center; padding: 15px 10px; }
.sidebar.collapsed .user-info { display: none; }
/* 收起后侧栏只有 76px 宽,两个 40px 圆形按钮并排会互相挤压变形 —— 只留日夜切换 */
.sidebar.collapsed .settings-btn { display: none; }
/* 主内容 */
.main-content { min-width: 0; flex: 1; overflow-y: auto; padding: 34px clamp(20px, 4vw, 52px) 52px; background-color: var(--bg-primary); background-image: linear-gradient(rgba(126, 190, 221, 0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(126, 190, 221, 0.035) 1px, transparent 1px); background-size: 28px 28px; }
@@ -70,7 +107,7 @@
.stat-card { display: flex; align-items: center; min-height: 106px; gap: 14px; padding: 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: linear-gradient(145deg, var(--surface-raised), var(--surface)); box-shadow: var(--shadow-raise); transition: transform var(--transition-fast), border-color var(--transition-fast); }
.stat-card:hover { transform: translateY(-2px); border-color: var(--primary-border); }
.stat-icon { display: flex; align-items: center; justify-content: center; flex: 0 0 38px; color: var(--primary-light); }
.stat-label { margin-bottom: 5px; color: var(--text-tertiary); font-size: 12px; font-weight: 600; }
.stat-label { margin-bottom: 5px; color: var(--text-tertiary); font-size: 13px; font-weight: 600; }
.stat-value { color: var(--text-primary); font-family: var(--font-mono); font-size: 27px; font-weight: 700; line-height: 1; }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr); gap: 20px; margin-bottom: 20px; }
@@ -80,7 +117,7 @@
.toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap; }
.form-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-size: 12px; font-weight: 700; }
.form-group label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-size: 13px; font-weight: 700; }
.search-input { width: 100%; }
.mono { font-family: var(--font-mono); }
.number, .price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
@@ -91,22 +128,22 @@
.empty-state { padding: 48px 20px; color: var(--text-tertiary); text-align: center; }
.empty-state-icon { margin-bottom: 14px; color: var(--text-disabled); font-size: 40px; }
.empty-state-title { margin-bottom: 6px; color: var(--text-secondary); font-weight: 700; }
.empty-state-hint { color: var(--text-tertiary); font-size: 13px; }
.empty-state-hint { color: var(--text-tertiary); font-size: 14px; }
.todo-list, .activity-list, .holiday-list { margin: 0; padding: 0; list-style: none; }
.todo-item, .activity-item { display: flex; align-items: flex-start; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border-light); }
.todo-item:last-child, .activity-item:last-child { border-bottom: 0; }
.todo-item input[type="checkbox"] { flex: 0 0 auto; width: 16px; height: 16px; margin-top: 3px; accent-color: var(--primary); }
.todo-content { min-width: 0; flex: 1; }
.todo-title { color: var(--text-primary); font-size: 13px; font-weight: 600; }
.todo-meta, .activity-time { margin-top: 3px; color: var(--text-tertiary); font-size: 11px; }
.activity-content { color: var(--text-secondary); font-size: 13px; }
.todo-title { color: var(--text-primary); font-size: 14px; font-weight: 600; }
.todo-meta, .activity-time { margin-top: 3px; color: var(--text-tertiary); font-size: 12.5px; }
.activity-content { color: var(--text-secondary); font-size: 14px; }
.calendar-widget { padding: 3px 0; }
.current-date { margin-bottom: 14px; padding: 12px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--primary-light); font-family: var(--font-mono); font-size: 13px; font-weight: 700; text-align: center; }
.current-date { margin-bottom: 14px; padding: 12px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--primary-light); font-family: var(--font-mono); font-size: 14px; font-weight: 700; text-align: center; }
.holiday-item { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border-light); }
.holiday-item:last-child { border-bottom: 0; }
.holiday-flag { display: flex; align-items: center; color: var(--primary-light); }
.holiday-name { flex: 1; color: var(--text-secondary); font-size: 13px; }
.holiday-date { color: var(--text-tertiary); font-family: var(--font-mono); font-size: 12px; }
.holiday-name { flex: 1; color: var(--text-secondary); font-size: 14px; }
.holiday-date { color: var(--text-tertiary); font-family: var(--font-mono); font-size: 13px; }
.info-box { margin-top: 18px; padding: 15px 17px; border: 1px solid var(--info-border); border-left: 3px solid var(--info); border-radius: var(--radius-md); background: var(--info-bg); color: var(--text-secondary); }
.info-box h3 { margin: 0 0 9px; color: var(--info); font-size: 14px; }
.info-box ul { margin: 8px 0 0 18px; padding: 0; }
@@ -117,33 +154,33 @@
.settings-sidebar { display: flex; flex-direction: column; width: 230px; flex: 0 0 230px; background: var(--surface-deep); border-right: 1px solid var(--border); }
.settings-header h2 { margin: 0; font-family: var(--font-display); font-size: 17px; }
.settings-nav { flex: 1; padding: 12px; overflow-y: auto; }
.settings-nav-item { width: 100%; margin-bottom: 4px; padding: 10px 12px; border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; font-size: 13px; font-weight: 600; transition: all var(--transition-fast); }
.settings-nav-item { width: 100%; margin-bottom: 4px; padding: 10px 12px; border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; font-size: 14px; font-weight: 600; transition: all var(--transition-fast); }
.settings-nav-item:hover { background: var(--surface-hover); color: var(--text-primary); }
.settings-nav-item.active { background: var(--primary-bg); border-color: var(--primary-border); color: var(--primary-light); }
.settings-content { flex: 1; overflow-y: auto; padding: 26px; }
.settings-section h3 { margin: 0 0 7px; color: var(--text-primary); font-family: var(--font-display); font-size: 22px; }
.settings-description { margin: 0 0 26px; color: var(--text-tertiary); font-size: 13px; }
.settings-description { margin: 0 0 26px; color: var(--text-tertiary); font-size: 14px; }
.settings-group { margin-bottom: 26px; padding-bottom: 23px; border-bottom: 1px solid var(--border-light); }
.settings-group:last-child { border-bottom: 0; }
.settings-group h4 { margin: 0 0 13px; color: var(--text-secondary); font-size: 13px; }
.settings-group h4 { margin: 0 0 13px; color: var(--text-secondary); font-size: 14px; }
.settings-actions { display: flex; flex-wrap: wrap; gap: 9px; }
.file-label { cursor: pointer; }
.help-text { margin: 9px 0 0; color: var(--text-tertiary); font-size: 12px; }
.help-text { margin: 9px 0 0; color: var(--text-tertiary); font-size: 13px; }
.checkbox-group label { display: flex; align-items: center; gap: 9px; cursor: pointer; }
.checkbox-group input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--primary); }
.checkbox-group span { color: var(--text-secondary); font-size: 13px; }
.checkbox-group span { color: var(--text-secondary); font-size: 14px; }
.loading { display: flex; align-items: center; justify-content: center; min-height: 220px; color: var(--text-tertiary); font-size: 13px; }
.loading { display: flex; align-items: center; justify-content: center; min-height: 220px; color: var(--text-tertiary); font-size: 14px; }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 700ms linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 1180px) { .stats-grid { grid-template-columns: repeat(3, minmax(160px, 1fr)); } }
@media (max-width: 900px) { .sidebar { width: var(--sidebar-collapsed-width); flex-basis: var(--sidebar-collapsed-width); } .sidebar-header { justify-content: center; padding: 18px 10px; } .sidebar-header h1 { display: none; } .sidebar-nav { padding-inline: 9px; } .nav-group-header, .nav-item span:not(.icon), .user-info { display: none; } .nav-item { justify-content: center; padding-inline: 10px; } .sidebar-footer { justify-content: center; padding-inline: 10px; } .dashboard-grid { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .sidebar { width: var(--sidebar-collapsed-width); flex-basis: var(--sidebar-collapsed-width); } .sidebar-top { flex-direction: column; gap: 8px; padding: 8px; } .sidebar-top .nav-item-home { width: 100%; flex: 0 0 auto; } .sidebar-top .collapse-btn svg { transform: rotate(180deg); } .sidebar-nav { padding-inline: 9px; } .nav-group-header, .nav-item span:not(.icon), .settings-btn { display: none; } .nav-item { justify-content: center; padding-inline: 10px; } .sidebar-footer { justify-content: center; padding-inline: 10px; } .dashboard-grid { grid-template-columns: 1fr; } }
@media (max-width: 620px) { .main-content { padding: 24px 14px 36px; } .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .stat-card { min-height: 88px; padding: 13px; } .stat-icon { flex-basis: 30px; } .stat-value { font-size: 22px; } .dashboard-grid { display: block; } .form-row { grid-template-columns: 1fr; } .settings-modal { height: calc(100vh - 20px); width: calc(100vw - 20px); flex-direction: column; } .settings-sidebar { width: auto; flex-basis: auto; } .settings-header { padding: 14px 18px; } .settings-nav { display: flex; gap: 8px; overflow-x: auto; } .settings-nav-item { width: auto; white-space: nowrap; margin: 0; } .settings-content { padding: 20px 18px; } }
/* 产品页使用的原生 table 同样复用工业表格视觉 */
.table-wrap table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
.table-wrap thead th { position: sticky; top: 0; z-index: 1; padding: 11px 14px; background: var(--surface-raised); color: var(--text-secondary); border-bottom: 1px solid var(--border-strong); text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
.table-wrap table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
.table-wrap thead th { position: sticky; top: 0; z-index: 1; padding: 11px 14px; background: var(--surface-raised); color: var(--text-secondary); border-bottom: 1px solid var(--border-strong); text-align: left; font-size: 12.5px; letter-spacing: 0.08em; text-transform: uppercase; }
.table-wrap tbody td { padding: 12px 14px; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); vertical-align: middle; }
.table-wrap tbody tr { cursor: pointer; transition: background var(--transition-fast); }
.table-wrap tbody tr:hover td { background: var(--primary-bg); color: var(--text-primary); }
@@ -155,28 +192,28 @@
.page-sub { margin: 6px 0 0; color: var(--text-secondary); font-size: 14px; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.notice-inline { margin-bottom: 16px; padding: 10px 14px; border: 1px solid var(--danger-border); border-left: 3px solid var(--danger); border-radius: var(--radius-md); background: var(--danger-bg); color: var(--danger); font-size: 13px; }
.notice-inline { margin-bottom: 16px; padding: 10px 14px; border: 1px solid var(--danger-border); border-left: 3px solid var(--danger); border-radius: var(--radius-md); background: var(--danger-bg); color: var(--danger); font-size: 14px; }
.supplier-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(258px, 1fr)); gap: 14px; }
.supplier-card { display: flex; flex-direction: column; gap: 7px; padding: 16px 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); cursor: pointer; transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); }
.supplier-card:hover { transform: translateY(-2px); border-color: var(--primary-border); background: var(--surface-raised); }
.supplier-card.is-unassigned { border-style: dashed; }
.sc-name { color: var(--text-primary); font-size: 15px; font-weight: 600; }
.sc-contact, .sc-loc { overflow: hidden; color: var(--text-secondary); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.sc-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: auto; padding-top: 9px; border-top: 1px solid var(--border-light); color: var(--text-tertiary); font-size: 12px; }
.sc-count b { color: var(--text-primary); font-size: 13px; }
.muted-note { color: var(--text-tertiary); font-size: 12px; }
.sc-contact, .sc-loc { overflow: hidden; color: var(--text-secondary); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.sc-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: auto; padding-top: 9px; border-top: 1px solid var(--border-light); color: var(--text-tertiary); font-size: 13px; }
.sc-count b { color: var(--text-primary); font-size: 14px; }
.muted-note { color: var(--text-tertiary); font-size: 13px; }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { padding: 2px 9px; border: 1px solid var(--border-light); border-radius: var(--radius-pill); background: var(--surface-deep); color: var(--text-secondary); font-size: 12px; white-space: nowrap; }
.chip { padding: 2px 9px; border: 1px solid var(--border-light); border-radius: var(--radius-pill); background: var(--surface-deep); color: var(--text-secondary); font-size: 13px; white-space: nowrap; }
.kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px 24px; }
.kv { display: flex; flex-direction: column; gap: 3px; padding-bottom: 9px; border-bottom: 1px solid var(--border-light); }
.kv-label { color: var(--text-tertiary); font-size: 12px; }
.kv-label { color: var(--text-tertiary); font-size: 13px; }
.kv-value { color: var(--text-primary); font-size: 14px; word-break: break-word; }
.kv-value.is-empty { color: var(--text-disabled); }
.pill { display: inline-flex; align-items: center; padding: 2px 9px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 600; }
.pill { display: inline-flex; align-items: center; padding: 2px 9px; border-radius: var(--radius-pill); font-size: 13px; font-weight: 600; }
.pill-ok { border: 1px solid var(--success-border); background: var(--success-bg); color: var(--success); }
.pill-warn { border: 1px solid var(--warning-border); background: var(--warning-bg); color: var(--warning); }
.pill-mute { border: 1px solid var(--border-light); background: var(--surface-deep); color: var(--text-tertiary); }
@@ -192,7 +229,7 @@
============================================================ */
/* 表头改成中文友好:原来 11px + 0.08em 字距是给全大写英文设计的,中文会被拉散 */
.data-table thead th { font-size: 12px; letter-spacing: 0.02em; text-transform: none; }
.data-table thead th { font-size: 13px; letter-spacing: 0.02em; text-transform: none; }
/* 排列方式分段控件 */
.view-toggle { display: inline-flex; padding: 2px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-deep); }
@@ -206,17 +243,17 @@
.product-table tbody tr.row-picked td { background: var(--primary-bg); }
.product-table tbody tr.row-hit td { background: var(--warning-bg); }
.check-inline { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-size: 13px; white-space: nowrap; cursor: pointer; }
.check-inline { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-size: 14px; white-space: nowrap; cursor: pointer; }
.check-inline input { width: 15px; height: 15px; accent-color: var(--primary); }
/* 批量操作条 */
.batch-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 10px 14px; border: 1px solid var(--primary-border); border-radius: var(--radius-md); background: var(--primary-bg); color: var(--text-secondary); font-size: 13px; }
.batch-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 10px 14px; border: 1px solid var(--primary-border); border-radius: var(--radius-md); background: var(--primary-bg); color: var(--text-secondary); font-size: 14px; }
.batch-bar b { color: var(--text-primary); }
.hit-line { margin-top: 3px; color: var(--text-tertiary); font-size: 12px; }
.hit-line { margin-top: 3px; color: var(--text-tertiary); font-size: 13px; }
.thumb-wrap { position: relative; display: inline-block; }
.thumb-badge { position: absolute; right: -5px; bottom: -5px; min-width: 16px; padding: 0 4px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; }
.thumb-badge { position: absolute; right: -5px; bottom: -5px; min-width: 16px; padding: 0 4px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 12px; font-weight: 700; line-height: 16px; text-align: center; }
.thumb-empty { display: inline-flex; align-items: center; justify-content: center; color: var(--text-disabled); border: 1px dashed var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); }
.unit { margin-right: 1px; color: var(--text-tertiary); font-size: 0.85em; }
@@ -226,7 +263,7 @@
/* 抽屉补充 */
.drawer-wide { width: min(940px, 96vw); }
.drawer-head-main { min-width: 0; }
.drawer-sub { margin: 5px 0 0; color: var(--text-tertiary); font-size: 13px; }
.drawer-sub { margin: 5px 0 0; color: var(--text-tertiary); font-size: 14px; }
/* 参数编辑器(参数名 / 值 / 删除) */
.attr-editor { display: flex; flex-direction: column; gap: 8px; }
@@ -239,10 +276,10 @@
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 12px; }
.gallery-item { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-deep); }
.gallery-item img { display: block; width: 100%; height: 108px; object-fit: cover; cursor: zoom-in; }
.gallery-flag { position: absolute; top: 6px; left: 6px; padding: 1px 7px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 11px; font-weight: 700; }
.gallery-flag { position: absolute; top: 6px; left: 6px; padding: 1px 7px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 12.5px; font-weight: 700; }
.gallery-actions { display: flex; gap: 6px; padding: 7px; border-top: 1px solid var(--border-light); }
.gallery-actions .btn { flex: 1; }
.add-image { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 142px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); color: var(--text-tertiary); font-size: 12px; cursor: pointer; transition: all var(--transition-fast); }
.add-image { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 142px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); color: var(--text-tertiary); font-size: 13px; cursor: pointer; transition: all var(--transition-fast); }
.add-image:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
.add-image input { display: none; }
@@ -251,38 +288,38 @@
/* 说明块 */
.notice-inline.notice-ok { border-color: var(--success-border); border-left-color: var(--success); background: var(--success-bg); color: var(--success); }
.note { display: flex; gap: 9px; margin-bottom: 14px; padding: 11px 14px; border-radius: var(--radius-md); border-left: 3px solid var(--border-strong); background: var(--surface-deep); color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.note { display: flex; gap: 9px; margin-bottom: 14px; padding: 11px 14px; border-radius: var(--radius-md); border-left: 3px solid var(--border-strong); background: var(--surface-deep); color: var(--text-secondary); font-size: 14px; line-height: 1.6; }
.note-info { border-left-color: var(--primary); }
.note-warn { border-left-color: var(--warning); background: var(--warning-bg); }
.spec-text { white-space: pre-wrap; color: var(--text-secondary); font-size: 13px; line-height: 1.75; }
.terms summary { color: var(--text-secondary); font-size: 13px; cursor: pointer; }
.spec-text { white-space: pre-wrap; color: var(--text-secondary); font-size: 14px; line-height: 1.75; }
.terms summary { color: var(--text-secondary); font-size: 14px; cursor: pointer; }
.terms .spec-text { margin-top: 9px; }
/* 调价时间线 */
.timeline { display: flex; flex-direction: column; gap: 14px; }
.timeline-item { display: flex; gap: 12px; }
.timeline-dot { flex: 0 0 8px; width: 8px; height: 8px; margin-top: 6px; border-radius: 50%; background: var(--primary); }
.timeline-title { color: var(--text-primary); font-size: 13px; }
.timeline-meta { margin-top: 2px; color: var(--text-tertiary); font-size: 12px; }
.timeline-title { color: var(--text-primary); font-size: 14px; }
.timeline-meta { margin-top: 2px; color: var(--text-tertiary); font-size: 13px; }
/* 小推车 */
.cart-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; margin-left: 6px; padding: 0 5px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 11px; font-weight: 700; }
.cart-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; margin-left: 6px; padding: 0 5px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 12.5px; font-weight: 700; }
.cart-list { display: flex; flex-direction: column; gap: 10px; }
.cart-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
.cart-thumb { flex: 0 0 auto; }
.cart-main { flex: 1; min-width: 0; }
.cart-name { overflow: hidden; color: var(--text-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.cart-meta { overflow: hidden; margin-top: 2px; color: var(--text-tertiary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.cart-meta { overflow: hidden; margin-top: 2px; color: var(--text-tertiary); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.cart-meta code { font-family: var(--font-mono); }
.cart-price { flex: 0 0 auto; min-width: 96px; text-align: right; color: var(--text-primary); font-size: 14px; }
.cart-qty { display: flex; flex-direction: column; gap: 3px; flex: 0 0 auto; }
.cart-qty span { color: var(--text-tertiary); font-size: 11px; }
.cart-qty span { color: var(--text-tertiary); font-size: 12.5px; }
.cart-qty input { width: 84px; }
.cart-total { align-self: center; color: var(--text-secondary); font-size: 13px; }
.cart-total { align-self: center; color: var(--text-secondary); font-size: 14px; }
.cart-total b { color: var(--text-primary); font-size: 16px; }
/* 设置页:系统与更新 */
.update-status { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 13px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-size: 13px; line-height: 1.5; }
.update-status { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 13px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-size: 14px; line-height: 1.5; }
.update-status b { color: var(--text-primary); font-family: var(--font-mono); }
.update-status .spinner { width: 14px; height: 14px; border-width: 2px; }
.update-status.is-checking { color: var(--text-tertiary); }
@@ -290,8 +327,8 @@
.update-status.is-new { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
.update-status.is-error { border-color: var(--danger-border); background: var(--danger-bg); color: var(--danger); }
.update-links { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 10px; }
.update-links .btn-link { text-decoration: none; font-size: 13px; }
.update-notes { margin: 10px 0 0; max-height: 230px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-family: var(--font-body); font-size: 12px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
.update-links .btn-link { text-decoration: none; font-size: 14px; }
.update-notes { margin: 10px 0 0; max-height: 230px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-family: var(--font-body); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
/* 窄屏:批量条与抽屉内表单换行 */
@media (max-width: 720px) {
+19 -12
View File
@@ -79,6 +79,8 @@
--text-secondary-legacy: var(--text-secondary);
--sidebar-width: 264px;
--sidebar-collapsed-width: 76px;
/* 顶栏高度:顶栏横跨整宽压在侧边栏之上,侧边栏据此吸附在其下方 */
--header-height: 80px;
}
[data-theme="light"] {
@@ -141,7 +143,7 @@ a { color: inherit; }
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-primary);
font-size: 13px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
@@ -153,27 +155,32 @@ a { color: inherit; }
.btn-secondary { background: var(--surface-raised); }
.btn-danger { color: var(--danger); }
.btn-danger:hover { background: var(--danger-bg); border-color: var(--danger-border); }
.btn-sm { min-height: 30px; padding: 5px 10px; font-size: 12px; }
.btn-sm { min-height: 30px; padding: 5px 10px; font-size: 13px; }
.btn-icon { min-width: 34px; padding: 7px; }
.btn-link { border: 0; background: transparent; color: var(--primary-light); font-weight: 600; cursor: pointer; padding: 5px 0; }
.btn-link:hover { color: var(--text-primary); }
input[type="text"], input[type="search"], input[type="number"], input[type="email"], input[type="tel"], input[type="url"], input[type="password"], select, textarea, .form-input, .form-select {
/* 输入框一律「无方框」:不做描边,只靠底色区分。
⚠️ 末尾的 input:not([type]) 是为了兜住没写 type 的裸 <input>(AI 输入框曾因此掉进浏览器默认样式)。
用的都是 (0,1,1) 同权重,别改成 :not() 链,会压过各处的局部覆盖。 */
input[type="text"], input[type="search"], input[type="number"], input[type="email"], input[type="tel"], input[type="url"], input[type="password"], input:not([type]), select, textarea, .form-input, .form-select {
width: 100%;
min-height: 38px;
padding: 9px 11px;
border: 1px solid var(--border);
border: 0;
border-radius: var(--radius-md);
background: var(--surface-deep);
color: var(--text-primary);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
transition: background var(--transition-fast), color var(--transition-fast);
}
input::placeholder, textarea::placeholder { color: var(--text-tertiary); }
input:focus, select:focus, textarea:focus, .form-input:focus, .form-select:focus {
/* 聚焦同样不用方框(原来的 3px 光环 + 描边就是「丑方框」的来源) */
input:focus, input:not([type]):focus, select:focus, textarea:focus, .form-input:focus, .form-select:focus {
outline: none;
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(42, 167, 220, 0.14);
border: 0;
background: var(--primary-bg);
color: var(--text-primary);
box-shadow: none;
}
/* 标签、状态、表格 */
@@ -184,7 +191,7 @@ input:focus, select:focus, textarea:focus, .form-input:focus, .form-select:focus
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
@@ -194,8 +201,8 @@ input:focus, select:focus, textarea:focus, .form-input:focus, .form-select:focus
.badge-danger, .status-shipped, .priority-high { background: var(--danger-bg); color: var(--danger); border-color: var(--danger-border); }
.badge-neutral, .status-draft, .priority-low { background: var(--surface-alt); color: var(--text-secondary); }
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
.data-table thead th { position: sticky; top: 0; z-index: 1; padding: 11px 14px; background: var(--surface-raised); color: var(--text-secondary); border-bottom: 1px solid var(--border-strong); text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
.data-table thead th { position: sticky; top: 0; z-index: 1; padding: 11px 14px; background: var(--surface-raised); color: var(--text-secondary); border-bottom: 1px solid var(--border-strong); text-align: left; font-size: 12.5px; letter-spacing: 0.08em; text-transform: uppercase; }
.data-table tbody td { padding: 12px 14px; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); vertical-align: middle; }
.data-table tbody tr:hover td { background: var(--primary-bg); color: var(--text-primary); }
.data-table tbody tr:last-child td { border-bottom: 0; }
+473 -94
View File
@@ -78,17 +78,15 @@ body::before {
background-size: 128px;
}
body { font-size: 14px; }
.sidebar { background: var(--bg-secondary); box-shadow: none; border-color: var(--border-light); }
/* 侧栏固定深色(不随主题翻转),颜色全部取自 index.css 里的 --sb-* 色板 */
.sidebar { background: linear-gradient(180deg, var(--sb-bg-top) 0%, var(--sb-bg-bottom) 100%); box-shadow: 10px 0 28px rgba(0, 0, 0, 0.28); border-right-color: var(--sb-border); }
.sidebar::before { background: transparent; }
.sidebar-header { min-height: 92px; }
.sidebar.collapsed .sidebar-header h1 { display: none; }
.sidebar.collapsed .sidebar-header { justify-content: center; }
.sidebar-top { min-height: 62px; }
.nav-item { min-height: 46px; font-weight: 600; }
.nav-item.active { border-color: transparent; background: var(--primary-bg); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.nav-item.active::before { display: none; }
.nav-item .icon { color: var(--text-tertiary); }
.nav-item.active .icon { color: var(--primary-light); }
.collapse-btn { border: 0; background: transparent; border-radius: 50%; color: var(--text-secondary); }
.nav-item.active { border-color: transparent; background: var(--sb-active-bg); box-shadow: inset 0 0 0 1px var(--sb-active-ring); }
.nav-item .icon { color: var(--sb-icon); }
.nav-item.active .icon { color: var(--sb-accent); }
.collapse-btn { border: 0; background: transparent; border-radius: 50%; color: var(--sb-text); }
.main-content { background: radial-gradient(ellipse at 50% 100%, var(--ambient), transparent 65%), var(--bg-primary); background-size: auto; }
.page-title { font-size: clamp(24px, 2.5vw, 32px); font-weight: 700; }
.page-title::after { display: none; }
@@ -100,9 +98,9 @@ body { font-size: 14px; }
.table-container, .table-wrap { border-color: var(--border-light); background: var(--surface); border-radius: 14px; box-shadow: var(--panel-shadow); }
.table-wrap table, .data-table { min-width: 680px; }
.data-table td.empty-state { padding: 48px 20px; text-align: center; }
.settings-btn { width: 40px; height: 40px; border-radius: 50%; border-color: var(--border-light); background: var(--surface); color: var(--text-secondary); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.settings-btn { width: 40px; height: 40px; border-radius: 50%; border-color: var(--sb-border); background: transparent; color: var(--sb-text); box-shadow: none; }
.settings-btn svg { width: 20px; height: 20px; }
.settings-btn:hover { background: var(--primary-bg); color: var(--primary-light); border-color: var(--primary-border); }
.settings-btn:hover { background: var(--sb-hover-bg); color: var(--sb-accent); border-color: var(--sb-active-ring); }
/* Include standalone legacy buttons as well as .btn components. */
.btn, .btn-primary, .btn-secondary {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
@@ -117,9 +115,13 @@ body { font-size: 14px; }
.btn-sm { min-height: 32px; padding: 5px 10px; }
.modal-content, .drawer { border-color: var(--border-light); border-radius: 22px; }
.drawer { border-radius: 22px 0 0 22px; }
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* 文本框不再画 2px 描边方框(鼠标点进去也会命中 :focus-visible,那是「丑方框」的主要来源);
聚焦反馈统一交给底色变化。按钮/链接保留键盘可见性轮廓。 */
button:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: none; }
/* Settings shell: a single rounded, softly illuminated surface. */
.settings-overlay { padding: 24px; background: rgba(4, 9, 18, .64); backdrop-filter: blur(9px); }
/* 设置仍是模态,层级要压过「小皮」浮窗(z-index 78) */
.settings-overlay { z-index: 90; padding: 24px; background: rgba(4, 9, 18, .64); backdrop-filter: blur(9px); }
.settings-modal.luxury-settings {
position: relative; width: min(1020px, 100%); height: min(700px, calc(100dvh - 48px));
display: flex; flex-direction: row; overflow: hidden; border: 0;
@@ -131,28 +133,27 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
.luxury-settings .settings-sidebar { width: 224px; flex: 0 0 224px; padding-bottom: 24px; background: var(--bg-secondary); border-color: var(--border-light); }
.luxury-settings .settings-header { padding: 32px 22px 26px; display: block; border: 0; }
.luxury-settings .settings-header h2 { font-size: 22px; letter-spacing: -.03em; }
.section-label { display: block; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .12em; margin-bottom: 12px; }
.luxury-settings .settings-header .close-btn { position: absolute; right: 18px; top: 18px; z-index: 2; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-deep); color: var(--text-secondary); }
.luxury-settings .settings-header .close-btn:hover { background: var(--surface-hover); }
.luxury-settings .settings-nav { padding: 8px 14px; }
.luxury-settings .settings-nav-item { display: flex; align-items: center; gap: 10px; min-height: 44px; border: 0; border-radius: 11px; padding: 12px; margin-bottom: 6px; font-size: 13px; }
.luxury-settings .settings-nav-item { display: flex; align-items: center; gap: 10px; min-height: 44px; border: 0; border-radius: 11px; padding: 12px; margin-bottom: 6px; font-size: 14px; }
.luxury-settings .settings-nav-item.active { color: var(--primary-light); background: var(--primary-bg); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.settings-nav-icon { display: flex; align-items: center; flex: 0 0 18px; }
.settings-sidebar-status { display: flex; align-items: flex-start; gap: 9px; margin: auto 22px 0; color: var(--text-tertiary); font-size: 11px; }
.settings-sidebar-status small { display: block; margin-top: 7px; font: 9px var(--font-mono); letter-spacing: .06em; }
.settings-sidebar-status { display: flex; align-items: flex-start; gap: 9px; margin: auto 22px 0; color: var(--text-tertiary); font-size: 12.5px; }
.settings-sidebar-status small { display: block; margin-top: 7px; font: 12px var(--font-mono); letter-spacing: .06em; }
.luxury-settings .settings-content { min-width: 0; padding: 42px 38px 30px; background: radial-gradient(ellipse at 50% 100%, var(--ambient), transparent 65%); }
.luxury-settings .settings-section h3 { font-size: 28px; font-weight: 700; letter-spacing: -.04em; }
.luxury-settings .settings-description { color: var(--text-secondary); font-size: 13px; line-height: 1.8; margin-bottom: 28px; }
.luxury-settings .settings-description { color: var(--text-secondary); font-size: 14px; line-height: 1.8; margin-bottom: 28px; }
.luxury-settings .settings-group { margin: 0 0 22px; padding: 20px; border: 0; border-radius: 16px; background: var(--surface-deep); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.luxury-settings .settings-group h4 { margin: 0 0 12px; font-size: 13px; color: var(--text-primary); }
.luxury-settings .settings-group h4 { margin: 0 0 12px; font-size: 14px; color: var(--text-primary); }
.theme-selector { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-bottom: 26px; }
.theme-option { position: relative; padding: 10px 10px 15px; min-width: 0; text-align: left; color: var(--text-primary); background: var(--surface-deep); border: 1px solid var(--border); border-radius: 17px; cursor: pointer; transition: box-shadow 180ms, border-color 180ms; }
.theme-option.active { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary), 0 5px 22px var(--accent-glow); }
.theme-option:hover { border-color: var(--primary); }
.theme-option > span:not(.theme-preview):not(.theme-check) { display: block; margin: 13px 8px 0; }
.theme-option strong, .theme-option small { display: block; }
.theme-option strong { font-size: 13px; }
.theme-option small { color: var(--text-tertiary); font-size: 11px; margin-top: 5px; }
.theme-option strong { font-size: 14px; }
.theme-option small { color: var(--text-tertiary); font-size: 12.5px; margin-top: 5px; }
.theme-check { position: absolute; bottom: 27px; right: 15px; display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid var(--border); border-radius: 50%; }
.theme-option.active .theme-check { color: var(--surface-deep); background: var(--primary); border-color: var(--primary); }
/* Miniature previews deliberately retain their own colors across both themes. */
@@ -168,16 +169,16 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
.mini-table i { display: block; height: 2px; margin: 4px 0; background: var(--mini-line); }
.setting-toggle, .setting-info { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 0; border-top: 1px solid var(--border-light); }
.setting-toggle { cursor: pointer; }
.setting-toggle strong, .setting-info strong { display: block; font-size: 13px; color: var(--text-primary); font-weight: 500; }
.setting-toggle small, .setting-info small { display: block; color: var(--text-tertiary); font-size: 12px; line-height: 1.7; margin-top: 3px; }
.preference-badge { flex-shrink: 0; font-size: 10px; color: var(--text-secondary); background: var(--surface-raised); border-radius: 20px; padding: 4px 8px; }
.setting-toggle strong, .setting-info strong { display: block; font-size: 14px; color: var(--text-primary); font-weight: 500; }
.setting-toggle small, .setting-info small { display: block; color: var(--text-tertiary); font-size: 13px; line-height: 1.7; margin-top: 3px; }
.preference-badge { flex-shrink: 0; font-size: 12px; color: var(--text-secondary); background: var(--surface-raised); border-radius: 20px; padding: 4px 8px; }
.setting-toggle input { position: absolute; width: 42px; height: 24px; right: 0; opacity: 0; }
.setting-toggle i { position: relative; pointer-events: none; width: 42px; height: 24px; flex: 0 0 42px; border: 1px solid var(--border-strong); border-radius: 99px; background: var(--surface-hover); transition: background 150ms; }
.setting-toggle i::after { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: var(--text-secondary); transition: transform 150ms; }
.setting-toggle input:checked + i { background: var(--primary); border-color: var(--primary); }
.setting-toggle input:checked + i::after { transform: translateX(18px); background: var(--surface); }
.setting-toggle input:focus-visible + i { outline: 2px solid var(--primary); outline-offset: 3px; }
.appearance-note { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--text-tertiary); margin: 0; }
.appearance-note { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--text-tertiary); margin: 0; }
.file-label input[type="file"] { display: none; }
@media (max-width: 1100px) {
.stats-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@@ -187,7 +188,6 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
.settings-modal.luxury-settings { height: calc(100dvh - 24px); flex-direction: column; border-radius: 22px; }
.luxury-settings .settings-sidebar { width: auto; flex: 0 0 auto; padding: 0; border: 0; }
.luxury-settings .settings-header { padding: 20px 22px 12px; }
.luxury-settings .settings-header .section-label { display: none; }
.luxury-settings .settings-nav { display: flex; gap: 6px; padding: 0 16px 12px; overflow-x: auto; }
.luxury-settings .settings-nav-item { white-space: nowrap; width: auto; flex: 1 0 auto; margin: 0; padding: 10px; }
.settings-sidebar-status { display: none; }
@@ -201,14 +201,14 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
.stat-card { gap: 8px; padding: 12px; }
.stat-icon { flex-basis: 22px; }
.stat-icon svg { width: 22px; height: 22px; }
.stat-label { font-size: 11px; }
.stat-label { font-size: 12.5px; }
.toolbar-search { min-width: 0; flex-basis: 100%; }
.settings-nav-item .settings-nav-icon { display: none; }
.luxury-settings .settings-group { padding: 16px; }
.theme-option { padding: 8px 8px 12px; }
.theme-option > span:not(.theme-preview):not(.theme-check) { margin-left: 0; }
.theme-check { right: 10px; bottom: 14px; width: 16px; height: 16px; }
.theme-option small { font-size: 10px; }
.theme-option small { font-size: 12px; }
.setting-info { flex-wrap: wrap; gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
@@ -219,62 +219,247 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
/* ============================================================
Global header + calendar dashboard + AI assistant
============================================================ */
.app-container { display: flex; min-height: 100vh; }
.main-content { min-height: 100vh; overflow: hidden; padding: 0; }
.route-content { min-height: calc(100vh - 72px); overflow-y: auto; padding: 32px clamp(18px, 3.6vw, 52px) 56px; }
.global-header { position: sticky; top: 0; z-index: 25; display: flex; align-items: center; gap: 22px; min-height: 72px; padding: 0 clamp(18px, 3.6vw, 52px); background: color-mix(in srgb, var(--bg-primary) 90%, transparent); border-bottom: 1px solid var(--border-light); backdrop-filter: blur(18px) saturate(1.25); }
.global-header-brand { display: flex; align-items: center; gap: 10px; min-width: 238px; }
.app-container { min-height: 100vh; }
.main-content { min-height: 0; overflow: hidden; padding: 0; }
.route-content { min-height: calc(100vh - var(--header-height)); overflow-y: auto; padding: 32px clamp(18px, 3.6vw, 52px) 56px; }
.global-header { position: sticky; top: 0; z-index: 25; display: flex; align-items: center; gap: 22px; min-height: var(--header-height); padding: 0 clamp(18px, 3.6vw, 52px); background: color-mix(in srgb, var(--bg-primary) 90%, transparent); border-bottom: 1px solid var(--border-light); backdrop-filter: blur(18px) saturate(1.25); }
.global-header-brand { display: flex; align-items: center; gap: 13px; min-width: 240px; }
.global-header-brand strong, .global-header-brand span { display: block; }
.global-header-brand strong { font-size: 13px; letter-spacing: .02em; }
.global-header-brand span:not(.header-status-dot) { color: var(--text-tertiary); font: 9px var(--font-mono); letter-spacing: .12em; margin-top: 3px; }
.header-status-dot { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 10px var(--success); }
.global-search { display: flex; align-items: center; gap: 9px; width: min(420px, 35vw); min-height: 38px; padding: 0 11px; color: var(--text-tertiary); background: var(--surface-deep); border: 1px solid var(--border-light); border-radius: 11px; transition: border-color 150ms, box-shadow 150ms; }
.global-search:focus-within { border-color: var(--primary-border); box-shadow: 0 0 0 3px var(--accent-glow); }
.global-search input { min-height: 0; padding: 0; border: 0; background: transparent; box-shadow: none; color: var(--text-primary); }
.global-header-brand strong { font-size: 19px; letter-spacing: .01em; line-height: 1.2; }
.global-header-brand span:not(.header-status-dot):not(.brand-mark) { color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .14em; margin-top: 2px; }
.header-status-dot { width: 9px; height: 9px; flex: 0 0 9px; border-radius: 50%; background: var(--success); box-shadow: 0 0 10px var(--success); }
.global-search { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; width: min(360px, 30vw); min-height: 38px; padding: 0 11px; color: var(--text-tertiary); background: var(--surface-deep); border: 0; border-radius: 11px; transition: background var(--transition-fast); }
.global-search:focus-within { background: var(--primary-bg); }
.global-search input { min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent; box-shadow: none; color: var(--text-primary); }
.global-search input:focus { box-shadow: none; }
.global-search kbd { margin-left: auto; padding: 2px 5px; color: var(--text-tertiary); background: var(--surface); border: 1px solid var(--border-light); border-radius: 5px; font: 10px var(--font-mono); }
.global-header-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.header-clock { display: flex; align-items: center; gap: 8px; margin-right: 8px; color: var(--text-tertiary); font: 11px var(--font-mono); white-space: nowrap; }
.header-clock b { color: var(--text-primary); font-size: 13px; font-weight: 500; }
.quick-create-btn, .ai-entry-btn, .mobile-search-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 8px 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 12px; transition: background 150ms, border-color 150ms, color 150ms; }
.global-search kbd { margin-left: auto; flex: 0 0 auto; white-space: nowrap; padding: 2px 5px; color: var(--text-tertiary); background: var(--surface); border: 1px solid var(--border-light); border-radius: 5px; font: 12px var(--font-mono); }
/* ---- 已打开页面标签:搜索框后的快速跳转(参考 GoodJob 的顶部标签条) ---- */
.page-tabs { display: flex; align-items: center; gap: 3px; flex: 0 1 auto; min-width: 0; max-width: min(580px, 38vw); padding: 4px; border: 1px solid var(--border-light); border-radius: 11px; background: var(--surface-deep); overflow-x: auto; scrollbar-width: none; }
.page-tabs::-webkit-scrollbar { display: none; }
.page-tab { display: inline-flex; align-items: center; flex: 0 0 auto; max-width: 176px; height: 30px; border-radius: 8px; background: transparent; transition: background 150ms, box-shadow 150ms; }
.page-tab:hover { background: var(--surface-hover); }
.page-tab.active { background: var(--surface); box-shadow: inset 0 1px 0 var(--panel-highlight), inset 0 0 0 1px var(--primary-border); }
.page-tab-label { max-width: 106px; height: 100%; padding: 0 4px 0 9px; border: 0; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color 150ms; }
.page-tab:hover .page-tab-label { color: var(--text-primary); }
.page-tab.active .page-tab-label { color: var(--text-primary); }
.page-tab-close { display: grid; place-items: center; width: 17px; height: 17px; margin-right: 4px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 14px; line-height: 1; transition: background 150ms, color 150ms; }
.page-tab-close:hover { background: var(--danger-bg); color: var(--danger); }
.page-tab.active .page-tab-close { color: var(--text-secondary); }
.page-tab.active .page-tab-close:hover { color: var(--danger); }
.global-header-actions { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; margin-left: auto; }
.header-clock { position: relative; flex: 0 0 auto; }
.clock-trigger { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid transparent; border-radius: 11px; background: transparent; color: var(--text-tertiary); cursor: pointer; white-space: nowrap; transition: background 150ms, border-color 150ms; }
.clock-trigger:hover, .clock-trigger.is-open { background: var(--surface-hover); border-color: var(--border-light); }
.clock-trigger > svg:first-child { flex: 0 0 auto; }
.clock-zone { color: var(--text-secondary); font-size: 13px; letter-spacing: .01em; }
.clock-caret { flex: 0 0 auto; color: var(--text-tertiary); transition: transform 180ms; }
.clock-trigger.is-open .clock-caret { transform: rotate(180deg); }
.clock-readout { display: grid; justify-items: end; gap: 2px; }
.clock-time { color: var(--text-primary); font: 600 20px/1 var(--font-mono); font-variant-numeric: tabular-nums; letter-spacing: .01em; }
.clock-date { color: var(--text-tertiary); font-size: 12px; font-style: normal; line-height: 1; }
.clock-pop { position: absolute; top: calc(100% + 9px); right: 0; z-index: 60; width: 318px; padding: 6px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
.clock-pop-head { padding: 8px 10px 7px; color: var(--text-tertiary); font-size: 12px; letter-spacing: .1em; }
.clock-pop-list { max-height: 344px; overflow-y: auto; }
.clock-zone-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; transition: background 120ms, color 120ms; }
.clock-zone-item:hover { background: var(--surface-hover); color: var(--text-primary); }
.clock-zone-item.active { background: var(--primary-bg); color: var(--text-primary); }
.clock-zone-name { white-space: nowrap; }
.clock-zone-right { display: flex; flex: 0 0 auto; align-items: baseline; gap: 7px; }
.clock-zone-right b { color: var(--text-primary); font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; }
.clock-zone-item.active .clock-zone-right b { color: var(--primary-light); }
.clock-zone-right em { color: var(--text-tertiary); font-size: 12px; font-style: normal; }
.quick-create-btn, .ai-entry-btn, .mobile-search-btn { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 36px; padding: 8px 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 13px; white-space: nowrap; transition: background 150ms, border-color 150ms, color 150ms; }
.quick-create-btn:hover, .mobile-search-btn:hover { color: var(--text-primary); background: var(--surface-hover); border-color: var(--border-light); }
.ai-entry-btn { color: var(--primary-light); background: var(--primary-bg); border-color: var(--primary-border); box-shadow: 0 0 18px var(--accent-glow); }
.ai-entry-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
.ai-entry-btn i { padding: 1px 4px; border-radius: 4px; background: var(--primary); color: var(--surface-deep); font: 9px var(--font-mono); font-style: normal; }
.ai-entry-btn i { padding: 1px 4px; border-radius: 4px; background: var(--primary); color: var(--surface-deep); font: 12px var(--font-mono); font-style: normal; }
.header-avatar { width: 34px; height: 34px; border: 1px solid var(--primary-border); border-radius: 50%; background: linear-gradient(135deg, var(--primary), var(--surface-raised)); color: var(--surface-deep); cursor: pointer; font-weight: 800; }
.mobile-search-btn { display: none; }
.dashboard-container { width: min(1480px, 100%); margin: 0 auto; }
.dashboard-welcome { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 22px; }
.dashboard-welcome .page-title { margin: 0; }
.dashboard-welcome p { margin: 7px 0 0; color: var(--text-secondary); font-size: 13px; }
.dashboard-date-chip { display: grid; gap: 5px; min-width: 150px; padding: 12px 14px; border-radius: 12px; background: var(--surface); box-shadow: var(--panel-shadow); text-align: right; }
.dashboard-date-chip span { color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
.dashboard-date-chip strong { color: var(--text-primary); font-size: 13px; font-weight: 600; }
.dashboard-welcome p { margin: 7px 0 0; color: var(--text-secondary); font-size: 14px; }
.dashboard-panel { margin-bottom: 18px; padding: 20px; border-radius: 16px; background: var(--surface); box-shadow: var(--panel-shadow); }
.dashboard-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.dashboard-panel-header h2 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-primary); font-size: 15px; }
.dashboard-panel-header .section-label { margin-bottom: 7px; }
.calendar-board { overflow: hidden; padding: 0; }
.calendar-board-header { padding: 20px 22px 16px; margin: 0; border-bottom: 1px solid var(--border-light); }
.calendar-toolbar { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); }
.calendar-toolbar button { display: inline-grid; place-items: center; min-width: 30px; height: 30px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; }
.calendar-board-header { padding: 12px 16px 10px; margin: 0; border-bottom: 1px solid var(--border-light); }
.calendar-toolbar { display: flex; align-items: center; gap: 7px; color: var(--text-secondary); }
.calendar-toolbar button { display: inline-grid; place-items: center; min-width: 28px; height: 28px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.calendar-toolbar button:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); }
.calendar-toolbar strong { min-width: 94px; color: var(--text-primary); text-align: center; font-size: 13px; }
.calendar-toolbar .calendar-today-btn, .calendar-toolbar .calendar-view-btn { padding: 0 9px; border-color: var(--border-light); background: var(--surface-deep); font-size: 11px; }
.calendar-toolbar .calendar-month-btn { display: inline-flex; align-items: center; justify-content: center; min-width: 88px; height: 28px; padding: 0 9px; color: var(--text-primary); font-size: 13.5px; font-variant-numeric: tabular-nums; }
.calendar-toolbar .calendar-month-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
.calendar-toolbar .calendar-month-btn.is-open { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-light); }
.calendar-toolbar .calendar-today-btn { padding: 0 9px; border-color: var(--border-light); background: var(--surface-deep); font-size: 12.5px; }
/* 快速跳转日期:平时是低调的日历图标,悬浮/展开变蓝 */
.calendar-toolbar .calendar-jump-btn { border-color: var(--border-light); background: var(--surface-deep); }
.calendar-toolbar .calendar-jump-btn:hover, .calendar-toolbar .calendar-jump-btn.is-open { border-color: var(--primary); background: var(--primary); color: var(--surface-deep); box-shadow: 0 0 14px var(--accent-glow); }
.calendar-week-row, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.calendar-week-row { background: var(--surface-deep); border-bottom: 1px solid var(--border-light); }
.calendar-week-row > div { padding: 10px 12px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
.calendar-day { min-height: 92px; padding: 8px 9px; border-right: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light); background: var(--surface); }
.calendar-week-row > div { padding: 5px 8px; color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .08em; }
.calendar-day { min-height: 60px; padding: 4px 6px; border-right: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light); background: var(--surface); }
.calendar-day:nth-child(7n) { border-right: 0; }
.calendar-day.is-outside { background: var(--surface-deep); opacity: .58; }
.calendar-day.is-today { background: linear-gradient(180deg, var(--primary-bg), var(--surface)); }
.calendar-day-number { display: inline-grid; place-items: center; min-width: 22px; height: 22px; color: var(--text-secondary); font: 11px var(--font-mono); }
.calendar-day.is-today .calendar-day-number { border-radius: 50%; background: var(--primary); color: var(--surface-deep); font-weight: 800; }
.calendar-events { display: grid; gap: 4px; margin-top: 5px; }
.calendar-event { display: block; overflow: hidden; padding: 3px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.calendar-day-number { display: inline-grid; place-items: center; min-width: 17px; height: 17px; color: var(--text-secondary); font: 12px var(--font-mono); transition: background var(--transition-base), color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-base); }
.calendar-day.is-today .calendar-day-number { border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--primary); color: var(--primary-light); font-weight: 800; }
.calendar-events { display: grid; gap: 2px; margin-top: 2px; }
.calendar-event { display: block; overflow: hidden; padding: 1px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 12px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
.calendar-event.danger { border-color: var(--danger); background: var(--danger-bg); color: var(--danger); }
.calendar-event.holiday { border-color: var(--success); background: var(--success-bg); color: var(--success); }
/* ---------- 工作日历交互:单击选中 → 再次单击添加日程;悬浮日程/节日看详情 ---------- */
.calendar-hint { margin: 5px 0 0; color: var(--text-tertiary); font-size: 12.5px; line-height: 1.4; }
.calendar-day { position: relative; cursor: pointer; transition: background var(--transition-fast), box-shadow var(--transition-fast); }
.calendar-day:hover { background: var(--surface-hover); }
.calendar-day.is-outside:hover { background: var(--surface-alt); opacity: .82; }
.calendar-day:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; z-index: 2; }
.calendar-day.is-selected { background: var(--primary-bg); }
.calendar-day.is-selected::after { content: ''; position: absolute; inset: 2px; border-radius: 8px; box-shadow: inset 0 0 0 1.5px var(--primary); pointer-events: none; animation: calendar-select-in 200ms cubic-bezier(.2, .8, .2, 1); }
@keyframes calendar-select-in { from { opacity: 0; transform: scale(.93); } to { opacity: 1; transform: scale(1); } }
.calendar-day.is-outside.is-selected { opacity: 1; }
.calendar-day.is-selected .calendar-day-number { border-radius: 50%; background: var(--primary); box-shadow: none; color: var(--surface-deep); font-weight: 800; transform: scale(1.05); }
.calendar-event { cursor: pointer; transition: filter var(--transition-fast), transform var(--transition-fast); }
.calendar-event:hover { filter: brightness(1.15); transform: translateX(1px); }
.calendar-event.done { border-color: var(--text-tertiary); background: var(--surface-deep); color: var(--text-tertiary); text-decoration: line-through; }
.calendar-more { justify-self: start; padding: 1px 5px; border-radius: 3px; background: var(--surface-deep); color: var(--text-tertiary); font: 12px var(--font-mono); cursor: pointer; }
.calendar-more:hover { color: var(--text-secondary); }
/* ---------- 日历右侧:今日 to do(备忘录)+ 选中日待办 ---------- */
.calendar-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(248px, 292px); align-items: stretch; }
.calendar-main { min-width: 0; }
.calendar-side { display: flex; flex-direction: column; min-width: 0; border-left: 1px solid var(--border-light); background: var(--surface-deep); }
.calendar-side-block { padding: 13px 15px 14px; border-bottom: 1px solid var(--border-light); }
.calendar-side-block:last-child { border-bottom: 0; }
.calendar-side-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 9px; }
.calendar-side-head h3 { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--text-primary); font-size: 14px; font-weight: 700; }
.calendar-side-head h3 svg { color: var(--primary-light); }
.calendar-side-sub { display: block; margin-top: 3px; color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .03em; }
.calendar-side-count { flex: 0 0 auto; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--primary-bg); color: var(--primary-light); font: 12px var(--font-mono); }
.calendar-side-add { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; height: 25px; padding: 0 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font-size: 12.5px; cursor: pointer; transition: border-color var(--transition-fast), color var(--transition-fast); }
.calendar-side-add:hover { border-color: var(--primary); color: var(--primary-light); }
.calendar-side-empty { margin: 0; padding: 14px 0 8px; color: var(--text-tertiary); font-size: 13px; text-align: center; }
/* 今日 to do */
/* 两块按列高平分(to do 略高),列表自己滚,不再缩在角落留一片空地 */
.calendar-side-block.is-memo { display: flex; flex-direction: column; flex: 1 1 58%; min-height: 0; }
.calendar-side-block.is-day { display: flex; flex-direction: column; flex: 1 1 42%; min-height: 0; }
.memo-list { display: grid; gap: 1px; flex: 1 1 auto; min-height: 0; margin: 0 0 8px; padding: 0; overflow-y: auto; list-style: none; }
.memo-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 4px 6px 5px; border-radius: var(--radius-sm); animation: memo-item-in 180ms cubic-bezier(.2, .8, .2, 1); }
@keyframes memo-item-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
.memo-item:hover { background: var(--surface-hover); }
.memo-check { flex: 0 0 15px; display: grid; place-items: center; width: 15px; height: 15px; margin-top: 2px; padding: 0; border: 1.5px solid var(--border-strong); border-radius: 4px; background: transparent; color: transparent; cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast), transform var(--transition-fast); }
.memo-check:hover { border-color: var(--primary); }
.memo-check:active { transform: scale(.86); }
.memo-item.is-done .memo-check { border-color: var(--primary); background: var(--primary); color: var(--surface-deep); }
.memo-text { flex: 1; min-width: 0; color: var(--text-primary); font-size: 13.5px; line-height: 1.45; word-break: break-word; transition: color var(--transition-base), opacity var(--transition-base); }
.memo-item.is-done .memo-text { color: var(--text-tertiary); opacity: .6; text-decoration: line-through; }
.memo-remove { flex: 0 0 auto; display: grid; place-items: center; width: 19px; height: 19px; padding: 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-disabled); cursor: pointer; opacity: 0; transition: opacity var(--transition-fast), color var(--transition-fast); }
.memo-item:hover .memo-remove, .memo-remove:focus-visible { opacity: 1; }
.memo-remove:hover { color: var(--danger); }
.memo-add { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border: 0; border-radius: 10px; background: var(--surface-deep); transition: background var(--transition-fast); }
.memo-add:focus-within { background: var(--primary-bg); }
.memo-add[data-saving="true"] { opacity: .6; }
.memo-add-icon { display: grid; place-items: center; flex: 0 0 auto; color: var(--text-tertiary); transition: color var(--transition-fast); }
.memo-add:focus-within .memo-add-icon { color: var(--primary-light); }
.memo-add input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text-primary); font: 13.5px var(--font-body); outline: none; }
.memo-add input::placeholder { color: var(--text-disabled); }
/* 选中日待办 */
.day-list { display: grid; align-content: start; gap: 7px; flex: 1 1 auto; min-height: 0; margin: 0; padding: 0; overflow-y: auto; list-style: none; }
.day-item { display: flex; gap: 8px; padding: 8px 9px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface); }
.day-item.is-holiday { border-color: var(--success-border); background: var(--success-bg); }
.day-item.is-done { opacity: .58; }
.day-dot { flex: 0 0 6px; width: 6px; height: 6px; margin-top: 6px; border-radius: 50%; background: var(--primary); }
.day-dot.danger { background: var(--danger); }
.day-dot.holiday { background: var(--success); }
.day-dot.done { background: var(--text-tertiary); }
.day-body { display: grid; gap: 3px; min-width: 0; }
.day-title { color: var(--text-primary); font-size: 13.5px; font-weight: 600; line-height: 1.4; word-break: break-word; }
.day-meta { color: var(--text-tertiary); font-size: 12.5px; line-height: 1.4; }
.day-note { color: var(--text-secondary); font-size: 12.5px; line-height: 1.5; }
.calendar-popover { position: fixed; z-index: 70; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: 0 20px 52px rgba(0, 0, 0, .26), inset 0 1px 0 var(--panel-highlight); }
.calendar-composer { width: min(306px, calc(100vw - 24px)); padding: 14px; }
.calendar-composer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 11px; }
.calendar-composer-head strong { display: block; color: var(--text-primary); font-size: 14px; }
.calendar-composer-head small { display: block; margin-top: 3px; color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .06em; }
.calendar-popover-close { display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 24px; border: 1px solid var(--border-light); border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; }
.calendar-popover-close:hover { border-color: var(--border); color: var(--text-primary); }
.calendar-field { display: block; margin-bottom: 10px; }
.calendar-field > span { display: block; margin-bottom: 5px; color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .08em; }
.calendar-field input, .calendar-field textarea { width: 100%; padding: 8px 10px; border: 0; border-radius: 9px; background: var(--surface-deep); color: var(--text-primary); font: 13px var(--font-body); }
.calendar-field textarea { min-height: 56px; resize: vertical; }
.calendar-field input::placeholder, .calendar-field textarea::placeholder { color: var(--text-disabled); }
.calendar-field input:focus, .calendar-field textarea:focus { outline: none; border: 0; background: var(--primary-bg); box-shadow: none; }
.calendar-priority-row { display: flex; gap: 6px; }
.calendar-priority-row button { flex: 1; height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--text-secondary); font-size: 12.5px; cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast); }
.calendar-priority-row button:hover { border-color: var(--border-strong); color: var(--text-primary); }
.calendar-priority-row button.is-active { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-light); font-weight: 700; }
.calendar-composer-error { margin: 0 0 8px; color: var(--danger); font-size: 12.5px; }
.calendar-composer-actions { display: flex; gap: 8px; margin-top: 12px; }
.calendar-composer-actions button { flex: 1; height: 32px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; }
.calendar-composer-actions .is-ghost { border: 1px solid var(--border); background: transparent; color: var(--text-secondary); }
.calendar-composer-actions .is-ghost:hover { border-color: var(--border-strong); color: var(--text-primary); }
.calendar-composer-actions .is-primary { border: 1px solid var(--primary); background: var(--primary); color: var(--surface-deep); font-weight: 700; }
.calendar-composer-actions .is-primary:disabled { opacity: .55; cursor: not-allowed; }
.calendar-tip { width: min(268px, calc(100vw - 24px)); padding: 12px; pointer-events: none; }
.calendar-tip-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.calendar-tip-kind { padding: 2px 8px; border-radius: 999px; font: 12px var(--font-mono); letter-spacing: .1em; }
.calendar-tip-kind.is-todo { background: var(--primary-bg); color: var(--primary-light); }
.calendar-tip-kind.is-holiday { background: var(--success-bg); color: var(--success); }
.calendar-tip-date { color: var(--text-tertiary); font: 12px var(--font-mono); }
.calendar-tip-title { color: var(--text-primary); font-size: 14px; font-weight: 700; line-height: 1.38; }
.calendar-tip-body { display: grid; gap: 5px; margin: 9px 0 0; }
.calendar-tip-row { display: flex; gap: 10px; font-size: 12.5px; }
.calendar-tip-row dt { flex: 0 0 44px; color: var(--text-tertiary); }
.calendar-tip-row dd { margin: 0; color: var(--text-secondary); }
.calendar-tip-note { margin: 9px 0 0; padding-top: 9px; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 12.5px; line-height: 1.55; }
.calendar-tip-more { display: grid; gap: 4px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-light); }
.calendar-tip-more span { color: var(--text-secondary); font-size: 12.5px; line-height: 1.4; }
.calendar-side-block.is-day .calendar-side-empty { display: grid; place-items: center; flex: 1 1 auto; padding: 10px 0; }
/* ---------- 快速跳转日期浮层(工具条上的小日历按钮) ---------- */
.calendar-jump-pop { z-index: 82; width: min(262px, calc(100vw - 24px)); padding: 10px 10px 9px; }
.jump-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 7px; }
.jump-head button { display: grid; place-items: center; width: 24px; height: 24px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; }
.jump-head button:hover { border-color: var(--border-light); background: var(--surface-hover); color: var(--text-primary); }
.jump-head strong { flex: 1; color: var(--text-primary); text-align: center; font-size: 13px; }
.jump-week, .jump-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.jump-week span { padding: 3px 0; color: var(--text-tertiary); text-align: center; font: 12px var(--font-mono); }
.jump-grid { gap: 1px; }
.jump-day { display: grid; place-items: center; height: 27px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-secondary); font: 12.5px var(--font-mono); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.jump-day:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
.jump-day.is-outside { color: var(--text-disabled); }
.jump-day.is-today { box-shadow: inset 0 0 0 1.5px var(--primary); color: var(--primary-light); font-weight: 800; }
.jump-day.is-selected { border-color: var(--primary); background: var(--primary); color: var(--surface-deep); font-weight: 800; }
.jump-today { width: 100%; height: 27px; margin-top: 8px; border: 1px solid var(--border-light); border-radius: 7px; background: var(--surface-deep); color: var(--text-secondary); font-size: 12.5px; cursor: pointer; }
.jump-today:hover { border-color: var(--primary); color: var(--primary-light); }
/* 跳转浮层标题 = 直接输入年月(前 4 位年 + 后 1~2 位月,回车跳转);无描边,只靠底色 */
.jump-head .jump-month-input { display: block; flex: 1; width: auto; min-width: 0; min-height: 0; height: 26px; padding: 0 8px; border: 0; border-radius: 8px; background: var(--surface-deep); box-shadow: none; color: var(--text-primary); font: 13px var(--font-body); font-variant-numeric: tabular-nums; text-align: center; outline: none; cursor: text; transition: background var(--transition-fast), color var(--transition-fast); }
.jump-head .jump-month-input:hover { background: var(--primary-bg); color: var(--primary-light); }
.jump-head .jump-month-input:focus { background: var(--primary-bg); color: var(--primary-light); font-weight: 700; letter-spacing: .3px; }
.jump-head .jump-month-input.is-invalid { background: var(--danger-bg); color: var(--danger); }
.jump-head .jump-month-input::selection { background: var(--primary); color: var(--surface-deep); }
/* 年月拨盘(iOS 闹钟式滚轮) */
.calendar-wheel-pop { --wheel-item: 32px; padding: 12px 14px 10px; }
.ymw-body { display: grid; gap: 9px; }
.ymw-drum { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; }
.ymw-highlight { position: absolute; left: 2px; right: 2px; top: 50%; height: var(--wheel-item); transform: translateY(-50%); border: 1px solid var(--primary-border); border-radius: 9px; background: var(--primary-bg); pointer-events: none; }
.ymw-col { position: relative; width: 72px; height: calc(var(--wheel-item) * 3); }
.ymw-list { height: 100%; overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scrollbar-width: none; touch-action: none; cursor: grab; }
.ymw-list::-webkit-scrollbar { display: none; }
.ymw-list.is-dragging { scroll-snap-type: none; cursor: grabbing; }
.ymw-cell { display: block; width: 100%; height: var(--wheel-item); border: 0; background: transparent; color: var(--text-tertiary); font: 15px var(--font-mono); font-variant-numeric: tabular-nums; text-align: center; scroll-snap-align: center; cursor: pointer; transform-origin: center; transition: color var(--transition-fast); }
.ymw-cell:hover { color: var(--text-secondary); }
.ymw-cell.is-active { color: var(--primary-light); font-weight: 800; }
.ymw-pad { height: var(--wheel-item); }
.ymw-unit { color: var(--text-tertiary); font-size: 13px; }
.ymw-today { width: 100%; height: 27px; border: 1px solid var(--border-light); border-radius: 7px; background: var(--surface-deep); color: var(--text-secondary); font-size: 12.5px; cursor: pointer; transition: border-color var(--transition-fast), color var(--transition-fast); }
.ymw-today:hover { border-color: var(--primary); color: var(--primary-light); }
.dashboard-content-grid { display: grid; grid-template-columns: minmax(0, 1.52fr) minmax(280px, .72fr); gap: 18px; align-items: start; }
.dashboard-primary-column, .dashboard-secondary-column { min-width: 0; }
.ai-digest-panel { background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 82%, var(--surface)), var(--surface)); }
@@ -283,13 +468,13 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
.ai-digest-body p { margin: 8px 0 12px; color: var(--text-secondary); line-height: 1.75; }
.ai-digest-body b { color: var(--primary-light); }
.digest-tags { display: flex; flex-wrap: wrap; gap: 7px; }
.digest-tags span, .panel-period { padding: 4px 8px; border-radius: 99px; background: var(--surface-raised); color: var(--text-tertiary); font: 10px var(--font-mono); }
.digest-tags span, .panel-period { padding: 4px 8px; border-radius: 99px; background: var(--surface-raised); color: var(--text-tertiary); font: 12px var(--font-mono); }
.spark-mark { color: var(--primary-light); }
.panel-live-status { display: inline-flex; align-items: center; gap: 6px; color: var(--success); font: 10px var(--font-mono); }
.panel-live-status { display: inline-flex; align-items: center; gap: 6px; color: var(--success); font: 12px var(--font-mono); }
.panel-live-status i, .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.panel-link { border: 0; background: transparent; color: var(--primary-light); cursor: pointer; font-size: 12px; }
.panel-link { border: 0; background: transparent; color: var(--primary-light); cursor: pointer; font-size: 13px; }
.dashboard-empty { padding: 26px 8px; color: var(--text-tertiary); text-align: center; }
.stat-rail .stat-line { display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 8px; padding: 13px 0; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 12px; }
.stat-rail .stat-line { display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 8px; padding: 13px 0; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 13px; }
.stat-line-icon { display: grid; place-items: center; color: var(--primary-light); }
.stat-line strong { color: var(--text-primary); font: 700 19px var(--font-mono); }
.completion-panel { text-align: center; }
@@ -298,36 +483,113 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
.completion-ring::before { content: ""; position: absolute; width: 106px; height: 106px; border-radius: 50%; background: var(--surface); }
.completion-ring > div { position: relative; display: grid; gap: 3px; }
.completion-ring strong { color: var(--text-primary); font: 700 27px var(--font-mono); }
.completion-ring span { color: var(--text-tertiary); font-size: 11px; }
.completion-legend { display: flex; justify-content: center; gap: 16px; color: var(--text-tertiary); font-size: 11px; }
.completion-ring span { color: var(--text-tertiary); font-size: 12.5px; }
.completion-legend { display: flex; justify-content: center; gap: 16px; color: var(--text-tertiary); font-size: 12.5px; }
.completion-legend span { display: flex; align-items: center; gap: 6px; }
.completion-legend .done { color: var(--primary); }.completion-legend .pending { color: var(--text-disabled); }
.quick-panel > button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 11px 0; border: 0; border-top: 1px solid var(--border-light); background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; font-size: 12px; }.quick-panel > button:hover { color: var(--primary-light); }
.quick-panel > button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 11px 0; border: 0; border-top: 1px solid var(--border-light); background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; font-size: 13px; }.quick-panel > button:hover { color: var(--primary-light); }
/* AI assistant drawer */
.assistant-layer { position: fixed; inset: 0; z-index: 80; background: rgba(3, 8, 15, .46); backdrop-filter: blur(5px); }
.ai-assistant-panel { position: absolute; top: 14px; right: 14px; bottom: 14px; display: flex; flex-direction: column; width: min(430px, calc(100vw - 28px)); overflow: hidden; border: 1px solid var(--border-light); border-radius: 22px; background: var(--surface); box-shadow: 0 28px 80px rgba(0,0,0,.36), inset 0 1px 0 var(--panel-highlight); animation: assistant-in 220ms var(--ease-out); }
@keyframes assistant-in { from { opacity: 0; transform: translateX(22px); } to { opacity: 1; transform: translateX(0); } }
.ai-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 20px 14px; }
.ai-panel-title { display: flex; align-items: center; gap: 11px; }.ai-panel-title h2 { margin: 0; font-size: 21px; }.ai-panel-title .section-label { margin-bottom: 4px; }
.ai-orb { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 15px; color: var(--primary-light); background: var(--primary-bg); box-shadow: 0 0 20px var(--accent-glow); }
.ai-close-btn { display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; }.ai-close-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
.ai-access-banner { display: flex; align-items: center; gap: 10px; margin: 0 20px 14px; padding: 10px 12px; border: 1px solid var(--primary-border); border-radius: 12px; background: var(--primary-bg); }.ai-access-banner strong, .ai-access-banner small { display: block; }.ai-access-banner strong { color: var(--text-primary); font-size: 12px; }.ai-access-banner small { margin-top: 3px; color: var(--text-secondary); font-size: 10px; }.ai-live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); }
.ai-panel-body { flex: 1; overflow-y: auto; padding: 0 20px 16px; }.ai-capability-card { padding: 13px; border-radius: 14px; background: var(--surface-deep); box-shadow: inset 0 1px 0 var(--panel-highlight); }.ai-capability-heading { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; color: var(--primary-light); font-size: 12px; font-weight: 700; }.ai-capability-row { display: flex; gap: 7px; padding: 4px 0; color: var(--text-secondary); font-size: 11px; }.ai-capability-row span { color: var(--success); }
.ai-context-strip { display: flex; flex-wrap: wrap; gap: 7px; margin: 12px 0; color: var(--text-tertiary); font-size: 10px; }.ai-context-strip span { margin-right: auto; }.ai-context-strip b { padding: 3px 6px; border-radius: 5px; color: var(--text-secondary); background: var(--surface-deep); font-weight: 500; }
.ai-message-list { display: grid; gap: 13px; margin-top: 14px; }.ai-message { display: flex; align-items: flex-start; gap: 8px; }.ai-message.user { flex-direction: row-reverse; }.ai-message-avatar { display: grid; place-items: center; width: 25px; height: 25px; flex: 0 0 25px; border-radius: 9px; background: var(--surface-deep); color: var(--primary-light); font-size: 10px; font-weight: 800; }.ai-message.user .ai-message-avatar { color: var(--surface-deep); background: var(--primary); }.ai-message-content { max-width: 82%; padding: 9px 11px; border-radius: 4px 13px 13px 13px; background: var(--surface-deep); }.ai-message.user .ai-message-content { border-radius: 13px 4px 13px 13px; background: var(--primary-bg); }.ai-message-content p { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.7; }.ai-message-content small { display: block; margin-top: 5px; color: var(--text-tertiary); font: 9px var(--font-mono); }.ai-thinking { display: flex; gap: 4px; align-items: center; min-height: 34px; }.ai-thinking span { width: 5px; height: 5px; border-radius: 50%; background: var(--primary); animation: ai-bounce 1s infinite ease-in-out; }.ai-thinking span:nth-child(2) { animation-delay: .15s; }.ai-thinking span:nth-child(3) { animation-delay: .3s; }@keyframes ai-bounce { 0%,80%,100% { transform: translateY(0); opacity:.45; } 40% { transform: translateY(-4px); opacity:1; } }
.ai-suggestion-row { display: flex; gap: 7px; padding: 8px 20px; overflow-x: auto; }.ai-suggestion-row button { flex: 0 0 auto; padding: 6px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; font-size: 10px; }.ai-suggestion-row button:hover { color: var(--primary-light); border-color: var(--primary-border); }
.ai-input-row { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border-light); }.ai-input-row input { min-height: 40px; background: var(--surface-deep); }.ai-input-row button { display: grid; place-items: center; width: 40px; min-width: 40px; border: 0; border-radius: 11px; background: var(--primary); color: var(--surface-deep); cursor: pointer; }.ai-input-row button:disabled { cursor: not-allowed; opacity: .4; }.ai-panel-footer { padding: 8px 20px 13px; color: var(--text-tertiary); font-size: 10px; text-align: center; }
@media (max-width: 980px) { .global-header-brand { min-width: auto; }.global-header-brand div { display: none; }.global-search { width: min(360px, 40vw); }.header-clock span { display: none; }.dashboard-content-grid { grid-template-columns: 1fr; }.dashboard-secondary-column { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 0; } }
@media (max-width: 680px) { .global-header { gap: 8px; min-height: 62px; padding: 0 12px; }.global-search { display: none; }.global-search.is-open { position: absolute; left: 12px; right: 12px; top: 65px; display: flex; width: auto; background: var(--surface); }.mobile-search-btn { display: inline-flex; }.quick-create-btn span, .ai-entry-btn span, .header-clock { display: none; }.ai-entry-btn { padding: 8px; }.route-content { min-height: calc(100vh - 62px); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.dashboard-date-chip { text-align: left; }.calendar-day { min-height: 68px; padding: 5px 4px; }.calendar-week-row > div { padding: 8px 4px; }.calendar-event { font-size: 8px; }.calendar-day-number { font-size: 10px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-assistant-panel { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); } }
/* AI assistant —— 右上角浮动小窗。没有遮罩、不 inert 主界面,可以边聊边自己点系统 */
.ai-window { position: fixed; top: calc(var(--header-height) + 14px); right: 16px; z-index: 78; display: flex; flex-direction: column; width: min(440px, calc(100vw - 24px)); max-height: min(720px, calc(100vh - var(--header-height) - 28px)); overflow: hidden; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); box-shadow: 0 22px 60px rgba(0, 0, 0, .34), inset 0 1px 0 var(--panel-highlight); animation: ai-window-in 200ms var(--ease-out); }
@keyframes ai-window-in { from { opacity: 0; transform: translateY(-10px) scale(.98); } to { opacity: 1; transform: none; } }
.ai-window.is-minimized { width: min(258px, calc(100vw - 24px)); }
.ai-window-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 9px 9px 12px; border-bottom: 1px solid var(--border-light); background: var(--surface-deep); cursor: grab; touch-action: none; user-select: none; }
.ai-window-bar:active { cursor: grabbing; }
.ai-window.is-minimized .ai-window-bar { border-bottom: 0; }
.ai-window-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
/* 之前默认色用 --text-tertiary 在深色主题下基本看不见(Wayne 2026-09-16 吐槽「关闭按钮看不到」)。
升到 --text-secondary 让最小化 / 关闭两个按钮一眼能找到;
关闭按钮单独加危险色 hover(红字 + 红底 + 红边)让"关闭窗口"动作有反馈。 */
.ai-icon-btn { display: grid; place-items: center; width: 25px; height: 25px; padding: 0; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.ai-icon-btn:hover { border-color: var(--border-light); background: var(--surface-hover); color: var(--text-primary); }
.ai-icon-btn.ai-close-btn:hover { color: #ff5d6c; border-color: rgba(255, 93, 108, .4); background: rgba(255, 93, 108, .12); }
.ai-panel-title { display: flex; align-items: center; gap: 9px; min-width: 0; }
.ai-panel-title h2 { margin: 0; font-size: 15px; }
.ai-orb { display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px; border-radius: 10px; color: var(--primary-light); background: var(--primary-bg); box-shadow: 0 0 16px var(--accent-glow); }
.ai-access-banner { display: flex; align-items: center; gap: 9px; margin: 0 0 10px; padding: 8px 10px; border: 1px solid var(--primary-border); border-radius: 10px; background: var(--primary-bg); }.ai-access-banner strong, .ai-access-banner small { display: block; }.ai-access-banner strong { color: var(--text-primary); font-size: 13px; }.ai-access-banner small { margin-top: 2px; color: var(--text-secondary); font-size: 12px; }.ai-live-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); }
.ai-panel-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 13px; }.ai-capability-card { padding: 10px 11px; border-radius: 11px; background: var(--surface-deep); box-shadow: inset 0 1px 0 var(--panel-highlight); }.ai-capability-heading { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; color: var(--primary-light); font-size: 13px; font-weight: 700; }.ai-capability-row { display: flex; gap: 7px; padding: 3px 0; color: var(--text-secondary); font-size: 12.5px; }.ai-capability-row span { color: var(--success); }
.ai-context-strip { display: flex; flex-wrap: wrap; gap: 7px; margin: 12px 0; color: var(--text-tertiary); font-size: 12px; }.ai-context-strip span { margin-right: auto; }.ai-context-strip b { padding: 3px 6px; border-radius: 5px; color: var(--text-secondary); background: var(--surface-deep); font-weight: 500; }
.ai-message-list { display: grid; gap: 11px; margin-top: 12px; }.ai-message { display: flex; align-items: flex-start; gap: 8px; }.ai-message.user { flex-direction: row-reverse; }.ai-message-avatar { display: grid; place-items: center; width: 25px; height: 25px; flex: 0 0 25px; border-radius: 9px; background: var(--surface-deep); color: var(--primary-light); font-size: 12px; font-weight: 800; }.ai-message.user .ai-message-avatar { color: var(--surface-deep); background: var(--primary); }.ai-message-content { max-width: 82%; padding: 8px 10px; border-radius: 4px 12px 12px 12px; background: var(--surface-deep); }.ai-message.user .ai-message-content { border-radius: 12px 4px 12px 12px; background: var(--primary-bg); }.ai-message-content p { margin: 0; color: var(--text-secondary); font-size: 13px; line-height: 1.65; }.ai-message-content small { display: block; margin-top: 5px; color: var(--text-tertiary); font: 12px var(--font-mono); }.ai-thinking { display: flex; gap: 4px; align-items: center; min-height: 32px; }.ai-thinking span { width: 5px; height: 5px; border-radius: 50%; background: var(--primary); animation: ai-bounce 1s infinite ease-in-out; }.ai-thinking span:nth-child(2) { animation-delay: .15s; }.ai-thinking span:nth-child(3) { animation-delay: .3s; }@keyframes ai-bounce { 0%,80%,100% { transform: translateY(0); opacity:.45; } 40% { transform: translateY(-4px); opacity:1; } }
.ai-suggestion-row { display: flex; gap: 7px; padding: 7px 13px; overflow-x: auto; }.ai-suggestion-row button { flex: 0 0 auto; padding: 5px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; font-size: 12px; }.ai-suggestion-row button:hover { color: var(--primary-light); border-color: var(--primary-border); }
.ai-input-row { display: flex; gap: 8px; padding: 9px 12px; border-top: 1px solid var(--border-light); }.ai-input-row input { min-height: 36px; padding: 0 12px; border: 0; border-radius: 12px; background: var(--surface-deep); }
.ai-input-row input:focus { background: var(--primary-bg); }.ai-input-row button { display: grid; place-items: center; width: 36px; min-width: 36px; border: 0; border-radius: 10px; background: var(--primary); color: var(--surface-deep); cursor: pointer; }.ai-input-row button:disabled { cursor: not-allowed; opacity: .4; }.ai-panel-footer { padding: 6px 13px 10px; color: var(--text-tertiary); font-size: 12px; text-align: center; }
/* ---------- 汇率计算器(顶栏) ---------- */
.fx-calc { position: relative; flex: 0 0 auto; }
.header-tool-btn { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background 150ms, border-color 150ms, color 150ms; }
.header-tool-btn:hover { color: var(--text-primary); background: var(--surface-hover); border-color: var(--border-light); }
.header-tool-btn.is-open { color: var(--primary-light); background: var(--primary-bg); border-color: var(--primary-border); }
.fx-panel { position: absolute; top: calc(100% + 9px); right: 0; z-index: 60; width: min(396px, calc(100vw - 24px)); padding: 14px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
.fx-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.fx-panel-title { display: inline-flex; align-items: center; gap: 7px; color: var(--text-primary); font-size: 13.5px; font-weight: 600; }
.fx-refresh { display: grid; place-items: center; width: 26px; height: 26px; padding: 0; border: 1px solid var(--border-light); border-radius: 8px; background: transparent; color: var(--text-tertiary); cursor: pointer; transition: color 150ms, border-color 150ms; }
.fx-refresh:hover { color: var(--primary-light); border-color: var(--primary-border); }
.fx-refresh:disabled { opacity: .45; cursor: default; }
.fx-panel-body { display: flex; align-items: center; gap: 10px; }
.fx-side { position: relative; flex: 1 1 0; min-width: 0; display: grid; gap: 6px; }
.fx-amount { width: 100%; height: 44px; padding: 0 12px; border: 0; border-radius: 11px; background: var(--surface-deep); color: var(--text-primary); font: 600 17px var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
.fx-amount:focus { outline: none; background: var(--primary-bg); color: var(--primary-light); box-shadow: none; }
.fx-swap { display: grid; place-items: center; flex: 0 0 auto; width: 34px; height: 34px; padding: 0; border: 1px solid var(--border-light); border-radius: 50%; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; transition: color 150ms, border-color 150ms, transform 200ms; }
.fx-swap:hover { color: var(--primary-light); border-color: var(--primary-border); transform: rotate(180deg); }
.fx-picker { position: relative; }
.fx-picker-btn { display: flex; align-items: center; gap: 6px; width: 100%; height: 34px; padding: 0 9px; border: 1px solid var(--border-light); border-radius: 10px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; transition: border-color 150ms, color 150ms; }
.fx-picker-btn:hover, .fx-picker-btn.is-open { border-color: var(--primary-border); color: var(--text-primary); }
.fx-picker-btn b { flex: 0 0 auto; color: var(--text-primary); font-size: 13.5px; font-weight: 600; letter-spacing: .02em; }
.fx-picker-btn span { flex: 1 1 auto; overflow: hidden; color: var(--text-tertiary); font-size: 12.5px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.fx-picker-btn svg { flex: 0 0 auto; color: var(--text-tertiary); }
.fx-picker-pop { position: absolute; top: calc(100% + 5px); z-index: 5; width: 232px; padding: 6px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
.fx-picker.left .fx-picker-pop { left: 0; }
.fx-picker.right .fx-picker-pop { right: 0; }
.fx-picker-search { width: 100%; height: 30px; margin-bottom: 5px; padding: 0 9px; border: 1px solid var(--border-light); border-radius: 8px; background: var(--surface-deep); color: var(--text-primary); font-size: 13px; }
.fx-picker-search:focus { outline: none; border-color: var(--primary-border); }
.fx-picker-list { max-height: 220px; overflow-y: auto; }
.fx-picker-item { display: flex; align-items: baseline; gap: 7px; width: 100%; padding: 6px 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; }
.fx-picker-item:hover { background: var(--surface-hover); color: var(--text-primary); }
.fx-picker-item.active { background: var(--primary-bg); }
.fx-picker-item b { flex: 0 0 auto; color: var(--text-primary); font-size: 13px; font-weight: 600; }
.fx-picker-item.active b { color: var(--primary-light); }
.fx-picker-item span { overflow: hidden; font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
.fx-picker-empty { padding: 12px 9px; color: var(--text-tertiary); font-size: 12.5px; text-align: center; }
.fx-rate-line { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 13px; }
.fx-rate-line b { color: var(--primary-light); font-family: var(--font-mono); font-size: 14px; }
.fx-rate-line em { margin-left: 7px; color: var(--text-tertiary); font-size: 12.5px; font-style: normal; }
.fx-error, .fx-warn { color: var(--warning); }
.fx-panel-foot { margin-top: 7px; color: var(--text-tertiary); font-size: 12px; }
/* 时钟与汇率入口加进来后顶栏变宽,中等屏先收紧留白与品牌区,避免把右侧按钮挤出去 */
@media (max-width: 1440px) {
.global-header { gap: 16px; padding: 0 clamp(14px, 2.2vw, 34px); }
.global-header-brand { min-width: 196px; }
.global-search { flex: 0 1 auto; width: min(340px, 26vw); min-width: 148px; }
.global-header-actions { gap: 7px; }
}
@media (max-width: 1280px) { .page-tabs { display: none; } }
/* 侧栏 + 右侧面板同时存在时,日历再窄就放不下 7 列了,改成上下排 */
@media (max-width: 1200px) {
.calendar-layout { grid-template-columns: 1fr; }
.calendar-side { flex-direction: row; border-left: 0; border-top: 1px solid var(--border-light); }
.calendar-side-block { flex: 1 1 50%; min-width: 0; border-bottom: 0; border-right: 1px solid var(--border-light); }
.calendar-side-block:last-child { border-right: 0; }
.memo-list, .day-list { max-height: 186px; }
}
@media (max-width: 780px) {
.calendar-side { flex-direction: column; }
.calendar-side-block { border-right: 0; border-bottom: 1px solid var(--border-light); }
.calendar-side-block:last-child { border-bottom: 0; }
}
/* 窄屏下标题与工具条抢宽度,改成上下两行,避免「工作日历」被折成两列 */
@media (max-width: 680px) {
.calendar-board-header { flex-direction: column; align-items: stretch; gap: 10px; }
.calendar-toolbar { justify-content: space-between; }
}
@media (max-width: 980px) { .global-header-brand { min-width: auto; }.global-header-brand div { display: none; }.global-search { width: min(360px, 40vw); }.clock-zone, .clock-caret { display: none; }.dashboard-content-grid { grid-template-columns: 1fr; }.dashboard-secondary-column { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 0; } }
@media (max-width: 680px) { :root { --header-height: 62px; }.global-header { gap: 8px; padding: 0 12px; }.global-search { display: none; }.global-search.is-open { position: absolute; left: 12px; right: 12px; top: 65px; display: flex; width: auto; background: var(--surface); }.mobile-search-btn { display: inline-flex; }.quick-create-btn span, .ai-entry-btn span, .header-clock { display: none; }.ai-entry-btn { padding: 8px; }.fx-panel { position: fixed; top: calc(var(--header-height) + 8px); left: 12px; right: 12px; width: auto; max-height: calc(100vh - var(--header-height) - 24px); overflow-y: auto; }.route-content { min-height: calc(100vh - var(--header-height)); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.calendar-day { min-height: 48px; padding: 3px 4px; }.calendar-week-row > div { padding: 4px 4px; }.calendar-event { font-size: 12.5px; }.calendar-day-number { font-size: 12px; }.calendar-side-block { padding: 12px; }.memo-list, .day-list { max-height: 180px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-window { top: calc(var(--header-height) + 8px); right: 8px; width: calc(100vw - 16px); max-height: min(72vh, 540px); } }
/* Requested compact navigation controls. */
.collapse-btn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-deep); color: var(--text-secondary); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.collapse-btn:hover { color: var(--primary-light); border-color: var(--primary-border); background: var(--primary-bg); }
.collapse-btn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--sb-border); border-radius: 8px; background: transparent; color: var(--sb-text); box-shadow: none; }
.collapse-btn:hover { color: #04131c; border-color: var(--sb-accent); background: var(--sb-accent); }
.collapse-btn svg { width: 19px; height: 19px; }
.sidebar-footer { gap: 8px; }
.theme-toggle-btn { display: grid; place-items: center; width: 40px; height: 40px; margin-left: auto; padding: 0; border: 1px solid var(--border-light); border-radius: 50%; background: var(--surface); color: var(--primary-light); box-shadow: inset 0 1px 0 var(--panel-highlight); cursor: pointer; transition: background 150ms, border-color 150ms, transform 150ms; }
.theme-toggle-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); transform: translateY(-1px); }
.theme-toggle-btn { display: grid; place-items: center; width: 40px; height: 40px; margin-left: auto; padding: 0; border: 1px solid var(--sb-border); border-radius: 50%; background: transparent; color: var(--sb-accent); box-shadow: none; cursor: pointer; transition: background 150ms, border-color 150ms, transform 150ms; }
.theme-toggle-btn:hover { border-color: var(--sb-active-ring); background: var(--sb-hover-bg); transform: translateY(-1px); }
.sidebar.collapsed .theme-toggle-btn { margin-left: 0; }
/* Search everything: results are scoped to ordinary business data only. */
@@ -335,20 +597,137 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
.global-search-results { position: absolute; top: calc(100% + 9px); left: 0; width: min(580px, calc(100vw - 32px)); max-height: min(520px, calc(100vh - 100px)); overflow-y: auto; padding: 7px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
.global-search-results button { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 10px; width: 100%; padding: 10px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
.global-search-results button:hover, .global-search-results button:focus-visible { background: var(--surface-hover); outline: none; }
.search-result-mark { align-self: start; padding: 3px 5px; border-radius: 5px; background: var(--surface-deep); color: var(--text-tertiary); font-size: 10px; text-align: center; }
.search-result-mark { align-self: start; padding: 3px 5px; border-radius: 5px; background: var(--surface-deep); color: var(--text-tertiary); font-size: 12px; text-align: center; }
.search-result-mark.product, .search-result-mark.customer { color: var(--primary-light); background: var(--primary-bg); }
.search-result-mark.order, .search-result-mark.deal { color: var(--warning); background: var(--warning-bg); }
.search-result-mark.todo, .search-result-mark.activity { color: var(--success); background: var(--success-bg); }
.global-search-results strong, .global-search-results small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.global-search-results strong { font-size: 12px; }
.global-search-results small { margin-top: 3px; color: var(--text-tertiary); font-size: 10px; }
.global-search-status { padding: 17px 12px; color: var(--text-tertiary); font-size: 12px; text-align: center; }
.global-search-footnote { margin: 4px 3px 1px; padding: 9px 8px 4px; border-top: 1px solid var(--border-light); color: var(--text-tertiary); font-size: 10px; }
.global-search-results strong { font-size: 13px; }
.global-search-results small { margin-top: 3px; color: var(--text-tertiary); font-size: 12px; }
.global-search-status { padding: 17px 12px; color: var(--text-tertiary); font-size: 13px; text-align: center; }
.global-search-footnote { margin: 4px 3px 1px; padding: 9px 8px 4px; border-top: 1px solid var(--border-light); color: var(--text-tertiary); font-size: 12px; }
.product-category-summary { display: flex; flex-wrap: wrap; gap: 7px; margin: -4px 0 15px; }
.product-category-summary button { padding: 5px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 11px; }
.product-category-summary button { padding: 5px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 12.5px; }
.product-category-summary button:hover, .product-category-summary button.active { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
@media (max-width: 680px) { .global-search-results { position: fixed; top: 70px; left: 12px; width: calc(100vw - 24px); }.sidebar-footer { flex-wrap: wrap; }.theme-toggle-btn { margin-left: 0; } }
/* Preserve the dense industrial table's column hierarchy on smaller desktops. */
.table-wrap table { min-width: 980px; }
/* ---- 品牌标识:云 + 数据库(矢量重绘,随主题变色) ---- */
.global-header-brand .brand-mark { position: relative; display: inline-flex; align-items: center; justify-content: center; color: var(--text-primary); }
.global-header-brand .brand-mark svg { display: block; }
.global-header-brand .brand-mark .header-status-dot { position: absolute; right: -1px; bottom: -1px; width: 9px; height: 9px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px var(--bg-primary), 0 0 10px var(--success); }
/* ---- 可搜索下拉 SearchSelect / 内联搜索 SearchField ----
供应商 272 家、产品 300+ 个,原生 <select> 只能一格一格滚。
触发区做成搜索框:点开即打字过滤。样式沿用全局「无方框」规则——只靠底色区分。 */
.ss-root, .sf-root { position: relative; display: flex; align-items: center; gap: 8px; min-width: 0; height: 38px; padding: 0 10px; border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-primary); cursor: text; transition: background var(--transition-fast); }
.ss-root.ss-sm, .sf-root.sf-sm { height: 32px; padding: 0 8px; border-radius: 9px; }
.ss-root:hover, .ss-root.is-open, .sf-root:hover, .sf-root:focus-within { background: var(--primary-bg); }
.ss-root.is-disabled { opacity: .5; cursor: not-allowed; }
.ss-root .ss-icon, .sf-root .ss-icon { display: inline-flex; flex: 0 0 auto; color: var(--text-tertiary); }
.ss-root.is-open .ss-icon, .sf-root:focus-within .ss-icon { color: var(--primary-light); }
.ss-root input[type="text"], .sf-root input[type="text"] { flex: 1 1 auto; width: auto; min-width: 0; min-height: 0; height: auto; padding: 0; border: 0; background: transparent; box-shadow: none; color: inherit; font: 14px var(--font-body); outline: none; cursor: inherit; }
.ss-root.ss-sm input[type="text"], .sf-root.sf-sm input[type="text"] { font-size: 13px; }
.ss-root input[type="text"]:focus, .sf-root input[type="text"]:focus { background: transparent; box-shadow: none; }
.ss-root input::placeholder, .sf-root input::placeholder { color: var(--text-tertiary); }
.ss-root .ss-caret { display: inline-flex; flex: 0 0 auto; color: var(--text-tertiary); transition: transform var(--transition-fast); }
.ss-root .ss-caret.is-up { transform: rotate(180deg); }
.ss-root .ss-clear, .sf-root .ss-clear { display: inline-grid; place-items: center; flex: 0 0 auto; width: 18px; height: 18px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-tertiary); cursor: pointer; }
.ss-root .ss-clear:hover, .sf-root .ss-clear:hover { background: var(--surface-hover); color: var(--text-primary); }
/* 菜单用 fixed + portal:抽屉的 .drawer-body 是 overflow:auto,就地绝对定位会被裁掉 */
.ss-menu { position: fixed; z-index: 86; overflow: auto; padding: 5px; border: 1px solid var(--border-light); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 18px 44px rgba(0, 0, 0, .3); }
.ss-opt { display: flex; align-items: baseline; gap: 8px; padding: 7px 9px; border-radius: 8px; color: var(--text-secondary); font: 13.5px var(--font-body); cursor: pointer; }
.ss-opt.is-active { background: var(--primary-bg); color: var(--text-primary); }
.ss-opt.is-selected .ss-label { color: var(--primary-light); font-weight: 700; }
.ss-opt .ss-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ss-opt .ss-hint { flex: 0 0 auto; margin-left: auto; padding-left: 10px; color: var(--text-tertiary); font-size: 12.5px; font-variant-numeric: tabular-nums; }
.ss-empty { padding: 14px 10px; color: var(--text-tertiary); font-size: 13px; text-align: center; }
/* 工具条/面板里给下拉定宽,别把搜索框挤没 */
.toolbar .ss-root { flex: 0 1 230px; min-width: 190px; }
.panel-search-row { display: flex; align-items: center; gap: 10px; margin-bottom: 11px; }
.panel-search-row .sf-root { flex: 1 1 auto; max-width: 340px; }
/* ---------- 小皮浮窗优化:清晰的标题层级 + 始终可见的关闭入口 ---------- */
.ai-window {
width: min(456px, calc(100vw - 24px));
max-height: min(720px, calc(100dvh - var(--header-height) - 24px));
border: 1px solid color-mix(in srgb, var(--primary-border) 48%, var(--border));
border-radius: 20px;
background: color-mix(in srgb, var(--surface) 94%, #07111f);
box-shadow: 0 28px 80px rgba(0, 0, 0, .42), 0 0 0 1px rgba(255, 255, 255, .03), inset 0 1px 0 var(--panel-highlight);
}
.ai-window-bar {
min-height: 66px;
padding: 12px 12px 12px 14px;
border-bottom: 1px solid color-mix(in srgb, var(--primary-border) 30%, var(--border-light));
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 68%, var(--surface-deep)), var(--surface-deep) 62%);
}
.ai-panel-title { gap: 11px; min-width: 0; flex: 1 1 auto; }
.ai-orb {
width: 36px;
height: 36px;
flex-basis: 36px;
border: 1px solid var(--primary-border);
border-radius: 12px;
background: linear-gradient(145deg, var(--primary-bg), color-mix(in srgb, var(--surface-raised) 76%, var(--primary-bg)));
box-shadow: 0 8px 20px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,.12);
}
.ai-panel-heading { min-width: 0; display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 8px; row-gap: 1px; }
.ai-panel-kicker { grid-column: 1 / -1; color: var(--primary-light); font: 700 9px/1.2 var(--font-mono); letter-spacing: .14em; opacity: .9; }
.ai-panel-title h2 { color: var(--text-primary); font-size: 17px; line-height: 1.15; }
.ai-panel-subtitle { min-width: 0; overflow: hidden; color: var(--text-tertiary); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
.ai-window-actions { gap: 7px; }
.ai-status-label { display: inline-flex; align-items: center; gap: 6px; margin-right: 2px; color: var(--success); font: 600 11px/1 var(--font-mono); white-space: nowrap; }
.ai-live-dot { width: 7px; height: 7px; flex-basis: 7px; }
.ai-icon-btn,
.ai-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 32px;
min-width: 32px;
padding: 0 8px;
border: 1px solid var(--border-light);
border-radius: 9px;
background: color-mix(in srgb, var(--surface) 56%, transparent);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.ai-icon-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); transform: translateY(-1px); }
.ai-close-btn { min-width: 62px; border-color: rgba(255, 113, 124, .42); background: rgba(255, 93, 108, .12); color: #ff9aa2; font-size: 12px; font-weight: 700; }
.ai-close-btn:hover { border-color: rgba(255, 113, 124, .8); background: rgba(255, 93, 108, .22); color: #ffd7da; transform: translateY(-1px); }
.ai-close-btn:focus-visible,
.ai-icon-btn:focus-visible { outline: 2px solid var(--primary-light); outline-offset: 2px; }
.ai-panel-body { padding: 15px; }
.ai-access-banner { margin-bottom: 12px; padding: 11px 12px; border-radius: 13px; background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 92%, var(--surface)), color-mix(in srgb, var(--surface-raised) 74%, var(--primary-bg))); }
.ai-capability-card { padding: 11px 12px; border: 1px solid var(--border-light); border-radius: 13px; background: color-mix(in srgb, var(--surface-deep) 82%, transparent); }
.ai-capability-heading { margin-bottom: 8px; font-size: 12.5px; letter-spacing: .02em; }
.ai-capability-row { padding: 4px 0; font-size: 12.5px; }
.ai-context-strip { margin: 13px 0 8px; }
.ai-message-list { gap: 12px; margin-top: 10px; }
.ai-message-content { padding: 10px 12px; border: 1px solid var(--border-light); border-radius: 5px 14px 14px 14px; background: color-mix(in srgb, var(--surface-deep) 88%, transparent); }
.ai-message.user .ai-message-content { border-color: var(--primary-border); background: var(--primary-bg); }
.ai-suggestion-row { gap: 8px; padding: 9px 15px 8px; }
.ai-suggestion-row button { padding: 7px 11px; border-radius: 10px; }
.ai-input-row { padding: 10px 15px; }
.ai-input-row input { min-height: 40px; border: 1px solid var(--border-light); }
.ai-input-row input:focus { border-color: var(--primary-border); }
.ai-input-row button { width: 40px; min-width: 40px; height: 40px; border-radius: 11px; box-shadow: 0 5px 14px var(--accent-glow); }
.ai-panel-footer { padding: 8px 15px 12px; border-top: 1px solid var(--border-light); }
@media (max-width: 680px) {
.ai-window { top: calc(var(--header-height) + 8px); right: 8px; width: calc(100vw - 16px); max-height: min(76dvh, 620px); border-radius: 17px; }
.ai-window-bar { min-height: 62px; padding: 10px; }
.ai-panel-subtitle, .ai-status-label { display: none; }
.ai-close-btn { min-width: 34px; padding: 0; }
.ai-close-btn span { display: none; }
.ai-panel-body { padding: 12px; }
.ai-suggestion-row, .ai-input-row, .ai-panel-footer { padding-left: 12px; padding-right: 12px; }
}
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
/**
* 时区清单(顶栏时钟用)
*
* 用 IANA 时区名而不是写死的 UTC 偏移量 —— 偏移量一年会变两次,
* 伦敦夏天是 UTC+1、冬天是 UTC+0,写死必然算错。交给
* Intl.DateTimeFormat 的 timeZone 处理,夏令时自动正确。
*
* zone / cities 只是给人看的标签,实际取时间只认 key。
* 排列按 UTC 偏移由西向东,方便顺着找客户所在时区。
*/
export const TIMEZONES = [
{ key: 'Pacific/Honolulu', zone: '西十区', cities: '檀香山' },
{ key: 'America/Anchorage', zone: '西九区', cities: '安克雷奇' },
{ key: 'America/Los_Angeles', zone: '西八区', cities: '洛杉矶,温哥华' },
{ key: 'America/Denver', zone: '西七区', cities: '丹佛' },
{ key: 'America/Chicago', zone: '西六区', cities: '芝加哥,墨西哥城' },
{ key: 'America/New_York', zone: '西五区', cities: '纽约,多伦多' },
{ key: 'America/Halifax', zone: '西四区', cities: '哈利法克斯,圣地亚哥' },
{ key: 'America/Sao_Paulo', zone: '西三区', cities: '圣保罗,布宜诺斯艾利斯' },
{ key: 'Atlantic/Azores', zone: '西一区', cities: '亚速尔群岛' },
{ key: 'Europe/London', zone: '零时区', cities: '伦敦' },
{ key: 'Europe/Paris', zone: '东一区', cities: '巴黎,法兰克福' },
{ key: 'Europe/Athens', zone: '东二区', cities: '雅典,开罗' },
{ key: 'Europe/Moscow', zone: '东三区', cities: '莫斯科,利雅得' },
{ key: 'Asia/Dubai', zone: '东四区', cities: '迪拜' },
{ key: 'Asia/Karachi', zone: '东五区', cities: '卡拉奇,伊斯兰堡' },
{ key: 'Asia/Kolkata', zone: '东五区半', cities: '新德里,孟买' },
{ key: 'Asia/Dhaka', zone: '东六区', cities: '达卡,阿拉木图' },
{ key: 'Asia/Bangkok', zone: '东七区', cities: '曼谷,雅加达,河内' },
{ key: 'Asia/Shanghai', zone: '东八区', cities: '北京,上海' },
{ key: 'Asia/Tokyo', zone: '东九区', cities: '东京,首尔' },
{ key: 'Australia/Sydney', zone: '东十区', cities: '悉尼,墨尔本' },
{ key: 'Pacific/Noumea', zone: '东十一区', cities: '努美阿' },
{ key: 'Pacific/Auckland', zone: '东十二区', cities: '奥克兰' }
]
/** 默认时区:Wayne 在上海,默认看本地时间。 */
export const DEFAULT_TIMEZONE = 'Asia/Shanghai'
export const TIMEZONE_STORAGE_KEY = 'happy.clockZone'
/** 按 IANA 时区名找条目;找不到给一个兜底条目,避免界面出现空白。 */
export function findTimeZone(key) {
return TIMEZONES.find(item => item.key === key) || TIMEZONES.find(item => item.key === DEFAULT_TIMEZONE)
}
export function readStoredTimeZone() {
try {
const saved = localStorage.getItem(TIMEZONE_STORAGE_KEY)
return TIMEZONES.some(item => item.key === saved) ? saved : DEFAULT_TIMEZONE
} catch {
return DEFAULT_TIMEZONE
}
}
/* Intl 的构造有开销,按时区缓存复用;界面每秒重绘一次,不缓存会白白产生垃圾。 */
const timeFormatters = new Map()
const dateFormatters = new Map()
const offsetFormatters = new Map()
function timeFormatter(timeZone) {
if (!timeFormatters.has(timeZone)) {
timeFormatters.set(timeZone, new Intl.DateTimeFormat('zh-CN', {
timeZone, hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit'
}))
}
return timeFormatters.get(timeZone)
}
function dateFormatter(timeZone) {
if (!dateFormatters.has(timeZone)) {
dateFormatters.set(timeZone, new Intl.DateTimeFormat('zh-CN', {
timeZone, month: 'long', day: 'numeric', weekday: 'short'
}))
}
return dateFormatters.get(timeZone)
}
/** "09:28:57"(某些环境会给 24:00 表示午夜,统一成 00) */
export function formatTimeIn(date, timeZone) {
return timeFormatter(timeZone).format(date).replace(/^24/, '00')
}
/** "9月10日周三" */
export function formatDateIn(date, timeZone) {
return dateFormatter(timeZone).format(date)
}
/**
* "UTC+8" / "UTC+5:30" / "UTC"。
* 优先用 Intl 的 longOffset(自动含夏令时),环境不支持时退回手算。
*/
export function formatOffsetIn(date, timeZone) {
try {
if (!offsetFormatters.has(timeZone)) {
offsetFormatters.set(timeZone, new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' }))
}
const part = offsetFormatters.get(timeZone).formatToParts(date).find(item => item.type === 'timeZoneName')
const raw = part && part.value // "GMT+08:00" / "GMT"
if (!raw) return ''
if (raw === 'GMT') return 'UTC'
const match = raw.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/)
if (!match) return raw
return `UTC${match[1]}${Number(match[2])}${match[3] && match[3] !== '00' ? `:${match[3]}` : ''}`
} catch {
return ''
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 全局「未保存改动」守卫。
*
* 为什么不用 react-router 的 useBlocker:它只在 data router(createBrowserRouter)下可用,
* 本项目用的是 <BrowserRouter> + <Routes>,调它会直接抛错。所以这里用一个模块级单例:
* 编辑页挂载时注册「我脏了吗 / 帮我存一下 / 帮我丢弃」,由外层外壳负责在跳转时询问。
*/
let active = null
export function setUnsavedGuard(guard) {
active = guard
}
export function clearUnsavedGuard() {
active = null
}
export function getUnsavedGuard() {
return active
}
/** 是否处于「有未保存改动」状态(没有编辑页时恒为 false) */
export function isGuardDirty() {
try {
return !!(active && active.isDirty())
} catch {
return false
}
}
+22 -6
View File
@@ -6,15 +6,15 @@ export default defineConfig({
server: {
port: 5300,
proxy: {
// SKU 能力代理(过渡期):前端统一以 /api/sku/* 访问,避免跨域与端口耦合。
// 后端正式合并(P1)完成后可直接删除本段。
// 产品目录(SKU)与产品图现在都由 happy 后端(4300)提供 —— 独立 SKU 服务已并入,
// 3300 不再存在。前缀与路径保持原样(/api/sku/groups → 4300/api/sku/groups),
// 前端代码零改动。写操作要记「上传者 / 调价人」,必须走 4300 才有人可记。
'/api/sku': {
target: 'http://127.0.0.1:3300',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api\/sku/, '/api')
target: 'http://127.0.0.1:4300',
changeOrigin: true
},
'/product-images': {
target: 'http://127.0.0.1:3300',
target: 'http://127.0.0.1:4300',
changeOrigin: true
},
'/api': {
@@ -22,5 +22,21 @@ export default defineConfig({
changeOrigin: true
}
}
},
build: {
rollupOptions: {
output: {
/* 把「很少变」的第三方库拆成独立 chunk:
* 1) 首屏只需再下载业务代码,React 这些体积大头可以长期缓存,发版不用重新下载;
* 2) 配合页面懒加载,首屏体积能降一大截。
* 只分两组,不按库细分 —— 拆太碎会变成一堆小请求,反而更慢。 */
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-axios': ['axios']
}
}
},
/* 业务代码已按页面拆分,单块都不大;阈值放宽,免得控制台刷一堆体积警告 */
chunkSizeWarningLimit: 700
}
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "happy-export",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)",
"repository": {
+13
View File
@@ -0,0 +1,13 @@
@echo off
rem 静默启动版:不弹窗口、不自动开浏览器、不等键盘 —— 适合双击或放进 Windows 启动文件夹做开机自启。
rem 用法:双击此文件,或按 Win+R 输入 shell:startup 把本文件的快捷方式拖进去,开机即起。
cd /d "%~dp0"
rem 用 /min 把后端、前端两个 cmd 最小化到任务栏,不在前台挡视线
start "" /min cmd /c "cd backend && npm start"
timeout /t 2 /nobreak >nul
start "" /min cmd /c "cd frontend && npm run dev"
rem 立即退出,不留任何窗口
exit /b 0