fix: 看板4项体验修复 — 时间/身份证/日期筛选/通知跳转

1. 完整16位产品身份证号显示(不再截断)
2. 日期筛选栏: 今天 | 近7天 | 近30天 | 全部
   - 后端 recent-activity 支持 since/until ISO参数
   - 默认展示今天动态,可按时间范围切换
3. 操作人兜底: TaskLog无operator_id时用任务assignee_id
4. 未读通知可点击跳转通知页面 + 快捷入口增加通知中心
This commit is contained in:
2026-08-12 13:35:23 +08:00
parent c3f3a5e291
commit 00fae01eed
4 changed files with 151 additions and 69 deletions

View File

@ -1,4 +1,5 @@
"""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
@ -18,7 +19,11 @@ async def dashboard_stats(db: AsyncSession = Depends(get_db)):
@router.get("/recent-activity", response_model=list[RecentActivity]) @router.get("/recent-activity", response_model=list[RecentActivity])
async def recent_activity( async def recent_activity(
limit: int = Query(10, ge=1, le=50), 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), 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)

View File

@ -1,4 +1,5 @@
"""Dashboard 统计服务""" """Dashboard 统计服务"""
from datetime import datetime
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel 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_log import TaskLog
from app.models.task import Task from app.models.task import Task
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 BEIJING_TZ
stmt = ( 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(Task, TaskLog.task_id == Task.id)
.join(Product, Task.product_id == Product.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) result = await db.execute(stmt)
rows = result.all() rows = result.all()
# 收集 operator_id → 批量翻译中文姓名 # 收集 operator_id + 兜底 assignee_id → 批量翻译中文姓名
operator_ids = list({row[0].operator_id for row in rows if row[0].operator_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] = {} name_map: dict[str, str] = {}
if operator_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(operator_ids) name_map = get_display_names(list(raw_ids))
activities: list[RecentActivity] = [] 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) action_label = _action_label(log.action_type)
# 强制转北京时间显示 # 强制转北京时间显示
t = log.created_at 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") time_str = t.strftime("%m-%d %H:%M")
else: else:
time_str = "" time_str = ""
# operator_id: 优先显示中文姓名 → 英文用户名兜底 → 无记录时为 # operator_id: 优先显示中文姓名 → 英文用户名兜底 → 任务负责人兜底 →
op = log.operator_id op = log.operator_id or task_assignee or ""
op_display = name_map.get(op, op or "") op_display = name_map.get(op, op)
activities.append(RecentActivity( activities.append(RecentActivity(
action=action_label, action=action_label,
task_name=task_name or "", task_name=task_name or "",

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle, Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Calendar,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
@ -9,14 +9,22 @@ import {
type DashboardStats, type RecentActivity, type DashboardStats, type RecentActivity,
} from "../../services/dashboardApi"; } from "../../services/dashboardApi";
// ─── 小卡片 ─────────────────────────────────────────────── // ─── 日期筛选选项 ─────────────────────────────────────────
function MiniStat({ value, label, color }: { value: number; label: string; color: string }) { type DateRange = "today" | "7d" | "30d" | "all";
return ( const DATE_OPTIONS: { key: DateRange; label: string }[] = [
<div className="text-center"> { key: "today", label: "今天" },
<span className={`text-xl font-bold ${color}`}>{value}</span> { key: "7d", label: "近7天" },
<p className="text-[11px] text-gray-400">{label}</p> { key: "30d", label: "近30天" },
</div> { 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"> <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">{a.action}</span>
<span className="truncate text-xs text-gray-500">{a.task_name}</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>
<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>{a.operator || "—"}</span>
<span>·</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> <span className="ml-auto">{a.time}</span>
</div> </div>
</div> </div>
@ -83,17 +94,26 @@ export default function AdminDashboard() {
const [activity, setActivity] = useState<RecentActivity[]>([]); 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 [dateRange, setDateRange] = useState<DateRange>("today");
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { const loadData = (dr: DateRange) => {
const since = getSince(dr);
Promise.all([ Promise.all([
fetchDashboardStats(), fetchDashboardStats(),
fetchRecentActivity(8), fetchRecentActivity(15, since),
]) ])
.then(([s, a]) => { setStats(s); setActivity(a); }) .then(([s, a]) => { setStats(s); setActivity(a); setError(null); })
.catch(() => setError("加载统计数据失败,请确认后端已启动")) .catch(() => setError("加载失败,请确认后端已启动"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); };
useEffect(() => { loadData(dateRange); }, [dateRange]);
const handleDateChange = (dr: DateRange) => {
setLoading(true);
setDateRange(dr);
};
// ── 加载态 ── // ── 加载态 ──
if (loading) { if (loading) {
@ -109,6 +129,8 @@ 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); }}
className="ml-auto text-blue-600 underline"></button>
</div> </div>
); );
} }
@ -116,18 +138,35 @@ export default function AdminDashboard() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ═══ 页头 ═══ */} {/* ═══ 页头 ═══ */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-3">
<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>
<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"> 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" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div>
{/* ═══ 第1行4 张概览卡片 ═══ */} {/* ═══ 第1行4 张概览卡片 ═══ */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-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={["待接收", "进行中", "已完成"]} /> 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" />
@ -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-2xl 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>
<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-2xl font-bold text-blue-600">{stats.unread_notifications}</p>
<p className="text-[11px] text-blue-500"></p> <p className="text-[11px] text-blue-500"> </p>
</div> </button>
</div> </div>
<div className="mt-3 flex justify-around border-t border-gray-100 pt-3"> <div className="mt-3 flex justify-around border-t border-gray-100 pt-3">
<MiniStat value={stats.tasks_rejected} label="已驳回" color="text-red-600" /> <div className="text-center">
<MiniStat value={stats.tasks_rework} label="返工中" color="text-orange-600" /> <span className="text-lg font-bold text-red-600">{stats.tasks_rejected}</span>
<MiniStat value={stats.unread_notifications} label="未读消息" color="text-blue-600" /> <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>
</div> </div>
@ -190,9 +241,7 @@ export default function AdminDashboard() {
</p> </p>
<p className="text-sm text-gray-400">{stats.tasks_completed}/{stats.tasks_total} </p> <p className="text-sm text-gray-400">{stats.tasks_completed}/{stats.tasks_total} </p>
</div> </div>
{/* 简易环形图 */} <svg viewBox="0 0 100 100" className="mx-auto mt-4 h-20 w-20 -rotate-90">
<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="#f3f4f6" strokeWidth="10" />
<circle cx="50" cy="50" r="40" fill="none" stroke="#10b981" 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`} strokeDasharray={`${stats.tasks_total > 0 ? (stats.tasks_completed / stats.tasks_total) * 251 : 0} 251`}
@ -200,18 +249,24 @@ export default function AdminDashboard() {
</svg> </svg>
</div> </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="text-sm font-semibold text-gray-700">🕐 </h3> <h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<span className="text-[11px] text-gray-400"> 8 </span> <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> </div>
{activity.length === 0 ? ( {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"> <div className="divide-y divide-gray-50">
{activity.map((a, i) => <ActivityItem key={i} a={a} />)} {activity.map((a, i) => <ActivityItem key={i} a={a} />)}
@ -225,7 +280,7 @@ export default function AdminDashboard() {
<div className="space-y-2"> <div className="space-y-2">
<button onClick={() => navigate("/admin/products")} <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"> 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" /> <ArrowRight className="h-4 w-4" />
</button> </button>
<button onClick={() => navigate("/admin/tasks")} <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> <span className="flex items-center gap-2"><ClipboardList className="h-4 w-4" /></span>
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</button> </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")} <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"> 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> <span className="flex items-center gap-2">🖨 </span>
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</button> </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>
{/* 说明卡片 */}
<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> 线
<strong className="text-gray-700"></strong>线 <strong className="text-gray-700"></strong>
<strong className="text-gray-700"></strong>
</p> </p>
</div> </div>
</div> </div>

View File

@ -28,7 +28,14 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
return data; return data;
} }
export async function fetchRecentActivity(limit = 10): Promise<RecentActivity[]> { export async function fetchRecentActivity(
const { data } = await api.get<RecentActivity[]>("/dashboard/recent-activity", { params: { limit } }); 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; return data;
} }