alert_detail.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. <template>
  2. <div class="alert-detail-page">
  3. <custom-header :name="pageTitle" />
  4. <div class="alert-detail-body" v-loading="loading">
  5. <!-- 综合研判 -->
  6. <div v-if="analysis.summary || analysis.crop.name" class="section-card">
  7. <div class="section-card__title">综合研判</div>
  8. <div v-if="analysis.summary" class="section-card__desc">{{ analysis.summary }}</div>
  9. <div v-if="analysis.crop.name || analysis.crop.metrics.length" class="crop-panel">
  10. <div class="crop-panel__head">
  11. <img class="crop-panel__cover" :src="analysis.crop.cover" alt="" />
  12. <span class="crop-panel__name">{{ analysis.crop.name }}</span>
  13. <span class="crop-panel__tag">当前作物</span>
  14. </div>
  15. <div v-if="analysis.crop.metrics.length" class="crop-panel__list">
  16. <div
  17. v-for="item in analysis.crop.metrics"
  18. :key="item.label"
  19. class="crop-metric"
  20. >
  21. <img class="crop-metric__icon" :src="item.icon" alt="" />
  22. <div class="crop-metric__content">
  23. <div class="crop-metric__label">{{ item.label }}</div>
  24. <div class="crop-metric__value">{{ item.value }}</div>
  25. </div>
  26. </div>
  27. </div>
  28. </div>
  29. </div>
  30. <!-- 巡园结果与分析 -->
  31. <div v-if="patrolResults.length" class="section-card">
  32. <div class="section-card__title">巡园结果与分析</div>
  33. <div class="patrol-list">
  34. <div
  35. v-for="item in patrolResults"
  36. :key="item.id"
  37. class="patrol-item"
  38. >
  39. <img v-if="item.cover" class="patrol-item__cover" :src="item.cover" alt="" />
  40. <div class="patrol-item__body">
  41. <div class="patrol-item__title">{{ item.title }}</div>
  42. <div class="patrol-item__desc">{{ item.desc }}</div>
  43. </div>
  44. </div>
  45. </div>
  46. </div>
  47. <!-- 诊断研判分析 -->
  48. <div v-if="diagnosisList.length" class="section-card">
  49. <div class="section-card__title">诊断研判分析</div>
  50. <div class="diag-list">
  51. <div
  52. v-for="item in diagnosisList"
  53. :key="item.id"
  54. class="diag-item"
  55. >
  56. <span v-if="item.tag" class="diag-item__tag">{{ item.tag }}</span>
  57. <div v-if="item.title" class="diag-item__title">{{ item.title }}</div>
  58. <div v-if="item.desc" class="diag-item__desc">{{ item.desc }}</div>
  59. </div>
  60. </div>
  61. </div>
  62. <!-- 农事建议 -->
  63. <div v-if="adviceList.length" class="section-card">
  64. <div class="section-card__title">农事建议</div>
  65. <div class="advice-list">
  66. <div
  67. v-for="item in adviceList"
  68. :key="item.id"
  69. class="advice-item"
  70. >
  71. <div class="advice-item__title">{{ item.title }}</div>
  72. <div class="advice-item__desc">{{ item.desc }}</div>
  73. </div>
  74. </div>
  75. </div>
  76. </div>
  77. </div>
  78. </template>
  79. <script setup>
  80. import { computed, onMounted, ref } from "vue";
  81. import { useRoute } from "vue-router";
  82. import customHeader from "@/components/customHeader.vue";
  83. const route = useRoute();
  84. const FALLBACK_QUERY_PARAMS = {
  85. farm_id: "321",
  86. zone_id: "46",
  87. date: "2026-08-11",
  88. };
  89. const DETAIL_TYPE = {
  90. WARNING: "warning",
  91. STRESS: "stress",
  92. };
  93. const phenologyIcon = require("@/assets/img/common/header-icon-3.png");
  94. const weatherIcon = require("@/assets/img/common/header-icon-1.png");
  95. const soilIcon = require("@/assets/img/common/header-icon-2.png");
  96. const cropCover = require("@/assets/img/home/plant.png");
  97. const detailType = computed(() =>
  98. String(route.query.type || "") === DETAIL_TYPE.STRESS
  99. ? DETAIL_TYPE.STRESS
  100. : DETAIL_TYPE.WARNING
  101. );
  102. const detailTitle = ref("");
  103. const pageTitle = computed(
  104. () => detailTitle.value || route.query.title || "具体预警/具体胁迫"
  105. );
  106. const analysis = ref({
  107. summary: "",
  108. crop: {
  109. name: "",
  110. cover: cropCover,
  111. metrics: [],
  112. },
  113. });
  114. const patrolResults = ref([]);
  115. const diagnosisList = ref([]);
  116. const adviceList = ref([]);
  117. const loading = ref(false);
  118. const getSelectedFarm = () => {
  119. try {
  120. return JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
  121. } catch {
  122. return {};
  123. }
  124. };
  125. const formatLocalDate = (date) => {
  126. const y = date.getFullYear();
  127. const m = String(date.getMonth() + 1).padStart(2, "0");
  128. const d = String(date.getDate()).padStart(2, "0");
  129. return `${y}-${m}-${d}`;
  130. };
  131. /** 有选中农场用缓存 farm_id/zone_id + 当天日期,否则用默认参数 */
  132. const getQueryParams = () => {
  133. const farm = getSelectedFarm();
  134. const farmId = farm.farm_id ?? farm.id;
  135. const zoneId = farm.zone_id ?? farm.zoneId;
  136. if (farmId == null || farmId === "" || zoneId == null || zoneId === "") {
  137. return { ...FALLBACK_QUERY_PARAMS };
  138. }
  139. return {
  140. farm_id: farmId,
  141. zone_id: zoneId,
  142. date: formatLocalDate(new Date()),
  143. };
  144. };
  145. /** 接口可能返回对象或数组,统一取第一条 */
  146. const pickFirstRecord = (res) => {
  147. const data = res?.data ?? res;
  148. if (Array.isArray(data)) return data[0] || null;
  149. if (data && typeof data === "object") return data;
  150. return null;
  151. };
  152. const buildMetrics = (record, type) => {
  153. const isStress = type === DETAIL_TYPE.STRESS;
  154. return [
  155. { label: "当前物候", value: record.phenophase_desc || "", icon: phenologyIcon },
  156. {
  157. label: isStress ? "气象胁迫" : "气象预警",
  158. value: (isStress ? record.stress_desc : record.risk_desc) || "",
  159. icon: weatherIcon,
  160. },
  161. { label: "土壤类型", value: record.soil_desc || "", icon: soilIcon },
  162. ].filter((item) => item.value);
  163. };
  164. /** 农事内容按「1. 标题。正文」拆成建议列表 */
  165. const parseFarmworkAdvice = (farmworkInfo) => {
  166. if (!farmworkInfo || typeof farmworkInfo !== "object") return [];
  167. const fallbackTitle = farmworkInfo["农事名称"] || "";
  168. const content = String(farmworkInfo["内容信息"] || "").trim();
  169. if (!content && !fallbackTitle) return [];
  170. const blocks = content
  171. .split(/\n(?=\d+\.\s*)/)
  172. .map((s) => s.trim())
  173. .filter(Boolean);
  174. if (!blocks.length) {
  175. return [{ id: 1, title: fallbackTitle, desc: content }];
  176. }
  177. return blocks.map((block, index) => {
  178. const text = block.replace(/^\d+\.\s*/, "");
  179. const sep = text.indexOf("。");
  180. if (sep > 0 && sep < 40) {
  181. return {
  182. id: index + 1,
  183. title: text.slice(0, sep),
  184. desc: text.slice(sep + 1).trim(),
  185. };
  186. }
  187. return {
  188. id: index + 1,
  189. title: fallbackTitle || `建议${index + 1}`,
  190. desc: text,
  191. };
  192. });
  193. };
  194. const applyRecord = (record, type) => {
  195. if (!record) return;
  196. const isStress = type === DETAIL_TYPE.STRESS;
  197. detailTitle.value = isStress ? record.stress_name || "" : record.risk_name || "";
  198. analysis.value = {
  199. summary: record.interact_explain || "",
  200. crop: {
  201. name: record.crop_name || "",
  202. cover: cropCover,
  203. metrics: buildMetrics(record, type),
  204. },
  205. };
  206. diagnosisList.value = [
  207. {
  208. id: record.id || 1,
  209. tag: (isStress ? record.stress_type : record.risk_type) || "",
  210. title: (isStress ? record.stress_name : record.risk_name) || "",
  211. desc:
  212. (isStress
  213. ? record.importance_desc || record.explain_simple
  214. : record.explain_simple) || "",
  215. },
  216. ].filter((item) => item.title || item.desc);
  217. adviceList.value = parseFarmworkAdvice(record.farmwork_info);
  218. patrolResults.value = [];
  219. };
  220. const fetchDetail = async () => {
  221. const type = detailType.value;
  222. const request =
  223. type === DETAIL_TYPE.STRESS
  224. ? VE_API.questionnaire.getWeatherStress
  225. : VE_API.questionnaire.getWeatherRisk;
  226. loading.value = true;
  227. try {
  228. const res = await request(getQueryParams());
  229. applyRecord(pickFirstRecord(res), type);
  230. } catch {
  231. // 详情页保持空态
  232. } finally {
  233. loading.value = false;
  234. }
  235. };
  236. onMounted(() => {
  237. fetchDetail();
  238. });
  239. </script>
  240. <style lang="scss" scoped>
  241. .alert-detail-page {
  242. min-height: 100vh;
  243. background: #F2F5F6;
  244. }
  245. .alert-detail-body {
  246. padding: 10px 10px 24px;
  247. box-sizing: border-box;
  248. overflow-y: auto;
  249. height: calc(100vh - 44px);
  250. }
  251. .section-card {
  252. margin-bottom: 12px;
  253. padding: 10px;
  254. border-radius: 10px;
  255. background: #fff;
  256. box-sizing: border-box;
  257. &__title {
  258. font-size: 16px;
  259. font-weight: 500;
  260. line-height: 24px;
  261. color: #000;
  262. }
  263. &__desc {
  264. margin-top: 10px;
  265. font-size: 14px;
  266. line-height: 22px;
  267. color: rgba(6, 6, 6, 0.5);
  268. word-break: break-all;
  269. text-align: justify;
  270. font-weight: 350;
  271. white-space: pre-line;
  272. }
  273. }
  274. .crop-panel {
  275. margin-top: 12px;
  276. padding: 10px;
  277. border-radius: 10px;
  278. background: #E7F1FD;
  279. box-sizing: border-box;
  280. &__head {
  281. display: flex;
  282. align-items: center;
  283. gap: 6px;
  284. margin-bottom: 10px;
  285. }
  286. &__cover {
  287. width: 24px;
  288. height: 24px;
  289. border-radius: 50%;
  290. object-fit: cover;
  291. flex-shrink: 0;
  292. background: #fff;
  293. }
  294. &__name {
  295. font-size: 16px;
  296. font-weight: 500;
  297. color: #2199F8;
  298. }
  299. &__tag {
  300. padding: 0 5px;
  301. border-radius: 2px;
  302. background: #2199f8;
  303. backdrop-filter: blur(4px);
  304. color: #fff;
  305. font-size: 12px;
  306. height: 18px;
  307. line-height: 18px;
  308. flex-shrink: 0;
  309. }
  310. &__list {
  311. display: flex;
  312. flex-direction: column;
  313. gap: 10px;
  314. background: #fff;
  315. padding: 10px;
  316. border-radius: 10px;
  317. box-sizing: border-box;
  318. }
  319. }
  320. .crop-metric {
  321. display: flex;
  322. align-items: flex-start;
  323. gap: 6px;
  324. &__icon {
  325. width: 16px;
  326. height: 16px;
  327. flex-shrink: 0;
  328. object-fit: contain;
  329. filter: grayscale(1);
  330. flex: none;
  331. margin-top: 2px;
  332. }
  333. &__content {
  334. display: flex;
  335. align-items: flex-start;
  336. gap: 6px;
  337. flex: 1;
  338. min-width: 0;
  339. font-size: 14px;
  340. line-height: 22px;
  341. word-break: break-all;
  342. }
  343. &__label {
  344. color: #9F9F9F;
  345. flex: none;
  346. }
  347. &__value {
  348. color: #1a1a1a;
  349. }
  350. }
  351. .patrol-list {
  352. margin-top: 12px;
  353. display: flex;
  354. flex-direction: column;
  355. gap: 14px;
  356. }
  357. .patrol-item {
  358. display: flex;
  359. align-items: flex-start;
  360. gap: 10px;
  361. background: #F7F8FA;
  362. padding: 10px;
  363. border-radius: 4px;
  364. box-sizing: border-box;
  365. &__cover {
  366. width: 100px;
  367. height: 86px;
  368. border-radius: 5px;
  369. object-fit: cover;
  370. flex-shrink: 0;
  371. background: #f5f5f5;
  372. }
  373. &__body {
  374. flex: 1;
  375. min-width: 0;
  376. }
  377. &__title {
  378. font-size: 14px;
  379. font-weight: 500;
  380. line-height: 22px;
  381. color: #1D2129;
  382. }
  383. &__desc {
  384. margin-top: 4px;
  385. font-size: 12px;
  386. line-height: 20px;
  387. color: #4E5969;
  388. word-break: break-all;
  389. text-align: justify;
  390. }
  391. }
  392. .diag-list,
  393. .advice-list {
  394. margin-top: 10px;
  395. display: flex;
  396. flex-direction: column;
  397. gap: 10px;
  398. }
  399. .diag-item,
  400. .advice-item {
  401. padding: 10px;
  402. border-radius: 4px;
  403. background: #F7F8FA;
  404. box-sizing: border-box;
  405. }
  406. .diag-item {
  407. &__tag {
  408. display: inline-block;
  409. padding: 0 8px;
  410. height: 22px;
  411. line-height: 22px;
  412. border-radius: 2px;
  413. background: #2199f8;
  414. color: #fff;
  415. font-size: 12px;
  416. }
  417. &__title {
  418. margin-top: 4px;
  419. font-size: 14px;
  420. font-weight: 500;
  421. line-height: 22px;
  422. color: #1D2129;
  423. }
  424. &__desc {
  425. margin-top: 4px;
  426. font-size: 12px;
  427. line-height: 20px;
  428. color: #4E5969;
  429. word-break: break-all;
  430. }
  431. }
  432. .advice-item {
  433. &__title {
  434. font-size: 14px;
  435. font-weight: 500;
  436. line-height: 22px;
  437. color: #1D2129;
  438. }
  439. &__desc {
  440. margin-top: 4px;
  441. font-size: 12px;
  442. line-height: 20px;
  443. color: #4E5969;
  444. word-break: break-all;
  445. white-space: pre-line;
  446. }
  447. }
  448. </style>