小程序点餐页吸顶滚动之分类按需加载,上划切换

效果图

点餐页的分类商品,不必在进页时一次加载完

点餐页常见的做法是先请求分类,再把所有分类的商品一起取回来,右侧排成一条长列表。这样做左右联动很直观:滚到哪一段,就选中左边哪一类。但商超分类多、商品数量差异又大时,用户可能只看了前两类,后面十几类的商品却已经全部请求并渲染了。

这次想试的是另一种节奏:分类目录先展示,右侧一次只看一个分类;进入某一类时才加载它的商品。少的分类只有一两件,多的分类可以有十几件。滑到当前分类底部,会看到"上拉继续浏览下一类";再次上拉,或者直接点击提示,才进入下一类。

实现时,页面保留一个主要的纵向滚动容器,左侧分类栏吸顶并可独立滚动。右侧只渲染当前分类,重点是把商品请求放到真正进入分类的时刻。

分类目录和商品数据分开

页面一开始就知道分类的顺序、名称和下一类是谁,但不因此生成所有商品。Demo 用本地 categories 模拟分类目录,用 fetchCategoryDishes(category) 模拟按分类 ID 请求商品。makeDishes 在这次模拟请求执行时才生成该分类的商品,进入页面只加载第一类。

ts 复制代码
const activeIndex = ref(0)
const activeCategory = computed(() => categories[activeIndex.value])
const nextCategory = computed(() => categories[activeIndex.value + 1])
const productCache = ref<Record<string, DishItem[]>>({})

onMounted(() => {
  scheduleMeasure()
  loadCategory(activeCategory.value)
})

这里的商品数量只是 mock 配置,用来展示两件、九件、一件、十二件以及空分类等不同高度。真实接口接入时,应让分类接口返回目录,再按当前分类 ID 请求商品;不要把 Demo 的 count 当作实际商品列表。

切换分类时,右侧只替换当前内容

点击左侧分类、点击底部提示、上拉进入下一类,最终都走分类切换逻辑。它先更新左侧高亮和右侧分类信息,再把外层滚动位置移回菜单顶部,最后加载目标分类。已访问过的分类直接从 productCache 恢复,不再发同一请求。

ts 复制代码
async function selectCategory(index: number) {
  if (index < 0 || index >= categories.length || index === activeIndex.value)
    return

  activeIndex.value = index
  leftScrollIntoView.value = `left-cat-${categories[index].id}`
  await nextTick()
  scrollToMenu()
  loadCategory(categories[index])
}

因此这不是"把下一类追加到当前列表底下"的无限列表。切换后右侧只有目标分类,向前查看可以点左侧分类,已请求的数据仍在本页缓存中。Demo 顶部的"请求次数 / 缓存类数"就是为了直观看出这点。

模拟请求也有两个容易忽略的保护:pendingRequests 按分类 ID 复用尚未结束的 Promise,避免快速重复操作发出同一请求;请求回来后只有目标分类仍是当前分类,才把结果显示到右侧。快速从 A 切到 B 时,A 的迟到响应可以入缓存,但不能覆盖 B 的页面。

到底只是提示,再上拉才切换

如果一滚到底就立刻请求下一类,短分类会尤其容易被跳过。这个 Demo 把"到达底部"和"确认继续"拆成两次动作:滚动事件和 scrolltolower 更新底部状态;手指按下时记录本次手势开始前是否已经到底;抬起时还在底部、且向上滑过一定距离,才切换下一类。

ts 复制代码
function handleTouchStart(event) {
  touchStartY = event.touches?.[0]?.clientY ?? 0
  touchStartedAtBottom = isAtBottom.value && requestState.value === 'ready'
}

function handleTouchEnd(event) {
  const endY = event.changedTouches?.[0]?.clientY ?? touchStartY
  if (touchStartedAtBottom && isAtBottom.value
    && touchStartY - endY > 55 && nextCategory.value) {
    goToNextCategory()
  }
  touchStartedAtBottom = false
}

触摸事件只绑在右侧商品区域,滚动左侧分类栏不会误触发"下一类"。不想再滑一次时,next-cue 本身也可以点击。最后一个分类只显示"已浏览全部分类",不会继续请求不存在的分类。

短列表也要给吸顶栏留出行程

一两件商品的分类还有一个布局问题:右侧内容太短,左侧 position: sticky 所在的父容器如果也只有一小段高度,左栏还没来得及贴住固定头部,整个菜单就滚走了。固定的 demo-hero 随后会盖住分类。

所以需要量出 demo-hero 的实际高度,再分别计算滚动内容的顶部留白和左栏的吸顶位置。菜单区域至少占满一屏,让短商品列表也有足够的吸顶行程。

ts 复制代码
const headerOffset = computed(() => heroHeight.value + menuShellGap.value)
const scrollContentStyle = computed(() => ({
  paddingTop: heroHeight.value
    ? `${heroHeight.value + heroBottomGap.value}px`
    : '430rpx',
}))
const sideStyle = computed(() => ({
  top: `${headerOffset.value}px`,
  height: `calc(100vh - ${headerOffset.value}px)`,
}))
scss 复制代码
.menu-section {
  display: flex;
  min-height: 100vh;
}

页面只有一个主要的纵向 scroll-view,左侧分类栏才有自己的内部滚动。切换分类后,scrollToMenu 用菜单相对于滚动内容的位置减去固定头部高度,让右侧从菜单顶部开始展示,不把分类标题藏到 demo-hero 后面。

加载、空态和失败都属于这个交互

按需加载不能只处理"有商品"的成功状态。Demo 分别展示加载中、零商品和加载失败;顶部"模拟失败"会让下一次未缓存分类的模拟请求失败,右侧可点击"重新加载"。缓存中即使是空数组,也代表该分类已经成功请求过,不应反复请求。

目前的 fetchCategoryDishes 只是延时 mock,没有接真实接口,也没有做单分类分页。如果真实分类内部还有多页商品,应该先把该分类的页加载到结束,再允许底部提示进入下一分类;否则"上拉下一类"会抢走"加载当前类下一页"的手势。

Demo 放在这里

代码在 menu-scroll-on-demand.vue,微信小程序页面路由为 /pages-demo/menu-scroll-on-demand。依次看两件商品的短分类、九件商品的长分类、零商品分类,再返回已经访问过的分类,可以观察滚动边界、请求计数与缓存行为。

这套方式适合"用户一次专注一类,按需进入下一类"的商超菜单。分类目录保持完整,商品请求则跟随用户的选择推进,页面不必为尚未浏览的分类提前承担加载和渲染成本。

代码

xml 复制代码
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import PriceDisplay from '@/components/price-display.vue'

definePage({
  style: {
    navigationStyle: 'default',
    navigationBarTitleText: '分类按需加载 Demo',
  },
})

interface CategorySeed {
  id: string
  name: string
  subtitle: string
  count: number
  color: string
}

interface DishItem {
  id: string
  name: string
  desc: string
  badge: string
  price: number
  color: string
}

// 分类目录先到达;商品只在进入对应分类时由 mock 请求生成。
const categories: CategorySeed[] = [
  { id: 'coffee', name: '浓缩系列', subtitle: '醇厚咖啡', count: 2, color: '#795b45' },
  { id: 'cold', name: '冷萃系列', subtitle: '清爽低温', count: 9, color: '#547b81' },
  { id: 'travel', name: '旅行家系列', subtitle: '风味合集', count: 1, color: '#a16c44' },
  { id: 'fruit', name: '果C系列', subtitle: '鲜果灵感', count: 12, color: '#d47943' },
  { id: 'select', name: '囤货优选', subtitle: '日常好物', count: 2, color: '#8a7865' },
  { id: 'bake', name: '烘焙小食', subtitle: '搭配一杯', count: 7, color: '#ab8060' },
  { id: 'new', name: '即将上新', subtitle: '敬请期待', count: 0, color: '#799281' },
  { id: 'gift', name: '礼盒专区', subtitle: '分享心意', count: 1, color: '#9b6764' },
]

const instance = getCurrentInstance()
const activeIndex = ref(0)
const activeCategory = computed(() => categories[activeIndex.value])
const nextCategory = computed(() => categories[activeIndex.value + 1])
const leftScrollIntoView = ref(`left-cat-${categories[0].id}`)
const productCache = ref<Record<string, DishItem[]>>({})
const dishes = ref<DishItem[]>([])
const requestState = ref<'loading' | 'ready' | 'error'>('loading')
const requestCount = ref(0)
const failNextRequest = ref(false)
const cachedCount = computed(() => Object.keys(productCache.value).length)

const heroHeight = ref(0)
const heroBottomGap = ref(0)
const menuShellGap = ref(0)
const outerScrollTop = ref(0)
const targetScrollTop = ref(0)
const viewportHeight = ref(0)
const scrollHeight = ref(0)
const isAtBottom = ref(false)
const headerOffset = computed(() => heroHeight.value + menuShellGap.value)
const scrollContentStyle = computed(() => ({
  paddingTop: heroHeight.value ? `${heroHeight.value + heroBottomGap.value}px` : '430rpx',
}))
const sideStyle = computed(() => ({
  top: `${headerOffset.value}px`,
  height: `calc(100vh - ${headerOffset.value}px)`,
}))

const pendingRequests = new Map<string, Promise<DishItem[]>>()
let measureTimer: ReturnType<typeof setTimeout> | undefined
const mockTimers = new Set<ReturnType<typeof setTimeout>>()
let touchStartY = 0
let touchStartedAtBottom = false
let disposed = false

function makeDishes(category: CategorySeed): DishItem[] {
  const badges = ['精选', '热卖', '新品', '推荐']
  const descriptions = ['一杯刚好的日常风味', '适合慢慢享用', '清爽又有层次', '这份值得尝试']

  return Array.from({ length: category.count }, (_, index) => ({
    id: `${category.id}-${index + 1}`,
    name: `${category.name} ${index + 1} 号`,
    desc: descriptions[index % descriptions.length],
    badge: badges[index % badges.length],
    price: 16 + activePriceBase(category.id) + index * 2,
    color: category.color,
  }))
}

function activePriceBase(categoryId: string) {
  return categories.findIndex(item => item.id === categoryId) * 3
}

function fetchCategoryDishes(category: CategorySeed): Promise<DishItem[]> {
  const existing = pendingRequests.get(category.id)
  if (existing)
    return existing

  requestCount.value += 1
  const shouldFail = failNextRequest.value
  failNextRequest.value = false
  const request = new Promise<DishItem[]>((resolve, reject) => {
    const timer = setTimeout(() => {
      mockTimers.delete(timer)
      if (shouldFail)
        reject(new Error('模拟请求失败'))
      else resolve(makeDishes(category))
    }, 420)
    mockTimers.add(timer)
  }).finally(() => pendingRequests.delete(category.id))

  pendingRequests.set(category.id, request)
  return request
}

async function loadCategory(category: CategorySeed) {
  const cached = productCache.value[category.id]
  if (cached) {
    dishes.value = cached
    requestState.value = 'ready'
    scheduleMeasure()
    return
  }

  requestState.value = 'loading'
  dishes.value = []
  try {
    const result = await fetchCategoryDishes(category)
    if (disposed)
      return
    productCache.value[category.id] = result
    if (activeCategory.value.id === category.id) {
      dishes.value = result
      requestState.value = 'ready'
      scheduleMeasure()
    }
  }
  catch {
    if (!disposed && activeCategory.value.id === category.id) {
      requestState.value = 'error'
      scheduleMeasure()
    }
  }
}

function measureLayout() {
  if (!instance?.proxy)
    return
  const query = uni.createSelectorQuery().in(instance.proxy)
  query.select('.demo-hero').boundingClientRect()
  query.select('.menu-page__scroll').boundingClientRect()
  query.select('.menu-scroll-content').boundingClientRect()
  query.exec((results: any[]) => {
    const hero = results?.[0]
    const viewport = results?.[1]
    const content = results?.[2]
    heroHeight.value = Number(hero?.height || heroHeight.value)
    viewportHeight.value = Number(viewport?.height || viewportHeight.value)
    scrollHeight.value = Number(content?.height || scrollHeight.value)
    updateBottomState()
  })
}

function scheduleMeasure() {
  if (measureTimer)
    clearTimeout(measureTimer)
  nextTick(measureLayout)
  measureTimer = setTimeout(measureLayout, 180)
}

function updateBottomState() {
  isAtBottom.value = scrollHeight.value > 0
    && viewportHeight.value > 0
    && scrollHeight.value - viewportHeight.value - outerScrollTop.value <= 10
}

function scrollToMenu() {
  if (!instance?.proxy)
    return
  const query = uni.createSelectorQuery().in(instance.proxy)
  query.select('.menu-scroll-content').boundingClientRect()
  query.select('.menu-shell').boundingClientRect()
  query.exec((results: any[]) => {
    const content = results?.[0]
    const menu = results?.[1]
    if (!content || !menu)
      return
    const menuTop = Number(menu.top) - Number(content.top)
    const next = Math.max(0, menuTop - headerOffset.value)
    // scroll-top 的同值赋值不会再次定位,先偏移 1px 以支持反复点击。
    targetScrollTop.value = Math.abs(targetScrollTop.value - next) < 1 ? next + 1 : next
    isAtBottom.value = false
  })
}

async function selectCategory(index: number) {
  if (index < 0 || index >= categories.length || index === activeIndex.value)
    return
  activeIndex.value = index
  leftScrollIntoView.value = `left-cat-${categories[index].id}`
  touchStartedAtBottom = false
  isAtBottom.value = false
  await nextTick()
  scrollToMenu()
  loadCategory(categories[index])
}

function handleScroll(event: { detail: { scrollTop: number, scrollHeight: number } }) {
  outerScrollTop.value = Number(event.detail.scrollTop || 0)
  scrollHeight.value = Number(event.detail.scrollHeight || scrollHeight.value)
  updateBottomState()
}

function handleScrollToLower() {
  isAtBottom.value = true
}

function handleTouchStart(event: { touches: Array<{ clientY: number }> }) {
  touchStartY = event.touches?.[0]?.clientY ?? 0
  // 必须在本次手势开始前就已到底;一次惯性滑到底不会跳过提示。
  touchStartedAtBottom = isAtBottom.value && requestState.value === 'ready'
}

function handleTouchEnd(event: { changedTouches: Array<{ clientY: number }> }) {
  const endY = event.changedTouches?.[0]?.clientY ?? touchStartY
  if (touchStartedAtBottom && isAtBottom.value && touchStartY - endY > 55 && nextCategory.value) {
    goToNextCategory()
  }
  touchStartedAtBottom = false
}

function goToNextCategory() {
  if (nextCategory.value)
    selectCategory(activeIndex.value + 1)
}

function retryCurrent() {
  loadCategory(activeCategory.value)
}

function armFailure() {
  failNextRequest.value = !failNextRequest.value
  uni.showToast({ title: failNextRequest.value ? '下一次新分类请求将失败' : '已取消模拟失败', icon: 'none' })
}

function handleDemoAction(name: string) {
  uni.showToast({ title: `演示:${name}`, icon: 'none' })
}

onMounted(() => {
  const { windowWidth = 375 } = uni.getSystemInfoSync()
  heroBottomGap.value = 20 * windowWidth / 750
  menuShellGap.value = 16 * windowWidth / 750
  scheduleMeasure()
  loadCategory(activeCategory.value)
})

onBeforeUnmount(() => {
  disposed = true
  if (measureTimer)
    clearTimeout(measureTimer)
  mockTimers.forEach(timer => clearTimeout(timer))
  mockTimers.clear()
})
</script>

<template>
  <view class="demo-page">
    <view class="demo-hero">
      <view class="demo-hero__title">
        按需加载的菜单
      </view>
      <view class="demo-hero__desc">
        分类先展示,商品进入时才请求。上拉或点击提示,进入下一类。
      </view>
      <view class="demo-hero__stats">
        <text>请求 {{ requestCount }} 次</text>
        <text>缓存 {{ cachedCount }} 类</text>
        <text class="demo-hero__test" @tap.stop="armFailure">{{ failNextRequest ? '取消失败' : '模拟失败' }}</text>
      </view>
    </view>

    <scroll-view
      class="menu-page__scroll"
      scroll-y
      enhanced
      :scroll-top="targetScrollTop"
      :show-scrollbar="false"
      lower-threshold="20"
      @scroll="handleScroll"
      @scrolltolower="handleScrollToLower"
    >
      <view class="menu-scroll-content" :style="scrollContentStyle">
        <view class="menu-shell">
          <view class="menu-section">
            <view class="menu-side" :style="sideStyle">
              <scroll-view
                class="menu-side__scroll"
                scroll-y
                :scroll-into-view="leftScrollIntoView"
                :show-scrollbar="false"
              >
                <view
                  v-for="(category, index) in categories"
                  :id="`left-cat-${category.id}`"
                  :key="category.id"
                  class="menu-side__item"
                  :class="{ 'menu-side__item--active': index === activeIndex }"
                  @tap.stop="selectCategory(index)"
                >
                  <text class="menu-side__item-name">{{ category.name }}</text>
                  <text class="menu-side__item-sub">{{ category.subtitle }}</text>
                </view>
              </scroll-view>
            </view>

            <view class="menu-content" @touchstart="handleTouchStart" @touchend="handleTouchEnd">
              <view class="category-banner" :style="{ backgroundColor: activeCategory.color }">
                <text class="category-banner__small">{{ String(activeIndex + 1).padStart(2, '0') }} / {{ String(categories.length).padStart(2, '0') }}</text>
                <text class="category-banner__title">{{ activeCategory.name }}</text>
                <text class="category-banner__subtitle">{{ activeCategory.subtitle }} · 发现这一类的好味道</text>
              </view>

              <view class="menu-group">
                <view class="menu-group__head">
                  <text class="menu-group__title">{{ activeCategory.name }}</text>
                  <text class="menu-group__count">{{ requestState === 'ready' ? `${dishes.length} 款` : '按需加载' }}</text>
                </view>

                <view v-if="requestState === 'loading'" class="menu-state">
                  正在加载 {{ activeCategory.name }}...
                </view>
                <view v-else-if="requestState === 'error'" class="menu-state menu-state--error">
                  <text>这一类暂时加载失败,请重试。</text>
                  <text class="menu-state__retry" @tap.stop="retryCurrent">重新加载</text>
                </view>
                <view v-else-if="!dishes.length" class="menu-state">
                  这一类还没有商品,继续上拉看看下一类。
                </view>

                <view v-for="dish in dishes" :key="dish.id" class="dish-card">
                  <view class="dish-card__thumb" :style="{ backgroundColor: dish.color }">
                    {{ dish.name.slice(-3) }}
                  </view>
                  <view class="dish-card__main">
                    <view class="dish-card__head">
                      <text class="dish-card__name">{{ dish.name }}</text>
                      <text class="dish-card__badge">{{ dish.badge }}</text>
                    </view>
                    <text class="dish-card__desc">{{ dish.desc }}</text>
                    <view class="dish-card__foot">
                      <PriceDisplay :price="dish.price" unit-font-size="20rpx" yuan-font-size="30rpx" decimal-font-size="22rpx" color="#d95535" />
                      <view class="dish-card__action" @tap.stop="handleDemoAction(dish.name)">
                        +
                      </view>
                    </view>
                  </view>
                </view>

                <view v-if="requestState === 'ready'" class="next-cue" :class="{ 'next-cue--action': nextCategory }" @tap.stop="goToNextCategory">
                  <template v-if="nextCategory">
                    <text class="next-cue__title">上拉继续浏览 {{ nextCategory.name }}</text>
                    <text class="next-cue__hint">到底后再次上拉,或点击这里进入</text>
                  </template>
                  <text v-else class="next-cue__title">已浏览全部分类</text>
                </view>
              </view>
            </view>
          </view>
        </view>
      </view>
    </scroll-view>
  </view>
</template>

<style scoped lang="scss">
.demo-page {
  min-height: 100vh;
  background: #f1f0ed;
}
.menu-page__scroll {
  position: fixed;
  inset: 0;
  z-index: 10;
}
.menu-scroll-content {
  min-height: 100%;
  padding: 430rpx 0 0;
  box-sizing: border-box;
}

.demo-hero {
  position: fixed;
  z-index: 40;
  top: 0;
  left: 24rpx;
  right: 24rpx;
  padding: 28rpx;
  border-radius: 22rpx;
  color: #fff;
  background: #273c37;
  box-shadow: 0 16rpx 32rpx rgb(26 46 39 / 16%);
}
.demo-hero__title {
  font-size: 36rpx;
  line-height: 48rpx;
  font-weight: 700;
}
.demo-hero__desc {
  margin-top: 8rpx;
  font-size: 23rpx;
  line-height: 34rpx;
  color: #d8e3d9;
}
.demo-hero__stats {
  display: flex;
  align-items: center;
  gap: 20rpx;
  margin-top: 18rpx;
  font-size: 22rpx;
  color: #d8e3d9;
}
.demo-hero__test {
  margin-left: auto;
  padding: 6rpx 12rpx;
  border: 1rpx solid #91b4a4;
  border-radius: 8rpx;
  color: #fff;
}

.menu-shell {
  width: 100%;
  margin-top: 16rpx;
  background: #f5f5f3;
}
.menu-section {
  display: flex;
  align-items: flex-start;
  width: 100%;
  min-height: 100vh;
}
.menu-side {
  position: sticky;
  flex: 0 0 160rpx;
  overflow: hidden;
  background: #f0f0ef;
  border-right: 1rpx solid #e9e9e7;
}
.menu-side__scroll {
  height: 100%;
}
.menu-side__item {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 4rpx;
  min-height: 104rpx;
  padding: 16rpx 10rpx 16rpx 18rpx;
  box-sizing: border-box;
  color: #656a68;
}
.menu-side__item--active {
  border-left: 6rpx solid #427863;
  padding-left: 12rpx;
  color: #244e3b;
  background: #fff;
}
.menu-side__item-name {
  font-size: 27rpx;
  line-height: 38rpx;
  font-weight: 600;
}
.menu-side__item-sub {
  font-size: 20rpx;
  line-height: 28rpx;
  opacity: 0.75;
}

.menu-content {
  flex: 1 1 0;
  min-width: 0;
  min-height: inherit;
  background: #fff;
}
.category-banner {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8rpx;
  margin: 14rpx 14rpx 0;
  padding: 22rpx;
  min-height: 164rpx;
  box-sizing: border-box;
  border-radius: 18rpx;
  color: #fff;
}
.category-banner__small {
  font-size: 20rpx;
  opacity: 0.8;
}
.category-banner__title {
  font-size: 35rpx;
  line-height: 44rpx;
  font-weight: 700;
}
.category-banner__subtitle {
  font-size: 22rpx;
  opacity: 0.85;
}
.menu-group {
  padding: 22rpx 14rpx 40rpx;
}
.menu-group__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10rpx;
  margin-bottom: 18rpx;
}
.menu-group__title {
  min-width: 0;
  font-size: 29rpx;
  line-height: 42rpx;
  font-weight: 700;
  color: #303735;
}
.menu-group__count {
  flex-shrink: 0;
  font-size: 21rpx;
  color: #929b96;
}
.menu-state {
  padding: 32rpx 8rpx;
  font-size: 23rpx;
  line-height: 36rpx;
  color: #828c87;
}
.menu-state--error {
  display: flex;
  flex-direction: column;
  gap: 16rpx;
}
.menu-state__retry {
  align-self: flex-start;
  padding: 8rpx 14rpx;
  border-radius: 8rpx;
  color: #fff;
  background: #427863;
}

.dish-card {
  display: flex;
  gap: 14rpx;
  padding: 14rpx 0;
  border-bottom: 1rpx solid #f0f0ee;
}
.dish-card__thumb {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 108rpx;
  height: 108rpx;
  border-radius: 14rpx;
  color: #fff;
  font-size: 23rpx;
  font-weight: 600;
}
.dish-card__main {
  flex: 1;
  min-width: 0;
}
.dish-card__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 4rpx;
}
.dish-card__name {
  flex: 1;
  min-width: 0;
  font-size: 25rpx;
  line-height: 36rpx;
  font-weight: 600;
  color: #303735;
}
.dish-card__badge {
  flex-shrink: 0;
  padding: 2rpx 5rpx;
  border-radius: 4rpx;
  font-size: 17rpx;
  color: #b36638;
  background: #fff1e5;
}
.dish-card__desc {
  display: block;
  margin-top: 4rpx;
  font-size: 20rpx;
  line-height: 30rpx;
  color: #929b96;
}
.dish-card__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 12rpx;
}
.dish-card__action {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 42rpx;
  height: 42rpx;
  border-radius: 50%;
  background: #427863;
  color: #fff;
  font-size: 30rpx;
  line-height: 1;
}
.next-cue {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8rpx;
  padding: 36rpx 0 16rpx;
  color: #424a46;
  text-align: center;
}
.next-cue--action {
  cursor: pointer;
}
.next-cue__title {
  font-size: 24rpx;
  line-height: 36rpx;
}
.next-cue__hint {
  font-size: 19rpx;
  color: #a1aaa5;
}
</style>
相关推荐
竹林8182 小时前
把神经网络塞进一个浏览器标签页:端侧视觉 AI 的工程真相
前端·浏览器
乘风gg2 小时前
别跟风 AI 副业,工程师最该学的是 AI Coding
前端·ai编程·claude
Frag0ut2 小时前
深度解析:Chrome 自启动与后台常驻服务的作用、关闭方法及利弊权衡
前端·chrome
Amos_Web3 小时前
Rspack 源码解析(十五):Loader Runner 与 JS Loader 桥接
前端·rust·前端框架
YIAN3 小时前
从 SSE 流式到结构化输出:LangChain 三大 OutputParser 与 ToolCall 方案全实战
前端·langchain·node.js
FYKJ_20103 小时前
springboot雅集社区养老管理系统04456-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django
FYKJ_20103 小时前
express皖美特色农产品网售系统53118-计算机课程设计、毕业设计
javascript·vue.js·spring boot·mysql·typescript·spark·express
YIAN3 小时前
从 SSE 流式原理到 LangChain 结构化输出:打字机效果与 JSON 解析全方案实战
前端·langchain
Moment3 小时前
如果你在做 RAG,可能会需要 pdf-inspector
前端·后端·面试