- {failed ? (
-
-
- {error}
- {mode !== 'init' ? `(当前连接方式:${mode === 'sse' ? 'SSE' : 'HTTP 轮询'})` : ''}
-
- }
- />
- }
- onClick={onRetry}
- style={{ marginTop: 16 }}
- >
- 重试
-
+ {error && mode === 'demo' && (
+
+ 演示数据 · 真实业务数据到位后自动切换
- ) : (
- children
)}
+ {children}
);
-}
\ No newline at end of file
+}
diff --git a/bj_power_dashboard/src/index.css b/bj_power_dashboard/src/index.css
index 7eb0640..5f7334f 100644
--- a/bj_power_dashboard/src/index.css
+++ b/bj_power_dashboard/src/index.css
@@ -13,29 +13,24 @@ 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;
+ overflow: hidden;
}
.app-shell {
- min-height: 100%;
-}
-
-.semi-always-dark .app-shell {
- background: transparent;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
}
.app-header {
display: flex;
align-items: center;
- justify-content: space-between;
- padding: 14px 24px;
+ justify-content: flex-end;
+ padding: 8px 20px;
background: linear-gradient(90deg, #0f1a30, #14213d);
border-bottom: 1px solid #22304a;
-}
-
-.app-title {
- font-size: 22px;
- font-weight: 600;
- letter-spacing: 1px;
+ height: 40px;
+ flex-shrink: 0;
}
.app-meta {
@@ -47,13 +42,71 @@ body {
.app-meta .clock {
font-size: 14px;
color: #8fa3bf;
+ font-variant-numeric: tabular-nums;
+}
+
+.app-meta .date {
+ font-size: 12px;
+ color: #5d6f8f;
}
.app-content {
- padding: 20px;
+ padding: 12px;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
}
.app-content .semi-card {
background: rgba(255, 255, 255, 0.03);
border-color: #22304a;
-}
\ No newline at end of file
+}
+
+/* ----- 主布局:左侧紧凑栏(生产+仓储)+ 右侧大块 LineModel ----- */
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: minmax(360px, 34%) 1fr;
+ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-areas:
+ 'production model'
+ 'warehouse model';
+ gap: 10px;
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+}
+
+.dash-section {
+ min-height: 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* Section 内的可滚动 card(统一暗色背板) */
+.dash-section .board-card {
+ flex: 1;
+ min-height: 0;
+ background: rgba(13, 26, 51, 0.55);
+ border: 1px solid rgba(56, 189, 248, 0.18);
+ border-radius: 6px;
+ overflow: auto;
+ padding: 8px 10px;
+}
+
+.dash-section.production { grid-area: production; }
+.dash-section.warehouse { grid-area: warehouse; }
+.dash-section.model { grid-area: model; }
+
+@media (max-width: 1100px) {
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto minmax(360px, 50vh);
+ grid-template-areas:
+ 'production'
+ 'warehouse'
+ 'model';
+ }
+}
diff --git a/bj_power_dashboard/src/mock.ts b/bj_power_dashboard/src/mock.ts
index 89361a0..ab7eee8 100644
--- a/bj_power_dashboard/src/mock.ts
+++ b/bj_power_dashboard/src/mock.ts
@@ -152,6 +152,28 @@ export function getDemoData(): DashboardData {
buildTraceItem(3, 55),
];
+ // 当前订单 12 道工序横道图:工期 -10 ~ +16 天,每序约 2.17 天
+ const stepDurationHours = ((planEnd.getTime() - planStart.getTime()) / 12) / 36e5;
+ const progressSteps: DashboardData['progress']['steps'] = PROCESS_NAMES.map((name, i) => {
+ const seqStart = new Date(planStart.getTime() + i * stepDurationHours * 36e5);
+ const seqEnd = new Date(seqStart.getTime() + stepDurationHours * 36e5);
+ const done = i < 6;
+ const running = i === 6;
+ const actualStart = done || running ? seqStart.toISOString() : undefined;
+ const actualEnd = done ? seqEnd.toISOString() : running ? now.toISOString() : undefined;
+ return {
+ code: i + 1,
+ name,
+ planStart: seqStart.toISOString(),
+ planEnd: seqEnd.toISOString(),
+ actualStart,
+ actualEnd,
+ progress: done ? 100 : running ? 65 : 0,
+ owner: OPERATORS[i % OPERATORS.length],
+ status: done ? 'done' : running ? 'running' : ('pending' as const),
+ };
+ });
+
return {
production: {
outputToday: 168,
@@ -195,6 +217,7 @@ export function getDemoData(): DashboardData {
status: '生产中',
traceStepCount: 2016,
traceable: true,
+ steps: progressSteps,
},
traces,
trends: { production: trends },
diff --git a/bj_power_dashboard/src/pages/LineModel.css b/bj_power_dashboard/src/pages/LineModel.css
new file mode 100644
index 0000000..176c35e
--- /dev/null
+++ b/bj_power_dashboard/src/pages/LineModel.css
@@ -0,0 +1,77 @@
+.linemodel-shell {
+ flex: 1;
+ width: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ background: linear-gradient(180deg, #040912, #050b1a 60%);
+ border: 1px solid rgba(125, 211, 252, 0.18);
+ border-radius: 6px;
+ position: relative;
+ overflow: hidden;
+}
+
+.linemodel-shell.is-pulsing {
+ animation: linemodel-pulse 0.6s ease-out 1;
+}
+
+@keyframes linemodel-pulse {
+ 0% { box-shadow: inset 0 0 0 0 rgba(125, 211, 252, 0.85); }
+ 35% { box-shadow: inset 0 0 0 6px rgba(125, 211, 252, 0.45); }
+ 100% { box-shadow: inset 0 0 0 12px rgba(125, 211, 252, 0); }
+}
+
+.linemodel-shell .lm-titlebar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 12px;
+ border-bottom: 1px solid rgba(125, 211, 252, 0.18);
+ background: linear-gradient(90deg, rgba(13, 26, 51, 0.6), rgba(13, 26, 51, 0.0));
+ flex-shrink: 0;
+}
+
+.linemodel-shell .lm-titlebar .lm-title {
+ color: #67e8f9;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 2px;
+}
+
+.linemodel-shell .lm-titlebar .lm-stat {
+ color: #94a3b8;
+ font-size: 11px;
+ display: flex;
+ gap: 14px;
+}
+
+.linemodel-shell .lm-titlebar .lm-stat b {
+ color: #f1f5f9;
+ font-weight: 600;
+}
+
+.linemodel-svg {
+ flex: 1;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ display: block;
+ background: transparent;
+}
+
+.linemodel-shell .lm-watermark {
+ position: absolute;
+ right: 10px;
+ bottom: 6px;
+ font-size: 10px;
+ color: rgba(148, 163, 184, 0.55);
+ letter-spacing: 1px;
+ pointer-events: none;
+}
+
+.linemodel-svg .legend-text {
+ font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
+ fill: #cbd5e1;
+ font-size: 3px;
+ dominant-baseline: middle;
+}
diff --git a/bj_power_dashboard/src/pages/LineModel.tsx b/bj_power_dashboard/src/pages/LineModel.tsx
index 7feb18a..a5ee019 100644
--- a/bj_power_dashboard/src/pages/LineModel.tsx
+++ b/bj_power_dashboard/src/pages/LineModel.tsx
@@ -1,508 +1,639 @@
-import { useEffect, useRef } from 'react';
-import * as THREE from 'three';
+import { useEffect, useMemo, useRef, useState } from 'react';
import type { DashboardData, Station } from '../types';
+import './LineModel.css';
-/**
- * 产线 3D 数字孪生(程序化生成,零模型依赖)
- *
- * 布局按真实厂房俯视图(回字环形):
- * ┌──────────────────────────────┐
- * │ 上方库房(红货架/来料) │
- * │ │
- * │ ◀── 6 工位 ──▶ │ ← 上边
- * │ ▲ ▼ │
- * │ │ 左侧 │右侧 │
- * │ │ │ │
- * │ ◀── 6 工位 ──▶ │ ← 底边
- * │ │
- * │ 下方库房(黄货架/电池) │
- * └──────────────────────────────┘
- * 右上 / 右下 = 办公区
- * 传送带顺时针循环;物料光点沿环跑;
- * 2 台 AGV 分别从上下库房向对应侧工位配送。
- */
+// ============================================================
+// 产线 2D 顶视平面图(严格按甲方 CAD 图布局)
+//
+// 单位 = 100mm,画布对应 CAD 48000 × 27000 mm
+// 世界坐标:中心 (0,0),y 上正下负
+//
+// 按图分区(CAD 1:1 严格布置):
+// ┌────┬─────────────────────────────────────────┬──────┐
+// │ │ 上方红高货架区(密集 4 排) │ │
+// │控制│ ████ ████ ████ ████ ████ │ 独立 │
+// │室 │ ████ ████ ████ ████ ████ │ 房间 │
+// │ │ │ │
+// │ ├──────────────────────────────────────────┴──────┤ ← 黄跑道
+// │ │ 绿跑道(矩形环) │
+// │ │ ▢ ▢ ▢ ▢ ▢ ▢ (接驳台) │
+// │ │ S1 S2 S3 S4 S5 工位(上) │
+// │ │ ▔▔▔▔ 装配线+传送带 ▔▔▔▔ │
+// │ │ S6 S7 S8 S9 S10 工位(下) │
+// │ │ ▢ ▢ ▢ ▢ ▢ ▢ (接驳台) │
+// │ ├─────────────────────────────────────────┬──────┤ ← 黄跑道
+// │ │ 下方左红低货架(堆料) │ 右下独立 │
+// │ │ │ 房间+白盒 │
+// └────┴──────────────────────────────────────────┴──────┘
+// ============================================================
-const RING_X = 56; // 环半宽(X)
-const RING_Z = 36; // 环半深(Z)
+// 画布(中心 0,0;sx/sy 做世界→SVG 转换)
+const VIEW_W = 480;
+const VIEW_H = 270;
+const cx = VIEW_W / 2;
+const cy = VIEW_H / 2;
+const sx = (x: number) => cx + x;
+const sy = (y: number) => cy - y;
-const BELT_W = 3.4; // 传送带宽
-const BELT_H = 1.1; // 传送带高
+// ============================================================
+// 分区坐标(按 CAD 图标注直接 hard-code)
+// ============================================================
-const STATION_OFFSET = 4; // 工位距环外偏移
-const STATION_SIZE = [7, 6.4, 7] as const;
+// 上方红高货架区(密集多排)
+const TOP_SHELF_AREA = { x: -230, y: -135, w: 410, h: 70 };
+// 上方货架每排位置(4 排,沿 x 均匀)
+const TOP_SHELVES: Array<{ x: number; y: number }> = [];
+for (let row = 0; row < 4; row++) {
+ for (let col = 0; col < 6; col++) {
+ TOP_SHELVES.push({
+ x: -200 + col * 70,
+ y: -120 + row * 16,
+ });
+ }
+}
-// 环形路径总长度
-const SEG_LEN_X = RING_X * 2;
-const SEG_LEN_Z = RING_Z * 2;
-const TOTAL_LEN = 2 * (SEG_LEN_X + SEG_LEN_Z);
+// 左上控制室(独立小房间)
+const TOP_LEFT_ROOM = { x: -230, y: -135, w: 25, h: 30 };
-// 环形 4 段端点(z 与 y 在 three 中都是水平轴;地面为 XZ,y 是高度)
-const P0 = new THREE.Vector3(-RING_X, BELT_H / 2, -RING_Z); // 左下
-const P1 = new THREE.Vector3(+RING_X, BELT_H / 2, -RING_Z); // 右下
-const P2 = new THREE.Vector3(+RING_X, BELT_H / 2, +RING_Z); // 右上
-const P3 = new THREE.Vector3(-RING_X, BELT_H / 2, +RING_Z); // 左上
+// 右上独立房间(库房 / 配电)
+const TOP_RIGHT_ROOM = { x: 180, y: -135, w: 50, h: 30 };
-// 工位总数:底边 6 + 上边 6(顺传送方向)
-const BOTTOM_STATIONS = 6;
-const TOP_STATIONS = 6;
+// 中央通道:黄跑道(外圈矩形环,沿外墙)
+const ASSEMBLY_TOP = -32; // 上黄跑道 y 中心
+const ASSEMBLY_BOTTOM = +32; // 下黄跑道 y 中心
+const ASSEMBLY_LEFT = -195;
+const ASSEMBLY_RIGHT = +195;
-// 库房区
-const DOCK_TOP = new THREE.Vector3(0, 0, +95);
-const DOCK_BOTTOM = new THREE.Vector3(0, 0, -95);
-
-const STATUS_COLOR: Record
= {
- running: 0x00d68f,
- idle: 0x36a3f7,
- alarm: 0xffd666,
- offline: 0x4b5b73,
+// 装配线(中央横向长条)
+const ASSEMBLY = {
+ x: -180, y: -15, w: 360, h: 30,
};
+// 工位(上下各 5)
const STATION_NAMES = [
- '上料扫码', '外壳组装', '端子排安装', '电路板装配', '线缆连接', '密封圈压装',
- '面板固定', '功能初检', '绝缘耐压', '计量校准', '外观终检', '铭牌绑定',
+ '上料扫码', '外壳组装', '端子排安装', '电路板装配', '线缆连接',
+ '面板固定', '功能初检', '绝缘耐压', '计量校准', '外观终检',
+];
+const STATION_W = 56;
+const STATION_H = 28;
+const STATION_GAP_X = 12; // 工位之间间距
+// 5 个工位总宽 = 5*56 + 4*12 = 328,居中:startX = -164
+const STATION_START_X = -((5 * STATION_W + 4 * STATION_GAP_X) / 2);
+const STATION_TOP_Y = -55;
+const STATION_BOT_Y = +27;
+
+// 接驳台:每个工位上下各 1 个,夹住工位
+const DOCK_W = 20;
+const DOCK_H = 14;
+const DOCK_GAP_FROM_STATION = 4; // 接驳台到工位边距
+
+// 下方左红低货架(堆料区)
+const BOT_LEFT_SHELF_AREA = { x: -230, y: +65, w: 200, h: 70 };
+// 内部细分(4 排 x 6 列)
+const BOT_LEFT_SHELVES: Array<{ x: number; y: number }> = [];
+for (let row = 0; row < 4; row++) {
+ for (let col = 0; col < 6; col++) {
+ BOT_LEFT_SHELVES.push({
+ x: -200 + col * 32,
+ y: +75 + row * 16,
+ });
+ }
+}
+
+// 下方右黄货架(成品/不同物料)
+const BOT_RIGHT_SHELF_AREA = { x: -30, y: +65, w: 210, h: 70 };
+const BOT_RIGHT_SHELVES: Array<{ x: number; y: number }> = [];
+for (let row = 0; row < 4; row++) {
+ for (let col = 0; col < 6; col++) {
+ BOT_RIGHT_SHELVES.push({
+ x: +5 + col * 33,
+ y: +75 + row * 16,
+ });
+ }
+}
+
+// 右下独立房间(充电区)+ 2 个白盒(机器人待命)
+const BOT_RIGHT_ROOM = { x: 180, y: +65, w: 50, h: 70 };
+const BOT_RIGHT_BOXES: Array<{ x: number; y: number }> = [
+ { x: 195, y: 95 },
+ { x: 220, y: 95 },
];
-interface LiveObj {
- topMat: THREE.MeshBasicMaterial;
- ringMat: THREE.MeshBasicMaterial;
- status: Station['status'];
- pulsePhase: number;
-}
+// ============================================================
+// 颜色
+// ============================================================
+const COLOR = {
+ factory: '#0a1830',
+ walkwayBg: 'rgba(250, 204, 21, 0.22)',
+ walkway: '#facc15',
+ agvBg: 'rgba(34, 197, 94, 0.30)',
+ agv: '#22c55e',
+ dock: '#ef4444',
+ dockEdge: '#fca5a5',
+ pile: '#dc2626',
+ pileEdge: '#fca5a5',
+ station: '#3b82f6',
+ stationOn: '#22d3ee',
+ stationOff: '#64748b',
+ assembly: '#0f172a',
+ assemblyLine: '#06b6d4',
+ shelfRed: 'rgba(239, 68, 68, 0.55)',
+ shelfRedEdge: '#fca5a5',
+ shelfYel: 'rgba(234, 179, 8, 0.65)',
+ shelfYelEdge: '#fde047',
+ room: '#1e293b',
+ roomEdge: '#94a3b8',
+ whiteBox: '#e2e8f0',
+ text: '#cbd5e1',
+ textDim: '#64748b',
+ agvCar: '#fb923c',
+ agvCar2: '#38bdf8',
+};
-interface AgvObj {
- mesh: THREE.Mesh;
- lamp: THREE.Mesh;
- baseZ: number; // 库房 z
- stationZ: number; // 工位 z
-}
+// ============================================================
+// AGV 路径(沿矩形环顺时针,绿跑道中线)
+// ============================================================
+const AGV_TRACK_TOP = +50;
+const AGV_TRACK_BOTTOM = -50;
+const AGV_TRACK_RIGHT = +195;
+const AGV_TRACK_LEFT = -195;
-/** 计算 12 工位沿环的位置(底边 6 + 上边 6,顺时针编号) */
-function buildStationLayout(): Array<{ pos: THREE.Vector3; name: string }> {
- const out: Array<{ pos: THREE.Vector3; name: string }> = [];
- // 底边:从左到右(编号 1-6)
- for (let i = 0; i < BOTTOM_STATIONS; i++) {
- const t = (i + 0.5) / BOTTOM_STATIONS;
- out.push({
- pos: new THREE.Vector3(
- -RING_X + t * SEG_LEN_X,
- 0,
- -RING_Z - STATION_OFFSET,
- ),
- name: STATION_NAMES[i] ?? `工位${i + 1}`,
- });
+const agvPathPos = (t: number) => {
+ const p = (t % 1 + 1) % 1;
+ const seg = p * 4;
+ if (seg < 1) {
+ return { x: AGV_TRACK_RIGHT, y: AGV_TRACK_BOTTOM + seg * (AGV_TRACK_TOP - AGV_TRACK_BOTTOM) };
+ } else if (seg < 2) {
+ const k = seg - 1;
+ return { x: AGV_TRACK_RIGHT - k * (AGV_TRACK_RIGHT - AGV_TRACK_LEFT), y: AGV_TRACK_TOP };
+ } else if (seg < 3) {
+ const k = seg - 2;
+ return { x: AGV_TRACK_LEFT, y: AGV_TRACK_TOP - k * (AGV_TRACK_TOP - AGV_TRACK_BOTTOM) };
+ } else {
+ const k = seg - 3;
+ return { x: AGV_TRACK_LEFT + k * (AGV_TRACK_RIGHT - AGV_TRACK_LEFT), y: AGV_TRACK_BOTTOM };
}
- // 上边:从右到左(编号 7-12,沿顺时针方向连续)
- for (let i = 0; i < TOP_STATIONS; i++) {
- const t = (i + 0.5) / TOP_STATIONS;
- out.push({
- pos: new THREE.Vector3(
- +RING_X - t * SEG_LEN_X,
- 0,
- +RING_Z + STATION_OFFSET,
- ),
- name: STATION_NAMES[BOTTOM_STATIONS + i] ?? `工位${BOTTOM_STATIONS + i + 1}`,
- });
- }
- return out;
-}
+};
-/** 给定总进度 p∈[0,1],返回环形路径上的位置 */
-function ringPosition(p: number, y = BELT_H / 2 + 0.6): THREE.Vector3 {
- const d = ((p % 1) + 1) % 1 * TOTAL_LEN;
- let rest = d;
- if (rest < SEG_LEN_X) {
- const t = rest / SEG_LEN_X;
- return new THREE.Vector3(P0.x + t * (P1.x - P0.x), y, P0.z);
- }
- rest -= SEG_LEN_X;
- if (rest < SEG_LEN_Z) {
- const t = rest / SEG_LEN_Z;
- return new THREE.Vector3(P1.x, y, P1.z + t * (P2.z - P1.z));
- }
- rest -= SEG_LEN_Z;
- if (rest < SEG_LEN_X) {
- const t = rest / SEG_LEN_X;
- return new THREE.Vector3(P2.x + t * (P3.x - P2.x), y, P2.z);
- }
- rest -= SEG_LEN_X;
- const t = rest / SEG_LEN_Z;
- return new THREE.Vector3(P3.x, y, P3.z + t * (P0.z - P3.z));
-}
+const agvTangent = (t: number) => {
+ const p = (t % 1 + 1) % 1;
+ const seg = p * 4;
+ if (seg < 1) return { dx: 0, dy: 1 };
+ if (seg < 2) return { dx: -1, dy: 0 };
+ if (seg < 3) return { dx: 0, dy: -1 };
+ return { dx: 1, dy: 0 };
+};
+// ============================================================
+// 组件
+// ============================================================
export function LineModel({ data }: { data: DashboardData }) {
- const holderRef = useRef(null);
- const liveRef = useRef([]);
- const agvRef = useRef([]);
- const statusRef = useRef(
- data.equipment.map((e) => e.status),
- );
- statusRef.current = data.equipment.map((e) => e.status);
+ const [tick, setTick] = useState(0);
+ const stationsRef = useRef(data.equipment);
+ stationsRef.current = data.equipment;
+ // 60fps 动画 tick(AGV 流动 + 工位呼吸 + 物料光斑)
useEffect(() => {
- const holder = holderRef.current;
- if (!holder) return;
-
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- holder.appendChild(renderer.domElement);
-
- const scene = new THREE.Scene();
- scene.fog = new THREE.Fog(0x050b18, 220, 520);
-
- const camera = new THREE.PerspectiveCamera(46, 1, 0.1, 1000);
- const camClock = new THREE.Clock();
-
- const clock = new THREE.Clock();
-
- // ============ 地面 + 网格 ============
- const floor = new THREE.Mesh(
- new THREE.PlaneGeometry(420, 320),
- new THREE.MeshBasicMaterial({ color: 0x081226, transparent: true, opacity: 0.95 }),
- );
- floor.rotation.x = -Math.PI / 2;
- scene.add(floor);
-
- const grid = new THREE.GridHelper(380, 38, 0x1f3a63, 0x12233d);
- (grid.material as THREE.Material).transparent = true;
- (grid.material as THREE.Material).opacity = 0.55;
- grid.position.y = 0.05;
- scene.add(grid);
-
- // ============ 厂房轮廓(线框)============
- const shellBox = new THREE.BoxGeometry(280, 50, 240);
- const shell = new THREE.LineSegments(
- new THREE.EdgesGeometry(shellBox),
- new THREE.LineBasicMaterial({ color: 0x2dd4bf, transparent: true, opacity: 0.45 }),
- );
- shell.position.y = 25;
- scene.add(shell);
-
- const roof = new THREE.Mesh(
- new THREE.PlaneGeometry(260, 220),
- new THREE.MeshBasicMaterial({
- color: 0x22d3ee, transparent: true, opacity: 0.04, side: THREE.DoubleSide,
- }),
- );
- roof.rotation.x = -Math.PI / 2;
- roof.position.y = 49;
- scene.add(roof);
-
- // ============ 库房区(上下两大块 + 右上下办公区)============
- const dockMat = new THREE.MeshBasicMaterial({ color: 0x4b1d1d }); // 上方红库房
- const dockBottomMat = new THREE.MeshBasicMaterial({ color: 0x3b3a16 }); // 下方黄库房
- const dockTop = new THREE.Mesh(new THREE.BoxGeometry(200, 8, 40), dockMat);
- dockTop.position.set(0, 4, +95);
- scene.add(dockTop);
- const dockTopEdge = new THREE.LineSegments(
- new THREE.EdgesGeometry(dockTop.geometry),
- new THREE.LineBasicMaterial({ color: 0xff7a45, transparent: true, opacity: 0.7 }),
- );
- dockTop.add(dockTopEdge);
-
- const dockBottom = new THREE.Mesh(new THREE.BoxGeometry(200, 8, 40), dockBottomMat);
- dockBottom.position.set(0, 4, -95);
- scene.add(dockBottom);
- const dockBottomEdge = new THREE.LineSegments(
- new THREE.EdgesGeometry(dockBottom.geometry),
- new THREE.LineBasicMaterial({ color: 0xfbbf24, transparent: true, opacity: 0.7 }),
- );
- dockBottom.add(dockBottomEdge);
-
- // 右上下办公区(小盒子代表隔间)
- const officeMat = new THREE.MeshBasicMaterial({ color: 0x0e2340 });
- [-1, 1].forEach((sgn) => {
- for (let k = 0; k < 3; k++) {
- const ofc = new THREE.Mesh(new THREE.BoxGeometry(36, 16, 26), officeMat);
- ofc.position.set(135, 8, sgn * (32 + k * 6));
- scene.add(ofc);
- }
- });
-
- // ============ 环形传送带(4 段 box)============
- const beltMat = new THREE.MeshBasicMaterial({ color: 0x12233d });
- const beltEdgeMat = new THREE.LineBasicMaterial({
- color: 0x22d3ee, transparent: true, opacity: 0.4,
- });
- const beltGroup = new THREE.Group();
- // 底边 P0→P1
- const segBottom = new THREE.Mesh(new THREE.BoxGeometry(SEG_LEN_X, BELT_H, BELT_W), beltMat);
- segBottom.position.set(0, BELT_H / 2, -RING_Z);
- beltGroup.add(segBottom);
- segBottom.add(new THREE.LineSegments(new THREE.EdgesGeometry(segBottom.geometry), beltEdgeMat));
- // 顶边 P2→P3
- const segTop = new THREE.Mesh(new THREE.BoxGeometry(SEG_LEN_X, BELT_H, BELT_W), beltMat);
- segTop.position.set(0, BELT_H / 2, +RING_Z);
- beltGroup.add(segTop);
- segTop.add(new THREE.LineSegments(new THREE.EdgesGeometry(segTop.geometry), beltEdgeMat));
- // 右边 P1→P2
- const segRight = new THREE.Mesh(new THREE.BoxGeometry(BELT_W, BELT_H, SEG_LEN_Z), beltMat);
- segRight.position.set(+RING_X, BELT_H / 2, 0);
- beltGroup.add(segRight);
- segRight.add(new THREE.LineSegments(new THREE.EdgesGeometry(segRight.geometry), beltEdgeMat));
- // 左边 P3→P0
- const segLeft = new THREE.Mesh(new THREE.BoxGeometry(BELT_W, BELT_H, SEG_LEN_Z), beltMat);
- segLeft.position.set(-RING_X, BELT_H / 2, 0);
- beltGroup.add(segLeft);
- segLeft.add(new THREE.LineSegments(new THREE.EdgesGeometry(segLeft.geometry), beltEdgeMat));
- scene.add(beltGroup);
-
- // 4 个拐角装饰小灯柱
- [P0, P1, P2, P3].forEach((p) => {
- const lamp = new THREE.Mesh(
- new THREE.SphereGeometry(1.2, 10, 10),
- new THREE.MeshBasicMaterial({ color: 0x67e8f9 }),
- );
- lamp.position.set(p.x, BELT_H + 1.5, p.z);
- scene.add(lamp);
- });
-
- // ============ 12 工位灯柱 ============
- const layout = buildStationLayout();
- const live: LiveObj[] = [];
- layout.forEach((st, i) => {
- const base = new THREE.Mesh(
- new THREE.BoxGeometry(...STATION_SIZE),
- new THREE.MeshBasicMaterial({ color: 0x1b2b47 }),
- );
- base.position.set(st.pos.x, STATION_SIZE[1] / 2, st.pos.z);
- scene.add(base);
-
- const topMat = new THREE.MeshBasicMaterial({ color: STATUS_COLOR.running });
- const top = new THREE.Mesh(
- new THREE.BoxGeometry(STATION_SIZE[0] + 0.4, 0.9, STATION_SIZE[2] + 0.4),
- topMat,
- );
- top.position.set(st.pos.x, STATION_SIZE[1] + 0.5, st.pos.z);
- scene.add(top);
-
- const ringMat = new THREE.MeshBasicMaterial({
- color: STATUS_COLOR.running, transparent: true, opacity: 0.4, side: THREE.DoubleSide,
- });
- const ring = new THREE.Mesh(new THREE.RingGeometry(4.6, 5.4, 36), ringMat);
- ring.rotation.x = -Math.PI / 2;
- ring.position.set(st.pos.x, 0.15, st.pos.z);
- scene.add(ring);
-
- // 工位名标签(用 sprite canvas)
- const labelCanvas = document.createElement('canvas');
- labelCanvas.width = 256;
- labelCanvas.height = 64;
- const ctx = labelCanvas.getContext('2d')!;
- ctx.fillStyle = 'rgba(15,26,48,0.85)';
- ctx.fillRect(0, 0, 256, 64);
- ctx.font = 'bold 32px "PingFang SC", "Microsoft YaHei", sans-serif';
- ctx.fillStyle = '#67e8f9';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText(st.name, 128, 36);
- const tex = new THREE.CanvasTexture(labelCanvas);
- tex.colorSpace = THREE.SRGBColorSpace;
- const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true }));
- sprite.scale.set(18, 4.5, 1);
- sprite.position.set(st.pos.x, STATION_SIZE[1] + 4, st.pos.z);
- scene.add(sprite);
-
- live.push({ topMat, ringMat, status: 'idle', pulsePhase: i * 0.4 });
- });
- liveRef.current = live;
-
- // ============ 物料流光(沿环形路径)============
- const DOTS = 26;
- const dotGroup = new THREE.Group();
- const dotMat = new THREE.MeshBasicMaterial({ color: 0x67e8f9 });
- for (let i = 0; i < DOTS; i++) {
- const d = new THREE.Mesh(new THREE.SphereGeometry(0.55, 12, 12), dotMat);
- d.position.set(0, BELT_H + 1.3, 0);
- dotGroup.add(d);
- }
- scene.add(dotGroup);
-
- // ============ AGV ×2(库房 ↔ 工位)============
- const agvConfigs: Array<{ color: number; baseZ: number; stationZ: number; x: number }> = [
- { color: 0xfb923c, baseZ: DOCK_TOP.z, stationZ: +RING_Z + STATION_OFFSET, x: 0 }, // 上库房→上边工位
- { color: 0x60a5fa, baseZ: DOCK_BOTTOM.z, stationZ: -RING_Z - STATION_OFFSET, x: 0 }, // 下库房→下边工位
- ];
- const agvList: AgvObj[] = [];
- agvConfigs.forEach((cfg) => {
- const body = new THREE.Mesh(
- new THREE.BoxGeometry(5.2, 2.6, 3.2),
- new THREE.MeshBasicMaterial({ color: cfg.color }),
- );
- const lamp = new THREE.Mesh(
- new THREE.SphereGeometry(0.6, 10, 10),
- new THREE.MeshBasicMaterial({ color: 0xffffff }),
- );
- lamp.position.y = 2;
- body.add(lamp);
- body.position.set(cfg.x, 1.3, cfg.baseZ);
- body.userData = { ...cfg, t: 0, dir: 1 };
- scene.add(body);
- agvList.push({
- mesh: body, lamp, baseZ: cfg.baseZ, stationZ: cfg.stationZ,
- });
- });
- agvRef.current = agvList;
-
- // ============ 灯光 ============
- scene.add(new THREE.HemisphereLight(0x9fc8ff, 0x0a1428, 1.05));
- const dir = new THREE.DirectionalLight(0xffffff, 1.3);
- dir.position.set(80, 160, 60);
- scene.add(dir);
-
- // ============ 尺寸自适应 ============
- const resize = () => {
- const w = holder.clientWidth || 960;
- const h = holder.clientHeight || 560;
- renderer.setSize(w, h);
- camera.aspect = w / h;
- camera.updateProjectionMatrix();
- };
- resize();
- window.addEventListener('resize', resize);
-
- // ============ 渲染循环 ============
- let raf = 0;
- const animate = () => {
- raf = requestAnimationFrame(animate);
- const t = clock.getElapsedTime();
-
- // 相机环绕(俯视更明显,看清环形)
- const theta = t * 0.04;
- const r = 230;
- camera.position.x = Math.sin(theta) * r;
- camera.position.z = Math.cos(theta) * r;
- camera.position.y = 145 + Math.sin(t * 0.06) * 8;
- camera.lookAt(0, 4, 0);
- // 静音未使用变量警告
- void camClock;
-
- // 工位状态脉冲
- const statuses = statusRef.current;
- live.forEach((o, i) => {
- const st = statuses[i] ?? 'idle';
- if (st !== o.status) {
- o.status = st;
- const c = STATUS_COLOR[st];
- o.topMat.color.setHex(c);
- o.ringMat.color.setHex(c);
- }
- const phase = t * 3 + o.pulsePhase;
- const pulse = st === 'running' ? 0.6 + 0.4 * Math.sin(phase) : st === 'offline' ? 0.25 : 0.45;
- o.ringMat.opacity = pulse;
- o.ringMat.transparent = true;
- });
-
- // 物料流光(环形)
- const span = TOTAL_LEN - 18;
- const speed = 32;
- for (let i = 0; i < DOTS; i++) {
- const d = dotGroup.children[i] as THREE.Mesh;
- const p = ((t * speed) / span + i / DOTS) % 1;
- const pos = ringPosition(p);
- d.position.set(pos.x, pos.y + Math.sin(t * 5 + i) * 0.3, pos.z);
- }
-
- // AGV 库房 ↔ 工位往返(24s 一个来回)
- agvList.forEach((agv, i) => {
- const ud = agv.mesh.userData as { t: number; dir: number };
- const period = 24; // 秒
- const phase = (t / period + i * 0.5) % 1;
- // 0..0.5 库房→工位,0.5..1 工位→库房
- const dir = phase < 0.5 ? 1 : -1;
- const tp = dir > 0 ? phase * 2 : (1 - phase) * 2; // 0..1
- agv.mesh.position.z = agv.baseZ + (agv.stationZ - agv.baseZ) * tp;
- agv.mesh.position.y = 1.3 + Math.sin(t * 4) * 0.2;
- (agv.lamp.material as THREE.MeshBasicMaterial).color.setHex(
- dir > 0 ? 0xffffff : 0xffaa66,
- );
- void ud;
- });
-
- renderer.render(scene, camera);
- };
- animate();
-
- return () => {
- cancelAnimationFrame(raf);
- window.removeEventListener('resize', resize);
- scene.traverse((obj) => {
- const mesh = obj as THREE.Mesh;
- mesh.geometry?.dispose?.();
- const m = mesh.material as THREE.Material | THREE.Material[] | undefined;
- if (Array.isArray(m)) m.forEach((mm) => mm.dispose());
- else m?.dispose();
- });
- liveRef.current = [];
- agvRef.current = [];
- renderer.dispose();
- if (renderer.domElement.parentElement === holder) {
- holder.removeChild(renderer.domElement);
- }
+ let timer = 0;
+ const loop = () => {
+ setTick((t) => (t + 1) % 1_000_000);
+ timer = window.setTimeout(loop, 60);
};
+ timer = window.setTimeout(loop, 60);
+ return () => window.clearTimeout(timer);
}, []);
- return (
-
- );
-}
+ // 数据指纹变化触发 0.6s 高亮脉冲(直观告诉用户「数据更新了」)
+ const fingerprint = useMemo(() => {
+ const eq = data.equipment
+ .slice()
+ .sort((a, b) => a.id - b.id)
+ .map((s) => `${s.id}:${s.status}:${s.doneCount ?? 0}`)
+ .join('|');
+ return `${data.production.outputToday}|${data.production.inLine}|${data.progress.orderNo}|${eq}`;
+ }, [data]);
+ const [pulsing, setPulsing] = useState(false);
+ useEffect(() => {
+ setPulsing(true);
+ const t = window.setTimeout(() => setPulsing(false), 650);
+ return () => window.clearTimeout(t);
+ }, [fingerprint]);
+
+ const agv1T = (tick * 0.0014) % 1; // 跑得更快(约一圈 12s)
+ const agv2T = ((tick * 0.0011) + 0.5) % 1;
+ const agv1 = agvPathPos(agv1T);
+ const agv2 = agvPathPos(agv2T);
+ const agv1Tan = agvTangent(agv1T);
+ const agv2Tan = agvTangent(agv2T);
+ const agv1Angle = Math.atan2(agv1Tan.dy, agv1Tan.dx);
+ const agv2Angle = Math.atan2(agv2Tan.dy, agv2Tan.dx);
+
+ // 工位状态(以 dashboard.equipment 为准)
+ const stations: Station[] = Array.from({ length: 10 }, (_, i) =>
+ stationsRef.current[i] ?? {
+ id: i + 1, stationNo: `S${i + 1}`, name: STATION_NAMES[i],
+ status: i < 2 ? 'running' : 'idle', doneCount: 0,
+ scanGun: { connected: true }, tighteningGun: { connected: true },
+ },
+ );
+
+ // 物料光点(沿装配线 x 方向跑,密度更高)
+ const flowDots = Array.from({ length: 28 }, (_, i) => ({
+ x: -ASSEMBLY.w / 2 + ((tick * 1.0 + i * (ASSEMBLY.w / 28)) % ASSEMBLY.w),
+ }));
+
+ // 概览统计
+ const runningCount = stations.filter((s) => s.status === 'running').length;
+ const idleCount = stations.filter((s) => s.status === 'idle').length;
+ const totalOutput = data.production.outputToday ?? 0;
-function Legend() {
- const items: Array<[string, string]> = [
- ['#00d68f', '运行中'],
- ['#36a3f7', '待料'],
- ['#ffd666', '异常'],
- ['#4b5b73', '离线'],
- ['#fb923c', 'AGV 上料'],
- ['#60a5fa', 'AGV 下料'],
- ];
return (
-
- {items.map(([color, label]) => (
-
-
- {label}
+
+
+ 数字孪生 · 产线 2D 顶视图
+
+ 在制 {data.production.inLine}
+ 今日产量 {totalOutput}
+ 运行 {runningCount}/待机 {idleCount}/10
+ 数据指纹 {pulsing ? '更新' : '稳态'}
+
+
+
更新指纹 {fingerprint.length} 位 · 内嵌 60fps 动画
);
}
-function Footnote() {
+function LegendRow({ y, color, label }: { y: number; color: string; label: string }) {
return (
-
- 环形产线自动漫游 · 红=上料库房 / 黄=下料库房 · 右上/右下=办公区
-
+
+
+ {label}
+
);
-}
\ No newline at end of file
+}
+
+// ============================================================
+// 子组件
+// ============================================================
+function Shelf({
+ x, y, w, h, fill, edge, layers = 2,
+}: {
+ x: number; y: number; w: number; h: number;
+ fill: string; edge: string; layers?: number;
+}) {
+ return (
+
+
+ {Array.from({ length: layers - 1 }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function RoomBox({
+ x, y, w, h, label,
+}: {
+ x: number; y: number; w: number; h: number; label: string;
+}) {
+ return (
+
+
+ {/* 门缝(右侧缺口表示门) */}
+
+
+ {label}
+
+
+ );
+}
+
+function StationBox({
+ x, y, idx, station, tick,
+}: {
+ x: number; y: number; idx: number; station: Station; tick: number;
+}) {
+ const W = STATION_W, H = STATION_H;
+ const running = station.status === 'running';
+ const offline = station.status === 'offline';
+ const fillCore = running ? '#0e7490' : offline ? '#334155' : '#1e40af';
+ const strokeCore = running ? COLOR.stationOn : offline ? COLOR.stationOff : COLOR.station;
+ const pulsePhase = (idx * 0.18) % 1;
+ const pulse = 0.5 + 0.5 * Math.sin((tick / 9) + pulsePhase * Math.PI * 2);
+ const num = `${String(idx + 1).padStart(2, '0')}`;
+ return (
+
+ {/* 底座呼吸光晕 */}
+
+ {/* 工位主体 */}
+
+ {/* 工位编号 */}
+ {num}
+ {/* 工序名(在工位外侧:上边工位向上,下边工位向下) */}
+
+ {STATION_NAMES[idx]}
+
+ {/* 状态点(右上角) */}
+
+ {/* 完成数(中央大字) */}
+
+ {station.doneCount ?? 0}
+
+
+ );
+}
+
+function Dock({
+ x, y, active,
+}: { x: number; y: number; active?: boolean }) {
+ return (
+
+
+ {active && (
+
+ )}
+
+ );
+}
+
+function Agv({
+ x, y, angle, body, lamp,
+}: {
+ x: number; y: number; angle: number; body: string; lamp: string;
+}) {
+ const W = 8, H = 4.5;
+ const rad = (angle * 180) / Math.PI;
+ return (
+
+
+
+
+
+ );
+}
diff --git a/bj_power_dashboard/src/pages/ProductionBoard.tsx b/bj_power_dashboard/src/pages/ProductionBoard.tsx
index 4fdbaa9..d1268da 100644
--- a/bj_power_dashboard/src/pages/ProductionBoard.tsx
+++ b/bj_power_dashboard/src/pages/ProductionBoard.tsx
@@ -1,5 +1,5 @@
import type { EChartsOption } from 'echarts';
-import type { DashboardData, Station } from '../types';
+import type { DashboardData, Station, ProgressGanttStep } from '../types';
import { EChart } from '../components/EChart';
const C = {
@@ -54,7 +54,7 @@ export function ProductionBoard({ data }: { data: DashboardData }) {
};
return (
-
+
@@ -66,17 +66,18 @@ export function ProductionBoard({ data }: { data: DashboardData }) {
/>
-
+
-
+
@@ -89,77 +90,85 @@ export function ProductionBoard({ data }: { data: DashboardData }) {
{progress.owner || '—'}
-
- {progress.planStart} ~ {progress.planEnd}
+
+ {progress.planStart || '—'} ~ {progress.planEnd || '—'}
= 0 ? C.green : C.amber }}>
- 剩余 {Math.max(progress.remainDays, 0)} 天
+ 剩余 {progress.remainDays >= 0 ? progress.remainDays : 0} 天
-
-
- 完成度
-
- {progress.doneQty}
- {' / '}
- {progress.totalQty} 台
-
-
-
+
+
+ 完成度 {progress.progress}% · 工序 {progress.processDone}/{progress.processTotal}
+
+
+ {progress.doneQty}
+ {' / '}{progress.totalQty} 台
+
+
-
-
-
-
+
+ {progress.steps.length > 0 ? (
+
+ ) : (
+
+ 暂无工序横道数据
+
+ )}
-
-
- {equipment.map((s) => (
+
+
+ {equipment.map((s, i) => (
-
-
{s.name}
-
- {s.status === 'running'
- ? '作业中'
- : s.status === 'idle'
- ? '待料'
- : s.status === 'alarm'
- ? '异常'
- : '离线'}
-
+
+ {s.name}
+ {i + 1}
-
- {s.currentOperator || '—'}
+
+ {s.status === 'running'
+ ? '作业中'
+ : s.status === 'idle'
+ ? '待料'
+ : s.status === 'alarm'
+ ? '异常'
+ : '离线'}
- {s.currentSn &&
{s.currentSn}
}
))}
-
+
运行中 {production.stationSummary.running} · 待料 {production.stationSummary.idle} · 离线{' '}
{production.stationSummary.offline}
@@ -235,19 +244,98 @@ function Bar({ percent }: { percent: number }) {
);
}
-function Chip({ label, value, color }: { label: string; value: string; color: string }) {
- return (
-
- );
+function GanttChart({ steps }: { steps: ProgressGanttStep[] }) {
+ const names = steps.map((s) => s.name);
+ const now = new Date();
+
+ const planData = steps.map((s, i) => ({
+ value: [i, new Date(s.planStart).getTime(), new Date(s.planEnd).getTime(), 'plan'],
+ itemStyle: { color: 'rgba(148, 163, 184, 0.12)' },
+ }));
+
+ const actualData = steps
+ .filter((s) => s.actualStart)
+ .map((s, i) => {
+ const start = new Date(s.actualStart!).getTime();
+ const end = s.actualEnd ? new Date(s.actualEnd).getTime() : now.getTime();
+ const color = s.status === 'done' ? C.green : C.cyan;
+ return {
+ value: [i, start, end, 'actual'],
+ itemStyle: { color },
+ };
+ });
+
+ const option: EChartsOption = {
+ tooltip: {
+ formatter: (params: any) => {
+ const s = steps[params.value[0] as number];
+ const start = new Date(params.value[1] as number).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+ const end = new Date(params.value[2] as number).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+ return `
${s.name}
负责人:${s.owner}
状态:${s.status === 'done' ? '已完成' : s.status === 'running' ? '进行中' : '未开始'}
${start} ~ ${end}
`;
+ },
+ },
+ grid: { left: 96, right: 24, top: 10, bottom: 24 },
+ xAxis: {
+ type: 'time',
+ axisLine: { lineStyle: { color: '#335' } },
+ axisLabel: { color: '#8fa3bf', fontSize: 11, formatter: '{MM}-{dd}' },
+ splitLine: { lineStyle: { color: '#1f2a3f' } },
+ },
+ yAxis: {
+ type: 'category',
+ data: names,
+ inverse: true,
+ axisLine: { lineStyle: { color: '#335' } },
+ axisLabel: { color: '#94a3b8', fontSize: 11 },
+ splitLine: { show: false },
+ },
+ series: [
+ {
+ type: 'custom',
+ name: '计划工期',
+ renderItem: (_params: any, api: any) => {
+ const categoryIndex = api.value(0);
+ const start = api.coord([api.value(1), categoryIndex]);
+ const end = api.coord([api.value(2), categoryIndex]);
+ const height = api.size([0, 1])[1] * 0.55;
+ return {
+ type: 'rect',
+ shape: {
+ x: start[0],
+ y: start[1] - height / 2,
+ width: Math.max(end[0] - start[0], 2),
+ height,
+ },
+ style: api.style(),
+ };
+ },
+ encode: { x: [1, 2], y: 0 },
+ data: planData,
+ },
+ {
+ type: 'custom',
+ name: '实际进度',
+ renderItem: (_params: any, api: any) => {
+ const categoryIndex = api.value(0);
+ const start = api.coord([api.value(1), categoryIndex]);
+ const end = api.coord([api.value(2), categoryIndex]);
+ const height = api.size([0, 1])[1] * 0.32;
+ return {
+ type: 'rect',
+ shape: {
+ x: start[0],
+ y: start[1] - height / 2,
+ width: Math.max(end[0] - start[0], 2),
+ height,
+ },
+ style: api.style(),
+ };
+ },
+ encode: { x: [1, 2], y: 0 },
+ data: actualData,
+ },
+ ],
+ };
+
+ return
;
}
diff --git a/bj_power_dashboard/src/services/dashboardService.ts b/bj_power_dashboard/src/services/dashboardService.ts
index 8269691..5b8db6a 100644
--- a/bj_power_dashboard/src/services/dashboardService.ts
+++ b/bj_power_dashboard/src/services/dashboardService.ts
@@ -32,6 +32,7 @@ export class DashboardDataService {
private controller: AbortController | null = null;
private timer: ReturnType
| null = null;
+ private pollingInFlight = false;
private changeListeners = new Set();
private modeListeners = new Set();
@@ -121,13 +122,14 @@ export class DashboardDataService {
}
private async pollOnce() {
- if (!this.running) return;
+ if (!this.running || this.pollingInFlight) return;
+ this.pollingInFlight = true;
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;
+ const raw = unwrapPayload(await res.json()) as Partial;
this.accept(raw, 'polling');
} catch (err) {
if (!this.running) return;
@@ -137,6 +139,8 @@ export class DashboardDataService {
`MES 不可用,已切换演示数据(${err instanceof Error ? err.message : '网络异常'})`,
);
}
+ } finally {
+ this.pollingInFlight = false;
}
}
@@ -162,12 +166,20 @@ export class DashboardDataService {
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 lines = block.split('\n');
+ const dataLine = lines.find((l) => l.startsWith('data:'));
if (!dataLine) continue;
const payload = dataLine.replace(/^data:\s*/, '').trim();
if (!payload) continue;
+ // 事件驱动:业务数据落库后后端广播 dashboard.updated(空载荷),
+ // 收到即拉一次最新快照,实时性优于轮询周期。
+ const evtLine = lines.find((l) => l.startsWith('event:'));
+ if (evtLine && evtLine.includes('dashboard.updated')) {
+ void this.pollOnce();
+ continue;
+ }
try {
- const parsed = JSON.parse(payload) as Partial;
+ const parsed = unwrapPayload(JSON.parse(payload)) as Partial;
// 忽略 hello / 心跳等无数据载荷(无 production 且无 updatedAt)
if (!parsed || typeof parsed !== 'object' || (!parsed.production && !parsed.updatedAt)) {
continue;
@@ -210,6 +222,15 @@ function isEmpty(d: DashboardData): boolean {
return !d.progress?.orderNo && (d.production?.outputToday ?? 0) === 0;
}
+/** 后端响应统一为 {code,message,data},取 data 层;SSE 心跳推的是裸对象,两种都兼容 */
+function unwrapPayload(j: unknown): unknown {
+ if (j && typeof j === 'object' && !Array.isArray(j)) {
+ const o = j as { code?: unknown; data?: unknown };
+ if (o.code !== undefined && 'data' in o) return o.data;
+ }
+ return j;
+}
+
/**
* 字段补全:后端可能只返回部分字段,缺失一律补零值,
* 避免前端到处判空,也避免 undefined 传进图表。
@@ -259,6 +280,7 @@ function normalize(raw: Partial): DashboardData {
status: pr.status ?? '',
traceStepCount: pr.traceStepCount ?? 0,
traceable: pr.traceable ?? false,
+ steps: pr.steps ?? [],
},
traces: raw.traces ?? [],
trends: raw.trends ?? { production: [] },
diff --git a/bj_power_dashboard/src/types.ts b/bj_power_dashboard/src/types.ts
index a3901a2..284a5e4 100644
--- a/bj_power_dashboard/src/types.ts
+++ b/bj_power_dashboard/src/types.ts
@@ -22,8 +22,12 @@ export interface ToolState {
/** 工位:装配线上一工位 = 一把扫码枪 + 一把拧紧枪 */
export interface Station {
id: number;
+ /** 工位编号 */
+ stationNo?: string;
name: string;
status: StationStatus;
+ /** 工位已完成件数(按今日累计) */
+ doneCount?: number;
scanGun: ToolState;
tighteningGun: ToolState;
currentSn?: string;
@@ -76,6 +80,27 @@ export interface WarehouseOverview {
* 当前订单(屏1 核心卡片)
* 展示:合同信息、负责人、工期、完成度、可追溯。
*/
+export interface ProgressGanttStep {
+ /** 工序编码 */
+ code: number;
+ /** 工序名称 */
+ name: string;
+ /** 计划开始时间 ISO */
+ planStart: string;
+ /** 计划结束时间 ISO */
+ planEnd: string;
+ /** 实际开始时间 ISO(未开始为空) */
+ actualStart?: string;
+ /** 实际结束时间 ISO(进行中/未开始为空) */
+ actualEnd?: string;
+ /** 该工序完成百分比 0-100 */
+ progress: number;
+ /** 该工序负责人 */
+ owner: string;
+ /** 状态:未开始 / 进行中 / 已完成 */
+ status: 'pending' | 'running' | 'done';
+}
+
export interface ProductionProgress {
/** 工单号 */
orderNo: string;
@@ -106,6 +131,8 @@ export interface ProductionProgress {
traceStepCount: number;
/** 是否全程可追溯 */
traceable: boolean;
+ /** 当前订单 12 道工序横道图数据 */
+ steps: ProgressGanttStep[];
}
/** 工件追溯:单道工序实绩 */
diff --git a/bj_power_mes/bj_power_mes.exe.bak_144517 b/bj_power_mes/bj_power_mes.exe.bak_144517
new file mode 100644
index 0000000..4617ee2
Binary files /dev/null and b/bj_power_mes/bj_power_mes.exe.bak_144517 differ
diff --git a/bj_power_mes/internal/logic/dailyplan.go b/bj_power_mes/internal/logic/dailyplan.go
index 73f1350..329b672 100644
--- a/bj_power_mes/internal/logic/dailyplan.go
+++ b/bj_power_mes/internal/logic/dailyplan.go
@@ -97,6 +97,7 @@ func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator
s.rememberOrderDocks(ctx, req.OrderNo, dockCodes(req.DockCodes))
}
s.ctx.EventLog.Write(ctx, "daily.plan.save", req.OrderNo, operator, "daily_plan", req.OrderNo, "保存日排产", map[string]any{"planDate": req.PlanDate, "planQty": req.PlanQty, "dockCodes": req.DockCodes})
+ s.notifyDashboard()
return nil
}
diff --git a/bj_power_mes/internal/logic/inspection.go b/bj_power_mes/internal/logic/inspection.go
index 90590d2..acfc516 100644
--- a/bj_power_mes/internal/logic/inspection.go
+++ b/bj_power_mes/internal/logic/inspection.go
@@ -74,6 +74,7 @@ func (s *Service) CreateInspection(ctx context.Context, req InspectionReq, opera
_, _ = s.ctx.RedisClient.Del("dashboard:alarms")
}
}
+ s.notifyDashboard()
return nil
}
diff --git a/bj_power_mes/internal/logic/logic.go b/bj_power_mes/internal/logic/logic.go
index 893e456..01639ee 100644
--- a/bj_power_mes/internal/logic/logic.go
+++ b/bj_power_mes/internal/logic/logic.go
@@ -17,6 +17,15 @@ func New(s *svc.ServiceContext) *Service {
return &Service{ctx: s}
}
+// notifyDashboard 生产业务数据落库成功后广播看板刷新事件。
+// SSE 事件只做"数据变了"的通知(空载荷),大屏收到后自行拉取最新快照,
+// 避免把整份快照塞进事件流。无订阅客户端时 Publish 为空操作,零开销。
+func (s *Service) notifyDashboard() {
+ if s.ctx.SSE != nil {
+ s.ctx.SSE.Publish("dashboard.updated", "{}")
+ }
+}
+
// cachedTyped 泛型版缓存:与 cached 同为 TTL + SETNX 防击穿,
// 区别是返回强类型,调用方不必再做类型断言(看板快照等结构化数据用这个)。
func cachedTyped[T any](ctx context.Context, s *Service, key string, ttl int, build func() (T, error)) (T, error) {
diff --git a/bj_power_mes/internal/logic/material.go b/bj_power_mes/internal/logic/material.go
index 9726419..c55428b 100644
--- a/bj_power_mes/internal/logic/material.go
+++ b/bj_power_mes/internal/logic/material.go
@@ -97,6 +97,7 @@ func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string,
}
}
s.ctx.EventLog.Write(ctx, "material.request.generate", "", operator, "material_request", planDate, "按日排产自动生成备料单", map[string]any{"planDate": planDate, "count": created})
+ s.notifyDashboard()
return created, nil, nil
}
@@ -127,5 +128,6 @@ func (s *Service) SetMaterialRequestStatus(ctx context.Context, requestNo, statu
if n == 0 {
return errors.New("备料单不存在")
}
+ s.notifyDashboard()
return nil
}
diff --git a/bj_power_mes/internal/logic/scan.go b/bj_power_mes/internal/logic/scan.go
index e5224d4..538e4c4 100644
--- a/bj_power_mes/internal/logic/scan.go
+++ b/bj_power_mes/internal/logic/scan.go
@@ -47,6 +47,7 @@ func (s *Service) ReportScan(ctx context.Context, req ScanReq, operator string)
if err == nil && wp != nil && wp.WorkOrderId > 0 {
s.bumpWorkOrderProgress(ctx, wp.OrderNo, wp.Sn)
}
+ s.notifyDashboard()
return nil
}
diff --git a/bj_power_mes/internal/logic/torque.go b/bj_power_mes/internal/logic/torque.go
index aacebbd..96e0489 100644
--- a/bj_power_mes/internal/logic/torque.go
+++ b/bj_power_mes/internal/logic/torque.go
@@ -55,6 +55,7 @@ func (s *Service) ReportTorque(ctx context.Context, req TorqueReq, operator stri
}
s.ctx.EventLog.Write(ctx, "torque.report", workOrderNo, operator, "torque_record", req.Sn, "接收拧紧数据上报", map[string]any{"strain": req.Strain, "angle": req.Angle, "result": result})
s.evaluateTorqueCriterion(ctx, req.Sn, req.StationNo, req.Strain, req.Angle, operator)
+ s.notifyDashboard()
return nil
}
diff --git a/bj_power_mes/internal/logic/workorder.go b/bj_power_mes/internal/logic/workorder.go
index 5cfcc3e..e82ceea 100644
--- a/bj_power_mes/internal/logic/workorder.go
+++ b/bj_power_mes/internal/logic/workorder.go
@@ -72,6 +72,7 @@ func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operato
return err
}
s.ctx.EventLog.Write(ctx, "work.order.create", req.WorkOrderNo, operator, "work_order", req.WorkOrderNo, "创建工单", map[string]any{"quantity": req.Quantity})
+ s.notifyDashboard()
return nil
}
@@ -120,6 +121,7 @@ func (s *Service) UpdateWorkOrder(ctx context.Context, req WorkOrderReq, operato
if err == nil {
s.ctx.EventLog.Write(ctx, "work.order.update", wo.WorkOrderNo, operator, "work_order", wo.WorkOrderNo, "更新工单", nil)
}
+ s.notifyDashboard()
return nil
}
@@ -201,6 +203,7 @@ func (s *Service) SetWorkOrderStatus(ctx context.Context, id int, status, reason
map[string]any{"from": wo.Status, "to": status, "reason": reason})
// 联动:DONE/CANCELLED → 该工单未完成排产批量取消;IN_PROGRESS → 排产置执行中
s.syncPlansByOrderStatus(ctx, wo.WorkOrderNo, status, operator)
+ s.notifyDashboard()
return nil
}
@@ -244,4 +247,4 @@ func (s *Service) DeleteWorkOrder(ctx context.Context, id int, operator string)
}
s.ctx.EventLog.Write(ctx, "work.order.delete", wo.WorkOrderNo, operator, "work_order", wo.WorkOrderNo, "删除工单", map[string]any{"status": wo.Status})
return nil
-}
\ No newline at end of file
+}
diff --git a/bj_power_mes/internal/logic/workpiece.go b/bj_power_mes/internal/logic/workpiece.go
index 838b719..4b71ac8 100644
--- a/bj_power_mes/internal/logic/workpiece.go
+++ b/bj_power_mes/internal/logic/workpiece.go
@@ -91,6 +91,7 @@ func (s *Service) OnlineWorkpiece(ctx context.Context, req OnlineReq, operator s
return err
}
s.ctx.EventLog.Write(ctx, "workpiece.online", req.OrderNo, operator, "workpiece", req.Sn, "工件进线登记", nil)
+ s.notifyDashboard()
return nil
}
@@ -141,6 +142,7 @@ func (s *Service) ReportProcess(ctx context.Context, req ReportProcessReq, opera
s.ctx.EventLog.Write(ctx, "workpiece.process.report", wp.OrderNo, operator, "workpiece", req.Sn,
"工位报工", map[string]any{"processCode": req.ProcessCode, "result": result})
+ s.notifyDashboard()
return nil
}
@@ -202,6 +204,7 @@ func (s *Service) DoneWorkpiece(ctx context.Context, req DoneReq, operator strin
s.bumpWorkOrderProgress(ctx, wp.OrderNo, req.Sn)
s.bumpDailyPlanCompleted(ctx, wp.OrderNo, now)
s.ctx.EventLog.Write(ctx, "workpiece.done", wp.OrderNo, operator, "workpiece", req.Sn, "完工+关联追溯", map[string]any{"batchItems": req.BatchItems, "serialItems": req.SerialItems})
+ s.notifyDashboard()
return nil
}
diff --git a/bj_power_mes/internal/sse/sse.go b/bj_power_mes/internal/sse/sse.go
index 87b3f98..3fd3c64 100644
--- a/bj_power_mes/internal/sse/sse.go
+++ b/bj_power_mes/internal/sse/sse.go
@@ -78,4 +78,4 @@ func (h *Hub) Handler(w http.ResponseWriter, r *http.Request) {
return
}
}
-}
\ No newline at end of file
+}
diff --git a/bj_power_wms_client/frontend/src/layouts/MainLayout.vue b/bj_power_wms_client/frontend/src/layouts/MainLayout.vue
index fccec20..be64450 100644
--- a/bj_power_wms_client/frontend/src/layouts/MainLayout.vue
+++ b/bj_power_wms_client/frontend/src/layouts/MainLayout.vue
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
HomeFilled, Download, Upload, Search, CircleCheck, List,
- Box, Tickets, Monitor, EditPen, Fold, Expand, Location, Goods, Key, Operation, Van
+ Box, Tickets, Monitor, EditPen, Fold, Expand, Location, Goods, Key, Operation, Van, Clock
} from '@element-plus/icons-vue'
import { getUser, getToken, logout, setSession } from '../utils/auth'
import request from '../utils/request'
diff --git a/bj_power_wms_client/web/static/index.html b/bj_power_wms_client/web/static/index.html
index e42c87e..90eaf72 100644
--- a/bj_power_wms_client/web/static/index.html
+++ b/bj_power_wms_client/web/static/index.html
@@ -6,8 +6,8 @@
库房客户端
-
-
+
+
diff --git a/build_pdf.py b/build_pdf.py
new file mode 100644
index 0000000..1c75215
--- /dev/null
+++ b/build_pdf.py
@@ -0,0 +1,247 @@
+# -*- coding: utf-8 -*-
+"""读取 shots/manifest.json,生成甲方交付用系统页面截图说明书 PDF。"""
+import os, json, base64, sys, datetime
+from playwright.sync_api import sync_playwright
+
+SHOTS = "D:/hardman/bj_power/shots"
+OUT = "D:/hardman/bj_power/系统页面截图说明书.pdf"
+
+# ---------------- 每页说明文案 ----------------
+# key -> [功能说明, 关键操作, 主要字段] (均为列表,逐条展示)
+DESC = {
+ # ===== WMS 库房客户端 =====
+ "dashboard": ["登录后首页,集中展示仓库运行概览与快捷入口。",
+ ["点击快捷卡片可跳转到对应业务模块", "顶部显示当前用户与待办提醒"],
+ ["待入库/待出库数量", "库存总量", "近期出入库动态", "快捷入口"]],
+ "inbound": ["统一入库管理,按「结构件入库 / 精密件SN入库 / 入库记录」三 Tab 组织。",
+ ["选择对应 Tab 录入(前两个默认展开)", "入库记录 Tab 带查询条件、分页与全量导出"],
+ ["入库单号", "物料编码/名称", "批次(结构件) 或 序列号(精密件)", "数量", "库位区域", "操作人", "入库时间"]],
+ "outbound": ["统一出库管理,含「备料出库 / 通用出库 / 出库记录」。",
+ ["备料出库受工单备料台账约束(累计≤需求)", "通用出库支持装箱号与合同号", "记录 Tab 分页+导出"],
+ ["出库单号", "出库类型(备料/通用)", "物料/数量", "目标工位(DOCKxx)", "装箱号", "合同号", "状态"]],
+ "inventory": ["库存查询三视图:①物料汇总(默认) ②库存明细 ③区域汇总。",
+ ["默认首屏为物料汇总(忽略区域/质量聚合)", "点物料行下钻批次/SN 抽屉查看明细"],
+ ["物料编码/名称", "总数/锁定/可用", "合格/未检/不合格", "分布区域", "最近入库单"]],
+ "inspection": ["质量检验,两 Tab:检验录入(默认) / 检验记录。",
+ ["在录入 Tab 填写检验项并提交", "提交成功后记录 Tab 自动刷新"],
+ ["检验单号", "关联工单/物料", "检验项目", "结果(合格/不合格)", "检验人", "时间"]],
+ "stocktake": ["库存盘点,顶部步骤条:发起 → 扫码实盘 → 确认差异写回。",
+ ["选择区域发起盘点", "录入各物料实盘数量(支持差异)", "确认后将差异写回库存"],
+ ["盘点单号", "盘点区域", "系统量", "实盘量", "差异量", "状态(进行中/已完成)"]],
+ "semi": ["半成品管理,登记与查询车间半成品。",
+ ["录入半成品入库", "按条件查询并分页展示"],
+ ["半成品编码/名称", "数量", "状态", "入库时间", "关联工单"]],
+ "ledger": ["工单备料台账,按工单维度展示物料备料与领用进度。",
+ ["状态由已领量派生:待领料/部分领料/领料完结", "支持工单号/物料/状态筛选"],
+ ["工单号", "物料编码", "需求总量", "已领量", "领料状态"]],
+ "agv": ["AGV 配送任务监控,展示搬运任务与执行状态。",
+ ["按状态筛选任务", "查看任务详情与时间线"],
+ ["任务号", "起点/终点工位", "物料", "状态(待执行/执行中/完成)", "创建时间"]],
+ "zone": ["区域维护(基础资料),管理库区/货位区域。",
+ ["新增/编辑区域", "编码搜索大小写不敏感"],
+ ["区域编码", "区域名称", "描述", "创建时间"]],
+ "material": ["物料档案(基础资料),维护物料主数据,支持 Excel 导入/导出。",
+ ["新增/编辑物料", "导入 Excel 模板(全成功或全失败)", "按筛选导出 xlsx"],
+ ["物料编码", "名称/简称", "规格", "单位", "类型(结构件/精密件)", "说明"]],
+ "users": ["账号管理,系统用户增删改查(admin 账号锁定不可改)。",
+ ["新增/编辑账号并分配角色", "禁用/启用账号", "非 admin 可正常维护"],
+ ["用户名", "姓名", "角色", "状态(启用/禁用)", "创建时间"]],
+ "roles": ["角色管理,按树形勾选配置菜单+按钮级权限(check-strictly)。",
+ ["编辑角色权限(树勾选父子独立)", "admin 角色锁定为全权限"],
+ ["角色名称", "权限码", "关联账号数", "是否内置"]],
+ "eventlog": ["操作日志,全量记录用户操作行为用于审计。",
+ ["按时间/操作人/模块筛选", "分页浏览"],
+ ["操作人", "模块", "动作", "IP", "操作时间"]],
+ "changepwd": ["修改密码,当前登录用户自助改密。",
+ ["输入原密码与新密码", "二次确认后提交"],
+ ["原密码", "新密码", "确认新密码"]],
+ "display": ["库存动态大屏(免登录),实时轮播展示库房运行态势。",
+ ["大屏独立展示,无需登录", "数据实时/轮询刷新"],
+ ["库存总量", "出入库趋势", "预警信息", "区域分布"]],
+ # ===== MES 产线控制 =====
+ "workorder": ["生产工单管理,覆盖 CREATED/RELEASED/IN_PROGRESS/PAUSED/DONE 全生命周期。",
+ ["创建工单", "下发/暂停/完工操作", "按状态筛选"],
+ ["工单号", "产品类型", "数量", "状态", "计划/实际起止"]],
+ "dailyplan": ["日排产,按日期将工单拆解为每日生产计划。",
+ ["按排产日期生成计划", "查看每日排产明细"],
+ ["工单号", "排产日期", "排产数量", "状态"]],
+ "bom": ["物料清单(BOM),维护产品多级物料构成。",
+ ["新增/编辑 BOM 物料与用量", "查看产品物料树"],
+ ["产品", "子物料", "单位用量", "损耗率"]],
+ "materialrequest": ["备料单,由日排产自动生成的下料/领料清单。",
+ ["按日生成备料单", "查看备料明细与状态"],
+ ["备料单号", "工单号", "物料", "需求数", "状态"]],
+ "plcsend": ["工位组合下发,将工艺指令下发至 PLC/工位。",
+ ["下发组合指令", "查看下发日志与结果"],
+ ["指令号", "目标工位", "下发内容", "下发时间", "结果"]],
+ "torque": ["拧紧查询,记录并查询拧紧力矩数据。",
+ ["按工单/工位/时间查询", "查看合格判定"],
+ ["工件", "工位", "力矩值", "合格阈值", "结果", "时间"]],
+ "processflow": ["工艺流程,定义产品加工的工序流。",
+ ["新增流程并配置工序步骤", "启用/停用流程"],
+ ["流程名称", "工序步骤", "状态"]],
+ "station": ["关联工位,维护工位与工艺流程/产品的绑定关系。",
+ ["绑定工位到流程", "查看工位配置"],
+ ["工位号", "工位名称", "关联流程"]],
+ "performance": ["绩效报表,统计人员/产线产出与质量。",
+ ["按时间/人员筛选", "查看完工与合格率"],
+ ["人员", "完工数", "合格率", "工时"]],
+ "inspect": ["巡检终端,录入质量巡检项与结果。",
+ ["录入巡检点结果", "提交巡检记录"],
+ ["巡检点", "结果", "巡检人", "时间"]],
+ "scan": ["手动报工,支持扫描/录入方式上报工序完工。",
+ ["扫描或录入报工", "填写数量与工序"],
+ ["工单", "工序", "数量", "报工人", "时间"]],
+ "trace": ["工件追溯,按工件/序列号查询全生命周期。",
+ ["输入工件号查询", "查看工序时间线"],
+ ["工件号", "工单", "各工序", "拧紧/检验", "时间线"]],
+ "producttype": ["产品类型,维护产品主数据类型。",
+ ["新增/编辑产品类型", "启停状态管理"],
+ ["类型编码", "名称", "类别", "状态"]],
+ "account": ["账号管理(MES),维护产线系统账号与工位权限。",
+ ["新增/编辑账号", "分配角色与工位权限"],
+ ["用户名", "姓名", "角色", "工位权限", "状态"]],
+ "role": ["角色管理(MES),配置角色与权限。",
+ ["编辑角色权限", "查看角色关联"],
+ ["角色名", "权限", "关联账号"]],
+ "eventlog_mes": ["操作日志(MES),记录产线系统操作审计。",
+ ["按条件筛选", "分页浏览"],
+ ["操作人", "动作", "时间"]],
+ "changepwd_mes": ["修改密码(MES),当前用户自助改密。",
+ ["输入原/新密码提交"],
+ ["原密码", "新密码", "确认"]],
+ # ===== 大屏 =====
+ "dashboard_dash": ["生产·仓储·产线三维总览大屏,一屏聚合三块看板。",
+ ["生产看板:工单进度与产量", "仓储看板:库存与出入库", "产线三维模型实时展示"],
+ ["工单进度", "产量统计", "库存态势", "产线 3D 模型"]],
+ # ===== 工位终端 =====
+ "ws_login": ["工位终端登录页,账号 + 工位终端密码登录,工位号由配置文件固定。",
+ ["输入账号与工位终端密码", "点击登录进入主界面"],
+ ["账号", "密码", "固定工位号(由配置下发)"]],
+ "ws_main": ["工位终端主界面,触屏操作当前工位任务。",
+ ["工序完成上报", "暂存退库", "查看我的工作量", "放大查看图纸"],
+ ["工位号", "当前任务", "操作按钮", "工作量统计"]],
+}
+
+SYSTEMS = [
+ ("wms", "一、库房客户端系统(WMS Client · 端口 8891)", "wms"),
+ ("mes", "二、MES 产线控制系统(端口 8888)", "mes"),
+ ("dash", "三、数据可视化大屏(端口 5173)", "dash"),
+ ("ws", "四、工位终端系统(端口 8892)", "ws"),
+]
+
+def b64(path):
+ with open(path, "rb") as f:
+ return "data:image/png;base64," + base64.b64encode(f.read()).decode()
+
+def build_html(manifest):
+ today = datetime.date.today().strftime("%Y-%m-%d")
+ parts = []
+ parts.append("""""")
+
+ # 封面
+ parts.append("""
+
北京电力 WMS 系统 · 页面截图说明书
+
库房客户端 · MES 产线控制 · 数据大屏 · 工位终端
+
+ 交付对象:甲方(项目验收 / 操作参考)
+ 系统版本:V1.0 | 编制日期:%s
+ 配套后端 API 服务:WMS 后端(端口 8890,无独立界面,本册聚焦 4 个前端系统)
+
+
""" % today)
+
+ # 目录
+ toc = ['目录
']
+ total_pages = 0
+ for key, title, _ in SYSTEMS:
+ n = len(manifest.get(key, []))
+ total_pages += n
+ toc.append('- %s%d 页
' % (title, n))
+ toc.append('- 全册共 %d 个功能页面截图
' % total_pages)
+ toc.append('
')
+ toc.append('')
+ toc.append('
')
+ parts.append("".join(toc))
+
+ # 各系统章节
+ for key, title, prefix in SYSTEMS:
+ items = manifest.get(key, [])
+ parts.append('%s
' % title)
+ parts.append('' % len(items))
+ parts.append('')
+ for idx, it in enumerate(items, 1):
+ key = os.path.splitext(os.path.basename(it["file"]))[0].split("_", 1)[-1]
+ dkey = it.get("desc_key") or key
+ d = DESC.get(dkey, DESC.get(key, [["", [], []]]))
+ func = d[0] if d and d[0] else "(详见截图)"
+ ops = d[1] if len(d) > 1 else []
+ fields = d[2] if len(d) > 2 else []
+ ops_html = "".join("%s" % o for o in ops)
+ fields_html = "、".join(fields)
+ img = b64(it["file"])
+ parts.append(
+ ''
+ '
%d.%s%s
' % (idx, it["title"], title.split("(")[0])
+ + '
' % img
+ + '
'
+ '
功能说明:%s
' % func
+ + ('
' % ops_html if ops else '')
+ + ('
主要字段:%s
' % fields_html if fields else '')
+ + '
'
+ )
+ return "".join(parts)
+
+def main():
+ with open(os.path.join(SHOTS, "manifest.json"), "r", encoding="utf-8") as f:
+ manifest = json.load(f)
+
+ # 给 dash / ws / MES 的 key 映射成特殊 desc key(manifest 没存 key,从文件名取)
+ def file_key(path):
+ return os.path.splitext(os.path.basename(path))[0].split("_", 1)[-1]
+ for it in manifest.get("dash", []):
+ it["desc_key"] = "dashboard_dash"
+ for it in manifest.get("ws", []):
+ k = file_key(it["file"])
+ it["desc_key"] = "ws_login" if k == "login" else "ws_main"
+ for it in manifest.get("mes", []):
+ k = file_key(it["file"])
+ if k == "eventlog": it["desc_key"] = "eventlog_mes"
+ if k == "changepwd": it["desc_key"] = "changepwd_mes"
+
+ html = build_html(manifest)
+ with sync_playwright() as p:
+ browser = p.chromium.launch(channel="msedge", args=["--no-sandbox"])
+ page = browser.new_page()
+ page.set_content(html, wait_until="networkidle")
+ page.pdf(path=OUT, format="A4", landscape=True, print_background=True,
+ margin={"top":"10mm","bottom":"10mm","left":"8mm","right":"8mm"})
+ browser.close()
+ size = os.path.getsize(OUT)
+ print("PDF 生成完成: %s (%.2f MB)" % (OUT, size/1024/1024))
+
+if __name__ == "__main__":
+ main()
diff --git a/demo_seed.py b/demo_seed.py
new file mode 100644
index 0000000..003f5be
--- /dev/null
+++ b/demo_seed.py
@@ -0,0 +1,537 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""演示数据落库脚本(北京电力 WMS + MES)
+
+用途:为交付甲方的《系统页面截图说明书》准备成体系的业务数据。
+数据真实落库,可长期保留。脚本可重复执行(幂等:已存在则跳过)。
+
+用法:
+ python demo_seed.py # 全部执行
+ python demo_seed.py inspect # 只探测库存/基线,不写库
+"""
+import json
+import sys
+import time
+import urllib.request
+import urllib.error
+
+sys.stdout.reconfigure(encoding="utf-8")
+
+WMS = "http://127.0.0.1:8890"
+MES = "http://127.0.0.1:8888"
+XAPI = "Hardman_2026"
+
+OK, FAIL, SKIP = [], [], []
+
+
+def req(base, method, path, body=None, token=None, xapi=None, timeout=20):
+ url = base + path
+ data = None
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = "Bearer " + token
+ if xapi:
+ headers["X-API-TOKEN"] = xapi
+ if body is not None:
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+ r = urllib.request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with urllib.request.urlopen(r, timeout=timeout) as resp:
+ return resp.status, resp.read().decode("utf-8", "replace")
+ except urllib.error.HTTPError as e:
+ return e.code, e.read().decode("utf-8", "replace")
+ except Exception as e:
+ return 0, str(e)
+
+
+def j(txt):
+ try:
+ return json.loads(txt)
+ except Exception:
+ return {"_raw": txt}
+
+
+def d_of(txt):
+ d = j(txt)
+ return d.get("data") if isinstance(d, dict) else None
+
+
+def step(label, st, txt, ok_cond=None, note=""):
+ body = j(txt)
+ ok = body.get("code") == 0 if isinstance(body, dict) else False
+ if ok_cond is not None:
+ ok = ok_cond
+ tag = "OK " if ok else "FAIL"
+ msg = body.get("message", "") if isinstance(body, dict) else txt[:120]
+ print(" [%s] %-38s %s %s" % (tag, label, msg, note))
+ (OK if ok else FAIL).append(label)
+ return body
+
+
+def skip(label, why):
+ print(" [SKIP] %-38s %s" % (label, why))
+ SKIP.append(label)
+
+
+# ============================== 登录 ==============================
+def login_wms():
+ st, txt = req(WMS, "POST", "/api/auth/login", {"username": "admin", "password": "123456"})
+ return (d_of(txt) or {}).get("token", "")
+
+
+def login_mes():
+ st, txt = req(MES, "POST", "/api/v1/login", {"username": "admin", "password": "123456"})
+ return (d_of(txt) or {}).get("accessToken", "")
+
+
+def today(offset=0):
+ return time.strftime("%Y-%m-%d", time.localtime(time.time() + offset * 86400))
+
+
+# ============================== 探测 ==============================
+def fetch_details(wms_tok, manage_mode=0, page_size=200):
+ """库存明细(原始行,含 batch_no / sn_code)——出库必须用它,聚合接口不返批次/SN"""
+ url = "/api/stock/details?page=1&pageSize=%d" % page_size
+ if manage_mode:
+ url += "&manageMode=%d" % manage_mode
+ st, txt = req(WMS, "GET", url, token=wms_tok)
+ d = d_of(txt) or {}
+ return d.get("list", [])
+
+
+def inspect(wms_tok, mes_tok):
+ print("\n===== 库存探测(明细)=====")
+ rows = fetch_details(wms_tok)
+ print(" 库存明细行数=%d" % len(rows))
+ if rows:
+ print(" 样例字段:%s" % sorted(rows[0].keys()))
+ for r in rows[:12]:
+ print(" %s mode=%s qty=%s zone=%s batch=%s sn=%s qual=%s" % (
+ r.get("materialCode"), r.get("manageMode"), r.get("quantity"),
+ r.get("zoneCode"), r.get("batchNo"), r.get("snCode"), r.get("qualityStatus")))
+ print("\n===== MES 基线 =====")
+ st, txt = req(MES, "GET", "/api/v1/product-types", token=mes_tok)
+ print(" 产品类型:", json.dumps(d_of(txt), ensure_ascii=False)[:200])
+ st, txt = req(MES, "GET", "/api/v1/stations", token=mes_tok)
+ sts = d_of(txt) or []
+ print(" 工位:%s" % [(s.get("stationNo"), s.get("name"), s.get("flowId")) for s in sts][:12])
+
+
+# ============================== MES 造数 ==============================
+def mes_seed(mes_tok, wms_tok):
+ print("\n===== MES 演示数据 =====")
+ st, txt = req(MES, "GET", "/api/v1/product-types", token=mes_tok)
+ ptypes = d_of(txt) or []
+ ptype = ptypes[0]
+ ptype_id = ptype["id"]
+ prod_code = ptype.get("code")
+ prod_name = ptype.get("name")
+ print(" 产品类型 id=%s code=%s name=%s" % (ptype_id, prod_code, prod_name))
+
+ # ---- BOM(用 WMS 真实物料)----
+ # 说明:前三项库内有现货(可走通备料出库全链路),第四项为长周期采购件
+ bom_items = [
+ {"materialCode": "ST-A100", "materialName": "标准结构件A100", "spec": "A100", "unit": "件", "manageMode": "1", "unitQty": 2, "lossRate": 5},
+ {"materialCode": "GJ-GANGGUAN-002", "materialName": "无缝钢管114x8", "spec": "114x8", "unit": "米", "manageMode": "1", "unitQty": 4, "lossRate": 3},
+ {"materialCode": "PR-S100", "materialName": "精密传感器S100", "spec": "S100", "unit": "个", "manageMode": "2", "unitQty": 1, "lossRate": 0},
+ {"materialCode": "JM-ZHOUCHENG-6020", "materialName": "精密轴承6020", "spec": "6020", "unit": "个", "manageMode": "2", "unitQty": 2, "lossRate": 0},
+ ]
+ step("BOM 保存(4 项物料)", *req(MES, "PUT", "/api/v1/bom", {"productCode": prod_code, "items": bom_items}, token=mes_tok))
+
+ # ---- 第二个产品类型(仅 1 项物料,用于演示台账"领料完结"状态)----
+ prod2_code = "PROD-20260904-002"
+ st, txt = req(MES, "GET", "/api/v1/product-types", token=mes_tok)
+ hit = [p for p in (d_of(txt) or []) if p.get("code") == prod2_code]
+ if hit:
+ ptype2_id = hit[0]["id"]
+ print(" [SKIP] 产品类型2 已存在 id=%s" % ptype2_id)
+ else:
+ st, txt = req(MES, "POST", "/api/v1/product-types",
+ {"code": prod2_code, "name": "接驳体总成", "category": "总成类",
+ "remark": "单物料演示产品", "isActive": True}, token=mes_tok)
+ b = j(txt)
+ print(" [%s] 创建产品类型2 %s" % ("OK " if b.get("code") == 0 else "FAIL", b.get("message", "")))
+ # 创建接口不返回 id,需回查
+ st, txt = req(MES, "GET", "/api/v1/product-types", token=mes_tok)
+ hit = [p for p in (d_of(txt) or []) if p.get("code") == prod2_code]
+ ptype2_id = hit[0]["id"] if hit else None
+ if ptype2_id:
+ step("BOM2 保存(单物料 PR-S100)", *req(MES, "PUT", "/api/v1/bom", {
+ "productCode": prod2_code,
+ "items": [{"materialCode": "PR-S100", "materialName": "精密传感器S100", "spec": "S100",
+ "unit": "个", "manageMode": "2", "unitQty": 1, "lossRate": 0}]}, token=mes_tok))
+
+ # ---- 工单:覆盖各状态 ----
+ tag = time.strftime("%m%d")
+ orders = {}
+
+ def create_wo(no, qty, status_flow, p_id=None, p_code=None, p_name=None):
+ body = {"workOrderNo": no, "productTypeId": p_id or ptype_id, "productCode": p_code or prod_code,
+ "productName": p_name or prod_name, "quantity": qty, "status": "CREATED", "processSeq": ""}
+ st, txt = req(MES, "POST", "/api/v1/work-orders", body, token=mes_tok)
+ b = j(txt)
+ if b.get("code") != 0 and "已存在" not in b.get("message", ""):
+ step("工单创建 %s" % no, st, txt)
+ return None
+ st, txt = req(MES, "GET", "/api/v1/work-orders?orderNo=" + no, token=mes_tok)
+ rows = d_of(txt) or []
+ if not rows:
+ step("工单创建 %s" % no, st, txt)
+ return None
+ wo = rows[0]
+ print(" [OK ] 工单 %-30s id=%s qty=%s" % (no, wo.get("id"), qty))
+ OK.append("工单 " + no)
+ for s, reason in status_flow:
+ st2, txt2 = req(MES, "POST", "/api/v1/work-orders/status",
+ {"id": wo["id"], "status": s, "reason": reason}, token=mes_tok)
+ b2 = j(txt2)
+ if b2.get("code") == 0:
+ print(" -> %s" % s)
+ else:
+ print(" -> %s 失败: %s" % (s, b2.get("message")))
+ orders[no] = wo
+ return wo
+
+ # WO-01 执行中(主力演示)
+ create_wo("WO-2026%s-01" % tag, 30, [("RELEASED", ""), ("IN_PROGRESS", "按计划投产")])
+ # WO-02 已发布
+ create_wo("WO-2026%s-02" % tag, 20, [("RELEASED", "")])
+ # WO-03 暂停
+ create_wo("WO-2026%s-03" % tag, 15, [("RELEASED", ""), ("IN_PROGRESS", ""), ("PAUSED", "待料:轴承未到货")])
+ # WO-04 已完工
+ create_wo("WO-2026%s-04" % tag, 10, [("RELEASED", ""), ("IN_PROGRESS", ""), ("DONE", "按计划完工")])
+ # WO-05 草稿
+ create_wo("WO-2026%s-05" % tag, 25, [])
+ # WO-06 单物料小工单(用于演示台账"领料完结")
+ if ptype2_id:
+ create_wo("WO-2026%s-06" % tag, 2, [("RELEASED", ""), ("IN_PROGRESS", "小批量试产")],
+ p_id=ptype2_id, p_code=prod2_code, p_name="接驳体总成")
+
+ # ---- 日排产 ----
+ print(" --- 日排产 ---")
+ plans = [
+ ("WO-2026%s-01" % tag, today(-1), 10),
+ ("WO-2026%s-01" % tag, today(0), 12),
+ ("WO-2026%s-01" % tag, today(1), 8),
+ ("WO-2026%s-02" % tag, today(0), 10),
+ ("WO-2026%s-02" % tag, today(2), 10),
+ ("WO-2026%s-05" % tag, today(3), 15),
+ ]
+ if ptype2_id:
+ plans.append(("WO-2026%s-06" % tag, today(4), 2))
+ for no, d, qty in plans:
+ st, txt = req(MES, "POST", "/api/v1/daily-plans",
+ {"orderNo": no, "planDate": d, "planQty": qty, "status": ""}, token=mes_tok)
+ b = j(txt)
+ okk = b.get("code") == 0 or "已存在" in b.get("message", "")
+ print(" [%s] 排产 %s %s qty=%s %s" % ("OK " if okk else "FAIL", no, d, qty, b.get("message", "")))
+ (OK if okk else FAIL).append("排产 %s %s" % (no, d))
+
+ # ---- 备料单(按日生成,幂等:已有该日记录则跳过)----
+ print(" --- 备料单生成 ---")
+ st, txt = req(MES, "GET", "/api/v1/material-requests", token=mes_tok)
+ exist_dates = {m.get("planDate") for m in (d_of(txt) or []) if m.get("planDate")}
+ for d in [today(-1), today(0), today(1)] + ([today(4)] if ptype2_id else []):
+ if d in exist_dates:
+ skip("备料单 %s" % d, "该日已生成过")
+ continue
+ st, txt = req(MES, "POST", "/api/v1/material-requests/generate", {"planDate": d}, token=mes_tok)
+ b = j(txt)
+ cnt = (b.get("data") or {}).get("count")
+ print(" [%s] 生成 %s 备料单 %s 条 %s" % ("OK " if b.get("code") == 0 else "FAIL", d, cnt, b.get("message", "")))
+ (OK if b.get("code") == 0 else FAIL).append("备料单 %s" % d)
+
+ # ---- 工件:进线 / 工序报工 / 拧紧 / 完工 ----
+ print(" --- 工件与工序 ---")
+ main_no = "WO-2026%s-01" % tag
+ sns = ["SN2026%s-0001" % tag, "SN2026%s-0002" % tag, "SN2026%s-0003" % tag,
+ "SN2026%s-0004" % tag, "SN2026%s-0005" % tag]
+ for i, sn in enumerate(sns):
+ st, txt = req(MES, "POST", "/api/v1/workpiece/online", {"sn": sn, "orderNo": main_no}, token=mes_tok)
+ b = j(txt)
+ print(" [%s] 进线 %s %s" % ("OK " if b.get("code") == 0 else "FAIL", sn, b.get("message", "")))
+ # 前 3 件走完工序 1~6,后 2 件只到工序 2(在制)
+ upto = 6 if i < 3 else 2
+ for p in range(1, upto + 1):
+ req(MES, "POST", "/api/v1/workpiece/process/report",
+ {"sn": sn, "processCode": p, "stationNo": p, "steps": []}, token=mes_tok)
+ print(" 工序 1~%d 已报工" % upto)
+ # 拧紧数据
+ for i, sn in enumerate(sns[:3]):
+ st, txt = req(MES, "POST", "/api/v1/torque/report",
+ {"sn": sn, "workOrder": main_no, "stationNo": "7", "screwNo": "S-%03d" % (i + 1),
+ "torque": 48.5 + i, "angle": 88.0 + i * 2, "result": "OK"}, token=mes_tok)
+ b = j(txt)
+ print(" [%s] 拧紧 %s torque=%.1f" % ("OK " if b.get("code") == 0 else "FAIL", sn, 48.5 + i))
+ # 完工 3 件
+ for sn in sns[:3]:
+ st, txt = req(MES, "POST", "/api/v1/workpiece/done",
+ {"sn": sn, "batchItems": [], "serialItems": []}, token=mes_tok)
+ b = j(txt)
+ print(" [%s] 完工 %s %s" % ("OK " if b.get("code") == 0 else "FAIL", sn, b.get("message", "")))
+
+ # ---- 扫码报工 ----
+ print(" --- 扫码报工 ---")
+ for i, sn in enumerate(sns[3:]):
+ st, txt = req(MES, "POST", "/api/v1/scan/report",
+ {"station": "装配工位%d" % (i + 3), "sn": sn, "orderNo": main_no,
+ "type": "PROCESS", "processCode": i + 1, "operator": "张装配"}, token=mes_tok)
+ b = j(txt)
+ print(" [%s] 报工 %s %s" % ("OK " if b.get("code") == 0 else "FAIL", sn, b.get("message", "")))
+
+ # ---- PLC 下发(握手:每次间隔 7s)----
+ print(" --- PLC 工位组合下发 ---")
+ for sn in sns[3:5]:
+ st, txt = req(MES, "POST", "/api/v1/plc/send-process",
+ {"orderNo": main_no, "sn": sn, "stationNo": 3, "processCombination": "1"}, token=mes_tok)
+ b = j(txt)
+ print(" [%s] 下发 %s %s" % ("OK " if b.get("code") == 0 else "FAIL", sn, b.get("message", "")))
+ time.sleep(7)
+
+ # ---- 巡检记录 ----
+ print(" --- 巡检终端 ---")
+ insp = [
+ {"category": "CHECKIN", "stationNo": "1", "shift": "早", "orderNo": main_no, "sn": "",
+ "result": "OK", "items": ["劳保穿戴齐全", "设备点检完成"], "photo": "", "remark": "到岗签到", "operator": "李巡检"},
+ {"category": "POINT", "stationNo": "7", "shift": "早", "orderNo": main_no, "sn": "",
+ "result": "OK", "items": ["扭矩枪校准", "气压正常"], "photo": "", "remark": "班前点检", "operator": "李巡检"},
+ {"category": "PROCESS", "stationNo": "3", "shift": "中", "orderNo": main_no, "sn": sns[3],
+ "result": "OK", "items": ["装配到位", "标识清晰"], "photo": "", "remark": "过程巡检", "operator": "王质检"},
+ {"category": "DONE", "stationNo": "1", "shift": "晚", "orderNo": main_no, "sn": "",
+ "result": "OK", "items": ["现场清理", "设备断电"], "photo": "", "remark": "收工巡检", "operator": "李巡检"},
+ ]
+ for it in insp:
+ st, txt = req(MES, "POST", "/api/v1/inspections", it, token=mes_tok)
+ b = j(txt)
+ print(" [%s] 巡检 %s/%s %s" % ("OK " if b.get("code") == 0 else "FAIL", it["category"], it["stationNo"], b.get("message", "")))
+
+ return {"ptype_id": ptype_id, "prod_code": prod_code, "main_no": main_no, "sns": sns, "tag": tag}
+
+
+# ============================== WMS 造数 ==============================
+def wms_seed(wms_tok, mes_info):
+ print("\n===== WMS 演示数据 =====")
+ tag = mes_info["tag"]
+ main_no = mes_info["main_no"]
+
+ # ---- 库存探测:取可用批次/SN(明细接口,聚合行无批次/SN)----
+ allrows = fetch_details(wms_tok)
+ batches = [r for r in allrows if r.get("manageMode") == 1 and (r.get("quantity") or 0) > 0]
+ snrows = [r for r in allrows if r.get("manageMode") == 2 and (r.get("quantity") or 0) > 0]
+ print(" 可用批次行=%d SN行=%d" % (len(batches), len(snrows)))
+ for r in batches[:6]:
+ print(" 批次 %s %s qty=%s zone=%s" % (r.get("materialCode"), r.get("batchNo"), r.get("quantity"), r.get("zoneCode")))
+ for r in snrows[:6]:
+ print(" SN %s %s zone=%s" % (r.get("materialCode"), r.get("snCode"), r.get("zoneCode")))
+
+ # ---- 补充入库:精密件 SN(演示出库会持续消耗,先保证后续链路有货)----
+ print(" --- 补充入库 ---")
+ for code, want in [("PR-S100", 12), ("ST-A100", 2)]:
+ have = sum(1 for r in allrows if r.get("materialCode") == code and (r.get("quantity") or 0) > 0)
+ if have >= want:
+ skip("补入库 %s" % code, "现有 %d 已充足" % have)
+ continue
+ need = want - have
+ if code == "PR-S100":
+ body = {"inboundType": "purchase", "materialCode": code,
+ "snList": ["PR-S100-DEMO%03d" % i for i in range(have, have + need)],
+ "zoneCode": "Z02", "operator": "孙采购"}
+ else:
+ body = {"inboundType": "purchase", "materialCode": code,
+ "batchNo": "B-DEMO-%s" % time.strftime("%H%M%S"), "quantity": 30 * need,
+ "zoneCode": "Z01", "operator": "孙采购"}
+ st, txt = req(WMS, "POST", "/api/inbound/create", body, token=wms_tok)
+ step("补入库 %s x%d" % (code, need), st, txt)
+ allrows = fetch_details(wms_tok)
+
+ # ---- 台账同步(MES 工单 → WMS 工单备料台账)----
+ print(" --- 工单备料台账同步 ---")
+ st, txt = req(MES, "GET", "/api/v1/material-requests", token=mes_info["mes_tok"])
+ mrs = d_of(txt) or []
+ by_order = {}
+ for m in mrs:
+ by_order.setdefault(m.get("orderNo"), []).append(m)
+ # 幂等:已同步过的工单不再同步(sync 会把 status 重置为"进行中",会抹掉"领料完结")
+ st, txt = req(WMS, "GET", "/api/ledger/query?page=1&pageSize=200", token=wms_tok)
+ synced = {x.get("orderNo") for x in ((d_of(txt) or {}).get("list") or [])}
+ for no, items in by_order.items():
+ if no in synced:
+ skip("台账同步 %s" % no, "已同步过")
+ continue
+ payload = {
+ "orderNo": no,
+ "productCode": mes_info["prod_code"],
+ "operator": "系统同步",
+ "items": [{"materialCode": i.get("materialCode"), "materialName": i.get("materialName"),
+ "totalQty": int(i.get("reqQty") or 0)} for i in items],
+ }
+ st, txt = req(WMS, "POST", "/api/internal/ledger/sync", payload, xapi=XAPI)
+ b = j(txt)
+ print(" [%s] 台账同步 %s (%d 项) %s" % ("OK " if b.get("code") == 0 else "FAIL", no, len(items), b.get("message", "")))
+ (OK if b.get("code") == 0 else FAIL).append("台账同步 " + no)
+
+ # ---- 备料出库(受台账约束:累计出库 ≤ BOM 需求)----
+ print(" --- 备料出库(按台账)---")
+ st, txt = req(WMS, "GET", "/api/ledger/query?page=1&pageSize=200", token=wms_tok)
+ lg = (d_of(txt) or {}).get("list") or []
+ print(" 台账共 %d 项" % len(lg))
+ for item in lg:
+ code, total = item.get("materialCode"), item.get("totalQty") or 0
+ out = item.get("outQty") or 0
+ need = total - out
+ if need <= 0:
+ skip("备料出库 %s/%s" % (item.get("orderNo"), code), "已领料完结")
+ continue
+ cand = [r for r in allrows if r.get("materialCode") == code and (r.get("quantity") or 0) > 0]
+ if not cand:
+ skip("备料出库 %s/%s" % (item.get("orderNo"), code), "库内无可用库存")
+ continue
+ r0 = cand[0]
+ # 小单(需求≤6)一次领足 → 演示"领料完结";大单只领一部分 → 演示"部分领料"
+ want = need if need <= 6 else min(need, 3)
+ if r0.get("manageMode") == 2:
+ take = cand[:min(want, len(cand))]
+ body = {"orderNo": item.get("orderNo"), "materialCode": code,
+ "snList": [x.get("snCode") for x in take], "qty": len(take),
+ "operator": "刘库管", "targetDock": "DOCK03",
+ "zoneCode": r0.get("zoneCode"), "remark": "工单备料"}
+ else:
+ qty = min(want, r0.get("quantity") or 1)
+ body = {"orderNo": item.get("orderNo"), "materialCode": code, "batchNo": r0.get("batchNo"),
+ "qty": qty, "operator": "刘库管", "targetDock": "DOCK03",
+ "zoneCode": r0.get("zoneCode"), "remark": "工单备料"}
+ st, txt = req(WMS, "POST", "/api/outbound/create", body, token=wms_tok)
+ step("备料出库 %s/%s x%s" % (item.get("orderNo"), code, body["qty"]), st, txt)
+ # 出库后刷新库存视图,避免同一行被重复分配
+ allrows = fetch_details(wms_tok)
+
+ # ---- 通用出库(含装箱/合同号;用最新库存,避免与备料出库抢同一行)----
+ print(" --- 通用出库 ---")
+ allrows = fetch_details(wms_tok)
+ fresh_batch = [r for r in allrows if r.get("manageMode") == 1 and (r.get("quantity") or 0) > 0]
+ fresh_sn = [r for r in allrows if r.get("manageMode") == 2 and (r.get("quantity") or 0) > 0]
+ for i, (r, box, contract) in enumerate([(fresh_batch[0], "BOX-2026-001", "HT2026-0117"),
+ (fresh_batch[1] if len(fresh_batch) > 1 else None, "BOX-2026-002", "HT2026-0118")]):
+ if not r:
+ continue
+ st, txt = req(WMS, "POST", "/api/outbound/general", {
+ "materialCode": r.get("materialCode"), "batchNo": r.get("batchNo"),
+ "qty": min(3, r.get("quantity") or 1), "operator": "刘库管",
+ "zoneCode": r.get("zoneCode"), "boxNo": box, "contractNo": contract,
+ "remark": "销售发货",
+ }, token=wms_tok)
+ step("通用出库 %s/%s" % (r.get("materialCode"), box), st, txt)
+
+ # SN 出库(挑库内仍可用的 SN)
+ s0 = next((r for r in fresh_sn if r.get("materialCode") == "PR-S100"), fresh_sn[0] if fresh_sn else None)
+ if s0:
+ st, txt = req(WMS, "POST", "/api/outbound/general", {
+ "materialCode": s0.get("materialCode"), "snList": [s0.get("snCode")],
+ "operator": "刘库管", "zoneCode": s0.get("zoneCode"),
+ "boxNo": "BOX-2026-003", "contractNo": "HT2026-0119", "remark": "精密件领用",
+ }, token=wms_tok)
+ step("通用出库 SN %s" % s0.get("snCode"), st, txt)
+
+ # ---- 半成品入库 ----
+ print(" --- 半成品管理 ---")
+ semi_plan = [
+ ("BP-2026%s-001" % tag, "BCP-JIEKOUTI", [1, 2, 3], "装配三", "Z02", main_no),
+ ("BP-2026%s-002" % tag, "BCP-JIEKOUTI", [1, 2], "装配二", "Z02", main_no),
+ ("BP-2026%s-003" % tag, "BCP-JIEKOUTI", [1, 2, 3, 4, 5, 6], "装配六", "Z02", main_no),
+ ("BP-2026%s-004" % tag, "BCP-JIEKOUTI", [1], "装配一", "Z02", main_no),
+ ]
+ for sn, mat, done, comp, zone, order in semi_plan:
+ st, txt = req(WMS, "POST", "/api/semi/inbound", {
+ "sn": sn, "materialCode": mat, "doneProcessCodes": done,
+ "completedProcess": comp, "zoneCode": zone, "orderNo": order, "operator": "陈产线",
+ }, token=wms_tok)
+ step("半成品入库 %s" % sn, st, txt)
+
+ # ---- 盘点 ----
+ print(" --- 库存盘点 ---")
+ st, txt = req(WMS, "GET", "/api/stocktake/query", token=wms_tok)
+ existing = (d_of(txt) or {}).get("list") or []
+ if existing:
+ skip("发起盘点", "已存在 %d 张盘点单" % len(existing))
+ else:
+ zone_pick = []
+ for r in allrows[:60]:
+ z = r.get("zoneCode")
+ if z and z not in zone_pick:
+ zone_pick.append(z)
+ if len(zone_pick) >= 2:
+ break
+ st, txt = req(WMS, "POST", "/api/stocktake/start", {"operator": "赵盘点", "zones": zone_pick}, token=wms_tok)
+ b = j(txt)
+ st_no = (b.get("data") or {}).get("stocktakeNo") if b.get("code") == 0 else None
+ print(" [%s] 发起盘点 zones=%s no=%s %s" % ("OK " if st_no else "FAIL", zone_pick, st_no, b.get("message", "")))
+ if st_no:
+ OK.append("发起盘点")
+ st, txt = req(WMS, "GET", "/api/stocktake/query?stocktakeNo=" + st_no + "&pageSize=200", token=wms_tok)
+ bb = j(txt)
+ items = (bb.get("data") or {}).get("list") or []
+ print(" 盘点项 %d 条" % len(items))
+ # 录入前 8 条实盘:前 6 条账实相符,后 2 条制造差异
+ for i, it in enumerate(items[:8]):
+ tid = it.get("target_id")
+ if not tid:
+ continue
+ sysq = it.get("book_qty") or 1
+ scan = sysq if i < 6 else max(0, sysq - 1)
+ req(WMS, "POST", "/api/stocktake/record", {
+ "stocktakeNo": st_no, "targetType": it.get("target_type") or "",
+ "targetId": tid, "scanQty": scan}, token=wms_tok)
+ print(" 已录入 8 条实盘(含 2 条差异)")
+ st, txt = req(WMS, "POST", "/api/stocktake/finish", {"stocktakeNo": st_no, "adjust": True}, token=wms_tok)
+ step("完成盘点 %s" % st_no, st, txt)
+
+ # ---- AGV 配送 ----
+ print(" --- AGV 配送 ---")
+ rows_payload = []
+ for i, m in enumerate((d_of(req(MES, "GET", "/api/v1/material-requests", token=mes_info["mes_tok"])[1]) or [])[:4]):
+ rows_payload.append({
+ "requestNo": m.get("requestNo"), "materialCode": m.get("materialCode"),
+ "materialName": m.get("materialName"), "unit": m.get("unit") or "件",
+ "qty": float(m.get("reqQty") or 1), "targetDock": "DOCK%02d" % (i + 1),
+ })
+ if rows_payload:
+ st, txt = req(WMS, "POST", "/api/agv/submit", {"rows": rows_payload, "sourceDock": "DOCK21"}, token=wms_tok)
+ step("AGV 下发 %d 行" % len(rows_payload), st, txt)
+
+
+# ============================== main ==============================
+def main():
+ mode = sys.argv[1] if len(sys.argv) > 1 else "all"
+ print("=" * 70)
+ print("演示数据落库 WMS=%s MES=%s" % (WMS, MES))
+ print("=" * 70)
+
+ wms_tok = login_wms()
+ mes_tok = login_mes()
+ if not wms_tok:
+ print("WMS 登录失败")
+ sys.exit(1)
+ if not mes_tok:
+ print("MES 登录失败")
+ sys.exit(1)
+ print("登录成功")
+
+ if mode == "inspect":
+ inspect(wms_tok, mes_tok)
+ return
+
+ mes_info = mes_seed(mes_tok, wms_tok)
+ mes_info["mes_tok"] = mes_tok
+ wms_seed(wms_tok, mes_info)
+
+ print("\n" + "=" * 70)
+ print("成功 %d / 失败 %d / 跳过 %d" % (len(OK), len(FAIL), len(SKIP)))
+ if FAIL:
+ print("失败项:")
+ for f in FAIL:
+ print(" - " + f)
+ print("=" * 70)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shots/dash/01_dashboard.png b/shots/dash/01_dashboard.png
new file mode 100644
index 0000000..eedb3b5
Binary files /dev/null and b/shots/dash/01_dashboard.png differ
diff --git a/shots/manifest.json b/shots/manifest.json
new file mode 100644
index 0000000..c4dec3f
--- /dev/null
+++ b/shots/manifest.json
@@ -0,0 +1,226 @@
+{
+ "wms": [
+ {
+ "route": "/",
+ "title": "工作台",
+ "file": "D:/hardman/bj_power/shots\\wms\\01_dashboard.png",
+ "size": 54159
+ },
+ {
+ "route": "/inbound",
+ "title": "入库管理",
+ "file": "D:/hardman/bj_power/shots\\wms\\02_inbound.png",
+ "size": 68946
+ },
+ {
+ "route": "/outbound",
+ "title": "出库管理",
+ "file": "D:/hardman/bj_power/shots\\wms\\03_outbound.png",
+ "size": 66820
+ },
+ {
+ "route": "/inventory",
+ "title": "库存查询",
+ "file": "D:/hardman/bj_power/shots\\wms\\04_inventory.png",
+ "size": 81933
+ },
+ {
+ "route": "/inspection",
+ "title": "质量检验",
+ "file": "D:/hardman/bj_power/shots\\wms\\05_inspection.png",
+ "size": 74477
+ },
+ {
+ "route": "/stocktake",
+ "title": "库存盘点",
+ "file": "D:/hardman/bj_power/shots\\wms\\06_stocktake.png",
+ "size": 83663
+ },
+ {
+ "route": "/semi",
+ "title": "半成品管理",
+ "file": "D:/hardman/bj_power/shots\\wms\\07_semi.png",
+ "size": 57078
+ },
+ {
+ "route": "/ledger",
+ "title": "工单备料台账",
+ "file": "D:/hardman/bj_power/shots\\wms\\08_ledger.png",
+ "size": 116867
+ },
+ {
+ "route": "/agv",
+ "title": "AGV配送",
+ "file": "D:/hardman/bj_power/shots\\wms\\09_agv.png",
+ "size": 84377
+ },
+ {
+ "route": "/zone",
+ "title": "区域维护",
+ "file": "D:/hardman/bj_power/shots\\wms\\10_zone.png",
+ "size": 75177
+ },
+ {
+ "route": "/material",
+ "title": "物料档案",
+ "file": "D:/hardman/bj_power/shots\\wms\\11_material.png",
+ "size": 104585
+ },
+ {
+ "route": "/users",
+ "title": "账号管理",
+ "file": "D:/hardman/bj_power/shots\\wms\\12_users.png",
+ "size": 62383
+ },
+ {
+ "route": "/roles",
+ "title": "角色管理",
+ "file": "D:/hardman/bj_power/shots\\wms\\13_roles.png",
+ "size": 102225
+ },
+ {
+ "route": "/event-log",
+ "title": "操作日志",
+ "file": "D:/hardman/bj_power/shots\\wms\\14_eventlog.png",
+ "size": 167943
+ },
+ {
+ "route": "/change-password",
+ "title": "修改密码",
+ "file": "D:/hardman/bj_power/shots\\wms\\15_changepwd.png",
+ "size": 48261
+ },
+ {
+ "route": "/display",
+ "title": "库存动态大屏",
+ "file": "D:/hardman/bj_power/shots\\wms\\16_display.png",
+ "size": 381324
+ }
+ ],
+ "mes": [
+ {
+ "route": "/work-order",
+ "title": "工单管理",
+ "file": "D:/hardman/bj_power/shots\\mes\\01_workorder.png",
+ "size": 77574
+ },
+ {
+ "route": "/daily-plan",
+ "title": "日排产",
+ "file": "D:/hardman/bj_power/shots\\mes\\02_dailyplan.png",
+ "size": 81807
+ },
+ {
+ "route": "/bom",
+ "title": "物料清单",
+ "file": "D:/hardman/bj_power/shots\\mes\\03_bom.png",
+ "size": 51401
+ },
+ {
+ "route": "/material-request",
+ "title": "备料单",
+ "file": "D:/hardman/bj_power/shots\\mes\\04_materialrequest.png",
+ "size": 107215
+ },
+ {
+ "route": "/plc-send",
+ "title": "工位组合下发",
+ "file": "D:/hardman/bj_power/shots\\mes\\05_plcsend.png",
+ "size": 90775
+ },
+ {
+ "route": "/torque",
+ "title": "拧紧查询",
+ "file": "D:/hardman/bj_power/shots\\mes\\06_torque.png",
+ "size": 72585
+ },
+ {
+ "route": "/process-flow",
+ "title": "工艺流程",
+ "file": "D:/hardman/bj_power/shots\\mes\\07_processflow.png",
+ "size": 115779
+ },
+ {
+ "route": "/station",
+ "title": "关联工位",
+ "file": "D:/hardman/bj_power/shots\\mes\\08_station.png",
+ "size": 74163
+ },
+ {
+ "route": "/performance",
+ "title": "绩效报表",
+ "file": "D:/hardman/bj_power/shots\\mes\\09_performance.png",
+ "size": 73432
+ },
+ {
+ "route": "/inspect",
+ "title": "巡检终端",
+ "file": "D:/hardman/bj_power/shots\\mes\\10_inspect.png",
+ "size": 53589
+ },
+ {
+ "route": "/scan",
+ "title": "手动报工",
+ "file": "D:/hardman/bj_power/shots\\mes\\11_scan.png",
+ "size": 73318
+ },
+ {
+ "route": "/trace",
+ "title": "工件追溯",
+ "file": "D:/hardman/bj_power/shots\\mes\\12_trace.png",
+ "size": 44885
+ },
+ {
+ "route": "/product-type",
+ "title": "产品类型",
+ "file": "D:/hardman/bj_power/shots\\mes\\13_producttype.png",
+ "size": 54254
+ },
+ {
+ "route": "/account",
+ "title": "账号管理",
+ "file": "D:/hardman/bj_power/shots\\mes\\14_account.png",
+ "size": 77574
+ },
+ {
+ "route": "/role",
+ "title": "角色管理",
+ "file": "D:/hardman/bj_power/shots\\mes\\15_role.png",
+ "size": 77574
+ },
+ {
+ "route": "/event-log",
+ "title": "操作日志",
+ "file": "D:/hardman/bj_power/shots\\mes\\16_eventlog.png",
+ "size": 152030
+ },
+ {
+ "route": "/change-password",
+ "title": "修改密码",
+ "file": "D:/hardman/bj_power/shots\\mes\\17_changepwd.png",
+ "size": 53785
+ }
+ ],
+ "dash": [
+ {
+ "route": "/",
+ "title": "生产·仓储·产线三维总览",
+ "file": "D:/hardman/bj_power/shots\\dash\\01_dashboard.png",
+ "size": 283255
+ }
+ ],
+ "ws": [
+ {
+ "route": "/#/login",
+ "title": "工位终端登录",
+ "file": "D:/hardman/bj_power/shots\\ws\\01_login.png",
+ "size": 516742
+ },
+ {
+ "route": "/#/main",
+ "title": "工位终端主界面",
+ "file": "D:/hardman/bj_power/shots\\ws\\02_main.png",
+ "size": 117488
+ }
+ ]
+}
\ No newline at end of file
diff --git a/shots/mes/01_workorder.png b/shots/mes/01_workorder.png
new file mode 100644
index 0000000..7994bb3
Binary files /dev/null and b/shots/mes/01_workorder.png differ
diff --git a/shots/mes/02_dailyplan.png b/shots/mes/02_dailyplan.png
new file mode 100644
index 0000000..8e4a0bc
Binary files /dev/null and b/shots/mes/02_dailyplan.png differ
diff --git a/shots/mes/03_bom.png b/shots/mes/03_bom.png
new file mode 100644
index 0000000..8e8663f
Binary files /dev/null and b/shots/mes/03_bom.png differ
diff --git a/shots/mes/04_materialrequest.png b/shots/mes/04_materialrequest.png
new file mode 100644
index 0000000..a0dce01
Binary files /dev/null and b/shots/mes/04_materialrequest.png differ
diff --git a/shots/mes/05_plcsend.png b/shots/mes/05_plcsend.png
new file mode 100644
index 0000000..8e155a5
Binary files /dev/null and b/shots/mes/05_plcsend.png differ
diff --git a/shots/mes/06_torque.png b/shots/mes/06_torque.png
new file mode 100644
index 0000000..cdc56a9
Binary files /dev/null and b/shots/mes/06_torque.png differ
diff --git a/shots/mes/07_processflow.png b/shots/mes/07_processflow.png
new file mode 100644
index 0000000..8f35b19
Binary files /dev/null and b/shots/mes/07_processflow.png differ
diff --git a/shots/mes/08_station.png b/shots/mes/08_station.png
new file mode 100644
index 0000000..079cd95
Binary files /dev/null and b/shots/mes/08_station.png differ
diff --git a/shots/mes/09_performance.png b/shots/mes/09_performance.png
new file mode 100644
index 0000000..43712ba
Binary files /dev/null and b/shots/mes/09_performance.png differ
diff --git a/shots/mes/10_inspect.png b/shots/mes/10_inspect.png
new file mode 100644
index 0000000..1b3d265
Binary files /dev/null and b/shots/mes/10_inspect.png differ
diff --git a/shots/mes/11_scan.png b/shots/mes/11_scan.png
new file mode 100644
index 0000000..d4d8c03
Binary files /dev/null and b/shots/mes/11_scan.png differ
diff --git a/shots/mes/12_trace.png b/shots/mes/12_trace.png
new file mode 100644
index 0000000..f2958a3
Binary files /dev/null and b/shots/mes/12_trace.png differ
diff --git a/shots/mes/13_producttype.png b/shots/mes/13_producttype.png
new file mode 100644
index 0000000..c6f0675
Binary files /dev/null and b/shots/mes/13_producttype.png differ
diff --git a/shots/mes/14_account.png b/shots/mes/14_account.png
new file mode 100644
index 0000000..7994bb3
Binary files /dev/null and b/shots/mes/14_account.png differ
diff --git a/shots/mes/15_role.png b/shots/mes/15_role.png
new file mode 100644
index 0000000..7994bb3
Binary files /dev/null and b/shots/mes/15_role.png differ
diff --git a/shots/mes/16_eventlog.png b/shots/mes/16_eventlog.png
new file mode 100644
index 0000000..1a83e01
Binary files /dev/null and b/shots/mes/16_eventlog.png differ
diff --git a/shots/mes/17_changepwd.png b/shots/mes/17_changepwd.png
new file mode 100644
index 0000000..f1fc49b
Binary files /dev/null and b/shots/mes/17_changepwd.png differ
diff --git a/shots/wms/01_dashboard.png b/shots/wms/01_dashboard.png
new file mode 100644
index 0000000..41fb10a
Binary files /dev/null and b/shots/wms/01_dashboard.png differ
diff --git a/shots/wms/02_inbound.png b/shots/wms/02_inbound.png
new file mode 100644
index 0000000..abca09b
Binary files /dev/null and b/shots/wms/02_inbound.png differ
diff --git a/shots/wms/03_outbound.png b/shots/wms/03_outbound.png
new file mode 100644
index 0000000..61ac8b6
Binary files /dev/null and b/shots/wms/03_outbound.png differ
diff --git a/shots/wms/04_inventory.png b/shots/wms/04_inventory.png
new file mode 100644
index 0000000..dc94e1e
Binary files /dev/null and b/shots/wms/04_inventory.png differ
diff --git a/shots/wms/05_inspection.png b/shots/wms/05_inspection.png
new file mode 100644
index 0000000..bb89d31
Binary files /dev/null and b/shots/wms/05_inspection.png differ
diff --git a/shots/wms/06_stocktake.png b/shots/wms/06_stocktake.png
new file mode 100644
index 0000000..62789e5
Binary files /dev/null and b/shots/wms/06_stocktake.png differ
diff --git a/shots/wms/07_semi.png b/shots/wms/07_semi.png
new file mode 100644
index 0000000..9f36d4c
Binary files /dev/null and b/shots/wms/07_semi.png differ
diff --git a/shots/wms/08_ledger.png b/shots/wms/08_ledger.png
new file mode 100644
index 0000000..861cf6b
Binary files /dev/null and b/shots/wms/08_ledger.png differ
diff --git a/shots/wms/09_agv.png b/shots/wms/09_agv.png
new file mode 100644
index 0000000..90edb2e
Binary files /dev/null and b/shots/wms/09_agv.png differ
diff --git a/shots/wms/10_zone.png b/shots/wms/10_zone.png
new file mode 100644
index 0000000..446bcc5
Binary files /dev/null and b/shots/wms/10_zone.png differ
diff --git a/shots/wms/11_material.png b/shots/wms/11_material.png
new file mode 100644
index 0000000..b3c2ab9
Binary files /dev/null and b/shots/wms/11_material.png differ
diff --git a/shots/wms/12_users.png b/shots/wms/12_users.png
new file mode 100644
index 0000000..84be0e3
Binary files /dev/null and b/shots/wms/12_users.png differ
diff --git a/shots/wms/13_roles.png b/shots/wms/13_roles.png
new file mode 100644
index 0000000..fb7d939
Binary files /dev/null and b/shots/wms/13_roles.png differ
diff --git a/shots/wms/14_eventlog.png b/shots/wms/14_eventlog.png
new file mode 100644
index 0000000..b8de2eb
Binary files /dev/null and b/shots/wms/14_eventlog.png differ
diff --git a/shots/wms/15_changepwd.png b/shots/wms/15_changepwd.png
new file mode 100644
index 0000000..fc2aaac
Binary files /dev/null and b/shots/wms/15_changepwd.png differ
diff --git a/shots/wms/16_display.png b/shots/wms/16_display.png
new file mode 100644
index 0000000..fef9164
Binary files /dev/null and b/shots/wms/16_display.png differ
diff --git a/shots/wms_debug.png b/shots/wms_debug.png
new file mode 100644
index 0000000..c87e12a
Binary files /dev/null and b/shots/wms_debug.png differ
diff --git a/shots/ws/01_login.png b/shots/ws/01_login.png
new file mode 100644
index 0000000..c79eb47
Binary files /dev/null and b/shots/ws/01_login.png differ
diff --git a/shots/ws/02_main.png b/shots/ws/02_main.png
new file mode 100644
index 0000000..0a79162
Binary files /dev/null and b/shots/ws/02_main.png differ
diff --git a/shots_capture.py b/shots_capture.py
new file mode 100644
index 0000000..4098750
--- /dev/null
+++ b/shots_capture.py
@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+"""逐页截图 4 个前端系统,输出 PNG + manifest.json 供生成 PDF。"""
+import os, json, sys
+from playwright.sync_api import sync_playwright
+
+SHOTS = "D:/hardman/bj_power/shots"
+os.makedirs(SHOTS, exist_ok=True)
+
+WMS = "http://127.0.0.1:8891"
+MES = "http://127.0.0.1:8888"
+DASH = "http://127.0.0.1:5173"
+WS = "http://127.0.0.1:8892"
+
+W = 1920
+H = 1080
+
+# (route, 标题, 说明用的key) —— 标题用于 PDF
+WMS_PAGES = [
+ ("/", "工作台", "dashboard"),
+ ("/inbound", "入库管理", "inbound"),
+ ("/outbound", "出库管理", "outbound"),
+ ("/inventory", "库存查询", "inventory"),
+ ("/inspection", "质量检验", "inspection"),
+ ("/stocktake", "库存盘点", "stocktake"),
+ ("/semi", "半成品管理", "semi"),
+ ("/ledger", "工单备料台账", "ledger"),
+ ("/agv", "AGV配送", "agv"),
+ ("/zone", "区域维护", "zone"),
+ ("/material", "物料档案", "material"),
+ ("/users", "账号管理", "users"),
+ ("/roles", "角色管理", "roles"),
+ ("/event-log", "操作日志", "eventlog"),
+ ("/change-password", "修改密码", "changepwd"),
+ ("/display", "库存动态大屏", "display"),
+]
+
+MES_PAGES = [
+ ("/work-order", "工单管理", "workorder"),
+ ("/daily-plan", "日排产", "dailyplan"),
+ ("/bom", "物料清单", "bom"),
+ ("/material-request","备料单", "materialrequest"),
+ ("/plc-send", "工位组合下发", "plcsend"),
+ ("/torque", "拧紧查询", "torque"),
+ ("/process-flow", "工艺流程", "processflow"),
+ ("/station", "关联工位", "station"),
+ ("/performance", "绩效报表", "performance"),
+ ("/inspect", "巡检终端", "inspect"),
+ ("/scan", "手动报工", "scan"),
+ ("/trace", "工件追溯", "trace"),
+ ("/product-type", "产品类型", "producttype"),
+ ("/account", "账号管理", "account"),
+ ("/role", "角色管理", "role"),
+ ("/event-log", "操作日志", "eventlog"),
+ ("/change-password", "修改密码", "changepwd"),
+]
+
+manifest = {"wms": [], "mes": [], "dash": [], "ws": []}
+
+def log(msg):
+ sys.stdout.write(msg + "\n"); sys.stdout.flush()
+
+def do_login(page, base, user, pwd):
+ page.goto(base + "/login", wait_until="networkidle")
+ page.wait_for_timeout(600)
+ filled = False
+ for sel in ['input[placeholder*="用户名"]', 'input[placeholder*="账号"]',
+ 'input[name="username"]', 'input[type="text"]']:
+ try:
+ page.fill(sel, user, timeout=3000); filled = True; break
+ except Exception:
+ continue
+ if not filled:
+ raise RuntimeError("找不到用户名输入框")
+ page.fill('input[type="password"]', pwd, timeout=5000)
+ page.click('button.el-button--primary', timeout=5000)
+ page.wait_for_timeout(1500)
+ return "login" not in page.url
+
+def shot(page, url, path, wait=1500, full=False):
+ page.goto(url, wait_until="networkidle")
+ page.wait_for_timeout(wait)
+ page.screenshot(path=path, full_page=full)
+ return os.path.getsize(path)
+
+def main():
+ with sync_playwright() as p:
+ browser = p.chromium.launch(channel="msedge", args=["--no-sandbox", "--disable-gpu"])
+ ctx = browser.new_context(viewport={"width": W, "height": H}, device_scale_factor=1)
+ page = ctx.new_page()
+ page.set_default_timeout(15000)
+
+ # ---------- WMS 库房客户端 ----------
+ log("===== WMS 库房客户端 (8891) =====")
+ ok = do_login(page, WMS, "admin", "123456")
+ log(" login=%s" % ok)
+ for route, title, key in WMS_PAGES:
+ d = "wms" if route != "/display" else "wms"
+ out = os.path.join(SHOTS, d, "%02d_%s.png" % (len(manifest["wms"])+1, key))
+ try:
+ if route == "/display":
+ # 免登录大屏,单独开上下文避免 token 干扰
+ page2 = ctx.new_page()
+ sz = shot(page2, WMS + "/display", out, wait=3500)
+ page2.close()
+ else:
+ sz = shot(page, WMS + route, out, wait=1600)
+ manifest["wms"].append({"route": route, "title": title, "file": out, "size": sz})
+ log(" [OK ] %-14s %s (%dKB)" % (title, route, sz//1024))
+ except Exception as e:
+ log(" [ERR] %-14s %s -> %s" % (title, route, e))
+
+ # ---------- MES 产线控制 ----------
+ log("===== MES 产线控制 (8888) =====")
+ ok = do_login(page, MES, "admin", "123456")
+ log(" login=%s" % ok)
+ for route, title, key in MES_PAGES:
+ out = os.path.join(SHOTS, "mes", "%02d_%s.png" % (len(manifest["mes"])+1, key))
+ try:
+ sz = shot(page, MES + route, out, wait=1600)
+ manifest["mes"].append({"route": route, "title": title, "file": out, "size": sz})
+ log(" [OK ] %-14s %s (%dKB)" % (title, route, sz//1024))
+ except Exception as e:
+ log(" [ERR] %-14s %s -> %s" % (title, route, e))
+
+ # ---------- 大屏 (5173) ----------
+ log("===== 数据大屏 (5173) =====")
+ # 整页(含生产/仓储/3D产线三板)
+ dctx = browser.new_context(viewport={"width": W, "height": 1440}, device_scale_factor=1)
+ dpage = dctx.new_page()
+ try:
+ dpage.goto(DASH + "/", wait_until="load")
+ # 等数据加载 + 3D 渲染(大屏用轮询/SSE,networkidle 不可达)
+ dpage.wait_for_timeout(7000)
+ out = os.path.join(SHOTS, "dash", "01_dashboard.png")
+ dpage.screenshot(path=out, full_page=True)
+ manifest["dash"].append({"route": "/", "title": "生产·仓储·产线三维总览", "file": out, "size": os.path.getsize(out)})
+ log(" [OK ] 大屏总览 (%dKB)" % (os.path.getsize(out)//1024))
+ except Exception as e:
+ log(" [ERR] 大屏 -> %s" % e)
+ dpage.close(); dctx.close()
+
+ # ---------- 工位终端 (8892) ----------
+ log("===== 工位终端 (8892) =====")
+ wctx = browser.new_context(viewport={"width": W, "height": H}, device_scale_factor=1)
+ wpage = wctx.new_page()
+ try:
+ wpage.goto(WS + "/#/login", wait_until="networkidle")
+ wpage.wait_for_timeout(1200)
+ out = os.path.join(SHOTS, "ws", "01_login.png")
+ wpage.screenshot(path=out)
+ manifest["ws"].append({"route": "/#/login", "title": "工位终端登录", "file": out, "size": os.path.getsize(out)})
+ log(" [OK ] 工位登录页")
+ # 登录
+ wpage.fill('input[placeholder*="账号"]', "syf", timeout=5000)
+ wpage.fill('input[type="password"]', "123456", timeout=5000)
+ wpage.click('button.el-button--primary', timeout=5000)
+ wpage.wait_for_url("**/#/main", timeout=8000)
+ wpage.wait_for_timeout(2500)
+ out = os.path.join(SHOTS, "ws", "02_main.png")
+ wpage.screenshot(path=out)
+ manifest["ws"].append({"route": "/#/main", "title": "工位终端主界面", "file": out, "size": os.path.getsize(out)})
+ log(" [OK ] 工位主界面")
+ except Exception as e:
+ log(" [ERR] 工位终端 -> %s" % e)
+ wpage.close(); wctx.close()
+
+ browser.close()
+
+ with open(os.path.join(SHOTS, "manifest.json"), "w", encoding="utf-8") as f:
+ json.dump(manifest, f, ensure_ascii=False, indent=2)
+ log("===== DONE =====")
+ log("WMS=%d MES=%d DASH=%d WS=%d" % (
+ len(manifest["wms"]), len(manifest["mes"]), len(manifest["dash"]), len(manifest["ws"])))
+
+if __name__ == "__main__":
+ main()
diff --git a/系统页面截图说明书.pdf b/系统页面截图说明书.pdf
new file mode 100644
index 0000000..7f565c4
Binary files /dev/null and b/系统页面截图说明书.pdf differ