228 lines
6.7 KiB
TypeScript
228 lines
6.7 KiB
TypeScript
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: '-',
|
|
};
|
|
}
|
|
} |