feat: 新增产线看板功能并优化多端系统体验

1. 新增SSE看板广播机制,业务操作后主动推送刷新事件
2. 新增产线工序横道图与工位状态展示面板
3. 移除dashboard无用的three.js依赖
4. 重构WMS客户端布局与WMS前端资源哈希
5. 新增工位终端时钟图标与大屏自适应布局
6. 新增系统截图脚本与说明书生成工具
7. 修复多处代码细节与空值处理逻辑
This commit is contained in:
SunYF
2026-09-04 17:31:18 +08:00
parent b6799c9de4
commit 92f0439d89
64 changed files with 2263 additions and 633 deletions
-2
View File
@@ -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"
+33 -41
View File
@@ -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>
);
}
}
+68 -15
View File
@@ -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';
}
}
+23
View File
@@ -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
+158 -70
View File
@@ -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: [] },
+27
View File
@@ -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[];
}
/** 工件追溯:单道工序实绩 */