2 コミット cf3fd7a958 ... 016b70d787

作者 SHA1 メッセージ 日付
  huangxw 016b70d787 Merge branch 'master' of http://git.yujin.shuziyunyao.com/yujin/forestry-wx 5 日 前
  huangxw db7b28f3ae 修改 5 日 前

+ 5 - 0
src/assets/styles/public.scss

@@ -185,3 +185,8 @@ $colors: (
 .base-shadow {
     box-shadow: 3rpx 0px 6rpx 0px #C6D2CB;
 }
+// 底部安全区域适配
+.safe-area-bottom {
+    // 兼容写法
+    padding-bottom: env(safe-area-inset-bottom);
+}

+ 14 - 9
src/pages.json

@@ -588,15 +588,6 @@
                         "navigationStyle": "default"
                     }
                 },
-                {
-                    "path": "map-gd/index",
-                    "style": {
-                        "navigationBarTitleText": "地图绘制面积图",
-                        "disableScroll": true,
-                        "enablePullDownRefresh": false,
-                        "navigationStyle": "default"
-                    }
-                },
                 // 选择企业人员
                 {
                     "path": "select-cpy-member/index",
@@ -702,6 +693,20 @@
                         "navigationBarTitleText": "预览webview页面",
                         "navigationStyle": "custom"
                     }
+                },
+                // 选择培养架具体位置
+                {
+                    "path": "select-cultivation-location/index",
+                    "style": {
+                        "navigationBarTitleText": "选择培养架具体位置"
+                    }
+                },
+                // 部分选择培养架部位
+                {
+                    "path": "select-layers-amount/index",
+                    "style": {
+                        "navigationBarTitleText": "部分选择培养架部位"
+                    }
                 }
             ]
         },

+ 2 - 3
src/pages/plant/base/index.vue

@@ -180,10 +180,9 @@
                                                 <text class="f-w-5">{{ selectDictLabel(pt_org_type, item?.orgType)
                                                 }}</text>
                                             </view>
-                                            <view v-if="item?.orgType" class="c-333 f-s-28 pd-5">
+                                            <view v-if="item?.gapInfo?.lng && item?.gapInfo?.lat" class="c-333 f-s-28 pd-5">
                                                 <text class="c-#666">经纬度:</text>
-                                                <text class="f-w-5">E{{ item?.gapInfo?.lng }},N{{ item?.gapInfo?.lat
-                                                }}</text>
+                                                <text class="f-w-5">E{{ item?.gapInfo?.lng }},N{{ item?.gapInfo?.lat }}</text>
                                             </view>
                                         </template>
                                         <view v-if="+(item?.tempFlag ?? 0)" class="temp_flag">暂存</view>

+ 4 - 0
src/plant/packaging/select-object/index.vue

@@ -76,6 +76,10 @@ onLoad((options: any) => {
         }
     }
 });
+//卸载监听
+onUnload(() => {
+    uni.$off(opts.value?.emitField || 'selectStorageObject');
+});
 </script>
 <style lang="scss" scoped>
 .swiper-content {

+ 10 - 5
src/plant/port/port-create/index.vue

@@ -778,7 +778,6 @@
     </ut-confirm-dialog>
     <!-- 添加drawer组件 -->
     <Drawer v-if="drawerVisible" :baseType="form.taskType" v-model="drawerVisible" @open="onDrawerOpen" @close="onDrawerClose" @confirm="onDrawerConfirm" />
-    <Drawer_laboratory v-if="laboratoryVisible" :baseId="form?.baseId" :landIds="form?.landIds" v-model="laboratoryVisible" @open="openLaboratory" @close="handleLaboratoryClose" @confirm="handleLaboratoryConfirm" />
 </template>
 
 <script setup lang="ts">
@@ -1245,7 +1244,7 @@ const save = () => {
             paging.value?.scrollIntoViewById(firstErrorField, 30, true);
             return;
         }
-    });
+    }, 500, true);
 };
 // 暂存
 const saveDraft = () => {
@@ -1275,7 +1274,7 @@ const saveDraft = () => {
         } catch (error: any) {
             console.log(error, 'error');
         }
-    });
+    }, 500, true);
 };
 const clickSwipe = async (name: any, index: number) => {
     if (name?.index == 0) {
@@ -1333,14 +1332,20 @@ const plantingMgUnit = computed(() => {
 });
 const laboratory = ref<any>();
 // 控制抽屉显示状态
-const laboratoryVisible = ref(false);
+
 // 处理实验室的关闭
 function handleLaboratoryClose() {
     laboratory.value = null;
 }
 // 处理实验室的打开
 function openLaboratory() {
-    laboratoryVisible.value = true;
+    uni.$on('cultivationLocationSelected', function (data) {
+        console.log(data);
+    });
+    uni.$u.route({
+        type: 'navigateTo',
+        url: '/tools/select-cultivation-location/index',
+    })
 }
 // 处理抽屉确认事件
 function handleLaboratoryConfirm(data: any) {

+ 0 - 5
src/tools/map-gd/index.vue

@@ -1,5 +0,0 @@
-<template>
-    <div>
-        <web-view src="https://www.shuziyunyao.com/admin/client/report-list?tlk=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOiJzeXNfdXNlcjoxOTIxMDQzNjEwNjY5MTk5MzYxIiwicm5TdHIiOiJHNzZNSlgxbjZUOFBiVEdYQnk4RnBXU3FyRDFTZkRsYSIsImNsaWVudGlkIjoiZTVjZDdlNDg5MWJmOTVkMWQxOTIwNmNlMjRhN2IzMmUiLCJjcHlpZCI6IjkxNTMwMTAyTUFDQTk2TUo5SCIsImNweU5hbWUiOiLmvJTnpLrkvIHkuJoiLCJ0ZW5hbnRJZCI6IjAwMDAwMCIsInVzZXJJZCI6MTkyMTA0MzYxMDY2OTE5OTM2MSwiZGVwdElkIjoxNzQ2Nzg0MjA3MjM5MDczNzk0fQ.Fq6ebfuf_galuCRUEVbZyj0lcXuqp9a0e7vBvSjlQKk&state=1766373817974"></web-view>
-    </div>
-</template>

+ 235 - 0
src/tools/select-cultivation-location/index.vue

@@ -0,0 +1,235 @@
+<template>
+    <z-paging ref="paging" bgColor="#f7f7f7" :loading-more-enabled="false" bottom-bg-color="#fff" paging-class="paging-btm-shadow" hide-no-more-inside :show-loading-more-no-more-view="false" safe-area-inset-bottom v-model="list" @query="query" :to-bottom-loading-more-enabled="false">
+        <template #top>
+            <ut-navbar leftText="请选择放置的具体位置" :fixed="false" :breadcrumb="false"></ut-navbar>
+        </template>
+        <view class="pd-24 bg-#f7f7f7">
+            <template v-for="(item, index) in list" :key="index">
+                <view class="land-wrapper mb-20">
+                    <view class="d-flex a-c land-item" @click="clickExpandedIndex(item.id)">
+                        <view @click.stop class="d-flex flex-cln pd2-22-24">
+                            <view @click.stop>
+                                <up-checkbox :checked="!!checkeds[item.id]?.checkedAll" usedAlone iconSize="44rpx" @change="handleCheckedAllChange(item, $event)"></up-checkbox>
+                            </view>
+                            <span class="f-s-20 c-#999">全选</span>
+                        </view>
+                        <view class="flex1">
+                            <view class="f-s-28 c-#333 f-w-5">{{ item?.landName }}</view>
+                            <view class="f-s-24 c-#666">
+                                <span class="mr-30">{{ item?.layers }}层</span>
+                                <span class="mr-30">每层{{ item?.capacityAmount }}{{ item?.capacityUnit }}</span>
+                                <span class="mr-30">共{{ item?.capacityAmount * item?.layers }}{{ item?.capacityUnit }}</span>
+                            </view>
+                        </view>
+                        <view @click="goSelectLayersAmount(item)" class="pd2-22-30 f-s-26 c-primary">部分选择</view>
+                    </view>
+                    <view class="bg-#f2f2f2 pd2-10-24 c-primary f-s-26" v-if="checkeds[item.id]?.checkedList?.length"> 已选择: {{ stringifyCheckedList(checkeds[item.id].checkedList, Number(item?.capacityAmount || 0)) || '无' }} </view>
+                </view>
+            </template>
+        </view>
+        <template #bottom>
+            <view class="pd3-10-20-20 bg-#fff">
+                <view class="mb-10 d-flex a-c">
+                    <view class="flex1 c-primary f-s-24">已选择{{ selectedCount || 0 }}个位置</view>
+                </view>
+                <view class="d-flex">
+                    <up-button @click="navigateBackOrHome()" class="mr-30" type="primary" plain>取消</up-button>
+                    <up-button @click="handleConfirmSelection" type="primary">确认选择</up-button>
+                </view>
+            </view>
+        </template>
+    </z-paging>
+</template>
+<script setup lang="ts">
+import { useClientRequest } from '@/utils/request';
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+const list = ref<any[]>([]);
+const form = ref({ baseId: 9 });
+const paging = ref();
+const query = async (pageNum: number, pageSize: number) => {
+    const params = {
+        pageNum,
+        pageSize,
+        ...form.value,
+    };
+    const res = await useClientRequest.post('/plt-api/app/baseLandInfo/chooseLandPageList', params);
+    if (res) {
+        const { data } = res;
+        data.forEach((item: any) => {
+            if (!checkeds.value[item.id]) {
+                checkeds.value[item.id] = {
+                    checkedAll: false,
+                    id: item.id,
+                    landName: item.landName,
+                    checkedList: [],
+                };
+            } else {
+                const checkedItem = checkeds.value[item.id];
+                const layers = Number(item?.layers || 0);
+                const capacityAmount = Number(item?.capacityAmount || 0);
+                const totalCapacity = layers * capacityAmount;
+                checkedItem.checkedAll = checkedItem.checkedList.length === totalCapacity;
+            }
+        });
+        paging.value.complete(data);
+    }
+};
+// 判断谁展开
+const expandedIndex = ref('');
+const onRefresh = () => {
+    try {
+        paging.value?.reload();
+    } catch (error) {
+        console.error('刷新列表失败:', error);
+    }
+};
+const checkeds = ref<any>({});
+// 监听已选择个数
+const selectedCount = computed(() => {
+    return Object.values(checkeds.value).reduce((total, item: any) => {
+        return total + (item.checkedList ? item.checkedList.length : 0);
+    }, 0);
+});
+const buildCheckedList = (item: any) => {
+    const result: string[] = [];
+    const layers = Number(item?.layers || 0);
+    const capacityAmount = Number(item?.capacityAmount || 0);
+    for (let layer = 1; layer <= layers; layer++) {
+        for (let capacity = 1; capacity <= capacityAmount; capacity++) {
+            result.push(`${layer}-${capacity}`);
+        }
+    }
+    return result;
+};
+
+const handleCheckedAllChange = (item: any, value: boolean | { value?: boolean }) => {
+    const checked = typeof value === 'boolean' ? value : !!value?.value;
+    checkeds.value[item.id] = {
+        checkedAll: checked,
+        id: item.id,
+        landName: item.landName,
+        checkedList: checked ? buildCheckedList(item) : [],
+    };
+};
+
+// 点击部分选择去往选择层数的页面
+const goSelectLayersAmount = (item: any) => {
+    uni.$once('selectLayersAmount', (data: any) => {
+        console.log('接收到选择的层数数据:', data);
+        // 在这里处理接收到的数据,例如更新列表或发送请求等
+        if (data && data.id) {
+            const landItem = list.value.find((land: any) => land.id === data.id);
+            const checkedList = Array.isArray(data.list) ? data.list : [];
+            checkeds.value[data.id] = {
+                checkedAll: checkedList.length === Number(landItem?.layers || 0) * Number(landItem?.capacityAmount || 0),
+                id: data.id,
+                landName: landItem?.landName || '',
+                checkedList,
+            };
+        }
+    });
+    uni.navigateTo({
+        url: `/tools/select-layers-amount/index`,
+        success: (res) => {
+            res.eventChannel.emit('initSelectLayersAmount', {
+                id: item.id,
+                layers: item.layers,
+                capacityAmount: item.capacityAmount,
+                checkedList: checkeds.value[item.id]?.checkedList || [],
+            });
+        },
+    });
+};
+const stringifyCheckedList = (list: any[], capacityAmount: number) => {
+    if (!list || list.length === 0) {
+        return '';
+    }
+    type ParsedItem = {
+        layer: number;
+        capacity: number;
+        value: string;
+    };
+    const parsed = list
+        .map((value) => {
+            const [layerText, capacityText] = String(value).split('-');
+            const layer = Number(layerText);
+            const capacity = Number(capacityText);
+            if (!layer || !capacity) {
+                return null;
+            }
+            return {
+                layer,
+                capacity,
+                value: `${layer}-${capacity}`,
+            };
+        })
+        .filter((item): item is ParsedItem => item !== null)
+        .sort((left, right) => {
+            if (left.layer !== right.layer) {
+                return left.layer - right.layer;
+            }
+            return left.capacity - right.capacity;
+        });
+
+    if (!parsed.length) {
+        return '';
+    }
+
+    const segments: string[] = [];
+    let startIndex = 0;
+
+    for (let index = 1; index <= parsed.length; index++) {
+        const previous = parsed[index - 1]!;
+        const current = parsed[index];
+        const isSameLayerContinuous = !!current && current.layer === previous.layer && current.capacity === previous.capacity + 1;
+        const isCrossLayerContinuous = !!current && current.layer === previous.layer + 1 && previous.capacity === capacityAmount && current.capacity === 1;
+        const isContinuous = isSameLayerContinuous || isCrossLayerContinuous;
+        if (isContinuous) {
+            continue;
+        }
+        const start = parsed[startIndex]!;
+        segments.push(start.value === previous.value ? start.value : `${start.value}至${previous.value}`);
+        startIndex = index;
+    }
+
+    return `${segments.join(',')}共${parsed.length}个位置`;
+};
+const clickExpandedIndex = (id: string) => {
+    if (expandedIndex.value === id) {
+        expandedIndex.value = '';
+    } else {
+        expandedIndex.value = id;
+    }
+};
+const handleConfirmSelection = () => {
+    // 在这里处理确认选择的逻辑,例如发送请求或更新父组件等
+    const selectedData = Object.values(checkeds.value).map((item: any) => ({
+        id: item.id,
+        landName: item.landName,
+        checkedList: item.checkedList,
+    }));
+    console.log('确认选择的数据:', selectedData);
+    
+    // 去掉没有选择任何位置的项
+    const filteredSelectedData = selectedData.filter((item: any) => item.checkedList && item.checkedList.length > 0);
+    // 发送事件给父组件或其他页面
+    uni.$emit('cultivationLocationSelected', filteredSelectedData);
+};
+onMounted(() => {
+
+});
+//卸载监听
+onUnload(() => {
+    uni.$off('cultivationLocationSelected');
+});
+</script>
+<style lang="scss" scoped>
+.land-item {
+    background-color: #fff;
+}
+
+.land-wrapper {
+    border-radius: 10rpx;
+    border: #fff solid 1px;
+}
+</style>

+ 562 - 0
src/tools/select-layers-amount/index.vue

@@ -0,0 +1,562 @@
+<template>
+    <view class="layers-page bg-#f7f7f7 ov-hd" @touchend="handleWrapperTouchEnd" @touchcancel="resetTouchState" @touchleave="handleWrapperTouchEnd">
+        <ut-navbar leftText="请选择放置的具体位置" :fixed="false" :breadcrumb="false"></ut-navbar>
+        <scroll-view class="layers-scroll" scroll-y :show-scrollbar="false" :scroll-top="scrollTop" @scroll="handleScroll" @touchmove="handleWrapperTouchMove" @touchend="handleWrapperTouchEnd" @touchcancel="resetTouchState" @touchleave="handleWrapperTouchEnd">
+            <view class="layers-content pd-25 bg-#f7f7f7 d-flex flex-wrap gap-4" @touchmove="handleWrapperTouchMove" @touchend="handleWrapperTouchEnd" @touchcancel="resetTouchState" @touchleave="handleWrapperTouchEnd">
+                <template v-for="(item, index) in layersAmount" :key="item.sort">
+                    <view class="bg-fff radius-10 text-center h-172 w-172 border-#fff card-item_layer_col p-rtv" :class="{ active: !!checkedStates[index] }" @touchstart="handleCardTouchStart(item, index, $event)">
+                        {{ item.value }}
+                        <view class="checkmark-icon">
+                            <icon class="checkmark-icon" v-if="!checkedStates[index]" type="circle" size="40rpx"></icon>
+                            <icon class="checkmark-icon" v-if="checkedStates[index]" color="#37A954" type="success" size="40rpx"></icon>
+                        </view>
+                    </view>
+                </template>
+            </view>
+        </scroll-view>
+        <view class="pd3-10-20-20 bg-#fff">
+            <view class="mb-10 d-flex a-c">
+                <up-checkbox v-model:checked="allLayers" usedAlone iconSize="44rpx" label="全选"></up-checkbox>
+                <view class="flex1 c-primary f-s-24 pl-40">已选择{{ checkedStates.filter(Boolean).length }}个位置</view>
+            </view>
+            <view class="d-flex">
+                <up-button @click="navigateBackOrHome()" class="mr-30" type="primary" plain>取消</up-button>
+                <up-button @click="handleConfirmSelection" type="primary">确认选择</up-button>
+            </view>
+            <view class="safe-area-bottom"></view>
+        </view>
+    </view>
+</template>
+<script setup lang="ts">
+type LayerItem = {
+    layer: number;
+    capacity: number;
+    value: string;
+    sort: number;
+    rowIndex: number;
+    columnIndex: number;
+};
+
+type Rect = {
+    left: number;
+    top: number;
+    width: number;
+    height: number;
+};
+
+type LayoutMetrics = {
+    contentLeft: number;
+    firstOffsetLeft: number;
+    firstOffsetTop: number;
+    cardWidth: number;
+    cardHeight: number;
+    stepX: number;
+    stepY: number;
+    columns: number;
+};
+
+type ScrollAreaRect = {
+    top: number;
+    bottom: number;
+    height: number;
+};
+const allLayers = computed({
+    get() {
+        return checkedStates.value.every(Boolean);
+    },
+    set(value: boolean) {
+        checkedStates.value = checkedStates.value.map(() => value);
+    },
+});
+const instance = getCurrentInstance();
+const opts = ref<any>(null);
+const CARDS_PER_ROW = 4;
+// 设置组培架的数组 1-1 1-2 1-3 2-1 2-2 2-3
+const layersAmount = ref<LayerItem[]>([]);
+const generateLayersAmount = (layers: number, capacityAmount: number) => {
+    const result: LayerItem[] = [];
+    for (let i = 1; i <= layers; i++) {
+        for (let j = 1; j <= capacityAmount; j++) {
+            const index = result.length;
+            result.push({
+                layer: i,
+                capacity: j,
+                value: `${i}-${j}`,
+                sort: (i - 1) * capacityAmount + j,
+                rowIndex: Math.floor(index / CARDS_PER_ROW),
+                columnIndex: index % CARDS_PER_ROW,
+            });
+        }
+    }
+    return result;
+};
+const checkedStates = ref<boolean[]>([]);
+const scrollTop = ref(0);
+const scrollMeta = reactive({
+    top: 0,
+    height: 0,
+    contentHeight: 0,
+});
+const layoutMetrics = reactive<LayoutMetrics>({
+    contentLeft: 0,
+    firstOffsetLeft: 0,
+    firstOffsetTop: 0,
+    cardWidth: 0,
+    cardHeight: 0,
+    stepX: 0,
+    stepY: 0,
+    columns: 1,
+});
+const scrollAreaRect = ref<ScrollAreaRect>({
+    top: 0,
+    bottom: 0,
+    height: 0,
+});
+const lastTouchPoint = reactive({ x: 0, y: 0 });
+let autoScrollTimer: ReturnType<typeof setInterval> | null = null;
+let autoScrollDirection = 0;
+const touchState = reactive({
+    touching: false,
+    batchActive: false,
+    scrolling: false,
+    directionLocked: '' as '' | 'horizontal' | 'vertical',
+    moved: false,
+    startIndex: -1,
+    currentIndex: -1,
+    startX: 0,
+    startY: 0,
+    targetChecked: false,
+    timer: null as any,
+});
+
+const TAP_MOVE_THRESHOLD = 8;
+const LONG_PRESS_DURATION = 280;
+const HORIZONTAL_TRIGGER_THRESHOLD = 5;
+const EDGE_TRIGGER_PX = 90;
+const AUTO_SCROLL_STEP = 18;
+const AUTO_SCROLL_INTERVAL = 40;
+
+// 计算当前页面还能滚动的最大距离,避免自动滚动越界后持续空转。
+const getMaxScrollTop = () => {
+    return Math.max(scrollMeta.contentHeight - scrollMeta.height, 0);
+};
+
+const setItemChecked = (index: number, checked: boolean) => {
+    if (checkedStates.value[index] === checked) {
+        return;
+    }
+    checkedStates.value[index] = checked;
+};
+
+const applyCheckedIndexes = (indexesToUpdate: number[], checked: boolean) => {
+    if (!indexesToUpdate.length) {
+        return;
+    }
+    indexesToUpdate.forEach((index) => {
+        if (checkedStates.value[index] !== checked) {
+            checkedStates.value[index] = checked;
+        }
+    });
+};
+
+// 将父页面传入的已选 value 列表映射成当前页面的索引选中态,用于进入页面时回显。
+const initCheckedStates = (checkedList: string[]) => {
+    const selectedSet = new Set(checkedList);
+    checkedStates.value = layersAmount.value.map((item) => selectedSet.has(item.value));
+};
+
+const clearLongPressTimer = () => {
+    if (touchState.timer) {
+        clearTimeout(touchState.timer);
+        touchState.timer = null;
+    }
+};
+
+const startLongPressTimer = () => {
+    clearLongPressTimer();
+    touchState.timer = setTimeout(() => {
+        if (!touchState.touching || touchState.startIndex < 0 || touchState.scrolling || touchState.batchActive) {
+            return;
+        }
+        activateBatchMode();
+    }, LONG_PRESS_DURATION);
+};
+
+// 进入批量模式时,先把起始项切换成目标状态,后续拖动经过的项都跟随这个状态变化。
+const activateBatchMode = () => {
+    if (touchState.batchActive || touchState.startIndex < 0) {
+        return;
+    }
+    touchState.batchActive = true;
+    touchState.directionLocked = 'horizontal';
+    touchState.targetChecked = !checkedStates.value[touchState.startIndex];
+    setItemChecked(touchState.startIndex, touchState.targetChecked);
+    touchState.currentIndex = touchState.startIndex;
+};
+
+const getTouchPoint = (event: any) => {
+    const touch = event?.touches?.[0] || event?.changedTouches?.[0];
+    if (!touch) {
+        return null;
+    }
+    return {
+        x: touch.clientX ?? touch.pageX,
+        y: touch.clientY ?? touch.pageY,
+    };
+};
+
+const updateScrollAreaRect = async () => {
+    await nextTick();
+    return new Promise<void>((resolve) => {
+        const query = uni.createSelectorQuery().in(instance?.proxy);
+        query
+            .select('.layers-scroll')
+            .boundingClientRect((rect: any) => {
+                if (!rect) {
+                    resolve();
+                    return;
+                }
+                scrollAreaRect.value = {
+                    top: rect.top,
+                    bottom: rect.bottom,
+                    height: rect.height,
+                };
+                scrollMeta.height = rect.height;
+            })
+            .select('.layers-content')
+            .boundingClientRect((rect: any) => {
+                if (rect?.height) {
+                    scrollMeta.contentHeight = rect.height;
+                }
+                resolve();
+            })
+            .exec();
+    });
+};
+
+const updateLayoutMetrics = async () => {
+    await nextTick();
+    return new Promise<void>((resolve) => {
+        const query = uni.createSelectorQuery().in(instance?.proxy);
+        let contentRect: Rect | null = null;
+        let cardRects: Rect[] = [];
+        query
+            .select('.layers-content')
+            .boundingClientRect((rect: Rect) => {
+                contentRect = rect;
+                if (rect?.height) {
+                    scrollMeta.contentHeight = rect.height;
+                }
+            })
+            .selectAll('.card-item_layer_col')
+            .boundingClientRect((rects: Rect[]) => {
+                cardRects = Array.isArray(rects) ? rects.filter(Boolean) : [];
+            })
+            .exec(() => {
+                const firstRect = cardRects[0];
+                if (!contentRect || !firstRect) {
+                    resolve();
+                    return;
+                }
+                const firstTop = firstRect.top;
+                const firstRowRects = cardRects.filter((rect) => Math.abs(rect.top - firstTop) < 2);
+                const secondRect = firstRowRects[1];
+                const nextRowRect = cardRects.find((rect) => rect.top - firstTop > 2);
+                layoutMetrics.contentLeft = contentRect.left;
+                layoutMetrics.firstOffsetLeft = firstRect.left - contentRect.left;
+                layoutMetrics.firstOffsetTop = firstRect.top - contentRect.top;
+                layoutMetrics.cardWidth = firstRect.width;
+                layoutMetrics.cardHeight = firstRect.height;
+                layoutMetrics.stepX = secondRect ? secondRect.left - firstRect.left : firstRect.width;
+                layoutMetrics.stepY = nextRowRect ? nextRowRect.top - firstRect.top : firstRect.height;
+                layoutMetrics.columns = Math.max(1, firstRowRects.length || 1);
+                resolve();
+            });
+    });
+};
+
+const findIndexByPoint = (x: number, y: number) => {
+    const { contentLeft, firstOffsetLeft, firstOffsetTop, cardWidth, cardHeight, stepX, stepY, columns } = layoutMetrics;
+    if (!cardWidth || !cardHeight || !stepX || !stepY || !columns) {
+        return -1;
+    }
+    const gapX = Math.max(stepX - cardWidth, 0);
+    const gapY = Math.max(stepY - cardHeight, 0);
+    const localX = x - contentLeft - firstOffsetLeft;
+    // 命中计算必须使用当前真实滚动位置,避免自动滚动结束后 scrollTop 绑定值与实际位置不同步。
+    const localY = y - scrollAreaRect.value.top + scrollMeta.top - firstOffsetTop;
+    const column = Math.max(0, Math.min(columns - 1, Math.floor((localX + gapX / 2) / stepX)));
+    const row = Math.max(0, Math.floor((localY + gapY / 2) / stepY));
+    const targetIndex = row * columns + column;
+    return Math.max(0, Math.min(targetIndex, layersAmount.value.length - 1));
+};
+
+// 批量拖动时只按索引区间更新,避免长数组里做字符串键查找。
+const applyRange = (targetIndex: number) => {
+    if (targetIndex < 0 || touchState.currentIndex < 0) {
+        return;
+    }
+    const from = Math.min(touchState.currentIndex, targetIndex);
+    const to = Math.max(touchState.currentIndex, targetIndex);
+    const indexesToUpdate: number[] = [];
+    for (let i = from; i <= to; i++) {
+        indexesToUpdate.push(i);
+    }
+    applyCheckedIndexes(indexesToUpdate, touchState.targetChecked);
+    touchState.currentIndex = targetIndex;
+};
+
+const handleCardTouchStart = (item: LayerItem, index: number, event: any) => {
+    const point = getTouchPoint(event);
+    touchState.touching = true;
+    touchState.batchActive = false;
+    touchState.scrolling = false;
+    touchState.directionLocked = '';
+    touchState.moved = false;
+    touchState.startIndex = index;
+    touchState.currentIndex = -1;
+    touchState.startX = point?.x ?? 0;
+    touchState.startY = point?.y ?? 0;
+    lastTouchPoint.x = touchState.startX;
+    lastTouchPoint.y = touchState.startY;
+    startLongPressTimer();
+    if (!layoutMetrics.cardWidth) {
+        updateLayoutMetrics();
+    }
+    if (!scrollAreaRect.value.height) {
+        updateScrollAreaRect();
+    }
+};
+
+const clearAutoScroll = () => {
+    if (autoScrollTimer) {
+        clearInterval(autoScrollTimer);
+        autoScrollTimer = null;
+    }
+    // 这里只停止自动滚动,不重置批量选择状态,保证停下后继续横向滑动仍然生效。
+    autoScrollDirection = 0;
+};
+
+const applySelectionByPoint = (x: number, y: number) => {
+    const targetIndex = findIndexByPoint(x, y);
+    if (targetIndex < 0) {
+        return;
+    }
+    applyRange(targetIndex);
+};
+
+// 自动滚动改成固定时间片的匀速步进,减少 scrollTop 高频回写导致的闪动感。
+const startAutoScroll = (direction: number) => {
+    if (autoScrollDirection === direction && autoScrollTimer) {
+        return;
+    }
+    clearAutoScroll();
+    autoScrollDirection = direction;
+    autoScrollTimer = setInterval(() => {
+        if (!touchState.batchActive || !touchState.touching) {
+            clearAutoScroll();
+            return;
+        }
+        const currentTop = scrollMeta.top;
+        const maxTop = getMaxScrollTop();
+        const nextTop = Math.min(Math.max(currentTop + direction * AUTO_SCROLL_STEP, 0), maxTop);
+        if (nextTop === currentTop) {
+            clearAutoScroll();
+            return;
+        }
+        scrollTop.value = nextTop;
+        scrollMeta.top = nextTop;
+        applySelectionByPoint(lastTouchPoint.x, lastTouchPoint.y);
+    }, AUTO_SCROLL_INTERVAL);
+};
+
+// 只有手指靠近可视区上下边缘,且页面确实还没到边界时,才触发自动滚动。
+const updateAutoScrollByPoint = (pointY: number) => {
+    if (!touchState.batchActive) {
+        clearAutoScroll();
+        return;
+    }
+    const currentTop = scrollMeta.top;
+    const maxTop = getMaxScrollTop();
+    const { top, bottom } = scrollAreaRect.value;
+    if (!top && !bottom) {
+        return;
+    }
+    if (pointY <= top + EDGE_TRIGGER_PX && currentTop > 0) {
+        startAutoScroll(-1);
+        return;
+    }
+    if (pointY >= bottom - EDGE_TRIGGER_PX && currentTop < maxTop) {
+        startAutoScroll(1);
+        return;
+    }
+    clearAutoScroll();
+};
+
+// 手势先做方向锁:纵向优先滚页面,横向才进入批量选择,减少误触。
+const handleWrapperTouchMove = (event: any) => {
+    if (!touchState.touching) {
+        return;
+    }
+    const point = getTouchPoint(event);
+    if (!point) {
+        return;
+    }
+    lastTouchPoint.x = point.x;
+    lastTouchPoint.y = point.y;
+
+    const deltaX = point.x - touchState.startX;
+    const deltaY = point.y - touchState.startY;
+    const absX = Math.abs(deltaX);
+    const absY = Math.abs(deltaY);
+
+    if (absX > TAP_MOVE_THRESHOLD || absY > TAP_MOVE_THRESHOLD) {
+        touchState.moved = true;
+    }
+
+    if (!touchState.directionLocked && (absX > TAP_MOVE_THRESHOLD || absY > TAP_MOVE_THRESHOLD)) {
+        touchState.directionLocked = absY > absX ? 'vertical' : 'horizontal';
+    }
+
+    if (!touchState.batchActive) {
+        if (touchState.directionLocked === 'vertical' && absY > TAP_MOVE_THRESHOLD) {
+            touchState.scrolling = true;
+            clearLongPressTimer();
+            clearAutoScroll();
+            return;
+        }
+        if (touchState.directionLocked === 'horizontal' && absX > HORIZONTAL_TRIGGER_THRESHOLD) {
+            clearLongPressTimer();
+            activateBatchMode();
+        }
+    }
+
+    if (!touchState.batchActive) {
+        return;
+    }
+
+    event?.preventDefault?.();
+    event?.stopPropagation?.();
+
+    updateAutoScrollByPoint(point.y);
+    applySelectionByPoint(point.x, point.y);
+};
+
+const handleWrapperTouchEnd = () => {
+    if (!touchState.touching) {
+        resetTouchState();
+        return;
+    }
+
+    if (!touchState.batchActive && !touchState.moved && touchState.startIndex >= 0) {
+        setItemChecked(touchState.startIndex, !checkedStates.value[touchState.startIndex]);
+    }
+
+    resetTouchState();
+};
+
+const resetTouchState = () => {
+    clearLongPressTimer();
+    clearAutoScroll();
+    touchState.touching = false;
+    touchState.batchActive = false;
+    touchState.scrolling = false;
+    touchState.directionLocked = '';
+    touchState.moved = false;
+    touchState.startIndex = -1;
+    touchState.currentIndex = -1;
+    touchState.startX = 0;
+    touchState.startY = 0;
+    touchState.targetChecked = false;
+};
+
+const handleScroll = (event: any) => {
+    const { scrollTop: top = 0 } = event?.detail || {};
+    scrollMeta.top = top;
+};
+
+// 确认选择时统一返回固定结构,后续外部页面接收时不需要再重新组装。
+const handleConfirmSelection = () => {
+    const { layers, capacityAmount } = opts.value;
+    const payload = {
+        id: opts.value?.id || '',
+        checkedAll: allLayers.value,
+        list: layersAmount.value.filter((_, index) => checkedStates.value[index]).map((item) => item.value),
+    };
+    console.log(payload);
+
+    uni.$emit('selectLayersAmount', payload);
+    uni.navigateBack({ delta: 1 });
+};
+
+onLoad((options) => {
+    try {
+        const instanceproxy: any = getCurrentInstance()?.proxy;
+        const eventChannel = instanceproxy?.getOpenerEventChannel();
+        eventChannel?.on('initSelectLayersAmount', (data: any) => {
+            const { layers, capacityAmount, id } = data;
+            opts.value = { layers, capacityAmount, id };
+            layersAmount.value = generateLayersAmount(Number(layers), Number(capacityAmount));
+            let selectedValues: string[] = [];
+            if (data?.id === id && Array.isArray(data?.checkedList)) {
+                selectedValues = data.checkedList;
+                initCheckedStates(selectedValues);
+                nextTick(() => {
+                    updateScrollAreaRect();
+                    updateLayoutMetrics();
+                });
+            }
+        });
+    } catch (error) {
+        console.warn('initSelectLayersAmount 事件通道不可用,使用空选中列表', error);
+    }
+});
+
+watch(
+    () => layersAmount.value.length,
+    () => {
+        checkedStates.value = Array.from({ length: layersAmount.value.length }, (_, index) => checkedStates.value[index] || false);
+        nextTick(() => {
+            updateScrollAreaRect();
+            updateLayoutMetrics();
+        });
+    },
+);
+
+onUnmounted(() => {
+    clearLongPressTimer();
+    clearAutoScroll();
+});
+</script>
+<style lang="scss" scoped>
+.layers-page {
+    height: 100vh;
+    display: flex;
+    flex-direction: column;
+}
+
+.layers-scroll {
+    flex: 1;
+    min-height: 0;
+}
+
+.card-item_layer_col {
+    line-height: 172rpx;
+    font-size: 28rpx;
+    font-weight: 500;
+    box-sizing: border-box;
+
+    &.active {
+        color: #37a954;
+    }
+}
+.checkmark-icon {
+    position: absolute;
+    right: 0rpx;
+    bottom: 0rpx;
+    width: 50rpx;
+    height: 50rpx;
+    line-height: 50rpx;
+    text-align: center;
+}
+</style>

+ 16 - 17
src/utils/request.ts

@@ -16,6 +16,11 @@ interface ClientDownloadConfig {
     header?: Record<string, any>;
 }
 
+interface DownloadResultMeta {
+    header?: Record<string, any>;
+    tempFilePath?: string;
+}
+
 // 获取全局请求头方法
 const getHeader = () => {
     let header = {
@@ -27,31 +32,25 @@ const getHeader = () => {
     return header;
 };
 
-const getFileType = (url: string, result?: { header?: Record<string, any> }): string => {
-    if (url) {
-        const filenameFromUrl = url.split('/').pop();
-        const urlExt = filenameFromUrl?.includes('.') ? filenameFromUrl.split('.').pop()?.split(/[?#]/)[0] : null;
-        if (urlExt) return urlExt.toLowerCase() || 'pdf';
+const setFileName = (downloadConfig: ClientDownloadConfig, result?: DownloadResultMeta): string => {
+    if (downloadConfig.fileName) {
+        return downloadConfig.fileName;
     }
 
-    if (result?.header?.['content-disposition']) {
+    const disposition = result?.header?.['content-disposition'] || result?.header?.['Content-Disposition'];
+    if (disposition) {
         try {
-            const disposition = result.header['content-disposition'];
             const filenameMatch = disposition.match(/filename\*?=(?:UTF-8''|")?([^;"\n]*)/i);
-
             if (filenameMatch && filenameMatch[1]) {
-                const filename = filenameMatch[1].replace(/['"]/g, '');
-                if (filename.includes('.')) {
-                    const ext = filename.split('.').pop()?.split(/[?#]/)[0];
-                    return ext?.toLowerCase() || 'pdf';
-                }
+                return decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
             }
         } catch (e) {
             console.warn('解析 Content-Disposition 失败:', e);
         }
     }
 
-    return 'pdf';
+    const tempFileName = result?.tempFilePath?.split('/').pop()?.split(/[?#]/)[0];
+    return tempFileName || 'download.pdf';
 };
 
 const buildDownloadUrl = (url: string, params?: Record<string, any>) => {
@@ -85,14 +84,14 @@ const download = (downloadConfig: ClientDownloadConfig, showTitle?: string): Pro
                 ...downloadConfig.header,
             },
             success: (res) => {
-                console.log(res);
-                
                 uni.hideLoading();
                 if (res.statusCode === 200) {
+                    const fileName = setFileName(downloadConfig, res as DownloadResultMeta);
+                    const fileType = fileName.split('.').pop()?.split(/[?#]/)[0]?.toLowerCase() || 'pdf';
                     uni.openDocument({
                         filePath: res.tempFilePath,
                         showMenu: true,
-                        fileType: getFileType(downloadConfig.fileName || downloadConfig.url, res as any) as any,
+                        fileType: fileType as any,
                         success: () => {
                             resolve();
                         },