HumidityCurve.vue 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. <template>
  2. <div class="humidity-curve-page">
  3. <a-card :bordered="false" class="query-card">
  4. <a-form :model="query" layout="inline" class="query-form">
  5. <a-form-item label="场站">
  6. <a-select
  7. v-model:value="query.stationId"
  8. :options="stationOptions"
  9. show-search
  10. :filter-option="filterOption"
  11. placeholder="请选择场站"
  12. class="station-control"
  13. />
  14. </a-form-item>
  15. <a-form-item label="时间">
  16. <a-range-picker
  17. v-model:value="query.timeRange"
  18. value-format="YYYY-MM-DD"
  19. class="time-control"
  20. />
  21. </a-form-item>
  22. <a-form-item class="query-actions">
  23. <a-space>
  24. <a-button type="primary" :loading="loading" @click="handleQuery">确认</a-button>
  25. <a-button :disabled="!hasData" @click="handleExport">导出</a-button>
  26. </a-space>
  27. </a-form-item>
  28. </a-form>
  29. </a-card>
  30. <a-card :bordered="false" class="chart-card" title="湿度曲线图">
  31. <div ref="chartRef" class="curve-chart" />
  32. </a-card>
  33. <a-card :bordered="false" class="detail-card" title="明细数据">
  34. <a-descriptions v-if="summary" size="small" :column="4" bordered class="summary">
  35. <a-descriptions-item label="总记录数(预测)">{{ summary.totalRecords }}</a-descriptions-item>
  36. <a-descriptions-item label="有效记录数(预测)">{{ summary.validRecords }}</a-descriptions-item>
  37. <a-descriptions-item label="总记录数(本地)">{{ summary.localTotalRecords }}</a-descriptions-item>
  38. <a-descriptions-item label="有效记录数(本地)">{{ summary.localValidRecords }}</a-descriptions-item>
  39. </a-descriptions>
  40. <a-table
  41. :columns="columns"
  42. :data-source="detailRows"
  43. :loading="loading"
  44. :pagination="pagination"
  45. :scroll="{ x: 500, y: 480 }"
  46. row-key="key"
  47. size="middle"
  48. bordered
  49. />
  50. </a-card>
  51. </div>
  52. </template>
  53. <script setup name="ElectricityHumidityCurve">
  54. import {computed, nextTick, onBeforeUnmount, onMounted, reactive, ref} from 'vue';
  55. import * as echarts from 'echarts';
  56. import dayjs from 'dayjs';
  57. import {message} from 'ant-design-vue';
  58. import {saveAs} from 'file-saver';
  59. import {listStation} from '@/api/config/station';
  60. import {getRhsfcChart, exportRhsfcChart} from '@/api/weather/weatherRaw';
  61. import {blobValidate} from '@/utils/bearjia';
  62. import {normalizeRows, toNumber} from '../components/dataAdapter';
  63. const loading = ref(false);
  64. const stationOptions = ref([]);
  65. const chartRef = ref();
  66. const detailRows = ref([]);
  67. const summary = ref(null);
  68. let chartInstance = null;
  69. const query = reactive({
  70. stationId: undefined,
  71. timeRange: [
  72. dayjs().subtract(3, 'day').format('YYYY-MM-DD'),
  73. dayjs().add(3, 'day').format('YYYY-MM-DD')
  74. ]
  75. });
  76. const hasData = computed(() => !!summary.value && (summary.value.totalRecords > 0 || summary.value.localTotalRecords > 0));
  77. const columns = [
  78. {title: '时间', dataIndex: 'time', width: 180, align: 'center', fixed: 'left'},
  79. {title: '来源', dataIndex: 'source', width: 100, align: 'center'},
  80. {title: '湿度(%)', dataIndex: 'rhsfc', width: 150, align: 'right'}
  81. ];
  82. const pagination = {
  83. pageSize: 20,
  84. showSizeChanger: true,
  85. showTotal: (total) => `共 ${total} 条`
  86. };
  87. const filterOption = (input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase());
  88. const toTimeParams = () => {
  89. const [start, end] = query.timeRange || [];
  90. return {
  91. stationId: query.stationId,
  92. startTime: dayjs(start).startOf('day').unix(),
  93. endTime: dayjs(end).endOf('day').unix()
  94. };
  95. };
  96. const loadStations = async () => {
  97. const res = await listStation({pageNum: 1, pageSize: 500});
  98. const list = normalizeRows(res);
  99. stationOptions.value = list.map((item) => ({
  100. label: item.stationName || item.name || item.id,
  101. value: item.id ?? item.stationId
  102. }));
  103. // 自动选中第一个场站
  104. if (stationOptions.value.length && !query.stationId) {
  105. query.stationId = stationOptions.value[0].value;
  106. }
  107. };
  108. const buildOption = (forecastStats, localStats) => {
  109. // 预测/本地采样时间点不同,用连续时间轴各自按自身采样点绘制,避免线条因对方缺数而中断
  110. // 按时间戳排序,保证数组顺序与时间一致,避免后端返回无序导致线条自我交叉
  111. const toPairs = (stats, key) => (stats || [])
  112. .map((item) => [dayjs(item.time).valueOf(), toNumber(item[key], 0)])
  113. .sort((a, b) => a[0] - b[0]);
  114. return {
  115. tooltip: {trigger: 'axis'},
  116. legend: {top: 0, left: 'center', data: ['预测', '本地']},
  117. grid: {left: 60, right: 24, top: 46, bottom: 60},
  118. xAxis: {
  119. type: 'time',
  120. axisLabel: {color: '#5a6a85'},
  121. axisPointer: {snap: true}
  122. },
  123. yAxis: {
  124. type: 'value',
  125. name: '湿度(%)',
  126. splitLine: {lineStyle: {color: '#e6eef8'}},
  127. axisLabel: {color: '#5a6a85'}
  128. },
  129. dataZoom: [
  130. {type: 'inside', start: 0, end: 100},
  131. {type: 'slider', height: 18, bottom: 12, start: 0, end: 100}
  132. ],
  133. series: [
  134. {name: '预测', type: 'line', smooth: true, showSymbol: false, data: toPairs(forecastStats, 'rhsfc')},
  135. {name: '本地', type: 'line', smooth: true, showSymbol: false, data: toPairs(localStats, 'rhsfc')}
  136. ]
  137. };
  138. };
  139. const renderChart = async (forecastStats, localStats) => {
  140. await nextTick();
  141. if (!chartRef.value) return;
  142. if (!chartInstance) {
  143. chartInstance = echarts.init(chartRef.value);
  144. }
  145. chartInstance.setOption(buildOption(forecastStats, localStats), true);
  146. };
  147. const resizeChart = () => {
  148. chartInstance?.resize();
  149. };
  150. const handleQuery = async () => {
  151. if (!query.stationId) {
  152. message.warning('请先选择场站');
  153. return;
  154. }
  155. const params = toTimeParams();
  156. if (!params.startTime || !params.endTime) {
  157. message.warning('请选择时间范围');
  158. return;
  159. }
  160. loading.value = true;
  161. try {
  162. const res = await getRhsfcChart(params);
  163. const data = res?.data;
  164. if (!data) {
  165. summary.value = null;
  166. detailRows.value = [];
  167. renderChart([], []);
  168. return;
  169. }
  170. const forecast = data.forecast || {};
  171. const local = data.local || {};
  172. summary.value = {
  173. totalRecords: forecast.totalRecords || 0,
  174. validRecords: forecast.validRecords || 0,
  175. localTotalRecords: local.totalRecords || 0,
  176. localValidRecords: local.validRecords || 0
  177. };
  178. detailRows.value = [
  179. ...(forecast.stats || []).map((item, index) => ({
  180. key: `forecast-${index}`,
  181. time: item.time,
  182. source: item.source || '预测',
  183. rhsfc: toNumber(item.rhsfc, 0)
  184. })),
  185. ...(local.stats || []).map((item, index) => ({
  186. key: `local-${index}`,
  187. time: item.time,
  188. source: item.source || '本地',
  189. rhsfc: toNumber(item.rhsfc, 0)
  190. }))
  191. ];
  192. renderChart(forecast.stats || [], local.stats || []);
  193. } finally {
  194. loading.value = false;
  195. }
  196. };
  197. const handleExport = async () => {
  198. if (!query.stationId) {
  199. message.warning('请先选择场站');
  200. return;
  201. }
  202. const params = toTimeParams();
  203. if (!params.startTime || !params.endTime) {
  204. message.warning('请选择时间范围');
  205. return;
  206. }
  207. const station = stationOptions.value.find((item) => String(item.value) === String(query.stationId));
  208. const [start, end] = query.timeRange || [];
  209. try {
  210. const res = await exportRhsfcChart(params);
  211. const isFile = await blobValidate(res);
  212. if (isFile) {
  213. saveAs(new Blob([res]), `湿度曲线_${station?.label || query.stationId}_${start}_${end}.xlsx`);
  214. message.success('导出成功');
  215. } else {
  216. const text = await res.text();
  217. let msg = '导出失败';
  218. try {
  219. msg = JSON.parse(text)?.msg || msg;
  220. } catch (e) {
  221. /* ignore */
  222. }
  223. message.error(msg);
  224. }
  225. } catch (error) {
  226. console.error('导出失败:', error);
  227. message.error('导出失败');
  228. }
  229. };
  230. onMounted(async () => {
  231. window.addEventListener('resize', resizeChart);
  232. await loadStations();
  233. await handleQuery();
  234. });
  235. onBeforeUnmount(() => {
  236. window.removeEventListener('resize', resizeChart);
  237. chartInstance?.dispose();
  238. chartInstance = null;
  239. });
  240. </script>
  241. <style lang="less" scoped>
  242. .humidity-curve-page {
  243. padding: 16px;
  244. }
  245. .query-card {
  246. margin-bottom: 16px;
  247. }
  248. .query-form {
  249. display: flex;
  250. flex-wrap: wrap;
  251. gap: 12px 16px;
  252. align-items: flex-start;
  253. :deep(.ant-form-item) {
  254. margin: 0;
  255. }
  256. :deep(.ant-form-item-label) {
  257. min-width: 48px;
  258. text-align: left;
  259. }
  260. }
  261. .station-control {
  262. width: 240px;
  263. }
  264. .time-control {
  265. width: 300px;
  266. }
  267. .query-actions {
  268. margin-left: auto !important;
  269. }
  270. .chart-card {
  271. margin-bottom: 16px;
  272. }
  273. .curve-chart {
  274. width: 100%;
  275. height: 460px;
  276. }
  277. .detail-card {
  278. .summary {
  279. margin-bottom: 16px;
  280. }
  281. }
  282. </style>