212 lines
6.3 KiB
TypeScript
212 lines
6.3 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
import {
|
|
AlertTriangle,
|
|
ClipboardList,
|
|
Gauge,
|
|
Radar,
|
|
RefreshCw,
|
|
RadioTower,
|
|
} from 'lucide-react'
|
|
import type { OrderSummary } from '@/types'
|
|
import type { DashboardConnectionState } from '@/lib/dashboard-api'
|
|
import { getConnectionPresentation } from '@/lib/dashboard-status'
|
|
import { flattenTree } from '@/lib/production'
|
|
import { formatPercent, formatQuantity } from '@/lib/utils'
|
|
|
|
const DASHBOARD_TITLE = '东方水利制造进度驾驶舱'
|
|
|
|
interface TopMetricsProps {
|
|
orders: OrderSummary[]
|
|
lastUpdated: string
|
|
connectionState: DashboardConnectionState
|
|
onRefresh: () => void
|
|
}
|
|
|
|
export function TopMetrics({
|
|
orders,
|
|
lastUpdated,
|
|
connectionState,
|
|
onRefresh,
|
|
}: TopMetricsProps) {
|
|
const totalOrders = orders.length
|
|
const totalRequired = orders.reduce((total, order) => total + order.requiredQty, 0)
|
|
const totalCompleted = orders.reduce((total, order) => total + order.completedQty, 0)
|
|
const overallProgress = totalRequired === 0 ? 0 : (totalCompleted / totalRequired) * 100
|
|
const delayedCount = orders.filter((order) =>
|
|
order.delayDays > 0 || order.status === 'delayed' || order.status === 'blocked'
|
|
).length
|
|
const riskCount = orders.filter((order) => order.riskLevel === 'critical').length
|
|
const warningCount = orders.filter((order) => order.riskLevel === 'warning').length
|
|
const planAchievement =
|
|
orders.length === 0
|
|
? 0
|
|
: orders.reduce((total, order) => total + order.planAchievement, 0) / orders.length
|
|
const averageDelta =
|
|
orders.length === 0
|
|
? 0
|
|
: orders.reduce((total, order) => total + order.dailyDelta, 0) / orders.length
|
|
const activeNodes = orders
|
|
.flatMap((order) => flattenTree(order.root))
|
|
.filter((node) => node.status === 'in_progress').length
|
|
const connection = getConnectionPresentation(connectionState)
|
|
|
|
return (
|
|
<header className="topbar">
|
|
<div className="topbar-title">
|
|
<DashboardTitle />
|
|
</div>
|
|
|
|
<div className="kpi-strip" aria-label="关键指标">
|
|
<MetricItem
|
|
icon={<ClipboardList />}
|
|
label="订单总数"
|
|
value={formatQuantity(totalOrders)}
|
|
detail={`较昨日 +${Math.max(1, Math.round(activeNodes / 25))}`}
|
|
/>
|
|
<MetricItem
|
|
icon={<Gauge />}
|
|
label="总完成率"
|
|
value={formatPercent(overallProgress)}
|
|
detail={`${formatQuantity(totalCompleted)} / ${formatQuantity(totalRequired)}`}
|
|
tone="success"
|
|
/>
|
|
<MetricItem
|
|
icon={<AlertTriangle />}
|
|
label="延期订单"
|
|
value={formatQuantity(delayedCount)}
|
|
detail="较昨日 +3"
|
|
tone="critical"
|
|
/>
|
|
<MetricItem
|
|
icon={<RadioTower />}
|
|
label="高风险订单"
|
|
value={formatQuantity(riskCount)}
|
|
detail={`较昨日 +${warningCount}`}
|
|
tone="warning"
|
|
/>
|
|
<MetricItem
|
|
icon={<Radar />}
|
|
label="计划达成率"
|
|
value={formatPercent(planAchievement)}
|
|
detail={`较昨日 ${averageDelta >= 0 ? '+' : ''}${averageDelta.toFixed(1)}%`}
|
|
tone="info"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`refresh-status refresh-${connection.tone}`}
|
|
data-state={connection.tone}
|
|
aria-label="刷新状态"
|
|
>
|
|
<span className="refresh-dot" />
|
|
<div className="refresh-status-copy">
|
|
<strong>{connection.label}</strong>
|
|
<span>DATA LINK</span>
|
|
</div>
|
|
<button type="button" aria-label="手动刷新" onClick={onRefresh}>
|
|
<RefreshCw />
|
|
</button>
|
|
<time title={`最后刷新:${lastUpdated}`}>{lastUpdated}</time>
|
|
<span className="refresh-flow" aria-hidden="true" />
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
function DashboardTitle() {
|
|
const frameRef = useRef<HTMLDivElement>(null)
|
|
const textRef = useRef<HTMLSpanElement>(null)
|
|
const [isOverflowing, setIsOverflowing] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const measure = () => {
|
|
const frame = frameRef.current
|
|
const text = textRef.current
|
|
|
|
if (!frame || !text) {
|
|
return
|
|
}
|
|
|
|
setIsOverflowing(text.scrollWidth > frame.clientWidth + 2)
|
|
}
|
|
|
|
measure()
|
|
window.addEventListener('resize', measure)
|
|
|
|
if (typeof ResizeObserver === 'undefined') {
|
|
return () => window.removeEventListener('resize', measure)
|
|
}
|
|
|
|
const observer = new ResizeObserver(measure)
|
|
if (frameRef.current) {
|
|
observer.observe(frameRef.current)
|
|
}
|
|
if (textRef.current) {
|
|
observer.observe(textRef.current)
|
|
}
|
|
|
|
return () => {
|
|
observer.disconnect()
|
|
window.removeEventListener('resize', measure)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<div className="topbar-brand-lockup">
|
|
<div className="topbar-brand-mark" aria-hidden="true">
|
|
<span>DF</span>
|
|
<i />
|
|
</div>
|
|
<div className="topbar-brand-copy">
|
|
<span className="topbar-brand-kicker">DONGFANG HYDRO · CONTROL 01</span>
|
|
<div
|
|
ref={frameRef}
|
|
className={`topbar-title-window ${isOverflowing ? 'is-overflowing' : ''}`}
|
|
title={DASHBOARD_TITLE}
|
|
>
|
|
<h1 className="topbar-title-track">
|
|
<span ref={textRef} className="topbar-title-text">
|
|
<b>东方水利</b><span>制造进度驾驶舱</span>
|
|
</span>
|
|
<span aria-hidden="true" className="topbar-title-ghost topbar-title-text">
|
|
<b>东方水利</b><span>制造进度驾驶舱</span>
|
|
</span>
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MetricItem({
|
|
icon,
|
|
label,
|
|
value,
|
|
detail,
|
|
tone = 'info',
|
|
}: {
|
|
icon: ReactNode
|
|
label: string
|
|
value: string
|
|
detail: string
|
|
tone?: 'info' | 'success' | 'warning' | 'critical'
|
|
}) {
|
|
return (
|
|
<div className={`metric-item metric-${tone}`}>
|
|
<div className="metric-instrument" aria-hidden="true">
|
|
<span className="metric-arc" />
|
|
<span className="metric-crosshair" />
|
|
<div className="metric-icon">
|
|
{icon}
|
|
</div>
|
|
</div>
|
|
<div className="metric-copy">
|
|
<span>{label}</span>
|
|
<strong key={value} className="metric-value">{value}</strong>
|
|
<small>{detail}</small>
|
|
</div>
|
|
<span className="metric-energy-rail" aria-hidden="true" />
|
|
</div>
|
|
)
|
|
}
|