feat: 完成产线与仓储系统多模块迭代升级
本次迭代覆盖MES与WMS核心业务: 1. 新增接驳台托盘传感器读取与AGV对接能力 2. 完善工单排产、备料流程与权限体系拆分 3. 优化看板接口与前端路由、样式 4. 新增操作日志、库存盘点与角色保护逻辑 5. 修复代理地址、BOM保存等已知问题
This commit is contained in:
@@ -1,31 +1,34 @@
|
||||
import { config, dashboardEndpoints } from '../config';
|
||||
import { getMockData } from '../mock';
|
||||
import type { DashboardData, Station } from '../types';
|
||||
import { getDemoData } from '../mock';
|
||||
import type {
|
||||
DashboardData,
|
||||
ProductionOverview,
|
||||
ProductionProgress,
|
||||
WarehouseOverview,
|
||||
} from '../types';
|
||||
|
||||
export type DataMode = 'sse' | 'polling' | 'demo' | 'init';
|
||||
|
||||
type ChangeListener = (data: DashboardData | null) => void;
|
||||
type ModeListener = (mode: 'sse' | 'polling') => void;
|
||||
type ModeListener = (mode: DataMode) => 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 轮询。
|
||||
* 看板数据源。
|
||||
*
|
||||
* 双通道设计:
|
||||
* 1. HTTP 快照轮询(兜底保真)——每 pollIntervalMs 拉一次 /dashboard/snapshot,
|
||||
* 保证真实数据一到位、或演示期间业务起量后能自动切回,不依赖事件推送。
|
||||
* 2. SSE 长连接(实时增强)——业务事件发生时即时刷新,可用时顶部标“实时推送”。
|
||||
*
|
||||
* 关于演示数据:看板是纯展示系统,MES 业务库在投产前为空,直连会让大屏显示全 0,
|
||||
* 比展示演示数据更糟。因此真实数据为空(无工单且无产量)时自动降级演示数据并在界面标注,
|
||||
* 演示期间轮询不停止,真实数据一到位即自动切回。
|
||||
*/
|
||||
export class DashboardDataService {
|
||||
private baseUrl = config.mesBaseUrl;
|
||||
private token = config.internalToken;
|
||||
private useMock = config.useMock;
|
||||
private forceDemo = config.forceDemo;
|
||||
|
||||
private controller: AbortController | null = null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -34,7 +37,7 @@ export class DashboardDataService {
|
||||
private modeListeners = new Set<ModeListener>();
|
||||
private errorListeners = new Set<ErrorListener>();
|
||||
|
||||
mode: 'sse' | 'polling' | 'init' = 'init';
|
||||
mode: DataMode = 'init';
|
||||
private running = false;
|
||||
|
||||
onChange(cb: ChangeListener): () => void {
|
||||
@@ -53,7 +56,8 @@ export class DashboardDataService {
|
||||
private emit(data: DashboardData | null) {
|
||||
this.changeListeners.forEach((l) => l(data));
|
||||
}
|
||||
private setMode(m: 'sse' | 'polling') {
|
||||
private setMode(m: DataMode) {
|
||||
if (this.mode === m) return;
|
||||
this.mode = m;
|
||||
this.modeListeners.forEach((l) => l(m));
|
||||
}
|
||||
@@ -61,44 +65,85 @@ export class DashboardDataService {
|
||||
this.errorListeners.forEach((l) => l(msg));
|
||||
}
|
||||
|
||||
/** 启动数据流(幂等,可安全重复调用) */
|
||||
/** 启动数据流(幂等) */
|
||||
start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
if (this.useMock) {
|
||||
// 演示模式:直接给出数据
|
||||
this.setMode('polling');
|
||||
this.emit(getMockData());
|
||||
if (this.forceDemo) {
|
||||
this.useDemo('已启用强制演示模式');
|
||||
return;
|
||||
}
|
||||
void this.pollOnce();
|
||||
this.startPolling();
|
||||
void this.openSse();
|
||||
}
|
||||
|
||||
/** 停止数据流并清理资源 */
|
||||
stop() {
|
||||
this.running = false;
|
||||
this.closeStream();
|
||||
this.clearTimer();
|
||||
this.stopPolling();
|
||||
this.changeListeners.clear();
|
||||
this.modeListeners.clear();
|
||||
this.errorListeners.clear();
|
||||
}
|
||||
|
||||
/** 手动重试:恢复默认时先重新建立 SSE,失败则轮询 */
|
||||
/** 手动重试:重走一次完整数据流 */
|
||||
retry() {
|
||||
if (!this.running) this.running = true;
|
||||
if (this.useMock) {
|
||||
this.emit(getMockData());
|
||||
if (this.forceDemo) {
|
||||
this.useDemo('已启用强制演示模式');
|
||||
return;
|
||||
}
|
||||
this.clearTimer();
|
||||
this.closeStream();
|
||||
this.stopPolling();
|
||||
void this.pollOnce();
|
||||
this.startPolling();
|
||||
void this.openSse();
|
||||
}
|
||||
|
||||
// ---------- SSE ----------
|
||||
/** 降级到演示数据(轮询不停止,真实数据到位后自动切回) */
|
||||
private useDemo(reason: string) {
|
||||
this.setMode('demo');
|
||||
this.emit(getDemoData());
|
||||
this.emitError(reason);
|
||||
}
|
||||
|
||||
// ---------- HTTP 快照轮询(兜底保真) ----------
|
||||
private startPolling() {
|
||||
this.stopPolling();
|
||||
this.timer = setInterval(() => void this.pollOnce(), config.pollIntervalMs);
|
||||
}
|
||||
private stopPolling() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollOnce() {
|
||||
if (!this.running) return;
|
||||
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>;
|
||||
this.accept(raw, 'polling');
|
||||
} catch (err) {
|
||||
if (!this.running) return;
|
||||
// 已处于演示模式且后端仍不可达时,不再重复弹错/重发演示数据
|
||||
if (this.mode !== 'demo') {
|
||||
this.useDemo(
|
||||
`MES 不可用,已切换演示数据(${err instanceof Error ? err.message : '网络异常'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SSE(实时增强) ----------
|
||||
private async openSse() {
|
||||
this.closeStream();
|
||||
const url = `${this.baseUrl}/api/internal/dashboard/stream`;
|
||||
const url = `${this.baseUrl}${dashboardEndpoints.stream}`;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
try {
|
||||
@@ -107,7 +152,6 @@ export class DashboardDataService {
|
||||
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 = '';
|
||||
@@ -115,28 +159,32 @@ export class DashboardDataService {
|
||||
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:'));
|
||||
const dataLine = block.split('\n').find((l) => l.startsWith('data:'));
|
||||
if (!dataLine) continue;
|
||||
const payload = dataLine.replace(/^data:\s*/, '').trim();
|
||||
if (!payload) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as DashboardData;
|
||||
if (parsed) this.emit(parsed);
|
||||
const parsed = JSON.parse(payload) as Partial<DashboardData>;
|
||||
// 忽略 hello / 心跳等无数据载荷(无 production 且无 updatedAt)
|
||||
if (!parsed || typeof parsed !== 'object' || (!parsed.production && !parsed.updatedAt)) {
|
||||
continue;
|
||||
}
|
||||
this.accept(parsed, 'sse');
|
||||
} catch {
|
||||
// 忽略无法解析的单条事件,继续读取
|
||||
// 单条事件解析失败不影响后续推送
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.running && this.mode === 'sse') this.setMode('polling');
|
||||
} catch (err) {
|
||||
if (!this.running) return;
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
this.emitError('SSE 连接失败,已降级为 HTTP 轮询(每 10s 刷新)');
|
||||
this.fallbackToPolling();
|
||||
// SSE 不可用时依赖轮询通道;仍在 init 则先降级演示,等待轮询恢复
|
||||
if (this.mode === 'init') this.useDemo('实时通道不可用,已切换演示数据');
|
||||
else if (this.mode === 'sse') this.setMode('polling');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,84 +193,75 @@ export class DashboardDataService {
|
||||
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 accept(raw: Partial<DashboardData>, source: 'sse' | 'polling') {
|
||||
const data = normalize(raw);
|
||||
if (isEmpty(data)) {
|
||||
if (this.mode !== 'demo') this.useDemo('MES 暂无业务数据,已切换演示数据');
|
||||
return;
|
||||
}
|
||||
if (this.mode !== source) this.setMode(source);
|
||||
this.emit(data);
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
/** 真实业务数据是否为空:无工单且无产量即视为尚未投产 */
|
||||
function isEmpty(d: DashboardData): boolean {
|
||||
return !d.progress?.orderNo && (d.production?.outputToday ?? 0) === 0;
|
||||
}
|
||||
|
||||
// ---------- 空值兜底 ----------
|
||||
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: '-',
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 字段补全:后端可能只返回部分字段,缺失一律补零值,
|
||||
* 避免前端到处判空,也避免 undefined 传进图表。
|
||||
*/
|
||||
function normalize(raw: Partial<DashboardData>): DashboardData {
|
||||
const p: ProductionOverview = raw.production ?? ({} as ProductionOverview);
|
||||
const w: WarehouseOverview = raw.warehouse ?? ({} as WarehouseOverview);
|
||||
const pr: ProductionProgress = raw.progress ?? ({} as ProductionProgress);
|
||||
return {
|
||||
production: {
|
||||
outputToday: p.outputToday ?? 0,
|
||||
targetToday: p.targetToday ?? 0,
|
||||
passCount: p.passCount ?? 0,
|
||||
torqueCount: p.torqueCount ?? 0,
|
||||
inLine: p.inLine ?? 0,
|
||||
deviceOnline: p.deviceOnline ?? 0,
|
||||
deviceTotal: p.deviceTotal ?? 0,
|
||||
stationSummary: {
|
||||
running: p.stationSummary?.running ?? 0,
|
||||
idle: p.stationSummary?.idle ?? 0,
|
||||
offline: p.stationSummary?.offline ?? 0,
|
||||
alarm: p.stationSummary?.alarm ?? 0,
|
||||
},
|
||||
},
|
||||
warehouse: {
|
||||
totalStock: w.totalStock ?? 0,
|
||||
materialTypes: w.materialTypes ?? 0,
|
||||
inboundToday: w.inboundToday ?? 0,
|
||||
outboundToday: w.outboundToday ?? 0,
|
||||
movements: w.movements ?? [],
|
||||
},
|
||||
equipment: raw.equipment ?? [],
|
||||
progress: {
|
||||
orderNo: pr.orderNo ?? '',
|
||||
contractNo: pr.contractNo ?? '',
|
||||
productName: pr.productName ?? '',
|
||||
owner: pr.owner ?? '',
|
||||
planStart: pr.planStart ?? '',
|
||||
planEnd: pr.planEnd ?? '',
|
||||
remainDays: pr.remainDays ?? 0,
|
||||
totalQty: pr.totalQty ?? 0,
|
||||
doneQty: pr.doneQty ?? 0,
|
||||
progress: pr.progress ?? 0,
|
||||
processDone: pr.processDone ?? 0,
|
||||
processTotal: pr.processTotal ?? 0,
|
||||
currentProcess: pr.currentProcess ?? '',
|
||||
status: pr.status ?? '',
|
||||
traceStepCount: pr.traceStepCount ?? 0,
|
||||
traceable: pr.traceable ?? false,
|
||||
},
|
||||
traces: raw.traces ?? [],
|
||||
trends: raw.trends ?? { production: [] },
|
||||
isDemo: false,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user