feat: 新增产线看板功能并优化多端系统体验
1. 新增SSE看板广播机制,业务操作后主动推送刷新事件 2. 新增产线工序横道图与工位状态展示面板 3. 移除dashboard无用的three.js依赖 4. 重构WMS客户端布局与WMS前端资源哈希 5. 新增工位终端时钟图标与大屏自适应布局 6. 新增系统截图脚本与说明书生成工具 7. 修复多处代码细节与空值处理逻辑
This commit is contained in:
@@ -15,14 +15,12 @@
|
||||
"echarts": "^5.5.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"three": "^0.185.1",
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.0",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/three": "^0.185.4",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
|
||||
@@ -1,58 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Layout, Nav, Tag, Spin } from '@douyinfe/semi-ui';
|
||||
import { IconMonitorStroked, IconSaveStroked, IconGridView } from '@douyinfe/semi-icons';
|
||||
import { useDashboardData } from '@bj_power_dashboard/hooks/useDashboardData';
|
||||
import { ProductionBoard } from '@bj_power_dashboard/pages/ProductionBoard';
|
||||
import { WarehouseBoard } from '@bj_power_dashboard/pages/WarehouseBoard';
|
||||
import { LineModel } from '@bj_power_dashboard/pages/LineModel';
|
||||
import { ErrorRetry } from '@bj_power_dashboard/components/ErrorRetry';
|
||||
|
||||
type PageKey = 'production' | 'warehouse' | 'model';
|
||||
|
||||
const navItems = [
|
||||
{ itemKey: 'production', text: '生产总览', icon: <IconMonitorStroked /> },
|
||||
{ itemKey: 'warehouse', text: '仓储动态', icon: <IconSaveStroked /> },
|
||||
{ itemKey: 'model', text: '产线孪生', icon: <IconGridView /> },
|
||||
];
|
||||
|
||||
const CYCLE_ORDER: PageKey[] = ['production', 'warehouse', 'model'];
|
||||
const CYCLE_MS = 40_000;
|
||||
import { useState } from 'react';
|
||||
import { Layout, Tag, Spin } from '@douyinfe/semi-ui';
|
||||
import { useDashboardData } from './hooks/useDashboardData';
|
||||
import { ProductionBoard } from './pages/ProductionBoard';
|
||||
import { WarehouseBoard } from './pages/WarehouseBoard';
|
||||
import { LineModel } from './pages/LineModel';
|
||||
import { ErrorRetry } from './components/ErrorRetry';
|
||||
|
||||
export function App() {
|
||||
const [page, setPage] = useState<PageKey>('production');
|
||||
const { data: dashboard, mode, error, retry } = useDashboardData();
|
||||
const loading = !dashboard && !error;
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
// 无人值守自动轮播:40s 切下一屏;手动点选后重新计时
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setPage((cur) => {
|
||||
const idx = CYCLE_ORDER.indexOf(cur);
|
||||
return CYCLE_ORDER[(idx + 1) % CYCLE_ORDER.length];
|
||||
});
|
||||
}, CYCLE_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [page]);
|
||||
|
||||
const selectPage = (k: string) => setPage(k as PageKey);
|
||||
// 时钟
|
||||
setTimeout(() => setNow(new Date()), 1000);
|
||||
|
||||
return (
|
||||
<Layout className="app-shell">
|
||||
<Layout.Header className="app-header">
|
||||
<div className="app-title">北京电力智能产线 · 生产看板</div>
|
||||
<div className="app-meta">
|
||||
{mode === 'sse' && <Tag color="green">实时推送</Tag>}
|
||||
{mode === 'polling' && <Tag color="blue">轮询刷新</Tag>}
|
||||
{mode === 'demo' && <Tag color="amber">演示数据</Tag>}
|
||||
{mode === 'init' && <Tag color="grey">连接中</Tag>}
|
||||
<span className="clock">{new Date().toLocaleTimeString('zh-CN')}</span>
|
||||
<span className="clock">{now.toLocaleTimeString('zh-CN')}</span>
|
||||
<span className="date">{now.toLocaleDateString('zh-CN', { weekday: 'long' })}</span>
|
||||
</div>
|
||||
</Layout.Header>
|
||||
|
||||
<Layout.Sider style={{ background: 'transparent' }}>
|
||||
<Nav selectedKeys={[page]} onSelect={(k) => selectPage(String(k.itemKey))} items={navItems} />
|
||||
</Layout.Sider>
|
||||
|
||||
<Layout.Content className="app-content">
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
|
||||
@@ -60,9 +34,27 @@ export function App() {
|
||||
</div>
|
||||
) : (
|
||||
<ErrorRetry error={error} mode={mode} loading={loading} onRetry={retry}>
|
||||
{dashboard && page === 'production' && <ProductionBoard data={dashboard} />}
|
||||
{dashboard && page === 'warehouse' && <WarehouseBoard data={dashboard} />}
|
||||
{dashboard && page === 'model' && <LineModel data={dashboard} />}
|
||||
{dashboard ? (
|
||||
<div className="dashboard-grid">
|
||||
<section className="dash-section production">
|
||||
<div className="board-card">
|
||||
<ProductionBoard data={dashboard} />
|
||||
</div>
|
||||
</section>
|
||||
<section className="dash-section warehouse">
|
||||
<div className="board-card">
|
||||
<WarehouseBoard data={dashboard} />
|
||||
</div>
|
||||
</section>
|
||||
<section className="dash-section model">
|
||||
<LineModel data={dashboard} />
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>
|
||||
等待产线数据…
|
||||
</div>
|
||||
)}
|
||||
</ErrorRetry>
|
||||
)}
|
||||
</Layout.Content>
|
||||
|
||||
@@ -11,33 +11,45 @@ interface ErrorRetryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 数据加载失败时显示错误与重试按钮的兜底容器,避免空白崩溃。 */
|
||||
/** 数据异常时的兜底容器。
|
||||
* - 演示模式(demo):数据源为本地演示数据,有内容可看,只在顶部显示轻量提示条,不阻断大屏;
|
||||
* - 启动即失败且无任何数据(init + error):才全屏显示错误与重试,避免空白崩溃。 */
|
||||
export function ErrorRetry({ error, mode, loading, onRetry, children }: ErrorRetryProps) {
|
||||
const failed = Boolean(error) && !loading;
|
||||
const failed = mode === 'init' && Boolean(error) && !loading;
|
||||
if (failed) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<Empty description={<Typography.Text type="danger">{error}</Typography.Text>} />
|
||||
<Button theme="solid" icon={<IconRefresh />} onClick={onRetry} style={{ marginTop: 16 }}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{failed ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<Empty
|
||||
description={
|
||||
<Typography.Text type="danger">
|
||||
{error}
|
||||
{mode !== 'init' ? `(当前连接方式:${mode === 'sse' ? 'SSE' : 'HTTP 轮询'})` : ''}
|
||||
</Typography.Text>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
theme="solid"
|
||||
icon={<IconRefresh />}
|
||||
onClick={onRetry}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
{error && mode === 'demo' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 68,
|
||||
right: 18,
|
||||
zIndex: 99,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 10,
|
||||
fontSize: 11,
|
||||
color: 'rgba(245, 200, 106, 0.9)',
|
||||
background: 'rgba(40, 32, 12, 0.55)',
|
||||
border: '1px solid rgba(245, 200, 106, 0.25)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
pointerEvents: 'none',
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
演示数据 · 真实业务数据到位后自动切换
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,29 +13,24 @@ body {
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', system-ui, sans-serif;
|
||||
background: radial-gradient(1200px 600px at 20% -10%, #13203a 0%, #0b1220 60%);
|
||||
color: #e7eef8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.semi-always-dark .app-shell {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 20px;
|
||||
background: linear-gradient(90deg, #0f1a30, #14213d);
|
||||
border-bottom: 1px solid #22304a;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-meta {
|
||||
@@ -47,13 +42,71 @@ body {
|
||||
.app-meta .clock {
|
||||
font-size: 14px;
|
||||
color: #8fa3bf;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-meta .date {
|
||||
font-size: 12px;
|
||||
color: #5d6f8f;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
padding: 20px;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-content .semi-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: #22304a;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- 主布局:左侧紧凑栏(生产+仓储)+ 右侧大块 LineModel ----- */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 34%) 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'production model'
|
||||
'warehouse model';
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dash-section {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Section 内的可滚动 card(统一暗色背板) */
|
||||
.dash-section .board-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: rgba(13, 26, 51, 0.55);
|
||||
border: 1px solid rgba(56, 189, 248, 0.18);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.dash-section.production { grid-area: production; }
|
||||
.dash-section.warehouse { grid-area: warehouse; }
|
||||
.dash-section.model { grid-area: model; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto minmax(360px, 50vh);
|
||||
grid-template-areas:
|
||||
'production'
|
||||
'warehouse'
|
||||
'model';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,28 @@ export function getDemoData(): DashboardData {
|
||||
buildTraceItem(3, 55),
|
||||
];
|
||||
|
||||
// 当前订单 12 道工序横道图:工期 -10 ~ +16 天,每序约 2.17 天
|
||||
const stepDurationHours = ((planEnd.getTime() - planStart.getTime()) / 12) / 36e5;
|
||||
const progressSteps: DashboardData['progress']['steps'] = PROCESS_NAMES.map((name, i) => {
|
||||
const seqStart = new Date(planStart.getTime() + i * stepDurationHours * 36e5);
|
||||
const seqEnd = new Date(seqStart.getTime() + stepDurationHours * 36e5);
|
||||
const done = i < 6;
|
||||
const running = i === 6;
|
||||
const actualStart = done || running ? seqStart.toISOString() : undefined;
|
||||
const actualEnd = done ? seqEnd.toISOString() : running ? now.toISOString() : undefined;
|
||||
return {
|
||||
code: i + 1,
|
||||
name,
|
||||
planStart: seqStart.toISOString(),
|
||||
planEnd: seqEnd.toISOString(),
|
||||
actualStart,
|
||||
actualEnd,
|
||||
progress: done ? 100 : running ? 65 : 0,
|
||||
owner: OPERATORS[i % OPERATORS.length],
|
||||
status: done ? 'done' : running ? 'running' : ('pending' as const),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
production: {
|
||||
outputToday: 168,
|
||||
@@ -195,6 +217,7 @@ export function getDemoData(): DashboardData {
|
||||
status: '生产中',
|
||||
traceStepCount: 2016,
|
||||
traceable: true,
|
||||
steps: progressSteps,
|
||||
},
|
||||
traces,
|
||||
trends: { production: trends },
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
.linemodel-shell {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #040912, #050b1a 60%);
|
||||
border: 1px solid rgba(125, 211, 252, 0.18);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.linemodel-shell.is-pulsing {
|
||||
animation: linemodel-pulse 0.6s ease-out 1;
|
||||
}
|
||||
|
||||
@keyframes linemodel-pulse {
|
||||
0% { box-shadow: inset 0 0 0 0 rgba(125, 211, 252, 0.85); }
|
||||
35% { box-shadow: inset 0 0 0 6px rgba(125, 211, 252, 0.45); }
|
||||
100% { box-shadow: inset 0 0 0 12px rgba(125, 211, 252, 0); }
|
||||
}
|
||||
|
||||
.linemodel-shell .lm-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid rgba(125, 211, 252, 0.18);
|
||||
background: linear-gradient(90deg, rgba(13, 26, 51, 0.6), rgba(13, 26, 51, 0.0));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.linemodel-shell .lm-titlebar .lm-title {
|
||||
color: #67e8f9;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.linemodel-shell .lm-titlebar .lm-stat {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.linemodel-shell .lm-titlebar .lm-stat b {
|
||||
color: #f1f5f9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.linemodel-svg {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.linemodel-shell .lm-watermark {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 6px;
|
||||
font-size: 10px;
|
||||
color: rgba(148, 163, 184, 0.55);
|
||||
letter-spacing: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.linemodel-svg .legend-text {
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
fill: #cbd5e1;
|
||||
font-size: 3px;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import type { DashboardData, Station } from '../types';
|
||||
import type { DashboardData, Station, ProgressGanttStep } from '../types';
|
||||
import { EChart } from '../components/EChart';
|
||||
|
||||
const C = {
|
||||
@@ -54,7 +54,7 @@ export function ProductionBoard({ data }: { data: DashboardData }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, gridAutoRows: 'min-content' }}>
|
||||
<Kpi value={production.outputToday} label="今日产量" sub={`目标 ${production.targetToday}`} color={C.cyan} />
|
||||
<Kpi value={production.passCount} label="一次通过" sub="件 · 全工序合格" color={C.green} />
|
||||
<Kpi value={production.inLine} label="在制工件" sub="产线流转中" color={C.blue} />
|
||||
@@ -66,17 +66,18 @@ export function ProductionBoard({ data }: { data: DashboardData }) {
|
||||
/>
|
||||
|
||||
<Panel title="近 7 日产量趋势" style={{ gridColumn: 'span 2' }}>
|
||||
<EChart option={trendOption} height={230} />
|
||||
<EChart option={trendOption} height={200} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="当前订单" style={{ gridColumn: 'span 2' }} accent>
|
||||
<Panel title="当前订单 · 工期横道" style={{ gridColumn: 'span 2' }} accent>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
rowGap: 10,
|
||||
rowGap: 8,
|
||||
columnGap: 14,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Field label="合同号" />
|
||||
@@ -89,77 +90,85 @@ export function ProductionBoard({ data }: { data: DashboardData }) {
|
||||
<span style={{ fontSize: 14, color: C.cyan }}>{progress.owner || '—'}</span>
|
||||
|
||||
<Field label="工期" />
|
||||
<span style={{ fontSize: 14 }}>
|
||||
{progress.planStart} ~ {progress.planEnd}
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{progress.planStart || '—'} ~ {progress.planEnd || '—'}
|
||||
<span style={{ marginLeft: 10, color: progress.remainDays >= 0 ? C.green : C.amber }}>
|
||||
剩余 {Math.max(progress.remainDays, 0)} 天
|
||||
剩余 {progress.remainDays >= 0 ? progress.remainDays : 0} 天
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: C.sub }}>完成度</span>
|
||||
<span style={{ fontSize: 13, color: C.sub }}>
|
||||
<span style={{ fontSize: 20, fontWeight: 500, color: C.cyan }}>{progress.doneQty}</span>
|
||||
{' / '}
|
||||
{progress.totalQty} 台
|
||||
</span>
|
||||
</div>
|
||||
<Bar percent={progress.progress} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: C.sub }}>
|
||||
完成度 {progress.progress}% · 工序 {progress.processDone}/{progress.processTotal}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: C.sub }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 500, color: C.cyan }}>{progress.doneQty}</span>
|
||||
{' / '}{progress.totalQty} 台
|
||||
</span>
|
||||
</div>
|
||||
<Bar percent={progress.progress} />
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
|
||||
<Chip label="工序进度" value={`${progress.processDone} / ${progress.processTotal} 序`} color={C.cyan} />
|
||||
<Chip label="当前工序" value={progress.currentProcess || '—'} color={C.blue} />
|
||||
<Chip
|
||||
label="责任追溯"
|
||||
value={progress.traceable ? `${progress.traceStepCount} 条 · 全程可追溯` : '待采集'}
|
||||
color={progress.traceable ? C.green : C.dim}
|
||||
/>
|
||||
<div style={{ marginTop: 14, height: 260 }}>
|
||||
{progress.steps.length > 0 ? (
|
||||
<GanttChart steps={progress.steps} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: C.dim,
|
||||
fontSize: 13,
|
||||
border: `1px dashed ${C.border}`,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
暂无工序横道数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="工位状态(12 工位 · 扫码枪 + 拧紧枪)" style={{ gridColumn: 'span 4' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 10 }}>
|
||||
{equipment.map((s) => (
|
||||
<Panel title="工位作业(12 工位 · 详见产线 2D 顶视)" style={{ gridColumn: 'span 4' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: 6 }}>
|
||||
{equipment.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
title={`${s.name} · ${s.currentOperator || '—'} · ${s.currentSn || ''}`}
|
||||
style={{
|
||||
border: `1px solid ${statusColor[s.status]}55`,
|
||||
borderLeft: `3px solid ${statusColor[s.status]}`,
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
border: `1px solid ${statusColor[s.status]}33`,
|
||||
borderTop: `3px solid ${statusColor[s.status]}`,
|
||||
borderRadius: 4,
|
||||
padding: '4px 4px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{s.name}</span>
|
||||
<span style={{ fontSize: 11, color: statusColor[s.status] }}>
|
||||
{s.status === 'running'
|
||||
? '作业中'
|
||||
: s.status === 'idle'
|
||||
? '待料'
|
||||
: s.status === 'alarm'
|
||||
? '异常'
|
||||
: '离线'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 10 }}>
|
||||
<span style={{ color: C.text, fontWeight: 600 }}>{s.name}</span>
|
||||
<span style={{ color: C.dim, fontVariantNumeric: 'tabular-nums' }}>{i + 1}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: C.dim, marginTop: 4 }}>
|
||||
{s.currentOperator || '—'}
|
||||
<div style={{ fontSize: 10, color: statusColor[s.status], marginTop: 2, fontWeight: 500 }}>
|
||||
{s.status === 'running'
|
||||
? '作业中'
|
||||
: s.status === 'idle'
|
||||
? '待料'
|
||||
: s.status === 'alarm'
|
||||
? '异常'
|
||||
: '离线'}
|
||||
</div>
|
||||
{s.currentSn && <div style={{ fontSize: 11, color: C.sub, marginTop: 2 }}>{s.currentSn}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: C.dim }}>
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: C.dim }}>
|
||||
运行中 {production.stationSummary.running} · 待料 {production.stationSummary.idle} · 离线{' '}
|
||||
{production.stationSummary.offline}
|
||||
</div>
|
||||
@@ -235,19 +244,98 @@ function Bar({ percent }: { percent: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
border: `1px solid ${color}44`,
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: C.dim }}>{label}</div>
|
||||
<div style={{ fontSize: 13, color, marginTop: 3 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
function GanttChart({ steps }: { steps: ProgressGanttStep[] }) {
|
||||
const names = steps.map((s) => s.name);
|
||||
const now = new Date();
|
||||
|
||||
const planData = steps.map((s, i) => ({
|
||||
value: [i, new Date(s.planStart).getTime(), new Date(s.planEnd).getTime(), 'plan'],
|
||||
itemStyle: { color: 'rgba(148, 163, 184, 0.12)' },
|
||||
}));
|
||||
|
||||
const actualData = steps
|
||||
.filter((s) => s.actualStart)
|
||||
.map((s, i) => {
|
||||
const start = new Date(s.actualStart!).getTime();
|
||||
const end = s.actualEnd ? new Date(s.actualEnd).getTime() : now.getTime();
|
||||
const color = s.status === 'done' ? C.green : C.cyan;
|
||||
return {
|
||||
value: [i, start, end, 'actual'],
|
||||
itemStyle: { color },
|
||||
};
|
||||
});
|
||||
|
||||
const option: EChartsOption = {
|
||||
tooltip: {
|
||||
formatter: (params: any) => {
|
||||
const s = steps[params.value[0] as number];
|
||||
const start = new Date(params.value[1] as number).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
const end = new Date(params.value[2] as number).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
return `<div style="font-size:12px"><b>${s.name}</b><br/>负责人:${s.owner}<br/>状态:${s.status === 'done' ? '已完成' : s.status === 'running' ? '进行中' : '未开始'}<br/>${start} ~ ${end}</div>`;
|
||||
},
|
||||
},
|
||||
grid: { left: 96, right: 24, top: 10, bottom: 24 },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
axisLine: { lineStyle: { color: '#335' } },
|
||||
axisLabel: { color: '#8fa3bf', fontSize: 11, formatter: '{MM}-{dd}' },
|
||||
splitLine: { lineStyle: { color: '#1f2a3f' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
inverse: true,
|
||||
axisLine: { lineStyle: { color: '#335' } },
|
||||
axisLabel: { color: '#94a3b8', fontSize: 11 },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: '计划工期',
|
||||
renderItem: (_params: any, api: any) => {
|
||||
const categoryIndex = api.value(0);
|
||||
const start = api.coord([api.value(1), categoryIndex]);
|
||||
const end = api.coord([api.value(2), categoryIndex]);
|
||||
const height = api.size([0, 1])[1] * 0.55;
|
||||
return {
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: start[0],
|
||||
y: start[1] - height / 2,
|
||||
width: Math.max(end[0] - start[0], 2),
|
||||
height,
|
||||
},
|
||||
style: api.style(),
|
||||
};
|
||||
},
|
||||
encode: { x: [1, 2], y: 0 },
|
||||
data: planData,
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
name: '实际进度',
|
||||
renderItem: (_params: any, api: any) => {
|
||||
const categoryIndex = api.value(0);
|
||||
const start = api.coord([api.value(1), categoryIndex]);
|
||||
const end = api.coord([api.value(2), categoryIndex]);
|
||||
const height = api.size([0, 1])[1] * 0.32;
|
||||
return {
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: start[0],
|
||||
y: start[1] - height / 2,
|
||||
width: Math.max(end[0] - start[0], 2),
|
||||
height,
|
||||
},
|
||||
style: api.style(),
|
||||
};
|
||||
},
|
||||
encode: { x: [1, 2], y: 0 },
|
||||
data: actualData,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <EChart option={option} height={260} />;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export class DashboardDataService {
|
||||
|
||||
private controller: AbortController | null = null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private pollingInFlight = false;
|
||||
|
||||
private changeListeners = new Set<ChangeListener>();
|
||||
private modeListeners = new Set<ModeListener>();
|
||||
@@ -121,13 +122,14 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
private async pollOnce() {
|
||||
if (!this.running) return;
|
||||
if (!this.running || this.pollingInFlight) return;
|
||||
this.pollingInFlight = true;
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}${dashboardEndpoints.snapshot}`, {
|
||||
headers: { 'X-API-TOKEN': this.token },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const raw = (await res.json()) as Partial<DashboardData>;
|
||||
const raw = unwrapPayload(await res.json()) as Partial<DashboardData>;
|
||||
this.accept(raw, 'polling');
|
||||
} catch (err) {
|
||||
if (!this.running) return;
|
||||
@@ -137,6 +139,8 @@ export class DashboardDataService {
|
||||
`MES 不可用,已切换演示数据(${err instanceof Error ? err.message : '网络异常'})`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.pollingInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,12 +166,20 @@ export class DashboardDataService {
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
for (const block of blocks) {
|
||||
const dataLine = block.split('\n').find((l) => l.startsWith('data:'));
|
||||
const lines = block.split('\n');
|
||||
const dataLine = lines.find((l) => l.startsWith('data:'));
|
||||
if (!dataLine) continue;
|
||||
const payload = dataLine.replace(/^data:\s*/, '').trim();
|
||||
if (!payload) continue;
|
||||
// 事件驱动:业务数据落库后后端广播 dashboard.updated(空载荷),
|
||||
// 收到即拉一次最新快照,实时性优于轮询周期。
|
||||
const evtLine = lines.find((l) => l.startsWith('event:'));
|
||||
if (evtLine && evtLine.includes('dashboard.updated')) {
|
||||
void this.pollOnce();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as Partial<DashboardData>;
|
||||
const parsed = unwrapPayload(JSON.parse(payload)) as Partial<DashboardData>;
|
||||
// 忽略 hello / 心跳等无数据载荷(无 production 且无 updatedAt)
|
||||
if (!parsed || typeof parsed !== 'object' || (!parsed.production && !parsed.updatedAt)) {
|
||||
continue;
|
||||
@@ -210,6 +222,15 @@ function isEmpty(d: DashboardData): boolean {
|
||||
return !d.progress?.orderNo && (d.production?.outputToday ?? 0) === 0;
|
||||
}
|
||||
|
||||
/** 后端响应统一为 {code,message,data},取 data 层;SSE 心跳推的是裸对象,两种都兼容 */
|
||||
function unwrapPayload(j: unknown): unknown {
|
||||
if (j && typeof j === 'object' && !Array.isArray(j)) {
|
||||
const o = j as { code?: unknown; data?: unknown };
|
||||
if (o.code !== undefined && 'data' in o) return o.data;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段补全:后端可能只返回部分字段,缺失一律补零值,
|
||||
* 避免前端到处判空,也避免 undefined 传进图表。
|
||||
@@ -259,6 +280,7 @@ function normalize(raw: Partial<DashboardData>): DashboardData {
|
||||
status: pr.status ?? '',
|
||||
traceStepCount: pr.traceStepCount ?? 0,
|
||||
traceable: pr.traceable ?? false,
|
||||
steps: pr.steps ?? [],
|
||||
},
|
||||
traces: raw.traces ?? [],
|
||||
trends: raw.trends ?? { production: [] },
|
||||
|
||||
@@ -22,8 +22,12 @@ export interface ToolState {
|
||||
/** 工位:装配线上一工位 = 一把扫码枪 + 一把拧紧枪 */
|
||||
export interface Station {
|
||||
id: number;
|
||||
/** 工位编号 */
|
||||
stationNo?: string;
|
||||
name: string;
|
||||
status: StationStatus;
|
||||
/** 工位已完成件数(按今日累计) */
|
||||
doneCount?: number;
|
||||
scanGun: ToolState;
|
||||
tighteningGun: ToolState;
|
||||
currentSn?: string;
|
||||
@@ -76,6 +80,27 @@ export interface WarehouseOverview {
|
||||
* 当前订单(屏1 核心卡片)
|
||||
* 展示:合同信息、负责人、工期、完成度、可追溯。
|
||||
*/
|
||||
export interface ProgressGanttStep {
|
||||
/** 工序编码 */
|
||||
code: number;
|
||||
/** 工序名称 */
|
||||
name: string;
|
||||
/** 计划开始时间 ISO */
|
||||
planStart: string;
|
||||
/** 计划结束时间 ISO */
|
||||
planEnd: string;
|
||||
/** 实际开始时间 ISO(未开始为空) */
|
||||
actualStart?: string;
|
||||
/** 实际结束时间 ISO(进行中/未开始为空) */
|
||||
actualEnd?: string;
|
||||
/** 该工序完成百分比 0-100 */
|
||||
progress: number;
|
||||
/** 该工序负责人 */
|
||||
owner: string;
|
||||
/** 状态:未开始 / 进行中 / 已完成 */
|
||||
status: 'pending' | 'running' | 'done';
|
||||
}
|
||||
|
||||
export interface ProductionProgress {
|
||||
/** 工单号 */
|
||||
orderNo: string;
|
||||
@@ -106,6 +131,8 @@ export interface ProductionProgress {
|
||||
traceStepCount: number;
|
||||
/** 是否全程可追溯 */
|
||||
traceable: boolean;
|
||||
/** 当前订单 12 道工序横道图数据 */
|
||||
steps: ProgressGanttStep[];
|
||||
}
|
||||
|
||||
/** 工件追溯:单道工序实绩 */
|
||||
|
||||
Reference in New Issue
Block a user