feat: 看板重构 — 在制品看板 + 留言未读数

核心理念转变:
  旧: 最近操作历史(谁做了什么)
  新: 当前在制品状态(谁的活卡了多久)

后端:
  - DashboardStats 新增 unread_messages(协同留言总数)
  - 新增 GET /dashboard/wip-tasks(在制品端点)
  - 按滞留时间升序排列,PENDING/WIP 任务倒计时
  - 自动计算 duration_hours(已滞留小时数)

前端:
  - 品质卡片: 新增留言未读数(紫色)
  - 在制品看板: 替换原最近动态
    每行显示: 状态徽标 | 任务名+负责人+身份证 | 滞留时长
  - 滞留颜色: 48h+红 | 24h+橙 | 8h+黄 | <8h灰
  - 系统通知可点击跳转
This commit is contained in:
2026-08-12 13:40:08 +08:00
parent 00fae01eed
commit 39377ae5e4
4 changed files with 155 additions and 202 deletions

View File

@ -1,11 +1,10 @@
"""Dashboard API""" """Dashboard API"""
from datetime import datetime
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.services.dashboard_service import ( from app.services.dashboard_service import (
get_dashboard_stats, DashboardStats, get_dashboard_stats, DashboardStats,
get_recent_activity, RecentActivity, get_wip_tasks, WipTask,
) )
router = APIRouter(prefix="/dashboard", tags=["管理看板"]) router = APIRouter(prefix="/dashboard", tags=["管理看板"])
@ -16,14 +15,10 @@ async def dashboard_stats(db: AsyncSession = Depends(get_db)):
return await get_dashboard_stats(db) return await get_dashboard_stats(db)
@router.get("/recent-activity", response_model=list[RecentActivity]) @router.get("/wip-tasks", response_model=list[WipTask])
async def recent_activity( async def wip_tasks(
limit: int = Query(10, ge=1, le=50), limit: int = Query(20, ge=1, le=100),
since: str | None = Query(None, description="起始日期 ISO格式 如 2026-08-12T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO格式"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""最近任务动态 — 支持日期范围筛选""" """在制品看板 — 当前所有 PENDING/WIP 任务,按滞留时间排序"""
since_dt = datetime.fromisoformat(since) if since else None return await get_wip_tasks(db, limit)
until_dt = datetime.fromisoformat(until) if until else None
return await get_recent_activity(db, limit, since=since_dt, until=until_dt)

View File

@ -18,26 +18,30 @@ class DashboardStats(BaseModel):
tasks_completed: int tasks_completed: int
tasks_rejected: int tasks_rejected: int
tasks_rework: int tasks_rework: int
# 通知 # 通知 + 留言
unread_notifications: int = 0 unread_notifications: int = 0
unread_messages: int = 0
class RecentActivity(BaseModel): class WipTask(BaseModel):
action: str """在制品 — 当前卡在手里的任务"""
task_id: str
task_name: str task_name: str
operator: str assignee: str # 中文姓名
product_sn: str product_sn: str
time: str status: str # PENDING / WIP
remark: str | None = None received_at: str # 接收时间
duration_hours: float # 已滞留小时数
async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
from app.models.product import Product from app.models.product import Product
from app.models.task import ( from app.models.task import (
Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED,
TASK_STATUS_REJECTED, TASK_STATUS_ARCHIVED, TASK_STATUS_REJECTED,
) )
from app.models.notification import Notification from app.models.notification import Notification
from app.models.message import ProductMessage
p_total = await db.scalar(select(func.count(Product.id))) p_total = await db.scalar(select(func.count(Product.id)))
p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending")) p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending"))
@ -51,9 +55,11 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
t_rejected = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_REJECTED)) t_rejected = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_REJECTED))
t_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True))) t_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True)))
unread = await db.scalar( unread_notif = await db.scalar(
select(func.count(Notification.id)).where(Notification.is_read.is_(False)) select(func.count(Notification.id)).where(Notification.is_read.is_(False))
) )
# 留言板未读数 — 全局(所有产品下的留言总数)
unread_msg = await db.scalar(select(func.count(ProductMessage.id)))
return DashboardStats( return DashboardStats(
products_total=p_total or 0, products_total=p_total or 0,
@ -66,80 +72,71 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
tasks_completed=t_done or 0, tasks_completed=t_done or 0,
tasks_rejected=t_rejected or 0, tasks_rejected=t_rejected or 0,
tasks_rework=t_rework or 0, tasks_rework=t_rework or 0,
unread_notifications=unread or 0, unread_notifications=unread_notif or 0,
unread_messages=unread_msg or 0,
) )
async def get_recent_activity( async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
db: AsyncSession, limit: int = 10, """
since: datetime | None = None, until: datetime | None = None, 在制品看板 — 当前所有 PENDING/WIP 状态的任务,
) -> list[RecentActivity]: 按接收时间升序(最早接收的排最前 = 滞留最久)。
from app.models.task_log import TaskLog """
from app.models.task import Task from app.models.task import (
Task, TASK_STATUS_PENDING, TASK_STATUS_WIP,
)
from app.models.product import Product from app.models.product import Product
from app.core.time_utils import BEIJING_TZ from app.core.time_utils import get_beijing_time, BEIJING_TZ
stmt = ( stmt = (
select(TaskLog, Task.task_name, Product.serial_number, Task.assignee_id) select(Task, Product.serial_number)
.join(Task, TaskLog.task_id == Task.id)
.join(Product, Task.product_id == Product.id) .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())
.limit(limit)
) )
if since:
stmt = stmt.where(TaskLog.created_at >= since)
if until:
stmt = stmt.where(TaskLog.created_at <= until)
stmt = stmt.order_by(TaskLog.created_at.desc()).limit(limit)
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() rows = result.all()
# 收集 operator_id + 兜底 assignee_id 批量翻译中文姓名 # 收集 assignee_id 批量翻译中文姓名
raw_ids: set[str] = set() raw_ids = list({t.assignee_id for t, _ in rows if t.assignee_id})
for row in rows:
op = row[0].operator_id
if op:
raw_ids.add(op)
elif row[3]: # assignee_id 兜底
raw_ids.add(row[3])
name_map: dict[str, str] = {} name_map: dict[str, str] = {}
if raw_ids: if raw_ids:
from app.services.mom_cache import get_display_names from app.services.mom_cache import get_display_names
name_map = get_display_names(list(raw_ids)) name_map = get_display_names(raw_ids)
activities: list[RecentActivity] = [] now = get_beijing_time()
for log, task_name, product_sn, task_assignee in rows: wip_list: list[WipTask] = []
action_label = _action_label(log.action_type) for task, product_sn in rows:
# 强制转北京时间显示 # 计算滞留时长
t = log.created_at start = task.received_at or task.created_at
if t: if start:
if t.tzinfo is None: if start.tzinfo is None:
t = t.replace(tzinfo=BEIJING_TZ) start = start.replace(tzinfo=BEIJING_TZ)
else: else:
t = t.astimezone(BEIJING_TZ) start = start.astimezone(BEIJING_TZ)
time_str = t.strftime("%m-%d %H:%M") hours = round((now - start).total_seconds() / 3600, 1)
recv_str = start.strftime("%m-%d %H:%M")
else: else:
time_str = "" hours = 0
# operator_id: 优先显示中文姓名 → 英文用户名兜底 → 任务负责人兜底 → 空 recv_str = ""
op = log.operator_id or task_assignee or ""
op_display = name_map.get(op, op) wip_list.append(WipTask(
activities.append(RecentActivity( task_id=str(task.id),
action=action_label, task_name=task.task_name,
task_name=task_name or "", assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"),
operator=op_display,
product_sn=product_sn or "", product_sn=product_sn or "",
time=time_str, status=task.status,
remark=log.remark, received_at=recv_str,
duration_hours=hours,
)) ))
return activities return wip_list
def _action_label(action_type: str) -> str: def _action_label(action_type: str) -> str:
labels = { labels = {
"create": "创建任务", "create": "创建任务", "receive": "确认接收", "complete": "完成任务",
"receive": "确认接收", "transfer": "完工转交", "reject": "品质驳回", "end": "结束分支",
"complete": "完成任务",
"transfer": "完工转交",
"reject": "品质驳回",
"end": "结束分支",
"recall": "撤回转交", "recall": "撤回转交",
} }
return labels.get(action_type, action_type) return labels.get(action_type, action_type)

View File

@ -1,32 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle, Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Calendar, RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Clock, MessageCircle,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
fetchDashboardStats, fetchRecentActivity, fetchDashboardStats, fetchWipTasks,
type DashboardStats, type RecentActivity, type DashboardStats, type WipTask,
} from "../../services/dashboardApi"; } from "../../services/dashboardApi";
// ─── 日期筛选选项 ─────────────────────────────────────────
type DateRange = "today" | "7d" | "30d" | "all";
const DATE_OPTIONS: { key: DateRange; label: string }[] = [
{ key: "today", label: "今天" },
{ key: "7d", label: "近7天" },
{ key: "30d", label: "近30天" },
{ key: "all", label: "全部" },
];
function getSince(key: DateRange): string | undefined {
if (key === "all") return undefined;
const now = new Date();
now.setHours(0, 0, 0, 0);
if (key === "today") return now.toISOString();
now.setDate(now.getDate() - (key === "7d" ? 7 : 30));
return now.toISOString();
}
// ─── 进度条 ─────────────────────────────────────────────── // ─── 进度条 ───────────────────────────────────────────────
function ProgressBar({ a, b, c, total, labels }: { function ProgressBar({ a, b, c, total, labels }: {
a: number; b: number; c: number; total: number; a: number; b: number; c: number; total: number;
@ -39,7 +21,6 @@ function ProgressBar({ a, b, c, total, labels }: {
{ n: b, color: "bg-blue-500", label: labels[1] }, { n: b, color: "bg-blue-500", label: labels[1] },
{ n: c, color: "bg-emerald-500", label: labels[2] }, { n: c, color: "bg-emerald-500", label: labels[2] },
].filter(s => s.n > 0); ].filter(s => s.n > 0);
return ( return (
<div> <div>
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100"> <div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
@ -60,30 +41,47 @@ function ProgressBar({ a, b, c, total, labels }: {
); );
} }
// ─── 动态条目 ───────────────────────────────────────────── // ─── 滞留时间颜色 ─────────────────────────────────────────
function ActivityItem({ a }: { a: RecentActivity }) { function durationColor(h: number): string {
const iconMap: Record<string, string> = { if (h >= 48) return "text-red-600 bg-red-50";
"创建任务": "📋", "确认接收": "✅", "完成任务": "🏁", "完工转交": "🔄", if (h >= 24) return "text-orange-600 bg-orange-50";
"品质驳回": "❌", "结束分支": "🛑", "撤回转交": "↩️", if (h >= 8) return "text-amber-600 bg-amber-50";
}; return "text-gray-500 bg-gray-50";
}
function durationLabel(h: number): string {
if (h >= 48) return `${Math.round(h / 24)}`;
if (h >= 24) return `${Math.round(h / 24)}`;
if (h >= 1) return `${h}小时`;
return `${Math.round(h * 60)}分钟`;
}
// ─── 在制品条目 ───────────────────────────────────────────
function WipRow({ t }: { t: WipTask }) {
return ( return (
<div className="flex items-start gap-3 border-b border-gray-50 py-2.5 last:border-0"> <div className="flex items-center gap-3 border-b border-gray-50 py-2.5 last:border-0">
<span className="mt-0.5 text-base">{iconMap[a.action] || "📌"}</span> {/* 状态标识 */}
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
t.status === "WIP" ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"
}`}>
{t.status === "WIP" ? "进行中" : "待接收"}
</span>
{/* 任务信息 */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-gray-700">{a.action}</span> <span className="text-sm font-medium text-gray-700 truncate">{t.task_name}</span>
<span className="truncate text-xs text-gray-500">{a.task_name}</span> <span className="text-xs text-gray-400 shrink-0">{t.assignee}</span>
{a.remark && (
<span className="truncate text-[11px] text-gray-400"> {a.remark.slice(0, 30)}{a.remark.length > 30 ? "…" : ""}</span>
)}
</div> </div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-gray-400"> <div className="mt-0.5 flex items-center gap-2 text-[11px] text-gray-400">
<span>{a.operator || "—"}</span> <span className="font-mono">{t.product_sn}</span>
<span>·</span> {t.received_at && <span>: {t.received_at}</span>}
<span className="font-mono">{a.product_sn}</span>
<span className="ml-auto">{a.time}</span>
</div> </div>
</div> </div>
{/* 滞留时长 */}
<span className={`shrink-0 rounded-md px-2 py-1 text-xs font-bold ${durationColor(t.duration_hours)}`}>
<Clock className="mr-0.5 inline h-3 w-3" />
{durationLabel(t.duration_hours)}
</span>
</div> </div>
); );
} }
@ -91,29 +89,23 @@ function ActivityItem({ a }: { a: RecentActivity }) {
// ─── 主组件 ─────────────────────────────────────────────── // ─── 主组件 ───────────────────────────────────────────────
export default function AdminDashboard() { export default function AdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [activity, setActivity] = useState<RecentActivity[]>([]); const [wipTasks, setWipTasks] = useState<WipTask[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dateRange, setDateRange] = useState<DateRange>("today");
const navigate = useNavigate(); const navigate = useNavigate();
const loadData = (dr: DateRange) => { const loadData = () => {
const since = getSince(dr); setLoading(true);
Promise.all([ Promise.all([
fetchDashboardStats(), fetchDashboardStats(),
fetchRecentActivity(15, since), fetchWipTasks(20),
]) ])
.then(([s, a]) => { setStats(s); setActivity(a); setError(null); }) .then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); })
.catch(() => setError("加载失败,请确认后端已启动")) .catch(() => setError("加载失败,请确认后端已启动"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
useEffect(() => { loadData(dateRange); }, [dateRange]); useEffect(() => { loadData(); }, []);
const handleDateChange = (dr: DateRange) => {
setLoading(true);
setDateRange(dr);
};
// ── 加载态 ── // ── 加载态 ──
if (loading) { if (loading) {
@ -129,8 +121,7 @@ export default function AdminDashboard() {
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4" />{error || "数据为空"} <AlertCircle className="h-4 w-4" />{error || "数据为空"}
<button onClick={() => { setLoading(true); loadData(dateRange); }} <button onClick={loadData} className="ml-auto text-blue-600 underline"></button>
className="ml-auto text-blue-600 underline"></button>
</div> </div>
); );
} }
@ -138,34 +129,17 @@ export default function AdminDashboard() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ═══ 页头 ═══ */} {/* ═══ 页头 ═══ */}
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-bold text-gray-800">📊 </h2> <h2 className="text-xl font-bold text-gray-800">📊 </h2>
<p className="mt-0.5 text-sm text-gray-400"> <p className="mt-0.5 text-sm text-gray-400">
= = = =
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <button onClick={loadData}
{/* 日期筛选 */} className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-100">
<div className="flex rounded-lg border border-gray-200 bg-white p-0.5"> <RefreshCw className="h-3.5 w-3.5" />
{DATE_OPTIONS.map(opt => ( </button>
<button key={opt.key}
onClick={() => handleDateChange(opt.key)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
dateRange === opt.key
? "bg-blue-600 text-white shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{opt.label}
</button>
))}
</div>
<button onClick={() => { setLoading(true); loadData(dateRange); }}
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-100">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div> </div>
{/* ═══ 第1行4 张概览卡片 ═══ */} {/* ═══ 第1行4 张概览卡片 ═══ */}
@ -177,7 +151,7 @@ export default function AdminDashboard() {
<h3 className="text-sm font-semibold text-gray-700">📦 </h3> <h3 className="text-sm font-semibold text-gray-700">📦 </h3>
</div> </div>
<p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> </span></p> <p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> </span></p>
<p className="mb-3 text-[11px] text-gray-400"></p> <p className="mb-3 text-[11px] text-gray-400"> · /</p>
<ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed} <ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed}
total={stats.products_total} labels={["待流转", "流转中", "已完成"]} /> total={stats.products_total} labels={["待流转", "流转中", "已完成"]} />
</div> </div>
@ -189,43 +163,34 @@ export default function AdminDashboard() {
<h3 className="text-sm font-semibold text-gray-700">📋 </h3> <h3 className="text-sm font-semibold text-gray-700">📋 </h3>
</div> </div>
<p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> </span></p> <p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> </span></p>
<p className="mb-3 text-[11px] text-gray-400"></p> <p className="mb-3 text-[11px] text-gray-400"> · 1=N任务</p>
<ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed} <ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed}
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} /> total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} />
</div> </div>
{/* 品质 & 通知 — 通知可点击 */} {/* 品质 & 留言板未读 */}
<div className="rounded-xl bg-white p-5 shadow-sm"> <div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600" /> <AlertTriangle className="h-5 w-5 text-orange-600" />
<h3 className="text-sm font-semibold text-gray-700"> & </h3> <h3 className="text-sm font-semibold text-gray-700"> </h3>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-red-50 p-3 text-center"> <div className="rounded-lg bg-red-50 p-3 text-center">
<p className="text-2xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p> <p className="text-xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p>
<p className="text-[11px] text-red-500">/</p> <p className="text-[11px] text-red-500">/</p>
</div> </div>
<button <button onClick={() => navigate("/notifications")}
onClick={() => navigate("/notifications")} className="rounded-lg bg-blue-50 p-3 text-center hover:bg-blue-100 transition-colors border-0 cursor-pointer">
className="rounded-lg bg-blue-50 p-3 text-center hover:bg-blue-100 transition-colors cursor-pointer border-0" <p className="text-xl font-bold text-blue-600">{stats.unread_notifications}</p>
> <p className="text-[11px] text-blue-500"> </p>
<p className="text-2xl font-bold text-blue-600">{stats.unread_notifications}</p>
<p className="text-[11px] text-blue-500"> </p>
</button> </button>
</div> </div>
<div className="mt-3 flex justify-around border-t border-gray-100 pt-3"> <div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<div className="text-center"> <div className="flex items-center gap-1 text-xs text-gray-500">
<span className="text-lg font-bold text-red-600">{stats.tasks_rejected}</span> <MessageCircle className="h-3.5 w-3.5" />
<p className="text-[11px] text-gray-400"></p>
</div>
<div className="text-center">
<span className="text-lg font-bold text-orange-600">{stats.tasks_rework}</span>
<p className="text-[11px] text-gray-400"></p>
</div>
<div className="text-center">
<span className="text-lg font-bold text-blue-600">{stats.unread_notifications}</span>
<p className="text-[11px] text-gray-400"></p>
</div> </div>
<span className="text-lg font-bold text-purple-600">{stats.unread_messages}</span>
</div> </div>
</div> </div>
@ -250,26 +215,29 @@ export default function AdminDashboard() {
</div> </div>
</div> </div>
{/* ═══ 第2行最近动态 + 快捷入口 ═══ */} {/* ═══ 第2行在制品看板 + 快捷入口 ═══ */}
<div className="grid gap-4 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-3">
{/* 最近动态 */} {/* 在制品看板 — 替换原来的"最近动态" */}
<div className="rounded-xl bg-white p-5 shadow-sm lg:col-span-2"> <div className="rounded-xl bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700"> <h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<Calendar className="h-4 w-4" /> <Clock className="h-4 w-4 text-orange-500" />
</h3> </h3>
<span className="text-[11px] text-gray-400"> <span className="text-[11px] text-gray-400">
{dateRange === "today" ? "今天" : dateRange === "7d" ? "近7天" : dateRange === "30d" ? "近30天" : "全部"} · {wipTasks.length}
· {activity.length}
</span> </span>
</div> </div>
{activity.length === 0 ? ( {wipTasks.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-400"> <div className="py-10 text-center text-sm text-gray-400">🎉 </div>
{dateRange === "today" ? "今天暂无流转记录" : "该时间段暂无流转记录"}
</div>
) : ( ) : (
<div className="divide-y divide-gray-50"> <div>
{activity.map((a, i) => <ActivityItem key={i} a={a} />)} {/* 表头 */}
<div className="mb-1 flex items-center gap-3 text-[11px] font-medium text-gray-400">
<span className="w-14 shrink-0"></span>
<span className="flex-1"> · </span>
<span className="w-16 shrink-0 text-right"></span>
</div>
{wipTasks.map((t, i) => <WipRow key={i} t={t} />)}
</div> </div>
)} )}
</div> </div>
@ -291,9 +259,7 @@ export default function AdminDashboard() {
<button onClick={() => navigate("/notifications")} <button onClick={() => navigate("/notifications")}
className="flex w-full items-center justify-between rounded-lg bg-red-50 px-4 py-3 text-left text-sm font-medium text-red-700 hover:bg-red-100 transition-colors"> className="flex w-full items-center justify-between rounded-lg bg-red-50 px-4 py-3 text-left text-sm font-medium text-red-700 hover:bg-red-100 transition-colors">
<span className="flex items-center gap-2"><Bell className="h-4 w-4" /> <span className="flex items-center gap-2"><Bell className="h-4 w-4" />
{stats.unread_notifications > 0 {stats.unread_notifications > 0 ? `通知 (${stats.unread_notifications})` : "通知中心"}
? `未读通知 (${stats.unread_notifications})`
: "通知中心"}
</span> </span>
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</button> </button>
@ -306,10 +272,10 @@ export default function AdminDashboard() {
<div className="mt-5 rounded-lg bg-gray-50 p-3"> <div className="mt-5 rounded-lg bg-gray-50 p-3">
<p className="text-[11px] leading-relaxed text-gray-500"> <p className="text-[11px] leading-relaxed text-gray-500">
<strong>💡 = </strong><br /> <strong>💡 = </strong><br />
线 =
<strong className="text-gray-700"></strong> <span className="text-red-600 font-bold">48</span>
<span className="text-orange-600 font-bold">24</span>
</p> </p>
</div> </div>
</div> </div>

View File

@ -12,15 +12,17 @@ export interface DashboardStats {
tasks_rejected: number; tasks_rejected: number;
tasks_rework: number; tasks_rework: number;
unread_notifications: number; unread_notifications: number;
unread_messages: number;
} }
export interface RecentActivity { export interface WipTask {
action: string; task_id: string;
task_name: string; task_name: string;
operator: string; assignee: string;
product_sn: string; product_sn: string;
time: string; status: string;
remark: string | null; received_at: string;
duration_hours: number;
} }
export async function fetchDashboardStats(): Promise<DashboardStats> { export async function fetchDashboardStats(): Promise<DashboardStats> {
@ -28,14 +30,7 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
return data; return data;
} }
export async function fetchRecentActivity( export async function fetchWipTasks(limit = 20): Promise<WipTask[]> {
limit = 10, const { data } = await api.get<WipTask[]>("/dashboard/wip-tasks", { params: { limit } });
since?: string,
until?: string,
): Promise<RecentActivity[]> {
const params: Record<string, string | number> = { limit };
if (since) params.since = since;
if (until) params.until = until;
const { data } = await api.get<RecentActivity[]>("/dashboard/recent-activity", { params });
return data; return data;
} }