增加推销模式
This commit is contained in:
383
pages/mall/consumer/balance/index.uvue
Normal file
383
pages/mall/consumer/balance/index.uvue
Normal file
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<scroll-view class="balance-page" scroll-y>
|
||||
<view class="balance-header">
|
||||
<view class="balance-info">
|
||||
<text class="balance-label">账户余额(元)</text>
|
||||
<text class="balance-value">{{ balance }}</text>
|
||||
</view>
|
||||
<view class="balance-tips">
|
||||
<text class="tips-text">余额来源于免单奖励,请联系商家微信提现</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stats-section">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ totalEarned }}</text>
|
||||
<text class="stat-label">累计获得</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ totalWithdrawn }}</text>
|
||||
<text class="stat-label">已提现</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="records-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">余额明细</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="records.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无余额记录</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="record-list">
|
||||
<view class="record-item" v-for="record in records" :key="record.id">
|
||||
<view class="record-left">
|
||||
<text class="record-type">{{ getTypeText(record.type) }}</text>
|
||||
<text class="record-time">{{ formatTime(record.created_at) }}</text>
|
||||
</view>
|
||||
<view class="record-right">
|
||||
<text class="record-amount" :class="record.amount > 0 ? 'positive' : 'negative'">
|
||||
{{ record.amount > 0 ? '+' : '' }}{{ record.amount }}
|
||||
</text>
|
||||
<text class="record-balance">余额: {{ record.balance_after }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="withdraw-section">
|
||||
<button class="withdraw-btn" @click="showWithdrawTips">
|
||||
<text class="btn-text">申请提现</text>
|
||||
</button>
|
||||
<text class="withdraw-tip">提现请联系商家微信处理</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type BalanceRecord = {
|
||||
id: string
|
||||
type: string
|
||||
amount: number
|
||||
balance_before: number
|
||||
balance_after: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const balance = ref<number>(0)
|
||||
const totalEarned = ref<number>(0)
|
||||
const totalWithdrawn = ref<number>(0)
|
||||
const records = ref<BalanceRecord[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
|
||||
const loadBalance = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getUserBalance()
|
||||
balance.value = result.getNumber('balance') ?? 0
|
||||
totalEarned.value = result.getNumber('total_earned') ?? 0
|
||||
totalWithdrawn.value = result.getNumber('total_withdrawn') ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecords = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getBalanceRecords(1, 50)
|
||||
const parsed: BalanceRecord[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
let id = ''
|
||||
let type = ''
|
||||
let amount = 0
|
||||
let balanceBefore = 0
|
||||
let balanceAfter = 0
|
||||
let description: string | null = null
|
||||
let createdAt = ''
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
id = (itemAny._getValue('id') as string) ?? ''
|
||||
type = (itemAny._getValue('type') as string) ?? ''
|
||||
amount = (itemAny._getValue('amount') as number) ?? 0
|
||||
balanceBefore = (itemAny._getValue('balance_before') as number) ?? 0
|
||||
balanceAfter = (itemAny._getValue('balance_after') as number) ?? 0
|
||||
description = itemAny._getValue('description') as string | null
|
||||
createdAt = (itemAny._getValue('created_at') as string) ?? ''
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
id,
|
||||
type,
|
||||
amount,
|
||||
balance_before: balanceBefore,
|
||||
balance_after: balanceAfter,
|
||||
description,
|
||||
created_at: createdAt
|
||||
})
|
||||
}
|
||||
|
||||
records.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载余额记录失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
loadBalance(),
|
||||
loadRecords()
|
||||
])
|
||||
}
|
||||
|
||||
const getTypeText = (type: string): string => {
|
||||
if (type === 'free_order') return '免单奖励'
|
||||
if (type === 'rebate') return '返利'
|
||||
if (type === 'withdraw') return '提现'
|
||||
if (type === 'clear') return '余额清零'
|
||||
if (type === 'manual') return '手动调整'
|
||||
return '余额变动'
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string): string => {
|
||||
if (timeStr === '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
const hh = date.getHours().toString().padStart(2, '0')
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
const showWithdrawTips = (): void => {
|
||||
uni.showModal({
|
||||
title: '提现说明',
|
||||
content: '请添加商家微信进行提现处理,商家确认后将通过微信转账给您。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.balance-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.balance-header {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.balance-tips {
|
||||
margin-top: 16px;
|
||||
padding: 8px 16px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.tips-text {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.records-section {
|
||||
background-color: white;
|
||||
padding: 0 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.record-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-type {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.record-amount {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record-amount.positive {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.record-amount.negative {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.record-balance {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.withdraw-section {
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.withdraw-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
border-radius: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.withdraw-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
373
pages/mall/consumer/doc/推销模式功能需求文档.md
Normal file
373
pages/mall/consumer/doc/推销模式功能需求文档.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# 商城推销模式功能需求文档
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
本文档描述商城消费者端的推销模式功能,包含以下三大模块:
|
||||
1. **分享免单系统** - 用户分享商品,达成条件后免单
|
||||
2. **会员等级系统** - 用户等级享受不同优惠价格
|
||||
3. **经销点返利系统** - 经销点销售返利
|
||||
|
||||
---
|
||||
|
||||
## 二、分享免单系统
|
||||
|
||||
### 2.1 功能描述
|
||||
|
||||
用户购买商品后,可分享商品链接给其他用户(二级用户)。当二级用户通过分享链接购买该商品累计达到指定数量(默认4件)时,原用户可获得免单奖励,免单金额存入账户余额。
|
||||
|
||||
### 2.2 业务流程
|
||||
|
||||
```
|
||||
用户A购买商品 → 生成分享链接 → 分享给用户B/C/D
|
||||
↓
|
||||
二级用户通过链接购买
|
||||
↓
|
||||
累计购买数量达标(4件)
|
||||
↓
|
||||
用户A获得免单奖励
|
||||
↓
|
||||
奖励金额存入余额
|
||||
↓
|
||||
商家微信返现后清零余额
|
||||
```
|
||||
|
||||
### 2.3 数据库设计
|
||||
|
||||
#### 2.3.1 分享记录表 ml_share_records
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 分享用户ID |
|
||||
| product_id | UUID | 商品ID |
|
||||
| order_id | UUID | 关联订单ID |
|
||||
| share_code | VARCHAR(20) | 分享码(唯一) |
|
||||
| required_count | INT | 需要的购买数量(默认4) |
|
||||
| current_count | INT | 当前已购买数量 |
|
||||
| status | INT | 状态:0-进行中,1-已完成,2-已失效 |
|
||||
| reward_amount | DECIMAL(10,2) | 奖励金额 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
| completed_at | TIMESTAMPTZ | 完成时间 |
|
||||
|
||||
#### 2.3.2 二级购买记录表 ml_secondary_purchases
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| share_record_id | UUID | 关联分享记录ID |
|
||||
| buyer_id | UUID | 购买用户ID |
|
||||
| order_id | UUID | 订单ID |
|
||||
| quantity | INT | 购买数量 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
#### 2.3.3 免单奖励记录表 ml_free_order_rewards
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 获得奖励的用户ID |
|
||||
| share_record_id | UUID | 关联分享记录ID |
|
||||
| amount | DECIMAL(10,2) | 奖励金额 |
|
||||
| status | INT | 状态:0-待处理,1-已发放,2-已清零 |
|
||||
| balance_before | DECIMAL(10,2) | 发放前余额 |
|
||||
| balance_after | DECIMAL(10,2) | 发放后余额 |
|
||||
| cleared_at | TIMESTAMPTZ | 清零时间 |
|
||||
| cleared_by | UUID | 清零操作人 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
### 2.4 API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| /api/share/create | POST | 创建分享记录,生成分享码 |
|
||||
| /api/share/info | GET | 获取分享详情和进度 |
|
||||
| /api/share/validate | GET | 验证分享码有效性 |
|
||||
| /api/share/my-records | GET | 获取我的分享记录列表 |
|
||||
| /api/share/rewards | GET | 获取我的免单奖励记录 |
|
||||
| /api/admin/clear-balance | POST | 商家清零用户余额(后台) |
|
||||
|
||||
### 2.5 前端页面
|
||||
|
||||
#### 2.5.1 分享弹窗(订单详情页)
|
||||
- 显示分享链接/二维码
|
||||
- 显示当前进度(X/4)
|
||||
- 分享按钮(微信好友、朋友圈)
|
||||
|
||||
#### 2.5.2 我的分享页
|
||||
- 分享记录列表
|
||||
- 每条记录显示:商品信息、进度、状态
|
||||
- 邀请好友按钮
|
||||
|
||||
#### 2.5.3 我的余额页
|
||||
- 余额显示
|
||||
- 免单奖励记录
|
||||
- 提现说明(联系商家微信)
|
||||
|
||||
---
|
||||
|
||||
## 三、会员等级系统
|
||||
|
||||
### 3.1 功能描述
|
||||
|
||||
商家可设置多个会员等级,每个等级对应不同的优惠折扣。用户可通过以下方式升级:
|
||||
1. 商家手动设置等级
|
||||
2. 累计消费金额自动升级
|
||||
|
||||
### 3.2 等级设置
|
||||
|
||||
| 等级 | 名称 | 升级条件 | 折扣 |
|
||||
|------|------|---------|------|
|
||||
| 0 | 普通会员 | 注册即可 | 无折扣 |
|
||||
| 1 | 铜牌会员 | 累计消费500元 | 98折 |
|
||||
| 2 | 银牌会员 | 累计消费2000元 | 95折 |
|
||||
| 3 | 金牌会员 | 累计消费5000元 | 92折 |
|
||||
| 4 | 钻石会员 | 累计消费10000元 | 88折 |
|
||||
| 5 | VIP会员 | 商家手动设置 | 85折 |
|
||||
|
||||
### 3.3 数据库设计
|
||||
|
||||
#### 3.3.1 会员等级配置表 ml_member_levels
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | INT | 等级ID |
|
||||
| name | VARCHAR(50) | 等级名称 |
|
||||
| min_amount | DECIMAL(10,2) | 升级最低消费金额 |
|
||||
| discount | DECIMAL(5,4) | 折扣率(0.85表示85折) |
|
||||
| icon | VARCHAR(200) | 等级图标 |
|
||||
| description | TEXT | 等级说明 |
|
||||
| sort_order | INT | 排序 |
|
||||
| status | INT | 状态:0-禁用,1-启用 |
|
||||
|
||||
#### 3.3.2 用户会员信息扩展(ml_user_profiles 扩展字段)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| member_level | INT | 当前会员等级 |
|
||||
| total_spent | DECIMAL(10,2) | 累计消费金额 |
|
||||
| level_updated_at | TIMESTAMPTZ | 等级更新时间 |
|
||||
| manual_level | BOOLEAN | 是否手动设置等级 |
|
||||
| manual_level_by | UUID | 手动设置操作人 |
|
||||
|
||||
#### 3.3.3 会员等级变更记录表 ml_member_level_logs
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 用户ID |
|
||||
| old_level | INT | 原等级 |
|
||||
| new_level | INT | 新等级 |
|
||||
| reason | VARCHAR(200) | 变更原因 |
|
||||
| operator_id | UUID | 操作人(自动则为空) |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
### 3.4 API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| /api/member/levels | GET | 获取会员等级列表 |
|
||||
| /api/member/my-info | GET | 获取我的会员信息 |
|
||||
| /api/member/upgrade-check | POST | 检查并升级会员等级 |
|
||||
| /api/admin/set-member-level | POST | 商家手动设置用户等级 |
|
||||
|
||||
### 3.5 前端页面
|
||||
|
||||
#### 3.5.1 会员中心页
|
||||
- 当前等级显示
|
||||
- 等级进度条
|
||||
- 等级权益说明
|
||||
- 升级攻略
|
||||
|
||||
#### 3.5.2 商品详情页
|
||||
- 显示会员价
|
||||
- 显示折扣信息
|
||||
|
||||
---
|
||||
|
||||
## 四、经销点返利系统
|
||||
|
||||
### 4.1 功能描述
|
||||
|
||||
商家可创建多个经销点,每个经销点有独立的推广码。经销点通过推广码产生的订单可获得返利,返利按单数计算。
|
||||
|
||||
### 4.2 返利规则
|
||||
|
||||
| 单数范围 | 返利金额/单 |
|
||||
|---------|------------|
|
||||
| 1-10单 | 2元/单 |
|
||||
| 11-50单 | 3元/单 |
|
||||
| 51-100单 | 4元/单 |
|
||||
| 100单以上 | 5元/单 |
|
||||
|
||||
### 4.3 数据库设计
|
||||
|
||||
#### 4.3.1 经销点表 ml_distribution_points
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| name | VARCHAR(100) | 经销点名称 |
|
||||
| contact_name | VARCHAR(50) | 联系人 |
|
||||
| contact_phone | VARCHAR(20) | 联系电话 |
|
||||
| address | VARCHAR(200) | 地址 |
|
||||
| invite_code | VARCHAR(20) | 邀请码(唯一) |
|
||||
| owner_id | UUID | 负责人用户ID |
|
||||
| status | INT | 状态:0-禁用,1-启用 |
|
||||
| total_orders | INT | 累计订单数 |
|
||||
| total_rebate | DECIMAL(10,2) | 累计返利 |
|
||||
| balance | DECIMAL(10,2) | 可提现余额 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
#### 4.3.2 经销点订单关联表 ml_distribution_orders
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| distribution_id | UUID | 经销点ID |
|
||||
| order_id | UUID | 订单ID |
|
||||
| user_id | UUID | 下单用户ID |
|
||||
| order_amount | DECIMAL(10,2) | 订单金额 |
|
||||
| rebate_amount | DECIMAL(10,2) | 返利金额 |
|
||||
| status | INT | 状态:0-待结算,1-已结算 |
|
||||
| settled_at | TIMESTAMPTZ | 结算时间 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
#### 4.3.3 返利配置表 ml_rebate_config
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| min_orders | INT | 最小单数 |
|
||||
| max_orders | INT | 最大单数 |
|
||||
| rebate_per_order | DECIMAL(10,2) | 每单返利金额 |
|
||||
| status | INT | 状态 |
|
||||
|
||||
#### 4.3.4 返利提现记录表 ml_rebate_withdrawals
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| distribution_id | UUID | 经销点ID |
|
||||
| amount | DECIMAL(10,2) | 提现金额 |
|
||||
| status | INT | 状态:0-待处理,1-已完成,2-已拒绝 |
|
||||
| handled_by | UUID | 处理人 |
|
||||
| handled_at | TIMESTAMPTZ | 处理时间 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
### 4.4 API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| /api/distribution/info | GET | 获取经销点信息 |
|
||||
| /api/distribution/orders | GET | 获取经销点订单列表 |
|
||||
| /api/distribution/rebate-summary | GET | 获取返利统计 |
|
||||
| /api/distribution/withdraw | POST | 申请提现 |
|
||||
| /api/admin/distributions | GET | 获取经销点列表(后台) |
|
||||
| /api/admin/create-distribution | POST | 创建经销点(后台) |
|
||||
| /api/admin/settle-rebate | POST | 结算返利(后台) |
|
||||
|
||||
### 4.5 前端页面
|
||||
|
||||
#### 4.5.1 经销点中心页(经销点负责人)
|
||||
- 经销点信息
|
||||
- 今日/本月订单数
|
||||
- 累计返利
|
||||
- 可提现余额
|
||||
- 申请提现按钮
|
||||
|
||||
#### 4.5.2 经销点订单页
|
||||
- 订单列表
|
||||
- 筛选(日期、状态)
|
||||
- 每单返利金额显示
|
||||
|
||||
---
|
||||
|
||||
## 五、用户余额系统
|
||||
|
||||
### 5.1 数据库设计
|
||||
|
||||
#### 5.1.1 用户余额表 ml_user_balance
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 用户ID |
|
||||
| balance | DECIMAL(10,2) | 当前余额 |
|
||||
| frozen_balance | DECIMAL(10,2) | 冻结余额 |
|
||||
| total_earned | DECIMAL(10,2) | 累计获得 |
|
||||
| total_withdrawn | DECIMAL(10,2) | 累计提现 |
|
||||
| updated_at | TIMESTAMPTZ | 更新时间 |
|
||||
|
||||
#### 5.1.2 余额变动记录表 ml_balance_records
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 用户ID |
|
||||
| type | VARCHAR(50) | 类型:free_order-免单,rebate-返利,withdraw-提现,clear-清零 |
|
||||
| amount | DECIMAL(10,2) | 变动金额(正数增加,负数减少) |
|
||||
| balance_before | DECIMAL(10,2) | 变动前余额 |
|
||||
| balance_after | DECIMAL(10,2) | 变动后余额 |
|
||||
| related_id | UUID | 关联ID |
|
||||
| description | VARCHAR(200) | 描述 |
|
||||
| operator_id | UUID | 操作人(系统操作为空) |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
---
|
||||
|
||||
## 六、前端页面汇总
|
||||
|
||||
### 6.1 消费者端新增页面
|
||||
|
||||
| 页面 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 我的分享 | /pages/mall/consumer/share/my-shares | 分享记录列表 |
|
||||
| 分享详情 | /pages/mall/consumer/share/detail | 分享进度详情 |
|
||||
| 我的余额 | /pages/mall/consumer/balance/index | 余额和奖励记录 |
|
||||
| 会员中心 | /pages/mall/consumer/member/index | 会员等级信息 |
|
||||
| 会员权益 | /pages/mall/consumer/member/benefits | 等级权益说明 |
|
||||
|
||||
### 6.2 经销点端页面(可选独立入口)
|
||||
|
||||
| 页面 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 经销点中心 | /pages/distribution/index | 经销点首页 |
|
||||
| 经销点订单 | /pages/distribution/orders | 订单列表 |
|
||||
| 返利记录 | /pages/distribution/rebates | 返利记录 |
|
||||
| 提现申请 | /pages/distribution/withdraw | 提现页面 |
|
||||
|
||||
---
|
||||
|
||||
## 七、开发优先级
|
||||
|
||||
### 第一阶段(核心功能)
|
||||
1. 用户余额系统
|
||||
2. 分享免单系统
|
||||
3. 会员等级系统
|
||||
|
||||
### 第二阶段(扩展功能)
|
||||
1. 经销点返利系统
|
||||
2. 后台管理功能
|
||||
3. 数据统计报表
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. **安全性**
|
||||
- 分享码唯一且不可预测
|
||||
- 防止刷单作弊(同一用户多次购买不计入)
|
||||
- 余额变动需有完整记录
|
||||
|
||||
2. **性能**
|
||||
- 高频查询使用缓存
|
||||
- 大数据量分页处理
|
||||
|
||||
3. **合规性**
|
||||
- 返利模式需符合当地法规
|
||||
- 用户协议需明确说明规则
|
||||
|
||||
4. **扩展性**
|
||||
- 返利规则可配置
|
||||
- 会员等级可扩展
|
||||
- 支持多种分享渠道
|
||||
629
pages/mall/consumer/doc/积分与评价功能完善需求文档.md
Normal file
629
pages/mall/consumer/doc/积分与评价功能完善需求文档.md
Normal file
@@ -0,0 +1,629 @@
|
||||
# 商城消费者端 - 积分与评价功能完善需求文档
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
### 1.1 项目背景
|
||||
本项目为商城消费者端应用,当前积分和评价功能已有基础实现,但存在部分功能缺失和体验优化空间。本文档旨在明确积分和评价功能的完善需求。
|
||||
|
||||
### 1.2 当前实现状态
|
||||
|
||||
| 功能模块 | 当前状态 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 消费者端积分页面 | ✅ 已完成 | 显示积分余额、积分明细列表 |
|
||||
| 积分兑换商城 | ❌ 未实现 | 点击"积分兑换"提示"开发中" |
|
||||
| 消费者端商品评价 | ✅ 已完成 | 支持评分、文字、图片、匿名评价 |
|
||||
| 消费者端店铺评价 | ✅ 已完成 | 支持描述/物流/服务三维评分 |
|
||||
| 商家端评价管理 | ✅ 已完成 | 支持查看、筛选、回复评价 |
|
||||
|
||||
---
|
||||
|
||||
## 二、积分功能完善需求
|
||||
|
||||
### 2.1 功能架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 积分系统架构 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 积分获取 │ │ 积分使用 │ │ 积分管理 │ │
|
||||
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
|
||||
│ │ • 注册赠送 │ │ • 积分兑换 │ │ • 积分查询 │ │
|
||||
│ │ • 每日签到 │ │ • 订单抵扣 │ │ • 明细记录 │ │
|
||||
│ │ • 购物奖励 │ │ • 积分抽奖 │ │ • 过期处理 │ │
|
||||
│ │ • 评价奖励 │ │ │ │ • 积分规则 │ │
|
||||
│ │ • 邀请好友 │ │ │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 前端功能需求
|
||||
|
||||
#### 2.2.1 积分首页优化
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/index.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **积分概览卡片**
|
||||
- 显示当前可用积分
|
||||
- 显示即将过期积分(30天内)
|
||||
- 显示历史累计积分
|
||||
- 积分趋势图表(近7天/30天)
|
||||
|
||||
2. **快捷入口**
|
||||
- 签到入口(带签到状态提示)
|
||||
- 积分兑换入口
|
||||
- 积分规则说明
|
||||
|
||||
3. **积分明细列表**
|
||||
- 支持按类型筛选(获取/消费/过期)
|
||||
- 支持按时间范围筛选
|
||||
- 下拉刷新、上拉加载更多
|
||||
|
||||
#### 2.2.2 每日签到功能(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/signin.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **签到日历**
|
||||
- 显示当月签到记录
|
||||
- 连续签到天数统计
|
||||
- 签到奖励预览
|
||||
|
||||
2. **签到奖励规则**
|
||||
- 每日签到:+5积分
|
||||
- 连续签到7天:额外+20积分
|
||||
- 连续签到30天:额外+100积分
|
||||
|
||||
3. **签到弹窗**
|
||||
- 签到成功动画
|
||||
- 显示获得积分
|
||||
- 连续签到进度提示
|
||||
|
||||
#### 2.2.3 积分兑换商城(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/exchange.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **兑换商品列表**
|
||||
- 优惠券兑换
|
||||
- 实物商品兑换
|
||||
- 虚拟商品兑换(会员权益等)
|
||||
|
||||
2. **商品详情**
|
||||
- 商品图片、名称、描述
|
||||
- 兑换所需积分
|
||||
- 库存状态
|
||||
- 兑换记录
|
||||
|
||||
3. **兑换流程**
|
||||
- 积分不足提示
|
||||
- 确认兑换弹窗
|
||||
- 兑换成功/失败反馈
|
||||
- 物流跟踪(实物商品)
|
||||
|
||||
#### 2.2.4 积分规则页面(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/rules.uvue`
|
||||
|
||||
**需求描述**:
|
||||
- 积分获取规则说明
|
||||
- 积分使用规则说明
|
||||
- 积分有效期说明
|
||||
- 常见问题FAQ
|
||||
|
||||
### 2.3 后端API需求
|
||||
|
||||
#### 2.3.1 新增API接口
|
||||
|
||||
| 接口名称 | 请求方法 | 接口路径 | 说明 |
|
||||
|---------|---------|---------|------|
|
||||
| 签到 | POST | `/api/points/signin` | 用户每日签到 |
|
||||
| 获取签到记录 | GET | `/api/points/signin-records` | 获取月度签到记录 |
|
||||
| 获取兑换商品列表 | GET | `/api/points/exchange-products` | 获取可兑换商品 |
|
||||
| 积分兑换 | POST | `/api/points/exchange` | 兑换商品 |
|
||||
| 获取兑换记录 | GET | `/api/points/exchange-records` | 获取兑换历史 |
|
||||
| 积分过期提醒 | GET | `/api/points/expiring` | 获取即将过期积分 |
|
||||
|
||||
#### 2.3.2 接口详细设计
|
||||
|
||||
**签到接口**
|
||||
```typescript
|
||||
// 请求
|
||||
POST /api/points/signin
|
||||
Response: {
|
||||
success: boolean
|
||||
points: number // 本次获得积分
|
||||
continuous_days: number // 连续签到天数
|
||||
bonus_points: number // 额外奖励积分
|
||||
total_points: number // 当前总积分
|
||||
}
|
||||
```
|
||||
|
||||
**积分兑换接口**
|
||||
```typescript
|
||||
// 请求
|
||||
POST /api/points/exchange
|
||||
Body: {
|
||||
product_id: string // 兑换商品ID
|
||||
quantity: number // 兑换数量
|
||||
address_id?: string // 收货地址(实物商品)
|
||||
}
|
||||
Response: {
|
||||
success: boolean
|
||||
exchange_id: string // 兑换记录ID
|
||||
points_used: number // 消耗积分
|
||||
remaining_points: number // 剩余积分
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 数据库设计
|
||||
|
||||
#### 2.4.1 现有表结构
|
||||
|
||||
**用户积分表 (ml_user_points)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_user_points (
|
||||
user_id UUID NOT NULL PRIMARY KEY REFERENCES auth.users(id),
|
||||
points INT DEFAULT 0 NOT NULL, -- 当前可用积分
|
||||
total_earned INT DEFAULT 0, -- 历史累计获得积分
|
||||
total_used INT DEFAULT 0, -- 历史累计使用积分
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_user_points_user_id ON ml_user_points(user_id);
|
||||
```
|
||||
|
||||
**积分记录表 (ml_point_records)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
points INT NOT NULL, -- 变动积分 (正/负)
|
||||
balance INT NOT NULL, -- 变动后余额
|
||||
type VARCHAR(50) NOT NULL, -- 类型
|
||||
reference_id UUID, -- 关联ID(订单ID/兑换ID等)
|
||||
description TEXT,
|
||||
expires_at TIMESTAMPTZ, -- 过期时间
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_point_records_user_id ON ml_point_records(user_id);
|
||||
CREATE INDEX idx_point_records_type ON ml_point_records(type);
|
||||
CREATE INDEX idx_point_records_created_at ON ml_point_records(created_at);
|
||||
```
|
||||
|
||||
#### 2.4.2 新增表结构
|
||||
|
||||
**签到记录表 (ml_signin_records)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_signin_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
signin_date DATE NOT NULL, -- 签到日期
|
||||
points_earned INT DEFAULT 0, -- 获得积分
|
||||
bonus_points INT DEFAULT 0, -- 奖励积分
|
||||
continuous_days INT DEFAULT 1, -- 当次连续签到天数
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, signin_date) -- 每用户每天只能签到一次
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_signin_records_user_id ON ml_signin_records(user_id);
|
||||
CREATE INDEX idx_signin_records_date ON ml_signin_records(signin_date);
|
||||
```
|
||||
|
||||
**积分兑换商品表 (ml_point_products)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_products (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL, -- 商品名称
|
||||
description TEXT, -- 商品描述
|
||||
image_url VARCHAR(500), -- 商品图片
|
||||
product_type VARCHAR(50) NOT NULL, -- 类型: coupon/physical/virtual
|
||||
points_required INT NOT NULL, -- 所需积分
|
||||
original_price DECIMAL(10,2), -- 原价(展示用)
|
||||
stock INT DEFAULT 0, -- 库存
|
||||
status INT DEFAULT 1, -- 状态: 0=下架, 1=上架
|
||||
sort_order INT DEFAULT 0, -- 排序
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**积分兑换记录表 (ml_point_exchanges)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_exchanges (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
product_id UUID NOT NULL REFERENCES ml_point_products(id),
|
||||
quantity INT DEFAULT 1, -- 兑换数量
|
||||
points_used INT NOT NULL, -- 消耗积分
|
||||
status INT DEFAULT 0, -- 状态: 0=待处理, 1=已发货, 2=已完成, 3=已取消
|
||||
tracking_no VARCHAR(100), -- 物流单号
|
||||
address_id UUID, -- 收货地址
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**积分规则配置表 (ml_point_rules)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_rules (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
rule_type VARCHAR(50) NOT NULL, -- 规则类型
|
||||
rule_name VARCHAR(100) NOT NULL, -- 规则名称
|
||||
points INT NOT NULL, -- 积分值
|
||||
description TEXT, -- 规则说明
|
||||
config JSONB, -- 扩展配置
|
||||
status INT DEFAULT 1, -- 状态
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 初始化规则数据
|
||||
INSERT INTO ml_point_rules (rule_type, rule_name, points, description) VALUES
|
||||
('register', '注册赠送', 100, '新用户注册赠送积分'),
|
||||
('signin_daily', '每日签到', 5, '每日签到获得积分'),
|
||||
('signin_continuous_7', '连续签到7天奖励', 20, '连续签到7天额外奖励'),
|
||||
('signin_continuous_30', '连续签到30天奖励', 100, '连续签到30天额外奖励'),
|
||||
('shopping', '购物奖励', 1, '每消费1元获得1积分'),
|
||||
('review', '评价奖励', 10, '完成商品评价获得积分'),
|
||||
('review_with_image', '带图评价奖励', 20, '带图评价额外奖励');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、评价功能完善需求
|
||||
|
||||
### 3.1 功能架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 评价系统架构 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 评价提交 │ │ 评价展示 │ │ 评价管理 │ │
|
||||
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
|
||||
│ │ • 商品评价 │ │ • 商品详情 │ │ • 商家回复 │ │
|
||||
│ │ • 店铺评价 │ │ • 评价列表 │ │ • 评价统计 │ │
|
||||
│ │ • 配送评价 │ │ • 评分统计 │ │ • 违规处理 │ │
|
||||
│ │ • 追加评价 │ │ • 筛选排序 │ │ • 评价审核 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 前端功能需求
|
||||
|
||||
#### 3.2.1 评价页面优化
|
||||
|
||||
**页面路径**: `pages/mall/consumer/review.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价表单优化**
|
||||
- 支持视频上传(限1个,30秒内)
|
||||
- 支持图片拖拽排序
|
||||
- 添加评价标签选择(质量好/物流快/服务好等)
|
||||
- 添加商品满意度维度(质量/包装/性价比)
|
||||
|
||||
2. **追加评价功能**
|
||||
- 订单完成后7天内可追加评价
|
||||
- 追加评价入口(订单详情页)
|
||||
- 追加评价表单
|
||||
|
||||
3. **评价预览**
|
||||
- 提交前预览评价效果
|
||||
- 匿名评价预览效果
|
||||
|
||||
#### 3.2.2 商品评价列表(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/product-reviews.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价统计概览**
|
||||
- 好评率百分比
|
||||
- 评分分布图(1-5星)
|
||||
- 标签云(高频评价关键词)
|
||||
|
||||
2. **评价列表**
|
||||
- 支持按评分筛选
|
||||
- 支持按时间/点赞数排序
|
||||
- 支持只看有图/有视频评价
|
||||
- 评价点赞功能
|
||||
- 评价举报功能
|
||||
|
||||
3. **评价详情**
|
||||
- 点击评价查看详情
|
||||
- 查看商家回复
|
||||
- 查看追加评价
|
||||
|
||||
#### 3.2.3 我的评价页面(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/my-reviews.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价列表**
|
||||
- 显示已评价商品
|
||||
- 显示待评价商品
|
||||
- 显示可追加评价商品
|
||||
|
||||
2. **评价操作**
|
||||
- 编辑评价(24小时内)
|
||||
- 删除评价
|
||||
- 追加评价
|
||||
|
||||
#### 3.2.4 配送员评价(完善)
|
||||
|
||||
**页面路径**: `pages/mall/delivery/ratings.uvue`
|
||||
|
||||
**需求描述**:
|
||||
- 对接真实API(当前使用Mock数据)
|
||||
- 配送员评分统计
|
||||
- 配送评价列表
|
||||
|
||||
### 3.3 后端API需求
|
||||
|
||||
#### 3.3.1 新增API接口
|
||||
|
||||
| 接口名称 | 请求方法 | 接口路径 | 说明 |
|
||||
|---------|---------|---------|------|
|
||||
| 获取商品评价列表 | GET | `/api/reviews/product/:productId` | 分页获取商品评价 |
|
||||
| 获取评价统计 | GET | `/api/reviews/stats/:productId` | 获取商品评分统计 |
|
||||
| 追加评价 | POST | `/api/reviews/append` | 追加评价内容 |
|
||||
| 评价点赞 | POST | `/api/reviews/like/:id` | 点赞评价 |
|
||||
| 获取我的评价 | GET | `/api/reviews/my` | 获取用户评价列表 |
|
||||
| 编辑评价 | PUT | `/api/reviews/:id` | 编辑评价(24小时内) |
|
||||
| 删除评价 | DELETE | `/api/reviews/:id` | 删除评价 |
|
||||
| 评价举报 | POST | `/api/reviews/report/:id` | 举报违规评价 |
|
||||
|
||||
#### 3.3.2 接口详细设计
|
||||
|
||||
**获取商品评价列表**
|
||||
```typescript
|
||||
GET /api/reviews/product/:productId?page=1&limit=10&rating=5&has_image=true
|
||||
Response: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
data: [{
|
||||
id: string
|
||||
user_name: string
|
||||
user_avatar: string
|
||||
rating: number
|
||||
content: string
|
||||
images: string[]
|
||||
videos: string[]
|
||||
tags: string[]
|
||||
like_count: number
|
||||
is_liked: boolean
|
||||
reply: {
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
append_content?: {
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
created_at: string
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**获取评价统计**
|
||||
```typescript
|
||||
GET /api/reviews/stats/:productId
|
||||
Response: {
|
||||
total_count: number
|
||||
avg_rating: number
|
||||
good_rate: number // 好评率
|
||||
rating_distribution: { // 评分分布
|
||||
1: number
|
||||
2: number
|
||||
3: number
|
||||
4: number
|
||||
5: number
|
||||
}
|
||||
tags: [{ // 高频标签
|
||||
name: string
|
||||
count: number
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 数据库设计
|
||||
|
||||
#### 3.4.1 现有表结构优化
|
||||
|
||||
**商品评价表 (ml_product_reviews)**
|
||||
```sql
|
||||
-- 添加新字段
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS videos JSONB DEFAULT '[]';
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '[]';
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS like_count INT DEFAULT 0;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS is_edited BOOLEAN DEFAULT false;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_content TEXT;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_at TIMESTAMPTZ;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_images JSONB DEFAULT '[]';
|
||||
```
|
||||
|
||||
#### 3.4.2 新增表结构
|
||||
|
||||
**评价点赞表 (ml_review_likes)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_review_likes (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
review_id UUID NOT NULL REFERENCES ml_product_reviews(id),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(review_id, user_id) -- 每用户对每条评价只能点赞一次
|
||||
);
|
||||
|
||||
CREATE INDEX idx_review_likes_review_id ON ml_review_likes(review_id);
|
||||
```
|
||||
|
||||
**评价举报表 (ml_review_reports)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_review_reports (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
review_id UUID NOT NULL REFERENCES ml_product_reviews(id),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
reason VARCHAR(200) NOT NULL, -- 举报原因
|
||||
description TEXT, -- 详细说明
|
||||
status INT DEFAULT 0, -- 状态: 0=待处理, 1=已处理, 2=已驳回
|
||||
handle_result TEXT, -- 处理结果
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**配送员评价表 (ml_delivery_ratings)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_delivery_ratings (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
order_id UUID NOT NULL REFERENCES ml_orders(id),
|
||||
delivery_user_id UUID NOT NULL, -- 配送员ID
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
rating INT CHECK (rating >= 1 AND rating <= 5),
|
||||
content TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(order_id) -- 每订单只能评价一次
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、开发优先级
|
||||
|
||||
### 4.1 第一阶段(高优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 每日签到功能 | 积分 | 2天 |
|
||||
| 2 | 积分兑换商城 | 积分 | 3天 |
|
||||
| 3 | 商品评价列表展示 | 评价 | 2天 |
|
||||
| 4 | 我的评价页面 | 评价 | 1天 |
|
||||
|
||||
### 4.2 第二阶段(中优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 积分规则配置 | 积分 | 1天 |
|
||||
| 2 | 追加评价功能 | 评价 | 1天 |
|
||||
| 3 | 评价点赞功能 | 评价 | 0.5天 |
|
||||
| 4 | 配送员评价对接 | 评价 | 1天 |
|
||||
|
||||
### 4.3 第三阶段(低优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 积分过期提醒 | 积分 | 0.5天 |
|
||||
| 2 | 评价视频上传 | 评价 | 1天 |
|
||||
| 3 | 评价举报功能 | 评价 | 0.5天 |
|
||||
| 4 | 管理端完善 | 综合 | 2天 |
|
||||
|
||||
---
|
||||
|
||||
## 五、技术要点
|
||||
|
||||
### 5.1 积分系统技术要点
|
||||
|
||||
1. **积分并发安全**
|
||||
- 使用数据库事务保证积分变动原子性
|
||||
- 添加乐观锁防止超扣
|
||||
|
||||
2. **积分过期处理**
|
||||
- 定时任务每日检查过期积分
|
||||
- 过期前7天推送提醒通知
|
||||
|
||||
3. **签到防刷**
|
||||
- 限制每日只能签到一次
|
||||
- 记录签到IP,检测异常行为
|
||||
|
||||
### 5.2 评价系统技术要点
|
||||
|
||||
1. **评价数据统计**
|
||||
- 使用触发器自动更新商品评分统计
|
||||
- 缓存热门商品评价数据
|
||||
|
||||
2. **图片/视频处理**
|
||||
- 图片压缩后上传
|
||||
- 视频转码处理
|
||||
- CDN加速访问
|
||||
|
||||
3. **敏感词过滤**
|
||||
- 评价内容敏感词检测
|
||||
- 自动替换或人工审核
|
||||
|
||||
---
|
||||
|
||||
## 六、测试要点
|
||||
|
||||
### 6.1 积分功能测试
|
||||
|
||||
- [ ] 签到功能:每日签到、连续签到奖励
|
||||
- [ ] 积分兑换:积分不足、库存不足、兑换成功
|
||||
- [ ] 积分过期:自动过期、过期提醒
|
||||
- [ ] 并发测试:同时兑换、同时签到
|
||||
|
||||
### 6.2 评价功能测试
|
||||
|
||||
- [ ] 评价提交:文字、图片、视频、匿名
|
||||
- [ ] 评价展示:列表、筛选、排序
|
||||
- [ ] 评价操作:编辑、删除、追加、点赞
|
||||
- [ ] 边界测试:空内容、超长内容、敏感词
|
||||
|
||||
---
|
||||
|
||||
## 七、附录
|
||||
|
||||
### 7.1 相关文件路径
|
||||
|
||||
```
|
||||
pages/
|
||||
├── mall/
|
||||
│ └── consumer/
|
||||
│ ├── points/
|
||||
│ │ ├── index.uvue # 积分首页(已有)
|
||||
│ │ ├── signin.uvue # 签到页面(新增)
|
||||
│ │ ├── exchange.uvue # 兑换商城(新增)
|
||||
│ │ └── rules.uvue # 积分规则(新增)
|
||||
│ ├── review.uvue # 评价页面(已有)
|
||||
│ ├── product-reviews.uvue # 商品评价列表(新增)
|
||||
│ └── my-reviews.uvue # 我的评价(新增)
|
||||
│
|
||||
└── user/
|
||||
└── login.uvue # 登录页面
|
||||
|
||||
utils/
|
||||
└── supabaseService.uts # API服务
|
||||
|
||||
types/
|
||||
└── mall-types.uts # 类型定义
|
||||
|
||||
doc_mall/
|
||||
└── consumer/
|
||||
└── sql/
|
||||
├── 01_wallet_and_points.sql
|
||||
└── add_reviews_tables.sql
|
||||
```
|
||||
|
||||
### 7.2 参考文档
|
||||
|
||||
- [Supabase 官方文档](https://supabase.com/docs)
|
||||
- [uni-app x 开发文档](https://doc.dcloud.net.cn/uni-app-x/)
|
||||
- [UTS 语法指南](https://doc.dcloud.net.cn/uni-app-x/uts/)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建日期**: 2026-03-05
|
||||
**最后更新**: 2026-03-05
|
||||
640
pages/mall/consumer/member/index.uvue
Normal file
640
pages/mall/consumer/member/index.uvue
Normal file
@@ -0,0 +1,640 @@
|
||||
<template>
|
||||
<scroll-view class="member-page" scroll-y>
|
||||
<view class="member-header">
|
||||
<view class="member-info">
|
||||
<view class="level-badge" :class="'level-' + memberInfo.member_level">
|
||||
<text class="level-name">{{ memberInfo.level_name }}</text>
|
||||
</view>
|
||||
<view class="discount-info">
|
||||
<text class="discount-value">{{ getDiscountText(memberInfo.discount) }}</text>
|
||||
<text class="discount-label">会员折扣</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-section" v-if="memberInfo.next_level != null">
|
||||
<view class="progress-header">
|
||||
<text class="progress-title">距离{{ getNextLevelName() }}还需</text>
|
||||
<text class="progress-amount">{{ getRemainingAmount() }}元</text>
|
||||
</view>
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: memberInfo.progress_percent + '%' }"></view>
|
||||
</view>
|
||||
<view class="progress-footer">
|
||||
<text class="current-amount">已消费 {{ memberInfo.total_spent }}元</text>
|
||||
<text class="target-amount">目标 {{ getNextLevelMinAmount() }}元</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="levels-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">会员等级</text>
|
||||
</view>
|
||||
<view class="level-list">
|
||||
<view
|
||||
class="level-item"
|
||||
v-for="level in levels"
|
||||
:key="level.id"
|
||||
:class="{ current: level.id === memberInfo.member_level }"
|
||||
>
|
||||
<view class="level-left">
|
||||
<view class="level-icon" :class="'level-bg-' + level.id">
|
||||
<text class="icon-text">{{ level.name.charAt(0) }}</text>
|
||||
</view>
|
||||
<view class="level-detail">
|
||||
<text class="level-title">{{ level.name }}</text>
|
||||
<text class="level-condition">{{ level.description || ('累计消费' + level.min_amount + '元') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="level-right">
|
||||
<text class="level-discount">{{ getDiscountText(level.discount) }}</text>
|
||||
<view class="current-tag" v-if="level.id === memberInfo.member_level">
|
||||
<text class="tag-text">当前</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="benefits-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">会员权益</text>
|
||||
</view>
|
||||
<view class="benefit-list">
|
||||
<view class="benefit-item">
|
||||
<text class="benefit-icon">💰</text>
|
||||
<text class="benefit-text">专属折扣价格</text>
|
||||
</view>
|
||||
<view class="benefit-item">
|
||||
<text class="benefit-icon">🎁</text>
|
||||
<text class="benefit-text">生日专属优惠</text>
|
||||
</view>
|
||||
<view class="benefit-item">
|
||||
<text class="benefit-icon">🚀</text>
|
||||
<text class="benefit-text">优先发货权益</text>
|
||||
</view>
|
||||
<view class="benefit-item">
|
||||
<text class="benefit-icon">📞</text>
|
||||
<text class="benefit-text">专属客服通道</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="logs-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">等级变更记录</text>
|
||||
</view>
|
||||
|
||||
<view v-if="logsLoading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="logs.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无变更记录</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="log-list">
|
||||
<view class="log-item" v-for="log in logs" :key="log.id">
|
||||
<view class="log-left">
|
||||
<text class="log-change">{{ getLevelName(log.old_level) }} → {{ getLevelName(log.new_level) }}</text>
|
||||
<text class="log-reason">{{ log.reason || '系统升级' }}</text>
|
||||
</view>
|
||||
<text class="log-time">{{ formatDate(log.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type MemberLevel = {
|
||||
id: number
|
||||
name: string
|
||||
min_amount: number
|
||||
discount: number
|
||||
description: string | null
|
||||
}
|
||||
|
||||
type MemberInfo = {
|
||||
member_level: number
|
||||
level_name: string
|
||||
discount: number
|
||||
total_spent: number
|
||||
next_level: MemberLevel | null
|
||||
progress_percent: number
|
||||
manual_level: boolean
|
||||
}
|
||||
|
||||
type LevelLog = {
|
||||
id: string
|
||||
old_level: number
|
||||
new_level: number
|
||||
reason: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const memberInfo = ref<MemberInfo>({
|
||||
member_level: 0,
|
||||
level_name: '普通会员',
|
||||
discount: 1.0,
|
||||
total_spent: 0,
|
||||
next_level: null,
|
||||
progress_percent: 0,
|
||||
manual_level: false
|
||||
})
|
||||
|
||||
const levels = ref<MemberLevel[]>([])
|
||||
const logs = ref<LevelLog[]>([])
|
||||
const logsLoading = ref<boolean>(false)
|
||||
|
||||
const loadMemberInfo = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getUserMemberInfo()
|
||||
|
||||
memberInfo.value = {
|
||||
member_level: result.getNumber('member_level') ?? 0,
|
||||
level_name: result.getString('level_name') ?? '普通会员',
|
||||
discount: result.getNumber('discount') ?? 1.0,
|
||||
total_spent: result.getNumber('total_spent') ?? 0,
|
||||
next_level: null,
|
||||
progress_percent: result.getNumber('progress_percent') ?? 0,
|
||||
manual_level: result.getBoolean('manual_level') ?? false
|
||||
}
|
||||
|
||||
const nextLevelRaw = result.get('next_level')
|
||||
if (nextLevelRaw != null) {
|
||||
const nextLevelAny = nextLevelRaw as any
|
||||
if (typeof nextLevelAny._getValue === 'function') {
|
||||
memberInfo.value.next_level = {
|
||||
id: (nextLevelAny._getValue('id') as number) ?? 0,
|
||||
name: (nextLevelAny._getValue('name') as string) ?? '',
|
||||
min_amount: (nextLevelAny._getValue('min_amount') as number) ?? 0,
|
||||
discount: 1.0,
|
||||
description: null
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载会员信息失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadLevels = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getMemberLevels()
|
||||
const parsed: MemberLevel[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
parsed.push({
|
||||
id: (itemAny._getValue('id') as number) ?? 0,
|
||||
name: (itemAny._getValue('name') as string) ?? '',
|
||||
min_amount: (itemAny._getValue('min_amount') as number) ?? 0,
|
||||
discount: (itemAny._getValue('discount') as number) ?? 1.0,
|
||||
description: itemAny._getValue('description') as string | null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
levels.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载会员等级失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadLogs = async (): Promise<void> => {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getMemberLevelLogs()
|
||||
const parsed: LevelLog[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
parsed.push({
|
||||
id: (itemAny._getValue('id') as string) ?? '',
|
||||
old_level: (itemAny._getValue('old_level') as number) ?? 0,
|
||||
new_level: (itemAny._getValue('new_level') as number) ?? 0,
|
||||
reason: itemAny._getValue('reason') as string | null,
|
||||
created_at: (itemAny._getValue('created_at') as string) ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logs.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载变更记录失败:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getDiscountText = (discount: number): string => {
|
||||
if (discount >= 1) return '无折扣'
|
||||
return Math.round(discount * 100) / 10 + '折'
|
||||
}
|
||||
|
||||
const getNextLevelName = (): string => {
|
||||
if (memberInfo.value.next_level != null) {
|
||||
return memberInfo.value.next_level.name
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getNextLevelMinAmount = (): number => {
|
||||
if (memberInfo.value.next_level != null) {
|
||||
return memberInfo.value.next_level.min_amount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const getRemainingAmount = (): number => {
|
||||
if (memberInfo.value.next_level != null) {
|
||||
return memberInfo.value.next_level.min_amount - memberInfo.value.total_spent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const getLevelName = (level: number): string => {
|
||||
for (let i = 0; i < levels.value.length; i++) {
|
||||
if (levels.value[i].id === level) {
|
||||
return levels.value[i].name
|
||||
}
|
||||
}
|
||||
return '普通会员'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (dateStr === '') return ''
|
||||
const date = new Date(dateStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMemberInfo()
|
||||
loadLevels()
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.member-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.member-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
padding: 8px 24px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.level-badge.level-0 {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.level-badge.level-1 {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #daa520 100%);
|
||||
}
|
||||
|
||||
.level-badge.level-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
|
||||
}
|
||||
|
||||
.level-badge.level-3 {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffec8b 100%);
|
||||
}
|
||||
|
||||
.level-badge.level-4 {
|
||||
background: linear-gradient(135deg, #b9f2ff 0%, #89cff0 100%);
|
||||
}
|
||||
|
||||
.level-badge.level-5 {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ff8e8e 100%);
|
||||
}
|
||||
|
||||
.level-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.discount-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.discount-value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.discount-label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-amount {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.current-amount {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.target-amount {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.levels-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.level-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.level-item.current {
|
||||
background-color: #f8f5ff;
|
||||
}
|
||||
|
||||
.level-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.level-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.level-bg-0 {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.level-bg-1 {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #daa520 100%);
|
||||
}
|
||||
|
||||
.level-bg-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
|
||||
}
|
||||
|
||||
.level-bg-3 {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffec8b 100%);
|
||||
}
|
||||
|
||||
.level-bg-4 {
|
||||
background: linear-gradient(135deg, #b9f2ff 0%, #89cff0 100%);
|
||||
}
|
||||
|
||||
.level-bg-5 {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ff8e8e 100%);
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.level-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-title {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.level-condition {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.level-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.level-discount {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.current-tag {
|
||||
background-color: #667eea;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.benefits-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.benefit-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.benefit-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.logs-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.log-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-change {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.log-reason {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
697
pages/mall/consumer/my-reviews.uvue
Normal file
697
pages/mall/consumer/my-reviews.uvue
Normal file
@@ -0,0 +1,697 @@
|
||||
<template>
|
||||
<view class="my-reviews-page">
|
||||
<view class="tabs">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'published' }"
|
||||
@click="switchTab('published')"
|
||||
>
|
||||
<text class="tab-text">已评价</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'pending' }"
|
||||
@click="switchTab('pending')"
|
||||
>
|
||||
<text class="tab-text">待评价</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="review-list" v-if="activeTab === 'published'">
|
||||
<view class="review-item" v-for="review in reviews" :key="review.id">
|
||||
<view class="product-info" @click="goToProduct(review.product_id)">
|
||||
<image
|
||||
class="product-image"
|
||||
:src="review.product_image || defaultImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-detail">
|
||||
<text class="product-name">{{ review.product_name }}</text>
|
||||
<view class="rating-row">
|
||||
<view class="rating-stars">
|
||||
<text
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="star"
|
||||
:class="{ filled: star <= review.rating }"
|
||||
>★</text>
|
||||
</view>
|
||||
<text class="review-time">{{ formatTime(review.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="review-content">
|
||||
<text class="review-text">{{ review.content }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-images" v-if="review.images.length > 0">
|
||||
<image
|
||||
v-for="(img, idx) in review.images.slice(0, 4)"
|
||||
:key="idx"
|
||||
class="review-image"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
@click="previewImage(review.images, idx)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="review-append" v-if="review.append_content">
|
||||
<text class="append-label">追评:</text>
|
||||
<text class="append-text">{{ review.append_content }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-actions">
|
||||
<view
|
||||
class="action-btn append"
|
||||
v-if="review.can_append"
|
||||
@click="showAppendPopup(review)"
|
||||
>
|
||||
<text class="action-text">追加评价</text>
|
||||
</view>
|
||||
<view
|
||||
class="action-btn delete"
|
||||
@click="confirmDelete(review)"
|
||||
>
|
||||
<text class="action-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pending-list" v-if="activeTab === 'pending'">
|
||||
<view class="pending-item" v-for="item in pendingItems" :key="item.order_id">
|
||||
<view class="product-info">
|
||||
<image
|
||||
class="product-image"
|
||||
:src="item.product_image || defaultImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-detail">
|
||||
<text class="product-name">{{ item.product_name }}</text>
|
||||
<text class="order-time">下单时间:{{ formatTime(item.order_time) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pending-actions">
|
||||
<button class="review-btn" @click="goToReview(item)">去评价</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="!loading && ((activeTab === 'published' && reviews.length === 0) || (activeTab === 'pending' && pendingItems.length === 0))">
|
||||
<text class="empty-text">{{ activeTab === 'published' ? '暂无评价记录' : '暂无待评价商品' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="loading-state" v-if="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="append-popup" v-if="showAppendModal" @click="closeAppendPopup">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">追加评价</text>
|
||||
<text class="popup-close" @click="closeAppendPopup">×</text>
|
||||
</view>
|
||||
|
||||
<textarea
|
||||
class="append-input"
|
||||
v-model="appendContent"
|
||||
placeholder="请输入追加评价内容"
|
||||
:maxlength="500"
|
||||
/>
|
||||
|
||||
<view class="popup-footer">
|
||||
<button class="cancel-btn" @click="closeAppendPopup">取消</button>
|
||||
<button class="submit-btn" @click="submitAppend">提交</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type MyReviewItem = {
|
||||
id: string
|
||||
product_id: string
|
||||
product_name: string
|
||||
product_image: string
|
||||
rating: number
|
||||
content: string
|
||||
images: string[]
|
||||
append_content: string | null
|
||||
can_append: boolean
|
||||
can_edit: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type PendingItem = {
|
||||
order_id: string
|
||||
product_id: string
|
||||
product_name: string
|
||||
product_image: string
|
||||
order_time: string
|
||||
}
|
||||
|
||||
const activeTab = ref<string>('published')
|
||||
const reviews = ref<MyReviewItem[]>([])
|
||||
const pendingItems = ref<PendingItem[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const showAppendModal = ref<boolean>(false)
|
||||
const appendContent = ref<string>('')
|
||||
const selectedReview = ref<MyReviewItem | null>(null)
|
||||
|
||||
const defaultImage: string = '/static/images/default-product.png'
|
||||
|
||||
const loadReviews = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getMyReviews()
|
||||
const parsed: MyReviewItem[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
let reviewObj: UTSJSONObject
|
||||
if (item instanceof UTSJSONObject) {
|
||||
reviewObj = item
|
||||
} else {
|
||||
reviewObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||||
}
|
||||
|
||||
let images: string[] = []
|
||||
const imagesRaw = reviewObj.get('images')
|
||||
if (imagesRaw != null && typeof imagesRaw === 'string') {
|
||||
try {
|
||||
const parsedImages = JSON.parse(imagesRaw as string)
|
||||
if (Array.isArray(parsedImages)) {
|
||||
images = parsedImages as string[]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析图片失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const review: MyReviewItem = {
|
||||
id: reviewObj.getString('id') ?? '',
|
||||
product_id: reviewObj.getString('product_id') ?? '',
|
||||
product_name: reviewObj.getString('product_name') ?? '',
|
||||
product_image: reviewObj.getString('product_image') ?? '',
|
||||
rating: reviewObj.getNumber('rating') ?? 5,
|
||||
content: reviewObj.getString('content') ?? '',
|
||||
images: images,
|
||||
append_content: reviewObj.getString('append_content'),
|
||||
can_append: reviewObj.getBoolean('can_append') ?? false,
|
||||
can_edit: reviewObj.getBoolean('can_edit') ?? false,
|
||||
created_at: reviewObj.getString('created_at') ?? ''
|
||||
}
|
||||
parsed.push(review)
|
||||
}
|
||||
|
||||
reviews.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载评价失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadPendingItems = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const orders = await supabaseService.getOrders('completed')
|
||||
const pending: PendingItem[] = []
|
||||
|
||||
for (let i = 0; i < orders.length; i++) {
|
||||
const order = orders[i]
|
||||
let orderObj: UTSJSONObject
|
||||
if (order instanceof UTSJSONObject) {
|
||||
orderObj = order
|
||||
} else {
|
||||
orderObj = JSON.parse(JSON.stringify(order)) as UTSJSONObject
|
||||
}
|
||||
|
||||
const orderId = orderObj.getString('id') ?? ''
|
||||
const itemsRaw = orderObj.get('items')
|
||||
|
||||
if (itemsRaw != null && Array.isArray(itemsRaw)) {
|
||||
const items = itemsRaw as any[]
|
||||
for (let j = 0; j < items.length; j++) {
|
||||
const orderItem = items[j]
|
||||
let itemObj: UTSJSONObject
|
||||
if (orderItem instanceof UTSJSONObject) {
|
||||
itemObj = orderItem
|
||||
} else {
|
||||
itemObj = JSON.parse(JSON.stringify(orderItem)) as UTSJSONObject
|
||||
}
|
||||
|
||||
pending.push({
|
||||
order_id: orderId,
|
||||
product_id: itemObj.getString('product_id') ?? '',
|
||||
product_name: itemObj.getString('product_name') ?? '',
|
||||
product_image: itemObj.getString('product_image') ?? '',
|
||||
order_time: orderObj.getString('created_at') ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingItems.value = pending
|
||||
} catch (e) {
|
||||
console.error('加载待评价商品失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchTab = (tab: string): void => {
|
||||
activeTab.value = tab
|
||||
if (tab === 'published' && reviews.value.length === 0) {
|
||||
loadReviews()
|
||||
} else if (tab === 'pending' && pendingItems.value.length === 0) {
|
||||
loadPendingItems()
|
||||
}
|
||||
}
|
||||
|
||||
const goToProduct = (productId: string): void => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/product-detail?id=${productId}`
|
||||
})
|
||||
}
|
||||
|
||||
const goToReview = (item: PendingItem): void => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/review?order_id=${item.order_id}`
|
||||
})
|
||||
}
|
||||
|
||||
const showAppendPopup = (review: MyReviewItem): void => {
|
||||
selectedReview.value = review
|
||||
appendContent.value = ''
|
||||
showAppendModal.value = true
|
||||
}
|
||||
|
||||
const closeAppendPopup = (): void => {
|
||||
showAppendModal.value = false
|
||||
selectedReview.value = null
|
||||
appendContent.value = ''
|
||||
}
|
||||
|
||||
const submitAppend = async (): Promise<void> => {
|
||||
if (selectedReview.value == null || appendContent.value.trim() === '') {
|
||||
uni.showToast({ title: '请输入评价内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '提交中...' })
|
||||
|
||||
try {
|
||||
const success = await supabaseService.appendReview(
|
||||
selectedReview.value.id,
|
||||
appendContent.value.trim(),
|
||||
[]
|
||||
)
|
||||
|
||||
if (success) {
|
||||
selectedReview.value.append_content = appendContent.value.trim()
|
||||
selectedReview.value.can_append = false
|
||||
closeAppendPopup()
|
||||
uni.showToast({ title: '追加成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '追加失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('追加评价失败:', e)
|
||||
uni.showToast({ title: '追加失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (review: MyReviewItem): void => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除这条评价吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
doDelete(review)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const doDelete = async (review: MyReviewItem): Promise<void> => {
|
||||
uni.showLoading({ title: '删除中...' })
|
||||
|
||||
try {
|
||||
const success = await supabaseService.deleteReview(review.id)
|
||||
if (success) {
|
||||
const index = reviews.value.indexOf(review)
|
||||
if (index > -1) {
|
||||
reviews.value.splice(index, 1)
|
||||
}
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('删除评价失败:', e)
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const previewImage = (images: string[], index: number): void => {
|
||||
uni.previewImage({
|
||||
urls: images,
|
||||
current: index
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string | null): string => {
|
||||
if (timeStr == null || timeStr === '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.my-reviews-page {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 14px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
border-bottom-color: #ff6b35;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-text {
|
||||
color: #ff6b35;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.review-item {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.product-detail {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rating-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.rating-stars {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.star {
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.star.filled {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.review-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.review-content {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.review-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.review-images {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.review-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.review-append {
|
||||
background-color: #f9f9f9;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.append-label {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.append-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 16px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.action-btn.append {
|
||||
background-color: #fff5f0;
|
||||
}
|
||||
|
||||
.action-btn.append .action-text {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.action-btn.delete .action-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pending-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pending-item {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pending-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.review-btn {
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
border-radius: 16px;
|
||||
padding: 0 20px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.order-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.append-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.append-input {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
border-radius: 24px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
border-radius: 24px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 消费者端 - 订单详情页 -->
|
||||
<!-- 消费者端 - 订单详情页 -->
|
||||
<template>
|
||||
<view class="order-detail-page">
|
||||
<scroll-view scroll-y="true" class="scroll-content">
|
||||
@@ -12,6 +12,15 @@
|
||||
</view>
|
||||
<text class="status-desc">{{ getStatusDesc() }}</text>
|
||||
</view>
|
||||
<!-- 分享免单入口 -->
|
||||
<view v-if="order?.order_status === 4" class="share-free-entry" @click="shareForFree">
|
||||
<text class="share-free-icon">🎁</text>
|
||||
<view class="share-free-info">
|
||||
<text class="share-free-title">分享免单</text>
|
||||
<text class="share-free-desc">分享给好友,4人购买即可免单</text>
|
||||
</view>
|
||||
<text class="share-free-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -143,6 +152,7 @@
|
||||
|
||||
<view v-if="order?.order_status === 4" class="btn-group">
|
||||
<button class="btn" @click="applyAfterSales">申请售后</button>
|
||||
<button class="btn share-free" @click="shareForFree">分享免单</button>
|
||||
<button class="btn" @click="rePurchase">再次购买</button>
|
||||
<button class="btn primary" @click="goToReview">评价订单</button>
|
||||
</view>
|
||||
@@ -686,6 +696,53 @@ const goToProduct = (pid: string) => {
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/product-detail?id=${pid}` })
|
||||
}
|
||||
|
||||
const shareForFree = async () => {
|
||||
if (orderItems.value.length === 0) {
|
||||
uni.showToast({ title: '没有可分享的商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const firstItem = orderItems.value[0]
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: '创建分享...' })
|
||||
const result = await supabaseService.createShareRecord(
|
||||
firstItem.product_id,
|
||||
orderId.value,
|
||||
firstItem.id,
|
||||
firstItem.product_name,
|
||||
firstItem.image_url,
|
||||
firstItem.price
|
||||
)
|
||||
uni.hideLoading()
|
||||
|
||||
const shareIdRaw = result.get('id')
|
||||
const shareCodeRaw = result.get('share_code')
|
||||
|
||||
if (shareIdRaw != null && shareCodeRaw != null) {
|
||||
const shareId = shareIdRaw as string
|
||||
const shareCode = shareCodeRaw as string
|
||||
|
||||
uni.showModal({
|
||||
title: '分享成功',
|
||||
content: `您的分享码: ${shareCode}\n分享给好友,当有4人购买后即可免单!`,
|
||||
confirmText: '查看详情',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/share/detail?id=${shareId}` })
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: '分享创建失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
console.error('[shareForFree] 创建分享失败:', e)
|
||||
uni.showToast({ title: '分享失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 onBackPress 拦截物理返回键和系统导航栏返回
|
||||
onBackPress((_): boolean => {
|
||||
const pages = getCurrentPages()
|
||||
@@ -792,6 +849,48 @@ onLoad((options) => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 分享免单入口 */
|
||||
.share-free-entry {
|
||||
margin-top: 20px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.share-free-icon {
|
||||
font-size: 28px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.share-free-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.share-free-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.share-free-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.share-free-arrow {
|
||||
font-size: 20px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* 配送信息 */
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
@@ -1113,6 +1212,13 @@ onLoad((options) => {
|
||||
box-shadow: 0 4px 8px rgba(255, 80, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn.share-free {
|
||||
background: linear-gradient(to right, #52c41a, #73d13d);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media screen and (min-width: 768px) {
|
||||
.card {
|
||||
|
||||
285
pages/mall/consumer/points/exchange-records.uvue
Normal file
285
pages/mall/consumer/points/exchange-records.uvue
Normal file
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<scroll-view class="records-page" scroll-y>
|
||||
<view class="empty-state" v-if="!loading && records.length === 0">
|
||||
<text class="empty-text">暂无兑换记录</text>
|
||||
</view>
|
||||
|
||||
<view class="loading-state" v-if="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="record-list" v-if="!loading && records.length > 0">
|
||||
<view class="record-item" v-for="record in records" :key="record.id">
|
||||
<view class="record-header">
|
||||
<text class="record-product-name">{{ record.product_name }}</text>
|
||||
<text class="record-status" :class="getStatusClass(record.status)">{{ getStatusText(record.status) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="record-info">
|
||||
<view class="info-row">
|
||||
<text class="info-label">消耗积分</text>
|
||||
<text class="info-value">{{ record.points_used }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">兑换数量</text>
|
||||
<text class="info-value">{{ record.quantity }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">兑换时间</text>
|
||||
<text class="info-value">{{ formatTime(record.created_at) }}</text>
|
||||
</view>
|
||||
<view class="info-row" v-if="record.tracking_no">
|
||||
<text class="info-label">物流单号</text>
|
||||
<text class="info-value">{{ record.tracking_no }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type ExchangeRecord = {
|
||||
id: string
|
||||
product_name: string
|
||||
product_image: string | null
|
||||
product_type: string
|
||||
quantity: number
|
||||
points_used: number
|
||||
status: number
|
||||
tracking_no: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const records = ref<ExchangeRecord[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
|
||||
const loadRecords = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getExchangeRecords()
|
||||
const parsed: ExchangeRecord[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
// 处理数组返回
|
||||
let recordData: any
|
||||
if (Array.isArray(itemAny)) {
|
||||
recordData = itemAny[0]
|
||||
} else {
|
||||
recordData = itemAny
|
||||
}
|
||||
|
||||
let id = ''
|
||||
let quantity = 1
|
||||
let points_used = 0
|
||||
let status = 0
|
||||
let tracking_no: string | null = null
|
||||
let created_at = ''
|
||||
let product_name = ''
|
||||
let product_image: string | null = null
|
||||
let product_type = 'coupon'
|
||||
|
||||
// 使用 _getValue 方法
|
||||
if (typeof recordData._getValue === 'function') {
|
||||
id = (recordData._getValue('id') as string) ?? ''
|
||||
quantity = (recordData._getValue('quantity') as number) ?? 1
|
||||
points_used = (recordData._getValue('points_used') as number) ?? 0
|
||||
status = (recordData._getValue('status') as number) ?? 0
|
||||
tracking_no = recordData._getValue('tracking_no') as string | null
|
||||
created_at = (recordData._getValue('created_at') as string) ?? ''
|
||||
|
||||
// 获取关联的商品信息
|
||||
const product = recordData._getValue('product')
|
||||
if (product != null) {
|
||||
const productAny = product as any
|
||||
if (typeof productAny._getValue === 'function') {
|
||||
product_name = (productAny._getValue('name') as string) ?? ''
|
||||
product_image = productAny._getValue('image_url') as string | null
|
||||
product_type = (productAny._getValue('product_type') as string) ?? 'coupon'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
id = recordData['id'] ?? ''
|
||||
quantity = recordData['quantity'] ?? 1
|
||||
points_used = recordData['points_used'] ?? 0
|
||||
status = recordData['status'] ?? 0
|
||||
tracking_no = recordData['tracking_no'] ?? null
|
||||
created_at = recordData['created_at'] ?? ''
|
||||
|
||||
const product = recordData['product']
|
||||
if (product != null) {
|
||||
product_name = product['name'] ?? ''
|
||||
product_image = product['image_url'] ?? null
|
||||
product_type = product['product_type'] ?? 'coupon'
|
||||
}
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
id,
|
||||
product_name,
|
||||
product_image,
|
||||
product_type,
|
||||
quantity,
|
||||
points_used,
|
||||
status,
|
||||
tracking_no,
|
||||
created_at
|
||||
})
|
||||
}
|
||||
|
||||
records.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载兑换记录失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: number): string => {
|
||||
if (status === 0) return '待处理'
|
||||
if (status === 1) return '已发货'
|
||||
if (status === 2) return '已完成'
|
||||
if (status === 3) return '已取消'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const getStatusClass = (status: number): string => {
|
||||
if (status === 0) return 'status-pending'
|
||||
if (status === 1) return 'status-shipped'
|
||||
if (status === 2) return 'status-completed'
|
||||
if (status === 3) return 'status-cancelled'
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string): string => {
|
||||
if (timeStr == '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
const hh = date.getHours().toString().padStart(2, '0')
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.records-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.record-product-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.record-status {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: #fff7e6;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.status-shipped {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background-color: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
735
pages/mall/consumer/points/exchange.uvue
Normal file
735
pages/mall/consumer/points/exchange.uvue
Normal file
@@ -0,0 +1,735 @@
|
||||
<template>
|
||||
<scroll-view class="exchange-page" scroll-y>
|
||||
<view class="header">
|
||||
<view class="points-info">
|
||||
<text class="points-label">可用积分</text>
|
||||
<text class="points-value">{{ totalPoints }}</text>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<text class="records-link" @click="goToRecords">兑换记录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tabs">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'all' ? 'active' : ''"
|
||||
@click="switchTab('all')"
|
||||
>
|
||||
<text class="tab-text">全部</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'coupon' ? 'active' : ''"
|
||||
@click="switchTab('coupon')"
|
||||
>
|
||||
<text class="tab-text">优惠券</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'physical' ? 'active' : ''"
|
||||
@click="switchTab('physical')"
|
||||
>
|
||||
<text class="tab-text">实物</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'virtual' ? 'active' : ''"
|
||||
@click="switchTab('virtual')"
|
||||
>
|
||||
<text class="tab-text">虚拟</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="product-list" v-if="!loading">
|
||||
<view
|
||||
class="product-card"
|
||||
v-for="product in filteredProducts"
|
||||
:key="product.id"
|
||||
@click="showExchangePopup(product)"
|
||||
>
|
||||
<image
|
||||
class="product-image"
|
||||
:src="product.image_url || defaultImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="product-desc" v-if="product.description">{{ product.description }}</text>
|
||||
<view class="product-bottom">
|
||||
<view class="product-points">
|
||||
<text class="points-num">{{ product.points_required }}</text>
|
||||
<text class="points-unit">积分</text>
|
||||
</view>
|
||||
<text class="product-stock">库存{{ product.stock }}件</text>
|
||||
<text class="product-original" v-if="product.original_price">¥{{ product.original_price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="!loading && filteredProducts.length === 0">
|
||||
<text class="empty-text">暂无可兑换商品</text>
|
||||
</view>
|
||||
|
||||
<view class="loading-state" v-if="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="exchange-popup" v-if="showPopup" @click="closePopup">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">确认兑换</text>
|
||||
<text class="popup-close" @click="closePopup">×</text>
|
||||
</view>
|
||||
|
||||
<view class="popup-product" v-if="selectedProduct != null">
|
||||
<image
|
||||
class="popup-product-image"
|
||||
:src="selectedProduct.image_url || defaultImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="popup-product-info">
|
||||
<text class="popup-product-name">{{ selectedProduct.name }}</text>
|
||||
<view class="popup-product-points">
|
||||
<text class="popup-points-num">{{ selectedProduct.points_required }}</text>
|
||||
<text class="popup-points-unit">积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-quantity">
|
||||
<text class="quantity-label">兑换数量</text>
|
||||
<view class="quantity-control">
|
||||
<text class="quantity-btn" @click="decreaseQuantity">-</text>
|
||||
<text class="quantity-value">{{ exchangeQuantity }}</text>
|
||||
<text class="quantity-btn" @click="increaseQuantity">+</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-summary">
|
||||
<view class="summary-row">
|
||||
<text class="summary-label">消耗积分</text>
|
||||
<text class="summary-value">{{ totalPointsCost }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text class="summary-label">当前积分</text>
|
||||
<text class="summary-value">{{ totalPoints }}</text>
|
||||
</view>
|
||||
<view class="summary-row" v-if="totalPoints < totalPointsCost">
|
||||
<text class="summary-label insufficient">积分不足</text>
|
||||
<text class="summary-value insufficient">差{{ totalPointsCost - totalPoints }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="popup-btn"
|
||||
:class="{ disabled: totalPoints < totalPointsCost }"
|
||||
:disabled="totalPoints < totalPointsCost || exchanging"
|
||||
@click="confirmExchange"
|
||||
>
|
||||
{{ exchanging ? '兑换中...' : '确认兑换' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="success-popup" v-if="showSuccess" @click="closeSuccess">
|
||||
<view class="success-content" @click.stop>
|
||||
<view class="success-icon">✓</view>
|
||||
<text class="success-title">兑换成功</text>
|
||||
<text class="success-desc">消耗 {{ totalPointsCost }} 积分</text>
|
||||
<button class="success-btn" @click="closeSuccess">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type PointProduct = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
image_url: string | null
|
||||
product_type: string
|
||||
points_required: number
|
||||
original_price: number | null
|
||||
stock: number
|
||||
status: number
|
||||
}
|
||||
|
||||
const totalPoints = ref<number>(0)
|
||||
const products = ref<PointProduct[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const activeTab = ref<string>('all')
|
||||
const showPopup = ref<boolean>(false)
|
||||
const showSuccess = ref<boolean>(false)
|
||||
const selectedProduct = ref<PointProduct | null>(null)
|
||||
const exchangeQuantity = ref<number>(1)
|
||||
const exchanging = ref<boolean>(false)
|
||||
|
||||
const defaultImage: string = '/static/images/default-product.png'
|
||||
|
||||
const filteredProducts = computed((): PointProduct[] => {
|
||||
if (activeTab.value === 'all') {
|
||||
return products.value
|
||||
}
|
||||
const filtered: PointProduct[] = []
|
||||
for (let i = 0; i < products.value.length; i++) {
|
||||
if (products.value[i].product_type === activeTab.value) {
|
||||
filtered.push(products.value[i])
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalPointsCost = computed((): number => {
|
||||
if (selectedProduct.value == null) return 0
|
||||
return selectedProduct.value.points_required * exchangeQuantity.value
|
||||
})
|
||||
|
||||
const loadProducts = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const points = await supabaseService.getUserPoints()
|
||||
totalPoints.value = points
|
||||
|
||||
const productList = await supabaseService.getPointProducts()
|
||||
const parsed: PointProduct[] = []
|
||||
for (let i = 0; i < productList.length; i++) {
|
||||
const item = productList[i]
|
||||
const itemAny = item as any
|
||||
|
||||
let id = ''
|
||||
let name = ''
|
||||
let description: string | null = null
|
||||
let image_url: string | null = null
|
||||
let product_type = 'coupon'
|
||||
let points_required = 0
|
||||
let original_price: number | null = null
|
||||
let stock = 0
|
||||
let status = 1
|
||||
|
||||
// UTSJSONObject2 需要使用 _getValue 方法
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
id = (itemAny._getValue('id') as string) ?? ''
|
||||
name = (itemAny._getValue('name') as string) ?? ''
|
||||
description = itemAny._getValue('description') as string | null
|
||||
image_url = itemAny._getValue('image_url') as string | null
|
||||
product_type = (itemAny._getValue('product_type') as string) ?? 'coupon'
|
||||
points_required = (itemAny._getValue('points_required') as number) ?? 0
|
||||
original_price = itemAny._getValue('original_price') as number | null
|
||||
stock = (itemAny._getValue('stock') as number) ?? 0
|
||||
status = (itemAny._getValue('status') as number) ?? 1
|
||||
} else {
|
||||
id = itemAny['id'] ?? ''
|
||||
name = itemAny['name'] ?? ''
|
||||
description = itemAny['description'] ?? null
|
||||
image_url = itemAny['image_url'] ?? null
|
||||
product_type = itemAny['product_type'] ?? 'coupon'
|
||||
points_required = itemAny['points_required'] ?? 0
|
||||
original_price = itemAny['original_price'] ?? null
|
||||
stock = itemAny['stock'] ?? 0
|
||||
status = itemAny['status'] ?? 1
|
||||
}
|
||||
|
||||
const product: PointProduct = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
image_url,
|
||||
product_type,
|
||||
points_required,
|
||||
original_price,
|
||||
stock,
|
||||
status
|
||||
}
|
||||
parsed.push(product)
|
||||
}
|
||||
products.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载商品失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchTab = (tab: string): void => {
|
||||
activeTab.value = tab
|
||||
}
|
||||
|
||||
const showExchangePopup = (product: PointProduct): void => {
|
||||
selectedProduct.value = product
|
||||
exchangeQuantity.value = 1
|
||||
showPopup.value = true
|
||||
}
|
||||
|
||||
const closePopup = (): void => {
|
||||
showPopup.value = false
|
||||
selectedProduct.value = null
|
||||
}
|
||||
|
||||
const increaseQuantity = (): void => {
|
||||
if (selectedProduct.value != null && exchangeQuantity.value < selectedProduct.value.stock) {
|
||||
exchangeQuantity.value++
|
||||
}
|
||||
}
|
||||
|
||||
const decreaseQuantity = (): void => {
|
||||
if (exchangeQuantity.value > 1) {
|
||||
exchangeQuantity.value--
|
||||
}
|
||||
}
|
||||
|
||||
const confirmExchange = async (): Promise<void> => {
|
||||
if (selectedProduct.value == null) return
|
||||
if (totalPoints.value < totalPointsCost.value) return
|
||||
|
||||
exchanging.value = true
|
||||
|
||||
try {
|
||||
const result = await supabaseService.exchangeProduct(
|
||||
selectedProduct.value.id,
|
||||
exchangeQuantity.value,
|
||||
null
|
||||
)
|
||||
|
||||
if (result.getBoolean('success') === true) {
|
||||
showPopup.value = false
|
||||
totalPoints.value -= totalPointsCost.value
|
||||
showSuccess.value = true
|
||||
loadProducts()
|
||||
} else {
|
||||
const message = result.getString('message') ?? '兑换失败'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('兑换异常:', e)
|
||||
uni.showToast({ title: '兑换异常', icon: 'none' })
|
||||
} finally {
|
||||
exchanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeSuccess = (): void => {
|
||||
showSuccess.value = false
|
||||
}
|
||||
|
||||
const goToRecords = (): void => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/points/exchange-records'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.exchange-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.points-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.records-link {
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
border-bottom-color: #ff6b35;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-text {
|
||||
color: #ff6b35;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
width: calc(50% - 8px);
|
||||
margin: 4px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
lines: 1;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-bottom {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.product-points {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.points-num {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.points-unit {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.product-original {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.exchange-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.popup-product {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 12px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-product-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.popup-product-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.popup-product-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
}
|
||||
|
||||
.popup-product-points {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.popup-points-num {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.popup-points-unit {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.popup-quantity {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.quantity-label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.quantity-control {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quantity-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quantity-value {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-summary {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.summary-label.insufficient {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-value.insufficient {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border-radius: 24px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.popup-btn.disabled {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.success-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.success-content {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #52c41a;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.success-btn {
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="points-page">
|
||||
<template>
|
||||
<scroll-view class="points-page" scroll-y>
|
||||
<view class="points-header">
|
||||
<view class="points-info">
|
||||
<text class="points-label">当前积分</text>
|
||||
@@ -9,7 +9,53 @@
|
||||
<button class="exchange-btn" @click="handleExchange">积分兑换</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
<view class="quick-actions">
|
||||
<view class="action-item" @click="goToSignin">
|
||||
<view class="action-icon signin-icon">📅</view>
|
||||
<text class="action-text">每日签到</text>
|
||||
<view class="action-badge" v-if="!signedToday">
|
||||
<text class="badge-text">+5</text>
|
||||
</view>
|
||||
<view class="signed-badge" v-else>
|
||||
<text class="signed-text">已签</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="action-item" @click="handleExchange">
|
||||
<view class="action-icon exchange-icon">🎁</view>
|
||||
<text class="action-text">积分兑换</text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToMyReviews">
|
||||
<view class="action-icon review-icon">⭐</view>
|
||||
<text class="action-text">我的评价</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="signin-card" v-if="!signedToday">
|
||||
<view class="signin-info">
|
||||
<text class="signin-title">今日未签到</text>
|
||||
<text class="signin-desc">连续签到可获得额外奖励</text>
|
||||
</view>
|
||||
<button class="signin-btn" @click="goToSignin">去签到</button>
|
||||
</view>
|
||||
|
||||
<view class="signin-card signed" v-else>
|
||||
<view class="signin-info">
|
||||
<text class="signin-title">今日已签到</text>
|
||||
<text class="signin-desc">已连续签到 {{ continuousDays }} 天</text>
|
||||
</view>
|
||||
<text class="signed-icon">✓</text>
|
||||
</view>
|
||||
|
||||
<view class="expiring-card" v-if="expiringPoints > 0" @click="showExpiringDetails">
|
||||
<view class="expiring-icon">⚠️</view>
|
||||
<view class="expiring-info">
|
||||
<text class="expiring-title">{{ expiringPoints }} 积分即将过期</text>
|
||||
<text class="expiring-date">过期日期:{{ expiringDate }}</text>
|
||||
</view>
|
||||
<text class="expiring-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<view class="records-section">
|
||||
<text class="section-title">积分明细</text>
|
||||
|
||||
@@ -35,7 +81,31 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="expiring-popup" v-if="showExpiringPopup" @click="closeExpiringPopup">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">即将过期积分</text>
|
||||
<text class="popup-close" @click="closeExpiringPopup">×</text>
|
||||
</view>
|
||||
<view class="popup-list">
|
||||
<view class="popup-item" v-for="(detail, index) in expiringDetails" :key="index">
|
||||
<view class="popup-item-info">
|
||||
<text class="popup-item-points">+{{ detail.points }} 积分</text>
|
||||
<text class="popup-item-desc">{{ detail.description ?? '积分获取' }}</text>
|
||||
</view>
|
||||
<view class="popup-item-expire">
|
||||
<text class="popup-item-date">{{ formatDate(detail.expires_at) }}</text>
|
||||
<text class="popup-item-label">过期</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="popup-tip">
|
||||
<text class="tip-text">积分有效期为获取后365天,请及时使用避免过期</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
@@ -51,9 +121,22 @@ type PointRecord = {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type ExpiringDetail = {
|
||||
points: number
|
||||
description: string | null
|
||||
expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const totalPoints = ref<number>(0)
|
||||
const records = ref<PointRecord[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const signedToday = ref<boolean>(false)
|
||||
const continuousDays = ref<number>(0)
|
||||
const expiringPoints = ref<number>(0)
|
||||
const expiringDate = ref<string>('')
|
||||
const expiringDetails = ref<ExpiringDetail[]>([])
|
||||
const showExpiringPopup = ref<boolean>(false)
|
||||
|
||||
const loadPoints = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -67,16 +150,62 @@ const loadPoints = async (): Promise<void> => {
|
||||
const loadRecords = async (): Promise<void> => {
|
||||
try {
|
||||
const list = await supabaseService.getPointRecords()
|
||||
records.value = list as PointRecord[]
|
||||
records.value = list as PointRecord[]
|
||||
} catch (e) {
|
||||
console.error('获取积分记录失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSigninStatus = async (): Promise<void> => {
|
||||
try {
|
||||
const status = await supabaseService.getTodaySigninStatus()
|
||||
signedToday.value = status.getBoolean('signed') ?? false
|
||||
continuousDays.value = status.getNumber('continuous_days') ?? 0
|
||||
} catch (e) {
|
||||
console.error('获取签到状态失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadExpiringPoints = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getExpiringPoints()
|
||||
expiringPoints.value = result.getNumber('expiring_points') ?? 0
|
||||
expiringDate.value = result.getString('expiring_date') ?? ''
|
||||
|
||||
const detailsRaw = result.get('details')
|
||||
if (detailsRaw != null && Array.isArray(detailsRaw)) {
|
||||
const details: ExpiringDetail[] = []
|
||||
const arr = detailsRaw as any[]
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i]
|
||||
let itemObj: UTSJSONObject
|
||||
if (item instanceof UTSJSONObject) {
|
||||
itemObj = item
|
||||
} else {
|
||||
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||||
}
|
||||
details.push({
|
||||
points: itemObj.getNumber('points') ?? 0,
|
||||
description: itemObj.getString('description'),
|
||||
expires_at: itemObj.getString('expires_at') ?? '',
|
||||
created_at: itemObj.getString('created_at') ?? ''
|
||||
})
|
||||
}
|
||||
expiringDetails.value = details
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取即将过期积分失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
await loadPoints()
|
||||
await loadRecords()
|
||||
await Promise.all([
|
||||
loadPoints(),
|
||||
loadRecords(),
|
||||
loadSigninStatus(),
|
||||
loadExpiringPoints()
|
||||
])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -84,15 +213,33 @@ onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
const handleExchange = () => {
|
||||
uni.showToast({
|
||||
title: '积分商城开发中',
|
||||
icon: 'none'
|
||||
const handleExchange = (): void => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/points/exchange'
|
||||
})
|
||||
}
|
||||
|
||||
const goToSignin = (): void => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/points/signin'
|
||||
})
|
||||
}
|
||||
|
||||
const goToMyReviews = (): void => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/my-reviews'
|
||||
})
|
||||
}
|
||||
|
||||
const showExpiringDetails = (): void => {
|
||||
showExpiringPopup.value = true
|
||||
}
|
||||
|
||||
const closeExpiringPopup = (): void => {
|
||||
showExpiringPopup.value = false
|
||||
}
|
||||
|
||||
const getTypeText = (type: string): string => {
|
||||
// 不支持 Record<string, string>,使用 if-else
|
||||
if (type == 'signin') {
|
||||
return '每日签到'
|
||||
} else if (type == 'shopping') {
|
||||
@@ -103,6 +250,8 @@ const getTypeText = (type: string): string => {
|
||||
return '系统调整'
|
||||
} else if (type == 'register') {
|
||||
return '注册赠送'
|
||||
} else if (type == 'expire') {
|
||||
return '积分过期'
|
||||
} else {
|
||||
return '积分变动'
|
||||
}
|
||||
@@ -118,15 +267,26 @@ const formatTime = (timeStr: string): string => {
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (dateStr == '') return ''
|
||||
const date = new Date(dateStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.points-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.points-header {
|
||||
background-color: #ff5000;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
padding: 30px 20px;
|
||||
color: white;
|
||||
display: flex;
|
||||
@@ -161,11 +321,174 @@ const formatTime = (timeStr: string): string => {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.signin-icon {
|
||||
background-color: #fff5f0;
|
||||
}
|
||||
|
||||
.exchange-icon {
|
||||
background-color: #f0f5ff;
|
||||
}
|
||||
|
||||
.review-icon {
|
||||
background-color: #fff5f0;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
background-color: #ff6b35;
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.signed-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
background-color: #52c41a;
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.signed-text {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.signin-card {
|
||||
background-color: white;
|
||||
margin: 0 12px 8px;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signin-card.signed {
|
||||
background: linear-gradient(135deg, #f6ffed 0%, #e6fffb 100%);
|
||||
}
|
||||
|
||||
.signin-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.signin-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.signin-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.signin-btn {
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
border-radius: 16px;
|
||||
padding: 0 20px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.signed-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #52c41a;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.expiring-card {
|
||||
background: linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%);
|
||||
margin: 0 12px 8px;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expiring-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.expiring-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.expiring-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.expiring-date {
|
||||
font-size: 12px;
|
||||
color: #ad8b00;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.expiring-arrow {
|
||||
font-size: 20px;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.records-section {
|
||||
background-color: white;
|
||||
margin-top: 10px;
|
||||
padding: 0 16px;
|
||||
min-height: 500px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -187,7 +510,10 @@ const formatTime = (timeStr: string): string => {
|
||||
.record-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
@@ -204,7 +530,7 @@ const formatTime = (timeStr: string): string => {
|
||||
}
|
||||
|
||||
.record-amount.positive {
|
||||
color: #ff5000;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.record-amount.negative {
|
||||
@@ -221,4 +547,108 @@ const formatTime = (timeStr: string): string => {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.loading-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expiring-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
max-height: 60%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.popup-list {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.popup-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.popup-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popup-item-points {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.popup-item-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.popup-item-expire {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.popup-item-date {
|
||||
font-size: 12px;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.popup-item-label {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.popup-tip {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
562
pages/mall/consumer/points/signin.uvue
Normal file
562
pages/mall/consumer/points/signin.uvue
Normal file
@@ -0,0 +1,562 @@
|
||||
<template>
|
||||
<view class="signin-page">
|
||||
<view class="header">
|
||||
<view class="header-content">
|
||||
<text class="title">每日签到</text>
|
||||
<text class="subtitle">连续签到可获得额外奖励</text>
|
||||
</view>
|
||||
<view class="points-display">
|
||||
<text class="points-label">当前积分</text>
|
||||
<text class="points-value">{{ totalPoints }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="calendar-section">
|
||||
<view class="calendar-header">
|
||||
<view class="month-nav">
|
||||
<text class="nav-btn" @click="prevMonth"><</text>
|
||||
<text class="current-month">{{ currentYear }}年{{ currentMonth }}月</text>
|
||||
<text class="nav-btn" @click="nextMonth">></text>
|
||||
</view>
|
||||
<view class="continuous-info">
|
||||
<text class="continuous-label">已连续签到</text>
|
||||
<text class="continuous-value">{{ continuousDays }}天</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="calendar-weekdays">
|
||||
<text class="weekday" v-for="day in weekdays" :key="day">{{ day }}</text>
|
||||
</view>
|
||||
|
||||
<view class="calendar-days">
|
||||
<view
|
||||
v-for="(day, index) in calendarDays"
|
||||
:key="index"
|
||||
class="day-cell"
|
||||
:class="{
|
||||
'empty': day.day === 0,
|
||||
'signed': day.signed,
|
||||
'today': day.isToday
|
||||
}"
|
||||
>
|
||||
<text v-if="day.day > 0" class="day-number">{{ day.day }}</text>
|
||||
<view v-if="day.signed" class="signed-mark">
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="signin-btn-section">
|
||||
<button
|
||||
class="signin-btn"
|
||||
:class="{ 'signed-today': signedToday }"
|
||||
:disabled="signedToday"
|
||||
@click="doSignin"
|
||||
>
|
||||
{{ signedToday ? '今日已签到' : '立即签到' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rules-section">
|
||||
<text class="section-title">签到规则</text>
|
||||
<view class="rule-list">
|
||||
<view class="rule-item">
|
||||
<text class="rule-icon">📅</text>
|
||||
<text class="rule-text">每日签到可获得5积分</text>
|
||||
</view>
|
||||
<view class="rule-item">
|
||||
<text class="rule-icon">🔥</text>
|
||||
<text class="rule-text">连续签到7天额外奖励20积分</text>
|
||||
</view>
|
||||
<view class="rule-item">
|
||||
<text class="rule-icon">🏆</text>
|
||||
<text class="rule-text">连续签到30天额外奖励100积分</text>
|
||||
</view>
|
||||
<view class="rule-item">
|
||||
<text class="rule-icon">⚠️</text>
|
||||
<text class="rule-text">中断签到后连续天数将重置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="signin-popup" v-if="showPopup" @click="closePopup">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-icon">🎉</view>
|
||||
<text class="popup-title">签到成功</text>
|
||||
<view class="popup-points">
|
||||
<text class="popup-points-label">获得积分</text>
|
||||
<text class="popup-points-value">+{{ popupPoints }}</text>
|
||||
</view>
|
||||
<view class="popup-bonus" v-if="popupBonus > 0">
|
||||
<text class="popup-bonus-label">连续签到奖励</text>
|
||||
<text class="popup-bonus-value">+{{ popupBonus }}</text>
|
||||
</view>
|
||||
<view class="popup-continuous">
|
||||
<text>已连续签到 {{ popupContinuousDays }} 天</text>
|
||||
</view>
|
||||
<button class="popup-btn" @click="closePopup">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type CalendarDay = {
|
||||
day: number
|
||||
signed: boolean
|
||||
isToday: boolean
|
||||
}
|
||||
|
||||
const totalPoints = ref<number>(0)
|
||||
const continuousDays = ref<number>(0)
|
||||
const signedToday = ref<boolean>(false)
|
||||
const currentYear = ref<number>(new Date().getFullYear())
|
||||
const currentMonth = ref<number>(new Date().getMonth() + 1)
|
||||
const signinRecords = ref<string[]>([])
|
||||
const showPopup = ref<boolean>(false)
|
||||
const popupPoints = ref<number>(0)
|
||||
const popupBonus = ref<number>(0)
|
||||
const popupContinuousDays = ref<number>(0)
|
||||
|
||||
const weekdays: string[] = ['日', '一', '二', '三', '四', '五', '六']
|
||||
|
||||
const calendarDays = computed((): CalendarDay[] => {
|
||||
const days: CalendarDay[] = []
|
||||
const year = currentYear.value
|
||||
const month = currentMonth.value
|
||||
|
||||
const firstDay = new Date(year, month - 1, 1).getDay()
|
||||
const daysInMonth = new Date(year, month, 0).getDate()
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push({ day: 0, signed: false, isToday: false })
|
||||
}
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${i.toString().padStart(2, '0')}`
|
||||
const isToday = dateStr === todayStr
|
||||
const signed = signinRecords.value.includes(dateStr)
|
||||
days.push({ day: i, signed, isToday })
|
||||
}
|
||||
|
||||
return days
|
||||
})
|
||||
|
||||
const loadSigninData = async (): Promise<void> => {
|
||||
uni.showLoading({ title: '加载中...' })
|
||||
|
||||
try {
|
||||
const points = await supabaseService.getUserPoints()
|
||||
totalPoints.value = points
|
||||
|
||||
const status = await supabaseService.getTodaySigninStatus()
|
||||
signedToday.value = status.getBoolean('signed') ?? false
|
||||
continuousDays.value = status.getNumber('continuous_days') ?? 0
|
||||
|
||||
const records = await supabaseService.getSigninRecords(currentYear.value, currentMonth.value)
|
||||
const dates: string[] = []
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i]
|
||||
let dateStr = ''
|
||||
if (record instanceof UTSJSONObject) {
|
||||
dateStr = record.getString('signin_date') ?? ''
|
||||
} else {
|
||||
const rObj = JSON.parse(JSON.stringify(record)) as UTSJSONObject
|
||||
dateStr = rObj.getString('signin_date') ?? ''
|
||||
}
|
||||
if (dateStr !== '') {
|
||||
dates.push(dateStr)
|
||||
}
|
||||
}
|
||||
signinRecords.value = dates
|
||||
} catch (e) {
|
||||
console.error('加载签到数据失败:', e)
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const doSignin = async (): Promise<void> => {
|
||||
if (signedToday.value) return
|
||||
|
||||
uni.showLoading({ title: '签到中...' })
|
||||
|
||||
try {
|
||||
const result = await supabaseService.signin()
|
||||
|
||||
if (result.getBoolean('success') === true) {
|
||||
popupPoints.value = result.getNumber('points') ?? 0
|
||||
popupBonus.value = result.getNumber('bonus_points') ?? 0
|
||||
popupContinuousDays.value = result.getNumber('continuous_days') ?? 0
|
||||
totalPoints.value = result.getNumber('total_points') ?? 0
|
||||
continuousDays.value = popupContinuousDays.value
|
||||
signedToday.value = true
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
signinRecords.value.push(today)
|
||||
|
||||
showPopup.value = true
|
||||
} else {
|
||||
const message = result.getString('message') ?? '签到失败'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('签到异常:', e)
|
||||
uni.showToast({ title: '签到异常', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const closePopup = (): void => {
|
||||
showPopup.value = false
|
||||
}
|
||||
|
||||
const prevMonth = (): void => {
|
||||
if (currentMonth.value === 1) {
|
||||
currentYear.value--
|
||||
currentMonth.value = 12
|
||||
} else {
|
||||
currentMonth.value--
|
||||
}
|
||||
loadSigninData()
|
||||
}
|
||||
|
||||
const nextMonth = (): void => {
|
||||
if (currentMonth.value === 12) {
|
||||
currentYear.value++
|
||||
currentMonth.value = 1
|
||||
} else {
|
||||
currentMonth.value++
|
||||
}
|
||||
loadSigninData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSigninData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.signin-page {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.points-display {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.month-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.current-month {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.continuous-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: #fff5f0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.continuous-label {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.continuous-value {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.calendar-days {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
width: 14.28%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.day-cell.empty {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.day-cell.today .day-number {
|
||||
color: #ff6b35;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.day-cell.signed {
|
||||
background-color: #fff5f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.signed-mark {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ff6b35;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.signin-btn-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.signin-btn {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
border-radius: 24px;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.signin-btn.signed-today {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.rules-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.rule-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.rule-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.signin-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-points {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.popup-points-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.popup-points-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.popup-bonus {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.popup-bonus-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.popup-bonus-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.popup-continuous {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 消费者端 - 商品详情页 -->
|
||||
<!-- 消费者端 - 商品详情页 -->
|
||||
<template>
|
||||
<view class="product-detail-page">
|
||||
<scroll-view class="page-scroll" scroll-y="true">
|
||||
@@ -16,8 +16,12 @@
|
||||
<view class="product-info">
|
||||
<view class="price-section">
|
||||
<text class="current-price">¥{{ product.price }}</text>
|
||||
<text v-if="memberPrice > 0 && memberPrice < product.price" class="member-price-tag">会员价 ¥{{ memberPrice }}</text>
|
||||
<text v-if="product.original_price" class="original-price">¥{{ product.original_price }}</text>
|
||||
</view>
|
||||
<view v-if="memberDiscount > 0" class="member-discount-row">
|
||||
<text class="member-discount-text">会员专享 {{ memberDiscount }}折优惠</text>
|
||||
</view>
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="sales-info">已售{{ product.sales }}件 · 库存{{ product.stock }}件</text>
|
||||
</view>
|
||||
@@ -270,7 +274,11 @@ export default {
|
||||
showParams: false,
|
||||
// 新增: 优惠券相关
|
||||
coupons: [] as Array<CouponTemplateType>,
|
||||
showCoupons: false
|
||||
showCoupons: false,
|
||||
// 会员价相关
|
||||
memberPrice: 0 as number,
|
||||
memberDiscount: 0 as number,
|
||||
memberLevelName: '' as string
|
||||
}
|
||||
},
|
||||
onLoad(options: any) {
|
||||
@@ -461,6 +469,9 @@ export default {
|
||||
if (this.product.id != null && this.product.id !== '') {
|
||||
this.loadProductSkus(this.product.id)
|
||||
}
|
||||
|
||||
// 加载会员价
|
||||
this.loadMemberPrice()
|
||||
|
||||
uni.hideLoading()
|
||||
},
|
||||
@@ -559,6 +570,28 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async loadMemberPrice() {
|
||||
try {
|
||||
const memberInfo = await supabaseService.getUserMemberInfo()
|
||||
const levelNameRaw = memberInfo.get('level_name')
|
||||
const discountRaw = memberInfo.get('discount')
|
||||
|
||||
if (levelNameRaw != null) {
|
||||
this.memberLevelName = levelNameRaw as string
|
||||
}
|
||||
|
||||
if (discountRaw != null) {
|
||||
const discount = discountRaw as number
|
||||
if (discount > 0 && discount < 10) {
|
||||
this.memberDiscount = discount
|
||||
this.memberPrice = Math.round(this.product.price * discount) / 10
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('获取会员信息失败,可能未登录或非会员:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 新增:加载优惠券
|
||||
async loadCoupons() {
|
||||
if (this.product.merchant_id == '') return
|
||||
@@ -958,6 +991,25 @@ export default {
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.member-price-tag {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
background-color: #f6ffed;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.member-discount-row {
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.member-discount-text {
|
||||
font-size: 24rpx;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.original-price {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
|
||||
749
pages/mall/consumer/product-reviews.uvue
Normal file
749
pages/mall/consumer/product-reviews.uvue
Normal file
@@ -0,0 +1,749 @@
|
||||
<template>
|
||||
<view class="reviews-page">
|
||||
<view class="stats-section" v-if="stats.total_count > 0">
|
||||
<view class="stats-header">
|
||||
<view class="stats-main">
|
||||
<text class="stats-avg">{{ stats.avg_rating }}</text>
|
||||
<text class="stats-label">综合评分</text>
|
||||
</view>
|
||||
<view class="stats-detail">
|
||||
<view class="stats-row">
|
||||
<text class="stats-good">{{ stats.good_rate }}%</text>
|
||||
<text class="stats-good-label">好评率</text>
|
||||
</view>
|
||||
<view class="stats-row">
|
||||
<text class="stats-total">{{ stats.total_count }}</text>
|
||||
<text class="stats-total-label">评价数</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="rating-bars">
|
||||
<view class="rating-bar" v-for="i in 5" :key="i">
|
||||
<text class="rating-label">{{ 6 - i }}星</text>
|
||||
<view class="rating-progress">
|
||||
<view
|
||||
class="rating-fill"
|
||||
:style="{ width: getRatingPercent(6 - i) + '%' }"
|
||||
></view>
|
||||
</view>
|
||||
<text class="rating-count">{{ getRatingCount(6 - i) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="filter-section">
|
||||
<scroll-view scroll-x class="filter-scroll">
|
||||
<view class="filter-list">
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: filterRating === 0 }"
|
||||
@click="setFilterRating(0)"
|
||||
>
|
||||
<text class="filter-text">全部({{ stats.total_count }})</text>
|
||||
</view>
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: filterRating === 5 }"
|
||||
@click="setFilterRating(5)"
|
||||
>
|
||||
<text class="filter-text">好评({{ getRatingCount(5) }})</text>
|
||||
</view>
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: filterRating === 4 }"
|
||||
@click="setFilterRating(4)"
|
||||
>
|
||||
<text class="filter-text">中评({{ getRatingCount(4) + getRatingCount(3) }})</text>
|
||||
</view>
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: filterRating === 2 }"
|
||||
@click="setFilterRating(2)"
|
||||
>
|
||||
<text class="filter-text">差评({{ getRatingCount(2) + getRatingCount(1) }})</text>
|
||||
</view>
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: hasImageFilter }"
|
||||
@click="toggleHasImage"
|
||||
>
|
||||
<text class="filter-text">有图</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="review-list">
|
||||
<view class="review-item" v-for="review in reviews" :key="review.id">
|
||||
<view class="review-header">
|
||||
<image
|
||||
class="user-avatar"
|
||||
:src="review.user_avatar || defaultAvatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ review.user_name }}</text>
|
||||
<view class="rating-stars">
|
||||
<text
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="star"
|
||||
:class="{ filled: star <= review.rating }"
|
||||
>★</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="review-time">{{ formatTime(review.created_at) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-content">
|
||||
<text class="review-text">{{ review.content }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-images" v-if="review.images.length > 0">
|
||||
<image
|
||||
v-for="(img, idx) in review.images.slice(0, 3)"
|
||||
:key="idx"
|
||||
class="review-image"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
@click="previewImage(review.images, idx)"
|
||||
/>
|
||||
<view class="more-images" v-if="review.images.length > 3">
|
||||
<text>+{{ review.images.length - 3 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="review-append" v-if="review.append_content">
|
||||
<text class="append-label">追评</text>
|
||||
<text class="append-text">{{ review.append_content }}</text>
|
||||
<text class="append-time">{{ formatTime(review.append_at) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-reply" v-if="review.reply">
|
||||
<text class="reply-label">商家回复:</text>
|
||||
<text class="reply-text">{{ review.reply }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-footer">
|
||||
<view
|
||||
class="like-btn"
|
||||
:class="{ liked: review.is_liked }"
|
||||
@click="toggleLike(review)"
|
||||
>
|
||||
<text class="like-icon">{{ review.is_liked ? '❤' : '♡' }}</text>
|
||||
<text class="like-count">{{ review.like_count || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="!loading && reviews.length === 0">
|
||||
<text class="empty-text">暂无评价</text>
|
||||
</view>
|
||||
|
||||
<view class="loading-state" v-if="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="!loading && hasMore && reviews.length > 0" @click="loadMore">
|
||||
<text class="load-more-text">加载更多</text>
|
||||
</view>
|
||||
|
||||
<view class="no-more" v-if="!loading && !hasMore && reviews.length > 0">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type ReviewItem = {
|
||||
id: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_avatar: string
|
||||
rating: number
|
||||
content: string
|
||||
images: string[]
|
||||
is_anonymous: boolean
|
||||
like_count: number
|
||||
is_liked: boolean
|
||||
append_content: string | null
|
||||
append_at: string | null
|
||||
reply: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type StatsType = {
|
||||
total_count: number
|
||||
avg_rating: number
|
||||
good_rate: number
|
||||
rating_distribution: Map<string, number>
|
||||
}
|
||||
|
||||
const productId = ref<string>('')
|
||||
const reviews = ref<ReviewItem[]>([])
|
||||
const stats = ref<StatsType>({
|
||||
total_count: 0,
|
||||
avg_rating: 0,
|
||||
good_rate: 0,
|
||||
rating_distribution: new Map<string, number>()
|
||||
})
|
||||
const loading = ref<boolean>(true)
|
||||
const hasMore = ref<boolean>(true)
|
||||
const page = ref<number>(1)
|
||||
const pageSize = 10
|
||||
const filterRating = ref<number>(0)
|
||||
const hasImageFilter = ref<boolean>(false)
|
||||
|
||||
const defaultAvatar: string = '/static/images/default-avatar.png'
|
||||
|
||||
const getRatingCount = (rating: number): number => {
|
||||
return stats.value.rating_distribution.get(rating.toString()) ?? 0
|
||||
}
|
||||
|
||||
const getRatingPercent = (rating: number): number => {
|
||||
if (stats.value.total_count === 0) return 0
|
||||
const count = getRatingCount(rating)
|
||||
return Math.round((count / stats.value.total_count) * 100)
|
||||
}
|
||||
|
||||
const loadStats = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getReviewStats(productId.value)
|
||||
const distMap = new Map<string, number>()
|
||||
|
||||
const dist = result.get('rating_distribution')
|
||||
if (dist != null && dist instanceof UTSJSONObject) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
distMap.set(i.toString(), dist.getNumber(i.toString()) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
stats.value = {
|
||||
total_count: result.getNumber('total_count') ?? 0,
|
||||
avg_rating: result.getNumber('avg_rating') ?? 0,
|
||||
good_rate: result.getNumber('good_rate') ?? 0,
|
||||
rating_distribution: distMap
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载统计失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadReviews = async (pageNum: number = 1): Promise<void> => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await supabaseService.getProductReviews(
|
||||
productId.value,
|
||||
pageNum,
|
||||
pageSize,
|
||||
filterRating.value,
|
||||
hasImageFilter.value
|
||||
)
|
||||
|
||||
const total = result.getNumber('total') ?? 0
|
||||
const data = result.get('data')
|
||||
const reviewList: ReviewItem[] = []
|
||||
|
||||
if (data != null && Array.isArray(data)) {
|
||||
const rawList = data as any[]
|
||||
for (let i = 0; i < rawList.length; i++) {
|
||||
const item = rawList[i]
|
||||
let reviewObj: UTSJSONObject
|
||||
if (item instanceof UTSJSONObject) {
|
||||
reviewObj = item
|
||||
} else {
|
||||
reviewObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||||
}
|
||||
|
||||
let images: string[] = []
|
||||
const imagesRaw = reviewObj.get('images')
|
||||
if (imagesRaw != null && typeof imagesRaw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(imagesRaw as string)
|
||||
if (Array.isArray(parsed)) {
|
||||
images = parsed as string[]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析图片失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const review: ReviewItem = {
|
||||
id: reviewObj.getString('id') ?? '',
|
||||
user_id: reviewObj.getString('user_id') ?? '',
|
||||
user_name: reviewObj.getString('user_name') ?? '匿名用户',
|
||||
user_avatar: reviewObj.getString('user_avatar') ?? '',
|
||||
rating: reviewObj.getNumber('rating') ?? 5,
|
||||
content: reviewObj.getString('content') ?? '',
|
||||
images: images,
|
||||
is_anonymous: reviewObj.getBoolean('is_anonymous') ?? false,
|
||||
like_count: reviewObj.getNumber('like_count') ?? 0,
|
||||
is_liked: reviewObj.getBoolean('is_liked') ?? false,
|
||||
append_content: reviewObj.getString('append_content'),
|
||||
append_at: reviewObj.getString('append_at'),
|
||||
reply: reviewObj.getString('reply'),
|
||||
created_at: reviewObj.getString('created_at') ?? ''
|
||||
}
|
||||
reviewList.push(review)
|
||||
}
|
||||
}
|
||||
|
||||
if (pageNum === 1) {
|
||||
reviews.value = reviewList
|
||||
} else {
|
||||
reviews.value = [...reviews.value, ...reviewList]
|
||||
}
|
||||
|
||||
hasMore.value = reviews.value.length < total
|
||||
page.value = pageNum
|
||||
} catch (e) {
|
||||
console.error('加载评价失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = (): void => {
|
||||
if (!loading.value && hasMore.value) {
|
||||
loadReviews(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const setFilterRating = (rating: number): void => {
|
||||
filterRating.value = rating
|
||||
hasImageFilter.value = false
|
||||
page.value = 1
|
||||
loadReviews(1)
|
||||
}
|
||||
|
||||
const toggleHasImage = (): void => {
|
||||
hasImageFilter.value = !hasImageFilter.value
|
||||
filterRating.value = 0
|
||||
page.value = 1
|
||||
loadReviews(1)
|
||||
}
|
||||
|
||||
const toggleLike = async (review: ReviewItem): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.toggleReviewLike(review.id)
|
||||
if (result.getBoolean('success') === true) {
|
||||
review.is_liked = result.getBoolean('is_liked') ?? false
|
||||
review.like_count = result.getNumber('like_count') ?? 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('点赞失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const previewImage = (images: string[], index: number): void => {
|
||||
uni.previewImage({
|
||||
urls: images,
|
||||
current: index
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string | null): string => {
|
||||
if (timeStr == null || timeStr === '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000))
|
||||
if (hours === 0) {
|
||||
const minutes = Math.floor(diff / (60 * 1000))
|
||||
return minutes <= 1 ? '刚刚' : `${minutes}分钟前`
|
||||
}
|
||||
return `${hours}小时前`
|
||||
} else if (days < 7) {
|
||||
return `${days}天前`
|
||||
} else {
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = (currentPage as any).options
|
||||
if (options != null && options.product_id != null) {
|
||||
productId.value = options.product_id as string
|
||||
loadStats()
|
||||
loadReviews(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.reviews-page {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-avg {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stats-detail {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.stats-good {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.stats-good-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stats-total {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stats-total-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.rating-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rating-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rating-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.rating-progress {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
margin: 0 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rating-fill {
|
||||
height: 100%;
|
||||
background-color: #ff6b35;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.rating-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background-color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
padding: 6px 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 16px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background-color: #fff5f0;
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filter-item.active .filter-text {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.review-item {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.review-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rating-stars {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.star {
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.star.filled {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.review-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.review-content {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.review-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.review-images {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.review-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.more-images {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.more-images text {
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.review-append {
|
||||
background-color: #f9f9f9;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.append-label {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.append-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.append-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.review-reply {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reply-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.review-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.like-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.like-btn.liked .like-icon {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.like-icon {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.like-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.no-more-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
603
pages/mall/consumer/share/detail.uvue
Normal file
603
pages/mall/consumer/share/detail.uvue
Normal file
@@ -0,0 +1,603 @@
|
||||
<template>
|
||||
<scroll-view class="share-detail-page" scroll-y>
|
||||
<view class="product-section">
|
||||
<image class="product-image" :src="shareRecord.product_image || defaultImage" mode="aspectFill" />
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ shareRecord.product_name }}</text>
|
||||
<text class="product-price">¥{{ shareRecord.product_price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-section">
|
||||
<view class="progress-header">
|
||||
<text class="progress-title">免单进度</text>
|
||||
<text class="progress-status" :class="getStatusClass(shareRecord.status)">{{ getStatusText(shareRecord.status) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="progress-content">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: getProgressPercent() + '%' }"></view>
|
||||
</view>
|
||||
<view class="progress-numbers">
|
||||
<text class="current-count">{{ shareRecord.current_count }}</text>
|
||||
<text class="divider">/</text>
|
||||
<text class="required-count">{{ shareRecord.required_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-tip" v-if="shareRecord.status === 0">
|
||||
<text class="tip-text">还需 {{ shareRecord.required_count - shareRecord.current_count }} 人购买即可免单</text>
|
||||
</view>
|
||||
|
||||
<view class="reward-info" v-if="shareRecord.status === 1">
|
||||
<text class="reward-label">已获得免单奖励</text>
|
||||
<text class="reward-amount">¥{{ shareRecord.reward_amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="share-code-section">
|
||||
<view class="code-header">
|
||||
<text class="code-title">分享码</text>
|
||||
<text class="copy-btn" @click="copyShareCode">复制</text>
|
||||
</view>
|
||||
<view class="code-content">
|
||||
<text class="code-value">{{ shareRecord.share_code }}</text>
|
||||
</view>
|
||||
<view class="code-tip">
|
||||
<text class="tip-text">将分享码告诉好友,好友下单时填写即可</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="buyers-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">购买记录</text>
|
||||
<text class="section-count">({{ buyers.length }}人)</text>
|
||||
</view>
|
||||
|
||||
<view v-if="buyersLoading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="buyers.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无购买记录</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="buyer-list">
|
||||
<view class="buyer-item" v-for="buyer in buyers" :key="buyer.id">
|
||||
<view class="buyer-avatar">
|
||||
<text class="avatar-text">{{ getBuyerInitial(buyer.buyer_name) }}</text>
|
||||
</view>
|
||||
<view class="buyer-info">
|
||||
<text class="buyer-name">{{ maskName(buyer.buyer_name) }}</text>
|
||||
<text class="buyer-time">{{ formatTime(buyer.created_at) }}</text>
|
||||
</view>
|
||||
<view class="buyer-count">
|
||||
<text class="count-text">购买 {{ buyer.quantity }} 件</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="time-section">
|
||||
<view class="time-item">
|
||||
<text class="time-label">创建时间</text>
|
||||
<text class="time-value">{{ formatTime(shareRecord.created_at) }}</text>
|
||||
</view>
|
||||
<view class="time-item" v-if="shareRecord.completed_at">
|
||||
<text class="time-label">完成时间</text>
|
||||
<text class="time-value">{{ formatTime(shareRecord.completed_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type ShareRecordType = {
|
||||
id: string
|
||||
product_name: string
|
||||
product_image: string | null
|
||||
product_price: number
|
||||
share_code: string
|
||||
required_count: number
|
||||
current_count: number
|
||||
status: number
|
||||
reward_amount: number | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
type BuyerType = {
|
||||
id: string
|
||||
buyer_id: string
|
||||
buyer_name: string
|
||||
quantity: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const shareId = ref<string>('')
|
||||
const shareRecord = ref<ShareRecordType>({
|
||||
id: '',
|
||||
product_name: '',
|
||||
product_image: null,
|
||||
product_price: 0,
|
||||
share_code: '',
|
||||
required_count: 4,
|
||||
current_count: 0,
|
||||
status: 0,
|
||||
reward_amount: null,
|
||||
created_at: '',
|
||||
completed_at: null
|
||||
})
|
||||
|
||||
const buyers = ref<BuyerType[]>([])
|
||||
const buyersLoading = ref<boolean>(false)
|
||||
const defaultImage: string = '/static/images/default-product.png'
|
||||
|
||||
const loadShareDetail = async (): Promise<void> => {
|
||||
if (shareId.value === '') return
|
||||
|
||||
try {
|
||||
const result = await supabaseService.getShareDetail(shareId.value)
|
||||
|
||||
const recordRaw = result.get('share_record')
|
||||
if (recordRaw != null) {
|
||||
const recordAny = recordRaw as any
|
||||
if (typeof recordAny._getValue === 'function') {
|
||||
shareRecord.value = {
|
||||
id: (recordAny._getValue('id') as string) ?? '',
|
||||
product_name: (recordAny._getValue('product_name') as string) ?? '',
|
||||
product_image: recordAny._getValue('product_image') as string | null,
|
||||
product_price: (recordAny._getValue('product_price') as number) ?? 0,
|
||||
share_code: (recordAny._getValue('share_code') as string) ?? '',
|
||||
required_count: (recordAny._getValue('required_count') as number) ?? 4,
|
||||
current_count: (recordAny._getValue('current_count') as number) ?? 0,
|
||||
status: (recordAny._getValue('status') as number) ?? 0,
|
||||
reward_amount: recordAny._getValue('reward_amount') as number | null,
|
||||
created_at: (recordAny._getValue('created_at') as string) ?? '',
|
||||
completed_at: recordAny._getValue('completed_at') as string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const purchasesRaw = result.get('secondary_purchases')
|
||||
if (purchasesRaw != null && Array.isArray(purchasesRaw)) {
|
||||
const parsed: BuyerType[] = []
|
||||
const arr = purchasesRaw as any[]
|
||||
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i]
|
||||
const itemAny = item as any
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
parsed.push({
|
||||
id: (itemAny._getValue('id') as string) ?? '',
|
||||
buyer_id: (itemAny._getValue('buyer_id') as string) ?? '',
|
||||
buyer_name: '用户' + (i + 1),
|
||||
quantity: (itemAny._getValue('quantity') as number) ?? 1,
|
||||
created_at: (itemAny._getValue('created_at') as string) ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
buyers.value = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载分享详情失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const getProgressPercent = (): number => {
|
||||
if (shareRecord.value.required_count <= 0) return 0
|
||||
return Math.min(100, Math.round((shareRecord.value.current_count / shareRecord.value.required_count) * 100))
|
||||
}
|
||||
|
||||
const getStatusText = (status: number): string => {
|
||||
if (status === 0) return '进行中'
|
||||
if (status === 1) return '已免单'
|
||||
if (status === 2) return '已失效'
|
||||
if (status === 3) return '已过期'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const getStatusClass = (status: number): string => {
|
||||
if (status === 0) return 'status-progress'
|
||||
if (status === 1) return 'status-completed'
|
||||
if (status === 2) return 'status-invalid'
|
||||
if (status === 3) return 'status-expired'
|
||||
return ''
|
||||
}
|
||||
|
||||
const copyShareCode = (): void => {
|
||||
uni.setClipboardData({
|
||||
data: shareRecord.value.share_code,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制分享码', icon: 'success' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getBuyerInitial = (name: string): string => {
|
||||
if (name.length > 0) {
|
||||
return name.charAt(0)
|
||||
}
|
||||
return '用'
|
||||
}
|
||||
|
||||
const maskName = (name: string): string => {
|
||||
if (name.length <= 2) {
|
||||
return name.charAt(0) + '*'
|
||||
}
|
||||
return name.charAt(0) + '***' + name.charAt(name.length - 1)
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string | null): string => {
|
||||
if (timeStr == null || timeStr === '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
const hh = date.getHours().toString().padStart(2, '0')
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = (currentPage as any).options
|
||||
if (options != null && options.id != null) {
|
||||
shareId.value = options.id as string
|
||||
loadShareDetail()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.share-detail-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.product-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-status {
|
||||
font-size: 14px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background-color: #fff5f0;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-invalid {
|
||||
background-color: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ff6b35 0%, #ff8c42 100%);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.progress-numbers {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.current-count {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.required-count {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.progress-tip {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background-color: #fff5f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 13px;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.reward-info {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f6ffed 0%, #e6fffb 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reward-label {
|
||||
font-size: 15px;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.reward-amount {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.share-code-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
font-size: 14px;
|
||||
color: #ff6b35;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #ff6b35;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
background-color: #f9f9f9;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.code-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
letter-spacing: 8px;
|
||||
}
|
||||
|
||||
.code-tip {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.buyers-section {
|
||||
background-color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.buyer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.buyer-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.buyer-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.buyer-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.buyer-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.buyer-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.buyer-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.time-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.time-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
409
pages/mall/consumer/share/index.uvue
Normal file
409
pages/mall/consumer/share/index.uvue
Normal file
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<scroll-view class="share-page" scroll-y>
|
||||
<view class="share-summary">
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ totalShares }}</text>
|
||||
<text class="summary-label">分享次数</text>
|
||||
</view>
|
||||
<view class="summary-divider"></view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ completedShares }}</text>
|
||||
<text class="summary-label">免单成功</text>
|
||||
</view>
|
||||
<view class="summary-divider"></view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ totalReward }}</text>
|
||||
<text class="summary-label">累计奖励(元)</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rules-section">
|
||||
<view class="rules-header" @click="toggleRules">
|
||||
<text class="rules-title">免单规则</text>
|
||||
<text class="rules-arrow">{{ showRules ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
<view class="rules-content" v-if="showRules">
|
||||
<text class="rules-text">1. 购买商品后可生成分享链接</text>
|
||||
<text class="rules-text">2. 分享给好友,好友通过链接购买</text>
|
||||
<text class="rules-text">3. 累计4人购买后,即可免单</text>
|
||||
<text class="rules-text">4. 免单金额存入余额,可联系商家提现</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="share-list-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">我的分享</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="shares.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无分享记录</text>
|
||||
<text class="empty-tip">购买商品后可以分享免单哦~</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="share-list">
|
||||
<view class="share-item" v-for="share in shares" :key="share.id" @click="goToShareDetail(share.id)">
|
||||
<image class="product-image" :src="share.product_image || defaultImage" mode="aspectFill" />
|
||||
<view class="share-info">
|
||||
<text class="product-name">{{ share.product_name }}</text>
|
||||
<view class="progress-section">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: getProgressPercent(share.current_count, share.required_count) + '%' }"></view>
|
||||
</view>
|
||||
<text class="progress-text">{{ share.current_count }}/{{ share.required_count }}</text>
|
||||
</view>
|
||||
<view class="share-bottom">
|
||||
<text class="share-code">分享码: {{ share.share_code }}</text>
|
||||
<text class="share-status" :class="getStatusClass(share.status)">{{ getStatusText(share.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="share-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type ShareRecord = {
|
||||
id: string
|
||||
product_id: string
|
||||
product_name: string
|
||||
product_image: string | null
|
||||
product_price: number
|
||||
share_code: string
|
||||
required_count: number
|
||||
current_count: number
|
||||
status: number
|
||||
reward_amount: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const shares = ref<ShareRecord[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const showRules = ref<boolean>(false)
|
||||
const defaultImage: string = '/static/images/default-product.png'
|
||||
|
||||
const totalShares = computed((): number => shares.value.length)
|
||||
|
||||
const completedShares = computed((): number => {
|
||||
let count = 0
|
||||
for (let i = 0; i < shares.value.length; i++) {
|
||||
if (shares.value[i].status === 1) count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
const totalReward = computed((): number => {
|
||||
let total = 0
|
||||
for (let i = 0; i < shares.value.length; i++) {
|
||||
if (shares.value[i].reward_amount != null) {
|
||||
total += shares.value[i].reward_amount!
|
||||
}
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
const loadShares = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getMyShareRecords()
|
||||
const parsed: ShareRecord[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
parsed.push({
|
||||
id: (itemAny._getValue('id') as string) ?? '',
|
||||
product_id: (itemAny._getValue('product_id') as string) ?? '',
|
||||
product_name: (itemAny._getValue('product_name') as string) ?? '',
|
||||
product_image: itemAny._getValue('product_image') as string | null,
|
||||
product_price: (itemAny._getValue('product_price') as number) ?? 0,
|
||||
share_code: (itemAny._getValue('share_code') as string) ?? '',
|
||||
required_count: (itemAny._getValue('required_count') as number) ?? 4,
|
||||
current_count: (itemAny._getValue('current_count') as number) ?? 0,
|
||||
status: (itemAny._getValue('status') as number) ?? 0,
|
||||
reward_amount: itemAny._getValue('reward_amount') as number | null,
|
||||
created_at: (itemAny._getValue('created_at') as string) ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
shares.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载分享记录失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRules = (): void => {
|
||||
showRules.value = !showRules.value
|
||||
}
|
||||
|
||||
const getProgressPercent = (current: number, required: number): number => {
|
||||
if (required <= 0) return 0
|
||||
return Math.min(100, Math.round((current / required) * 100))
|
||||
}
|
||||
|
||||
const getStatusText = (status: number): string => {
|
||||
if (status === 0) return '进行中'
|
||||
if (status === 1) return '已免单'
|
||||
if (status === 2) return '已失效'
|
||||
if (status === 3) return '已过期'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const getStatusClass = (status: number): string => {
|
||||
if (status === 0) return 'status-progress'
|
||||
if (status === 1) return 'status-completed'
|
||||
if (status === 2) return 'status-invalid'
|
||||
if (status === 3) return 'status-expired'
|
||||
return ''
|
||||
}
|
||||
|
||||
const goToShareDetail = (shareId: string): void => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/share/detail?id=${shareId}`
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadShares()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.share-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.share-summary {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.rules-section {
|
||||
background-color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rules-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rules-title {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rules-arrow {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.rules-content {
|
||||
padding: 0 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rules-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.share-list-section {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.share-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.share-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.share-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #ff6b35;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.share-bottom {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.share-code {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.share-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background-color: #fff5f0;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-invalid {
|
||||
background-color: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.share-arrow {
|
||||
font-size: 20px;
|
||||
color: #ccc;
|
||||
margin-left: 8px;
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user