bindbox-mini/pages/shop/index.vue
邹方成 6f7207da2d feat: 优化UI设计并重构样式系统
refactor(components): 重构ElCard、FlipGrid、YifanSelector和PaymentPopup组件样式
refactor(pages): 优化地址管理、商品详情、订单列表、积分记录和活动页面UI
style: 更新uni.scss全局样式变量和设计系统
docs: 添加说明文档记录UI优化进度
2025-12-17 14:32:55 +08:00

553 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 class="bg-decoration"></view>
<!-- 顶部固定区域 -->
<view class="header-section">
<view class="search-box">
<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('')
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
}
loading.value = true
await loadProducts()
loading.value = false
})
// 分享功能
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
onShareAppMessage(() => {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return {
title: '奇盒潮玩商城 - 好物等你来兑',
path: `/pages/index/index?invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
}
})
onShareTimeline(() => {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return {
title: '奇盒潮玩商城 - 好物等你来兑',
query: `invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background-color: $bg-page;
padding-bottom: 40rpx;
position: relative;
overflow-x: hidden;
}
.bg-decoration {
position: fixed;
top: 0; left: 0; width: 100%; height: 100vh;
pointer-events: none;
z-index: 0;
&::before {
content: '';
position: absolute;
top: -100rpx; left: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.1) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
bottom: 10%; right: -100rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.5;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
/* 顶部 Header */
.header-section {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: $bg-glass;
backdrop-filter: blur(20rpx);
padding: 0 24rpx 24rpx;
box-shadow: $shadow-sm;
border-bottom: 1rpx solid $border-color-light;
}
.header-placeholder {
height: 160rpx; /* 根据 header 高度调整 */
}
.page-title {
font-size: 36rpx;
font-weight: 800;
color: $text-main;
padding: 20rpx 0;
}
/* 搜索框 */
.search-box {
margin-bottom: 20rpx;
margin-top: 20rpx;
}
.search-input-wrap {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: $radius-round;
padding: 18rpx 24rpx;
transition: all 0.3s;
}
.search-input-wrap:focus-within {
background: $bg-card;
border-color: $brand-primary;
box-shadow: 0 0 0 4rpx rgba($brand-primary, 0.1);
}
.search-icon {
font-size: 28rpx;
margin-right: 16rpx;
opacity: 0.5;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: $text-main;
}
.placeholder-style {
color: $text-tertiary;
}
/* 筛选行 */
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.price-range {
flex: 1;
display: flex;
align-items: center;
background: $bg-secondary;
border-radius: $radius-md;
padding: 10rpx 20rpx;
}
.price-label {
font-size: 24rpx;
color: $text-sub;
margin-right: 16rpx;
}
.price-input {
flex: 1;
font-size: 26rpx;
text-align: center;
color: $text-main;
}
.price-ph {
color: $text-tertiary;
font-size: 24rpx;
}
.price-sep {
color: $text-tertiary;
margin: 0 10rpx;
}
.filter-btn {
background: $gradient-brand;
color: $text-inverse;
font-size: 26rpx;
font-weight: 600;
border-radius: $radius-md;
padding: 0 32rpx;
height: 64rpx;
line-height: 64rpx;
border: none;
box-shadow: $shadow-sm;
}
.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-item {
animation: fadeInUp 0.5s ease-out backwards;
}
@for $i from 1 through 10 {
.product-item:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
}
}
.product-card {
background: $bg-card;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
}
.product-item:active .product-card {
transform: scale(0.98);
box-shadow: $shadow-sm;
}
.thumb-wrap {
position: relative;
width: 100%;
padding-top: 100%; /* 1:1 Aspect Ratio */
background: $bg-secondary;
}
.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($accent-red, 0.9);
color: #fff;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-top-left-radius: 12rpx;
backdrop-filter: blur(4px);
font-weight: 700;
box-shadow: 0 -2rpx 8rpx rgba(0,0,0,0.1);
}
.product-info {
padding: 20rpx;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-title {
font-size: 28rpx;
color: $text-main;
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;
font-weight: 600;
}
.product-bottom {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 8rpx;
}
.price-row {
color: $accent-red;
font-weight: 700;
display: flex;
align-items: baseline;
}
.price-symbol {
font-size: 24rpx;
}
.price-val {
font-size: 34rpx;
}
.points-badge {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
border: 1px solid rgba($brand-primary, 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 rgba($brand-primary, 0.2);
border-top-color: $brand-primary;
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: $text-tertiary;
font-size: 28rpx;
}
.check-text {
font-size: 26rpx;
color: $text-sub;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>