初始化2

This commit is contained in:
SunYF
2026-08-28 15:06:01 +08:00
parent b34325a16f
commit a9c3fbeb41
537 changed files with 176215 additions and 17 deletions
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Layout, Nav, Tag, Spin } from '@douyinfe/semi-ui';
import { IconMonitorStroked, IconSaveStroked, IconAlertCircle, 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 { AlarmBoard } from '@bj_power_dashboard/pages/AlarmBoard';
import { LineModel } from '@bj_power_dashboard/pages/LineModel';
import { ErrorRetry } from '@bj_power_dashboard/components/ErrorRetry';
type PageKey = 'production' | 'warehouse' | 'alarms' | 'model';
export function App() {
const [page, setPage] = useState<PageKey>('production');
const { data: dashboard, mode, error, retry } = useDashboardData();
const loading = !dashboard && !error;
const navItems = [
{ itemKey: 'production', text: '生产数据看板', icon: <IconMonitorStroked /> },
{ itemKey: 'warehouse', text: '仓储数据看板', icon: <IconSaveStroked /> },
{ itemKey: 'alarms', text: '异常报警', icon: <IconAlertCircle /> },
{ itemKey: 'model', text: '产线模型', icon: <IconGridView /> },
];
return (
<Layout className="app-shell">
<Layout.Header className="app-header">
<div className="app-title">线 · </div>
<div className="app-meta">
{mode === 'sse' ? (
<Tag color="green">SSE </Tag>
) : mode === 'polling' ? (
<Tag color="blue">HTTP 10s</Tag>
) : (
<Tag color="grey"></Tag>
)}
<span className="clock">{new Date().toLocaleTimeString('zh-CN')}</span>
</div>
</Layout.Header>
<Layout.Sider style={{ background: 'transparent' }}>
<Nav
selectedKeys={[page]}
onSelect={(k) => setPage(k.itemKey as PageKey)}
items={navItems}
/>
</Layout.Sider>
<Layout.Content className="app-content">
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
<Spin size="large" tip="数据加载中…" />
</div>
) : (
<ErrorRetry error={error} mode={mode} loading={loading} onRetry={retry}>
{dashboard && page === 'production' && <ProductionBoard data={dashboard} />}
{dashboard && page === 'warehouse' && <WarehouseBoard data={dashboard} />}
{dashboard && page === 'alarms' && <AlarmBoard data={dashboard} />}
{dashboard && page === 'model' && <LineModel data={dashboard} />}
</ErrorRetry>
)}
</Layout.Content>
</Layout>
);
}
@@ -0,0 +1,28 @@
import { useEffect, useRef } from 'react';
import * as echarts from 'echarts';
import type { EChartsOption } from 'echarts';
interface EChartProps {
option: EChartsOption;
height?: number | string;
}
/** 简易 ECharts 容器,option 变化时自动更新,卸载时销毁实例。 */
export function EChart({ option, height = 260 }: EChartProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const chart = echarts.init(el);
chart.setOption(option);
const onResize = () => chart.resize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
chart.dispose();
};
}, [option]);
return <div ref={ref} style={{ width: '100%', height }} />;
}
@@ -0,0 +1,43 @@
import { Button, Empty, Typography } from '@douyinfe/semi-ui';
import { IconRefresh } from '@douyinfe/semi-icons';
import type { ReactNode } from 'react';
import type { DataMode } from '../hooks/useDashboardData';
interface ErrorRetryProps {
error: string | null;
mode: DataMode;
loading: boolean;
onRetry: () => void;
children: ReactNode;
}
/** 数据加载失败时显示错误与重试按钮的兜底容器,避免空白崩溃。 */
export function ErrorRetry({ error, mode, loading, onRetry, children }: ErrorRetryProps) {
const failed = Boolean(error) && !loading;
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>
</div>
) : (
children
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 全局配置:MES 地址、内部 API Token、轮询间隔、是否使用演示数据。
*
* - MES 地址优先取环境变量 VITE_MES_BASE_URL,其次取此处的默认常量。
* - 内部鉴权 Token 统一为 Hardman_2026,请求头 X-API-TOKEN。
* - VITE_USE_MOCK 未显式设为 'false' 时默认开启演示数据,保证无 MES 时页面可展示、不空白。
*/
const DEFAULT_MES_BASE_URL = 'http://127.0.0.1:8000';
export const config = {
/** MES 后端基础地址 */
mesBaseUrl: import.meta.env.VITE_MES_BASE_URL || DEFAULT_MES_BASE_URL,
/** 内部服务间鉴权 Token(请求头 X-API-TOKEN */
internalToken: 'Hardman_2026',
/** SSE 失效时降级为 HTTP 轮询的间隔(毫秒) */
pollIntervalMs: 10_000,
/** 是否使用演示数据(替代真实 MES 调用) */
useMock: import.meta.env.VITE_USE_MOCK !== 'false',
};
/** MES 看板数据接口清单 */
export const dashboardEndpoints = {
overview: '/api/internal/dashboard/overview',
equipment: '/api/internal/dashboard/equipment',
progress: '/api/internal/dashboard/progress',
alarms: '/api/internal/dashboard/alarms',
trends: '/api/internal/dashboard/trends',
} as const;
/** 数据加载方式 */
export type DataMode = 'sse' | 'polling';
@@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from 'react';
import { DashboardDataService } from '../services/dashboardService';
import type { DashboardData } from '../types';
export type DataMode = 'sse' | 'polling' | 'init';
interface UseDashboardResult {
data: DashboardData | null;
mode: DataMode;
error: string | null;
retry: () => void;
}
/**
* 订阅看板数据。SSE 优先,失败自动降级为 HTTP 轮询;
* 数据加载失败时暴露 error 与 retry,供 UI 展示重试/错误状态。
*/
export function useDashboardData(): UseDashboardResult {
const [data, setData] = useState<DashboardData | null>(null);
const [mode, setMode] = useState<DataMode>('init');
const [error, setError] = useState<string | null>(null);
const serviceRef = useRef<DashboardDataService | null>(null);
useEffect(() => {
const svc = new DashboardDataService();
serviceRef.current = svc;
const offChange = svc.onChange((d) => {
setData(d);
if (d) setError(null);
});
const offMode = svc.onModeChange((m) => setMode(m));
const offError = svc.onError((msg) => setError(msg));
svc.start();
return () => {
offChange();
offMode();
offError();
svc.stop();
serviceRef.current = null;
};
}, []);
const retry = () => {
setError(null);
serviceRef.current?.retry();
};
return { data, mode, error, retry };
}
+59
View File
@@ -0,0 +1,59 @@
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
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;
}
.app-shell {
min-height: 100%;
}
.semi-always-dark .app-shell {
background: transparent;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: linear-gradient(90deg, #0f1a30, #14213d);
border-bottom: 1px solid #22304a;
}
.app-title {
font-size: 22px;
font-weight: 600;
letter-spacing: 1px;
}
.app-meta {
display: flex;
align-items: center;
gap: 12px;
}
.app-meta .clock {
font-size: 14px;
color: #8fa3bf;
}
.app-content {
padding: 20px;
}
.app-content .semi-card {
background: rgba(255, 255, 255, 0.03);
border-color: #22304a;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import { App } from './App';
// 启用 Semi Design 暗色主题(适合大屏看板)
document.body.classList.add('semi-always-dark');
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+125
View File
@@ -0,0 +1,125 @@
import type { DashboardData, Station, ToolStatus } from './types';
/** 生成演示设备状态(装配线:扫码枪 + 拧紧枪) */
function buildStations(): Station[] {
const statusPool: ToolStatus[] = ['online', 'busy', 'busy', 'online', 'alarm', 'busy'];
return Array.from({ length: 12 }, (_, i) => {
const id = i + 1;
const scan = statusPool[id % statusPool.length];
const tight: ToolStatus =
id % 6 === 0 ? 'offline' : id % 5 === 0 ? 'alarm' : 'busy';
const css = scan === 'alarm' || tight === 'alarm' ? 'alarm' : 'running';
return {
id,
name: `工位${id}`,
status: id % 6 === 0 ? 'offline' : css,
scanGun: { status: scan, processedCount: 30 + id * 13 },
tighteningGun: {
status: tight,
currentSn: tight === 'busy' ? `SN-${2500 + id * 7}` : undefined,
processedCount: 28 + id * 11,
},
currentSn: `SN-${2400 + id * 5}`,
};
});
}
/** 演示数据(无 MES 时使用,页面不空白) */
export function getMockData(): DashboardData {
const now = Date.now();
const hour = new Date().getHours();
const trends = Array.from({ length: 12 }, (_, i) => {
const t = new Date(now - (11 - i) * 30 * 60_000);
const hh = `${String(t.getHours()).padStart(2, '0')}:${String(
t.getMinutes(),
).padStart(2, '0')}`;
return {
time: hh,
output: 18 + i * 4 + (i % 3),
qualifiedRate: 94 + (i % 5),
};
});
return {
production: {
outputToday: 168 + hour,
targetToday: 260,
qualifiedRate: 96.4,
qualityOk: 162,
qualityNg: 6,
stationSummary: { running: 10, idle: 1, offline: 1, alarm: 1 },
},
warehouse: {
totalStock: 12860,
materialTypes: 46,
inboundToday: 1320,
outboundToday: 890,
movements: [
{ id: 'm1', time: '09:12', type: 'in', material: '电路板 PCBA-01', qty: 200, operator: '李工' },
{ id: 'm2', time: '09:28', type: 'out', material: '装配螺丝 M6', qty: 500, operator: '王工' },
{ id: 'm3', time: '10:05', type: 'in', material: '外壳上盖-铝合金', qty: 120, operator: '李工' },
{ id: 'm4', time: '10:41', type: 'out', material: '密封圈 硅胶', qty: 300, operator: '赵工' },
{ id: 'm5', time: '11:03', type: 'in', material: '端子排 T-38', qty: 80, operator: '李工' },
{ id: 'm6', time: '11:20', type: 'out', material: '装配螺栓 M8', qty: 460, operator: '王工' },
],
},
equipment: buildStations(),
progress: {
orderNo: 'WO-20260828-007',
productName: '智能电能表 DDS-02',
totalQty: 260,
doneQty: 168,
completedQty: 162,
currentProcess: '工序8 / 12',
status: '生产中',
},
alarms: [
{
id: 'a1',
level: 'critical',
type: 'equipment',
target: '工位6 拧紧枪',
message: '拧紧扭矩超上限(目标 5.0 N·m,实测 5.6 N·m',
time: '10:52',
status: 'active',
},
{
id: 'a2',
level: 'warning',
type: 'material',
target: '工位4',
message: '结构件 装配螺丝 M6 余量不足(剩余 12 件)',
time: '11:08',
status: 'active',
},
{
id: 'a3',
level: 'critical',
type: 'equipment',
target: '工位6 扫码枪',
message: '连续 5 次扫码未识别',
time: '11:12',
status: 'active',
},
{
id: 'a4',
level: 'warning',
type: 'material',
target: '库房',
message: '端子排 T-38 库存低于安全库存',
time: '11:15',
status: 'active',
},
{
id: 'a5',
level: 'warning',
type: 'equipment',
target: '工位3 拧紧枪',
message: '角度偏差过大,已复检',
time: '09:44',
status: 'resolved',
},
],
trends: { production: trends },
};
}
@@ -0,0 +1,67 @@
import { Card, Table, Tag, Typography } from '@douyinfe/semi-ui';
import type { DashboardData, AlarmItem } from '../types';
const typeLabel: Record<AlarmItem['type'], string> = {
equipment: '设备故障',
material: '物料短缺',
};
export function AlarmBoard({ data }: { data: DashboardData }) {
const rows = data.alarms.map((a) => ({
key: a.id,
level: a.level,
type: a.type,
target: a.target,
message: a.message,
time: a.time,
status: a.status,
}));
const activeCount = data.alarms.filter((a) => a.status === 'active').length;
const columns = [
{
title: '级别',
dataIndex: 'level',
width: 90,
render: (level: AlarmItem['level']) => (
<Tag color={level === 'critical' ? 'red' : 'orange'}>
{level === 'critical' ? '严重' : '警告'}
</Tag>
),
},
{
title: '类型',
dataIndex: 'type',
width: 110,
render: (type: AlarmItem['type']) => typeLabel[type] ?? type,
},
{ title: '对象', dataIndex: 'target', width: 140 },
{ title: '描述', dataIndex: 'message' },
{ title: '时间', dataIndex: 'time', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (status: AlarmItem['status']) => (
<Tag color={status === 'active' ? 'red' : 'green'}>
{status === 'active' ? '处理中' : '已解决'}
</Tag>
),
},
];
return (
<div>
<Card title={`异常报警(当前告警 ${activeCount} 条)`}>
{rows.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Typography.Text type="tertiary"></Typography.Text>
</div>
) : (
<Table columns={columns} dataSource={rows} pagination={false} rowKey="key" />
)}
</Card>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { Card, Tag, Tooltip } from '@douyinfe/semi-ui';
import type { DashboardData, Station, ToolStatus } from '../types';
const toolColor: Record<ToolStatus, string> = {
online: '#00d68f',
busy: '#36a3f7',
alarm: '#ff5252',
offline: '#8fa3bf',
};
const toolText: Record<ToolStatus, string> = {
online: '在线',
busy: '作业中',
alarm: '报警',
offline: '离线',
};
const stationColor: Record<Station['status'], string> = {
running: '#00d68f',
idle: '#36a3f7',
alarm: '#ff5252',
offline: '#8fa3bf',
};
/**
* 平面产线模型:12 个工位,每个工位含「扫码枪 + 拧紧枪」两台设备。
* 本装配线仅这两类设备,无其他设备。
*/
export function LineModel({ data }: { data: DashboardData }) {
const stations = data.equipment;
return (
<Card title="产线平面模型(12 工位 · 扫码枪 + 拧紧枪)">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 14,
justifyContent: stations.length ? 'flex-start' : 'center',
}}
>
{stations.length === 0 && (
<Tag color="grey"></Tag>
)}
{stations.map((s) => (
<div
key={s.id}
style={{
width: 148,
border: `2px solid ${stationColor[s.status]}`,
borderRadius: 8,
padding: 10,
background: 'rgba(255,255,255,0.03)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<strong>{s.name}</strong>
<Tag color={s.status === 'alarm' ? 'red' : s.status === 'offline' ? 'grey' : 'green'}>
{statusText(s.status)}
</Tag>
</div>
<Tooltip content={`扫码枪 · 累计 ${s.scanGun.processedCount}`}>
<DeviceLine label="扫码枪" status={s.scanGun.status} />
</Tooltip>
<Tooltip
content={`拧紧枪 · 累计 ${s.tighteningGun.processedCount}${
s.tighteningGun.currentSn ? ` · ${s.tighteningGun.currentSn}` : ''
}`}
>
<DeviceLine label="拧紧枪" status={s.tighteningGun.status} />
</Tooltip>
{s.currentSn && (
<div style={{ marginTop: 8, fontSize: 12, color: '#8fa3bf' }}>
{s.currentSn}
</div>
)}
</div>
))}
</div>
<div style={{ marginTop: 20, display: 'flex', gap: 20, flexWrap: 'wrap' }}>
<Legend color="#00d68f" label="在线 / 运行" />
<Legend color="#36a3f7" label="作业中 / 空闲" />
<Legend color="#ff5252" label="报警" />
<Legend color="#8fa3bf" label="离线" />
</div>
</Card>
);
}
function DeviceLine({ label, status }: { label: string; status: ToolStatus }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: 5, background: toolColor[status] }} />
<span style={{ fontSize: 13 }}>{label}</span>
<span style={{ fontSize: 12, color: '#8fa3bf' }}>{toolText[status]}</span>
</div>
);
}
function statusText(s: Station['status']): string {
switch (s) {
case 'running':
return '运行';
case 'idle':
return '空闲';
case 'alarm':
return '报警';
default:
return '离线';
}
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#8fa3bf' }}>
<span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: 5, background: color }} />
{label}
</span>
);
}
@@ -0,0 +1,157 @@
import { Card, Progress, Tag, Typography } from '@douyinfe/semi-ui';
import type { EChartsOption } from 'echarts';
import type { DashboardData } from '../types';
import { EChart } from '../components/EChart';
const gridLabel = {
color: '#8fa3bf',
fontSize: 13,
};
export function ProductionBoard({ data }: { data: DashboardData }) {
const { production, progress, trends } = data;
const st = production.stationSummary;
const prog =
progress.totalQty > 0 ? (progress.completedQty / progress.totalQty) * 100 : 0;
const ringOption: EChartsOption = {
tooltip: {
formatter: '{b}: {c}{d}%',
},
series: [
{
type: 'pie',
radius: ['62%', '82%'],
silent: true,
label: { show: true, position: 'center', formatter: '{value}%'.replace('{value}', String(production.qualifiedRate)), fontSize: 30, fontWeight: 'bold', color: '#00d68f' },
data: [
{ value: production.qualityOk, name: '合格', itemStyle: { color: '#00d68f' } },
{ value: production.qualityNg, name: '不合格', itemStyle: { color: '#ff5252' } },
],
},
],
};
const trendOption: EChartsOption = {
grid: { left: 40, right: 16, top: 28, bottom: 24 },
tooltip: { trigger: 'axis' },
legend: { textStyle: gridLabel, top: 0, data: ['当日产量', '合格率'] },
xAxis: {
type: 'category',
data: trends.production.map((t) => t.time),
axisLine: { lineStyle: { color: '#335' } },
axisLabel: gridLabel,
},
yAxis: [
{
type: 'value',
name: '产量',
splitLine: { lineStyle: { color: '#1f2a3f' } },
axisLabel: gridLabel,
},
{
type: 'value',
name: '合格率',
min: 80,
max: 100,
splitLine: { show: false },
axisLabel: gridLabel,
},
],
series: [
{
name: '当日产量',
type: 'line',
smooth: true,
data: trends.production.map((t) => t.output),
itemStyle: { color: '#36a3f7' },
areaStyle: { color: 'rgba(54,163,247,0.2)' },
},
{
name: '合格率',
type: 'line',
yAxisIndex: 1,
smooth: true,
data: trends.production.map((t) => t.qualifiedRate),
itemStyle: { color: '#00d68f' },
},
],
};
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
<Card title="产线运行状态" style={{ gridColumn: 'span 2' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 12,
textAlign: 'center',
}}
>
<Cell value={st.running} label="运行中" color="#00d68f" />
<Cell value={st.idle} label="空闲" color="#36a3f7" />
<Cell value={st.alarm} label="报警" color="#ff5252" />
<Cell value={st.offline} label="离线" color="#8fa3bf" />
</div>
</Card>
<Card title="当日产量">
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 36, fontWeight: 700, color: '#36a3f7' }}>
{production.outputToday}
</span>
<span style={{ color: '#8fa3bf' }}>/ {production.targetToday}</span>
</div>
<div style={{ marginTop: 8 }}>
<Typography.Text type="tertiary"></Typography.Text>
<Progress
percent={
production.targetToday
? Math.round((production.outputToday / production.targetToday) * 100)
: 0
}
showInfo
style={{ marginTop: 8 }}
/>
</div>
</Card>
<Card title="合格率">
<EChart option={ringOption} height={170} />
</Card>
<Card title="工单进度">
<div style={{ marginBottom: 8 }}>
<Typography.Text strong>{progress.orderNo}</Typography.Text>
<Tag size="small" style={{ marginLeft: 8 }} color="green">
{progress.status}
</Tag>
</div>
<Typography.Text type="tertiary">{progress.productName}</Typography.Text>
<div style={{ margin: '10px 0' }}>
<Typography.Text>
{progress.completedQty} / {progress.totalQty}
</Typography.Text>
</div>
<Progress percent={Math.round(prog)} showInfo stroke="#5cabff" />
<div style={{ marginTop: 12 }}>
<Typography.Text type="secondary">{progress.currentProcess}</Typography.Text>
</div>
</Card>
<Card title="产量与合格率趋势" style={{ gridColumn: 'span 4' }}>
<EChart option={trendOption} height={280} />
</Card>
</div>
);
}
function Cell({ value, label, color }: { value: number; label: string; color: string }) {
return (
<div>
<div style={{ fontSize: 32, fontWeight: 600, color }}>{value}</div>
<div style={{ color: '#8fa3bf', fontSize: 13 }}>{label}</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { Card, Table, Tag, Typography } from '@douyinfe/semi-ui';
import type { DashboardData } from '../types';
interface MovementRow {
id: string;
time: string;
dir: string;
material: string;
qty: string;
operator: string;
}
export function WarehouseBoard({ data }: { data: DashboardData }) {
const { warehouse: w } = data;
const rows: MovementRow[] = w.movements.map((m) => ({
id: m.id,
time: m.time,
dir: m.type === 'in' ? '入库' : '出库',
material: m.material,
qty: `${m.qty}`,
operator: m.operator,
}));
const columns = [
{ title: '时间', dataIndex: 'time' },
{
title: '方向',
dataIndex: 'dir',
render: (dir: string) => (
<Tag color={dir === '入库' ? 'green' : 'blue'}>{dir}</Tag>
),
},
{ title: '物料名称', dataIndex: 'material' },
{ title: '数量', dataIndex: 'qty' },
{ title: '操作人', dataIndex: 'operator' },
];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
<Card title="库存总量" style={{ gridColumn: 'span 2' }}>
<BigNumber value={w.totalStock} unit="件" color="#36a3f7" />
</Card>
<Card title="物料种类">
<BigNumber value={w.materialTypes} unit="种" color="#00d68f" />
</Card>
<Card title="当日出入库">
<div style={{ display: 'flex', gap: 24 }}>
<TextStat color="#00d68f" value={w.inboundToday} label="入库" />
<TextStat color="#36a3f7" value={w.outboundToday} label="出库" />
</div>
</Card>
<Card title="出入库动态(实时)" style={{ gridColumn: 'span 4' }}>
<Table
columns={columns}
dataSource={rows}
pagination={false}
rowKey="id"
empty={<Typography.Text type="tertiary"></Typography.Text>}
/>
</Card>
</div>
);
}
function TextStat({ value, label, color }: { value: number; label: string; color: string }) {
return (
<div>
<div style={{ fontSize: 28, fontWeight: 600, color }}>{value}</div>
<div style={{ color: '#8fa3bf', fontSize: 13 }}>{label}</div>
</div>
);
}
function BigNumber({
value,
unit,
color,
}: {
value: number;
unit: string;
color: string;
}) {
return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 36, fontWeight: 700, color }}>{value}</span>
<span style={{ color: '#8fa3bf' }}>{unit}</span>
</div>
);
}
@@ -0,0 +1,228 @@
import { config, dashboardEndpoints } from '../config';
import { getMockData } from '../mock';
import type { DashboardData, Station } from '../types';
type ChangeListener = (data: DashboardData | null) => void;
type ModeListener = (mode: 'sse' | 'polling') => void;
type ErrorListener = (message: string) => void;
/** MES 各接口返回片段 */
interface EndpointOverview {
production?: DashboardData['production'];
warehouse?: DashboardData['warehouse'];
}
interface EndpointEquipment {
stations?: Station[];
}
interface EndpointAlarms {
items?: DashboardData['alarms'];
}
/**
* 数据源:优先尝试 SSE(经 fetch 实现,可携带 X-API-TOKEN 请求头);
* SSE 建立或读取失败时降级为 10s HTTP 轮询。
*/
export class DashboardDataService {
private baseUrl = config.mesBaseUrl;
private token = config.internalToken;
private useMock = config.useMock;
private controller: AbortController | null = null;
private timer: ReturnType<typeof setInterval> | null = null;
private changeListeners = new Set<ChangeListener>();
private modeListeners = new Set<ModeListener>();
private errorListeners = new Set<ErrorListener>();
mode: 'sse' | 'polling' | 'init' = 'init';
private running = false;
onChange(cb: ChangeListener): () => void {
this.changeListeners.add(cb);
return () => this.changeListeners.delete(cb);
}
onModeChange(cb: ModeListener): () => void {
this.modeListeners.add(cb);
return () => this.modeListeners.delete(cb);
}
onError(cb: ErrorListener): () => void {
this.errorListeners.add(cb);
return () => this.errorListeners.delete(cb);
}
private emit(data: DashboardData | null) {
this.changeListeners.forEach((l) => l(data));
}
private setMode(m: 'sse' | 'polling') {
this.mode = m;
this.modeListeners.forEach((l) => l(m));
}
private emitError(msg: string) {
this.errorListeners.forEach((l) => l(msg));
}
/** 启动数据流(幂等,可安全重复调用) */
start() {
if (this.running) return;
this.running = true;
if (this.useMock) {
// 演示模式:直接给出数据
this.setMode('polling');
this.emit(getMockData());
return;
}
void this.openSse();
}
/** 停止数据流并清理资源 */
stop() {
this.running = false;
this.closeStream();
this.clearTimer();
this.changeListeners.clear();
this.modeListeners.clear();
this.errorListeners.clear();
}
/** 手动重试:恢复默认时先重新建立 SSE,失败则轮询 */
retry() {
if (!this.running) this.running = true;
if (this.useMock) {
this.emit(getMockData());
return;
}
this.clearTimer();
void this.openSse();
}
// ---------- SSE ----------
private async openSse() {
this.closeStream();
const url = `${this.baseUrl}/api/internal/dashboard/stream`;
const controller = new AbortController();
this.controller = controller;
try {
const res = await fetch(url, {
headers: { 'X-API-TOKEN': this.token },
signal: controller.signal,
});
if (!res.ok || !res.body) throw new Error(`SSE 连接失败:HTTP ${res.status}`);
this.setMode('sse');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按空行切分 SSE 事件块
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const dataLine = block
.split('\n')
.find((l) => l.startsWith('data:'));
if (!dataLine) continue;
const payload = dataLine.replace(/^data:\s*/, '').trim();
try {
const parsed = JSON.parse(payload) as DashboardData;
if (parsed) this.emit(parsed);
} catch {
// 忽略无法解析的单条事件,继续读取
}
}
}
} catch (err) {
if (!this.running) return;
if (err instanceof Error && err.name === 'AbortError') return;
this.emitError('SSE 连接失败,已降级为 HTTP 轮询(每 10s 刷新)');
this.fallbackToPolling();
}
}
private closeStream() {
this.controller?.abort();
this.controller = null;
}
// ---------- HTTP 轮询(降级) ----------
private fallbackToPolling() {
this.clearTimer();
void this.pollOnce();
this.timer = setInterval(() => void this.pollOnce(), config.pollIntervalMs);
}
private async pollOnce() {
try {
const headers = { 'X-API-TOKEN': this.token };
const fetchJson = async <T>(path: string): Promise<T> => {
const res = await fetch(`${this.baseUrl}${path}`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T;
};
const [overview, equipment, progress, alarms, trends] = await Promise.all([
fetchJson<EndpointOverview>(dashboardEndpoints.overview),
fetchJson<EndpointEquipment>(dashboardEndpoints.equipment),
fetchJson<DashboardData['progress']>(dashboardEndpoints.progress),
fetchJson<EndpointAlarms>(dashboardEndpoints.alarms),
fetchJson<DashboardData['trends']>(dashboardEndpoints.trends),
]);
const assembled: DashboardData = {
production: overview.production ?? this.emptyProduction(),
warehouse: overview.warehouse ?? this.emptyWarehouse(),
equipment: equipment.stations ?? [],
progress: progress ?? this.emptyProgress(),
alarms: alarms.items ?? [],
trends: trends ?? { production: [] },
};
this.setMode('polling');
this.emit(assembled);
} catch (err) {
this.emit(null);
this.emitError(
`数据加载失败:${err instanceof Error ? err.message : '网络异常或无 MES 服务'}`,
);
}
}
private clearTimer() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
// ---------- 空值兜底 ----------
private emptyProduction(): DashboardData['production'] {
return {
outputToday: 0,
targetToday: 0,
qualifiedRate: 0,
qualityOk: 0,
qualityNg: 0,
stationSummary: { running: 0, idle: 0, offline: 0, alarm: 0 },
};
}
private emptyWarehouse(): DashboardData['warehouse'] {
return {
totalStock: 0,
materialTypes: 0,
inboundToday: 0,
outboundToday: 0,
movements: [],
};
}
private emptyProgress(): DashboardData['progress'] {
return {
orderNo: '-',
productName: '-',
totalQty: 0,
doneQty: 0,
completedQty: 0,
currentProcess: '-',
status: '-',
};
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* 看板数据结构定义(camelCase)。
* 产线为纯装配线,设备仅「扫码枪 + 拧紧枪」,不含其他设备。
*/
export type StationStatus = 'running' | 'idle' | 'alarm' | 'offline';
export type ToolStatus = 'online' | 'offline' | 'busy' | 'alarm';
export type AlarmLevel = 'critical' | 'warning';
export type AlarmType = 'equipment' | 'material';
export type AlarmStatus = 'active' | 'resolved';
/** 扫码枪 / 拧紧枪 状态 */
export interface ToolState {
status: ToolStatus;
/** 当前正在作业的序列号(拧紧枪采集对象) */
currentSn?: string;
/** 累计作业件数 */
processedCount: number;
}
/** 工位:装配线上一工位 = 一把扫码枪 + 一把拧紧枪 */
export interface Station {
id: number;
name: string;
status: StationStatus;
scanGun: ToolState;
tighteningGun: ToolState;
currentSn?: string;
}
/** 生产/产线运行概览 */
export interface ProductionOverview {
/** 产线当日产量 */
outputToday: number;
/** 当日计划产量 */
targetToday: number;
/** 合格率(0-100 */
qualifiedRate: number;
qualityOk: number;
qualityNg: number;
stationSummary: {
running: number;
idle: number;
offline: number;
alarm: number;
};
}
/** 仓储数据概览 */
export interface StockMovement {
id: string;
time: string;
type: 'in' | 'out';
material: string;
qty: number;
operator: string;
}
export interface WarehouseOverview {
totalStock: number;
materialTypes: number;
inboundToday: number;
outboundToday: number;
movements: StockMovement[];
}
/** 工单进度 */
export interface ProductionProgress {
orderNo: string;
productName: string;
totalQty: number;
completedQty: number;
/** 校验合格数量(合格率用) */
doneQty: number;
currentProcess: string;
status: string;
}
/** 报警项 */
export interface AlarmItem {
id: string;
level: AlarmLevel;
type: AlarmType;
target: string;
message: string;
time: string;
status: AlarmStatus;
}
/** 趋势数据 */
export interface TrendSeries {
time: string;
output: number;
qualifiedRate: number;
}
export interface TrendData {
production: TrendSeries[];
}
/** 看板完整数据快照 */
export interface DashboardData {
production: ProductionOverview;
warehouse: WarehouseOverview;
equipment: Station[];
progress: ProductionProgress;
alarms: AlarmItem[];
trends: TrendData;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />