feat: 任务全景表格新增"最新动态"列
后端: - ProductResponse 新增 latest_record_time/content 字段 - get_all_products 批量查询活跃任务(WIP/PENDING)的最新 task_records - 使用窗口函数 ROW_NUMBER() PARTITION BY product_id 前端: - AdminTasksPage: grid-cols-12→grid-cols-[14] - 表头+数据行新增"最新动态"列 - Tooltip悬浮显示完整内容, 截断+灰字二级排版
This commit is contained in:
@ -55,6 +55,9 @@ class ProductResponse(BaseModel):
|
|||||||
overall_status: str | None = None
|
overall_status: str | None = None
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
# 🔧 最新动态 — 该产品活跃任务的最新记录
|
||||||
|
latest_record_time: datetime | None = None
|
||||||
|
latest_record_content: str | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@ -461,6 +461,32 @@ async def get_all_products(
|
|||||||
merged_location_ids = list(set(static_location_ids + dynamic_location_ids))
|
merged_location_ids = list(set(static_location_ids + dynamic_location_ids))
|
||||||
merged_name_map = _lookup_display_names(merged_location_ids)
|
merged_name_map = _lookup_display_names(merged_location_ids)
|
||||||
|
|
||||||
|
# 🔧 批量查询每个产品活跃任务的最新记录
|
||||||
|
latest_record_map: dict[uuid.UUID, tuple] = {}
|
||||||
|
if product_ids:
|
||||||
|
from app.models.task import TaskRecord as TR
|
||||||
|
wip_pending_ids = select(Task.id).where(
|
||||||
|
and_(
|
||||||
|
Task.product_id.in_(product_ids),
|
||||||
|
Task.status.in_(["WIP", "PENDING"]),
|
||||||
|
)
|
||||||
|
).subquery()
|
||||||
|
ranked = (
|
||||||
|
select(TR.task_id, TR.remark, TR.created_at, Task.product_id,
|
||||||
|
func.row_number().over(
|
||||||
|
partition_by=Task.product_id,
|
||||||
|
order_by=TR.created_at.desc()
|
||||||
|
).label("rn"))
|
||||||
|
.join(Task, TR.task_id == Task.id)
|
||||||
|
.where(Task.id.in_(select(wip_pending_ids.c.id)))
|
||||||
|
).subquery()
|
||||||
|
rec_result = await db.execute(
|
||||||
|
select(ranked.c.product_id, ranked.c.created_at, ranked.c.remark)
|
||||||
|
.where(ranked.c.rn == 1)
|
||||||
|
)
|
||||||
|
for row in rec_result:
|
||||||
|
latest_record_map[row[0]] = (row[1], row[2])
|
||||||
|
|
||||||
return [
|
return [
|
||||||
ProductResponse(
|
ProductResponse(
|
||||||
id=p.id,
|
id=p.id,
|
||||||
@ -474,10 +500,8 @@ async def get_all_products(
|
|||||||
category=p.category,
|
category=p.category,
|
||||||
material_type=p.material_type,
|
material_type=p.material_type,
|
||||||
parent_product_id=p.parent_product_id,
|
parent_product_id=p.parent_product_id,
|
||||||
# 🔧 当前位置:动态主干assignee优先 → 静态兜底
|
|
||||||
current_location_id=(
|
current_location_id=(
|
||||||
main_assignees.get(p.id) # 动态主干
|
main_assignees.get(p.id) or p.current_location_id
|
||||||
or p.current_location_id # 静态兜底
|
|
||||||
),
|
),
|
||||||
current_location_name=(
|
current_location_name=(
|
||||||
"仓库" if (main_assignees.get(p.id) or p.current_location_id) == "virtual_warehouse"
|
"仓库" if (main_assignees.get(p.id) or p.current_location_id) == "virtual_warehouse"
|
||||||
@ -489,6 +513,8 @@ async def get_all_products(
|
|||||||
overall_status=overall_names.get(p.id) or p.overall_status,
|
overall_status=overall_names.get(p.id) or p.overall_status,
|
||||||
status=p.status,
|
status=p.status,
|
||||||
created_at=p.created_at,
|
created_at=p.created_at,
|
||||||
|
latest_record_time=latest_record_map.get(p.id, (None, None))[0],
|
||||||
|
latest_record_content=latest_record_map.get(p.id, (None, None))[1],
|
||||||
)
|
)
|
||||||
for p in products
|
for p in products
|
||||||
]
|
]
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
Search, Loader2, Package, ChevronDown, ChevronRight,
|
Search, Loader2, Package, ChevronDown, ChevronRight,
|
||||||
Warehouse, GitBranch, X,
|
Warehouse, GitBranch, X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Tooltip } from "antd";
|
||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import { scanProduct } from "../../services/productApi";
|
import { scanProduct } from "../../services/productApi";
|
||||||
import {
|
import {
|
||||||
@ -368,14 +369,15 @@ export default function AdminTasksPage() {
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="border-t border-gray-100">
|
<div className="border-t border-gray-100">
|
||||||
{/* 表头 */}
|
{/* 表头 */}
|
||||||
<div className="grid grid-cols-12 gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500">
|
<div className="grid grid-cols-[14] gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500">
|
||||||
<div className="col-span-2">产品身份证</div>
|
<div className="col-span-2">产品身份证</div>
|
||||||
<div className="col-span-1">序列号</div>
|
<div className="col-span-1">序列号</div>
|
||||||
<div className="col-span-2">规格型号</div>
|
<div className="col-span-1">规格型号</div>
|
||||||
<div className="col-span-1">宏观状态</div>
|
<div className="col-span-1">宏观状态</div>
|
||||||
<div className="col-span-1">任务状态</div>
|
<div className="col-span-1">任务状态</div>
|
||||||
<div className="col-span-1">当前位置</div>
|
<div className="col-span-1">当前位置</div>
|
||||||
<div className="col-span-2">创建时间</div>
|
<div className="col-span-2">最新动态</div>
|
||||||
|
<div className="col-span-1">创建时间</div>
|
||||||
<div className="col-span-2">操作</div>
|
<div className="col-span-2">操作</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -391,14 +393,25 @@ export default function AdminTasksPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={p.id}>
|
<div key={p.id}>
|
||||||
<div className="grid grid-cols-12 gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50">
|
<div className="grid grid-cols-14 gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50">
|
||||||
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
|
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
|
||||||
<div className="col-span-1 font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>
|
<div className="col-span-1 font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>
|
||||||
<div className="col-span-2 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>
|
<div className="col-span-1 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>
|
||||||
<div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div>
|
<div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div>
|
||||||
<div className="col-span-1"><span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span></div>
|
<div className="col-span-1"><span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span></div>
|
||||||
<div className="col-span-1 text-xs text-gray-500 truncate">{p.current_location_id === "virtual_warehouse" ? (<span className="inline-flex items-center gap-1 text-purple-600">🏭 仓库</span>) : (p.current_location_name || p.current_location_id || "—")}</div>
|
<div className="col-span-1 text-xs text-gray-500 truncate">{p.current_location_id === "virtual_warehouse" ? (<span className="inline-flex items-center gap-1 text-purple-600">🏭 仓库</span>) : (p.current_location_name || p.current_location_id || "—")}</div>
|
||||||
<div className="col-span-2 text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>
|
{/* 最新动态 */}
|
||||||
|
<div className="col-span-2 text-xs">
|
||||||
|
{p.latest_record_time ? (
|
||||||
|
<Tooltip title={p.latest_record_content || ""}>
|
||||||
|
<div className="cursor-default">
|
||||||
|
<div className="text-[10px] text-gray-400">{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</div>
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-gray-600">{p.latest_record_content || "—"}</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : <span className="text-gray-300">—</span>}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleProductTree(p.serial_number)}
|
onClick={() => toggleProductTree(p.serial_number)}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ export interface ProductResponse {
|
|||||||
overall_status: string | null;
|
overall_status: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
latest_record_time: string | null;
|
||||||
|
latest_record_content: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MOM 物料选项 */
|
/** MOM 物料选项 */
|
||||||
|
|||||||
Reference in New Issue
Block a user