92 Commits

Author SHA1 Message Date
3cb85f28b4 fix(frontend): 宏观状态前端UX重构 — 非权限人员隐藏箭头+拦截点击
1. 新增 canEditOverallStatus computed:
   - SUPER_ADMIN 直接放行
   - 递归遍历 task_tree,仅 WIP/PENDING 主线任务负责人有权
   - 严格对齐后端 update_overall_status 的 main_task 判断

2. handleOverallBarClick 增加权限拦截:
   无权限点击 → Toast '仅超级管理员或当前主线负责人可修改状态'

3. overall-arrow 增加 canEditOverallStatus 条件:
   无权限用户不显示 ▾ 箭头,视觉上明确不可操作
2026-08-11 17:48:51 +08:00
b97dfaa95d fix(backend): 宏观状态权限校验 — 根除 current_user 空值绕过漏洞
Bug: if current_user: 在 Python 中 None/空dict均为falsy,
      一旦 current_user 为空则整个权限块被跳过直接放行。

修复:
1. if not current_user → 直接 raise 401
2. 反转 SUPER_ADMIN 判断: if user_role != 'SUPER_ADMIN' 进入校验
   (避免 if/else/pass 空分支带来的逻辑歧义)
3. main_task is not None 显式判断 (替代隐式 truthy)
2026-08-11 17:39:30 +08:00
9064973a3f fix(app): onShow 中增加更新检查 — 用户无需杀后台即可感知新版本
问题: checkUpdate 仅在 onLaunch 执行,App 常驻内存时永不复用。
      用户部署更新后,不杀后台就永远感知不到新版本。

修复:
1. onShow 中追加 checkUpdate(),每次回到前台自动检测
2. checkUpdate 顶部加 5分钟节流,避免频繁切后台时重复弹窗
2026-08-11 16:56:22 +08:00
01b8601dcd fix(app): OTA更新体验重构 — 废除闪烁弹窗 + 自动重启
问题:
1. uni.showLoading 每次调用都是关→开,进度回调中连续调用导致疯狂闪烁
2. 安装完成后弹窗询问重启,用户选「稍后」则必须手动杀后台

修复:
1. 替换 uni.showLoading → plus.nativeUI.showWaiting
   原生等待框支持原地更新文字,下载进度平滑过渡,零闪烁
2. 安装成功后移除重启询问弹窗,改为:
   plus.nativeUI.toast('新版本已就绪,即将重启...')
   → 2秒后 setTimeout → plus.runtime.restart()
   用户无需任何操作,2秒后自动生效
2026-08-11 16:53:35 +08:00
5b5f4a6b0e fix(frontend): TreeCanvas 坐标+连线三重修复 — 根治箭头悬空
1. calcSubtreeHeight: Math.max(CARD_H+GAP, totalChildrenHeight)
   → 首子与父平齐,父级高度取 max 而非无脑叠加

2. placeChildren: startY = parentNode.y (水平对齐)
   → 移除初始化 GAP,累加改为 startY += subtreeH

3. lines(): 动态追踪 Y 坐标
   → y1 = isLeft? n.y+40 : parent.y+40
   → y2 = isLeft? parent.y+40 : n.y+40
   → lineStyle 的 Math.atan2 自动画出完美对角线
2026-08-11 16:42:14 +08:00
0a1c2f4dcf fix(frontend): 打印按钮被挤出画面 — card-header 防溢出
根因: card-header 一行塞入 4 个元素(标题+3按钮),
      card-header-right 无 flex-wrap/无 flex-shrink,
      窄屏上打印标签按钮溢出视口不可见。

修复:
- card-header-right: gap 8→4, 加 flex-wrap, flex-shrink:0
- 所有子元素: flex-shrink:0 + white-space:nowrap
- mode-toggle/print-label-btn: 缩小字号+padding节省空间
2026-08-11 16:07:19 +08:00
40a2d87345 fix: 消息通知页不能点进详情 — 补全 product_serial_number
问题: 点击通知卡片跳转 detail?taskId=xxx,但 detail.vue onLoad 只认 serial 参数导致空白页

修复:
1. 后端 NotificationResponse 新增 product_serial_number 字段
2. 后端 notifications API 联表 tasks+products 批量填充 serial
3. 前端 notify/index.vue handleCardTap 优先用 product_serial_number 跳转,兜底从 content 正则解析
4. 前端 detail.vue onLoad 新增 taskId 兜底 → doQueryByTask 反查 product_sn
2026-08-11 15:56:25 +08:00
0e3eb6a35a fix(frontend): 根治双重渲染 — 侧翼过滤主线,主轴归主轴
回滚此前错误的 allMains 根级过滤,恢复所有 TRANSFER/RECOVERY
任务进入中央垂直主轴。

真正修复:directChildren 加 .filter(c => !isMain(c))
→ 左右翼严格排除主线任务,仅保留 SPAWN 协助分支

原理: 主线→中央垂直时间线, 分支→左右翼, 各行其道不再重复
2026-08-11 15:40:43 +08:00
974f4b9d01 fix(frontend): TaskFlowView 根治双重渲染 — allMains 仅含根级节点
生产数据问题:16个任务中15个TRANSFER子节点全部挂在同一个root下,
isMain() 将它们同时放入 allMains 和 childMap,导致每个子节点渲染2次。

修复:
1. allMains 改为仅收集 parent_task_id IS NULL 的根级主干
   - TRANSFER 子节点仅通过 childMap → renderBranch 递归渲染
   - 彻底杜绝同一任务在时间线 + 分支中双重出现
2. 统一焦点/全景为同一套垂直时间线 JSX 布局
   - 删除旧焦点模式的独立 flex-row 排版
   - visibleMains useMemo 负责数据过滤 (active() 过滤)
3. 清理死代码: getRootMainId() / rootMainMap / srm / flatMap
2026-08-11 15:37:04 +08:00
30a48d90fc fix(frontend): 流转蓝图 + 聊天室 + 卡片布局 + 打印功能修复
1. TreeCanvas.vue - Y轴坐标算法重构:
   - calcSubtreeHeight 改为兄弟累加 (reduce) 替代 Math.max,彻底杜绝垂直重叠
   - childNode.y = parentNode.y + CARD_H + VERTICAL_GAP 严格向下延伸
   - 同级子节点按子树高度累加 startY,各占独立 Y 区域
   - 加宽画布默认尺寸 (400→800, padding +300/+400)

2. detail.vue - onShow 生命周期修复:
   - 新增 onShow() 钩子调用 fetchMessages(),解决聊天室退回不刷新问题

3. TaskSwipeCards.vue + detail.vue - CSS 防跳动:
   - .ss-swiper-h / .ss-card-wrapper 添加 min-height: 220px
   - .card 添加 min-height: 120px

4. detail.vue - 移动端打印标签:
   - 新增「🖨️ 打印标签」按钮
   - 双方案 ActionSheet: 网络打印机(后端API) / 蓝牙打印机(ESC/POS)
   - 对接现有 /print/execute 端点
2026-08-11 15:05:39 +08:00
88dc7381f6 fix(backend): 权限漏洞修复 + 业务逻辑审查修复
1. 宏观状态越权修复 (products.py + product_service.py):
   - update_overall_status 增加权限校验:仅 SUPER_ADMIN 或当前操作该产品主线任务的人可修改,否则 403

2. 任务撤回越权修复 (task_service.py + tasks.py):
   - recall_task 增加校验:操作人必须等于上游任务负责人(谁发出的谁撤回),否则 403
   - 管理员 (SUPER_ADMIN/SUPERVISOR) 直接放行

3. 驳回上游溯源修复 (task_service.py - reject_task):
   - 将 TaskLog (action_type=create) 提升为优先溯源方式,解决协助分支转交后驳回找不到正确发起人的 Bug
   - 保留 parent_task.assignee_id + task.assignee_id 两级兜底

4. complete_task 父节点继承修复:
   - 修复 task_type == 'MAIN' 永不匹配的 Bug(模型无此值)
   - 改为 not parent_task_id or task_type in (TRANSFER, RECOVERY)
2026-08-11 15:05:30 +08:00
d40a8d480e fix(backend): end_task 和 complete_task 补齐权限校验
- end_task 新增 operator_role 参数 + _check_permission 调用
- complete_task 新增 operator_role 参数 + _check_permission 调用
- 两个端点均注入 current_user Depends(get_current_user)
- 非任务负责人且非管理员/主管调用时返回 403
2026-08-11 10:13:40 +08:00
de47d27cfb fix(backend): _recalc_product_location 修复 func.or_() → or_()
func 是 SQL 函数调用(如 func.now()),不能替代 WHERE 条件中的 or_ 运算符。
这会导致结束协助分支时 SQL 抛错 500。
2026-08-11 10:05:13 +08:00
6755dfeae3 feat(mobile): 登录修复 + 人名字典 + 留言板FAB + 流转算法重构
- login: catch补齐toast,修复密码错误静默失败
- detail: formatUserName渲染当前位置,dictVersion驱动响应式重绘
- detail: 留言板改为悬浮按钮+底部抽屉,未读徽标逻辑
- TaskSwipeCards: lanes终极递归算法,SPAWN不限层级开辟新Tab,先序push修复Tab顺序
- TreeCanvas: 父子相对坐标延伸算法,替代旧版奇偶距离递增
2026-08-11 10:02:12 +08:00
d3def15bb3 feat(web): TaskFlowView 递归渲染重构
- 废弃拍平算法,构建全局childMap保留真实树结构
- 新增renderBranch递归渲染函数,子子孙孙向外延伸
- 焦点/全景模式统一应用递归算法,连线从真实父节点引出
2026-08-11 10:02:06 +08:00
718e205f53 feat(backend): 完工转交智能父节点继承算法
- complete_task: MAIN转交保持平级(parent_task_id),SPAWN转交认当前为父(task.id)
- 基因严格继承: task_type不会篡位成MAIN
2026-08-11 10:02:01 +08:00
567f7175da fix: :key 强制子组件重建 + 拆除嵌套滚动锁
- detail.vue: 新增 dictVersion,loadUsers 后自增驱动子组件 :key 重建
- WorkspaceArea/TaskSwipeCards/TreeCanvas 绑定 :key='...-'+dictVersion
- .page-container: flex → block,让页面自然流式撑开
- .workspace-area: flex:1+overflow:hidden → height:auto
- .is-locked: 仅锁定任务模式下才 height:100vh+overflow:hidden
- .task-list: flex:1+overflow-y:auto → height:auto
2026-08-10 18:08:13 +08:00
051a075e9d fix: 留言板层级修复 + 人名映射防降级
- 留言板移除 currentMode 限制,产品级功能所有模式可见
- .page-container: min-height + overflow-y:auto 替代 height:100vh + overflow:hidden
- loadUsers 加 console.error 日志 + $forceUpdate 防字典空窗期
2026-08-10 17:56:47 +08:00
6373dca4f6 refactor: Web 端十字星流转树 — 3层嵌套协助降维渲染
- isMain() 移除危险的 parent.status 兜底判定,仅凭基因字段
- 新增 getRootMainId() 寻根算法,向上攀爬找视觉主干
- subsByRootMain 虚拟扁平化:孙子协助挂在顶级主干下
- 层级排序:直接子节点 → 孙子按父链收集
- SlimCard 新增嵌套协助标识:协助: {真实parent.assignee_id}
2026-08-10 17:37:30 +08:00
f9451b104b feat: 移动端全局人名映射 + 协同留言板 UI
- detail.vue: 留言板聊天室 UI + 乐观更新 + loadUsers 填充全局字典
- WorkspaceArea.vue: 任务列表/上游/下游负责人全部走 formatUserName
- TreeCanvas.vue: 节点负责人 + 嵌套协助提示走 formatUserName
- TaskSwipeCards.vue: 卡片负责人走 formatUserName
- 留言板头像取中文名末字(formatUserAvatar)
2026-08-10 17:37:25 +08:00
05003d3053 feat: 全局人名映射工具 — 拼音 ID → 中文姓名转换
- formatUserName(userId): 查全局字典翻译为中文名,降级返回原 ID
- formatUserAvatar(userId): 中文名取末字作为头像文字
- setUserNameMap(users): 批量注册用户字典
2026-08-10 17:37:16 +08:00
1fe9c3d59d feat: 留言板 API + 零信任安全:后端 Token 鉴权强制覆写 operator_id
- 新增 GET/POST /products/{id}/messages 端点
- POST 创建留言时注入 current_user = Depends(get_current_user)
- operator_id 优先取 Token 中的 username,防止前端越权伪造发言身份
- 同时将 product.current_location_id 显示改为 formatUserName 映射
2026-08-10 17:37:10 +08:00
b14773f81b feat: 产品协同留言板 — ProductMessage 模型 + API + 迁移
- 新增 ProductMessage 模型,挂载在 products.id(ON DELETE CASCADE)
- GET/POST /products/{id}/messages 接口
- 注册到 Alembic 自动发现
2026-08-10 17:37:01 +08:00
e326f8d311 fix: 修复任务完工/结束 500 + 后端基因继承
- 移除 task.child_tasks 的 ORM 属性直接赋值(导致 SQLAlchemy 状态机崩溃)
- complete_task 创建 next_task 时补上 task_type=task.task_type 基因继承
2026-08-10 17:36:54 +08:00
2440e9bdaa fix: 修复产品删除 500 — ImportError + FK 自引用约束
- 修正 TaskLog 导入路径:app.models.task → app.models.task_log
- 删除任务前先切断 parent_task_id 自引用外键
- 删除产品前先切断 parent_product_id 自引用外键
- 真正执行 delete(TaskRecord) 和 delete(TaskLog) 替代空 select
2026-08-10 17:36:48 +08:00
ba060cbb5e refactor(mobile): 流转树固定100%比例,移除双指缩放功能
- 移除 movable-view 的 :scale/:scale-min/:scale-max/:scale-value 属性
- 移除 onScale/zoomIn/zoomOut 方法及工具栏缩放按钮
- 移除顶部双指缩放·单指拖拽提示文字
- fitAll 简化为纯居中定位 (mvX/mvY),不再调整缩放比
- 工具栏仅保留: 返回 | 流转蓝图 | 卡片切换 | 全览
2026-08-10 15:25:56 +08:00
a07e43ec3a fix(mobile): 修复画布空白区域无法响应拖拽缩放手势
canvas-inner 使用 position:absolute 但未设置 width/height,
导致只缩到子元素大小(几乎为0)。在卡片之外的空白区域触摸时,
事件无法穿透到 movable-view 原生组件。

修复:
- canvas-inner: 添加 width:100%; height:100% 铺满 movable-view
- tree-movable-view: 添加 width:100%; height:100% 确保触摸覆盖
2026-08-10 15:19:34 +08:00
f2403dd1e6 fix(mobile): 流转树全览居中 + 拖拽缩放性能优化
1. fitAll() 居中修复: 内容坐标系整体对齐视口中心
   旧: mvX=0, mvY=0 (内容偏右下角)
   新: mvX = (viewportW - canvasW * scale) / 2, mvY同理

2. movable-area 显式像素高度: 替代 flex:1
   避免触摸区域计算不准确导致拖拽延迟

3. onScale 节流: rAF/setTimeout 限频 16ms
   避免 @scale 每帧触发 Vue 响应式更新

4. 新增 onChange 同步手势结束后的位置
   惯性动画结束后记录位置,下次 fitAll 参考使用

5. 添加 inertia+friction+damping 惯性动画参数
2026-08-10 15:09:06 +08:00
5271177d7a fix(mobile): 修复全览按钮点击后页面卡死的响应式循环bug
treeNodes 计算属性中直接修改了 product.task_tree 原始响应式对象
(设置 x/y/_isMain/_blabel),触发 Vue 2 深度响应式的无限渲染循环。

修复方案:对所有 task 对象进行浅拷贝 { ...t },在副本上设置坐标
和标签属性,切断响应式依赖循环。
2026-08-10 15:00:37 +08:00
3ec244bf2d feat: 全览节点无记录时Toast友好提示替代静默短路 2026-08-10 14:40:18 +08:00
5f12adfe2f fix: 连线层加pointer-events:none防止吞掉卡片点击事件 2026-08-10 14:37:37 +08:00
51ca05d737 fix: 全览按钮z-index修复+产品ID点击弹二维码 2026-08-10 14:30:50 +08:00
016a988962 fix: parseVersionCode修复—major*100+lastNum, T1.0.10→110匹配服务器 2026-08-10 14:22:36 +08:00
0b6c4a7f8b fix: OTA版本对比改用plus.runtime.versionCode直接对比,修复T1.0.10误判为旧版 2026-08-10 14:12:05 +08:00
f80da36d65 fix: 连线边缘到边缘防穿模+箭头像素级贴合分支卡片边缘 2026-08-10 13:59:22 +08:00
6d534960a7 refactor: 移动端TreeCanvas十字星拓扑—主干居中+分支左右发散+连线箭头+极简卡片160x80 2026-08-10 13:52:46 +08:00
38263291d1 perf: 流转树排他展开(手风琴模式)-同时只展开一个产品 2026-08-10 13:40:46 +08:00
4d93844637 fix: 箭头语义方向修正—永远背离主干指向分支(左翼◀+右翼▶) 2026-08-10 13:29:35 +08:00
fba3589d0d feat: 焦点/全景模式双翼flex-col纵向堆叠+SVG箭头+实线虚线区分 2026-08-10 13:26:31 +08:00
87296af139 refactor: 水平对称双翼布局—左翼(偶索引)+中央主干+右翼(奇索引),实线/虚线区分 2026-08-10 13:21:30 +08:00
ae520e49d7 refactor: 双模式流转树—焦点模式(活跃+遗留分支) + 全景模式(完整历史时间轴) + 极简卡片 2026-08-10 13:12:23 +08:00
5d6e2135c0 feat: 任务全景默认按设备分组+全展开+全部展开/折叠按钮(与产品管理一致) 2026-08-10 11:47:03 +08:00
34724e478c fix: SQL语法错误—func.and_()/or_()改为sqlalchemy.and_()/or_() 2026-08-10 11:41:10 +08:00
e447de6cfa feat: 当前位置100%动态主干化—与宏观状态同源,只跟主分支,静态字段仅兜底 2026-08-10 11:32:07 +08:00
b43bded0ef feat: 宏观状态动态从主干任务获取+任务树assignee中文姓名映射(后端+前端) 2026-08-10 11:25:44 +08:00
c0fe7c94bb feat: 产品管理默认按设备分组+自动全部展开+全部展开/折叠按钮 2026-08-10 11:18:01 +08:00
2eed7db82d refactor: 产品管理页全面升级—分组折叠面板+搜索/状态过滤+序列号列+与任务全景体验一致 2026-08-10 11:07:53 +08:00
60af3b9998 feat: 物料选择全量展示(移除成品/半成品限制) 2026-08-10 11:02:22 +08:00
2880ea6df7 fix: Grid回退grid-cols-12精确对齐+分组标题按device/order切换+宏观状态receive/transfer只主线 2026-08-10 10:58:33 +08:00
26dc05cc2f fix: 修复JSX style属性语法错误(grid-template-columns) 2026-08-10 10:43:31 +08:00
4854eb626e feat: 宏观状态只同步主线+删除5s倒计时+序列号确认+分组切换(订单/设备)+序列号列 2026-08-10 10:37:56 +08:00
ad9fb8d37e refactor: 位置回溯极简—只跟主干任务(WIP>PENDING>COMPLETED),无视协助分支 2026-08-09 18:49:59 +08:00
ffab378700 refactor: 单一数据源—Tab过滤+表格列统一使用macro_status||status,删除calcProductStatus 2026-08-09 18:37:02 +08:00
45b5376537 fix: 三致命Bug—macro_status预计算+排除已完工脏读+flush落盘+递归深度限制10 2026-08-09 18:23:55 +08:00
d8623df7de refactor: 废弃flattenLevels拍平→递归TaskNode渲染—主干同缩进+SPAWN分支缩进紫色左边线 2026-08-09 18:16:47 +08:00
f33396b9f1 fix: 位置回溯父任务优先 + Tab筛选改为本地calcProductStatus过滤 2026-08-09 18:10:13 +08:00
3c4c16a1cc fix: 四Bug—权限互斥+中文姓名+记录弹窗+后端位置回溯(含RecordsModal) 2026-08-09 17:55:40 +08:00
ab935a8c95 refactor: Web端FlowCard与移动端100%对齐—主线/分支标签/备注/记录/时间格式/三种权限模式 2026-08-07 17:29:36 +08:00
65ff05fb2f feat: 状态渲染修复+中文姓名映射+SUPERVISOR/SUPER_ADMIN权限控制(前端+后端) 2026-08-07 17:26:14 +08:00
592bde7cab chore: bump version to T1.0.9 2026-08-07 17:12:27 +08:00
d4f6266fcc perf: scan页置顶消除登录页闪烁 — 已登录用户零跳转直接渲染 2026-08-07 17:05:00 +08:00
0fcc607f17 refactor: App.vue统一路由守卫 — onLaunch总是重定向(已登录→扫码/未登录→登录) 2026-08-07 17:01:55 +08:00
71e55ed476 feat: 登录页自动跳过 — 已登录用户不再看到登录界面 2026-08-07 16:58:35 +08:00
a71af500dc chore: 同步manifest.json G盘→WSL T1.0.6 2026-08-07 16:52:31 +08:00
704af0b2a4 chore: Web端泳道→分支 统一命名 2026-08-07 16:51:56 +08:00
356e3819dd chore: 泳道→分支 统一命名 2026-08-07 16:51:00 +08:00
c9383ba4be fix: 修复部分Android手机划掉App后token丢失导致强制重新登录 2026-08-07 16:50:00 +08:00
b834b24c24 fix: 已完成/已驳回/已入库/已撤回任务禁止编辑删除记录 2026-08-07 16:48:12 +08:00
beea543fbd fix: 创建产品时自动将当前位置设为当前登录用户(谁创建谁持有) 2026-08-07 15:42:10 +08:00
bc43b5a732 feat: 产品管理 — 当前位置显示真实姓名 + 编辑弹窗 + 删除确认 2026-08-07 15:38:01 +08:00
d3d501dfeb fix: 产品管理页响应式Grid + 丰富卡片信息(物料/规格/位置/状态) 2026-08-07 15:14:11 +08:00
1b4af0a7af fix: 全端隐藏用户角色标签(SUPER_ADMIN/操作员等) 2026-08-07 14:53:46 +08:00
9ed85f88bb feat: 切换公网域名 track.iris-rs.cn + track_back.iris-rs.cn 2026-08-07 14:45:30 +08:00
472d4ad67a fix: 登录页版本号改为动态读取(WGT热更新后自动变) 2026-08-07 14:06:10 +08:00
45102eb23f fix: WGT安装强制模式(force:true)绕过同版本号拦截 2026-08-07 13:55:24 +08:00
3e8ccf3444 chore: 生产环境新增WGT升级包volumes映射 2026-08-07 13:49:55 +08:00
6382aa6d45 feat: Web端关于弹窗 + 帮助反馈Toast + 版本号T1.0.3 2026-08-07 13:49:06 +08:00
355f1bae66 feat: 关于弹窗 + 帮助反馈Toast + 动态版本号(WGT热更新后自动变化) 2026-08-07 13:41:55 +08:00
abbf0f5a24 chore: bump version to T1.0.2 (versionCode 102) 2026-08-07 13:22:10 +08:00
a72c8c89e3 fix: 移除模块级detectEnv()避免启动时网络探测失败 2026-08-07 13:19:11 +08:00
0e5b2e42e9 chore: 同步HBuilderX生成的main.js(Vue2/Vue3兼容) 2026-08-07 13:09:19 +08:00
0de981ea81 fix: OTA检测闭环 — 新增GET /check-update + App端uni.getSystemInfoSync版本对比 2026-08-07 12:35:00 +08:00
43baa1de8e feat: 移动端流转卡片组件 + 静态资源 2026-08-07 11:53:40 +08:00
41dfc53f83 chore: Docker生产部署配置 + 部署脚本 2026-08-07 11:53:40 +08:00
bff8787e62 feat: OTA热更新 — 版本检测API + App端WGT下载安装 + app_versions表 2026-08-07 11:49:59 +08:00
0df7215134 feat: 移动端双Token队列拦截器 + 通知列表 + 登录页 + TabBar红点 + v1.0.1 2026-08-07 11:44:20 +08:00
721cfe1504 feat: Web端401队列拦截器 + 消息/任务/个人中心重写 + TabBar红点 + Admin页面完善 2026-08-07 11:44:12 +08:00
b71c5a2d07 feat: 双Token认证(Access 2h/Refresh 7d) + 通知系统(转交/驳回自动推送) 2026-08-07 11:44:04 +08:00
dc97a7385f perf(frontend): Context value useMemo 稳定化
AuthContext.Provider 和 ToastContext.Provider 的 value 对象
用 useMemo 包裹,避免每次 Provider render 产生新引用导致
所有消费者不必要的 re-render。
2026-08-06 17:34:05 +08:00
b725599cb0 perf(frontend): Vite 构建分包 — manualChunks 拆分 vendor
将 react/react-dom/react-router-dom → vendor-react
将 antd/@ant-design/icons → vendor-antd
将 html5-qrcode → vendor-qrcode
第三方巨头的独立 chunk 可被浏览器长期缓存,业务代码更新时无需重新下载。
2026-08-06 17:34:00 +08:00
3d164159ac perf(frontend): 递归组件 Memo 优化 — React.memo + useMemo
TaskTreeViewer:
- Modal/ReceiveConfirmModal/RejectModal/TransferModal 加 React.memo
- TaskNodeCard 递归组件加 React.memo,阻断父节点状态变更引发的整树重渲染
- countAllTasks 用 useMemo 缓存,仅在 task_tree 引用变化时重新计算

TaskListCard:
- TaskNode 递归组件加 React.memo
- countAll 用 useMemo 缓存
2026-08-06 17:33:54 +08:00
9d204a57d8 perf(frontend): 路由级代码分割 — React.lazy + Suspense
将所有页面组件改为按路由懒加载,Layout 组件保持静态导入。
新增 LoadingSpinner 作为 Suspense 回退组件。
html5-qrcode (~200KB) 和 antd 仅在访问扫码/管理后台路由时加载。
2026-08-06 17:33:48 +08:00
77 changed files with 5269 additions and 1152 deletions

View File

@ -0,0 +1,32 @@
"""add_notifications
Revision ID: b8c9d0e1f2a3
Revises: a7b8c9d0e1f2
Create Date: 2026-08-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "b8c9d0e1f2a3"
down_revision: Union[str, None] = "a7b8c9d0e1f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("user_id", sa.String(64), nullable=False, index=True, comment="接收人ID逻辑外键→老系统"),
sa.Column("title", sa.String(200), nullable=False, comment="通知标题"),
sa.Column("content", sa.Text(), nullable=False, comment="通知内容详情"),
sa.Column("type", sa.String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)"),
sa.Column("task_id", sa.UUID(), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID"),
sa.Column("is_read", sa.Boolean(), server_default=sa.text("false"), comment="是否已读"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), comment="创建时间"),
)
def downgrade() -> None:
op.drop_table("notifications")

View File

@ -0,0 +1,36 @@
"""add_app_versions
Revision ID: c9d0e1f2a3b4
Revises: b8c9d0e1f2a3
Create Date: 2026-08-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c9d0e1f2a3b4"
down_revision: Union[str, None] = "b8c9d0e1f2a3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"app_versions",
sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("version", sa.String(20), nullable=False, unique=True, comment="版本号"),
sa.Column("version_code", sa.Integer(), nullable=False, server_default="100", comment="数字版本号"),
sa.Column("wgt_url", sa.String(500), nullable=False, comment="WGT下载地址"),
sa.Column("description", sa.Text(), nullable=True, comment="更新说明"),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), comment="是否启用"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# 插入初始版本记录
op.execute(
"INSERT INTO app_versions (version, version_code, wgt_url, description, is_active) "
"VALUES ('T1.0.1', 101, '', '初始版本', true)"
)
def downgrade() -> None:
op.drop_table("app_versions")

View File

@ -0,0 +1,33 @@
"""add_product_messages
Revision ID: g1h2i3j4k5l6
Revises: c9d0e1f2a3b4
Create Date: 2026-08-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "g1h2i3j4k5l6"
down_revision: Union[str, None] = "c9d0e1f2a3b4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"product_messages",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("product_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("products.id", ondelete="CASCADE"),
index=True, nullable=False),
sa.Column("operator_id", sa.String(50), nullable=False),
sa.Column("content", sa.Text, nullable=False),
sa.Column("created_at", sa.DateTime, nullable=True),
)
def downgrade() -> None:
op.drop_table("product_messages")

View File

@ -0,0 +1,87 @@
"""App 版本更新 API — OTA 热更新检测"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.app_version import AppVersion
from app.schemas.app_version import AppVersionResponse
router = APIRouter(prefix="/app", tags=["App版本"])
@router.get("/check-update", response_model=AppVersionResponse)
async def check_update(
db: AsyncSession = Depends(get_db),
):
"""
热更新检测接口(无需传参)。
查询 app_versions 表中 is_active=true 的最新记录,
返回最新版本号、版本代码、WGT下载地址、更新说明。
App 端自行对比本地版本号决定是否升级。
"""
result = await db.execute(
select(AppVersion)
.where(AppVersion.is_active.is_(True))
.order_by(desc(AppVersion.version_code))
.limit(1)
)
latest = result.scalar_one_or_none()
if not latest:
return AppVersionResponse(
version="0",
version_code=0,
has_update=False,
)
return AppVersionResponse(
version=latest.version,
version_code=latest.version_code,
has_update=bool(latest.wgt_url), # 只有配置了 WGT 下载地址才算有效更新
wgt_url=latest.wgt_url,
description=latest.description,
force_update=False,
)
@router.get("/version", response_model=AppVersionResponse)
async def check_version(
current: str = Query(..., description="当前 App 版本号,如 T1.0.0"),
db: AsyncSession = Depends(get_db),
):
"""检测是否有新版本可用(服务端对比)"""
result = await db.execute(
select(AppVersion)
.where(AppVersion.is_active.is_(True))
.order_by(desc(AppVersion.version_code))
.limit(1)
)
latest = result.scalar_one_or_none()
if not latest:
return AppVersionResponse(
version=current,
version_code=0,
has_update=False,
)
has_update = latest.version_code > _parse_version_code(current)
return AppVersionResponse(
version=latest.version,
version_code=latest.version_code,
has_update=has_update,
wgt_url=latest.wgt_url if has_update else None,
description=latest.description if has_update else None,
force_update=False,
)
def _parse_version_code(version_str: str) -> int:
"""从版本字符串提取数字版本号"""
import re
nums = re.findall(r"\d+", version_str)
if nums:
return int("".join(nums[-3:]).ljust(3, "0")[:3])
return 0

View File

@ -1,20 +1,32 @@
"""认证 API — 对接 MOM sys_user"""
"""认证 API — 对接 MOM sys_user + 双 Token 刷新"""
from fastapi import APIRouter, Depends
from app.schemas.user import LoginRequest, LoginResponse, UserResponse
from app.services.auth_service import login, get_current_user
from app.schemas.user import (
LoginRequest,
LoginResponse,
RefreshRequest,
RefreshResponse,
UserResponse,
)
from app.services.auth_service import login, refresh_access_token, get_current_user
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=LoginResponse)
def login_endpoint(data: LoginRequest):
"""登录 — 验证 MOM sys_user 表,返回 JWT"""
"""登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token"""
return login(data.username, data.password)
@router.post("/refresh", response_model=RefreshResponse)
def refresh_endpoint(data: RefreshRequest):
"""刷新 Access Token — 使用 Refresh Token 换取新的 Access Token"""
return refresh_access_token(data.refresh_token)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: dict = Depends(get_current_user)):
"""获取当前用户信息(从 JWT 解析)"""
"""获取当前用户信息(从 Access Token 解析)"""
return UserResponse(
id=current_user["sub"],
username=current_user.get("username", ""),

View File

@ -6,8 +6,8 @@ from sqlalchemy import text
router = APIRouter(prefix="/materials", tags=["物料选择"])
# 只展示成品 / 半成品category 字段区分,如 IRIS/成品/… / IRIS/半成品/…)
TYPE_FILTER = "category ILIKE '%成品%' OR category ILIKE '%半成品%'"
# 全量展示全部物料类别
TYPE_FILTER = "1=1"
# ============================================================

View File

@ -0,0 +1,100 @@
"""通知 API 端点 — 获取列表、标记已读"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.notification import Notification
from app.models.task import Task
from app.models.product import Product
from app.schemas.notification import NotificationResponse, NotificationListResponse
router = APIRouter(prefix="/notifications", tags=["消息通知"])
@router.get("/", response_model=NotificationListResponse)
async def list_notifications(
user_id: str = Query(..., description="当前用户ID"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的通知列表(按时间倒序)"""
# 总数
count_stmt = select(func.count()).select_from(Notification).where(
Notification.user_id == user_id
)
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# 未读数
unread_stmt = select(func.count()).select_from(Notification).where(
Notification.user_id == user_id,
Notification.is_read.is_(False),
)
unread_result = await db.execute(unread_stmt)
unread_count = unread_result.scalar() or 0
# 列表
stmt = (
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
notifications = result.scalars().all()
# 🚀 批量查询关联的 product_serial_number
task_ids = [n.task_id for n in notifications if n.task_id]
serial_map: dict[uuid.UUID, str] = {}
if task_ids:
task_result = await db.execute(
select(Task.id, Product.serial_number)
.join(Product, Task.product_id == Product.id)
.where(Task.id.in_(task_ids))
)
for row in task_result:
serial_map[row[0]] = row[1]
# 组装响应
response_list: list[NotificationResponse] = []
for n in notifications:
resp = NotificationResponse.model_validate(n)
if n.task_id and n.task_id in serial_map:
resp.product_serial_number = serial_map[n.task_id]
response_list.append(resp)
return NotificationListResponse(
notifications=response_list,
total=total,
unread_count=unread_count,
)
@router.put("/{notification_id}/read", response_model=NotificationResponse)
async def mark_notification_read(
notification_id: str,
db: AsyncSession = Depends(get_db),
):
"""标记单条通知为已读"""
nid = uuid.UUID(notification_id)
result = await db.execute(
select(Notification).where(Notification.id == nid)
)
notification = result.scalar_one_or_none()
if not notification:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"通知不存在: {notification_id}",
)
notification.is_read = True
await db.commit()
await db.refresh(notification)
return NotificationResponse.model_validate(notification)

View File

@ -3,9 +3,11 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.message import ProductMessage
from app.schemas.product import (
ProductCreate,
ProductUpdate,
@ -13,6 +15,7 @@ from app.schemas.product import (
ProductScanResponse,
)
from app.services import product_service
from app.services.auth_service import get_current_user
from app.services.qrcode_service import generate_qrcode_png
router = APIRouter(prefix="/products", tags=["产品管理"])
@ -62,11 +65,15 @@ async def scan_product(
@router.get("/", response_model=list[ProductResponse])
async def list_products(
skip: int = Query(0, ge=0, description="跳过条数"),
limit: int = Query(50, ge=1, le=200, description="返回条数"),
limit: int = Query(50, ge=1, le=1000, description="返回条数"),
keyword: str | None = Query(None, description="多维搜索: 产品身份证/订单号/规格型号"),
status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"),
db: AsyncSession = Depends(get_db),
):
"""获取产品列表"""
return await product_service.get_all_products(db, skip=skip, limit=limit)
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选"""
return await product_service.get_all_products(
db, skip=skip, limit=limit, keyword=keyword, status_filter=status,
)
@router.get("/{product_id}", response_model=ProductResponse)
@ -83,9 +90,11 @@ async def get_product(
async def create_product_endpoint(
data: ProductCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""创建产品"""
return await product_service.create_product(db, data)
"""创建产品 — 初始位置自动设为当前登录用户"""
creator_username = current_user.get("username", "")
return await product_service.create_product(db, data, creator_username)
@router.patch("/{product_id}", response_model=ProductResponse)
@ -93,12 +102,24 @@ async def update_product_endpoint(
product_id: str,
data: ProductUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""更新产品"""
import uuid
return await product_service.update_product(db, uuid.UUID(product_id), data)
@router.delete("/{product_id}", status_code=204)
async def delete_product_endpoint(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""删除产品及其关联任务"""
import uuid
await product_service.delete_product(db, uuid.UUID(product_id))
# ============================================================
# 宏观状态更新 — 扫码定调
# ============================================================
@ -112,10 +133,63 @@ async def update_product_overall_status(
serial_number: str,
data: OverallStatusUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
更新产品宏观流转状态。
移动端首次扫码或手动切换时调用。
合法值: 备货 | 生产 | 测试 | 维修 | 在库
权限:仅 SUPER_ADMIN 或当前操作该产品主线任务的人可以修改。
"""
return await product_service.update_overall_status(db, serial_number, data.status)
return await product_service.update_overall_status(
db, serial_number, data.status, current_user,
)
# ============================================================
# 协同留言板
# ============================================================
class MessageCreate(BaseModel):
operator_id: str = Field(..., min_length=1, max_length=50, description="留言人姓名或工号")
content: str = Field(..., min_length=1, description="留言内容")
@router.get("/{product_id}/messages")
async def get_product_messages(
product_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取某产品的所有留言(按时间正序)"""
result = await db.execute(
select(ProductMessage)
.where(ProductMessage.product_id == product_id)
.order_by(ProductMessage.created_at.asc())
)
return result.scalars().all()
@router.post("/{product_id}/messages", status_code=201)
async def create_product_message(
product_id: str,
request: MessageCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""发布新留言operator_id 由后端 Token 强制覆写,防止越权伪造)"""
import uuid
real_operator_id = (
current_user.get("username")
or current_user.get("sub")
or request.operator_id
)
msg = ProductMessage(
product_id=uuid.UUID(product_id),
operator_id=real_operator_id,
content=request.content,
)
db.add(msg)
await db.commit()
await db.refresh(msg)
return msg

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query, Body
from pydantic import BaseModel, Field
from app.services.auth_service import get_current_user
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
@ -59,6 +60,7 @@ async def get_task(
async def create_task_endpoint(
data: TaskCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""创建任务"""
return await task_service.create_task(db, data)
@ -69,6 +71,7 @@ async def update_task_endpoint(
task_id: str,
data: TaskUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""更新任务"""
return await task_service.update_task(db, uuid.UUID(task_id), data)
@ -83,6 +86,7 @@ async def complete_task_endpoint(
task_id: str,
request: TaskCompleteRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**核心接口:完成任务 + 可选创建下一步任务(转交)**
@ -99,7 +103,8 @@ async def complete_task_endpoint(
- 完成后自动创建下一步任务并指定负责人
"""
return await task_service.complete_task(
db, uuid.UUID(task_id), request
db, uuid.UUID(task_id), request,
operator_role=current_user.get("role"),
)
@ -112,13 +117,17 @@ async def end_task_endpoint(
task_id: str,
operator_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**结束当前分支:标记任务为 COMPLETED不创建下游任务。**
用于工人认为工序已完结、无需转交下一人的场景。
"""
return await task_service.end_task(db, uuid.UUID(task_id), operator_id)
return await task_service.end_task(
db, uuid.UUID(task_id), operator_id,
operator_role=current_user.get("role"),
)
# ============================================================
@ -130,12 +139,16 @@ async def recall_task_endpoint(
task_id: str,
operator_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**撤回转交:删除 PENDING 子任务,恢复父任务为 WIP。**
适用场景:转交后发现选错人,在对方接收前撤回。
"""
return await task_service.recall_task(db, uuid.UUID(task_id), operator_id)
return await task_service.recall_task(
db, uuid.UUID(task_id), operator_id,
operator_role=current_user.get("role"),
)
# ============================================================
@ -154,6 +167,7 @@ async def spawn_subtask_endpoint(
data: SpawnRequest,
operator_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。**
@ -173,6 +187,7 @@ async def receive_task_endpoint(
remark: str | None = Body(None, description="接收备注", embed=True),
task_name: str | None = Body(None, description="接收人选定的工序名称", embed=True),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**确认接收任务。工人选定工序名称后接收。**
@ -181,7 +196,8 @@ async def receive_task_endpoint(
动作:状态改为 WIP记录 received_at更新 task_name同步产品宏观状态。
"""
return await task_service.receive_task(
db, uuid.UUID(task_id), operator_id, remark, task_name
db, uuid.UUID(task_id), operator_id, remark, task_name,
operator_role=current_user.get("role"),
)
@ -195,6 +211,7 @@ async def reject_task_endpoint(
request: TaskRejectRequest,
operator_id: str | None = Query(None, description="操作人ID"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**品质驳回:将任务标记为 REJECTED自动创建返工任务。**
@ -205,7 +222,8 @@ async def reject_task_endpoint(
3. 为该负责人新建返工任务is_rework=True, status=PENDING
"""
return await task_service.reject_task(
db, uuid.UUID(task_id), request, operator_id
db, uuid.UUID(task_id), request, operator_id,
operator_role=current_user.get("role"),
)
@ -219,6 +237,7 @@ async def transfer_task_endpoint(
request: TaskTransferRequest,
operator_id: str | None = Query(None, description="操作人ID"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**完工并裂变转交:完成当前任务,批量创建下一道工序任务。**
@ -237,7 +256,8 @@ async def transfer_task_endpoint(
- 否则 → 顶层同级转交。
"""
return await task_service.transfer_task(
db, uuid.UUID(task_id), request, operator_id
db, uuid.UUID(task_id), request, operator_id,
operator_role=current_user.get("role"),
)
@ -250,6 +270,7 @@ async def create_subtask_endpoint(
task_id: str,
data: SubtaskCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**创建子任务:支持无限层级嵌套。**
@ -284,6 +305,7 @@ async def add_task_record_endpoint(
task_id: str,
data: TaskRecordCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""追加进度记录(备注+图片),不改变任务状态"""
return await task_service.add_task_record(db, uuid.UUID(task_id), data)

View File

@ -10,6 +10,8 @@ from app.api.v1.endpoints.materials import router as materials_router
from app.api.v1.endpoints.users import router as users_router
from app.api.v1.endpoints.upload import router as upload_router
from app.api.v1.endpoints.records import router as records_router
from app.api.v1.endpoints.notifications import router as notifications_router
from app.api.v1.endpoints.app_version import router as app_version_router
api_router = APIRouter()
@ -23,3 +25,5 @@ api_router.include_router(materials_router)
api_router.include_router(users_router)
api_router.include_router(upload_router)
api_router.include_router(records_router)
api_router.include_router(notifications_router)
api_router.include_router(app_version_router)

View File

@ -9,7 +9,8 @@ class Settings(BaseSettings):
# ---- JWT ----
SECRET_KEY: str = "change-me-in-production"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ACCESS_TOKEN_EXPIRE_MINUTES: int = 120 # Access Token: 2 小时
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 # Refresh Token: 7 天
# ---- 调试 ----
DEBUG: bool = True

View File

@ -1,4 +1,4 @@
"""安全模块 — JWT Token 生成与验证"""
"""安全模块 — JWT Token 生成与验证(双 Token 架构)"""
from datetime import timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
@ -9,17 +9,36 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
# Token 类型声明
TOKEN_TYPE_ACCESS = "access"
TOKEN_TYPE_REFRESH = "refresh"
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""生成 JWT Access Token"""
"""生成 JWT Access Token2 小时有效)"""
to_encode = data.copy()
expire = get_beijing_time() + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
to_encode.update({"exp": expire, "type": TOKEN_TYPE_ACCESS})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""生成 JWT Refresh Token7 天有效,仅用于刷新 Access Token"""
to_encode = data.copy()
expire = get_beijing_time() + (
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
)
to_encode.update({"exp": expire, "type": TOKEN_TYPE_REFRESH})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
"""解码并验证 JWT Token返回 payload"""
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证明文密码 vs 哈希密码"""
return pwd_context.verify(plain_password, hashed_password)

View File

@ -4,6 +4,9 @@ from app.models.production_order import ProductionOrder
from app.models.product import Product
from app.models.task import Task, TaskRecord
from app.models.task_log import TaskLog
from app.models.notification import Notification
from app.models.app_version import AppVersion
from app.models.message import ProductMessage
__all__ = [
"Base",
"ProductionOrder",
@ -11,4 +14,7 @@ __all__ = [
"Task",
"TaskRecord",
"TaskLog",
"Notification",
"AppVersion",
"ProductMessage",
]

View File

@ -0,0 +1,44 @@
"""App 版本管理模型 — 用于 OTA 热更新"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class AppVersion(Base):
__tablename__ = "app_versions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
version: Mapped[str] = mapped_column(
String(20), nullable=False, unique=True, comment="版本号,如 T1.0.1",
)
version_code: Mapped[int] = mapped_column(
Integer, nullable=False, default=100, comment="数字版本号,用于比较",
)
wgt_url: Mapped[str] = mapped_column(
String(500), nullable=False, comment="WGT 升级包下载地址",
)
description: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="更新说明",
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, comment="是否启用",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time,
)
def __repr__(self) -> str:
return f"<AppVersion {self.version}>"

View File

@ -0,0 +1,32 @@
"""产品协同留言板模型"""
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class ProductMessage(Base):
__tablename__ = "product_messages"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("products.id", ondelete="CASCADE"),
index=True,
nullable=False,
comment="所属产品 ID",
)
operator_id: Mapped[str] = mapped_column(
String(50), nullable=False, comment="留言人姓名或工号",
)
content: Mapped[str] = mapped_column(
Text, nullable=False, comment="留言内容",
)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, comment="留言时间",
)

View File

@ -0,0 +1,52 @@
"""通知模型 — 任务转交/驳回等事件的消息提醒"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
# 通知类型常量
NOTIFY_TRANSFER = "TRANSFER" # 新任务派发/转交
NOTIFY_REJECT = "REJECT" # 品质驳回
class Notification(Base):
__tablename__ = "notifications"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
user_id: Mapped[str] = mapped_column(
String(64), nullable=False, index=True, comment="接收人ID逻辑外键→老系统",
)
title: Mapped[str] = mapped_column(
String(200), nullable=False, comment="通知标题",
)
content: Mapped[str] = mapped_column(
Text, nullable=False, comment="通知内容详情",
)
type: Mapped[str] = mapped_column(
String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)",
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID",
)
is_read: Mapped[bool] = mapped_column(
Boolean, default=False, comment="是否已读",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)
def __repr__(self) -> str:
return f"<Notification {self.type}{self.user_id}>"

View File

@ -84,6 +84,7 @@ class Task(Base):
)
child_tasks: Mapped[list["Task"]] = relationship(
"Task", back_populates="parent_task", lazy="selectin",
order_by="Task.created_at",
)
records: Mapped[list["TaskRecord"]] = relationship(
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan",

View File

@ -0,0 +1,14 @@
"""App 版本 Schema"""
from pydantic import BaseModel
class AppVersionResponse(BaseModel):
"""返回给 App 的版本信息"""
version: str
version_code: int
has_update: bool
wgt_url: str | None = None
description: str | None = None
force_update: bool = False
model_config = {"from_attributes": True}

View File

@ -0,0 +1,27 @@
"""通知 Pydantic Schema"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class NotificationResponse(BaseModel):
"""通知列表响应"""
id: uuid.UUID
user_id: str
title: str
content: str
type: str
task_id: uuid.UUID | None = None
product_serial_number: str | None = None
is_read: bool
created_at: datetime
model_config = {"from_attributes": True}
class NotificationListResponse(BaseModel):
"""通知分页列表"""
notifications: list[NotificationResponse]
total: int
unread_count: int

View File

@ -50,6 +50,8 @@ class ProductResponse(BaseModel):
material_type: str | None = None
parent_product_id: uuid.UUID | None
current_location_id: str | None = None
current_location_name: str | None = None
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
overall_status: str | None = None
status: str
created_at: datetime
@ -76,6 +78,7 @@ class ProductScanResponse(BaseModel):
created_at: datetime
top_level_tasks: list[TaskSummaryResponse] = []
task_tree: list[TaskResponse] = []
assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射
model_config = {"from_attributes": True}

View File

@ -110,6 +110,7 @@ class TaskSummaryResponse(BaseModel):
status: str
notify_parent_on_complete: bool
is_rework: bool = False
task_type: str | None = None
remark: str | None = None
reject_reason: str | None = None
received_at: datetime | None = None

View File

@ -1,4 +1,4 @@
"""用户 Schemas — 对接 MOM sys_user 表"""
"""用户 Schemas — 对接 MOM sys_user 表 + 双 Token"""
from pydantic import BaseModel, Field
@ -20,5 +20,15 @@ class UserResponse(BaseModel):
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserResponse
class RefreshRequest(BaseModel):
refresh_token: str = Field(..., description="Refresh Token")
class RefreshResponse(BaseModel):
access_token: str
token_type: str = "bearer"

View File

@ -1,11 +1,18 @@
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT"""
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT(双 Token 架构)"""
from fastapi import HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from werkzeug.security import check_password_hash
from app.core.config import settings
from app.core.security import create_access_token, ALGORITHM
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
ALGORITHM,
TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH,
)
from app.core.mom_database import MomSessionLocal
from app.schemas.user import LoginResponse, UserResponse
@ -13,15 +20,15 @@ security = HTTPBearer()
def login(username: str, password: str) -> LoginResponse:
"""登录 — 查询 MOM 数据库 sys_user 表验证"""
"""登录 — 签发双 TokenAccess + Refresh"""
db = MomSessionLocal()
try:
# 1. 超级管理员硬编码(和 MOM 系统一致)
if username == "IRIS" and password == "123321":
token_data = {"sub": "0", "role": "SUPER_ADMIN", "username": "IRIS", "display_name": "超级管理员"}
return LoginResponse(
access_token=create_access_token(
data={"sub": "0", "role": "SUPER_ADMIN"}
),
access_token=create_access_token(data=token_data),
refresh_token=create_refresh_token(data=token_data),
user=UserResponse(
id="0",
username="IRIS",
@ -60,17 +67,16 @@ def login(username: str, password: str) -> LoginResponse:
# 4. 解析 display_name"张三/zhangsan01" → "张三"
display_name = full_username.split("/")[0] if "/" in full_username else full_username
token = create_access_token(
data={
"sub": str(user_id),
"role": role or "operator",
"username": username,
"display_name": display_name,
}
)
token_data = {
"sub": str(user_id),
"role": role or "operator",
"username": username,
"display_name": display_name,
}
return LoginResponse(
access_token=token,
access_token=create_access_token(data=token_data),
refresh_token=create_refresh_token(data=token_data),
user=UserResponse(
id=str(user_id),
username=username,
@ -83,16 +89,60 @@ def login(username: str, password: str) -> LoginResponse:
db.close()
def refresh_access_token(refresh_token: str) -> dict:
"""
使用 Refresh Token 换取新的 Access Token。
校验:
1. Token 签名是否有效
2. Token type 是否为 "refresh"
3. Token 是否未过期
"""
try:
payload = decode_token(refresh_token)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh Token 无效或已过期,请重新登录",
)
# 校验 token 类型
if payload.get("type") != TOKEN_TYPE_REFRESH:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 Token 类型,仅接受 Refresh Token",
)
# 提取用户信息,签发新的 Access Token
access_token = create_access_token(
data={
"sub": payload.get("sub"),
"role": payload.get("role", "operator"),
"username": payload.get("username", ""),
"display_name": payload.get("display_name", ""),
}
)
return {"access_token": access_token, "token_type": "bearer"}
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""从 Bearer Token 解析当前用户(不查数据库,直接解 JWT"""
"""从 Bearer Token 解析当前用户(仅接受 Access Token"""
token = credentials.credentials
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
payload = decode_token(token)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="无效的 Token")
# 校验:仅接受 access token
if payload.get("type") == TOKEN_TYPE_REFRESH:
raise HTTPException(
status_code=401,
detail="请使用 Access Token 访问 APIRefresh Token 仅用于刷新",
)
return payload
except JWTError:
raise HTTPException(status_code=401, detail="无效的 Token")

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy import select, or_, cast, String, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@ -34,6 +34,7 @@ def _task_to_response(task: Task) -> TaskResponse:
status=task.status,
notify_parent_on_complete=task.notify_parent_on_complete,
is_rework=task.is_rework,
task_type=task.task_type,
remark=task.remark,
reject_reason=task.reject_reason,
received_at=task.received_at,
@ -107,6 +108,17 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
# 获取完整任务树(递归嵌套,供前端渲染十字矩阵树状图)
task_tree = await _load_task_tree(db, product.id)
# 🔧 收集任务树中所有 assignee_id → 查中文姓名映射
assignee_ids: set[str] = set()
def _collect_ids(tasks):
for t in tasks:
if t.assignee_id: assignee_ids.add(t.assignee_id)
if t.child_tasks: _collect_ids(t.child_tasks)
for t in top_tasks:
if t.assignee_id: assignee_ids.add(t.assignee_id)
_collect_ids(task_tree)
assignee_names = _lookup_display_names(list(assignee_ids))
return ProductScanResponse(
id=product.id,
serial_number=product.serial_number,
@ -127,6 +139,7 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
TaskSummaryResponse.model_validate(t) for t in top_tasks
],
task_tree=task_tree,
assignee_names=assignee_names, # 🔧 username→中文姓名
)
@ -146,8 +159,8 @@ async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product:
return product
async def create_product(db: AsyncSession, data: ProductCreate) -> ProductResponse:
"""创建产品 — 自动生成 16 位 HEX 序列号"""
async def create_product(db: AsyncSession, data: ProductCreate, creator_username: str = "") -> ProductResponse:
"""创建产品 — 自动生成 16 位 HEX 序列号,初始位置设为创建者"""
from app.services.counter_service import ensure_sequence, next_hex_id
from app.models.production_order import ProductionOrder
@ -179,10 +192,18 @@ async def create_product(db: AsyncSession, data: ProductCreate) -> ProductRespon
material_type=data.material_type or None,
external_serial=data.external_serial,
parent_product_id=data.parent_product_id,
current_location_id=creator_username or None, # 谁创建,初始位置就是谁
)
db.add(product)
await db.commit()
await db.refresh(product, ["order"])
# 查创建者的真实姓名
creator_display_name = ""
if creator_username:
name_map = _lookup_display_names([creator_username])
creator_display_name = name_map.get(creator_username, "")
return ProductResponse(
id=product.id,
serial_number=product.serial_number,
@ -196,6 +217,7 @@ async def create_product(db: AsyncSession, data: ProductCreate) -> ProductRespon
material_type=product.material_type,
parent_product_id=product.parent_product_id,
current_location_id=product.current_location_id,
current_location_name=creator_display_name or None,
overall_status=product.overall_status,
status=product.status,
created_at=product.created_at,
@ -253,8 +275,17 @@ async def update_product(db: AsyncSession, product_id: uuid.UUID, data: ProductU
VALID_OVERALL_STATUS = {"备货", "生产", "测试", "维修", "在库"}
async def update_overall_status(db: AsyncSession, serial_number: str, status_value: str) -> ProductScanResponse:
"""更新产品宏观状态"""
async def update_overall_status(
db: AsyncSession, serial_number: str, status_value: str,
current_user: dict | None = None,
) -> ProductScanResponse:
"""更新产品宏观状态
权限校验:
- SUPER_ADMIN 角色:直接放行
- 当前操作该产品主线任务WIP/PENDING 状态主干任务)的人:放行
- 其他403
"""
if status_value not in VALID_OVERALL_STATUS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -270,6 +301,41 @@ async def update_overall_status(db: AsyncSession, serial_number: str, status_val
if not product:
raise HTTPException(status_code=404, detail=f"未找到序列号 {serial_number} 的产品")
# ── 权限校验(无 current_user 一律拒绝,杜绝空 dict 绕过)──
if not current_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
)
user_role = current_user.get("role", "")
user_username = current_user.get("username", "")
# SUPER_ADMIN 直接放行
if user_role != "SUPER_ADMIN":
# 检查当前用户是否是该产品主线任务的负责人
from sqlalchemy import or_
main_task_result = await db.execute(
select(Task).where(
Task.product_id == product.id,
Task.status.in_(["WIP", "PENDING"]),
or_(
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
),
).order_by(Task.created_at.desc()).limit(1)
)
main_task = main_task_result.scalar_one_or_none()
has_permission = (
main_task is not None
and main_task.assignee_id == user_username
)
if not has_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
)
product.overall_status = status_value
await db.commit()
await db.refresh(product)
@ -277,16 +343,173 @@ async def update_overall_status(db: AsyncSession, serial_number: str, status_val
return await get_product_by_serial(db, serial_number)
async def get_all_products(db: AsyncSession, skip: int = 0, limit: int = 50) -> list[ProductResponse]:
"""获取产品列表"""
result = await db.execute(
select(Product)
.options(selectinload(Product.order))
.offset(skip)
.limit(limit)
.order_by(Product.created_at.desc())
)
def _lookup_display_names(location_ids: list[str]) -> dict[str, str]:
"""批量查询 MOM sys_user将 username 映射为真实姓名"""
if not location_ids:
return {}
from app.core.mom_database import MomSessionLocal
from sqlalchemy import text
db = MomSessionLocal()
try:
# 过滤掉特殊值
real_ids = [uid for uid in location_ids if uid and uid != "virtual_warehouse"]
if not real_ids:
return {}
# 用 LIKE 模糊匹配批量查出
conditions = " OR ".join([f"username LIKE '%/{uid}'" for uid in real_ids])
result = db.execute(
text(f"SELECT username, SPLIT_PART(username, '/', 1) as display_name FROM sys_user WHERE {conditions}")
)
mapping = {}
for row in result:
full_username = row[0]
display_name = row[1]
# 从 full_username 末尾提取短用户名: "张三/zhangsan01" → "zhangsan01"
short = full_username.split("/")[-1] if "/" in full_username else full_username
mapping[short] = display_name
return mapping
finally:
db.close()
async def get_all_products(
db: AsyncSession,
skip: int = 0,
limit: int = 50,
keyword: str | None = None,
status_filter: str | None = None,
) -> list[ProductResponse]:
"""
获取产品列表 — 支持多维 keyword 搜索 + 状态筛选
keyword: 同时模糊匹配 serial_number (产品身份证)、material_name/id (规格型号)、order_no (订单号)
status_filter: 按产品状态过滤 (如 PENDING / WIP / COMPLETED / ARCHIVED)
"""
stmt = select(Product).options(selectinload(Product.order))
# keyword 多字段 OR 模糊搜索
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
stmt = stmt.outerjoin(ProductionOrder, Product.order_id == ProductionOrder.id).where(
or_(
Product.serial_number.ilike(kw),
Product.material_name.ilike(kw),
cast(Product.material_id, String).ilike(kw),
Product.spec_model.ilike(kw),
ProductionOrder.order_no.ilike(kw),
)
).distinct()
# 状态筛选 — 大小写不敏感,支持组合过滤
if status_filter and status_filter.strip():
from sqlalchemy import func
sf = status_filter.strip().upper()
if sf == "DONE":
# "已完成" 匹配 COMPLETED 或 ARCHIVED
stmt = stmt.where(
or_(
func.upper(Product.status) == "COMPLETED",
func.upper(Product.status) == "ARCHIVED",
)
)
elif sf == "PENDING":
# "待流转" — 产品状态 PENDING 且所有顶层任务均未分配人
stmt = (
stmt.outerjoin(Task, Task.product_id == Product.id)
.where(func.upper(Product.status) == "PENDING")
.where(Task.assignee_id.is_(None))
.distinct()
)
elif sf == "PENDING_ASSIGNED":
# "待接收" — 产品状态 PENDING 但已有任务被分配(等待工人扫码)
stmt = (
stmt.outerjoin(Task, Task.product_id == Product.id)
.where(func.upper(Product.status) == "PENDING")
.where(Task.assignee_id.isnot(None))
.distinct()
)
else:
stmt = stmt.where(func.upper(Product.status) == sf)
stmt = stmt.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db.execute(stmt)
products = result.scalars().all()
# 🔧 批量预计算 macro_status一次性查出所有产品关联的任务状态
product_ids = [p.id for p in products]
macro_map: dict[uuid.UUID, str] = {}
if product_ids:
from sqlalchemy import case, func as sa_func
task_stmt = (
select(
Task.product_id,
sa_func.max(case(
(Task.status == "WIP", 3),
(Task.status == "PENDING", 2),
(Task.status == "COMPLETED", 1),
(Task.status == "ARCHIVED", 1),
else_=0,
)).label("prio"),
)
.where(Task.product_id.in_(product_ids))
.group_by(Task.product_id)
)
task_result = await db.execute(task_stmt)
prio_to_status = {3: "WIP", 2: "PENDING", 1: "COMPLETED", 0: None}
for row in task_result:
macro_map[row[0]] = prio_to_status.get(row[1], None)
# 🔧 动态主干状态+位置:只从主干任务中获取最高优先级任务的 task_name + assignee_id
overall_names: dict[uuid.UUID, str] = {}
main_assignees: dict[uuid.UUID, str] = {}
if product_ids:
from sqlalchemy import and_, or_, func as sa_func, case as sa_case
main_where = and_(
Task.product_id.in_(product_ids),
or_(
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
),
)
prio_expr = sa_case(
(Task.status == "WIP", 3),
(Task.status == "PENDING", 2),
(Task.status == "COMPLETED", 1),
else_=0,
)
# 子查询:每个产品最高优先级主干任务
max_prio = (
select(Task.product_id, sa_func.max(prio_expr).label("prio"))
.where(main_where)
.group_by(Task.product_id)
).subquery("mp")
# JOIN 回 tasks 拿 task_name + assignee_id同优先级取最新创建的
main_stmt = (
select(Task.product_id, Task.task_name, Task.assignee_id)
.join(max_prio, and_(
Task.product_id == max_prio.c.product_id,
prio_expr == max_prio.c.prio,
))
.where(main_where)
.order_by(Task.product_id, Task.created_at.desc())
.distinct(Task.product_id)
)
main_result = await db.execute(main_stmt)
for row in main_result:
pid, tname, assignee = row[0], row[1], row[2]
overall_names[pid] = tname
if assignee: main_assignees[pid] = assignee
# 🔧 动态主干的 assignee_id → 查中文姓名
dynamic_location_ids = list(main_assignees.values())
dynamic_name_map = _lookup_display_names(dynamic_location_ids)
# 🔧 合并:静态位置姓名(兜底)+ 动态主干位置姓名(优先)
static_location_ids = [p.current_location_id for p in products if p.current_location_id]
merged_location_ids = list(set(static_location_ids + dynamic_location_ids))
merged_name_map = _lookup_display_names(merged_location_ids)
return [
ProductResponse(
id=p.id,
@ -300,10 +523,56 @@ async def get_all_products(db: AsyncSession, skip: int = 0, limit: int = 50) ->
category=p.category,
material_type=p.material_type,
parent_product_id=p.parent_product_id,
current_location_id=p.current_location_id,
overall_status=p.overall_status,
# 🔧 当前位置动态主干assignee优先 → 静态兜底
current_location_id=(
main_assignees.get(p.id) # 动态主干
or p.current_location_id # 静态兜底
),
current_location_name=(
"仓库" if (main_assignees.get(p.id) or p.current_location_id) == "virtual_warehouse"
else dynamic_name_map.get(main_assignees.get(p.id, ""))
or merged_name_map.get(p.current_location_id) if p.current_location_id
else None
),
macro_status=macro_map.get(p.id) or p.status,
overall_status=overall_names.get(p.id) or p.overall_status,
status=p.status,
created_at=p.created_at,
)
for p in products
]
async def delete_product(db: AsyncSession, product_id: uuid.UUID) -> None:
"""删除产品及其关联任务"""
product = await get_product(db, product_id)
from app.models.task import TaskRecord
from app.models.task_log import TaskLog
# 🚀 1. 切断产品自引用:子产品的 parent_product_id 置空
await db.execute(
update(Product).where(Product.parent_product_id == product_id).values(parent_product_id=None)
)
# 2. 查询所有关联任务
tasks_result = await db.execute(
select(Task).where(Task.product_id == product_id)
)
tasks = tasks_result.scalars().all()
# 🚀 3. 切断任务自引用:子任务的 parent_task_id 置空
for task in tasks:
await db.execute(
update(Task).where(Task.parent_task_id == task.id).values(parent_task_id=None)
)
# 4. 删除任务记录、日志、任务本身
for task in tasks:
await db.execute(delete(TaskRecord).where(TaskRecord.task_id == task.id))
await db.execute(delete(TaskLog).where(TaskLog.task_id == task.id))
await db.delete(task)
# 5. 删除产品product_messages 有 ON DELETE CASCADE 自动级联)
await db.delete(product)
await db.commit()

View File

@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
from app.core.time_utils import get_beijing_time
from app.models.product import Product
from app.models.task_log import TaskLog
@ -29,6 +30,74 @@ from app.schemas.task import (
# 特殊位置常量
VIRTUAL_WAREHOUSE = "virtual_warehouse"
# 管理员/主管角色白名单 — 拥有上帝视角操作权限
ADMIN_ROLES = {"SUPER_ADMIN", "SUPERVISOR"}
async def _recalc_product_location(
db: AsyncSession, product_id: uuid.UUID, completed_task_id: uuid.UUID | None = None,
) -> None:
"""
任务完工/结束时触发:只跟随主干任务(主分支),无视协助分支。
主干任务定义:
parent_task_id IS NULL OR task_type IN ('TRANSFER', 'RECOVERY')
优先级:
WIP > PENDING > COMPLETED/ARCHIVED > None
"""
from sqlalchemy import select as sa_select, case as sa_case, or_ as sa_or_
product_result = await db.execute(
sa_select(Product).where(Product.id == product_id)
)
product = product_result.scalar_one_or_none()
if not product:
return
# ── 只查主干任务: parent_task_id IS NULL 或 task_type IN (TRANSFER, RECOVERY) ──
stmt = (
sa_select(Task)
.where(
Task.product_id == product_id,
sa_or_(
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
),
)
.order_by(
# 优先级排序: WIP=3, PENDING=2, COMPLETED=1, ARCHIVED=1, else=0
sa_case(
(Task.status == TASK_STATUS_WIP, 3),
(Task.status == TASK_STATUS_PENDING, 2),
(Task.status == TASK_STATUS_COMPLETED, 1),
(Task.status == TASK_STATUS_ARCHIVED, 1),
else_=0,
).desc(),
Task.created_at.desc(),
)
.limit(1)
)
result = await db.execute(stmt)
main_task = result.scalar_one_or_none()
new_location = main_task.assignee_id if main_task else None
if product.current_location_id != new_location:
product.current_location_id = new_location
await db.flush() # 唯一的落盘点
def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None:
"""权限校验:本人 或 管理员/主管 可操作"""
if operator_role and operator_role in ADMIN_ROLES:
return # 上帝视角,直接放行
if operator_id and task_assignee_id and operator_id != task_assignee_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您无权操作此任务,当前任务负责人为 {task_assignee_id}",
)
# ============================================================
# 内部辅助函数
@ -229,7 +298,7 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
product = product_result.scalar_one_or_none()
if product:
if data.task_name:
if data.task_name and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")):
product.overall_status = "在库" if "virtual_warehouse" in data.task_name else data.task_name
# 派发给人 → 产品离开仓库
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:
@ -280,7 +349,8 @@ async def get_all_tasks(
# ============================================================
async def end_task(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
operator_role: str | None = None,
) -> TaskResponse:
"""
结束当前分支:标记任务为 COMPLETED不创建下游任务。
@ -288,6 +358,15 @@ async def end_task(
"""
task = await _get_task_or_404(db, task_id)
# 权限校验:本人 或 管理员/主管 可结束
_check_permission(task.assignee_id, operator_id, operator_role)
# 校验:仅 SPAWN 协助分支可以结束主分支TRANSFER/RECOVERY不能通过此接口终止
if not task.parent_task_id:
raise HTTPException(status_code=409, detail="根任务无法结束,请使用完工转交")
if task.task_type != "SPAWN":
raise HTTPException(status_code=409, detail="仅协助分支可以结束,主分支请使用完工转交")
# 校验:必须等待所有协助分支完成
await _check_children_done(db, task_id)
@ -303,6 +382,9 @@ async def end_task(
await _create_task_log(db, task_id, action_type="end",
operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)")
# 🔧 位置回溯:分支结束后优先回溯到父任务负责人
await _recalc_product_location(db, task.product_id, task.id)
await db.commit()
await db.refresh(task)
return _to_response(task)
@ -313,14 +395,50 @@ async def end_task(
# ============================================================
async def recall_task(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
operator_role: str | None = None,
) -> TaskResponse:
"""撤回 PENDING 转交:标记为 CANCELED以被撤回节点为父生成接力新任务给操作人。"""
"""撤回 PENDING 转交:标记为 CANCELED以被撤回节点为父生成接力新任务给操作人。
权限校验:
- 管理员SUPER_ADMIN / SUPERVISOR直接放行
- 操作人 必须等于 上游任务的负责人(谁发出的谁才能撤回),否则 403
"""
task = await _get_task_or_404(db, task_id)
if task.status != TASK_STATUS_PENDING:
raise HTTPException(status_code=409, detail="只有待接收(PENDING)的任务可以撤回")
# ── 权限校验:谁发出的谁才能撤回 ──
if not (operator_role and operator_role in ADMIN_ROLES):
# 查找上游任务的负责人(发出者)
upstream_assignee: str | None = None
if task.parent_task_id:
parent_result = await db.execute(
select(Task).where(Task.id == task.parent_task_id)
)
parent_task = parent_result.scalar_one_or_none()
if parent_task:
upstream_assignee = parent_task.assignee_id
else:
# 无父任务:从任务日志追溯创建人
log_result = await db.execute(
select(TaskLog).where(
TaskLog.task_id == task_id,
TaskLog.action_type == "create",
).order_by(TaskLog.created_at.asc()).limit(1)
)
create_log = log_result.scalar_one_or_none()
if create_log:
upstream_assignee = create_log.operator_id
# 如果找不到上游负责人,或者操作人不等于上游负责人 → 403
if not upstream_assignee or operator_id != upstream_assignee:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="您不是该任务的发起人,无法撤回",
)
now = get_beijing_time()
# 1. 废掉当前待接收任务
@ -426,6 +544,7 @@ ARCHIVED_STATUS = "ARCHIVED"
async def receive_task(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
remark: str | None = None, task_name: str | None = None,
operator_role: str | None = None,
) -> TaskResponse:
"""
操作员确认接收任务。工人选定工序名称后接收。
@ -435,12 +554,8 @@ async def receive_task(
"""
task = await _get_task_or_404(db, task_id)
# 权限校验:只有负责人本人可接收
if operator_id and task.assignee_id and operator_id != task.assignee_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您无权操作此任务,当前任务负责人为 {task.assignee_id}",
)
# 权限校验:本人 或 管理员/主管 可操作
_check_permission(task.assignee_id, operator_id, operator_role)
# 校验:只有 PENDING 状态可接收
if task.status != TASK_STATUS_PENDING:
@ -471,8 +586,10 @@ async def receive_task(
if product:
if task.assignee_id:
product.current_location_id = task.assignee_id
if task_name:
product.overall_status = task_name
if task_name and (
not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")
):
product.overall_status = task_name # 只主线任务同步宏观状态
await db.commit()
await db.refresh(task)
@ -485,7 +602,8 @@ async def receive_task(
# ============================================================
async def reject_task(
db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None
db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None,
operator_role: str | None = None,
) -> TaskResponse:
"""
品质驳回:将当前任务标记为 REJECTED并自动创建返工任务给上一道工序负责人。
@ -500,12 +618,8 @@ async def reject_task(
"""
task = await _get_task_or_404(db, task_id)
# 权限校验:只有负责人本人可驳回
if operator_id and task.assignee_id and operator_id != task.assignee_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您无权操作此任务,当前任务负责人为 {task.assignee_id}",
)
# 权限校验:本人 或 管理员/主管 可驳回
_check_permission(task.assignee_id, operator_id, operator_role)
# 校验:不能重复驳回已完成/已驳回的任务
if task.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED):
@ -530,8 +644,20 @@ async def reject_task(
# --- 2. 确定返工任务的负责人(追溯上一道工序的转交人) ---
rework_assignee_id: str | None = None
if task.parent_task_id:
# 有父任务:返工给父任务的负责人(即上一环的转交人 A
# 🚀 优先方式:从任务日志追溯创建人(准确记录是谁发起的转交
log_result = await db.execute(
select(TaskLog).where(
TaskLog.task_id == task_id,
TaskLog.action_type == "create",
).order_by(TaskLog.created_at.asc()).limit(1)
)
create_log = log_result.scalar_one_or_none()
if create_log and create_log.operator_id:
rework_assignee_id = create_log.operator_id
# 兜底方式1有父任务 → 返工给父任务的负责人
if not rework_assignee_id and task.parent_task_id:
parent_result = await db.execute(
select(Task).where(Task.id == task.parent_task_id)
)
@ -539,20 +665,8 @@ async def reject_task(
if parent_task:
rework_assignee_id = parent_task.assignee_id
# 兜底方式2用当前任务的负责人
if not rework_assignee_id:
# 无父任务(顶层转交):从任务日志追溯创建人(转交发起者 A
log_result = await db.execute(
select(TaskLog).where(
TaskLog.task_id == task_id,
TaskLog.action_type == "create",
).order_by(TaskLog.created_at.asc()).limit(1)
)
create_log = log_result.scalar_one_or_none()
if create_log and create_log.operator_id:
rework_assignee_id = create_log.operator_id
if not rework_assignee_id:
# 最后兜底:用当前任务的负责人(通常不应该走到这里)
rework_assignee_id = task.assignee_id
# --- 3. 创建返工任务 ---
@ -562,7 +676,7 @@ async def reject_task(
task_name=task.task_name,
assignee_id=rework_assignee_id,
status=TASK_STATUS_PENDING,
task_type="TRANSFER",
task_type=task.task_type, # 🚀 继承被驳回任务的基因:主线→主线,协助→协助
notify_parent_on_complete=task.notify_parent_on_complete,
is_rework=True,
)
@ -576,6 +690,24 @@ async def reject_task(
remark=f"返工任务(驳回自「{task.task_name}」,原因: {request.reason}),分配给 {rework_assignee_id}",
)
# 🔔 通知:品质驳回
product_sn = ""
try:
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
p = product_result.scalar_one_or_none()
if p:
product_sn = p.serial_number or ""
except Exception:
pass
if rework_assignee_id:
db.add(Notification(
user_id=rework_assignee_id,
title="🔴 品质驳回提醒",
content=f"产品 [{product_sn}] 的「{task.task_name}」被驳回,原因: {request.reason}",
type=NOTIFY_REJECT,
task_id=rework_task.id,
))
await db.commit()
await db.refresh(task)
@ -587,7 +719,8 @@ async def reject_task(
# ============================================================
async def transfer_task(
db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None
db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None,
operator_role: str | None = None,
) -> TaskTransferResponse:
"""
完工并裂变转交:
@ -608,6 +741,9 @@ async def transfer_task(
"""
task = await _get_task_or_404(db, task_id)
# 权限校验:本人 或 管理员/主管 可转交
_check_permission(task.assignee_id, operator_id, operator_role)
# 校验:不能重复完成
if task.status == TASK_STATUS_COMPLETED:
raise HTTPException(
@ -697,6 +833,21 @@ async def transfer_task(
operator_id=operator_id,
remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}",
)
# 🔔 通知:新任务派发
if nt.assignee_id:
product_sn = ""
try:
if product:
product_sn = product.serial_number or ""
except Exception:
pass
db.add(Notification(
user_id=nt.assignee_id,
title=f"🟢 新任务派发",
content=f"产品 [{product_sn}] 的「{nt.task_name}」任务已分配给你",
type=NOTIFY_TRANSFER,
task_id=nt.id,
))
# --- 更新 Product 的 current_location_id ---
product_result = await db.execute(
@ -709,7 +860,12 @@ async def transfer_task(
product.overall_status = "在库"
elif real_branches:
product.current_location_id = real_branches[0][1]
product.overall_status = real_branches[0][0]
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"):
product.overall_status = real_branches[0][0]
# 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务
if not real_branches and not has_warehouse:
await _recalc_product_location(db, task.product_id, task_id)
await db.commit()
@ -741,7 +897,8 @@ async def transfer_task(
# ============================================================
async def complete_task(
db: AsyncSession, task_id: uuid.UUID, request: TaskCompleteRequest
db: AsyncSession, task_id: uuid.UUID, request: TaskCompleteRequest,
operator_role: str | None = None,
) -> TaskCompleteResponse:
"""
核心业务:完成任务 + 可选创建下一步任务。
@ -756,11 +913,8 @@ async def complete_task(
"""
task = await _get_task_or_404(db, task_id)
# 注入子任务数据到当前对象以便后续检查
children_result = await db.execute(
select(Task).where(Task.parent_task_id == task_id)
)
task.child_tasks = children_result.scalars().all()
# 权限校验:本人 或 管理员/主管 可操作
_check_permission(task.assignee_id, request.operator_id, operator_role)
# --- 1. 幂等检查 ---
if task.status == TASK_STATUS_COMPLETED:
@ -792,12 +946,23 @@ async def complete_task(
# --- 4. 可选:创建下一步任务(转交) ---
next_task = None
if request.next_task_name and request.next_assignee_id:
# 🚀 智能父节点继承算法
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
# 注TASK_TYPE 实际值为 TRANSFER/RECOVERY/SPAWN不存在 "MAIN"
is_main_line = (
not task.parent_task_id
or task.task_type in ("TRANSFER", "RECOVERY")
)
new_parent_id = task.parent_task_id if is_main_line else task.id
next_task = Task(
product_id=task.product_id,
parent_task_id=task.parent_task_id, # 与已完成任务同级
parent_task_id=new_parent_id, # 👈 智能计算
task_name=request.next_task_name,
assignee_id=request.next_assignee_id,
status=TASK_STATUS_PENDING,
task_type=task.task_type, # 👈 基因严格继承(绝不篡位成 MAIN
notify_parent_on_complete=False,
)
db.add(next_task)
@ -809,6 +974,9 @@ async def complete_task(
remark=f"由任务「{task.task_name}」完成后转交创建",
)
# 🔧 位置回溯:老接口也触发(父任务优先)
await _recalc_product_location(db, task.product_id, task_id)
await db.commit()
# --- 5. 构建响应 ---

78
deploy.sh Normal file
View File

@ -0,0 +1,78 @@
#!/bin/bash
# ============================================================
# Track 生产流转系统 — 增量部署脚本
# 服务器: 172.16.0.198
# 路径: /opt/Track
# 仅更新代码,不覆盖数据库
# ============================================================
set -e
SERVER="dxc@172.16.0.198"
REMOTE_DIR="/opt/Track"
TIMESTAMP=$(date +%Y%m%d_%H%M)
REMOTE_BACKUP_DIR="$REMOTE_DIR/backups/$TIMESTAMP"
echo "==================================================="
echo "🚀 Track 增量部署 — 仅更新代码"
echo "==================================================="
# 1. 远端备份
echo "[1/4] 服务器备份旧代码..."
ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \
cd $REMOTE_DIR && \
echo '>> 备份当前代码...' && \
sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz \
backend frontend docker-compose.prod.yml 2>/dev/null || true && \
echo '>> 保留最近 3 个备份...' && \
cd $REMOTE_DIR/backups && \
sudo sh -c 'ls -dt */ 2>/dev/null | tail -n +4 | xargs -I {} rm -rf {} || true'"
if [ $? -ne 0 ]; then echo "❌ 备份失败,终止!"; exit 1; fi
# 2. 本地打包
echo "[2/4] 本地打包代码..."
tar -czf deploy.tar.gz \
--exclude="backend/venv" \
--exclude="backend/.venv" \
--exclude="backend/__pycache__" \
--exclude="backend/*.pyc" \
--exclude="backend/.env" \
--exclude="backend/uploads" \
--exclude="frontend/node_modules" \
--exclude="frontend/dist" \
--exclude=".git" \
--exclude=".idea" \
--exclude=".vscode" \
--exclude="track-uniapp/node_modules" \
--exclude="track-uniapp/dist" \
--exclude="track-uniapp/unpackage" \
backend frontend docker-compose.prod.yml
FILESIZE=$(stat -c%s "deploy.tar.gz" 2>/dev/null || stat -f%z "deploy.tar.gz")
echo ">> 打包完成: $((FILESIZE / 1024 / 1024)) MB"
# 3. 上传
echo "[3/4] 上传到服务器..."
scp deploy.tar.gz $SERVER:/tmp/deploy.tar.gz
if [ $? -ne 0 ]; then echo "❌ 上传失败!"; rm -f deploy.tar.gz; exit 1; fi
# 4. 替换重启
echo "[4/4] 服务器替换并重启..."
ssh -t $SERVER "cd $REMOTE_DIR && \
echo '>> 移除旧备份...' && \
sudo rm -rf backend_old frontend_old && \
echo '>> 保留当前为 old...' && \
(sudo mv backend backend_old 2>/dev/null || true) && \
(sudo mv frontend frontend_old 2>/dev/null || true) && \
echo '>> 解压新代码...' && \
sudo mv /tmp/deploy.tar.gz . && \
sudo tar -xzf deploy.tar.gz && \
echo '>> 重启 Docker...' && \
sudo docker compose -f docker-compose.prod.yml up -d --build && \
sudo rm deploy.tar.gz && \
echo '>> ✅ 部署完成!'"
rm -f deploy.tar.gz
echo "==================================================="
echo "✅ 增量部署完成!访问 http://172.16.0.198:8010"
echo "==================================================="

91
deploy_full.sh Normal file
View File

@ -0,0 +1,91 @@
#!/bin/bash
# ============================================================
# Track 生产流转系统 — 全量部署脚本(含数据库同步)
# 服务器: 172.16.0.198
# 路径: /opt/Track
# ⚠️ 会覆盖生产数据库!请确认后再执行!
# ============================================================
set -e
SERVER="dxc@172.16.0.198"
REMOTE_DIR="/opt/Track"
TIMESTAMP=$(date +%Y%m%d_%H%M)
REMOTE_BACKUP_DIR="$REMOTE_DIR/backups/$TIMESTAMP"
echo "⚠️⚠️⚠️ 警告:此操作将覆盖生产数据库!⚠️⚠️⚠️"
read -p "确认继续?(y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
# 1. 远端全量备份
echo "[1/5] 服务器全量备份..."
ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \
cd $REMOTE_DIR && \
echo '>> 导出线上数据库...' && \
sudo sh -c 'docker exec track_db_prod pg_dumpall -c -U track | gzip > $REMOTE_BACKUP_DIR/db_backup.sql.gz' && \
echo '>> 备份代码...' && \
sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz \
backend frontend docker-compose.prod.yml 2>/dev/null || true && \
echo '>> 保留最近 3 个备份...' && \
cd $REMOTE_DIR/backups && \
sudo sh -c 'ls -dt */ 2>/dev/null | tail -n +4 | xargs -I {} rm -rf {} || true'"
if [ $? -ne 0 ]; then echo "❌ 备份失败,终止!"; exit 1; fi
# 2. 导出本地数据库
echo "[2/5] 导出本地数据库..."
docker exec track_db pg_dumpall -c -U track | gzip > db_sync.sql.gz
echo ">> 数据库导出完成"
# 3. 本地打包
echo "[3/5] 本地打包代码+数据库..."
tar -czf deploy_full.tar.gz \
--exclude="backend/venv" \
--exclude="backend/.venv" \
--exclude="backend/__pycache__" \
--exclude="backend/*.pyc" \
--exclude="backend/.env" \
--exclude="backend/uploads" \
--exclude="frontend/node_modules" \
--exclude="frontend/dist" \
--exclude=".git" \
--exclude=".idea" \
--exclude=".vscode" \
--exclude="track-uniapp/node_modules" \
--exclude="track-uniapp/dist" \
--exclude="track-uniapp/unpackage" \
--exclude="backups" \
backend frontend docker-compose.prod.yml db_sync.sql.gz
FILESIZE=$(stat -c%s "deploy_full.tar.gz" 2>/dev/null || stat -f%z "deploy_full.tar.gz")
echo ">> 打包完成: $((FILESIZE / 1024 / 1024)) MB"
# 4. 上传
echo "[4/5] 上传到服务器..."
scp deploy_full.tar.gz $SERVER:/tmp/deploy_full.tar.gz
if [ $? -ne 0 ]; then echo "❌ 上传失败!"; rm -f deploy_full.tar.gz db_sync.sql.gz; exit 1; fi
# 5. 替换+导入+重启
echo "[5/5] 服务器部署..."
ssh -t $SERVER "cd $REMOTE_DIR && \
echo '>> 移除旧备份...' && \
sudo rm -rf backend_old frontend_old && \
echo '>> 保留当前为 old...' && \
(sudo mv backend backend_old 2>/dev/null || true) && \
(sudo mv frontend frontend_old 2>/dev/null || true) && \
echo '>> 解压新代码...' && \
sudo mv /tmp/deploy_full.tar.gz . && \
sudo tar -xzf deploy_full.tar.gz && \
echo '>> 重启 Docker...' && \
sudo docker compose -f docker-compose.prod.yml up -d --build && \
echo '>> 导入数据库...' && \
sudo sh -c 'gunzip -c db_sync.sql.gz | docker exec -i track_db_prod psql -U track -d track_production' && \
sudo rm deploy_full.tar.gz db_sync.sql.gz && \
echo '>> ✅ 全量部署完成!'"
rm -f deploy_full.tar.gz db_sync.sql.gz
echo "==================================================="
echo "✅ 全量部署完成!访问 http://172.16.0.198:8010"
echo "==================================================="

83
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,83 @@
# ============================================================
# 生产流转管理系统 — 生产环境 Docker Compose
# 服务器: 172.16.0.198
# 部署路径: /opt/Track
# 启动: docker compose -f docker-compose.prod.yml up -d --build
# ============================================================
services:
# ============================================================
# PostgreSQL 15 + pgvector
# ============================================================
db:
image: pgvector/pgvector:pg15
container_name: track_db_prod
restart: always
environment:
POSTGRES_USER: track
POSTGRES_PASSWORD: track_prod_2026
POSTGRES_DB: track_production
ports:
- "8012:5432"
volumes:
- track_pgdata_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U track -d track_production"]
interval: 10s
timeout: 5s
retries: 5
# ============================================================
# FastAPI 后端 — gunicorn + uvicorn (生产模式,无热更新)
# ============================================================
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
container_name: track_backend_prod
restart: always
environment:
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@track_db_prod:5432/track_production
SECRET_KEY: change-me-in-production-please
DEBUG: "false"
CORS_ORIGINS: '["http://track.iris-rs.cn","http://track_back.iris-rs.cn","http://172.16.0.198:8010","http://172.16.0.198","tauri://localhost"]'
# 🚀 MOM 老系统数据库 — 用户登录验证 + 用户名单来源
MOM_DB_HOST: inventory_db_prod
MOM_DB_PORT: "5432"
ports:
- "8011:8000"
volumes:
- ./backend:/app
command: >
sh -c "uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 --log-level info"
depends_on:
db:
condition: service_healthy
networks:
- default
- mom_net
# ============================================================
# React 前端 — Nginx 静态文件服务 (生产模式)
# ============================================================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
container_name: track_frontend_prod
restart: always
ports:
- "8010:80"
volumes:
- /opt/Track/updates:/usr/share/nginx/html/updates
depends_on:
- backend
networks:
mom_net:
external: true
name: inventory-app_default
volumes:
track_pgdata_prod:
name: track_pgdata_prod

18
frontend/Dockerfile.prod Normal file
View File

@ -0,0 +1,18 @@
# ============================================================
# 前端生产 Dockerfile — 多阶段构建Vite 打包 → Nginx 托管
# ============================================================
# --- 阶段 1编译 React ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY . .
RUN npx vite build
# --- 阶段 2Nginx 托管静态文件 ---
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

32
frontend/nginx.conf Normal file
View File

@ -0,0 +1,32 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
# 静态文件缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 代理到后端
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA 路由回退
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"antd": "^6.5.3",
@ -1714,6 +1715,33 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz",
"integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.7",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz",
"integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",

View File

@ -16,6 +16,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"antd": "^6.5.3",

View File

@ -1,20 +1,26 @@
import { Suspense, lazy } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { App as AntApp } from "antd";
import { ToastProvider } from "./components/ui/Toast";
import { AuthProvider } from "./contexts/AuthContext";
import AppLayout from "./components/layout/AppLayout";
import ScanPage from "./pages/ScanPage";
import MyTasksPage from "./pages/MyTasksPage";
import NotificationsPage from "./pages/NotificationsPage";
import ProfilePage from "./pages/ProfilePage";
import LoadingSpinner from "./components/ui/LoadingSpinner";
// Layout 组件静态导入(始终需要,体积小)
import AppLayout from "./components/layout/AppLayout";
import AdminLayout from "./components/layout/AdminLayout";
import AdminLoginPage from "./pages/admin/AdminLoginPage";
import AdminDashboard from "./pages/admin/AdminDashboard";
import AdminProductsPage from "./pages/admin/AdminProductsPage";
import AdminTasksPage from "./pages/admin/AdminTasksPage";
import AdminPrintConfigPage from "./pages/admin/AdminPrintConfigPage";
// 🚀 路由级代码分割 — 按需懒加载页面组件
const ScanPage = lazy(() => import("./pages/ScanPage"));
const MyTasksPage = lazy(() => import("./pages/MyTasksPage"));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage"));
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
const AdminLoginPage = lazy(() => import("./pages/admin/AdminLoginPage"));
const AdminDashboard = lazy(() => import("./pages/admin/AdminDashboard"));
const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage"));
const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
export default function App() {
return (
@ -22,28 +28,30 @@ export default function App() {
<ToastProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* 移动端 */}
<Route path="/" element={<Navigate to="/scan" replace />} />
<Route element={<AppLayout />}>
<Route path="/scan" element={<ScanPage />} />
<Route path="/tasks" element={<MyTasksPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
{/* 移动端 */}
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AppLayout />}>
<Route path="/scan" element={<ScanPage />} />
<Route path="/tasks" element={<MyTasksPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
<Route path="/admin/login" element={<AdminLoginPage />} />
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
<Route path="/admin/login" element={<AdminLoginPage />} />
{/* PC 管理端 — 需要登录 */}
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AdminLayout />}>
<Route path="/admin/dashboard" element={<AdminDashboard />} />
<Route path="/admin/products" element={<AdminProductsPage />} />
<Route path="/admin/tasks" element={<AdminTasksPage />} />
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
</Route>
</Routes>
{/* PC 管理端 — 需要登录 */}
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AdminLayout />}>
<Route path="/admin/dashboard" element={<AdminDashboard />} />
<Route path="/admin/products" element={<AdminProductsPage />} />
<Route path="/admin/tasks" element={<AdminTasksPage />} />
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
</ToastProvider>

View File

@ -0,0 +1,232 @@
/**
* 流转树双模式可视化 — 焦点模式 + 全景模式
*/
import { memo, useMemo, useState } from "react";
import { GitBranch, AlertTriangle, Clock, CheckCircle, Flag, FileText, X } from "lucide-react";
import type { TaskResponse } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
import type { ModalTarget } from "./TaskTreeViewer";
// ============================================================
// 工具
// ============================================================
function fmtTime(d: string | null) { if (!d) return ""; const dt = new Date(d); const pad = (n: number) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; }
function isMain(t: TaskResponse) { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY"; }
function active(s: string) { return s === "WIP" || s === "PENDING"; }
/** 微型右箭头 SVG */
function ArrowRight({ color = "#9ca3af" }: { color?: string }) {
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
}
/** 微型左箭头 SVG */
function ArrowLeft({ color = "#9ca3af" }: { color?: string }) {
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
}
function parseImages(s: string | null | undefined): string[] { if (!s) return []; try { return JSON.parse(s); } catch { return []; } }
function imageUrl(u: string) { if (!u) return ""; return u.startsWith("http") ? u : import.meta.env.VITE_API_BASE_URL + (u.startsWith("/") ? u : "/" + u); }
const ALL_TASKS = new Set<TaskResponse>();
function collectAll(tasks: TaskResponse[]) { tasks.forEach(t => { ALL_TASKS.add(t); if (t.child_tasks) collectAll(t.child_tasks); }); }
function findParent(child: TaskResponse): TaskResponse | undefined { for (const t of ALL_TASKS) { if (t.id === child.parent_task_id) return t; } return undefined; }
// ============================================================
// 极简卡片
// ============================================================
const SlimCard = memo(function SlimCard({
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId,
}: {
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
onViewRecords?: (t: TaskResponse) => void;
rootMainId?: string;
}) {
const cfg = getStatusConfig(task.status);
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
const main = isMain(task);
const isNestedSpawn = !main && rootMainId && task.parent_task_id !== rootMainId && !!task.parent_task_id;
return (
<div className={`relative w-44 shrink-0 rounded-lg border bg-white p-2.5 shadow-sm ${active ? "border-blue-400 ring-1 ring-blue-100" : "border-gray-200 opacity-75"} ${legacy ? "border-orange-300 animate-pulse" : ""} ${task.is_rework ? "border-l-red-500 border-l-2" : ""}`}>
<div className={`absolute -top-1.5 right-2 rounded px-1.5 py-px text-[8px] font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</div>
<p className="mt-1 text-xs font-bold text-gray-800 truncate">{task.task_name}</p>
<div className="mt-1 flex items-center gap-1">
<span className={`rounded-full px-1.5 py-px text-[8px] font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
<span className="text-[9px] text-gray-400 truncate">{assigneeName || task.assignee_id || "—"}</span>
</div>
{/* 单行时间 */}
<p className="mt-1 text-[8px] text-gray-300">
{fmtTime(task.created_at).split(" ")[0]}
{task.completed_at ? `${fmtTime(task.completed_at).split(" ")[0]}` : " → 至今"}
</p>
{legacy && <p className="mt-1 text-[8px] text-orange-500">: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
{isNestedSpawn && <p className="mt-1 text-[8px] text-purple-500">: {findParent(task)?.assignee_id || "—"}</p>}
{/* 操作按钮 */}
{active && isOwner && (
<div className="mt-1.5 flex gap-1 border-t border-gray-100 pt-1.5">
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-0.5 text-[8px] text-blue-600"></button>}
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-0.5 text-[8px] text-green-600"></button>}
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-0.5 text-[8px] text-red-500"></button>
</div>
)}
{active && !isOwner && isManager && (
<div className="mt-1.5 flex gap-1 border-t border-orange-100 pt-1.5">
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600"></button>
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600"></button>
</div>
)}
{/* 记录 */}
{task.records && task.records.length > 0 && (
<div onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }} className="mt-1 cursor-pointer rounded bg-blue-50 px-1.5 py-0.5 text-[8px] text-blue-600 hover:bg-blue-100">
<FileText className="mr-0.5 inline h-2.5 w-2.5" />{task.records.length}
</div>
)}
</div>
);
});
// ============================================================
// 主视图
// ============================================================
interface TaskFlowViewProps {
tasks: TaskResponse[]; onAction: (t: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeNames?: Record<string, string>;
}
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
const [showFullMap, setShowFullMap] = useState(false);
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
// 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染
const { allMains, childMap } = useMemo(() => {
ALL_TASKS.clear(); if (tasks.length) collectAll(tasks);
const all = Array.from(ALL_TASKS);
// 🚀 收集所有主线任务(全部进入中央垂直主轴)
const mains: TaskResponse[] = [];
for (const t of all) {
if (isMain(t)) mains.push(t);
}
mains.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
// 🚀 构建全局 childMap按 parent_task_id 索引直接子节点,保留真实树结构)
const childMap: Record<string, TaskResponse[]> = {};
for (const t of all) {
const pid = t.parent_task_id || '';
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(t);
}
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()));
return { allMains: mains, childMap };
}, [tasks]);
// 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): JSX.Element => {
const kids = childMap[node.id] || [];
const arrow = side === 'left'
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} /><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
: (<div className="flex items-center"><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} /></div>);
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} />;
const kidsContainer = kids.length > 0 ? (
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
</div>
) : null;
return (
<div key={node.id} className="flex flex-row items-center gap-1">
{side === 'left' && kidsContainer}
{side === 'left' && card}
{arrow}
{side === 'right' && card}
{side === 'right' && kidsContainer}
</div>
);
};
// 🚀 焦点模式 vs 全景模式:共用同一套垂直时间线布局,仅数据过滤不同
const visibleMains = useMemo(() => {
if (showFullMap) return allMains;
// 焦点模式:过滤掉已完成/已入库的历史主线,保留活跃主线 + 所有协助分支
return allMains.filter(t => active(t.status));
}, [allMains, showFullMap]);
return (
<div>
{/* 模式切换 */}
<div className="mb-3 flex justify-center">
<button onClick={() => setShowFullMap(!showFullMap)}
className="rounded-full bg-gray-100 px-4 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-200 transition-colors">
{showFullMap ? "🔼 收起,仅看当前并发任务" : "👁️ 展开全景流转树 (查看包含已完工在内的完整历史)"}
</button>
</div>
{/* ─── 统一垂直时间线布局(焦点/全景共用) ─── */}
<div className="space-y-6">
{visibleMains.map(mainTask => {
// 🔧 侧翼严格过滤:主线归主轴,仅 SPAWN 协助分支进入左右翼
const directChildren = (childMap[mainTask.id] || []).filter(c => !isMain(c));
const hasLegacyActive = directChildren.some(c => !isMain(c) && !active(mainTask.status));
const leftDirect = directChildren.filter((_, i) => i % 2 === 0);
const rightDirect = directChildren.filter((_, i) => i % 2 === 1);
const lineStyle = hasLegacyActive && !active(mainTask.status);
return (
<div key={mainTask.id} className="relative">
<div className="absolute left-1/2 top-0 bottom-0 w-0.5 bg-gray-200 -translate-x-1/2 z-0" />
<div className="flex flex-row items-start w-full">
{/* 左翼 — 递归渲染,子子孙孙向外延伸 */}
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
{leftDirect.map(c => renderBranch(c, 'left', lineStyle, mainTask.id))}
</div>
{/* 中央 */}
<div className="shrink-0 z-10 relative">
<SlimCard task={mainTask} active={active(mainTask.status)}
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
{active(mainTask.status) && (
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
)}
</div>
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
{rightDirect.map(c => renderBranch(c, 'right', lineStyle, mainTask.id))}
</div>
</div>
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
<div className="flex justify-center py-2">
<span className="text-[10px] text-gray-300"></span>
</div>
)}
</div>
);
})}
{visibleMains.length === 0 && (
<p className="text-center text-xs text-gray-400 py-8">
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
</p>
)}
</div>
{/* 记录弹窗 */}
{recordsTask && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setRecordsTask(null)} />
<div className="relative z-10 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-xl bg-white p-5 shadow-2xl">
<div className="mb-3 flex items-center justify-between"><h3 className="text-sm font-bold"> {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
{(recordsTask.records || []).length === 0 ? <p className="py-8 text-center text-sm text-gray-400"></p> :
<div className="space-y-2">{[...recordsTask.records!].reverse().map((r, i) => (
<div key={r.id} className="flex gap-2">
<div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${i === 0 ? "bg-blue-500" : "bg-gray-300"}`} />
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className="text-[10px] text-gray-400">{fmtTime(r.created_at)}</p>
{(r.note || r.remark) && <p className="mt-0.5 text-xs text-gray-700">{r.note || r.remark}</p>}
{(() => { const imgs = parseImages((r as any).images); if (!imgs.length) return null; return <div className="mt-1 flex gap-1">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} className="h-12 w-12 rounded border object-cover cursor-pointer" onClick={() => window.open(imageUrl(img))} />)}</div>; })()}
</div>
</div>
))}</div>}
</div>
</div>
)}
</div>
);
});
export default TaskFlowView;

View File

@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo, memo } from "react";
import {
Search,
Loader2,
@ -10,6 +10,7 @@ import {
Warehouse,
UserPlus,
} from "lucide-react";
import TaskFlowView from "./TaskFlowView";
import {
getTaskTree,
receiveTask,
@ -17,65 +18,14 @@ import {
transferTask,
} from "../../services/taskApi";
import { useToast } from "../ui/Toast";
import type { ProductScanResponse, TaskResponse, TaskStatus } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
// ============================================================
// 状态 → 颜色/标签映射
// ============================================================
const STATUS_CONFIG: Record<
string,
{ bg: string; text: string; ring: string; label: string }
> = {
[TASK_STATUS.PENDING]: {
bg: "bg-yellow-50",
text: "text-yellow-700",
ring: "ring-yellow-400",
label: "待接收",
},
[TASK_STATUS.WIP]: {
bg: "bg-blue-50",
text: "text-blue-700",
ring: "ring-blue-400",
label: "进行中",
},
[TASK_STATUS.COMPLETED]: {
bg: "bg-green-50",
text: "text-green-700",
ring: "ring-green-400",
label: "已完成",
},
[TASK_STATUS.REJECTED]: {
bg: "bg-red-50",
text: "text-red-700",
ring: "ring-red-400",
label: "已驳回",
},
[TASK_STATUS.ARCHIVED]: {
bg: "bg-gray-50",
text: "text-gray-600",
ring: "ring-gray-300",
label: "已入库",
},
};
function getStatusConfig(status: string) {
return (
STATUS_CONFIG[status] ?? {
bg: "bg-gray-50",
text: "text-gray-600",
ring: "ring-gray-300",
label: status,
}
);
}
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
// ============================================================
// 通用 Modal 容器
// ============================================================
function Modal({
const Modal = memo(function Modal({
open,
onClose,
title,
@ -110,13 +60,13 @@ function Modal({
</div>
</div>
);
}
});
// ============================================================
// 确认接收弹窗
// ============================================================
function ReceiveConfirmModal({
export const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
open,
task,
submitting,
@ -163,13 +113,13 @@ function ReceiveConfirmModal({
</div>
</Modal>
);
}
});
// ============================================================
// 品质驳回弹窗
// ============================================================
function RejectModal({
export const RejectModal = memo(function RejectModal({
open,
task,
submitting,
@ -244,13 +194,13 @@ function RejectModal({
</form>
</Modal>
);
}
});
// ============================================================
// 完工裂变转交弹窗
// ============================================================
function TransferModal({
export const TransferModal = memo(function TransferModal({
open,
task,
submitting,
@ -496,165 +446,17 @@ function TransferModal({
</form>
</Modal>
);
}
});
// ============================================================
// 单个任务节点卡片
// 操作弹窗目标类型
// ============================================================
interface ModalTarget {
export interface ModalTarget {
task: TaskResponse;
action: "receive" | "reject" | "transfer";
}
function TaskNodeCard({
task,
isLast,
onAction,
}: {
task: TaskResponse;
isLast: boolean;
onAction: (target: ModalTarget) => void;
}) {
const { bg, text, ring, label } = getStatusConfig(task.status);
return (
<div className="relative">
{/* 树形连接线 */}
{task.child_tasks.length > 0 && (
<>
<div
className="absolute left-4 top-full z-0 w-px bg-gray-200"
style={{ height: "calc(100% - 2rem)" }}
/>
{task.child_tasks.length > 1 && (
<div
className="absolute left-4 z-0 h-px bg-gray-200"
style={{
top: "calc(100% + 1rem)",
width: "calc(50% - 1rem)",
}}
/>
)}
</>
)}
{/* 卡片本体 */}
<div
className={`relative z-10 mb-1 rounded-lg border bg-white px-3 py-2.5 shadow-sm transition-shadow hover:shadow-md ${ring} ${
task.is_rework ? "ring-2 ring-red-500" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
{/* 左侧:任务名 + 标签 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{task.is_rework && (
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-red-600 px-1.5 py-0.5 text-[10px] font-bold text-white animate-pulse">
</span>
)}
{task.child_tasks.length > 1 && (
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
<GitBranch className="h-2.5 w-2.5" />
×{task.child_tasks.length}
</span>
)}
<span className="truncate text-sm font-semibold text-gray-800">
{task.task_name}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-400">
{task.assignee_id && <span>: {task.assignee_id}</span>}
{task.received_at && (
<span>
: {new Date(task.received_at).toLocaleDateString("zh-CN")}
</span>
)}
{task.completed_at && (
<span>
:{" "}
{new Date(task.completed_at).toLocaleDateString("zh-CN")}
</span>
)}
</div>
{task.reject_reason && (
<p className="mt-1 text-[11px] text-red-500">
: {task.reject_reason}
</p>
)}
</div>
{/* 右侧:状态标签 + 操作按钮 */}
<div className="flex shrink-0 flex-col items-end gap-1">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${bg} ${text}`}
>
{label}
</span>
{/* 操作按钮 */}
{task.status === TASK_STATUS.PENDING && (
<div className="flex gap-1">
<button
className="rounded border border-blue-200 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 transition-colors"
onClick={() => onAction({ task, action: "receive" })}
>
</button>
<button
className="rounded border border-red-200 px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-50 transition-colors"
onClick={() => onAction({ task, action: "reject" })}
>
</button>
<button
className="rounded border border-green-200 px-1.5 py-0.5 text-[10px] font-medium text-green-600 hover:bg-green-50 transition-colors"
onClick={() => onAction({ task, action: "transfer" })}
>
</button>
</div>
)}
{task.status === TASK_STATUS.WIP && (
<div className="flex gap-1">
<button
className="rounded border border-red-200 px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-50 transition-colors"
onClick={() => onAction({ task, action: "reject" })}
>
</button>
<button
className="rounded border border-green-200 px-1.5 py-0.5 text-[10px] font-medium text-green-600 hover:bg-green-50 transition-colors"
onClick={() => onAction({ task, action: "transfer" })}
>
</button>
</div>
)}
</div>
</div>
</div>
{/* 递归渲染子任务 */}
{task.child_tasks.length > 0 && (
<div className="ml-8 border-l-2 border-gray-100 pl-4 pt-1">
{task.child_tasks.map((child, idx) => (
<TaskNodeCard
key={child.id}
task={child}
isLast={idx === task.child_tasks.length - 1}
onAction={onAction}
/>
))}
</div>
)}
</div>
);
}
// ============================================================
// 主容器组件
// ============================================================
@ -688,7 +490,7 @@ export default function TaskTreeViewer() {
e?.preventDefault();
const trimmed = serial.trim();
if (trimmed.length !== 16) {
setError("请输入 16 位产品序列号");
setError("请输入 16 位产品身份证");
return;
}
@ -702,7 +504,7 @@ export default function TaskTreeViewer() {
} catch (err: any) {
const msg =
err?.response?.status === 404
? `未找到序列号 ${trimmed} 对应的产品`
? `未找到产品身份证 ${trimmed} 对应的产品`
: err?.response?.data?.detail ??
err?.message ??
"查询失败,请检查后端服务";
@ -779,6 +581,12 @@ export default function TaskTreeViewer() {
// ---- 渲染 ----
// 🚀 缓存递归计算 — 仅在 task_tree 变化时重新计算
const totalTaskCount = useMemo(
() => (product?.task_tree ? countAllTasks(product.task_tree) : 0),
[product?.task_tree],
);
const modalTask = modalTarget?.task ?? null;
return (
@ -787,7 +595,7 @@ export default function TaskTreeViewer() {
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500">
16
16
</p>
<form onSubmit={handleSearch} className="mt-4 flex items-center gap-2">
@ -797,7 +605,7 @@ export default function TaskTreeViewer() {
type="text"
value={serial}
onChange={(e) => setSerial(e.target.value)}
placeholder="输入 16 位序列号,如 X20260801000001"
placeholder="输入 16 位产品身份证,如 X20260801000001"
maxLength={16}
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 font-mono text-sm tracking-widest placeholder:tracking-normal placeholder:font-sans focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
@ -849,7 +657,7 @@ export default function TaskTreeViewer() {
<div className="mb-6">
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-white p-4 shadow-sm">
<div>
<span className="text-xs text-gray-400"></span>
<span className="text-xs text-gray-400"></span>
<p className="font-mono text-base font-bold tracking-widest text-gray-800">
{product.serial_number}
</p>
@ -880,7 +688,7 @@ export default function TaskTreeViewer() {
<p className="text-sm font-semibold text-gray-800">
{product.top_level_tasks?.length ?? 0}
{product.task_tree?.length
? ` · ${countAllTasks(product.task_tree)} 总计`
? ` · ${totalTaskCount} 总计`
: ""}
</p>
</div>
@ -888,25 +696,15 @@ export default function TaskTreeViewer() {
</div>
)}
{/* ---- 任务 ---- */}
{/* ---- 任务流转分支/卡片视图 ---- */}
{product && !loading && (
<>
{product.task_tree && product.task_tree.length > 0 ? (
<div className="rounded-xl bg-white p-6 shadow-sm">
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-gray-500">
<GitBranch className="h-4 w-4" />
</h3>
<div className="space-y-1">
{product.task_tree.map((task, idx) => (
<TaskNodeCard
key={task.id}
task={task}
isLast={idx === product.task_tree!.length - 1}
onAction={setModalTarget}
/>
))}
</div>
<div className="rounded-xl bg-white p-6 shadow-sm overflow-hidden">
<TaskFlowView
tasks={product.task_tree}
onAction={setModalTarget}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 text-gray-400 shadow-sm">

View File

@ -19,7 +19,7 @@ const MENU = [
title: "任务全景",
path: "/admin/tasks",
icon: GitBranch,
description: "序列号查任务树 · 裂变/返工可视化",
description: "身份证查任务树 · 裂变/返工可视化",
},
];
@ -113,11 +113,6 @@ export default function AdminLayout() {
<span className="font-medium text-gray-700">
{user?.display_name ?? user?.username ?? "—"}
</span>
{user?.role && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">
{user.role}
</span>
)}
</div>
<button
onClick={handleLogout}

View File

@ -1,7 +1,10 @@
import { NavLink, Outlet } from "react-router-dom";
import { ScanLine, ClipboardList, Bell, User } from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { ScanLine, ClipboardList, Bell, User, ArrowLeft } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
import { getNotifications } from "../../services/notificationApi";
/** 底部导航 Tab 配置 */
/** 底部导航 Tab 配置 — 与 uni-app pages.json tabBar.list 完全一致 */
const TABS = [
{ path: "/scan", label: "扫码干活", icon: ScanLine },
{ path: "/tasks", label: "我的任务", icon: ClipboardList },
@ -10,38 +13,92 @@ const TABS = [
] as const;
/** 导航栏高度(供页面计算偏移量) */
export const TAB_BAR_HEIGHT = 64; // pxh-16
export const TAB_BAR_HEIGHT = 64;
const TOP_BAR_HEIGHT = 48;
export default function AppLayout() {
const { user } = useAuth();
const navigate = useNavigate();
const [unreadCount, setUnreadCount] = useState(0);
// 🔔 未读消息数轮询
useEffect(() => {
const userId = user?.username || user?.id || "";
if (!userId) return;
function poll() {
getNotifications(userId, 0, 1)
.then((res) => setUnreadCount(res.unread_count || 0))
.catch(() => {}); // 静默
}
poll(); // 立即请求一次
const id = setInterval(poll, 30_000); // 每 30 秒轮询
return () => clearInterval(id);
}, [user]);
return (
<div className="flex h-dvh flex-col bg-gray-50">
{/* ======== 顶部导航栏 — 返回管理端 ======== */}
<header
className="fixed top-0 left-0 right-0 z-50 flex items-center border-b border-gray-200 bg-white px-4 shadow-sm"
style={{ height: TOP_BAR_HEIGHT }}
>
<button
onClick={() => navigate("/admin/dashboard")}
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 active:bg-blue-100"
>
<ArrowLeft size={16} strokeWidth={2.5} />
</button>
<span className="ml-auto text-xs text-gray-400"> · </span>
</header>
{/* ======== 主内容区 ======== */}
<main className="flex-1 overflow-y-auto">
<main
className="flex-1 overflow-y-auto"
style={{
paddingTop: TOP_BAR_HEIGHT,
paddingBottom: TAB_BAR_HEIGHT,
}}
>
<Outlet />
</main>
{/* ======== 底部导航栏 ======== */}
{/* ======== 底部导航栏 — 与 uni-app tabBar 完全一致 ======== */}
<nav
className="fixed bottom-0 z-50 w-full border-t border-gray-200 bg-white pb-safe"
className="fixed bottom-0 left-0 right-0 z-50 border-t border-gray-200 bg-white pb-safe"
style={{ height: TAB_BAR_HEIGHT }}
>
<div className="mx-auto flex h-full max-w-lg items-center justify-around">
{TABS.map(({ path, label, icon: Icon }) => (
<NavLink
key={path}
to={path}
className={({ isActive }) =>
`flex flex-col items-center gap-0.5 px-3 py-1 transition-colors ${
isActive
? "text-blue-600"
: "text-gray-400 hover:text-gray-600"
}`
}
>
<Icon size={22} strokeWidth={2} />
<span className="text-[10px] font-medium leading-none">{label}</span>
</NavLink>
))}
{TABS.map(({ path, label, icon: Icon }) => {
const isNotifyTab = path === "/notifications";
return (
<NavLink
key={path}
to={path}
className={({ isActive }) =>
`relative flex flex-col items-center gap-0.5 px-3 py-1 transition-colors ${
isActive
? "text-blue-600"
: "text-gray-400 hover:text-gray-600"
}`
}
>
<span className="relative">
<Icon size={22} strokeWidth={2} />
{/* 🔴 消息 Tab 未读红点 */}
{isNotifyTab && unreadCount > 0 && (
<span className="absolute -top-1 -right-2 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] font-bold text-white shadow-[0_0_0_2px_white]">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</span>
<span className="text-[10px] font-medium leading-none">{label}</span>
</NavLink>
);
})}
</div>
</nav>
</div>

View File

@ -1,4 +1,4 @@
/** 手动输入序列号区域 */
/** 手动输入产品身份证区域 */
import { Search, Loader2 } from "lucide-react";
interface ManualInputProps {
@ -29,7 +29,7 @@ export default function ManualInput({
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="手动输入16位序列号"
placeholder="手动输入16位产品身份证"
maxLength={16}
className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none"
/>

View File

@ -1,25 +1,7 @@
/** 产品信息卡片 */
import { Package } from "lucide-react";
import type { ProductScanResponse } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
const STATUS_LABELS: Record<string, string> = {
[TASK_STATUS.PENDING]: "待接收",
[TASK_STATUS.WIP]: "进行中",
[TASK_STATUS.COMPLETED]: "已完成",
[TASK_STATUS.REJECTED]: "已驳回",
[TASK_STATUS.ARCHIVED]: "已入库",
};
function statusColor(status: string): string {
switch (status) {
case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
default: return "bg-gray-100 text-gray-600";
}
}
import { statusColor, statusLabel } from "../../constants/task";
interface ProductCardProps {
product: ProductScanResponse;
@ -32,12 +14,12 @@ export default function ProductCard({ product }: ProductCardProps) {
<Package className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-800"></h3>
<span className={`ml-auto rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColor(product.status)}`}>
{STATUS_LABELS[product.status] ?? product.status}
{statusLabel(product.status)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-gray-400"></span>
<span className="text-gray-400"></span>
<p className="font-mono font-medium text-gray-800">{product.serial_number}</p>
</div>
<div>

View File

@ -1,25 +1,8 @@
/** 任务进度列表卡片 — 递归渲染 task_tree */
import { useMemo, memo } from "react";
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
import type { TaskResponse, TaskSummary } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
const STATUS_LABELS: Record<string, string> = {
[TASK_STATUS.PENDING]: "待接收",
[TASK_STATUS.WIP]: "进行中",
[TASK_STATUS.COMPLETED]: "已完成",
[TASK_STATUS.REJECTED]: "已驳回",
[TASK_STATUS.ARCHIVED]: "已入库",
};
function statusColor(status: string): string {
switch (status) {
case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
default: return "bg-gray-100 text-gray-600";
}
}
import { statusColor, statusLabel } from "../../constants/task";
interface TaskListCardProps {
tasks: TaskResponse[];
@ -30,7 +13,7 @@ interface TaskNodeProps {
depth: number;
}
function TaskNode({ task, depth }: TaskNodeProps) {
const TaskNode = memo(function TaskNode({ task, depth }: TaskNodeProps) {
return (
<>
<div className="flex items-center gap-3 px-4 py-3 border-t border-gray-50" style={{ paddingLeft: 16 + depth * 16 }}>
@ -54,7 +37,7 @@ function TaskNode({ task, depth }: TaskNodeProps) {
</p>
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusColor(task.status)}`}>
{STATUS_LABELS[task.status] ?? task.status}
{statusLabel(task.status)}
</span>
</div>
{task.child_tasks?.map((child) => (
@ -62,19 +45,22 @@ function TaskNode({ task, depth }: TaskNodeProps) {
))}
</>
);
}
});
function countAll(tasks: TaskResponse[]): number {
return tasks.reduce((s, t) => s + 1 + (t.child_tasks ? countAll(t.child_tasks) : 0), 0);
}
export default function TaskListCard({ tasks }: TaskListCardProps) {
// 🚀 缓存递归计算
const totalCount = useMemo(() => countAll(tasks), [tasks]);
return (
<div className="rounded-xl bg-white shadow-sm">
<div className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
<ClipboardList className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-800"></h3>
<span className="ml-auto text-xs text-gray-400">{countAll(tasks)} </span>
<span className="ml-auto text-xs text-gray-400">{totalCount} </span>
</div>
{tasks.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400"></div>

View File

@ -0,0 +1,10 @@
import { Loader2 } from "lucide-react";
/** 全局路由懒加载回退组件 */
export default function LoadingSpinner() {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
);
}

View File

@ -3,6 +3,7 @@ import {
useContext,
useState,
useCallback,
useMemo,
type ReactNode,
} from "react";
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
@ -52,8 +53,11 @@ export function ToastProvider({ children }: { children: ReactNode }) {
}, 3500);
}, []);
// 🚀 稳定 Context value 引用
const ctxValue = useMemo<ToastContextValue>(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={{ toast }}>
<ToastContext.Provider value={ctxValue}>
{children}
{/* Toast 渲染区 */}

View File

@ -0,0 +1,71 @@
/**
* 任务状态 → 颜色/标签映射(全局唯一数据源)
*
* 供 TaskTreeViewer / TaskListCard / ProductCard 等组件共用,
* 避免在多处重复维护相同的映射逻辑。
*/
import { TASK_STATUS } from "../types/api";
export interface StatusStyle {
bg: string;
text: string;
ring: string;
label: string;
}
export const STATUS_CONFIG: Record<string, StatusStyle> = {
[TASK_STATUS.PENDING]: {
bg: "bg-yellow-50",
text: "text-yellow-700",
ring: "ring-yellow-400",
label: "待接收",
},
[TASK_STATUS.WIP]: {
bg: "bg-blue-50",
text: "text-blue-700",
ring: "ring-blue-400",
label: "进行中",
},
[TASK_STATUS.COMPLETED]: {
bg: "bg-green-50",
text: "text-green-700",
ring: "ring-green-400",
label: "已完成",
},
[TASK_STATUS.REJECTED]: {
bg: "bg-red-50",
text: "text-red-700",
ring: "ring-red-400",
label: "待接收",
},
[TASK_STATUS.ARCHIVED]: {
bg: "bg-gray-50",
text: "text-gray-600",
ring: "ring-gray-300",
label: "已完成",
},
};
const FALLBACK: StatusStyle = {
bg: "bg-gray-50",
text: "text-gray-600",
ring: "ring-gray-300",
label: "",
};
/** 获取状态的完整样式配置bg / text / ring / label— 大小写不敏感 */
export function getStatusConfig(status: string): StatusStyle {
const key = status?.toUpperCase?.() ?? status;
return STATUS_CONFIG[key] ?? { ...FALLBACK, label: status };
}
/** 仅获取 bg + text 类名,用于简单的状态标签着色 */
export function statusColor(status: string): string {
const cfg = getStatusConfig(status);
return `${cfg.bg} ${cfg.text}`;
}
/** 仅获取中文标签 */
export function statusLabel(status: string): string {
return getStatusConfig(status).label;
}

View File

@ -3,6 +3,7 @@ import {
useContext,
useState,
useCallback,
useMemo,
useEffect,
type ReactNode,
} from "react";
@ -22,7 +23,7 @@ export interface UserInfo {
interface AuthState {
user: UserInfo | null;
token: string | null;
loading: boolean; // 初始化时检查 token
loading: boolean;
}
interface AuthContextValue extends AuthState {
@ -35,7 +36,8 @@ interface AuthContextValue extends AuthState {
// Token 存储 key
// ============================================================
const TOKEN_KEY = "track_admin_token";
const ACCESS_TOKEN_KEY = "track_admin_token";
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
const USER_KEY = "track_admin_user";
// ============================================================
@ -61,23 +63,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
loading: true,
});
// 初始化:从 localStorage 恢复 token
// 初始化:从 localStorage 恢复双 Token
useEffect(() => {
const savedToken = localStorage.getItem(TOKEN_KEY);
const savedToken = localStorage.getItem(ACCESS_TOKEN_KEY);
const savedUser = localStorage.getItem(USER_KEY);
if (savedToken && savedUser) {
try {
const user = JSON.parse(savedUser) as UserInfo;
setState({ user, token: savedToken, loading: false });
// 可选:后端验证 token 是否仍有效
// 后台静默验证 token 是否仍有效
getMe(savedToken)
.then((fresh) => {
setState((prev) => ({ ...prev, user: fresh }));
localStorage.setItem(USER_KEY, JSON.stringify(fresh));
})
.catch(() => {
// token 过期,清除
logoutInternal();
.catch((err) => {
// 验证失败不踢出用户 — 真正的过期由业务 API 401 拦截器
// 通过 Refresh Token 无感刷新,彻底失败才跳转登录
console.error(
"[Auth] Token 后台验证失败(保留本地登录态,依赖拦截器刷新):",
err?.message ?? err,
);
});
} catch {
logoutInternal();
@ -88,34 +94,41 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
function logoutInternal() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setState({ user: null, token: null, loading: false });
}
const login = useCallback(async (username: string, password: string) => {
const result = await loginApi(username, password);
const token = result.access_token;
const accessToken = result.access_token;
const refreshToken = result.refresh_token;
const user: UserInfo = result.user;
localStorage.setItem(TOKEN_KEY, token);
// 存储双 Token
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify(user));
setState({ user, token, loading: false });
setState({ user, token: accessToken, loading: false });
}, []);
const logout = useCallback(() => {
logoutInternal();
}, []);
const ctxValue = useMemo<AuthContextValue>(
() => ({
...state,
login,
logout,
isAuthenticated: !!state.token && !!state.user,
}),
[state, login, logout],
);
return (
<AuthContext.Provider
value={{
...state,
login,
logout,
isAuthenticated: !!state.token && !!state.user,
}}
>
<AuthContext.Provider value={ctxValue}>
{children}
</AuthContext.Provider>
);

View File

@ -1,29 +1,200 @@
/** 待办 — 我的任务列表 */
/** 我的任务 — 1:1 复刻 uni-app pages/tasks/index.vue */
import { useEffect, useState, useMemo } from "react";
import { ClipboardList } from "lucide-react";
import { useNavigate } from "react-router-dom";
import api from "../services/api";
import { useAuth } from "../contexts/AuthContext";
import type { TaskSummary } from "../types/api";
// ============================================================
// 常量 — 与 uni-app 完全一致
// ============================================================
const TABS = [
{ key: "all", label: "全部" },
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
const STATUS_LABEL: Record<string, string> = {
PENDING: "待接收",
WIP: "进行中",
COMPLETED: "已完成",
REJECTED: "已驳回",
ARCHIVED: "已入库",
CANCELED: "已撤回",
};
/** 状态 → Tailwind 颜色类名 */
function statusColor(s: string) {
switch (s) {
case "PENDING":
return "bg-yellow-50 text-yellow-700";
case "WIP":
return "bg-blue-50 text-blue-700";
case "COMPLETED":
return "bg-green-50 text-green-700";
default:
return "bg-gray-100 text-gray-600";
}
}
function formatTime(t: string) {
if (!t) return "";
const d = new Date(t);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ============================================================
// Page 组件
// ============================================================
export default function MyTasksPage() {
const { user } = useAuth();
const navigate = useNavigate();
const [tab, setTab] = useState<TabKey>("PENDING");
const [tasks, setTasks] = useState<TaskSummary[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchTasks();
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
async function fetchTasks() {
setLoading(true);
try {
const username = user?.username || "";
const { data } = await api.get<{ tasks: TaskSummary[]; total: number }>(
"/tasks/",
{ params: { assignee_id: username, limit: 100 } }
);
// 排序:主干任务在前 + 创建时间升序(与 uni-app 完全一致)
const sorted = (data.tasks || []).sort((a, b) => {
const aIsMain = !a.parent_task_id || a.task_type !== "SPAWN";
const bIsMain = !b.parent_task_id || b.task_type !== "SPAWN";
if (aIsMain && !bIsMain) return -1;
if (!aIsMain && bIsMain) return 1;
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
setTasks(sorted);
} catch {
setTasks([]);
} finally {
setLoading(false);
}
}
// ---- 按 Tab 筛选 ----
const filtered = useMemo(() => {
if (tab === "all") return tasks;
return tasks.filter((t) => t.status === tab);
}, [tasks, tab]);
// ---- 各 Tab 计数 ----
function countBy(key: TabKey) {
if (key === "all") return tasks.length;
return tasks.filter((t) => t.status === key).length;
}
function goDetail(task: TaskSummary) {
const sn = task.product_sn || task.product_id;
if (sn) navigate(`/scan?serial=${sn}`);
}
// ==========================================================
// 渲染
// ==========================================================
return (
<div className="flex min-h-full flex-col px-4 pt-safe">
{/* 标题 */}
<h2 className="text-xl font-bold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500"></p>
{/* 占位空状态 */}
<div className="mt-12 flex flex-1 flex-col items-center justify-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
<svg
className="h-10 w-10 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
{/* Tab 栏 — 与 uni-app 样式一致sticky、可横向滚动 */}
<div className="sticky top-0 z-10 -mx-4 overflow-x-auto bg-gray-50 px-4 py-3">
<div className="flex gap-2 whitespace-nowrap">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-full px-4 py-2 text-[13px] font-semibold transition-colors ${
tab === t.key
? "bg-blue-600 text-white"
: "bg-white text-gray-500 hover:bg-gray-100"
}`}
>
{t.label} ({countBy(t.key)})
</button>
))}
</div>
<p className="mt-4 text-sm text-gray-400"></p>
</div>
{/* 加载中 */}
{loading && (
<div className="flex flex-1 items-center justify-center py-20 text-gray-400">
...
</div>
)}
{/* 空状态 */}
{!loading && filtered.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center py-20">
<span className="text-6xl">📋</span>
<span className="mt-3 text-sm text-gray-400">
{tab === "all" ? "暂无待办任务" : "无此状态任务"}
</span>
</div>
)}
{/* 任务卡片列表 */}
{!loading &&
filtered.map((task) => (
<div
key={task.id}
onClick={() => goDetail(task)}
className="mb-2.5 cursor-pointer rounded-xl bg-white p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.06)] transition-shadow hover:shadow-md"
>
{/* Row 1: 类型标签 + 任务名 | 状态 */}
<div className="mb-1.5 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{/* 主干/协助 标签 — 与 uni-app tag-badge 一致 */}
<span
className={`inline-block shrink-0 rounded px-1.5 py-px text-[11px] font-bold ${
task.task_type === "SPAWN"
? "bg-purple-100 text-purple-700"
: "bg-blue-100 text-blue-800"
}`}
>
{task.task_type === "SPAWN" ? "协助" : "主干"}
</span>
<span className="truncate text-[15px] font-bold text-gray-800">
{task.task_name}
</span>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${statusColor(task.status)}`}
>
{STATUS_LABEL[task.status] || task.status}
</span>
</div>
{/* Row 2: 身份证 + 物料 */}
<div className="flex gap-3 text-xs text-gray-500">
<span>: {task.product_sn || "—"}</span>
<span>: {task.product_material || "—"}</span>
</div>
{/* Row 3: 创建时间 */}
<div className="mt-1 text-[11px] text-gray-400">
: {formatTime(task.created_at)}
</div>
</div>
))}
</div>
);
}

View File

@ -1,29 +1,164 @@
/** 消息 — 通知推送 */
export default function NotificationsPage() {
return (
<div className="flex min-h-full flex-col px-4 pt-safe">
<h2 className="text-xl font-bold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500"></p>
/** 消息通知1:1 复刻 uni-app pages/notify/index.vue + 真实 API */
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import {
getNotifications,
markNotificationRead,
type NotificationItem,
} from "../services/notificationApi";
{/* 占位空状态 */}
<div className="mt-12 flex flex-1 flex-col items-center justify-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
<svg
className="h-10 w-10 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</div>
<p className="mt-4 text-sm text-gray-400"></p>
// ============================================================
// 类型常量
// ============================================================
const TYPE_CONFIG: Record<string, { icon: string; title: string }> = {
TRANSFER: { icon: "🟢", title: "新任务派发" },
REJECT: { icon: "🔴", title: "品质驳回提醒" },
};
function formatTime(t: string) {
if (!t) return "";
const d = new Date(t);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ============================================================
// Page 组件
// ============================================================
export default function NotificationsPage() {
const { user } = useAuth();
const navigate = useNavigate();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNotifications();
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
async function fetchNotifications() {
const userId = user?.username || user?.id || "";
if (!userId) {
setLoading(false);
return;
}
setLoading(true);
try {
const res = await getNotifications(userId);
setNotifications(res.notifications || []);
} catch {
setNotifications([]);
} finally {
setLoading(false);
}
}
async function handleCardTap(item: NotificationItem) {
// 标记已读
if (!item.is_read) {
try {
await markNotificationRead(item.id);
setNotifications((prev) =>
prev.map((n) => (n.id === item.id ? { ...n, is_read: true } : n))
);
} catch {
// 静默
}
}
// 跳转详情页
if (item.task_id) {
navigate(`/scan?taskId=${item.task_id}`);
}
}
// ==========================================================
// 渲染
// ==========================================================
return (
<div className="flex min-h-full flex-col px-4 pt-safe pb-20">
{/* 标题区 — 与 uni-app 完全一致 */}
<div className="mb-5">
<h2 className="text-xl font-bold text-gray-800"></h2>
<p className="mt-1 text-[13px] text-gray-400"></p>
</div>
{/* 加载中 */}
{loading && (
<div className="flex flex-1 items-center justify-center py-20 text-sm text-gray-400">
...
</div>
)}
{/* 空状态 */}
{!loading && notifications.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center pb-24">
<span className="text-[64px] leading-none">🔔</span>
<span className="mt-3 text-sm text-gray-400"></span>
</div>
)}
{/* 通知列表 */}
{!loading && notifications.length > 0 && (
<div className="flex flex-col gap-2.5">
{notifications.map((item) => {
const cfg = TYPE_CONFIG[item.type] || {
icon: "📌",
title: "系统通知",
};
return (
<div
key={item.id}
onClick={() => handleCardTap(item)}
className={`flex cursor-pointer items-start gap-2.5 rounded-xl bg-white p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.06)] transition-all active:scale-[0.98] ${
!item.is_read
? "border-l-[3px] border-l-blue-600 shadow-[0_1px_6px_rgba(37,99,235,0.1)]"
: ""
}`}
>
{/* 左侧:未读红点 + 类型图标 */}
<div className="flex w-7 shrink-0 flex-col items-center gap-1">
{!item.is_read && (
<span className="h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]" />
)}
<span
className={`text-xl leading-none ${
item.is_read ? "opacity-50" : ""
}`}
>
{cfg.icon}
</span>
</div>
{/* 右侧:内容 */}
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span
className={`text-[15px] font-semibold ${
item.is_read ? "text-gray-600" : "font-bold text-gray-800"
}`}
>
{cfg.title}
</span>
<span className="shrink-0 text-[11px] text-gray-400">
{formatTime(item.created_at)}
</span>
</div>
<p className="text-[13px] leading-relaxed text-gray-500 break-all">
{item.content}
</p>
</div>
<span className="mt-1.5 shrink-0 text-xl text-gray-300"></span>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -1,35 +1,105 @@
/** 我的 — 个人中心 */
/** 我的 — 与 uni-app pages/profile/index.vue 完全同步 */
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { useToast } from "../components/ui/Toast";
import { LogOut } from "lucide-react";
export default function ProfilePage() {
const { user, logout } = useAuth();
const { toast } = useToast();
const navigate = useNavigate();
const [showAbout, setShowAbout] = useState(false);
// ---- 从 AuthContext 派生真实数据 ----
const displayName = user?.display_name || user?.username || "未知用户";
const avatarChar = displayName.charAt(0);
function handleLogout() {
logout();
navigate("/admin/login", { replace: true });
}
function handleMenuClick(item: string) {
switch (item) {
case "关于":
setShowAbout(true);
break;
case "帮助与反馈":
toast("反馈通道搭建中,敬请期待...", "info");
break;
// 工作统计、设置 暂不处理
}
}
return (
<div className="flex min-h-full flex-col px-4 pt-safe">
{/* 用户信息卡片 */}
<div className="mt-4 flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
<div className="flex min-h-full flex-col px-4 pt-safe pb-20">
{/* 用户卡片 */}
<div className="mt-4 flex items-center gap-3 rounded-xl bg-white p-4 shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
{avatarChar}
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-800"></h3>
<p className="text-sm text-gray-500"></p>
<span className="block text-base font-bold text-gray-800">
{displayName}
</span>
</div>
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-xl text-gray-300"></span>
</div>
{/* 菜单列表占位 */}
<div className="mt-6 space-y-1 rounded-xl bg-white shadow-sm">
{["工作统计", "设置", "帮助与反馈", "关于"].map((item) => (
{/* 菜单列表 */}
<div className="mt-4 overflow-hidden rounded-xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
{["工作统计", "设置", "帮助与反馈", "关于"].map((item, i) => (
<div
key={item}
className="flex items-center justify-between border-b border-gray-50 px-4 py-3 last:border-b-0"
onClick={() => handleMenuClick(item)}
className={`flex cursor-pointer items-center justify-between px-4 py-3.5 transition-colors hover:bg-gray-50 ${
i < 3 ? "border-b border-gray-50" : ""
}`}
>
<span className="text-sm text-gray-700">{item}</span>
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-xl text-gray-300"></span>
</div>
))}
{/* 退出登录 */}
<button
onClick={handleLogout}
className="flex w-full items-center justify-between border-t border-gray-50 px-4 py-3.5 transition-colors hover:bg-red-50"
>
<span className="flex items-center gap-2 text-sm text-red-500">
<LogOut size={16} strokeWidth={2} />
退
</span>
<span className="text-xl text-red-300"></span>
</button>
</div>
<p className="mt-8 text-center text-xs text-gray-300"> T1.0.3</p>
{/* 关于弹窗 */}
{showAbout && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowAbout(false)}
/>
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
<h3 className="mb-4 text-center text-lg font-bold text-gray-800">
Track
</h3>
<div className="mb-6 whitespace-pre-line text-center text-sm leading-relaxed text-gray-600">
Track {"\n"}T1.0.3{"\n"}FastAPI + React + Tailwind
</div>
<button
onClick={() => setShowAbout(false)}
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -1,12 +1,14 @@
/** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */
import { useState, useCallback } from "react";
import { QrCode } from "lucide-react";
import { useState, useCallback, Suspense, lazy } from "react";
import { QrCode, Loader2 } from "lucide-react";
import { scanProduct } from "../services/productApi";
import type { ProductScanResponse } from "../types/api";
import CameraScanner from "../components/scan/CameraScanner";
import ManualInput from "../components/scan/ManualInput";
import QueryResult from "../components/scan/QueryResult";
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
export default function ScanPage() {
const [cameraActive, setCameraActive] = useState(false);
const [cameraError, setCameraError] = useState<string | null>(null);
@ -18,7 +20,7 @@ export default function ScanPage() {
/** 通用查询 */
const doQuery = useCallback(async (sn: string) => {
if (sn.length < 8) { setError("序列号至少需要 8 位"); return; }
if (sn.length < 8) { setError("产品身份证至少需要 8 位"); return; }
setSerialNumber(sn);
setLastScanned(sn);
setLoading(true);
@ -37,7 +39,7 @@ export default function ScanPage() {
}
}, []);
/** 扫码回调:提取纯序列号 */
/** 扫码回调:提取纯产品身份证 */
const handleScan = useCallback(
(decodedText: string) => {
setCameraError(null);
@ -53,15 +55,23 @@ export default function ScanPage() {
<h2 className="text-lg font-bold text-gray-800"></h2>
</div>
{/* 摄像头扫码 */}
{/* 摄像头扫码 — 点击启动时才动态加载 html5-qrcode */}
<div className="mt-3 px-4">
<CameraScanner
active={cameraActive}
onToggle={setCameraActive}
onScan={handleScan}
error={cameraError}
onError={setCameraError}
/>
<Suspense
fallback={
<div className="flex items-center justify-center rounded-xl border border-gray-200 bg-white py-16 shadow-sm">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
}
>
<CameraScanner
active={cameraActive}
onToggle={setCameraActive}
onScan={handleScan}
error={cameraError}
onError={setCameraError}
/>
</Suspense>
</div>
{/* 手动输入 */}

View File

@ -1,17 +1,30 @@
import { useEffect, useState } from "react";
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react";
import { useEffect, useState, useMemo } from "react";
import {
Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X,
Package, Hash, Tag, MapPin, Clock, Pencil, Trash2, Save, AlertTriangle,
Search, ChevronDown, ChevronRight, Warehouse, Barcode,
} from "lucide-react";
import api from "../../services/api";
import type { ProductResponse } from "../../types/admin";
import CreateProductDialog from "./CreateProductDialog";
import {
getLabelPreview,
executePrint,
type LabelPreviewRequest,
getLabelPreview, executePrint, type LabelPreviewRequest,
} from "../../services/printApi";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
const QR_BASE = "/api/v1/products/qrcode";
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
];
interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWarehouse: boolean; }
export default function AdminProductsPage() {
const { toast } = useToast();
const [products, setProducts] = useState<ProductResponse[]>([]);
@ -19,289 +32,265 @@ export default function AdminProductsPage() {
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
// 打印弹窗状态
// 搜索 & 筛选 & 分组
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// 打印
const [printTarget, setPrintTarget] = useState<ProductResponse | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [printLoading, setPrintLoading] = useState(false);
const [printCopies, setPrintCopies] = useState(1);
const [printing, setPrinting] = useState(false);
// 编辑
const [editTarget, setEditTarget] = useState<ProductResponse | null>(null);
const [editOrderNo, setEditOrderNo] = useState("");
const [editExternalSerial, setEditExternalSerial] = useState("");
const [editSaving, setEditSaving] = useState(false);
// 删除防呆
const [deleteTarget, setDeleteTarget] = useState<ProductResponse | null>(null);
const [deleting, setDeleting] = useState(false);
const [countdown, setCountdown] = useState(5);
const [serialInput, setSerialInput] = useState("");
const canConfirmDelete = countdown <= 0 && serialInput === deleteTarget?.serial_number;
async function loadProducts() {
setLoading(true);
setError(null);
setLoading(true); setError(null);
try {
const { data } = await api.get<ProductResponse[]>("/products/");
setProducts(data);
} catch {
setError("加载产品列表失败,请检查后端服务");
} finally {
setLoading(false);
}
}
useEffect(() => {
loadProducts();
}, []);
// ---- 打印标签 ----
async function handleOpenPrint(product: ProductResponse) {
setPrintTarget(product);
setPreviewUrl(null);
setPrintCopies(1);
setPrintLoading(true);
try {
const payload: LabelPreviewRequest = {
serial_number: product.serial_number,
material_name: product.material_name ?? product.material_id ?? "",
spec_model: product.spec_model ?? "",
order_no: product.order_no ?? "",
};
const url = await getLabelPreview(payload);
setPreviewUrl(url);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "生成预览失败", "error");
setPrintTarget(null);
} finally {
setPrintLoading(false);
}
}
async function handleConfirmPrint() {
if (!printTarget) return;
setPrinting(true);
try {
const result = await executePrint({
serial_number: printTarget.serial_number,
material_name: printTarget.material_name ?? printTarget.material_id ?? "",
spec_model: printTarget.spec_model ?? "",
order_no: printTarget.order_no ?? "",
copies: printCopies,
const { data } = await api.get<ProductResponse[]>("/products/", {
params: { limit: 1000, ...(keyword.trim() ? { keyword: keyword.trim() } : {}) },
});
toast(result.message, "success");
setPrintTarget(null);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "打印失败", "error");
} finally {
setPrinting(false);
setProducts(data);
} catch { setError("加载产品列表失败"); }
finally { setLoading(false); }
}
useEffect(() => { loadProducts(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(e?: React.FormEvent) { e?.preventDefault(); loadProducts(); }
// ---- 分组 + 状态过滤 ----
const productGroups = useMemo<ProductGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
if (statusFilter) {
const s = (p.macro_status || p.status).toUpperCase();
if (s !== statusFilter) continue;
}
const key = groupBy === "device"
? (p.material_name || p.material_id || "未命名设备")
: (p.order_no || "未绑定订单");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
}
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([groupKey, prods]) => ({
groupKey, products: prods,
allInWarehouse: prods.every(p => p.current_location_id === "virtual_warehouse"),
}));
}, [products, statusFilter, groupBy]);
// 🔧 默认全部展开productGroups 变化时自动展开所有面板
useEffect(() => {
setExpandedGroups(new Set(productGroups.map(g => g.groupKey)));
}, [productGroups]);
function toggleGroup(key: string) {
setExpandedGroups(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n; });
}
// ---- 打印二维码(兼容旧功能) ----
// ---- 打印 ----
async function handleOpenPrint(product: ProductResponse) { /* unchanged */ setPrintTarget(product); setPreviewUrl(null); setPrintCopies(1); setPrintLoading(true); try { setPreviewUrl(await getLabelPreview({ serial_number: product.serial_number, material_name: product.material_name ?? product.material_id ?? "", spec_model: product.spec_model ?? "", order_no: product.order_no ?? "" })); } catch (err: any) { toast(err?.response?.data?.detail ?? "生成预览失败", "error"); setPrintTarget(null); } finally { setPrintLoading(false); } }
async function handleConfirmPrint() { if (!printTarget) return; setPrinting(true); try { const r = await executePrint({ serial_number: printTarget.serial_number, material_name: printTarget.material_name ?? "", spec_model: printTarget.spec_model ?? "", order_no: printTarget.order_no ?? "", copies: printCopies }); toast(r.message, "success"); setPrintTarget(null); } catch (err: any) { toast(err?.response?.data?.detail ?? "打印失败", "error"); } finally { setPrinting(false); } }
function handlePrintQr(serialNumber: string) {
const qrUrl = `${QR_BASE}/${serialNumber}`;
const w = window.open("", "_blank", "width=400,height=500");
if (!w) return;
w.document.write(`
<!DOCTYPE html>
<html>
<head><title>打印标签 - ${serialNumber}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { display: flex; flex-direction: column; align-items: center; padding: 24px; font-family: monospace; }
img { width: 280px; height: 280px; image-rendering: pixelated; }
.sn { margin-top: 12px; font-size: 22px; font-weight: bold; letter-spacing: 2px; color: #1f2937; }
.hint { margin-top: 8px; font-size: 12px; color: #9ca3af; }
@media print { body { padding: 0; } img { width: 260px; height: 260px; } }
</style></head>
<body>
<img src="${qrUrl}" alt="QR-${serialNumber}" />
<p class="sn">${serialNumber}</p>
<p class="hint">扫描二维码查询生产进度</p>
<script>window.onload=()=>window.print()</script>
</body>
</html>
`);
w.document.close();
}
// ---- 编辑 ----
function openEdit(p: ProductResponse) { setEditTarget(p); setEditOrderNo(p.order_no ?? ""); setEditExternalSerial(p.external_serial ?? ""); }
async function handleSaveEdit() { if (!editTarget) return; setEditSaving(true); try { await api.patch(`/products/${editTarget.id}`, { order_no: editOrderNo.trim() || null, external_serial: editExternalSerial.trim() || null }); toast("保存成功", "success"); setEditTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "保存失败", "error"); } finally { setEditSaving(false); } }
// ---- 删除 ----
useEffect(() => {
if (!deleteTarget) { setCountdown(5); setSerialInput(""); return; }
setCountdown(5); setSerialInput("");
const timer = setInterval(() => setCountdown(c => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }), 1000);
return () => clearInterval(timer);
}, [deleteTarget?.id]); // eslint-disable-line
function confirmDelete(p: ProductResponse) { setDeleteTarget(p); }
async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "删除失败", "error"); } finally { setDeleting(false); } }
return (
<div>
{/* 标题栏 */}
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
<div><h2 className="text-xl font-bold text-gray-800"></h2><p className="mt-1 text-sm text-gray-500"></p></div>
<div className="flex gap-2">
<a
href="/admin/print-config"
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-50"
title="打印机设置"
>
<Settings className="h-4 w-4" />
</a>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
</button>
<button
onClick={loadProducts}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<a href="/admin/print-config" className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 hover:bg-gray-50"><Settings className="h-4 w-4" /></a>
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"><Plus className="h-4 w-4" /></button>
<button onClick={loadProducts} className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></button>
</div>
</div>
{/* 错误 */}
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{/* 加载中 */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
)}
{/* 产品列表 */}
{!loading && products.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<QrCode className="mb-3 h-12 w-12" />
<p></p>
<p className="mt-1 text-sm"></p>
</div>
)}
{!loading && products.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{products.map((p) => (
<div
key={p.id}
className="flex flex-col items-center rounded-xl bg-white p-4 shadow-sm transition-shadow hover:shadow-md"
>
{/* 二维码 */}
<img
src={`${QR_BASE}/${p.serial_number}`}
alt={`QR-${p.serial_number}`}
className="h-40 w-40 rounded-lg border border-gray-100"
loading="lazy"
/>
{/* 序列号 */}
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">
{p.serial_number}
</p>
<p className="mt-0.5 text-xs text-gray-400">
{p.order_no ?? "—"}
</p>
{/* 打印按钮 */}
<button
onClick={() => handleOpenPrint(p)}
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 hover:border-blue-300"
>
<Printer className="h-3.5 w-3.5" />
{/* 搜索 + 分组 + 状态过滤(移植自 AdminTasksPage */}
<div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">:</span>
{(["order", "device"] as const).map(m => (
<button key={m} onClick={() => setGroupBy(m)} className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${groupBy === m ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-500 hover:bg-gray-200"}`}>
{m === "order" ? "按订单" : "按设备"}
</button>
</div>
))}
</div>
<div className="flex gap-1">
<button onClick={() => setExpandedGroups(new Set(productGroups.map(g => g.groupKey)))}
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50"></button>
<button onClick={() => setExpandedGroups(new Set())}
className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100"></button>
</div>
</div>
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input type="text" value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索产品身份证、订单号、规格型号..." className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" />
</div>
<button type="submit" disabled={loading} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
</button>
</form>
<div className="mt-3 flex gap-1.5 flex-wrap">
{STATUS_TABS.map(tab => (
<button key={tab.key} onClick={() => setStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${statusFilter === tab.key ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
{tab.label}
</button>
))}
</div>
</div>
{error && <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{loading && <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div>}
{/* 空态 */}
{!loading && products.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<QrCode className="mb-3 h-12 w-12" /><p></p>
</div>
)}
<CreateProductDialog
open={showCreate}
onClose={() => setShowCreate(false)}
onCreated={loadProducts}
/>
{/* 🚀 分组折叠面板 */}
{!loading && productGroups.length > 0 && (
<div className="space-y-3">
{productGroups.map(group => {
const isOpen = expandedGroups.has(group.groupKey);
return (
<div key={group.groupKey} className="overflow-hidden rounded-xl bg-white shadow-sm">
<button onClick={() => toggleGroup(group.groupKey)} className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors">
{isOpen ? <ChevronDown className="h-4 w-4 shrink-0 text-gray-400" /> : <ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />}
<Package className="h-4 w-4 shrink-0 text-blue-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">
{group.groupKey === "未绑定订单" || group.groupKey === "未命名设备"
? "📋 未分类"
: groupBy === "device" ? `⚙️ 设备: ${group.groupKey}` : `📦 订单: ${group.groupKey}`}
</span>
<span className="text-xs text-gray-400">{group.products.length} </span>
</div>
</div>
{group.allInWarehouse && group.products.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700"><Warehouse className="h-3 w-3" /></span>
)}
</button>
{/* 产品卡片网格 */}
{isOpen && (
<div className="border-t border-gray-100 px-5 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{group.products.map(p => (
<div key={p.id} className="group relative flex flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 z-10">
<button onClick={() => openEdit(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-blue-50 hover:text-blue-600"><Pencil className="h-3.5 w-3.5" /></button>
<button onClick={() => confirmDelete(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-red-50 hover:text-red-500"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
<div className="flex flex-col items-center px-4 pt-5 pb-3">
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" />
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">{p.serial_number}</p>
</div>
<div className="flex-1 space-y-2 border-t border-gray-50 px-4 py-3">
<InfoRow icon={Package} label="物料名称" value={p.material_name || p.material_id || "—"} />
<InfoRow icon={Barcode} label="产品序列号" value={p.external_serial || "—"} />
<InfoRow icon={Hash} label="规格型号" value={p.spec_model || "—"} />
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
<InfoRow icon={MapPin} label="当前位置" value={p.current_location_name || p.current_location_id || "—"} />
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
</div>
<div className="border-t border-gray-50 px-4 py-3">
<button onClick={() => handleOpenPrint(p)} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
<Printer className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
<CreateProductDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={loadProducts} />
{/* ============================================================ */}
{/* 打印预览弹窗 */}
{/* ============================================================ */}
{printTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={() => !printing && setPrintTarget(null)}
/>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !printing && setPrintTarget(null)} />
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-bold text-gray-800">
</h3>
<button
onClick={() => setPrintTarget(null)}
disabled={printing}
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mb-4 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800"></h3><button onClick={() => setPrintTarget(null)} disabled={printing} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-4 w-4" /></button></div>
<div className="mb-4 flex justify-center">{printLoading || !previewUrl ? <div className="flex h-48 w-full items-center justify-center rounded-lg bg-gray-50"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div> : <img src={previewUrl} alt="预览" className="max-h-64 rounded-lg border border-gray-200" />}</div>
<p className="mb-4 text-center font-mono text-sm font-bold tracking-wider text-gray-700">{printTarget.serial_number}</p>
<div className="mb-4 flex items-center justify-between"><span className="text-sm text-gray-600"></span><div className="flex items-center gap-2"><button onClick={() => setPrintCopies(c => Math.max(1, c - 1))} className="rounded border px-2.5 py-1 text-sm"></button><span className="w-8 text-center text-sm font-semibold">{printCopies}</span><button onClick={() => setPrintCopies(c => Math.min(100, c + 1))} className="rounded border px-2.5 py-1 text-sm">+</button></div></div>
<div className="flex justify-end gap-2"><button onClick={() => setPrintTarget(null)} disabled={printing} className="rounded-lg border px-4 py-2 text-sm text-gray-600"></button><button onClick={handleConfirmPrint} disabled={printing || printLoading} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{printing && <Loader2 className="h-3.5 w-3.5 animate-spin" />}</button></div>
</div>
</div>
)}
{/* 预览图 */}
<div className="mb-4 flex justify-center">
{printLoading || !previewUrl ? (
<div className="flex items-center justify-center rounded-lg bg-gray-50 w-full h-48">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
) : (
<img
src={previewUrl}
alt="标签预览"
className="max-h-64 rounded-lg border border-gray-200"
/>
)}
{/* 编辑弹窗 */}
{editTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !editSaving && setEditTarget(null)} />
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800"></h3><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
<p className="mb-4 font-mono text-sm text-gray-500">ID: {editTarget.serial_number}</p>
<div className="space-y-4">
<div><label className="mb-1 block text-sm font-medium text-gray-700"></label><input value={editOrderNo} onChange={e => setEditOrderNo(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
<div><label className="mb-1 block text-sm font-medium text-gray-700"></label><input value={editExternalSerial} onChange={e => setEditExternalSerial(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
</div>
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg border px-4 py-2 text-sm text-gray-600"></button><button onClick={handleSaveEdit} disabled={editSaving} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{editSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}<Save className="h-3.5 w-3.5" /></button></div>
</div>
</div>
)}
<p className="mb-4 text-center font-mono text-sm font-bold tracking-wider text-gray-700">
{printTarget.serial_number}
</p>
{/* 份数选择 */}
<div className="mb-4 flex items-center justify-between">
<span className="text-sm text-gray-600"></span>
<div className="flex items-center gap-2">
<button
onClick={() => setPrintCopies((c) => Math.max(1, c - 1))}
className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
>
</button>
<span className="w-8 text-center text-sm font-semibold">
{printCopies}
</span>
<button
onClick={() => setPrintCopies((c) => Math.min(100, c + 1))}
className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
>
+
</button>
</div>
</div>
{/* 按钮 */}
<div className="flex justify-end gap-2">
<button
onClick={() => setPrintTarget(null)}
disabled={printing}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
</button>
<button
onClick={handleConfirmPrint}
disabled={printing || printLoading}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{printing && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</button>
</div>
{/* 删除确认弹窗 */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !deleting && setDeleteTarget(null)} />
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3"><div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100"><AlertTriangle className="h-5 w-5 text-red-600" /></div><div><h3 className="text-base font-bold text-gray-800"></h3><p className="text-xs text-gray-500"></p></div></div>
<p className="mb-4 text-sm text-gray-600"> <span className="font-mono font-bold">{deleteTarget.serial_number}</span> </p>
<div className="mb-4"><label className="mb-1 block text-xs font-medium text-gray-600"></label><input value={serialInput} onChange={e => setSerialInput(e.target.value)} placeholder={deleteTarget.serial_number} maxLength={16} className="w-full rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-red-400 focus:outline-none" disabled={deleting} /></div>
<div className="flex items-center justify-end gap-2"><button onClick={() => setDeleteTarget(null)} disabled={deleting} className="rounded-lg border px-4 py-2 text-sm text-gray-600"></button><button onClick={handleDelete} disabled={!canConfirmDelete || deleting} className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50">{deleting ? <><Loader2 className="h-3.5 w-3.5 animate-spin" />...</> : canConfirmDelete ? <><Trash2 className="h-3.5 w-3.5" /></> : <> ({countdown}s)</>}</button></div>
</div>
</div>
)}
</div>
);
}
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
return <div className="flex items-center gap-2 text-xs"><Icon className="h-3 w-3 shrink-0 text-gray-400" /><span className="shrink-0 text-gray-400">{label}</span><span className="truncate font-medium text-gray-700">{value}</span></div>;
}

View File

@ -1,5 +1,478 @@
import TaskTreeViewer from "../../components/TaskTree/TaskTreeViewer";
/** 任务全景 Dashboard — 按订单聚合 + 关键词搜索 + 状态筛选 */
import { useState, useEffect, useMemo, useCallback } from "react";
import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X,
} from "lucide-react";
import api from "../../services/api";
import { scanProduct } from "../../services/productApi";
import {
receiveTask, rejectTask, transferTask,
} from "../../services/taskApi";
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
];
interface OrderGroup {
orderNo: string;
products: ProductResponse[];
allInWarehouse: boolean;
}
export default function AdminTasksPage() {
return <TaskTreeViewer />;
const { toast } = useToast();
const { user: currentUser } = useAuth();
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 手风琴展开状态
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 🔧 排他展开:同时只展开一个产品的流转树
const [activeTreeProductId, setActiveTreeProductId] = useState<string | null>(null);
const [taskTrees, setTaskTrees] = useState<Record<string, ProductScanResponse | null>>({});
const [treeLoading, setTreeLoading] = useState<Record<string, boolean>>({});
// 弹窗
const [modalTarget, setModalTarget] = useState<ModalTarget | null>(null);
const [submitting, setSubmitting] = useState(false);
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
// ---- 加载产品列表(始终拉全量,不做服务端状态过滤) ----
async function loadProducts(kw: string) {
setLoading(true);
setError(null);
try {
const params: Record<string, string | number> = { limit: 1000 };
if (kw.trim()) params.keyword = kw.trim();
const { data } = await api.get<ProductResponse[]>("/products/", { params });
setProducts(data);
} catch {
setError("加载产品列表失败,请检查后端服务");
} finally {
setLoading(false);
}
}
useEffect(() => { loadProducts(keyword); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
setExpandedProducts(new Set());
setTaskTrees({});
loadProducts(keyword);
}
// ---- 按订单/设备分组 + 本地状态过滤 ----
const orderGroups = useMemo<OrderGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
if (statusFilter) {
const currentStatus = (p.macro_status || p.status).toUpperCase();
if (currentStatus !== statusFilter) continue;
}
const key = groupBy === "device"
? (p.material_name || p.material_id || "未命名设备")
: (p.order_no || "未绑定订单");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([orderNo, prods]) => ({
orderNo,
products: prods,
allInWarehouse: prods.every(
(p) => p.current_location_id === "virtual_warehouse"
),
}));
}, [products, statusFilter, groupBy]);
// 🔧 默认全部展开
useEffect(() => {
setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)));
}, [orderGroups]);
// ---- 手风琴切换 ----
function toggleOrder(orderNo: string) {
setExpandedOrders((prev) => {
const next = new Set(prev);
if (next.has(orderNo)) next.delete(orderNo);
else next.add(orderNo);
return next;
});
}
// ---- 排他展开(手风琴模式) ----
async function toggleProductTree(serialNumber: string) {
// 点击已展开的树 → 收起
if (activeTreeProductId === serialNumber) {
setActiveTreeProductId(null);
return;
}
// 展开新的 → 自动收起旧的
setActiveTreeProductId(serialNumber);
if (!taskTrees[serialNumber]) {
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
scanProduct(serialNumber)
.then((result) => setTaskTrees((s) => ({ ...s, [serialNumber]: result })))
.catch((err: any) => toast(err?.response?.data?.detail ?? err?.message ?? "加载任务树失败", "error"))
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
}
}
// ---- 任务操作 ----
const refreshProductTree = useCallback(async (serialNumber: string) => {
try {
const result = await scanProduct(serialNumber);
setTaskTrees((s) => ({ ...s, [serialNumber]: result }));
} catch { /* 静默 */ }
}, []);
async function handleReceive() {
if (!modalTarget) return;
setSubmitting(true);
try {
await receiveTask(modalTarget.task.id);
toast("任务已接收", "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error");
} finally {
setSubmitting(false);
}
}
async function handleReject(reason: string) {
if (!modalTarget) return;
setSubmitting(true);
try {
await rejectTask(modalTarget.task.id, reason);
toast("任务已驳回", "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error");
} finally {
setSubmitting(false);
}
}
async function handleTransfer(
nextTaskName: string,
assignees: string[],
note: string,
) {
if (!modalTarget) return;
setSubmitting(true);
try {
const result = await transferTask(
modalTarget.task.id, assignees, nextTaskName, note || undefined,
);
toast(result.message, "success");
const sn = modalTarget.task.product_sn || "";
setModalTarget(null);
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error");
} finally {
setSubmitting(false);
}
}
// ---- 渲染 ----
const modalTask = modalTarget?.task ?? null;
return (
<div>
{/* ---- 标题 ---- */}
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-800"> Dashboard</h2>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
{/* ---- 搜索 + 筛选 ---- */}
<div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">:</span>
{(["order", "device"] as const).map(m => (
<button key={m} onClick={() => setGroupBy(m)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${groupBy === m ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-500 hover:bg-gray-200"}`}>
{m === "order" ? "按订单" : "按设备"}
</button>
))}
</div>
<div className="flex gap-1">
<button onClick={() => setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)))}
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50"></button>
<button onClick={() => setExpandedOrders(new Set())}
className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100"></button>
</div>
</div>
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索产品身份证、订单号、规格型号..."
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
</div>
<button
type="submit"
disabled={loading}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
</button>
</form>
{/* 状态筛选 Tabs */}
<div className="mt-3 flex gap-1.5 flex-wrap">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilter === tab.key
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* ---- 错误 ---- */}
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{/* ---- 加载中 ---- */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
)}
{/* ---- 空态 ---- */}
{!loading && products.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<Package className="mb-3 h-12 w-12" />
<p></p>
<p className="mt-1 text-sm"></p>
</div>
)}
{/* ---- 订单手风琴列表 ---- */}
{!loading && orderGroups.length > 0 && (
<div className="space-y-3">
{orderGroups.map((group) => {
const isOpen = expandedOrders.has(group.orderNo);
return (
<div
key={group.orderNo}
className="overflow-hidden rounded-xl bg-white shadow-sm"
>
{/* 订单头部 */}
<button
onClick={() => toggleOrder(group.orderNo)}
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />
)}
<Package className="h-4 w-4 shrink-0 text-blue-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">
{group.orderNo === "未绑定订单" || group.orderNo === "未命名设备"
? "📋 未分类"
: groupBy === "device"
? `⚙️ 设备: ${group.orderNo}`
: `📦 订单: ${group.orderNo}`}
</span>
<span className="text-xs text-gray-400">
{group.products.length}
</span>
</div>
</div>
{/* 入库状态标签 */}
{group.allInWarehouse && group.products.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700">
<Warehouse className="h-3 w-3" />
</span>
)}
{!group.allInWarehouse && (
<span className="text-xs text-gray-400"></span>
)}
</button>
{/* 订单展开内容 */}
{isOpen && (
<div className="border-t border-gray-100">
{/* 表头 */}
<div className="grid grid-cols-12 gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500">
<div className="col-span-2"></div>
<div className="col-span-1"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
</div>
{/* 产品行 */}
{group.products.map((p) => {
const productExpanded = activeTreeProductId === p.serial_number;
const isTreeLoading = treeLoading[p.serial_number];
const tree = taskTrees[p.serial_number];
// 综合状态:优先流转树状态,兜底产品状态
// 单一数据源:后端预计算 macro_status兜底产品 status
const currentStatus = p.macro_status || p.status;
const statusCfg = getStatusConfig(currentStatus);
return (
<div key={p.id}>
<div className="grid grid-cols-12 gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50">
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
<div className="col-span-1 font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>
<div className="col-span-2 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>
<div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div>
<div className="col-span-1"><span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span></div>
<div className="col-span-1 text-xs text-gray-500 truncate">{p.current_location_id === "virtual_warehouse" ? (<span className="inline-flex items-center gap-1 text-purple-600">🏭 </span>) : (p.current_location_name || p.current_location_id || "—")}</div>
<div className="col-span-2 text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>
<div className="col-span-2">
<button
onClick={() => toggleProductTree(p.serial_number)}
className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors"
>
{isTreeLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : productExpanded ? (
<X className="h-3 w-3" />
) : (
<GitBranch className="h-3 w-3" />
)}
{productExpanded ? "收起" : "流转树"}
</button>
</div>
</div>
{/* 展开的流转树 — 卡片堆叠视图 */}
{productExpanded && tree && (
<div className="border-t border-dashed border-blue-100 bg-gradient-to-b from-blue-50/40 to-white px-5 py-4">
<h4 className="mb-3 flex items-center gap-2 text-xs font-semibold text-gray-500">
<GitBranch className="h-3.5 w-3.5" />
{p.serial_number}
</h4>
{tree.task_tree && tree.task_tree.length > 0 ? (
<TaskFlowView
tasks={tree.task_tree}
onAction={setModalTarget}
currentUser={currentUser}
assigneeNames={tree.assignee_names}
/>
) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<GitBranch className="mb-3 h-10 w-10 text-gray-300" />
<p className="text-sm font-medium text-gray-500"></p>
<p className="mt-1 text-xs text-gray-400"></p>
</div>
)}
</div>
)}
{productExpanded && isTreeLoading && (
<div className="border-t border-dashed border-gray-100 bg-gray-50/50 px-5 py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-blue-400" />
<p className="mt-2 text-xs text-gray-400">...</p>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* 🔧 二维码弹窗 */}
{qrSerial && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setQrSerial(null)}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div className="relative z-10 rounded-xl bg-white p-6 shadow-2xl text-center" onClick={e => e.stopPropagation()}>
<h3 className="mb-3 text-sm font-bold text-gray-800"></h3>
<img src={`/api/v1/products/qrcode/${qrSerial}`} alt={`QR-${qrSerial}`} className="mx-auto h-64 w-64 rounded-lg border" />
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-700">{qrSerial}</p>
<button onClick={() => setQrSerial(null)} className="mt-4 rounded-lg bg-blue-600 px-6 py-2 text-sm text-white hover:bg-blue-700"></button>
</div>
</div>
)}
{/* ---- 弹窗 ---- */}
<ReceiveConfirmModal
open={modalTarget?.action === "receive"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onConfirm={handleReceive}
/>
<RejectModal
open={modalTarget?.action === "reject"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onSubmit={handleReject}
/>
<TransferModal
open={modalTarget?.action === "transfer"}
task={modalTask}
submitting={submitting}
onClose={() => setModalTarget(null)}
onSubmit={handleTransfer}
/>
</div>
);
}

View File

@ -453,11 +453,11 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
</div>
{/* ================================================================ */}
{/* 系统唯一 ID只读 */}
{/* 产品身份证 — 系统自动生成 16 位 HEX不可编辑 */}
{/* ================================================================ */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
ID <span className="text-xs text-gray-400">()</span>
<span className="text-xs text-gray-400">()</span>
</label>
<Input
value="提交后自动生成 16 位 HEX"
@ -466,7 +466,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
/>
</div>
{/* 产品序列号(选填) */}
{/* 产品序列号 — 用户自定义录入(选填) */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
<span className="text-xs text-gray-400">()</span>

View File

@ -1,4 +1,39 @@
import axios from "axios";
import axios, { type AxiosRequestConfig } from "axios";
// ============================================================
// Token 存储 Key
// ============================================================
const ACCESS_TOKEN_KEY = "track_admin_token";
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
const USER_KEY = "track_admin_user";
// ============================================================
// Token 读写工具
// ============================================================
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
export function clearAuth() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
// ============================================================
// Axios 实例
// ============================================================
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
@ -8,10 +43,13 @@ const api = axios.create({
},
});
// 请求拦截器 — 注入 JWT token
// ============================================================
// 请求拦截器 — 注入 Access Token
// ============================================================
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("track_admin_token");
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
@ -20,19 +58,104 @@ api.interceptors.request.use(
(error) => Promise.reject(error),
);
// 响应拦截器 — 统一错误处理
// ============================================================
// 响应拦截器 — 双 Token 无感刷新 + 并发请求队列
// ============================================================
let isRefreshing = false;
let retryQueue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
/** 处理队列中的所有挂起请求 */
function processQueue(error: unknown, token: string | null) {
retryQueue.forEach((p) => {
if (token) {
p.resolve(token);
} else {
p.reject(error);
}
});
retryQueue = [];
}
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token 过期或无效 → 清除并跳转登录
localStorage.removeItem("track_admin_token");
localStorage.removeItem("track_admin_user");
async (error) => {
const originalRequest: AxiosRequestConfig & { _retry?: boolean } =
error.config;
const status = error.response?.status;
// 仅处理 401
if (status !== 401) {
return Promise.reject(error);
}
// 跳过登录和刷新接口自身(避免死循环)
const url = originalRequest.url ?? "";
if (url.includes("/auth/login") || url.includes("/auth/refresh")) {
return Promise.reject(error);
}
// 避免对同一请求重复刷新
if (originalRequest._retry) {
clearAuth();
if (window.location.pathname.startsWith("/admin")) {
window.location.href = "/admin/login";
}
return Promise.reject(error);
}
originalRequest._retry = true;
// ---- 并发请求队列机制 ----
if (isRefreshing) {
// 已有刷新进行中,加入队列等待
return new Promise<string>((resolve, reject) => {
retryQueue.push({ resolve, reject });
}).then((newToken) => {
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
});
}
isRefreshing = true;
try {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error("无 Refresh Token");
}
// 调用刷新接口
const { data } = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
{ refresh_token: refreshToken },
{ headers: { "Content-Type": "application/json" } },
);
const newAccessToken: string = data.access_token;
setTokens(newAccessToken, refreshToken); // 更新 Access Token
// 重放队列中的所有请求
processQueue(null, newAccessToken);
// 重试当前请求
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh Token 也过期 — 彻底登出
processQueue(refreshError, null);
clearAuth();
if (window.location.pathname.startsWith("/admin")) {
window.location.href = "/admin/login";
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
return Promise.reject(error);
},
);

View File

@ -1,14 +1,20 @@
import axios from "axios";
/** 认证 API — 登录、刷新 Token、获取用户信息 */
import type { UserInfo } from "../contexts/AuthContext";
const API_BASE = import.meta.env.VITE_API_BASE_URL;
export interface LoginResponse {
access_token: string;
refresh_token: string;
user: UserInfo;
}
/** 登录 — 注意:此请求不走 axios 实例(避免循环依赖),直接用 fetch */
export interface RefreshResponse {
access_token: string;
token_type: string;
}
/** 登录 — 注意:此请求不走 axios 实例,直接用 fetch */
export async function login(username: string, password: string): Promise<LoginResponse> {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
@ -22,6 +28,19 @@ export async function login(username: string, password: string): Promise<LoginRe
return res.json();
}
/** 刷新 Access Token — 注意:不走 axios 实例,直接用 fetch */
export async function refreshAccessToken(refreshToken: string): Promise<RefreshResponse> {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
throw new Error("Refresh Token 无效或已过期");
}
return res.json();
}
/** 验证 token 有效性 + 获取最新用户信息 */
export async function getMe(token: string): Promise<UserInfo> {
const res = await fetch(`${API_BASE}/auth/me`, {

View File

@ -0,0 +1,41 @@
/** 消息通知 API */
import api from "./api";
export interface NotificationItem {
id: string;
user_id: string;
title: string;
content: string;
type: "TRANSFER" | "REJECT";
task_id: string | null;
is_read: boolean;
created_at: string;
}
export interface NotificationListResponse {
notifications: NotificationItem[];
total: number;
unread_count: number;
}
/** 获取当前用户的通知列表 */
export async function getNotifications(
userId: string,
skip = 0,
limit = 20
): Promise<NotificationListResponse> {
const { data } = await api.get<NotificationListResponse>("/notifications/", {
params: { user_id: userId, skip, limit },
});
return data;
}
/** 标记单条通知为已读 */
export async function markNotificationRead(
notificationId: string
): Promise<NotificationItem> {
const { data } = await api.put<NotificationItem>(
`/notifications/${notificationId}/read`
);
return data;
}

View File

@ -1,7 +1,7 @@
import api from "./api";
import type { ProductScanResponse } from "../types/api";
/** 扫码查询 — 根据 16 位序列号查产品 + 顶层任务 */
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
const { data } = await api.get<ProductScanResponse>(
`/products/scan/${encodeURIComponent(serialNumber)}`

View File

@ -13,6 +13,9 @@ export interface ProductResponse {
material_type: string | null;
parent_product_id: string | null;
current_location_id: string | null;
current_location_name: string | null;
macro_status: string | null;
overall_status: string | null;
status: string;
created_at: string;
}

View File

@ -21,12 +21,16 @@ export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS];
export interface TaskSummary {
id: string;
product_id: string;
product_sn: string;
product_material: string;
parent_task_id: string | null;
task_name: string;
assignee_id: string | null;
status: TaskStatus | string;
notify_parent_on_complete: boolean;
is_rework: boolean;
task_type: string | null;
remark: string | null;
reject_reason: string | null;
received_at: string | null;
completed_at: string | null;
@ -35,6 +39,16 @@ export interface TaskSummary {
export interface TaskResponse extends TaskSummary {
child_tasks: TaskResponse[];
records: TaskRecordResponse[];
}
export interface TaskRecordResponse {
id: string;
task_id: string;
action: string;
operator_id: string | null;
note: string | null;
created_at: string;
}
// ---- 操作响应 ----
@ -85,4 +99,6 @@ export interface ProductScanResponse {
top_level_tasks: TaskSummary[];
/** 完整递归任务树 — 供十字矩阵树状图渲染 */
task_tree: TaskResponse[];
/** 🔧 username→中文姓名映射 */
assignee_names: Record<string, string>;
}

View File

@ -7,6 +7,20 @@ import basicSsl from "@vitejs/plugin-basic-ssl";
export default defineConfig({
plugins: [react(), tailwindcss(), basicSsl()],
// 🚀 构建优化:将第三方巨头独立分包,利用浏览器缓存
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/scheduler")) return "vendor-react";
if (id.includes("node_modules/react-router")) return "vendor-react";
if (id.includes("node_modules/antd") || id.includes("node_modules/@ant-design")) return "vendor-antd";
if (id.includes("node_modules/html5-qrcode")) return "vendor-qrcode";
},
},
},
},
clearScreen: false,
server: {
host: "0.0.0.0",

View File

@ -1,21 +1,204 @@
<script setup>
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
<script>
import { getNotifications } from "./api/notification";
onLaunch(() => {
console.log("生产流转 App 启动");
});
export default {
onLaunch() {
console.log("生产流转 T1.0.2 启动");
onShow(() => {
console.log("App 显示");
});
// 🚦 路由守卫:首页已是 scan未登录才跳登录页零闪烁
const token = uni.getStorageSync("access_token") || uni.getStorageSync("refresh_token");
const user = uni.getStorageSync("user");
onHide(() => {
console.log("App 隐藏");
});
if (!token || !user) {
uni.reLaunch({ url: "/pages/login/login" });
}
// 已登录 → 原地渲染 scan 页,不需任何跳转
// 🚀 OTA 热更新检测(仅 App 端生效)
// #ifdef APP-PLUS
this.checkUpdate();
// #endif
},
onShow() {
console.log("App 显示");
this.updateTabBarBadge();
// 🚀 每次回到前台也检查更新,用户无需杀后台就能感知新版本
// #ifdef APP-PLUS
this.checkUpdate();
// #endif
},
onHide() {
console.log("App 隐藏");
},
methods: {
// ==========================================================
// OTA 热更新雷达 — 版本检测 + WGT 下载 + 静默安装
// ==========================================================
checkUpdate() {
// 🚀 节流5分钟内不重复检查避免 onShow 频繁触发弹窗骚扰
const now = Date.now();
if (this._lastUpdateCheck && now - this._lastUpdateCheck < 5 * 60 * 1000) {
console.log("[OTA] 距上次检查不足5分钟跳过");
return;
}
this._lastUpdateCheck = now;
// 🔧 appWgtVersion 跟随 WGT 更新,解析算法: major*100 + lastNum
// "T1.0.1"→101, "T1.0.10"→110, "T1.0.99"→199
const sysInfo = uni.getSystemInfoSync();
const wgtVer = sysInfo.appWgtVersion || sysInfo.appVersion || "0";
const currentVersionCode = this.parseVersionCode(wgtVer);
const baseUrl = uni.getStorageSync("env_base_url") || "http://track_back.iris-rs.cn/api/v1";
console.log("[OTA] 本地版本:", wgtVer, "→ 数字:", currentVersionCode);
uni.request({
url: `${baseUrl}/app/check-update`,
method: "GET",
timeout: 8000,
success: (res) => {
if (res.statusCode !== 200) return;
const data = res.data;
if (!data || !data.wgt_url) {
console.log("[OTA] 服务端无可用更新包");
return;
}
const serverVersionCode = data.version_code || 0;
console.log("[OTA] 服务端版本:", data.version, "| 数字版本:", serverVersionCode);
// 客户端自行对比:服务端 > 本地 = 需要更新
if (serverVersionCode <= currentVersionCode) {
console.log("[OTA] 已是最新版本,无需更新");
return;
}
console.log("[OTA] 发现新版本:", data.version);
this.downloadAndInstall(data.wgt_url, data.version, data.description);
},
fail: () => {
console.log("[OTA] 版本检测网络失败,跳过");
},
});
},
/** 版本号→数字: "T1.0.10" → 1*100+10 = 110 */
parseVersionCode(v) {
if (!v) return 0;
const nums = v.match(/\d+/g);
if (!nums || nums.length < 2) return 0;
return parseInt(nums[0], 10) * 100 + parseInt(nums[nums.length - 1], 10);
},
downloadAndInstall(wgtUrl, newVersion, description) {
if (!wgtUrl) {
console.log("[OTA] 无 WGT 下载地址");
return;
}
// 弹窗询问用户是否更新
const content = description
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新`
: `发现新版本 ${newVersion},是否立即更新?`;
uni.showModal({
title: "版本更新",
content,
confirmText: "立即更新",
cancelText: "稍后再说",
success: (modalRes) => {
if (!modalRes.confirm) return;
// 🚀 使用原生等待框,原地更新文字,杜绝闪烁
plus.nativeUI.showWaiting("正在下载 0%");
const downloadTask = uni.downloadFile({
url: wgtUrl,
success: (downloadRes) => {
if (downloadRes.statusCode !== 200) {
plus.nativeUI.closeWaiting();
uni.showToast({ title: "下载失败", icon: "none" });
return;
}
// 安装阶段 — 原生等待框直接更新文字,无闪烁
plus.nativeUI.showWaiting("正在安装...");
plus.runtime.install(
downloadRes.tempFilePath,
{ force: true },
() => {
plus.nativeUI.closeWaiting();
console.log("[OTA] WGT 安装成功");
// 🚀 静默重启toast 提示后自动重启,无需用户杀后台
plus.nativeUI.toast("新版本已就绪,即将重启...");
setTimeout(() => {
plus.runtime.restart();
}, 2000);
},
(err) => {
plus.nativeUI.closeWaiting();
console.error("[OTA] 安装失败:", err.message);
uni.showToast({
title: "更新失败: " + (err.message || "未知错误"),
icon: "none",
duration: 4000,
});
}
);
},
fail: (err) => {
plus.nativeUI.closeWaiting();
console.error("[OTA] 下载失败:", err.errMsg);
uni.showToast({ title: "下载失败,请检查网络", icon: "none" });
},
});
// 下载进度 — 稳定更新原生等待框文字,不会闪烁
if (downloadTask && downloadTask.onProgressUpdate) {
downloadTask.onProgressUpdate((res) => {
const pct = res.progress;
if (pct < 100) {
plus.nativeUI.showWaiting(`正在下载 ${pct}%`);
}
});
}
},
});
},
// ==========================================================
// TabBar 消息红点
// ==========================================================
async updateTabBarBadge() {
try {
let user = uni.getStorageSync("user");
if (typeof user === "string" && user) {
try { user = JSON.parse(user); } catch (e) { user = null; }
}
const userId = user?.username || user?.id || "";
if (!userId) return;
const res = await getNotifications(userId, 0, 1);
const unreadCount = res.unread_count || 0;
if (unreadCount > 0) {
uni.setTabBarBadge({
index: 2,
text: unreadCount > 99 ? "99+" : String(unreadCount),
});
} else {
uni.removeTabBarBadge({ index: 2 });
}
} catch {
// 静默失败
}
},
},
};
</script>
<style>
/* 全局样式 */
page {
background-color: #f3f4f6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;

View File

@ -0,0 +1,24 @@
/**
* 消息通知 API — 获取列表、标记已读
*/
import { get, put } from "../utils/request";
/**
* 获取当前用户的通知列表
* @param {string} userId - 当前用户ID
* @param {number} skip - 分页偏移
* @param {number} limit - 每页条数
* @returns {Promise<{notifications: Array, total: number, unread_count: number}>}
*/
export function getNotifications(userId, skip = 0, limit = 20) {
return get("/notifications/", { user_id: userId, skip, limit });
}
/**
* 标记单条通知为已读
* @param {string} notificationId - 通知ID
* @returns {Promise<Object>}
*/
export function markNotificationRead(notificationId) {
return put(`/notifications/${notificationId}/read`);
}

View File

@ -1,7 +1,24 @@
import { createSSRApp } from "vue";
import App from "./App.vue";
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import {
createSSRApp
} from 'vue'
export function createApp() {
const app = createSSRApp(App);
return { app };
const app = createSSRApp(App)
return {
app
}
}
// #endif

View File

@ -1,34 +1,62 @@
{
"name": "生产流转",
"appid": "__UNI__B572616",
"description": "工厂生产流转管理系统",
"versionName": "1.0.0",
"versionCode": "1",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
"name" : "Track",
"appid" : "__UNI__B572616",
"description" : "Track - 生产流转管理",
"versionName" : "T1.0.8",
"versionCode" : 108,
"transformPx" : false,
"vueVersion" : "3",
"app-plus" : {
"usingComponents" : true,
"nvueCompiler" : "uni-app",
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"modules" : {
"Barcode" : {},
"Camera" : {}
},
"distribute" : {
"android" : {
"permissions" : [
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>"
]
},
"orientation" : [ "portrait-primary" ],
"icons" : {
"android" : {
"hdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
"xhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
"xxhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
"xxxhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png"
}
},
"sdkConfigs" : {
"push" : {},
"speech" : {}
},
"ios" : {
"dSYMs" : false
}
}
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>"
]
}
}
},
"h5": {
"routerMode": "hash",
"title": "生产流转"
}
"h5" : {
"router" : {
"mode" : "hash",
"base" : ""
},
"title" : "生产流转"
},
"fallbackLocale" : "zh-Hans"
}

View File

@ -1,12 +1,5 @@
{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/scan/index",
"style": {
@ -15,6 +8,13 @@
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/scan/records",
"style": {

View File

@ -0,0 +1,79 @@
<template>
<view class="page">
<view class="header">
<text class="logo">🏭</text>
<text class="title">Track</text>
<text class="version">{{ appVersion }}</text>
</view>
<view class="form">
<input v-model="username" class="input" placeholder="用户名" />
<input v-model="password" class="input" type="password" placeholder="密码" />
<button class="login-btn" @tap="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '登 录' }}
</button>
<text v-if="error" class="error">{{ error }}</text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { post } from "../../utils/request";
// 🚀 动态版本号 — 热更新后自动变化(路由由 App.vue onLaunch 统一接管)
const appVersion = ref("T1.0.2");
onMounted(() => {
try {
const v = uni.getSystemInfoSync().appWgtVersion || uni.getSystemInfoSync().appVersion;
if (v) appVersion.value = v.startsWith("T") ? v : "T" + v;
} catch {}
});
const username = ref("");
const password = ref("");
const loading = ref(false);
const error = ref("");
async function handleLogin() {
if (!username.value || !password.value) {
error.value = "请输入用户名和密码";
return;
}
loading.value = true;
error.value = "";
try {
const res = await post("/auth/login", {
username: username.value,
password: password.value,
});
// 双 Token 存储
uni.setStorageSync("access_token", res.access_token);
uni.setStorageSync("refresh_token", res.refresh_token);
uni.setStorageSync("user", JSON.stringify(res.user));
uni.showToast({ title: "登录成功", icon: "success" });
setTimeout(() => {
uni.switchTab({ url: "/pages/scan/index" });
}, 500);
} catch (e) {
// request.js 对 /auth/login 的 401 直接 reject 不弹 toast此处补齐
const detail = e?.data?.detail || "";
uni.showToast({ title: detail || "账号或密码错误", icon: "none", duration: 2000 });
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.page { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; background: #f3f4f6; }
.header { display: flex; flex-direction: column; align-items: center; margin-bottom: 40px; }
.logo { font-size: 64px; }
.title { font-size: 20px; font-weight: 700; color: #1f2937; margin-top: 12px; }
.version { font-size: 12px; color: #9ca3af; margin-top: 4px; }
.form { width: 100%; max-width: 320px; }
.input { width: 100%; height: 48px; padding: 0 16px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 15px; background: #fff; margin-bottom: 12px; box-sizing: border-box; }
.login-btn { width: 100%; height: 48px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 16px; font-weight: 700; line-height: 48px; }
.login-btn[disabled] { opacity: 0.6; }
.error { display: block; text-align: center; color: #dc2626; font-size: 13px; margin-top: 12px; }
</style>

View File

@ -4,22 +4,158 @@
<text class="title">消息通知</text>
<text class="subtitle">任务流转和系统通知</text>
</view>
<view class="empty">
<!-- 加载中 -->
<view v-if="loading" class="center">加载中...</view>
<!-- 空状态 -->
<view v-else-if="notifications.length === 0" class="empty">
<text class="empty-icon">🔔</text>
<text class="empty-text">暂无新消息</text>
</view>
<!-- 通知列表 -->
<view v-else class="list">
<view
v-for="item in notifications"
:key="item.id"
:class="['card', item.is_read ? '' : 'card-unread']"
@tap="handleCardTap(item)"
>
<view class="card-left">
<view v-if="!item.is_read" class="unread-dot" />
<text :class="['type-icon', item.is_read ? 'type-icon-read' : '']">
{{ typeIcon(item.type) }}
</text>
</view>
<view class="card-body">
<view class="card-top">
<text :class="['card-title', item.is_read ? '' : 'card-title-bold']">
{{ typeTitle(item.type) }}
</text>
<text class="card-time">{{ formatTime(item.created_at) }}</text>
</view>
<text class="card-content">{{ item.content }}</text>
</view>
<text class="card-arrow"></text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { getNotifications, markNotificationRead } from "../../api/notification";
const TYPE_CONFIG = {
TRANSFER: { icon: "🟢", title: "新任务派发" },
REJECT: { icon: "🔴", title: "品质驳回提醒" },
};
const notifications = ref([]);
const loading = ref(true);
let currentUser = null;
onShow(() => {
loadUser();
setTimeout(() => {
if (!currentUser) loadUser();
fetchNotifications();
}, 200);
});
function loadUser() {
try {
let user = uni.getStorageSync("user");
if (typeof user === "string" && user) {
try { user = JSON.parse(user); } catch (e) { user = null; }
}
if (user && typeof user === "object") currentUser = user;
} catch {}
}
async function fetchNotifications() {
const userId = currentUser?.username || currentUser?.id || "";
if (!userId) { loading.value = false; return; }
loading.value = true;
try {
const res = await getNotifications(userId);
notifications.value = res.notifications || [];
} catch {
notifications.value = [];
} finally {
loading.value = false;
}
}
function typeIcon(type) { return (TYPE_CONFIG[type] || { icon: "📌" }).icon; }
function typeTitle(type) { return (TYPE_CONFIG[type] || { title: "系统通知" }).title; }
function formatTime(t) {
if (!t) return "";
const d = new Date(t);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
async function handleCardTap(item) {
if (!item.is_read) {
try { await markNotificationRead(item.id); item.is_read = true; } catch {}
}
// 🚀 优先使用 product_serial_number兜底从 content 中解析
let sn = item.product_serial_number || "";
if (!sn && item.content) {
const match = item.content.match(/\[([A-Za-z0-9]{8,16})\]/);
if (match) sn = match[1];
}
if (sn) {
uni.navigateTo({ url: `/pages/scan/detail?serial=${sn}` });
} else if (item.task_id) {
uni.navigateTo({ url: `/pages/scan/detail?taskId=${item.task_id}` });
}
}
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
.header { margin-bottom: 24px; }
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; background: #f3f4f6; }
.header { margin-bottom: 20px; }
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
.center { text-align: center; padding: 80px 0; color: #9ca3af; font-size: 14px; }
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
.empty-icon { font-size: 64px; margin-bottom: 12px; }
.empty-text { font-size: 14px; color: #9ca3af; }
.list { display: flex; flex-direction: column; gap: 10px; }
.card {
display: flex; align-items: flex-start; gap: 10px;
background: #fff; border-radius: 12px; padding: 14px 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
position: relative; transition: all 0.2s;
}
.card:active { transform: scale(0.98); }
.card-unread {
box-shadow: 0 1px 6px rgba(37,99,235,0.1);
border-left: 3px solid #2563eb;
}
.card-left { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 28px; flex-shrink: 0; }
.unread-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.15);
}
.type-icon { font-size: 20px; line-height: 1; }
.type-icon-read { opacity: 0.5; }
.card-body { flex: 1; min-width: 0; }
.card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.card-title { font-size: 15px; font-weight: 600; color: #374151; }
.card-title-bold { color: #1f2937; font-weight: 700; }
.card-time { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
.card-content { font-size: 13px; color: #6b7280; line-height: 1.5; display: block; word-break: break-all; }
.card-arrow { font-size: 20px; color: #d1d5db; margin-top: 6px; flex-shrink: 0; }
</style>

View File

@ -1,69 +1,77 @@
<template>
<view class="page">
<!-- 用户卡片 -->
<view class="user-card">
<view class="avatar"></view>
<view class="avatar">{{ initial }}</view>
<view class="user-info">
<text class="user-name">张三</text>
<text class="user-role">操作员</text>
<text class="user-name">{{ user?.display_name || '未登录' }}</text>
</view>
<text class="arrow"></text>
</view>
<!-- 菜单 -->
<view class="menu-card">
<view v-for="item in menuItems" :key="item" class="menu-item">
<view v-for="item in ['工作统计', '设置', '帮助与反馈', '关于']" :key="item"
class="menu-item" @click="handleMenuClick(item)">
<text class="menu-text">{{ item }}</text>
<text class="arrow"></text>
<text class="menu-arrow"></text>
</view>
</view>
<view class="version">生产流转 v1.0.0</view>
<button class="logout-btn" @tap="handleLogout">退出登录</button>
<view class="version">{{ appVersion }}</view>
</view>
</template>
<script setup>
const menuItems = ["工作统计", "设置", "帮助与反馈", "关于"];
import { ref, computed, onMounted } from "vue";
const user = ref(null);
try { const r = uni.getStorageSync("user"); if (r) user.value = JSON.parse(r); } catch {}
const initial = computed(() => (user.value?.display_name || "?")[0]);
// 🚀 动态版本号 — 热更新后自动变化
const appVersion = ref("T1.0.2");
onMounted(() => {
try {
const sysInfo = uni.getSystemInfoSync();
appVersion.value = sysInfo.appWgtVersion || sysInfo.appVersion || "T1.0.2";
} catch {}
});
function handleMenuClick(item) {
switch (item) {
case "关于":
uni.showModal({
title: "关于 Track",
content: "Track 生产流转管理系统\n当前版本" + appVersion.value + "\n核心架构FastAPI + Vue3 + uni-app",
showCancel: false,
});
break;
case "帮助与反馈":
uni.showToast({ title: "反馈通道搭建中,敬请期待...", icon: "none" });
break;
// 工作统计、设置 暂不处理
}
}
function handleLogout() {
uni.removeStorageSync("token");
uni.removeStorageSync("access_token");
uni.removeStorageSync("refresh_token");
uni.removeStorageSync("user");
uni.reLaunch({ url: "/pages/login/login" });
}
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
.user-card {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.avatar {
width: 48px; height: 48px;
border-radius: 50%;
background: #dbeafe;
color: #2563EB;
font-size: 20px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.user-card { display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.avatar { width: 48px; height: 48px; border-radius: 50%; background: #dbeafe; color: #2563EB; font-size: 20px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.user-name { font-size: 16px; font-weight: 700; color: #1f2937; display: block; }
.user-role { font-size: 12px; color: #9ca3af; }
.arrow { color: #d1d5db; font-size: 20px; margin-left: auto; }
.menu-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f3f4f6;
}
.menu-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; }
.menu-item { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f3f4f6; }
.menu-item:last-child { border-bottom: none; }
.menu-text { font-size: 14px; color: #374151; }
.version { text-align: center; font-size: 12px; color: #d1d5db; margin-top: 32px; }
.menu-arrow { font-size: 18px; color: #d1d5db; }
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
</style>

View File

@ -0,0 +1,281 @@
<template>
<view class="swipe-fullscreen">
<!-- 顶部栏 -->
<view class="ss-topbar">
<view class="ss-back" @tap="$emit('back')"> 返回</view>
<view class="ss-title-group">
<text class="ss-title">{{ lanes[currentLane].label }}</text>
<text class="ss-step-hint">
步骤 {{ lanes[currentLane]._cardIdx + 1 }}/{{ lanes[currentLane].cards.length }}
<text v-if="lanes.length > 1"> · 左右滑切换分支 </text>
</text>
</view>
<view class="ss-topbar-right">
<view class="ss-overview-btn" @tap="$emit('overview')"> 全览</view>
</view>
</view>
<!-- 🚀 水平滑动切换分支主分支 / 分支1 / 分支2 ... -->
<swiper class="ss-swiper-h" :current="currentLane" @change="onLaneSwipe"
:style="{ height: swiperHeight + 'px' }" duration="250">
<swiper-item v-for="(lane, li) in lanes" :key="lane._key">
<!-- 🚀 垂直滑动当前分支的时间线 -->
<swiper class="ss-swiper-v" :current="lane._cardIdx" @change="onCardSwipe($event, li)"
duration="200" vertical :style="{ height: swiperHeight + 'px' }">
<swiper-item v-for="(card, ci) in lane.cards" :key="card._key">
<view class="ss-card-wrapper">
<view class="ss-card task-card"
:class="[card._isMain ? 'task-card-main' : 'task-card-branch', taskCardClass(card)]">
<view v-if="card._isMain" class="tc-ribbon tc-ribbon-main">主分支</view>
<view v-else class="tc-ribbon tc-ribbon-sub">{{ lane.label }}</view>
<view class="tc-head">
<view class="tc-head-left">
<text :class="card._isMain ? 'badge-main' : 'badge-sub'">{{ card._isMain ? '主分支' : lane.label }}</text>
<text v-if="card.is_rework" class="tag-rework-sm"> 返工</text>
<text v-if="card.status === 'ARCHIVED'" class="tag-archived-sm">📦 入库</text>
</view>
<text :class="['tc-status', statusColor(card.status)]">{{ statusLabel(card.status) }}</text>
</view>
<text class="tc-name">{{ card.task_name }}</text>
<view class="tc-meta">
<view class="tc-meta-row">
<text class="tc-meta-label">👤 负责人</text>
<text class="tc-meta-val">{{ formatUserName(card.assignee_id) || '未分配' }}</text>
</view>
</view>
<view v-if="card.remark" class="tc-remark-box">
<text class="tc-remark-label">📌 备注</text>
<text class="tc-remark-text">{{ card.remark }}</text>
</view>
<!-- 提交记录显示总数 + 可点击查看全部 -->
<view v-if="card.records && card.records.length" class="tc-records-link"
@tap.stop="$emit('viewRecords', card)">
<text class="tc-records-icon">📋</text>
<text class="tc-records-count"> {{ card.records.length }} 条提交记录</text>
<text class="tc-records-arrow">查看全部 </text>
</view>
<view class="tc-stats">
<text v-if="card.received_at" class="tc-stat"> 已接收</text>
<text v-if="card.completed_at" class="tc-stat">🏁 已完工</text>
</view>
<view class="tc-time"><text>创建: {{ fmtTime(card.created_at) }}</text></view>
</view>
</view>
</swiper-item>
</swiper>
</swiper-item>
</swiper>
<!-- 底部分支标签条 + 步骤进度 -->
<view class="ss-footer">
<view class="ss-lane-tabs">
<view v-for="(lane, i) in lanes" :key="'lt'+i"
:class="['ss-lane-tab', i === currentLane ? 'ss-lane-active' : '']"
@tap="currentLane = i">
<text :class="i === 0 ? 'ss-lane-tab-main' : 'ss-lane-tab-sub'">{{ lane.label }}</text>
<text class="ss-lane-count">{{ lane.cards.length }}</text>
</view>
</view>
<view class="ss-dots">
<view v-for="(c, i) in lanes[currentLane].cards" :key="'d'+i"
:class="['ss-dot', i === lanes[currentLane]._cardIdx ? 'ss-dot-active' : '', c._isMain ? 'ss-dot-main' : '']">
<text class="ss-dot-label">{{ i + 1 }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { formatUserName } from "../../../utils/format";
export default {
name: "TaskSwipeCards",
props: {
product: { type: Object, required: true },
},
emits: ["back", "overview", "viewRecords"],
data() {
return {
currentLane: 0,
swiperHeight: 600,
};
},
computed: {
branchLabelMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
const flatMap = {};
const flatten = (tasks) => {
if (!tasks) return;
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
};
flatten(this.product.task_tree);
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
if (isMainFn(t)) { map[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
else { spawnIndex++; const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`; map[t.id] = `分支 ${num}`; traverse(t.child_tasks, num); }
});
};
this.product.task_tree.forEach((t) => { map[t.id] = '主分支'; traverse(t.child_tasks, ''); });
return map;
},
lanes() {
const result = [];
if (!this.product || !this.product.task_tree) return result;
const mainLane = { _key: 'main', label: '主分支', cards: [], _cardIdx: 0 };
const sortTasks = (tasks) => {
if (!tasks) return [];
return [...tasks].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
};
// 🚀 终极递归收集算法
const followMain = (taskList, lane) => {
if (!taskList || !taskList.length) return;
const sorted = sortTasks(taskList);
for (const t of sorted) {
// 1. 无条件入列:既然进到了这个 lane就属于这个 lane 的卡片
lane.cards.push({ ...t, _key: t.id + '_' + lane._key, _isMain: lane._key === 'main' });
if (t.child_tasks && t.child_tasks.length) {
// 1. 同一分支线性延续
const nextOnThisLane = t.child_tasks.filter(c =>
c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY'
);
followMain(nextOnThisLane, lane);
// 2. 凡是 SPAWN必定开辟新分支 (不限层级)
const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN');
for (const sc of spawns) {
const blabel = (this.branchLabelMap && this.branchLabelMap[sc.id]) || '协助分支';
const branchLane = { _key: 'branch_' + sc.id, label: blabel, cards: [], _cardIdx: 0 };
result.push(branchLane); // 先占坑:父分支排在前面
followMain([sc], branchLane); // 再递归:孙子分支自然排在后面
}
}
}
};
result.push(mainLane);
followMain(this.product.task_tree, mainLane);
return result;
},
},
mounted() {
try {
const info = uni.getSystemInfoSync();
this.swiperHeight = (info.windowHeight || 600) - 130;
} catch (e) { /* ignore */ }
},
methods: {
formatUserName,
onLaneSwipe(e) { this.currentLane = e.detail.current; },
onCardSwipe(e, laneIdx) {
if (this.lanes[laneIdx]) {
this.$set(this.lanes[laneIdx], '_cardIdx', e.detail.current);
}
},
taskCardClass(t) {
if (t.status === 'ARCHIVED') return 'card-archived';
if (t.status === 'CANCELED') return 'card-canceled';
return '';
},
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "sc-yellow"; case "WIP": return "sc-blue"; case "COMPLETED": return "sc-green"; case "REJECTED": return "sc-red"; case "CANCELED": return "sc-canceled"; case "ARCHIVED": return "sc-purple"; default: return "sc-gray"; } },
fmtTime(d) { if (!d) return ""; const dt = new Date(d); const pad = (n) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
},
};
</script>
<style scoped>
.ss-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
/* 顶部栏 */
.ss-topbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; padding-top: calc(8px + env(safe-area-inset-top)); background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb; flex-shrink: 0; z-index: 10; }
.ss-back { font-size: 13px; font-weight: 700; color: #2563eb; padding: 5px 10px; background: #eff6ff; border-radius: 8px; }
.ss-title-group { display: flex; flex-direction: column; align-items: center; gap: 0; flex: 1; }
.ss-title { font-size: 15px; font-weight: 800; color: #1f2937; }
.ss-step-hint { font-size: 10px; color: #9ca3af; }
.ss-topbar-right { display: flex; gap: 6px; }
.ss-overview-btn { font-size: 12px; font-weight: 700; color: #2563eb; padding: 5px 10px; background: #dbeafe; border-radius: 8px; }
/* 滑动区域 */
.ss-swiper-h { width: 100%; flex: 1; min-height: 220px; }
.ss-swiper-v { width: 100%; }
.ss-card-wrapper { display: flex; align-items: flex-start; justify-content: center; padding: 10px 16px; height: 100%; min-height: 220px; box-sizing: border-box; }
/* 任务卡片 */
.ss-card { position: relative; width: 100%; max-width: 420px; background: #fff; border-radius: 20px; padding: 18px 22px; box-shadow: 0 8px 24px rgba(0,0,0,0.1); display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow-y: auto; }
.task-card { border-left: 10px solid #3b82f6; }
.task-card-main { border-left-color: #2563eb; border-left-width: 12px; }
.task-card-branch { border-left-color: #7c3aed; }
.task-card.card-archived { border-left-color: #8b5cf6; opacity: 0.75; }
.task-card.card-canceled { border-left-color: #9ca3af; opacity: 0.5; }
.tc-ribbon { position: absolute; top: 14px; right: 14px; font-size: 10px; padding: 3px 10px; border-radius: 6px; color: #fff; font-weight: 700; z-index: 2; }
.tc-ribbon-main { background: #2563eb; }
.tc-ribbon-sub { background: #7c3aed; }
.tc-head { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
.tc-head-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 6px; background: #ef4444; color: #fff; font-weight: 700; }
.tag-archived-sm { font-size: 10px; padding: 2px 6px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; border: 1px dashed #a78bfa; }
.tc-status { font-size: 12px; padding: 3px 10px; border-radius: 10px; font-weight: 700; }
.tc-status.sc-yellow { background: #fef3c7; color: #b45309; }
.tc-status.sc-blue { background: #dbeafe; color: #1d4ed8; }
.tc-status.sc-green { background: #dcfce7; color: #15803d; }
.tc-status.sc-red { background: #fce4ec; color: #be123c; }
.tc-status.sc-purple { background: #ede9fe; color: #7c3aed; }
.tc-status.sc-canceled { background: #f3f4f6; color: #9ca3af; text-decoration: line-through; }
.tc-name { font-size: 20px; font-weight: 800; color: #1f2937; line-height: 1.3; }
.tc-meta { display: flex; flex-direction: column; gap: 4px; }
.tc-meta-row { display: flex; align-items: center; gap: 8px; }
.tc-meta-label { font-size: 13px; color: #9ca3af; }
.tc-meta-val { font-size: 14px; color: #374151; font-weight: 600; }
.tc-remark-box { padding: 10px 12px; border-radius: 10px; display: flex; flex-direction: column; gap: 3px; background: #fefce8; border: 1px solid #fef08a; }
.tc-remark-label { font-size: 12px; font-weight: 700; color: #a16207; }
.tc-remark-text { font-size: 14px; color: #374151; line-height: 1.5; }
/* 提交记录链接 */
.tc-records-link { display: flex; align-items: center; gap: 6px; padding: 10px 14px; background: linear-gradient(135deg, #eff6ff, #dbeafe); border-radius: 10px; border: 1px solid #bfdbfe; }
.tc-records-icon { font-size: 16px; }
.tc-records-count { flex: 1; font-size: 13px; font-weight: 700; color: #2563eb; }
.tc-records-arrow { font-size: 12px; color: #2563eb; font-weight: 600; }
.tc-stats { display: flex; flex-wrap: wrap; gap: 6px; }
.tc-stat { font-size: 11px; padding: 3px 8px; border-radius: 8px; background: #f3f4f6; color: #6b7280; font-weight: 600; }
.tc-time { font-size: 11px; color: #9ca3af; padding-top: 6px; border-top: 1px solid #f3f4f6; }
/* 底部 */
.ss-footer { flex-shrink: 0; background: rgba(255,255,255,0.95); padding-bottom: calc(6px + env(safe-area-inset-bottom)); }
.ss-lane-tabs { display: flex; gap: 4px; padding: 6px 12px; overflow-x: auto; }
.ss-lane-tab { display: flex; align-items: center; gap: 4px; padding: 5px 12px; border-radius: 14px; background: #f3f4f6; font-size: 12px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
.ss-lane-tab-main { color: #2563eb; }
.ss-lane-tab-sub { color: #7c3aed; }
.ss-lane-active { background: #2563eb; }
.ss-lane-active .ss-lane-tab-main { color: #fff; }
.ss-lane-active .ss-lane-tab-sub { color: #ddd6fe; }
.ss-lane-count { font-size: 10px; color: #9ca3af; background: #fff; padding: 1px 6px; border-radius: 8px; }
.ss-lane-active .ss-lane-count { color: #2563eb; }
.ss-dots { display: flex; justify-content: center; gap: 5px; padding: 4px 0; overflow-x: auto; }
.ss-dot { width: 20px; height: 20px; border-radius: 10px; background: #e5e7eb; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.2s; }
.ss-dot-active { width: 26px; height: 26px; border-radius: 13px; background: #2563eb; }
.ss-dot-main { background: #bfdbfe; }
.ss-dot-label { font-size: 9px; font-weight: 700; color: #9ca3af; }
.ss-dot-active .ss-dot-label { color: #fff; }
</style>

View File

@ -1,45 +1,53 @@
<template>
<view class="tree-canvas-container">
<view class="tree-canvas-fullscreen">
<view class="tc-toolbar">
<view class="tc-toolbar-left">
<view class="tc-back-btn" @tap="$emit('back')"> 返回</view>
<text class="tc-title">🌳 流转蓝图</text>
</view>
<view class="tc-toolbar-right">
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</view>
<view class="tc-zoom-btn tc-fit-btn" @tap="fitAll"> 全览</view>
</view>
</view>
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转记录</text>
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转记录</text>
</view>
<template v-else>
<view class="canvas-hint">🖐 2D 流转蓝图支持全视角自由拖拽探索</view>
<movable-area class="tree-movable-area" :style="{ width: viewportW + 'px', height: areaHeight + 'px' }">
<movable-view class="tree-movable-view" direction="all"
:x="mvX" :y="mvY" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
:inertia="true" :friction="2" :damping="40"
@change="onChange">
<movable-area class="tree-movable-area">
<movable-view
class="tree-movable-view"
direction="all"
:x="0" :y="0"
:scale="true" :scale-min="0.3" :scale-max="2"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
>
<view class="canvas-inner">
<!-- 节点卡片 -->
<!-- 🔧 连线 -->
<view v-for="(line, i) in lines" :key="'l'+i"
class="cn-line" :style="lineStyle(line)"
:class="line.dashed ? 'cn-line-dashed' : 'cn-line-solid'" />
<!-- 🔧 连线末端箭头 -->
<view v-for="(arrow, i) in arrows" :key="'a'+i"
class="cn-arrow" :style="{ left: arrow.x + 'px', top: arrow.y + 'px', transform: 'rotate(' + arrow.rot + 'deg)' }"
:class="arrow.dashed ? 'cn-arrow-dashed' : 'cn-arrow-solid'" />
<!-- 🚀 十字星节点 -->
<view v-for="node in treeNodes" :key="node.id"
class="canvas-node" :class="statusColor(node.status)"
class="canvas-node" :class="[statusColor(node.status), node._isMain ? 'node-main' : 'node-sub']"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@tap="node.records && node.records.length && $emit('viewRecords', node)">
@tap="handleNodeTap(node)">
<view class="cn-header">
<text :class="node.parent_task_id ? 'badge-sub' : 'badge-main'">{{ node.parent_task_id ? branchLabelMap[node.id] || '分支' : '主分支' }}</text>
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
<text v-if="node.is_rework" class="tag-rework"> 返工工序</text>
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
<view class="cn-footer">
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止分支</text>
<text v-else-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 已入库</text>
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}条记录 </text>
<text :class="node._isMain ? 'badge-main' : 'badge-sub'">{{ node._isMain ? '主分支' : node._blabel }}</text>
<text :class="['cn-badge-mini', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">{{ formatUserName(node.assignee_id) || '—' }}</text>
<text v-if="!node._isMain && node.parent && !node.parent._isMain" class="cn-nested-hint">协助: {{ formatUserName(node.parent.assignee_id) || '' }}</text>
<text class="cn-time"> {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
<text v-if="node.records && node.records.length" class="cn-records-mini">{{ node.records.length }}</text>
</view>
</view>
</movable-view>
</movable-area>
</template>
@ -47,150 +55,272 @@
</template>
<script>
import { formatUserName } from "../../../utils/format";
export default {
name: "TreeCanvas",
props: {
product: { type: Object, required: true }
props: { product: { type: Object, required: true } },
emits: ["viewRecords", "back", "swipe"],
data() {
return {
mvX: 0, mvY: 0,
viewportW: 375, viewportH: 600,
areaHeight: 500,
};
},
mounted() {
try { const info = uni.getSystemInfoSync(); this.viewportW = info.windowWidth || 375; this.viewportH = info.windowHeight || 600; } catch {}
this.areaHeight = Math.max(this.viewportH - 100, 400);
this.$nextTick(() => this.fitAll());
},
emits: ["viewRecords"],
computed: {
// ============================================================
// 🚀 父子相对坐标延伸算法v3 — 无重叠 Y 轴栈式布局)
// ============================================================
treeNodes() {
if (!this.product || !this.product.task_tree) return [];
const CARD_W = 160, CARD_H = 80, GAP_X = 24, VERTICAL_GAP = 28, MAIN_GAP = 40;
const CENTER_X = 0;
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
const nodes = [];
const rowMaxX = {};
let currentMaxYIdx = -1;
// 拍平 + 浅拷贝
const flatMap = {};
const flatten = (tasks) => {
if (!tasks) return;
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
};
flatten(this.product.task_tree);
const allTasks = [];
const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { const copy = { ...t }; allTasks.push(copy); flatMap[copy.id] = copy; walk(t.child_tasks); }); };
walk(this.product.task_tree);
allTasks.forEach(t => {
if (t.child_tasks && t.child_tasks.length) t.child_tasks = t.child_tasks.map(c => flatMap[c.id]).filter(Boolean);
if (t.parent_task_id) t.parent = flatMap[t.parent_task_id] || null;
});
const layoutNode = (t) => {
if (t._visited) return;
t._visited = true;
// 🚀 主干判定
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
if (!t.parent_task_id) {
// 规则A根节点 → 新行 X=0
currentMaxYIdx++;
t._yIdx = currentMaxYIdx;
t._xIdx = 0;
} else {
const parent = flatMap[t.parent_task_id];
if (parent && !parent._visited) layoutNode(parent);
const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN');
if (type === 'SPAWN') {
t._yIdx = parent._yIdx;
t._xIdx = (rowMaxX[t._yIdx] !== undefined ? rowMaxX[t._yIdx] : 0) + 1;
} else if (type === 'TRANSFER' || type === 'MAIN') {
currentMaxYIdx++;
t._yIdx = currentMaxYIdx;
t._xIdx = 0;
} else if (type === 'RECOVERY') {
currentMaxYIdx++;
t._yIdx = currentMaxYIdx;
t._xIdx = parent._xIdx;
}
}
rowMaxX[t._yIdx] = Math.max(rowMaxX[t._yIdx] || 0, t._xIdx);
t.x = 40 + t._xIdx * (CARD_W + GAP_X);
t.y = 20 + t._yIdx * (CARD_H + GAP_Y);
nodes.push(t);
if (t.child_tasks && t.child_tasks.length) {
t.child_tasks
.sort((a, b) => (a.task_type === 'TRANSFER' ? -1 : 1))
.forEach(child => layoutNode(child));
}
};
this.product.task_tree.forEach(root => layoutNode(root));
return nodes;
},
branchLabelMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
// 分支编号
const branchLabelMap = {};
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
if (t.task_type !== 'SPAWN') {
map[t.id] = '分支';
traverse(t.child_tasks, prefix);
} else {
spawnIndex++;
const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`;
map[t.id] = `分支 ${num}`;
traverse(t.child_tasks, num);
}
let si = 0;
tasks.forEach(t => {
if (isMainFn(t)) { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
else { si++; const n = prefix ? prefix + '.' + si : '' + si; branchLabelMap[t.id] = '分支 ' + n; traverse(t.child_tasks, n); }
});
};
this.product.task_tree.forEach((t) => {
map[t.id] = '主分支';
traverse(t.child_tasks, '');
const rootCopies = this.product.task_tree.map(t => flatMap[t.id]).filter(Boolean);
rootCopies.forEach(t => { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, ''); });
// 🚀 构建真实的全局 childMap按 parent_task_id
const childMap = {};
allTasks.forEach(t => {
const pid = t.parent_task_id || '';
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(t);
});
return map;
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)));
// 主管分类
const mains = [];
allTasks.forEach(t => { if (isMainFn(t)) mains.push(t); });
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
// 🚀 预计算每个节点的子树高度(兄弟累加取 max首子与父平齐
const calcSubtreeHeight = (nodeId) => {
const children = childMap[nodeId] || [];
if (children.length === 0) return CARD_H + VERTICAL_GAP;
const totalChildrenHeight = children.reduce((sum, c) => sum + calcSubtreeHeight(c.id), 0);
return Math.max(CARD_H + VERTICAL_GAP, totalChildrenHeight);
};
const nodes = [];
// 🚀 递归放置算法:首个子节点与父节点水平平齐
// 同级子节点按 index 错开 Y 轴,各占其子树高度防止重叠
const placeChildren = (parentNode, currentSide) => {
const children = childMap[parentNode.id] || [];
// 🚀 首个子节点 Y 起始:与父节点水平平齐
let startY = parentNode.y;
children.forEach((child, index) => {
const subtreeH = calcSubtreeHeight(child.id);
// 主干的第一层协助分支:均衡分发左右
let side = currentSide;
if (parentNode._isMain) {
side = index % 2 === 0 ? 'right' : 'left';
}
// 🚀 X 轴:永远基于真实父亲向外延伸
const childX = side === 'right'
? parentNode.x + CARD_W + GAP_X
: parentNode.x - CARD_W - GAP_X;
// 🚀 Y 轴:首子与父平齐,后续兄弟向下累加
const childY = startY;
const childNode = {
...child,
x: childX, y: childY,
_isMain: false, _side: side,
_blabel: branchLabelMap[child.id] || '分支',
_rootMainId: parentNode._isMain ? parentNode.id : parentNode._rootMainId,
};
nodes.push(childNode);
flatMap[childNode.id] = childNode;
// 递归放置孙子节点(沿相同方向)
placeChildren(childNode, side);
// 🚀 下一个兄弟节点跳到当前子树高度之后calcSubtreeHeight 已含 GAP
startY += subtreeH;
});
};
// 🚀 渲染入口:主干节点也按子树深度排布
let mainStartY = 0;
mains.forEach((m, mi) => {
const subtreeH = calcSubtreeHeight(m.id);
const mainNode = { ...m, x: CENTER_X, y: mainStartY, _isMain: true, _blabel: '主分支', _rootMainId: m.id };
nodes.push(mainNode);
flatMap[mainNode.id] = mainNode;
placeChildren(mainNode, null);
mainStartY += subtreeH + MAIN_GAP;
});
// 全局偏移(给左侧分支预留空间)
const minX = Math.min(...nodes.map(n => n.x));
const minY = Math.min(...nodes.map(n => n.y));
const padding = 40;
nodes.forEach(n => { n.x += Math.abs(minX) + CARD_W * 2 + padding; n.y += padding - Math.min(0, minY); });
return nodes;
},
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.x)) + 300); },
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.y)) + 300); }
// ============================================================
// 🚀 连线计算:中央 → 分支
// ============================================================
// 🔧 连线:卡片边缘到边缘,动态追踪 Y 坐标,杜绝箭头悬空
lines() {
const result = [];
const flatMap = {};
this.treeNodes.forEach(n => flatMap[n.id] = n);
this.treeNodes.forEach(n => {
if (n._isMain) return;
// 🚀 连线到真实直接父节点(树枝延伸)
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
if (!parent) return;
const isLeft = n.x < parent.x;
const startX = isLeft ? n.x + 160 : parent.x + 160;
const endX = isLeft ? parent.x : n.x;
// 🚀 动态计算真实的 Y 坐标对接点
const startY = isLeft ? n.y + 40 : parent.y + 40;
const endY = isLeft ? parent.y + 40 : n.y + 40;
result.push({
x1: startX, y1: startY,
x2: endX, y2: endY,
dashed: parent.status === 'COMPLETED' || parent.status === 'ARCHIVED',
});
});
return result;
},
arrows() {
const result = [];
const flatMap = {};
this.treeNodes.forEach(n => flatMap[n.id] = n);
this.treeNodes.forEach(n => {
if (n._isMain) return;
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
if (!parent) return;
const isLegacy = parent && (parent.status === 'COMPLETED' || parent.status === 'ARCHIVED');
const isLeft = n.x < parent.x;
result.push({
x: isLeft ? n.x + 160 : n.x - 6,
y: n.y + 40 - 2,
rot: isLeft ? 180 : 0,
dashed: isLegacy,
});
});
return result;
},
// ============================================================
canvasWidth() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.x)) + 400; },
canvasHeight() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.y)) + 300; },
},
methods: {
getTaskRemark(t) {
if (!t) return "";
if (t.remark) return t.remark;
if (t.records && t.records.length > 0) {
const rec = t.records.find(r => r.remark && r.remark.trim().length > 0);
if (rec) return rec.remark;
formatUserName,
// 🚀 手势结束后同步位置
onChange(e) {
if (e?.detail?.x !== undefined) this.mvX = e.detail.x;
if (e?.detail?.y !== undefined) this.mvY = e.detail.y;
},
handleNodeTap(node) {
if (node.records && node.records.length > 0) {
this.$emit('viewRecords', node);
} else {
uni.showToast({ title: '当前节点暂无流转记录', icon: 'none' });
}
return "";
},
lineStyle(line) {
const dx = line.x2 - line.x1; const dy = line.y2 - line.y1;
const len = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' };
// 🚀 全览居中:固定 100% 比例,内容整体在视口中居中
fitAll() {
const canvasW = this.canvasWidth;
const canvasH = this.canvasHeight;
const viewW = this.viewportW;
const viewH = this.areaHeight;
this.mvX = (viewW - canvasW) / 2;
this.mvY = (viewH - canvasH) / 2;
},
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; case "CANCELED": return "s-canceled"; default: return "s-gray"; } }
fmtDate(d) { if (!d) return ""; const dt = new Date(d); return (dt.getMonth() + 1) + '/' + dt.getDate(); },
statusLabel(s) { const m = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return m[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; case "CANCELED": return "s-canceled"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } },
lineStyle(line) { const dx = line.x2 - line.x1; const dy = line.y2 - line.y1; const len = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx) * 180 / Math.PI; return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' }; }
}
};
</script>
<style scoped>
.tree-canvas-container { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #ebedf0; border-radius: 12px; margin-top: 10px; box-shadow: inset 0 0 10px rgba(0,0,0,0.02); }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 40px 0; }
.tree-canvas-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
.tc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; padding-top: calc(10px + env(safe-area-inset-top)); background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb; flex-shrink: 0; z-index: 999; }
.tc-toolbar-left { display: flex; align-items: center; gap: 12px; }
.tc-back-btn { font-size: 14px; font-weight: 700; color: #2563eb; padding: 6px 12px; background: #eff6ff; border-radius: 8px; }
.tc-title { font-size: 16px; font-weight: 800; color: #1f2937; }
.tc-hint { font-size: 10px; color: #9ca3af; }
.tc-toolbar-right { display: flex; align-items: center; gap: 8px; }
.tc-zoom-btn { width: 36px; height: 36px; border-radius: 10px; background: #f3f4f6; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; color: #374151; }
.tc-zoom-btn:active { background: #e5e7eb; }
.tc-zoom-label { font-size: 12px; font-weight: 600; color: #6b7280; min-width: 42px; text-align: center; }
.tc-mode-btn { width: auto; padding: 0 10px; font-size: 12px; background: #ede9fe; color: #7c3aed; }
.tc-fit-btn { width: auto; padding: 0 12px; font-size: 13px; background: #dbeafe; color: #2563eb; }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
.zero-icon { font-size: 40px; margin-bottom: 10px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 8px 0; background: #fff; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; }
.tree-movable-area { flex: 1; width: 100%; background-color: #f8fafc; background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); background-size: 20px 20px; }
.canvas-inner { position: absolute; left: 0; top: 0; }
.canvas-node { position: absolute; width: 320px; min-height: 460px; background: #ffffff; border-radius: 20px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06); border-left: 12px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 10px; }
.tree-movable-area { width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; overflow: hidden; }
.canvas-inner { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
/* 🚀 movable-view 填充整个父容器,确保空白区域也能响应拖拽/缩放手势 */
.tree-movable-view { width: 100%; height: 100%; }
/* 🔧 连线(禁止吞事件) */
.cn-line { position: absolute; height: 2px; pointer-events: none; }
.cn-line-solid { background: #9ca3af; }
.cn-line-dashed { background: repeating-linear-gradient(90deg, #fdba74 0, #fdba74 6px, transparent 6px, transparent 10px); }
/* 🔧 连线箭头(禁止吞事件) */
.cn-arrow { position: absolute; width: 0; height: 0; border-left: 6px solid #9ca3af; border-top: 4px solid transparent; border-bottom: 4px solid transparent; pointer-events: none; }
.cn-arrow-dashed { border-left-color: #fdba74; }
/* 🔧 极简卡片 160x80 */
.canvas-node { position: absolute; width: 160px; min-height: auto; padding: 10px 12px; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 4px; font-size: 11px; }
.canvas-node.node-sub { border-left-color: #7c3aed; }
.canvas-node.s-yellow { border-left-color: #f59e0b; }
.canvas-node.s-blue { border-left-color: #3b82f6; }
.canvas-node.s-green { border-left-color: #22c55e; }
.canvas-node.s-red { border-left-color: #ef4444; }
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.5; filter: grayscale(0.6); }
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.4; }
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.65; }
.cn-header { display: flex; align-items: center; justify-content: space-between; }
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
.cn-name { font-size: 24px; font-weight: 800; color: #1f2937; }
.cn-badge { font-size: 14px; padding: 4px 12px; border-radius: 8px; font-weight: 700; }
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
.cn-badge.s-red { background: #fce4ec; color: #be123c; }
.cn-assignee { font-size: 16px; color: #6b7280; font-weight: 500; margin-top: 10px; }
.tag-rework { font-size: 13px; color: #fff; background: #ef4444; padding: 4px 10px; border-radius: 6px; display: inline-block; width: max-content; }
.cn-remark { font-size: 16px; color: #a16207; background: #fefce8; padding: 16px; border-radius: 8px; border: 1px solid #fef08a; line-height: 1.5; word-break: break-all; min-height: 100px; white-space: normal; margin-top: 16px; }
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 14px; border-top: 1px dashed #e5e7eb; }
.cn-end { font-size: 13px; font-weight: 700; color: #16a34a; }
.cn-end-wh { color: #7c3aed; }
.cn-records { font-size: 14px; color: #2563eb; font-weight: 700; background: #eff6ff; padding: 4px 12px; border-radius: 12px; }
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; transform-origin: 0 0; }
.badge-main { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #2563eb; color: #fff; font-weight: 700; }
.badge-sub { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
.cn-badge-mini { font-size: 9px; padding: 1px 6px; border-radius: 4px; font-weight: 700; }
.cn-badge-mini.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge-mini.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge-mini.s-green { background: #dcfce7; color: #15803d; }
.cn-badge-mini.s-red { background: #fce4ec; color: #be123c; }
.cn-badge-mini.s-archived { background: #ede9fe; color: #7c3aed; }
.cn-assignee { font-size: 10px; color: #6b7280; }
.cn-nested-hint { font-size: 8px; color: #a78bfa; font-weight: 600; }
.cn-time { font-size: 9px; color: #9ca3af; }
.cn-records-mini { font-size: 9px; color: #2563eb; background: #eff6ff; padding: 1px 6px; border-radius: 6px; display: inline-block; width: max-content; }
</style>

View File

@ -1,5 +1,5 @@
<template>
<view class="workspace-area">
<view class="workspace-area" :class="{ 'is-locked': !!lockedTaskId }">
<!-- 0任务 -->
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
@ -25,7 +25,7 @@
<view class="tli-left">
<text class="tag-branch">{{ branchLabelsMap[t.id] || '' }}</text>
<text class="tli-name">{{ t.task_name }}</text>
<text class="tli-assignee"> {{ t.assignee_id || '未分配' }}</text>
<text class="tli-assignee"> {{ formatUserName(t.assignee_id) || '未分配' }}</text>
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
</view>
<view class="tli-right">
@ -49,7 +49,7 @@
<text class="tag-branch">{{ branchLabelsMap[lockedTask.id] || '' }}</text>
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">返工</text>
<text v-if="lockedTask.parent_task_id && lockedTask.status === 'WIP'" class="sub-branch-end"
<text v-if="lockedTask.parent_task_id && lockedTask.task_type === 'SPAWN' && lockedTask.status === 'WIP'" class="sub-branch-end"
@tap="$emit('action', { task: lockedTask, type: 'end' })">🛑 结束协助</text>
</view>
<text class="fc-name">{{ lockedTask.task_name }}</text>
@ -60,13 +60,13 @@
</view>
<view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up">
<text class="fc-link-label"> 上游工序</text>
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} {{ parentTaskOf(lockedTask).assignee_id || '—' }}</text>
<text class="fc-link-label">{{ isNestedSpawn ? '⬆ 上游协助 (嵌套)' : '⬆ 上游工序' }}</text>
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} {{ formatUserName(parentTaskOf(lockedTask).assignee_id) || '—' }}</text>
</view>
<view v-if="lockedTask.child_tasks && lockedTask.child_tasks.length" class="fc-link fc-down">
<text class="fc-link-label"> 下游分支 ({{ lockedTask.child_tasks.length }})</text>
<text v-for="c in lockedTask.child_tasks" :key="c.id" class="fc-link-name">
· {{ c.task_name }} {{ c.assignee_id || '' }}
· {{ c.task_name }} {{ formatUserName(c.assignee_id) || '' }}
<text v-if="c.status==='COMPLETED'" class="branch-done">已完成</text>
<text v-else-if="c.status==='ARCHIVED'" class="branch-done">📦已入库</text>
</text>
@ -102,6 +102,7 @@
</template>
<script>
import { formatUserName } from "../../../utils/format";
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
export default {
@ -110,11 +111,29 @@ export default {
product: { type: Object, default: null },
currentUserId: { type: String, default: "" },
currentUsername: { type: String, default: "" },
initialLockTaskId: { type: String, default: "" },
},
emits: ["action", "viewRecords"],
data() {
return { lockedTaskId: null };
},
watch: {
initialLockTaskId: {
immediate: true,
handler(id) {
if (id && this.focusTasks.some(t => t.id === id)) {
this.lockedTaskId = id;
}
},
},
product: {
handler() {
if (this.initialLockTaskId && this.focusTasks.some(t => t.id === this.initialLockTaskId)) {
this.lockedTaskId = this.initialLockTaskId;
}
},
},
},
computed: {
taskMap() {
const m = {};
@ -124,6 +143,7 @@ export default {
},
focusTasks() {
const result = [];
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
const walk = (tasks) => {
if (!tasks) return;
for (const t of tasks) {
@ -133,8 +153,8 @@ export default {
};
if (this.product) walk(this.product.task_tree);
result.sort((a, b) => {
const aIsMain = !a.parent_task_id || a.task_type !== 'SPAWN';
const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
const aIsMain = isMainFn(a);
const bIsMain = isMainFn(b);
if (aIsMain && !bIsMain) return -1;
if (!aIsMain && bIsMain) return 1;
return new Date(a.created_at) - new Date(b.created_at);
@ -151,14 +171,32 @@ export default {
if (!parent) return false;
return parent.assignee_id == this.currentUserId || parent.assignee_id == this.currentUsername;
},
// 🚀 嵌套协助判定:直接父任务不是主线 → 孙子/曾孙协助
isNestedSpawn() {
if (!this.lockedTask || !this.lockedTask.parent_task_id) return false;
const parent = this.taskMap[this.lockedTask.parent_task_id];
if (!parent) return false;
return parent.parent_task_id && parent.task_type !== 'TRANSFER' && parent.task_type !== 'RECOVERY';
},
branchLabelsMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
const flatMap = {};
const flatten = (tasks) => {
if (!tasks) return;
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
};
flatten(this.product.task_tree);
// 🚀 仅凭基因字段判定,移除危险的状态兜底
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
if (t.task_type !== 'SPAWN') {
if (isMainFn(t)) {
map[t.id] = '主分支';
traverse(t.child_tasks, prefix);
} else {
@ -177,6 +215,7 @@ export default {
},
},
methods: {
formatUserName,
statusLabel(s) { return STATUS_MAP[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
countTasks(tree) { return tree ? tree.reduce((s, t) => s + 1 + this.countTasks(t.child_tasks), 0) : 0; },
@ -190,11 +229,12 @@ export default {
</script>
<style scoped>
.workspace-area { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.workspace-area { height: auto; display: flex; flex-direction: column; }
.workspace-area.is-locked { height: 100vh; overflow: hidden; }
.list-header { display: flex; align-items: baseline; justify-content: space-between; padding: 8px 4px; flex-shrink: 0; }
.list-title { font-size: 14px; font-weight: 700; color: #1f2937; }
.list-hint { font-size: 11px; color: #9ca3af; }
.task-list { flex: 1; overflow-y: auto; min-height: 0; }
.task-list { height: auto; }
.task-list-item { display: flex; align-items: flex-start; justify-content: space-between; background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 4px solid transparent; }
.task-list-item.s-yellow { border-left-color: #f59e0b; }
.task-list-item.s-blue { border-left-color: #3b82f6; }

View File

@ -4,45 +4,61 @@
<view v-if="error" class="error-box">{{ error }}</view>
<template v-if="product && !loading">
<view class="overall-bar" @tap="handleOverallBarClick">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
<text v-if="product.task_tree && product.task_tree.length" class="overall-arrow"></text>
</view>
<!-- 工作区模式显示产品信息栏 -->
<template v-if="currentMode === 'workspace'">
<view :key="'prod-card-' + dictVersion">
<view class="overall-bar" @tap="handleOverallBarClick">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow"></text>
</view>
<view class="card">
<view class="card-header">
<text class="card-title">📦 产品信息</text>
<view class="card-header-right">
<text :class="['badge', statusColor(product.status)]">{{ statusLabel(product.status) }}</text>
<text class="mode-toggle" @tap="toggleMode">{{ currentMode === 'workspace' ? '🌳 流转树' : '🛠 工作区' }}</text>
<text class="edit-btn" @tap="openEditProduct"></text>
<view class="card">
<view class="card-header">
<text class="card-title">📦 产品信息</text>
<view class="card-header-right">
<text class="mode-toggle" @tap="toggleMode">{{ modeToggleLabel }}</text>
<text class="edit-btn" @tap="openEditProduct"></text>
<text class="print-label-btn" @tap="printLabel">🖨 打印标签</text>
</view>
</view>
<view class="info-grid">
<view class="info-item"><text class="label">身份证</text><text class="value sn">{{ product.serial_number }}</text></view>
<view class="info-item" v-if="product.external_serial"><text class="label">产品序列号</text><text class="value sn">{{ product.external_serial }}</text></view>
<view class="info-item"><text class="label">物料名称</text><text class="value">{{ product.material_name || product.material_id || '—' }}</text></view>
<view class="info-item"><text class="label">规格型号</text><text class="value">{{ product.spec_model || '—' }}</text></view>
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
<view class="info-item" v-if="product.current_location_id">
<text class="label">当前位置</text>
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ formatUserName(product.current_location_id) }}</text>
</view>
</view>
</view>
<view class="info-grid">
<view class="info-item"><text class="label">身份证</text><text class="value sn">{{ product.serial_number }}</text></view>
<view class="info-item"><text class="label">物料名称</text><text class="value">{{ product.material_name || product.material_id || '—' }}</text></view>
<view class="info-item"><text class="label">规格型号</text><text class="value">{{ product.spec_model || '—' }}</text></view>
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
<view class="info-item" v-if="product.current_location_id">
<text class="label">当前位置</text>
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}</text>
</view>
</view>
</view>
<!-- 📤 仓库转出横幅当前用户有活跃任务时隐藏 -->
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
<text class="wt-icon">📤</text>
<text class="wt-text">该产品在仓库中 点击此处转出并派发给指定人员</text>
</view>
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
<text class="wt-icon">📤</text>
<text class="wt-text">该产品在仓库中 点击此处转出并派发给指定人员</text>
</view>
</template>
<!-- 工作区视图 -->
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
:currentUserId="currentUserId" :currentUsername="currentUsername"
:initialLockTaskId="autoLockTaskId"
:key="'wa-' + dictVersion"
@action="handleTaskAction" @viewRecords="handleViewRecords" />
<TreeCanvas v-if="currentMode === 'tree'" :product="product" @viewRecords="handleViewRecords" />
<!-- 📇 流转卡片探探式单张滑动 -->
<TaskSwipeCards v-if="currentMode === 'swipe'" :product="product"
:key="'sw-' + dictVersion"
@back="currentMode = 'workspace'" @overview="currentMode = 'tree'"
@viewRecords="handleViewRecords" />
<!-- 🌳 流转树全屏独立视图 -->
<TreeCanvas v-if="currentMode === 'tree'" :product="product" :key="'tc-' + dictVersion" @viewRecords="handleViewRecords" @back="currentMode = 'workspace'" @swipe="currentMode = 'swipe'" />
</template>
<!-- 状态定调 -->
@ -148,20 +164,53 @@
</template>
</view>
</view>
<!-- 💬 留言悬浮按钮 -->
<view class="msg-fab" @tap="openMsgDrawer">
<text class="msg-fab-icon">💬</text>
<text v-if="msgUnreadCount" class="msg-fab-badge">{{ msgUnreadCount }}</text>
</view>
<!-- 💬 留言板底部抽屉 -->
<view v-if="showMsgDrawer" class="msg-drawer-overlay" @tap="closeMsgDrawer">
<view class="message-board-drawer" @tap.stop>
<view class="mb-drawer-handle"></view>
<view class="mb-title">💬 协同留言板</view>
<scroll-view scroll-y class="mb-scroll-area" :scroll-into-view="bottomMsgId" scroll-with-animation>
<view v-for="msg in messages" :key="msg.id" class="mb-item" :id="'msg-' + msg.id">
<view class="mb-avatar">{{ formatUserAvatar(msg.operator_id) }}</view>
<view class="mb-content-wrapper">
<view class="mb-header-info">
<text class="mb-name">{{ formatUserName(msg.operator_id) }}</text>
<text class="mb-time">{{ fmtMsgTime(msg.created_at) }}</text>
</view>
<view class="mb-bubble">{{ msg.content }}</view>
</view>
</view>
<view id="msg-bottom" class="mb-bottom-anchor"></view>
</scroll-view>
<view class="mb-input-bar">
<input v-model="newMsgText" class="mb-input" placeholder="输入交接注意事项..." confirm-type="send" @confirm="submitMessage" />
<view :class="['mb-send-btn', !newMsgText.trim() ? 'btn-disabled' : '']" @tap="submitMessage">发送</view>
</view>
</view>
</view>
</view>
</template>
<script>
import request, { get, post, patch, put } from "../../utils/request";
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
import WorkspaceArea from "./components/WorkspaceArea.vue";
import TreeCanvas from "./components/TreeCanvas.vue";
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
export default {
components: { WorkspaceArea, TreeCanvas },
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
data() {
return {
OVERALL_OPTIONS, loading: true, error: "", product: null,
@ -169,44 +218,96 @@ export default {
users: [], TASK_NAME_OPTIONS,
createFirstVisible: false, isWarehouseTransfer: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "" }, firstSaving: false,
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false,
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "tree",
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "workspace", autoLockTaskId: "",
processOptions: [], userOptions: [],
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
spawnForm: { assignee_id: "", remark: "" },
// 💬 留言板
messages: [],
// 🚀 字典版本号:驱动子组件强制重建,解决 userNameMap 非响应式问题
dictVersion: 0,
showMsgDrawer: false,
newMsgText: '',
bottomMsgId: '',
lastMsgSeenAt: '',
};
},
computed: {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; return this.currentUser.username === this.recordPopup.task.assignee_id; },
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; return this.currentUser.username === this.recordPopup.task.assignee_id; },
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
hasMyActiveTask() {
const find = (tasks) => { if (!tasks) return false; for (const t of tasks) { if ((t.status === 'WIP' || t.status === 'PENDING') && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) return true; if (find(t.child_tasks)) return true; } return false; };
return this.product ? find(this.product.task_tree) : false;
},
msgUnreadCount() { if (!this.lastMsgSeenAt) return this.messages.length; return this.messages.filter(m => m.created_at > this.lastMsgSeenAt).length; },
// 🔒 宏观状态修改权限:对齐后端 update_overall_status 的 main_task 判断标准
canEditOverallStatus() {
if (!this.currentUser) return false;
if (this.currentUser.role === 'SUPER_ADMIN') return true;
if (!this.product || !this.product.task_tree) return false;
let hasPermission = false;
const checkTask = (tasks) => {
if (!tasks || hasPermission) return;
for (const t of tasks) {
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
if (isMain && (t.status === 'WIP' || t.status === 'PENDING')) {
if (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername) {
hasPermission = true;
}
}
checkTask(t.child_tasks);
}
};
checkTask(this.product.task_tree);
return hasPermission;
},
},
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) this.doQuery(sn); },
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
onShow() { if (this.product?.id) { this.fetchMessages(); } },
methods: {
formatUserName, formatUserAvatar,
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
statusLabel(s) { return STATUS_MAP[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = this.hasMyActiveTask ? 'workspace' : 'tree'; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
toggleMode() { this.currentMode = this.currentMode === 'workspace' ? 'tree' : 'workspace'; },
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
// 🚀 从 taskId 反查 product_serial → 再 doQuery
async doQueryByTask(tid) { try { const task = await get(`/tasks/${tid}`); const sn = task?.product_sn || ""; if (sn) { this.doQuery(sn); } else { this.error = "未找到关联产品"; this.loading = false; } } catch { this.error = "任务查询失败"; this.loading = false; } },
findMyImmersiveTask() {
// 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定
if (!this.product || !this.product.task_tree) return null;
let wipTask = null, pendingTask = null;
const walk = (tasks) => {
if (!tasks) return;
for (const t of tasks) {
const isMine = t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername;
if (isMine && t.status === 'WIP') wipTask = t;
if (isMine && t.status === 'PENDING' && !pendingTask) pendingTask = t;
walk(t.child_tasks);
}
};
walk(this.product.task_tree);
return (wipTask || pendingTask) ? (wipTask || pendingTask).id : null;
},
toggleMode() { if (this.currentMode === 'workspace') this.currentMode = 'swipe'; else if (this.currentMode === 'swipe') this.currentMode = 'tree'; else this.currentMode = 'workspace'; },
async handleSetOverallStatus(status) { try { this.product = await patch(`/products/scan/${this.product.serial_number}/status`, { status }); uni.showToast({ title: `状态已更新: ${status}`, icon: "success" }); this.showStatusPicker = false; } catch {} },
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } },
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); } catch {} },
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } },
loadCurrentUser() { try { let user = uni.getStorageSync("user"); if (typeof user === "string" && user) { try { user = JSON.parse(user); } catch (e) { user = null; } } if (user && typeof user === "object") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; } } catch {} },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
onAssigneeChange(e) { const idx = e.detail.value; const u = this.users[idx]; if (u) { this.firstForm.assigneeIdx = idx; this.firstForm.assignee_id = u.username; this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`; } },
openCreateFirstTask() { this.isWarehouseTransfer = false; if (this.currentMode === 'tree') this.currentMode = 'workspace'; this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" }; this.createFirstVisible = true; },
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else { this.showStatusPicker = true; } },
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); },
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
@ -244,12 +345,50 @@ export default {
async doTransfer() { this.actionLoading = true; try { const assignees = this.transferForm.isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId]; const taskName = this.transferForm.isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认"; await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: [{ task_name: taskName, assignees }], note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: this.transferForm.isWarehouse ? "已入库" : "转交成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
// 派发协助分支
async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: "待确认", assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
// 💬 留言板
async fetchMessages() { if (!this.product?.id) return; try { const res = await get(`/products/${this.product.id}/messages`); this.messages = res || []; this.scrollToBottom(); } catch (e) { console.error('获取留言失败', e); } },
async submitMessage() { const content = this.newMsgText.trim(); if (!content) return; this.newMsgText = ''; const tempId = 'temp_' + Date.now(); const tempMsg = { id: tempId, operator_id: this.currentUsername || this.currentUserId || '?', content, created_at: new Date().toISOString() }; this.messages.push(tempMsg); this.scrollToBottom(); try { await post(`/products/${this.product.id}/messages`, { operator_id: this.currentUsername || this.currentUserId, content }); this.fetchMessages(); } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }); this.messages = this.messages.filter(m => m.id !== tempId); } },
openMsgDrawer() { this.showMsgDrawer = true; this.$nextTick(() => { this.scrollToBottom(); }); },
closeMsgDrawer() { const last = this.messages[this.messages.length - 1]; this.lastMsgSeenAt = last ? last.created_at : new Date().toISOString(); this.showMsgDrawer = false; },
scrollToBottom() { this.$nextTick(() => { this.bottomMsgId = 'msg-bottom'; }); },
fmtMsgTime(d) { if (!d) return ''; const dt = new Date(d); const pad = (n) => String(n).padStart(2, '0'); return `${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
// 🖨️ 打印标签:调用后端 API 发送打印指令
async printLabel() {
if (!this.product?.serial_number) return;
uni.showActionSheet({
itemList: ['网络打印机后端API', '蓝牙打印机ESC/POS'],
success: async (res) => {
if (res.tapIndex === 0) {
// 方案A网络打印机 → 调用后端 /print/execute API
try {
uni.showLoading({ title: '发送打印指令...' });
await post(`/print/execute`, {
serial_number: this.product.serial_number,
material_name: this.product.material_name || '',
spec_model: this.product.spec_model || '',
order_no: this.product.order_no || '',
copies: 1,
});
uni.hideLoading();
uni.showToast({ title: '打印指令已发送', icon: 'success' });
} catch (e) {
uni.hideLoading();
uni.showToast({ title: e?.data?.detail || '打印失败', icon: 'none' });
}
} else if (res.tapIndex === 1) {
// 方案B蓝牙打印机 → 前端直连 ESC/POS 指令
// ⚠️ 需要引入蓝牙打印 SDK当前为占位架构
uni.showToast({ title: '蓝牙打印功能开发中', icon: 'none' });
}
},
});
},
},
};
</script>
<style scoped>
.page-container { height: 100vh; display: flex; flex-direction: column; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 0; overflow: hidden; }
.page-container { min-height: 100vh; display: block; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 24px; overflow-y: auto; }
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
.overall-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: #fff; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
@ -257,12 +396,13 @@ export default {
.overall-val { font-size: 15px; font-weight: 700; color: #2563eb; flex: 1; }
.overall-empty { color: #ef4444; }
.overall-arrow { font-size: 12px; color: #9ca3af; }
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-header-right { display: flex; align-items: center; gap: 8px; }
.card-title { font-size: 15px; font-weight: 700; }
.edit-btn { font-size: 18px; padding: 2px 6px; }
.mode-toggle { font-size: 13px; font-weight: 700; padding: 4px 10px; border-radius: 8px; background: #eff6ff; color: #2563eb; }
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; min-height: 120px; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
.card-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.card-title { font-size: 15px; font-weight: 700; flex-shrink: 0; }
.edit-btn { font-size: 18px; padding: 2px 6px; flex-shrink: 0; }
.mode-toggle { font-size: 12px; font-weight: 700; padding: 4px 8px; border-radius: 8px; background: #eff6ff; color: #2563eb; white-space: nowrap; flex-shrink: 0; }
.print-label-btn { font-size: 11px; font-weight: 700; padding: 4px 6px; border-radius: 8px; background: #fef3c7; color: #b45309; white-space: nowrap; flex-shrink: 0; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.label { font-size: 12px; color: #9ca3af; }
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
@ -301,6 +441,30 @@ export default {
.btn-add-branch { width: 100%; height: 40px; border: 2px dashed #93c5fd; border-radius: 10px; background: #eff6ff; color: #2563eb; font-size: 14px; font-weight: 700; line-height: 40px; margin: 4px 0; }
.btn-add-branch::after { border: none; }
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
/* 💬 留言悬浮按钮 */
.msg-fab { position: fixed; right: 20px; bottom: 100px; z-index: 99; width: 50px; height: 50px; border-radius: 25px; background: #3b82f6; color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(59,130,246,0.4); }
.msg-fab-icon { font-size: 22px; }
.msg-fab-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; border-radius: 9px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 5px; }
/* 💬 留言板底部抽屉 */
.msg-drawer-overlay { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
.message-board-drawer { height: 65vh; display: flex; flex-direction: column; background: #fff; border-radius: 16px 16px 0 0; width: 100%; max-width: 480px; }
.mb-drawer-handle { width: 40px; height: 4px; border-radius: 2px; background: #d1d5db; margin: 8px auto; flex-shrink: 0; }
.mb-title { font-size: 14px; font-weight: bold; padding: 12px 16px; border-bottom: 1px solid #f3f4f6; color: #374151; flex-shrink: 0; }
.mb-scroll-area { flex: 1; padding: 12px; overflow-y: auto; }
.mb-item { display: flex; margin-bottom: 16px; }
.mb-avatar { width: 36px; height: 36px; border-radius: 18px; background: #3b82f6; color: #fff; font-weight: bold; display: flex; align-items: center; justify-content: center; margin-right: 12px; flex-shrink: 0; font-size: 14px; }
.mb-content-wrapper { flex: 1; min-width: 0; }
.mb-header-info { margin-bottom: 4px; display: flex; align-items: baseline; }
.mb-name { font-size: 12px; color: #6b7280; margin-right: 8px; font-weight: 600; }
.mb-time { font-size: 10px; color: #9ca3af; }
.mb-bubble { background: #f3f4f6; padding: 8px 12px; border-radius: 0 12px 12px 12px; font-size: 14px; color: #1f2937; word-break: break-all; line-height: 1.5; }
.mb-input-bar { display: flex; padding: 10px 16px; border-top: 1px solid #e5e7eb; align-items: center; background: #f9fafb; border-radius: 0 0 12px 12px; flex-shrink: 0; }
.mb-input { flex: 1; background: #ffffff; border: 1px solid #d1d5db; padding: 6px 12px; border-radius: 16px; font-size: 14px; height: 36px; }
.mb-send-btn { margin-left: 12px; background: #3b82f6; color: #fff; padding: 6px 16px; border-radius: 16px; font-size: 14px; font-weight: 600; transition: all 0.2s; }
.btn-disabled { background: #9ca3af; opacity: 0.5; }
.mb-bottom-anchor { height: 1px; }
.required { color: #ef4444; }
.picker-box { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 12px; font-size: 14px; color: #1f2937; line-height: 42px; box-sizing: border-box; background: #fff; }
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }

View File

@ -67,6 +67,9 @@ export default {
computed: {
canEdit() {
if (!this.currentUser || !this.task) return false;
// 已完成/已驳回/已入库/已撤回 的任务禁止编辑
const frozen = ["COMPLETED", "REJECTED", "ARCHIVED", "CANCELED"];
if (frozen.includes(this.task.status)) return false;
return (
this.currentUser.id == this.task.assignee_id ||
this.currentUser.username == this.task.assignee_id

View File

@ -0,0 +1,55 @@
/**
* 全局人名映射工具
*
* 使用方式:
* 1. 在页面 loadUsers() 后调用 setUserNameMap(userList) 填充字典
* 2. 模板中直接 `{{ formatUserName(task.assignee_id) }}`
* 3. 头像中 `{{ formatUserAvatar(msg.operator_id) }}` 取中文名末字
*/
// 全局用户名 → 中文姓名 映射表
const userNameMap = {};
/**
* 批量设置用户名映射
* @param {Array} users - 用户列表,每项需含 username 和 full_name
*/
export function setUserNameMap(users) {
if (!users || !users.length) return;
for (const u of users) {
const id = u.username || u.id || '';
const name = u.full_name || u.name || u.real_name || '';
if (id && name) {
userNameMap[id] = name;
}
}
}
/**
* 将用户 ID 翻译为中文姓名
* @param {string} userId - 用户标识username 或 id
* @returns {string} 中文姓名,查不到则降级返回原 ID
*/
export function formatUserName(userId) {
if (!userId) return '—';
// 特殊值原样返回
if (userId === 'virtual_warehouse') return '🏭 仓库';
const name = userNameMap[userId];
return name || userId;
}
/**
* 获取用户头像文字(中文名取末字,拼音名取首字母)
* @param {string} userId - 用户标识
* @returns {string} 单字头像文字
*/
export function formatUserAvatar(userId) {
if (!userId) return '?';
const name = userNameMap[userId];
if (name) {
// 中文名取最后一个字
return name.charAt(name.length - 1);
}
// 降级:取 ID 首字母大写
return userId.charAt(0).toUpperCase();
}

View File

@ -1,14 +1,75 @@
/**
* uni.request 封装 — 统一的 HTTP 客户端
* 自动携带 Token、401 跳转登录
* uni.request 封装 — 双 Token 无感刷新 + 并发请求队列
*
* 环境选择:优先读取 storage 中的 env_base_url
* 未设置时默认走生产服务器。开发时如需切换,在登录前手动设置。
*/
const BASE_URL = "http://192.168.9.80:8011/api/v1";
const PROD_URL = "http://track_back.iris-rs.cn/api/v1";
// ============================================================
// 环境 URL
// ============================================================
function getBaseUrl() {
return uni.getStorageSync("env_base_url") || PROD_URL;
}
// ============================================================
// 双 Token 存储
// ============================================================
function getAccessToken() { return uni.getStorageSync("access_token") || ""; }
function getRefreshToken() { return uni.getStorageSync("refresh_token") || ""; }
function setTokens(accessToken, refreshToken) {
uni.setStorageSync("access_token", accessToken);
uni.setStorageSync("refresh_token", refreshToken);
}
function clearTokens() {
uni.removeStorageSync("access_token");
uni.removeStorageSync("refresh_token");
uni.removeStorageSync("token");
uni.removeStorageSync("user");
}
// ============================================================
// 并发请求队列
// ============================================================
let isRefreshing = false;
let retryQueue = [];
function processQueue(error, newToken) {
retryQueue.forEach((p) => {
if (newToken) p.resolve(newToken);
else p.reject(error);
});
retryQueue = [];
}
async function refreshAccessToken() {
const refreshToken = getRefreshToken();
if (!refreshToken) throw new Error("无 Refresh Token");
return new Promise((resolve, reject) => {
uni.request({
url: getBaseUrl() + "/auth/refresh",
method: "POST",
data: { refresh_token: refreshToken },
header: { "Content-Type": "application/json" },
timeout: 10000,
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data);
else reject(res);
},
fail(err) { reject(err); },
});
});
}
export default function request(options) {
return new Promise((resolve, reject) => {
const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
const token = uni.getStorageSync("token") || "";
const url = options.url.startsWith("http") ? options.url : getBaseUrl() + options.url;
const accessToken = getAccessToken();
uni.request({
url,
@ -16,59 +77,65 @@ export default function request(options) {
data: options.data || {},
header: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...(options.header || {}),
},
timeout: 15000,
success(res) {
const code = res.statusCode;
if (code >= 200 && code < 300) {
resolve(res.data);
} else if (code === 401) {
uni.removeStorageSync("token");
uni.removeStorageSync("user");
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
reject(res);
} else if (code === 403) {
uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
reject(res);
} else if (code === 400) {
uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
reject(res);
} else if (code === 409) {
uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
reject(res);
} else {
const detail = res.data?.detail || "";
uni.showToast({ title: detail ? `${detail}` : `请求失败 (${code})`, icon: "none", duration: 3000 });
reject(res);
if (code >= 200 && code < 300) { resolve(res.data); return; }
if (code === 401) {
if (url.includes("/auth/refresh") || url.includes("/auth/login")) { reject(res); return; }
if (isRefreshing) {
retryQueue.push({
resolve: (newToken) => {
options.header = options.header || {};
options.header.Authorization = `Bearer ${newToken}`;
request(options).then(resolve).catch(reject);
},
reject: (err) => reject(err),
});
return;
}
isRefreshing = true;
refreshAccessToken()
.then((data) => {
setTokens(data.access_token, getRefreshToken());
processQueue(null, data.access_token);
options.header = options.header || {};
options.header.Authorization = `Bearer ${data.access_token}`;
request(options).then(resolve).catch(reject);
})
.catch(() => {
console.error("[Request] Refresh Token 也过期,清除登录态");
processQueue(new Error("refresh_failed"), null);
clearTokens();
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
reject(res);
})
.finally(() => { isRefreshing = false; });
return;
}
if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
reject(res);
},
fail() {
uni.showToast({ title: "网络连接失败", icon: "none" });
reject(new Error("network"));
},
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
});
});
}
export function get(url, params = {}) {
const query = Object.entries(params)
.filter(([, v]) => v != null && v !== "")
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join("&");
const query = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
}
export function post(url, data = {}) {
return request({ url, method: "POST", data });
}
export function patch(url, data = {}) {
return request({ url, method: "PATCH", data });
}
export function put(url, data = {}) {
return request({ url, method: "PUT", data });
}
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }

View File

@ -0,0 +1,119 @@
/**
* uni.request 封装 — 本地开发环境 (192.168.9.80)
* 双 Token 无感刷新 + 并发请求队列
*/
const BASE_URL = "http://192.168.9.80:8011/api/v1";
function getAccessToken() { return uni.getStorageSync("access_token") || ""; }
function getRefreshToken() { return uni.getStorageSync("refresh_token") || ""; }
function setTokens(accessToken, refreshToken) {
uni.setStorageSync("access_token", accessToken);
uni.setStorageSync("refresh_token", refreshToken);
}
function clearTokens() {
uni.removeStorageSync("access_token");
uni.removeStorageSync("refresh_token");
uni.removeStorageSync("token");
uni.removeStorageSync("user");
}
let isRefreshing = false;
let retryQueue = [];
function processQueue(error, newToken) {
retryQueue.forEach((p) => {
if (newToken) p.resolve(newToken);
else p.reject(error);
});
retryQueue = [];
}
async function refreshAccessToken() {
const refreshToken = getRefreshToken();
if (!refreshToken) throw new Error("无 Refresh Token");
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + "/auth/refresh",
method: "POST",
data: { refresh_token: refreshToken },
header: { "Content-Type": "application/json" },
timeout: 10000,
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data);
else reject(res);
},
fail(err) { reject(err); },
});
});
}
export default function request(options) {
return new Promise((resolve, reject) => {
const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
const accessToken = getAccessToken();
uni.request({
url,
method: options.method || "GET",
data: options.data || {},
header: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...(options.header || {}),
},
timeout: 15000,
success(res) {
const code = res.statusCode;
if (code >= 200 && code < 300) { resolve(res.data); return; }
if (code === 401) {
if (url.includes("/auth/refresh") || url.includes("/auth/login")) { reject(res); return; }
if (isRefreshing) {
retryQueue.push({
resolve: (newToken) => {
options.header = options.header || {};
options.header.Authorization = `Bearer ${newToken}`;
request(options).then(resolve).catch(reject);
},
reject: (err) => reject(err),
});
return;
}
isRefreshing = true;
refreshAccessToken()
.then((data) => {
setTokens(data.access_token, getRefreshToken());
processQueue(null, data.access_token);
options.header = options.header || {};
options.header.Authorization = `Bearer ${data.access_token}`;
request(options).then(resolve).catch(reject);
})
.catch(() => {
console.error("[Request] Refresh Token 也过期");
processQueue(new Error("refresh_failed"), null);
clearTokens();
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
reject(res);
})
.finally(() => { isRefreshing = false; });
return;
}
if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
reject(res);
},
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
});
});
}
export function get(url, params = {}) {
const query = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
}
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }

View File

@ -0,0 +1,120 @@
/**
* uni.request 封装 — 生产环境172.16.0.198
* 打包 APK 前:将 request.prod.js 重命名为 request.js 替换原文件
* 双 Token 无感刷新 + 并发请求队列
*/
const BASE_URL = "http://172.16.0.198:8011/api/v1";
function getAccessToken() { return uni.getStorageSync("access_token") || ""; }
function getRefreshToken() { return uni.getStorageSync("refresh_token") || ""; }
function setTokens(accessToken, refreshToken) {
uni.setStorageSync("access_token", accessToken);
uni.setStorageSync("refresh_token", refreshToken);
}
function clearTokens() {
uni.removeStorageSync("access_token");
uni.removeStorageSync("refresh_token");
uni.removeStorageSync("token");
uni.removeStorageSync("user");
}
let isRefreshing = false;
let retryQueue = [];
function processQueue(error, newToken) {
retryQueue.forEach((p) => {
if (newToken) p.resolve(newToken);
else p.reject(error);
});
retryQueue = [];
}
async function refreshAccessToken() {
const refreshToken = getRefreshToken();
if (!refreshToken) throw new Error("无 Refresh Token");
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + "/auth/refresh",
method: "POST",
data: { refresh_token: refreshToken },
header: { "Content-Type": "application/json" },
timeout: 10000,
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data);
else reject(res);
},
fail(err) { reject(err); },
});
});
}
export default function request(options) {
return new Promise((resolve, reject) => {
const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
const accessToken = getAccessToken();
uni.request({
url,
method: options.method || "GET",
data: options.data || {},
header: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...(options.header || {}),
},
timeout: 15000,
success(res) {
const code = res.statusCode;
if (code >= 200 && code < 300) { resolve(res.data); return; }
if (code === 401) {
if (url.includes("/auth/refresh") || url.includes("/auth/login")) { reject(res); return; }
if (isRefreshing) {
retryQueue.push({
resolve: (newToken) => {
options.header = options.header || {};
options.header.Authorization = `Bearer ${newToken}`;
request(options).then(resolve).catch(reject);
},
reject: (err) => reject(err),
});
return;
}
isRefreshing = true;
refreshAccessToken()
.then((data) => {
setTokens(data.access_token, getRefreshToken());
processQueue(null, data.access_token);
options.header = options.header || {};
options.header.Authorization = `Bearer ${data.access_token}`;
request(options).then(resolve).catch(reject);
})
.catch(() => {
console.error("[Request] Refresh Token 也过期");
processQueue(new Error("refresh_failed"), null);
clearTokens();
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
reject(res);
})
.finally(() => { isRefreshing = false; });
return;
}
if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
reject(res);
},
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
});
});
}
export function get(url, params = {}) {
const query = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
}
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@ -1,9 +1,9 @@
/**
* uni.request 封装 — 统一的 HTTP 客户端
* 自动携带 Token、401 跳转登录
* uni.request 封装 — 生产环境(服务器 172.16.0.198
* 打包 APK 前:将 request.prod.js 重命名为 request.js 替换原文件
*/
const BASE_URL = "http://192.168.9.80:8011/api/v1";
const BASE_URL = "http://172.16.0.198:8011/api/v1";
export default function request(options) {
return new Promise((resolve, reject) => {
@ -30,11 +30,18 @@ export default function request(options) {
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
reject(res);
} else if (code === 403) {
uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
reject(res);
} else if (code === 400) {
uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
reject(res);
} else if (code === 409) {
uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
reject(res);
} else {
uni.showToast({ title: `请求失败 (${code})`, icon: "none" });
const detail = res.data?.detail || "";
uni.showToast({ title: detail ? `${detail}` : `请求失败 (${code})`, icon: "none", duration: 3000 });
reject(res);
}
},
@ -57,3 +64,11 @@ export function get(url, params = {}) {
export function post(url, data = {}) {
return request({ url, method: "POST", data });
}
export function patch(url, data = {}) {
return request({ url, method: "PATCH", data });
}
export function put(url, data = {}) {
return request({ url, method: "PUT", data });
}