selectVariety.vue 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292
  1. <template>
  2. <div class="select-variety">
  3. <div class="select-variety__content">
  4. <div class="page-header">
  5. <div class="page-title">完善种植品种</div>
  6. <div class="page-title">精准管理每一块地块<span class="page-title-tip">(单选)</span></div>
  7. </div>
  8. <div class="variety-card" v-loading="varietyLoading">
  9. <!-- 单品类标题 -->
  10. <div class="category-title" v-if="currentCategory?.name">
  11. <span class="category-title__line"></span>
  12. <span class="category-title__diamond"></span>
  13. <span class="category-title__text">品类 - {{ currentCategory.name }}</span>
  14. <span class="category-title__diamond"></span>
  15. <span class="category-title__line right-line"></span>
  16. </div>
  17. <el-input v-model="searchKeyword" class="search-bar" placeholder="请输入品种" @keyup.enter="handleSearch">
  18. <template #prefix>
  19. <el-icon class="search-icon">
  20. <Search />
  21. </el-icon>
  22. </template>
  23. <template #suffix>
  24. <span class="search-btn" @click="handleSearch">搜索</span>
  25. </template>
  26. </el-input>
  27. <div class="variety-grid">
  28. <div v-for="item in visibleVarieties" :key="item.id" class="variety-item"
  29. :class="{ selected: isSelected(item.id) }" @click="toggleVariety(item)">
  30. {{ item.name }}
  31. </div>
  32. </div>
  33. <div v-if="!varietyLoading && !filteredVarieties.length" class="empty-tip">暂无品种</div>
  34. <div v-if="filteredVarieties.length > DEFAULT_VISIBLE_COUNT" class="expand-trigger"
  35. @click="gridExpanded = !gridExpanded">
  36. {{ gridExpanded ? "点击收起" : "点击展开更多" }}
  37. </div>
  38. </div>
  39. <!-- 默认表单卡(单选,始终一张) -->
  40. <div v-if="currentSelected" :key="currentSelected.uid" class="form-card"
  41. :class="hasVariety ? 'selected-card' : 'form-card--default'">
  42. <div class="form-card__header">
  43. <img class="title-icon" src="@/assets/img/home/label-icon.png" alt="" />
  44. <el-select
  45. v-model="currentSelected.id"
  46. class="variety-select"
  47. placeholder="选择品种"
  48. popper-class="variety-select-popper"
  49. @change="handleFormVarietyChange"
  50. >
  51. <el-option
  52. v-for="opt in currentVarieties"
  53. :key="opt.id"
  54. :label="opt.name"
  55. :value="opt.id"
  56. />
  57. </el-select>
  58. <!-- <span v-if="hasVariety" class="delete-btn" @click="clearVariety">删除</span> -->
  59. </div>
  60. <div
  61. class="form-card__body"
  62. :class="{ 'form-card__body--locked': !hasVariety }"
  63. @click="onFormBodyClick"
  64. >
  65. <div class="form-row">
  66. <span class="form-label">当下物候期</span>
  67. <el-select
  68. v-model="currentSelected.phenologyId"
  69. class="form-select"
  70. placeholder="选择物候期"
  71. placement="bottom-end"
  72. popper-class="variety-select-popper"
  73. >
  74. <el-option
  75. v-for="opt in currentPhenologyOptions"
  76. :key="opt.id"
  77. :label="opt.name"
  78. :value="opt.id"
  79. />
  80. </el-select>
  81. </div>
  82. <div class="form-row">
  83. <span class="form-label" :class="{ muted: !hasVariety }">
  84. {{ currentStartingPointQuestion }}
  85. </span>
  86. <el-date-picker
  87. v-model="currentSelected.startTime"
  88. class="form-picker"
  89. type="date"
  90. placeholder="请选择时间"
  91. format="YYYY-MM-DD"
  92. value-format="YYYY-MM-DD"
  93. :clearable="false"
  94. style="width: 130px"
  95. :readonly="!hasVariety"
  96. />
  97. </div>
  98. <div class="form-row">
  99. <span class="form-label">种植亩数</span>
  100. <el-input
  101. v-model="currentSelected.area"
  102. class="form-input"
  103. placeholder="请输入亩数"
  104. type="number"
  105. :readonly="!hasVariety"
  106. />
  107. </div>
  108. <div class="form-row location-row">
  109. <span class="form-label">选择种植点位</span>
  110. <div
  111. class="map"
  112. :class="{ 'map--clickable': hasVariety }"
  113. :ref="(el) => setItemMapRef(currentSelected.uid, el)"
  114. @click="goSelectItemLocation(currentSelected)"
  115. ></div>
  116. </div>
  117. </div>
  118. </div>
  119. </div>
  120. <div class="custom-bottom-fixed-btns">
  121. <div class="bottom-btn secondary-btn" @click="handlePrev">上一步</div>
  122. <div class="bottom-btn primary-btn" @click="handleConfirm">{{ varietyNextText }}</div>
  123. </div>
  124. </div>
  125. </template>
  126. <script setup>
  127. import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from "vue";
  128. import { ElMessage } from "element-plus";
  129. import { Search } from "@element-plus/icons-vue";
  130. import { useRouter } from "vue-router";
  131. import { useStore } from "vuex";
  132. import SelectLocationMap, { POINT_ICON } from "../map/selectLocationMap.js";
  133. const emit = defineEmits(["prev", "next"]);
  134. const props = defineProps({
  135. multiple: { type: Boolean, default: false },
  136. isLastStep: { type: Boolean, default: false },
  137. });
  138. const router = useRouter();
  139. const store = useStore();
  140. const varietyNextText = computed(() => (props.isLastStep ? "提交信息" : "下一步 (3/4)"));
  141. const DEFAULT_VISIBLE_COUNT = 12;
  142. const DEFAULT_POINT = "POINT(113.6142086995688 23.585836479509055)";
  143. const LOCATION_KEY = "ENTRY_RESIDENT_LOCATION";
  144. const EDIT_UID_KEY = "ENTRY_VARIETY_EDIT_UID";
  145. const SELECTED_LIST_KEY = "ENTRY_SELECTED_VARIETY_LIST";
  146. const CATEGORY_SESSION_KEY = "ENTRY_SELECTED_CATEGORY";
  147. const MAP_PADDING = [6, 6, 6, 6];
  148. let selectedUid = 0;
  149. const itemPreviewMaps = new Map();
  150. function getResidentPoint() {
  151. try {
  152. const saved = JSON.parse(sessionStorage.getItem(LOCATION_KEY) || "null");
  153. return saved?.point || "";
  154. } catch {
  155. return "";
  156. }
  157. }
  158. function getDefaultPoint() {
  159. return (
  160. getResidentPoint() ||
  161. localStorage.getItem("MINI_USER_LOCATION_POINT") ||
  162. store.state.home.miniUserLocationPoint ||
  163. DEFAULT_POINT
  164. );
  165. }
  166. function utilWktToCoordinate(point) {
  167. const match = String(point).match(/POINT\s*\(([\d.\-]+)\s+([\d.\-]+)\)/i);
  168. if (!match) return null;
  169. return [Number(match[1]), Number(match[2])];
  170. }
  171. function createPreviewMap(el, point, showPoint = false) {
  172. if (!el || !point) return null;
  173. const map = new SelectLocationMap({ iconSrc: POINT_ICON.plant });
  174. map.initMap(point, el, {
  175. enableClick: false,
  176. padding: MAP_PADDING,
  177. showPoint,
  178. });
  179. nextTick(() => {
  180. map.kmap?.map?.updateSize?.();
  181. });
  182. return map;
  183. }
  184. function destroyPreviewMap(map) {
  185. map?.clearLayer();
  186. }
  187. function updateItemMap(uid, point, showPoint) {
  188. const map = itemPreviewMaps.get(uid);
  189. if (!map?.kmap || !point) return;
  190. const coordinate = utilWktToCoordinate(point);
  191. if (!coordinate) return;
  192. if (showPoint) {
  193. map.showPoint(coordinate);
  194. } else {
  195. map.hidePoint();
  196. map.setMapPosition(coordinate, false);
  197. }
  198. }
  199. function setItemMapRef(uid, el) {
  200. if (!el) {
  201. destroyPreviewMap(itemPreviewMaps.get(uid));
  202. itemPreviewMaps.delete(uid);
  203. return;
  204. }
  205. if (itemPreviewMaps.has(uid)) {
  206. nextTick(() => itemPreviewMaps.get(uid)?.kmap?.map?.updateSize?.());
  207. return;
  208. }
  209. nextTick(() => {
  210. const item = selectedList.value.find((i) => i.uid === uid);
  211. const point = item?.location || getDefaultPoint();
  212. const map = createPreviewMap(el, point, !!item?.location);
  213. if (map) itemPreviewMaps.set(uid, map);
  214. });
  215. }
  216. function syncItemLocationFromSession() {
  217. const uid = sessionStorage.getItem(EDIT_UID_KEY);
  218. if (!uid) return;
  219. sessionStorage.removeItem(EDIT_UID_KEY);
  220. try {
  221. const raw = sessionStorage.getItem(LOCATION_KEY);
  222. const saved = raw ? JSON.parse(raw) : null;
  223. if (!saved?.point) return;
  224. const item = selectedList.value.find((i) => i.uid === uid);
  225. if (!item) return;
  226. item.location = saved.point;
  227. saveSelectedListDraft();
  228. nextTick(() => updateItemMap(uid, saved.point, true));
  229. } catch {
  230. // ignore
  231. }
  232. }
  233. function saveSelectedListDraft() {
  234. try {
  235. sessionStorage.setItem(
  236. SELECTED_LIST_KEY,
  237. JSON.stringify({
  238. selectedUid,
  239. activeCategoryId: activeCategoryId.value,
  240. list: selectedList.value,
  241. })
  242. );
  243. } catch {
  244. // ignore
  245. }
  246. }
  247. function restoreSelectedListDraft() {
  248. try {
  249. const raw = sessionStorage.getItem(SELECTED_LIST_KEY);
  250. if (!raw) return;
  251. const draft = JSON.parse(raw);
  252. if (draft?.activeCategoryId != null) {
  253. activeCategoryId.value = draft.activeCategoryId;
  254. }
  255. if (typeof draft?.selectedUid === "number") {
  256. selectedUid = draft.selectedUid;
  257. }
  258. if (Array.isArray(draft?.list) && draft.list.length) {
  259. selectedList.value = props.multiple ? draft.list : draft.list.slice(0, 1);
  260. }
  261. } catch {
  262. // ignore
  263. }
  264. }
  265. const goSelectItemLocation = (item) => {
  266. if (!item?.id) {
  267. ElMessage.warning("请选择品种");
  268. return;
  269. }
  270. sessionStorage.setItem(EDIT_UID_KEY, item.uid);
  271. sessionStorage.setItem("ENTRY_INFORMATION_STEP", "3");
  272. saveSelectedListDraft();
  273. router.push({
  274. path: "/entry_select_location",
  275. query: {
  276. mapCenter: item.location || getDefaultPoint(),
  277. pointType: "plant",
  278. },
  279. });
  280. };
  281. async function bootstrapVarietyPage() {
  282. initCategoryTabsFromSession();
  283. restoreSelectedListDraft();
  284. ensureDefaultForm();
  285. syncItemLocationFromSession();
  286. await Promise.all(
  287. categoryTabs.value.map((tab) => fetchVarietiesByCrop(tab.id))
  288. );
  289. await fetchInitialInteractOptionsWithoutZone();
  290. }
  291. onMounted(() => {
  292. bootstrapVarietyPage();
  293. });
  294. /** keep-alive 返回时只同步点位/地图,避免重复拉接口 */
  295. onActivated(() => {
  296. syncItemLocationFromSession();
  297. nextTick(() => {
  298. itemPreviewMaps.forEach((map) => map.kmap?.map?.updateSize?.());
  299. });
  300. });
  301. onBeforeUnmount(() => {
  302. itemPreviewMaps.forEach((map) => destroyPreviewMap(map));
  303. itemPreviewMaps.clear();
  304. });
  305. const getSelectedCategoriesFromSession = () => {
  306. try {
  307. const raw = sessionStorage.getItem(CATEGORY_SESSION_KEY);
  308. if (!raw) return [];
  309. const list = JSON.parse(raw);
  310. if (!Array.isArray(list)) return [];
  311. if (!props.multiple) return list;
  312. return [...list].sort((a, b) => {
  313. const rank = (item) => {
  314. if (item?.firstCrop === "大田" || item?.majorKey === "field") return 0;
  315. if (item?.firstCrop === "果树" || item?.majorKey === "fruit") return 1;
  316. return 2;
  317. };
  318. return rank(a) - rank(b);
  319. });
  320. } catch {
  321. return [];
  322. }
  323. };
  324. const mapPhenologyList = (list) => {
  325. if (!Array.isArray(list)) return [];
  326. return list
  327. .map((item, index) => {
  328. if (typeof item === "string") {
  329. return { id: item, name: item };
  330. }
  331. const name = item?.name || item?.phenology || String(item?.id ?? index);
  332. const id = item?.id ?? name;
  333. return { id, name };
  334. })
  335. .filter((item) => item.name);
  336. };
  337. const initCategoryTabsFromSession = () => {
  338. const selected = getSelectedCategoriesFromSession();
  339. const prevLoaded = new Map(
  340. categoryTabs.value.map((tab) => [String(tab.id), tab])
  341. );
  342. categoryTabs.value = selected.map((item) => {
  343. const id = item.id;
  344. const cached = prevLoaded.get(String(id));
  345. if (cached?.loaded) {
  346. return {
  347. ...cached,
  348. name: item.name || cached.name,
  349. code: item.code || cached.code || "",
  350. firstCrop: item.firstCrop || cached.firstCrop || "",
  351. };
  352. }
  353. return {
  354. id,
  355. name: item.name || "",
  356. code: item.code || "",
  357. firstCrop: item.firstCrop || "",
  358. varieties: [],
  359. phenology: [],
  360. startingPointQuestion: "起种点问题",
  361. loaded: false,
  362. };
  363. });
  364. if (!categoryTabs.value.length) {
  365. activeCategoryId.value = null;
  366. return;
  367. }
  368. const stillExists = categoryTabs.value.some(
  369. (tab) => String(tab.id) === String(activeCategoryId.value)
  370. );
  371. if (!stillExists) {
  372. activeCategoryId.value = categoryTabs.value[0].id;
  373. }
  374. };
  375. const categoryTabs = ref([]);
  376. const activeCategoryId = ref(null);
  377. const varietyLoading = ref(false);
  378. const searchKeyword = ref("");
  379. const appliedKeyword = ref("");
  380. const gridExpanded = ref(false);
  381. const selectedList = ref([]);
  382. /** 无分区初始互动选项,业务侧自行对接 */
  383. const initialInteractOptions = ref(null);
  384. /** 进行中的品种请求,防止并发重复 */
  385. const varietyFetchPromises = new Map();
  386. /** 进行中的初始互动请求 key */
  387. let interactFetchKey = "";
  388. let interactFetchPromise = null;
  389. function readJson(key) {
  390. try {
  391. const raw = sessionStorage.getItem(key);
  392. return raw ? JSON.parse(raw) : null;
  393. } catch {
  394. return null;
  395. }
  396. }
  397. function writeJson(key, value) {
  398. sessionStorage.setItem(key, JSON.stringify(value));
  399. }
  400. function saveCropDetailFromApi(data) {
  401. if (data?.crop_id == null || data?.crop_id === "") return;
  402. const cache = readJson("ENTRY_CROP_DETAIL") || {};
  403. cache[String(data.crop_id)] = {
  404. crop_id: data.crop_id,
  405. crop_code: data.crop_code || "",
  406. crop_group: data.crop_group || "",
  407. crop_name: data.crop_name || "",
  408. crop_type: data.crop_type || "",
  409. };
  410. writeJson("ENTRY_CROP_DETAIL", cache);
  411. }
  412. /** options_interact.interact → 物候期下拉(展示 period_name,提交 period_code) */
  413. function mapOptionsInteractToPhenology(interact) {
  414. if (!interact) return [];
  415. const list = Array.isArray(interact)
  416. ? interact
  417. : Object.keys(interact)
  418. .sort((a, b) => Number(a) - Number(b))
  419. .map((key) => interact[key]);
  420. return list
  421. .map((item) => {
  422. if (!item || typeof item !== "object") return null;
  423. const code = item.period_code ?? item.code ?? item.id;
  424. const name = item.period_name ?? item.name ?? "";
  425. if (!name) return null;
  426. return {
  427. id: code != null && code !== "" ? code : name,
  428. name,
  429. code: code != null && code !== "" ? String(code) : "",
  430. url: item.url || "",
  431. };
  432. })
  433. .filter(Boolean);
  434. }
  435. /** 将无分区初始互动结果写入当前品类:起种问题文案 + 物候期选项 */
  436. function applyInitialInteractToCurrentCategory(data) {
  437. const tab = currentCategory.value;
  438. if (!tab || !data) return;
  439. const theme = data.date_interact?.interact_theme;
  440. if (theme) {
  441. tab.startingPointQuestion = theme;
  442. }
  443. const phenology = mapOptionsInteractToPhenology(data.options_interact?.interact);
  444. if (!phenology.length) return;
  445. tab.phenology = phenology;
  446. const form = getCurrentForm();
  447. if (!form?.phenologyId) return;
  448. const stillExists = phenology.some((opt) => String(opt.id) === String(form.phenologyId));
  449. if (!stillExists) form.phenologyId = "";
  450. }
  451. /** 组装并请求无分区初始互动选项 */
  452. async function fetchInitialInteractOptionsWithoutZone() {
  453. const category = currentCategory.value;
  454. if (!category) return;
  455. const form = getCurrentForm();
  456. const resident = readJson(LOCATION_KEY);
  457. const cropDetail = readJson("ENTRY_CROP_DETAIL") || {};
  458. const detail = cropDetail[String(category.id)] || {};
  459. const params = {
  460. crop_big_type: category.firstCrop || detail.crop_group || "",
  461. crop_code: category.code || detail.crop_code || form?.cropCode || "",
  462. crop_maturing: "P0",
  463. location: form?.location || resident?.point || getDefaultPoint(),
  464. date: form?.startTime || new Date().toISOString().split("T")[0],
  465. };
  466. const key = `${params.crop_code}|${params.location}|${params.date}`;
  467. if (interactFetchPromise && interactFetchKey === key) {
  468. return interactFetchPromise;
  469. }
  470. interactFetchKey = key;
  471. interactFetchPromise = (async () => {
  472. try {
  473. const raw = await VE_API.questionnaire.getInitialInteractionWithoutZone(params);
  474. const res = Array.isArray(raw) ? raw[0] : raw;
  475. if (res?.code === 200) {
  476. initialInteractOptions.value = res.data ?? null;
  477. applyInitialInteractToCurrentCategory(res.data);
  478. }
  479. } catch (error) {
  480. console.error("fetchInitialInteractOptionsWithoutZone failed", error);
  481. } finally {
  482. if (interactFetchKey === key) {
  483. interactFetchPromise = null;
  484. interactFetchKey = "";
  485. }
  486. }
  487. })();
  488. return interactFetchPromise;
  489. }
  490. const fetchVarietiesByCrop = async (cropId, force = false) => {
  491. if (cropId == null || cropId === "") return;
  492. const tab = categoryTabs.value.find((item) => String(item.id) === String(cropId));
  493. if (!tab) return;
  494. if (tab.loaded && !force) return;
  495. const cacheKey = String(cropId);
  496. if (!force && varietyFetchPromises.has(cacheKey)) {
  497. return varietyFetchPromises.get(cacheKey);
  498. }
  499. const showLoading = String(cropId) === String(activeCategoryId.value);
  500. if (showLoading) varietyLoading.value = true;
  501. const request = (async () => {
  502. try {
  503. const res = await VE_API.entry.getVarietiesByCrop({
  504. crop_id: cropId,
  505. });
  506. if (res.code === 200 && res.data) {
  507. const data = res.data;
  508. if (data.crop_code) tab.code = data.crop_code;
  509. if (data.crop_group) tab.firstCrop = tab.firstCrop || data.crop_group;
  510. tab.varieties = (data.varieties || [])
  511. .map((item) => {
  512. if (typeof item === "string") {
  513. return { id: item, name: item };
  514. }
  515. const name = item?.name || "";
  516. return {
  517. id: item?.id ?? name,
  518. name,
  519. };
  520. })
  521. .filter((item) => item.name);
  522. tab.phenology = mapPhenologyList(data.phenology);
  523. tab.startingPointQuestion =
  524. data.starting_point_question || "起种点问题";
  525. tab.loaded = true;
  526. // 优先用已缓存的 crop_code,避免重复打 farmer_crop_detail
  527. if (!tab.code) {
  528. const cached = readJson("ENTRY_CROP_DETAIL") || {};
  529. const detail = cached[cacheKey];
  530. if (detail?.crop_code) {
  531. tab.code = detail.crop_code;
  532. tab.firstCrop = tab.firstCrop || detail.crop_group || "";
  533. }
  534. }
  535. if (!tab.code) {
  536. try {
  537. const detailRes = await VE_API.entry.getMachines({ crop_id: cropId });
  538. const detail = detailRes?.data || {};
  539. if (detailRes?.code === 200 && detail.crop_code) {
  540. tab.code = detail.crop_code;
  541. tab.firstCrop = tab.firstCrop || detail.crop_group || "";
  542. saveCropDetailFromApi(detail);
  543. }
  544. } catch {
  545. // ignore
  546. }
  547. }
  548. } else {
  549. tab.varieties = [];
  550. tab.phenology = [];
  551. tab.startingPointQuestion = "起种点问题";
  552. if (showLoading) {
  553. ElMessage.error(res.msg || "获取品种列表失败");
  554. }
  555. }
  556. } catch {
  557. tab.varieties = [];
  558. tab.phenology = [];
  559. tab.startingPointQuestion = "起种点问题";
  560. if (showLoading) {
  561. ElMessage.error("获取品种列表失败,请稍后再试");
  562. }
  563. } finally {
  564. if (showLoading) varietyLoading.value = false;
  565. varietyFetchPromises.delete(cacheKey);
  566. }
  567. })();
  568. varietyFetchPromises.set(cacheKey, request);
  569. return request;
  570. };
  571. watch(
  572. selectedList,
  573. () => {
  574. saveSelectedListDraft();
  575. },
  576. { deep: true }
  577. );
  578. const currentCategory = computed(
  579. () =>
  580. categoryTabs.value.find((tab) => String(tab.id) === String(activeCategoryId.value)) ||
  581. categoryTabs.value[0]
  582. );
  583. const getPhenologyOptions = (categoryId) => {
  584. const tab = categoryTabs.value.find((item) => String(item.id) === String(categoryId));
  585. return tab?.phenology || [];
  586. };
  587. const getStartingPointQuestion = (categoryId) => {
  588. const tab = categoryTabs.value.find((item) => String(item.id) === String(categoryId));
  589. return tab?.startingPointQuestion || "起种点问题";
  590. };
  591. const currentVarieties = computed(() => currentCategory.value?.varieties || []);
  592. const getVarietiesByCategory = (categoryId) => {
  593. const tab = categoryTabs.value.find((item) => String(item.id) === String(categoryId));
  594. return tab?.varieties || [];
  595. };
  596. const filteredVarieties = computed(() => {
  597. const list = currentVarieties.value;
  598. const kw = appliedKeyword.value.trim();
  599. if (!kw) return list;
  600. return list.filter((item) => item.name.includes(kw));
  601. });
  602. const visibleVarieties = computed(() => {
  603. if (gridExpanded.value) return filteredVarieties.value;
  604. return filteredVarieties.value.slice(0, DEFAULT_VISIBLE_COUNT);
  605. });
  606. const createEmptyForm = (category) => ({
  607. uid: `sel-${++selectedUid}`,
  608. id: "",
  609. name: "",
  610. categoryId: category?.id ?? null,
  611. categoryName: category?.name ?? "",
  612. cropCode: category?.code || "",
  613. firstCrop: category?.firstCrop || "",
  614. phenologyId: "",
  615. startTime: "",
  616. area: "",
  617. location: "",
  618. });
  619. /** 始终保留可编辑表单卡:农户一张;农服按品类各一张 */
  620. const ensureDefaultForm = () => {
  621. const category = currentCategory.value;
  622. if (!category) {
  623. if (!props.multiple) selectedList.value = [];
  624. return;
  625. }
  626. if (props.multiple) {
  627. const categoryIds = new Set(categoryTabs.value.map((tab) => String(tab.id)));
  628. selectedList.value = selectedList.value.filter((item) =>
  629. categoryIds.has(String(item.categoryId))
  630. );
  631. const exists = selectedList.value.some(
  632. (item) => String(item.categoryId) === String(category.id)
  633. );
  634. if (!exists) {
  635. selectedList.value.push(createEmptyForm(category));
  636. }
  637. return;
  638. }
  639. if (!selectedList.value.length) {
  640. selectedList.value = [createEmptyForm(category)];
  641. return;
  642. }
  643. const item = selectedList.value[0];
  644. if (String(item.categoryId) !== String(category.id)) {
  645. selectedList.value = [createEmptyForm(category)];
  646. return;
  647. }
  648. item.categoryId = category.id;
  649. item.categoryName = category.name;
  650. item.cropCode = category.code || item.cropCode || "";
  651. item.firstCrop = category.firstCrop || item.firstCrop || "";
  652. };
  653. const getCurrentForm = () => {
  654. if (props.multiple) {
  655. return (
  656. selectedList.value.find(
  657. (item) => String(item.categoryId) === String(activeCategoryId.value)
  658. ) || null
  659. );
  660. }
  661. return selectedList.value[0] || null;
  662. };
  663. const currentSelected = computed(() => getCurrentForm());
  664. const hasVariety = computed(() => !!currentSelected.value?.id);
  665. const currentPhenologyOptions = computed(() =>
  666. getPhenologyOptions(currentSelected.value?.categoryId || activeCategoryId.value)
  667. );
  668. const currentStartingPointQuestion = computed(() =>
  669. getStartingPointQuestion(currentSelected.value?.categoryId || activeCategoryId.value)
  670. );
  671. watch(activeCategoryId, async (id, prevId) => {
  672. searchKeyword.value = "";
  673. appliedKeyword.value = "";
  674. gridExpanded.value = false;
  675. ensureDefaultForm();
  676. // 初始化赋值由 bootstrap 拉取,避免与 onMounted 重复请求
  677. if (id == null || prevId == null) return;
  678. if (String(id) === String(prevId)) return;
  679. await fetchVarietiesByCrop(id);
  680. fetchInitialInteractOptionsWithoutZone();
  681. });
  682. const isSelected = (id) =>
  683. !!id && String(getCurrentForm()?.id) === String(id);
  684. const handleSearch = () => {
  685. appliedKeyword.value = searchKeyword.value;
  686. gridExpanded.value = false;
  687. };
  688. const applyVarietyToForm = (variety) => {
  689. ensureDefaultForm();
  690. const item = getCurrentForm();
  691. if (!item || !variety || !currentCategory.value) return;
  692. item.id = variety.id;
  693. item.name = variety.name;
  694. item.categoryId = currentCategory.value.id;
  695. item.categoryName = currentCategory.value.name;
  696. item.cropCode = currentCategory.value.code || item.cropCode || "";
  697. item.firstCrop = currentCategory.value.firstCrop || item.firstCrop || "";
  698. item.phenologyId = "";
  699. saveSelectedListDraft();
  700. };
  701. const handleFormVarietyChange = (varietyId) => {
  702. if (!varietyId) {
  703. clearVariety();
  704. return;
  705. }
  706. const found = currentVarieties.value.find((item) => String(item.id) === String(varietyId));
  707. if (!found) return;
  708. applyVarietyToForm(found);
  709. };
  710. const clearVariety = () => {
  711. ensureDefaultForm();
  712. const item = getCurrentForm();
  713. if (!item) return;
  714. item.id = "";
  715. item.name = "";
  716. item.phenologyId = "";
  717. item.startTime = "";
  718. item.area = "";
  719. item.location = "";
  720. nextTick(() => updateItemMap(item.uid, getDefaultPoint(), false));
  721. saveSelectedListDraft();
  722. };
  723. const onFormBodyClick = () => {
  724. if (!hasVariety.value) {
  725. ElMessage.warning("请选择品种");
  726. }
  727. };
  728. const toggleVariety = (variety) => {
  729. const current = getCurrentForm();
  730. if (current && String(current.id) === String(variety.id)) {
  731. clearVariety();
  732. return;
  733. }
  734. applyVarietyToForm(variety);
  735. };
  736. const handlePrev = () => {
  737. const idx = categoryTabs.value.findIndex(
  738. (tab) => String(tab.id) === String(activeCategoryId.value)
  739. );
  740. if (idx > 0) {
  741. activeCategoryId.value = categoryTabs.value[idx - 1].id;
  742. saveSelectedListDraft();
  743. return;
  744. }
  745. emit("prev");
  746. };
  747. const getBaForm = () => {
  748. try {
  749. const raw = sessionStorage.getItem("ENTRY_BA_FORM");
  750. return raw ? JSON.parse(raw) : null;
  751. } catch {
  752. return null;
  753. }
  754. };
  755. const validateItem = (item, categoryName = "") => {
  756. const label = item?.name || categoryName;
  757. if (!item?.id) {
  758. ElMessage.warning(categoryName ? `请选择「${categoryName}」的品种` : "请选择一个品种");
  759. return false;
  760. }
  761. if (!item.phenologyId) {
  762. ElMessage.warning(`请选择「${label}」的当下物候期`);
  763. return false;
  764. }
  765. if (!item.startTime) {
  766. ElMessage.warning(`请填写「${label}」的起种点时间`);
  767. return false;
  768. }
  769. if (item.area === "" || item.area == null || Number(item.area) <= 0) {
  770. ElMessage.warning(`请填写「${label}」的种植亩数`);
  771. return false;
  772. }
  773. if (!item.location) {
  774. ElMessage.warning(`请选择「${label}」的种植点位`);
  775. return false;
  776. }
  777. return true;
  778. };
  779. const validateSubmitData = () => {
  780. if (!categoryTabs.value.length) {
  781. ElMessage.warning("请先选择种植品类");
  782. return false;
  783. }
  784. if (props.multiple) {
  785. for (const category of categoryTabs.value) {
  786. const item = selectedList.value.find(
  787. (row) => String(row.categoryId) === String(category.id)
  788. );
  789. if (!validateItem(item, category.name)) return false;
  790. }
  791. } else if (!validateItem(selectedList.value[0])) {
  792. return false;
  793. }
  794. const baForm = getBaForm();
  795. if (!baForm?.name || !baForm?.phone) {
  796. ElMessage.warning("请先完善个人信息");
  797. return false;
  798. }
  799. return true;
  800. };
  801. const handleConfirm = () => {
  802. const currentCategoryName = currentCategory.value?.name || "";
  803. if (props.multiple && !validateItem(getCurrentForm(), currentCategoryName)) return;
  804. const idx = categoryTabs.value.findIndex(
  805. (tab) => String(tab.id) === String(activeCategoryId.value)
  806. );
  807. if (idx > -1 && idx < categoryTabs.value.length - 1) {
  808. activeCategoryId.value = categoryTabs.value[idx + 1].id;
  809. saveSelectedListDraft();
  810. return;
  811. }
  812. if (!validateSubmitData()) return;
  813. selectedList.value.forEach((row) => {
  814. if (!row.id) return;
  815. const options = getPhenologyOptions(row.categoryId);
  816. const found = options.find((opt) => String(opt.id) === String(row.phenologyId));
  817. row.phenophase = found?.name || String(row.phenologyId || "");
  818. row.phenophaseCode = found?.code || String(row.phenologyId || "");
  819. });
  820. saveSelectedListDraft();
  821. emit("next");
  822. };
  823. </script>
  824. <style lang="scss" scoped>
  825. .select-variety {
  826. flex: 1;
  827. min-height: 0;
  828. display: flex;
  829. flex-direction: column;
  830. overflow: hidden;
  831. &__content {
  832. flex: 1;
  833. min-height: 0;
  834. overflow-y: auto;
  835. -webkit-overflow-scrolling: touch;
  836. padding: 20px 10px 100px;
  837. .page-header {
  838. margin-bottom: 20px;
  839. .page-title {
  840. font-size: 26px;
  841. color: #005599;
  842. line-height: 30px;
  843. font-family: "PangMenZhengDao";
  844. .page-title-tip {
  845. font-size: 20px;
  846. }
  847. }
  848. .page-subtitle {
  849. color: rgba(46, 46, 46, 0.4);
  850. }
  851. }
  852. .category-title {
  853. display: flex;
  854. align-items: center;
  855. justify-content: center;
  856. gap: 1px;
  857. height: 24px;
  858. margin-bottom: 9px;
  859. padding: 0 6px;
  860. box-sizing: border-box;
  861. &__line {
  862. flex: 1;
  863. height: 1px;
  864. background: linear-gradient(90deg, rgba(118, 118, 118, 0) 0%, rgba(118, 118, 118, 0.4) 100%);
  865. &.right-line {
  866. background: linear-gradient(270deg, rgba(118, 118, 118, 0) 0%, rgba(118, 118, 118, 0.4) 100%);
  867. }
  868. }
  869. &__diamond {
  870. flex-shrink: 0;
  871. width: 6px;
  872. height: 6px;
  873. background: rgba(61, 61, 61, 0.2);
  874. transform: rotate(45deg);
  875. }
  876. &__text {
  877. flex-shrink: 0;
  878. padding: 0 10px;
  879. font-size: 16px;
  880. color: #3D3D3D;
  881. line-height: 24px;
  882. white-space: nowrap;
  883. }
  884. }
  885. .variety-card {
  886. margin-bottom: 10px;
  887. background: rgba(255, 255, 255, 0.5);
  888. border: 1px solid #fff;
  889. border-radius: 10px;
  890. padding: 10px 6px;
  891. .search-bar {
  892. margin-bottom: 10px;
  893. :deep(.el-input__wrapper) {
  894. height: 36px;
  895. padding: 0 10px;
  896. background: #fff;
  897. border-radius: 6px;
  898. box-shadow: none;
  899. }
  900. :deep(.el-input__inner) {
  901. color: #1a1a1a;
  902. line-height: 20px;
  903. &::placeholder {
  904. color: rgba(29, 33, 41, 0.2);
  905. }
  906. }
  907. .search-icon {
  908. font-size: 16px;
  909. color: rgba(0, 0, 0, 0.2);
  910. }
  911. .search-btn {
  912. color: #2199f8;
  913. }
  914. }
  915. .variety-grid {
  916. display: grid;
  917. grid-template-columns: repeat(4, minmax(0, 1fr));
  918. gap: 8px;
  919. .variety-item {
  920. display: flex;
  921. align-items: center;
  922. justify-content: center;
  923. padding: 7px 0;
  924. background: #fff;
  925. border: 1px solid transparent;
  926. border-radius: 6px;
  927. color: #565656;
  928. text-align: center;
  929. &.selected {
  930. color: #2199f8;
  931. background: rgba(33, 153, 248, 0.1);
  932. border-color: #2199f8;
  933. }
  934. }
  935. }
  936. .expand-trigger {
  937. margin-top: 10px;
  938. text-align: center;
  939. color: rgba(0, 0, 0, 0.6);
  940. }
  941. .empty-tip {
  942. padding: 24px 0 10px;
  943. text-align: center;
  944. color: rgba(0, 0, 0, 0.35);
  945. font-size: 14px;
  946. }
  947. }
  948. .form-card {
  949. margin-bottom: 10px;
  950. background: #fff;
  951. border-radius: 10px;
  952. padding: 12px;
  953. &__header {
  954. display: flex;
  955. align-items: center;
  956. border-bottom: .2px solid rgba(0, 0, 0, 0.05);
  957. padding-bottom: 10px;
  958. .title-icon {
  959. width: 15px;
  960. object-fit: contain;
  961. margin-right: 5px;
  962. }
  963. .variety-select {
  964. flex: 0 0 auto;
  965. width: auto !important;
  966. max-width: calc(100% - 50px);
  967. :deep(.el-select__wrapper) {
  968. box-shadow: none;
  969. padding: 0;
  970. display: inline-flex;
  971. width: auto !important;
  972. }
  973. :deep(.el-select__selection) {
  974. display: inline-flex;
  975. width: auto;
  976. flex: 0 0 auto;
  977. }
  978. :deep(.el-select__placeholder) {
  979. position: static;
  980. font-size: 16px;
  981. color: #000;
  982. transform: none;
  983. }
  984. :deep(.el-select__caret) {
  985. color: #000;
  986. font-size: 16px;
  987. }
  988. }
  989. .delete-btn {
  990. flex-shrink: 0;
  991. margin-left: auto;
  992. padding-left: 12px;
  993. font-size: 14px;
  994. color: #f53f3f;
  995. line-height: 20px;
  996. white-space: nowrap;
  997. }
  998. }
  999. &--default {
  1000. .form-card__header {
  1001. .variety-select {
  1002. :deep(.el-select__selected-item),
  1003. :deep(.el-select__placeholder) {
  1004. color: #1d2129;
  1005. }
  1006. :deep(.el-select__placeholder.is-transparent) {
  1007. color: rgba(34, 34, 34, 0.3);
  1008. }
  1009. :deep(.el-select__caret) {
  1010. color: rgba(34, 34, 34, 0.3);
  1011. }
  1012. }
  1013. }
  1014. .form-card__body--locked {
  1015. cursor: pointer;
  1016. .form-row {
  1017. pointer-events: none;
  1018. }
  1019. }
  1020. }
  1021. .form-row {
  1022. display: flex;
  1023. align-items: center;
  1024. justify-content: space-between;
  1025. gap: 12px;
  1026. margin-top: 10px;
  1027. .form-label {
  1028. font-size: 14px;
  1029. color: #1D2129;
  1030. &.muted {
  1031. color: rgba(29, 33, 41, 0.2);
  1032. }
  1033. }
  1034. .form-input {
  1035. flex: 1;
  1036. min-width: 0;
  1037. :deep(.el-input__wrapper) {
  1038. box-shadow: none;
  1039. background: transparent;
  1040. padding: 0;
  1041. justify-content: flex-end;
  1042. }
  1043. :deep(.el-input__inner) {
  1044. text-align: right;
  1045. font-size: 14px;
  1046. color: #1a1a1a;
  1047. &::placeholder {
  1048. color: rgba(0, 0, 0, 0.25);
  1049. }
  1050. }
  1051. }
  1052. .form-select {
  1053. flex: 0 0 auto;
  1054. width: auto;
  1055. max-width: 160px;
  1056. margin-left: auto;
  1057. :deep(.el-select__wrapper) {
  1058. box-shadow: none;
  1059. background: transparent;
  1060. padding: 0;
  1061. justify-content: flex-end;
  1062. min-height: auto;
  1063. width: auto;
  1064. }
  1065. :deep(.el-select__selection) {
  1066. justify-content: flex-end;
  1067. flex: 0 1 auto;
  1068. }
  1069. :deep(.el-select__placeholder),
  1070. :deep(.el-select__selected-item) {
  1071. text-align: right;
  1072. font-size: 14px;
  1073. color: #1a1a1a;
  1074. }
  1075. :deep(.el-select__placeholder) {
  1076. color: #1D2129;
  1077. position: static;
  1078. transform: none;
  1079. }
  1080. :deep(.el-select__placeholder.is-transparent) {
  1081. color: rgba(0, 0, 0, 0.25);
  1082. }
  1083. :deep(.el-select__caret) {
  1084. color: rgba(0, 0, 0, 0.25);
  1085. }
  1086. }
  1087. .map {
  1088. width: 98px;
  1089. height: 60px;
  1090. flex-shrink: 0;
  1091. margin-left: auto;
  1092. border-radius: 8px;
  1093. overflow: hidden;
  1094. background: linear-gradient(135deg, #e8eef3 0%, #d4dde6 100%);
  1095. pointer-events: none;
  1096. &--clickable {
  1097. pointer-events: auto;
  1098. cursor: pointer;
  1099. }
  1100. }
  1101. }
  1102. }
  1103. }
  1104. .custom-bottom-fixed-btns {
  1105. display: flex;
  1106. justify-content: space-between;
  1107. align-items: baseline;
  1108. padding: 10px 12px 0 12px;
  1109. height: 80px;
  1110. background: #fff;
  1111. box-sizing: border-box;
  1112. box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 0.4);
  1113. .bottom-btn {
  1114. padding: 0 30px;
  1115. height: 40px;
  1116. line-height: 40px;
  1117. font-size: 14px;
  1118. border-radius: 25px;
  1119. text-align: center;
  1120. }
  1121. .secondary-btn {
  1122. color: #2199f8;
  1123. background: #fff;
  1124. border: 1px solid #2199f8;
  1125. }
  1126. .primary-btn {
  1127. background: #2199f8;
  1128. color: #fff;
  1129. &.disabled {
  1130. opacity: 0.6;
  1131. pointer-events: none;
  1132. }
  1133. }
  1134. }
  1135. }
  1136. </style>
  1137. <style lang="scss">
  1138. .variety-select-popper {
  1139. width: auto !important;
  1140. min-width: 100px !important;
  1141. .el-select-dropdown__item.is-variety-selected {
  1142. color: #2199f8;
  1143. background: transparent;
  1144. font-weight: 500;
  1145. &.is-hovering,
  1146. &:hover {
  1147. color: #2199f8;
  1148. background: transparent;
  1149. }
  1150. }
  1151. }
  1152. </style>