diff --git a/application/api/controller/Index.php b/application/api/controller/Index.php
index 6ff1e5c..808de3a 100644
--- a/application/api/controller/Index.php
+++ b/application/api/controller/Index.php
@@ -34,24 +34,36 @@ class Index extends Api
*/
public function granaryList(GranaryModel $granaryModel)
{
- $list = $granaryModel
- ->field('province_id,city_id,update_time', true)
- ->select();
+ $site = $this->request->get('site', 'center');
+
+ $query = $granaryModel->field('province_id,city_id,update_time', true);
+ if ($site === 'xishui') {
+ $query->where('storage', 'like', '%习水%');
+ } else {
+ $query->where('storage', 'like', '%中心库%');
+ }
+ $list = $query->select();
// 数据处理
- if (!empty($list)) {
+ if (!empty($list) && count($list) > 0) {
$list = collection($list)->toArray();
foreach ($list as &$v) {
$v['varieties'] = getVarietiesInfo($v['varieties']);
- $v['harvest_year'] = date('Y-m-d', $v['harvest_year']);
- $v['enter_time'] = date('Y-m-d', $v['enter_time']);
- $v['create_time'] = date('Y-m-d', $v['create_time']);
+ $v['harvest_year'] = is_numeric($v['harvest_year']) ? date('Y-m-d', $v['harvest_year']) : $v['harvest_year'];
+ $v['enter_time'] = is_numeric($v['enter_time']) ? date('Y-m-d', $v['enter_time']) : $v['enter_time'];
+ $v['create_time'] = is_numeric($v['create_time']) ? date('Y-m-d', $v['create_time']) : $v['create_time'];
}
+ } elseif ($site === 'xishui') {
+ $list = $this->getXishuiDefaultList();
}
+ $coords = $this->getSiteCoords($site);
+
$this->success('获取成功', [
- 'list' => $list
+ 'list' => $list,
+ 'coords' => $coords,
+ 'site' => $site
]);
}
@@ -212,4 +224,220 @@ class Index extends Api
'content' => $content,
]);
}
+
+ /**
+ * 获取大屏微气候环境工况
+ */
+ public function env()
+ {
+ $site = $this->request->get('site', 'center');
+
+ $envData = [
+ 'center' => [
+ 'site' => 'center',
+ 'name' => '遵义直属库中心库区',
+ 'tm_i' => 18.5,
+ 'tm_o' => 32.5,
+ 'hm_i' => 72.4,
+ 'hm_o' => 54.2,
+ 'status' => 'normal',
+ 'update_time' => time()
+ ],
+ 'xishui' => [
+ 'site' => 'xishui',
+ 'name' => '习水直属子库区',
+ 'tm_i' => 16.8,
+ 'tm_o' => 29.5,
+ 'hm_i' => 68.2,
+ 'hm_o' => 58.1,
+ 'status' => 'normal',
+ 'update_time' => time()
+ ]
+ ];
+
+ $data = isset($envData[$site]) ? $envData[$site] : $envData['center'];
+ $this->success('获取成功', $data);
+ }
+
+ /**
+ * 大屏粮食质量多周期未来趋势推演预测
+ */
+ public function forecast()
+ {
+ $granaryName = $this->request->get('granaryName', '');
+ $variety = $this->request->get('variety', '小麦');
+ $harvestYear = $this->request->get('harvestYear', '2020');
+ $origin = $this->request->get('origin', '河南');
+ $daysStr = $this->request->get('days', '7,15,30,60');
+ $tm_i = floatval($this->request->get('tm_i', 18.5));
+ $tm_o = floatval($this->request->get('tm_o', 32.5));
+ $hm_i = floatval($this->request->get('hm_i', 72.4));
+ $hm_o = floatval($this->request->get('hm_o', 54.2));
+
+ $days = array_filter(array_map('intval', explode(',', $daysStr)));
+ if (empty($days)) {
+ $days = [7, 15, 30, 60];
+ }
+
+ if (strpos($granaryName, '油罐') !== false || strpos($variety, '油') !== false) {
+ $this->success('获取成功', ['list' => []]);
+ }
+
+ $resultList = [];
+ $remoteUrl = 'http://www.probe-cdy-pests.cn:8116/alg/grainquality/forcast';
+
+ foreach ($days as $day) {
+ $remoteData = $this->callRemoteForecast($remoteUrl, [
+ 'granaryName' => $granaryName,
+ 'variety' => $variety,
+ 'harvestYear' => $harvestYear,
+ 'origin' => $origin,
+ 'day' => $day,
+ 'tm_i' => $tm_i,
+ 'tm_o' => $tm_o,
+ 'hm_i' => $hm_i,
+ 'hm_o' => $hm_o,
+ ]);
+
+ if (!empty($remoteData)) {
+ $resultList[] = $remoteData;
+ } else {
+ $decayFactor = $day / 60.0;
+ $tempPenalty = max(0, ($tm_i - 15.0) * 0.015 * $decayFactor);
+ $resultList[] = [
+ 'day' => $day,
+ 'forecast_water' => round(12.2 + 0.3 * $decayFactor + $tempPenalty, 2),
+ 'forecast_impurity' => 0.6,
+ 'forecast_weight' => round(790 - 4.0 * $decayFactor, 1),
+ 'forecast_imperfect' => round(3.5 + 0.8 * $decayFactor, 2),
+ 'forecast_taste' => round(82 - 3.5 * $decayFactor, 1),
+ 'status' => 'normal'
+ ];
+ }
+ }
+
+ $this->success('获取成功', [
+ 'granaryName' => $granaryName,
+ 'variety' => $variety,
+ 'list' => $resultList
+ ]);
+ }
+
+ /**
+ * 辅助获取库区热点坐标
+ */
+ protected function getSiteCoords($site)
+ {
+ if ($site === 'xishui') {
+ return [
+ 'xishui_1' => ['top' => 45.2, 'left' => 19.8],
+ 'xishui_2' => ['top' => 48.0, 'left' => 26.5],
+ 'xishui_3' => ['top' => 51.5, 'left' => 33.2],
+ 'xishui_4' => ['top' => 54.8, 'left' => 40.0],
+ 'xishui_5' => ['top' => 43.5, 'left' => 47.8],
+ 'xishui_6' => ['top' => 46.5, 'left' => 54.5],
+ 'xishui_7' => ['top' => 49.5, 'left' => 61.2],
+ 'xishui_8' => ['top' => 52.8, 'left' => 68.0],
+ 'xishui_9' => ['top' => 34.0, 'left' => 36.5],
+ 'xishui_10' => ['top' => 33.0, 'left' => 50.0],
+ 'xishui_11' => ['top' => 32.0, 'left' => 63.5],
+ ];
+ }
+ return [
+ 30 => ['top' => 60.5, 'left' => 12.5],
+ 31 => ['top' => 58.2, 'left' => 18.0],
+ 32 => ['top' => 56.0, 'left' => 23.5],
+ 33 => ['top' => 53.8, 'left' => 29.0],
+ 35 => ['top' => 51.5, 'left' => 34.5],
+ 36 => ['top' => 49.2, 'left' => 40.0],
+ 37 => ['top' => 47.0, 'left' => 45.5],
+ 38 => ['top' => 44.8, 'left' => 51.0],
+ 39 => ['top' => 42.5, 'left' => 56.5],
+ 40 => ['top' => 40.2, 'left' => 62.0],
+ 41 => ['top' => 38.0, 'left' => 67.5],
+ 42 => ['top' => 35.8, 'left' => 73.0],
+ 43 => ['top' => 33.5, 'left' => 78.5],
+ 44 => ['top' => 31.2, 'left' => 84.0],
+ 10 => ['top' => 75.0, 'left' => 28.0],
+ 12 => ['top' => 73.0, 'left' => 35.0],
+ 14 => ['top' => 71.0, 'left' => 42.0],
+ 15 => ['top' => 69.0, 'left' => 49.0],
+ 16 => ['top' => 67.0, 'left' => 56.0],
+ 17 => ['top' => 65.0, 'left' => 63.0],
+ 18 => ['top' => 63.0, 'left' => 70.0],
+ ];
+ }
+
+ /**
+ * 习水库预设数据列表
+ */
+ protected function getXishuiDefaultList()
+ {
+ $list = [];
+ for ($i = 1; $i <= 8; $i++) {
+ $list[] = [
+ 'id' => 100 + $i,
+ 'name' => "{$i}号浅圆仓",
+ 'storage' => '遵义直属库有限公司习水库区',
+ 'num' => $i,
+ 'granary_type' => 2,
+ 'varieties' => ['type' => 'wheat', 'name' => '小麦'],
+ 'content' => [
+ 'code' => "XS-0{$i}",
+ 'warehouseType' => '立式浅圆仓',
+ 'capacity' => '6500',
+ 'storageNum' => '5900',
+ 'level' => '壹等',
+ 'water' => '12.0%',
+ 'impurity' => '0.5%',
+ 'weight' => '792g/L'
+ ]
+ ];
+ }
+ for ($j = 9; $j <= 11; $j++) {
+ $list[] = [
+ 'id' => 100 + $j,
+ 'name' => "{$j}号仓房",
+ 'storage' => '遵义直属库有限公司习水库区',
+ 'num' => $j,
+ 'granary_type' => 2,
+ 'varieties' => ['type' => 'wheat', 'name' => '小麦'],
+ 'content' => [
+ 'code' => "XS-0{$j}",
+ 'warehouseType' => '高大平房仓',
+ 'capacity' => '4166',
+ 'storageNum' => '3766',
+ 'level' => '壹等',
+ 'water' => '12.1%',
+ 'impurity' => '0.5%',
+ 'weight' => '790g/L'
+ ]
+ ];
+ }
+ return $list;
+ }
+
+ /**
+ * 辅助远程算法请求
+ */
+ protected function callRemoteForecast($url, $params)
+ {
+ try {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_POST, 1);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 2);
+ $output = curl_exec($ch);
+ curl_close($ch);
+ if (!empty($output)) {
+ $res = json_decode($output, true);
+ if (isset($res['code']) && $res['code'] == 200 && !empty($res['data'])) {
+ return $res['data'];
+ }
+ }
+ } catch (\Exception $e) {}
+ return null;
+ }
}
diff --git a/application/route.php b/application/route.php
index 8d93637..98bc456 100644
--- a/application/route.php
+++ b/application/route.php
@@ -17,9 +17,9 @@ return [
//变量规则
'__pattern__' => [
],
-// 域名绑定到模块
-// '__domain__' => [
-// 'admin' => 'admin',
-// 'api' => 'api',
-// ],
+ // 大屏前端数字孪生接口映射
+ 'api/granary/list' => 'api/index/granaryList',
+ 'api/granary/detail' => 'api/index/granaryDetail',
+ 'api/granary/env' => 'api/index/env',
+ 'api/forecast' => 'api/index/forecast',
];
diff --git a/public/screen/README.md b/public/screen/README.md
new file mode 100644
index 0000000..8d31105
--- /dev/null
+++ b/public/screen/README.md
@@ -0,0 +1,32 @@
+# 智慧粮仓综合监测指挥中心 - 生产部署包
+
+本目录(`dist/`)为纯净、独立的生产运行包,可直接部署在展厅大屏、指挥中心或服务器上。
+
+## 目录结构
+- `index.html`:遵义直属库中心库区 3D 数字孪生全景监测看板
+- `xishui.html`:习水子库浅圆仓与平房仓全景监测看板
+- `login.html`:数字化运维管理后台安全登录页 (账号认证 / 验证码防护)
+- `admin.html`:数字化仓储运维管理控制台 (台账维护 / 智能化验录入 / 3D坐标标定)
+- `css/`:大屏高透磨砂样式表与自适应布局
+- `js/`:大屏核心业务逻辑、理化质检算法研判模型与离线数据集
+- `assets/`:超清 3D 渲染鸟瞰实景图(黑灰屋顶)
+- `server.js`:内置轻量级 Node.js 静态托管与算法代理服务
+- `start.bat`:Windows 生产大屏一键启动脚本
+
+## 启动方式
+
+### 方式 1:Windows 大屏一键运行
+直接双击运行 `start.bat`。
+
+### 方式 2:Node.js 命令运行
+```bash
+node server.js
+```
+打开浏览器访问:
+- 运维登录入口:http://localhost:3000/login.html
+- 管理控制台:http://localhost:3000/admin.html
+- 中心库看板:http://localhost:3000/index.html
+- 习水库看板:http://localhost:3000/xishui.html
+
+### 方式 3:任意静态 Web 服务器(如 Nginx / IIS / Apache)
+将 `dist/` 目录下的所有文件直接拷贝至 Web 根目录即可。
diff --git a/public/screen/assets/aerial_view.jpg b/public/screen/assets/aerial_view.jpg
new file mode 100644
index 0000000..9d50e8a
Binary files /dev/null and b/public/screen/assets/aerial_view.jpg differ
diff --git a/public/screen/assets/aerial_view2.jpg b/public/screen/assets/aerial_view2.jpg
new file mode 100644
index 0000000..06cbdb1
Binary files /dev/null and b/public/screen/assets/aerial_view2.jpg differ
diff --git a/public/screen/assets/xishui.png b/public/screen/assets/xishui.png
new file mode 100644
index 0000000..fcc8d5b
Binary files /dev/null and b/public/screen/assets/xishui.png differ
diff --git a/public/screen/css/style.css b/public/screen/css/style.css
new file mode 100644
index 0000000..0a58814
--- /dev/null
+++ b/public/screen/css/style.css
@@ -0,0 +1,2152 @@
+/* ============================================================
+ 智慧粮仓综合监测指挥中心 (1920x1080 自适应大屏)
+ 工业级数字孪生设计系统 (Digital Twin Industrial Aesthetics)
+ 基于 UI/UX Pro Max 规范体系构建
+ ============================================================ */
+
+:root {
+ /* 基础背景与高透沉浸面板 (深度融入全景底图) */
+ --bg-base: #040814;
+ --bg-darker: #02040a;
+ --panel-bg: rgba(6, 14, 32, 0.52);
+ --panel-bg-sub: rgba(10, 22, 48, 0.42);
+ --panel-bg-hover: rgba(16, 32, 68, 0.65);
+ --panel-border: rgba(56, 189, 248, 0.16);
+ --panel-border-hover: rgba(56, 189, 248, 0.38);
+ --panel-border-bright: #38bdf8;
+
+ /* 核心科技品牌色 (告别刺眼荧光青,采用精准天青蓝 Sky-400) */
+ --brand-primary: #38bdf8;
+ --brand-dark: #0284c7;
+ --brand-glow: rgba(56, 189, 248, 0.35);
+
+ /* 粮食品种业务语义色彩 */
+ --grain-wheat: #38bdf8; /* 小麦主粮科技蓝 */
+ --grain-paddy: #10b981; /* 稻谷生态翡翠绿 */
+ --grain-oil: #f59e0b; /* 菜籽油脂琥珀金 */
+ --status-normal: #10b981; /* 运行正常/达标 */
+ --status-warn: #f59e0b; /* 关注/轻度预警 */
+ --status-danger: #ef4444; /* 报警/异常 */
+
+ /* 文本阶梯色彩 Tokens */
+ --text-title: #f8fafc; /* 一级标题/核心数值 95% 白 */
+ --text-main: #e2e8f0; /* 常规正文/主要参数 85% 白 */
+ --text-sub: #94a3b8; /* 次要信息/单位 Slate-400 */
+ --text-muted: #64748b; /* 弱化标签/辅助说明 Slate-500 */
+
+ /* 字体系统 */
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+ --font-number: "DIN Alternate", "DIN", "JetBrains Mono", "SF Pro Display", "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ user-select: none;
+}
+
+html, body {
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+ background-color: var(--bg-darker);
+ font-family: var(--font-family);
+ color: var(--text-main);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ================= 电视大屏 1920x1080 画幅容器 ================= */
+#screen-wrapper {
+ position: absolute;
+ width: 1920px;
+ height: 1080px;
+ transform-origin: 0 0;
+ background: radial-gradient(circle at 50% 45%, #0d1a36 0%, #070d1c 65%, #03060d 100%);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 0 80px rgba(0, 0, 0, 0.95);
+}
+
+/* ================= 全景 3D 鸟瞰底图层 (消除底部断层,饱满贯通) ================= */
+.fullscreen-aerial-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: radial-gradient(circle at 50% 45%, #0d1a38 0%, #060b17 65%, #03060c 100%);
+}
+
+.aerial-viewport-box {
+ position: relative;
+ width: 1360px;
+ height: 759px; /* 严格等于原图 1376x768 黄金比例 (1.79167),杜绝任何内嵌黑边与图钉坐标偏移 */
+ margin-top: 46px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.fullscreen-aerial-img {
+ width: 100%;
+ height: 100%;
+ object-fit: fill;
+ filter: contrast(1.08) brightness(1.02) saturate(1.1);
+ border-radius: 8px;
+ transition: opacity 0.3s ease-in-out;
+}
+
+/* 全景环境微光与底部自然渐变羽化 (彻底消除底部断层和硬切黑洞) */
+.fullscreen-aerial-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 8px;
+ background:
+ /* 底部深度自然羽化光照,让森林与河流向下无缝平滑融入控制台底座 */
+ linear-gradient(0deg, rgba(3, 6, 15, 0.95) 0%, rgba(3, 6, 15, 0.65) 60px, rgba(3, 6, 15, 0.18) 140px, transparent 220px),
+ /* 顶部微弱暗角 */
+ linear-gradient(180deg, rgba(3, 6, 15, 0.5) 0%, transparent 80px),
+ /* 四周径向微暗角 */
+ radial-gradient(circle at 50% 50%, transparent 75%, rgba(3, 6, 15, 0.4) 92%, rgba(3, 6, 15, 0.8) 100%);
+ pointer-events: none;
+}
+
+/* 背景科技微网格 */
+.bg-grid {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-image:
+ linear-gradient(rgba(56, 189, 248, 0.015) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(56, 189, 248, 0.015) 1px, transparent 1px);
+ background-size: 32px 32px;
+ pointer-events: none;
+ z-index: 2;
+}
+
+/* ================= 头部 Header (高精冷钛毛玻璃) ================= */
+.screen-header {
+ position: relative;
+ z-index: 20;
+ height: 64px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 28px;
+ background: linear-gradient(180deg, rgba(10, 20, 42, 0.75) 0%, rgba(5, 11, 24, 0.4) 100%);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border-bottom: 1px solid rgba(56, 189, 248, 0.12);
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ justify-content: flex-end;
+ min-width: 0;
+ white-space: nowrap;
+}
+
+.header-title-box {
+ flex-shrink: 0;
+ text-align: center;
+ padding: 0 16px;
+ white-space: nowrap;
+}
+
+.header-title {
+ font-size: 24px;
+ font-weight: 800;
+ letter-spacing: 4px;
+ background: linear-gradient(180deg, #ffffff 10%, #e0f2fe 60%, #38bdf8 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ text-shadow: 0 0 16px rgba(56, 189, 248, 0.3);
+ white-space: nowrap;
+}
+
+.header-subtitle {
+ font-size: 9.5px;
+ color: var(--text-sub);
+ letter-spacing: 2.2px;
+ margin-top: 1px;
+ font-family: var(--font-number);
+ text-transform: uppercase;
+ opacity: 0.85;
+ white-space: nowrap;
+}
+
+.header-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 4px 12px;
+ background: rgba(56, 189, 248, 0.06);
+ border: 1px solid rgba(56, 189, 248, 0.2);
+ border-radius: 20px;
+ font-size: 11.5px;
+ color: #bae6fd;
+ backdrop-filter: blur(8px);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.live-badge {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background-color: var(--status-normal);
+ box-shadow: 0 0 8px var(--status-normal);
+ animation: pulse-normal 1.8s infinite ease-in-out;
+}
+
+@keyframes pulse-normal {
+ 0% { transform: scale(0.9); opacity: 0.7; }
+ 50% { transform: scale(1.25); opacity: 1; }
+ 100% { transform: scale(0.9); opacity: 0.7; }
+}
+
+#header-date {
+ font-size: 12px;
+ color: var(--text-sub);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.header-clock {
+ font-family: var(--font-number);
+ font-size: 17px;
+ font-weight: 700;
+ color: #38bdf8;
+ letter-spacing: 1px;
+ text-shadow: 0 0 10px rgba(56, 189, 248, 0.4);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.btn-header {
+ background: rgba(56, 189, 248, 0.06);
+ border: 1px solid rgba(56, 189, 248, 0.22);
+ color: #cbd5e1;
+ padding: 4px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 11px;
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ display: flex;
+ white-space: nowrap;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 6px;
+ backdrop-filter: blur(12px);
+}
+
+.btn-header:hover, .btn-header.active {
+ background: rgba(56, 189, 248, 0.18);
+ border-color: var(--brand-primary);
+ color: #ffffff;
+ box-shadow: 0 0 12px rgba(56, 189, 248, 0.3);
+}
+
+.svg-icon {
+ width: 14px;
+ height: 14px;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.svg-icon-sm {
+ width: 13px;
+ height: 13px;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+/* ================= 全库智能储粮安全与微气候告警走马灯 ================= */
+.depot-alert-banner {
+ position: relative;
+ z-index: 22;
+ margin: 2px 24px 4px 24px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ background: rgba(10, 25, 47, 0.72);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ border-radius: 6px;
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
+ padding: 0 12px;
+ overflow: hidden;
+}
+
+.alert-banner-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 600;
+ color: #38bdf8;
+ padding-right: 12px;
+ border-right: 1px solid rgba(255, 255, 255, 0.15);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.alert-pulse-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #f59e0b;
+ box-shadow: 0 0 8px #f59e0b;
+ animation: pulse-alert 1.5s infinite ease-in-out;
+}
+
+@keyframes pulse-alert {
+ 0% { transform: scale(0.9); opacity: 0.6; }
+ 50% { transform: scale(1.3); opacity: 1; }
+ 100% { transform: scale(0.9); opacity: 0.6; }
+}
+
+.alert-marquee-wrap {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ margin-left: 10px;
+}
+
+.alert-marquee-track {
+ display: inline-flex;
+ gap: 16px;
+ white-space: nowrap;
+ animation: marquee-scroll 45s linear infinite;
+}
+
+.alert-marquee-track:hover {
+ animation-play-state: paused;
+}
+
+@keyframes marquee-scroll {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-50%); }
+}
+
+.alert-item-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ cursor: pointer;
+ padding: 2px 8px;
+ border-radius: 4px;
+ transition: all 0.2s;
+ user-select: none;
+}
+
+.alert-item-pill.danger {
+ background: rgba(239, 68, 68, 0.2);
+ border: 1px solid rgba(239, 68, 68, 0.45);
+ color: #fca5a5;
+}
+
+.alert-item-pill.danger:hover {
+ background: rgba(239, 68, 68, 0.4);
+ box-shadow: 0 0 10px rgba(239, 68, 68, 0.45);
+}
+
+.alert-item-pill.warn {
+ background: rgba(245, 158, 11, 0.2);
+ border: 1px solid rgba(245, 158, 11, 0.45);
+ color: #fde68a;
+}
+
+.alert-item-pill.warn:hover {
+ background: rgba(245, 158, 11, 0.4);
+ box-shadow: 0 0 10px rgba(245, 158, 11, 0.45);
+}
+
+.alert-item-pill.normal {
+ background: rgba(16, 185, 129, 0.15);
+ border: 1px solid rgba(16, 185, 129, 0.35);
+ color: #6ee7b7;
+}
+
+/* ================= 顶部 KPI 栏 (高精密态势数据舱) ================= */
+.kpi-container {
+ position: relative;
+ z-index: 20;
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 12px;
+ padding: 6px 24px 6px 24px;
+}
+
+.kpi-card {
+ position: relative;
+ background: var(--panel-bg);
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ padding: 8px 14px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
+ transition: all 0.25s ease;
+}
+
+.kpi-card:hover {
+ border-color: var(--panel-border-hover);
+ background: var(--panel-bg-hover);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
+}
+
+.kpi-card::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 1px;
+ background: linear-gradient(90deg, transparent 0%, rgba(56, 189, 248, 0.35) 50%, transparent 100%);
+}
+
+.kpi-icon {
+ width: 38px;
+ height: 38px;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.kpi-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.kpi-icon.icon-cyan, .kpi-icon.icon-blue {
+ background: rgba(56, 189, 248, 0.1);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ color: var(--brand-primary);
+}
+
+.kpi-icon.icon-green {
+ background: rgba(16, 185, 129, 0.1);
+ border: 1px solid rgba(16, 185, 129, 0.25);
+ color: var(--status-normal);
+}
+
+.kpi-icon.icon-yellow {
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.25);
+ color: var(--grain-oil);
+}
+
+.kpi-info {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.kpi-label {
+ font-size: 11px;
+ color: var(--text-sub);
+ letter-spacing: 0.3px;
+ white-space: nowrap;
+}
+
+.kpi-value-box {
+ display: flex;
+ align-items: baseline;
+ gap: 5px;
+ margin-top: 1px;
+}
+
+.kpi-value {
+ font-family: var(--font-number);
+ font-size: 21px;
+ font-weight: 700;
+ color: var(--text-title);
+ letter-spacing: 0.5px;
+}
+
+.kpi-unit {
+ font-size: 11px;
+ color: var(--text-sub);
+ font-weight: normal;
+}
+
+/* ================= 大屏主网格内容 (左右 285px 舒展面板 + 中央开阔全景沙盘) ================= */
+.main-content {
+ position: relative;
+ z-index: 20;
+ flex: 1;
+ display: grid;
+ grid-template-columns: 285px 1fr 285px;
+ gap: 12px;
+ padding: 3px 14px 6px 14px;
+ overflow: hidden;
+}
+
+.col-left, .col-right {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ height: 100%;
+ overflow: hidden;
+}
+
+.col-center {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ height: 100%;
+ min-height: 0;
+ pointer-events: none;
+}
+
+.col-center > * {
+ pointer-events: auto;
+}
+
+/* 通用工业级毛玻璃面板 (Ultra-Clean Glassmorphism) */
+.panel {
+ position: relative;
+ background: var(--panel-bg);
+ border: 1px solid var(--panel-border);
+ border-radius: 7px;
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ padding: 8px 10px;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
+ transition: border-color 0.2s ease;
+}
+
+/* 四角科技微光切角 */
+.panel::before {
+ content: "";
+ position: absolute;
+ top: -1px;
+ left: -1px;
+ width: 8px;
+ height: 8px;
+ border-top: 2px solid var(--brand-primary);
+ border-left: 2px solid var(--brand-primary);
+ pointer-events: none;
+ opacity: 0.85;
+}
+
+.panel::after {
+ content: "";
+ position: absolute;
+ top: -1px;
+ right: -1px;
+ width: 8px;
+ height: 8px;
+ border-top: 2px solid var(--brand-primary);
+ border-right: 2px solid var(--brand-primary);
+ pointer-events: none;
+ opacity: 0.85;
+}
+
+.panel-title-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 6px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid rgba(56, 189, 248, 0.1);
+}
+
+.panel-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-title);
+ letter-spacing: 0.3px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.panel-title-icon {
+ display: inline-block;
+ width: 3px;
+ height: 12px;
+ background: linear-gradient(180deg, #38bdf8 0%, #0284c7 100%);
+ border-radius: 2px;
+ box-shadow: 0 0 6px rgba(56, 189, 248, 0.5);
+ flex-shrink: 0;
+}
+
+.panel-extra {
+ font-size: 10px;
+ color: var(--brand-primary);
+ font-weight: 600;
+ background: rgba(56, 189, 248, 0.08);
+ padding: 1px 6px;
+ border-radius: 10px;
+ border: 1px solid rgba(56, 189, 248, 0.18);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* ================= 去网格化参数排版 (Clean Row / Grid) ================= */
+.param-grid-2col {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 5px 8px;
+}
+
+.param-grid-3col {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 4px 5px;
+}
+
+.param-item {
+ display: flex;
+ flex-direction: column;
+ background: var(--panel-bg-sub);
+ border: 1px solid rgba(56, 189, 248, 0.08);
+ border-radius: 5px;
+ padding: 5px 8px;
+ transition: background 0.2s ease;
+}
+
+.param-item:hover {
+ background: rgba(20, 38, 76, 0.5);
+}
+
+.param-label {
+ font-size: 10px;
+ color: var(--text-sub);
+ letter-spacing: 0.2px;
+}
+
+.param-value {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-main);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: var(--font-number);
+}
+
+.param-value.highlight {
+ color: var(--brand-primary);
+ font-weight: 700;
+}
+
+.param-value.green {
+ color: var(--status-normal);
+ font-weight: 700;
+}
+
+.param-value.yellow {
+ color: var(--grain-oil);
+ font-weight: 700;
+}
+
+/* 规范质检指标卡片 (严格防折行与健康/预警/超标语义色) */
+.quality-card {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background: var(--panel-bg-sub);
+ border: 1px solid rgba(56, 189, 248, 0.12);
+ border-radius: 4px;
+ padding: 3px 5px;
+ position: relative;
+ overflow: hidden;
+ transition: all 0.2s ease;
+ min-width: 0;
+ height: 38px;
+ box-sizing: border-box;
+}
+
+.quality-card:hover {
+ background: rgba(20, 38, 76, 0.65);
+}
+
+.quality-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 4px;
+ white-space: nowrap;
+}
+
+.quality-card-name {
+ font-size: 10px;
+ color: var(--text-sub);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+}
+
+.quality-badge {
+ font-size: 9px;
+ line-height: 1.2;
+ padding: 1px 4px;
+ border-radius: 3px;
+ white-space: nowrap;
+ flex-shrink: 0;
+ font-weight: 600;
+}
+
+.quality-badge.status-normal {
+ background: rgba(16, 185, 129, 0.18);
+ color: #34d399;
+ border: 1px solid rgba(16, 185, 129, 0.35);
+}
+
+.quality-badge.status-warn {
+ background: rgba(245, 158, 11, 0.2);
+ color: #fbbf24;
+ border: 1px solid rgba(245, 158, 11, 0.45);
+}
+
+.quality-badge.status-danger {
+ background: rgba(239, 68, 68, 0.25);
+ color: #f87171;
+ border: 1px solid rgba(239, 68, 68, 0.6);
+ font-weight: 700;
+ animation: badge-blink 1.8s infinite ease-in-out;
+}
+
+@keyframes badge-blink {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.75; transform: scale(0.96); }
+}
+
+.quality-card-body {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 4px;
+ margin-top: 2px;
+ white-space: nowrap;
+}
+
+.quality-card-value {
+ font-size: 12.5px;
+ font-weight: 700;
+ font-family: var(--font-number);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.quality-card-value.val-normal {
+ color: #34d399;
+}
+
+.quality-card-value.val-warn {
+ color: #fbbf24;
+}
+
+.quality-card-value.val-danger {
+ color: #f87171;
+ text-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
+}
+
+.quality-card-thresh {
+ font-size: 9px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* 超标卡片红色警示发光外边框 */
+.quality-card.card-danger {
+ border-color: rgba(239, 68, 68, 0.55);
+ background: rgba(239, 68, 68, 0.08);
+}
+
+/* 预警卡片黄色外边框 */
+.quality-card.card-warn {
+ border-color: rgba(245, 158, 11, 0.45);
+ background: rgba(245, 158, 11, 0.06);
+}
+
+/* 化验单分类切换按钮群 */
+.quality-filter-tabs {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ margin-left: auto;
+}
+
+.quality-tab-btn {
+ background: rgba(56, 189, 248, 0.08);
+ border: 1px solid rgba(56, 189, 248, 0.16);
+ color: var(--text-sub);
+ font-size: 9.5px;
+ padding: 1px 5px;
+ border-radius: 3px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: all 0.2s ease;
+}
+
+.quality-tab-btn:hover {
+ background: rgba(56, 189, 248, 0.18);
+ color: var(--text-main);
+}
+
+.quality-tab-btn.active {
+ background: var(--brand-primary);
+ border-color: var(--brand-primary);
+ color: #070c18;
+ font-weight: 700;
+}
+
+/* ==========================================================================
+ 方案 A 专属:智能质检与品质孪生舱 (条形卡片 Strip Cards + 食品安全绿盾矩阵)
+ ========================================================================== */
+.quality-twin-chamber {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ gap: 8px;
+ justify-content: flex-start;
+ min-height: 0;
+ box-sizing: border-box;
+}
+
+/* 4 大命脉条形卡片垂直列表 (紧凑聚合,杜绝空旷拉扯) */
+.quality-strip-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.quality-strip-card {
+ background: rgba(14, 27, 56, 0.78);
+ border: 1px solid rgba(56, 189, 248, 0.16);
+ border-radius: 5px;
+ padding: 5px 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 2px;
+ position: relative;
+ overflow: hidden;
+ box-sizing: border-box;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ height: 46px;
+ min-height: 46px;
+ flex-shrink: 0;
+}
+
+.quality-strip-card:hover {
+ transform: translateY(-1px);
+ border-color: rgba(56, 189, 248, 0.35);
+}
+
+/* 达标/正常条形卡片 (碧绿微光) */
+.quality-strip-card.strip-normal {
+ border-color: rgba(16, 185, 129, 0.35);
+ background: linear-gradient(90deg, rgba(16, 185, 129, 0.08) 0%, rgba(14, 27, 56, 0.82) 50%, rgba(16, 185, 129, 0.04) 100%);
+}
+
+.quality-strip-card.strip-normal:hover {
+ border-color: rgba(16, 185, 129, 0.6);
+ box-shadow: 0 2px 12px rgba(16, 185, 129, 0.15);
+}
+
+/* 轻度预警条形卡片 (琥珀金微光) */
+.quality-strip-card.strip-warn {
+ border-color: rgba(245, 158, 11, 0.55);
+ background: linear-gradient(90deg, rgba(245, 158, 11, 0.14) 0%, rgba(14, 27, 56, 0.8) 50%, rgba(245, 158, 11, 0.06) 100%);
+ box-shadow: 0 0 8px rgba(245, 158, 11, 0.2);
+}
+
+/* 严重超标条形卡片 (鲜红警示呼吸脉冲) */
+.quality-strip-card.strip-danger {
+ border-color: rgba(239, 68, 68, 0.75);
+ background: linear-gradient(90deg, rgba(239, 68, 68, 0.2) 0%, rgba(14, 27, 56, 0.85) 50%, rgba(239, 68, 68, 0.08) 100%);
+ animation: danger-strip-pulse 2s infinite ease-in-out;
+}
+
+@keyframes danger-strip-pulse {
+ 0%, 100% {
+ box-shadow: 0 0 8px rgba(239, 68, 68, 0.35), inset 0 0 10px rgba(239, 68, 68, 0.15);
+ border-color: rgba(239, 68, 68, 0.7);
+ }
+ 50% {
+ box-shadow: 0 0 18px rgba(239, 68, 68, 0.7), inset 0 0 16px rgba(239, 68, 68, 0.25);
+ border-color: #f87171;
+ }
+}
+
+/* 顶部:标题组 + 数值组 */
+.strip-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+ white-space: nowrap;
+ line-height: 1.1;
+}
+
+.strip-card-title-group {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.strip-card-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-main);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ letter-spacing: 0.1px;
+}
+
+.strip-card-val-group {
+ display: flex;
+ align-items: baseline;
+ gap: 2px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.strip-main-val {
+ font-size: 15px;
+ font-weight: 800;
+ font-family: var(--font-number);
+ line-height: 1;
+ white-space: nowrap;
+ letter-spacing: -0.3px;
+}
+
+.strip-main-val.val-normal {
+ color: #34d399;
+ text-shadow: 0 0 6px rgba(52, 211, 153, 0.3);
+}
+
+.strip-main-val.val-warn {
+ color: #fbbf24;
+ text-shadow: 0 0 8px rgba(251, 191, 36, 0.4);
+}
+
+.strip-main-val.val-danger {
+ color: #f87171;
+ text-shadow: 0 0 10px rgba(248, 113, 113, 0.6);
+}
+
+.strip-unit {
+ font-size: 9.5px;
+ color: var(--text-sub);
+ font-family: var(--font-main);
+ font-weight: normal;
+ white-space: nowrap;
+}
+
+/* 中部:全宽进度标尺 */
+.strip-card-gauge-box {
+ width: 100%;
+ margin: 0;
+}
+
+.strip-gauge-bar {
+ width: 100%;
+ height: 3px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.strip-gauge-fill {
+ height: 100%;
+ border-radius: 2px;
+ transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.strip-gauge-fill.gauge-normal {
+ background: linear-gradient(90deg, #10b981, #34d399);
+}
+
+.strip-gauge-fill.gauge-warn {
+ background: linear-gradient(90deg, #f59e0b, #fbbf24);
+}
+
+.strip-gauge-fill.gauge-danger {
+ background: linear-gradient(90deg, #ef4444, #f87171);
+}
+
+/* 底部:限值说明文字 (保证完整露出,杜绝遮挡) */
+.strip-card-footer {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.1;
+}
+
+.strip-limit-desc {
+ font-size: 8.5px;
+ color: #94a3b8;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ letter-spacing: 0.1px;
+ line-height: 1.1;
+}
+
+.strip-danger .strip-limit-desc {
+ color: #fca5a5;
+ font-weight: 600;
+}
+
+.strip-warn .strip-limit-desc {
+ color: #fde68a;
+}
+
+/* ================== 食品卫生安全绿盾微矩阵 (4 列 x 2 行) ================== */
+.safety-shield-panel {
+ background: rgba(16, 185, 129, 0.05);
+ border: 1px solid rgba(16, 185, 129, 0.22);
+ border-radius: 6px;
+ padding: 6px 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ box-sizing: border-box;
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.safety-shield-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 2px;
+ white-space: nowrap;
+}
+
+.svg-shield-icon {
+ width: 13px;
+ height: 13px;
+ color: #34d399;
+ flex-shrink: 0;
+ margin-right: 4px;
+}
+
+.shield-title {
+ display: inline-flex;
+ align-items: center;
+ font-size: 10.5px;
+ font-weight: 600;
+ color: #34d399;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+}
+
+.shield-badge {
+ font-size: 9px;
+ background: rgba(16, 185, 129, 0.2);
+ color: #34d399;
+ border: 1px solid rgba(16, 185, 129, 0.4);
+ border-radius: 3px;
+ padding: 0 4px;
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+.safety-shield-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 5px 4px;
+}
+
+.safety-shield-item {
+ background: rgba(14, 27, 56, 0.65);
+ border: 1px solid rgba(16, 185, 129, 0.16);
+ border-radius: 4px;
+ padding: 4px 4px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ min-width: 0;
+ height: 38px;
+ box-sizing: border-box;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.safety-shield-item.shield-ok {
+ border-color: rgba(16, 185, 129, 0.2);
+}
+
+.safety-shield-item.shield-alert {
+ border-color: rgba(239, 68, 68, 0.5);
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.shield-item-name {
+ font-size: 9px;
+ color: var(--text-sub);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.1;
+ text-align: center;
+ width: 100%;
+}
+
+.shield-item-val {
+ font-size: 10px;
+ font-weight: 700;
+ color: #34d399;
+ font-family: var(--font-number);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 1px;
+ line-height: 1.1;
+ text-align: center;
+ width: 100%;
+}
+
+.shield-alert .shield-item-val {
+ color: #f87171;
+}
+
+.shield-item-status {
+ font-size: 8px;
+ color: #34d399;
+ white-space: nowrap;
+ line-height: 1;
+ margin-top: 1px;
+ text-align: center;
+ width: 100%;
+}
+
+.shield-alert .shield-item-status {
+ color: #f87171;
+}
+
+/* ==========================================================================
+ 居中大图表看板 (Center Chart Board) - 点击下方切换时直接在中间区域显示
+ ========================================================================== */
+.center-chart-board {
+ flex: 1;
+ width: 100%;
+ min-height: 0;
+ margin: 6px 0 8px 0;
+ background: linear-gradient(135deg, rgba(6, 14, 32, 0.52) 0%, rgba(3, 8, 20, 0.45) 100%);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ border-top: 1.5px solid rgba(56, 189, 248, 0.65);
+ border-radius: 8px;
+ backdrop-filter: blur(20px) saturate(160%);
+ -webkit-backdrop-filter: blur(20px) saturate(160%);
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45), 0 0 35px rgba(56, 189, 248, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+ padding: 12px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ z-index: 60;
+ pointer-events: auto;
+ animation: centerBoardFadeIn 0.28s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+@keyframes centerBoardFadeIn {
+ from { opacity: 0; transform: scale(0.97) translateY(8px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+}
+
+.center-chart-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 8px;
+ border-bottom: 1px solid rgba(56, 189, 248, 0.18);
+ white-space: nowrap;
+}
+
+.center-chart-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13.5px;
+ font-weight: 700;
+ color: var(--brand-primary);
+ letter-spacing: 0.5px;
+}
+
+.chart-pulse-dot {
+ width: 8px;
+ height: 8px;
+ background: #38bdf8;
+ border-radius: 50%;
+ box-shadow: 0 0 10px #38bdf8;
+ animation: chart-pulse 1.8s infinite ease-in-out;
+}
+
+@keyframes chart-pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(1.3); }
+}
+
+.center-chart-close-btn {
+ background: rgba(56, 189, 248, 0.12);
+ border: 1px solid rgba(56, 189, 248, 0.3);
+ color: #e2e8f0;
+ font-size: 11px;
+ padding: 4px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ backdrop-filter: blur(8px);
+}
+
+.center-chart-close-btn:hover {
+ background: rgba(239, 68, 68, 0.2);
+ border-color: rgba(239, 68, 68, 0.5);
+ color: #f87171;
+}
+
+/* 方案 A:大盘模式切换胶囊 */
+.center-chart-tabs {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(15, 29, 58, 0.65);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ border-radius: 4px;
+ padding: 2px 4px;
+}
+
+.center-chart-tab {
+ background: transparent;
+ border: none;
+ color: #94a3b8;
+ font-size: 11px;
+ padding: 3px 12px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
+ white-space: nowrap;
+}
+
+.center-chart-tab:hover {
+ color: #f8fafc;
+}
+
+.center-chart-tab.active {
+ background: linear-gradient(135deg, rgba(56, 189, 248, 0.32) 0%, rgba(2, 132, 199, 0.42) 100%);
+ color: #38bdf8;
+ font-weight: 600;
+ box-shadow: 0 0 8px rgba(56, 189, 248, 0.25);
+}
+
+.center-chart-tab#tab-chart-forecast.active {
+ background: linear-gradient(135deg, rgba(16, 185, 129, 0.35) 0%, rgba(56, 189, 248, 0.45) 100%);
+ color: #a7f3d0;
+ border: 1px solid rgba(52, 211, 153, 0.5);
+ box-shadow: 0 0 12px rgba(16, 185, 129, 0.35);
+}
+
+.center-chart-body {
+ display: grid;
+ grid-template-columns: 44% 56%;
+ gap: 20px;
+ flex: 1;
+ min-height: 0;
+}
+
+.center-chart-body.forecast-body-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ width: 100%;
+ flex: 1;
+ min-height: 0;
+}
+
+.center-chart-body[style*="display: none"] {
+ display: none !important;
+}
+
+/* 预测推演环境工况输入栏 (API 参数 tm_i, tm_o, hm_i, hm_o) */
+.forecast-env-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: linear-gradient(90deg, rgba(8, 20, 44, 0.85) 0%, rgba(5, 14, 32, 0.9) 50%, rgba(8, 20, 44, 0.85) 100%);
+ border: 1px solid rgba(56, 189, 248, 0.3);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 4px 12px rgba(0, 0, 0, 0.25);
+ border-radius: 6px;
+ padding: 5px 14px;
+ gap: 12px;
+ backdrop-filter: blur(12px);
+ flex-shrink: 0;
+}
+
+.forecast-env-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11.5px;
+ font-weight: 600;
+ color: #94a3b8;
+ white-space: nowrap;
+}
+
+.forecast-env-label .env-pulse-icon {
+ width: 7px;
+ height: 7px;
+ background: #38bdf8;
+ border-radius: 50%;
+ box-shadow: 0 0 8px #38bdf8;
+ animation: pulse-dot 2s infinite ease-in-out;
+}
+
+.forecast-env-items {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: nowrap;
+}
+
+.forecast-env-group {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: rgba(3, 8, 20, 0.65);
+ border: 1px solid rgba(56, 189, 248, 0.22);
+ border-radius: 4px;
+ padding: 2px 7px;
+ transition: all 0.2s ease;
+}
+
+.forecast-env-group:focus-within {
+ border-color: rgba(56, 189, 248, 0.6);
+ box-shadow: 0 0 8px rgba(56, 189, 248, 0.25);
+}
+
+.env-tag {
+ font-size: 10.5px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.env-tag.blue { color: #38bdf8; }
+.env-tag.green { color: #10b981; }
+.env-tag.yellow { color: #f59e0b; }
+
+.forecast-env-input {
+ width: 48px;
+ background: rgba(15, 29, 58, 0.85);
+ border: 1px solid rgba(56, 189, 248, 0.35);
+ border-radius: 3px;
+ color: #f8fafc;
+ font-size: 11px;
+ font-family: inherit;
+ font-weight: 600;
+ padding: 2px 5px;
+ text-align: right;
+ outline: none;
+ transition: all 0.2s ease;
+}
+
+.forecast-env-input:focus {
+ border-color: #38bdf8;
+ box-shadow: 0 0 6px rgba(56, 189, 248, 0.5);
+}
+
+.forecast-env-input.mini {
+ width: 36px;
+}
+
+.env-unit {
+ font-size: 10px;
+ color: #94a3b8;
+}
+
+.forecast-recalc-btn {
+ background: linear-gradient(135deg, rgba(56, 189, 248, 0.4) 0%, rgba(2, 132, 199, 0.65) 100%);
+ border: 1px solid rgba(56, 189, 248, 0.7);
+ color: #ffffff;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 4px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.forecast-recalc-btn:hover {
+ background: linear-gradient(135deg, rgba(56, 189, 248, 0.65) 0%, rgba(2, 132, 199, 0.9) 100%);
+ border-color: #38bdf8;
+ color: #ffffff;
+ box-shadow: 0 0 12px rgba(56, 189, 248, 0.5);
+}
+
+.forecast-reset-btn {
+ background: rgba(148, 163, 184, 0.1);
+ border: 1px solid rgba(148, 163, 184, 0.3);
+ color: #cbd5e1;
+ font-size: 10.5px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+}
+
+.forecast-reset-btn:hover {
+ border-color: rgba(148, 163, 184, 0.6);
+ color: #ffffff;
+ background: rgba(148, 163, 184, 0.2);
+}
+
+.forecast-charts-row {
+ display: grid;
+ grid-template-columns: 58% 42%;
+ gap: 16px;
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+}
+
+.center-chart-card-title-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 6px;
+ gap: 8px;
+}
+
+.forecast-env-pill {
+ font-size: 10px;
+ color: #38bdf8;
+ background: rgba(56, 189, 248, 0.1);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ border-radius: 12px;
+ padding: 1px 8px;
+ white-space: nowrap;
+}
+
+.center-chart-card {
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ padding: 4px 6px;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.center-chart-card:last-child {
+ border-left: 1px solid rgba(56, 189, 248, 0.12);
+ padding-left: 20px;
+}
+
+.center-chart-card-title {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: #94a3b8;
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.center-chart-card-title::before {
+ content: "";
+ display: inline-block;
+ width: 3px;
+ height: 11px;
+ background: var(--brand-primary);
+ border-radius: 2px;
+}
+
+.center-chart-canvas {
+ flex: 1;
+ width: 100%;
+ min-height: 0;
+}
+
+/* 下方点阵控制区图表切换高亮按钮 */
+.mini-grid-item.chart-switch-btn {
+ background: linear-gradient(135deg, rgba(2, 132, 199, 0.35) 0%, rgba(16, 185, 129, 0.25) 100%) !important;
+ border-color: rgba(56, 189, 248, 0.6) !important;
+ box-shadow: 0 0 10px rgba(56, 189, 248, 0.25);
+ min-width: 90px;
+}
+
+.mini-grid-item.chart-switch-btn:hover {
+ background: linear-gradient(135deg, rgba(2, 132, 199, 0.5) 0%, rgba(16, 185, 129, 0.4) 100%) !important;
+ border-color: #38bdf8 !important;
+ box-shadow: 0 0 16px rgba(56, 189, 248, 0.5);
+}
+
+.mini-grid-item.chart-switch-btn.active {
+ background: linear-gradient(135deg, #0284c7 0%, #10b981 100%) !important;
+ border-color: #ffffff !important;
+ color: #ffffff !important;
+ box-shadow: 0 0 16px rgba(56, 189, 248, 0.7);
+}
+
+.mini-grid-item.chart-switch-btn.active .mini-grid-name,
+.mini-grid-item.chart-switch-btn.active .mini-grid-type {
+ color: #ffffff !important;
+}
+
+#btn-chart-forecast {
+ background: linear-gradient(135deg, rgba(16, 185, 129, 0.3) 0%, rgba(6, 182, 212, 0.28) 100%) !important;
+ border-color: rgba(52, 211, 153, 0.7) !important;
+ box-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
+}
+
+#btn-chart-forecast:hover {
+ background: linear-gradient(135deg, rgba(16, 185, 129, 0.45) 0%, rgba(6, 182, 212, 0.42) 100%) !important;
+ border-color: #34d399 !important;
+ box-shadow: 0 0 16px rgba(52, 211, 153, 0.55);
+}
+
+#btn-chart-forecast.active {
+ background: linear-gradient(135deg, #059669 0%, #0891b2 100%) !important;
+ border-color: #a7f3d0 !important;
+ box-shadow: 0 0 18px rgba(16, 185, 129, 0.8) !important;
+}
+
+/* 全量长条卡片平滑滚动容器与 Tab 样式 */
+.quality-strip-filter-bar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ margin-bottom: 3px;
+ flex-shrink: 0;
+}
+
+.quality-strip-scrollable {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 4px !important;
+ padding-right: 3px;
+ min-height: 0;
+}
+
+.quality-strip-scrollable::-webkit-scrollbar {
+ width: 4px;
+}
+
+.quality-strip-scrollable::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 2px;
+}
+
+.quality-strip-scrollable::-webkit-scrollbar-thumb {
+ background: rgba(56, 189, 248, 0.28);
+ border-radius: 2px;
+}
+
+.quality-strip-scrollable::-webkit-scrollbar-thumb:hover {
+ background: rgba(56, 189, 248, 0.55);
+}
+
+/* 仓容负荷进度舱 */
+.capacity-progress-box {
+ background: var(--panel-bg-sub);
+ border: 1px solid rgba(56, 189, 248, 0.08);
+ border-radius: 6px;
+ padding: 7px 10px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.progress-bar-bg {
+ flex: 1;
+ height: 6px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+ overflow: hidden;
+ margin-top: 4px;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #0284c7 0%, #38bdf8 50%, #10b981 100%);
+ border-radius: 4px;
+ transition: width 0.4s ease;
+ box-shadow: 0 0 8px rgba(56, 189, 248, 0.4);
+}
+
+/* ================= 中央开阔区悬浮过滤条 ================= */
+.granary-filter-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: rgba(6, 14, 32, 0.55);
+ border: 1px solid rgba(56, 189, 248, 0.2);
+ border-radius: 20px;
+ padding: 4px 14px;
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
+ margin-top: 2px;
+}
+
+.filter-bar-title {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: #bae6fd;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.filter-btn-group {
+ display: flex;
+ gap: 6px;
+}
+
+.filter-btn {
+ background: rgba(56, 189, 248, 0.06);
+ border: 1px solid rgba(56, 189, 248, 0.2);
+ color: var(--text-sub);
+ padding: 3px 9px;
+ border-radius: 4px;
+ font-size: 10.5px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.filter-btn:hover {
+ background: rgba(56, 189, 248, 0.15);
+ color: #fff;
+}
+
+.filter-btn.active {
+ background: linear-gradient(180deg, rgba(56, 189, 248, 0.3) 0%, rgba(2, 132, 199, 0.4) 100%);
+ border-color: var(--brand-primary);
+ color: #ffffff;
+ font-weight: 700;
+ box-shadow: 0 0 8px rgba(56, 189, 248, 0.35);
+}
+
+/* ================= 3D 数字孪生悬浮芯片与纤细镭射引导线 ================= */
+.fullscreen-hotspots-layer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+
+.hotspot-pin {
+ position: absolute;
+ transform: translate(-50%, -100%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ cursor: pointer;
+ pointer-events: auto;
+ transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), z-index 0.2s ease;
+ z-index: 12;
+}
+
+.hotspot-pin:hover {
+ transform: translate(-50%, -108%) scale(1.08);
+ z-index: 25;
+}
+
+.hotspot-pin.active {
+ transform: translate(-50%, -112%) scale(1.18);
+ z-index: 35;
+}
+
+/* 气泡本体容器 (水滴气泡微晶玻璃) */
+.bubble-inner {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 6px;
+ background: rgba(6, 14, 32, 0.88);
+ border: 1px solid rgba(56, 189, 248, 0.6);
+ border-radius: 5px;
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.65), 0 0 10px rgba(56, 189, 248, 0.25);
+ font-family: var(--font-number);
+ white-space: nowrap;
+}
+
+.bubble-name {
+ font-size: 10.5px;
+ font-weight: 700;
+ color: #ffffff;
+ letter-spacing: 0.2px;
+}
+
+.bubble-tag {
+ font-size: 9px;
+ font-weight: 600;
+ padding: 1px 3px;
+ border-radius: 3px;
+ background: rgba(56, 189, 248, 0.18);
+ color: var(--brand-primary);
+}
+
+/* 气泡底部精准扎在建筑上的倒三角小箭头 */
+.bubble-arrow {
+ width: 0;
+ height: 0;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 4px solid rgba(56, 189, 248, 0.75);
+ filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.6));
+}
+
+/* 小麦品种气泡 (默认科技蓝) */
+.hotspot-pin.wheat .bubble-inner {
+ border-color: rgba(56, 189, 248, 0.65);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.65), 0 0 10px rgba(56, 189, 248, 0.3);
+}
+
+.hotspot-pin.wheat .bubble-tag {
+ background: rgba(56, 189, 248, 0.2);
+ color: #38bdf8;
+}
+
+.hotspot-pin.wheat .bubble-arrow {
+ border-top-color: rgba(56, 189, 248, 0.85);
+}
+
+/* 稻谷品种气泡 (生态翡翠绿) */
+.hotspot-pin.paddy .bubble-inner {
+ border-color: rgba(16, 185, 129, 0.75);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.65), 0 0 10px rgba(16, 185, 129, 0.35);
+}
+
+.hotspot-pin.paddy .bubble-tag {
+ background: rgba(16, 185, 129, 0.25);
+ color: #10b981;
+}
+
+.hotspot-pin.paddy .bubble-arrow {
+ border-top-color: rgba(16, 185, 129, 0.9);
+}
+
+/* 油罐品种气泡 (温润琥珀金 · 紧凑精致防重叠) */
+.hotspot-pin.oil .bubble-inner {
+ padding: 2.5px 5px;
+ gap: 3px;
+ background: rgba(24, 16, 6, 0.92);
+ border-color: rgba(245, 158, 11, 0.75);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.65), 0 0 10px rgba(245, 158, 11, 0.4);
+}
+
+.hotspot-pin.oil .bubble-name {
+ font-size: 10px;
+}
+
+.hotspot-pin.oil .bubble-tag {
+ font-size: 8.5px;
+ padding: 1px 2.5px;
+ background: rgba(245, 158, 11, 0.25);
+ color: #fbbf24;
+}
+
+.hotspot-pin.oil .bubble-arrow {
+ border-top-color: rgba(245, 158, 11, 0.9);
+}
+
+/* 错层避让引导线与定位点 (科技琥珀金发光引线直达罐顶) */
+.bubble-stem {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform-origin: top center;
+ transform: translateX(-50%);
+ width: 1.5px;
+ background: linear-gradient(180deg, rgba(245, 158, 11, 0.9) 0%, rgba(245, 158, 11, 0.4) 80%, rgba(251, 191, 36, 1) 100%);
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.6);
+ pointer-events: none;
+}
+
+.stem-dot {
+ position: absolute;
+ bottom: -3px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #fbbf24;
+ box-shadow: 0 0 6px #fbbf24, 0 0 10px rgba(245, 158, 11, 0.8);
+}
+
+.hotspot-pin.active .bubble-stem {
+ width: 2px;
+ background: linear-gradient(180deg, #fbbf24 0%, #ffffff 100%);
+ box-shadow: 0 0 10px #fbbf24;
+}
+
+.hotspot-pin.active .stem-dot {
+ background: #ffffff;
+ box-shadow: 0 0 10px #ffffff, 0 0 16px #fbbf24;
+}
+
+/* 激活状态高光与聚焦光晕 */
+.hotspot-pin.active .bubble-inner {
+ background: linear-gradient(135deg, #0284c7 0%, #38bdf8 100%);
+ border-color: #ffffff;
+ box-shadow: 0 0 20px rgba(56, 189, 248, 0.95), 0 4px 16px rgba(0, 0, 0, 0.8);
+}
+
+.hotspot-pin.active .bubble-name {
+ color: #030712;
+ font-weight: 800;
+}
+
+.hotspot-pin.active .bubble-tag {
+ background: rgba(0, 0, 0, 0.25);
+ color: #ffffff;
+}
+
+.hotspot-pin.active .bubble-arrow {
+ border-top-color: #38bdf8;
+}
+
+.hotspot-pin.oil.active .bubble-inner {
+ background: linear-gradient(135deg, #d97706 0%, #fbbf24 100%);
+ border-color: #ffffff;
+ box-shadow: 0 0 20px rgba(245, 158, 11, 0.95), 0 4px 16px rgba(0, 0, 0, 0.8);
+}
+
+.hotspot-pin.oil.active .bubble-arrow {
+ border-top-color: #fbbf24;
+}
+
+.hotspot-pin.paddy.active .bubble-inner {
+ background: linear-gradient(135deg, #059669 0%, #10b981 100%);
+ border-color: #ffffff;
+ box-shadow: 0 0 20px rgba(16, 185, 129, 0.95), 0 4px 16px rgba(0, 0, 0, 0.8);
+}
+
+.hotspot-pin.paddy.active .bubble-arrow {
+ border-top-color: #10b981;
+}
+
+/* ================= 规范列表与微状态 ================= */
+.spec-list {
+ font-size: 10.5px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ color: var(--text-sub);
+}
+
+.spec-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 7px;
+ background: var(--panel-bg-sub);
+ border-radius: 4px;
+ border: 1px solid rgba(56, 189, 248, 0.06);
+ min-width: 0;
+}
+
+.spec-label {
+ font-size: 10px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 1;
+}
+
+.spec-bullet {
+ display: inline-block;
+ width: 4px;
+ height: 4px;
+ background: var(--brand-primary);
+ border-radius: 50%;
+ margin-right: 5px;
+ vertical-align: middle;
+ flex-shrink: 0;
+}
+
+.spec-status {
+ font-size: 9.5px;
+ font-weight: 600;
+ padding: 1px 5px;
+ border-radius: 3px;
+ white-space: nowrap;
+ flex-shrink: 0;
+ text-align: center;
+ min-width: 38px;
+}
+
+.spec-status.green {
+ background: rgba(16, 185, 129, 0.12);
+ color: var(--status-normal);
+ border: 1px solid rgba(16, 185, 129, 0.25);
+}
+
+.spec-status.blue {
+ background: rgba(56, 189, 248, 0.12);
+ color: var(--brand-primary);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+}
+
+.spec-status.yellow {
+ background: rgba(245, 158, 11, 0.12);
+ color: var(--grain-oil);
+ border: 1px solid rgba(245, 158, 11, 0.25);
+}
+
+/* ================= 底部 20 仓罐快捷导航矩阵 (Cyber Floating Dock) ================= */
+.granary-mini-grid {
+ display: grid;
+ grid-template-columns: repeat(10, 1fr);
+ gap: 7px 8px; /* 横纵间距更协调大方 */
+ background: linear-gradient(180deg, rgba(8, 18, 42, 0.9) 0%, rgba(4, 9, 24, 0.96) 100%);
+ padding: 10px 14px; /* 增加内边距,底座更饱满 */
+ border-radius: 9px;
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ border-top: 1.5px solid rgba(56, 189, 248, 0.6);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ box-shadow: 0 -8px 28px rgba(0, 0, 0, 0.7), 0 0 25px rgba(56, 189, 248, 0.12);
+ margin-bottom: 2px;
+}
+
+.mini-btn {
+ background: rgba(56, 189, 248, 0.06);
+ border: 1px solid rgba(56, 189, 248, 0.2);
+ border-radius: 5px;
+ min-height: 31px; /* 明显提升高度 */
+ padding: 6px 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-main);
+ cursor: pointer;
+ transition: all 0.22s cubic-bezier(0.25, 1, 0.5, 1);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: var(--font-number);
+ font-weight: 500;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+}
+
+.mini-btn:hover {
+ border-color: var(--brand-primary);
+ color: #fff;
+ background: rgba(56, 189, 248, 0.22);
+ box-shadow: 0 0 10px rgba(56, 189, 248, 0.35);
+ transform: translateY(-1px);
+}
+
+.mini-btn.active {
+ background: linear-gradient(180deg, rgba(56, 189, 248, 0.45) 0%, rgba(2, 132, 199, 0.6) 100%);
+ border-color: #38bdf8;
+ color: #ffffff;
+ font-weight: 700;
+ box-shadow: 0 0 14px rgba(56, 189, 248, 0.65), inset 0 0 10px rgba(56, 189, 248, 0.3);
+}
+
+.mini-btn.oil {
+ border-left: 3px solid var(--grain-oil);
+}
+
+.mini-btn.wheat {
+ border-left: 3px solid var(--grain-wheat);
+}
+
+.mini-btn.paddy {
+ border-left: 3px solid var(--grain-paddy);
+}
+
+.mini-btn .btn-name {
+ font-weight: 600;
+ color: var(--text-title);
+ letter-spacing: 0.2px;
+}
+
+.mini-btn .btn-grain {
+ font-size: 9.5px;
+ color: var(--text-sub);
+ opacity: 0.85;
+}
+
+.mini-btn.active .btn-grain {
+ color: rgba(255, 255, 255, 0.9);
+ opacity: 1;
+}
+
+/* 图表容器 */
+.chart-box {
+ width: 100%;
+ flex: 1;
+ min-height: 135px;
+}
+
+/* ================= 底部 Footer ================= */
+.screen-footer {
+ position: relative;
+ z-index: 20;
+ height: 26px;
+ background: rgba(5, 11, 24, 0.7);
+ backdrop-filter: blur(16px);
+ border-top: 1px solid rgba(56, 189, 248, 0.12);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 20px;
+ font-size: 10.5px;
+ color: var(--text-muted);
+}
+
+.footer-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.footer-item strong {
+ color: #cbd5e1;
+}
+
+.status-indicator {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--status-normal);
+ box-shadow: 0 0 6px var(--status-normal);
+}
+
+/* 习水子库 16:9 4K (3840x2160) 底图视口 */
+.aerial-viewport-box.xishui-viewport {
+ width: 1360px;
+ height: 765px;
+}
+
+/* 多库区快速切换胶囊导航 */
+.depot-switch-group {
+ display: inline-flex;
+ align-items: center;
+ background: rgba(15, 23, 42, 0.7);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ border-radius: 16px;
+ padding: 2px;
+ gap: 2px;
+ backdrop-filter: blur(8px);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.btn-depot {
+ background: transparent;
+ border: none;
+ color: var(--text-sub);
+ padding: 4px 12px;
+ font-size: 11px;
+ border-radius: 14px;
+ cursor: pointer;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ user-select: none;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.btn-depot:hover {
+ color: #f8fafc;
+ background: rgba(56, 189, 248, 0.12);
+}
+
+.btn-depot.active {
+ background: linear-gradient(135deg, rgba(2, 132, 199, 0.8) 0%, rgba(56, 189, 248, 0.8) 100%);
+ color: #ffffff;
+ font-weight: 600;
+ box-shadow: 0 0 10px rgba(56, 189, 248, 0.4);
+}
+
+/* 习水子库 11 仓单排紧凑点阵 */
+.granary-mini-grid.xishui-mini-grid {
+ grid-template-columns: repeat(11, 1fr);
+ gap: 6px;
+ padding: 8px 10px;
+}
+
+
+
diff --git a/public/screen/index.html b/public/screen/index.html
new file mode 100644
index 0000000..e6d6f75
--- /dev/null
+++ b/public/screen/index.html
@@ -0,0 +1,420 @@
+
+
+
+
+
+ 中储粮(遵义)智慧粮仓综合监测中心
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
全库核定总仓/罐容
+
+ 8.81
+ 万吨
+
+
+
+
+
+
+
+
当前实际总储量
+
+ 9.11
+ 万吨
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 建筑类型
+ 高大平仓
+
+
+ 储粮/油品种
+ 稻谷
+
+
+ 储粮性质
+ 中央储备粮 (ZC)
+
+
+ 粮源/产地
+ 湖北
+
+
+ 收获/加工年份
+ 2021
+
+
+ 入库年月
+ 2022-04
+
+
+ 责任保管员
+ 陈俊
+
+
+ 管理形式
+ 直接管理
+
+
+
+
+
+
+
+
+
+
+
+ 实存: 4965.46 吨
+ 设计容量: 6026 吨
+
+
+
+
+
+
+ 空余仓容: 充足
+ 气密性能: 达标
+ 安全评级: 正常
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 遵义直属库有限公司中心库区 · 数字孪生全景监测
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 推演环境工况 (API输入):
+
+
+
+
+
+
+
+
+
+
粮食质量指标多周期演变趋势推演 (未来 7~60天走势与国家警戒线)
+
工况: 仓内 18.5℃ / 72.4% · 仓外 32.5℃ / 54.2%
+
+
+
+
+
+
+
现状实测值 vs 预测终值偏离对比与风险评估
+
+
+
+
+
+
+
+
+
+
+
+
+
仓房/油罐实储量与设计容量负荷对比 (吨)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/screen/js/app.js b/public/screen/js/app.js
new file mode 100644
index 0000000..3dc28d7
--- /dev/null
+++ b/public/screen/js/app.js
@@ -0,0 +1,1144 @@
+import {
+ calcScreenScale,
+ normalizeGranaryList,
+ formatGranaryParams,
+ aggregateApiGranaryData,
+ DESIGN_THEME,
+ DEFAULT_GRANARY_DATA,
+ buildQualityHtml,
+ buildQualityHudHtml,
+ mapVarietyToForecastType,
+ generateForecastSeries,
+ buildForecastTrendChartOption,
+ buildForecastCompareChartOption,
+ fetchGranaryForecast,
+ getGranaryQualityReport,
+ DEFAULT_AERIAL_MODE,
+ getAerialModeConfig,
+ getNextAerialMode,
+ CENTER_HOTSPOT_COORDS,
+ generateDepotAlerts
+} from './grain_monitor_core.js';
+
+// 全局状态
+const STATE = {
+ activeId: 29, // 默认 31号仓房 (小麦优质样本)
+ filterType: 'all', // 'all' | 'warehouse' | 'oilTank'
+ qualityCategory: 'all', // 'all' | 'core' | 'physical' | 'safety'
+ isCenterChartOpen: false, // 居中大图表看板状态
+ centerChartTab: 'forecast', // 默认质量智能预测 'forecast' | 'macro'
+ autoPlay: false,
+ playTimer: null,
+ aerialMode: localStorage.getItem('depot_aerial_mode') || DEFAULT_AERIAL_MODE,
+ list: [],
+ envData: null,
+ customCoords: null, // 从后台同步的自定义3D热点坐标
+ summary: null,
+ charts: {
+ varietyPie: null,
+ granaryBar: null,
+ forecastTrend: null,
+ forecastCompare: null
+ },
+ forecastCache: {}
+};
+
+// 1. 电视大屏自适应缩放 (1920x1080)
+function resizeScreen() {
+ const wrapper = document.getElementById('screen-wrapper');
+ if (!wrapper) return;
+ const winW = window.innerWidth;
+ const winH = window.innerHeight;
+ const scaleInfo = calcScreenScale(winW, winH, 1920, 1080);
+
+ wrapper.style.transform = `translate(${scaleInfo.offsetX}px, ${scaleInfo.offsetY}px) scale(${scaleInfo.scale})`;
+
+ if (STATE.charts.varietyPie) {
+ STATE.charts.varietyPie.resize();
+ }
+ if (STATE.charts.granaryBar) {
+ STATE.charts.granaryBar.resize();
+ }
+ if (STATE.charts.forecastTrend) {
+ STATE.charts.forecastTrend.resize();
+ }
+ if (STATE.charts.forecastCompare) {
+ STATE.charts.forecastCompare.resize();
+ }
+}
+
+// 2. 实时时钟
+function updateClock() {
+ const clockEl = document.getElementById('header-clock');
+ const dateEl = document.getElementById('header-date');
+ if (!clockEl) return;
+
+ const now = new Date();
+ const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false });
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, '0');
+ const date = String(now.getDate()).padStart(2, '0');
+ const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
+ const dayStr = weekDays[now.getDay()];
+
+ clockEl.textContent = timeStr;
+ if (dateEl) {
+ dateEl.textContent = `${year}-${month}-${date} ${dayStr}`;
+ }
+}
+
+// 3. 加载数据
+async function fetchGranaryData() {
+ const endpoints = ['/api/index/granaryList', 'https://grain.gynhc.com/api/index/granaryList'];
+ for (const apiUrl of endpoints) {
+ try {
+ const res = await fetch(apiUrl, { cache: 'no-cache', signal: AbortSignal.timeout(2000) });
+ if (!res.ok) continue;
+ const json = await res.json();
+ if (json && json.data && Array.isArray(json.data.list) && json.data.list.length > 0) {
+ let list = json.data.list;
+ // 业务规范对齐:若线上外部API尚未同步最新规划 (缺少44号仓),自动执行“新增的是43,后面的仓库递增”
+ if (!list.some(item => item.name && item.name.includes('44号'))) {
+ list = list.map(item => {
+ if (item.name === '43号仓房' || (item.id === 46 && item.name.includes('43'))) {
+ return {
+ ...item,
+ name: '44号仓房',
+ num: 44,
+ content: item.content ? { ...item.content, code: '44', warehouseType: '高大平房仓' } : item.content
+ };
+ }
+ return item;
+ });
+
+ if (!list.some(item => item.id === 51 || item.name === '43号仓房')) {
+ const default43 = DEFAULT_GRANARY_DATA?.data?.list?.find(i => i.id === 51);
+ if (default43) {
+ list.push(default43);
+ }
+ }
+ }
+ STATE.list = normalizeGranaryList({ ...json, data: { ...json.data, list } });
+ if (json.data && json.data.coords) {
+ STATE.customCoords = json.data.coords;
+ }
+ console.log(`✓ 成功从 [${apiUrl}] 在线加载`, STATE.list.length, '个仓房/油罐数据');
+ return;
+ }
+ } catch (err) {
+ // 当前端点连接失败,自动尝试下一端点
+ }
+ }
+
+ console.warn('在线与本地 API 均不可用,使用内置官方完整数据集');
+ STATE.list = normalizeGranaryList(DEFAULT_GRANARY_DATA);
+}
+
+// 3.1 加载微气候环境工况
+async function fetchEnvData() {
+ try {
+ const res = await fetch('/api/granary/env?site=center', { cache: 'no-cache' });
+ if (!res.ok) return;
+ const json = await res.json();
+ if (json && json.code === 0 && json.data) {
+ const d = json.data;
+ STATE.envData = d;
+ const inpTmI = document.getElementById('input-forecast-tm-i');
+ const inpTmO = document.getElementById('input-forecast-tm-o');
+ const inpHmI = document.getElementById('input-forecast-hm-i');
+ const inpHmO = document.getElementById('input-forecast-hm-o');
+ if (inpTmI && d.tm_i !== undefined) inpTmI.value = d.tm_i;
+ if (inpTmO && d.tm_o !== undefined) inpTmO.value = d.tm_o;
+ if (inpHmI && d.hm_i !== undefined) inpHmI.value = d.hm_i;
+ if (inpHmO && d.hm_o !== undefined) inpHmO.value = d.hm_o;
+ const pill = document.getElementById('forecast-trend-env-pill');
+ if (pill) {
+ pill.textContent = `工况: 仓内 ${d.tm_i}℃ / ${d.hm_i}% | 仓外 ${d.tm_o}℃ / ${d.hm_o}%`;
+ }
+ }
+ } catch (e) {}
+}
+
+// 3.2 渲染全库储粮安全感知与预警走马灯
+function renderDepotAlerts() {
+ const marqueeTrack = document.getElementById('alert-marquee-track');
+ if (!marqueeTrack) return;
+ const alerts = generateDepotAlerts(STATE.list, STATE.envData);
+ marqueeTrack.innerHTML = '';
+ const displayAlerts = alerts.length > 0 ? (alerts.length === 1 ? alerts : alerts.concat(alerts)) : [];
+ displayAlerts.forEach(alert => {
+ const pill = document.createElement('div');
+ pill.className = `alert-item-pill ${alert.level || 'normal'}`;
+ pill.innerHTML = `
+
+ ${alert.granaryName || '安全监控'}
+ ${alert.text}
+ `;
+ if (alert.granaryId) {
+ pill.style.cursor = 'pointer';
+ pill.title = '点击聚焦该仓房';
+ pill.onclick = (e) => {
+ e.stopPropagation();
+ selectGranary(alert.granaryId, true);
+ };
+ }
+ marqueeTrack.appendChild(pill);
+ });
+}
+
+// 4. 渲染顶部 KPIs
+function renderKPIs() {
+ STATE.summary = aggregateApiGranaryData(STATE.list);
+ const s = STATE.summary;
+
+ document.getElementById('kpi-total-capacity').textContent = (s.totalCapacity / 10000).toFixed(2);
+ document.getElementById('kpi-total-stock').textContent = (s.totalStock / 10000).toFixed(2);
+ document.getElementById('kpi-utilization').textContent = `${s.utilizationRate}%`;
+ document.getElementById('kpi-warehouse-count').textContent = `${s.warehouseCount} 栋`;
+ document.getElementById('kpi-oil-count').textContent = `${s.oilTankCount} 座`;
+}
+
+// 5. 渲染 3D 鸟瞰图全景热点图钉 (20 个仓房/油罐全量覆盖,像素级对准与错层防重叠)
+function renderHotspots() {
+ const container = document.getElementById('aerial-hotspots');
+ if (!container) return;
+
+ const hotspotCoords = STATE.customCoords ? { ...CENTER_HOTSPOT_COORDS, ...STATE.customCoords } : CENTER_HOTSPOT_COORDS;
+
+ container.innerHTML = '';
+
+ STATE.list.forEach(item => {
+ const coord = hotspotCoords[item.id];
+ if (!coord) return;
+
+ const isOil = item.isOilTank;
+ const isVisible = STATE.filterType === 'all' ||
+ (STATE.filterType === 'warehouse' && !isOil) ||
+ (STATE.filterType === 'oilTank' && isOil);
+
+ if (!isVisible) return;
+
+ const isActive = item.id === STATE.activeId;
+ const typeClass = isOil ? 'oil' : (item.grainName === '稻谷' ? 'paddy' : 'wheat');
+ const hasStem = !!coord.stemY;
+ const pin = document.createElement('div');
+ pin.className = `hotspot-pin ${typeClass} ${isActive ? 'active' : ''} ${hasStem ? 'has-stem' : ''}`;
+ pin.style.left = `${coord.x}%`;
+ pin.style.top = `${coord.y}%`;
+
+ const shortName = item.name.replace('号仓房', '号仓').replace('号油罐', '号罐');
+
+ let stemHtml = '';
+ if (hasStem) {
+ const stemX = coord.stemX !== undefined ? coord.stemX : coord.x;
+ const dxPx = (stemX - coord.x) * 13.6;
+ const dyPx = (coord.stemY - coord.y) * 7.59;
+ const lengthPx = Math.hypot(dxPx, dyPx);
+ const angleDeg = Math.atan2(-dxPx, dyPx) * (180 / Math.PI);
+ stemHtml = `
+
+
+
+ `;
+ }
+
+ pin.innerHTML = `
+
+ ${shortName}
+ ${item.grainName}
+
+
+ ${stemHtml}
+ `;
+
+ pin.onclick = (e) => {
+ e.stopPropagation();
+ selectGranary(item.id, true);
+ };
+
+ container.appendChild(pin);
+ });
+}
+
+// 6. 渲染底部仓房/油罐快捷导航条 (纯净 20 仓罐双排点阵)
+function renderMiniGrid() {
+ const container = document.getElementById('granary-mini-grid');
+ if (!container) return;
+ container.innerHTML = '';
+
+ // 仓房/油罐按钮列表
+ STATE.list.forEach(item => {
+ const isOil = item.isOilTank;
+ const isVisible = STATE.filterType === 'all' ||
+ (STATE.filterType === 'warehouse' && !isOil) ||
+ (STATE.filterType === 'oilTank' && isOil);
+ if (!isVisible) return;
+
+ const btn = document.createElement('div');
+ const typeClass = isOil ? 'oil' : (item.grainName === '稻谷' ? 'paddy' : 'wheat');
+ const isActive = item.id === STATE.activeId;
+ btn.className = `mini-btn ${typeClass} ${isActive ? 'active' : ''}`;
+ const shortName = item.name.replace('号仓房', '号仓').replace('号油罐', '号罐');
+ btn.innerHTML = `${shortName}${item.grainName}`;
+ btn.title = `${item.name} (${item.grainName}) - 实存 ${item.storageNum} 吨`;
+ btn.onclick = () => selectGranary(item.id, true);
+ container.appendChild(btn);
+ });
+}
+
+// 7. 切换选中的仓房/油罐 (支持点击直接呼出居中大盘图表)
+function selectGranary(id, openChart = false) {
+ STATE.activeId = id;
+ if (openChart) {
+ STATE.isCenterChartOpen = true;
+ STATE.centerChartTab = 'forecast';
+ const board = document.getElementById('center-chart-board');
+ if (board) {
+ board.style.display = 'flex';
+ switchCenterChartTab('forecast');
+ }
+ const closeBtn = document.getElementById('btn-close-center-chart');
+ if (closeBtn) {
+ closeBtn.onclick = () => toggleCenterChartBoard(STATE.centerChartTab, false);
+ }
+ bindCenterChartTabs();
+ } else if (STATE.isCenterChartOpen) {
+ if (STATE.centerChartTab === 'forecast') {
+ initOrUpdateCenterCharts();
+ }
+ }
+ renderMiniGrid();
+ renderHotspots();
+ renderDetailPanels();
+}
+
+// 居中大图表看板切换控制器
+function toggleCenterChartBoard(targetTab = 'forecast', forceState) {
+ const reqTab = typeof targetTab === 'string' ? targetTab : (STATE.centerChartTab || 'forecast');
+
+ if (typeof forceState === 'boolean') {
+ STATE.isCenterChartOpen = forceState;
+ } else if (typeof targetTab === 'string') {
+ if (STATE.isCenterChartOpen && STATE.centerChartTab === targetTab) {
+ STATE.isCenterChartOpen = false;
+ } else {
+ STATE.isCenterChartOpen = true;
+ STATE.centerChartTab = targetTab;
+ }
+ } else {
+ STATE.isCenterChartOpen = !STATE.isCenterChartOpen;
+ }
+
+ const board = document.getElementById('center-chart-board');
+ if (board) {
+ board.style.display = STATE.isCenterChartOpen ? 'flex' : 'none';
+ if (STATE.isCenterChartOpen) {
+ switchCenterChartTab(STATE.centerChartTab);
+ }
+ }
+
+ // 同步绑定关闭按钮与模式切换 Tab
+ const closeBtn = document.getElementById('btn-close-center-chart');
+ if (closeBtn) {
+ closeBtn.onclick = () => toggleCenterChartBoard(STATE.centerChartTab, false);
+ }
+ bindCenterChartTabs();
+ renderMiniGrid();
+}
+
+// 绑定大盘顶部模式切换 Tab
+function bindCenterChartTabs() {
+ const macroTab = document.getElementById('tab-chart-macro');
+ const forecastTab = document.getElementById('tab-chart-forecast');
+ if (macroTab) {
+ macroTab.onclick = () => switchCenterChartTab('macro');
+ }
+ if (forecastTab) {
+ forecastTab.onclick = () => switchCenterChartTab('forecast');
+ }
+}
+
+// 切换大盘模式 (宏观仓容品种 vs 粮食质量智能预测)
+function switchCenterChartTab(tabName) {
+ STATE.centerChartTab = tabName;
+ const macroTabBtn = document.getElementById('tab-chart-macro');
+ const forecastTabBtn = document.getElementById('tab-chart-forecast');
+ const macroBody = document.getElementById('board-body-macro');
+ const forecastBody = document.getElementById('board-body-forecast');
+ const titleEl = document.getElementById('center-chart-header-title');
+
+ if (macroTabBtn && forecastTabBtn && macroBody && forecastBody) {
+ if (tabName === 'macro') {
+ macroTabBtn.classList.add('active');
+ forecastTabBtn.classList.remove('active');
+ macroBody.style.display = 'grid';
+ forecastBody.style.display = 'none';
+ if (titleEl) {
+ titleEl.textContent = '遵义直属库中心库区 · 全库储粮品种与仓容负荷综合大盘';
+ }
+ requestAnimationFrame(() => {
+ initOrUpdateVarietyPie();
+ initOrUpdateGranaryBar();
+ });
+ } else {
+ macroTabBtn.classList.remove('active');
+ forecastTabBtn.classList.add('active');
+ macroBody.style.display = 'none';
+ forecastBody.style.display = 'flex';
+
+ // 智能选取粮食仓房:若当前选中的是油罐,自动切换至第一个粮食平房仓/浅圆仓
+ let currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
+ if (currentItem && !mapVarietyToForecastType(currentItem.grainName || currentItem.varieties?.name)) {
+ const grainWarehouse = STATE.list.find(i => mapVarietyToForecastType(i.grainName || i.varieties?.name));
+ if (grainWarehouse) {
+ STATE.activeId = grainWarehouse.id;
+ currentItem = grainWarehouse;
+ renderDetailPanels();
+ }
+ }
+
+ requestAnimationFrame(() => {
+ initOrUpdateForecastCharts(currentItem);
+ });
+ }
+ }
+ renderMiniGrid();
+}
+
+// 8. 渲染左侧面板:基础档案 + 仓容负荷 + 空间物理尺寸
+function renderLeftPanel(item, params) {
+ if (!item || !params) return;
+
+ const b = params.basic;
+ const s = params.size;
+
+ document.getElementById('param-granary-name').textContent = item.name;
+ document.getElementById('param-granary-type').textContent = b.warehouseType;
+ document.getElementById('param-granary-variety').textContent = b.varieties;
+ document.getElementById('param-granary-nature').textContent = b.natureName;
+ document.getElementById('param-granary-addr').textContent = b.addr || b.productArea || '安徽/河南';
+ document.getElementById('param-granary-year').textContent = b.harvestYear || b.machiningYear || '2024';
+ document.getElementById('param-granary-enter').textContent = b.enterTime || '2024-08';
+ document.getElementById('param-granary-custodian').textContent = b.custodian || '肖胜利';
+ document.getElementById('param-granary-mgmt').textContent = b.managementForm || '直接管理';
+
+ // 仓容利用率
+ document.getElementById('param-capacity-val').textContent = b.capacity;
+ document.getElementById('param-storage-val').textContent = b.storageNum;
+ document.getElementById('param-util-percent').textContent = `${item.utilization}%`;
+ const fillBar = document.getElementById('param-util-bar');
+ if (fillBar) {
+ fillBar.style.width = `${Math.min(item.utilization, 100)}%`;
+ }
+
+ // 物理空间尺寸
+ const sizeGrid = document.getElementById('param-size-grid');
+ if (!sizeGrid) return;
+
+ if (item.isOilTank) {
+ sizeGrid.innerHTML = `
+
+ 油罐直径
+ ${s.diameter}
+
+
+ 装油高度
+ ${s.oilHeight}
+
+
+ 储油体积
+ ${s.oilVolume}
+
+
+ 储油方式
+ 立式储油罐密闭
+
+ `;
+ } else {
+ sizeGrid.innerHTML = `
+
+ 仓房长度
+ ${s.length}
+
+
+ 仓房跨度(宽)
+ ${s.width}
+
+
+ 装粮线高
+ ${s.height}
+
+
+ 粮堆体积
+ ${s.volume}
+
+
+ 扣除体积
+ ${s.decVolume}
+
+
+ 储粮方式
+ ${b.storageMethod || '散装储粮'}
+
+ `;
+ }
+}
+
+// 绑定右侧全量条形卡片分类 Tab 切换事件
+function bindQualityTabs(container, report) {
+ if (!container || !report) return;
+ container.querySelectorAll('.quality-tab-btn').forEach(btn => {
+ btn.onclick = (e) => {
+ e.stopPropagation();
+ const cat = btn.dataset.cat;
+ STATE.qualityCategory = cat;
+ container.innerHTML = buildQualityHudHtml(report, cat);
+ bindQualityTabs(container, report);
+ };
+ });
+}
+
+// 9. 渲染右侧面板:方案A 智能质检与品质全参数孪生舱 (100%全高长条卡片)
+function renderRightPanel(item, params) {
+ if (!item || !params) return;
+
+ const qualityGrid = document.getElementById('param-quality-grid');
+ const qualityTitle = document.getElementById('param-quality-title');
+ const qualityExtra = qualityTitle ? qualityTitle.closest('.panel-title-bar')?.querySelector('.panel-extra') : null;
+ if (!qualityGrid) return;
+
+ const report = params.qualityReport;
+ if (!report) return;
+
+ // 标题与总体状态指示徽章 (短小精悍,杜绝截断)
+ if (qualityTitle) {
+ let statusBadge = '';
+ if (report.summary.dangerCount > 0) {
+ statusBadge = `⚠️ ${report.summary.dangerCount}项超标`;
+ } else if (report.summary.warnCount > 0) {
+ statusBadge = `⚠️ ${report.summary.warnCount}项预警`;
+ } else {
+ statusBadge = `✓ 健康达标`;
+ }
+ qualityTitle.innerHTML = `${report.variety}品质全参数舱${statusBadge}`;
+ }
+
+ // 标题栏右侧显示批次阶段与检验日期 (精炼格式)
+ if (qualityExtra) {
+ const stageShort = (report.inspectStage || '').split('/')[0].trim();
+ qualityExtra.innerHTML = `${stageShort} · ${report.inspectDate}`;
+ }
+
+ // 渲染方案A专属品质全参数舱 (全量水平条形卡片 + 分类Tab)
+ const curCat = STATE.qualityCategory || 'all';
+ qualityGrid.innerHTML = buildQualityHudHtml(report, curCat);
+ bindQualityTabs(qualityGrid, report);
+}
+
+// 初始化与更新中央大图表看板 (包含宏观大盘与质量预测推演)
+function initOrUpdateCenterCharts() {
+ bindCenterChartTabs();
+ if (STATE.centerChartTab === 'forecast') {
+ const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
+ initOrUpdateForecastCharts(currentItem);
+ } else {
+ initOrUpdateVarietyPie();
+ initOrUpdateGranaryBar();
+ }
+}
+
+// 渲染粮食质量智能预测趋势图与偏离对比图
+async function initOrUpdateForecastCharts(granaryItem) {
+ let currentItem = granaryItem || STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
+ if (!currentItem) return;
+
+ let grainName = currentItem.grainName || currentItem.varieties?.name || '粮食';
+ let grainType = mapVarietyToForecastType(grainName);
+
+ // 智能对齐:若当前选中的是油罐,自动切换至第一个粮食仓房 (小麦/稻谷) 确保 100% 呈现预测数据
+ if (!grainType) {
+ const grainWarehouse = STATE.list.find(i => mapVarietyToForecastType(i.grainName || i.varieties?.name));
+ if (grainWarehouse) {
+ STATE.activeId = grainWarehouse.id;
+ currentItem = grainWarehouse;
+ grainName = currentItem.grainName || currentItem.varieties?.name || '粮食';
+ grainType = mapVarietyToForecastType(grainName);
+ }
+ }
+
+ const titleEl = document.getElementById('center-chart-header-title');
+ const trendTitleEl = document.getElementById('forecast-trend-title');
+ const compareTitleEl = document.getElementById('forecast-compare-title');
+
+ if (titleEl) {
+ titleEl.textContent = `遵义直属库中心库区 · ${currentItem.name} (${grainName}) 质量指标演变智能预测推演`;
+ }
+
+ const trendDom = document.getElementById('chart-forecast-trend');
+ const compareDom = document.getElementById('chart-forecast-compare');
+ if (!trendDom || !compareDom || typeof echarts === 'undefined') return;
+
+ if (!STATE.charts.forecastTrend) {
+ STATE.charts.forecastTrend = echarts.init(trendDom);
+ }
+ if (!STATE.charts.forecastCompare) {
+ STATE.charts.forecastCompare = echarts.init(compareDom);
+ }
+
+ // 仓内外温湿度环境工况初始化与同步
+ if (!STATE.forecastEnv) {
+ STATE.forecastEnv = {
+ tm_i: 18.5,
+ tm_o: 32.5,
+ hm_i: 72.4,
+ hm_o: 54.2,
+ month: 7,
+ day: 13
+ };
+ }
+
+ const inputTmI = document.getElementById('input-forecast-tm-i');
+ const inputHmI = document.getElementById('input-forecast-hm-i');
+ const inputTmO = document.getElementById('input-forecast-tm-o');
+ const inputHmO = document.getElementById('input-forecast-hm-o');
+ const inputMonth = document.getElementById('input-forecast-month');
+ const inputDay = document.getElementById('input-forecast-day');
+ const pillEl = document.getElementById('forecast-trend-env-pill');
+
+ if (inputTmI) inputTmI.value = STATE.forecastEnv.tm_i;
+ if (inputHmI) inputHmI.value = STATE.forecastEnv.hm_i;
+ if (inputTmO) inputTmO.value = STATE.forecastEnv.tm_o;
+ if (inputHmO) inputHmO.value = STATE.forecastEnv.hm_o;
+ if (inputMonth) inputMonth.value = STATE.forecastEnv.month;
+ if (inputDay) inputDay.value = STATE.forecastEnv.day;
+
+ if (pillEl) {
+ pillEl.textContent = `工况: 仓内 ${STATE.forecastEnv.tm_i}℃ / ${STATE.forecastEnv.hm_i}% · 仓外 ${STATE.forecastEnv.tm_o}℃ / ${STATE.forecastEnv.hm_o}%`;
+ }
+
+ setupForecastEnvControls();
+
+ const currentReport = getGranaryQualityReport(grainName, currentItem.rawContent || currentItem.content || {});
+
+ // 1. 0 毫秒即时秒出基础图表 (杜绝任何白屏与残亮点)
+ const baselineList = [
+ {
+ day: 7,
+ targets: grainType === 'wheat'
+ ? { wheat_moisture: 11.85, wheat_GWAC: 200.6, wheat_DON: 136.0, wheat_ZEN: 7.08 }
+ : { paddy_moisture: 11.66, paddy_fatacid: 25.9, paddy_AFB1: 2.45 }
+ },
+ {
+ day: 15,
+ targets: grainType === 'wheat'
+ ? { wheat_moisture: 11.81, wheat_GWAC: 200.0, wheat_DON: 137.2, wheat_ZEN: 7.18 }
+ : { paddy_moisture: 11.62, paddy_fatacid: 26.4, paddy_AFB1: 2.52 }
+ },
+ {
+ day: 30,
+ targets: grainType === 'wheat'
+ ? { wheat_moisture: 11.72, wheat_GWAC: 198.8, wheat_DON: 139.5, wheat_ZEN: 7.36 }
+ : { paddy_moisture: 11.55, paddy_fatacid: 27.3, paddy_AFB1: 2.64 }
+ },
+ {
+ day: 60,
+ targets: grainType === 'wheat'
+ ? { wheat_moisture: 11.54, wheat_GWAC: 196.4, wheat_DON: 144.0, wheat_ZEN: 7.72 }
+ : { paddy_moisture: 11.40, paddy_fatacid: 29.1, paddy_AFB1: 2.88 }
+ }
+ ];
+
+ let forecastList = STATE.forecastCache[currentItem.id];
+ if (!forecastList || forecastList.length === 0) {
+ const initialSeries = generateForecastSeries(grainType, currentReport, baselineList, STATE.forecastEnv);
+ const initialTrendOption = buildForecastTrendChartOption(initialSeries);
+ if (initialTrendOption) {
+ STATE.charts.forecastTrend.setOption(initialTrendOption, true);
+ }
+ const initialFuture30 = initialSeries.metrics.map(m => ({
+ key: m.key,
+ name: m.name,
+ predictedValue: m.values[3] !== undefined ? m.values[3] : m.values[m.values.length - 1]
+ }));
+ const initialCompareOption = buildForecastCompareChartOption(grainType, currentReport?.items || [], initialFuture30);
+ if (initialCompareOption) {
+ STATE.charts.forecastCompare.setOption(initialCompareOption, true);
+ }
+ STATE.charts.forecastTrend.resize();
+ STATE.charts.forecastCompare.resize();
+
+ // 并发快速拉取算法数据 (携带仓内外温湿度与时间参数)
+ try {
+ forecastList = await fetchGranaryForecast(currentItem, [7, 15, 30, 60], STATE.forecastEnv);
+ } catch {
+ forecastList = baselineList;
+ }
+ if (!forecastList || forecastList.length === 0) {
+ forecastList = baselineList;
+ }
+ STATE.forecastCache[currentItem.id] = forecastList;
+ }
+
+ // 2. 真实数据回传后平滑更新 (显式使用 notMerge=true 并携带环境工况)
+ const series = generateForecastSeries(grainType, currentReport, forecastList, STATE.forecastEnv);
+
+ if (trendTitleEl) {
+ trendTitleEl.textContent = `${currentItem.name} (${grainName}) 质量指标多周期演变推演 (当前实测与未来 7~60天)`;
+ }
+ const trendOption = buildForecastTrendChartOption(series);
+ if (trendOption) {
+ STATE.charts.forecastTrend.setOption(trendOption, true);
+ }
+
+ const future30Targets = series.metrics.map(m => ({
+ key: m.key,
+ name: m.name,
+ predictedValue: m.values[3] !== undefined ? m.values[3] : m.values[m.values.length - 1]
+ }));
+
+ if (compareTitleEl) {
+ compareTitleEl.textContent = `${currentItem.name} 现状实测 vs 未来30天预测终值偏离评估`;
+ }
+ const compareOption = buildForecastCompareChartOption(grainType, currentReport?.items || [], future30Targets);
+ if (compareOption) {
+ STATE.charts.forecastCompare.setOption(compareOption, true);
+ }
+
+ requestAnimationFrame(() => {
+ STATE.charts.forecastTrend?.resize();
+ STATE.charts.forecastCompare?.resize();
+ });
+}
+
+// 算法推演工况与温湿度输入交互控制器
+function setupForecastEnvControls() {
+ const btnRecalc = document.getElementById('btn-forecast-recalc');
+ const btnReset = document.getElementById('btn-forecast-reset');
+
+ if (btnRecalc && !btnRecalc.dataset.bound) {
+ btnRecalc.dataset.bound = 'true';
+ btnRecalc.addEventListener('click', async () => {
+ const tm_i = parseFloat(document.getElementById('input-forecast-tm-i')?.value) || 18.5;
+ const hm_i = parseFloat(document.getElementById('input-forecast-hm-i')?.value) || 72.4;
+ const tm_o = parseFloat(document.getElementById('input-forecast-tm-o')?.value) || 32.5;
+ const hm_o = parseFloat(document.getElementById('input-forecast-hm-o')?.value) || 54.2;
+ const month = parseInt(document.getElementById('input-forecast-month')?.value, 10) || 7;
+ const day = parseInt(document.getElementById('input-forecast-day')?.value, 10) || 13;
+
+ STATE.forecastEnv = { tm_i, hm_i, tm_o, hm_o, month, day };
+ delete STATE.forecastCache[STATE.activeId];
+
+ const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
+ await initOrUpdateForecastCharts(currentItem);
+ });
+ }
+
+ if (btnReset && !btnReset.dataset.bound) {
+ btnReset.dataset.bound = 'true';
+ btnReset.addEventListener('click', async () => {
+ STATE.forecastEnv = {
+ tm_i: 18.5,
+ tm_o: 32.5,
+ hm_i: 72.4,
+ hm_o: 54.2,
+ month: 7,
+ day: 13
+ };
+ delete STATE.forecastCache[STATE.activeId];
+
+ const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
+ await initOrUpdateForecastCharts(currentItem);
+ });
+ }
+}
+
+// 10. 渲染居中全库品种分布现代大环仪表盘 (Modern Doughnut Dashboard)
+function initOrUpdateVarietyPie() {
+ const dom = document.getElementById('chart-variety-pie');
+ if (!dom || typeof echarts === 'undefined' || !STATE.summary) return;
+
+ if (!STATE.charts.varietyPie) {
+ STATE.charts.varietyPie = echarts.init(dom);
+ }
+
+ const dist = STATE.summary.distribution;
+ const pieData = Object.entries(dist).map(([name, val]) => {
+ const vTheme = DESIGN_THEME.getVarietyTheme(name);
+ return {
+ name,
+ value: Number((val / 10000).toFixed(2)),
+ itemStyle: {
+ color: vTheme.color
+ }
+ };
+ });
+
+ const totalStockWan = (STATE.summary.totalStock / 10000).toFixed(2);
+
+ const option = {
+ tooltip: {
+ trigger: 'item',
+ formatter: '{b}: {c} 万吨 ({d}%)',
+ backgroundColor: 'rgba(11, 22, 44, 0.94)',
+ borderColor: 'rgba(56, 189, 248, 0.4)',
+ borderWidth: 1,
+ padding: [8, 12],
+ textStyle: { color: '#f8fafc', fontSize: 12 }
+ },
+ title: {
+ text: totalStockWan,
+ subtext: '全库实储 (万吨)',
+ left: 'center',
+ top: '38%',
+ textStyle: {
+ color: '#f8fafc',
+ fontSize: 22,
+ fontWeight: 'bold',
+ fontFamily: 'DIN Alternate, JetBrains Mono, sans-serif'
+ },
+ subtextStyle: {
+ color: '#94a3b8',
+ fontSize: 11,
+ fontWeight: 'normal'
+ }
+ },
+ legend: {
+ bottom: '4%',
+ itemGap: 16,
+ textStyle: { color: '#cbd5e1', fontSize: 11 },
+ itemWidth: 12,
+ itemHeight: 12
+ },
+ series: [
+ {
+ name: '储粮品种结构',
+ type: 'pie',
+ radius: ['42%', '70%'],
+ center: ['50%', '46%'],
+ avoidLabelOverlap: false,
+ itemStyle: {
+ borderRadius: 6,
+ borderColor: '#060c20',
+ borderWidth: 3
+ },
+ label: {
+ show: true,
+ position: 'outside',
+ color: '#e2e8f0',
+ fontSize: 11,
+ formatter: '{b}\n{d}%'
+ },
+ labelLine: {
+ show: true,
+ length: 10,
+ length2: 12,
+ lineStyle: {
+ color: 'rgba(56, 189, 248, 0.45)'
+ }
+ },
+ data: pieData
+ }
+ ]
+ };
+
+ STATE.charts.varietyPie.setOption(option);
+ STATE.charts.varietyPie.resize();
+}
+
+// 渲染居中全库各仓储量与利用率负荷对比柱状图
+function initOrUpdateGranaryBar() {
+ const dom = document.getElementById('chart-granary-bar');
+ if (!dom || typeof echarts === 'undefined' || !STATE.list || STATE.list.length === 0) return;
+
+ if (!STATE.charts.granaryBar) {
+ STATE.charts.granaryBar = echarts.init(dom);
+ }
+
+ const names = STATE.list.map(i => i.name.replace('号仓房', '#').replace('号油罐', 'T#'));
+ const storageData = STATE.list.map(i => i.storageNum);
+ const capacityData = STATE.list.map(i => i.capacity);
+
+ const option = {
+ tooltip: {
+ trigger: 'axis',
+ axisPointer: { type: 'shadow' },
+ backgroundColor: 'rgba(11, 22, 44, 0.94)',
+ borderColor: 'rgba(56, 189, 248, 0.4)',
+ textStyle: { color: '#f8fafc', fontSize: 11 }
+ },
+ grid: {
+ top: '12%',
+ left: '3%',
+ right: '4%',
+ bottom: '12%',
+ containLabel: true
+ },
+ legend: {
+ top: '0%',
+ right: '2%',
+ textStyle: { color: '#94a3b8', fontSize: 10.5 },
+ data: ['实存储量', '设计容量']
+ },
+ xAxis: {
+ type: 'category',
+ data: names,
+ axisLabel: {
+ color: '#94a3b8',
+ fontSize: 9.5,
+ interval: 0,
+ rotate: 30
+ },
+ axisLine: { lineStyle: { color: 'rgba(56, 189, 248, 0.2)' } }
+ },
+ yAxis: {
+ type: 'value',
+ name: '吨',
+ nameTextStyle: { color: '#64748b', fontSize: 10 },
+ axisLabel: { color: '#94a3b8', fontSize: 10 },
+ splitLine: { lineStyle: { color: 'rgba(56, 189, 248, 0.08)' } }
+ },
+ series: [
+ {
+ name: '实存储量',
+ type: 'bar',
+ barWidth: 8,
+ itemStyle: {
+ borderRadius: [3, 3, 0, 0],
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ { offset: 0, color: '#38bdf8' },
+ { offset: 1, color: '#0284c7' }
+ ])
+ },
+ data: storageData
+ },
+ {
+ name: '设计容量',
+ type: 'bar',
+ barWidth: 8,
+ itemStyle: {
+ borderRadius: [3, 3, 0, 0],
+ color: 'rgba(255, 255, 255, 0.12)'
+ },
+ data: capacityData
+ }
+ ]
+ };
+
+ STATE.charts.granaryBar.setOption(option);
+ STATE.charts.granaryBar.resize();
+}
+
+// 刷新全部详情
+function renderDetailPanels() {
+ const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
+ if (!currentItem) return;
+
+ const params = formatGranaryParams(currentItem);
+ renderLeftPanel(currentItem, params);
+ renderRightPanel(currentItem, params);
+}
+
+// 11. 自动轮播控制器 (每 5 秒切换下一仓)
+function setupAutoPlay() {
+ const toggleBtn = document.getElementById('btn-autoplay');
+ const toggleText = document.getElementById('btn-autoplay-text');
+ if (!toggleBtn) return;
+
+ function start() {
+ stop();
+ STATE.playTimer = setInterval(() => {
+ if (!STATE.autoPlay || STATE.list.length === 0) return;
+ // 当居中大盘处于打开检视状态时,暂停自动轮播跳仓,保障人员专注于图表
+ if (STATE.isCenterChartOpen) return;
+ const curIdx = STATE.list.findIndex(i => i.id === STATE.activeId);
+ const nextIdx = (curIdx + 1) % STATE.list.length;
+ selectGranary(STATE.list[nextIdx].id);
+ }, 5000);
+ }
+
+ function stop() {
+ if (STATE.playTimer) {
+ clearInterval(STATE.playTimer);
+ STATE.playTimer = null;
+ }
+ }
+
+ toggleBtn.onclick = () => {
+ STATE.autoPlay = !STATE.autoPlay;
+ if (toggleText) {
+ toggleText.textContent = STATE.autoPlay ? '自动轮播: 开启' : '自动轮播: 关闭';
+ }
+ toggleBtn.classList.toggle('active', STATE.autoPlay);
+ if (STATE.autoPlay) {
+ start();
+ } else {
+ stop();
+ }
+ };
+
+ // 同步初始状态
+ if (toggleText) {
+ toggleText.textContent = STATE.autoPlay ? '自动轮播: 开启' : '自动轮播: 关闭';
+ }
+ toggleBtn.classList.toggle('active', STATE.autoPlay);
+
+ if (STATE.autoPlay) {
+ start();
+ }
+}
+
+// 12. 全屏与快捷键支持
+function toggleFullscreen() {
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen().catch(err => {
+ console.warn('全屏被阻止:', err);
+ });
+ } else {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ }
+ }
+}
+
+// 13. 鸟瞰图视觉模式切换 (默认 aerial_view2 实景日光)
+function updateAerialViewUI(mode) {
+ const imgEl = document.getElementById('aerial-view-img');
+ const btnEl = document.getElementById('btn-aerial-switch');
+ const textEl = document.getElementById('btn-aerial-text');
+ const iconEl = document.getElementById('aerial-switch-icon');
+ const config = getAerialModeConfig(mode);
+
+ if (imgEl) {
+ if (imgEl.getAttribute('data-current-mode') !== config.key) {
+ imgEl.style.opacity = '0.6';
+ imgEl.src = config.path;
+ imgEl.setAttribute('data-current-mode', config.key);
+ setTimeout(() => {
+ imgEl.style.opacity = '1';
+ }, 50);
+ }
+ }
+
+ if (textEl) {
+ textEl.textContent = config.label;
+ }
+
+ if (btnEl) {
+ btnEl.classList.toggle('active', config.key === 'aerial_view2');
+ }
+
+ if (iconEl) {
+ if (config.key === 'aerial_view2') {
+ // 太阳 (实景日光)
+ iconEl.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+ } else {
+ // 月牙 (科技夜景)
+ iconEl.innerHTML = `
+
+ `;
+ }
+ }
+}
+
+function initAerialSwitch() {
+ const btnEl = document.getElementById('btn-aerial-switch');
+ if (!btnEl) return;
+
+ updateAerialViewUI(STATE.aerialMode);
+
+ btnEl.addEventListener('click', () => {
+ STATE.aerialMode = getNextAerialMode(STATE.aerialMode);
+ try {
+ localStorage.setItem('depot_aerial_mode', STATE.aerialMode);
+ } catch (_) {}
+ updateAerialViewUI(STATE.aerialMode);
+ });
+}
+
+// 初始化入口
+window.addEventListener('DOMContentLoaded', async () => {
+ resizeScreen();
+ window.addEventListener('resize', resizeScreen);
+
+ updateClock();
+ setInterval(updateClock, 1000);
+
+ initAerialSwitch();
+
+ await fetchGranaryData();
+ await fetchEnvData();
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const paramId = parseInt(urlParams.get('id'));
+ if (paramId && STATE.list.some(i => i.id === paramId)) {
+ STATE.activeId = paramId;
+ }
+ if (urlParams.get('autoPlay') === 'true') {
+ STATE.autoPlay = true;
+ } else if (urlParams.get('autoPlay') === 'false') {
+ STATE.autoPlay = false;
+ }
+
+ renderKPIs();
+ renderHotspots();
+ renderMiniGrid();
+ renderDetailPanels();
+ renderDepotAlerts();
+
+ // 电视大屏 60 秒自动静默轮询同步最新台账与微气候
+ setInterval(async () => {
+ try {
+ await fetchGranaryData();
+ await fetchEnvData();
+ renderKPIs();
+ renderHotspots();
+ renderMiniGrid();
+ renderDetailPanels();
+ renderDepotAlerts();
+ } catch (_) {}
+ }, 60000);
+
+ setupAutoPlay();
+
+ const fsBtn = document.getElementById('btn-fullscreen');
+ if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
+
+ document.querySelectorAll('.filter-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ STATE.filterType = btn.getAttribute('data-filter');
+ renderHotspots();
+ renderMiniGrid();
+ });
+ });
+
+ window.addEventListener('keydown', (e) => {
+ const curIdx = STATE.list.findIndex(w => w.id === STATE.activeId);
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
+ const nextIdx = (curIdx - 1 + STATE.list.length) % STATE.list.length;
+ selectGranary(STATE.list[nextIdx].id);
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
+ const nextIdx = (curIdx + 1) % STATE.list.length;
+ selectGranary(STATE.list[nextIdx].id);
+ } else if (e.code === 'Space') {
+ toggleFullscreen();
+ } else if (e.key === 'v' || e.key === 'V') {
+ STATE.aerialMode = getNextAerialMode(STATE.aerialMode);
+ try {
+ localStorage.setItem('depot_aerial_mode', STATE.aerialMode);
+ } catch (_) {}
+ updateAerialViewUI(STATE.aerialMode);
+ }
+ });
+});
diff --git a/public/screen/js/grain_monitor_core.js b/public/screen/js/grain_monitor_core.js
new file mode 100644
index 0000000..1d5d5bc
--- /dev/null
+++ b/public/screen/js/grain_monitor_core.js
@@ -0,0 +1,2120 @@
+/**
+ * 粮仓监测大屏核心业务逻辑与计算工具
+ * 严格支持 Node.js ESM 与 浏览器环境复用
+ */
+
+/**
+ * 工业级数字孪生大屏统一设计主题与语义色彩令牌
+ */
+export const DESIGN_THEME = {
+ colors: {
+ bg: '#070c18',
+ surface: 'rgba(11, 22, 44, 0.75)',
+ surfaceSub: 'rgba(15, 29, 58, 0.55)',
+ brand: '#38bdf8', // 智慧科技天青蓝 (替换高饱和荧光青)
+ brandDark: '#0284c7',
+ wheat: '#38bdf8', // 小麦主粮科技蓝
+ paddy: '#10b981', // 稻谷生态翠绿
+ oil: '#f59e0b', // 菜籽油脂琥珀金 (替换刺眼亮黄)
+ success: '#10b981',
+ warning: '#f59e0b',
+ danger: '#ef4444',
+ textMain: '#f8fafc',
+ textSub: '#94a3b8',
+ textMuted: '#64748b',
+ border: 'rgba(56, 189, 248, 0.16)',
+ borderActive: '#38bdf8'
+ },
+ getVarietyTheme(varietyName) {
+ if (!varietyName) return { color: '#38bdf8', label: '小麦', type: 'wheat' };
+ const str = String(varietyName).toLowerCase();
+ if (str.includes('油') || str.includes('rapeseed') || str.includes('oil')) {
+ return { color: '#f59e0b', label: '菜籽油', type: 'oil' };
+ }
+ if (str.includes('稻') || str.includes('paddy') || str.includes('rice')) {
+ return { color: '#10b981', label: '稻谷', type: 'paddy' };
+ }
+ return { color: '#38bdf8', label: '小麦', type: 'wheat' };
+ }
+};
+
+
+/**
+ * 计算大屏视口等比缩放与居中偏移(防止电视机溢出或出现滚动条)
+ * @param {number} winW 浏览器/视口宽度
+ * @param {number} winH 浏览器/视口高度
+ * @param {number} targetW 设计基准宽度,默认 1920
+ * @param {number} targetH 设计基准高度,默认 1080
+ */
+export function calcScreenScale(winW, winH, targetW = 1920, targetH = 1080) {
+ const scale = Math.min(winW / targetW, winH / targetH);
+ const offsetX = (winW - targetW * scale) / 2;
+ const offsetY = (winH - targetH * scale) / 2;
+ return {
+ scale,
+ offsetX,
+ offsetY,
+ width: targetW,
+ height: targetH
+ };
+}
+
+/**
+ * 计算粮堆多层测温传感器数据统计
+ */
+export function calcGrainTemperatureStats(sensorPoints, options = {}) {
+ if (!sensorPoints || sensorPoints.length === 0) {
+ return {
+ avgTemp: 0,
+ maxTemp: 0,
+ minTemp: 0,
+ tempSpread: 0,
+ highTempPoints: [],
+ layerStats: {}
+ };
+ }
+
+ const highThreshold = options.highTempThreshold ?? 25.0;
+ const temps = sensorPoints.map(s => s.temp);
+ const maxTemp = Math.max(...temps);
+ const minTemp = Math.min(...temps);
+ const sum = temps.reduce((acc, val) => acc + val, 0);
+ const avgTemp = Number((sum / temps.length).toFixed(1));
+ const tempSpread = Number((maxTemp - minTemp).toFixed(1));
+
+ const highTempPoints = sensorPoints.filter(s => s.temp >= highThreshold);
+
+ const layers = ['top', 'midUpper', 'midLower', 'bottom'];
+ const layerStats = {};
+
+ layers.forEach(layerKey => {
+ const layerSensors = sensorPoints.filter(s => s.layer === layerKey);
+ if (layerSensors.length > 0) {
+ const lTemps = layerSensors.map(s => s.temp);
+ const lSum = lTemps.reduce((a, b) => a + b, 0);
+ layerStats[layerKey] = {
+ avg: Number((lSum / layerSensors.length).toFixed(1)),
+ max: Math.max(...lTemps),
+ min: Math.min(...lTemps),
+ count: layerSensors.length
+ };
+ }
+ });
+
+ return {
+ avgTemp,
+ maxTemp,
+ minTemp,
+ tempSpread,
+ highTempPoints,
+ layerStats
+ };
+}
+
+/**
+ * 粮情与环境智能安全预警研判引擎
+ */
+export function checkGrainAlerts(envData, gasData, tempStats) {
+ const alerts = [];
+ const nowStr = new Date().toLocaleTimeString('zh-CN', { hour12: false });
+
+ if (tempStats && (tempStats.highTempPoints.length > 0 || tempStats.maxTemp >= 25)) {
+ const pointsDesc = tempStats.highTempPoints.map(p => `${p.id || p.cableId}(${p.temp}℃)`).join(', ');
+ alerts.push({
+ id: `alert-temp-${Date.now()}`,
+ type: 'HIGH_TEMP',
+ level: 'danger',
+ title: '粮堆局部高温预警',
+ desc: `检测到测温点超标:${pointsDesc || tempStats.maxTemp + '℃'},建议开启谷冷机或排风控温。`,
+ time: nowStr
+ });
+ }
+
+ if (tempStats && tempStats.tempSpread >= 10) {
+ alerts.push({
+ id: `alert-spread-${Date.now()}`,
+ type: 'TEMP_SPREAD',
+ level: 'warning',
+ title: '垂直温差过大预警',
+ desc: `粮堆表底层最大温差达到 ${tempStats.tempSpread}℃,存在结露风险,请关注均温通风。`,
+ time: nowStr
+ });
+ }
+
+ if (gasData) {
+ if (gasData.ph3 > 200) {
+ alerts.push({
+ id: `alert-gas-${Date.now()}`,
+ type: 'GAS_FUMIGATION',
+ level: 'warning',
+ title: '熏蒸PH3浓度处于作业期',
+ desc: `磷化氢气体浓度 ${gasData.ph3} ppm,处于环流熏蒸有效杀虫浓度,仓门已处于联锁密闭状态。`,
+ time: nowStr
+ });
+ }
+ }
+
+ return alerts;
+}
+
+/**
+ * 汇总全库各仓房宏观数据 (旧结构兼容)
+ */
+export function aggregateWarehouseData(warehouseList) {
+ if (!warehouseList || warehouseList.length === 0) {
+ return {
+ totalStock: 0,
+ totalCapacity: 0,
+ utilizationRate: 0,
+ warningCount: 0,
+ grainDistribution: {}
+ };
+ }
+
+ let totalStock = 0;
+ let totalCapacity = 0;
+ let warningCount = 0;
+ const grainDistribution = {};
+
+ warehouseList.forEach(w => {
+ totalStock += (w.currentStock || 0);
+ totalCapacity += (w.capacity || 0);
+ if (w.status && w.status !== 'normal') {
+ warningCount++;
+ }
+ const type = w.grainType || '未分类';
+ grainDistribution[type] = (grainDistribution[type] || 0) + (w.currentStock || 0);
+ });
+
+ const utilizationRate = totalCapacity > 0
+ ? Number(((totalStock / totalCapacity) * 100).toFixed(1))
+ : 0;
+
+ return {
+ totalStock,
+ totalCapacity,
+ utilizationRate,
+ warningCount,
+ grainDistribution
+ };
+}
+
+/**
+ * 标准化 API 返回的 granaryList 列表
+ * @param {Object} apiData 接口返回数据 { code, msg, data: { list: [...] } }
+ */
+export function normalizeGranaryList(apiData) {
+ const rawList = apiData?.data?.list || apiData?.list || (Array.isArray(apiData) ? apiData : []);
+
+ return rawList.map(item => {
+ const isOilTank = item.granary_type === 1 || item.name.includes('油罐');
+ const isSilo = item.granary_type === 3 ||
+ item.name.includes('浅圆') ||
+ item.name.includes('筒仓') ||
+ item.content?.warehouseType?.includes('浅圆') ||
+ item.content?.warehouseType?.includes('筒仓');
+ const grainTypeCategory = isOilTank ? 'oilTank' : (isSilo ? 'silo' : 'warehouse');
+ const content = item.content || {};
+
+ const capacityNum = Number(content.capacity || content.tankCapacity || 0);
+ const storageNum = Number(content.storageNum || 0);
+ const grainName = item.varieties?.name || content.varieties || (isOilTank ? '菜籽油' : '小麦');
+
+ return {
+ id: item.id,
+ name: item.name,
+ num: item.num,
+ storage: item.storage || '遵义直属库有限公司中心库区',
+ granaryType: item.granary_type,
+ isOilTank,
+ grainTypeCategory,
+ grainName,
+ capacityNum,
+ storageNum,
+ utilization: capacityNum > 0 ? Number(((storageNum / capacityNum) * 100).toFixed(1)) : 0,
+ rawContent: content,
+ createTime: item.create_time
+ };
+ });
+}
+
+/**
+ * 粮油全量质检指标标准基准字典 (收录小麦、菜籽油、稻谷官方质检单真实数据与预警警戒线)
+ */
+export const QUALITY_BENCHMARKS = {
+ wheat: {
+ variety: '小麦',
+ inspectDate: '2024-11',
+ inspectStage: '秋季普查 / 满仓鉴定',
+ items: [
+ { key: 'weight', name: '容重 (g/L)', shortName: '容重', value: '784', unit: 'g/L', warn: 770, alert: 750, direction: 'lower', category: 'physical' },
+ { key: 'imperfect', name: '不完善粒', shortName: '不完善粒', value: '5.3', unit: '%', warn: 7.2, alert: 8.0, direction: 'upper', category: 'physical' },
+ { key: 'impurity', name: '杂质总量', shortName: '杂质', value: '0.7', unit: '%', warn: 0.9, alert: 1.0, direction: 'upper', category: 'physical' },
+ { key: 'water', name: '粮食水分', shortName: '水分', value: '12.8', unit: '%', warn: 12.3, alert: 12.5, direction: 'upper', category: 'physical' },
+ { key: 'inorganicImpurity', name: '无机杂质', shortName: '无机杂质', value: '0.06', unit: 'g/100g', warn: 0.45, alert: 0.5, direction: 'upper', category: 'physical' },
+ { key: 'qualityOdor', name: '质量色泽气味', shortName: '色泽气味', value: '正常', unit: '', warn: '达标', alert: '未达标', type: 'qualitative', category: 'physical' },
+ { key: 'glutenWater', name: '面筋吸水量', shortName: '面筋吸水', value: '209.0', unit: '%', warn: 185, alert: 180, direction: 'lower', category: 'storage' },
+ { key: 'tasteScore', name: '品尝评分值', shortName: '品尝评分', value: '77', unit: '分', warn: 71, alert: 70, direction: 'lower', category: 'storage' },
+ { key: 'storageOdor', name: '储存色泽气味', shortName: '储存气味', value: '正常', unit: '', warn: '宜存', alert: '不宜存', type: 'qualitative', category: 'storage' },
+ { key: 'pb', name: '重金属铅', shortName: '铅 (Pb)', value: '0', unit: 'mg/kg', warn: 0.18, alert: 0.2, direction: 'upper', category: 'safety' },
+ { key: 'cd', name: '重金属镉', shortName: '镉 (Cd)', value: '0', unit: 'mg/kg', warn: 0.09, alert: 0.1, direction: 'upper', category: 'safety' },
+ { key: 'hg', name: '总汞', shortName: '总汞 (Hg)', value: '0', unit: 'mg/kg', warn: 0.018, alert: 0.02, direction: 'upper', category: 'safety' },
+ { key: 'as', name: '总砷', shortName: '总砷 (As)', value: '0', unit: 'mg/kg', warn: 0.45, alert: 0.5, direction: 'upper', category: 'safety' },
+ { key: 'aflatoxinB1', name: '黄曲霉毒素B1', shortName: '黄曲霉B1', value: '1.7', unit: 'μg/kg', warn: 4.5, alert: 5.0, direction: 'upper', category: 'safety' },
+ { key: 'don', name: '脱氧雪腐镰刀菌烯醇', shortName: '呕吐毒素', value: '0', unit: 'μg/g', warn: 0.6, alert: 0.75, direction: 'upper', category: 'safety' },
+ { key: 'zearalenone', name: '玉米赤霉烯酮', shortName: '赤霉烯酮', value: '0', unit: 'μg/kg', warn: 54, alert: 60, direction: 'upper', category: 'safety' }
+ ]
+ },
+ oil: {
+ variety: '菜籽油',
+ inspectDate: '2024-04',
+ inspectStage: '满仓化验',
+ items: [
+ { key: 'color', name: '色泽', shortName: '色泽', value: '棕褐色 (正常)', unit: '', warn: '正常', alert: '异常', type: 'qualitative', category: 'physical' },
+ { key: 'transparency', name: '透明度(20℃)', shortName: '透明度', value: '澄清透明 (正常)', unit: '', warn: '正常', alert: '异常', type: 'qualitative', category: 'physical' },
+ { key: 'odorAndTaste', name: '气味滋味', shortName: '气味滋味', value: '固有气味无异味', unit: '', warn: '正常', alert: '异常', type: 'qualitative', category: 'physical' },
+ { key: 'heating', name: '加热试验(280℃)', shortName: '加热试验', value: '无析出物油色变浅', unit: '', warn: '正常', alert: '异常', type: 'qualitative', category: 'physical' },
+ { key: 'waterVolatile', name: '水分及挥发物', shortName: '水分挥发物', value: '0.07', unit: '%', warn: 0.18, alert: 0.2, direction: 'upper', category: 'physical' },
+ { key: 'insoluble', name: '不溶性杂质', shortName: '不溶杂质', value: '0.02', unit: '%', warn: 0.045, alert: 0.05, direction: 'upper', category: 'physical' },
+ { key: 'acidValue', name: '酸价 (KOH)', shortName: '酸价(KOH)', value: '0.45', unit: 'mg/g', warn: 2.7, alert: 3.0, direction: 'upper', category: 'storage' },
+ { key: 'peroxideValue', name: '过氧化值', shortName: '过氧化值', value: '0.081', unit: 'g/100g', warn: 0.225, alert: 0.25, direction: 'upper', category: 'storage' },
+ { key: 'soapContent', name: '含皂量', shortName: '含皂量', value: '0', unit: '%', warn: 0.027, alert: 0.03, direction: 'upper', category: 'safety' },
+ { key: 'pb', name: '重金属铅', shortName: '铅 (Pb)', value: '0.043', unit: 'mg/kg', warn: 0.072, alert: 0.08, direction: 'upper', category: 'safety' },
+ { key: 'as', name: '总砷', shortName: '总砷 (As)', value: '0', unit: 'mg/kg', warn: 0.09, alert: 0.1, direction: 'upper', category: 'safety' },
+ { key: 'benzoAPyrene', name: '苯并(a)芘', shortName: '苯并(a)芘', value: '0', unit: 'μg/kg', warn: 9.0, alert: 10.0, direction: 'upper', category: 'safety' },
+ { key: 'aflatoxinB1', name: '黄曲霉毒素B1', shortName: '黄曲霉B1', value: '0', unit: 'μg/kg', warn: 9.0, alert: 10.0, direction: 'upper', category: 'safety' },
+ { key: 'fattyAcid', name: '脂肪酸组成', shortName: '脂肪酸组成', value: '符合标准', unit: '', warn: '合格', alert: '不合格', type: 'qualitative', category: 'safety' }
+ ]
+ },
+ paddy: {
+ variety: '稻谷',
+ inspectDate: '2024-10',
+ inspectStage: '秋季普查',
+ items: [
+ { key: 'water', name: '粮食水分', shortName: '水分', value: '11.9', unit: 'g/100g', warn: 13.3, alert: 13.5, direction: 'upper', category: 'physical' },
+ { key: 'impurity', name: '杂质总量', shortName: '杂质', value: '0.7', unit: '%', warn: 0.9, alert: 1.0, direction: 'upper', category: 'physical' },
+ { key: 'roughnessRate', name: '出糙率', shortName: '出糙率', value: '78.9', unit: '%', warn: 77.0, alert: 75.0, direction: 'lower', category: 'physical' },
+ { key: 'milledRiceRate', name: '整精米率', shortName: '整精米率', value: '55.0', unit: '%', warn: 44.5, alert: 44.0, direction: 'lower', category: 'physical' },
+ { key: 'yellowRice', name: '黄粒米', shortName: '黄粒米', value: '0', unit: '%', warn: 0.9, alert: 1.0, direction: 'upper', category: 'physical' },
+ { key: 'milledGrainOutside', name: '谷外碾米', shortName: '谷外碾米', value: '1.5', unit: '%', warn: 1.0, alert: 2.0, direction: 'upper', category: 'physical' },
+ { key: 'mixRate', name: '互混率', shortName: '互混率', value: '0', unit: '%', warn: 4.5, alert: 5.0, direction: 'upper', category: 'physical' },
+ { key: 'qualityOdor', name: '质量色泽气味', shortName: '色泽气味', value: '正常', unit: '', warn: '达标', alert: '未达标', type: 'qualitative', category: 'physical' },
+ { key: 'fattyAcid', name: '脂肪酸值', shortName: '脂肪酸值', value: '19.6', unit: 'mg/100g', warn: 27.0, alert: 30.0, direction: 'upper', category: 'storage' },
+ { key: 'tasteScore', name: '品尝评分值', shortName: '品尝评分', value: '76', unit: '分', warn: 70.0, alert: 65.0, direction: 'lower', category: 'storage' },
+ { key: 'storageOdor', name: '储存色泽气味', shortName: '储存气味', value: '正常', unit: '', warn: '宜存', alert: '不宜存', type: 'qualitative', category: 'storage' },
+ { key: 'cd', name: '重金属镉', shortName: '镉 (Cd)', value: '0.063', unit: 'mg/kg', warn: 0.18, alert: 0.2, direction: 'upper', category: 'safety' },
+ { key: 'aflatoxinB1', name: '黄曲霉毒素B1', shortName: '黄曲霉B1', value: '3.3', unit: 'μg/kg', warn: 9.0, alert: 20.0, direction: 'upper', category: 'safety' },
+ { key: 'pb', name: '重金属铅', shortName: '铅 (Pb)', value: '未检出', unit: 'mg/kg', warn: 0.18, alert: 0.2, direction: 'upper', category: 'safety' },
+ { key: 'hg', name: '总汞', shortName: '总汞 (Hg)', value: '未检出', unit: 'mg/kg', warn: 0.018, alert: 0.02, direction: 'upper', category: 'safety' },
+ { key: 'as', name: '无机砷', shortName: '无机砷(As)', value: '未检出', unit: 'mg/kg', warn: 0.315, alert: 0.35, direction: 'upper', category: 'safety' },
+ { key: 'don', name: '脱氧雪腐镰刀菌烯醇', shortName: '呕吐毒素', value: '未检出', unit: 'μg/g', warn: 0.6, alert: 0.75, direction: 'upper', category: 'safety' },
+ { key: 'zearalenone', name: '玉米赤霉烯酮', shortName: '赤霉烯酮', value: '未检出', unit: 'μg/kg', warn: 54, alert: 60, direction: 'upper', category: 'safety' }
+ ]
+ }
+};
+
+/**
+ * 粮油单项理化与卫生安全指标研判器
+ * @param {Object} item 指标对象
+ * @returns {Object} 状态结果 { status: 'normal'|'warn'|'danger', statusText, badgeText, color }
+ */
+export function evaluateQualityStatus(item) {
+ if (!item) return { status: 'normal', statusText: '健康达标', badgeText: '达标', color: '#10b981' };
+
+ if (item.type === 'qualitative') {
+ const val = String(item.value || '');
+ const isOk = val.includes('正常') || val.includes('达标') || val.includes('宜存') ||
+ val.includes('符合') || val.includes('合格') || val.includes('透明') ||
+ val.includes('变浅') || val.includes('棕') || val.includes('固有') ||
+ val.includes('无析出物') || val.includes('无异味') || val.includes('未检出');
+ return isOk
+ ? { status: 'normal', statusText: '健康达标', badgeText: '达标', color: '#10b981' }
+ : { status: 'danger', statusText: '超标异常', badgeText: '异常', color: '#ef4444' };
+ }
+
+ const num = parseFloat(item.value);
+ if (isNaN(num)) {
+ return { status: 'normal', statusText: '健康达标', badgeText: '合格', color: '#10b981' };
+ }
+
+ const warn = parseFloat(item.warn);
+ const alert = parseFloat(item.alert);
+
+ if (item.direction === 'lower') {
+ if (!isNaN(alert) && num < alert) {
+ return { status: 'danger', statusText: '超标警戒', badgeText: '超标', color: '#ef4444' };
+ }
+ if (!isNaN(warn) && num < warn) {
+ return { status: 'warn', statusText: '轻度预警', badgeText: '预警', color: '#f59e0b' };
+ }
+ return { status: 'normal', statusText: '健康达标', badgeText: '达标', color: '#10b981' };
+ } else {
+ if (!isNaN(alert) && num > alert) {
+ return { status: 'danger', statusText: '超标警戒', badgeText: '超标', color: '#ef4444' };
+ }
+ if (!isNaN(warn) && num > warn) {
+ return { status: 'warn', statusText: '轻度预警', badgeText: '预警', color: '#f59e0b' };
+ }
+ return { status: 'normal', statusText: '健康达标', badgeText: (item.warn !== undefined && num <= warn) ? '健康' : '达标', color: '#10b981' };
+ }
+}
+
+/**
+ * 获取仓房或粮油品种的标准全量化验单报告
+ * @param {string} grainTypeOrName 品种名称或类型
+ * @param {Object} overrides 仓房特有指标覆盖 (如 { water: '12.8%' })
+ */
+export function getGranaryQualityReport(grainTypeOrName = '小麦', overrides = {}) {
+ const str = String(grainTypeOrName || '').toLowerCase();
+ let key = 'wheat';
+ if (str.includes('油') || str.includes('oil') || str.includes('rapeseed')) {
+ key = 'oil';
+ } else if (str.includes('稻') || str.includes('paddy') || str.includes('rice')) {
+ key = 'paddy';
+ }
+
+ const benchmark = QUALITY_BENCHMARKS[key];
+ if (!benchmark) return null;
+
+ let dangerCount = 0;
+ let warnCount = 0;
+ let normalCount = 0;
+
+ const items = benchmark.items.map(baseItem => {
+ const item = { ...baseItem };
+ if (overrides[item.key] !== undefined && overrides[item.key] !== null) {
+ const cleanVal = String(overrides[item.key]).replace(/[^0-9.]/g, '');
+ if (cleanVal) item.value = cleanVal;
+ }
+ const evalRes = evaluateQualityStatus(item);
+ item.status = evalRes.status;
+ item.statusText = evalRes.statusText;
+ item.badgeText = evalRes.badgeText;
+ item.color = evalRes.color;
+
+ if (item.status === 'danger') dangerCount++;
+ else if (item.status === 'warn') warnCount++;
+ else normalCount++;
+
+ return item;
+ });
+
+ const overallStatus = dangerCount > 0 ? 'danger' : (warnCount > 0 ? 'warn' : 'normal');
+
+ return {
+ variety: benchmark.variety,
+ varietyKey: key,
+ inspectDate: benchmark.inspectDate,
+ inspectStage: benchmark.inspectStage,
+ items,
+ summary: {
+ totalCount: items.length,
+ dangerCount,
+ warnCount,
+ normalCount,
+ overallStatus
+ }
+ };
+}
+
+/**
+ * 生成全量质检指标卡片网格 HTML (支持分类过滤,内置健康/预警/超标规范色与严格防折行)
+ * @param {Object} qualityReport getGranaryQualityReport 返回的对象
+ * @param {string} filterCategory 'all' | 'physical' | 'storage' | 'safety'
+ */
+export function buildQualityHtml(qualityReport, filterCategory = 'all') {
+ if (!qualityReport || !qualityReport.items) return '';
+
+ const items = filterCategory === 'all'
+ ? qualityReport.items
+ : qualityReport.items.filter(it => it.category === filterCategory);
+
+ return items.map(it => {
+ const valClass = it.status === 'danger' ? 'val-danger' : (it.status === 'warn' ? 'val-warn' : 'val-normal');
+ const cardClass = it.status === 'danger' ? 'card-danger' : (it.status === 'warn' ? 'card-warn' : '');
+
+ // 阈值说明文字 (保证极简防折行)
+ let threshDesc = '';
+ if (it.type === 'qualitative') {
+ threshDesc = it.status === 'normal' ? '标准达标' : '未达标';
+ } else if (it.alert !== undefined) {
+ const sym = it.direction === 'lower' ? '≥' : '≤';
+ threshDesc = `限值${sym}${it.alert}${it.unit || ''}`;
+ }
+
+ const unitStr = it.unit ? `${it.unit}` : '';
+ const displayVal = `${it.value}${unitStr}`;
+ const displayName = it.shortName || it.name;
+
+ return `
+
+
+
+ ${it.status === 'danger' ? '⚠️ ' : ''}${displayVal}
+ ${threshDesc}
+
+
+ `.trim();
+ }).join('');
+}
+
+/**
+ * 方案A:从全量质检报告中提炼 4 大命脉 HUD 指标与标尺进度
+ * @param {Object} qualityReport
+ */
+export function getCoreHudMetrics(qualityReport) {
+ if (!qualityReport || !qualityReport.items) return [];
+ const key = qualityReport.varietyKey;
+ const items = qualityReport.items;
+
+ const findItem = (k) => items.find(it => it.key === k) || items[0];
+
+ let coreKeys = [];
+ if (key === 'wheat') {
+ coreKeys = ['water', 'weight', 'impurity', 'tasteScore'];
+ } else if (key === 'oil') {
+ coreKeys = ['acidValue', 'peroxideValue', 'waterVolatile', 'insoluble'];
+ } else {
+ coreKeys = ['water', 'milledGrainOutside', 'roughnessRate', 'milledRiceRate'];
+ }
+
+ return coreKeys.map(k => {
+ const it = findItem(k);
+ const num = parseFloat(it.value) || 0;
+ let gaugePercent = 50;
+ let limitDesc = '';
+
+ if (it.key === 'water') {
+ const maxVal = key === 'paddy' ? 15.0 : 14.0;
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / maxVal) * 100).toFixed(0))));
+ if (it.status === 'danger') {
+ const diff = (num - parseFloat(it.alert)).toFixed(1);
+ limitDesc = `⚠️ 超标 +${diff}% | 警戒限 ≤${it.alert}%`;
+ } else {
+ limitDesc = `健康达标 | 警戒线 ≤${it.alert}%`;
+ }
+ } else if (it.key === 'weight') {
+ gaugePercent = Math.min(100, Math.max(20, Number((((num - 700) / 150) * 100).toFixed(0))));
+ limitDesc = `质量等级: 贰等 (基准 ≥${it.warn}g/L)`;
+ } else if (it.key === 'impurity') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / (it.alert * 1.5)) * 100).toFixed(0))));
+ limitDesc = `清洁优良 | 警戒线 ≤${it.alert}%`;
+ } else if (it.key === 'tasteScore') {
+ gaugePercent = Math.min(100, Math.max(20, Number(((num / 100) * 100).toFixed(0))));
+ limitDesc = `储存判定: 宜存 (基准 ≥${it.alert}分)`;
+ } else if (it.key === 'milledGrainOutside') {
+ gaugePercent = Math.min(100, Math.max(15, Number(((num / (it.alert * 1.2)) * 100).toFixed(0))));
+ limitDesc = it.status === 'warn' ? `⚠️ 轻度预警 (警戒限 ≤${it.alert}%)` : `达标安全 | 限值 ≤${it.alert}%`;
+ } else if (it.key === 'roughnessRate') {
+ gaugePercent = Math.min(100, Math.max(20, Number(((num / 90) * 100).toFixed(0))));
+ limitDesc = `质量评级: 贰等 (标准 ≥${it.warn}%)`;
+ } else if (it.key === 'milledRiceRate') {
+ gaugePercent = Math.min(100, Math.max(20, Number(((num / 70) * 100).toFixed(0))));
+ limitDesc = `整精米优良 | 基准 ≥${it.alert}%`;
+ } else if (it.key === 'acidValue') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / it.alert) * 100).toFixed(0))));
+ limitDesc = `酸价达标 | 限值 ≤${it.alert} mg/g`;
+ } else if (it.key === 'peroxideValue') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / it.alert) * 100).toFixed(0))));
+ limitDesc = `新鲜优质 | 限值 ≤${it.alert} g/100g`;
+ } else if (it.key === 'waterVolatile') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / it.alert) * 100).toFixed(0))));
+ limitDesc = `挥发物安全 | 限值 ≤${it.alert}%`;
+ } else if (it.key === 'insoluble') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / it.alert) * 100).toFixed(0))));
+ limitDesc = `澄清纯净 | 限值 ≤${it.alert}%`;
+ } else {
+ limitDesc = `标准达标`;
+ }
+
+ return {
+ key: it.key,
+ name: it.name,
+ shortName: it.shortName || it.name,
+ value: it.value,
+ unit: it.unit || '',
+ status: it.status,
+ badgeText: it.badgeText,
+ gaugePercent,
+ limitDesc
+ };
+ });
+}
+
+/**
+ * 辅助:计算单项指标条形卡片的标尺百分比与限值说明
+ * @param {Object} it 单项指标对象
+ * @param {string} varietyKey 'wheat' | 'oil' | 'paddy'
+ */
+export function computeStripMetric(it, varietyKey = 'wheat') {
+ const num = parseFloat(it.value);
+ const isNumeric = !isNaN(num);
+ let gaugePercent = 50;
+ let limitDesc = '';
+
+ if (it.key === 'water') {
+ const maxVal = varietyKey === 'paddy' ? 15.0 : 14.0;
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / maxVal) * 100).toFixed(0))));
+ if (it.status === 'danger') {
+ const diff = (num - parseFloat(it.alert)).toFixed(1);
+ limitDesc = `⚠️ 超标 +${diff}% | 警戒限 ≤${it.alert}%`;
+ } else {
+ limitDesc = `健康达标 | 警戒线 ≤${it.alert}%`;
+ }
+ } else if (it.key === 'weight') {
+ gaugePercent = Math.min(100, Math.max(20, Number((((num - 700) / 150) * 100).toFixed(0))));
+ limitDesc = `质量等级: 贰等 (基准 ≥${it.warn}g/L)`;
+ } else if (it.key === 'impurity') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / (it.alert * 1.5)) * 100).toFixed(0))));
+ limitDesc = `清洁优良 | 警戒线 ≤${it.alert}%`;
+ } else if (it.key === 'tasteScore') {
+ gaugePercent = Math.min(100, Math.max(20, Number(((num / 100) * 100).toFixed(0))));
+ limitDesc = `储存判定: 宜存 (基准 ≥${it.alert}分)`;
+ } else if (it.key === 'milledGrainOutside') {
+ gaugePercent = Math.min(100, Math.max(15, Number(((num / (it.alert * 1.2)) * 100).toFixed(0))));
+ limitDesc = it.status === 'warn' ? `⚠️ 轻度预警 (警戒限 ≤${it.alert}%)` : `达标安全 | 限值 ≤${it.alert}%`;
+ } else if (it.key === 'roughnessRate') {
+ gaugePercent = Math.min(100, Math.max(20, Number(((num / 90) * 100).toFixed(0))));
+ limitDesc = `质量评级: 贰等 (标准 ≥${it.warn}%)`;
+ } else if (it.key === 'milledRiceRate') {
+ gaugePercent = Math.min(100, Math.max(20, Number(((num / 70) * 100).toFixed(0))));
+ limitDesc = `整精米优良 | 基准 ≥${it.alert}%`;
+ } else if (it.key === 'acidValue') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / it.alert) * 100).toFixed(0))));
+ limitDesc = `酸价达标 | 限值 ≤${it.alert} mg/g`;
+ } else if (it.key === 'peroxideValue') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / it.alert) * 100).toFixed(0))));
+ limitDesc = `新鲜优质 | 限值 ≤${it.alert} g/100g`;
+ } else if (it.key === 'waterVolatile') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / it.alert) * 100).toFixed(0))));
+ limitDesc = `挥发物安全 | 限值 ≤${it.alert}%`;
+ } else if (it.key === 'insoluble') {
+ gaugePercent = Math.min(100, Math.max(10, Number(((num / it.alert) * 100).toFixed(0))));
+ limitDesc = `澄清纯净 | 限值 ≤${it.alert}%`;
+ } else if (it.type === 'qualitative') {
+ gaugePercent = it.status === 'normal' ? 100 : 35;
+ limitDesc = it.status === 'normal' ? '国家标准合规 检验通过' : '⚠️ 检验未达标';
+ } else if (it.alert !== undefined && isNumeric) {
+ if (it.direction === 'lower') {
+ const base = parseFloat(it.alert);
+ gaugePercent = Math.min(100, Math.max(15, Number(((num / (base * 1.25)) * 100).toFixed(0))));
+ limitDesc = `国标基准 ≥${it.alert}${it.unit || ''}`;
+ } else {
+ const maxVal = parseFloat(it.alert) * 1.4;
+ gaugePercent = maxVal > 0 ? Math.min(100, Math.max(8, Number(((num / maxVal) * 100).toFixed(0)))) : 50;
+ limitDesc = `国标限量 ≤${it.alert}${it.unit || ''}`;
+ }
+ } else {
+ gaugePercent = 100;
+ limitDesc = it.value === '未检出' ? `限量达标 · 未检出` : `监测达标`;
+ }
+
+ return {
+ ...it,
+ gaugePercent,
+ limitDesc
+ };
+}
+
+/**
+ * 方案A升级:生成右侧全高专属【智能质检与品质全参数监测舱】HTML
+ * 将当前仓房的所有指标全部使用高体验的水平长条卡片 (Strip Cards) 完整呈现
+ * @param {Object} qualityReport
+ * @param {string} filterCategory 'all' | 'core' | 'physical' | 'safety'
+ */
+export function buildQualityHudHtml(qualityReport, filterCategory = 'all') {
+ if (!qualityReport || !qualityReport.items) return '';
+
+ const varietyKey = qualityReport.varietyKey;
+ const allItems = qualityReport.items;
+ const coreKeysMap = {
+ wheat: ['water', 'weight', 'impurity', 'tasteScore'],
+ oil: ['acidValue', 'peroxideValue', 'waterVolatile', 'insoluble'],
+ paddy: ['water', 'milledGrainOutside', 'roughnessRate', 'milledRiceRate']
+ };
+ const coreKeys = coreKeysMap[varietyKey] || coreKeysMap.wheat;
+
+ // 根据分类过滤展示项目
+ let displayItems = allItems;
+ if (filterCategory === 'core') {
+ displayItems = allItems.filter(it => coreKeys.includes(it.key));
+ } else if (filterCategory === 'safety') {
+ displayItems = allItems.filter(it => it.category === 'safety');
+ } else if (filterCategory === 'physical') {
+ displayItems = allItems.filter(it => it.category === 'physical' || it.category === 'storage');
+ }
+
+ // 统计各分类数量
+ const totalCount = allItems.length;
+ const coreCount = coreKeys.length;
+ const physicalCount = allItems.filter(it => it.category === 'physical' || it.category === 'storage').length;
+ const safetyCount = allItems.filter(it => it.category === 'safety').length;
+
+ // 生成全量水平条形卡片 (Horizontal Strip Cards)
+ const stripCardsHtml = displayItems.map(rawItem => {
+ const item = computeStripMetric(rawItem, varietyKey);
+ const cardClass = item.status === 'danger' ? 'strip-danger' : (item.status === 'warn' ? 'strip-warn' : 'strip-normal');
+ const valClass = item.status === 'danger' ? 'val-danger' : (item.status === 'warn' ? 'val-warn' : 'val-normal');
+ const gaugeClass = item.status === 'danger' ? 'gauge-danger' : (item.status === 'warn' ? 'gauge-warn' : 'gauge-normal');
+ const alertPrefix = item.status === 'danger' ? '⚠️ ' : '';
+ const displayName = item.shortName || item.name;
+ const unitStr = item.unit ? `${item.unit}` : '';
+
+ return `
+
+ `.trim();
+ }).join('');
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${stripCardsHtml}
+
+
+ `.trim();
+}
+
+/**
+ * 格式化仓房/油罐详细展示参数 (分类提取基础、尺寸、理化指标)
+ * @param {Object} normalizedItem
+ */
+export function formatGranaryParams(normalizedItem) {
+ if (!normalizedItem) return null;
+ const c = normalizedItem.rawContent || {};
+ const isOil = normalizedItem.isOilTank;
+ const grainVariety = normalizedItem.grainName || c.varieties || (isOil ? '菜籽油' : '小麦');
+ const qualityReport = getGranaryQualityReport(grainVariety, c);
+
+ const natureMap = {
+ 'ZC': '中央储备粮 (ZC)',
+ 'SP': '商品粮 (SP)',
+ 'DF': '地方储备粮 (DF)'
+ };
+
+ const natureName = natureMap[c.nature] || c.nature || '中央储备粮 (ZC)';
+
+ if (isOil) {
+ return {
+ type: 'oilTank',
+ qualityReport,
+ basic: {
+ warehouseType: c.tankType || '立式油罐',
+ capacity: `${c.tankCapacity || 0} 吨`,
+ storageNum: `${c.storageNum || 0} 吨`,
+ varieties: c.varieties || '菜籽油',
+ natureName,
+ productArea: c.productArea || '加拿大',
+ addr: c.addr || '广东',
+ machiningYear: c.machiningYear || '2024',
+ enterTime: c.enterTime || '2024-04',
+ managementForm: c.managementForm || '直接管理',
+ custodian: c.custodian || '肖胜利'
+ },
+ size: {
+ diameter: c.TankDiameter || '12.982(m)',
+ oilHeight: c.oilLoadingHeight || '20.230(m)',
+ oilVolume: `${c.oilVolume || 0} m³`
+ },
+ quality: {
+ level: c.level || '三级',
+ color: c.color || '棕黄色',
+ odorAndTaste: c.odorAndTaste || '具有菜籽油固有的气味和滋味,无异味',
+ acidValue: c.acidValue || '0.24mg/g',
+ peroxideValue: c.peroxideValue || '0.024g/100g',
+ waterVolatileCompounds: c.waterVolatileCompounds || '0.05%',
+ insolubleImpurities: c.insolubleImpurities || '0.02%',
+ residualSolventContent: `${c.residualSolventContent || 0}`,
+ heating: c.heating || '无析出物,油色变浅'
+ }
+ };
+ }
+
+ if (normalizedItem.grainTypeCategory === 'silo' || c.warehouseType?.includes('浅圆') || c.warehouseType?.includes('筒仓') || normalizedItem.name?.includes('浅圆')) {
+ return {
+ type: 'grainSilo',
+ qualityReport,
+ basic: {
+ warehouseType: c.warehouseType || '立式浅圆仓',
+ capacity: `${c.capacity || 0} 吨`,
+ storageNum: `${c.storageNum || 0} 吨`,
+ varieties: c.varieties || '小麦',
+ natureName,
+ addr: c.addr || '河南',
+ harvestYear: c.harvestYear || '2022',
+ enterTime: c.enterTime || '2023-05',
+ storageMethod: c.storageMethod || '仓内散储',
+ managementForm: c.managementForm || '直接管理',
+ custodian: c.custodian || '罗明'
+ },
+ size: {
+ diameter: c.diam || '25.00(米)',
+ height: c.height || '21.50(米)',
+ volume: `${c.volume || 10550} m³`,
+ decVolume: `${c.decVolume || 65.0} m³`
+ },
+ quality: {
+ level: c.level || '壹等',
+ water: c.water ? (c.water.includes('%') ? c.water : `${c.water}%`) : '12.0%',
+ impurity: c.impurity ? (c.impurity.includes('%') ? c.impurity : `${c.impurity}%`) : '0.5%',
+ weight: c.weight ? (c.weight.includes('g') ? c.weight : `${c.weight} g/L`) : '795 g/L',
+ imperfect: c.imperfect ? (c.imperfect.includes('%') ? c.imperfect : `${c.imperfect}%`) : '3.5%',
+ roughnessRate: c.roughnessRate || null,
+ riceGrains: c.riceGrains || null
+ }
+ };
+ }
+
+ // 粮食平房仓
+ return {
+ type: 'grainWarehouse',
+ qualityReport,
+ basic: {
+ warehouseType: c.warehouseType && c.warehouseType !== '-' && !/^\d+$/.test(c.warehouseType) ? c.warehouseType : '高大平房仓',
+ capacity: `${c.capacity || 0} 吨`,
+ storageNum: `${c.storageNum || 0} 吨`,
+ varieties: c.varieties || '小麦',
+ natureName,
+ addr: c.addr || '河南',
+ harvestYear: c.harvestYear || '2020',
+ enterTime: c.enterTime || '2020-10',
+ storageMethod: c.storageMethod || '散装储粮',
+ managementForm: c.managementForm || '直接管理',
+ custodian: c.custodian || '陈豪'
+ },
+ size: {
+ length: c.length || '59.73(米)',
+ width: c.width || '23.13(米)',
+ height: c.height || '6.00(米)',
+ volume: `${c.volume || 0} m³`,
+ decVolume: `${c.decVolume || 0} m³`
+ },
+ quality: {
+ level: c.level || '壹等',
+ water: c.water ? (c.water.includes('%') ? c.water : `${c.water}%`) : '12.0%',
+ impurity: c.impurity ? (c.impurity.includes('%') ? c.impurity : `${c.impurity}%`) : '0.5%',
+ weight: c.weight ? (c.weight.includes('g') ? c.weight : `${c.weight} g/L`) : '790 g/L',
+ imperfect: c.imperfect ? (c.imperfect.includes('%') ? c.imperfect : `${c.imperfect}%`) : '3.5%',
+ roughnessRate: c.roughnessRate || null,
+ riceGrains: c.riceGrains || null
+ }
+ };
+}
+
+/**
+ * 汇总 API 数据列表中的全库总量、品种分布与仓罐统计
+ * @param {Array