diff --git a/application/admin/controller/Granary.php b/application/admin/controller/Granary.php index a068267..ddde372 100644 --- a/application/admin/controller/Granary.php +++ b/application/admin/controller/Granary.php @@ -99,6 +99,26 @@ class Granary extends Backend { $data = $this->request->post(); if (!empty($data)) { + $content = isset($data['content']) && is_array($data['content']) ? $data['content'] : []; + $position = isset($content['screenPosition']) ? $content['screenPosition'] : ''; + $positions = \app\common\library\GranaryScreen::extraPositions(); + if ($position !== '' && !isset($positions[$position])) { + return json(['code' => 400, 'msg' => '请选择有效的大屏建筑位置']); + } + if ($position !== '') { + if (strpos($data['storage'], '习水') !== false) { + return json(['code' => 400, 'msg' => '此建筑位置属于中心库区']); + } + foreach ($this->model->field('id,content')->select() as $existing) { + $saved = $existing['content']; + if (!is_array($saved)) $saved = json_decode($saved, true); + if ((string)$existing['id'] !== (string)(isset($data['id']) ? $data['id'] : 0) + && isset($saved['screenPosition']) && $saved['screenPosition'] === $position) { + return json(['code' => 400, 'msg' => '此建筑已绑定仓库,请编辑对应仓库或选择其他位置']); + } + } + } + $data['content'] = $content; $id = $this->model->where(['num' => $data['num']])->value('id'); if (isset($data['granary_type']) && in_array(intval($data['granary_type']), [1, 2])) { $data['granary_type'] = intval($data['granary_type']); diff --git a/application/admin/controller/Testing.php b/application/admin/controller/Testing.php index 51b2489..6c23e2b 100644 --- a/application/admin/controller/Testing.php +++ b/application/admin/controller/Testing.php @@ -113,6 +113,10 @@ class Testing extends Backend if (!empty($data)) { $content = $data['content']; $date = $data['record_date']; + $ruleError = \app\common\library\QualityRule::validate($content); + if ($ruleError !== '') { + return json(['code' => 400, 'msg' => $ruleError]); + } // 添加 if (empty($data['id'])) { diff --git a/application/admin/view/granary/index.html b/application/admin/view/granary/index.html index 0023aba..1bd9015 100644 --- a/application/admin/view/granary/index.html +++ b/application/admin/view/granary/index.html @@ -270,6 +270,14 @@ + + + + + + + 新仓库请选择对应建筑;现有标记可直接修改仓号,保存后大屏自动同步。 + 立式油罐 (油类) diff --git a/application/admin/view/testing/index.html b/application/admin/view/testing/index.html index aeeb803..88f57e5 100644 --- a/application/admin/view/testing/index.html +++ b/application/admin/view/testing/index.html @@ -35,6 +35,16 @@ .search-select .el-input--suffix .el-input__inner { width: 130px; } + + .quality-direction-toggle { + display: inline-flex; + flex-wrap: nowrap; + } + + .quality-direction-toggle .el-button { + padding-left: 8px; + padding-right: 8px; + } @@ -214,39 +224,63 @@ + 数值指标请选择“越大越好”或“越小越好”。绿色为达标、黄色为预警、红色为警告;等于阈值时归入较低一级,保存后对大屏生效。 - - - - - - - - + + + + + 越大越好 + 越小越好 + + {{ruleError(scope.row)}} + + 绿 · {{ruleRanges(scope.row)[0]}} + 黄 · {{ruleRanges(scope.row)[1]}} + 红 · {{ruleRanges(scope.row)[2]}} + + + 文字指标,按检测结果判定 + + @@ -274,7 +308,7 @@ width="98%" > - + {{detail.granary.name}} @@ -308,9 +342,20 @@ - + + + + {{ruleDirection(scope.row) === 'lower' ? '● 越大越好' : '● 越小越好'}} + 绿 · {{ruleRanges(scope.row)[0]}} + 黄 · {{ruleRanges(scope.row)[1]}} + 红 · {{ruleRanges(scope.row)[2]}} + + 文字指标,按检测结果判定 + + @@ -326,4 +371,4 @@ - \ No newline at end of file + diff --git a/application/api/controller/Index.php b/application/api/controller/Index.php index ee03396..fa1259a 100644 --- a/application/api/controller/Index.php +++ b/application/api/controller/Index.php @@ -52,8 +52,23 @@ class Index extends Api if (!empty($list) && count($list) > 0) { $list = collection($list)->toArray(); + // Load saved rules in two queries rather than one query per granary. + $tests = TestingModel::with(['testingParams']) + ->where('gid', 'in', array_column($list, 'id')) + ->order('test_date DESC, id DESC')->select(); + $latestTests = []; + foreach ($tests as $test) { + $test = $test->toArray(); + $testKey = $test['gid'] . ':' . $test['type']; + if (!isset($latestTests[$testKey])) { + $latestTests[$testKey] = $test; + } + } + foreach ($list as &$v) { $v['varieties'] = getVarietiesInfo($v['varieties']); + $testKey = $v['id'] . ':' . $v['varieties']['type']; + $v['quality_test'] = isset($latestTests[$testKey]) ? $latestTests[$testKey] : null; $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']; @@ -72,6 +87,12 @@ class Index extends Api } $coords = $this->getSiteCoords($site); + if ($site !== 'xishui' && is_array($list)) { + foreach ($list as &$granary) { + $granary['screen_coord'] = \app\common\library\GranaryScreen::resolve($granary, $coords); + } + unset($granary); + } $this->success('获取成功', [ 'list' => $list, diff --git a/application/common/library/GranaryScreen.php b/application/common/library/GranaryScreen.php new file mode 100644 index 0000000..2071781 --- /dev/null +++ b/application/common/library/GranaryScreen.php @@ -0,0 +1,32 @@ + ['x' => 46.8, 'y' => 28.4], + 'center_lower_right' => ['x' => 58.8, 'y' => 34.1], + ]; + } + + public static function resolve($item, $coords) + { + $content = is_array($item['content']) ? $item['content'] : json_decode($item['content'], true); + $position = isset($content['screenPosition']) ? $content['screenPosition'] : ''; + $extra = self::extraPositions(); + if (isset($extra[$position])) { + return $extra[$position]; + } + // 历史记录按数据库 id 锚定原建筑,避免改号后套用另一栋仓库的位置。 + $originalNumbers = [27 => 30, 29 => 31, 30 => 32, 31 => 10, 33 => 42, + 34 => 33, 36 => 35, 37 => 36, 38 => 12, 39 => 39, 40 => 40, + 42 => 41, 43 => 14, 44 => 37, 45 => 15, 46 => 44, 47 => 16, + 48 => 17, 49 => 18, 50 => 38, 51 => 43]; + $num = isset($originalNumbers[$item['id']]) ? $originalNumbers[$item['id']] : null; + return $num !== null && isset($coords[$num]) ? $coords[$num] : null; + } +} diff --git a/application/common/library/QualityRule.php b/application/common/library/QualityRule.php new file mode 100644 index 0000000..866a78b --- /dev/null +++ b/application/common/library/QualityRule.php @@ -0,0 +1,45 @@ + $alert ? 'lower' : 'upper'; + } + + public static function validate($content) + { + if (!is_array($content) || empty($content)) { + return '请填写检测参数'; + } + foreach ($content as $item) { + if (!is_array($item)) { + return '检测参数格式无效'; + } + if (isset($item['direction']) && !in_array($item['direction'], ['upper', 'lower'], true)) { + return '预警方向无效'; + } + if (!isset($item['warning'], $item['alert'])) { + return '请填写预警值和警告值'; + } + if (is_numeric($item['warning']) && is_numeric($item['alert'])) { + $upper = self::direction($item) === 'upper'; + if (($upper && $item['warning'] > $item['alert']) + || (!$upper && $item['warning'] < $item['alert'])) { + return $item['name'] . ($upper + ? ':越小越好指标要求预警值 ≤ 警告值(高值预警)' + : ':越大越好指标要求预警值 ≥ 警告值(低值预警)'); + } + } + } + return ''; + } +} diff --git a/application/common/model/TestingModel.php b/application/common/model/TestingModel.php index 7057a44..e1e5e8f 100644 --- a/application/common/model/TestingModel.php +++ b/application/common/model/TestingModel.php @@ -799,6 +799,13 @@ class TestingModel extends Model ], ]; + foreach ($typeArr as &$type) { + foreach ($type['content'] as &$item) { + $item['direction'] = \app\common\library\QualityRule::direction($item); + } + unset($item); + } + unset($type); return $typeArr; } @@ -914,6 +921,7 @@ class TestingModel extends Model 'val' => $v['val'], 'warning' => $v['warning'], 'alert' => $v['alert'], + 'direction' => \app\common\library\QualityRule::direction($v), 'test_date' => $data['test_date'], 'year' => $data['year'], 'season' => $data['season'], @@ -956,6 +964,7 @@ class TestingModel extends Model 'val' => $v['val'], 'warning' => $v['warning'], 'alert' => $v['alert'], + 'direction' => \app\common\library\QualityRule::direction($v), 'test_date' => $data['test_date'], 'year' => $data['year'], 'season' => $data['season'], @@ -1013,4 +1022,4 @@ class TestingModel extends Model }); } -} \ No newline at end of file +} diff --git a/data.sql b/data.sql index ace5184..4d56d67 100644 --- a/data.sql +++ b/data.sql @@ -902,3 +902,6 @@ UNLOCK TABLES; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; -- Dump completed on 2024-11-24 22:58:20 + +-- Persist per-indicator warning direction after seed inserts. +ALTER TABLE `fa_testing_params` ADD COLUMN `direction` varchar(5) DEFAULT NULL COMMENT '预警方向:upper高值预警、lower低值预警'; diff --git a/migrations/20261028_quality_direction.sql b/migrations/20261028_quality_direction.sql new file mode 100644 index 0000000..72689fe --- /dev/null +++ b/migrations/20261028_quality_direction.sql @@ -0,0 +1,4 @@ +-- Apply once before deploying the editor/API changes. Adjust fa_ if using a custom prefix. +-- NULL preserves historical inference until a user explicitly saves a direction. +ALTER TABLE `fa_testing_params` + ADD COLUMN `direction` varchar(5) DEFAULT NULL COMMENT '指标偏好:upper越小越好(高值预警)、lower越大越好(低值预警)'; diff --git a/public/assets/js/backend/granary.js b/public/assets/js/backend/granary.js index 0c4309a..1578448 100644 --- a/public/assets/js/backend/granary.js +++ b/public/assets/js/backend/granary.js @@ -395,6 +395,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin image: '', storage: '成都直属库有限公司本部', num: '', + screenPosition: '', granary_type: 1, varieties: '菜籽油', content: [] @@ -413,6 +414,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin image: item.image, storage: item.storage, num: item.num, + screenPosition: (item.content || {}).screenPosition || '', old_num: item.num, granary_type: item.granary_type || (item.num > 18 ? 2 : 1), varieties: item.varieties, @@ -433,7 +435,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin this.varietiesType = 'two'; } - this.contentFields[this.contentType] = item.content || {}; + this.contentFields[this.contentType] = JSON.parse(JSON.stringify(item.content || {})); } }, // 查看详情 @@ -503,6 +505,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin } } fields['varieties'] = this.formData.varieties; + fields['screenPosition'] = this.formData.screenPosition || ''; // 提交 let loading = this.$loading({ @@ -512,12 +515,13 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin background: 'rgba(0, 0, 0, 0.3)' }); - this.formData.content = fields; + const payload = Object.assign({}, this.formData, {content: fields}); + delete payload.screenPosition; $.ajax({ url: 'granary/add', type: 'POST', - data: this.formData, + data: payload, success: res => { loading.close(); if (res.code === 200) { diff --git a/public/assets/js/backend/testing.js b/public/assets/js/backend/testing.js index 6d338a8..a33af67 100644 --- a/public/assets/js/backend/testing.js +++ b/public/assets/js/backend/testing.js @@ -115,6 +115,36 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin this.loadData(this.search.keyword, this.currentPage, this.pageSize, this.search.sort); }, methods: { + isNumericRule(item) { + return [item.warning, item.alert].every(value => + value !== null && value !== undefined && String(value).trim() !== '' && Number.isFinite(Number(value))); + }, + ruleDirection(item) { + return item.direction === 'upper' || item.direction === 'lower' ? item.direction + : (this.isNumericRule(item) && Number(item.warning) > Number(item.alert) ? 'lower' : 'upper'); + }, + rulePreference(item) { + return this.ruleDirection(item) === 'lower' ? 'higher' : 'lower'; + }, + setRuleDirection(item, direction) { + if (direction !== 'upper' && direction !== 'lower') return; + this.$set(item, 'direction', direction); + }, + toggleRule(item) { + this.setRuleDirection(item, this.ruleDirection(item) === 'upper' ? 'lower' : 'upper'); + }, + ruleRanges(item) { + const w = item.warning, a = item.alert; + return this.ruleDirection(item) === 'upper' + ? [`当前值 ≤ ${w}`, `${w} < 当前值 ≤ ${a}`, `当前值 > ${a}`] + : [`当前值 ≥ ${w}`, `${a} ≤ 当前值 < ${w}`, `当前值 < ${a}`]; + }, + ruleError(item) { + if (!this.isNumericRule(item)) return ''; + const upper = this.ruleDirection(item) === 'upper'; + return (upper && Number(item.warning) > Number(item.alert)) || (!upper && Number(item.warning) < Number(item.alert)) + ? (upper ? '越小越好指标要求:预警值 ≤ 警告值(高值预警)' : '越大越好指标要求:预警值 ≥ 警告值(低值预警)') : ''; + }, // 刷新页面 refreshPage() { this.search = { @@ -175,7 +205,13 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin // 参数验证 for (let item of content) { name = item.name; - if (!item.val) { + const error = this.ruleError(item); + if (error) { + this._sweetMsg({ type: 'warning', content: `${name}:${error}` }); + return false; + } + this.$set(item, 'direction', this.ruleDirection(item)); + if (item.val === '' || item.val === null || item.val === undefined) { this._sweetMsg({ type: 'warning', content: `请您输入${name}值` @@ -192,7 +228,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin if (item.alert === '') { this._sweetMsg({ type: 'warning', - content: `请您输入${name}警戒值` + content: `请您输入${name}警告值` }); return false; } @@ -254,7 +290,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }; this.typeName = item.granary.varieties; - this.typeList[item.type]['content'] = item.testing_params; + this.typeList[item.type]['content'] = JSON.parse(JSON.stringify(item.testing_params)); } }, handleSort(item) { diff --git a/public/screen/css/style.css b/public/screen/css/style.css index 0a58814..877ef38 100644 --- a/public/screen/css/style.css +++ b/public/screen/css/style.css @@ -346,7 +346,7 @@ html, body { display: inline-flex; gap: 16px; white-space: nowrap; - animation: marquee-scroll 45s linear infinite; + animation: marquee-scroll var(--marquee-duration, 90s) linear infinite; } .alert-marquee-track:hover { @@ -1189,12 +1189,12 @@ html, body { 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%); + background: linear-gradient(135deg, rgba(12, 26, 44, 0.97) 0%, rgba(8, 19, 33, 0.98) 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%); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); 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; @@ -1462,7 +1462,7 @@ html, body { .forecast-charts-row { display: grid; - grid-template-columns: 58% 42%; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 16px; flex: 1; min-height: 0; @@ -2150,3 +2150,22 @@ html, body { + +/* Forecast analysis: quiet surfaces, legible hierarchy and independent scales. */ +.forecast-charts-row > .center-chart-card { min-width: 0; } +.forecast-charts-row .center-chart-card-title-bar { flex-wrap: wrap; align-items: flex-start; gap: 4px; margin-bottom: 8px; } +.forecast-charts-row .center-chart-card-title { color: #e3edf7; font-size: 13px; margin-bottom: 0; letter-spacing: .2px; } +.forecast-charts-row .center-chart-card:last-child { padding-left: 18px; border-left-color: rgba(148, 173, 200, .13); } +.forecast-chart-note { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px; color: #8ba4bd; font-size: 10px; line-height: 1.7; padding: 0 0 12px; } +.threshold-key { color: #dfb883; } +.forecast-env-pill { color: #9eb5cb; border-color: #314459; background: #132438; font-size: 9px; } +.forecast-env-bar { border-color: #2a4058; box-shadow: none; padding: 8px 10px; flex-wrap: wrap; } +.forecast-env-items { flex-wrap: wrap; gap: 7px; } +.forecast-env-label { color: #c2d2e3; } +.forecast-env-label .env-pulse-icon, .chart-pulse-dot { animation: none; box-shadow: none; } +.center-chart-board .center-chart-title { color: #dcebf8; } +.center-chart-tabs { border-color: #30465e; border-radius: 6px; background: #0b192b; } +.center-chart-tab { padding: 6px 12px; } +.center-chart-tab#tab-chart-forecast.active { background: #193e58; color: #93e4f6; box-shadow: none; border-color: #306079; } +.center-chart-board button:focus-visible { outline: 2px solid #91d9f5; outline-offset: 3px; } +@media (prefers-reduced-motion: reduce) { .center-chart-board { animation: none; } } diff --git a/public/screen/index.html b/public/screen/index.html index e6d6f75..ef3cfca 100644 --- a/public/screen/index.html +++ b/public/screen/index.html @@ -4,7 +4,7 @@ 中储粮(遵义)智慧粮仓综合监测中心 - + @@ -298,7 +298,7 @@ - 推演环境工况 (API输入): + 推演环境设置 @@ -340,9 +340,10 @@ - 粮食质量指标多周期演变趋势推演 (未来 7~60天走势与国家警戒线) + 多周期趋势 工况: 仓内 18.5℃ / 72.4% · 仓外 32.5℃ / 54.2% + 各指标独立刻度 · 保留原始单位● 实测起点 ┈ 预测趋势 ┄ 警戒参考 @@ -350,6 +351,7 @@ 现状实测值 vs 预测终值偏离对比与风险评估 + 同一指标内对比柱长 · 悬停查看变化量 @@ -403,7 +405,7 @@ 数据接口: https://grain.gynhc.com/api/index/granaryList (数据同步正常) - +
数值指标请选择“越大越好”或“越小越好”。绿色为达标、黄色为预警、红色为警告;等于阈值时归入较低一级,保存后对大屏生效。