290 lines
12 KiB
Vue
290 lines
12 KiB
Vue
<template>
|
|
<view class="page">
|
|
<view v-if="loading" class="loading-wrap"><view class="spinner"></view></view>
|
|
<view class="products-section" v-else>
|
|
<view class="section-title">商品</view>
|
|
<view class="toolbar">
|
|
<input class="search" v-model="keyword" placeholder="搜索商品" confirm-type="search" @confirm="onSearchConfirm" />
|
|
<view class="filters">
|
|
<input class="price" type="number" v-model="minPrice" placeholder="最低价" />
|
|
<text class="dash">-</text>
|
|
<input class="price" type="number" v-model="maxPrice" placeholder="最高价" />
|
|
<button class="apply-btn" size="mini" @tap="onApplyFilters">筛选</button>
|
|
</view>
|
|
</view>
|
|
<view v-if="displayCount" class="products-columns">
|
|
<view class="column" v-for="(col, ci) in columns" :key="ci">
|
|
<view class="product-item" v-for="p in col" :key="p.id" @tap="onProductTap(p)">
|
|
<view class="product-card">
|
|
<view class="thumb-wrap">
|
|
<image class="product-thumb" :class="{ visible: isLoaded(p) }" :src="p.image" mode="widthFix" lazy-load="true" @load="onImageLoad(p)" @error="onImageError(p)" />
|
|
<view v-if="!isLoaded(p)" class="skeleton"></view>
|
|
<view class="badge">
|
|
<text class="badge-price" v-if="p.price !== null">¥{{ p.price }}</text>
|
|
<text class="badge-points" v-if="p.points !== null">{{ p.points }}积分</text>
|
|
</view>
|
|
</view>
|
|
<text class="product-title">{{ p.title }}</text>
|
|
<view class="product-extra" v-if="p.stock !== null">
|
|
<text class="stock">库存 {{ p.stock }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view v-else class="empty">暂无商品</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 columns = ref([[], []])
|
|
const colHeights = ref([0, 0])
|
|
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 displayCount = computed(() => (columns.value[0].length + columns.value[1].length))
|
|
const loadedMap = ref({})
|
|
function getKey(p) { return String((p && p.id) ?? '') + '|' + String((p && p.image) ?? '') }
|
|
function unwrap(list) {
|
|
if (Array.isArray(list)) return list
|
|
const obj = list || {}
|
|
const data = obj.data || {}
|
|
const arr = obj.list || obj.items || data.list || data.items || data
|
|
return Array.isArray(arr) ? arr : []
|
|
}
|
|
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 apiGet(url, data = {}) {
|
|
const token = uni.getStorageSync('token')
|
|
const fn = token ? authRequest : request
|
|
return fn({ url, method: 'GET', data })
|
|
}
|
|
|
|
function getCachedProducts() {
|
|
try {
|
|
const obj = uni.getStorageSync(CACHE_KEY)
|
|
if (obj && Array.isArray(obj.data) && typeof obj.ts === 'number') {
|
|
const fresh = Date.now() - obj.ts < TTL_MS
|
|
if (fresh) return obj.data
|
|
}
|
|
} catch (_) {}
|
|
return null
|
|
}
|
|
|
|
function setCachedProducts(list) {
|
|
try {
|
|
uni.setStorageSync(CACHE_KEY, { data: Array.isArray(list) ? list : [], ts: Date.now() })
|
|
} catch (_) {}
|
|
}
|
|
|
|
function estimateHeight(p) {
|
|
const base = 220
|
|
const len = String(p.title || '').length
|
|
const lines = Math.min(2, Math.ceil(len / 12))
|
|
const titleH = lines * 36
|
|
const stockH = p.stock !== null && p.stock !== undefined ? 34 : 0
|
|
const padding = 28
|
|
return base + titleH + stockH + padding
|
|
}
|
|
|
|
function distributeToColumns(list) {
|
|
const arr = Array.isArray(list) ? list : []
|
|
const cols = Array.from({ length: 2 }, () => [])
|
|
const hs = [0, 0]
|
|
for (let i = 0; i < arr.length; i++) {
|
|
const h = estimateHeight(arr[i])
|
|
const idx = hs[0] <= hs[1] ? 0 : 1
|
|
cols[idx].push(arr[i])
|
|
hs[idx] += h
|
|
}
|
|
columns.value = cols
|
|
colHeights.value = hs
|
|
const presentKeys = new Set(arr.map(getKey))
|
|
const next = {}
|
|
const prev = loadedMap.value || {}
|
|
for (const k in prev) { if (presentKeys.has(k)) next[k] = prev[k] }
|
|
loadedMap.value = next
|
|
}
|
|
|
|
function extractListAndTotal(payload) {
|
|
if (Array.isArray(payload)) return { list: payload, total: payload.length }
|
|
const obj = payload || {}
|
|
const data = obj.data || {}
|
|
const list = obj.list || obj.items || data.list || data.items || []
|
|
const totalRaw = obj.total ?? data.total
|
|
const total = typeof totalRaw === 'number' ? totalRaw : (Array.isArray(list) ? list.length : 0)
|
|
return { list: Array.isArray(list) ? list : [], total }
|
|
}
|
|
|
|
function normalizeProducts(list) {
|
|
const arr = unwrap(list)
|
|
return arr.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: i.price_sale ?? i.price ?? i.price_min ?? i.amount ?? null,
|
|
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 imgs = (Array.isArray(products.value) ? products.value : []).map(x => x.image).filter(Boolean)
|
|
const current = p && p.image
|
|
if (current) {
|
|
uni.previewImage({ urls: imgs.length ? imgs : [current], current })
|
|
return
|
|
}
|
|
if (p.link && /^\/.+/.test(p.link)) {
|
|
uni.navigateTo({ url: p.link })
|
|
}
|
|
}
|
|
|
|
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 = Array.isArray(products.value) ? products.value : []
|
|
const filtered = 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
|
|
})
|
|
distributeToColumns(filtered)
|
|
}
|
|
|
|
function onSearchConfirm() { applyFilters() }
|
|
function onApplyFilters() { applyFilters() }
|
|
|
|
async function loadProducts() {
|
|
try {
|
|
const cached = getCachedProducts()
|
|
if (cached) {
|
|
products.value = cached
|
|
distributeToColumns(cached)
|
|
return
|
|
}
|
|
const first = await apiGet('/api/app/products', { page: 1 })
|
|
const { list: firstList, total } = extractListAndTotal(first)
|
|
const pageSize = 20
|
|
const totalPages = Math.max(1, Math.ceil(((typeof total === 'number' ? total : 0)) / pageSize))
|
|
if (totalPages <= 1) {
|
|
const normalized = normalizeProducts(firstList)
|
|
products.value = normalized
|
|
distributeToColumns(normalized)
|
|
setCachedProducts(normalized)
|
|
return
|
|
}
|
|
const tasks = []
|
|
for (let p = 2; p <= totalPages; p++) {
|
|
tasks.push(apiGet('/api/app/products', { page: p }))
|
|
}
|
|
const results = await Promise.allSettled(tasks)
|
|
const restLists = results.map(r => {
|
|
if (r.status === 'fulfilled') {
|
|
const { list } = extractListAndTotal(r.value)
|
|
return Array.isArray(list) ? list : []
|
|
}
|
|
return []
|
|
})
|
|
const merged = [firstList, ...restLists].flat()
|
|
const normalized = normalizeProducts(merged)
|
|
products.value = normalized
|
|
distributeToColumns(normalized)
|
|
setCachedProducts(normalized)
|
|
} catch (e) {
|
|
products.value = []
|
|
columns.value = [[], []]
|
|
colHeights.value = [0, 0]
|
|
const presentKeys = new Set([])
|
|
const next = {}
|
|
const prev = loadedMap.value || {}
|
|
for (const k in prev) { if (presentKeys.has(k)) next[k] = prev[k] }
|
|
loadedMap.value = next
|
|
}
|
|
}
|
|
|
|
function isLoaded(p) { return !!(loadedMap.value && loadedMap.value[getKey(p)]) }
|
|
function onImageLoad(p) { const k = getKey(p); if (!k) return; loadedMap.value = { ...(loadedMap.value || {}), [k]: true } }
|
|
function onImageError(p) { const k = getKey(p); if (!k) return; const prev = { ...(loadedMap.value || {}) }; delete prev[k]; loadedMap.value = prev }
|
|
|
|
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
|
|
}
|
|
loading.value = true
|
|
await loadProducts()
|
|
loading.value = false
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page { padding: 24rpx }
|
|
.section-title { font-size: 30rpx; font-weight: 600; margin-bottom: 16rpx }
|
|
.products-section { background: #ffffff; border-radius: 12rpx; padding: 24rpx; margin-top: 24rpx }
|
|
.loading-wrap { min-height: 60vh; display: flex; align-items: center; justify-content: center }
|
|
.spinner { width: 56rpx; height: 56rpx; border: 6rpx solid rgba(0,122,255,0.15); border-top-color: #007AFF; border-radius: 50%; animation: spin 1s linear infinite }
|
|
@keyframes spin { from { transform: rotate(0) } to { transform: rotate(360deg) } }
|
|
.toolbar { display: flex; flex-direction: column; gap: 12rpx; margin-bottom: 16rpx }
|
|
.search { background: #f6f8ff; border: 1rpx solid rgba(0,122,255,0.25); border-radius: 999rpx; padding: 14rpx 20rpx; font-size: 26rpx }
|
|
.filters { display: flex; align-items: center; gap: 12rpx }
|
|
.price { flex: 1; background: #f6f8ff; border: 1rpx solid rgba(0,122,255,0.25); border-radius: 999rpx; padding: 12rpx 16rpx; font-size: 26rpx }
|
|
.dash { color: #888; font-size: 26rpx }
|
|
.apply-btn { background: #007AFF; color: #fff; border-radius: 999rpx; padding: 0 20rpx }
|
|
.products-columns { display: flex; gap: 12rpx }
|
|
.column { flex: 1 }
|
|
.product-item { margin-bottom: 12rpx }
|
|
.empty { padding: 40rpx; color: #888; text-align: center }
|
|
.product-card { background: #fff; border-radius: 16rpx; overflow: hidden; box-shadow: 0 6rpx 16rpx rgba(0,122,255,0.08); transition: transform .15s ease }
|
|
.product-item:active .product-card { transform: scale(0.98) }
|
|
.thumb-wrap { position: relative }
|
|
.product-thumb { width: 100%; height: auto; display: block; opacity: 0; transition: opacity .25s ease; z-index: 0 }
|
|
.product-thumb.visible { opacity: 1 }
|
|
.skeleton { position: absolute; left: 0; top: 0; right: 0; bottom: 0; background: linear-gradient(90deg, #eef2ff 25%, #f6f8ff 37%, #eef2ff 63%); background-size: 400% 100%; animation: shimmer 1.2s ease infinite; z-index: 1 }
|
|
@keyframes shimmer { 0% { background-position: 100% 0 } 100% { background-position: 0 0 } }
|
|
.thumb-wrap { background: #f6f8ff; min-height: 220rpx }
|
|
.badge { position: absolute; left: 12rpx; bottom: 12rpx; display: flex; gap: 8rpx }
|
|
.badge-price { background: #007AFF; color: #fff; font-size: 22rpx; padding: 6rpx 12rpx; border-radius: 999rpx; box-shadow: 0 2rpx 8rpx rgba(0,122,255,0.25) }
|
|
.badge-points { background: rgba(0,122,255,0.85); color: #fff; font-size: 22rpx; padding: 6rpx 12rpx; border-radius: 999rpx }
|
|
.product-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin: 12rpx; font-size: 26rpx; color: #222 }
|
|
.product-extra { display: flex; justify-content: flex-end; align-items: center; margin: 0 12rpx 12rpx }
|
|
.stock { font-size: 22rpx; color: #888 }
|
|
</style>
|