564 lines
14 KiB
Vue
564 lines
14 KiB
Vue
<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>
|