fix: 看板4项体验修复 — 时间/身份证/日期筛选/通知跳转
1. 完整16位产品身份证号显示(不再截断) 2. 日期筛选栏: 今天 | 近7天 | 近30天 | 全部 - 后端 recent-activity 支持 since/until ISO参数 - 默认展示今天动态,可按时间范围切换 3. 操作人兜底: TaskLog无operator_id时用任务assignee_id 4. 未读通知可点击跳转通知页面 + 快捷入口增加通知中心
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
"""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
|
||||
@ -18,7 +19,11 @@ async def dashboard_stats(db: AsyncSession = Depends(get_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格式"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""最近任务动态 — 看板活动时间线"""
|
||||
return await get_recent_activity(db, limit)
|
||||
"""最近任务动态 — 支持日期范围筛选"""
|
||||
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)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""Dashboard 统计服务"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
@ -69,31 +70,43 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
|
||||
)
|
||||
|
||||
|
||||
async def get_recent_activity(db: AsyncSession, limit: int = 10) -> list[RecentActivity]:
|
||||
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
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
|
||||
stmt = (
|
||||
select(TaskLog, Task.task_name, Product.serial_number)
|
||||
select(TaskLog, Task.task_name, Product.serial_number, Task.assignee_id)
|
||||
.join(Task, TaskLog.task_id == Task.id)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.order_by(TaskLog.created_at.desc())
|
||||
.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 → 批量翻译中文姓名
|
||||
operator_ids = list({row[0].operator_id for row in rows if row[0].operator_id})
|
||||
# 收集 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])
|
||||
name_map: dict[str, str] = {}
|
||||
if operator_ids:
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(operator_ids)
|
||||
name_map = get_display_names(list(raw_ids))
|
||||
|
||||
activities: list[RecentActivity] = []
|
||||
for log, task_name, product_sn in rows:
|
||||
for log, task_name, product_sn, task_assignee in rows:
|
||||
action_label = _action_label(log.action_type)
|
||||
# 强制转北京时间显示
|
||||
t = log.created_at
|
||||
@ -105,9 +118,9 @@ async def get_recent_activity(db: AsyncSession, limit: int = 10) -> list[RecentA
|
||||
time_str = t.strftime("%m-%d %H:%M")
|
||||
else:
|
||||
time_str = ""
|
||||
# operator_id: 优先显示中文姓名 → 英文用户名兜底 → 无记录时为空
|
||||
op = log.operator_id
|
||||
op_display = name_map.get(op, op or "")
|
||||
# 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 "",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
|
||||
RefreshCw, Loader2, AlertCircle, Plus, ArrowRight,
|
||||
RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Calendar,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
@ -9,14 +9,22 @@ import {
|
||||
type DashboardStats, type RecentActivity,
|
||||
} from "../../services/dashboardApi";
|
||||
|
||||
// ─── 小卡片 ───────────────────────────────────────────────
|
||||
function MiniStat({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<span className={`text-xl font-bold ${color}`}>{value}</span>
|
||||
<p className="text-[11px] text-gray-400">{label}</p>
|
||||
</div>
|
||||
);
|
||||
// ─── 日期筛选选项 ─────────────────────────────────────────
|
||||
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();
|
||||
}
|
||||
|
||||
// ─── 进度条 ───────────────────────────────────────────────
|
||||
@ -65,11 +73,14 @@ function ActivityItem({ a }: { a: RecentActivity }) {
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-gray-400">
|
||||
<span>{a.operator}</span>
|
||||
<span>{a.operator || "—"}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{a.product_sn.slice(0, 8)}…</span>
|
||||
<span className="font-mono">{a.product_sn}</span>
|
||||
<span className="ml-auto">{a.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -83,17 +94,26 @@ export default function AdminDashboard() {
|
||||
const [activity, setActivity] = useState<RecentActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dateRange, setDateRange] = useState<DateRange>("today");
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = (dr: DateRange) => {
|
||||
const since = getSince(dr);
|
||||
Promise.all([
|
||||
fetchDashboardStats(),
|
||||
fetchRecentActivity(8),
|
||||
fetchRecentActivity(15, since),
|
||||
])
|
||||
.then(([s, a]) => { setStats(s); setActivity(a); })
|
||||
.catch(() => setError("加载统计数据失败,请确认后端已启动"))
|
||||
.then(([s, a]) => { setStats(s); setActivity(a); setError(null); })
|
||||
.catch(() => setError("加载失败,请确认后端已启动"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(dateRange); }, [dateRange]);
|
||||
|
||||
const handleDateChange = (dr: DateRange) => {
|
||||
setLoading(true);
|
||||
setDateRange(dr);
|
||||
};
|
||||
|
||||
// ── 加载态 ──
|
||||
if (loading) {
|
||||
@ -109,6 +129,8 @@ 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -116,18 +138,35 @@ export default function AdminDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ═══ 页头 ═══ */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<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()}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第1行:4 张概览卡片 ═══ */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@ -155,7 +194,7 @@ export default function AdminDashboard() {
|
||||
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" />
|
||||
@ -166,15 +205,27 @@ export default function AdminDashboard() {
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-[11px] text-blue-500">未读通知 ↗</p>
|
||||
</button>
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -190,9 +241,7 @@ export default function AdminDashboard() {
|
||||
</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">
|
||||
<svg viewBox="0 0 100 100" className="mx-auto mt-4 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`}
|
||||
@ -200,18 +249,24 @@ export default function AdminDashboard() {
|
||||
</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>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<Calendar className="h-4 w-4" /> 最近流转动态
|
||||
</h3>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
{dateRange === "today" ? "今天" : dateRange === "7d" ? "近7天" : dateRange === "30d" ? "近30天" : "全部"}
|
||||
· {activity.length} 条
|
||||
</span>
|
||||
</div>
|
||||
{activity.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-400">暂无流转记录</div>
|
||||
<div className="py-10 text-center text-sm text-gray-400">
|
||||
{dateRange === "today" ? "今天暂无流转记录" : "该时间段暂无流转记录"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{activity.map((a, i) => <ActivityItem key={i} a={a} />)}
|
||||
@ -225,7 +280,7 @@ export default function AdminDashboard() {
|
||||
<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>
|
||||
<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")}
|
||||
@ -233,26 +288,28 @@ export default function AdminDashboard() {
|
||||
<span className="flex items-center gap-2"><ClipboardList className="h-4 w-4" />任务管理</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
<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})`
|
||||
: "通知中心"}
|
||||
</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>,由不同工人完成。
|
||||
所以任务数 ≥ 产品数是正常的。
|
||||
<strong>💡 一个产品 = 多道工序</strong><br />
|
||||
产品从创建到入库,经过装配→接线→质检等多道工序。
|
||||
每个工序就是一个<strong className="text-gray-700">任务</strong>,
|
||||
由不同工人完成。任务数 ≥ 产品数是正常的。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -28,7 +28,14 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRecentActivity(limit = 10): Promise<RecentActivity[]> {
|
||||
const { data } = await api.get<RecentActivity[]>("/dashboard/recent-activity", { params: { limit } });
|
||||
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 });
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user