|
|
@@ -3,41 +3,44 @@
|
|
|
<custom-header name="选择作物"></custom-header>
|
|
|
<div class="page-body">
|
|
|
<div class="top-tabs">
|
|
|
- <div v-for="tab in topTabs" :key="tab.id" class="tab-item" :class="{ active: activeTopTab === tab.id }"
|
|
|
- @click="scrollToTab(tab.id)">
|
|
|
- {{ tab.name }}
|
|
|
+ <div v-for="(m, mi) in majorTabList" :key="m.key" class="tab-item"
|
|
|
+ :class="{ active: activeMajorKey === m.key }" @click="scrollToMajor(mi)">
|
|
|
+ {{ m.label }}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div ref="categoryScrollEl" class="category-sections">
|
|
|
- <div v-for="tab in topTabs" :key="tab.id" :id="tabAnchorId(tab.id)" class="tab-anchor-block">
|
|
|
- <template v-for="section in cropSource[tab.id] || []" :key="sectionKey(tab.id, section.id)">
|
|
|
- <div class="category-section">
|
|
|
- <div class="section-header">
|
|
|
- <div class="section-title">
|
|
|
- <span class="title-bar"></span>
|
|
|
- <span>{{ section.name }}</span>
|
|
|
- </div>
|
|
|
- <el-input v-model="searchMap[sectionKey(tab.id, section.id)]" class="search-wrap"
|
|
|
- placeholder="搜索品类" :prefix-icon="Search" />
|
|
|
+ <div v-for="(m, mi) in majorTabList" :key="m.key" :id="majorAnchorId(mi)" class="major-anchor-block">
|
|
|
+ <div class="category-section">
|
|
|
+ <div class="section-header">
|
|
|
+ <div class="section-title">
|
|
|
+ <span class="title-bar"></span>
|
|
|
+ <span>{{ m.label }}</span>
|
|
|
</div>
|
|
|
+ <el-input v-model="searchMap[majorBlockKey(m.key)]" class="search-wrap" placeholder="搜索品类">
|
|
|
+ <template #prefix>
|
|
|
+ <el-icon>
|
|
|
+ <Search />
|
|
|
+ </el-icon>
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="crop-grid">
|
|
|
- <div v-for="crop in getVisibleCrops(tab.id, section)" :key="crop.id" class="crop-item"
|
|
|
- :class="{ selected: isSelected(crop.id) }"
|
|
|
- @click="handleCropClick(tab.id, section, crop.id)">
|
|
|
- <span>{{ crop.name }}</span>
|
|
|
- <span v-if="isSelected(crop.id)" class="selected-mark">
|
|
|
- <el-icon><Select /></el-icon>
|
|
|
- </span>
|
|
|
- </div>
|
|
|
+ <div class="crop-grid">
|
|
|
+ <div class="crop-item" v-for="tab in getVisibleTabsForMajor(m.key)" :key="blockKey(tab)"
|
|
|
+ :class="{ selected: isTabVarietySelected(tab) }" @click="handleVarietyTileClick(tab)">
|
|
|
+ <span>{{ tab.variety_name }}</span>
|
|
|
+ <span v-if="isTabVarietySelected(tab)" class="selected-mark">
|
|
|
+ <el-icon><Select /></el-icon>
|
|
|
+ </span>
|
|
|
</div>
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="expand-trigger" @click="toggleExpand(tab.id, section.id)">
|
|
|
- {{ expandedSections[sectionKey(tab.id, section.id)] ? "收起" : "点击展开更多" }}
|
|
|
- </div>
|
|
|
+ <div v-if="shouldShowExpandForMajor(m.key)" class="expand-trigger"
|
|
|
+ @click="toggleExpandMajor(m.key)">
|
|
|
+ {{ expandedSections[majorBlockKey(m.key)] ? "收起" : "点击展开更多" }}
|
|
|
</div>
|
|
|
- </template>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -49,27 +52,34 @@
|
|
|
<popup v-model:show="showPopup" class="crop-popup" round>
|
|
|
<div class="popup-title">请选择具体品种</div>
|
|
|
<div class="popup-body">
|
|
|
- <div v-if="popupSection" class="category-section">
|
|
|
+ <div v-if="popupVarietyDetail" class="category-section">
|
|
|
<div class="section-header">
|
|
|
<div class="section-title">
|
|
|
<span class="title-bar"></span>
|
|
|
- <span>{{ popupSection.name }}</span>
|
|
|
+ <span>{{ popupVarietyDetail.varietyName }}</span>
|
|
|
</div>
|
|
|
- <el-input v-model="popupSearchKeyword" class="search-wrap" placeholder="搜索品种" :prefix-icon="Search" />
|
|
|
+ <el-input v-model="popupSearchKeyword" class="search-wrap" placeholder="搜索品种">
|
|
|
+ <template #prefix>
|
|
|
+ <el-icon>
|
|
|
+ <Search />
|
|
|
+ </el-icon>
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
</div>
|
|
|
<div class="crop-grid">
|
|
|
- <div v-for="crop in popupVisibleCrops" :key="crop.id" class="crop-item"
|
|
|
- :class="{ selected: isSelected(crop.id) }" @click="toggleCrop(crop.id)">
|
|
|
- <span>{{ crop.name }}</span>
|
|
|
- <span v-if="isSelected(crop.id)" class="selected-mark">
|
|
|
+ <div v-for="(cat, catIdx) in popupVisibleCategories" :key="categoryRowKey(cat, catIdx)"
|
|
|
+ class="crop-item" :class="{ selected: isPopupSelected(cat) }"
|
|
|
+ @click="togglePopupCrop(cat)">
|
|
|
+ <span>{{ cat.category_name }}</span>
|
|
|
+ <span v-if="isPopupSelected(cat)" class="selected-mark">
|
|
|
<el-icon><Select /></el-icon>
|
|
|
</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div class="switch-btn" @click="handleSwitch">换一换</div>
|
|
|
+ <div v-if="shouldShowPopupSwitch" class="switch-btn" @click="handleSwitch">换一换</div>
|
|
|
<div class="popup-actions">
|
|
|
- <div class="action-btn cancel-btn" @click="clearSelection">取消选中</div>
|
|
|
+ <div class="action-btn cancel-btn" @click="clearPopupSelection">取消选中</div>
|
|
|
<div class="action-btn confirm-btn" @click="handlePopupConfirm">确认</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -78,9 +88,12 @@
|
|
|
|
|
|
<script setup>
|
|
|
import CustomHeader from "@/components/customHeader.vue";
|
|
|
-import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
|
|
+import { ElMessage } from "element-plus";
|
|
|
+import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref } from "vue";
|
|
|
import { Popup } from "vant";
|
|
|
-import { Search, Select } from "@element-plus/icons-vue";
|
|
|
+import { useRouter } from "vue-router";
|
|
|
+
|
|
|
+const router = useRouter();
|
|
|
|
|
|
const SECTION_VISIBLE_COUNT = 9;
|
|
|
const POPUP_PAGE_SIZE = 12;
|
|
|
@@ -88,74 +101,17 @@ const TAB_SCROLL_MARGIN = 8;
|
|
|
const TAB_SCROLL_LEAD = 16;
|
|
|
const PROGRAMMATIC_SCROLL_UNLOCK_MS = 750;
|
|
|
|
|
|
-const topTabs = [
|
|
|
- { id: "tree", name: "类别" },
|
|
|
- { id: "grain", name: "类别" },
|
|
|
- { id: "bean", name: "类别" },
|
|
|
- { id: "other", name: "类别" },
|
|
|
-];
|
|
|
-
|
|
|
-const cropSource = {
|
|
|
- tree: [
|
|
|
- {
|
|
|
- id: "s1",
|
|
|
- name: "类别",
|
|
|
- crops: Array.from({ length: 10 }, (_, idx) => ({
|
|
|
- id: `tree-s1-${idx + 1}`,
|
|
|
- name: "品类",
|
|
|
- })),
|
|
|
- },
|
|
|
- {
|
|
|
- id: "s2",
|
|
|
- name: "类别",
|
|
|
- crops: Array.from({ length: 10 }, (_, idx) => ({
|
|
|
- id: `tree-s2-${idx + 1}`,
|
|
|
- name: "品类",
|
|
|
- })),
|
|
|
- },
|
|
|
- {
|
|
|
- id: "s3",
|
|
|
- name: "类别",
|
|
|
- crops: Array.from({ length: 10 }, (_, idx) => ({
|
|
|
- id: `tree-s3-${idx + 1}`,
|
|
|
- name: "品类",
|
|
|
- })),
|
|
|
- },
|
|
|
- ],
|
|
|
- grain: [
|
|
|
- {
|
|
|
- id: "s1",
|
|
|
- name: "类别",
|
|
|
- crops: Array.from({ length: 9 }, (_, idx) => ({
|
|
|
- id: `grain-s1-${idx + 1}`,
|
|
|
- name: "品类",
|
|
|
- })),
|
|
|
- },
|
|
|
- ],
|
|
|
- bean: [
|
|
|
- {
|
|
|
- id: "s1",
|
|
|
- name: "类别",
|
|
|
- crops: Array.from({ length: 9 }, (_, idx) => ({
|
|
|
- id: `bean-s1-${idx + 1}`,
|
|
|
- name: "品类",
|
|
|
- })),
|
|
|
- },
|
|
|
- ],
|
|
|
- other: [
|
|
|
- {
|
|
|
- id: "s1",
|
|
|
- name: "类别",
|
|
|
- crops: Array.from({ length: 9 }, (_, idx) => ({
|
|
|
- id: `other-s1-${idx + 1}`,
|
|
|
- name: "品类",
|
|
|
- })),
|
|
|
- },
|
|
|
- ],
|
|
|
-};
|
|
|
+// 接口 data 下按「大田作物 / 常绿果树」等大类的分组;顶部 Tab 展示这一层
|
|
|
+const majorTabList = ref([]);
|
|
|
+const activeMajorKey = ref(null);
|
|
|
+
|
|
|
+// 扁平品种列表(含 majorKey),供弹窗查找、一大类下筛选用
|
|
|
+const categoryList = ref([]);
|
|
|
|
|
|
-const activeTopTab = ref(topTabs[0].id);
|
|
|
+/** 页面最终选中(底部确认带回):仅弹窗点「确认」后写入 */
|
|
|
const selectedCropIds = ref(new Set());
|
|
|
+/** 弹窗内临时勾选,与外面列表互不影响 */
|
|
|
+const popupSelectedIds = ref(new Set());
|
|
|
const expandedSections = ref({});
|
|
|
const searchMap = ref({});
|
|
|
const showPopup = ref(false);
|
|
|
@@ -181,17 +137,25 @@ const clearProgrammaticScrollWatchers = (root) => {
|
|
|
/** 取消未完成的点击滚动时释放锁,避免连点清掉定时器后无法跟手滚动 */
|
|
|
tabScrollProgrammatic.value = false;
|
|
|
};
|
|
|
-/** 弹窗展示的数据:来自用户点击的 tab + 类别块 */
|
|
|
-const popupContext = ref({ tabId: null, sectionId: null });
|
|
|
-
|
|
|
-const sectionKey = (tabId, sectionId) => `${tabId}-${sectionId}`;
|
|
|
-const tabAnchorId = (tabId) => `crop-tab-anchor-${tabId}`;
|
|
|
-const getTabAnchorEl = (root, tabId) => root?.querySelector(`#${tabAnchorId(tabId)}`);
|
|
|
-const filterByKeyword = (list, keyword) => {
|
|
|
- const normalizedKeyword = (keyword || "").trim();
|
|
|
- if (!normalizedKeyword) return list;
|
|
|
- return list.filter((item) => item.name.includes(normalizedKeyword));
|
|
|
+/** 弹窗:当前选中的品种 tab(variety_code + majorKey 定位 categoryList 中的一项) */
|
|
|
+const popupContext = ref({ tabId: null, majorKey: null });
|
|
|
+
|
|
|
+/** 同一页内搜索/展开区块 key;含一大类时避免不同大类下 variety_code 冲突 */
|
|
|
+const blockKey = (tab) => {
|
|
|
+ if (tab.majorKey != null && tab.majorKey !== "") {
|
|
|
+ return `${tab.majorKey}:${tab.variety_code}`;
|
|
|
+ }
|
|
|
+ return String(tab.variety_code);
|
|
|
};
|
|
|
+
|
|
|
+const majorAnchorId = (majorIndex) => `major-anchor-${majorIndex}`;
|
|
|
+
|
|
|
+/** 一大类整块卡片:搜索 / 展开 共用同一 key */
|
|
|
+const majorBlockKey = (majorKey) => String(majorKey);
|
|
|
+
|
|
|
+const tabsUnderMajor = (majorKey) =>
|
|
|
+ categoryList.value.filter((t) => t.majorKey === majorKey);
|
|
|
+
|
|
|
const sliceByPage = (list, page, pageSize) => {
|
|
|
const start = page * pageSize;
|
|
|
return list.slice(start, start + pageSize);
|
|
|
@@ -206,13 +170,40 @@ const toggleSetItem = (sourceSet, item) => {
|
|
|
return next;
|
|
|
};
|
|
|
|
|
|
-const popupSection = computed(() => {
|
|
|
- const { tabId, sectionId } = popupContext.value;
|
|
|
- if (!tabId || !sectionId) return null;
|
|
|
- const sections = cropSource[tabId] || [];
|
|
|
- return sections.find((s) => s.id === sectionId) || null;
|
|
|
+/** 弹窗标题为点击的品种名;列表为该品种下 categories(category_name / categorycode) */
|
|
|
+const popupVarietyDetail = computed(() => {
|
|
|
+ const { tabId, majorKey } = popupContext.value;
|
|
|
+ if (tabId == null) return null;
|
|
|
+ const tab = categoryList.value.find((c) => {
|
|
|
+ if (String(c.variety_code) !== String(tabId)) return false;
|
|
|
+ if (majorKey != null && majorKey !== "") return c.majorKey === majorKey;
|
|
|
+ return c.majorKey == null || c.majorKey === "";
|
|
|
+ });
|
|
|
+ if (!tab) return null;
|
|
|
+ return {
|
|
|
+ varietyName: tab.variety_name ?? "",
|
|
|
+ categories: Array.isArray(tab.categories) ? tab.categories : [],
|
|
|
+ };
|
|
|
+});
|
|
|
+
|
|
|
+const popupFilteredCategories = computed(() => {
|
|
|
+ const list = popupVarietyDetail.value?.categories ?? [];
|
|
|
+ const kw = (popupSearchKeyword.value || "").trim();
|
|
|
+ if (!kw) return list;
|
|
|
+ return list.filter((c) =>
|
|
|
+ String(c.category_name ?? c.name ?? "").includes(kw)
|
|
|
+ );
|
|
|
});
|
|
|
|
|
|
+const popupVisibleCategories = computed(() =>
|
|
|
+ sliceByPage(popupFilteredCategories.value, popupPage.value, POPUP_PAGE_SIZE)
|
|
|
+);
|
|
|
+
|
|
|
+/** 品类超过 9 条才需要「换一换」分页切换 */
|
|
|
+const shouldShowPopupSwitch = computed(
|
|
|
+ () => popupFilteredCategories.value.length > SECTION_VISIBLE_COUNT
|
|
|
+);
|
|
|
+
|
|
|
/** 锚点顶部相对滚动容器内容区的纵向位置(与 scrollTop 同坐标系) */
|
|
|
const anchorContentTop = (root, el) => {
|
|
|
const elRect = el.getBoundingClientRect();
|
|
|
@@ -220,21 +211,24 @@ const anchorContentTop = (root, el) => {
|
|
|
return root.scrollTop + (elRect.top - rootRect.top);
|
|
|
};
|
|
|
|
|
|
-/** 根据滚动位置更新顶部 Tab 高亮(与点击 scrollToTab 共用 activeTopTab) */
|
|
|
-const syncActiveTopTabFromScroll = () => {
|
|
|
+/** 一大类 Tab:按滚动位置高亮当前大类(定位而非切换) */
|
|
|
+const syncActiveMajorFromScroll = () => {
|
|
|
const root = categoryScrollEl.value;
|
|
|
if (!root) return;
|
|
|
+ const majors = majorTabList.value;
|
|
|
+ if (!majors.length) return;
|
|
|
const st = root.scrollTop;
|
|
|
- let nextId = topTabs[0].id;
|
|
|
- for (const tab of topTabs) {
|
|
|
- const el = getTabAnchorEl(root, tab.id);
|
|
|
+ let nextIdx = 0;
|
|
|
+ for (let i = 0; i < majors.length; i++) {
|
|
|
+ const el = root.querySelector(`#${majorAnchorId(i)}`);
|
|
|
if (!el) continue;
|
|
|
if (anchorContentTop(root, el) <= st + TAB_SCROLL_LEAD) {
|
|
|
- nextId = tab.id;
|
|
|
+ nextIdx = i;
|
|
|
}
|
|
|
}
|
|
|
- if (activeTopTab.value !== nextId) {
|
|
|
- activeTopTab.value = nextId;
|
|
|
+ const nextKey = majors[nextIdx]?.key ?? null;
|
|
|
+ if (activeMajorKey.value !== nextKey) {
|
|
|
+ activeMajorKey.value = nextKey;
|
|
|
}
|
|
|
};
|
|
|
|
|
|
@@ -243,15 +237,18 @@ const scheduleSyncFromScroll = () => {
|
|
|
if (scrollRaf) return;
|
|
|
scrollRaf = requestAnimationFrame(() => {
|
|
|
scrollRaf = 0;
|
|
|
- syncActiveTopTabFromScroll();
|
|
|
+ syncActiveMajorFromScroll();
|
|
|
});
|
|
|
};
|
|
|
|
|
|
-const scrollToTab = (tabId) => {
|
|
|
- activeTopTab.value = tabId;
|
|
|
+const scrollToMajor = (majorIndex) => {
|
|
|
+ const majors = majorTabList.value;
|
|
|
+ const key = majors[majorIndex]?.key;
|
|
|
+ if (key == null) return;
|
|
|
+ activeMajorKey.value = key;
|
|
|
nextTick(() => {
|
|
|
const root = categoryScrollEl.value;
|
|
|
- const target = getTabAnchorEl(root, tabId);
|
|
|
+ const target = root?.querySelector(`#${majorAnchorId(majorIndex)}`);
|
|
|
if (!root || !target) return;
|
|
|
clearProgrammaticScrollWatchers(root);
|
|
|
tabScrollProgrammatic.value = true;
|
|
|
@@ -260,12 +257,10 @@ const scrollToTab = (tabId) => {
|
|
|
if (unlocked) return;
|
|
|
unlocked = true;
|
|
|
clearProgrammaticScrollWatchers(root);
|
|
|
- /** 以点击目标为准,避免动画结束瞬间用 scroll 反算出现中间 Tab */
|
|
|
- activeTopTab.value = tabId;
|
|
|
+ activeMajorKey.value = key;
|
|
|
};
|
|
|
progScrollEndHandler = () => unlock();
|
|
|
root.addEventListener("scrollend", progScrollEndHandler);
|
|
|
- /** 只滚内容区,避免 scrollIntoView 带动外层滚动;与 .tab-anchor-block 的 scroll-margin-top 一致 */
|
|
|
const top = anchorContentTop(root, target);
|
|
|
root.scrollTo({ top: Math.max(0, top - TAB_SCROLL_MARGIN), behavior: "smooth" });
|
|
|
progUnlockTimer = window.setTimeout(unlock, PROGRAMMATIC_SCROLL_UNLOCK_MS);
|
|
|
@@ -288,72 +283,278 @@ const unbindCategoryScroll = () => {
|
|
|
|
|
|
onMounted(() => {
|
|
|
bindCategoryScroll();
|
|
|
- nextTick(() => syncActiveTopTabFromScroll());
|
|
|
+ nextTick(() => syncActiveMajorFromScroll());
|
|
|
+ getCategoryList();
|
|
|
+});
|
|
|
+
|
|
|
+/** keep-alive 再次进入:若无已保存的选中数据,清空页面勾选与弹窗状态 */
|
|
|
+const resetCropSelectionState = () => {
|
|
|
+ selectedCropIds.value = new Set();
|
|
|
+ popupSelectedIds.value = new Set();
|
|
|
+ showPopup.value = false;
|
|
|
+ popupSearchKeyword.value = "";
|
|
|
+ popupPage.value = 0;
|
|
|
+ popupContext.value = { tabId: null, majorKey: null };
|
|
|
+};
|
|
|
+
|
|
|
+onActivated(() => {
|
|
|
+ try {
|
|
|
+ const raw = sessionStorage.getItem("selectedCrop");
|
|
|
+ if (raw == null || String(raw).trim() === "") {
|
|
|
+ resetCropSelectionState();
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ resetCropSelectionState();
|
|
|
+ }
|
|
|
});
|
|
|
|
|
|
+/** 从 data 对象里取出「值为数组」的字段作为顶层大类 Tab(排除 total_majors 等元数据) */
|
|
|
+const META_MAJOR_KEYS = new Set(["total_majors", "totalMajors"]);
|
|
|
+
|
|
|
+const extractMajorTabGroups = (raw) => {
|
|
|
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return [];
|
|
|
+ const out = [];
|
|
|
+ for (const [key, val] of Object.entries(raw)) {
|
|
|
+ if (META_MAJOR_KEYS.has(key)) continue;
|
|
|
+ if (Array.isArray(val)) {
|
|
|
+ out.push({ key, label: key });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+};
|
|
|
+
|
|
|
+/** 兼容后端字段命名;保证 categories 一定有数组 */
|
|
|
+const normalizeCategoryTab = (item, index) => {
|
|
|
+ const categories =
|
|
|
+ item.categories ??
|
|
|
+ item.category_list ??
|
|
|
+ item.categoryList ??
|
|
|
+ item.children ??
|
|
|
+ item.subList ??
|
|
|
+ [];
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ variety_code: item.variety_code ?? item.varietyCode ?? item.code ?? `tab-${index}`,
|
|
|
+ variety_name: item.variety_name ?? item.varietyName ?? item.name ?? "",
|
|
|
+ categories: Array.isArray(categories) ? categories : [],
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+/** 0/1:项目内成功码;200:常见后端;无 code 且带 data:一并兼容 */
|
|
|
+const isListResponseOk = (res) => {
|
|
|
+ const c = res?.code;
|
|
|
+ if (c === 0 || c === 1 || c === 200) return true;
|
|
|
+ if (c == null && res && Object.prototype.hasOwnProperty.call(res, "data")) return true;
|
|
|
+ return false;
|
|
|
+};
|
|
|
+
|
|
|
+const getCategoryList = () => {
|
|
|
+ VE_API.farm.findCategoryList().then((res) => {
|
|
|
+ if (!isListResponseOk(res)) return;
|
|
|
+ const raw = res?.data !== undefined ? res.data : res;
|
|
|
+ const majors = extractMajorTabGroups(raw);
|
|
|
+ if (majors.length) {
|
|
|
+ majorTabList.value = majors;
|
|
|
+ const flat = [];
|
|
|
+ for (const m of majors) {
|
|
|
+ const arr = Array.isArray(raw[m.key]) ? raw[m.key] : [];
|
|
|
+ arr.forEach((item, i) => {
|
|
|
+ flat.push({
|
|
|
+ ...normalizeCategoryTab(item, i),
|
|
|
+ majorKey: m.key,
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ categoryList.value = flat;
|
|
|
+ activeMajorKey.value = majors[0]?.key ?? null;
|
|
|
+ nextTick(() => syncActiveMajorFromScroll());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ majorTabList.value = [];
|
|
|
+ categoryList.value = [];
|
|
|
+ activeMajorKey.value = null;
|
|
|
+ }).catch(() => { });
|
|
|
+};
|
|
|
+
|
|
|
onBeforeUnmount(() => {
|
|
|
clearProgrammaticScrollWatchers(categoryScrollEl.value);
|
|
|
unbindCategoryScroll();
|
|
|
});
|
|
|
|
|
|
-const getFilteredCrops = (tabId, section) => {
|
|
|
- const key = sectionKey(tabId, section.id);
|
|
|
- return filterByKeyword(section.crops, searchMap.value[key]);
|
|
|
+/** 一大类下:按搜索过滤品种 tile,展开前最多 SECTION_VISIBLE_COUNT 个 */
|
|
|
+const filterTabsByMajorSearch = (tabs, keyword) => {
|
|
|
+ const k = (keyword || "").trim();
|
|
|
+ if (!k) return tabs || [];
|
|
|
+ return (tabs || []).filter((tab) => {
|
|
|
+ if (String(tab.variety_name || "").includes(k)) return true;
|
|
|
+ return (tab.categories || []).some((c) =>
|
|
|
+ String(c.category_name ?? c.name ?? "").includes(k)
|
|
|
+ );
|
|
|
+ });
|
|
|
};
|
|
|
|
|
|
-const getVisibleCrops = (tabId, section) => {
|
|
|
- const list = getFilteredCrops(tabId, section);
|
|
|
- const key = sectionKey(tabId, section.id);
|
|
|
- return expandedSections.value[key] ? list : list.slice(0, SECTION_VISIBLE_COUNT);
|
|
|
+/** 当前搜索条件下该大类下的全部品种(不分页) */
|
|
|
+const getMajorFilteredTabList = (majorKey) => {
|
|
|
+ const tabs = tabsUnderMajor(majorKey);
|
|
|
+ const kw = searchMap.value[majorBlockKey(majorKey)];
|
|
|
+ return filterTabsByMajorSearch(tabs, kw);
|
|
|
};
|
|
|
|
|
|
-const toggleExpand = (tabId, sectionId) => {
|
|
|
- const key = sectionKey(tabId, sectionId);
|
|
|
+const getVisibleTabsForMajor = (majorKey) => {
|
|
|
+ const list = getMajorFilteredTabList(majorKey);
|
|
|
+ const bk = majorBlockKey(majorKey);
|
|
|
+ return expandedSections.value[bk] ? list : list.slice(0, SECTION_VISIBLE_COUNT);
|
|
|
+};
|
|
|
+
|
|
|
+const shouldShowExpandForMajor = (majorKey) =>
|
|
|
+ getMajorFilteredTabList(majorKey).length > SECTION_VISIBLE_COUNT;
|
|
|
+
|
|
|
+const toggleExpandMajor = (majorKey) => {
|
|
|
+ const key = majorBlockKey(majorKey);
|
|
|
expandedSections.value[key] = !expandedSections.value[key];
|
|
|
};
|
|
|
|
|
|
-const isSelected = (cropId) => selectedCropIds.value.has(cropId);
|
|
|
+/** 品类唯一标识:接口字段 categorycode(兼容 category_code / 旧数据 id) */
|
|
|
+const categoryCodeOf = (cat) =>
|
|
|
+ cat?.categorycode ??
|
|
|
+ cat?.categoryCode ??
|
|
|
+ cat?.category_code ??
|
|
|
+ cat?.id;
|
|
|
+
|
|
|
+/** 选中存储键:品种父级(majorKey + variety_code)+ categorycode,避免不同品种下重复 categorycode 串台 */
|
|
|
+const cropSelectionKey = (tab, cat) => {
|
|
|
+ if (!tab) return null;
|
|
|
+ const code = categoryCodeOf(cat);
|
|
|
+ if (code == null || code === "") return null;
|
|
|
+ return `${blockKey(tab)}\x1f${String(code)}`;
|
|
|
+};
|
|
|
+
|
|
|
+/** 弹窗当前对应的品种行(与 popupVarietyDetail 同源) */
|
|
|
+const getPopupTab = () => {
|
|
|
+ const { tabId, majorKey } = popupContext.value;
|
|
|
+ if (tabId == null) return null;
|
|
|
+ return (
|
|
|
+ categoryList.value.find((c) => {
|
|
|
+ if (String(c.variety_code) !== String(tabId)) return false;
|
|
|
+ if (majorKey != null && majorKey !== "") return c.majorKey === majorKey;
|
|
|
+ return c.majorKey == null || c.majorKey === "";
|
|
|
+ }) ?? null
|
|
|
+ );
|
|
|
+};
|
|
|
|
|
|
-const toggleCrop = (cropId) => {
|
|
|
- selectedCropIds.value = toggleSetItem(selectedCropIds.value, cropId);
|
|
|
+const categoryRowKey = (cat, index) => {
|
|
|
+ const tab = getPopupTab();
|
|
|
+ const ck = tab ? cropSelectionKey(tab, cat) : null;
|
|
|
+ if (ck) return ck;
|
|
|
+ const code = categoryCodeOf(cat);
|
|
|
+ return code != null && code !== "" ? String(code) : `category-${index}`;
|
|
|
};
|
|
|
|
|
|
-/** 主列表选品类:选中并打开弹窗(带当前 tab / 类别上下文) */
|
|
|
-const handleCropClick = (tabId, section, cropId) => {
|
|
|
- const alreadySelected = isSelected(cropId);
|
|
|
- selectedCropIds.value = toggleSetItem(selectedCropIds.value, cropId);
|
|
|
- if (!alreadySelected) {
|
|
|
- popupContext.value = { tabId, sectionId: section.id };
|
|
|
- popupSearchKeyword.value = "";
|
|
|
- popupPage.value = 0;
|
|
|
- showPopup.value = true;
|
|
|
+/** 品种格:仅反映已确认选中(selectedCropIds),不受弹窗内临时勾选影响 */
|
|
|
+const isTabVarietySelected = (tab) =>
|
|
|
+ (tab.categories || []).some((s) => {
|
|
|
+ const k = cropSelectionKey(tab, s);
|
|
|
+ return Boolean(k && selectedCropIds.value.has(k));
|
|
|
+ });
|
|
|
+
|
|
|
+/** 点击品种格:弹窗标题为 variety_name,内容为 categories */
|
|
|
+const handleVarietyTileClick = (tab) => {
|
|
|
+ const list = tab.categories || [];
|
|
|
+ if (!list.length) return;
|
|
|
+ popupContext.value = {
|
|
|
+ tabId: tab.variety_code,
|
|
|
+ majorKey: tab.majorKey ?? null,
|
|
|
+ };
|
|
|
+ popupSearchKeyword.value = "";
|
|
|
+ popupPage.value = 0;
|
|
|
+ const nextPopup = new Set();
|
|
|
+ for (const cat of list) {
|
|
|
+ const k = cropSelectionKey(tab, cat);
|
|
|
+ if (k && selectedCropIds.value.has(k)) nextPopup.add(k);
|
|
|
}
|
|
|
+ popupSelectedIds.value = nextPopup;
|
|
|
+ showPopup.value = true;
|
|
|
};
|
|
|
|
|
|
-const handleConfirm = () => {
|
|
|
- history.back();
|
|
|
+const isPopupSelected = (cat) => {
|
|
|
+ const tab = getPopupTab();
|
|
|
+ const k = cropSelectionKey(tab, cat);
|
|
|
+ return Boolean(k && popupSelectedIds.value.has(k));
|
|
|
};
|
|
|
|
|
|
-const popupFilteredCrops = computed(() => {
|
|
|
- if (!popupSection.value) return [];
|
|
|
- return filterByKeyword(popupSection.value.crops, popupSearchKeyword.value);
|
|
|
-});
|
|
|
+const togglePopupCrop = (cat) => {
|
|
|
+ const tab = getPopupTab();
|
|
|
+ const k = cropSelectionKey(tab, cat);
|
|
|
+ if (!k) return;
|
|
|
+ popupSelectedIds.value = toggleSetItem(popupSelectedIds.value, k);
|
|
|
+};
|
|
|
|
|
|
-const popupVisibleCrops = computed(() => {
|
|
|
- return sliceByPage(popupFilteredCrops.value, popupPage.value, POPUP_PAGE_SIZE);
|
|
|
-});
|
|
|
+/** 根据已确认的 categorycode 拼出可序列化的选项列表 */
|
|
|
+const buildSelectedCropPayload = () => {
|
|
|
+ const items = [];
|
|
|
+ for (const tab of categoryList.value) {
|
|
|
+ for (const cat of tab.categories || []) {
|
|
|
+ const k = cropSelectionKey(tab, cat);
|
|
|
+ if (!k || !selectedCropIds.value.has(k)) continue;
|
|
|
+ const code = categoryCodeOf(cat);
|
|
|
+ items.push({
|
|
|
+ categorycode: code,
|
|
|
+ category_name: cat.category_name ?? cat.name ?? "",
|
|
|
+ variety_code: tab.variety_code,
|
|
|
+ variety_name: tab.variety_name,
|
|
|
+ majorKey: tab.majorKey ?? null,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return items;
|
|
|
+};
|
|
|
+
|
|
|
+const handleConfirm = () => {
|
|
|
+ if (!selectedCropIds.value.size) {
|
|
|
+ ElMessage.warning("请至少选择一个品类");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ sessionStorage.removeItem("selectedCrop");
|
|
|
+ sessionStorage.setItem(
|
|
|
+ "selectedCrop",
|
|
|
+ JSON.stringify(buildSelectedCropPayload())
|
|
|
+ );
|
|
|
+ } catch {
|
|
|
+ ElMessage.warning("保存选中结果失败,请重试");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ router.back();
|
|
|
+};
|
|
|
|
|
|
const handleSwitch = () => {
|
|
|
- if (!popupFilteredCrops.value.length) return;
|
|
|
- const totalPage = Math.ceil(popupFilteredCrops.value.length / POPUP_PAGE_SIZE);
|
|
|
+ if (!popupFilteredCategories.value.length) return;
|
|
|
+ const totalPage = Math.ceil(popupFilteredCategories.value.length / POPUP_PAGE_SIZE);
|
|
|
popupPage.value = (popupPage.value + 1) % totalPage;
|
|
|
};
|
|
|
|
|
|
-const clearSelection = () => {
|
|
|
- selectedCropIds.value = new Set();
|
|
|
+const clearPopupSelection = () => {
|
|
|
+ popupSelectedIds.value = new Set();
|
|
|
};
|
|
|
|
|
|
const handlePopupConfirm = () => {
|
|
|
+ const detail = popupVarietyDetail.value;
|
|
|
+ const tab = getPopupTab();
|
|
|
+ if (detail?.categories?.length && tab) {
|
|
|
+ const codesInTab = new Set(
|
|
|
+ detail.categories
|
|
|
+ .map((c) => cropSelectionKey(tab, c))
|
|
|
+ .filter(Boolean)
|
|
|
+ );
|
|
|
+ const next = new Set(selectedCropIds.value);
|
|
|
+ for (const k of next) {
|
|
|
+ if (codesInTab.has(k)) next.delete(k);
|
|
|
+ }
|
|
|
+ for (const k of popupSelectedIds.value) {
|
|
|
+ next.add(k);
|
|
|
+ }
|
|
|
+ selectedCropIds.value = next;
|
|
|
+ }
|
|
|
showPopup.value = false;
|
|
|
};
|
|
|
</script>
|
|
|
@@ -451,18 +652,27 @@ const handlePopupConfirm = () => {
|
|
|
height: calc(100% - 100px);
|
|
|
|
|
|
.top-tabs {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: repeat(4, 1fr);
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: nowrap;
|
|
|
gap: 10px;
|
|
|
margin-bottom: 10px;
|
|
|
+ overflow-x: auto;
|
|
|
+ -webkit-overflow-scrolling: touch;
|
|
|
+ scrollbar-width: none;
|
|
|
+
|
|
|
+ &::-webkit-scrollbar {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
|
|
|
.tab-item {
|
|
|
+ flex: 0 0 auto;
|
|
|
border-radius: 2px;
|
|
|
- padding: 6px;
|
|
|
+ padding: 6px 14px;
|
|
|
background: #fff;
|
|
|
border: 1px solid transparent;
|
|
|
color: #858585;
|
|
|
text-align: center;
|
|
|
+ white-space: nowrap;
|
|
|
|
|
|
&.active {
|
|
|
background: #2f9cf4;
|
|
|
@@ -475,10 +685,14 @@ const handlePopupConfirm = () => {
|
|
|
overflow: auto;
|
|
|
height: 100%;
|
|
|
|
|
|
+ .major-anchor-block {
|
|
|
+ scroll-margin-top: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
.tab-anchor-block {
|
|
|
scroll-margin-top: 8px;
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
.expand-trigger {
|
|
|
text-align: center;
|
|
|
color: rgba(0, 0, 0, 0.4);
|
|
|
@@ -500,12 +714,15 @@ const handlePopupConfirm = () => {
|
|
|
width: 100%;
|
|
|
background: linear-gradient(0deg, #FFFFFF 79.37%, #93CEFD 108.08%);
|
|
|
padding: 20px 10px;
|
|
|
+
|
|
|
.popup-title {
|
|
|
text-align: center;
|
|
|
font-size: 20px;
|
|
|
}
|
|
|
+
|
|
|
.popup-body {
|
|
|
margin-top: 12px;
|
|
|
+
|
|
|
.switch-btn {
|
|
|
border-radius: 25px;
|
|
|
background: rgba(238, 238, 238, 0.6);
|
|
|
@@ -515,10 +732,12 @@ const handlePopupConfirm = () => {
|
|
|
text-align: center;
|
|
|
margin: 0 auto;
|
|
|
}
|
|
|
+
|
|
|
.popup-actions {
|
|
|
display: flex;
|
|
|
gap: 10px;
|
|
|
margin-top: 18px;
|
|
|
+
|
|
|
.action-btn {
|
|
|
flex: 1;
|
|
|
border-radius: 25px;
|
|
|
@@ -528,11 +747,11 @@ const handlePopupConfirm = () => {
|
|
|
padding: 8px;
|
|
|
background: #fff;
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
.cancel-btn {
|
|
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
.confirm-btn {
|
|
|
background: #2f9cf4;
|
|
|
color: #fff;
|
|
|
@@ -540,5 +759,4 @@ const handlePopupConfirm = () => {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
</style>
|