滑动菜单用遇到的常见的问题

最近开发过程中遇到一个滑动菜单模块,因为以前没有接触过,市场上还没有完全符合要求的组件,开发过程中还遇到很多问题,今天趁着有时间,总结一下,我们先一下实现后的效果(源代码我会放在文章的最后):

我的项目是使用uni-app开发,主要使用vue3语句来实现,使用的组件库uView-plus这个组件库中也有对应的组件,但是代码内部仍然使用的是vue2的语法,在我这项目中使用会报错,所以干脆就把整个源文件下载下来,然后修改源码。下面是我遇到的几个问题以及解决方案,希望能帮助到大家。

首先是解决源代码中的this问题

因为vue3的代码中是没有this的,而这两个函数中都有用到这个方法,uni.createSelectorQuery().in(this)

解决办法是用vue中的getCurrentInstance这个方法来获取到当前组件的实例

js 复制代码
const currentInstance = ref(); // 组件实例

onMounted(() => {
    currentInstance.value = getCurrentInstance(); // 获取组件实例
});

下面是修改后的代码:

js 复制代码
/*
此函数用于获取指定元素的高度。它创建一个选择器查询(createSelectorQuery),
获取特定类的元素高度,并将这个值保存在指定的响应式变量中。这对于计算滚动位置非常重要。
*/
const getElRect = async (elClass, dataVal) => {
    return new Promise((resolve) => {
        const query = uni.createSelectorQuery().in(currentInstance.value);
        query
            .select('.' + elClass)
            .boundingClientRect((rect) => {
                if (!rect) {
                    setTimeout(() => {
                        getElRect(elClass, dataVal);
                    }, 10);
                    return;
                }
                if (dataVal == 'menuHeight') menuHeight.value = rect.height;
                if (dataVal == 'menuItemHeight') menuItemHeight.value = rect.height;

                dataVal = rect.height;
                resolve();
            })
            .exec();
    });
};


// 获取并存储右侧每个内容项的顶部位置。这对于后续根据滚动位置判断当前激活的菜单项非常关键。
const getMenuItemTop = () => {
    return new Promise((resolve) => {
        let selectorQuery = uni.createSelectorQuery().in(currentInstance.value);
        setTimeout(() => {
            selectorQuery
                .selectAll('.search-item')
                .boundingClientRect((rects) => {
                    if (!rects.length) {
                        setTimeout(() => {
                            getMenuItemTop();
                        }, 10);
                        return;
                    }
                    rects.forEach((rect) => {
                        arr.value.push(rect.top - rects[0].top);
                        resolve();
                    });
                })
                .exec();
        }, 100);
    });
**};**

菜单栏位置匹配,会出现上下乱动的情况

这个问题出现在下面的函数中(修改后)

js 复制代码
/*
当用户滚动右侧内容区时触发。它首先确保所有必要的尺寸信息都已经获取。然后使用一个简单的节流机制
(通过 timer)来限制事件处理的频率。该方法根据当前滚动位置更新左侧菜单的选中状态。
*/
const rightScroll = async (e) => {
    oldScrollTop.value = e.detail.scrollTop;
    if (arr.value.length === 0) {
        await getMenuItemTop();
    }
    if (timer.value) return;
    if (!menuHeight.value) {
        await getElRect('menu-scroll-view', 'menuHeight');
    }
    setTimeout(() => {
        timer.value = null;
        let scrollHeight = e.detail.scrollTop;

        for (let i = 0; i < arr.value.length; i++) {
            let height1 = Math.floor(arr.value[i]);
            let height2 = Math.floor(arr.value[i + 1]);
            if (scrollHeight >= height1 && scrollHeight < height2) {
                leftMenuStatus(i);
                return;
            }
            if (!height2) {
                leftMenuStatus(5);
            }
        }
    }, 10);
};

我们发现leftMenuStatus传递的i值与点击的i值始终是不同的,也就是说右侧在点击的时候计算高度出现了问题

顺藤摸瓜,发现getElRect这个函数获取的menuHeight左侧菜单的总高度的值使用为0,便修改了getElRect这个函数

javascript 复制代码
/*
此函数用于获取指定元素的高度。它创建一个选择器查询(createSelectorQuery),
获取特定类的元素高度,并将这个值保存在指定的响应式变量中。这对于计算滚动位置非常重要。
*/
const getElRect = async (elClass, dataVal) => {
    return new Promise((resolve) => {
        const query = uni.createSelectorQuery().in(currentInstance.value);
        query
            .select('.' + elClass)
            .boundingClientRect((rect) => {
                if (!rect) {
                    setTimeout(() => {
                        getElRect(elClass, dataVal);
                    }, 10);
                    return;
                }
                dataVal = rect.height
                resolve();
            })
            .exec();
    });
};

原因就出现在这行dataVal = rect.height代码中,我们获取的高度没有问题,因为我们是vue3的代码,所以这样赋值就会出现错误,没办法将值正确的与变量绑定。 修改后:

ini 复制代码
//将原本的dataVal = rect.height修改成下面的代码

 if (dataVal == 'menuHeight') menuHeight.value = rect.height;
 if (dataVal == 'menuItemHeight') menuItemHeight.value = rect.height;

这样就可以拿到menuHeight左侧菜单栏总高度了

点击左侧菜单栏的时候右侧不动

我们发现原来是这个函数getMenuItemTop在获取右侧距离顶部的距离的数组的时候,结果为0。原因是当我们滚动屏幕的时候,这个时候DOM元素还没有创建完成,所以获取的值就是0。解决办法:设置一个定时器,等待一定时间再去获取

scss 复制代码
// 获取并存储右侧每个内容项的顶部位置。这对于后续根据滚动位置判断当前激活的菜单项非常关键。
const getMenuItemTop = () => {
    return new Promise((resolve) => {
        let selectorQuery = uni.createSelectorQuery().in(currentInstance.value);
        setTimeout(() => {
            selectorQuery
                .selectAll('.search-item')
                .boundingClientRect((rects) => {
                    if (!rects.length) {
                        setTimeout(() => {
                            getMenuItemTop();
                        }, 10);
                        return;
                    }
                    rects.forEach((rect) => {
                        arr.value.push(rect.top - rects[0].top);
                        resolve();
                    });
                })
                .exec();
        }, 100);
    });
};

这样这个问题就解决了

左右不匹配

这个问题就在于rightScroll函数中我们在计算右侧滚动距离的时候传递的i值不正确。我们只需要修改一下我们的左右匹配机制即可(将原本需要对比的滚动距离修改成滑轮滚动的距离即可)。

js 复制代码
/*
当用户滚动右侧内容区时触发。它首先确保所有必要的尺寸信息都已经获取。然后使用一个简单的节流机制
(通过 timer)来限制事件处理的频率。该方法根据当前滚动位置更新左侧菜单的选中状态。
*/
const rightScroll = async (e) => {
    oldScrollTop.value = e.detail.scrollTop;
    if (arr.value.length === 0) {
        await getMenuItemTop();
    }
    if (timer.value) return;
    if (!menuHeight.value) {
        await getElRect('menu-scroll-view', 'menuHeight');
    }
    setTimeout(() => {
        timer.value = null;
        let scrollHeight = e.detail.scrollTop;
        for (let i = 0; i < arr.value.length; i++) {
            let height1 = Math.floor(arr.value[i]);
            let height2 = Math.floor(arr.value[i + 1]);
            if (!height2 &&  scrollHeight >= height1 && scrollHeight < height2) {
                leftMenuStatus(i);
                return;
            }
        }
    }, 10);
};

这样这个组件就可以使用了 如果还有什么bug欢迎大家来留言

修改后源代码(适用vue3)

js 复制代码
<template>
    <view class="u-wrap">
        <view class="u-search-box" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
            <view class="u-search-inner">
                <u-icon name="search" color="#909399" :size="28"></u-icon>
                <text class="u-search-text">搜索</text>
            </view>
        </view>
        <view class="u-menu-wrap">
            <scroll-view scroll-y scroll-with-animation class="u-tab-view menu-scroll-view" :scroll-top="scrollTop" :scroll-into-view="itemId">
                <view
                    v-for="(item, index) in tabbar"
                    :key="index"
                    class="u-tab-item"
                    :class="[current == index ? 'u-tab-item-active' : '']"
                    @tap.stop="swichMenu(index)">
                    <text class="u-line-1">{{ item.name }}</text>
                </view>
            </scroll-view>
            <scroll-view :scroll-top="scrollRightTop" scroll-y scroll-with-animation class="right-box menuHeight" @scroll="rightScroll">
                <view class="page-view">
                    <view class="class-item menuList" :id="'item' + index" v-for="(item, index) in tabbar" :key="index">
                        <view class="item-title">
                            <text>{{ item.name }}</text>
                        </view>
                        <view class="item-container">
                            <view v-if="item.name == '价格范围'" class="roomPrice">
                                <view class="priceMsg search-item">
                                    <text :style="{ color: price >= 20 ? '#3DC5FB' : 'black' }">0</text>
                                    <text :style="{ color: price >= 20 ? '#3DC5FB' : 'black' }">¥100</text>
                                    <text :style="{ color: price >= 40 ? '#3DC5FB' : 'black' }">¥200</text>
                                    <text :style="{ color: price >= 60 ? '#3DC5FB' : 'black' }">¥300</text>
                                    <text :style="{ color: price >= 80 ? '#3DC5FB' : 'black' }">¥400</text>
                                    <text :style="{ color: price >= 100 ? '#3DC5FB' : 'black' }">¥500+</text>
                                </view>
                                <u-slider v-model="price" step="20" @change="changePrice"></u-slider>
                            </view>
                            <view v-if="item.name == '房源户型' && searchObj" class="roomType search-item">
                                <view v-for="item in housType" :key="item">
                                    <view
                                        :class="{ 'typeList-activ': searchObj.housTypeVal == item.val, typeList: true }"
                                        @click="choiceHouseType(item.val)">
                                        {{ item.name }}
                                    </view>
                                </view>
                            </view>
                            <view v-if="item.name == '出租类型' && searchObj" class="roomType search-item">
                                <view
                                    :class="{ 'typeList-activ': searchObj.houseType.rentMode == 1, typeList: true }"
                                    @click="searchObj.houseType.rentMode = 1">
                                    <text>独享整套</text>
                                </view>
                                <view
                                    :class="{ 'typeList-activ': searchObj.houseType.rentMode == 2, typeList: true }"
                                    @click="searchObj.houseType.rentMode = 2">
                                    <text>单间出租</text>
                                </view>
                            </view>
                            <view v-if="item.name == '设施' && searchObj" class="roomType search-item">
                                <view
                                    :class="{ 'typeList-activ': searchObj.otherArr.includes(item.id), typeList: true }"
                                    v-for="item in facilitiesArr"
                                    :key="item.id"
                                    @click="appendOtherList(item.id)">
                                    {{ item.name }}
                                </view>
                                <view :class="{ 'typeList-activ': searchObj.otherArr.includes(1049), menuList: true }" @click="appendOtherList(1049)">
                                    洗发水/沐浴露
                                </view>
                            </view>
                            <view v-if="item.name == '服务' && searchObj" class="roomType search-item">
                                <view
                                    v-for="item in serveArr"
                                    :key="item.id"
                                    :class="{ 'typeList-activ': searchObj.otherArr.includes(item.id), menuList: true }"
                                    @click="appendOtherList(item.id)">
                                    {{ item.name }}
                                </view>
                            </view>
                            <view v-if="item.name == '用途' && searchObj" class="roomType search-item">
                                <view
                                    @click="searchRequreArr(item.id)"
                                    :class="{
                                        'typeList-activ': addrequeryArr.includes(item.id) || searchObj.addrequeryArr.includes(item.id),
                                        typeList: true,
                                    }"
                                    v-for="item in requireArr"
                                    :key="item.id">
                                    {{ item.name }}
                                </view>
                            </view>
                        </view>
                    </view>
                </view>
            </scroll-view>
        </view>
        <view class="detail-btn">
            <view>
                <u-button
                    @click="cancellation"
                    :custom-style="{
                        width: '187.5rpx',
                        height: '97.22rpx',
                        borderRadius: '18rpx',
                    }"
                    >取消</u-button
                >
            </view>
            <view>
                <u-button
                    :custom-style="{
                        width: '458.33rpx',
                        height: '97.22rpx',
                        background: '#ff960c',
                        borderRadius: '18rpx',
                        color: '#fff',
                    }"
                    @click="sendInfo"
                    >确定</u-button
                >
            </view>
        </view>
    </view>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import { onLoad, onShow, onReady } from '@dcloudio/uni-app';
import mallmenu from './mallMenu.vue';
import { getCurrentInstance } from 'vue';

const props = defineProps({
    searchObj: Object,
});

const menuCompoments = ref(null); // 得到组件本身实例
const currentInstance = ref(); // 组件实例

const emit = defineEmits(['closePopup', 'sendSearObj']);

const scrollTop = ref(0); // 用于控制左侧菜单滚动位置的响应式变量。
const oldScrollTop = ref(0); //  用于存储右侧内容区上次的滚动位置。
const current = ref(0); // 表示当前激活的菜单项的索引。
const menuHeight = ref(0); //左侧菜单的总高度。
const menuItemHeight = ref(0); //单个菜单项的高度。
const itemId = ref(''); //用于右侧内容滚动定位的元素 ID。

const arr = ref([]); //存储每个右侧内容项顶部距离的数组。
const scrollRightTop = ref(0); //控制右侧内容区滚动位置的响应式变量。
const timer = ref(null); //用于函数节流的定时器变量。
const { safeAreaInsets } = uni.getSystemInfoSync();
const price = ref(20); // 价格默认值
const searchObj = ref(); // 搜索条件对象
const housTypeVal = ref(); // 控制房源户型条件
const addrequeryArr = ref([]); // 需要添加的数组id列表
const tabbar = ref([{ name: '价格范围' }, { name: '房源户型' }, { name: '出租类型' }, { name: '设施' }, { name: '服务' }, { name: '用途' }]);
// 房源户型
const housType = ref([
    { name: '1室', val: 1 },
    { name: '1室1厅', val: 1.1 },
    { name: '2室', val: 2 },
    { name: '4室', val: 4 },
    { name: '2室1厅', val: 2.1 },
    { name: '3室', val: 3 },
]);
// 用途数组
const requireArr = ref([
    { id: 1068, name: '接待婴儿' },
    { id: 1069, name: '接待儿童' },
    { id: 1070, name: '接待老人' },
    { id: 1071, name: '接待外宾' },
    { id: 1072, name: '允许吸烟' },
    { id: 1073, name: '携带宠物' },
    { id: 1074, name: '允许做饭' },
    { id: 1075, name: '允许聚会' },
    { id: 1076, name: '商业拍摄' },
]);
// 设施数组
const facilitiesArr = ref([
    { id: 1035, name: '电梯' },
    { id: 1047, name: '暖气' },
    { id: 1031, name: '电视' },
    { id: 1036, name: '厨房' },
    { id: 1037, name: '书房' },
    { id: 1038, name: '阳台' },
    { id: 1048, name: '浴巾' },
    { id: 1050, name: '电吹风' },
]);
// 服务数组
const serveArr = ref([
    { id: 1029, name: '无线网络' },
    { id: 1032, name: '独立卫浴' },
    { id: 1041, name: '提供餐饮' },
    { id: 1039, name: '免费停车位' },
    { id: 1040, name: '付费停车位' },
    { id: 1027, name: '可洗热水澡' },
    { id: 1042, name: '支团建会议' },
]);

onMounted(() => {
    currentInstance.value = getCurrentInstance(); // 获取组件实例
    getMenuItemTop();
    if (props.searchObj) searchObj.value = props.searchObj;
    dateEcho();
});

/*
当用户点击左侧菜单项时调用。它首先确保所有右侧内容项的顶部距离已被计算和存储。然后,如果选中的菜单项与当前激活的菜单项不同,
它会更新 scrollRightTop 来滚动右侧内容区,使选中的内容项可见,并调用 leftMenuStatus 来更新左侧菜单的状态。
*/
const swichMenu = async (index) => {
    if (arr.value.length === 0) {
        await getMenuItemTop();
    }
    if (index === current.value) return;
    scrollRightTop.value = oldScrollTop.value;
    nextTick(() => {
        scrollRightTop.value = arr.value[index];
        current.value = index;

        leftMenuStatus(index);
    });
};
/*
此函数用于获取指定元素的高度。它创建一个选择器查询(createSelectorQuery),
获取特定类的元素高度,并将这个值保存在指定的响应式变量中。这对于计算滚动位置非常重要。
*/
const getElRect = async (elClass, dataVal) => {
    return new Promise((resolve) => {
        const query = uni.createSelectorQuery().in(currentInstance.value);
        query
            .select('.' + elClass)
            .boundingClientRect((rect) => {
                if (!rect) {
                    setTimeout(() => {
                        getElRect(elClass, dataVal);
                    }, 10);
                    return;
                }
                if (dataVal == 'menuHeight') menuHeight.value = rect.height;
                if (dataVal == 'menuItemHeight') menuItemHeight.value = rect.height;

                dataVal = rect.height;
                resolve();
            })
            .exec();
    });
};

const observer = () => {
    tabbar.value.map((val, index) => {
        let observer = uni.createIntersectionObserver(currentInstance.value);
        // 检测右边scroll-view的id为itemxx的元素与right-box的相交状态
        // 如果跟.right-box底部相交,就动态设置左边栏目的活动状态
        observer
            .relativeTo('.right-box', {
                top: 0,
            })
            .observe('#item' + index, (res) => {
                if (res.intersectionRatio > 0) {
                    let id = res.id.substring(4);
                    leftMenuStatus(id);
                }
            });
    });
};

//根据当前选中的菜单项索引来更新左侧菜单的状态。此方法计算 scrollTop 以确保选中的菜单项在左侧菜单中垂直居中。
// menuHeight:左侧菜单总高度   menuItemHeight:单个菜单的高度
const leftMenuStatus = async (index) => {
    current.value = index;
    if (menuHeight.value === 0 || menuItemHeight.value === 0) {
        await getElRect('menu-scroll-view', 'menuHeight');
        await getElRect('u-tab-item', 'menuItemHeight');
    }
    // scrollTop:控制左侧菜单滚动位置
    scrollTop.value = index * menuItemHeight.value + menuItemHeight.value / 2 - menuHeight.value / 2;
};
// 获取并存储右侧每个内容项的顶部位置。这对于后续根据滚动位置判断当前激活的菜单项非常关键。
const getMenuItemTop = () => {
    return new Promise((resolve) => {
        let selectorQuery = uni.createSelectorQuery().in(currentInstance.value);
        setTimeout(() => {
            selectorQuery
                .selectAll('.search-item')
                .boundingClientRect((rects) => {
                    if (!rects.length) {
                        setTimeout(() => {
                            getMenuItemTop();
                        }, 10);
                        return;
                    }
                    rects.forEach((rect) => {
                        arr.value.push(rect.top - rects[0].top);
                        resolve();
                    });
                })
                .exec();
        }, 100);
    });
};
/*
当用户滚动右侧内容区时触发。它首先确保所有必要的尺寸信息都已经获取。然后使用一个简单的节流机制
(通过 timer)来限制事件处理的频率。该方法根据当前滚动位置更新左侧菜单的选中状态。
*/
const rightScroll = async (e) => {
    oldScrollTop.value = e.detail.scrollTop;
    if (arr.value.length === 0) {
        await getMenuItemTop();
    }
    if (timer.value) return;
    if (!menuHeight.value) {
        await getElRect('menu-scroll-view', 'menuHeight');
    }
    setTimeout(() => {
        timer.value = null;
        let scrollHeight = e.detail.scrollTop;

        for (let i = 0; i < arr.value.length; i++) {
            let height1 = Math.floor(arr.value[i]);
            let height2 = Math.floor(arr.value[i + 1]);
            if (scrollHeight >= height1 && scrollHeight < height2) {
                leftMenuStatus(i);
                return;
            }
            if (!height2) {
                leftMenuStatus(5);
            }
        }
    }, 10);
};
// 用于接受数据进行回显
const dateEcho = () => {
    price.value = props.searchObj.priceRange[1] / 500;
};
// 搜索金额变化
const changePrice = () => {
    searchObj.value.priceRange = [0, (price.value / 2) * 1000];
    if (price.value === 100) searchObj.value.priceRange = [0, 9999];
};
// 点击取消
const cancellation = () => {
    emit('closePopup', false);
};
// 点击确定
const sendInfo = () => {
    emit('sendSearObj', searchObj.value);
};
// 选择房源户型
const choiceHouseType = (val) => {
    housTypeVal.value = val;
    searchObj.value.houseType.bedRoomCount = +val.toFixed(0);
    searchObj.value.housTypeVal = val;
};
// 选择其它数组
const searchRequreArr = (val) => {
    if (!addrequeryArr.value.includes(val)) {
        addrequeryArr.value.push(val);
    } else {
        addrequeryArr.value = addrequeryArr.value.filter((item) => item !== val);
    }
    searchObj.value.requireArr = addrequeryArr.value.map((item) => ({
        id: String(item),
        checked: '1',
    }));
    searchObj.value.addrequeryArr = addrequeryArr.value;
};
// 点击其它按钮,将id存放到数组中
const appendOtherList = (val) => {
    if (!searchObj.value.otherArr.includes(val)) {
        searchObj.value.otherArr.push(val);
    } else {
        searchObj.value.otherArr = searchObj.value.otherArr.filter((item) => item !== val);
    }
};
</script>

<style lang="scss" scoped>
.u-wrap {
    height: calc(100vh);
    /* #ifdef H5 */
    height: calc(100vh - var(--window-top));
    /* #endif */
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
    padding-top: 20rpx;
    background-color: #f6f6f6;
}

.u-search-box {
    display: none;
}

.u-menu-wrap {
    flex: 1;
    display: flex;
    overflow: hidden;
}

.u-search-inner {
    background-color: rgb(234, 234, 234);
    border-radius: 100rpx;
    display: flex;
    align-items: center;
    padding: 10rpx 16rpx;
}

.u-search-text {
    font-size: 26rpx;
    color: $u-tips-color;
    margin-left: 10rpx;
}

.u-tab-view {
    width: 200rpx;
    height: 100%;
}

.u-tab-item {
    height: 110rpx;
    background: #f6f6f6;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 26rpx;
    color: #444;
    font-weight: 400;
    line-height: 1;
}

.u-tab-item-active {
    position: relative;
    color: #000;
    font-size: 30rpx;
    font-weight: 600;
    background: #fff;
}

.u-tab-item-active::before {
    content: '';
    position: absolute;
    border-left: 4px solid $u-primary;
    height: 32rpx;
    left: 0;
    top: 39rpx;
}

.u-tab-view {
    height: 100%;
}

.right-box {
    background-color: rgb(250, 250, 250);
}

.page-view {
    padding: 16rpx;
}

.class-item {
    margin-bottom: 30rpx;
    background-color: #fff;
    padding: 16rpx;
    border-radius: 8rpx;
}

.class-item:last-child {
    min-height: 100vh;
}

.item-title {
    font-size: 26rpx;
    color: $u-main-color;
    font-weight: bold;
    margin-bottom: 20rpx;
}

.item-menu-name {
    font-weight: normal;
    font-size: 24rpx;
    color: $u-main-color;
}

.item-container {
    display: flex;
    flex-wrap: wrap;

    .roomPrice {
        width: 100%;
        .priceMsg {
            display: flex;
            justify-content: space-between;
            font-size: 25rpx;
            margin-left: 34.72rpx;
        }
    }
}

.thumb-box {
    width: 33.333333%;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    margin-top: 20rpx;
}

.item-menu-image {
    width: 120rpx;
    height: 120rpx;
}
.roomType {
    display: flex;
    flex-wrap: wrap;

    .typeList {
        width: 152.78rpx;
        height: 69.44rpx;
        margin: 0 20.83rpx 13.89rpx 0;
        display: flex;
        justify-content: center;
        align-items: center;
        background-color: #f0f1f3;
        font-size: 14px;
        font-weight: 300;
    }
    .menuList {
        box-sizing: border-box;
        margin: 0 20.83rpx 13.89rpx 0;
        display: flex;
        justify-content: center;
        align-items: center;
        background-color: #f0f1f3;
        font-size: 14px;
        font-weight: 300;
        padding: 22.92rpx;
    }
    .typeList-activ {
        background-image: url(https://rizuwang-1316974425.cos.ap-beijing.myqcloud.com/mobilePics/home/background.png);
        background-repeat: no-repeat;
        background-position-x: 5px;
        background-size: 22.22rpx 22.22rpx;
        background-position: bottom right;
        background-color: #dcf5ff;
    }
}
.detail-btn {
    margin-top: 50rpx;
    margin-bottom: 20rpx;
    display: flex;
    justify-content: space-around;
}
</style>
相关推荐
孟祥_成都11 分钟前
前端角度学 AI - 15 分钟入门 Python
前端·人工智能
掘金安东尼12 分钟前
Astro 十一月更新:新特性与生态亮点(2025)
前端
拉不动的猪13 分钟前
判断dom元素是否在可视区域的常规方式
前端·javascript·面试
Hilaku32 分钟前
如何用隐形字符给公司内部文档加盲水印?(抓内鬼神器🤣)
前端·javascript·面试
guxuehua36 分钟前
Monorepo Beta 版本发布问题排查与解决方案
前端
猫头虎-前端技术36 分钟前
小白也能做AI产品?我用 MateChat 给学生做了一个会“拍照解题 + 分步教学”的AI智能老师
前端·javascript·vue.js·前端框架·ecmascript·devui·matechat
b***666137 分钟前
前端的dist包放到后端springboot项目下一起打包
前端·spring boot·后端
栀秋66638 分钟前
ES6+新增语法特性:重塑JavaScript的开发范式
前端·javascript
爱分享的鱼鱼40 分钟前
Vue动态路由详解:从基础到实践
前端
未来之窗软件服务42 分钟前
幽冥大陆(三十七)文件系统路径格式化——东方仙盟筑基期
前端·javascript·文件系统·仙盟创梦ide·东方仙盟