Compare commits

...

7 Commits

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

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

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

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

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

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

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

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

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

修复:
1. 后端 NotificationResponse 新增 product_serial_number 字段
2. 后端 notifications API 联表 tasks+products 批量填充 serial
3. 前端 notify/index.vue handleCardTap 优先用 product_serial_number 跳转,兜底从 content 正则解析
4. 前端 detail.vue onLoad 新增 taskId 兜底 → doQueryByTask 反查 product_sn
2026-08-11 15:56:25 +08:00
7 changed files with 141 additions and 77 deletions

View File

@ -4,9 +4,12 @@ import uuid
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.notification import Notification
from app.models.task import Task
from app.models.product import Product
from app.schemas.notification import NotificationResponse, NotificationListResponse
router = APIRouter(prefix="/notifications", tags=["消息通知"])
@ -46,10 +49,28 @@ async def list_notifications(
result = await db.execute(stmt)
notifications = result.scalars().all()
# 🚀 批量查询关联的 product_serial_number
task_ids = [n.task_id for n in notifications if n.task_id]
serial_map: dict[uuid.UUID, str] = {}
if task_ids:
task_result = await db.execute(
select(Task.id, Product.serial_number)
.join(Product, Task.product_id == Product.id)
.where(Task.id.in_(task_ids))
)
for row in task_result:
serial_map[row[0]] = row[1]
# 组装响应
response_list: list[NotificationResponse] = []
for n in notifications:
resp = NotificationResponse.model_validate(n)
if n.task_id and n.task_id in serial_map:
resp.product_serial_number = serial_map[n.task_id]
response_list.append(resp)
return NotificationListResponse(
notifications=[
NotificationResponse.model_validate(n) for n in notifications
],
notifications=response_list,
total=total,
unread_count=unread_count,
)

View File

@ -13,6 +13,7 @@ class NotificationResponse(BaseModel):
content: str
type: str
task_id: uuid.UUID | None = None
product_serial_number: str | None = None
is_read: bool
created_at: datetime

View File

@ -301,37 +301,40 @@ async def update_overall_status(
if not product:
raise HTTPException(status_code=404, detail=f"未找到序列号 {serial_number} 的产品")
# ── 权限校验 ──
if current_user:
user_role = current_user.get("role", "")
user_username = current_user.get("username", "")
# ── 权限校验(无 current_user 一律拒绝,杜绝空 dict 绕过)──
if not current_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
)
# SUPER_ADMIN 直接放行
if user_role == "SUPER_ADMIN":
pass
else:
# 检查当前用户是否是该产品主线任务的负责人
from sqlalchemy import or_
main_task_result = await db.execute(
select(Task).where(
Task.product_id == product.id,
Task.status.in_(["WIP", "PENDING"]),
or_(
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
),
).order_by(Task.created_at.desc()).limit(1)
user_role = current_user.get("role", "")
user_username = current_user.get("username", "")
# SUPER_ADMIN 直接放行
if user_role != "SUPER_ADMIN":
# 检查当前用户是否是该产品主线任务的负责人
from sqlalchemy import or_
main_task_result = await db.execute(
select(Task).where(
Task.product_id == product.id,
Task.status.in_(["WIP", "PENDING"]),
or_(
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
),
).order_by(Task.created_at.desc()).limit(1)
)
main_task = main_task_result.scalar_one_or_none()
has_permission = (
main_task is not None
and main_task.assignee_id == user_username
)
if not has_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
)
main_task = main_task_result.scalar_one_or_none()
has_permission = (
main_task
and main_task.assignee_id == user_username
)
if not has_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
)
product.overall_status = status_value
await db.commit()

View File

@ -22,6 +22,10 @@ export default {
onShow() {
console.log("App 显示");
this.updateTabBarBadge();
// 🚀 每次回到前台也检查更新,用户无需杀后台就能感知新版本
// #ifdef APP-PLUS
this.checkUpdate();
// #endif
},
onHide() {
console.log("App 隐藏");
@ -31,6 +35,14 @@ export default {
// 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();
@ -97,41 +109,35 @@ export default {
success: (modalRes) => {
if (!modalRes.confirm) return;
// 显示下载进度
uni.showLoading({ title: "下载 0%", mask: true });
// 🚀 使用原生等待框,原地更新文字,杜绝闪烁
plus.nativeUI.showWaiting("正在下载 0%");
const downloadTask = uni.downloadFile({
url: wgtUrl,
success: (downloadRes) => {
if (downloadRes.statusCode !== 200) {
uni.hideLoading();
plus.nativeUI.closeWaiting();
uni.showToast({ title: "下载失败", icon: "none" });
return;
}
uni.showLoading({ title: "安装中...", mask: true });
// 安装阶段 — 原生等待框直接更新文字,无闪烁
plus.nativeUI.showWaiting("正在安装...");
// 调用 plus.runtime.install 安装 WGT
plus.runtime.install(
downloadRes.tempFilePath,
{ force: true },
() => {
uni.hideLoading();
plus.nativeUI.closeWaiting();
console.log("[OTA] WGT 安装成功");
uni.showModal({
title: "更新完成",
content: "新版本已安装,重启后生效。是否立即重启?",
confirmText: "立即重启",
cancelText: "稍后",
success: (restartRes) => {
if (restartRes.confirm) {
plus.runtime.restart();
}
},
});
// 🚀 静默重启toast 提示后自动重启,无需用户杀后台
plus.nativeUI.toast("新版本已就绪,即将重启...");
setTimeout(() => {
plus.runtime.restart();
}, 2000);
},
(err) => {
uni.hideLoading();
plus.nativeUI.closeWaiting();
console.error("[OTA] 安装失败:", err.message);
uni.showToast({
title: "更新失败: " + (err.message || "未知错误"),
@ -142,19 +148,18 @@ export default {
);
},
fail: (err) => {
uni.hideLoading();
plus.nativeUI.closeWaiting();
console.error("[OTA] 下载失败:", err.errMsg);
uni.showToast({ title: "下载失败,请检查网络", icon: "none" });
},
});
// 下载进度回调
// 下载进度 — 稳定更新原生等待框文字,不会闪烁
if (downloadTask && downloadTask.onProgressUpdate) {
downloadTask.onProgressUpdate((res) => {
const pct = res.progress;
uni.showLoading({ title: `下载中 ${pct}%`, mask: true });
if (pct >= 100) {
uni.showLoading({ title: "安装中...", mask: true });
if (pct < 100) {
plus.nativeUI.showWaiting(`正在下载 ${pct}%`);
}
});
}

View File

@ -103,7 +103,15 @@ async function handleCardTap(item) {
if (!item.is_read) {
try { await markNotificationRead(item.id); item.is_read = true; } catch {}
}
if (item.task_id) {
// 🚀 优先使用 product_serial_number兜底从 content 中解析
let sn = item.product_serial_number || "";
if (!sn && item.content) {
const match = item.content.match(/\[([A-Za-z0-9]{8,16})\]/);
if (match) sn = match[1];
}
if (sn) {
uni.navigateTo({ url: `/pages/scan/detail?serial=${sn}` });
} else if (item.task_id) {
uni.navigateTo({ url: `/pages/scan/detail?taskId=${item.task_id}` });
}
}

View File

@ -121,22 +121,22 @@ export default {
allTasks.forEach(t => { if (isMainFn(t)) mains.push(t); });
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
// 🚀 预计算每个节点的子树高度(兄弟累加,用于防重叠占位
// 🚀 预计算每个节点的子树高度(兄弟累加取 max首子与父平齐
const calcSubtreeHeight = (nodeId) => {
const children = childMap[nodeId] || [];
if (children.length === 0) return CARD_H + VERTICAL_GAP;
const totalChildrenHeight = children.reduce((sum, c) => sum + calcSubtreeHeight(c.id), 0);
return CARD_H + VERTICAL_GAP + totalChildrenHeight;
return Math.max(CARD_H + VERTICAL_GAP, totalChildrenHeight);
};
const nodes = [];
// 🚀 递归放置算法:childNode.y = parentNode.y + CARD_H + VERTICAL_GAP
// 🚀 递归放置算法:首个子节点与父节点水平平齐
// 同级子节点按 index 错开 Y 轴,各占其子树高度防止重叠
const placeChildren = (parentNode, currentSide) => {
const children = childMap[parentNode.id] || [];
// 🚀 首个子节点 Y 起始:parent.y 正下方
let startY = parentNode.y + CARD_H + VERTICAL_GAP;
// 🚀 首个子节点 Y 起始:与父节点水平平齐
let startY = parentNode.y;
children.forEach((child, index) => {
const subtreeH = calcSubtreeHeight(child.id);
@ -152,7 +152,7 @@ export default {
? parentNode.x + CARD_W + GAP_X
: parentNode.x - CARD_W - GAP_X;
// 🚀 Y 轴:严格基于父节点向下延伸,同级按 index 错开
// 🚀 Y 轴:首子与父平齐,后续兄弟向下累加
const childY = startY;
const childNode = {
@ -168,8 +168,8 @@ export default {
// 递归放置孙子节点(沿相同方向)
placeChildren(childNode, side);
// 🚀 下一个兄弟节点跳到当前子树高度之后,严禁 Y 坐标重叠
startY += subtreeH + VERTICAL_GAP;
// 🚀 下一个兄弟节点跳到当前子树高度之后calcSubtreeHeight 已含 GAP
startY += subtreeH;
});
};
@ -194,7 +194,7 @@ export default {
// ============================================================
// 🚀 连线计算:中央 → 分支
// ============================================================
// 🔧 连线:卡片边缘到边缘,杜绝穿模
// 🔧 连线:卡片边缘到边缘,动态追踪 Y 坐标,杜绝箭头悬空
lines() {
const result = [];
const flatMap = {};
@ -206,10 +206,13 @@ export default {
if (!parent) return;
const isLeft = n.x < parent.x;
const startX = isLeft ? n.x + 160 : parent.x + 160;
const endX = isLeft ? parent.x : n.x;
const endX = isLeft ? parent.x : n.x;
// 🚀 动态计算真实的 Y 坐标对接点
const startY = isLeft ? n.y + 40 : parent.y + 40;
const endY = isLeft ? parent.y + 40 : n.y + 40;
result.push({
x1: startX, y1: parent.y + 40,
x2: endX, y2: parent.y + 40,
x1: startX, y1: startY,
x2: endX, y2: endY,
dashed: parent.status === 'COMPLETED' || parent.status === 'ARCHIVED',
});
});

View File

@ -10,7 +10,7 @@
<view class="overall-bar" @tap="handleOverallBarClick">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
<text v-if="product.task_tree && product.task_tree.length" class="overall-arrow"></text>
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow"></text>
</view>
<view class="card">
@ -245,8 +245,29 @@ export default {
return this.product ? find(this.product.task_tree) : false;
},
msgUnreadCount() { if (!this.lastMsgSeenAt) return this.messages.length; return this.messages.filter(m => m.created_at > this.lastMsgSeenAt).length; },
// 🔒 宏观状态修改权限:对齐后端 update_overall_status 的 main_task 判断标准
canEditOverallStatus() {
if (!this.currentUser) return false;
if (this.currentUser.role === 'SUPER_ADMIN') return true;
if (!this.product || !this.product.task_tree) return false;
let hasPermission = false;
const checkTask = (tasks) => {
if (!tasks || hasPermission) return;
for (const t of tasks) {
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
if (isMain && (t.status === 'WIP' || t.status === 'PENDING')) {
if (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername) {
hasPermission = true;
}
}
checkTask(t.child_tasks);
}
};
checkTask(this.product.task_tree);
return hasPermission;
},
},
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) this.doQuery(sn); },
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
onShow() { if (this.product?.id) { this.fetchMessages(); } },
methods: {
@ -256,6 +277,8 @@ export default {
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
// 🚀 从 taskId 反查 product_serial → 再 doQuery
async doQueryByTask(tid) { try { const task = await get(`/tasks/${tid}`); const sn = task?.product_sn || ""; if (sn) { this.doQuery(sn); } else { this.error = "未找到关联产品"; this.loading = false; } } catch { this.error = "任务查询失败"; this.loading = false; } },
findMyImmersiveTask() {
// 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定
if (!this.product || !this.product.task_tree) return null;
@ -284,7 +307,7 @@ export default {
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
onAssigneeChange(e) { const idx = e.detail.value; const u = this.users[idx]; if (u) { this.firstForm.assigneeIdx = idx; this.firstForm.assignee_id = u.username; this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`; } },
openCreateFirstTask() { this.isWarehouseTransfer = false; if (this.currentMode === 'tree') this.currentMode = 'workspace'; this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" }; this.createFirstVisible = true; },
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else { this.showStatusPicker = true; } },
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); },
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
@ -374,12 +397,12 @@ export default {
.overall-empty { color: #ef4444; }
.overall-arrow { font-size: 12px; color: #9ca3af; }
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; min-height: 120px; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-header-right { display: flex; align-items: center; gap: 8px; }
.card-title { font-size: 15px; font-weight: 700; }
.edit-btn { font-size: 18px; padding: 2px 6px; }
.mode-toggle { font-size: 13px; font-weight: 700; padding: 4px 10px; border-radius: 8px; background: #eff6ff; color: #2563eb; }
.print-label-btn { font-size: 12px; font-weight: 700; padding: 4px 8px; border-radius: 8px; background: #fef3c7; color: #b45309; margin-left: 4px; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
.card-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.card-title { font-size: 15px; font-weight: 700; flex-shrink: 0; }
.edit-btn { font-size: 18px; padding: 2px 6px; flex-shrink: 0; }
.mode-toggle { font-size: 12px; font-weight: 700; padding: 4px 8px; border-radius: 8px; background: #eff6ff; color: #2563eb; white-space: nowrap; flex-shrink: 0; }
.print-label-btn { font-size: 11px; font-weight: 700; padding: 4px 6px; border-radius: 8px; background: #fef3c7; color: #b45309; white-space: nowrap; flex-shrink: 0; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.label { font-size: 12px; color: #9ca3af; }
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }