index.vue 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652
  1. <template>
  2. <div class="agri-file-page" :style="{ height: `calc(100vh - ${tabBarHeight}px)` }">
  3. <farm-header />
  4. <div class="nav-cards">
  5. <div v-for="item in navCards" :key="item.key" class="nav-card-wrap"
  6. :class="{ 'nav-card-wrap--report': item.key === 'report' }">
  7. <div class="nav-card" :class="{ 'is-active': activeNav === item.key }" @click="handleNavClick(item)">
  8. <img class="nav-card__icon" :src="item.icon" alt="" />
  9. <span class="nav-card__label">{{ t(item.labelKey) }}</span>
  10. </div>
  11. <!-- <div v-if="item.key === 'report' && guideVisible" class="report-guide-bubble">
  12. <span class="report-guide-bubble__arrow"></span>
  13. <div class="report-guide-bubble__content">
  14. {{ t("agriFile.diagnosisReportGuidePrefix") }}
  15. <span class="report-guide-bubble__link" @click.stop="handleGuideUpload">
  16. {{ t("agriFile.uploadPhoto") }}
  17. </span>
  18. {{ t("agriFile.diagnosisReportGuideSuffix") }}
  19. </div>
  20. </div> -->
  21. </div>
  22. </div>
  23. <div class="content-panel">
  24. <div v-show="activeNav === 'patrol'">
  25. <div v-for="(item, index) in patrolList" :key="index" class="tracking-item tracking-item--blue"
  26. @click="goGrowthTrack(item)">
  27. <div class="tracking-item__header">
  28. <div class="tracking-item__header-row">
  29. <div class="tracking-item__header-left">
  30. <span class="tracking-item__level">
  31. <el-icon>
  32. <BellFilled />
  33. </el-icon>
  34. {{ t("agriFile.riskLevel2") }}
  35. </span>
  36. <span class="tracking-item__title van-ellipsis">
  37. {{
  38. item.type === "abnormal"
  39. ? t("agriFile.patrolAbnormalTitle")
  40. : t("agriFile.patrolGrowthTitle")
  41. }}
  42. <span v-if="item.crop_category_name" class="tracking-item__zone">
  43. ({{ item.crop_category_name }})
  44. </span>
  45. <span v-else-if="item.zone" class="tracking-item__zone">
  46. ({{ item.zone }})
  47. </span>
  48. </span>
  49. </div>
  50. </div>
  51. </div>
  52. <div class="tracking-item__body">
  53. <div class="tracking-item__action">
  54. <div class="tracking-item__action-main">
  55. <div class="tracking-item__icon">
  56. <img :src="patrolIcon" alt="" />
  57. <span class="tracking-item__reason">{{ item.interaction_title || item.interaction_problem }}</span>
  58. </div>
  59. <div class="tracking-item__issue">{{ item.interaction_reason }}</div>
  60. </div>
  61. </div>
  62. </div>
  63. </div>
  64. <div class="crop-archive">
  65. <div class="crop-archive__title">{{ t("agriFile.cropArchive") }}</div>
  66. <PhenologyTrackTimelineItem :list="cropArchiveList" />
  67. </div>
  68. </div>
  69. <div v-show="activeNav === 'album'">
  70. <div class="map-card" @click="goAlbumMap">
  71. <div ref="mapContainer" class="map-container"></div>
  72. <!-- <div v-if="noFarmRange" class="map-mask" @click.stop="goAddZone('farm')">
  73. <div class="mask-content">
  74. <div class="mask-content__title">勾画种植区域,关联作物照片</div>
  75. <div class="mask-content__btn">去勾选</div>
  76. </div>
  77. </div> -->
  78. <!-- <div v-else class="add-zone-btn" @click.stop="goAddZone('region')">
  79. <el-icon>
  80. <Plus />
  81. </el-icon>
  82. <span>{{ t("agriFile.addZone") }}</span>
  83. </div> -->
  84. <!-- 地图标记由 Overlay 托管 DOM,勿用 Vue v-for 渲染,否则 KeepAlive 更新会报 __vnode -->
  85. </div>
  86. <div class="album-grid" v-if="!noFarmRange">
  87. <!-- <div class="album-add" @click.stop="goAddZone('region')">
  88. <el-icon class="album-add__icon">
  89. <Plus />
  90. </el-icon>
  91. <span>{{ t("agriFile.addAlbum") }}</span>
  92. </div> -->
  93. <div v-for="item in albumList" :key="item.id" class="album-card"
  94. :class="{ 'is-empty': !item.count }">
  95. <div class="album-card__cover" @click.stop="goAlbumDetail(item)">
  96. <img v-if="item.count && item.cover" :src="item.cover" alt="" />
  97. <img v-else class="album-card__empty-icon" src="@/assets/img/agricultural/photo.png"
  98. alt="" />
  99. <span class="album-card__count">{{ t("agriFile.photoCount", { count: item.count }) }}</span>
  100. <!-- <div class="album-card__add" @click.stop="openUploadPopup">
  101. <el-icon>
  102. <Plus />
  103. </el-icon>
  104. </div> -->
  105. </div>
  106. <div class="album-card__name">{{ item.name }}</div>
  107. </div>
  108. </div>
  109. </div>
  110. <div v-show="activeNav === 'report'">
  111. <div v-for="item in reportList" :key="item.id" class="report-card" @click="goReportDetail(item)">
  112. <div class="report-card__cover">
  113. <img :src="item.cover" alt="" />
  114. <!-- <span class="report-card__date">{{ item.date }}</span> -->
  115. <!-- <span class="report-card__status">
  116. <i class="report-card__dot"></i>
  117. {{ t("agriFile.reportUpdate") }}
  118. </span> -->
  119. </div>
  120. <div class="report-card__body">
  121. <div class="report-card__head">
  122. <div class="report-card__title">{{ item.title }}</div>
  123. <div v-if="item.date" class="report-card__day">{{ item.date }}</div>
  124. </div>
  125. <div class="report-card__desc">{{ item.desc }}</div>
  126. </div>
  127. </div>
  128. <!-- 气象报告:weather_report_list -->
  129. <div
  130. v-for="item in weatherReportList"
  131. :key="item.id"
  132. class="report-card"
  133. @click="goReportDetail(item)"
  134. >
  135. <div class="report-card__cover">
  136. <img :src="item.cover" alt="" />
  137. </div>
  138. <div class="report-card__body">
  139. <div class="report-card__head">
  140. <div class="report-card__title">{{ item.title }}</div>
  141. <div v-if="item.date" class="report-card__day">{{ item.date }}</div>
  142. </div>
  143. <div class="report-card__desc">{{ item.desc }}</div>
  144. </div>
  145. </div>
  146. </div>
  147. </div>
  148. <album-upload-popup v-model:show="showUploadPopup" />
  149. <complete-farm-info-popup v-model:show="showCompleteFarmPopup" @confirm="goCompleteFarmInfo" />
  150. <!-- 诊断报告弹窗 -->
  151. <diagnosis-report-popup ref="diagnosisReportPopupRef" />
  152. <!-- 恢复种植弹窗:确认已到达后触发诊断报告弹窗检查 -->
  153. <restore-planting-popup @phenology-reached="onPhenologyReached" />
  154. <!-- 代管弹窗:后续由接口控制是否展示 -->
  155. <proxy-auth-popup v-model:show="showProxyAuthPopup" :service-name="proxyServiceName" @reject="handleProxyReject"
  156. @allow="handleProxyAllow" />
  157. <!-- 申请更换物候期弹窗:后续由接口控制是否展示 -->
  158. <phenology-update-popup v-model:show="showPhenologyUpdatePopup" :service-name="phenologyUpdateInfo.serviceName"
  159. :current-stage="phenologyUpdateInfo.currentStage" :target-stage="phenologyUpdateInfo.targetStage"
  160. :current-image="phenologyUpdateInfo.currentImage" :farm-location="phenologyUpdateInfo.farmLocation"
  161. @reject="handlePhenologyUpdateReject" @confirm="handlePhenologyUpdateConfirm" />
  162. </div>
  163. </template>
  164. <script setup>
  165. import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from "vue";
  166. import { useStore } from "vuex";
  167. import { useRouter } from "vue-router";
  168. import Overlay from "ol/Overlay";
  169. import { useI18n } from "@/i18n";
  170. import { Icon } from 'vant';
  171. import FileMap from "./fileMap";
  172. import * as util from "@/common/ol_common.js";
  173. import farmHeader from "@/components/farmHeader.vue";
  174. import albumUploadPopup from "./components/albumUploadPopup.vue";
  175. import completeFarmInfoPopup from "@/components/popup/completeFarmInfoPopup.vue";
  176. import diagnosisReportPopup from "@/components/popup/diagnosisReportPopup.vue";
  177. import restorePlantingPopup from "@/components/popup/restorePlantingPopup.vue";
  178. import proxyAuthPopup from "@/components/popup/proxyAuthPopup.vue";
  179. import phenologyUpdatePopup from "@/components/popup/phenologyUpdatePopup.vue";
  180. import PhenologyTrackTimelineItem from "@/components/pageComponents/PhenologyTrackTimelineItem.vue";
  181. import eventBus from "@/api/eventBus";
  182. import wx from "weixin-js-sdk";
  183. import { base_img_url2 } from "@/api/config";
  184. const { t } = useI18n();
  185. const store = useStore();
  186. const router = useRouter();
  187. const tabBarHeight = computed(() => store.state.home.tabBarHeight);
  188. const diagnosisReportPopupRef = ref(null);
  189. /** 与 growthTrack 异常巡检约定:成功后刷新列表(弹窗已由 App 全局承接) */
  190. const INSPECT_SUCCESS_EVENT = "inspect-upload-success";
  191. function onInspectSuccess() {
  192. fetchReportList();
  193. fetchWeatherReportList();
  194. fetchCropArchiveList();
  195. }
  196. /** 恢复种植确认「已到达」后,再检查是否弹出诊断报告 */
  197. const onPhenologyReached = () => {
  198. diagnosisReportPopupRef.value?.checkShouldShow?.();
  199. };
  200. const DIAGNOSIS_GUIDE_STORAGE_KEY = "AGRI_FILE_DIAGNOSIS_REPORT_GUIDE";
  201. const ENTRY_FARM_DATA_KEY = "ENTRY_FARM_DATA";
  202. const CROP_ARCHIVE_KEY = "CROP_ARCHIVE_RECORDS";
  203. /** crop_question 物候配图,诊断报告封面复用 */
  204. const CROP_QUESTION_PIC_KEY = "CROP_QUESTION_PIC_URL";
  205. const guideVisible = ref(false);
  206. const DEFAULT_CENTER = [113.6142086995688, 23.585836479509055];
  207. const activeNav = ref("patrol");
  208. const mapContainer = ref(null);
  209. const fileMap = new FileMap();
  210. const mapOverlays = [];
  211. const noFarmRange = ref(false);
  212. const navCards = [
  213. {
  214. key: "patrol",
  215. labelKey: "agriFile.patrolRecord",
  216. icon: require("@/assets/img/agricultural/icon-1.png"),
  217. },
  218. {
  219. key: "album",
  220. labelKey: "agriFile.agriAlbum",
  221. icon: require("@/assets/img/agricultural/icon-2.png"),
  222. },
  223. {
  224. key: "report",
  225. labelKey: "agriFile.diagnosisReport",
  226. icon: require("@/assets/img/agricultural/icon-3.png"),
  227. },
  228. ];
  229. /** 读取用户新增农场时选中的点位 */
  230. function readEntryFarmCoordinate() {
  231. try {
  232. const data = JSON.parse(localStorage.getItem(ENTRY_FARM_DATA_KEY) || "null");
  233. if (Array.isArray(data?.coordinate) && data.coordinate.length === 2) {
  234. return [Number(data.coordinate[0]), Number(data.coordinate[1])];
  235. }
  236. const point =
  237. data?.point ||
  238. localStorage.getItem("GROWTH_REPORT_MAP_POINT") ||
  239. localStorage.getItem("selectedFarmPoint");
  240. if (typeof point === "string" && /^POINT\s*\(/i.test(point.trim())) {
  241. return util.wktCastGeom(point).getFirstCoordinate();
  242. }
  243. } catch {
  244. // ignore
  245. }
  246. return null;
  247. }
  248. function readEntryFarmData() {
  249. try {
  250. return JSON.parse(localStorage.getItem(ENTRY_FARM_DATA_KEY) || "null");
  251. } catch {
  252. return null;
  253. }
  254. }
  255. /** 用户录入时选择的作物品类名 */
  256. function resolveSelectedCropName() {
  257. return readEntryFarmData()?.crop || "";
  258. }
  259. const zoneList = ref([]);
  260. const albumList = ref([]);
  261. function resolveCropQuestionImageUrl(path) {
  262. if (!path) return "";
  263. if (/^https?:\/\//i.test(path)) return path;
  264. return base_img_url2 + String(path).replace(/^\//, "");
  265. }
  266. function readStoredCropQuestionPic() {
  267. return localStorage.getItem(CROP_QUESTION_PIC_KEY) || "";
  268. }
  269. /** 拉取 crop_question 配图并缓存,诊断报告封面复用 */
  270. async function fetchCropQuestionAlbumPic() {
  271. const entry = readEntryFarmData();
  272. const crop = entry?.crop;
  273. const code = entry?.cropCode;
  274. if (!crop || !code) return;
  275. try {
  276. const res = await VE_API.questionnaire.phenologyProblem({
  277. crop_type: crop,
  278. phenophase_code: String(code),
  279. code: String(code),
  280. });
  281. if (res?.code !== 200 || !res.data) return;
  282. const url = resolveCropQuestionImageUrl(
  283. res.data.phenophase_pic_url || res.data.interact_pic_url
  284. );
  285. if (url) localStorage.setItem(CROP_QUESTION_PIC_KEY, url);
  286. } catch {
  287. // ignore
  288. }
  289. }
  290. function resolveGroupPoint(group) {
  291. const lng = Number(group?.longitude);
  292. const lat = Number(group?.latitude);
  293. if (Number.isFinite(lng) && Number.isFinite(lat)) return [lng, lat];
  294. return readEntryFarmCoordinate() || DEFAULT_CENTER;
  295. }
  296. function buildEmptyAlbum() {
  297. const coordinate = readEntryFarmCoordinate() || DEFAULT_CENTER;
  298. return {
  299. id: "album-empty",
  300. name: resolveSelectedCropName(),
  301. count: 0,
  302. cover: "",
  303. longitude: Number(coordinate[0]),
  304. latitude: Number(coordinate[1]),
  305. };
  306. }
  307. /** 组内展示名:优先分区名,否则用户品类 */
  308. function resolveGroupAlbumName(images, cropName) {
  309. const zoneName = String(images.find((img) => img?.zone_name)?.zone_name || "").trim();
  310. return zoneName || cropName || "";
  311. }
  312. /**
  313. * query_tmp_image:data.groups 为按点位分组的二维结构
  314. * groups[i].images[j] → cloud_url / zone_name;每组自带经纬度与 image_count
  315. */
  316. function mapTmpImageAlbum(data) {
  317. const cropName = resolveSelectedCropName();
  318. const rawGroups = Array.isArray(data?.groups) ? data.groups : [];
  319. const albums = [];
  320. const zones = [];
  321. rawGroups.forEach((group, index) => {
  322. const images = Array.isArray(group?.images)
  323. ? group.images.filter((img) => img?.cloud_url)
  324. : [];
  325. if (!images.length) return;
  326. const [lng, lat] = resolveGroupPoint(group);
  327. const name = resolveGroupAlbumName(images, cropName);
  328. const count = Number(group.image_count) || images.length;
  329. const cover = images[0].cloud_url;
  330. const id = `${images[0]?.farm_id || "album"}-${index}`;
  331. const item = {
  332. id,
  333. groupIndex: index,
  334. name,
  335. count,
  336. cover,
  337. longitude: lng,
  338. latitude: lat,
  339. };
  340. albums.push(item);
  341. zones.push({ ...item, id: `album-point-${index}` });
  342. });
  343. if (!albums.length) {
  344. return { albums: [buildEmptyAlbum()], zones: [] };
  345. }
  346. return { albums, zones };
  347. }
  348. let tmpAlbumTask = null;
  349. /** 农情相册:query_tmp_image,手机号与诊断报告同一来源 */
  350. async function fetchTmpAlbumImages() {
  351. if (tmpAlbumTask) return tmpAlbumTask;
  352. tmpAlbumTask = (async () => {
  353. const tel = resolveUserTel();
  354. let data = null;
  355. if (tel) {
  356. try {
  357. data = unwrapApiData(await VE_API.questionnaire.queryTmpImage({ tel }));
  358. } catch {
  359. data = null;
  360. }
  361. }
  362. const mapped = mapTmpImageAlbum(data);
  363. albumList.value = mapped.albums;
  364. zoneList.value = mapped.zones;
  365. })().finally(() => {
  366. tmpAlbumTask = null;
  367. });
  368. return tmpAlbumTask;
  369. }
  370. const reportCover = require("@/assets/img/common/sd-1.jpg");
  371. /** 诊断报告列表:风险报告 / 胁迫报告 / 异常农情研判报告 */
  372. const reportList = ref([]);
  373. /** 气象报告列表:weather_report_list */
  374. const weatherReportList = ref([]);
  375. function resolveReportCover() {
  376. return readStoredCropQuestionPic() || reportCover;
  377. }
  378. function resolveUserTel() {
  379. const entry = readEntryFarmData();
  380. if (entry?.phone) return String(entry.phone).trim();
  381. try {
  382. const userInfo = JSON.parse(localStorage.getItem("localUserInfo") || "{}");
  383. return String(userInfo.tel || userInfo.phone || userInfo.mobile || "").trim();
  384. } catch {
  385. return "";
  386. }
  387. }
  388. function unwrapApiData(raw) {
  389. const res = Array.isArray(raw) ? raw[0] : raw;
  390. if (Number(res?.code) !== 200 || !res.data) return null;
  391. return Array.isArray(res.data) ? res.data[0] : res.data;
  392. }
  393. /**
  394. * risk_report / stress_report:兼容
  395. * - 新:{ current_time, reports: [...] }
  396. * - 旧:data 为报告对象或数组
  397. */
  398. function unwrapRiskStressEnvelope(raw) {
  399. const res = Array.isArray(raw) ? raw[0] : raw;
  400. if (Number(res?.code) !== 200 || !res.data) {
  401. return { report: null, currentTime: "" };
  402. }
  403. const data = res.data;
  404. if (Array.isArray(data)) {
  405. return { report: data[0] || null, currentTime: "" };
  406. }
  407. if (Array.isArray(data.reports)) {
  408. return {
  409. report: data.reports[0] || null,
  410. currentTime: data.current_time || "",
  411. };
  412. }
  413. return {
  414. report: data,
  415. currentTime: data.current_time || "",
  416. };
  417. }
  418. function pickReportDate(data) {
  419. return data?.create_time || data?.update_time || data?.date || data?.report_date || "";
  420. }
  421. /** 日期字段 → 仅年月日;兼容 2026-09-11 / Wed, 23 Sep 2026 09:42:00 GMT */
  422. function formatReportDay(value) {
  423. if (!value) return "";
  424. const text = String(value).trim();
  425. const matched = text.match(/(\d{4})-(\d{2})-(\d{2})/);
  426. if (matched) return `${matched[1]}-${matched[2]}-${matched[3]}`;
  427. const d = new Date(text);
  428. if (Number.isNaN(d.getTime())) return "";
  429. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  430. }
  431. /** 异常研判报告:取 summary 首段正文作卡片简介 */
  432. function pickAbnormalReportDesc(summary) {
  433. const text = String(summary || "").trim();
  434. if (!text) return "";
  435. return (
  436. text
  437. .split(/\n+/)
  438. .map((line) => line.trim())
  439. .find((line) => line && !/^[一二三四五六七八九十]+[、..]/.test(line)) || text
  440. );
  441. }
  442. /** 拼装诊断报告 tab:risk_report / stress_report / query_abnormal_report */
  443. async function fetchReportList() {
  444. const tel = resolveUserTel();
  445. const cover = resolveReportCover();
  446. const entry = readEntryFarmData();
  447. const empty = (id) => ({ id, title: "", desc: "", date: "", cover });
  448. if (!tel) {
  449. reportList.value = [empty(1), empty(2), empty(3)];
  450. return;
  451. }
  452. const [riskRaw, stressRaw, abnormalRaw] = await Promise.all([
  453. VE_API.questionnaire.riskReport({ tel }).catch(() => null),
  454. VE_API.questionnaire.stressReport({ tel }).catch(() => null),
  455. VE_API.questionnaire.queryAbnormalReport({ tel }).catch(() => null),
  456. ]);
  457. const { report: riskData, currentTime: riskTime } = unwrapRiskStressEnvelope(riskRaw);
  458. const { report: stressData, currentTime: stressTime } = unwrapRiskStressEnvelope(stressRaw);
  459. // query_abnormal_report:data.reports / data.report 均为报告数组
  460. const abnormalRes = Array.isArray(abnormalRaw) ? abnormalRaw[0] : abnormalRaw;
  461. const abnormalRawList =
  462. Number(abnormalRes?.code) === 200
  463. ? abnormalRes?.data?.reports ?? abnormalRes?.data?.report
  464. : null;
  465. const abnormalReports = Array.isArray(abnormalRawList)
  466. ? abnormalRawList
  467. : abnormalRawList && typeof abnormalRawList === "object"
  468. ? [abnormalRawList]
  469. : [];
  470. const baseList = [
  471. {
  472. id: 1,
  473. type: "risk",
  474. title: riskData
  475. ? `未来预警: ${riskData.risk_name || "预警"}`
  476. : "",
  477. // 卡片简介:explain_simple
  478. desc: riskData?.explain_simple || "",
  479. // 日期:外层 current_time
  480. date: formatReportDay(riskTime),
  481. cover,
  482. },
  483. {
  484. id: 2,
  485. type: "stress",
  486. title: stressData
  487. ? `当下胁迫: ${stressData.stress_name || "胁迫"}`
  488. : "",
  489. // 卡片简介:explain_simple
  490. desc: stressData?.explain_simple || "",
  491. // 日期:外层 current_time
  492. date: formatReportDay(stressTime),
  493. cover,
  494. },
  495. ].filter((item) => item.title || item.desc);
  496. const abnormalList = abnormalReports
  497. .filter((item) => item && (item.id != null || item.analysis || item.explain_simple))
  498. .map((item) => ({
  499. id: `abnormal-${item.id}`,
  500. reportId: item.id,
  501. type: "abnormal_report",
  502. title: item.title || "异常农情诊断报告",
  503. // 卡片简介:analysis[].desc
  504. desc: item.explain_simple || "",
  505. // cover_url 相对路径需拼 base_img_url2
  506. cover: resolveCropQuestionImageUrl(item.cover_url) || cover,
  507. // 异常报告日期:calc_date → 年月日
  508. date: formatReportDay(item.calc_date),
  509. }));
  510. reportList.value = [...baseList, ...abnormalList];
  511. }
  512. /** 气象报告列表:weather_report_list → 卡片展示在诊断报告下方 */
  513. async function fetchWeatherReportList() {
  514. const cover = resolveReportCover();
  515. try {
  516. const raw = await VE_API.questionnaire.weatherReportList({}).catch(() => null);
  517. const res = Array.isArray(raw) ? raw[0] : raw;
  518. if (Number(res?.code) !== 200) {
  519. weatherReportList.value = [];
  520. return;
  521. }
  522. const reports = Array.isArray(res?.data?.reports) ? res.data.reports : [];
  523. weatherReportList.value = reports
  524. .filter((item) => item && (item.id != null || item.explain_simple || item.risk_name))
  525. .map((item) => ({
  526. id: `weather-${item.id}`,
  527. reportId: item.id,
  528. type: "weather",
  529. title: item.risk_name
  530. ? `历史气象报告: ${item.risk_name}`
  531. : item.crop_name
  532. ? `历史气象报告: ${item.crop_name}`
  533. : "历史气象报告",
  534. desc: item.explain_simple || item.interact_explain || "",
  535. date: formatReportDay(item.report_date),
  536. // cover_url 相对路径需拼 base_img_url2
  537. cover: resolveCropQuestionImageUrl(item.cover_url) || cover,
  538. }));
  539. } catch {
  540. weatherReportList.value = [];
  541. }
  542. }
  543. const showUploadPopup = ref(false);
  544. const showCompleteFarmPopup = ref(false);
  545. // 后续由接口控制是否展示代管授权弹窗
  546. const showProxyAuthPopup = ref(false);
  547. const proxyServiceName = ref("农服名称");
  548. const handleProxyReject = () => {
  549. // TODO: 调用拒绝代管接口
  550. };
  551. const handleProxyAllow = () => {
  552. // TODO: 调用允许代管接口
  553. };
  554. // 后续由接口控制是否展示申请更换物候期弹窗
  555. const showPhenologyUpdatePopup = ref(false);
  556. const phenologyUpdateInfo = ref({
  557. serviceName: "某某农服",
  558. currentStage: "花穗期",
  559. targetStage: "小果期",
  560. varietyName: "荔枝-桂味",
  561. farmLocation: "位置位置位置",
  562. });
  563. const handlePhenologyUpdateReject = () => {
  564. // TODO: 调用暂不更新接口
  565. };
  566. const handlePhenologyUpdateConfirm = () => {
  567. // TODO: 调用确认更新接口
  568. };
  569. const patrolIcon = require("@/assets/img/report/wh-icon.png");
  570. /** 巡园要点列表:接口 data 原样 push,字段在模板自行对接 */
  571. const patrolList = ref([]);
  572. const cropArchiveList = ref([]);
  573. // function resolveUserTel() {
  574. // try {
  575. // const entry = JSON.parse(localStorage.getItem(ENTRY_FARM_DATA_KEY) || "null");
  576. // if (entry?.phone) return String(entry.phone).trim();
  577. // } catch {
  578. // // ignore
  579. // }
  580. // try {
  581. // const userInfo = JSON.parse(localStorage.getItem("localUserInfo") || "{}");
  582. // return String(userInfo.tel || userInfo.phone || userInfo.mobile || "").trim();
  583. // } catch {
  584. // return "";
  585. // }
  586. // }
  587. function formatPhenophaseDate(value) {
  588. if (!value) return "";
  589. const text = String(value).trim();
  590. const iso = text.match(/(\d{4})-(\d{2})-(\d{2})/);
  591. if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
  592. const d = new Date(text);
  593. if (Number.isNaN(d.getTime())) return "";
  594. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  595. }
  596. /** get_phenophase_info → 作物档案时间轴 */
  597. function mapPhenophaseInfoToArchive(list) {
  598. const cropName = resolveSelectedCropName();
  599. const rows = Array.isArray(list) ? list : [];
  600. const filtered = cropName
  601. ? rows.filter((item) => !item?.crop_category_name || item.crop_category_name === cropName)
  602. : rows;
  603. const source = filtered.length ? filtered : rows;
  604. return source
  605. .map((item, index) => ({
  606. id: item?.phenophase_code
  607. ? `${item.phenophase_code}-${item.start_time || index}`
  608. : `pheno-${index}`,
  609. date: formatPhenophaseDate(item?.start_time) || formatPhenophaseDate(item?.sentence),
  610. zone_name: item?.crop_category_name || cropName || "",
  611. content: item?.sentence || item?.phenophase_name || "",
  612. phenophase_code: item?.phenophase_code,
  613. phenophase_name: item?.phenophase_name,
  614. }))
  615. .filter((item) => item.date && item.content)
  616. .sort((a, b) => String(b.date).localeCompare(String(a.date)));
  617. }
  618. function readCropArchiveAnswers() {
  619. try {
  620. const list = JSON.parse(localStorage.getItem(CROP_ARCHIVE_KEY) || "[]");
  621. return Array.isArray(list) ? list : [];
  622. } catch {
  623. return [];
  624. }
  625. }
  626. async function fetchCropArchiveList() {
  627. const tel = resolveUserTel();
  628. if (!tel) {
  629. cropArchiveList.value = readCropArchiveAnswers();
  630. return;
  631. }
  632. try {
  633. const res = await VE_API.questionnaire.getPhenophaseInfo({ tel });
  634. if (res?.code !== 200) {
  635. cropArchiveList.value = readCropArchiveAnswers();
  636. return;
  637. }
  638. const apiList = mapPhenophaseInfoToArchive(res.data);
  639. cropArchiveList.value = [...readCropArchiveAnswers(), ...apiList];
  640. } catch {
  641. cropArchiveList.value = readCropArchiveAnswers();
  642. }
  643. }
  644. const getFarmCenter = () => {
  645. const entryCoordinate = readEntryFarmCoordinate();
  646. if (entryCoordinate) return entryCoordinate;
  647. try {
  648. const farm = JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
  649. const wkt = farm.wkt || farm.geom_wkt || farm.farm_location;
  650. if (typeof wkt === "string" && /^POINT\s*\(/i.test(wkt.trim())) {
  651. return util.wktCastGeom(wkt).getFirstCoordinate();
  652. }
  653. } catch {
  654. // ignore
  655. }
  656. return DEFAULT_CENTER;
  657. };
  658. /** 手动创建标记节点,避免 OL Overlay 挪走 Vue vnode 对应的 DOM */
  659. function createZoneMarkerEl(zone) {
  660. const root = document.createElement("div");
  661. root.className = "zone-marker";
  662. const label = document.createElement("div");
  663. label.className = "zone-marker__label";
  664. label.textContent = zone.name || "";
  665. const thumb = document.createElement("div");
  666. thumb.className = "zone-marker__thumb";
  667. const img = document.createElement("img");
  668. img.src = zone.cover || "";
  669. img.alt = "";
  670. const badge = document.createElement("span");
  671. badge.className = "zone-marker__badge";
  672. badge.textContent = String(zone.count ?? 0);
  673. thumb.append(img, badge);
  674. const arrow = document.createElement("div");
  675. arrow.className = "zone-marker__arrow";
  676. root.append(label, thumb, arrow);
  677. return root;
  678. }
  679. const clearMapOverlays = () => {
  680. if (!fileMap.kmap?.map) {
  681. mapOverlays.length = 0;
  682. return;
  683. }
  684. mapOverlays.forEach((overlay) => {
  685. fileMap.kmap.map.removeOverlay(overlay);
  686. const el = overlay.getElement?.();
  687. el?.remove?.();
  688. });
  689. mapOverlays.length = 0;
  690. };
  691. const bindZoneOverlays = () => {
  692. if (!fileMap.kmap?.map) return;
  693. clearMapOverlays();
  694. zoneList.value.forEach((zone) => {
  695. if (zone.longitude == null || zone.latitude == null) return;
  696. const overlay = new Overlay({
  697. element: createZoneMarkerEl(zone),
  698. position: [zone.longitude, zone.latitude],
  699. positioning: "bottom-center",
  700. stopEvent: false,
  701. });
  702. fileMap.kmap.map.addOverlay(overlay);
  703. mapOverlays.push(overlay);
  704. });
  705. };
  706. const focusAlbumPoint = () => {
  707. const point = zoneList.value[0];
  708. if (!point || point.longitude == null || point.latitude == null || !fileMap.kmap?.getView) return;
  709. fileMap.kmap.getView().animate({
  710. center: [Number(point.longitude), Number(point.latitude)],
  711. zoom: 16,
  712. duration: 0,
  713. });
  714. };
  715. const initAlbumMap = async () => {
  716. await nextTick();
  717. if (!mapContainer.value) return;
  718. const center = getFarmCenter();
  719. fileMap.initMap(`POINT(${center[0]} ${center[1]})`, mapContainer.value);
  720. await nextTick();
  721. if (noFarmRange.value) {
  722. zoneList.value = [];
  723. albumList.value = [];
  724. return;
  725. }
  726. await fetchTmpAlbumImages();
  727. await nextTick();
  728. bindZoneOverlays();
  729. focusAlbumPoint();
  730. fileMap.kmap?.map?.updateSize?.();
  731. };
  732. const fillMockLabels = () => {
  733. reportList.value = reportList.value.map((item) => ({
  734. ...item,
  735. title: t("agriFile.reportTitle"),
  736. desc: t("agriFile.reportDesc"),
  737. date: t("agriFile.reportDate"),
  738. }));
  739. };
  740. /**
  741. * 长势跟踪巡园要点:parse_interact
  742. */
  743. async function fetchPatrolGrowthInteract() {
  744. const entry = readEntryFarmData();
  745. const cropCategoryName = entry?.crop;
  746. const phenophaseCode = entry?.cropCode;
  747. const riskName = entry?.weatherRisk;
  748. if (!cropCategoryName || !phenophaseCode || !riskName) return;
  749. try {
  750. const res = await VE_API.questionnaire.parseInteract({
  751. crop_category_name: cropCategoryName,
  752. phenophase_code: String(phenophaseCode),
  753. risk_name: riskName,
  754. });
  755. if (res.code !== 200) return;
  756. const data = res.data;
  757. if (Array.isArray(data)) {
  758. patrolList.value.push(...data);
  759. } else if (data) {
  760. patrolList.value.push(data);
  761. }
  762. } catch {
  763. // ignore
  764. }
  765. }
  766. /**
  767. * 异常态势巡园要点:parse_anormal
  768. */
  769. async function fetchPatrolAbnormalInteract() {
  770. const entry = readEntryFarmData();
  771. const cropCategoryName = entry?.crop;
  772. const phenophaseCode = entry?.cropCode;
  773. const riskName = entry?.weatherStress || entry?.weatherRisk;
  774. if (!cropCategoryName || !phenophaseCode || !riskName) return;
  775. try {
  776. const res = await VE_API.questionnaire.abnormalContent({
  777. crop_category_name: cropCategoryName,
  778. phenophase_code: String(phenophaseCode),
  779. risk_name: riskName,
  780. });
  781. if (res?.code !== 200 || !res.data) return;
  782. const list = Array.isArray(res.data) ? res.data : [res.data];
  783. list.forEach((item) => {
  784. if (!item || typeof item !== "object") return;
  785. patrolList.value.push({
  786. ...item,
  787. type: "abnormal",
  788. crop_category_name: item.crop_category_name || cropCategoryName,
  789. // 卡片标题 / 描述:兼容异常接口字段
  790. interaction_problem:
  791. item.interaction_problem || item.interaction_title || item.interaction_theme || "",
  792. interaction_reason:
  793. item.interaction_reason || item.interaction_theme || item.inspection_guide || "",
  794. });
  795. });
  796. } catch {
  797. // ignore
  798. }
  799. }
  800. async function loadPatrolContent() {
  801. fillMockLabels();
  802. patrolList.value = [];
  803. // 先长势跟踪,后异常态势
  804. await fetchPatrolGrowthInteract();
  805. await fetchPatrolAbnormalInteract();
  806. await fetchCropQuestionAlbumPic();
  807. await fetchTmpAlbumImages();
  808. await fetchReportList();
  809. await fetchWeatherReportList();
  810. await fetchCropArchiveList();
  811. await nextTick();
  812. bindZoneOverlays();
  813. }
  814. const goAddZone = (type) => {
  815. router.push(
  816. {
  817. path: "/add_zone",
  818. query: {
  819. type,
  820. isSuccessPopup: type === 'farm' ? '1' : '0',
  821. },
  822. }
  823. );
  824. };
  825. const goAlbumMap = () => {
  826. router.push("/album_map");
  827. };
  828. const goGrowthTrack = (item) => {
  829. router.push({
  830. path: "/growth_track",
  831. query: {
  832. type: item?.type || "growth",
  833. pageName: item.type === "abnormal"
  834. ? t("agriFile.patrolAbnormalTitle")
  835. : t("agriFile.patrolGrowthTitle"),
  836. },
  837. });
  838. };
  839. /** 诊断报告:风险/胁迫/异常研判进详情页,种植诊断进报告页 */
  840. const goReportDetail = (item) => {
  841. if (!item) return;
  842. if (item.type === "diagnosis") {
  843. router.push({
  844. path: "/diagnosis_report",
  845. query: {
  846. id: item.reportId || item.id || "",
  847. },
  848. });
  849. return;
  850. }
  851. router.push({
  852. path: "/alert_detail",
  853. query: {
  854. title: item.title || "具体预警",
  855. detailType: item.type || "",
  856. id: item.reportId || item.id || "",
  857. },
  858. });
  859. };
  860. const handleShare = (item) => {
  861. const query = {
  862. askInfo: { title: "转发巡园要点", content: "是否分享给好友" },
  863. shareText: item?.title || "速长窗口不追肥?后期发育慢人一步,直接掉队!",
  864. targetUrl: "growth_track",
  865. paramsPage: JSON.stringify({
  866. id: item?.id,
  867. type: item?.type || "growth",
  868. fromShare: 1,
  869. }),
  870. imageUrl: "https://birdseye-img.sysuimars.com/temp/field.png",
  871. };
  872. wx.miniProgram.navigateTo({
  873. url: `/pages/subPages/share_page/index?pageParams=${JSON.stringify(query)}&type=sharePage`,
  874. });
  875. };
  876. const needDraw = ref(true);
  877. const openUploadPopup = () => {
  878. showUploadPopup.value = true;
  879. };
  880. /** 与 growthTrack 上传一致:需勾画 → add_zone;异常 → pest_interact;否则弹上传 */
  881. const handleUploadClick = (item) => {
  882. const isAbnormal = item?.type === "abnormal";
  883. if (needDraw.value) {
  884. router.push({
  885. path: "/add_zone",
  886. query: {
  887. type: "farm",
  888. targetUrl: isAbnormal ? "/pest_interact" : "/growth_track",
  889. ...(isAbnormal ? { isAbnormal: "true" } : {}),
  890. ...(item?.id != null ? { trackId: item.id } : {}),
  891. ...(item?.type ? { trackType: item.type } : {}),
  892. },
  893. });
  894. return;
  895. }
  896. if (isAbnormal) {
  897. router.push({ path: "/pest_interact" });
  898. return;
  899. }
  900. showUploadPopup.value = true;
  901. };
  902. const dismissGuide = () => {
  903. if (!guideVisible.value) return;
  904. guideVisible.value = false;
  905. localStorage.setItem(DIAGNOSIS_GUIDE_STORAGE_KEY, "1");
  906. };
  907. const tryShowGuide = () => {
  908. if (localStorage.getItem(DIAGNOSIS_GUIDE_STORAGE_KEY)) return;
  909. guideVisible.value = true;
  910. };
  911. const handleGuideUpload = () => {
  912. dismissGuide();
  913. openUploadPopup();
  914. };
  915. const handleNavClick = (item) => {
  916. if (item.key === "report") dismissGuide();
  917. activeNav.value = item.key;
  918. };
  919. const handleOutsideClick = (event) => {
  920. if (!guideVisible.value) return;
  921. const target = event.target;
  922. if (
  923. target?.closest?.(".report-guide-bubble") ||
  924. target?.closest?.(".nav-card-wrap--report .nav-card")
  925. ) {
  926. return;
  927. }
  928. dismissGuide();
  929. };
  930. const goCompleteFarmInfo = () => {
  931. router.push("/entry_information");
  932. };
  933. const goAlbumDetail = (item) => {
  934. router.push({
  935. path: "/region_albums",
  936. query: {
  937. id: item?.id,
  938. name: item?.name || "",
  939. groupIndex: item?.groupIndex != null ? String(item.groupIndex) : "",
  940. noImage: item?.count ? "false" : "true",
  941. },
  942. });
  943. };
  944. onMounted(() => {
  945. initAlbumMap();
  946. tryShowGuide();
  947. eventBus.on(INSPECT_SUCCESS_EVENT, onInspectSuccess);
  948. document.addEventListener("click", handleOutsideClick, true);
  949. });
  950. onActivated(() => {
  951. loadPatrolContent();
  952. initAlbumMap();
  953. tryShowGuide();
  954. });
  955. onBeforeUnmount(() => {
  956. eventBus.off(INSPECT_SUCCESS_EVENT, onInspectSuccess);
  957. clearMapOverlays();
  958. document.removeEventListener("click", handleOutsideClick, true);
  959. });
  960. watch(activeNav, (key) => {
  961. if (key === "album") {
  962. initAlbumMap();
  963. }
  964. });
  965. </script>
  966. <style lang="scss" scoped>
  967. .agri-file-page {
  968. position: relative;
  969. display: flex;
  970. flex-direction: column;
  971. width: 100%;
  972. height: 100%;
  973. overflow: hidden;
  974. background: linear-gradient(180deg, #D1EBFF 0%, #FFFFFF 100%);
  975. .nav-cards {
  976. position: relative;
  977. z-index: 2;
  978. display: flex;
  979. justify-content: space-around;
  980. padding: 10px 6px;
  981. margin-bottom: 10px;
  982. gap: 6px;
  983. .nav-card-wrap {
  984. position: relative;
  985. width: 100%;
  986. &--report {
  987. z-index: 5;
  988. }
  989. }
  990. .nav-card {
  991. position: relative;
  992. display: flex;
  993. // flex-direction: column;
  994. align-items: center;
  995. justify-content: center;
  996. border-radius: 8px;
  997. background: #fff;
  998. // width: 90px;
  999. gap: 4px;
  1000. height: 40px;
  1001. box-shadow: 0px 4px 4px 0px #0000000D;
  1002. .nav-card__icon {
  1003. width: 26px;
  1004. height: 26px;
  1005. // margin-bottom: 4px;
  1006. }
  1007. &.is-active {
  1008. background: linear-gradient(180deg, #5BB8FF 0%, #2199F8 100%);
  1009. box-shadow: 0 4px 10px rgba(33, 153, 248, 0.28);
  1010. .nav-card__icon {
  1011. filter: brightness(0) invert(1);
  1012. }
  1013. .nav-card__label {
  1014. color: #fff;
  1015. }
  1016. &::after {
  1017. content: "";
  1018. position: absolute;
  1019. left: 50%;
  1020. bottom: -5px;
  1021. transform: translateX(-50%);
  1022. border-left: 6px solid transparent;
  1023. border-right: 6px solid transparent;
  1024. border-top: 6px solid #2199F8;
  1025. z-index: 3;
  1026. }
  1027. }
  1028. }
  1029. .report-guide-bubble {
  1030. position: absolute;
  1031. top: calc(100% + 10px);
  1032. right: 22px;
  1033. width: 216px;
  1034. padding: 6px 10px;
  1035. border-radius: 6px;
  1036. background: rgba(0, 0, 0, 0.8);
  1037. backdrop-filter: blur(4px);
  1038. box-sizing: border-box;
  1039. z-index: 20;
  1040. pointer-events: auto;
  1041. &__arrow {
  1042. position: absolute;
  1043. top: -5px;
  1044. right: 16px;
  1045. width: 0;
  1046. height: 0;
  1047. border-left: 6px solid transparent;
  1048. border-right: 6px solid transparent;
  1049. border-bottom: 6px solid rgba(0, 0, 0, 0.8);
  1050. }
  1051. &__content {
  1052. font-size: 14px;
  1053. line-height: 22px;
  1054. color: #fff;
  1055. word-break: break-all;
  1056. text-align: justify;
  1057. }
  1058. &__link {
  1059. padding: 0 2px;
  1060. color: #2199f8;
  1061. cursor: pointer;
  1062. }
  1063. }
  1064. }
  1065. .content-panel {
  1066. flex: 1;
  1067. overflow-y: auto;
  1068. background: linear-gradient(360deg, rgba(229, 243, 255, 0.55) 0%, rgba(255, 255, 255, 0.55) 100%);
  1069. border-radius: 20px 20px 0 0;
  1070. border: 1px solid #FFFFFF;
  1071. padding: 18px 10px;
  1072. &::-webkit-scrollbar {
  1073. display: none;
  1074. }
  1075. .map-card {
  1076. position: relative;
  1077. height: 210px;
  1078. }
  1079. .map-container {
  1080. width: 100%;
  1081. height: 100%;
  1082. clip-path: inset(0px round 8px);
  1083. }
  1084. .map-mask {
  1085. position: absolute;
  1086. top: 0;
  1087. left: 0;
  1088. z-index: 5;
  1089. width: 100%;
  1090. height: 100%;
  1091. background: rgba(0, 0, 0, 0.5);
  1092. border-radius: 8px;
  1093. display: flex;
  1094. align-items: center;
  1095. justify-content: center;
  1096. pointer-events: auto;
  1097. cursor: pointer;
  1098. .mask-content {
  1099. background: rgba(255, 255, 255, 0.2);
  1100. backdrop-filter: blur(4px);
  1101. padding: 8px 10px;
  1102. border-radius: 4px;
  1103. width: fit-content;
  1104. display: flex;
  1105. flex-direction: column;
  1106. align-items: center;
  1107. justify-content: center;
  1108. gap: 12px;
  1109. color: #fff;
  1110. pointer-events: none;
  1111. &__title {
  1112. font-size: 18px;
  1113. font-family: "PangMenZhengDao";
  1114. }
  1115. &__btn {
  1116. padding: 0 24px;
  1117. height: 32px;
  1118. line-height: 32px;
  1119. border-radius: 24px;
  1120. background: #2199F8;
  1121. }
  1122. }
  1123. }
  1124. .add-zone-btn {
  1125. position: absolute;
  1126. right: 5px;
  1127. bottom: 10px;
  1128. z-index: 5;
  1129. display: flex;
  1130. align-items: center;
  1131. gap: 2px;
  1132. padding: 5px 10px;
  1133. border-radius: 4px;
  1134. background: #fff;
  1135. color: #2199F8;
  1136. font-weight: 500;
  1137. }
  1138. .album-grid {
  1139. display: grid;
  1140. grid-template-columns: 1fr 1fr;
  1141. gap: 16px;
  1142. margin-top: 16px;
  1143. box-sizing: border-box;
  1144. .album-add {
  1145. align-self: start;
  1146. display: flex;
  1147. flex-direction: column;
  1148. align-items: center;
  1149. justify-content: center;
  1150. width: 100%;
  1151. aspect-ratio: 1;
  1152. border-radius: 5px;
  1153. background: #FFFFFF;
  1154. color: #969494;
  1155. font-size: 16px;
  1156. &__icon {
  1157. margin-bottom: 6px;
  1158. font-size: 20px;
  1159. }
  1160. }
  1161. .album-card {
  1162. width: 100%;
  1163. &.is-empty {
  1164. .album-card__cover {
  1165. display: flex;
  1166. align-items: center;
  1167. justify-content: center;
  1168. overflow: hidden;
  1169. border-radius: 8px;
  1170. background: linear-gradient(180deg, #E0F1FF 0%, #C2E4FF 100%);
  1171. }
  1172. }
  1173. &__cover {
  1174. position: relative;
  1175. width: 100%;
  1176. aspect-ratio: 1;
  1177. img:not(.album-card__empty-icon) {
  1178. width: 100%;
  1179. height: 100%;
  1180. object-fit: cover;
  1181. border-radius: 6px;
  1182. }
  1183. }
  1184. &__empty-icon {
  1185. width: 50px;
  1186. height: 50px;
  1187. }
  1188. &__count {
  1189. position: absolute;
  1190. top: 1px;
  1191. right: 0;
  1192. padding: 1px 5px;
  1193. border-radius: 0 5px 0 5px;
  1194. background: rgba(0, 0, 0, 0.4);
  1195. backdrop-filter: blur(4px);
  1196. color: #fff;
  1197. }
  1198. &__add {
  1199. position: absolute;
  1200. right: 8px;
  1201. bottom: 8px;
  1202. z-index: 1;
  1203. display: flex;
  1204. align-items: center;
  1205. justify-content: center;
  1206. width: 44px;
  1207. height: 44px;
  1208. border-radius: 50%;
  1209. background: #2199F8;
  1210. color: #fff;
  1211. font-size: 20px;
  1212. box-shadow: 0 2px 8px rgba(33, 153, 248, 0.35);
  1213. }
  1214. &__name {
  1215. margin-top: 10px;
  1216. color: #1F1F1F;
  1217. }
  1218. }
  1219. }
  1220. .report-card {
  1221. margin-bottom: 10px;
  1222. border-radius: 8px;
  1223. background: #fff;
  1224. &:last-child {
  1225. margin-bottom: 0;
  1226. }
  1227. &__cover {
  1228. position: relative;
  1229. width: 100%;
  1230. height: 158px;
  1231. img {
  1232. display: block;
  1233. width: 100%;
  1234. height: 100%;
  1235. object-fit: cover;
  1236. border-radius: 5px;
  1237. }
  1238. }
  1239. &__date {
  1240. position: absolute;
  1241. top: 0;
  1242. padding: 5px 12px;
  1243. border-radius: 4px;
  1244. background: rgba(0, 0, 0, 0.45);
  1245. backdrop-filter: blur(4px);
  1246. color: #fff;
  1247. font-size: 16px;
  1248. border-radius: 5px 0 5px 0;
  1249. }
  1250. &__status {
  1251. position: absolute;
  1252. top: 10px;
  1253. right: 0;
  1254. display: flex;
  1255. align-items: center;
  1256. gap: 4px;
  1257. padding: 2px 13px;
  1258. border-radius: 20px 0 0 20px;
  1259. background: #FF5454;
  1260. color: #fff;
  1261. font-size: 12px;
  1262. }
  1263. &__dot {
  1264. width: 6px;
  1265. height: 6px;
  1266. border-radius: 50%;
  1267. background: #fff;
  1268. }
  1269. &__body {
  1270. padding: 10px;
  1271. margin-top: 4px;
  1272. }
  1273. &__head {
  1274. display: flex;
  1275. align-items: center;
  1276. justify-content: space-between;
  1277. gap: 8px;
  1278. }
  1279. &__title {
  1280. flex: 1;
  1281. min-width: 0;
  1282. font-size: 16px;
  1283. font-weight: 500;
  1284. }
  1285. &__day {
  1286. flex: none;
  1287. color: #9E9B9B;
  1288. font-size: 14px;
  1289. white-space: nowrap;
  1290. }
  1291. &__desc {
  1292. margin-top: 4px;
  1293. color: #9E9B9B;
  1294. }
  1295. }
  1296. .tracking-item {
  1297. width: 100%;
  1298. margin-bottom: 16px;
  1299. box-shadow: 0px 4px 4px 0px rgba(33, 153, 248, 0.1);
  1300. background: linear-gradient(180deg, #86CAFF 0%, #2199F8 49%);
  1301. border-radius: 10px;
  1302. &:last-of-type {
  1303. margin-bottom: 0;
  1304. }
  1305. &__header {
  1306. position: relative;
  1307. padding: 10px 10px 20px;
  1308. border-radius: 10px 10px 0 0;
  1309. // background: linear-gradient(180deg, #86CAFF 0%, #2199F8 48.5%);
  1310. }
  1311. &__header-row {
  1312. display: flex;
  1313. align-items: center;
  1314. justify-content: space-between;
  1315. }
  1316. &__header-left {
  1317. display: flex;
  1318. align-items: center;
  1319. gap: 5px;
  1320. }
  1321. &__level {
  1322. display: flex;
  1323. align-items: center;
  1324. gap: 4px;
  1325. padding: 1px 4px;
  1326. border-radius: 2px;
  1327. background: #fff;
  1328. color: #FF6A6A;
  1329. font-size: 12px;
  1330. }
  1331. &__title {
  1332. color: #fff;
  1333. font-size: 16px;
  1334. font-weight: 500;
  1335. }
  1336. &__zone {
  1337. font-size: 12px;
  1338. }
  1339. &__share {
  1340. display: flex;
  1341. align-items: center;
  1342. gap: 4px;
  1343. padding: 2px 10px;
  1344. border-radius: 25px;
  1345. background: #fff;
  1346. color: #2199F8;
  1347. font-size: 12px;
  1348. }
  1349. &__body {
  1350. position: relative;
  1351. padding: 10px;
  1352. margin-top: -8px;
  1353. border-radius: 10px;
  1354. background: #fff;
  1355. &::before {
  1356. content: "";
  1357. position: absolute;
  1358. top: -6px;
  1359. left: 21%;
  1360. width: 0;
  1361. height: 0;
  1362. border-left: 7px solid transparent;
  1363. border-right: 7px solid transparent;
  1364. border-bottom: 7px solid #fff;
  1365. transform: translateX(-50%);
  1366. }
  1367. }
  1368. &__action {
  1369. display: flex;
  1370. align-items: center;
  1371. justify-content: space-between;
  1372. border: 1px solid rgba(33, 153, 248, 0.2);
  1373. border-radius: 6px;
  1374. padding: 8px;
  1375. }
  1376. &__icon {
  1377. display: flex;
  1378. align-items: center;
  1379. gap: 5px;
  1380. img {
  1381. width: 18px;
  1382. height: 16px;
  1383. }
  1384. }
  1385. &__reason {
  1386. color: #2199f8;
  1387. font-weight: 500;
  1388. }
  1389. &__issue {
  1390. margin-top: 2px;
  1391. color: #2199f8;
  1392. font-size: 12px;
  1393. }
  1394. &__action-main {
  1395. flex: 1;
  1396. padding-right: 4px;
  1397. }
  1398. &__btn {
  1399. flex: none;
  1400. display: flex;
  1401. align-items: center;
  1402. gap: 2px;
  1403. padding: 7px 10px;
  1404. border-radius: 5px;
  1405. background: #2199f8;
  1406. color: #fff;
  1407. font-size: 12px;
  1408. }
  1409. }
  1410. .crop-archive {
  1411. padding: 10px;
  1412. border-radius: 10px;
  1413. background: #fff;
  1414. &__title {
  1415. position: relative;
  1416. display: inline-block;
  1417. z-index: 1;
  1418. font-size: 16px;
  1419. font-weight: 500;
  1420. margin-bottom: 5px;
  1421. &::after {
  1422. content: "";
  1423. position: absolute;
  1424. left: 0;
  1425. bottom: 2px;
  1426. z-index: -1;
  1427. width: 90px;
  1428. height: 6px;
  1429. border-radius: 25px;
  1430. background: linear-gradient(90deg, #2199f8 0%, rgba(255, 255, 255, 0.37) 100%);
  1431. opacity: 0.4;
  1432. }
  1433. }
  1434. }
  1435. }
  1436. }
  1437. </style>
  1438. <!-- Overlay 移出 Vue 树后无 scoped attr,标记样式需非 scoped -->
  1439. <style lang="scss">
  1440. .zone-marker {
  1441. display: flex;
  1442. flex-direction: column;
  1443. align-items: center;
  1444. pointer-events: none;
  1445. &__label {
  1446. padding: 3px 14px;
  1447. border-radius: 25px;
  1448. background: #fff;
  1449. color: #0a0a0a;
  1450. font-size: 12px;
  1451. }
  1452. &__thumb {
  1453. position: relative;
  1454. width: 42px;
  1455. height: 42px;
  1456. margin-top: 8px;
  1457. img {
  1458. width: 100%;
  1459. height: 100%;
  1460. object-fit: cover;
  1461. border: 2px solid #fff;
  1462. border-radius: 6px;
  1463. box-sizing: border-box;
  1464. }
  1465. }
  1466. &__badge {
  1467. position: absolute;
  1468. top: -6px;
  1469. right: -6px;
  1470. min-width: 18px;
  1471. height: 18px;
  1472. line-height: 16px;
  1473. border-radius: 50%;
  1474. background: #fff;
  1475. color: #0a0a0a;
  1476. font-size: 12px;
  1477. text-align: center;
  1478. }
  1479. &__arrow {
  1480. width: 0;
  1481. height: 0;
  1482. border-left: 5px solid transparent;
  1483. border-right: 5px solid transparent;
  1484. border-top: 6px solid #fff;
  1485. }
  1486. }
  1487. </style>