Files
huachuang/packingAreaDashboard.html

965 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>装箱区信息看板</title>
<style>
:root {
color-scheme: dark;
--bg: #0d0d0d;
--panel: rgba(255, 255, 255, .03);
--panel-2: rgba(255, 255, 255, .025);
--line: rgba(255, 255, 255, .07);
--cyan: #31d7ed;
--blue: #2a91ff;
--green: #42df91;
--yellow: #ffd35a;
--red: #ff6574;
--purple: #b28cff;
--text: #eef8ff;
--muted: #898781;
--agv: rgba(242, 250, 255, .92);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body {
color: var(--text);
background: #0d0d0d;
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
button, input { font: inherit; }
#stage {
position: relative;
width: 1920px;
height: 1080px;
flex: 0 0 auto;
overflow: hidden;
padding: 20px 24px 16px;
transform-origin: center center;
background:
radial-gradient(1200px 500px at 50% -10%, rgba(25, 110, 161, .28), transparent 60%),
linear-gradient(180deg, #0a1424 0%, #0d1424 45%, #0d1220 100%);
display: flex;
flex-direction: column;
gap: 12px;
}
#stage::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(90, 140, 190, .05) 1px, transparent 1px),
linear-gradient(90deg, rgba(90, 140, 190, .05) 1px, transparent 1px);
background-size: 48px 48px;
}
#stage::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 100px rgba(0, 0, 0, .28);
}
#stage > * { position: relative; z-index: 1; }
.header {
height: 58px;
display: grid;
grid-template-columns: 350px 1fr 350px;
align-items: center;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #10aeff, #007aff);
box-shadow: none;
}
.brand-mark::before, .brand-mark::after {
content: "箱";
position: absolute;
inset: 0;
display: grid;
place-items: center;
border: 0;
color: #fff;
font-size: 18px;
font-weight: 800;
}
.brand-mark::after { display: none; }
.brand-cn { font-size: 17px; font-weight: 700; letter-spacing: 1px; }
.brand-en { margin-top: 2px; color: #898781; font-size: 10px; letter-spacing: 2.4px; }
h1 {
margin: 0;
text-align: center;
font-size: 30px;
font-weight: 700;
letter-spacing: 8px;
background: linear-gradient(180deg, #ffffff, #9ed0ff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: none;
}
.clock { text-align: right; font-variant-numeric: tabular-nums; }
#clockTime { font-size: 25px; font-weight: 750; letter-spacing: 2px; }
#clockDate { margin-top: 2px; color: var(--muted); font-size: 12px; letter-spacing: 1px; }
.kpis { height: 70px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
.kpi, .toolbar, .map-panel, .side-panel {
border: 1px solid rgba(255, 255, 255, .07);
background: rgba(255, 255, 255, .03);
box-shadow: none;
}
.kpi {
position: relative;
overflow: hidden;
padding: 10px 18px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 14px;
}
.kpi::before { display: none; }
.kpi-icon {
width: 10px;
height: 42px;
display: block;
flex: none;
overflow: hidden;
border: 0;
border-radius: 5px;
color: transparent;
background: var(--accent);
font-size: 0;
box-shadow: none;
}
.kpi-value { font-size: 25px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
.kpi-value small { margin-left: 3px; color: var(--muted); font-size: 12px; font-weight: 400; }
.kpi-label { margin-top: 5px; color: #9eb8ca; font-size: 12px; }
.toolbar {
min-height: 55px;
padding: 8px 12px;
border-radius: 9px;
display: flex;
align-items: center;
gap: 18px;
}
.legend { display: flex; align-items: center; gap: 12px; flex-wrap: nowrap; }
.legend-title { color: #b7d6e9; font-size: 12px; font-weight: 700; }
.legend-item { display: inline-flex; align-items: center; gap: 5px; color: #aac2d4; font-size: 11px; white-space: nowrap; }
.legend-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); }
.legend-dot.alert { box-shadow: 0 0 8px var(--c); }
.toolbar-spacer { flex: 1; }
.control { display: flex; align-items: center; gap: 7px; color: #9db8cb; font-size: 12px; }
.control input {
height: 31px;
padding: 0 9px;
border: 1px solid #315a79;
border-radius: 5px;
outline: none;
color: #eaf9ff;
background: rgba(3, 14, 27, .72);
}
.control input:focus { border-color: var(--cyan); }
#searchInput { width: 190px; }
#thresholdInput, #zoomInput { width: 62px; text-align: center; }
.btn {
height: 31px;
padding: 0 13px;
border: 1px solid #287ca8;
border-radius: 5px;
color: #def8ff;
background: linear-gradient(180deg, #11618a, #0a4669);
cursor: pointer;
transition: .18s ease;
}
.btn:hover { border-color: #50dcf2; filter: brightness(1.13); }
.btn.secondary { border-color: #365b78; background: #102c45; }
#searchMessage { min-width: 105px; font-size: 11px; }
#searchMessage.ok { color: #55e89a; }
#searchMessage.error { color: #ff7c89; }
.content { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 342px; gap: 12px; }
.map-panel { min-width: 0; padding: 10px; border-radius: 10px; display: flex; flex-direction: column; }
.panel-head { height: 28px; display: flex; align-items: center; justify-content: space-between; padding: 0 4px 8px; }
.panel-title { color: #bcecff; font-size: 14px; font-weight: 700; letter-spacing: 1px; }
.panel-hint { color: #668aa3; font-size: 10px; }
.map-viewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
cursor: grab;
touch-action: none;
border: 1px solid rgba(255, 255, 255, .06);
border-radius: 10px;
background:
linear-gradient(rgba(90, 140, 190, .04) 1px, transparent 1px),
linear-gradient(90deg, rgba(90, 140, 190, .04) 1px, transparent 1px),
rgba(4, 13, 25, .68);
background-size: 36px 36px;
}
.map-viewport.dragging { cursor: grabbing; }
.map-canvas {
position: absolute;
left: 0;
top: 0;
width: 1450px;
height: 1590px;
transform-origin: top left;
will-change: transform;
user-select: none;
}
.map-canvas::before {
content: "装箱车间 / PACKING WORKSHOP";
position: absolute;
left: 18px;
top: 415px;
color: rgba(90, 206, 238, .3);
font-size: 10px;
letter-spacing: 3px;
writing-mode: vertical-rl;
}
.north-arrow {
position: absolute;
z-index: 8;
right: 18px;
top: 16px;
width: 58px;
height: 72px;
display: flex;
flex-direction: column;
align-items: center;
color: #c8f6ff;
font-size: 10px;
letter-spacing: 1px;
text-shadow: none;
}
.north-arrow b { font-size: 32px; line-height: 32px; color: #ecfdff; }
.floor-zone {
position: absolute;
left: 65px;
width: 1320px;
padding: 14px 12px 10px;
border: 2px solid var(--zone-color);
border-radius: 8px;
background: color-mix(in srgb, var(--zone-color) 4%, rgba(3, 13, 25, .7));
box-shadow: inset 0 0 16px rgba(24, 118, 174, .06);
}
.floor-zone::before, .floor-zone::after {
content: "";
position: absolute;
width: 13px;
height: 13px;
border: 2px solid var(--zone-color);
background: #06111f;
}
.floor-zone::before { left: -8px; top: -8px; border-right: 0; border-bottom: 0; }
.floor-zone::after { right: -8px; bottom: -8px; border-left: 0; border-top: 0; }
.zone-label {
position: absolute;
z-index: 5;
left: 14px;
top: -13px;
height: 24px;
padding: 3px 12px;
border: 1px solid var(--zone-color);
border-radius: 12px;
color: #effcff;
background: color-mix(in srgb, var(--zone-color) 38%, #071526);
box-shadow: 0 3px 10px rgba(0, 0, 0, .4);
font-size: 11px;
font-weight: 800;
letter-spacing: 1px;
}
.zone-count { margin-left: 7px; color: rgba(255, 255, 255, .68); font-weight: 400; }
.zone-north { top: 28px; height: 182px; --zone-color: #2ca9c2; }
.zone-center { top: 265px; height: 950px; --zone-color: #3578b7; }
.zone-south { top: 1270px; height: 290px; --zone-color: #367e9d; }
.slot-grid {
height: 100%;
display: grid;
grid-template-columns: 150px 150px 54px 150px 150px 54px 150px 150px;
gap: 8px;
justify-content: center;
}
.zone-center .slot-grid { grid-template-rows: repeat(7, 124px); row-gap: 8px; }
.zone-north .slot-grid { grid-template-columns: repeat(3, 150px) 54px repeat(3, 150px); grid-template-rows: 150px; }
.agv-column {
position: relative;
grid-row: 1 / -1;
border-left: 2px solid var(--agv);
border-right: 2px solid var(--agv);
background: repeating-linear-gradient(180deg, transparent 0 14px, rgba(236, 250, 255, .08) 14px 28px);
box-shadow: inset 0 0 8px rgba(238, 252, 255, .05);
}
.agv-column::after {
content: "↑ AGV ↓";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: rgba(235, 250, 255, .75);
font-size: 9px;
letter-spacing: 4px;
writing-mode: vertical-rl;
}
.agv-main {
position: absolute;
left: 65px;
width: 1320px;
height: 30px;
border-top: 2px solid var(--agv);
border-bottom: 2px solid var(--agv);
background: repeating-linear-gradient(90deg, transparent 0 22px, rgba(236, 250, 255, .07) 22px 44px);
box-shadow: none;
color: rgba(238, 251, 255, .72);
font-size: 9px;
line-height: 26px;
text-align: center;
letter-spacing: 14px;
}
.agv-main.one { top: 220px; }
.agv-main.two { top: 1225px; }
.south-grid {
height: 100%;
display: grid;
grid-template-columns: repeat(8, 125px) 54px 158px;
grid-template-rows: repeat(2, 124px);
gap: 10px 8px;
justify-content: center;
}
.south-grid .agv-column { grid-column: 9; grid-row: 1 / 3; }
.slot {
position: relative;
min-width: 0;
overflow: hidden;
padding: 8px 9px 7px;
border: 1px solid var(--slot-border);
border-radius: 5px;
color: #eaf7ff;
background: linear-gradient(150deg, var(--slot-top), var(--slot-bottom));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .06), 0 3px 7px rgba(0, 0, 0, .25);
cursor: pointer;
transition: transform .16s ease, filter .16s ease, box-shadow .16s ease;
}
.slot::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: var(--state-color);
box-shadow: none;
}
.slot:hover { z-index: 5; transform: translateY(-2px); filter: brightness(1.12); }
.slot.selected {
z-index: 6;
border-color: #fff;
outline: 2px solid rgba(221, 246, 255, .8);
outline-offset: 1px;
}
.slot.search-hit { animation: search-pulse .7s ease-in-out infinite alternate; }
@keyframes search-pulse {
from { outline-color: rgba(178, 225, 245, .65); }
to { outline-color: #ffffff; filter: brightness(1.12); }
}
.slot-head { min-height: 16px; padding-right: 14px; display: flex; align-items: center; }
.slot-code { overflow: hidden; color: #fff; font-size: 12px; font-weight: 800; letter-spacing: .3px; text-overflow: ellipsis; white-space: nowrap; }
.slot-state-dot {
position: absolute;
right: 8px;
top: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--state-color);
box-shadow: none;
}
.slot-name {
height: 17px;
margin-top: 3px;
padding: 2px 5px;
overflow: hidden;
border-radius: 3px;
color: #b8d8ed;
background: rgba(0, 0, 0, .2);
font-size: 8px;
line-height: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-box-panel {
min-height: 28px;
margin-top: 4px;
padding: 3px 5px;
overflow: hidden;
border: 1px solid rgba(142, 179, 205, .28);
border-radius: 4px;
background: rgba(0, 0, 0, .22);
}
.slot-box-panel.has-box { border-color: rgba(70, 151, 190, .7); background: rgba(20, 68, 94, .45); }
.slot-box-label { display: block; color: #8faec4; font-size: 7px; line-height: 9px; }
.slot-box-panel.has-box .slot-box-label { color: #87c9e5; }
.slot-box-value { display: block; margin-top: 1px; overflow: hidden; color: #dbeaf4; font-size: 9px; font-weight: 700; line-height: 11px; text-overflow: ellipsis; white-space: nowrap; }
.slot-box-panel.has-box .slot-box-value { color: #dcf4ff; }
.slot-info-row { min-height: 15px; display: flex; align-items: center; justify-content: space-between; gap: 4px; color: #91adc1; font-size: 8px; }
.slot-info-label { flex: none; }
.slot-info-value { min-width: 0; overflow: hidden; color: #f1f8fc; font-weight: 700; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.slot-info-value.roll-no { color: #b9dff7; }
.slot-stock {
flex: none;
padding: 1px 5px;
border: 1px solid var(--state-color);
border-radius: 8px;
color: var(--state-color);
background: color-mix(in srgb, var(--state-color) 10%, transparent);
font-size: 7px;
font-weight: 700;
line-height: 11px;
white-space: nowrap;
}
.slot-stock::before { content: ""; display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%; background: currentColor; vertical-align: 0; }
.slot.empty { --slot-top: rgba(23, 54, 79, .63); --slot-bottom: rgba(8, 28, 46, .62); --slot-border: #345974; opacity: .78; border-style: dashed; }
.slot.empty .slot-info-value.roll-no { color: #b9dff7; }
.slot.carrier { --slot-top: #153c51; --slot-bottom: #0b2636; --slot-border: #356b85; }
.slot.roll { --slot-top: #183d63; --slot-bottom: #0c2844; --slot-border: #426f9b; }
.slot.qualified { --slot-top: #16444d; --slot-bottom: #0b2b34; --slot-border: #3c747d; }
.slot.controlled { --slot-top: #762536; --slot-bottom: #3b111c; --slot-border: #f05a70; box-shadow: 0 0 12px rgba(255, 83, 100, .45); }
.slot.overdue { --slot-top: #8a2935; --slot-bottom: #430f18; --slot-border: #ff6574; animation: overdue-pulse 1.8s ease-in-out infinite; }
.slot.controlled::after, .slot.overdue::after { box-shadow: 0 0 8px #ff6574; }
.slot.controlled .slot-state-dot, .slot.overdue .slot-state-dot { box-shadow: 0 0 7px #ff6574; }
.slot.controlled .slot-stock::before, .slot.overdue .slot-stock::before { box-shadow: 0 0 4px #ff6574; }
@keyframes overdue-pulse { 50% { filter: brightness(1.18); box-shadow: 0 0 13px rgba(255, 83, 100, .55); } }
.dock-slot { padding: 8px 9px; border-width: 2px; }
.dock-slot .slot-code { color: #fff2c2; font-size: 11px; }
.side-panel { min-height: 0; padding: 12px; border-radius: 10px; display: flex; flex-direction: column; gap: 10px; }
.side-title { display: flex; align-items: center; justify-content: space-between; color: #c0ecff; font-size: 14px; font-weight: 750; }
.demo-tag { padding: 3px 8px; border: 1px solid #3f6f95; border-radius: 10px; color: #9fc9e5; background: rgba(45, 101, 142, .14); font-size: 9px; letter-spacing: 1px; }
.detail-card {
padding: 12px;
border: 1px solid rgba(74, 150, 204, .35);
border-radius: 8px;
background: rgba(5, 20, 36, .72);
}
.detail-code { color: #fff; font-size: 18px; font-weight: 800; letter-spacing: 1px; }
.detail-name { margin-top: 3px; color: #75b9df; font-size: 11px; }
.detail-status { margin: 10px 0 5px; display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--detail-color); border-radius: 11px; color: var(--detail-color); font-size: 10px; }
.detail-status i { width: 7px; height: 7px; border-radius: 50%; background: var(--detail-color); }
.detail-card.alert .detail-status i { box-shadow: 0 0 7px #ff6574; }
.detail-rows { margin-top: 7px; border-top: 1px solid rgba(87, 143, 182, .2); }
.detail-row { min-height: 31px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(87, 143, 182, .13); font-size: 11px; }
.detail-row span:first-child { flex: none; color: #779ab3; }
.detail-row b { min-width: 0; color: #e9f7ff; text-align: right; overflow-wrap: anywhere; }
.detail-row b.em { color: #ffe594; }
.queue-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
.queue-group { flex: 1; min-height: 0; padding: 7px 8px 5px; border: 1px solid rgba(67, 131, 172, .28); border-radius: 7px; background: rgba(5, 20, 36, .5); display: flex; flex-direction: column; }
.queue-group.dock-02 { border-color: rgba(54, 126, 157, .34); }
.queue-head { margin: 0 0 6px; display: flex; justify-content: space-between; color: #91cce9; font-size: 11px; }
.queue-head b { color: #d9f2ff; font-weight: 750; }
.queue-head small { margin-left: 5px; color: #638ba5; font-size: 8px; font-weight: 400; }
.queue-list { flex: 1; min-height: 0; margin: 0; padding: 0; overflow: auto; list-style: none; }
.queue-list::-webkit-scrollbar { width: 5px; }
.queue-list::-webkit-scrollbar-thumb { border-radius: 3px; background: #27516d; }
.queue-item {
margin-bottom: 6px;
padding: 7px 8px;
border: 1px solid rgba(83, 137, 175, .25);
border-radius: 6px;
background: rgba(8, 27, 46, .72);
display: grid;
grid-template-columns: 25px 1fr auto;
align-items: center;
gap: 7px;
}
.queue-no { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 5px; color: #77d7eb; background: rgba(35, 213, 232, .11); font-size: 9px; font-weight: 800; }
.queue-box { color: #f3fbff; font-size: 10px; font-weight: 700; }
.queue-point { margin-top: 2px; color: #6f96ae; font-size: 9px; }
.queue-time { color: #ffdb73; font-size: 9px; }
.zone-mini { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.zone-mini-item { padding: 7px 5px; border: 1px solid rgba(74, 137, 179, .22); border-radius: 6px; background: rgba(6, 24, 41, .62); text-align: center; }
.zone-mini-item b { display: block; color: #ecf9ff; font-size: 15px; }
.zone-mini-item span { color: #7197af; font-size: 8px; }
.context-menu {
position: fixed;
z-index: 999;
display: none;
width: 162px;
padding: 6px;
border: 1px solid #3f7da3;
border-radius: 7px;
background: #081a2b;
box-shadow: 0 14px 34px rgba(0, 0, 0, .55);
}
.context-menu.open { display: block; }
.context-title { padding: 5px 8px 7px; border-bottom: 1px solid #24445d; color: #8bd8ef; font-size: 10px; }
.context-action { width: 100%; margin-top: 4px; padding: 7px 8px; border: 0; border-radius: 4px; color: #eaf7ff; background: transparent; text-align: left; cursor: pointer; font-size: 11px; }
.context-action:hover:not(:disabled) { background: #104463; }
.context-action:disabled { color: #526779; cursor: not-allowed; }
.toast { position: fixed; z-index: 1000; left: 50%; bottom: 28px; display: none; padding: 8px 14px; transform: translateX(-50%); border: 1px solid #3fba7e; border-radius: 6px; color: #dcffeb; background: #0f5b38; box-shadow: 0 6px 20px rgba(0, 0, 0, .45); font-size: 11px; }
</style>
</head>
<body>
<main id="stage">
<header class="header">
<div class="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div><div class="brand-cn">智能制造物流中心</div><div class="brand-en">SMART LOGISTICS CENTER</div></div>
</div>
<h1>装箱区信息看板</h1>
<div class="clock"><div id="clockTime">--:--:--</div><div id="clockDate">----</div></div>
</header>
<section class="kpis" aria-label="汇总指标">
<article class="kpi" style="--accent:#37b8d1"><div class="kpi-icon">Σ</div><div><div id="kpiTotal" class="kpi-value">66<small></small></div><div class="kpi-label">规划点位总数</div></div></article>
<article class="kpi" style="--accent:#3f91c6"><div class="kpi-icon">IN</div><div><div id="kpiOccupied" class="kpi-value">0<small></small></div><div class="kpi-label">当前占用点位</div></div></article>
<article class="kpi" style="--accent:#668ba8"><div class="kpi-icon">E</div><div><div id="kpiEmpty" class="kpi-value">0<small></small></div><div class="kpi-label">空闲点位</div></div></article>
<article class="kpi" style="--accent:#ff6574"><div class="kpi-icon">!</div><div><div id="kpiOverdue" class="kpi-value">0<small></small></div><div class="kpi-label">超时滞留点位</div></div></article>
<article class="kpi" style="--accent:#4e7fb8"><div class="kpi-icon">%</div><div><div id="kpiRate" class="kpi-value">0<small>%</small></div><div class="kpi-label">综合点位占用率</div></div></article>
</section>
<section class="toolbar">
<div class="legend"><span class="legend-title">点位状态</span><span class="legend-item"><i class="legend-dot" style="--c:#71869a"></i>空闲</span><span class="legend-item"><i class="legend-dot" style="--c:#57a8c8"></i>空载具</span><span class="legend-item"><i class="legend-dot" style="--c:#5d92c9"></i>有子卷</span><span class="legend-item"><i class="legend-dot" style="--c:#4eb2b0"></i>合格品</span><span class="legend-item"><i class="legend-dot alert" style="--c:#ff6574"></i>管制/超时</span><span class="legend-item"><i class="legend-dot" style="--c:#f2faff;border-radius:1px"></i>AGV 通道</span></div>
<div class="toolbar-spacer"></div>
<div class="control"><label for="searchInput">卷号</label><input id="searchInput" type="text" placeholder="输入卷号或点位编码"><button id="searchBtn" class="btn" type="button">定位</button><span id="searchMessage"></span></div>
<div class="control"><label for="thresholdInput">超时</label><input id="thresholdInput" type="number" min="1" max="72" step="1"><span>h</span></div>
<div class="control"><label for="zoomInput">比例</label><input id="zoomInput" type="number" min="55" max="300" step="5"><span>%</span><button id="resetBtn" class="btn secondary" type="button">复位</button></div>
</section>
<section class="content">
<section class="map-panel">
<div class="panel-head"><span class="panel-title">装箱区域平面布置图</span><span class="panel-hint">滚轮缩放 · 按住左键拖动 · 双击恢复居中</span></div>
<div id="mapViewport" class="map-viewport">
<div id="mapCanvas" class="map-canvas">
<div class="north-arrow"><b></b><span>北 N</span></div>
<div class="floor-zone zone-north"><div class="zone-label">北区 · 装箱信息暂存 <span class="zone-count">6 点位</span></div><div id="northGrid" class="slot-grid"></div></div>
<div class="agv-main one">← AGV 主通道 →</div>
<div class="floor-zone zone-center"><div class="zone-label">中区 · 待检区 <span class="zone-count">7 排 × 6 列 / 42 点位</span></div><div id="centerGrid" class="slot-grid"></div></div>
<div class="agv-main two">← AGV 主通道 →</div>
<div class="floor-zone zone-south"><div class="zone-label">南区 · 装箱作业区 <span class="zone-count">16 点位 + 2 等待位</span></div><div id="southGrid" class="south-grid"></div></div>
</div>
</div>
</section>
<aside class="side-panel">
<div class="side-title"><span>点位实时信息</span><span class="demo-tag">DEMO DATA</span></div>
<section id="detailCard" class="detail-card" aria-live="polite"></section>
<div id="zoneMini" class="zone-mini"></div>
<div class="queue-wrap">
<section class="queue-group dock-01"><div class="queue-head"><span><b>ZXDJW_01</b><small>装箱区 1 队列</small></span><span id="queueCount01">0 项</span></div><ol id="queueList01" class="queue-list"></ol></section>
<section class="queue-group dock-02"><div class="queue-head"><span><b>ZXDJW_02</b><small>装箱区 2 队列</small></span><span id="queueCount02">0 项</span></div><ol id="queueList02" class="queue-list"></ol></section>
</div>
</aside>
</section>
</main>
<div id="contextMenu" class="context-menu"><div id="contextTitle" class="context-title">点位</div><button id="copyRollBtn" class="context-action" type="button">复制卷号</button><button id="copyBoxBtn" class="context-action" type="button">复制箱号</button></div>
<div id="toast" class="toast">复制成功</div>
<script>
(() => {
'use strict';
const STATUS = {
0: { text: '空闲', color: '#71869a', className: 'empty' },
1: { text: '空载具', color: '#57a8c8', className: 'carrier' },
2: { text: '有子卷', color: '#5d92c9', className: 'roll' },
3: { text: '合格品', color: '#4eb2b0', className: 'qualified' },
4: { text: '管制品', color: '#ff6574', className: 'controlled' }
};
const THRESHOLD_KEY = 'packingDashboardTechThreshold';
const ZOOM_KEY = 'packingDashboardTechZoom';
const COLUMN_MAP = [1, 2, 4, 5, 7, 8];
const NORTH_COLUMN_MAP = [1, 2, 3, 5, 6, 7];
const pad = value => String(value).padStart(2, '0');
const rangeCodes = (prefix, start, end) => Array.from({ length: end - start + 1 }, (_, index) => `${prefix}_${pad(start + index)}`);
const northCodes = rangeCodes('ZJXX', 1, 6);
const centerCodes = rangeCodes('DJQ', 1, 42);
const southCodes = rangeCodes('ZXQ', 1, 16);
const dockCodes = rangeCodes('ZXDJW', 1, 2);
const allCodes = [...northCodes, ...centerCodes, ...southCodes, ...dockCodes];
const zoneOf = code => code.startsWith('ZJXX') ? 'north' : code.startsWith('DJQ') ? 'center' : code.startsWith('ZXDJW') ? 'dock' : 'south';
const pointNameOf = code => code.startsWith('ZJXX') ? '装箱信息暂存位' : code.startsWith('DJQ') ? '待检缓存位' : code.startsWith('ZXDJW') ? '装箱等待位' : Number(code.slice(-2)) <= 8 ? '装箱区 1 作业位' : '装箱区 2 作业位';
const statusPattern = [2, 3, 0, 1, 2, 4, 3, 0, 2, 1, 3, 2];
function createMockPoint(code, index) {
const zone = zoneOf(code);
let status = statusPattern[(index * 5 + code.length) % statusPattern.length];
if (zone === 'dock') status = index % 2 ? 1 : 3;
const occupied = status >= 2;
const durationHours = occupied ? ((index * 7) % 19) + .5 : status === 1 ? ((index * 3) % 5) + .2 : 0;
const entryTime = new Date(Date.now() - durationHours * 3600000);
const rollNo = occupied ? `R260814${String(index + 1).padStart(4, '0')}` : '';
const hasBox = occupied && (zone === 'south' || zone === 'dock' || index % 3 === 0);
const boxNo = hasBox ? `BX2608${String((index % 28) + 1).padStart(3, '0')}` : '';
return {
code,
zone,
name: pointNameOf(code),
status,
rollNo,
boxNo,
width: occupied ? [980, 1120, 1250, 1380][index % 4] : null,
diameter: occupied ? [760, 820, 900][index % 3] : null,
durationHours,
entryTime
};
}
const points = allCodes.map(createMockPoint);
const pointMap = new Map(points.map(point => [point.code, point]));
let thresholdHours = Math.max(1, Number(localStorage.getItem(THRESHOLD_KEY)) || 12);
let fitScale = 1;
let mapScale = 1;
let panX = 0;
let panY = 0;
let selectedCode = '';
let contextPoint = null;
const $ = id => document.getElementById(id);
const formatDateTime = date => {
const d = new Date(date);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const durationText = hours => hours ? `${Math.floor(hours)}h ${Math.round((hours % 1) * 60)}m` : '--';
const isOverdue = point => point.status >= 2 && point.durationHours >= thresholdHours;
function slotElement(point, options = {}) {
const meta = STATUS[point.status];
const overdue = isOverdue(point);
const element = document.createElement('article');
element.className = `slot ${meta.className}${overdue ? ' overdue' : ''}${options.dock ? ' dock-slot' : ''}`;
element.style.setProperty('--state-color', overdue ? '#ff6574' : meta.color);
element.dataset.code = point.code;
element.dataset.roll = point.rollNo;
element.dataset.box = point.boxNo;
element.title = `${point.code}${point.name}\n库存状态:${overdue ? '超时滞留' : meta.text}\n卷号:${point.rollNo || '空闲'}\n箱号:${point.boxNo || '未组盘'}\n停留:${durationText(point.durationHours)}`;
const statusText = overdue ? '超时滞留' : meta.text;
const specification = point.width ? `${point.width} × ${point.diameter}` : '- × -';
element.innerHTML = `<i class="slot-state-dot" aria-hidden="true"></i>
<div class="slot-head"><span class="slot-code">${point.code}</span></div>
<div class="slot-name" title="${point.name}">${point.name}</div>
<div class="slot-box-panel ${point.boxNo ? 'has-box' : ''}"><span class="slot-box-label">组盘木箱</span><span class="slot-box-value">${point.boxNo || '未组盘'}</span></div>
<div class="slot-info-row"><span class="slot-info-label">库存状态</span><span class="slot-stock">${statusText}</span></div>
<div class="slot-info-row"><span class="slot-info-label">卷号</span><span class="slot-info-value roll-no" title="${point.rollNo}">${point.rollNo || '空闲'}</span></div>
<div class="slot-info-row"><span class="slot-info-label">规格</span><span class="slot-info-value">${specification}</span></div>`;
element.addEventListener('click', event => {
event.stopPropagation();
selectPoint(point.code);
});
element.addEventListener('contextmenu', event => showContextMenu(event, point));
return element;
}
function addAisles(grid, rowCount, columns = [3, 6]) {
columns.forEach(column => {
const aisle = document.createElement('div');
aisle.className = 'agv-column';
aisle.style.gridColumn = column;
aisle.style.gridRow = `1 / ${rowCount + 1}`;
aisle.setAttribute('aria-label', 'AGV 通道');
grid.appendChild(aisle);
});
}
function renderMap() {
const northGrid = $('northGrid');
const centerGrid = $('centerGrid');
const southGrid = $('southGrid');
northGrid.innerHTML = '';
centerGrid.innerHTML = '';
southGrid.innerHTML = '';
addAisles(northGrid, 1, [4]);
addAisles(centerGrid, 7);
northCodes.forEach((code, index) => {
const slot = slotElement(pointMap.get(code));
slot.style.gridColumn = NORTH_COLUMN_MAP[index];
slot.style.gridRow = 1;
northGrid.appendChild(slot);
});
centerCodes.forEach((code, index) => {
const slot = slotElement(pointMap.get(code));
slot.style.gridColumn = COLUMN_MAP[index % 6];
slot.style.gridRow = Math.floor(index / 6) + 1;
centerGrid.appendChild(slot);
});
const southAisle = document.createElement('div');
southAisle.className = 'agv-column';
southAisle.setAttribute('aria-label', 'AGV 通道');
southGrid.appendChild(southAisle);
southCodes.forEach((code, index) => {
const slot = slotElement(pointMap.get(code));
slot.style.gridColumn = (index % 8) + 1;
slot.style.gridRow = Math.floor(index / 8) + 1;
southGrid.appendChild(slot);
});
dockCodes.forEach((code, index) => {
const slot = slotElement(pointMap.get(code), { dock: true });
slot.style.gridColumn = 10;
slot.style.gridRow = index + 1;
southGrid.appendChild(slot);
});
if (selectedCode) selectPoint(selectedCode);
updateDashboard();
}
function updateDashboard() {
const occupied = points.filter(point => point.status >= 2);
const overdue = occupied.filter(isOverdue);
$('kpiTotal').innerHTML = `${points.length}<small>个</small>`;
$('kpiOccupied').innerHTML = `${occupied.length}<small>个</small>`;
$('kpiEmpty').innerHTML = `${points.length - occupied.length}<small>个</small>`;
$('kpiOverdue').innerHTML = `${overdue.length}<small>个</small>`;
$('kpiRate').innerHTML = `${Math.round(occupied.length / points.length * 100)}<small>%</small>`;
const zones = [
['北区', northCodes],
['中区', centerCodes],
['南区', [...southCodes, ...dockCodes]]
];
$('zoneMini').innerHTML = zones.map(([name, codes]) => {
const used = codes.filter(code => pointMap.get(code).status >= 2).length;
return `<div class="zone-mini-item"><b>${used}/${codes.length}</b><span>${name}占用</span></div>`;
}).join('');
renderQueueGroup('queueList01', 'queueCount01', southCodes.slice(0, 8));
renderQueueGroup('queueList02', 'queueCount02', southCodes.slice(8, 16));
document.querySelectorAll('.queue-item[data-code]').forEach(item => item.addEventListener('click', () => selectPoint(item.dataset.code)));
}
function renderQueueGroup(listId, countId, codes) {
const queue = codes.map(code => pointMap.get(code)).filter(point => point.boxNo).sort((a, b) => b.durationHours - a.durationHours);
$(countId).textContent = `${queue.length}`;
$(listId).innerHTML = queue.length ? queue.map((point, index) => `<li class="queue-item" data-code="${point.code}"><span class="queue-no">${pad(index + 1)}</span><div><div class="queue-box">${point.boxNo}</div><div class="queue-point">${point.code} · ${point.rollNo}</div></div><span class="queue-time">${durationText(point.durationHours)}</span></li>`).join('') : '<li class="queue-item">当前队列为空</li>';
}
function selectPoint(code) {
const point = pointMap.get(code);
if (!point) return;
selectedCode = code;
document.querySelectorAll('.slot.selected').forEach(element => element.classList.remove('selected'));
const element = document.querySelector(`.slot[data-code="${code}"]`);
if (element) element.classList.add('selected');
const meta = STATUS[point.status];
const overdue = isOverdue(point);
const statusColor = overdue ? '#ff6574' : meta.color;
$('detailCard').classList.toggle('alert', overdue || point.status === 4);
$('detailCard').style.setProperty('--detail-color', statusColor);
$('detailCard').innerHTML = `<div class="detail-code">${point.code}</div><div class="detail-name">${point.name}</div><div class="detail-status"><i></i>${overdue ? '超时滞留' : meta.text}</div><div class="detail-rows"><div class="detail-row"><span>卷号</span><b class="em">${point.rollNo || '空闲'}</b></div><div class="detail-row"><span>组盘木箱</span><b class="em">${point.boxNo || '未组盘'}</b></div><div class="detail-row"><span>产品规格</span><b>${point.width ? `${point.width} × ${point.diameter} mm` : '--'}</b></div><div class="detail-row"><span>进入时间</span><b>${point.durationHours ? formatDateTime(point.entryTime) : '--'}</b></div><div class="detail-row"><span>停留时长</span><b>${durationText(point.durationHours)}</b></div></div>`;
}
function locateRoll() {
const query = $('searchInput').value.trim().toUpperCase();
const message = $('searchMessage');
document.querySelectorAll('.search-hit').forEach(element => element.classList.remove('search-hit'));
message.className = '';
if (!query) {
message.textContent = '请输入查询内容';
message.classList.add('error');
return;
}
const point = points.find(item => item.rollNo.toUpperCase().includes(query) || item.code.includes(query));
if (!point) {
message.textContent = '未找到匹配点位';
message.classList.add('error');
return;
}
const target = document.querySelector(`.slot[data-code="${point.code}"]`);
if (!target) return;
target.classList.add('search-hit');
selectPoint(point.code);
focusElement(target);
message.textContent = `已定位 ${point.code}`;
message.classList.add('ok');
}
function applyTransform() {
$('mapCanvas').style.transform = `translate(${panX}px, ${panY}px) scale(${mapScale})`;
$('zoomInput').value = fitScale ? Math.round(mapScale / fitScale * 100) : 100;
}
function fitMap() {
const viewport = $('mapViewport');
const canvas = $('mapCanvas');
fitScale = Math.min(viewport.clientWidth / canvas.offsetWidth, viewport.clientHeight / canvas.offsetHeight) * .96;
const saved = Number(localStorage.getItem(ZOOM_KEY));
const ratio = Number.isFinite(saved) && saved >= .55 && saved <= 3 ? saved : 1;
mapScale = fitScale * ratio;
panX = (viewport.clientWidth - canvas.offsetWidth * mapScale) / 2;
panY = (viewport.clientHeight - canvas.offsetHeight * mapScale) / 2;
applyTransform();
}
function focusElement(element) {
const viewport = $('mapViewport');
const canvasRect = $('mapCanvas').getBoundingClientRect();
const targetRect = element.getBoundingClientRect();
const worldX = (targetRect.left + targetRect.width / 2 - canvasRect.left) / mapScale;
const worldY = (targetRect.top + targetRect.height / 2 - canvasRect.top) / mapScale;
mapScale = Math.max(mapScale, fitScale * 2);
panX = viewport.clientWidth / 2 - worldX * mapScale;
panY = viewport.clientHeight / 2 - worldY * mapScale;
localStorage.setItem(ZOOM_KEY, String(mapScale / fitScale));
applyTransform();
}
function resetMap() {
localStorage.setItem(ZOOM_KEY, '1');
fitMap();
}
function showContextMenu(event, point) {
event.preventDefault();
event.stopPropagation();
contextPoint = point;
selectPoint(point.code);
const menu = $('contextMenu');
$('contextTitle').textContent = point.code;
$('copyRollBtn').disabled = !point.rollNo;
$('copyBoxBtn').disabled = !point.boxNo;
menu.classList.add('open');
const x = Math.min(event.clientX, window.innerWidth - 174);
const y = Math.min(event.clientY, window.innerHeight - 120);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
}
function hideContextMenu() { $('contextMenu').classList.remove('open'); }
async function copyValue(type) {
const value = type === 'roll' ? contextPoint?.rollNo : contextPoint?.boxNo;
if (!value) return;
try {
await navigator.clipboard.writeText(value);
} catch (error) {
const area = document.createElement('textarea');
area.value = value;
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
document.execCommand('copy');
area.remove();
}
hideContextMenu();
const toast = $('toast');
toast.textContent = `已复制${type === 'roll' ? '卷号' : '箱号'}${value}`;
toast.style.display = 'block';
window.setTimeout(() => { toast.style.display = 'none'; }, 1800);
}
function updateClock() {
const now = new Date();
const week = ['日', '一', '二', '三', '四', '五', '六'];
$('clockTime').textContent = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
$('clockDate').textContent = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())} 日 星期${week[now.getDay()]}`;
}
function scaleStage() {
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
$('stage').style.transform = `scale(${scale})`;
}
function bindEvents() {
$('searchBtn').addEventListener('click', locateRoll);
$('searchInput').addEventListener('keydown', event => { if (event.key === 'Enter') locateRoll(); });
$('thresholdInput').value = thresholdHours;
$('thresholdInput').addEventListener('change', () => {
const value = Math.max(1, Math.min(72, Number($('thresholdInput').value) || 12));
thresholdHours = value;
$('thresholdInput').value = value;
localStorage.setItem(THRESHOLD_KEY, String(value));
renderMap();
});
$('zoomInput').addEventListener('change', () => {
const ratio = Math.max(.55, Math.min(3, (Number($('zoomInput').value) || 100) / 100));
const viewport = $('mapViewport');
const canvas = $('mapCanvas');
mapScale = fitScale * ratio;
panX = (viewport.clientWidth - canvas.offsetWidth * mapScale) / 2;
panY = (viewport.clientHeight - canvas.offsetHeight * mapScale) / 2;
localStorage.setItem(ZOOM_KEY, String(ratio));
applyTransform();
});
$('resetBtn').addEventListener('click', resetMap);
$('mapViewport').addEventListener('dblclick', resetMap);
$('copyRollBtn').addEventListener('click', () => copyValue('roll'));
$('copyBoxBtn').addEventListener('click', () => copyValue('box'));
document.addEventListener('click', event => { if (!event.target.closest('#contextMenu')) hideContextMenu(); });
const viewport = $('mapViewport');
viewport.addEventListener('wheel', event => {
event.preventDefault();
const rect = viewport.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - panX) / mapScale;
const worldY = (mouseY - panY) / mapScale;
const next = Math.max(fitScale * .55, Math.min(fitScale * 3, mapScale * (event.deltaY < 0 ? 1.11 : .9)));
panX = mouseX - worldX * next;
panY = mouseY - worldY * next;
mapScale = next;
localStorage.setItem(ZOOM_KEY, String(mapScale / fitScale));
applyTransform();
}, { passive: false });
let dragging = false;
let startX = 0;
let startY = 0;
let startPanX = 0;
let startPanY = 0;
viewport.addEventListener('mousedown', event => {
if (event.button !== 0 || event.target.closest('.slot')) return;
dragging = true;
startX = event.clientX;
startY = event.clientY;
startPanX = panX;
startPanY = panY;
viewport.classList.add('dragging');
});
window.addEventListener('mousemove', event => {
if (!dragging) return;
panX = startPanX + event.clientX - startX;
panY = startPanY + event.clientY - startY;
applyTransform();
});
window.addEventListener('mouseup', () => { dragging = false; viewport.classList.remove('dragging'); });
window.addEventListener('resize', () => { scaleStage(); fitMap(); });
}
renderMap();
selectPoint('DJQ_01');
bindEvents();
scaleStage();
requestAnimationFrame(fitMap);
updateClock();
window.setInterval(updateClock, 1000);
})();
</script>
</body>
</html>