Initial commit of akmon project
This commit is contained in:
832
pages/mall/consumer/index.uvue
Normal file
832
pages/mall/consumer/index.uvue
Normal file
@@ -0,0 +1,832 @@
|
||||
<!-- 消费者端首页 - 严格UTS Android规范 -->
|
||||
<template>
|
||||
<view class="consumer-home">
|
||||
<!-- 顶部搜索栏 -->
|
||||
<view class="header">
|
||||
<view class="search-container">
|
||||
<view class="search-box" @click="navigateToSearch">
|
||||
<text class="search-icon">🔍</text>
|
||||
<text class="search-placeholder">搜索商品</text>
|
||||
</view>
|
||||
<view class="cart-icon" @click="navigateToCart">
|
||||
<text class="icon">🛒</text>
|
||||
<text v-if="cartCount > 0" class="badge">{{ cartCount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view direction="vertical" class="main-content">
|
||||
<!-- 轮播图 -->
|
||||
<swiper class="banner-swiper" :autoplay="true" :interval="3000" :duration="500">
|
||||
<swiper-item v-for="(banner, index) in bannerList" :key="index">
|
||||
<image class="banner-image" :src="banner.image_url" mode="aspectFill" @click="handleBannerClick(banner)" />
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 分类导航 -->
|
||||
<view class="category-section">
|
||||
<text class="section-title">商品分类</text>
|
||||
<view class="category-grid">
|
||||
<view v-for="(category, index) in categoryList" :key="category.id" class="category-item" @click="navigateToCategory(category)">
|
||||
<image class="category-icon" :src="category.icon_url || '/static/default-category.png'" mode="aspectFit" />
|
||||
<text class="category-name">{{ category.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 优惠券中心 -->
|
||||
<view class="coupon-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">优惠券</text>
|
||||
<text class="more-btn" @click="navigateToCoupons">更多 ›</text>
|
||||
</view>
|
||||
<scroll-view direction="horizontal" class="coupon-scroll">
|
||||
<view class="coupon-list">
|
||||
<view v-for="(coupon, index) in couponList" :key="coupon.id" class="coupon-item" @click="receiveCoupon(coupon)">
|
||||
<view class="coupon-left">
|
||||
<text class="coupon-value">¥{{ coupon.discount_value }}</text>
|
||||
<text class="coupon-condition">满{{ coupon.min_order_amount }}可用</text>
|
||||
</view>
|
||||
<view class="coupon-right">
|
||||
<text class="coupon-name">{{ coupon.name }}</text>
|
||||
<text class="coupon-expire">{{ formatExpireTime(coupon.end_time) }}</text>
|
||||
<view class="receive-btn">
|
||||
<text class="btn-text">立即领取</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 商品推荐 -->
|
||||
<view class="product-section">
|
||||
<text class="section-title">为你推荐</text>
|
||||
<!-- #ifdef APP -->
|
||||
<waterfall :column-count="2" :column-gap="10" :row-gap="10" class="product-waterfall">
|
||||
<view v-for="(product, index) in productList" :key="product.id" class="product-card" @click="navigateToProduct(product)">
|
||||
<image class="product-image" :src="getProductFirstImage(product)" mode="aspectFill" @error="handleImageError" />
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<view class="price-row">
|
||||
<text class="current-price">¥{{ product.price }}</text>
|
||||
<text v-if="product.original_price !== null && product.original_price > product.price" class="original-price">¥{{ product.original_price }}</text>
|
||||
</view>
|
||||
<view class="sales-row">
|
||||
<text class="sales-text">已售{{ product.sales }}件</text>
|
||||
<view class="add-cart-btn" @click.stop="addToCart(product)">
|
||||
<text class="add-text">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</waterfall>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef APP -->
|
||||
<view class="product-grid">
|
||||
<view v-for="(product, index) in productList" :key="product.id" class="product-card" @click="navigateToProduct(product)">
|
||||
<image class="product-image" :src="getProductFirstImage(product)" mode="aspectFill" @error="handleImageError" />
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<view class="price-row">
|
||||
<text class="current-price">¥{{ product.price }}</text>
|
||||
<text v-if="product.original_price !== null && product.original_price > product.price" class="original-price">¥{{ product.original_price }}</text>
|
||||
</view>
|
||||
<view class="sales-row">
|
||||
<text class="sales-text">已售{{ product.sales }}件</text>
|
||||
<view class="add-cart-btn" @click.stop="addToCart(product)">
|
||||
<text class="add-text">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="isLoading" class="loading-container">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<view class="bottom-navigation">
|
||||
<view class="nav-item active">
|
||||
<text class="nav-icon">🏠</text>
|
||||
<text class="nav-text">首页</text>
|
||||
</view>
|
||||
<view class="nav-item" @click="navigateToCategory()">
|
||||
<text class="nav-icon">📂</text>
|
||||
<text class="nav-text">分类</text>
|
||||
</view>
|
||||
<view class="nav-item" @click="navigateToCart">
|
||||
<text class="nav-icon">🛒</text>
|
||||
<text class="nav-text">购物车</text>
|
||||
</view>
|
||||
<view class="nav-item" @click="navigateToProfile">
|
||||
<text class="nav-icon">👤</text>
|
||||
<text class="nav-text">我的</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { ProductType, CouponTemplateType } from '@/types/mall-types.uts'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
// 数据定义
|
||||
const bannerList = ref<Array<any>>([])
|
||||
const categoryList = ref<Array<any>>([])
|
||||
const couponList = ref<Array<CouponTemplateType>>([])
|
||||
const productList = ref<Array<ProductType>>([])
|
||||
const cartCount = ref<number>(0)
|
||||
const isLoading = ref<boolean>(false)
|
||||
const page = ref<number>(1)
|
||||
const pageSize = ref<number>(20)
|
||||
const hasMore = ref<boolean>(true)
|
||||
|
||||
// 页面加载
|
||||
onMounted(() => {
|
||||
loadBanners()
|
||||
loadCategories()
|
||||
loadCoupons()
|
||||
loadProducts()
|
||||
loadCartCount()
|
||||
})
|
||||
|
||||
// 加载轮播图
|
||||
const loadBanners = async () => {
|
||||
try {
|
||||
const { data, error } = await supa
|
||||
.from('mall_banners')
|
||||
.select('*')
|
||||
.eq('status', 1)
|
||||
.order('sort_order', { ascending: true })
|
||||
.limit(5)
|
||||
|
||||
if (error !== null) {
|
||||
console.error('加载轮播图失败:', error)
|
||||
return
|
||||
}
|
||||
|
||||
bannerList.value = data ?? []
|
||||
} catch (err) {
|
||||
console.error('加载轮播图异常:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载分类
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const { data, error } = await supa
|
||||
.from('categories')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.is('parent_id', null)
|
||||
.order('sort_order', { ascending: true })
|
||||
.limit(8)
|
||||
|
||||
if (error !== null) {
|
||||
console.error('加载分类失败:', error)
|
||||
return
|
||||
}
|
||||
|
||||
categoryList.value = data ?? []
|
||||
} catch (err) {
|
||||
console.error('加载分类异常:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载优惠券
|
||||
const loadCoupons = async () => {
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
const { data, error } = await supa
|
||||
.from('coupon_templates')
|
||||
.select('*')
|
||||
.eq('status', 1)
|
||||
.gte('end_time', now)
|
||||
.lte('start_time', now)
|
||||
.limit(5)
|
||||
|
||||
if (error !== null) {
|
||||
console.error('加载优惠券失败:', error)
|
||||
return
|
||||
}
|
||||
|
||||
couponList.value = data ?? []
|
||||
} catch (err) {
|
||||
console.error('加载优惠券异常:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载商品
|
||||
const loadProducts = async (loadMore: boolean = false) => {
|
||||
if (isLoading.value || (!hasMore.value && loadMore)) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const currentPage = loadMore ? page.value + 1 : 1
|
||||
const { data, error } = await supa
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('status', 1)
|
||||
.order('created_at', { ascending: false })
|
||||
.range((currentPage - 1) * pageSize.value, currentPage * pageSize.value - 1)
|
||||
|
||||
if (error !== null) {
|
||||
console.error('加载商品失败:', error)
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const newProducts = data ?? []
|
||||
|
||||
if (loadMore) {
|
||||
for (let i: Int = 0; i < newProducts.length; i++) {
|
||||
productList.value.push(newProducts[i])
|
||||
}
|
||||
page.value = currentPage
|
||||
} else {
|
||||
productList.value = newProducts
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
hasMore.value = newProducts.length === pageSize.value
|
||||
} catch (err) {
|
||||
console.error('加载商品异常:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载购物车数量
|
||||
const loadCartCount = async () => {
|
||||
try {
|
||||
// 这里应该从用户登录状态获取user_id
|
||||
const userId = getCurrentUserId()
|
||||
if (userId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { data, error } = await supa
|
||||
.from('shopping_cart')
|
||||
.select('quantity')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error !== null) {
|
||||
console.error('加载购物车数量失败:', error)
|
||||
return
|
||||
}
|
||||
|
||||
let total = 0
|
||||
const cartItems = data ?? []
|
||||
for (let i: Int = 0; i < cartItems.length; i++) {
|
||||
total += cartItems[i].quantity
|
||||
}
|
||||
cartCount.value = total
|
||||
} catch (err) {
|
||||
console.error('加载购物车数量异常:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前用户ID (临时实现)
|
||||
const getCurrentUserId = (): string | null => {
|
||||
// 这里应该从全局状态或storage中获取
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取商品第一张图片
|
||||
const getProductFirstImage = (product: ProductType): string => {
|
||||
if (product.images.length > 0) {
|
||||
return product.images[0]
|
||||
}
|
||||
return '/static/default-product.png'
|
||||
}
|
||||
|
||||
// 格式化过期时间
|
||||
const formatExpireTime = (endTime: string): string => {
|
||||
const date = new Date(endTime)
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
return `${month}.${day}到期`
|
||||
}
|
||||
|
||||
// 图片加载错误处理
|
||||
const handleImageError = (event: UniImageErrorEvent) => {
|
||||
const target = event.target as UniImageElement
|
||||
if (target !== null) {
|
||||
target.src = '/static/default-product.png'
|
||||
}
|
||||
}
|
||||
|
||||
// 轮播图点击
|
||||
const handleBannerClick = (banner: any) => {
|
||||
if (banner.link_url !== null && banner.link_url !== '') {
|
||||
// 处理轮播图跳转
|
||||
console.log('点击轮播图:', banner.link_url)
|
||||
}
|
||||
}
|
||||
|
||||
// 领取优惠券
|
||||
const receiveCoupon = async (coupon: CouponTemplateType) => {
|
||||
const userId = getCurrentUserId()
|
||||
if (userId === null) {
|
||||
uni.showToast({
|
||||
title: '请先登录',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查是否已领取
|
||||
const { data: existingCoupons, error: checkError } = await supa
|
||||
.from('user_coupons')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('template_id', coupon.id)
|
||||
|
||||
if (checkError !== null) {
|
||||
console.error('检查优惠券失败:', checkError)
|
||||
return
|
||||
}
|
||||
|
||||
if (existingCoupons !== null && existingCoupons.length >= coupon.per_user_limit) {
|
||||
uni.showToast({
|
||||
title: '已达领取上限',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成优惠券码
|
||||
const couponCode = generateCouponCode()
|
||||
const expireAt = coupon.end_time
|
||||
|
||||
const { error: insertError } = await supa
|
||||
.from('user_coupons')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
template_id: coupon.id,
|
||||
coupon_code: couponCode,
|
||||
expire_at: expireAt
|
||||
})
|
||||
|
||||
if (insertError !== null) {
|
||||
console.error('领取优惠券失败:', insertError)
|
||||
uni.showToast({
|
||||
title: '领取失败',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: '领取成功',
|
||||
icon: 'success'
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('领取优惠券异常:', err)
|
||||
uni.showToast({
|
||||
title: '领取失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 生成优惠券码
|
||||
const generateCouponCode = (): string => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let result = ''
|
||||
for (let i: Int = 0; i < 12; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 添加到购物车
|
||||
const addToCart = async (product: ProductType) => {
|
||||
const userId = getCurrentUserId()
|
||||
if (userId === null) {
|
||||
uni.showToast({
|
||||
title: '请先登录',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 这里简化处理,直接添加商品(实际应该选择SKU)
|
||||
try {
|
||||
const { error } = await supa
|
||||
.from('shopping_cart')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
product_id: product.id,
|
||||
quantity: 1
|
||||
})
|
||||
|
||||
if (error !== null) {
|
||||
console.error('添加购物车失败:', error)
|
||||
uni.showToast({
|
||||
title: '添加失败',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cartCount.value++
|
||||
uni.showToast({
|
||||
title: '已添加到购物车',
|
||||
icon: 'success'
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('添加购物车异常:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 导航方法
|
||||
const navigateToSearch = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/search'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToCart = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/cart'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToCategory = (category?: any) => {
|
||||
const url = category !== null ?
|
||||
`/pages/mall/consumer/category?id=${category.id}` :
|
||||
'/pages/mall/consumer/category'
|
||||
uni.navigateTo({
|
||||
url: url
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToProduct = (product: ProductType) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/product-detail?id=${product.id}`
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToCoupons = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/coupons'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToProfile = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/profile'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.consumer-home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #ffffff;
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f8f8f8;
|
||||
border-radius: 20px;
|
||||
padding: 8px 15px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: #999999;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: #999999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cart-icon {
|
||||
position: relative;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
background-color: #ff4757;
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.banner-swiper {
|
||||
height: 180px;
|
||||
margin: 10px 15px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
background-color: #ffffff;
|
||||
margin: 10px 15px;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coupon-section {
|
||||
background-color: #ffffff;
|
||||
margin: 10px 15px;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
color: #007aff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.coupon-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.coupon-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.coupon-item {
|
||||
display: flex;
|
||||
width: 280px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #ff6b6b, #ffa726);
|
||||
border-radius: 8px;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.coupon-left {
|
||||
width: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.coupon-value {
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.coupon-condition {
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.coupon-right {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.coupon-name {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.coupon-expire {
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.receive-btn {
|
||||
align-self: flex-end;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
color: #ff6b6b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-section {
|
||||
background-color: #ffffff;
|
||||
margin: 10px 15px;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.product-waterfall {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
width: 48%;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.current-price {
|
||||
color: #ff4757;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.original-price {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
text-decoration: line-through;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.sales-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sales-text {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-cart-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #007aff;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-text {
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #999999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bottom-navigation {
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
590
pages/mall/consumer/order-detail.uvue
Normal file
590
pages/mall/consumer/order-detail.uvue
Normal file
@@ -0,0 +1,590 @@
|
||||
<!-- 消费者端 - 订单详情页 -->
|
||||
<template>
|
||||
<view class="order-detail-page">
|
||||
<!-- 订单状态 -->
|
||||
<view class="order-status">
|
||||
<view class="status-icon">
|
||||
<text class="status-emoji">{{ getStatusIcon() }}</text>
|
||||
</view>
|
||||
<view class="status-info">
|
||||
<text class="status-text">{{ getStatusText() }}</text>
|
||||
<text class="status-desc">{{ getStatusDesc() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 配送信息 -->
|
||||
<view v-if="order.status >= 2" class="delivery-info">
|
||||
<view class="delivery-header">
|
||||
<text class="delivery-title">配送信息</text>
|
||||
</view>
|
||||
<view class="delivery-address">
|
||||
<view class="address-info">
|
||||
<text class="recipient">{{ getDeliveryAddress().name }}</text>
|
||||
<text class="phone">{{ getDeliveryAddress().phone }}</text>
|
||||
</view>
|
||||
<text class="address-detail">{{ getDeliveryAddress().detail }}</text>
|
||||
</view>
|
||||
<view v-if="deliveryInfo.courier_name" class="courier-info">
|
||||
<text class="courier-label">配送员:</text>
|
||||
<text class="courier-name">{{ deliveryInfo.courier_name }}</text>
|
||||
<text class="courier-phone">{{ deliveryInfo.courier_phone }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品信息 -->
|
||||
<view class="order-products">
|
||||
<view class="shop-header">
|
||||
<text class="shop-name">{{ merchant.shop_name }}</text>
|
||||
</view>
|
||||
<view v-for="item in orderItems" :key="item.id" class="product-item">
|
||||
<image :src="item.product_image || '/static/default-product.png'" class="product-image" />
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ item.product_name }}</text>
|
||||
<text v-if="item.sku_specifications" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
|
||||
<view class="price-quantity">
|
||||
<text class="product-price">¥{{ item.price }}</text>
|
||||
<text class="product-quantity">×{{ item.quantity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单信息 -->
|
||||
<view class="order-info">
|
||||
<view class="info-row">
|
||||
<text class="info-label">订单编号</text>
|
||||
<text class="info-value">{{ order.order_no }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">下单时间</text>
|
||||
<text class="info-value">{{ formatTime(order.created_at) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">支付方式</text>
|
||||
<text class="info-value">{{ getPaymentMethodText() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 费用明细 -->
|
||||
<view class="cost-detail">
|
||||
<view class="cost-row">
|
||||
<text class="cost-label">商品总额</text>
|
||||
<text class="cost-value">¥{{ order.total_amount }}</text>
|
||||
</view>
|
||||
<view class="cost-row">
|
||||
<text class="cost-label">优惠金额</text>
|
||||
<text class="cost-value">-¥{{ order.discount_amount }}</text>
|
||||
</view>
|
||||
<view class="cost-row">
|
||||
<text class="cost-label">配送费</text>
|
||||
<text class="cost-value">¥{{ order.delivery_fee }}</text>
|
||||
</view>
|
||||
<view class="cost-row total">
|
||||
<text class="cost-label">实付金额</text>
|
||||
<text class="cost-value">¥{{ order.actual_amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<view class="bottom-actions">
|
||||
<button v-if="order.status === 1" class="pay-btn" @click="payOrder">立即支付</button>
|
||||
<button v-if="order.status === 2" class="remind-btn" @click="remindDelivery">提醒发货</button>
|
||||
<button v-if="order.status === 3" class="confirm-btn" @click="confirmReceive">确认收货</button>
|
||||
<button v-if="order.status === 4" class="review-btn" @click="goToReview">评价商品</button>
|
||||
<button v-if="order.status <= 2" class="cancel-btn" @click="cancelOrder">取消订单</button>
|
||||
<button class="service-btn" @click="contactService">联系客服</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OrderType, OrderItemType, MerchantType } from '@/types/mall-types.uts'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
order: {
|
||||
id: '',
|
||||
order_no: '',
|
||||
user_id: '',
|
||||
merchant_id: '',
|
||||
status: 0,
|
||||
total_amount: 0,
|
||||
discount_amount: 0,
|
||||
delivery_fee: 0,
|
||||
actual_amount: 0,
|
||||
payment_method: 0,
|
||||
payment_status: 0,
|
||||
delivery_address: {},
|
||||
created_at: ''
|
||||
} as OrderType,
|
||||
orderItems: [] as Array<OrderItemType & { product_image: string }>,
|
||||
merchant: {
|
||||
id: '',
|
||||
user_id: '',
|
||||
shop_name: '',
|
||||
shop_logo: '',
|
||||
shop_banner: '',
|
||||
shop_description: '',
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
shop_status: 0,
|
||||
rating: 0,
|
||||
total_sales: 0,
|
||||
created_at: ''
|
||||
} as MerchantType,
|
||||
deliveryInfo: {
|
||||
courier_name: '',
|
||||
courier_phone: '',
|
||||
tracking_no: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
onLoad(options: any) {
|
||||
const orderId = options.orderId as string
|
||||
if (orderId) {
|
||||
this.loadOrderDetail(orderId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadOrderDetail(orderId: string) {
|
||||
// 模拟加载订单详情数据
|
||||
this.order = {
|
||||
id: orderId,
|
||||
order_no: 'ORD202401150001',
|
||||
user_id: 'user_001',
|
||||
merchant_id: 'merchant_001',
|
||||
status: 3, // 1:待支付 2:待发货 3:待收货 4:已完成 5:已取消
|
||||
total_amount: 299.98,
|
||||
discount_amount: 30.00,
|
||||
delivery_fee: 8.00,
|
||||
actual_amount: 277.98,
|
||||
payment_method: 1, // 1:微信支付 2:支付宝 3:余额
|
||||
payment_status: 1,
|
||||
delivery_address: {
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
detail: '北京市朝阳区某某街道某某小区1号楼101室'
|
||||
},
|
||||
created_at: '2024-01-15 14:30:00'
|
||||
}
|
||||
|
||||
this.orderItems = [
|
||||
{
|
||||
id: 'item_001',
|
||||
order_id: orderId,
|
||||
product_id: 'product_001',
|
||||
sku_id: 'sku_001',
|
||||
product_name: '精选好物商品',
|
||||
sku_specifications: { color: '红色', size: 'M' },
|
||||
price: 199.99,
|
||||
quantity: 1,
|
||||
total_amount: 199.99,
|
||||
product_image: '/static/product1.jpg'
|
||||
},
|
||||
{
|
||||
id: 'item_002',
|
||||
order_id: orderId,
|
||||
product_id: 'product_002',
|
||||
sku_id: 'sku_002',
|
||||
product_name: '优质配件',
|
||||
sku_specifications: { type: '标准版' },
|
||||
price: 99.99,
|
||||
quantity: 1,
|
||||
total_amount: 99.99,
|
||||
product_image: '/static/product2.jpg'
|
||||
}
|
||||
]
|
||||
|
||||
this.merchant = {
|
||||
id: 'merchant_001',
|
||||
user_id: 'user_001',
|
||||
shop_name: '优质好店',
|
||||
shop_logo: '/static/shop-logo.png',
|
||||
shop_banner: '/static/shop-banner.png',
|
||||
shop_description: '专注品质生活',
|
||||
contact_name: '店主小王',
|
||||
contact_phone: '13800138000',
|
||||
shop_status: 1,
|
||||
rating: 4.8,
|
||||
total_sales: 15680,
|
||||
created_at: '2023-06-01'
|
||||
}
|
||||
|
||||
if (this.order.status >= 3) {
|
||||
this.deliveryInfo = {
|
||||
courier_name: '李师傅',
|
||||
courier_phone: '13900139000',
|
||||
tracking_no: 'YT123456789'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getStatusIcon(): string {
|
||||
const icons = ['⏳', '💰', '📦', '🚚', '✅', '❌']
|
||||
return icons[this.order.status] || '⏳'
|
||||
},
|
||||
|
||||
getStatusText(): string {
|
||||
const statusTexts = ['订单异常', '待支付', '待发货', '待收货', '已完成', '已取消']
|
||||
return statusTexts[this.order.status] || '未知状态'
|
||||
},
|
||||
|
||||
getStatusDesc(): string {
|
||||
const statusDescs = [
|
||||
'订单状态异常',
|
||||
'请在24小时内完成支付',
|
||||
'商家正在准备发货',
|
||||
'商品正在配送中,请耐心等待',
|
||||
'订单已完成,感谢您的购买',
|
||||
'订单已取消'
|
||||
]
|
||||
return statusDescs[this.order.status] || ''
|
||||
},
|
||||
|
||||
getDeliveryAddress(): any {
|
||||
return this.order.delivery_address as any
|
||||
},
|
||||
|
||||
getSpecText(specifications: any): string {
|
||||
if (!specifications) return ''
|
||||
return Object.keys(specifications).map(key => `${key}: ${specifications[key]}`).join(', ')
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
return timeStr.replace('T', ' ').split('.')[0]
|
||||
},
|
||||
|
||||
getPaymentMethodText(): string {
|
||||
const methods = ['', '微信支付', '支付宝', '余额支付']
|
||||
return methods[this.order.payment_method || 0] || '未知'
|
||||
},
|
||||
|
||||
payOrder() {
|
||||
uni.showModal({
|
||||
title: '确认支付',
|
||||
content: `确认支付 ¥${this.order.actual_amount} 吗?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 模拟支付
|
||||
uni.showLoading({ title: '支付中...' })
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
this.order.status = 2
|
||||
uni.showToast({
|
||||
title: '支付成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
remindDelivery() {
|
||||
uni.showToast({
|
||||
title: '已提醒商家发货',
|
||||
icon: 'success'
|
||||
})
|
||||
},
|
||||
|
||||
confirmReceive() {
|
||||
uni.showModal({
|
||||
title: '确认收货',
|
||||
content: '确认已收到商品吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.order.status = 4
|
||||
uni.showToast({
|
||||
title: '确认收货成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
goToReview() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/product-review?orderId=${this.order.id}`
|
||||
})
|
||||
},
|
||||
|
||||
cancelOrder() {
|
||||
uni.showModal({
|
||||
title: '取消订单',
|
||||
content: '确定要取消这个订单吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.order.status = 5
|
||||
uni.showToast({
|
||||
title: '订单已取消',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
contactService() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/service/chat?orderId=${this.order.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.order-detail-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
background-color: #fff;
|
||||
padding: 40rpx 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background-color: #f0f8ff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 30rpx;
|
||||
}
|
||||
|
||||
.status-emoji {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.delivery-info {
|
||||
background-color: #fff;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.delivery-header {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.delivery-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.delivery-address {
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.address-info {
|
||||
display: flex;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.recipient {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-right: 30rpx;
|
||||
}
|
||||
|
||||
.phone {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.address-detail {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.courier-info {
|
||||
padding: 20rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.courier-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.courier-name {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
margin-right: 30rpx;
|
||||
}
|
||||
|
||||
.courier-phone {
|
||||
font-size: 26rpx;
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.order-products {
|
||||
background-color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.shop-header {
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.shop-name {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.product-item {
|
||||
display: flex;
|
||||
padding: 30rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.product-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 10rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 10rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.product-spec {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.price-quantity {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 28rpx;
|
||||
color: #ff4444;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.product-quantity {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.order-info, .cost-detail {
|
||||
background-color: #fff;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.info-row, .cost-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15rpx 0;
|
||||
}
|
||||
|
||||
.info-label, .cost-label {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-value, .cost-value {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cost-row.total {
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
margin-top: 10rpx;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.cost-row.total .cost-label,
|
||||
.cost-row.total .cost-value {
|
||||
font-weight: bold;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.bottom-actions button {
|
||||
flex: 1;
|
||||
height: 70rpx;
|
||||
border-radius: 35rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pay-btn, .confirm-btn {
|
||||
background-color: #ff4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.remind-btn, .review-btn {
|
||||
background-color: #ffa726;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.service-btn {
|
||||
background-color: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
518
pages/mall/consumer/product-detail.uvue
Normal file
518
pages/mall/consumer/product-detail.uvue
Normal file
@@ -0,0 +1,518 @@
|
||||
<!-- 消费者端 - 商品详情页 -->
|
||||
<template>
|
||||
<view class="product-detail-page">
|
||||
<!-- 商品图片轮播 -->
|
||||
<view class="product-images">
|
||||
<swiper class="image-swiper" :indicator-dots="true" :autoplay="false">
|
||||
<swiper-item v-for="(image, index) in product.images" :key="index">
|
||||
<image :src="image" class="product-image" mode="aspectFit" />
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="image-indicator">{{ currentImageIndex + 1 }} / {{ product.images.length }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品基本信息 -->
|
||||
<view class="product-info">
|
||||
<view class="price-section">
|
||||
<text class="current-price">¥{{ product.price }}</text>
|
||||
<text v-if="product.original_price" class="original-price">¥{{ product.original_price }}</text>
|
||||
</view>
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="sales-info">已售{{ product.sales }}件 · 库存{{ product.stock }}件</text>
|
||||
</view>
|
||||
|
||||
<!-- 店铺信息 -->
|
||||
<view class="shop-info" @click="goToShop">
|
||||
<image :src="merchant.shop_logo || '/static/default-shop.png'" class="shop-logo" />
|
||||
<view class="shop-details">
|
||||
<text class="shop-name">{{ merchant.shop_name }}</text>
|
||||
<view class="shop-rating">
|
||||
<text class="rating-text">评分: {{ merchant.rating.toFixed(1) }}</text>
|
||||
<text class="sales-text">销量: {{ merchant.total_sales }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="enter-shop">进店 ></text>
|
||||
</view>
|
||||
|
||||
<!-- 规格选择 -->
|
||||
<view class="spec-section" @click="showSpecModal">
|
||||
<text class="spec-title">规格</text>
|
||||
<text class="spec-selected">{{ selectedSpec || '请选择规格' }}</text>
|
||||
<text class="spec-arrow">></text>
|
||||
</view>
|
||||
|
||||
<!-- 商品详情 -->
|
||||
<view class="product-description">
|
||||
<view class="section-title">商品详情</view>
|
||||
<text class="description-text">{{ product.description || '暂无详细描述' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="bottom-actions">
|
||||
<view class="action-buttons">
|
||||
<button class="cart-btn" @click="addToCart">加入购物车</button>
|
||||
<button class="buy-btn" @click="buyNow">立即购买</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 规格选择弹窗 -->
|
||||
<view v-if="showSpec" class="spec-modal" @click="hideSpecModal">
|
||||
<view class="spec-content" @click.stop>
|
||||
<view class="spec-header">
|
||||
<text class="spec-title">选择规格</text>
|
||||
<text class="close-btn" @click="hideSpecModal">×</text>
|
||||
</view>
|
||||
<view class="spec-list">
|
||||
<view v-for="sku in productSkus" :key="sku.id"
|
||||
class="spec-item"
|
||||
:class="{ active: selectedSkuId === sku.id }"
|
||||
@click="selectSku(sku)">
|
||||
<text class="spec-name">{{ getSkuSpecText(sku) }}</text>
|
||||
<text class="spec-price">¥{{ sku.price }}</text>
|
||||
<text class="spec-stock">库存{{ sku.stock }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ProductType, MerchantType, ProductSkuType } from '@/types/mall-types.uts'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
product: {
|
||||
id: '',
|
||||
merchant_id: '',
|
||||
category_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
images: [] as Array<string>,
|
||||
price: 0,
|
||||
original_price: 0,
|
||||
stock: 0,
|
||||
sales: 0,
|
||||
status: 0,
|
||||
created_at: ''
|
||||
} as ProductType,
|
||||
merchant: {
|
||||
id: '',
|
||||
user_id: '',
|
||||
shop_name: '',
|
||||
shop_logo: '',
|
||||
shop_banner: '',
|
||||
shop_description: '',
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
shop_status: 0,
|
||||
rating: 0,
|
||||
total_sales: 0,
|
||||
created_at: ''
|
||||
} as MerchantType,
|
||||
productSkus: [] as Array<ProductSkuType>,
|
||||
currentImageIndex: 0,
|
||||
showSpec: false,
|
||||
selectedSkuId: '',
|
||||
selectedSpec: '',
|
||||
quantity: 1
|
||||
}
|
||||
},
|
||||
onLoad(options: any) {
|
||||
const productId = options.productId as string
|
||||
if (productId) {
|
||||
this.loadProductDetail(productId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadProductDetail(productId: string) {
|
||||
// 模拟加载商品详情数据
|
||||
this.product = {
|
||||
id: productId,
|
||||
merchant_id: 'merchant_001',
|
||||
category_id: 'cat_001',
|
||||
name: '精选好物商品',
|
||||
description: '这是一个高品质的商品,具有优秀的性能和优美的外观设计。',
|
||||
images: [
|
||||
'/static/product1.jpg',
|
||||
'/static/product2.jpg',
|
||||
'/static/product3.jpg'
|
||||
],
|
||||
price: 199.99,
|
||||
original_price: 299.99,
|
||||
stock: 100,
|
||||
sales: 1256,
|
||||
status: 1,
|
||||
created_at: '2024-01-15'
|
||||
}
|
||||
|
||||
this.merchant = {
|
||||
id: 'merchant_001',
|
||||
user_id: 'user_001',
|
||||
shop_name: '优质好店',
|
||||
shop_logo: '/static/shop-logo.png',
|
||||
shop_banner: '/static/shop-banner.png',
|
||||
shop_description: '专注品质生活',
|
||||
contact_name: '店主小王',
|
||||
contact_phone: '13800138000',
|
||||
shop_status: 1,
|
||||
rating: 4.8,
|
||||
total_sales: 15680,
|
||||
created_at: '2023-06-01'
|
||||
}
|
||||
|
||||
this.loadProductSkus(productId)
|
||||
},
|
||||
|
||||
loadProductSkus(productId: string) {
|
||||
// 模拟加载商品SKU数据
|
||||
this.productSkus = [
|
||||
{
|
||||
id: 'sku_001',
|
||||
product_id: productId,
|
||||
sku_code: 'SKU001',
|
||||
specifications: { color: '红色', size: 'M' },
|
||||
price: 199.99,
|
||||
stock: 50,
|
||||
image_url: '/static/sku1.jpg',
|
||||
status: 1
|
||||
},
|
||||
{
|
||||
id: 'sku_002',
|
||||
product_id: productId,
|
||||
sku_code: 'SKU002',
|
||||
specifications: { color: '蓝色', size: 'L' },
|
||||
price: 219.99,
|
||||
stock: 30,
|
||||
image_url: '/static/sku2.jpg',
|
||||
status: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
showSpecModal() {
|
||||
this.showSpec = true
|
||||
},
|
||||
|
||||
hideSpecModal() {
|
||||
this.showSpec = false
|
||||
},
|
||||
|
||||
selectSku(sku: ProductSkuType) {
|
||||
this.selectedSkuId = sku.id
|
||||
this.selectedSpec = this.getSkuSpecText(sku)
|
||||
this.hideSpecModal()
|
||||
},
|
||||
|
||||
getSkuSpecText(sku: ProductSkuType): string {
|
||||
if (sku.specifications) {
|
||||
const specs: any = sku.specifications
|
||||
return Object.keys(specs).map(key => `${key}: ${specs[key]}`).join(', ')
|
||||
}
|
||||
return sku.sku_code
|
||||
},
|
||||
|
||||
addToCart() {
|
||||
if (!this.selectedSkuId) {
|
||||
uni.showToast({
|
||||
title: '请选择规格',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟添加到购物车
|
||||
uni.showToast({
|
||||
title: '已添加到购物车',
|
||||
icon: 'success'
|
||||
})
|
||||
},
|
||||
|
||||
buyNow() {
|
||||
if (!this.selectedSkuId) {
|
||||
uni.showToast({
|
||||
title: '请选择规格',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 跳转到订单确认页
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/order-confirm?productId=${this.product.id}&skuId=${this.selectedSkuId}&quantity=${this.quantity}`
|
||||
})
|
||||
},
|
||||
|
||||
goToShop() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/shop-detail?merchantId=${this.merchant.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.product-detail-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.product-images {
|
||||
position: relative;
|
||||
height: 750rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.image-swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-indicator {
|
||||
position: absolute;
|
||||
bottom: 20rpx;
|
||||
right: 20rpx;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
background-color: #fff;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.price-section {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.current-price {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #ff4444;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.original-price {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.sales-info {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.shop-info {
|
||||
background-color: #fff;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shop-logo {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 10rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.shop-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shop-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.shop-rating {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.rating-text, .sales-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-right: 30rpx;
|
||||
}
|
||||
|
||||
.enter-shop {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.spec-section {
|
||||
background-color: #fff;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spec-title {
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
width: 120rpx;
|
||||
}
|
||||
|
||||
.spec-selected {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.spec-arrow {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.product-description {
|
||||
background-color: #fff;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.cart-btn, .buy-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cart-btn {
|
||||
background-color: #ffa726;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
background-color: #ff4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.spec-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.spec-content {
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
border-radius: 20rpx 20rpx 0 0;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.spec-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
padding-bottom: 20rpx;
|
||||
border-bottom: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.spec-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
font-size: 48rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.spec-list {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 25rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.spec-item.active {
|
||||
background-color: #fff3e0;
|
||||
}
|
||||
|
||||
.spec-name {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.spec-price {
|
||||
font-size: 26rpx;
|
||||
color: #ff4444;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.spec-stock {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
width: 100rpx;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
909
pages/mall/consumer/profile.uvue
Normal file
909
pages/mall/consumer/profile.uvue
Normal file
@@ -0,0 +1,909 @@
|
||||
<!-- 消费者端 - 个人中心 -->
|
||||
<template>
|
||||
<view class="consumer-profile">
|
||||
<!-- 用户信息头部 -->
|
||||
<view class="profile-header">
|
||||
<image :src="userInfo.avatar_url || '/static/default-avatar.png'" class="user-avatar" @click="editProfile" />
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ userInfo.nickname || userInfo.phone }}</text>
|
||||
<text class="user-level">{{ getUserLevel() }}</text>
|
||||
<view class="user-stats">
|
||||
<text class="stat-item">积分: {{ userStats.points }}</text>
|
||||
<text class="stat-item">余额: ¥{{ userStats.balance }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="settings-icon" @click="goToSettings">⚙️</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单状态快捷入口 -->
|
||||
<view class="order-shortcuts">
|
||||
<view class="section-title">我的订单</view>
|
||||
<view class="order-tabs">
|
||||
<view class="order-tab" @click="goToOrders('all')">
|
||||
<text class="tab-icon">📋</text>
|
||||
<text class="tab-text">全部订单</text>
|
||||
<text v-if="orderCounts.total > 0" class="tab-badge">{{ orderCounts.total }}</text>
|
||||
</view>
|
||||
<view class="order-tab" @click="goToOrders('pending')">
|
||||
<text class="tab-icon">💰</text>
|
||||
<text class="tab-text">待支付</text>
|
||||
<text v-if="orderCounts.pending > 0" class="tab-badge">{{ orderCounts.pending }}</text>
|
||||
</view>
|
||||
<view class="order-tab" @click="goToOrders('shipped')">
|
||||
<text class="tab-icon">🚚</text>
|
||||
<text class="tab-text">待收货</text>
|
||||
<text v-if="orderCounts.shipped > 0" class="tab-badge">{{ orderCounts.shipped }}</text>
|
||||
</view>
|
||||
<view class="order-tab" @click="goToOrders('completed')">
|
||||
<text class="tab-icon">⭐</text>
|
||||
<text class="tab-text">待评价</text>
|
||||
<text v-if="orderCounts.review > 0" class="tab-badge">{{ orderCounts.review }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近订单 -->
|
||||
<view class="recent-orders">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最近订单</text>
|
||||
<text class="view-all" @click="goToOrders('all')">查看全部 ></text>
|
||||
</view>
|
||||
|
||||
<view v-if="recentOrders.length === 0" class="empty-orders">
|
||||
<text class="empty-text">暂无订单记录</text>
|
||||
<button class="start-shopping" @click="goShopping">去逛逛</button>
|
||||
</view>
|
||||
|
||||
<view v-for="order in recentOrders" :key="order.id" class="order-item" @click="viewOrderDetail(order)">
|
||||
<view class="order-header">
|
||||
<text class="order-no">订单号: {{ order.order_no }}</text>
|
||||
<text class="order-status" :class="getOrderStatusClass(order.status)">{{ getOrderStatusText(order.status) }}</text>
|
||||
</view>
|
||||
<view class="order-content">
|
||||
<image :src="getOrderMainImage(order)" class="order-image" />
|
||||
<view class="order-info">
|
||||
<text class="order-title">{{ getOrderTitle(order) }}</text>
|
||||
<text class="order-amount">¥{{ order.actual_amount }}</text>
|
||||
<text class="order-time">{{ formatTime(order.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="order-actions">
|
||||
<button v-if="order.status === 1" class="action-btn pay" @click.stop="payOrder(order)">立即支付</button>
|
||||
<button v-if="order.status === 3" class="action-btn confirm" @click.stop="confirmReceive(order)">确认收货</button>
|
||||
<button v-if="order.status === 4" class="action-btn review" @click.stop="reviewOrder(order)">评价</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的服务 -->
|
||||
<view class="my-services">
|
||||
<view class="section-title">我的服务</view>
|
||||
<view class="service-grid">
|
||||
<view class="service-item" @click="goToCoupons">
|
||||
<text class="service-icon">🎫</text>
|
||||
<text class="service-text">优惠券</text>
|
||||
<text v-if="serviceCounts.coupons > 0" class="service-badge">{{ serviceCounts.coupons }}</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToAddress">
|
||||
<text class="service-icon">📍</text>
|
||||
<text class="service-text">收货地址</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToFavorites">
|
||||
<text class="service-icon">❤️</text>
|
||||
<text class="service-text">我的收藏</text>
|
||||
<text v-if="serviceCounts.favorites > 0" class="service-badge">{{ serviceCounts.favorites }}</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToFootprint">
|
||||
<text class="service-icon">👣</text>
|
||||
<text class="service-text">浏览足迹</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToRefund">
|
||||
<text class="service-icon">🔄</text>
|
||||
<text class="service-text">退款/售后</text>
|
||||
</view>
|
||||
<view class="service-item" @click="contactService">
|
||||
<text class="service-icon">💬</text>
|
||||
<text class="service-text">在线客服</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToMySubscriptions">
|
||||
<text class="service-icon">🧩</text>
|
||||
<text class="service-text">我的订阅</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToSubscriptions">
|
||||
<text class="service-icon">🧩</text>
|
||||
<text class="service-text">软件订阅</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消费统计 -->
|
||||
<view class="consumption-stats">
|
||||
<view class="section-title">消费统计</view>
|
||||
<view class="stats-period">
|
||||
<text v-for="period in statsPeriods" :key="period.key"
|
||||
class="period-tab"
|
||||
:class="{ active: activeStatsPeriod === period.key }"
|
||||
@click="switchStatsPeriod(period.key)">{{ period.label }}</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-content">
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">¥{{ currentStats.total_amount }}</text>
|
||||
<text class="stat-label">总消费</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ currentStats.order_count }}</text>
|
||||
<text class="stat-label">订单数</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">¥{{ currentStats.avg_amount }}</text>
|
||||
<text class="stat-label">平均消费</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ currentStats.save_amount }}</text>
|
||||
<text class="stat-label">节省金额</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 账户安全 -->
|
||||
<view class="account-security">
|
||||
<view class="section-title">账户安全</view>
|
||||
<view class="security-items">
|
||||
<view class="security-item" @click="changePassword">
|
||||
<text class="security-icon">🔒</text>
|
||||
<text class="security-text">修改密码</text>
|
||||
<text class="security-arrow">></text>
|
||||
</view>
|
||||
<view class="security-item" @click="bindPhone">
|
||||
<text class="security-icon">📱</text>
|
||||
<text class="security-text">手机绑定</text>
|
||||
<view class="security-status">
|
||||
<text class="status-text" :class="{ bound: userInfo.phone }">{{ userInfo.phone ? '已绑定' : '未绑定' }}</text>
|
||||
<text class="security-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="security-item" @click="bindEmail">
|
||||
<text class="security-icon">📧</text>
|
||||
<text class="security-text">邮箱绑定</text>
|
||||
<view class="security-status">
|
||||
<text class="status-text" :class="{ bound: userInfo.email }">{{ userInfo.email ? '已绑定' : '未绑定' }}</text>
|
||||
<text class="security-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { UserType, OrderType } from '@/types/mall-types.uts'
|
||||
|
||||
type UserStatsType = {
|
||||
points: number
|
||||
balance: number
|
||||
level: number
|
||||
}
|
||||
|
||||
type OrderCountsType = {
|
||||
total: number
|
||||
pending: number
|
||||
shipped: number
|
||||
review: number
|
||||
}
|
||||
|
||||
type ServiceCountsType = {
|
||||
coupons: number
|
||||
favorites: number
|
||||
}
|
||||
|
||||
type ConsumptionStatsType = {
|
||||
total_amount: number
|
||||
order_count: number
|
||||
avg_amount: number
|
||||
save_amount: number
|
||||
}
|
||||
|
||||
type StatsPeriodType = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
userInfo: {
|
||||
id: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
nickname: '',
|
||||
avatar_url: '',
|
||||
gender: 0,
|
||||
user_type: 0,
|
||||
status: 0,
|
||||
created_at: ''
|
||||
} as UserType,
|
||||
userStats: {
|
||||
points: 0,
|
||||
balance: 0,
|
||||
level: 1
|
||||
} as UserStatsType,
|
||||
orderCounts: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
shipped: 0,
|
||||
review: 0
|
||||
} as OrderCountsType,
|
||||
serviceCounts: {
|
||||
coupons: 0,
|
||||
favorites: 0
|
||||
} as ServiceCountsType,
|
||||
recentOrders: [] as Array<OrderType>,
|
||||
statsPeriods: [
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'quarter', label: '本季度' },
|
||||
{ key: 'year', label: '本年' },
|
||||
{ key: 'all', label: '全部' }
|
||||
] as Array<StatsPeriodType>,
|
||||
activeStatsPeriod: 'month',
|
||||
currentStats: {
|
||||
total_amount: 0,
|
||||
order_count: 0,
|
||||
avg_amount: 0,
|
||||
save_amount: 0
|
||||
} as ConsumptionStatsType
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.loadUserProfile()
|
||||
},
|
||||
onShow() {
|
||||
this.refreshData()
|
||||
},
|
||||
methods: {
|
||||
loadUserProfile() {
|
||||
// 模拟加载用户信息
|
||||
this.userInfo = {
|
||||
id: 'user_001',
|
||||
phone: '13800138000',
|
||||
email: 'user@example.com',
|
||||
nickname: '张三',
|
||||
avatar_url: '/static/avatar1.jpg',
|
||||
gender: 1,
|
||||
user_type: 1,
|
||||
status: 1,
|
||||
created_at: '2023-06-15T10:30:00'
|
||||
}
|
||||
|
||||
this.userStats = {
|
||||
points: 1580,
|
||||
balance: 268.50,
|
||||
level: 3
|
||||
}
|
||||
|
||||
this.orderCounts = {
|
||||
total: 23,
|
||||
pending: 2,
|
||||
shipped: 1,
|
||||
review: 3
|
||||
}
|
||||
|
||||
this.serviceCounts = {
|
||||
coupons: 5,
|
||||
favorites: 12
|
||||
}
|
||||
|
||||
this.recentOrders = [
|
||||
{
|
||||
id: 'order_001',
|
||||
order_no: 'ORD202401150001',
|
||||
user_id: 'user_001',
|
||||
merchant_id: 'merchant_001',
|
||||
status: 3,
|
||||
total_amount: 299.98,
|
||||
discount_amount: 30.00,
|
||||
delivery_fee: 8.00,
|
||||
actual_amount: 277.98,
|
||||
payment_method: 1,
|
||||
payment_status: 1,
|
||||
delivery_address: {},
|
||||
created_at: '2024-01-15T14:30:00'
|
||||
},
|
||||
{
|
||||
id: 'order_002',
|
||||
order_no: 'ORD202401140002',
|
||||
user_id: 'user_001',
|
||||
merchant_id: 'merchant_002',
|
||||
status: 4,
|
||||
total_amount: 158.00,
|
||||
discount_amount: 0,
|
||||
delivery_fee: 6.00,
|
||||
actual_amount: 164.00,
|
||||
payment_method: 1,
|
||||
payment_status: 1,
|
||||
delivery_address: {},
|
||||
created_at: '2024-01-14T09:20:00'
|
||||
}
|
||||
]
|
||||
|
||||
this.loadConsumptionStats()
|
||||
},
|
||||
|
||||
loadConsumptionStats() {
|
||||
// 模拟加载消费统计数据
|
||||
const statsData: Record<string, ConsumptionStatsType> = {
|
||||
month: {
|
||||
total_amount: 1280.50,
|
||||
order_count: 8,
|
||||
avg_amount: 160.06,
|
||||
save_amount: 85.20
|
||||
},
|
||||
quarter: {
|
||||
total_amount: 3680.80,
|
||||
order_count: 18,
|
||||
avg_amount: 204.49,
|
||||
save_amount: 256.30
|
||||
},
|
||||
year: {
|
||||
total_amount: 15680.90,
|
||||
order_count: 56,
|
||||
avg_amount: 280.02,
|
||||
save_amount: 986.50
|
||||
},
|
||||
all: {
|
||||
total_amount: 25680.50,
|
||||
order_count: 89,
|
||||
avg_amount: 288.55,
|
||||
save_amount: 1580.20
|
||||
}
|
||||
}
|
||||
|
||||
this.currentStats = statsData[this.activeStatsPeriod]
|
||||
},
|
||||
|
||||
refreshData() {
|
||||
// 刷新页面数据
|
||||
this.loadUserProfile()
|
||||
},
|
||||
|
||||
getUserLevel(): string {
|
||||
const levels = ['新手', '铜牌会员', '银牌会员', '金牌会员', '钻石会员']
|
||||
return levels[this.userStats.level] || '新手'
|
||||
},
|
||||
|
||||
getOrderStatusText(status: number): string {
|
||||
const statusTexts = ['异常', '待支付', '待发货', '待收货', '已完成', '已取消']
|
||||
return statusTexts[status] || '未知'
|
||||
},
|
||||
|
||||
getOrderStatusClass(status: number): string {
|
||||
const statusClasses = ['error', 'pending', 'processing', 'shipping', 'completed', 'cancelled']
|
||||
return statusClasses[status] || 'error'
|
||||
},
|
||||
|
||||
getOrderMainImage(order: OrderType): string {
|
||||
// 模拟获取订单主图
|
||||
return '/static/product1.jpg'
|
||||
},
|
||||
|
||||
getOrderTitle(order: OrderType): string {
|
||||
// 模拟获取订单标题
|
||||
return '精选商品等多件商品'
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) {
|
||||
return '今天'
|
||||
} else if (days === 1) {
|
||||
return '昨天'
|
||||
} else {
|
||||
return `${days}天前`
|
||||
}
|
||||
},
|
||||
|
||||
switchStatsPeriod(period: string) {
|
||||
this.activeStatsPeriod = period
|
||||
this.loadConsumptionStats()
|
||||
},
|
||||
|
||||
editProfile() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/edit-profile'
|
||||
})
|
||||
},
|
||||
|
||||
goToSettings() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/settings'
|
||||
})
|
||||
},
|
||||
|
||||
goToOrders(type: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/orders?type=${type}`
|
||||
})
|
||||
},
|
||||
|
||||
goShopping() {
|
||||
uni.switchTab({
|
||||
url: '/pages/mall/consumer/index'
|
||||
})
|
||||
},
|
||||
|
||||
viewOrderDetail(order: OrderType) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/order-detail?orderId=${order.id}`
|
||||
})
|
||||
},
|
||||
|
||||
payOrder(order: OrderType) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/payment?orderId=${order.id}`
|
||||
})
|
||||
},
|
||||
|
||||
confirmReceive(order: OrderType) {
|
||||
uni.showModal({
|
||||
title: '确认收货',
|
||||
content: '确认已收到商品吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showToast({
|
||||
title: '确认收货成功',
|
||||
icon: 'success'
|
||||
})
|
||||
this.refreshData()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
reviewOrder(order: OrderType) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/review?orderId=${order.id}`
|
||||
})
|
||||
},
|
||||
|
||||
goToCoupons() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/coupons'
|
||||
})
|
||||
},
|
||||
|
||||
goToAddress() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/address'
|
||||
})
|
||||
},
|
||||
|
||||
goToFavorites() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/favorites'
|
||||
})
|
||||
},
|
||||
|
||||
goToFootprint() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/footprint'
|
||||
})
|
||||
},
|
||||
|
||||
goToRefund() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/refund'
|
||||
})
|
||||
},
|
||||
|
||||
contactService() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/service/chat'
|
||||
})
|
||||
},
|
||||
goToMySubscriptions() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/subscription/my-subscriptions'
|
||||
})
|
||||
},
|
||||
goToSubscriptions() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/subscription/plan-list'
|
||||
})
|
||||
},
|
||||
|
||||
changePassword() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/change-password'
|
||||
})
|
||||
},
|
||||
|
||||
bindPhone() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/bind-phone'
|
||||
})
|
||||
},
|
||||
|
||||
bindEmail() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/bind-email'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.consumer-profile {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 60rpx 30rpx 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
margin-right: 30rpx;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.user-level {
|
||||
font-size: 24rpx;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 15rpx;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
gap: 30rpx;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
font-size: 32rpx;
|
||||
padding: 10rpx;
|
||||
}
|
||||
|
||||
.order-shortcuts, .recent-orders, .my-services, .consumption-stats, .account-security {
|
||||
background-color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 25rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25rpx;
|
||||
}
|
||||
|
||||
.view-all {
|
||||
font-size: 24rpx;
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.order-tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.order-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 40rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: 20rpx;
|
||||
background-color: #ff4444;
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 8rpx;
|
||||
border-radius: 10rpx;
|
||||
min-width: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-orders {
|
||||
text-align: center;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.start-shopping {
|
||||
background-color: #007aff;
|
||||
color: #fff;
|
||||
padding: 20rpx 40rpx;
|
||||
border-radius: 25rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
padding: 25rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.order-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
font-size: 24rpx;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 10rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.order-status.pending {
|
||||
background-color: #ffa726;
|
||||
}
|
||||
|
||||
.order-status.processing {
|
||||
background-color: #2196f3;
|
||||
}
|
||||
|
||||
.order-status.shipping {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.order-status.completed {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.order-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.order-image {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.order-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.order-title {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
font-size: 28rpx;
|
||||
color: #ff4444;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.order-time {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12rpx 25rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 24rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn.pay {
|
||||
background-color: #ff4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.confirm {
|
||||
background-color: #4caf50;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.review {
|
||||
background-color: #ffa726;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 30rpx;
|
||||
}
|
||||
|
||||
.service-item {
|
||||
width: 30%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
font-size: 48rpx;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.service-text {
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.service-badge {
|
||||
position: absolute;
|
||||
top: -5rpx;
|
||||
right: 10rpx;
|
||||
background-color: #ff4444;
|
||||
color: #fff;
|
||||
font-size: 18rpx;
|
||||
padding: 4rpx 6rpx;
|
||||
border-radius: 8rpx;
|
||||
min-width: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-period {
|
||||
display: flex;
|
||||
gap: 30rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.period-tab {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.period-tab.active {
|
||||
background-color: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stats-content {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 30rpx 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.security-items {
|
||||
margin-top: 25rpx;
|
||||
}
|
||||
|
||||
.security-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 25rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.security-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.security-icon {
|
||||
font-size: 32rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.security-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.security-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.status-text.bound {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.security-arrow {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
16
pages/mall/consumer/subscription/README.md
Normal file
16
pages/mall/consumer/subscription/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
软件订阅(consumer)
|
||||
|
||||
入口:
|
||||
- 用户中心 -> 软件订阅
|
||||
|
||||
页面:
|
||||
- plan-list.uvue:展示可用订阅方案(ml_subscription_plans)
|
||||
- plan-detail.uvue:展示某个订阅方案详情
|
||||
- subscribe-checkout.uvue:确认支付并创建订阅(写入 ml_user_subscriptions)
|
||||
|
||||
依赖表(示例名称,可按实际后端调整):
|
||||
- ml_subscription_plans(id, plan_code, name, description, features jsonb, price numeric, currency text, billing_period text, trial_days int, is_active bool, sort_order int, created_at, updated_at)
|
||||
- ml_user_subscriptions(id, user_id, plan_id, status text, start_date timestamptz, end_date timestamptz, next_billing_date timestamptz, auto_renew bool, cancel_at_period_end bool, metadata jsonb, created_at, updated_at)
|
||||
|
||||
注意:
|
||||
- 本实现使用 uni-app-x 兼容组件与 supaClient。实际支付请替换为你们的支付网关,并在后端完成对账与签名校验。
|
||||
154
pages/mall/consumer/subscription/my-subscriptions.uvue
Normal file
154
pages/mall/consumer/subscription/my-subscriptions.uvue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<view class="my-subs">
|
||||
<view class="header">
|
||||
<text class="title">我的订阅</text>
|
||||
<button class="ghost" @click="goPlanList">订阅更多</button>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-else-if="items.length === 0" class="empty">暂无订阅</view>
|
||||
|
||||
<view v-else class="list">
|
||||
<view class="card" v-for="s in items" :key="s['id']">
|
||||
<view class="row between">
|
||||
<text class="name">{{ s['plan']?.['name'] || '订阅' }}</text>
|
||||
<text class="status" :class="'st-' + (s['status'] || 'active')">{{ statusText(s['status'] as string) }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">周期</text>
|
||||
<text class="value">{{ (s['plan']?.['billing_period'] === 'yearly') ? '年付' : '月付' }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">价格</text>
|
||||
<text class="value">¥{{ s['plan']?.['price'] }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">开始</text>
|
||||
<text class="value">{{ fmt(s['start_date'] as string) }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">下次扣费</text>
|
||||
<text class="value">{{ fmt(s['next_billing_date'] as string) }}</text>
|
||||
</view>
|
||||
<view class="actions">
|
||||
<label class="toggle">
|
||||
<switch :checked="!!s['auto_renew']" @change="e => toggleAutoRenew(s, e.detail.value as boolean)" />
|
||||
<text>自动续费</text>
|
||||
</label>
|
||||
<button class="danger" @click="cancelAtPeriodEnd(s)" :disabled="(s['status'] as string) !== 'active'">到期取消</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
import { getCurrentUserId } from '@/utils/store.uts'
|
||||
|
||||
const loading = ref<boolean>(true)
|
||||
const items = ref<Array<UTSJSONObject>>([])
|
||||
|
||||
const fmt = (s: string | null): string => {
|
||||
if (s == null || s.length === 0) return '-'
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return '-'
|
||||
return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2,'0')}-${d.getDate().toString().padStart(2,'0')}`
|
||||
}
|
||||
|
||||
const statusText = (st: string): string => {
|
||||
const map: UTSJSONObject = { trial: '试用', active: '生效', past_due: '逾期', canceled: '已取消', expired: '已过期' } as UTSJSONObject
|
||||
return (map[st] as string) || st
|
||||
}
|
||||
|
||||
const loadSubs = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const userId = getCurrentUserId()
|
||||
if (userId == null || userId.length === 0) {
|
||||
items.value = []
|
||||
return
|
||||
}
|
||||
// join: ml_user_subscriptions + ml_subscription_plans
|
||||
const res = await supaClient
|
||||
.from('ml_user_subscriptions')
|
||||
.select('*, plan:ml_subscription_plans(*)', {})
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.execute()
|
||||
items.value = Array.isArray(res.data) ? (res.data as Array<UTSJSONObject>) : []
|
||||
} catch (e) {
|
||||
console.error('加载订阅失败:', e)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAutoRenew = async (s: UTSJSONObject, value: boolean) => {
|
||||
try {
|
||||
const id = (s['id'] ?? '') as string
|
||||
const res = await supaClient
|
||||
.from('ml_user_subscriptions')
|
||||
.update({ auto_renew: value })
|
||||
.eq('id', id)
|
||||
.execute()
|
||||
if (res.error != null) throw new Error(res.error?.message ?? '未知错误')
|
||||
s['auto_renew'] = value
|
||||
uni.showToast({ title: value ? '已开启自动续费' : '已关闭自动续费', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.error('更新自动续费失败:', e)
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const cancelAtPeriodEnd = async (s: UTSJSONObject) => {
|
||||
try {
|
||||
const id = (s['id'] ?? '') as string
|
||||
const res = await supaClient
|
||||
.from('ml_user_subscriptions')
|
||||
.update({ cancel_at_period_end: true })
|
||||
.eq('id', id)
|
||||
.execute()
|
||||
if (res.error != null) throw new Error(res.error?.message ?? '未知错误')
|
||||
s['cancel_at_period_end'] = true
|
||||
s['status'] = 'active' // 保持到期前仍为active
|
||||
uni.showToast({ title: '已设置到期取消', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.error('设置到期取消失败:', e)
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const goPlanList = () => {
|
||||
uni.navigateTo({ url: '/pages/mall/consumer/subscription/plan-list' })
|
||||
}
|
||||
|
||||
onMounted(loadSubs)
|
||||
onShow(loadSubs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-subs { padding: 12px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.title { font-size: 18px; font-weight: 600; }
|
||||
.ghost { background: #fff; border: 1px solid #ddd; color: #333; border-radius: 6px; padding: 6px 10px; }
|
||||
.loading, .empty { padding: 24px; text-align: center; color: #888; }
|
||||
.list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.row { display: flex; gap: 8px; padding: 4px 0; }
|
||||
.between { justify-content: space-between; align-items: center; }
|
||||
.name { font-size: 16px; font-weight: 600; }
|
||||
.status { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: #eee; color: #333; }
|
||||
.st-trial { background: #e6f7ff; color: #1677ff; }
|
||||
.st-active { background: #f6ffed; color: #52c41a; }
|
||||
.st-past_due { background: #fff7e6; color: #fa8c16; }
|
||||
.st-canceled, .st-expired { background: #fff1f0; color: #f5222d; }
|
||||
.label { color: #666; width: 80px; }
|
||||
.value { color: #111; flex: 1; }
|
||||
.actions { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; }
|
||||
.toggle { display: flex; align-items: center; gap: 6px; }
|
||||
.danger { background: #f5222d; color: #fff; border-radius: 6px; padding: 6px 10px; }
|
||||
</style>
|
||||
112
pages/mall/consumer/subscription/plan-detail.uvue
Normal file
112
pages/mall/consumer/subscription/plan-detail.uvue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<view class="plan-detail">
|
||||
<view class="header">
|
||||
<text class="title">订阅方案详情</text>
|
||||
</view>
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-else-if="plan == null" class="empty">未找到该方案</view>
|
||||
<view v-else class="card">
|
||||
<text class="name">{{ plan['name'] }}</text>
|
||||
<text class="desc">{{ plan['description'] || '—' }}</text>
|
||||
|
||||
<view class="price-row">
|
||||
<text class="price">¥{{ plan['price'] }}</text>
|
||||
<text class="period">/{{ plan['billing_period'] === 'yearly' ? '年' : '月' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="features">
|
||||
<text class="f-title">包含功能</text>
|
||||
<view class="f-list">
|
||||
<text class="f-item" v-for="(v,k) in toFeatureArray(plan['features'])" :key="k">• {{ v }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="actions">
|
||||
<button class="primary" @click="toCheckout">订阅此方案</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
const planId = ref<string>('')
|
||||
const loading = ref<boolean>(true)
|
||||
const plan = ref<UTSJSONObject | null>(null)
|
||||
|
||||
onLoad((opts: OnLoadOptions) => {
|
||||
planId.value = (opts['id'] ?? '') as string
|
||||
})
|
||||
|
||||
const toFeatureArray = (features: any): Array<string> => {
|
||||
const arr: Array<string> = []
|
||||
if (features == null) return arr
|
||||
if (features instanceof UTSJSONObject) {
|
||||
const keys = Object.keys(features as any)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i]
|
||||
const v = (features as UTSJSONObject)[k]
|
||||
const vs = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
arr.push(vs)
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const loadPlan = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
if (planId.value.length === 0) return
|
||||
const res = await supaClient
|
||||
.from('ml_subscription_plans')
|
||||
.select('*', {})
|
||||
.eq('id', planId.value)
|
||||
.single()
|
||||
.execute()
|
||||
if (res != null && res.error == null) {
|
||||
// single() 风格有些客户端会返回对象数组,这里兼容
|
||||
if (Array.isArray(res.data)) {
|
||||
plan.value = (res.data as Array<UTSJSONObject>)[0] ?? null
|
||||
} else {
|
||||
plan.value = res.data as UTSJSONObject
|
||||
}
|
||||
} else {
|
||||
plan.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载方案失败:', e)
|
||||
plan.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toCheckout = () => {
|
||||
if (plan.value == null) return
|
||||
const id = (plan.value['id'] ?? '') as string
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
|
||||
}
|
||||
|
||||
onMounted(loadPlan)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plan-detail { padding: 12px; }
|
||||
.header { margin-bottom: 8px; }
|
||||
.title { font-size: 18px; font-weight: 600; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.name { font-size: 16px; font-weight: 600; }
|
||||
.desc { color: #666; margin: 6px 0; }
|
||||
.price-row { display: flex; align-items: baseline; gap: 4px; margin: 8px 0; }
|
||||
.price { font-size: 22px; color: #ff4d4f; font-weight: 700; }
|
||||
.period { color: #999; }
|
||||
.features { margin-top: 8px; }
|
||||
.f-title { font-weight: 600; margin-bottom: 4px; }
|
||||
.f-list { display: flex; flex-direction: column; gap: 2px; color: #444; }
|
||||
.actions { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
|
||||
.loading, .empty { padding: 24px; text-align: center; color: #888; }
|
||||
</style>
|
||||
110
pages/mall/consumer/subscription/plan-list.uvue
Normal file
110
pages/mall/consumer/subscription/plan-list.uvue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<view class="sub-plan-list">
|
||||
<view class="header">
|
||||
<text class="title">软件订阅</text>
|
||||
</view>
|
||||
|
||||
<view class="plan-container" v-if="!loading && plans.length > 0">
|
||||
<view class="plan-card" v-for="p in plans" :key="p['id']" @click="goPlanDetail(p)">
|
||||
<view class="plan-header">
|
||||
<text class="plan-name">{{ p['name'] }}</text>
|
||||
<text v-if="p['billing_period'] === 'yearly'" class="badge">年付优惠</text>
|
||||
</view>
|
||||
<text class="plan-desc">{{ p['description'] || '适用于大部分使用场景' }}</text>
|
||||
<view class="price-row">
|
||||
<text class="price">¥{{ p['price'] }}</text>
|
||||
<text class="period">/{{ p['billing_period'] === 'yearly' ? '年' : '月' }}</text>
|
||||
</view>
|
||||
<view class="feature-list">
|
||||
<text class="feature-item" v-for="(v,k) in toFeatureArray(p['features'])" :key="k">• {{ v }}</text>
|
||||
</view>
|
||||
<view class="actions">
|
||||
<button class="primary" @click.stop="toCheckout(p)">立即订阅</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && plans.length === 0" class="empty">
|
||||
<text>暂无可用订阅方案</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading"><text>加载中...</text></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
const loading = ref<boolean>(true)
|
||||
const plans = ref<Array<UTSJSONObject>>([])
|
||||
|
||||
const toFeatureArray = (features: any): Array<string> => {
|
||||
const arr: Array<string> = []
|
||||
if (features == null) return arr
|
||||
if (features instanceof UTSJSONObject) {
|
||||
const keys = Object.keys(features as any)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i]
|
||||
const v = (features as UTSJSONObject)[k]
|
||||
const vs = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
arr.push(vs)
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const loadPlans = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await supaClient
|
||||
.from('ml_subscription_plans')
|
||||
.select('*', {})
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true })
|
||||
.execute()
|
||||
if (Array.isArray(res.data)) {
|
||||
plans.value = res.data as Array<UTSJSONObject>
|
||||
} else {
|
||||
plans.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载订阅方案失败:', e)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goPlanDetail = (p: UTSJSONObject) => {
|
||||
const id = (p['id'] ?? '') as string
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/subscription/plan-detail?id=${id}` })
|
||||
}
|
||||
|
||||
const toCheckout = (p: UTSJSONObject) => {
|
||||
const id = (p['id'] ?? '') as string
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
|
||||
}
|
||||
|
||||
onMounted(loadPlans)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sub-plan-list { padding: 12px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.title { font-size: 18px; font-weight: 600; }
|
||||
.plan-container { display: flex; flex-direction: column; gap: 12px; }
|
||||
.plan-card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.plan-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.plan-name { font-size: 16px; font-weight: 600; }
|
||||
.badge { font-size: 12px; color: #fff; background: #3cc51f; border-radius: 999px; padding: 2px 8px; }
|
||||
.plan-desc { color: #666; margin: 6px 0; line-height: 1.5; }
|
||||
.price-row { display: flex; align-items: baseline; gap: 4px; margin: 6px 0; }
|
||||
.price { font-size: 22px; color: #ff4d4f; font-weight: 700; }
|
||||
.period { color: #999; }
|
||||
.feature-list { color: #444; display: flex; flex-direction: column; gap: 2px; margin: 6px 0; }
|
||||
.feature-item { font-size: 12px; color: #555; }
|
||||
.actions { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
|
||||
.loading, .empty { padding: 24px; text-align: center; color: #888; }
|
||||
</style>
|
||||
164
pages/mall/consumer/subscription/subscribe-checkout.uvue
Normal file
164
pages/mall/consumer/subscription/subscribe-checkout.uvue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<view class="subscribe-checkout">
|
||||
<view class="header">
|
||||
<text class="title">确认订阅</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-else-if="plan == null" class="empty">未找到订阅方案</view>
|
||||
<view v-else class="card">
|
||||
<view class="row">
|
||||
<text class="label">方案</text>
|
||||
<text class="value">{{ plan['name'] }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">价格</text>
|
||||
<text class="value">¥{{ plan['price'] }} / {{ plan['billing_period'] === 'yearly' ? '年' : '月' }}</text>
|
||||
</view>
|
||||
<view class="row" v-if="trialDays > 0">
|
||||
<text class="label">试用期</text>
|
||||
<text class="value">{{ trialDays }} 天</text>
|
||||
</view>
|
||||
|
||||
<view class="section-title">支付方式</view>
|
||||
<view class="pay-methods">
|
||||
<label class="pay-item" @click="selPay(1)">
|
||||
<radio :checked="payMethod === 1"></radio>
|
||||
<text>微信支付</text>
|
||||
</label>
|
||||
<label class="pay-item" @click="selPay(2)">
|
||||
<radio :checked="payMethod === 2"></radio>
|
||||
<text>支付宝</text>
|
||||
</label>
|
||||
<label class="pay-item" @click="selPay(4)">
|
||||
<radio :checked="payMethod === 4"></radio>
|
||||
<text>余额</text>
|
||||
</label>
|
||||
</view>
|
||||
|
||||
<view class="actions">
|
||||
<button class="primary" :disabled="submitting" @click="confirmSubscribe">确认并支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
import { PAYMENT_METHOD } from '@/types/mall-types.uts'
|
||||
|
||||
const planId = ref<string>('')
|
||||
const loading = ref<boolean>(true)
|
||||
const plan = ref<UTSJSONObject | null>(null)
|
||||
const payMethod = ref<number>(PAYMENT_METHOD.WECHAT)
|
||||
const trialDays = ref<number>(0)
|
||||
const submitting = ref<boolean>(false)
|
||||
|
||||
onLoad(async (opts: OnLoadOptions) => {
|
||||
planId.value = (opts['planId'] ?? '') as string
|
||||
await loadPlan()
|
||||
})
|
||||
|
||||
const loadPlan = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await supaClient
|
||||
.from('ml_subscription_plans')
|
||||
.select('*', {})
|
||||
.eq('id', planId.value)
|
||||
.single()
|
||||
.execute()
|
||||
if (res != null && res.error == null) {
|
||||
if (Array.isArray(res.data)) {
|
||||
plan.value = (res.data as Array<UTSJSONObject>)[0] ?? null
|
||||
} else {
|
||||
plan.value = res.data as UTSJSONObject
|
||||
}
|
||||
trialDays.value = (plan.value?.['trial_days'] ?? 0) as number
|
||||
} else {
|
||||
plan.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载方案失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selPay = (v: number) => { payMethod.value = v }
|
||||
|
||||
// 获取当前用户ID(按现有store实现替换)
|
||||
const getCurrentUserId = (): string => {
|
||||
try { return (uni.getStorageSync('current_user_id') as string) || '' } catch { return '' }
|
||||
}
|
||||
|
||||
const confirmSubscribe = async () => {
|
||||
if (plan.value == null) return
|
||||
const userId = getCurrentUserId()
|
||||
if (userId.length === 0) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 1) 创建订单或支付意图(此处简化为直接创建订阅记录)
|
||||
const now = new Date()
|
||||
const start = now.toISOString()
|
||||
// 简单计算下个扣费日
|
||||
let nextBilling: string | null = null
|
||||
if ((plan.value?.['billing_period'] ?? 'monthly') === 'yearly') {
|
||||
nextBilling = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()).toISOString()
|
||||
} else {
|
||||
nextBilling = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()).toISOString()
|
||||
}
|
||||
const body = {
|
||||
user_id: userId,
|
||||
plan_id: plan.value['id'],
|
||||
status: 'active',
|
||||
start_date: start,
|
||||
end_date: null,
|
||||
next_billing_date: nextBilling,
|
||||
auto_renew: true,
|
||||
metadata: { pay_method: payMethod.value }
|
||||
}
|
||||
const ins = await supaClient
|
||||
.from('ml_user_subscriptions')
|
||||
.insert(body)
|
||||
.single?.()
|
||||
.execute()
|
||||
if (ins != null && ins.error == null) {
|
||||
uni.showToast({ title: '订阅成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pages/mall/consumer/profile' })
|
||||
}, 600)
|
||||
} else {
|
||||
uni.showToast({ title: ins?.error?.message || '订阅失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('订阅失败:', e)
|
||||
uni.showToast({ title: '订阅失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subscribe-checkout { padding: 12px; }
|
||||
.header { margin-bottom: 8px; }
|
||||
.title { font-size: 18px; font-weight: 600; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.row:last-child { border-bottom: none; }
|
||||
.label { color: #666; }
|
||||
.value { color: #111; font-weight: 600; }
|
||||
.section-title { margin-top: 12px; font-weight: 600; }
|
||||
.pay-methods { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; }
|
||||
.pay-item { display: flex; align-items: center; gap: 8px; }
|
||||
.actions { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
|
||||
.loading, .empty { padding: 24px; text-align: center; color: #888; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user