@ -2,9 +2,9 @@ import { useEffect, useState, useCallback, useRef } from "react";
import {
Package , ClipboardList , Bell , TrendingUp , AlertTriangle ,
RefreshCw , Loader2 , AlertCircle , ArrowRight , ArrowUp , ArrowDown , ArrowUpDown ,
Clock , MessageCircle , CheckCircle2 , Users ,
Clock , MessageCircle , CheckCircle2 , Users , Info ,
} from "lucide-react" ;
import { Radio , DatePicker , Drawer , Input } from "antd" ;
import { Radio , DatePicker , Drawer , Input , Tooltip } from "antd" ;
import dayjs , { type Dayjs } from "dayjs" ;
import {
fetchDashboardStats , fetchWipTasks , fetchDashboardMessages , fetchCompletedTasks , fetchRejectedTasks , fetchUserOperations ,
@ -34,10 +34,16 @@ function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
}
// ─── 进度条( 支持3或4段) ─────────────────────────────────
function ProgressBar ( { a , b , c , total , labels , d } : {
function ProgressBar ( { a , b , c , total , labels , d , hints } : {
a : number ; b : number ; c : number ; total : number ;
labels : [ string , string , string ] ;
d? : number ;
/**
* 图例口径说明: label → 悬浮提示文案。
* 用于消除「产品数 > 任务数」这类口径差异带来的误判 —— 底层逻辑没问题,
* 但不写清楚就极像算错数据。
*/
hints? : Record < 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 ) ;
@ -60,6 +66,11 @@ function ProgressBar({ a, b, c, total, labels, d }: {
< span key = { i } className = "flex items-center gap-1" >
< span className = { ` inline-block h-2 w-2 rounded-full ${ s . color } ` } / >
{ s . label } { s . n } ( { pct ( s . n ) } % )
{ hints ? . [ s . label ] && (
< Tooltip title = { hints [ s . label ] } >
< Info className = "h-3 w-3 shrink-0 cursor-help text-gray-400 transition-colors hover:text-gray-600" / >
< / Tooltip >
) }
< / span >
) ) }
< / div >
@ -190,43 +201,34 @@ function CompletedRow({ t }: { t: CompletedTask }) {
}
// ─── 被驳回明细项(卡片式) ──────────────────────────────
/**
* 驳回明细行。
*
* 只渲染「已驳回」这一种形态:一次驳回必然派生一条返工任务,两条同时成行会
* 让看板看起来「有两条异常」。而本行已经带了「返工给: xxx」, 一条就足以说清
* 「这道工序被驳回了,现在由谁在返工」的完整语义,返工任务无需再独立成行。
*/
function RejectedRow ( { t } : { t : RejectedTask } ) {
const timeStr = t . rejected_at ? dayjs ( t . rejected_at ) . format ( "YYYY-MM-DD HH:mm" ) : "" ;
const isRework = t . kind === "rework" ;
return (
< div
onClick = { ( ) = > t . product_sn && window . open ( ` /admin/tasks?sn= ${ t . product_sn } ` , "_blank" ) }
className = "cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md"
>
< div className = "flex items-center gap-2" >
{ isRework ? < RefreshCw className = "h-4 w-4 shrink-0 text-orange-500" / > : <AlertTriangle className = "h-4 w-4 shrink-0 text-red-500" / > }
< AlertTriangle className = "h-4 w-4 shrink-0 text-red-500" / >
< span className = "text-sm font-semibold text-gray-800 truncate" > { t . task_name } < / span >
< span className = "text-xs text-gray-500" > | < / span >
{ isRework ? (
< span className = "shrink-0 rounded-full bg-orange -50 px-2 py-0.5 text-[10px] font-bold text-orange-600" >
{ t . status === "WIP" ? "返工中" : t . status === "PENDING" ? "待返工" : "已返工" }
< / span >
) : (
< span className = "shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600" > 已 驳 回 < / span >
) }
< span className = "text-xs text-gray-500 shrink-0" >
{ isRework ? ` 返工人: ${ t . rework_assignee } ` : ` 驳回人: ${ t . rejected_by } ` }
< / span >
< span className = "shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600" > 已 驳 回 < / span >
< span className = "text-xs text-gray -500 shrink-0" > 驳 回 人 : { t . rejected_by } < / span >
< span className = "ml-auto shrink-0 text-xs text-gray-400" > { timeStr } < / span >
< / div >
< div className = "mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400" >
{ ! isRework && (
< >
< span className = "inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600" >
🔁 返 工 给 : { t . rework_assignee }
< / span >
< span className = "text-gray-300" > | < / span >
< span className = "truncate text-red-500/80" title = { t . reject_reason || "" } > 原 因 : { t . reject_reason || "—" } < / span >
< / >
) }
{ isRework && (
< span className = "truncate text-orange-600/80" > 🔁 返 工 任 务 · { t . rework_assignee } 负 责 < / span >
) }
< span className = "inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600" >
🔁 返 工 给 : { t . rework_assignee }
< / span >
< span className = "text-gray-300" > | < / span >
< span className = "truncate text-red-500/80" title = { t . reject_reason || "" } > 原 因 : { t . reject_reason || "—" } < / span >
< / div >
< div className = "mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400" >
< span className = "font-medium text-gray-500" > { t . material_name || "未知设备" } < / span >
@ -245,6 +247,9 @@ function RejectedRow({ t }: { t: RejectedTask }) {
export default function AdminDashboard() {
const [ stats , setStats ] = useState < DashboardStats | null > ( null ) ;
const [ wipTasks , setWipTasks ] = useState < WipTask [ ] > ( [ ] ) ;
// 在制品列表的真实总数,与「任务状态」卡片的活跃任务同源(待接收 + 进行中)。
// 用它而不是 wipTasks.length 做计数,避免接口截断时列表谎报总数。
const activeTaskCount = ( stats ? . tasks_pending ? ? 0 ) + ( stats ? . tasks_in_progress ? ? 0 ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
@ -288,7 +293,7 @@ export default function AdminDashboard() {
const { since , until } = rangeToParams ( key , range ) ;
Promise . all ( [
fetchDashboardStats ( since , until ) ,
fetchWipTasks ( 20 ) ,
fetchWipTasks ( ) , // 不传 limit: 取全量在制品, 避免被截断后与卡片数字对不上
] )
. then ( ( [ s , w ] ) = > { setStats ( s ) ; setWipTasks ( w ) ; setError ( null ) ; } )
. catch ( ( ) = > setError ( "加载失败,请确认后端已启动" ) )
@ -469,12 +474,6 @@ export default function AdminDashboard() {
< / div >
< / div >
{ /* 提示:已完结受时间筛选 */ }
{ dateKey !== "today" && (
< div className = "rounded-lg bg-blue-50 px-3 py-1.5 text-[11px] text-blue-600" >
📐 当 前 时 间 筛 选 仅 影 响 < strong > 已 完 成 / 已 驳 回 < / strong > 计 数 , 在 制 品 和 总 数 始 终 为 实 时 快 照
< / div >
) }
{ /* ═══ 4 卡片 ═══ */ }
< div className = "grid gap-4 sm:grid-cols-2 xl:grid-cols-4" >
@ -485,9 +484,12 @@ 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 = { [ "待流转" , "流转中" , "已完结" ] } / >
total = { stats . products_total } labels = { [ "待流转" , "流转中" , "已完结" ] }
hints = { {
"待流转" : "指已建档,但尚未派发首道工序,或当前无任何活跃工序挂载的物理产品。因此待流转数可能会大于待接收任务数。" ,
} } / >
< / div >
{ /* 任务状态 */ }
@ -499,7 +501,10 @@ export default function AdminDashboard() {
< 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" > PENDING / WIP 实 时 | COMPLETED 按 时 段 < / p >
< ProgressBar a = { stats . tasks_pending } b = { stats . tasks_in_progress } c = { stats . tasks_completed }
total = { stats . tasks_total } labels = { [ "待接收" , "进行中" , "已完成" ] } d = { stats . tasks_rejected } / >
total = { stats . tasks_total } labels = { [ "待接收" , "进行中" , "已完成" ] } d = { stats . tasks_rejected }
hints = { {
"待接收" : "指系统已生成具体工序单并指派给工人,但工人尚未在手机端点击接单的任务。" ,
} } / >
< / div >
{ /* 品质 & 留言 */ }
@ -571,12 +576,18 @@ export default function AdminDashboard() {
< h3 className = "flex items-center gap-2 text-sm font-semibold text-gray-700" >
< Clock className = "h-4 w-4 text-orange-500" / > 当 前 在 制 品 ( 实 时 )
< / h3 >
< span className = "text-[11px] text-gray-400" > 共 { wipTasks. length} 个 < / span >
{ /* 计数取「任务状态」卡片同源的真实活跃数,而不是 wipTasks. length ——
后者一旦被接口截断就会比实际少,出现「卡片 23、列表共 20」的假象 */ }
< span className = "text-[11px] text-gray-400" >
共 { activeTaskCount } 个
{ wipTasks . length < activeTaskCount && ` (仅显示前 ${ wipTasks . length } 条) ` }
< / span >
< / div >
{ wipTasks . length === 0 ? (
< div className = "py-10 text-center text-sm text-gray-400" > 🎉 暂 无 滞 留 任 务 < / div >
) : (
< div >
// 容器内滚动:列表可能很长(全量在制品),不让它把整页撑开
< div className = "max-h-[420px] overflow-y-auto pr-1" >
< 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 >
@ -692,7 +703,7 @@ export default function AdminDashboard() {
{ /* ═══ 驳回/返工明细抽屉 ═══ */ }
< Drawer
title = { < span className = "text-base font-bold" > 🔴 驳 回 / 返 工 明 细 < span className = "font-normal text-gray-400" > 共 { rejectedTasks . length } 条 < / span > < / span > }
title = { < span className = "text-base font-bold" > 🔴 驳 回 明 细 < span className = "font-normal text-gray-400" > 共 { rejectedTasks . filter ( t = > t . kind === "rejected" ) . length} 条 < / span > < / span > }
open = { rejectedDrawerOpen }
onClose = { ( ) = > setRejectedDrawerOpen ( false ) }
size = "large"
@ -702,37 +713,29 @@ export default function AdminDashboard() {
< div className = "flex items-center justify-center py-20" >
< Loader2 className = "h-6 w-6 animate-spin text-red-500" / >
< / div >
) : rejectedTasks . length === 0 ? (
< div className = "py-16 text-center text-sm text-gray-400" >
该 时 段 暂 无 驳 回 / 返 工 记 录
< / div >
) : (
< div className = "space-y-4" >
{ ( ( ) = > {
const rejected = rejectedTasks . filter ( t = > t . kind === "rejected" ) ;
const rework = rejectedTasks . filter ( t = > t . kind === "rework " ) ;
( ( ) = > {
// 只展示「已驳回」行:返工任务是驳回自动派生的,独立成行会让同一次
// 异常看起来像两条(详见 RejectedRow 顶部说明)。
const rejected = rejectedTasks . filter ( t = > t . kind === "rejected " ) ;
if ( rejected . length === 0 ) {
return (
< >
{ rejected . length > 0 && (
< div >
< h4 className = "mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600" >
< AlertTriangle className = "h-3.5 w-3.5" / > 已 驳 回 · { rejected . length } 条
< / h4 >
{ rejected . map ( t = > < RejectedRow key = { t . task_id } t = { t } / > ) }
< / div >
) }
{ rework . length > 0 && (
< div >
< h4 className = "mb-2 flex items-center gap-1.5 text-xs font-bold text-orange-600" >
< RefreshCw className = "h-3.5 w-3.5" / > 返 工 任 务 · { rework . length } 条
< / h4 >
{ rework . map ( t = > < RejectedRow key = { t . task_id } t = { t } / > ) }
< / div >
) }
< / >
< div className = "py-16 text-center text-sm text-gray-400" >
该 时 段 暂 无 驳 回 记 录
</ div >
) ;
} ) ( ) }
< / div >
}
return (
< div className = "space-y-4" >
< div >
< h4 className = "mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600" >
< AlertTriangle className = "h-3.5 w-3.5" / > 已 驳 回 · { rejected . length } 条
< / h4 >
{ rejected . map ( t = > < RejectedRow key = { t . task_id } t = { t } / > ) }
< / div >
< / div >
) ;
} ) ( )
) }
< / Drawer >