feat: 看板重构 — 在制品看板 + 留言未读数
核心理念转变:
旧: 最近操作历史(谁做了什么)
新: 当前在制品状态(谁的活卡了多久)
后端:
- DashboardStats 新增 unread_messages(协同留言总数)
- 新增 GET /dashboard/wip-tasks(在制品端点)
- 按滞留时间升序排列,PENDING/WIP 任务倒计时
- 自动计算 duration_hours(已滞留小时数)
前端:
- 品质卡片: 新增留言未读数(紫色)
- 在制品看板: 替换原最近动态
每行显示: 状态徽标 | 任务名+负责人+身份证 | 滞留时长
- 滞留颜色: 48h+红 | 24h+橙 | 8h+黄 | <8h灰
- 系统通知可点击跳转
This commit is contained in:
@ -1,11 +1,10 @@
|
||||
"""Dashboard API"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.database import get_db
|
||||
from app.services.dashboard_service import (
|
||||
get_dashboard_stats, DashboardStats,
|
||||
get_recent_activity, RecentActivity,
|
||||
get_wip_tasks, WipTask,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
|
||||
@ -16,14 +15,10 @@ async def dashboard_stats(db: AsyncSession = Depends(get_db)):
|
||||
return await get_dashboard_stats(db)
|
||||
|
||||
|
||||
@router.get("/recent-activity", response_model=list[RecentActivity])
|
||||
async def recent_activity(
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
since: str | None = Query(None, description="起始日期 ISO格式 如 2026-08-12T00:00:00"),
|
||||
until: str | None = Query(None, description="截止日期 ISO格式"),
|
||||
@router.get("/wip-tasks", response_model=list[WipTask])
|
||||
async def wip_tasks(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""最近任务动态 — 支持日期范围筛选"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_recent_activity(db, limit, since=since_dt, until=until_dt)
|
||||
"""在制品看板 — 当前所有 PENDING/WIP 任务,按滞留时间排序"""
|
||||
return await get_wip_tasks(db, limit)
|
||||
|
||||
@ -18,26 +18,30 @@ class DashboardStats(BaseModel):
|
||||
tasks_completed: int
|
||||
tasks_rejected: int
|
||||
tasks_rework: int
|
||||
# 通知
|
||||
# 通知 + 留言
|
||||
unread_notifications: int = 0
|
||||
unread_messages: int = 0
|
||||
|
||||
|
||||
class RecentActivity(BaseModel):
|
||||
action: str
|
||||
class WipTask(BaseModel):
|
||||
"""在制品 — 当前卡在手里的任务"""
|
||||
task_id: str
|
||||
task_name: str
|
||||
operator: str
|
||||
assignee: str # 中文姓名
|
||||
product_sn: str
|
||||
time: str
|
||||
remark: str | None = None
|
||||
status: str # PENDING / WIP
|
||||
received_at: str # 接收时间
|
||||
duration_hours: float # 已滞留小时数
|
||||
|
||||
|
||||
async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
|
||||
from app.models.product import Product
|
||||
from app.models.task import (
|
||||
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.message import ProductMessage
|
||||
|
||||
p_total = await db.scalar(select(func.count(Product.id)))
|
||||
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_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))
|
||||
)
|
||||
# 留言板未读数 — 全局(所有产品下的留言总数)
|
||||
unread_msg = await db.scalar(select(func.count(ProductMessage.id)))
|
||||
|
||||
return DashboardStats(
|
||||
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_rejected=t_rejected 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(
|
||||
db: AsyncSession, limit: int = 10,
|
||||
since: datetime | None = None, until: datetime | None = None,
|
||||
) -> list[RecentActivity]:
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.task import Task
|
||||
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
"""
|
||||
在制品看板 — 当前所有 PENDING/WIP 状态的任务,
|
||||
按接收时间升序(最早接收的排最前 = 滞留最久)。
|
||||
"""
|
||||
from app.models.task import (
|
||||
Task, TASK_STATUS_PENDING, TASK_STATUS_WIP,
|
||||
)
|
||||
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 = (
|
||||
select(TaskLog, Task.task_name, Product.serial_number, Task.assignee_id)
|
||||
.join(Task, TaskLog.task_id == Task.id)
|
||||
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())
|
||||
.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)
|
||||
rows = result.all()
|
||||
|
||||
# 收集 operator_id + 兜底 assignee_id → 批量翻译中文姓名
|
||||
raw_ids: set[str] = set()
|
||||
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])
|
||||
# 收集 assignee_id 批量翻译中文姓名
|
||||
raw_ids = list({t.assignee_id for t, _ in rows if t.assignee_id})
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
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] = []
|
||||
for log, task_name, product_sn, task_assignee in rows:
|
||||
action_label = _action_label(log.action_type)
|
||||
# 强制转北京时间显示
|
||||
t = log.created_at
|
||||
if t:
|
||||
if t.tzinfo is None:
|
||||
t = t.replace(tzinfo=BEIJING_TZ)
|
||||
now = get_beijing_time()
|
||||
wip_list: list[WipTask] = []
|
||||
for task, product_sn in rows:
|
||||
# 计算滞留时长
|
||||
start = task.received_at or task.created_at
|
||||
if start:
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=BEIJING_TZ)
|
||||
else:
|
||||
t = t.astimezone(BEIJING_TZ)
|
||||
time_str = t.strftime("%m-%d %H:%M")
|
||||
start = start.astimezone(BEIJING_TZ)
|
||||
hours = round((now - start).total_seconds() / 3600, 1)
|
||||
recv_str = start.strftime("%m-%d %H:%M")
|
||||
else:
|
||||
time_str = ""
|
||||
# operator_id: 优先显示中文姓名 → 英文用户名兜底 → 任务负责人兜底 → 空
|
||||
op = log.operator_id or task_assignee or ""
|
||||
op_display = name_map.get(op, op)
|
||||
activities.append(RecentActivity(
|
||||
action=action_label,
|
||||
task_name=task_name or "",
|
||||
operator=op_display,
|
||||
hours = 0
|
||||
recv_str = ""
|
||||
|
||||
wip_list.append(WipTask(
|
||||
task_id=str(task.id),
|
||||
task_name=task.task_name,
|
||||
assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"),
|
||||
product_sn=product_sn or "",
|
||||
time=time_str,
|
||||
remark=log.remark,
|
||||
status=task.status,
|
||||
received_at=recv_str,
|
||||
duration_hours=hours,
|
||||
))
|
||||
return activities
|
||||
return wip_list
|
||||
|
||||
|
||||
def _action_label(action_type: str) -> str:
|
||||
labels = {
|
||||
"create": "创建任务",
|
||||
"receive": "确认接收",
|
||||
"complete": "完成任务",
|
||||
"transfer": "完工转交",
|
||||
"reject": "品质驳回",
|
||||
"end": "结束分支",
|
||||
"create": "创建任务", "receive": "确认接收", "complete": "完成任务",
|
||||
"transfer": "完工转交", "reject": "品质驳回", "end": "结束分支",
|
||||
"recall": "撤回转交",
|
||||
}
|
||||
return labels.get(action_type, action_type)
|
||||
|
||||
@ -1,32 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
|
||||
RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Calendar,
|
||||
RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Clock, MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
fetchDashboardStats, fetchRecentActivity,
|
||||
type DashboardStats, type RecentActivity,
|
||||
fetchDashboardStats, fetchWipTasks,
|
||||
type DashboardStats, type WipTask,
|
||||
} 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 }: {
|
||||
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: c, color: "bg-emerald-500", label: labels[2] },
|
||||
].filter(s => s.n > 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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 }) {
|
||||
const iconMap: Record<string, string> = {
|
||||
"创建任务": "📋", "确认接收": "✅", "完成任务": "🏁", "完工转交": "🔄",
|
||||
"品质驳回": "❌", "结束分支": "🛑", "撤回转交": "↩️",
|
||||
};
|
||||
// ─── 滞留时间颜色 ─────────────────────────────────────────
|
||||
function durationColor(h: number): 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 (
|
||||
<div className="flex items-start gap-3 border-b border-gray-50 py-2.5 last:border-0">
|
||||
<span className="mt-0.5 text-base">{iconMap[a.action] || "📌"}</span>
|
||||
<div className="flex items-center gap-3 border-b border-gray-50 py-2.5 last:border-0">
|
||||
{/* 状态标识 */}
|
||||
<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="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">{a.action}</span>
|
||||
<span className="truncate text-xs text-gray-500">{a.task_name}</span>
|
||||
{a.remark && (
|
||||
<span className="truncate text-[11px] text-gray-400">— {a.remark.slice(0, 30)}{a.remark.length > 30 ? "…" : ""}</span>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-700 truncate">{t.task_name}</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">{t.assignee}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-gray-400">
|
||||
<span>{a.operator || "—"}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{a.product_sn}</span>
|
||||
<span className="ml-auto">{a.time}</span>
|
||||
<span className="font-mono">{t.product_sn}</span>
|
||||
{t.received_at && <span>接收: {t.received_at}</span>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -91,29 +89,23 @@ function ActivityItem({ a }: { a: RecentActivity }) {
|
||||
// ─── 主组件 ───────────────────────────────────────────────
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [activity, setActivity] = useState<RecentActivity[]>([]);
|
||||
const [wipTasks, setWipTasks] = useState<WipTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dateRange, setDateRange] = useState<DateRange>("today");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loadData = (dr: DateRange) => {
|
||||
const since = getSince(dr);
|
||||
const loadData = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
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("加载失败,请确认后端已启动"))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(dateRange); }, [dateRange]);
|
||||
|
||||
const handleDateChange = (dr: DateRange) => {
|
||||
setLoading(true);
|
||||
setDateRange(dr);
|
||||
};
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
// ── 加载态 ──
|
||||
if (loading) {
|
||||
@ -129,8 +121,7 @@ export default function AdminDashboard() {
|
||||
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">
|
||||
<AlertCircle className="h-4 w-4" />{error || "数据为空"}
|
||||
<button onClick={() => { setLoading(true); loadData(dateRange); }}
|
||||
className="ml-auto text-blue-600 underline">重试</button>
|
||||
<button onClick={loadData} className="ml-auto text-blue-600 underline">重试</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -138,34 +129,17 @@ export default function AdminDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ═══ 页头 ═══ */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">📊 生产管理看板</h2>
|
||||
<p className="mt-0.5 text-sm text-gray-400">
|
||||
产品 = 物理实体(身份证)| 任务 = 工序节点(流转步骤)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 日期筛选 */}
|
||||
<div className="flex rounded-lg border border-gray-200 bg-white p-0.5">
|
||||
{DATE_OPTIONS.map(opt => (
|
||||
<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>
|
||||
<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">
|
||||
<RefreshCw className="h-3.5 w-3.5" />刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第1行:4 张概览卡片 ═══ */}
|
||||
@ -177,7 +151,7 @@ export default function AdminDashboard() {
|
||||
<h3 className="text-sm font-semibold text-gray-700">📦 产品流转</h3>
|
||||
</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="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}
|
||||
total={stats.products_total} labels={["待流转", "流转中", "已完成"]} />
|
||||
</div>
|
||||
@ -189,43 +163,34 @@ export default function AdminDashboard() {
|
||||
<h3 className="text-sm font-semibold text-gray-700">📋 任务状态</h3>
|
||||
</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="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}
|
||||
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} />
|
||||
</div>
|
||||
|
||||
{/* 品质 & 通知 — 通知可点击 */}
|
||||
{/* 品质 & 留言板未读 */}
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<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 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">
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate("/notifications")}
|
||||
className="rounded-lg bg-blue-50 p-3 text-center hover:bg-blue-100 transition-colors cursor-pointer border-0"
|
||||
>
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.unread_notifications}</p>
|
||||
<p className="text-[11px] text-blue-500">未读通知 ↗</p>
|
||||
<button onClick={() => navigate("/notifications")}
|
||||
className="rounded-lg bg-blue-50 p-3 text-center hover:bg-blue-100 transition-colors border-0 cursor-pointer">
|
||||
<p className="text-xl font-bold text-blue-600">{stats.unread_notifications}</p>
|
||||
<p className="text-[11px] text-blue-500">系统通知 ↗</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-around border-t border-gray-100 pt-3">
|
||||
<div className="text-center">
|
||||
<span className="text-lg font-bold text-red-600">{stats.tasks_rejected}</span>
|
||||
<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 className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
协同留言
|
||||
</div>
|
||||
<span className="text-lg font-bold text-purple-600">{stats.unread_messages}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -250,26 +215,29 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第2行:最近动态 + 快捷入口 ═══ */}
|
||||
{/* ═══ 第2行:在制品看板 + 快捷入口 ═══ */}
|
||||
<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="mb-3 flex items-center justify-between">
|
||||
<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>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
{dateRange === "today" ? "今天" : dateRange === "7d" ? "近7天" : dateRange === "30d" ? "近30天" : "全部"}
|
||||
· {activity.length} 条
|
||||
滞留中 · 共 {wipTasks.length} 个任务
|
||||
</span>
|
||||
</div>
|
||||
{activity.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-gray-400">
|
||||
{dateRange === "today" ? "今天暂无流转记录" : "该时间段暂无流转记录"}
|
||||
</div>
|
||||
{wipTasks.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-gray-400">🎉 当前没有滞留任务,所有工序已完结</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{activity.map((a, i) => <ActivityItem key={i} a={a} />)}
|
||||
<div>
|
||||
{/* 表头 */}
|
||||
<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>
|
||||
@ -291,9 +259,7 @@ export default function AdminDashboard() {
|
||||
<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">
|
||||
<span className="flex items-center gap-2"><Bell className="h-4 w-4" />
|
||||
{stats.unread_notifications > 0
|
||||
? `未读通知 (${stats.unread_notifications})`
|
||||
: "通知中心"}
|
||||
{stats.unread_notifications > 0 ? `通知 (${stats.unread_notifications})` : "通知中心"}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
@ -306,10 +272,10 @@ export default function AdminDashboard() {
|
||||
|
||||
<div className="mt-5 rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-[11px] leading-relaxed text-gray-500">
|
||||
<strong>💡 一个产品 = 多道工序</strong><br />
|
||||
产品从创建到入库,经过装配→接线→质检等多道工序。
|
||||
每个工序就是一个<strong className="text-gray-700">任务</strong>,
|
||||
由不同工人完成。任务数 ≥ 产品数是正常的。
|
||||
<strong>💡 在制品 = 当前卡在工人手里的活</strong><br />
|
||||
颜色越红 = 滞留越久。超过
|
||||
<span className="text-red-600 font-bold">48小时</span>需关注,
|
||||
<span className="text-orange-600 font-bold">24小时</span>内属正常。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,15 +12,17 @@ export interface DashboardStats {
|
||||
tasks_rejected: number;
|
||||
tasks_rework: number;
|
||||
unread_notifications: number;
|
||||
unread_messages: number;
|
||||
}
|
||||
|
||||
export interface RecentActivity {
|
||||
action: string;
|
||||
export interface WipTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
operator: string;
|
||||
assignee: string;
|
||||
product_sn: string;
|
||||
time: string;
|
||||
remark: string | null;
|
||||
status: string;
|
||||
received_at: string;
|
||||
duration_hours: number;
|
||||
}
|
||||
|
||||
export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
@ -28,14 +30,7 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRecentActivity(
|
||||
limit = 10,
|
||||
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 });
|
||||
export async function fetchWipTasks(limit = 20): Promise<WipTask[]> {
|
||||
const { data } = await api.get<WipTask[]>("/dashboard/wip-tasks", { params: { limit } });
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user