feat(quality): 支持质检参数大屏显示开启与关闭,并在数据大屏对齐联动过滤隐藏

这个提交包含在:
wikiboss
2026-11-02 14:02:51 +08:00
父节点 ad89b44b4e
当前提交 6f877be41a
共有 2 个文件被更改,包括 356 次插入 和 69 次删除

查看文件

@@ -353,7 +353,7 @@
<el-form-item label="检测参数:" prop="content">
<div class="quality-editor-card">
<div class="quality-editor-toolbar">
<div><strong>录入提示:</strong>先填实测值、预警值和警告值,再选择指标偏好。等于阈值时归入较低一级。</div>
<div><strong>录入提示:</strong>先填实测值、预警值和警告值,再选择指标偏好。大屏显示列可开启或关闭该参数在大屏上的展示。等于阈值时归入较低一级。</div>
<div class="quality-legend" aria-label="颜色规则">
<span class="good">绿 · 达标</span>
<span class="warn">黄 · 预警</span>
@@ -393,11 +393,11 @@
v-model="typeList[formData.type]['content'][scope.$index].alert"></el-input>
</template>
</el-table-column>
<el-table-column prop="is_number" width="100" label="图表" show-overflow-tooltip
<el-table-column prop="is_number" width="110" label="大屏显示" show-overflow-tooltip
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>
<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="270" label="指标偏好与颜色规则" align="left">
@@ -488,6 +488,13 @@
<el-table-column prop="alert" width="230" label="警告值" show-overflow-tooltip
align="center">
</el-table-column>
<el-table-column prop="is_number" width="100" label="大屏显示" show-overflow-tooltip
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 width="250" label="指标偏好与颜色规则" align="left">
<template scope="scope">
<template v-if="isNumericRule(scope.row)">

查看文件

@@ -45,6 +45,14 @@ export const DESIGN_THEME = {
};
// 全部质检状态(卡片、图表、提示)共用同一组语义色。
export const QUALITY_STATUS_COLORS = {
normal: DESIGN_THEME.colors.success,
warn: DESIGN_THEME.colors.warning,
danger: DESIGN_THEME.colors.danger,
unknown: '#94a3b8'
};
/**
* 计算大屏视口等比缩放与居中偏移(防止电视机溢出或出现滚动条)
* @param {number} winW 浏览器/视口宽度
@@ -241,17 +249,25 @@ export function normalizeGranaryList(apiData) {
const rawList = apiData?.data?.list || apiData?.list || (Array.isArray(apiData) ? apiData : []);
return rawList.map(item => {
const content = decodeLooseUnicodeDeep(item.content || {});
const isOilTank = item.granary_type === 1 || item.name.includes('油罐');
const isSilo = item.granary_type === 3 ||
const content = { ...decodeLooseUnicodeDeep(item.content || {}) };
const mappings = {capacity:'capacity', tankCapacity:'capacity', storageNum:'storage_num', nature:'nature', addr:'addr', harvestYear:'harvest_year', enterTime:'enter_time', level:'level', weight:'weight', impurity:'impurity', water:'water', volume:'volume', decVolume:'dec_volume', imperfect:'imperfect', managementForm:'admin_form', length:'length', width:'width', diam:'diam', height:'height', storageMethod:'save_form', custodian:'custodian'};
for (const [key, column] of Object.entries(mappings)) {
if ((content[key] === undefined || content[key] === null || String(content[key]).trim() === '' || content[key] === '-') && item[column] !== undefined && item[column] !== null) {
if (['length','width','diam','height'].includes(key) && !Number(item[column])) continue;
if (['harvestYear','enterTime'].includes(key) && (!item[column] || String(item[column]).startsWith('1970'))) continue;
content[key] = item[column];
}
}
const isOilTank = Number(item.granary_type) === 1 || item.name.includes('油罐');
const isSilo = Number(item.granary_type) === 3 ||
item.name.includes('浅圆') ||
item.name.includes('筒仓') ||
content.warehouseType?.includes('浅圆') ||
content.warehouseType?.includes('筒仓');
const grainTypeCategory = isOilTank ? 'oilTank' : (isSilo ? 'silo' : 'warehouse');
const capacityNum = Number(content.capacity || content.tankCapacity || 0);
const storageNum = Number(content.storageNum || 0);
const capacityNum = Number(content.capacity ?? content.tankCapacity ?? item.capacity ?? 0) || 0;
const storageNum = Number(content.storageNum ?? item.storage_num ?? 0) || 0;
const grainName = item.varieties?.name || content.varieties || (isOilTank ? '菜籽油' : '小麦');
return {
@@ -261,7 +277,7 @@ export function normalizeGranaryList(apiData) {
screenPosition: content.screenPosition || '',
screenCoord: item.screen_coord || null,
storage: item.storage || '遵义直属库有限公司中心库区',
granaryType: item.granary_type,
granaryType: Number(item.granary_type),
isOilTank,
grainTypeCategory,
grainName,
@@ -270,6 +286,9 @@ export function normalizeGranaryList(apiData) {
utilization: capacityNum > 0 ? Number(((storageNum / capacityNum) * 100).toFixed(1)) : 0,
rawContent: content,
qualityTest: decodeLooseUnicodeDeep(item.quality_test || null),
dataSource: apiData?.data?.source || 'reference',
isDemo: Number(item.quality_test?.is_demo) === 1,
site: item.site || apiData?.data?.site || apiData?.site || (String(item.storage || '').includes('习水') ? 'xishui' : 'center'),
createTime: item.create_time
};
});
@@ -355,45 +374,85 @@ export const QUALITY_BENCHMARKS = {
* @param {Object} item 指标对象
* @returns {Object} 状态结果 { status: 'normal'|'warn'|'danger', statusText, badgeText, color }
*/
function qualityNumber(value) {
if (typeof value !== 'string' && typeof value !== 'number') return NaN;
const text = String(value).trim();
if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(text)) return NaN;
const number = Number(text);
return Number.isFinite(number) ? number : NaN;
}
const unknownQualityStatus = (text = '检测值缺失或无效') => ({
status: 'unknown', statusText: text, badgeText: '待核实', color: QUALITY_STATUS_COLORS.unknown
});
export function isQualityParamVisible(param) {
if (!param || typeof param !== 'object') return false;
if (param.is_display !== undefined && param.is_display !== null) {
return Number(param.is_display) === 1 || param.is_display === true;
}
if (param.is_number !== undefined && param.is_number !== null) {
return Number(param.is_number) === 1;
}
return true;
}
export function evaluateQualityStatus(item) {
if (!item) return { status: 'normal', statusText: '健康达标', badgeText: '达标', color: '#10b981' };
if (!item) return { status: 'normal', statusText: '健康达标', badgeText: '达标', color: QUALITY_STATUS_COLORS.normal };
if (item.type === 'qualitative') {
const val = String(item.value || '');
const val = String(item.value ?? '').trim();
if (!val || /^(?:\/|--?|未检测|未提供|暂无)$/.test(val)) return unknownQualityStatus();
// Historical grade thresholds are descriptions, e.g. 一等/大于等于790.
const firstGrade = String(item.warn ?? '').match(/(?:大于等于|≥)\s*(\d+(?:\.\d+)?)/);
const gradeRanges = [...String(item.alert ?? '').matchAll(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)/g)];
const gradeValue = Number(val);
if (firstGrade && gradeRanges.length && val.trim() !== '' && Number.isFinite(gradeValue)) {
const lowestGrade = Math.min(...gradeRanges.map(range => Number(range[1])));
if (gradeValue >= Number(firstGrade[1])) {
return { status: 'normal', statusText: '等级达标', badgeText: '达标', color: QUALITY_STATUS_COLORS.normal };
}
return gradeValue >= lowestGrade
? { status: 'warn', statusText: '等级预警', badgeText: '预警', color: QUALITY_STATUS_COLORS.warn }
: { status: 'danger', statusText: '低于等级标准', badgeText: '超标', color: QUALITY_STATUS_COLORS.danger };
}
const negative = /不合格|未达标|不达标|不宜存|不符合|异常|不具有|未有|有异味/.test(val.replace(/无异味/g, ''));
const isOk = !negative && (val.includes('正常') || val.includes('达标') || val.includes('宜存') ||
val.includes('符合') || val.includes('合格') || val.includes('透明') ||
val.includes('变浅') || val.includes('棕') || val.includes('固有') ||
val.includes('无析出物') || val.includes('无异味') || val.includes('未检出'));
return isOk
? { status: 'normal', statusText: '健康达标', badgeText: '达标', color: '#10b981' }
: { status: 'danger', statusText: '超标异常', badgeText: '异常', color: '#ef4444' };
? { status: 'normal', statusText: '健康达标', badgeText: '达标', color: QUALITY_STATUS_COLORS.normal }
: { status: 'danger', statusText: '超标异常', badgeText: '异常', color: QUALITY_STATUS_COLORS.danger };
}
const num = parseFloat(item.value);
if (isNaN(num)) {
return { status: 'normal', statusText: '健康达标', badgeText: '合格', color: '#10b981' };
}
const nonDetect = /^(?:未检出|未检出\s*ND|ND)$/i.test(String(item.value ?? '').trim());
const num = nonDetect ? 0 : qualityNumber(item.value);
if (!Number.isFinite(num)) return unknownQualityStatus();
const warn = parseFloat(item.warn);
const alert = parseFloat(item.alert);
const warn = qualityNumber(item.warn);
const alert = qualityNumber(item.alert);
if (!Number.isFinite(warn) || !Number.isFinite(alert)
|| (item.direction === 'lower' ? warn < alert : warn > alert)) {
return unknownQualityStatus('预警阈值无效,请核实配置');
}
if (item.direction === 'lower') {
if (!isNaN(alert) && num < alert) {
return { status: 'danger', statusText: '超标警戒', badgeText: '超标', color: '#ef4444' };
return { status: 'danger', statusText: '超标警戒', badgeText: '超标', color: QUALITY_STATUS_COLORS.danger };
}
if (!isNaN(warn) && num < warn) {
return { status: 'warn', statusText: '轻度预警', badgeText: '预警', color: '#f59e0b' };
return { status: 'warn', statusText: '轻度预警', badgeText: '预警', color: QUALITY_STATUS_COLORS.warn };
}
return { status: 'normal', statusText: '健康达标', badgeText: '达标', color: '#10b981' };
return { status: 'normal', statusText: '健康达标', badgeText: '达标', color: QUALITY_STATUS_COLORS.normal };
} else {
if (!isNaN(alert) && num > alert) {
return { status: 'danger', statusText: '超标警戒', badgeText: '超标', color: '#ef4444' };
return { status: 'danger', statusText: '超标警戒', badgeText: '超标', color: QUALITY_STATUS_COLORS.danger };
}
if (!isNaN(warn) && num > warn) {
return { status: 'warn', statusText: '轻度预警', badgeText: '预警', color: '#f59e0b' };
return { status: 'warn', statusText: '轻度预警', badgeText: '预警', color: QUALITY_STATUS_COLORS.warn };
}
return { status: 'normal', statusText: '健康达标', badgeText: (item.warn !== undefined && num <= warn) ? '健康' : '达标', color: '#10b981' };
return { status: 'normal', statusText: '健康达标', badgeText: (item.warn !== undefined && num <= warn) ? '健康' : '达标', color: QUALITY_STATUS_COLORS.normal };
}
}
@@ -402,7 +461,7 @@ export function evaluateQualityStatus(item) {
* @param {string} grainTypeOrName 品种名称或类型
* @param {Object} overrides 仓房特有指标覆盖 (如 { water: '12.8%' })
*/
export function getGranaryQualityReport(grainTypeOrName = '小麦', overrides = {}, qualityTest = null) {
export function getGranaryQualityReport(grainTypeOrName = '小麦', overrides = {}, qualityTest = null, options = {}) {
const str = String(grainTypeOrName || '').toLowerCase();
let key = 'wheat';
if (str.includes('油') || str.includes('oil') || str.includes('rapeseed')) {
@@ -413,13 +472,23 @@ export function getGranaryQualityReport(grainTypeOrName = '小麦', overrides =
const benchmark = QUALITY_BENCHMARKS[key];
if (!benchmark) return null;
const recordedParams = Array.isArray(qualityTest?.testing_params) ? qualityTest.testing_params : [];
if (options.requireRecordedData && recordedParams.length === 0) {
return {
variety: benchmark.variety, varietyKey: key,
inspectDate: '', inspectStage: '未录入化验记录', dataSource: 'missing', empty: true,
items: [],
summary: { totalCount: 0, dangerCount: 0, warnCount: 0, normalCount: 0, unknownCount: 0, overallStatus: 'unknown' }
};
}
let dangerCount = 0;
let warnCount = 0;
let normalCount = 0;
let unknownCount = 0;
// A saved test is the source of truth for values, thresholds and direction.
const savedParams = qualityTest?.testing_params || [];
const savedParams = recordedParams.filter(isQualityParamVisible);
const cleanName = name => String(name).replace(/[((].*?[))]/g, '').replace(/[、,,\s]/g, '');
const aliases = {
'杂质': 'impurity', '杂质总量': 'impurity', '谷外糙米': 'milledGrainOutside',
@@ -434,15 +503,14 @@ export function getGranaryQualityReport(grainTypeOrName = '小麦', overrides =
}
const base = benchmark.items.find(it => it.key === itemKey
|| cleanName(it.name) === cleanName(param.name) || cleanName(it.shortName) === cleanName(param.name));
const numeric = [param.warning, param.alert].every(v => v !== null && v !== undefined
&& String(v).trim() !== '' && Number.isFinite(Number(v)));
const numeric = [param.warning, param.alert].every(v => Number.isFinite(qualityNumber(v)));
return {
...(base || { key: `saved-${param.num || index}`, category: 'physical' }),
name: param.name, shortName: base?.shortName || param.name,
value: param.val, unit: param.unit || '', warn: param.warning, alert: param.alert,
direction: param.direction === 'upper' || param.direction === 'lower' ? param.direction
: (numeric && Number(param.warning) > Number(param.alert) ? 'lower' : 'upper'),
type: numeric ? undefined : 'qualitative', savedRule: numeric
type: numeric || Number(param.is_number) === 1 ? undefined : 'qualitative', savedRule: numeric
};
}) : benchmark.items;
const items = sourceItems.map(baseItem => {
@@ -459,16 +527,20 @@ export function getGranaryQualityReport(grainTypeOrName = '小麦', overrides =
if (item.status === 'danger') dangerCount++;
else if (item.status === 'warn') warnCount++;
else if (item.status === 'unknown') unknownCount++;
else normalCount++;
return item;
});
const overallStatus = dangerCount > 0 ? 'danger' : (warnCount > 0 ? 'warn' : 'normal');
const overallStatus = dangerCount > 0 ? 'danger' : (warnCount > 0 ? 'warn' : (unknownCount > 0 ? 'unknown' : 'normal'));
return {
variety: benchmark.variety,
varietyKey: key,
dataSource: savedParams.length ? (Number(qualityTest?.is_demo) === 1 ? 'demo' : 'recorded') : 'reference',
isDemo: Number(qualityTest?.is_demo) === 1,
empty: false,
inspectDate: qualityTest?.test_date ? String(qualityTest.test_date).replace(/^(\d{4})(\d{2})$/, '$1-$2') : benchmark.inspectDate,
inspectStage: qualityTest ? ({ 1: '春季检测', 2: '秋季检测', 3: '满仓验收' }[qualityTest.season] || '最新检测') : benchmark.inspectStage,
items,
@@ -477,6 +549,7 @@ export function getGranaryQualityReport(grainTypeOrName = '小麦', overrides =
dangerCount,
warnCount,
normalCount,
unknownCount,
overallStatus
}
};
@@ -501,12 +574,14 @@ export function buildQualityHtml(qualityReport, filterCategory = 'all') {
: qualityReport.items.filter(it => it.category === filterCategory);
return items.map(it => {
const valClass = it.status === 'danger' ? 'val-danger' : (it.status === 'warn' ? 'val-warn' : 'val-normal');
const cardClass = it.status === 'danger' ? 'card-danger' : (it.status === 'warn' ? 'card-warn' : '');
const valClass = `val-${it.status}`;
const cardClass = it.status === 'danger' ? 'card-danger' : (it.status === 'warn' ? 'card-warn' : (it.status === 'unknown' ? 'card-unknown' : ''));
// 阈值说明文字 (保证极简防折行)
let threshDesc = '';
if (it.type === 'qualitative') {
if (it.status === 'unknown') {
threshDesc = it.statusText;
} else if (it.type === 'qualitative') {
threshDesc = it.status === 'normal' ? '标准达标' : '未达标';
} else if (it.alert !== undefined) {
const sym = it.direction === 'lower' ? '≥' : '≤';
@@ -537,7 +612,7 @@ export function buildQualityHtml(qualityReport, filterCategory = 'all') {
* @param {Object} qualityReport
*/
export function getCoreHudMetrics(qualityReport) {
if (!qualityReport || !qualityReport.items) return [];
if (!qualityReport || !qualityReport.items?.length || qualityReport.empty) return [];
const key = qualityReport.varietyKey;
const items = qualityReport.items;
@@ -609,8 +684,8 @@ export function getCoreHudMetrics(qualityReport) {
unit: it.unit || '',
status: it.status,
badgeText: it.badgeText,
gaugePercent,
limitDesc
gaugePercent: computeStripMetric(it, key).gaugePercent,
limitDesc: computeStripMetric(it, key).limitDesc
};
});
}
@@ -683,11 +758,21 @@ export function computeStripMetric(it, varietyKey = 'wheat') {
limitDesc = it.value === '未检出' ? `限量达标 · 未检出` : `监测达标`;
}
if (it.type === 'qualitative' && /大于等于|≥/.test(String(it.warn))) {
limitDesc = `等级标准 | ${it.warn}`;
}
if (it.savedRule) {
const lower = it.direction === 'lower';
limitDesc = `${it.status === 'danger' ? '警告' : it.status === 'warn' ? '预警' : '正常'} | ${lower ? '越大越好(低值预警)' : '越小越好(高值预警)'} · 预警${lower ? '<' : '>'}${it.warn} · 警告${lower ? '<' : '>'}${it.alert}${it.unit || ''}`;
}
if (it.status === 'unknown') {
gaugePercent = 0;
limitDesc = it.statusText || '检测值缺失或无效,请核实';
}
gaugePercent = Number.isFinite(gaugePercent) ? Math.max(0, Math.min(100, gaugePercent)) : 0;
return {
...it,
gaugePercent,
@@ -703,6 +788,13 @@ export function computeStripMetric(it, varietyKey = 'wheat') {
*/
export function buildQualityHudHtml(qualityReport, filterCategory = 'all') {
if (!qualityReport || !qualityReport.items) return '';
if (qualityReport.empty) {
return `<div class="quality-empty-state" role="status">
<span class="quality-badge status-unknown">暂无化验数据</span>
<h3>本仓尚未录入化验记录</h3>
<p>请在后台添加本仓化验数据。保存后,检测值、预警方向及颜色状态将自动同步。</p>
</div>`;
}
const varietyKey = qualityReport.varietyKey;
const allItems = qualityReport.items;
@@ -732,9 +824,10 @@ export function buildQualityHudHtml(qualityReport, filterCategory = 'all') {
// 生成全量水平条形卡片 (Horizontal Strip Cards)
const stripCardsHtml = displayItems.map(rawItem => {
const item = computeStripMetric(rawItem, varietyKey);
const cardClass = item.status === 'danger' ? 'strip-danger' : (item.status === 'warn' ? 'strip-warn' : 'strip-normal');
const valClass = item.status === 'danger' ? 'val-danger' : (item.status === 'warn' ? 'val-warn' : 'val-normal');
const gaugeClass = item.status === 'danger' ? 'gauge-danger' : (item.status === 'warn' ? 'gauge-warn' : 'gauge-normal');
if (qualityReport.isDemo) item.limitDesc = item.limitDesc.replace('国家标准合规', '演示规则').replace('检验通过', '示例达标');
const cardClass = `strip-${item.status}`;
const valClass = `val-${item.status}`;
const gaugeClass = `gauge-${item.status}`;
const alertPrefix = item.status === 'danger' ? '⚠️ ' : '';
const displayName = item.shortName || item.name;
const unitStr = item.unit ? `${item.unit}` : '';
@@ -787,12 +880,38 @@ export function buildQualityHudHtml(qualityReport, filterCategory = 'all') {
* 格式化仓房/油罐详细展示参数 (分类提取基础、尺寸、理化指标)
* @param {Object} normalizedItem
*/
function formatRecordedGranaryParams(item) {
const c = item.rawContent || {};
const text = (key, fallback = '—') => c[key] === undefined || c[key] === null || String(c[key]).trim() === '' || c[key] === '-' ? fallback : String(c[key]);
const unit = (key, suffix) => text(key) === '—' ? '—' : `${text(key)}${suffix}`;
const percentage = key => text(key) === '—' ? '—' : (text(key).includes('%') ? text(key) : `${text(key)}%`);
const oil = item.isOilTank;
const silo = item.grainTypeCategory === 'silo';
const natureName = {ZC:'中央储备粮 (ZC)',SP:'商品粮 (SP)',DF:'地方储备粮 (DF)'}[c.nature] || text('nature');
const basic = {
warehouseType: oil ? text('tankType','立式油罐') : (/^\d+$/.test(text('warehouseType')) ? (silo ? '立式浅圆仓' : '高大平房仓') : text('warehouseType',silo ? '立式浅圆仓' : '高大平房仓')),
capacity: `${item.capacityNum} 吨`, storageNum: `${item.storageNum} 吨`, varieties: item.grainName, natureName,
addr:text('addr'),productArea:text('productArea'),harvestYear:text('harvestYear'),machiningYear:text('machiningYear'),
enterTime:text('enterTime'),managementForm:text('managementForm'),custodian:text('custodian'),storageMethod:text('storageMethod')
};
const size = oil ? {diameter:text('TankDiameter'),oilHeight:text('oilLoadingHeight'),oilVolume:unit('oilVolume',' m³')}
: {length:text('length'),width:text('width'),diameter:text('diam'),height:text('height'),volume:unit('volume',' m³'),decVolume:unit('decVolume',' m³')};
return {type:oil ? 'oilTank' : silo ? 'grainSilo' : 'grainWarehouse', basic, size,
qualityReport:getGranaryQualityReport(item.grainName,c,item.qualityTest,{requireRecordedData:true}),
quality:{level:text('level'),water:percentage('water'),impurity:percentage('impurity'),weight:text('weight'),imperfect:percentage('imperfect'),roughnessRate:text('roughnessRate'),riceGrains:text('riceGrains'),
color:text('color'),odorAndTaste:text('odorAndTaste'),acidValue:text('acidValue'),peroxideValue:text('peroxideValue'),waterVolatileCompounds:text('waterVolatileCompounds'),insolubleImpurities:text('insolubleImpurities'),residualSolventContent:text('residualSolventContent'),heating:text('heating')}
};
}
export function formatGranaryParams(normalizedItem) {
if (!normalizedItem) return null;
if (normalizedItem.dataSource === 'database') return formatRecordedGranaryParams(normalizedItem);
const c = normalizedItem.rawContent || {};
const isOil = normalizedItem.isOilTank;
const grainVariety = normalizedItem.grainName || c.varieties || (isOil ? '菜籽油' : '小麦');
const qualityReport = getGranaryQualityReport(grainVariety, c, normalizedItem.qualityTest);
const qualityReport = getGranaryQualityReport(grainVariety, c, normalizedItem.qualityTest, {
requireRecordedData: normalizedItem.dataSource === 'database' || normalizedItem.site === 'xishui' || String(normalizedItem.storage || '').includes('习水')
});
const natureMap = {
'ZC': '中央储备粮 (ZC)',
@@ -1216,7 +1335,8 @@ export function parseForecastResult(apiJson, grainType) {
*/
export function generateForecastSeries(grainType, currentReport, forecastList = [], env = null) {
const type = grainType || currentReport?.grainType || 'wheat';
const timeline = ['现状实测', ...forecastList.map(f => `+${f.day}天预测`)];
if (currentReport?.empty) return { grainType: type, timeline: [], metrics: [], env: env || null };
const timeline = [currentReport?.isDemo ? 'DEMO 演示现状' : '现状实测', ...forecastList.map(f => `+${f.day}天预测`)];
const currentItems = currentReport?.items || [];
const findCurrentVal = (key, fallback) => {
@@ -1238,18 +1358,26 @@ export function generateForecastSeries(grainType, currentReport, forecastList =
];
const metrics = metricConfigs.map(cfg => {
const curVal = findCurrentVal(cfg.key, cfg.fallback);
const saved = currentItems.find(item => item.key === cfg.key);
const unit = saved?.unit || cfg.unit;
const canonicalUnit = value => String(value).replace(/[μµ]/g, 'u').replace(/\s/g, '').toLowerCase();
const concentrations = {'ug/kg': 1, 'ng/g': 1, 'ug/g': 1000, 'mg/kg': 1000, 'mg/g': 1000000};
const from = canonicalUnit(cfg.unit), to = canonicalUnit(unit);
const factor = from === to ? 1 : concentrations[from] && concentrations[to] ? concentrations[from] / concentrations[to] : null;
const curVal = findCurrentVal(cfg.key, factor === null ? null : cfg.fallback * factor);
const rules = saved && Number.isFinite(qualityNumber(saved.warn)) && Number.isFinite(qualityNumber(saved.alert))
? saved : cfg;
const futureVals = forecastList.map(f => {
const tVal = f.targets ? f.targets[cfg.targetKey] : undefined;
return tVal !== undefined && tVal !== null && tVal !== '' && Number.isFinite(Number(tVal)) ? Number(Number(tVal).toFixed(2)) : null;
return factor !== null && tVal !== undefined && tVal !== null && tVal !== '' && Number.isFinite(Number(tVal)) ? Number((Number(tVal) * factor).toFixed(4)) : null;
});
return {
key: cfg.key,
name: cfg.name,
unit: cfg.unit,
alert: cfg.alert,
warn: cfg.warn,
direction: cfg.key === 'glutenWater' ? 'lower' : 'upper',
unit,
alert: rules === cfg ? (factor === null ? undefined : cfg.alert * factor) : rules.alert,
warn: rules === cfg ? (factor === null ? undefined : cfg.warn * factor) : rules.warn,
direction: rules.direction || (cfg.key === 'glutenWater' ? 'lower' : 'upper'),
values: [curVal, ...futureVals]
};
});
@@ -1324,10 +1452,12 @@ export function buildForecastTrendChartOption(forecastSeries) {
const c = forecastMetricColors.get(key);
const current = forecastText(m.values[0]);
const last = forecastText(m.values[m.values.length - 1]);
const threshold = m.alert === undefined ? '' : `\n警戒 ${m.direction === 'lower' ? '≥' : '≤'} ${m.alert} ${m.unit || ''}`;
titles.push({ text: m.name, subtext: `${current} → ${last} ${m.unit || ''}${threshold}`, left: 8, top: `${idx * row + 1}%`,
const currentStatus = evaluateQualityStatus({...m, value: m.values[0]}).status;
const lastStatus = evaluateQualityStatus({...m, value: m.values[m.values.length - 1]}).status;
const threshold = `${m.warn === undefined ? '' : `\n{warn|预警 ${m.warn} ${m.unit || ''}}`}${m.alert === undefined ? '' : `\n{danger|警戒 ${m.alert} ${m.unit || ''}}`}`;
titles.push({ text: m.name, subtext: `{${currentStatus}|${current} ${m.unit || ''}} → {${lastStatus}|${last === '暂无数据' ? '暂无预测' : last + ' ' + (m.unit || '')}}${threshold}`, left: 8, top: `${idx * row + 1}%`,
textStyle: {color: '#e2eaf4', fontSize: 12, fontWeight: 600},
subtextStyle: {color: c, fontSize: 12, lineHeight: 16}, itemGap: 5 });
subtextStyle: {color: c, fontSize: 12, lineHeight: 16, rich: Object.fromEntries(Object.entries(QUALITY_STATUS_COLORS).map(([status, color]) => [status, {color, fontSize: 10}]))}, itemGap: 5 });
grids.push({ left: 154, right: 36, top: `${idx * row + 3}%`, height: `${row - 9}%`, containLabel: true });
xAxes.push({ type: 'category', gridIndex: idx, data: timeline, boundaryGap: false,
axisTick: {show: false}, axisLine: {show: false},
@@ -1343,17 +1473,25 @@ export function buildForecastTrendChartOption(forecastSeries) {
emphasis: {focus: 'series', scale: 1.5},
areaStyle: {color: {type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{offset: 0, color: c + '16'}, {offset: 1, color: c + '00'}]}},
data: m.values };
if (m.alert !== undefined) result.markLine = {symbol: ['none', 'none'], silent: true, data: [{
yAxis: m.alert, name: `${m.name}警戒线`,
lineStyle: {color: '#c89e68', type: 'dashed', width: 1, opacity: 0.65},
label: {formatter: `警戒 {c}${m.unit || ''}`, position: 'insideEndTop', color: '#dfb883', fontSize: 9,
data: m.values.map(value => {
if (value === null || value === undefined) return null;
const status = evaluateQualityStatus({...m, value});
return {value, itemStyle: {color: status.status === 'normal' ? c : status.color}};
}) };
const references = [
{value: m.warn, name: '预警', color: QUALITY_STATUS_COLORS.warn},
{value: m.alert, name: '警戒', color: QUALITY_STATUS_COLORS.danger}
].filter(ref => Number.isFinite(qualityNumber(ref.value)));
result.markLine = {symbol: ['none', 'none'], silent: true, data: references.map(ref => ({
yAxis: Number(ref.value), name: `${m.name}${ref.name}线`,
lineStyle: {color: ref.color, type: 'dashed', width: 1, opacity: 0.8},
label: {formatter: `${ref.name} {c}${m.unit || ''}`, position: ref.name === '预警' ? 'insideStartTop' : 'insideEndTop', color: ref.color, fontSize: 9,
backgroundColor: '#122031', padding: [2, 4], borderRadius: 3}
}]};
}))};
return result;
});
return { animationDuration: 350, title: titles, grid: grids, xAxis: xAxes, yAxis: yAxes,
aria: {enabled: true, description: '各指标使用独立刻度。圆点为实测与预测值,点状虚线为预测趋势,金色虚线为警戒参考。'},
aria: {enabled: true, description: '各指标使用独立刻度。圆点为实测与预测值,点状虚线为预测趋势,黄色虚线为预警参考,红色虚线为警戒参考。'},
axisPointer: {link: [{xAxisIndex: 'all'}]},
tooltip: {...forecastTooltip, formatter: params => {
if (!Array.isArray(params) || !params.length) return '';
@@ -1361,7 +1499,7 @@ export function buildForecastTrendChartOption(forecastSeries) {
let html = `<div style="font-weight:600;margin-bottom:8px">${forecastEscape(heading)}</div>`;
params.forEach(p => {
const m = metrics[p.seriesIndex] || metrics.find(m => m.name === p.seriesName);
html += `<div style="display:flex;justify-content:space-between;gap:24px"><span>${p.marker || ''} ${forecastEscape(p.seriesName)}</span><b>${forecastText(p.value)} <small>${forecastEscape(m?.unit)}</small></b></div>`;
html += `<div style="display:flex;justify-content:space-between;gap:24px"><span>${p.marker || ''} ${forecastEscape(p.seriesName)}</span><b style="color:${evaluateQualityStatus({...m, value: p.value}).color}">${forecastText(p.value)} <small>${forecastEscape(m?.unit)}</small></b></div>`;
});
html += `<div style="margin-top:10px;padding-top:8px;border-top:1px solid #304157;color:#8ea3ba;font-size:11px">仓内 ${forecastEscape(envInfo.tm_i)}℃ / ${forecastEscape(envInfo.hm_i)}% · 仓外 ${forecastEscape(envInfo.tm_o)}℃ / ${forecastEscape(envInfo.hm_o)}%</div>`;
return html;
@@ -1377,9 +1515,15 @@ export function buildForecastCompareChartOption(grainType, currentItems = [], fu
const unit = f.unit || cur?.unit || '';
const scale = Math.max(current ?? 0, future ?? 0, 0.01);
const delta = current !== null && future !== null ? Number((future - current).toFixed(2)) : null;
return {...f, current, future, unit, scale, delta};
return {...f, current, future, unit, scale, delta, rule: {...cur, ...f}};
});
const datum = (r, value) => ({value: value === null ? 0 : value / r.scale * 100, rawValue: value, unit: r.unit});
const datum = (r, value) => {
const status = evaluateQualityStatus({...r.rule, value});
const color = status.status === 'normal' ? QUALITY_STATUS_COLORS.normal : status.color;
return {value: value === null ? 0 : value / r.scale * 100, rawValue: value, unit: r.unit,
// 未配置规则时维持实测/预测系列色;缺失值始终显示灰色。
...(value === null || r.rule.alert !== undefined ? {label: {color}} : {})};
};
return { animationDuration: 350,
aria: {enabled: true, description: '每个指标独立对比当前实测与未来预测,柱长仅可在同一指标内比较。'},
tooltip: {...forecastTooltip, axisPointer: {type: 'shadow', shadowStyle: {color: '#94adc808'}}, formatter: params => {
@@ -1387,7 +1531,7 @@ export function buildForecastCompareChartOption(grainType, currentItems = [], fu
const r = rows[params[0].dataIndex];
if (!r) return '';
const change = r.delta === null ? '暂无预测' : `${r.delta > 0 ? '+' : ''}${r.delta} ${r.unit}`;
return `<b>${forecastEscape(r.name)}</b><div>现状实测:${forecastText(r.current)} ${forecastEscape(r.unit)}</div><div>未来预测:${forecastText(r.future)} ${forecastEscape(r.unit)}</div><div style="border-top:1px solid #304157;margin-top:8px;padding-top:8px;color:#b9c9da">变化量:${forecastEscape(change)}</div>`;
return `<b>${forecastEscape(r.name)}</b><div>现状实测:<span style="color:${evaluateQualityStatus({...r.rule, value: r.current}).color}">${forecastText(r.current)} ${forecastEscape(r.unit)}</span></div><div>未来预测:<span style="color:${evaluateQualityStatus({...r.rule, value: r.future}).color}">${forecastText(r.future)} ${forecastEscape(r.unit)}</span></div><div style="border-top:1px solid #304157;margin-top:8px;padding-top:8px;color:#b9c9da">变化量:${forecastEscape(change)}</div>`;
}},
legend: {top: 10, left: 12, itemWidth: 12, itemHeight: 7, itemGap: 22,
textStyle: {color: '#a6bbcf', fontSize: 11}, data: ['现状实测', '未来预测']},
@@ -1395,17 +1539,17 @@ export function buildForecastCompareChartOption(grainType, currentItems = [], fu
yAxis: {type: 'category', inverse: true, data: rows.map(r => r.name),
axisTick: {show: false}, axisLine: {show: false},
axisLabel: {color: '#d2dfed', fontSize: 11, lineHeight: 17, width: 112, overflow: 'break',
formatter: (v, i) => `${v.match(/.{1,8}/g)?.join('\n') || v}\n{unit|${rows[i].unit}}`, rich: {unit: {color: '#7089a2', fontSize: 10}}}},
formatter: v => v.match(/.{1,8}/g)?.join('\n') || v}},
xAxis: {type: 'value', max: 100, show: false},
series: [{name: '现状实测', type: 'bar', barWidth: 9, barGap: '65%',
itemStyle: {color: '#547797', borderRadius: 3},
label: {show: true, position: 'right', distance: 8, color: '#afc3d8', fontSize: 11,
formatter: p => `${forecastText(p.data.rawValue)} ${p.data.unit}`},
formatter: p => p.data.rawValue === null ? '暂无数据' : `${forecastText(p.data.rawValue)} ${p.data.unit}`},
data: rows.map(r => datum(r, r.current))},
{name: '未来预测', type: 'bar', barWidth: 9,
itemStyle: {color: '#59d9ce', borderRadius: 3},
label: {show: true, position: 'right', distance: 8, color: '#83e9df', fontSize: 11, fontWeight: 600,
formatter: p => `${forecastText(p.data.rawValue)} ${p.data.unit}`},
formatter: p => p.data.rawValue === null ? '暂无数据' : `${forecastText(p.data.rawValue)} ${p.data.unit}`},
data: rows.map(r => datum(r, r.future))}]
};
}
@@ -2031,6 +2175,7 @@ export function getNextAerialMode(currentMode) {
*/
export function generateDepotAlerts(normalizedList = [], envData = null) {
const alerts = [];
let pendingQualityCount = 0;
// 1. 检查微气候环境工况
if (envData) {
@@ -2051,8 +2196,15 @@ export function generateDepotAlerts(normalizedList = [], envData = null) {
// 2. 遍历各仓房质检报告
(normalizedList || []).forEach(item => {
const vName = item.grainName || (item.rawContent && item.rawContent.varieties) || (item.isOilTank ? '菜籽油' : '小麦');
const report = getGranaryQualityReport(vName, item.rawContent || {}, item.qualityTest || null);
const report = getGranaryQualityReport(vName, item.rawContent || {}, item.qualityTest || null, {
requireRecordedData: item.dataSource === 'database' || item.site === 'xishui' || String(item.storage || '').includes('习水')
});
if (!report || !report.items) return;
if (report.empty) {
pendingQualityCount++;
return;
}
if (report.summary.unknownCount > 0) pendingQualityCount++;
report.items.forEach(it => {
if (it.status === 'danger') {
@@ -2065,7 +2217,8 @@ export function generateDepotAlerts(normalizedList = [], envData = null) {
itemKey: it.key,
itemName: it.name,
value: it.value,
text: `⚠️ [超标警戒] ${item.name} (${vName}): ${it.shortName || it.name} 实测 ${it.value}${it.unit || ''} (警戒限值 ${it.direction === 'lower' ? '≥' : '≤'}${it.alert}${it.unit || ''})`
isDemo: report.isDemo,
text: `⚠️ [${report.isDemo ? 'DEMO 演示·' : ''}超标警戒] ${item.name} (${vName}): ${it.shortName || it.name} ${report.isDemo ? '演示值' : '实测'} ${it.value}${it.unit || ''} (警戒限值 ${it.direction === 'lower' ? '≥' : '≤'}${it.alert}${it.unit || ''})`
});
} else if (it.status === 'warn') {
alerts.push({
@@ -2077,12 +2230,20 @@ export function generateDepotAlerts(normalizedList = [], envData = null) {
itemKey: it.key,
itemName: it.name,
value: it.value,
text: `⚠️ [指标预警] ${item.name} (${vName}): ${it.shortName || it.name} 检测 ${it.value}${it.unit || ''} 接近阈值 (标准基线 ${it.direction === 'lower' ? '≥' : '≤'}${it.warn}${it.unit || ''})`
isDemo: report.isDemo,
text: `⚠️ [${report.isDemo ? 'DEMO 演示·' : ''}指标预警] ${item.name} (${vName}): ${it.shortName || it.name} ${report.isDemo ? '演示值' : '检测'} ${it.value}${it.unit || ''} 接近阈值 (标准基线 ${it.direction === 'lower' ? '≥' : '≤'}${it.warn}${it.unit || ''})`
});
}
});
});
if (pendingQualityCount > 0) {
alerts.push({
id: 'quality-pending', type: 'quality-pending', level: 'unknown',
text: `ℹ️ [化验数据待完善] ${pendingQualityCount}座仓房的化验记录缺失或检测值待核实,请在后台补充。`
});
}
// 3. 若全库各项指标无异常
if (alerts.length === 0) {
alerts.push({
@@ -2169,3 +2330,122 @@ export function parseQualityCsv(csvText) {
return result;
}
/**
* 中储粮《粮油储藏技术规范》GB/T 29890 标准工况模板
*/
export const GRAIN_TEMPERATURE_PRESETS = {
quasi_low: {
key: 'quasi_low',
name: '准低温储粮推荐工况',
tm_i: 18.5,
hm_i: 70.0,
tm_o: 32.0,
hm_o: 55.0,
desc: '推荐工况:仓温≤20℃,控温防虫防霉,延缓陈化'
},
low: {
key: 'low',
name: '低温储粮标准工况',
tm_i: 14.0,
hm_i: 65.0,
tm_o: 28.0,
hm_o: 60.0,
desc: '优质工况:仓温≤15℃,绿色抑虫控酶,保持新鲜品质'
},
normal: {
key: 'normal',
name: '常温储粮常规工况',
tm_i: 22.0,
hm_i: 72.0,
tm_o: 32.0,
hm_o: 55.0,
desc: '常规工况:仓温≤25℃,处于安全储粮控温线内'
}
};
/**
* 依据中储粮储粮控温技术规范研判仓内温湿度状态
* @param {number|string} tm_i 仓内温度 (℃)
* @param {number|string} hm_i 仓内相对湿度 (%)
* @returns {Object} 规范研判结果
*/
export function evaluateTemperatureStandard(tm_i, hm_i) {
const temp = (tm_i !== '' && tm_i !== null && tm_i !== undefined && Number.isFinite(Number(tm_i))) ? Number(tm_i) : null;
const hum = (hm_i !== '' && hm_i !== null && hm_i !== undefined && Number.isFinite(Number(hm_i))) ? Number(hm_i) : null;
if (temp === null) {
return {
tempGrade: 'unknown',
tempLabel: '待设置',
tagType: 'info',
isHighTemp: false,
hint: '请设置仓内温度',
humidityGrade: 'unknown',
isHighHumidity: false,
humidityHint: ''
};
}
// 1. 仓温规范研判: 低温(<=15) -> 准低温(<=20) -> 常温(<=25) -> 高温预警(>25)
let tempGrade = 'normal';
let tempLabel = '常温储粮';
let tagType = 'info';
let isHighTemp = false;
let hint = '符合常规储粮规范(安全受控)';
if (temp <= 15.0) {
tempGrade = 'low';
tempLabel = '低温储粮';
tagType = 'success';
hint = '达到低温储粮标准 (≤15℃,防虫抑霉最优,保鲜保质)';
} else if (temp <= 20.0) {
tempGrade = 'quasi_low';
tempLabel = '准低温储粮';
tagType = 'primary';
hint = '达到中储粮准低温推荐标准 (≤20℃,绿色控温优选温区)';
} else if (temp <= 25.0) {
tempGrade = 'normal';
tempLabel = '常温储粮';
tagType = 'info';
hint = '处于常温储粮安全控温范围 (≤25℃)';
} else {
tempGrade = 'high_warn';
tempLabel = '高温预警';
tagType = 'danger';
isHighTemp = true;
hint = '⚠️ 超过25℃中储粮控温安全线(当前' + temp + '℃),高温易引发粮食发热劣变与虫害,请及时开启空调或通风降温!';
}
// 2. 湿度规范研判: 理想(<=70) -> 适中(70~75) -> 高湿预警(>75)
let humidityGrade = 'safe';
let isHighHumidity = false;
let humidityHint = '相对湿度适宜';
if (hum !== null) {
if (hum > 75.0) {
humidityGrade = 'high_warn';
isHighHumidity = true;
humidityHint = '⚠️ 仓内相对湿度达 ' + hum + '%,超过75%高湿警戒线,易引发粮食吸湿结露霉变,请注意排湿!';
} else if (hum > 70.0) {
humidityGrade = 'moderate';
humidityHint = '相对湿度在 70%~75% 适中安全区间';
} else {
humidityGrade = 'safe';
humidityHint = '相对湿度 ≤70% 干燥理想';
}
}
return {
tempGrade,
tempLabel,
tagType,
isHighTemp,
hint,
humidityGrade,
isHighHumidity,
humidityHint
};
}