feat(testing): 增加质检报告独立编辑与详情页面,不再使用弹窗
这个提交包含在:
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\controller\GranaryBackend;
|
||||
use app\common\library\GranarySite;
|
||||
use app\common\model\ContentModel;
|
||||
use think\Db;
|
||||
|
||||
@@ -11,7 +12,7 @@ use think\Db;
|
||||
* Class Content
|
||||
* @package app\admin\controller\Content
|
||||
*/
|
||||
class Content extends Backend
|
||||
class Content extends GranaryBackend
|
||||
{
|
||||
|
||||
/**
|
||||
@@ -49,20 +50,19 @@ class Content extends Backend
|
||||
$endTime = input('param.end_time', '');
|
||||
$rid = input('param.rid/d');
|
||||
$date = input('param.date');
|
||||
$result = $this->model->getLists([
|
||||
$this->scopeRecords($this->model);
|
||||
$result = $this->model->getLists([
|
||||
'keyword' => $key,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'rid' => $rid,
|
||||
'gid' => $rid,
|
||||
'date' => $date,
|
||||
], $rows, $page);
|
||||
|
||||
// 仓库
|
||||
$result['granaryList'] = Db::name('granary')
|
||||
->field(['id', 'name'])
|
||||
->select();
|
||||
$result['granaryList'] = GranarySite::granaries($this->filterSites);
|
||||
|
||||
return json($result);
|
||||
}
|
||||
@@ -152,9 +152,7 @@ class Content extends Backend
|
||||
$data['content'] = json_decode($data['content'], true);
|
||||
}
|
||||
|
||||
$granaryList = Db::name('granary')
|
||||
->field(['id', 'name'])
|
||||
->select();;
|
||||
$granaryList = GranarySite::granaries($this->granarySites);
|
||||
|
||||
return json([
|
||||
'code' => 200,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\GranaryBackend;
|
||||
use app\common\library\GranarySite;
|
||||
use app\common\library\GrainForecast;
|
||||
use think\Db;
|
||||
|
||||
class Forecast extends GranaryBackend
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if (!$this->request->isAjax()) return $this->fetch();
|
||||
$gid = $this->request->get('gid/d', 0);
|
||||
$page = max(1, $this->request->get('page/d', 1));
|
||||
$query = $this->scopeRecords(Db::name('grain_forecast'));
|
||||
if ($gid) $query->where('gid', $gid);
|
||||
$total = $query->count();
|
||||
$query = $this->scopeRecords(Db::name('grain_forecast'));
|
||||
if ($gid) $query->where('gid', $gid);
|
||||
$list = $query->field('request_key', true)->order('id DESC')->page($page, 15)->select();
|
||||
$points = $list ? Db::name('grain_forecast_point')->where('forecast_id', 'in', array_column($list, 'id'))->select() : [];
|
||||
foreach ($list as &$row) {
|
||||
$row['measured'] = 0;
|
||||
$row['point_count'] = 0;
|
||||
foreach ($points as $point) {
|
||||
if ($point['forecast_id'] != $row['id']) continue;
|
||||
$row['point_count']++;
|
||||
$actual = json_decode($point['actual'], true) ?: [];
|
||||
$filled = count(array_filter($actual, function ($v) { return $v !== null && $v !== ''; }));
|
||||
if ($filled === count(GrainForecast::metrics($row['grain_type']))) $row['measured']++;
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
$granaries = array_values(array_filter(GranarySite::granaries(->filterSites), function () { return in_array(['varieties'], ['小麦', '稻谷'], true); }));
|
||||
if (!empty()) {
|
||||
= array_column(, 'id');
|
||||
= Db::name('grain_forecast')->where('gid', 'in', )
|
||||
->field('gid,tm_i,hm_i,tm_o,hm_o,observed_date')->order('id DESC')->select();
|
||||
= [];
|
||||
foreach ( as ) {
|
||||
if (!isset([['gid']])) {
|
||||
[['gid']] = [
|
||||
'tm_i' => (float)['tm_i'],
|
||||
'hm_i' => (float)['hm_i'],
|
||||
'tm_o' => (float)['tm_o'],
|
||||
'hm_o' => (float)['hm_o'],
|
||||
'observed_date' => ['observed_date']
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach ( as &) {
|
||||
['last_env'] = isset([['id']]) ? [['id']] : null;
|
||||
}
|
||||
unset();
|
||||
}
|
||||
return json(['code' => 200, 'list' => , 'total' => , 'granaries' => ]);
|
||||
}
|
||||
|
||||
public function detail()
|
||||
{
|
||||
try {
|
||||
return json(['code' => 200, 'data' => (new GrainForecast())->detail($this->request->get('id/d', 0))]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return json(['code' => 400, 'msg' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
return $this->write(function ($service) {
|
||||
$id = $service->create($this->request->post(), $this->auth->id);
|
||||
$service->generate($id);
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
public function retry()
|
||||
{
|
||||
return $this->write(function ($service) {
|
||||
$id = $this->request->post('id/d', 0);
|
||||
$service->generate($id);
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
public function actual()
|
||||
{
|
||||
return $this->write(function ($service) {
|
||||
return $service->saveActual($this->request->post(), $this->auth->id);
|
||||
});
|
||||
}
|
||||
|
||||
protected function write($action)
|
||||
{
|
||||
if (!$this->request->isPost()) return json(['code' => 405, 'msg' => '请使用POST提交']);
|
||||
try {
|
||||
$service = new GrainForecast();
|
||||
$id = $action($service);
|
||||
return json(['code' => 200, 'msg' => '保存成功', 'data' => $service->detail($id)]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return json(['code' => 400, 'msg' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::record('Grain forecast: ' . $e->getMessage(), 'error');
|
||||
return json(['code' => 500, 'msg' => '保存失败,请刷新查看记录后重试']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\controller\GranaryBackend;
|
||||
use app\common\library\GranarySite;
|
||||
use app\common\model\ChinaModel;
|
||||
use app\common\model\GranaryModel;
|
||||
use app\common\model\TestingModel;
|
||||
@@ -12,7 +13,7 @@ use app\common\model\TestingModel;
|
||||
* Class Granary
|
||||
* @package app\admin\controller
|
||||
*/
|
||||
class Granary extends Backend
|
||||
class Granary extends GranaryBackend
|
||||
{
|
||||
|
||||
/**
|
||||
@@ -50,6 +51,7 @@ class Granary extends Backend
|
||||
$granaryType = input('param.granary_type', '');
|
||||
$china = input('param.china/a', '');
|
||||
$varieties = input('param.varieties', '');
|
||||
$this->model->where('site', 'in', $this->filterSites ?: ['__none__']);
|
||||
$result = $this->model->getLists([
|
||||
'keyword' => $key,
|
||||
'type' => $type,
|
||||
@@ -99,6 +101,17 @@ class Granary extends Backend
|
||||
{
|
||||
$data = $this->request->post();
|
||||
if (!empty($data)) {
|
||||
$site = isset($data['site']) ? $data['site'] : '';
|
||||
if (!GranarySite::valid($site) || !in_array($site, $this->granarySites, true)) {
|
||||
return json(['code' => 400, 'msg' => '请选择有权限的所属库区']);
|
||||
}
|
||||
$data['storage'] = GranarySite::STORAGE[$site];
|
||||
if (!isset($data['num']) || !preg_match('/^[1-9]\d{0,8}$/D', (string)$data['num'])) {
|
||||
return json(['code' => 400, 'msg' => '仓号必须是正整数']);
|
||||
}
|
||||
$duplicate = $this->model->where('site', $site)->where('num', $data['num'])
|
||||
->where('id', '<>', isset($data['id']) ? (int)$data['id'] : 0)->count();
|
||||
if ($duplicate) return json(['code' => 400, 'msg' => '该库区的仓号已存在']);
|
||||
$content = isset($data['content']) && is_array($data['content']) ? $data['content'] : [];
|
||||
$position = isset($content['screenPosition']) ? $content['screenPosition'] : '';
|
||||
$positions = \app\common\library\GranaryScreen::extraPositions();
|
||||
@@ -106,7 +119,7 @@ class Granary extends Backend
|
||||
return json(['code' => 400, 'msg' => '请选择有效的大屏建筑位置']);
|
||||
}
|
||||
if ($position !== '') {
|
||||
if (strpos($data['storage'], '习水') !== false) {
|
||||
if ($site === 'xishui') {
|
||||
return json(['code' => 400, 'msg' => '此建筑位置属于中心库区']);
|
||||
}
|
||||
foreach ($this->model->field('id,content')->select() as $existing) {
|
||||
@@ -119,11 +132,10 @@ class Granary extends Backend
|
||||
}
|
||||
}
|
||||
$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, 3])) {
|
||||
$data['granary_type'] = intval($data['granary_type']);
|
||||
} else {
|
||||
$data['granary_type'] = $data['num'] > 18 ? 2 : 1;
|
||||
return json(['code' => 400, 'msg' => '请选择有效仓型']);
|
||||
}
|
||||
if ($position !== '') {
|
||||
$data['granary_type'] = strpos($position, 'center_silo_') === 0 ? 3 : 2;
|
||||
@@ -135,12 +147,6 @@ class Granary extends Backend
|
||||
|
||||
// 添加
|
||||
if (empty($data['id'])) {
|
||||
if (!empty($id)) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'msg' => '您输入的仓号已存在'
|
||||
]);
|
||||
}
|
||||
|
||||
unset($data['id'], $data['old_num'], $data['china']);
|
||||
$data['create_time'] = time();
|
||||
@@ -158,14 +164,6 @@ class Granary extends Backend
|
||||
}
|
||||
}
|
||||
|
||||
if ($data['num'] != $data['old_num']) {
|
||||
if (!empty($id)) {
|
||||
return json([
|
||||
'code' => 401,
|
||||
'msg' => '您输入的仓号已存在'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
$data['update_time'] = time();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\controller\GranaryBackend;
|
||||
use app\common\library\GranarySite;
|
||||
use app\common\model\TestingModel;
|
||||
use think\Db;
|
||||
|
||||
@@ -11,7 +12,7 @@ use think\Db;
|
||||
* Class Testing
|
||||
* @package app\admin\controller
|
||||
*/
|
||||
class Testing extends Backend
|
||||
class Testing extends GranaryBackend
|
||||
{
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,8 @@ class Testing extends Backend
|
||||
$startTime = input('param.start_time', '');
|
||||
$endTime = input('param.end_time', '');
|
||||
$season = input('param.season', '');
|
||||
$result = $this->model->getLists([
|
||||
$this->scopeRecords($this->model);
|
||||
$result = $this->model->getLists([
|
||||
'gid' => $gid,
|
||||
'type' => $type,
|
||||
'date' => $date,
|
||||
@@ -60,9 +62,7 @@ class Testing extends Backend
|
||||
], $rows, $page);
|
||||
|
||||
// 仓库
|
||||
$result['granaryList'] = Db::name('granary')
|
||||
->field(['id', 'name', 'varieties'])
|
||||
->select();
|
||||
$result['granaryList'] = GranarySite::granaries($this->granarySites);
|
||||
if (!empty($result['granaryList'])) {
|
||||
foreach ($result['granaryList'] as &$v) {
|
||||
$w = getVarietiesInfo($v['varieties']);
|
||||
@@ -103,55 +103,151 @@ class Testing extends Backend
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加编辑
|
||||
* 获取仓房列表下拉数据
|
||||
* @return array
|
||||
*/
|
||||
protected function getGranaryOptions()
|
||||
{
|
||||
$granaryList = GranarySite::granaries($this->granarySites);
|
||||
if (!empty($granaryList)) {
|
||||
foreach ($granaryList as &$v) {
|
||||
$w = getVarietiesInfo($v['varieties']);
|
||||
$v['type'] = $w['type'];
|
||||
}
|
||||
}
|
||||
return $granaryList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加(GET返回独立页面,POST执行添加)
|
||||
* @return string|\think\response\Json
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
if ($this->request->isPost()) {
|
||||
$data = $this->request->post();
|
||||
|
||||
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)) {
|
||||
$content = $data['content'];
|
||||
$date = $data['record_date'];
|
||||
$types = $this->model->getTypeList();
|
||||
$originalRules = isset($types[$data['type']]) ? $types[$data['type']]['content'] : [];
|
||||
if (!empty($data['id'])) {
|
||||
$existing = $this->model->getItem((int)$data['id'], '*', ['testingParams']);
|
||||
if (!$existing) {
|
||||
return json(['code' => 400, 'msg' => '检测记录不存在']);
|
||||
}
|
||||
$existing = $existing->toArray();
|
||||
$originalRules = $existing['testing_params'];
|
||||
$allowedIds = array_column($originalRules, 'id');
|
||||
foreach (is_array($content) ? $content : [] as $item) {
|
||||
if (!is_array($item) || empty($item['id']) || !in_array($item['id'], $allowedIds)) {
|
||||
return json(['code' => 400, 'msg' => '检测参数不属于当前记录,请刷新后重试']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$ruleError = \app\common\library\QualityRule::validate($content, $originalRules);
|
||||
if ($ruleError !== '') {
|
||||
return json(['code' => 400, 'msg' => $ruleError]);
|
||||
}
|
||||
|
||||
// 添加
|
||||
if (empty($data['id'])) {
|
||||
unset($data['id'], $data['content'], $data['record_date']);
|
||||
$data['create_time'] = time();
|
||||
// 添加
|
||||
if (empty($data['id'])) {
|
||||
unset($data['id'], $data['content'], $data['record_date']);
|
||||
$data['create_time'] = time();
|
||||
|
||||
if ($this->model->add($data, $content, $date)) {
|
||||
if ($this->model->add($data, $content, $date)) {
|
||||
return json([
|
||||
'code' => 200,
|
||||
'msg' => '添加成功'
|
||||
]);
|
||||
} else {
|
||||
return json([
|
||||
'code' => 400,
|
||||
'msg' => '添加失败'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
$data['update_time'] = time();
|
||||
unset($data['content'], $data['record_date']);
|
||||
|
||||
if ($this->model->edit($data, $content, $date)) {
|
||||
return json([
|
||||
'code' => 200,
|
||||
'msg' => '添加成功'
|
||||
'msg' => '编辑成功'
|
||||
]);
|
||||
} else {
|
||||
return json([
|
||||
'code' => 400,
|
||||
'msg' => '添加失败'
|
||||
'msg' => '编辑失败'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
$data['update_time'] = time();
|
||||
unset($data['content'], $data['record_date']);
|
||||
|
||||
if ($this->model->edit($data, $content, $date)) {
|
||||
return json([
|
||||
'code' => 200,
|
||||
'msg' => '编辑成功'
|
||||
]);
|
||||
} else {
|
||||
return json([
|
||||
'code' => 400,
|
||||
'msg' => '编辑失败'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$granaryList = $this->getGranaryOptions();
|
||||
$typeList = $this->model->getTypeList();
|
||||
|
||||
$this->assign('item', null);
|
||||
$this->assign('granaryList', $granaryList);
|
||||
$this->assign('typeList', $typeList);
|
||||
$this->assign('siteOptions', $this->granarySites);
|
||||
|
||||
return $this->fetch('edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立编辑页面
|
||||
* @param null $ids
|
||||
* @return string
|
||||
* @throws \think\Exception
|
||||
*/
|
||||
public function edit($ids = null)
|
||||
{
|
||||
$id = $ids ? $ids : input('param.id');
|
||||
$item = $this->model->getItem((int)$id, '*', ['granary', 'testingParams']);
|
||||
if (!$item) {
|
||||
$this->error('检测记录不存在');
|
||||
}
|
||||
$item = $item->toArray();
|
||||
|
||||
$granaryList = $this->getGranaryOptions();
|
||||
$typeList = $this->model->getTypeList();
|
||||
|
||||
$this->assign('item', $item);
|
||||
$this->assign('granaryList', $granaryList);
|
||||
$this->assign('typeList', $typeList);
|
||||
$this->assign('siteOptions', $this->granarySites);
|
||||
|
||||
return $this->fetch('edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立详情页面
|
||||
* @param null $ids
|
||||
* @return string
|
||||
* @throws \think\Exception
|
||||
*/
|
||||
public function detail($ids = null)
|
||||
{
|
||||
$id = $ids ? $ids : input('param.id');
|
||||
$item = $this->model->getItem((int)$id, '*', ['granary', 'testingParams']);
|
||||
if (!$item) {
|
||||
$this->error('检测记录不存在');
|
||||
}
|
||||
$item = $item->toArray();
|
||||
|
||||
$granaryList = $this->getGranaryOptions();
|
||||
$typeList = $this->model->getTypeList();
|
||||
|
||||
$this->assign('item', $item);
|
||||
$this->assign('granaryList', $granaryList);
|
||||
$this->assign('typeList', $typeList);
|
||||
$this->assign('siteOptions', $this->granarySites);
|
||||
|
||||
return $this->fetch('detail');
|
||||
}
|
||||
|
||||
// 删除
|
||||
|
||||
@@ -60,6 +60,7 @@ class Admin extends Backend
|
||||
}
|
||||
|
||||
$this->view->assign('groupdata', $groupdata);
|
||||
$this->view->assign('canSetGranarySites', $this->auth->isSuperAdmin());
|
||||
$this->assignconfig("admin", ['id' => $this->auth->id]);
|
||||
}
|
||||
|
||||
@@ -122,6 +123,18 @@ class Admin extends Backend
|
||||
if ($this->request->isPost()) {
|
||||
$this->token();
|
||||
$params = $this->request->post("row/a");
|
||||
if ($params) {
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
$scope = isset($params['granary_sites']) ? $params['granary_sites'] : 'all';
|
||||
if (!in_array($scope, ['all', 'center', 'xishui', 'none'], true)) $this->error('无效库区权限');
|
||||
$params['granary_sites'] = $scope;
|
||||
} elseif ($this->request->action() === 'add') {
|
||||
$ownScope = Db::name('admin')->where('id', $this->auth->id)->value('granary_sites');
|
||||
$params['granary_sites'] = $ownScope ?: 'none';
|
||||
} else {
|
||||
unset($params['granary_sites']);
|
||||
}
|
||||
}
|
||||
if ($params) {
|
||||
Db::startTrans();
|
||||
try {
|
||||
@@ -175,6 +188,18 @@ class Admin extends Backend
|
||||
if ($this->request->isPost()) {
|
||||
$this->token();
|
||||
$params = $this->request->post("row/a");
|
||||
if ($params) {
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
$scope = isset($params['granary_sites']) ? $params['granary_sites'] : 'all';
|
||||
if (!in_array($scope, ['all', 'center', 'xishui', 'none'], true)) $this->error('无效库区权限');
|
||||
$params['granary_sites'] = $scope;
|
||||
} elseif ($this->request->action() === 'add') {
|
||||
$ownScope = Db::name('admin')->where('id', $this->auth->id)->value('granary_sites');
|
||||
$params['granary_sites'] = $ownScope ?: 'none';
|
||||
} else {
|
||||
unset($params['granary_sites']);
|
||||
}
|
||||
}
|
||||
if ($params) {
|
||||
Db::startTrans();
|
||||
try {
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
{:build_radios('row[status]', ['normal'=>__('Normal'), 'hidden'=>__('Hidden')])}
|
||||
</div>
|
||||
</div>
|
||||
{if $canSetGranarySites}
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">库区数据权限:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
{:build_select('row[granary_sites]', ['all'=>'全部库区', 'center'=>'遵义中心库', 'xishui'=>'习水库', 'none'=>'无库区权限'], 'all', ['class'=>'form-control'])}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="form-group hidden layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
|
||||
@@ -48,6 +48,14 @@
|
||||
{:build_radios('row[status]', ['normal'=>__('Normal'), 'hidden'=>__('Hidden')], $row['status'])}
|
||||
</div>
|
||||
</div>
|
||||
{if $canSetGranarySites}
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">库区数据权限:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
{:build_select('row[granary_sites]', ['all'=>'全部库区', 'center'=>'遵义中心库', 'xishui'=>'习水库', 'none'=>'无库区权限'], $row['granary_sites'], ['class'=>'form-control'])}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="form-group hidden layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
|
||||
@@ -51,13 +51,18 @@
|
||||
</style>
|
||||
<div id="vue" data-id="{$id}" v-cloak>
|
||||
<el-form ref="form" :rules="rules" :model="formData" label-width="90px">
|
||||
<el-form-item label="所属库区:" required>
|
||||
<el-select v-model="formSite" @change="formSiteChanged" placeholder="请选择库区" style="width: 330px">
|
||||
<el-option v-for="site in siteOptions" :key="site.id" :value="site.id" :label="site.name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属仓库:" prop="gid">
|
||||
<el-select style="width: 330px;" v-model="formData.gid" filterable clearable
|
||||
placeholder="请您选择所属仓库">
|
||||
<el-option
|
||||
v-for="item in granaryList"
|
||||
v-for="item in siteGranaries"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:label="granaryLabel(item)"
|
||||
:value="item.id">
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
<div id="vue" v-cloak>
|
||||
<div class="screen-container">
|
||||
<div class="screen-container-show">
|
||||
<div class="screen-container-right display-flex" style="margin-right: 12px">
|
||||
<el-select v-model="search.site" @change="siteChanged" clearable size="small" placeholder="全部库区" aria-label="筛选库区" style="width: 150px">
|
||||
<el-option v-for="site in siteOptions" :key="site.id" :value="site.id" :label="site.name"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="screen-container-left display-flex">
|
||||
<el-button size="small" @click="refreshPage" icon="el-icon-refresh" circle type="primary"></el-button>
|
||||
<div class="datetime">
|
||||
@@ -71,7 +76,7 @@
|
||||
<el-option
|
||||
v-for="item in granaryList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:label="granaryLabel(item)"
|
||||
:value="item.id">
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<link rel="stylesheet" href="__CDN__/assets/element-ui-2.15.14/css/index.css">
|
||||
<link rel="stylesheet" href="__CDN__/assets/css/vvv.css">
|
||||
<style>
|
||||
.forecast-page { background: #fff; padding: 20px; }
|
||||
.forecast-toolbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 18px; }
|
||||
.forecast-toolbar h3 { margin: 0 auto 0 0; font-size: 18px; }
|
||||
.forecast-intro { color: #606266; margin: 0 0 20px; line-height: 1.8; }
|
||||
.forecast-env { display: flex; flex-wrap: wrap; gap: 12px 28px; margin: 16px 0; color: #606266; }
|
||||
.forecast-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0 20px; }
|
||||
.forecast-fields .el-input, .forecast-fields .el-date-editor { width: 100%; }
|
||||
.forecast-page .el-pagination { margin-top: 18px; text-align: right; }
|
||||
.forecast-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
|
||||
.forecast-error { margin-bottom: 16px; }
|
||||
.forecast-actual-date { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 16px 0; }
|
||||
.forecast-hint { color: #909399; font-size: 13px; line-height: 1.8; }
|
||||
.forecast-page .el-dialog { max-width: calc(100vw - 24px); }
|
||||
@media (max-width: 700px) { .forecast-fields { grid-template-columns: 1fr; } .forecast-page { padding: 12px; } }
|
||||
</style>
|
||||
<div id="forecast-app" class="forecast-page" v-cloak>
|
||||
<div class="forecast-toolbar">
|
||||
<h3>温湿度预测与实测</h3>
|
||||
<el-select v-model="site" clearable placeholder="全部库区" @change="siteChanged" style="width: 150px" aria-label="筛选库区"><el-option v-for="s in siteOptions" :key="s.id" :value="s.id" :label="s.name"></el-option></el-select>
|
||||
<el-select v-model="gid" clearable filterable placeholder="全部仓库" @change="filterChanged" style="width: 280px" aria-label="筛选仓库">
|
||||
<el-option v-for="g in granaries" :key="g.id" :value="g.id" :label="g.label"></el-option>
|
||||
</el-select>
|
||||
<el-button icon="el-icon-refresh" size="small" @click="load" :loading="loading">刷新</el-button>
|
||||
<el-button type="primary" icon="el-icon-plus" size="small" @click="newRecord">录入温湿度并预测</el-button>
|
||||
</div>
|
||||
<p class="forecast-intro">选择仓库,录入采集当天的仓内外温湿度,生成未来品质指标预测;取得检测结果后,在对应周期补录实测值并查看偏差。</p>
|
||||
<forecast-list :rows="rows" :loading="loading" @open="openRecord"></forecast-list>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="15" :current-page="page" @current-change="changePage"></el-pagination>
|
||||
<el-dialog title="录入温湿度并预测" :visible.sync="creating" width="760px" :close-on-click-modal="false" :before-close="closeCreate" :show-close="!saving">
|
||||
<forecast-create v-if="creating" ref="create" :granaries="granaries" :busy="saving" @submit="createRecord" @cancel="closeCreate"></forecast-create>
|
||||
</el-dialog>
|
||||
<el-dialog title="预测与真实监测值对比" :visible.sync="viewing" width="1180px" :close-on-click-modal="false" :before-close="closeDetail" :show-close="!saving">
|
||||
<forecast-detail v-if="viewing && selected" ref="detail" :record="selected" :busy="saving" @save="saveActual" @retry="retry"></forecast-detail>
|
||||
<div class="forecast-foot"><el-button @click="closeDetail" :disabled="saving">关闭</el-button></div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<script type="text/x-template" id="forecast-list-template">
|
||||
<el-table :data="rows" border stripe v-loading="loading" empty-text="暂无预测记录,请先录入温湿度">
|
||||
<el-table-column prop="id" label="记录编号" width="90"></el-table-column>
|
||||
<el-table-column prop="granary_name" label="仓库" min-width="120"></el-table-column>
|
||||
<el-table-column prop="storage" label="库区" min-width="180" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="observed_date" label="采集日期" width="115"></el-table-column>
|
||||
<el-table-column label="仓内温湿度" min-width="175">
|
||||
<template slot-scope="s">
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<span>{{number(s.row.tm_i)}}℃ / {{number(s.row.hm_i)}}%</span>
|
||||
<el-tag :type="evalTemp(s.row.tm_i, s.row.hm_i).tagType" size="mini" effect="plain">{{evalTemp(s.row.tm_i, s.row.hm_i).label}}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="仓外温湿度" min-width="145"><template slot-scope="s">{{number(s.row.tm_o)}}℃ / {{number(s.row.hm_o)}}%</template></el-table-column>
|
||||
<el-table-column label="预测状态" width="130"><template slot-scope="s"><el-tag :type="s.row.status === 'ready' ? 'success' : s.row.status === 'failed' ? 'danger' : 'warning'" size="small">{{status(s.row.status)}}</el-tag></template></el-table-column>
|
||||
<el-table-column label="实测完成周期" width="120"><template slot-scope="s">{{s.row.measured}} / {{s.row.point_count}}</template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right"><template slot-scope="s"><el-button type="text" @click="$emit('open', s.row.id)">查看 / 补录实测</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</script>
|
||||
|
||||
<script type="text/x-template" id="forecast-create-template">
|
||||
<el-form label-position="top" @submit.native.prevent>
|
||||
<el-form-item label="所属仓库(小麦 / 稻谷)" required>
|
||||
<el-select v-model="form.gid" filterable placeholder="请选择仓库" style="width: 100%" :disabled="busy">
|
||||
<el-option v-for="g in granaries" :key="g.id" :value="g.id" :label="g.label"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- 中储粮温控规范快捷工况 -->
|
||||
<div class="forecast-preset-box" style="margin-bottom: 16px; padding: 10px 14px; background: #f4f8fa; border: 1px solid #dbe6ee; border-radius: 6px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="font-size: 13px; font-weight: bold; color: #304157;">🎯 快捷规范工况模板:</span>
|
||||
<span style="font-size: 12px; color: #909399;">符合中储粮《粮油储藏技术规范》GB/T 29890</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<el-button v-for="(p, k) in presets" :key="k" size="mini" :type="selectedPresetKey === k ? p.type : ''" plain @click="applyPreset(k)">
|
||||
{{p.name}} ({{p.tm_i}}℃ / {{p.hm_i}}%)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时温控规范研判指示卡片 -->
|
||||
<div class="forecast-standard-banner" style="margin-bottom: 16px; padding: 8px 12px; border-radius: 6px; font-size: 13px; display: flex; align-items: center; justify-content: space-between;"
|
||||
:style="{ background: tempStatus.isHigh ? '#fef0f0' : (tempStatus.grade === 'low' ? '#f0f9eb' : '#edf6fc'), border: '1px solid ' + (tempStatus.isHigh ? '#fde2e2' : (tempStatus.grade === 'low' ? '#e1f3d8' : '#d9ecff')) }">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<el-tag :type="tempStatus.tagType" size="small">{{tempStatus.label}}</el-tag>
|
||||
<span :style="{ color: tempStatus.isHigh ? '#f56c6c' : (tempStatus.grade === 'low' ? '#67c23a' : '#409eff') }">{{tempStatus.hint}}</span>
|
||||
</div>
|
||||
<span v-if="tempStatus.isHigh" style="font-size: 12px; color: #f56c6c; font-weight: bold;">[注意通风降温]</span>
|
||||
</div>
|
||||
|
||||
<div class="forecast-fields">
|
||||
<el-form-item label="温湿度采集日期" required><el-date-picker v-model="form.observed_date" type="date" value-format="yyyy-MM-dd" :picker-options="dateOptions" :disabled="busy" :editable="false" placeholder="选择采集日期"></el-date-picker></el-form-item>
|
||||
<el-form-item label="预测周期" required><el-checkbox-group v-model="form.horizons" :disabled="busy"><el-checkbox v-for="day in [7,15,30,60]" :key="day" :label="day">{{day}}天后</el-checkbox></el-checkbox-group></el-form-item>
|
||||
<el-form-item v-for="field in fields" :key="field.key" :label="field.label" required>
|
||||
<el-input v-model="form[field.key]" :label="field.label" type="number" :min="field.min" :max="field.max" step="0.1" :disabled="busy" placeholder="请输入实测环境值"><template slot="append">{{field.unit}}</template></el-input>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<p class="forecast-hint"><strong>中储粮控温技术规范:</strong>低温储粮 ≤15℃,准低温储粮 ≤20℃,常温储粮 ≤25℃(>25℃需通风或机械降温)。仓内相对湿度建议 ≤70%~75%。预测目标日期按采集日期加周期计算。</p>
|
||||
<div class="forecast-foot"><el-button :disabled="busy" @click="$emit('cancel')">取消</el-button><el-button type="primary" :loading="busy" @click="submit">保存并生成预测</el-button></div>
|
||||
</el-form>
|
||||
</script>
|
||||
|
||||
<script type="text/x-template" id="forecast-detail-template">
|
||||
<div>
|
||||
<strong>{{record.storage}} · {{record.granary_name}} · {{record.grain_type === 'wheat' ? '小麦' : '稻谷'}}</strong>
|
||||
<div class="forecast-env"><span>采集日期:{{record.observed_date}}</span><span>仓内:{{number(record.tm_i)}}℃ / {{number(record.hm_i)}}%</span><span>仓外:{{number(record.tm_o)}}℃ / {{number(record.hm_o)}}%</span></div>
|
||||
<div v-if="record.status !== 'ready'" class="forecast-error">
|
||||
<el-alert :title="record.status === 'processing' ? '预测正在生成,稍后刷新查看结果;若请求中断,两分钟后可重试。' : (record.error || '温湿度已保存,等待生成预测。')" :type="record.status === 'failed' ? 'error' : 'warning'" :closable="false" show-icon></el-alert>
|
||||
<el-button style="margin-top: 10px" size="small" type="primary" :loading="busy" @click="$emit('retry', record.id)">重新生成预测 / 刷新状态</el-button>
|
||||
</div>
|
||||
<el-tabs v-model="active" :before-leave="beforeLeave">
|
||||
<el-tab-pane v-for="p in record.points" :key="p.id" :name="String(p.id)" :label="'+' + p.horizon + '天 · ' + p.target_date"></el-tab-pane>
|
||||
</el-tabs>
|
||||
<div v-if="point">
|
||||
<div class="forecast-actual-date"><strong>预测目标:{{point.target_date}}</strong><label>实际检测日期:</label><el-date-picker v-model="actualDate" type="date" value-format="yyyy-MM-dd" :picker-options="dateOptions" :editable="false" :disabled="busy" placeholder="取得结果后选择实际检测日期"></el-date-picker></div>
|
||||
<el-alert v-if="actualDate && actualDate !== point.target_date" title="实际检测日期与预测目标日期不同,以下偏差仅供参考,请核对是否选对周期。" type="warning" :closable="false" show-icon style="margin-bottom: 12px"></el-alert>
|
||||
<el-table :data="comparison" border>
|
||||
<el-table-column prop="name" label="监测指标" min-width="200"></el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="100"></el-table-column>
|
||||
<el-table-column label="原始预测值" width="125"><template slot-scope="s">{{number(s.row.predicted)}}</template></el-table-column>
|
||||
<el-table-column label="人工实测值" min-width="155"><template slot-scope="s"><el-input v-model="actual[s.row.key]" :label="s.row.name + '实测值'" type="number" min="0" :max="s.row.max" step="0.0001" :disabled="busy" placeholder="待检测,可留空"></el-input></template></el-table-column>
|
||||
<el-table-column label="偏差(实测−预测)" width="150"><template slot-scope="s">{{signed(s.row.delta)}}</template></el-table-column>
|
||||
<el-table-column label="相对偏差" width="115"><template slot-scope="s">{{s.row.percent === null ? '—' : signed(s.row.percent) + '%'}}</template></el-table-column>
|
||||
</el-table>
|
||||
<p class="forecast-hint">实测值可以分次补录;空白表示未检测,0 表示真实零值。偏差 = 实测值 − 预测值;相对偏差 = 偏差 ÷ |预测值|,预测值为0时不计算相对偏差。</p>
|
||||
<div class="forecast-foot"><el-button type="primary" :loading="busy" @click="save">保存本周期实测值</el-button></div>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script src="__CDN__/assets/element-ui-2.15.14/js/vue.js"></script>
|
||||
<script src="__CDN__/assets/element-ui-2.15.14/js/index.js"></script>
|
||||
@@ -87,6 +87,11 @@
|
||||
<div id="vue" v-cloak>
|
||||
<div class="screen-container">
|
||||
<div class="screen-container-show">
|
||||
<div class="screen-container-right display-flex" style="margin-right: 12px">
|
||||
<el-select v-model="search.site" @change="siteChanged" clearable size="small" placeholder="全部库区" aria-label="筛选库区" style="width: 150px">
|
||||
<el-option v-for="site in siteOptions" :key="site.id" :value="site.id" :label="site.name"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="screen-container-left display-flex">
|
||||
<el-button size="small" @click="refreshPage" icon="el-icon-refresh" circle type="primary"></el-button>
|
||||
<div class="datetime">
|
||||
@@ -202,6 +207,7 @@
|
||||
</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="site" label="所属库区" width="120" align="center"><template slot-scope="s"><el-tag :type="s.row.site === 'xishui' ? 'warning' : 'primary'" size="small">{{s.row.site === 'xishui' ? '习水库' : '遵义中心库'}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="storage" width="270" label="仓库储存点" show-overflow-tooltip
|
||||
align="center"></el-table-column>
|
||||
<el-table-column prop="num" width="130" label="仓号" show-overflow-tooltip
|
||||
@@ -209,7 +215,7 @@
|
||||
<el-table-column prop="granary_type" label="仓型" show-overflow-tooltip
|
||||
align="center">
|
||||
<template scope="scope">
|
||||
<span v-if="scope.row.granary_type == 1">圆型</span>
|
||||
<span v-if="scope.row.granary_type == 1">立式油罐</span>
|
||||
<span v-else-if="scope.row.granary_type == 3">浅圆仓</span>
|
||||
<span v-else>平房仓</span>
|
||||
</template>
|
||||
@@ -257,9 +263,14 @@
|
||||
</a>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属库区:" prop="site">
|
||||
<el-select v-model="formData.site" @change="changeSite" placeholder="请选择所属库区" style="width: 330px">
|
||||
<el-option v-for="site in siteOptions" :key="site.id" :value="site.id" :label="site.name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="仓库储存点:" prop="storage">
|
||||
<el-input placeholder="请您输入仓库储存点" style="width: 330px;" maxlength="100" show-word-limit
|
||||
v-model="formData.storage"></el-input>
|
||||
v-model="formData.storage" readonly></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="仓号:" prop="num">
|
||||
<el-select @change="handleSelectNum" style="width: 330px;" v-model="formData.num" filterable allow-create default-first-option clearable
|
||||
@@ -272,7 +283,7 @@
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="大屏建筑位置:">
|
||||
<el-form-item v-if="formData.site === 'center'" label="大屏建筑位置:">
|
||||
<el-select @change="changeScreenPosition" v-model="formData.screenPosition" style="width: 440px;">
|
||||
<el-option label="保留现有建筑位置" value=""></el-option>
|
||||
<el-option label="中心库 · 左上新增仓库(42号仓左侧)" value="center_upper_left"></el-option>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<link rel="stylesheet" href="__CDN__/assets/element-ui-2.15.14/css/index.css">
|
||||
<link href="/assets/css/sweetalert.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="__CDN__/assets/css/vvv.css">
|
||||
<style>
|
||||
.quality-detail-page {
|
||||
padding: 16px;
|
||||
background: #f4f6f9;
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
.page-header-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
padding: 14px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
.page-body-card {
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
.quality-rule-ranges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="vue" v-cloak class="quality-detail-page">
|
||||
<div class="page-header-card">
|
||||
<div class="page-header-left">
|
||||
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
|
||||
<span class="page-header-title">质检报告详情</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-button size="small" type="primary" icon="el-icon-edit" @click="toEdit">编辑此报告</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-body-card">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">所属库区</span>
|
||||
<span class="info-value">{{ granarySiteName }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">所属仓库</span>
|
||||
<span class="info-value">{{ item.granary ? item.granary.name : '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">品种类型</span>
|
||||
<span class="info-value">
|
||||
<span v-if="item.type == 'rapeseed'">菜籽油</span>
|
||||
<span v-else-if="item.type == 'bean'">大豆油</span>
|
||||
<span v-else-if="item.type == 'wheat'">小麦</span>
|
||||
<span v-else-if="item.type == 'paddy'">稻谷</span>
|
||||
<span v-else-if="item.type == 'corn'">玉米</span>
|
||||
<span v-else>{{ item.type }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">记录日期</span>
|
||||
<span class="info-value">{{ item.year }}-{{ item.month < 10 ? '0' + parseInt(item.month) : item.month }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">季节</span>
|
||||
<span class="info-value">
|
||||
<el-tag size="small" type="info">{{ item.season == 1 ? '春季' : item.season == 2 ? '秋季' : '满仓' }}</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">排序</span>
|
||||
<span class="info-value">{{ item.sort }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">数据来源</span>
|
||||
<span class="info-value">
|
||||
<el-tag :type="Number(item.is_demo) === 1 ? 'warning' : 'success'" size="small">
|
||||
{{ Number(item.is_demo) === 1 ? 'DEMO 演示' : '后台录入' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin: 20px 0 12px 0; color: #303133;">检测参数清单</h4>
|
||||
<el-table :data="item.testing_params" border highlight-current-row style="width: 100%;">
|
||||
<el-table-column prop="num" width="60" label="序号" align="center"></el-table-column>
|
||||
<el-table-column prop="name" min-width="150" label="检测项目" align="center"></el-table-column>
|
||||
<el-table-column prop="unit" width="120" label="单位" align="center"></el-table-column>
|
||||
<el-table-column prop="val" width="150" label="实测值" align="center">
|
||||
<template scope="scope">
|
||||
<strong>{{ scope.row.val }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="warning" width="140" label="预警值" align="center"></el-table-column>
|
||||
<el-table-column prop="alert" width="140" label="警告值" align="center"></el-table-column>
|
||||
<el-table-column prop="is_number" width="110" label="大屏显示" align="center">
|
||||
<template scope="scope">
|
||||
<el-tag size="mini" v-if="scope.row.is_number == 1" type="success">显示</el-tag>
|
||||
<el-tag size="mini" v-else type="info">隐藏</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="260" label="指标偏好与颜色规则" align="left">
|
||||
<template scope="scope">
|
||||
<template v-if="isNumericRule(scope.row)">
|
||||
<div style="font-weight: 500; margin-bottom: 4px;">{{ ruleDirection(scope.row) === 'lower' ? '● 越大越好' : '● 越小越好' }}</div>
|
||||
<div class="quality-rule-ranges">
|
||||
<span style="color: #16844a;">绿 · {{ ruleRanges(scope.row)[0] }}</span>
|
||||
<span style="color: #946200;">黄 · {{ ruleRanges(scope.row)[1] }}</span>
|
||||
<span style="color: #c0392b;">红 · {{ ruleRanges(scope.row)[2] }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else style="color: #909399;">文字指标,按检测结果判定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="__CDN__/assets/element-ui-2.15.14/js/vue.js"></script>
|
||||
<script src="__CDN__/assets/element-ui-2.15.14/js/index.js"></script>
|
||||
<script src="__CDN__/assets/js/jquery-2.1.1.min.js"></script>
|
||||
|
||||
<script>
|
||||
var SERVER_DATA = {
|
||||
item: {:json_encode($item, JSON_UNESCAPED_UNICODE)},
|
||||
siteOptions: {:json_encode($siteOptions, JSON_UNESCAPED_UNICODE)}
|
||||
};
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
data: function () {
|
||||
return {
|
||||
item: SERVER_DATA.item || {},
|
||||
siteOptions: SERVER_DATA.siteOptions || []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
granarySiteName: function () {
|
||||
var siteId = this.item && this.item.granary ? this.item.granary.site : '';
|
||||
var found = this.siteOptions.find(function (s) { return s.id === siteId; });
|
||||
return found ? found.name : (siteId || '-');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isRuleNumber: function (val) {
|
||||
return (typeof val === 'string' || typeof val === 'number')
|
||||
&& /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(String(val).trim())
|
||||
&& Number.isFinite(Number(val));
|
||||
},
|
||||
isNumericRule: function (item) {
|
||||
return this.isRuleNumber(item.warning) && this.isRuleNumber(item.alert);
|
||||
},
|
||||
ruleDirection: function (item) {
|
||||
if (item.direction === 'upper' || item.direction === 'lower') return item.direction;
|
||||
return (this.isNumericRule(item) && Number(item.warning) > Number(item.alert)) ? 'lower' : 'upper';
|
||||
},
|
||||
ruleRanges: function (item) {
|
||||
var w = item.warning, a = item.alert;
|
||||
return this.ruleDirection(item) === 'upper'
|
||||
? ['当前值 ≤ ' + w, w + ' < 当前值 ≤ ' + a, '当前值 > ' + a]
|
||||
: ['当前值 ≥ ' + w, a + ' ≤ 当前值 < ' + w, '当前值 < ' + a];
|
||||
},
|
||||
goBack: function () {
|
||||
if (typeof top !== 'undefined' && top && top.Backend && top.Backend.api && top.Backend.api.closetabs) {
|
||||
var currentNav = top.$("#nav ul.nav-tabs li.active");
|
||||
if (currentNav.length > 0) {
|
||||
currentNav.find(".close-tab").trigger("click");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
window.location.href = 'testing/index';
|
||||
}
|
||||
},
|
||||
toEdit: function () {
|
||||
var url = 'testing/edit?ids=' + this.item.id;
|
||||
var title = '编辑检测报告';
|
||||
if (typeof top !== 'undefined' && top && top.Backend && top.Backend.api && typeof top.Backend.api.addtabs === 'function') {
|
||||
top.Backend.api.addtabs(url, title);
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,457 @@
|
||||
<link rel="stylesheet" href="__CDN__/assets/element-ui-2.15.14/css/index.css">
|
||||
<link href="/assets/css/sweetalert.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="__CDN__/assets/css/vvv.css">
|
||||
<style>
|
||||
.quality-edit-page {
|
||||
padding: 16px;
|
||||
background: #f4f6f9;
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
.page-header-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
padding: 14px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
.page-body-card {
|
||||
background: #fff;
|
||||
padding: 24px 24px 80px 24px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,.08);
|
||||
}
|
||||
.quality-editor-card {
|
||||
padding: 14px 16px 10px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.quality-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: #f0f9ff;
|
||||
color: #5f6b7a;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.quality-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.quality-legend span {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.quality-legend .good { background: #eaf8f0; color: #16844a; }
|
||||
.quality-legend .warn { background: #fef7e7; color: #946200; }
|
||||
.quality-legend .bad { background: #fdeeee; color: #c0392b; }
|
||||
.quality-rule-ranges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.quality-direction-help {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.page-footer-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
padding: 12px 30px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
box-shadow: 0 -3px 10px rgba(0, 0, 0, .05);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="vue" v-cloak class="quality-edit-page">
|
||||
<div class="page-header-card">
|
||||
<div class="page-header-left">
|
||||
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
|
||||
<span class="page-header-title">{{ formData.id ? '编辑检测报告' : '添加检测报告' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-button size="small" type="primary" :loading="submitting" icon="el-icon-check" @click="submit">保存提交</el-button>
|
||||
<el-button size="small" @click="goBack">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-body-card">
|
||||
<el-form ref="form" :rules="rules" :model="formData" label-width="120px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-form-item label="所属库区:" required>
|
||||
<el-select v-model="formSite" @change="formSiteChanged" placeholder="请选择库区" style="width: 100%;">
|
||||
<el-option v-for="site in siteOptions" :key="site.id" :value="site.id" :label="site.name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-form-item label="所属仓库:" prop="gid">
|
||||
<el-select @change="handleGranaryChange" style="width: 100%;" v-model="formData.gid" filterable clearable placeholder="请选择所属仓库">
|
||||
<el-option v-for="item in siteGranaries" :key="item.id" :label="granaryLabel(item)" :value="item.id"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-form-item label="品种类型:">
|
||||
<el-tag type="info" size="medium">{{ typeName || '随仓库自动匹配' }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-form-item label="记录日期:" prop="record_date">
|
||||
<el-date-picker style="width: 100%;" value-format="yyyy-MM" v-model="formData.record_date" type="month" placeholder="请选择记录日期"></el-date-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-form-item label="季节:" prop="season">
|
||||
<el-radio-group v-model="formData.season" size="small">
|
||||
<el-radio-button :label="1">春季</el-radio-button>
|
||||
<el-radio-button :label="2">秋季</el-radio-button>
|
||||
<el-radio-button :label="3">满仓</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-form-item label="排序:" prop="sort">
|
||||
<el-input type="number" placeholder="排序序号" style="width: 100%;" v-model="formData.sort"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="检测参数:" required style="margin-top: 10px;">
|
||||
<div class="quality-editor-card">
|
||||
<div class="quality-editor-toolbar">
|
||||
<div><strong>录入提示:</strong>先填实测值、预警值和警告值,再选择指标偏好。大屏显示列可开启或关闭该参数在大屏上的展示。等于阈值时归入较低一级。</div>
|
||||
<div class="quality-legend" aria-label="颜色规则">
|
||||
<span class="good">绿 · 达标</span>
|
||||
<span class="warn">黄 · 预警</span>
|
||||
<span class="bad">红 · 警告</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="currentParams" border highlight-current-row style="width: 100%;">
|
||||
<el-table-column prop="num" width="60" label="序号" align="center"></el-table-column>
|
||||
<el-table-column prop="name" min-width="150" label="检测项目" align="center"></el-table-column>
|
||||
<el-table-column prop="unit" width="120" label="单位" align="center">
|
||||
<template scope="scope">
|
||||
<el-input style="width: 95px;" placeholder="单位" size="small" maxlength="15" v-model="scope.row.unit"></el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="val" width="140" label="值" align="center">
|
||||
<template scope="scope">
|
||||
<el-input style="width: 115px;" placeholder="输入检测值" size="small" maxlength="50" v-model="scope.row.val"></el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="warning" width="140" label="预警值" align="center">
|
||||
<template scope="scope">
|
||||
<el-input style="width: 115px;" placeholder="输入预警值" size="small" maxlength="50" v-model="scope.row.warning"></el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alert" width="140" label="警告值" align="center">
|
||||
<template scope="scope">
|
||||
<el-input style="width: 115px;" placeholder="输入警告值" size="small" maxlength="50" v-model="scope.row.alert"></el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_number" width="110" label="大屏显示" align="center">
|
||||
<template scope="scope">
|
||||
<el-button @click="handelIsNumber(scope.$index, 2)" size="mini" v-if="scope.row.is_number == 1" type="success" icon="el-icon-check">显示</el-button>
|
||||
<el-button @click="handelIsNumber(scope.$index, 1)" size="mini" v-else type="warning" icon="el-icon-close">隐藏</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="280" label="指标偏好与颜色规则" align="left">
|
||||
<template scope="scope">
|
||||
<template v-if="isNumericRule(scope.row)">
|
||||
<el-button-group class="quality-direction-toggle" :aria-label="'选择' + scope.row.name + '的指标偏好'">
|
||||
<el-button size="mini" :type="ruleDirection(scope.row) === 'lower' ? 'success' : 'info'"
|
||||
:plain="ruleDirection(scope.row) !== 'lower'"
|
||||
@click="setRuleDirection(scope.row, 'lower')">越大越好</el-button>
|
||||
<el-button size="mini" :type="ruleDirection(scope.row) === 'upper' ? 'success' : 'info'"
|
||||
:plain="ruleDirection(scope.row) !== 'upper'"
|
||||
@click="setRuleDirection(scope.row, 'upper')">越小越好</el-button>
|
||||
</el-button-group>
|
||||
<div v-if="ruleError(scope.row)" style="color: #c0392b; margin-top: 6px;">{{ruleError(scope.row)}}</div>
|
||||
<div v-else class="quality-rule-ranges">
|
||||
<span style="color: #16844a;">绿 {{ruleRanges(scope.row)[0]}}</span>
|
||||
<span style="color: #946200;">黄 {{ruleRanges(scope.row)[1]}}</span>
|
||||
<span style="color: #c0392b;">红 {{ruleRanges(scope.row)[2]}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else-if="ruleError(scope.row)" style="color: #c0392b;">{{ruleError(scope.row)}}</span>
|
||||
<span v-else>文字指标,按检测结果判定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="quality-direction-help">“越大越好”:低于预警值会触发风险;“越小越好”:高于预警值会触发风险。文字指标按检测结果判定。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="page-footer-bar">
|
||||
<el-button @click="goBack" icon="el-icon-close">返回列表</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit" icon="el-icon-check">保存提交</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="__CDN__/assets/element-ui-2.15.14/js/vue.js"></script>
|
||||
<script src="__CDN__/assets/element-ui-2.15.14/js/index.js"></script>
|
||||
<script src="/assets/js/sweetalert.min.js"></script>
|
||||
<script src="__CDN__/assets/js/jquery-2.1.1.min.js"></script>
|
||||
|
||||
<script>
|
||||
var SERVER_DATA = {
|
||||
item: {:json_encode($item, JSON_UNESCAPED_UNICODE)},
|
||||
granaryList: {:json_encode($granaryList, JSON_UNESCAPED_UNICODE)},
|
||||
typeList: {:json_encode($typeList, JSON_UNESCAPED_UNICODE)},
|
||||
siteOptions: {:json_encode($siteOptions, JSON_UNESCAPED_UNICODE)}
|
||||
};
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
data: function () {
|
||||
var rawItem = SERVER_DATA.item;
|
||||
var granaryList = SERVER_DATA.granaryList || [];
|
||||
var typeList = SERVER_DATA.typeList || {};
|
||||
var siteOptions = SERVER_DATA.siteOptions || [];
|
||||
|
||||
var formSite = '';
|
||||
var formData = {
|
||||
id: 0,
|
||||
gid: '',
|
||||
type: 'rapeseed',
|
||||
record_date: '',
|
||||
season: 1,
|
||||
sort: '',
|
||||
content: []
|
||||
};
|
||||
var typeName = '';
|
||||
|
||||
if (rawItem) {
|
||||
formSite = (rawItem.granary && rawItem.granary.site) ? rawItem.granary.site : ((siteOptions[0] || {}).id || '');
|
||||
formData = {
|
||||
id: rawItem.id,
|
||||
gid: rawItem.gid,
|
||||
type: rawItem.type,
|
||||
season: rawItem.season,
|
||||
record_date: rawItem.year + '-' + (rawItem.month < 10 ? '0' + parseInt(rawItem.month) : rawItem.month),
|
||||
sort: rawItem.sort,
|
||||
content: []
|
||||
};
|
||||
typeName = (rawItem.granary && rawItem.granary.varieties) ? rawItem.granary.varieties : '';
|
||||
if (typeList[rawItem.type]) {
|
||||
typeList[rawItem.type]['content'] = JSON.parse(JSON.stringify(rawItem.testing_params || []));
|
||||
}
|
||||
} else {
|
||||
formSite = (siteOptions[0] || {}).id || '';
|
||||
}
|
||||
|
||||
return {
|
||||
siteOptions: siteOptions,
|
||||
granaryList: granaryList,
|
||||
typeList: typeList,
|
||||
formSite: formSite,
|
||||
typeName: typeName,
|
||||
formData: formData,
|
||||
submitting: false,
|
||||
rules: {
|
||||
gid: [{ required: true, message: '请选择所属仓库', trigger: 'blur' }],
|
||||
record_date: [{ required: true, message: '请选择记录日期', trigger: 'blur' }]
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
siteGranaries: function () {
|
||||
var self = this;
|
||||
if (!self.formSite) return self.granaryList;
|
||||
return self.granaryList.filter(function (item) {
|
||||
return item.site === self.formSite;
|
||||
});
|
||||
},
|
||||
currentParams: function () {
|
||||
var group = this.typeList[this.formData.type];
|
||||
return group ? group.content : [];
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (!this.formData.id && this.siteGranaries.length > 0) {
|
||||
var first = this.siteGranaries[0];
|
||||
this.formData.gid = first.id;
|
||||
this.handleGranaryChange(first.id);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
granaryLabel: function (item) {
|
||||
return (item.site_name ? item.site_name + ' · ' : '') + item.name + (item.varieties ? ' (' + item.varieties + ')' : '');
|
||||
},
|
||||
formSiteChanged: function () {
|
||||
var valid = this.siteGranaries.some(function (g) { return g.id === this.formData.gid; }.bind(this));
|
||||
if (!valid && this.siteGranaries.length > 0) {
|
||||
this.formData.gid = this.siteGranaries[0].id;
|
||||
this.handleGranaryChange(this.formData.gid);
|
||||
} else if (this.siteGranaries.length === 0) {
|
||||
this.formData.gid = '';
|
||||
this.typeName = '';
|
||||
}
|
||||
},
|
||||
handleGranaryChange: function (gid) {
|
||||
var item = this.granaryList.find(function (v) { return v.id === gid; });
|
||||
if (!item) return;
|
||||
this.typeName = item.varieties || '';
|
||||
this.formData.type = item.type || 'rapeseed';
|
||||
},
|
||||
handelIsNumber: function (index, val) {
|
||||
var params = this.currentParams;
|
||||
if (params && params[index]) {
|
||||
this.$set(params[index], 'is_number', val);
|
||||
}
|
||||
},
|
||||
isRuleNumber: function (val) {
|
||||
return (typeof val === 'string' || typeof val === 'number')
|
||||
&& /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(String(val).trim())
|
||||
&& Number.isFinite(Number(val));
|
||||
},
|
||||
isNumericRule: function (item) {
|
||||
return this.isRuleNumber(item.warning) && this.isRuleNumber(item.alert);
|
||||
},
|
||||
ruleDirection: function (item) {
|
||||
if (item.direction === 'upper' || item.direction === 'lower') return item.direction;
|
||||
return (this.isNumericRule(item) && Number(item.warning) > Number(item.alert)) ? 'lower' : 'upper';
|
||||
},
|
||||
setRuleDirection: function (item, dir) {
|
||||
this.$set(item, 'direction', dir);
|
||||
},
|
||||
ruleRanges: function (item) {
|
||||
var w = item.warning, a = item.alert;
|
||||
return this.ruleDirection(item) === 'upper'
|
||||
? ['当前值 ≤ ' + w, w + ' < 当前值 ≤ ' + a, '当前值 > ' + a]
|
||||
: ['当前值 ≥ ' + w, a + ' ≤ 当前值 < ' + w, '当前值 < ' + a];
|
||||
},
|
||||
ruleError: function (item) {
|
||||
var expectsNumeric = item.rule_numeric === true || Number(item.is_number) === 1
|
||||
|| this.isRuleNumber(item.warning) || this.isRuleNumber(item.alert);
|
||||
if (expectsNumeric && !this.isNumericRule(item)) return '预警值和警告值必须都是有限数字';
|
||||
if (!this.isNumericRule(item)) return '';
|
||||
if (!this.isRuleNumber(item.val)
|
||||
&& !/^(?:\/|--?|未检测|未提供|暂无|未检出|未检出\s*ND|ND)$/i.test(String(item.val ?? '').trim())) {
|
||||
return '检测值必须是有限数字,缺测可填 /';
|
||||
}
|
||||
var upper = this.ruleDirection(item) === 'upper';
|
||||
return (upper && Number(item.warning) > Number(item.alert)) || (!upper && Number(item.warning) < Number(item.alert))
|
||||
? (upper ? '越小越好指标要求:预警值 ≤ 警告值(高值预警)' : '越大越好指标要求:预警值 ≥ 警告值(低值预警)') : '';
|
||||
},
|
||||
goBack: function (refresh) {
|
||||
if (typeof top !== 'undefined' && top && top.Backend && top.Backend.api && top.Backend.api.closetabs) {
|
||||
if (refresh && top.$) {
|
||||
var listIframe = top.$('.tab-pane iframe[src*="testing/index"], .tab-pane iframe[src*="testing"]');
|
||||
if (listIframe.length && listIframe[0].contentWindow && listIframe[0].contentWindow.location) {
|
||||
listIframe[0].contentWindow.location.reload();
|
||||
}
|
||||
}
|
||||
var currentNav = top.$("#nav ul.nav-tabs li.active");
|
||||
if (currentNav.length > 0) {
|
||||
currentNav.find(".close-tab").trigger("click");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
window.location.href = 'testing/index';
|
||||
}
|
||||
},
|
||||
submit: function () {
|
||||
var self = this;
|
||||
self.$refs.form.validate(function (valid) {
|
||||
if (!valid) return false;
|
||||
var content = self.currentParams;
|
||||
for (var i = 0; i < content.length; i++) {
|
||||
var p = content[i];
|
||||
var err = self.ruleError(p);
|
||||
if (err) {
|
||||
swal({ title: '温馨提示', text: p.name + ':' + err, type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
self.$set(p, 'direction', self.ruleDirection(p));
|
||||
if (p.val === '' || p.val === null || p.val === undefined) {
|
||||
swal({ title: '温馨提示', text: '请您输入' + p.name + '值', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (p.warning === '') {
|
||||
swal({ title: '温馨提示', text: '请您输入' + p.name + '预警值', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (p.alert === '') {
|
||||
swal({ title: '温馨提示', text: '请您输入' + p.name + '警告值', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
self.formData.content = content;
|
||||
self.submitting = true;
|
||||
|
||||
$.ajax({
|
||||
url: 'testing/add',
|
||||
type: 'POST',
|
||||
data: self.formData,
|
||||
dataType: 'json',
|
||||
success: function (res) {
|
||||
self.submitting = false;
|
||||
if (res.code === 200) {
|
||||
swal({
|
||||
title: '保存成功',
|
||||
text: res.msg || '质检报告已成功保存',
|
||||
type: 'success',
|
||||
confirmButtonColor: '#7367f0',
|
||||
confirmButtonText: '确定'
|
||||
}, function () {
|
||||
self.goBack(true);
|
||||
});
|
||||
} else {
|
||||
swal({ title: '保存失败', text: res.msg, type: 'error' });
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
self.submitting = false;
|
||||
swal({ title: '网络错误', text: '提交失败,请稍后重试', type: 'error' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -231,8 +231,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="search-select screen-container-right display-flex keyword">
|
||||
<el-button @click="openAddDialog('add', '')" size="small" type="primary" plain icon="el-icon-plus">添加
|
||||
</el-button>
|
||||
<a href="testing/add" @click.prevent="openAddPage" class="el-button el-button--primary el-button--small is-plain btn-addtabs" title="添加检测报告"><i class="el-icon-plus"></i><span>添加</span></a>
|
||||
<el-button @click="del" size="small" type="danger" plain icon="el-icon-delete">删除
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -275,12 +274,12 @@
|
||||
align="center"></el-table-column>
|
||||
<el-table-column align="center" width="320" fixed="right" label="操作">
|
||||
<template scope="scope">
|
||||
<button @click="openAddDialog('edit', scope.row)" type="button"
|
||||
class="el-button el-button--primary el-button--small"><i
|
||||
class="el-icon-edit"></i><span>编辑</span></button>
|
||||
<button @click="openDialogDetail(scope.row)" type="button"
|
||||
class="el-button el-button--success el-button--small"><i
|
||||
class="el-icon-tickets"></i><span>详情</span></button>
|
||||
<a :href="'testing/edit?ids=' + scope.row.id" @click.prevent="openEditPage(scope.row)"
|
||||
class="el-button el-button--primary el-button--small btn-addtabs" title="编辑检测报告"><i
|
||||
class="el-icon-edit"></i><span>编辑</span></a>
|
||||
<a :href="'testing/detail?ids=' + scope.row.id" @click.prevent="openDetailPage(scope.row)"
|
||||
class="el-button el-button--success el-button--small btn-addtabs" title="检测报告详情"><i
|
||||
class="el-icon-tickets"></i><span>详情</span></a>
|
||||
<button @click="dell(scope.row.id)" type="button"
|
||||
class="el-button el-button--danger el-button--small"><i
|
||||
class="el-icon-delete"></i><span>删除</span></button>
|
||||
|
||||
@@ -8,6 +8,7 @@ use app\common\model\GranaryModel;
|
||||
use app\common\model\TestingModel;
|
||||
use app\common\model\TestingParamsModel;
|
||||
use think\Db;
|
||||
use app\common\library\GranarySite;
|
||||
|
||||
/**
|
||||
* 首页接口
|
||||
@@ -35,17 +36,10 @@ class Index extends Api
|
||||
public function granaryList(GranaryModel $granaryModel)
|
||||
{
|
||||
$site = $this->request->get('site', 'center');
|
||||
if (!GranarySite::valid($site)) $this->error('无效库区');
|
||||
|
||||
$query = $granaryModel->field('province_id,city_id,update_time', true);
|
||||
if ($site === 'xishui') {
|
||||
$query->where('storage', 'like', '%习水%');
|
||||
} else {
|
||||
$query->where(function ($q) {
|
||||
$q->where('storage', 'like', '%中心库%')
|
||||
->whereOr('storage', 'not like', '%习水%')
|
||||
->whereOr('storage', null);
|
||||
});
|
||||
}
|
||||
$query->where('site', $site);
|
||||
$list = $query->select();
|
||||
|
||||
// 数据处理
|
||||
@@ -55,7 +49,7 @@ class Index extends Api
|
||||
// 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();
|
||||
->order('is_demo ASC, test_date DESC, id DESC')->select();
|
||||
$latestTests = [];
|
||||
foreach ($tests as $test) {
|
||||
$test = $test->toArray();
|
||||
@@ -82,8 +76,6 @@ class Index extends Api
|
||||
$v['storage_num'] = $content['storageNum'];
|
||||
}
|
||||
}
|
||||
} elseif ($site === 'xishui') {
|
||||
$list = $this->getXishuiDefaultList();
|
||||
}
|
||||
|
||||
$coords = $this->getSiteCoords($site);
|
||||
@@ -94,10 +86,19 @@ class Index extends Api
|
||||
unset($granary);
|
||||
}
|
||||
|
||||
if ($site === 'xishui') {
|
||||
foreach ($list as &$granary) {
|
||||
$key = 'xishui_' . $granary['num'];
|
||||
$granary['screen_coord'] = isset($coords[$key]) ? $coords[$key] : null;
|
||||
}
|
||||
unset($granary);
|
||||
}
|
||||
$this->success('获取成功', [
|
||||
'list' => $list,
|
||||
'coords' => $coords,
|
||||
'site' => $site
|
||||
'site' => $site,
|
||||
'site_name' => GranarySite::NAMES[$site],
|
||||
'source' => 'database'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -264,9 +265,10 @@ class Index extends Api
|
||||
*/
|
||||
public function env()
|
||||
{
|
||||
$site = $this->request->get('site', 'center');
|
||||
= ->request->get('site', 'center');
|
||||
if (!GranarySite::valid()) ->error('无效库区');
|
||||
|
||||
$envData = [
|
||||
= [
|
||||
'center' => [
|
||||
'site' => 'center',
|
||||
'name' => '遵义直属库中心库区',
|
||||
@@ -289,8 +291,29 @@ class Index extends Api
|
||||
]
|
||||
];
|
||||
|
||||
$data = isset($envData[$site]) ? $envData[$site] : $envData['center'];
|
||||
$this->success('获取成功', $data);
|
||||
= isset([]) ? [] : ['center'];
|
||||
|
||||
// 优先读取该库区最新一条人工录入或设定的温湿度快照
|
||||
try {
|
||||
= Db::name('grain_forecast')
|
||||
->alias('f')
|
||||
->join('granary g', 'f.gid = g.id')
|
||||
->where('g.site', )
|
||||
->order('f.id DESC')
|
||||
->field('f.tm_i, f.hm_i, f.tm_o, f.hm_o, f.update_time')
|
||||
->find();
|
||||
if () {
|
||||
['tm_i'] = (float)['tm_i'];
|
||||
['hm_i'] = (float)['hm_i'];
|
||||
['tm_o'] = (float)['tm_o'];
|
||||
['hm_o'] = (float)['hm_o'];
|
||||
['update_time'] = (int)['update_time'];
|
||||
}
|
||||
} catch (\Exception ) {
|
||||
// 异常时安全兜底基准工况
|
||||
}
|
||||
|
||||
->success('获取成功', );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -428,76 +451,4 @@ class Index extends Api
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 习水库预设数据列表
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\library\GranarySite;
|
||||
use think\Db;
|
||||
|
||||
/** Apply site authorization to every grain-management read and write route. */
|
||||
class GranaryBackend extends Backend
|
||||
{
|
||||
protected $granarySites = [];
|
||||
protected $filterSites = [];
|
||||
|
||||
public function _initialize()
|
||||
{
|
||||
parent::_initialize();
|
||||
$this->initializeGranarySiteAccess();
|
||||
}
|
||||
|
||||
protected function initializeGranarySiteAccess()
|
||||
{
|
||||
$this->granarySites = GranarySite::allowed($this->auth);
|
||||
$site = $this->request->get('site', '');
|
||||
if ($site !== '' && (!GranarySite::valid($site) || !in_array($site, $this->granarySites, true))) {
|
||||
$this->error('无权访问此库区');
|
||||
}
|
||||
$this->filterSites = $site === '' ? $this->granarySites : [$site];
|
||||
$this->assignconfig('granarySites', GranarySite::options($this->granarySites));
|
||||
|
||||
$controller = strtolower($this->request->controller());
|
||||
$action = strtolower($this->request->action());
|
||||
$actions = [
|
||||
'granary' => ['index', 'add', 'detail', 'dell'],
|
||||
'testing' => ['index', 'add', 'sort', 'dell'],
|
||||
'content' => ['index', 'add', 'status', 'getcontent', 'dell'],
|
||||
'forecast' => ['index', 'detail', 'add', 'retry', 'actual'],
|
||||
];
|
||||
if (!isset($actions[$controller]) || !in_array($action, $actions[$controller], true)) $this->error('不支持此操作');
|
||||
$table = $controller === 'forecast' ? 'grain_forecast' : $controller;
|
||||
$id = $this->request->post('id', $this->request->get('id', ''));
|
||||
if ($action === 'actual') {
|
||||
$id = Db::name('grain_forecast_point')->where('id', $this->request->post('point_id/d', 0))->value('forecast_id');
|
||||
if (!$id) $this->error('预测周期不存在');
|
||||
}
|
||||
if ($id !== '' && $id !== null && $id !== 0 && $id !== '0') {
|
||||
if (!is_scalar($id) || !preg_match('/^\d+(,\d+)*$/D', (string)$id)) $this->error('记录编号不正确');
|
||||
foreach (explode(',', (string)$id) as $recordId) {
|
||||
$gid = $controller === 'granary' ? $recordId : Db::name($table)->where('id', $recordId)->value('gid');
|
||||
if (!GranarySite::permits($gid, $this->granarySites)) $this->error('记录不存在或无权访问所属库区');
|
||||
}
|
||||
}
|
||||
$gid = $this->request->post('gid', $this->request->get('gid', 0));
|
||||
if ($gid && !GranarySite::permits($gid, $this->granarySites)) $this->error('无权访问此仓库');
|
||||
if ($controller !== 'granary' && $action === 'add' && $this->request->isPost() && !$gid) $this->error('请选择所属仓库');
|
||||
if ($controller === 'content' && $action === 'index') {
|
||||
$rid = $this->request->get('rid/d', 0);
|
||||
if ($rid && !GranarySite::permits($rid, $this->granarySites)) $this->error('无权访问此仓库');
|
||||
}
|
||||
if ($controller === 'forecast' && $action === 'add' && $this->request->isPost()) {
|
||||
$existingGid = Db::name('grain_forecast')->where('request_key', $this->request->post('request_key', ''))->value('gid');
|
||||
if ($existingGid && !GranarySite::permits($existingGid, $this->granarySites)) $this->error('无权访问原预测记录');
|
||||
}
|
||||
}
|
||||
|
||||
protected function scopeRecords($query)
|
||||
{
|
||||
$ids = array_column(GranarySite::granaries($this->filterSites), 'id');
|
||||
return $query->where('gid', 'in', $ids ?: [0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
use think\Db;
|
||||
|
||||
/** Saved environmental conditions, immutable predictions and dated measurements. */
|
||||
class GrainForecast
|
||||
{
|
||||
public static function metrics($type)
|
||||
{
|
||||
return $type === 'wheat' ? [
|
||||
['key' => 'wheat_moisture', 'name' => '粮食水分', 'unit' => '%', 'max' => 100],
|
||||
['key' => 'wheat_GWAC', 'name' => '面筋吸水量', 'unit' => '%', 'max' => 1000],
|
||||
['key' => 'wheat_DON', 'name' => '脱氧雪腐镰刀菌烯醇(呕吐毒素)', 'unit' => 'μg/kg', 'max' => 100000000],
|
||||
['key' => 'wheat_ZEN', 'name' => '玉米赤霉烯酮', 'unit' => 'μg/kg', 'max' => 100000000],
|
||||
] : [
|
||||
['key' => 'paddy_moisture', 'name' => '粮食水分', 'unit' => '%', 'max' => 100],
|
||||
['key' => 'paddy_fatacid', 'name' => '脂肪酸值', 'unit' => 'mg/100g', 'max' => 100000000],
|
||||
['key' => 'paddy_AFB1', 'name' => '黄曲霉毒素B1', 'unit' => 'μg/kg', 'max' => 100000000],
|
||||
];
|
||||
}
|
||||
|
||||
public static function number($value, $label, $min, $max)
|
||||
{
|
||||
if (!is_scalar($value) || is_bool($value) || !is_numeric($value)
|
||||
|| !is_finite((float)$value) || $value < $min || $value > $max) {
|
||||
throw new \InvalidArgumentException($label . '须为' . $min . '至' . $max . '之间的数值');
|
||||
}
|
||||
return round((float)$value, 4);
|
||||
}
|
||||
|
||||
public static function date($value, $label)
|
||||
{
|
||||
$date = is_string($value) ? \DateTimeImmutable::createFromFormat('!Y-m-d', $value) : false;
|
||||
if (!$date || $date->format('Y-m-d') !== $value || $value < '2000-01-01' || $value > date('Y-m-d')) {
|
||||
throw new \InvalidArgumentException($label . '须为2000年至今天之间的有效日期');
|
||||
}
|
||||
return $date;
|
||||
}
|
||||
|
||||
public function create(array $input, $adminId)
|
||||
{
|
||||
$key = isset($input['request_key']) ? $input['request_key'] : '';
|
||||
if (!is_string($key) || !preg_match('/^[a-f0-9]{32,64}$/D', $key)) {
|
||||
throw new \InvalidArgumentException('请求标识无效,请刷新后重试');
|
||||
}
|
||||
$existing = Db::name('grain_forecast')->where('request_key', $key)->find();
|
||||
if ($existing) {
|
||||
if ((int)$existing['admin_id'] !== (int)$adminId) throw new \InvalidArgumentException('请求标识已使用');
|
||||
return (int)$existing['id'];
|
||||
}
|
||||
$gid = isset($input['gid']) && is_scalar($input['gid']) ? (int)$input['gid'] : 0;
|
||||
$granary = Db::name('granary')->where('id', $gid)->find();
|
||||
if (!$granary || !in_array($granary['varieties'], ['小麦', '稻谷'], true)) {
|
||||
throw new \InvalidArgumentException('请选择小麦或稻谷仓库');
|
||||
}
|
||||
$date = self::date(isset($input['observed_date']) ? $input['observed_date'] : '', '温湿度采集日期');
|
||||
$horizons = isset($input['horizons']) ? $input['horizons'] : [];
|
||||
if (!is_array($horizons) || !$horizons || count($horizons) > 4) throw new \InvalidArgumentException('请选择预测周期');
|
||||
foreach ($horizons as $day) {
|
||||
if (!is_scalar($day) || !in_array((string)$day, ['7', '15', '30', '60'], true)) throw new \InvalidArgumentException('预测周期仅支持7、15、30、60天');
|
||||
}
|
||||
$horizons = array_unique(array_map('intval', $horizons));
|
||||
sort($horizons);
|
||||
$row = ['gid' => $gid, 'granary_name' => $granary['name'], 'storage' => $granary['storage'],
|
||||
'grain_type' => $granary['varieties'] === '小麦' ? 'wheat' : 'rice',
|
||||
'observed_date' => $date->format('Y-m-d'), 'request_key' => $key, 'admin_id' => (int)$adminId,
|
||||
'status' => 'pending', 'create_time' => time(), 'update_time' => time()];
|
||||
foreach (['tm_i' => '仓内温度', 'tm_o' => '仓外温度', 'hm_i' => '仓内湿度', 'hm_o' => '仓外湿度'] as $field => $label) {
|
||||
$isTemp = strpos($field, 'tm_') === 0;
|
||||
$row[$field] = self::number(isset($input[$field]) ? $input[$field] : null, $label, $isTemp ? -50 : 0, $isTemp ? 80 : 100);
|
||||
}
|
||||
return Db::transaction(function () use ($row, $horizons, $date) {
|
||||
// The unique request key also prevents concurrent duplicate batches.
|
||||
$id = Db::name('grain_forecast')->insertGetId($row);
|
||||
foreach ($horizons as $days) {
|
||||
Db::name('grain_forecast_point')->insert(['forecast_id' => $id, 'horizon' => $days,
|
||||
'target_date' => $date->modify('+' . $days . ' days')->format('Y-m-d'),
|
||||
'predicted' => '{}', 'actual' => '{}', 'version' => 0, 'update_time' => time()]);
|
||||
}
|
||||
return (int)$id;
|
||||
});
|
||||
}
|
||||
|
||||
/** The API document defines month/day as a calendar date, not a duration. */
|
||||
protected function predict(array $record, array $point)
|
||||
{
|
||||
$date = new \DateTimeImmutable($point['target_date']);
|
||||
$params = array_intersect_key($record, array_flip(['tm_i', 'tm_o', 'hm_i', 'hm_o']));
|
||||
$params += ['month' => (int)$date->format('n'), 'day' => (int)$date->format('j'), 'type' => $record['grain_type']];
|
||||
$url = 'http://www.probe-cdy-pests.cn:8116/alg/grainquality/forcast?' . http_build_query($params);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 6,
|
||||
CURLOPT_PROXY => '', CURLOPT_FOLLOWLOCATION => false]);
|
||||
$raw = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$json = $raw !== false ? json_decode($raw, true) : null;
|
||||
if ($status !== 200 || !is_array($json) || !isset($json['code']) || (string)$json['code'] !== '0'
|
||||
|| empty($json['output']['targets']) || !is_array($json['output']['targets'])) {
|
||||
throw new \RuntimeException('算法服务暂不可用,温湿度记录已保存,请稍后重新生成');
|
||||
}
|
||||
return $json['output']['targets'];
|
||||
}
|
||||
|
||||
public function generate($id)
|
||||
{
|
||||
$record = Db::name('grain_forecast')->where('id', $id)->find();
|
||||
if (!$record) throw new \InvalidArgumentException('预测记录不存在');
|
||||
if ($record['status'] === 'ready') return;
|
||||
// Recover an interrupted request after its bounded remote calls have expired.
|
||||
$locked = Db::name('grain_forecast')->where('id', $id)->where(function ($q) {
|
||||
$q->where('status', 'in', ['pending', 'failed'])->whereOr(function ($q) {
|
||||
$q->where('status', 'processing')->where('attempt_time', '<', time() - 120);
|
||||
});
|
||||
})->update(['status' => 'processing', 'attempt_time' => time(), 'update_time' => time(), 'error' => '']);
|
||||
if (!$locked) return;
|
||||
try {
|
||||
$points = Db::name('grain_forecast_point')->where('forecast_id', $id)->order('horizon')->select();
|
||||
$predictions = [];
|
||||
foreach ($points as $point) {
|
||||
$targets = $this->predict($record, $point);
|
||||
$values = [];
|
||||
foreach (self::metrics($record['grain_type']) as $metric) {
|
||||
$key = $metric['key'];
|
||||
$values[$key] = self::number(isset($targets[$key]) ? $targets[$key] : null, $metric['name'] . '预测值', 0, $metric['max']);
|
||||
}
|
||||
$predictions[$point['id']] = json_encode($values, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
Db::transaction(function () use ($id, $predictions) {
|
||||
foreach ($predictions as $pointId => $values) {
|
||||
Db::name('grain_forecast_point')->where('id', $pointId)->update(['predicted' => $values]);
|
||||
}
|
||||
Db::name('grain_forecast')->where('id', $id)->update(['status' => 'ready', 'error' => '', 'update_time' => time()]);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
Db::name('grain_forecast')->where('id', $id)->update(['status' => 'failed',
|
||||
'error' => '算法服务暂不可用或返回数据不完整,请稍后重新生成', 'update_time' => time()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function detail($id)
|
||||
{
|
||||
$record = Db::name('grain_forecast')->where('id', $id)->find();
|
||||
if (!$record) throw new \InvalidArgumentException('预测记录不存在');
|
||||
unset($record['request_key']);
|
||||
$record['metrics'] = self::metrics($record['grain_type']);
|
||||
$record['points'] = Db::name('grain_forecast_point')->where('forecast_id', $id)->order('horizon')->select();
|
||||
foreach ($record['points'] as &$point) {
|
||||
$point['predicted'] = json_decode($point['predicted'], true) ?: [];
|
||||
$point['actual'] = json_decode($point['actual'], true) ?: [];
|
||||
$point['rows'] = [];
|
||||
foreach ($record['metrics'] as $metric) {
|
||||
$key = $metric['key'];
|
||||
$predicted = isset($point['predicted'][$key]) ? $point['predicted'][$key] : null;
|
||||
$actual = isset($point['actual'][$key]) ? $point['actual'][$key] : null;
|
||||
$delta = $predicted !== null && $actual !== null ? round($actual - $predicted, 4) : null;
|
||||
$point['rows'][] = $metric + ['predicted' => $predicted, 'actual' => $actual, 'delta' => $delta,
|
||||
'error_percent' => $delta !== null && $predicted != 0 ? round($delta / abs($predicted) * 100, 2) : null];
|
||||
}
|
||||
}
|
||||
unset($point);
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function saveActual(array $input, $adminId)
|
||||
{
|
||||
$point = Db::name('grain_forecast_point')->where('id', isset($input['point_id']) ? (int)$input['point_id'] : 0)->find();
|
||||
if (!$point) throw new \InvalidArgumentException('预测周期不存在');
|
||||
$record = Db::name('grain_forecast')->where('id', $point['forecast_id'])->find();
|
||||
$actual = isset($input['actual']) ? $input['actual'] : [];
|
||||
if (!is_array($actual)) throw new \InvalidArgumentException('实测数据格式错误');
|
||||
$metrics = self::metrics($record['grain_type']);
|
||||
if (array_diff(array_keys($actual), array_column($metrics, 'key'))) throw new \InvalidArgumentException('实测指标与粮食品种不匹配');
|
||||
$values = [];
|
||||
foreach ($metrics as $metric) {
|
||||
$key = $metric['key'];
|
||||
$value = isset($actual[$key]) ? $actual[$key] : null;
|
||||
$values[$key] = $value === '' || $value === null ? null : self::number($value, $metric['name'], 0, $metric['max']);
|
||||
}
|
||||
$filled = count(array_filter($values, function ($v) { return $v !== null; }));
|
||||
$actualDate = null;
|
||||
if ($filled) {
|
||||
$date = self::date(isset($input['actual_date']) ? $input['actual_date'] : '', '实际检测日期');
|
||||
if ($date->format('Y-m-d') < $record['observed_date']) throw new \InvalidArgumentException('实际检测日期不能早于温湿度采集日期');
|
||||
$actualDate = $date->format('Y-m-d');
|
||||
}
|
||||
$version = isset($input['version']) && is_scalar($input['version']) ? (int)$input['version'] : -1;
|
||||
$saved = Db::name('grain_forecast_point')->where('id', $point['id'])->where('version', $version)->update([
|
||||
'actual' => json_encode($values, JSON_UNESCAPED_UNICODE), 'actual_date' => $actualDate,
|
||||
'actual_admin_id' => (int)$adminId, 'version' => $version + 1, 'update_time' => time(),
|
||||
]);
|
||||
if (!$saved) throw new \InvalidArgumentException('实测数据已被其他人更新,请关闭并重新打开后填写');
|
||||
return (int)$point['forecast_id'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
namespace app\common\library;
|
||||
|
||||
use think\Db;
|
||||
|
||||
/** Canonical site identities shared by management and screen APIs. */
|
||||
class GranarySite
|
||||
{
|
||||
const NAMES = ['center' => '遵义中心库', 'xishui' => '习水库'];
|
||||
const STORAGE = ['center' => '遵义直属库有限公司中心库区', 'xishui' => '中央储备粮遵义直属库有限公司习水分公司'];
|
||||
|
||||
public static function valid($site)
|
||||
{
|
||||
return is_string($site) && isset(self::NAMES[$site]);
|
||||
}
|
||||
|
||||
public static function allowed($auth)
|
||||
{
|
||||
if ($auth->isSuperAdmin()) return array_keys(self::NAMES);
|
||||
$scope = Db::name('admin')->where('id', $auth->id)->value('granary_sites');
|
||||
return $scope === 'all' ? array_keys(self::NAMES) : (self::valid($scope) ? [$scope] : []);
|
||||
}
|
||||
|
||||
public static function options(array $sites)
|
||||
{
|
||||
$options = [];
|
||||
foreach ($sites as $site) $options[] = ['id' => $site, 'name' => self::NAMES[$site], 'storage' => self::STORAGE[$site]];
|
||||
return $options;
|
||||
}
|
||||
|
||||
public static function granaries(array $sites)
|
||||
{
|
||||
$rows = Db::name('granary')->where('site', 'in', $sites ?: ['__none__'])
|
||||
->field('id,name,num,site,storage,varieties,granary_type')->order('site,num')->select();
|
||||
foreach ($rows as &$row) {
|
||||
$row['site_name'] = self::NAMES[$row['site']];
|
||||
$row['label'] = $row['site_name'] . ' · ' . $row['name'];
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function permits($gid, array $sites)
|
||||
{
|
||||
return (bool) Db::name('granary')->where('id', (int)$gid)->where('site', 'in', $sites ?: ['__none__'])->count();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,12 @@ namespace app\common\library;
|
||||
/** Numeric warning rules shared by the editor and persistence layer. */
|
||||
class QualityRule
|
||||
{
|
||||
public static function isNumber($value)
|
||||
{
|
||||
return (is_int($value) || is_float($value) || is_string($value))
|
||||
&& is_numeric($value) && is_finite((float)$value);
|
||||
}
|
||||
|
||||
public static function direction($item)
|
||||
{
|
||||
if (isset($item['direction']) && in_array($item['direction'], ['upper', 'lower'], true)) {
|
||||
@@ -12,14 +18,22 @@ class QualityRule
|
||||
}
|
||||
$warning = isset($item['warning']) ? $item['warning'] : null;
|
||||
$alert = isset($item['alert']) ? $item['alert'] : null;
|
||||
return is_numeric($warning) && is_numeric($alert) && $warning > $alert ? 'lower' : 'upper';
|
||||
return self::isNumber($warning) && self::isNumber($alert) && $warning > $alert ? 'lower' : 'upper';
|
||||
}
|
||||
|
||||
public static function validate($content)
|
||||
public static function validate($content, $originalRules = [])
|
||||
{
|
||||
if (!is_array($content) || empty($content)) {
|
||||
return '请填写检测参数';
|
||||
}
|
||||
$numericRules = [];
|
||||
foreach ($originalRules as $original) {
|
||||
if (isset($original['warning'], $original['alert'])
|
||||
&& self::isNumber($original['warning']) && self::isNumber($original['alert'])) {
|
||||
$key = !empty($original['id']) ? 'id:' . $original['id'] : 'num:' . $original['num'];
|
||||
$numericRules[$key] = true;
|
||||
}
|
||||
}
|
||||
foreach ($content as $item) {
|
||||
if (!is_array($item)) {
|
||||
return '检测参数格式无效';
|
||||
@@ -30,7 +44,23 @@ class QualityRule
|
||||
if (!isset($item['warning'], $item['alert'])) {
|
||||
return '请填写预警值和警告值';
|
||||
}
|
||||
if (is_numeric($item['warning']) && is_numeric($item['alert'])) {
|
||||
$warningNumeric = self::isNumber($item['warning']);
|
||||
$alertNumeric = self::isNumber($item['alert']);
|
||||
$numeric = $warningNumeric && $alertNumeric;
|
||||
$key = !empty($item['id']) ? 'id:' . $item['id'] : 'num:' . (isset($item['num']) ? $item['num'] : '');
|
||||
$expectsNumeric = isset($numericRules[$key]) || (isset($item['is_number']) && (string)$item['is_number'] === '1')
|
||||
|| $warningNumeric || $alertNumeric;
|
||||
if ($expectsNumeric && !$numeric) {
|
||||
return $item['name'] . ':预警值和警告值必须都是有限数字';
|
||||
}
|
||||
if (!isset($item['val']) || (is_string($item['val']) && trim($item['val']) === '')) {
|
||||
return $item['name'] . ':请填写检测值,缺测可填 /';
|
||||
}
|
||||
if ($numeric && !self::isNumber($item['val'])
|
||||
&& !preg_match('/^(?:\/|--?|未检测|未提供|暂无|未检出|未检出\s*ND|ND)$/ui', trim((string)$item['val']))) {
|
||||
return $item['name'] . ':检测值必须是有限数字,缺测可填 /';
|
||||
}
|
||||
if ($numeric) {
|
||||
$upper = self::direction($item) === 'upper';
|
||||
if (($upper && $item['warning'] > $item['alert'])
|
||||
|| (!$upper && $item['warning'] < $item['alert'])) {
|
||||
|
||||
@@ -20,7 +20,7 @@ class ContentModel extends Model
|
||||
public function granary()
|
||||
{
|
||||
return $this->hasOne(GranaryModel::class, 'id', 'gid')
|
||||
->field(['id', 'name', 'num', 'code', 'addr', 'storage']);
|
||||
->field(['id', 'name', 'num', 'code', 'addr', 'site', 'storage']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestingModel extends Model
|
||||
public function granary()
|
||||
{
|
||||
return $this->hasOne(GranaryModel::class, 'id', 'gid')
|
||||
->field(['id', 'name', 'num', 'code', 'addr', 'storage', 'varieties']);
|
||||
->field(['id', 'name', 'num', 'code', 'addr', 'site', 'storage', 'varieties']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
// CLI only. First run schema, then publish files, then run with "menu".
|
||||
if (PHP_SAPI !== 'cli') exit;
|
||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
|
||||
chdir('/www/wwwroot/grain.gynhc.com');
|
||||
define('APP_PATH', getcwd() . '/application/');
|
||||
require getcwd() . '/thinkphp/base.php';
|
||||
\think\App::initCommon();
|
||||
$prefix = config('database.prefix');
|
||||
if (!preg_match('/^[a-zA-Z0-9_]+$/D', $prefix)) throw new Exception('Invalid prefix');
|
||||
$backup = $argv[1];
|
||||
if (!is_dir($backup)) throw new Exception('Backup directory required');
|
||||
if (($argv[2] ?? '') !== 'menu') {
|
||||
$sql = str_replace('`fa_', '`' . $prefix, file_get_contents(__DIR__ . '/20261028_grain_forecast.sql'));
|
||||
foreach (preg_split('/;\s*(?:\r?\n|$)/', trim($sql)) as $statement) {
|
||||
if (trim($statement)) \think\Db::execute($statement);
|
||||
}
|
||||
echo "FORECAST_SCHEMA_READY\n";
|
||||
exit;
|
||||
}
|
||||
$rules = \think\Db::name('auth_rule')->select();
|
||||
$groups = \think\Db::name('auth_group')->select();
|
||||
$snapshot = $backup . '/forecast-auth-backup.json';
|
||||
if (!file_exists($snapshot)) {
|
||||
file_put_contents($snapshot, json_encode(['rules' => $rules, 'groups' => $groups], JSON_UNESCAPED_UNICODE));
|
||||
chmod($snapshot, 0600);
|
||||
}
|
||||
\think\Db::transaction(function () use ($rules, $groups) {
|
||||
$testing = \think\Db::name('auth_rule')->where('name', 'testing')->find();
|
||||
$nodes = [
|
||||
'forecast' => ['温湿度预测与实测', 1],
|
||||
'forecast/index' => ['查看记录', 0],
|
||||
'forecast/detail' => ['查看对比', 0],
|
||||
'forecast/add' => ['录入温湿度并预测', 0],
|
||||
'forecast/retry' => ['重新生成预测', 0],
|
||||
'forecast/actual' => ['补录真实监测值', 0],
|
||||
];
|
||||
$ids = [];
|
||||
foreach ($nodes as $name => $info) {
|
||||
$id = \think\Db::name('auth_rule')->where('name', $name)->value('id');
|
||||
if (!$id) {
|
||||
$id = \think\Db::name('auth_rule')->insertGetId([
|
||||
'name' => $name, 'title' => $info[0], 'ismenu' => $info[1], 'type' => 'file',
|
||||
'pid' => $info[1] ? ($testing ? $testing['pid'] : 0) : $ids['forecast'],
|
||||
'icon' => $info[1] ? 'fa fa-line-chart' : 'fa fa-circle-o', 'menutype' => 'addtabs',
|
||||
'weigh' => $info[1] && $testing ? $testing['weigh'] - 1 : 0, 'status' => 'normal',
|
||||
'createtime' => time(), 'updatetime' => time(),
|
||||
]);
|
||||
}
|
||||
$ids[$name] = $id;
|
||||
}
|
||||
$oldIds = [];
|
||||
foreach ($rules as $rule) $oldIds[$rule['name']] = (string)$rule['id'];
|
||||
foreach ($groups as $group) {
|
||||
if ($group['rules'] === '*') continue;
|
||||
$granted = explode(',', $group['rules']);
|
||||
$canRead = false;
|
||||
foreach (['testing', 'testing/index'] as $name) if (isset($oldIds[$name]) && in_array($oldIds[$name], $granted, true)) $canRead = true;
|
||||
$canWrite = isset($oldIds['testing/add']) && in_array($oldIds['testing/add'], $granted, true);
|
||||
if (!$canRead && !$canWrite) continue;
|
||||
$extra = [$ids['forecast'], $ids['forecast/index'], $ids['forecast/detail']];
|
||||
if ($canWrite) $extra = array_merge($extra, [$ids['forecast/add'], $ids['forecast/retry'], $ids['forecast/actual']]);
|
||||
\think\Db::name('auth_group')->where('id', $group['id'])->update(['rules' => implode(',', array_unique(array_merge($granted, $extra)))]);
|
||||
}
|
||||
});
|
||||
echo "FORECAST_MENU_READY\n";
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Replace fa_ with the configured database prefix when executing manually.
|
||||
CREATE TABLE IF NOT EXISTS `fa_grain_forecast` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`gid` int unsigned NOT NULL,
|
||||
`granary_name` varchar(100) NOT NULL,
|
||||
`storage` varchar(255) NOT NULL DEFAULT '',
|
||||
`grain_type` varchar(10) NOT NULL,
|
||||
`observed_date` date NOT NULL,
|
||||
`tm_i` decimal(8,4) NOT NULL,
|
||||
`tm_o` decimal(8,4) NOT NULL,
|
||||
`hm_i` decimal(8,4) NOT NULL,
|
||||
`hm_o` decimal(8,4) NOT NULL,
|
||||
`request_key` varchar(64) NOT NULL,
|
||||
`status` varchar(12) NOT NULL DEFAULT 'pending',
|
||||
`error` varchar(255) NOT NULL DEFAULT '',
|
||||
`attempt_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`admin_id` int unsigned NOT NULL DEFAULT 0,
|
||||
`create_time` int unsigned NOT NULL,
|
||||
`update_time` int unsigned NOT NULL,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `request_key` (`request_key`), KEY `gid_id` (`gid`,`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人工温湿度及预测批次';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `fa_grain_forecast_point` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`forecast_id` int unsigned NOT NULL,
|
||||
`horizon` tinyint unsigned NOT NULL,
|
||||
`target_date` date NOT NULL,
|
||||
`predicted` text NOT NULL,
|
||||
`actual` text NOT NULL,
|
||||
`actual_date` date DEFAULT NULL,
|
||||
`actual_admin_id` int unsigned NOT NULL DEFAULT 0,
|
||||
`version` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `forecast_horizon` (`forecast_id`,`horizon`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预测目标日期与人工实测对比';
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// CLI: php this-file.php /absolute/backup/directory
|
||||
if (PHP_SAPI !== 'cli') exit;
|
||||
chdir(dirname(__DIR__));
|
||||
define('APP_PATH', getcwd() . '/application/');
|
||||
require getcwd() . '/thinkphp/base.php';
|
||||
\think\App::initCommon();
|
||||
$backup = isset($argv[1]) ? $argv[1] : '';
|
||||
if (!is_dir($backup)) throw new Exception('Backup directory required');
|
||||
$prefix = config('database.prefix');
|
||||
if (!preg_match('/^[a-zA-Z0-9_]+$/D', $prefix)) throw new Exception('Invalid prefix');
|
||||
$rows = \think\Db::name('granary')->select();
|
||||
$mapping = [];
|
||||
$keys = [];
|
||||
foreach ($rows as $row) {
|
||||
$site = isset($row['site']) && in_array($row['site'], ['center', 'xishui'], true) ? $row['site'] : '';
|
||||
if ($site === '') {
|
||||
if (strpos($row['storage'], '习水') !== false) $site = 'xishui';
|
||||
elseif (strpos($row['storage'], '中心库') !== false) $site = 'center';
|
||||
else throw new Exception('Unknown site for granary ' . $row['id']);
|
||||
}
|
||||
$key = $site . ':' . $row['num'];
|
||||
if (isset($keys[$key])) throw new Exception('Duplicate site/number: ' . $key);
|
||||
$keys[$key] = true;
|
||||
$mapping[$row['id']] = $site;
|
||||
}
|
||||
$snapshot = $backup . '/granary-sites-before.json';
|
||||
if (!file_exists($snapshot)) {
|
||||
file_put_contents($snapshot, json_encode(['granaries' => $rows,
|
||||
'admin_scopes' => \think\Db::name('admin')->field('id,username')->select()], JSON_UNESCAPED_UNICODE));
|
||||
chmod($snapshot, 0600);
|
||||
}
|
||||
$columns = array_column(\think\Db::query('SHOW COLUMNS FROM `' . $prefix . 'granary`'), 'Field');
|
||||
if (!in_array('site', $columns)) \think\Db::execute('ALTER TABLE `' . $prefix . 'granary` ADD `site` varchar(16) NOT NULL DEFAULT \'\' COMMENT \'库区:center中心库,xishui习水库\' AFTER `storage`');
|
||||
// Use bound SQL because this process may have cached the pre-migration fields.
|
||||
foreach ($mapping as $id => $site) \think\Db::execute('UPDATE `' . $prefix . 'granary` SET site = ? WHERE id = ?', [$site, $id]);
|
||||
$indexes = array_column(\think\Db::query('SHOW INDEX FROM `' . $prefix . 'granary`'), 'Key_name');
|
||||
if (!in_array('uq_granary_site_num', $indexes)) \think\Db::execute('ALTER TABLE `' . $prefix . 'granary` ADD UNIQUE KEY `uq_granary_site_num` (`site`,`num`)');
|
||||
$columns = array_column(\think\Db::query('SHOW COLUMNS FROM `' . $prefix . 'admin`'), 'Field');
|
||||
if (!in_array('granary_sites', $columns)) \think\Db::execute('ALTER TABLE `' . $prefix . 'admin` ADD `granary_sites` varchar(16) NOT NULL DEFAULT \'all\' COMMENT \'库区权限:all/center/xishui/none\'');
|
||||
foreach (['granary', 'admin'] as $table) {
|
||||
$schema = RUNTIME_PATH . 'schema/' . config('database.database') . '.' . $prefix . $table . '.php';
|
||||
if (is_file($schema)) {
|
||||
copy($schema, $backup . '/' . $table . '-schema-before.php');
|
||||
unlink($schema);
|
||||
}
|
||||
}
|
||||
echo "GRANARY_SITES_READY\n";
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
// CLI only: php file.php /absolute/backup/directory fixture.json
|
||||
if (PHP_SAPI !== 'cli') exit;
|
||||
chdir(dirname(__DIR__));
|
||||
define('APP_PATH', getcwd() . '/application/');
|
||||
require getcwd() . '/thinkphp/base.php';
|
||||
\think\App::initCommon();
|
||||
$backup = $argv[1];
|
||||
if (!is_dir($backup)) throw new Exception('Backup directory required');
|
||||
$fixture = json_decode(file_get_contents($argv[2]), true);
|
||||
if (!is_array($fixture) || count($fixture) !== 11) throw new Exception('Expected 11 Xishui warehouses');
|
||||
$prefix = config('database.prefix');
|
||||
if (!preg_match('/^[a-zA-Z0-9_]+$/D', $prefix)) throw new Exception('Invalid prefix');
|
||||
$ids = array_column($fixture, 'gid');
|
||||
$rows = \think\Db::name('granary')->where('site', 'xishui')->where('id', 'in', $ids)->select();
|
||||
if (count($rows) !== 11) throw new Exception('Warehouse ownership mismatch');
|
||||
$snapshot = $backup . '/xishui-before-demo.json';
|
||||
if (!file_exists($snapshot)) {
|
||||
file_put_contents($snapshot, json_encode(['granaries'=>$rows,
|
||||
'tests'=>\think\Db::name('testing')->where('gid','in',$ids)->select(),
|
||||
'params'=>\think\Db::name('testing_params')->where('gid','in',$ids)->select()], JSON_UNESCAPED_UNICODE));
|
||||
chmod($snapshot, 0600);
|
||||
}
|
||||
$columns = array_column(\think\Db::query('SHOW COLUMNS FROM `' . $prefix . 'testing`'), 'Field');
|
||||
if (!in_array('is_demo', $columns)) \think\Db::execute('ALTER TABLE `' . $prefix . 'testing` ADD `is_demo` tinyint unsigned NOT NULL DEFAULT 0 COMMENT \'1为演示化验记录,非实测\'');
|
||||
$schema = RUNTIME_PATH . 'schema/' . config('database.database') . '.' . $prefix . 'testing.php';
|
||||
if (is_file($schema)) { copy($schema, $backup . '/testing-schema-before.php'); unlink($schema); }
|
||||
\think\Db::clear();
|
||||
$created = [];
|
||||
\think\Db::transaction(function () use ($fixture, &$created, $prefix) {
|
||||
foreach ($fixture as $warehouse) {
|
||||
foreach ($warehouse['records'] as $record) {
|
||||
$month = str_replace('-', '', $record['date']);
|
||||
// Any existing record for this period is preserved, real or demo.
|
||||
if (\think\Db::name('testing')->where('gid', $warehouse['gid'])->where('type', $warehouse['type'])->where('test_date', $month)->count()) continue;
|
||||
if (\app\common\library\QualityRule::validate($record['params']) !== '') throw new Exception('Invalid demo parameters');
|
||||
$id = (new \app\common\model\TestingModel())->add([
|
||||
'gid'=>$warehouse['gid'], 'type'=>$warehouse['type'], 'season'=>2,
|
||||
'sort'=>0, 'create_time'=>time(), 'update_time'=>time(),
|
||||
], $record['params'], $record['date']);
|
||||
// This CLI process may have cached the schema before ADD COLUMN.
|
||||
\think\Db::execute('UPDATE `' . $prefix . 'testing` SET is_demo = 1 WHERE id = ?', [$id]);
|
||||
$created[] = $id;
|
||||
}
|
||||
}
|
||||
});
|
||||
$manifest = $backup . '/xishui-demo-created-ids.json';
|
||||
$previous = is_file($manifest) ? json_decode(file_get_contents($manifest), true) : [];
|
||||
file_put_contents($manifest, json_encode(array_values(array_unique(array_merge(is_array($previous) ? $previous : [], $created)))));
|
||||
echo json_encode(['result'=>'XISHUI_DEMO_READY','created_tests'=>count($created),'created_ids'=>$created], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
@@ -1,9 +1,10 @@
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'backend/granary-site'], function ($, undefined, Backend, Table, Form, SiteMixin) {
|
||||
|
||||
var Controller = {
|
||||
index: function () {
|
||||
var vue = new Vue({
|
||||
el: '#vue',
|
||||
mixins: [SiteMixin],
|
||||
data: {
|
||||
granaryList: [],
|
||||
// 表格当前页数据
|
||||
@@ -22,6 +23,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
listLoading: false,
|
||||
// 搜索字段
|
||||
search: {
|
||||
site: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
keyword: '',
|
||||
@@ -61,6 +63,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
// 刷新页面
|
||||
refreshPage() {
|
||||
this.search = {
|
||||
site: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
keyword: '',
|
||||
@@ -103,6 +106,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
this.listLoading = true;
|
||||
$.getJSON('content/index', {
|
||||
keyword: key,
|
||||
site: this.search.site,
|
||||
type: this.search.type,
|
||||
start_time: this.search.startTime,
|
||||
end_time: this.search.endTime,
|
||||
@@ -263,6 +267,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
add: function () {
|
||||
let vue = new Vue({
|
||||
el: '#vue',
|
||||
mixins: [SiteMixin],
|
||||
data: {
|
||||
granaryList: [],
|
||||
// 字段信息
|
||||
@@ -341,6 +346,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
}
|
||||
|
||||
this.granaryList = data.granaryList;
|
||||
const selected = this.granaryList.find(g => g.id === this.formData.gid);
|
||||
this.formSite = selected ? selected.site : ((this.siteOptions[0] || {}).id || '');
|
||||
} else {
|
||||
this.$message.warning(res.msg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
define(['jquery', 'bootstrap', 'backend', 'backend/forecast/components'], function ($, undefined, Backend, components) {
|
||||
'use strict';
|
||||
return { index: function () {
|
||||
new Vue({
|
||||
el: '#forecast-app',
|
||||
components: {'forecast-list': components.list, 'forecast-create': components.create, 'forecast-detail': components.detail},
|
||||
data: { rows: [], granaries: [], total: 0, page: 1, gid: '', site: '', siteOptions: Config.granarySites || [], loading: false, saving: false, creating: false, viewing: false, selected: null, loadSequence: 0 },
|
||||
created: function () { this.load(); },
|
||||
methods: {
|
||||
api: function (action, data, method) {
|
||||
var self = this;
|
||||
return new Promise(function (resolve, reject) {
|
||||
$.ajax({url: 'forecast/' + action, type: method || 'GET', data: data || {}, dataType: 'json', timeout: 45000,
|
||||
success: function (res) {
|
||||
if (res.code !== 200) { self.$message.error(res.msg || '操作失败,请刷新后重试'); reject(new Error(res.msg)); }
|
||||
else resolve(res);
|
||||
},
|
||||
error: function () { self.$message.error('请求失败或登录已失效,请刷新查看记录后重试'); reject(new Error('request failed')); }
|
||||
});
|
||||
});
|
||||
},
|
||||
load: function () {
|
||||
var self = this, sequence = ++this.loadSequence;
|
||||
this.loading = true;
|
||||
return this.api('index', {gid: this.gid, site: this.site, page: this.page}).then(function (res) {
|
||||
if (sequence !== self.loadSequence) return;
|
||||
self.rows = res.list; self.total = res.total; self.granaries = res.granaries;
|
||||
}).catch(function () {}).then(function () { if (sequence === self.loadSequence) self.loading = false; });
|
||||
},
|
||||
siteChanged: function () { this.gid = ''; this.page = 1; this.load(); },
|
||||
filterChanged: function () { this.page = 1; this.load(); },
|
||||
changePage: function (page) { this.page = page; this.load(); },
|
||||
newRecord: function () { this.creating = true; },
|
||||
closeCreate: function (done) { if (!this.saving) { this.creating = false; if (typeof done === 'function') done(); } },
|
||||
closeDetail: function (done) {
|
||||
var self = this;
|
||||
if (this.saving) return;
|
||||
this.$refs.detail.confirmDiscard().then(function () { self.viewing = false; if (typeof done === 'function') done(); }).catch(function () {});
|
||||
},
|
||||
openRecord: function (id) {
|
||||
var self = this;
|
||||
this.api('detail', {id: id}).then(function (res) { self.selected = res.data; self.viewing = true; }).catch(function () {});
|
||||
},
|
||||
write: function (action, data, callback) {
|
||||
if (this.saving) return;
|
||||
var self = this;
|
||||
this.saving = true;
|
||||
this.api(action, data, 'POST').then(function (res) {
|
||||
self.selected = res.data;
|
||||
if (action !== 'actual' && res.data.status === 'failed') self.$message.warning('温湿度已保存,算法暂不可用,可稍后重新生成');
|
||||
else self.$message.success(action === 'actual' ? '实测值已保存' : res.data.status === 'ready' ? '预测已生成并保存' : '记录已保存');
|
||||
if (callback) callback();
|
||||
self.load();
|
||||
}).catch(function () {}).then(function () { self.saving = false; });
|
||||
},
|
||||
createRecord: function (form) {
|
||||
var self = this;
|
||||
this.write('add', form, function () { self.creating = false; self.viewing = true; });
|
||||
},
|
||||
saveActual: function (form) { this.write('actual', form); },
|
||||
retry: function (id) {
|
||||
var self = this;
|
||||
this.$refs.detail.confirmDiscard().then(function () { self.write('retry', {id: id}); }).catch(function () {});
|
||||
}
|
||||
}
|
||||
});
|
||||
} };
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
define([], function () {
|
||||
'use strict';
|
||||
function today() {
|
||||
var d = new Date();
|
||||
return [d.getFullYear(), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0')].join('-');
|
||||
}
|
||||
function number(v) { return v === null || v === undefined || v === '' ? '—' : String(Number(v)); }
|
||||
function signed(v) { return v === null ? '—' : (v > 0 ? '+' : '') + Number(v); }
|
||||
function requestKey() {
|
||||
var bytes = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, function (n) { return n.toString(16).padStart(2, '0'); }).join('');
|
||||
}
|
||||
var dateOptions = { disabledDate: function (date) { return date.getTime() > Date.now() || date.getFullYear() < 2000; } };
|
||||
|
||||
/**
|
||||
* 中储粮《粮油储藏技术规范》GB/T 29890 标准工况模板
|
||||
*/
|
||||
var PRESETS = {
|
||||
quasi_low: {
|
||||
key: 'quasi_low',
|
||||
name: '准低温储粮推荐工况',
|
||||
tag: '准低温 (≤20℃)',
|
||||
type: 'primary',
|
||||
desc: '仓温≤20℃,绿色控温优选温区,延缓陈化',
|
||||
tm_i: 18.5,
|
||||
hm_i: 70.0,
|
||||
tm_o: 32.0,
|
||||
hm_o: 55.0
|
||||
},
|
||||
low: {
|
||||
key: 'low',
|
||||
name: '低温储粮标准工况',
|
||||
tag: '低温 (≤15℃)',
|
||||
type: 'success',
|
||||
desc: '仓温≤15℃,抑虫抑菌保鲜,高标准储粮',
|
||||
tm_i: 14.0,
|
||||
hm_i: 65.0,
|
||||
tm_o: 28.0,
|
||||
hm_o: 60.0
|
||||
},
|
||||
normal: {
|
||||
key: 'normal',
|
||||
name: '常温储粮常规工况',
|
||||
tag: '常温 (≤25℃)',
|
||||
type: 'info',
|
||||
desc: '仓温≤25℃,常规安全储粮受控温区',
|
||||
tm_i: 22.0,
|
||||
hm_i: 72.0,
|
||||
tm_o: 32.0,
|
||||
hm_o: 55.0
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 依据中储粮温控标准研判仓内温湿度合规状态
|
||||
*/
|
||||
function evaluateTemp(tm_i, hm_i) {
|
||||
if (tm_i === '' || tm_i === null || tm_i === undefined || !Number.isFinite(Number(tm_i))) {
|
||||
return { grade: 'unknown', label: '待设置', tagType: 'info', isHigh: false, hint: '请设置仓内温度' };
|
||||
}
|
||||
var t = Number(tm_i);
|
||||
if (t <= 15.0) {
|
||||
return { grade: 'low', label: '低温储粮', tagType: 'success', isHigh: false, hint: '达到低温储粮标准 (≤15℃,抑虫控酶,保质保鲜最优)' };
|
||||
}
|
||||
if (t <= 20.0) {
|
||||
return { grade: 'quasi_low', label: '准低温储粮', tagType: 'primary', isHigh: false, hint: '达到中储粮准低温推荐标准 (≤20℃,绿色控温优选温区)' };
|
||||
}
|
||||
if (t <= 25.0) {
|
||||
return { grade: 'normal', label: '常温储粮', tagType: 'info', isHigh: false, hint: '处于常温储粮安全控温范围 (≤25℃)' };
|
||||
}
|
||||
return { grade: 'high_warn', label: '高温超标预警', tagType: 'danger', isHigh: true, hint: '⚠️ 超过25℃中储粮控温安全线(当前' + t + '℃),高温易致发热霉变与虫害,请及时开启空调或通风降温!' };
|
||||
}
|
||||
|
||||
return {
|
||||
list: {
|
||||
template: '#forecast-list-template', props: ['rows', 'loading'],
|
||||
methods: {
|
||||
number: number,
|
||||
status: function (s) { return {pending: '待生成', processing: '生成中', ready: '已生成', failed: '生成失败,可重试'}[s] || s; },
|
||||
evalTemp: evaluateTemp
|
||||
}
|
||||
},
|
||||
create: {
|
||||
template: '#forecast-create-template', props: ['granaries', 'busy', 'siteDefaultEnv'],
|
||||
data: function () { return {
|
||||
form: {gid: '', observed_date: today(), horizons: [7, 15, 30, 60], tm_i: 18.5, hm_i: 70.0, tm_o: 32.0, hm_o: 55.0, request_key: requestKey()},
|
||||
dateOptions: dateOptions,
|
||||
presets: PRESETS,
|
||||
selectedPresetKey: 'quasi_low',
|
||||
fields: [
|
||||
{key: 'tm_i', label: '仓内温度', unit: '℃', min: -50, max: 80, step: 0.1, placeholder: '例:18.5'},
|
||||
{key: 'hm_i', label: '仓内相对湿度', unit: '%', min: 0, max: 100, step: 0.1, placeholder: '例:70.0'},
|
||||
{key: 'tm_o', label: '仓外温度', unit: '℃', min: -50, max: 80, step: 0.1, placeholder: '例:32.0'},
|
||||
{key: 'hm_o', label: '仓外相对湿度', unit: '%', min: 0, max: 100, step: 0.1, placeholder: '例:55.0'}
|
||||
]
|
||||
}; },
|
||||
computed: {
|
||||
tempStatus: function () {
|
||||
return evaluateTemp(this.form.tm_i, this.form.hm_i);
|
||||
},
|
||||
selectedGranary: function () {
|
||||
var gid = this.form.gid;
|
||||
return this.granaries.find(function (g) { return String(g.id) === String(gid); }) || null;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'form.gid': function (newGid) {
|
||||
if (!newGid) return;
|
||||
this.onGranaryChange(newGid);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
applyPreset: function (key) {
|
||||
var p = this.presets[key];
|
||||
if (!p) return;
|
||||
this.selectedPresetKey = key;
|
||||
this.form.tm_i = p.tm_i;
|
||||
this.form.hm_i = p.hm_i;
|
||||
this.form.tm_o = p.tm_o;
|
||||
this.form.hm_o = p.hm_o;
|
||||
this..success('已套用【' + p.name + '】规范温湿度');
|
||||
},
|
||||
onGranaryChange: function (gid) {
|
||||
var granary = this.granaries.find(function (g) { return String(g.id) === String(gid); });
|
||||
if (!granary) return;
|
||||
// 若仓库自带最近一次历史环境记录 last_env,则智能推荐带出
|
||||
if (granary.last_env && granary.last_env.tm_i !== undefined && granary.last_env.tm_i !== '') {
|
||||
this.form.tm_i = Number(granary.last_env.tm_i);
|
||||
this.form.hm_i = Number(granary.last_env.hm_i);
|
||||
this.form.tm_o = Number(granary.last_env.tm_o);
|
||||
this.form.hm_o = Number(granary.last_env.hm_o);
|
||||
this.selectedPresetKey = '';
|
||||
this..info('已自动读取【' + granary.name + '】最近一次温湿度记录');
|
||||
} else if (this.form.tm_i === '' || this.form.tm_i === undefined) {
|
||||
this.applyPreset('quasi_low');
|
||||
}
|
||||
},
|
||||
submit: function () {
|
||||
if (!this.form.gid || !this.form.observed_date || !this.form.horizons.length) return this..warning('请选择仓库、采集日期和预测周期');
|
||||
for (var i = 0; i < this.fields.length; i++) {
|
||||
var f = this.fields[i], v = this.form[f.key];
|
||||
if (v === '' || !Number.isFinite(Number(v)) || Number(v) < f.min || Number(v) > f.max) return this..warning('请填写有效的' + f.label + '(' + f.min + '~' + f.max + ')');
|
||||
}
|
||||
this.('submit', JSON.parse(JSON.stringify(this.form)));
|
||||
}
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
template: '#forecast-detail-template', props: ['record', 'busy'],
|
||||
data: function () { return { active: '', actualDate: '', actual: {}, baseline: '', dateOptions: dateOptions }; },
|
||||
computed: {
|
||||
point: function () { var id = this.active; return this.record.points.find(function (p) { return String(p.id) === id; }); },
|
||||
dirty: function () { return this.baseline !== this.snapshot(); },
|
||||
comparison: function () {
|
||||
if (!this.point) return [];
|
||||
var actual = this.actual;
|
||||
return this.point.rows.map(function (row) {
|
||||
var value = actual[row.key], val = value === '' || value === null || value === undefined || !Number.isFinite(Number(value)) ? null : Number(value);
|
||||
var delta = val !== null && row.predicted !== null ? Number((val - row.predicted).toFixed(4)) : null;
|
||||
return Object.assign({}, row, {delta: delta, percent: delta !== null && row.predicted !== 0 ? Number((delta / Math.abs(row.predicted) * 100).toFixed(2)) : null});
|
||||
});
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
active: function () { this.reset(); },
|
||||
record: { immediate: true, handler: function () {
|
||||
if (!this.record.points.some(function (p) { return String(p.id) === this.active; }, this)) this.active = String(this.record.points[0].id);
|
||||
this.reset();
|
||||
} }
|
||||
},
|
||||
methods: {
|
||||
number: number, signed: signed,
|
||||
snapshot: function () { return JSON.stringify([this.actualDate, this.actual]); },
|
||||
reset: function () {
|
||||
var point = this.point;
|
||||
this.actualDate = point.actual_date || '';
|
||||
var values = {};
|
||||
this.record.metrics.forEach(function (m) { values[m.key] = point.actual[m.key] == null ? '' : String(point.actual[m.key]); });
|
||||
this.actual = values;
|
||||
this.baseline = this.snapshot();
|
||||
},
|
||||
confirmDiscard: function () {
|
||||
if (this.busy) return Promise.reject();
|
||||
return this.dirty ? this.('当前周期有未保存的实测数据,确定放弃修改?', '未保存的数据', {type: 'warning'}) : Promise.resolve();
|
||||
},
|
||||
beforeLeave: function () {
|
||||
return this.confirmDiscard().then(function () { return true; });
|
||||
},
|
||||
save: function () {
|
||||
var any = false;
|
||||
for (var i = 0; i < this.record.metrics.length; i++) {
|
||||
var m = this.record.metrics[i], v = this.actual[m.key];
|
||||
if (v === '') continue;
|
||||
any = true;
|
||||
if (!Number.isFinite(Number(v)) || Number(v) < 0 || Number(v) > m.max) return this..warning('请填写有效的' + m.name + '实测值');
|
||||
}
|
||||
if (any && !this.actualDate) return this..warning('请选择实际检测日期');
|
||||
this.('save', {point_id: this.point.id, version: this.point.version, actual_date: this.actualDate, actual: Object.assign({}, this.actual)});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
define([], function () {
|
||||
'use strict';
|
||||
return {
|
||||
data: function () { return { siteOptions: Config.granarySites || [], formSite: '' }; },
|
||||
computed: {
|
||||
siteGranaries: function () {
|
||||
var site = this.formSite;
|
||||
return (this.granaryList || []).filter(function (g) { return site && g.site === site; });
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
siteChanged: function () {
|
||||
this.currentPage = 1;
|
||||
this.search.gid = '';
|
||||
this.search.rid = '';
|
||||
this.multipleSelection = [];
|
||||
this.loadData(this.search.keyword, 1, this.pageSize, this.search.sort);
|
||||
},
|
||||
formSiteChanged: function () { this.formData.gid = ''; },
|
||||
granaryLabel: function (g) { return g.label || ((g.site === 'xishui' ? '习水库' : '遵义中心库') + ' · ' + g.name); }
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'backend/granary-site'], function ($, undefined, Backend, Table, Form, SiteMixin) {
|
||||
|
||||
var Controller = {
|
||||
index: function () {
|
||||
var vue = new Vue({
|
||||
el: '#vue',
|
||||
mixins: [SiteMixin],
|
||||
data: {
|
||||
contentType: 'one',
|
||||
varietiesType: 'one',
|
||||
@@ -212,6 +213,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
}],
|
||||
// 搜索字段
|
||||
search: {
|
||||
site: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
keyword: '',
|
||||
@@ -239,6 +241,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
message: '请您输入粮仓名称',
|
||||
trigger: 'blur'
|
||||
}],
|
||||
site: [{
|
||||
required: true, message: '请选择所属库区', trigger: 'change'
|
||||
}],
|
||||
storage: [{
|
||||
required: true,
|
||||
message: '请您输入仓库储存点',
|
||||
@@ -263,6 +268,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
this.loadData(this.search.keyword, this.currentPage, this.pageSize, this.search.sort);
|
||||
},
|
||||
methods: {
|
||||
changeSite(site) {
|
||||
const selected = this.siteOptions.find(s => s.id === site);
|
||||
this.formData.storage = selected ? selected.storage : '';
|
||||
this.formData.screenPosition = '';
|
||||
this.changeGranaryType(site === 'xishui' ? 3 : 2);
|
||||
},
|
||||
// 直达中心库大屏
|
||||
openCenterScreen() {
|
||||
window.open('/screen/index.html', '_blank');
|
||||
@@ -274,6 +285,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
// 刷新页面
|
||||
refreshPage() {
|
||||
this.search = {
|
||||
site: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
keyword: '',
|
||||
@@ -291,7 +303,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
if (isNaN(num)) return;
|
||||
this.formData.num = num;
|
||||
if (this.title === '添加' && this.formData.granary_type !== 3) {
|
||||
this.formData.granary_type = num > 18 ? 2 : 1;
|
||||
this.formData.granary_type = this.formData.site === 'xishui' ? (num <= 8 ? 3 : 2) : (num > 18 ? 2 : 1);
|
||||
this.changeGranaryType(this.formData.granary_type);
|
||||
}
|
||||
},
|
||||
@@ -397,15 +409,15 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
id: 0,
|
||||
name: '',
|
||||
image: '',
|
||||
storage: '成都直属库有限公司本部',
|
||||
site: this.search.site || (this.siteOptions[0] || {}).id || '',
|
||||
storage: '',
|
||||
num: '',
|
||||
screenPosition: '',
|
||||
granary_type: 1,
|
||||
varieties: '菜籽油',
|
||||
content: []
|
||||
};
|
||||
this.contentType = 'one';
|
||||
this.varietiesType = 'one';
|
||||
this.changeSite(this.formData.site);
|
||||
} else {
|
||||
this.title = '编辑';
|
||||
let isPreset = ['菜籽油', '大豆油', '稻谷', '小麦', '玉米'].indexOf(item.varieties) !== -1;
|
||||
@@ -416,6 +428,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
image: item.image,
|
||||
site: item.site,
|
||||
storage: item.storage,
|
||||
num: item.num,
|
||||
screenPosition: (item.content || {}).screenPosition || '',
|
||||
@@ -433,7 +446,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
this.contentType = 'two';
|
||||
}
|
||||
|
||||
if (this.formData.granary_type == 1) {
|
||||
if (Number(this.formData.granary_type) === 1) {
|
||||
this.varietiesType = 'one';
|
||||
} else {
|
||||
this.varietiesType = 'two';
|
||||
@@ -463,6 +476,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
this.listLoading = true;
|
||||
$.getJSON('granary/index', {
|
||||
keyword: key,
|
||||
site: this.search.site,
|
||||
type: this.search.type,
|
||||
start_time: this.search.startTime,
|
||||
end_time: this.search.endTime,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'backend/granary-site'], function ($, undefined, Backend, Table, Form, SiteMixin) {
|
||||
|
||||
var Controller = {
|
||||
index: function () {
|
||||
let vue = new Vue({
|
||||
el: '#vue',
|
||||
mixins: [SiteMixin],
|
||||
data: {
|
||||
typeName: '',
|
||||
detail: {
|
||||
@@ -62,6 +63,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
listLoading: false,
|
||||
// 搜索字段
|
||||
search: {
|
||||
site: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
keyword: '',
|
||||
@@ -115,9 +117,13 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
this.loadData(this.search.keyword, this.currentPage, this.pageSize, this.search.sort);
|
||||
},
|
||||
methods: {
|
||||
isRuleNumber(value) {
|
||||
return (typeof value === 'string' || typeof value === 'number')
|
||||
&& /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(String(value).trim())
|
||||
&& Number.isFinite(Number(value));
|
||||
},
|
||||
isNumericRule(item) {
|
||||
return [item.warning, item.alert].every(value =>
|
||||
value !== null && value !== undefined && String(value).trim() !== '' && Number.isFinite(Number(value)));
|
||||
return [item.warning, item.alert].every(value => this.isRuleNumber(value));
|
||||
},
|
||||
ruleDirection(item) {
|
||||
return item.direction === 'upper' || item.direction === 'lower' ? item.direction
|
||||
@@ -140,7 +146,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
: [`当前值 ≥ ${w}`, `${a} ≤ 当前值 < ${w}`, `当前值 < ${a}`];
|
||||
},
|
||||
ruleError(item) {
|
||||
const expectsNumeric = item.rule_numeric === true || Number(item.is_number) === 1
|
||||
|| this.isRuleNumber(item.warning) || this.isRuleNumber(item.alert);
|
||||
if (expectsNumeric && !this.isNumericRule(item)) return '预警值和警告值必须都是有限数字';
|
||||
if (!this.isNumericRule(item)) return '';
|
||||
if (!this.isRuleNumber(item.val)
|
||||
&& !/^(?:\/|--?|未检测|未提供|暂无|未检出|未检出\s*ND|ND)$/i.test(String(item.val ?? '').trim())) {
|
||||
return '检测值必须是有限数字,缺测可填 /';
|
||||
}
|
||||
const upper = this.ruleDirection(item) === 'upper';
|
||||
return (upper && Number(item.warning) > Number(item.alert)) || (!upper && Number(item.warning) < Number(item.alert))
|
||||
? (upper ? '越小越好指标要求:预警值 ≤ 警告值(高值预警)' : '越大越好指标要求:预警值 ≥ 警告值(低值预警)') : '';
|
||||
@@ -148,6 +161,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
// 刷新页面
|
||||
refreshPage() {
|
||||
this.search = {
|
||||
site: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
keyword: '',
|
||||
@@ -164,15 +178,37 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
let list = this.granaryList;
|
||||
let item = list.find(v => v.id === e);
|
||||
|
||||
if (!item) return;
|
||||
this.typeName = item.varieties;
|
||||
this.formData.type = item.type;
|
||||
},
|
||||
handelIsNumber(index, val) {
|
||||
this.typeList[this.formData.type]['content'][index].is_number = val;
|
||||
},
|
||||
openPage(url, title) {
|
||||
if (typeof top !== 'undefined' && top && top.Backend && top.Backend.api && typeof top.Backend.api.addtabs === 'function') {
|
||||
top.Backend.api.addtabs(url, title);
|
||||
} else if (typeof Backend !== 'undefined' && Backend && Backend.api && typeof Backend.api.addtabs === 'function') {
|
||||
Backend.api.addtabs(url, title);
|
||||
} else if (typeof window !== 'undefined' && window.location) {
|
||||
window.location.href = url;
|
||||
}
|
||||
},
|
||||
openAddPage() {
|
||||
this.openPage('testing/add', '添加检测报告');
|
||||
},
|
||||
openEditPage(item) {
|
||||
var id = typeof item === 'object' ? item.id : item;
|
||||
var name = (item && item.granary && item.granary.name) ? ' - ' + item.granary.name : '';
|
||||
this.openPage('testing/edit?ids=' + id, '编辑检测报告' + name);
|
||||
},
|
||||
openDetailPage(item) {
|
||||
var id = typeof item === 'object' ? item.id : item;
|
||||
var name = (item && item.granary && item.granary.name) ? ' - ' + item.granary.name : '';
|
||||
this.openPage('testing/detail?ids=' + id, '检测报告详情' + name);
|
||||
},
|
||||
openDialogDetail(detail) {
|
||||
this.detail = detail;
|
||||
this.detailDialog = true;
|
||||
this.openDetailPage(detail);
|
||||
},
|
||||
handleClearSelect(field) {
|
||||
// 重新加载
|
||||
@@ -261,13 +297,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
})
|
||||
});
|
||||
},
|
||||
// 添加/编辑
|
||||
// 添加/编辑(兼容原有状态准备)
|
||||
openAddDialog(type, item) {
|
||||
this.addDialog = true;
|
||||
this.typeList = JSON.parse(JSON.stringify(this.newTypeList));
|
||||
|
||||
if (type === 'add') {
|
||||
this.title = '添加';
|
||||
this.formSite = this.search.site || (this.siteOptions[0] || {}).id || '';
|
||||
this.formData = {
|
||||
id: 0,
|
||||
gid: '',
|
||||
@@ -279,6 +316,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
};
|
||||
} else {
|
||||
this.title = '编辑';
|
||||
this.formSite = item.granary.site;
|
||||
this.formData = {
|
||||
id: item.id,
|
||||
gid: item.gid,
|
||||
@@ -292,6 +330,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
this.typeName = item.granary.varieties;
|
||||
this.typeList[item.type]['content'] = JSON.parse(JSON.stringify(item.testing_params));
|
||||
}
|
||||
for (const group of Object.values(this.typeList)) {
|
||||
for (const row of group.content || []) {
|
||||
this.$set(row, 'rule_numeric', this.isNumericRule(row));
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSort(item) {
|
||||
// 提交
|
||||
@@ -325,6 +368,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
||||
this.listLoading = true;
|
||||
$.getJSON('testing/index', {
|
||||
keyword: key,
|
||||
site: this.search.site,
|
||||
type: this.search.type,
|
||||
gid: this.search.gid,
|
||||
date: this.search.date,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
.quality-badge.status-unknown {
|
||||
background: rgba(148, 163, 184, .16);
|
||||
color: #cbd5e1;
|
||||
border: 1px solid rgba(148, 163, 184, .4);
|
||||
}
|
||||
.val-unknown { color: #94a3b8; text-shadow: none; }
|
||||
.quality-strip-card.strip-unknown, .quality-card.card-unknown {
|
||||
border-color: rgba(148, 163, 184, .35);
|
||||
box-shadow: none;
|
||||
}
|
||||
.gauge-unknown { background: #64748b; box-shadow: none; }
|
||||
|
||||
.quality-empty-state {
|
||||
min-height: 240px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 24px 18px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.quality-empty-state h3 { margin: 0; color: #cbd5e1; font-size: 14px; font-weight: 500; }
|
||||
.quality-empty-state p { margin: 0; max-width: 280px; font-size: 12px; line-height: 1.9; }
|
||||
.alert-item-pill.unknown { color: #cbd5e1; border-color: rgba(148, 163, 184, .4); background: rgba(148, 163, 184, .1); }
|
||||
.alert-item-pill.unknown .pill-dot { background: #94a3b8; box-shadow: none; }
|
||||
|
||||
/* 状态颜色统一:数据状态与品种/系列配色分别表达。 */
|
||||
.quality-badge.status-normal, .quality-card-value.val-normal,
|
||||
.strip-main-val.val-normal, .alert-item-pill.normal { color: var(--status-normal); }
|
||||
.quality-badge.status-warn, .quality-card-value.val-warn,
|
||||
.strip-main-val.val-warn, .alert-item-pill.warn, .strip-warn .strip-limit-desc { color: var(--status-warn); }
|
||||
.quality-badge.status-danger, .quality-card-value.val-danger,
|
||||
.strip-main-val.val-danger, .alert-item-pill.danger, .strip-danger .strip-limit-desc { color: var(--status-danger); }
|
||||
.quality-badge.status-unknown, .quality-card-value.val-unknown,
|
||||
.strip-main-val.val-unknown, .alert-item-pill.unknown { color: var(--status-unknown); }
|
||||
.strip-gauge-fill.gauge-normal { background: var(--status-normal); }
|
||||
.strip-gauge-fill.gauge-warn { background: var(--status-warn); }
|
||||
.strip-gauge-fill.gauge-danger { background: var(--status-danger); }
|
||||
.strip-gauge-fill.gauge-unknown { background: var(--status-unknown); }
|
||||
.alert-item-pill .pill-dot { background: currentColor; }
|
||||
@@ -26,6 +26,7 @@
|
||||
--grain-oil: #f59e0b; /* 菜籽油脂琥珀金 */
|
||||
--status-normal: #10b981; /* 运行正常/达标 */
|
||||
--status-warn: #f59e0b; /* 关注/轻度预警 */
|
||||
--status-unknown: #94a3b8; /* 缺失/待核实 */
|
||||
--status-danger: #ef4444; /* 报警/异常 */
|
||||
|
||||
/* 文本阶梯色彩 Tokens */
|
||||
@@ -373,7 +374,7 @@ html, body {
|
||||
.alert-item-pill.danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border: 1px solid rgba(239, 68, 68, 0.45);
|
||||
color: #fca5a5;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.alert-item-pill.danger:hover {
|
||||
@@ -384,7 +385,7 @@ html, body {
|
||||
.alert-item-pill.warn {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border: 1px solid rgba(245, 158, 11, 0.45);
|
||||
color: #fde68a;
|
||||
color: var(--status-warn);
|
||||
}
|
||||
|
||||
.alert-item-pill.warn:hover {
|
||||
@@ -395,7 +396,7 @@ html, body {
|
||||
.alert-item-pill.normal {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
color: #6ee7b7;
|
||||
color: var(--status-normal);
|
||||
}
|
||||
|
||||
/* ================= 顶部 KPI 栏 (高精密态势数据舱) ================= */
|
||||
@@ -737,19 +738,19 @@ html, body {
|
||||
|
||||
.quality-badge.status-normal {
|
||||
background: rgba(16, 185, 129, 0.18);
|
||||
color: #34d399;
|
||||
color: var(--status-normal);
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
.quality-badge.status-warn {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fbbf24;
|
||||
color: var(--status-warn);
|
||||
border: 1px solid rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
.quality-badge.status-danger {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
color: #f87171;
|
||||
color: var(--status-danger);
|
||||
border: 1px solid rgba(239, 68, 68, 0.6);
|
||||
font-weight: 700;
|
||||
animation: badge-blink 1.8s infinite ease-in-out;
|
||||
@@ -779,15 +780,15 @@ html, body {
|
||||
}
|
||||
|
||||
.quality-card-value.val-normal {
|
||||
color: #34d399;
|
||||
color: var(--status-normal);
|
||||
}
|
||||
|
||||
.quality-card-value.val-warn {
|
||||
color: #fbbf24;
|
||||
color: var(--status-warn);
|
||||
}
|
||||
|
||||
.quality-card-value.val-danger {
|
||||
color: #f87171;
|
||||
color: var(--status-danger);
|
||||
text-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
@@ -919,7 +920,7 @@ html, body {
|
||||
}
|
||||
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;
|
||||
border-color: var(--status-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,17 +971,17 @@ html, body {
|
||||
}
|
||||
|
||||
.strip-main-val.val-normal {
|
||||
color: #34d399;
|
||||
color: var(--status-normal);
|
||||
text-shadow: 0 0 6px rgba(52, 211, 153, 0.3);
|
||||
}
|
||||
|
||||
.strip-main-val.val-warn {
|
||||
color: #fbbf24;
|
||||
color: var(--status-warn);
|
||||
text-shadow: 0 0 8px rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
.strip-main-val.val-danger {
|
||||
color: #f87171;
|
||||
color: var(--status-danger);
|
||||
text-shadow: 0 0 10px rgba(248, 113, 113, 0.6);
|
||||
}
|
||||
|
||||
@@ -1013,15 +1014,15 @@ html, body {
|
||||
}
|
||||
|
||||
.strip-gauge-fill.gauge-normal {
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
background: linear-gradient(90deg, #10b981, var(--status-normal));
|
||||
}
|
||||
|
||||
.strip-gauge-fill.gauge-warn {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
background: linear-gradient(90deg, #f59e0b, var(--status-warn));
|
||||
}
|
||||
|
||||
.strip-gauge-fill.gauge-danger {
|
||||
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||
background: linear-gradient(90deg, #ef4444, var(--status-danger));
|
||||
}
|
||||
|
||||
/* 底部:限值说明文字 (保证完整露出,杜绝遮挡) */
|
||||
@@ -1045,12 +1046,12 @@ html, body {
|
||||
}
|
||||
|
||||
.strip-danger .strip-limit-desc {
|
||||
color: #fca5a5;
|
||||
color: var(--status-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.strip-warn .strip-limit-desc {
|
||||
color: #fde68a;
|
||||
color: var(--status-warn);
|
||||
}
|
||||
|
||||
/* ================== 食品卫生安全绿盾微矩阵 (4 列 x 2 行) ================== */
|
||||
@@ -1078,7 +1079,7 @@ html, body {
|
||||
.svg-shield-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: #34d399;
|
||||
color: var(--status-normal);
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
@@ -1088,7 +1089,7 @@ html, body {
|
||||
align-items: center;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: #34d399;
|
||||
color: var(--status-normal);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1098,7 +1099,7 @@ html, body {
|
||||
.shield-badge {
|
||||
font-size: 9px;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
color: var(--status-normal);
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
@@ -1152,7 +1153,7 @@ html, body {
|
||||
.shield-item-val {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #34d399;
|
||||
color: var(--status-normal);
|
||||
font-family: var(--font-number);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -1164,12 +1165,12 @@ html, body {
|
||||
}
|
||||
|
||||
.shield-alert .shield-item-val {
|
||||
color: #f87171;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.shield-item-status {
|
||||
font-size: 8px;
|
||||
color: #34d399;
|
||||
color: var(--status-normal);
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
margin-top: 1px;
|
||||
@@ -1178,7 +1179,7 @@ html, body {
|
||||
}
|
||||
|
||||
.shield-alert .shield-item-status {
|
||||
color: #f87171;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -1258,7 +1259,7 @@ html, body {
|
||||
.center-chart-close-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
color: #f87171;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
/* 方案 A:大盘模式切换胶囊 */
|
||||
@@ -1561,7 +1562,7 @@ html, body {
|
||||
|
||||
#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;
|
||||
border-color: var(--status-normal) !important;
|
||||
box-shadow: 0 0 16px rgba(52, 211, 153, 0.55);
|
||||
}
|
||||
|
||||
@@ -1812,7 +1813,7 @@ html, body {
|
||||
font-size: 8.5px;
|
||||
padding: 1px 2.5px;
|
||||
background: rgba(245, 158, 11, 0.25);
|
||||
color: #fbbf24;
|
||||
color: var(--status-warn);
|
||||
}
|
||||
|
||||
.hotspot-pin.oil .bubble-arrow {
|
||||
@@ -1840,19 +1841,19 @@ html, body {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #fbbf24;
|
||||
box-shadow: 0 0 6px #fbbf24, 0 0 10px rgba(245, 158, 11, 0.8);
|
||||
background: var(--status-warn);
|
||||
box-shadow: 0 0 6px var(--status-warn), 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;
|
||||
background: linear-gradient(180deg, var(--status-warn) 0%, #ffffff 100%);
|
||||
box-shadow: 0 0 10px var(--status-warn);
|
||||
}
|
||||
|
||||
.hotspot-pin.active .stem-dot {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 10px #ffffff, 0 0 16px #fbbf24;
|
||||
box-shadow: 0 0 10px #ffffff, 0 0 16px var(--status-warn);
|
||||
}
|
||||
|
||||
/* 激活状态高光与聚焦光晕 */
|
||||
@@ -1877,13 +1878,13 @@ html, body {
|
||||
}
|
||||
|
||||
.hotspot-pin.oil.active .bubble-inner {
|
||||
background: linear-gradient(135deg, #d97706 0%, #fbbf24 100%);
|
||||
background: linear-gradient(135deg, #d97706 0%, var(--status-warn) 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;
|
||||
border-top-color: var(--status-warn);
|
||||
}
|
||||
|
||||
.hotspot-pin.paddy.active .bubble-inner {
|
||||
@@ -2157,7 +2158,8 @@ html, body {
|
||||
.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; }
|
||||
.threshold-key { color: var(--status-danger); }
|
||||
.warning-key { color: var(--status-warn); }
|
||||
.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; }
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>中储粮(遵义)智慧粮仓综合监测中心</title>
|
||||
<link rel="stylesheet" href="./css/style.css?v=20261028_chart3">
|
||||
<link rel="stylesheet" href="./css/style.css?v=20261028_status2">
|
||||
<link rel="stylesheet" href="./css/quality-status.css?v=20261028_xishui1">
|
||||
<!-- ECharts 可视化库 (CDN) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
</head>
|
||||
@@ -343,7 +344,7 @@
|
||||
<div class="center-chart-card-title" id="forecast-trend-title">多周期趋势</div>
|
||||
<div class="forecast-env-pill" id="forecast-trend-env-pill">工况: 仓内 18.5℃ / 72.4% · 仓外 32.5℃ / 54.2%</div>
|
||||
</div>
|
||||
<div class="forecast-chart-note"><span>各指标独立刻度 · 保留原始单位</span><span>● 实测起点 ┈ 预测趋势 <span class="threshold-key">┄ 警戒参考</span></span></div>
|
||||
<div class="forecast-chart-note"><span>各指标独立刻度 · 数值为实测 → 末期预测</span><span>● 实测起点 ┈ 预测趋势 <span class="warning-key">┄ 预警</span> <span class="threshold-key">┄ 警戒</span></span></div>
|
||||
<div id="chart-forecast-trend" class="center-chart-canvas"></div>
|
||||
</div>
|
||||
<!-- 右侧:现状实测 vs 预测推演偏离对比柱状图 -->
|
||||
@@ -417,6 +418,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module" src="./js/app.js?v=20261028_chart3"></script>
|
||||
<script type="module" src="./js/app.js?v=20261028_demo3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchSiteData, showSiteDataState } from './site_data.js?v=20261028_demo3';
|
||||
import {
|
||||
calcScreenScale,
|
||||
normalizeGranaryList,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
resolveHotspotCoord,
|
||||
generateDepotAlerts,
|
||||
syncAlertMarqueeSpeed
|
||||
} from './grain_monitor_core.js?v=20261028_chart3';
|
||||
} from './grain_monitor_core.js?v=20261028_demo3';
|
||||
|
||||
// 全局状态
|
||||
const STATE = {
|
||||
@@ -92,28 +93,18 @@ function updateClock() {
|
||||
|
||||
// 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) {
|
||||
const list = json.data.list;
|
||||
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) {
|
||||
// 当前端点连接失败,自动尝试下一端点
|
||||
}
|
||||
try {
|
||||
const data = await fetchSiteData('center');
|
||||
STATE.list = normalizeGranaryList({ data });
|
||||
STATE.customCoords = data.coords || {};
|
||||
STATE.activeId = STATE.list.some(item => item.id === STATE.activeId) ? STATE.activeId : (STATE.list[0]?.id || null);
|
||||
showSiteDataState('center', STATE.list.length ? 'live' : 'empty', STATE.list.length, STATE.list.some(item => item.isDemo));
|
||||
} catch (_) {
|
||||
STATE.list = [];
|
||||
STATE.customCoords = {};
|
||||
STATE.activeId = null;
|
||||
showSiteDataState('center', 'unavailable', 0);
|
||||
}
|
||||
|
||||
console.warn('在线与本地 API 均不可用,使用内置官方完整数据集');
|
||||
STATE.list = normalizeGranaryList(DEFAULT_GRANARY_DATA);
|
||||
}
|
||||
|
||||
// 3.1 加载微气候环境工况
|
||||
@@ -192,7 +183,7 @@ function renderKPIs() {
|
||||
document.getElementById('kpi-oil-count').textContent = `${s.oilTankCount} 座`;
|
||||
const filterLabels = {
|
||||
all: `全部 (${STATE.list.length})`,
|
||||
warehouse: `平房仓群 (${s.warehouseCount})`,
|
||||
warehouse: `粮仓群 (${s.warehouseCount + s.siloCount})`,
|
||||
oilTank: `储油罐区 (${s.oilTankCount})`
|
||||
};
|
||||
document.querySelectorAll('.filter-btn[data-filter]').forEach(button => {
|
||||
@@ -444,8 +435,8 @@ function renderLeftPanel(item, params) {
|
||||
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-addr').textContent = item.isOilTank ? b.productArea : b.addr;
|
||||
document.getElementById('param-granary-year').textContent = item.isOilTank ? b.machiningYear : b.harvestYear;
|
||||
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 || '直接管理';
|
||||
@@ -482,6 +473,12 @@ function renderLeftPanel(item, params) {
|
||||
<span class="param-value">立式储油罐密闭</span>
|
||||
</div>
|
||||
`;
|
||||
} else if (params.type === 'grainSilo') {
|
||||
sizeGrid.innerHTML = `<div class="param-item"><span class="param-label">筒仓直径</span><span class="param-value highlight">${s.diameter}</span></div>
|
||||
<div class="param-item"><span class="param-label">装粮线高</span><span class="param-value highlight">${s.height}</span></div>
|
||||
<div class="param-item"><span class="param-label">粮堆体积</span><span class="param-value">${s.volume}</span></div>
|
||||
<div class="param-item"><span class="param-label">扣除体积</span><span class="param-value">${s.decVolume}</span></div>
|
||||
<div class="param-item"><span class="param-label">储粮方式</span><span class="param-value">${b.storageMethod}</span></div>`;
|
||||
} else {
|
||||
sizeGrid.innerHTML = `
|
||||
<div class="param-item">
|
||||
@@ -537,6 +534,12 @@ function renderRightPanel(item, params) {
|
||||
|
||||
const report = params.qualityReport;
|
||||
if (!report) return;
|
||||
if (report.empty) {
|
||||
if (qualityTitle) qualityTitle.textContent = `${report.variety}品质全参数舱 · 未录入化验记录`;
|
||||
if (qualityExtra) qualityExtra.textContent = '暂无化验数据';
|
||||
qualityGrid.innerHTML = buildQualityHudHtml(report);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标题与总体状态指示徽章 (短小精悍,杜绝截断)
|
||||
if (qualityTitle) {
|
||||
@@ -545,9 +548,12 @@ function renderRightPanel(item, params) {
|
||||
statusBadge = `<span class="quality-badge status-danger" style="margin-left: 5px;">⚠️ ${report.summary.dangerCount}项超标</span>`;
|
||||
} else if (report.summary.warnCount > 0) {
|
||||
statusBadge = `<span class="quality-badge status-warn" style="margin-left: 5px;">⚠️ ${report.summary.warnCount}项预警</span>`;
|
||||
} else {
|
||||
} else if (!report.summary.unknownCount) {
|
||||
statusBadge = `<span class="quality-badge status-normal" style="margin-left: 5px;">✓ 健康达标</span>`;
|
||||
}
|
||||
if (report.summary.unknownCount > 0) {
|
||||
statusBadge += `<span class="quality-badge status-unknown" style="margin-left: 5px;">${report.summary.unknownCount}项待核实</span>`;
|
||||
}
|
||||
qualityTitle.innerHTML = `<span>${report.variety}品质全参数舱</span>${statusBadge}`;
|
||||
}
|
||||
|
||||
@@ -646,7 +652,7 @@ async function initOrUpdateForecastCharts(granaryItem) {
|
||||
|
||||
setupForecastEnvControls();
|
||||
|
||||
const currentReport = getGranaryQualityReport(grainName, currentItem.rawContent || currentItem.content || {}, currentItem.qualityTest);
|
||||
const currentReport = getGranaryQualityReport(grainName, currentItem.rawContent || currentItem.content || {}, currentItem.qualityTest, { requireRecordedData: currentItem.dataSource === 'database' });
|
||||
|
||||
// 1. 0 毫秒即时秒出基础图表 (杜绝任何白屏与残亮点)
|
||||
const baselineList = [
|
||||
@@ -687,6 +693,9 @@ async function initOrUpdateForecastCharts(granaryItem) {
|
||||
key: m.key,
|
||||
name: m.name,
|
||||
unit: m.unit,
|
||||
warn: m.warn,
|
||||
alert: m.alert,
|
||||
direction: m.direction,
|
||||
predictedValue: m.values[3] !== undefined ? m.values[3] : m.values[m.values.length - 1]
|
||||
}));
|
||||
const initialCompareOption = buildForecastCompareChartOption(grainType, currentReport?.items || [], initialFuture30);
|
||||
@@ -723,6 +732,9 @@ async function initOrUpdateForecastCharts(granaryItem) {
|
||||
key: m.key,
|
||||
name: m.name,
|
||||
unit: m.unit,
|
||||
warn: m.warn,
|
||||
alert: m.alert,
|
||||
direction: m.direction,
|
||||
predictedValue: m.values[3] !== undefined ? m.values[3] : m.values[m.values.length - 1]
|
||||
}));
|
||||
|
||||
@@ -886,7 +898,7 @@ function initOrUpdateGranaryBar() {
|
||||
|
||||
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 capacityData = STATE.list.map(i => i.capacityNum);
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
@@ -1139,6 +1151,7 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
}, 60000);
|
||||
|
||||
setupAutoPlay();
|
||||
if (urlParams.get('view') === 'forecast') selectGranary(STATE.activeId, true);
|
||||
|
||||
const fsBtn = document.getElementById('btn-fullscreen');
|
||||
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
|
||||
@@ -1154,6 +1167,11 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
if (e.key === 'Escape' && STATE.isCenterChartOpen) {
|
||||
toggleCenterChartBoard(STATE.centerChartTab, false);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
46
public/screen/js/site_data.js
普通文件
46
public/screen/js/site_data.js
普通文件
@@ -0,0 +1,46 @@
|
||||
export function validateSitePayload(payload, site) {
|
||||
const data = payload?.data;
|
||||
if (!data || data.site !== site || !Array.isArray(data.list)) throw new Error('库区数据不匹配');
|
||||
if (data.list.some(row => row.site !== site)) throw new Error('仓库归属不匹配');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchSiteData(site, fetcher = fetch) {
|
||||
const path = `/api/index/granaryList?site=${encodeURIComponent(site)}`;
|
||||
const endpoints = [path, `https://grain.gynhc.com${path}`];
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const response = await fetcher(url, { cache: 'no-store', signal: AbortSignal.timeout(8000) });
|
||||
if (!response.ok) continue;
|
||||
return validateSitePayload(await response.json(), site);
|
||||
} catch (_) { /* Try the configured service if this origin is unavailable. */ }
|
||||
}
|
||||
throw new Error('暂时无法读取库区数据');
|
||||
}
|
||||
|
||||
export function showSiteDataState(site, state, count, hasDemo = false) {
|
||||
const wrapper = document.getElementById('screen-wrapper');
|
||||
if (!wrapper) return;
|
||||
let badge = document.getElementById('site-data-state');
|
||||
if (!badge) {
|
||||
badge = document.createElement('div');
|
||||
badge.id = 'site-data-state';
|
||||
badge.setAttribute('role', 'status');
|
||||
badge.style.cssText = 'position:absolute;top:83px;left:50%;transform:translateX(-50%);z-index:100;padding:5px 14px;border:1px solid #35536a;border-radius:4px;background:#0b1828;color:#bcd4e8;font-size:13px;pointer-events:none;white-space:nowrap';
|
||||
wrapper.appendChild(badge);
|
||||
}
|
||||
const name = site === 'xishui' ? '习水库' : '遵义中心库';
|
||||
badge.dataset.state = state;
|
||||
badge.hidden = state === 'live';
|
||||
badge.textContent = state === 'live' ? `${name} · 后台数据 · ${count} 座仓储设施${hasDemo ? ' · 含 DEMO 演示化验,非实测' : ''}`
|
||||
: state === 'empty' ? `${name} · 暂无仓库资料` : `${name} · 数据连接失败,请稍后重试`;
|
||||
let empty = document.getElementById('site-empty-state');
|
||||
if (!empty) {
|
||||
empty = document.createElement('div');
|
||||
empty.id = 'site-empty-state';
|
||||
empty.style.cssText = 'position:absolute;inset:120px 0 50px;z-index:99;display:none;align-items:center;justify-content:center;background:rgba(6,16,29,.97);color:#bcd4e8;font-size:24px';
|
||||
wrapper.appendChild(empty);
|
||||
}
|
||||
empty.style.display = state === 'live' ? 'none' : 'flex';
|
||||
empty.textContent = state === 'empty' ? '当前库区暂无仓库资料,请在后台录入' : '暂时无法读取库区数据,请检查网络后刷新';
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchSiteData, showSiteDataState } from './site_data.js?v=20261028_demo3';
|
||||
import {
|
||||
calcScreenScale,
|
||||
normalizeGranaryList,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
getGranaryQualityReport,
|
||||
generateDepotAlerts,
|
||||
syncAlertMarqueeSpeed
|
||||
} from './grain_monitor_core.js?v=20261028_chart3';
|
||||
} from './grain_monitor_core.js?v=20261028_demo3';
|
||||
|
||||
// 全局状态
|
||||
const STATE = {
|
||||
@@ -86,37 +87,18 @@ function updateClock() {
|
||||
}
|
||||
|
||||
// 3. 加载习水子库标准数据集
|
||||
function loadGranaryData() {
|
||||
STATE.list = normalizeGranaryList(XISHUI_GRANARY_DATA);
|
||||
|
||||
// 尝试连接后台接口同步最新配置
|
||||
if (typeof fetch !== 'undefined') {
|
||||
const urls = [
|
||||
'/api/index/granaryList?site=xishui',
|
||||
'https://grain.gynhc.com/api/index/granaryList?site=xishui',
|
||||
'/api/granary/list?site=xishui'
|
||||
];
|
||||
(async () => {
|
||||
for (const u of urls) {
|
||||
try {
|
||||
const res = await fetch(u, { 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) {
|
||||
STATE.list = normalizeGranaryList(json);
|
||||
if (json.data.coords) {
|
||||
STATE.customCoords = json.data.coords;
|
||||
}
|
||||
renderKPIs();
|
||||
renderHotspots();
|
||||
renderMiniGrid();
|
||||
renderDetailPanels();
|
||||
renderDepotAlerts();
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
async function loadGranaryData() {
|
||||
try {
|
||||
const data = await fetchSiteData('xishui');
|
||||
STATE.list = normalizeGranaryList({ data });
|
||||
STATE.customCoords = data.coords || {};
|
||||
STATE.activeId = STATE.list.some(item => item.id === STATE.activeId) ? STATE.activeId : (STATE.list[0]?.id || null);
|
||||
showSiteDataState('xishui', STATE.list.length ? 'live' : 'empty', STATE.list.length, STATE.list.some(item => item.isDemo));
|
||||
} catch (_) {
|
||||
STATE.list = [];
|
||||
STATE.customCoords = {};
|
||||
STATE.activeId = null;
|
||||
showSiteDataState('xishui', 'unavailable', 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,8 +377,8 @@ function renderLeftPanel(item, params) {
|
||||
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 || '河南/湖北';
|
||||
document.getElementById('param-granary-year').textContent = b.harvestYear || '2023';
|
||||
document.getElementById('param-granary-addr').textContent = item.isOilTank ? b.productArea : b.addr;
|
||||
document.getElementById('param-granary-year').textContent = item.isOilTank ? b.machiningYear : b.harvestYear;
|
||||
document.getElementById('param-granary-enter').textContent = b.enterTime || '2023-08';
|
||||
document.getElementById('param-granary-custodian').textContent = b.custodian || '张卫国';
|
||||
document.getElementById('param-granary-mgmt').textContent = b.managementForm || '直接管理';
|
||||
@@ -500,20 +482,26 @@ function renderRightPanel(item, params) {
|
||||
// 标题与总体状态指示徽章 (短小精悍,杜绝截断)
|
||||
if (qualityTitle) {
|
||||
let statusBadge = '';
|
||||
if (report.summary.dangerCount > 0) {
|
||||
if (report.empty) {
|
||||
statusBadge = '<span class="quality-badge status-unknown" style="margin-left: 5px;">暂无数据</span>';
|
||||
} else if (report.summary.dangerCount > 0) {
|
||||
statusBadge = `<span class="quality-badge status-danger" style="margin-left: 5px;">⚠️ ${report.summary.dangerCount}项超标</span>`;
|
||||
} else if (report.summary.warnCount > 0) {
|
||||
statusBadge = `<span class="quality-badge status-warn" style="margin-left: 5px;">⚠️ ${report.summary.warnCount}项预警</span>`;
|
||||
} else {
|
||||
} else if (!report.summary.unknownCount) {
|
||||
statusBadge = `<span class="quality-badge status-normal" style="margin-left: 5px;">✓ 健康达标</span>`;
|
||||
}
|
||||
if (report.summary.unknownCount > 0) {
|
||||
statusBadge += `<span class="quality-badge status-unknown" style="margin-left: 5px;">${report.summary.unknownCount}项待核实</span>`;
|
||||
}
|
||||
qualityTitle.innerHTML = `<span>${report.variety}品质全参数舱</span>${statusBadge}`;
|
||||
}
|
||||
|
||||
// 标题栏右侧显示批次阶段与检验日期 (精炼格式)
|
||||
if (qualityExtra) {
|
||||
const stageShort = (report.inspectStage || '').split('/')[0].trim();
|
||||
qualityExtra.innerHTML = `<span style="font-size: 9px; color: var(--text-sub); white-space: nowrap;">${stageShort} · ${report.inspectDate}</span>`;
|
||||
if (report.empty) qualityExtra.textContent = stageShort;
|
||||
if (!report.empty) qualityExtra.innerHTML = `<span style="font-size: 9px; color: var(--text-sub); white-space: nowrap;">${stageShort} · ${report.inspectDate}</span>`;
|
||||
}
|
||||
|
||||
// 渲染方案A专属品质全参数舱 (全量水平条形卡片 + 分类Tab)
|
||||
@@ -560,6 +548,27 @@ async function initOrUpdateForecastCharts(granaryItem) {
|
||||
STATE.charts.forecastCompare = echarts.init(compareDom);
|
||||
}
|
||||
|
||||
const currentReport = getGranaryQualityReport(grainName, currentItem.rawContent || currentItem.content || {}, currentItem.qualityTest, { requireRecordedData: true });
|
||||
for (const id of ['btn-forecast-recalc', 'btn-forecast-reset']) {
|
||||
const button = document.getElementById(id);
|
||||
if (button) {
|
||||
button.disabled = currentReport.empty;
|
||||
button.title = currentReport.empty ? '请先录入本仓化验数据' : '';
|
||||
}
|
||||
}
|
||||
if (currentReport.empty) {
|
||||
const emptyOption = {
|
||||
title: { text: '暂无化验数据\n录入本仓实测数据后可查看质量预测', left: 'center', top: '40%',
|
||||
textStyle: { color: '#94a3b8', fontSize: 13, lineHeight: 24 } }
|
||||
};
|
||||
STATE.charts.forecastTrend.setOption(emptyOption, true);
|
||||
STATE.charts.forecastCompare.setOption(emptyOption, true);
|
||||
if (trendTitleEl) trendTitleEl.textContent = `${currentItem.name} · 暂无实测数据`;
|
||||
if (compareTitleEl) compareTitleEl.textContent = '待录入化验记录';
|
||||
delete STATE.forecastCache[currentItem.id];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!grainType) {
|
||||
const emptyOption = {
|
||||
title: {
|
||||
@@ -607,7 +616,7 @@ async function initOrUpdateForecastCharts(granaryItem) {
|
||||
|
||||
setupForecastEnvControls();
|
||||
|
||||
const currentReport = getGranaryQualityReport(grainName, currentItem.rawContent || currentItem.content || {}, currentItem.qualityTest);
|
||||
|
||||
|
||||
// 1. 0 毫秒即时秒出基础图表 (杜绝任何白屏与残亮点)
|
||||
const baselineList = [
|
||||
@@ -648,6 +657,9 @@ async function initOrUpdateForecastCharts(granaryItem) {
|
||||
key: m.key,
|
||||
name: m.name,
|
||||
unit: m.unit,
|
||||
warn: m.warn,
|
||||
alert: m.alert,
|
||||
direction: m.direction,
|
||||
predictedValue: m.values[3] !== undefined ? m.values[3] : m.values[m.values.length - 1]
|
||||
}));
|
||||
const initialCompareOption = buildForecastCompareChartOption(grainType, currentReport?.items || [], initialFuture30);
|
||||
@@ -684,6 +696,9 @@ async function initOrUpdateForecastCharts(granaryItem) {
|
||||
key: m.key,
|
||||
name: m.name,
|
||||
unit: m.unit,
|
||||
warn: m.warn,
|
||||
alert: m.alert,
|
||||
direction: m.direction,
|
||||
predictedValue: m.values[3] !== undefined ? m.values[3] : m.values[m.values.length - 1]
|
||||
}));
|
||||
|
||||
@@ -847,7 +862,7 @@ function initOrUpdateGranaryBar() {
|
||||
|
||||
const names = STATE.list.map(i => i.name.replace('号浅圆仓', '#圆').replace('号仓房', '#平'));
|
||||
const storageData = STATE.list.map(i => i.storageNum);
|
||||
const capacityData = STATE.list.map(i => i.capacity);
|
||||
const capacityData = STATE.list.map(i => i.capacityNum);
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
@@ -992,14 +1007,14 @@ function toggleFullscreen() {
|
||||
}
|
||||
|
||||
// 初始化入口
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
resizeScreen();
|
||||
window.addEventListener('resize', resizeScreen);
|
||||
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
loadGranaryData();
|
||||
await loadGranaryData();
|
||||
loadEnvData();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -1020,15 +1035,20 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
renderDepotAlerts();
|
||||
|
||||
// 电视大屏 60 秒自动静默轮询同步最新台账与微气候
|
||||
setInterval(() => {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
loadGranaryData();
|
||||
await loadGranaryData();
|
||||
loadEnvData();
|
||||
renderKPIs();
|
||||
renderHotspots();
|
||||
renderMiniGrid();
|
||||
renderDetailPanels();
|
||||
renderDepotAlerts();
|
||||
} catch (_) {}
|
||||
}, 60000);
|
||||
|
||||
setupAutoPlay();
|
||||
if (urlParams.get('view') === 'forecast') selectGranary(STATE.activeId, true);
|
||||
|
||||
const fsBtn = document.getElementById('btn-fullscreen');
|
||||
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
|
||||
@@ -1044,6 +1064,11 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
if (e.key === 'Escape' && STATE.isCenterChartOpen) {
|
||||
toggleCenterChartBoard(STATE.centerChartTab, false);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>中储粮(习水)智慧粮仓综合监测中心</title>
|
||||
<link rel="stylesheet" href="./css/style.css?v=20261028_chart3">
|
||||
<link rel="stylesheet" href="./css/style.css?v=20261028_status2">
|
||||
<link rel="stylesheet" href="./css/quality-status.css?v=20261028_xishui1">
|
||||
<!-- ECharts 可视化库 (CDN) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
</head>
|
||||
@@ -328,7 +329,7 @@
|
||||
<div class="center-chart-card-title" id="forecast-trend-title">多周期趋势</div>
|
||||
<div class="forecast-env-pill" id="forecast-trend-env-pill">工况: 仓内 18.5℃ / 72.4% · 仓外 32.5℃ / 54.2%</div>
|
||||
</div>
|
||||
<div class="forecast-chart-note"><span>各指标独立刻度 · 保留原始单位</span><span>● 实测起点 ┈ 预测趋势 <span class="threshold-key">┄ 警戒参考</span></span></div>
|
||||
<div class="forecast-chart-note"><span>各指标独立刻度 · 数值为实测 → 末期预测</span><span>● 实测起点 ┈ 预测趋势 <span class="warning-key">┄ 预警</span> <span class="threshold-key">┄ 警戒</span></span></div>
|
||||
<div id="chart-forecast-trend" class="center-chart-canvas"></div>
|
||||
</div>
|
||||
<!-- 右侧:现状实测 vs 预测推演偏离对比柱状图 -->
|
||||
@@ -402,6 +403,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module" src="./js/xishui_app.js?v=20261028_chart3"></script>
|
||||
<script type="module" src="./js/xishui_app.js?v=20261028_demo3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
在新工单中引用
屏蔽一个用户