fix: 在制品按滞留时间降序 + 留言抽屉UI美化

1. 在制品排序: nulls_first → nulls_last
   已接收任务按receipt_at升序(最早=滞留最久)排最前
   未接收任务(null)排最后

2. 留言抽屉UI重设计:
   - 卡片式布局: 彩色头像圆+边框阴影+hover效果
   - 头像颜色按姓名首字自动分配7色
   - Input.Search替代普通Input+Search图标
   - 灰色背景区分卡片, 时间格式改为YYYY-MM-DD HH:mm
   - 手动分页按钮替代Antd分页器
This commit is contained in:
2026-08-12 13:59:39 +08:00
parent ca195192b6
commit 32c234fea1
2 changed files with 73 additions and 48 deletions

View File

@ -131,7 +131,7 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
select(Task, Product.serial_number)
.join(Product, Task.product_id == Product.id)
.where(Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]))
.order_by(Task.received_at.asc().nulls_first(), Task.created_at.asc())
.order_by(Task.received_at.asc().nulls_last(), Task.created_at.asc())
.limit(limit)
)
result = await db.execute(stmt)

View File

@ -1,10 +1,10 @@
import { useEffect, useState, useCallback } from "react";
import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle, Search,
RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Radio, DatePicker, Drawer, Input, List, Tag } from "antd";
import { Radio, DatePicker, Drawer, Input, Tag } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages,
@ -102,23 +102,38 @@ function WipRow({ t }: { t: WipTask }) {
);
}
// ─── 留言列表项 ───────────────────────────────────────────
// ─── 留言列表项(卡片式) ─────────────────────────────────
const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4"];
function MsgRow({ m }: { m: ProductMessageItem }) {
const t = m.created_at ? dayjs(m.created_at).format("MM-DD HH:mm") : "";
const t = m.created_at ? dayjs(m.created_at).format("YYYY-MM-DD HH:mm") : "";
const initial = (m.operator_name || "?").charAt(0);
const color = AVATAR_COLORS[initial.charCodeAt(0) % AVATAR_COLORS.length];
return (
<List.Item>
<div className="flex w-full flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{m.operator_name}</span>
<span className="text-[11px] text-gray-400">{t}</span>
<div className="mb-3 rounded-xl border border-gray-100 bg-white p-4 shadow-sm transition-shadow hover:shadow-md">
<div className="flex gap-3">
{/* 头像 */}
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
<div className="text-sm text-gray-600">{m.content}</div>
<div className="flex items-center gap-2 text-[11px] text-gray-400">
<Tag color="blue" className="text-[10px] leading-tight">{m.product_sn}</Tag>
<span className="truncate">{m.material_name}</span>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">{m.operator_name}</span>
<span className="shrink-0 text-xs text-gray-400">{t}</span>
</div>
<p className="mb-2 text-sm leading-relaxed text-gray-600">{m.content}</p>
<div className="flex items-center gap-2">
<Tag color="blue" className="m-0 text-xs">{m.product_sn}</Tag>
{m.material_name && (
<span className="truncate text-xs text-gray-400">{m.material_name}</span>
)}
</div>
</div>
</div>
</List.Item>
</div>
);
}
@ -372,43 +387,53 @@ export default function AdminDashboard() {
{/* ═══ 留言抽屉 ═══ */}
<Drawer
title={`💬 协同留言板(全厂 · ${msgTotal} 条)`}
title={<span className="text-base font-bold">💬 <span className="font-normal text-gray-400"> · {msgTotal} </span></span>}
open={msgDrawerOpen}
onClose={() => setMsgDrawerOpen(false)}
width={520}
styles={{ body: { padding: 0 } }}
width={560}
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
<div className="px-4 pt-4">
<Input
prefix={<Search className="h-4 w-4 text-gray-400" />}
placeholder="搜索 SN码 / 物料名称 / 留言人 / 内容"
value={msgKeyword}
onChange={e => setMsgKeyword(e.target.value)}
onPressEnter={() => onMsgSearch(msgKeyword)}
allowClear
onClear={() => onMsgSearch("")}
/>
</div>
<List
className="mt-3 px-4"
loading={msgLoading}
dataSource={msgData}
locale={{ emptyText: "暂无留言记录" }}
renderItem={(item: ProductMessageItem) => <MsgRow m={item} />}
pagination={{
total: msgTotal,
pageSize: 30,
size: "small",
onChange: (page, size) => {
loadMessages(msgKeyword);
// simplified: re-fetch with skip
fetchDashboardMessages(msgKeyword, (page - 1) * size, size)
.then(res => { setMsgData(res.items); setMsgTotal(res.total); })
.catch(() => {});
},
showTotal: (t) => `${t}`,
}}
<Input.Search
placeholder="搜索 SN码 / 物料名称 / 留言人 / 内容"
value={msgKeyword}
onChange={e => setMsgKeyword(e.target.value)}
onSearch={onMsgSearch}
allowClear
onClear={() => onMsgSearch("")}
enterButton
className="mb-4"
/>
{msgLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : msgData.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">
{msgKeyword ? "未找到匹配的留言" : "暂无留言记录"}
</div>
) : (
<div>
{msgData.map(m => <MsgRow key={m.id} m={m} />)}
{/* 简易分页 */}
{msgTotal > 30 && (
<div className="mt-4 flex items-center justify-center gap-2">
{Array.from({ length: Math.ceil(msgTotal / 30) }, (_, i) => (
<button
key={i}
onClick={() => {
fetchDashboardMessages(msgKeyword, i * 30, 30)
.then(res => { setMsgData(res.items); setMsgTotal(res.total); })
.catch(() => {});
}}
className="rounded-md border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-100"
>
{i + 1}
</button>
))}
</div>
)}
</div>
)}
</Drawer>
</div>
);