Files
integral-shop/single_uniapp22miao/pages/integral/index.vue
apple 079076a70e miao33: 从 main 同步 single_uniapp22miao,dart-sass 兼容修复,DEPLOY.md 更新
- 从 main 获取 single_uniapp22miao 子项目
- dart-sass: /deep/ -> ::v-deep,calc 运算符加空格
- DEPLOY.md 采用 shccd159 版本(4 子项目架构说明)

Made-with: Cursor
2026-03-16 11:16:42 +08:00

1060 lines
26 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="integral-mall">
<!-- 顶部橙色头部 -->
<view class="header-section">
<view class="header-top">
<view class="nav-back" @click="goBack">
<text class="iconfont icon-fanhui"></text>
</view>
<view class="title-section">
<view class="icon-box">
<image class="gift-icon" src="/static/svg/Icon-1.svg" mode="aspectFit"></image>
</view>
<view class="title-content">
<text class="main-title">积分商城</text>
<text class="sub-title">兑换心仪好物</text>
</view>
</view>
<view class="points-badge" @click="goToPoints">
<text class="badge-label">我的积分</text>
<view class="badge-content">
<image class="user-icon" src="/static/svg/Icon-3.svg" mode="aspectFit"></image>
<text class="badge-value">{{ formatPoints(userPoints) }}</text>
</view>
</view>
</view>
<!-- 订单中心入口 -->
<!-- <view class="order-center-btn" @click="goToOrders">
<image class="btn-icon" src="/static/svg/Icon-2.svg" mode="aspectFit"></image>
<text class="btn-text">订单中心</text>
<text class="btn-arrow"></text>
</view> -->
</view>
<!-- Banner轮播 -->
<view class="banner-section" v-if="banners.length">
<swiper
class="banner-swiper"
:indicator-dots="true"
:autoplay="true"
:interval="3000"
:circular="true"
indicator-color="rgba(255,255,255,0.5)"
indicator-active-color="#FF4D4F"
>
<swiper-item v-for="(banner, index) in banners" :key="index">
<image
:src="banner.image"
class="banner-image"
mode="aspectFill"
@click="onBannerClick(banner)"
/>
</swiper-item>
</swiper>
</view>
<!-- 分类导航 -->
<scroll-view
class="category-scroll"
scroll-x
:scroll-into-view="'category-' + activeCategory"
show-scrollbar="false"
>
<view
v-for="(category, index) in categories"
:key="category.id"
:id="'category-' + category.id"
:class="['category-item', { active: activeCategory === category.id }]"
@click="onCategoryChange(category.id)"
>
<text class="category-text">{{ category.cate_name }}</text>
</view>
</scroll-view>
<!-- 商品列表 -->
<view class="goods-list">
<!-- 空状态 -->
<view v-if="!loading && goodsList.length === 0" class="empty-state">
<image src="/static/images/empty.png" class="empty-image" mode="aspectFit" />
<text class="empty-text">暂无商品</text>
</view>
<!-- 商品列表 -->
<view v-else class="goods-container">
<view
v-for="goods in goodsList"
:key="goods.id"
class="goods-card"
@click="goToDetail(goods.id)"
>
<!-- 商品图片区域 -->
<view class="goods-image-wrapper" @click="goToDetail(goods.id)">
<image
v-if="goods.image"
:src="goods.image"
class="goods-image"
mode="aspectFill"
/>
<view v-else class="goods-image-placeholder">
<text class="placeholder-text">暂无图片</text>
</view>
<!-- 限量标签 -->
<view v-if="goods.is_limited && goods.stock > 0" class="limited-badge">
<text class="badge-text">限量</text>
</view>
<!-- 已兑完遮罩 -->
<view v-if="goods.stock === 0" class="soldout-overlay">
<view class="soldout-badge">
<text class="soldout-text">已兑完</text>
</view>
</view>
</view>
<!-- 商品信息区域 -->
<view class="goods-content">
<text class="goods-title" @click="goToDetail(goods.id)">{{ goods.name }}</text>
<text class="goods-desc">{{ goods.description || '暂无描述' }}</text>
<view class="goods-info-bottom">
<view class="info-left">
<view class="price-row">
<text class="price-number">{{ formatPoints(goods.points) }}</text>
<text class="price-unit">积分</text>
</view>
<text class="stock-text">库存: {{ goods.stock }}</text>
</view>
<view class="info-right">
<button
class="btn-exchange"
:class="{ 'disabled': goods.stock === 0 }"
@click.stop="handleExchange(goods)"
>
{{ goods.stock === 0 ? '已兑完' : '立即兑换' }}
</button>
<text class="sales-text">已兑: {{ goods.sales || 0 }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loading" class="loading-more">
<text>加载中...</text>
</view>
<view v-else-if="noMore && goodsList.length > 0" class="no-more">
<text>没有更多了</text>
</view>
<!-- 底部导航栏 -->
<view class="bottom-tabbar safe-area-inset-bottom">
<view class="tab-item active">
<image class="tab-icon" src="/static/tabBar/home-a.png" mode="aspectFit"></image>
<text class="tab-text">积分兑换</text>
</view>
<view class="tab-item" @click="goToCart">
<view class="tab-icon-wrap">
<!-- <view class="tab-icon icon-cart"></view> -->
<image class="tab-icon" src="/static/tabBar/cart.png" mode="aspectFit"></image>
<view class="cart-badge" v-if="cartCount > 0">
<text>{{ cartCount > 99 ? '99+' : cartCount }}</text>
</view>
</view>
<text class="tab-text">购物车</text>
</view>
<view class="tab-item" @click="goToOrders">
<!-- <view class="tab-icon icon-order"></view> -->
<image class="tab-icon" src="/static/tabBar/order.png" mode="aspectFit"></image>
<text class="tab-text">订单</text>
</view>
</view>
</view>
</template>
<script>
import { getProductslist, getCategoryList } from '@/api/store.js'
import { getIntegralUserByAccount } from '@/api/user.js'
import { loginV2, getUserInfo } from '@/api/user.js'
import { getCartCounts } from '@/api/order.js'
import { mapGetters } from "vuex"
export default {
computed: {
...mapGetters(['isLogin','userInfo']),
// 获取用户账号
userAccount() {
if (this.userInfo) {
return this.userInfo.account || this.userInfo.phone || this.userInfo.phoneNumber || ''
}
return ''
}
},
data() {
return {
account: '', // 从URL获取的用户名
password: '123456',
userPoints: "0.00",
cartCount: 0,
banners: [],
categories: [
{ id: 0, cate_name: '全部' }
],
activeCategory: 0,
goodsList: [],
page: 1,
limit: 20,
loading: false,
noMore: false
}
},
async onLoad(options) {
console.log('onLoad options:', options)
// 获取username优先从options获取否则从window.location获取兼容iframe嵌入场景
let username = (options && options.username) ? options.username : null
// 如果options中没有username尝试从URL中获取兼容H5 iframe嵌入
if (!username) {
username = this.getUrlParam('username')
console.log('从URL获取username:', username)
}
// URL中有username参数时必须调用autoLogin
if (username) {
console.log('获取到username:', username)
this.account = username
// 强制调用autoLogin无论当前是否已登录都要重新登录刷新用户信息
await this.autoLogin()
console.log('autoLogin执行完成isLogin:', this.isLogin)
} else {
// URL没有username时尝试从已登录的用户信息获取账号
if (this.isLogin && this.userInfo && this.userInfo.account) {
this.account = this.userInfo.account
}
// 未登录且有账号则自动登录
if (!this.isLogin && this.account) {
await this.autoLogin()
}
}
this.init()
},
onShow() {
// 如果已登录但account为空从userInfo同步
if (this.isLogin && this.userInfo && this.userInfo.account && !this.account) {
this.account = this.userInfo.account
}
// 刷新积分余额和购物车数量(需要登录后才能调用)
if (this.isLogin) {
this.getUserPoints()
this.getCartNum()
}
},
onPullDownRefresh() {
this.refreshData()
},
onReachBottom() {
this.loadMore()
},
methods: {
// 从URL中获取参数兼容多种URL格式
getUrlParam(name) {
// #ifdef H5
try {
// 尝试从当前页面URL获取
let url = window.location.href
// 如果在iframe中尝试获取父页面URL
try {
if (window.parent && window.parent !== window) {
url = window.parent.location.href
}
} catch (e) {
// 跨域情况下无法访问parent.location
console.log('无法访问父页面URL')
}
console.log('解析URL:', url)
// 解析URL参数
const urlObj = new URL(url)
let value = urlObj.searchParams.get(name)
// 如果没找到尝试从hash中获取兼容hash路由
if (!value && url.includes('#')) {
const hashPart = url.split('#')[1]
if (hashPart && hashPart.includes('?')) {
const hashParams = new URLSearchParams(hashPart.split('?')[1])
value = hashParams.get(name)
}
}
return value
} catch (e) {
console.error('解析URL参数失败:', e)
return null
}
// #endif
return null
},
// 自动登录
async autoLogin() {
console.log('autoLogin开始执行account:', this.account)
// 检查账号是否存在
if (!this.account) {
console.warn('autoLogin: 账号为空,跳过登录')
return
}
try {
console.log('正在调用loginV2接口account:', this.account)
const res = await loginV2({
account: this.account,
password: this.password,
spread_spid: this.$Cache.get("spread") || ''
})
console.log("loginV2返回结果:", res)
if (res.data && res.data.token) {
// 保存token到store
this.$store.commit("LOGIN", {
token: res.data.token
})
console.log('token已保存')
// 保存UID
if (res.data.uid) {
this.$store.commit("SETUID", res.data.uid)
console.log('uid已保存:', res.data.uid)
}
// 获取并保存用户信息
console.log('正在获取用户信息...')
const userInfoRes = await getUserInfo()
console.log('getUserInfo返回:', userInfoRes)
if (userInfoRes.data) {
// 更新vuex storestore内部会自动保存到Cache
this.$store.commit("UPDATE_USERINFO", userInfoRes.data)
console.log('用户信息已更新到store:', userInfoRes.data)
// 同步account到本地变量确保后续API调用可用
if (userInfoRes.data.account) {
this.account = userInfoRes.data.account
} else if (userInfoRes.data.phone) {
this.account = userInfoRes.data.phone
}
}
console.log('自动登录成功完成')
} else {
console.warn('loginV2返回数据异常无token:', res)
}
} catch (error) {
console.error('自动登录失败:', error)
uni.showToast({
title: '自动登录失败',
icon: 'none',
duration: 2000
})
} finally {
uni.hideLoading()
}
},
// 格式化积分数字(添加千分位逗号)
formatPoints(points) {
if (!points && points !== 0) return '0'
return Number(points).toFixed(2).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
},
async init() {
await Promise.all([
this.getUserPoints(),
this.getCartNum(),
this.getCategories(),
this.getGoodsList()
])
},
// 获取用户积分
async getUserPoints() {
// 优先使用登录后的userInfo.account否则使用URL传入的account
const accountToUse = (this.userInfo && this.userInfo.account) || this.userAccount || this.account
if (!accountToUse) {
// 未提供账号时静默返回,不影响其他功能
return
}
try {
const res = await getIntegralUserByAccount(accountToUse)
if (res.data) {
this.userPoints = res.data.integral || 0
}
} catch (error) {
console.error('获取积分失败:', error)
}
},
// 获取购物车数量
async getCartNum() {
// 需要登录才能获取购物车数量
if (!this.isLogin) {
return
}
try {
const res = await getCartCounts(true, 'total')
if (res.data) {
this.cartCount = res.data.count || 0
}
} catch (error) {
console.error('获取购物车数量失败:', error)
}
},
// 获取分类列表
async getCategories() {
try {
const res = await getCategoryList()
if (res.data) {
const list = res.data
// 映射字段cate_name -> name
const mappedList = list.map(item => ({
id: item.id,
cate_name: item.name
}))
this.categories = [{ id: 0, cate_name: '全部' }, ...mappedList]
}
} catch (error) {
console.error('获取分类失败:', error)
}
},
// 获取商品列表
async getGoodsList(isRefresh = false) {
if (this.loading) return
this.loading = true
if (isRefresh) {
this.page = 1
this.noMore = false
}
try {
// 使用 store.js 中的 getProductslist
// 参数: page, limit, cid (分类ID), keyword, etc.
const params = {
page: this.page,
limit: this.limit,
type: 1 // 假设1为普通商品根据实际情况调整
}
// 当不是"全部"分类时,传递 cid 参数
if (this.activeCategory !== 0) {
params.cid = this.activeCategory
}
const res = await getProductslist(params)
if (res.data && res.data.list) {
const list = res.data.list || []
// 数据映射:将 store 接口返回的字段映射为组件需要的字段
const mappedList = list.map(item => ({
id: item.id,
name: item.storeName, // storeName -> name
image: item.image,
points: Number(item.price), // price -> points
stock: item.stock,
sales: item.sales,
description: item.store_info || '', // store_info 可能不存在
is_limited: false // 接口暂无此字段默认false
}))
if (isRefresh) {
this.goodsList = mappedList
} else {
this.goodsList = [...this.goodsList, ...mappedList]
}
// 判断是否还有更多
if (list.length < this.limit) {
this.noMore = true
}
}
} catch (error) {
console.error('获取商品列表失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
this.loading = false
uni.stopPullDownRefresh()
}
},
// 刷新数据
refreshData() {
this.getGoodsList(true)
this.getUserPoints()
},
// 加载更多
loadMore() {
if (this.noMore || this.loading) return
this.page++
this.getGoodsList()
},
// 切换分类
onCategoryChange(categoryId) {
if (this.activeCategory === categoryId) return
this.activeCategory = categoryId
this.getGoodsList(true)
},
// Banner点击
onBannerClick(banner) {
if (banner.link_type === 'goods' && banner.goods_id) {
this.goToDetail(banner.goods_id)
}
},
// 跳转商品详情
goToDetail(goodsId) {
uni.navigateTo({
url: `/pages/integral/detail?id=${goodsId}`
})
},
// 返回上一页
goBack() {
uni.navigateBack()
},
// 跳转积分明细
goToPoints() {
uni.navigateTo({
url: '/pages/integral/points'
})
},
// 跳转积分规则
goToRules() {
uni.navigateTo({
url: '/pages/integral/rules'
})
},
// 跳转订单中心
goToOrders() {
uni.navigateTo({
url: '/pages/integral/orders'
})
},
// 跳转购物车
goToCart() {
uni.navigateTo({
url: '/pages/integral/cart',
fail: (err) => {
console.error('跳转购物车失败:', err);
uni.showToast({
title: '跳转失败',
icon: 'none'
});
}
});
},
// 立即兑换
handleExchange(goods) {
// 检查积分是否足够
if (this.userPoints < goods.points) {
uni.showToast({
title: '积分不足',
icon: 'none'
})
return
}
// 检查库存
if (goods.stock <= 0) {
uni.showToast({
title: '商品已售罄',
icon: 'none'
})
return
}
// 保存商品信息到缓存
const goodsInfo = {
id: goods.id,
name: goods.name,
storeName: goods.name,
image: goods.image,
pic: goods.image,
points: goods.points,
integral: goods.points,
quantity: 1,
attrValueId: '',
unique: '',
productAttrUnique: ''
}
uni.setStorageSync('buy_now_goods', goodsInfo)
// 跳转到积分商城下单页面
uni.navigateTo({
url: `/pages/integral/confirm?id=${goods.id}&quantity=1`
})
}
}
}
</script>
<style lang="scss" scoped>
.integral-mall {
min-height: 100vh;
background-color: #F8F8F8;
padding-bottom: 20rpx;
}
// 顶部橙色头部
.header-section {
background: linear-gradient(90deg, #FF6900 0%, #F54900 100%);
padding: 60rpx 30rpx 60rpx 1px;
position: relative;
overflow: hidden;
margin-bottom: 1rpx;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
}
.nav-back {
width: 72rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
.iconfont {
font-size: 36rpx;
color: #FFFFFF;
}
}
.title-section {
display: flex;
align-items: center;
gap: 20rpx;
flex: 1;
}
.icon-box {
width: 80rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.gift-icon {
width: 48rpx;
height: 48rpx;
}
.title-content {
display: flex;
flex-direction: column;
}
.main-title {
font-size: 40rpx;
color: #FFFFFF;
font-weight: 600;
line-height: 1.2;
}
.sub-title {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 4rpx;
}
.points-badge {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 30rpx;
padding: 16rpx 30rpx;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.badge-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 4rpx;
}
.badge-content {
display: flex;
align-items: center;
gap: 10rpx;
}
.user-icon {
width: 32rpx;
height: 32rpx;
}
.badge-value {
font-size: 36rpx;
font-weight: 600;
color: #FFFFFF;
}
// 分类导航
.category-scroll {
white-space: nowrap; font-size: 32rpx;
background-color: #FFFFFF;
height: 108rpx; padding: 10rpx 20rpx;
margin-bottom: 20rpx;
border-bottom: 1px solid #EEEEEE;
}
.category-item {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 30rpx;
height: 88rpx;
position: relative;
&.active {
.category-text {
color: #FF6900;
font-weight: 600;
position: relative;
&::after {
content: '';
position: absolute;
bottom: -10rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background-color: #FF6900;
border-radius: 2rpx;
}
}
}
}
.category-text {
font-size: 28rpx;
color: #333333;
}
// 商品列表
.goods-list {
padding: 0 20rpx 120rpx; // 增加底部padding防止被tabbar遮挡
}
.goods-card {
background-color: #FFFFFF;
border-radius: 20rpx;
overflow: hidden;
margin-bottom: 20rpx;
padding: 30rpx;
}
.goods-image-wrapper {
width: 100%;
height: 360rpx;
border-radius: 12rpx;
overflow: hidden;
position: relative;
margin-bottom: 20rpx;
background-color: #F8F8F8;
}
.goods-image {
width: 100%;
height: 100%;
}
.goods-content {
display: flex;
flex-direction: column;
}
.goods-title {
font-size: 32rpx;
color: #333333;
font-weight: 600;
margin-bottom: 10rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.goods-desc {
font-size: 24rpx;
color: #999999;
margin-bottom: 30rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.goods-info-bottom {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.info-left {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.price-row {
display: flex;
align-items: baseline;
}
.price-number {
font-size: 40rpx;
color: #FF6900;
font-weight: 600;
line-height: 1;
}
.price-unit {
font-size: 24rpx;
color: #999999;
margin-left: 4rpx;
}
.stock-text {
font-size: 24rpx;
color: #999999;
}
.info-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.btn-exchange {
background: linear-gradient(90deg, #FF6900 0%, #F54900 100%);
color: #FFFFFF;
font-size: 26rpx;
padding: 0 30rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
margin: 0;
&.disabled {
background: #CCCCCC;
opacity: 1;
}
&::after {
border: none;
}
}
.sales-text {
font-size: 22rpx; margin-right: 20rpx;
color: #999999;
}
// 底部导航
.bottom-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background-color: #FFFFFF;
display: flex;
border-top: 1px solid #EEEEEE;
z-index: 100;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
&.active {
.tab-text {
color: #FF6900;
}
.tab-icon {
// background-color: #FF6900; // Placeholder for active icon
}
}
}
.tab-icon-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.tab-icon {
width: 44rpx;
height: 44rpx;
// background-color: #CCCCCC; // Placeholder
}
.cart-badge {
position: absolute;
top: -8rpx;
right: -16rpx;
min-width: 32rpx;
height: 32rpx;
background-color: #FF4D4F;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
text {
font-size: 20rpx;
color: #FFFFFF;
font-weight: 500;
}
}
.tab-text {
font-size: 22rpx;
color: #999999;
}
// 其他样式保持不变...
.goods-image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #F5F5F5;
}
.placeholder-text {
font-size: 28rpx;
color: #CCCCCC;
}
.limited-badge {
position: absolute;
top: 16rpx;
right: 16rpx;
background-color: #FF6900;
border-radius: 8rpx;
padding: 4rpx 12rpx;
z-index: 2;
}
.badge-text {
font-size: 20rpx;
color: #FFFFFF;
}
.soldout-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.soldout-badge {
background-color: rgba(255, 255, 255, 0.9);
border-radius: 8rpx;
padding: 10rpx 30rpx;
}
.soldout-text {
font-size: 28rpx;
color: #333333;
font-weight: 600;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.empty-image {
width: 300rpx;
height: 300rpx;
}
.empty-text {
margin-top: 30rpx;
font-size: 28rpx;
color: #999999;
}
.loading-more,
.no-more {
text-align: center;
padding: 30rpx 0;
font-size: 26rpx;
color: #999999;
}
</style>