FutureCadmiumPrediction.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. <template>
  2. <div class="container">
  3. <!-- 顶部操作栏 -->
  4. <div class="toolbar">
  5. <div class="forecast-controls">
  6. <div class="year-selector">
  7. <!-- 新增:年份输入标题 -->
  8. <span class="input-label">预测年份:</span>
  9. <el-input
  10. v-model.number="forecastYear"
  11. type="number"
  12. placeholder="输入预测年份(1-100)"
  13. :min="1"
  14. :max="100"
  15. @keypress.enter="generateForecast"
  16. :class="{ 'is-invalid': !isValidYear }"
  17. class="custom-input"
  18. ></el-input>
  19. <el-button
  20. class="custom-button"
  21. :loading="isGenerating"
  22. :disabled="(!isValidYear || isGenerating)"
  23. @click="generateForecast"
  24. >
  25. 开始预测
  26. </el-button>
  27. </div>
  28. </div>
  29. </div>
  30. <!-- 主体内容区 -->
  31. <div class="content-area">
  32. <!-- 预测结果展示区 - 垂直排列 -->
  33. <!-- 地图部分 -->
  34. <div class="visualization-section">
  35. <h3>土壤Cd未来浓度预测地图</h3>
  36. <div class="image-container">
  37. <div v-if="loadingMap" class="loading-container">
  38. <el-icon class="loading-icon"><Loading /></el-icon>
  39. <span>地图加载中...</span>
  40. </div>
  41. <img v-if="mapImageUrl" :src="mapImageUrl" class="result-img"></img>
  42. <div v-else class="no-data">
  43. <el-icon><Picture /></el-icon>
  44. <p>暂无地图数据</p>
  45. </div>
  46. </div>
  47. </div>
  48. <!-- 直方图部分 -->
  49. <div class="visualization-section">
  50. <h3>预测直方图</h3>
  51. <div class="image-container">
  52. <div v-if="loadingHistogram" class="loading-container">
  53. <el-icon class="loading-icon"><Loading /></el-icon>
  54. <span>直方图加载中...</span>
  55. </div>
  56. <img v-if="histogramImageUrl" :src="histogramImageUrl" class="result-img"></img>
  57. <div v-else class="no-data">
  58. <el-icon><Histogram /></el-icon>
  59. <p>暂无直方图数据</p>
  60. </div>
  61. </div>
  62. </div>
  63. </div>
  64. </div>
  65. </template>
  66. <script>
  67. import { api8000 } from '@/utils/request';
  68. import { Loading, Picture, Histogram, Right } from '@element-plus/icons-vue';
  69. export default {
  70. name: 'FutureCdPrediction',
  71. components: {
  72. Loading,
  73. Picture,
  74. Histogram,
  75. Right
  76. },
  77. props: {
  78. countyName: {
  79. type: String,
  80. required: true,
  81. default: '乐昌市'
  82. }
  83. },
  84. data() {
  85. return {
  86. forecastYear: null,
  87. isGenerating: false,
  88. generateVisualization: true,
  89. loadingMap: false,
  90. loadingHistogram: false,
  91. mapBlob: null,
  92. histogramBlob: null,
  93. mapImageUrl: '',
  94. histogramImageUrl: ''
  95. };
  96. },
  97. computed: {
  98. isValidYear() {
  99. return this.forecastYear >= 1 && this.forecastYear <= 100;
  100. },
  101. canGenerate() {
  102. return this.isValidYear && !this.isGenerating;
  103. }
  104. },
  105. watch: {
  106. forecastYear(newVal) {
  107. newVal = parseInt(newVal);
  108. if (isNaN(newVal)) this.forecastYear = null;
  109. if (newVal < 1) this.forecastYear = 1;
  110. if (newVal > 100) this.forecastYear = 100;
  111. }
  112. },
  113. methods: {
  114. // 预测控制逻辑
  115. async generateForecast() {
  116. if (this.isGenerating) return;
  117. this.isGenerating = true;
  118. this.mapBlob = null;
  119. this.histogramBlob = null;
  120. this.mapImageUrl = '';
  121. this.histogramImageUrl = '';
  122. try {
  123. // 1. 尝试加载现有预测数据
  124. await this.checkExistingData();
  125. // 2. 如果存在历史数据,直接显示
  126. this.$message.success(`成功加载${this.forecastYear}年预测数据`);
  127. this.isGenerating = false;
  128. return;
  129. } catch (loadError) {
  130. console.log('未找到历史数据,开始生成预测...', loadError);
  131. }
  132. try {
  133. // 3. 生成新预测
  134. await this.generateNewPrediction();
  135. // 4. 加载新生成的数据
  136. await Promise.all([
  137. this.loadVisualization('map', this.forecastYear),
  138. this.loadVisualization('histogram', this.forecastYear)
  139. ]);
  140. this.$message.success(`预测生成成功(年份:${this.forecastYear})`);
  141. } catch (generateError) {
  142. this.$message.error(`预测失败:${generateError.message}`);
  143. } finally {
  144. this.isGenerating = false;
  145. }
  146. },
  147. // 检查历史数据是否存在
  148. async checkExistingData() {
  149. await Promise.all([
  150. this.loadVisualization('map', this.forecastYear),
  151. this.loadVisualization('histogram', this.forecastYear)
  152. ]);
  153. },
  154. // 生成新预测
  155. async generateNewPrediction() {
  156. let url = `/api/cd-flux/predict-future-cd?years=${encodeURIComponent(
  157. this.forecastYear
  158. )}`;
  159. if (this.countyName) {
  160. url += `&area=${encodeURIComponent(this.countyName)}`;
  161. }
  162. if (this.generateVisualization) {
  163. url += '&generate_visualization=true';
  164. }
  165. const response = await api8000.get(url, { responseType: 'json' });
  166. if (!response.data.success) {
  167. throw new Error(response.data.message || '生成预测失败');
  168. }
  169. },
  170. // 加载可视化数据
  171. async loadVisualization(type, year) {
  172. try {
  173. const response = await api8000.get(
  174. `/api/cd-flux/predict-future-cd/${type}/${year}`,
  175. { responseType: 'blob' }
  176. );
  177. if (type === 'map') {
  178. this.mapBlob = response.data;
  179. this.mapImageUrl = URL.createObjectURL(this.mapBlob);
  180. } else {
  181. this.histogramBlob = response.data;
  182. this.histogramImageUrl = URL.createObjectURL(this.histogramBlob);
  183. }
  184. } catch (error) {
  185. // 显式抛出错误供外部捕获
  186. throw new Error(`加载${type}失败: ${error.message}`);
  187. }
  188. },
  189. }
  190. };
  191. </script>
  192. <style scoped>
  193. /* 自定义覆盖样式 */
  194. .container {
  195. padding: 20px;
  196. background: linear-gradient(
  197. 135deg,
  198. rgba(230, 247, 255, 0.7) 0%,
  199. rgba(240, 248, 255, 0.7) 100%
  200. );
  201. min-height: 100vh;
  202. box-sizing: border-box;
  203. }
  204. /* 结果展示区 - 垂直排列 */
  205. .content-area {
  206. display: flex;
  207. flex-direction: column;
  208. gap: 25px;
  209. margin-top: 15px;
  210. }
  211. /* 可视化区域通用样式 */
  212. .visualization-section {
  213. background-color: rgba(255, 255, 255, 0.85);
  214. border-radius: 10px;
  215. padding: 20px;
  216. box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
  217. position: relative;
  218. backdrop-filter: blur(5px);
  219. overflow: hidden;
  220. transition: all 0.3s ease;
  221. border: 1px solid rgba(100, 180, 255, 0.2);
  222. }
  223. .visualization-section:hover {
  224. box-shadow: 0 6px 20px rgba(0, 100, 200, 0.12);
  225. transform: translateY(-3px);
  226. }
  227. .visualization-section h3 {
  228. margin-top: 0;
  229. margin-bottom: 15px;
  230. color: #2c3e50;
  231. font-size: 1.3rem;
  232. padding-bottom: 10px;
  233. border-bottom: 1px solid #eaeaea;
  234. display: flex;
  235. align-items: center;
  236. }
  237. .visualization-section h3 i {
  238. margin-right: 8px;
  239. color: #47C3B9;
  240. }
  241. /* 图片容器样式 */
  242. .image-container {
  243. position: relative;
  244. min-height: 350px;
  245. border-radius: 6px;
  246. overflow: hidden;
  247. background-color: #f8fafc;
  248. border: 1px dashed #cbd5e0;
  249. display: flex;
  250. align-items: center;
  251. justify-content: center;
  252. }
  253. .result-img {
  254. width: 100%;
  255. max-height: 500px;
  256. object-fit: contain;
  257. border-radius: 4px;
  258. transition: opacity 0.3s;
  259. }
  260. /* 加载状态样式 */
  261. .loading-container {
  262. display: flex;
  263. flex-direction: column;
  264. align-items: center;
  265. justify-content: center;
  266. height: 100%;
  267. color: #4a5568;
  268. }
  269. .loading-icon {
  270. font-size: 2rem;
  271. margin-bottom: 10px;
  272. color: #47C3B9;
  273. animation: spin 1.5s linear infinite;
  274. }
  275. @keyframes spin {
  276. 0% { transform: rotate(0deg); }
  277. 100% { transform: rotate(360deg); }
  278. }
  279. /* 无数据状态样式 */
  280. .no-data {
  281. display: flex;
  282. flex-direction: column;
  283. align-items: center;
  284. justify-content: center;
  285. height: 100%;
  286. color: #a0aec0;
  287. text-align: center;
  288. padding: 20px;
  289. }
  290. .no-data p {
  291. margin-top: 10px;
  292. font-size: 1.1rem;
  293. }
  294. /* 工具栏样式增强 */
  295. .toolbar {
  296. display: flex;
  297. flex-direction: column;
  298. gap: 15px;
  299. margin-bottom: 20px;
  300. padding: 20px;
  301. background-color: rgba(255, 255, 255, 0.9);
  302. border-radius: 10px;
  303. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  304. backdrop-filter: blur(5px);
  305. border: 1px solid rgba(100, 180, 255, 0.2);
  306. }
  307. .forecast-controls {
  308. display: flex;
  309. flex-wrap: wrap;
  310. gap: 15px;
  311. align-items: center;
  312. }
  313. .year-selector {
  314. display: flex;
  315. flex-wrap: wrap;
  316. gap: 15px;
  317. align-items: center;
  318. flex: 1;
  319. }
  320. .custom-input {
  321. flex: 1;
  322. min-width: 200px;
  323. }
  324. .custom-input.is-invalid {
  325. border-color: #fc8181;
  326. }
  327. .custom-button {
  328. background: linear-gradient(135deg, #47C3B9 0%, #3ba0a0 100%) !important;
  329. color: white !important;
  330. border: none;
  331. border-radius: 15px;
  332. padding: 10px 25px;
  333. font-weight: bold;
  334. transition: all 0.3s ease;
  335. box-shadow: 0 4px 6px rgba(71, 195, 185, 0.2);
  336. }
  337. .custom-button:hover:not(:disabled) {
  338. transform: translateY(-2px);
  339. box-shadow: 0 6px 8px rgba(71, 195, 185, 0.3);
  340. }
  341. .custom-button:disabled {
  342. opacity: 0.7;
  343. cursor: not-allowed;
  344. }
  345. /* 响应式设计 */
  346. @media (min-width: 992px) {
  347. .content-area {
  348. flex-direction: row;
  349. flex-wrap: wrap;
  350. }
  351. .visualization-section {
  352. flex: 1;
  353. min-width: calc(50% - 15px);
  354. }
  355. }
  356. @media (max-width: 768px) {
  357. .toolbar {
  358. padding: 15px;
  359. }
  360. .visualization-section {
  361. padding: 15px;
  362. }
  363. .image-container {
  364. min-height: 300px;
  365. }
  366. }
  367. /* 结果提示动画 */
  368. .fade-enter-active, .fade-leave-active {
  369. transition: opacity 0.5s;
  370. }
  371. .fade-enter, .fade-leave-to {
  372. opacity: 0;
  373. }
  374. /* 新增:输入栏标题样式 */
  375. .input-label {
  376. font-size: 14px;
  377. color: #666;
  378. white-space: nowrap; /* 禁止换行,保持标签与输入框对齐 */
  379. margin-right: 8px; /* 与输入框的间距 */
  380. }
  381. /* 新增:限定输入框长度 */
  382. .custom-input {
  383. flex: 1;
  384. min-width: 200px; /* 最小宽度,避免过窄 */
  385. max-width: 320px; /* 最大宽度,限制输入栏长度 */
  386. }
  387. /* 调整按钮与输入框的间距(可选) */
  388. .custom-button {
  389. margin-left: auto; /* 让按钮靠右(若需要) */
  390. /* 原有按钮样式不变 */
  391. }
  392. </style>