feat: 完成产线与仓储系统多模块迭代升级

本次迭代覆盖MES与WMS核心业务:
1. 新增接驳台托盘传感器读取与AGV对接能力
2. 完善工单排产、备料流程与权限体系拆分
3. 优化看板接口与前端路由、样式
4. 新增操作日志、库存盘点与角色保护逻辑
5. 修复代理地址、BOM保存等已知问题
This commit is contained in:
SunYF
2026-09-04 14:18:53 +08:00
parent c1fc6d32b2
commit b6799c9de4
138 changed files with 20613 additions and 2220 deletions
@@ -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,
};
}