<script lang="ts" setup>
|
import type { EChartsOption } from 'echarts';
|
|
import type { EchartsUIType } from '@vben/plugins/echarts';
|
|
import type { HuanjingKanbanRow, HuanjingQushiData, HuanjingQushiRecord, HuanjingQushiStandard } from '#/api/x/lims/shiyanshi';
|
|
import { computed, defineComponent, h, onMounted, reactive, ref, watch } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
|
import { Spin as ASpin } from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
|
import { getHuanjingKanbanRows, getHuanjingQushiData } from '#/api/x/lims/shiyanshi';
|
import { useBaseStore } from '#/store';
|
|
defineOptions({ name: 'XlimsShiyanshiHuanjingQushi' });
|
|
interface DicOption {
|
enCode?: string;
|
fullName: string;
|
id: string;
|
}
|
|
interface SelectOption {
|
aliases?: string[];
|
label: string;
|
value: string;
|
}
|
|
interface TrendChart {
|
categories: string[];
|
field: 'ceshi_jieguo' | 'ceshi_jieguo_lijing';
|
item: string;
|
key: string;
|
limit?: number;
|
option?: EChartsOption;
|
title: string;
|
unit: string;
|
}
|
|
const LineChartPanel = defineComponent({
|
name: 'LineChartPanel',
|
props: {
|
option: {
|
default: () => ({}),
|
type: Object,
|
},
|
},
|
setup(props) {
|
const chartRef = ref<EchartsUIType>();
|
const { renderEcharts } = useEcharts(chartRef);
|
|
watch(
|
() => props.option,
|
(option) => {
|
renderEcharts(option as EChartsOption);
|
},
|
{ deep: true, immediate: true },
|
);
|
|
return () => h(EchartsUI, { ref: chartRef, height: '520px', width: '100%' });
|
},
|
});
|
|
const baseStore = useBaseStore();
|
const { createMessage } = useMessage();
|
|
const loading = ref(false);
|
const queried = ref(false);
|
const dateRange = ref<string[]>([]);
|
const allRows = ref<HuanjingKanbanRow[]>([]);
|
const resultData = ref<HuanjingQushiData>({ records: [], standards: [] });
|
const chartList = ref<TrendChart[]>([]);
|
const searchForm = reactive({
|
jiance_pinci: undefined as string | undefined,
|
jiance_quyu: undefined as string | undefined,
|
jiance_xiangmu: [] as string[],
|
jiance_zhuangtai: undefined as string | undefined,
|
jiejing_jibie: undefined as string | undefined,
|
weizhi_bianhao: undefined as string | undefined,
|
});
|
const dictMap = reactive<Record<string, Record<string, string>>>({
|
huanjingJianceJiejingJibie: {},
|
huanjingJiancePinci: {},
|
huanjingJianceXiangmu: {},
|
huanjingJianceZhuangtai: {},
|
});
|
const levelOptionList = ref<SelectOption[]>([]);
|
const frequencyOptionList = ref<SelectOption[]>([]);
|
const itemOptionList = ref<SelectOption[]>([]);
|
const statusOptionList = ref<SelectOption[]>([]);
|
|
const areaOptions = computed<SelectOption[]>(() => {
|
const map = new Map<string, string>();
|
allRows.value.forEach((row) => {
|
if (row.jiance_quyu) map.set(row.jiance_quyu, row.jiance_quyu_mingcheng || row.jiance_quyu);
|
});
|
return [...map.entries()].map(([value, label]) => ({ label, value }));
|
});
|
const roomOptions = computed<SelectOption[]>(() => {
|
const map = new Map<string, string>();
|
allRows.value
|
.filter((row) => !searchForm.jiance_quyu || row.jiance_quyu === searchForm.jiance_quyu)
|
.filter((row) => !searchForm.jiejing_jibie || row.jiejing_jibie === searchForm.jiejing_jibie)
|
.forEach((row) => {
|
if (row.weizhi_bianhao) map.set(row.weizhi_bianhao, row.weizhi_mingcheng || row.weizhi_bianhao);
|
});
|
return [...map.entries()].map(([value, label]) => ({ label, value }));
|
});
|
|
function getDictLabel(type: string, value?: string) {
|
if (!value) return '';
|
return dictMap[type]?.[value] || value;
|
}
|
|
function getFrequencyLabel(value?: string) {
|
if (!value) return '';
|
return frequencyOptionList.value.find((item) => item.value === value)?.label || getDictLabel('huanjingJiancePinci', value);
|
}
|
|
function getUniqueList(list: Array<string | undefined>) {
|
return [...new Set(list.filter((item): item is string => !!item))];
|
}
|
|
function getItemOption(value: string) {
|
return itemOptionList.value.find((item) => item.value === value || item.aliases?.includes(value));
|
}
|
|
function getItemAliases(value: string) {
|
const option = getItemOption(value);
|
return option?.aliases?.length ? option.aliases : [value];
|
}
|
|
function getSelectedItemAliases() {
|
return getUniqueList(searchForm.jiance_xiangmu.flatMap((item) => getItemAliases(item)));
|
}
|
|
function getDateRange() {
|
const start = dateRange.value?.[0] ? dayjs(dateRange.value[0]).format('YYYY-MM-DD') : '';
|
const end = dateRange.value?.[1] ? dayjs(dateRange.value[1]).format('YYYY-MM-DD') : '';
|
return { end, start };
|
}
|
|
function validateSearch() {
|
const { end, start } = getDateRange();
|
if (!start || !end) return '请选择日期范围';
|
if (!searchForm.jiance_quyu) return '请选择区域';
|
if (!searchForm.jiejing_jibie) return '请选择洁净级别';
|
if (!searchForm.weizhi_bianhao) return '请选择房间/其他编号';
|
if (!searchForm.jiance_pinci) return '请选择监测频次';
|
if (!searchForm.jiance_xiangmu.length) return '请选择监测项目';
|
if (!searchForm.jiance_zhuangtai) return '请选择监测状态';
|
return '';
|
}
|
|
function parseNumber(value?: string) {
|
if (!value) return undefined;
|
const match = String(value)
|
.replaceAll(',', '')
|
.match(/-?\d+(?:\.\d+)?/);
|
if (!match) return undefined;
|
const number = Number(match[0]);
|
return Number.isFinite(number) ? number : undefined;
|
}
|
|
function isParticleItem(item: string) {
|
const label = getItemOption(item)?.label || getDictLabel('huanjingJianceXiangmu', item);
|
return label.includes('浮游粒子') || label.includes('悬浮粒子');
|
}
|
|
function standardText(standard: HuanjingQushiStandard) {
|
return `${standard.lijing || ''} ${standard.biaozhun_guiding || ''}`;
|
}
|
|
function getStandard(item: string, particleSize?: '0.5' | '5') {
|
const itemAliases = getItemAliases(item);
|
const standards = resultData.value.standards.filter((standard) => itemAliases.includes(standard.jiance_xiangmu));
|
if (!particleSize) return standards[0];
|
const matched = standards.find((standard) => {
|
const text = standardText(standard);
|
if (particleSize === '0.5') return text.includes('0.5');
|
return text.includes('5') && !text.includes('0.5');
|
});
|
return matched || standards[0];
|
}
|
|
function getChartTitle(item: string, extraTitle = '') {
|
const { end, start } = getDateRange();
|
const area = areaOptions.value.find((option) => option.value === searchForm.jiance_quyu)?.label || '';
|
const level = getDictLabel('huanjingJianceJiejingJibie', searchForm.jiejing_jibie);
|
const status = getDictLabel('huanjingJianceZhuangtai', searchForm.jiance_zhuangtai);
|
const itemLabel = getDictLabel('huanjingJianceXiangmu', item);
|
const displayItemLabel = getItemOption(item)?.label || itemLabel;
|
return `${start}~${end}${area}${level}${status}${displayItemLabel}${extraTitle}趋势分析图 ${getFrequencyLabel(searchForm.jiance_pinci)}`;
|
}
|
|
function getAxisDateFormat() {
|
switch (searchForm.jiance_pinci) {
|
case 'annual': {
|
return 'YYYY';
|
}
|
case 'bimonthly':
|
case 'monthly':
|
case 'quarterly':
|
case 'semiannual': {
|
return 'YYYY-MM';
|
}
|
default: {
|
return 'YYYY-MM-DD';
|
}
|
}
|
}
|
|
function getRecordDate(record: HuanjingQushiRecord) {
|
return dayjs(record.jiance_riqi).format(getAxisDateFormat());
|
}
|
|
function buildChartCategories(item: string) {
|
const itemAliases = getItemAliases(item);
|
const dateSet = new Set<string>();
|
resultData.value.records.forEach((record) => {
|
if (itemAliases.includes(record.jiance_xiangmu) && record.jiance_riqi) dateSet.add(getRecordDate(record));
|
});
|
return [...dateSet].sort((a, b) => dayjs(a).valueOf() - dayjs(b).valueOf());
|
}
|
|
function buildCharts() {
|
const charts: TrendChart[] = [];
|
searchForm.jiance_xiangmu.forEach((item) => {
|
const categories = buildChartCategories(item);
|
if (isParticleItem(item)) {
|
const smallStandard = getStandard(item, '0.5');
|
charts.push({
|
categories,
|
field: 'ceshi_jieguo',
|
item,
|
key: `${item}-particle-05`,
|
limit: Number(smallStandard?.biaozhun_shangxian),
|
title: getChartTitle(item, '粒径≥0.5μm'),
|
unit: smallStandard?.moren_danwei || '',
|
});
|
const largeStandard = getStandard(item, '5');
|
charts.push({
|
categories,
|
field: 'ceshi_jieguo_lijing',
|
item,
|
key: `${item}-particle-5`,
|
limit: Number(largeStandard?.biaozhun_shangxian),
|
title: getChartTitle(item, '粒径≥5μm'),
|
unit: largeStandard?.moren_danwei || '',
|
});
|
} else {
|
const standard = getStandard(item);
|
charts.push({
|
categories,
|
field: 'ceshi_jieguo',
|
item,
|
key: `${item}-result`,
|
limit: Number(standard?.biaozhun_shangxian),
|
title: getChartTitle(item),
|
unit: standard?.moren_danwei || '',
|
});
|
}
|
});
|
return charts;
|
}
|
|
function getDirectValue(records: HuanjingQushiRecord[], date: string, field: TrendChart['field']) {
|
for (const record of records) {
|
if (getRecordDate(record) !== date) continue;
|
const value = parseNumber(record[field]);
|
if (value !== undefined) return value;
|
}
|
return null;
|
}
|
|
function buildSeries(chart: TrendChart) {
|
const itemAliases = getItemAliases(chart.item);
|
const records = resultData.value.records.filter((record) => itemAliases.includes(record.jiance_xiangmu));
|
const pointMap = new Map<string, HuanjingQushiRecord[]>();
|
records.forEach((record) => {
|
const name = record.quyangdian_mingcheng || record.weizhi_bianhao || '取样点';
|
const list = pointMap.get(name) || [];
|
list.push(record);
|
pointMap.set(name, list);
|
});
|
|
const series: any[] = [];
|
if (Number.isFinite(chart.limit)) {
|
series.push({
|
data: chart.categories.map(() => chart.limit),
|
lineStyle: { color: '#2f7ed8', width: 2 },
|
name: '最高限',
|
symbol: 'circle',
|
type: 'line',
|
});
|
}
|
pointMap.forEach((list, name) => {
|
series.push({
|
data: chart.categories.map((date) => getDirectValue(list, date, chart.field)),
|
name,
|
symbol: 'circle',
|
type: 'line',
|
});
|
});
|
return series;
|
}
|
|
function getChartOption(chart: TrendChart) {
|
const series = buildSeries(chart);
|
return {
|
color: ['#2f7ed8', '#e84c4f', '#8bc34a', '#8e63ad', '#18aebf', '#f7a35c', '#5c6bc0'],
|
grid: { bottom: 86, containLabel: true, left: 70, right: 40, top: 86 },
|
legend: { bottom: 20, data: series.map((item) => item.name), type: 'scroll' },
|
title: { left: 'center', text: chart.title, textStyle: { fontSize: 20, fontWeight: 700 } },
|
tooltip: { trigger: 'axis' },
|
toolbox: { feature: { saveAsImage: {} }, right: 16, top: 14 },
|
xAxis: { axisLabel: { interval: 0, rotate: chart.categories.length > 8 ? 35 : 0 }, data: chart.categories, name: '时间', type: 'category' },
|
yAxis: { min: 0, name: chart.unit || '检测值', nameGap: 18, type: 'value' },
|
series,
|
};
|
}
|
|
async function loadDictionaries() {
|
const codes = Object.keys(dictMap);
|
await Promise.all(
|
codes.map(async (code) => {
|
const list = ((await baseStore.getDictionaryData(code)) || []) as DicOption[];
|
dictMap[code] = list.reduce<Record<string, string>>((prev, item) => {
|
if (item.id) prev[item.id] = item.fullName;
|
if (item.enCode) prev[item.enCode] = item.fullName;
|
return prev;
|
}, {});
|
const options = list.map((item) => ({
|
aliases: getUniqueList([item.id, item.enCode, item.fullName]),
|
label: item.fullName,
|
value: item.enCode || item.id,
|
}));
|
if (code === 'huanjingJiancePinci') frequencyOptionList.value = [{ label: 'N/A', value: 'na' }, ...options];
|
if (code === 'huanjingJianceJiejingJibie') levelOptionList.value = options;
|
if (code === 'huanjingJianceXiangmu') itemOptionList.value = options;
|
if (code === 'huanjingJianceZhuangtai') statusOptionList.value = options;
|
}),
|
);
|
}
|
|
async function loadRows() {
|
const res = await getHuanjingKanbanRows({});
|
allRows.value = res?.data || [];
|
}
|
|
async function handleSearch() {
|
const message = validateSearch();
|
if (message) {
|
createMessage.error(message);
|
return;
|
}
|
const { end, start } = getDateRange();
|
loading.value = true;
|
queried.value = true;
|
try {
|
const res = await getHuanjingQushiData({
|
end_date: end,
|
jiance_pinci: searchForm.jiance_pinci!,
|
jiance_quyu: searchForm.jiance_quyu!,
|
jiance_xiangmu: getSelectedItemAliases(),
|
jiance_zhuangtai: searchForm.jiance_zhuangtai!,
|
jiejing_jibie: searchForm.jiejing_jibie!,
|
start_date: start,
|
weizhi_bianhao: searchForm.weizhi_bianhao!,
|
});
|
resultData.value = res?.data || { records: [], standards: [] };
|
chartList.value = buildCharts().map((chart) => ({ ...chart, option: getChartOption(chart) }));
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function handleReset() {
|
dateRange.value = [];
|
searchForm.jiance_quyu = undefined;
|
searchForm.jiejing_jibie = undefined;
|
searchForm.weizhi_bianhao = undefined;
|
searchForm.jiance_pinci = undefined;
|
searchForm.jiance_xiangmu = [];
|
searchForm.jiance_zhuangtai = undefined;
|
queried.value = false;
|
resultData.value = { records: [], standards: [] };
|
chartList.value = [];
|
}
|
|
onMounted(async () => {
|
loading.value = true;
|
try {
|
await Promise.all([loadDictionaries(), loadRows()]);
|
} finally {
|
loading.value = false;
|
}
|
});
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper huanjing-qushi">
|
<div class="jnpf-content-wrapper-center">
|
<div class="qushi-title">环境监测趋势分析</div>
|
|
<div class="jnpf-content-wrapper-search-box qushi-search">
|
<div class="search-row">
|
<span class="search-label">日期范围:</span>
|
<jnpf-date-range v-model:value="dateRange" allow-clear class="date-range" format="YYYY-MM-DD" />
|
<span class="search-label">区域:</span>
|
<a-select
|
v-model:value="searchForm.jiance_quyu"
|
:options="areaOptions"
|
allow-clear
|
class="search-select"
|
placeholder="请选择数据"
|
show-search
|
@change="searchForm.weizhi_bianhao = undefined" />
|
<span class="search-label">级别:</span>
|
<a-select
|
v-model:value="searchForm.jiejing_jibie"
|
:options="levelOptionList"
|
allow-clear
|
class="search-select"
|
placeholder="请选择数据"
|
show-search
|
@change="searchForm.weizhi_bianhao = undefined" />
|
<span class="search-label">房间/其他编号:</span>
|
<a-select
|
v-model:value="searchForm.weizhi_bianhao"
|
:options="roomOptions"
|
allow-clear
|
class="search-select"
|
option-filter-prop="label"
|
placeholder="请选择数据"
|
show-search />
|
<span class="search-label">监测频次:</span>
|
<a-select v-model:value="searchForm.jiance_pinci" :options="frequencyOptionList" allow-clear class="search-select" placeholder="请选择数据" />
|
<span class="search-label">监测状态:</span>
|
<a-select v-model:value="searchForm.jiance_zhuangtai" :options="statusOptionList" allow-clear class="status-select" placeholder="请选择数据" />
|
</div>
|
<div class="search-row">
|
<span class="search-label">监测项目:</span>
|
<a-checkbox-group v-model:value="searchForm.jiance_xiangmu" :options="itemOptionList" class="item-checks" />
|
<a-button :loading="loading" type="primary" @click="handleSearch">搜索</a-button>
|
<a-button @click="handleReset">重置</a-button>
|
</div>
|
</div>
|
|
<div class="jnpf-content-wrapper-content qushi-content">
|
<ASpin :spinning="loading">
|
<div v-if="chartList.length" class="chart-list">
|
<div v-for="chart in chartList" :key="chart.key" class="chart-block">
|
<LineChartPanel class="chart-canvas" :option="chart.option" />
|
</div>
|
</div>
|
<div v-else class="empty-panel">
|
{{ queried ? '暂无趋势数据' : '请选择筛选条件后搜索' }}
|
</div>
|
</ASpin>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<style lang="scss" scoped>
|
.huanjing-qushi {
|
box-sizing: border-box;
|
min-height: 100%;
|
padding: 10px 12px 12px;
|
background: #fff;
|
|
.jnpf-content-wrapper-center {
|
overflow: hidden;
|
background: #fff;
|
border-radius: 6px;
|
}
|
}
|
|
.qushi-title {
|
height: 66px;
|
color: #111;
|
font-size: 30px;
|
font-weight: 700;
|
line-height: 66px;
|
text-align: center;
|
text-shadow: 0 4px 10px rgb(0 0 0 / 24%);
|
}
|
|
.huanjing-qushi .qushi-search {
|
display: flex;
|
flex-direction: column;
|
box-sizing: border-box;
|
min-height: 112px;
|
padding: 14px;
|
border-top: 1px solid #d9d9d9;
|
border-bottom: 1px solid #d9d9d9;
|
gap: 12px;
|
}
|
|
.search-row {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
min-width: 0;
|
flex-wrap: wrap;
|
}
|
|
.search-label {
|
color: #444;
|
font-size: 14px;
|
font-weight: 600;
|
white-space: nowrap;
|
}
|
|
.date-range {
|
width: 320px;
|
}
|
|
.search-select {
|
width: 170px;
|
}
|
|
.status-select {
|
width: 190px;
|
}
|
|
.item-checks {
|
display: inline-flex;
|
align-items: center;
|
min-height: 32px;
|
flex-wrap: wrap;
|
gap: 2px 8px;
|
}
|
|
.huanjing-qushi .qushi-content {
|
flex: 1;
|
min-height: 0;
|
height: auto;
|
overflow-x: hidden;
|
overflow-y: auto;
|
padding: 12px 14px 28px;
|
background: #fff;
|
}
|
|
.chart-list {
|
display: flex;
|
flex-direction: column;
|
gap: 24px;
|
padding-bottom: 16px;
|
}
|
|
.chart-block {
|
width: 100%;
|
background: #fff;
|
}
|
|
.chart-canvas {
|
width: 100%;
|
height: 520px;
|
}
|
|
.empty-panel {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
height: 360px;
|
color: #999;
|
font-size: 14px;
|
}
|
|
@media (max-width: 1200px) {
|
.date-range {
|
width: 280px;
|
}
|
|
.search-select,
|
.status-select {
|
width: 150px;
|
}
|
}
|
</style>
|