refactor(components): 重构ElCard、FlipGrid、YifanSelector和PaymentPopup组件样式 refactor(pages): 优化地址管理、商品详情、订单列表、积分记录和活动页面UI style: 更新uni.scss全局样式变量和设计系统 docs: 添加说明文档记录UI优化进度
553 lines
14 KiB
Vue
553 lines
14 KiB
Vue
<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>
|