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
This commit is contained in:
2026-08-11 15:56:25 +08:00
parent 0e3eb6a35a
commit 40a2d87345
4 changed files with 37 additions and 5 deletions

View File

@ -4,9 +4,12 @@ import uuid
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db from app.core.database import get_db
from app.models.notification import Notification 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 from app.schemas.notification import NotificationResponse, NotificationListResponse
router = APIRouter(prefix="/notifications", tags=["消息通知"]) router = APIRouter(prefix="/notifications", tags=["消息通知"])
@ -46,10 +49,28 @@ async def list_notifications(
result = await db.execute(stmt) result = await db.execute(stmt)
notifications = result.scalars().all() 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( return NotificationListResponse(
notifications=[ notifications=response_list,
NotificationResponse.model_validate(n) for n in notifications
],
total=total, total=total,
unread_count=unread_count, unread_count=unread_count,
) )

View File

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

View File

@ -103,7 +103,15 @@ async function handleCardTap(item) {
if (!item.is_read) { if (!item.is_read) {
try { await markNotificationRead(item.id); item.is_read = true; } catch {} 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}` }); uni.navigateTo({ url: `/pages/scan/detail?taskId=${item.task_id}` });
} }
} }

View File

@ -246,7 +246,7 @@ export default {
}, },
msgUnreadCount() { if (!this.lastMsgSeenAt) return this.messages.length; return this.messages.filter(m => m.created_at > this.lastMsgSeenAt).length; }, msgUnreadCount() { if (!this.lastMsgSeenAt) return this.messages.length; return this.messages.filter(m => m.created_at > this.lastMsgSeenAt).length; },
}, },
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 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
onShow() { if (this.product?.id) { this.fetchMessages(); } }, onShow() { if (this.product?.id) { this.fetchMessages(); } },
methods: { methods: {
@ -256,6 +256,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"; } }, 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; } }, 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() { findMyImmersiveTask() {
// 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定 // 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定
if (!this.product || !this.product.task_tree) return null; if (!this.product || !this.product.task_tree) return null;