feat: 管理看板全面改版 — 增强可读性 + 填充空白区域
后端增强: - DashboardStats 新增 tasks_rejected, tasks_rework, unread_notifications - 新增 GET /dashboard/recent-activity 最近动态端点 - 关联 Task + Product 表返回完整动态信息 前端改版 (AdminDashboard.tsx): - 4 张概览卡片: 产品流转 | 任务状态 | 品质通知 | 完成率 - 每张卡片含进度条(百分比标注) + 中文说明 - SVG 环形图展示任务完成率 - 最近流转动态时间线 (8条) - 快捷入口: 创建产品/任务管理/打印配置/扫码干活 - 底部说明卡片解释"产品 vs 任务"的区别 - 响应式网格填满全屏, 无空白区域
This commit is contained in:
@ -1,8 +1,11 @@
|
|||||||
"""Dashboard API"""
|
"""Dashboard API"""
|
||||||
from fastapi import APIRouter, Depends
|
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 get_dashboard_stats, DashboardStats
|
from app.services.dashboard_service import (
|
||||||
|
get_dashboard_stats, DashboardStats,
|
||||||
|
get_recent_activity, RecentActivity,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
|
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
|
||||||
|
|
||||||
@ -10,3 +13,12 @@ router = APIRouter(prefix="/dashboard", tags=["管理看板"])
|
|||||||
@router.get("/stats", response_model=DashboardStats)
|
@router.get("/stats", response_model=DashboardStats)
|
||||||
async def dashboard_stats(db: AsyncSession = Depends(get_db)):
|
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])
|
||||||
|
async def recent_activity(
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""最近任务动态 — 看板活动时间线"""
|
||||||
|
return await get_recent_activity(db, limit)
|
||||||
|
|||||||
@ -5,19 +5,38 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
|
|
||||||
class DashboardStats(BaseModel):
|
class DashboardStats(BaseModel):
|
||||||
|
# 产品
|
||||||
products_total: int
|
products_total: int
|
||||||
products_pending: int
|
products_pending: int
|
||||||
products_in_progress: int
|
products_in_progress: int
|
||||||
products_completed: int
|
products_completed: int
|
||||||
|
# 任务
|
||||||
tasks_total: int
|
tasks_total: int
|
||||||
tasks_pending: int
|
tasks_pending: int
|
||||||
tasks_in_progress: int
|
tasks_in_progress: int
|
||||||
tasks_completed: int
|
tasks_completed: int
|
||||||
|
tasks_rejected: int
|
||||||
|
tasks_rework: int
|
||||||
|
# 通知
|
||||||
|
unread_notifications: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class RecentActivity(BaseModel):
|
||||||
|
action: str
|
||||||
|
task_name: str
|
||||||
|
operator: str
|
||||||
|
product_sn: str
|
||||||
|
time: str
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
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 Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED
|
from app.models.task import (
|
||||||
|
Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED,
|
||||||
|
TASK_STATUS_REJECTED, TASK_STATUS_ARCHIVED,
|
||||||
|
)
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
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"))
|
||||||
@ -28,6 +47,12 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
|
|||||||
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
|
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
|
||||||
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP))
|
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP))
|
||||||
t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED))
|
t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED))
|
||||||
|
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(
|
||||||
|
select(func.count(Notification.id)).where(Notification.is_read.is_(False))
|
||||||
|
)
|
||||||
|
|
||||||
return DashboardStats(
|
return DashboardStats(
|
||||||
products_total=p_total or 0,
|
products_total=p_total or 0,
|
||||||
@ -38,4 +63,50 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
|
|||||||
tasks_pending=t_pending or 0,
|
tasks_pending=t_pending or 0,
|
||||||
tasks_in_progress=t_progress or 0,
|
tasks_in_progress=t_progress or 0,
|
||||||
tasks_completed=t_done or 0,
|
tasks_completed=t_done or 0,
|
||||||
|
tasks_rejected=t_rejected or 0,
|
||||||
|
tasks_rework=t_rework or 0,
|
||||||
|
unread_notifications=unread or 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_recent_activity(db: AsyncSession, limit: int = 10) -> list[RecentActivity]:
|
||||||
|
from app.models.task_log import TaskLog
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.models.product import Product
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(TaskLog, Task.task_name, Product.serial_number)
|
||||||
|
.join(Task, TaskLog.task_id == Task.id)
|
||||||
|
.join(Product, Task.product_id == Product.id)
|
||||||
|
.order_by(TaskLog.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
activities: list[RecentActivity] = []
|
||||||
|
for log, task_name, product_sn in rows:
|
||||||
|
action_label = _action_label(log.action_type)
|
||||||
|
time_str = log.created_at.strftime("%m-%d %H:%M") if log.created_at else ""
|
||||||
|
activities.append(RecentActivity(
|
||||||
|
action=action_label,
|
||||||
|
task_name=task_name or "",
|
||||||
|
operator=log.operator_id or "系统",
|
||||||
|
product_sn=product_sn or "",
|
||||||
|
time=time_str,
|
||||||
|
remark=log.remark,
|
||||||
|
))
|
||||||
|
return activities
|
||||||
|
|
||||||
|
|
||||||
|
def _action_label(action_type: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"create": "创建任务",
|
||||||
|
"receive": "确认接收",
|
||||||
|
"complete": "完成任务",
|
||||||
|
"transfer": "完工转交",
|
||||||
|
"reject": "品质驳回",
|
||||||
|
"end": "结束分支",
|
||||||
|
"recall": "撤回转交",
|
||||||
|
}
|
||||||
|
return labels.get(action_type, action_type)
|
||||||
|
|||||||
@ -1,80 +1,261 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Package, ClipboardList, Loader2, AlertCircle } from "lucide-react";
|
import {
|
||||||
import { fetchDashboardStats, type DashboardStats } from "../../services/dashboardApi";
|
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
|
||||||
|
RefreshCw, Loader2, AlertCircle, Plus, ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
fetchDashboardStats, fetchRecentActivity,
|
||||||
|
type DashboardStats, type RecentActivity,
|
||||||
|
} from "../../services/dashboardApi";
|
||||||
|
|
||||||
function StatCard({
|
// ─── 小卡片 ───────────────────────────────────────────────
|
||||||
label,
|
function MiniStat({ value, label, color }: { value: number; label: string; color: string }) {
|
||||||
total,
|
|
||||||
pending,
|
|
||||||
progress,
|
|
||||||
done,
|
|
||||||
icon: Icon,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
total: number;
|
|
||||||
pending: number;
|
|
||||||
progress: number;
|
|
||||||
done: number;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
<div className="text-center">
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<span className={`text-xl font-bold ${color}`}>{value}</span>
|
||||||
<Icon className="h-5 w-5 text-blue-600" />
|
<p className="text-[11px] text-gray-400">{label}</p>
|
||||||
<h3 className="font-semibold text-gray-800">{label}</h3>
|
</div>
|
||||||
<span className="ml-auto text-2xl font-bold text-gray-800">{total}</span>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 进度条 ───────────────────────────────────────────────
|
||||||
|
function ProgressBar({ a, b, c, total, labels }: {
|
||||||
|
a: number; b: number; c: number; total: number;
|
||||||
|
labels: [string, string, string];
|
||||||
|
}) {
|
||||||
|
if (total === 0) return <div className="py-4 text-center text-xs text-gray-400">暂无数据</div>;
|
||||||
|
const pct = (n: number) => Math.round((n / total) * 100);
|
||||||
|
const segs = [
|
||||||
|
{ n: a, color: "bg-amber-400", label: labels[0] },
|
||||||
|
{ 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">
|
||||||
|
{segs.map((s, i) => (
|
||||||
|
<div key={i} className={`${s.color} transition-all duration-500`}
|
||||||
|
style={{ width: `${(s.n / total) * 100}%` }} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-2 overflow-hidden rounded-full bg-gray-100">
|
<div className="mt-2 flex flex-wrap gap-3 text-xs text-gray-500">
|
||||||
{pending > 0 && (
|
{segs.map((s, i) => (
|
||||||
<div className="bg-yellow-400" style={{ width: `${(pending / Math.max(total, 1)) * 100}%` }} />
|
<span key={i} className="flex items-center gap-1">
|
||||||
)}
|
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
|
||||||
{progress > 0 && (
|
{s.label} {s.n}({pct(s.n)}%)
|
||||||
<div className="bg-blue-500" style={{ width: `${(progress / Math.max(total, 1)) * 100}%` }} />
|
</span>
|
||||||
)}
|
))}
|
||||||
{done > 0 && (
|
|
||||||
<div className="bg-green-500" style={{ width: `${(done / Math.max(total, 1)) * 100}%` }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-4 text-xs text-gray-500">
|
|
||||||
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-yellow-400" />待处理 {pending}</span>
|
|
||||||
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-blue-500" />进行中 {progress}</span>
|
|
||||||
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-green-500" />已完成 {done}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 动态条目 ─────────────────────────────────────────────
|
||||||
|
function ActivityItem({ a }: { a: RecentActivity }) {
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
"创建任务": "📋", "确认接收": "✅", "完成任务": "🏁", "完工转交": "🔄",
|
||||||
|
"品质驳回": "❌", "结束分支": "🛑", "撤回转交": "↩️",
|
||||||
|
};
|
||||||
|
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="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>
|
||||||
|
</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.slice(0, 8)}…</span>
|
||||||
|
<span className="ml-auto">{a.time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 主组件 ───────────────────────────────────────────────
|
||||||
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 [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardStats()
|
Promise.all([
|
||||||
.then(setStats)
|
fetchDashboardStats(),
|
||||||
|
fetchRecentActivity(8),
|
||||||
|
])
|
||||||
|
.then(([s, a]) => { setStats(s); setActivity(a); })
|
||||||
.catch(() => setError("加载统计数据失败,请确认后端已启动"))
|
.catch(() => setError("加载统计数据失败,请确认后端已启动"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── 加载态 ──
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div>;
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
// ── 错误态 ──
|
||||||
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}</div>;
|
if (error || !stats) {
|
||||||
|
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 || "数据为空"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stats) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<div className="mb-6">
|
{/* ═══ 页头 ═══ */}
|
||||||
<h2 className="text-xl font-bold text-gray-800">全局生产概览</h2>
|
<div className="flex items-center justify-between">
|
||||||
<p className="mt-1 text-sm text-gray-500">PC端与移动端共享同一后台数据</p>
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-800">📊 生产管理看板</h2>
|
||||||
|
<p className="mt-0.5 text-sm text-gray-400">
|
||||||
|
产品 = 物理实体(身份证)| 任务 = 工序节点(流转步骤)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => window.location.reload()}
|
||||||
|
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 className="grid gap-5 md:grid-cols-2">
|
|
||||||
<StatCard label="产品统计" total={stats.products_total} pending={stats.products_pending} progress={stats.products_in_progress} done={stats.products_completed} icon={Package} />
|
{/* ═══ 第1行:4 张概览卡片 ═══ */}
|
||||||
<StatCard label="任务统计" total={stats.tasks_total} pending={stats.tasks_pending} progress={stats.tasks_in_progress} done={stats.tasks_completed} icon={ClipboardList} />
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{/* 产品流转 */}
|
||||||
|
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
|
<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>
|
||||||
|
<ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed}
|
||||||
|
total={stats.products_total} labels={["待流转", "流转中", "已完成"]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务状态 */}
|
||||||
|
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-5 w-5 text-purple-600" />
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<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-[11px] text-red-500">驳回/返工</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-blue-50 p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{stats.unread_notifications}</p>
|
||||||
|
<p className="text-[11px] text-blue-500">未读通知</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-around border-t border-gray-100 pt-3">
|
||||||
|
<MiniStat value={stats.tasks_rejected} label="已驳回" color="text-red-600" />
|
||||||
|
<MiniStat value={stats.tasks_rework} label="返工中" color="text-orange-600" />
|
||||||
|
<MiniStat value={stats.unread_notifications} label="未读消息" color="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 完成率 */}
|
||||||
|
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5 text-emerald-600" />
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">✅ 流转完成率</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<p className="text-3xl font-bold text-emerald-600">
|
||||||
|
{stats.tasks_total > 0 ? Math.round((stats.tasks_completed / stats.tasks_total) * 100) : 0}%
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">{stats.tasks_completed}/{stats.tasks_total} 已完成</p>
|
||||||
|
</div>
|
||||||
|
{/* 简易环形图 */}
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<svg viewBox="0 0 100 100" className="h-20 w-20 -rotate-90">
|
||||||
|
<circle cx="50" cy="50" r="40" fill="none" stroke="#f3f4f6" strokeWidth="10" />
|
||||||
|
<circle cx="50" cy="50" r="40" fill="none" stroke="#10b981" strokeWidth="10"
|
||||||
|
strokeDasharray={`${stats.tasks_total > 0 ? (stats.tasks_completed / stats.tasks_total) * 251 : 0} 251`}
|
||||||
|
strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 第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="text-sm font-semibold text-gray-700">🕐 最近流转动态</h3>
|
||||||
|
<span className="text-[11px] text-gray-400">最新 8 条</span>
|
||||||
|
</div>
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<div className="py-8 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>
|
||||||
|
|
||||||
|
{/* 快捷入口 */}
|
||||||
|
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||||
|
<h3 className="mb-4 text-sm font-semibold text-gray-700">⚡ 快捷入口</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button onClick={() => navigate("/admin/products")}
|
||||||
|
className="flex w-full items-center justify-between rounded-lg bg-blue-50 px-4 py-3 text-left text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors">
|
||||||
|
<span className="flex items-center gap-2"><Plus className="h-4 w-4" />创建产品</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate("/admin/tasks")}
|
||||||
|
className="flex w-full items-center justify-between rounded-lg bg-purple-50 px-4 py-3 text-left text-sm font-medium text-purple-700 hover:bg-purple-100 transition-colors">
|
||||||
|
<span className="flex items-center gap-2"><ClipboardList className="h-4 w-4" />任务管理</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate("/admin/print-config")}
|
||||||
|
className="flex w-full items-center justify-between rounded-lg bg-amber-50 px-4 py-3 text-left text-sm font-medium text-amber-700 hover:bg-amber-100 transition-colors">
|
||||||
|
<span className="flex items-center gap-2">🖨️ 打印配置</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate("/scan")}
|
||||||
|
className="flex w-full items-center justify-between rounded-lg bg-emerald-50 px-4 py-3 text-left text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors">
|
||||||
|
<span className="flex items-center gap-2">📱 扫码干活</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 说明卡片 */}
|
||||||
|
<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 className="text-gray-700">工序</strong>(装配→接线→质检…)。
|
||||||
|
每道工序就是一个<strong className="text-gray-700">任务</strong>,由不同工人完成。
|
||||||
|
所以任务数 ≥ 产品数是正常的。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,9 +9,26 @@ export interface DashboardStats {
|
|||||||
tasks_pending: number;
|
tasks_pending: number;
|
||||||
tasks_in_progress: number;
|
tasks_in_progress: number;
|
||||||
tasks_completed: number;
|
tasks_completed: number;
|
||||||
|
tasks_rejected: number;
|
||||||
|
tasks_rework: number;
|
||||||
|
unread_notifications: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentActivity {
|
||||||
|
action: string;
|
||||||
|
task_name: string;
|
||||||
|
operator: string;
|
||||||
|
product_sn: string;
|
||||||
|
time: string;
|
||||||
|
remark: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDashboardStats(): Promise<DashboardStats> {
|
export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||||
const { data } = await api.get<DashboardStats>("/dashboard/stats");
|
const { data } = await api.get<DashboardStats>("/dashboard/stats");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchRecentActivity(limit = 10): Promise<RecentActivity[]> {
|
||||||
|
const { data } = await api.get<RecentActivity[]>("/dashboard/recent-activity", { params: { limit } });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user