bindbox-mini/pages/shop/index.vue
2026-02-07 00:58:10 +08:00

954 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<view class="bg-decoration"></view>
<!-- 自定义 tabBar -->
<!-- #ifdef MP-TOUTIAO -->
<customTabBarToutiao />
<!-- #endif -->
<!-- #ifndef MP-TOUTIAO -->
<customTabBar />
<!-- #endif -->
<!-- [NEW] 全新左右布局布局容器 -->
<view class="shop-layout">
<!-- 左侧边栏 - 极致轻盈设计 -->
<view class="sidebar glass-effect">
<scroll-view scroll-y class="sidebar-scroll">
<view class="sidebar-list">
<view
class="sidebar-item"
:class="{ active: selectedCategoryId === 0 }"
@tap="onCategorySelect(0)"
>
<view class="indicator" v-if="selectedCategoryId === 0"></view>
<text class="item-text">全部</text>
</view>
<view
v-for="cat in categories"
:key="cat.id"
class="sidebar-item"
:class="{ active: selectedCategoryId === cat.id }"
@tap="onCategorySelect(cat.id)"
>
<view class="indicator" v-if="selectedCategoryId === cat.id"></view>
<text class="item-text">{{ cat.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 右侧主内容区域 -->
<view class="main-content">
<!-- 顶部搜索浮层 -->
<view class="top-nav glass-effect">
<view class="search-wrap">
<view class="search-bar">
<text class="search-icon">🔍</text>
<input class="search-input" v-model="keyword" placeholder="搜好物" @confirm="onSearchConfirm" />
<view class="filter-btn" @tap="togglePriceFilter">
<text class="filter-icon">🔽</text>
</view>
</view>
<!-- 频道切换 (商品/优惠券) -->
<view class="tab-pill">
<view
class="tab-pill-item"
v-for="tab in tabs" :key="tab.id"
:class="{ active: currentTab === tab.id }"
@tap="switchTab(tab.id)"
>
{{ tab.name }}
</view>
</view>
</view>
</view>
<!-- 价格区间筛选面板 (独立浮层) -->
<view class="price-filter-panel" :class="{ expanded: showPriceFilter }">
<view class="filter-row">
<view class="price-input-group">
<input class="price-input" v-model="priceMin" type="digit" placeholder="最低价" />
<text class="price-separator">-</text>
<input class="price-input" v-model="priceMax" type="digit" placeholder="最高价" />
</view>
<view class="filter-actions">
<view class="filter-btn-reset" @tap="resetPriceFilter">重置</view>
<view class="filter-btn-confirm" @tap="applyPriceFilter">确定</view>
</view>
</view>
<!-- 快捷价格区间 -->
<view class="quick-price-ranges">
<view
v-for="range in priceRanges"
:key="range.key"
class="price-range-tag"
:class="{ active: isRangeActive(range.key) }"
@tap="selectQuickPrice(range)"
>
{{ range.label }}
</view>
</view>
</view>
<!-- 内容滚动容器 (带缩放动画) -->
<scroll-view
scroll-y
class="content-scroll-area"
@scrolltolower="handleScrollToLower"
>
<view
class="transition-container"
:class="{ switching: isSwitching }"
@animationend="onAnimationEnd"
>
<!-- 商品 Grid (保持原有逻辑优化视觉) -->
<view v-if="currentTab === 'product'" class="products-grid">
<view class="product-item" v-for="p in items" :key="p.id" @tap="onProductTap(p)">
<view class="product-card">
<view class="thumb-wrap">
<image class="product-thumb" :src="p.image" mode="aspectFill" lazy-load="true" />
<view class="stock-tag" v-if="p.stock !== null && p.stock < 10 && p.stock > 0">仅剩{{p.stock}}</view>
<view class="stock-tag out" v-else-if="p.stock === 0">售罄</view>
</view>
<view class="product-info">
<text class="product-title">{{ p.title }}</text>
<view class="product-bottom">
<view class="price-row">
<text class="points-val">{{ p.points }}</text>
<text class="points-unit">积分</text>
</view>
<view class="redeem-btn-sm" v-if="p.stock > 0" @tap.stop="onRedeemTap(p)">+</view>
</view>
</view>
</view>
</view>
</view>
<!-- 优惠券视图 -->
<view v-else-if="currentTab === 'coupon'" class="coupons-compact-list">
<view class="coupon-compact-card" v-for="c in items" :key="c.id">
<view class="c-val-box">
<text class="c-val">¥{{ (c.discount_value || 0) / 100 }}</text>
</view>
<view class="c-info-box">
<text class="c-title">{{ c.title }}</text>
<text class="c-price">{{ c.points }} 积分</text>
</view>
<view class="c-btn" @tap="onRedeemTap(c)">兑换</view>
</view>
</view>
<!-- 道具卡视图 (后期开放) -->
<view v-else-if="currentTab === 'item_card'" class="item-cards-list">
<view class="empty-state" style="padding-top: 50rpx;">
<image class="empty-img" src="/static/empty.png" mode="widthFix" />
<text class="empty-text">暂未开放</text>
</view>
</view>
<view v-if="!items.length && !loading" class="empty-mini">
<image class="empty-mini-img" src="/static/empty.png" mode="widthFix" />
<text>空空如也</text>
</view>
<view v-if="loading" class="mini-loading">
<view class="pulse"></view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { ref, watch, onUnmounted } from 'vue'
import { getStoreItems, redeemProductByPoints, redeemCouponByPoints, redeemItemCardByPoints, getProductCategories } from '../../api/appUser'
import { checkPhoneBound, checkPhoneBoundSync } from '../../utils/checkPhone.js'
import { vibrateShort } from '@/utils/vibrate.js'
// #ifdef MP-TOUTIAO
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
// #endif
// #ifndef MP-TOUTIAO
import customTabBar from '@/components/app-tab-bar.vue'
// #endif
// 由于是 setup 语法,组件会自动注册,无需手动声明
const loading = ref(false)
const keyword = ref('')
const currentTab = ref('product')
const items = ref([])
const allItems = ref([])
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
// 分类相关
const categories = ref([])
const selectedCategoryId = ref(0)
// 标记是否已经初始化加载过数据
let hasInitialized = false
// 趣味加载文字数组
const loadingTexts = [
'请稍等,正在努力加载中。。。',
'精彩马上就来,请稍候片刻~',
'正在搜寻更多好物,别急哦~',
'加载中,好东西值得等待~',
'正在为您准备惊喜,马上就好~',
'小憩一下,精彩内容即将呈现~',
'努力加载中,比心❤️~',
'正在赶来,马上就到~',
'稍安勿躁,美好值得等待~',
'加载进度99%... 开个玩笑😄'
]
const loadingText = ref(loadingTexts[0])
const isSwitching = ref(false)
let loadingTextInterval = null
// 开始切换加载文字
function startLoadingTextRotation() {
if (loadingTextInterval) return
let index = 0
loadingText.value = loadingTexts[index]
loadingTextInterval = setInterval(() => {
index = (index + 1) % loadingTexts.length
loadingText.value = loadingTexts[index]
}, 2000) // 每2秒切换一次
}
// 停止切换加载文字
function stopLoadingTextRotation() {
if (loadingTextInterval) {
clearInterval(loadingTextInterval)
loadingTextInterval = null
}
}
// 监听 loading 状态,自动启停文字轮播
watch(loading, (newVal) => {
if (newVal) {
startLoadingTextRotation()
} else {
stopLoadingTextRotation()
}
})
// 价格筛选相关
const priceMin = ref('')
const priceMax = ref('')
const showPriceFilter = ref(false)
const priceRanges = [
{ key: 'all', label: '全部', min: null, max: null },
{ key: '0-100', label: '0-100', min: 0, max: 100 },
{ key: '100-500', label: '100-500', min: 100, max: 500 },
{ key: '500-1000', label: '500-1K', min: 500, max: 1000 },
{ key: '1000-5000', label: '1K-5K', min: 1000, max: 5000 },
{ key: '5000+', label: '5K+', min: 5000, max: null }
]
const activePriceRange = ref('all')
const tabs = [
{ id: 'product', name: '商品' },
{ id: 'coupon', name: '优惠券' }
// { id: 'item_card', name: '道具卡' } // 暂不开放
]
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function normalizeItems(list, kind) {
if (!Array.isArray(list)) return []
// 格式化积分显示 - 不四舍五入,保留两位小数
const formatPoints = (value) => {
if (value === undefined || value === null) return '0.00'
const num = Number(value)
if (isNaN(num)) return '0.00'
// 价格字段单位是分,如 1250 = 12.50积分
// 除以100得到显示值
const finalValue = num / 100
// 使用 Math.floor 避免四舍五入,保留两位小数
return String(Math.floor(finalValue * 100) / 100).replace(/(\.\d)$/, '$10')
}
return list.map((i, idx) => ({
id: i.id,
kind: i.kind || kind,
image: cleanUrl(i.main_image || i.image || ''),
title: i.name || i.title || '',
price: i.price || i.discount_value || 0,
points: formatPoints(i.price || i.discount_value),
stock: i.in_stock ? 99 : 0, // Simplified stock check if returned as bool
discount_value: i.discount_value || 0,
min_spend: i.min_spend || 0,
description: i.description || ''
})).filter(i => i.title)
}
function switchTab(id) {
if (currentTab.value === id) return
vibrateShort()
currentTab.value = id
triggerSwitching()
items.value = []
allItems.value = []
page.value = 1
hasMore.value = true
loadItems()
}
function triggerSwitching() {
isSwitching.value = true
}
function onAnimationEnd() {
isSwitching.value = false
}
async function loadItems(append = false) {
if (loading.value) return
loading.value = true
try {
// 构建筛选参数
const filters = {}
if (keyword.value && keyword.value.trim()) {
filters.keyword = keyword.value.trim()
}
if (priceMin.value !== '' && priceMin.value !== null) {
filters.price_min = priceMin.value
}
if (priceMax.value !== '' && priceMax.value !== null) {
filters.price_max = priceMax.value
}
// 添加分类筛选
if (selectedCategoryId.value > 0) {
filters.category_id = selectedCategoryId.value
}
const res = await getStoreItems(currentTab.value, page.value, pageSize, filters)
const list = res.list || res || []
const total = res.total || 0
const newItems = normalizeItems(list, currentTab.value)
if (append) {
allItems.value = [...allItems.value, ...newItems]
} else {
allItems.value = newItems
}
// 直接使用服务端返回的数据(已经过筛选)
items.value = allItems.value
// 检查是否还有更多数据
const totalPages = Math.ceil(total / pageSize)
hasMore.value = page.value < totalPages
} catch (e) {
console.error(e)
hasMore.value = false
} finally {
loading.value = false
}
}
function applyFilters() {
const k = String(keyword.value || '').trim().toLowerCase()
const minPrice = priceMin.value !== '' ? parseFloat(priceMin.value) : null
const maxPrice = priceMax.value !== '' ? parseFloat(priceMax.value) : null
items.value = allItems.value.filter(item => {
// 关键词筛选
const title = String(item.title || '').toLowerCase()
const matchKeyword = !k || title.includes(k)
// 价格筛选
const itemPrice = item.points || 0
let matchPrice = true
if (minPrice !== null) {
matchPrice = matchPrice && itemPrice >= minPrice
}
if (maxPrice !== null) {
matchPrice = matchPrice && itemPrice <= maxPrice
}
return matchKeyword && matchPrice
})
}
function applyPriceFilter() {
activePriceRange.value = 'custom'
// 重新加载数据(服务端筛选)
page.value = 1
hasMore.value = true
allItems.value = []
loadItems()
// 关闭筛选面板
showPriceFilter.value = false
}
function resetPriceFilter() {
priceMin.value = ''
priceMax.value = ''
activePriceRange.value = 'all'
// 重新加载数据
page.value = 1
hasMore.value = true
allItems.value = []
loadItems()
// 关闭筛选面板
showPriceFilter.value = false
}
function selectQuickPrice(range) {
vibrateShort()
activePriceRange.value = range.key
if (range.min !== null) {
priceMin.value = range.min.toString()
} else {
priceMin.value = ''
}
if (range.max !== null) {
priceMax.value = range.max.toString()
} else {
priceMax.value = ''
}
// 重新加载数据
page.value = 1
hasMore.value = true
allItems.value = []
loadItems()
// 关闭筛选面板
showPriceFilter.value = false
}
function isRangeActive(range) {
return activePriceRange.value === range.key
}
// 切换价格筛选面板显示/隐藏
function togglePriceFilter() {
vibrateShort()
showPriceFilter.value = !showPriceFilter.value
}
function onSearchConfirm() {
// 搜索时重新加载数据
page.value = 1
hasMore.value = true
allItems.value = []
loadItems()
}
function onProductTap(p) {
if (p.kind === 'product') {
uni.navigateTo({ url: `/pages-shop/shop/detail?id=${p.id}` })
}
}
async function onRedeemTap(item) {
// 检查商品库存
if (item.kind === 'product' && item.stock === 0) {
uni.showModal({
title: '商品已售罄',
content: '该商品库存不足,请联系客服处理',
showCancel: false
})
return
}
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '温馨提示',
content: '兑换商品需要先登录哦',
confirmText: '去登录',
cancelText: '暂不登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
uni.showModal({
title: '确认兑换',
content: `是否消耗 ${item.points} 积分兑换 ${item.title}`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '兑换中...' })
try {
const userId = uni.getStorageSync('user_id')
if (!userId) throw new Error('用户ID不存在')
if (item.kind === 'product') {
await redeemProductByPoints(userId, item.id, 1)
} else if (item.kind === 'coupon') {
await redeemCouponByPoints(userId, item.id)
} else if (item.kind === 'item_card') {
await redeemItemCardByPoints(userId, item.id, 1)
}
uni.hideLoading()
uni.showModal({
title: '兑换成功',
content: `您已成功兑换 ${item.title || item.name}`,
showCancel: false,
success: () => {
loadItems()
}
})
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
}
}
}
})
}
// 获取分类列表
async function fetchCategories() {
try {
const res = await getProductCategories()
// 兼容可能存在的不同数据结构
if (res && res.items) {
categories.value = res.items
} else if (Array.isArray(res)) {
categories.value = res
} else {
categories.value = []
}
} catch (e) {
console.error('获取分类失败:', e)
}
}
// 选择分类
function onCategorySelect(id) {
if (selectedCategoryId.value === id) return
vibrateShort()
selectedCategoryId.value = id
triggerSwitching()
page.value = 1
hasMore.value = true
items.value = []
loadItems()
}
onShow(() => {
// 检查手机号绑定状态(快速检查本地缓存)
if (!checkPhoneBoundSync()) return
const token = uni.getStorageSync('token')
if (token) {
// 只在首次加载时初始化数据,避免从详情页返回时重复加载
if (!hasInitialized) {
page.value = 1
hasMore.value = true
allItems.value = []
loadItems()
fetchCategories()
hasInitialized = true
}
}
})
onReachBottom(() => {
// 触底加载更多页面级scroll-view 内不触发)
handleScrollToLower()
})
// scroll-view 触底加载
function handleScrollToLower() {
if (hasMore.value && !loading.value) {
page.value++
loadItems(true)
}
}
watch(keyword, () => applyFilters())
// 组件卸载时清理定时器
onUnmounted(() => {
stopLoadingTextRotation()
})
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
background-color: #f7f8fa;
overflow: hidden;
}
.shop-layout {
display: flex;
height: 100vh;
width: 100%;
}
.glass-effect {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(30rpx);
-webkit-backdrop-filter: blur(30rpx);
}
/* 侧边栏 */
.sidebar {
width: 160rpx;
height: 100vh;
border-right: 1rpx solid rgba(0, 0, 0, 0.05);
z-index: 10;
}
.sidebar-scroll { height: 100%; }
.sidebar-list { padding: 40rpx 0; }
.sidebar-item {
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.item-text {
font-size: 26rpx;
color: #666;
transition: all 0.3s;
}
&.active {
.item-text {
color: $brand-primary;
font-weight: 700;
transform: scale(1.1);
}
}
}
.indicator {
position: absolute;
left: 0;
width: 8rpx;
height: 32rpx;
background: $gradient-brand;
border-radius: 0 4rpx 4rpx 0;
}
/* 主内容区 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
position: relative;
overflow: visible;
}
.top-nav {
padding: 20rpx 24rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
position: relative;
z-index: 50;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(30rpx);
-webkit-backdrop-filter: blur(30rpx);
flex-shrink: 0;
}
.search-wrap {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.search-bar {
flex: 1;
height: 64rpx;
background: rgba(0, 0, 0, 0.04);
border-radius: 32rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
}
.search-icon { font-size: 24rpx; opacity: 0.4; }
.search-input { flex: 1; margin-left: 12rpx; font-size: 24rpx; }
/* 筛选按钮 */
.filter-btn {
margin-left: 12rpx;
padding: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.filter-icon {
font-size: 20rpx;
opacity: 0.5;
transition: transform 0.3s;
}
/* 价格筛选面板 */
.price-filter-panel {
position: absolute;
top: 120rpx;
left: 24rpx;
right: 24rpx;
padding: 20rpx;
background: rgba(255, 255, 255, 0.98);
border-radius: 20rpx;
overflow: hidden;
max-height: 0;
opacity: 0;
transition: all 0.3s ease-out;
z-index: 100;
box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.12);
pointer-events: none;
&.expanded {
max-height: 500rpx;
opacity: 1;
pointer-events: auto;
}
}
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 16rpx;
}
.price-input-group {
flex: 1;
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.03);
border-radius: 12rpx;
padding: 0 16rpx;
height: 60rpx;
}
.price-input {
flex: 1;
height: 100%;
font-size: 24rpx;
text-align: center;
}
.price-separator {
margin: 0 12rpx;
color: #999;
font-size: 24rpx;
}
.filter-actions {
display: flex;
gap: 12rpx;
}
.filter-btn-reset,
.filter-btn-confirm {
padding: 12rpx 24rpx;
border-radius: 12rpx;
font-size: 24rpx;
white-space: nowrap;
}
.filter-btn-reset {
background: rgba(0, 0, 0, 0.05);
color: #666;
}
.filter-btn-confirm {
background: $gradient-brand;
color: #fff;
}
/* 快捷价格区间 */
.quick-price-ranges {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.price-range-tag {
padding: 10rpx 20rpx;
background: rgba(0, 0, 0, 0.04);
border-radius: 20rpx;
font-size: 22rpx;
color: #666;
transition: all 0.2s;
&.active {
background: $gradient-brand;
color: #fff;
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
}
.tab-pill {
display: flex;
background: rgba(0, 0, 0, 0.03);
padding: 4rpx;
border-radius: 30rpx;
}
.tab-pill-item {
padding: 8rpx 20rpx;
font-size: 22rpx;
color: #888;
border-radius: 26rpx;
&.active {
background: #fff;
color: #333;
font-weight: 600;
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
}
}
.content-scroll-area {
flex: 1;
height: 0; /* 关键:在 flex 布局中需要设置为 0 才能正确计算高度 */
background: #fcfcfc;
overflow: hidden;
position: relative;
z-index: 1;
}
/* 缩放动画的核心部分 */
.transition-container {
padding: 24rpx;
min-height: 100%;
&.switching {
animation: zoomInOut 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
}
@keyframes zoomInOut {
0% { transform: scale(1); opacity: 1; }
45% { transform: scale(0.95); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
/* [NEW] 真正瀑布流布局 (Masonry) */
.products-grid {
column-count: 2;
column-gap: 20rpx;
width: 100%;
}
.product-item {
break-inside: avoid;
margin-bottom: 20rpx;
width: 100%;
}
.product-card {
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
border: 1rpx solid rgba(0,0,0,0.02);
transition: transform 0.2s;
&:active {
transform: scale(0.98);
}
}
/* 创造高度落差感 */
.product-item:nth-child(2n) .thumb-wrap {
padding-top: 120%; /* 偶数项更高,产生交错效果 */
}
.thumb-wrap {
width: 100%;
padding-top: 100%;
position: relative;
background: #fafafa;
overflow: hidden;
}
.thumb-wrap .product-thumb {
transition: opacity 0.3s;
}
/* 库存标签 */
.stock-tag {
position: absolute;
top: 8rpx;
right: 8rpx;
padding: 4rpx 12rpx;
border-radius: 20rpx;
font-size: 20rpx;
color: #fff;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4rpx);
-webkit-backdrop-filter: blur(4rpx);
z-index: 2;
&.out {
background: rgba(255, 59, 48, 0.9);
}
}
.product-thumb { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.product-info { padding: 16rpx; }
.product-title {
font-size: 24rpx; color: #333; line-height: 1.4; height: 2.8em;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.product-bottom { display: flex; align-items: center; justify-content: space-between; margin-top: 12rpx; }
.points-val { font-size: 28rpx; color: $brand-primary; font-weight: 700; }
.points-unit { font-size: 18rpx; color: $brand-primary; margin-left: 2rpx; }
.redeem-btn-sm {
width: 44rpx; height: 44rpx; background: $gradient-brand;
color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 28rpx;
}
/* 紧凑型优惠券列表 */
.coupons-compact-list { display: flex; flex-direction: column; gap: 20rpx; }
.coupon-compact-card {
background: #fff; border-radius: 16rpx; padding: 24rpx;
display: flex; align-items: center; border: 1rpx solid rgba(0,0,0,0.03);
}
.c-val-box { width: 100rpx; font-weight: 800; color: #FF6B6B; }
.c-info-box { flex: 1; }
.c-title { display: block; font-size: 24rpx; font-weight: 600; color: #333; }
.c-price { font-size: 20rpx; color: #999; }
.c-btn { background: #333; color: #fff; font-size: 22rpx; padding: 6rpx 20rpx; border-radius: 10rpx; }
.empty-mini {
padding-top: 100rpx;
text-align: center;
color: #ccc;
font-size: 24rpx;
}
.empty-mini-img {
width: 160rpx;
display: block;
margin: 0 auto 10rpx;
}
.mini-loading { padding-top: 60rpx; display: flex; justify-content: center; }
.pulse { width: 40rpx; height: 40rpx; background: $brand-primary; border-radius: 50%; opacity: 0.3; animation: pulse 1s infinite; }
@keyframes pulse { from { transform: scale(1); opacity: 0.3; } to { transform: scale(2); opacity: 0; } }
.no-more {
text-align: center;
padding: 40rpx 0;
color: $text-tertiary;
opacity: 0.6;
}
</style>