Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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。实际支付请替换为你们的支付网关并在后端完成对账与签名校验。

View 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>

View 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>

View 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>

View 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>