selectVariety.vue 41 KB

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