CropCadmiumPrediction.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017
  1. <template>
  2. <div class="container">
  3. <!-- 顶部操作栏 -->
  4. <div class="toolbar">
  5. <!-- 文件上传区域 -->
  6. <div class="upload-section">
  7. <input type="file" ref="fileInput" accept=".csv" @change="handleFileUpload" style="display: none">
  8. <el-button class="custom-button" @click="triggerFileUpload">
  9. <el-icon class="upload-icon"><Upload /></el-icon>
  10. 选择CSV文件
  11. </el-button>
  12. <span v-if="selectedFile" class="file-name">{{ selectedFile.name }}</span>
  13. <el-button
  14. class="custom-button"
  15. :loading="isCalculating"
  16. :disabled="!selectedFile"
  17. @click="calculate"
  18. >
  19. <el-icon class="upload-icon"><Document /></el-icon>
  20. 上传并计算
  21. </el-button>
  22. <!-- 添加从数据库计算按钮 -->
  23. <el-button
  24. class="custom-button"
  25. :loading="isCalculatingFromDB"
  26. @click="calculateFromDatabase"
  27. >
  28. <el-icon class="upload-icon"><Box /></el-icon>
  29. 从数据库计算
  30. </el-button>
  31. </div>
  32. <!-- 操作按钮 -->
  33. <div class="action-buttons">
  34. <el-button class="custom-button" :disabled="!mapBlob" @click="exportMap">
  35. <el-icon class="upload-icon"><Download /></el-icon>
  36. 导出地图</el-button>
  37. <el-button class="custom-button" :disabled="!histogramBlob" @click="exportHistogram">
  38. <el-icon class="upload-icon"><Download /></el-icon>
  39. 导出直方图</el-button>
  40. <el-button class="custom-button" :disabled="!statisticsData.length" @click="exportData">
  41. <el-icon class="upload-icon"><Download /></el-icon>
  42. 导出数据</el-button>
  43. </div>
  44. </div>
  45. <!-- 主体内容区 -->
  46. <div class="content-area">
  47. <!-- 地图区域 - 单独一行 -->
  48. <div class="map-section">
  49. <h3>作物态Cd预测地图</h3>
  50. <div v-if="loadingMap" class="loading-container">
  51. <el-icon class="loading-icon"><Loading /></el-icon>
  52. <span>地图加载中...</span>
  53. </div>
  54. <img v-if="mapImageUrl && !loadingMap" :src="mapImageUrl" alt="作物态Cd预测地图" class="map-image">
  55. <div v-if="!mapImageUrl && !loadingMap" class="no-data">
  56. <el-icon><Picture /></el-icon>
  57. <p>暂无地图数据</p>
  58. </div>
  59. <div v-if="loadingTownshipMap" class="loading-container">
  60. <el-icon class="loading-icon"><Loading /></el-icon>
  61. <span>乡镇地图加载中...</span>
  62. </div>
  63. <!-- ECharts地图容器 -->
  64. <div
  65. v-show="!loadingTownshipMap"
  66. ref="townshipMapRef"
  67. class="township-map-container"
  68. ></div>
  69. <div v-if="!loadingTownshipMap && !townshipMapInstance" class="no-data">
  70. <el-icon><Location /></el-icon>
  71. <p>暂无乡镇边界数据</p>
  72. </div>
  73. </div>
  74. <!-- 统计图表区域 -->
  75. <div class="stats-area">
  76. <h3>作物态Cd预测统计信息</h3>
  77. <div class="model-info">
  78. <el-tag type="info">{{ modelInfo.modelType || 'Cd预测模型' }}</el-tag>
  79. <span class="update-time">
  80. 最后更新: {{ modelInfo.updateTime ? new Date(modelInfo.updateTime).toLocaleString() : '未知' }}
  81. </span>
  82. </div>
  83. <div v-if="loadingStats" class="loading-container">
  84. <el-icon class="loading-icon"><Loading /></el-icon>
  85. <span>统计数据加载中...</span>
  86. </div>
  87. <div v-if="!loadingStats && statisticsData.length" class="stats-container">
  88. <!-- 基础统计表格 -->
  89. <h4>基础统计信息</h4>
  90. <el-table
  91. :data="statisticsData"
  92. style="width: 100%; margin-bottom: 20px;"
  93. border
  94. stripe
  95. >
  96. <el-table-column prop="name" label="统计项" min-width="180" />
  97. <el-table-column prop="value" label="值" min-width="150" />
  98. <el-table-column prop="unit" label="单位" min-width="100" />
  99. <el-table-column prop="description" label="描述" min-width="200" />
  100. </el-table>
  101. <!-- 分布统计表格 -->
  102. <h4>水稻镉含量分布统计</h4>
  103. <el-table
  104. :data="distributionData"
  105. style="width: 100%; margin-bottom: 20px;"
  106. border
  107. stripe
  108. >
  109. <el-table-column prop="序号" label="序号" width="80" />
  110. <el-table-column prop="分布区间" label="分布区间 (mg/kg)" min-width="150" />
  111. <el-table-column prop="区间说明" label="区间说明" min-width="120" />
  112. <el-table-column prop="数据点数量" label="数据点数量" min-width="120" />
  113. <el-table-column prop="占比" label="占比 (%)" min-width="100">
  114. <template #default="{ row }">
  115. {{ row.占比 }}%
  116. </template>
  117. </el-table-column>
  118. </el-table>
  119. </div>
  120. <div v-if="!loadingStats && !statisticsData.length" class="no-data">
  121. <el-icon><DataAnalysis /></el-icon>
  122. <p>暂无统计数据</p>
  123. </div>
  124. </div>
  125. </div>
  126. <!-- 直方图区域 - 单独一行 -->
  127. <div class="histogram-section">
  128. <h3>作物态Cd预测直方图</h3>
  129. <div v-if="loadingHistogram" class="loading-container">
  130. <el-icon class="loading-icon"><Loading /></el-icon>
  131. <span>直方图加载中...</span>
  132. </div>
  133. <img v-if="histogramImageUrl && !loadingHistogram" :src="histogramImageUrl" alt="作物态Cd预测直方图" class="histogram-image">
  134. <div v-if="!histogramImageUrl && !loadingHistogram" class="no-data">
  135. <el-icon><Histogram /></el-icon>
  136. <p>暂无直方图数据</p>
  137. </div>
  138. </div>
  139. </div>
  140. </template>
  141. <script>
  142. import * as XLSX from 'xlsx';
  143. import * as echarts from 'echarts';
  144. import { saveAs } from 'file-saver';
  145. import { api8000 } from '@/utils/request';
  146. import {
  147. Location,Loading, Upload, Picture, Histogram, Download, Document, Box, DataAnalysis
  148. } from '@element-plus/icons-vue';
  149. export default {
  150. name: 'CropCadmiumPrediction',
  151. components: {
  152. Loading, Upload, Picture, Histogram, Download, Document, Box, DataAnalysis
  153. },
  154. data() {
  155. return {
  156. isCalculating: false,
  157. isCalculatingFromDB: false,
  158. loadingMap: false,
  159. loadingHistogram: false,
  160. loadingStats: false,
  161. statisticsData: [],
  162. distributionData: [],
  163. distributionSummary: null,
  164. distributionTotal: 0,
  165. modelInfo: {
  166. modelType: '',
  167. unit: '',
  168. updateTime: null,
  169. dataSource: ''
  170. },
  171. mapImageUrl: null,
  172. histogramImageUrl: null,
  173. mapBlob: null,
  174. histogramBlob: null,
  175. selectedFile: null,
  176. // 新增:乡镇地图相关数据
  177. loadingTownshipMap: false, // 乡镇地图加载状态
  178. townshipMapInstance: null, // ECharts实例
  179. townshipGeoJson: null, // 本地边界数据(TopoJSON/GeoJSON)
  180. townshipData: [], // 接口获取的乡镇Cd数据
  181. countyName: '乐昌市', // 默认县市名称
  182. townshipMapRef :null,
  183. currentTooltipTown: '', // 当前悬浮的乡镇
  184. currentTooltipData: null, // 当前乡镇的详情数据
  185. isTooltipLoading: false, // tooltip 是否在加载中
  186. };
  187. },
  188. mounted() {
  189. // 组件挂载时获取最新数据
  190. this.fetchLatestResults();
  191. this.fetchStatistics();
  192. this.initTownshipMap();
  193. },
  194. beforeDestroy() {
  195. if (this.mapImageUrl) URL.revokeObjectURL(this.mapImageUrl);
  196. if (this.histogramImageUrl) URL.revokeObjectURL(this.histogramImageUrl);
  197. // 新增:销毁ECharts实例,避免内存泄漏
  198. if (this.townshipMapInstance) {
  199. this.townshipMapInstance.dispose();
  200. this.townshipMapInstance = null;
  201. }
  202. },
  203. methods: {
  204. // 触发文件选择
  205. triggerFileUpload() {
  206. this.$refs.fileInput.click();
  207. },
  208. // 处理文件上传
  209. handleFileUpload(event) {
  210. const files = event.target.files;
  211. if (files && files.length > 0) {
  212. this.selectedFile = files[0];
  213. } else {
  214. this.selectedFile = null;
  215. }
  216. },
  217. // 获取最新结果
  218. async fetchLatestResults() {
  219. try {
  220. this.loadingMap = true;
  221. this.loadingHistogram = true;
  222. // 获取最新地图
  223. await this.fetchLatestMap();
  224. // 获取最新直方图
  225. await this.fetchLatestHistogram();
  226. } catch (error) {
  227. console.error('获取最新结果失败:', error);
  228. this.$message.error('获取最新结果失败');
  229. } finally {
  230. this.loadingMap = false;
  231. this.loadingHistogram = false;
  232. }
  233. },
  234. // 获取最新地图
  235. async fetchLatestMap() {
  236. try {
  237. const response = await api8000.get(
  238. `/api/cd-prediction/crop-cd/latest-map/${this.countyName}`,
  239. { responseType: 'blob' }
  240. );
  241. this.mapBlob = response.data;
  242. this.mapImageUrl = URL.createObjectURL(this.mapBlob);
  243. } catch (error) {
  244. console.error('获取最新地图失败:', error);
  245. this.$message.warning('获取最新地图失败,请先执行预测');
  246. }
  247. },
  248. // 获取最新直方图
  249. async fetchLatestHistogram() {
  250. try {
  251. const response = await api8000.get(
  252. `/api/cd-prediction/crop-cd/latest-histogram/${this.countyName}`,
  253. { responseType: 'blob' }
  254. );
  255. this.histogramBlob = response.data;
  256. this.histogramImageUrl = URL.createObjectURL(this.histogramBlob);
  257. } catch (error) {
  258. console.error('获取最新直方图失败:', error);
  259. this.$message.warning('获取最新直方图失败,请先执行预测');
  260. }
  261. },
  262. // 格式化统计数据
  263. formatStatisticsData(statsData) {
  264. if (!statsData || !statsData.data) return [];
  265. const { 基础统计, 数据单位 } = statsData.data;
  266. return [
  267. {
  268. name: '数据点总数',
  269. value: 基础统计.数据点总数,
  270. unit: '-',
  271. description: '样本总数'
  272. },
  273. {
  274. name: '均值',
  275. value: 基础统计.均值.toFixed(4),
  276. unit: 数据单位,
  277. description: '所有样本的平均值'
  278. },
  279. {
  280. name: '中位数',
  281. value: 基础统计.中位数.toFixed(4),
  282. unit: 数据单位,
  283. description: '样本的中位数值'
  284. },
  285. {
  286. name: '标准差',
  287. value: 基础统计.标准差.toFixed(4),
  288. unit: 数据单位,
  289. description: '数据的离散程度'
  290. },
  291. {
  292. name: '最小值',
  293. value: 基础统计.最小值.toFixed(4),
  294. unit: 数据单位,
  295. description: '样本中的最小值'
  296. },
  297. {
  298. name: '最大值',
  299. value: 基础统计.最大值.toFixed(4),
  300. unit: 数据单位,
  301. description: '样本中的最大值'
  302. },
  303. {
  304. name: '25%分位数',
  305. value: 基础统计['25%分位数'].toFixed(4),
  306. unit: 数据单位,
  307. description: '第一四分位数'
  308. },
  309. {
  310. name: '75%分位数',
  311. value: 基础统计['75%分位数'].toFixed(4),
  312. unit: 数据单位,
  313. description: '第三四分位数'
  314. }
  315. ];
  316. },
  317. // 获取统计信息
  318. async fetchStatistics() {
  319. try {
  320. this.loadingStats = true;
  321. const response = await api8000.get(
  322. `/api/cd-prediction/crop-cd/statistics`
  323. );
  324. if (response.data && response.data.success) {
  325. const statsData = response.data;
  326. this.statisticsData = this.formatStatisticsData(statsData);
  327. // 设置分布统计表格数据
  328. if (statsData.data.分布统计表格 && statsData.data.分布统计表格.表格数据) {
  329. this.distributionData = statsData.data.分布统计表格.表格数据;
  330. this.distributionSummary = statsData.data.分布统计表格.汇总;
  331. this.distributionTotal = statsData.data.分布统计表格.总数据点数;
  332. }
  333. // 设置模型信息
  334. this.modelInfo = {
  335. modelType: statsData.data.模型类型,
  336. unit: statsData.data.数据单位,
  337. updateTime: statsData.data.数据更新时间,
  338. dataSource: statsData.data.数据来源
  339. };
  340. }
  341. } catch (error) {
  342. console.error('获取统计信息失败:', error);
  343. this.$message.warning('获取统计信息失败');
  344. } finally {
  345. this.loadingStats = false;
  346. }
  347. },
  348. // 新增方法:根据乡镇名请求接口
  349. async fetchTownshipDetailByName(townName) {
  350. try {
  351. // 调用对应乡镇的接口,假设接口地址为 /api/township-details/{townName}
  352. const encodedTownName = encodeURIComponent(townName);
  353. const response = await api8000.get(`/api/cd-prediction/crop-cd/statistics/town/${encodedTownName}`);
  354. if (response.data && response.data.success) {
  355. return response.data.data;
  356. }
  357. return null;
  358. } catch (error) {
  359. console.error(`获取${townName}详情失败:`, error);
  360. return null;
  361. }
  362. },
  363. // 新增1:初始化乡镇边界地图(核心逻辑,仅加载边界)
  364. async initTownshipMap() {
  365. try {
  366. this.loadingTownshipMap = true;
  367. // 步骤1:加载本地 GeoJSON 边界文件
  368. await this.loadLocalGeoJson();
  369. // 步骤2:渲染边界地图(不关联接口数据)
  370. this.renderTownshipMap();
  371. } catch (error) {
  372. console.error('乡镇边界加载失败:', error);
  373. this.townshipMapInstance = null; // 标记加载失败
  374. } finally {
  375. this.loadingTownshipMap = false;
  376. }
  377. },
  378. // 新增2:加载本地 GeoJSON 文件(关键:路径必须正确)
  379. async loadLocalGeoJson() {
  380. try {
  381. // 1. 确认文件路径:public/data/韶关市乡镇划分图5.geojson
  382. const geoJsonPath = '/data/韶关市乡镇划分图5.geojson';
  383. // 2. 发送请求加载 GeoJSON
  384. const response = await fetch(geoJsonPath);
  385. // 3. 检查请求是否成功(状态码 200-299 为成功)
  386. if (!response.ok) {
  387. throw new Error(`文件加载失败:状态码 ${response.status}(路径:${geoJsonPath})`);
  388. }
  389. // 4. 解析 GeoJSON 数据
  390. let originalGeoJson = await response.json();
  391. // 关键修改:过滤只保留 FXZQMC 为"乐昌市"的特征数据
  392. this.townshipGeoJson = {
  393. ...originalGeoJson, // 保留原有属性(如 type、crs 等)
  394. features: originalGeoJson.features// 过滤出乐昌市的乡镇
  395. .filter(feature => feature.properties?.FXZQMC === '乐昌市')
  396. // 为每个乡镇添加name字段,值为TXZQMC(ECharts默认读取name字段)
  397. .map(feature => ({
  398. ...feature,
  399. properties: {
  400. ...feature.properties,
  401. name: feature.properties?.TXZQMC || '未知乡镇' // 核心:映射TXZQMC到name
  402. }
  403. }))
  404. };
  405. // 5. 验证 GeoJSON 格式(必须包含 features 数组,否则是无效格式)
  406. if (!this.townshipGeoJson.features || !Array.isArray(this.townshipGeoJson.features)) {
  407. throw new Error('GeoJSON 格式错误:缺少 features 数组');
  408. }
  409. console.log('GeoJSON 加载成功,包含乡镇数量:', this.townshipGeoJson.features.length);
  410. } catch (error) {
  411. console.error('GeoJSON 加载异常:', error);
  412. throw error; // 抛出错误,让 initTownshipMap 捕获
  413. }
  414. },
  415. // 新增3:渲染乡镇边界(仅显示边界和乡镇名,不关联数据)
  416. renderTownshipMap() {
  417. // 1. 获取地图容器 DOM(必须存在)
  418. const mapContainer = this.$refs.townshipMapRef;
  419. if (!mapContainer) {
  420. throw new Error('ECharts 容器不存在:请检查 ref="townshipMapRef" 是否正确');
  421. }
  422. // 2. 初始化 ECharts 实例
  423. this.townshipMapInstance = echarts.init(mapContainer);
  424. // 3. 注册地图:将 GeoJSON 数据注册到 ECharts(名称用 countyName:乐昌市)
  425. echarts.registerMap(this.countyName, this.townshipGeoJson);
  426. // 4. ECharts 配置项(仅显示边界和乡镇名,无数据关联)
  427. const option = {
  428. // 标题(可选,显示在地图上方)
  429. title: {
  430. text: '乐昌市乡镇边界',
  431. left: 'center',
  432. textStyle: { fontSize: 16, fontWeight: 'bold' }
  433. },
  434. // 提示框(鼠标悬浮时显示乡镇名)
  435. tooltip: {
  436. trigger: 'item', // 按乡镇区域触发
  437. formatter: () => {
  438. if (this.isTooltipLoading) {
  439. return '<div style="padding: 5px;">加载中...</div>';
  440. } else if (this.currentTooltipData) {
  441. const detail = this.currentTooltipData;
  442. let content = `
  443. <div class="town-tooltip">
  444. <h3 style="margin: 0 0 5px; color: #0066CC; text-align : center;">${this.currentTooltipTown}</h3>
  445. <div style="height: 1px; background-color: #0066CC; margin-bottom: 8px;"></div>
  446. <p>采样点数量: ${detail.基础统计.采样点数量}</p>
  447. <p>平均值: ${detail.基础统计.平均值.toFixed(4)} mg/kg</p>
  448. <p>最小值: ${detail.基础统计.最小值.toFixed(4)} mg/kg</p>
  449. <p>最大值: ${detail.基础统计.最大值.toFixed(4)} mg/kg</p>
  450. <p>数据更新时间: ${new Date(detail.数据更新时间).toLocaleString()}</p>
  451. <div style="height: 1px; background-color: #0066CC; margin-bottom: 8px;"></div>
  452. <p style="color:black; font-size:16px;">分布统计:</p>
  453. <p>安全区间占比: ${detail.分布统计表格.汇总.安全区间占比}</p>
  454. <p>预警区间占比: ${detail.分布统计表格.汇总.预警区间占比}</p>
  455. <p>超标区间占比: ${detail.分布统计表格.汇总.超标区间占比}</p>
  456. </div>
  457. `;
  458. return content;
  459. }
  460. },
  461. },
  462. // 地图系列(核心:渲染边界)
  463. series: [
  464. {
  465. type: 'map',
  466. map: this.countyName, // 对应注册的地图名称(乐昌市)
  467. roam: true, // 允许鼠标缩放、平移地图(方便查看)
  468. label: {
  469. show: true, // 显示乡镇名称标签
  470. fontSize: 10, // 标签字体大小(避免重叠)
  471. color: '#333' // 标签颜色
  472. },
  473. itemStyle: {
  474. color: 'transparent', // 乡镇区域填充色(透明,仅显示边界)
  475. borderColor: '#000000', // 边界颜色(青色,醒目)
  476. borderWidth: 1.5 // 边界宽度
  477. },
  478. emphasis: {
  479. // 鼠标悬浮时的样式(高亮边界和标签)
  480. label: { color: '#fff', fontSize: 11 },
  481. itemStyle: { color: 'rgba(71, 195, 185, 0.3)' } // 悬浮区域填充色
  482. }
  483. }
  484. ]
  485. };
  486. this.townshipMapInstance.setOption(option);
  487. // 监听鼠标悬浮事件
  488. this.townshipMapInstance.on('mouseover', async (params) => {
  489. if (params.componentType === 'series' && params.seriesType === 'map') {
  490. const townName = params.name;
  491. this.currentTooltipTown = townName;
  492. this.isTooltipLoading = true;
  493. this.currentTooltipData = null;
  494. // 手动触发 tooltip 更新
  495. this.townshipMapInstance.setOption({ tooltip: {} });
  496. const detail = await this.fetchTownshipDetailByName(townName);
  497. this.isTooltipLoading = false;
  498. this.currentTooltipData = detail;
  499. // 再次手动触发 tooltip 更新,显示获取到的数据
  500. this.townshipMapInstance.setOption({ tooltip: {} });
  501. }
  502. });
  503. // 监听鼠标移出事件,重置状态
  504. this.townshipMapInstance.on('mouseout', () => {
  505. this.currentTooltipTown = '';
  506. this.currentTooltipData = null;
  507. this.isTooltipLoading = false;
  508. });
  509. // 5. 渲染地图
  510. this.townshipMapInstance.setOption(option);
  511. // 6. 监听窗口 resize(地图自适应)
  512. window.addEventListener('resize', () => {
  513. this.townshipMapInstance && this.townshipMapInstance.resize();
  514. });
  515. },
  516. // 上传并计算
  517. async calculate() {
  518. if (!this.selectedFile) {
  519. this.$message.warning('请先选择CSV文件');
  520. return;
  521. }
  522. try {
  523. this.isCalculating = true;
  524. this.loadingMap = true;
  525. this.loadingHistogram = true;
  526. // 创建FormData
  527. const formData = new FormData();
  528. formData.append('area', this.countyName);
  529. formData.append('data_file', this.selectedFile);
  530. formData.append('use_database', 'false'); // 使用上传的文件
  531. // 调用作物Cd地图接口
  532. const mapResponse = await api8000.post(
  533. '/api/cd-prediction/crop-cd/generate-and-get-map',
  534. formData,
  535. {
  536. headers: {
  537. 'Content-Type': 'multipart/form-data'
  538. },
  539. responseType: 'blob'
  540. }
  541. );
  542. // 保存地图数据
  543. this.mapBlob = mapResponse.data;
  544. this.mapImageUrl = URL.createObjectURL(this.mapBlob);
  545. // 更新后重新获取直方图和统计数据
  546. await this.fetchLatestHistogram();
  547. await this.fetchStatistics();
  548. this.$message.success('计算完成!');
  549. await this.initTownshipMap();
  550. } catch (error) {
  551. console.error('计算失败:', error);
  552. let errorMessage = '计算失败,请重试';
  553. if (error.response) {
  554. if (error.response.status === 400) {
  555. errorMessage = '文件格式错误:' + (error.response.data.detail || '请上传正确的CSV文件');
  556. } else if (error.response.status === 404) {
  557. errorMessage = '不支持的县市:' + this.countyName;
  558. } else if (error.response.status === 500) {
  559. errorMessage = '服务器错误:' + (error.response.data.detail || '请稍后重试');
  560. }
  561. }
  562. this.$message.error(errorMessage);
  563. } finally {
  564. this.isCalculating = false;
  565. this.loadingMap = false;
  566. this.loadingHistogram = false;
  567. }
  568. },
  569. // 从数据库计算
  570. async calculateFromDatabase() {
  571. try {
  572. this.isCalculatingFromDB = true;
  573. this.loadingMap = true;
  574. this.loadingHistogram = true;
  575. // 创建FormData
  576. const formData = new FormData();
  577. formData.append('area', this.countyName);
  578. formData.append('use_database', 'true'); // 使用数据库数据
  579. // 调用作物Cd地图接口
  580. const mapResponse = await api8000.post(
  581. '/api/cd-prediction/crop-cd/generate-and-get-map',
  582. formData,
  583. {
  584. headers: {
  585. 'Content-Type': 'multipart/form-data'
  586. },
  587. responseType: 'blob'
  588. }
  589. );
  590. // 保存地图数据
  591. this.mapBlob = mapResponse.data;
  592. this.mapImageUrl = URL.createObjectURL(this.mapBlob);
  593. // 更新后重新获取直方图和统计数据
  594. await this.fetchLatestHistogram();
  595. await this.fetchStatistics();
  596. this.$message.success('数据库计算完成!');
  597. await this.initTownshipMap();
  598. } catch (error) {
  599. console.error('从数据库计算失败:', error);
  600. let errorMessage = '数据库计算失败,请重试';
  601. if (error.response) {
  602. if (error.response.status === 400) {
  603. errorMessage = '参数错误:' + (error.response.data.detail || '请检查县市名称');
  604. } else if (error.response.status === 404) {
  605. errorMessage = '不支持的县市:' + this.countyName;
  606. } else if (error.response.status === 500) {
  607. errorMessage = '服务器错误:' + (error.response.data.detail || '请稍后重试');
  608. }
  609. }
  610. this.$message.error(errorMessage);
  611. } finally {
  612. this.isCalculatingFromDB = false;
  613. this.loadingMap = false;
  614. this.loadingHistogram = false;
  615. }
  616. },
  617. // 导出地图
  618. exportMap() {
  619. if (!this.mapBlob) {
  620. this.$message.warning('请先计算生成地图');
  621. return;
  622. }
  623. const link = document.createElement('a');
  624. link.href = URL.createObjectURL(this.mapBlob);
  625. link.download = `${this.countyName}_作物态Cd预测地图.jpg`;
  626. link.click();
  627. URL.revokeObjectURL(link.href);
  628. },
  629. // 导出直方图
  630. exportHistogram() {
  631. if (!this.histogramBlob) {
  632. this.$message.warning('请先计算生成直方图');
  633. return;
  634. }
  635. const link = document.createElement('a');
  636. link.href = URL.createObjectURL(this.histogramBlob);
  637. link.download = `${this.countyName}_作物态Cd预测直方图.jpg`;
  638. link.click();
  639. URL.revokeObjectURL(link.href);
  640. },
  641. // 导出数据
  642. async exportData() {
  643. try {
  644. this.$message.info('正在获取作物态Cd预测数据...');
  645. const response = await api8000.get(
  646. `/api/cd-prediction/crop-cd/export-csv`,
  647. { responseType: 'blob' }
  648. );
  649. const blob = new Blob([response.data], { type: 'text/csv' });
  650. const link = document.createElement('a');
  651. link.href = URL.createObjectURL(blob);
  652. link.download = `作物态Cd预测数据.csv`;
  653. link.click();
  654. URL.revokeObjectURL(link.href);
  655. this.$message.success('数据导出成功');
  656. } catch (error) {
  657. console.error('导出数据失败:', error);
  658. this.$message.error('导出数据失败: ' + (error.response?.data?.detail || '请稍后重试'));
  659. }
  660. },
  661. // 处理窗口大小变化
  662. handleResize() {
  663. if (this.distributionChart) this.distributionChart.resize();
  664. },
  665. }
  666. };
  667. </script>
  668. <style scoped>
  669. .container {
  670. padding: 20px;
  671. background: linear-gradient(
  672. 135deg,
  673. rgba(230, 247, 255, 0.7) 0%,
  674. rgba(240, 248, 255, 0.7) 100%
  675. );
  676. min-height: 100vh;
  677. box-sizing: border-box;
  678. }
  679. /* 新增:乡镇地图样式 */
  680. .township-map-section {
  681. background-color: rgba(255, 255, 255, 0.8);
  682. border-radius: 8px;
  683. padding: 15px;
  684. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  685. position: relative;
  686. min-height: 500px; /* 与原有地图高度一致 */
  687. backdrop-filter: blur(5px);
  688. margin-bottom: 20px; /* 与下方地图间距 */
  689. }
  690. .township-map-container {
  691. width: 90%; /* 使用百分比宽度 */
  692. max-width: 1000px; /* 最大宽度限制 */
  693. height: 500px;
  694. border-radius: 4px;
  695. background-color: #fff;
  696. margin: 15px auto; /* 上下15px,水平自动居中 */
  697. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  698. }
  699. .toolbar {
  700. display: flex;
  701. flex-direction: column;
  702. gap: 15px;
  703. margin-bottom: 20px;
  704. padding: 15px;
  705. background-color: rgba(255, 255, 255, 0.8);
  706. border-radius: 8px;
  707. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  708. backdrop-filter: blur(5px);
  709. }
  710. .upload-section {
  711. display: flex;
  712. align-items: center;
  713. gap: 15px;
  714. padding-bottom: 15px;
  715. border-bottom: 1px solid rgba(0, 0, 0, 0.1);
  716. }
  717. .file-name {
  718. flex: 1;
  719. padding: 0 10px;
  720. color: #666;
  721. font-size: 14px;
  722. overflow: hidden;
  723. text-overflow: ellipsis;
  724. white-space: nowrap;
  725. }
  726. .action-buttons {
  727. display: flex;
  728. gap: 10px;
  729. flex-wrap: wrap;
  730. }
  731. .custom-button {
  732. background-color: #47C3B9 !important;
  733. color: #DCFFFA !important;
  734. border: none;
  735. border-radius: 155px;
  736. padding: 10px 20px;
  737. font-weight: bold;
  738. display: flex;
  739. align-items: center;
  740. }
  741. .upload-icon {
  742. margin-right: 5px;
  743. }
  744. .content-area {
  745. display: flex;
  746. flex-direction: column;
  747. gap: 20px;
  748. }
  749. /* 地图区域 */
  750. .map-section {
  751. background-color: rgba(255, 255, 255, 0.8);
  752. border-radius: 8px;
  753. padding: 15px;
  754. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  755. position: relative;
  756. min-height: 500px;
  757. backdrop-filter: blur(5px);
  758. }
  759. .map-image {
  760. width: 100%;
  761. height: 100%;
  762. max-height: 500px;
  763. object-fit: contain;
  764. border-radius: 4px;
  765. }
  766. /* 直方图区域 */
  767. .histogram-section {
  768. background-color: rgba(255, 255, 255, 0.8);
  769. border-radius: 8px;
  770. padding: 15px;
  771. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  772. position: relative;
  773. min-height: 500px;
  774. backdrop-filter: blur(5px);
  775. }
  776. .histogram-image {
  777. width: 100%;
  778. height: 100%;
  779. max-height: 600px;
  780. object-fit: contain;
  781. border-radius: 4px;
  782. }
  783. /* 统计区域 */
  784. .stats-area {
  785. background-color: rgba(255, 255, 255, 0.8);
  786. border-radius: 8px;
  787. padding: 15px;
  788. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  789. position: relative;
  790. backdrop-filter: blur(5px);
  791. }
  792. .model-info {
  793. display: flex;
  794. align-items: center;
  795. gap: 15px;
  796. margin-bottom: 15px;
  797. padding-bottom: 15px;
  798. border-bottom: 1px solid rgba(0, 0, 0, 0.1);
  799. }
  800. .update-time {
  801. color: #666;
  802. font-size: 14px;
  803. }
  804. .data-source {
  805. display: flex;
  806. align-items: center;
  807. gap: 10px;
  808. margin-top: 15px;
  809. padding-top: 15px;
  810. border-top: 1px solid rgba(0, 0, 0, 0.1);
  811. color: #666;
  812. }
  813. .loading-container {
  814. display: flex;
  815. flex-direction: column;
  816. align-items: center;
  817. justify-content: center;
  818. height: 300px;
  819. color: #47C3B9;
  820. }
  821. .no-data {
  822. display: flex;
  823. flex-direction: column;
  824. align-items: center;
  825. justify-content: center;
  826. height: 300px;
  827. color: #999;
  828. font-size: 16px;
  829. }
  830. .no-data .el-icon {
  831. font-size: 48px;
  832. margin-bottom: 10px;
  833. }
  834. .loading-icon {
  835. font-size: 36px;
  836. margin-bottom: 10px;
  837. animation: rotate 2s linear infinite;
  838. }
  839. /* 新增样式 */
  840. .summary-info {
  841. margin-top: 20px;
  842. }
  843. .card-header {
  844. font-weight: bold;
  845. color: #409EFF;
  846. }
  847. .summary-items {
  848. display: flex;
  849. flex-direction: column;
  850. gap: 10px;
  851. }
  852. .summary-item {
  853. display: flex;
  854. justify-content: space-between;
  855. align-items: center;
  856. padding: 8px 0;
  857. border-bottom: 1px solid #ebeef5;
  858. }
  859. .summary-item:last-child {
  860. border-bottom: none;
  861. }
  862. .summary-item .label {
  863. font-weight: bold;
  864. color: #606266;
  865. }
  866. .summary-item .value {
  867. font-weight: bold;
  868. }
  869. .summary-item .value.safe {
  870. color: #67C23A;
  871. }
  872. .summary-item .value.warning {
  873. color: #E6A23C;
  874. }
  875. .summary-item .value.danger {
  876. color: #F56C6C;
  877. }
  878. @keyframes rotate {
  879. from {
  880. transform: rotate(0deg);
  881. }
  882. to {
  883. transform: rotate(360deg);
  884. }
  885. }
  886. /* 响应式布局调整 */
  887. @media (max-width: 992px) {
  888. .content-area {
  889. flex-direction: column;
  890. }
  891. .action-buttons {
  892. flex-direction: column;
  893. align-items: stretch;
  894. }
  895. .custom-button {
  896. justify-content: center;
  897. }
  898. .upload-section {
  899. flex-direction: column;
  900. align-items: stretch;
  901. }
  902. .file-name {
  903. text-align: center;
  904. }
  905. }
  906. </style>