bindbox-mini/pages/shop/index.vue

564 lines
14 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 v-if="showNotice" class="notice-mask" @touchmove.stop.prevent @tap.stop>
<view class="notice-dialog" @tap.stop>
<view class="notice-title">温馨提示</view>
<view class="notice-content">由于商品价格实时浮动当前暂不支持自助兑换如需兑换商品请联系客服核对最新价格</view>
<view class="notice-actions">
<view class="notice-check" @tap.stop="toggleHideForever">
<view class="check-box" :class="{ on: hideForever }">
<text v-if="hideForever" class="iconfont icon-check"></text>
</view>
<text class="check-text">不再提示</text>
</view>
<button class="notice-btn" hover-class="btn-hover" @tap.stop="onDismissNotice">我知道了</button>
</view>
</view>
</view>
<!-- 顶部固定区域 -->
<view class="header-section">
<view class="search-box" style="margin-top: 20rpx;">
<view class="search-input-wrap">
<text class="search-icon">🔍</text>
<input class="search-input" v-model="keyword" placeholder="搜索心仪的商品" placeholder-class="placeholder-style" confirm-type="search" @confirm="onSearchConfirm" />
</view>
</view>
<view class="filter-row">
<view class="price-range">
<text class="price-label">价格区间</text>
<input class="price-input" type="number" v-model="minPrice" placeholder="最低" placeholder-class="price-ph" />
<text class="price-sep">-</text>
<input class="price-input" type="number" v-model="maxPrice" placeholder="最高" placeholder-class="price-ph" />
</view>
<button class="filter-btn" hover-class="btn-hover" @tap="onApplyFilters">筛选</button>
</view>
</view>
<!-- 占位防止内容被头部遮挡 -->
<view class="header-placeholder"></view>
<view v-if="loading && !products.length" class="loading-wrap"><view class="spinner"></view></view>
<view class="products-container" v-else>
<view v-if="products.length > 0" class="products-grid">
<view class="product-item" v-for="p in products" :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}}</view>
</view>
<view class="product-info">
<text class="product-title">{{ p.title }}</text>
<view class="product-bottom">
<view class="price-row">
<text class="price-symbol"></text>
<text class="price-val">{{ p.price }}</text>
</view>
<view class="points-badge" v-if="p.points">
<text class="points-val">{{ p.points }}</text>
<text class="points-unit">积分</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view v-else class="empty-state">
<image class="empty-img" src="/static/empty.png" mode="widthFix" />
<text class="empty-text">暂无相关商品</text>
</view>
</view>
</view>
</template>
<script setup>
import { onShow } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { request, authRequest } from '../../utils/request.js'
const products = ref([])
const CACHE_KEY = 'products_cache_v1'
const TTL_MS = 10 * 60 * 1000
const loading = ref(false)
const keyword = ref('')
const minPrice = ref('')
const maxPrice = ref('')
const showNotice = ref(false)
const hideForever = ref(false)
const skipReloadOnce = ref(false)
function apiGet(url, data = {}) {
const token = uni.getStorageSync('token')
const fn = token ? authRequest : request
return fn({ url, method: 'GET', data })
}
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 normalizeProducts(list) {
if (!Array.isArray(list)) return []
return list.map((i, idx) => ({
id: i.id ?? i.productId ?? i._id ?? i.sku_id ?? String(idx),
image: cleanUrl(i.main_image ?? i.imageUrl ?? i.image_url ?? i.image ?? i.img ?? i.pic ?? ''),
title: i.title ?? i.name ?? i.product_name ?? i.sku_name ?? '',
price: (() => { const raw = i.price_sale ?? i.price ?? i.price_min ?? i.amount ?? null; return raw == null ? null : (Number(raw) / 100) })(),
points: i.points_required ?? i.points ?? i.integral ?? null,
stock: i.stock ?? i.inventory ?? i.quantity ?? null,
link: cleanUrl(i.linkUrl ?? i.link_url ?? i.link ?? i.url ?? '')
})).filter(i => i.image || i.title)
}
function onProductTap(p) {
const id = p && p.id
if (id !== undefined && id !== null && id !== '') {
uni.navigateTo({ url: `/pages/shop/detail?id=${id}` })
return
}
if (p.link && /^\/.+/.test(p.link)) {
uni.navigateTo({ url: p.link })
}
}
// Filter logic
const allProducts = ref([]) // Store all fetched products for client-side filtering
function applyFilters() {
const k = String(keyword.value || '').trim().toLowerCase()
const min = Number(minPrice.value)
const max = Number(maxPrice.value)
const hasMin = !isNaN(min) && String(minPrice.value).trim() !== ''
const hasMax = !isNaN(max) && String(maxPrice.value).trim() !== ''
const list = allProducts.value
products.value = list.filter(p => {
const title = String(p.title || '').toLowerCase()
if (k && !title.includes(k)) return false
const priceNum = typeof p.price === 'number' ? p.price : Number(p.price)
if (hasMin) {
if (isNaN(priceNum)) return false
if (priceNum < min) return false
}
if (hasMax) {
if (isNaN(priceNum)) return false
if (priceNum > max) return false
}
return true
})
}
function onSearchConfirm() { applyFilters() }
function onApplyFilters() { applyFilters() }
async function loadProducts() {
try {
const cached = uni.getStorageSync(CACHE_KEY)
if (cached && cached.data && Date.now() - cached.ts < TTL_MS) {
allProducts.value = cached.data
applyFilters()
return
}
const first = await apiGet('/api/app/products', { page: 1 })
// Simple extraction
let list = []
let total = 0
if (first && first.list) { list = first.list; total = first.total }
else if (first && first.data && first.data.list) { list = first.data.list; total = first.data.total }
// If not too many, fetch all for better client UX
const pageSize = 20
const totalPages = Math.ceil((total || 0) / pageSize)
if (totalPages > 1) {
const tasks = []
for (let p = 2; p <= totalPages; p++) {
tasks.push(apiGet('/api/app/products', { page: p }))
}
const results = await Promise.allSettled(tasks)
results.forEach(r => {
if (r.status === 'fulfilled') {
const val = r.value
const subList = (val && val.list) || (val && val.data && val.data.list) || []
if (Array.isArray(subList)) list = list.concat(subList)
}
})
}
const normalized = normalizeProducts(list)
allProducts.value = normalized
applyFilters()
uni.setStorageSync(CACHE_KEY, { data: normalized, ts: Date.now() })
} catch (e) {
console.error(e)
products.value = []
}
}
onShow(async () => {
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return
}
// Notice logic
try {
const sess = String(uni.getStorageSync('app_session_id') || '')
const hiddenSess = String(uni.getStorageSync('shop_notice_hidden_session_id') || '')
const hiddenThisSession = !!(sess && hiddenSess && hiddenSess === sess)
showNotice.value = !hiddenThisSession
hideForever.value = hiddenThisSession
} catch (_) { showNotice.value = true }
loading.value = true
await loadProducts()
loading.value = false
})
function toggleHideForever() { hideForever.value = !hideForever.value }
function onDismissNotice() {
if (hideForever.value) {
try {
const sess = String(uni.getStorageSync('app_session_id') || '')
if (sess) uni.setStorageSync('shop_notice_hidden_session_id', sess)
} catch (_) {}
}
showNotice.value = false
}
</script>
<style scoped>
.page {
min-height: 100vh;
background-color: #F5F6F8;
padding-bottom: 40rpx;
}
/* 顶部 Header */
.header-section {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: #FFFFFF;
padding: 0 24rpx 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.04);
}
.header-placeholder {
height: 160rpx; /* 根据 header 高度调整 */
}
.page-title {
font-size: 36rpx;
font-weight: 800;
color: #111;
padding: 20rpx 0;
}
/* 搜索框 */
.search-box {
margin-bottom: 20rpx;
}
.search-input-wrap {
display: flex;
align-items: center;
background: #F5F7FA;
border-radius: 16rpx;
padding: 18rpx 24rpx;
transition: all 0.3s;
}
.search-input-wrap:focus-within {
background: #FFF;
box-shadow: 0 0 0 2rpx #FF9F43;
}
.search-icon {
font-size: 28rpx;
margin-right: 16rpx;
opacity: 0.5;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
.placeholder-style {
color: #999;
}
/* 筛选行 */
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.price-range {
flex: 1;
display: flex;
align-items: center;
background: #F5F7FA;
border-radius: 12rpx;
padding: 10rpx 20rpx;
}
.price-label {
font-size: 24rpx;
color: #666;
margin-right: 16rpx;
}
.price-input {
flex: 1;
font-size: 26rpx;
text-align: center;
color: #333;
}
.price-ph {
color: #BBB;
font-size: 24rpx;
}
.price-sep {
color: #CCC;
margin: 0 10rpx;
}
.filter-btn {
background: linear-gradient(135deg, #FF9F43, #FF6B35);
color: white;
font-size: 26rpx;
font-weight: 600;
border-radius: 12rpx;
padding: 0 32rpx;
height: 64rpx;
line-height: 64rpx;
border: none;
}
.btn-hover {
opacity: 0.9;
transform: scale(0.98);
}
/* 商品 Grid 容器 */
.products-container {
padding: 24rpx;
}
.products-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
/* 商品卡片 */
.product-card {
background: #FFFFFF;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
}
.thumb-wrap {
position: relative;
width: 100%;
padding-top: 100%; /* 1:1 Aspect Ratio */
background: #F9F9F9;
}
.product-thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.stock-tag {
position: absolute;
bottom: 0;
right: 0;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-top-left-radius: 12rpx;
}
.product-info {
padding: 20rpx;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-title {
font-size: 28rpx;
color: #333;
line-height: 1.4;
height: 2.8em; /* 2 lines */
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 16rpx;
}
.product-bottom {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 8rpx;
}
.price-row {
color: #FF5500;
font-weight: 700;
display: flex;
align-items: baseline;
}
.price-symbol {
font-size: 24rpx;
}
.price-val {
font-size: 34rpx;
}
.points-badge {
background: #FFF0E6;
color: #FF6B35;
border: 1px solid rgba(255, 107, 53, 0.2);
border-radius: 8rpx;
padding: 2rpx 10rpx;
display: flex;
align-items: center;
gap: 4rpx;
}
.points-val {
font-size: 24rpx;
font-weight: 700;
}
.points-unit {
font-size: 20rpx;
}
/* Loading & Empty */
.loading-wrap {
padding: 100rpx 0;
display: flex;
justify-content: center;
}
.spinner {
width: 50rpx;
height: 50rpx;
border: 4rpx solid #ddd;
border-top-color: #FF9F43;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 100rpx;
}
.empty-img {
width: 240rpx;
margin-bottom: 24rpx;
opacity: 0.6;
}
.empty-text {
color: #999;
font-size: 28rpx;
}
/* 弹窗样式 */
.notice-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.notice-dialog {
width: 560rpx;
background: #FFF;
border-radius: 24rpx;
padding: 40rpx 32rpx;
text-align: center;
}
.notice-title {
font-size: 34rpx;
font-weight: 700;
margin-bottom: 24rpx;
color: #333;
}
.notice-content {
font-size: 28rpx;
color: #555;
line-height: 1.6;
margin-bottom: 40rpx;
text-align: left;
}
.notice-actions {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.notice-btn {
width: 100%;
height: 80rpx;
line-height: 80rpx;
background: linear-gradient(90deg, #FF9F43, #FF6B35);
color: #fff;
border-radius: 40rpx;
font-size: 30rpx;
font-weight: 600;
}
.notice-check {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
opacity: 0.8;
}
.check-box {
width: 32rpx;
height: 32rpx;
border: 2rpx solid #CCC;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.check-box.on {
background: #FF6B35;
border-color: #FF6B35;
}
.icon-check {
font-size: 20rpx;
color: #FFF;
}
.check-text {
font-size: 26rpx;
color: #888;
}
</style>