Compare commits
7 Commits
0e3eb6a35a
...
3cb85f28b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb85f28b4 | |||
| b97dfaa95d | |||
| 9064973a3f | |||
| 01b8601dcd | |||
| 5b5f4a6b0e | |||
| 0a1c2f4dcf | |||
| 40a2d87345 |
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}%`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user