增加推销模式

This commit is contained in:
cyh666666
2026-03-06 17:30:50 +08:00
parent 3b0e397714
commit 7b5801a72b
39 changed files with 9831 additions and 34 deletions

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

View 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. **扩展性**
- 返利规则可配置
- 会员等级可扩展
- 支持多种分享渠道

View 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

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

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

View File

@@ -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 {

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

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

View File

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

View 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">&lt;</text>
<text class="current-month">{{ currentYear }}年{{ currentMonth }}月</text>
<text class="nav-btn" @click="nextMonth">&gt;</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>

View File

@@ -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;

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

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

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