35 Commits

Author SHA1 Message Date
d729a19b86 feat: MOM 撤回出库强制回滚通道
MOM 把误点出库的设备物理回滚到仓库时,Track 被动跟随 MOM 的权威物理状态。

【撤回信号识别】
- action / event 里的 revoke / rollback / revert / cancel 子串匹配
  (MOM 侧字段命名尚未冻结,刻意宽松,避免对方改词就整条链路失联)
- 无显式标记但产品正处于「已出库」时,按"已发货设备收到入库回调 = 货回来了"
  隐式判定

【匹配放宽】
- 常规入库仍严格要求 current_location_id == virtual_warehouse
- 撤回信号、或 payload 带 serial 时才放行「已出库」产品 —— 出库回调已把
  location 置为 None,不放宽则撤回必然失配、静默返回 matched=False
- 刻意不给 sku 兜底也无条件放宽:同型号可能多台,放宽会误标到别的设备

【特权通道】
- 强制覆写 overall_status=已入库 + 位置回滚 virtual_warehouse,优先级高于
  task_service 的【绝对物理终态保护】。两者方向刻意相反:那套约束的是
  "车间内部流转不许用工序名抹掉物理终态",而本接口是物理事实的权威来源。
  代码内已留醒目注释,防止后续维护者误加状态互斥校验
- 改用 sync_product_status 统一双字段同步(原先硬编码 status="ARCHIVED"
  绕过了 lifecycle.py 的约定),并把 status 变化一并计入 changed,
  避免"状态与位置本就正确时纠偏不提交"

【撤回留痕】
- 追加「撤回出库(重新入库)」主线节点。名称里的「入库」二字是必须保留的契约:
  product_service._has_warehouse_task 用子串判定仓库节点,若只有"出库"会让
  location==virtual_warehouse 的产品被注入假的「待仓库收货」虚拟节点,
  出现"已入库却在等收货"的自相矛盾

【重构】
- 抽出 _pick_warehouse_log_task / _match_inbound_product 复用,出库回调同步简化
2026-09-17 17:42:06 +08:00
42c883fe7d feat: 移动端直接完结入口、权限与派发解绑
【直接完结入口(双重门槛)】
- 转交弹窗新增「🏁 直接完结」选项,与「入库」互斥
- 角色门槛:仅超管 / 主管可见可操作(与后端 ADMIN_ROLES 对齐,两个角色都判 ——
  本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN 漏了 SUPERVISOR)
- 场景门槛:新增 showFinishDirect,要求产品确实是「已出库」。普通生产设备若被
  直接完结,既无下游任务、又不在仓库池,会变成卡在工人名下的孤儿数据
- transferMode 的 direct 分支同步校验双门槛,防残留选中态把 finish_directly 发出去

【修复售后"鸡生蛋"死锁】
- 已出库设备的下拉工序改走售后词表:taskOptionsFor 增加 overall_status 入参,
  已出库与 AFTER_SALES 同等待遇。此前只认 lifecycle_phase,而上一轮已禁止后端
  因"正常已出库做质检"翻转该阶段 → 工人选不到工序 → 进不了售后 → 更选不到

【派发入口与仓库位置解绑】
- 原条件硬性要求 current_location_id === 'virtual_warehouse',直接完结后的设备
  位置停在最后经手人名下,派发入口彻底消失,产品再也无法流转
- 新增豁免:产品空闲(无活跃主线任务)+ 超管/主管即可派发;新增 hasActiveMainTask
  只算主干,协助分支(SPAWN)不阻塞派发
- 文案与图标按场景动态化(在仓库 / 任务已完结)

【顺带修复】
- isWarehouseTransfer 死标志位:openWarehouseTransfer 先置 true,紧接着调用的
  openCreateFirstTask 又立刻重置为 false,导致弹窗标题永远显示「发起首道工序」,
  仓库转出文案从未生效。改为在 openCreateFirstTask 内按产品实际位置推导
2026-09-17 17:06:00 +08:00
ee25b8fd23 feat: PC 端直接完结入口与当前工序口径(前端)
【直接完结入口】
- 共享 TransferModal 新增「🏁 直接完结」开关,与「入库」互斥;选中后下游字段
  禁用(保留输入便于反悔)、标题/预览/按钮切换为独立视觉
- 入口仅超管/主管可见(useAuth + isAdminRole),并加防御性 useEffect 清掉
  非管理员的残留选中态,避免提交出 finish_directly
- 表单重置改为「打开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
  且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交

【角色判断收口】
- 新增 ADMIN_ROLES / isAdminRole(与后端 task_service.ADMIN_ROLES 对齐)
  此前该判断散落在 TaskFlowView / AdminProductsPage 各处,移动端那份还只判了
  SUPER_ADMIN 漏了 SUPERVISOR,导致主管被前端误挡

【当前工序口径】
- 「当前工序」列改用后端派生的 current_step,不再用 overall_status ——
  后者会被"最新主干任务名"覆盖,让已 COMPLETED 的历史工序(扫码出库/测试…)
  冒充当前工序。显示与筛选值一并切换以保持一致
- ProductResponse 类型补齐 current_step
2026-09-17 17:05:51 +08:00
d666bbd4ed feat: 直接完结通道与报表口径收口(后端)
【直接完结:保留动作,剥离状态】
- transfer_task 权限硬拦截:仅 SUPER_ADMIN / SUPERVISOR。判据取签名保护的
  operator_role,刻意不用客户端可通过 ?operator_id= 伪造的 operator_id
- 直接完结【不再改写】overall_status —— 它只是任务闭环动作,不改变物理状态。
  已出库设备完结后依然是「已出库」,回归 WIP 矩阵的已出库列
- 物理终态保护:create_task / receive_task / transfer_task 三处写入点统一加
  _is_physical_terminal 守卫,禁止用任务名覆写 已入库/在库/已出库。
  历史缺陷:「发货测试」会把设备的「已出库」标识静默抹掉,跨报表口径随之打架

【位置与工序口径】
- _recalc_product_location:已出库且闲置 → 位置清空(货发走就离场)。
  只清「已出库」;已入库/在库的设备确实还在仓库里,位置必须保留
- MOM 出库回调同步清空 current_location_id
- 新增 ProductResponse.current_step:只认活跃主干任务,无活跃任务时返回
  宏观终态或空。不再让已 COMPLETED 的历史工序(扫码出库/测试…)冒充"当前工序"

【售后烙印去死锁】
- 拆分 OUTBOUND_QC_STEPS(发货测试) / AFTER_SALES_REPAIR_STEPS(售后维修)
- resolve_phase_for_step 与 _mark_after_sales_if_reactivated 双双改为仅
  「售后维修」触发 AFTER_SALES,解除「已出库 + 发货测试」被永久烙印的死锁
- PRODUCTION_OVERALL_STEPS 补入「发货测试」,使出厂质检在生产阶段合法
  (否则 _enforce_step_isolation 会把已出库设备的发货测试直接 400)

【游魂收口】
- get_wip_matrix / get_wip_matrix_detail 的 is_terminal 增加"名下再无任何活跃
  任务则视同终结态",让被直接完结的生产设备接受时间筛选,不再恒挂在看板上
  冒充在制。两处必须一字不差同步,否则会出现"矩阵有数、下钻为空"
2026-09-17 17:05:42 +08:00
e707f3673d feat: PC 端补齐直接完结通道
- taskApi.transferTask 增 finishDirectly 参数,按新 Schema 发送
  { next_tasks: [], finish_directly: true, note }
- TaskTransferPayload 支持 next_tasks / finish_directly,旧版单线字段降为可选
  (后端仍兼容 next_assignees / next_task_name,存量调用不受影响)
- 共享 TransferModal 新增「🏁 直接完结(无下游,不入库)」开关:
  与入库互斥,选中后下游字段禁用(保留输入便于反悔切回)、
  切换独立标题/提示/预览/提交按钮,不再套用「将创建 N 个任务」模板
- 表单重置改为「展开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
  且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交
- TaskTreeViewer 与 AdminTasksPage 两个入口的 handleTransfer 签名对齐
2026-09-17 14:08:30 +08:00
1e6c006360 feat: MES/MOM 出入库状态解绑与直接完结通道(后端契约 + 移动端)
- TaskTransferRequest 新增 finish_directly 开关:闭环当前任务但不产生任何下游任务,
  产品 overall_status 保持原样,工人无需再借道「入库(virtual_warehouse)」来关闭任务,
  从根源上避免产品被误标「待仓库收货」而卡住 MOM 对账
- 空 assignees 分支加防呆校验:想直接完结必须显式传 finish_directly=true,
  杜绝漏填导致「转交」静默退化成「直接完结」的丢件级隐患
- 直接完结跳过 _mark_after_sales_if_reactivated:不产生新任务即不构成「回流返厂」信号,
  否则已出库设备的正常收官会把产品误翻成售后机(该标志单向不可回退)
- 补齐直接完结的独立响应文案,不再拼出「已创建 0 个下一道工序任务「None」」
- 移动端转交弹窗改为「转交个人 / 入库 / 直接完结」三选一互斥
2026-09-17 14:07:02 +08:00
2539bede3c chore: 看板页头副标题改用车间口语措辞
原标题「产品 = 物理实体(身份证)| 任务 = 工序节点」偏学术抽象,车间管理人员
不易一眼建立对应关系。改为按「产品盯物、任务盯人」的直觉表述:

    产品 = 车间里的物理设备 | 任务 = 派发给工人的生产指令(派工单)

配合卡片上已加的「待流转 / 待接收」口径 Tooltip,共同消除「产品数 > 任务数」
带来的误判。
2026-09-16 14:41:33 +08:00
dcc92b223e feat: 生产看板多维度重构(全量在制品、时间维度联动、口径提示)
本次只含后端看板接口与 PC 端页面。移动端的体验优化已在之前的 12 个提交中
入库,不在本提交范围内。

后端 · 服务层 dashboard_service.py
- 在制品列表不再按 20 条静默截断:此前 `.limit(limit*2)` 预取再排序切片,
  前端又拿数组长度当总数,表现为「卡片 23、列表却写共 20 个」的数据被吞。
  现默认返回全量,limit 降级为防御性上限
- 产品流转「已完结」接入右上角时间筛选(since/until),废弃硬编码「本月」。
  此前选「今天」也会看到整月产出,业务语义分裂;待流转/流转中仍为实时快照。
  Product 表无 completed_at,以名下工序的完成时间锚定「完结时刻」
- 返工任务可见性:未完结卡点永远展示,已了结的仅限本月,且与卡片数字共用
  同一份条件,避免「按钮显示 2、点开共 0 条」
- 驳回明细的「返工给」改为真实溯源:直接查派生出的返工任务当前 assignee_id,
  不再复刻 reject_task 的推算逻辑(返工任务事后被转交时会显示旧名字)

后端 · 接口层 dashboard.py
- /wip-tasks 的 limit 默认 20→500、上限 100→1000
- /my-stats 支持 since/until,新增接收/转交/上传备注口径(与 PC 人员操作统计
  逐字段交叉验证一致)

PC 端
- AdminDashboard: 在制品计数绑定真实总数并加截断提示、列表改为容器内滚动;
  驳回明细只展示「已驳回」行(返工行不再独立成行,其信息已由「返工给」承载);
  「待流转」「待接收」图例新增口径 Tooltip,消除「产品数 > 任务数」的误解;
  移除已失真的时间筛选提示条
- App.tsx: dayjs 中文 locale 上提到应用根。此前散落在 AdminPeoplePage /
  AnalyticsDashboard 两个页面里,而路由是 lazy 分割的 —— 直接打开概览页时
  那两个模块未加载,日期面板就露出 Sep/Su/Mo
- AdminPeoplePage / AnalyticsDashboard: 移除重复的页面级 locale 设置
- dashboardApi.ts: fetchWipTasks 默认值同步为 500
2026-09-16 14:29:56 +08:00
6bc6531e6a chore: 开发环境 CORS 补齐 uniapp H5 调试来源
原先只放行 PC 管理端(8010),移动端 H5 调试的服务起不来联调。
- 8010 = PC 管理端;5173/8020 = track-uniapp 的 H5 调试服务
- HBuilderX「运行到浏览器」起在 5173,直接跑 vite(按 track-uniapp/vite.config.ts)
  是 8020
- 漏了 uniapp 来源时,浏览器会在 CORS 预检阶段就掐掉请求,
  表现是「点登录没反应」,且控制台只报一句笼统的 404/400
2026-09-15 15:52:09 +08:00
a5fa789657 chore: 移除已废弃的工序树组件 FlowTree / TaskTreeNode
两者已被 TreeCanvas 取代:detail.vue 的 components 只注册
WorkspaceArea / TreeCanvas / TaskSwipeCards,全项目对 FlowTree 与
TaskTreeNode 已无任何 import(仅 detail.vue 内一句注释还提及后者)。

uni-app 不会编译未被引用的 .vue,留着不报错但会持续误导后续排查,
故清理。
2026-09-15 15:52:08 +08:00
1e46d6edd9 fix: 沉浸工作区不再自建滚动容器,滚动统一交还原生页面
is-locked 时锁定 height:100vh + overflow:hidden,会造出「卡片内部滚动 +
原生页面滚动」两个滚动容器并存的局面:卡片滚到顶之后再往下拉,手势会穿透
到页面层,误触发下拉刷新,工人根本没法往上翻内容。

- .workspace-area.is-locked 去掉 height:100vh + overflow:hidden,高度交还
  给内容自然撑开
- .focus-card 去掉 overflow-y:auto,随页面一起滚;
  padding-bottom 仍保留 160rpx,用来让内容避开底部固定操作栏
2026-09-15 15:52:08 +08:00
5cd08b551a fix: 扫码取码兼容带域名的完整 URL 二维码
二维码内容有两种可能,旧逻辑只做「去掉非字母数字后截前 16 位」,扫到带域名
的 URL 时会把 "https"、主机名一起当成 SN 的前半段,截出来是个完全不存在的
号,永远提示「未找到该产品」。

- 先把 "协议://主机名" 整段剥掉,否则域名会被当成候选 SN
  (如 trackbackirisrscn 这类 17 位串,恰好能通过长度校验)
- 在剩余内容里找长度 >= 8 的连续字母数字串,取最长的一段;
  等长时取靠后的 —— URL 里 SN 通常在路径末尾
2026-09-15 15:52:02 +08:00
a7d1c53b1f feat: 消息与任务列表改为分页加载
此前一次性拉全量(硬编码 limit:100),任务/通知多了会拖慢首屏。

- 分页状态:page / pageSize(20) / hasMore / loadingMore,触底加载下一页
- 下拉刷新回到第 1 页并清空重载 —— 分页后必须重置,否则新旧页会错位
- 任务列表按 id 去重后追加:翻页期间若有新任务插入,分页边界会错位导致
  重复项
- hasMore 优先以响应里的 total 为准,缺失时退回「本页是否满员」判断
- 底部新增状态提示(加载中 / 没有更多了 / 上拉加载更多),
  让工人知道是「到底了」还是「还在拉」
2026-09-15 15:52:02 +08:00
c7f046a993 feat: 个人中心补齐工作统计/设置/帮助与反馈三个模块
原先三个菜单都是空壳,点「帮助与反馈」只弹「敬请期待」。

工作统计(statistics.vue)
- 时段切换:今日 / 本周 / 本月 / 自定义(含起止日期选择器)
- 「生产战绩」完成 / 被驳回 / 参与产品;「操作统计」接收 / 转交 / 上传备注
  后者的口径与 PC「人员操作统计」严格同源,已逐字段交叉验证(4 人 × 4 指标
  全部相等),工人自查的数与主管看到的面板对得上
- 时段边界显式拼 "+08:00":车间按北京时间作息,发裸字符串会被后端按服务器
  本地时间解释,边界整体偏 8 小时

设置(settings.vue)
- 清理缓存:黑名单式,只删登记过的业务缓存(当前为 msg_seen_<product_id>,
  即留言抽屉的已读位点,随扫过的设备数无上限增长)。未登记的 key 一律保留,
  新增插件天然免疫;另有 NEVER_DELETE 兜底,任何情况下不动登录凭证与环境
  配置。副标题实时显示待清理条数,卡片下方说明「不动什么」
- 检查更新:调用 utils/ota.js 的 checkAppUpdate({ manual: true })
- 退出登录:由 profile/index 迁入,加确认弹窗防误触

帮助与反馈(feedback.vue)
- 问题类型(系统问题/设备故障/其他)、描述文本域(500 字计数)、
  图片上传最多 4 张,复用 utils/upload.js 的并发上传池
- api/feedback.js 采用「先真后假」:先按最终契约 POST /feedback/,
  仅当 404(端点尚未实现)才回退本地 mock。后端补上接口后前端零改动即可
  接上;而 400/422/断网仍照常抛出,不会被 mock 悄悄吞掉

路由
- pages.json 注册三个新页面(工作统计开启下拉刷新)
- profile/index.vue 用 MENU_ROUTES 映射 uni.navigateTo,移除「敬请期待」拦截
  与退出登录按钮
2026-09-15 15:51:50 +08:00
7fca315ff4 feat: 图片上传抽离公共模块,统一成功角标并修复记录拍照无法删除
上传模块 utils/upload.js(新增)
此前 detail.vue 与 records.vue 各写一份 uni.uploadFile 封装,超时与并发策略
容易漂移,且都是串行上传 —— 9 张图排队一张张传完,弱网下工人要干等半分钟。
- uploadImage:单张上传,UPLOAD_TIMEOUT 防弱网永久挂起;非 2xx 时后端返回的
  是 {detail:...} 而非 {url},必须当失败处理,否则图片被静默丢弃
- uploadImages:有界并发池(并发 3),onEachDone 对每张图恰好回调一次,
  成功与失败都精确回收一个 ⏳ 占位符
- isUploadedUrl:判定「是否已真正上传成功」。成功 resolve 的是后端 URL
  (/api/v1/upload/files/… 或 http(s)://),未完成的拿到的是本地临时地址
  (blob: / file:// / _doc/ / wxfile://)

上传成功绿勾角标
- 应用到 detail.vue(记录表单 + 驳回表单)与 records.vue(编辑记录弹窗)
- 角标仅在 isUploadedUrl 判定为真时渲染;⏳ 占位与失败项结构上不可能出现
  角标,因为角标只存在于「已上传」的那个循环里
- 圆角与裁剪改由 .success-badge-wrapper 负责,图片只负责填满

修复:记录/拍照里已选图片无法删除
删除按钮原为 v-if="canDeleteImage",而该项在「任务已定稿
(COMPLETED/REJECTED/ARCHIVED/CANCELED)」或「非本人任务」时返回 false。
但 handleTaskAction 里 type === "record" 是无条件打开弹窗的,工人完全可能
在已完成任务上打开「记录/拍照」,于是刚选、尚未提交的图也被一并锁死 ——
那些图只存在于内存里,删掉不产生任何服务端影响。

- 新增 recordForm.savedCount(打开弹窗时已落库的张数),
  新增 canDeleteRecordImage(i) = canDeleteImage || i >= savedCount:
  本次新选的图永远可删,已落库的才受「任务已定稿」约束
- 未落库的图免去 5 秒倒计时 —— 该机制本是为不可逆的服务器删除设计的,
  刚选错的图重拍一张即可,没必要卡 5 秒
- openEditRecord 由 images: record.images || [] 改为 saved.slice():
  原先按引用赋值,删图会直接改动底层记录对象,此时点「取消」被删的图
  在本地列表里也已经没了
2026-09-15 15:51:44 +08:00
eef4e5b72d refactor: OTA 热更新抽离为公共模块 utils/ota.js
原先 checkUpdate / parseVersionCode / downloadAndInstall / downloadWgt /
installWgt / otaFailed 整个长在 App.vue 的 methods 里,个人中心「设置 →
检查更新」想手动触发一次根本做不到。抽到 utils/ota.js 后两处共用同一份
实现,改版本比对规则或下载策略只需改一处。

- 节流状态从 this._lastUpdateCheck 改为模块级单例
- 新增 checkAppUpdate({ manual }),manual 模式跳过节流、显示「检查中」、
  并在「已是最新 / 检查失败」时给出明确反馈 —— 用户主动点的按钮必须有回音,
  自动检查才静默收场
- App.vue 的 onLaunch/onShow 改为调用同一函数,自动模式行为不变

同时 App.vue 的全局样式新增 .success-badge-wrapper / .success-badge
(上传成功绿勾角标,供各页面图片列表复用):
- 直角三角形用 border-top(实色) + border-left(透明),直角落在右上;
  写成 border-top + border-right 会得到左上角的三角,方向是反的
- 白勾用两条边框旋转 -45° 画成,不用 "✓" 字符 —— 该字形在安卓与 iOS 上
  的字形和基线差异很大,对不齐
- 容器 overflow:hidden 只包图片本身,不能包住单元格:各页面的删除按钮
  .img-del 定位在 -12rpx 处(单元格之外),包进去会被一并裁掉
2026-09-15 15:51:38 +08:00
44ca09ae22 feat: 新增个人效能统计接口 /dashboard/my-stats(支持自选时段)
移动端「个人中心 → 工作统计」的数据源。现有接口拿不到这份数据:
/dashboard/people-history 支持 assignee_id 但只返回 WIP/PENDING/COMPLETED,
不含 REJECTED;/dashboard/rejected-tasks 则根本没有 assignee_id 参数 ——
「今日被驳回数」按现有接口无论如何都过滤不到个人。故补一个薄桥接端点。

服务端 get_my_stats(db, assignee_id, since, until) 返回两组指标:

生产战绩(按 Task.assignee_id 归因)
- tasks_completed / tasks_rejected:status 判定 + completed_at 落在区间,
  与 get_dashboard_stats 的 t_done_q 同一口径,只多了 assignee_id 过滤
- products_touched:按 product_id 去重,同一台设备做多道工序只算一台

操作统计(与 PC /dashboard/user-operations 严格同口径)
- receive/transfer:task_logs 的 receive / complete,按 operator_id 归因
- record:task_records 按 Task.assignee_id 归因,排除 '[' 开头的系统自动备注
- 前两项归因 operator_id、第三项归因 assignee_id 是 PC 端既有口径,
  此处刻意保持一致,便于工人自查的数与主管看到的面板对得上

端点侧新增 _parse_bound():裸时间字符串(无时区偏移)按北京时间解释,
否则会被当作服务器本地时间,边界整体偏 8 小时,出现「选了今日却统计到
昨天下午」。since/until 缺省为「本月 1 日 ~ 此刻」。
2026-09-15 15:51:34 +08:00
242a6d5463 refactor: 环境地址收口到 utils/config.js,请求层新增 silent 静默模式
config.js(新增)作为后端域名/接口地址的唯一来源。此前 request.js 与 App.vue
各硬编码一份生产域名,改域名要改两处,漏一处就会出现「接口通了但 OTA 检查
更新接不上」这类极难排查的偏差。

request.js 改动:
- PROD_URL / LOCAL_URL 改为从 ./config 引入,不再就地硬编码
- 新增 options.silent:调用方自行兜底提示时跳过通用错误 toast。
  用于 feedback 的 404 回退 mock —— 否则用户会先看到「请求失败 (404)」
  再看到「感谢反馈」,两句自相矛盾。HTTP 错误与 fail 两个分支都受其控制
- fail 回调保留原始 errMsg 并打上 isNetworkError / isTimeout 标记:
  fail 表示压根没拿到后端响应(超时/断网),与 4xx/5xx 有本质区别 ——
  后端可能已执行成功只是响应没回来,上层需要据此打断用户的连点重试
2026-09-15 15:51:30 +08:00
93e4714019 chore: 修正 sync-watch.sh 同步方向为 src/ → HBuilderX 项目根
原脚本按 track-uniapp/ ↔ track-app/track/ 双向对拷,方向完全错误:
HBuilderX 是以 G:\Track\track-app\track 为项目根打开的(经典布局,
pages.json/App.vue/manifest.json 都在根目录),该目录是 track-uniapp/src/
的扁平镜像。证据:test_wgt_local.bat 写明 "build WGT in HBuilderX first",
且两侧文件时间戳逐项吻合。

按旧规则运行会造成三处破坏:
- 正向把 src/ 整个塞成 track/src/,并用 WSL 的 337B index.html 覆盖
  HBuilderX 的 637B 入口文件
- 反向把 Windows 项目根的文件搬回 WSL 根目录,让刚清理的幽灵文件复活
- 用 src/manifest.json(T1.0.8) 覆盖 HBuilderX 里维护的 T1.0.44 + 存储权限

改动:
- 改为单向 src/ → 项目根,并显式排除 13 类仅在 Windows 侧存在的项
  (manifest.json / index.html / static/ / uni.scss / unpackage/ / *.bak-* 等),
  --delete 因此不会误伤它们,同时能让 WSL 侧的删除传播过去
- 不用 -a:/mnt/g 是 drvfs,POSIX 权限/属主恒为 777,-p/-o/-g 每次都判定为有差异,
  产生无谓 chmod 与刷屏的 p 标记;改用 -rlt --no-perms --no-owner --no-group
- inotifywait 在 WSL 里并未安装,旧脚本 2>/dev/null 把 command not found 吞掉了
  (表现为「脚本像在跑但 G 盘不更新」);改为明确提示 + 3 秒轮询兜底
- 不再吞 rsync 报错,失败时打印退出码;新增 once / dry 子命令
2026-09-15 15:51:24 +08:00
ab75b9613f chore: 删除 track-uniapp 根目录与 src/ 重复的陈旧副本
构建实际读取的是 track-uniapp/src/ —— @dcloudio/vite-plugin-uni 的
UNI_INPUT_DIR 默认取 path.resolve(cwd, 'src'),检测到 src/manifest.json
即以其为输入目录。根目录那套 pages.json / App.vue / main.js / manifest.json
停留在 2026-08-04,是早期 HBuilderX 布局的遗留,早已不参与构建。

危害在于「同名同结构」:IDE 搜索 pages/profile/index.vue 会命中两个结果,
改到根目录那份既不编译也不报错,表现为「代码明明改了,App 里没变化」,
排查成本极高。

- 删除 pages/(4 个 .vue)、pages.json、App.vue、main.js、manifest.json
- 根 manifest.json 版本号为 T1.0.0(字符串),src/ 已是 T1.0.8(108),
  且缺 Barcode/Camera 模块与图标配置
- 保留 index.html:它是生效的 H5 入口模板,内容引用 /src/main.js,
  uni-app CLI 布局中本就在项目根
- 保留 static/:src/ 下无同名目录,非重复项
2026-09-15 15:51:19 +08:00
7b8084a43d fix: 前端统一解析后端 detail 报错,杜绝 [object Object]
各处 catch 里直接 `toast(err?.response?.data?.detail ?? "xx失败")` 有坑:
请求体校验失败(422)时 detail 是数组,React 会把每个对象渲染成 [object Object]。

- utils/errorMessage.ts(新增): extractErrorMessage 统一解析三种形态 ——
  字符串 / FastAPI 校验错误数组 / 对象;数组取 msg 并剥掉 Pydantic 的
  "Value error, " 前缀、带上字段名、同字段去重后用「;」拼接;解析不出内容
  则回落到调用方给的兜底文案。行为与移动端 extractErrorDetail 对齐
- 19 处调用点全部替换:AdminTasksPage(4) / AdminProductsPage(5) /
  TaskTreeViewer(4) / AdminPrintConfigPage(2) / ScanPage / CreateProductDialog /
  ImageUploader / authApi
- TaskRejectPayload.images 与 taskApi.rejectTask 的注释同步为「选填,支持空数组」
- 新增 components/ui/ImageUploader.tsx(驳回弹窗选图上传组件),
  RejectModal 同步放开无图拦截:去掉 images 非空校验、提交按钮不再因
  无图禁用、标签由必填星号改为「(选填,最多 9 张)」,并移除因此失效的
  error 状态
- AdminTasksPage.tsx: 支持大屏下钻的 ?search / ?status / ?stage 预设参数,
  新增「售后流转中」状态 Tab(按 lifecycle_phase 判定)与全局重置;
  ?stage 只在跳入时生效一次,用户手动改条件即解除锁定
- 杂项清理: TaskFlowView 移除透传的 assigneeNames、TaskListCard/MyTasksPage
  移除未使用导入、QrScanner 关闭 verbose 日志
2026-09-15 10:57:56 +08:00
6b3f14e8fd fix: 移动端驳回支持传图、422 报错可读化、修复详情页图片破图
车间的驳回操作不再强制拍照(编号错误等场景无需照片),移动端同步放开。

- utils/request.js: 新增 extractErrorDetail 统一解析后端 detail 的三种形态
  (字符串 / FastAPI 校验错误数组 / 对象),新增 422 拦截分支,并把
  400/403/409 及兜底分支的 `res.data?.detail || x` 全部换掉 —— 原写法在
  detail 是数组时会把整个数组丢给 showToast,弹出 [object Object]
- pages/scan/detail.vue: 驳回弹窗新增与「追加记录」一致的选图/传图 UI,
  doReject 携带 { reason, images }(允许空数组);选图上传流程抽成通用的
  pickAndUploadImages,与追加记录共用
- pages/scan/detail.vue: 新增 imageUrl() 补全后端返回的相对路径
  (/api/v1/upload/files/xxx.jpg 直接给 <image> 会破图),缩略图渲染与
  previewImage 预览均已接入;逻辑与 records.vue / TaskTreeNode.vue 对齐
2026-09-15 10:57:49 +08:00
0d1e45e3fb feat: 管理层数据大屏(后端聚合接口 + 前端全屏页面)
PC 管理端新增独立全屏数据大屏,供管理层查看直通率/产量趋势/不良分布,
替代原 AdminDashboard 上零散的手工下钻。

后端:
- endpoints/screen.py + services/screen_service.py: 大屏聚合接口
  (复用 lifecycle 的售后工序归一,保证统计口径与展示一致)
- router.py: 注册 screen_router

前端:
- pages/admin/ScreenDashboard.tsx: 全屏大屏页(自带鉴权守卫,无侧边栏)
- services/screenApi.ts: 大屏数据接口封装
- components/admin/UserOperationDetailDrawer.tsx: 人员操作明细抽屉,
  由 AdminDashboard 的原生 state 抽成独立组件(含命令式 handle)
- AdminDashboard.tsx: 改为使用该抽屉组件,移除内联的下钻状态
- MatrixBoard.tsx / App.tsx / AdminLayout.tsx: 挂载路由与导航入口
- BaseEChart.tsx: 注册 GaugeChart 与 GraphicComponent
  (graphic 需显式注册,否则饼图中心文字静默不渲染)
2026-09-15 10:57:46 +08:00
71e660b428 feat: 驳回图片改为选填,驳回原因/转交备注落库留痕
业务调整:车间驳回不必须拍照(编号错误、选错工序等场景无需照片举证)。

- schemas/task.py: TaskRejectRequest.images 由必填(min_length=1)改为
  default_factory=list,保留 max_length=9 与 URL 总长保护;reason 增加
  mode="before" 的 strip 校验器,堵住纯空格凑长度绕过 min_length 的口子
  (顺带保证落库的 reject_reason 不带首尾空白)
- task_service.py: 驳回时把原因+图片写入 TaskRecord 挂到被驳回任务下,
  修复前端时间线看不到驳回详情的问题;转交时把交接备注也挂一条记录到
  下家任务,补齐接手人的上下文视角
- task_service.py: create_task / receive_task 写入 overall_status 后调用
  sync_product_status 对齐 status 字段
2026-09-15 10:57:35 +08:00
f22eae315f fix: 售后回流口径统一 — 状态双字段同步、工序名归一、标签配色
产品存在 overall_status 与 status 两个状态字段,此前各写入点各写一份映射、
甚至只改 overall_status 不改 status,导致回流设备(product.status 停留在
OUTBOUND)污染看板统计口径。

- lifecycle.py: 把映射表收敛为单一来源 overall_to_product_status /
  sync_product_status;新增 normalize_after_sales_step,将售后设备沿用生产
  阶段写法的历史工序名(测试/维修)折算到售后区独立工序名
- product_service.py: 删除本地 _OVERALL_TO_STATUS 副本,改用共享函数
- dashboard_service.py: WIP 矩阵补出 lifecycle_phase 列,活跃任务判定
  (is_active) 提前到所有终结态判定之前,避免残留 OUTBOUND 被误判为完结
- scripts/fix_product_status.py: 历史数据修复脚本(一次性)
- constants/task.ts: 售后工序标签由红色改紫色 —— 红色在本系统是「驳回/危险」
  语义色,售后只是另一条流转支线,用红色会让操作员误以为设备报错
2026-09-15 10:57:29 +08:00
31d085881b feat: 移动端工序选项隔离与售后标签 2026-09-14 14:49:38 +08:00
3238e4819a feat: 网页端渲染售后回流标签(发货测试/售后维修) 2026-09-14 14:49:38 +08:00
db3bafcc66 feat: 前端补充生命周期类型定义与工序词表 2026-09-14 14:49:38 +08:00
2de95799c6 feat: 售后回流判定与工序选项隔离守卫(建单/接收/转交) 2026-09-14 14:49:34 +08:00
b40340ea55 feat: 产品接口暴露lifecycle_phase并做阶段感知状态校验 2026-09-14 14:49:34 +08:00
b9f9b897a1 feat: 新增产品生命周期阶段字段lifecycle_phase与售后工序词表 2026-09-14 14:49:34 +08:00
3568d17865 fix: 转交时动态推导task_type,避免协助分支转交被误升为主线 2026-09-14 14:49:26 +08:00
b088d25df9 feat: 任务树/流转卡展示驳回原因与返工备注(网页SlimCard,移动端FlowCard/SwipeCards) 2026-09-09 17:29:40 +08:00
7b7dbbb0d8 fix: WIP矩阵-左外连接纳入无任务新品归待接收,PENDING并入待接收,空负责人显式计数为未分配; 表头去待确认列并做TOP N折叠 2026-09-09 17:29:36 +08:00
d757c7985b fix: WIP矩阵口径重构-在制无条件计入,终结态按完工时间锚点(completed_at兜底created_at)过滤,多维终结归类(含扫码入库/出库节点),下钻同步 2026-09-07 14:46:58 +08:00
78 changed files with 5711 additions and 1668 deletions

View File

@ -0,0 +1,45 @@
"""add_product_lifecycle_phase
Revision ID: i1j2k3l4m5n6
Revises: h1h2h3h4h5h6
Create Date: 2026-09-14
产品生命周期阶段(lifecycle_phase)
--------------------------------
用于区分「生产阶段的测试(发货测试)」与「出库后再次返厂的售后维修」。
存量数据一律回填 'PRODUCTION'(历史产品视为从未出库回流);
新数据由 SQLAlchemy 端 default 提供,双保险。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "i1j2k3l4m5n6"
down_revision: Union[str, None] = "h1h2h3h4h5h6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"products",
sa.Column(
"lifecycle_phase",
sa.String(20),
nullable=False,
server_default="PRODUCTION",
comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)",
),
)
# 存量数据回填:历史产品从未出库回流,统一视为生产阶段
op.execute("UPDATE products SET lifecycle_phase = 'PRODUCTION' WHERE lifecycle_phase IS NULL")
# 加索引:售后设备排查(按阶段筛选)会高频用到
op.create_index(
"ix_products_lifecycle_phase", "products", ["lifecycle_phase"], unique=False,
)
def downgrade() -> None:
op.drop_index("ix_products_lifecycle_phase", table_name="products")
op.drop_column("products", "lifecycle_phase")

View File

@ -5,8 +5,10 @@ from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.time_utils import BEIJING_TZ
from app.services.dashboard_service import (
get_dashboard_stats, DashboardStats,
get_my_stats, MyStats,
get_wip_tasks, WipTask,
get_completed_tasks, CompletedTask,
get_rejected_tasks, RejectedTask,
@ -22,6 +24,18 @@ from app.services.dashboard_service import (
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
def _parse_bound(value: str | None) -> datetime | None:
"""解析 ISO 时间边界。
裸时间(无时区偏移)按**北京时间**解释 —— 否则会被当作服务器本地时间,
在边界上整体偏移 8 小时,出现「选了今日却统计到昨天下午」这类错位。
"""
if not value:
return None
dt = datetime.fromisoformat(value)
return dt.replace(tzinfo=BEIJING_TZ) if dt.tzinfo is None else dt
@router.get("/stats", response_model=DashboardStats)
async def dashboard_stats(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
@ -39,12 +53,32 @@ async def dashboard_stats(
return await get_dashboard_stats(db, since=since_dt, until=until_dt)
@router.get("/wip-tasks", response_model=list[WipTask])
async def wip_tasks(
limit: int = Query(20, ge=1, le=100),
@router.get("/my-stats", response_model=MyStats)
async def my_stats(
assignee_id: str = Query(..., description="负责人ID(移动端传当前登录用户 username)"),
since: str | None = Query(None, description="起始时间 ISO(含时区偏移,如 2026-09-01T00:00:00+08:00);缺省=本月 1 日"),
until: str | None = Query(None, description="截止时间 ISO(含时区偏移);缺省=此刻"),
db: AsyncSession = Depends(get_db),
):
"""在制品看板 — 永远实时的 PENDING/WIP 任务"""
"""
一线工人个人效能 — 移动端「工作统计」页,支持自选时段。
- 生产战绩(完成 / 被驳回 / 参与产品)按本人名下任务归因;
- 操作统计(接收 / 转交 / 上传备注)与 PC /dashboard/user-operations 严格同口径,
工人自查的数与主管看到的面板对得上。
⚠️ since/until 必须带时区偏移。移动端发的是北京时间 (+08:00),
不带偏移的裸字符串会被当成"本地时间"导致边界偏移 8 小时。
"""
return await get_my_stats(db, assignee_id, since=_parse_bound(since), until=_parse_bound(until))
@router.get("/wip-tasks", response_model=list[WipTask])
async def wip_tasks(
limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"),
db: AsyncSession = Depends(get_db),
):
"""在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)"""
return await get_wip_tasks(db, limit)

View File

@ -0,0 +1,52 @@
"""大屏 API — 面向管理层**日常运营与督导**的轻量聚合接口
视角:当月吞吐 / 当前卡点 / 系统活跃度。
与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表),
/screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.screen_service import (
get_monthly_metrics, MonthlyMetrics,
get_wip_distribution, WipDistributionResponse,
get_active_users, ActiveUsersResponse,
)
router = APIRouter(prefix="/screen", tags=["大屏统计"])
@router.get("/monthly-metrics", response_model=MonthlyMetrics)
async def monthly_metrics(db: AsyncSession = Depends(get_db)):
"""
当月吞吐 — 大屏顶部四张数字卡。
返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。
统计区间为北京时间当月 1 日 00:00 至此刻。
"""
return await get_monthly_metrics(db)
@router.get("/wip-distribution", response_model=WipDistributionResponse)
async def wip_distribution(db: AsyncSession = Depends(get_db)):
"""
工序积压分布 — 当前未完结设备按 overall_status 聚合的**纯数量**。
返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷。
"""
return await get_wip_distribution(db)
@router.get("/active-users", response_model=ActiveUsersResponse)
async def active_users(
top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"),
db: AsyncSession = Depends(get_db),
):
"""
本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数。
桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。
"""
return await get_active_users(db, top_n=top_n)

View File

@ -2,6 +2,10 @@
MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态
真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。
同一条入站通道还承担【撤回出库】的强制回滚:MOM 把误点出库的设备物理
回滚到仓库时,Track 必须被动跟随 MOM 的权威物理状态(详见
_mom_inbound_revoke 上方的特权通道说明)。
"""
from __future__ import annotations
@ -14,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.lifecycle import sync_product_status
from app.models.product import Product
from app.models.task import Task, TaskRecord
from app.models.task_log import TaskLog
@ -22,11 +27,101 @@ router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
class MomInboundPayload(BaseModel):
"""MOM 仓储系统确认接收入库的回调载荷"""
"""MOM 仓储系统确认接收入库 / 撤回出库的回调载荷"""
serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配)
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
operator: str | None = None # 入库操作人(写入 task_logs.operator_id)
inbound_time: datetime | None = None # 入库确认时间
# ↓ MOM 侧一直在发、此前被 Pydantic 静默丢弃的字段。撤回信号靠它们识别。
event: str | None = None # 事件名,如 inbound.created / outbound.revoked
action: str | None = None # 显式动作指令,如 revoke_outbound
source_table: str | None = None # stock_product / stock_semi
# 「撤回出库」信号词 —— 只在 action / event 里做子串匹配。
# MOM 侧的字段命名尚未冻结,故刻意宽松:revoke_outbound / outbound.revoked /
# rollback_outbound 都能命中,避免因对方改个词就整条链路失联。
_OUTBOUND_REVOKE_TOKENS = ("revoke", "rollback", "revert", "cancel")
def _is_outbound_revoke(payload: MomInboundPayload) -> bool:
"""payload 是否携带**显式**的撤回出库信号。
注意:返回 False 不代表「不是撤回」——MOM 也可能不加任何标记、直接以
常规 inbound.created 重推。那种隐式信号由调用方用「产品此刻是否处于
已出库」兜底判定(见 mom_inbound_webhook 里的 was_outbound)。
"""
for raw in (payload.action, payload.event):
token = (raw or "").strip().lower()
if token and any(word in token for word in _OUTBOUND_REVOKE_TOKENS):
return True
return False
async def _pick_warehouse_log_task(db: AsyncSession, product: Product) -> Task | None:
"""挑一条挂日志的任务:优先「在库」任务,其次该产品最新任务,都没有则 None。"""
task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
if task is None:
task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
return task
async def _match_inbound_product(
db: AsyncSession, payload: MomInboundPayload, *, allow_outbound: bool,
) -> Product | None:
"""按 serial_number(优先)或 sku 匹配产品。
allow_outbound=False:只认「当前挂在虚拟仓库池」的产品(常规入库的既有语义)。
allow_outbound=True :额外放行「已出库」产品 —— 出库回调会把 current_location_id
置为 None,若仍用原条件,撤回信号必然失配并静默 return matched=False,
造成 MOM 认为货已回库、Track 却永远停在「已出库」的数据脑裂。
"""
location_cond = Product.current_location_id == "virtual_warehouse"
where_cond = (
or_(
location_cond,
Product.overall_status == "已出库",
Product.status == "OUTBOUND",
)
if allow_outbound
else location_cond
)
if payload.serial_number:
return (
await db.execute(
select(Product).where(
or_(
Product.serial_number == payload.serial_number,
Product.external_serial == payload.serial_number,
),
where_cond,
)
)
).scalar_one_or_none()
if payload.sku:
return (
await db.execute(
select(Product)
.where(Product.spec_model == payload.sku, where_cond)
.order_by(Product.created_at.desc())
)
).scalars().first()
return None
@router.post("/mom-inbound")
@ -35,92 +130,127 @@ async def mom_inbound_webhook(
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> dict:
"""MOM 仓储系统确认接收产品入库后回调本接口。
"""MOM 确认接收入库 / 撤回出库后回调本接口。
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
- 用 serial_number(优先)或 sku 查询当前位于 virtual_warehouse 的产品;
命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
- 未命中返回 200(MOM 可能入库了非 Track 生产的物料,直接忽略)。
- 常规入库:用 serial_number(优先)或 sku 匹配「当前位于 virtual_warehouse」
的产品,命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
- 撤回出库:MOM 把误出库的设备物理回滚到仓库 → 本接口强制执行特权回滚。
- 未命中返回 200(MOM 可能操作了非 Track 生产的物料,直接忽略)。
"""
# ── 鉴权 ──
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
# ── 按 serial_number / external_serial(双字段联合)或 sku 匹配"当前位于仓库"的产品 ──
product = None
if payload.serial_number:
product = (
await db.execute(
select(Product).where(
or_(
Product.serial_number == payload.serial_number,
Product.external_serial == payload.serial_number,
),
Product.current_location_id == "virtual_warehouse",
)
)
).scalar_one_or_none()
elif payload.sku:
product = (
await db.execute(
select(Product)
.where(
Product.spec_model == payload.sku,
Product.current_location_id == "virtual_warehouse",
)
.order_by(Product.created_at.desc())
)
).scalars().first()
explicit_revoke = _is_outbound_revoke(payload)
# ── 匹配产品 ──
# 常规入库保持严格匹配;撤回(显式标记,或带 serial 可精确定位)才放宽到已出库产品。
# 刻意不给 sku 兜底也无条件放宽:同型号可能有多台,放宽后可能误标到别的设备。
product = await _match_inbound_product(
db, payload, allow_outbound=explicit_revoke or bool(payload.serial_number),
)
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
if product is None:
return {"ok": True, "matched": False}
# ── 标记"已实收"闭环 ──
# 隐式撤回:payload 没带任何标记,但产品此刻正处于「已出库」。
# 对一台已发货的设备来说,任何入库回调都只能意味着「货回来了」。
was_outbound = (
(product.overall_status or "").strip() == "已出库"
or (product.status or "").strip().upper() == "OUTBOUND"
)
is_revoke = explicit_revoke or was_outbound
# ══════════════════════════════════════════════════════════════════════
# ★ 特权通道 — MOM 的物理状态同步优先级最高,强制覆写、不受任何内部守卫约束
#
# 与 task_service.py 的【绝对物理终态保护】(PHYSICAL_TERMINAL_OVERALL,
# task_service.py:115-127) 方向刻意相反:那套保护约束的是「车间内部流转
# 不许用工序名抹掉物理终态」;而本接口是物理事实的**权威来源**——MOM 说
# 货已回到仓库,Track 必须无条件跟随。
#
# ⚠️ 后续维护者:不要在此处添加 _is_physical_terminal / 状态互斥 / 仅当
# 状态为 X 才允许覆写 之类的校验。那会让设备永远卡在「已出库」,
# 与 MOM 账面对不上——正是本次要消灭的数据脑裂。
# ══════════════════════════════════════════════════════════════════════
changed = False
# 1) 宏观状态强制覆写为「已入库」(撤回时从「已出库」拉回)
if product.overall_status != "已入库":
product.overall_status = "已入库"
# 双字段同步:整体状态与产品状态保持一致(前端徽标依赖 status)
product.status = "ARCHIVED"
changed = True
# 记录仓库接收日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态)
inbound_task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
if inbound_task is None:
inbound_task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
# 2) 物理位置强制回滚到虚拟仓库池(出库回调曾把它置为 None)
if product.current_location_id != "virtual_warehouse":
product.current_location_id = "virtual_warehouse"
changed = True
# 3) 双字段同步:lifecycle.py 约定凡改写 overall_status 必调一次。
# (原实现在这里硬编码 product.status="ARCHIVED",绕过了约定,一并纠正)
# ⚠️ 必须把 status 的变化也计入 changed:否则当 overall_status / location
# 本来就已经正确时,这一处纠偏会因为 changed 保持 False 而永远不提交。
prev_status = product.status
sync_product_status(product)
if product.status != prev_status:
changed = True
# ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ──
log_task = await _pick_warehouse_log_task(db, product)
if log_task is not None:
if is_revoke:
signal = payload.action or payload.event or "inbound.created(隐式)"
remark = (
f"MOM 撤回出库 → 强制回滚:宏观状态已入库、"
f"位置已回到 virtual_warehouse(信号: {signal})"
)
action_type = "warehouse_outbound_revoked"
else:
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
remark = f"MOM 仓储系统确认接收入库(inbound_time: {time_str})"
action_type = "warehouse_inbound"
if inbound_task is not None:
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
db.add(TaskLog(
task_id=inbound_task.id,
task_id=log_task.id,
operator_id=(payload.operator or "virtual_warehouse")[:64],
action_type="warehouse_inbound",
remark=f"MOM 仓储系统确认接收入库(inbound_time: {time_str})",
action_type=action_type,
remark=remark,
))
changed = True
# ── 动态生成"扫码入库"主线任务节点 + 操作日志(流转树最底部长出入库节点) ──
if await _append_warehouse_task(db, product, "扫码入库", "通过 MOM 系统扫码入库完成"):
# ── 动态生成主线任务节点 + 操作日志(流转树最底部长出节点) ──
if is_revoke:
# 撤回必须留痕:否则流转树末节点仍是「扫码出库」,而产品徽标已是
# 「已入库」,这种可见的自相矛盾会让车间不敢信这套数据。
#
# ⚠️ 节点名里的「(重新入库)」不是装饰,是 [必须保留] 的契约:
# product_service.py:166-174 的 _has_warehouse_task() 用**子串**判定
# 仓库节点("在库" in task_name or "入库" in task_name)。而
# 「撤回出库」四个字里只有"出库"、不含"入库",会让它判定为"无仓库任务",
# 进而给 location==virtual_warehouse 的产品注入一个假的「已完成 /
# 待仓库扫码」虚拟节点(product_service.py:237-242 的情况 A)——
# 该设备明明已入库且在仓库里,树尾却显示待收货。
# 补上「重新入库」后关键字命中,虚拟节点不再注入。
appended = await _append_warehouse_task(
db, product, "撤回出库(重新入库)", "MOM 撤回出库,设备已物理回滚至仓库",
)
else:
appended = await _append_warehouse_task(
db, product, "扫码入库", "通过 MOM 系统扫码入库完成",
)
if appended:
changed = True
if changed:
await db.commit()
return {"ok": True, "matched": True, "serial_number": product.serial_number}
return {
"ok": True,
"matched": True,
"serial_number": product.serial_number,
"revoked": is_revoke,
}
async def _append_warehouse_task(
@ -256,24 +386,16 @@ async def mom_outbound_webhook(
product.status = "OUTBOUND"
changed = True
# 🚚 同步出清厂内位置(与 _recalc_product_location 的「货发走就离场」对齐):
# 本回调的匹配条件之一就是 current_location_id == "virtual_warehouse",
# 即设备此刻还挂在仓库池里。既然 MOM 已确认发货,就不该再显示为厂内仓库/工位。
# 置空后产品列表的「当前位置」显示为「—」。
if product.current_location_id is not None:
product.current_location_id = None
changed = True
# 记录出库日志(优先"在库"任务,其次该产品最新任务)
outbound_task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
if outbound_task is None:
outbound_task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalars().first()
outbound_task = await _pick_warehouse_log_task(db, product)
if outbound_task is not None:
time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—"

View File

@ -16,6 +16,7 @@ from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.holidays import router as holidays_router
from app.api.v1.endpoints.webhooks import router as webhooks_router
from app.api.v1.endpoints.external_products import router as external_products_router
from app.api.v1.endpoints.screen import router as screen_router
api_router = APIRouter()
@ -35,3 +36,4 @@ api_router.include_router(analytics_router)
api_router.include_router(holidays_router)
api_router.include_router(webhooks_router)
api_router.include_router(external_products_router)
api_router.include_router(screen_router)

View File

@ -0,0 +1,196 @@
"""生命周期阶段(Lifecycle Phase)词汇表与选项隔离 — 单一事实来源
生产制造(PRODUCTION)与售后回流(AFTER_SALES)各自拥有一套合法的
宏观状态 / 工序名。回流设备绝不允许被重新排产回「备货 / 生产」等前期环节,
本模块集中定义两套词表并提供校验,前端各端复制同一份口径即可。
设计要点
--------
1. 售后阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
「测试 / 维修」。这样报表、看板、导出无需 JOIN lifecycle_phase 就能区分,
也不会出现"同一个词两种含义"。
2. 选定售后专属工序 = 设备进入售后生命周期,单向不可回退。
这条规则同时覆盖两类设备:
- 已出库后被重新派发任务(由 task_service 的 outbound 判定捕获)
- 无任何历史记录、直接走售后流程的老设备(首次选定售后工序即判定)
3. 「待确认」与仓库虚拟节点是建单占位符,不参与阶段校验,否则会卡死建单流程。
"""
from __future__ import annotations
# ============================================================
# 生命周期阶段
# ============================================================
LIFECYCLE_PRODUCTION = "PRODUCTION" # 生产制造阶段(发货前)
LIFECYCLE_AFTER_SALES = "AFTER_SALES" # 已出库后再次回流返厂(售后)
PHASE_LABELS = {
LIFECYCLE_PRODUCTION: "生产制造阶段",
LIFECYCLE_AFTER_SALES: "售后回流阶段",
}
# ============================================================
# 售后专属工序名
# ============================================================
STEP_SHIP_TEST = "发货测试" # 出厂 / 发货前测试
STEP_AFTER_SALES_REPAIR = "售后维修" # 售后返修本体
# 售后区独立成列所用的两个工序名(前端分列 / 大屏返厂统计仍以它为准)
AFTER_SALES_ONLY_STEPS = frozenset({STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR})
# 🔧 工序语义细分(2026-09-17)——「售后专属」不等于「回流返厂」:
# · 出厂质检(发货测试):设备可能压根没回厂,只是补做发货前测试。
# 它【不】构成回流信号,选中它不应把设备永久烙进售后生命周期。
# · 明确修机指令(售后维修):设备确实是回厂返修 → 判定回流。
# 历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
# AFTER_SALES 烙印(售后死锁),而「已出库」的物理终态也被抹掉。
OUTBOUND_QC_STEPS = frozenset({STEP_SHIP_TEST})
AFTER_SALES_REPAIR_STEPS = frozenset({STEP_AFTER_SALES_REPAIR})
# ============================================================
# 各阶段合法的工序名 / 宏观状态名
# ============================================================
# ── 生产制造阶段 ──
# 🔧 2026-09-17 补入「发货测试」:出厂质检现在被允许在生产阶段执行
# (设备的 lifecycle_phase 不再因选中它而翻成售后)。若不补,
# _enforce_step_isolation 会把「已出库设备的发货测试」直接 400 掉。
PRODUCTION_TASK_STEPS = ("备货", "生产", "测试", "维修", "在库")
PRODUCTION_OVERALL_STEPS = (
"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库",
STEP_SHIP_TEST,
)
# ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
AFTER_SALES_TASK_STEPS = (STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库")
AFTER_SALES_OVERALL_STEPS = (
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "待仓库收货", "已入库", "已出库",
)
# 仅属于生产制造阶段的工序名 — 售后设备出现即视为"跨阶段误排"。
# 注意:「在库 / 已入库 / 已出库」是两阶段通用的,不在此列。
PRODUCTION_ONLY_STEPS = frozenset({"备货", "生产", "测试", "维修"})
# 建单占位工序名 — 建单时为「待确认」,接收时才由操作员选定真实工序
PLACEHOLDER_STEPS = frozenset({"待确认"})
# 仓库虚拟节点标记(转交入库时作为 assignee 传递)
VIRTUAL_WAREHOUSE = "virtual_warehouse"
# 两阶段并集 — 用于"完全非法取值"的第一道粗筛
ALL_OVERALL_STEPS = frozenset(PRODUCTION_OVERALL_STEPS) | frozenset(AFTER_SALES_OVERALL_STEPS)
_ALLOWED_BY_PHASE = {
LIFECYCLE_PRODUCTION: frozenset(PRODUCTION_OVERALL_STEPS),
LIFECYCLE_AFTER_SALES: frozenset(AFTER_SALES_OVERALL_STEPS),
}
# ============================================================
# 查询 / 校验
# ============================================================
def phase_label(phase: str | None) -> str:
"""阶段的中文名(用于报错文案)"""
return PHASE_LABELS.get(phase or "", PHASE_LABELS[LIFECYCLE_PRODUCTION])
def allowed_steps(phase: str | None) -> tuple[str, ...]:
"""该阶段合法的全部工序/状态名"""
if (phase or "") == LIFECYCLE_AFTER_SALES:
return AFTER_SALES_OVERALL_STEPS
return PRODUCTION_OVERALL_STEPS
def is_placeholder_step(step: str | None) -> bool:
"""建单占位符(待确认)/ 仓库虚拟节点 — 不做阶段校验
否则 create_task 会被「待确认」卡住,转交入库会被
「🏭 入库 (virtual_warehouse)」卡住,整条流程直接断掉。
"""
if not step:
return True
return step in PLACEHOLDER_STEPS or VIRTUAL_WAREHOUSE in step
def resolve_phase_for_step(current_phase: str | None, step: str | None) -> str:
"""选定【明确修机指令】→ 设备进入售后生命周期(单向,不可回退)
⚠️ 只有「售后维修」触发,出厂质检(发货测试)【不】触发。
「已出库的设备补做发货测试」不代表它回厂返修;若在此烙印,
设备会被永久打成售后(该标志单向不可回退),这就是售后死锁的第二个入口。
这是"无历史记录的老设备"进入售后阶段的入口:首次选定「售后维修」即判定回流。
"""
if step and step.strip() in AFTER_SALES_REPAIR_STEPS:
return LIFECYCLE_AFTER_SALES
return current_phase or LIFECYCLE_PRODUCTION
def is_step_allowed(phase: str | None, step: str | None) -> bool:
"""工序名在该生命周期阶段下是否合法"""
if is_placeholder_step(step):
return True
allowed = _ALLOWED_BY_PHASE.get(
phase or LIFECYCLE_PRODUCTION, _ALLOWED_BY_PHASE[LIFECYCLE_PRODUCTION],
)
return step.strip() in allowed
# 老数据兼容:售后设备的工序曾沿用生产阶段的「测试 / 维修」写法
# (售后独立工序名是后加的)。统计/看板展示时折算到售后区的独立工序名,
# 否则售后设备的历史工序会混进生产区的同名工序,导致统计失真。
AFTER_SALES_STEP_ALIAS = {
"测试": STEP_SHIP_TEST,
"维修": STEP_AFTER_SALES_REPAIR,
}
def normalize_after_sales_step(phase: str | None, step: str | None) -> str | None:
"""售后回流设备的历史工序名归一 —— 折算到售后区的独立工序名。
仅对 AFTER_SALES 生效;生产设备的「测试 / 维修」原样保留。
统计口径(WIP 矩阵 / 下钻)必须与展示口径一致,故集中在模块内。
"""
if (phase or "") != LIFECYCLE_AFTER_SALES or not step:
return step
return AFTER_SALES_STEP_ALIAS.get(step.strip(), step)
# ============================================================
# overall_status(中文宏观状态) ⇄ product.status(英文状态码)
# ============================================================
# 这两个字段必须始终同步。历史缺陷:receive_task / transfer_task 只改
# overall_status 不改 status,导致设备出库回流后 status 永远停在 OUTBOUND,
# 统计口径被污染(WIP 矩阵曾因此把正在做「发货测试」的设备吞进「已出库」列)。
# 所有改写 overall_status 的地方一律调 sync_product_status()。
# 终态映射;不在表内的(备货/生产/测试/维修/发货测试/售后维修…)都是活跃工序 → WIP
OVERALL_TO_PRODUCT_STATUS = {
"已入库": "ARCHIVED",
"在库": "ARCHIVED",
"已出库": "OUTBOUND",
"待仓库收货": "COMPLETED",
}
DEFAULT_PRODUCT_STATUS = "WIP"
def overall_to_product_status(overall: str | None) -> str:
"""中文宏观状态 → product.status 状态码。
活跃工序(含售后「发货测试 / 售后维修」)不在映射表里,统一落到 WIP ——
这正是消除「status 残留在 OUTBOUND」的关键。
"""
if not overall:
return DEFAULT_PRODUCT_STATUS
return OVERALL_TO_PRODUCT_STATUS.get(overall.strip(), DEFAULT_PRODUCT_STATUS)
def sync_product_status(product) -> str:
"""把 product.status 与 overall_status 对齐,返回落定的状态码。
刻意用 duck-typing 而非导入 ORM 模型,避免 core 层反向依赖 models。
调用点:任何改写 product.overall_status 之后都必须调一次。
"""
product.status = overall_to_product_status(getattr(product, "overall_status", None))
return product.status

View File

@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.core.time_utils import get_beijing_time
from app.core.lifecycle import LIFECYCLE_PRODUCTION
class Product(Base):
@ -64,6 +65,13 @@ class Product(Base):
overall_status: Mapped[str | None] = mapped_column(
String(20), nullable=True, comment="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库",
)
# 生命周期阶段 — 生产制造 vs 出库后返厂售后。
# 设备出库后再次被派发任务 = 回流返厂,此处切为 AFTER_SALES 且不再回退。
lifecycle_phase: Mapped[str] = mapped_column(
String(20), nullable=False, default=LIFECYCLE_PRODUCTION,
server_default=LIFECYCLE_PRODUCTION, index=True,
comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)

View File

@ -53,7 +53,17 @@ class ProductResponse(BaseModel):
current_location_name: str | None = None
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
overall_status: str | None = None
# 【当前工序】—— 只反映"此刻在做什么",绝不拿历史工序冒充。
# · 有活跃主干任务(WIP/PENDING) → 该任务工序名
# · 否则产品处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态(表达"货在哪")
# · 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
# ⚠️ 必须与 overall_status 分开:ProductResponse.overall_status 会被"最新主干任务名"
# 覆盖(见 product_service 的 overall_names),于是已 COMPLETED 的历史工序
# (扫码出库 / 测试 / 发货测试…)会被当成"当前工序"长期展示。
current_step: str = ""
status: str
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION"
created_at: datetime
# 🔧 最新动态 — 该产品活跃任务的最新记录
latest_record_time: datetime | None = None
@ -86,6 +96,8 @@ class ProductScanResponse(BaseModel):
current_location_id: str | None = None
overall_status: str | None = None
status: str
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION"
created_at: datetime
top_level_tasks: list[TaskSummaryResponse] = []
task_tree: list[TaskResponse] = []

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
# ============================================================
@ -49,14 +49,43 @@ class SubtaskCreate(BaseModel):
class TaskRejectRequest(BaseModel):
"""品质驳回请求"""
"""品质驳回请求 — 驳回原因必填;异常图片【选填】
(编号错误、工序选错等场景无需拍照举证,故不强制传图)"""
reason: str = Field(..., min_length=1, max_length=500, description="驳回原因(必填)")
images: list[str] = Field(
default_factory=list, max_length=9,
description="异常图片 URL 列表(选填,可传空数组,最多 9 张)",
)
@field_validator("reason", mode="before")
@classmethod
def _strip_reason(cls, v):
"""先 strip 再交给 min_length 校验:否则纯空格(' ')能凑够长度绕过必填。
顺带保证落库的 Task.reject_reason / TaskRecord.remark 不带首尾空白。
非字符串原样返回,让 Pydantic 抛出正常的类型错误。"""
return v.strip() if isinstance(v, str) else v
@field_validator("images")
@classmethod
def _validate_images(cls, v: list[str]) -> list[str]:
"""剥离空串(前端可能提交 [''] 之类的占位),并做总长度上限校验,
避免落库时才撞上 TaskRecord.images 的 String(4000) 上限。图片选填,允许为空。"""
urls = [u.strip() for u in v if u and u.strip()]
if sum(len(u) for u in urls) > 3500:
raise ValueError("异常图片 URL 总长度超限,请减少图片数量")
return urls
class TaskTransferBranch(BaseModel):
"""裂变分支"""
task_name: str = Field(..., max_length=200, description="工序名称")
assignees: list[str] = Field(..., min_length=1, description="接收人列表")
assignees: list[str] = Field(
default_factory=list, min_length=0,
description=(
"接收人列表。允许为空数组,但仅当 finish_directly=True 时合法——"
"空分支不产生任何下游任务(见 TaskTransferRequest 的校验)。"
),
)
class TaskTransferRequest(BaseModel):
@ -65,6 +94,29 @@ class TaskTransferRequest(BaseModel):
next_task_name: str | None = Field(None, max_length=200, description="[旧版] 下一道工序名称")
next_tasks: list[TaskTransferBranch] | None = Field(None, description="[新版] 多分支任务列表")
note: str | None = Field(None, description="交接备注")
finish_directly: bool = Field(
False,
description=(
"直接完结:闭环当前任务但【不产生任何下游任务】。"
"它只是一个任务闭环动作,【不改动】产品的 overall_status —— "
"已出库的设备完结后依然是已出库(不入库,也不会变成「待仓库收货」)。"
"权限:仅 SUPER_ADMIN / SUPERVISOR 可调用,其余角色 403。"
"置 True 时忽略 next_tasks / next_assignees。"
),
)
@model_validator(mode="after")
def _reject_silent_empty_branch(self):
"""空 assignees 分支会让「转交」静默退化成「直接完结」——任务闭环了却没人接手,
是个丢件级隐患。想直接完结必须显式传 finish_directly=true,不能靠漏填凑合。"""
if self.finish_directly:
return self
if any(not b.assignees for b in (self.next_tasks or [])):
raise ValueError(
"分支 assignees 不能为空;若意图是「直接完结该任务」,"
"请改为传 finish_directly=true"
)
return self
class TaskRecordCreate(BaseModel):

View File

@ -4,6 +4,8 @@ from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.core.lifecycle import normalize_after_sales_step
# ============================================================
# Schemas
@ -24,6 +26,30 @@ class DashboardStats(BaseModel):
unread_messages: int = 0
class MyStats(BaseModel):
"""一线工人个人效能 — /dashboard/my-stats 桥接口径。
与 DashboardStats 的「上帝视角」不同,这里强制过滤到本人;
供移动端「个人中心 → 工作统计」使用,支持自选时段。
两组指标口径:
「生产战绩」按 Task.assignee_id 归因(我名下的活);
「操作统计」与 PC /dashboard/user-operations 完全一致,
便于工人自查看到的数与管理端面板对得上。
"""
assignee_id: str
# ── 生产战绩(所选时段内)──
tasks_completed: int = 0 # 完成数
tasks_rejected: int = 0 # 被驳回数(本人名下任务被驳回)
products_touched: int = 0 # 参与产品数(按 product_id 去重)
# ── 操作统计(与 PC /dashboard/user-operations 同一口径,所选时段内)──
receive_count: int = 0 # 接收次数
transfer_count: int = 0 # 转交次数
record_count: int = 0 # 上传备注次数
op_total: int = 0 # 操作总计 = 接收 + 转交 + 上传备注
class WipTask(BaseModel):
task_id: str
task_name: str
@ -96,6 +122,8 @@ class WipMatrixDetailRow(BaseModel):
material_name: str = "" # 产品名称
spec_model: str = ""
task_status: str = "" # 当前状态 WIP/PENDING/COMPLETED/ARCHIVED/OUTBOUND
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION"
assignee_id: str = ""
assignee: str = "" # 负责人中文名
duration_hours: float = 0.0 # 已在该工序滞留时长(小时)
@ -154,6 +182,59 @@ class ProductMessageList(BaseModel):
total: int
# ============================================================
# 看板「当下性」闸门 —— 返工任务可见性
# ============================================================
def _rework_visible_condition(
month_start: datetime,
since: datetime | None = None,
until: datetime | None = None,
):
"""构造返工任务在看板上的可见条件。
⚠️ 卡片数字(get_dashboard_stats.tasks_rework)与明细抽屉
(get_rejected_tasks 的 kind="rework")**必须共用这一份**,否则会出现
「按钮显示 2、点开却是共 0 条」的自相矛盾。
背景(2026-09 整改):原实现里返工查询是 `is_rework=True AND status != REJECTED`
的纯实时快照,没有任何状态/时间边界,导致上季度甚至更早的「已返工完成」记录
无限期挂在品质异常区持续报警 —— 报警永远不消,等于没有报警。
规则:
- 未完结卡点(PENDING / WIP):无论发生在何时都展示。没解决的异常必须
一直可见,这正是看板的价值所在。
- 已了结的(COMPLETED / ARCHIVED / CANCELED …):只保留「本月 1 日
(北京时间)之后」的,并进一步尊重调用方所选时段。
用 status.notin_(开放态) 而不是枚举已了结态:将来新增状态时会自动落进
「已了结」一侧(受时间约束),而不是凭空消失或无限期留存。
"""
from datetime import timezone
from sqlalchemy import and_
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
open_states = [TASK_STATUS_PENDING, TASK_STATUS_WIP]
open_cond = Task.status.in_(open_states)
# 已了结项的时间下限 = max(本月 1 日, 调用方给的 since):
# 既保证「绝不早于本月」,也不会越过用户选定的时段。
# since 可能是裸时间(无时区),先补成 UTC 再比较,否则 max() 会抛 TypeError。
floor = month_start
if since is not None:
floor = max(floor, since if since.tzinfo else since.replace(tzinfo=timezone.utc))
closed_cond = and_(
Task.status.notin_(open_states),
Task.completed_at.isnot(None),
Task.completed_at >= floor,
)
if until is not None:
closed_cond = and_(closed_cond, Task.completed_at <= until)
return or_(open_cond, closed_cond)
# ============================================================
# 看板统计(时间快照语义)
# ============================================================
@ -177,32 +258,78 @@ async def get_dashboard_stats(
)
from app.models.notification import Notification
from app.models.message import ProductMessage
# ── 产品(实时快照,不过滤) ──
# ── 产品流转三段 ──
# 状态口径(用户确认):废弃 Product.status 恒值判断,
# COMPLETED=待仓库收货,ARCHIVED=已入库('在库' 旧命名等价),OUTBOUND=已出库。
# 产品流转卡片第三段"已完结"= 待收货 + 已实收 + 旧在库 + 已出库,保证三段和 = 总数。
p_total = await db.scalar(select(func.count(Product.id)))
#
# ⚠️「已完结」= 产出,必须响应用户在右上角选的时间范围(since / until),
# 不能钉死在某个固定窗口 —— 否则选「今天」却看到「本月」的产出,业务语义就裂了。
# 历史上这里先是无边界(历史全量无限累加),后又改成硬编码「本月」,两次都错。
# Product 表没有 completed_at / updated_at(只有 created_at),故用它名下工序的
# 完成时间来锚定「完结时刻」:只要有一条 Task.completed_at 落在所选时段内即计入。
#
# 「待流转 / 流转中」保持实时快照:只要还没完结,不论多久以前建的单都要统计出来 ——
# 它们才是车间真实的积压物料,正是看板要盯的东西。
from sqlalchemy import and_
# 时间下限优先取调用方所选时段的起点;未给时段时退回「本月」作为**防御性下限**,
# 避免退化成历史全量累加(注意用独立变量,不要覆盖 since —— 下方 t_done / t_rej
# 与返工可见性还要用它,改了会连带改变那些口径)。
if since is not None:
done_since = since
else:
from app.services.screen_service import _month_bounds
done_since = _month_bounds()[0]
finished_cond = Product.overall_status.in_(["待仓库收货", "已入库", "在库", "已出库"])
p_done = await db.scalar(select(func.count(Product.id)).where(finished_cond))
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
# 在制 WIP = 未完结 且 存在活跃任务(PENDING/WIP) 的产品数
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
has_active_task = select(Task.id).where(
Task.product_id == Product.id,
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
).exists()
p_progress = await db.scalar(
select(func.count(Product.id)).where(not_finished, has_active_task)
# 所选时段内完结 = 已完结 且 名下有工序在该时段内完成
done_time_conds = [
Task.product_id == Product.id,
Task.completed_at.isnot(None),
Task.completed_at >= done_since,
]
if until is not None:
done_time_conds.append(Task.completed_at <= until)
done_in_range = and_(
finished_cond,
select(Task.id).where(*done_time_conds).exists(),
)
# 待流转 = 总数 - 在制 - 完结(三段互斥,保证进度条总和=总数)
p_pending = max((p_total or 0) - (p_progress or 0) - (p_done or 0), 0)
p_unfinished = await db.scalar(select(func.count(Product.id)).where(not_finished)) or 0
p_progress = await db.scalar(
select(func.count(Product.id)).where(not_finished, has_active_task)
) or 0
p_done = await db.scalar(select(func.count(Product.id)).where(done_in_range)) or 0
# ── 任务实时快照(PENDING/WIP/返工 — 永远不过滤) ──
# 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结
# (三段互斥,保证进度条总和 = 展示总数)
p_pending = max(p_unfinished - p_progress, 0)
p_total = p_unfinished + p_done
# ── 任务实时快照(PENDING/WIP — 永远不过滤) ──
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP))
t_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True)))
# 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。
# 与 get_rejected_tasks 的 kind="rework" 共用同一份可见性条件,
# 保证「品质异常」卡片数字与点开后的明细条数对得上。
from app.services.screen_service import _month_bounds
month_start_utc, _, _ = _month_bounds()
t_rework = await db.scalar(
select(func.count(Task.id)).where(
Task.is_rework.is_(True),
_rework_visible_condition(month_start_utc, since, until),
)
)
# ── 任务已完成/驳回(时间可过滤) ──
t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED)
@ -241,11 +368,129 @@ async def get_dashboard_stats(
)
# ============================================================
# 个人效能(按 assignee_id 过滤到本人 — 移动端工作统计)
# ============================================================
async def get_my_stats(
db: AsyncSession,
assignee_id: str,
since: datetime | None = None,
until: datetime | None = None,
) -> MyStats:
"""一线工人个人效能:生产战绩 + 操作统计(均可按自选时段)。
时段缺省为「本月」;由调用方(移动端)传入北京时间边界。
【生产战绩】按 Task.assignee_id 归因:
- 完成 = status == COMPLETED 且 completed_at 落在区间内(与
get_dashboard_stats 的 t_done_q 同一口径,只多加了 assignee_id 过滤)
- 被驳回 = status == REJECTED。reject_task 只把状态改成 REJECTED 并写
completed_at,assignee_id 仍是被驳回的那个人,直接过滤即可拿到
「我的活被驳了几次」
- 参与产品数按 product_id 去重 —— 同一台设备做多道工序只算一台
【操作统计】与 PC /dashboard/user-operations 严格同口径,便于工人自查的
数与主管看到的面板对得上:
- 接收 task_logs.action_type='receive' ,按 operator_id 归因
- 转交 task_logs.action_type='complete',按 operator_id 归因
- 上传备注 task_records 按 **Task.assignee_id** 归因,并排除以 '[' 开头的
系统自动备注(如 "[接收] 操作员已确认接收")
注意前两项归因于 operator_id、第三项归因于 assignee_id —— 这是 PC 端既有
口径,此处刻意保持一致,不要"顺手统一"。
时间边界统一用北京时间再转 UTC:时间戳字段都是 timestamptz,拿北京时区的
aware datetime 比较语义一致,且不受数据库会话时区影响
(与 screen_service._month_bounds 的写法一致)。
"""
from datetime import timezone as dt_timezone
from app.core.time_utils import get_beijing_time
from app.models.task import Task, TaskRecord, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED
from app.models.task_log import TaskLog
from sqlalchemy import or_
now_bj = get_beijing_time()
if since is None:
since = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if until is None:
until = now_bj
since_utc = since.astimezone(dt_timezone.utc)
until_utc = until.astimezone(dt_timezone.utc)
# ── 1. 生产战绩 ──
async def _count_task(status: str) -> int:
return await db.scalar(
select(func.count(Task.id)).where(
Task.assignee_id == assignee_id,
Task.status == status,
Task.completed_at.isnot(None),
Task.completed_at >= since_utc,
Task.completed_at <= until_utc,
)
) or 0
products_touched = await db.scalar(
select(func.count(func.distinct(Task.product_id))).where(
Task.assignee_id == assignee_id,
Task.status == TASK_STATUS_COMPLETED,
Task.completed_at.isnot(None),
Task.completed_at >= since_utc,
Task.completed_at <= until_utc,
)
) or 0
# ── 2. 操作统计(口径同 get_user_operations,只是固定到本人)──
async def _count_log(action_type: str) -> int:
return await db.scalar(
select(func.count(TaskLog.id)).where(
TaskLog.operator_id == assignee_id,
TaskLog.action_type == action_type,
TaskLog.created_at >= since_utc,
TaskLog.created_at <= until_utc,
)
) or 0
# 备注归因给任务负责人(不是记录人)—— 与 PC 面板一致
record_count = await db.scalar(
select(func.count(TaskRecord.id))
.join(Task, TaskRecord.task_id == Task.id)
.where(
Task.assignee_id == assignee_id,
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
TaskRecord.created_at >= since_utc,
TaskRecord.created_at <= until_utc,
)
) or 0
receive_count = await _count_log("receive")
transfer_count = await _count_log("complete")
return MyStats(
assignee_id=assignee_id,
tasks_completed=await _count_task(TASK_STATUS_COMPLETED),
tasks_rejected=await _count_task(TASK_STATUS_REJECTED),
products_touched=products_touched,
receive_count=receive_count,
transfer_count=transfer_count,
record_count=record_count,
op_total=receive_count + transfer_count + record_count,
)
# ============================================================
# 在制品看板(永远实时)
# ============================================================
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]:
"""在制品看板 — 永远实时的 PENDING/WIP 任务。
默认返回**全部**活跃任务,limit 只是防御性安全上限。
⚠️ 这里曾默认 limit=20,且先 `.limit(limit * 2)` 预取 40 条、按滞留时长排序后
再 `[:limit]` 截断 —— 列表被静默砍到 20 条,而卡片数字来自
tasks_pending + tasks_in_progress 的真实计数,于是看板上出现
「卡片 23、列表却写共 20 个」的数据被吞现象。
"""
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product
from app.models.holiday import Holiday
@ -255,11 +500,11 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
# 不在 SQL 层做预取限制:排序在内存里按滞留时长进行,截断只作为上限保护
stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id)
.where(Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]))
.limit(limit * 2)
)
result = await db.execute(stmt)
rows = result.all()
@ -296,7 +541,7 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
))
# 统一按滞留时间降序排列(无视 status,纯数值排序)
wip_list.sort(key=lambda t: t.duration_hours, reverse=True)
return wip_list[:limit]
return wip_list[:limit] # limit 仅作上限保护,默认 500 实际不会截断
# ============================================================
@ -370,10 +615,14 @@ async def get_rejected_tasks(
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
- kind="rejected":被驳回任务,按 completed_at 时间过滤
- kind="rework":返工任务(is_rework=True 且非驳回状态),实时快照不过滤时间
- kind="rework":返工任务(is_rework=True 且非驳回状态)。
未完结(PENDING/WIP)永远展示;已了结的仅限本月 —— 详见
_rework_visible_condition。该条件与 get_dashboard_stats 的 t_rework
共用,保证卡片数字与明细条数一致。
返工负责人追溯逻辑与 task_service.reject_task 一致:
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
返工负责人(rework_assignee)不再复刻 reject_task 的推算逻辑,而是直接查
本次驳回派生出的那条返工任务、读其**当前** assignee_id —— 这样返工任务事后
被转交/撤回时,看板显示的仍是真实责任人。详见 _derived_rework_assignee。
"""
from app.models.task import Task, TASK_STATUS_REJECTED
from app.models.task_log import TaskLog
@ -403,11 +652,20 @@ async def get_rejected_tasks(
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
rejected_rows = (await db.execute(stmt)).all()
# ── 2. 返工任务(is_rework=True 且当前非驳回状态,实时不过滤时间)──
# ── 2. 返工任务(is_rework=True 且当前非驳回状态)──
# 原实现此处无任何状态/时间边界,是本页「数据发霉」的根源:上季度甚至更早的
# 「已返工完成」记录会无限期留在品质异常区。改由 _rework_visible_condition
# 统一约束 —— 未完结卡点永远展示,已了结的仅限本月(详见该函数说明)。
from app.services.screen_service import _month_bounds
month_start_utc, _, _ = _month_bounds()
rework_stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id)
.where(Task.is_rework.is_(True), Task.status != TASK_STATUS_REJECTED)
.where(
Task.is_rework.is_(True),
Task.status != TASK_STATUS_REJECTED,
_rework_visible_condition(month_start_utc, since, until),
)
.order_by(Task.created_at.desc())
.limit(limit)
)
@ -438,41 +696,44 @@ async def get_rejected_tasks(
if row[1]:
reject_op[row[0]] = row[1]
create_op: dict = {}
if rejected_ids:
sub = (
select(
TaskLog.task_id, TaskLog.operator_id,
func.row_number().over(
partition_by=TaskLog.task_id,
order_by=TaskLog.created_at.asc(),
).label("rn"),
)
.where(TaskLog.task_id.in_(rejected_ids), TaskLog.action_type == "create")
).subquery()
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
for row in r:
if row[1]:
create_op[row[0]] = row[1]
parent_assignee: dict = {}
parent_ids = [t.parent_task_id for t, *_ in rejected_rows if t.parent_task_id and t.id not in create_op]
if parent_ids:
r = await db.execute(
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids))
# ── 派生返工任务的真实负责人 ──
# 不再复刻 reject_task 的推算逻辑去「猜」当时派给了谁 —— 那只是把当时的推导
# 重跑一遍,返工任务事后一旦被转交/撤回,看板就会继续显示当初那个旧名字。
# 改为去库里定位真正由本次驳回派生出的那条返工任务,读它**当前**的 assignee_id。
#
# 定位方式:reject_task 建返工任务时只继承了 product_id 与 parent_task_id
# (数据模型里没有「派生自哪条任务」的外键),故按「同产品 + 同父节点 +
# is_rework」筛出候选,再用「创建时间在驳回之后的最早一条」消歧 —— 若返工任务
# 又被驳回,会再派生一条更晚的,取最早即命中本次那条。
# 实测两者时间戳完全相等:reject_task 里用的是同一个 now。
rework_candidates: dict = {}
product_ids = {t.product_id for t, *_ in rejected_rows if t.product_id}
if product_ids:
cand_rows = await db.execute(
select(Task.parent_task_id, Task.product_id, Task.created_at, Task.assignee_id)
.where(Task.is_rework.is_(True), Task.product_id.in_(product_ids))
.order_by(Task.created_at.asc())
)
for row in r:
if row[1]:
parent_assignee[row[0]] = row[1]
for parent_id, prod_id, created_at, assignee_id in cand_rows.all():
rework_candidates.setdefault((parent_id, prod_id), []).append((created_at, assignee_id))
def _derived_rework_assignee(task) -> str | None:
"""取本次驳回派生出的返工任务的**当前**负责人。"""
cands = rework_candidates.get((task.parent_task_id, task.product_id)) or []
if not cands:
return None # 找不到就如实返回空,不拿推算值冒充
for created_at, assignee_id in cands: # 已按 created_at 升序
if task.completed_at and created_at and created_at >= task.completed_at:
return assignee_id
return cands[0][1] # 缺 completed_at 的历史数据:退回最早一条
# ── 中文名映射(驳回人 + 返工负责人 一次批量查)──
derived_map = {t.id: _derived_rework_assignee(t) for t, *_ in rejected_rows}
raw_ids: set[str] = set()
for kind, (t, *_row) in all_rows:
if kind == "rejected":
raw_ids.add(reject_op.get(t.id) or "")
raw_ids.add(create_op.get(t.id) or "")
if t.id not in create_op and t.parent_task_id:
raw_ids.add(parent_assignee.get(t.parent_task_id) or "")
raw_ids.add(derived_map.get(t.id) or "")
raw_ids.add(t.assignee_id or "")
raw_ids.discard("")
name_map: dict[str, str] = {}
@ -483,12 +744,8 @@ async def get_rejected_tasks(
items: list[RejectedTask] = []
for kind, (task, sn, ext, mat, spec) in all_rows:
if kind == "rejected":
# 复刻 reject_task 追溯逻辑:create op → 父任务负责人 → 自身
rework_id = create_op.get(task.id)
if not rework_id and task.parent_task_id:
rework_id = parent_assignee.get(task.parent_task_id)
if not rework_id:
rework_id = task.assignee_id
# 直接取派生返工任务的真实负责人(见 _derived_rework_assignee)
rework_id = derived_map.get(task.id)
rejected_by_id = reject_op.get(task.id) or task.assignee_id
items.append(RejectedTask(
task_id=str(task.id),
@ -652,16 +909,22 @@ async def get_wip_matrix(
- 其他已完成工序(如「测试」完成)按原工序显示,不强制归仓库态
这样一台设备在「上一步已完成 + 下一步待确认」时只算一次(待确认),不会重复计数。
since/until 按设备最新主任务的创建时间过滤。
since/until 时间过滤为“动静分离”:仅对 终结(收口/在库/出库) 设备过滤,锚点取
最新主线任务的 completed_at(完工/变动时间,兜底 created_at,schema 无 updated_at);
在制(未收口)设备无条件全量计入,保证与人员看板实时在制不脱节。
dimension:
- assignee: 按当前任务负责人聚合(dimension_key 为中文姓名)
- task_name: 按当前工序聚合(dimension_key 为工序名,附主负责人)
"""
from datetime import timezone as dt_timezone
from sqlalchemy.orm import aliased
from app.models.task import Task
from app.models.product import Product
# 外层已 join 了 tasks,内层判活跃任务必须换别名,否则语义无歧义
_ActiveTask = aliased(Task, name="active_task")
# 每台设备按主任务创建时间倒序,取第一条即「最新主任务」
result = await db.execute(
select(
@ -670,16 +933,29 @@ async def get_wip_matrix(
Product.material_name,
Task.task_name,
Task.assignee_id,
Task.status.label("task_status"),
Task.created_at,
Task.completed_at,
Product.current_location_id,
Product.overall_status,
Product.status,
Product.lifecycle_phase,
# 🔧 该产品名下是否还有 ANY 活跃任务(含协助分支)——
# 注意:这比下面的 is_active 更宽。is_active 只看"最新一条主干任务",
# 而这里扫描该产品的全部任务,用于 is_terminal 兜底判定。
# ⚠️ 必须用别名:外层 FROM 里已有一个 tasks,同名会让 SQLAlchemy
# 自动关联掉内层 FROM 而报 "no FROM clauses"。别名后语义才无歧义。
select(_ActiveTask.id).where(
_ActiveTask.product_id == Product.id,
_ActiveTask.status.in_(["WIP", "PENDING"]),
).exists().label("has_any_active"),
)
.join(Task, Task.product_id == Product.id)
.outerjoin(Task, Task.product_id == Product.id)
.where(
or_(
Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收)
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
)
)
.order_by(Product.id, Task.created_at.desc())
@ -689,69 +965,133 @@ async def get_wip_matrix(
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
device_cur: dict[str, tuple] = {}
seen: set[str] = set()
for pid, spec, product_name, task_name, assignee, created, loc, overall_status, product_status in rows:
for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle, has_any_active in rows:
if pid in seen:
continue
seen.add(pid)
# 时间筛选:设备最新主任务的创建时间
if created is not None and created.tzinfo is None:
created = created.replace(tzinfo=dt_timezone.utc)
if since and created is not None and created < since:
continue
if until and created is not None and created > until:
continue
# ── 真实产出时间锚点:完工/变动时间优先,创建时间仅兜底 ──
# 说明:tasks 表无 updated_at 列(已核实 information_schema),故锚点取
# completed_at or created_at;若后续新增 updated_at 可并入。
anchor = completed or created
# ── 活跃任务判定:决定"按当前工序归列"还是"降级判完结态"的唯一依据 ──
# 必须在所有终结态判定之前。否则「已出库」的残留状态(设备回流后
# product.status 仍是 OUTBOUND,因为 receive_task/transfer_task 只同步
# overall_status 不同步 status)会把正在做售后工序的设备误判为终结态。
is_active = tstatus in ("WIP", "PENDING")
# ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ──
# 有活跃任务的一律视为在制,不做时间过滤(与"在制无条件全量"口径一致)
is_terminal = (not is_active) and (
loc == "virtual_warehouse"
or overall_status in ("待仓库收货", "已入库", "在库", "已出库")
or str(product_status).upper() in ("ARCHIVED", "OUTBOUND")
or task_name in ("扫码入库", "扫码出库")
# 🔧 兜底(2026-09-17):设备名下【再无任何活跃任务】= 真的没活了 → 视同终结态。
# 历史缺陷:生产设备被【直接完结】后 overall_status 停在工序名(如「测试」),
# 既不满足上面任何一条,又因 is_terminal=False 对时间筛选完全免疫 ——
# 于是它永远挂在看板上冒充"在制"(报表游魂)。
# 补上这一条后,它照常接受 since~until 过滤,不再污染在制口径。
# 仍保留原有的终结态判定(本条件是「或」,只会让更多设备被正确归类为终结)。
or not has_any_active
)
if is_terminal:
if anchor is not None and anchor.tzinfo is None:
anchor = anchor.replace(tzinfo=dt_timezone.utc)
if since and anchor is not None and anchor < since:
continue
if until and anchor is not None and anchor > until:
continue
# else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致
if dimension == "task_name":
# 🔧 在仓库的设备按"已入库/已出库/待仓库收货"区分:
# 已入库 = MOM 已扫码实收 (overall_status=='已入库'/'在库' 或 status=='ARCHIVED')
# 已出库 = MOM 已发货出库 (overall_status=='已出库' 或 status=='OUTBOUND')
# 待仓库收货 = 完工已转交仓库、MOM 尚未扫码实收
# 否则按最新主任务工序名(活跃/完成态都归该工序)
if loc == "virtual_warehouse":
# 统一术语:待仓库收货(车间完工) → "已完成";扫码实收 → "已入库";已出库保持
if overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED":
key = "已入库"
elif overall_status == "已出库" or str(product_status).upper() == "OUTBOUND":
key = "已出库"
# ══ ① 第一优先级:有活跃任务(WIP/PENDING) → 一律按当前工序归列 ══
# 绝不看 overall_status / product.status 的终结态字段。这两个字段
# 在售后回流场景下会残留旧值(status 停在 OUTBOUND),若先判终结态,
# 正在做「发货测试」的设备会被吞进「已出库」列。
if is_active:
if tstatus == "PENDING":
# 转交后待接收(工序名还是占位符「待确认」)→ 统一归「待接收」
key = "待接收"
else:
key = "已完成"
key = task_name if (task_name and task_name != "—") else "待接收"
# 售后回流设备按折算后的工序名落列:发货测试 / 售后维修
key = normalize_after_sales_step(lifecycle, key)
# ══ ② 无活跃任务,才降级判完结态(多维防御,不依赖单一 loc / overall)══
# 已入库: 整体实收(已入库/在库) 或 status==ARCHIVED 或 收口节点「扫码入库」
elif overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED" or task_name == "扫码入库":
key = "已入库"
# 已出库: 整体已出库 或 status==OUTBOUND 或 收口节点「扫码出库」
elif overall_status == "已出库" or str(product_status).upper() == "OUTBOUND" or task_name == "扫码出库":
key = "已出库"
# 已完成: 车间完工待实收(待仓库收货) 或 处于仓库(未实收/未出库)
elif overall_status == "待仓库收货" or loc == "virtual_warehouse":
key = "已完成"
else:
key = task_name or "—"
# 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」
if tstatus is None and task_name is None:
key = "待接收"
else:
# 已完工但未收口的工序(如「测试」完成),按原工序名显示
key = task_name if (task_name and task_name != "—") else "待接收"
# 🔧 售后回流设备的历史工序归一:老数据里售后设备的工序可能仍叫
# 「测试 / 维修」,折算进售后区的独立列,避免混入生产区同名工序
key = normalize_after_sales_step(lifecycle, key)
else:
key = assignee or "未分配"
device_cur[pid] = (spec or "未知型号", key, assignee or "", product_name or "")
# 聚合:规格 × 当前工序 → 设备数;同时收集负责人 与 产品名称
from collections import Counter
# 聚合:规格 × 当前工序 → 设备数;负责人按人头计数,空值显式计为「未分配」
agg: dict[tuple, int] = {}
assignee_map: dict[tuple, set] = {}
assignee_counter: dict[tuple, Counter] = {}
product_name_map: dict[tuple, str] = {}
for spec, key, assignee, product_name in device_cur.values():
k = (spec, key)
agg[k] = agg.get(k, 0) + 1
product_name_map.setdefault(k, product_name)
if assignee:
assignee_map.setdefault(k, set()).add(assignee)
raw = assignee if assignee and str(assignee).strip() not in ("", "—", "-", "null", "None") else ""
assignee_counter.setdefault(k, Counter())[raw] += 1
# 负责人 ID → 中文名
raw_ids: set[str] = set()
for s in assignee_map.values():
raw_ids |= s
for counter in assignee_counter.values():
raw_ids |= set(counter.keys())
raw_ids.discard("")
name_map: dict[str, str] = {}
if raw_ids:
from app.services.mom_cache import get_display_names
name_map = get_display_names(list(raw_ids))
def _disp(raw: str) -> str:
"""负责人显示名:空值统一为 未分配"""
return "未分配" if not raw else name_map.get(raw, raw)
def _fmt_label(raw: str, n: int) -> str:
"""负责人标签:>1 台必须带数量后缀;1 台不带"""
disp = _disp(raw)
return f"{disp}({n})" if n > 1 else disp
items: list[WipMatrixRow] = []
for (spec, key), cnt in agg.items():
dim_display = name_map.get(key, key) if dimension == "assignee" else key
assignees = [name_map.get(a, a) for a in assignee_map.get((spec, key), set())] or []
dim_display = _disp(key) if dimension == "assignee" else key
counter = assignee_counter.get((spec, key), Counter())
# 负责人名单:未分配固定排最前,其余按台数降序、名称升序;带数量后缀(>1 台)
labels = [
_fmt_label(raw, n)
for raw, n in sorted(
counter.items(),
key=lambda kv: (0 if kv[0] == "" else 1, -kv[1], _disp(kv[0])),
)
]
items.append(WipMatrixRow(
spec_model=spec,
product_name=product_name_map.get((spec, key), ""),
dimension_key=dim_display,
count=cnt,
assignees=assignees,
assignees=labels,
))
items.sort(key=lambda x: (x.spec_model, x.dimension_key))
return items
@ -774,11 +1114,15 @@ async def get_wip_matrix_detail(
按其在库三态(已完成/已入库/已出库)或工序名归类到 process。
"""
from datetime import timezone as dt_timezone
from sqlalchemy.orm import aliased
from app.models.task import Task
from app.models.product import Product
from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
# 与外层 join 的 tasks 区分开(理由见 get_wip_matrix)
_ActiveTask = aliased(Task, name="active_task")
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
@ -792,17 +1136,26 @@ async def get_wip_matrix_detail(
Product.current_location_id,
Product.overall_status,
Product.status,
Product.lifecycle_phase,
Task.task_name,
Task.assignee_id,
Task.status.label("task_status"),
Task.created_at,
Task.completed_at,
Task.received_at,
# 🔧 与 get_wip_matrix 同口径:该产品名下是否还有 ANY 活跃任务
# (同样必须用别名,理由见 get_wip_matrix 处注释)
select(_ActiveTask.id).where(
_ActiveTask.product_id == Product.id,
_ActiveTask.status.in_(["WIP", "PENDING"]),
).exists().label("has_any_active"),
)
.join(Task, Task.product_id == Product.id)
.outerjoin(Task, Task.product_id == Product.id)
.where(
or_(
Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收)
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
)
)
.order_by(Product.id, Task.created_at.desc())
@ -814,7 +1167,7 @@ async def get_wip_matrix_detail(
matched: list[WipMatrixDetailRow] = []
raw_names: set[str] = set()
for pid, serial, ext, mat_name, spec, loc, overall, pstatus, task_name, assignee, tstatus, created, received in rows:
for pid, serial, ext, mat_name, spec, loc, overall, pstatus, lifecycle, task_name, assignee, tstatus, created, completed, received, has_any_active in rows:
if pid in seen:
continue
seen.add(pid)
@ -822,24 +1175,58 @@ async def get_wip_matrix_detail(
if spec_model and (spec or "") != spec_model:
continue
# 时间筛选(设备最新主任务创建时间)
if created is not None and created.tzinfo is None:
created = created.replace(tzinfo=dt_timezone.utc)
if since and created is not None and created < since:
continue
if until and created is not None and created > until:
continue
# ── 真实产出时间锚点:完工/变动时间优先,创建时间仅兜底(schema 无 updated_at)──
anchor = completed or created
# 当前工序归类(与 get_wip_matrix 一致)
if loc == "virtual_warehouse":
if overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED":
key = "已入库"
elif overall == "已出库" or str(pstatus).upper() == "OUTBOUND":
key = "已出库"
# ── 活跃任务判定:与 get_wip_matrix 同口径,必须先于一切终结态判定 ──
# 否则「已出库」残留状态(回流后 pstatus 仍是 OUTBOUND)会把正在做
# 售后工序的设备误判为终结态,既错配时间过滤又错列。
is_active = tstatus in ("WIP", "PENDING")
# ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ──
# ⚠️ 必须与 get_wip_matrix 一字不差同步,否则会出现
# 「矩阵格子里有数、点进来下钻却是空的」。
is_terminal = (not is_active) and (
loc == "virtual_warehouse"
or overall in ("待仓库收货", "已入库", "在库", "已出库")
or str(pstatus).upper() in ("ARCHIVED", "OUTBOUND")
or task_name in ("扫码入库", "扫码出库")
# 🔧 兜底:名下再无任何活跃任务 → 视同终结态,接受时间筛选(防报表游魂)
or not has_any_active
)
if is_terminal:
if anchor is not None and anchor.tzinfo is None:
anchor = anchor.replace(tzinfo=dt_timezone.utc)
if since and anchor is not None and anchor < since:
continue
if until and anchor is not None and anchor > until:
continue
# else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致
# ══ 列归类(与 get_wip_matrix 一字不差同步)══
# ① 第一优先级:有活跃任务 → 一律按当前工序归列,不看终结态字段
if is_active:
if tstatus == "PENDING":
key = "待接收"
else:
key = "已完成"
key = task_name if (task_name and task_name != "—") else "待接收"
key = normalize_after_sales_step(lifecycle, key)
# ② 无活跃任务,才降级判完结态
elif overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED" or task_name == "扫码入库":
key = "已入库"
elif overall == "已出库" or str(pstatus).upper() == "OUTBOUND" or task_name == "扫码出库":
key = "已出库"
elif overall == "待仓库收货" or loc == "virtual_warehouse":
key = "已完成"
else:
key = task_name or "—"
# 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」
if tstatus is None and task_name is None:
key = "待接收"
else:
key = task_name if (task_name and task_name != "—") else "待接收"
# 🔧 必须与 get_wip_matrix 同口径归一:否则矩阵格子里有数、
# 点进来下钻却是空的(售后设备的历史「测试/维修」工序)
key = normalize_after_sales_step(lifecycle, key)
if process and key != process:
continue
@ -860,6 +1247,7 @@ async def get_wip_matrix_detail(
material_name=mat_name or "",
spec_model=spec or "",
task_status=tstatus or "",
lifecycle_phase=lifecycle or "PRODUCTION",
assignee_id=assignee or "",
duration_hours=duration,
received_at=received_str,

View File

@ -6,6 +6,14 @@ from sqlalchemy import select, or_, cast, String, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.lifecycle import (
ALL_OVERALL_STEPS,
allowed_steps,
is_step_allowed,
phase_label,
resolve_phase_for_step,
sync_product_status,
)
from app.models.product import Product
from app.models.production_order import ProductionOrder
from app.models.task import Task
@ -279,6 +287,7 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
current_location_id=product.current_location_id,
overall_status=product.overall_status,
status=product.status,
lifecycle_phase=product.lifecycle_phase,
created_at=product.created_at,
top_level_tasks=[
TaskSummaryResponse.model_validate(t) for t in top_tasks
@ -365,6 +374,7 @@ async def create_product(db: AsyncSession, data: ProductCreate, creator_username
current_location_name=creator_display_name or None,
overall_status=product.overall_status,
status=product.status,
lifecycle_phase=product.lifecycle_phase,
created_at=product.created_at,
)
@ -413,11 +423,14 @@ async def update_product(db: AsyncSession, product_id: uuid.UUID, data: ProductU
current_location_id=product.current_location_id,
overall_status=product.overall_status,
status=product.status,
lifecycle_phase=product.lifecycle_phase,
created_at=product.created_at,
)
VALID_OVERALL_STATUS = {"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库"}
# 全部合法宏观状态(两阶段并集)— 仅作"完全非法取值"的第一道粗筛;
# 阶段内的细分校验(售后回流设备禁止改回「备货 / 生产」)见下方 is_step_allowed
VALID_OVERALL_STATUS = ALL_OVERALL_STEPS
async def update_overall_status(
@ -434,7 +447,7 @@ async def update_overall_status(
if status_value not in VALID_OVERALL_STATUS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效状态: {status_value},合法值: {', '.join(sorted(VALID_OVERALL_STATUS))}",
detail=f"无效状态: {status_value},合法值: {'、'.join(sorted(ALL_OVERALL_STEPS))}",
)
result = await db.execute(
@ -480,15 +493,23 @@ async def update_overall_status(
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
)
# ── 选项隔离:阶段感知校验 ──
# 售后回流设备禁止被改回「备货 / 生产」;选定售后专属工序(发货测试 / 售后维修)
# 则设备随即进入售后生命周期(无历史记录的老设备由此进入售后阶段)。
phase = resolve_phase_for_step(product.lifecycle_phase, status_value)
if not is_step_allowed(phase, status_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"产品当前处于{phase_label(product.lifecycle_phase)},不允许改为「{status_value}」。"
f"该阶段可选:{'、'.join(allowed_steps(product.lifecycle_phase))}"
),
)
product.lifecycle_phase = phase
product.overall_status = status_value
# 同步 status 字段,保证与整体状态口径一致(修复"只改整体状态不改 status"的旧缺陷)
_OVERALL_TO_STATUS = {
"已入库": "ARCHIVED",
"在库": "ARCHIVED",
"已出库": "OUTBOUND",
"待仓库收货": "COMPLETED",
}
product.status = _OVERALL_TO_STATUS.get(status_value, "WIP")
# 同步 status 字段(映射表已上提到 app.core.lifecycle 单一来源,
# 供 task_service 的各写入点共用,避免各处各写一份)
sync_product_status(product)
await db.commit()
await db.refresh(product)
@ -652,6 +673,8 @@ async def get_all_products(
# 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name(宏观状态名)
overall_names: dict[uuid.UUID, str] = {}
# 🔧 【当前工序】专用:仅「活跃主干任务」的工序名(见下方 main_stmt 循环填充)
active_step_map: dict[uuid.UUID, str] = {}
if product_ids:
from sqlalchemy import and_, func as sa_func, case as sa_case
main_where = and_(
@ -673,7 +696,7 @@ async def get_all_products(
.group_by(Task.product_id)
).subquery("mp")
main_stmt = (
select(Task.product_id, Task.task_name)
select(Task.product_id, Task.task_name, Task.status)
.join(max_prio, and_(
Task.product_id == max_prio.c.product_id,
prio_expr == max_prio.c.prio,
@ -685,6 +708,11 @@ async def get_all_products(
main_result = await db.execute(main_stmt)
for row in main_result:
overall_names[row[0]] = row[1]
# 🔧 供【当前工序】专用:只收【活跃】主干任务的工序名。
# overall_names 保持原语义不动(它连 COMPLETED 的任务也收,
# 是"最新主干任务名",被 overall_status 复用,改动影响面太大)。
if row[2] in ("WIP", "PENDING"):
active_step_map[row[0]] = row[1]
# 🔧 当前位置:汇总所有活跃任务(WIP/PENDING,不分主线/分支)的负责人,去重保序
active_assignees_map: dict[uuid.UUID, list[str]] = {}
@ -804,6 +832,28 @@ async def get_all_products(
return "COMPLETED"
return macro_map.get(p.id) or "PENDING"
# 宏观终态 —— 表达的是"货在哪",不是"在做什么"。
# 这类值即使当前没有活跃任务也必须照常展示(业务要求保留物理标识)。
_TERMINAL_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
def _resolve_current_step(p: Product) -> str:
"""【当前工序】—— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
· 有活跃主干任务(WIP/PENDING) → 该任务工序名
· 否则产品处于宏观终态 → 该终态("货在哪",保留)
· 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
历史缺陷:ProductResponse.overall_status 会被"最新主干任务名"覆盖
(见上方 overall_names),于是已 COMPLETED 的「扫码出库 / 测试 / 发货测试」
会长期冒充"当前工序",误导管理者以为活还在干。
"""
raw = (p.overall_status or "").strip()
if not raw:
return ""
if raw in _TERMINAL_OVERALL:
return raw
return active_step_map.get(p.id, "")
return [
ProductResponse(
id=p.id,
@ -828,7 +878,9 @@ async def get_all_products(
),
macro_status=_resolve_macro_status(p),
overall_status=overall_names.get(p.id) or p.overall_status,
current_step=_resolve_current_step(p),
status=p.status,
lifecycle_phase=p.lifecycle_phase,
created_at=p.created_at,
latest_record_time=latest_record_map.get(p.id, (None, None, False, None))[0],
latest_record_content=latest_record_map.get(p.id, (None, None, False, None))[1],

View File

@ -0,0 +1,254 @@
"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合
设计约定:
- 只读,无写操作,字段扁平,便于大屏高频轮询。
- 时间一律按**北京时间**判定;DB 列为 timestamptz(实存 UTC),
比较前统一把边界换算成 UTC,避免月初/凌晨的边界漂移。
- 口径与 dashboard_service.get_dashboard_stats 保持一致:
未完结 = overall_status 不属于 {待仓库收货, 已入库, 在库, 已出库}。
视角说明:
管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」,
而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。
"""
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.lifecycle import (
AFTER_SALES_ONLY_STEPS,
LIFECYCLE_AFTER_SALES,
LIFECYCLE_PRODUCTION,
)
from app.core.time_utils import get_beijing_time
from app.models.product import Product
from app.models.task import Task
from app.models.task_log import TaskLog
# 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径
FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
# 仓储动作日志类型(由 webhooks / product_finalize_service 写入)
LOG_WAREHOUSE_INBOUND = "warehouse_inbound"
LOG_WAREHOUSE_OUTBOUND = "warehouse_outbound"
def _not_finished():
"""未完结条件 —— overall_status 为 NULL 或不在已完结集合内。
注意 PostgreSQL 中 `NULL NOT IN (...)` 结果为 NULL 而非 TRUE,
必须显式带上 IS NULL 分支,否则建单后未流转的设备会被整批漏掉。
"""
return or_(
Product.overall_status.is_(None),
~Product.overall_status.in_(FINISHED_OVERALL),
)
def _month_bounds() -> tuple[datetime, datetime, str]:
"""返回 (本月起点 UTC, 当前时刻 UTC, 'YYYY-MM')。
本月起点 = 北京时间当月 1 日 00:00 —— 直接换算成 UTC 参与 timestamptz 比较,
不依赖数据库会话时区设置。
"""
now_bj = get_beijing_time()
month_start_bj = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return (
month_start_bj.astimezone(timezone.utc),
now_bj.astimezone(timezone.utc),
month_start_bj.strftime("%Y-%m"),
)
# ============================================================
# 1. 当月吞吐指标
# ============================================================
class MonthlyMetrics(BaseModel):
"""大屏顶部四张数字卡(当月视角)"""
month: str # "2026-09"
month_start: str # "2026-09-01"(北京时间)
month_production: int # 本月有流转记录或新建的生产态设备数
month_inbound: int # 本月扫码入库数
month_outbound: int # 本月扫码出库数
month_returned: int # 本月进入售后/回流状态的设备数
async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics:
"""当月吞吐四联指标。
口径:
- month_production:lifecycle_phase = PRODUCTION,且「本月新建」或
「本月产生过任意 TaskLog 流转记录」的设备数(按设备去重)。
反映这个月实际被推着走的机器有多少。
- month_inbound / month_outbound:task_logs 中 action_type =
warehouse_inbound / warehouse_outbound 的记录数(MOM 扫码回调写入)。
- month_returned:本月创建过售后专属工序任务(发货测试 / 售后维修)的
设备数(去重)。Product 表无 updated_at,无法直接查「转为 AFTER_SALES
的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。
"""
month_start_utc, now_utc, month_label = _month_bounds()
# 本月该设备产生过任意流转日志
has_activity = (
select(TaskLog.id)
.join(Task, Task.id == TaskLog.task_id)
.where(
Task.product_id == Product.id,
TaskLog.created_at >= month_start_utc,
)
.exists()
)
month_production = await db.scalar(
select(func.count(func.distinct(Product.id))).where(
Product.lifecycle_phase == LIFECYCLE_PRODUCTION,
or_(Product.created_at >= month_start_utc, has_activity),
)
) or 0
async def _count_log(action_type: str) -> int:
return await db.scalar(
select(func.count(TaskLog.id)).where(
TaskLog.action_type == action_type,
TaskLog.created_at >= month_start_utc,
)
) or 0
month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND)
month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND)
month_returned = await db.scalar(
select(func.count(func.distinct(Task.product_id)))
.join(Product, Task.product_id == Product.id)
.where(
Product.lifecycle_phase == LIFECYCLE_AFTER_SALES,
Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)),
Task.created_at >= month_start_utc,
)
) or 0
return MonthlyMetrics(
month=month_label,
month_start=f"{month_label}-01",
month_production=int(month_production),
month_inbound=int(month_inbound),
month_outbound=int(month_outbound),
month_returned=int(month_returned),
)
# ============================================================
# 2. 工序积压分布(柱状图)
# ============================================================
class WipStage(BaseModel):
stage: str # 工序名(中文,直接作为图表类目)
phase: str # PRODUCTION / AFTER_SALES,供前端分区着色
count: int
class WipDistributionResponse(BaseModel):
total: int # 未完结设备总数
items: list[WipStage]
# 阶段与流转顺序 —— 顺序即「工序先后」,前端据此排布柱子
WIP_STAGES: tuple[tuple[str, str], ...] = (
("待启动", LIFECYCLE_PRODUCTION), # overall_status 为空:建单后尚未流转
("备货", LIFECYCLE_PRODUCTION),
("生产", LIFECYCLE_PRODUCTION),
("测试", LIFECYCLE_PRODUCTION),
("维修", LIFECYCLE_PRODUCTION),
("发货测试", LIFECYCLE_AFTER_SALES),
("售后维修", LIFECYCLE_AFTER_SALES),
)
# 「待启动」对应的真实分组键(overall_status IS NULL / 空串)
_UNSTARTED_KEY = ""
async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse:
"""工序积压分布 —— 当前未完结设备按 overall_status 聚合的数量。
返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而
整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的
count 之和恒等于 total(避免统计悄悄漏数)。
"""
rows = await db.execute(
select(Product.overall_status, func.count(Product.id))
.where(_not_finished())
.group_by(Product.overall_status)
)
counts: dict[str, int] = {}
for raw_status, cnt in rows.all():
key = (raw_status or "").strip()
counts[key] = counts.get(key, 0) + cnt
total = sum(counts.values())
items: list[WipStage] = []
for stage, phase in WIP_STAGES:
key = _UNSTARTED_KEY if stage == "待启动" else stage
items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0)))
known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in WIP_STAGES}
for key, cnt in counts.items():
if key not in known_keys and cnt:
items.append(WipStage(
stage=key or "待启动",
phase=LIFECYCLE_PRODUCTION,
count=cnt,
))
return WipDistributionResponse(total=total, items=items)
# ============================================================
# 3. 系统使用活跃度(本月人员排行)
# ============================================================
class ActiveUser(BaseModel):
user_id: str
user_name: str
receive_count: int # 接收
transfer_count: int # 转交
record_count: int # 上传备注
total: int
class ActiveUsersResponse(BaseModel):
month: str
items: list[ActiveUser]
async def get_active_users(db: AsyncSession, top_n: int = 5) -> ActiveUsersResponse:
"""本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。
不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete),
上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员,
避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。
"""
from app.services.dashboard_service import get_user_operations
month_start_utc, _now, month_label = _month_bounds()
operations = await get_user_operations(db, since=month_start_utc, until=None)
active = [op for op in operations if op.total > 0][:top_n]
return ActiveUsersResponse(
month=month_label,
items=[
ActiveUser(
user_id=op.user_id,
user_name=op.user_name,
receive_count=op.receive_count,
transfer_count=op.transfer_count,
record_count=op.record_count,
total=op.total,
)
for op in active
],
)

View File

@ -1,5 +1,6 @@
"""任务服务 — 核心业务逻辑:接收、驳回返工、裂变转交、无限嵌套子任务"""
from __future__ import annotations
import json
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select, delete
@ -9,6 +10,16 @@ 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.core.lifecycle import (
AFTER_SALES_REPAIR_STEPS,
LIFECYCLE_AFTER_SALES,
PRODUCTION_ONLY_STEPS,
allowed_steps,
is_step_allowed,
phase_label,
resolve_phase_for_step,
sync_product_status,
)
from app.models.product import Product
from app.models.task_log import TaskLog
from app.schemas.task import (
@ -81,13 +92,141 @@ async def _recalc_product_location(
result = await db.execute(stmt)
main_task = result.scalar_one_or_none()
# 上面的排序把 WIP(3) > PENDING(2) 放最前,所以取到的这条即最高优先级:
# 它是 WIP/PENDING 说明产品仍有活跃主线任务,否则视为闲置。
has_active_main = main_task is not None and main_task.status in (
TASK_STATUS_WIP, TASK_STATUS_PENDING,
)
new_location = main_task.assignee_id if main_task else None
# 🚚 「货发走就离场」——已出库且闲置的设备,厂内不再有它的位置。
# 直接完结后若继续停在最后经手人名下(或残留 virtual_warehouse),
# 列表里会与「已出库」自相矛盾。置空后前端统一显示「—」。
# ⚠️ 只清「已出库」:已入库 / 在库 的设备确实还在仓库里,位置必须保留。
if product.overall_status == "已出库" and not has_active_main:
new_location = None
if product.current_location_id != new_location:
product.current_location_id = new_location
await db.flush() # 唯一的落盘点
# 绝对物理终态 — 描述「设备此刻物理上在哪」的宏观状态,由 MOM 仓储/发货回调驱动。
# 接收 / 派发 / 转交任务时【禁止】用任务名覆写它们:否则「已出库」会被
# 「发货测试」这类工序名静默抹掉,设备在按终态口径统计的报表里就不再是已出库
# (WIP 矩阵因为优先看活跃任务而看不出问题,但全局概览 / 大屏会把它移出已完结)。
#
# ⚠️ 刻意不含「待仓库收货」:那是「车间完工待实收」的过渡态,设备随后仍会被
# 重新派活,宏观状态理应随工序更新。
PHYSICAL_TERMINAL_OVERALL = ("已入库", "在库", "已出库")
def _is_physical_terminal(overall: str | None) -> bool:
"""该宏观状态是否为「绝对物理终态」(不应被任务名覆写)"""
return (overall or "").strip() in PHYSICAL_TERMINAL_OVERALL
async def _mark_after_sales_if_reactivated(
db: AsyncSession, product_id: uuid.UUID,
steps: str | list[str | None] | None = None,
) -> bool:
"""出库后又接到【明确修机指令】= 设备回流返厂 → 生命周期切到 AFTER_SALES。
判定依据(必须同时满足):
1. 产品当前处于「已出库」终态(overall_status == '已出库' 或 status == 'OUTBOUND')
2. 本次接到的工序是明确修机指令(售后维修)
⚠️ 出厂质检(发货测试)【不】触发。设备可能压根没回厂,只是补做发货前测试。
历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
AFTER_SALES 烙印(售后死锁),且设备的物理终态「已出库」被工序名抹掉。
该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把
生产阶段的「发货测试」与回流后的「售后维修」区分开。
调用时机必须早于调用方改写 overall_status,否则会漏判。
:param steps: 本次接到的工序名;转交场景可能多分支,故接受列表。
返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中
identity map,不产生额外查询。
"""
product = await db.get(Product, product_id)
if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES:
return False
names = [steps] if isinstance(steps, str) else [s for s in (steps or []) if s]
if not any(n.strip() in AFTER_SALES_REPAIR_STEPS for n in names):
return False
is_outbound = (
product.overall_status == "已出库"
or (product.status or "").upper() == "OUTBOUND"
)
if not is_outbound:
return False
product.lifecycle_phase = LIFECYCLE_AFTER_SALES
await db.flush()
return True
def _enforce_step_isolation(
product: Product, step: str | None, *, action: str,
) -> None:
"""选项隔离守卫 — 售后回流设备禁止被重新排产回「备货 / 生产」等前期工序。
行为两步:
1. 选定售后专属工序(发货测试 / 售后维修)→ 设备随即进入售后生命周期。
这是「无历史记录的老设备」进入售后阶段的入口。
2. 再校验该工序在当前阶段是否合法,非法直接 400 —— 前端下拉被绕过、
请求被伪造时,仍在此拦下。
必须在 _mark_after_sales_if_reactivated 之后调用,这样出库回流的设备
已处于 AFTER_SALES,自然排不回前期工序。
注意:「待确认」与仓库虚拟节点属建单占位符,由 is_step_allowed 直接放行,
否则建单 / 转交入库整条链路会被卡死。
"""
phase = resolve_phase_for_step(product.lifecycle_phase, step)
if not is_step_allowed(phase, step):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"产品当前处于{phase_label(phase)},{action}不允许使用工序「{step}」。"
f"该阶段可选:{'、'.join(allowed_steps(phase))}"
),
)
if phase != product.lifecycle_phase:
product.lifecycle_phase = phase
def _reject_cross_phase_steps(
phase: str | None, steps: list[str | None], *, action: str,
) -> None:
"""跨阶段工序拦截 — 用于「转交」这类自由文本工序名路径。
转交对话框允许用户手打工序名(喷漆 / 老化 / 待确认…),所以不能用
白名单,否则会误伤合法命名;但"售后回流设备被转交到【生产】"必须挡住。
因此这里只拒绝**生产阶段专属**词(备货 / 生产 / 测试 / 维修)。
「在库 / 已入库 / 已出库」两阶段通用,占位符同样放行。
"""
if (phase or "") != LIFECYCLE_AFTER_SALES:
return
bad = sorted({
s.strip() for s in steps
if s and s.strip() in PRODUCTION_ONLY_STEPS
})
if bad:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"产品当前处于{phase_label(LIFECYCLE_AFTER_SALES)},{action}不允许使用前期工序:"
f"{'、'.join(bad)}。该阶段可选:{'、'.join(allowed_steps(LIFECYCLE_AFTER_SALES))}"
),
)
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:
@ -277,8 +416,22 @@ 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 and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")):
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期
# (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。
await _mark_after_sales_if_reactivated(db, data.product_id, data.task_name)
# 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参)
_enforce_step_isolation(product, data.task_name, action="创建任务")
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— 设备已是
# 已入库/在库/已出库 时禁止用任务名覆写,否则「已出库」会被工序名抹掉。
if (
data.task_name
and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY"))
and not _is_physical_terminal(product.overall_status)
):
product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name
# 🔧 双字段同步:overall_status 改动后必须对齐 status,
# 否则出库回流设备的 status 会永远停在 OUTBOUND,污染统计口径
sync_product_status(product)
# 派发给人 → 产品离开仓库
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:
product.current_location_id = data.assignee_id
@ -569,12 +722,30 @@ async def receive_task(
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
product = product_result.scalar_one_or_none()
if product:
# 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期
# (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。
await _mark_after_sales_if_reactivated(
db, task.product_id, task_name or task.task_name,
)
# 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内
#(task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」)
_enforce_step_isolation(product, task_name, action="接收任务")
if task.assignee_id:
product.current_location_id = task.assignee_id
if task_name and (
not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】——
# 设备已是 已入库/在库/已出库 时禁止用任务名覆写。
# 这是「发货测试 抹掉 已出库」的关键拦截点:若不拦,设备会在
# 概览/大屏等按终态口径统计的报表里不再是已出库,而矩阵因优先看
# 活跃任务看不出问题(跨报表口径就此打架)。
if (
task_name
and (not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"))
and not _is_physical_terminal(product.overall_status)
):
product.overall_status = task_name # 只主线任务同步宏观状态
product.overall_status = task_name
# 🔧 双字段同步:无条件对齐一次,顺带治愈历史残留
# (如整体已是「发货测试」而 status 还停在 OUTBOUND)
sync_product_status(product)
await db.commit()
await db.refresh(task)
@ -598,8 +769,9 @@ async def reject_task(
2. 查找当前任务的父任务 (parent_task) 的负责人 (assignee_id)。
- 若有父任务:返工任务分配给父任务的 assignee_id。
- 若无父任务(顶层任务):返工任务分配给当前任务自己的 assignee_id。
3. 为该负责人新建一个完全一样的任务,但 is_rework=True,status=PENDING。
4. 新返工任务挂在同一个 parent_task_id 下(与原任务同级)。
3. 把驳回原因 + 异常图片写入 TaskRecord,挂到被驳回的任务下(前端时间线可见)。
4. 为该负责人新建一个完全一样的任务,但 is_rework=True,status=PENDING。
5. 新返工任务挂在同一个 parent_task_id 下(与原任务同级)。
"""
task = await _get_task_or_404(db, task_id)
@ -655,7 +827,16 @@ async def reject_task(
if not rework_assignee_id:
rework_assignee_id = task.assignee_id
# --- 3. 创建返工任务 ---
# --- 3. 写入驳回记录(原因 + 异常图片)→ 修复前端时间线缺失 ---
# 图片为选填(编号错误等场景可不传),空数组落库为 "[]",前端时间线照常渲染
db.add(TaskRecord(
task_id=task.id,
remark=f"[品质驳回] {request.reason}(返工任务已派发给 {rework_assignee_id or '未指派'})",
images=json.dumps(request.images, ensure_ascii=False),
created_at=now,
))
# --- 4. 创建返工任务 ---
rework_task = Task(
product_id=task.product_id,
parent_task_id=task.parent_task_id, # 与原任务同级
@ -720,6 +901,12 @@ async def transfer_task(
- 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为 'virtual_warehouse'。
- 为每一个 assignee_id(非 virtual_warehouse)新建一条 Task 记录(状态 PENDING)。
动作 2'(直接完结 finish_directly=True):
- 不解析任何下家,分支强制为空,直接落入下方"无下家"兜底分支。
- 用于「售后返厂直接发走 / 半成品被提走」这类**无需入库**的收官场景:
工人不必再借道「入库(virtual_warehouse)」来关闭任务,
从而避免把产品误标成「待仓库收货」并卡住 MOM 对账。
裂变逻辑:
- 如果 len(next_assignees) > 1:多路裂变 → 所有新任务挂到当前任务下(parent_task_id = 当前任务ID)。
- 如果当前任务本身就是子任务(有 parent_task_id):单路转交也挂到同一父任务下。
@ -731,6 +918,18 @@ async def transfer_task(
# 权限校验:本人 或 管理员/主管 可转交
_check_permission(task.assignee_id, operator_id, operator_role)
# ── 🏁 直接完结:额外收紧为「仅管理员/主管」──
# 这是【正向】拦截(非管理员一律拒绝),刻意不复用 _check_permission 的反向写法:
# 后者在 operator_id 为空时会静默放行,而直接完结是不可逆的收官动作
# (不产生下游任务、产品落终态),必须默认拒绝。
# ⚠️ 判据只能是 operator_role —— 它来自签名保护的 JWT;
# operator_id 是客户端可通过 ?operator_id= 自行填写的,不可作为权限依据。
if request.finish_directly and not (operator_role and operator_role in ADMIN_ROLES):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅超管和主管有权限进行直接完结操作",
)
# 校验:不能重复完成
if task.status == TASK_STATUS_COMPLETED:
raise HTTPException(
@ -755,6 +954,16 @@ async def transfer_task(
now = get_beijing_time()
# --- 动作 1:闭环当前节点 ---
# 🔧 直接完结走独立文案:action_type 沿用 "complete"(不新增取值,避免扰动现有
# 看板的完成数口径),仅在 remark 里区分,审计时可按文本筛出「直接完结」。
finish_directly = bool(request.finish_directly)
if finish_directly:
log_remark = request.note or f"直接完结任务「{task.task_name}」(无下游,不入库)"
record_remark = request.note or "[直接完结] 无下游任务,产品宏观状态保持不变"
else:
log_remark = request.note or f"完成任务「{task.task_name}」,转交至下一道工序"
record_remark = request.note or "[完工转交] 移交下一工序"
task.status = TASK_STATUS_COMPLETED
task.completed_at = now
@ -762,16 +971,26 @@ async def transfer_task(
db, task_id,
action_type="complete",
operator_id=operator_id,
remark=request.note or f"完成任务「{task.task_name}」,转交至下一道工序",
remark=log_remark,
task_assignee_id=task.assignee_id,
)
db.add(TaskRecord(task_id=task.id,
remark=(request.note or f"[完工转交] 移交下一工序") + _admin_proxy_note(operator_id, task.assignee_id),
remark=record_remark + _admin_proxy_note(operator_id, task.assignee_id),
images="[]"))
# --- 动作 2:解析下家 & 裂变 ---
# 🔧 提前查产品:下面的阶段隔离校验依赖它的生命周期阶段。
# (通知也需要 product_sn,原本在创建任务之后才查,提前不增加查询次数)
product_result = await db.execute(
select(Product).where(Product.id == task.product_id)
)
product = product_result.scalar_one_or_none()
# 兼容新旧格式
if request.next_tasks:
# 🔧 直接完结:不解析任何下家,分支强制为空,直接命中下方"无下家"兜底分支。
if finish_directly:
branches: list[tuple[str | None, str]] = []
elif request.next_tasks:
branches = [
(b.task_name, a)
for b in request.next_tasks
@ -786,18 +1005,40 @@ async def transfer_task(
has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches)
real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE]
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期。
# 必须在下方阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
# (故本调用安排在分支解析之后、_reject_cross_phase_steps 之前。)
# ⚠️ 直接完结【不产生新任务】,不构成"回流返厂"信号,必须跳过:
# 否则一台已出库设备的正常收官,会把产品误翻成售后机(该标志单向不可回退)。
# ⚠️ 出厂质检(发货测试)同样不触发 —— 详见 _mark_after_sales_if_reactivated。
if product and not finish_directly:
await _mark_after_sales_if_reactivated(
db, task.product_id, [tn for tn, _ in real_branches],
)
# 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。
# 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。
if product:
_reject_cross_phase_steps(
product.lifecycle_phase, [tn for tn, _ in real_branches], action="转交",
)
is_fission = len(real_branches) > 1 or (request.next_tasks and len(request.next_tasks) > 1)
is_child_task = task.parent_task_id is not None
created_tasks: list[Task] = []
for task_name, assignee_id in real_branches:
# 🔧 动态推导 task_type:禁止分支任务转交被硬编码为 TRANSFER(否则 is_main 判定误升为主线)
if is_fission:
new_parent_task_id = task.id
new_task_type = "SPAWN" # 裂变产生新分支
elif is_child_task:
new_parent_task_id = task.parent_task_id
new_task_type = task.task_type or "SPAWN" # 继承父任务的分支血统
else:
new_parent_task_id = task.id
new_task_type = "TRANSFER" # 主干常规流转
new_task = Task(
product_id=task.product_id,
@ -805,7 +1046,7 @@ async def transfer_task(
task_name=task_name,
assignee_id=assignee_id,
status=TASK_STATUS_PENDING,
task_type="TRANSFER",
task_type=new_task_type, # ← 使用动态推导的类型
notify_parent_on_complete=False,
is_rework=False,
remark=request.note or None,
@ -816,11 +1057,6 @@ async def transfer_task(
# 批量 flush 以生成 ID
await db.flush()
# 提前查询产品(通知需要 product_sn)
product_result = await db.execute(
select(Product).where(Product.id == task.product_id)
)
product = product_result.scalar_one_or_none()
product_sn = product.serial_number if product else ""
for nt in created_tasks:
@ -831,6 +1067,16 @@ async def transfer_task(
remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}",
task_assignee_id=nt.assignee_id,
)
# 🔧 转交留言留痕:把上一工序的嘱咐也挂一条记录到**下家任务**上。
# 原先留言只落在「已完工的上游任务记录」+「新任务的 Task.remark 字段」里,
# 接手人打开自己任务的「操作日志」只能看到 [接收],看不到交接上下文,
# 转交备注的协作意义就丢了。这里补齐下游视角。
if request.note and request.note.strip():
db.add(TaskRecord(
task_id=nt.id,
remark=f"[上一工序转交留言]:{request.note.strip()}",
images="[]",
))
# 🔔 通知:新任务派发
if nt.assignee_id:
db.add(Notification(
@ -841,19 +1087,44 @@ async def transfer_task(
task_id=nt.id,
))
# --- 更新 Product 的 current_location_id ---
# --- 更新 Product 的 current_location_id / overall_status ---
# 🏁 直接完结【不改动 overall_status】:它只是一个「任务闭环动作」,
# 不改变产品的物理状态。已出库的设备完结后依然是已出库,
# 从而回归 WIP 矩阵的【已出库】列(无活跃任务 → 落终结态判定)。
# 下方 has_warehouse / real_branches 两个分支对直接完结均为假,
# overall_status 自然原样保留。
if product:
if has_warehouse and not real_branches:
# 转入虚拟仓库池:位置一定改(下一手才能在这里看到并接手),
# 但宏观状态要分情况 ——
# · 绝对物理终态(已入库 / 在库 / 已出库):禁止改写成「待仓库收货」。
# 已出库设备售后返修时选择【入库】是为了「放回池子让下一个人继续转」,
# 改成「待仓库收货」会抹掉它的物理终态,且与 MOM 侧「已发货」的记录冲突
# (MOM 认为货已出门,Track 却说「等仓库收货」,双方对不上账)。
# 已入库 / 在库设备本就在仓库,再入库更不该回退成「待收货」。
# · 普通生产设备(备货/生产/测试/维修…):维持原行为,正常置为「待仓库收货」。
product.current_location_id = VIRTUAL_WAREHOUSE
product.overall_status = "待仓库收货"
# 双字段同步:完工转交入库 → 产品标记为已完成(MOM 扫码实收后才变已入库)
product.status = "COMPLETED"
if not _is_physical_terminal(product.overall_status):
product.overall_status = "待仓库收货"
elif real_branches:
product.current_location_id = real_branches[0][1]
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"):
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】——
# 设备已是 已入库/在库/已出库 时禁止用任务名覆写,
# 否则「已出库」会被「发货测试」这类工序名抹掉。
if (
(not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"))
and not _is_physical_terminal(product.overall_status)
):
product.overall_status = real_branches[0][0]
# 🔧 双字段同步:各分支统一在这里对齐一次(原先只有入库分支硬编码
# product.status="COMPLETED",普通转交分支完全没同步 → 残留 OUTBOUND)
# 注意顺序:必须在上面的 overall_status 赋值【之后】,否则 status 同步到旧值。
sync_product_status(product)
# 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务
# 「直接完结」(finish_directly) 与旧版空分支都落到这里:
# current_location_id 回溯到最后一手经手人(业务确认:位置停在经手人合理)。
# 注意它只改 location,不碰 overall_status,故上面的终态不会被覆盖。
if not real_branches and not has_warehouse:
await _recalc_product_location(db, task.product_id, task_id)
@ -866,19 +1137,25 @@ async def transfer_task(
await db.refresh(nt)
created_task_responses.append(_to_response(nt))
assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库"
location_info = ""
if has_warehouse:
location_info = ",产品已入库(virtual_warehouse)"
# 🔧 直接完结没有下家,不能套用"已创建 N 个任务(接收人: 仓库)"的模板
# (否则会拼出「已创建 0 个下一道工序任务「None」(接收人: 仓库)」这种误导文案)
if finish_directly:
message = f"任务「{task.task_name}」已直接完结(无下游任务,产品状态保持不变)"
else:
assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库"
location_info = ""
if has_warehouse:
location_info = ",产品已入库(virtual_warehouse)"
message = (
f"任务「{task.task_name}」已完成,"
f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」"
f"(接收人: {assignee_list}){location_info}"
)
return TaskTransferResponse(
completed_task=_to_response(refreshed_task),
created_tasks=created_task_responses,
message=(
f"任务「{task.task_name}」已完成,"
f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」"
f"(接收人: {assignee_list}){location_info}"
),
message=message,
)
@ -933,10 +1210,26 @@ async def complete_task(
remark=request.remark or f"完成任务: {task.task_name}",
task_assignee_id=task.assignee_id,
)
# 🔧 完工留痕(与 transfer_task 同口径):备注要写成 TaskRecord,
# 否则「操作日志」时间线只读 TaskRecord、看不到 TaskLog,交接内容就丢了。
_will_transfer = bool(request.next_task_name and request.next_assignee_id)
db.add(TaskRecord(
task_id=task.id,
remark=(
request.remark
or ("[完工转交] 移交下一工序" if _will_transfer else "[完工] 任务已完成")
) + _admin_proxy_note(request.operator_id, task.assignee_id),
images="[]",
))
# --- 4. 可选:创建下一步任务(转交) ---
next_task = None
if request.next_task_name and request.next_assignee_id:
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期
# (出厂质检「发货测试」不触发)
await _mark_after_sales_if_reactivated(
db, task.product_id, request.next_task_name,
)
# 🚀 智能父节点继承算法
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
@ -965,6 +1258,15 @@ async def complete_task(
remark=f"由任务「{task.task_name}」完成后转交创建",
task_assignee_id=next_task.assignee_id,
)
# 🔧 转交留言留痕:把上一工序的嘱咐也挂一条记录到**下家任务**上,
# 让接手人打开自己的「操作日志」就能看到交接上下文
# (与 transfer_task 同口径,保证两条转交路径行为一致)
if request.remark and request.remark.strip():
db.add(TaskRecord(
task_id=next_task.id,
remark=f"[上一工序转交留言]:{request.remark.strip()}",
images="[]",
))
# 🔧 位置回溯:老接口也触发(父任务优先)
await _recalc_product_location(db, task.product_id, task_id)

View File

@ -0,0 +1 @@
"""一次性运维脚本集合(按模块方式运行:python -m scripts.<name>)"""

View File

@ -0,0 +1,84 @@
"""一次性数据清洗 — 修复 Product.status 与 overall_status 脱节的历史存量
背景
----
app/core/lifecycle.py 记录了历史缺陷:早期 receive_task / transfer_task 只改
overall_status、不改 status,导致部分设备的 status 残留为 'pending' / 'OUTBOUND'
等旧值。
现状(重要)
----------
所有 overall_status 的写入点**都已补上 sync_product_status()**:
task_service.py:387 / :691 / :1007、product_service.py:512、
webhooks.py:84 / :256、product_finalize_service.py:107
因此新数据不会再脱节,本脚本只处理历史存量。
影响面
------
前端列表筛选走的是 macro_status(由 overall_status 实时派生,见
product_service._resolve_macro_status),所以这批脏数据**基本不影响页面展示**。
但 _mark_after_sales_if_reactivated 等逻辑会读 product.status 判断"是否出库回流",
脏值可能引发误判 —— 故仍需清洗。
用法
----
python -m scripts.fix_product_status # 干跑:只打印待修复清单
python -m scripts.fix_product_status --apply # 确认后真正写库
"""
from __future__ import annotations
import argparse
import asyncio
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.core.lifecycle import overall_to_product_status
from app.models.product import Product
async def main(apply: bool) -> None:
async with AsyncSessionLocal() as db:
products = (
await db.execute(select(Product).order_by(Product.serial_number))
).scalars().all()
fixes: list[tuple[Product, str, str]] = []
for p in products:
expected = overall_to_product_status(p.overall_status)
current = (p.status or "").strip()
# 大小写不敏感比较:历史数据里存在小写 'pending'
if current.upper() == expected:
continue
fixes.append((p, current or "(空)", expected))
print(f"扫描 {len(products)} 台设备,需修复 {len(fixes)} 台\n")
if fixes:
header = f"{'序列号':<18}{'overall_status':<16}{'当前 status':<14}→ 目标 status"
print(header)
print("-" * len(header) * 2)
for p, cur, exp in fixes:
overall = p.overall_status or "(NULL)"
phase = p.lifecycle_phase or "-"
print(f"{p.serial_number:<18}{overall:<16}{cur:<14}→ {exp} [{phase}]")
else:
print("所有设备的 status 均已与 overall_status 对齐。")
if not apply:
print("\n[干跑] 未写库。确认清单无误后,加 --apply 执行。")
return
if not fixes:
return
for p, _cur, exp in fixes:
p.status = exp
await db.commit()
print(f"\n[已提交] {len(fixes)} 台设备的 status 已对齐到 overall_status。")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="对齐 Product.status 与 overall_status 的历史脏数据")
parser.add_argument("--apply", action="store_true", help="真正写库(默认仅干跑)")
asyncio.run(main(parser.parse_args().apply))

View File

@ -41,7 +41,11 @@ services:
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@track_db:5432/track_production
SECRET_KEY: change-me-in-production
DEBUG: "true"
CORS_ORIGINS: '["http://localhost:8010","https://localhost:8010","http://192.168.9.80:8010","https://192.168.9.80:8010","tauri://localhost"]'
# 8010 = PC 管理端;5173/8020 = track-uniapp 的 H5 调试服务
# (HBuilderX「运行到浏览器」起在 5173,直接跑 vite 按 track-uniapp/vite.config.ts 是 8020)。
# ⚠️ 漏了 uniapp 的来源时,浏览器会在 CORS 预检阶段就掐掉请求,
# 表现是「点登录没反应」,且控制台只报一句笼统的 404/400。
CORS_ORIGINS: '["http://localhost:8010","https://localhost:8010","http://192.168.9.80:8010","https://192.168.9.80:8010","http://localhost:5173","https://localhost:5173","http://192.168.9.80:5173","https://192.168.9.80:5173","http://localhost:8020","https://localhost:8020","tauri://localhost"]'
# 🚀 MOM 老系统数据库 — 通过容器名解析(projects_default 网络内 DNS),不再写死 IP
MOM_DB_HOST: inventory_db
MOM_DB_PORT: "5432"

View File

@ -2,6 +2,24 @@ import { Suspense, lazy } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { App as AntApp, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
// ── dayjs 中文 locale ────────────────────────────────────────────────
// 必须设在应用根模块,不能下放到各页面。
//
// antd 的 ConfigProvider locale 只负责「请选择日期 / 今天 / 此刻」这类**文案**;
// 日期面板上的**月份与星期名**是 rc-picker 交给 dayjs 取的 ——
// @rc-component/picker/es/generate/dayjs.js 里:
// getShortWeekDays: locale => dayjs().locale(parseLocale(locale)).localeData().weekdaysMin()
// parseLocale 把 antd 传来的 "zh_CN"(下划线)在 localeMap 未命中后按 '_' 切分,
// 得到 "zh",最终查的是 dayjs 的 locale 表 —— 不注册就永远是默认的 en。
//
// 此前这两行被分别写在 AdminPeoplePage / AnalyticsDashboard 两个页面里,而路由是
// lazy() 代码分割:直接打开 /admin/dashboard 或 /admin/matrix 时那两个模块根本没
// 被加载,dayjs 仍是 en,面板就露出 Sep / Su / Mo —— 表现为「有的页面中文、有的
// 页面英文」。放到根模块可保证任何路由下都先生效。
dayjs.locale("zh-cn");
import { ToastProvider } from "./components/ui/Toast";
import { AuthProvider } from "./contexts/AuthContext";
@ -25,6 +43,7 @@ const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
export default function App() {
return (
@ -47,6 +66,9 @@ export default function App() {
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
<Route path="/admin/login" element={<AdminLoginPage />} />
{/* PC 管理端 — 管理层数据大屏(独立全屏,无侧边栏,组件内自带鉴权守卫) */}
<Route path="/admin/screen" element={<ScreenDashboard />} />
{/* PC 管理端 — 需要登录 */}
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AdminLayout />}>

View File

@ -2,9 +2,9 @@
* 支持 onEvents(常规事件)与 onZrClick(ZRender 底层点击,扩大热区)。 */
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { LineChart, BarChart, CustomChart } from "echarts/charts";
import { LineChart, BarChart, CustomChart, GaugeChart } from "echarts/charts";
import {
GridComponent, TooltipComponent, LegendComponent, DataZoomComponent,
GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, GraphicComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { EChartsCoreOption } from "echarts/core";
@ -14,10 +14,12 @@ echarts.use([
LineChart,
BarChart,
CustomChart,
GaugeChart, // 仪表盘(预留,当前大屏未使用)
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
GraphicComponent, // 大屏饼图中心文字(graphic 需显式注册,否则静默不渲染)
CanvasRenderer,
]);

View File

@ -73,6 +73,7 @@ export default function QrScanner({
// 3. 创建全新实例
const scanner = new Html5Qrcode(SCANNER_ID, {
verbose: false,
useBarCodeDetectorIfSupported: true,
formatsToSupport: [
Html5QrcodeSupportedFormats.CODE_128,

View File

@ -3,7 +3,7 @@
*/
import { memo, useMemo, useState, useEffect } from "react";
import { Image } from "antd";
import { FileText, Package, User, X } from "lucide-react";
import { Package, User, X } from "lucide-react";
import type { TaskResponse } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
@ -87,14 +87,13 @@ const SIZE_MAP = {
// 极简卡片
// ============================================================
const SlimCard = memo(function SlimCard({
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm", assigneeNames,
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm",
}: {
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
onViewRecords?: (t: TaskResponse) => void;
rootMainId?: string;
size?: "sm" | "lg";
assigneeNames?: Record<string, string>;
}) {
const cfg = getStatusConfig(task.status);
const sz = SIZE_MAP[size];
@ -146,6 +145,13 @@ const SlimCard = memo(function SlimCard({
⏰ {fmtTime(task.created_at)}
{task.completed_at ? ` → ${fmtTime(task.completed_at)}` : " → 至今"}
</p>
{/* 驳回/返工原因:原 REJECTED 取 reject_reason;返工节点取含“驳回/返工”的备注 */}
{(() => {
const rj = task.reject_reason;
const rm = task.remark;
const txt = rj ? `❌ 驳回: ${rj}` : (rm && /驳回|返工/.test(rm) ? rm : null);
return txt ? <p className={`${sz.sub} text-red-500 whitespace-pre-wrap`}>{txt}</p> : null;
})()}
{legacy && <p className={`${sz.sub} text-orange-500`}>源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
{isNestedSpawn && <p className={`${sz.sub} text-purple-500`}>协助: {findParent(task)?.assignee_id || "—"}</p>}
{/* 操作按钮 */}
@ -225,7 +231,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
const arrow = side === 'left'
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
: (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></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} size={size} assigneeNames={assigneeNames} />;
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} />;
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))}
@ -292,7 +298,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
{/* 中央 */}
<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} size={size} assigneeNames={assigneeNames} />
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} />
{active(mainTask.status) && (
<div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} />
)}

View File

@ -19,7 +19,10 @@ import {
} from "../../services/taskApi";
import { useToast } from "../ui/Toast";
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
import { getStatusConfig, isAdminRole } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
import ImageUploader from "../ui/ImageUploader";
import { extractErrorMessage } from "../../utils/errorMessage";
// ============================================================
// 通用 Modal 容器
@ -142,9 +145,10 @@ export const RejectModal = memo(function RejectModal({
task: TaskResponse | null;
submitting: boolean;
onClose: () => void;
onSubmit: (reason: string) => void;
onSubmit: (reason: string, images: string[]) => void;
}) {
const [reason, setReason] = useState("");
const [images, setImages] = useState<string[]>([]);
if (!task) return null;
@ -152,12 +156,15 @@ export const RejectModal = memo(function RejectModal({
e.preventDefault();
const trimmed = reason.trim();
if (!trimmed) return;
onSubmit(trimmed);
// 异常图片为选填:编号错误、选错工序等场景无需拍照举证,不再拦截无图提交
onSubmit(trimmed, images);
setReason("");
setImages([]);
}
function handleClose() {
setReason("");
setImages([]);
onClose();
}
@ -179,29 +186,37 @@ export const RejectModal = memo(function RejectModal({
placeholder="请详细说明品质驳回的原因(必填)"
rows={3}
maxLength={500}
className="mb-4 w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100"
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100"
autoFocus
/>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">{reason.length}/500</span>
<div className="flex gap-2">
<button
type="button"
onClick={handleClose}
disabled={submitting}
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
type="submit"
disabled={submitting || !reason.trim()}
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
确认驳回
</button>
</div>
<div className="mb-3 mt-1 text-right text-xs text-gray-400">{reason.length}/500</div>
<label className="mb-1 block text-sm font-medium text-gray-700">
异常图片 <span className="text-xs font-normal text-gray-400">(选填,最多 9 张)</span>
</label>
<ImageUploader
value={images}
onChange={setImages}
disabled={submitting}
/>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleClose}
disabled={submitting}
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
type="submit"
disabled={submitting || !reason.trim()}
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
确认驳回
</button>
</div>
</form>
</Modal>
@ -223,14 +238,45 @@ export const TransferModal = memo(function TransferModal({
task: TaskResponse | null;
submitting: boolean;
onClose: () => void;
onSubmit: (nextTaskName: string, assignees: string[], note: string) => void;
onSubmit: (
nextTaskName: string,
assignees: string[],
note: string,
finishDirectly: boolean
) => void;
}) {
const [nextTaskName, setNextTaskName] = useState("");
const [assigneeInput, setAssigneeInput] = useState("");
const [assignees, setAssignees] = useState<string[]>([]);
const [useWarehouse, setUseWarehouse] = useState(false);
// 🏁 直接完结:无下游、不入库。与「转交个人 / 入库」互斥
const [finishDirectly, setFinishDirectly] = useState(false);
const [note, setNote] = useState("");
// 🔄 每次打开都重置:TransferModal 被 memo 后常驻挂载(task 为 null 时只是 return null),
// 状态会跨次打开残留,必须显式清零。
// ⚠️ 必须放在 `if (!task) return null` 之前 —— 否则是条件提前返回后再调 Hook,
// 违反 Rules of Hooks,task 从 null 变非 null 时 Hook 数量错位会直接崩。
useEffect(() => {
if (!open) return;
setNextTaskName("");
setAssigneeInput("");
setAssignees([]);
setUseWarehouse(false);
setFinishDirectly(false);
setNote("");
}, [open]);
// 🔒 直接完结入口仅超管/主管【可见】:普通人看不到这个选项,而不是点了才被后端 403。
// 后端那道硬拦截依然保留 —— 这里是体验层,角色来自客户端存储、可被伪造。
const { user } = useAuth();
const canFinishDirectly = isAdminRole(user?.role);
// 防御:非管理员若因任何原因残留了选中态,强制清掉,避免提交出 finish_directly
useEffect(() => {
if (!canFinishDirectly) setFinishDirectly(false);
}, [canFinishDirectly]);
if (!task) return null;
function handleAddAssignee() {
@ -257,44 +303,80 @@ export const TransferModal = memo(function TransferModal({
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const name = nextTaskName.trim();
if (!name) return;
const finalAssignees = [...assignees];
if (useWarehouse) {
finalAssignees.push("virtual_warehouse");
// 🏁 直接完结:不需要工序名与接收人,只闭环当前任务
if (!finishDirectly) {
const name = nextTaskName.trim();
if (!name) return;
const finalAssignees = [...assignees];
if (useWarehouse) {
finalAssignees.push("virtual_warehouse");
}
if (finalAssignees.length === 0) return;
onSubmit(name, finalAssignees, note.trim(), false);
} else {
onSubmit("", [], note.trim(), true);
}
if (finalAssignees.length === 0) return;
// ⚠️ 此处刻意不重置表单:请求是异步的,父组件要等 await 结束才关闭弹窗。
// 若在此清空,提交等待期间标题/预览会从「直接完结」闪回普通转交。
// 统一改由下方的「打开时重置」负责。
}
onSubmit(name, finalAssignees, note.trim());
// 重置表单
function resetForm() {
setNextTaskName("");
setAssignees([]);
setAssigneeInput("");
setUseWarehouse(false);
setFinishDirectly(false);
setNote("");
}
function handleClose() {
setNextTaskName("");
setAssignees([]);
setAssigneeInput("");
setUseWarehouse(false);
setNote("");
resetForm();
onClose();
}
// 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回)
const downstreamDisabled = finishDirectly;
// 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效
function handleToggleFinishDirectly(checked: boolean) {
setFinishDirectly(checked);
if (checked) setUseWarehouse(false);
}
const allAssignees = [
...assignees,
...(useWarehouse ? ["virtual_warehouse"] : []),
];
return (
<Modal open={open} onClose={handleClose} title="完工转交 — 裂变到下道工序">
<div className="mb-4 rounded-lg bg-green-50 px-3 py-2.5 text-sm text-green-700">
<Modal
open={open}
onClose={handleClose}
title={
finishDirectly
? "完工转交 — 🏁 直接完结(无下游,不入库)"
: "完工转交 — 裂变到下道工序"
}
>
<div
className={`mb-4 rounded-lg px-3 py-2.5 text-sm ${
finishDirectly
? "bg-amber-50 text-amber-700"
: "bg-green-50 text-green-700"
}`}
>
<p className="font-medium">{task.task_name}</p>
<p className="mt-0.5 text-xs text-green-500">
完成任务并批量创建下一道工序任务,支持多路裂变分支
<p
className={`mt-0.5 text-xs ${
finishDirectly ? "text-amber-500" : "text-green-500"
}`}
>
{finishDirectly
? "结束本任务且不创建下游任务,产品状态保持原样"
: "完成任务并批量创建下一道工序任务,支持多路裂变分支"}
</p>
</div>
@ -310,7 +392,8 @@ export const TransferModal = memo(function TransferModal({
onChange={(e) => setNextTaskName(e.target.value)}
placeholder="如:组装、质检、包装"
maxLength={200}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100"
disabled={downstreamDisabled}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
autoFocus
/>
</div>
@ -361,12 +444,13 @@ export const TransferModal = memo(function TransferModal({
onKeyDown={handleAssigneeKeyDown}
placeholder="输入接收人 ID,按 Enter 添加"
maxLength={64}
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100"
disabled={downstreamDisabled}
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
/>
<button
type="button"
onClick={handleAddAssignee}
disabled={!assigneeInput.trim()}
disabled={!assigneeInput.trim() || downstreamDisabled}
className="flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
<Plus className="h-3.5 w-3.5" />
@ -376,11 +460,16 @@ export const TransferModal = memo(function TransferModal({
</div>
{/* 虚拟入库 */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<label
className={`flex items-center gap-2 select-none ${
downstreamDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"
}`}
>
<input
type="checkbox"
checked={useWarehouse}
onChange={(e) => setUseWarehouse(e.target.checked)}
disabled={downstreamDisabled}
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<Warehouse className="h-4 w-4 text-purple-500" />
@ -389,6 +478,32 @@ export const TransferModal = memo(function TransferModal({
</span>
</label>
{/* 🏁 直接完结 — 与转交/入库互斥。仅超管/主管可见 */}
{canFinishDirectly && (
<div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={finishDirectly}
onChange={(e) => handleToggleFinishDirectly(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-sm font-medium text-gray-700">
🏁 直接完结(无下游,不入库)
</span>
</label>
{finishDirectly && (
<p className="mt-1.5 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
任务将直接完结,不创建任何下游任务。
<span className="font-semibold">它只是一个任务闭环动作,不改动产品状态</span>
:已出库的设备完结后依然是<span className="font-semibold">「已出库」</span>
,回归 WIP 矩阵的已出库列;不会变成「待仓库收货」,也无需仓库扫码实收。
适用于售后返厂直接发走、半成品被直接提走等无需入库的收官场景。
</p>
)}
</div>
)}
{/* 备注 */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
@ -403,8 +518,21 @@ export const TransferModal = memo(function TransferModal({
/>
</div>
{/* 预览 — 🏁 直接完结走独立文案,不能套用「将创建 N 个任务」模板 */}
{finishDirectly && (
<div className="rounded-lg bg-amber-50 px-3 py-2.5 text-xs text-amber-700">
<p className="font-medium">🏁 直接完结预览:</p>
<p className="mt-1">
任务「<span className="font-semibold">{task.task_name}</span>
」将直接结束,
<span className="font-semibold">不创建任何下游任务</span>
;产品宏观状态与当前位置保持不变。
</p>
</div>
)}
{/* 预览 */}
{allAssignees.length > 0 && nextTaskName.trim() && (
{!finishDirectly && allAssignees.length > 0 && nextTaskName.trim() && (
<div className="rounded-lg bg-gray-50 px-3 py-2.5 text-xs text-gray-500">
<p className="font-medium text-gray-600">裂变预览:</p>
<p className="mt-1">
@ -446,13 +574,17 @@ export const TransferModal = memo(function TransferModal({
type="submit"
disabled={
submitting ||
!nextTaskName.trim() ||
allAssignees.length === 0
(!finishDirectly &&
(!nextTaskName.trim() || allAssignees.length === 0))
}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
className={`flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${
finishDirectly
? "bg-amber-600 hover:bg-amber-700"
: "bg-green-600 hover:bg-green-700"
}`}
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
确认转交
{finishDirectly ? "🏁 确认直接完结" : "确认转交"}
</button>
</div>
</form>
@ -517,9 +649,7 @@ export default function TaskTreeViewer() {
const msg =
err?.response?.status === 404
? `未找到产品身份证 ${trimmed} 对应的产品`
: err?.response?.data?.detail ??
err?.message ??
"查询失败,请检查后端服务";
: extractErrorMessage(err, "查询失败,请检查后端服务");
setError(msg);
} finally {
setLoading(false);
@ -537,28 +667,22 @@ export default function TaskTreeViewer() {
setModalTarget(null);
await refresh();
} catch (err: any) {
toast(
err?.response?.data?.detail ?? err?.message ?? "接收失败",
"error"
);
toast(extractErrorMessage(err, "接收失败"), "error");
} finally {
setSubmitting(false);
}
}
async function handleReject(reason: string) {
async function handleReject(reason: string, images: string[]) {
if (!modalTarget) return;
setSubmitting(true);
try {
await rejectTask(modalTarget.task.id, reason);
await rejectTask(modalTarget.task.id, reason, images);
toast("任务已驳回,已自动创建返工任务", "success");
setModalTarget(null);
await refresh();
} catch (err: any) {
toast(
err?.response?.data?.detail ?? err?.message ?? "驳回失败",
"error"
);
toast(extractErrorMessage(err, "驳回失败"), "error");
} finally {
setSubmitting(false);
}
@ -567,7 +691,8 @@ export default function TaskTreeViewer() {
async function handleTransfer(
nextTaskName: string,
assignees: string[],
note: string
note: string,
finishDirectly: boolean
) {
if (!modalTarget) return;
setSubmitting(true);
@ -576,16 +701,14 @@ export default function TaskTreeViewer() {
modalTarget.task.id,
assignees,
nextTaskName,
note || undefined
note || undefined,
finishDirectly
);
toast(result.message, "success");
setModalTarget(null);
await refresh();
} catch (err: any) {
toast(
err?.response?.data?.detail ?? err?.message ?? "转交失败",
"error"
);
toast(extractErrorMessage(err, "转交失败"), "error");
} finally {
setSubmitting(false);
}

View File

@ -0,0 +1,114 @@
/** 人员操作明细抽屉 — 数据源 /dashboard/user-operations/detail
*
* AdminDashboard(全局概览 · 人员操作统计)与 ScreenDashboard(大屏 · 系统活跃度)
* 共用同一实现,保证两处的下钻交互与呈现完全一致。
*
* 用法:ref.open({ userId, userName, actionType, label, since, until })
*/
import { forwardRef, useImperativeHandle, useState } from "react";
import { Drawer } from "antd";
import dayjs from "dayjs";
import { Loader2 } from "lucide-react";
import { fetchOperationDetail, type OperationDetail } from "../../services/dashboardApi";
export interface OperationDetailRequest {
userId: string;
userName: string;
/** 操作类型: receive / transfer / record */
actionType: string;
/** 类型中文名(接收 / 转交 / 上传备注),用于标题 */
label: string;
/** 时间范围 ISO;缺省则不加时间过滤 */
since?: string;
until?: string;
}
export interface UserOperationDetailDrawerHandle {
open: (req: OperationDetailRequest) => void;
}
const UserOperationDetailDrawer = forwardRef<
UserOperationDetailDrawerHandle,
{ theme?: "light" | "dark" }
>(function UserOperationDetailDrawer({ theme = "light" }, ref) {
const [open, setOpen] = useState(false);
const [list, setList] = useState<OperationDetail[]>([]);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const dark = theme === "dark";
useImperativeHandle(ref, () => ({
open: (req) => {
setOpen(true);
setTitle(`${req.userName} · ${req.label}`);
setLoading(true);
fetchOperationDetail(req.userId, req.actionType, req.since, req.until)
.then(setList)
.catch(() => setList([]))
.finally(() => setLoading(false));
},
}));
return (
<Drawer
title={
<span className={`text-base font-bold ${dark ? "text-slate-100" : ""}`}>
📋 {title}
<span className={`ml-1 font-normal ${dark ? "text-slate-500" : "text-gray-400"}`}>
{list.length} 条
</span>
</span>
}
open={open}
onClose={() => setOpen(false)}
size="large"
styles={{
header: dark ? { background: "#0f172a", borderBottom: "1px solid #1e293b" } : undefined,
content: dark ? { background: "#0b1220" } : undefined,
body: { padding: 16, background: dark ? "#0b1220" : "#f8fafc" },
}}
>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className={`h-6 w-6 animate-spin ${dark ? "text-cyan-500" : "text-blue-500"}`} />
</div>
) : list.length === 0 ? (
<div className={`py-16 text-center text-sm ${dark ? "text-slate-600" : "text-gray-400"}`}>
该时段暂无相关记录
</div>
) : (
<div>
{list.map((d, i) => (
<div
key={i}
className={`mb-2 rounded-lg border px-4 py-3 shadow-sm ${
dark ? "border-slate-800 bg-slate-900/50" : "border-gray-100 bg-white"
}`}
>
<div className="flex items-center justify-between">
<span className={`text-sm font-semibold ${dark ? "text-slate-100" : "text-gray-800"}`}>
{d.task_name}
</span>
<span className={`shrink-0 text-xs ${dark ? "text-slate-500" : "text-gray-400"}`}>
{d.time ? dayjs(d.time).format("YYYY-MM-DD HH:mm") : ""}
</span>
</div>
{d.remark && (
<p className={`mt-1 text-xs leading-relaxed ${dark ? "text-slate-300" : "text-gray-600"}`}>
{d.remark}
</p>
)}
<div className={`mt-1.5 text-[11px] ${dark ? "text-slate-500" : "text-gray-400"}`}>
<span>{d.material_name || "未知设备"}</span>
{d.product_sn && <span className="ml-2 font-mono">身份证: {d.product_sn}</span>}
</div>
</div>
))}
</div>
)}
</Drawer>
);
});
export default UserOperationDetailDrawer;

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2 } from "lucide-react";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
const MENU = [
@ -39,6 +39,12 @@ const MENU = [
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
{
title: "管理层大屏",
path: "/admin/screen",
icon: Tv,
description: "全屏数据大盘 · 直通率 / 产量趋势 / 不良分布",
},
];
export default function AdminLayout() {

View File

@ -1,7 +1,7 @@
/** 产品信息卡片 */
import { Package } from "lucide-react";
import type { ProductScanResponse } from "../../types/api";
import { statusColor, statusLabel } from "../../constants/task";
import { lifecycleBadge, statusColor, statusLabel } from "../../constants/task";
interface ProductCardProps {
product: ProductScanResponse;
@ -10,6 +10,8 @@ interface ProductCardProps {
export default function ProductCard({ product }: ProductCardProps) {
// 优先显示宏观状态(整体流转),回退到产品状态字段
const displayStatus = product.overall_status || product.status;
// 🔧 发货测试 / 售后维修 — 仅「测试 / 维修」工序下显示
const lifeBadge = lifecycleBadge(product.overall_status, product.lifecycle_phase);
return (
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2">
@ -18,6 +20,11 @@ export default function ProductCard({ product }: ProductCardProps) {
<span className={`ml-auto rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColor(displayStatus)}`}>
{statusLabel(displayStatus)}
</span>
{lifeBadge && (
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold ${lifeBadge.className}`}>
{lifeBadge.label}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>

View File

@ -1,7 +1,7 @@
/** 任务进度列表卡片 — 递归渲染 task_tree */
import { useMemo, memo } from "react";
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
import type { TaskResponse, TaskSummary } from "../../types/api";
import type { TaskResponse } from "../../types/api";
import { statusColor, statusLabel } from "../../constants/task";
interface TaskListCardProps {

View File

@ -0,0 +1,133 @@
/** 图片上传器 — 选图后直传 /api/v1/upload/,回填可访问的 URL 列表
*
* 用途:品质驳回弹窗(后端 TaskRejectRequest.images 为选填,可不上传)。
* 样式与 RejectModal 的原生控件保持一致(非 antd),支持缩略图预览与移除。
*/
import { useRef, useState } from "react";
import { ImagePlus, Loader2, X } from "lucide-react";
import api from "../../services/api";
import { extractErrorMessage } from "../../utils/errorMessage";
// 与后端 TaskRejectRequest.images 的 max_length 对齐
const MAX_IMAGES = 9;
interface ImageUploaderProps {
value: string[];
onChange: (urls: string[]) => void;
max?: number;
disabled?: boolean;
}
export default function ImageUploader({
value,
onChange,
max = MAX_IMAGES,
disabled,
}: ImageUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
setError("");
const slots = max - value.length;
if (slots <= 0) {
setError(`最多上传 ${max} 张图片`);
return;
}
const picked = Array.from(files).slice(0, slots);
const notImage = picked.find((f) => !f.type.startsWith("image/"));
if (notImage) {
setError(`「${notImage.name}」不是图片文件`);
return;
}
setUploading(true);
try {
const uploaded: string[] = [];
for (const file of picked) {
const form = new FormData();
form.append("file", file);
// api 实例默认 Content-Type 为 application/json,此处必须覆盖为 multipart
const { data } = await api.post<{ url: string }>("/upload/", form, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 60000, // 图片上传比普通请求慢,放宽超时
});
uploaded.push(data.url);
}
onChange([...value, ...uploaded]);
} catch (err: any) {
setError(extractErrorMessage(err, "上传失败,请重试"));
} finally {
setUploading(false);
// 清空 input,允许重复选择同一文件
if (inputRef.current) inputRef.current.value = "";
}
}
function removeAt(idx: number) {
onChange(value.filter((_, i) => i !== idx));
}
const full = value.length >= max;
return (
<div>
<div className="flex flex-wrap gap-2">
{value.map((url, i) => (
<div
key={`${url}-${i}`}
className="group relative h-20 w-20 overflow-hidden rounded-lg border border-gray-200"
>
<img src={url} alt={`异常图片 ${i + 1}`} className="h-full w-full object-cover" />
{!disabled && (
<button
type="button"
onClick={() => removeAt(i)}
title="移除"
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
{!full && (
<button
type="button"
disabled={disabled || uploading}
onClick={() => inputRef.current?.click()}
className="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-gray-300 text-gray-400 transition-colors hover:border-red-400 hover:text-red-500 disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ImagePlus className="h-5 w-5" />
)}
<span className="text-[11px]">{uploading ? "上传中" : "添加图片"}</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => handleFiles(e.target.files)}
/>
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-gray-400">
{value.length}/{max}
</span>
{error && <span className="text-xs text-red-500">{error}</span>}
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@
* 供 TaskTreeViewer / TaskListCard / ProductCard 等组件共用,
* 避免在多处重复维护相同的映射逻辑。
*/
import { TASK_STATUS } from "../types/api";
import { TASK_STATUS, type LifecyclePhase } from "../types/api";
export interface StatusStyle {
bg: string;
@ -112,6 +112,22 @@ export const STATUS_CONFIG: Record<string, StatusStyle> = {
ring: "ring-blue-400",
label: "维修",
},
// ---- 售后回流阶段专属工序(紫系)----
// 选紫色而非红色:红色在本系统里是「驳回 / 危险」的语义色,售后只是另一条
// 流转支线而非异常,用红色会让操作员误以为设备报错。也不选靛蓝——那已被
// 「已出库 OUTBOUND」占用,两者相邻显示会混淆。
发货测试: {
bg: "bg-purple-50",
text: "text-purple-700",
ring: "ring-purple-300",
label: "发货测试",
},
售后维修: {
bg: "bg-purple-50",
text: "text-purple-700",
ring: "ring-purple-300",
label: "售后维修",
},
};
const FALLBACK: StatusStyle = {
@ -137,3 +153,127 @@ export function statusColor(status: string): string {
export function statusLabel(status: string): string {
return getStatusConfig(status).label;
}
// ============================================================
// 🔧 生命周期阶段(lifecycle_phase)与工序选项隔离
//
// 生产制造(PRODUCTION)与售后回流(AFTER_SALES)各有一套合法工序名。
// ⚠️ 本段与后端 app/core/lifecycle.py 是同一份口径,改动请两边同步。
//
// 售后阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
// 「测试 / 维修」—— 这样看板、下钻、导出无需 JOIN lifecycle_phase 即可区分。
// ============================================================
export const LIFECYCLE_PHASE = {
PRODUCTION: "PRODUCTION",
AFTER_SALES: "AFTER_SALES",
} as const satisfies Record<string, LifecyclePhase>;
/** 售后专属工序名 — 选中即代表设备进入售后生命周期 */
export const STEP_SHIP_TEST = "发货测试";
export const STEP_AFTER_SALES_REPAIR = "售后维修";
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
// ── 生产制造阶段合法工序(原有词表,一字未改)──
export const PRODUCTION_TASK_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
export const PRODUCTION_OVERALL_OPTIONS = [
"备货", "生产", "测试", "维修", "在库", "已入库", "已出库",
];
// ── 售后回流阶段合法工序:只保留「发货测试 / 售后维修 / 入库出库」──
export const AFTER_SALES_TASK_OPTIONS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库"];
export const AFTER_SALES_OVERALL_OPTIONS = [
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "已入库", "已出库",
];
/**
* 按生命周期阶段取「可选工序」——选项隔离的 UI 侧实现。
*
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**,
* 让操作员自行声明这是生产设备还是售后回流设备:一旦选中售后专属工序,
* 后端即把设备判为 AFTER_SALES,之后下拉就只剩售后选项。
*
* 注意:这只是体验层的第一道防线,真正的拦截在后端
* task_service._enforce_step_isolation(防伪造传参)。
*/
export function taskOptionsFor(
phase: string | null | undefined,
hasHistory = true,
overallStatus?: string | null,
): string[] {
// 🔧 出库设备与售后生命周期同等待遇(与 track-uniapp/src/utils/lifecycle.js 同步):
// 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备翻成
// AFTER_SALES。若这里仍只认 phase,工人永远选不到「发货测试 / 售后维修」。
if (
phase === LIFECYCLE_PHASE.AFTER_SALES ||
String(overallStatus || "").trim() === "已出库"
) {
return AFTER_SALES_TASK_OPTIONS;
}
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS;
}
/** 按生命周期阶段取「可选宏观状态」— 同上,含已入库/已出库等终态 */
export function overallOptionsFor(
phase: string | null | undefined,
hasHistory = true,
): string[] {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_OVERALL_OPTIONS;
if (!hasHistory) return [...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_OVERALL_OPTIONS;
}
/** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */
/**
* 管理角色 — 必须与后端 task_service.ADMIN_ROLES 保持一致。
*
* ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage),
* 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。
* 新增判断一律用 isAdminRole(),不要再手写 === 比较。
*
* 注意:这是【体验层】判断,角色来自客户端存储、可被伪造。
* 真正的拦截在后端(transfer_task 对 finish_directly 有 403 硬校验)。
*/
export const ADMIN_ROLES = ["SUPER_ADMIN", "SUPERVISOR"] as const;
export function isAdminRole(role?: string | null): boolean {
return !!role && (ADMIN_ROLES as readonly string[]).includes(role);
}
export const ALL_OVERALL_OPTIONS = [
...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS,
];
/** 售后服务相关环节 — 只有落到这些工序才打红色标签 */
const AFTER_SALES_TAG_STEPS = new Set<string>(AFTER_SALES_ONLY_STEPS);
export interface LifecycleBadge {
label: string;
className: string;
phase: LifecyclePhase;
}
/**
* 生命周期标签 — 只在设备确实处于**售后回流环节**时返回:
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
* → 紫底白字,文案即工序名。
*
* 生产制造阶段一律返回 null(不打标),避免和售后设备混淆。
* 配色用紫色:红色在本系统是「驳回 / 危险」语义,售后只是另一条流转支线,
* 用红色会让操作员误以为设备报错。
*/
export function lifecycleBadge(
overallStatus: string | null | undefined,
phase: string | null | undefined,
): LifecycleBadge | null {
if (phase !== LIFECYCLE_PHASE.AFTER_SALES) return null;
const step = (overallStatus || "").trim();
if (!AFTER_SALES_TAG_STEPS.has(step)) return null;
return {
label: step,
className: "bg-purple-600 text-white ring-1 ring-purple-600",
phase: LIFECYCLE_PHASE.AFTER_SALES,
};
}

View File

@ -8,11 +8,22 @@ import { Loader2 } from "lucide-react";
import dayjs, { type Dayjs } from "dayjs";
import { fetchWipMatrix, fetchWipMatrixDetail, type WipMatrixRow, type WipMatrixDetailRow } from "../services/dashboardApi";
import { fetchDeviceRecords, type DeviceRecord } from "../services/analyticsApi";
import { lifecycleBadge } from "../constants/task";
const { RangePicker } = DatePicker;
// 🔧 固定标准主轴:核心工序 + 状态列始终显示(即使计数为 0),表头不随操作内容增减
const FIXED_STEP_COLUMNS = ["备货", "生产", "测试", "维修", "待确认", "已完成", "已入库", "已出库"];
// 🔧 三区段列定义:生产区 / 售后区 / 完结区
// 售后区必须独立成列 —— 出库回流设备的「发货测试 / 售后维修」若与生产区的
// 「测试 / 维修」共用一列,两类设备会被加在一起,统计完全失真。
// accent 用于给分组表头着色,售后区用紫色呼应标签配色(红=驳回语义,不占用)。
const COLUMN_GROUPS: { title: string; keys: string[]; accent: string }[] = [
{ title: "生产区", keys: ["待接收", "备货", "生产", "测试", "维修"], accent: "text-blue-700" },
{ title: "售后区", keys: ["发货测试", "售后维修"], accent: "text-purple-700" },
{ title: "完结区", keys: ["已完成", "已入库", "已出库"], accent: "text-gray-700" },
];
// 固定标准主轴:三区段的全部列始终显示(即使计数为 0),表头不随操作内容增减
const FIXED_STEP_COLUMNS = COLUMN_GROUPS.flatMap((g) => g.keys);
// ⏰ 时间筛选(与全局概览一致)
type DateRangeKey = "today" | "7d" | "30d" | "custom";
@ -42,6 +53,21 @@ function imageUrl(u: string) {
return base + path;
}
// ─── 单元格负责人聚合预览:数量解析 + TOP 折叠 ────────────────────
function labelCount(label: string): number {
const m = label.match(/\((\d+)\)\s*$/);
return m ? Number(m[1]) : 1;
}
function summarizeAssignees(list: string[]): string {
if (!list.length) return "";
// 数量降序(“未分配”同样参与按台数排序),数量最多者排最前
const sorted = [...list].sort((a, b) => labelCount(b) - labelCount(a));
if (sorted.length <= 2) return sorted.join("、");
// ≥3 人:只显示台数最多的第 1 人(去掉数量后缀),折叠为 “等 N 人”
const first = sorted[0].replace(/\(\d+\)\s*$/, "");
return `${first}等 ${sorted.length} 人`;
}
export default function MatrixBoard() {
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
@ -96,7 +122,18 @@ export default function MatrixBoard() {
// 🔧 全局唯一表头 + 单表数据:固定标准主轴在前,动态工序(数据中出现的新工序)追加在后
const { columns, dataSource, keys } = useMemo(() => {
const dynamicKeys = Array.from(new Set(data.map((d) => d.dimension_key)));
const keys = Array.from(new Set([...FIXED_STEP_COLUMNS, ...dynamicKeys]));
const fixedKeys = new Set(FIXED_STEP_COLUMNS);
const extraKeys = dynamicKeys.filter((k) => !fixedKeys.has(k));
// 🔧 三区段分组:自定义工序(喷漆 / 老化…)挂到生产区末尾 —— 它们是产线自建工序,
// 与售后区那两个受控工序名不同,无法从名字反推归属,故按生产口径展示。
// 注:售后设备的历史工序名已在后端归一(测试→发货测试 / 维修→售后维修),
// 所以这里拿到的 dimension_key 已经是正确的区段归属。
const groups = COLUMN_GROUPS.map((g, i) => ({
...g,
keys: i === 0 ? [...g.keys, ...extraKeys] : g.keys,
}));
const keys = groups.flatMap((g) => g.keys);
const rowsMap = new Map<string, any>();
for (const item of data) {
@ -125,31 +162,32 @@ export default function MatrixBoard() {
</div>
),
},
...keys.map((key) => ({
title: key,
dataIndex: key,
align: "center" as const,
width: 96,
render: (val: number, record: any) => {
const v = val || 0;
const assignees = record._assignees?.[key] || [];
return (
<div
className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
>
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
{assignees.length > 0 && (
<div
className="mx-auto max-w-[90px] truncate text-[10px] leading-tight text-gray-400"
title={assignees.join("、")}
>
{assignees.join("、")}
</div>
)}
</div>
);
},
// 🔧 两行表头:第一行区段名(生产区 / 售后区 / 完结区),第二行具体工序
...groups.map((g) => ({
title: <span className={`text-xs font-bold ${g.accent}`}>{g.title}</span>,
children: g.keys.map((key) => ({
title: key,
dataIndex: key,
align: "center" as const,
width: 96,
render: (val: number, record: any) => {
const v = val || 0;
const assignees = record._assignees?.[key] || [];
return (
<div
className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
>
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
{assignees.length > 0 && (
<div className="mx-auto max-w-[92px] text-xs leading-tight text-gray-500">
{summarizeAssignees(assignees)}
</div>
)}
</div>
);
},
})),
})),
{
title: "合计",
@ -169,11 +207,15 @@ export default function MatrixBoard() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-gray-800">📊 WIP 分布矩阵</h2>
<p className="mt-1 text-sm text-gray-500">生产分布透视表:规格型号 × 工序 · 含已完成/已入库</p>
<p className="mt-1 text-sm text-gray-500">生产分布透视表:规格型号 × 工序 · 含售后回流与已完结</p>
{/* 🔴 术语解释:区分"已完成"(车间完工待实收)与"已入库"(仓库已扫码实收) */}
<p className="mt-1 text-xs text-red-500">
ⓘ "已完成" = 车间完工已转交仓库,等待仓库扫码实收;"已入库" = 仓库已扫码确认实收。
</p>
{/* 🟣 三区段说明:强调售后区独立计数,不与生产区同名工序混算 */}
<p className="mt-1 text-xs text-purple-600">
ⓘ 表头分三区:<b>生产区</b>(产线工序)|<b>售后区</b>(出库回流设备的「发货测试 / 售后维修」,独立计数,不并入生产区的测试 / 维修)|<b>完结区</b>。
</p>
</div>
{/* ⏰ 时间筛选 */}
<div className="flex flex-wrap items-center gap-2">
@ -273,8 +315,21 @@ export default function MatrixBoard() {
{
title: "产品名称",
dataIndex: "material_name",
width: 160,
render: (v) => <span className="font-semibold text-blue-600">{v || "—"}</span>,
width: 200,
render: (v: string, record: WipMatrixDetailRow) => {
// 🔧 发货测试 / 售后维修 — 工序取当前下钻的列名(点产品名时为全部工序,不显示)
const lb = lifecycleBadge(drill?.process, record.lifecycle_phase);
return (
<span className="flex items-center gap-1.5">
<span className="font-semibold text-blue-600">{v || "—"}</span>
{lb && (
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
{lb.label}
</span>
)}
</span>
);
},
},
{
title: "身份证号",

View File

@ -1,6 +1,5 @@
/** 我的任务 — 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";

View File

@ -6,6 +6,8 @@ import { scanProduct } from "../services/productApi";
import type { ProductScanResponse } from "../types/api";
import ManualInput from "../components/scan/ManualInput";
import QueryResult from "../components/scan/QueryResult";
import { lifecycleBadge } from "../constants/task";
import { extractErrorMessage } from "../utils/errorMessage";
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
@ -31,11 +33,7 @@ export default function ScanPage() {
try {
setProduct(await scanProduct(sn));
} catch (err: unknown) {
const detail =
err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null;
setError(detail ?? "网络错误,请检查后端服务");
setError(extractErrorMessage(err, "网络错误,请检查后端服务"));
} finally {
setLoading(false);
}
@ -74,6 +72,11 @@ export default function ScanPage() {
<span className={`flex-1 text-sm font-bold ${product.overall_status === "已入库" ? "text-gray-500" : product.overall_status === "待仓库收货" ? "text-orange-600" : product.overall_status ? "text-blue-600" : "text-red-500"}`}>
{product.overall_status || "未设定"}
</span>
{/* 🔧 售后回流标识:处于发货测试/售后维修环节时红底白字提示 */}
{(() => {
const lb = lifecycleBadge(product.overall_status, product.lifecycle_phase);
return lb ? <span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${lb.className}`}>{lb.label}</span> : null;
})()}
</div>
</div>
)}
@ -126,6 +129,11 @@ export default function ScanPage() {
<span className={`flex-1 text-sm font-bold ${product.overall_status === "已入库" ? "text-gray-500" : product.overall_status === "待仓库收货" ? "text-orange-600" : product.overall_status ? "text-blue-600" : "text-red-500"}`}>
{product.overall_status || "未设定"}
</span>
{/* 🔧 售后回流标识:处于发货测试/售后维修环节时红底白字提示 */}
{(() => {
const lb = lifecycleBadge(product.overall_status, product.lifecycle_phase);
return lb ? <span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${lb.className}`}>{lb.label}</span> : null;
})()}
</div>
</div>
)}

View File

@ -1,15 +1,18 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
Clock, MessageCircle, CheckCircle2, Users,
Clock, MessageCircle, CheckCircle2, Users, Info,
} from "lucide-react";
import { Radio, DatePicker, Drawer, Input } from "antd";
import { Radio, DatePicker, Drawer, Input, Tooltip } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchOperationDetail,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation, type OperationDetail,
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation,
} from "../../services/dashboardApi";
import UserOperationDetailDrawer, {
type UserOperationDetailDrawerHandle,
} from "../../components/admin/UserOperationDetailDrawer";
const { RangePicker } = DatePicker;
@ -31,10 +34,16 @@ function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
}
// ─── 进度条(支持3或4段) ─────────────────────────────────
function ProgressBar({ a, b, c, total, labels, d }: {
function ProgressBar({ a, b, c, total, labels, d, hints }: {
a: number; b: number; c: number; total: number;
labels: [string, string, string];
d?: number;
/**
* 图例口径说明:label → 悬浮提示文案。
* 用于消除「产品数 > 任务数」这类口径差异带来的误判 —— 底层逻辑没问题,
* 但不写清楚就极像算错数据。
*/
hints?: Record<string, string>;
}) {
if (total === 0) return <div className="py-4 text-center text-xs text-gray-400">暂无数据</div>;
const pct = (n: number) => Math.round((n / total) * 100);
@ -57,6 +66,11 @@ function ProgressBar({ a, b, c, total, labels, d }: {
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
{s.label} {s.n}({pct(s.n)}%)
{hints?.[s.label] && (
<Tooltip title={hints[s.label]}>
<Info className="h-3 w-3 shrink-0 cursor-help text-gray-400 transition-colors hover:text-gray-600" />
</Tooltip>
)}
</span>
))}
</div>
@ -187,43 +201,34 @@ function CompletedRow({ t }: { t: CompletedTask }) {
}
// ─── 被驳回明细项(卡片式) ──────────────────────────────
/**
* 驳回明细行。
*
* 只渲染「已驳回」这一种形态:一次驳回必然派生一条返工任务,两条同时成行会
* 让看板看起来「有两条异常」。而本行已经带了「返工给: xxx」,一条就足以说清
* 「这道工序被驳回了,现在由谁在返工」的完整语义,返工任务无需再独立成行。
*/
function RejectedRow({ t }: { t: RejectedTask }) {
const timeStr = t.rejected_at ? dayjs(t.rejected_at).format("YYYY-MM-DD HH:mm") : "";
const isRework = t.kind === "rework";
return (
<div
onClick={() => t.product_sn && window.open(`/admin/tasks?sn=${t.product_sn}`, "_blank")}
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md"
>
<div className="flex items-center gap-2">
{isRework ? <RefreshCw className="h-4 w-4 shrink-0 text-orange-500" /> : <AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />}
<AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
<span className="text-xs text-gray-500">|</span>
{isRework ? (
<span className="shrink-0 rounded-full bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
{t.status === "WIP" ? "返工中" : t.status === "PENDING" ? "待返工" : "已返工"}
</span>
) : (
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span>
)}
<span className="text-xs text-gray-500 shrink-0">
{isRework ? `返工人: ${t.rework_assignee}` : `驳回人: ${t.rejected_by}`}
</span>
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span>
<span className="text-xs text-gray-500 shrink-0">驳回人: {t.rejected_by}</span>
<span className="ml-auto shrink-0 text-xs text-gray-400">{timeStr}</span>
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
{!isRework && (
<>
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
🔁 返工给: {t.rework_assignee}
</span>
<span className="text-gray-300">|</span>
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span>
</>
)}
{isRework && (
<span className="truncate text-orange-600/80">🔁 返工任务 · {t.rework_assignee} 负责</span>
)}
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
🔁 返工给: {t.rework_assignee}
</span>
<span className="text-gray-300">|</span>
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span>
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
@ -242,6 +247,9 @@ function RejectedRow({ t }: { t: RejectedTask }) {
export default function AdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [wipTasks, setWipTasks] = useState<WipTask[]>([]);
// 在制品列表的真实总数,与「任务状态」卡片的活跃任务同源(待接收 + 进行中)。
// 用它而不是 wipTasks.length 做计数,避免接口截断时列表谎报总数。
const activeTaskCount = (stats?.tasks_pending ?? 0) + (stats?.tasks_in_progress ?? 0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -276,11 +284,8 @@ export default function AdminDashboard() {
// 抽屉内独立时间筛选(不依赖顶部)
const [opDateKey, setOpDateKey] = useState<DateRangeKey>("today");
const [opCustomRange, setOpCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
// 操作明细下钻
const [opDetailOpen, setOpDetailOpen] = useState(false);
const [opDetailList, setOpDetailList] = useState<OperationDetail[]>([]);
const [opDetailLoading, setOpDetailLoading] = useState(false);
const [opDetailTitle, setOpDetailTitle] = useState("");
// 操作明细下钻 — 交给共用抽屉组件(与大屏 ScreenDashboard 同一实现)
const opDetailRef = useRef<UserOperationDetailDrawerHandle>(null);
// ── 加载主数据 ──
const loadData = useCallback((key: DateRangeKey, range: [Dayjs, Dayjs] | null) => {
@ -288,7 +293,7 @@ export default function AdminDashboard() {
const { since, until } = rangeToParams(key, range);
Promise.all([
fetchDashboardStats(since, until),
fetchWipTasks(20),
fetchWipTasks(), // 不传 limit:取全量在制品,避免被截断后与卡片数字对不上
])
.then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); })
.catch(() => setError("加载失败,请确认后端已启动"))
@ -395,14 +400,15 @@ export default function AdminDashboard() {
// 点击数字下钻查看明细(跟随抽屉内时间)
const openOpDetail = (user: UserOperation, actionType: string, label: string) => {
setOpDetailOpen(true);
setOpDetailTitle(`${user.user_name} · ${label}`);
setOpDetailLoading(true);
const { since, until } = rangeToParams(opDateKey, opCustomRange);
fetchOperationDetail(user.user_id, actionType, since, until)
.then(setOpDetailList)
.catch(() => setOpDetailList([]))
.finally(() => setOpDetailLoading(false));
opDetailRef.current?.open({
userId: user.user_id,
userName: user.user_name,
actionType,
label,
since,
until,
});
};
const onMsgSearch = (value: string) => {
@ -435,7 +441,7 @@ export default function AdminDashboard() {
<div>
<h2 className="text-xl font-bold text-gray-800">📊 生产管理看板</h2>
<p className="mt-0.5 text-sm text-gray-400">
产品 = 物理实体(身份证)| 任务 = 工序节点
产品 = 车间里的物理设备 | 任务 = 派发给工人的生产指令(派工单)
</p>
</div>
<div className="flex items-center gap-3">
@ -468,12 +474,6 @@ export default function AdminDashboard() {
</div>
</div>
{/* 提示:已完结受时间筛选 */}
{dateKey !== "today" && (
<div className="rounded-lg bg-blue-50 px-3 py-1.5 text-[11px] text-blue-600">
📐 当前时间筛选仅影响<strong>已完成/已驳回</strong>计数,在制品和总数始终为实时快照
</div>
)}
{/* ═══ 4 卡片 ═══ */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
@ -484,9 +484,12 @@ export default function AdminDashboard() {
<h3 className="text-sm font-semibold text-gray-700">📦 产品流转</h3>
</div>
<p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> 个</span></p>
<p className="mb-3 text-[11px] text-gray-400">实时快照(不受时间筛选影响)</p>
<p className="mb-3 text-[11px] text-gray-400">在制品为实时快照;「已完结」受右上角时间筛选影响</p>
<ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed}
total={stats.products_total} labels={["待流转", "流转中", "已完结"]} />
total={stats.products_total} labels={["待流转", "流转中", "已完结"]}
hints={{
"待流转": "指已建档,但尚未派发首道工序,或当前无任何活跃工序挂载的物理产品。因此待流转数可能会大于待接收任务数。",
}} />
</div>
{/* 任务状态 */}
@ -498,7 +501,10 @@ export default function AdminDashboard() {
<p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> 个</span></p>
<p className="mb-3 text-[11px] text-gray-400">PENDING/WIP 实时 | COMPLETED 按时段</p>
<ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed}
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected} />
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected}
hints={{
"待接收": "指系统已生成具体工序单并指派给工人,但工人尚未在手机端点击接单的任务。",
}} />
</div>
{/* 品质 & 留言 */}
@ -570,12 +576,18 @@ export default function AdminDashboard() {
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<Clock className="h-4 w-4 text-orange-500" /> 当前在制品(实时)
</h3>
<span className="text-[11px] text-gray-400">共 {wipTasks.length} 个</span>
{/* 计数取「任务状态」卡片同源的真实活跃数,而不是 wipTasks.length ——
后者一旦被接口截断就会比实际少,出现「卡片 23、列表共 20」的假象 */}
<span className="text-[11px] text-gray-400">
共 {activeTaskCount} 个
{wipTasks.length < activeTaskCount && `(仅显示前 ${wipTasks.length} 条)`}
</span>
</div>
{wipTasks.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-400">🎉 暂无滞留任务</div>
) : (
<div>
// 容器内滚动:列表可能很长(全量在制品),不让它把整页撑开
<div className="max-h-[420px] overflow-y-auto pr-1">
<div className="mb-1 flex items-center gap-3 text-[11px] font-medium text-gray-400">
<span className="w-14 shrink-0">状态</span>
<span className="flex-1">任务 · 负责人</span>
@ -691,7 +703,7 @@ export default function AdminDashboard() {
{/* ═══ 驳回/返工明细抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">🔴 驳回/返工明细 <span className="font-normal text-gray-400">共 {rejectedTasks.length} 条</span></span>}
title={<span className="text-base font-bold">🔴 驳回明细 <span className="font-normal text-gray-400">共 {rejectedTasks.filter(t => t.kind === "rejected").length} 条</span></span>}
open={rejectedDrawerOpen}
onClose={() => setRejectedDrawerOpen(false)}
size="large"
@ -701,37 +713,29 @@ export default function AdminDashboard() {
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-red-500" />
</div>
) : rejectedTasks.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">
该时段暂无驳回/返工记录
</div>
) : (
<div className="space-y-4">
{(() => {
const rejected = rejectedTasks.filter(t => t.kind === "rejected");
const rework = rejectedTasks.filter(t => t.kind === "rework");
(() => {
// 只展示「已驳回」行:返工任务是驳回自动派生的,独立成行会让同一次
// 异常看起来像两条(详见 RejectedRow 顶部说明)。
const rejected = rejectedTasks.filter(t => t.kind === "rejected");
if (rejected.length === 0) {
return (
<>
{rejected.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600">
<AlertTriangle className="h-3.5 w-3.5" />已驳回 · {rejected.length} 条
</h4>
{rejected.map(t => <RejectedRow key={t.task_id} t={t} />)}
</div>
)}
{rework.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-orange-600">
<RefreshCw className="h-3.5 w-3.5" />返工任务 · {rework.length} 条
</h4>
{rework.map(t => <RejectedRow key={t.task_id} t={t} />)}
</div>
)}
</>
<div className="py-16 text-center text-sm text-gray-400">
该时段暂无驳回记录
</div>
);
})()}
</div>
}
return (
<div className="space-y-4">
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600">
<AlertTriangle className="h-3.5 w-3.5" />已驳回 · {rejected.length} 条
</h4>
{rejected.map(t => <RejectedRow key={t.task_id} t={t} />)}
</div>
</div>
);
})()
)}
</Drawer>
@ -859,38 +863,8 @@ export default function AdminDashboard() {
)}
</Drawer>
{/* ═══ 操作明细抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">📋 {opDetailTitle} <span className="font-normal text-gray-400">{opDetailList.length} 条</span></span>}
open={opDetailOpen}
onClose={() => setOpDetailOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
{opDetailLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : opDetailList.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">该时段暂无相关记录</div>
) : (
<div>
{opDetailList.map((d, i) => (
<div key={i} className="mb-2 rounded-lg border border-gray-100 bg-white px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">{d.task_name}</span>
<span className="shrink-0 text-xs text-gray-400">{d.time ? dayjs(d.time).format("YYYY-MM-DD HH:mm") : ""}</span>
</div>
{d.remark && <p className="mt-1 text-xs leading-relaxed text-gray-600">{d.remark}</p>}
<div className="mt-1.5 text-[11px] text-gray-400">
<span>{d.material_name || "未知设备"}</span>
{d.product_sn && <span className="ml-2 font-mono">身份证: {d.product_sn}</span>}
</div>
</div>
))}
</div>
)}
</Drawer>
{/* ═══ 操作明细抽屉 — 公共组件,与大屏 ScreenDashboard 共用同一实现 ═══ */}
<UserOperationDetailDrawer ref={opDetailRef} />
</div>
);
}

View File

@ -6,8 +6,7 @@ import {
import { Radio, DatePicker, Table, Button, AutoComplete, Modal, Timeline, Image, Select } from "antd";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
import "dayjs/locale/zh-cn";
dayjs.locale("zh-cn");
// dayjs 的中文 locale 已在应用根(App.tsx)统一设置,此处不再重复
import {
fetchPeopleWorkload, fetchPeopleHistory, downloadPeopleHistory,
type PersonWorkload, type PersonHistoryRecord, type PeopleHistoryQuery,

View File

@ -1,11 +1,12 @@
import { useEffect, useState } from "react";
import { Settings, Save, Loader2, Wifi, WifiOff } from "lucide-react";
import { Save, Loader2, Wifi, WifiOff } from "lucide-react";
import {
getPrinterConfig,
updatePrinterConfig,
type PrinterConfig,
} from "../../services/printApi";
import { useToast } from "../../components/ui/Toast";
import { extractErrorMessage } from "../../utils/errorMessage";
export default function AdminPrintConfigPage() {
const { toast } = useToast();
@ -33,7 +34,7 @@ export default function AdminPrintConfigPage() {
setPort(lp.port);
setEnabled(lp.enabled ?? true);
} catch (err: any) {
toast(err?.response?.data?.detail ?? "加载配置失败", "error");
toast(extractErrorMessage(err, "加载配置失败"), "error");
} finally {
setLoading(false);
}
@ -50,7 +51,7 @@ export default function AdminPrintConfigPage() {
const result = await updatePrinterConfig(ip.trim(), port, enabled);
toast(result.message, "success");
} catch (err: any) {
toast(err?.response?.data?.detail ?? "保存失败", "error");
toast(extractErrorMessage(err, "保存失败"), "error");
} finally {
setSaving(false);
}

View File

@ -13,7 +13,8 @@ import {
} from "../../services/printApi";
import { useToast } from "../../components/ui/Toast";
import { useAuth } from "../../contexts/AuthContext";
import { getStatusConfig } from "../../constants/task";
import { getStatusConfig, lifecycleBadge } from "../../constants/task";
import { extractErrorMessage } from "../../utils/errorMessage";
const QR_BASE = "/api/v1/products/qrcode";
@ -125,12 +126,12 @@ export default function AdminProductsPage() {
}
// ---- 打印 ----
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); } }
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: unknown) { toast(extractErrorMessage(err, "生成预览失败"), "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: unknown) { toast(extractErrorMessage(err, "打印失败"), "error"); } finally { setPrinting(false); } }
// ---- 编辑 ----
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); } }
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: unknown) { toast(extractErrorMessage(err, "保存失败"), "error"); } finally { setEditSaving(false); } }
// ---- 删除 ----
useEffect(() => {
@ -141,7 +142,7 @@ export default function AdminProductsPage() {
}, [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); } }
async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: unknown) { toast(extractErrorMessage(err, "删除失败"), "error"); } finally { setDeleting(false); } }
// ---- 状态收口(入库 / 出库,管理员)----
// 🛡️ 双重确认倒计时 + 必须输入产品身份证,防误触
@ -164,8 +165,8 @@ export default function AdminProductsPage() {
toast(`已收口为「${finalizeStatus}」`, "success");
setFinalizeTarget(null);
loadProducts();
} catch (err: any) {
toast(err?.response?.data?.detail ?? "操作失败", "error");
} catch (err: unknown) {
toast(extractErrorMessage(err, "操作失败"), "error");
} finally {
setFinalizing(false);
}
@ -275,12 +276,21 @@ export default function AdminProductsPage() {
<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>
{(() => {
const cfg = getStatusConfig(p.macro_status || p.status);
return cfg.label ? (
<span className={`mt-1 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
) : null;
})()}
<div className="mt-1 flex flex-wrap items-center justify-center gap-1.5">
{(() => {
const cfg = getStatusConfig(p.macro_status || p.status);
return cfg.label ? (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
) : null;
})()}
{/* 🔧 发货测试 / 售后维修 — 仅「测试 / 维修」工序下显示 */}
{(() => {
const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase);
return lb ? (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${lb.className}`}>{lb.label}</span>
) : null;
})()}
</div>
</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 || "—"} />

View File

@ -5,7 +5,7 @@ import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
} from "lucide-react";
import { Tooltip, Popover, Checkbox, Input } from "antd";
import { Tooltip, Popover, Checkbox, Input, Button } from "antd";
import api from "../../services/api";
import { scanProduct } from "../../services/productApi";
import {
@ -17,15 +17,20 @@ import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../co
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
import { getStatusConfig, lifecycleBadge, ALL_OVERALL_OPTIONS } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
import { extractErrorMessage } from "../../utils/errorMessage";
const STATUS_TABS = [
const STATUS_TABS: { key: string; label: string; accent?: boolean }[] = [
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
{ key: "OUTBOUND", label: "已出库" },
// 🔧 售后流转中 = 出库回流设备中正在流转的(lifecycle_phase=AFTER_SALES 且 当前 WIP)。
// 它与上面几个"状态码"不是同一维度(靠 lifecycle_phase 判定),故单列一个独立 key;
// 多选时与其它状态是「或」关系 —— 只勾它即可一键剥离全部生产数据。
{ key: "AFTER_SALES_WIP", label: "售后流转中", accent: true },
];
type SortOrder = "asc" | "desc";
@ -60,11 +65,15 @@ function formatDuration(hours: number | null): string {
export default function AdminTasksPage() {
const { toast } = useToast();
const { user: currentUser } = useAuth();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
// 从大屏下钻: ?search=xxx 灌进搜索框、?status=WIP,ARCHIVED 点亮状态 Tabs
// —— 直接复用原生筛选状态,表现与用户手点完全一致,不引入任何额外 UI
const [keyword, setKeyword] = useState(() => searchParams.get("search") || "");
const [statusFilters, setStatusFilters] = useState<Set<string>>(
() => new Set((searchParams.get("status") || "").split(",").map((s) => s.trim()).filter(Boolean)),
);
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(false);
@ -105,10 +114,27 @@ export default function AdminTasksPage() {
render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>,
},
{
key: "overall_status", label: "当前工序", colSpan: 1,
filterType: "enum", getFilterValue: (p) => p.overall_status || "—",
enumOptions: ["备货", "生产", "测试", "维修", "待仓库收货", "已入库", "已出库"].map((v) => ({ value: v, label: v })),
render: (p) => <span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>,
key: "current_step", label: "当前工序", colSpan: 1,
// 🔧 改用后端派生的 current_step,而不是 overall_status ——
// 后者的取值被"最新主干任务名"覆盖(含已 COMPLETED 的历史工序),
// 会让「扫码出库 / 测试 / 发货测试」等已完结工序冒充"当前工序"。
// current_step 只认活跃主干任务;无活跃任务时返回终态或空。
filterType: "enum", getFilterValue: (p) => p.current_step || "—",
// 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来
enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })),
render: (p) => {
const lb = lifecycleBadge(p.current_step, p.lifecycle_phase);
return (
<span className="flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700">{p.current_step || "—"}</span>
{lb && (
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
{lb.label}
</span>
)}
</span>
);
},
},
{
key: "status", label: "产品状态", colSpan: 1,
@ -194,7 +220,14 @@ export default function AdminTasksPage() {
const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key));
const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null);
const [textFilters, setTextFilters] = useState<Record<string, string>>({});
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>({});
// 从大屏下钻: ?stage=测试 → 点亮「当前工序」列的筛选(该列显示值对空状态用「—」,
// 故「待启动」需折算成「—」才能命中)
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>(() => {
const stage = searchParams.get("stage");
const initial: Record<string, Set<string>> = {};
if (stage) initial.overall_status = new Set([stage === "待启动" ? "—" : stage]);
return initial;
});
const [dragCol, setDragCol] = useState<string | null>(null);
const [dragOverCol, setDragOverCol] = useState<string | null>(null);
// 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示)
@ -256,7 +289,13 @@ export default function AdminTasksPage() {
for (const p of products) {
if (statusFilters.size > 0) {
const currentStatus = (p.macro_status || p.status).toUpperCase();
if (!statusFilters.has(currentStatus)) continue;
// 🔧 「售后流转中」按 lifecycle_phase 判定,与状态码不同维,单独算:
// 只勾它时,未勾任何状态码 → 仅留下售后回流且正在流转的设备。
const isAfterSalesWip =
p.lifecycle_phase === "AFTER_SALES" && currentStatus === "WIP";
const matchedByStatus = statusFilters.has(currentStatus);
const matchedByAfterSales = statusFilters.has("AFTER_SALES_WIP") && isAfterSalesWip;
if (!matchedByStatus && !matchedByAfterSales) continue;
}
// 列筛选(所有列)
let skip = false;
@ -308,7 +347,51 @@ export default function AdminTasksPage() {
});
}, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps
/** 删除 URL 上的指定跳转预设参数(只删传入的 key,不动其它无关参数) */
function clearUrlPresets(keys: string[]) {
const hit = keys.filter((k) => searchParams.has(k));
if (hit.length === 0) return;
const next = new URLSearchParams(searchParams);
hit.forEach((k) => next.delete(k));
setSearchParams(next, { replace: true });
}
/**
* 解锁大屏带过来的 ?stage —— URL 参数与本地列筛选一起清掉。
*
* 该筛选只应在「从大屏跳进来的那一刻」生效一次。它藏在列筛选浮层里,
* 界面上几乎不可见,若不清除会与状态 Tab 叠加成互斥条件、筛出空列表,
* 用户却找不到原因。用户手动改了任何分类条件 = 最新意图优先。
*/
function clearStageLock() {
clearUrlPresets(["stage"]);
setEnumFilters((prev) => {
if (!prev.overall_status) return prev;
const next = { ...prev };
delete next.overall_status;
return next;
});
}
/** 全局重置:清空全部筛选条件与跳转预设,并强制一次无条件全量查询 */
function handleReset() {
// stage(工序锁)与 sn(设备定位)都是跳转带进来的预设,重置时一并丢弃,
// 否则用户刷新页面会被旧条件"复活"回去
clearUrlPresets(["stage", "sn"]);
setKeyword("");
setStatusFilters(new Set());
setTextFilters({});
setEnumFilters({});
setSort(null);
setExpandedOrders(new Set());
setTaskTrees({});
setActiveTreeProductId(null);
loadProducts("");
}
// 用户手动切换状态分类 = 最新意图,丢弃大屏带来的工序限制
function toggleStatusFilter(key: string) {
clearStageLock();
setStatusFilters((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
@ -395,7 +478,7 @@ export default function AdminTasksPage() {
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"))
.catch((err: unknown) => toast(extractErrorMessage(err, "加载任务树失败"), "error"))
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
}
}
@ -422,24 +505,24 @@ export default function AdminTasksPage() {
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error");
toast(extractErrorMessage(err, "接收失败"), "error");
} finally {
setSubmitting(false);
}
}
async function handleReject(reason: string) {
async function handleReject(reason: string, images: string[]) {
if (!modalTarget) return;
setSubmitting(true);
try {
await rejectTask(modalTarget.task.id, reason);
await rejectTask(modalTarget.task.id, reason, images);
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");
toast(extractErrorMessage(err, "驳回失败"), "error");
} finally {
setSubmitting(false);
}
@ -449,12 +532,13 @@ export default function AdminTasksPage() {
nextTaskName: string,
assignees: string[],
note: string,
finishDirectly: boolean,
) {
if (!modalTarget) return;
setSubmitting(true);
try {
const result = await transferTask(
modalTarget.task.id, assignees, nextTaskName, note || undefined,
modalTarget.task.id, assignees, nextTaskName, note || undefined, finishDirectly,
);
toast(result.message, "success");
const sn = modalTarget.task.product_sn || "";
@ -462,7 +546,7 @@ export default function AdminTasksPage() {
if (sn) await refreshProductTree(sn);
await loadProducts(keyword);
} catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error");
toast(extractErrorMessage(err, "转交失败"), "error");
} finally {
setSubmitting(false);
}
@ -566,6 +650,7 @@ export default function AdminTasksPage() {
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" />
@ -594,12 +679,13 @@ export default function AdminTasksPage() {
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
查询
</button>
<Button onClick={handleReset} disabled={loading}>重置</Button>
</form>
{/* 状态筛选 Tabs(多选) */}
<div className="mt-3 flex gap-1.5 flex-wrap">
<button
onClick={() => setStatusFilters(new Set())}
onClick={() => { clearStageLock(); setStatusFilters(new Set()); }}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilters.size === 0
? "bg-blue-600 text-white"
@ -616,8 +702,8 @@ export default function AdminTasksPage() {
onClick={() => toggleStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
active
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
? tab.accent ? "bg-purple-600 text-white" : "bg-blue-600 text-white"
: tab.accent ? "bg-purple-50 text-purple-700 hover:bg-purple-100" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{tab.label}

View File

@ -6,7 +6,7 @@ import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image, Radio
import { Users, GitBranch, RefreshCw, Loader2, AlertCircle, CalendarDays } from "lucide-react";
import dayjs, { type Dayjs } from "dayjs";
import type { EChartsCoreOption } from "echarts/core";
import "dayjs/locale/zh-cn";
// dayjs 的中文 locale 已在应用根(App.tsx)统一设置,此处不再重复
import BaseEChart from "../../components/BaseEChart";
import {
@ -15,8 +15,6 @@ import {
type CapabilityQuery, type FlowQuery, type DeviceRecord, type FlowDevice, type FlowInterval,
} from "../../services/analyticsApi";
dayjs.locale("zh-cn");
const { RangePicker } = DatePicker;
// ─── 状态中文映射 ─────────────────────────────────────────

View File

@ -3,6 +3,7 @@ import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } f
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
import api from "../../services/api";
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
import { extractErrorMessage } from "../../utils/errorMessage";
// ============================================================
// 类型
@ -184,8 +185,8 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
});
setCreatedSn(data.serial_number);
onCreated();
} catch (err: any) {
message.error(err?.response?.data?.detail ?? "创建失败");
} catch (err: unknown) {
message.error(extractErrorMessage(err, "创建失败"));
} finally {
setSubmitting(false);
}

View File

@ -0,0 +1,482 @@
/** 管理层数据大屏 — 全屏无侧边栏 · 亮色主题(与系统其他页面统一)· 60s 自动轮询
*
* 视角:日常运营与督导 —— 当月吞吐 / 当前卡点 / 系统活跃度。
*
* 布局:
* 顶部一排 4 张当月指标卡(生产流转 / 已入库 / 已出库 / 返厂回流)
* 左·主图区 【车间工序积压分布】横向柱状图,按生产段/售后段着色
* 右·侧边区 【系统活跃度排行】本月 接收 / 转交 / 备注 次数表(主体,撑满高度)
*
* 跳转:下钻一律用 window.open 开新标签页 —— 领导看板时不丢失大盘视图。
* 过滤:URL 参数直接映射到 AdminTasksPage 的原生筛选状态
* (?status= 点亮状态 Tabs、?stage= 点亮「当前工序」列筛选),
* 不引入任何大屏专用的额外 UI。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Maximize2, Minimize2, RefreshCw, ArrowLeft } from "lucide-react";
import type { EChartsCoreOption } from "echarts/core";
import BaseEChart from "../../components/BaseEChart";
import { useAuth } from "../../contexts/AuthContext";
import {
fetchMonthlyMetrics,
fetchWipDistribution,
fetchActiveUsers,
type MonthlyMetrics,
type WipDistributionResponse,
type ActiveUser,
type ActiveUsersResponse,
} from "../../services/screenApi";
import UserOperationDetailDrawer, {
type UserOperationDetailDrawerHandle,
} from "../../components/admin/UserOperationDetailDrawer";
// 大屏轮询间隔
const REFRESH_MS = 60_000;
// 亮色主题下的 ECharts 配色
const AXIS_COLOR = "#6b7280"; // 坐标轴文字 gray-500
const GRID_COLOR = "#e5e7eb"; // 网格线 gray-200
const TEXT_COLOR = "#374151"; // 标签文字 gray-700
const TOOLTIP_STYLE = {
backgroundColor: "rgba(255,255,255,0.97)",
borderColor: "#d1d5db",
textStyle: { color: "#1f2937" },
extraCssText: "box-shadow:0 4px 12px rgba(0,0,0,0.08);",
};
// 生产段 / 售后段的柱子配色
const COLOR_PRODUCTION = "#0891b2"; // cyan-600
const COLOR_AFTER_SALES = "#ea580c"; // orange-600
// 操作类型 → 中文名(明细抽屉标题用)
const OP_LABELS: Record<string, string> = {
receive: "接收",
transfer: "转交",
record: "上传备注",
};
// ============================================================
// 鉴权外壳 — 与 AdminLayout 同款守卫,但不渲染侧边栏
// ============================================================
export default function ScreenDashboard() {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-gray-50">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
);
}
if (!isAuthenticated) return <Navigate to="/admin/login" replace />;
return <ScreenBoard />;
}
// ============================================================
// 大屏主体
// ============================================================
function ScreenBoard() {
const navigate = useNavigate();
const [metrics, setMetrics] = useState<MonthlyMetrics | null>(null);
const [dist, setDist] = useState<WipDistributionResponse | null>(null);
const [users, setUsers] = useState<ActiveUsersResponse | null>(null);
// 操作明细下钻 — 与「全局概览」AdminDashboard 的人员操作统计共用同一抽屉组件
const opDetailRef = useRef<UserOperationDetailDrawerHandle>(null);
const [updatedAt, setUpdatedAt] = useState("");
const [error, setError] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// 卸载后不再 setState;reqId 丢弃过期响应(防止慢请求覆盖新数据)
const aliveRef = useRef(true);
const reqIdRef = useRef(0);
const load = useCallback(async () => {
const reqId = ++reqIdRef.current;
setRefreshing(true);
try {
const [m, d, u] = await Promise.all([
fetchMonthlyMetrics(),
fetchWipDistribution(),
fetchActiveUsers(10), // 多取一些,过滤掉 0 操作的人后再展示
]);
if (!aliveRef.current || reqId !== reqIdRef.current) return;
setMetrics(m);
setDist(d);
setUsers(u);
setError("");
setUpdatedAt(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
} catch {
if (!aliveRef.current || reqId !== reqIdRef.current) return;
setError("数据加载失败,将自动重试");
} finally {
if (aliveRef.current && reqId === reqIdRef.current) setRefreshing(false);
}
}, []);
// 首屏加载 + 60s 轮询
useEffect(() => {
aliveRef.current = true;
load();
const timer = setInterval(load, REFRESH_MS);
return () => {
aliveRef.current = false;
clearInterval(timer);
};
}, [load]);
// 全屏状态同步(用户按 Esc 退出时图标要跟着变)
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
function toggleFullscreen() {
if (document.fullscreenElement) void document.exitFullscreen();
else void document.documentElement.requestFullscreen();
}
/** 下钻一律开新标签页 —— 领导看板时不丢失大盘视图 */
const openDrill = useCallback((url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
}, []);
// 柱状图下钻:点击某道工序 → 任务全景页点亮「当前工序」列筛选
const handleBarClick = useCallback((params: any) => {
const stage = params?.name;
if (!stage) return;
openDrill(`/admin/tasks?stage=${encodeURIComponent(stage)}`);
}, [openDrill]);
// ── 横向柱状图:车间工序积压分布 ──
const distOption = useMemo<EChartsCoreOption>(() => {
const items = dist?.items ?? [];
const total = dist?.total ?? 0;
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (params: any) => {
const row = items[params[0]?.dataIndex ?? 0];
if (!row) return "";
const pct = total ? ((row.count / total) * 100).toFixed(1) : "0.0";
const phaseLabel = row.phase === "AFTER_SALES" ? "售后段" : "生产段";
return `${row.stage}(${phaseLabel})<br/><b>${row.count}</b> 台 · 占比 ${pct}%`;
},
...TOOLTIP_STYLE,
},
grid: { left: 6, right: 52, top: 8, bottom: 2, containLabel: true },
xAxis: {
type: "value",
minInterval: 1,
axisLabel: { color: AXIS_COLOR, fontSize: 11 },
splitLine: { lineStyle: { color: GRID_COLOR } },
},
yAxis: {
type: "category",
data: items.map((i) => i.stage),
inverse: true, // 首项(待启动)排在最上方,保持工序先后顺序
axisLine: { lineStyle: { color: "#d1d5db" } },
axisTick: { show: false },
axisLabel: { color: TEXT_COLOR, fontSize: 13 },
},
series: [{
type: "bar",
barMaxWidth: 26,
data: items.map((i) => i.count),
itemStyle: {
// 回调按生命周期阶段区分生产段 / 售后段
color: (p: any) => (items[p.dataIndex]?.phase === "AFTER_SALES"
? COLOR_AFTER_SALES
: COLOR_PRODUCTION),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: "right",
color: TEXT_COLOR,
fontSize: 12,
fontWeight: "bold",
},
}],
};
}, [dist]);
// 仅展示本月确实有操作的人员 —— 0 接收 / 0 转交 / 0 备注 一律隐藏
const activeUsers = (users?.items ?? []).filter((u) => u.total > 0);
const maxTotal = activeUsers.length ? Math.max(...activeUsers.map((u) => u.total)) : 1;
// 明细抽屉的时间范围取「本月」,与顶部卡片口径一致
const monthSince = metrics
? new Date(`${metrics.month_start}T00:00:00+08:00`).toISOString()
: undefined;
function openUserDetail(u: ActiveUser, actionType: string, label: string) {
opDetailRef.current?.open({
userId: u.user_id,
userName: u.user_name,
actionType,
label,
since: monthSince,
});
}
/** 整行点击:默认展开该人首个有记录的操作类型(与行内数字按钮同源) */
function openUserRow(u: ActiveUser) {
if (u.receive_count > 0) return openUserDetail(u, "receive", "接收");
if (u.transfer_count > 0) return openUserDetail(u, "transfer", "转交");
return openUserDetail(u, "record", "上传备注");
}
return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-gray-50 text-gray-800">
{/* ── 顶栏 ── */}
<header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-2.5">
<div className="flex items-center gap-3">
<span className="h-5 w-1.5 rounded-full bg-blue-600" />
<h1 className="text-lg font-bold tracking-wide text-gray-800">
生产流转 · 管理层数据大屏
</h1>
{metrics?.month && (
<span className="rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
{metrics.month} 当月
</span>
)}
{error && (
<span className="rounded bg-red-50 px-2 py-0.5 text-xs text-red-600">{error}</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="tabular-nums">最后更新 {updatedAt || "—"}</span>
<button
onClick={load}
disabled={refreshing}
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
刷新
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600"
>
{isFullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
{isFullscreen ? "退出全屏" : "全屏"}
</button>
<button
onClick={() => navigate("/admin/dashboard")}
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600"
>
<ArrowLeft className="h-3.5 w-3.5" />
返回后台
</button>
</div>
</header>
<main className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{/* ── 顶部一排:4 张当月指标卡(点击新标签页下钻任务全景) ── */}
<div className="grid shrink-0 grid-cols-4 gap-4">
<MetricCard
label="本月生产流转"
value={metrics?.month_production ?? 0}
tone="text-cyan-600"
hint="本月有流转或新建的生产态设备"
onClick={() => openDrill("/admin/tasks?status=WIP")}
/>
<MetricCard
label="本月已入库"
value={metrics?.month_inbound ?? 0}
tone="text-sky-600"
hint="扫码入库完成"
/* 直接点亮任务页原生的「已入库」Tab */
onClick={() => openDrill("/admin/tasks?status=ARCHIVED")}
/>
<MetricCard
label="本月已出库"
value={metrics?.month_outbound ?? 0}
tone="text-green-600"
hint="扫码出库发货"
onClick={() => openDrill("/admin/tasks?status=OUTBOUND")}
/>
<MetricCard
label="本月返厂回流"
value={metrics?.month_returned ?? 0}
tone="text-orange-600"
hint="本月转入售后阶段"
/* 直接点亮原生的「售后流转中」Tab */
onClick={() => openDrill("/admin/tasks?status=AFTER_SALES_WIP")}
/>
</div>
{/* ── 主区:左积压分布 + 右活跃度 ── */}
<div className="grid min-h-0 flex-1 grid-cols-12 gap-4">
{/* 左·主图区 */}
<section className="col-span-7 flex min-h-0 flex-col rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<PanelTitle
extra={
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5 text-gray-500">
<span className="h-2 w-2 rounded-sm" style={{ background: COLOR_PRODUCTION }} />
生产段
</span>
<span className="flex items-center gap-1.5 text-gray-500">
<span className="h-2 w-2 rounded-sm" style={{ background: COLOR_AFTER_SALES }} />
售后段
</span>
<span className="text-gray-500">
未完结合计 <b className="text-cyan-600">{dist?.total ?? 0}</b> 台
</span>
</div>
}
>
车间工序积压分布
</PanelTitle>
<div className="min-h-0 flex-1">
<BaseEChart
option={distOption}
height="100%"
onEvents={{ click: handleBarClick }}
/>
</div>
</section>
{/* 右·侧边区:系统活跃度排行(主体,撑满右侧高度) */}
<div className="col-span-5 flex min-h-0 flex-col">
<section className="flex min-h-0 flex-1 flex-col rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<PanelTitle
extra={
<span className="text-xs text-gray-400">
本月活跃 {activeUsers.length} 人 · 点击查看明细
</span>
}
>
系统活跃度排行
</PanelTitle>
<div className="min-h-0 flex-1 overflow-hidden">
{activeUsers.length > 0 ? (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400">
<th className="pb-1.5 text-left font-normal">姓名</th>
<th className="w-10 pb-1.5 text-right font-normal">接收</th>
<th className="w-10 pb-1.5 text-right font-normal">转交</th>
<th className="w-10 pb-1.5 text-right font-normal">备注</th>
<th className="w-12 pb-1.5 text-right font-normal">合计</th>
</tr>
</thead>
<tbody>
{activeUsers.map((u) => (
<tr
key={u.user_id}
title="点击查看该人员的操作明细"
onClick={() => openUserRow(u)}
className="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50"
>
<td className="py-1.5 pr-2">
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 truncate text-gray-700">
{u.user_name}
</span>
{/* 胶囊条:相对活跃度 */}
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-100">
<div
className="h-1 rounded-full bg-blue-500"
style={{ width: `${Math.max((u.total / maxTotal) * 100, 6)}%` }}
/>
</div>
</div>
</td>
{/* 与「全局概览 · 人员操作统计」一致:点数字看对应类型的明细 */}
{([
["receive", u.receive_count],
["transfer", u.transfer_count],
["record", u.record_count],
] as const).map(([type, cnt]) => (
<td key={type} className="py-1.5 text-right">
<button
onClick={(e) => {
e.stopPropagation(); // 不冒泡触发整行的默认下钻
openUserDetail(u, type, OP_LABELS[type]);
}}
className="cursor-pointer tabular-nums text-gray-500 transition-colors hover:text-blue-600 hover:underline"
>
{cnt}
</button>
</td>
))}
<td className="py-1.5 text-right font-bold tabular-nums text-blue-600">{u.total}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
{users ? "本月暂无操作记录" : "加载中…"}
</div>
)}
</div>
</section>
</div>
</div>
</main>
{/* 操作明细抽屉 — 与「全局概览」共用同一组件(亮色主题,与系统一致) */}
<UserOperationDetailDrawer ref={opDetailRef} />
</div>
);
}
// ============================================================
// 局部小组件
// ============================================================
function PanelTitle({ children, extra }: { children: ReactNode; extra?: ReactNode }) {
return (
<div className="mb-2 flex shrink-0 items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-gray-700">
<span className="h-3.5 w-1 rounded-full bg-blue-600" />
{children}
</h2>
{extra}
</div>
);
}
function MetricCard({
label, value, tone, hint, onClick,
}: {
label: string;
value: number;
tone: string;
hint: string;
onClick?: () => void;
}) {
return (
<div
onClick={onClick}
role={onClick ? "button" : undefined}
className={`rounded-xl border border-gray-200 bg-white px-5 py-3 shadow-sm transition-colors ${
onClick ? "cursor-pointer hover:border-blue-400 hover:bg-blue-50/40" : ""
}`}
>
<p className="text-xs text-gray-500">{label}</p>
<div className="mt-0.5 flex items-baseline gap-1.5">
<span className={`text-4xl font-bold tabular-nums ${tone}`}>{value}</span>
<span className="text-xs text-gray-400">台</span>
</div>
<p className="mt-0.5 text-[11px] text-gray-400">{hint}</p>
{onClick && <p className="mt-1 text-[10px] text-blue-500">点击下钻(新标签页)→</p>}
</div>
);
}

View File

@ -1,5 +1,6 @@
/** 认证 API — 登录、刷新 Token、获取用户信息 */
import type { UserInfo } from "../contexts/AuthContext";
import { extractErrorMessage } from "../utils/errorMessage";
const API_BASE = import.meta.env.VITE_API_BASE_URL;
@ -23,7 +24,7 @@ export async function login(username: string, password: string): Promise<LoginRe
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any).detail ?? "登录失败");
throw new Error(extractErrorMessage(err, "登录失败"));
}
return res.json();
}

View File

@ -1,4 +1,5 @@
import api from "./api";
import type { LifecyclePhase } from "../types/api";
export interface DashboardStats {
products_total: number;
@ -86,6 +87,8 @@ export interface WipMatrixDetailRow {
material_name: string;
spec_model: string;
task_status: string;
/** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
lifecycle_phase?: LifecyclePhase;
assignee_id: string;
assignee: string;
duration_hours: number;
@ -161,7 +164,8 @@ export async function fetchDashboardStats(since?: string, until?: string): Promi
return data;
}
export async function fetchWipTasks(limit = 20): Promise<WipTask[]> {
/** 在制品任务。limit 只是防御性上限,默认值足以覆盖全量 —— 不要拿它做分页截断。 */
export async function fetchWipTasks(limit = 500): Promise<WipTask[]> {
const { data } = await api.get<WipTask[]>("/dashboard/wip-tasks", { params: { limit } });
return data;
}

View File

@ -0,0 +1,62 @@
/** 管理层数据大屏 — 前后端数据契约类型 + API
* 对应后端 app/services/screen_service.py 的 Pydantic Schema
*/
import api from "./api";
// ─── 当月吞吐(顶部数字卡) ────────────────────────────────
export interface MonthlyMetrics {
month: string; // "2026-09"
month_start: string; // "2026-09-01"(北京时间)
month_production: number; // 本月有流转记录或新建的生产态设备数
month_inbound: number; // 本月扫码入库数
month_outbound: number; // 本月扫码出库数
month_returned: number; // 本月进入售后/回流状态的设备数
}
// ─── 工序积压分布(柱状图) ────────────────────────────────
export interface WipStage {
stage: string;
phase: "PRODUCTION" | "AFTER_SALES";
count: number;
}
export interface WipDistributionResponse {
total: number;
items: WipStage[];
}
// ─── 系统使用活跃度 ────────────────────────────────────────
export interface ActiveUser {
user_id: string;
user_name: string;
receive_count: number;
transfer_count: number;
record_count: number;
total: number;
}
export interface ActiveUsersResponse {
month: string;
items: ActiveUser[];
}
// ============================================================
// API 方法
// ============================================================
export async function fetchMonthlyMetrics(): Promise<MonthlyMetrics> {
const { data } = await api.get<MonthlyMetrics>("/screen/monthly-metrics");
return data;
}
export async function fetchWipDistribution(): Promise<WipDistributionResponse> {
const { data } = await api.get<WipDistributionResponse>("/screen/wip-distribution");
return data;
}
export async function fetchActiveUsers(topN = 5): Promise<ActiveUsersResponse> {
const { data } = await api.get<ActiveUsersResponse>("/screen/active-users", {
params: { top_n: topN },
});
return data;
}

View File

@ -96,14 +96,17 @@ export async function receiveTask(taskId: string): Promise<TaskResponse> {
/**
* 品质驳回 — → REJECTED,后端自动创建返工任务。
* 对应后端 POST /api/v1/tasks/{taskId}/reject
*
* images 为选填,支持空数组(编号错误、选错工序等场景无需拍照举证)。
*/
export async function rejectTask(
taskId: string,
reason: string
reason: string,
images: string[]
): Promise<TaskResponse> {
const { data } = await api.post<TaskResponse>(
`/tasks/${taskId}/reject`,
{ reason } satisfies TaskRejectPayload
{ reason, images } satisfies TaskRejectPayload
);
return data;
}
@ -111,20 +114,25 @@ export async function rejectTask(
/**
* 完工裂变转交 — → COMPLETED,批量创建下家任务。
* 对应后端 POST /api/v1/tasks/{taskId}/transfer
*
* finishDirectly=true 时走「直接完结」通道:next_tasks 留空 + finish_directly=true,
* 后端据此跳过分支解析,只闭环任务【不产生下游任务】、且不改产品宏观状态
* (不会把产品误标成「待仓库收货」)。用于售后返厂直接发走 / 半成品被提走等无需入库的收官场景。
*/
export async function transferTask(
taskId: string,
nextAssignees: string[],
nextTaskName: string,
note?: string
note?: string,
finishDirectly = false
): Promise<TaskTransferResponse> {
const payload: TaskTransferPayload = finishDirectly
? { next_tasks: [], finish_directly: true, note }
: { next_assignees: nextAssignees, next_task_name: nextTaskName, note };
const { data } = await api.post<TaskTransferResponse>(
`/tasks/${taskId}/transfer`,
{
next_assignees: nextAssignees,
next_task_name: nextTaskName,
note,
} satisfies TaskTransferPayload
payload
);
return data;
}

View File

@ -1,4 +1,5 @@
/** 管理端 API 响应类型 */
import type { LifecyclePhase } from "./api";
export interface ProductResponse {
id: string;
@ -16,7 +17,17 @@ export interface ProductResponse {
current_location_name: string | null;
macro_status: string | null;
overall_status: string | null;
/**
* 【当前工序】— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
* · 有活跃主干任务(WIP/PENDING) → 该工序名
* · 否则处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态
* · 否则(活已干完)→ ""(列表显示「—」)
* ⚠️ 不要用 overall_status 渲染该列:它会被"最新主干任务名"覆盖,含已 COMPLETED 的。
*/
current_step?: string;
status: string;
/** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
lifecycle_phase?: LifecyclePhase;
created_at: string;
latest_record_time: string | null;
latest_record_content: string | null;

View File

@ -1,5 +1,11 @@
/** 后端 API 响应类型 */
// ============================================================
// 生命周期阶段 — 区分「生产阶段的测试(发货测试)」与「出库后返厂(售后维修)」
// ============================================================
export type LifecyclePhase = "PRODUCTION" | "AFTER_SALES";
// ============================================================
// 任务状态常量
// ============================================================
@ -75,11 +81,28 @@ export interface TaskListResponse {
export interface TaskRejectPayload {
reason: string;
/** 异常图片 URL 列表(选填,支持空数组,最多 9 张)
* 对应后端 TaskRejectRequest.images —— 缺失或空数组均合法 */
images: string[];
}
/** 裂变分支 — assignees 允许为空数组,但【仅当】finish_directly=true 时合法;
* 否则后端 TaskTransferRequest 校验会直接 422 拒绝(防「转交」静默退化成「直接完结」的丢件隐患)。 */
export interface TaskTransferBranch {
task_name: string;
assignees: string[];
}
export interface TaskTransferPayload {
next_assignees: string[];
next_task_name: string;
/** [旧版] 下一道工序接收人列表 — 与 next_tasks 二选一 */
next_assignees?: string[];
/** [旧版] 下一道工序名称 — 与 next_tasks 二选一 */
next_task_name?: string;
/** [新版] 多分支任务列表 */
next_tasks?: TaskTransferBranch[];
/** 🏁 直接完结:闭环当前任务但【不产生任何下游任务】,产品 overall_status 保持原样
* (不入库 → 不会变成「待仓库收货」)。置 true 时后端忽略 next_tasks / next_assignees。 */
finish_directly?: boolean;
note?: string;
}
@ -93,10 +116,16 @@ export interface ProductScanResponse {
order_id: string;
order_no: string;
material_id: string | null;
/** 后端 schemas/product.py:83 返回,卡片展示用 */
material_name: string | null;
/** 后端 schemas/product.py:84 返回,卡片展示用 */
spec_model: string | null;
parent_product_id: string | null;
current_location_id: string | null;
status: string;
overall_status: string | null;
/** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
lifecycle_phase?: LifecyclePhase;
created_at: string;
top_level_tasks: TaskSummary[];
/** 完整递归任务树 — 供十字矩阵树状图渲染 */

View File

@ -0,0 +1,71 @@
/**
* 错误文案提取 — 把 FastAPI 的各类 detail 统一转成可读中文。
*
* 背景:各处 catch 里直接 `toast(err?.response?.data?.detail ?? "失败")` 是有坑的 ——
* 请求体校验失败(422)时 detail 是一个数组,React 拿到数组会把里面每个对象渲染成
* "[object Object]",用户看不懂,排查也无从下手。
*
* 这里对齐移动端 track-uniapp/src/utils/request.js 的 extractErrorDetail 行为,
* 覆盖三种形态:
* 1. 字符串: {"detail": "任务状态为 COMPLETED,无法驳回"}
* 2. 校验错误数组(422): {"detail": [{"loc": ["body","images"], "msg": "Value error, ..."}]}
* 3. 对象: {"detail": {"msg": "..."}}
* 解析不出内容时回落到调用方给的 fallback,绝不把原始对象丢给 UI。
*/
/** loc 里这些是 FastAPI 的位置前缀,不是业务字段名,展示时去掉 */
const LOC_PREFIXES = new Set(["body", "query", "path", "header", "cookie"]);
/** 校验错误数组 → "字段: 原因;字段2: 原因2" */
function flattenValidationErrors(items: unknown[]): string {
const messages = items
.map((item) => {
if (typeof item === "string") return item;
if (!item || typeof item !== "object") return "";
const { msg, message, loc } = item as {
msg?: unknown;
message?: unknown;
loc?: unknown;
};
// Pydantic v2 自定义校验器抛的错带 "Value error, " 前缀,纯噪音,去掉
const text = String(msg ?? message ?? "").replace(/^Value error,\s*/, "");
const field = Array.isArray(loc)
? loc.filter((seg) => !LOC_PREFIXES.has(String(seg))).join(".")
: "";
return field && text ? `${field}: ${text}` : text;
})
.filter(Boolean);
// 同一字段可能有多条规则失败,去重后再拼,避免「images: x;images: x」
return Array.from(new Set(messages)).join(";");
}
/**
* 从任意抛出物里取出可读的中文报错。
* @param err catch 到的任意值(axios 错误、被抛出的裸 JSON、Error、字符串皆可)
* @param fallback 解析不出任何内容时的兜底文案
*/
export function extractErrorMessage(err: unknown, fallback = "操作失败"): string {
const responseData = (err as { response?: { data?: unknown } })?.response?.data;
// detail 优先取 axios 的 response.data.detail,其次兼容被直接抛出的裸对象/裸 JSON
const detail =
(responseData as { detail?: unknown } | undefined)?.detail ??
(err as { detail?: unknown })?.detail;
if (typeof detail === "string" && detail.trim()) return detail.trim();
if (Array.isArray(detail)) {
const flattened = flattenValidationErrors(detail);
if (flattened) return flattened;
}
if (detail && typeof detail === "object") {
const obj = detail as { msg?: unknown; message?: unknown };
const text = String(obj.msg ?? obj.message ?? "").trim();
if (text) return text;
}
// 后端直接返回字符串 body 的情况
if (typeof responseData === "string" && responseData.trim()) {
return responseData.trim();
}
// 兜底沿用 axios / fetch 的原生 message(如 "Request failed with status code 500")
if (err instanceof Error && err.message) return err.message;
return fallback;
}

View File

@ -1,23 +0,0 @@
<script>
export default {
onLaunch() {
console.log("生产流转 T1.0.0 启动");
// 无 Token 跳转登录页
const token = uni.getStorageSync("token");
if (!token) {
uni.reLaunch({ url: "/pages/login/login" });
}
},
onShow() {},
onHide() {},
};
</script>
<style>
page {
background-color: #f3f4f6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: #1f2937;
}
</style>

View File

@ -1,7 +0,0 @@
import App from "./App.vue";
import { createSSRApp } from "vue";
export function createApp() {
const app = createSSRApp(App);
return { app };
}

View File

@ -1,43 +0,0 @@
{
"name": "Track",
"appid": "__UNI__B572616",
"description": "Track - 生产流转管理",
"versionName": "T1.0.0",
"versionCode": "100",
"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": {},
"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"]
}
},
"h5": {
"router": {
"mode": "hash",
"base": ""
},
"title": "生产流转"
}
}

View File

@ -1,73 +0,0 @@
{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/scan/index",
"style": {
"navigationBarTitleText": "扫码干活",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/tasks/index",
"style": {
"navigationBarTitleText": "我的任务",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/notify/index",
"style": {
"navigationBarTitleText": "消息通知",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "个人中心",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "生产流转",
"navigationBarBackgroundColor": "#2563EB",
"backgroundColor": "#F3F4F6"
},
"tabBar": {
"color": "#9CA3AF",
"selectedColor": "#2563EB",
"backgroundColor": "#FFFFFF",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/scan/index",
"text": "扫码干活"
},
{
"pagePath": "pages/tasks/index",
"text": "我的任务"
},
{
"pagePath": "pages/notify/index",
"text": "消息"
},
{
"pagePath": "pages/profile/index",
"text": "我的"
}
]
}
}

View File

@ -1,68 +0,0 @@
<template>
<view class="page">
<view class="header">
<text class="logo">🏭</text>
<text class="title">Track</text>
<text class="version">T1.0.0</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 } from "vue";
import { post } from "../../utils/request";
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("token", res.access_token);
uni.setStorageSync("user", JSON.stringify(res.user));
uni.showToast({ title: "登录成功", icon: "success" });
// 跳转到扫码页
setTimeout(() => {
uni.switchTab({ url: "/pages/scan/index" });
}, 500);
} catch {
// request.js 已弹 toast
} 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

@ -1,25 +0,0 @@
<template>
<view class="page">
<view class="header">
<text class="title">消息通知</text>
<text class="subtitle">任务流转和系统通知</text>
</view>
<view class="empty">
<text class="empty-icon">🔔</text>
<text class="empty-text">暂无新消息</text>
</view>
</view>
</template>
<script setup>
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
.header { margin-bottom: 24px; }
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
.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; }
</style>

View File

@ -1,48 +0,0 @@
<template>
<view class="page">
<view class="user-card">
<view class="avatar">{{ initial }}</view>
<view class="user-info">
<text class="user-name">{{ user?.display_name || '未登录' }}</text>
<text class="user-role">{{ user?.role === 'admin' ? '管理员' : '操作员' }}</text>
</view>
</view>
<view class="menu-card">
<view v-for="item in ['工作统计', '设置', '帮助与反馈', '关于']" :key="item" class="menu-item">
<text class="menu-text">{{ item }}</text>
</view>
</view>
<button class="logout-btn" @tap="handleLogout">退出登录</button>
<view class="version">T1.0.0</view>
</view>
</template>
<script setup>
import { ref, computed } 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]);
function handleLogout() {
uni.removeStorageSync("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-name { font-size: 16px; font-weight: 700; color: #1f2937; display: block; }
.user-role { font-size: 12px; color: #9ca3af; }
.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; }
.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

@ -1,162 +0,0 @@
<template>
<view class="page">
<!-- ======== 扫码按钮 ======== -->
<view class="scan-area">
<view class="scan-btn" @tap="handleScanCode">
<text class="scan-icon">📷</text>
<text class="scan-text">点击扫码</text>
<text class="scan-hint">调用原生摄像头扫描二维码/条码</text>
</view>
<!-- 手动输入 -->
<view class="manual-input">
<input
v-model="serialNumber"
class="input"
type="text"
maxlength="16"
placeholder="手动输入16位序列号"
@confirm="handleManualSearch"
/>
<button class="search-btn" @tap="handleManualSearch" :disabled="loading">
{{ loading ? '查询中' : '查询' }}
</button>
</view>
</view>
<!-- ======== 最近扫描 ======== -->
<view v-if="lastScanned" class="last-scan">
最近扫描: <text class="sn-text">{{ lastScanned }}</text>
</view>
<!-- ======== 加载中 / 错误 / 结果 ======== -->
<view v-if="loading" class="loading">查询中...</view>
<view v-if="error && !loading" class="error-box">{{ error }}</view>
<view v-if="product && !loading" class="result">
<view class="card">
<view class="card-header">
<text class="card-title">📦 产品信息</text>
<text :class="['status', statusClass(product.status)]">{{ statusLabel(product.status) }}</text>
</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.order_no }}</text></view>
<view v-if="product.material_id" class="info-item"><text class="label">物料ID</text><text class="value">{{ product.material_id }}</text></view>
</view>
</view>
<view class="card">
<view class="card-header">
<text class="card-title">📋 当前进度</text>
<text class="task-count">{{ product.top_level_tasks.length }} 个任务</text>
</view>
<view v-if="product.top_level_tasks.length === 0" class="empty-tasks">暂无关联任务</view>
<view v-for="task in product.top_level_tasks" :key="task.id" class="task-item">
<view class="task-info">
<text class="task-name">{{ task.task_name }}</text>
<text class="task-assignee">负责人: {{ task.assignee_id || '未分配' }}</text>
</view>
<text :class="['status-sm', statusClass(task.status)]">{{ statusLabel(task.status) }}</text>
</view>
</view>
</view>
<view v-if="!product && !loading && !error" class="empty">
<text class="empty-icon">📱</text>
<text class="empty-text">扫码或手动输入序列号查询产品进度</text>
</view>
<view class="version">T1.0.0</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { get } from "../../utils/request";
const serialNumber = ref("");
const lastScanned = ref("");
const loading = ref(false);
const error = ref("");
const product = ref(null);
const STATUS_MAP = { pending: "待处理", in_progress: "进行中", completed: "已完成", cancelled: "已取消" };
function statusLabel(s) { return STATUS_MAP[s] || s; }
function statusClass(s) {
switch (s) {
case "pending": return "status-yellow";
case "in_progress": return "status-blue";
case "completed": return "status-green";
default: return "status-gray";
}
}
async function doQuery(sn) {
if (!sn || sn.length < 8) { error.value = "序列号至少需要 8 位"; return; }
serialNumber.value = sn;
lastScanned.value = sn;
loading.value = true;
error.value = "";
product.value = null;
try { product.value = await get(`/products/scan/${sn}`); }
catch { /* request.js 已弹 toast */ }
finally { loading.value = false; }
}
function handleScanCode() {
uni.scanCode({
onlyFromCamera: true,
scanType: ["qrCode", "barCode"],
success(res) {
const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
doQuery(sn);
},
fail(err) {
if (err.errMsg && err.errMsg.includes("cancel")) return;
uni.showToast({ title: "扫码失败,请重试", icon: "none" });
},
});
}
function handleManualSearch() { doQuery(serialNumber.value.trim()); }
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
.scan-btn { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 180px; background: linear-gradient(135deg, #2563EB, #3B82F6); border-radius: 16px; color: #fff; box-shadow: 0 4px 16px rgba(37,99,235,0.3); }
.scan-icon { font-size: 44px; margin-bottom: 6px; }
.scan-text { font-size: 18px; font-weight: 700; }
.scan-hint { font-size: 12px; opacity: 0.8; margin-top: 4px; }
.manual-input { display: flex; gap: 8px; margin-top: 12px; }
.input { flex: 1; height: 44px; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 14px; background: #fff; }
.search-btn { height: 44px; padding: 0 20px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; line-height: 44px; }
.search-btn[disabled] { opacity: 0.6; }
.last-scan { font-size: 12px; color: #9ca3af; margin: 12px 0; }
.sn-text { font-family: monospace; color: #4b5563; }
.loading { text-align: center; padding: 32px 0; color: #6b7280; }
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-title { font-size: 15px; font-weight: 700; }
.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; }
.sn { font-family: monospace; }
.status { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
.status-sm { font-size: 11px; padding: 2px 8px; border-radius: 20px; font-weight: 600; flex-shrink: 0; }
.status-yellow { background: #fef3c7; color: #b45309; }
.status-blue { background: #dbeafe; color: #1d4ed8; }
.status-green { background: #dcfce7; color: #15803d; }
.status-gray { background: #f3f4f6; color: #6b7280; }
.task-count { font-size: 12px; color: #9ca3af; }
.empty-tasks { text-align: center; padding: 24px 0; color: #9ca3af; font-size: 13px; }
.task-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-top: 1px solid #f3f4f6; }
.task-info { flex: 1; min-width: 0; }
.task-name { font-size: 14px; font-weight: 600; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-assignee { font-size: 12px; color: #9ca3af; }
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 60px; color: #9ca3af; }
.empty-icon { font-size: 64px; margin-bottom: 12px; }
.empty-text { font-size: 14px; }
.version { text-align: center; font-size: 11px; color: #d1d5db; padding: 16px 0; }
</style>

View File

@ -1,25 +0,0 @@
<template>
<view class="page">
<view class="header">
<text class="title">我的任务</text>
<text class="subtitle">待处理和进行中的任务</text>
</view>
<view class="empty">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无待办任务</text>
</view>
</view>
</template>
<script setup>
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
.header { margin-bottom: 24px; }
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
.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; }
</style>

View File

@ -1,5 +1,6 @@
<script>
import { getNotifications } from "./api/notification";
import { checkAppUpdate } from "./utils/ota";
export default {
onLaunch() {
@ -14,9 +15,9 @@ export default {
}
// 已登录 → 原地渲染 scan 页,不需任何跳转
// 🚀 OTA 热更新检测(仅 App 端生效)
// 🚀 OTA 热更新检测(仅 App 端生效;实现见 utils/ota.js,设置页手动检查共用同一份)
// #ifdef APP-PLUS
this.checkUpdate();
checkAppUpdate();
// #endif
},
onShow() {
@ -24,121 +25,13 @@ export default {
this.updateTabBarBadge();
// 🚀 每次回到前台也检查更新,用户无需杀后台就能感知新版本
// #ifdef APP-PLUS
this.checkUpdate();
checkAppUpdate();
// #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;
// 🚀 静默更新:无进度条、无 toast、不监听 onProgressUpdate
uni.downloadFile({
url: wgtUrl,
success: (downloadRes) => {
if (downloadRes.statusCode !== 200) {
console.error("[OTA] 下载失败, statusCode:", downloadRes.statusCode);
return;
}
plus.runtime.install(
downloadRes.tempFilePath,
{ force: true },
() => {
console.log("[OTA] 安装成功,3秒后自动重启");
setTimeout(() => {
plus.runtime.restart();
}, 3000);
},
(err) => {
console.error("[OTA] 安装失败:", err.message);
}
);
},
fail: (err) => {
console.error("[OTA] 下载失败:", err.errMsg);
},
});
},
});
},
// ==========================================================
// TabBar 消息红点
// ==========================================================
@ -177,4 +70,56 @@ page {
font-size: 14px;
color: #1f2937;
}
/* ============================================================
上传成功角标 —— 图片缩略图右上角「绿底白勾」
============================================================
用法:给图片的【直接父容器】加 .success-badge-wrapper,
容器内放 <image> 和一个空的 <view class="success-badge" />,
后者仅在确认该图已上传成功时才渲染。
⚠️ 容器只能包住图片本身,不要连删除按钮一起包 —— overflow:hidden 会把
定位在单元格外的 ✕ 按钮一并裁掉(各页面的 .img-del 都在 -12rpx 处)。
============================================================ */
.success-badge-wrapper {
position: relative;
overflow: hidden;
border-radius: 12rpx; /* 与缩略图圆角一致,负责裁剪贴边的角标 */
}
/* 直角三角形本体:border-top 实色 + border-left 透明,直角落在右上角。
注意别写成 border-top + border-right —— 那样得到的直角在左上,方向是反的。 */
.success-badge {
position: absolute;
top: 0;
right: 0;
width: 40rpx;
height: 40rpx;
z-index: 2;
pointer-events: none; /* 不遮挡图片本身的点击(预览) */
}
.success-badge::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-top: 40rpx solid #67c23a;
border-left: 40rpx solid transparent;
}
/* 白色对勾:用两条边框旋转 -45° 画出来,不依赖字体。
直接用 "✓" 字符的话,安卓与 iOS 的字形和基线差异很大,对不齐。 */
.success-badge::after {
content: "";
position: absolute;
top: 9rpx;
right: 7rpx;
width: 12rpx;
height: 7rpx;
border-left: 3rpx solid #ffffff;
border-bottom: 3rpx solid #ffffff;
transform: rotate(-45deg);
}
</style>

View File

@ -0,0 +1,37 @@
/**
* 帮助与反馈 API。
*
* ⚠️ 后端目前【没有】feedback 接口(已核对 openapi 路由表)。这里不直接写死
* 一个假函数,而是「先真后假」:先按最终契约 POST /feedback/,只有拿到
* 404(端点尚未实现)才回退到本地 mock 成功。这样等后端补上接口后,前端
* 一行都不用改就能自动接上真实链路;而 400/422/断网等真实错误仍会照常
* 抛出,不会被 mock 悄悄吞掉 —— 否则工人会以为反馈提交成功了,其实没有。
*/
import request from "../utils/request";
/** 反馈问题类型(与页面选择项一一对应) */
export const FEEDBACK_TYPES = [
{ value: "system", label: "系统问题" },
{ value: "device", label: "设备故障" },
{ value: "other", label: "其他" },
];
/**
* 提交一条反馈。
* @param {{type: string, content: string, images?: string[], user_id?: string}} payload
* @returns {Promise<{id?: string, mocked?: boolean}>}
*/
export async function submitFeedback(payload) {
try {
// silent:404 会走到下面的 mock 分支,不能先弹一句「请求失败 (404)」再弹「感谢反馈」
return await request({ url: "/feedback/", method: "POST", data: payload, silent: true });
} catch (e) {
// 只认「端点不存在」这一种情况回退 mock;其余错误(校验失败/网络断)
// 必须原样抛给页面去提示,不能让用户误以为提交成功
if (e && e.statusCode === 404) {
console.warn("[feedback] 后端暂无 /feedback/ 接口,回退本地 mock");
return { id: null, mocked: true };
}
throw e;
}
}

View File

@ -0,0 +1,27 @@
/**
* 个人效能统计 API — 「个人中心 → 工作统计」数据源。
*
* 后端 /dashboard/* 系列是「上帝视角」(全厂数据、不按人过滤),且
* people-history 只返回 WIP/PENDING/COMPLETED、rejected-tasks 压根没有
* assignee_id 参数 —— 一线工人要看的「我的今日被驳回数」在这两个接口里
* 都拿不到。故后端补了一个薄桥接端点 /dashboard/my-stats,沿用同一套统计
* 口径,但强制按 assignee_id 过滤到本人。
*/
import { get } from "../utils/request";
/**
* 获取当前登录用户的个人效能统计(支持自选时段)。
*
* 返回两组指标:
* 生产战绩 — tasks_completed / tasks_rejected / products_touched
* 操作统计 — receive_count / transfer_count / record_count / op_total
* (与 PC「人员操作统计」严格同口径,已逐字段交叉验证)
*
* @param {string} assigneeId - 负责人ID(即登录用户的 username)
* @param {string} [since] - 起始时间 ISO,**必须带时区偏移**(如 2026-09-01T00:00:00+08:00)
* @param {string} [until] - 截止时间 ISO,同上
* @returns {Promise<Object>}
*/
export function getMyStats(assigneeId, since, until) {
return get("/dashboard/my-stats", { assignee_id: assigneeId, since, until });
}

View File

@ -36,7 +36,8 @@
"style": {
"navigationBarTitleText": "我的任务",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
},
{
@ -44,7 +45,8 @@
"style": {
"navigationBarTitleText": "消息通知",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
},
{
@ -54,6 +56,31 @@
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/profile/statistics",
"style": {
"navigationBarTitleText": "工作统计",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
},
{
"path": "pages/profile/settings",
"style": {
"navigationBarTitleText": "设置",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/profile/feedback",
"style": {
"navigationBarTitleText": "帮助与反馈",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
}
],
"globalStyle": {

View File

@ -39,13 +39,18 @@
</view>
<text class="card-arrow">›</text>
</view>
<!-- ⬇️ 触底加载状态:让工人知道"到底了"还是"还在拉" -->
<view v-if="loadingMore" class="load-more">加载中...</view>
<view v-else-if="!hasMore" class="load-more">— 没有更多了 —</view>
<view v-else class="load-more">上拉加载更多</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { onShow, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { getNotifications, markNotificationRead } from "../../api/notification";
const TYPE_CONFIG = {
@ -58,11 +63,17 @@ const notifications = ref([]);
const loading = ref(true);
let currentUser = null;
// 📄 分页状态:此前用默认 limit=20 但翻不动页,第 21 条以后的消息永远看不到
const pageSize = 20;
let page = 1;
const hasMore = ref(true);
const loadingMore = ref(false);
onShow(() => {
loadUser();
setTimeout(() => {
if (!currentUser) loadUser();
fetchNotifications();
fetchNotifications("initial");
}, 200);
});
@ -76,17 +87,69 @@ function loadUser() {
} catch {}
}
async function fetchNotifications() {
const userId = currentUser?.username || currentUser?.id || "";
if (!userId) { loading.value = false; return; }
loading.value = true;
// 🚀 下拉刷新:界面提示了「请下拉刷新重试」,就必须把功能做实。
// 分页后下拉必须重置 page 并清空列表 —— 否则新的第 1 页会和旧的第 2、3 页
// 混在一起,出现重复项与排序错乱。
onPullDownRefresh(async () => {
try {
const res = await getNotifications(userId);
notifications.value = res.notifications || [];
} catch {
notifications.value = [];
loadUser();
await fetchNotifications("refresh");
} finally {
loading.value = false;
uni.stopPullDownRefresh(); // 无论成败都要收起动画,否则会一直挂着
}
});
// 🚀 触底加载下一页
onReachBottom(() => {
if (loading.value || loadingMore.value || !hasMore.value) return;
loadingMore.value = true;
fetchNotifications("more").finally(() => { loadingMore.value = false; });
});
/**
* 拉取通知
* @param {'initial'|'refresh'|'more'} mode
* initial — 首次进入/切页:显示整页 loading,失败清空
* refresh — 下拉刷新:重置到第 1 页并清空重载
* more — 触底加载:追加下一页,失败保留已加载内容只提示
*/
async function fetchNotifications(mode = "initial") {
const userId = currentUser?.username || currentUser?.id || "";
if (!userId) {
if (mode === "initial") loading.value = false;
else uni.showToast({ title: "未获取到登录信息,请重新登录", icon: "none", duration: 2500 });
return;
}
// initial / refresh 都要从第 1 页重来:前者是切页回来,后者是下拉刷新。
// 若 initial 沿用旧的 page,onShow 只会去拉"第 N 页"并追加,列表永远刷不新。
if (mode === "initial" || mode === "refresh") {
page = 1;
hasMore.value = true;
notifications.value = [];
}
if (mode === "initial") loading.value = true;
const target = page;
try {
const res = await getNotifications(userId, (target - 1) * pageSize, pageSize);
const batch = res.notifications || [];
// 按 id 去重后追加:翻页期间若来了新消息,分页边界会错位导致重复项
const seen = new Set(notifications.value.map((n) => n.id));
notifications.value = notifications.value.concat(batch.filter((n) => !seen.has(n.id)));
// 后端给了 total 就以它为准;否则退回「本页是否满员」判断
hasMore.value = res.total != null
? notifications.value.length < res.total
: batch.length >= pageSize;
if (hasMore.value) page = target + 1;
} catch (e) {
console.error("[notify] 拉取消息失败:", e);
if (mode === "initial") notifications.value = [];
else uni.showToast({
title: mode === "more" ? "加载更多失败,请重试" : "刷新失败,请稍后重试",
icon: "none",
duration: 2500,
});
} finally {
if (mode === "initial") loading.value = false;
}
}
@ -165,4 +228,7 @@ async function handleCardTap(item) {
.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; }
/* 触底加载状态 */
.load-more { text-align: center; font-size: 12px; color: #9ca3af; padding: 16px 0 4px; }
</style>

View File

@ -0,0 +1,233 @@
<template>
<view class="page">
<!-- 问题类型 -->
<view class="section">
<text class="section-title">问题类型</text>
<view class="type-row">
<view
v-for="t in FEEDBACK_TYPES"
:key="t.value"
:class="['type-chip', form.type === t.value ? 'type-chip-active' : '']"
@tap="form.type = t.value"
>{{ t.label }}</view>
</view>
</view>
<!-- 问题描述 -->
<view class="section">
<text class="section-title">详细描述</text>
<view class="textarea-wrap">
<textarea
v-model="form.content"
class="textarea"
placeholder="请描述遇到的问题:在哪个页面、做了什么操作、出现什么结果。描述越清楚,我们修得越快。"
placeholder-class="ta-placeholder"
:maxlength="MAX_LEN"
:show-confirm-bar="false"
auto-height
/>
<text class="counter">{{ form.content.length }}/{{ MAX_LEN }}</text>
</view>
</view>
<!-- 图片上传(复用 utils/upload.js 的有界并发上传;上限 4 张) -->
<view class="section">
<text class="section-title">问题截图<text class="section-sub">(选填,最多 {{ MAX_IMAGES }} 张)</text></text>
<view class="img-grid">
<view v-for="(img, i) in form.images" :key="img + '-' + i" class="img-cell">
<view class="success-badge-wrapper img-frame">
<image :src="imageUrl(img)" mode="aspectFill" class="img" @tap="previewImage(i)" />
<view v-if="isUploaded(img)" class="success-badge" />
</view>
<text class="img-del" @tap.stop="removeImage(i)">✕</text>
</view>
<!-- ⏳ 占位符:每张图成功/失败都会恰好回收一个,避免"以为传好了其实没有" -->
<view v-for="n in pendingCount" :key="'p' + n" class="img-cell img-loading">
<text class="img-loading-text">⏳</text>
</view>
<view
v-if="form.images.length + pendingCount < MAX_IMAGES"
class="img-cell img-add"
@tap="chooseImages"
>
<text class="img-add-icon">📷</text>
<text class="img-add-text">添加图片</text>
</view>
</view>
</view>
<button
class="submit-btn"
:disabled="!canSubmit"
@tap="handleSubmit"
>{{ submitting ? '提交中...' : '提交反馈' }}</button>
<view class="hint">提交后我们会尽快处理。若设备故障影响生产,请同时通知当班主管。</view>
</view>
</template>
<script setup>
import { ref, computed, reactive } from "vue";
import { FEEDBACK_TYPES, submitFeedback } from "../../api/feedback";
import { uploadImages, isUploadedUrl } from "../../utils/upload";
import { getBaseUrl } from "../../utils/request";
const MAX_IMAGES = 4;
const MAX_LEN = 500;
const form = reactive({ type: "system", content: "", images: [] });
const pendingCount = ref(0);
const uploading = ref(false);
const submitting = ref(false);
const canSubmit = computed(
() => form.content.trim().length > 0 && !uploading.value && !submitting.value
);
function imageUrl(url) {
if (!url) return "";
if (url.startsWith("http")) return url;
const domain = getBaseUrl().replace(/\/api.*$/, "");
return domain + (url.startsWith("/") ? url : "/" + url);
}
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
function isUploaded(img) {
return isUploadedUrl(img);
}
function previewImage(index) {
uni.previewImage({ urls: form.images.map(imageUrl), current: index });
}
function removeImage(index) {
form.images.splice(index, 1);
}
async function chooseImages() {
const maxSlots = MAX_IMAGES - (form.images.length + pendingCount.value);
if (maxSlots <= 0) return;
const chooseRes = await new Promise((resolve, reject) => {
uni.chooseImage({
count: maxSlots,
sizeType: ["compressed"],
sourceType: ["camera", "album"],
success: resolve,
fail: reject,
});
}).catch(() => null);
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
const compressedPaths = [];
for (const p of chooseRes.tempFilePaths) {
try {
const compressed = await new Promise((resolve, reject) => {
uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject });
});
compressedPaths.push(compressed.tempFilePath);
} catch {}
}
if (!compressedPaths.length) {
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
return;
}
uploading.value = true;
pendingCount.value += compressedPaths.length;
// 🚀 复用 utils/upload.js 的 3 并发上传池(与追加记录 / 驳回弹窗同一份实现)
const { failed, total } = await uploadImages(compressedPaths, (url) => {
if (url) form.images.push(url);
pendingCount.value--;
});
uploading.value = false;
if (failed > 0) {
uni.showToast({
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
icon: "none",
duration: 3000,
});
}
}
function readUserId() {
try {
let user = uni.getStorageSync("user");
if (typeof user === "string" && user) user = JSON.parse(user);
return (user && (user.username || user.id)) || "";
} catch {
return "";
}
}
async function handleSubmit() {
if (!canSubmit.value) return;
// 上传还没结束就提交,会把没传完的图片漏掉 —— 拦住并说明原因
if (pendingCount.value > 0) {
uni.showToast({ title: "图片还在上传,请稍候", icon: "none" });
return;
}
submitting.value = true;
try {
await submitFeedback({
type: form.type,
content: form.content.trim(),
images: form.images,
user_id: readUserId(),
});
uni.showToast({ title: "感谢反馈", icon: "success" });
// 让 toast 露个面再返回;被直接打开(无上级页)时退回个人中心,避免卡死
setTimeout(() => {
if (getCurrentPages().length > 1) uni.navigateBack();
else uni.reLaunch({ url: "/pages/profile/index" });
}, 1200);
} catch (e) {
// 校验失败 / 断网等真实错误照常提示;request.js 已弹过通用 toast 就不再重复弹
console.error("[feedback] 提交失败:", e);
if (e && e.isNetworkError) {
uni.showToast({ title: "网络连接失败,请稍后重试", icon: "none", duration: 3000 });
}
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
.section { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.section-title { font-size: 14px; font-weight: 700; color: #1f2937; display: block; margin-bottom: 12px; }
.section-sub { font-size: 12px; font-weight: 400; color: #9ca3af; }
/* 问题类型 */
.type-row { display: flex; gap: 10px; }
.type-chip { flex: 1; height: 40px; line-height: 40px; text-align: center; border-radius: 10px; background: #f3f4f6; color: #6b7280; font-size: 13px; font-weight: 600; }
.type-chip-active { background: #2563eb; color: #fff; }
/* 描述 */
.textarea-wrap { position: relative; }
.textarea { width: 100%; min-height: 110px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 10px 26px; font-size: 14px; line-height: 1.5; box-sizing: border-box; background: #fafbfc; }
.ta-placeholder { color: #b0b6bf; font-size: 13px; }
.counter { position: absolute; right: 12px; bottom: 8px; font-size: 11px; color: #b0b6bf; }
/* 图片 */
.img-grid { display: flex; flex-wrap: wrap; }
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 0 16rpx 16rpx 0; }
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
.img-frame { width: 160rpx; height: 160rpx; }
.img { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; background: #e5e7eb; }
.img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }
.img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
.img-loading-text { font-size: 36rpx; }
.img-add { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f9fafb; border-radius: 12rpx; border: 1px dashed #d1d5db; }
.img-add-icon { font-size: 36rpx; }
.img-add-text { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }
/* 提交 */
.submit-btn { width: 100%; height: 46px; line-height: 46px; background: #2563eb; color: #fff; border-radius: 12px; font-size: 15px; font-weight: 600; margin-top: 6px; padding: 0; }
.submit-btn::after { border: none; }
.submit-btn[disabled] { opacity: 0.45; }
.hint { margin-top: 14px; padding: 0 4px; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
</style>

View File

@ -15,7 +15,6 @@
</view>
</view>
<button class="logout-btn" @tap="handleLogout">退出登录</button>
<view class="version">{{ appVersion }}</view>
</view>
</template>
@ -36,28 +35,26 @@ onMounted(() => {
} 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;
// 工作统计、设置 暂不处理
}
}
// 菜单项 → 子页面路由。退出登录已迁到「设置」页,此处不再保留按钮
const MENU_ROUTES = {
"工作统计": "/pages/profile/statistics",
"设置": "/pages/profile/settings",
"帮助与反馈": "/pages/profile/feedback",
};
function handleLogout() {
uni.removeStorageSync("token");
uni.removeStorageSync("access_token");
uni.removeStorageSync("refresh_token");
uni.removeStorageSync("user");
uni.reLaunch({ url: "/pages/login/login" });
function handleMenuClick(item) {
const url = MENU_ROUTES[item];
if (url) {
uni.navigateTo({ url });
return;
}
if (item === "关于") {
uni.showModal({
title: "关于 Track",
content: "Track 生产流转管理系统\n当前版本:" + appVersion.value + "\n核心架构:FastAPI + Vue3 + uni-app",
showCancel: false,
});
}
}
</script>
@ -72,6 +69,5 @@ function handleLogout() {
.menu-item:last-child { border-bottom: none; }
.menu-text { font-size: 14px; color: #374151; }
.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; }
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 24px; }
</style>

View File

@ -0,0 +1,214 @@
<template>
<view class="page">
<view class="menu-card">
<view class="menu-item" @tap="handleClearCache">
<view class="menu-labels">
<text class="menu-text">清理缓存</text>
<text class="menu-sub">{{ cacheSummary }}</text>
</view>
<view class="menu-right">
<text class="menu-value">{{ cacheSize }}</text>
<text class="menu-arrow">›</text>
</view>
</view>
<view class="menu-item" @tap="handleCheckUpdate">
<view class="menu-labels">
<text class="menu-text">检查更新</text>
<text class="menu-sub">热更新资源包,无需重装 App</text>
</view>
<view class="menu-right">
<text class="menu-value">{{ appVersion }}</text>
<text class="menu-arrow">›</text>
</view>
</view>
</view>
<!-- 明确告诉工人「清的是什么、不动什么」—— 否则点了没变化会以为没生效 -->
<view class="hint">
清理仅删除留言已读记录等临时数据,不会影响登录状态、后端地址配置,也不会删除已上传的照片。
</view>
<button class="logout-btn" @tap="handleLogout">退出登录</button>
<view class="version">Track 生产流转 {{ appVersion }}</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { checkAppUpdate } from "../../utils/ota";
// ============================================================
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
// ============================================================
//
// 不用白名单(遍历时跳过白名单、其余全删):那样每引入一个新的插件 / SDK,
// 只要它在本地存了配置,就会被无声清掉,属于「默认伤害」。
// 黑名单反过来 —— **没被匹配到的 key 一律保留**,新增插件天然免疫。
//
// ⚠️ 新增业务缓存时,请顺手到下面登记前缀,不要在这里写零散判断。
/** 按前缀匹配的可清理缓存 */
const CACHE_KEY_PREFIXES = [
// ── 当前真实在用 ──────────────────────────────────────────
// 留言抽屉的「已读位点」,scan/detail.vue 里每打开一台设备的留言就写一个
// msg_seen_<product_id>,随工人扫过的设备数无上限增长。这是目前全 App 唯一
// 真正会无限膨胀的业务缓存。清掉只影响未读小红点会重新出现,无业务损失。
"msg_seen_",
// ── 预留:以下前缀当前无数据,供新功能落缓存时对齐命名 ──────
"draft_", // 各页面临时保存的草稿
"cache_", // 通用业务缓存
"search_", // 历史搜索
"history_", // 操作历史
"scan_", // 扫码记录
"recent_", // 最近访问
"tmp_", // 临时数据
"upload_", // 未完成上传的图片本地记录
];
/** 无前缀可循、需整键精确匹配的(当前为空,留作扩展位) */
const CACHE_KEYS_EXACT = new Set();
/**
* 无论如何都不允许删除的 key —— 最后一道保险。
* 即使将来有人加了与它们撞车的前缀规则(比如为「user_viewed_tips」加了
* `user_`),也不至于把登录凭证或环境配置连带清掉。
*/
const NEVER_DELETE = new Set(["access_token", "refresh_token", "token", "user", "env_base_url"]);
function isCacheKey(key) {
if (NEVER_DELETE.has(key)) return false;
if (CACHE_KEYS_EXACT.has(key)) return true;
return CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
const appVersion = ref("T1.0.2");
const cacheSize = ref("—");
const cacheCount = ref(0); // 当前可清理的临时数据条数
/**
* 「清理缓存」的副标题 —— 直接回答「到底清的是什么」。
* 原先只有一个按钮,工人点完看不到任何变化,会怀疑没生效。
*/
const cacheSummary = computed(() => {
if (cacheCount.value > 0) return `${cacheCount.value} 项临时数据(留言已读记录等)`;
return "暂无临时数据可清理";
});
function readAppVersion() {
try {
const sysInfo = uni.getSystemInfoSync();
return sysInfo.appWgtVersion || sysInfo.appVersion || "T1.0.2";
} catch {
return "T1.0.2";
}
}
/** 刷新缓存信息:占用体积 + 可清理条数 —— 让「清理缓存」有个看得见的效果,而不是盲点一下 */
function refreshCacheSize() {
try {
const info = uni.getStorageInfoSync() || {};
const kb = typeof info.currentSize === "number" ? info.currentSize : null;
cacheSize.value = kb == null ? "—" : kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
cacheCount.value = (info.keys || []).filter(isCacheKey).length;
} catch {
cacheSize.value = "—";
cacheCount.value = 0;
}
}
/**
* 清理缓存 —— 逐键判定:只清 isCacheKey() 认得出来的业务缓存。
*
* 其余一律保留,包括登录态、环境配置,以及任何未登记的 key。
* 逐项 try/catch,单项失败不影响其余项继续清理。
*/
function handleClearCache() {
let keys = [];
try {
keys = (uni.getStorageInfoSync() || {}).keys || [];
} catch (e) {
console.error("[settings] 读取缓存列表失败:", e);
uni.showToast({ title: "清理失败,请重试", icon: "none" });
return;
}
let removed = 0;
let failed = 0;
for (const key of keys) {
if (!isCacheKey(key)) continue; // 未登记的一律保留(登录态、环境配置、未来插件配置)
try {
uni.removeStorageSync(key);
removed++;
} catch (e) {
// 单项失败不影响其余项继续清理
failed++;
console.error("[settings] 清除缓存项失败:", key, e);
}
}
console.log(`[settings] 缓存清理完成:已清除 ${removed} 项,失败 ${failed} 项,保留 ${keys.length - removed} 项`);
refreshCacheSize();
if (failed > 0) {
uni.showToast({ title: `清理完成,${failed} 项未能清除`, icon: "none" });
} else if (removed === 0) {
// 一项都没删就别报「清理成功」—— 那是在骗用户
uni.showToast({ title: "暂无可清理的缓存", icon: "none" });
} else {
uni.showToast({ title: "清理成功", icon: "success" });
}
}
async function handleCheckUpdate() {
// manual = true:跳过节流并给出「已是最新 / 检查失败」的明确反馈 ——
// 用户主动点的按钮必须有个回音(自动检查才静默收场)
await checkAppUpdate({ manual: true });
}
function handleLogout() {
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
uni.showModal({
title: "退出登录",
content: "退出后需要重新输入账号密码,确定退出吗?",
confirmText: "退出",
cancelText: "取消",
success: (res) => {
if (!res.confirm) return;
try {
uni.removeStorageSync("token");
uni.removeStorageSync("access_token");
uni.removeStorageSync("refresh_token");
uni.removeStorageSync("user");
} catch {}
uni.reLaunch({ url: "/pages/login/login" });
},
});
}
onMounted(() => {
appVersion.value = readAppVersion();
refreshCacheSize();
});
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; background: #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-labels { display: flex; flex-direction: column; gap: 3px; }
.menu-text { font-size: 14px; color: #374151; }
.menu-sub { font-size: 11px; color: #9ca3af; }
.hint { margin: 14px 4px 0; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
.menu-right { display: flex; align-items: center; gap: 6px; }
.menu-value { font-size: 12px; color: #9ca3af; }
.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; }
.logout-btn::after { border: none; }
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
</style>

View File

@ -0,0 +1,290 @@
<template>
<view class="page">
<!-- 时段选择 -->
<view class="range-bar">
<view v-for="r in RANGES" :key="r.key"
:class="['range-chip', range === r.key ? 'range-chip-active' : '']"
@tap="selectRange(r.key)">{{ r.label }}</view>
</view>
<!-- 自定义时段:起止日期 -->
<view v-if="range === 'custom'" class="custom-row">
<picker mode="date" :value="customStart || todayStr" :end="todayStr" @change="onCustomStart">
<view class="date-pill">{{ customStart || '开始日期' }}</view>
</picker>
<text class="date-sep">至</text>
<picker mode="date" :value="customEnd || todayStr" :start="customStart" :end="todayStr" @change="onCustomEnd">
<view class="date-pill">{{ customEnd || todayStr }}</view>
</picker>
</view>
<view v-if="!rangeInvalid" class="range-label">{{ rangeLabel }}</view>
<view v-if="loading" class="center">
<text class="center-icon">⏳</text>
<text class="center-text">加载中...</text>
</view>
<!-- 失败也要给出口:工人不会去看控制台,只有「重试」按钮能救他 -->
<view v-else-if="error" class="error-box">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @tap="load">重新加载</button>
</view>
<template v-else>
<!-- 生产战绩 -->
<view class="card">
<view class="card-head">
<text class="card-title">生产战绩</text>
<text class="card-date">{{ rangeLabel }}</text>
</view>
<view class="stat-row">
<view class="stat">
<text class="stat-num num-done">{{ stats.tasks_completed }}</text>
<text class="stat-label">完成</text>
</view>
<view class="stat-divider" />
<view class="stat">
<text class="stat-num" :class="stats.tasks_rejected > 0 ? 'num-reject' : 'num-zero'">
{{ stats.tasks_rejected }}
</text>
<text class="stat-label">被驳回</text>
</view>
<view class="stat-divider" />
<view class="stat">
<text class="stat-num num-product">{{ stats.products_touched }}</text>
<text class="stat-label">参与产品</text>
</view>
</view>
<view class="card-foot">{{ recordComment }}</view>
</view>
<!-- 操作统计 —— 与 PC「人员操作统计」同一口径,方便和主管核对 -->
<view class="card">
<view class="card-head">
<text class="card-title">操作统计</text>
<text class="card-date">与管理系统同口径</text>
</view>
<view class="stat-row">
<view class="stat">
<text class="stat-num num-op">{{ stats.receive_count }}</text>
<text class="stat-label">接收</text>
</view>
<view class="stat-divider" />
<view class="stat">
<text class="stat-num num-op">{{ stats.transfer_count }}</text>
<text class="stat-label">转交</text>
</view>
<view class="stat-divider" />
<view class="stat">
<text class="stat-num num-op">{{ stats.record_count }}</text>
<text class="stat-label">上传备注</text>
</view>
</view>
<view class="card-foot">操作总计 {{ stats.op_total }} 次</view>
</view>
<view class="hint">
统计口径:完成 / 被驳回按本人名下任务计;接收、转交按实际操作人计,上传备注归到任务负责人名下。时间以北京时间为准。
</view>
</template>
</view>
</template>
<script setup>
import { ref, computed } from "vue";
import { onShow, onPullDownRefresh } from "@dcloudio/uni-app";
import { getMyStats } from "../../api/stats";
const RANGES = [
{ key: "today", label: "今日" },
{ key: "week", label: "本周" },
{ key: "month", label: "本月" },
{ key: "custom", label: "自定义" },
];
const range = ref("today");
const customStart = ref("");
const customEnd = ref("");
const loading = ref(true);
const error = ref("");
const stats = ref({
tasks_completed: 0, tasks_rejected: 0, products_touched: 0,
receive_count: 0, transfer_count: 0, record_count: 0, op_total: 0,
});
function pad(n) {
return String(n).padStart(2, "0");
}
function ymd(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const todayStr = ymd(new Date());
/**
* 把当前选择换算成时间段。
*
* 边界一律显式拼 "+08:00" —— 车间按北京时间作息,统计口径也定义在北京时间上。
* 若图省事发不带偏移的裸字符串,后端会按服务器本地时间解释,边界整体偏 8 小时,
* 出现「选了今日,昨晚下午的活也算进来」这种错位。
*/
function buildRange() {
const now = new Date();
let startYmd = todayStr;
let endYmd = todayStr;
if (range.value === "week") {
const d = new Date(now);
d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // 周一为一周之始
startYmd = ymd(d);
} else if (range.value === "month") {
startYmd = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
} else if (range.value === "custom") {
startYmd = customStart.value || todayStr;
endYmd = customEnd.value || todayStr;
}
return {
since: `${startYmd}T00:00:00+08:00`,
until: `${endYmd}T23:59:59+08:00`,
label: startYmd === endYmd ? startYmd : `${startYmd} ~ ${endYmd}`,
};
}
const rangeInvalid = computed(
() => range.value === "custom" && !!customStart.value && !!customEnd.value && customStart.value > customEnd.value
);
const rangeLabel = computed(() => buildRange().label);
const recordComment = computed(() => {
const { tasks_completed, tasks_rejected, products_touched } = stats.value;
if (tasks_completed === 0 && tasks_rejected === 0) return "这段时间还没有完工记录";
if (tasks_rejected > 0) return `有 ${tasks_rejected} 单被驳回,记得跟进返工`;
return `完成 ${tasks_completed} 单,涉及 ${products_touched} 台设备 👍`;
});
function readUsername() {
try {
let user = uni.getStorageSync("user");
if (typeof user === "string" && user) user = JSON.parse(user);
return (user && user.username) || "";
} catch {
return "";
}
}
async function load() {
if (rangeInvalid.value) {
loading.value = false;
error.value = "";
return;
}
const username = readUsername();
if (!username) {
loading.value = false;
error.value = "未获取到登录信息,请重新登录";
return;
}
loading.value = true;
error.value = "";
try {
const { since, until } = buildRange();
const res = await getMyStats(username, since, until);
stats.value = {
tasks_completed: res?.tasks_completed || 0,
tasks_rejected: res?.tasks_rejected || 0,
products_touched: res?.products_touched || 0,
receive_count: res?.receive_count || 0,
transfer_count: res?.transfer_count || 0,
record_count: res?.record_count || 0,
op_total: res?.op_total || 0,
};
} catch (e) {
// request.js 已经弹过一次 toast,这里只在页面上留一个可操作的出口
error.value = e && e.isNetworkError ? "网络连接失败,请检查网络后重试" : "统计数据加载失败";
console.error("[statistics] 加载个人统计失败:", e);
} finally {
loading.value = false;
}
}
function selectRange(key) {
if (range.value === key) return;
range.value = key;
// 切到自定义时先给个默认区间(本月至今),避免空值直接发请求
if (key === "custom" && !customStart.value) {
const now = new Date();
customStart.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
customEnd.value = todayStr;
}
load();
}
function onCustomStart(e) {
customStart.value = e.detail.value;
// 开始日期越过结束日期时顺势把结束日跟过去,避免出现无效区间
if (customEnd.value && customStart.value > customEnd.value) customEnd.value = customStart.value;
load();
}
function onCustomEnd(e) {
customEnd.value = e.detail.value;
load();
}
// 统计是「每次进来都要新鲜」的数据,用 onShow 保证从详情页返回后也刷新
onShow(load);
onPullDownRefresh(async () => {
try {
await load();
} finally {
uni.stopPullDownRefresh(); // 无论成败都要收起动画
}
});
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
/* 时段选择 */
.range-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.range-chip { flex: 1; height: 34px; line-height: 34px; text-align: center; border-radius: 10px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; }
.range-chip-active { background: #2563eb; color: #fff; }
/* 自定义起止日期 */
.custom-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.date-pill { flex: 1; height: 36px; line-height: 36px; text-align: center; border-radius: 10px; background: #fff; color: #374151; font-size: 13px; border: 1px solid #e5e7eb; }
.date-sep { font-size: 12px; color: #9ca3af; }
.range-label { font-size: 11px; color: #9ca3af; margin: 0 2px 10px; }
.center { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
.center-icon { font-size: 40px; margin-bottom: 8px; }
.center-text { font-size: 14px; color: #9ca3af; }
.error-box { background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; padding: 20px 16px; display: flex; flex-direction: column; align-items: center; }
.error-text { font-size: 14px; color: #dc2626; margin-bottom: 14px; text-align: center; }
.retry-btn { width: 160px; height: 40px; line-height: 40px; background: #2563eb; color: #fff; border-radius: 10px; font-size: 14px; font-weight: 600; padding: 0; margin: 0; }
.retry-btn::after { border: none; }
.card { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.card-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
.card-title { font-size: 16px; font-weight: 700; color: #1f2937; }
.card-date { font-size: 11px; color: #9ca3af; }
.stat-row { display: flex; align-items: center; }
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
.stat-divider { width: 1px; height: 40px; background: #f3f4f6; }
.stat-num { font-size: 30px; font-weight: 800; line-height: 1.15; }
.stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; }
.num-done { color: #16a34a; }
.num-reject { color: #dc2626; }
.num-zero { color: #d1d5db; }
.num-product { color: #2563eb; }
.num-op { color: #4f46e5; }
.card-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid #f3f4f6; font-size: 12px; color: #9ca3af; }
.hint { margin-top: 4px; padding: 0 4px; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
</style>

View File

@ -16,6 +16,8 @@
</view>
<!-- 时间独占一行 -->
<text class="fc-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
<!-- 驳回/返工原因:原 REJECTED 取 reject_reason;返工节点取含“驳回/返工”的备注 -->
<text v-if="reasonText" style="display:block;margin:8rpx 0 0;font-size:22rpx;line-height:1.4;color:#dc2626;font-weight:600;">❌ {{ reasonText }}</text>
<!-- 卡片底部:全宽蓝色日志条(对齐 Web 端) -->
<view v-if="node.records && node.records.length" class="ft-log-bar" @tap.stop="$emit('viewRecords', node)">
查看操作日志 ({{ node.records.length }}条) ›
@ -34,6 +36,13 @@ export default {
},
emits: ["viewRecords"],
computed: {
// 驳回/返工原因:原 REJECTED 有 reject_reason;返工节点原因在 remark(形如“返工任务(驳回自…原因…)”)
reasonText() {
const n = this.node || {};
if (n.reject_reason) return `驳回: ${n.reject_reason}`;
const rm = n.remark;
return rm && (rm.indexOf("驳回") >= 0 || rm.indexOf("返工") >= 0) ? rm : "";
},
isMainNode() {
return !this.node.parent_task_id
|| this.node.task_type === "TRANSFER"

View File

@ -1,188 +0,0 @@
<template>
<view class="ft-node">
<!-- 节点卡片 -->
<view class="ft-card" :class="statusColorClass(node.status)">
<view class="ft-head">
<text class="ft-badge" :class="isMainNode ? 'ft-badge-main' : 'ft-badge-sub'">{{ isMainNode ? '主线' : '分支' }}</text>
<text :class="['ft-status', statusColorClass(node.status)]">{{ statusLabel(node.status) }}</text>
<text v-if="node.is_rework" class="ft-rework">⚠返工</text>
</view>
<text class="ft-name">{{ node.task_name }}</text>
<view class="ft-meta">
<!-- 在库任务显示"转入在库",其余显示负责人 -->
<text v-if="isWarehouseTask" class="ft-warehouse-in">📥 转入在库: {{ formatUserName(node.assignee_id) }}</text>
<text v-else class="ft-assignee">👤 {{ formatUserName(node.assignee_id) || '未分配' }}</text>
</view>
<view class="ft-bottom">
<text class="ft-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? ' → ' + fmtDate(node.completed_at) : ' → 至今' }}</text>
<text v-if="node.records && node.records.length" class="ft-records" @tap.stop="$emit('viewRecords', node)">📋 {{ node.records.length }}条 ›</text>
</view>
</view>
<!-- 子任务:先渲染分支(紧贴父节点、缩进),再渲染主干道(继续向下延伸) -->
<view v-if="node.child_tasks && node.child_tasks.length">
<!-- 1. 先渲染分支:紧贴父节点下方,缩进 + 左侧虚线框,视觉上被父节点"兜住" -->
<view v-if="branchChildren.length" class="ft-branch-children">
<view class="ft-branch-indicator">↳ 分支延伸</view>
<FlowTree
v-for="child in branchChildren"
:key="child.id"
:node="child"
:is-branch-context="true"
:current-user="currentUser"
:current-user-id="currentUserId"
:current-username="currentUsername"
@view-records="$emit('viewRecords', $event)"
/>
</view>
<!-- 2. 再渲染主干道:每个主线子节点前都画向下连线箭头(含父→长子) -->
<template v-for="(child, i) in mainChildren" :key="'mc-' + child.id">
<view v-if="i === 0 || i > 0" class="ft-flow-arrow">↓</view>
<FlowTree
:node="child"
:current-user="currentUser"
:current-user-id="currentUserId"
:current-username="currentUsername"
@view-records="$emit('viewRecords', $event)"
/>
</template>
</view>
</view>
</template>
<script>
import { formatUserName as fmtUserName } from "../../../utils/format";
export default {
name: "FlowTree",
props: {
node: { type: Object, required: true },
currentUser: { type: Object, default: null },
currentUserId: { type: [String, Number], default: "" },
currentUsername: { type: String, default: "" },
// 分支上下文:一旦进入分支,其子子孙孙都强制缩进,不再回主干道
isBranchContext: { type: Boolean, default: false },
},
emits: ["viewRecords"],
computed: {
isMainNode() {
return !this.node.parent_task_id
|| this.node.task_type === "TRANSFER"
|| this.node.task_type === "RECOVERY";
},
// 主线子任务:TRANSFER/RECOVERY,继续主干道(左对齐)
// 分支上下文拦截:一旦处于分支,所有子任务强制归入分支,主线返回空
mainChildren() {
if (this.isBranchContext) return [];
return (this.node.child_tasks || []).filter((c) =>
!c.parent_task_id || c.task_type === "TRANSFER" || c.task_type === "RECOVERY"
);
},
// 分支子任务:其余(SPAWN 协助等),缩进显示
// 分支上下文拦截:处于分支时,全部子任务都作为分支继续缩进
branchChildren() {
if (this.isBranchContext) return this.node.child_tasks || [];
return (this.node.child_tasks || []).filter((c) =>
c.parent_task_id && c.task_type !== "TRANSFER" && c.task_type !== "RECOVERY"
);
},
isWarehouseTask() {
const name = String(this.node.task_name || "");
return (name.includes("在库") || name.includes("入库")) || this.node.assignee_id === "virtual_warehouse";
},
},
methods: {
// Vue2 Options API 中 import 的函数需挂到 methods 才能被模板访问
formatUserName(userId) {
return fmtUserName(userId);
},
statusLabel(s) {
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库" };
return map[s] || s;
},
statusColorClass(s) {
switch (s) {
case "PENDING": return "s-yellow";
case "WIP": return "s-blue";
case "COMPLETED": return "s-green";
case "REJECTED": return "s-red";
case "ARCHIVED": return "s-archived";
case "OUTBOUND": return "s-outbound";
default: return "s-gray";
}
},
fmtDate(t) {
if (!t) return "";
const d = new Date(t);
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
},
},
};
</script>
<style scoped>
.ft-node { margin-bottom: 10px; }
.ft-card {
background: #fff;
border-radius: 10px;
border: 1px solid #e5e7eb;
border-left-width: 4px;
padding: 10px 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
/* 状态左色条 */
.ft-card.s-yellow { border-left-color: #f59e0b; }
.ft-card.s-blue { border-left-color: #3b82f6; }
.ft-card.s-green { border-left-color: #22c55e; }
.ft-card.s-red { border-left-color: #ef4444; }
.ft-card.s-archived { border-left-color: #8b5cf6; }
.ft-card.s-outbound { border-left-color: #4f46e5; }
.ft-card.s-gray { border-left-color: #9ca3af; }
.ft-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.ft-badge { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 4px; color: #fff; }
.ft-badge-main { background: #2563eb; }
.ft-badge-sub { background: #7c3aed; }
.ft-status { font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 10px; }
.s-yellow .ft-status { background: #fef3c7; color: #b45309; }
.s-blue .ft-status { background: #dbeafe; color: #1d4ed8; }
.s-green .ft-status { background: #dcfce7; color: #15803d; }
.s-red .ft-status { background: #fce4ec; color: #be123c; }
.s-archived .ft-status { background: #ede9fe; color: #7c3aed; }
.s-outbound .ft-status { background: #e0e7ff; color: #4338ca; }
.s-gray .ft-status { background: #f3f4f6; color: #6b7280; }
.ft-rework { font-size: 10px; background: #ef4444; color: #fff; padding: 1px 5px; border-radius: 4px; font-weight: 700; }
.ft-name { font-size: 14px; font-weight: 700; color: #1f2937; }
.ft-meta { margin-top: 4px; font-size: 12px; }
.ft-assignee { color: #4b5563; }
.ft-warehouse-in { color: #059669; font-weight: 700; }
.ft-bottom { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; }
.ft-time { font-size: 11px; color: #9ca3af; }
.ft-records { font-size: 11px; color: #2563eb; font-weight: 600; }
/* 主线子任务:无缩进(左边缘对齐成笔直主干道),之间用 ↓ 连线 */
.ft-flow-arrow {
text-align: center;
color: #9ca3af;
font-size: 16px;
font-weight: 700;
line-height: 1;
margin: 2px 0;
}
/* 分支子任务:缩进 + 左侧虚线框,视觉上被父节点"兜住" */
.ft-branch-children {
margin-left: 12px;
padding-left: 12px;
border-left: 2px dashed #cbd5e1;
margin-top: 8px;
}
/* 分支指示符:轻量级连接锚点 */
.ft-branch-indicator {
color: #9ca3af;
font-size: 14px;
margin-bottom: 4px;
padding-left: 4px;
}
</style>

View File

@ -41,6 +41,9 @@
<text class="tc-name">{{ card.task_name }}</text>
<!-- 被驳回原任务:显示驳回原因(返工节点原因由下方“备注”框承载) -->
<text v-if="card.reject_reason" style="display:block;margin-top:6rpx;font-size:22rpx;line-height:1.4;color:#dc2626;font-weight:600;">❌ 驳回: {{ card.reject_reason }}</text>
<view class="tc-meta">
<view class="tc-meta-row">
<!-- 仓储任务:assignee 为 null,直接显示系统名,不取用户头像 -->

View File

@ -1,246 +0,0 @@
<template>
<view class="tnode">
<view v-if="hasChildren" class="tree-line-vertical" />
<!-- 卡片 -->
<view :class="['tnode-card', statusColor(task.status), { 'is-rework': task.is_rework }]">
<!-- 第一层:任务名称、标签、状态 Badge、负责人 -->
<view class="tnode-body">
<view class="tnode-name-row">
<text v-if="task.is_rework" class="tag tag-rework">⚠返工</text>
<text v-if="task.child_tasks && task.child_tasks.length > 1" class="tag tag-fission">裂变×{{ task.child_tasks.length }}</text>
<text class="tnode-name">{{ task.task_name }}</text>
</view>
<view class="tnode-meta">
<!-- 在库任务不单独显示"负责人",由下方"转入在库"统一展示实际操作人 -->
<text v-if="task.assignee_id && !isWarehouseTask">负责人: {{ formatUserName(task.assignee_id) }}</text>
<text v-if="task.reject_reason" class="reject-reason">驳回: {{ task.reject_reason }}</text>
</view>
<!-- 📥 在库任务:显示实际执行"转入在库"的操作人(任务负责人) -->
<text v-if="isWarehouseTask && task.assignee_id" class="warehouse-in">📥 转入在库: {{ formatUserName(task.assignee_id) }}</text>
<!-- 任务初始描述 / 交接备注(常驻显示,区别于动态日志 records) -->
<view v-if="task.remark || task.description" class="task-initial-remark">
<text class="remark-label">📌 初始说明:</text>
<text class="remark-text">{{ task.remark || task.description }}</text>
</view>
</view>
<!-- 状态标签 + 分支闭环标记 -->
<view style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;margin-left:8px;">
<text :class="['badge', statusColor(task.status)]">{{ statusLabel(task.status) }}</text>
<text v-if="task.status === 'COMPLETED' && (!task.child_tasks || !task.child_tasks.length)" class="end-marker">🏁 已终止</text>
<text v-if="task.status === 'ARCHIVED'" class="end-marker end-warehouse">📦 已入库</text>
</view>
</view>
<!-- 第二层:递归子任务树 -->
<view v-if="task.child_tasks && task.child_tasks.length" class="tnode-children">
<TaskTreeNode
v-for="(child, idx) in task.child_tasks"
:key="child.id"
:task="child"
:currentUser="currentUser"
:currentUserId="currentUserId"
:currentUsername="currentUsername"
:readonly="readonly"
:has-children="!!(child.child_tasks && child.child_tasks.length)"
:isLast="idx === task.child_tasks.length - 1"
@action="(e) => $emit('action', e)"
@editRecord="(e) => $emit('editRecord', e)"
@viewRecords="(t) => $emit('viewRecords', t)"
/>
</view>
<!-- 第三层(最底部):进度记录 — 最新一条 + 查看全部入口 -->
<view v-if="task.records && task.records.length" class="records-area">
<!-- 查看全部记录按钮 -->
<view class="records-bar" @tap.stop="$emit('viewRecords', task)">
<text class="records-bar-icon">📋</text>
<text class="records-bar-text">查看全部记录 ({{ task.records.length }})</text>
<text class="records-bar-arrow">›</text>
</view>
<!-- 最新一条预览 -->
<view v-if="latestRecord" class="record-item">
<view class="record-top">
<text class="record-time">{{ formatTime(latestRecord.created_at) }}</text>
<view v-if="canEditRecord" class="record-actions">
<text class="rec-act" @tap.stop="$emit('editRecord', { task, record: latestRecord })">✏️</text>
<text class="rec-act" @tap.stop="confirmDelete(latestRecord)">🗑️</text>
</view>
</view>
<text v-if="latestRecord.remark" class="record-remark">{{ latestRecord.remark }}</text>
<view v-if="latestRecord.images && latestRecord.images.length" class="record-images">
<image
v-for="(img, i) in latestRecord.images"
:key="i"
:src="imageUrl(img)"
class="record-thumb"
mode="aspectFill"
@tap.stop="previewImage(latestRecord.images, i)"
/>
</view>
</view>
</view>
</view>
</template>
<script>
import { getBaseUrl } from "../../../utils/request";
import { formatUserName as fmtUserName } from "../../../utils/format";
export default {
name: "TaskTreeNode",
props: {
task: { type: Object, required: true },
isLast: { type: Boolean, default: false },
hasChildren: { type: Boolean, default: false },
currentUser: { type: Object, default: null },
currentUserId: { type: [String, Number], default: "" },
currentUsername: { type: String, default: "" },
readonly: { type: Boolean, default: false },
},
emits: ["action", "editRecord", "viewRecords"],
mounted() {
console.log("TaskTreeNode task keys:", Object.keys(this.task));
console.log("task.remark =", this.task.remark);
console.log("task.description =", this.task.description);
},
computed: {
isWarehouseTask() {
return !!(this.task && (
String(this.task.task_name || '').includes('在库')
|| String(this.task.task_name || '').includes('入库')
|| this.task.assignee_id === 'virtual_warehouse'
));
},
canEditRecord() {
if (this.readonly) return false;
if (this.task.assignee_id == this.currentUserId) return true;
if (this.task.assignee_id == this.currentUsername) return true;
if (this.currentUser && this.currentUser.id == this.task.assignee_id) return true;
if (this.currentUser && this.currentUser.username == this.task.assignee_id) return true;
return false;
},
latestRecord() {
const recs = this.task.records;
if (!recs || !recs.length) return null;
return [...recs].sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
},
},
methods: {
imageUrl(url) {
if (!url) return "";
if (url.startsWith("http")) return url;
// 后端返回 /api/v1/upload/files/xxx.jpg,需要补全域名
const domain = getBaseUrl().replace(/\/api.*$/, '');
return domain + (url.startsWith("/") ? url : "/" + url);
},
previewImage(urls, index) {
const fullUrls = (urls || []).map(img => this.imageUrl(img));
uni.previewImage({ urls: fullUrls, current: index });
},
confirmDelete(rec) {
// 🚀 交给父页面统一做「双重确认倒计时」,避免原生 showModal 取消行为异常
this.$emit("action", { task: this.task, type: "deleteRecord", record: rec });
},
statusLabel(s) {
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
return map[s] || s;
},
// Vue2 Options API 中 import 的函数需挂到 methods 才能被模板访问
formatUserName(userId) {
return fmtUserName(userId);
},
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 "OUTBOUND": return "s-outbound";
default: return "s-gray";
}
},
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())}:${pad(d.getSeconds())}`;
},
},
};
</script>
<style scoped>
.tnode { position: relative; padding-left: 12px; margin-bottom: 6px; }
.tree-line-vertical { position: absolute; left: 8px; top: 28px; bottom: 0; width: 2px; background: #e5e7eb; }
.tnode-card {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 10px 12px; border-radius: 10px 10px 0 0;
background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
border-left: 3px solid transparent;
}
.is-rework { border-left-color: #ef4444 !important; }
.s-yellow { border-left-color: #f59e0b; }
.s-blue { border-left-color: #3b82f6; }
.s-green { border-left-color: #22c55e; }
.s-red { border-left-color: #ef4444; }
.s-outbound { border-left-color: #4f46e5; }
.tnode-body { flex: 1; min-width: 0; }
.tnode-name-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.tnode-name { font-size: 14px; font-weight: 700; color: #1f2937; }
.tag { font-size: 10px; padding: 1px 5px; border-radius: 6px; font-weight: 700; color: #fff; }
.tag-rework { background: #ef4444; }
.tag-fission { background: #7c3aed; }
.tnode-meta { margin-top: 2px; font-size: 11px; color: #9ca3af; display: flex; gap: 8px; flex-wrap: wrap; }
.reject-reason { color: #ef4444; }
.warehouse-in { display: block; margin-top: 4px; font-size: 11px; color: #059669; font-weight: 700; }
/* 任务初始描述(常驻,区别于动态记录) */
.task-initial-remark {
margin-top: 10rpx; padding: 12rpx 16rpx;
background: linear-gradient(135deg, #fefce8, #fef9c3);
border-left: 6rpx solid #eab308; border-radius: 8rpx;
}
.remark-label { font-size: 22rpx; font-weight: 700; color: #a16207; }
.remark-text { font-size: 26rpx; color: #713f12; line-height: 1.5; word-break: break-all; }
/* 进度记录 */
.records-area { background-color: #f9f9f9; padding: 0; border-radius: 10rpx; margin-top: 16rpx; margin-bottom: 6rpx; overflow: hidden; }
.records-bar {
display: flex; align-items: center; gap: 8rpx;
padding: 16rpx 20rpx;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border-bottom: 1px solid #bfdbfe;
}
.records-bar-icon { font-size: 28rpx; }
.records-bar-text { flex: 1; font-size: 26rpx; font-weight: 700; color: #2563eb; }
.records-bar-arrow { font-size: 32rpx; color: #93c5fd; font-weight: 700; }
.record-item { padding: 12rpx 16rpx; }
.record-item { padding: 8rpx 0; border-bottom: 1px dashed #e5e7eb; }
.record-item:last-child { border-bottom: none; }
.record-top { display: flex; align-items: center; justify-content: space-between; }
.record-actions { display: flex; gap: 12rpx; }
.rec-act { font-size: 28rpx; padding: 4rpx; }
.record-remark { font-size: 26rpx; color: #333; display: block; margin: 6rpx 0; line-height: 1.5; word-break: break-all; }
.record-images { display: flex; gap: 8rpx; margin-top: 8rpx; flex-wrap: wrap; }
.record-thumb { width: 100rpx; height: 100rpx; border-radius: 8rpx; border: 1px solid #e5e7eb; background: #f3f4f6; }
.record-time { font-size: 22rpx; color: #9ca3af; }
.badge {
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
white-space: nowrap; flex-shrink: 0;
background: #f3f4f6; color: #6b7280;
}
.end-marker { font-size: 9px; padding: 1px 6px; border-radius: 8px; font-weight: 600; white-space: nowrap; background: #dcfce7; color: #16a34a; }
.end-warehouse { background: #ede9fe; color: #7c3aed; }
.s-yellow .badge { background: #fef3c7; color: #b45309; }
.s-blue .badge { background: #dbeafe; color: #1d4ed8; }
.s-green .badge { background: #dcfce7; color: #15803d; }
.s-red .badge { background: #fce4ec; color: #be123c; }
.s-outbound .badge { background: #e0e7ff; color: #4338ca; }
.tnode-children { position: relative; }
</style>

View File

@ -248,7 +248,11 @@ export default {
<style scoped>
.workspace-area { height: auto; display: flex; flex-direction: column; }
.workspace-area.is-locked { height: 100vh; overflow: hidden; }
/* 沉浸工作区(is-locked)不再锁死 height:100vh + overflow:hidden ——
那会造出「卡片内部滚动 + 原生页面滚动」两个滚动容器并存的局面:卡片滚到顶
之后再往下拉,手势会穿透到页面层,误触发下拉刷新,工人根本没法往上翻内容。
高度交还给内容自然撑开,滚动统一由原生页面负责。 */
.workspace-area.is-locked { height: auto; }
.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; }
@ -271,7 +275,9 @@ export default {
.lock-title { font-size: 14px; font-weight: 700; color: #1f2937; }
.branch-done { font-size: 11px; font-weight: 600; color: #16a34a; }
.task-count { font-size: 12px; color: #9ca3af; display: block; margin-top: 4px; }
.focus-card { flex: 1; min-height: 0; margin: 0 4px; padding: 20px 16px calc(160rpx + env(safe-area-inset-bottom)); border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow-y: auto; border-top: 5px solid #3b82f6; }
/* 去掉 overflow-y: auto —— 锁定任务详情不再自己滚,随页面一起滚。
padding-bottom 仍保留 160rpx,用来让内容避开底部固定操作栏。 */
.focus-card { min-height: 0; margin: 0 4px; padding: 20px 16px calc(160rpx + env(safe-area-inset-bottom)); border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border-top: 5px solid #3b82f6; }
.focus-card.s-yellow { border-top-color: #f59e0b; }
.focus-card.s-blue { border-top-color: #3b82f6; }
.focus-card.s-green { border-top-color: #22c55e; }

View File

@ -10,6 +10,8 @@
<view class="overall-bar" @tap="handleOverallBarClick">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', overallStatusClass(product.overall_status)]">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
<!-- 🔧 生命周期标识:生产阶段「发货测试」 vs 出库回流后「售后维修」 -->
<text v-if="lifeBadge" :class="['life-badge', lifeBadge.cls]">{{ lifeBadge.label }}</text>
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow">▾</text>
</view>
@ -36,10 +38,10 @@
</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 v-if="showDispatchBanner"
class="warehouse-transfer-banner" @tap="openCreateFirstTask">
<text class="wt-icon">{{ dispatchBannerIcon }}</text>
<text class="wt-text">{{ dispatchBannerText }}</text>
</view>
</template>
@ -70,7 +72,7 @@
<text class="sheet-title">{{ product && product.overall_status ? '修改宏观状态' : '🔔 请设定产品宏观状态' }}</text>
<text class="sheet-hint">首次扫码,请选择一个状态以开启流转</text>
<view class="sheet-options">
<view v-for="opt in OVERALL_OPTIONS" :key="opt" :class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']" @tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
<view v-for="opt in availableOverallOptions" :key="opt" :class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']" @tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
</view>
<button v-if="product && product.overall_status" class="sheet-close" @tap="showStatusPicker = false">关闭</button>
</view>
@ -108,7 +110,7 @@
<text class="popup-task">{{ recordPopup.task && recordPopup.task.task_name }}</text>
<textarea v-model="recordForm.remark" class="popup-textarea" placeholder="填写备注说明" :maxlength="2000" />
<view class="img-grid">
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell"><image :src="img" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" /><text v-if="canDeleteImage" class="img-del" @tap.stop="removeRecordImage(i)">✕</text></view>
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell"><view class="success-badge-wrapper img-frame"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" /><view v-if="isUploaded(img)" class="success-badge" /></view><text v-if="canDeleteRecordImage(i)" class="img-del" @tap.stop="removeRecordImage(i)">✕</text></view>
<view v-for="n in recordForm.pendingCount" :key="'p'+n" class="img-cell img-cell-loading"><text class="img-loading-text">⏳</text></view>
</view>
<button v-if="recordForm.images.length + recordForm.pendingCount < 9" class="btn-upload" @tap="handleChooseImage" :disabled="isUploading">{{ isUploading ? '上传中...' : `📷 拍照/选图 (${recordForm.images.length + recordForm.pendingCount}/9)` }}</button>
@ -124,7 +126,7 @@
<text class="popup-hint">状态: {{ statusLabel(actionPopup.task && actionPopup.task.status) }} → 进行中</text>
<view class="field-label">选择工序 <text class="required">*</text></view>
<view class="user-grid">
<view v-for="opt in TASK_NAME_OPTIONS" :key="opt"
<view v-for="opt in availableTaskOptions" :key="opt"
:class="['user-grid-item', receiveTaskName === opt ? 'user-grid-active' : '']"
@tap="receiveTaskName = opt">{{ opt }}</view>
</view>
@ -134,12 +136,19 @@
<template v-if="actionPopup.type === 'reject'">
<text class="popup-title">品质驳回</text>
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
<!-- 📷 异常图片为选填:编号错误、选错工序等场景可不拍照 -->
<view class="field-label">异常图片 <text class="optional">(选填,最多 9 张)</text></view>
<view class="img-grid">
<view v-for="(img, i) in rejectForm.images" :key="i" class="img-cell"><view class="success-badge-wrapper img-frame"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRejectImage(i)" /><view v-if="isUploaded(img)" class="success-badge" /></view><text class="img-del" @tap.stop="removeRejectImage(i)">✕</text></view>
<view v-for="n in rejectForm.pendingCount" :key="'rp'+n" class="img-cell img-cell-loading"><text class="img-loading-text">⏳</text></view>
</view>
<button v-if="rejectForm.images.length + rejectForm.pendingCount < 9" class="btn-upload" @tap="handleChooseRejectImage" :disabled="isUploading">{{ isUploading ? '上传中...' : `📷 拍照/选图 (${rejectForm.images.length + rejectForm.pendingCount}/9)` }}</button>
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :class="{ 'btn-counting': confirming === 'reject' && confirmCount > 0 }" :disabled="actionLoading || !rejectReason.trim() || (confirming === 'reject' && confirmCount > 0)" @tap="confirmBtn('reject', doReject)">{{ confirmLabel('reject', '确认驳回') }}</button></view>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :class="{ 'btn-counting': confirming === 'reject' && confirmCount > 0 }" :disabled="actionLoading || isUploading || !rejectReason.trim() || (confirming === 'reject' && confirmCount > 0)" @tap="confirmBtn('reject', doReject)">{{ isUploading ? '上传中...' : confirmLabel('reject', '确认驳回') }}</button></view>
</template>
<template v-if="actionPopup.type === 'transfer'">
<text class="popup-title">完工转交</text>
<view class="field-label">接收人 <text class="required">*</text></view>
<view class="field-label">接收人 / 处理方式 <text class="required">*</text></view>
<view class="user-grid">
<view v-for="u in userGridOptions" :key="u.id"
:class="['user-grid-item', transferForm.selectedUserId === u.id ? 'user-grid-active' : '']"
@ -147,10 +156,14 @@
</view>
<view class="field-label" style="margin-top:12px;">或</view>
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view>
<template v-if="showFinishDirect">
<view :class="['user-grid-item', transferForm.isFinishDirect ? 'user-grid-active' : '']" style="width:100%;margin-top:8px;" @tap="toggleFinishDirect">🏁 直接完结 (不入库)</view>
<text class="popup-hint">🏁 直接完结:结束本任务且不创建下游任务,不改动产品状态(已出库的设备完结后依然是「已出库」)。用于售后返厂直接发走、半成品被直接提走等无需入库的场景</text>
</template>
<view class="field-label" style="margin-top:12px;">交接备注 <text class="required">*</text></view>
<textarea v-model="transferForm.note" class="popup-textarea" placeholder="请填写交接备注(必填)" :maxlength="500" />
<view v-if="transferForm.isWarehouse || transferForm.selectedUserId" class="preview-hint">{{ transferForm.isWarehouse ? '产品将入库并从个人待办中移除' : '将创建新任务指派给 ' + (transferUserName || '—') }}</view>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || (!transferForm.isWarehouse && !transferForm.selectedUserId) || !transferForm.note.trim()" @tap="doTransfer">{{ actionLoading ? '提交中...' : (transferForm.isWarehouse ? '📦 确认入库' : '确认转交') }}</button></view>
<view v-if="transferMode" class="preview-hint">{{ transferPreview }}</view>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || !transferMode || !transferForm.note.trim()" @tap="doTransfer">{{ actionLoading ? '提交中...' : transferSubmitLabel }}</button></view>
</template>
<template v-if="actionPopup.type === 'spawn'">
<text class="popup-title">➕ 派发协助分支</text>
@ -216,28 +229,32 @@
<script>
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
import { uploadImages, isUploadedUrl } from "../../utils/upload";
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
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 = ["备货", "生产", "测试", "维修", "在库"];
// 🔧 工序可选项不再写死 —— 由 computed availableOverallOptions / availableTaskOptions
// 按生命周期阶段(生产制造 / 售后回流)动态收窄,词表见 utils/lifecycle.js
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库", CANCELED: "已撤回" };
export default {
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
data() {
return {
OVERALL_OPTIONS, loading: true, error: "", product: null,
loading: true, error: "", product: null,
showStatusPicker: false, editProductVisible: false, editForm: { order_no: "", external_serial: "" }, editSaving: false,
users: [], TASK_NAME_OPTIONS,
users: [],
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,
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0, savedCount: 0 }, recordSaving: false, isUploading: false,
currentUser: null, currentUserId: "", currentUsername: "", currentUserRole: "", currentMode: "workspace", autoLockTaskId: "",
processOptions: [], userOptions: [],
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
// 📷 驳回异常图片(选填):与追加记录共用同一套选图/上传流程
rejectForm: { images: [], pendingCount: 0 },
transferForm: { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" },
spawnForm: { assignee_id: "", remark: "" },
// 🛡️ 双重确认倒计时:避免误触
confirming: "", // 当前倒计时中的操作 key('' = 无)
@ -258,6 +275,64 @@ export default {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
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; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
// 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。
// ⚠️ 必须两个角色都判:本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN、
// 漏了 SUPERVISOR,导致主管被前端误挡。此处与后端 ADMIN_ROLES 对齐。
// 🔒 管理角色判定(超管 / 主管)—— 与后端 ADMIN_ROLES 对齐,作为各处权限判断的唯一入口
isAdminUser() { const r = (this.currentUser && this.currentUser.role) || this.currentUserRole || ''; return r === 'SUPER_ADMIN' || r === 'SUPERVISOR'; },
canFinishDirectly() { return this.isAdminUser; },
// 📤 产品是否空闲:没有任何活跃的【主线】任务(WIP/PENDING)。
// 只算主干(无父任务 或 TRANSFER/RECOVERY):协助分支(SPAWN)不阻塞派发,
// 否则一个挂着的协助分支会把产品永久锁死。
hasActiveMainTask() {
const walk = (tasks) => {
if (!tasks) return false;
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')) return true;
if (walk(t.child_tasks)) return true;
}
return false;
};
return this.product ? walk(this.product.task_tree) : false;
},
// 📤 派发新任务入口的显示条件 —— 2026-09-17 解绑「派发权限」与「仓库位置」。
//
// 原实现硬性要求 current_location_id === 'virtual_warehouse',于是
// 【直接完结】后的设备(位置停在最后经手人名下、又不在仓库池)派发入口
// 彻底消失,产品变成再也无法流转的孤儿数据。
// 但物理上除非设备报废,永远存在派发新任务的需求,故增加豁免:
// · 在仓库且我本人没有待办 → 转出派发(原有行为,保持不动)
// · 产品空闲(无活跃主线任务)且我是超管/主管 → 直接派发新任务
// (已出库设备尤其如此:位置在工人名下也不该挡住派发)
showDispatchBanner() {
if (!this.product) return false;
if (this.product.current_location_id === 'virtual_warehouse' && !this.hasMyActiveTask) return true;
return this.isAdminUser && !this.hasActiveMainTask;
},
dispatchBannerText() {
return this.product && this.product.current_location_id === 'virtual_warehouse'
? '该产品在仓库中 — 点击此处转出并派发给指定人员'
: '当前任务已完结 — 点击此处直接派发新任务';
},
dispatchBannerIcon() {
return this.product && this.product.current_location_id === 'virtual_warehouse' ? '📤' : '🚀';
},
// 🛡️ 直接完结的【场景】门槛:角色够 + 产品确实是「已出库」。
// 为什么必须卡状态:普通生产中的设备若被直接完结,产品既无下游任务、
// 又不在仓库池中,会变成卡在工人名下的**孤儿数据**;而且售后往往要多步
// 流转(发货测试 → 维修 → 入库 → …),提前完结会把任务流彻底切断。
// 生产中的设备只能走「入库」或「转交个人」,回归正常流转。
showFinishDirect() {
return this.canFinishDirectly
&& !!this.product
&& this.product.overall_status === '已出库';
},
// 🏁 转交弹窗的三选一模式:'' = 未选 | 'user' 转交个人 | 'warehouse' 入库 | 'direct' 直接完结
// direct 分支额外校验 角色 + 场景 双门槛,防御残留选中态把 finish_directly 发出去
transferMode() { const f = this.transferForm; if (f.isFinishDirect && this.showFinishDirect) return 'direct'; if (f.isWarehouse) return 'warehouse'; return f.selectedUserId ? 'user' : ''; },
transferSubmitLabel() { return { direct: '🏁 确认直接完结', warehouse: '📦 确认入库', user: '确认转交' }[this.transferMode] || '确认转交'; },
transferPreview() { return { direct: '任务将直接完结,不创建下游任务;不改动产品状态(已出库的设备完结后依然是「已出库」)', warehouse: '产品将入库并从个人待办中移除', user: '将创建新任务指派给 ' + (this.transferUserName || '—') }[this.transferMode] || ''; },
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 '🛠️ 工作区'; },
@ -287,12 +362,60 @@ export default {
checkTask(this.product.task_tree);
return hasPermission;
},
// 🔧 生命周期标签:只在售后回流环节(发货测试 / 售后维修)打红色标签
lifeBadge() {
return lifecycleBadge(
this.product && this.product.overall_status,
this.product && this.product.lifecycle_phase,
);
},
// ── 🔧 选项隔离:按生命周期阶段收窄可选工序 ──
// 生产制造设备只能排「备货/生产/测试/维修/在库」;
// 售后回流设备只能选「发货测试/售后维修/在库」,排不回前期环节。
isAfterSales() {
return !!(this.product && this.product.lifecycle_phase === "AFTER_SALES");
},
// 是否已有"真实工序"历史 —— 仅判断 task_tree 非空是不够的:
// 老设备首次「发起首道工序」建出来的任务名是占位符「待确认」,
// 此时产品已有一条任务,但接收人还没机会声明工序。若按 task_tree 非空
// 就判定"有历史",接收下拉将只给生产工序,老设备永远选不到「售后维修」,
// 售后通路直接断掉。故这里必须排除占位符。
hasHistory() {
const walk = (tasks) => {
if (!tasks) return false;
for (const t of tasks) {
const name = String(t.task_name || "").trim();
if (name && name !== "待确认" && !name.includes("virtual_warehouse")) return true;
if (walk(t.child_tasks)) return true;
}
return false;
};
return walk(this.product && this.product.task_tree);
},
availableOverallOptions() {
return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
},
availableTaskOptions() {
// 🔧 第三个参数 overall_status:已出库设备同样要走售后词表(发货测试 / 售后维修),
// 否则出库后补做质检的工人永远选不到对应工序(鸡生蛋死锁)。
return taskOptionsFor(
this.product && this.product.lifecycle_phase,
this.hasHistory,
this.product && this.product.overall_status,
);
},
},
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(); } },
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
onUnload() { this.clearConfirm(); },
// ⚠️ 本页【刻意不开启】下拉刷新(pages.json 中已移除 enablePullDownRefresh)。
// 原因:本页有三种沉浸式模式 —— 锁定的工作区卡片、全屏流转卡片(swiper)、
// 全屏流转树(scroll-view),它们各自持有滚动/滑动手势,且都是 position:fixed
// 或原生 scroll-view,无法改由页面滚动接管。一旦开启页面下拉刷新,这些区域
// 滑到顶后再下拉就会误触发刷新,工人没法正常往上翻内容。
// 状态纠偏不依赖下拉刷新:handleNetworkFailure 会自动静默拉取真实状态。
methods: {
formatUserName, formatUserAvatar,
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
@ -335,40 +458,162 @@ export default {
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 || ""; this.currentUserRole = user.role || ""; } } catch {} },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[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; },
// 🚀 打开「派发新任务」弹窗。标题由 isWarehouseTransfer 决定,
// 而该标志【必须在此处按产品实际位置重新推导】——
// 原先 openWarehouseTransfer 先置 true、再调用本方法被立刻重置为 false,
// 导致弹窗标题永远显示「发起首道工序」,仓库转出场景的文案从未生效。
openCreateFirstTask() {
this.isWarehouseTransfer = !!(this.product && this.product.current_location_id === 'virtual_warehouse');
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 if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); },
// 注:原 openWarehouseTransfer() 已移除 —— 它的 isWarehouseTransfer=true 会被
// openCreateFirstTask 立刻覆盖(死代码)。现在 banner 直接调用 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; } },
openRecordPopup(task) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: null, remark: "", images: [], pendingCount: 0 }; this.isUploading = false; },
openEditRecord({ task, record }) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: record.id, remark: record.remark || "", images: record.images || [], pendingCount: 0 }; this.isUploading = false; },
// savedCount = 打开弹窗时已落库的图片张数。列表里 [0, savedCount) 是服务端已有的,
// 之后的都是本次会话新选的(尚未提交),删除判定据此区分,见 canDeleteRecordImage。
openRecordPopup(task) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: null, remark: "", images: [], pendingCount: 0, savedCount: 0 }; this.isUploading = false; },
openEditRecord({ task, record }) { const saved = record.images || []; this.recordPopup = { visible: true, task }; this.recordForm = { recordId: record.id, remark: record.remark || "", images: saved.slice(), pendingCount: 0, savedCount: saved.length }; this.isUploading = false; },
closeRecordPopup() { this.recordPopup = { visible: false, task: null }; },
async handleChooseImage() { const maxSlots = 9 - (this.recordForm.images.length + this.recordForm.pendingCount); if (maxSlots <= 0) return; const chooseRes = await new Promise((resolve, reject) => { uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject }); }).catch(() => null); if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return; let compressSkipCount = 0; const compressedPaths = []; for (const p of chooseRes.tempFilePaths) { try { const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); }); compressedPaths.push(compressed.tempFilePath); } catch { compressSkipCount++; } } if (!compressedPaths.length) return; this.isUploading = true; this.recordForm.pendingCount += compressedPaths.length; for (const path of compressedPaths) { const url = await this.uploadFile(path); if (url) this.recordForm.images.push(url); this.recordForm.pendingCount--; } this.isUploading = false; },
uploadFile(filePath) { return new Promise((resolve) => { uni.uploadFile({ url: getBaseUrl() + "/upload/", filePath, name: "file", success(res) { try { const data = JSON.parse(res.data); resolve(data.url || null); } catch { resolve(null); } }, fail: () => resolve(null) }); }); },
removeRecordImage(i) { this.openConfirmDlg({ title: "删除图片", content: "确定删除这张图片吗?", countdown: true, action: () => { this.recordForm.images.splice(i, 1); } }); },
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images, current: i }); },
async doSaveRecord() { if (this.isUploading) return; this.recordSaving = true; try { const payload = { remark: this.recordForm.remark.trim(), images: this.recordForm.images }; if (this.recordForm.recordId) await put(`/records/${this.recordForm.recordId}`, payload); else await patch(`/tasks/${this.recordPopup.task.id}/records`, payload); uni.showToast({ title: "已保存", icon: "success" }); this.closeRecordPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.recordSaving = false; } },
// 🖼️ 后端返回的是相对路径(如 /api/v1/upload/files/xxx.jpg),直接丢给 <image> 会破图,
// 这里补全域名 → 完整可访问 URL。逻辑与 records.vue / TaskTreeNode.vue 保持一致。
imageUrl(url) {
if (!url) return "";
if (url.startsWith("http")) return url;
const domain = getBaseUrl().replace(/\/api.*$/, '');
return domain + (url.startsWith("/") ? url : "/" + url);
},
// 📷 通用选图上传:压缩后逐个上传,URL 累积进 target.images,
// 过程中用 target.pendingCount 显示 ⏳ 占位(追加记录 / 驳回共用)。
// ⚠️ 失败必须显式提示 + 确保图片不进 images 数组,绝不静默吞掉:
// 工人会以为图传好了,提交上去才发现缺图,而任务已流转出去。
async pickAndUploadImages(target) {
const maxSlots = 9 - (target.images.length + target.pendingCount);
if (maxSlots <= 0) return;
const chooseRes = await new Promise((resolve, reject) => { uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject }); }).catch(() => null);
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
const compressedPaths = [];
for (const p of chooseRes.tempFilePaths) {
try { const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); }); compressedPaths.push(compressed.tempFilePath); } catch {}
}
if (!compressedPaths.length) {
// 压缩全军覆没(内存不足/格式异常)也必须说一声,不能悄悄什么都没发生
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
return;
}
this.isUploading = true;
target.pendingCount += compressedPaths.length;
// 🚀 有界并发上传(utils/upload.js):9 张图并行补位,不再串行干等。
// onEachDone 对每张图恰好回调一次 —— 成功的推入 images 供预览,
// 失败的保持不推入(不给"图片已传好"的错觉),两者都回收一个 ⏳ 占位。
const { failed, total } = await uploadImages(compressedPaths, (url) => {
if (url) target.images.push(url);
target.pendingCount--;
});
this.isUploading = false;
if (failed > 0) {
uni.showToast({
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
icon: "none",
duration: 3000,
});
}
},
handleChooseImage() { return this.pickAndUploadImages(this.recordForm); },
handleChooseRejectImage() { return this.pickAndUploadImages(this.rejectForm); },
/**
* 删除一张记录图。
*
* 已落库的图(编辑历史记录)删掉是不可逆的,保留 5 秒倒计时防误触;
* 本次刚选、尚未提交的图只存在内存里,删错了重新拍一张即可,没必要卡 5 秒。
*/
removeRecordImage(i) {
const isUnsaved = i >= (this.recordForm.savedCount || 0);
this.openConfirmDlg({
title: "删除图片",
content: "确定删除这张图片吗?",
countdown: !isUnsaved,
action: () => { this.recordForm.images.splice(i, 1); },
});
},
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images.map((u) => this.imageUrl(u)), current: i }); },
removeRejectImage(i) { this.rejectForm.images.splice(i, 1); },
previewRejectImage(i) { uni.previewImage({ urls: this.rejectForm.images.map((u) => this.imageUrl(u)), current: i }); },
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
isUploaded(url) { return isUploadedUrl(url); },
/**
* 某张记录图能否删除。
*
* 本次会话新选、尚未提交的图(下标 >= savedCount)**永远可删** —— 它只存在于
* 内存里,删掉不产生任何服务端影响。canDeleteImage 那道「任务已定稿 / 不是我的活」
* 的闸门本意是保护已落库的历史记录,不该连带把工人刚选错、想撤掉的候选图一起锁死。
* (此前这里直接写 v-if="canDeleteImage",导致在已完成任务上打开「记录/拍照」,
* 选完图后根本没有删除入口。)
*/
canDeleteRecordImage(i) { return this.canDeleteImage || i >= (this.recordForm.savedCount || 0); },
async doSaveRecord() { if (this.isUploading) return; this.recordSaving = true; try { const payload = { remark: this.recordForm.remark.trim(), images: this.recordForm.images }; if (this.recordForm.recordId) await put(`/records/${this.recordForm.recordId}`, payload); else await patch(`/tasks/${this.recordPopup.task.id}/records`, payload); uni.showToast({ title: "已保存", icon: "success" }); this.closeRecordPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, this.recordForm.recordId ? "更新记录" : "保存记录"); } finally { this.recordSaving = false; } },
async handleTaskAction({ task, type, record }) {
if (type === "record") { this.openRecordPopup(task); return; }
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
if (type === "end") { this.confirmEndBranch(task); return; }
if (type === "recall") { this.confirmRecall(task); return; }
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); if (!this.processOptions.length) this.processOptions = ["🏭 入库 (virtual_warehouse)", ...TASK_NAME_OPTIONS]; } finally { uni.hideLoading(); } }
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); this.processOptions = ["🏭 入库 (virtual_warehouse)", ...this.availableTaskOptions]; } finally { uni.hideLoading(); } }
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
this.rejectForm = { images: [], pendingCount: 0 }; this.isUploading = false;
this.transferForm = { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" };
this.spawnForm = { assignee_id: "", remark: "" };
},
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
async doDeleteRecord(record) { this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", countdown: true, action: async () => { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch (e) { uni.showToast({ title: (e && e.data && e.data.detail) || "删除失败", icon: "none" }); } } }); },
confirmEndBranch(task) { this.openConfirmDlg({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, countdown: true, action: () => this.doEndBranch(task) }); },
confirmRecall(task) { this.openConfirmDlg({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, countdown: true, action: () => this.doRecall(task) }); },
async doRecall(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/recall?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "已撤回", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
async doEndBranch(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/end?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "分支已结束", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
async doRecall(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/recall?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "已撤回", icon: "success" }); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "撤回转交"); } },
async doEndBranch(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/end?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "分支已结束", icon: "success" }); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "结束分支"); } },
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
// ═══ 网络级失败的识别与统一处置(防"超时后连点"造成重复流转) ═══
// 🌐 判定「网络级失败」:uni.request 的 fail 回调 = 压根没拿到后端响应
// (超时 / 断网 / DNS 失败),与 4xx/5xx 有本质区别 —— 后者后端明确应答过,
// 而前者后端很可能已经把动作执行成功了,只是响应没回来。
isNetworkFailure(e) {
if (!e) return false;
if (e.isNetworkError === true) return true;
if (e.statusCode) return false; // 有 HTTP 状态码 = 后端应答过,不是网络级
const msg = String(e.errMsg || e.message || "");
return /timeout|network|request:fail/i.test(msg);
},
// 🛡️ 网络级失败的统一处置:
// 1) 关闭弹窗 —— 强行打断工人的连点,否则第二次点下去就是重复驳回/重复转交;
// 2) 强提示(模态,必须手动确认)—— 告诉他"可能已生效",别再点;
// 3) 静默拉取真实状态 —— 防止继续基于过期数据产生脏操作。
// 返回 true 表示已按网络级失败处理,调用方无需再兜底。
handleNetworkFailure(e, actionLabel) {
if (!this.isNetworkFailure(e)) return false;
this.closeActionPopup();
this.closeRecordPopup();
uni.showModal({
title: "网络超时",
content: `未收到服务器响应,「${actionLabel}」可能已生效。已为你刷新最新状态,请确认后再操作。`,
showCancel: false,
confirmText: "知道了",
});
this.refreshProductSilently();
return true;
},
// 静默刷新产品详情:不置 loading,避免打断视线(网络故障后自动纠偏用)
async refreshProductSilently() {
const sn = this.product && this.product.serial_number;
if (!sn) return;
try { this.product = await get(`/products/scan/${sn}`); } catch (e) { console.error("[refresh] 静默刷新失败:", e); }
},
// ═══ 双重确认倒计时(防误触) ═══
// 首次点击进入 5 秒倒计时:期间确认按钮虚化禁用(点不了),只有「取消」可用;
// 5 秒结束后确认按钮解锁,点击才真正执行。
@ -428,14 +673,34 @@ export default {
this.confirmDlg.visible = false;
this.confirmDlg.action = null;
},
async doReceive() { this.actionLoading = true; try { const remark = this.receiveRemark.trim() || undefined; const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/receive?operator_id=${encodeURIComponent(opId)}`, { remark, task_name: this.receiveTaskName }); uni.showToast({ title: "已接收", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
async doReject() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim() }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
async doReceive() { this.actionLoading = true; try { const remark = this.receiveRemark.trim() || undefined; const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/receive?operator_id=${encodeURIComponent(opId)}`, { remark, task_name: this.receiveTaskName }); uni.showToast({ title: "已接收", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "接收任务"); } finally { this.actionLoading = false; } },
// 驳回:reason 必填,images 选填(编号错误等场景允许空数组)
async doReject() { if (this.isUploading) return; this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim(), images: this.rejectForm.images }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "驳回任务"); } finally { this.actionLoading = false; } },
// 转交 — 互斥选择
selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; },
toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) this.transferForm.selectedUserId = ""; },
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; } },
selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; this.transferForm.isFinishDirect = false; },
toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) { this.transferForm.selectedUserId = ""; this.transferForm.isFinishDirect = false; } },
// 🏁 直接完结:与前两者互斥。不发往仓库 → 后端落入"无下家"分支,overall_status 原样保留
toggleFinishDirect() { this.transferForm.isFinishDirect = !this.transferForm.isFinishDirect; if (this.transferForm.isFinishDirect) { this.transferForm.selectedUserId = ""; this.transferForm.isWarehouse = false; } },
async doTransfer() {
this.actionLoading = true;
const { isWarehouse, isFinishDirect } = this.transferForm;
try {
const note = this.transferForm.note.trim() || undefined;
// 🏁 直接完结:next_tasks 留空 + finish_directly=true,
// 后端据此跳过分支解析,只闭环任务、不改产品宏观状态(不会变成"待仓库收货")
const payload = isFinishDirect
? { next_tasks: [], finish_directly: true, note }
: { next_tasks: [{ task_name: isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认", assignees: isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId] }], note };
await post(`/tasks/${this.actionPopup.task.id}/transfer`, payload);
uni.showToast({ title: isFinishDirect ? "已直接完结" : (isWarehouse ? "已入库" : "转交成功"), icon: "success" });
this.closeActionPopup();
this.doQuery(this.product.serial_number);
} catch (e) {
this.handleNetworkFailure(e, isFinishDirect ? "直接完结" : (isWarehouse ? "入库" : "转交"));
} 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 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 (e) { this.handleNetworkFailure(e, "派发协助分支"); } finally { this.actionLoading = false; } },
// 💬 留言板
async fetchMessages() { if (!this.product?.id) return; try { const res = await get(`/products/${this.product.id}/messages`); this.messages = res || []; const key = `msg_seen_${this.product.id}`; this.lastMsgSeenAt = uni.getStorageSync(key) || ''; 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); } },
@ -479,7 +744,9 @@ export default {
</script>
<style scoped>
.page-container { min-height: 100vh; display: block; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 24px; overflow-y: auto; }
/* 根容器只负责背景与内边距,不设固定高度、不做内部滚动 —— 内容自然撑开,
整页滚动完全交给原生 Page 层,避免与页面下拉刷新手势打架。 */
.page-container { min-height: 100vh; display: block; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 24px; }
.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); }
@ -490,6 +757,10 @@ export default {
.overall-outbound { color: #4f46e5; } /* 已出库:靛蓝 */
.overall-warehouse { color: #ea580c; } /* 待仓库收货:橙 */
.overall-arrow { font-size: 12px; color: #9ca3af; }
/* 🔧 售后回流标识:紫底白字(生产阶段不打标)
不用红色——红色在本系统是「驳回/危险」语义,售后只是另一条流转支线。 */
.life-badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; flex-shrink: 0; }
.life-badge-after { background: #9333ea; color: #ffffff; }
.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; }
@ -562,10 +833,13 @@ export default {
.btn-disabled { background: #9ca3af; opacity: 0.5; }
.mb-bottom-anchor { height: 1px; }
.required { color: #ef4444; }
.optional { color: #9ca3af; font-weight: 400; font-size: 12px; }
.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; }
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
.img-thumb { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
.img-frame { width: 160rpx; height: 160rpx; }
.img-thumb { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; }
.img-cell-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
.img-loading-text { font-size: 36rpx; }
.img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }

View File

@ -75,13 +75,44 @@ export default {
},
handleSearch() { this.doQuery(this.serialNumber.trim()); },
// 📷 从扫码原始文本中提取设备身份证(SN)
// 二维码内容有两种可能,必须都兼容:
// ① 裸 SN → ABC1234567890DEF
// ② 带域名的完整 URL → https://track.iris-rs.cn/sn/ABC1234567890DEF?from=label
// 旧逻辑只是「去掉非字母数字后截前 16 位」,扫到 ② 时会把 "https"、域名
// 一起当成 SN 的前半段,截出来是个完全不存在的号,永远提示"未找到该产品"。
extractSerial(raw) {
const text = String(raw || "").trim();
if (!text) return "";
// 关键一步:先把 "协议://主机名" 整段剥掉,否则域名会被当成候选 SN
// (如 trackbackirisrscn 这种 17 位串,恰好能通过长度校验)。
let body = text;
const origin = text.match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]+/i);
if (origin) body = text.slice(origin[0].length);
// 在剩余内容里找长度 >= 8 的连续字母数字串(身份证至少 8 位)。
// 取最长的一段;等长时取靠后的 —— URL 里 SN 通常在路径末尾。
const runs = body.match(/[a-zA-Z0-9]{8,}/g) || [];
if (!runs.length) return "";
let best = runs[0];
for (const run of runs) {
if (run.length >= best.length) best = run;
}
return best.slice(0, 16);
},
// 📷 全屏相机扫码
handleScanCamera() {
uni.scanCode({
onlyFromCamera: true,
scanType: ["qrCode", "barCode"],
success: (res) => {
const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
const sn = this.extractSerial(res.result);
if (!sn) {
uni.showToast({ title: "无法识别二维码内容,请确认扫的是设备标签", icon: "none", duration: 2500 });
return;
}
this.doQuery(sn);
},
fail: (err) => {

View File

@ -69,7 +69,10 @@
<textarea v-model="editForm.remark" class="edit-textarea" placeholder="填写备注说明" :maxlength="2000" />
<view class="edit-imgs">
<view v-for="(img, i) in editForm.images" :key="i" class="edit-img-cell">
<image :src="imageUrl(img)" mode="aspectFill" class="edit-img" @tap="previewEditImage(i)" />
<view class="success-badge-wrapper edit-img-frame">
<image :src="imageUrl(img)" mode="aspectFill" class="edit-img" @tap="previewEditImage(i)" />
<view v-if="isUploaded(img)" class="success-badge" />
</view>
<text class="edit-img-del" @tap.stop="removeEditImage(i)">✕</text>
</view>
<view v-for="n in editForm.pendingCount" :key="'p'+n" class="edit-img-cell edit-img-loading"><text class="edit-img-loading-text">⏳</text></view>
@ -86,6 +89,7 @@
<script>
import request, { get, put, getBaseUrl } from "../../utils/request";
import { uploadImages, isUploadedUrl } from "../../utils/upload";
export default {
data() {
@ -170,6 +174,11 @@ export default {
uni.previewImage({ urls: fullUrls, current: index });
},
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
isUploaded(url) {
return isUploadedUrl(url);
},
formatTime(t) {
if (!t) return "";
const d = new Date(t);
@ -212,24 +221,28 @@ export default {
compressedPaths.push(compressed.tempFilePath);
} catch {}
}
if (!compressedPaths.length) return;
if (!compressedPaths.length) {
// 压缩全军覆没(内存不足/格式异常)也必须说一声,不能悄悄什么都没发生
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
return;
}
this.isUploading = true;
this.editForm.pendingCount += compressedPaths.length;
for (const path of compressedPaths) {
const url = await this.uploadFile(path);
// 🚀 有界并发上传(utils/upload.js):9 张图并行补位,不再串行干等。
// onEachDone 对每张图恰好回调一次 —— 成功的推入 images 供预览,
// 失败的保持不推入(不给"图片已传好"的错觉),两者都回收一个 ⏳ 占位。
const { failed, total } = await uploadImages(compressedPaths, (url) => {
if (url) this.editForm.images.push(url);
this.editForm.pendingCount--;
}
this.isUploading = false;
},
uploadFile(filePath) {
return new Promise((resolve) => {
uni.uploadFile({
url: getBaseUrl() + "/upload/", filePath, name: "file",
success(res) { try { const data = JSON.parse(res.data); resolve(data.url || null); } catch { resolve(null); } },
fail: () => resolve(null),
});
});
this.isUploading = false;
if (failed > 0) {
uni.showToast({
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
icon: "none",
duration: 3000,
});
}
},
async doSaveEdit() {
if (this.isUploading) return;
@ -348,7 +361,9 @@ export default {
.edit-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; box-sizing: border-box; }
.edit-imgs { display: flex; flex-wrap: wrap; margin-top: 10px; }
.edit-img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
.edit-img { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
.edit-img-frame { width: 160rpx; height: 160rpx; }
.edit-img { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; }
.edit-img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
.edit-img-loading-text { font-size: 36rpx; }
.edit-img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }

View File

@ -32,6 +32,11 @@
</view>
<view class="card-time">创建: {{ formatTime(task.created_at) }}</view>
</view>
<!-- ⬇️ 触底加载状态:让工人知道"到底了"还是"还在拉" -->
<view v-if="loadingMore" class="load-more">加载中...</view>
<view v-else-if="!hasMore && tasks.length > 0" class="load-more">— 没有更多了 —</view>
<view v-else-if="hasMore && tasks.length > 0" class="load-more">上拉加载更多</view>
</template>
</view>
</template>
@ -54,6 +59,11 @@ export default {
tasks: [],
loading: true,
currentUser: null,
// 📄 分页状态:此前硬编码 limit:100 一次性拉全量,任务多了会拖慢首屏
page: 1,
pageSize: 20,
hasMore: true,
loadingMore: false,
};
},
computed: {
@ -64,7 +74,21 @@ export default {
},
onShow() {
this.loadCurrentUser();
this.fetchTasks();
this.fetchTasks({ reset: true });
},
// 🚀 下拉刷新:回到第 1 页并清空现有列表重载(分页后必须重置,否则新旧页会错位)
async onPullDownRefresh() {
try {
this.loadCurrentUser();
await this.fetchTasks({ reset: true });
} finally {
uni.stopPullDownRefresh(); // 无论成败都要收起动画,否则会一直挂着
}
},
// 🚀 触底加载下一页
onReachBottom() {
if (this.loading || this.loadingMore || !this.hasMore) return;
this.loadMore();
},
methods: {
statusLabel(s) {
@ -95,20 +119,51 @@ export default {
if (u) this.currentUser = typeof u === "string" ? JSON.parse(u) : u;
} catch {}
},
async fetchTasks() {
this.loading = true;
// 排序:主干任务在前,协助分支在后;同组内按创建时间升序
compareTask(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) - new Date(b.created_at);
},
/** 拉取一页任务;reset=true 表示回到第 1 页并清空重载(下拉刷新 / 首次进入) */
async fetchTasks({ reset = false } = {}) {
if (reset) {
this.page = 1;
this.hasMore = true;
this.tasks = [];
}
if (reset) this.loading = true;
const target = this.page;
try {
const username = this.currentUser?.username || "";
const res = await get("/tasks/", { assignee_id: username, limit: 100 });
this.tasks = (res.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) - new Date(b.created_at);
const res = await get("/tasks/", {
assignee_id: username,
skip: (target - 1) * this.pageSize,
limit: this.pageSize,
});
} catch { this.tasks = []; }
finally { this.loading = false; }
const batch = res.tasks || [];
// 按 id 去重后追加:翻页期间若有新任务插入,分页边界会错位导致重复项
const seen = new Set(this.tasks.map((t) => t.id));
this.tasks = this.tasks.concat(batch.filter((t) => !seen.has(t.id))).sort(this.compareTask);
// 后端给了 total 就以它为准;否则退回「本页是否满员」判断
this.hasMore = res.total != null
? this.tasks.length < res.total
: batch.length >= this.pageSize;
if (this.hasMore) this.page = target + 1;
} catch (e) {
console.error("[tasks] 拉取任务失败:", e);
if (reset) this.tasks = [];
this.hasMore = false;
} finally {
if (reset) this.loading = false;
}
},
async loadMore() {
this.loadingMore = true;
try { await this.fetchTasks(); } finally { this.loadingMore = false; }
},
goDetail(task) {
const sn = task.product_sn || task.product_id;
@ -155,4 +210,7 @@ export default {
.s-gray { background: #f3f4f6; color: #6b7280; }
.card-meta { font-size: 12px; color: #6b7280; display: flex; gap: 12px; }
.card-time { font-size: 11px; color: #9ca3af; margin-top: 4px; }
/* 触底加载状态 */
.load-more { text-align: center; font-size: 12px; color: #9ca3af; padding: 16px 0 4px; }
</style>

View File

@ -0,0 +1,24 @@
/**
* 全局环境配置 — 后端域名/接口地址的唯一来源。
*
* 此前 request.js 与 App.vue 各自硬编码了一份生产域名,改域名要改两处,
* 漏一处就会出现「接口通了但 OTA 检查更新接不上」这类极难排查的偏差,
* 故统一收口到本文件。新增环境地址一律加在这里,不要在业务代码里写死。
*/
// 生产环境 API 地址
//
// ⚠️ HTTPS 说明(2026-09 实测):生产域名 track_back.iris-rs.cn 的 443 端口
// 是通的、也确实配有证书,但证书【已过期】—— 现在切 https 会让 App 的
// 全部请求直接失败。待运维完成证书续期后,只需把下面 PROD_URL 与
// LOCAL_URL 的协议头改成 https:// 即可,业务代码无需任何改动。
// 续期前请勿改动,保持 http:// 以保证可用性。
export const PROD_URL = "http://track_back.iris-rs.cn/api/v1";
// 本地开发地址 — 真机调试时指向开发机局域网 IP
export const LOCAL_URL = "http://192.168.9.80:8011/api/v1";
// 生产基础地址:供只认「生产环境」的场景直接引用
export const BASE_URL = PROD_URL;
export default { PROD_URL, LOCAL_URL, BASE_URL };

View File

@ -0,0 +1,91 @@
/**
* 生命周期阶段(lifecycle_phase)与工序选项隔离 — 移动端口径
*
* ⚠️ 与后端 backend/app/core/lifecycle.py、PC 端 frontend/src/constants/task.ts
* 是同一份词表,改动请三处同步。
*
* 售后回流阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
* 「测试 / 维修」,选中售后专属工序即代表设备进入售后生命周期。
*
* 本模块只负责"该给操作员看哪些选项"(体验层);
* 真正的拦截在后端 task_service._enforce_step_isolation(防伪造传参)。
*/
export const LIFECYCLE_PHASE = {
PRODUCTION: "PRODUCTION",
AFTER_SALES: "AFTER_SALES",
};
/** 售后专属工序名 — 选中即代表设备进入售后生命周期 */
export const STEP_SHIP_TEST = "发货测试";
export const STEP_AFTER_SALES_REPAIR = "售后维修";
/**
* 绝对物理终态之一:设备已发货出库。
*
* 🔧 出库设备也要按售后环节给选项:设备出库后仍可能被叫回来补做
* 「发货测试 / 售后维修」,但后端已不再因「正常已出库做质检」而把
* lifecycle_phase 翻成 AFTER_SALES(避免不可回退的售后烙印)。
* 若选项渲染仍只认 phase,工人就永远选不到那两个工序 —— 鸡生蛋死锁。
*/
export const OUTBOUND_OVERALL = "已出库";
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
// ── 生产制造阶段合法工序(原有词表,一字未改)──
export const PRODUCTION_TASK_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
export const PRODUCTION_OVERALL_OPTIONS = [
"备货", "生产", "测试", "维修", "在库", "已入库", "已出库",
];
// ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
export const AFTER_SALES_TASK_OPTIONS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库"];
export const AFTER_SALES_OVERALL_OPTIONS = [
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "已入库", "已出库",
];
/**
* 接收任务时可选工序。
*
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**:
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES,之后下拉自动收窄。
*
* @param phase lifecycle_phase
* @param hasHistory 是否有历史任务
* @param overallStatus 产品当前宏观状态 — 【已出库】同样走售后词表,见下方说明
*/
export function taskOptionsFor(phase, hasHistory = true, overallStatus = null) {
// 🔧 出库设备与售后生命周期同等待遇(2026-09-17 修复「鸡生蛋」死锁):
// 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备
// 翻成 AFTER_SALES。若这里仍只认 phase,工人就永远选不到
// 「发货测试 / 售后维修」—— 而选不到就无法进入售后,phase 也就永远不变。
if (
phase === LIFECYCLE_PHASE.AFTER_SALES ||
String(overallStatus || "").trim() === OUTBOUND_OVERALL
) {
return AFTER_SALES_TASK_OPTIONS;
}
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS;
}
/** 宏观状态可选值 — 同上,含已入库/已出库等终态 */
export function overallOptionsFor(phase, hasHistory = true) {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_OVERALL_OPTIONS;
if (!hasHistory) return [...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_OVERALL_OPTIONS;
}
/**
* 生命周期标签 — 只在设备确实处于售后回流环节时返回:
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
* → 红底白字,文案即工序名。
* 生产制造阶段一律返回 null(不打标),避免与售后设备混淆。
*/
export function lifecycleBadge(overallStatus, phase) {
if (phase !== LIFECYCLE_PHASE.AFTER_SALES) return null;
const step = String(overallStatus || "").trim();
if (step !== STEP_SHIP_TEST && step !== STEP_AFTER_SALES_REPAIR) return null;
return { label: step, phase: LIFECYCLE_PHASE.AFTER_SALES, cls: "life-badge-after" };
}

View File

@ -0,0 +1,400 @@
/**
* OTA 热更新 — 版本检测 + WGT 下载 + 安装。App 端专属能力的唯一来源。
*
* 原先这套逻辑整个长在 App.vue 的 methods 里(checkUpdate / parseVersionCode /
* downloadAndInstall / downloadWgt / installWgt / otaFailed),个人中心的
*「设置 → 检查更新」想手动触发一次就得把 App.vue 的方法捞出来,做不到。
* 抽到这里后两处共用同一份实现,改版本比对规则或下载策略只需改这里。
*
* 调用方:
* - App.vue onLaunch / onShow 静默自动检查(受 5 分钟节流)
* - settings.vue 「检查更新」手动检查(manual = true,跳过节流并给出明确反馈)
*/
import { getBaseUrl } from "./request";
/** 自动检查的节流窗口:5 分钟内不重复检查,避免 onShow 频繁触发弹窗骚扰 */
const THROTTLE_MS = 5 * 60 * 1000;
/** 上次检查时间戳(模块级单例,等价于原 App.vue 的 this._lastUpdateCheck) */
let lastCheckAt = 0;
/** 版本号→数字: "T1.0.10" → 1*100+10 = 110 */
export function 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);
}
/**
* 检查是否有新版本。
*
* @param {{manual?: boolean}} [opts]
* manual = true 时(设置页手动触发):跳过节流、显示「检查中」、
* 并在「已是最新 / 检查失败」时给出明确提示 —— 用户主动点的按钮
* 必须有个回音,不能像自动检查那样静默收场。
* @returns {Promise<"latest"|"available"|"skipped"|"unsupported"|"error">}
*/
export async function checkAppUpdate({ manual = false } = {}) {
// #ifndef APP-PLUS
// 非 App 环境(H5 / 小程序)没有 plus.runtime,热更新无从谈起
if (manual) {
uni.showToast({ title: "当前环境不支持热更新", icon: "none", duration: 2500 });
}
return "unsupported";
// #endif
// #ifdef APP-PLUS
const now = Date.now();
if (!manual && lastCheckAt && now - lastCheckAt < THROTTLE_MS) {
console.log("[OTA] 距上次检查不足5分钟,跳过");
return "skipped";
}
lastCheckAt = now;
// 🔧 appWgtVersion 跟随 WGT 更新,解析算法: major*100 + lastNum
// "T1.0.1"→101, "T1.0.10"→110, "T1.0.99"→199
//
// ⚠️ 这两个字段必须分开看,别只看合并后的值:
// appWgtVersion = 已安装的 wgt 资源版本(热更新成功后会变)
// appVersion = APK 基座版本(重装多少次热更新都不会变)
// 若 appWgtVersion 为空,说明热更新从未装成功过,此时取到的是 APK 的
// 静态版本号,表现就是"更新提示反复弹、版本号永远不动"。
const sysInfo = uni.getSystemInfoSync();
const wgtVersion = sysInfo.appWgtVersion || "";
const apkVersion = sysInfo.appVersion || "";
const wgtVer = wgtVersion || apkVersion || "0";
const currentVersionCode = parseVersionCode(wgtVer);
// 🔧 复用 getBaseUrl():与业务请求永远指向同一个后端(含 App 内动态切换的环境)
const baseUrl = getBaseUrl();
console.log(
"[OTA] wgt版本:", wgtVersion || "(空,说明热更新从未装成功)",
"| APK版本:", apkVersion,
"→ 取用:", wgtVer, "=", currentVersionCode
);
if (manual) uni.showLoading({ title: "检查更新中...", mask: true });
return new Promise((resolve) => {
uni.request({
url: `${baseUrl}/app/check-update`,
method: "GET",
timeout: 8000,
success: (res) => {
if (res.statusCode !== 200) {
if (manual) {
uni.hideLoading();
uni.showToast({ title: `检查更新失败 (${res.statusCode})`, icon: "none" });
}
resolve("error");
return;
}
const data = res.data;
if (!data || !data.wgt_url) {
console.log("[OTA] 服务端无可用更新包");
if (manual) {
uni.hideLoading();
uni.showToast({ title: "当前已是最新版本", icon: "none" });
}
resolve("latest");
return;
}
const serverVersionCode = data.version_code || 0;
console.log("[OTA] 服务端版本:", data.version, "| 数字版本:", serverVersionCode);
// 客户端自行对比:服务端 > 本地 = 需要更新
if (serverVersionCode <= currentVersionCode) {
console.log("[OTA] 已是最新版本,无需更新");
if (manual) {
uni.hideLoading();
uni.showToast({ title: "当前已是最新版本", icon: "none" });
}
resolve("latest");
return;
}
console.log("[OTA] 发现新版本:", data.version);
if (manual) uni.hideLoading();
downloadAndInstall(data.wgt_url, data.version, data.description);
resolve("available");
},
fail: (err) => {
console.log("[OTA] 版本检测网络失败,跳过");
if (manual) {
uni.hideLoading();
uni.showToast({
title: err && err.errMsg && /timeout/i.test(err.errMsg) ? "检查更新超时" : "网络连接失败",
icon: "none",
});
}
resolve("error");
},
});
});
// #endif
}
function downloadAndInstall(wgtUrl, newVersion, description) {
if (!wgtUrl) {
console.log("[OTA] 无 WGT 下载地址");
return;
}
if (typeof plus === "undefined") {
console.log("[OTA] 非 App 环境,跳过安装");
return;
}
const content = description
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
: `发现新版本 ${newVersion},是否立即更新?`;
uni.showModal({
title: "版本更新",
content,
confirmText: "立即更新",
cancelText: "稍后再说",
success: (modalRes) => {
if (!modalRes.confirm) return;
downloadWgt(wgtUrl, newVersion);
},
});
}
/**
* 下载 WGT 更新包。
*
* 为什么不用 uni.downloadFile(旧实现的坑):
* plus.runtime.install 是**靠文件扩展名**区分 wgt / apk 的,而
* uni.downloadFile 返回的 tempFilePath 不保证带 `.wgt` 后缀,路径不对时
* 安装会被直接拒绝 —— 且旧代码失败只打 console.error,工人只看到
* 「点了立即更新没反应」。这里改用 plus.downloader 并显式指定带后缀的
* 落盘路径;写到 _doc/(应用私有目录)也顺带避开 Android 10+ 分区存储限制。
* (注意:本函数走的是 plus.downloader,不是 uni.downloadFile —— 后者没有
* 可用的落盘路径控制,正是当初被换掉的原因。)
*
* ⚠️ 两道内容校验的由来("invalid LOC header" 事故):
* plus.runtime.install 拿到文件就按 ZIP 解压,文件不是合法 wgt 时抛的是
* "invalid LOC header (bad signature)" 这种底层报错,工人只看到一句看不懂的
* 英文。而拿到非 wgt 内容的情形并不罕见:静态服务器把 404 配成返回首页、
* 反向代理吐 JSON 错误体、SSO 把请求 302 到登录页 —— 这些**都可能带着
* HTTP 200 回来**,只判断 status === 200 根本拦不住。
* 故:下载前探测响应头,下载后校验 ZIP 魔数,两道都过了才交给 install。
*/
async function downloadWgt(wgtUrl, newVersion) {
// ── 第一道:下载前探测(状态码 / Content-Type / 体积)──
// 目的是在浪费流量之前就拦下明显的错误页,并给出人话错误
const probeError = await probeWgtUrl(wgtUrl);
if (probeError) {
otaFailed(probeError);
return;
}
// 文件名必须带 .wgt 后缀 —— 这是 install 能识别为 wgt 资源包的前提。
// 从下载地址取文件名(去掉 query),拿不到合法后缀时退回默认名。
const rawName = String(wgtUrl).split("?")[0].split("/").pop() || "";
const fileName = /\.wgt$/i.test(rawName) ? rawName : "__UNI__B572616.wgt";
const savePath = "_doc/" + fileName;
console.log("[OTA] 开始下载:", wgtUrl, "→", savePath);
let lastPct = -1;
let task = null;
const onFinished = async (d, status) => {
uni.hideLoading();
console.log("[OTA] 下载结束, status =", status, "| 文件:", d && d.filename);
// 非 2xx 一律当失败:404=包没传上去,401/403=下载地址需要鉴权
if (status !== 200) {
const hint =
status === 404 ? "更新包不存在,请联系管理员确认是否已上传"
: status === 401 || status === 403 ? "更新包下载被拒绝(无权限),请联系管理员"
: "请稍后重试";
otaFailed(`更新包下载失败(HTTP ${status}):${hint}`);
return;
}
// 没拿到落盘路径就没法安装 —— 明确报出来,别让它以"没反应"收场
if (!d || !d.filename) {
otaFailed("下载已完成但未生成本地文件,请重试");
return;
}
// 截断检测:服务端中途断开时 status 也可能是 200,但文件是不完整的
if (d.totalSize > 0 && d.downloadedSize > 0 && d.downloadedSize < d.totalSize) {
otaFailed(`更新包下载不完整(${d.downloadedSize}/${d.totalSize} 字节),请重试`);
return;
}
// ── 第二道:ZIP 魔数校验(拦下所有「HTTP 200 但内容不是 wgt」的情况)──
const contentError = await verifyWgtFile(d.filename);
if (contentError) {
otaFailed(contentError);
return;
}
installWgt(d.filename, newVersion);
};
try {
task = plus.downloader.createDownload(wgtUrl, { filename: savePath }, onFinished);
// 进度反馈:每跨 10% 刷新一次提示(过于频繁会卡顿)
task.addEventListener("statechanged", (d) => {
if (!d || d.state !== 3) return; // 3 = 下载中
if (!d.totalSize || d.totalSize <= 0) return;
const pct = Math.floor((d.downloadedSize / d.totalSize) * 100);
if (pct >= lastPct + 10) {
lastPct = pct - (pct % 10);
uni.showLoading({ title: `下载中 ${pct}%`, mask: true });
}
});
} catch (e) {
console.error("[OTA] 创建下载任务异常:", e);
otaFailed("无法创建下载任务:" + ((e && e.message) || "未知原因"));
return;
}
uni.showLoading({ title: "下载中 0%", mask: true });
task.start();
}
/**
* 下载前探测更新包地址 —— 在消耗流量之前拦下「错误页」。
*
* 用 HEAD 只取响应头,不下载正文。核对三件事:
* 1. statusCode 必须是 200(404 / 401 / 403 直接判失败)
* 2. Content-Type 不能是 JSON / HTML —— 那说明拿回来的是错误页而不是安装包
* 3. Content-Length 不能小得离谱(合法 wgt 不可能只有几百字节)
*
* @returns {Promise<string>} 空串 = 通过;非空 = 给用户看的失败原因
*/
function probeWgtUrl(wgtUrl) {
return new Promise((resolve) => {
uni.request({
url: wgtUrl,
method: "HEAD",
timeout: 8000,
success: (res) => {
const header = res.header || {};
const contentType = String(header["Content-Type"] || header["content-type"] || "");
const contentLength = Number(header["Content-Length"] || header["content-length"] || 0);
if (res.statusCode !== 200) {
resolve(
res.statusCode === 404 ? "更新包不存在,请联系管理员确认是否已上传"
: res.statusCode === 401 || res.statusCode === 403 ? "更新包下载被拒绝(无权限),请联系管理员"
: `更新包地址不可访问(HTTP ${res.statusCode})`
);
return;
}
if (/json|html|text\/plain/i.test(contentType)) {
resolve(`更新包地址返回的是 ${contentType},不是安装包 —— 请检查服务端配置`);
return;
}
if (contentLength > 0 && contentLength < 1024) {
resolve(`更新包体积异常(仅 ${contentLength} 字节),疑似错误页面`);
return;
}
// Content-Type / Content-Length 都可能被服务端省略,此处只做「明显不对」的拦截
console.log("[OTA] 预检通过 | Content-Type:", contentType || "(未返回)", "| 长度:", contentLength || "(未返回)");
resolve("");
},
fail: (err) => {
// HEAD 不被支持(部分静态服务器返回 405)或探测请求本身超时:
// 不阻断下载 —— 还有第二道魔数校验兜底,没必要因此卡死更新
console.warn("[OTA] 预检请求失败,跳过预检,改由下载后校验兜底:", err && err.errMsg);
resolve("");
},
});
});
}
/**
* 校验已下载的文件确实是 WGT —— 读取文件头 4 字节比对 ZIP 魔数。
*
* WGT 就是 ZIP 包,合法文件必定以 "50 4B 03 04" 开头(ASCII 即 "PK\x03\x04")。
* 这正是 install 报 "invalid LOC header (bad signature)" 时会去校验的东西,
* 我们提前自己验一遍,就能把底层报错换成一句人话。
*
* 实现上用 readAsDataURL 而非读二进制:base64 后 4 字节是 "UEsD",比对前缀
* 即可,避开了 plus.io.FileReader 对二进制读取支持不一致的问题。
*
* @returns {Promise<string>} 空串 = 通过;非空 = 给用户看的失败原因
*/
function verifyWgtFile(filePath) {
return new Promise((resolve) => {
try {
plus.io.resolveLocalFileSystemURL(
filePath,
(entry) => {
entry.file(
(file) => {
// slice 不可用的老机型上放弃校验(不阻断更新),交给 install 自己报错
if (typeof file.slice !== "function") {
console.warn("[OTA] 当前环境不支持文件切片,跳过魔数校验");
resolve("");
return;
}
const reader = new plus.io.FileReader();
reader.onloadend = (e) => {
const result = String((e.target && e.target.result) || "");
if (/;base64,UEsD/.test(result)) {
console.log("[OTA] 更新包文件头校验通过 (ZIP signature OK)");
resolve("");
} else {
resolve("下载到的文件不是有效的更新包(文件头异常),请联系管理员重新打包上传");
}
};
reader.onerror = () => {
console.warn("[OTA] 读取更新包文件头失败,跳过校验");
resolve("");
};
reader.readAsDataURL(file.slice(0, 4));
},
() => resolve("")
);
},
() => resolve("")
);
} catch (e) {
console.warn("[OTA] 魔数校验异常,跳过:", e);
resolve("");
}
});
}
/** 安装已下载的 WGT —— 成败都要在界面上说清楚 */
function installWgt(filePath, newVersion) {
console.log("[OTA] 开始安装:", filePath);
uni.showLoading({ title: "安装中...", mask: true });
plus.runtime.install(
filePath,
{ force: true },
() => {
uni.hideLoading();
console.log("[OTA] 安装成功:", newVersion, "→ 即将重启");
uni.showToast({ title: "更新完成,即将重启", icon: "none", duration: 2500 });
setTimeout(() => {
plus.runtime.restart();
}, 2500);
},
(err) => {
uni.hideLoading();
console.error("[OTA] 安装失败:", JSON.stringify(err));
otaFailed(
`更新包安装失败:${(err && (err.message || err.code)) || "未知原因"}`
);
}
);
}
/** 更新失败必须在界面上说出来 —— 只打日志等于让工人对着「点了没反应」干瞪眼 */
function otaFailed(msg) {
uni.showModal({
title: "更新失败",
content: `${msg}\n\n请确认网络正常后重试;若反复失败请联系管理员。`,
showCancel: false,
confirmText: "知道了",
});
}

View File

@ -7,8 +7,8 @@
* 3. 生产默认 PROD_URL
*/
const PROD_URL = "http://track_back.iris-rs.cn/api/v1";
const LOCAL_URL = "http://192.168.9.80:8011/api/v1";
// 域名/环境地址统一来自 utils/config.js,勿在此处硬编码
import { PROD_URL, LOCAL_URL } from "./config";
// ============================================================
// 环境 URL(自动感知:缓存配置 > NODE_ENV > 生产默认)
@ -82,6 +82,39 @@ async function refreshAccessToken() {
});
}
// ============================================================
// 错误文案提取 — 把后端 detail 统一转成能读的中文,杜绝 [object Object]
// ============================================================
//
// FastAPI 的 detail 有三种形态,直接丢给 showToast 会变成 [object Object]:
// 1. 字符串: {"detail": "任务状态为 COMPLETED,无法驳回"}
// 2. 校验错误数组(422): {"detail": [{"loc": ["body","images"], "msg": "Value error, 驳回必须...", "type": "value_error"}]}
// 3. 对象: {"detail": {"msg": "..."}}
export function extractErrorDetail(data, fallback = "") {
const detail = data && data.detail;
if (!detail) return (data && data.msg) || fallback;
if (typeof detail === "string") return detail || fallback;
if (Array.isArray(detail)) {
const msgs = detail.map((item) => {
if (typeof item === "string") return item;
if (!item || typeof item !== "object") return "";
// Pydantic v2 自定义校验器抛的错带 "Value error, " 前缀,读起来噪音大,去掉
const msg = String(item.msg || item.message || "").replace(/^Value error,\s*/, "");
const field = Array.isArray(item.loc)
? item.loc.filter((p) => !["body", "query", "path"].includes(p)).join(".")
: "";
return field && msg ? `${field}: ${msg}` : msg;
}).filter(Boolean);
return msgs.length ? msgs.join(";") : fallback;
}
if (typeof detail === "object") {
return detail.msg || detail.message || JSON.stringify(detail);
}
return String(detail) || fallback;
}
export default function request(options) {
return new Promise((resolve, reject) => {
const url = options.url.startsWith("http") ? options.url : getBaseUrl() + options.url;
@ -137,13 +170,35 @@ export default function request(options) {
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 }); }
// silent:调用方自行兜底提示时(如 feedback 的 404 回退 mock)跳过通用 toast,
// 否则用户会先看到一句「请求失败 (404)」,再看到「感谢反馈」,自相矛盾。
if (!options.silent) {
if (code === 403) uni.showToast({ title: extractErrorDetail(res.data, "无权操作"), icon: "none", duration: 3000 });
else if (code === 400) uni.showToast({ title: extractErrorDetail(res.data, "请求参数有误"), icon: "none", duration: 2500 });
else if (code === 409) uni.showToast({ title: extractErrorDetail(res.data, "操作冲突"), icon: "none", duration: 3000 });
else if (code === 422) {
// 表单/参数校验失败(Pydantic)—— detail 是校验项数组,必须解析出 msg 再提示
uni.showToast({ title: extractErrorDetail(res.data, "提交内容不符合要求"), icon: "none", duration: 3000 });
}
else uni.showToast({ title: extractErrorDetail(res.data, `请求失败 (${code})`), icon: "none", duration: 3000 });
}
reject(res);
},
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
fail(err) {
// ⚠️ fail = 压根没拿到后端响应(超时/断网),与 4xx/5xx 有本质区别:
// 后端可能已经执行成功,只是响应没回来。故保留原始 errMsg 并打上标记,
// 让上层能识别出「网络级失败」从而打断用户的连点重试。
const errMsg = (err && (err.errMsg || err.message)) || "";
const isTimeout = /timeout/i.test(errMsg);
if (!options.silent) {
uni.showToast({ title: isTimeout ? "网络超时,请稍后重试" : "网络连接失败", icon: "none" });
}
const e = new Error(errMsg || "network");
e.errMsg = errMsg;
e.isNetworkError = true;
e.isTimeout = isTimeout;
reject(e);
},
});
});
}

View File

@ -0,0 +1,101 @@
/**
* 图片上传工具 — 超时与并发策略的唯一来源(追加记录 / 驳回弹窗共用)。
*
* 此前两个页面各写一份 uni.uploadFile 封装,行为容易漂移;且都是串行上传,
* 9 张图要排队一张张传完,弱网下工人要干等半分钟以上。
*/
import { getBaseUrl, extractErrorDetail } from "./request";
/** 单张上传超时(毫秒)—— 弱网下防止请求永久挂起,卡住整个上传流程 */
export const UPLOAD_TIMEOUT = 15000;
/**
* 上传并发数。
* 不用「9 张一起上」的无限并发:弱网带宽本就紧张,同时打满会互相挤占、
* 整体反而更慢,还容易触发服务端限流。3 是等待时间与成功率的折中点。
*/
export const UPLOAD_CONCURRENCY = 3;
/**
* 上传单个文件。
* @param {string} filePath 本地临时文件路径
* @returns {Promise<string>} 成功时 resolve 后端返回的可访问 URL
* @throws 失败时 reject —— 不 resolve(null) 静默丢弃,调用方必须显式处理
*/
export function uploadImage(filePath) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: getBaseUrl() + "/upload/",
filePath,
name: "file",
timeout: UPLOAD_TIMEOUT,
success(res) {
let data = null;
try { data = JSON.parse(res.data); } catch { data = null; }
const url = data && data.url;
// HTTP 非 2xx 时(类型不支持 400 / 超过 50MB 413 等)后端返回的是
// {detail: ...} 而非 {url},必须当失败处理,否则图片会被静默丢弃
if (res.statusCode >= 200 && res.statusCode < 300 && url) resolve(url);
else reject(new Error(extractErrorDetail(data, `图片上传失败 (${res.statusCode || "无响应"})`)));
},
fail(err) {
const errMsg = (err && err.errMsg) || "";
reject(new Error(/timeout/i.test(errMsg) ? "图片上传超时" : errMsg || "图片上传失败"));
},
});
});
}
/**
* 判断一个图片项是否「已真正上传成功」。
*
* 依据来自本模块的契约:uploadImage() 成功时 resolve 的是后端返回的 URL
* (相对路径 /api/v1/upload/files/xxx,或带域名的 http(s)://…);
* 而未上传完 / 上传失败的项,拿到的是本地临时地址(blob:、file://、
* _doc/、wxfile:// 等)。
*
* 各页面的「成功绿勾」角标只应依据本函数显示 —— 绝不能因为「它出现在
* images 数组里」就当作成功,否则将来若有人把失败项也塞进数组(比如为了
* 支持重试而保留占位),角标就会撒谎。
*
* @param {*} url 图片项
* @returns {boolean}
*/
export function isUploadedUrl(url) {
if (typeof url !== "string" || !url) return false;
return url.startsWith("/") || /^https?:\/\//i.test(url);
}
/**
* 并发上传一组图片(有界并发池,逐个补位,不会一次性打满)。
*
* @param {string[]} filePaths 本地临时文件路径列表
* @param {(url: string|null, index: number) => void} [onEachDone]
* 每张图片「有结果」时回调一次(成功传 url,失败传 null)——
* 调用方据此回收 ⏳ 占位符,保证成功与失败都恰好回收一次。
* @returns {Promise<{failed: number, total: number}>}
*/
export async function uploadImages(filePaths, onEachDone) {
const queue = filePaths.map((filePath, index) => ({ filePath, index }));
let cursor = 0;
let failed = 0;
const worker = async () => {
while (cursor < queue.length) {
// 单线程 JS 里 cursor++ 在两次 await 之间是原子的,不会重复取到同一项
const { filePath, index } = queue[cursor++];
let url = null;
try {
url = await uploadImage(filePath);
} catch (e) {
failed++;
console.error("[upload] 图片上传失败:", e);
}
if (onEachDone) onEachDone(url, index);
}
};
const workerCount = Math.min(UPLOAD_CONCURRENCY, queue.length);
await Promise.all(Array.from({ length: workerCount }, worker));
return { failed, total: filePaths.length };
}

View File

@ -1,29 +1,117 @@
#!/bin/bash
# ============================================================
# uni-app 双向同步脚本
# WSL ←→ Windows G 盘,两边改代码互相跟随
# 用法: bash sync-watch.sh 停止: Ctrl+C
# track-uniapp 源码 → HBuilderX 项目 同步
#
# 方向:单向 WSL track-uniapp/src/ → G:\Track\track-app\track
#
# ⚠️ 为什么是「src/ → 项目根」且单向(2026-09-15 修正):
# HBuilderX 是以 G:\Track\track-app\track 为项目根打开的(经典布局,
# pages.json / App.vue / manifest.json 都在根目录),该目录就是
# track-uniapp/src/ 的**扁平镜像** —— 证据:test_wgt_local.bat 里写着
# "Please build WGT in HBuilderX first",且两侧文件时间戳逐项吻合。
#
# 旧版脚本按 track-uniapp/ ↔ track-app/track/ 双向对拷,方向完全错误:
# · 正向会把 src/ 整个塞成 track/src/,并用 WSL 的 index.html 覆盖
# HBuilderX 的入口文件;
# · 反向会把 Windows 项目根的文件搬回 WSL 根目录,让已清理的
# 「幽灵文件」复活。
# 故改为单向,且显式排除所有「只在 Windows 侧存在」的项。
#
# 用法:
# bash sync-watch.sh # 同步一次,然后持续监听(推荐)
# bash sync-watch.sh once # 只同步一次
# bash sync-watch.sh dry # 只预览差异,不落盘
# 停止: Ctrl+C
# ============================================================
SRC="/home/yueli/track/track-uniapp"
SRC="/home/yueli/track/track-uniapp/src"
DST="/mnt/g/Track/track-app/track"
EXCLUDES="--exclude node_modules --exclude .git --exclude dist --exclude unpackage --exclude .hbuilderx --exclude package.json --exclude package-lock.json --exclude vite.config.ts --exclude uni.scss --exclude uni.promisify.adaptor.js"
# ── 只在 Windows 侧存在、绝对不能被覆盖或删除的项 ──
EXCLUDES=(
# manifest.json 的版本号(T1.0.44)与存储权限只在 HBuilderX 里维护。
# WSL 那份停留在 T1.0.8,是过期副本 —— 同步过去会回退版本号并抹掉权限。
--exclude 'manifest.json'
# HBuilderX 经典布局特有,src/ 里没有对应物
--exclude 'index.html'
--exclude 'static/'
--exclude 'uni.scss'
--exclude 'uni.promisify.adaptor.js'
--exclude '.editorconfig'
--exclude '.gitignore'
--exclude '.hbuilderx/'
# HBuilderX 的 WGT 构建产物(约 216M)
--exclude 'unpackage/'
--exclude 'node_modules/'
--exclude '.git/'
# 手工备份(如 pages/scan/detail.vue.bak-20260914)
--exclude '*.bak-*'
--exclude '*.wgt'
)
# --delete 让「在 WSL 删掉的文件」在 Windows 侧同步消失(如已废弃的
# FlowTree.vue / TaskTreeNode.vue)。被 --exclude 命中的项不受 --delete
# 影响,所以上面的白名单不会被误删。
#
# 不用 -a:目标 /mnt/g 是 drvfs,POSIX 权限/属主都是合成的(恒为 777),
# -a 里的 -p/-o/-g 每次都会判定为「有差异」→ 无谓 chmod + 刷屏的 p 标记。
# 保留 -r(递归)+ -t(时间戳,两侧时间戳本就是同步依据)。
RSYNC_OPTS=(-rlt --delete "${EXCLUDES[@]}" --no-perms --no-owner --no-group)
do_sync() {
rsync -av $EXCLUDES "$SRC/" "$DST/" 2>/dev/null
sleep 0.5
rsync -av $EXCLUDES "$DST/" "$SRC/" 2>/dev/null
case "$1" in
dry)
echo "🔍 预览模式(不落盘):"
rsync "${RSYNC_OPTS[@]}" --dry-run --itemize-changes "$SRC/" "$DST/"
;;
*)
rsync "${RSYNC_OPTS[@]}" --itemize-changes "$SRC/" "$DST/"
local rc=$?
if [ $rc -eq 0 ]; then
echo " ↻ $(date +%H:%M:%S) 已同步"
else
# 不吞错误:旧脚本 2>/dev/null 把 rsync 的报错全丢了,出了问题只能干瞪眼
echo " ❌ $(date +%H:%M:%S) rsync 退出码 $rc —— 同步可能不完整,请检查"
fi
;;
esac
}
echo "🔁 双向同步 $SRC ↔ $DST"
echo " 按 Ctrl+C 停止"
case "$1" in
dry)
do_sync dry
exit 0
;;
once)
do_sync
exit 0
;;
esac
echo "🔁 单向同步 $SRC → $DST"
do_sync
echo "✅ 首次同步完成,开始监听..."
inotifywait -m -r -e modify,create,delete,move \
--exclude 'node_modules|.git|dist|unpackage|.hbuilderx' \
"$SRC" 2>/dev/null | while read -r dir action file; do
do_sync
echo " ↻ $(date +%H:%M:%S) 已同步"
done
if command -v inotifywait >/dev/null 2>&1; then
echo "👀 inotifywait 实时监听中(Ctrl+C 停止)"
inotifywait -m -r -e modify,create,delete,move \
--exclude 'node_modules|\.git|dist|unpackage|\.hbuilderx' \
"$SRC" | while read -r _dir _action _file; do
do_sync
done
else
# 旧脚本在这里静默失败过:inotifywait 没装,2>/dev/null 又把报错吞了,
# 表现为「脚本像是跑着,但改了代码 G 盘一直不更新」。改为明确提示 + 轮询兜底。
echo "⚠️ inotifywait 未安装,退回轮询模式(每 3 秒比对一次)"
echo " 想要实时监听: sudo apt install -y inotify-tools"
echo " Ctrl+C 停止"
last=""
while true; do
cur=$(find "$SRC" -type f -printf '%T@ %p\n' 2>/dev/null | sort | md5sum)
if [ "$cur" != "$last" ]; then
[ -n "$last" ] && do_sync
last="$cur"
fi
sleep 3
done
fi