自适应瀑布流下拉加载游标查询

游标查询

游标查询是一种数据库查询方式,它允许逐行处理查询结果集,而不是一次性将所有数据加载到内存中。这种方式特别适合处理大数据量的场景,能够有效减少内存占用和提升性能。

游标查询的特点

逐行处理:游标可以像指针一样指向结果集中的某一行,支持逐行读取或操作。

分批加载:通过游标,可以按需加载数据,避免一次性加载过多数据导致性能问题。

状态保持:游标会维护当前的位置,方便后续继续读取未处理的数据。

游标查询的应用场景

大数据量分页:传统分页(如 LIMIT 和 OFFSET)在数据量较大时性能较差,因为每次查询都需要跳过前面的记录。而游标查询可以通过保存上一次查询的状态,直接从上次结束的地方继续加载。

动态加载:如瀑布流、无限滚动等场景,用户下拉加载更多内容时,后端需要返回与之前不重复的数据。

游标查询的工作原理

假设我们有一个图片表 images,字段包括 id 和 created_at,我们需要实现一个瀑布流加载功能:

初始请求:

用户首次访问时,后端查询最新的几条数据(如前 10 条)。

返回这些数据的同时,记录最后一条数据的关键信息(如 id 或 created_at),作为游标的起点。

后续请求:

用户下拉加载更多时,前端将上次返回的游标值(如最后一条数据的 id 或 created_at)传递给后端。

后端根据游标值,查询比该值更旧的数据,并返回新的结果集。

-- 初始请求:获取最新的 10 条数据
SELECT id, created_at, url FROM images ORDER BY created_at DESC LIMIT 10;

-- 后续请求:根据游标值加载更多数据
SELECT id, created_at, url
FROM images
WHERE created_at < '上次返回的最后一条数据的时间'
ORDER BY created_at DESC
LIMIT 10;

游标查询的优点

高效性:避免了传统分页中使用 OFFSET 导致的性能下降问题。

无重复数据:通过游标值确保每次加载的数据不会重复。

灵活性:适用于动态加载、实时更新等场景。

游标查询的注意事项

唯一性:游标字段(如 id 或 created_at)必须是唯一的,或者结合其他字段确保唯一性。

排序稳定性:查询时需要明确的排序规则(如 ORDER BY),否则可能导致数据顺序混乱。

并发问题:如果数据频繁更新,可能会出现游标失效的情况,需要设计合理的锁机制或版本控制。

后端

本质是添加了一个id>cursor 或 id<cursor的条件限制 以过滤已经查过的数据 防止重复数据显示

允许使用分类 标签 关键字 进行查询

cursor记录当前展示的最后一张图片的id

每次允许查12张图片 这里可以仍然使用分页

一个小细节 每次多查一个数据 用于判断是否为最后一页

由于cursor以id为索引 所以排序规则仅允许使用id

至于升序还是降序可以选择

dto

 package com.zwnsyw.yunpicturebackend.model.dto.PictureRequest;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Data;

import javax.validation.constraints.*;
import java.io.Serializable;
import java.util.List;

@Data
public class PictureCursorRequest implements Serializable {
    /**
     * id
     */
    private Long id;

    /**
     * 分类
     */
    private String category;

    /**
     * 标签
     */
    private List<String> tags;

    /**
     * 搜索关键字
     */
    @Size(max = 30, message = "搜索关键字长度不能超过30")
    private String searchText;

    /**
     * 游标ID
     */
    private String cursor;

    /**
     * 每页数量
     */
    @NotNull(message = "pageSize 不能为空")
    @Min(value = 1, message = "pageSize 至少为 1")
    @Max(value = 20, message = "pageSize 最大为 20")
    private int pageSize = 12;

    /**
     * 页面数据(每次多查一条数据 以判断是否是最后一页)
     */
    private Page page = new Page(1, pageSize + 1);

    /**
     * 排序字段
     */
    @NotNull(message = "sortField 不能为空")
    @Pattern(regexp = "^(id)$",
            message = "sortField字段值不合法,允许的值为 id")
    private String sortField;

    /**
     * 排序方式
     */
    @NotNull(message = "sortOrder 不能为空")
    @Pattern(regexp = "^(ascend|descend)$",
            message = "sortOrder字段值不合法,允许的值为 ascend|descend")
    private String sortOrder;

    private static final long serialVersionUID = 1L;
}

controller

    /**
     * 游标查询获取图片列表(供用户使用)
     */
    @PostMapping("/listByCursor")
    @PreAuthorizeRole("USER")
    public BaseResponse<PictureCursorResponse> listPicturesByCursor(
            @RequestBody @Valid PictureCursorRequest pictureCursorRequest) {

        PictureCursorResponse pictureCursorResponse = pictureService.listPicturesByCursor(pictureCursorRequest);

        return ResultUtils.success(pictureCursorResponse);
    }

service

 /**
     * 获取图片列表
     *
     * @param pictureCursorRequest 图片查询请求
     * @return 图片列表
     */
    PictureCursorResponse listPicturesByCursor(@Valid PictureCursorRequest pictureCursorRequest);

serviceimpl

cursor记录当前返回的最后一个数据id 下次查询传入该id 查询大于该id的数据 queryWrapper.gt("id", cursor);

如果记录下一数据id 查询大于等于该id的数据 queryWrapper.ge("id", cursor); 会导致循环链表无限循环的bug 如果已经是最后一个数据 那么下个位置是0 再调用又从头开始了

简单方法是用一个isLast字段记录是否为最后一个数据 在前端请求前判断是否需要进行请求

private void isContainsTags(QueryWrapper<Picture> queryWrapper, List<String> tags2) {
        List<String> tags = tags2;
        if (CollUtil.isNotEmpty(tags)) {
            for (String tag : tags) {
                if (!isValidTag(tag)) {
                    throw new IllegalArgumentException("Invalid tag: " + tag);
                }
            }

            queryWrapper.and(qw -> {
                for (String tag : tags) {
                    String jsonArrayStr = JSONUtil.toJsonStr(Collections.singletonList(tag));
                    qw.or().apply("JSON_CONTAINS(tags, {0})", jsonArrayStr);
                }
            });
        }
    }

    @Override
    public PictureCursorResponse listPicturesByCursor(PictureCursorRequest pictureCursorRequest) {

        QueryWrapper<Picture> queryWrapper = new QueryWrapper<>();

        // 处理基本查询条件
        queryWrapper.eq(StrUtil.isNotBlank(pictureCursorRequest.getCategory()), "category", pictureCursorRequest.getCategory());
        String searchText = pictureCursorRequest.getSearchText();
        if (StrUtil.isNotBlank(searchText)) {
            queryWrapper.and(qw -> qw.like("name", searchText).or().like("introduction", searchText));
        }
        isContainsTags(queryWrapper, pictureCursorRequest.getTags());

        // 处理游标条件
        String cursor = pictureCursorRequest.getCursor();
        if (cursor == null || cursor.isEmpty()) {
            cursor = "0"; // 初始游标设为0,兼容升序/降序
        }
        boolean isAsc = "ascend".equals(pictureCursorRequest.getSortOrder());

        // 根据排序方向选择gt或lt
        if (isAsc) {
            queryWrapper.gt("id", cursor);
        } else {
            queryWrapper.lt("id", cursor);
        }

        // 设置排序
        queryWrapper.orderByAsc("id");
        if (!isAsc) {
            queryWrapper.orderByDesc("id");
        }

        // 分页设置(pageSize + 1)
        int pageSize = pictureCursorRequest.getPageSize();
        Page pagePlus = new Page<>(1, pageSize + 1);
        Page<Picture> page = this.page(pagePlus, queryWrapper);

        // 判断是否最后一页
        boolean isLast = page.getRecords().size() <= pageSize;

        // 提取数据
        List<Picture> pictures = page.getRecords();
        if (!isLast) {
            pictures.remove(pictures.size() - 1); // 去除多查的一条
        }

        // 计算新游标
        String newCursor = isLast ? "0" : String.valueOf(pictures.get(pictures.size() - 1).getId());

        // 用户信息预加载
        Set<Long> userIdSet = pictures.stream()
                .map(Picture::getUserId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, User> userMap = userIdSet.isEmpty()
                ? Collections.emptyMap()
                : userService.listByIds(userIdSet).stream()
                .collect(Collectors.toMap(User::getId, user -> user));

        // 转换为VO
        List<PictureVO> pictureVOs = pictures.stream()
                .map(p -> {
                    PictureVO vo = PictureVO.fromPicture(p);
                    User user = userMap.get(p.getUserId());
                    vo.setUser(user != null ? userService.convertToLoginUserVO(user) : null);
                    return vo;
                })
                .collect(Collectors.toList());

        // 构建响应
        return buildResponse(newCursor, isLast, pictureVOs);
    }

    private PictureCursorResponse buildResponse(String newCursor, boolean isLast, List<PictureVO> pictureVOs) {
        PictureCursorResponse response = new PictureCursorResponse();
        response.setCursor(newCursor);
        response.setRecords(pictureVOs);
        response.setLast(isLast);
        return response;
    }

vo

package com.zwnsyw.yunpicturebackend.model.vo;

import lombok.Data;

import java.util.List;

@Data
public class PictureCursorResponse {

    /**
     * 游标
     */
    private String cursor;

    /**
     * 是否为最后一个数据
     */
    private boolean isLast = false;

    /**
     * 图片列表
     */
    private List<PictureVO> records;

}

遗留问题:

默认cursor为0 实际只能应对升序的情况 如果是降序 cursor应该初始化为什么?不太好确定

前端

使用https://github.com/heikaimu/vue3-waterfall-plugin?tab=readme-ov-file组件 实现瀑布流

瀑布流原理参考https://juejin.cn/user/2964720076469133/posts大佬的文章

<template>
  <div id="homePage">
    <h1>{{ msg }}</h1>
    <!-- 搜索框 -->
    <div class="search-bar">
      <a-input-search
        v-model:value="searchParams.searchText"
        placeholder="从海量图片中搜索"
        enter-button="搜索"
        size="large"
        @search="doSearch"
      />
    </div>
    <!-- 分类和标签筛选 -->
    <a-tabs v-model:active-key="selectedCategory" @change="doSearch">
      <a-tab-pane key="all" tab="全部" />
      <a-tab-pane v-for="category in categoryList" :tab="category" :key="category" />
    </a-tabs>
    <div class="tag-bar">
      <span style="margin-right: 8px">标签:</span>
      <a-space :size="[0, 8]" wrap>
        <a-checkable-tag
          v-for="(tag, index) in tagList"
          :key="tag"
          v-model:checked="selectedTagList[index]"
          @change="doSearch"
        >
          {{ tag }}
        </a-checkable-tag>
      </a-space>
    </div>

    <!-- 图片列表 -->
    <div class="waterfall-container">
      <Waterfall
        :list="dataList"
        :row-key="'id'"
        :img-selector="'url'"
        :breakpoints="breakpoints"
        :gutter="10"
        :has-around-gutter="true"
        :posDuration="300"
        :animation-effect="'fadeIn'"
        :lazyload="true"
        :load-props="loadProps"
        :cross-origin="false"
        :delay="300"
      >
        <template #default="{ item, url, index }">
          <div class="card-container" @click="doClickPicture(item)">
            <div class="card-image-wrapper">
              <LazyImg
                :url="getFullImageUrl(url)"
                class="lazy-img"
                :style="{
                  '--width': item.picWidth,
                  '--height': item.picHeight,
                }"
                @error="handleImageError"
              />
              <div class="image-overlay">
                <div class="overlay-content">
                  <h3>{{ item.name || '无标题' }}</h3>
                  <p>{{ item.introduction || '无介绍' }}</p>
                </div>
              </div>
            </div>
          </div>
        </template>
      </Waterfall>
    </div>

    <div class="load-more-btn" v-if="!loading && hasMore">
      <a-button @click="loadMore" :disabled="loading || !hasMore"> 加载更多</a-button>
    </div>
  </div>
</template>


import { onMounted, reactive, ref, watch } from 'vue'
import {
  listPictureTagCategoryUsingGet,
  listPicturesByCursorUsingPost,
} from '@/api/pictureController.ts'
import { message } from 'ant-design-vue'
import { useRouter, useRoute } from 'vue-router'
import { Waterfall, LazyImg } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
import load from '@/assets/images/loding.gif'
import error from '@/assets/images/error.png'

const msg = 'Zwww云图库 海量图片免费存取~'

const dataList = ref<API.PictureVO[]>([])
const loading = ref(true)
const hasMore = ref(true)

const searchParams = reactive<API.PictureCursorRequest>({
  pageSize: 12,
  sortField: 'id',
  sortOrder: 'ascend',
  cursor: '',
  tags: [] as string[],
})

const categoryList = ref<string[]>([])
const selectedCategory = ref<string>('all')
const tagList = ref<string[]>([])
const selectedTagList = ref<boolean[]>([])
const router = useRouter()
const route = useRoute()

const breakpoints = {
  1200: { rowPerView: 4 },
  800: { rowPerView: 3 },
  500: { rowPerView: 2 },
}

let isFetching = false
const fetchData = async () => {
  if (isFetching) return
  try {
    isFetching = true
    loading.value = true
    const params = {
      ...searchParams,
      category: selectedCategory.value !== 'all' ? selectedCategory.value : undefined,
      tags: selectedTagList.value
        .map((selected, index) => (selected ? tagList.value[index] : null))
        .filter(Boolean as any) as string[],
    }
    const res = await listPicturesByCursorUsingPost(params)
    if (res.data.code === 0 && res.data.data) {
      dataList.value.push(...(res.data.data.records || []))
      searchParams.cursor = res.data.data.cursor ?? ''
      hasMore.value = !res.data.data.last
    } else {
      message.error('获取数据失败,' + res.data.message)
    }
  } catch (error) {
    message.error('网络请求失败,请稍后再试')
  } finally {
    isFetching = false
    loading.value = false
  }
}

onMounted(async () => {
  const query = route.query;
  searchParams.pageSize = parseInt(query.pageSize as string) || 12;
  searchParams.searchText = (query.searchText as string) || '';

  await getTagCategoryOptions();

  let initialCategory = (query.category as string) || 'all';
  if (initialCategory !== 'all' && !categoryList.value.includes(initialCategory)) {
    selectedCategory.value = 'all';
  } else {
    selectedCategory.value = initialCategory;
  }

  doSearch(); // 触发首次数据加载
});

const getTagCategoryOptions = async () => {
  try {
    const res = await listPictureTagCategoryUsingGet();
    if (res.data.code === 0 && res.data.data) {
      tagList.value = res.data.data.tagList ?? [];
      categoryList.value = res.data.data.categoryList ?? [];

      const tagsQuery = (route.query.tags as string)?.split(',') || [];
      selectedTagList.value = tagList.value.map(tag => tagsQuery.includes(tag));
    }
  } catch (error) {
    message.error('网络请求失败,请稍后再试');
  }
};

const updateUrlQuery = () => {
  const tags = selectedTagList.value
    .map((selected, index) => (selected ? tagList.value[index] : null))
    .filter(Boolean)
  router.replace({
    query: {
      cursor: searchParams.cursor,
      pageSize: searchParams.pageSize,
      searchText: searchParams.searchText,
      category: selectedCategory.value,
      tags: tags.join(','),
    },
  })
}

const doSearch = () => {
  searchParams.cursor = ''
  dataList.value = []
  updateUrlQuery()
  fetchData()
}

watch(
  () => ({
    searchText: searchParams.searchText,
    category: selectedCategory.value,
    tags: selectedTagList.value,
  }),
  ({ category, tags }, oldVal) => {
    if (
      category === oldVal.category &&
      JSON.stringify(tags) === JSON.stringify(oldVal.tags)
    ) {
      return;
    }

    updateUrlQuery();
    fetchData();
  },
  { deep: true, immediate: false }
);

const doClickPicture = (picture: API.PictureVO) => {
  router.push({ path: `/picture/${picture.id}` })
}

const getFullImageUrl = (url: string) => {
  return `${import.meta.env.VITE_COS_HOST}/${url}`
}

// 自定义懒加载配置
const loadProps = {
  load,
  error,
  ratioCalculator: (width: number, height: number) => {
    const minRatio = 16 / 9
    const maxRatio = 4 / 3
    return Math.min(Math.max(width / height, minRatio), maxRatio)
  },
}

// 图片加载失败处理
const handleImageError = (event: Event) => {
  const img = event.target as HTMLImageElement
  img.src = error
}

const loadMore = () => {
  fetchData()
}



#homePage {
  margin-bottom: 16px;
}

#homePage .search-bar {
  max-width: 480px;
  margin: 0 auto 16px;
}

#homePage .tag-bar {
  margin-bottom: 16px;
}

#homePage .load-more-btn {
  text-align: center;
  margin: 24px 0;
}

.waterfall-container {
  padding: 20px;
}

.card-container {
  position: relative;
  width: 100%;
  max-width: 300px;
  margin: 10px;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.card-image-wrapper {
  position: relative;
  cursor: pointer;
}

.animate__animated {
  animation-fill-mode: both;
  animation-duration: 1s;
}

.card-container:hover .lazy-img {
  transform: scale(1.1);
}

.image-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.6);
  opacity: 0;
  transition: opacity 0.3s ease;
  display: flex;
  align-items: center;
  justify-content: center;
}

.card-container:hover .image-overlay {
  opacity: 1;
}

.overlay-content {
  text-align: center;
  color: white;
  padding: 20px;
}

.overlay-content h3 {
  margin: 0 0 10px;
  font-size: 18px;
}

.overlay-content p {
  margin: 0;
  font-size: 14px;
}

.lazy-img {
  width: 100%;
  height: 200px;
  object-fit: cover;
  transition: transform 0.3s ease;
}

.lazy-img[lazy='loading'] {
  padding: 5em 0;
  width: 48px;
}

.lazy-img[lazy='loaded'] {
  width: 100%;
}

.lazy-img[lazy='error'] {
  padding: 5em 0;
  width: 48px;
}

遗留问题:

瀑布流本质是 宽度由页面计算大小 决定展示多少列 每列的宽度其实是固定的 而错落有致的效果实际上是因为

image-b0d4561c

介绍文本的长度(一行或两行)决定每个卡片高度不一致

多个不一致之后 导致多个列之间不是死板的整齐划一 给人带来错落有致的美感

列宽固定:每列的宽度是固定的,但列的数量会根据屏幕宽度动态调整。

高度自适应:每个图片的高度根据其内容自动调整,导致不同列的高度不一致。

填充算法:当新元素加入时,选择当前高度最短的那一列进行填充,从而形成错落有致的效果。

很难实现这种既保持宽度一致 又不缩放宽图 见缝插针布局的效果

image-1114aadf

另外 当前实现版本为 图片显示完全后 点击加载 而非触底自动加载 后续再实现

done

<template>

  <div id="homePage" ref="containerRef">

    <h1>{{ msg }}</h1>

    <!-- 搜索框 -->

    <div class="search-bar">

      <a-input-search

        v-model:value="searchParams.searchText"

        placeholder="从海量图片中搜索"

        enter-button="搜索"

        size="large"

        @search="doSearch"

      />

    </div>

    <!-- 分类和标签筛选 -->

    <a-tabs v-model:active-key="selectedCategory" @change="doSearch">

      <a-tab-pane key="all" tab="全部" />

      <a-tab-pane v-for="category in categoryList" :tab="category" :key="category" />

    </a-tabs>

    <div class="tag-bar">

      <span style="margin-right: 8px">标签:</span>

      <a-space :size="[0, 8]" wrap>

        <a-checkable-tag

          v-for="(tag, index) in tagList"

          :key="tag"

          v-model:checked="selectedTagList[index]"

          @change="doSearch"

        >

          {{ tag }}

        </a-checkable-tag>

      </a-space>

    </div>

    <!-- 图片列表 -->

    <div class="waterfall-container">

      <Waterfall

        :list="dataList"

        :row-key="'id'"

        :img-selector="'url'"

        :breakpoints="breakpoints"

        :gutter="10"

        :has-around-gutter="true"

        :posDuration="300"

        :animation-effect="'fadeIn'"

        :lazyload="true"

        :load-props="loadProps"

        :cross-origin="false"

        :delay="300"

      >

        <template #default="{ item, url, index }">

          <div class="card-container" @click="doClickPicture(item)">

            <div class="card-image-wrapper">

              <LazyImg

                :url="getFullImageUrl(url)"

                class="lazy-img"

                :style="{

                  '--width': item.picWidth,

                  '--height': item.picHeight,

                }"

                @error="handleImageError"

              />

              <div class="image-overlay">

                <div class="overlay-content">

                  <h3>{{ item.name || '无标题' }}</h3>

                  <p :ref="(el) => setRef(el, index)" class="description" :title="item.introduction">{{ item.introduction || '无介绍' }}</p>

                </div>

              </div>

            </div>

<!--            <div class="card-footer">-->

<!--              <div class="user-info">-->

<!--                <img :src="api/images/WxO1pXBJW8RI/item.user.userAvatar" alt="User Avatar" class="user-avatar">-->

<!--                <span class="username">{{ item.user.userName}}</span>-->

<!--              </div>-->

<!--              <p :ref="(el) => setRef(el, index)" class="description" :title="item.introduction">{{ item.introduction || '无介绍' }}</p>-->

<!--            </div>-->

          </div>

        </template>

      </Waterfall>

    </div>

    <div v-if="loading && hasMore" class="loading-more-data">

      加载中...

    </div>

    <div v-if="!hasMore && !loading" class="no-more-data">

      没有更多数据了~

    </div>

  </div>

</template>



import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'

import {

  listPictureTagCategoryUsingGet,

  listPicturesByCursorUsingPost,

} from '@/api/pictureController.ts'

import { message } from 'ant-design-vue'

import { useRouter, useRoute } from 'vue-router'

import { Waterfall, LazyImg } from 'vue-waterfall-plugin-next'

import 'vue-waterfall-plugin-next/dist/style.css'

import load from '@/assets/images/loding.gif'

import error from '@/assets/images/error.png'

const msg = 'Zwww云图库 海量图片免费存取~'

const dataList = ref<API.PictureVO[]>([])

const loading = ref(true)

const hasMore = ref(true)

const searchParams = reactive<API.PictureCursorRequest>({

  pageSize: 24,

  sortField: 'id',

  sortOrder: 'ascend',

  cursor: '',

  tags: [] as string[],

})

const categoryList = ref<string[]>([])

const selectedCategory = ref<string>('all')

const tagList = ref<string[]>([])

const selectedTagList = ref<boolean[]>([])

const router = useRouter()

const route = useRoute()

const containerRef = ref<HTMLDivElement | null>(null)

const descriptionRefs = ref<HTMLElement[]>([]);

const setRef = (el: HTMLElement | null, index: number) => {

  if (el) {

    descriptionRefs.value[index] = el;

  }

};

const handleScroll = () => {

  if (loading.value || !hasMore.value) return

  const container = document.documentElement

  const scrollTop = container.scrollTop

  const scrollHeight = container.scrollHeight

  const clientHeight = container.clientHeight

  console.log(`scrollTop: ${scrollTop}, scrollHeight: ${scrollHeight}, clientHeight: ${clientHeight}`)

  if (scrollTop + clientHeight >= scrollHeight - 100) {

    console.log('触发加载')

    fetchData()

  }

}

onMounted(async () => {

  const query = route.query

  searchParams.pageSize = parseInt(query.pageSize as string) || 24

  searchParams.searchText = (query.searchText as string) || ''

  await getTagCategoryOptions()

  let initialCategory = (query.category as string) || 'all'

  if (initialCategory !== 'all' && !categoryList.value.includes(initialCategory)) {

    selectedCategory.value = 'all'

  } else {

    selectedCategory.value = initialCategory

  }

  nextTick(() => {

    descriptionRefs.value.forEach((descriptionRef) => {

      if (descriptionRef) {

        const lines = Math.ceil(descriptionRef.scrollHeight / parseInt(getComputedStyle(descriptionRef).lineHeight));

        if (lines > 2) {

          descriptionRef.style.webkitLineClamp = '2';

        }

      }

    });

  });

  doSearch() // 触发首次数据加载

  window.addEventListener('scroll', handleScroll) // 监听窗口滚动事件

})

// 组件卸载时移除事件监听

onBeforeUnmount(() => {

  window.removeEventListener('scroll', handleScroll)

})

let isFetching = false

const fetchData = async () => {

  if (isFetching) return

  try {

    isFetching = true

    loading.value = true

    const params = {

      ...searchParams,

      category: selectedCategory.value !== 'all' ? selectedCategory.value : undefined,

      tags: selectedTagList.value

        .map((selected, index) => (selected ? tagList.value[index] : null))

        .filter(Boolean as any) as string[],

    }

    const res = await listPicturesByCursorUsingPost(params)

    if (res.data.code === 0 && res.data.data) {

      dataList.value.push(...(res.data.data.records || []))

      searchParams.cursor = res.data.data.cursor ?? ''

      hasMore.value = !res.data.data.last

    } else {

      message.error('获取数据失败,' + res.data.message)

    }

  } catch (error) {

    message.error('网络请求失败,请稍后再试')

  } finally {

    isFetching = false

    loading.value = false

  }

}

const getTagCategoryOptions = async () => {

  try {

    const res = await listPictureTagCategoryUsingGet()

    if (res.data.code === 0 && res.data.data) {

      tagList.value = res.data.data.tagList ?? []

      categoryList.value = res.data.data.categoryList ?? []

      const tagsQuery = (route.query.tags as string)?.split(',') || []

      selectedTagList.value = tagList.value.map((tag) => tagsQuery.includes(tag))

    }

  } catch (error) {

    message.error('网络请求失败,请稍后再试')

  }

}

const updateUrlQuery = () => {

  const tags = selectedTagList.value

    .map((selected, index) => (selected ? tagList.value[index] : null))

    .filter(Boolean)

  router.replace({

    query: {

      cursor: searchParams.cursor,

      pageSize: searchParams.pageSize,

      searchText: searchParams.searchText,

      category: selectedCategory.value,

      tags: tags.join(','),

    },

  })

}

const doSearch = () => {

  searchParams.cursor = ''

  dataList.value = []

  updateUrlQuery()

  fetchData()

}

watch(

  () => ({

    searchText: searchParams.searchText,

    category: selectedCategory.value,

    tags: selectedTagList.value,

  }),

  ({ category, tags }, oldVal) => {

    if (category === oldVal.category && JSON.stringify(tags) === JSON.stringify(oldVal.tags)) {

      return

    }

    updateUrlQuery()

    fetchData()

  },

  { deep: true, immediate: false },

)

const doClickPicture = (picture: API.PictureVO) => {

  router.push({ path: `/picture/${picture.id}` })

}

const getFullImageUrl = (url: string) => {

  return `${import.meta.env.VITE_COS_HOST}/${url}`

}

const breakpoints = {

  1200: { rowPerView: 4 },

  800: { rowPerView: 3 },

  500: { rowPerView: 2 },

}

// 自定义懒加载配置

const loadProps = {

  load,

  error,

  ratioCalculator: (width: number, height: number) => {

    const minRatio = 16 / 9

    const maxRatio = 4 / 3

    return Math.min(Math.max(width / height, minRatio), maxRatio)

  },

}

// 图片加载失败处理

const handleImageError = (event: Event) => {

  const img = event.target as HTMLImageElement

  img.src = error

}





#homePage {

  overflow-y: visible;

  margin-bottom: 16px;

}

#homePage .search-bar {

  max-width: 480px;

  margin: 0 auto 16px;

}

#homePage .tag-bar {

  margin-bottom: 16px;

}

#homePage .load-more-btn {

  text-align: center;

  margin: 24px 0;

}

.waterfall-container {

  padding: 20px;

}

.card-container {

  position: relative;

  width: 100%;

  max-width: 300px;

  margin: 10px;

  border-radius: 8px;

  overflow: hidden;

  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);

  transition: transform 0.3s ease;

}

.card-image-wrapper {

  position: relative;

  cursor: pointer;

  z-index: 1;

}

.animate__animated {

  animation-fill-mode: both;

  animation-duration: 1s;

}

.card-container:hover .lazy-img {

  transform: scale(1.1);

}

.image-overlay {

  position: absolute;

  top: 0;

  left: 0;

  width: 100%;

  height: 100%;

  background: rgba(0, 0, 0, 0.6);

  opacity: 0;

  transition: opacity 0.3s ease;

  display: flex;

  align-items: center;

  justify-content: center;

  z-index: 2;

}

.card-container:hover .image-overlay {

  opacity: 1;

}

.card-footer {

  display: flex;

  flex-direction: column;

  align-items: flex-start;

  padding: 10px;

  background-color: #f8f9fa;

  border-top: 1px solid #e9ecef;

  z-index: 3;

  position: relative;

}

.user-info {

  display: flex;

  align-items: center;

  margin-bottom: 5px;

}

.user-avatar {

  width: 30px;

  height: 30px;

  border-radius: 50%;

  margin-right: 10px;

}

.username {

  font-weight: bold;

}

.description {

  color: #6c757d;

  display: -webkit-box;

  -webkit-box-orient: vertical;

  overflow: hidden;

  -webkit-line-clamp: 2;

}

.overlay-content {

  text-align: center;

  color: white;

  padding: 20px;

}

.overlay-content h3 {

  margin: 0 0 10px;

  font-size: 18px;

}

.overlay-content p {

  margin: 0;

  font-size: 14px;

}

.lazy-img {

  width: 100%;

  height: 200px;

  object-fit: cover;

  transition: transform 0.3s ease;

}

.lazy-img[lazy='loading'] {

  padding: 5em 0;

  width: 48px;

}

.lazy-img[lazy='loaded'] {

  width: 100%;

}

.lazy-img[lazy='error'] {

  padding: 5em 0;

  width: 48px;

}

.loading-more-data {

  text-align: center;

  color: #666;

}

.no-more-data {

  text-align: center;

  color: #999;

}



项目分区导航:⬅️ 07-图片模块 | 08-自适应瀑布流下拉加载游标查询 | ➡️ 09-async