Compare commits

...

12 Commits

Author SHA1 Message Date
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
33 changed files with 2062 additions and 1134 deletions

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,6 +53,26 @@ async def dashboard_stats(
return await get_dashboard_stats(db, since=since_dt, until=until_dt)
@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),
):
"""
一线工人个人效能 — 移动端「工作统计」页,支持自选时段。
- 生产战绩(完成 / 被驳回 / 参与产品)按本人名下任务归因;
- 操作统计(接收 / 转交 / 上传备注)与 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(20, ge=1, le=100),

View File

@ -26,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
@ -245,6 +269,115 @@ 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,
)
# ============================================================
# 在制品看板(永远实时)
# ============================================================

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

@ -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

@ -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

@ -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

@ -110,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="imageUrl(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>
@ -139,7 +139,7 @@
<!-- 📷 异常图片为选填:编号错误、选错工序等场景可不拍照 -->
<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"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRejectImage(i)" /><text class="img-del" @tap.stop="removeRejectImage(i)">✕</text></view>
<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>
@ -225,6 +225,7 @@
<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";
@ -243,7 +244,7 @@ export default {
showStatusPicker: false, editProductVisible: false, editForm: { order_no: "", external_serial: "" }, editSaving: false,
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: "",
@ -341,6 +342,12 @@ export default {
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; },
@ -390,8 +397,10 @@ export default {
openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); },
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
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 }; },
// 🖼️ 后端返回的是相对路径(如 /api/v1/upload/files/xxx.jpg),直接丢给 <image> 会破图,
// 这里补全域名 → 完整可访问 URL。逻辑与 records.vue / TaskTreeNode.vue 保持一致。
@ -402,7 +411,9 @@ export default {
return domain + (url.startsWith("/") ? url : "/" + url);
},
// 📷 通用选图上传:压缩后逐个上传,URL 累积进 target.images,
// 过程中用 target.pendingCount 显示 ⏳ 占位(追加记录 / 驳回共用)
// 过程中用 target.pendingCount 显示 ⏳ 占位(追加记录 / 驳回共用)。
// ⚠️ 失败必须显式提示 + 确保图片不进 images 数组,绝不静默吞掉:
// 工人会以为图传好了,提交上去才发现缺图,而任务已流转出去。
async pickAndUploadImages(target) {
const maxSlots = 9 - (target.images.length + target.pendingCount);
if (maxSlots <= 0) return;
@ -412,24 +423,62 @@ export default {
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) return;
if (!compressedPaths.length) {
// 压缩全军覆没(内存不足/格式异常)也必须说一声,不能悄悄什么都没发生
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
return;
}
this.isUploading = true;
target.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) 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); },
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); } }); },
/**
* 删除一张记录图。
*
* 已落库的图(编辑历史记录)删掉是不可逆的,保留 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 }); },
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; } },
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 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; }
@ -446,10 +495,46 @@ export default {
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 秒结束后确认按钮解锁,点击才真正执行。
@ -509,15 +594,15 @@ 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 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 {} finally { this.actionLoading = false; } },
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; } },
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 (e) { this.handleNetworkFailure(e, this.transferForm.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); } },
@ -561,7 +646,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); }
@ -652,7 +739,9 @@ export default {
.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,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 > 生产默认)
@ -170,17 +170,35 @@ export default function request(options) {
return;
}
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 });
// 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 });
}
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