初始化2
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '-',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user