完成consumer端同步

This commit is contained in:
2026-05-14 15:28:09 +08:00
parent 612fb3d360
commit 0ffbc53902
197 changed files with 92657 additions and 7564 deletions

View File

@@ -0,0 +1,612 @@
<template>
<view class="page-container">
<scroll-view class="address-edit-scroll" scroll-y="true">
<view class="address-edit-content">
<!-- 基础信息组 -->
<view class="form-group">
<view class="form-item">
<text class="label">收货人</text>
<input class="input" v-model="formData.name" placeholder="请填写收货人姓名" placeholder-class="placeholder" />
</view>
<view class="form-item">
<text class="label">手机号码</text>
<input class="input" v-model="formData.phone" type="number" maxlength="11" placeholder="请填写手机号码" placeholder-class="placeholder" />
</view>
<view class="form-item">
<text class="label">所在地区</text>
<input class="input" v-model="regionString" placeholder="省市区县、乡镇等" placeholder-class="placeholder" />
<text class="arrow-icon"></text>
</view>
<view class="form-item detail-item">
<text class="label">详细地址</text>
<textarea class="textarea" v-model="formData.detail" placeholder="街道、楼牌号等" placeholder-class="placeholder" maxlength="100"></textarea>
</view>
</view>
<!-- 标签与默认设置组 -->
<view class="form-group">
<view class="form-item label-section">
<text class="label">地址标签</text>
<view class="tags-container">
<view
v-for="tag in tags"
:key="tag"
class="tag-item"
:class="{ active: formData.label === tag }"
@click="selectTag(tag)"
>
<text class="tag-text" :class="{ 'tag-text-active': formData.label === tag }">{{ tag }}</text>
</view>
</view>
</view>
<view class="form-item switch-item">
<view class="switch-label-group">
<text class="label">设为默认地址</text>
<text class="sub-label">下单时优先使用该地址</text>
</view>
<switch :checked="formData.isDefault" color="#ff5000" @change="onSwitchChange" />
</view>
</view>
<!-- 智能识别组 -->
<view class="form-group smart-group">
<view class="smart-header">
<text class="smart-title">智能填写</text>
<text class="smart-clear" v-if="smartInput" @click="smartInput = ''">清空</text>
</view>
<textarea class="smart-textarea" v-model="smartInput" placeholder="粘贴整段地址,自动识别姓名、电话、地址" @input="parseSmartInput" maxlength="200"></textarea>
<view class="smart-footer">
<text class="smart-tip">示例张三13800138000北京市朝阳区...</text>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="footer-actions">
<button class="save-btn" @click="saveAddress">保存地址</button>
<button v-if="isEdit" class="delete-btn" @click="deleteAddress">删除此地址</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService, AddAddressParams, UpdateAddressParams } from '@/utils/supabaseService.uts'
type Address = {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
label?: string
}
const isEdit = ref(false)
const addressId = ref('')
const regionString = ref('')
const tags = ['家', '公司', '学校']
const smartInput = ref('')
type AddressForm = {
name: string
phone: string
detail: string
isDefault: boolean
label: string
}
const formData = reactive({
name: '',
phone: '',
detail: '',
isDefault: false,
label: ''
} as AddressForm)
const loadAddress = async (id: string) => {
try {
// 从Supabase加载地址详情
const address = await supabaseService.getAddressById(id)
if (address != null) {
formData.name = address.recipient_name
formData.phone = address.phone
formData.detail = address.detail_address
formData.isDefault = address.is_default
formData.label = address.label ?? ''
regionString.value = `${address.province} ${address.city} ${address.district}`.trim()
} else {
// 如果Supabase没有找到尝试从本地存储加载
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
const addresses = JSON.parse(storedAddresses as string) as Address[]
const localAddress = addresses.find(item => item.id === id)
if (localAddress != null) {
formData.name = localAddress.name
formData.phone = localAddress.phone
formData.detail = localAddress.detail
formData.isDefault = localAddress.isDefault
formData.label = localAddress.label ?? ''
regionString.value = `${localAddress.province} ${localAddress.city} ${localAddress.district}`.trim()
}
}
}
} catch (error) {
console.error('加载地址详情失败:', error)
// 失败时从本地存储加载
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
try {
const addresses = JSON.parse(storedAddresses as string) as Address[]
const address = addresses.find(item => item.id === id)
if (address != null) {
formData.name = address.name
formData.phone = address.phone
formData.detail = address.detail
formData.isDefault = address.isDefault
formData.label = address.label ?? ''
regionString.value = `${address.province} ${address.city} ${address.district}`.trim()
}
} catch (e) {
console.error('解析本地地址数据失败', e)
}
}
}
}
onLoad((options) => {
if (options['id'] != null) {
isEdit.value = true
addressId.value = options['id'] as string
loadAddress(addressId.value)
}
})
const selectTag = (tag: string) => {
if (formData.label === tag) {
formData.label = ''
} else {
formData.label = tag
}
}
const onSwitchChange = (e: UniSwitchChangeEvent) => {
formData.isDefault = e.detail.value
}
const saveAddress = async () => {
if (formData.name == '') {
uni.showToast({ title: '请填写收货人', icon: 'none' })
return
}
if (formData.phone == '') {
uni.showToast({ title: '请填写手机号码', icon: 'none' })
return
}
if (regionString.value == '') {
uni.showToast({ title: '请填写所在地区', icon: 'none' })
return
}
if (formData.detail == '') {
uni.showToast({ title: '请填写详细地址', icon: 'none' })
return
}
// 简单解析地区(这里简化处理,实际应使用选择器)
const regions = regionString.value.split(' ')
const province = regions[0] ?? ''
const city = regions[1] ?? ''
const district = regions.slice(2).join(' ')
// 构建地址对象
const addressData = {
recipient_name: formData.name,
phone: formData.phone,
province: province,
city: city,
district: district,
detail_address: formData.detail,
postal_code: '', // 如果需要可以添加邮政编码字段
is_default: formData.isDefault,
label: formData.label
} as AddAddressParams
let success = false
if (isEdit.value) {
// 更新地址
const updateData = {
recipient_name: formData.name,
phone: formData.phone,
province: province,
city: city,
district: district,
detail_address: formData.detail,
postal_code: '',
is_default: formData.isDefault,
label: formData.label
} as UpdateAddressParams
success = await supabaseService.updateAddress(addressId.value, updateData)
} else {
// 添加新地址
success = await supabaseService.addAddress(addressData)
}
if (success) {
// 同时更新本地存储作为缓存
const storedAddresses = uni.getStorageSync('addresses')
let addresses: Address[] = []
if (storedAddresses != null) {
try {
addresses = JSON.parse(storedAddresses as string) as Address[]
} catch (e) {
addresses = []
}
}
// 如果设为默认,取消其他默认
if (formData.isDefault) {
addresses.forEach(item => {
item.isDefault = false
})
}
if (isEdit.value) {
const index = addresses.findIndex(item => item.id === addressId.value)
if (index !== -1) {
addresses[index] = {
...addresses[index],
name: formData.name,
phone: formData.phone,
province: province,
city: city,
district: district,
detail: formData.detail,
isDefault: formData.isDefault,
label: formData.label
}
}
} else {
const newAddress: Address = {
id: `addr_${Date.now()}`, // 临时ID实际由Supabase生成
name: formData.name,
phone: formData.phone,
province: province,
city: city,
district: district,
detail: formData.detail,
isDefault: formData.isDefault,
label: formData.label
}
addresses.push(newAddress)
}
uni.setStorageSync('addresses', JSON.stringify(addresses))
uni.showToast({
title: '保存成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
console.error('保存地址失败')
uni.showToast({
title: '保存失败',
icon: 'none'
})
}
}
const parseSmartInput = () => {
const input = smartInput.value.trim()
if (input == '') return
// 提取手机号
const phoneRegex = /(1[3-9]\d{9})/
const phoneMatch = input.match(phoneRegex)
if (phoneMatch != null) {
formData.phone = phoneMatch[0] ?? ''
}
// 提取姓名取第一个2-4位中文
const nameRegex = /([\u4e00-\u9fa5]{2,4})/
const nameMatch = input.match(nameRegex)
if (nameMatch != null) {
formData.name = nameMatch[0] ?? ''
}
// 去掉姓名和电话后剩余作为地址
let addrText = input
if (formData.name != '') addrText = addrText.replace(formData.name, '')
if (formData.phone != '') addrText = addrText.replace(formData.phone, '')
addrText = addrText.replace(/[,;\s]+/g, ' ').trim()
// 解析省市区
const pattern1 = /^(.*?省)?(.*?市)?(.*?[区县])?(.*)$/
const m = addrText.match(pattern1)
if (m != null) {
const province = m[1] ?? ''
const city = m[2] ?? ''
const district = m[3] ?? ''
const detail = m[4] ?? ''
regionString.value = `${province.trim()} ${city.trim()} ${district.trim()}`.trim()
formData.detail = detail.trim()
} else {
formData.detail = addrText
}
}
const deleteAddress = () => {
uni.showModal({
title: '提示',
content: '确定要删除该地址吗?',
success: (res: UniShowModalResult) => {
if (res.confirm) {
// 调用Supabase服务删除地址
supabaseService.deleteAddress(addressId.value).then((success) => {
if (success) {
// 同时从本地存储中移除
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
try {
let addresses = JSON.parse(storedAddresses as string) as Address[]
addresses = addresses.filter(item => item.id !== addressId.value)
uni.setStorageSync('addresses', JSON.stringify(addresses))
} catch (e) {
console.error('解析本地地址数据失败', e)
}
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
console.error('删除地址失败')
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
})
}
}
})
}
</script>
<style>
.page-container {
flex: 1;
background-color: #f8f8f8;
display: flex;
flex-direction: column;
}
.address-edit-scroll {
flex: 1;
}
.address-edit-content {
padding: 12px;
padding-bottom: 40px;
}
.form-group {
background-color: #ffffff;
border-radius: 12px;
padding: 16px; /* 给整个组增加内边距 */
margin-bottom: 12px;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.02);
}
.form-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 16px;
min-height: 52px;
background-color: #f8f8f8;
border-radius: 26px; /* 增加大圆角,使其从直角变为圆角 */
margin-bottom: 12px;
border: none;
}
.form-item:last-child {
margin-bottom: 0;
}
.detail-item {
align-items: flex-start;
flex-direction: column;
padding: 16px;
border-radius: 16px; /* 详细地址区域也增加圆角 */
}
.detail-item .label {
margin-bottom: 8px;
}
.label {
width: 80px;
font-size: 15px;
color: #666;
font-weight: 400;
}
.input {
flex: 1;
height: 44px; /* 增加高度 */
line-height: 44px;
font-size: 16px;
color: #333;
padding: 0 4px;
background-color: transparent; /* 确保输入框背景透明 */
border: none; /* 强制去除安卓原生边框 */
}
.textarea {
width: 100%;
height: 80px;
font-size: 15px;
line-height: 1.6;
color: #333;
padding: 4px 0;
background-color: transparent;
border: none; /* 强制去除安卓原生边框 */
}
.placeholder {
color: #bbb;
font-size: 15px;
}
.arrow-icon {
font-size: 18px;
color: #ccc;
margin-left: 8px;
}
/* 标签选择 */
.label-section {
align-items: flex-start;
flex-direction: column;
}
.label-section .label {
margin-bottom: 16px;
}
.tags-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.tag-item {
padding: 8px 20px; /* 增大点击区域 */
background-color: #f7f7f7;
border-radius: 20px;
margin-right: 12px;
margin-bottom: 8px; /* 增加底部间距 */
border: 1px solid transparent;
}
.tag-item.active {
background-color: #fff1eb;
border-color: #ff5000;
}
.tag-text {
font-size: 14px; /* 增大标签文字 */
color: #666;
}
.tag-text-active {
color: #ff5000;
font-weight: bold;
}
/* 开关项 */
.switch-item {
justify-content: space-between;
min-height: 72px; /* 增加开关项高度 */
}
.switch-label-group {
display: flex;
flex-direction: column;
}
.sub-label {
font-size: 13px; /* 增大副标题 */
color: #999;
margin-top: 6px;
}
/* 智能填写 */
.smart-group {
padding: 16px;
}
.smart-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.smart-title {
font-size: 14px;
color: #333;
font-weight: bold;
}
.smart-clear {
font-size: 12px;
color: #007aff;
}
.smart-textarea {
width: 100%;
height: 80px;
background-color: #f9f9f9;
border-radius: 8px;
padding: 12px;
font-size: 13px;
line-height: 1.6;
color: #666;
}
.smart-footer {
margin-top: 8px;
}
.smart-tip {
font-size: 11px;
color: #999;
}
/* 底部按钮 */
.footer-actions {
margin-top: 32px;
display: flex;
flex-direction: column;
}
.save-btn {
background-color: #ff5000;
color: #ffffff;
height: 48px;
line-height: 48px;
font-size: 16px;
font-weight: bold;
border-radius: 24px;
border: none;
margin-bottom: 16px;
box-shadow: 0 8rpx 20rpx rgba(255, 80, 0, 0.2);
}
.delete-btn {
background-color: #ffffff;
color: #ee0a24;
height: 48px;
line-height: 48px;
font-size: 16px;
border-radius: 24px;
border: 1px solid #f0f0f0;
}
</style>

View File

@@ -0,0 +1,342 @@
<template>
<view class="address-list-page">
<view class="address-list">
<view v-if="addresses.length === 0" class="empty-state">
<text class="empty-icon">📍</text>
<text class="empty-text">暂无收货地址</text>
</view>
<view v-else v-for="(item, index) in addresses" :key="item.id" class="address-item" @click="selectAddress(item)">
<view class="item-content">
<view class="item-header">
<text class="user-name">{{ item.name }}</text>
<text class="user-phone">{{ item.phone }}</text>
<text v-if="item.isDefault" class="default-tag">默认</text>
<text v-if="item.label" class="label-tag">{{ item.label }}</text>
</view>
<text class="address-text">{{ getFullAddress(item) }}</text>
</view>
<view class="item-actions">
<view class="action-item" @click.stop="editAddress(item.id)">
<text class="action-icon">📝</text>
</view>
<view class="action-item" @click.stop="deleteAddress(item.id)">
<text class="action-icon">🗑️</text>
</view>
</view>
</view>
</view>
<view class="footer-btn">
<button class="add-btn" @click="addAddress">新建收货地址</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, getCurrentInstance } from 'vue'
import { onShow, onLoad } from '@dcloudio/uni-app'
import { supabaseService, type UserAddress as SupabaseUserAddress } from '@/utils/supabaseService.uts'
type Address = {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
label?: string
}
const addresses = ref<Address[]>([])
const selectionMode = ref<boolean>(false)
const loadAddresses = async () => {
try {
// 从Supabase加载地址数据
const supabaseAddresses = await supabaseService.getAddresses()
// 转换数据格式以匹配前端界面
const transformedAddresses: Address[] = []
for (let i = 0; i < supabaseAddresses.length; i++) {
const item = supabaseAddresses[i]
const addr: Address = {
id: item.id,
name: item.recipient_name,
phone: item.phone,
province: item.province,
city: item.city,
district: item.district,
detail: item.detail_address,
isDefault: item.is_default,
label: ''
} as Address
transformedAddresses.push(addr)
}
addresses.value = transformedAddresses
// 同时更新本地存储作为缓存
uni.setStorageSync('addresses', JSON.stringify(addresses.value))
} catch (error) {
console.error('加载地址数据失败:', error)
// 如果API调用失败尝试从本地存储加载
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
try {
addresses.value = JSON.parse(storedAddresses as string) as Address[]
} catch (e) {
console.error('解析地址数据失败', e)
addresses.value = []
}
} else {
addresses.value = []
}
}
}
onLoad((options) => {
if (options['selectMode'] == 'true') {
selectionMode.value = true
}
})
onShow(() => {
loadAddresses()
})
// onMounted logic for EventChannel removed as it is not fully supported in UTS Android
// Using uni.$emit for global event communication instead
const getFullAddress = (item: Address): string => {
return `${item.province}${item.city}${item.district} ${item.detail}`
}
const addAddress = () => {
uni.navigateTo({
url: '/pages/mall/consumer/address-edit'
})
}
// 删除地址
const deleteAddress = (id: string) => {
uni.showModal({
title: '提示',
content: '确定要删除该地址吗?',
success: (res) => {
if (res.confirm) {
// 调用Supabase服务删除地址
supabaseService.deleteAddress(id).then((success) => {
if (success) {
// 从本地列表移除
const index = addresses.value.findIndex(addr => addr.id === id)
if (index !== -1) {
addresses.value.splice(index, 1)
// 更新本地存储缓存
uni.setStorageSync('addresses', JSON.stringify(addresses.value))
uni.showToast({
title: '删除成功',
icon: 'success'
})
}
} else {
console.error('删除地址失败')
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
})
}
}
})
}
const editAddress = (id: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/address-edit?id=${id}`
})
}
const selectAddress = (item: Address) => {
if (selectionMode.value) {
uni.$emit('addressSelected', {
id: item.id,
recipient_name: item.name,
phone: item.phone,
province: item.province,
city: item.city,
district: item.district,
detail: item.detail,
is_default: item.isDefault
})
uni.navigateBack()
} else {
editAddress(item.id)
}
}
</script>
<style>
.address-list-page {
background-color: #f8f8f8;
padding: 12px;
padding-bottom: 100px;
}
.address-list {
display: flex;
flex-direction: column;
}
.address-item {
background-color: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
display: flex;
flex-direction: row;
align-items: center;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.03);
}
.item-content {
flex: 1;
display: flex;
flex-direction: column;
margin-right: 12px;
}
.item-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
.user-name {
font-size: 16px;
font-weight: bold;
color: #333;
margin-right: 12px;
}
.user-phone {
font-size: 14px;
color: #666;
margin-right: 12px;
}
.default-tag {
background-color: #fff1eb;
color: #ff5000;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
margin-right: 6px;
border: 1px solid #ff5000;
}
.label-tag {
background-color: #eef5ff;
color: #007aff;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #007aff;
}
.address-text {
font-size: 14px;
color: #333;
line-height: 1.5;
lines: 2;
text-overflow: ellipsis;
}
.item-actions {
padding-left: 16px;
border-left: 1px solid #f0f0f0;
display: flex;
flex-direction: row; /* 改为横向排列图标更符合习惯 */
align-items: center;
}
.action-item {
padding: 8px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 100px;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 14px;
color: #999;
}
.footer-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: white;
padding: 10px 15px;
padding-bottom: 30px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.add-btn {
background-color: #ff5000;
color: white;
border-radius: 25px;
font-size: 16px;
height: 44px;
line-height: 44px;
border: none;
width: 100%; /* 默认占满 */
}
/* 响应式布局优化 */
@media screen and (min-width: 768px) {
.address-list {
max-width: 800px;
margin: 0 auto;
}
.address-list-page {
background-color: #f5f5f5;
}
.footer-btn {
max-width: 800px;
margin: 0 auto;
left: 50%;
transform: translateX(-50%);
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
border-radius: 12px 12px 0 0;
}
.add-btn {
width: 300px; /* 桌面端限制宽度 */
}
}
</style>

View File

@@ -0,0 +1,923 @@
<!-- 地址管理页面 -->
<template>
<view class="address-page">
<!-- 顶部栏 -->
<view class="address-header">
<view class="header-title">
<text class="title-text">收货地址</text>
</view>
</view>
<!-- 地址列表 -->
<scroll-view class="address-list" scroll-y>
<!-- 地址为空 -->
<view v-if="addressList.length === 0" class="empty-address">
<text class="empty-icon">📍</text>
<text class="empty-text">暂无收货地址</text>
<text class="empty-subtext">点击下方按钮添加地址</text>
</view>
<!-- 地址项 -->
<view v-for="address in addressList" :key="address.id" class="address-item">
<view class="address-info" @click="selectAddress(address)">
<view class="address-header-row">
<text class="address-name">{{ address.recipient_name }}</text>
<text class="address-phone">{{ address.phone }}</text>
<view v-if="address.is_default" class="default-tag">
<text class="tag-text">默认</text>
</view>
</view>
<view class="address-detail">
<text class="detail-text">{{ getFullAddress(address) }}</text>
</view>
</view>
<view class="address-actions">
<view class="action-item" @click="editAddress(address)">
<text class="action-icon">✏️</text>
<text class="action-text">编辑</text>
</view>
<view class="action-item" @click="deleteAddress(address)">
<text class="action-icon">🗑️</text>
<text class="action-text">删除</text>
</view>
<view v-if="!address.is_default" class="action-item" @click="setDefaultAddress(address)">
<text class="action-icon">⭐</text>
<text class="action-text">设为默认</text>
</view>
</view>
</view>
<!-- 从选择页面返回时的提示 -->
<view v-if="fromSelect && addressList.length > 0" class="select-tip">
<text class="tip-text">请选择收货地址</text>
<text class="tip-subtext">或点击下方添加新地址</text>
</view>
</scroll-view>
<!-- 添加地址按钮 -->
<view class="add-address-btn" @click="showNewAddressForm = true">
<text class="btn-icon">+</text>
<text class="btn-text">添加新地址</text>
</view>
<!-- 新建地址表单弹窗 -->
<view v-if="showNewAddressForm" class="address-form-mask" @click="cancelNewAddress">
<view class="address-form-popup" @click.stop>
<view class="form-header">
<text class="form-title">新建收货地址</text>
<text class="form-close" @click="cancelNewAddress">×</text>
</view>
<scroll-view class="form-content" scroll-y>
<view class="form-item">
<text class="form-label">收货人</text>
<input class="form-input" v-model="newAddress.recipient_name"
placeholder="请输入收货人姓名" />
</view>
<view class="form-item">
<text class="form-label">手机号</text>
<input class="form-input" v-model="newAddress.phone"
placeholder="请输入手机号码" type="number" />
</view>
<!-- 地址智能填写 -->
<view class="form-item">
<text class="form-label">智能填写地址</text>
<textarea class="form-textarea smart-address-input"
v-model="smartAddressInput"
placeholder="请输入完整地址,系统将自动识别省市区和详细地址"
@blur="parseSmartAddress"
maxlength="200"></textarea>
<text class="smart-tip">例如北京市朝阳区三里屯SOHO A座</text>
</view>
<view class="form-item">
<text class="form-label">所在地区</text>
<view class="region-inputs">
<input class="form-input region-input" v-model="newAddress.province"
placeholder="省" readonly />
<input class="form-input region-input" v-model="newAddress.city"
placeholder="市" readonly />
<input class="form-input region-input" v-model="newAddress.district"
placeholder="区/县" readonly />
</view>
</view>
<view class="form-item">
<text class="form-label">详细地址</text>
<textarea class="form-textarea" v-model="newAddress.detail"
placeholder="街道、小区、楼栋、门牌号等"
maxlength="100"></textarea>
</view>
<view class="form-item checkbox-item">
<view class="checkbox-wrapper" @click="newAddress.is_default = !newAddress.is_default">
<view :class="['checkbox', { checked: newAddress.is_default }]">
<text v-if="newAddress.is_default" class="checkbox-check">✓</text>
</view>
<text class="checkbox-label">设为默认地址</text>
</view>
</view>
</scroll-view>
<view class="form-buttons">
<button class="form-cancel-btn" @click="cancelNewAddress">取消</button>
<button class="form-submit-btn" @click="saveNewAddress">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supa from '@/components/supadb/aksupainstance.uts'
type AddressType = {
id: string
user_id: string
recipient_name: string
phone: string
province: string
city: string
district: string
detail: string
postal_code: string | null
is_default: boolean
created_at: string
}
const addressList = ref<Array<AddressType>>([])
const fromSelect = ref<boolean>(false)
const showNewAddressForm = ref<boolean>(false)
const newAddress = ref<any>({
recipient_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
is_default: false
})
const smartAddressInput = ref<string>('')
onLoad((options: any) => {
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const flag = optObj.getString('fromSelect') ?? ''
fromSelect.value = (flag == '1' || flag == 'true')
})
// 生命周期
onMounted(() => {
loadAddresses()
// 监听地址更新事件从checkout页面或其他页面
uni.$on('addressUpdated', (updatedAddressList: any) => {
addressList.value = updatedAddressList
})
})
// 组件卸载时移除事件监听
onUnmounted(() => {
uni.$off('addressUpdated')
})
// 加载地址列表
const loadAddresses = async () => {
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
uni.navigateTo({
url: '/pages/user/login'
})
return
}
try {
const { data, error } = await supa
.from('user_addresses')
.select('*')
.eq('user_id', userId)
.order('is_default', { ascending: false })
.order('created_at', { ascending: false })
if (error !== null) {
console.error('加载地址失败:', error)
return
}
addressList.value = data ?? []
} catch (err) {
console.error('加载地址异常:', err)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string | null => {
const userStore = uni.getStorageSync('userInfo')
if (userStore instanceof UTSJSONObject) {
return userStore.getString('id')
}
const obj = JSON.parse(JSON.stringify(userStore ?? {})) as UTSJSONObject
return obj.getString('id')
}
// 获取完整地址
const getFullAddress = (address: AddressType): string => {
return `${address.province}${address.city}${address.district}${address.detail}`
}
// 选择地址
const selectAddress = (address: AddressType) => {
if (fromSelect.value) {
try {
uni.setStorageSync('selectedAddress', JSON.stringify(address))
} catch (e) {}
uni.$emit('addressSelected', address)
uni.navigateBack()
}
}
// 编辑地址
const editAddress = (address: AddressType) => {
uni.navigateTo({
url: `/pages/mall/consumer/address-edit?id=${address.id}`
})
}
// 删除地址
const deleteAddress = (address: AddressType) => {
uni.showModal({
title: '删除地址',
content: '确定要删除这个收货地址吗?',
success: async (res) => {
if (res.confirm) {
try {
// 如果是默认地址,删除前检查是否还有其他地址
if (address.is_default && addressList.value.length > 1) {
uni.showModal({
title: '提示',
content: '删除默认地址后,系统会自动设置第一个地址为默认地址',
success: async (confirmRes) => {
if (confirmRes.confirm) {
await performDelete(address)
}
}
})
} else {
await performDelete(address)
}
} catch (err) {
console.error('删除地址异常:', err)
}
}
}
})
}
// 执行删除
const performDelete = async (address: AddressType) => {
try {
const { error } = await supa
.from('user_addresses')
.delete()
.eq('id', address.id)
if (error !== null) {
console.error('删除地址失败:', error)
uni.showToast({
title: '删除失败',
icon: 'none'
})
return
}
// 从列表中移除
const index = addressList.value.findIndex(item => item.id === address.id)
if (index !== -1) {
addressList.value.splice(index, 1)
}
// 如果是默认地址被删除,设置第一个地址为默认
if (address.is_default && addressList.value.length > 0) {
const newDefault = addressList.value[0]
await setAsDefault(newDefault)
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
} catch (err) {
console.error('执行删除异常:', err)
}
}
// 设为默认地址
const setDefaultAddress = async (address: AddressType) => {
try {
const userId = getCurrentUserId()
if (!userId) return
// 1. 取消当前所有默认地址
const { error: updateError } = await supa
.from('user_addresses')
.update({ is_default: false })
.eq('user_id', userId)
.eq('is_default', true)
if (updateError !== null) {
console.error('取消默认地址失败:', updateError)
return
}
// 2. 设置新的默认地址
const { error: setError } = await supa
.from('user_addresses')
.update({ is_default: true })
.eq('id', address.id)
if (setError !== null) {
console.error('设置默认地址失败:', setError)
return
}
// 更新本地数据
addressList.value.forEach(item => {
item.is_default = item.id === address.id
})
uni.showToast({
title: '已设为默认地址',
icon: 'success'
})
} catch (err) {
console.error('设置默认地址异常:', err)
}
}
// 设置地址为默认(内部方法)
const setAsDefault = async (address: AddressType) => {
try {
const { error } = await supa
.from('user_addresses')
.update({ is_default: true })
.eq('id', address.id)
if (error !== null) {
console.error('设置默认地址失败:', error)
return
}
address.is_default = true
} catch (err) {
console.error('设置默认地址异常:', err)
}
}
// 解析智能地址
const parseSmartAddress = () => {
const input = smartAddressInput.value.trim()
if (!input) return
// 重置表单
newAddress.value.recipient_name = ''
newAddress.value.phone = ''
newAddress.value.province = ''
newAddress.value.city = ''
newAddress.value.district = ''
newAddress.value.detail = ''
// 尝试匹配手机号码11位数字
const phoneRegex = /(1[3-9]\d{9})/g
const phoneMatches = input.match(phoneRegex)
if (phoneMatches && phoneMatches.length > 0) {
newAddress.value.phone = phoneMatches[0]
}
// 尝试匹配收件人姓名中文姓名2-4个汉字
const nameRegex = /([\u4e00-\u9fa5]{2,4})/g
const nameMatches = input.match(nameRegex)
if (nameMatches && nameMatches.length > 0) {
// 取第一个匹配的中文姓名作为收件人
newAddress.value.recipient_name = nameMatches[0]
}
// 提取地址部分(移除姓名和手机号)
let addressText = input
if (newAddress.value.recipient_name) {
addressText = addressText.replace(newAddress.value.recipient_name, '')
}
if (newAddress.value.phone) {
addressText = addressText.replace(newAddress.value.phone, '')
}
// 清理地址文本(移除多余的空格和标点)
addressText = addressText.replace(/[,;\s]+/g, ' ').trim()
// 地址解析逻辑
const patterns = [
// 匹配格式:省市区详细地址
/^(.*?省)?(.*?市)?(.*?[区县])?(.*)$/,
// 匹配格式:省市详细地址
/^(.*?省)?(.*?市)?(.*)$/
]
for (const pattern of patterns) {
const match = addressText.match(pattern)
if (match) {
const [, province, city, district, detail] = match
if (province) newAddress.value.province = province.replace('省', '').trim()
if (city) newAddress.value.city = city.replace('市', '').trim()
if (district) newAddress.value.district = district.trim()
if (detail) newAddress.value.detail = detail.trim()
// 如果详细地址为空,但还有剩余内容,则作为详细地址
if (!newAddress.value.detail && district && detail) {
newAddress.value.detail = detail.trim()
}
break
}
}
// 如果没有匹配到模式,尝试简单分割
if (!newAddress.value.province && !newAddress.value.city && !newAddress.value.district) {
// 尝试按常见分隔符分割
const parts = addressText.split(/[省市县区]/)
if (parts.length >= 2) {
newAddress.value.province = parts[0] || ''
newAddress.value.city = parts[1] || ''
newAddress.value.detail = parts.slice(2).join('').trim() || addressText
} else {
newAddress.value.detail = addressText
}
}
// 如果地址部分为空,但还有剩余文本,则作为详细地址
if (!newAddress.value.detail && addressText.trim()) {
newAddress.value.detail = addressText.trim()
}
}
// 保存新地址
const saveNewAddress = async () => {
// 验证表单
if (!newAddress.value.recipient_name || !newAddress.value.phone || !newAddress.value.detail) {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
})
return
}
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
return
}
try {
const { data, error } = await supa
.from('user_addresses')
.insert({
user_id: userId,
recipient_name: newAddress.value.recipient_name,
phone: newAddress.value.phone,
province: newAddress.value.province,
city: newAddress.value.city,
district: newAddress.value.district,
detail: newAddress.value.detail,
is_default: newAddress.value.is_default,
created_at: new Date().toISOString()
})
.select()
.single()
if (error !== null) {
console.error('保存地址失败:', error)
uni.showToast({
title: '保存失败',
icon: 'none'
})
return
}
// 如果是默认地址,取消其他默认地址
if (newAddress.value.is_default) {
addressList.value.forEach(addr => {
addr.is_default = false
})
// 更新数据库中的其他地址
await supa
.from('user_addresses')
.update({ is_default: false })
.eq('user_id', userId)
.neq('id', data.id)
}
// 添加到列表
addressList.value.unshift(data)
// 发布地址更新事件让checkout页面也能获取到
uni.$emit('addressUpdated', addressList.value)
// 重置表单
resetNewAddressForm()
uni.showToast({
title: '地址保存成功',
icon: 'success'
})
} catch (err) {
console.error('保存地址异常:', err)
uni.showToast({
title: '保存失败',
icon: 'none'
})
}
}
// 重置新建地址表单
const resetNewAddressForm = () => {
showNewAddressForm.value = false
newAddress.value = {
recipient_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
is_default: false
}
smartAddressInput.value = ''
}
// 取消新建地址
const cancelNewAddress = () => {
resetNewAddressForm()
}
</script>
<style scoped>
.address-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.address-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #e5e5e5;
}
.header-title {
text-align: center;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.address-list {
flex: 1;
padding: 10px;
}
.empty-address {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
border-radius: 8px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
}
.address-item {
background-color: #ffffff;
margin-bottom: 10px;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.address-info {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #f5f5f5;
}
.address-header-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.address-name {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-right: 15px;
}
.address-phone {
font-size: 14px;
color: #666666;
margin-right: 10px;
}
.default-tag {
background-color: #ff4757;
padding: 2px 8px;
border-radius: 10px;
}
.tag-text {
color: #ffffff;
font-size: 12px;
}
.address-detail {
font-size: 14px;
color: #333333;
line-height: 1.5;
}
.detail-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.address-actions {
display: flex;
justify-content: flex-end;
gap: 20px;
}
.action-item {
display: flex;
align-items: center;
}
.action-icon {
font-size: 16px;
margin-right: 5px;
}
.action-text {
font-size: 14px;
color: #666666;
}
.select-tip {
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
margin-bottom: 10px;
text-align: center;
}
.tip-text {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 5px;
display: block;
}
.tip-subtext {
font-size: 14px;
color: #999999;
display: block;
}
.add-address-btn {
background-color: #007aff;
margin: 10px;
padding: 15px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon {
color: #ffffff;
font-size: 24px;
margin-right: 10px;
}
.btn-text {
color: #ffffff;
font-size: 16px;
font-weight: bold;
}
/* 新建地址表单弹窗样式 */
.address-form-mask {
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;
}
.address-form-popup {
background-color: #ffffff;
width: 90%;
max-width: 500px;
max-height: 80vh;
border-radius: 12px;
display: flex;
flex-direction: column;
}
.form-header {
padding: 20px 15px;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
}
.form-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.form-close {
font-size: 24px;
color: #999999;
padding: 5px;
}
.form-content {
flex: 1;
padding: 15px;
max-height: 50vh;
}
.form-item {
margin-bottom: 20px;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
font-size: 14px;
color: #333333;
margin-bottom: 8px;
display: block;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
color: #333333;
box-sizing: border-box;
}
.form-input[readonly] {
background-color: #f9f9f9;
color: #666666;
}
.region-inputs {
display: flex;
gap: 10px;
}
.region-input {
flex: 1;
}
.form-textarea {
width: 100%;
min-height: 80px;
padding: 12px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
color: #333333;
box-sizing: border-box;
}
.smart-address-input {
min-height: 60px;
}
.smart-tip {
font-size: 12px;
color: #999999;
margin-top: 5px;
display: block;
}
.checkbox-item {
margin-top: 20px;
}
.checkbox-wrapper {
display: flex;
align-items: center;
}
.checkbox {
width: 20px;
height: 20px;
border: 1px solid #e5e5e5;
border-radius: 4px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.checkbox.checked {
background-color: #007aff;
border-color: #007aff;
}
.checkbox-check {
color: #ffffff;
font-size: 14px;
}
.checkbox-label {
font-size: 14px;
color: #333333;
}
.form-buttons {
display: flex;
padding: 15px;
border-top: 1px solid #e5e5e5;
gap: 10px;
}
.form-cancel-btn {
flex: 1;
background-color: #f5f5f5;
color: #333333;
padding: 12px;
border-radius: 8px;
font-size: 16px;
border: none;
}
.form-submit-btn {
flex: 1;
background-color: #007aff;
color: #ffffff;
padding: 12px;
border-radius: 8px;
font-size: 16px;
border: none;
}
</style>

View File

@@ -0,0 +1,298 @@
<template>
<view class="apply-refund-page">
<view class="section">
<view class="section-title">退款类型</view>
<radio-group @change="handleTypeChange" class="type-group">
<label class="type-item">
<radio value="1" :checked="refundType === 1" color="#ff5000" class="type-radio" />
<text>仅退款</text>
</label>
<label class="type-item">
<radio value="2" :checked="refundType === 2" color="#ff5000" class="type-radio" />
<text>退货退款</text>
</label>
</radio-group>
</view>
<view class="section">
<view class="section-title">退款原因</view>
<picker @change="handleReasonChange" :range="reasonList" class="picker">
<view class="picker-content">
<text v-if="refundReason">{{ refundReason }}</text>
<text v-else class="placeholder">请选择退款原因</text>
<text class="arrow">></text>
</view>
</picker>
</view>
<view class="section">
<view class="section-title">退款金额</view>
<view class="amount-input-wrap">
<text class="currency">¥</text>
<input
type="digit"
v-model="refundAmount"
class="amount-input"
:placeholder="`最多可退 ¥${maxAmount}`"
/>
</view>
<text class="amount-tip">最多可退 ¥{{ maxAmount }},含发货邮费 ¥{{ deliveryFee }}</text>
</view>
<view class="section">
<view class="section-title">退款说明</view>
<textarea
v-model="description"
class="desc-input"
placeholder="选填:补充详细的退款说明,有助于商家快速处理"
maxlength="200"
></textarea>
</view>
<view class="submit-bar">
<button class="submit-btn" @click="submitRefund" :loading="submitting">提交申请</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
const orderId = ref('')
const orderItemId = ref('') // Optional, if refunding specific item
const refundType = ref(1) // 1: Only Refund, 2: Return & Refund
const refundReason = ref('')
const refundAmount = ref('')
const description = ref('')
const maxAmount = ref(0)
const deliveryFee = ref(0)
const submitting = ref(false)
const reasonList = [
'多拍/错拍/不想要',
'快递一直未送达',
'未按约定时间发货',
'快递无记录',
'空包裹/少货/错发',
'质量问题',
'其他'
]
const loadOrderInfo = async () => {
try {
const orderData = await supabaseService.getOrderDetail(orderId.value)
if (orderData != null) {
// Cast to UTSJSONObject to access properties safely
const order = orderData as UTSJSONObject
const total = order.getNumber('total_amount') ?? 0
const shipping = order.getNumber('shipping_fee') ?? 0
maxAmount.value = total
deliveryFee.value = shipping
refundAmount.value = maxAmount.value.toString()
}
} catch (err) {
console.error('加载订单信息失败', err)
uni.showToast({
title: '加载订单失败',
icon: 'none'
})
}
}
onLoad((options) => {
if (options['orderId'] != null) {
orderId.value = options['orderId'] as string
loadOrderInfo()
}
})
const handleTypeChange = (e: any) => {
// Use bracket notation to access detail property safely on 'any' type in UTS
// The structure is e -> detail -> value
// We need to cast e to UTSJSONObject first if we want to use bracket notation,
// OR we can use JSON.parse/stringify trick if simple casting fails,
// BUT the most standard way for UTS 'any' which is actually a Map/JSONObject at runtime:
const target = e as UTSJSONObject
const detail = target['detail'] as UTSJSONObject
const value = detail['value'] as string
refundType.value = parseInt(value)
}
const handleReasonChange = (e: any) => {
// Use bracket notation to access detail property safely on 'any' type in UTS
const target = e as UTSJSONObject
const detail = target['detail'] as UTSJSONObject
const value = detail['value'] as number
const index = value
refundReason.value = reasonList[index]
}
const submitRefund = async () => {
console.log('=== 提交退款 ===')
console.log('refundReason:', refundReason.value)
console.log('refundAmount:', refundAmount.value)
console.log('maxAmount:', maxAmount.value)
if (refundReason.value == '') {
uni.showToast({ title: '请选择退款原因', icon: 'none' })
return
}
const amount = parseFloat(refundAmount.value)
console.log('解析后金额:', amount)
if (isNaN(amount) || amount <= 0 || amount > maxAmount.value) {
uni.showToast({ title: '请输入有效的退款金额', icon: 'none' })
return
}
submitting.value = true
uni.showLoading({ title: '提交中...' })
try {
console.log('调用 createRefund, orderId:', orderId.value)
const result = await supabaseService.createRefund({
order_id: orderId.value,
refund_type: refundType.value,
refund_reason: refundReason.value,
refund_amount: amount,
description: description.value
})
console.log('createRefund 结果:', JSON.stringify(result))
uni.hideLoading()
if (result.success) {
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({ title: result.message, icon: 'none' })
}
} catch (err) {
console.error('提交退款失败', err)
uni.hideLoading()
uni.showToast({ title: '提交异常', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.apply-refund-page {
flex: 1;
background-color: #f5f5f5;
padding: 15px;
padding-bottom: 80px;
}
.section {
background-color: #ffffff;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 15px;
}
.type-group {
display: flex;
flex-direction: column;
}
.type-item {
display: flex;
align-items: center;
margin-bottom: 15px;
font-size: 14px;
}
.type-radio {
margin-right: 10px;
}
.picker-content {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
color: #333;
}
.placeholder {
color: #999;
}
.arrow {
color: #ccc;
}
.amount-input-wrap {
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
margin-bottom: 10px;
}
.currency {
font-size: 24px;
font-weight: bold;
color: #333;
margin-right: 10px;
}
.amount-input {
flex: 1;
font-size: 24px;
font-weight: bold;
height: 40px;
}
.amount-tip {
font-size: 12px;
color: #999;
}
.desc-input {
width: 100%;
height: 100px;
font-size: 14px;
background-color: #f9f9f9;
border-radius: 4px;
padding: 10px;
box-sizing: border-box;
}
.submit-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
padding: 15px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
}
.submit-btn {
background-color: #ff5000;
color: #ffffff;
border-radius: 22px;
font-size: 16px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,387 @@
<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]
let id = ''
let type = ''
let amount = 0
let balanceBefore = 0
let balanceAfter = 0
let description: string | null = null
let createdAt = ''
let itemObj: UTSJSONObject | null = null
if (item instanceof UTSJSONObject) {
itemObj = item
} else {
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
}
id = itemObj.getString('id') ?? ''
type = itemObj.getString('type') ?? ''
amount = itemObj.getNumber('amount') ?? 0
balanceBefore = itemObj.getNumber('balance_before') ?? 0
balanceAfter = itemObj.getNumber('balance_after') ?? 0
description = itemObj.getString('description')
createdAt = itemObj.getString('created_at') ?? ''
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,160 @@
<template>
<view class="add-card-page">
<view class="form-container">
<view class="form-item">
<text class="label">持卡人</text>
<input class="input" type="text" v-model="form.holder_name" placeholder="请输入持卡人姓名" />
</view>
<view class="form-item">
<text class="label">卡号</text>
<input class="input" type="number" v-model="form.card_no" placeholder="请输入银行卡号" @input="detectBank" maxlength="19" />
</view>
<view class="form-item">
<text class="label">银行</text>
<input class="input" type="text" v-model="form.bank_name" placeholder="自动识别或手动输入" />
</view>
<view class="form-item">
<text class="label">手机号</text>
<input class="input" type="number" v-model="form.phone" placeholder="银行预留手机号" maxlength="11" />
</view>
<view class="form-item switch-item">
<text class="label">设为默认卡</text>
<switch :checked="form.is_default" @change="onSwitchChange" color="#ff5000" />
</view>
</view>
<view class="action-section">
<button class="submit-btn" :class="{ disabled: loading }" :disabled="loading" @click="submit">确认添加</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type BankCardForm = {
holder_name: string
card_no: string
bank_name: string
phone: string
is_default: boolean
}
const loading = ref(false)
const form = reactive({
holder_name: '',
card_no: '',
bank_name: '',
phone: '',
is_default: false
} as BankCardForm)
const onSwitchChange = (e: UniSwitchChangeEvent) => {
form.is_default = e.detail.value
}
// 模拟卡号识别
const detectBank = (e: any) => {
const val = form.card_no
if (val.length >= 6) {
if (val.startsWith('6222')) form.bank_name = '中国工商银行'
else if (val.startsWith('6227')) form.bank_name = '中国建设银行'
else if (val.startsWith('6225')) form.bank_name = '招商银行'
else if (val.startsWith('6228')) form.bank_name = '中国农业银行'
// else form.bank_name = ''
}
}
const submit = async () => {
if (form.holder_name == '' || form.card_no == '' || form.bank_name == '') {
uni.showToast({ title: '请完善卡片信息', icon: 'none' })
return
}
loading.value = true
try {
const cardData = new UTSJSONObject()
cardData.set('holder_name', form.holder_name)
cardData.set('bank_name', form.bank_name)
cardData.set('card_no', form.card_no) // Also save full card no if needed, or just last4
// 截取后4位
const last4 = form.card_no.length > 4 ? form.card_no.slice(-4) : form.card_no
cardData.set('card_no_last4', last4)
cardData.set('phone', form.phone)
cardData.set('is_default', form.is_default)
// 简单推定为储蓄卡
cardData.set('card_type', 'debit')
const success = await supabaseService.addBankCard(cardData)
if (success) {
uni.showToast({ title: '添加成功' })
setTimeout(() => {
uni.navigateBack()
}, 1000)
} else {
uni.showToast({ title: '添加失败', icon: 'none' })
}
} catch (e) {
console.error(e)
uni.showToast({ title: '系统错误', icon: 'none' })
} finally {
loading.value = false
}
}
</script>
<style>
.add-card-page {
background-color: #f5f5f5;
flex: 1;
}
.form-container {
background-color: #fff;
padding: 0 15px;
}
.form-item {
display: flex;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #eee;
}
.form-item:last-child {
border-bottom: none;
}
.label {
width: 80px;
font-size: 15px;
color: #333;
}
.input {
flex: 1;
font-size: 15px;
}
.switch-item {
justify-content: space-between;
}
.action-section {
padding: 30px 15px;
}
.submit-btn {
background-color: #ff5000;
color: #fff;
border-radius: 25px;
font-size: 16px;
}
.submit-btn.disabled {
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<view class="bank-cards-page">
<view class="card-list">
<view v-for="card in cards" :key="card.id" class="card-item" :class="getCardClass(card.bank_name)">
<view class="card-bg-mask"></view>
<view class="card-content">
<view class="card-header">
<text class="bank-name">{{ card.bank_name }}</text>
<text class="card-type">{{ card.card_type === 'credit' ? '信用卡' : '储蓄卡' }}</text>
</view>
<view class="card-number">
<text class="dots">**** **** ****</text>
<text class="last-digits">{{ card.card_no_last4 }}</text>
</view>
<view class="delete-btn" @click.stop="deleteCard(card)">
<text class="del-text">✕</text>
</view>
</view>
</view>
<view class="add-card-btn" @click="addCard">
<text class="plus-icon">+</text>
<text>添加银行卡</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type BankCard = {
id: string
user_id: string
bank_name: string
card_no_last4: string
card_type: string
holder_name: string
is_default: boolean
}
const cards = ref<BankCard[]>([])
const loading = ref(true)
const loadData = async () => {
loading.value = true
try {
const rawList = await supabaseService.getUserBankCards()
const cardList: BankCard[] = []
// Use for loop instead of map to avoid complex closure typing issues
for (let i = 0; i < rawList.length; i++) {
const item = rawList[i]
let id = ''
let bankName = ''
let last4 = ''
let type = 'debit'
let holder = ''
let isDef = false
if (item instanceof UTSJSONObject) {
id = item.getString('id') ?? ''
bankName = item.getString('bank_name') ?? ''
last4 = item.getString('card_no_last4') ?? ''
type = item.getString('card_type') ?? 'debit'
holder = item.getString('holder_name') ?? ''
isDef = item.getBoolean('is_default') ?? false
} else {
const obj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
id = obj.getString('id') ?? ''
bankName = obj.getString('bank_name') ?? ''
last4 = obj.getString('card_no_last4') ?? ''
type = obj.getString('card_type') ?? 'debit'
holder = obj.getString('holder_name') ?? ''
isDef = obj.getBoolean('is_default') ?? false
}
cardList.push({
id: id,
user_id: '',
bank_name: bankName,
card_no_last4: last4,
card_type: type,
holder_name: holder,
is_default: isDef
} as BankCard)
}
cards.value = cardList
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onShow(() => {
loadData()
})
const addCard = () => {
uni.navigateTo({
url: '/pages/mall/consumer/bank-cards/add'
})
}
const deleteCard = (card: BankCard) => {
uni.showModal({
title: '删除银行卡',
content: `确认删除尾号${card.card_no_last4}的${card.bank_name}卡片吗?`,
success: (res) => {
if (res.confirm) {
supabaseService.deleteBankCard(card.id).then((success) => {
if (success) {
uni.showToast({ title: '已删除' })
loadData()
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
})
}
}
})
}
const getCardClass = (bankName: string): string => {
if (bankName.includes('招商')) return 'cmb'
if (bankName.includes('建设')) return 'ccb'
if (bankName.includes('工商')) return 'icbc'
if (bankName.includes('农业')) return 'abc'
return 'default-bank'
}
</script>
<style>
.bank-cards-page {
padding: 15px;
background-color: #f5f5f5;
flex: 1;
height: 140px;
border-radius: 12px;
margin-bottom: 15px;
color: #fff;
position: relative;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.cmb { background: linear-gradient(135deg, #f55, #c00); }
.ccb { background: linear-gradient(135deg, #09f, #00609c); }
.icbc { background: linear-gradient(135deg, #f66, #c00); }
.abc { background: linear-gradient(135deg, #0b9, #086); }
.default-bank { background: linear-gradient(135deg, #666, #333); }
.card-content {
padding: 20px;
z-index: 2;
position: relative;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-header {
display: flex;
align-items: center;
}
.bank-name {
font-size: 18px;
font-weight: bold;
margin-right: 10px;
}
.card-type {
font-size: 12px;
background-color: rgba(255,255,255,0.2);
padding: 2px 6px;
border-radius: 4px;
}
.card-number {
display: flex;
align-items: center;
justify-content: flex-end; /* 右对齐 */
margin-bottom: 10px;
}
.dots {
font-size: 24px;
margin-right: 15px;
line-height: 1;
}
.last-digits {
font-size: 24px;
font-family: monospace;
}
.add-card-btn {
background-color: #fff;
height: 60px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 16px;
border: 1px dashed #ccc;
}
.plus-icon {
font-size: 24px;
margin-right: 5px;
/* font-weight: 300; removed */
}
.delete-btn {
position: absolute;
top: 15px;
right: 15px;
width: 24px;
height: 24px;
background-color: rgba(0,0,0,0.2);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.del-text {
color: #fff;
font-size: 14px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,951 @@
<!-- pages/mall/consumer/chat.uvue -->
<template>
<view class="chat-page">
<!-- 聊天头部 -->
<view class="chat-header" :style="{ paddingTop: navPaddingTop }">
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="header-info">
<view class="header-info-text-wrapper">
<text class="chat-title">{{ headerTitle }}</text>
<text class="chat-status">在线</text>
</view>
</view>
<view class="header-actions">
<view class="action-icon" @click="showMoreActions">
<text class="action-icon-text">⋯</text>
</view>
</view>
</view>
<!-- 聊天内容 -->
<scroll-view
scroll-y="true"
class="chat-content"
:scroll-into-view="scrollToView"
:scroll-with-animation="true"
:show-scrollbar="false"
upper-threshold="100"
@scrolltoupper="onScrollToUpper"
>
<!-- 聊天消息列表 -->
<view class="chat-messages">
<!-- 系统消息 -->
<view class="message-item system">
<text class="system-text">客服 小美 已接入,请描述您的问题</text>
</view>
<!-- 时间分割线 -->
<view class="time-divider">
<text class="time-text">今天 14:30</text>
</view>
<!-- 消息项 -->
<view
v-for="message in messages"
:key="message.id"
:class="['message-item', message.type]"
:id="message.viewId"
>
<!-- 对方消息 -->
<view v-if="message.type === 'received'" class="message-wrapper">
<image
class="avatar"
:src="merchantAvatar"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<text class="sender-name">{{ headerTitle }}</text>
<view class="message-bubble received-bubble">
<!-- 图片消息 -->
<image
v-if="message.msgType == 'image'"
class="message-image"
:src="message.content"
mode="widthFix"
@click="previewImage(message.content)"
/>
<!-- 文本消息 -->
<text v-if="message.msgType != 'image'" class="message-text">{{ message.content }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
</view>
</view>
<!-- 我的消息 -->
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me">
<!-- 图片消息 -->
<image
v-if="message.msgType == 'image'"
class="message-image"
:src="message.content"
mode="widthFix"
@click="previewImage(message.content)"
/>
<!-- 文本消息 -->
<text v-if="message.msgType != 'image'" class="message-text">{{ message.content }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
</view>
<image
class="avatar me"
src="/static/images/default-product.png"
mode="aspectFill"
/>
</view>
</view>
</view>
</scroll-view>
<!-- 聊天输入区 -->
<view class="chat-input">
<view class="input-tools">
<text class="tool-icon" @click="showEmojiPicker">😊</text>
<text class="tool-icon" @click="showImagePicker">📷</text>
<text class="tool-icon" @click="showMoreTools"></text>
</view>
<view class="input-wrapper">
<input
class="message-input"
v-model="inputMessage"
placeholder="请输入消息..."
:focus="inputFocus"
@confirm="sendMessage"
confirm-type="send"
/>
<button
class="send-button"
:class="{ active: inputMessage.trim() }"
@click="sendMessage"
>
发送
</button>
</view>
</view>
<!-- 表情选择器 -->
<scroll-view v-if="showEmoji" class="emoji-picker" direction="vertical">
<view class="emoji-category">
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { supabaseService, type ChatMessage } from '@/utils/supabaseService.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import type { AkSupaRealtimeChannel } from '@/components/supadb/aksupa.uts'
import { getCurrentUser } from '@/utils/store.uts'
type UiChatMessage = {
id: string
viewId: string
type: string
content: string
time: string
msgType: string // 'text' | 'image'
}
// 响应式数据
const messages = ref<UiChatMessage[]>([])
const inputMessage = ref<string>('')
const inputFocus = ref<boolean>(false)
const showEmoji = ref<boolean>(false)
const scrollToView = ref<string>('')
const currentUserId = ref<string>('')
const merchantId = ref<string>('') // 商家ID
const headerTitle = ref<string>('在线客服')
const merchantAvatar = ref<string>('/static/default-shop.png') // 商家头像
const navPaddingTop = ref<string>('30px') // 默认值,包含状态栏高度+原有内边距
const isInitialLoading = ref<boolean>(true)
let realtimeChannel: AkSupaRealtimeChannel | null = null
// 模拟表情列表
const emojiList = ['😊', '😂', '🤣', '😍', '😘', '🥰', '😭', '😡', '👍', '👏', '🙏', '🎉', '❤️', '🔥', '⭐']
function scrollToBottom() : void {
if (messages.value.length === 0) return
// 获取最后一条消息的 ID
const lastMsg = messages.value[messages.value.length - 1]
const targetId = lastMsg.viewId
// 关键点:在 UVue 安卓端,直接连续赋值可能被合并。
// 我们先清空 ID然后在下一帧赋值确保 scroll-view 监听到变化。
scrollToView.value = ''
// 延迟更久一点,确保安卓端列表排版彻底完成
setTimeout(() => {
scrollToView.value = targetId
console.log('[scrollToBottom] 发起滚动定位:', targetId)
// 分级校准:针对长消息或渲染抖动导致的高度变化
setTimeout(() => {
scrollToView.value = ''
setTimeout(() => {
scrollToView.value = targetId
console.log('[scrollToBottom] 第一阶段校准:', targetId)
}, 50)
}, 500)
// 最终深度校准(针对首屏数据较多时)
setTimeout(() => {
scrollToView.value = ''
setTimeout(() => {
scrollToView.value = targetId
console.log('[scrollToBottom] 最终校准:', targetId)
}, 50)
}, 1200)
}, 300)
}
function getCurrentTime(): string {
const now = new Date()
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
function setupRealtimeSubscription(): void {
console.log('开始建立聊天实时订阅...')
console.log('当前用户ID:', currentUserId.value, '商家ID:', merchantId.value)
realtimeChannel = supa.channel('chat-messages-' + Date.now().toString())
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
}, (payload: any) => {
console.log('=== 收到实时订阅回调 ===')
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
const newMsgAny = payloadObj.get('new')
if (newMsgAny == null) {
console.log('newMsgAny 为空,跳过')
return
}
const newMsg = (newMsgAny instanceof UTSJSONObject) ? (newMsgAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newMsgAny)) as UTSJSONObject)
console.log('收到新消息:', newMsg)
const senderId = newMsg.getString('sender_id') ?? ''
const receiverId = newMsg.getString('receiver_id') ?? ''
const msgId = newMsg.getString('id') ?? ''
const content = newMsg.getString('content') ?? ''
const msgType = newMsg.getString('msg_type') ?? 'text'
console.log('=== 消息详情 ===')
console.log('消息ID:', msgId)
console.log('发送者ID:', senderId)
console.log('接收者ID:', receiverId)
console.log('当前用户ID:', currentUserId.value)
console.log('商家ID:', merchantId.value)
console.log('消息内容:', content)
console.log('消息类型 msgType:', msgType)
// 检查消息是否已经在列表中(避免重复)
for (let i = 0; i < messages.value.length; i++) {
if (messages.value[i].id == msgId) {
console.log('消息已存在,跳过')
return
}
}
// 判断消息类型
const isMyMessage = (senderId == currentUserId.value)
const isForMe = (receiverId == currentUserId.value)
const isRelatedToCurrentChat = (senderId == merchantId.value || receiverId == merchantId.value)
console.log('=== 条件判断 ===')
console.log('isMyMessage:', isMyMessage)
console.log('isForMe:', isForMe)
console.log('isRelatedToCurrentChat:', isRelatedToCurrentChat)
// 如果消息与当前聊天无关,跳过
if (!isRelatedToCurrentChat) {
console.log('消息与当前聊天无关,跳过')
return
}
// 如果是自己发送的消息,或者是发给自己的消息,都显示
if (isMyMessage || isForMe) {
const createdAt = newMsg.getString('created_at') ?? new Date().toISOString()
const date = new Date(createdAt)
const timeStr = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
// 生成安全的 viewId
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
const incomingMsg: UiChatMessage = {
id: msgId,
viewId: safeViewId,
type: isMyMessage ? 'sent' : 'received',
content: content,
time: timeStr,
msgType: msgType
}
console.log('=== 添加新消息到列表 ===')
console.log('消息类型:', incomingMsg.type)
console.log('消息内容:', incomingMsg.content)
messages.value.push(incomingMsg)
scrollToBottom()
} else {
console.log('条件不满足,不添加消息')
}
})
.subscribe((status: string, err: any | null) => {
console.log('订阅状态:', status)
if (err != null) {
console.log('订阅错误:', err)
}
})
}
async function loadChatHistory(): Promise<void> {
let rawMsgs : ChatMessage[] = []
if (merchantId.value != '') {
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
} else {
console.warn("No merchant ID provided for chat")
return
}
// 确保时间顺序是升序(旧的在前,新的在后)
// Supabase 返回的消息如果是降序,我们需要 reverse 过来显示
const sortedRawMsgs = rawMsgs.sort((a, b) => {
const timeA = new Date(a.created_at ?? '').getTime()
const timeB = new Date(b.created_at ?? '').getTime()
return timeA - timeB
})
const uiMessages : UiChatMessage[] = []
for (let i = 0; i < sortedRawMsgs.length; i++) {
const m = sortedRawMsgs[i]
const date = new Date(m.created_at ?? new Date().toISOString())
const timeStr = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
const sender = m.sender_id ?? ''
const msgType = (currentUserId.value != '' && sender == currentUserId.value) ? 'sent' : 'received'
const rawId = (m.id ?? '').toString()
const msgId = rawId != '' ? rawId : Date.now().toString() + i.toString()
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
const uiMsg : UiChatMessage = {
id: msgId,
viewId: safeViewId,
type: msgType,
content: m.content ?? '',
time: timeStr,
msgType: m.msg_type ?? 'text'
}
uiMessages.push(uiMsg)
}
messages.value = uiMessages
if (isInitialLoading.value) {
// 增加一点初始化延迟,等待 scroll-view 渲染就绪
setTimeout(() => {
scrollToBottom()
isInitialLoading.value = false
}, 500)
}
}
function onScrollToUpper(e: any): void {
console.log('[onScrollToUpper] 触发加载历史记录')
}
async function loadMerchantInfo(): Promise<void> {
if (merchantId.value == '') return
try {
const response = await supa
.from('ml_shops')
.select('shop_logo, shop_name')
.eq('merchant_id', merchantId.value)
.limit(1)
.execute()
if (response.error != null) {
console.error('[loadMerchantInfo] 获取商家信息失败:', response.error)
return
}
const rawData = response.data
if (rawData == null) return
const rawList = rawData as any[]
if (rawList.length == 0) return
const shopData = rawList[0]
const shopObj = JSON.parse(JSON.stringify(shopData)) as UTSJSONObject
const logo = shopObj.getString('shop_logo')
if (logo != null && logo != '') {
merchantAvatar.value = logo
}
const name = shopObj.getString('shop_name')
if (name != null && name != '' && headerTitle.value == '在线客服') {
headerTitle.value = name
}
} catch (e) {
console.error('[loadMerchantInfo] 获取商家信息异常:', e)
}
}
// 生命周期
onLoad((options: any) => {
// 动态获取状态栏高度
const sysInfo = uni.getSystemInfoSync()
const statusBarH = sysInfo.statusBarHeight
// 状态栏高度 + 10px 原有顶部内边距
navPaddingTop.value = (statusBarH + 10) + 'px'
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const mid = optObj.getString('merchantId') ?? ''
if (mid !== '') {
merchantId.value = mid
}
const mname = optObj.getString('merchantName') ?? ''
if (mname !== '') {
headerTitle.value = mname
}
})
onMounted(() => {
supabaseService.ensureSession().then((uid) => {
if (uid != null) {
currentUserId.value = uid
} else {
getCurrentUser().then((user) => {
if (user != null) {
currentUserId.value = user.id ?? ''
}
})
}
loadMerchantInfo()
loadChatHistory()
setupRealtimeSubscription()
})
})
onUnmounted(() => {
if (realtimeChannel != null) {
supa.removeChannel(realtimeChannel!!)
}
})
const sendMessage = async () => {
const content = inputMessage.value.trim()
if (content == '') return
// 清空输入框
inputMessage.value = ''
// 发送消息时确保收起表情面板
showEmoji.value = false
// 发送到 Supabase
if (merchantId.value != '') {
console.log('[sendMessage] 开始发送消息到:', merchantId.value)
const success = await supabaseService.sendMessage(merchantId.value, content)
console.log('[sendMessage] 发送结果:', success)
if (!success) {
uni.showToast({
title: '发送失败',
icon: 'none'
})
}
// 不需要手动添加消息,等待实时订阅推送
}
}
// 模拟客服回复 (已禁用,改用 Realtime)
/*
const simulateCustomerReply = async () => {
// ...
}
*/
/* 移除不再使用的 simulateCustomerReply 和 addReceivedMessage */
// 插入表情
function insertEmoji(emoji: string): void {
inputMessage.value += emoji
showEmoji.value = false // 选中表情后收起表情列表
inputFocus.value = true
}
// 显示表情选择器
function showEmojiPicker(): void {
showEmoji.value = !showEmoji.value
if (showEmoji.value) {
// 如果打开表情,通常需要收起键盘
uni.hideKeyboard()
}
}
// 执行图片上传
async function doUploadImage(filePath: string): Promise<void> {
console.log('[doUploadImage] 开始上传图片:', filePath)
// 显示加载提示
uni.showLoading({
title: '发送中...',
mask: true
})
try {
// 上传图片
const imageUrl = await supabaseService.uploadChatImage(filePath)
uni.hideLoading()
if (imageUrl == '') {
uni.showToast({
title: '图片上传失败',
icon: 'none'
})
return
}
console.log('[doUploadImage] 图片上传成功:', imageUrl)
// 发送图片消息
const success = await supabaseService.sendMessage(merchantId.value, imageUrl, 'image')
if (!success) {
uni.showToast({
title: '发送失败',
icon: 'none'
})
}
} catch (e) {
uni.hideLoading()
console.error('[doUploadImage] 上传异常:', e)
uni.showToast({
title: '上传失败',
icon: 'none'
})
}
}
// 显示图片选择器
function showImagePicker(): void {
uni.chooseImage({
count: 1,
success: (res) => {
console.log('选择图片成功:', JSON.stringify(res))
// 处理 tempFilePaths兼容不同平台
let filePath: string = ''
const tempFilePaths = res.tempFilePaths
if (tempFilePaths != null) {
if (Array.isArray(tempFilePaths)) {
const arr = tempFilePaths as string[]
if (arr.length > 0) {
filePath = arr[0]
}
} else if (tempFilePaths instanceof UTSJSONObject) {
const keys = UTSJSONObject.keys(tempFilePaths as UTSJSONObject)
if (keys.length > 0) {
filePath = (tempFilePaths as UTSJSONObject).getString(keys[0]) ?? ''
}
} else if (typeof tempFilePaths === 'string') {
filePath = tempFilePaths as string
}
}
// 尝试从 tempFiles 获取
if (filePath == '' && res.tempFiles != null) {
const tempFiles = res.tempFiles
if (Array.isArray(tempFiles)) {
const files = tempFiles as any[]
if (files.length > 0) {
const firstFile = files[0]
if (firstFile instanceof UTSJSONObject) {
filePath = firstFile.getString('path') ?? ''
} else if (typeof firstFile === 'object' && firstFile != null) {
const fileObj = JSON.parse(JSON.stringify(firstFile)) as UTSJSONObject
filePath = fileObj.getString('path') ?? ''
}
}
}
}
console.log('[showImagePicker] 文件路径:', filePath)
if (filePath == '') {
uni.showToast({
title: '获取图片路径失败',
icon: 'none'
})
return
}
// 执行上传
doUploadImage(filePath)
},
fail: (err) => {
console.log('选择图片失败:', err)
uni.showToast({
title: '选择图片失败',
icon: 'none'
})
}
})
}
// 预览图片
function previewImage(url: string): void {
uni.previewImage({
urls: [url],
current: url
})
}
// 显示更多工具
function showMoreTools(): void {
uni.showActionSheet({
itemList: ['发送位置', '发送文件', '发送语音'],
success: (res) => {
console.log('选择工具:', res.tapIndex)
}
})
}
// 显示更多操作
function showMoreActions(): void {
uni.showActionSheet({
itemList: ['投诉客服', '结束对话', '清除记录'],
success: (res) => {
switch (res.tapIndex) {
case 0:
uni.navigateTo({ url: '/pages/mall/consumer/complaint' })
break
case 1:
uni.showModal({
title: '确认结束',
content: '确定要结束本次对话吗?',
success: (res) => {
if (res.confirm) {
uni.navigateBack()
}
}
})
break
case 2:
uni.showModal({
title: '确认清除',
content: '确定要清除聊天记录吗?',
success: (res) => {
if (res.confirm) {
messages.value = []
}
}
})
break
}
}
})
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style>
.chat-page {
width: 100%;
flex: 1;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 聊天头部 */
.chat-header {
background-color: white;
padding: 10px 15px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
flex-shrink: 0;
}
.header-back {
width: 40px;
}
.back-icon {
font-size: 20px;
color: #333;
}
.header-info {
flex: 1;
}
.header-info-text-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.chat-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 2px;
}
.chat-status {
font-size: 12px;
color: #34c759;
}
.header-actions .action-icon {
font-size: 20px;
color: #333;
width: 40px;
}
.action-icon-text {
text-align: right;
width: 100%;
}
/* 聊天内容区 */
.chat-content {
flex: 1;
height: 0;
padding: 10px;
padding-bottom: 20px;
box-sizing: border-box;
}
.chat-messages {
display: flex;
flex-direction: column;
padding-bottom: 80px;
}
/* 系统消息 */
.message-item.system {
display: flex;
flex-direction: row;
justify-content: center;
margin-bottom: 20px;
}
.system-text {
font-size: 12px;
color: #999;
background-color: #f0f0f0;
padding: 5px 15px;
border-radius: 15px;
text-align: center;
}
/* 时间分割线 */
.time-divider {
display: flex;
flex-direction: row;
justify-content: center;
margin: 20px 0;
}
.time-text {
font-size: 12px;
color: #999;
background-color: #f0f0f0;
padding: 3px 10px;
border-radius: 10px;
text-align: center;
}
/* 消息项 */
.message-wrapper {
display: flex;
flex-direction: row;
margin-bottom: 15px;
}
.message-wrapper.me {
justify-content: flex-end;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 20px;
margin-right: 10px;
flex-shrink: 0;
}
.avatar.me {
margin-right: 0;
margin-left: 10px;
/* order: 2; removed for uni-app-x */
}
.message-content-wrapper {
width: 260px;
display: flex;
flex-direction: column;
}
.message-bubble {
background-color: white;
padding: 10px 15px;
border-radius: 12px;
position: relative;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.received-bubble {
align-self: flex-start;
border-top-left-radius: 2px;
}
.message-bubble.me {
background-color: #95ec69;
align-self: flex-end; /* 关键:靠右对齐且宽度自适应 */
border-top-right-radius: 2px;
}
.sender-name {
font-size: 11px;
color: #999;
margin-bottom: 2px;
align-self: flex-start;
}
.message-text {
font-size: 15px;
color: #333;
line-height: 1.4;
margin-bottom: 5px;
white-space: pre-wrap;
}
.message-image {
max-width: 200px;
min-width: 100px;
border-radius: 8px;
margin-bottom: 5px;
}
.message-time {
font-size: 11px;
color: #999;
text-align: right;
}
/* 聊天输入区 */
.chat-input {
background-color: white;
border-top: 1px solid #eee;
padding: 10px 15px;
padding-bottom: 20px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
flex-shrink: 0;
}
.input-tools {
display: flex;
flex-direction: row;
margin-bottom: 10px;
}
.tool-icon {
font-size: 20px;
margin-right: 15px;
color: #666;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.message-input {
flex: 1;
background-color: #f5f5f5;
border-radius: 20px;
padding: 10px 15px;
font-size: 15px;
margin-right: 10px;
min-height: 40px;
max-height: 100px;
}
.send-button {
background-color: #ccc;
color: white;
border: none;
border-radius: 20px;
padding: 8px 20px;
font-size: 14px;
min-width: 60px;
transition: all 0.3s ease;
}
.send-button.active {
background-color: #ff5000;
}
/* 表情选择器 */
.emoji-picker {
background-color: white;
border-top: 1px solid #eee;
padding: 10px;
height: 200px;
position: fixed;
bottom: 80px;
left: 0;
right: 0;
z-index: 99;
}
.emoji-category {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.emoji-item {
font-size: 24px;
padding: 8px;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
}
/* 响应式适配 removed for strict uv-app-x compliance */
</style>

View File

@@ -0,0 +1,619 @@
<template>
<view class="chat-page">
<!-- 聊天顶部 -->
<view class="chat-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-content">
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="header-info">
<text class="chat-title">{{ merchantName ?? '在线客服' }}</text>
<text class="chat-status">在线</text>
</view>
<view class="header-actions">
<text class="action-icon" @click="showMoreActions">⋯</text>
</view>
</view>
</view>
<!-- 聊天内容 -->
<scroll-view
scroll-y
class="chat-content"
:scroll-into-view="scrollToView"
scroll-with-animation
@scrolltoupper="loadMoreHistory"
>
<!-- 顶部占位:用于状态栏与标题栏留白 -->
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 聊天消息列表 -->
<view class="chat-messages">
<!-- 系统提示 -->
<view class="message-item system">
<text class="system-text">已连接到商家,开始会话</text>
</view>
<!-- 消息项 -->
<view
v-for="(message, index) in messages"
:key="message.id"
:class="['message-item', message.type]"
:id="'msg-' + message.id"
>
<view v-if="shouldShowTime(index)" class="time-divider">
<text>{{ formatTime(message.rawTime) }}</text>
</view>
<!-- 对方消息 -->
<view v-if="message.type === 'received'" class="message-wrapper">
<image
class="avatar"
:src="merchantLogo !== '' ? merchantLogo : '/static/logo.png'"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<view class="message-bubble">
<text class="message-text">{{ message.content }}</text>
</view>
</view>
</view>
<!-- 我的消息 -->
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me">
<text class="message-text">{{ message.content }}</text>
</view>
</view>
<image
class="avatar me"
:src="userAvatar !== '' ? userAvatar : '/static/images/default-product.png'"
mode="aspectFill"
/>
</view>
</view>
<!-- 底部填充,避免输入框遮挡 -->
<view style="height: 20px;"></view>
<view id="bottom-anchor" style="height: 1px;"></view>
</view>
</scroll-view>
<!-- 聊天输入区 -->
<view class="chat-input-area">
<view class="input-tools">
<text class="tool-icon" @click="toggleEmoji">😊</text>
</view>
<view class="input-wrapper">
<input
class="message-input"
v-model="inputMessage"
placeholder="请输入要发送的消息..."
:adjust-position="true"
confirm-type="send"
@confirm="sendMessage"
/>
<button
class="send-button"
:class="{ active: inputMessage.trim().length > 0 }"
@click="sendMessage"
>
发送
</button>
</view>
<!-- 消息选择器(例如表情) -->
<scroll-view scroll-y v-if="showEmoji" class="emoji-picker">
<view class="emoji-grid">
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supabaseService from '@/utils/supabaseService.uts'
import type { ChatMessage } from '@/utils/supabaseService.uts'
import supa from '@/components/supadb/aksupainstance.uts'
// 界面状态
const statusBarHeight = ref(0)
const scrollToView = ref('')
const showEmoji = ref(false)
const inputMessage = ref('')
// 业务数据
const merchantId = ref('')
const merchantName = ref('')
const merchantLogo = ref('')
const userAvatar = ref('')
const currentUserId = ref('')
const messages = ref<any[]>([])
const emojiList = ['😊','🙂','😂','😍','😢','👍','👏','😄','😁','😜','😭','😮','🤔','😎','😅']
let realtimeChannel: any | null = null
onLoad((options: any) => {
// 获取状态栏高度
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight ?? 0
// 鑾峰彇鍙傛暟
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const mid = optObj.getString('merchantId') ?? ''
if (mid !== '') {
merchantId.value = mid
merchantName.value = optObj.getString('merchantName') ?? '商家'
merchantLogo.value = optObj.getString('merchantLogo') ?? ''
console.log('已初始化会话商家ID:', merchantId.value)
} else {
merchantName.value = '平台客服'
}
// 获取当前用户
const uid = supabaseService.getCurrentUserId()
if (uid != null) {
currentUserId.value = uid
// 简单获取一下头像,实际应该从 Profile 获取
userAvatar.value = 'https://picsum.photos/100'
}
// 加载历史消息
loadHistory()
// 启动实时订阅
startRealtimeSubscription()
})
onUnmounted(() => {
if (realtimeChannel != null) {
supa.removeChannel(realtimeChannel!)
}
})
// 加载历史记录
const loadHistory = async () => {
let rawMsgs: ChatMessage[] = []
if (merchantId.value) {
// 获取与特定商家的聊天
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
} else {
// 获取所有,比如客服
rawMsgs = await supabaseService.getUserChatMessages()
}
const formatted = rawMsgs.reverse().map((m: ChatMessage) => formatMessage(m))
messages.value = formatted
scrollToBottom()
}
const loadMoreHistory = () => {
// TODO: 实现下拉加载更多历史
}
// 开启实时订阅
const startRealtimeSubscription = () => {
if (currentUserId.value == '') return
console.log('启动实时订阅')
const filterObj = ({
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
} as UTSJSONObject)
realtimeChannel = supa.channel(`chat_${currentUserId.value}`)
.on(
'postgres_changes',
filterObj,
(payload: any) => {
console.log('收到变更事件', payload)
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
const newAny = payloadObj.get('new')
if (newAny == null) return
const newMsg = (newAny instanceof UTSJSONObject) ? (newAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newAny)) as UTSJSONObject)
// 只有来自当前聊天商家的消息才显示,或全局客服模式
const senderId = newMsg.getString('sender_id') ?? ''
if (senderId === merchantId.value || merchantId.value == '') {
const formatted = formatMessage({
id: newMsg.getString('id') ?? '',
content: newMsg.getString('content') ?? '',
msg_type: newMsg.getString('msg_type') ?? '',
sender_id: senderId,
receiver_id: newMsg.getString('receiver_id') ?? '',
is_from_user: false, // 收到的一定不是自己发的
created_at: newMsg.getString('created_at') ?? ''
} as ChatMessage)
messages.value.push(formatted)
scrollToBottom()
// 闇囧姩鎻愮ず
uni.vibrateShort({})
}
}
)
.subscribe()
}
// 格式化消息
const formatMessage = (m: ChatMessage): any => {
// 如果 sender_id 是自己,就是 'sent',否则 'received'
// 注意:数据库字段 is_from_user 有时可能只是标记是否由 C 端用户发起,
// 最准确的是对比 id
let isMe = false
if (currentUserId.value) {
isMe = m.sender_id === currentUserId.value
} else {
isMe = m.is_from_user === true
}
return {
id: m.id,
type: isMe ? 'sent' : 'received',
content: m.content,
rawTime: m.created_at || new Date().toISOString(),
senderId: m.sender_id
}
}
const sendMessage = async () => {
const text = inputMessage.value.trim()
if (text == '') return
// 乐观更新 UI
const tempId = 'temp_' + Date.now()
const tempMsg = {
id: tempId,
type: 'sent',
content: text,
rawTime: new Date().toISOString(),
senderId: currentUserId.value
}
messages.value.push(tempMsg)
inputMessage.value = ''
scrollToBottom()
showEmoji.value = false
// 发送请求
// 注意:如果 merchantId 为空sendChatMessage 第二个参数传 null会变成无主消息
const success = await supabaseService.sendChatMessage(text, merchantId.value ? merchantId.value : null)
if (!success) {
uni.showToast({ title: '发送失败', icon: 'none' })
// 这里可以重试或删除临时消息
}
}
const scrollToBottom = () => {
// 延时滚动以确保视图更新
setTimeout(() => {
scrollToView.value = 'bottom-anchor'
// Hack: 重置再设置以强制触发
setTimeout(() => {
scrollToView.value = 'msg-' + (messages.value.length > 0 ? messages.value[messages.value.length-1].id : '')
}, 50)
}, 100)
}
const goBack = () => {
uni.navigateBack({})
}
const formatTime = (isoString: string): string => {
const date = new Date(isoString)
const now = new Date()
// 如果是今天,显示 HH:mm
if (date.toDateString() === now.toDateString()) {
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
// 否则显示 MM-DD HH:mm
const mo = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${mo}-${d} ${h}:${m}`
}
const shouldShowTime = (index: number): boolean => {
if (index === 0) return true
const prev = messages.value[index - 1]
const curr = messages.value[index]
const t1 = new Date(prev.rawTime).getTime()
const t2 = new Date(curr.rawTime).getTime()
// 间隔超过5分钟(300000ms)显示时间
return (t2 - t1) > 300000
}
const toggleEmoji = () => {
showEmoji.value = !showEmoji.value
if (showEmoji.value) {
scrollToBottom()
}
}
const insertEmoji = (emoji: string) => {
inputMessage.value += emoji
}
const showMoreActions = () => {
uni.showActionSheet({
itemList: ['清空记录', '联系客服'],
success: (res) => {
if (res.tapIndex === 0) {
messages.value = [] // 仅本地清空
}
}
})
}
</script>
<style>
.chat-page {
background-color: #f5f5f5;
height: 100%;
display: flex;
flex-direction: column;
}
.chat-header {
background-color: #fff;
border-bottom: 1px solid #eee;
z-index: 100;
}
.header-content {
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 16px;
justify-content: space-between;
}
.header-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-icon {
font-size: 32px;
color: #333;
line-height: 1;
}
.header-info {
display: flex;
flex-direction: column;
align-items: center;
}
.chat-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.chat-status {
font-size: 10px;
color: #4CAF50;
margin-top: 2px;
}
.header-actions {
width: 40px;
display: flex;
justify-content: flex-end;
}
.action-icon {
font-size: 24px;
color: #333;
}
.chat-content {
flex: 1;
/* height: 0; flex grow handles it */
background-color: #f5f5f5;
padding: 10px 0;
}
.chat-messages {
padding: 16px;
padding-bottom: 20px;
display: flex;
flex-direction: column;
}
.time-divider {
display: flex;
justify-content: center;
margin: 16px 0;
}
.time-divider text {
background-color: rgba(0,0,0,0.1);
color: #999;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.system {
justify-content: center;
margin-bottom: 16px;
display: flex;
align-items: center;
}
.system-text {
background-color: #e0e0e0;
color: #666;
font-size: 12px;
padding: 4px 12px;
border-radius: 12px;
}
.message-item {
margin-bottom: 16px;
display: flex;
width: 100%;
}
.received {
justify-content: flex-start;
flex-direction: row;
}
.sent {
justify-content: flex-end;
}
.message-wrapper {
display: flex;
flex-direction: row;
max-width: 80%;
align-items: flex-start;
}
.me {
flex-direction: row; /* Keep standard flow but justify-end handles position */
}
.avatar {
width: 40px;
height: 40px;
border-radius: 20px;
background-color: #ddd;
margin-right: 8px;
flex-shrink: 0;
}
.avatar.me {
margin-right: 0;
margin-left: 8px;
}
.message-content-wrapper {
display: flex;
flex-direction: column;
}
.sender-name {
font-size: 12px;
color: #999;
margin-bottom: 4px;
margin-left: 4px;
}
.message-bubble {
background-color: #fff;
padding: 10px 14px;
border-radius: 4px 12px 12px 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.message-bubble.me {
background-color: #95ec69; /* WeChat green */
border-radius: 12px 4px 12px 12px;
}
.message-text {
font-size: 15px;
color: #333;
line-height: 1.4;
word-break: break-all;
}
.chat-input-area {
background-color: #f9f9f9;
padding: 10px;
border-top: 1px solid #e0e0e0;
padding-bottom: env(safe-area-inset-bottom);
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
background-color: #fff;
border-radius: 24px;
padding: 8px 12px;
}
.message-input {
flex: 1;
font-size: 16px;
height: 36px;
}
.send-button {
margin-left: 8px;
background-color: #e0e0e0;
color: #999;
font-size: 14px;
padding: 4px 12px;
border-radius: 16px;
border: none;
line-height: 24px;
}
.send-button.active {
background-color: #4CAF50;
color: #fff;
}
.input-tools {
display: flex;
flex-direction: row;
padding-bottom: 8px;
}
.tool-icon {
font-size: 24px;
color: #666;
margin-right: 16px;
padding: 4px;
}
.emoji-picker {
height: 150px;
background-color: #f9f9f9;
border-top: 1px solid #eee;
padding: 10px;
}
.emoji-grid {
display: flex;
flex-wrap: wrap;
flex-direction: row;
}
.emoji-item {
font-size: 24px;
padding: 8px;
margin: 4px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
<template>
<view class="coupons-page">
<view class="coupon-list">
<view v-if="coupons.length === 0" class="empty-state">
<text class="empty-icon">🎫</text>
<text class="empty-text">暂无优惠券</text>
</view>
<view v-else v-for="(coupon, index) in coupons" :key="index" class="coupon-item">
<view class="coupon-left">
<text class="coupon-amount">{{ coupon.amount }}</text>
<text class="coupon-type">优惠券</text>
</view>
<view class="coupon-right">
<text class="coupon-title">{{ coupon.title }}</text>
<text class="coupon-expiry">有效期至: {{ coupon.expiry }}</text>
<button class="use-btn" @click="useCoupon(coupon)">去使用</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supabaseService from '@/utils/supabaseService.uts'
import type { UserCoupon } from '@/utils/supabaseService.uts'
type Coupon = {
title: string
amount: string
expiry: string
id: string
}
const coupons = ref<Coupon[]>([])
const loadCoupons = async () => {
uni.showLoading({ title: '加载中...' })
try {
const userCoupons = await supabaseService.getUserCoupons(1)
const couponList: Coupon[] = []
for (let i = 0; i < userCoupons.length; i++) {
const item = userCoupons[i]
const amountVal = item.amount ?? 0
const expiryVal = (item.expire_at != null && item.expire_at !== '')
? item.expire_at.substring(0, 10)
: '长期有效'
const coupon: Coupon = {
id: item.id,
title: (item.template_name != null && item.template_name !== '') ? item.template_name : '优惠券',
amount: `¥${amountVal}`,
expiry: expiryVal
} as Coupon
couponList.push(coupon)
}
coupons.value = couponList
} catch (e) {
console.error('加载优惠券失败', e)
coupons.value = []
} finally {
uni.hideLoading()
}
}
onMounted(() => {
loadCoupons()
})
const useCoupon = (coupon: Coupon) => {
uni.switchTab({
url: '/pages/main/index'
})
}
</script>
<style>
.coupons-page {
padding: 15px;
background-color: #f5f5f5;
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 100px;
}
.empty-icon {
font-size: 60px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #999;
}
.coupon-item {
display: flex;
background-color: white;
border-radius: 8px;
margin-bottom: 15px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.coupon-left {
width: 100px;
background: linear-gradient(135deg, #FF9800, #FF5722);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
padding: 15px;
}
.coupon-amount {
font-size: 24px;
font-weight: bold;
}
.coupon-type {
font-size: 12px;
margin-top: 5px;
}
.coupon-right {
flex: 1;
padding: 15px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.coupon-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.coupon-expiry {
font-size: 12px;
color: #999;
margin-bottom: 10px;
}
.use-btn {
align-self: flex-end;
font-size: 12px;
background-color: #FF5722;
color: white;
padding: 4px 12px;
border-radius: 15px;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,591 @@
<!-- 收藏页面 -->
<template>
<view class="favorites-page">
<view class="favorites-header">
<view v-if="favorites.length > 0" class="header-actions">
<text class="action-btn" @click="toggleEditMode">{{ isEditMode ? '完成' : '编辑' }}</text>
<text class="action-btn" @click="clearAll">清空</text>
</view>
</view>
<scroll-view class="favorites-content" :scroll-y="true">
<view v-if="favorites.length === 0 && !isLoading" class="empty-favorites">
<text class="empty-icon">❤️</text>
<text class="empty-text">暂无收藏商品</text>
<text class="empty-subtext">快去收藏喜欢的商品吧</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<view class="product-group">
<view class="group-items">
<view v-for="(product, index) in favorites" :key="index" class="product-item">
<view v-if="isEditMode" class="item-selector" @click="toggleSelect(product)">
<view :class="['select-icon', { selected: product.selected === true }]">
<text v-if="product.selected === true" class="icon-text">✓</text>
</view>
</view>
<view class="item-content" @click="viewProduct(product)">
<image class="product-image" :src="product.main_image_url" mode="aspectFill" />
<text class="product-name" :lines="2">{{ product.name }}</text>
<view class="product-bottom">
<text class="product-price">¥{{ product.price }}</text>
<view v-if="!isEditMode" class="product-add-btn" @click.stop="addToCart(product)">
<text class="add-icon">+</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view v-if="isLoading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!isLoading && favorites.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<view v-if="isEditMode && favorites.length > 0" class="edit-bar">
<view class="select-all" @click="toggleSelectAll">
<view :class="['all-select-icon', { selected: isAllSelected }]">
<text v-if="isAllSelected" class="icon-text">✓</text>
</view>
<text class="select-all-text">全选</text>
</view>
<view class="delete-btn" @click="deleteSelected">
<text class="delete-text">删除({{ selectedCount }})</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type FavoriteType = {
id: string
name: string
price: number
main_image_url: string
merchant_id: string
selected: boolean
}
const favorites = ref<FavoriteType[]>([])
const isEditMode = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const selectedCount = computed((): number => {
return favorites.value.filter((item): Boolean => item.selected === true).length
})
const isAllSelected = computed((): boolean => {
return favorites.value.length > 0 && favorites.value.every((item): Boolean => item.selected === true)
})
const loadFavorites = async () => {
isLoading.value = true
try {
const res = await supabaseService.getFavorites()
console.log('收藏数据加载完成,数量:', res.length)
const productList: FavoriteType[] = []
for (let i = 0; i < res.length; i++) {
const item = res[i]
let prod: any | null = null
let itemObj: UTSJSONObject | null = null
if (item instanceof UTSJSONObject) {
itemObj = item as UTSJSONObject
prod = itemObj.get('ml_products')
} else {
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
prod = itemObj.get('ml_products')
}
let image = '/static/default-product.png'
let id = ''
let name = '未知商品'
let price = 0
let merchantId = ''
if (prod != null) {
let prodObj: UTSJSONObject
if (prod instanceof UTSJSONObject) {
prodObj = prod as UTSJSONObject
} else {
prodObj = JSON.parse(JSON.stringify(prod)) as UTSJSONObject
}
id = prodObj.getString('id') ?? ''
name = prodObj.getString('name') ?? '未知商品'
price = prodObj.getNumber('base_price') ?? 0
image = prodObj.getString('main_image_url') ?? image
merchantId = prodObj.getString('merchant_id') ?? ''
if (image === '/static/default-product.png') {
const imgUrls = prodObj.getString('image_urls')
if (imgUrls != null) {
try {
const arr = JSON.parse(imgUrls)
if (Array.isArray(arr) && arr.length > 0) {
image = arr[0] as string
}
} catch(e) {}
}
}
} else {
if (itemObj != null) {
id = itemObj.getString('target_id') ?? ''
}
}
const product: FavoriteType = {
id: id,
name: name,
price: price,
main_image_url: image,
merchant_id: merchantId,
selected: false
}
productList.push(product)
}
favorites.value = productList
console.log('收藏列表更新完成,数量:', favorites.value.length)
} catch (e) {
console.error('加载收藏失败', e)
} finally {
isLoading.value = false
}
}
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
for (let i = 0; i < favorites.value.length; i++) {
favorites.value[i].selected = false
}
}
const clearAll = () => {
if (favorites.value.length === 0) return
uni.showModal({
title: '清空收藏',
content: '确定要清空所有收藏商品吗?',
success: (res) => {
if (res.confirm) {
uni.showLoading({ title: '清空中...' })
const productIds: string[] = []
for (let i = 0; i < favorites.value.length; i++) {
productIds.push(favorites.value[i].id)
}
let completed = 0
for (let i = 0; i < productIds.length; i++) {
supabaseService.toggleFavorite(productIds[i]).then(() => {
completed++
if (completed === productIds.length) {
uni.hideLoading()
favorites.value = []
uni.showToast({
title: '已清空',
icon: 'success'
})
}
}).catch(() => {
completed++
if (completed === productIds.length) {
uni.hideLoading()
loadFavorites()
uni.showToast({
title: '部分清空失败',
icon: 'none'
})
}
})
}
}
}
})
}
const toggleSelect = (item: FavoriteType) => {
item.selected = !(item.selected === true)
favorites.value = [...favorites.value]
}
const toggleSelectAll = () => {
const newSelectedState = !isAllSelected.value
for (let i = 0; i < favorites.value.length; i++) {
favorites.value[i].selected = newSelectedState
}
favorites.value = [...favorites.value]
}
const deleteSelected = () => {
const selectedItems = favorites.value.filter((item): Boolean => item.selected === true)
if (selectedItems.length === 0) {
uni.showToast({
title: '请选择要删除的商品',
icon: 'none'
})
return
}
uni.showModal({
title: '删除收藏',
content: `确定要删除选中的 ${selectedItems.length} 个商品吗?`,
success: (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中...' })
let completed = 0
const total = selectedItems.length
for (let i = 0; i < selectedItems.length; i++) {
supabaseService.toggleFavorite(selectedItems[i].id).then(() => {
completed++
if (completed === total) {
uni.hideLoading()
loadFavorites()
uni.showToast({
title: '已删除',
icon: 'success'
})
}
}).catch(() => {
completed++
if (completed === total) {
uni.hideLoading()
loadFavorites()
uni.showToast({
title: '部分删除失败',
icon: 'none'
})
}
})
}
}
}
})
}
const viewProduct = (product: FavoriteType) => {
if (isEditMode.value) {
toggleSelect(product)
return
}
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?id=${product.id}`
})
}
const addToCart = async (product: FavoriteType) => {
uni.showLoading({ title: '检查商品...' })
try {
const merchantId = product.merchant_id ?? ''
const skus = await supabaseService.getProductSkus(product.id)
uni.hideLoading()
if (skus.length > 0) {
uni.showToast({ title: '请选择规格', icon: 'none' })
setTimeout(() => {
uni.navigateTo({
url: '/pages/mall/consumer/product-detail?id=' + product.id
})
}, 500)
} else {
uni.showLoading({ title: '添加中...' })
const success = await supabaseService.addToCart(product.id, 1, '', merchantId)
uni.hideLoading()
if (success) {
uni.showToast({ title: '已添加到购物车', icon: 'success' })
} else {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
} catch (e) {
console.error('添加到购物车异常', e)
uni.hideLoading()
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const goShopping = () => {
uni.switchTab({
url: '/pages/main/index'
})
}
onMounted(() => {
loadFavorites()
})
</script>
<style scoped>
.favorites-page {
display: flex;
flex-direction: column;
flex: 1;
}
.favorites-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-actions {
display: flex;
flex-direction: row;
flex: 1;
justify-content: flex-end;
align-items: center;
padding-right: 0;
}
.action-btn {
color: #007aff;
font-size: 14px;
padding: 5px;
margin-left: 20px;
}
.favorites-content {
flex: 1;
height: 0px;
}
.empty-favorites {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
margin-bottom: 30px;
}
.go-shopping-btn {
background-color: #007aff;
color: #ffffff;
padding: 10px 40px;
border-radius: 25px;
font-size: 14px;
border: none;
}
.product-group {
background-color: #ffffff;
margin-bottom: 10px;
padding: 0 10px;
}
.group-items {
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.product-item {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
overflow: hidden;
width: 48%;
margin-bottom: 12px;
position: relative;
}
.item-selector {
position: absolute;
top: 5px;
right: 5px;
z-index: 10;
width: 30px;
height: 30px;
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
}
.select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
background-color: rgba(255,255,255,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.icon-text {
color: #ffffff;
font-size: 12px;
}
.item-content {
display: flex;
flex-direction: column;
}
.product-image {
width: 100%;
height: 170px;
border-radius: 8px;
margin-bottom: 8px;
background: #f5f5f5;
}
.product-name {
font-size: 13px;
color: #333;
margin-bottom: 5px;
line-height: 1.4;
height: 36px;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 8px;
}
.product-bottom {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 8px 8px;
}
.product-price {
font-size: 15px;
color: #ff5000;
font-weight: bold;
}
.product-add-btn {
width: 24px;
height: 24px;
background-color: #ff5000;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.add-icon {
color: #fff;
font-size: 16px;
font-weight: bold;
}
@media (min-width: 768px) {
.product-item {
width: 32% !important;
}
}
@media (min-width: 1024px) {
.product-item {
width: 16% !important;
}
.favorites-content, .favorites-header {
max-width: 1200px;
margin: 0 auto;
}
}
.loading-more,
.no-more {
padding: 20px;
text-align: center;
background-color: #ffffff;
}
.loading-text,
.no-more-text {
color: #999999;
font-size: 14px;
}
.edit-bar {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.select-all {
display: flex;
align-items: center;
}
.all-select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.all-select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.select-all-text {
font-size: 14px;
color: #333333;
}
.delete-btn {
background-color: #ff4757;
padding: 10px 20px;
border-radius: 15px;
}
.delete-text {
color: #ffffff;
font-size: 14px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,750 @@
<!-- 足迹页面 -->
<template>
<view class="footprint-page">
<view class="footprint-header">
<view v-if="footprints.length > 0" class="header-actions">
<text class="action-btn" @click="toggleEditMode">{{ isEditMode ? '完成' : '编辑' }}</text>
<text class="action-btn" @click="clearAll">清空</text>
</view>
</view>
<scroll-view class="footprint-content" :scroll-y="true" @scrolltolower="loadMore">
<view v-if="footprints.length === 0 && !isLoading" class="empty-footprints">
<text class="empty-icon">👣</text>
<text class="empty-text">暂无浏览记录</text>
<text class="empty-subtext">快去浏览喜欢的商品吧</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<view v-for="(group, index) in groupedFootprints" :key="index" class="date-group">
<view class="group-header">
<text class="group-date">{{ group.dateLabel }}</text>
<text v-if="isEditMode" class="group-select" @click="toggleGroupSelect(index)">
{{ isGroupSelected(index) ? '取消全选' : '全选' }}
</text>
</view>
<view class="group-items">
<view v-for="item in group.items" :key="item.id" class="footprint-item">
<view v-if="isEditMode" class="item-selector" @click="toggleSelect(item)">
<view :class="['select-icon', { selected: item.selected === true }]">
<text v-if="item.selected === true" class="icon-text">✓</text>
</view>
</view>
<view class="item-content" @click="viewProduct(item)">
<image class="product-image" :src="item.image" mode="aspectFill" />
<text class="product-name" :lines="2">{{ item.name }}</text>
<view class="product-bottom">
<text class="product-price">¥{{ item.price }}</text>
<view class="product-add-btn" @click.stop="addToCart(item)">
<text class="add-icon">+</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view v-if="isLoading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!hasMore && footprints.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<view v-if="isEditMode && footprints.length > 0" class="edit-bar">
<view class="select-all" @click="toggleSelectAll">
<view :class="['all-select-icon', { selected: isAllSelected }]">
<text v-if="isAllSelected" class="icon-text">✓</text>
</view>
<text class="select-all-text">全选</text>
</view>
<view class="delete-btn" @click="deleteSelected">
<text class="delete-text">删除({{ selectedCount }})</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type FootprintType = {
id: string
name: string
price: number
original_price: number
image: string
sales: number
shopId: string
shopName: string
viewTime: number
selected: boolean
merchant_id: string
}
type FootprintGroup = {
dateLabel: string
dateKey: string
items: FootprintType[]
}
type FootprintSaveType = {
id: string
name: string
price: number
original_price: number
image: string
sales: number
shopId: string
shopName: string
viewTime: number
}
const footprints = ref<FootprintType[]>([])
const isEditMode = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const hasMore = ref<boolean>(false)
const selectedCount = computed((): number => {
return footprints.value.filter((item): Boolean => item.selected === true).length
})
const isAllSelected = computed((): boolean => {
return footprints.value.length > 0 && footprints.value.every((item): Boolean => item.selected === true)
})
const formatGroupDate = (dateStr: string): string => {
const date = new Date(dateStr)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return '今天'
} else if (date.toDateString() === yesterday.toDateString()) {
return '昨天'
} else {
const month = date.getMonth() + 1
const day = date.getDate()
return `${month}月${day}日`
}
}
const groupedFootprints = computed((): FootprintGroup[] => {
const result: FootprintGroup[] = []
for (let i = 0; i < footprints.value.length; i++) {
const item = footprints.value[i]
const dateKey = new Date(item.viewTime).toDateString()
let foundGroup: FootprintGroup | null = null
for (let j = 0; j < result.length; j++) {
if (result[j].dateKey === dateKey) {
foundGroup = result[j]
break
}
}
if (foundGroup != null) {
foundGroup.items.push(item)
} else {
const newGroup: FootprintGroup = {
dateLabel: formatGroupDate(dateKey),
dateKey: dateKey,
items: [item]
} as FootprintGroup
result.push(newGroup)
}
}
return result
})
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
for (let i = 0; i < footprints.value.length; i++) {
footprints.value[i].selected = false
}
}
const clearAll = () => {
if (footprints.value.length === 0) return
uni.showModal({
title: '清空足迹',
content: '确定要清空所有浏览记录吗?',
success: (res) => {
if (res.confirm) {
uni.showLoading({ title: '清空中...' })
supabaseService.clearFootprints().then((success) => {
uni.hideLoading()
if (success) {
footprints.value = []
uni.removeStorageSync('footprints')
uni.showToast({
title: '已清空',
icon: 'success'
})
} else {
uni.showToast({
title: '清空失败',
icon: 'none'
})
}
})
}
}
})
}
const toggleSelect = (item: FootprintType) => {
item.selected = !(item.selected === true)
footprints.value = [...footprints.value]
}
const toggleGroupSelect = (groupIndex: number) => {
const group = groupedFootprints.value[groupIndex]
if (group == null) return
const allSelected = group.items.every((item): Boolean => item.selected === true)
const newSelectedState = !allSelected
for (let i = 0; i < group.items.length; i++) {
group.items[i].selected = newSelectedState
}
footprints.value = [...footprints.value]
}
const isGroupSelected = (groupIndex: number): boolean => {
const group = groupedFootprints.value[groupIndex]
if (group == null || group.items.length === 0) return false
return group.items.every((item): Boolean => item.selected === true)
}
const toggleSelectAll = () => {
const newSelectedState = !isAllSelected.value
for (let i = 0; i < footprints.value.length; i++) {
footprints.value[i].selected = newSelectedState
}
footprints.value = [...footprints.value]
}
const deleteSelected = () => {
const selectedItems = footprints.value.filter((item): Boolean => item.selected === true)
if (selectedItems.length === 0) {
uni.showToast({
title: '请选择要删除的记录',
icon: 'none'
})
return
}
uni.showModal({
title: '确认删除',
content: `确定要删除选中的${selectedItems.length}条记录吗?`,
success: (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中...' })
// 收集要删除的商品ID
const productIds: string[] = []
for (let i = 0; i < selectedItems.length; i++) {
productIds.push(selectedItems[i].id)
}
// 调用服务层批量删除
supabaseService.deleteFootprints(productIds).then((success) => {
uni.hideLoading()
if (success) {
// 从本地列表中移除
footprints.value = footprints.value.filter((item): Boolean => item.selected !== true)
// 更新本地缓存
const dataToSave: FootprintSaveType[] = []
for (let i = 0; i < footprints.value.length; i++) {
const item = footprints.value[i]
dataToSave.push({
id: item.id,
name: item.name,
price: item.price,
original_price: item.original_price,
image: item.image,
sales: item.sales,
shopId: item.shopId,
shopName: item.shopName,
viewTime: item.viewTime
} as FootprintSaveType)
}
uni.setStorageSync('footprints', JSON.stringify(dataToSave))
uni.showToast({
title: '删除成功',
icon: 'success'
})
if (footprints.value.length === 0) {
isEditMode.value = false
}
} else {
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
})
}
}
})
}
const addToCart = async (item: FootprintType) => {
uni.showLoading({ title: '检查商品...' })
try {
const productId = item.id
const merchantId = item.merchant_id ?? item.shopId ?? ''
// 检查商品是否有SKU
const skus = await supabaseService.getProductSkus(productId)
uni.hideLoading()
if (skus.length > 0) {
// 有规格,提示并跳转到商品详情页选择规格
uni.showToast({
title: '请选择规格',
icon: 'none'
})
setTimeout(() => {
uni.navigateTo({
url: '/pages/mall/consumer/product-detail?id=' + productId
})
}, 500)
} else {
// 无规格,直接加入购物车
uni.showLoading({ title: '添加中...' })
const success = await supabaseService.addToCart(productId, 1, '', merchantId)
uni.hideLoading()
if (success) {
uni.showToast({
title: '已添加到购物车',
icon: 'success'
})
} else {
uni.showToast({
title: '添加失败',
icon: 'none'
})
}
}
} catch (e) {
console.error('添加到购物车异常', e)
uni.hideLoading()
uni.showToast({
title: '操作失败',
icon: 'none'
})
}
}
const viewProduct = (item: FootprintType) => {
if (isEditMode.value) return
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?productId=${item.id}&price=${item.price}&originalPrice=${item.original_price}`
})
}
const loadMore = () => {
}
const goShopping = () => {
uni.switchTab({
url: '/pages/main/index'
})
}
const parseFootprintItem = (item: any): FootprintType => {
let itemObj: UTSJSONObject
if (item instanceof UTSJSONObject) {
itemObj = item as UTSJSONObject
} else {
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
}
return {
id: itemObj.getString('id') ?? '',
name: itemObj.getString('name') ?? '',
price: itemObj.getNumber('price') ?? 0,
original_price: itemObj.getNumber('original_price') ?? 0,
image: itemObj.getString('image') ?? '',
sales: itemObj.getNumber('sales') ?? 0,
shopId: itemObj.getString('shopId') ?? '',
shopName: itemObj.getString('shopName') ?? '',
viewTime: itemObj.getNumber('viewTime') ?? 0,
selected: false,
merchant_id: itemObj.getString('merchant_id') ?? ''
} as FootprintType
}
const loadFootprints = async () => {
isLoading.value = true
try {
const remoteData = await supabaseService.getFootprints()
if (remoteData.length > 0) {
console.log('获取到远程足迹数据:', remoteData.length)
const newFootprints: FootprintType[] = []
for (let i = 0; i < remoteData.length; i++) {
newFootprints.push(parseFootprintItem(remoteData[i]))
}
footprints.value = newFootprints
const dataToSave: FootprintSaveType[] = []
for (let i = 0; i < footprints.value.length; i++) {
const item = footprints.value[i]
dataToSave.push({
id: item.id,
name: item.name,
price: item.price,
original_price: item.original_price,
image: item.image,
sales: item.sales,
shopId: item.shopId,
shopName: item.shopName,
viewTime: item.viewTime
} as FootprintSaveType)
}
uni.setStorageSync('footprints', JSON.stringify(dataToSave))
} else {
const storedFootprints = uni.getStorageSync('footprints')
if (storedFootprints != null) {
try {
const data = JSON.parse(storedFootprints as string) as any[]
const newFootprints: FootprintType[] = []
for (let i = 0; i < data.length; i++) {
newFootprints.push(parseFootprintItem(data[i]))
}
footprints.value = newFootprints
} catch (e) {
console.error('Failed to parse footprints', e)
footprints.value = []
}
} else {
footprints.value = []
}
}
} catch (e) {
console.error('加载足迹失败', e)
const storedFootprints = uni.getStorageSync('footprints')
if (storedFootprints != null) {
try {
const data = JSON.parse(storedFootprints as string) as any[]
const newFootprints: FootprintType[] = []
for (let i = 0; i < data.length; i++) {
newFootprints.push(parseFootprintItem(data[i]))
}
footprints.value = newFootprints
} catch (err) {
footprints.value = []
}
}
}
isLoading.value = false
hasMore.value = false
}
onMounted(() => {
loadFootprints()
})
</script>
<style scoped>
.footprint-page {
display: flex;
flex-direction: column;
flex: 1;
}
.footprint-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-actions {
display: flex;
flex-direction: row;
flex: 1;
justify-content: flex-end;
align-items: center;
padding-right: 0;
}
.action-btn {
color: #007aff;
font-size: 14px;
padding: 5px;
margin-left: 20px;
}
.footprint-content {
flex: 1;
height: 0px;
}
.empty-footprints {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
margin-bottom: 30px;
}
.go-shopping-btn {
background-color: #007aff;
color: #ffffff;
padding: 10px 40px;
border-radius: 25px;
font-size: 14px;
border: none;
}
.date-group {
background-color: #ffffff;
margin-bottom: 10px;
padding: 0 10px;
}
.group-header {
padding: 15px 5px;
display: flex;
align-items: center;
justify-content: space-between;
}
.group-date {
font-size: 16px;
font-weight: bold;
color: #333333;
}
.group-select {
color: #007aff;
font-size: 14px;
}
.group-items {
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.footprint-item {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
overflow: hidden;
width: 48%;
margin-bottom: 12px;
position: relative;
}
.item-selector {
position: absolute;
top: 5px;
right: 5px;
z-index: 10;
width: 30px;
height: 30px;
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
}
.select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
background-color: rgba(255,255,255,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.icon-text {
color: #ffffff;
font-size: 12px;
}
.item-content {
display: flex;
flex-direction: column;
}
.product-image {
width: 100%;
height: 170px;
border-radius: 8px;
margin-bottom: 8px;
background: #f5f5f5;
}
.product-name {
font-size: 13px;
color: #333;
margin-bottom: 5px;
line-height: 1.4;
height: 36px;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 8px;
}
.product-bottom {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 8px 8px;
}
.product-price {
font-size: 15px;
color: #ff5000;
font-weight: bold;
}
.product-add-btn {
width: 24px;
height: 24px;
background-color: #ff5000;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.add-icon {
color: #fff;
font-size: 16px;
font-weight: bold;
}
@media (min-width: 768px) {
.footprint-item {
width: 32% !important;
}
}
@media (min-width: 1024px) {
.footprint-item {
width: 16% !important;
}
.footprint-content, .footprint-header {
max-width: 1200px;
margin: 0 auto;
}
}
.loading-more,
.no-more {
padding: 20px;
text-align: center;
background-color: #ffffff;
}
.loading-text,
.no-more-text {
color: #999999;
font-size: 14px;
}
.edit-bar {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.select-all {
display: flex;
align-items: center;
}
.all-select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.all-select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.select-all-text {
font-size: 14px;
color: #333333;
}
.delete-btn {
background-color: #ff4757;
padding: 10px 20px;
border-radius: 15px;
}
.delete-text {
color: #ffffff;
font-size: 14px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,182 @@
<template>
<scroll-view class="page" scroll-y="true">
<ServicePanel title="提交服务申请" subtitle="先使用 mock 数据模拟申请受理流程。">
<view class="form-item">
<text class="label">选择服务</text>
<view class="choice-wrap">
<view
v-for="item in services"
:key="item.id"
class="choice-card"
:class="selectedServiceId == item.id ? 'choice-active' : ''"
@click="selectService(item.id, item.name)"
>
<text class="choice-title">{{ item.name }}</text>
<text class="choice-desc">{{ item.durationText }} · ¥{{ item.price }}</text>
</view>
</view>
</view>
<view class="form-item">
<text class="label">申请人</text>
<input v-model="form.applicantName" class="input" placeholder="请输入申请人姓名" />
</view>
<view class="form-item">
<text class="label">服务对象</text>
<input v-model="form.elderName" class="input" placeholder="请输入老人姓名" />
</view>
<view class="form-item">
<text class="label">年龄</text>
<input v-model="ageText" class="input" type="number" placeholder="请输入老人年龄" />
</view>
<view class="form-item">
<text class="label">联系电话</text>
<input v-model="form.phone" class="input" type="number" placeholder="请输入联系电话" />
</view>
<view class="form-item">
<text class="label">服务地址</text>
<textarea v-model="form.address" class="textarea" placeholder="请输入详细上门地址"></textarea>
</view>
<view class="form-item">
<text class="label">期望时间</text>
<input v-model="form.preferredTime" class="input" placeholder="例如 2026-05-14 上午" />
</view>
<view class="form-item">
<text class="label">需求说明</text>
<textarea v-model="form.demandSummary" class="textarea" placeholder="简要描述照护需求、病情重点、注意事项"></textarea>
</view>
<view class="submit-btn" @click="submitApplication">提交申请</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { createHomeServiceApplication, fetchHomeServiceCatalog } from '@/services/homeServiceService.uts'
import { HomeServiceApplicationDraftType, HomeServiceCatalogType } from '@/types/home-service.uts'
const services = ref<Array<HomeServiceCatalogType>>([])
const selectedServiceId = ref('svc-001')
const selectedServiceName = ref('基础上门护理')
const ageText = ref('78')
const form = reactive({
serviceId: 'svc-001',
serviceName: '基础上门护理',
applicantName: '李晓兰',
elderName: '李奶奶',
age: 78,
phone: '13800138000',
address: '梅州市梅江区学海路 18 号 2 栋 602',
preferredTime: '2026-05-14 上午',
demandSummary: '老人需要基础照护、血压监测和跌倒风险提醒。'
} as HomeServiceApplicationDraftType)
async function loadData() {
services.value = await fetchHomeServiceCatalog()
}
function selectService(serviceId: string, serviceName: string) {
selectedServiceId.value = serviceId
selectedServiceName.value = serviceName
form.serviceId = serviceId
form.serviceName = serviceName
}
async function submitApplication() {
if (form.applicantName == '' || form.elderName == '' || form.phone == '' || form.address == '' || form.preferredTime == '') {
uni.showToast({ title: '请补全申请信息', icon: 'none' })
return
}
const parsedAge = parseInt(ageText.value)
form.age = isNaN(parsedAge) ? 0 : parsedAge
const created = await createHomeServiceApplication(form)
uni.showToast({ title: '申请已提交', icon: 'success' })
uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + created.id })
}
onLoad(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.form-item {
margin-bottom: 24rpx;
}
.label {
font-size: 28rpx;
font-weight: 700;
color: #16324f;
margin-bottom: 12rpx;
}
.input,
.textarea {
width: 100%;
background: #f8fbfc;
border-radius: 18rpx;
padding: 22rpx 24rpx;
font-size: 28rpx;
color: #23384d;
box-sizing: border-box;
}
.textarea {
height: 160rpx;
}
.choice-wrap {
gap: 16rpx;
}
.choice-card {
padding: 22rpx;
background: #f8fbfc;
border-radius: 18rpx;
margin-bottom: 16rpx;
border-width: 2rpx;
border-style: solid;
border-color: transparent;
}
.choice-active {
border-color: #0f766e;
background: #effcf8;
}
.choice-title {
font-size: 30rpx;
font-weight: 700;
color: #16324f;
}
.choice-desc {
margin-top: 8rpx;
font-size: 24rpx;
color: #66788a;
}
.submit-btn {
margin-top: 16rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background: #0f766e;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,182 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到验收信息</text>
</view>
<view v-else>
<ServicePanel title="验收反馈" subtitle="家属可确认服务完成情况,也可退回整改。">
<text class="info">服务单号:{{ detail.caseNo }}</text>
<text class="info">服务对象:{{ detail.elderName }}</text>
<text class="info">服务项目:{{ detail.serviceName }}</text>
<text class="info">当前状态:{{ detail.acceptanceStatusText }}</text>
<view class="block">
<text class="label">满意度评分</text>
<view class="rating-row">
<view v-for="score in scores" :key="score" class="rating-item" :class="rating >= score ? 'rating-active' : ''" @click="rating = score">{{ score }}分</view>
</view>
</view>
<view class="block">
<text class="label">评价标签</text>
<view class="tag-row">
<view
v-for="item in allTags"
:key="item"
class="tag-item"
:class="selectedTags.indexOf(item) >= 0 ? 'tag-active' : ''"
@click="toggleTag(item)"
>
{{ item }}
</view>
</view>
</view>
<view class="block">
<text class="label">反馈说明</text>
<textarea v-model="feedback" class="textarea" placeholder="填写验收意见、服务感受或需要整改的问题"></textarea>
</view>
<view class="action-row">
<view class="action ghost" @click="submitResult(false)">退回整改</view>
<view class="action primary" @click="submitResult(true)">确认验收</view>
</view>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchConsumerAcceptanceDetail, submitConsumerAcceptance } from '@/services/homeServiceService.uts'
import { HomeServiceAcceptanceType } from '@/types/home-service.uts'
const caseId = ref('')
const detail = ref<HomeServiceAcceptanceType | null>(null)
const rating = ref(5)
const feedback = ref('护理员服务规范,过程说明清楚,老人状态稳定。')
const selectedTags = ref<Array<string>>([])
const scores = [1, 2, 3, 4, 5]
const allTags = ['准时上门', '沟通清楚', '动作规范', '记录完整', '需进一步整改']
onLoad((options) => {
const id = options['id']
if (id != null) {
caseId.value = id as string
fetchConsumerAcceptanceDetail(caseId.value).then((res) => {
if (res != null) {
detail.value = res
rating.value = res.rating
feedback.value = res.feedback
selectedTags.value = res.tags.slice(0)
}
})
}
})
function toggleTag(tag: string) {
const index = selectedTags.value.indexOf(tag)
if (index >= 0) {
selectedTags.value.splice(index, 1)
} else {
selectedTags.value.push(tag)
}
}
async function submitResult(approved: boolean) {
if (caseId.value == '' || feedback.value == '') {
uni.showToast({ title: '请填写反馈说明', icon: 'none' })
return
}
const result = await submitConsumerAcceptance(caseId.value, approved, rating.value, feedback.value, selectedTags.value)
if (result != null) {
uni.showToast({ title: approved ? '已完成验收' : '已退回整改', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.info,
.label,
.rating-item,
.tag-item,
.empty-text {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.block {
margin-top: 24rpx;
}
.rating-row,
.tag-row,
.action-row {
margin-top: 16rpx;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
}
.rating-item,
.tag-item {
padding: 16rpx 20rpx;
border-radius: 18rpx;
background: #eef2f6;
}
.rating-active,
.tag-active {
background: #e8f2ff;
color: #1d4ed8;
}
.textarea {
margin-top: 16rpx;
width: 100%;
height: 240rpx;
padding: 24rpx;
box-sizing: border-box;
background: #f8fbfc;
border-radius: 20rpx;
font-size: 28rpx;
color: #23384d;
}
.action {
flex: 1;
padding: 24rpx 0;
border-radius: 18rpx;
text-align: center;
font-size: 28rpx;
font-weight: 700;
}
.ghost {
background: #fff4e5;
color: #b45309;
}
.primary {
background: #1d4ed8;
color: #ffffff;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<scroll-view class="page" scroll-y="true">
<view class="hero-card">
<text class="hero-title">居家上门服务</text>
<text class="hero-desc">覆盖服务申请、上门评估、执行跟踪与验收反馈,先用 mock 数据跑通前端闭环。</text>
<view class="hero-actions">
<view class="primary-btn" @click="goApply">立即申请</view>
</view>
</view>
<ServicePanel title="推荐服务" subtitle="适老化信息更清晰,入口更聚焦。">
<view v-for="item in services" :key="item.id" class="service-card">
<view class="service-top">
<view>
<text class="service-name">{{ item.name }}</text>
<text class="service-meta">{{ item.category }} · {{ item.durationText }}</text>
</view>
<text class="service-price">¥{{ item.price }}</text>
</view>
<text class="service-summary">{{ item.summary }}</text>
<text class="service-suitable">适用对象:{{ item.suitableFor }}</text>
</view>
</ServicePanel>
<ServicePanel title="我的服务单" subtitle="先展示待派单和服务中的 mock 单据。">
<view v-if="cases.length == 0" class="empty-box">
<text class="empty-text">当前没有服务单</text>
</view>
<view v-for="item in cases" :key="item.id" class="case-card" @click="goDetail(item.id)">
<view class="case-row">
<view>
<text class="case-title">{{ item.serviceName }}</text>
<text class="case-no">{{ item.caseNo }}</text>
</view>
<ServiceStatusTag :text="item.statusText" :tone="item.statusTone"></ServiceStatusTag>
</view>
<text class="case-info">服务对象:{{ item.elderName }}{{ item.age }} 岁</text>
<text class="case-info">上门时间:{{ item.serviceTime }}</text>
<text class="case-info">服务地址:{{ item.address }}</text>
</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import ServiceStatusTag from '@/components/homeService/ServiceStatusTag.uvue'
import { fetchConsumerHomeServiceCases, fetchHomeServiceCatalog } from '@/services/homeServiceService.uts'
import { HomeServiceCatalogType, HomeServiceCaseType } from '@/types/home-service.uts'
const services = ref<Array<HomeServiceCatalogType>>([])
const cases = ref<Array<HomeServiceCaseType>>([])
async function loadData() {
services.value = await fetchHomeServiceCatalog()
cases.value = await fetchConsumerHomeServiceCases()
}
function goApply() {
uni.navigateTo({ url: '/pages/mall/consumer/home-service/apply' })
}
function goDetail(caseId: string) {
uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + caseId })
}
onLoad(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.hero-card {
background: linear-gradient(135deg, #0f766e, #1d4ed8);
border-radius: 28rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.hero-title {
font-size: 40rpx;
font-weight: 700;
color: #ffffff;
line-height: 56rpx;
}
.hero-desc {
margin-top: 16rpx;
font-size: 28rpx;
line-height: 40rpx;
color: rgba(255, 255, 255, 0.9);
}
.hero-actions {
margin-top: 28rpx;
}
.primary-btn {
background: #ffffff;
color: #0f3d66;
font-size: 30rpx;
font-weight: 700;
text-align: center;
padding: 24rpx 0;
border-radius: 18rpx;
}
.service-card,
.case-card {
padding: 24rpx;
border-radius: 20rpx;
background: #f8fbfc;
margin-bottom: 20rpx;
}
.service-top,
.case-row {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.service-name,
.case-title {
font-size: 32rpx;
font-weight: 700;
color: #16324f;
}
.service-meta,
.case-no,
.case-info,
.service-summary,
.service-suitable,
.empty-text {
margin-top: 10rpx;
font-size: 26rpx;
line-height: 38rpx;
color: #66788a;
}
.service-price {
font-size: 34rpx;
font-weight: 700;
color: #0f766e;
}
.empty-box {
padding: 40rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到对应服务单</text>
</view>
<view v-else>
<ServicePanel title="服务进度" subtitle="申请到执行的关键状态先以 mock 流程展示。">
<ServiceInfoCard
:title="detail.serviceName"
:code="detail.caseNo"
:status-text="detail.statusText"
:status-tone="detail.statusTone"
:items="[
{ label: '上门时间', value: detail.serviceTime },
{ label: '当前进度', value: '第 ' + detail.currentStep + ' / ' + detail.totalSteps + ' 步' },
{ label: '执行人员', value: detail.staffName + ' ' + detail.staffPhone }
]"
></ServiceInfoCard>
</ServicePanel>
<ServicePanel title="服务对象信息">
<ServiceInfoList
:items="[
{ label: '申请人:', value: detail.applicantName },
{ label: '服务对象:', value: detail.elderName + '' + detail.age + ' 岁' },
{ label: '联系电话:', value: detail.phone },
{ label: '服务地址:', value: detail.address },
{ label: '需求说明:', value: detail.summary }
]"
></ServiceInfoList>
<view v-if="detail.status == 'pending_acceptance'" class="feedback-btn" @click="goFeedback">去验收反馈</view>
</ServicePanel>
<ServicePanel title="过程留痕" subtitle="后续可替换为真实时间线与上传凭证。">
<ServiceTimeline :items="detail.timeline"></ServiceTimeline>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import ServiceInfoCard from '@/components/homeService/ServiceInfoCard.uvue'
import ServiceInfoList from '@/components/homeService/ServiceInfoList.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import ServiceTimeline from '@/components/homeService/ServiceTimeline.uvue'
import { fetchConsumerHomeServiceCaseDetail } from '@/services/homeServiceService.uts'
import { HomeServiceCaseType } from '@/types/home-service.uts'
const caseId = ref('')
const detail = ref<HomeServiceCaseType | null>(null)
async function loadData() {
if (caseId.value == '') {
return
}
detail.value = await fetchConsumerHomeServiceCaseDetail(caseId.value)
}
function goFeedback() {
if (caseId.value == '') {
return
}
uni.navigateTo({ url: '/pages/mall/consumer/home-service/feedback?id=' + caseId.value })
}
onLoad((options) => {
const id = options['id']
if (id != null) {
caseId.value = id as string
loadData()
}
})
onShow(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.empty-text {
margin-top: 10rpx;
font-size: 26rpx;
line-height: 38rpx;
color: #66788a;
}
.feedback-btn {
margin-top: 24rpx;
padding: 24rpx 0;
text-align: center;
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
background: #1d4ed8;
border-radius: 18rpx;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,299 @@
<template>
<view class="logistics-page">
<view class="logistics-header">
<view class="product-info">
<image class="product-image" :src="productImage" mode="aspectFill"></image>
<view class="info-right">
<text class="status-text">{{ logisticsStatus }}</text>
<text class="courier-name">{{ courierName }}: {{ trackingNo }}</text>
<text class="phone-text">官方电话: {{ courierPhone }}</text>
</view>
</view>
</view>
<view class="logistics-body">
<view class="track-list">
<view
v-for="(item, index) in trackList"
:key="index"
class="track-item"
:class="{ first: index === 0 }"
>
<view class="node-icon">
<view class="dot"></view>
<view class="line" v-if="index !== trackList.length - 1"></view>
</view>
<view class="node-content">
<text class="track-desc">{{ item.desc }}</text>
<text class="track-time">{{ item.time }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
const orderId = ref('')
const productImage = ref('/static/product1.jpg')
const logisticsStatus = ref('暂无物流信息')
const courierName = ref('')
const courierPhone = ref('')
const trackingNo = ref('')
type TrackItem = {
desc: string,
time: string
}
const trackList = ref<TrackItem[]>([])
// 加载物流信息函数 - 必须在 onLoad 之前定义
const loadLogisticsInfo = async () => {
if (orderId.value == '') return
try {
console.log('[logistics] 开始加载物流信息, orderId:', orderId.value)
const order = await supabaseService.getOrderDetail(orderId.value)
console.log('[logistics] 获取订单结果:', order != null ? '成功' : '失败')
if (order != null) {
const orderStr = JSON.stringify(order)
console.log('[logistics] 订单JSON:', orderStr)
const orderParsed = JSON.parse(orderStr)
if (orderParsed == null) {
console.error('[logistics] 解析订单数据失败')
return
}
const orderObj = orderParsed as UTSJSONObject
// 获取物流信息
const trackingNoVal = orderObj.getString('tracking_no')
const carrierNameVal = orderObj.getString('carrier_name')
const shippingStatus = orderObj.getNumber('shipping_status')
console.log('[logistics] tracking_no:', trackingNoVal)
console.log('[logistics] carrier_name:', carrierNameVal)
console.log('[logistics] shipping_status:', shippingStatus)
if (trackingNoVal != null && trackingNoVal != '') {
trackingNo.value = trackingNoVal
} else {
console.log('[logistics] 物流单号为空,订单可能未发货')
// 物流单号为空时显示提示
trackingNo.value = '暂无物流单号'
logisticsStatus.value = '商家未填写物流信息'
}
if (carrierNameVal != null && carrierNameVal != '') {
courierName.value = carrierNameVal
// 根据快递公司设置电话
if (carrierNameVal.includes('顺丰')) {
courierPhone.value = '95338'
} else if (carrierNameVal.includes('中通')) {
courierPhone.value = '95311'
} else if (carrierNameVal.includes('圆通')) {
courierPhone.value = '95554'
} else if (carrierNameVal.includes('韵达')) {
courierPhone.value = '95546'
} else if (carrierNameVal.includes('申通')) {
courierPhone.value = '95543'
} else {
courierPhone.value = ''
}
}
// 根据发货状态设置物流状态
if (shippingStatus == 2) {
logisticsStatus.value = '已签收'
} else if (shippingStatus == 1) {
logisticsStatus.value = '运输中'
} else {
logisticsStatus.value = '待发货'
}
// 获取商品图片
const itemsRaw = orderObj.get('ml_order_items')
if (itemsRaw != null && Array.isArray(itemsRaw)) {
const items = itemsRaw as any[]
if (items.length > 0) {
const firstItem = items[0]
const itemStr = JSON.stringify(firstItem)
const itemParsed = JSON.parse(itemStr)
if (itemParsed != null) {
const itemObj = itemParsed as UTSJSONObject
const imgUrl = itemObj.getString('image_url')
if (imgUrl != null && imgUrl != '') {
productImage.value = imgUrl
}
}
}
}
// 构建物流轨迹(如果有发货时间)
const shippedAt = orderObj.getString('shipped_at')
if (shippedAt != null && shippedAt != '') {
const trackItem: TrackItem = {
desc: '商家已发货,等待快递揽收',
time: shippedAt
}
trackList.value.push(trackItem)
}
// 如果已签收,添加签收信息
const deliveredAt = orderObj.getString('delivered_at')
if (deliveredAt != null && deliveredAt != '') {
const trackItem: TrackItem = {
desc: '快件已签收',
time: deliveredAt
}
trackList.value.unshift(trackItem)
logisticsStatus.value = '已签收'
}
}
} catch (e) {
console.error('加载物流信息失败:', e)
}
}
onLoad((options) => {
if (options == null) return
const orderIdValue = options['orderId']
if (orderIdValue != null) {
orderId.value = orderIdValue as string
loadLogisticsInfo()
}
})
onMounted(() => {
// 逻辑已移到 onLoad
})
</script>
<style scoped>
.logistics-page {
/* min-height: 100vh; */
flex: 1;
background-color: #f5f5f5;
padding-bottom: 20px;
}
.logistics-header {
background-color: #fff;
padding: 15px;
margin-bottom: 10px;
}
.product-info {
display: flex;
align-items: center;
}
.product-image {
width: 60px;
height: 60px;
border-radius: 4px;
margin-right: 15px;
background-color: #eee;
}
.info-right {
display: flex;
flex-direction: column;
}
.status-text {
font-size: 16px;
color: #ff5000;
font-weight: bold;
margin-bottom: 5px;
}
.courier-name {
font-size: 14px;
color: #333;
margin-bottom: 2px;
}
.phone-text {
font-size: 12px;
color: #999;
}
.logistics-body {
background-color: #fff;
padding: 20px 15px;
}
.track-list {
display: flex;
flex-direction: column;
}
.track-item {
display: flex;
position: relative;
padding-bottom: 25px;
}
.track-item:last-child {
padding-bottom: 0;
}
.node-icon {
width: 20px;
display: flex;
flex-direction: column;
align-items: center;
margin-right: 15px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 4px;
}
.first .dot {
background-color: #ff5000;
width: 12px;
height: 12px;
margin-top: 4px;
box-shadow: 0 0 0 4px rgba(255, 80, 0, 0.2);
}
.line {
width: 1px;
background-color: #eee;
flex: 1;
margin-top: 2px;
}
.node-content {
flex: 1;
display: flex;
flex-direction: column;
}
.track-desc {
font-size: 14px;
color: #333;
line-height: 1.5;
margin-bottom: 5px;
}
.first .track-desc {
color: #ff5000;
font-weight: bold;
}
.track-time {
font-size: 12px;
color: #999;
}
</style>

View File

@@ -0,0 +1,651 @@
<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 != null && level.description != '' ? 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 != null && 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()
const info: MemberInfo = {
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) {
let nextLevelObj: UTSJSONObject | null = null
if (nextLevelRaw instanceof UTSJSONObject) {
nextLevelObj = nextLevelRaw
} else {
nextLevelObj = JSON.parse(JSON.stringify(nextLevelRaw)) as UTSJSONObject
}
const nextLevel: MemberLevel = {
id: nextLevelObj.getNumber('id') ?? 0,
name: nextLevelObj.getString('name') ?? '',
min_amount: nextLevelObj.getNumber('min_amount') ?? 0,
discount: 1.0,
description: null
}
info.next_level = nextLevel
}
memberInfo.value = info
} 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]
let itemObj: UTSJSONObject | null = null
if (item instanceof UTSJSONObject) {
itemObj = item
} else {
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
}
parsed.push({
id: itemObj.getNumber('id') ?? 0,
name: itemObj.getString('name') ?? '',
min_amount: itemObj.getNumber('min_amount') ?? 0,
discount: itemObj.getNumber('discount') ?? 1.0,
description: itemObj.getString('description')
})
}
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]
let itemObj: UTSJSONObject | null = null
if (item instanceof UTSJSONObject) {
itemObj = item
} else {
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
}
parsed.push({
id: itemObj.getString('id') ?? '',
old_level: itemObj.getNumber('old_level') ?? 0,
new_level: itemObj.getNumber('new_level') ?? 0,
reason: itemObj.getString('reason'),
created_at: itemObj.getString('created_at') ?? ''
})
}
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,271 @@
<template>
<scroll-view class="message-detail-page" scroll-y>
<view class="message-header">
<text class="message-title">{{ message.title }}</text>
<text class="message-time">{{ formatTime(message.created_at) }}</text>
</view>
<view class="message-content">
<text class="content-text">{{ message.content }}</text>
</view>
<view v-if="message.link_url" class="message-action" @click="goToLink">
<text class="action-text">查看详情</text>
<text class="action-arrow"></text>
</view>
<view v-if="message.icon_url" class="message-image">
<image :src="message.icon_url" mode="widthFix" class="icon-image" />
</view>
<view v-if="extraInfo.length > 0" class="extra-info">
<view v-for="(item, index) in extraInfo" :key="index" class="extra-item">
<text class="extra-label">{{ item.label }}</text>
<text class="extra-value">{{ item.value }}</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
type MessageType = {
id: string
type: string
title: string
content: string
icon_url: string | null
link_url: string | null
extra_data: any | null
created_at: string
}
type ExtraInfoItem = {
label: string
value: string
}
const message = ref<MessageType>({
id: '',
type: '',
title: '',
content: '',
icon_url: null,
link_url: null,
extra_data: null,
created_at: ''
})
const extraInfo = ref<ExtraInfoItem[]>([])
const formatLabel = (key: string): string => {
if (key === 'share_code') return '分享码'
if (key === 'product_name') return '商品名称'
if (key === 'reward_amount') return '奖励金额'
if (key === 'order_no') return '订单号'
if (key === 'buyer_name') return '购买者'
if (key === 'quantity') return '数量'
return key
}
const parseExtraData = (data: any) => {
extraInfo.value = []
if (data == null) return
try {
let dataObj: UTSJSONObject | null = null
if (typeof data === 'string') {
const parsed = JSON.parse(data as string)
if (parsed != null) {
dataObj = parsed as UTSJSONObject
}
} else if (data instanceof UTSJSONObject) {
dataObj = data
} else {
dataObj = JSON.parse(JSON.stringify(data)) as UTSJSONObject
}
if (dataObj != null) {
const keys = UTSJSONObject.keys(dataObj)
for (let i = 0; i < keys.length; i++) {
const key = keys[i] as string
const value = dataObj.get(key)
if (value != null) {
const item: ExtraInfoItem = {
label: formatLabel(key),
value: `${value}`
}
extraInfo.value.push(item)
}
}
}
} catch (e) {
console.error('解析extra_data失败:', e)
}
}
const loadMessage = async (id: string) => {
try {
const notifications = await supabaseService.getUserNotifications(null)
const found = notifications.find(n => n.id === id)
if (found != null) {
const extraData = found.extra_data
const msg: MessageType = {
id: found.id,
type: found.type,
title: found.title,
content: found.content,
icon_url: found.icon_url,
link_url: found.link_url,
extra_data: extraData,
created_at: found.created_at ?? ''
}
message.value = msg
if (extraData != null) {
parseExtraData(extraData)
}
}
} catch (e) {
console.error('加载消息失败:', e)
}
}
const formatTime = (timeStr: string): 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}`
}
const goToLink = () => {
const url = message.value.link_url
if (url != null && url !== '') {
if (url.startsWith('/pages/')) {
uni.navigateTo({ url: url })
} else {
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' })
}
})
}
}
}
onLoad((options) => {
if (options != null) {
const idVal = options['id']
if (idVal != null) {
loadMessage(idVal as string)
}
}
})
</script>
<style>
.message-detail-page {
flex: 1;
height: 100%;
background-color: #f5f5f5;
}
.message-header {
background-color: white;
padding: 20px 16px;
margin-bottom: 8px;
}
.message-title {
font-size: 18px;
font-weight: bold;
color: #333;
display: flex;
margin-bottom: 10px;
}
.message-time {
font-size: 13px;
color: #999;
}
.message-content {
background-color: white;
padding: 16px;
margin-bottom: 8px;
}
.content-text {
font-size: 15px;
color: #333;
line-height: 1.8;
}
.message-action {
background-color: white;
padding: 16px;
margin-bottom: 8px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.action-text {
font-size: 15px;
color: #ff6b35;
}
.action-arrow {
font-size: 18px;
color: #ccc;
}
.message-image {
background-color: white;
padding: 16px;
margin-bottom: 8px;
}
.icon-image {
width: 100%;
border-radius: 8px;
}
.extra-info {
background-color: white;
padding: 16px;
}
.extra-item {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #f5f5f5;
}
.extra-item:last-child {
border-bottom: none;
}
.extra-label {
font-size: 14px;
color: #666;
}
.extra-value {
font-size: 14px;
color: #333;
}
</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.length > 0 ? 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.length > 0 ? 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(4)
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 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 confirmDelete = (review: MyReviewItem): void => {
uni.showModal({
title: '提示',
content: '确定要删除这条评价吗?',
success: (res) => {
if (res.confirm) {
doDelete(review)
}
}
})
}
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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
<template>
<view class="payment-success-page">
<view class="success-content">
<view class="icon-wrapper">
<text class="success-icon">✓</text>
</view>
<text class="success-title">支付成功</text>
<text class="success-desc">您的订单已支付成功,我们将尽快为您发货</text>
<view class="order-info" v-if="orderId">
<text class="info-text">订单编号:{{ orderNo }}</text>
<text class="info-text">支付金额:¥{{ amount.toFixed(2) }}</text>
</view>
<view class="action-buttons">
<button class="btn primary-btn" @click="viewOrder">查看订单</button>
<button class="btn secondary-btn" @click="goHome">返回首页</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
const orderId = ref('')
const orderNo = ref('')
const amount = ref(0)
// 定义 loadOrderInfo 函数(必须在 onMounted 之前)
const loadOrderInfo = async () => {
try {
const response = await supabaseService.getOrderById(orderId.value)
console.log('[payment-success] getOrderById response:', JSON.stringify(response))
if (response != null) {
const orderData = response as UTSJSONObject
const totalAmount = orderData.getNumber('total_amount')
const paidAmount = orderData.getNumber('paid_amount')
console.log('[payment-success] total_amount:', totalAmount, 'paid_amount:', paidAmount)
if (paidAmount != null && paidAmount > 0) {
amount.value = paidAmount
} else if (totalAmount != null && totalAmount > 0) {
amount.value = totalAmount
}
const orderNoVal = orderData.getString('order_no')
if (orderNoVal != null && orderNoVal != '') {
orderNo.value = orderNoVal
}
}
} catch (err) {
console.error('[payment-success] 加载订单信息失败:', err)
}
}
onLoad((options) => {
if (options == null) return
const orderIdValue = options['orderId']
if (orderIdValue != null) {
orderId.value = orderIdValue as string
orderNo.value = orderIdValue as string
const amountValue = options['amount']
if (amountValue != null) {
const amountStr = amountValue.toString()
console.log('[payment-success] amountStr:', amountStr)
const parsed = parseFloat(amountStr)
console.log('[payment-success] parsed:', parsed)
if (isNaN(parsed) == false) {
amount.value = parsed
}
}
if (amount.value == 0) {
console.log('[payment-success] amount为0尝试从数据库查询')
}
loadOrderInfo()
}
})
onMounted(() => {
// 逻辑已移到 onLoad
})
const viewOrder = () => {
uni.navigateTo({
url: '/pages/mall/consumer/orders'
})
}
const goHome = () => {
uni.switchTab({
url: '/pages/main/index'
})
}
</script>
<style scoped>
.payment-success-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* height: 100vh; */
flex: 1;
background-color: #ffffff;
padding: 0 30px;
}
.success-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.icon-wrapper {
width: 80px;
height: 80px;
border-radius: 40px;
background-color: #4cd964;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow: 0 4px 10px rgba(76, 217, 100, 0.3);
}
.success-icon {
font-size: 40px;
color: #ffffff;
font-weight: bold;
}
.success-title {
font-size: 24px;
font-weight: bold;
color: #333333;
margin-bottom: 10px;
}
.success-desc {
font-size: 14px;
color: #999999;
text-align: center;
margin-bottom: 30px;
line-height: 1.5;
}
.order-info {
background-color: #f9f9f9;
padding: 15px 20px;
border-radius: 8px;
width: 100%;
margin-bottom: 30px;
display: flex;
flex-direction: column;
align-items: center;
}
.info-text {
font-size: 14px;
color: #666666;
margin-bottom: 5px;
}
.action-buttons {
width: 100%;
display: flex;
flex-direction: column;
/* gap: 15px; */
}
.btn {
width: 100%;
height: 45px;
line-height: 45px;
text-align: center;
border-radius: 22.5px;
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.primary-btn {
background-color: #007aff;
color: #ffffff;
border: none;
}
.secondary-btn {
background-color: #ffffff;
color: #666666;
border: 1px solid #cccccc;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
<template>
<scroll-view class="records-page" direction="vertical">
<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'
// 转换为 UTSJSONObject
let recordObj: UTSJSONObject | null = null
if (recordData instanceof UTSJSONObject) {
recordObj = recordData
} else {
recordObj = JSON.parse(JSON.stringify(recordData)) as UTSJSONObject
}
id = recordObj.getString('id') ?? ''
quantity = recordObj.getNumber('quantity') ?? 1
points_used = recordObj.getNumber('points_used') ?? 0
status = recordObj.getNumber('status') ?? 0
tracking_no = recordObj.getString('tracking_no')
created_at = recordObj.getString('created_at') ?? ''
// 获取关联的商品信息
const product = recordObj.get('product')
if (product != null) {
let productObj: UTSJSONObject | null = null
if (product instanceof UTSJSONObject) {
productObj = product
} else {
productObj = JSON.parse(JSON.stringify(product)) as UTSJSONObject
}
product_name = productObj.getString('name') ?? ''
product_image = productObj.getString('image_url')
product_type = productObj.getString('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,727 @@
<template>
<scroll-view class="exchange-page" direction="vertical">
<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 != null && product.image_url.length > 0 ? 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 != null && selectedProduct.image_url.length > 0 ? 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]
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
let itemObj: UTSJSONObject | null = null
if (item instanceof UTSJSONObject) {
itemObj = item
} else {
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
}
id = itemObj.getString('id') ?? ''
name = itemObj.getString('name') ?? ''
description = itemObj.getString('description')
image_url = itemObj.getString('image_url')
product_type = itemObj.getString('product_type') ?? 'coupon'
points_required = itemObj.getNumber('points_required') ?? 0
original_price = itemObj.getNumber('original_price')
stock = itemObj.getNumber('stock') ?? 0
status = itemObj.getNumber('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: 48%;
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: center;
}
.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;
}
.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: center;
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: 30px;
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

@@ -0,0 +1,654 @@
<template>
<scroll-view class="points-page" direction="vertical">
<view class="points-header">
<view class="points-info">
<text class="points-label">当前积分</text>
<text class="points-value">{{ totalPoints }}</text>
</view>
<view class="points-actions">
<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>
<view v-if="loading" class="loading-state">
<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 v-for="item in records" :key="item.id" class="record-item">
<view class="record-left">
<text class="record-title">{{ item.description ?? getTypeText(item.type) }}</text>
<text class="record-time">{{ formatTime(item.created_at) }}</text>
</view>
<view class="record-right">
<text class="record-amount" :class="{ positive: item.points > 0, negative: item.points < 0 }">
{{ item.points > 0 ? '+' : '' }}{{ item.points }}
</text>
</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">
import { ref, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type PointRecord = {
id: string
user_id: string
points: number
type: string
description: string
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 {
const points = await supabaseService.getUserPoints()
totalPoints.value = points
} catch (e) {
console.error('获取积分失败', e)
}
}
const loadRecords = async (): Promise<void> => {
try {
const list = await supabaseService.getPointRecords()
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 Promise.all([
loadPoints(),
loadRecords(),
loadSigninStatus(),
loadExpiringPoints()
])
loading.value = false
}
onMounted(() => {
loadData()
})
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 => {
if (type == 'signin') {
return '每日签到'
} else if (type == 'shopping') {
return '购物奖励'
} else if (type == 'redeem') {
return '积分兑换'
} else if (type == 'admin') {
return '系统调整'
} else if (type == 'register') {
return '注册赠送'
} else if (type == 'expire') {
return '积分过期'
} else {
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 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: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
padding: 30px 20px;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.points-info {
display: flex;
flex-direction: column;
}
.points-label {
font-size: 14px;
opacity: 0.9;
margin-bottom: 8px;
}
.points-value {
font-size: 36px;
font-weight: bold;
}
.exchange-btn {
background-color: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.4);
font-size: 14px;
border-radius: 20px;
padding: 0 15px;
height: 32px;
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: 16px;
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;
padding: 0 16px;
min-height: 300px;
}
.section-title {
font-size: 16px;
font-weight: bold;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
display: flex;
}
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f9f9f9;
}
.record-left {
display: flex;
flex-direction: column;
}
.record-title {
margin-bottom: 4px;
font-size: 15px;
color: #333;
}
.record-time {
font-size: 12px;
color: #999;
}
.record-amount {
font-size: 16px;
font-weight: bold;
color: #333;
}
.record-amount.positive {
color: #ff6b35;
}
.record-amount.negative {
color: #333;
}
.empty-state {
padding: 40px 0;
display: flex;
justify-content: center;
}
.empty-text {
color: #999;
font-size: 14px;
}
.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: 400px;
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%;
height: 45px;
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: 8px;
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>

File diff suppressed because it is too large Load Diff

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.length > 0 ? 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 class="more-images-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 != null ? 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 { onLoad } from '@dcloudio/uni-app'
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)
}
}
const statsData: StatsType = {
total_count: result.getNumber('total_count') ?? 0,
avg_rating: result.getNumber('avg_rating') ?? 0,
good_rate: result.getNumber('good_rate') ?? 0,
rating_distribution: distMap
}
stats.value = statsData
} catch (e) {
console.error('加载统计失败:', e)
}
}
const loadReviews = async (pageNum: number): 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}`
}
}
onLoad((options) => {
if (options != null) {
const idVal = options['product_id']
if (idVal != null) {
productId.value = idVal 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,250 @@
<template>
<view class="red-packets-page">
<view class="tab-header" style="position: fixed; top: 0; left: 0; right: 0; z-index: 10;">
<text
class="tab-item"
:class="{ active: currentTab === 0 }"
@click="currentTab = 0">未使用</text>
<text
class="tab-item"
:class="{ active: currentTab === 1 }"
@click="currentTab = 1">已使用/过期</text>
</view>
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<scroll-view v-else class="packet-list" direction="vertical">
<view v-if="filteredPackets.length === 0" class="empty-state">
<text class="empty-text">暂无相关红包</text>
</view>
<view v-else v-for="item in filteredPackets" :key="item.id" class="packet-item" :class="{ disabled: item.status !== 0 }">
<view class="packet-left">
<text class="packet-amount">¥<text class="amount-num">{{ item.amount }}</text></text>
<text class="packet-condition">无门槛</text>
</view>
<view class="packet-right">
<view class="packet-info">
<text class="packet-name">{{ item.name }}</text>
<text class="packet-date">有效期至 {{ formatTime(item.expire_at) }}</text>
</view>
<view class="packet-action">
<button v-if="item.status === 0" class="use-btn" @click="usePacket(item)">立即使用</button>
<text v-else class="status-text">{{ getStatusText(item.status) }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type RedPacket = {
id: string
user_id: string
amount: number
name: string
status: number // 0: unused, 1: used, 2: expired
expire_at: string
created_at: string
}
const loading = ref(true)
const currentTab = ref(0)
const packets = ref<Array<RedPacket>>([])
const filteredPackets = computed((): Array<RedPacket> => {
const result: Array<RedPacket> = []
if (currentTab.value === 0) {
for (let i: number = 0; i < packets.value.length; i++) {
if (packets.value[i].status === 0) {
result.push(packets.value[i])
}
}
} else {
for (let i: number = 0; i < packets.value.length; i++) {
if (packets.value[i].status !== 0) {
result.push(packets.value[i])
}
}
}
return result
})
const loadData = async () => {
loading.value = true
try {
const rawList = await supabaseService.getUserRedPackets()
const mappedList: Array<RedPacket> = []
for (let i: number = 0; i < rawList.length; i++) {
const item = rawList[i] as UTSJSONObject
const packet: RedPacket = {
id: item.getString('id') ?? '',
user_id: '',
amount: item.getNumber('amount') ?? 0,
name: item.getString('name') ?? '',
status: item.getNumber('status') ?? 0,
expire_at: item.getString('expire_at') ?? '',
created_at: item.getString('created_at') ?? ''
} as RedPacket
mappedList.push(packet)
}
packets.value = mappedList
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
const usePacket = (item: RedPacket) => {
uni.switchTab({
url: '/pages/main/index'
})
}
const getStatusText = (status: number): string => {
if (status === 1) return '已使用'
if (status === 2) return '已过期'
return ''
}
const formatTime = (timeStr: string): string => {
if (timeStr == '') return '永久有效'
const date = new Date(timeStr)
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
}
</script>
<style>
.red-packets-page {
background-color: #f5f5f5;
flex: 1;
}
.tab-header {
display: flex;
background-color: #fff;
padding: 10px 0;
/* position: sticky is removed in flavor of inline fixed style */
}
.tab-item {
flex: 1;
text-align: center;
font-size: 14px;
color: #666;
padding-bottom: 8px;
border-bottom: 2px solid transparent;
}
.tab-item.active {
color: #ff5000;
border-bottom-color: #ff5000;
font-weight: bold;
}
.packet-list {
flex: 1;
padding: 15px;
}
.packet-item {
display: flex;
background-color: #fff;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.packet-item.disabled .packet-left,
.packet-item.disabled .packet-name,
.packet-item.disabled .amount-num {
color: #999;
background-color: #f0f0f0;
}
.packet-item.disabled .packet-left {
background-color: #e0e0e0;
}
.packet-left {
width: 100px;
background-color: #fff5f0;
color: #ff5000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15px 0;
}
.packet-amount {
font-size: 14px;
}
.amount-num {
font-size: 28px;
font-weight: bold;
}
.packet-condition {
font-size: 12px;
margin-top: 4px;
}
.packet-right {
flex: 1;
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.packet-info {
display: flex;
flex-direction: column;
}
.packet-name {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.packet-date {
font-size: 12px;
color: #999;
}
.use-btn {
font-size: 12px;
background-color: #ff5000;
color: #fff;
border-radius: 15px;
padding: 4px 12px;
line-height: 1.5;
}
.status-text {
font-size: 14px;
color: #999;
}
.loading-state, .empty-state {
padding: 40px;
align-items: center;
justify-content: center;
display: flex;
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<view class="review-page">
<view class="header">
<text class="title">服务评价</text>
<text class="subtitle">请对本次售后服务进行评价</text>
</view>
<view class="rating-section">
<text class="label">服务评分</text>
<view class="stars">
<text
v-for="i in 5"
:key="i"
class="star"
:class="{ active: i <= rating }"
@click="setRating(i)"
>★</text>
</view>
<text class="rating-text">{{ ratingText }}</text>
</view>
<view class="comment-section">
<textarea
v-model="comment"
class="comment-input"
placeholder="请输入您的评价内容,您的建议是我们改进的动力"
maxlength="200"
></textarea>
<text class="word-count">{{ comment.length }}/200</text>
</view>
<button class="submit-btn" @click="submitReview" :loading="submitting">提交评价</button>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
const rating = ref(5)
const comment = ref('')
const submitting = ref(false)
const ratingText = computed(() => {
const texts = ['非常不满意', '不满意', '一般', '满意', '非常满意']
return texts[rating.value - 1]
})
const setRating = (val: number) => {
rating.value = val
}
const submitReview = () => {
if (submitting.value) return
submitting.value = true
// 模拟提交
setTimeout(() => {
uni.showToast({
title: '评价成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
submitting.value = false
}, 1000)
}
</script>
<style scoped>
.review-page {
flex: 1;
background-color: #ffffff;
padding: 20px;
}
.header {
margin-bottom: 30px;
text-align: center;
}
.title {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.subtitle {
font-size: 14px;
color: #999;
}
.rating-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 30px;
}
.label {
font-size: 16px;
color: #333;
margin-bottom: 15px;
}
.stars {
display: flex;
margin-bottom: 10px;
}
.star {
margin-right: 10px;
font-size: 32px;
color: #ddd;
transition: color 0.2s;
}
.star.active {
color: #ffca28;
}
.rating-text {
font-size: 14px;
color: #666;
}
.comment-section {
position: relative;
margin-bottom: 30px;
}
.comment-input {
width: 100%;
height: 120px;
background-color: #f5f5f5;
border-radius: 8px;
padding: 15px;
font-size: 14px;
box-sizing: border-box;
}
.word-count {
position: absolute;
bottom: 10px;
right: 10px;
font-size: 12px;
color: #999;
}
.submit-btn {
background-color: #007aff;
color: #ffffff;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
height: 50px;
line-height: 50px;
}
</style>

View File

@@ -0,0 +1,876 @@
<!-- 退款页面 -->
<template>
<view class="refund-page">
<!-- 顶部栏 -->
<view class="refund-header">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">退款/售后</text>
</view>
<!-- 标签页 -->
<view class="refund-tabs">
<view :class="['refund-tab', { active: activeTab === 'all' }]" @click="changeTab('all')">
<text class="tab-text">全部</text>
</view>
<view :class="['refund-tab', { active: activeTab === 'processing' }]" @click="changeTab('processing')">
<text class="tab-text">处理中</text>
<text v-if="tabCounts.processing > 0" class="tab-badge">{{ tabCounts.processing }}</text>
</view>
<view :class="['refund-tab', { active: activeTab === 'completed' }]" @click="changeTab('completed')">
<text class="tab-text">已完成</text>
</view>
</view>
<!-- 售后列表 -->
<scroll-view class="refund-content" direction="vertical" @scrolltolower="loadMore">
<!-- 空状态 -->
<view v-if="refunds.length === 0 && !isLoading" class="empty-refunds">
<text class="empty-icon">🔄</text>
<text class="empty-text">暂无售后记录</text>
<text class="empty-subtext">您可以在订单详情中申请售后</text>
<button class="go-orders-btn" @click="goToOrders">查看订单</button>
</view>
<!-- 售后项 -->
<view v-for="refund in refunds" :key="refund.id" class="refund-item">
<view class="refund-header">
<text class="refund-no">售后单号: {{ refund.refund_no }}</text>
<text :class="['refund-status', getStatusClass(refund.status)]">
{{ getStatusText(refund.status) }}
</text>
</view>
<view class="order-info">
<text class="order-no">订单号: {{ refund.order?.order_no }}</text>
<text class="order-time">{{ formatTime(refund.order?.created_at) }}</text>
</view>
<view class="product-info" @click="viewOrder(refund.order_id)">
<image class="product-image" :src="getProductImage(refund)" />
<view class="product-details">
<text class="product-name">{{ getProductName(refund) }}</text>
<text v-if="refund.refund_reason" class="refund-reason">原因: {{ refund.refund_reason }}</text>
<view class="refund-amount">
<text class="amount-label">退款金额:</text>
<text class="amount-value">¥{{ refund.refund_amount }}</text>
</view>
</view>
</view>
<!-- 进度时间线 -->
<view v-if="refund.status_history != null && refund.status_history.length > 0" class="timeline">
<view v-for="(step, index) in getTimelineSteps(refund)"
:key="index"
class="timeline-step">
<view class="step-dot" :class="{ active: step.active, completed: step.completed }"></view>
<view class="step-info">
<text class="step-title">{{ step.title }}</text>
<text class="step-time">{{ step.time }}</text>
<text v-if="step.desc" class="step-desc">{{ step.desc }}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view v-if="refund.status === 1" class="refund-actions">
<button class="action-btn cancel" @click="cancelRefund(refund)">取消申请</button>
<button class="action-btn contact" @click="contactService(refund)">联系客服</button>
</view>
<view v-if="refund.status === 3" class="refund-actions">
<button class="action-btn review" @click="reviewRefund(refund)">评价服务</button>
<button class="action-btn delete" @click="deleteRefund(refund)">删除记录</button>
</view>
</view>
<!-- 加载更多 -->
<view v-if="isLoading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!hasMore && refunds.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 申请售后按钮 -->
<view class="apply-btn-container">
<button class="apply-btn" @click="applyRefund">申请售后</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, watch } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type RefundStatusHistoryItem = {
status: number
remark: string
created_at: string
}
type RefundProductInfo = {
images: string[]
}
type RefundOrderItem = {
id: string
product_name: string
sku_specifications: any | null
price: number
quantity: number
product?: RefundProductInfo
}
type RefundOrderInfo = {
id: string
order_no: string
created_at: string
order_items: RefundOrderItem[]
}
type RefundType = {
id: string
user_id: string
order_id: string
refund_no: string
refund_type: number // 1:仅退款 2:退货退款
refund_reason: string
refund_amount: number
status: number // 1:待处理 2:处理中 3:已完成 4:已取消 5:已拒绝
status_history: RefundStatusHistoryItem[] | null
created_at: string
order?: RefundOrderInfo
}
type TabCountsType = {
processing: number
}
const activeTab = ref<string>('all')
const refunds = ref<Array<RefundType>>([])
const tabCounts = ref<TabCountsType>({
processing: 0
})
const isLoading = ref<boolean>(false)
const currentPage = ref<number>(1)
const pageSize = ref<number>(15)
const hasMore = ref<boolean>(true)
const getCurrentUserId = (): string => {
return supabaseService.getCurrentUserId() ?? ''
}
const resetData = () => {
refunds.value = []
currentPage.value = 1
hasMore.value = true
}
const loadRefunds = async (loadMore: boolean): Promise<void> => {
if (isLoading.value || (!hasMore.value && loadMore)) {
return
}
isLoading.value = true
try {
const userId = getCurrentUserId()
if (userId == '') {
uni.navigateTo({
url: '/pages/user/login'
})
return
}
const page = loadMore ? currentPage.value + 1 : 1
let statusList: number[] = []
if (activeTab.value === 'processing') {
statusList = [1, 2]
} else if (activeTab.value === 'completed') {
statusList = [3, 4, 5]
}
const rawData = await supabaseService.getRefunds(statusList, page, pageSize.value)
const newRefunds: Array<RefundType> = []
for (let i: number = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const orderObjRaw = item.get('order')
const orderObj = (orderObjRaw != null) ? (orderObjRaw as UTSJSONObject) : (new UTSJSONObject())
const dbItemsRaw = orderObj.get('ml_order_items')
const dbItems = (dbItemsRaw != null) ? (dbItemsRaw as any[]) : []
const uiItems: Array<RefundOrderItem> = []
for (let j: number = 0; j < dbItems.length; j++) {
const di = dbItems[j] as UTSJSONObject
const imgRaw = di.get('image_url')
const imgUrl = (imgRaw != null) ? (imgRaw as string) : '/static/default-product.png'
const productInfo: RefundProductInfo = {
images: [imgUrl]
} as RefundProductInfo
const specRaw = di.get('specifications')
const specifications = (specRaw != null) ? (specRaw as any) : null
const orderItem: RefundOrderItem = {
id: di.getString('id') ?? '',
product_name: di.getString('product_name') ?? '',
sku_specifications: specifications,
price: 0,
quantity: di.getNumber('quantity') ?? 1,
product: productInfo
} as RefundOrderItem
uiItems.push(orderItem)
}
const statusHistoryRaw = item.get('status_history')
const statusHistory = (statusHistoryRaw != null) ? (statusHistoryRaw as RefundStatusHistoryItem[]) : []
const refundItem: RefundType = {
id: item.getString('id') ?? '',
user_id: item.getString('user_id') ?? '',
order_id: item.getString('order_id') ?? '',
refund_no: item.getString('refund_no') ?? '',
refund_type: item.getNumber('refund_type') ?? 1,
refund_reason: item.getString('refund_reason') ?? '',
refund_amount: item.getNumber('refund_amount') ?? 0,
status: item.getNumber('status') ?? 1,
status_history: statusHistory,
created_at: item.getString('created_at') ?? '',
order: {
id: item.getString('order_id') ?? '',
order_no: orderObj.getString('order_no') ?? '',
created_at: orderObj.getString('created_at') ?? '',
order_items: uiItems
} as RefundOrderInfo
} as RefundType
newRefunds.push(refundItem)
}
if (loadMore) {
refunds.value.push(...newRefunds)
currentPage.value = page
} else {
refunds.value = newRefunds
currentPage.value = 1
}
hasMore.value = newRefunds.length === pageSize.value
} catch (err) {
console.error('加载售后记录异常:', err)
} finally {
isLoading.value = false
}
}
const loadTabCounts = async () => {
const userId = getCurrentUserId()
if (userId == '') return
try {
const processingRefunds = await supabaseService.getRefunds([1, 2], 1, 100)
tabCounts.value.processing = processingRefunds.length
} catch (err) {
console.error('加载计数异常:', err)
}
}
watch(activeTab, () => {
resetData()
loadRefunds(false)
})
onMounted(() => {
loadRefunds(false)
loadTabCounts()
})
const getStatusText = (status: number): string => {
if (status === 1) return '待处理'
if (status === 2) return '处理中'
if (status === 3) return '已完成'
if (status === 4) return '已取消'
if (status === 5) return '已拒绝'
return '未知状态'
}
const getStatusClass = (status: number): string => {
if (status === 1) return 'status-pending'
if (status === 2) return 'status-processing'
if (status === 3) return 'status-completed'
if (status === 4) return 'status-cancelled'
if (status === 5) return 'status-rejected'
return 'status-unknown'
}
// 获取商品图片
const getProductImage = (refund: RefundType): string => {
const firstItem = refund.order?.order_items?.[0]
if (firstItem?.product?.images == null || firstItem?.product?.images.length == 0) {
return '/static/default-product.png'
}
return firstItem.product!.images[0]
}
// 获取商品名称
const getProductName = (refund: RefundType): string => {
const items = refund.order?.order_items ?? []
if (items.length === 0) return '未知商品'
if (items.length === 1) {
return items[0].product_name
} else {
return `${items[0].product_name}等${items.length}件商品`
}
}
// 格式化时间
const formatTime = (timeStr?: string): string => {
if (timeStr == null || timeStr == '') return ''
const date = new Date(timeStr)
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${month}-${day}`
}
const getCurrentStepIndex = (status: number): number => {
if (status === 1) return 0
if (status === 2) return 1
if (status === 3) return 2
if (status === 4) return 0
if (status === 5) return 1
return 0
}
type TimelineStepType = {
status: number,
title: string,
time: string,
active: boolean,
completed: boolean,
desc: string
}
const getTimelineSteps = (refund: RefundType): Array<TimelineStepType> => {
const steps: Array<TimelineStepType> = [
{ status: 0, title: '提交申请', time: refund.created_at, active: false, completed: false, desc: '' },
{ status: 1, title: '商家处理', time: '', active: false, completed: false, desc: '' },
{ status: 3, title: '退款完成', time: '', active: false, completed: false, desc: '' }
]
if (refund.status_history != null) {
for (let i: number = 0; i < refund.status_history.length; i++) {
const history = refund.status_history[i]
if (history.status === 1 || history.status === 2) {
steps[1].time = history.created_at ?? ''
steps[1].desc = history.remark ?? ''
} else if (history.status === 3) {
steps[2].time = history.created_at ?? ''
steps[2].desc = history.remark ?? ''
}
}
}
const currentStepIndex = getCurrentStepIndex(refund.status)
const result: Array<TimelineStepType> = []
for (let i: number = 0; i < steps.length; i++) {
const step = steps[i]
result.push({
status: step.status,
title: step.title,
time: step.time,
desc: step.desc,
active: i === currentStepIndex,
completed: i < currentStepIndex
})
}
return result
}
// 切换标签页
const changeTab = (tab: string) => {
activeTab.value = tab
}
// 加载更多
const loadMore = () => {
if (hasMore.value && !isLoading.value) {
loadRefunds(true)
}
}
// 查看订单
const viewOrder = (orderId: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/order-detail?id=${orderId}`
})
}
const doCancelRefund = async (refund: RefundType) => {
try {
const result = await supabaseService.createRefund({
id: refund.id,
status: 4
} as any)
if (result.success) {
refund.status = 4
loadTabCounts()
uni.showToast({
title: '已取消',
icon: 'success'
})
} else {
uni.showToast({
title: '取消失败',
icon: 'none'
})
}
} catch (err) {
console.error('取消退款失败:', err)
uni.showToast({
title: '取消失败',
icon: 'none'
})
}
}
const cancelRefund = (refund: RefundType) => {
uni.showModal({
title: '取消申请',
content: '确定要取消这个退款申请吗?',
success: (res) => {
if (res.confirm) {
doCancelRefund(refund)
}
}
})
}
// 联系客服
const contactService = (refund: RefundType) => {
// #ifndef MP-WEIXIN
uni.navigateTo({
url: `/pages/mall/service/chat?refundId=${refund.id}`
})
// #endif
}
// 评价服务
const reviewRefund = (refund: RefundType) => {
uni.navigateTo({
url: `/pages/mall/consumer/refund-review?id=${refund.id}`
})
}
const doDeleteRefund = async (refund: RefundType) => {
try {
const result = await supabaseService.deleteRefund(refund.id)
if (result) {
const newRefunds: Array<RefundType> = []
for (let i: number = 0; i < refunds.value.length; i++) {
if (refunds.value[i].id !== refund.id) {
newRefunds.push(refunds.value[i])
}
}
refunds.value = newRefunds
uni.showToast({
title: '删除成功',
icon: 'success'
})
} else {
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
} catch (err) {
console.error('删除记录失败:', err)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
const deleteRefund = (refund: RefundType) => {
uni.showModal({
title: '删除记录',
content: '确定要删除这个售后记录吗?',
success: (res) => {
if (res.confirm) {
doDeleteRefund(refund)
}
}
})
}
// 申请售后
const applyRefund = () => {
uni.navigateTo({
url: '/pages/mall/consumer/apply-refund'
})
}
// 查看订单
const goToOrders = () => {
uni.switchTab({
url: '/pages/mall/consumer/orders'
})
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.refund-page {
display: flex;
flex-direction: column;
flex: 1;
background-color: #f5f5f5;
}
.refund-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 20px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.refund-tabs {
background-color: #ffffff;
display: flex;
border-bottom: 1px solid #e5e5e5;
}
.refund-tab {
flex: 1;
padding: 15px;
text-align: center;
position: relative;
}
.refund-tab.active {
color: #007aff;
border-bottom: 2px solid #007aff;
}
.tab-text {
font-size: 16px;
color: #666666;
}
.refund-tab.active .tab-text {
color: #007aff;
font-weight: bold;
}
.tab-badge {
position: absolute;
top: 10px;
right: 20px;
background-color: #ff4757;
color: #ffffff;
font-size: 10px;
padding: 2px 5px;
border-radius: 8px;
min-width: 16px;
text-align: center;
}
.refund-content {
flex: 1;
}
.empty-refunds {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
margin-bottom: 30px;
}
.go-orders-btn {
background-color: #007aff;
color: #ffffff;
padding: 10px 40px;
border-radius: 25px;
font-size: 14px;
border: none;
}
.refund-item {
background-color: #ffffff;
margin-bottom: 10px;
padding: 15px;
}
.refund-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f5f5f5;
}
.refund-no {
font-size: 14px;
color: #333333;
}
.refund-status {
font-size: 14px;
padding: 4px 10px;
border-radius: 12px;
color: #ffffff;
}
.status-pending {
background-color: #ff5000;
}
.status-processing {
background-color: #2196f3;
}
.status-completed {
background-color: #4caf50;
}
.status-cancelled {
background-color: #9e9e9e;
}
.status-rejected {
background-color: #f44336;
}
.order-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #f5f5f5;
}
.order-no {
font-size: 13px;
color: #666666;
}
.order-time {
font-size: 12px;
color: #999999;
}
.product-info {
display: flex;
margin-bottom: 15px;
}
.product-image {
width: 60px;
height: 60px;
border-radius: 5px;
margin-right: 15px;
}
.product-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 5px;
}
.refund-reason {
font-size: 12px;
color: #666666;
margin-bottom: 8px;
}
.refund-amount {
display: flex;
align-items: flex-end;
}
.amount-label {
font-size: 13px;
color: #666666;
margin-right: 5px;
}
.amount-value {
font-size: 16px;
color: #ff4757;
font-weight: bold;
}
.timeline {
padding: 15px 0;
border-top: 1px solid #f5f5f5;
}
.timeline-step {
display: flex;
margin-bottom: 15px;
}
.timeline-step:last-child {
margin-bottom: 0;
}
.step-dot {
width: 12px;
height: 12px;
border-radius: 6px;
border: 2px solid #e5e5e5;
margin-right: 15px;
position: relative;
top: 3px;
}
.step-dot.active {
border-color: #007aff;
background-color: #007aff;
}
.step-dot.completed {
border-color: #4caf50;
background-color: #4caf50;
}
.step-info {
flex: 1;
}
.step-title {
font-size: 14px;
color: #333333;
font-weight: bold;
margin-bottom: 3px;
}
.step-time {
font-size: 12px;
color: #999999;
margin-bottom: 3px;
}
.step-desc {
font-size: 12px;
color: #666666;
}
.refund-actions {
display: flex;
justify-content: flex-end;
/* gap: 10px; removed for uni-app-x */
padding-top: 15px;
border-top: 1px solid #f5f5f5;
}
.action-btn {
margin-left: 10px;
padding: 6px 15px;
border-radius: 15px;
font-size: 12px;
border: 1px solid;
background-color: #ffffff;
}
.action-btn.cancel {
border-color: #666666;
color: #666666;
}
.action-btn.contact {
border-color: #007aff;
color: #007aff;
}
.action-btn.review {
border-color: #ff5000;
color: #ff5000;
}
.action-btn.delete {
border-color: #f44336;
color: #f44336;
}
.loading-more,
.no-more {
padding: 20px;
text-align: center;
background-color: #ffffff;
}
.loading-text,
.no-more-text {
color: #999999;
font-size: 14px;
}
.apply-btn-container {
background-color: #ffffff;
padding: 15px;
border-top: 1px solid #e5e5e5;
}
.apply-btn {
background-color: #007aff;
color: #ffffff;
height: 50px;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
border: none;
}
</style>

View File

@@ -0,0 +1,896 @@
<!-- 评价页面 -->
<template>
<view class="review-page">
<!-- 顶部栏:已移除“发表评价”文字 -->
<view class="review-header">
<view class="header-back" @click="goBack">
<image class="back-icon" src="/static/icons/back.png" mode="aspectFit"></image>
</view>
<view class="header-title-placeholder"></view>
<view class="header-right"></view>
</view>
<scroll-view class="review-content" direction="vertical">
<!-- 订单信息 -->
<view class="order-section">
<text class="order-no">订单号: {{ order != null ? order.order_no : '' }}</text>
<text class="order-time">下单时间: {{ formatTime(order != null ? order.created_at : '') }}</text>
</view>
<!-- 商品评价 -->
<view class="products-section">
<view v-for="(item, index) in orderItems" :key="item.id" class="product-review">
<view class="product-header">
<image class="product-image" :src="item.product_image ?? '/static/default-product.png'" />
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text v-if="item.sku_specifications != null" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
</view>
</view>
<!-- 评分 -->
<view class="rating-section">
<text class="rating-label">评分</text>
<view class="rating-stars">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= ratings[index] }"
@click="setRating(index, star)">
</text>
</view>
<text class="rating-text">{{ getRatingText(ratings[index]) }}</text>
</view>
<!-- 评价内容 -->
<view class="content-section">
<textarea class="review-textarea"
v-model="contents[index]"
placeholder="请写下您的使用感受,分享给其他小伙伴吧"
maxlength="500"></textarea>
<text class="word-count">{{ contents[index]?.length || 0 }}/500</text>
</view>
<!-- 图片上传 -->
<view class="images-section">
<text class="images-label">上传图片(可选)</text>
<view class="images-grid">
<view v-for="(image, imgIndex) in images[index]"
:key="imgIndex"
class="image-item">
<image class="uploaded-image" :src="image" />
<text class="delete-image" @click="deleteImage(index, imgIndex)">×</text>
</view>
<view v-if="images[index].length < 9"
class="upload-btn"
@click="uploadImage(index)">
<text class="upload-icon">+</text>
<text class="upload-text">添加图片</text>
</view>
</view>
</view>
<!-- 匿名评价 -->
<view class="anonymous-section">
<view class="anonymous-switch">
<text class="switch-label">匿名评价</text>
<switch :checked="anonymous" @change="toggleAnonymous" />
</view>
<text class="anonymous-tip">评价内容对其他用户不可见</text>
</view>
</view>
</view>
<!-- 店铺评价 -->
<view v-if="merchant" class="merchant-section">
<text class="section-title">店铺评价</text>
<view class="merchant-rating">
<text class="rating-item">商品描述相符</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.description }"
@click="setMerchantRating('description', star)">
</text>
</view>
</view>
<view class="merchant-rating">
<text class="rating-item">物流服务</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.logistics }"
@click="setMerchantRating('logistics', star)">
</text>
</view>
</view>
<view class="merchant-rating">
<text class="rating-item">服务态度</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.service }"
@click="setMerchantRating('service', star)">
</text>
</view>
</view>
</view>
<!-- 评价提示 -->
<view class="tips-section">
<text class="tip-title">评价须知</text>
<text class="tip-item">1. 评价后不可修改,请谨慎评价</text>
<text class="tip-item">2. 上传图片需为真实商品照片</text>
<text class="tip-item">3. 恶意评价将被删除并限制评价功能</text>
<text class="tip-item">4. 优质评价可获得积分奖励</text>
</view>
</scroll-view>
<!-- 提交按钮 -->
<view class="submit-section">
<button class="submit-btn"
:class="{ disabled: canSubmit === false || isSubmitting }"
@click="submitReview">
<text v-if="isSubmitting === false" class="submit-text">提交评价</text>
<text v-else class="submit-text">提交中...</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
type OrderType = {
id: string
order_no: string
created_at: string
merchant_id: string
}
type OrderItemType = {
id: number
order_id: number
product_id: number
product_name: string
product_image: string
sku_specifications: any | null
price: number
quantity: number
}
type MerchantRatingType = {
description: number
logistics: number
service: number
}
type MerchantType = {
id: string
shop_name: string
rating: number
}
const orderId = ref<string>('')
const order = ref<OrderType | null>(null)
const orderItems = ref<Array<OrderItemType>>([])
const merchant = ref<MerchantType | null>(null)
const ratings = ref<Array<number>>([])
const contents = ref<Array<string>>([])
const images = ref<Array<Array<string>>>([])
const anonymous = ref<boolean>(false)
const merchantRating = ref<MerchantRatingType>({
description: 5,
logistics: 5,
service: 5
} as MerchantRatingType)
const isSubmitting = ref<boolean>(false)
const loadOrderData = async (): Promise<void> => {
try {
console.log('[loadOrderData] 开始加载订单数据, orderId:', orderId.value)
// 使用 supabaseService 获取订单详情
const orderDetailRaw = await supabaseService.getOrderDetail(orderId.value)
console.log('[loadOrderData] orderDetailRaw:', JSON.stringify(orderDetailRaw))
if (orderDetailRaw == null) {
console.error('加载订单失败: 未找到订单')
uni.showToast({ title: '订单不存在', icon: 'none' })
return
}
// 转换为 UTSJSONObject
const orderDetail = JSON.parse(JSON.stringify(orderDetailRaw)) as UTSJSONObject
// 解析订单基本信息
order.value = {
id: orderDetail.getString('id') ?? '',
order_no: orderDetail.getString('order_no') ?? '',
created_at: orderDetail.getString('created_at') ?? '',
merchant_id: orderDetail.getString('merchant_id') ?? ''
} as OrderType
// 解析订单商品
const itemsRaw = orderDetail.get('ml_order_items')
console.log('[loadOrderData] itemsRaw:', JSON.stringify(itemsRaw))
if (itemsRaw != null) {
const itemsList = itemsRaw as any[]
const processedItems: Array<OrderItemType> = []
for (let i: number = 0; i < itemsList.length; i++) {
const itemStr = JSON.stringify(itemsList[i])
const item = JSON.parse(itemStr) as UTSJSONObject
const skuSpec = item.get('sku_specifications')
processedItems.push({
id: (item.getNumber('id') ?? 0) as number,
order_id: (item.getNumber('order_id') ?? 0) as number,
product_id: (item.getNumber('product_id') ?? 0) as number,
product_name: item.getString('product_name') ?? '',
price: (item.getNumber('price') ?? 0) as number,
quantity: (item.getNumber('quantity') ?? 1) as number,
sku_specifications: skuSpec,
product_image: item.getString('product_image') ?? item.getString('image_url') ?? '/static/default-product.png'
} as OrderItemType)
}
orderItems.value = processedItems
console.log('[loadOrderData] processedItems count:', processedItems.length)
}
// 初始化评价数据
const count = orderItems.value.length
const newRatings: Array<number> = []
const newContents: Array<string> = []
const newImages: Array<Array<string>> = []
for (let i: number = 0; i < count; i++) {
newRatings.push(5)
newContents.push('')
newImages.push([])
}
ratings.value = newRatings
contents.value = newContents
images.value = newImages
// 获取商家信息
const orderObj = order.value
if (orderObj != null) {
const merchantId = orderObj.merchant_id
if (merchantId != '') {
const shopInfo = await supabaseService.getShopByMerchantId(merchantId)
if (shopInfo != null) {
merchant.value = {
id: shopInfo.id,
shop_name: shopInfo.shop_name,
rating: shopInfo.rating_avg ?? 5
} as MerchantType
}
}
}
} catch (err) {
console.error('加载订单数据异常:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
const canSubmit = computed((): boolean => {
if (ratings.value.length === 0) return false
for (let i: number = 0; i < ratings.value.length; i++) {
if (ratings.value[i] <= 0) return false
}
return true
})
onLoad((options: any) => {
if (options != null) {
const optObj = options as UTSJSONObject
orderId.value = optObj.getString('orderId') ?? ''
if (orderId.value != '') loadOrderData()
}
})
// 格式化时间
const formatTime = (timeStr?: string): string => {
if (timeStr == null) return ''
const date = new Date(timeStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}
const getSpecText = (specs: any | null): string => {
if (specs == null) return ''
if (typeof specs === 'string') return specs as string
try {
const specObj = JSON.parse(JSON.stringify(specs)) as UTSJSONObject
const jsonStr = JSON.stringify(specObj)
if (jsonStr == '{}' || jsonStr == 'null') return ''
// 简单解析:直接返回 JSON 字符串(去除大括号)
const cleanStr = jsonStr.replace(/^\{|\}$/g, '').replace(/"/g, '').replace(/:/g, ': ').replace(/,/g, '; ')
return cleanStr
} catch (e) {
return ''
}
}
// 获取评分文本
const getRatingText = (rating: number): string => {
if (rating === 1) return '非常差'
if (rating === 2) return '差'
if (rating === 3) return '一般'
if (rating === 4) return '好'
if (rating === 5) return '非常好'
return '未评价'
}
// 设置商品评分
const setRating = (index: number, rating: number) => {
ratings.value[index] = rating
// 触发响应式更新
const newRatings: number[] = []
for (let i: number = 0; i < ratings.value.length; i++) {
newRatings.push(ratings.value[i])
}
ratings.value = newRatings
}
const setMerchantRating = (type: string, rating: number) => {
if (type === 'description') {
merchantRating.value.description = rating
} else if (type === 'logistics') {
merchantRating.value.logistics = rating
} else if (type === 'service') {
merchantRating.value.service = rating
}
}
// 切换匿名
const toggleAnonymous = (event: any) => {
const eventObj = event as UTSJSONObject
const detailRaw = eventObj.get('detail')
const detail = detailRaw != null ? (detailRaw as UTSJSONObject) : (new UTSJSONObject())
const valueRaw = detail.get('value')
anonymous.value = valueRaw != null ? (valueRaw as boolean) : false
}
// 上传图片
const uploadImage = async (index: number) => {
// 检查图片数量限制
if (images.value[index].length >= 9) {
uni.showToast({
title: '最多上传9张图片',
icon: 'none'
})
return
}
// 使用uni.chooseImage选择图片
uni.chooseImage({
count: 9 - images.value[index].length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const resObj = res as UTSJSONObject
const tempFilesRaw = resObj.get('tempFilePaths')
const tempFiles = tempFilesRaw != null ? (tempFilesRaw as Array<string>) : []
uni.showLoading({
title: '上传中...'
})
setTimeout(() => {
for (let i: number = 0; i < tempFiles.length; i++) {
images.value[index].push(tempFiles[i])
}
const newImages: Array<Array<string>> = []
for (let i: number = 0; i < images.value.length; i++) {
const innerArray: Array<string> = []
for (let j: number = 0; j < images.value[i].length; j++) {
innerArray.push(images.value[i][j])
}
newImages.push(innerArray)
}
images.value = newImages
uni.hideLoading()
uni.showToast({
title: '上传成功',
icon: 'success'
})
}, 1000)
}
})
}
// 删除图片
const deleteImage = (index: number, imgIndex: number) => {
images.value[index].splice(imgIndex, 1)
// 触发响应式更新
const newImages: string[][] = []
for (let i: number = 0; i < images.value.length; i++) {
const innerArray: string[] = []
for (let j: number = 0; j < images.value[i].length; j++) {
innerArray.push(images.value[i][j])
}
newImages.push(innerArray)
}
images.value = newImages
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
if (userStore == null) return ''
const userInfo = userStore as UTSJSONObject
return userInfo.getString('id') ?? ''
}
const submitReview = async (): Promise<void> => {
if (canSubmit.value === false || isSubmitting.value) return
isSubmitting.value = true
try {
const userId = getCurrentUserId()
if (userId == '') {
uni.showToast({
title: '用户信息错误',
icon: 'none'
})
return
}
type ProductReviewType = {
user_id: string,
product_id: number,
order_id: string,
rating: number,
content: string,
images: Array<string>,
is_anonymous: boolean
}
const productReviews: Array<UTSJSONObject> = []
for (let index: number = 0; index < orderItems.value.length; index++) {
const item = orderItems.value[index]
const reviewObj: UTSJSONObject = new UTSJSONObject()
reviewObj.set('user_id', userId)
reviewObj.set('product_id', item.product_id)
reviewObj.set('order_id', orderId.value)
reviewObj.set('rating', ratings.value[index])
reviewObj.set('content', contents.value[index] != '' ? contents.value[index] : '')
reviewObj.set('images', images.value[index])
reviewObj.set('is_anonymous', anonymous.value)
productReviews.push(reviewObj)
}
const reviewsSuccess = await supabaseService.submitProductReviews(productReviews)
if (reviewsSuccess == false) {
uni.showToast({
title: '提交失败',
icon: 'none'
})
isSubmitting.value = false
return
}
if (merchant.value != null) {
type MerchantReviewType = {
user_id: string,
shop_id: string,
order_id: string,
description_rating: number,
logistics_rating: number,
service_rating: number
}
const merchantReviewObj: UTSJSONObject = new UTSJSONObject()
merchantReviewObj.set('user_id', userId)
merchantReviewObj.set('shop_id', merchant.value.id)
merchantReviewObj.set('order_id', orderId.value)
merchantReviewObj.set('description_rating', merchantRating.value.description)
merchantReviewObj.set('logistics_rating', merchantRating.value.logistics)
merchantReviewObj.set('service_rating', merchantRating.value.service)
await supabaseService.submitShopReview(merchantReviewObj)
}
await supabaseService.updateOrderStatus(orderId.value, 4)
uni.showToast({
title: '评价成功',
icon: 'success',
duration: 2000
})
// 跳转到评价成功页面
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (err) {
console.error('提交评价失败:', err)
uni.showToast({
title: '提交失败',
icon: 'none'
})
} finally {
isSubmitting.value = false
}
}
// 返回
const goBack = (): void => {
uni.navigateBack()
}
</script>
<style scoped>
.review-page {
display: flex;
flex-direction: column;
flex: 1;
background-color: #f5f5f5;
}
.review-header {
background-color: #ffffff;
padding: 10px 15px;
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f0f0;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
}
.header-back {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-icon {
width: 20px;
height: 20px;
}
.header-title-placeholder {
flex: 1;
}
.header-right {
width: 44px;
}
.review-content {
flex: 1;
}
.order-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.order-no {
font-size: 14px;
color: #333333;
}
.order-time {
font-size: 12px;
color: #999999;
}
.products-section {
background-color: #ffffff;
margin-bottom: 10px;
}
.product-review {
padding: 15px;
border-bottom: 1px solid #f5f5f5;
}
.product-header {
display: flex;
margin-bottom: 20px;
}
.product-image {
width: 60px;
height: 60px;
border-radius: 5px;
margin-right: 15px;
}
.product-info {
flex: 1;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 5px;
/* display: block; removed */
}
.product-spec {
font-size: 12px;
color: #999999;
/* display: block; removed */
}
.rating-section {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
padding: 5px 0;
}
.rating-label {
font-size: 15px;
color: #333333;
font-weight: bold;
margin-right: 20px;
}
.rating-stars {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 15px;
}
.star-icon {
font-size: 26px;
margin-right: 8px;
color: #e0e0e0;
transition: transform 0.1s ease;
}
.star-icon.active {
color: #ff5000;
transform: scale(1.1);
}
.rating-text {
font-size: 14px;
color: #999999;
margin-left: 5px;
}
.rating-stars.small {
/* gap: 5px; removed */
}
.content-section {
margin-bottom: 15px;
}
.review-textarea {
width: 100%;
min-height: 80px;
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
color: #333333;
line-height: 1.4;
}
.word-count {
/* display: block; removed */
text-align: right;
font-size: 12px;
color: #999999;
margin-top: 5px;
}
.images-section {
margin-bottom: 15px;
}
.images-label {
/* display: block; removed */
font-size: 14px;
color: #333333;
margin-bottom: 10px;
}
.images-grid {
display: flex;
flex-wrap: wrap;
/* gap: 10px; removed */
}
.image-item {
margin-right: 10px;
margin-bottom: 10px;
width: 70px;
height: 70px;
border-radius: 5px;
overflow: hidden;
position: relative;
}
.uploaded-image {
width: 100%;
height: 100%;
}
.delete-image {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.5);
color: #ffffff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.upload-btn {
width: 70px;
height: 70px;
border: 1px dashed #cccccc;
border-radius: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
font-size: 24px;
color: #999999;
margin-bottom: 5px;
}
.upload-text {
font-size: 10px;
color: #999999;
}
.anonymous-section {
margin-bottom: 15px;
}
.anonymous-switch {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.switch-label {
font-size: 14px;
/* display: block; removed */
}
.anonymous-tip {
/* display: block; removed */
font-size: 12px;
color: #999999;
}
.merchant-section {
background-color: #ffffff;
padding: 20px 15px;
margin-top: 15px;
border-radius: 12px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 20px;
padding-left: 10px;
border-left: 4px solid #ff5000;
}
.merchant-rating {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 0 5px;
}
.rating-item {
font-size: 14px;
color: #666666;
flex: 1;
}
.merchant-rating .rating-stars.small {
display: flex;
flex-direction: row;
align-items: center;
}
.merchant-rating .rating-stars.small .star-icon {
font-size: 20px;
margin-right: 5px;
color: #e0e0e0;
}
.merchant-rating .rating-stars.small .star-icon.active {
color: #ff5000;
}
.tips-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
}
.tip-title {
/* display: block; removed */
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 10px;
}
.tip-item {
/* display: block; removed */
font-size: 12px;
color: #666666;
line-height: 1.6;
margin-bottom: 5px;
}
.submit-section {
background-color: #ffffff;
padding: 15px;
border-top: 1px solid #e5e5e5;
}
.submit-btn {
background-color: #007aff;
color: #ffffff;
height: 50px;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
border: none;
}
.submit-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,832 @@
<!-- 设置页面 -->
<template>
<view class="settings-page">
<!-- 顶部栏 -->
<!--<view class="settings-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">设置</text>
</view>-->
<scroll-view class="settings-content" direction="vertical">
<!-- 账户设置 -->
<view class="settings-section">
<text class="section-title">账户设置</text>
<view class="section-list">
<view class="list-item" @click="goToProfile">
<text class="item-icon">👤</text>
<text class="item-text">个人资料</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="goToAddressList">
<text class="item-icon">📍</text>
<text class="item-text">收货地址</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="changePassword">
<text class="item-icon">🔒</text>
<text class="item-text">修改密码</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="bindPhone">
<text class="item-icon">📱</text>
<text class="item-text">手机绑定</text>
<view class="item-right">
<text class="item-status" :class="userInfo.phone != null && userInfo.phone != '' ? 'bound' : ''">
{{ userInfo.phone != null && userInfo.phone != '' ? '已绑定' : '未绑定' }}
</text>
<text class="item-arrow"></text>
</view>
</view>
<view class="list-item" @click="bindEmail">
<text class="item-icon">📧</text>
<text class="item-text">邮箱绑定</text>
<view class="item-right">
<text class="item-status" :class="userInfo.email != null && userInfo.email != '' ? 'bound' : ''">
{{ userInfo.email != null && userInfo.email != '' ? '已绑定' : '未绑定' }}
</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
</view>
<!-- 消息通知 -->
<view class="settings-section">
<text class="section-title">消息通知</text>
<view class="section-list">
<view class="list-item">
<text class="item-icon">🔔</text>
<text class="item-text">订单消息</text>
<switch class="settings-switch" :checked="notifications.order" @change="toggleNotification('order')" />
</view>
<view class="list-item">
<text class="item-icon">🎁</text>
<text class="item-text">促销活动</text>
<switch class="settings-switch" :checked="notifications.promotion" @change="toggleNotification('promotion')" />
</view>
<view class="list-item">
<text class="item-icon">⭐</text>
<text class="item-text">评价提醒</text>
<switch class="settings-switch" :checked="notifications.review" @change="toggleNotification('review')" />
</view>
</view>
</view>
<!-- 隐私设置 -->
<view class="settings-section">
<text class="section-title">隐私设置</text>
<view class="section-list">
<view class="list-item">
<text class="item-icon">👁️</text>
<text class="item-text">隐藏购物记录</text>
<switch class="settings-switch" :checked="privacy.hidePurchase" @change="togglePrivacy('hidePurchase')" />
</view>
<view class="list-item">
<text class="item-icon">🔍</text>
<text class="item-text">允许通过手机号找到我</text>
<switch class="settings-switch" :checked="privacy.allowSearchByPhone" @change="togglePrivacy('allowSearchByPhone')" />
</view>
<view class="list-item">
<text class="item-icon">💬</text>
<text class="item-text">接收商家消息</text>
<switch class="settings-switch" :checked="privacy.receiveMerchantMsg" @change="togglePrivacy('receiveMerchantMsg')" />
</view>
</view>
</view>
<!-- 通用设置 -->
<view class="settings-section">
<text class="section-title">通用设置</text>
<view class="section-list">
<view class="list-item" @click="clearCache">
<text class="item-icon">🗑️</text>
<text class="item-text">清除缓存</text>
<view class="item-right">
<text class="item-cache">{{ cacheSize }}</text>
<text class="item-arrow"></text>
</view>
</view>
<view class="list-item" @click="changeLanguage">
<text class="item-icon">🌐</text>
<text class="item-text">语言设置</text>
<view class="item-right">
<text class="item-status">{{ currentLanguage }}</text>
<text class="item-arrow"></text>
</view>
</view>
<view class="list-item" @click="changeTheme">
<text class="item-icon">🎨</text>
<text class="item-text">主题设置</text>
<view class="item-right">
<text class="item-status">{{ currentTheme }}</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
</view>
<!-- 我的服务 -->
<view class="settings-section">
<text class="section-title">我的服务</text>
<view class="section-list">
<view class="list-item" @click="goToMyReviews">
<text class="item-icon">📝</text>
<text class="item-text">我的评价</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
<!-- 关于我们 -->
<view class="settings-section">
<text class="section-title">关于我们</text>
<view class="section-list">
<view class="list-item" @click="aboutUs">
<text class="item-icon"></text>
<text class="item-text">关于商城</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="userAgreement">
<text class="item-icon">📜</text>
<text class="item-text">用户协议</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="privacyPolicy">
<text class="item-icon">🛡️</text>
<text class="item-text">隐私政策</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="checkUpdate">
<text class="item-icon">🔄</text>
<text class="item-text">检查更新</text>
<view class="item-right">
<text class="item-status">{{ appVersion }}</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
</view>
<!-- 客服与反馈 -->
<view class="settings-section">
<text class="section-title">客服与反馈</text>
<view class="section-list">
<view class="list-item" @click="contactService">
<text class="item-icon">💬</text>
<text class="item-text">联系客服</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="feedback">
<text class="item-icon">📝</text>
<text class="item-text">意见反馈</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="rateApp">
<text class="item-icon">⭐</text>
<text class="item-text">给个好评</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-section">
<button class="logout-btn" @click="logout">退出登录</button>
</view>
<!-- 账号注销 -->
<view class="delete-account-section">
<text class="delete-account" @click="deleteAccount">注销账号</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onBackPress } from '@dcloudio/uni-app'
import supa from '@/components/supadb/aksupainstance.uts'
// 拦截返回事件,强制跳转到个人中心页
onBackPress((options) => {
// 无论是什么触发的返回系统返回键或导航栏返回按钮都跳转到profile
// 注意onBackPress 只能在 page 中使用component 中无效
uni.switchTab({
url: '/pages/main/profile'
})
// 返回 true 表示阻止默认返回行为
return true
})
type UserType = {
id: string
phone: string | null
email: string | null
nickname: string | null
avatar_url: string | null
}
type NotificationType = {
order: boolean
promotion: boolean
review: boolean
}
type PrivacyType = {
hidePurchase: boolean
allowSearchByPhone: boolean
receiveMerchantMsg: boolean
}
const userInfo = ref<UserType>({
id: '',
phone: null,
email: null,
nickname: null,
avatar_url: null
})
const notifications = ref<NotificationType>({
order: true,
promotion: true,
review: true
})
const privacy = ref<PrivacyType>({
hidePurchase: false,
allowSearchByPhone: true,
receiveMerchantMsg: true
})
const cacheSize = ref<string>('0.0 MB')
const currentLanguage = ref<string>('简体中文')
const currentTheme = ref<string>('自动')
const appVersion = ref<string>('1.0.0')
const statusBarHeight = ref<number>(0)
const loadUserInfo = () => {
const userStore = uni.getStorageSync('userInfo')
if (userStore != null) {
const storeObj = userStore as UTSJSONObject
const user: UserType = {
id: storeObj.getString('id') ?? '',
phone: storeObj.getString('phone'),
email: storeObj.getString('email'),
nickname: storeObj.getString('nickname'),
avatar_url: storeObj.getString('avatar_url')
} as UserType
userInfo.value = user
}
}
const loadSettings = () => {
const savedNotifications = uni.getStorageSync('userNotifications')
if (savedNotifications != null) {
const notifObj = savedNotifications as UTSJSONObject
const notif: NotificationType = {
order: notifObj.getBoolean('order') ?? true,
promotion: notifObj.getBoolean('promotion') ?? true,
review: notifObj.getBoolean('review') ?? true
} as NotificationType
notifications.value = notif
}
const savedPrivacy = uni.getStorageSync('userPrivacy')
if (savedPrivacy != null) {
const privacyObj = savedPrivacy as UTSJSONObject
const priv: PrivacyType = {
hidePurchase: privacyObj.getBoolean('hidePurchase') ?? false,
allowSearchByPhone: privacyObj.getBoolean('allowSearchByPhone') ?? true,
receiveMerchantMsg: privacyObj.getBoolean('receiveMerchantMsg') ?? true
} as PrivacyType
privacy.value = priv
}
cacheSize.value = '12.5 MB'
const appInfo = uni.getAppBaseInfo()
if (appInfo != null) {
const infoObj = appInfo as UTSJSONObject
const version = infoObj.getString('appVersion')
if (version != null) {
appVersion.value = version
}
}
}
onMounted(() => {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight ?? 0
loadUserInfo()
loadSettings()
})
// 跳转到个人资料
const goToProfile = () => {
uni.navigateTo({
url: '/pages/user/profile'
})
}
// 跳转到地址管理
const goToAddressList = () => {
uni.navigateTo({
url: '/pages/mall/consumer/address-list'
})
}
// 修改密码
const changePassword = () => {
uni.navigateTo({
url: '/pages/user/change-password'
})
}
// 绑定手机
const bindPhone = () => {
uni.navigateTo({
url: '/pages/user/bind-phone'
})
}
// 绑定邮箱
const bindEmail = () => {
uni.navigateTo({
url: '/pages/user/bind-email'
})
}
// 切换通知设置
const toggleNotification = (type: string) => {
if (type === 'order') {
notifications.value.order = notifications.value.order === false
} else if (type === 'promotion') {
notifications.value.promotion = notifications.value.promotion === false
} else if (type === 'review') {
notifications.value.review = notifications.value.review === false
}
uni.setStorageSync('userNotifications', notifications.value)
}
// 切换隐私设置
const togglePrivacy = (type: string) => {
if (type === 'hidePurchase') {
privacy.value.hidePurchase = privacy.value.hidePurchase === false
} else if (type === 'allowSearchByPhone') {
privacy.value.allowSearchByPhone = privacy.value.allowSearchByPhone === false
} else if (type === 'receiveMerchantMsg') {
privacy.value.receiveMerchantMsg = privacy.value.receiveMerchantMsg === false
}
uni.setStorageSync('userPrivacy', privacy.value)
}
// 清除缓存
const clearCache = () => {
uni.showModal({
title: '清除缓存',
content: `确定要清除 ${cacheSize.value} 缓存吗?`,
success: (res) => {
if (res.confirm) {
// 这里应该清除实际缓存
uni.showLoading({
title: '清除中...'
})
setTimeout(() => {
cacheSize.value = '0.0 MB'
uni.hideLoading()
uni.showToast({
title: '缓存已清除',
icon: 'success'
})
}, 1000)
}
}
})
}
// 切换语言
const changeLanguage = () => {
uni.showActionSheet({
itemList: ['简体中文', 'English', '日本語'],
success: (res) => {
const languages = ['简体中文', 'English', '日本語']
currentLanguage.value = languages[res.tapIndex]
uni.setStorageSync('appLanguage', currentLanguage.value)
uni.showToast({
title: '语言已切换',
icon: 'success'
})
}
})
}
// 切换主题
const changeTheme = () => {
uni.showActionSheet({
itemList: ['自动', '浅色模式', '深色模式'],
success: (res) => {
const themes = ['自动', '浅色模式', '深色模式']
currentTheme.value = themes[res.tapIndex]
uni.setStorageSync('appTheme', currentTheme.value)
uni.showToast({
title: '主题已切换',
icon: 'success'
})
}
})
}
// 我的评价
const goToMyReviews = () => {
// 跳转到订单列表的已完成或者是评价相关的页面
uni.navigateTo({
url: '/pages/mall/consumer/orders?status=completed'
})
}
// 关于我们
const aboutUs = () => {
uni.navigateTo({
url: '/pages/user/terms?type=about'
})
}
// 用户协议
const userAgreement = () => {
uni.navigateTo({
url: '/pages/user/terms?type=agreement'
})
}
// 隐私政策
const privacyPolicy = () => {
uni.navigateTo({
url: '/pages/user/terms?type=privacy'
})
}
// 检查更新
const checkUpdate = () => {
uni.showLoading({
title: '检查更新中...'
})
setTimeout(() => {
uni.hideLoading()
uni.showModal({
title: '检查更新',
content: '当前已是最新版本',
showCancel: false
})
}, 1000)
}
// 联系客服
const contactService = () => {
uni.navigateTo({
url: '/pages/mall/consumer/chat'
})
}
// 意见反馈
const feedback = () => {
uni.navigateTo({
url: '/pages/info/feedback'
})
}
const rateApp = () => {
uni.showModal({
title: '给个好评',
content: '如果喜欢我们的应用,请给个好评吧!感谢您的支持!',
confirmText: '好的',
showCancel: false
})
}
// 退出登录
const logout = () => {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.showLoading({
title: '正在退出...'
})
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.hideLoading()
uni.showToast({
title: '已退出登录',
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1000)
}
}
})
}
const deleteAccount = () => {
uni.showModal({
title: '注销账号',
content: '确定要注销账号吗?此操作不可恢复,所有数据将被删除!',
confirmText: '注销',
confirmColor: '#ff4757',
success: (res) => {
if (res.confirm) {
uni.showLoading({
title: '注销中...'
})
let userId: string | null = userInfo.value.id
if (userId == null || userId === '') {
const storageId = uni.getStorageSync('user_id')
userId = (storageId != null) ? storageId as string : null
}
if (userId != null) {
const updateObj: UTSJSONObject = new UTSJSONObject()
updateObj.set('status', 3)
supa
.from('ml_user_profiles')
.update(updateObj)
.eq('user_id', userId)
.execute()
}
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.hideLoading()
uni.showToast({
title: '账号已注销',
icon: 'success',
duration: 2000
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1500)
}
}
})
}
</script>
<style scoped>
/* 响应式布局优化 */
.section-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
}
.list-item {
display: flex;
align-items: center;
padding: 15px;
border-bottom: 1px solid #f5f5f5;
background-color: #ffffff;
/* 手机端每行显示4个自适应排到下一行 */
width: 25%;
flex-direction: column; /* 内容改为垂直排列,图标在上文字在下 */
justify-content: center;
text-align: center;
box-sizing: border-box;
border-right: 1px solid #f5f5f5; /* 添加右边框分隔 */
}
.item-icon {
font-size: 24px;
margin-right: 0; /* 移除右侧间距 */
margin-bottom: 5px; /* 添加底部间距 */
}
.item-text {
font-size: 12px;
color: #333333;
/* 文字太长可能需要处理,这里暂时不做截断 */
}
.item-arrow {
display: none; /* 网格模式下通常不需要箭头 */
}
.item-right {
display: none; /* 简化显示,隐藏右侧状态/箭头等复杂内容 */
}
/* 针对 switch 组件的特殊处理,如果需要显示开关,可能需要调整布局 */
.settings-switch {
transform: scale(0.7);
margin-top: 5px;
}
/* 屏幕宽度大于 480px (大屏手机/平板/PC) 时,启用更宽的网格布局或列表布局 */
@media screen and (min-width: 480px) {
.list-item {
width: 47%; /* width: calc(50% - 10px); REPLACED */
margin: 5px;
border: 1px solid #f0f0f0;
border-radius: 8px;
border-bottom: 1px solid #f0f0f0;
flex-direction: row; /* 恢复水平排列 */
text-align: left;
justify-content: flex-start;
}
.item-icon {
margin-right: 15px;
margin-bottom: 0;
}
.item-text {
font-size: 14px;
}
.item-arrow, .item-right {
display: flex; /* 恢复显示 */
margin-left: auto; /* 推到右侧 */
}
}
/* 增加针对手机横屏的媒体查询 */
@media screen and (orientation: landscape) and (max-height: 500px) {
.list-item {
width: 22%; /* width: calc(25% - 10px); REPLACED */
margin: 5px;
border: 1px solid #f0f0f0;
border-radius: 8px;
flex-direction: column;
}
}
/* 屏幕宽度大于 1024px (大屏PC) 时 */
@media screen and (min-width: 1024px) {
.settings-page {
flex-direction: row; /* 整体左右布局 */
}
.settings-header {
display: none;
}
.settings-content {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.list-item {
width: 31%; /* width: calc(33.33% - 10px); REPLACED */
flex-direction: row; /* PC端保持水平排列 */
justify-content: flex-start;
text-align: left;
}
.item-icon {
margin-right: 15px;
margin-bottom: 0;
}
.item-arrow, .item-right {
display: flex;
margin-left: auto;
}
}
.settings-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: #f5f5f5;
}
.settings-header {
background-color: #ffffff;
padding: 15px;
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.settings-content {
flex: 1;
width: 100%;
height: 100px;
}
.settings-section {
background-color: #ffffff;
margin-bottom: 10px;
padding: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
/* 删除多余的 .section-list 定义 */
/* 删除多余的 .list-item 定义 */
/* 删除多余的 .list-item:last-child 定义 */
.item-icon {
font-size: 20px;
margin-right: 15px;
}
.item-text {
flex: 1;
font-size: 14px;
color: #333333;
}
.item-arrow {
color: #999999;
font-size: 16px;
margin-left: 10px;
}
.item-right {
display: flex;
align-items: center;
}
.item-status {
font-size: 12px;
color: #999999;
margin-right: 10px;
}
.item-status.bound {
color: #4caf50;
}
.item-cache {
font-size: 12px;
color: #999999;
margin-right: 10px;
}
.logout-section {
background-color: #ffffff;
margin-top: 10px;
padding: 15px;
}
.logout-btn {
background-color: #ffffff;
color: #ff4757;
height: 45px;
border: 1px solid #ff4757;
border-radius: 22.5px;
font-size: 16px;
font-weight: bold;
}
.delete-account-section {
background-color: #ffffff;
padding: 20px 15px;
text-align: center;
}
.delete-account {
color: #999999;
font-size: 14px;
}/* text-decoration: underline; REMOVED */
</style>

View File

@@ -0,0 +1,610 @@
<template>
<scroll-view class="share-detail-page" direction="vertical">
<view class="product-section">
<image class="product-image" :src="shareRecord.product_image != null && shareRecord.product_image.length > 0 ? 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 { onLoad } from '@dcloudio/uni-app'
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) {
let recordObj: UTSJSONObject | null = null
if (recordRaw instanceof UTSJSONObject) {
recordObj = recordRaw
} else {
recordObj = JSON.parse(JSON.stringify(recordRaw)) as UTSJSONObject
}
const record: ShareRecordType = {
id: recordObj.getString('id') ?? '',
product_name: recordObj.getString('product_name') ?? '',
product_image: recordObj.getString('product_image'),
product_price: recordObj.getNumber('product_price') ?? 0,
share_code: recordObj.getString('share_code') ?? '',
required_count: recordObj.getNumber('required_count') ?? 4,
current_count: recordObj.getNumber('current_count') ?? 0,
status: recordObj.getNumber('status') ?? 0,
reward_amount: recordObj.getNumber('reward_amount'),
created_at: recordObj.getString('created_at') ?? '',
completed_at: recordObj.getString('completed_at')
}
shareRecord.value = record
}
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]
let itemObj: UTSJSONObject | null = null
if (item instanceof UTSJSONObject) {
itemObj = item
} else {
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
}
parsed.push({
id: itemObj.getString('id') ?? '',
buyer_id: itemObj.getString('buyer_id') ?? '',
buyer_name: '用户' + (i + 1),
quantity: itemObj.getNumber('quantity') ?? 1,
created_at: itemObj.getString('created_at') ?? ''
})
}
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}`
}
onLoad((options) => {
if (options != null) {
const idVal = options['id']
if (idVal != null) {
shareId.value = idVal 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: center;
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,412 @@
<template>
<scroll-view class="share-page" direction="vertical">
<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 != null && share.product_image.length > 0 ? 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]
let itemObj: UTSJSONObject | null = null
if (item instanceof UTSJSONObject) {
itemObj = item
} else {
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
}
parsed.push({
id: itemObj.getString('id') ?? '',
product_id: itemObj.getString('product_id') ?? '',
product_name: itemObj.getString('product_name') ?? '',
product_image: itemObj.getString('product_image'),
product_price: itemObj.getNumber('product_price') ?? 0,
share_code: itemObj.getString('share_code') ?? '',
required_count: itemObj.getNumber('required_count') ?? 4,
current_count: itemObj.getNumber('current_count') ?? 0,
status: itemObj.getNumber('status') ?? 0,
reward_amount: itemObj.getNumber('reward_amount'),
created_at: itemObj.getString('created_at') ?? ''
})
}
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>

View File

@@ -0,0 +1,959 @@
<template>
<view class="shop-detail-page">
<scroll-view class="page-scroll" scroll-y="true" @scrolltolower="onScrollToLower" refresher-enabled="true" @refresherrefresh="onRefresherRefresh" :refresher-triggered="isRefresherTriggered">
<!-- 店铺头部信息 -->
<view class="shop-header">
<image :src="merchant.shop_banner != '' ? merchant.shop_banner : '/static/default-banner.png'" class="shop-banner" mode="aspectFill" />
<view class="shop-info-card">
<image :src="merchant.shop_logo != '' ? merchant.shop_logo : '/static/default-shop.png'" class="shop-logo" />
<view class="shop-basic-info">
<text class="shop-name">{{ merchant.shop_name }}</text>
<view class="shop-stats">
<text class="stat-item">⭐ {{ merchant.rating.toFixed(1) }}</text>
<text class="stat-item">销量 {{ merchant.total_sales }}</text>
</view>
</view>
<view class="shop-actions">
<view class="action-btn chat-btn" @click="contactService">
<text class="action-text">客服</text>
</view>
<view class="action-btn follow-btn" @click="toggleFollow">
<text class="action-text" :class="{ followed: isFollowed }">{{ isFollowed ? '已关注' : '+ 关注' }}</text>
</view>
</view>
</view>
<text class="shop-desc">{{ merchant.shop_description != '' ? merchant.shop_description : '这家店很懒,什么都没写~' }}</text>
<!-- 优惠券列表 (新增) -->
<view class="shop-coupons" v-if="coupons.length > 0">
<scroll-view scroll-x="true" class="coupon-scroll" show-scrollbar="false">
<view class="coupon-wrapper">
<view class="coupon-card" v-for="coupon in coupons" :key="coupon.id" @click="claimCoupon(coupon)">
<view class="coupon-left">
<text class="coupon-amount"><text style="font-size:10px">¥</text>{{ coupon.discount_value }}</text>
<text class="coupon-cond" v-if="coupon.min_order_amount > 0">满{{ coupon.min_order_amount }}</text>
<text class="coupon-cond" v-else>无门槛</text>
</view>
<view class="coupon-right">
<text class="coupon-btn-label">领取</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 商品列表 -->
<view class="product-section">
<view class="results-header">
<text class="results-title">全部商品</text>
<view class="filter-tabs">
<text class="filter-tab active">综合</text>
<text class="filter-tab">销量</text>
<text class="filter-tab">价格</text>
</view>
</view>
<view class="results-list">
<view v-for="product in products" :key="product.id" class="result-item" @click="goToProduct(product.id)">
<image :src="product.images[0]" class="product-image" mode="aspectFill" />
<text class="product-name" :lines="2">{{ product.name }}</text>
<view class="product-bottom">
<text class="product-price">¥{{ product.price }}</text>
<view class="product-add-btn" @click.stop="addToCart(product)">
<text class="add-icon">+</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { MerchantType, ProductType } from '@/types/mall-types.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
// 优惠券类型定义
type CouponType = {
id: string
discount_value: number
min_order_amount: number
name: string
start_time: string
end_time: string
status: number
}
// 分页相关状态
const currentPage = ref(1)
const pageSize = ref(6) // 默认显示六个
const hasMore = ref(true)
const isLoading = ref(false)
const currentMerchantId = ref('')
const merchant = ref<MerchantType>({
id: '',
user_id: '',
shop_name: '',
shop_logo: '',
shop_banner: '',
shop_description: '',
contact_name: '',
contact_phone: '',
shop_status: 0,
rating: 0,
total_sales: 0,
created_at: ''
} as MerchantType)
const products = ref<ProductType[]>([])
const isFollowed = ref(false)
const coupons = ref<CouponType[]>([]) // 新增优惠券
const isRefresherTriggered = ref(false)
// 函数定义必须在 onMounted 之前
// checkFollowStatus 必须在 loadShopData 之前定义
const checkFollowStatus = async (shopId: string) => {
const userId = supabaseService.getCurrentUserId()
if (userId != null && userId != '') {
try {
// @ts-ignore
isFollowed.value = await supabaseService.isShopFollowed(shopId, userId)
} catch(e) {
console.warn('isShopFollowed method not found')
}
}
}
const loadShopData = async (id: string) => {
console.log('Loading shop data for:', id)
const shop = await supabaseService.getShopByMerchantId(id)
if (shop != null) {
console.log('Shop loaded successfully:', shop.shop_name)
// 使用显式类型转换
const merchantData: MerchantType = {
id: shop.id,
user_id: shop.merchant_id,
shop_name: shop.shop_name,
shop_logo: shop.shop_logo != null ? shop.shop_logo : '/static/default-shop.png',
shop_banner: shop.shop_banner != null ? shop.shop_banner : '/static/default-banner.png',
shop_description: shop.description != null ? shop.description : '',
contact_name: shop.contact_name != null ? shop.contact_name : '',
contact_phone: shop.contact_phone != null ? shop.contact_phone : '',
shop_status: 1,
rating: shop.rating_avg != null ? shop.rating_avg : 5.0,
total_sales: shop.total_sales != null ? shop.total_sales : 0,
created_at: shop.created_at != null ? shop.created_at : ''
}
merchant.value = merchantData
// 检查关注状态
checkFollowStatus(shop.id)
} else {
console.warn('Shop data is null for ID:', id)
uni.showToast({
title: '未找到店铺信息',
icon: 'none',
duration: 3000
})
}
}
const loadCoupons = async (id: string) => {
try {
// @ts-ignore
const res = await supabaseService.fetchShopCoupons(id)
if (res != null && Array.isArray(res)) {
const couponList: CouponType[] = []
for (let i = 0; i < res.length; i++) {
const item = res[i]
const itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
couponList.push({
id: itemObj.getString('id') ?? '',
discount_value: itemObj.getNumber('discount_value') ?? 0,
min_order_amount: itemObj.getNumber('min_order_amount') ?? 0,
name: itemObj.getString('name') ?? '',
start_time: itemObj.getString('start_time') ?? '',
end_time: itemObj.getString('end_time') ?? '',
status: itemObj.getNumber('status') ?? 1
} as CouponType)
}
coupons.value = couponList
}
} catch(e1) {
console.warn('SupabaseService.fetchShopCoupons method missing. Please rebuild project.')
}
}
const loadShopProducts = async (id: string) => {
if (isLoading.value) return
isLoading.value = true
// 保存当前使用的MerchantID供下拉/触底使用
if (currentPage.value === 1) {
currentMerchantId.value = id
}
console.log(`shop-detail loadShopProducts for: ${id} page: ${currentPage.value}`)
let res: any = {}
try {
// @ts-ignore
res = await supabaseService.getProductsByMerchantId(id, currentPage.value, pageSize.value)
} catch(e) {
console.error('getProductsByMerchantId missing or error', e)
isLoading.value = false
uni.stopPullDownRefresh()
return
}
console.log(`shop-detail getProductsByMerchantId result count: ${res.data?.length}`)
const rawList = res.data
if (rawList != null && Array.isArray(rawList) && rawList.length > 0) {
// 过滤掉已经在列表中的重复商品 (防止分页计算错误导致的重复)
const newItems: ProductType[] = []
const existingIds = products.value.map(p => p.id)
const list = rawList.map((item: any): ProductType => {
// 解析图片数组
let images: string[] = []
// 转换为 UTSJSONObject 安全访问属性
const itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
// 1. 尝试 main_image_url
const mainImageUrl = itemObj.getString('main_image_url')
if (mainImageUrl != null && mainImageUrl != '') {
images.push(mainImageUrl)
}
// 2. 尝试 image_urls (如果 main 为空,或者需要展示多图)
const imageUrls = itemObj.get('image_urls')
if (imageUrls != null) {
try {
if (Array.isArray(imageUrls)) {
const arr = imageUrls as string[]
if (arr.length > 0) {
if (images.length == 0) images.push(...arr)
}
} else if (typeof imageUrls === 'string') {
const rawUrl = imageUrls as string
if (rawUrl.startsWith('[')) {
const parsed = JSON.parse(rawUrl)
if (Array.isArray(parsed)) {
const arr = parsed as string[]
if (images.length == 0) images.push(...arr)
}
} else {
if (images.indexOf(rawUrl) === -1) images.push(rawUrl)
}
}
} catch(e) {
console.error('解析图片数组失败:', e)
}
}
// 没有任何图片则使用默认
if (images.length === 0) {
images.push('/static/default-product.png')
}
return {
id: itemObj.getString('id') ?? '',
merchant_id: itemObj.getString('merchant_id') ?? '',
category_id: itemObj.getString('category_id') ?? '',
name: itemObj.getString('name') ?? '未知商品',
description: itemObj.getString('description') ?? '',
images: images,
price: itemObj.getNumber('base_price') ?? 0,
original_price: itemObj.getNumber('market_price') ?? 0,
stock: itemObj.getNumber('total_stock') ?? 0,
sales: itemObj.getNumber('sale_count') ?? 0,
status: 1,
created_at: itemObj.getString('created_at') ?? ''
} as ProductType
})
// 只有在 currentPage > 1 时才需要过滤currentPage = 1 时直接替换
if (currentPage.value === 1) {
products.value = list
} else {
for (let i = 0; i < list.length; i++) {
if (existingIds.indexOf(list[i].id) === -1) {
newItems.push(list[i])
}
}
if (newItems.length > 0) {
products.value.push(...newItems)
}
}
currentPage.value++
hasMore.value = list.length >= pageSize.value
} else {
hasMore.value = false
}
isLoading.value = false
uni.stopPullDownRefresh()
}
onMounted(() => {
const pages = getCurrentPages()
const options = pages[pages.length - 1].options as UTSJSONObject
// Search传递的是 id (shop_id), 其他地方可能传递 merchantId
const mId = options.get('merchantId')
const pId = options.get('id')
const paramId = (mId != null ? mId : pId) as string
if (paramId != null && paramId != '') {
console.log('Page mounted with params:', paramId)
// 优先加载店铺信息
loadShopData(paramId).then(() => {
// 加载成功后,使用确定的 merchant_id 来查询关联数据 (商品/优惠券通常是关联在 merchant_id 上的)
const realMerchantId = merchant.value.user_id // 这里 user_id 映射了 DB 中的 merchant_id
if (realMerchantId != null && realMerchantId != '') {
console.log('Chain loading products for Corrected Merchant ID:', realMerchantId)
currentMerchantId.value = realMerchantId // 更新当前上下文ID
loadShopProducts(realMerchantId)
loadCoupons(realMerchantId)
} else {
// 防御性策略:如果没能获取 merchant_id尝试用传入 ID
console.warn('Shop load failed or id empty, fallback using original id:', paramId)
currentMerchantId.value = paramId
loadShopProducts(paramId)
loadCoupons(paramId)
}
})
} else {
console.error('No ID passed to shop-detail')
uni.showToast({title: '参数错误', icon: 'error'})
}
})
const onRefresherRefresh = () => {
isRefresherTriggered.value = true
currentPage.value = 1
hasMore.value = true
isLoading.value = false
if (currentMerchantId.value != '') {
const id = currentMerchantId.value
Promise.all([
loadShopData(id),
loadCoupons(id),
loadShopProducts(id)
]).then(() => {
isRefresherTriggered.value = false
})
} else {
setTimeout(() => {
isRefresherTriggered.value = false
}, 500)
}
}
const onScrollToLower = () => {
if (hasMore.value && !isLoading.value && currentMerchantId.value != '') {
console.log('Scroll to lower, loading more...')
loadShopProducts(currentMerchantId.value)
}
}
onPullDownRefresh(() => {
onRefresherRefresh()
})
onReachBottom(() => {
onScrollToLower()
})
const claimCoupon = async (coupon: any) => {
const userId = supabaseService.getCurrentUserId()
if (userId == null) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
uni.showLoading({ title: '领取中' })
// 转换为 UTSJSONObject 安全访问属性
const couponObj = JSON.parse(JSON.stringify(coupon)) as UTSJSONObject
const couponId = couponObj.getString('id') ?? ''
let success = false
try {
// @ts-ignore
success = await supabaseService.claimShopCoupon(couponId, userId)
} catch(e1) {
try {
// @ts-ignore
success = await supabaseService.claimCoupon(couponId, userId)
} catch(e2) {
console.warn('claimCoupon not found')
}
}
uni.hideLoading()
if (success) {
uni.showToast({ title: '领取成功', icon: 'success' })
} else {
uni.showToast({ title: '领取失败', icon: 'none' })
}
}
const toggleFollow = async () => {
const userId = supabaseService.getCurrentUserId()
if (userId == null) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
// 这里的 merchant.value.id 假如是 ML_SHOPS.id
const shopId = merchant.value.id
if (shopId == null || shopId == '') return
uni.showLoading({ title: '处理中' })
// @ts-ignore
if (isFollowed.value) {
// 取消关注
// @ts-ignore
const success = await supabaseService.unfollowShop(shopId, userId)
if (success) {
isFollowed.value = false
uni.showToast({ title: '已取消关注', icon: 'none' })
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} else {
// 关注
// @ts-ignore
const success = await supabaseService.followShop(shopId, userId)
if (success) {
isFollowed.value = true
uni.showToast({ title: '关注成功', icon: 'success' })
} else {
uni.showToast({ title: '关注失败', icon: 'none' })
}
}
uni.hideLoading()
}
const contactService = () => {
const currentUser = supabaseService.getCurrentUserId()
if (currentUser == null) {
uni.navigateTo({ url: '/pages/user/login' })
return
}
if (merchant.value.user_id != null && merchant.value.user_id != '') {
uni.navigateTo({
url: `/pages/mall/consumer/chat?merchantId=${merchant.value.user_id}&merchantName=${encodeURIComponent(merchant.value.shop_name)}`
})
} else {
uni.showToast({ title: '无法联系商家', icon: 'none'})
}
}
const addToCart = async (product: ProductType) => {
uni.showLoading({ title: '检查商品...' })
try {
// 使用店铺的 merchant_id
const merchantId = merchant.value.user_id ?? ''
// 检查商品是否有SKU
const skus = await supabaseService.getProductSkus(product.id)
uni.hideLoading()
if (skus.length > 0) {
// 有规格,提示并跳转到商品详情页选择规格
uni.showToast({
title: '请选择规格',
icon: 'none'
})
setTimeout(() => {
uni.navigateTo({
url: '/pages/mall/consumer/product-detail?id=' + product.id
})
}, 500)
} else {
// 无规格,直接加入购物车
uni.showLoading({ title: '添加中...' })
const success = await supabaseService.addToCart(product.id, 1, '', merchantId)
uni.hideLoading()
if (success) {
uni.showToast({
title: '已添加到购物车',
icon: 'success'
})
} else {
uni.showToast({
title: '添加失败,请重试',
icon: 'none'
})
}
}
} catch (e) {
console.error('添加到购物车异常', e)
uni.hideLoading()
uni.showToast({
title: '操作失败',
icon: 'none'
})
}
}
const goToProduct = (id: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?productId=${id}`
})
}
</script>
<style>
.shop-detail-page {
background-color: #f5f5f5;
flex: 1;
display: flex;
flex-direction: column;
}
.page-scroll {
flex: 1;
height: 0;
width: 100%;
}
.shop-header {
background-color: #fff;
padding-bottom: 20px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
}
.shop-banner {
width: 100%;
height: 150px;
background-color: #eee;
}
.shop-info-card {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 0 15px;
margin-top: -30px;
position: relative;
z-index: 1;
width: 100%;
box-sizing: border-box;
}
.shop-logo {
width: 60px;
height: 60px;
border-radius: 8px;
border: 2px solid #fff;
background-color: #fff;
margin-right: 12px;
flex-shrink: 0;
}
.shop-basic-info {
flex: 1;
display: flex;
flex-direction: column;
padding-top: 35px;
}
.shop-name {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 6px;
line-height: 1.2;
}
.shop-stats {
display: flex;
flex-direction: row;
align-items: center;
}
.stat-item {
font-size: 11px;
color: #666;
margin-right: 8px;
background-color: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
}
.shop-actions {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 40px;
flex-shrink: 0;
}
.action-btn {
border-radius: 17px;
margin-left: 8px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 4px 12px;
cursor: pointer;
}
.action-text {
font-size: 12px;
}
.chat-btn {
background-color: #ffffff;
border: 1px solid #ddd;
}
.chat-btn .action-text {
color: #333;
}
.follow-btn {
background-color: #ff5000;
border: 1px solid #ff5000;
min-width: 60px;
}
.follow-btn .action-text {
color: #ffffff;
}
.follow-btn .followed {
opacity: 0.9;
}
.shop-desc {
color: #999;
padding: 10px 15px 0;
line-height: 1.5;
width: 100%;
box-sizing: border-box;
font-size: 13px;
}
/* PC 端响应式覆盖 */
@media screen and (min-width: 1025px) {
.shop-header {
align-items: center;
}
.shop-banner {
height: 300px;
max-width: 1200px;
}
.shop-info-card {
max-width: 1200px;
margin-top: -40px;
}
.shop-logo {
width: 100px;
height: 100px;
margin-right: 20px;
}
.shop-basic-info {
padding-top: 45px;
}
.shop-name {
font-size: 24px;
margin-bottom: 12px;
}
.shop-stats .stat-item {
font-size: 14px;
padding: 6px 15px;
margin-right: 15px;
}
.shop-actions {
padding-top: 50px;
}
.action-btn {
padding: 8px 24px;
margin-left: 15px;
border-radius: 20px;
}
.action-text {
font-size: 14px;
}
.shop-desc {
max-width: 1200px;
font-size: 14px;
padding: 15px 15px;
}
}
/* Coupon Styles */
.shop-coupons {
margin-top: 20px;
padding: 0 15px;
width: 100%;
max-width: 1200px;
box-sizing: border-box;
}
.coupon-scroll {
width: 100%;
white-space: nowrap;
flex-direction: row;
}
.coupon-wrapper {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
}
.coupon-card {
display: flex;
flex-direction: row;
background-color: #fff5f5;
border: 1px solid #ffccc7;
border-radius: 4px;
margin-right: 15px;
width: 180px; /* PC 端优惠券加宽 */
height: 70px;
overflow: hidden;
flex-shrink: 0;
cursor: pointer;
}
.coupon-left {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-right: 1px dashed #ffccc7;
padding: 0 10px;
}
.coupon-amount {
color: #ff5000;
font-weight: bold;
font-size: 20px;
}
.coupon-cond {
color: #999;
font-size: 12px;
}
.coupon-right {
width: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: #ff5000;
flex-direction: column;
}
.coupon-btn-label {
color: #fff;
font-size: 14px;
text-align: center;
line-height: 1.2;
}
.product-section {
padding: 20px 0;
width: 100%;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.results-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background-color: #fff;
margin-bottom: 2px;
width: 100%;
box-sizing: border-box;
}
.results-title {
font-size: 15px;
font-weight: bold;
color: #333;
padding-left: 10px;
border-left: 5px solid #ff5000;
}
.filter-tabs {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
}
.filter-tab {
font-size: 13px;
color: #666;
padding: 8px 12px;
border-radius: 20px;
border: 1px solid #e0e0e0;
transition: all 0.2s ease;
white-space: nowrap;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
margin-left: 8px;
}
.filter-tab.active {
background: #ff5000;
color: white;
border-color: #ff5000;
}
.results-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
padding: 10px;
width: 100%;
box-sizing: border-box;
margin-top: 5px;
background-color: #fff;
}
.result-item {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
overflow: hidden;
width: 48%;
margin-bottom: 12px;
margin-right: 2%;
border: 1px solid #f0f0f0;
box-sizing: border-box;
}
.result-item:nth-child(2n) {
margin-right: 0;
}
.product-image {
width: 100%;
height: 170px;
object-fit: cover;
background-color: #f5f5f5;
border-radius: 8px;
margin-bottom: 8px;
}
.product-name {
font-size: 13px;
color: #333;
margin-bottom: 5px;
line-height: 1.4;
height: 36px;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 8px;
}
.product-bottom {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 8px 8px;
}
.product-price {
font-size: 15px;
color: #ff5000;
font-weight: bold;
}
.product-add-btn {
width: 24px;
height: 24px;
background-color: #ff5000;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.add-icon {
color: #fff;
font-size: 16px;
font-weight: bold;
}
/* 电脑端响应式覆盖 */
@media screen and (min-width: 1025px) {
.product-section {
max-width: 95%;
width: 1200px;
margin: 0 auto;
}
.result-item {
width: 23%;
margin-right: 2%;
}
.result-item:nth-child(2n) {
margin-right: 2%;
}
.result-item:nth-child(4n) {
margin-right: 0;
}
}
/* 大桌面端 (1400px以上) */
@media screen and (min-width: 1400px) {
.result-item {
width: 23%;
}
}
.shop-banner {
width: 100%;
height: 200px;
background-color: #f5f5f5;
}
@media screen and (min-width: 1025px) {
.shop-banner {
height: 300px; /* 大屏加宽 Banner */
border-radius: 0 0 20px 20px;
}
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<view class="followed-shops-page">
<view class="header">
<text class="header-title">我关注的店铺</text>
</view>
<view class="shop-list" v-if="shops.length > 0">
<view class="shop-item" v-for="shop in shops" :key="shop.id" @click="goToShop(shop)">
<image :src="shop.shop_logo != null ? shop.shop_logo : '/static/default-shop.png'" class="shop-logo" mode="aspectFill" />
<view class="shop-info">
<text class="shop-name">{{ shop.shop_name }}</text>
<text class="shop-desc">{{ shop.description != null ? shop.description : '暂无介绍' }}</text>
<view class="shop-meta">
<text class="rating shop-meta-text">⭐ {{ shop.rating_avg }}</text>
<text class="sales shop-meta-text">销量: {{ shop.total_sales }}</text>
</view>
</view>
<button class="unfollow-btn" @click.stop="unfollow(shop)">已关注</button>
</view>
</view>
<view v-else-if="loading == false" class="empty-state">
<text class="empty-text">暂无关注的店铺</text>
<button class="go-shop-btn" @click="goHome">去逛逛</button>
</view>
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type FollowedShop = {
id: string
merchant_id: string
shop_name: string
shop_logo: string | null
description: string | null
rating_avg: number
total_sales: number
}
const shops = ref<Array<FollowedShop>>([])
const loading = ref<boolean>(true)
const loadFollowedShops = async (): Promise<void> => {
loading.value = true
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId == '') {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
const res = await supabaseService.getFollowedShops(userId)
const list: Array<FollowedShop> = []
if (res != null && Array.isArray(res)) {
for (let i: number = 0; i < res.length; i++) {
const item = res[i] as UTSJSONObject
const shopDataRaw = item.get('ml_shops')
if (shopDataRaw != null) {
const shopData = shopDataRaw as UTSJSONObject
const shop: FollowedShop = {
id: shopData.getString('id') ?? '',
merchant_id: shopData.getString('merchant_id') ?? '',
shop_name: shopData.getString('shop_name') ?? '',
shop_logo: shopData.getString('shop_logo'),
description: shopData.getString('description'),
rating_avg: shopData.getNumber('rating_avg') ?? 5.0,
total_sales: shopData.getNumber('total_sales') ?? 0
} as FollowedShop
list.push(shop)
}
}
}
shops.value = list
loading.value = false
}
const doUnfollow = async (shopId: string, userId: string): Promise<void> => {
const success = await supabaseService.unfollowShop(shopId, userId)
if (success) {
uni.showToast({ title: '已取消', icon: 'none' })
loadFollowedShops()
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const unfollow = async (shop: FollowedShop): Promise<void> => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId == '') return
uni.showModal({
title: '提示',
content: '确定取消关注该店铺吗?',
success: (res) => {
if (res.confirm) {
doUnfollow(shop.id, userId)
}
}
})
}
const goToShop = (shop: FollowedShop): void => {
const targetId = shop.merchant_id != '' ? shop.merchant_id : shop.id
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?merchantId=${targetId}`
})
}
const goHome = (): void => {
uni.switchTab({ url: '/pages/main/index' })
}
onMounted(() => {
loadFollowedShops()
})
</script>
<style>
.followed-shops-page {
padding: 15px;
background-color: #f5f5f5;
flex: 1;
}
.header {
margin-bottom: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
}
.shop-list {
display: flex;
flex-direction: column;
}
.shop-item {
background-color: #fff;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 10px;
}
.shop-item:last-child {
margin-bottom: 0;
}
.shop-logo {
width: 50px;
height: 50px;
border-radius: 4px;
background-color: #eee;
margin-right: 12px;
}
.shop-info {
flex: 1;
display: flex;
flex-direction: column;
}
.shop-name {
font-size: 16px;
font-weight: bold;
color: #333;
}
.shop-desc {
font-size: 12px;
color: #999;
margin-top: 4px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shop-meta {
font-size: 10px;
color: #999;
margin-top: 4px;
display: flex;
}
.shop-meta-text {
margin-right: 8px;
}
.unfollow-btn {
font-size: 12px;
padding: 4px 12px;
background-color: #eee;
color: #666;
border-radius: 20px;
margin-left: 10px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 100px;
}
.empty-text {
color: #999;
margin-bottom: 20px;
}
.go-shop-btn {
background-color: #ff4444;
color: white;
padding: 8px 24px;
border-radius: 20px;
font-size: 14px;
}
.loading-state {
text-align: center;
padding-top: 50px;
color: #999;
}
</style>

View File

@@ -0,0 +1,158 @@
<template>
<view class="my-subs">
<view class="header">
<text class="title">我的订阅</text>
<button class="ghost" @click="goPlanList">订阅更多</button>
</view>
<view v-if="loading" class="loading">加载中...</view>
<view v-else-if="items.length === 0" class="empty">暂无订阅</view>
<view v-else class="list">
<view class="card" v-for="s in items" :key="s['id']">
<view class="row between">
<text class="name">{{ s['plan']?.['name'] != null ? s['plan']?.['name'] : '订阅' }}</text>
<text class="status" :class="'st-' + (s['status'] != null ? s['status'] : 'active')">{{ statusText(s['status'] as string) }}</text>
</view>
<view class="row">
<text class="label">周期</text>
<text class="value">{{ (s['plan']?.['billing_period'] === 'yearly') ? '年付' : '月付' }}</text>
</view>
<view class="row">
<text class="label">价格</text>
<text class="value">¥{{ s['plan']?.['price'] }}</text>
</view>
<view class="row">
<text class="label">开始</text>
<text class="value">{{ fmt(s['start_date'] as string) }}</text>
</view>
<view class="row">
<text class="label">下次扣费</text>
<text class="value">{{ fmt(s['next_billing_date'] as string) }}</text>
</view>
<view class="actions">
<label class="toggle">
<switch :checked="!!s['auto_renew']" @change="e => toggleAutoRenew(s, e.detail.value as boolean)" />
<text class="toggle-text">自动续费</text>
</label>
<button class="danger" @click="cancelAtPeriodEnd(s)" :disabled="(s['status'] as string) !== 'active'">到期取消</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supaClient from '@/components/supadb/aksupainstance.uts'
import { getCurrentUserId } from '@/utils/store.uts'
const loading = ref<boolean>(true)
const items = ref<Array<UTSJSONObject>>([])
const fmt = (s: string | null): string => {
if (s == null || s.length === 0) return '-'
const d = new Date(s)
if (isNaN(d.getTime())) return '-'
return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2,'0')}-${d.getDate().toString().padStart(2,'0')}`
}
const statusText = (st: string): string => {
const map: UTSJSONObject = { trial: '试用', active: '生效', past_due: '逾期', canceled: '已取消', expired: '已过期' } as UTSJSONObject
const val = map[st] as string | null
return val != null ? val : st
}
const loadSubs = async () => {
try {
loading.value = true
const userId = getCurrentUserId()
if (userId == null || userId.length === 0) {
items.value = []
return
}
// join: ml_user_subscriptions + ml_subscription_plans
const res = await supaClient
.from('ml_user_subscriptions')
.select('*, plan:ml_subscription_plans(*)', {})
.eq('user_id', userId)
.order('created_at', { ascending: false })
.execute()
items.value = Array.isArray(res.data) ? (res.data as Array<UTSJSONObject>) : []
} catch (e) {
console.error('加载订阅失败:', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const toggleAutoRenew = async (s: UTSJSONObject, value: boolean) => {
try {
const id = (s['id'] ?? '') as string
const res = await supaClient
.from('ml_user_subscriptions')
.update({ auto_renew: value })
.eq('id', id)
.execute()
if (res.error != null) throw new Error(res.error?.message ?? '未知错误')
s['auto_renew'] = value
uni.showToast({ title: value ? '已开启自动续费' : '已关闭自动续费', icon: 'success' })
} catch (e) {
console.error('更新自动续费失败:', e)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const cancelAtPeriodEnd = async (s: UTSJSONObject) => {
try {
const id = (s['id'] ?? '') as string
const res = await supaClient
.from('ml_user_subscriptions')
.update({ cancel_at_period_end: true })
.eq('id', id)
.execute()
if (res.error != null) throw new Error(res.error?.message ?? '未知错误')
s['cancel_at_period_end'] = true
s['status'] = 'active' // 保持到期前仍为active
uni.showToast({ title: '已设置到期取消', icon: 'success' })
} catch (e) {
console.error('设置到期取消失败:', e)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const goPlanList = () => {
uni.navigateTo({ url: '/pages/mall/consumer/subscription/plan-list' })
}
onMounted(loadSubs)
// 注意uni-app x 的 <script setup> 中不支持 onShow使用 onMounted 代替
// 如果需要页面显示时刷新,可以在页面选项中定义 onShow
</script>
<style scoped>
.my-subs { padding: 12px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.title { font-size: 18px; font-weight: 700; }
.ghost { background: #fff; border: 1px solid #ddd; color: #333; border-radius: 6px; padding: 6px 10px; }
.loading, .empty { padding: 24px; text-align: center; color: #888; }
.list { display: flex; flex-direction: column; }
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin-bottom: 12px; }
.card:last-child { margin-bottom: 0; }
.row { display: flex; padding: 4px 0; }
.label { margin-right: 8px; }
.between { justify-content: space-between; align-items: center; }
.name { font-size: 16px; font-weight: 700; }
.status { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: #eee; color: #333; }
.st-trial { background: #e6f7ff; color: #1677ff; }
.st-active { background: #f6ffed; color: #52c41a; }
.st-past_due { background: #fff7e6; color: #fa8c16; }
.st-canceled, .st-expired { background: #fff1f0; color: #f5222d; }
.label { color: #666; width: 80px; }
.value { color: #111; flex: 1; }
.actions { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; }
.toggle { display: flex; align-items: center; }
.toggle-text { margin-right: 6px; }
.danger { background: #f5222d; color: #fff; border-radius: 6px; padding: 6px 10px; }
</style>

View File

@@ -0,0 +1,114 @@
<template>
<view class="plan-detail">
<view class="header">
<text class="title">订阅方案详情</text>
</view>
<view v-if="loading" class="loading">加载中...</view>
<view v-else-if="plan == null" class="empty">未找到该方案</view>
<view v-else class="card">
<text class="name">{{ plan['name'] }}</text>
<text class="desc">{{ plan['description'] != null && (plan['description'] as string).length > 0 ? plan['description'] : '—' }}</text>
<view class="price-row">
<text class="price">¥{{ plan['price'] }}</text>
<text class="period">/{{ plan['billing_period'] === 'yearly' ? '年' : '月' }}</text>
</view>
<view class="features">
<text class="f-title">包含功能</text>
<view class="f-list">
<text class="f-item" v-for="(v,k) in toFeatureArray(plan['features'])" :key="k">• {{ v }}</text>
</view>
</view>
<view class="actions">
<button class="primary" @click="toCheckout">订阅此方案</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supaClient from '@/components/supadb/aksupainstance.uts'
const planId = ref<string>('')
const loading = ref<boolean>(true)
const plan = ref<UTSJSONObject | null>(null)
onLoad((opts: OnLoadOptions) => {
planId.value = (opts['id'] ?? '') as string
})
const toFeatureArray = (features: any): Array<string> => {
const arr: Array<string> = []
if (features == null) return arr
if (features instanceof UTSJSONObject) {
const featureMap = (features as UTSJSONObject).toMap()
const entries = featureMap.entries()
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const v = entry.value
const vs = typeof v === 'string' ? v : JSON.stringify(v)
arr.push(vs)
}
}
return arr
}
const loadPlan = async () => {
try {
loading.value = true
if (planId.value.length === 0) return
const res = await supaClient
.from('ml_subscription_plans')
.select('*', {})
.eq('id', planId.value)
.single()
.execute()
if (res != null && res.error == null) {
// single() 风格有些客户端会返回对象数组,这里兼容
if (Array.isArray(res.data)) {
plan.value = (res.data as Array<UTSJSONObject>)[0] ?? null
} else {
plan.value = res.data as UTSJSONObject
}
} else {
plan.value = null
}
} catch (e) {
console.error('加载方案失败:', e)
plan.value = null
} finally {
loading.value = false
}
}
const toCheckout = () => {
if (plan.value == null) return
const id = (plan.value['id'] ?? '') as string
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
}
onMounted(loadPlan)
</script>
<style scoped>
.plan-detail { padding: 12px; }
.header { margin-bottom: 8px; }
.title { font-size: 18px; font-weight: 700; }
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.name { font-size: 16px; font-weight: 700; }
.desc { color: #666; margin: 6px 0; }
.price-row { display: flex; align-items: flex-end; margin: 8px 0; }
.price { font-size: 22px; color: #ff4d4f; font-weight: 700; margin-right: 4px; }
.period { color: #999; }
.features { margin-top: 8px; }
.f-title { font-weight: 700; margin-bottom: 4px; }
.f-list { display: flex; flex-direction: column; color: #444; }
.f-item { margin-bottom: 2px; }
.actions { display: flex; justify-content: flex-end; margin-top: 12px; }
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
.loading, .empty { padding: 24px; text-align: center; color: #888; }
</style>

View File

@@ -0,0 +1,112 @@
<template>
<view class="sub-plan-list">
<view class="header">
<text class="title">软件订阅</text>
</view>
<view class="plan-container" v-if="!loading && plans.length > 0">
<view class="plan-card" v-for="p in plans" :key="p['id']" @click="goPlanDetail(p)">
<view class="plan-header">
<text class="plan-name">{{ p['name'] }}</text>
<text v-if="p['billing_period'] === 'yearly'" class="badge">年付优惠</text>
</view>
<text class="plan-desc">{{ p['description'] != null && (p['description'] as string).length > 0 ? p['description'] : '适用于大部分使用场景' }}</text>
<view class="price-row">
<text class="price">¥{{ p['price'] }}</text>
<text class="period">/{{ p['billing_period'] === 'yearly' ? '年' : '月' }}</text>
</view>
<view class="feature-list">
<text class="feature-item" v-for="(v,k) in toFeatureArray(p['features'])" :key="k">• {{ v }}</text>
</view>
<view class="actions">
<button class="primary" @click.stop="toCheckout(p)">立即订阅</button>
</view>
</view>
</view>
<view v-if="!loading && plans.length === 0" class="empty">
<text>暂无可用订阅方案</text>
</view>
<view v-if="loading" class="loading"><text>加载中...</text></view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supaClient from '@/components/supadb/aksupainstance.uts'
const loading = ref<boolean>(true)
const plans = ref<Array<UTSJSONObject>>([])
const toFeatureArray = (features: any): Array<string> => {
const arr: Array<string> = []
if (features == null) return arr
if (features instanceof UTSJSONObject) {
const featureMap = (features as UTSJSONObject).toMap()
const entries = featureMap.entries()
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const v = entry.value
const vs = typeof v === 'string' ? v : JSON.stringify(v)
arr.push(vs)
}
}
return arr
}
const loadPlans = async () => {
try {
loading.value = true
const res = await supaClient
.from('ml_subscription_plans')
.select('*', {})
.eq('is_active', true)
.order('sort_order', { ascending: true })
.execute()
if (Array.isArray(res.data)) {
plans.value = res.data as Array<UTSJSONObject>
} else {
plans.value = []
}
} catch (e) {
console.error('加载订阅方案失败:', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const goPlanDetail = (p: UTSJSONObject) => {
const id = (p['id'] ?? '') as string
uni.navigateTo({ url: `/pages/mall/consumer/subscription/plan-detail?id=${id}` })
}
const toCheckout = (p: UTSJSONObject) => {
const id = (p['id'] ?? '') as string
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
}
onMounted(loadPlans)
</script>
<style scoped>
.sub-plan-list { padding: 12px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.title { font-size: 18px; font-weight: 700; }
.plan-container { display: flex; flex-direction: column; }
.plan-card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin-bottom: 12px; }
.plan-card:last-child { margin-bottom: 0; }
.plan-header { display: flex; align-items: center; justify-content: space-between; }
.plan-name { font-size: 16px; font-weight: 700; color: #333; }
.badge { font-size: 12px; color: #fff; background: #3cc51f; border-radius: 999px; padding: 2px 8px; }
.plan-desc { color: #666; margin: 6px 0; line-height: 1.5; }
.price-row { display: flex; align-items: flex-end; margin: 6px 0; }
.price { font-size: 22px; color: #ff4d4f; font-weight: 700; margin-right: 4px; }
.period { color: #999; }
.feature-list { color: #444; display: flex; flex-direction: column; margin: 6px 0; }
.feature-item { font-size: 12px; color: #555; margin-bottom: 2px; }
.actions { display: flex; justify-content: flex-end; margin-top: 8px; }
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
.loading, .empty { padding: 24px; text-align: center; color: #888; }
</style>

View File

@@ -0,0 +1,171 @@
<template>
<view class="subscribe-checkout">
<view class="header">
<text class="title">确认订阅</text>
</view>
<view v-if="loading" class="loading">加载中...</view>
<view v-else-if="plan == null" class="empty">未找到订阅方案</view>
<view v-else class="card">
<view class="row">
<text class="label">方案</text>
<text class="value">{{ plan['name'] }}</text>
</view>
<view class="row">
<text class="label">价格</text>
<text class="value">¥{{ plan['price'] }} / {{ plan['billing_period'] === 'yearly' ? '年' : '月' }}</text>
</view>
<view class="row" v-if="trialDays > 0">
<text class="label">试用期</text>
<text class="value">{{ trialDays }} 天</text>
</view>
<view class="section-title">支付方式</view>
<view class="pay-methods">
<label class="pay-item" @click="selPay(1)">
<radio :checked="payMethod === 1"></radio>
<text>微信支付</text>
</label>
<label class="pay-item" @click="selPay(2)">
<radio :checked="payMethod === 2"></radio>
<text>支付宝</text>
</label>
<label class="pay-item" @click="selPay(4)">
<radio :checked="payMethod === 4"></radio>
<text>余额</text>
</label>
</view>
<view class="actions">
<button class="primary" :disabled="submitting" @click="confirmSubscribe">确认并支付</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supaClient from '@/components/supadb/aksupainstance.uts'
import { PAYMENT_METHOD } from '@/types/mall-types.uts'
const planId = ref<string>('')
const loading = ref<boolean>(true)
const plan = ref<UTSJSONObject | null>(null)
const payMethod = ref<number>(PAYMENT_METHOD.WECHAT)
const trialDays = ref<number>(0)
const submitting = ref<boolean>(false)
onLoad(async (opts: OnLoadOptions) => {
planId.value = (opts['planId'] ?? '') as string
await loadPlan()
})
const loadPlan = async () => {
try {
loading.value = true
const res = await supaClient
.from('ml_subscription_plans')
.select('*', {})
.eq('id', planId.value)
.single()
.execute()
if (res != null && res.error == null) {
if (Array.isArray(res.data)) {
plan.value = (res.data as Array<UTSJSONObject>)[0] ?? null
} else {
plan.value = res.data as UTSJSONObject
}
trialDays.value = (plan.value?.['trial_days'] ?? 0) as number
} else {
plan.value = null
}
} catch (e) {
console.error('加载方案失败:', e)
} finally {
loading.value = false
}
}
const selPay = (v: number) => { payMethod.value = v }
// 获取当前用户ID按现有store实现替换
const getCurrentUserId = (): string => {
try {
const session = supaClient.getSession()
return (session != null && session.user != null) ? (session.user.getString('id') ?? '') : ''
} catch (e) {
return ''
}
}
const confirmSubscribe = async () => {
if (plan.value == null) return
const userId = getCurrentUserId()
if (userId.length === 0) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
submitting.value = true
try {
// 1) 创建订单或支付意图(此处简化为直接创建订阅记录)
const now = new Date()
const start = now.toISOString()
// 简单计算下个扣费日
let nextBilling: string | null = null
if ((plan.value?.['billing_period'] ?? 'monthly') === 'yearly') {
nextBilling = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()).toISOString()
} else {
nextBilling = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()).toISOString()
}
const body = {
user_id: userId,
plan_id: plan.value['id'],
status: 'active',
start_date: start,
end_date: null,
next_billing_date: nextBilling,
auto_renew: true,
metadata: { pay_method: payMethod.value }
}
const ins = await supaClient
.from('ml_user_subscriptions')
.insert(body)
.single?.()
.execute()
if (ins != null && ins.error == null) {
uni.showToast({ title: '订阅成功', icon: 'success' })
setTimeout(() => {
uni.redirectTo({ url: '/pages/main/profile' })
}, 600)
} else {
uni.showToast({ title: ins?.error?.message ?? '订阅失败', icon: 'none' })
}
} catch (e) {
console.error('订阅失败:', e)
uni.showToast({ title: '订阅失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.subscribe-checkout { padding: 12px; }
.header { margin-bottom: 8px; }
.title { font-size: 18px; font-weight: 700; }
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.row:last-child { border-bottom: none; }
.label { color: #666; }
.value { color: #111; font-weight: 700; }
.section-title { margin-top: 12px; font-weight: 700; }
.pay-methods { display: flex; flex-direction: column; padding: 8px 0; }
.pay-item { display: flex; align-items: center; margin-bottom: 8px; }
.pay-item:last-child { margin-bottom: 0; }
.pay-icon { margin-right: 8px; }
.actions { display: flex; justify-content: flex-end; margin-top: 12px; }
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
.loading, .empty { padding: 24px; text-align: center; color: #888; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,341 @@
<template>
<view class="page-container">
<view class="card">
<view class="section-title">提现至</view>
<view class="bank-selector" @click="openBankSelector">
<view class="bank-info" v-if="selectedBank != null">
<text class="bank-name">{{ selectedBank.bank_name }}</text>
<text class="card-type">储蓄卡</text>
<text class="card-no">尾号 {{ getTailNumber(selectedBank.card_number) }}</text>
</view>
<view class="bank-info placeholder" v-else>
<text>请选择到账银行卡</text>
</view>
<text class="arrow">></text>
</view>
<view class="amount-section">
<text class="label">提现金额</text>
<view class="input-wrapper">
<text class="currency">¥</text>
<input
class="amount-input"
type="digit"
v-model="amount"
placeholder="请输入提现金额"
/>
</view>
<view class="balance-line">
<text class="balance-text">当前可提现余额 ¥{{ balance }}</text>
<text class="all-btn" @click="setAll">全部提现</text>
</view>
</view>
<button
class="submit-btn"
:disabled="isValid === false"
:loading="loading"
@click="submitWithdraw"
>
{{ loading ? '处理中...' : '确认提现' }}
</button>
</view>
<!-- 简单弹窗选择银行卡 -->
<view v-if="showBankPopup" class="popup-mask" @click="showBankPopup = false">
<view class="popup-content" @click.stop>
<view class="popup-header">
<text class="popup-title">选择到账银行卡</text>
<text class="close-btn" @click="showBankPopup = false">×</text>
</view>
<scroll-view scroll-y="true" class="bank-list">
<view
v-for="(item, index) in bankCards"
:key="index"
class="bank-item"
@click="selectBank(item)"
>
<view class="bank-row">
<text class="bank-name-popup">{{ item.bank_name }}</text>
<text class="card-no-popup">({{ getTailNumber(item.card_number) }})</text>
</view>
<text v-if="selectedBank != null && selectedBank.id == item.id" class="check">✓</text>
</view>
<view class="add-card-btn" @click="navigateToAddCard">
<text>+ 添加银行卡</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type BankCard = {
id: string
bank_name: string
card_number: string
}
const amount = ref('')
const balance = ref(0.00)
const loading = ref(false)
const bankCards = ref<BankCard[]>([])
const selectedBank = ref<BankCard | null>(null)
const showBankPopup = ref(false)
const isValid = computed((): boolean => {
const val = parseFloat(amount.value)
// 检查 val 是否有效(替代 isNaN
if (val == null || val <= 0) return false
if (val > balance.value) return false
if (selectedBank.value == null) return false
return true
})
const loadData = async (): Promise<void> => {
try {
const bal = await supabaseService.getUserBalanceNumber()
balance.value = bal
const res = await supabaseService.getUserBankCards()
const list: Array<BankCard> = []
for(let i: number = 0; i < res.length; i++) {
const item = res[i]
let id = ''
let bankName = ''
let cardNum = ''
if (item instanceof UTSJSONObject) {
id = item.getString('id') ?? ''
bankName = item.getString('bank_name') ?? ''
cardNum = item.getString('card_number') ?? ''
} else {
const itemObj = item as UTSJSONObject
id = itemObj.getString('id') ?? ''
bankName = itemObj.getString('bank_name') ?? ''
cardNum = itemObj.getString('card_number') ?? ''
}
if (id != '') {
const card: BankCard = {
id: id,
bank_name: bankName,
card_number: cardNum
} as BankCard
list.push(card)
}
}
bankCards.value = list
if (bankCards.value.length > 0) {
selectedBank.value = bankCards.value[0]
}
} catch (e) {
console.error(e)
}
}
onMounted(() => {
loadData()
})
const getTailNumber = (cardNo: string | null): string => {
if (cardNo == null) return ''
if (cardNo.length <= 4) return cardNo
return cardNo.substring(cardNo.length - 4)
}
const setAll = () => {
amount.value = balance.value.toString()
}
const openBankSelector = () => {
showBankPopup.value = true
}
const selectBank = (bank: BankCard) => {
selectedBank.value = bank
showBankPopup.value = false
}
const navigateToAddCard = () => {
uni.navigateTo({
url: '/pages/mall/consumer/bank-cards/add'
})
showBankPopup.value = false
}
const submitWithdraw = async () => {
if (isValid.value === false) return
loading.value = true
try {
const val = parseFloat(amount.value)
const success = await supabaseService.withdrawBalance(val)
if (success) {
uni.showToast({
title: '提现申请已提交',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({
title: '提现失败, ' + (val > balance.value ? '余额不足' : '请重试'),
icon: 'none'
})
}
} catch (e) {
uni.showToast({
title: '系统异常',
icon: 'none'
})
} finally {
loading.value = false
}
}
</script>
<style scoped>
.page-container {
background-color: #f5f5f5;
flex: 1; /* Fixed 100vh issue */
padding: 20px;
}
.card {
background-color: #fff;
border-radius: 12px;
padding: 20px;
}
.section-title {
font-size: 16px;
color: #333;
margin-bottom: 15px;
}
.bank-selector {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #eee;
}
.bank-info {
display: flex;
align-items: center;
/* gap removed */
}
.bank-name {
margin-right: 10px;
font-weight: bold;
}
.card-type {
margin-right: 10px;
}
.placeholder {
color: #999;
}
.amount-section {
margin-top: 20px;
}
.label {
font-size: 14px;
color: #666;
margin-bottom: 10px;
/* display: block removed */
}
.input-wrapper {
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
margin-bottom: 10px;
}
.currency {
font-size: 30px;
font-weight: bold;
margin-right: 10px;
}
.amount-input {
flex: 1;
font-size: 30px;
font-weight: bold;
height: 40px;
}
.balance-line {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.balance-text {
color: #999;
}
.all-btn {
color: #5785e5;
}
.submit-btn {
margin-top: 40px;
background-color: #5785e5;
color: #fff;
border-radius: 25px;
}
.submit-btn:disabled {
background-color: #ccc;
}
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0,0,0,0.5);
z-index: 999;
display: flex;
justify-content: center;
align-items: flex-end;
}
.popup-content {
background-color: #fff;
width: 100%;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
padding: 20px;
min-height: 300px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.popup-title {
font-size: 16px;
font-weight: bold;
}
.close-btn {
font-size: 20px;
color: #999;
padding: 5px;
}
.bank-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
}
.add-card-btn {
padding: 15px 0;
text-align: center;
color: #5785e5;
font-weight: bold;
}
</style>