完成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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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