很多页面

This commit is contained in:
haomingming
2025-09-03 17:13:10 +08:00
parent 92bfc36d51
commit 84f59b20f6
26 changed files with 29536 additions and 616 deletions
+99
View File
@@ -0,0 +1,99 @@
# 积分商城页面
## 页面结构
### 1. 积分商城首页 (`pointMall/pointMall.vue`)
- **状态栏**: 显示时间、网络状态、电池等信息
- **顶部导航栏**: 包含返回按钮、标题"积分商城"、搜索按钮
- **轮播图横幅**: 显示活动信息,包含渐变背景和文字覆盖
- **筛选排序栏**: 提供筛选和排序功能
- **商品网格**: 两列布局显示商品列表
- **底部导航栏**: 包含"我的兑换"和"购买积分"两个选项
### 2. 商品详情页 (`productDetail/productDetail.vue`)
- **状态栏**: 与首页相同的状态栏
- **顶部导航栏**: 包含返回按钮、标题"商品详情"、分享按钮
- **商品信息**: 显示商品图片、标题、价格和描述
- **兑换按钮**: 提供商品兑换功能
## 功能特性
### 积分商城首页
- ✅ 100%还原设计稿的UI布局
- ✅ 响应式设计,适配不同屏幕尺寸
- ✅ 轮播图自动播放
- ✅ 商品网格布局
- ✅ 点击商品跳转到详情页
- ✅ 筛选和排序功能(待实现具体逻辑)
- ✅ 底部导航栏交互
### 商品详情页
- ✅ 商品信息展示
- ✅ 兑换确认功能
- ✅ 返回导航
## 路由配置
页面已添加到 `pages.json` 中:
```json
{
"root": "pages_goods",
"pages": [
{
"path": "pointMall/pointMall",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "积分商城"
}
},
{
"path": "productDetail/productDetail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "商品详情"
}
}
]
}
```
## 使用方法
### 访问积分商城首页
```javascript
uni.navigateTo({
url: '/pages_goods/pointMall/pointMall'
})
```
### 访问商品详情页
```javascript
uni.navigateTo({
url: '/pages_goods/productDetail/productDetail?id=1'
})
```
## 样式特点
- 使用自定义状态栏和导航栏
- 渐变色背景的轮播图
- 卡片式商品布局
- 固定底部导航栏
- 响应式网格布局
- 统一的颜色主题(红色 #e74c3c,青色 #20b2aa)
## 待完善功能
1. 筛选和排序的具体实现
2. 商品数据的动态加载
3. 搜索功能的具体实现
4. 我的兑换页面
5. 购买积分页面
6. 商品图片的实际加载
## 注意事项
- 页面使用了自定义导航栏,确保在 `pages.json` 中设置了 `"navigationStyle": "custom"`
- 商品图片路径需要根据实际情况调整
- 可以根据需要添加更多的商品数据和功能
+230
View File
@@ -0,0 +1,230 @@
<template>
<view class="address-page">
<uni-nav-bar left-icon="left" title="收货地址" fixed color="#8B2316" height="140rpx" :border="false" backgroundColor="#ffffff" @clickLeft="goBack" />
<scroll-view scroll-y class="content">
<view class="form-item">
<view class="label">收件人</view>
<input class="input" v-model="receiver" placeholder="请输入收货人名字" />
</view>
<view class="divider-line"></view>
<view class="form-item">
<view class="label">手机号</view>
<input class="input" v-model="mobile" type="number" placeholder="收货人的电话,方便联系" />
</view>
<view class="divider-line"></view>
<view class="form-item">
<view class="label">邮箱</view>
<input class="input" v-model="email" type="text" placeholder="用于接收电子卡等信息(可选)" />
</view>
<view class="divider-line"></view>
<view class="form-item select-item" @click="openAreaPicker">
<view class="label">地址</view>
<view class="input placeholder" v-if="!regionText">请选择地址</view>
<view class="input" v-else>{{ regionText }}</view>
<text class="arrow">›</text>
</view>
<view class="divider-line"></view>
<view class="form-item">
<view class="label">详细地址</view>
<input class="input" v-model="detail" placeholder="请输入街道、门牌等详细地址信息" />
</view>
</scroll-view>
<view class="footer-bar">
<view class="confirm-btn" @click="submit">确定</view>
</view>
<!-- 省市区选择器 -->
<view v-if="showAreaPicker" class="picker-mask" @click="closeAreaPicker"></view>
<view v-if="showAreaPicker" class="picker-panel">
<view class="picker-header">
<text class="picker-btn" @click="closeAreaPicker">取消</text>
<text class="picker-title">选择地区</text>
<text class="picker-btn ok" @click="confirmArea">确定</text>
</view>
<picker-view v-if="provinces.length && cities.length && areas.length" class="picker-view" :indicator-style="indicatorStyle" :value="pickerIndex" @change="onAreaChange">
<picker-view-column>
<view v-for="(p,pi) in provinces" :key="pi" class="picker-item">{{ p.name }}</view>
</picker-view-column>
<picker-view-column>
<view v-for="(c,ci) in cities" :key="ci" class="picker-item">{{ c.name }}</view>
</picker-view-column>
<picker-view-column>
<view v-for="(a,ai) in areas" :key="ai" class="picker-item">{{ a.name }}</view>
</picker-view-column>
</picker-view>
<view v-else class="picker-empty">地区数据加载中...</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import areaList from '@/utils/areaList.js'
const receiver = ref('')
const mobile = ref('')
const regionText = ref('')
const detail = ref('')
const email = ref('')
const editingId = ref(null)
const goBack = () => uni.navigateBack()
// 省市区数据(适配树形数组:[{code,label,value,children:[...] }...])
const provinces = ref([])
const cities = ref([])
const areas = ref([])
const pickerIndex = ref([0,0,0])
const showAreaPicker = ref(false)
const indicatorStyle = `height: 80rpx;`
const normalizeNode = (node) => ({
code: node?.code || '',
name: node?.label || node?.value || node?.name || '',
children: Array.isArray(node?.children) ? node.children : []
})
const getAreaTree = () => {
const raw = areaList && (areaList.default || areaList)
return Array.isArray(raw) ? raw.map(normalizeNode) : []
}
const buildData = () => {
const tree = getAreaTree()
provinces.value = tree.length ? tree : [{ code: '', name: '', children: [] }]
const pIdx = Math.min(pickerIndex.value[0], Math.max(provinces.value.length - 1, 0))
const pNode = provinces.value[pIdx]
cities.value = (pNode?.children || []).map(normalizeNode)
if (!cities.value.length) cities.value = [{ code: '', name: '', children: [] }]
const cIdx = Math.min(pickerIndex.value[1], Math.max(cities.value.length - 1, 0))
const cNode = cities.value[cIdx]
areas.value = (cNode?.children || []).map(normalizeNode)
if (!areas.value.length) areas.value = [{ code: '', name: '' }]
}
const openAreaPicker = () => {
showAreaPicker.value = true
pickerIndex.value = [0,0,0]
buildData()
}
const closeAreaPicker = () => { showAreaPicker.value = false }
const onAreaChange = (e) => {
const val = (e && e.detail && e.detail.value) ? e.detail.value : [0,0,0]
const [pi, ci, ai] = val
if (pi !== pickerIndex.value[0]) {
pickerIndex.value = [pi, 0, 0]
buildData()
return
}
if (ci !== pickerIndex.value[1]) {
pickerIndex.value = [pi, ci, 0]
buildData()
return
}
pickerIndex.value = [pi, ci, ai]
}
const confirmArea = () => {
const p = provinces.value[pickerIndex.value[0]]
const c = cities.value[pickerIndex.value[1]]
const a = areas.value[pickerIndex.value[2]]
regionText.value = [p?.name, c?.name, a?.name].filter(Boolean).join(' ')
closeAreaPicker()
}
onLoad((opts) => {
if (opts && opts.id) {
// 回填编辑
editingId.value = Number(opts.id)
try {
const list = uni.getStorageSync('goods_addresses') || []
const target = Array.isArray(list) ? list.find(a => a.id === editingId.value) : null
if (target) {
receiver.value = target.receiver || ''
mobile.value = target.mobile || ''
regionText.value = target.region || ''
detail.value = target.detail || ''
email.value = target.email || ''
}
} catch (e) {}
}
})
const submit = () => {
if (!receiver.value) return uni.showToast({ title: '请输入收件人', icon: 'none' })
if (!/^1\d{10}$/.test(mobile.value)) return uni.showToast({ title: '请输入正确手机号', icon: 'none' })
if (!regionText.value) return uni.showToast({ title: '请选择地址', icon: 'none' })
if (!detail.value) return uni.showToast({ title: '请输入详细地址', icon: 'none' })
if (email.value && !/^([a-zA-Z0-9_\.-]+)@([a-zA-Z0-9\.-]+)\.([a-zA-Z]{2,})$/.test(email.value)) return uni.showToast({ title: '邮箱格式不正确', icon: 'none' })
const STORAGE_KEY = 'goods_addresses'
let list = []
try {
const cached = uni.getStorageSync(STORAGE_KEY)
list = Array.isArray(cached) ? cached : []
} catch (e) { list = [] }
if (editingId.value) {
// 更新
list = list.map(a => a.id === editingId.value ? {
...a,
receiver: receiver.value,
mobile: mobile.value,
region: regionText.value,
detail: detail.value,
fullAddress: `${regionText.value} ${detail.value}`,
email: email.value
} : a)
} else {
// 新增
const address = {
id: Date.now(),
receiver: receiver.value,
mobile: mobile.value,
region: regionText.value,
detail: detail.value,
fullAddress: `${regionText.value} ${detail.value}`,
email: email.value,
createdAt: Date.now()
}
list.unshift(address)
}
uni.setStorageSync(STORAGE_KEY, list)
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => { uni.navigateBack() }, 500)
}
</script>
<style scoped>
.address-page { min-height: 100vh; background: #fff; }
.content { position: absolute; top: 140rpx; bottom: 120rpx; left: 0; right: 0; background: #fff; }
.form-item { display: flex; align-items: center; padding: 24rpx; }
.label { width: 160rpx; color: #333; font-size: 30rpx; }
.input { flex: 1; color: #333; font-size: 30rpx; }
.placeholder { color: #bbb; }
.divider-line { height: 2rpx; background: #eee; margin: 0 24rpx; }
.select-item { position: relative; }
.arrow { position: absolute; right: 24rpx; color: #999; font-size: 48rpx; }
.footer-bar { position: fixed; left: 0; right: 0; bottom: 0; height: 120rpx; background: #27c5b8; display: flex; align-items: center; justify-content: center; }
.confirm-btn { color: #fff; font-size: 34rpx; font-weight: 700; }
/* 地区选择器 */
.picker-mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,0.4); }
.picker-panel { position: fixed; left: 0; right: 0; bottom: 0; background: #fff; }
.picker-header { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; border-bottom: 1rpx solid #eee; }
.picker-title { font-size: 30rpx; color: #333; }
.picker-btn { color: #666; font-size: 28rpx; }
.picker-btn.ok { color: #38c1b1; }
.picker-view { height: 480rpx; }
.picker-item { height: 80rpx; line-height: 80rpx; text-align: center; color: #333; }
.picker-empty { padding: 40rpx; text-align: center; color: #999; }
</style>
+91
View File
@@ -0,0 +1,91 @@
<template>
<view class="addr-list-page">
<uni-nav-bar left-icon="left" title="地址管理" fixed color="#8B2316" height="140rpx" :border="false" backgroundColor="#ffffff" @clickLeft="goBack" />
<scroll-view scroll-y class="list-wrap">
<view v-if="!list.length" class="empty">暂无收货地址</view>
<view v-for="item in list" :key="item.id" class="addr-item" @click="select(item)">
<view class="addr-main">
<view class="row">
<text class="name">{{ item.receiver }}</text>
<text class="mobile">{{ item.mobile }}</text>
</view>
<view class="row small">{{ item.fullAddress || (item.region + ' ' + item.detail) }}</view>
</view>
<view class="addr-actions">
<text class="act" @click.stop="edit(item)">编辑</text>
<text class="act danger" @click.stop="remove(item)">删除</text>
</view>
</view>
</scroll-view>
<view class="footer-bar">
<view class="add-btn" @click="addNew">新增地址</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
const STORAGE_KEY = 'goods_addresses'
const SELECTED_KEY = 'goods_selected_address'
const list = ref([])
const goBack = () => uni.navigateBack()
const load = () => {
try {
const cached = uni.getStorageSync(STORAGE_KEY)
list.value = Array.isArray(cached) ? cached : []
} catch (e) {
list.value = []
}
}
onShow(() => load())
const addNew = () => uni.navigateTo({ url: '/pages_goods/exchange/address' })
const edit = (item) => {
uni.navigateTo({ url: `/pages_goods/exchange/address?id=${item.id}` })
}
const remove = (item) => {
uni.showModal({
title: '删除地址',
content: '确定删除该地址吗?',
success: (r) => {
if (r.confirm) {
list.value = list.value.filter(a => a.id !== item.id)
uni.setStorageSync(STORAGE_KEY, list.value)
}
}
})
}
const select = (item) => {
uni.setStorageSync(SELECTED_KEY, item)
uni.showToast({ title: '已选择该地址', icon: 'success' })
setTimeout(() => uni.navigateBack(), 300)
}
</script>
<style scoped>
.addr-list-page { min-height: 100vh; background: #fff; }
.list-wrap { position: absolute; top: 140rpx; bottom: 120rpx; left: 0; right: 0; }
.empty { text-align: center; color: #999; padding: 80rpx 24rpx; }
.addr-item { display: flex; align-items: center; justify-content: space-between; padding: 24rpx; border-bottom: 1rpx solid #f0f0f0; }
.addr-main { flex: 1; }
.row { display: flex; align-items: center; gap: 20rpx; margin-bottom: 8rpx; }
.row.small { color: #666; font-size: 26rpx; }
.name { color: #333; font-size: 30rpx; }
.mobile { color: #333; font-size: 30rpx; }
.addr-actions { display: flex; align-items: center; gap: 24rpx; margin-left: 24rpx; }
.act { color: #38c1b1; font-size: 28rpx; }
.act.danger { color: #ff4d4f; }
.footer-bar { position: fixed; left: 0; right: 0; bottom: 0; height: 120rpx; background: #27c5b8; display: flex; align-items: center; justify-content: center; }
.add-btn { color: #fff; font-size: 34rpx; font-weight: 700; }
</style>
+192
View File
@@ -0,0 +1,192 @@
<template>
<view class="exchange-page">
<uni-nav-bar left-icon="left" title="在线兑换" fixed color="#8B2316" height="140rpx" :border="false" backgroundColor="#ffffff" @clickLeft="goBack" />
<scroll-view scroll-y class="content">
<!-- 收货地址 -->
<view class="addr-card" @click="changeAddress">
<view class="addr-header">
<text class="addr-title">收货信息</text>
<text class="addr-change">{{ selectedAddress ? '更换' : '添加' }}</text>
</view>
<view v-if="selectedAddress" class="addr-info">
<text class="addr-row">{{ selectedAddress.receiver }} {{ selectedAddress.mobile }}</text>
<text class="addr-row small">{{ selectedAddress.fullAddress || (selectedAddress.region + ' ' + selectedAddress.detail) }}</text>
</view>
<view v-else class="addr-empty">请添加收货地址</view>
</view>
<view class="goods-title">{{ title }}</view>
<view class="price">{{ price }}积分</view>
<view class="divider"></view>
<view class="section">
<view class="section-label">兑换数量</view>
<view class="qty-row">
<view class="qty-btn" :class="{ active: qty === 1 }" @click="setQty(1)">1</view>
<view class="qty-btn" :class="{ active: qty === 2 }" @click="setQty(2)">2</view>
<view class="qty-btn" :class="{ active: qty === 3 }" @click="setQty(3)">3</view>
</view>
</view>
<view class="section">
<view class="section-label">所需积分</view>
<view class="need-points">{{ needPoints }}积分</view>
</view>
</scroll-view>
<view class="footer-bar">
<view class="confirm-btn" @click="goAddress">我要兑换</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import goods_api from '@/api/goods_api'
const title = ref('')
const price = ref(0)
const qty = ref(1)
const needPoints = ref(0)
const totalPoints = ref(0)
const selectedAddress = ref(null)
const id = ref('')
const goBack = () => uni.navigateBack()
const setQty = (n) => {
qty.value = n
needPoints.value = n * Number(price.value || 0)
}
onLoad((opts) => {
if (opts) {
title.value = decodeURIComponent(opts.title || '')
price.value = Number(opts.price || 0)
id.value = opts.id || ''
setQty(1)
getTotalPoints()
}
})
onShow(() => {
loadAddress()
})
const STORAGE_KEY = 'goods_addresses'
const SELECTED_KEY = 'goods_selected_address'
const loadAddress = () => {
try {
const sel = uni.getStorageSync(SELECTED_KEY)
if (sel && sel.id) {
selectedAddress.value = sel
return
}
const list = uni.getStorageSync(STORAGE_KEY)
if (Array.isArray(list) && list.length) {
selectedAddress.value = list[0]
} else {
selectedAddress.value = null
}
} catch (e) {
selectedAddress.value = null
}
}
const changeAddress = () => {
let list = []
try {
const cached = uni.getStorageSync(STORAGE_KEY)
list = Array.isArray(cached) ? cached : []
} catch (e) { list = [] }
if (!list.length) {
uni.navigateTo({ url: '/pages_goods/exchange/address' })
return
}
uni.navigateTo({ url: '/pages_goods/exchange/address_list' })
}
const goAddress = () => {
if (needPoints.value > totalPoints.value) {
uni.showToast({ title: '积分不足', icon: 'none' })
return
}
if (!selectedAddress.value) {
changeAddress()
return
}
const data = {
goodsUuid: id.value,
address: selectedAddress.value.fullAddress,
user_name: selectedAddress.value.receiver,
mobile: selectedAddress.value.mobile,
goodsNum: qty.value,
email: selectedAddress.value.email
}
console.log(data)
//{"goodsUuid":"e6ca84edc8e64242a379dd0b895ea812",
// "address":"北京市东城区你一下",
// "user_name":"噢噢",
// "mobile":"13825244552",
// "goodsNum":"1",
// "email":""}
// 模拟下单:此处可调用后端接口完成兑换
uni.showModal({
title: '确认兑换',
content: `兑换“${title.value}”x${qty.value},共需 ${needPoints.value} 积分`,
success: (r) => {
if (r.confirm) {
// 扣减本地积分(演示)
totalPoints.value = Math.max(0, totalPoints.value - needPoints.value)
uni.removeStorageSync(SELECTED_KEY)
goods_api.createGoodsOrder(data).then(res => {
console.log(res)
if (res.code == 200) {
uni.showToast({ title: '兑换成功', icon: 'success' })
uni.navigateBack()
} else {
uni.showToast({ title: res.msg, icon: 'none' })
}
})
}
}
})
}
const getTotalPoints = () => {
goods_api.getTotalPoints && goods_api.getTotalPoints().then(res => {
if (res.code == 200) {
totalPoints.value = res.data || 0
}
}).catch(() => {})
}
</script>
<style scoped>
.exchange-page { min-height: 100vh; background: #fff; }
.content { position: absolute; top: 140rpx; bottom: 120rpx; left: 0; right: 0; background: #fff; }
.addr-card { background: #fff; padding: 24rpx; border-bottom: 1rpx solid #f0f0f0; }
.addr-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
.addr-title { color: #333; font-size: 30rpx; }
.addr-change { color: #38c1b1; font-size: 28rpx; }
.addr-info { display: flex; flex-direction: column; gap: 8rpx; }
.addr-row { color: #333; font-size: 28rpx; }
.addr-row.small { color: #666; font-size: 26rpx; }
.addr-empty { color: #bbb; font-size: 28rpx; }
.goods-title { padding: 24rpx; font-size: 32rpx; color: #333; line-height: 1.6; }
.price { padding: 0 24rpx 24rpx; color: #e74c3c; font-size: 30rpx; font-weight: 700; }
.divider { height: 16rpx; background: #f5f5f5; }
.section { padding: 24rpx; }
.section-label { color: #333; font-size: 30rpx; margin-bottom: 20rpx; }
.qty-row { display: flex; gap: 24rpx; }
.qty-btn { flex: 1; text-align: center; height: 96rpx; line-height: 96rpx; border: 2rpx solid #38c1b1; color: #38c1b1; border-radius: 12rpx; font-size: 36rpx; }
.qty-btn.active { background: #38c1b1; color: #fff; }
.need-points { color: #333; font-size: 30rpx; text-align: right; }
.footer-bar { position: fixed; left: 0; right: 0; bottom: 0; height: 120rpx; background: #27c5b8; display: flex; align-items: center; justify-content: center; }
.confirm-btn { color: #fff; font-size: 34rpx; font-weight: 700; }
</style>
+113
View File
@@ -0,0 +1,113 @@
<template>
<view class="page">
<uni-nav-bar left-icon="left" title="我的兑换" fixed color="#8B2316" height="140rpx" :border="false" backgroundColor="#ffffff" @clickLeft="goBack" />
<scroll-view
scroll-y
class="content"
refresher-enabled
:refresher-triggered="refresherTriggered"
@refresherrefresh="onRefresh"
@scrolltolower="onReachBottom"
:lower-threshold="80"
>
<view v-if="!orderList.length" class="empty">暂无兑换记录</view>
<view v-for="item in orderList" :key="item.uuid" class="card">
<view class="row">
<text class="label">兑换物品:</text>
<text class="value">{{ item.goods_name }}</text>
</view>
<view class="row">
<text class="label">兑换时间:</text>
<text class="value">{{ formatDate(item.create_date) }}</text>
</view>
<view class="row">
<text class="label">状态:</text>
<text class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</text>
</view>
</view>
<view class="list-footer" v-if="isLoading">加载中...</view>
<view class="list-footer" v-else-if="noMore && orderList.length">没有更多了</view>
</scroll-view>
</view>
</template>
<script setup>
import goods_api from '@/api/goods_api'
import { ref, onMounted } from 'vue'
const orderList = ref([])
const page = ref(1)
const pageSize = ref(10)
const isLoading = ref(false)
const noMore = ref(false)
const refresherTriggered = ref(false)
const fetchList = () => {
if (isLoading.value || noMore.value) return
isLoading.value = true
goods_api.getGoodsOrderList({ page: page.value, pageSize: pageSize.value }).then(res => {
if (res.code == 200 && res.data) {
const list = Array.isArray(res.data.list) ? res.data.list : []
orderList.value = page.value === 1 ? list : orderList.value.concat(list)
noMore.value = !!res.data.isLastPage
page.value = (res.data.pageNum || page.value) + 1
}
}).finally(() => {
isLoading.value = false
refresherTriggered.value = false
})
}
const onRefresh = () => {
refresherTriggered.value = true
noMore.value = false
page.value = 1
fetchList()
}
const onReachBottom = () => {
fetchList()
}
const formatDate = (sec) => {
if (!sec) return ''
const d = new Date(Number(sec) * 1000)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const statusText = (s) => {
if (s === 1 || s === 2) return '已支付'
if (s === 3) return '待支付'
return ''
}
const statusClass = (s) => {
return (s === 1 || s === 2) ? 'paid' : 'unpaid'
}
onMounted(() => {
page.value = 1
noMore.value = false
orderList.value = []
fetchList()
})
const goBack = () => uni.navigateBack()
</script>
<style scoped>
.page { min-height: 100vh; background: #fff; }
.content { position: absolute; top: 140rpx; bottom: 0; left: 0; right: 0; background: #fff; }
.card { background: #fff; padding: 24rpx; border-bottom: 16rpx solid #f5f5f5; }
.row { display: flex; align-items: center; padding: 10rpx 0; }
.label { color: #333; font-size: 30rpx; width: 180rpx; }
.value { color: #333; font-size: 30rpx; }
.status { font-size: 30rpx; }
.status.paid { color: #333; }
.status.unpaid { color: #333; }
.list-footer { text-align: center; color: #999; padding: 24rpx 0; }
.empty { text-align: center; color: #999; padding: 40rpx 0; }
</style>
+555
View File
@@ -0,0 +1,555 @@
<template>
<view class="point-mall-container">
<!-- 顶部导航栏 -->
<uni-nav-bar
left-icon="left"
title="积分商城"
@clickLeft="goBack"
fixed
color="#e74c3c"
height="140rpx"
:border="false"
backgroundColor="#ffffff"
>
<template v-slot:right>
<text class="search-btn" @click="goToSearch">搜索</text>
</template>
</uni-nav-bar>
<!-- 轮播图横幅 -->
<view class="banner-section">
<swiper class="banner-swiper" :indicator-dots="true" :autoplay="true" :interval="3000" :duration="500">
<swiper-item v-for="(banner, index) in bannerList" :key="banner.uuid">
<view class="banner-item" @click="goToBannerDetail(banner)">
<image class="banner-image" :src="banner.headImg" mode="aspectFill"></image>
<view class="banner-content">
<view class="banner-text green">{{ banner.title }}</view>
<view class="banner-text orange">{{ banner.create_date }}</view>
</view>
</view>
</swiper-item>
<!-- 如果没有数据,显示默认轮播图 -->
<swiper-item v-if="bannerList.length === 0">
<view class="banner-item">
<image class="banner-image" src="/static/banner-bg.jpg" mode="aspectFill"></image>
<view class="banner-content">
<view class="banner-text green">第二届京津冀感染肝病高峰论坛&</view>
<view class="banner-text green">第九届河北省感染科医师培训班</view>
<view class="banner-text orange">2016.10.14-2016.10.16</view>
<view class="banner-text orange">河北•石家庄</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
<!-- 筛选排序栏 -->
<view class="filter-bar">
<view class="filter-item" @click="showFilter">
<text class="filter-text">{{ selectedTagName || '筛选' }}</text>
<text class="filter-icon">▼</text>
</view>
<view class="filter-divider"></view>
<view class="filter-item" @click="showSort">
<text class="filter-text">{{ selectedSortLabel || '排序' }}</text>
<text class="filter-icon">▼</text>
</view>
</view>
<!-- 筛选下拉 -->
<view v-if="showFilterDropdown" class="dropdown">
<view
class="dropdown-item"
:class="{ active: selectedTagId === null }"
@click="onTagSelect({ id: null, name: '全部' })">
全部
</view>
<view
v-for="tag in tagList"
:key="tag.id"
class="dropdown-item"
:class="{ active: selectedTagId === tag.id }"
@click="onTagSelect(tag)">
{{ tag.name }}
</view>
</view>
<!-- 排序下拉 -->
<view v-if="showSortDropdown" class="dropdown">
<view
v-for="opt in sortOptions"
:key="opt.value"
class="dropdown-item"
:class="{ active: selectedSortValue === opt.value }"
@click="onSortSelect(opt)">
{{ opt.label }}
</view>
</view>
<!-- 商品网格(上拉加载) -->
<scroll-view class="product-grid" scroll-y @scrolltolower="getGoodsList" :lower-threshold="100">
<view class="grid-wrap">
<view class="product-item" v-for="(product, index) in products" :key="product.uuid || index" @click="goToProductDetail(product)">
<view class="product-image-container">
<image class="product-image" :src="product.image" mode="aspectFill"></image>
</view>
<view class="product-info">
<text class="product-title">{{ product.title }}</text>
<text class="product-price">{{ product.price }}积分</text>
</view>
</view>
</view>
</scroll-view>
<!-- 加载与无更多提示 -->
<view class="list-footer" v-if="isLoadingMore">
<text>加载中...</text>
</view>
<view class="list-footer" v-else-if="noMore && products.length > 0">
<text>没有更多了</text>
</view>
<!-- 底部导航栏 -->
<view class="bottom-nav">
<view class="nav-item active" @click="goToMyRedemption">
<view class="nav-icon">💰</view>
<text class="nav-text">我的兑换</text>
</view>
<view class="nav-divider"></view>
<view class="nav-item" @click="goToBuyPoints">
<view class="nav-icon">🛒</view>
<text class="nav-text">购买积分</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import goods_api from '@/api/goods_api'
import docUrl from '@/utils/docUrl'
// 轮播图数据
const bannerList = ref([])
// 商品数据
const products = ref([])
const page = ref(1)
const pageSize = ref(10)
const isLoadingMore = ref(false)
const noMore = ref(false)
// 筛选相关
const tagList = ref([])
const selectedTagId = ref(null)
const selectedTagName = ref('')
const showFilterDropdown = ref(false)
// 排序相关
const showSortDropdown = ref(false)
const selectedSortValue = ref(null)
const selectedSortLabel = ref('')
const sortOptions = ref([
{ label: '积分从小到大', value: 4 },
{ label: '积分从大到小', value: 3 },
{ label: '兑换从多到少', value: 5 },
{ label: '兑换从少到多', value: 6 }
])
// 方法
const goBack = () => {
uni.navigateBack()
}
const goToSearch = () => {
uni.navigateTo({
url: '/pages_app/search/search'
})
}
const showFilter = () => {
showFilterDropdown.value = !showFilterDropdown.value
}
const showSort = () => {
showSortDropdown.value = !showSortDropdown.value
}
const onTagSelect = (tag) => {
selectedTagId.value = tag.id
selectedTagName.value = tag.name
showFilterDropdown.value = false
// 重置并拉取
products.value = []
page.value = 1
noMore.value = false
getGoodsList()
}
const onSortSelect = (opt) => {
selectedSortValue.value = opt.value
selectedSortLabel.value = opt.label
showSortDropdown.value = false
products.value = []
page.value = 1
noMore.value = false
getGoodsList()
}
const goToProductDetail = (product) => {
const id = product.uuid || product.id
uni.navigateTo({
url: `/pages_goods/productDetail/productDetail?id=${id}`
})
}
const goToMyRedemption = () => {
uni.navigateTo({
url: '/pages_goods/myRedemption/myRedemption'
})
}
const goToBuyPoints = () => {
uni.navigateTo({
url: '/pages_app/buyPoint/buyPoint'
})
}
const goToBannerDetail = (banner) => {
// 跳转到轮播图详情页面
uni.navigateTo({
url: `/pages_app/webview/webview?url=${encodeURIComponent(banner.path)}&title=${encodeURIComponent(banner.title)}`
})
}
const getGoodsNewsList = () => {
goods_api.goodsNewsList().then(res => {
console.log('轮播图数据:', res)
if (res.code === 200 && res.data && res.data.length > 0) {
// 处理轮播图数据
bannerList.value = res.data.map(item => ({
uuid: item.uuid,
title: item.title,
headImg: docUrl + item.headImg,
create_date: item.create_date,
path: item.path,
agreenum: item.agreenum,
readnum: item.readnum
}))
} else {
console.log('轮播图数据为空或请求失败')
}
}).catch(err => {
console.error('获取轮播图数据失败:', err)
uni.showToast({
title: '获取数据失败',
icon: 'none'
})
})
}
const getGoodsList = () => {
if (noMore.value || isLoadingMore.value) return
isLoadingMore.value = true
const tagParam = selectedTagId.value ? selectedTagId.value : ""
const sortParam = selectedSortValue.value ? selectedSortValue.value : 1
goods_api.goodsList({ name: '', page: page.value, sort: sortParam, tag_type: tagParam }).then(res => {
console.log('商品数据:', res)
if ((res.code === 200 || res.code === '200') && res.data && res.data.list) {
const list = res.data.list.map(item => ({
uuid: item.uuid,
title: item.name,
price: item.bonuspoints,
image: docUrl + item.img,
times: item.times,
type: item.type,
upan: item.upan
}))
products.value = products.value.concat(list)
const { isLastPage, pageNum } = res.data
page.value = pageNum + 1
noMore.value = !!isLastPage
} else {
console.log('商品列表为空或请求失败')
}
}).catch(err => {
console.error('获取商品列表失败:', err)
uni.showToast({ title: '获取商品失败', icon: 'none' })
}).finally(() => {
isLoadingMore.value = false
})
}
const getGoodsTagList = () => {
goods_api.goodsTagList({}).then(res => {
console.log('商品类型数据:', res)
if (res.code === 200 && res.data) {
tagList.value = res.data.map(i => ({ id: i.id, name: i.name }))
}
})
}
onMounted(() => {
getGoodsNewsList()
// 初始化分页并加载第一页
page.value = 1
noMore.value = false
products.value = []
getGoodsList()
getGoodsTagList()
})
</script>
<style scoped>
.point-mall-container {
width: 100%;
min-height: 100vh;
background-color: #f5f5f5;
position: relative;
padding-bottom: 80px;
}
/* 搜索按钮样式 */
.search-btn {
color: #e74c3c;
font-size: 28rpx;
padding: 10rpx;
}
/* 轮播图样式 */
.banner-section {
margin: 0;
position: relative;
}
.banner-swiper {
height: 200px;
}
.banner-item {
position: relative;
width: 100%;
height: 100%;
}
.banner-image {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #87CEEB 0%, #98FB98 50%, #F0E68C 100%);
}
.banner-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px;
}
.banner-text {
background-color: rgba(255, 255, 255, 0.95);
padding: 6px 12px;
margin: 3px 0;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.banner-text.green {
color: #27ae60;
}
.banner-text.orange {
color: #e67e22;
}
/* 轮播图指示器样式 */
.banner-swiper ::v-deep .uni-swiper-dots {
bottom: 10px;
}
.banner-swiper ::v-deep .uni-swiper-dot {
width: 8px;
height: 8px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 50%;
margin: 0 4px;
}
.banner-swiper ::v-deep .uni-swiper-dot-active {
background-color: #fff;
}
/* 筛选排序栏样式 */
.filter-bar {
display: flex;
align-items: center;
background-color: #fff;
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
.dropdown {
background: #fff;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
padding: 8px 0;
}
.dropdown-item {
padding: 12px 16px;
font-size: 14px;
color: #333;
}
.dropdown-item.active {
color: #e74c3c;
font-weight: bold;
background: #fff7f7;
}
.filter-item {
display: flex;
align-items: center;
flex: 1;
justify-content: center;
padding: 8px;
}
.filter-text {
font-size: 14px;
color: #333;
margin-right: 4px;
}
.filter-icon {
font-size: 10px;
color: #666;
}
.filter-divider {
width: 1px;
height: 20px;
background-color: #ddd;
}
/* 商品网格样式 */
.product-grid {
height: calc(100vh - 320rpx); /* 预留顶部导航/筛选及底部栏 */
background-color: #f5f5f5;
}
.grid-wrap {
display: flex;
flex-wrap: wrap;
padding: 16px;
gap: 16px;
}
.list-footer {
text-align: center;
color: #999;
padding: 12px 0 80px; /* 避开底部吸底栏 */
}
.product-item {
width: calc(50% - 8px);
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.product-item:active {
transform: scale(0.98);
}
.product-image-container {
width: 100%;
height: 120px;
overflow: hidden;
position: relative;
}
.product-image {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
.product-info {
padding: 12px;
}
.product-title {
font-size: 12px;
color: #333;
line-height: 1.4;
margin-bottom: 8px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-price {
font-size: 14px;
color: #e74c3c;
font-weight: bold;
}
/* 底部导航栏样式 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background-color: #20b2aa;
display: flex;
align-items: center;
z-index: 1000;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
transition: background-color 0.2s ease;
}
.nav-item.active {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 8px;
margin: 4px;
}
.nav-icon {
font-size: 20px;
margin-bottom: 2px;
}
.nav-text {
font-size: 12px;
color: #fff;
font-weight: 500;
}
.nav-divider {
width: 1px;
height: 40px;
background-color: rgba(255, 255, 255, 0.3);
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<template>
<view class="product-detail-container">
<!-- 顶部导航栏 -->
<uni-nav-bar
left-icon="left"
:title="navTitle"
@clickLeft="goBack"
fixed
color="#8B2316"
height="140rpx"
:border="false"
backgroundColor="#ffffff"
/>
<!-- 内容区 -->
<scroll-view scroll-y class="detail-scroll">
<!-- 商品主图轮播 -->
<view class="swiper-box">
<swiper class="detail-swiper" :indicator-dots="true" :autoplay="false" :interval="3000" :duration="400">
<swiper-item v-for="(img, idx) in images" :key="idx">
<image class="swiper-image" :src="img" mode="aspectFit"></image>
</swiper-item>
</swiper>
</view>
<!-- 标题与价格、销量 -->
<view class="summary">
<view class="title">{{ product.title }}</view>
<view class="price-row">
<text class="price">{{ product.price }}积分</text>
<text class="exchanged">已兑换{{ product.times }}件</text>
</view>
</view>
<view class="divider"></view>
<!-- 温馨提示 -->
<view class="tips-section">
<view class="tips-title">温馨提示</view>
<view class="tip-item"><text class="num">1</text><text class="tip-text">商品一经兑换,消耗的积分恕不退还,请各位用户兑换前仔细阅读商品描述详情。</text></view>
<view class="tip-item"><text class="num">2</text><text class="tip-text">商品兑换后不接受退换货申请,虚拟商品请在商品有效期内及时兑换。</text></view>
<view class="tip-item"><text class="num">3</text><text class="tip-text">所有实物商品包邮,虚拟商品将发送相关信息到联系邮箱或手机。</text></view>
<view class="tip-item"><text class="num">4</text><text class="tip-text">实物商品兑换后2-4工作日内发放,虚拟商品兑换后1-3工作日内发送。</text></view>
</view>
<!-- 物品展示 -->
<view class="section-title">物品展示</view>
<view class="rich-wrapper">
<rich-text :nodes="product.content"></rich-text>
</view>
</scroll-view>
<!-- 底部兑换按钮 -->
<view class="bottom-bar">
<view class="redeem-btn" @click="goExchange">在线兑换</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import goods_api from '@/api/goods_api'
import docUrl from '@/utils/docUrl'
const product = ref({ id: '', title: '', price: 0, times: 0, content: '' })
const images = ref([])
const navTitle = ref('商品详情')
const goBack = () => uni.navigateBack()
const fetchDetail = async (id) => {
try {
const res = await goods_api.getGoodsDetail({ uuid: id })
if ((res.code === 200 || res.code === '200') && res.data) {
product.value = {
id,
title: res.data.name || '',
price: res.data.bonuspoints || 0,
times: res.data.times || 0,
content: res.data.content || ''
}
navTitle.value = (product.value.title || '').slice(0, 18)
const list = []
if (res.data.detial_imgpath) {
res.data.detial_imgpath.split(',').forEach(u => u && list.push(docUrl + u))
}
// if (res.data.img) list.push(docUrl + res.data.img)
images.value = list.length ? list : ['/static/product2.jpg']
} else {
uni.showToast({ title: res.message || '获取详情失败', icon: 'none' })
}
} catch (e) {
console.error(e)
uni.showToast({ title: '网络错误', icon: 'none' })
}
}
onLoad((opts) => {
if (opts && opts.id) fetchDetail(opts.id)
})
const goExchange = () => {
uni.navigateTo({
url: `/pages_goods/exchange/index?id=${product.value.id}&title=${encodeURIComponent(product.value.title)}&price=${product.value.price}`
})
}
</script>
<style scoped>
.product-detail-container {
width: 100%;
min-height: 100vh;
background-color: #f5f5f5;
}
.detail-scroll {
position: absolute;
top: 140rpx;
left: 0;
right: 0;
bottom: 100rpx;
background: #fff;
}
/* 主图轮播 */
.swiper-box { background: #fff; }
.detail-swiper { height: 480rpx; }
.swiper-image { width: 100%; height: 100%; }
/* 概要 */
.summary { background: #fff; padding: 24rpx; }
.title { font-size: 34rpx; color: #333; line-height: 1.6; }
.price-row { display: flex; justify-content: space-between; align-items: center; margin-top: 16rpx; }
.price { color: #e74c3c; font-size: 34rpx; font-weight: 700; }
.exchanged { color: #999; font-size: 24rpx; }
.divider { height: 16rpx; background: #f5f5f5; }
/* 温馨提示 */
.tips-section { background: #fff; padding: 24rpx; }
.tips-title { text-align: center; font-size: 36rpx; font-weight: 700; color: #333; margin-bottom: 16rpx; }
.tip-item { display: flex; align-items: flex-start; background: #f7f7f7; border-radius: 8rpx; padding: 20rpx; margin: 12rpx 0; }
.num { width: 36rpx; height: 36rpx; line-height: 36rpx; text-align: center; color: #888; background: #fff; border-radius: 50%; margin-right: 16rpx; border: 1rpx solid #ddd; }
.tip-text { color: #666; font-size: 26rpx; line-height: 1.6; flex: 1; }
.section-title { background: #fff; padding: 24rpx; font-size: 34rpx; font-weight: 700; color: #333; text-align: center; }
.rich-wrapper { background: #fff; padding: 24rpx; }
/* 底部按钮 */
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #27c5b8;
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
}
.redeem-btn { color: #fff; font-size: 32rpx; font-weight: 700; }
</style>